diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index c91597e..91a3a56 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.534 收緊 PChome rescore accepted gate:`no_match / price_basis=none / alert_tier=suppress` 不得再進 `rescore_accepted_current`,並新增 `--retract-unsafe-accepted` 退回舊的 unsafe accepted rows;Dashboard / daily / growth / OpenClaw 文案改為「重算待人工覆核」,避免操作員把人工覆核隊列誤解為可直接採用或可自動寫價。 - V10.533 補 ElephantAlpha legacy OpenClaw advisory 相容:`generate_dynamic_pricing_strategy` 與既有 `generate_market_strategy` / `generate_resource_optimization_strategy` 一樣只記錄為 skipped,不再觸發 `Unrecognized step` 與 circuit breaker;避免舊協調器輸出的建議型動態定價步驟被誤解為真正可執行任務。 - V10.532 修正 PChome coverage / review queue 口徑落差:`fetch_competitor_coverage()` 的 `attempt_status` / `rescore_accepted_count` / `actionable_review_count` 改跟 review queue 一樣統計「沒有新鮮有效 identity」的商品,而不是只看「完全沒有 identity」;這讓已過期 identity 的 `rescore_accepted_current` 待審能正確顯示在 Dashboard / 狀態 API。 - V10.531 補 PChome matcher 過度保守的安全 exact 線:同品線、同規格、同數量的多件組若沒有 variant / count / bundle / commercial / unit-price 等阻擋理由,且商品型別完全對齊,允許進 `exact / total_price / price_alert_exact`;新增 DHC 純欖護唇膏 1.5g、FRUDIA 蜂蜜藍莓護唇膏 10g、SEBAMED 嬰兒護唇膏 4.8g x2、理膚寶水滋養修護潤唇膏 4.7ml 的 focused total-price 規則。負例仍鎖住混合組、香味款、粉底色號與蠟燭 catalog,不放寬全域門檻。 diff --git a/config.py b/config.py index 6def019..aef61c9 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.533" +SYSTEM_VERSION = "V10.534" 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 e73e7da..38164d4 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -95,7 +95,7 @@ SQL漏斗(~300筆) - 排程閉環:`run_pchome_match_backfill_task` 每日 10:30 執行,補抓 PChome 待比對商品、寫入歷史價格,再重算 `strategy='product_pick'` 清單。 - PChome / MOMO 競價摘要出口 `services/competitor_intel_repository.py` 使用 30 分鐘共享快取(`COMPETITOR_INTEL_CACHE_TTL_SECONDS` 可調),避免 `/growth_analysis`、`/daily_sales`、PPT/AI 報表每次請求重跑昂貴覆蓋率與價差趨勢查詢;`run_competitor_price_feeder_task` 與 PChome backfill 完成後會主動清除快取。快取只包摘要輸出,不改 matcher 的高信心門檻與 identity_v2 準確性規則。 - 商品看板第一屏:`/` 的 V2 看板直接以 `products`、`price_records`、`competitor_prices`、`competitor_match_attempts`、`competitor_match_reviews`、`ai_price_recommendations` 顯示比對覆蓋率、PChome 優勢、MOMO 威脅、AI 挑品、待比對優先清單與 PChome 覆核隊列;`filter=ai_picks` 可查看 50 品 AI 挑品列表,`filter=pchome_review` 可直接查看需人工處理的比價覆核 SKU,並以 DB 分頁支援 search/category/status 後的完整隊列,不得只截前 50 筆。覆核狀態篩選必須至少包含全部、需單位價、已排除、低信心、價格過期、找不到同款與人工閉環,讓人工可依 matcher 診斷類型分批處理。列內顯示候選 PChome 商品、候選價、match score、單位價換算摘要、人工動作與 matcher 診斷原因標籤(品牌不符、商品線不符、容量差異、組合差異、需單位價、價差極端等),不得只顯示籠統「待比對」。`/api/export/excel/pchome-review` 必須匯出同一套覆核隊列、人工處置、候選 PChome、單位價比較與原始診斷,讓人工覆核、簡報與後續 AI 分析共用同一份證據。`/api/pchome-review//decision` 是人工閉環入口:`accept_identity` 才可把候選寫入 `competitor_prices` 與 `competitor_price_history` 並打上 `manual_review/manual_accept/identity_v2`;`reject_identity`、`unit_price_required` 與 `needs_research` 只寫 `competitor_match_reviews` 並追加 manual attempt,不得把不同販售組合或否決候選灌入正式價差。PChome feeder 後續搜尋同一候選時必須讀取 `competitor_match_reviews`:已否決候選寫 `manual_rejected` 並跳過正式寫入,且必須繼續評估下一個候選,不能讓已否決候選長期阻塞同 SKU;已標記單位價候選寫 `manual_unit_price_required`;已要求補搜尋候選寫 `manual_needs_research` 並停留在覆核隊列;已採用候選可保守補到最低門檻並保留 `manual_review/manual_accept` 標籤。搜尋候選池只有強同款分數達 `0.90` 才可提前停止,避免 0.76 灰區候選卡掉後續更精準搜尋詞。人工 `reject_identity`、`unit_price_required`、`needs_research` 若命中當前正式候選,必須將同候選 `competitor_prices` 過期,不得繼續顯示正式總價差。商品列表必須將 `manual_rejected`、`manual_unit_price_required`、`manual_needs_research` 顯示為明確人工閉環狀態,不可回落成籠統「待比對」。`fetch_competitor_coverage()` 必須輸出人工採用、人工否決、人工單位價與採用率,daily/growth/PPT 共用 payload 必須顯示人工閉環成效,避免只呈現待審數。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。 -- PChome re-score 回收線:`rescore_accepted_current` 只能表示最新版 matcher 判定「可人工採用」,不可直接寫入正式 `competitor_prices`;`fetch_competitor_coverage()` 必須輸出 `rescore_accepted_count`,Dashboard、daily/growth 與 OpenClaw 競品摘要都要把「重算可採用待審」獨立呈現,避免和一般低信心/單位價覆核混在一起。 +- PChome re-score 回收線:`rescore_accepted_current` 只能表示最新版 matcher 判定「值得人工覆核身份」,不可直接寫入正式 `competitor_prices`;`no_match`、`price_basis=none`、`alert_tier=suppress`、`variant_selection_review` 不得進入此隊列。`fetch_competitor_coverage()` 必須輸出 `rescore_accepted_count`,Dashboard、daily/growth 與 OpenClaw 競品摘要都要把「重算待人工覆核」獨立呈現,避免和一般低信心/單位價覆核混在一起。 - PChome 低信心操作分流:Dashboard 與 read-only `/api/pchome-review/queue` 必須把近門檻可救、證據不足、低信心舊候選拆成 `recoverable_low_score`、`true_low_confidence`、`legacy_low_score` 三個可篩選桶;廣義 `low_score` 僅作 repository/export 相容查詢,不可在 UI 中冒充單一操作分流。 - PChome coverage 的 `attempt_status` / `rescore_accepted_count` / `actionable_review_count` 口徑必須與 review queue 對齊:統計「沒有新鮮有效 identity」的商品,而不是只統計「完全沒有 identity」的商品;已過期但可重算採用的 stale identity 仍應出現在待審數字中,避免 API 與 Dashboard 漏報。 - `run_retryable_candidate_revalidation()` 的自動回刷主戰場仍限 `low_score` / `refresh_low_score` / `recoverable_low_score`;`true_low_confidence` 只有在已補 focused exact 規則的窄範圍品線、舊分數 >= 0.95、`comparison_mode='exact_identity'`、含 `strong_exact_spec_match` 且不含 commercial / variant / count / bundle / refill 等阻擋理由時,才可進入重評,不得全面打開人工審核池。 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 3570c0f..bd594b0 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-06-01:PChome 比價新鮮度操作閉環 +- **V10.534 rescore accepted gate 收緊與語意修正**: 正式 96 筆 `rescore_accepted_current` 盤點顯示多數仍是 `manual_review / identity_review`,且有 2 筆 `no_match / none / suppress` 混入。收緊 `classify_match_attempt_row()`:`no_match`、`price_basis=none`、`alert_tier=suppress` 不得 gate pass;新增 `--retract-unsafe-accepted` 可把既有 unsafe accepted 退回 `true_low_confidence`。Dashboard / daily / growth / OpenClaw 文案改成「重算待人工覆核」,明確表示仍需人工確認身份後才可寫正式價差。 - **V10.533 ElephantAlpha legacy OpenClaw advisory 相容**: 正式 scheduler 日誌出現 `Unrecognized step: agent=openclaw action=generate_dynamic_pricing_strategy`,屬於舊協調器把建議型策略文字放進 execution plan。執行器現在將 `generate_dynamic_pricing_strategy` 納入既有 OpenClaw advisory no-op 清單,只記錄 skipped,不觸發 circuit breaker,也不轉成自動調價或外部呼叫。 - **V10.532 coverage / review queue 口徑對齊**: V10.531 materialize 96 筆 `rescore_accepted_current` 後,DB 最新狀態正確,但 `/api/ai/pchome-match/backfill/status` 的 `rescore_accepted_count` 仍為 0。原因是 coverage 的 `attempt_status` 統計只看「完全沒有 identity」商品,而 review queue 看的是「沒有新鮮有效 identity」商品。改為以 `fresh_competitor` 排除條件統計,讓 stale identity 的重算可採用待審能正確上屏;正式價差表仍未被 rescore materialize 寫入。 - **V10.531 PChome 安全 exact 規則補強**: production refresh 顯示大量舊 identity 不是分數不足,而是被多件組 / 護唇品 variant 防線過度保守地擋在 `identity_review`。新增 `safe_multi_component_exact_total_price`:同品線、同規格、同數量且商品型別完全對齊、無 variant / count / bundle / commercial / unit-price 阻擋時,才可進 `exact / total_price / price_alert_exact`;另補 DHC 純欖護唇膏 1.5g、FRUDIA 蜂蜜藍莓護唇膏 10g、SEBAMED 嬰兒護唇膏 4.8g x2、理膚寶水滋養修護潤唇膏 4.7ml focused total-price。回歸測試保留 HH 混合組、TS6 香味衣物手洗精、粉底色號與蠟燭 catalog 不自動放行。 diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index c0d60c7..af7badc 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -65,7 +65,7 @@ REVIEW_STATUS_OPTIONS = [ 'rescore_accepted_current', ), }, - {'key': 'rescore_accepted', 'label': '重算可採用', 'statuses': ('rescore_accepted_current',)}, + {'key': 'rescore_accepted', 'label': '重算覆核', 'statuses': ('rescore_accepted_current',)}, { 'key': 'unit_comparable', 'label': '需單位價', @@ -158,10 +158,10 @@ def _build_pchome_match_status(attempt=None, ineligible=None): score = _to_float(attempt.get('best_match_score')) score_text = f"目前信心 {int(score * 100)}%" if score is not None else '目前信心待補' return { - 'label': '重算可採用待審', + 'label': '重算待人工覆核', 'tone': 'watch', 'score': score, - 'summary': f'{score_text},最新版 matcher 已通過身份門檻;仍需人工採用後才寫入正式 PChome 價差', + 'summary': f'{score_text},最新版 matcher 已通過重算覆核門檻;仍需人工確認身份後才可寫入正式 PChome 價差', 'detail': attempt.get('error_message') } if status == 'manual_accepted': diff --git a/scripts/audit_competitor_match_attempt_rescore.py b/scripts/audit_competitor_match_attempt_rescore.py index 029f18e..420dd13 100755 --- a/scripts/audit_competitor_match_attempt_rescore.py +++ b/scripts/audit_competitor_match_attempt_rescore.py @@ -20,8 +20,10 @@ from services.competitor_match_attempt_rescore_audit import ( # noqa: E402 DEFAULT_RESCAN_STATUSES, build_match_attempt_rescore_audit, fetch_match_attempt_rescore_rows, + fetch_unsafe_rescore_accept_review_rows, fetch_variant_rescore_accept_review_rows, materialize_rescore_accept_reviews, + retract_unsafe_rescore_accept_reviews, retract_variant_rescore_accept_reviews, summarize_match_attempt_rescore, ) @@ -92,12 +94,20 @@ def main(argv: list[str] | None = None) -> int: "attempts that contain variant_selection_review; never writes competitor_prices." ), ) + parser.add_argument( + "--retract-unsafe-accepted", + action="store_true", + help=( + "Append low-confidence retraction rows for latest rescore_accepted_current " + "attempts that no longer pass the current accept-review gate; never writes competitor_prices." + ), + ) args = parser.parse_args(argv) statuses = tuple(args.statuses or DEFAULT_RESCAN_STATUSES) if args.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.") + if args.apply_accepted or args.retract_variant_accepted or args.retract_unsafe_accepted: + parser.error("write modes 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, @@ -108,9 +118,33 @@ def main(argv: list[str] | None = None) -> int: from config import DATABASE_PATH engine = create_engine(DATABASE_PATH) - 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: + write_modes = sum(bool(flag) for flag in ( + args.apply_accepted, + args.retract_variant_accepted, + args.retract_unsafe_accepted, + )) + if write_modes > 1: + parser.error("Choose only one write mode.") + if args.retract_unsafe_accepted: + with engine.begin() as conn: + rows = fetch_unsafe_rescore_accept_review_rows( + conn, + source=args.source, + limit=args.limit, + min_score=args.min_score, + ) + summary = { + "selection_mode": "latest_sku_only", + "scanned": len(rows), + "rows": rows[: max(0, args.sample_limit)], + "retraction": retract_unsafe_rescore_accept_reviews( + conn, + rows, + source=args.source, + min_score=args.min_score, + ), + } + elif args.retract_variant_accepted: with engine.begin() as conn: rows = fetch_variant_rescore_accept_review_rows( conn, diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index d157950..f6760dd 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -56,7 +56,7 @@ REVIEW_STATUS_FILTER_GROUPS = { "manual_closed": ("manual_rejected", "manual_unit_price_required", "manual_needs_research"), } ATTEMPT_STATUS_LABELS = { - "rescore_accepted_current": "重算可採用待審", + "rescore_accepted_current": "重算待人工覆核", "unit_comparable": "需單位價比較", "refresh_unit_comparable": "需單位價比較", "identity_veto": "身份否決", @@ -75,7 +75,7 @@ ATTEMPT_STATUS_LABELS = { "manual_needs_research": "人工要求補搜尋", } ATTEMPT_ACTION_LABELS = { - "rescore_accepted_current": "覆核後可人工採用同款", + "rescore_accepted_current": "人工確認身份後才可採用", "unit_comparable": "人工確認檔期、贈品與單位價", "refresh_unit_comparable": "人工確認檔期、贈品與單位價", "identity_veto": "確認是否為不同商品線或規格", @@ -101,7 +101,7 @@ MANUAL_REVIEW_ACTION_LABELS = { } DECISION_ACTION_LABELS = { "compare_existing_identity": "比較既有正式候選與新候選", - "review_accept_identity": "人工覆核後採用同款", + "review_accept_identity": "人工確認身份後採用同款", "unit_price_required": "確認單位價 / 組合差異", "needs_research": "補搜尋詞或重新抓取", "verify_or_reject_identity": "確認身份或否決候選", diff --git a/services/competitor_match_attempt_rescore_audit.py b/services/competitor_match_attempt_rescore_audit.py index 762a4f3..14c0c54 100644 --- a/services/competitor_match_attempt_rescore_audit.py +++ b/services/competitor_match_attempt_rescore_audit.py @@ -23,6 +23,7 @@ DEFAULT_RESCAN_STATUSES = ( ) RESCORE_ACCEPTED_CURRENT_STATUS = "rescore_accepted_current" RETRACTED_VARIANT_REVIEW_STATUS = "true_low_confidence" +RETRACTED_UNSAFE_ACCEPT_STATUS = "true_low_confidence" @dataclass(frozen=True) @@ -117,7 +118,7 @@ def classify_match_attempt_row( elif diagnostics.comparison_mode == "unit_comparable": suggested_status = "unit_comparable_current" gate_pass = False - elif diagnostics.score >= min_score and "variant_selection_review" not in reasons: + elif _is_rescore_accept_gate_pass(diagnostics, reasons, min_score=min_score): suggested_status = "accepted_current" gate_pass = True else: @@ -142,6 +143,25 @@ def classify_match_attempt_row( ) +def _is_rescore_accept_gate_pass(diagnostics: Any, reasons: Sequence[str], *, min_score: float) -> bool: + """Return True only for candidates safe enough to enter manual accept review.""" + if diagnostics.score < min_score: + return False + if diagnostics.hard_veto: + return False + if diagnostics.comparison_mode != "exact_identity": + return False + if diagnostics.match_type == "no_match": + return False + if diagnostics.price_basis == "none": + return False + if diagnostics.alert_tier == "suppress": + return False + if "variant_selection_review" in set(reasons): + return False + return True + + def summarize_match_attempt_rescore( rows: Iterable[dict[str, Any]], *, @@ -223,6 +243,22 @@ def _retraction_diagnostic_text(decision: MatchAttemptRescoreDecision) -> str: ) +def _unsafe_retraction_diagnostic_text(decision: MatchAttemptRescoreDecision) -> str: + reasons = ",".join(decision.reasons or []) + return ( + "rescore_retracted_unsafe_accepted_current; " + "matcher_rescore=retracted_unsafe_accepted_current; " + f"stored_status={decision.stored_status}; " + f"stored_score={decision.stored_score}; " + f"current_score={decision.current_score}; " + f"mode={decision.comparison_mode}; " + f"match_type={decision.match_type}; " + f"price_basis={decision.price_basis}; " + f"alert_tier={decision.alert_tier}; " + f"reasons={reasons}" + ) + + def _ensure_attempt_table(conn) -> None: from services.competitor_price_feeder import CompetitorPriceFeeder @@ -516,6 +552,175 @@ def retract_variant_rescore_accept_reviews( return stats +def fetch_unsafe_rescore_accept_review_rows( + conn, + *, + source: str = "pchome", + limit: int = 100, + min_score: float = MIN_MATCH_SCORE, +) -> list[dict[str, Any]]: + """Fetch latest rescore-accepted rows no longer eligible for accept review.""" + rows = fetch_variant_rescore_accept_review_rows(conn, source=source, limit=limit) + known_skus = {str(row.get("sku") or "") for row in rows} + 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 + ORDER BY attempted_at DESC{nulls_last} + LIMIT :limit + """) + latest_rows = [ + dict(row) + for row in conn.execute(sql, { + "source": source, + "attempt_status": RESCORE_ACCEPTED_CURRENT_STATUS, + "limit": max(1, int(limit)), + }).mappings().all() + ] + for row in latest_rows: + sku = str(row.get("sku") or "") + if sku in known_skus: + continue + decision = classify_match_attempt_row(row, min_score=min_score) + if decision.suggested_status != "accepted_current": + rows.append(row) + known_skus.add(sku) + return rows + + +def retract_unsafe_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 unsafe accepted-current rows.""" + _ensure_attempt_table(conn) + rows = list(rows) if rows is not None else fetch_unsafe_rescore_accept_review_rows( + conn, + source=source, + limit=limit, + min_score=min_score, + ) + stats = { + "scanned": 0, + "retracted": 0, + "skipped_still_gate_pass": 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 decision.suggested_status == "accepted_current": + stats["skipped_still_gate_pass"] += 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_unsafe_accepted_current", + f"stored_status:{decision.stored_status}", + ], ensure_ascii=False), + "candidate_count": int(row.get("candidate_count") or 1), + "attempt_status": RETRACTED_UNSAFE_ACCEPT_STATUS, + "best_id": candidate_id, + "best_name": candidate_name[:300], + "best_price": candidate_price, + "best_score": decision.current_score, + "error_message": _unsafe_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/openclaw_strategist_service.py b/services/openclaw_strategist_service.py index 45a377d..1edf48b 100644 --- a/services/openclaw_strategist_service.py +++ b/services/openclaw_strategist_service.py @@ -1500,7 +1500,7 @@ def generate_weekly_strategy_report( 被競品削價數:{competitor_summary.get('undercut_count', 0)} 個 我方具優勢數:{competitor_summary.get('premium_count', 0)} 個 需單位價覆核:{competitor_summary.get('unit_comparable_count', 0)} 個 - 重算可採用待審:{competitor_summary.get('rescore_accepted_count', 0)} 個 + 重算待人工覆核:{competitor_summary.get('rescore_accepted_count', 0)} 個 人工覆核採用率:{competitor_summary.get('manual_accept_rate', 0):.1f}% PChome 覆核決策信封(HITL,不可自動寫正式價差): @@ -1785,7 +1785,7 @@ def _legacy_full_gemini_daily_report() -> dict: 被削價風險:{competitor_summary.get('undercut_count', 0)} 個(價差超過10%) 平均價差:{competitor_summary.get('avg_gap_pct', 0):+.1f}% 單位價/身份覆核隊列:{competitor_summary.get('review_queue_count', 0)} 個 - 重算可採用待審:{competitor_summary.get('rescore_accepted_count', 0)} 個 + 重算待人工覆核:{competitor_summary.get('rescore_accepted_count', 0)} 個 【PChome 覆核決策信封(HITL,不可自動寫正式價差)】 {competitor_summary.get('review_decision_text', '(目前沒有待覆核決策信封)')} @@ -1966,7 +1966,7 @@ def generate_monthly_report() -> dict: 月均價差:{competitor_summary.get('avg_gap_pct', 0):+.1f}% 被削價風險SKU:{competitor_summary.get('undercut_count', 0)} 個 需單位價覆核SKU:{competitor_summary.get('unit_comparable_count', 0)} 個 - 重算可採用待審SKU:{competitor_summary.get('rescore_accepted_count', 0)} 個 + 重算待人工覆核SKU:{competitor_summary.get('rescore_accepted_count', 0)} 個 PChome 覆核決策信封(HITL,不可自動寫正式價差): {competitor_summary.get('review_decision_text', '(目前沒有待覆核決策信封)')} diff --git a/templates/daily_sales.html b/templates/daily_sales.html index 7b80df1..76def2b 100644 --- a/templates/daily_sales.html +++ b/templates/daily_sales.html @@ -368,7 +368,7 @@ {{ comp_coverage.unit_comparable_count | default(0) | number_format }}
- 重算可採用待審 + 重算待人工覆核 {{ comp_coverage.rescore_accepted_count | default(0) | number_format }}
diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index 248b11a..31112cd 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -46,7 +46,7 @@
比價覆核
{{ overview.review_queue_count | default(0) | number_format }}
@@ -71,7 +71,7 @@
PCHOME MATCH BACKFILL
PChome 補抓產線
- 待刷新 {{ overview.stale_match_count | default(0) | number_format }} · 待補抓 {{ overview.pending_match_count | default(0) | number_format }} · 覆核 {{ overview.review_queue_count | default(0) | number_format }} · 重算可採用 {{ overview.rescore_accepted_count | default(0) | number_format }} · 單位價 {{ overview.unit_comparable_count | default(0) | number_format }} + 待刷新 {{ overview.stale_match_count | default(0) | number_format }} · 待補抓 {{ overview.pending_match_count | default(0) | number_format }} · 覆核 {{ overview.review_queue_count | default(0) | number_format }} · 重算待覆核 {{ overview.rescore_accepted_count | default(0) | number_format }} · 單位價 {{ overview.unit_comparable_count | default(0) | number_format }}