Render price decision envelopes directly
This commit is contained in:
@@ -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`,避免已修正或已入隊商品被舊低信心紀錄重複推回報表。
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -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 寫正式價格。
|
||||
|
||||
|
||||
@@ -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 香味款式的假陽性。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 "系統事件"
|
||||
|
||||
@@ -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:<code>{sku}</code>")
|
||||
if name:
|
||||
subject_lines.append(f"• 商品:{name}")
|
||||
if competitor_id:
|
||||
subject_lines.append(f"• PChome:<code>{competitor_id}</code>")
|
||||
if competitor_name:
|
||||
subject_lines.append(f"• 候選:{competitor_name}")
|
||||
if subject_lines:
|
||||
lines += ["", "<b>標的</b>", *subject_lines]
|
||||
|
||||
evidence_items = envelope.get("evidence") if isinstance(envelope.get("evidence"), list) else []
|
||||
if evidence_items:
|
||||
lines += ["", "<b>證據</b>"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 "🧭 <b>決策信封</b>" in message
|
||||
assert "SKU:<code>SKU-1</code>" in message
|
||||
assert "PChome:<code>PC-1</code>" 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
|
||||
|
||||
|
||||
@@ -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 "資料品質:<code>complete</code>" in msg
|
||||
assert "自動執行:<b>不允許</b>" in msg
|
||||
assert "邊界:price adjustment requires HITL" in msg
|
||||
assert "<b>標的</b>" in msg
|
||||
assert "PChome" in msg
|
||||
assert "<code>match_score</code> / 91%" in msg
|
||||
assert "identity_v2 + price_alert_exact" in msg
|
||||
assert "動作:<code>human_review</code>" in msg
|
||||
|
||||
Reference in New Issue
Block a user