diff --git a/config.py b/config.py index a2d274a..939c5a1 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.567" +SYSTEM_VERSION = "V10.568" 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 7e033eb..280dea2 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -444,6 +444,7 @@ python3 -m services.competitor_identity_revalidator --limit 500 --apply 3. **收斂行動呼籲 (Call to Action)** — 每則訊息只有一個明確的 👉 建議行動 4. **底部運算足跡** — FinOps + Observability,用分隔線隔開主訊息 5. **EA HITL 專業 brief** — `ea_escalation` 必須分成決策狀態、背景摘要、風險摘要、TOP 待審 SKU 與建議處置;價格類行動不得用長 bullet 串接,必須拆出 MOMO/PChome 價格、價差、人工處置與 PChome ID。 +6. **價格類決策信封專業 brief** — `price_alert`、`pchome_match_review`、`competitor_price_review` 等含 PChome / 價格證據的 `decision_envelope`,EventRouter 必須直送 evidence template,不得進 L1/L2 重摘要;Telegram 內容必須拆成「標的、價格證據、比對證據、人工下一步」,並從信封讀取 `momo_price`、`competitor_price`、`candidate_gap_pct`、`match_score`、`unit_price_insight` 與 `existing_match_conflict`。 ### 5.2 語意化 Emoji 字典 diff --git a/docs/memory/current_execution_queue_20260524.md b/docs/memory/current_execution_queue_20260524.md index fe34135..d9f341f 100644 --- a/docs/memory/current_execution_queue_20260524.md +++ b/docs/memory/current_execution_queue_20260524.md @@ -105,6 +105,7 @@ - 2026-05-31 起,`V10.509` 新增市場情報 MCP Fetch Candidate Queue Writer Review Decision Approval Writer Preflight gate:在 human approval 通過後只審核 operator writer preflight 摘要,要求 approval linkage、writer_preflight_id、target operation、row count、dedupe keys、approved decision 到 target review_state 的逐列映射、decision/approval/preflight evidence refs、matched row exact-identity/variant/overwrite guard 與 operator boundary;仍不讀 token、不執行 CLI、不開 DB、不寫 preflight/approval/decision/match、不更新 review_state、不補 queue、不掛 scheduler,只放行到後續 CLI review / run package 設計。 - 2026-06-01 起,`V10.566` 新增市場情報 Professional Source Governance gate:將 robots/REP、sitemap/lastmod、JSON-LD / schema.org structured data、canonical URL、rate limit、公開資料邊界、provenance、snapshot hash 與 idempotency key 納入 source contract,並接上 `/api/market_intel/mcp_professional_source_governance`、UI preview panel、deployment readiness check 與 production smoke target;仍不抓外站、不讀 robots/sitemap、不開 DB、不寫檔、不掛 scheduler。 - 2026-06-02 起,`V10.567` 將 MCP 市場洞察 fallback 收斂為 GCP-A / GCP-B only,不再讓 111 承接非即時市場分析長任務;預設 timeout 25 秒、`num_predict` 500,GCP 不可用時直接保守降級,避免 Elephant Alpha 60 秒 timeout 與 111 負載尖峰。 +- 2026-06-02 起,`V10.568` 將價格類 `decision_envelope` 的 Telegram 直送訊息改為專業 brief:標的、價格證據、比對證據、人工下一步四段式;review queue 信封 subject 同步帶 `momo_price` / `competitor_price`,讓 Telegram、PPT、Webcrumbs 與 AI 摘要共用價格證據。 ## 3. 12 Agent 決策信封整合 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 71d067f..899e2ed 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-06-01:PChome 比價新鮮度操作閉環 +- **V10.568 價格類決策信封專業 brief**: `decision_envelope` 的價格 / PChome 覆核事件在 Telegram EventRouter 直送時,改以「標的、價格證據、比對證據、人工下一步」四段式排版呈現,保留 `momo:eig:` 忽略按鈕且不進 L1/L2 AI 重摘要。`competitor_intel_repository` 同步在 review queue 信封 subject 補上 `momo_price` / `competitor_price`,讓 Telegram、PPT、Webcrumbs 與 AI 摘要可共用同一份價格證據,不再各自補查或重組。 - **V10.567 MCP 市場洞察 GCP-only fallback**: `MCPCollectorService._ollama_topic_fallback()` 改成只使用 GCP-A / GCP-B Ollama,失敗後保守回本地 fallback,不再把市場洞察長分析轉嫁到 111。預設 timeout 收斂為 25 秒、`num_predict` 收斂為 500,避免 Elephant Alpha 在 GCP-A/GCP-B 短暫不可用時撞 60 秒總上限或造成 111 負載尖峰;Gemini 仍維持 `GEMINI_API_HARD_DISABLED=true` 預設硬封鎖。 - **V10.565 PChome 覆蓋率操作建議**: 補強 `/api/ai/pchome-match/backfill/status`,將低覆蓋率拆成 `operation_backlog`:刷新舊 identity、重評近門檻、補抓未配對、人工覆核、單位價覆核與過期搜尋救援預覽;並新增 `recommended_next_action`,Dashboard 狀態摘要會直接顯示建議下一步,避免使用者只看到低覆蓋率卻不知道該按哪條產線。 - **V10.563 正式 preview 假可救候選收斂**: 針對正式 `retryable_candidate_preview` 露出的 M.A.C 蜜粉與 SAUGELLA 菁萃潔浴凝露案例補 guard。M.A.C 單邊明確色號(如 `#絕絕紫`)會進 `variant_selection_review`,維持 `true_low_confidence`;SAUGELLA 潤澤 / 日用型 / 加強 / 黃金女郎型互斥,直接 hard veto,避免同品線不同私密清潔款式被當成 recoverable low_score。 diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index f61b153..e4679df 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -522,6 +522,8 @@ def _build_review_decision_envelope(item: dict[str, Any]) -> dict[str, Any]: "sku": str(item.get("sku") or ""), "name": item.get("name") or "", "event_type": "pchome_match_review", + "momo_price": momo_price, + "competitor_price": candidate_price, "competitor_product_id": item.get("candidate_pc_id") or "", "competitor_product_name": item.get("candidate_pc_name") or "", }, diff --git a/services/telegram_templates.py b/services/telegram_templates.py index 4c98974..e5b55c3 100644 --- a/services/telegram_templates.py +++ b/services/telegram_templates.py @@ -718,10 +718,207 @@ def _format_ea_escalation_alert( return "\n".join(lines) +def _numeric_value(value: Any) -> Optional[float]: + if value is None or value == "": + return None + try: + text = str(value).strip().replace(",", "") + text = text.replace("NT$", "").replace("$", "").replace("%", "") + return float(text) + except (TypeError, ValueError): + return None + + +def _format_money_value(value: Any) -> str: + number = _numeric_value(value) + if number is None or number <= 0: + return "" + if number == int(number): + return f"NT$ {int(number):,}" + return f"NT$ {number:,.2f}" + + +def _format_percent_value(value: Any) -> str: + number = _numeric_value(value) + if number is None: + text = str(value or "").strip() + return escape(text) if text else "" + return f"{number:+.1f}%" + + +def _evidence_items(envelope: Dict[str, Any]) -> List[Dict[str, Any]]: + raw_items = envelope.get("evidence") if isinstance(envelope.get("evidence"), list) else [] + return [item for item in raw_items if isinstance(item, dict)] + + +def _find_evidence(envelope: Dict[str, Any], metric: str) -> Optional[Dict[str, Any]]: + for item in _evidence_items(envelope): + if str(item.get("metric") or item.get("type") or "") == metric: + return item + return None + + +def _is_price_decision_envelope(envelope: Dict[str, Any]) -> bool: + decision_type = str(envelope.get("decision_type") or "").lower() + if decision_type in {"price_alert", "pchome_match_review", "competitor_price_review"}: + return True + subject = envelope.get("subject") if isinstance(envelope.get("subject"), dict) else {} + return bool( + subject.get("competitor_product_id") + or subject.get("competitor_price") + or subject.get("pchome_price") + or _find_evidence(envelope, "candidate_gap_pct") + or _find_evidence(envelope, "unit_price_gap_pct") + ) + + +def _action_label(action_code: str) -> str: + labels = { + "price_follow_review": "確認是否跟價或改用促銷防守", + "review_accept_identity": "人工確認同款後採納 identity", + "unit_price_required": "改用單位價覆核,不寫總價型價差", + "verify_or_reject_identity": "確認候選是否同款;非同款即駁回", + "compare_existing_identity": "比較既有正式 identity 與新候選", + "refresh_or_compare_identity": "刷新過期 identity 後再覆核", + "needs_research": "補搜尋或補證據後再判斷", + "human_review": "人工覆核", + } + return labels.get(action_code or "", action_code or "人工覆核") + + +def _format_price_decision_envelope(envelope: Dict[str, Any]) -> List[str]: + """將價格/競品決策信封排成可讀的專業 brief。""" + severity = escape(str(envelope.get("severity") or "info")) + decision_type = escape(str(envelope.get("decision_type") or "price_review")) + confidence = _numeric_value(envelope.get("confidence")) + subject = envelope.get("subject") if isinstance(envelope.get("subject"), dict) else {} + expected = envelope.get("expected_impact") if isinstance(envelope.get("expected_impact"), dict) else {} + guardrails = envelope.get("guardrails") if isinstance(envelope.get("guardrails"), dict) else {} + recommended_action = envelope.get("recommended_action") if isinstance(envelope.get("recommended_action"), dict) else {} + + data_quality = escape(str(guardrails.get("data_quality") or envelope.get("data_quality") or "unknown")) + can_auto_execute = bool(guardrails.get("can_auto_execute", False)) + blocked_reason = escape(str(guardrails.get("blocked_reason") or "")) + confidence_text = f" 信心度:{confidence:.0%}" if confidence is not None else "" + + lines = [ + "🧭 決策信封", + f"• 狀態:{decision_type} 等級:{severity}{confidence_text}", + f"• 資料品質:{data_quality} 自動執行:{'允許' if can_auto_execute else '不允許'}", + ] + if blocked_reason: + lines.append(f"• 邊界:{blocked_reason}") + + 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 subject.get("pchome_id") or "")) + competitor_name = escape(_short_text(subject.get("competitor_product_name") or subject.get("pchome_name") or "", 96)) + + target_lines = [] + if sku: + target_lines.append(f"• SKU:{sku}") + if name: + target_lines.append(f"• MOMO:{name}") + if competitor_id: + target_lines.append(f"• PChome:{competitor_id}") + if competitor_name: + target_lines.append(f"• 候選:{competitor_name}") + if target_lines: + lines += ["", "🎯 標的", *target_lines] + + momo_price = ( + subject.get("momo_price") + or expected.get("momo_price") + or (_find_evidence(envelope, "momo_price") or {}).get("value") + ) + competitor_price = ( + subject.get("competitor_price") + or subject.get("pchome_price") + or expected.get("competitor_price") + or expected.get("candidate_price") + or expected.get("pchome_price") + or (_find_evidence(envelope, "pchome_price") or {}).get("value") + ) + gap_pct = ( + expected.get("candidate_gap_pct") + if expected.get("candidate_gap_pct") is not None + else (_find_evidence(envelope, "candidate_gap_pct") or {}).get("value") + ) + gap_amount = expected.get("gap_amount") + + price_lines = [] + momo_price_text = _format_money_value(momo_price) + competitor_price_text = _format_money_value(competitor_price) + if momo_price_text or competitor_price_text: + price_lines.append( + f"• MOMO:{momo_price_text or '—'} PChome:{competitor_price_text or '—'}" + ) + gap_text = _format_percent_value(gap_pct) + gap_amount_text = _format_money_value(gap_amount) + if gap_text or gap_amount_text: + detail = [] + if gap_text: + detail.append(f"{gap_text}") + if gap_amount_text: + detail.append(gap_amount_text) + price_lines.append(f"• 價差:{' / '.join(detail)}(正值代表 MOMO 較貴)") + + unit_insight = expected.get("unit_price_insight") + if isinstance(unit_insight, dict) and unit_insight: + unit_summary = escape(_short_text(unit_insight.get("summary") or "", 120)) + if unit_summary: + price_lines.append(f"• 單位價:{unit_summary}") + if price_lines: + lines += ["", "📊 價格證據", *price_lines] + + evidence_lines = [] + match_evidence = _find_evidence(envelope, "match_score") + if match_evidence: + score = match_evidence.get("value") + basis = escape(str(match_evidence.get("basis") or "")) + score_text = escape(str(score if score is not None else "")) + evidence_lines.append(f"• Match:{score_text}" + (f" {basis}" if basis else "")) + reason_evidence = _find_evidence(envelope, "reasons") + if reason_evidence: + evidence_lines.append(f"• 診斷:{escape(_short_text(reason_evidence.get('value') or '', 130))}") + conflict = expected.get("existing_match_conflict") + if isinstance(conflict, dict) and conflict: + incoming = escape(str(conflict.get("incoming_product_id") or "unknown")) + existing = escape(str(conflict.get("existing_product_id") or "unknown")) + delta_value = _numeric_value(conflict.get("score_delta")) + delta = f"{delta_value:+.3f}" if delta_value is not None else "" + evidence_lines.append(f"• 既有保護:新候選 {incoming} vs 既有 {existing}" + (f" delta {delta}" if delta else "")) + if evidence_lines: + lines += ["", "🧩 比對證據", *evidence_lines] + + action_code = str(recommended_action.get("action") or "human_review") + owner = escape(str(recommended_action.get("owner") or "未指定")) + requires_hitl = bool(recommended_action.get("requires_hitl", True)) + lines += [ + "", + "✅ 人工下一步", + f"• {_action_label(action_code)}", + f"• 動作:{escape(action_code)} 負責:{owner} HITL:{'需要' if requires_hitl else '不需要'}", + ] + + trace = envelope.get("trace") + if isinstance(trace, dict): + trace_parts = [] + for key in ("ai_call_id", "insight_id", "action_plan_id", "source", "attempted_at", "model", "provider"): + if trace.get(key) is not None: + trace_parts.append(f"{key}={trace[key]}") + if trace_parts: + lines += ["", f"{escape(' | '.join(trace_parts))}"] + + return lines + [""] + + def _format_decision_envelope(envelope: Dict[str, Any]) -> List[str]: """將 12 Agent 共用決策信封轉成可審核的 Telegram 區塊。""" if not isinstance(envelope, dict) or not envelope: return [] + if _is_price_decision_envelope(envelope): + return _format_price_decision_envelope(envelope) severity = escape(str(envelope.get("severity") or "info")) decision_type = escape(str(envelope.get("decision_type") or "general")) diff --git a/tests/test_competitor_intel_cache.py b/tests/test_competitor_intel_cache.py index 26a0d2b..acb7152 100644 --- a/tests/test_competitor_intel_cache.py +++ b/tests/test_competitor_intel_cache.py @@ -255,6 +255,8 @@ def test_rescore_accepted_review_item_has_actionable_decision_envelope(): envelope = item["decision_envelope"] assert envelope["severity"] in {"P1", "P2"} + assert envelope["subject"]["momo_price"] == 99 + assert envelope["subject"]["competitor_price"] == 89 assert envelope["recommended_action"]["action"] == "review_accept_identity" assert envelope["guardrails"]["data_quality"] == "complete" assert envelope["expected_impact"]["gap_amount"] == 10 diff --git a/tests/test_event_router.py b/tests/test_event_router.py index 9c04a78..f9cf941 100644 --- a/tests/test_event_router.py +++ b/tests/test_event_router.py @@ -149,6 +149,8 @@ def test_dispatch_decision_envelope_skips_ai_handler(monkeypatch): "subject": { "sku": "SKU-1", "name": "測試精華液", + "momo_price": 120, + "competitor_price": 100, "competitor_product_id": "PC-1", "competitor_product_name": "PChome 測試精華液", }, @@ -167,6 +169,10 @@ def test_dispatch_decision_envelope_skips_ai_handler(monkeypatch): "data_quality": "complete", "blocked_reason": "價格調整需人工覆核", }, + "expected_impact": { + "gap_amount": 20, + "candidate_gap_pct": 20, + }, } result = asyncio.run(event_router.dispatch({ @@ -184,7 +190,14 @@ def test_dispatch_decision_envelope_skips_ai_handler(monkeypatch): assert len(sent) == 1 message, kwargs = sent[0] assert "🧭 決策信封" in message + assert "🎯 標的" in message + assert "📊 價格證據" in message + assert "🧩 比對證據" in message + assert "✅ 人工下一步" in message assert "SKU:SKU-1" in message + assert "MOMO:NT$ 120" in message + assert "PChome:NT$ 100" in message + assert "價差:+20.0% / NT$ 20" in message assert "PChome:PC-1" in message assert "exact/total_price/price_alert_exact" in message assert "legacy raw message" not in message