This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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 字典
|
||||
|
||||
|
||||
@@ -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 決策信封整合
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 "",
|
||||
},
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user