fix(drift-narrator): 兩個 hotfix — NEMOTRON wrapper 解析 + tags asyncpg 型別
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-18 16:26:16 +08:00
parent e7bd37a5ac
commit 1606093dd2

View File

@@ -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()