清除外部報價與前台 P2 人工語意
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
ogt
2026-07-01 14:04:43 +08:00
parent e15d543aa2
commit 6f8d8e82e8
7 changed files with 96 additions and 25 deletions

View File

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

View File

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

View File

@@ -594,7 +594,7 @@
{% if unfollowed_count > 0 %}
<section class="biz-alert-strip">
<div><i class="fas fa-bell me-2"></i>{{ unfollowed_count }} 筆高信心 AI 價格建議尚未跟進,建議優先轉為行動計畫或標記原因。</div>
<span class="biz-badge warn">人工決策</span>
<span class="biz-badge warn"> AI 例外決策</span>
</section>
{% endif %}

View File

@@ -3452,7 +3452,7 @@
<div class="card-header py-2 bg-white border-bottom">
<span class="ai-panel-title">
<i class="fas fa-clipboard-check text-danger me-2"></i>最近處理紀錄
<small class="text-muted fw-normal ms-2">挑品、比價與人工覆核</small>
<small class="text-muted fw-normal ms-2">挑品、比價與 AI 例外決策</small>
</span>
</div>
<div class="card-body p-0 ai-recs-scroll">
@@ -4210,7 +4210,7 @@ function renderGrowthActionHint(stats) {
}
if (needsMapping > 0 && reviewCandidateCount > 0) {
hint.textContent = `先處理 ${mappedCount.toLocaleString()} 件可立即處理商品;另有 ${reviewCandidateCount.toLocaleString()} 筆候選等人工確認。`;
hint.textContent = `先處理 ${mappedCount.toLocaleString()} 件可立即處理商品;另有 ${reviewCandidateCount.toLocaleString()} 筆候選等待 AI 自動驗證確認。`;
return;
}

View File

@@ -482,7 +482,7 @@ function renderEA(ea, autoFix) {
<div class="ea-box ${ea.priority}">
<div style="display:flex;align-items:center;gap:10px">
<div class="ea-priority" style="color:${col}">${priorityLabel}</div>
<div>${autoFix ? '🔧 <b>自動修復已觸發</b>' : (ea.human_review_needed ? '👁 <b>需人工審查</b>' : '✅ <b>無需修復</b>')}</div>
<div>${autoFix ? '🔧 <b>自動修復已觸發</b>' : (ea.human_review_needed ? '👁 <b>需 AI 例外審查</b>' : '✅ <b>無需修復</b>')}</div>
</div>
<div class="ea-reasoning">${escHtml(ea.reasoning||'')}</div>
${fixFiles ? `<div class="ea-fix">修復範圍:${fixFiles}</div>` : ''}

View File

@@ -1258,7 +1258,7 @@ La Roche-Posay 安得利防曬液 50ml,920
} else if (momoUnitCompareCandidates.length) {
showToast(`已自動換算 ${momoUnitCompareCandidates.length} 筆單位價候選`, 'success');
} else if (momoReviewCandidates.length) {
showToast(`找到 ${momoReviewCandidates.length} 筆需人工確認候選,暫不進自動比價`, 'warning');
showToast(`找到 ${momoReviewCandidates.length} 筆需 AI 自動驗證確認候選,暫不進自動比價`, 'warning');
} else {
showToast(data.message || '目前沒有找到 MOMO 候選', 'warning');
}
@@ -1326,7 +1326,7 @@ La Roche-Posay 安得利防曬液 50ml,920
const labels = Array.isArray(item?.target_match_reason_labels)
? item.target_match_reason_labels.filter(Boolean)
: [];
return labels.length ? labels : ['需人工確認同款'];
return labels.length ? labels : ['需 AI 自動驗證確認同款'];
}
function renderMomoReviewPanel() {
@@ -1572,7 +1572,7 @@ La Roche-Posay 安得利防曬液 50ml,920
if (!momoCount && unitCount) {
setText('priceReadySummary', '已有單位價比較');
setNextAction('今天先看:自動單位價比較', `系統已自動換算 ${unitCount} 筆候選,不需要先人工確認。`, '查看單位價', 'focus-momo-unit');
setNextAction('今天先看:自動單位價比較', `系統已自動換算 ${unitCount} 筆候選,不需要先做 AI 自動驗證確認。`, '查看單位價', 'focus-momo-unit');
return;
}

View File

@@ -52,7 +52,7 @@
<div class="stockout-kpi">
<div class="stockout-kpi-label momo-mono">失敗</div>
<div class="stockout-kpi-value momo-mono is-danger">{{ stats.failed | number_format }}</div>
<div class="stockout-kpi-sub momo-mono">人工檢查</div>
<div class="stockout-kpi-sub momo-mono"> AI 例外檢查</div>
</div>
<div class="stockout-kpi">
<div class="stockout-kpi-label momo-mono">來源廠商</div>