From 3c0e558fbe26a300a58add2f0a7b34554a92761a Mon Sep 17 00:00:00 2001 From: OoO Date: Tue, 16 Jun 2026 11:49:06 +0800 Subject: [PATCH] V10.622 refresh growth cache after offer sync --- config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 3 +- .../current_execution_queue_20260524.md | 7 ++++ routes/ai_routes.py | 6 +++ services/external_market_offer_service.py | 6 +++ services/pchome_growth_cache_state.py | 37 +++++++++++++++++++ tests/test_external_market_offer_service.py | 20 +++++++--- tests/test_pchome_revenue_growth_service.py | 21 +++++++++++ 8 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 services/pchome_growth_cache_state.py diff --git a/config.py b/config.py index 35c2cab..62140b1 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.621" +SYSTEM_VERSION = "V10.622" 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 dcaeb8e..987dab7 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-06-16 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯;PChome 後台業績匯入韌性已補強;產品定位正名為「PChome 業績成長自動化作戰系統」;外部市場來源正規化層、自動同步、作戰清單與價格參考表優先讀取、CSV 備援預檢、前台操作入口與高可見頁面繁中化守門已建立 -> **適用版本**: V10.621 +> **適用版本**: V10.622 --- @@ -68,6 +68,7 @@ - V10.619 起 MOMO 比價候選來源新增「PChome 商品導向搜尋」:當比價 API 已有 PChome 商品但缺 MOMO 清單時,必須用每筆 PChome 商品名稱產生精準搜尋詞反查 MOMO,保留品牌、品名、容量與組合線索;新版 MOMO 搜尋頁需解析 Next.js `goodsInfoList` payload。此路徑只擴大候選池,不放寬同款 matcher 門檻。 - V10.620 起 `unit_comparable` 不再一律丟人工確認:若 `build_unit_price_comparison()` 可產生明確容量/數量、MOMO 單位價、PChome 單位價與差距百分比,候選需標為「自動單位價比較」並回傳 `auto_compare_type=unit_price`。此類候選可自動呈現價格壓力,但不得混入舊總價同款比價表,也不得直接寫入正式價差或自動改價;無法產生單位證據時才維持「需人工確認」。 - V10.621 起 `/price_comparison` 的「自動找 MOMO 候選」會把可直接總價比價與自動單位價候選同步到 `external_offers`,`ingestion_method='targeted_momo_search'`,人工確認候選不得寫入。`external_offers.raw_payload_json.price_basis='unit_price'` 時,作戰清單必須使用 `unit_price_comparison` 的 MOMO / PChome 單位價與 `unit_gap_pct` 判斷價格壓力;不得把 MOMO 組合總價與 PChome 單品總價直接相減。此同步只影響外部價格參考與作戰清單,不寫 `competitor_prices`,也不自動改價。 +- V10.622 起任何 `external_offers` 自動同步成功寫入後,必須呼叫 `mark_pchome_growth_cache_stale()` 寫入共享 cache epoch;`/api/ai/pchome-growth/opportunities` 讀快取前必須比對 `get_pchome_growth_cache_epoch()`。這是跨 Gunicorn worker 的可見性保護,避免自動候選已進外部價格參考,但 AI 情報頁仍回 120 秒舊作戰清單。 ## 零之一、12 Agent 決策信封(2026-05-24) diff --git a/docs/memory/current_execution_queue_20260524.md b/docs/memory/current_execution_queue_20260524.md index 152e42a..12f92d5 100644 --- a/docs/memory/current_execution_queue_20260524.md +++ b/docs/memory/current_execution_queue_20260524.md @@ -289,3 +289,10 @@ - 新增 `sync_targeted_momo_candidates_to_external_offers()`,只寫 `ingestion_method='targeted_momo_search'`、`match_status='verified'`、`data_quality_status='verified'` 的安全候選;`unit_price` 候選會在 `raw_payload_json.unit_price_comparison` 保留 MOMO / PChome 單位價、容量/數量與價差百分比。 - `build_pchome_growth_opportunities()` 已能讀 `external_offers.raw_payload_json.price_basis='unit_price'`:作戰清單會顯示「資料可用單位價判斷」,並用單位價差距做「檢查售價與活動 / 放大價格優勢」判斷。 - 此路徑只同步外部價格參考與作戰清單,不寫 `competitor_prices`,不自動改價;目標是減少人工補資料,而不是放寬正式價差寫入。 + +## 25. 2026-06-16 V10.622 外部報價同步後即時刷新作戰清單 + +- 新增 `services/pchome_growth_cache_state.py`,以 `data/pchome_growth_cache_epoch.txt` 作為跨 Gunicorn worker 的作戰清單快取失效標記。 +- `sync_legacy_momo_reference_offers()` 與 `sync_targeted_momo_candidates_to_external_offers()` 只要成功寫入 `external_offers`,就呼叫 `mark_pchome_growth_cache_stale()`。 +- `/api/ai/pchome-growth/opportunities` 的 in-memory cache 會記住建立時的 epoch;讀快取前若發現共享 epoch 較新,會直接重建,不再讓使用者看到 120 秒舊清單。 +- 這讓「自動找 MOMO 候選 → 同步外部價格參考 → AI 情報頁作戰清單」變成同一條即時資料流,減少使用者手動重新整理或等待快取過期。 diff --git a/routes/ai_routes.py b/routes/ai_routes.py index df052bf..07ec296 100644 --- a/routes/ai_routes.py +++ b/routes/ai_routes.py @@ -15,6 +15,7 @@ from services.trend_crawler import TrendCrawler from services.ai_history_service import AIHistoryService, AITemplateService from database.manager import DatabaseManager from database.ai_models import AIUsageTracking +from services.pchome_growth_cache_state import get_pchome_growth_cache_epoch import pandas as pd import logging import json @@ -58,6 +59,7 @@ _ICAIM_MATCH_SCORE_FLOOR = 0.76 _ICAIM_DB_STATEMENT_TIMEOUT_MS = 5000 _PCHOME_GROWTH_CACHE = { "expires_at": 0.0, + "epoch": 0.0, "payload": None, } _PCHOME_GROWTH_TTL_SECONDS = 120 @@ -1604,6 +1606,9 @@ def _create_icaim_dashboard_engine(database_path): def _get_cached_pchome_growth_payload(): now_ts = time.time() cached_payload = _PCHOME_GROWTH_CACHE.get("payload") + cache_epoch = float(_PCHOME_GROWTH_CACHE.get("epoch") or 0) + if get_pchome_growth_cache_epoch() > cache_epoch: + return None if cached_payload is not None and now_ts < float(_PCHOME_GROWTH_CACHE.get("expires_at") or 0): payload = json.loads(json.dumps(cached_payload, ensure_ascii=False, default=str)) payload["cache_state"] = "fresh" @@ -1616,6 +1621,7 @@ def _set_pchome_growth_cache(payload): cached["cache_state"] = "fresh" _PCHOME_GROWTH_CACHE.update({ "expires_at": time.time() + _PCHOME_GROWTH_TTL_SECONDS, + "epoch": get_pchome_growth_cache_epoch(), "payload": cached, }) diff --git a/services/external_market_offer_service.py b/services/external_market_offer_service.py index 762d2b6..7b47407 100644 --- a/services/external_market_offer_service.py +++ b/services/external_market_offer_service.py @@ -17,6 +17,8 @@ from typing import Any from sqlalchemy import inspect, text +from services.pchome_growth_cache_state import mark_pchome_growth_cache_stale + logger = logging.getLogger(__name__) @@ -822,6 +824,8 @@ def sync_targeted_momo_candidates_to_external_offers( if not dry_run: for offer in offers: _upsert_external_offer(conn, offer) + if offers: + mark_pchome_growth_cache_stale() unit_count = sum( 1 @@ -879,6 +883,8 @@ def sync_legacy_momo_reference_offers(engine, *, limit: int = 500, dry_run: bool if not dry_run: for offer in offers: _upsert_external_offer(conn, offer) + if offers: + mark_pchome_growth_cache_stale() return { "success": True, diff --git a/services/pchome_growth_cache_state.py b/services/pchome_growth_cache_state.py new file mode 100644 index 0000000..dfbc576 --- /dev/null +++ b/services/pchome_growth_cache_state.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Shared cache invalidation marker for the PChome growth opportunity API.""" + +from __future__ import annotations + +import time +from pathlib import Path + +_BASE_DIR = Path(__file__).resolve().parents[1] +_CACHE_MARKER_FILE = _BASE_DIR / "data" / "pchome_growth_cache_epoch.txt" +_MEMORY_EPOCH = 0.0 + + +def get_pchome_growth_cache_epoch() -> float: + """Return the newest known invalidation epoch across workers.""" + global _MEMORY_EPOCH + try: + raw = _CACHE_MARKER_FILE.read_text(encoding="utf-8").strip() + file_epoch = float(raw or 0) + except (OSError, ValueError): + file_epoch = 0.0 + _MEMORY_EPOCH = max(_MEMORY_EPOCH, file_epoch) + return _MEMORY_EPOCH + + +def mark_pchome_growth_cache_stale() -> float: + """Invalidate cached PChome growth opportunities in every worker.""" + global _MEMORY_EPOCH + epoch = time.time() + _MEMORY_EPOCH = max(_MEMORY_EPOCH, epoch) + try: + _CACHE_MARKER_FILE.parent.mkdir(parents=True, exist_ok=True) + _CACHE_MARKER_FILE.write_text(f"{epoch:.6f}", encoding="utf-8") + except OSError: + pass + return _MEMORY_EPOCH diff --git a/tests/test_external_market_offer_service.py b/tests/test_external_market_offer_service.py index a14b205..e67f79e 100644 --- a/tests/test_external_market_offer_service.py +++ b/tests/test_external_market_offer_service.py @@ -196,13 +196,16 @@ def _seed_external_offer_sync_tables(engine): )) -def test_sync_legacy_momo_reference_offers_writes_verified_cache_to_external_offers(): - from services.external_market_offer_service import sync_legacy_momo_reference_offers +def test_sync_legacy_momo_reference_offers_writes_verified_cache_to_external_offers(monkeypatch): + from services import external_market_offer_service as service + + stale_marks = [] + monkeypatch.setattr(service, "mark_pchome_growth_cache_stale", lambda: stale_marks.append(True)) engine = create_engine("sqlite:///:memory:") _seed_external_offer_sync_tables(engine) - payload = sync_legacy_momo_reference_offers(engine, limit=20) + payload = service.sync_legacy_momo_reference_offers(engine, limit=20) assert payload["success"] is True assert payload["status"] == "synced" @@ -228,6 +231,7 @@ def test_sync_legacy_momo_reference_offers_writes_verified_cache_to_external_off assert row["quality_score"] == 91 assert row["data_quality_status"] == "verified" assert row["ingestion_method"] == "legacy_competitor_cache" + assert stale_marks == [True] def test_sync_legacy_momo_reference_offers_dry_run_does_not_write(): @@ -247,13 +251,16 @@ def test_sync_legacy_momo_reference_offers_dry_run_does_not_write(): assert count == 0 -def test_sync_targeted_momo_candidates_writes_unit_price_offer(): - from services.external_market_offer_service import sync_targeted_momo_candidates_to_external_offers +def test_sync_targeted_momo_candidates_writes_unit_price_offer(monkeypatch): + from services import external_market_offer_service as service + + stale_marks = [] + monkeypatch.setattr(service, "mark_pchome_growth_cache_stale", lambda: stale_marks.append(True)) engine = create_engine("sqlite:///:memory:") _seed_external_offer_sync_tables(engine) - payload = sync_targeted_momo_candidates_to_external_offers(engine, [ + payload = service.sync_targeted_momo_candidates_to_external_offers(engine, [ { "product_id": "10833188", "name": "MOMO B5 修復霜 40ml", @@ -315,6 +322,7 @@ def test_sync_targeted_momo_candidates_writes_unit_price_offer(): assert raw_payload["price_basis"] == "unit_price" assert raw_payload["pchome_public_price"] == 920 assert raw_payload["unit_price_comparison"]["unit_gap_pct"] == -49.13 + assert stale_marks == [True] def test_external_source_readiness_uses_legacy_momo_reference_cache(): diff --git a/tests/test_pchome_revenue_growth_service.py b/tests/test_pchome_revenue_growth_service.py index 59c5068..8ef17e6 100644 --- a/tests/test_pchome_revenue_growth_service.py +++ b/tests/test_pchome_revenue_growth_service.py @@ -203,6 +203,27 @@ def test_pchome_growth_understands_unit_price_external_offers(): assert any("單位價" in line for line in pchome_1["reason_lines"]) +def test_pchome_growth_route_cache_respects_shared_invalidation_epoch(monkeypatch): + from routes import ai_routes as routes + + epoch = {"value": 1.0} + monkeypatch.setattr(routes, "get_pchome_growth_cache_epoch", lambda: epoch["value"]) + routes._PCHOME_GROWTH_CACHE.update({ + "expires_at": 0.0, + "epoch": 0.0, + "payload": None, + }) + + routes._set_pchome_growth_cache({"success": True, "stats": {"candidate_count": 1}}) + + cached = routes._get_cached_pchome_growth_payload() + assert cached["success"] is True + assert cached["stats"]["candidate_count"] == 1 + + epoch["value"] = 2.0 + assert routes._get_cached_pchome_growth_payload() is None + + def test_ai_product_pick_sales_join_by_sku_disabled_by_default(monkeypatch): from services.ai_product_pick_agent import _sales_join_by_momo_sku_enabled