fix(drift-narrator): 兩個 hotfix — NEMOTRON wrapper 解析 + tags asyncpg 型別
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user