diff --git a/services/nemoton_dispatcher_service.py b/services/nemoton_dispatcher_service.py index 06ea511..47dcb21 100644 --- a/services/nemoton_dispatcher_service.py +++ b/services/nemoton_dispatcher_service.py @@ -203,6 +203,57 @@ TOOLS = [ }, }, }, + { + "type": "function", + "function": { + "name": "route_to_km", + "description": ( + "將商品競價洞察路由到知識庫(KM)的指定領域分類," + "供未來 RAG 查詢與 OpenClaw 週報引用。" + "適用於:數據有參考價值但不需立即告警的情況。" + ), + "parameters": { + "type": "object", + "properties": { + "sku": {"type": "string", "description": "商品 SKU 編號"}, + "name": {"type": "string", "description": "商品名稱"}, + "km_domain": { + "type": "string", + "description": ( + "KM 領域分類,必須為以下之一:" + "price_competition(競價情報)、" + "sales_anomaly(銷量異常)、" + "promotion_opportunity(促銷機會)、" + "market_trend(市場趨勢)" + ), + }, + "summary": {"type": "string", "description": "此洞察的核心摘要(繁體中文,50 字內)"}, + "confidence": {"type": "number", "description": "AI 信心度 0.0~1.0"}, + }, + "required": ["sku", "name", "km_domain", "summary", "confidence"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "mark_for_relearn", + "description": ( + "當新數據與 KM 既有洞察矛盾,或告警方向被推翻時," + "將該商品的歷史洞察標記為需重新學習(relearn)。" + "適用於:此次分析結果與上次截然不同的情況。" + ), + "parameters": { + "type": "object", + "properties": { + "sku": {"type": "string", "description": "商品 SKU 編號"}, + "name": {"type": "string", "description": "商品名稱"}, + "reason": {"type": "string", "description": "標記原因說明(繁體中文)"}, + }, + "required": ["sku", "name", "reason"], + }, + }, + }, ] @@ -737,6 +788,73 @@ class NemotronDispatcher: "momo_price": momo_price, "comp_price": comp_price}, ) + def _exec_route_to_km( + self, + sku: str, name: str, km_domain: str, summary: str, confidence: float, + footprint: str = "", + threat=None, + ): + """ + 將洞察路由到 KM 指定領域,sink 到 ai_insights 供 RAG 使用。 + 不送 Telegram 告警(靜默操作,僅 log)。 + """ + _KM_DOMAINS = {"price_competition", "sales_anomaly", "promotion_opportunity", "market_trend"} + domain = km_domain if km_domain in _KM_DOMAINS else "price_competition" + summary = _sanitize_text(summary, fallback="競價洞察已歸檔") + + self._sink_insight_to_km( + insight_type=f"km_{domain}", + sku=sku, name=name, + content=f"[KM 路由 {domain}] {name}:{summary}", + metadata={ + "km_domain": domain, + "confidence": confidence, + "momo_price": getattr(threat, "momo_price", None) if threat else None, + "pchome_price": getattr(threat, "pchome_price", None) if threat else None, + "gap_pct": getattr(threat, "gap_pct", None) if threat else None, + "sales_delta": getattr(threat, "sales_7d_delta_pct", None) if threat else None, + }, + ) + logger.info(f"[Dispatcher] KM 路由 → {sku} domain={domain} confidence={confidence:.2f}") + + def _exec_mark_for_relearn( + self, + sku: str, name: str, reason: str, + footprint: str = "", + ): + """ + 將該 SKU 的既有 ai_insights 標記 status='relearn' + feedback_down+1, + 讓每日去重/品質分數重算批次可感知「此洞察已被推翻」。 + 不送 Telegram 告警(靜默操作,僅 log)。 + """ + reason = _sanitize_text(reason, fallback="新數據與歷史洞察矛盾,需重新學習") + try: + from database.manager import DatabaseManager + db = DatabaseManager() + with db.get_session() as session: + from sqlalchemy import text + result = session.execute(text(""" + UPDATE ai_insights + SET status = 'relearn', + feedback_down = COALESCE(feedback_down, 0) + 1, + updated_at = CURRENT_TIMESTAMP + WHERE product_sku = :sku + AND status NOT IN ('relearn', 'archived') + """), {"sku": sku}) + session.commit() + rows = result.rowcount + logger.info(f"[Dispatcher] mark_for_relearn → {sku} 共更新 {rows} 筆洞察;原因:{reason}") + except Exception as e: + logger.warning(f"[Dispatcher] mark_for_relearn DB 更新失敗 ({sku}): {e}") + + # 同時寫入一筆 relearn 事件到 ai_insights 留存紀錄 + self._sink_insight_to_km( + insight_type="relearn_event", + sku=sku, name=name, + content=f"[重新學習事件] {name}:{reason}", + metadata={"sku": sku, "trigger": "nemoton_dispatcher"}, + ) + def _sink_insight_to_km(self, insight_type: str, sku: str, name: str, content: str, metadata: dict = None): """ @@ -948,6 +1066,8 @@ class NemotronDispatcher: "trigger_price_alert": self._exec_trigger_price_alert, "add_to_recommendation": self._exec_add_to_recommendation, "flag_for_human_review": self._exec_flag_for_human_review, + "route_to_km": self._exec_route_to_km, + "mark_for_relearn": self._exec_mark_for_relearn, } for tc in tool_calls: @@ -979,6 +1099,9 @@ class NemotronDispatcher: elif tool_name == "add_to_recommendation": args["footprint_data"] = footprint_data args["threat"] = t + elif tool_name == "route_to_km": + args["threat"] = t + # mark_for_relearn 無需注入客觀數字(僅寫 DB) try: handler(**args)