feat: persist targeted momo review candidates
Some checks failed
CD Pipeline / deploy (push) Failing after 35s

This commit is contained in:
OoO
2026-06-19 02:43:34 +08:00
parent bed4488a72
commit 4b0a331d98
8 changed files with 291 additions and 3 deletions

View File

@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.637"
SYSTEM_VERSION = "V10.638"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -73,6 +73,7 @@
- V10.621 起 `/price_comparison` 的「自動找 MOMO 候選」會把可直接總價比價與自動單位價候選同步到 `external_offers``ingestion_method='targeted_momo_search'`,人工確認候選不得寫入。`external_offers.raw_payload_json.price_basis='unit_price'` 時,作戰清單必須使用 `unit_price_comparison` 的 MOMO / PChome 單位價與 `unit_gap_pct` 判斷價格壓力;不得把 MOMO 組合總價與 PChome 單品總價直接相減。此同步只影響外部價格參考與作戰清單,不寫 `competitor_prices`,也不自動改價。
- V10.622 起任何 `external_offers` 自動同步成功寫入後,必須呼叫 `mark_pchome_growth_cache_stale()` 寫入共享 cache epoch`/api/ai/pchome-growth/opportunities` 讀快取前必須比對 `get_pchome_growth_cache_epoch()`。這是跨 Gunicorn worker 的可見性保護,避免自動候選已進外部價格參考,但 AI 情報頁仍回 120 秒舊作戰清單。
- 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 顯示「已有候選待確認」而不是只顯示無法比價。
## 零之一、12 Agent 決策信封2026-05-24

View File

@@ -834,6 +834,92 @@ def _targeted_candidate_to_external_offer(
}, ""
def _targeted_review_candidate_to_external_offer(
candidate: dict[str, Any],
*,
observed_at: datetime,
) -> tuple[dict[str, Any] | None, str]:
"""保存待人工確認候選;這類資料不得進價格判斷。"""
alert_tier = str(candidate.get("target_alert_tier") or "").strip()
match_type = str(candidate.get("target_match_type") or "").strip()
if alert_tier not in {"identity_review", "unit_price_review"} and match_type in {"", "no_match"}:
return None, "不是可人工確認候選"
momo_sku = str(candidate.get("product_id") or candidate.get("goodsCode") or candidate.get("id") or "").strip()
pchome_product_id = str(candidate.get("target_pchome_product_id") or "").strip()
momo_price = _to_float(candidate.get("price"))
if not momo_sku:
return None, "缺少 MOMO 商品 ID"
if not pchome_product_id:
return None, "缺少 PChome 商品 ID"
if not momo_price or momo_price <= 0:
return None, "缺少 MOMO 售價"
match_score = _quality_score_from_match(candidate.get("target_match_score"))
if match_score < 60:
return None, "人工確認候選分數過低"
unit_price_comparison = (
candidate.get("target_unit_price_comparison")
if isinstance(candidate.get("target_unit_price_comparison"), dict)
else {}
)
title = str(candidate.get("name") or candidate.get("title") or momo_sku).strip()
price_basis = str(candidate.get("target_price_basis") or "manual_review").strip()
raw_payload = {
"source": "pchome_targeted_momo_search",
"review_state": "needs_review",
"auto_compare_type": "manual_review",
"price_basis": price_basis,
"pchome_public_price": _to_float(candidate.get("target_pchome_price")),
"pchome_public_name": candidate.get("target_pchome_name"),
"match_score": candidate.get("target_match_score"),
"match_reasons": candidate.get("target_match_reasons") or [],
"comparison_mode": candidate.get("target_comparison_mode"),
"match_type": match_type,
"alert_tier": alert_tier,
"hard_veto": bool(candidate.get("target_hard_veto")),
"target_gap_pct": candidate.get("target_gap_pct"),
"unit_price_comparison": unit_price_comparison,
"search_term": candidate.get("target_search_term"),
"tags": [
"identity_v2",
"source_targeted_momo_search",
"needs_review",
f"review_{alert_tier or 'identity_review'}",
],
}
return {
"source_code": "momo_reference",
"platform_code": "momo",
"source_product_id": momo_sku,
"source_offer_key": f"momo_reference:{momo_sku}:{pchome_product_id}:needs_review",
"title": title or momo_sku,
"brand": candidate.get("brand"),
"category_text": candidate.get("category") or candidate.get("category_text"),
"product_url": candidate.get("product_url") or candidate.get("url"),
"image_url": candidate.get("image_url"),
"price": momo_price,
"original_price": _to_float(candidate.get("original_price")),
"currency": "TWD",
"stock_status": None,
"sold_count": None,
"rating": None,
"review_count": None,
"observed_at": observed_at,
"expires_at": None,
"ingestion_method": "targeted_momo_review",
"connector_key": "pchome_targeted_momo_search_review",
"pchome_product_id": pchome_product_id,
"momo_sku": momo_sku,
"match_status": "needs_review",
"quality_score": round(match_score, 2),
"data_quality_status": "needs_review",
"quality_notes_json": json.dumps(["候選已找到,需人工確認同款或色號"], ensure_ascii=False),
"raw_payload_json": json.dumps(raw_payload, ensure_ascii=False),
}, ""
def sync_targeted_momo_candidates_to_external_offers(
engine,
candidates: list[dict[str, Any]],
@@ -914,6 +1000,76 @@ def sync_targeted_momo_candidates_to_external_offers(
}
def sync_targeted_momo_review_candidates_to_external_offers(
engine,
candidates: list[dict[str, Any]],
*,
dry_run: bool = False,
) -> dict[str, Any]:
"""把待確認 MOMO 候選保存起來,供人工審核;不進價格告警。"""
generated_at = datetime.now().isoformat(timespec="seconds")
candidates = list(candidates or [])
required_tables = {"external_market_sources", "external_offers"}
with engine.begin() as conn:
missing_tables = sorted(table for table in required_tables if not _has_table(conn, table))
if missing_tables:
return {
"success": False,
"status": "skipped",
"generated_at": generated_at,
"candidate_count": len(candidates),
"written_count": 0,
"dry_run": dry_run,
"message": "待確認候選暫時無法保存,缺少必要資料表。",
"missing_tables": missing_tables,
}
_ensure_external_market_source_seeds(conn)
base_observed_at = datetime.now()
ranked_offers: list[tuple[dict[str, Any], tuple[float, float, float, float]]] = []
skipped_reasons: dict[str, int] = {}
for index, candidate in enumerate(candidates):
offer, reason = _targeted_review_candidate_to_external_offer(
candidate,
observed_at=base_observed_at + timedelta(microseconds=index),
)
if offer:
ranked_offers.append((offer, _targeted_candidate_sync_rank(candidate)))
else:
skipped_reasons[reason] = skipped_reasons.get(reason, 0) + 1
selected_by_pchome: dict[str, tuple[dict[str, Any], tuple[float, float, float, float]]] = {}
for offer, rank in ranked_offers:
key = str(offer.get("pchome_product_id") or offer.get("source_offer_key") or "").strip()
existing = selected_by_pchome.get(key)
if existing is None or rank > existing[1]:
selected_by_pchome[key] = (offer, rank)
offers = [offer for offer, _ in selected_by_pchome.values()]
if not dry_run:
for offer in offers:
_upsert_external_offer(conn, offer)
if offers:
mark_pchome_growth_cache_stale()
return {
"success": True,
"status": "dry_run" if dry_run else "synced",
"generated_at": generated_at,
"candidate_count": len(candidates),
"written_count": 0 if dry_run else len(offers),
"dry_run": dry_run,
"source_code": "momo_reference",
"skipped_reasons": skipped_reasons,
"message": (
"已保存待人工確認的 MOMO 候選。"
if not dry_run
else "已完成待確認候選預檢,尚未寫入資料。"
),
}
def sync_legacy_momo_reference_offers(engine, *, limit: int = 500, dry_run: bool = False) -> dict[str, Any]:
"""把既有已確認同款的比價快取自動同步到 external_offers。"""
limit = max(1, min(int(limit or 500), 5000))
@@ -1172,6 +1328,11 @@ def _normalized_offer_stats(conn) -> dict[str, dict[str, Any]]:
AND COALESCE(o.quality_score, 0) >= 76
THEN 1 ELSE 0 END
) AS usable_offer_count,
SUM(CASE
WHEN o.match_status = 'needs_review'
OR o.data_quality_status = 'needs_review'
THEN 1 ELSE 0 END
) AS review_offer_count,
MAX(o.observed_at) AS last_seen_at
FROM external_market_sources s
LEFT JOIN external_offers o ON o.source_code = s.code
@@ -1184,6 +1345,7 @@ def _normalized_offer_stats(conn) -> dict[str, dict[str, Any]]:
"enabled": bool(row.get("enabled")),
"offer_count": int(row.get("offer_count") or 0),
"usable_offer_count": int(row.get("usable_offer_count") or 0),
"review_offer_count": int(row.get("review_offer_count") or 0),
"last_seen_at": str(row.get("last_seen_at") or "") or None,
}
for row in rows
@@ -1244,16 +1406,23 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]:
source["schema_ready"] = schema_ready
source["usable_offer_count"] = usable
source["review_offer_count"] = int(stats.get("review_offer_count") or 0)
source["last_seen_at"] = last_seen_at
source["can_alert"] = source["status_code"] == "active" and usable > 0
if source["status_code"] == "active":
source["plain_state"] = "已接入,可進作戰清單" if usable else "已接入,等待可用資料"
if usable:
source["plain_state"] = "已接入,可進作戰清單"
elif source["review_offer_count"]:
source["plain_state"] = "已有待確認候選"
else:
source["plain_state"] = "已接入,等待可用資料"
else:
source["plain_state"] = "先保留接口,不進告警"
active_count = sum(1 for source in sources if source["status_code"] == "active")
paused_count = sum(1 for source in sources if source["status_code"] == "paused")
usable_count = sum(int(source["usable_offer_count"]) for source in sources)
review_offer_count = sum(int(source.get("review_offer_count") or 0) for source in sources)
return {
"success": True,
@@ -1261,6 +1430,7 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]:
"active_count": active_count,
"paused_count": paused_count,
"usable_offer_count": usable_count,
"review_offer_count": review_offer_count,
"sources": sources,
"connector_contract": build_connector_contracts(),
"plain_summary": "MOMO 先用;蝦皮與酷澎先保留接口,暫不進告警。",

View File

@@ -97,6 +97,12 @@ def _default_sync_candidates(engine, candidates: list[dict[str, Any]]) -> dict[s
return sync_targeted_momo_candidates_to_external_offers(engine, candidates, dry_run=False)
def _default_sync_review_candidates(engine, candidates: list[dict[str, Any]]) -> dict[str, Any]:
from services.external_market_offer_service import sync_targeted_momo_review_candidates_to_external_offers
return sync_targeted_momo_review_candidates_to_external_offers(engine, candidates, dry_run=False)
def run_pchome_growth_momo_backfill(
engine,
*,
@@ -104,16 +110,18 @@ def run_pchome_growth_momo_backfill(
build_payload_func: Callable[[Any, int], dict[str, Any]] | None = None,
search_func: Callable[[list[dict[str, Any]], int], tuple[bool, str, list[dict[str, Any]]]] | None = None,
sync_func: Callable[[Any, list[dict[str, Any]]], dict[str, Any]] | None = None,
sync_review_func: Callable[[Any, list[dict[str, Any]]], dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""補高業績 PChome 商品的 MOMO 對應。
不呼叫 LLM只搜尋 MOMO 候選,並只把可自動判斷的 total_price / unit_price
寫入 external_offers需人工確認的候選只回報、不寫入
寫入 external_offers需人工確認的候選會以 needs_review 保存,不進價格判斷
"""
limit = max(1, min(int(limit or 12), 20))
build_payload = build_payload_func or _default_build_payload
search_candidates = search_func or _default_search_candidates
sync_candidates = sync_func or _default_sync_candidates
sync_review_candidates = sync_review_func or _default_sync_review_candidates
before_payload = build_payload(engine, max(limit, 16))
targets = build_momo_backfill_targets(before_payload, limit)
@@ -135,6 +143,12 @@ def run_pchome_growth_momo_backfill(
"written_count": 0,
"message": "沒有需要同步的自動候選。",
},
"review_candidate_sync": {
"success": True,
"status": "not_needed",
"written_count": 0,
"message": "沒有需要保存的待確認候選。",
},
"before_stats": before_payload.get("stats") or {},
"after_stats": before_payload.get("stats") or {},
"targets": [],
@@ -165,6 +179,14 @@ def run_pchome_growth_momo_backfill(
}
if auto_candidates:
external_offer_sync = sync_candidates(engine, auto_candidates)
review_candidate_sync = {
"success": True,
"status": "not_found",
"written_count": 0,
"message": "沒有需要保存的待確認候選。",
}
if review_candidates:
review_candidate_sync = sync_review_candidates(engine, review_candidates)
after_payload = build_payload(engine, max(limit, 16))
written_count = int(external_offer_sync.get("written_count") or 0)
@@ -189,6 +211,7 @@ def run_pchome_growth_momo_backfill(
"auto_compare_count": len(auto_candidates),
"review_count": len(review_candidates),
"external_offer_sync": external_offer_sync,
"review_candidate_sync": review_candidate_sync,
"before_stats": before_payload.get("stats") or {},
"after_stats": after_payload.get("stats") or {},
"targets": targets[:8],

View File

@@ -703,6 +703,7 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
limit = max(5, min(int(limit or 20), 50))
generated_at = datetime.now().isoformat(timespec="seconds")
source_readiness = build_external_source_readiness(engine)
review_candidate_count = int(source_readiness.get("review_offer_count") or 0)
source_scope = {
"primary_goal": "提升 PChome 業績",
"primary_sales_source": PRIMARY_SALES_SOURCE,
@@ -724,6 +725,7 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
"mapped_count": 0,
"mapping_rate": 0,
"needs_mapping_count": 0,
"review_candidate_count": review_candidate_count,
},
"opportunities": [],
"message": "目前還沒有 PChome 業績資料,請先完成業績匯入。",
@@ -745,6 +747,7 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
"mapped_count": 0,
"mapping_rate": 0,
"needs_mapping_count": 0,
"review_candidate_count": review_candidate_count,
"total_sales_7d": 0,
"opportunity_sales_7d": 0,
"action_counts": {},
@@ -794,6 +797,7 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
"mapped_count": mapped_count,
"mapping_rate": mapping_rate,
"needs_mapping_count": needs_mapping_count,
"review_candidate_count": review_candidate_count,
"total_sales_7d": round(sum(_to_float(item.get("sales_7d")) for item in opportunities), 2),
"opportunity_sales_7d": round(sum(_to_float(item.get("sales_7d")) for item in opportunities), 2),
"action_counts": action_counts,

View File

@@ -1196,6 +1196,10 @@
<strong id="growthNeedsMapping"></strong>
<span>無法比價</span>
</div>
<div class="growth-metric">
<strong id="growthReviewCandidateCount"></strong>
<span>待確認</span>
</div>
</div>
<p class="growth-action-hint" id="growthActionHint">正在判斷今天優先處理順序...</p>
<p class="growth-data-summary" id="growthDataSourceSummary">來源整理中...</p>
@@ -1724,6 +1728,7 @@ async function loadGrowthOps(forceRefresh = false) {
document.getElementById('growthCandidateCount').textContent = (stats.candidate_count || 0).toLocaleString();
document.getElementById('growthMappedCount').textContent = (stats.mapped_count || 0).toLocaleString();
document.getElementById('growthNeedsMapping').textContent = (stats.needs_mapping_count || 0).toLocaleString();
document.getElementById('growthReviewCandidateCount').textContent = (stats.review_candidate_count || 0).toLocaleString();
renderOpsCommandDashboard(stats, scope);
renderGrowthActionHint(stats);
renderGrowthDataSourceSummary(stats);
@@ -1768,6 +1773,7 @@ function renderGrowthActionHint(stats) {
const candidateCount = Number(stats.candidate_count || 0);
const mappedCount = Number(stats.mapped_count || 0);
const needsMapping = Number(stats.needs_mapping_count || 0);
const reviewCandidateCount = Number(stats.review_candidate_count || 0);
if (!candidateCount) {
hint.textContent = '還不能產生今日清單,原因是缺少最新 PChome 業績。';
@@ -1779,6 +1785,11 @@ function renderGrowthActionHint(stats) {
return;
}
if (needsMapping > 0 && reviewCandidateCount > 0) {
hint.textContent = `先處理 ${mappedCount.toLocaleString()} 件可立即處理商品;另有 ${reviewCandidateCount.toLocaleString()} 筆候選等人工確認。`;
return;
}
if (needsMapping > 0) {
hint.textContent = `先處理 ${mappedCount.toLocaleString()} 件可立即處理商品,再補 ${needsMapping.toLocaleString()} 件比價資料。`;
return;

View File

@@ -361,6 +361,74 @@ def test_sync_targeted_momo_candidates_skips_total_price_identity_review(monkeyp
assert count == 0
def test_sync_targeted_momo_review_candidates_writes_needs_review_offer(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)
payload = 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,
"auto_compare_type": "manual_review",
"target_price_basis": "none",
"target_alert_tier": "identity_review",
"target_match_type": "exact",
"target_match_reasons": ["variant_selection_review", "strong_exact_spec_match"],
"target_comparison_mode": "exact_identity",
"target_gap_pct": -6.16,
},
{
"product_id": "LOW-SCORE",
"name": "低分候選",
"price": 100,
"target_pchome_product_id": "PCH-LOW",
"target_match_score": 0.4,
"target_alert_tier": "identity_review",
},
])
assert payload["success"] is True
assert payload["status"] == "synced"
assert payload["candidate_count"] == 2
assert payload["written_count"] == 1
assert payload["skipped_reasons"] == {"人工確認候選分數過低": 1}
with engine.connect() as conn:
row = conn.execute(text("""
SELECT source_product_id, price, pchome_product_id, match_status,
quality_score, data_quality_status, ingestion_method,
raw_payload_json
FROM external_offers
""")).mappings().one()
readiness = service.build_external_source_readiness(engine)
raw_payload = __import__("json").loads(row["raw_payload_json"])
assert row["source_product_id"] == "14917079"
assert row["price"] == 2618
assert row["pchome_product_id"] == "PCH-CDP"
assert row["match_status"] == "needs_review"
assert row["quality_score"] == 100
assert row["data_quality_status"] == "needs_review"
assert row["ingestion_method"] == "targeted_momo_review"
assert raw_payload["review_state"] == "needs_review"
assert raw_payload["price_basis"] == "none"
assert raw_payload["alert_tier"] == "identity_review"
assert "needs_review" in raw_payload["tags"]
assert readiness["review_offer_count"] == 1
assert stale_marks == [True]
def test_sync_targeted_momo_candidates_keeps_best_unit_quantity_match(monkeypatch):
from services import external_market_offer_service as service

View File

@@ -307,12 +307,21 @@ def test_pchome_growth_momo_backfill_service_targets_unmapped_high_sales_items()
"unit_price_count": 1,
}
def fake_sync_review(engine, candidates):
captured["review_sync_candidates"] = candidates
return {
"success": True,
"status": "synced",
"written_count": len(candidates),
}
payload = run_pchome_growth_momo_backfill(
FakeEngine(),
limit=2,
build_payload_func=fake_build_payload,
search_func=fake_search,
sync_func=fake_sync,
sync_review_func=fake_sync_review,
)
assert payload["success"] is True
@@ -321,10 +330,12 @@ def test_pchome_growth_momo_backfill_service_targets_unmapped_high_sales_items()
assert payload["data"]["auto_compare_count"] == 2
assert payload["data"]["review_count"] == 1
assert payload["data"]["external_offer_sync"]["written_count"] == 2
assert payload["data"]["review_candidate_sync"]["written_count"] == 1
assert payload["data"]["after_stats"]["mapping_rate"] == 66.7
assert [item["product_id"] for item in captured["targets"]] == ["PCH-NEEDS-1", "PCH-NEEDS-2"]
assert [item["price"] for item in captured["targets"]] == [920, 760]
assert [item["product_id"] for item in captured["sync_candidates"]] == ["MOMO-AUTO", "MOMO-UNIT"]
assert [item["product_id"] for item in captured["review_sync_candidates"]] == ["MOMO-REVIEW"]
assert captured["search_limit"] == 2