From 0242aebb66b939e4df5a6bd54e20175aed272f59 Mon Sep 17 00:00:00 2001 From: OoO Date: Wed, 20 May 2026 09:48:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20PChome=20=E8=A6=86?= =?UTF-8?q?=E6=A0=B8=E5=8C=AF=E5=87=BA=E8=88=87=E8=A8=BA=E6=96=B7=E5=8E=9F?= =?UTF-8?q?=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO_NEXT_STEPS.txt | 1 + config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 4 +- routes/export_routes.py | 120 ++++++++++++++++++++++++ services/competitor_intel_repository.py | 49 +++++++++- templates/dashboard_v2.html | 18 ++++ tests/test_frontend_v2_assets.py | 22 +++++ web/static/css/page-dashboard-v2.css | 25 +++++ 8 files changed, 237 insertions(+), 4 deletions(-) diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 130d63f..db33a0b 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.302 補 PChome 比價覆核匯出與診斷原因:`filter=pchome_review` 每筆覆核把 matcher `reasons=` 翻成品牌不符、商品線不符、容量差異、組合差異、需單位價、價差極端等可行動標籤;新增 `/api/export/excel/pchome-review` 匯出完整覆核隊列、人工處置、候選 PChome、單位價比較與原始診斷,避免核心比價只停在籠統「待對比」。 - V10.301 補市場情報 candidate queue review AI summary Telegram dispatch gate:新增 `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_gate` 與 UI 按鈕,在 summary persistence closeout 後檢查 Telegram 訊息契約、channel label、artifact path、token 外洩風險與後續 run package promotion;API/UI 仍不讀 approval/Telegram token、不呼叫 LLM、不開 DB、不寫檔、不派送 Telegram、不掛 scheduler。 - V10.300 補商品看板比價覆核狀態分流:`filter=pchome_review` 新增全部、需單位價、身份否決、低信心、價格過期、找不到同款 segmented 篩選與分頁保留參數,讓 6,000+ 筆覆核隊列能依 matcher 診斷類型分批處理;同步修正覆核列表表頭/分頁連結狀態保留。 - V10.299 補市場情報 candidate queue review AI summary persistence run closeout:新增 `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_run_closeout` 與 UI 按鈕,在 receipt 通過後收尾 metadata_json persistence gate,確認 closeout artifact、操作員確認與後續 Telegram dispatch 必須另開 gate;API/UI 仍不讀 approval token、不執行 CLI、不連 DB、不寫 `metadata_json`、不派送 Telegram、不掛 scheduler。 diff --git a/config.py b/config.py index ad3e568..db9c84a 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.301" +SYSTEM_VERSION = "V10.303" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index 32a6368..b9ca1d0 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-05-20 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 僅備援 / 鎖定場景 -> **適用版本**: V10.300 +> **適用版本**: V10.302 --- @@ -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`、`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、單位價換算摘要與人工動作。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。 +- 商品看板第一屏:`/` 的 V2 看板直接以 `products`、`price_records`、`competitor_prices`、`competitor_match_attempts`、`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 分析共用同一份證據。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。 | 角色 | 模型 | 主機 | 成本 | 每日限額 | |------|------|------|------|---------| diff --git a/routes/export_routes.py b/routes/export_routes.py index 7dd7bde..7706ea4 100644 --- a/routes/export_routes.py +++ b/routes/export_routes.py @@ -245,6 +245,126 @@ def export_excel_ai_picks(): session.close() +@export_bp.route('/api/export/excel/pchome-review') +@login_required +def export_excel_pchome_review(): + """匯出 PChome 比價覆核隊列,保留 matcher 診斷與人工處置欄位。""" + from services.competitor_intel_repository import ( + REVIEW_STATUS_FILTER_GROUPS, + fetch_competitor_review_queue_page, + ) + + db = DatabaseManager() + session = db.get_session() + try: + search_query = (request.args.get('q') or request.args.get('search') or '').strip() + category = (request.args.get('category') or '').strip() + if category.lower() == 'all': + category = '' + + status_filter = (request.args.get('review_status') or request.args.get('status') or '').strip() + if status_filter == 'all' or status_filter not in REVIEW_STATUS_FILTER_GROUPS: + status_filter = '' + + try: + limit = int(request.args.get('limit') or 500) + except (TypeError, ValueError): + limit = 500 + limit = max(1, min(limit, 2000)) + + engine = session.get_bind() + rows = [] + page = 1 + while len(rows) < limit: + per_page = min(100, limit - len(rows)) + payload = fetch_competitor_review_queue_page( + engine, + page=page, + per_page=per_page, + search_query=search_query, + category=category, + status_filter=status_filter, + ) + batch = payload.get('items') or [] + if not batch: + break + rows.extend(batch) + if len(rows) >= int(payload.get('total') or 0) or len(batch) < per_page: + break + page += 1 + + if not rows: + return "目前沒有 PChome 覆核資料可匯出", 404 + + export_rows = [] + for idx, item in enumerate(rows[:limit], start=1): + sku = str(item.get('sku') or '').strip() + pchome_id = str(item.get('candidate_pc_id') or '').strip() + unit_comparison = item.get('unit_comparison') or {} + momo_url = build_momo_product_url(sku) if sku else '' + pchome_url = f"https://24h.pchome.com.tw/prod/{pchome_id}" if pchome_id else '' + export_rows.append({ + '覆核序': idx, + '狀態': item.get('status_label') or '', + '建議處置': item.get('action_label') or '', + '診斷原因': item.get('diagnostic_reason_text') or '', + 'MOMO商品ID': sku, + 'MOMO商品名稱': item.get('name') or '', + '分類': item.get('category') or '', + 'MOMO價格': float(item.get('momo_price') or 0), + '候選PChome商品ID': pchome_id, + '候選PChome商品名稱': item.get('candidate_pc_name') or '', + '候選PChome價格': float(item.get('candidate_pc_price') or 0), + 'Match分數%': round(float(item.get('best_match_score') or 0) * 100, 1), + '候選數': int(item.get('candidate_count') or 0), + '單位價比較': unit_comparison.get('summary') or '', + '原始診斷': item.get('match_diagnostic') or '', + '嘗試時間': item.get('attempted_at') or '', + 'MOMO商品URL': momo_url, + 'PChome商品URL': pchome_url, + }) + + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df = pd.DataFrame(export_rows) + df.to_excel(writer, index=False, sheet_name='PChome覆核隊列') + worksheet = writer.sheets['PChome覆核隊列'] + for column_cells in worksheet.columns: + header = str(column_cells[0].value or '') + width = min(max(len(header) + 4, 12), 42) + if header in { + 'MOMO商品名稱', + '候選PChome商品名稱', + '建議處置', + '診斷原因', + '單位價比較', + '原始診斷', + 'MOMO商品URL', + 'PChome商品URL', + }: + width = 52 + worksheet.column_dimensions[column_cells[0].column_letter].width = width + worksheet.freeze_panes = 'A2' + + output.seek(0) + status_label = status_filter or 'all' + filename = f"PChome比價覆核_{status_label}_{datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M')}.xlsx" + sys_log.info( + f"[Web] [Export] PChome 覆核隊列匯出成功 | rows={len(export_rows)} status={status_label}" + ) + return send_file( + output, + as_attachment=True, + download_name=filename, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + except Exception as e: + sys_log.error(f"[Web] [Export] PChome 覆核隊列匯出失敗 | Error: {e}") + return f"匯出失敗: {e}", 500 + finally: + session.close() + + @export_bp.route('/api/export/excel/delisted') @login_required def export_excel_delisted(): diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index d85f0be..94df201 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -59,6 +59,20 @@ ATTEMPT_ACTION_LABELS = { "no_result": "補充搜尋詞或品牌關鍵字", "never_attempted": "排入 PChome 補抓", } +MATCH_DIAGNOSTIC_REASON_LABELS = { + "brand_conflict": "品牌不符", + "product_line_conflict": "商品線不符", + "type_conflict": "品類不符", + "volume_conflict": "容量差異", + "weight_conflict": "重量差異", + "count_conflict": "件數差異", + "component_count_conflict": "入數差異", + "multi_component_conflict": "組合差異", + "refill_pack_conflict": "補充包差異", + "unit_comparable": "需單位價", + "price_ratio_extreme": "價差極端", + "price_ratio_wide": "價差過大", +} COMPETITOR_INTEL_CACHE_TTL_SECONDS = int(os.getenv("COMPETITOR_INTEL_CACHE_TTL_SECONDS", "1800")) _BASE_DIR = Path(__file__).resolve().parents[1] _CACHE_FILE = _BASE_DIR / "data" / "competitor_intel_cache.pkl" @@ -93,6 +107,35 @@ def _attempt_action_label(status: Any) -> str: return ATTEMPT_ACTION_LABELS.get(str(status or ""), "人工確認比對證據") +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 "") + if not text_value: + return [] + + reason_blob = "" + for part in text_value.split(";"): + key, _, value = part.strip().partition("=") + if key.strip() == "reasons": + reason_blob = value.strip() + break + if not reason_blob: + return [] + + reasons: list[dict[str, str]] = [] + seen: set[str] = set() + for raw_reason in reason_blob.replace("|", ",").split(","): + code = raw_reason.strip() + if not code or code in seen: + continue + seen.add(code) + reasons.append({ + "code": code, + "label": MATCH_DIAGNOSTIC_REASON_LABELS.get(code, code.replace("_", " ")), + }) + return reasons + + def _build_unit_comparison_for_attempt(row: dict[str, Any]) -> Optional[dict[str, Any]]: status = str(row.get("attempt_status") or "") if status not in UNIT_COMPARABLE_STATUSES: @@ -112,6 +155,8 @@ def _build_unit_comparison_for_attempt(row: dict[str, Any]) -> Optional[dict[str def _format_competitor_review_item(row: dict[str, Any]) -> dict[str, Any]: item = dict(row) unit_comparison = _build_unit_comparison_for_attempt(item) + match_diagnostic = item.get("error_message") or "" + diagnostic_reasons = _extract_match_diagnostic_reasons(match_diagnostic) return { "sku": str(item.get("sku") or ""), "name": item.get("name") or "", @@ -125,7 +170,9 @@ def _format_competitor_review_item(row: dict[str, Any]) -> dict[str, Any]: "candidate_pc_name": item.get("best_competitor_product_name") or "", "candidate_pc_price": _num(item.get("best_competitor_price")), "best_match_score": _num(item.get("best_match_score")), - "match_diagnostic": item.get("error_message") or "", + "match_diagnostic": match_diagnostic, + "diagnostic_reasons": diagnostic_reasons, + "diagnostic_reason_text": "、".join(reason["label"] for reason in diagnostic_reasons), "attempted_at": _date_label(item.get("attempted_at")), "unit_comparison": unit_comparison, } diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index 8386ae0..37297c7 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -163,6 +163,13 @@ {% endif %}
{{ item.action_label }}
+ {% if item.diagnostic_reasons %} +
+ {% for reason in item.diagnostic_reasons[:4] %} + {{ reason.label }} + {% endfor %} +
+ {% endif %} {% if item.unit_comparison and item.unit_comparison.summary %}
{{ item.unit_comparison.summary }}
{% endif %} @@ -283,6 +290,10 @@ 匯出 AI 挑品 + {% elif current_filter == 'pchome_review' %} + + 匯出覆核 + {% endif %} @@ -508,6 +519,13 @@
{{ review.action_label if review else decision.summary }}
{% if review %} + {% if review.diagnostic_reasons %} +
+ {% for reason in review.diagnostic_reasons[:4] %} + {{ reason.label }} + {% endfor %} +
+ {% endif %}
{% if review.candidate_count %} {{ review.candidate_count }} 筆候選 diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index 70e49ee..fa2b7cc 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -192,6 +192,27 @@ def test_ai_pick_export_uses_real_recommendation_data(): assert "pd.ExcelWriter" in export_source +def test_pchome_review_export_and_diagnostics_use_real_queue_data(): + export_source = (ROOT / "routes/export_routes.py").read_text(encoding="utf-8") + repository_source = (ROOT / "services/competitor_intel_repository.py").read_text(encoding="utf-8") + dashboard = (ROOT / "templates/dashboard_v2.html").read_text(encoding="utf-8") + dashboard_css = (ROOT / "web/static/css/page-dashboard-v2.css").read_text(encoding="utf-8") + + assert "@export_bp.route('/api/export/excel/pchome-review')" in export_source + assert "fetch_competitor_review_queue_page" in export_source + assert "診斷原因" in export_source + assert "原始診斷" in export_source + assert "PChome比價覆核_" in export_source + assert "MATCH_DIAGNOSTIC_REASON_LABELS" in repository_source + assert "diagnostic_reasons" in repository_source + assert "商品線不符" in repository_source + assert "容量差異" in repository_source + assert "匯出覆核" in dashboard + assert "review.diagnostic_reasons" in dashboard + assert "dashboard-review-reasons" in dashboard + assert ".dashboard-review-reasons" in dashboard_css + + def test_ai_intelligence_uses_v2_shell_and_real_runtime_apis(): template = (ROOT / "templates/ai_intelligence.html").read_text(encoding="utf-8") route_source = (ROOT / "routes/ai_routes.py").read_text(encoding="utf-8") @@ -355,6 +376,7 @@ def test_dashboard_v2_shows_pchome_competitor_pricing_and_links(): assert "_load_pchome_match_attempt_map" in route_source assert "低信心待審" in route_source assert "規格衝突待審" in route_source + assert "dashboard-review-reasons" in dashboard assert "series.pchome" in page_js assert "label: 'PChome'" in page_js assert "含 PChome 歷史快照" in page_js diff --git a/web/static/css/page-dashboard-v2.css b/web/static/css/page-dashboard-v2.css index ffe2034..3241531 100644 --- a/web/static/css/page-dashboard-v2.css +++ b/web/static/css/page-dashboard-v2.css @@ -809,6 +809,31 @@ gap: 6px; } + .dashboard-review-reasons { + display: flex; + flex-wrap: wrap; + gap: 4px; + min-width: 0; + } + + .dashboard-review-reasons span { + display: inline-flex; + align-items: center; + max-width: 130px; + min-height: 20px; + padding: 2px 7px; + overflow: hidden; + color: var(--momo-accent-strong); + background: rgba(188, 117, 48, 0.08); + border: 1px solid rgba(188, 117, 48, 0.22); + border-radius: var(--momo-radius-pill); + font-size: 10px; + font-weight: 800; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; + } + .dashboard-review-note { color: var(--momo-warning-text); font-size: 10px;