V10.622 refresh growth cache after offer sync
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s
This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 情報頁作戰清單」變成同一條即時資料流,減少使用者手動重新整理或等待快取過期。
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
37
services/pchome_growth_cache_state.py
Normal file
37
services/pchome_growth_cache_state.py
Normal 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
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user