補齊 PChome 比價人工覆核閉環
All checks were successful
CD Pipeline / deploy (push) Successful in 1m7s

This commit is contained in:
OoO
2026-05-20 10:00:58 +08:00
parent 50af4be9a8
commit 756b01af66
12 changed files with 592 additions and 5 deletions

View File

@@ -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 promotionAPI/UI 仍不讀 approval/Telegram token、不呼叫 LLM、不開 DB、不寫檔、不派送 Telegram、不掛 scheduler。
- V10.300 補商品看板比價覆核狀態分流:`filter=pchome_review` 新增全部、需單位價、身份否決、低信心、價格過期、找不到同款 segmented 篩選與分頁保留參數,讓 6,000+ 筆覆核隊列能依 matcher 診斷類型分批處理;同步修正覆核列表表頭/分頁連結狀態保留。

View File

@@ -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 # 用於模板顯示

View File

@@ -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 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。
| 角色 | 模型 | 主機 | 成本 | 每日限額 |
|------|------|------|------|---------|

View 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 $$;

View File

@@ -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():

View File

@@ -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": "品牌不符",

View 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"],
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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():

View File

@@ -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;

View File

@@ -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) {