From 1606093dd2db5e8657a3a04c9f1918e878cad7d5 Mon Sep 17 00:00:00 2001 From: OG T Date: Sat, 18 Apr 2026 16:26:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(drift-narrator):=20=E5=85=A9=E5=80=8B=20hot?= =?UTF-8?q?fix=20=E2=80=94=20NEMOTRON=20wrapper=20=E8=A7=A3=E6=9E=90=20+?= =?UTF-8?q?=20tags=20asyncpg=20=E5=9E=8B=E5=88=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-04-18 下午(台北時區)—— ogt + Claude Opus 4.7 (1M) Live-fire test (report_id=80a34b58) 暴露兩個 bug: ## Bug 1: LLM JSON 被 NEMOTRON wrapper 吞掉 根因: openclaw.call() 經 NEMOTRON 路由時強制回 {description,...} 結構, 我的 prompt 要 {narrative, items} 無法穿透。 (同 1ff3405 早前碰過的 JSON 裸奔問題根源) 修復: 三路 fallback 解析 - Path 1: 直接我們的 {narrative, items}(Ollama 或 LLM 守規矩) - Path 2: NEMOTRON wrapper,description 巢狀 JSON 含我們結構 - Path 3: description 是純敘述 → 當 narrative + Python fallback_items ## Bug 2: tags 參數 asyncpg DataError 根因: 傳 '{drift,type4d,llm_summary}' 字面量字串,asyncpg 要求 Python list '(a sized iterable container expected (got type str))' 修復: tags 改傳 ['drift','type4d','llm_summary'] Python list,移除 CAST AS text[] asyncpg 自動推斷 text[] Live-fire 結果驗證: - narrative ✅ 生成(fallback path) - items ⚠️ 只 1 筆(NEMOTRON 未吐我們結構) - DB write ❌ tags 型別錯 - Telegram ✅ 送出(雖 fallback 內容但視覺 OK) 本 commit 後預期: - LLM 回應走 Path 2/3 → narrative + Python fallback items(5 筆 smart summary) - DB write 成功 → automation_operation_log + ai_collaboration_trace 皆有記錄 - 若 LLM 未來學會走 Path 1(給我們 {narrative, items}),自動升級 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/services/drift_narrator_service.py | 79 ++++++++++++++----- 1 file changed, 60 insertions(+), 19 deletions(-) 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()