diff --git a/config.py b/config.py
index 26c7919..0faaf27 100644
--- a/config.py
+++ b/config.py
@@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
-SYSTEM_VERSION = "V10.386"
+SYSTEM_VERSION = "V10.387"
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 512a649..9fbf000 100644
--- a/docs/AI_INTELLIGENCE_MODULE_SOT.md
+++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md
@@ -2,7 +2,7 @@
> **最後更新**: 2026-05-21 (台北時間)
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 備援預設關閉
-> **適用版本**: V10.386
+> **適用版本**: V10.387
---
@@ -29,6 +29,7 @@
- Gemini 不可被任何狀態面板或 router 推薦為主提供者:`AIProviderService._get_recommended_provider()` 不得回傳 `gemini`,只能顯示為 fallback 狀態;`llm_model_router` 的 `ea_engine` 若收到 `gemini-*` default 必須改回 `hermes3:latest`,需要深推理時才升本地 `deepseek-r1:14b`。
- ElephantAlpha prompt / agent registry 不得再把 OpenClaw 描述為 Gemini 主模型;OpenClaw 是 `qwen2.5-coder:7b` / `qwen3:14b` Ollama-first 策略師,Gemini 僅能在 guard 顯式解鎖後作 emergency fallback。
- 111 `192.168.0.111` 只是最後一道 Mac fallback,不承接 7B+、vision、long-context 模型長駐;`OllamaService.generate()` 落到 111 時會將 `qwen3`、`deepseek-r1`、`hermes3`、`qwen2.5*`、`gemma3`、`llava`、`minicpm-v` 與 7B+ 模型依 `OLLAMA_111_MODEL_DOWNGRADE_PATTERNS` 降級到 `OLLAMA_111_MODEL_FALLBACK=llama3.2:latest`,並以 `OLLAMA_111_KEEP_ALIVE=5m`、`OLLAMA_111_MAX_TIMEOUT=20`、`OLLAMA_111_NUM_CTX=4096`、`OLLAMA_111_NUM_PREDICT=512` 封頂,避免 16GB RAM 主機被大 context runner、長輸出與 24h keep-alive 壓到 swap。
+- ElephantAlpha 的 `price_drop_alert` / `market_opportunity` Telegram HITL 告警必須把同款證據獨立呈現,至少包含 `match_type`、`price_basis`、`alert_tier` 與 `match_score`;沒有高信心同款與總價可比證據時,不得把 PChome/MOMO 價差寫成可直接跟價建議。
## 一、四 AI Agent 路由架構
diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md
index 31faf1c..6002436 100644
--- a/docs/memory/history_logs.md
+++ b/docs/memory/history_logs.md
@@ -13,6 +13,7 @@
## 📅 詳細更新日誌 (考古存檔)
### 2026-05-21:瀏覽器測試守門與 PChome 熱路徑優化
+- **V10.387 EA 比價 HITL 告警證據排版**: Elephant Alpha 的 DB evidence 與 Hermes pre-fetch action 現在會把 PChome/MOMO 同款證據帶進 Telegram:`match_type`、`price_basis`、`alert_tier` 與 `match_score` 會獨立成「證據」行,讓人工審核能分辨高信心同款、總價可比、單位價覆核與身份覆核,不再只看到乾巴巴的 `MOMO vs PChome` 長句。同版 marketplace matcher 補 Relove「私密潔淨凝露」identity anchor 與聯名款搜尋噪音,避免 PLAY BOY / 小虎等活動詞壓過真同款名稱。
- **V10.386 Gemini compose hard default / KATE 唇膏比對**: `docker-compose.yml` 針對 `momo-app`、`scheduler`、`telegram-bot` 明確釘住 `GEMINI_API_HARD_DISABLED=true` 與 `GEMINI_FALLBACK_ENABLED=false` 的預設,讓 `.env` 保留 API key 時也不會自動產生 Gemini 付費出站;AI SOT 與 compose 測試同步鎖定此契約。同版 marketplace matcher 補 KATE/凱婷「柔霧裸唇膏」identity anchor,避免 MOMO 長標含東京夜喫茶系列與任選文案時漏掉 PChome 同款短標。
- **V10.385 Lactacyd / MAQuillAGE 櫃別同款比對**: Marketplace matcher 補強 Lactacyd 私密潔浴露多款任選長標與 PChome 短標同款放行,並橋接「資生堂東京櫃」與 `MAQuillAGE 心機彩妝` 在「心機星魅蜜光圈潤唇膏」上的櫃別/品牌 alias,避免真同款被 `brand_conflict` 擋掉。
- **V10.384 Karadium 無規格眼影棒同款放行**: Marketplace matcher 對 Karadium「閃亮珍珠眼影棒」新增品牌 + 強 identity anchor 加分,當 PChome 標題省略 1.4g 規格但品名/品牌高度一致、無變體衝突與 hard veto 時仍可進入 exact identity 告警候選,避免同款因規格缺字漏報。
diff --git a/services/elephant_alpha_autonomous_engine.py b/services/elephant_alpha_autonomous_engine.py
index e6e0bfc..5336626 100644
--- a/services/elephant_alpha_autonomous_engine.py
+++ b/services/elephant_alpha_autonomous_engine.py
@@ -35,6 +35,26 @@ from database.manager import get_db_manager, get_session
logger = SystemLogger("ElephantAlphaEngine").get_logger()
+COMPETITOR_MATCH_TYPE_LABELS = {
+ "exact": "高信心同款",
+ "same_product_different_pack": "同商品不同包裝",
+ "same_line_variant": "同系列不同款",
+ "comparable": "可比但需覆核",
+ "no_match": "非同款",
+}
+COMPETITOR_PRICE_BASIS_LABELS = {
+ "total_price": "總價可比",
+ "unit_price": "單位價可比",
+ "manual_review": "人工覆核後可比",
+ "none": "不可比",
+}
+COMPETITOR_ALERT_TIER_LABELS = {
+ "price_alert_exact": "可直接價格告警",
+ "unit_price_review": "單位價覆核",
+ "identity_review": "身份覆核",
+ "suppress": "不告警",
+}
+
# ---- Configuration ----
SSH_JUMP_HOST = os.getenv("ELEPHANT_ALPHA_JUMP_HOST", "192.168.0.110")
SSH_JUMP_USER = os.getenv("ELEPHANT_ALPHA_JUMP_USER", "wooo")
@@ -1204,6 +1224,74 @@ class ElephantAlphaAutonomousEngine:
self._log.error("Resource pressure Telegram failed (non-blocking): %s", e)
# ---- Sub-services ----
+ @classmethod
+ def _jsonish_dict(cls, value: Any) -> Dict[str, Any]:
+ if isinstance(value, dict):
+ return value
+ if isinstance(value, str) and value.strip():
+ try:
+ parsed = json.loads(value)
+ return parsed if isinstance(parsed, dict) else {}
+ except Exception:
+ return {}
+ return {}
+
+ @classmethod
+ def _jsonish_list(cls, value: Any) -> List[str]:
+ if isinstance(value, list):
+ return [str(item) for item in value]
+ if isinstance(value, tuple):
+ return [str(item) for item in value]
+ if isinstance(value, str) and value.strip():
+ try:
+ parsed = json.loads(value)
+ if isinstance(parsed, list):
+ return [str(item) for item in parsed]
+ except Exception:
+ return []
+ return []
+
+ @classmethod
+ def _tag_suffix(cls, tags: List[str], prefix: str) -> str:
+ marker = f"{prefix}_"
+ for tag in tags or []:
+ text = str(tag)
+ if text.startswith(marker):
+ return text.removeprefix(marker)
+ return ""
+
+ @classmethod
+ def _format_competitor_match_evidence(cls, row: Any) -> str:
+ diagnostic = cls._jsonish_dict(cls._row_get(row, "match_diagnostic_json"))
+ tags = cls._jsonish_list(cls._row_get(row, "tags"))
+ match_type = (
+ diagnostic.get("match_type")
+ or cls._tag_suffix(tags, "match_type")
+ or "exact"
+ )
+ price_basis = (
+ diagnostic.get("price_basis")
+ or cls._tag_suffix(tags, "price_basis")
+ or "total_price"
+ )
+ alert_tier = (
+ diagnostic.get("alert_tier")
+ or cls._tag_suffix(tags, "alert_tier")
+ or "price_alert_exact"
+ )
+ match_score = cls._to_float(cls._row_get(row, "match_score"))
+ if match_score is None:
+ match_score = cls._to_float(diagnostic.get("score"))
+
+ parts = [
+ COMPETITOR_MATCH_TYPE_LABELS.get(str(match_type), str(match_type)),
+ COMPETITOR_PRICE_BASIS_LABELS.get(str(price_basis), str(price_basis)),
+ COMPETITOR_ALERT_TIER_LABELS.get(str(alert_tier), str(alert_tier)),
+ ]
+ if match_score is not None and match_score > 0:
+ parts.append(f"score {match_score:.2f}")
+ return "證據:" + " / ".join(part for part in parts if part)
+
@classmethod
def _format_competitor_evidence_actions(
cls,
@@ -1236,7 +1324,13 @@ class ElephantAlphaAutonomousEngine:
action = "建議人工確認 PChome identity_v2 後評估跟價或促銷"
impact = f"每件價差 NT$ {gap_amount:,.0f}"
- parts = [f"[{sku}] {name}", comparison, impact, action]
+ parts = [
+ f"[{sku}] {name}",
+ comparison,
+ impact,
+ cls._format_competitor_match_evidence(row),
+ action,
+ ]
if competitor_id:
parts.append(f"PChome {competitor_id}")
actions.append("|".join(parts))
@@ -1277,6 +1371,9 @@ class ElephantAlphaAutonomousEngine:
cp.price AS competitor_price,
cp.competitor_product_id,
cp.competitor_product_name,
+ cp.match_score,
+ cp.tags,
+ cp.match_diagnostic_json,
cp.crawled_at
FROM competitor_prices cp
WHERE cp.source = 'pchome'
@@ -1294,6 +1391,9 @@ class ElephantAlphaAutonomousEngine:
((lm.momo_price - lc.competitor_price) / NULLIF(lm.momo_price, 0) * 100) AS price_gap_pct,
lc.competitor_product_id,
lc.competitor_product_name,
+ lc.match_score,
+ lc.tags,
+ lc.match_diagnostic_json,
lc.crawled_at
FROM latest_momo lm
JOIN latest_competitor lc ON lc.sku = lm.sku
@@ -1388,6 +1488,15 @@ class ElephantAlphaAutonomousEngine:
parts = [
f"[{sku}] {name}",
f"MOMO ${momo:,.0f} vs PChome ${pchome:,.0f} ({gap_pct:+.1f}%)",
+ self._format_competitor_match_evidence({
+ "match_score": getattr(t, "match_score", 0),
+ "tags": list(getattr(t, "competitor_tags", ()) or ()),
+ "match_diagnostic_json": {
+ "match_type": getattr(t, "match_type", "exact"),
+ "price_basis": getattr(t, "price_basis", "total_price"),
+ "alert_tier": getattr(t, "alert_tier", "price_alert_exact"),
+ },
+ }),
]
if loss > 0:
parts.append(f"近 7 日流失 NT$ {loss:,.0f}")
diff --git a/services/marketplace_product_matcher.py b/services/marketplace_product_matcher.py
index 3e94d0a..1e8d396 100644
--- a/services/marketplace_product_matcher.py
+++ b/services/marketplace_product_matcher.py
@@ -290,6 +290,14 @@ SEARCH_NOISE_TOKENS = {
"第三代",
"經典版",
"版白",
+ "限量聯名款",
+ "play",
+ "boy",
+ "小虎",
+ "啾啾妹",
+ "煎妮花",
+ "涼感潔淨",
+ "私密處清潔",
"溫和不乾澀",
"寶寶共和國",
"三款",
@@ -305,6 +313,7 @@ SEARCH_NOISE_TOKENS = {
}
SEARCH_IDENTITY_ANCHORS = (
+ "私密潔淨凝露",
"柔霧裸唇膏",
"潤浸保濕清爽身體乳液",
"閃亮珍珠眼影棒",
@@ -1841,6 +1850,20 @@ def score_marketplace_match(
):
score += 0.10
reasons.append("shared_identity_anchor_lactacyd_wash")
+ if (
+ "私密潔淨凝露" in shared_anchor
+ and {"relove"} <= (left.brand_tokens | right.brand_tokens)
+ and brand_score >= 0.95
+ and not hard_veto
+ and price_penalty == 0
+ and type_score >= 0.95
+ and spec_score >= 0.85
+ and token_score >= 0.30
+ and sequence_score >= 0.40
+ and not variant_descriptor_conflict
+ ):
+ score += 0.11
+ reasons.append("shared_identity_anchor_relove_cleanser")
if (
"柔霧裸唇膏" in shared_anchor
and {"kate", "凱婷"} & (left.brand_tokens | right.brand_tokens)
diff --git a/services/telegram_templates.py b/services/telegram_templates.py
index 2aaa4c2..b76cd91 100644
--- a/services/telegram_templates.py
+++ b/services/telegram_templates.py
@@ -527,6 +527,8 @@ def _parse_ea_action(action: Any) -> Dict[str, Any]:
item["pchome_id"] = part.replace("PChome", "", 1).strip()
elif part.startswith("建議"):
item["action"] = part
+ elif part.startswith("證據"):
+ item["evidence"] = part.replace("證據", "", 1).strip(" ::")
elif "NT$" in part or "流失" in part or "價差" in part or "價格優勢" in part:
item["impact"] = part
amount = re.search(r"NT\$\s*([0-9,]+)", part)
@@ -590,6 +592,10 @@ def _format_ea_action_card(item: Dict[str, Any], index: int) -> List[str]:
else:
lines.append(f" 影響:{impact_text}")
+ evidence = item.get("evidence")
+ if evidence:
+ lines.append(f" 證據:{escape(_short_text(evidence, 96))}")
+
action = str(item.get("action") or "").replace("建議", "", 1).strip(" ::")
if action:
lines.append(f" 動作:{escape(_short_text(action, 86))}")
diff --git a/tests/test_elephant_alpha_engine.py b/tests/test_elephant_alpha_engine.py
index d081f17..25d97b1 100644
--- a/tests/test_elephant_alpha_engine.py
+++ b/tests/test_elephant_alpha_engine.py
@@ -128,13 +128,20 @@ def test_competitor_db_evidence_actions_are_concrete():
"competitor_price": 990,
"price_gap_pct": 17.5,
"competitor_product_id": "D123456",
+ "match_score": 0.84,
+ "tags": [
+ "identity_v2",
+ "match_type_exact",
+ "price_basis_total_price",
+ "alert_tier_price_alert_exact",
+ ],
}
],
trigger_type="price_drop_alert",
)
assert actions == [
- "[SKU-1] 測試商品|MOMO $1,200 vs PChome $990 (+17.5%)|每件價差 NT$ 210|建議人工確認 PChome identity_v2 後評估跟價或促銷|PChome D123456"
+ "[SKU-1] 測試商品|MOMO $1,200 vs PChome $990 (+17.5%)|每件價差 NT$ 210|證據:高信心同款 / 總價可比 / 可直接價格告警 / score 0.84|建議人工確認 PChome identity_v2 後評估跟價或促銷|PChome D123456"
]
diff --git a/tests/test_marketplace_product_matcher.py b/tests/test_marketplace_product_matcher.py
index f7a380b..2f93c80 100644
--- a/tests/test_marketplace_product_matcher.py
+++ b/tests/test_marketplace_product_matcher.py
@@ -593,6 +593,25 @@ def test_marketplace_matcher_promotes_lactacyd_private_wash_multi_option_title()
assert "shared_identity_anchor_lactacyd_wash" in diagnostics.reasons
+def test_marketplace_matcher_promotes_relove_private_cleanser_campaign_title():
+ from services.marketplace_product_matcher import score_marketplace_match, build_search_terms
+
+ source = "【Relove】胺基酸私密潔淨凝露120ml-限量聯名款(小虎/啾啾妹/煎妮花/PLAY BOY私密處清潔 涼感潔淨)"
+ diagnostics = score_marketplace_match(
+ source,
+ "RELOVE金盞花萃取低敏私密潔淨凝露 120ml",
+ momo_price=629,
+ competitor_price=629,
+ )
+ terms = build_search_terms(source, max_terms=4)
+
+ assert diagnostics.score >= 0.76
+ assert diagnostics.hard_veto is False
+ assert "shared_identity_anchor_relove_cleanser" in diagnostics.reasons
+ assert "play" not in terms[0].lower()
+ assert "boy" not in terms[0].lower()
+
+
def test_marketplace_matcher_bridges_maquillage_shiseido_counter_alias():
from services.marketplace_product_matcher import score_marketplace_match
diff --git a/tests/test_telegram_triaged_alert_format.py b/tests/test_telegram_triaged_alert_format.py
index 3fc56a9..dc55eca 100644
--- a/tests/test_telegram_triaged_alert_format.py
+++ b/tests/test_telegram_triaged_alert_format.py
@@ -76,11 +76,13 @@ def test_ea_escalation_uses_structured_incident_brief():
"[5900068] [derma Angel 護妍天使] 集中抗痘精華|"
"MOMO $300 vs PChome $250 (+16.7%)|"
"每件價差 NT$ 50|"
+ "證據:高信心同款 / 總價可比 / 可直接價格告警 / score 0.86|"
"建議人工確認 PChome identity_v2 後評估跟價或促銷|"
"PChome DABC53-A9009OEF",
"[3518670] L'Occitane 歐舒丹 官方直營 乳油木|"
"MOMO $1,220 vs PChome $850 (+30.3%)|"
"每件價差 NT$ 370|"
+ "證據:高信心同款 / 總價可比 / 可直接價格告警 / score 0.91|"
"建議人工確認 PChome identity_v2 後評估跟價或促銷|"
"PChome DDADKS-A900HIG5Y",
],
@@ -95,6 +97,7 @@ def test_ea_escalation_uses_structured_incident_brief():
assert "• 最大單件價差:NT$ 370" in msg
assert "1. [5900068]" in msg
assert "MOMO:$300 PChome:$250" in msg
+ assert "證據:高信心同款 / 總價可比 / 可直接價格告警 / score 0.86" in msg
assert "PChome:DABC53-A9009OEF" in msg
assert " • [5900068]" not in msg
assert keyboard["inline_keyboard"][0][0]["callback_data"] == "momo:eig:ea_review_test"