diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index eff7260..012828d 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.458 將 OpenClaw / 競品 PPT 接上 PChome 覆核 `decision_envelope` 摘要:`competitor_intel_repository.summarize_review_decision_envelopes()` 成為共用 formatter,OpenClaw 週報/日報/月報與競品簡報 data_summary / KPI slide 都讀同一份信封文字,避免策略報告與 PPT 各自翻譯覆核狀態或遺失 HITL guardrails。 - V10.457 將 PChome 覆核 `decision_envelope` 連到人工操作面:Dashboard 覆核卡新增決策等級、資料品質、HITL/trace 信封摘要;`/api/export/excel/pchome-review` 匯出同步增加決策信封 ID、決策類型、建議代碼、責任人、資料品質、自動執行允許與證據摘要,讓線上操作與下載檔都保留同一份 guardrails。 - V10.456 將 PChome 覆核隊列接上 `decision_envelope` contract:`fetch_competitor_review_queue()` 與 `/api/pchome-review/queue` 每筆候選都輸出同一份 SKU、PChome 候選、match evidence、recommended_action、expected_impact 與 HITL guardrails,Dashboard、Agent、Telegram、PPT 後續不得再各自重建比價判讀格式;同版將 review queue cache key 升到 v3,避免正式環境沿用舊 payload。 - 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`,且不寫正式價差表。 diff --git a/config.py b/config.py index 69b5512..f6ecaf6 100644 --- a/config.py +++ b/config.py @@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.457" +SYSTEM_VERSION = "V10.458" 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 a5f4a53..fcfecba 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.457 +> **適用版本**: V10.458 --- @@ -49,6 +49,7 @@ - 已帶 `decision_envelope` 的價格/覆核事件必須由 EventRouter 直接渲染證據模板,不再進 L1/L2 AI 重新摘要;Telegram 決策信封需顯示標的 SKU、商品名稱、PChome 候選、evidence、guardrails 與 HITL 動作,避免已有實證的比價告警被二次生成文字稀釋或造成額外模型成本。 - PChome 覆核隊列本身也必須輸出 `decision_envelope`:`fetch_competitor_review_queue()`、`fetch_competitor_review_queue_page()` 與 `/api/pchome-review/queue` 的每筆候選需帶相同的 `subject`、`evidence`、`recommended_action`、`expected_impact` 與 `guardrails`,供 Dashboard、Agent、Telegram 與 PPT 共用;任何下游不得另寫一套比價狀態翻譯或繞過 HITL guardrails。 - Dashboard 覆核卡與 `/api/export/excel/pchome-review` 也必須顯示/匯出 `decision_envelope` 的等級、資料品質、建議代碼、HITL、trace 與 `can_auto_execute=false` 邊界;操作員離開系統畫面或下載 Excel 後,仍要看得到「不可自動寫正式價差」的 guardrails。 +- OpenClaw 週報/日報/月報與 competitor PPT 不得再各自重算或翻譯 PChome 覆核狀態;必須透過 `competitor_intel_repository.summarize_review_decision_envelopes()` 讀取同一份 `decision_envelope` 摘要,並在 prompt / data_summary / KPI slide 保留 HITL 與 `can_auto_execute=false` 邊界。 ## 一、四 AI Agent 路由架構 diff --git a/docs/memory/current_execution_queue_20260524.md b/docs/memory/current_execution_queue_20260524.md index 191de65..abdeda8 100644 --- a/docs/memory/current_execution_queue_20260524.md +++ b/docs/memory/current_execution_queue_20260524.md @@ -13,6 +13,7 @@ - 2026-05-24 22:55 CST 狀態:`main` 已推 Gitea 並部署到 188,正式 `/health` 為 `V10.455`。本輪 recreate `momo-app`、`scheduler`、`telegram-bot`;未使用 `--remove-orphans`,未碰 `momo-db`。Smoke 通過:三個 app 容器 healthy、EventRouter `decision_envelope` 直送不進 L1/L2 AI handler、Telegram 信封顯示標的 SKU 與 PChome 候選、Gemini hard disabled 且 24 小時 `ai_calls` 無 Gemini provider、Ollama 順序維持 GCP-A → GCP-B → 111、`/api/pchome-review/queue?review_status=rescore_accepted` 查詢成功、10 分鐘錯誤 log 未見 Traceback / ERROR / CRITICAL。已執行 `--retract-variant-accepted`,最新 `rescore_accepted_current` 中 `variant_selection_review` 殘留為 0。 - 2026-05-24 23:05 CST 狀態:`main` 已推 Gitea 並部署到 188,正式 `/health` 為 `V10.456`。本輪 recreate `momo-app`、`scheduler`、`telegram-bot`;未使用 `--remove-orphans`,未碰 `momo-db`。Smoke 通過:三個 app 容器 healthy、`/api/pchome-review/queue?review_status=rescore_accepted` 每筆帶 `decision_envelope`、guardrail `can_auto_execute=false`、Gemini hard disabled 且 24 小時 `ai_calls` 無 Gemini provider、Ollama 順序維持 GCP-A → GCP-B → 111、5 分鐘三容器錯誤 log 未見 Traceback / ERROR / CRITICAL。 - 2026-05-24 23:17 CST 狀態:`main` 已推 Gitea 並部署到 188,正式 `/health` 為 `V10.457`。本輪 recreate `momo-app`、`scheduler`、`telegram-bot`;未使用 `--remove-orphans`,未碰 `momo-db`。Smoke 通過:三個 app 容器 healthy、Dashboard PChome 覆核頁顯示 `dashboard-review-envelope` 與 HITL、`/api/pchome-review/queue?review_status=rescore_accepted` 仍帶 `decision_envelope` 且 `can_auto_execute=false`、Excel flatten helper 輸出決策信封 ID/資料品質/自動執行允許/證據摘要、Gemini hard disabled 且 24 小時 `ai_calls` 無 Gemini provider、Ollama 順序維持 GCP-A → GCP-B → 111、5 分鐘三容器錯誤 log 未見 Traceback / ERROR / CRITICAL。 +- 2026-05-24 23:25 CST 狀態:V10.458 補 OpenClaw / competitor PPT 共用 PChome review `decision_envelope` 摘要;待部署後回填正式 `/health` 與 smoke 結果。 ## 1. MOMO / PChome 核心比價準確率 @@ -44,6 +45,7 @@ - 2026-05-24 22:44 CST 起,EventRouter 對已附 `decision_envelope` 的事件直接渲染證據模板,不呼叫 L1/L2 AI handler;這讓 NemoTron 價格告警、人工覆核與後續 Agent 共用同一份 SKU / PChome / evidence / guardrails,不再二次生成摘要。 - 2026-05-24 23:00 CST 起,`fetch_competitor_review_queue()`、`fetch_competitor_review_queue_page()` 與 `/api/pchome-review/queue` 每筆候選也帶 `decision_envelope`,包含 SKU/PChome 標的、match evidence、人工下一步、預期價差與不可自動寫正式價差的 guardrails;Dashboard、Agent、Telegram、PPT 後續共用此 contract。 - 2026-05-24 23:15 CST 起,Dashboard 覆核卡與 PChome 覆核 Excel 匯出也顯示/輸出信封摘要、資料品質、HITL、trace、自動執行阻擋原因與證據摘要;下載檔不得丟失 guardrails。 +- 2026-05-24 23:25 CST 起,OpenClaw 週報/日報/月報與 competitor PPT 使用 `summarize_review_decision_envelopes()` 的同一份 HITL 信封摘要,不再手寫 attempt_status 統計或自行翻譯覆核狀態。 - 告警不得再輸出空泛「預期效益」;必須帶資料品質、證據來源、HITL 邊界與 trace id。 - Agent 建議只能輔助排序與分析,不得繞過 matcher / feeder / review service 寫正式價格。 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 1d10cfe..7d3ce93 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-24:PChome 近門檻身份回收第二輪 +- **V10.458 OpenClaw / PPT 決策信封摘要**: 新增 `summarize_review_decision_envelopes()` 作為 PChome 覆核信封共用摘要 formatter;OpenClaw 週報/日報/月報、OpenClaw Bot competitor PPT data_summary 與 PPT KPI slide 都使用同一份 HITL / 資料品質 / action / trace 摘要,不再各自手寫 attempt_status 翻譯。 - **V10.457 Dashboard / Excel 決策信封連動**: 商品看板 PChome 覆核卡顯示 `decision_envelope` 的決策等級、資料品質、HITL 與 trace;`/api/export/excel/pchome-review` 匯出新增決策信封 ID、建議代碼、責任人、資料品質、自動執行允許、阻擋原因與證據摘要,讓下載檔仍保留不可自動寫正式價差的 guardrails。 - **V10.456 review queue 決策信封**: `fetch_competitor_review_queue()`、`fetch_competitor_review_queue_page()` 與 `/api/pchome-review/queue` 每筆 PChome 覆核候選都輸出 `decision_envelope`,包含標的 SKU/PChome 候選、match evidence、建議人工動作、預期價差、資料品質與「不可自動寫正式價差」guardrails;review queue cache key 升到 v3,避免正式環境沿用舊 payload。 - **V10.455 EventRouter 決策信封直送**: 已帶 `decision_envelope` 的價格/覆核事件會略過 L1/L2 AI 重新摘要,直接用 Telegram 證據模板通知;決策信封新增標的區塊,顯示 SKU、商品名稱、PChome 候選 ID/名稱,避免 NemoTron 已有實證的價格告警被二次生成文字稀釋或產生額外模型呼叫。 diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index 24da5e9..9976d7a 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -3348,13 +3348,20 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, if not cached_ai: mcp_text_c = _fetch_mcp_context() - from services.competitor_intel_repository import fetch_competitor_comparison_results + from services.competitor_intel_repository import ( + fetch_competitor_comparison_results, + fetch_competitor_review_queue, + summarize_review_decision_envelopes, + ) + competitor_engine = _db() results = fetch_competitor_comparison_results( - _db(), + competitor_engine, start_date=start_d.strftime('%Y-%m-%d'), end_date=end_d.strftime('%Y-%m-%d'), limit=30, ) + review_queue = fetch_competitor_review_queue(competitor_engine, limit=5) + review_decision_brief = summarize_review_decision_envelopes(review_queue, limit=5) found_c = [r for r in results if r.get('found')] pchome_low_price_c = [r for r in found_c if r.get('price_diff', 0) > 10] @@ -3385,6 +3392,7 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, f"待補資料樣本:" + " / ".join( f"{r['momo_name'][:12]}({r.get('match_status', 'no_valid_match')})" for r in not_found_c[:3]) + "\n\n" + f"覆核決策信封(HITL,不可自動寫正式價差):\n{review_decision_brief.get('text')}\n\n" f"外部情報:{mcp_text_c[:400]}" ) ai_text = cached_ai or _ppt_ai_analysis(data_summary, f'競品比較簡報({period_label})') @@ -3394,6 +3402,8 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, db_data = { 'results': results, 'period_label': period_label, + 'review_queue': review_queue, + 'review_decision_brief': review_decision_brief, 'mcp': mcp_text_c, } ppt_path = generate_competitor_ppt(period_label, db_data, ai_text) diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index e734d89..404ea01 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -98,6 +98,20 @@ MANUAL_REVIEW_ACTION_LABELS = { "unit_price_required": "人工單位價", "needs_research": "需補搜尋", } +DECISION_ACTION_LABELS = { + "review_accept_identity": "人工覆核後採用同款", + "unit_price_required": "確認單位價 / 組合差異", + "needs_research": "補搜尋詞或重新抓取", + "verify_or_reject_identity": "確認身份或否決候選", + "refresh_or_compare_identity": "刷新價格或比較候選", + "human_review": "人工覆核", +} +DATA_QUALITY_LABELS = { + "complete": "證據完整", + "partial": "證據部分完整", + "missing": "缺少候選證據", + "stale": "證據過期", +} MATCH_DIAGNOSTIC_REASON_LABELS = { "brand_conflict": "品牌不符", "product_line_conflict": "商品線不符", @@ -434,6 +448,99 @@ def _build_review_decision_envelope(item: dict[str, Any]) -> dict[str, Any]: } +def _decision_action_label(action_code: str) -> str: + return DECISION_ACTION_LABELS.get(action_code or "", action_code or "人工覆核") + + +def _data_quality_label(data_quality: str) -> str: + return DATA_QUALITY_LABELS.get(data_quality or "", data_quality or "證據部分完整") + + +def summarize_review_decision_envelopes( + review_queue: list[dict[str, Any]], + limit: int = 5, +) -> dict[str, Any]: + """Create a compact evidence brief for OpenClaw/PPT from shared envelopes.""" + limit = max(1, min(int(limit or 5), 10)) + lines: list[str] = [] + items: list[dict[str, Any]] = [] + severity_counts: dict[str, int] = {} + data_quality_counts: dict[str, int] = {} + hitl_count = 0 + auto_execute_blocked_count = 0 + + for idx, row in enumerate((review_queue or [])[:limit], start=1): + envelope = row.get("decision_envelope") or {} + subject = envelope.get("subject") if isinstance(envelope.get("subject"), dict) else {} + guardrails = envelope.get("guardrails") if isinstance(envelope.get("guardrails"), dict) else {} + action = envelope.get("recommended_action") if isinstance(envelope.get("recommended_action"), dict) else {} + expected = envelope.get("expected_impact") if isinstance(envelope.get("expected_impact"), dict) else {} + evidence = envelope.get("evidence") if isinstance(envelope.get("evidence"), list) else [] + + severity = str(envelope.get("severity") or "P4") + data_quality = str(guardrails.get("data_quality") or "partial") + action_code = str(action.get("action") or "human_review") + requires_hitl = bool(action.get("requires_hitl", True)) + can_auto_execute = bool(guardrails.get("can_auto_execute")) + sku = str(subject.get("sku") or row.get("sku") or "") + name = str(subject.get("name") or row.get("name") or "") + pchome_id = str(subject.get("competitor_product_id") or row.get("candidate_pc_id") or "") + gap_pct = expected.get("candidate_gap_pct") + gap_text = f"價差 {gap_pct:+.1f}%" if isinstance(gap_pct, (int, float)) else "" + evidence_basis = "" + for evidence_row in evidence: + if isinstance(evidence_row, dict) and evidence_row.get("metric") == "match_score": + evidence_basis = str(evidence_row.get("basis") or "") + break + + severity_counts[severity] = severity_counts.get(severity, 0) + 1 + data_quality_counts[data_quality] = data_quality_counts.get(data_quality, 0) + 1 + if requires_hitl: + hitl_count += 1 + if not can_auto_execute: + auto_execute_blocked_count += 1 + + pchome_text = f"PChome {pchome_id}" if pchome_id else "無候選 ID" + line_parts = [ + f"{idx}. [{severity}/{_data_quality_label(data_quality)}{'/HITL' if requires_hitl else ''}]", + f"SKU {sku}", + name[:28], + f"→ {_decision_action_label(action_code)}", + pchome_text, + ] + if gap_text: + line_parts.append(gap_text) + if evidence_basis: + line_parts.append(evidence_basis) + line = " | ".join(part for part in line_parts if part) + lines.append(line) + items.append({ + "decision_id": envelope.get("decision_id") or "", + "severity": severity, + "sku": sku, + "name": name, + "competitor_product_id": pchome_id, + "action": action_code, + "action_label": _decision_action_label(action_code), + "data_quality": data_quality, + "data_quality_label": _data_quality_label(data_quality), + "requires_hitl": requires_hitl, + "can_auto_execute": can_auto_execute, + "candidate_gap_pct": gap_pct, + "line": line, + }) + + return { + "items": items, + "lines": lines, + "text": "\n".join(lines) if lines else "(目前沒有待覆核決策信封)", + "severity_counts": severity_counts, + "data_quality_counts": data_quality_counts, + "hitl_count": hitl_count, + "auto_execute_blocked_count": auto_execute_blocked_count, + } + + def _format_competitor_review_item(row: dict[str, Any]) -> dict[str, Any]: item = dict(row) unit_comparison = _build_unit_comparison_for_attempt(item) @@ -1526,10 +1633,12 @@ def fetch_competitor_comparison_results( def build_competitor_intel_payload(engine, days: int = 30) -> dict: """頁面、AI、PPT 可共用的摘要 payload。""" + review_queue = fetch_competitor_review_queue(engine, limit=12) return { "coverage": fetch_competitor_coverage(engine), "trend": fetch_competitor_gap_trend(engine, days=days), "top_risks": fetch_top_competitor_risks(engine, limit=10), - "review_queue": fetch_competitor_review_queue(engine, limit=12), + "review_queue": review_queue, + "review_decision_brief": summarize_review_decision_envelopes(review_queue, limit=5), "match_score_floor": PCHOME_MATCH_SCORE_FLOOR, } diff --git a/services/openclaw_strategist_service.py b/services/openclaw_strategist_service.py index 72d3234..45a377d 100644 --- a/services/openclaw_strategist_service.py +++ b/services/openclaw_strategist_service.py @@ -30,7 +30,7 @@ from datetime import datetime, timedelta from typing import Any, Dict, List, Optional from database.manager import get_session -from sqlalchemy import bindparam, inspect, text +from sqlalchemy import bindparam, text from services.ai_call_logger import log_ai_call # Operation Ollama-First v5.0 P1 from services.gemini_guard import ( @@ -562,6 +562,28 @@ def _fetch_competitor_summary() -> Dict[str, Any]: """競品價格整體概況""" session = get_session() try: + coverage: Dict[str, Any] = {} + review_decision_brief: Dict[str, Any] = { + "text": "(目前沒有待覆核決策信封)", + "lines": [], + "items": [], + "hitl_count": 0, + "auto_execute_blocked_count": 0, + } + if session.bind is not None: + try: + from services.competitor_intel_repository import ( + fetch_competitor_coverage, + fetch_competitor_review_queue, + summarize_review_decision_envelopes, + ) + + coverage = fetch_competitor_coverage(session.bind) or {} + review_queue = fetch_competitor_review_queue(session.bind, limit=5) or [] + review_decision_brief = summarize_review_decision_envelopes(review_queue, limit=5) + except Exception as repo_exc: + logger.warning("[OpenClaw] 競品覆核信封摘要讀取失敗,降級只讀正式價差: %s", repo_exc) + row = session.execute(text(""" SELECT COUNT(*) AS total, @@ -578,34 +600,23 @@ def _fetch_competitor_summary() -> Dict[str, Any]: AND COALESCE(cp.match_score, 0) >= 0.76 AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' """)).fetchone() - if row and row[0]: - attempt_row = None - if session.bind is not None and inspect(session.bind).has_table("competitor_match_attempts"): - attempt_row = session.execute(text(""" - WITH latest_attempt AS ( - SELECT DISTINCT ON (sku) - sku, - attempt_status - FROM competitor_match_attempts - WHERE source = 'pchome' - ORDER BY sku, attempted_at DESC NULLS LAST - ) - SELECT - SUM(CASE WHEN attempt_status IN ('unit_comparable', 'refresh_unit_comparable') THEN 1 ELSE 0 END) AS unit_comparable_count, - SUM(CASE WHEN attempt_status = 'rescore_accepted_current' THEN 1 ELSE 0 END) AS rescore_accepted_count, - SUM(CASE WHEN attempt_status IN ('rescore_accepted_current', 'unit_comparable', 'refresh_unit_comparable', 'identity_veto', 'low_score', 'refresh_low_score', 'recoverable_low_score', 'true_low_confidence', 'protected_existing_match', 'expired_match', 'no_result', 'refresh_no_result') THEN 1 ELSE 0 END) AS review_queue_count - FROM latest_attempt - """)).fetchone() - return { - "total_skus": int(row[0]), - "avg_gap_pct": round(float(row[1] or 0), 1), - "undercut_count": int(row[2] or 0), - "premium_count": int(row[3] or 0), - "unit_comparable_count": int((attempt_row[0] if attempt_row else 0) or 0), - "rescore_accepted_count": int((attempt_row[1] if attempt_row else 0) or 0), - "review_queue_count": int((attempt_row[2] if attempt_row else 0) or 0), - } - return {} + return { + "total_skus": int((row[0] if row else 0) or 0), + "avg_gap_pct": round(float((row[1] if row else 0) or 0), 1), + "undercut_count": int((row[2] if row else 0) or 0), + "premium_count": int((row[3] if row else 0) or 0), + "match_rate": float(coverage.get("match_rate") or 0), + "active_with_price": int(coverage.get("active_with_price") or 0), + "unit_comparable_count": int(coverage.get("unit_comparable_count") or 0), + "rescore_accepted_count": int(coverage.get("rescore_accepted_count") or 0), + "review_queue_count": int(coverage.get("actionable_review_count") or 0), + "manual_accept_count": int(coverage.get("manual_accept_count") or 0), + "manual_reject_count": int(coverage.get("manual_reject_count") or 0), + "manual_unit_price_count": int(coverage.get("manual_unit_price_count") or 0), + "manual_accept_rate": float(coverage.get("manual_accept_rate") or 0), + "review_decision_brief": review_decision_brief, + "review_decision_text": review_decision_brief.get("text") or "(目前沒有待覆核決策信封)", + } except Exception as e: logger.error("[OpenClaw] 競品概況讀取失敗: %s", e) raise @@ -1490,6 +1501,10 @@ def generate_weekly_strategy_report( 我方具優勢數:{competitor_summary.get('premium_count', 0)} 個 需單位價覆核:{competitor_summary.get('unit_comparable_count', 0)} 個 重算可採用待審:{competitor_summary.get('rescore_accepted_count', 0)} 個 + 人工覆核採用率:{competitor_summary.get('manual_accept_rate', 0):.1f}% + +PChome 覆核決策信封(HITL,不可自動寫正式價差): +{competitor_summary.get('review_decision_text', '(目前沒有待覆核決策信封)')} TOP 威脅品項(近48h Hermes 偵測): {_format_threats(threats)} @@ -1772,6 +1787,9 @@ def _legacy_full_gemini_daily_report() -> dict: 單位價/身份覆核隊列:{competitor_summary.get('review_queue_count', 0)} 個 重算可採用待審:{competitor_summary.get('rescore_accepted_count', 0)} 個 +【PChome 覆核決策信封(HITL,不可自動寫正式價差)】 +{competitor_summary.get('review_decision_text', '(目前沒有待覆核決策信封)')} + 請按以下結構輸出(使用 HTML 標題): 📅 {period} 電商日報 @@ -1950,6 +1968,9 @@ def generate_monthly_report() -> dict: 需單位價覆核SKU:{competitor_summary.get('unit_comparable_count', 0)} 個 重算可採用待審SKU:{competitor_summary.get('rescore_accepted_count', 0)} 個 +PChome 覆核決策信封(HITL,不可自動寫正式價差): +{competitor_summary.get('review_decision_text', '(目前沒有待覆核決策信封)')} + 【價格變動概況】 本月調價次數:{price_trend_data.get('price_changes', 0)} 次 平均調幅:{price_trend_data.get('avg_change_pct', 0):+.1f}% diff --git a/services/ppt_generator.py b/services/ppt_generator.py index 44f7a98..da5353b 100644 --- a/services/ppt_generator.py +++ b/services/ppt_generator.py @@ -2543,6 +2543,8 @@ def generate_competitor_ppt(period_label: str, db_data: dict, ai_text: str) -> s avg_pct = (sum(r.get("price_diff_pct", 0) for r in found) / len(found) if found else 0) momo_rev = db_data.get("momo_revenue", 0) + review_brief = db_data.get("review_decision_brief") or {} + review_lines = list(review_brief.get("lines") or [])[:3] # P1: 封面 _cover_slide( @@ -2586,6 +2588,13 @@ def generate_competitor_ppt(period_label: str, db_data: dict, ai_text: str) -> s if momo_rev: _add_text(s2, f"本期 momo 掃描商品總業績:NT$ {momo_rev:,.0f}({momo_rev/10000:.1f}萬)", 0.8, 11.8, W - 1.6, 0.7, size=10, color=_SUBTEXT) + if review_lines: + _add_rect(s2, 18.2, 6.25, 14.7, 4.2, _BG_PAPER, line_hex=_SUBTLE) + _add_text(s2, "覆核決策信封(HITL)", 18.55, 6.45, 13.8, 0.45, + bold=True, size=10, color=_DARK_TEXT) + _add_text(s2, "\n".join(review_lines), 18.55, 7.05, 13.7, 3.0, + size=8.2, color=_SUBTEXT, wrap=True, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) _add_footer(s2, W) # P3: 商品比較表 diff --git a/tests/test_competitor_intel_cache.py b/tests/test_competitor_intel_cache.py index 495c15b..8b13c09 100644 --- a/tests/test_competitor_intel_cache.py +++ b/tests/test_competitor_intel_cache.py @@ -100,7 +100,8 @@ def test_competitor_review_queue_is_canonical_unit_price_handoff(): growth_template = (ROOT / "templates" / "growth_analysis.html").read_text(encoding="utf-8") assert "def fetch_competitor_review_queue" in source - assert "\"review_queue\": fetch_competitor_review_queue" in source + assert "review_queue = fetch_competitor_review_queue" in source + assert "\"review_queue\": review_queue" in source assert "\"unit_comparable_count\"" in source assert "\"rescore_accepted_count\"" in source assert "manual_review_summary" in source @@ -219,6 +220,54 @@ def test_rescore_accepted_review_item_has_actionable_decision_envelope(): assert any(evidence["metric"] == "candidate_gap_pct" for evidence in envelope["evidence"]) +def test_review_decision_brief_is_shared_by_openclaw_and_ppt(): + from services.competitor_intel_repository import ( + _format_competitor_review_item, + summarize_review_decision_envelopes, + ) + + item = _format_competitor_review_item({ + "sku": "10922465", + "name": "【Herbacin 德國小甘菊】小甘菊1號護手霜20ml", + "momo_price": 99, + "attempt_status": "rescore_accepted_current", + "candidate_count": 1, + "best_competitor_product_id": "DDAO4C-A79050612", + "best_competitor_product_name": "小甘菊經典護手霜20ml", + "best_competitor_price": 89, + "best_match_score": 0.872, + "match_diagnostic_json": { + "match_type": "exact", + "price_basis": "total_price", + "alert_tier": "identity_review", + }, + }) + + brief = summarize_review_decision_envelopes([item], limit=5) + + assert brief["hitl_count"] == 1 + assert brief["auto_execute_blocked_count"] == 1 + assert brief["severity_counts"] + assert brief["data_quality_counts"] == {"complete": 1} + assert "人工覆核後採用同款" in brief["text"] + assert "HITL" in brief["text"] + assert "DDAO4C-A79050612" in brief["text"] + + repo_source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8") + openclaw_source = (ROOT / "services" / "openclaw_strategist_service.py").read_text(encoding="utf-8") + ppt_source = (ROOT / "services" / "ppt_generator.py").read_text(encoding="utf-8") + bot_source = (ROOT / "routes" / "openclaw_bot_routes.py").read_text(encoding="utf-8") + + assert "\"review_decision_brief\": summarize_review_decision_envelopes" in repo_source + assert "summarize_review_decision_envelopes" in openclaw_source + assert "review_decision_text" in openclaw_source + assert "PChome 覆核決策信封(HITL,不可自動寫正式價差)" in openclaw_source + assert "SUM(CASE WHEN attempt_status IN ('unit_comparable'" not in openclaw_source + assert "review_decision_brief" in bot_source + assert "覆核決策信封(HITL,不可自動寫正式價差)" in bot_source + assert "覆核決策信封(HITL)" in ppt_source + + def test_competitor_ppt_prompt_uses_neutral_ewooc_viewpoint(): source = (ROOT / "routes" / "openclaw_bot_routes.py").read_text(encoding="utf-8") diff --git a/tests/test_openclaw_daily_template.py b/tests/test_openclaw_daily_template.py index 4db3c9d..fa13148 100644 --- a/tests/test_openclaw_daily_template.py +++ b/tests/test_openclaw_daily_template.py @@ -155,7 +155,9 @@ class TestKPIComputation: source = (Path(__file__).resolve().parents[1] / "services/openclaw_strategist_service.py").read_text(encoding="utf-8") assert "rescore_accepted_count" in source - assert "attempt_status = 'rescore_accepted_current'" in source + assert "fetch_competitor_coverage" in source + assert "summarize_review_decision_envelopes" in source + assert "review_decision_text" in source assert "重算可採用待審" in source