diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 9612750..3d22f81 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.305 將 PChome 人工覆核回饋接回 feeder:下一輪搜尋若命中已被 `reject_identity` 否決的同一候選,會記錄 `manual_rejected` 並跳過正式寫入;已被標記 `unit_price_required` 的候選只保留單位價比較,不寫入正式總價差;人工 `accept_identity` 可保守覆蓋低分門檻但會打 `manual_review/manual_accept` 標籤,讓核心比價閉環可被後續報表與簡報追蹤。 - V10.304 補 PChome 比價人工覆核決策閉環:新增 `competitor_match_reviews`、`/api/pchome-review//decision` 與商品看板覆核列「採用同款 / 否決候選 / 標記單位價」動作;只有人工採用同款才寫入 `competitor_prices` + `competitor_price_history`,否決與單位價標記只追加 manual attempt 並關閉本輪覆核,避免錯配污染核心價差。 - V10.302 補 PChome 比價覆核匯出與診斷原因:`filter=pchome_review` 每筆覆核把 matcher `reasons=` 翻成品牌不符、商品線不符、容量差異、組合差異、需單位價、價差極端等可行動標籤;新增 `/api/export/excel/pchome-review` 匯出完整覆核隊列、人工處置、候選 PChome、單位價比較與原始診斷,避免核心比價只停在籠統「待對比」。 - V10.301 補市場情報 candidate queue review AI summary Telegram dispatch gate:新增 `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_gate` 與 UI 按鈕,在 summary persistence closeout 後檢查 Telegram 訊息契約、channel label、artifact path、token 外洩風險與後續 run package promotion;API/UI 仍不讀 approval/Telegram token、不呼叫 LLM、不開 DB、不寫檔、不派送 Telegram、不掛 scheduler。 diff --git a/config.py b/config.py index 6dd0a06..f895600 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.304" +SYSTEM_VERSION = "V10.305" 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 337639a..10b9280 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-05-20 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 僅備援 / 鎖定場景 -> **適用版本**: V10.304 +> **適用版本**: V10.305 --- @@ -56,7 +56,7 @@ SQL漏斗(~300筆) - 比對覆蓋率補強入口:`POST /api/ai/pchome-match/backfill`,優先補抓仍無有效 PChome 配對的高價 ACTIVE 商品,完成後自動重算 AI 挑品清單。 - 排程閉環:`run_pchome_match_backfill_task` 每日 10:30 執行,補抓 PChome 待比對商品、寫入歷史價格,再重算 `strategy='product_pick'` 清單。 - PChome / MOMO 競價摘要出口 `services/competitor_intel_repository.py` 使用 30 分鐘共享快取(`COMPETITOR_INTEL_CACHE_TTL_SECONDS` 可調),避免 `/growth_analysis`、`/daily_sales`、PPT/AI 報表每次請求重跑昂貴覆蓋率與價差趨勢查詢;`run_competitor_price_feeder_task` 與 PChome backfill 完成後會主動清除快取。快取只包摘要輸出,不改 matcher 的高信心門檻與 identity_v2 準確性規則。 -- 商品看板第一屏:`/` 的 V2 看板直接以 `products`、`price_records`、`competitor_prices`、`competitor_match_attempts`、`competitor_match_reviews`、`ai_price_recommendations` 顯示比對覆蓋率、PChome 優勢、MOMO 威脅、AI 挑品、待比對優先清單與 PChome 覆核隊列;`filter=ai_picks` 可查看 50 品 AI 挑品列表,`filter=pchome_review` 可直接查看需人工處理的比價覆核 SKU,並以 DB 分頁支援 search/category/status 後的完整隊列,不得只截前 50 筆。覆核狀態篩選必須至少包含全部、需單位價、身份否決、低信心、價格過期與找不到同款,讓人工可依 matcher 診斷類型分批處理。列內顯示候選 PChome 商品、候選價、match score、單位價換算摘要、人工動作與 matcher 診斷原因標籤(品牌不符、商品線不符、容量差異、組合差異、需單位價、價差極端等),不得只顯示籠統「待比對」。`/api/export/excel/pchome-review` 必須匯出同一套覆核隊列、人工處置、候選 PChome、單位價比較與原始診斷,讓人工覆核、簡報與後續 AI 分析共用同一份證據。`/api/pchome-review//decision` 是人工閉環入口:`accept_identity` 才可把候選寫入 `competitor_prices` 與 `competitor_price_history` 並打上 `manual_review/manual_accept/identity_v2`;`reject_identity` 與 `unit_price_required` 只寫 `competitor_match_reviews` 並追加 manual attempt,不得把不同販售組合或否決候選灌入正式價差。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。 +- 商品看板第一屏:`/` 的 V2 看板直接以 `products`、`price_records`、`competitor_prices`、`competitor_match_attempts`、`competitor_match_reviews`、`ai_price_recommendations` 顯示比對覆蓋率、PChome 優勢、MOMO 威脅、AI 挑品、待比對優先清單與 PChome 覆核隊列;`filter=ai_picks` 可查看 50 品 AI 挑品列表,`filter=pchome_review` 可直接查看需人工處理的比價覆核 SKU,並以 DB 分頁支援 search/category/status 後的完整隊列,不得只截前 50 筆。覆核狀態篩選必須至少包含全部、需單位價、身份否決、低信心、價格過期與找不到同款,讓人工可依 matcher 診斷類型分批處理。列內顯示候選 PChome 商品、候選價、match score、單位價換算摘要、人工動作與 matcher 診斷原因標籤(品牌不符、商品線不符、容量差異、組合差異、需單位價、價差極端等),不得只顯示籠統「待比對」。`/api/export/excel/pchome-review` 必須匯出同一套覆核隊列、人工處置、候選 PChome、單位價比較與原始診斷,讓人工覆核、簡報與後續 AI 分析共用同一份證據。`/api/pchome-review//decision` 是人工閉環入口:`accept_identity` 才可把候選寫入 `competitor_prices` 與 `competitor_price_history` 並打上 `manual_review/manual_accept/identity_v2`;`reject_identity` 與 `unit_price_required` 只寫 `competitor_match_reviews` 並追加 manual attempt,不得把不同販售組合或否決候選灌入正式價差。PChome feeder 後續搜尋同一候選時必須讀取 `competitor_match_reviews`:已否決候選寫 `manual_rejected` 並跳過正式寫入;已標記單位價候選寫 `manual_unit_price_required`;已採用候選可保守補到最低門檻並保留 `manual_review/manual_accept` 標籤。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。 | 角色 | 模型 | 主機 | 成本 | 每日限額 | |------|------|------|------|---------| diff --git a/services/competitor_price_feeder.py b/services/competitor_price_feeder.py index 92ea456..5fa5484 100644 --- a/services/competitor_price_feeder.py +++ b/services/competitor_price_feeder.py @@ -716,6 +716,37 @@ class CompetitorPriceFeeder: f"incoming_score={match_score:.3f}", ) + def _fetch_latest_manual_review_for_candidate( + self, + sku: str, + competitor_product_id: str, + source: str = "pchome", + ) -> Optional[dict]: + """Read the latest human review for this exact candidate, if the table exists.""" + if not competitor_product_id: + return None + from sqlalchemy import text + + try: + with self.engine.connect() as conn: + row = conn.execute(text(""" + SELECT review_action, review_reason, reviewer_identity, reviewed_at + FROM competitor_match_reviews + WHERE sku = :sku + AND source = :source + AND candidate_product_id = :candidate_id + ORDER BY reviewed_at DESC, id DESC + LIMIT 1 + """), { + "sku": sku, + "source": source, + "candidate_id": competitor_product_id, + }).mappings().first() + except Exception: + return None + + return dict(row) if row else None + def _run_sku_items(self, skus: list, source: str = "pchome", label: str = "PChome 競品價格") -> FeederResult: start = time.time() @@ -777,7 +808,57 @@ class CompetitorPriceFeeder: continue best_product, score, diagnostics = result - if getattr(diagnostics, "comparison_mode", "") == "unit_comparable": + manual_review = self._fetch_latest_manual_review_for_candidate( + sku, + getattr(best_product, "product_id", None), + source=source, + ) + manual_action = (manual_review or {}).get("review_action") + if manual_action == "reject_identity": + logger.info( + f"[Feeder] {sku} 候選已被人工否決,跳過正式寫入 | " + f"candidate={getattr(best_product, 'product_id', None)}" + ) + self._record_match_attempt( + sku, + momo_name, + momo_product_id=momo_product_id, + momo_price=momo_price, + search_terms=search_terms, + candidate_count=len(products), + attempt_status="manual_rejected", + best_product=best_product, + best_score=score, + error_message=f"manual_review_rejected; {_format_match_diagnostics(diagnostics)}", + source=source, + ) + attempts_written += 1 + skipped_low += 1 + continue + if manual_action == "unit_price_required": + logger.info( + f"[Feeder] {sku} 候選已被人工標記為單位價比較,不寫正式總價差 | " + f"candidate={getattr(best_product, 'product_id', None)}" + ) + self._record_match_attempt( + sku, + momo_name, + momo_product_id=momo_product_id, + momo_price=momo_price, + search_terms=search_terms, + candidate_count=len(products), + attempt_status="manual_unit_price_required", + best_product=best_product, + best_score=score, + error_message=f"manual_review_unit_price_required; {_format_match_diagnostics(diagnostics)}", + source=source, + ) + attempts_written += 1 + skipped_low += 1 + continue + + manual_accept_override = manual_action == "accept_identity" + if getattr(diagnostics, "comparison_mode", "") == "unit_comparable" and not manual_accept_override: logger.info( f"[Feeder] {sku} 候選屬單位價可比但非同販售組合," f"不寫入正式價差 | {_format_match_diagnostics(diagnostics)}" @@ -799,7 +880,7 @@ class CompetitorPriceFeeder: skipped_low += 1 continue - if score < MIN_MATCH_SCORE: + if score < MIN_MATCH_SCORE and not manual_accept_override: logger.debug( f"[Feeder] {sku} 比對分數過低 ({score:.3f} < {MIN_MATCH_SCORE})," f"{_format_match_diagnostics(diagnostics)}" @@ -821,10 +902,15 @@ class CompetitorPriceFeeder: skipped_low += 1 continue + if manual_accept_override: + score = max(score, MIN_MATCH_SCORE) tags = _extract_tags(best_product) tags.extend(getattr(diagnostics, "tags", [])) for reason in getattr(diagnostics, "reasons", ()) or (): tags.append(f"match_{reason}") + if manual_accept_override: + tags.extend(["manual_review", "manual_accept"]) + tags = [tag for tag in tags if tag != "identity_veto"] tags = list(dict.fromkeys(tags)) should_write, write_reason = self._should_upsert_competitor_price( sku, @@ -832,6 +918,9 @@ class CompetitorPriceFeeder: score, source=source, ) + if manual_accept_override and not should_write: + should_write = True + write_reason = "manual_accept_override" if not should_write: logger.info(f"[Feeder] {sku} 進入人工覆核,不覆蓋既有配對 | {write_reason}") self._record_match_attempt( diff --git a/tests/test_competitor_match_attempts_persistence.py b/tests/test_competitor_match_attempts_persistence.py index c92dcc1..519e076 100644 --- a/tests/test_competitor_match_attempts_persistence.py +++ b/tests/test_competitor_match_attempts_persistence.py @@ -59,6 +59,9 @@ def test_competitor_match_review_service_closes_human_review_loop(): assert "INSERT INTO competitor_price_history" in service_source assert "manual_review" in service_source assert "manual_accept" in service_source + assert "_fetch_latest_manual_review_for_candidate" in (ROOT / "services/competitor_price_feeder.py").read_text(encoding="utf-8") + assert "manual_review_rejected" in (ROOT / "services/competitor_price_feeder.py").read_text(encoding="utf-8") + assert "manual_accept_override" in (ROOT / "services/competitor_price_feeder.py").read_text(encoding="utf-8") assert "CREATE TABLE IF NOT EXISTS competitor_match_reviews" in migration assert "review_action" in migration assert "reviewer_identity" in migration @@ -68,6 +71,67 @@ def test_competitor_match_review_service_closes_human_review_loop(): assert "/api/pchome-review/" in dashboard_js +def test_competitor_feeder_respects_manual_rejected_candidate(monkeypatch): + from services.competitor_price_feeder import CompetitorPriceFeeder + from services.pchome_crawler import PChomeProduct + + product = PChomeProduct( + product_id="DDAB01-REJECTED", + name="舒特膚 AD 乳液 200ml", + price=899, + original_price=999, + discount=10, + image_url="", + product_url="https://24h.pchome.com.tw/prod/DDAB01-REJECTED", + stock=20, + store="24h", + rating=4.7, + review_count=8, + is_on_sale=True, + crawled_at=datetime.now(), + ) + + class FakeCrawler: + def __init__(self, *_args, **_kwargs): + pass + + def search_products(self, *_args, **_kwargs): + return True, "ok", [product] + + monkeypatch.setattr("services.pchome_crawler.PChomeCrawler", FakeCrawler) + feeder = CompetitorPriceFeeder(engine=object()) + attempts = [] + writes = [] + monkeypatch.setattr( + feeder, + "_fetch_latest_manual_review_for_candidate", + lambda *_args, **_kwargs: {"review_action": "reject_identity"}, + ) + monkeypatch.setattr( + feeder, + "_record_match_attempt", + lambda *args, **kwargs: attempts.append(kwargs), + ) + monkeypatch.setattr( + feeder, + "_upsert_competitor_price", + lambda *args, **kwargs: writes.append((args, kwargs)), + ) + + result = feeder._run_sku_items([{ + "sku": "A003", + "name": "舒特膚 AD 乳液 200ml", + "product_id": 3, + "momo_price": 980, + }]) + + assert result.matched == 0 + assert result.skipped_low_score == 1 + assert writes == [] + assert attempts[0]["attempt_status"] == "manual_rejected" + assert "manual_review_rejected" in attempts[0]["error_message"] + + def test_competitor_feeder_logs_keyword_parser_fallback(monkeypatch, caplog): from services import competitor_price_feeder from services import marketplace_product_matcher