This commit is contained in:
@@ -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、單位價比較與原始診斷,避免核心比價只停在籠統「待對比」。
|
||||
|
||||
@@ -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 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。
|
||||
|
||||
| 角色 | 模型 | 主機 | 成本 | 每日限額 |
|
||||
|------|------|------|------|---------|
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user