串接 PChome 人工覆核成效指標
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s

This commit is contained in:
OoO
2026-05-20 10:16:44 +08:00
parent 8c3ca03f92
commit 296269bd43
6 changed files with 107 additions and 3 deletions

View File

@@ -4,6 +4,7 @@
================================================================================
【已完成】
- V10.307 將 PChome 人工覆核成效接進 daily/growth/PPT 共用資料出口:`fetch_competitor_coverage()` 讀取 `competitor_match_reviews` 最新決策,輸出人工採用、人工否決、人工單位價與採用率;`daily_sales` 與 `growth_analysis` 的比價資料品質區塊直接顯示這些閉環指標,讓報表與簡報不只看待審數,也能看人工處理成效。
- V10.305 將 PChome 人工覆核回饋接回 feeder下一輪搜尋若命中已被 `reject_identity` 否決的同一候選,會記錄 `manual_rejected` 並跳過正式寫入;已被標記 `unit_price_required` 的候選只保留單位價比較,不寫入正式總價差;人工 `accept_identity` 可保守覆蓋低分門檻但會打 `manual_review/manual_accept` 標籤,讓核心比價閉環可被後續報表與簡報追蹤。
- V10.304 補 PChome 比價人工覆核決策閉環:新增 `competitor_match_reviews`、`/api/pchome-review/<sku>/decision` 與商品看板覆核列「採用同款 / 否決候選 / 標記單位價」動作;只有人工採用同款才寫入 `competitor_prices` + `competitor_price_history`,否決與單位價標記只追加 manual attempt 並關閉本輪覆核,避免錯配污染核心價差。
- V10.302 補 PChome 比價覆核匯出與診斷原因:`filter=pchome_review` 每筆覆核把 matcher `reasons=` 翻成品牌不符、商品線不符、容量差異、組合差異、需單位價、價差極端等可行動標籤;新增 `/api/export/excel/pchome-review` 匯出完整覆核隊列、人工處置、候選 PChome、單位價比較與原始診斷避免核心比價只停在籠統「待對比」。

View File

@@ -2,7 +2,7 @@
> **最後更新**: 2026-05-20 (台北時間)
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地LLM 路由紅線升級為 Ollama-first 三主機級聯Gemini 僅備援 / 鎖定場景
> **適用版本**: V10.305
> **適用版本**: V10.307
---
@@ -56,7 +56,7 @@ SQL漏斗(~300筆)
- 比對覆蓋率補強入口:`POST /api/ai/pchome-match/backfill`,優先補抓仍無有效 PChome 配對的高價 ACTIVE 商品,完成後自動重算 AI 挑品清單。
- 排程閉環:`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` 只寫 `competitor_match_reviews` 並追加 manual attempt不得把不同販售組合或否決候選灌入正式價差。PChome feeder 後續搜尋同一候選時必須讀取 `competitor_match_reviews`:已否決候選寫 `manual_rejected` 並跳過正式寫入;已標記單位價候選寫 `manual_unit_price_required`;已採用候選可保守補到最低門檻並保留 `manual_review/manual_accept` 標籤。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。
- 商品看板第一屏:`/` 的 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` 只寫 `competitor_match_reviews` 並追加 manual attempt不得把不同販售組合或否決候選灌入正式價差。PChome feeder 後續搜尋同一候選時必須讀取 `competitor_match_reviews`:已否決候選寫 `manual_rejected` 並跳過正式寫入;已標記單位價候選寫 `manual_unit_price_required`;已採用候選可保守補到最低門檻並保留 `manual_review/manual_accept` 標籤。`fetch_competitor_coverage()` 必須輸出人工採用、人工否決、人工單位價與採用率daily/growth/PPT 共用 payload 必須顯示人工閉環成效,避免只呈現待審數。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。
| 角色 | 模型 | 主機 | 成本 | 每日限額 |
|------|------|------|------|---------|

View File

@@ -67,6 +67,12 @@ ATTEMPT_ACTION_LABELS = {
"manual_unit_price_required": "維持單位價比較,不寫入正式總價差",
"manual_needs_research": "補搜尋詞或重新抓取後再判斷",
}
MANUAL_REVIEW_ACTION_LABELS = {
"accept_identity": "人工採用",
"reject_identity": "人工否決",
"unit_price_required": "人工單位價",
"needs_research": "需補搜尋",
}
MATCH_DIAGNOSTIC_REASON_LABELS = {
"brand_conflict": "品牌不符",
"product_line_conflict": "商品線不符",
@@ -115,6 +121,18 @@ def _attempt_action_label(status: Any) -> str:
return ATTEMPT_ACTION_LABELS.get(str(status or ""), "人工確認比對證據")
def _empty_manual_review_summary() -> dict[str, Any]:
return {
"total": 0,
"accept_identity": 0,
"reject_identity": 0,
"unit_price_required": 0,
"needs_research": 0,
"accept_rate": 0,
"action_labels": MANUAL_REVIEW_ACTION_LABELS,
}
def _extract_match_diagnostic_reasons(diagnostic_text: Any) -> list[dict[str, str]]:
"""Translate matcher diagnostics into short operator-facing reason chips."""
text_value = str(diagnostic_text or "")
@@ -255,7 +273,7 @@ def _cached_payload(cache_key: str, producer, ttl_seconds: int = COMPETITOR_INTE
def fetch_competitor_coverage(engine) -> dict:
return _cached_payload(
f"coverage:v2:floor={PCHOME_MATCH_SCORE_FLOOR}",
f"coverage:v3:floor={PCHOME_MATCH_SCORE_FLOOR}:manual_reviews=1",
lambda: _fetch_competitor_coverage_uncached(engine),
)
@@ -263,6 +281,9 @@ def fetch_competitor_coverage(engine) -> dict:
def _fetch_competitor_coverage_uncached(engine) -> dict:
"""讀取目前 PChome 比價覆蓋率與待審分類。"""
inspector = inspect(engine)
manual_review_summary = _empty_manual_review_summary()
if inspector.has_table("competitor_match_reviews"):
manual_review_summary = _fetch_manual_review_summary(engine)
if not inspector.has_table("competitor_prices"):
return {
"active_with_price": 0,
@@ -272,6 +293,12 @@ def _fetch_competitor_coverage_uncached(engine) -> dict:
"attempt_status": {},
"unit_comparable_count": 0,
"actionable_review_count": 0,
"manual_review_summary": manual_review_summary,
"manual_review_total": manual_review_summary["total"],
"manual_accept_count": manual_review_summary["accept_identity"],
"manual_reject_count": manual_review_summary["reject_identity"],
"manual_unit_price_count": manual_review_summary["unit_price_required"],
"manual_accept_rate": manual_review_summary["accept_rate"],
}
has_match_attempts = inspector.has_table("competitor_match_attempts")
@@ -355,10 +382,57 @@ def _fetch_competitor_coverage_uncached(engine) -> dict:
"attempt_status": statuses,
"unit_comparable_count": unit_count,
"actionable_review_count": actionable_count,
"manual_review_summary": manual_review_summary,
"manual_review_total": manual_review_summary["total"],
"manual_accept_count": manual_review_summary["accept_identity"],
"manual_reject_count": manual_review_summary["reject_identity"],
"manual_unit_price_count": manual_review_summary["unit_price_required"],
"manual_accept_rate": manual_review_summary["accept_rate"],
"match_score_floor": PCHOME_MATCH_SCORE_FLOOR,
}
def _fetch_manual_review_summary(engine) -> dict[str, Any]:
sql = text("""
WITH latest_reviews AS (
SELECT DISTINCT ON (sku, source, candidate_product_id)
sku,
source,
candidate_product_id,
review_action,
reviewed_at
FROM competitor_match_reviews
WHERE source = 'pchome'
ORDER BY sku, source, candidate_product_id, reviewed_at DESC, id DESC
)
SELECT
review_action,
COUNT(*) AS action_count
FROM latest_reviews
GROUP BY review_action
""")
try:
with engine.connect() as conn:
rows = conn.execute(sql).mappings().all()
except Exception:
return _empty_manual_review_summary()
summary = _empty_manual_review_summary()
for row in rows:
action = str(row.get("review_action") or "")
if action in summary:
summary[action] = int(row.get("action_count") or 0)
summary["total"] = sum(
int(summary.get(action) or 0)
for action in MANUAL_REVIEW_ACTION_LABELS
)
summary["accept_rate"] = round(
summary["accept_identity"] / max(summary["total"], 1) * 100,
1,
)
return summary
def fetch_competitor_gap_trend(engine, days: int = 30) -> dict:
days = max(7, min(int(days or 30), 120))
return _cached_payload(

View File

@@ -359,6 +359,18 @@
<span>需單位價覆核</span>
<strong class="momo-mono">{{ comp_coverage.unit_comparable_count | default(0) | number_format }}</strong>
</div>
<div>
<span>人工採用</span>
<strong class="momo-mono">{{ comp_coverage.manual_accept_count | default(0) | number_format }}</strong>
</div>
<div>
<span>人工否決</span>
<strong class="momo-mono">{{ comp_coverage.manual_reject_count | default(0) | number_format }}</strong>
</div>
<div>
<span>人工單位價</span>
<strong class="momo-mono">{{ comp_coverage.manual_unit_price_count | default(0) | number_format }}</strong>
</div>
</div>
{% if competitor_intel.review_queue %}
<ol class="daily-competitor-risk-list daily-competitor-risk-list--review">

View File

@@ -149,6 +149,12 @@
<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>
<span>人工採用</span>
<strong class="momo-mono">{{ coverage.manual_accept_count | default(0) | number_format }}</strong>
<span>人工否決</span>
<strong class="momo-mono">{{ coverage.manual_reject_count | default(0) | number_format }}</strong>
<span>人工單位價</span>
<strong class="momo-mono">{{ coverage.manual_unit_price_count | default(0) | number_format }}</strong>
</div>
</div>
</article>

View File

@@ -59,12 +59,23 @@ def test_competitor_review_queue_is_canonical_unit_price_handoff():
assert "def fetch_competitor_review_queue" in source
assert "\"review_queue\": fetch_competitor_review_queue" in source
assert "\"unit_comparable_count\"" in source
assert "manual_review_summary" in source
assert "manual_accept_count" in source
assert "manual_reject_count" in source
assert "manual_unit_price_count" in source
assert "competitor_match_reviews" in source
assert "\"status_label\"" in source
assert "\"action_label\"" in source
assert "build_unit_price_comparison" in source
assert "需單位價覆核" in daily_template
assert "人工採用" in daily_template
assert "人工否決" in daily_template
assert "人工單位價" in daily_template
assert "competitor_intel.review_queue" in daily_template
assert "coverage.unit_comparable_count" in growth_template
assert "coverage.manual_accept_count" in growth_template
assert "coverage.manual_reject_count" in growth_template
assert "coverage.manual_unit_price_count" in growth_template
def test_competitor_ppt_prompt_uses_neutral_ewooc_viewpoint():