This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>` : ''}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user