V10.542 拆分可用比價覆蓋率口徑

This commit is contained in:
OoO
2026-06-01 10:01:48 +08:00
parent 7e057435c5
commit 4bc271f7ed
16 changed files with 57 additions and 10 deletions

View File

@@ -4,6 +4,7 @@
================================================================================
【已完成】
- V10.542 拆開「可用比價覆蓋率」與「身份覆蓋率」:`decision_ready_rate = fresh identity / ACTIVE 商品數`Dashboard 第一張 KPI 改顯示真正可進入決策、圖表、簡報的比價資料比例daily / growth / Webcrumbs / OpenClaw payload 同步輸出,避免把身份覆蓋、新鮮率、價格可用率混成單一數字。
- V10.541 補正式覆核頁高信心 exact 線The Ordinary 咖啡因 EGCG 單側漏 30ml、Natures Care 綿羊油同入數 125ml/125m、TOMOON 指甲剪同 L/S 尺寸、HH 私密潔淨露+衣物手洗精雙 200ml、SEBAMED 護潔露 200ml x2、YES 德悅氏 9cm 剪刀;都同步進 revalidation SQL且 TOMOON/O.P.I 不同型號或尺寸仍不得自動通過。
- V10.540 補 O.P.I 類光繚指彩精準型號線:雙方同為 O.P.I 類光繚 / 如膠似漆指甲油或指彩,且共享 `ISL...` 型號 token 時才允許 total-price不同型號/色號仍不得自動寫入。同步把此族群接進 `true_low_confidence` revalidation 窄門,降低高信心指彩候選卡在人工覆核池的比例。
- V10.539 補 PChome 任選 catalog focused exact 線FLORTTE 水果沙拉眼線液筆 0.5ml、露得清護手霜 56g 無香/有香、Kanebo ALLIE 持采亮化 UV 防曬水凝乳 60g 雙方皆任選時可走 total-price同步接進 revalidation queue。focused bypass 新增 commercial condition 防線,`即期品` 等商業狀態差異不會被自動寫入正式價差。

View File

@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.541"
SYSTEM_VERSION = "V10.542"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -92,7 +92,7 @@ SQL漏斗(~300筆)
- 比對覆蓋率補強入口:`POST /api/ai/pchome-match/backfill`,優先補抓仍無有效 PChome 配對的高價 ACTIVE 商品,完成後自動重算 AI 挑品清單。
- 過期價格刷新入口:`POST /api/ai/pchome-match/refresh-stale`,只針對已建立 `identity_v2``expires_at` 過期的 PChome product_id 執行 `run_expired_identity_refresh()`;不得跑 fresh search recovery不得呼叫 LLM完成後重算 AI 挑品並清除 Dashboard / competitor intel cache。
- 過期 identity 搜尋救援入口:`POST /api/ai/pchome-match/recover-stale` 預設必須關閉主操作入口,僅保留只讀 preview正式 smoke 顯示小批次成功率不足且耗時偏高時,不得在 Dashboard 顯示日常操作按鈕。若需操作員手動執行,必須先明確設定 `PCHOME_STALE_RECOVERY_ENABLED=true`,再對已過期 `identity_v2` 先走既有 PChome product_id refresh只有舊 ID 查無商品或重評低於門檻時,才允許受控 fresh search recovery。救援隊列必須先排除 variant、catalog、commercial condition、count、bundle、unit-price 與任選 / 多款 / 香味 / 色號 / 即期 / 融燭燈 / 香氛蠟燭 / `+` / `xN` / `*N` / 具名香味或膚感版本等高風險名稱訊號。這條路徑可抓 PChome但不得呼叫 LLM正式寫入仍必須通過 matcher、hard veto、auto price write safety 與 overwrite protection。
- 補抓狀態入口:`GET /api/ai/pchome-match/backfill/status` 除背景任務狀態外,必須回傳 read-only coverage snapshot`active_with_price` / `valid_matches` / `match_rate` / `fresh_matches` / `fresh_match_rate` / `stale_matches` / `pending` / `actionable_review_count`,供 Dashboard 顯示目前該刷新過期價格或補抓未搜尋商品;此端點不寫 DB、不呼叫 LLM、不抓外站。
- 補抓狀態入口:`GET /api/ai/pchome-match/backfill/status` 除背景任務狀態外,必須回傳 read-only coverage snapshot`active_with_price` / `valid_matches` / `match_rate` / `fresh_matches` / `fresh_match_rate` / `decision_ready_matches` / `decision_ready_rate` / `stale_matches` / `pending` / `actionable_review_count`,供 Dashboard 顯示目前該刷新過期價格或補抓未搜尋商品;此端點不寫 DB、不呼叫 LLM、不抓外站。`match_rate` 是身份覆蓋率,`fresh_match_rate` 是已配對 identity 內的新鮮比例,`decision_ready_rate` 才是可直接進入決策、圖表與簡報的 ACTIVE 商品比價覆蓋率。
- 排程閉環:`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/<sku>/decision` 是人工閉環入口:`accept_identity` 才可把候選寫入 `competitor_prices``competitor_price_history` 並打上 `manual_review/manual_accept/identity_v2``reject_identity``unit_price_required``needs_research` 只寫 `competitor_match_reviews` 並追加 manual attempt不得把不同販售組合或否決候選灌入正式價差。PChome feeder 後續搜尋同一候選時必須讀取 `competitor_match_reviews`:已否決候選寫 `manual_rejected` 並跳過正式寫入,且必須繼續評估下一個候選,不能讓已否決候選長期阻塞同 SKU已標記單位價候選寫 `manual_unit_price_required`;已要求補搜尋候選寫 `manual_needs_research` 並停留在覆核隊列;已採用候選可保守補到最低門檻並保留 `manual_review/manual_accept` 標籤。搜尋候選池只有強同款分數達 `0.90` 才可提前停止,避免 0.76 灰區候選卡掉後續更精準搜尋詞。人工 `reject_identity``unit_price_required``needs_research` 若命中當前正式候選,必須將同候選 `competitor_prices` 過期,不得繼續顯示正式總價差。商品列表必須將 `manual_rejected``manual_unit_price_required``manual_needs_research` 顯示為明確人工閉環狀態,不可回落成籠統「待比對」。`fetch_competitor_coverage()` 必須輸出人工採用、人工否決、人工單位價與採用率daily/growth/PPT 共用 payload 必須顯示人工閉環成效,避免只呈現待審數。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。

View File

@@ -13,6 +13,7 @@
## 📅 詳細更新日誌 (考古存檔)
### 2026-06-01PChome 比價新鮮度操作閉環
- **V10.542 可用比價覆蓋率口徑拆分**: `fetch_competitor_coverage()` 新增 `decision_ready_matches` / `decision_ready_rate`,以「高信心 identity 且價格仍新鮮」除以 ACTIVE 商品數,和 `match_rate`(身份覆蓋)及 `fresh_match_rate`已配對中的新鮮率分開。Dashboard 第一屏改顯示可用比價覆蓋率daily / growth / Webcrumbs / OpenClaw payload 同步輸出,避免使用者把舊截圖的低價格可用率、身份覆蓋率與新鮮率混成同一個 KPI。
- **V10.541 高信心覆核池 exact 線再收斂**: 從正式覆核頁抓到多筆 `rescore_accepted_current` 仍卡人工的同款The Ordinary 咖啡因 EGCGMOMO 單側漏 30ml、Natures Care 綿羊油同入數、TOMOON 指甲剪同 L/S 尺寸、HH 私密潔淨露+私密衣物手洗精雙 200ml、SEBAMED 護潔露 200ml x2、YES 德悅氏 9cm 剪刀。新增 focused total-price 規則與 revalidation SQLTOMOON / O.P.I 仍要求同型號或同尺寸,避免跨款式誤配。
- **V10.540 O.P.I 指彩精準型號救回**: 正式覆核頁面顯示多筆 O.P.I 類光繚 / 如膠似漆指彩因 score 0.76~0.83 停在 `rescore_accepted_current` 人工池,但雙方都有明確 `ISL...` 型號(如 ISLL87、ISLL00、ISLN25。新增 `opi_gel_polish_exact_model` focused total-price 規則:必須同品牌、同類光繚指彩線、共享型號 token不同型號/色號不自動通過。同步接入 revalidation SQL讓舊 `true_low_confidence` 指彩候選可批次重評。
- **V10.539 任選 catalog focused exact 與 commercial 防線**: 新增 FLORTTE 水果沙拉眼線液筆 0.5ml、露得清護手霜 56g 無香/有香、Kanebo ALLIE 持采亮化 UV 防曬水凝乳 60g 的 focused total-price 規則,條件是雙方同品牌、同品線、同規格且任選 catalog 語意一致;同步接入 `run_retryable_candidate_revalidation()`。同時修正 focused bypass若存在 `commercial_condition_gap`(例如即期品),不得移除 `variant_selection_review`,避免商業狀態差異被自動寫入正式價差。

View File

@@ -2137,6 +2137,10 @@ def _build_pchome_backfill_coverage_payload():
'match_rate': float(coverage.get('match_rate') or 0),
'fresh_matches': int(coverage.get('fresh_matches') or 0),
'fresh_match_rate': float(coverage.get('fresh_match_rate') or 0),
'decision_ready_matches': int(
coverage.get('decision_ready_matches') or coverage.get('fresh_matches') or 0
),
'decision_ready_rate': float(coverage.get('decision_ready_rate') or 0),
'stale_matches': int(coverage.get('stale_matches') or 0),
'pending': int(coverage.get('pending') or 0),
'actionable_review_count': int(coverage.get('actionable_review_count') or 0),

View File

@@ -686,6 +686,8 @@ def _merge_competitor_review_context(overview, review_context):
'match_rate': coverage.get('match_rate', overview.get('match_rate') or 0),
'fresh_match_count': int(coverage.get('fresh_matches') or 0),
'fresh_match_rate': coverage.get('fresh_match_rate', 0),
'decision_ready_count': int(coverage.get('decision_ready_matches') or coverage.get('fresh_matches') or 0),
'decision_ready_rate': coverage.get('decision_ready_rate', 0),
'stale_match_count': int(coverage.get('stale_matches') or 0),
'pending_match_count': int(coverage.get('pending') or overview.get('pending_match_count') or 0),
'review_queue_count': int(coverage.get('actionable_review_count') or len(review_queue) or 0),
@@ -829,6 +831,8 @@ def _load_competitor_decision_overview(session, latest_items=None):
'match_rate': 0,
'fresh_match_count': 0,
'fresh_match_rate': 0,
'decision_ready_count': 0,
'decision_ready_rate': 0,
'stale_match_count': 0,
'pchome_advantage_count': 0,
'momo_threat_count': 0,
@@ -1686,6 +1690,8 @@ def _load_cached_competitor_overview_for_review(now_taipei, review_queue, review
'total_active': 0,
'matched_count': 0,
'match_rate': 0,
'decision_ready_count': 0,
'decision_ready_rate': 0,
'pchome_advantage_count': 0,
'momo_threat_count': 0,
'near_count': 0,

View File

@@ -705,7 +705,7 @@ def _cached_payload(cache_key: str, producer, ttl_seconds: int = COMPETITOR_INTE
def fetch_competitor_coverage(engine) -> dict:
return _cached_payload(
f"coverage:v7:floor={PCHOME_MATCH_SCORE_FLOOR}:manual_reviews=1:rescore=1:review_no_fresh=1",
f"coverage:v8:floor={PCHOME_MATCH_SCORE_FLOOR}:manual_reviews=1:rescore=1:review_no_fresh=1:decision_ready=1",
lambda: _fetch_competitor_coverage_uncached(engine),
)
@@ -723,8 +723,10 @@ def _fetch_competitor_coverage_uncached(engine) -> dict:
"fresh_matches": 0,
"stale_matches": 0,
"pending": 0,
"decision_ready_matches": 0,
"match_rate": 0,
"fresh_match_rate": 0,
"decision_ready_rate": 0,
"attempt_status": {},
"unit_comparable_count": 0,
"rescore_accepted_count": 0,
@@ -838,8 +840,10 @@ def _fetch_competitor_coverage_uncached(engine) -> dict:
"fresh_matches": fresh,
"stale_matches": stale,
"pending": pending,
"decision_ready_matches": fresh,
"match_rate": round(valid / max(active, 1) * 100, 1),
"fresh_match_rate": round(fresh / max(valid, 1) * 100, 1),
"decision_ready_rate": round(fresh / max(active, 1) * 100, 1),
"attempt_status": statuses,
"unit_comparable_count": unit_count,
"rescore_accepted_count": rescore_accepted_count,

View File

@@ -607,6 +607,10 @@ def _fetch_competitor_summary() -> Dict[str, Any]:
"premium_count": int((row[3] if row else 0) or 0),
"match_rate": float(coverage.get("match_rate") or 0),
"active_with_price": int(coverage.get("active_with_price") or 0),
"decision_ready_matches": int(
coverage.get("decision_ready_matches") or coverage.get("fresh_matches") or 0
),
"decision_ready_rate": float(coverage.get("decision_ready_rate") or 0),
"unit_comparable_count": int(coverage.get("unit_comparable_count") or 0),
"rescore_accepted_count": int(coverage.get("rescore_accepted_count") or 0),
"review_queue_count": int(coverage.get("actionable_review_count") or 0),

View File

@@ -80,6 +80,14 @@ def _coverage_metadata(coverage: dict, row_count: int) -> dict:
"source": "competitor_intel_repository",
"matched_count": int(coverage.get("valid_matches") or coverage.get("matched_count") or 0),
"coverage_rate": _num(coverage.get("match_rate")),
"identity_coverage_rate": _num(coverage.get("match_rate")),
"decision_ready_count": int(
coverage.get("decision_ready_matches")
or coverage.get("fresh_matches")
or coverage.get("fresh_match_count")
or 0
),
"decision_ready_rate": _num(coverage.get("decision_ready_rate")),
"fresh_match_count": int(coverage.get("fresh_matches") or coverage.get("fresh_match_count") or 0),
"fresh_match_rate": _num(coverage.get("fresh_match_rate")),
"stale_match_count": int(coverage.get("stale_matches") or coverage.get("stale_match_count") or 0),

View File

@@ -343,6 +343,10 @@
</div>
<div class="card-body">
<div class="daily-competitor-summary">
<div>
<span>可用比價覆蓋率</span>
<strong class="momo-mono">{{ comp_coverage.decision_ready_rate | default(0) }}%</strong>
</div>
<div>
<span>身份配對</span>
<strong class="momo-mono">{{ comp_coverage.valid_matches | default(0) | number_format }}</strong>
@@ -353,7 +357,7 @@
</div>
<div>
<span>價格新鮮</span>
<strong class="momo-mono">{{ comp_coverage.fresh_matches | default(0) | number_format }}</strong>
<strong class="momo-mono">{{ comp_coverage.decision_ready_matches | default(comp_coverage.fresh_matches | default(0)) | number_format }}</strong>
</div>
<div>
<span>價格過期</span>

View File

@@ -17,11 +17,11 @@
</div>
<div class="dashboard-kpi-grid">
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">身份覆蓋率</div>
<div class="dashboard-kpi-value momo-mono">{{ overview.match_rate | default(0) }}%</div>
<div class="dashboard-kpi-label momo-mono">可用比價覆蓋率</div>
<div class="dashboard-kpi-value momo-mono">{{ overview.decision_ready_rate | default(0) }}%</div>
<div class="dashboard-kpi-sub momo-mono">
{{ overview.matched_count | default(0) | number_format }} / {{ overview.total_active | default(total_products) | number_format }} ACTIVE
· 新鮮 {{ overview.fresh_match_count | default(0) | number_format }}
{{ overview.decision_ready_count | default(0) | number_format }} / {{ overview.total_active | default(total_products) | number_format }} ACTIVE
· 身份 {{ overview.match_rate | default(0) }}%
· 過期 {{ overview.stale_match_count | default(0) | number_format }}
</div>
</div>

View File

@@ -141,6 +141,8 @@
<div class="ga-competitor-quality">
<span>高信心門檻</span>
<strong class="momo-mono">{{ coverage.match_score_floor | default(0.76) }}</strong>
<span>可用比價覆蓋率</span>
<strong class="momo-mono">{{ coverage.decision_ready_rate | default(0) }}%</strong>
<span>身份配對</span>
<strong class="momo-mono">{{ coverage.valid_matches | default(0) | number_format }}</strong>
<span>身份覆蓋率</span>

View File

@@ -75,7 +75,7 @@ def test_competitor_coverage_counts_only_active_product_intersection():
"def _fetch_manual_review_summary", 1
)[0]
assert "coverage:v7" in source
assert "coverage:v8" in source
assert "rescore_accepted_count" in coverage_source
assert "(SELECT COUNT(*) FROM valid_competitor) AS valid_matches" not in coverage_source
assert "identity_competitor AS" in coverage_source
@@ -85,6 +85,8 @@ def test_competitor_coverage_counts_only_active_product_intersection():
assert "WHERE fc.sku IS NULL" in coverage_source
assert "\"fresh_matches\": fresh" in coverage_source
assert "\"stale_matches\": stale" in coverage_source
assert "\"decision_ready_matches\": fresh" in coverage_source
assert "\"decision_ready_rate\": round(fresh / max(active, 1) * 100, 1)" in coverage_source
assert "FROM products p\n JOIN LATERAL" in coverage_source
assert "WHERE p.status = 'ACTIVE'" in coverage_source
@@ -129,6 +131,7 @@ def test_competitor_review_queue_is_canonical_unit_price_handoff():
assert "competitor_intel.review_queue" in daily_template
assert "coverage.fresh_matches" in growth_template
assert "coverage.fresh_match_rate" in growth_template
assert "coverage.decision_ready_rate" in growth_template
assert "coverage.stale_matches" in growth_template
assert "coverage.unit_comparable_count" in growth_template
assert "coverage.rescore_accepted_count" in growth_template
@@ -139,6 +142,7 @@ def test_competitor_review_queue_is_canonical_unit_price_handoff():
assert "comp_coverage.rescore_accepted_count" in daily_template
assert "重算待人工覆核" in daily_template
assert "comp_coverage.stale_matches" in daily_template
assert "comp_coverage.decision_ready_rate" in daily_template
def test_competitor_review_filters_split_low_score_operational_buckets():

View File

@@ -542,6 +542,7 @@ def test_ai_product_pick_agent_uses_real_competitor_data_and_dashboard_action():
assert "JSON.stringify({ limit: 50 })" in template
assert "完成後會重算 AI 挑品清單" in route_source
assert "match_rate" in route_source
assert "decision_ready_rate" in route_source
assert "product_pick_count" in route_source
dashboard_route_source = (ROOT / "routes/dashboard_routes.py").read_text(encoding="utf-8")
dashboard_template = (ROOT / "templates/dashboard_v2.html").read_text(encoding="utf-8")
@@ -586,6 +587,7 @@ def test_ai_product_pick_agent_uses_real_competitor_data_and_dashboard_action():
assert "formatBackfillCoverageSummary" in dashboard_js
assert "formatBackfillLimitedCount" in dashboard_js
assert "status.coverage" in dashboard_js
assert "可用比價 ${formatBackfillRate(coverage.decision_ready_rate)}" in dashboard_js
assert "待刷新 ${formatBackfillCount(coverage.stale_matches)}" in dashboard_js
assert "待補抓 ${formatBackfillCount(coverage.pending)}" in dashboard_js
assert "可重評 ${formatBackfillLimitedCount(preview.candidate_count" in dashboard_js

View File

@@ -43,6 +43,8 @@ def test_webcrumbs_host_data_maps_price_alert_exact_rows(monkeypatch):
"match_rate": 12.3,
"fresh_matches": 70,
"fresh_match_rate": 79.5,
"decision_ready_matches": 70,
"decision_ready_rate": 9.8,
"stale_matches": 18,
"pending": 612,
},
@@ -62,6 +64,9 @@ def test_webcrumbs_host_data_maps_price_alert_exact_rows(monkeypatch):
assert payload["metadata"]["fetches_external"] is False
assert payload["metadata"]["matched_count"] == 88
assert payload["metadata"]["coverage_rate"] == 12.3
assert payload["metadata"]["identity_coverage_rate"] == 12.3
assert payload["metadata"]["decision_ready_count"] == 70
assert payload["metadata"]["decision_ready_rate"] == 9.8
assert payload["metadata"]["fresh_match_count"] == 70
assert payload["metadata"]["fresh_match_rate"] == 79.5
assert payload["metadata"]["stale_match_count"] == 18
@@ -82,6 +87,7 @@ def test_webcrumbs_host_data_uses_empty_state_without_risks(monkeypatch):
assert payload["marketSnapshot"][0]["freshness_status"] == "no_current_exact_risk"
assert payload["aiCandidate"]["release_status"] == "blocked"
assert payload["metadata"]["matched_count"] == 3
assert payload["metadata"]["decision_ready_count"] == 1
assert payload["metadata"]["fresh_match_count"] == 1
assert payload["metadata"]["stale_match_count"] == 2
assert payload["metadata"]["pending_match_count"] == 9

View File

@@ -328,7 +328,8 @@ let priceChartInstance = null;
? ` · 可救援 ${formatBackfillLimitedCount(staleRecovery.candidate_count, staleRecovery.has_more)}`
: '';
return (
`身份覆蓋 ${formatBackfillRate(coverage.match_rate)}`
`可用比價 ${formatBackfillRate(coverage.decision_ready_rate)}`
+ ` · 身份 ${formatBackfillRate(coverage.match_rate)}`
+ ` · 新鮮 ${formatBackfillRate(coverage.fresh_match_rate)}`
+ ` · 待刷新 ${formatBackfillCount(coverage.stale_matches)}`
+ ` · 待補抓 ${formatBackfillCount(coverage.pending)}`