新增 PChome 覆核匯出與診斷原因
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s

This commit is contained in:
OoO
2026-05-20 09:48:25 +08:00
parent ac93d185f4
commit 0242aebb66
8 changed files with 237 additions and 4 deletions

View File

@@ -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 promotionAPI/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 必須另開 gateAPI/UI 仍不讀 approval token、不執行 CLI、不連 DB、不寫 `metadata_json`、不派送 Telegram、不掛 scheduler。

View File

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

View File

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

View File

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

View File

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

View File

@@ -163,6 +163,13 @@
{% endif %}
</div>
<div class="dashboard-focus-sub">{{ item.action_label }}</div>
{% if item.diagnostic_reasons %}
<div class="dashboard-review-reasons" aria-label="比對診斷原因">
{% for reason in item.diagnostic_reasons[:4] %}
<span>{{ reason.label }}</span>
{% endfor %}
</div>
{% endif %}
{% if item.unit_comparison and item.unit_comparison.summary %}
<div class="dashboard-review-note momo-mono">{{ item.unit_comparison.summary }}</div>
{% endif %}
@@ -283,6 +290,10 @@
<a class="dashboard-action-link" href="/api/export/excel/ai-picks">
<i class="fas fa-file-excel"></i> 匯出 AI 挑品
</a>
{% elif current_filter == 'pchome_review' %}
<a class="dashboard-action-link" href="{{ url_for('export.export_excel_pchome_review', review_status=current_review_status, category=current_category, q=search_query) }}">
<i class="fas fa-file-excel"></i> 匯出覆核
</a>
{% endif %}
</div>
@@ -508,6 +519,13 @@
</div>
<div class="dashboard-ai-pick-reason">{{ review.action_label if review else decision.summary }}</div>
{% if review %}
{% if review.diagnostic_reasons %}
<div class="dashboard-review-reasons" aria-label="比對診斷原因">
{% for reason in review.diagnostic_reasons[:4] %}
<span>{{ reason.label }}</span>
{% endfor %}
</div>
{% endif %}
<div class="dashboard-ai-evidence-line">
{% if review.candidate_count %}
<span>{{ review.candidate_count }} 筆候選</span>

View File

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

View File

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