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