diff --git a/apps/api/src/services/drift_narrator_service.py b/apps/api/src/services/drift_narrator_service.py index 18a8a162..1806f398 100644 --- a/apps/api/src/services/drift_narrator_service.py +++ b/apps/api/src/services/drift_narrator_service.py @@ -204,27 +204,66 @@ class DriftNarratorService: _raw = text.strip() if _raw.startswith("```"): _raw = _raw.strip("`").lstrip("json").strip() + + # 解析策略: 3 路 fallback + # Path 1: 直接我們的 {narrative, items} 結構 (純 Ollama 或 LLM 守規矩) + # Path 2: NEMOTRON wrapper {description,...} 且 description 內含我們的結構 + # Path 3: NEMOTRON wrapper,description 是純敘述 → 用它當 narrative + Python fallback items + _parsed_narrative = None + _parsed_items = None try: _parsed = _json.loads(_raw) if isinstance(_parsed, dict): - _narrative = str(_parsed.get("narrative", "")).strip() - _items = _parsed.get("items", []) - if isinstance(_items, list) and _narrative: - clean_items = [] - for it in _items[:5]: - if isinstance(it, dict) and it.get("field") and it.get("summary"): - clean_items.append({ - "level": it.get("level", "medium"), - "field": str(it["field"])[:60], - "summary": str(it["summary"])[:80], - }) - if clean_items: - narrative = _narrative - items = clean_items - status = "success" - llm_accepted = True + # Path 1 + if "narrative" in _parsed and isinstance(_parsed.get("items"), list): + _parsed_narrative = str(_parsed["narrative"]).strip() + _parsed_items = _parsed["items"] + else: + # Path 2 / Path 3: NEMOTRON wrapper + _desc = ( + _parsed.get("description") + or _parsed.get("action_title") + or _parsed.get("reasoning") + or "" + ) + _desc = str(_desc).strip() + # Path 2: description 本身是巢狀 JSON 含我們結構? + if _desc.startswith("{") and "narrative" in _desc: + try: + _inner = _json.loads(_desc) + if isinstance(_inner, dict) and "narrative" in _inner: + _parsed_narrative = str(_inner.get("narrative", "")).strip() + _parsed_items = _inner.get("items", []) if isinstance(_inner.get("items"), list) else None + except (_json.JSONDecodeError, ValueError): + pass + # Path 3: 只有 narrative(來自 description),items 用 Python fallback + if _parsed_narrative is None and _desc: + _parsed_narrative = _desc + _parsed_items = None # 觸發下方 fallback_items except (_json.JSONDecodeError, ValueError) as e: - logger.warning("drift_narrator_json_parse_fail", err=str(e), raw_prefix=_raw[:80]) + logger.warning("drift_narrator_json_parse_fail", err=str(e), + raw_prefix=_raw[:100], provider=provider) + + # 驗證 + 清洗 + if _parsed_narrative: + # 清洗 items (若 LLM 有給) + clean_items = [] + if isinstance(_parsed_items, list): + for it in _parsed_items[:5]: + if isinstance(it, dict) and it.get("field") and it.get("summary"): + clean_items.append({ + "level": it.get("level", "medium"), + "field": str(it["field"])[:60], + "summary": str(it["summary"])[:80], + }) + # items 空就用 Python smart fallback (不是 str()[:30]) + if not clean_items: + clean_items = self._fallback_items(report) + + narrative = _parsed_narrative + items = clean_items + status = "success" + llm_accepted = True if not llm_accepted: logger.warning("drift_narrator_openclaw_failed", provider=provider) @@ -314,6 +353,8 @@ class DriftNarratorService: parent_op_id = parent_row.scalar() if parent_row else None # 寫 automation_operation_log + # 2026-04-18 hotfix: tags 要傳 Python list(不是 PG array literal 字串) + # 否則 asyncpg 會報 "a sized iterable container expected" op_row = await db.execute( _sql(""" INSERT INTO automation_operation_log ( @@ -327,7 +368,7 @@ class DriftNarratorService: CAST(:input AS jsonb), CAST(:output AS jsonb), :duration_ms, :parent_op_id, - CAST(:tags AS text[]) + :tags ) RETURNING op_id """), @@ -337,7 +378,7 @@ class DriftNarratorService: "output": output_json, "duration_ms": duration_ms, "parent_op_id": parent_op_id, - "tags": "{drift,type4d,llm_summary}", + "tags": ["drift", "type4d", "llm_summary"], }, ) op_id = op_row.scalar()