V10.549 收斂比價新鮮度口徑

This commit is contained in:
OoO
2026-06-01 12:34:16 +08:00
parent 12dc452061
commit 339bf68e14
10 changed files with 71 additions and 31 deletions

View File

@@ -4,6 +4,7 @@
================================================================================
【已完成】
- V10.549 收斂比價新鮮度 KPI 口徑coverage cache 升到 v10`expires_at IS NULL` 不再算進「可用比價 / decision ready」改拆成 `unknown_freshness_matches` / `unknown_freshness_count`避免沒有到期時間的舊資料被當成可直接決策的新鮮價格。Dashboard / daily / growth 同步顯示未知新鮮度與「未形成有效身份配對」,並把 PChome/MOMO 價格方向文案改成 `PChome 價格壓力` / `MOMO 價格優勢`,降低誤讀。
- V10.548 接線更多 focused exact 舊候選回刷:把 matcher 已驗證可安全走 total-price 的 3W CLINIC 膠原蛋白粉底液 50ml x2、花美水 Moisture/Inclear 1.7g x3、KUSSEN 寶寶益菌屁屁膏 50ml 3 入、Lab52 齒妍堂嬰幼兒/汪汪隊牙刷 2 入接進 `_fetch_retryable_candidate_skus()` focused true-low / rescore 窄門。這只擴大「舊候選可被新版 matcher 重評」的入口,不改 `MIN_MATCH_SCORE`、hard veto、auto price write safety 或既有覆寫保護。
- V10.547 強化單位價覆核洞察:`manual_unit_price_required` 不再只是人工狀態覆核隊列與商品看板會重新帶出單位價換算、MOMO/PChome 單位價方向、差距百分比與處理建議;決策信封 / OpenClaw / PPT 摘要可讀到 `unit_price_insight`。人工覆核寫回也會保留原始 `match_diagnostic_json` / comparison mode / diagnostic codes避免後續簡報、審計或 AI 策略只剩人工文案而失去 matcher 證據鏈。
- V10.546 補近門檻舊候選回刷隊列:`run_retryable_candidate_revalidation()` 新增 `legacy_unmasked_attempt`,當最新狀態是 `no_result` / `refresh_no_result` / `expired_match` 時,可回撈同 SKU 早期近門檻候選交給最新版 matcher 重評;仍要求 candidate id、分數下限、無 hard veto、exact_identity且不打開人工否決、單位價、identity_veto 或 protected existing match。

View File

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

View File

@@ -13,6 +13,7 @@
## 📅 詳細更新日誌 (考古存檔)
### 2026-06-01PChome 比價新鮮度操作閉環
- **V10.549 比價新鮮度 KPI 口徑收斂**: `fetch_competitor_coverage()` cache 升到 v10`expires_at IS NULL` 不再混入 `fresh_matches` / `decision_ready_rate`,改拆成 `unknown_freshness_matches``unknown_freshness_rate`,讓「可用比價覆蓋率」只代表有明確未過期時間的 identity 價格。Dashboard、daily、growth 同步顯示未知新鮮度與「未形成有效身份配對」,第一屏資料時間改看最新有效 PChome 價格抓取,並把價格方向文案改為 `PChome 價格壓力` / `MOMO 價格優勢`
- **V10.548 focused exact 舊候選回刷接線**: `_fetch_retryable_candidate_skus()` 的 focused true-low / rescore 窄門新增 3W CLINIC 膠原蛋白粉底液 50ml x2、花美水 Moisture/Inclear 1.7g x3、KUSSEN 寶寶益菌屁屁膏 50ml 3 入、Lab52 齒妍堂嬰幼兒 / 汪汪隊牙刷 2 入。這些品線在 matcher 測試中已是 `exact / total_price / price_alert_exact`,本次只讓舊 `true_low_confidence` / `rescore_accepted_current` 候選能被新版 matcher 重新判斷;仍不放寬 `MIN_MATCH_SCORE`、hard veto、auto write safety 與 stronger existing match 保護。
- **V10.547 單位價覆核洞察與證據鏈保留**: `manual_unit_price_required` 現在會和 `unit_comparable` 一樣重新產生單位價比較,並轉成 `unit_price_insight`,明確標示 PChome 或 MOMO 哪邊單位價較低、差距百分比、嚴重度與操作建議Dashboard 覆核卡、商品列、決策信封與 OpenClaw/PPT 摘要都可讀到這個訊號。人工覆核寫回 `competitor_match_attempts` 時也會在欄位存在時保留原始 `match_diagnostic_json``comparison_mode``hard_veto``diagnostic_codes``competitor_match_reviews.candidate_diagnostic` 同步附帶 JSON 證據,避免人工閉環後只剩狀態文字。
- **V10.546 近門檻舊候選回刷隊列補漏**: `run_retryable_candidate_revalidation()` 的候選來源不再只看每個 SKU 最新一筆 attempt。新增 `legacy_unmasked_attempt`,當最新狀態是 `no_result` / `refresh_no_result` / `expired_match` 時,可回撈同 SKU 早期 `low_score``recoverable_low_score``true_low_confidence``rescore_accepted_current` 的近門檻候選,再交給最新版 matcher 重評。此入口仍要求 candidate product id、分數下限、無 hard veto、`exact_identity`且不打開人工否決、單位價、identity_veto 或 protected existing match避免為了覆蓋率破壞安全邊界。

View File

@@ -372,19 +372,19 @@ def _build_competitor_decision(momo_price, pchome_price, match_status=None):
if gap_pct >= 5:
return {
'label': 'PChome 優勢',
'tone': 'win',
'gap_amount': gap_amount,
'gap_pct': gap_pct,
'summary': 'PChome 較便宜,可加強曝光與轉換'
}
if gap_pct <= -5:
return {
'label': 'MOMO 威脅',
'label': 'PChome 價格壓力',
'tone': 'risk',
'gap_amount': gap_amount,
'gap_pct': gap_pct,
'summary': 'MOMO 較便宜,需評估價格促銷因應'
'summary': 'PChome 較便宜,需評估 MOMO 價格促銷或曝光策略'
}
if gap_pct <= -5:
return {
'label': 'MOMO 價格優勢',
'tone': 'win',
'gap_amount': gap_amount,
'gap_pct': gap_pct,
'summary': 'MOMO 較便宜,可優先檢查毛利與曝光機會'
}
return {
'label': '價格接近',
@@ -714,6 +714,7 @@ def _merge_competitor_review_context(overview, review_context):
'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),
'unknown_freshness_count': int(coverage.get('unknown_freshness_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),
'manual_closed_count': int(coverage.get('manual_closed_count') or 0),
@@ -863,6 +864,7 @@ def _load_competitor_decision_overview(session, latest_items=None):
'decision_ready_count': 0,
'decision_ready_rate': 0,
'stale_match_count': 0,
'unknown_freshness_count': 0,
'pchome_advantage_count': 0,
'momo_threat_count': 0,
'near_count': 0,
@@ -1721,6 +1723,7 @@ def _load_cached_competitor_overview_for_review(now_taipei, review_queue, review
'match_rate': 0,
'decision_ready_count': 0,
'decision_ready_rate': 0,
'unknown_freshness_count': 0,
'pchome_advantage_count': 0,
'momo_threat_count': 0,
'near_count': 0,

View File

@@ -778,7 +778,7 @@ def _cached_payload(cache_key: str, producer, ttl_seconds: int = COMPETITOR_INTE
def fetch_competitor_coverage(engine) -> dict:
return _cached_payload(
f"coverage:v9:floor={PCHOME_MATCH_SCORE_FLOOR}:manual_reviews=1:rescore=1:review_no_fresh=1:decision_ready=1:open_queue=1",
f"coverage:v10:floor={PCHOME_MATCH_SCORE_FLOOR}:manual_reviews=1:rescore=1:review_no_fresh=1:decision_ready=1:open_queue=1:unknown_freshness=1",
lambda: _fetch_competitor_coverage_uncached(engine),
)
@@ -795,14 +795,18 @@ def _fetch_competitor_coverage_uncached(engine) -> dict:
"valid_matches": 0,
"fresh_matches": 0,
"stale_matches": 0,
"unknown_freshness_matches": 0,
"pending": 0,
"decision_ready_matches": 0,
"identity_coverage_matches": 0,
"identity_coverage_rate": 0,
"pending_identity_count": 0,
"stale_identity_count": 0,
"unknown_freshness_count": 0,
"not_decision_ready_count": 0,
"match_rate": 0,
"fresh_match_rate": 0,
"unknown_freshness_rate": 0,
"decision_ready_rate": 0,
"last_decision_ready_crawled_at": None,
"attempt_status": {},
@@ -871,7 +875,17 @@ def _fetch_competitor_coverage_uncached(engine) -> dict:
fresh_competitor AS (
SELECT sku, crawled_at
FROM identity_competitor
WHERE expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP
WHERE expires_at > CURRENT_TIMESTAMP
),
unknown_freshness_competitor AS (
SELECT sku, crawled_at
FROM identity_competitor
WHERE expires_at IS NULL
),
stale_competitor AS (
SELECT sku, crawled_at
FROM identity_competitor
WHERE expires_at <= CURRENT_TIMESTAMP
),
{attempt_cte}
SELECT
@@ -884,9 +898,10 @@ def _fetch_competitor_coverage_uncached(engine) -> dict:
JOIN fresh_competitor fc ON fc.sku = lm.sku) AS fresh_matches,
(SELECT COUNT(*)
FROM latest_momo lm
JOIN identity_competitor ic ON ic.sku = lm.sku
LEFT JOIN fresh_competitor fc ON fc.sku = lm.sku
WHERE fc.sku IS NULL) AS stale_matches,
JOIN stale_competitor sc ON sc.sku = lm.sku) AS stale_matches,
(SELECT COUNT(*)
FROM latest_momo lm
JOIN unknown_freshness_competitor ufc ON ufc.sku = lm.sku) AS unknown_freshness_matches,
(SELECT COUNT(*)
FROM latest_momo lm
LEFT JOIN identity_competitor ic ON ic.sku = lm.sku
@@ -909,6 +924,7 @@ def _fetch_competitor_coverage_uncached(engine) -> dict:
valid = int(rows[0].get("valid_matches") or 0) if rows else 0
fresh = int(rows[0].get("fresh_matches") or 0) if rows else 0
stale = int(rows[0].get("stale_matches") or 0) if rows else 0
unknown_freshness = int(rows[0].get("unknown_freshness_matches") or 0) if rows else 0
pending = int(rows[0].get("pending") or 0) if rows else 0
statuses = {
str(row.get("attempt_status")): int(row.get("status_count") or 0)
@@ -924,14 +940,18 @@ def _fetch_competitor_coverage_uncached(engine) -> dict:
"valid_matches": valid,
"fresh_matches": fresh,
"stale_matches": stale,
"unknown_freshness_matches": unknown_freshness,
"pending": pending,
"decision_ready_matches": fresh,
"identity_coverage_matches": valid,
"identity_coverage_rate": round(valid / max(active, 1) * 100, 1),
"pending_identity_count": pending,
"stale_identity_count": stale,
"unknown_freshness_count": unknown_freshness,
"not_decision_ready_count": pending + stale + unknown_freshness,
"match_rate": round(valid / max(active, 1) * 100, 1),
"fresh_match_rate": round(fresh / max(valid, 1) * 100, 1),
"unknown_freshness_rate": round(unknown_freshness / max(valid, 1) * 100, 1),
"decision_ready_rate": round(fresh / max(active, 1) * 100, 1),
"last_decision_ready_crawled_at": last_decision_ready_crawled_at,
"attempt_status": statuses,

View File

@@ -364,7 +364,11 @@
<strong class="momo-mono">{{ comp_coverage.stale_matches | default(0) | number_format }}</strong>
</div>
<div>
<span>待審/待補</span>
<span>未知新鮮度</span>
<strong class="momo-mono">{{ comp_coverage.unknown_freshness_matches | default(0) | number_format }}</strong>
</div>
<div>
<span>未形成有效身份配對</span>
<strong class="momo-mono">{{ comp_coverage.pending | default(0) | number_format }}</strong>
</div>
<div>

View File

@@ -13,7 +13,7 @@
<div class="dashboard-section-label">
<span class="num momo-mono">01</span>
<span class="title">比價監控總覽</span>
<span class="meta momo-mono">LIVE · 更新於 {{ datetime_now }}</span>
<span class="meta momo-mono">KPI · 最新有效價格 {{ overview.last_pchome_crawled or '待刷新' }}</span>
</div>
<div class="dashboard-kpi-grid">
<div class="dashboard-kpi">
@@ -23,16 +23,17 @@
{{ overview.decision_ready_count | default(0) | number_format }} / {{ overview.total_active | default(total_products) | number_format }} ACTIVE
· 身份 {{ overview.identity_coverage_rate | default(overview.match_rate | default(0)) }}%
· 過期 {{ overview.stale_match_count | default(0) | number_format }}
· 未設到期 {{ overview.unknown_freshness_count | default(0) | number_format }}
</div>
</div>
<div class="dashboard-kpi is-accent">
<div class="dashboard-kpi-label momo-mono">PChome 優勢</div>
<div class="dashboard-kpi-label momo-mono">PChome 價格壓力</div>
<div class="dashboard-kpi-value momo-mono">{{ overview.pchome_advantage_count | default(0) | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">平均價差 +{{ overview.avg_advantage_gap | default(0) }}%</div>
<div class="dashboard-kpi-sub momo-mono">PChome 較低 · 平均價差 +{{ overview.avg_advantage_gap | default(0) }}%</div>
</div>
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">MOMO 威脅</div>
<div class="dashboard-kpi-value momo-mono is-danger">{{ overview.momo_threat_count | default(0) | number_format }}</div>
<div class="dashboard-kpi-label momo-mono">MOMO 價格優勢</div>
<div class="dashboard-kpi-value momo-mono is-success">{{ overview.momo_threat_count | default(0) | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">MOMO 價格低於 PChome</div>
</div>
<div class="dashboard-kpi">
@@ -53,12 +54,12 @@
</div>
</div>
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">可用資料新鮮度</div>
<div class="dashboard-kpi-value momo-mono is-small">{{ '已更新' if overview.last_pchome_crawled else '待新' }}</div>
<div class="dashboard-kpi-label momo-mono">最新有效價格抓取</div>
<div class="dashboard-kpi-value momo-mono is-small">{{ overview.last_pchome_crawled or '待新' }}</div>
<div class="dashboard-kpi-sub momo-mono">
{{ overview.last_pchome_crawled or '尚無 PChome 抓取紀錄' }}
· 新鮮率 {{ overview.fresh_match_rate | default(0) }}%
新鮮率 {{ overview.fresh_match_rate | default(0) }}%
· 待刷新 {{ overview.stale_match_count | default(0) | number_format }}
· 未設到期 {{ overview.unknown_freshness_count | default(0) | number_format }}
</div>
</div>
</div>
@@ -72,7 +73,7 @@
<div class="dashboard-backfill-label momo-mono">PCHOME MATCH BACKFILL</div>
<div class="dashboard-backfill-title">PChome 補抓產線</div>
<div class="dashboard-backfill-meta momo-mono">
待刷新 {{ overview.stale_match_count | default(0) | number_format }} · 待補抓 {{ overview.pending_match_count | default(0) | number_format }} · 待處理覆核 {{ overview.review_queue_count | default(0) | number_format }} · 人工閉環 {{ overview.manual_closed_count | default(0) | number_format }} · 單位價 {{ overview.unit_comparable_count | default(0) | number_format }}
待刷新 {{ overview.stale_match_count | default(0) | number_format }} · 未設到期 {{ overview.unknown_freshness_count | default(0) | number_format }} · 待補抓 {{ overview.pending_match_count | default(0) | number_format }} · 待處理覆核 {{ overview.review_queue_count | default(0) | number_format }} · 人工閉環 {{ overview.manual_closed_count | default(0) | number_format }} · 單位價 {{ overview.unit_comparable_count | default(0) | number_format }}
</div>
</div>
<div class="dashboard-backfill-progress" aria-hidden="true">

View File

@@ -153,7 +153,9 @@
<strong class="momo-mono">{{ coverage.fresh_match_rate | default(0) }}%</strong>
<span>價格過期</span>
<strong class="momo-mono">{{ coverage.stale_matches | default(0) | number_format }}</strong>
<span>待審/待補</span>
<span>未知新鮮度</span>
<strong class="momo-mono">{{ coverage.unknown_freshness_matches | default(0) | number_format }}</strong>
<span>未形成有效身份配對</span>
<strong class="momo-mono">{{ coverage.pending | default(0) | number_format }}</strong>
<span>需單位價覆核</span>
<strong class="momo-mono">{{ coverage.unit_comparable_count | default(0) | number_format }}</strong>

View File

@@ -75,16 +75,21 @@ def test_competitor_coverage_counts_only_active_product_intersection():
"def _fetch_manual_review_summary", 1
)[0]
assert "coverage:v9" in source
assert "coverage:v10" 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
assert "fresh_competitor AS" in coverage_source
assert "unknown_freshness_competitor AS" in coverage_source
assert "WHERE expires_at > CURRENT_TIMESTAMP" in coverage_source
assert "WHERE expires_at IS NULL" in coverage_source
assert "FROM latest_momo lm\n JOIN identity_competitor ic ON ic.sku = lm.sku" in coverage_source
assert "LEFT JOIN fresh_competitor fc ON fc.sku = lm.sku" in coverage_source
assert "WHERE fc.sku IS NULL" in coverage_source
assert "\"fresh_matches\": fresh" in coverage_source
assert "\"stale_matches\": stale" in coverage_source
assert "\"unknown_freshness_matches\": unknown_freshness" in coverage_source
assert "\"not_decision_ready_count\": pending + stale + unknown_freshness" 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 "\"identity_coverage_matches\": valid" in coverage_source
@@ -136,6 +141,8 @@ def test_competitor_review_queue_is_canonical_unit_price_handoff():
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.unknown_freshness_matches" in growth_template
assert "未形成有效身份配對" in growth_template
assert "coverage.unit_comparable_count" in growth_template
assert "coverage.rescore_accepted_count" in growth_template
assert "重算待人工覆核" in growth_template
@@ -145,6 +152,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.unknown_freshness_matches" in daily_template
assert "comp_coverage.decision_ready_rate" in daily_template

View File

@@ -421,8 +421,8 @@ def test_dashboard_v2_shows_pchome_competitor_pricing_and_links():
assert "competitor_product_id" in route_source
assert "https://24h.pchome.com.tw/prod/" in route_source
assert "_build_competitor_decision(" in route_source
assert "PChome 優勢" in route_source
assert "MOMO 威脅" in route_source
assert "PChome 價格壓力" in route_source
assert "MOMO 價格優勢" in route_source
assert "item['pchome_competitor']" in route_source
assert "item['competitor_decision']" in route_source