This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
================================================================================
|
||||
|
||||
【已完成】
|
||||
- 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、單位價比較與原始診斷,避免核心比價只停在籠統「待對比」。
|
||||
- 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 診斷類型分批處理;同步修正覆核列表表頭/分頁連結狀態保留。
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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/<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,不得把不同販售組合或否決候選灌入正式價差。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。
|
||||
|
||||
| 角色 | 模型 | 主機 | 成本 | 每日限額 |
|
||||
|------|------|------|------|---------|
|
||||
|
||||
52
migrations/039_create_competitor_match_reviews.sql
Normal file
52
migrations/039_create_competitor_match_reviews.sql
Normal file
@@ -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 $$;
|
||||
@@ -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/<sku>/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():
|
||||
|
||||
@@ -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": "品牌不符",
|
||||
|
||||
348
services/competitor_match_review_service.py
Normal file
348
services/competitor_match_review_service.py
Normal file
@@ -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"],
|
||||
}
|
||||
@@ -540,6 +540,31 @@
|
||||
{% if review.unit_comparison and review.unit_comparison.summary %}
|
||||
<div class="dashboard-review-note momo-mono">{{ review.unit_comparison.summary }}</div>
|
||||
{% endif %}
|
||||
<div class="dashboard-review-actions" aria-label="人工覆核決策">
|
||||
{% if review.candidate_pc_id and review.candidate_pc_price %}
|
||||
<button class="dashboard-review-action is-accept" type="button"
|
||||
data-pchome-review-action
|
||||
data-review-action="accept_identity"
|
||||
data-review-sku="{{ review.sku }}"
|
||||
data-review-confirm="確認採用這筆 PChome 候選為正式同款配對?">
|
||||
採用同款
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class="dashboard-review-action" type="button"
|
||||
data-pchome-review-action
|
||||
data-review-action="reject_identity"
|
||||
data-review-sku="{{ review.sku }}"
|
||||
data-review-confirm="確認否決這筆 PChome 候選?">
|
||||
否決候選
|
||||
</button>
|
||||
<button class="dashboard-review-action" type="button"
|
||||
data-pchome-review-action
|
||||
data-review-action="unit_price_required"
|
||||
data-review-sku="{{ review.sku }}"
|
||||
data-review-confirm="確認標記為需單位價比較?">
|
||||
標記單位價
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<sku>/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():
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user