This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
================================================================================
|
||||
|
||||
【已完成】
|
||||
- V10.302 補 PChome 比價覆核匯出與診斷原因:`filter=pchome_review` 每筆覆核把 matcher `reasons=` 翻成品牌不符、商品線不符、容量差異、組合差異、需單位價、價差極端等可行動標籤;新增 `/api/export/excel/pchome-review` 匯出完整覆核隊列、人工處置、候選 PChome、單位價比較與原始診斷,避免核心比價只停在籠統「待對比」。
|
||||
- V10.301 補市場情報 candidate queue review AI summary Telegram dispatch gate:新增 `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_gate` 與 UI 按鈕,在 summary persistence closeout 後檢查 Telegram 訊息契約、channel label、artifact path、token 外洩風險與後續 run package promotion;API/UI 仍不讀 approval/Telegram token、不呼叫 LLM、不開 DB、不寫檔、不派送 Telegram、不掛 scheduler。
|
||||
- V10.300 補商品看板比價覆核狀態分流:`filter=pchome_review` 新增全部、需單位價、身份否決、低信心、價格過期、找不到同款 segmented 篩選與分頁保留參數,讓 6,000+ 筆覆核隊列能依 matcher 診斷類型分批處理;同步修正覆核列表表頭/分頁連結狀態保留。
|
||||
- V10.299 補市場情報 candidate queue review AI summary persistence run closeout:新增 `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_run_closeout` 與 UI 按鈕,在 receipt 通過後收尾 metadata_json persistence gate,確認 closeout artifact、操作員確認與後續 Telegram dispatch 必須另開 gate;API/UI 仍不讀 approval token、不執行 CLI、不連 DB、不寫 `metadata_json`、不派送 Telegram、不掛 scheduler。
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。
|
||||
|
||||
| 角色 | 模型 | 主機 | 成本 | 每日限額 |
|
||||
|------|------|------|------|---------|
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user