diff --git a/config.py b/config.py index f08fe7e..1ca8aa8 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.276" +SYSTEM_VERSION = "V10.277" 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 d0fcc94..bf7037a 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-05-19 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 僅備援 / 鎖定場景 -> **適用版本**: V10.276 +> **適用版本**: V10.277 --- @@ -52,6 +52,7 @@ SQL漏斗(~300筆) - 配對來源仍以 PChome crawler 真實搜尋結果為準;無競品資料時不生成挑品。 - 比對覆蓋率補強入口:`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` 使用 5 分鐘共享快取(`COMPETITOR_INTEL_CACHE_TTL_SECONDS` 可調),避免 `/growth_analysis`、`/daily_sales`、PPT/AI 報表每次請求重跑昂貴覆蓋率與價差趨勢查詢;快取只包摘要輸出,不改 matcher 的高信心門檻與 identity_v2 準確性規則。 - 商品看板第一屏:`/` 的 V2 看板直接以 `products`、`price_records`、`competitor_prices`、`ai_price_recommendations` 顯示比對覆蓋率、PChome 優勢、MOMO 威脅、AI 挑品與待比對優先清單;`filter=ai_picks` 可查看 50 品 AI 挑品列表,並在列表上方顯示平均信心、平均價差、最大價差與估算總價差空間,列表列內顯示 AI 排名與建議理由,且可透過 `/api/export/excel/ai-picks` 匯出 50 品 Excel 操作清單。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。 | 角色 | 模型 | 主機 | 成本 | 每日限額 | diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index ae61767..d59e363 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -10,13 +10,23 @@ from __future__ import annotations +import os +import pickle +import time from datetime import date, datetime, timedelta +from pathlib import Path +from threading import Lock from typing import Any, Optional, Union from sqlalchemy import inspect, text PCHOME_MATCH_SCORE_FLOOR = 0.76 +COMPETITOR_INTEL_CACHE_TTL_SECONDS = int(os.getenv("COMPETITOR_INTEL_CACHE_TTL_SECONDS", "300")) +_BASE_DIR = Path(__file__).resolve().parents[1] +_CACHE_FILE = _BASE_DIR / "data" / "competitor_intel_cache.pkl" +_CACHE_LOCK = Lock() +_MEM_CACHE: dict[str, dict[str, Any]] = {} def _num(value: Any) -> float: @@ -38,7 +48,81 @@ def _month_label(value: Any) -> str: return str(value or "")[:7] +def clear_competitor_intel_cache() -> None: + """Clear cached PChome/MOMO intelligence after crawler/import updates.""" + with _CACHE_LOCK: + _MEM_CACHE.clear() + try: + if _CACHE_FILE.exists(): + _CACHE_FILE.unlink() + except OSError: + pass + + +def _load_shared_cache() -> dict[str, dict[str, Any]]: + if not _CACHE_FILE.exists(): + return {} + try: + with _CACHE_FILE.open("rb") as handle: + payload = pickle.load(handle) + return payload if isinstance(payload, dict) else {} + except Exception: + return {} + + +def _write_shared_cache(payload: dict[str, dict[str, Any]]) -> None: + try: + _CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp_file = _CACHE_FILE.with_suffix(f".{os.getpid()}.tmp") + with tmp_file.open("wb") as handle: + pickle.dump(payload, handle, protocol=pickle.HIGHEST_PROTOCOL) + os.replace(tmp_file, _CACHE_FILE) + except Exception: + try: + if "tmp_file" in locals() and tmp_file.exists(): + tmp_file.unlink() + except OSError: + pass + + +def _cached_payload(cache_key: str, producer, ttl_seconds: int = COMPETITOR_INTEL_CACHE_TTL_SECONDS): + if ttl_seconds <= 0: + return producer() + now = time.time() + with _CACHE_LOCK: + entry = _MEM_CACHE.get(cache_key) + if entry and now - float(entry.get("time", 0)) < ttl_seconds: + return entry.get("value") + shared = _load_shared_cache() + entry = shared.get(cache_key) + if entry and now - float(entry.get("time", 0)) < ttl_seconds: + _MEM_CACHE[cache_key] = entry + return entry.get("value") + + value = producer() + entry = {"time": now, "value": value} + with _CACHE_LOCK: + _MEM_CACHE[cache_key] = entry + shared = _load_shared_cache() + shared[cache_key] = entry + stale_before = now - max(ttl_seconds * 4, 3600) + shared = { + key: item + for key, item in shared.items() + if isinstance(item, dict) and float(item.get("time", 0)) >= stale_before + } + _write_shared_cache(shared) + return value + + def fetch_competitor_coverage(engine) -> dict: + return _cached_payload( + f"coverage:v2:floor={PCHOME_MATCH_SCORE_FLOOR}", + lambda: _fetch_competitor_coverage_uncached(engine), + ) + + +def _fetch_competitor_coverage_uncached(engine) -> dict: """讀取目前 PChome 比價覆蓋率與待審分類。""" if not inspect(engine).has_table("competitor_prices"): return { @@ -117,6 +201,14 @@ def fetch_competitor_coverage(engine) -> dict: def fetch_competitor_gap_trend(engine, days: int = 30) -> dict: + days = max(7, min(int(days or 30), 120)) + return _cached_payload( + f"gap_trend:v2:days={days}:floor={PCHOME_MATCH_SCORE_FLOOR}", + lambda: _fetch_competitor_gap_trend_uncached(engine, days=days), + ) + + +def _fetch_competitor_gap_trend_uncached(engine, days: int = 30) -> dict: """近 N 天 PChome 價差壓力趨勢。""" if not inspect(engine).has_table("competitor_price_history"): return {"labels": [], "avg_gap_pct": [], "risk_count": [], "momo_advantage_count": [], "match_count": []} @@ -167,6 +259,14 @@ def fetch_competitor_gap_trend(engine, days: int = 30) -> dict: def fetch_competitor_monthly_pressure(engine, months: int = 12) -> dict: + months = max(3, min(int(months or 12), 36)) + return _cached_payload( + f"monthly_pressure:v2:months={months}:floor={PCHOME_MATCH_SCORE_FLOOR}", + lambda: _fetch_competitor_monthly_pressure_uncached(engine, months=months), + ) + + +def _fetch_competitor_monthly_pressure_uncached(engine, months: int = 12) -> dict: """月度競品價格壓力,用於 growth analysis。""" if not inspect(engine).has_table("competitor_price_history"): return {"labels": [], "avg_gap_pct": [], "risk_count": [], "match_count": []} @@ -215,6 +315,14 @@ def fetch_competitor_monthly_pressure(engine, months: int = 12) -> dict: def fetch_top_competitor_risks(engine, limit: int = 10) -> list[dict]: + limit = max(1, min(int(limit or 10), 50)) + return _cached_payload( + f"top_risks:v2:limit={limit}:floor={PCHOME_MATCH_SCORE_FLOOR}", + lambda: _fetch_top_competitor_risks_uncached(engine, limit=limit), + ) + + +def _fetch_top_competitor_risks_uncached(engine, limit: int = 10) -> list[dict]: """目前 MOMO 比 PChome 貴的高風險商品。""" if not inspect(engine).has_table("competitor_prices"): return [] diff --git a/tests/test_competitor_intel_cache.py b/tests/test_competitor_intel_cache.py new file mode 100644 index 0000000..68b0f72 --- /dev/null +++ b/tests/test_competitor_intel_cache.py @@ -0,0 +1,36 @@ +from pathlib import Path + + +def test_competitor_intel_cache_reuses_memory_and_shared_file(tmp_path, monkeypatch): + from services import competitor_intel_repository as repo + + monkeypatch.setattr(repo, "_CACHE_FILE", Path(tmp_path) / "competitor_intel_cache.pkl") + repo._MEM_CACHE.clear() + + calls = {"count": 0} + + def producer(): + calls["count"] += 1 + return {"valid_matches": 7, "match_rate": 0.1} + + first = repo._cached_payload("coverage:test", producer, ttl_seconds=60) + second = repo._cached_payload("coverage:test", producer, ttl_seconds=60) + repo._MEM_CACHE.clear() + third = repo._cached_payload("coverage:test", producer, ttl_seconds=60) + + assert first == second == third == {"valid_matches": 7, "match_rate": 0.1} + assert calls["count"] == 1 + + +def test_clear_competitor_intel_cache_removes_shared_file(tmp_path, monkeypatch): + from services import competitor_intel_repository as repo + + cache_file = Path(tmp_path) / "competitor_intel_cache.pkl" + monkeypatch.setattr(repo, "_CACHE_FILE", cache_file) + repo._MEM_CACHE["x"] = {"time": 1, "value": {"ok": True}} + cache_file.write_bytes(b"stale") + + repo.clear_competitor_intel_cache() + + assert repo._MEM_CACHE == {} + assert not cache_file.exists()