All checks were successful
CD Pipeline / deploy (push) Successful in 1m35s
## Telegram Bot 功能修復 - 補全 16 個 await: 按鈕的 handler(日期選擇/目標設定/促銷追蹤等), 新增 _handle_await_callback + _process_await_input 完整狀態機 - cmd: 按鈕加入 ⏳ 即時回饋 + try/except 防 BadRequest - handle_callback 加頂層 try/except 錯誤兜底 - 補 momo:cmd:suggestion + momo:menu:main callback handler - 修復 _enhanced_keyword_matching context NameError ## AI 模型遷移(hermes3@111 → qwen2.5@188) - hermes_analyst_service: URL 192.168.0.111→188, hermes3→qwen2.5:7b-instruct - code_review_pipeline: 改用 HERMES_URL/HERMES_MODEL 常數 - elephant_alpha_orchestrator / nemoton_dispatcher: registry/footprint 同步 - aider_heal_executor: OLLAMA_API_BASE fallback 改 188 - ai_routes: footprint display 字串改 qwen2.5:7b-instruct ## ElephantAlpha 404 修復 - elephant_service: openrouter→NVIDIA NIM, nvidia/llama-3.1-nemotron-ultra-253b-v1 - ai_provider: 模型 ID 同步更新 ## TELEGRAM_CHAT_ID 環境變數修正 - cicd_routes + aider_heal_executor: 優先讀 TELEGRAM_CHAT_IDS[0], fallback TELEGRAM_CHAT_ID,修復通知靜默失敗 ## AI 對話 logging 改善 - telegram_ai_integration: Hermes 降級改 WARNING,OpenClaw 失敗加 exc_info - hermes_analyst_service: 連線失敗 log 加 host/model context ## DB Schema 修復 - migrations/019: action_plans 補齊全欄位,DROP NOT NULL action_type - autoheal_models: ActionPlan ORM 同步為超集 schema Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
545 lines
25 KiB
Python
545 lines
25 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Hermes 3 競價情報分析服務 (Module 2)
|
||
|
||
角色:分析師 (Analyst)
|
||
模型:qwen2.5:7b-instruct @ 192.168.0.188:11434
|
||
輸入:SQL 漏斗篩選後的候選商品(~300筆)
|
||
輸出:Top N 威脅清單(結構化 JSON)→ 交給 NemoTron dispatcher
|
||
|
||
架構位置:
|
||
SQL漏斗 → [本服務] → NemotronDispatcher → Telegram 告警
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import re
|
||
import time
|
||
from dataclasses import dataclass
|
||
from typing import Optional
|
||
|
||
import requests
|
||
from sqlalchemy import text
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
HERMES_MODEL = "qwen2.5:7b-instruct"
|
||
HERMES_URL = "http://192.168.0.188:11434"
|
||
HERMES_TIMEOUT = 120 # 秒,批量 300 筆最長預估 ~90s
|
||
TOP_N = 20 # 輸出前 N 個威脅,控制 NemoTron 每次消耗配額
|
||
|
||
|
||
@dataclass
|
||
class PriceThreat:
|
||
sku: str
|
||
name: str
|
||
category: str
|
||
momo_price: float
|
||
pchome_price: float
|
||
gap_pct: float # 正值代表我貴
|
||
sales_7d_delta_pct: float
|
||
risk: str # HIGH / MED / LOW
|
||
recommended_action: str
|
||
confidence: float
|
||
|
||
|
||
@dataclass
|
||
class AnalysisResult:
|
||
success: bool
|
||
threats: list
|
||
total_candidates: int
|
||
analysis_duration_sec: float
|
||
hermes_tokens: int = 0 # Ollama eval_count(供 footprint 顯示)
|
||
error: Optional[str] = None
|
||
|
||
|
||
class HermesAnalystService:
|
||
"""
|
||
競價情報分析師
|
||
負責從 DB 撈候選商品、交給 Hermes 3 分析、回傳結構化威脅清單
|
||
"""
|
||
|
||
SYSTEM_PROMPT = """你是一位台灣電商競價情報分析師。
|
||
規則:
|
||
1. 輸出「只能」是有效的 JSON 陣列,禁止任何前言或解釋文字
|
||
2. recommended_action 必須使用「台灣標準正體中文(繁體)」。
|
||
【語言鐵律 — 2026-04-18 台北強化】
|
||
a. 嚴禁簡體字(例:不可用「参给当为来国发会说时间过从实现这话动问题」,
|
||
必須用「參給當為來國發會說時間過從實現這話動問題」)
|
||
b. 嚴禁異體字(例:不可用「亊」,必須用「事」)
|
||
c. 嚴禁短語重複(例:不可輸出「當前事當前事」這種坍塌)
|
||
d. 嚴禁無意義字元組合或亂碼
|
||
e. 若無法產出合理的繁體中文說明,直接輸出「建議人工評估」
|
||
3. 風險等級判定:
|
||
- HIGH:價差 > 15% 且 7天銷量跌幅 > 20%
|
||
- MED:價差 > 10% 或 7天銷量跌幅 > 15%
|
||
- LOW:其他
|
||
4. confidence 根據數據確定性給分(0.0~1.0)
|
||
5. 【防幻覺鐵律】絕對禁止捏造輸入資料中未提供的數據(如折扣%、促銷活動、隱藏優惠)。
|
||
只能基於 gap_pct、sales_delta、competitor_tags 等已提供欄位做推論。
|
||
6. 【非價格異常路由】若 gap_pct 絕對值 < 5% 但 sales_delta < -30%:
|
||
- 判定為「非價格因素異常」(高機率:缺貨、下架、平台流量異常、頁面問題)
|
||
- risk 設為 MED,recommended_action 必須寫「價差接近零但業績異常下滑,建議立即人工走查前台頁面(確認是否缺貨/下架/頁面異常)」
|
||
- confidence 設為 0.5(因缺乏確切原因)"""
|
||
|
||
def __init__(self, engine=None):
|
||
self.engine = engine # SQLAlchemy engine,可外部注入
|
||
|
||
# ──────────────────────────────────────────────
|
||
# L1 意圖分析介面(給 EventRouter / Telegram NLP 使用)
|
||
# ──────────────────────────────────────────────
|
||
async def handle_l1(self, event: dict, ctx: dict) -> dict:
|
||
"""L1 語意理解:輸入 event(含 message),輸出 intent / complexity_score / requires_data_fetch。
|
||
|
||
Contract:
|
||
event: {"message": str, "user_id": int, "chat_id": int, ...}
|
||
ctx: 既有 agent_context(可為空 dict)
|
||
Returns:
|
||
{
|
||
"intent": str, # greeting / help / query_sales / analyze_competitor / report / unknown
|
||
"confidence": float,
|
||
"complexity_score": float, # 0.0~1.0
|
||
"requires_data_fetch": bool,
|
||
"preliminary_answer": str,
|
||
"metadata": dict,
|
||
}
|
||
|
||
實作策略:
|
||
1) 先用 Hermes3 LLM 做意圖分類(timeout 短、temp 低)
|
||
2) Ollama 掛掉時降級為關鍵字規則引擎,保證永遠回傳結構化結果
|
||
3) 任何例外都被 graceful 吞掉,不會讓 handle_message 整個炸
|
||
"""
|
||
message = (event or {}).get("message", "") or ""
|
||
llm_result = None
|
||
try:
|
||
llm_result = await asyncio.get_running_loop().run_in_executor(
|
||
None, self._call_hermes_intent, message
|
||
)
|
||
except Exception as e:
|
||
logger.error(
|
||
f"[Hermes.handle_l1] run_in_executor 例外,降級規則引擎"
|
||
f"({type(e).__name__}: {e})",
|
||
exc_info=True,
|
||
)
|
||
|
||
if llm_result:
|
||
return llm_result
|
||
return self._rule_based_intent(message)
|
||
|
||
def _call_hermes_intent(self, message: str) -> Optional[dict]:
|
||
"""呼叫 Hermes3 做輕量意圖分類(非批量分析)。失敗回 None 由上層降級。"""
|
||
if not message.strip():
|
||
return self._rule_based_intent(message)
|
||
|
||
system = (
|
||
"你是一位繁體中文意圖分類器。讀取使用者訊息,輸出單行 JSON,"
|
||
"嚴禁任何解釋文字與 markdown 標記。欄位:\n"
|
||
'{"intent": "greeting|help|query_sales|analyze_competitor|'
|
||
'report|product_info|unknown", "confidence": 0.0~1.0, '
|
||
'"complexity_score": 0.0~1.0, "requires_data_fetch": true|false, '
|
||
'"preliminary_answer": "若為 greeting/help 請回一句繁中問候,否則空字串"}\n'
|
||
"規則:greeting/help 類 complexity_score<=0.3;涉及數據、報告、日期、"
|
||
"品牌、競品對比者 complexity_score>=0.7 且 requires_data_fetch=true。"
|
||
)
|
||
payload = {
|
||
"model": HERMES_MODEL,
|
||
"system": system,
|
||
"prompt": f"使用者訊息:{message}\n輸出 JSON:",
|
||
"stream": False,
|
||
"options": {"temperature": 0.1},
|
||
}
|
||
try:
|
||
resp = requests.post(
|
||
f"{HERMES_URL}/api/generate",
|
||
json=payload,
|
||
timeout=15, # 意圖分類不必等 120s
|
||
)
|
||
resp.raise_for_status()
|
||
raw = (resp.json().get("response", "") or "").strip()
|
||
if raw.startswith("```"):
|
||
raw = re.sub(r"^```(?:json)?\s*", "", raw, flags=re.MULTILINE)
|
||
raw = re.sub(r"\s*```\s*$", "", raw.strip(), flags=re.MULTILINE).strip()
|
||
data = json.loads(raw)
|
||
return {
|
||
"intent": data.get("intent", "unknown"),
|
||
"confidence": float(data.get("confidence", 0.5)),
|
||
"complexity_score": float(data.get("complexity_score", 0.5)),
|
||
"requires_data_fetch": bool(data.get("requires_data_fetch", False)),
|
||
"preliminary_answer": data.get("preliminary_answer", "") or "",
|
||
"metadata": {"source": "hermes_llm"},
|
||
}
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"[Hermes.intent] Ollama 連線失敗,降級規則引擎"
|
||
f"(host={HERMES_URL} model={HERMES_MODEL} error={type(e).__name__}: {e})"
|
||
)
|
||
return None
|
||
|
||
def _rule_based_intent(self, message: str) -> dict:
|
||
"""Ollama 掛掉時的規則引擎 fallback — 永遠返回結構化結果。"""
|
||
msg = (message or "").lower().strip()
|
||
if not msg:
|
||
return {
|
||
"intent": "unknown", "confidence": 0.3, "complexity_score": 0.1,
|
||
"requires_data_fetch": False, "preliminary_answer": "",
|
||
"metadata": {"source": "rule_empty"},
|
||
}
|
||
greetings = ("hi", "hello", "哈囉", "你好", "嗨", "早安", "午安", "晚安")
|
||
helps = ("help", "幫助", "說明", "怎麼用", "功能")
|
||
data_keywords = (
|
||
"業績", "銷量", "銷售", "營收", "排名", "威脅", "競品", "報表",
|
||
"週報", "月報", "日報", "2024", "2025", "2026",
|
||
"ppt", "report", "compare", "vs", "分析", "趨勢",
|
||
)
|
||
if any(g in msg for g in greetings):
|
||
return {
|
||
"intent": "greeting", "confidence": 0.9, "complexity_score": 0.1,
|
||
"requires_data_fetch": False,
|
||
"preliminary_answer": "您好!我是 MOMO Pro 智能助理,請問需要什麼協助?",
|
||
"metadata": {"source": "rule"},
|
||
}
|
||
if any(h in msg for h in helps):
|
||
return {
|
||
"intent": "help", "confidence": 0.85, "complexity_score": 0.2,
|
||
"requires_data_fetch": False,
|
||
"preliminary_answer": "我可以協助您查詢業績、商品、競品情報與產出報告。",
|
||
"metadata": {"source": "rule"},
|
||
}
|
||
if any(k in msg for k in data_keywords):
|
||
return {
|
||
"intent": "query_sales", "confidence": 0.7, "complexity_score": 0.8,
|
||
"requires_data_fetch": True, "preliminary_answer": "",
|
||
"metadata": {"source": "rule"},
|
||
}
|
||
return {
|
||
"intent": "unknown", "confidence": 0.4, "complexity_score": 0.5,
|
||
"requires_data_fetch": False, "preliminary_answer": "",
|
||
"metadata": {"source": "rule"},
|
||
}
|
||
|
||
# ──────────────────────────────────────────────
|
||
# Step 1:SQL 漏斗 — 從 226萬筆壓縮到 ~300 筆候選
|
||
# ──────────────────────────────────────────────
|
||
def _validate_snapshot_columns(self) -> tuple:
|
||
"""
|
||
校驗 daily_sales_snapshot 欄位,回傳 (商品名稱欄位, 銷售金額欄位)
|
||
如果找不到,則 raise ValueError 避免 SQL 靜默失敗
|
||
"""
|
||
with self.engine.connect() as conn:
|
||
res = conn.execute(text("SELECT * FROM daily_sales_snapshot LIMIT 0"))
|
||
cols = list(res.keys())
|
||
|
||
def find_col(keywords):
|
||
for kw in keywords:
|
||
for col in cols:
|
||
if kw in col:
|
||
return col
|
||
return None
|
||
|
||
name_col = find_col(['商品名稱', '品名', 'Name', 'Product'])
|
||
sales_col = find_col(['銷售金額', '業績', '金額', 'Amount', 'Sales', 'Total', '總業績'])
|
||
|
||
if not name_col or not sales_col:
|
||
raise ValueError(f"daily_sales_snapshot 缺少必要欄位!目前欄位: {cols}")
|
||
|
||
return name_col, sales_col
|
||
|
||
def fetch_candidates(self) -> list:
|
||
"""
|
||
競價威脅候選漏斗(v2 — 直接 LEFT JOIN competitor_prices)
|
||
|
||
條件:近7天銷量下滑 > 10% 且 MOMO 自家有歷史價格紀錄
|
||
擴充:LEFT JOIN competitor_prices(PChome 快取,TTL 6h)
|
||
帶回 pchome_price + competitor_tags 供 Hermes 情境分析
|
||
|
||
無競品資料的商品仍回傳,pchome_price=NULL → _batch_analyze 跳過
|
||
"""
|
||
if self.engine is None:
|
||
raise RuntimeError("需要注入 SQLAlchemy engine")
|
||
|
||
name_col, sales_col = self._validate_snapshot_columns()
|
||
|
||
# 注意:products.i_code(MOMO商品頁URL碼,純數字)與
|
||
# daily_sales_snapshot.商品ID(訂單目錄碼,英數格式)是不同 ID 系統,
|
||
# 無法直接 JOIN。改以商品名稱(商品名稱 = p.name)做橋接。
|
||
sql_str = f"""
|
||
WITH latest_momo_price AS (
|
||
SELECT
|
||
p.i_code AS sku,
|
||
p.name,
|
||
p.category,
|
||
pr.price AS momo_price,
|
||
ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pr.timestamp DESC) AS rn
|
||
FROM products p
|
||
JOIN price_records pr ON pr.product_id = p.id
|
||
WHERE p.status = 'ACTIVE'
|
||
),
|
||
recent_sales AS (
|
||
SELECT
|
||
"{name_col}" AS product_name,
|
||
SUM(CASE WHEN snapshot_date::date >= CURRENT_DATE - 7
|
||
THEN COALESCE("{sales_col}"::numeric, 0) ELSE 0 END) AS sales_7d_curr,
|
||
SUM(CASE WHEN snapshot_date::date >= CURRENT_DATE - 14
|
||
AND snapshot_date::date < CURRENT_DATE - 7
|
||
THEN COALESCE("{sales_col}"::numeric, 0) ELSE 0 END) AS sales_7d_prev
|
||
FROM daily_sales_snapshot
|
||
GROUP BY "{name_col}"
|
||
)
|
||
SELECT
|
||
lmp.sku,
|
||
lmp.name,
|
||
lmp.category,
|
||
lmp.momo_price,
|
||
rs.sales_7d_curr,
|
||
rs.sales_7d_prev,
|
||
cp.price AS pchome_price,
|
||
cp.tags AS competitor_tags,
|
||
cp.match_score AS competitor_match_score
|
||
FROM latest_momo_price lmp
|
||
JOIN recent_sales rs ON rs.product_name = lmp.name
|
||
LEFT JOIN competitor_prices cp
|
||
ON cp.sku = lmp.sku
|
||
AND cp.source = 'pchome'
|
||
AND cp.expires_at > NOW()
|
||
AND cp.match_score >= 0.45
|
||
WHERE lmp.rn = 1
|
||
AND rs.sales_7d_prev > 0
|
||
AND (rs.sales_7d_curr - rs.sales_7d_prev) / rs.sales_7d_prev < -0.10
|
||
ORDER BY (rs.sales_7d_curr - rs.sales_7d_prev) / rs.sales_7d_prev ASC
|
||
LIMIT 300
|
||
"""
|
||
sql = text(sql_str)
|
||
|
||
with self.engine.connect() as conn:
|
||
rows = conn.execute(sql).fetchall()
|
||
|
||
return [dict(row._mapping) for row in rows]
|
||
|
||
# ──────────────────────────────────────────────
|
||
# Step 2:批量注入 Hermes 3 分析
|
||
# ──────────────────────────────────────────────
|
||
def _batch_analyze(self, candidates: list, pchome_prices: dict = None) -> tuple:
|
||
"""
|
||
將候選商品 + PChome 比價資料打包交 Hermes 分析
|
||
|
||
Args:
|
||
candidates: SQL 漏斗結果 (list of dict)
|
||
v2 起 candidates 已內含 pchome_price + competitor_tags
|
||
pchome_prices: 舊式外部注入 {sku: price}(向下相容,優先度低於 candidates 內建值)
|
||
|
||
Returns:
|
||
tuple(raw_threats, items)
|
||
- raw_threats: Hermes LLM 輸出的 JSON 陣列(含 risk/action/confidence 等分類結果)
|
||
- items: Python 計算的客觀數據 source of truth(sku/momo/pchome/gap_pct/sales_delta)
|
||
|
||
[2026-04-18 台北] Bug-1 根治 Layer A:同時回傳 items 作為客觀數據的真理來源,
|
||
防止 run() 從 LLM 輸出讀 momo_price/pchome_price 時因 LLM 漏吐 → default=0 → $0 幻覺
|
||
— Claude Opus 4.7
|
||
"""
|
||
external = pchome_prices or {}
|
||
items = []
|
||
for c in candidates:
|
||
# 優先使用 DB 快取的競品價格;fallback 到外部注入
|
||
pchome_price = c.get("pchome_price") or external.get(c["sku"])
|
||
if not pchome_price:
|
||
continue
|
||
|
||
sales_prev = float(c.get("sales_7d_prev") or 0)
|
||
sales_curr = float(c.get("sales_7d_curr") or 0)
|
||
momo_price = float(c["momo_price"])
|
||
pchome_price = float(pchome_price)
|
||
delta_pct = round((sales_curr - sales_prev) / sales_prev * 100, 1) if sales_prev else 0
|
||
gap_pct = round((momo_price - pchome_price) / pchome_price * 100, 1)
|
||
|
||
# 競品語意標籤(JSONB 從 DB 來,可能是 list 或 JSON 字串)
|
||
raw_tags = c.get("competitor_tags") or []
|
||
if isinstance(raw_tags, str):
|
||
try:
|
||
import json as _json
|
||
raw_tags = _json.loads(raw_tags)
|
||
except Exception:
|
||
raw_tags = []
|
||
|
||
item = {
|
||
"sku": c["sku"],
|
||
"name": c["name"][:30], # 截斷避免 token 爆炸
|
||
"category": c.get("category", ""),
|
||
"momo": c["momo_price"],
|
||
"pchome": pchome_price,
|
||
"gap_pct": gap_pct, # Python 預算好,Hermes 只做分類
|
||
"sales_delta": delta_pct,
|
||
}
|
||
if raw_tags:
|
||
item["competitor_tags"] = raw_tags # 語意情境給 Hermes 加分
|
||
|
||
items.append(item)
|
||
|
||
if not items:
|
||
return [], []
|
||
|
||
prompt = (
|
||
f"分析以下 {len(items)} 支商品的競價威脅,回傳前 {TOP_N} 個最高風險商品。\n\n"
|
||
f"資料:{json.dumps(items, ensure_ascii=False)}\n\n"
|
||
f"輸出格式(JSON 陣列,每筆含):\n"
|
||
f'[{{"sku": string, "name": string, "category": string, '
|
||
f'"momo_price": number, "pchome_price": number, '
|
||
f'"gap_pct": number, "sales_7d_delta_pct": number, '
|
||
f'"risk": "HIGH|MED|LOW", "recommended_action": string, "confidence": number}}]'
|
||
)
|
||
|
||
payload = {
|
||
"model": HERMES_MODEL,
|
||
"system": self.SYSTEM_PROMPT,
|
||
"prompt": prompt,
|
||
"stream": False,
|
||
"options": {"temperature": 0.1},
|
||
}
|
||
|
||
resp = requests.post(
|
||
f"{HERMES_URL}/api/generate",
|
||
json=payload,
|
||
timeout=HERMES_TIMEOUT,
|
||
)
|
||
resp.raise_for_status()
|
||
|
||
data = resp.json()
|
||
raw = data.get("response", "").strip()
|
||
duration_sec = round(data.get("total_duration", 0) / 1e9, 1)
|
||
eval_tokens = data.get("eval_count", "?") # Ollama 推理 token 數
|
||
logger.info(
|
||
f"[Hermes] 推理耗時 {duration_sec}s,"
|
||
f"輸入 {len(items)} 筆,tokens={eval_tokens},回應長度 {len(raw)}"
|
||
)
|
||
# 儲存統計供 footprint 使用(掛在 instance 上供 run() 讀取)
|
||
self._last_stats = {"duration_sec": duration_sec, "tokens": eval_tokens}
|
||
|
||
# P0-1 修復:剝除 Hermes 可能輸出的 markdown code fence
|
||
if raw.startswith("```"):
|
||
raw = re.sub(r"^```(?:json)?\s*", "", raw, flags=re.MULTILINE)
|
||
raw = re.sub(r"\s*```\s*$", "", raw.strip(), flags=re.MULTILINE)
|
||
raw = raw.strip()
|
||
logger.debug("[Hermes] 已剝除 markdown code fence")
|
||
|
||
return json.loads(raw), items
|
||
|
||
# ──────────────────────────────────────────────
|
||
# 公開介面
|
||
# ──────────────────────────────────────────────
|
||
def run(self, pchome_prices: dict = None) -> AnalysisResult:
|
||
"""
|
||
執行完整競價情報分析流程
|
||
|
||
Args:
|
||
pchome_prices: 舊式外部注入 {sku: price}(可選,向下相容)
|
||
v2 起競品價格由 competitor_prices DB 表提供(fetch_candidates LEFT JOIN)
|
||
若 DB 快取缺漏,此參數可作 fallback 補充
|
||
|
||
Returns:
|
||
AnalysisResult
|
||
"""
|
||
start = time.time()
|
||
|
||
try:
|
||
candidates = self.fetch_candidates()
|
||
logger.info(f"[漏斗] 候選商品 {len(candidates)} 筆")
|
||
|
||
if not candidates:
|
||
return AnalysisResult(
|
||
success=True, threats=[], total_candidates=0,
|
||
analysis_duration_sec=time.time() - start,
|
||
)
|
||
|
||
raw_threats, ground_items = self._batch_analyze(candidates, pchome_prices or {})
|
||
|
||
# [2026-04-18 台北] Bug-1 根治 Layer A:
|
||
# 客觀數據(momo/pchome/gap_pct/sales_delta)從 Python items 讀,不從 LLM 輸出讀
|
||
# LLM 只保留分類結果(risk / recommended_action / confidence)
|
||
# 避免 Hermes 漏吐欄位 → default=0 → Telegram $0 幻覺 — Claude Opus 4.7
|
||
items_by_sku = {i["sku"]: i for i in ground_items}
|
||
|
||
threats = []
|
||
for t in raw_threats:
|
||
sku = t.get("sku")
|
||
ground = items_by_sku.get(sku)
|
||
if not ground:
|
||
logger.warning(
|
||
f"[Hermes] LLM 回吐未知 SKU={sku},不在 items 清單,防幻覺跳過"
|
||
)
|
||
continue
|
||
threats.append(PriceThreat(
|
||
sku=sku,
|
||
name=ground["name"], # Python truth
|
||
category=ground.get("category", ""), # Python truth
|
||
momo_price=float(ground["momo"]), # Python truth(根治 $0)
|
||
pchome_price=float(ground["pchome"]), # Python truth(根治 $0)
|
||
gap_pct=float(ground["gap_pct"]), # Python truth
|
||
sales_7d_delta_pct=float(ground["sales_delta"]), # Python truth
|
||
risk=t.get("risk", "LOW"), # LLM 分類
|
||
recommended_action=t.get("recommended_action", ""), # LLM 洞察
|
||
confidence=float(t.get("confidence", 0.5)), # LLM 信心度
|
||
))
|
||
|
||
hermes_stats = getattr(self, "_last_stats", {})
|
||
return AnalysisResult(
|
||
success=True,
|
||
threats=threats,
|
||
total_candidates=len(candidates),
|
||
analysis_duration_sec=round(time.time() - start, 2),
|
||
hermes_tokens=hermes_stats.get("tokens", 0),
|
||
)
|
||
|
||
except requests.Timeout:
|
||
return AnalysisResult(
|
||
success=False, threats=[], total_candidates=0,
|
||
analysis_duration_sec=time.time() - start,
|
||
error="Hermes 推理超時(>120s),候選數量可能過多",
|
||
)
|
||
except json.JSONDecodeError as e:
|
||
return AnalysisResult(
|
||
success=False, threats=[], total_candidates=0,
|
||
analysis_duration_sec=time.time() - start,
|
||
error=f"Hermes JSON 解析失敗:{e}",
|
||
)
|
||
except Exception as e:
|
||
logger.exception("[HermesAnalyst] 分析失敗")
|
||
return AnalysisResult(
|
||
success=False, threats=[], total_candidates=0,
|
||
analysis_duration_sec=time.time() - start,
|
||
error=str(e),
|
||
)
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# CLI 快速測試(不依賴 DB)
|
||
# python3 services/hermes_analyst_service.py
|
||
# ─────────────────────────────────────────────
|
||
if __name__ == "__main__":
|
||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||
|
||
service = HermesAnalystService()
|
||
|
||
fake_candidates = [
|
||
{"sku": "A001", "name": "玻尿酸面膜10片裝", "category": "美妝保養",
|
||
"momo_price": 320, "sales_7d_curr": 58000, "sales_7d_prev": 100000},
|
||
{"sku": "A003", "name": "舒特膚AD乳液200ml", "category": "美妝保養",
|
||
"momo_price": 1200, "sales_7d_curr": 65000, "sales_7d_prev": 100000},
|
||
{"sku": "A005", "name": "玻尿酸精華液30ml", "category": "美妝保養",
|
||
"momo_price": 890, "sales_7d_curr": 72000, "sales_7d_prev": 100000},
|
||
{"sku": "A007", "name": "眼霜15ml", "category": "美妝保養",
|
||
"momo_price": 680, "sales_7d_curr": 82000, "sales_7d_prev": 100000},
|
||
{"sku": "A009", "name": "美白化妝水150ml", "category": "美妝保養",
|
||
"momo_price": 420, "sales_7d_curr": 78000, "sales_7d_prev": 100000},
|
||
]
|
||
fake_pchome = {"A001": 280, "A003": 980, "A005": 760, "A007": 590, "A009": 350}
|
||
|
||
print("=== AI分析師 競價分析 CLI 測試 ===\n")
|
||
raw = service._batch_analyze(fake_candidates, fake_pchome)
|
||
|
||
for t in raw:
|
||
icon = {"HIGH": "🔴", "MED": "🟡", "LOW": "🟢"}.get(t.get("risk", ""), "⚪")
|
||
print(f"{icon} [{t['sku']}] {t['name']}")
|
||
print(f" MOMO ${t['momo_price']} vs PChome ${t['pchome_price']} → 價差 {t['gap_pct']:.1f}%")
|
||
print(f" 銷量 {t['sales_7d_delta_pct']}% | 建議:{t['recommended_action']}\n")
|