From 9a96e9500b138ed91ecf075f8b6c7001270725c6 Mon Sep 17 00:00:00 2001 From: OoO Date: Sun, 24 May 2026 22:49:46 +0800 Subject: [PATCH] Render price decision envelopes directly --- TODO_NEXT_STEPS.txt | 1 + config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 4 +- .../current_execution_queue_20260524.md | 3 + docs/memory/history_logs.md | 2 + .../audit_competitor_match_attempt_rescore.py | 36 +++- .../competitor_match_attempt_rescore_audit.py | 182 ++++++++++++++++++ services/competitor_price_feeder.py | 2 + services/event_router.py | 64 +++++- services/telegram_templates.py | 18 ++ ..._competitor_match_attempt_rescore_audit.py | 69 +++++++ tests/test_event_router.py | 74 +++++++ tests/test_telegram_triaged_alert_format.py | 8 + 13 files changed, 459 insertions(+), 6 deletions(-) diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index a24e9f5..c1898f0 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.455 讓 EventRouter 對 `decision_envelope` 事件走直送證據模板:NemoTron / 價格比對已產生 SKU、PChome 候選、match evidence 與 HITL guardrails 時,不再進 L1/L2 AI 重新摘要,避免額外模型呼叫與告警文字二次發散;Telegram 決策信封同步補「標的」區塊,顯示 SKU、商品與 PChome 候選。同版補 `audit_competitor_match_attempt_rescore.py --retract-variant-accepted`,可把最新仍帶 `variant_selection_review` 的 `rescore_accepted_current` 批次追加退回 `true_low_confidence`,且不寫正式價差表。 - V10.454 補 feeder / rescore 正式寫入安全閘門:matcher 若只到 `manual_review` / `identity_review` / `variant_selection_review`,例如 MOMO 多款任選唇膏對 PChome 單一款式,只能進 `true_low_confidence` 覆核,不得由 retryable replay、known identity refresh 或 rescore accepted 語意自動寫入 `competitor_prices` 正式價差。 - V10.453 補 PChome matcher 安全回收規則:新增 Herbacin 小甘菊護手霜 20ml brandless 同款 anchor;修正 `EX8` 型號不可被誤解析成 `x8` 入數;新增 GONESH / 香氛固體凝膠的一側泛稱、一側明確香味或 No. 款式 veto,避免近門檻 replay 把不同香味、不同入數商品錯寫成正式價差。 - V10.452 修正 PChome rescore audit 掃描口徑:`audit_competitor_match_attempt_rescore.py` 預設先取每個 SKU 最新 attempt,再套用 status / reason 篩選,和 Dashboard review queue 的最新狀態一致;舊 SKU/候選考古掃描需明確加 `--include-historical-candidates`,避免已修正或已入隊商品被舊低信心紀錄重複推回報表。 diff --git a/config.py b/config.py index f867467..b588ea7 100644 --- a/config.py +++ b/config.py @@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.454" +SYSTEM_VERSION = "V10.455" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index e57598d..1ce7c7f 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-05-24 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 備援預設關閉 -> **適用版本**: V10.454 +> **適用版本**: V10.455 --- @@ -46,6 +46,7 @@ - NemoTron `price_alert` / `human_review` 派發會把同款證據、價差、七日銷量變化、營收流失、HITL 邊界與資料品質寫入同一份 `decision_envelope`,並同步放入 EventRouter event 與 KM metadata;12 Agent 後續只能沿用此信封補充分析,不得繞過 matcher / feeder / review service 直接改價或覆寫比價資料。 - EventRouter / Telegram 的 HITL callback 必須優先使用 `decision_envelope.decision_id` 作為事件追蹤 ID;若上游未帶 `event.id`,`triaged_alert()` 仍會用 `decision_id` 產生 `momo:eig:*` callback,避免價格決策審核落成 `unknown`。所有 `momo:eig:*` callback 必須以 UTF-8 byte-safe 截斷,確保 `callback_data` 不超過 Telegram 64-byte 限制。 - 競品比價相關的 Agent 建議只能讀 `competitor_match_attempts` / review queue / `competitor_prices` 的既有證據;不得直接寫 `competitor_prices` 或覆蓋 `_should_upsert_competitor_price()` 的保護規則。 +- 已帶 `decision_envelope` 的價格/覆核事件必須由 EventRouter 直接渲染證據模板,不再進 L1/L2 AI 重新摘要;Telegram 決策信封需顯示標的 SKU、商品名稱、PChome 候選、evidence、guardrails 與 HITL 動作,避免已有實證的比價告警被二次生成文字稀釋或造成額外模型成本。 ## 一、四 AI Agent 路由架構 @@ -85,6 +86,7 @@ SQL漏斗(~300筆) - PChome 低信心操作分流:Dashboard 與 read-only `/api/pchome-review/queue` 必須把近門檻可救、證據不足、低信心舊候選拆成 `recoverable_low_score`、`true_low_confidence`、`legacy_low_score` 三個可篩選桶;廣義 `low_score` 僅作 repository/export 相容查詢,不可在 UI 中冒充單一操作分流。 - PChome re-score audit 預設必須先取每個 SKU 的最新 `competitor_match_attempts` 狀態,再套用 status / reason 篩選;舊低信心歷史候選只能透過 `--include-historical-candidates` 明確進入考古掃描,避免已入隊、已否決或已修正 SKU 被舊紀錄重新推回報表。 - production re-score `--apply-accepted` 僅可追加 `rescore_accepted_current` attempt 給人工覆核;執行後需清除 Dashboard / competitor intel cache,且必須抽查 `competitor_prices` / `competitor_price_history` 未新增正式價差。 +- production re-score 若曾把 `variant_selection_review` 追加成 `rescore_accepted_current`,必須用 `audit_competitor_match_attempt_rescore.py --retract-variant-accepted` 追加最新 `true_low_confidence` 退回列;此路徑只寫 `competitor_match_attempts`,不得刪歷史紀錄,也不得寫 `competitor_prices` / `competitor_price_history`。 - PChome matcher replay 必須先守住假陽性:`EX8` 等型號不可被誤解析成 `x8` 入數;香氛固體凝膠 / 空氣芳香劑若一側為泛稱、一側含明確香味或 No. 款式,必須走 `aroma_scent_variant_conflict` veto,不得因同品牌同重量直接寫正式價差。 - PChome feeder 正式寫入必須再套一層價格資料閘門:只有 `match_type='exact'`、`price_basis='total_price'`、`alert_tier='price_alert_exact'` 且無 `variant_selection_review` 的結果可以自動寫入 `competitor_prices`;`manual_review` / `identity_review` 只能留在覆核隊列或人工採用流程,不得由 retryable replay 或 known identity refresh 自動升成正式價差。Rescore audit 若遇到 `variant_selection_review`,也不得產生 `accepted_current`。 diff --git a/docs/memory/current_execution_queue_20260524.md b/docs/memory/current_execution_queue_20260524.md index eaee4b0..40cfc3e 100644 --- a/docs/memory/current_execution_queue_20260524.md +++ b/docs/memory/current_execution_queue_20260524.md @@ -10,6 +10,7 @@ - 每次上線只 recreate `momo-app`、`scheduler`、`telegram-bot`,禁止使用 `--remove-orphans`,禁止影響 `momo-db`。 - 2026-05-24 21:33 CST 狀態:`main` 已推 Gitea 並部署到 188,正式 `/health` 為 `V10.451`。本輪只 recreate `momo-app`;`scheduler`、`telegram-bot` 未重建但保持 healthy;未使用 `--remove-orphans`,未碰 `momo-db`。Smoke 通過:主要頁面 HTTP 200、三個 app 容器 healthy、`/api/pchome-review/queue` 可用於 `recoverable_low_score` / `legacy_low_score` read-only 查詢,且 10 分鐘錯誤 log 未見 Traceback / ERROR。 - 2026-05-24 22:17 CST 狀態:`main` 已推 Gitea 並部署到 188,正式 `/health` 為 `V10.453`。本輪 recreate `momo-app`、`scheduler`、`telegram-bot`;未使用 `--remove-orphans`,未碰 `momo-db`。Smoke 通過:三個 app 容器 healthy、Gemini hard disabled 且 24 小時 `ai_calls` 無 Gemini provider、Ollama 順序維持 GCP-A → GCP-B → 111、`/api/pchome-review/queue` 三個 status 查詢成功、rescore audit read-only `selection_mode=latest_sku_only`。 +- 2026-05-24 22:44 CST 狀態:V10.455 補 EventRouter `decision_envelope` 直送路徑;帶完整價格證據的 NemoTron / 比價事件不再進 L1/L2 AI 重新摘要,Telegram 信封顯示標的 SKU 與 PChome 候選。待部署後回填正式 `/health` 與 smoke 結果。 ## 1. MOMO / PChome 核心比價準確率 @@ -23,6 +24,7 @@ - 2026-05-24 22:20 CST 起,matcher replay 先套用 V10.453 安全修正:`EX8` 型號不視為 `x8` 入數,香氛固體凝膠一側泛稱、一側具體香味/No. 款式走 veto;Herbacin 小甘菊護手霜 20ml brandless 可作窄範圍安全回收。 - 2026-05-24 22:42 CST 起,feeder / rescore audit 套用 V10.454 安全閘門:`identity_review` / `manual_review` / `variant_selection_review` 的近門檻候選只能留在覆核,不能由 replay、refresh 或 `accepted_current` 入隊語意自動寫正式 PChome 價差。 - 2026-05-24 22:48 CST 已執行 production rescore 入隊:745 筆 `true_low_confidence` 中先有 2 筆通過舊 gate;V10.454 gate 補上 `variant_selection_review` 排除後,SKU `8884618` KATE 多款任選唇膏已退回最新 `true_low_confidence`,最終只保留 SKU `10922465` Herbacin 小甘菊護手霜 20ml 為 `rescore_accepted_current` 人工覆核 attempt;正式價格表未寫入,Dashboard / competitor intel cache 已清除。 +- 2026-05-24 22:44 CST 起,rescore audit 補 `--retract-variant-accepted` 工具化退回路徑;若最新 `rescore_accepted_current` 仍帶 `variant_selection_review`,只追加 `true_low_confidence` attempt,不刪歷史、不寫正式價格表。 - 只新增窄範圍、可解釋 matcher 規則。 - 保留 `MIN_MATCH_SCORE`、`identity_veto`、既有正式候選覆寫保護。 - 驗收:`matched` 有增加、目標 `low_score` 下降、`needs_review` 不異常上升、無明顯跨色號/跨款式/跨劑型錯配。 @@ -37,6 +39,7 @@ ## 3. 12 Agent 決策信封整合 - `decision_envelope` 已接到 NemoTron 價格告警與人工覆核,下一步要讓 OpenClaw、ElephantAlpha、PPT QA 與 review queue 共用同一份 evidence contract。 +- 2026-05-24 22:44 CST 起,EventRouter 對已附 `decision_envelope` 的事件直接渲染證據模板,不呼叫 L1/L2 AI handler;這讓 NemoTron 價格告警、人工覆核與後續 Agent 共用同一份 SKU / PChome / evidence / guardrails,不再二次生成摘要。 - 告警不得再輸出空泛「預期效益」;必須帶資料品質、證據來源、HITL 邊界與 trace id。 - Agent 建議只能輔助排序與分析,不得繞過 matcher / feeder / review service 寫正式價格。 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 78e0bd4..bb38282 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,8 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-24:PChome 近門檻身份回收第二輪 +- **V10.455 EventRouter 決策信封直送**: 已帶 `decision_envelope` 的價格/覆核事件會略過 L1/L2 AI 重新摘要,直接用 Telegram 證據模板通知;決策信封新增標的區塊,顯示 SKU、商品名稱、PChome 候選 ID/名稱,避免 NemoTron 已有實證的價格告警被二次生成文字稀釋或產生額外模型呼叫。 +- **V10.455 rescore variant retraction CLI**: `audit_competitor_match_attempt_rescore.py --retract-variant-accepted` 可找出最新仍為 `rescore_accepted_current` 且帶 `variant_selection_review` 的 SKU,追加 `true_low_confidence` 退回列;保留歷史 audit trail,不刪資料、不寫正式價格表。 - **V10.454 production rescore 入人工覆核隊列**: 以 latest-sku-only 口徑重算 745 筆 `true_low_confidence`,先追加 2 筆人工覆核列;V10.454 gate 補上 `variant_selection_review` 排除後,SKU `8884618` KATE 怪獸級持色唇膏(MOMO 多款任選 vs PChome 單一水光款)已退回最新 `true_low_confidence`,最終只保留 SKU `10922465` Herbacin 小甘菊護手霜 20ml 為 `rescore_accepted_current`。這次只寫 `competitor_match_attempts` 人工覆核列,未寫 `competitor_prices` / `competitor_price_history`,並已清除 Dashboard 與 competitor intel cache。 - **V10.454 feeder / rescore 正式寫入閘門**: `CompetitorPriceFeeder` 現在只允許 `exact + total_price + price_alert_exact` 的 matcher 結果自動寫入 `competitor_prices`;`manual_review`、`identity_review`、`variant_selection_review`(例如 MOMO 多款任選唇膏對 PChome 單一水光款)會保留在 `true_low_confidence` 覆核,不得因分數剛過門檻而污染正式比價資料。Rescore audit 也會把 `variant_selection_review` 擋在 `accepted_current` 之外。 - **V10.453 matcher 安全回收規則**: 新增 Herbacin 小甘菊護手霜 20ml brandless 同款 anchor;修正 `EX8` 型號不再被誤解析為 `x8` 入數;新增香氛固體凝膠 / 空氣芳香劑一側泛稱、一側明確香味或 No. 款式的 `aroma_scent_variant_conflict` veto。這輪目標是讓 retryable replay 可救回真同款,同時先封住 MIRAE 入數與 GONESH 香味款式的假陽性。 diff --git a/scripts/audit_competitor_match_attempt_rescore.py b/scripts/audit_competitor_match_attempt_rescore.py index cdccd83..86ceb13 100755 --- a/scripts/audit_competitor_match_attempt_rescore.py +++ b/scripts/audit_competitor_match_attempt_rescore.py @@ -20,7 +20,9 @@ from services.competitor_match_attempt_rescore_audit import ( # noqa: E402 DEFAULT_RESCAN_STATUSES, build_match_attempt_rescore_audit, fetch_match_attempt_rescore_rows, + fetch_variant_rescore_accept_review_rows, materialize_rescore_accept_reviews, + retract_variant_rescore_accept_reviews, summarize_match_attempt_rescore, ) from services.competitor_price_feeder import MIN_MATCH_SCORE # noqa: E402 @@ -74,12 +76,20 @@ def main(argv: list[str] | None = None) -> int: action="store_true", help="Append accepted-current rows to competitor_match_attempts for manual review; never writes competitor_prices.", ) + parser.add_argument( + "--retract-variant-accepted", + action="store_true", + help=( + "Append low-confidence retraction rows for latest rescore_accepted_current " + "attempts that contain variant_selection_review; never writes competitor_prices." + ), + ) args = parser.parse_args(argv) statuses = tuple(args.statuses or DEFAULT_RESCAN_STATUSES) if args.input: - if args.apply_accepted: - parser.error("--apply-accepted requires DB mode; do not combine it with --input.") + if args.apply_accepted or args.retract_variant_accepted: + parser.error("--apply-accepted/--retract-variant-accepted require DB mode; do not combine them with --input.") rows = [row for row in _read_jsonl(args.input) if not row.get("_invalid_json")] summary = summarize_match_attempt_rescore( rows, @@ -90,7 +100,27 @@ def main(argv: list[str] | None = None) -> int: from config import DATABASE_PATH engine = create_engine(DATABASE_PATH) - if args.apply_accepted: + if args.apply_accepted and args.retract_variant_accepted: + parser.error("Choose only one write mode: --apply-accepted or --retract-variant-accepted.") + if args.retract_variant_accepted: + with engine.begin() as conn: + rows = fetch_variant_rescore_accept_review_rows( + conn, + source=args.source, + limit=args.limit, + ) + summary = { + "selection_mode": "latest_sku_only", + "scanned": len(rows), + "rows": rows[: max(0, args.sample_limit)], + "retraction": retract_variant_rescore_accept_reviews( + conn, + rows, + source=args.source, + min_score=args.min_score, + ), + } + elif args.apply_accepted: with engine.begin() as conn: rows = fetch_match_attempt_rescore_rows( conn, diff --git a/services/competitor_match_attempt_rescore_audit.py b/services/competitor_match_attempt_rescore_audit.py index 7bf1c95..1c4cec2 100644 --- a/services/competitor_match_attempt_rescore_audit.py +++ b/services/competitor_match_attempt_rescore_audit.py @@ -22,6 +22,7 @@ DEFAULT_RESCAN_STATUSES = ( "refresh_low_score", ) RESCORE_ACCEPTED_CURRENT_STATUS = "rescore_accepted_current" +RETRACTED_VARIANT_REVIEW_STATUS = "true_low_confidence" @dataclass(frozen=True) @@ -208,6 +209,20 @@ def _diagnostic_text(decision: MatchAttemptRescoreDecision) -> str: ) +def _retraction_diagnostic_text(decision: MatchAttemptRescoreDecision) -> str: + reasons = ",".join(decision.reasons or []) + return ( + "rescore_retracted_variant_selection_review; " + "matcher_rescore=retracted_variant_selection_review; " + f"stored_status={decision.stored_status}; " + f"stored_score={decision.stored_score}; " + f"current_score={decision.current_score}; " + f"mode={decision.comparison_mode}; " + f"price_basis={decision.price_basis}; " + f"reasons={reasons}" + ) + + def _ensure_attempt_table(conn) -> None: from services.competitor_price_feeder import CompetitorPriceFeeder @@ -326,6 +341,173 @@ def materialize_rescore_accept_reviews( return stats +def fetch_variant_rescore_accept_review_rows( + conn, + *, + source: str = "pchome", + limit: int = 100, +) -> list[dict[str, Any]]: + """Fetch latest rescore-accepted rows that should be retracted to review.""" + reason_predicate = ( + "diagnostic_codes::text LIKE :reason_filter" + if conn.dialect.name == "postgresql" + else "CAST(diagnostic_codes AS TEXT) LIKE :reason_filter" + ) + nulls_last = " NULLS LAST" if conn.dialect.name == "postgresql" else "" + sql = text(f""" + WITH ranked AS ( + SELECT + sku, + attempt_status, + momo_product_id, + momo_product_name, + momo_price, + candidate_count, + best_competitor_product_id, + best_competitor_product_name, + best_competitor_price, + best_match_score, + competitor_product_url, + competitor_image_url, + competitor_stock, + diagnostic_codes, + attempted_at, + ROW_NUMBER() OVER ( + PARTITION BY sku + ORDER BY attempted_at DESC{nulls_last}, id DESC + ) AS rn + FROM competitor_match_attempts + WHERE source = :source + ) + SELECT + sku, + attempt_status, + momo_product_id, + momo_product_name, + momo_price, + candidate_count, + best_competitor_product_id, + best_competitor_product_name, + best_competitor_price, + best_match_score, + competitor_product_url, + competitor_image_url, + competitor_stock, + diagnostic_codes, + attempted_at + FROM ranked + WHERE rn = 1 + AND attempt_status = :attempt_status + AND {reason_predicate} + ORDER BY attempted_at DESC{nulls_last} + LIMIT :limit + """) + return [ + dict(row) + for row in conn.execute(sql, { + "source": source, + "attempt_status": RESCORE_ACCEPTED_CURRENT_STATUS, + "reason_filter": "%variant_selection_review%", + "limit": max(1, int(limit)), + }).mappings().all() + ] + + +def retract_variant_rescore_accept_reviews( + conn, + rows: Iterable[dict[str, Any]] | None = None, + *, + source: str = "pchome", + limit: int = 100, + min_score: float = MIN_MATCH_SCORE, +) -> dict[str, Any]: + """Append low-confidence attempts for stale variant-review accepted rows.""" + _ensure_attempt_table(conn) + rows = list(rows) if rows is not None else fetch_variant_rescore_accept_review_rows( + conn, + source=source, + limit=limit, + ) + stats = { + "scanned": 0, + "retracted": 0, + "skipped_not_variant_review": 0, + "skipped_not_retractable": 0, + "skipped_missing_candidate": 0, + "samples": [], + } + search_terms_expr = _json_expr(conn, "search_terms") + diagnostic_json_expr = _json_expr(conn, "match_diagnostic_json") + diagnostic_codes_expr = _json_expr(conn, "diagnostic_codes") + + for row in rows: + stats["scanned"] += 1 + decision = classify_match_attempt_row(row, min_score=min_score) + if "variant_selection_review" not in set(decision.reasons): + stats["skipped_not_variant_review"] += 1 + continue + if decision.suggested_status != "low_score_current": + stats["skipped_not_retractable"] += 1 + continue + + candidate_id = str(row.get("best_competitor_product_id") or "").strip() + candidate_name = str(row.get("best_competitor_product_name") or "").strip() + candidate_price = _to_float(row.get("best_competitor_price")) + if not candidate_id or not candidate_name or not candidate_price or candidate_price <= 0: + stats["skipped_missing_candidate"] += 1 + continue + + diagnostic_payload = _diagnostic_payload(decision) + diagnostic_payload["rescore_retracted_from"] = RESCORE_ACCEPTED_CURRENT_STATUS + conn.execute(text(f""" + INSERT INTO competitor_match_attempts + (sku, source, momo_product_id, momo_product_name, momo_price, + search_terms, candidate_count, attempt_status, + best_competitor_product_id, best_competitor_product_name, + best_competitor_price, best_match_score, error_message, + attempted_at, competitor_product_url, competitor_image_url, + competitor_stock, match_diagnostic_json, comparison_mode, + hard_veto, diagnostic_codes) + VALUES + (:sku, :source, :momo_product_id, :momo_product_name, :momo_price, + {search_terms_expr}, :candidate_count, :attempt_status, + :best_id, :best_name, + :best_price, :best_score, :error_message, + CURRENT_TIMESTAMP, :competitor_product_url, :competitor_image_url, + :competitor_stock, {diagnostic_json_expr}, :comparison_mode, + :hard_veto, {diagnostic_codes_expr}) + """), { + "sku": decision.sku, + "source": source, + "momo_product_id": row.get("momo_product_id"), + "momo_product_name": decision.momo_product_name, + "momo_price": _to_float(row.get("momo_price")), + "search_terms": json.dumps([ + "matcher_rescore:retracted_variant_selection_review", + f"stored_status:{decision.stored_status}", + ], ensure_ascii=False), + "candidate_count": int(row.get("candidate_count") or 1), + "attempt_status": RETRACTED_VARIANT_REVIEW_STATUS, + "best_id": candidate_id, + "best_name": candidate_name[:300], + "best_price": candidate_price, + "best_score": decision.current_score, + "error_message": _retraction_diagnostic_text(decision)[:1000], + "competitor_product_url": row.get("competitor_product_url"), + "competitor_image_url": row.get("competitor_image_url"), + "competitor_stock": row.get("competitor_stock"), + "match_diagnostic_json": json.dumps(diagnostic_payload, ensure_ascii=False), + "comparison_mode": decision.comparison_mode, + "hard_veto": decision.hard_veto, + "diagnostic_codes": json.dumps(decision.reasons, ensure_ascii=False), + }) + stats["retracted"] += 1 + if len(stats["samples"]) < 10: + stats["samples"].append(decision.to_dict()) + + return stats + + def fetch_match_attempt_rescore_rows( conn, *, diff --git a/services/competitor_price_feeder.py b/services/competitor_price_feeder.py index 16a7dd6..8bb1b05 100644 --- a/services/competitor_price_feeder.py +++ b/services/competitor_price_feeder.py @@ -807,6 +807,7 @@ class CompetitorPriceFeeder: """)) self._ensure_table_columns(conn, "competitor_match_attempts", [ + ("search_terms", "JSONB" if conn.dialect.name == "postgresql" else "TEXT"), ("competitor_product_url", "TEXT"), ("competitor_image_url", "TEXT"), ("competitor_stock", "INTEGER"), @@ -815,6 +816,7 @@ class CompetitorPriceFeeder: ("hard_veto", "BOOLEAN"), ("diagnostic_codes", "JSONB" if conn.dialect.name == "postgresql" else "TEXT"), ("browse_diagnostic_json", "JSONB" if conn.dialect.name == "postgresql" else "TEXT"), + ("error_message", "TEXT"), ]) self._attempt_table_ready = True diff --git a/services/event_router.py b/services/event_router.py index 1194cc9..1022aee 100644 --- a/services/event_router.py +++ b/services/event_router.py @@ -111,6 +111,32 @@ async def _run_tier_handler(tier: str, event: Dict[str, Any], session_id: str) - } +def _decision_envelope_from_event(event: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Return an attached decision envelope from top-level or payload.""" + envelope = event.get("decision_envelope") or event.get("decision") + if isinstance(envelope, dict) and envelope: + return envelope + payload = event.get("payload") if isinstance(event.get("payload"), dict) else {} + envelope = payload.get("decision_envelope") or payload.get("decision") + if isinstance(envelope, dict) and envelope: + return envelope + return None + + +def _should_render_decision_direct(event: Dict[str, Any]) -> bool: + """Evidence-ready decision events should not be re-summarized by L1/L2 AI.""" + return _decision_envelope_from_event(event) is not None + + +def _direct_decision_result(event: Dict[str, Any]) -> Dict[str, Any]: + envelope = _decision_envelope_from_event(event) or {} + return { + "status": "decision_envelope_direct", + "summary": "已收到完整決策信封,略過 AI 重新摘要,直接以證據模板通知。", + "decision_envelope": envelope, + } + + def _event_key(event: Dict[str, Any]) -> str: return f"{event.get('source', 'unknown')}:{event.get('event_type', 'unknown')}" @@ -357,7 +383,10 @@ async def dispatch(event: Dict[str, Any], admin_chat_ids: Optional[list] = None) ) return response - result = await _run_tier_handler(tier, event, session_id) + if _should_render_decision_direct(event): + result = _direct_decision_result(event) + else: + result = await _run_tier_handler(tier, event, session_id) executed_actions = _execute_safe_actions(result, event) if executed_actions: result["executed_actions"] = executed_actions @@ -505,6 +534,39 @@ def _classify(event: Dict[str, Any]) -> str: def _build_telegram_message(event: Dict[str, Any], tier: str, result: Optional[Dict[str, Any]]) -> tuple[str, Optional[Dict[str, Any]]]: + decision_envelope = _decision_envelope_from_event(event) + if decision_envelope: + render_event = dict(event) + render_event["decision_envelope"] = decision_envelope + render_event["event_type"] = decision_envelope.get("decision_type") or event.get("event_type", "decision") + if not render_event.get("id") and decision_envelope.get("decision_id"): + render_event["id"] = decision_envelope.get("decision_id") + + subject = decision_envelope.get("subject") if isinstance(decision_envelope.get("subject"), dict) else {} + sku = str(subject.get("sku") or "").strip() + name = str(subject.get("name") or "").strip() + if sku or name: + render_event["summary"] = " · ".join(part for part in (f"SKU {sku}" if sku else "", name) if part) + + raw_source_agent = str(decision_envelope.get("source_agent") or event.get("source") or "Decision") + source_agent = { + "nemotron": "NemoTron", + "openclaw": "OpenClaw", + "elephant_alpha": "Elephant Alpha", + "hermes": "Hermes", + }.get(raw_source_agent.lower(), raw_source_agent.replace("_", " ").title()) + severity = str(decision_envelope.get("severity") or tier) + ai_summary = "" + if isinstance(result, dict): + ai_summary = str(result.get("summary") or "") + if not ai_summary: + ai_summary = str(decision_envelope.get("analysis") or "依決策信封進行人工覆核。") + return triaged_alert( + render_event, + tier_label=f"{source_agent} · {severity}", + ai_summary=ai_summary, + ) + if tier == "L0": title = event.get("title") or event.get("event_type", "system_event") summary = event.get("summary") or event.get("status") or "系統事件" diff --git a/services/telegram_templates.py b/services/telegram_templates.py index 5fa0648..9940647 100644 --- a/services/telegram_templates.py +++ b/services/telegram_templates.py @@ -742,6 +742,24 @@ def _format_decision_envelope(envelope: Dict[str, Any]) -> List[str]: if blocked_reason: lines.append(f"• 邊界:{blocked_reason}") + subject = envelope.get("subject") if isinstance(envelope.get("subject"), dict) else {} + if subject: + sku = escape(str(subject.get("sku") or "")) + name = escape(_short_text(subject.get("name") or "", 96)) + competitor_id = escape(str(subject.get("competitor_product_id") or "")) + competitor_name = escape(_short_text(subject.get("competitor_product_name") or "", 96)) + subject_lines = [] + if sku: + subject_lines.append(f"• SKU:{sku}") + if name: + subject_lines.append(f"• 商品:{name}") + if competitor_id: + subject_lines.append(f"• PChome:{competitor_id}") + if competitor_name: + subject_lines.append(f"• 候選:{competitor_name}") + if subject_lines: + lines += ["", "標的", *subject_lines] + evidence_items = envelope.get("evidence") if isinstance(envelope.get("evidence"), list) else [] if evidence_items: lines += ["", "證據"] diff --git a/tests/test_competitor_match_attempt_rescore_audit.py b/tests/test_competitor_match_attempt_rescore_audit.py index eadf34d..a4c554b 100644 --- a/tests/test_competitor_match_attempt_rescore_audit.py +++ b/tests/test_competitor_match_attempt_rescore_audit.py @@ -22,6 +22,7 @@ def _create_match_attempts_table(conn): competitor_image_url TEXT, competitor_stock TEXT, diagnostic_codes TEXT, + error_message TEXT, attempted_at TEXT ) """)) @@ -183,6 +184,74 @@ def test_match_attempt_rescore_materializes_accepted_current_for_manual_review() assert "matcher_rescore=accepted_current" in stored[0]["error_message"] +def test_match_attempt_rescore_retracts_variant_review_from_accepted_queue(): + from services.competitor_match_attempt_rescore_audit import ( + fetch_variant_rescore_accept_review_rows, + retract_variant_rescore_accept_reviews, + ) + + engine = create_engine("sqlite:///:memory:") + with engine.begin() as conn: + _create_match_attempts_table(conn) + conn.execute(text(""" + INSERT INTO competitor_match_attempts + (sku, source, attempt_status, momo_product_id, momo_product_name, + momo_price, candidate_count, best_competitor_product_id, + best_competitor_product_name, best_competitor_price, + best_match_score, diagnostic_codes, error_message, attempted_at) + VALUES + ( + '8884618', 'pchome', 'rescore_accepted_current', 8884618, + '【KATE 凱婷】怪獸級持色唇膏 水光款/經典款/微發色款(獨家技術持久不沾 高保濕)', + 420, 1, 'KATE-WATER', + '【KATE 凱婷】怪獸級持色唇膏(水光) 1.6g', + 350, 0.783, + '["variant_selection_review", "focused_exact_identity_kate_monster_lipstick_catalog"]', + 'matcher_rescore=accepted_current', '2026-05-24 14:24:17' + ), + ( + '10922465', 'pchome', 'rescore_accepted_current', 10922465, + '【Herbacin 德國小甘菊】小甘菊1號護手霜20ml', + 99, 1, 'HERBACIN-20ML', + '小甘菊經典護手霜20ml', + 89, 0.872, + '["focused_exact_identity_herbacin_classic_hand_cream_20ml_brandless"]', + 'matcher_rescore=accepted_current', '2026-05-24 14:24:17' + ) + """)) + + rows = fetch_variant_rescore_accept_review_rows(conn, limit=10) + stats = retract_variant_rescore_accept_reviews(conn, rows) + latest = conn.execute(text(""" + WITH ranked AS ( + SELECT sku, attempt_status, search_terms, error_message, + diagnostic_codes, attempted_at, + ROW_NUMBER() OVER ( + PARTITION BY sku + ORDER BY attempted_at DESC, id DESC + ) AS rn + FROM competitor_match_attempts + WHERE source = 'pchome' + ) + SELECT * + FROM ranked + WHERE rn = 1 + ORDER BY sku + """)).mappings().all() + after_rows = fetch_variant_rescore_accept_review_rows(conn, limit=10) + + assert [row["sku"] for row in rows] == ["8884618"] + assert stats["retracted"] == 1 + latest_by_sku = {row["sku"]: row for row in latest} + assert latest_by_sku["8884618"]["attempt_status"] == "true_low_confidence" + assert "matcher_rescore:retracted_variant_selection_review" in json.loads( + latest_by_sku["8884618"]["search_terms"] + ) + assert "rescore_retracted_variant_selection_review" in latest_by_sku["8884618"]["error_message"] + assert latest_by_sku["10922465"]["attempt_status"] == "rescore_accepted_current" + assert after_rows == [] + + def test_match_attempt_rescore_materialize_does_not_enqueue_veto(): from services.competitor_match_attempt_rescore_audit import materialize_rescore_accept_reviews diff --git a/tests/test_event_router.py b/tests/test_event_router.py index 553be64..9c04a78 100644 --- a/tests/test_event_router.py +++ b/tests/test_event_router.py @@ -117,6 +117,80 @@ def test_dispatch_dedupes_repeated_event(monkeypatch): assert len(sent) == 1 +def test_dispatch_decision_envelope_skips_ai_handler(monkeypatch): + import services.event_router as event_router + + sent = [] + monkeypatch.setattr(event_router, "_is_event_silenced", lambda event: False) + + async def should_not_run(*args, **kwargs): + raise AssertionError("decision envelope event should not enter AI tier") + + monkeypatch.setattr(event_router, "_handle_l1", should_not_run) + monkeypatch.setattr(event_router, "_handle_l2", should_not_run) + monkeypatch.setattr( + event_router, + "send_telegram_with_result", + lambda message, **kwargs: sent.append((message, kwargs)) or { + "ok": True, + "sent": 1, + "failed": 0, + "chat_ids": [123], + "errors": [], + }, + ) + + envelope = { + "decision_id": "nemotron:price_alert:SKU-1:abcdef12", + "source_agent": "nemotron", + "decision_type": "price_alert", + "severity": "P1", + "confidence": 0.91, + "subject": { + "sku": "SKU-1", + "name": "測試精華液", + "competitor_product_id": "PC-1", + "competitor_product_name": "PChome 測試精華液", + }, + "evidence": [ + { + "type": "match", + "metric": "match_score", + "value": 0.93, + "basis": "exact/total_price/price_alert_exact", + "confidence": 0.93, + } + ], + "recommended_action": {"action": "price_follow_review", "owner": "營運", "requires_hitl": True}, + "guardrails": { + "can_auto_execute": False, + "data_quality": "complete", + "blocked_reason": "價格調整需人工覆核", + }, + } + + result = asyncio.run(event_router.dispatch({ + "source": "NemoTron.Dispatcher", + "event_type": "nemoton_dispatch_alert", + "severity": "alert", + "title": "NemoTron 派發器告警", + "summary": "legacy raw message should not become the decision brief", + "decision_envelope": envelope, + "payload": {"raw_message": "legacy raw message should not become the decision brief"}, + })) + + assert result["delivered"] is True + assert result["payload"]["status"] == "decision_envelope_direct" + assert len(sent) == 1 + message, kwargs = sent[0] + assert "🧭 決策信封" in message + assert "SKU:SKU-1" in message + assert "PChome:PC-1" in message + assert "exact/total_price/price_alert_exact" in message + assert "legacy raw message" not in message + assert kwargs["reply_markup"]["inline_keyboard"][0][0]["callback_data"].startswith("momo:eig:") + + def test_replay_failed_deliveries_removes_successful_records(tmp_path, monkeypatch): import services.event_router as event_router diff --git a/tests/test_telegram_triaged_alert_format.py b/tests/test_telegram_triaged_alert_format.py index bcf0672..1224d9f 100644 --- a/tests/test_telegram_triaged_alert_format.py +++ b/tests/test_telegram_triaged_alert_format.py @@ -137,6 +137,12 @@ def test_triaged_alert_renders_decision_envelope_contract(): "decision_type": "price_alert", "severity": "P1", "confidence": 0.86, + "subject": { + "sku": "SKU-1", + "name": "測試商品", + "competitor_product_id": "PC-1", + "competitor_product_name": "PChome 測試商品", + }, "evidence": [ { "type": "match", @@ -188,6 +194,8 @@ def test_triaged_alert_renders_decision_envelope_contract(): assert "資料品質:complete" in msg assert "自動執行:不允許" in msg assert "邊界:price adjustment requires HITL" in msg + assert "標的" in msg + assert "PChome" in msg assert "match_score / 91%" in msg assert "identity_v2 + price_alert_exact" in msg assert "動作:human_review" in msg