V10.622 refresh growth cache after offer sync
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s

This commit is contained in:
OoO
2026-06-16 11:49:06 +08:00
parent 01c73e02a2
commit 3c0e558fbe
8 changed files with 94 additions and 8 deletions

View File

@@ -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 # 用於模板顯示

View File

@@ -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

View File

@@ -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 情報頁作戰清單」變成同一條即時資料流,減少使用者手動重新整理或等待快取過期。

View File

@@ -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,
})

View File

@@ -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,

View File

@@ -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

View File

@@ -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():

View File

@@ -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