V10.568 優化價格決策信封通知
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s

This commit is contained in:
OoO
2026-06-02 10:47:09 +08:00
parent d90f96fab3
commit 97e7e2843b
8 changed files with 218 additions and 1 deletions

View File

@@ -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 # 用於模板顯示

View File

@@ -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 字典

View File

@@ -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` 500GCP 不可用時直接保守降級,避免 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 決策信封整合

View File

@@ -13,6 +13,7 @@
## 📅 詳細更新日誌 (考古存檔)
### 2026-06-01PChome 比價新鮮度操作閉環
- **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。

View File

@@ -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 "",
},

View File

@@ -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" 信心度:<b>{confidence:.0%}</b>" if confidence is not None else ""
lines = [
"🧭 <b>決策信封</b>",
f"• 狀態:<code>{decision_type}</code> 等級:<b>{severity}</b>{confidence_text}",
f"• 資料品質:<code>{data_quality}</code> 自動執行:<b>{'允許' if can_auto_execute else '不允許'}</b>",
]
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<code>{sku}</code>")
if name:
target_lines.append(f"• MOMO{name}")
if competitor_id:
target_lines.append(f"• PChome<code>{competitor_id}</code>")
if competitor_name:
target_lines.append(f"• 候選:{competitor_name}")
if target_lines:
lines += ["", "🎯 <b>標的</b>", *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<b>{momo_price_text or ''}</b> PChome<b>{competitor_price_text or ''}</b>"
)
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"<b>{gap_text}</b>")
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 += ["", "📊 <b>價格證據</b>", *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<code>{score_text}</code>" + (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"• 既有保護:新候選 <code>{incoming}</code> vs 既有 <code>{existing}</code>" + (f" delta {delta}" if delta else ""))
if evidence_lines:
lines += ["", "🧩 <b>比對證據</b>", *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 += [
"",
"✅ <b>人工下一步</b>",
f"{_action_label(action_code)}",
f"• 動作:<code>{escape(action_code)}</code> 負責:<b>{owner}</b> HITL<b>{'需要' if requires_hitl else '不需要'}</b>",
]
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"<code>{escape(' | '.join(trace_parts))}</code>"]
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"))

View File

@@ -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

View File

@@ -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 "🧭 <b>決策信封</b>" in message
assert "🎯 <b>標的</b>" in message
assert "📊 <b>價格證據</b>" in message
assert "🧩 <b>比對證據</b>" in message
assert "✅ <b>人工下一步</b>" in message
assert "SKU<code>SKU-1</code>" in message
assert "MOMO<b>NT$ 120</b>" in message
assert "PChome<b>NT$ 100</b>" in message
assert "價差:<b>+20.0%</b> / NT$ 20" 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