From 06418878e008de0dedbecea5639dd25210e5b0b0 Mon Sep 17 00:00:00 2001 From: ogt Date: Wed, 24 Jun 2026 13:09:56 +0800 Subject: [PATCH] feat: add momo review candidate queue --- config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 1 + routes/ai_routes.py | 58 ++++++ services/external_market_offer_service.py | 182 +++++++++++++++++ templates/ai_intelligence.html | 207 +++++++++++++++++++- tests/test_external_market_offer_service.py | 56 ++++++ tests/test_pchome_revenue_growth_service.py | 4 + 7 files changed, 508 insertions(+), 2 deletions(-) diff --git a/config.py b/config.py index 5857db9..4d69a8d 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.639" +SYSTEM_VERSION = "V10.640" 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 9ebb070..4cf351c 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -75,6 +75,7 @@ - V10.623 起 `/price_comparison` 與 `/ai_intelligence` 不得只靠大段文字說明流程:比價頁第一屏必須有主 KPI、目前卡點、四步流程與結果決策摘要;作戰頁第一屏必須有今日任務、可立即處理、待補比價與最新業績日。所有狀態都要由實際 API/前端狀態驅動,讓使用者一眼知道下一步要按哪個動作。 - V10.638 起 PChome 導向 MOMO 補抓會把「找到但不能自動比價」的候選以 `match_status='needs_review'`、`data_quality_status='needs_review'` 保存到 `external_offers`;這些候選不得進價格壓力判斷,也不得發告警,但 `/api/ai/pchome-growth/opportunities` 可回傳待確認候選數,讓 UI 顯示「已有候選待確認」而不是只顯示無法比價。 - V10.639 起待確認候選排序必須容忍缺少單位數量;沒有 `momo_total_quantity` / `competitor_total_quantity` 時仍可保存為 `needs_review`,不得中斷 PChome 導向 MOMO 回填。 +- V10.640 起 `/ai_intelligence` 必須提供 MOMO 待確認候選操作佇列;使用者可直接確認同款或排除候選。確認後 `external_offers` 會轉為 `verified/verified` 並進入作戰清單,排除後轉為 `rejected/rejected`,兩者都必須清掉 PChome 成長作戰清單快取。 ## 零之一、12 Agent 決策信封(2026-05-24) diff --git a/routes/ai_routes.py b/routes/ai_routes.py index 94176b9..77f4c66 100644 --- a/routes/ai_routes.py +++ b/routes/ai_routes.py @@ -1724,6 +1724,64 @@ def api_pchome_growth_source_contract(): }), 500 +@ai_bp.route('/api/ai/pchome-growth/review-candidates') +@login_required +def api_pchome_growth_review_candidates(): + """列出 MOMO 待確認候選,只讀、不呼叫 LLM。""" + try: + from config import DATABASE_PATH + from services.external_market_offer_service import list_momo_review_candidates + + limit = request.args.get('limit', 20, type=int) + limit = max(1, min(limit, 50)) + engine = _create_icaim_dashboard_engine(DATABASE_PATH) + try: + payload = list_momo_review_candidates(engine, limit=limit) + finally: + engine.dispose() + status_code = 200 if payload.get("success") else 400 + return jsonify(payload), status_code + except Exception as exc: + logger.error("[PChomeGrowth] MOMO 待確認候選讀取失敗: %s", exc, exc_info=True) + return jsonify({ + "success": False, + "error": "MOMO 待確認候選暫時無法讀取,請稍後再試。", + }), 500 + + +@ai_bp.route('/api/ai/pchome-growth/review-candidates/', methods=['POST']) +@login_required +def api_pchome_growth_update_review_candidate(offer_id): + """確認或排除 MOMO 待確認候選,不呼叫 LLM。""" + payload = request.get_json(silent=True) or {} + action = str(payload.get("action") or "").strip().lower() + note = str(payload.get("note") or "").strip() + engine = None + try: + from config import DATABASE_PATH + from services.external_market_offer_service import update_momo_review_candidate + + engine = _create_icaim_dashboard_engine(DATABASE_PATH) + result = update_momo_review_candidate(engine, offer_id, action, note=note) + if result.get("success"): + _PCHOME_GROWTH_CACHE.update({ + "expires_at": 0.0, + "epoch": 0.0, + "payload": None, + }) + status_code = 200 if result.get("success") else 400 + return jsonify(result), status_code + except Exception as exc: + logger.error("[PChomeGrowth] MOMO 待確認候選更新失敗: %s", exc, exc_info=True) + return jsonify({ + "success": False, + "error": "MOMO 待確認候選暫時無法更新,請稍後再試。", + }), 500 + finally: + if engine is not None: + engine.dispose() + + def _decode_external_offer_csv_upload(raw_bytes): for encoding in ("utf-8-sig", "utf-8", "big5", "cp950"): try: diff --git a/services/external_market_offer_service.py b/services/external_market_offer_service.py index 1e5e6f9..fa86c8f 100644 --- a/services/external_market_offer_service.py +++ b/services/external_market_offer_service.py @@ -1435,3 +1435,185 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]: "connector_contract": build_connector_contracts(), "plain_summary": "MOMO 先用;蝦皮與酷澎先保留接口,暫不進告警。", } + + +def list_momo_review_candidates(engine, *, limit: int = 20) -> dict[str, Any]: + """列出待人工確認的 MOMO 候選,供前台直接處理。""" + limit = max(1, min(int(limit or 20), 50)) + generated_at = datetime.now().isoformat(timespec="seconds") + required_tables = {"external_offers"} + + with engine.connect() as conn: + missing_tables = sorted(table for table in required_tables if not _has_table(conn, table)) + if missing_tables: + return { + "success": False, + "generated_at": generated_at, + "rows": [], + "count": 0, + "missing_tables": missing_tables, + "message": "待確認候選暫時無法讀取,缺少必要資料表。", + } + + rows = conn.execute(text(""" + SELECT + id, + source_product_id, + title, + product_url, + image_url, + price, + pchome_product_id, + momo_sku, + match_status, + quality_score, + data_quality_status, + quality_notes_json, + raw_payload_json, + observed_at, + updated_at + FROM external_offers + WHERE source_code = 'momo_reference' + AND ingestion_method = 'targeted_momo_review' + AND ( + match_status = 'needs_review' + OR data_quality_status = 'needs_review' + ) + ORDER BY observed_at DESC, id DESC + LIMIT :limit + """), {"limit": limit * 4}).mappings().all() + + seen: set[tuple[str, str]] = set() + items: list[dict[str, Any]] = [] + for row in rows: + raw_payload = _load_json_dict(row.get("raw_payload_json")) + quality_notes = _load_json_list(row.get("quality_notes_json")) + key = ( + str(row.get("pchome_product_id") or "").strip(), + str(row.get("source_product_id") or "").strip(), + ) + if key in seen: + continue + seen.add(key) + + reasons = [ + str(reason) + for reason in (raw_payload.get("match_reasons") or []) + if str(reason or "").strip() + ] + if not reasons: + reasons = [str(note) for note in quality_notes if str(note or "").strip()] + pchome_price = _to_float(raw_payload.get("pchome_public_price")) + momo_price = _to_float(row.get("price")) + gap_pct = _to_float(raw_payload.get("target_gap_pct")) + + items.append({ + "id": int(row.get("id")), + "pchome_product_id": row.get("pchome_product_id"), + "pchome_product_name": raw_payload.get("pchome_public_name") or "", + "pchome_price": pchome_price, + "momo_sku": row.get("momo_sku") or row.get("source_product_id"), + "momo_title": row.get("title"), + "momo_price": momo_price, + "product_url": row.get("product_url"), + "image_url": row.get("image_url"), + "quality_score": round(_to_float(row.get("quality_score")) or 0.0, 2), + "alert_tier": raw_payload.get("alert_tier") or "identity_review", + "price_basis": raw_payload.get("price_basis") or "manual_review", + "gap_pct": gap_pct, + "match_reasons": reasons[:5], + "observed_at": str(row.get("observed_at") or ""), + "updated_at": str(row.get("updated_at") or ""), + "plain_status": "待確認同款或色號", + "suggested_next_action": "確認同款後才進入價格判斷;不是同款就排除。", + }) + if len(items) >= limit: + break + + return { + "success": True, + "generated_at": generated_at, + "rows": items, + "count": len(items), + "message": "已整理 MOMO 待確認候選。", + } + + +def update_momo_review_candidate(engine, offer_id: int, action: str, *, note: str = "") -> dict[str, Any]: + """確認或排除一筆 MOMO 待確認候選。""" + try: + offer_id = int(offer_id) + except (TypeError, ValueError): + return {"success": False, "message": "缺少有效的候選編號。"} + action = str(action or "").strip().lower() + if action not in {"confirm", "reject"}: + return {"success": False, "message": "請選擇確認同款或排除候選。"} + + generated_at = datetime.now().isoformat(timespec="seconds") + new_match_status = "verified" if action == "confirm" else "rejected" + new_quality_status = "verified" if action == "confirm" else "rejected" + label = "人工確認同款" if action == "confirm" else "人工排除候選" + review_note = str(note or "").strip()[:240] + + with engine.begin() as conn: + if not _has_table(conn, "external_offers"): + return { + "success": False, + "generated_at": generated_at, + "message": "待確認候選暫時無法更新,缺少必要資料表。", + } + + row = conn.execute(text(""" + SELECT id, match_status, data_quality_status, quality_notes_json, raw_payload_json + FROM external_offers + WHERE id = :offer_id + AND source_code = 'momo_reference' + AND ingestion_method = 'targeted_momo_review' + LIMIT 1 + """), {"offer_id": offer_id}).mappings().first() + if not row: + return { + "success": False, + "generated_at": generated_at, + "message": "找不到這筆待確認候選。", + } + + raw_payload = _load_json_dict(row.get("raw_payload_json")) + raw_payload["review_state"] = new_match_status + raw_payload["reviewed_at"] = generated_at + raw_payload["review_action"] = action + if review_note: + raw_payload["review_note"] = review_note + tags = raw_payload.get("tags") if isinstance(raw_payload.get("tags"), list) else [] + tag_to_add = "manual_verified" if action == "confirm" else "manual_rejected" + raw_payload["tags"] = [*tags, tag_to_add] if tag_to_add not in tags else tags + + notes = [str(item) for item in _load_json_list(row.get("quality_notes_json")) if str(item or "").strip()] + notes.append(label if not review_note else f"{label}:{review_note}") + + conn.execute(text(""" + UPDATE external_offers + SET match_status = :match_status, + data_quality_status = :data_quality_status, + quality_notes_json = :quality_notes_json, + raw_payload_json = :raw_payload_json, + updated_at = CURRENT_TIMESTAMP + WHERE id = :offer_id + """), { + "offer_id": offer_id, + "match_status": new_match_status, + "data_quality_status": new_quality_status, + "quality_notes_json": json.dumps(notes[-6:], ensure_ascii=False), + "raw_payload_json": json.dumps(raw_payload, ensure_ascii=False), + }) + mark_pchome_growth_cache_stale() + + return { + "success": True, + "generated_at": generated_at, + "id": offer_id, + "action": action, + "match_status": new_match_status, + "data_quality_status": new_quality_status, + "message": "已確認同款,會進入作戰清單。" if action == "confirm" else "已排除候選,不會再進入待確認。", + } diff --git a/templates/ai_intelligence.html b/templates/ai_intelligence.html index e3fb0ae..7bd7a52 100644 --- a/templates/ai_intelligence.html +++ b/templates/ai_intelligence.html @@ -533,7 +533,7 @@ .growth-metric-row { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } @@ -845,6 +845,62 @@ color: #94372d; } + .review-candidate-panel { + display: grid; + grid-template-columns: minmax(0, 0.76fr) minmax(0, 1.6fr); + gap: 14px; + } + + .review-candidate-summary { + display: grid; + gap: 8px; + } + + .review-candidate-result { + border: 1px solid rgba(42, 37, 32, 0.1); + border-radius: 8px; + background: rgba(255, 255, 255, 0.76); + min-height: 212px; + max-height: 360px; + overflow: auto; + padding: 10px; + } + + .review-candidate-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + border-bottom: 1px solid rgba(42, 37, 32, 0.08); + padding: 10px 0; + } + + .review-candidate-row:last-child { + border-bottom: 0; + } + + .review-candidate-title { + margin: 0; + color: var(--momo-text-strong); + font-size: 0.84rem; + font-weight: 900; + line-height: 1.35; + } + + .review-candidate-meta, + .review-candidate-reason { + margin: 4px 0 0; + color: var(--momo-text-muted); + font-size: 0.74rem; + line-height: 1.4; + } + + .review-candidate-actions { + display: grid; + gap: 7px; + align-content: start; + min-width: 104px; + } + @media (max-width: 992px) { .ai-intel-hero { grid-template-columns: 1fr; @@ -1007,6 +1063,7 @@ } .growth-ops-grid, + .review-candidate-panel, .growth-executive-strip, .offer-dryrun-grid, .growth-metric-row, @@ -1225,6 +1282,41 @@ + +
+
+ + MOMO 待確認候選 + 先確認同款,再進價格判斷 + + +
+
+
+
+
+ + 待確認 +
+
+ 0 + 確認後可進作戰 +
+
+ 確認同款後才會進入 MOMO 價格參考;不確定色號、容量或組合時請先排除。 +
+
+
+
+
整理待確認候選中... +
+
+
+
+
+
@@ -1520,6 +1612,15 @@ function escapeHtml(value) { }[ch])); } +function safeHttpUrl(value) { + try { + const url = new URL(String(value || ''), window.location.origin); + return ['http:', 'https:'].includes(url.protocol) ? url.href : ''; + } catch (_) { + return ''; + } +} + function scrollToPanel(panelId) { const panel = document.getElementById(panelId); if (!panel) return; @@ -1740,11 +1841,13 @@ async function loadGrowthOps(forceRefresh = false) { renderGrowthSourceReadiness((scope.source_readiness || {}).sources || []); renderGrowthOps(data.opportunities || []); + loadGrowthReviewCandidates(); } catch (error) { console.error(error); renderOpsCommandDashboard({}, {}); renderGrowthActionHint({ candidate_count: 0, mapped_count: 0, needs_mapping_count: 0 }); renderGrowthDataSourceSummary({}); + renderGrowthReviewCandidates([]); list.innerHTML = `
今日處理清單暫時讀不到,請重新整理;若仍失敗,請檢查業績資料。 @@ -1921,6 +2024,108 @@ function renderGrowthOps(rows) {
`; } +async function loadGrowthReviewCandidates(forceRefresh = false) { + const box = document.getElementById('growthReviewCandidateList'); + if (!box) return; + if (forceRefresh) { + box.innerHTML = `
+
更新待確認候選中... +
`; + } + + try { + const response = await fetch('/api/ai/pchome-growth/review-candidates?limit=20'); + const data = await readJsonResponse(response); + if (!data.success) throw new Error(data.error || data.message || '讀取失敗'); + renderGrowthReviewCandidates(data.rows || []); + } catch (error) { + console.error(error); + box.innerHTML = `
+ + 待確認候選暫時讀不到,請稍後再試。 +
`; + } +} + +function renderGrowthReviewCandidates(rows) { + const box = document.getElementById('growthReviewCandidateList'); + const total = document.getElementById('reviewCandidateTotal'); + const readyHint = document.getElementById('reviewCandidateReadyHint'); + if (!box) return; + + rows = Array.isArray(rows) ? rows : []; + if (total) total.textContent = rows.length.toLocaleString(); + if (readyHint) readyHint.textContent = rows.length.toLocaleString(); + + if (!rows.length) { + box.innerHTML = `
+ + 目前沒有待確認候選。 +
`; + return; + } + + box.innerHTML = rows.map((row) => { + const reasons = (row.match_reasons || []).slice(0, 3).join('、') || '候選已找到,需確認同款、色號或組合'; + const gap = row.gap_pct === null || row.gap_pct === undefined ? '' : ` · 參考差距 ${Number(row.gap_pct).toFixed(1)}%`; + const score = Number(row.quality_score || 0).toFixed(0); + const momoPrice = row.momo_price ? formatMoney(row.momo_price) : '未取得 MOMO 價格'; + const pchomePrice = row.pchome_price ? formatMoney(row.pchome_price) : '未取得 PChome 價格'; + const safeUrl = safeHttpUrl(row.product_url); + const url = safeUrl ? `看 MOMO` : ''; + return `
+
+

${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}

+

+ PChome ${escapeHtml(pchomePrice)} · MOMO ${escapeHtml(momoPrice)}${escapeHtml(gap)} +

+

+ MOMO:${escapeHtml(row.momo_title || row.momo_sku || '未命名候選')} ${url} +

+

+ 可信度 ${score}% · ${escapeHtml(reasons)} +

+
+
+ + +
+
`; + }).join(''); +} + +async function updateGrowthReviewCandidate(id, action, button) { + if (!id || !action) return; + const actionText = action === 'confirm' ? '確認同款' : '排除候選'; + const originalText = button ? button.textContent : ''; + if (button) { + button.disabled = true; + button.textContent = '處理中'; + } + + try { + const response = await fetch(`/api/ai/pchome-growth/review-candidates/${id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action }), + }); + const data = await readJsonResponse(response); + if (!data.success) throw new Error(data.error || data.message || `${actionText}失敗`); + showToast('success', data.message || `${actionText}完成`, 3500); + await loadGrowthReviewCandidates(true); + await loadGrowthOps(true); + loadDashboard(); + } catch (error) { + console.error(error); + showToast('error', `${actionText}失敗:${error.message}`, 5000); + } finally { + if (button) { + button.disabled = false; + button.textContent = originalText; + } + } +} + function fillExternalOfferSample() { const sample = [ '來源,平台商品編號,商品名稱,售價,資料時間,取得方式,PChome商品編號,是否同款,可信度', diff --git a/tests/test_external_market_offer_service.py b/tests/test_external_market_offer_service.py index 685859a..ce37b98 100644 --- a/tests/test_external_market_offer_service.py +++ b/tests/test_external_market_offer_service.py @@ -429,6 +429,58 @@ def test_sync_targeted_momo_review_candidates_writes_needs_review_offer(monkeypa assert stale_marks == [True] +def test_momo_review_candidate_queue_can_confirm_candidate(monkeypatch): + from services import external_market_offer_service as service + + stale_marks = [] + monkeypatch.setattr(service, "mark_pchome_growth_cache_stale", lambda: stale_marks.append(True)) + + engine = create_engine("sqlite:///:memory:") + _seed_external_offer_sync_tables(engine) + service.sync_targeted_momo_review_candidates_to_external_offers(engine, [ + { + "product_id": "14917079", + "name": "【cle de peau 肌膚之鑰】光采柔焦蜜粉 24g (國際航空版)", + "price": 2618, + "target_pchome_product_id": "PCH-CDP", + "target_pchome_name": "cle de peau 光采柔焦蜜粉 24g #1", + "target_pchome_price": 2790, + "target_match_score": 1.0, + "target_price_basis": "none", + "target_alert_tier": "identity_review", + "target_match_type": "exact", + "target_match_reasons": ["variant_selection_review"], + "target_gap_pct": -6.16, + }, + ]) + + queue = service.list_momo_review_candidates(engine) + + assert queue["success"] is True + assert queue["count"] == 1 + candidate = queue["rows"][0] + assert candidate["pchome_product_name"] == "cle de peau 光采柔焦蜜粉 24g #1" + assert candidate["momo_sku"] == "14917079" + assert candidate["plain_status"] == "待確認同款或色號" + + updated = service.update_momo_review_candidate(engine, candidate["id"], "confirm", note="同款 #1") + + assert updated["success"] is True + assert updated["match_status"] == "verified" + assert service.list_momo_review_candidates(engine)["count"] == 0 + with engine.connect() as conn: + row = conn.execute(text(""" + SELECT match_status, data_quality_status, raw_payload_json + FROM external_offers + WHERE id = :id + """), {"id": candidate["id"]}).mappings().one() + raw_payload = __import__("json").loads(row["raw_payload_json"]) + assert row["match_status"] == "verified" + assert row["data_quality_status"] == "verified" + assert raw_payload["review_action"] == "confirm" + assert stale_marks == [True, True] + + def test_sync_targeted_momo_candidates_keeps_best_unit_quantity_match(monkeypatch): from services import external_market_offer_service as service @@ -547,6 +599,10 @@ def test_external_offer_csv_dry_run_route_is_registered_as_post_only(): assert "@ai_bp.route('/api/ai/pchome-growth/external-offers/csv-dry-run', methods=['POST'])" in route_source assert "dry_run_external_offer_csv" in route_source + assert "@ai_bp.route('/api/ai/pchome-growth/review-candidates')" in route_source + assert "@ai_bp.route('/api/ai/pchome-growth/review-candidates/', methods=['POST'])" in route_source + assert "list_momo_review_candidates" in route_source + assert "update_momo_review_candidate" in route_source def test_external_offer_sync_is_registered_in_scheduler(): diff --git a/tests/test_pchome_revenue_growth_service.py b/tests/test_pchome_revenue_growth_service.py index 7a29e5f..49b3cb3 100644 --- a/tests/test_pchome_revenue_growth_service.py +++ b/tests/test_pchome_revenue_growth_service.py @@ -421,7 +421,11 @@ def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint(): assert "PChome 業績成長自動化作戰系統" in template assert "/api/ai/pchome-growth/opportunities" in template assert "/api/ai/pchome-growth/external-offers/csv-dry-run" in template + assert "/api/ai/pchome-growth/review-candidates" in template assert "growthSourceReadiness" in template + assert "MOMO 待確認候選" in template + assert "確認同款" in template + assert "不是同款" in template assert "今日重點總覽" in template assert "nextActionTitle" in template assert "商品處理進度" in template