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

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