V10.545 收斂比價覆蓋率口徑

This commit is contained in:
OoO
2026-06-01 12:02:22 +08:00
parent 83b813c1b9
commit e4534c2f96
7 changed files with 65 additions and 19 deletions

View File

@@ -4,6 +4,7 @@
================================================================================
【已完成】
- V10.545 收斂 Dashboard 比價覆蓋率口徑coverage cache 升到 v9新增身份覆蓋、可用比價、新鮮度、待補身份、過期身份與人工閉環欄位商品看板和 PChome 覆核頁改只把真正待處理狀態算進「比價覆核」,人工已否決 / 人工單位價 / 需補研究改列為人工閉環PChome competitor map 只吃有效價格、新鮮、identity_v2 最新 row資料新鮮度也改看可用比價 row。
- V10.544 收斂變體安全與 YES 指甲工具線:新增 YES 德悅氏指甲剪附除垢銼刀、腳皮銼腳板、藍寶石銼刀、三面拋光棒與 6/8cm 指甲剪的精準 total-price 線,要求同品牌、同工具名稱、同尺寸與同亮面/霧面/可收納/三面/不掉屑等款式訊號;同步接進 revalidation SQL。新增 MUJI / COCODOR 未知香味差異與 OPI 無型號不同色名 hard vetoHOOOME 暖燈材質差留人工覆核,搜尋詞也會優先帶香味/色名,提升 crawler 精準候選率。
- V10.543 打通 `rescore_accepted_current` 窄門回刷:已進人工覆核池的候選若命中具名 focused exact 線,可進 `run_retryable_candidate_revalidation()` 重新評分;新增 SK-II 青春露 330ml 兩入、AMIINO 安美諾 30ml、YES 腳指甲剪刀 10.5cm、YES 極細指甲緣硬皮剪刀 9cm 的安全 total-price 線,並補 ANNY / OPI 指甲油型號 code hard veto避免不同色號被錯配。
- V10.542 拆開「可用比價覆蓋率」與「身份覆蓋率」:`decision_ready_rate = fresh identity / ACTIVE 商品數`Dashboard 第一張 KPI 改顯示真正可進入決策、圖表、簡報的比價資料比例daily / growth / Webcrumbs / OpenClaw payload 同步輸出,避免把身份覆蓋、新鮮率、價格可用率混成單一數字。

View File

@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.544"
SYSTEM_VERSION = "V10.545"
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.545 Dashboard 比價覆蓋率口徑收斂**: 商品看板與 PChome 覆核頁把「身份覆蓋率」與「可用比價覆蓋率」拆成明確欄位coverage cache 更新為 v9 並回傳 `identity_coverage_*``pending_identity_count``stale_identity_count``last_decision_ready_crawled_at`。覆核 KPI 改只計算真正待處理狀態,人工否決 / 人工單位價 / 需補研究另列 `manual_closed_count`避免人工閉環候選被混進待審總數。Dashboard 的 PChome competitor map 也改成只取新鮮、有效價格、identity_v2 的最新 row資料新鮮度改看可用比價 row不再被無效或低信心抓取紀錄撐高。
- **V10.544 變體安全與 YES 工具線收斂**: 延續近門檻 `low_score` 救回,但把安全邊界補得更硬。新增 YES 德悅氏指甲工具精準線,只有同品牌、同工具線、同尺寸且同亮面/霧面/可收納/三面等關鍵款式時才進 `total_price`,並接入 revalidation SQL。同步新增未知香味差異與無型號指彩色名差異 hard vetoMUJI / COCODOR 不同香型、OPI 無型號不同色名不再被高分誤配HOOOME 暖燈陶瓷/玻璃/水晶/金屬等材質差保留人工覆核。搜尋詞對護手霜、擴香瓶、無型號指彩優先帶上香味/色名,提升 crawler 找到真同款候選的機率。
- **V10.543 rescore accepted 窄門回刷與高信心線補強**: `run_retryable_candidate_revalidation()` 追加 `rescore_accepted_current` 窄門,只允許已進人工池且命中具名 focused exact 品線的候選重新評分,仍由 matcher 判定是否可寫正式價差。新增 SK-II 青春露 330ml 兩入、AMIINO 安美諾美白修護霜 30ml、YES 腳指甲剪刀 10.5cm、YES 極細指甲緣硬皮剪刀 9cm 的 total-price 安全線同時新增指甲油型號衝突防線ANNY `A10.074.60` vs `A10.500`、OPI 不同 `ISL...` 型號都會 hard veto不會為了拉覆蓋率誤配色號。
- **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。

View File

@@ -46,7 +46,7 @@ PCHOME_MATCH_SCORE_FLOOR = 0.76
REVIEW_STATUS_OPTIONS = [
{
'key': 'all',
'label': '全部',
'label': '全部待處理',
'statuses': (
'unit_comparable',
'refresh_unit_comparable',
@@ -59,9 +59,6 @@ REVIEW_STATUS_OPTIONS = [
'expired_match',
'refresh_no_result',
'no_result',
'manual_rejected',
'manual_unit_price_required',
'manual_needs_research',
'rescore_accepted_current',
),
},
@@ -69,7 +66,7 @@ REVIEW_STATUS_OPTIONS = [
{
'key': 'unit_comparable',
'label': '需單位價',
'statuses': ('unit_comparable', 'refresh_unit_comparable', 'manual_unit_price_required'),
'statuses': ('unit_comparable', 'refresh_unit_comparable'),
},
{'key': 'identity_veto', 'label': '已排除', 'statuses': ('identity_veto',)},
{'key': 'recoverable_low_score', 'label': '近門檻可救', 'statuses': ('recoverable_low_score',)},
@@ -398,7 +395,7 @@ def _load_pchome_competitor_map(session, skus):
try:
stmt = text("""
SELECT
SELECT DISTINCT ON (sku)
sku,
price,
original_price,
@@ -419,8 +416,11 @@ def _load_pchome_competitor_map(session, skus):
WHERE source = 'pchome'
AND sku IN :skus
AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
AND price IS NOT NULL
AND price > 0
AND COALESCE(match_score, 0) >= :match_score_floor
AND COALESCE(tags, '[]'::jsonb) ? 'identity_v2'
ORDER BY sku, crawled_at DESC NULLS LAST
""").bindparams(bindparam("skus", expanding=True))
rows = session.execute(
stmt,
@@ -683,6 +683,16 @@ def _merge_competitor_review_context(overview, review_context):
overview.update({
'total_active': int(coverage.get('active_with_price') or overview.get('total_active') or 0),
'matched_count': int(coverage.get('valid_matches') or overview.get('matched_count') or 0),
'identity_coverage_count': int(
coverage.get('identity_coverage_matches')
or coverage.get('valid_matches')
or overview.get('identity_coverage_count')
or 0
),
'identity_coverage_rate': coverage.get(
'identity_coverage_rate',
overview.get('identity_coverage_rate') or coverage.get('match_rate', 0),
),
'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),
@@ -691,6 +701,7 @@ def _merge_competitor_review_context(overview, review_context):
'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),
'manual_closed_count': int(coverage.get('manual_closed_count') or 0),
'unit_comparable_count': int(coverage.get('unit_comparable_count') or 0),
'rescore_accepted_count': int(
coverage.get('rescore_accepted_count')
@@ -700,6 +711,9 @@ def _merge_competitor_review_context(overview, review_context):
'review_status_counts': review_status_counts,
'review_queue': review_queue[:3],
})
last_decision_ready = coverage.get('last_decision_ready_crawled_at')
if last_decision_ready:
overview['last_pchome_crawled'] = _format_dashboard_dt(last_decision_ready)
return overview
@@ -1041,7 +1055,7 @@ def _load_competitor_decision_overview(session, latest_items=None):
(SELECT COUNT(*) FROM compared WHERE gap_pct > -5 AND gap_pct < 5) AS near_count,
(SELECT COALESCE(ROUND(AVG(gap_pct)::numeric, 1), 0) FROM compared WHERE gap_pct >= 5) AS avg_advantage_gap,
(SELECT COUNT(*) FROM ai_price_recommendations WHERE strategy = 'product_pick' AND status = 'pending') AS ai_pick_count,
(SELECT MAX(crawled_at) FROM competitor_prices WHERE source = 'pchome') AS last_pchome_crawled
(SELECT MAX(crawled_at) FROM compared) AS last_pchome_crawled
""")
advantage_sql = text(latest_compared_cte + """
@@ -1737,12 +1751,14 @@ def _render_pchome_review_dashboard(
now_taipei,
today_start_db,
):
fresh_review_context = _load_competitor_review_context(session, limit=12)
overview_hint = _load_cached_competitor_overview_for_review(
now_taipei,
[],
0,
review_status,
)
_merge_competitor_review_context(overview_hint, fresh_review_context)
count_total = (
review_status != 'all'
or bool(search_query)
@@ -1783,6 +1799,9 @@ def _render_pchome_review_dashboard(
review_queue_total,
review_status,
)
_merge_competitor_review_context(competitor_overview, fresh_review_context)
if review_queue:
competitor_overview['review_queue'] = review_queue[:3]
review_status_options = _build_review_status_options(competitor_overview)
total_pages = math.ceil(review_queue_total / per_page) if review_queue_total else 0
total_products_history = int(competitor_overview.get('total_active') or 0)

View File

@@ -25,6 +25,11 @@ from sqlalchemy import inspect, text
PCHOME_MATCH_SCORE_FLOOR = 0.76
UNIT_COMPARABLE_STATUSES = {"unit_comparable", "refresh_unit_comparable"}
MANUAL_CLOSED_ATTEMPT_STATUSES = {
"manual_rejected",
"manual_unit_price_required",
"manual_needs_research",
}
ACTIONABLE_ATTEMPT_STATUSES = {
"rescore_accepted_current",
"unit_comparable",
@@ -38,13 +43,11 @@ ACTIONABLE_ATTEMPT_STATUSES = {
"expired_match",
"refresh_no_result",
"no_result",
"manual_rejected",
"manual_unit_price_required",
"manual_needs_research",
}
REVIEW_QUEUE_ATTEMPT_STATUSES = ACTIONABLE_ATTEMPT_STATUSES | MANUAL_CLOSED_ATTEMPT_STATUSES
REVIEW_STATUS_FILTER_GROUPS = {
"rescore_accepted": ("rescore_accepted_current",),
"unit_comparable": ("unit_comparable", "refresh_unit_comparable", "manual_unit_price_required"),
"unit_comparable": ("unit_comparable", "refresh_unit_comparable"),
"identity_veto": ("identity_veto",),
"low_score": ("low_score", "refresh_low_score", "recoverable_low_score", "true_low_confidence"),
"recoverable_low_score": ("recoverable_low_score",),
@@ -705,7 +708,7 @@ def _cached_payload(cache_key: str, producer, ttl_seconds: int = COMPETITOR_INTE
def fetch_competitor_coverage(engine) -> dict:
return _cached_payload(
f"coverage:v8:floor={PCHOME_MATCH_SCORE_FLOOR}:manual_reviews=1:rescore=1:review_no_fresh=1:decision_ready=1",
f"coverage:v9:floor={PCHOME_MATCH_SCORE_FLOOR}:manual_reviews=1:rescore=1:review_no_fresh=1:decision_ready=1:open_queue=1",
lambda: _fetch_competitor_coverage_uncached(engine),
)
@@ -724,13 +727,19 @@ def _fetch_competitor_coverage_uncached(engine) -> dict:
"stale_matches": 0,
"pending": 0,
"decision_ready_matches": 0,
"identity_coverage_matches": 0,
"identity_coverage_rate": 0,
"pending_identity_count": 0,
"stale_identity_count": 0,
"match_rate": 0,
"fresh_match_rate": 0,
"decision_ready_rate": 0,
"last_decision_ready_crawled_at": None,
"attempt_status": {},
"unit_comparable_count": 0,
"rescore_accepted_count": 0,
"actionable_review_count": 0,
"manual_closed_count": 0,
"manual_review_summary": manual_review_summary,
"manual_review_total": manual_review_summary["total"],
"manual_accept_count": manual_review_summary["accept_identity"],
@@ -779,7 +788,8 @@ def _fetch_competitor_coverage_uncached(engine) -> dict:
identity_competitor AS (
SELECT DISTINCT ON (cp.sku)
cp.sku,
cp.expires_at
cp.expires_at,
cp.crawled_at
FROM competitor_prices cp
WHERE cp.source = 'pchome'
AND cp.price IS NOT NULL
@@ -789,7 +799,7 @@ def _fetch_competitor_coverage_uncached(engine) -> dict:
ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST
),
fresh_competitor AS (
SELECT sku
SELECT sku, crawled_at
FROM identity_competitor
WHERE expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP
),
@@ -811,6 +821,9 @@ def _fetch_competitor_coverage_uncached(engine) -> dict:
FROM latest_momo lm
LEFT JOIN identity_competitor ic ON ic.sku = lm.sku
WHERE ic.sku IS NULL) AS pending,
(SELECT MAX(fc.crawled_at)
FROM latest_momo lm
JOIN fresh_competitor fc ON fc.sku = lm.sku) AS last_decision_ready_crawled_at,
COALESCE(la.attempt_status, 'never_attempted') AS attempt_status,
COUNT(*) AS status_count
FROM latest_momo lm
@@ -834,6 +847,8 @@ def _fetch_competitor_coverage_uncached(engine) -> dict:
unit_count = sum(statuses.get(status, 0) for status in UNIT_COMPARABLE_STATUSES)
rescore_accepted_count = int(statuses.get("rescore_accepted_current") or 0)
actionable_count = sum(statuses.get(status, 0) for status in ACTIONABLE_ATTEMPT_STATUSES)
manual_closed_count = sum(statuses.get(status, 0) for status in MANUAL_CLOSED_ATTEMPT_STATUSES)
last_decision_ready_crawled_at = rows[0].get("last_decision_ready_crawled_at") if rows else None
return {
"active_with_price": active,
"valid_matches": valid,
@@ -841,13 +856,19 @@ def _fetch_competitor_coverage_uncached(engine) -> dict:
"stale_matches": stale,
"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,
"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),
"last_decision_ready_crawled_at": last_decision_ready_crawled_at,
"attempt_status": statuses,
"unit_comparable_count": unit_count,
"rescore_accepted_count": rescore_accepted_count,
"actionable_review_count": actionable_count,
"manual_closed_count": manual_closed_count,
"manual_review_summary": manual_review_summary,
"manual_review_total": manual_review_summary["total"],
"manual_accept_count": manual_review_summary["accept_identity"],

View File

@@ -21,7 +21,7 @@
<div class="dashboard-kpi-value momo-mono">{{ overview.decision_ready_rate | default(0) }}%</div>
<div class="dashboard-kpi-sub momo-mono">
{{ overview.decision_ready_count | default(0) | number_format }} / {{ overview.total_active | default(total_products) | number_format }} ACTIVE
· 身份 {{ overview.match_rate | default(0) }}%
· 身份 {{ overview.identity_coverage_rate | default(overview.match_rate | default(0)) }}%
· 過期 {{ overview.stale_match_count | default(0) | number_format }}
</div>
</div>
@@ -48,11 +48,12 @@
<div class="dashboard-kpi-sub momo-mono">
<a class="dashboard-kpi-sub-link" href="{{ url_for('dashboard.index', filter='pchome_review', category=current_category, q=search_query, review_status='rescore_accepted', sort_by='pchome_review', order='desc') }}">重算待覆核 {{ overview.rescore_accepted_count | default(0) | number_format }}</a>
· <a class="dashboard-kpi-sub-link" href="{{ url_for('dashboard.index', filter='pchome_review', category=current_category, q=search_query, review_status='unit_comparable', sort_by='pchome_review', order='desc') }}">需單位價 {{ overview.unit_comparable_count | default(0) | number_format }}</a>
· 人工閉環 {{ overview.manual_closed_count | default(0) | number_format }}
· 待補抓 {{ overview.pending_match_count | default(0) | number_format }}
</div>
</div>
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">資料新鮮度</div>
<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-sub momo-mono">
{{ overview.last_pchome_crawled or '尚無 PChome 抓取紀錄' }}
@@ -71,7 +72,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.rescore_accepted_count | default(0) | number_format }} · 單位價 {{ overview.unit_comparable_count | default(0) | number_format }}
待刷新 {{ 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 }}
</div>
</div>
<div class="dashboard-backfill-progress" aria-hidden="true">

View File

@@ -75,7 +75,7 @@ def test_competitor_coverage_counts_only_active_product_intersection():
"def _fetch_manual_review_summary", 1
)[0]
assert "coverage:v8" in source
assert "coverage:v9" 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
@@ -87,6 +87,9 @@ def test_competitor_coverage_counts_only_active_product_intersection():
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 "\"identity_coverage_matches\": valid" in coverage_source
assert "\"manual_closed_count\": manual_closed_count" in coverage_source
assert "\"last_decision_ready_crawled_at\": last_decision_ready_crawled_at" in coverage_source
assert "FROM products p\n JOIN LATERAL" in coverage_source
assert "WHERE p.status = 'ACTIVE'" in coverage_source