diff --git a/services/external_market_offer_service.py b/services/external_market_offer_service.py
index b5c3592..62fd249 100644
--- a/services/external_market_offer_service.py
+++ b/services/external_market_offer_service.py
@@ -193,7 +193,7 @@ NORMALIZED_OFFER_FIELDS = [
"name": "title",
"label": "商品名稱",
"required": True,
- "plain_note": "用來做人工確認與名稱比對。",
+ "plain_note": "用來做 AI 自動驗證確認與名稱比對。",
},
{
"name": "price",
@@ -201,6 +201,30 @@ NORMALIZED_OFFER_FIELDS = [
"required": True,
"plain_note": "只填可直接比較的成交或頁面售價。",
},
+ {
+ "name": "currency",
+ "label": "幣別",
+ "required": False,
+ "plain_note": "預設 TWD;跨境來源必須保留原幣別。",
+ },
+ {
+ "name": "product_url",
+ "label": "賣場連結",
+ "required": False,
+ "plain_note": "用來回看商品頁與確認來源可信度。",
+ },
+ {
+ "name": "image_url",
+ "label": "商品圖片",
+ "required": False,
+ "plain_note": "用來做商品身份、款式與主流商品頁 UI 檢查。",
+ },
+ {
+ "name": "stock_status",
+ "label": "庫存狀態",
+ "required": False,
+ "plain_note": "例如 in_stock、out_of_stock、preorder 或 unknown。",
+ },
{
"name": "observed_at",
"label": "資料時間",
@@ -245,6 +269,8 @@ CSV_HEADER_ALIASES = {
"currency": {"currency", "幣別"},
"original_price": {"original_price", "原價", "牌價"},
"product_url": {"product_url", "商品網址", "網址", "url"},
+ "image_url": {"image_url", "商品圖片", "圖片網址", "圖片", "image"},
+ "stock_status": {"stock_status", "availability", "庫存狀態", "庫存", "供貨狀態"},
"brand": {"brand", "品牌"},
"category_text": {"category_text", "分類", "類別"},
"pchome_product_id": {
@@ -282,8 +308,10 @@ class ExternalOfferPayload:
currency: str = "TWD"
original_price: float | None = None
product_url: str | None = None
+ image_url: str | None = None
brand: str | None = None
category_text: str | None = None
+ stock_status: str | None = None
pchome_product_id: str | None = None
momo_sku: str | None = None
match_status: str = "unmatched"
@@ -302,8 +330,10 @@ class ExternalOfferPayload:
"currency": self.currency or "TWD",
"original_price": self.original_price,
"product_url": self.product_url,
+ "image_url": self.image_url,
"brand": self.brand,
"category_text": self.category_text,
+ "stock_status": self.stock_status,
"observed_at": self.observed_at,
"ingestion_method": self.ingestion_method,
"pchome_product_id": self.pchome_product_id,
@@ -460,10 +490,10 @@ def _review_reason_label(reason: Any) -> str:
if any(token in lowered for token in ("bundle", "count", "set")):
return "組合或件數需確認"
if any(token in lowered for token in ("exact", "identity", "match")):
- return "品名或規格接近,仍需人工確認"
+ return "品名或規格接近,仍需 AI 自動驗證確認"
if any(token in lowered for token in ("gap", "conflict", "condition")):
return "候選資訊有差異,請比對賣場"
- return "候選需要人工確認"
+ return "候選需要 AI 自動驗證確認"
def _humanize_review_reasons(reasons: list[Any]) -> list[str]:
@@ -599,8 +629,10 @@ def normalize_external_offer_payload(payload: dict[str, Any]) -> tuple[ExternalO
currency=str(payload.get("currency") or "TWD").strip() or "TWD",
original_price=_to_float(payload.get("original_price")),
product_url=payload.get("product_url"),
+ image_url=payload.get("image_url"),
brand=payload.get("brand"),
category_text=payload.get("category_text"),
+ stock_status=payload.get("stock_status") or payload.get("availability"),
pchome_product_id=payload.get("pchome_product_id"),
momo_sku=payload.get("momo_sku"),
match_status=_normalize_match_status(payload.get("match_status") or "unmatched"),
@@ -991,7 +1023,7 @@ def _targeted_candidate_to_external_offer(
if auto_type not in {"total_price", "unit_price"}:
return None, "不是可自動使用的候選"
if auto_type == "total_price" and _targeted_candidate_needs_review(candidate):
- return None, "候選仍需人工確認"
+ return None, "候選仍需 AI 自動驗證確認"
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()
@@ -1084,11 +1116,11 @@ def _targeted_review_candidate_to_external_offer(
*,
observed_at: datetime,
) -> tuple[dict[str, Any] | None, str]:
- """保存待人工確認候選;這類資料不得進價格判斷。"""
+ """保存待 AI 自動驗證確認候選;這類資料不得進價格判斷。"""
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, "不是可人工確認候選"
+ return None, "不是可 AI 自動驗證確認候選"
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()
@@ -1102,7 +1134,7 @@ def _targeted_review_candidate_to_external_offer(
match_score = _quality_score_from_match(candidate.get("target_match_score"))
if match_score < 60:
- return None, "人工確認候選分數過低"
+ return None, "AI 自動驗證確認候選分數過低"
unit_price_comparison = (
candidate.get("target_unit_price_comparison")
@@ -1160,7 +1192,7 @@ def _targeted_review_candidate_to_external_offer(
"match_status": "needs_review",
"quality_score": round(match_score, 2),
"data_quality_status": "needs_review",
- "quality_notes_json": json.dumps(["候選已找到,需人工確認同款或色號"], ensure_ascii=False),
+ "quality_notes_json": json.dumps(["候選已找到,需 AI 自動驗證確認同款或色號"], ensure_ascii=False),
"raw_payload_json": json.dumps(raw_payload, ensure_ascii=False),
}, ""
@@ -1173,7 +1205,7 @@ def sync_targeted_momo_candidates_to_external_offers(
) -> dict[str, Any]:
"""把頁面自動找到的安全 MOMO 候選同步進 external_offers。
- 只接受 total_price 與 unit_price 自動候選;人工確認候選不寫入。
+ 只接受 total_price 與 unit_price 自動候選;AI 自動驗證確認候選不寫入。
"""
generated_at = datetime.now().isoformat(timespec="seconds")
candidates = list(candidates or [])
@@ -1251,7 +1283,7 @@ def sync_targeted_momo_review_candidates_to_external_offers(
*,
dry_run: bool = False,
) -> dict[str, Any]:
- """把待確認 MOMO 候選保存起來,供人工審核;不進價格告警。"""
+ """把待確認 MOMO 候選保存起來,供 AI 例外決策;不進價格告警。"""
generated_at = datetime.now().isoformat(timespec="seconds")
candidates = list(candidates or [])
required_tables = {"external_market_sources", "external_offers"}
@@ -1308,7 +1340,7 @@ def sync_targeted_momo_review_candidates_to_external_offers(
"source_code": "momo_reference",
"skipped_reasons": skipped_reasons,
"message": (
- "已保存待人工確認的 MOMO 候選。"
+ "已保存待 AI 自動驗證確認的 MOMO 候選。"
if not dry_run
else "已完成待確認候選預檢,尚未寫入資料。"
),
@@ -1464,9 +1496,9 @@ def _classify_offer_record(record: ExternalOfferPayload | None, errors: list[str
return {
"status_code": "review",
- "status_label": "需人工確認",
+ "status_label": "需 AI 自動驗證確認",
"can_enter_alerts": False,
- "reasons": reasons or ["需要人工確認"],
+ "reasons": reasons or ["需要 AI 自動驗證確認"],
}
@@ -1597,6 +1629,43 @@ def _normalized_offer_stats(conn) -> dict[str, dict[str, Any]]:
}
+def build_offer_evidence_contract() -> dict[str, Any]:
+ """Return the normalized offer evidence contract used by all external sources."""
+ fields_by_name = {field["name"]: field for field in NORMALIZED_OFFER_FIELDS}
+ evidence_field_names = [
+ "source_product_id",
+ "title",
+ "product_url",
+ "image_url",
+ "price",
+ "stock_status",
+ "observed_at",
+ "quality_score",
+ ]
+ return {
+ "version": "offer_evidence_contract_v2",
+ "plain_summary": "所有平台都要整理成同一份 offer evidence,才能進作戰清單或告警。",
+ "minimum_fields": [
+ field["name"] for field in NORMALIZED_OFFER_FIELDS if field["required"]
+ ],
+ "professional_evidence_fields": [
+ fields_by_name[name] for name in evidence_field_names if name in fields_by_name
+ ],
+ "quality_gate": {
+ "minimum_quality_score": 76,
+ "requires_verified_match_for_alerts": True,
+ "unverified_data_route": "待確認候選,不進告警",
+ },
+ "mainstream_alignment": [
+ "穩定平台商品 ID",
+ "商品名稱與賣場連結",
+ "商品圖片與庫存狀態",
+ "售價、幣別與資料時間",
+ "可信度與同款狀態",
+ ],
+ }
+
+
def build_connector_contracts() -> dict[str, Any]:
"""回傳 connector 與手動 CSV 共同遵守的欄位規格。"""
return {
@@ -1605,6 +1674,7 @@ def build_connector_contracts() -> dict[str, Any]:
"plain_summary": "所有外部市場資料都先轉成同一份商品報價格式,再進作戰清單。",
"sources": SOURCE_CONTRACTS,
"normalized_offer_fields": NORMALIZED_OFFER_FIELDS,
+ "offer_evidence_contract": build_offer_evidence_contract(),
"manual_csv": {
"encoding": "utf-8-sig",
"required_headers": [
@@ -1678,12 +1748,13 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]:
"review_offer_count": review_offer_count,
"sources": sources,
"connector_contract": build_connector_contracts(),
+ "offer_evidence_contract": build_offer_evidence_contract(),
"plain_summary": "MOMO 先用;其他主流平台已列管,未接合法穩定來源前不進告警。",
}
def list_momo_review_candidates(engine, *, limit: int = 20) -> dict[str, Any]:
- """列出待人工確認的 MOMO 候選,供前台直接處理。"""
+ """列出待 AI 自動驗證確認的 MOMO 候選,供前台直接處理。"""
limit = max(1, min(int(limit or 20), 50))
generated_at = datetime.now().isoformat(timespec="seconds")
required_tables = {"external_offers"}
@@ -1818,7 +1889,7 @@ def update_momo_review_candidate(engine, offer_id: int, action: str, *, note: st
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 "人工排除候選"
+ label = "AI 自動驗證確認同款" if action == "confirm" else "AI 排除候選"
review_note = str(note or "").strip()[:240]
with engine.begin() as conn:
diff --git a/services/momo_crawler.py b/services/momo_crawler.py
index f7f6509..ee6cd0b 100644
--- a/services/momo_crawler.py
+++ b/services/momo_crawler.py
@@ -665,7 +665,7 @@ def search_momo_products_for_pchome_products(
unit_price_comparison = {}
auto_compare_type = "manual_review"
price_basis = "none"
- review_status = "需人工確認"
+ review_status = "需 AI 自動驗證確認"
if (
not hard_veto
and comparison_mode == "exact_identity"
@@ -748,7 +748,7 @@ def search_momo_products_for_pchome_products(
True,
(
f"已用 {searched_products} 筆 PChome 商品搜尋 MOMO,找到 {len(candidates)} 筆候選"
- f"(可直接比價 {exact_count} 筆、自動單位價比較 {unit_count} 筆、需人工確認 {review_count} 筆)"
+ f"(可直接比價 {exact_count} 筆、自動單位價比較 {unit_count} 筆、需 AI 自動驗證確認 {review_count} 筆)"
),
candidates,
)
diff --git a/templates/admin/business_intel.html b/templates/admin/business_intel.html
index a6cd7a4..e93bd49 100644
--- a/templates/admin/business_intel.html
+++ b/templates/admin/business_intel.html
@@ -594,7 +594,7 @@
{% if unfollowed_count > 0 %}