diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index db33a0b..9612750 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.304 補 PChome 比價人工覆核決策閉環:新增 `competitor_match_reviews`、`/api/pchome-review//decision` 與商品看板覆核列「採用同款 / 否決候選 / 標記單位價」動作;只有人工採用同款才寫入 `competitor_prices` + `competitor_price_history`,否決與單位價標記只追加 manual attempt 並關閉本輪覆核,避免錯配污染核心價差。 - 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 診斷類型分批處理;同步修正覆核列表表頭/分頁連結狀態保留。 diff --git a/config.py b/config.py index db9c84a..6dd0a06 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.303" +SYSTEM_VERSION = "V10.304" 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 b9ca1d0..337639a 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.302 +> **適用版本**: V10.304 --- @@ -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、單位價換算摘要、人工動作與 matcher 診斷原因標籤(品牌不符、商品線不符、容量差異、組合差異、需單位價、價差極端等),不得只顯示籠統「待比對」。`/api/export/excel/pchome-review` 必須匯出同一套覆核隊列、人工處置、候選 PChome、單位價比較與原始診斷,讓人工覆核、簡報與後續 AI 分析共用同一份證據。商品看板深度快取同時寫入 `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//decision` 是人工閉環入口:`accept_identity` 才可把候選寫入 `competitor_prices` 與 `competitor_price_history` 並打上 `manual_review/manual_accept/identity_v2`;`reject_identity` 與 `unit_price_required` 只寫 `competitor_match_reviews` 並追加 manual attempt,不得把不同販售組合或否決候選灌入正式價差。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。 | 角色 | 模型 | 主機 | 成本 | 每日限額 | |------|------|------|------|---------| diff --git a/migrations/039_create_competitor_match_reviews.sql b/migrations/039_create_competitor_match_reviews.sql new file mode 100644 index 0000000..b5cb6b1 --- /dev/null +++ b/migrations/039_create_competitor_match_reviews.sql @@ -0,0 +1,52 @@ +-- ============================================================================= +-- Migration 039: 競品比對人工覆核決策表 +-- MOMO PRO — PChome match human-in-the-loop review +-- 2026-05-20 台北 +-- ============================================================================= +-- 說明: +-- competitor_match_attempts 保存 matcher / feeder 每一次嘗試; +-- competitor_match_reviews 保存人工對最佳候選的處置結果。 +-- 只有 review_action='accept_identity' 才會由服務層寫入 competitor_prices, +-- reject_identity / unit_price_required 只關閉覆核與保存決策,不污染正式價差。 +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS competitor_match_reviews ( + id BIGSERIAL PRIMARY KEY, + + sku VARCHAR(50) NOT NULL, + source VARCHAR(30) NOT NULL DEFAULT 'pchome', + + review_action VARCHAR(40) NOT NULL, + review_reason TEXT, + reviewer_identity VARCHAR(120), + + momo_product_id INTEGER, + momo_product_name TEXT, + momo_price NUMERIC(10,2), + + candidate_product_id VARCHAR(100), + candidate_product_name TEXT, + candidate_price NUMERIC(10,2), + candidate_match_score NUMERIC(4,3), + candidate_diagnostic TEXT, + + resulting_attempt_status VARCHAR(40), + reviewed_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_comp_match_reviews_sku_source_time + ON competitor_match_reviews (sku, source, reviewed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_comp_match_reviews_action_time + ON competitor_match_reviews (review_action, reviewed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_comp_match_reviews_candidate + ON competitor_match_reviews (candidate_product_id); + +GRANT ALL PRIVILEGES ON competitor_match_reviews TO momo; +GRANT USAGE, SELECT ON SEQUENCE competitor_match_reviews_id_seq TO momo; + +DO $$ +BEGIN + RAISE NOTICE '✅ Migration 039 完成 — competitor_match_reviews 人工覆核決策表已建立'; +END $$; diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index b16b283..5abc835 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -13,11 +13,11 @@ import hashlib import pickle import threading from datetime import datetime, timezone, timedelta -from flask import Blueprint, request, render_template +from flask import Blueprint, request, render_template, jsonify from sqlalchemy import func, and_, text, bindparam from sqlalchemy.orm import joinedload -from auth import login_required +from auth import login_required, get_current_user from config import BASE_DIR, SYSTEM_VERSION, public_url from database.manager import DatabaseManager from database.models import Product, PriceRecord @@ -1724,6 +1724,43 @@ def get_dashboard_stats(): # 頁面路由 # ========================================== +@dashboard_bp.route('/api/pchome-review//decision', methods=['POST']) +@login_required +def record_pchome_review_decision(sku): + """Record an operator decision for a PChome comparison candidate.""" + payload = request.get_json(silent=True) or request.form or {} + action = payload.get('action') or '' + reason = payload.get('reason') or '' + user = get_current_user() or {} + reviewer = user.get('username') or user.get('client_ip') or 'dashboard' + + db = DatabaseManager() + session = db.get_session() + try: + from services.cache_manager import clear_dashboard_cache + from services.competitor_intel_repository import clear_competitor_intel_cache + from services.competitor_match_review_service import record_competitor_match_review + + result = record_competitor_match_review( + session.get_bind(), + sku=sku, + review_action=action, + reviewer_identity=reviewer, + review_reason=reason, + source='pchome', + ) + if result.get('success'): + clear_dashboard_cache() + clear_competitor_intel_cache() + return jsonify(result) + return jsonify(result), 400 + except Exception as exc: + sys_log.error(f"[Dashboard] PChome 覆核決策寫入失敗 | sku={sku} action={action} error={exc}") + return jsonify({'success': False, 'message': f'覆核寫入失敗:{exc}'}), 500 + finally: + session.close() + + @dashboard_bp.route('/') @login_required def index(): diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index 94df201..f5a944c 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -48,6 +48,10 @@ ATTEMPT_STATUS_LABELS = { "refresh_no_result": "刷新找不到商品", "no_result": "找不到同款", "never_attempted": "尚未搜尋", + "manual_accepted": "人工已採用", + "manual_rejected": "人工已否決", + "manual_unit_price_required": "人工標記單位價", + "manual_needs_research": "人工要求補搜尋", } ATTEMPT_ACTION_LABELS = { "unit_comparable": "人工確認檔期、贈品與單位價", @@ -58,6 +62,10 @@ ATTEMPT_ACTION_LABELS = { "refresh_no_result": "調整搜尋詞後重抓", "no_result": "補充搜尋詞或品牌關鍵字", "never_attempted": "排入 PChome 補抓", + "manual_accepted": "已寫入正式 PChome 同款配對", + "manual_rejected": "已關閉此候選,等待下一輪新候選", + "manual_unit_price_required": "維持單位價比較,不寫入正式總價差", + "manual_needs_research": "補搜尋詞或重新抓取後再判斷", } MATCH_DIAGNOSTIC_REASON_LABELS = { "brand_conflict": "品牌不符", diff --git a/services/competitor_match_review_service.py b/services/competitor_match_review_service.py new file mode 100644 index 0000000..2a609d3 --- /dev/null +++ b/services/competitor_match_review_service.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""PChome / MOMO 比價人工覆核決策服務。""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from typing import Any + +from sqlalchemy import text + + +VALID_REVIEW_ACTIONS = { + "accept_identity": { + "label": "採用同款", + "attempt_status": "manual_accepted", + "message": "已採用候選商品為正式 PChome 同款配對", + }, + "reject_identity": { + "label": "否決候選", + "attempt_status": "manual_rejected", + "message": "已否決候選商品,本輪覆核已關閉", + }, + "unit_price_required": { + "label": "標記單位價", + "attempt_status": "manual_unit_price_required", + "message": "已標記為需單位價比較,不寫入正式總價差", + }, + "needs_research": { + "label": "需補搜尋", + "attempt_status": "manual_needs_research", + "message": "已標記為需補搜尋詞或重新抓取", + }, +} + + +def _num(value: Any) -> float | None: + try: + if value is None: + return None + return float(value) + except (TypeError, ValueError): + return None + + +def _json_array_expr(conn, bind_name: str) -> str: + return f"CAST(:{bind_name} AS jsonb)" if conn.dialect.name == "postgresql" else f":{bind_name}" + + +def _ensure_competitor_match_reviews_table(conn) -> None: + if conn.dialect.name == "postgresql": + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS competitor_match_reviews ( + id BIGSERIAL PRIMARY KEY, + sku VARCHAR(50) NOT NULL, + source VARCHAR(30) NOT NULL DEFAULT 'pchome', + review_action VARCHAR(40) NOT NULL, + review_reason TEXT, + reviewer_identity VARCHAR(120), + momo_product_id INTEGER, + momo_product_name TEXT, + momo_price NUMERIC(10,2), + candidate_product_id VARCHAR(100), + candidate_product_name TEXT, + candidate_price NUMERIC(10,2), + candidate_match_score NUMERIC(4,3), + candidate_diagnostic TEXT, + resulting_attempt_status VARCHAR(40), + reviewed_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + """)) + else: + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS competitor_match_reviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sku VARCHAR(50) NOT NULL, + source VARCHAR(30) NOT NULL DEFAULT 'pchome', + review_action VARCHAR(40) NOT NULL, + review_reason TEXT, + reviewer_identity VARCHAR(120), + momo_product_id INTEGER, + momo_product_name TEXT, + momo_price NUMERIC(10,2), + candidate_product_id VARCHAR(100), + candidate_product_name TEXT, + candidate_price NUMERIC(10,2), + candidate_match_score NUMERIC(4,3), + candidate_diagnostic TEXT, + resulting_attempt_status VARCHAR(40), + reviewed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """)) + conn.execute(text(""" + CREATE INDEX IF NOT EXISTS idx_comp_match_reviews_sku_source_time + ON competitor_match_reviews (sku, source, reviewed_at DESC) + """)) + conn.execute(text(""" + CREATE INDEX IF NOT EXISTS idx_comp_match_reviews_action_time + ON competitor_match_reviews (review_action, reviewed_at DESC) + """)) + conn.execute(text(""" + CREATE INDEX IF NOT EXISTS idx_comp_match_reviews_candidate + ON competitor_match_reviews (candidate_product_id) + """)) + + +def _ensure_competitor_price_history_table(conn) -> None: + tags_type = "JSONB" if conn.dialect.name == "postgresql" else "TEXT" + pk_type = "BIGSERIAL PRIMARY KEY" if conn.dialect.name == "postgresql" else "INTEGER PRIMARY KEY AUTOINCREMENT" + conn.execute(text(f""" + CREATE TABLE IF NOT EXISTS competitor_price_history ( + id {pk_type}, + sku VARCHAR(50) NOT NULL, + source VARCHAR(30) NOT NULL DEFAULT 'pchome', + momo_product_id INTEGER, + momo_price NUMERIC(10,2), + price NUMERIC(10,2) NOT NULL, + original_price NUMERIC(10,2), + discount_pct INTEGER, + competitor_product_id VARCHAR(100), + competitor_product_name TEXT, + match_score NUMERIC(4,3), + tags {tags_type} DEFAULT '[]', + crawled_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """)) + conn.execute(text(""" + CREATE INDEX IF NOT EXISTS idx_comp_price_history_sku_source_time + ON competitor_price_history (sku, source, crawled_at DESC) + """)) + + +def _fetch_latest_attempt(conn, sku: str, source: str) -> dict[str, Any] | None: + latest_price_sql = """ + pr.price AS current_momo_price + FROM competitor_match_attempts cma + LEFT JOIN products p ON p.i_code = cma.sku + LEFT JOIN LATERAL ( + SELECT price + FROM price_records + WHERE product_id = p.id + ORDER BY timestamp DESC, id DESC + LIMIT 1 + ) pr ON TRUE + """ + if conn.dialect.name != "postgresql": + latest_price_sql = """ + ( + SELECT price + FROM price_records + WHERE product_id = p.id + ORDER BY timestamp DESC, id DESC + LIMIT 1 + ) AS current_momo_price + FROM competitor_match_attempts cma + LEFT JOIN products p ON p.i_code = cma.sku + """ + + row = conn.execute(text(f""" + SELECT + cma.*, + p.id AS current_momo_product_id, + p.name AS current_momo_product_name, + {latest_price_sql} + WHERE cma.sku = :sku + AND cma.source = :source + ORDER BY cma.attempted_at DESC, cma.id DESC + LIMIT 1 + """), {"sku": sku, "source": source}).mappings().first() + return dict(row) if row else None + + +def _insert_manual_attempt(conn, attempt: dict[str, Any], action_meta: dict[str, str], source: str) -> None: + search_terms_expr = _json_array_expr(conn, "search_terms") + conn.execute(text(f""" + INSERT INTO competitor_match_attempts + (sku, source, momo_product_id, momo_product_name, momo_price, + search_terms, candidate_count, attempt_status, + best_competitor_product_id, best_competitor_product_name, + best_competitor_price, best_match_score, error_message, + attempted_at) + VALUES + (:sku, :source, :momo_product_id, :momo_product_name, :momo_price, + {search_terms_expr}, :candidate_count, :attempt_status, + :best_id, :best_name, + :best_price, :best_score, :error_message, + CURRENT_TIMESTAMP) + """), { + "sku": attempt.get("sku"), + "source": source, + "momo_product_id": attempt.get("momo_product_id") or attempt.get("current_momo_product_id"), + "momo_product_name": attempt.get("momo_product_name") or attempt.get("current_momo_product_name"), + "momo_price": attempt.get("momo_price") or attempt.get("current_momo_price"), + "search_terms": json.dumps([f"manual_review:{action_meta['attempt_status']}"], ensure_ascii=False), + "candidate_count": attempt.get("candidate_count") or 0, + "attempt_status": action_meta["attempt_status"], + "best_id": attempt.get("best_competitor_product_id"), + "best_name": attempt.get("best_competitor_product_name"), + "best_price": attempt.get("best_competitor_price"), + "best_score": attempt.get("best_match_score"), + "error_message": action_meta["message"], + }) + + +def _promote_manual_match(conn, attempt: dict[str, Any], source: str) -> None: + candidate_id = str(attempt.get("best_competitor_product_id") or "").strip() + candidate_name = str(attempt.get("best_competitor_product_name") or "").strip() + candidate_price = _num(attempt.get("best_competitor_price")) + if not candidate_id or not candidate_name or not candidate_price or candidate_price <= 0: + raise ValueError("採用同款需要候選 PChome 商品 ID、名稱與有效價格") + + _ensure_competitor_price_history_table(conn) + _taipei = timezone(timedelta(hours=8)) + expires_at = (datetime.now(_taipei) + timedelta(hours=6)).strftime("%Y-%m-%d %H:%M:%S") + match_score = max(_num(attempt.get("best_match_score")) or 0, 0.76) + tags = [ + "identity_v2", + "manual_review", + "manual_accept", + "comparison_exact_identity", + ] + tags_json = json.dumps(tags, ensure_ascii=False) + tags_expr = _json_array_expr(conn, "tags") + momo_product_id = attempt.get("momo_product_id") or attempt.get("current_momo_product_id") + momo_price = attempt.get("momo_price") or attempt.get("current_momo_price") + + conn.execute(text(f""" + INSERT INTO competitor_prices + (sku, source, price, original_price, discount_pct, + competitor_product_id, competitor_product_name, + match_score, tags, crawled_at, expires_at) + VALUES + (:sku, :source, :price, NULL, NULL, + :candidate_id, :candidate_name, + :match_score, {tags_expr}, CURRENT_TIMESTAMP, :expires_at) + ON CONFLICT (sku, source) DO UPDATE + SET price = EXCLUDED.price, + original_price = EXCLUDED.original_price, + discount_pct = EXCLUDED.discount_pct, + competitor_product_id = EXCLUDED.competitor_product_id, + competitor_product_name = EXCLUDED.competitor_product_name, + match_score = EXCLUDED.match_score, + tags = EXCLUDED.tags, + crawled_at = CURRENT_TIMESTAMP, + expires_at = EXCLUDED.expires_at + """), { + "sku": attempt.get("sku"), + "source": source, + "price": candidate_price, + "candidate_id": candidate_id, + "candidate_name": candidate_name[:200], + "match_score": match_score, + "tags": tags_json, + "expires_at": expires_at, + }) + conn.execute(text(f""" + INSERT INTO competitor_price_history + (sku, source, momo_product_id, momo_price, + price, original_price, discount_pct, + competitor_product_id, competitor_product_name, + match_score, tags, crawled_at) + VALUES + (:sku, :source, :momo_product_id, :momo_price, + :price, NULL, NULL, + :candidate_id, :candidate_name, + :match_score, {tags_expr}, CURRENT_TIMESTAMP) + """), { + "sku": attempt.get("sku"), + "source": source, + "momo_product_id": momo_product_id, + "momo_price": momo_price, + "price": candidate_price, + "candidate_id": candidate_id, + "candidate_name": candidate_name[:200], + "match_score": match_score, + "tags": tags_json, + }) + + +def record_competitor_match_review( + engine, + sku: str, + review_action: str, + reviewer_identity: str = "dashboard", + review_reason: str = "", + source: str = "pchome", +) -> dict[str, Any]: + """Record a human decision for the latest PChome match attempt.""" + sku = str(sku or "").strip() + source = str(source or "pchome").strip() or "pchome" + review_action = str(review_action or "").strip() + review_reason = str(review_reason or "").strip()[:1000] + reviewer_identity = str(reviewer_identity or "dashboard").strip()[:120] or "dashboard" + + if not sku: + return {"success": False, "message": "缺少 MOMO 商品 ID"} + action_meta = VALID_REVIEW_ACTIONS.get(review_action) + if not action_meta: + return {"success": False, "message": "不支援的覆核動作"} + + with engine.begin() as conn: + _ensure_competitor_match_reviews_table(conn) + attempt = _fetch_latest_attempt(conn, sku, source) + if not attempt: + return {"success": False, "message": "找不到可覆核的 PChome 比對嘗試"} + + if review_action == "accept_identity": + _promote_manual_match(conn, attempt, source) + + _insert_manual_attempt(conn, attempt, action_meta, source) + conn.execute(text(""" + INSERT INTO competitor_match_reviews + (sku, source, review_action, review_reason, reviewer_identity, + momo_product_id, momo_product_name, momo_price, + candidate_product_id, candidate_product_name, candidate_price, + candidate_match_score, candidate_diagnostic, + resulting_attempt_status, reviewed_at) + VALUES + (:sku, :source, :review_action, :review_reason, :reviewer_identity, + :momo_product_id, :momo_product_name, :momo_price, + :candidate_id, :candidate_name, :candidate_price, + :candidate_score, :candidate_diagnostic, + :resulting_attempt_status, CURRENT_TIMESTAMP) + """), { + "sku": sku, + "source": source, + "review_action": review_action, + "review_reason": review_reason, + "reviewer_identity": reviewer_identity, + "momo_product_id": attempt.get("momo_product_id") or attempt.get("current_momo_product_id"), + "momo_product_name": attempt.get("momo_product_name") or attempt.get("current_momo_product_name"), + "momo_price": attempt.get("momo_price") or attempt.get("current_momo_price"), + "candidate_id": attempt.get("best_competitor_product_id"), + "candidate_name": attempt.get("best_competitor_product_name"), + "candidate_price": attempt.get("best_competitor_price"), + "candidate_score": attempt.get("best_match_score"), + "candidate_diagnostic": attempt.get("error_message"), + "resulting_attempt_status": action_meta["attempt_status"], + }) + + return { + "success": True, + "message": action_meta["message"], + "sku": sku, + "review_action": review_action, + "attempt_status": action_meta["attempt_status"], + } diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index 37297c7..df431de 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -540,6 +540,31 @@ {% if review.unit_comparison and review.unit_comparison.summary %}
{{ review.unit_comparison.summary }}
{% endif %} +
+ {% if review.candidate_pc_id and review.candidate_pc_price %} + + {% endif %} + + +
{% endif %} diff --git a/tests/test_competitor_match_attempts_persistence.py b/tests/test_competitor_match_attempts_persistence.py index d4e9892..c92dcc1 100644 --- a/tests/test_competitor_match_attempts_persistence.py +++ b/tests/test_competitor_match_attempts_persistence.py @@ -42,6 +42,32 @@ def test_competitor_feeder_persists_all_match_attempt_outcomes(): assert "idx_comp_match_attempts_sku_source_time" in migration +def test_competitor_match_review_service_closes_human_review_loop(): + service_source = (ROOT / "services/competitor_match_review_service.py").read_text(encoding="utf-8") + migration = (ROOT / "migrations/039_create_competitor_match_reviews.sql").read_text(encoding="utf-8") + dashboard_js = (ROOT / "web/static/js/page-dashboard-v2.js").read_text(encoding="utf-8") + + assert "VALID_REVIEW_ACTIONS" in service_source + assert "accept_identity" in service_source + assert "reject_identity" in service_source + assert "unit_price_required" in service_source + assert "manual_accepted" in service_source + assert "manual_rejected" in service_source + assert "manual_unit_price_required" in service_source + assert "INSERT INTO competitor_match_reviews" in service_source + assert "INSERT INTO competitor_prices" in service_source + assert "INSERT INTO competitor_price_history" in service_source + assert "manual_review" in service_source + assert "manual_accept" in service_source + assert "CREATE TABLE IF NOT EXISTS competitor_match_reviews" in migration + assert "review_action" in migration + assert "reviewer_identity" in migration + assert "candidate_diagnostic" in migration + assert "idx_comp_match_reviews_sku_source_time" in migration + assert "runPchomeReviewDecision" in dashboard_js + assert "/api/pchome-review/" in dashboard_js + + def test_competitor_feeder_logs_keyword_parser_fallback(monkeypatch, caplog): from services import competitor_price_feeder from services import marketplace_product_matcher diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index fa2b7cc..b19ecdc 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -144,6 +144,9 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data(): assert "total_items = review_queue_total" in route_source assert "REVIEW_STATUS_OPTIONS" in route_source assert "current_review_status" in route_source + assert "@dashboard_bp.route('/api/pchome-review//decision', methods=['POST'])" in route_source + assert "record_competitor_match_review" in route_source + assert "clear_competitor_intel_cache()" in route_source assert "MockRecord" not in route_source assert "{% for item in items %}" in dashboard assert "比價監控總覽" in dashboard @@ -159,6 +162,10 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data(): assert "review_status=option.key" in dashboard assert "需單位價" in dashboard assert "dashboard-review-segments" in dashboard + assert "data-pchome-review-action" in dashboard + assert "採用同款" in dashboard + assert "否決候選" in dashboard + assert "標記單位價" in dashboard assert "AI 挑品清單" in dashboard assert "比價覆核隊列" in dashboard assert "覆核動作" in dashboard @@ -210,7 +217,9 @@ def test_pchome_review_export_and_diagnostics_use_real_queue_data(): assert "匯出覆核" in dashboard assert "review.diagnostic_reasons" in dashboard assert "dashboard-review-reasons" in dashboard + assert "dashboard-review-actions" in dashboard assert ".dashboard-review-reasons" in dashboard_css + assert ".dashboard-review-actions" in dashboard_css def test_ai_intelligence_uses_v2_shell_and_real_runtime_apis(): diff --git a/web/static/css/page-dashboard-v2.css b/web/static/css/page-dashboard-v2.css index 3241531..72ff2fb 100644 --- a/web/static/css/page-dashboard-v2.css +++ b/web/static/css/page-dashboard-v2.css @@ -841,6 +841,50 @@ line-height: 1.45; } + .dashboard-review-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-width: 0; + } + + .dashboard-review-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 26px; + padding: 4px 8px; + color: var(--momo-text-secondary); + background: var(--momo-bg-paper); + border: 1px solid var(--momo-border); + border-radius: 4px; + font-size: 10px; + font-weight: 800; + line-height: 1.2; + transition: var(--momo-transition-base); + } + + .dashboard-review-action:hover { + color: var(--momo-text-primary); + background: var(--momo-bg-subtle); + } + + .dashboard-review-action.is-accept { + color: var(--momo-text-inverse); + background: var(--momo-success); + border-color: var(--momo-success); + } + + .dashboard-review-action.is-accept:hover { + color: var(--momo-text-inverse); + filter: brightness(0.94); + } + + .dashboard-review-action:disabled { + cursor: wait; + opacity: 0.58; + } + .dashboard-history-button { display: inline-flex; align-items: center; diff --git a/web/static/js/page-dashboard-v2.js b/web/static/js/page-dashboard-v2.js index 7af936b..1d802a4 100644 --- a/web/static/js/page-dashboard-v2.js +++ b/web/static/js/page-dashboard-v2.js @@ -280,6 +280,43 @@ let priceChartInstance = null; button.addEventListener('click', () => runDashboardTask(button.dataset.dashboardTask)); }); + function runPchomeReviewDecision(button) { + const sku = button.dataset.reviewSku || ''; + const action = button.dataset.reviewAction || ''; + if (!sku || !action) return; + const confirmText = button.dataset.reviewConfirm || '確認寫入這筆覆核決策?'; + if (!confirm(confirmText)) return; + const reason = prompt('補充覆核原因(可留空)', '') || ''; + const originalText = button.textContent; + button.disabled = true; + button.textContent = '寫入中'; + fetch(`/api/pchome-review/${encodeURIComponent(sku)}/decision`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCSRFToken() + }, + body: JSON.stringify({ action, reason }) + }) + .then(response => response.json().then(data => ({ ok: response.ok, data }))) + .then(({ ok, data }) => { + if (!ok || !data.success) { + throw new Error(data.message || '覆核寫入失敗'); + } + alert(data.message || '覆核已寫入'); + window.location.reload(); + }) + .catch(error => { + alert('錯誤: ' + error.message); + button.disabled = false; + button.textContent = originalText; + }); + } + + document.querySelectorAll('[data-pchome-review-action]').forEach(button => { + button.addEventListener('click', () => runPchomeReviewDecision(button)); + }); + function trackMomoLinkClick(event) { const link = event.target.closest('.momo-tracked-link'); if (!link) {