feat: add momo review candidate queue
All checks were successful
CD Pipeline / deploy (push) Successful in 1m11s

This commit is contained in:
ogt
2026-06-24 13:09:56 +08:00
parent 76a89a7098
commit 06418878e0
7 changed files with 508 additions and 2 deletions

View File

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

View File

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

View File

@@ -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/<int:offer_id>', 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:

View File

@@ -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 "已排除候選,不會再進入待確認。",
}

View File

@@ -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 @@
</div>
</section>
<!-- ── MOMO 待確認候選 ── -->
<section class="card shadow-sm ai-panel" id="growthReviewPanel">
<div class="card-header d-flex justify-content-between align-items-center py-2 bg-white border-bottom">
<span class="ai-panel-title">
<i class="fas fa-list-check"></i>MOMO 待確認候選
<small class="text-muted fw-normal ms-2">先確認同款,再進價格判斷</small>
</span>
<button class="btn btn-outline-secondary btn-sm ai-action-btn" onclick="loadGrowthReviewCandidates(true)">
<i class="fas fa-redo me-1"></i>更新候選
</button>
</div>
<div class="card-body">
<div class="review-candidate-panel">
<div class="review-candidate-summary">
<div class="growth-metric">
<strong id="reviewCandidateTotal"></strong>
<span>待確認</span>
</div>
<div class="growth-metric">
<strong id="reviewCandidateReadyHint">0</strong>
<span>確認後可進作戰</span>
</div>
<div class="growth-action-hint">
確認同款後才會進入 MOMO 價格參考;不確定色號、容量或組合時請先排除。
</div>
</div>
<div class="review-candidate-result" id="growthReviewCandidateList">
<div class="text-center py-4 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>整理待確認候選中...
</div>
</div>
</div>
</div>
</section>
<!-- ── KPI 卡片 ── -->
<div class="row g-3 mb-4" id="kpiRow">
<div class="col-6 col-md-3">
@@ -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 = `<div class="text-center py-4 text-muted">
<i class="fas fa-circle-exclamation d-block mb-2"></i>
今日處理清單暫時讀不到,請重新整理;若仍失敗,請檢查業績資料。
@@ -1921,6 +2024,108 @@ function renderGrowthOps(rows) {
</div>`;
}
async function loadGrowthReviewCandidates(forceRefresh = false) {
const box = document.getElementById('growthReviewCandidateList');
if (!box) return;
if (forceRefresh) {
box.innerHTML = `<div class="text-center py-4 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>更新待確認候選中...
</div>`;
}
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 = `<div class="text-center py-4 text-muted">
<i class="fas fa-circle-exclamation d-block mb-2"></i>
待確認候選暫時讀不到,請稍後再試。
</div>`;
}
}
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 = `<div class="text-center py-4 text-muted">
<i class="fas fa-circle-check d-block mb-2"></i>
目前沒有待確認候選。
</div>`;
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 ? `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener">看 MOMO</a>` : '';
return `<article class="review-candidate-row">
<div>
<h3 class="review-candidate-title">${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}</h3>
<p class="review-candidate-meta">
PChome ${escapeHtml(pchomePrice)} · MOMO ${escapeHtml(momoPrice)}${escapeHtml(gap)}
</p>
<p class="review-candidate-meta">
MOMO${escapeHtml(row.momo_title || row.momo_sku || '未命名候選')} ${url}
</p>
<p class="review-candidate-reason">
可信度 ${score}% · ${escapeHtml(reasons)}
</p>
</div>
<div class="review-candidate-actions">
<button type="button" class="btn btn-sm btn-success" onclick="updateGrowthReviewCandidate(${Number(row.id)}, 'confirm', this)">確認同款</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="updateGrowthReviewCandidate(${Number(row.id)}, 'reject', this)">不是同款</button>
</div>
</article>`;
}).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商品編號,是否同款,可信度',

View File

@@ -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/<int:offer_id>', 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():

View File

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