refactor(pchome): extract backlog reporter
This commit is contained in:
@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.783"
|
||||
SYSTEM_VERSION = "V10.784"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **最後更新**: 2026-07-11 (台北時間)
|
||||
> **狀態**: 🟠 Partial。四 Agent、PixelRAG、MCP/RAG registry、PChome controlled-preview families 與多項 smoke/readback 已建立;但 access control、database identity/RBAC、full asset reconciliation、software supply chain、same-run controlled apply、restore drill、internal RAG canary 與 MCP/RAG runtime closure 尚未全部完成。任何局部 receipt 不得代表整體閉環完成。
|
||||
> **適用版本**: V10.783
|
||||
> **適用版本**: V10.784
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
|
||||
| 行數 | 檔案 | 分類 | 拆分方向 |
|
||||
|---:|---|---|---|
|
||||
| 43543 | `services/pchome_mapping_backlog_service.py` | P0 gate 巨型 service、拆分進行中 | policy、AI exception contract、evidence parser、artifact guard、bounded executor、drift verifier 與 receipt replay 已移到 `services/pchome_mapping_backlog/`;下一步拆 reporter |
|
||||
| 43183 | `services/pchome_mapping_backlog_service.py` | P0 gate 巨型 service、拆分進行中 | policy、AI exception contract、evidence parser、artifact guard、bounded executor、drift verifier、receipt replay 與 reporter 已移到 `services/pchome_mapping_backlog/`;下一步拆 evidence enrichment orchestration |
|
||||
| 14289 | `services/ai_automation_smoke_service.py` | P0 smoke 巨型 service | 拆 family registry、collectors、health projection、metrics adapter |
|
||||
| 9383 | `routes/openclaw_bot_routes.py` | P0 巨型 Blueprint、拆分進行中 | scheduler hook 已移到 `services/openclaw_bot/scheduled_jobs.py`;下一步拆 report、command |
|
||||
| 7641 | `routes/ai_routes.py` | P0 巨型 Blueprint | PChome growth、AI automation、recommendation route extension 分離 |
|
||||
|
||||
451
services/pchome_mapping_backlog/reporter.py
Normal file
451
services/pchome_mapping_backlog/reporter.py
Normal file
@@ -0,0 +1,451 @@
|
||||
"""Read-only PChome backlog reporting and operator preview projections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from services.pchome_mapping_backlog.contracts import _ai_exception_compatibility_fields
|
||||
from services.pchome_mapping_backlog.evidence import (
|
||||
_action_code,
|
||||
_action_label,
|
||||
_first_present,
|
||||
_pchome_product_url,
|
||||
_to_float,
|
||||
parse_unit_package_basis,
|
||||
)
|
||||
from services.pchome_mapping_backlog.policies import (
|
||||
BACKLOG_POLICY,
|
||||
EXTERNAL_BENCHMARK_REFERENCES,
|
||||
OPERATOR_PREVIEW_POLICY,
|
||||
)
|
||||
|
||||
|
||||
def _evidence_completeness(
|
||||
item: dict[str, Any],
|
||||
review_candidate: dict[str, Any],
|
||||
external_price: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
product_id = str(item.get("pchome_product_id") or "").strip()
|
||||
product_name = str(item.get("product_name") or "").strip()
|
||||
product_url = _first_present(
|
||||
item.get("product_url"),
|
||||
item.get("pchome_url"),
|
||||
_pchome_product_url(product_id),
|
||||
)
|
||||
pchome_price = _first_present(
|
||||
item.get("pchome_price"),
|
||||
external_price.get("pchome_price"),
|
||||
review_candidate.get("pchome_price"),
|
||||
)
|
||||
image_url = _first_present(
|
||||
item.get("image_url"),
|
||||
item.get("image"),
|
||||
item.get("product_image_url"),
|
||||
)
|
||||
availability = _first_present(
|
||||
item.get("availability"),
|
||||
item.get("stock_status"),
|
||||
item.get("is_available"),
|
||||
)
|
||||
unit_package_basis = parse_unit_package_basis(product_name)
|
||||
parsed_unit_basis = (
|
||||
unit_package_basis
|
||||
if unit_package_basis.get("package_basis") != "insufficient"
|
||||
else None
|
||||
)
|
||||
unit_basis = _first_present(
|
||||
external_price.get("price_basis"),
|
||||
item.get("price_basis"),
|
||||
item.get("unit_label"),
|
||||
parsed_unit_basis,
|
||||
)
|
||||
unit_review_required = bool(unit_package_basis.get("risk_signals"))
|
||||
|
||||
checks = [
|
||||
("stable_product_id", bool(product_id), "required"),
|
||||
("product_name", bool(product_name), "required"),
|
||||
("product_url", bool(product_url), "required"),
|
||||
("price", pchome_price is not None, "required"),
|
||||
("image", bool(image_url), "strongly_recommended"),
|
||||
("availability", availability is not None, "strongly_recommended"),
|
||||
(
|
||||
"unit_price_or_package_basis",
|
||||
bool(unit_basis),
|
||||
"required_when_bundle_or_unit_sensitive",
|
||||
),
|
||||
]
|
||||
present = [field for field, ok, _requirement in checks if ok]
|
||||
missing = [field for field, ok, _requirement in checks if not ok]
|
||||
blocking_missing = [
|
||||
field
|
||||
for field, ok, requirement in checks
|
||||
if not ok and requirement in {"required", "strongly_recommended"}
|
||||
]
|
||||
score = round(len(present) / max(len(checks), 1) * 100, 1)
|
||||
|
||||
ai_exception_required = (
|
||||
bool(blocking_missing)
|
||||
or bool(review_candidate)
|
||||
or not external_price
|
||||
or unit_review_required
|
||||
)
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"present_fields": present,
|
||||
"missing_fields": missing,
|
||||
"blocking_missing_fields": blocking_missing,
|
||||
"auto_accept_ready": (
|
||||
not blocking_missing and bool(external_price) and not unit_review_required
|
||||
),
|
||||
**_ai_exception_compatibility_fields(ai_exception_required),
|
||||
"product_url": product_url,
|
||||
"image_url": image_url,
|
||||
"availability": availability,
|
||||
"unit_package_basis": unit_package_basis,
|
||||
}
|
||||
|
||||
|
||||
def compact_mapping_item(item: dict[str, Any]) -> dict[str, Any]:
|
||||
review_candidate = item.get("review_candidate") or {}
|
||||
external_price = item.get("external_price") or {}
|
||||
product_id = str(item.get("pchome_product_id") or "")
|
||||
product_url = _first_present(
|
||||
item.get("product_url"),
|
||||
item.get("pchome_url"),
|
||||
_pchome_product_url(product_id),
|
||||
)
|
||||
return {
|
||||
"pchome_product_id": product_id,
|
||||
"product_url": product_url,
|
||||
"product_name": item.get("product_name") or "",
|
||||
"sales_7d": round(_to_float(item.get("sales_7d")), 2),
|
||||
"sales_delta_pct": item.get("sales_delta_pct"),
|
||||
"priority_score": item.get("priority_score"),
|
||||
"pchome_price": item.get("pchome_price"),
|
||||
"action_code": _action_code(item),
|
||||
"action_label": _action_label(item),
|
||||
"review_candidate": {
|
||||
"id": review_candidate.get("id"),
|
||||
"momo_sku": review_candidate.get("momo_sku"),
|
||||
"momo_name": review_candidate.get("momo_name"),
|
||||
"quality_score": review_candidate.get("quality_score"),
|
||||
"gap_pct": review_candidate.get("gap_pct"),
|
||||
}
|
||||
if review_candidate
|
||||
else None,
|
||||
"external_price": {
|
||||
"momo_sku": external_price.get("momo_sku"),
|
||||
"momo_name": external_price.get("momo_name"),
|
||||
"price_basis": external_price.get("price_basis"),
|
||||
"gap_pct": external_price.get("gap_pct"),
|
||||
"data_source_label": external_price.get("data_source_label"),
|
||||
"updated_at": external_price.get("updated_at"),
|
||||
}
|
||||
if external_price
|
||||
else None,
|
||||
"evidence_completeness": _evidence_completeness(
|
||||
item,
|
||||
review_candidate,
|
||||
external_price,
|
||||
),
|
||||
"reason_lines": list(item.get("reason_lines") or [])[:3],
|
||||
}
|
||||
|
||||
|
||||
def _build_external_benchmark_alignment() -> dict[str, Any]:
|
||||
return {
|
||||
"references": EXTERNAL_BENCHMARK_REFERENCES,
|
||||
"required_evidence_fields": [
|
||||
{
|
||||
"field": "stable_product_id",
|
||||
"current_payload": "pchome_product_id",
|
||||
"status": "present",
|
||||
"why": "Stable IDs preserve mapping history and make post-run readback comparable.",
|
||||
},
|
||||
{
|
||||
"field": "product_name",
|
||||
"current_payload": "product_name",
|
||||
"status": "present",
|
||||
"why": "Exact title/name matching is the first identity anchor for operator review.",
|
||||
},
|
||||
{
|
||||
"field": "product_url",
|
||||
"current_payload": "derived_from_pchome_product_id",
|
||||
"status": "present_for_pchome",
|
||||
"why": "Operators need a direct product page path for visual confirmation.",
|
||||
},
|
||||
{
|
||||
"field": "price",
|
||||
"current_payload": "pchome_price/external_price",
|
||||
"status": "partial",
|
||||
"why": "Offer price and currency are required before a candidate can become decision-ready.",
|
||||
},
|
||||
{
|
||||
"field": "image",
|
||||
"current_payload": None,
|
||||
"status": "missing_in_current_growth_payload",
|
||||
"why": "Image evidence should be added before high-volume auto-accept expansion.",
|
||||
},
|
||||
{
|
||||
"field": "availability",
|
||||
"current_payload": None,
|
||||
"status": "missing_in_current_growth_payload",
|
||||
"why": "Availability prevents matching stale or non-purchasable offers.",
|
||||
},
|
||||
{
|
||||
"field": "unit_price_or_package_basis",
|
||||
"current_payload": (
|
||||
"external_price.price_basis or deterministic title parser preview"
|
||||
),
|
||||
"status": "parser_preview_available",
|
||||
"why": "Unit price and package basis protect bundles, variants, and volume-size comparisons.",
|
||||
},
|
||||
],
|
||||
"operator_review_principles": [
|
||||
"Separate direct mapping, review candidate, and already comparable items.",
|
||||
"Do not auto-accept variants, colors, bundles, or catalog offers without explicit evidence.",
|
||||
"Keep search/query support exact-title friendly so copied product names and model terms remain useful.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_ai_automation_plan(
|
||||
selected_direct: list[dict[str, Any]],
|
||||
selected_review: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"policy": "ollama_first_read_only_ai_assist",
|
||||
"llm_calls_in_preview": False,
|
||||
"gemini_allowed": False,
|
||||
"provider_order": [
|
||||
"GCP-A 34.87.90.216:11434",
|
||||
"GCP-B 34.21.145.224:11434",
|
||||
"111 192.168.0.111:11434",
|
||||
],
|
||||
"automation_readiness": {
|
||||
"direct_mapping_targets": len(selected_direct),
|
||||
"review_candidate_targets": len(selected_review),
|
||||
"can_generate_operator_summary": bool(selected_direct or selected_review),
|
||||
"can_execute_write": False,
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"name": "identity_anchor_extraction",
|
||||
"mode": "deterministic_first_ollama_assist_later",
|
||||
"writes_database": False,
|
||||
"output": "brand/product_line/spec/package/variant anchors for each selected target",
|
||||
},
|
||||
{
|
||||
"name": "candidate_search_plan",
|
||||
"mode": "rule_based_query_pack",
|
||||
"writes_database": False,
|
||||
"output": "exact title, brand plus product line, and spec-preserving search terms",
|
||||
},
|
||||
{
|
||||
"name": "operator_decision_summary",
|
||||
"mode": "ollama_first_after_write_gate_only",
|
||||
"writes_database": False,
|
||||
"output": "plain-language review reason, evidence gaps, and post-write readback checklist",
|
||||
},
|
||||
{
|
||||
"name": "post_write_readback",
|
||||
"mode": "deterministic_metrics",
|
||||
"writes_database": False,
|
||||
"output": "mapping_rate, direct_mapping_count, review_candidate_count, mapped_count delta",
|
||||
},
|
||||
],
|
||||
"ai_exception_required_for": [
|
||||
"missing image or availability evidence",
|
||||
"variant/color/fragrance/shade/package ambiguity",
|
||||
"unit-price or bundle-sensitive comparisons",
|
||||
"any candidate not meeting exact identity evidence",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def summarize_pchome_mapping_backlog(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
stats = payload.get("stats") or {}
|
||||
opportunities = [
|
||||
item
|
||||
for item in payload.get("opportunities") or []
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
needs_mapping = [item for item in opportunities if not item.get("external_price")]
|
||||
review_candidates = [
|
||||
item for item in needs_mapping if item.get("review_candidate")
|
||||
]
|
||||
direct_mapping = [
|
||||
item
|
||||
for item in needs_mapping
|
||||
if _action_code(item) == "map_external_product"
|
||||
and not item.get("review_candidate")
|
||||
]
|
||||
mapped = [item for item in opportunities if item.get("external_price")]
|
||||
|
||||
action_counts: dict[str, int] = {}
|
||||
sales_by_action: dict[str, float] = {}
|
||||
for item in opportunities:
|
||||
label = _action_label(item)
|
||||
action_counts[label] = action_counts.get(label, 0) + 1
|
||||
sales_by_action[label] = round(
|
||||
sales_by_action.get(label, 0.0) + _to_float(item.get("sales_7d")),
|
||||
2,
|
||||
)
|
||||
|
||||
candidate_count = int(stats.get("candidate_count") or len(opportunities))
|
||||
mapped_count = int(stats.get("mapped_count") or len(mapped))
|
||||
needs_mapping_count = int(stats.get("needs_mapping_count") or len(needs_mapping))
|
||||
mapping_rate = stats.get("mapping_rate")
|
||||
if mapping_rate is None:
|
||||
mapping_rate = round(mapped_count / max(candidate_count, 1) * 100, 1)
|
||||
|
||||
top_needs_mapping = sorted(
|
||||
needs_mapping,
|
||||
key=lambda item: (
|
||||
_to_float(item.get("sales_7d")),
|
||||
_to_float(item.get("priority_score")),
|
||||
),
|
||||
reverse=True,
|
||||
)[:10]
|
||||
top_review_candidates = sorted(
|
||||
review_candidates,
|
||||
key=lambda item: _to_float(
|
||||
(item.get("review_candidate") or {}).get("quality_score")
|
||||
),
|
||||
reverse=True,
|
||||
)[:10]
|
||||
|
||||
if not payload.get("success", False):
|
||||
result = "BLOCKED"
|
||||
elif needs_mapping_count > 0:
|
||||
result = "NEEDS_MAPPING"
|
||||
else:
|
||||
result = "PASS"
|
||||
|
||||
return {
|
||||
"policy": BACKLOG_POLICY,
|
||||
"result": result,
|
||||
"success": bool(payload.get("success")),
|
||||
"generated_at": payload.get("generated_at"),
|
||||
"cache_state": payload.get("cache_state"),
|
||||
"system_name": payload.get("system_name"),
|
||||
"message": payload.get("message"),
|
||||
"stats": {
|
||||
"candidate_count": candidate_count,
|
||||
"mapped_count": mapped_count,
|
||||
"mapping_rate": mapping_rate,
|
||||
"needs_mapping_count": needs_mapping_count,
|
||||
"review_candidate_count": int(
|
||||
stats.get("review_candidate_count") or len(review_candidates)
|
||||
),
|
||||
"latest_sales_date": stats.get("latest_sales_date"),
|
||||
"overall_latest_sales_date": stats.get("overall_latest_sales_date"),
|
||||
"overall_sales_7d": stats.get("overall_sales_7d"),
|
||||
"opportunity_sales_7d": stats.get("opportunity_sales_7d"),
|
||||
"action_counts": dict(stats.get("action_counts") or action_counts),
|
||||
"action_code_counts": dict(stats.get("action_code_counts") or {}),
|
||||
"external_data_source_counts": dict(
|
||||
stats.get("external_data_source_counts") or {}
|
||||
),
|
||||
},
|
||||
"backlog": {
|
||||
"direct_mapping_count": len(direct_mapping),
|
||||
"review_candidate_count": len(review_candidates),
|
||||
"mapped_opportunity_count": len(mapped),
|
||||
"sales_by_action": sales_by_action,
|
||||
"top_needs_mapping": [
|
||||
compact_mapping_item(item) for item in top_needs_mapping
|
||||
],
|
||||
"top_review_candidates": [
|
||||
compact_mapping_item(item) for item in top_review_candidates
|
||||
],
|
||||
},
|
||||
"next_actions": [
|
||||
"Run the production version truth guard before changing or deploying.",
|
||||
"Handle direct mapping items first; they have no verified external price yet.",
|
||||
"Review candidate items next; they already have MOMO candidates but need same-item confirmation.",
|
||||
"Keep this report read-only until an explicit DB-write operator run is approved.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_pchome_mapping_operator_preview(
|
||||
payload: dict[str, Any],
|
||||
batch_size: int = 5,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a read-only operator run package for the direct mapping backlog."""
|
||||
summary = summarize_pchome_mapping_backlog(payload)
|
||||
backlog = summary.get("backlog") or {}
|
||||
direct_items = [
|
||||
item
|
||||
for item in backlog.get("top_needs_mapping") or []
|
||||
if item.get("action_code") == "map_external_product"
|
||||
]
|
||||
review_items = list(backlog.get("top_review_candidates") or [])
|
||||
batch_size = max(1, min(int(batch_size or 5), 8))
|
||||
selected_direct = direct_items[:batch_size]
|
||||
selected_review = review_items[:batch_size]
|
||||
|
||||
if selected_direct:
|
||||
result = "READY_FOR_OPERATOR_PREVIEW"
|
||||
elif selected_review:
|
||||
result = "REVIEW_CANDIDATES_ONLY"
|
||||
else:
|
||||
result = "NO_DIRECT_MAPPING_TARGETS"
|
||||
|
||||
return {
|
||||
"policy": OPERATOR_PREVIEW_POLICY,
|
||||
"result": result,
|
||||
"success": bool(summary.get("success")),
|
||||
"generated_at": summary.get("generated_at"),
|
||||
"stats": summary.get("stats") or {},
|
||||
"backlog": {
|
||||
"direct_mapping_count": int(backlog.get("direct_mapping_count") or 0),
|
||||
"review_candidate_count": int(
|
||||
backlog.get("review_candidate_count") or 0
|
||||
),
|
||||
"mapped_opportunity_count": int(
|
||||
backlog.get("mapped_opportunity_count") or 0
|
||||
),
|
||||
},
|
||||
"operator_batch": {
|
||||
"batch_size": batch_size,
|
||||
"selected_direct_mapping_count": len(selected_direct),
|
||||
"selected_review_candidate_count": len(selected_review),
|
||||
"direct_mapping_targets": selected_direct,
|
||||
"review_candidate_targets": selected_review,
|
||||
},
|
||||
"command_preview": {
|
||||
"method": "POST",
|
||||
"endpoint": "/api/ai/pchome-growth/backfill-momo-candidates",
|
||||
"payload": {"limit": min(batch_size, 8)},
|
||||
"executes_search": True,
|
||||
"writes_database": True,
|
||||
"write_gate_required": True,
|
||||
},
|
||||
"external_benchmark_alignment": _build_external_benchmark_alignment(),
|
||||
"ai_automation_plan": _build_ai_automation_plan(
|
||||
selected_direct,
|
||||
selected_review,
|
||||
),
|
||||
"safety": {
|
||||
"read_only_preview": True,
|
||||
"executes_search": False,
|
||||
"writes_database": False,
|
||||
"dispatches_telegram": False,
|
||||
"requires_production_version_truth": True,
|
||||
"requires_operator_write_approval": True,
|
||||
},
|
||||
"required_before_execute": [
|
||||
"Run production version truth guard and keep production /health as latest truth.",
|
||||
"Confirm the selected direct mapping targets are the intended PChome products.",
|
||||
"Confirm DB-write authorization for /api/ai/pchome-growth/backfill-momo-candidates.",
|
||||
"Run post-write mapping backlog readback and compare direct_mapping_count / mapped_count.",
|
||||
],
|
||||
"acceptance_criteria": [
|
||||
"direct_mapping_count decreases, or review_candidate_count increases with named MOMO candidates.",
|
||||
"mapped_count or mapping_rate increases only when a verified external price is written.",
|
||||
"No Gemini, Telegram dispatch, scheduler mutation, or unrelated DB write is part of this run.",
|
||||
],
|
||||
}
|
||||
@@ -162,220 +162,14 @@ from services.pchome_mapping_backlog.evidence import (
|
||||
parse_pchome_product_page_evidence_html,
|
||||
parse_unit_package_basis,
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _evidence_completeness(item: dict[str, Any], review_candidate: dict[str, Any], external_price: dict[str, Any]) -> dict[str, Any]:
|
||||
product_id = str(item.get("pchome_product_id") or "").strip()
|
||||
product_name = str(item.get("product_name") or "").strip()
|
||||
product_url = _first_present(item.get("product_url"), item.get("pchome_url"), _pchome_product_url(product_id))
|
||||
pchome_price = _first_present(
|
||||
item.get("pchome_price"),
|
||||
external_price.get("pchome_price"),
|
||||
review_candidate.get("pchome_price"),
|
||||
)
|
||||
image_url = _first_present(item.get("image_url"), item.get("image"), item.get("product_image_url"))
|
||||
availability = _first_present(item.get("availability"), item.get("stock_status"), item.get("is_available"))
|
||||
unit_package_basis = parse_unit_package_basis(product_name)
|
||||
parsed_unit_basis = (
|
||||
unit_package_basis
|
||||
if unit_package_basis.get("package_basis") != "insufficient"
|
||||
else None
|
||||
)
|
||||
unit_basis = _first_present(
|
||||
external_price.get("price_basis"),
|
||||
item.get("price_basis"),
|
||||
item.get("unit_label"),
|
||||
parsed_unit_basis,
|
||||
)
|
||||
unit_review_required = bool(unit_package_basis.get("risk_signals"))
|
||||
|
||||
checks = [
|
||||
("stable_product_id", bool(product_id), "required"),
|
||||
("product_name", bool(product_name), "required"),
|
||||
("product_url", bool(product_url), "required"),
|
||||
("price", pchome_price is not None, "required"),
|
||||
("image", bool(image_url), "strongly_recommended"),
|
||||
("availability", availability is not None, "strongly_recommended"),
|
||||
(
|
||||
"unit_price_or_package_basis",
|
||||
bool(unit_basis),
|
||||
"required_when_bundle_or_unit_sensitive",
|
||||
),
|
||||
]
|
||||
present = [field for field, ok, _requirement in checks if ok]
|
||||
missing = [field for field, ok, _requirement in checks if not ok]
|
||||
blocking_missing = [
|
||||
field
|
||||
for field, ok, requirement in checks
|
||||
if not ok and requirement in {"required", "strongly_recommended"}
|
||||
]
|
||||
score = round(len(present) / max(len(checks), 1) * 100, 1)
|
||||
|
||||
ai_exception_required = (
|
||||
bool(blocking_missing)
|
||||
or bool(review_candidate)
|
||||
or not external_price
|
||||
or unit_review_required
|
||||
)
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"present_fields": present,
|
||||
"missing_fields": missing,
|
||||
"blocking_missing_fields": blocking_missing,
|
||||
"auto_accept_ready": not blocking_missing and bool(external_price) and not unit_review_required,
|
||||
**_ai_exception_compatibility_fields(ai_exception_required),
|
||||
"product_url": product_url,
|
||||
"image_url": image_url,
|
||||
"availability": availability,
|
||||
"unit_package_basis": unit_package_basis,
|
||||
}
|
||||
|
||||
|
||||
def compact_mapping_item(item: dict[str, Any]) -> dict[str, Any]:
|
||||
review_candidate = item.get("review_candidate") or {}
|
||||
external_price = item.get("external_price") or {}
|
||||
product_id = str(item.get("pchome_product_id") or "")
|
||||
product_url = _first_present(item.get("product_url"), item.get("pchome_url"), _pchome_product_url(product_id))
|
||||
return {
|
||||
"pchome_product_id": product_id,
|
||||
"product_url": product_url,
|
||||
"product_name": item.get("product_name") or "",
|
||||
"sales_7d": round(_to_float(item.get("sales_7d")), 2),
|
||||
"sales_delta_pct": item.get("sales_delta_pct"),
|
||||
"priority_score": item.get("priority_score"),
|
||||
"pchome_price": item.get("pchome_price"),
|
||||
"action_code": _action_code(item),
|
||||
"action_label": _action_label(item),
|
||||
"review_candidate": {
|
||||
"id": review_candidate.get("id"),
|
||||
"momo_sku": review_candidate.get("momo_sku"),
|
||||
"momo_name": review_candidate.get("momo_name"),
|
||||
"quality_score": review_candidate.get("quality_score"),
|
||||
"gap_pct": review_candidate.get("gap_pct"),
|
||||
}
|
||||
if review_candidate
|
||||
else None,
|
||||
"external_price": {
|
||||
"momo_sku": external_price.get("momo_sku"),
|
||||
"momo_name": external_price.get("momo_name"),
|
||||
"price_basis": external_price.get("price_basis"),
|
||||
"gap_pct": external_price.get("gap_pct"),
|
||||
"data_source_label": external_price.get("data_source_label"),
|
||||
"updated_at": external_price.get("updated_at"),
|
||||
}
|
||||
if external_price
|
||||
else None,
|
||||
"evidence_completeness": _evidence_completeness(item, review_candidate, external_price),
|
||||
"reason_lines": list(item.get("reason_lines") or [])[:3],
|
||||
}
|
||||
|
||||
|
||||
def _build_external_benchmark_alignment() -> dict[str, Any]:
|
||||
return {
|
||||
"references": EXTERNAL_BENCHMARK_REFERENCES,
|
||||
"required_evidence_fields": [
|
||||
{
|
||||
"field": "stable_product_id",
|
||||
"current_payload": "pchome_product_id",
|
||||
"status": "present",
|
||||
"why": "Stable IDs preserve mapping history and make post-run readback comparable.",
|
||||
},
|
||||
{
|
||||
"field": "product_name",
|
||||
"current_payload": "product_name",
|
||||
"status": "present",
|
||||
"why": "Exact title/name matching is the first identity anchor for operator review.",
|
||||
},
|
||||
{
|
||||
"field": "product_url",
|
||||
"current_payload": "derived_from_pchome_product_id",
|
||||
"status": "present_for_pchome",
|
||||
"why": "Operators need a direct product page path for visual confirmation.",
|
||||
},
|
||||
{
|
||||
"field": "price",
|
||||
"current_payload": "pchome_price/external_price",
|
||||
"status": "partial",
|
||||
"why": "Offer price and currency are required before a candidate can become decision-ready.",
|
||||
},
|
||||
{
|
||||
"field": "image",
|
||||
"current_payload": None,
|
||||
"status": "missing_in_current_growth_payload",
|
||||
"why": "Image evidence should be added before high-volume auto-accept expansion.",
|
||||
},
|
||||
{
|
||||
"field": "availability",
|
||||
"current_payload": None,
|
||||
"status": "missing_in_current_growth_payload",
|
||||
"why": "Availability prevents matching stale or non-purchasable offers.",
|
||||
},
|
||||
{
|
||||
"field": "unit_price_or_package_basis",
|
||||
"current_payload": "external_price.price_basis or deterministic title parser preview",
|
||||
"status": "parser_preview_available",
|
||||
"why": "Unit price and package basis protect bundles, variants, and volume-size comparisons.",
|
||||
},
|
||||
],
|
||||
"operator_review_principles": [
|
||||
"Separate direct mapping, review candidate, and already comparable items.",
|
||||
"Do not auto-accept variants, colors, bundles, or catalog offers without explicit evidence.",
|
||||
"Keep search/query support exact-title friendly so copied product names and model terms remain useful.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_ai_automation_plan(selected_direct: list[dict[str, Any]], selected_review: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
return {
|
||||
"policy": "ollama_first_read_only_ai_assist",
|
||||
"llm_calls_in_preview": False,
|
||||
"gemini_allowed": False,
|
||||
"provider_order": [
|
||||
"GCP-A 34.87.90.216:11434",
|
||||
"GCP-B 34.21.145.224:11434",
|
||||
"111 192.168.0.111:11434",
|
||||
],
|
||||
"automation_readiness": {
|
||||
"direct_mapping_targets": len(selected_direct),
|
||||
"review_candidate_targets": len(selected_review),
|
||||
"can_generate_operator_summary": bool(selected_direct or selected_review),
|
||||
"can_execute_write": False,
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"name": "identity_anchor_extraction",
|
||||
"mode": "deterministic_first_ollama_assist_later",
|
||||
"writes_database": False,
|
||||
"output": "brand/product_line/spec/package/variant anchors for each selected target",
|
||||
},
|
||||
{
|
||||
"name": "candidate_search_plan",
|
||||
"mode": "rule_based_query_pack",
|
||||
"writes_database": False,
|
||||
"output": "exact title, brand plus product line, and spec-preserving search terms",
|
||||
},
|
||||
{
|
||||
"name": "operator_decision_summary",
|
||||
"mode": "ollama_first_after_write_gate_only",
|
||||
"writes_database": False,
|
||||
"output": "plain-language review reason, evidence gaps, and post-write readback checklist",
|
||||
},
|
||||
{
|
||||
"name": "post_write_readback",
|
||||
"mode": "deterministic_metrics",
|
||||
"writes_database": False,
|
||||
"output": "mapping_rate, direct_mapping_count, review_candidate_count, mapped_count delta",
|
||||
},
|
||||
],
|
||||
"ai_exception_required_for": [
|
||||
"missing image or availability evidence",
|
||||
"variant/color/fragrance/shade/package ambiguity",
|
||||
"unit-price or bundle-sensitive comparisons",
|
||||
"any candidate not meeting exact identity evidence",
|
||||
],
|
||||
}
|
||||
from services.pchome_mapping_backlog.reporter import (
|
||||
_build_ai_automation_plan,
|
||||
_build_external_benchmark_alignment,
|
||||
_evidence_completeness,
|
||||
build_pchome_mapping_operator_preview,
|
||||
compact_mapping_item,
|
||||
summarize_pchome_mapping_backlog,
|
||||
)
|
||||
|
||||
|
||||
def _field_enrichment_sources(field: str) -> list[dict[str, Any]]:
|
||||
@@ -537,160 +331,6 @@ def _build_evidence_task(target: dict[str, Any], lane: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def summarize_pchome_mapping_backlog(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
stats = payload.get("stats") or {}
|
||||
opportunities = [item for item in payload.get("opportunities") or [] if isinstance(item, dict)]
|
||||
needs_mapping = [item for item in opportunities if not item.get("external_price")]
|
||||
review_candidates = [item for item in needs_mapping if item.get("review_candidate")]
|
||||
direct_mapping = [
|
||||
item
|
||||
for item in needs_mapping
|
||||
if _action_code(item) == "map_external_product" and not item.get("review_candidate")
|
||||
]
|
||||
mapped = [item for item in opportunities if item.get("external_price")]
|
||||
|
||||
action_counts: dict[str, int] = {}
|
||||
sales_by_action: dict[str, float] = {}
|
||||
for item in opportunities:
|
||||
label = _action_label(item)
|
||||
action_counts[label] = action_counts.get(label, 0) + 1
|
||||
sales_by_action[label] = round(sales_by_action.get(label, 0.0) + _to_float(item.get("sales_7d")), 2)
|
||||
|
||||
candidate_count = int(stats.get("candidate_count") or len(opportunities))
|
||||
mapped_count = int(stats.get("mapped_count") or len(mapped))
|
||||
needs_mapping_count = int(stats.get("needs_mapping_count") or len(needs_mapping))
|
||||
mapping_rate = stats.get("mapping_rate")
|
||||
if mapping_rate is None:
|
||||
mapping_rate = round(mapped_count / max(candidate_count, 1) * 100, 1)
|
||||
|
||||
top_needs_mapping = sorted(
|
||||
needs_mapping,
|
||||
key=lambda item: (_to_float(item.get("sales_7d")), _to_float(item.get("priority_score"))),
|
||||
reverse=True,
|
||||
)[:10]
|
||||
top_review_candidates = sorted(
|
||||
review_candidates,
|
||||
key=lambda item: _to_float((item.get("review_candidate") or {}).get("quality_score")),
|
||||
reverse=True,
|
||||
)[:10]
|
||||
|
||||
if not payload.get("success", False):
|
||||
result = "BLOCKED"
|
||||
elif needs_mapping_count > 0:
|
||||
result = "NEEDS_MAPPING"
|
||||
else:
|
||||
result = "PASS"
|
||||
|
||||
return {
|
||||
"policy": BACKLOG_POLICY,
|
||||
"result": result,
|
||||
"success": bool(payload.get("success")),
|
||||
"generated_at": payload.get("generated_at"),
|
||||
"cache_state": payload.get("cache_state"),
|
||||
"system_name": payload.get("system_name"),
|
||||
"message": payload.get("message"),
|
||||
"stats": {
|
||||
"candidate_count": candidate_count,
|
||||
"mapped_count": mapped_count,
|
||||
"mapping_rate": mapping_rate,
|
||||
"needs_mapping_count": needs_mapping_count,
|
||||
"review_candidate_count": int(stats.get("review_candidate_count") or len(review_candidates)),
|
||||
"latest_sales_date": stats.get("latest_sales_date"),
|
||||
"overall_latest_sales_date": stats.get("overall_latest_sales_date"),
|
||||
"overall_sales_7d": stats.get("overall_sales_7d"),
|
||||
"opportunity_sales_7d": stats.get("opportunity_sales_7d"),
|
||||
"action_counts": dict(stats.get("action_counts") or action_counts),
|
||||
"action_code_counts": dict(stats.get("action_code_counts") or {}),
|
||||
"external_data_source_counts": dict(stats.get("external_data_source_counts") or {}),
|
||||
},
|
||||
"backlog": {
|
||||
"direct_mapping_count": len(direct_mapping),
|
||||
"review_candidate_count": len(review_candidates),
|
||||
"mapped_opportunity_count": len(mapped),
|
||||
"sales_by_action": sales_by_action,
|
||||
"top_needs_mapping": [compact_mapping_item(item) for item in top_needs_mapping],
|
||||
"top_review_candidates": [compact_mapping_item(item) for item in top_review_candidates],
|
||||
},
|
||||
"next_actions": [
|
||||
"Run the production version truth guard before changing or deploying.",
|
||||
"Handle direct mapping items first; they have no verified external price yet.",
|
||||
"Review candidate items next; they already have MOMO candidates but need same-item confirmation.",
|
||||
"Keep this report read-only until an explicit DB-write operator run is approved.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_pchome_mapping_operator_preview(payload: dict[str, Any], batch_size: int = 5) -> dict[str, Any]:
|
||||
"""Build a read-only operator run package for the direct mapping backlog."""
|
||||
summary = summarize_pchome_mapping_backlog(payload)
|
||||
backlog = summary.get("backlog") or {}
|
||||
direct_items = [
|
||||
item
|
||||
for item in backlog.get("top_needs_mapping") or []
|
||||
if item.get("action_code") == "map_external_product"
|
||||
]
|
||||
review_items = list(backlog.get("top_review_candidates") or [])
|
||||
batch_size = max(1, min(int(batch_size or 5), 8))
|
||||
selected_direct = direct_items[:batch_size]
|
||||
selected_review = review_items[:batch_size]
|
||||
|
||||
if selected_direct:
|
||||
result = "READY_FOR_OPERATOR_PREVIEW"
|
||||
elif selected_review:
|
||||
result = "REVIEW_CANDIDATES_ONLY"
|
||||
else:
|
||||
result = "NO_DIRECT_MAPPING_TARGETS"
|
||||
|
||||
return {
|
||||
"policy": OPERATOR_PREVIEW_POLICY,
|
||||
"result": result,
|
||||
"success": bool(summary.get("success")),
|
||||
"generated_at": summary.get("generated_at"),
|
||||
"stats": summary.get("stats") or {},
|
||||
"backlog": {
|
||||
"direct_mapping_count": int(backlog.get("direct_mapping_count") or 0),
|
||||
"review_candidate_count": int(backlog.get("review_candidate_count") or 0),
|
||||
"mapped_opportunity_count": int(backlog.get("mapped_opportunity_count") or 0),
|
||||
},
|
||||
"operator_batch": {
|
||||
"batch_size": batch_size,
|
||||
"selected_direct_mapping_count": len(selected_direct),
|
||||
"selected_review_candidate_count": len(selected_review),
|
||||
"direct_mapping_targets": selected_direct,
|
||||
"review_candidate_targets": selected_review,
|
||||
},
|
||||
"command_preview": {
|
||||
"method": "POST",
|
||||
"endpoint": "/api/ai/pchome-growth/backfill-momo-candidates",
|
||||
"payload": {"limit": min(batch_size, 8)},
|
||||
"executes_search": True,
|
||||
"writes_database": True,
|
||||
"write_gate_required": True,
|
||||
},
|
||||
"external_benchmark_alignment": _build_external_benchmark_alignment(),
|
||||
"ai_automation_plan": _build_ai_automation_plan(selected_direct, selected_review),
|
||||
"safety": {
|
||||
"read_only_preview": True,
|
||||
"executes_search": False,
|
||||
"writes_database": False,
|
||||
"dispatches_telegram": False,
|
||||
"requires_production_version_truth": True,
|
||||
"requires_operator_write_approval": True,
|
||||
},
|
||||
"required_before_execute": [
|
||||
"Run production version truth guard and keep production /health as latest truth.",
|
||||
"Confirm the selected direct mapping targets are the intended PChome products.",
|
||||
"Confirm DB-write authorization for /api/ai/pchome-growth/backfill-momo-candidates.",
|
||||
"Run post-write mapping backlog readback and compare direct_mapping_count / mapped_count.",
|
||||
],
|
||||
"acceptance_criteria": [
|
||||
"direct_mapping_count decreases, or review_candidate_count increases with named MOMO candidates.",
|
||||
"mapped_count or mapping_rate increases only when a verified external price is written.",
|
||||
"No Gemini, Telegram dispatch, scheduler mutation, or unrelated DB write is part of this run.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_direct_mapping_search_terms(product_name: str, max_terms: int) -> list[str]:
|
||||
try:
|
||||
from services.momo_crawler import build_targeted_momo_search_terms
|
||||
|
||||
@@ -9,6 +9,7 @@ from services.pchome_mapping_backlog import (
|
||||
controlled_apply_verifier,
|
||||
evidence,
|
||||
policies,
|
||||
reporter,
|
||||
)
|
||||
|
||||
|
||||
@@ -161,7 +162,46 @@ def test_controlled_apply_receipt_replay_keeps_identity_and_no_write_default(tmp
|
||||
assert len(result["executor_receipt_artifact"]["payload_sha256"]) == 64
|
||||
|
||||
|
||||
def test_legacy_facade_is_smaller_after_fourth_p0_extraction():
|
||||
def test_reporter_keeps_legacy_identity_and_read_only_preview():
|
||||
assert (
|
||||
compatibility_facade.summarize_pchome_mapping_backlog
|
||||
is reporter.summarize_pchome_mapping_backlog
|
||||
)
|
||||
assert (
|
||||
compatibility_facade.build_pchome_mapping_operator_preview
|
||||
is reporter.build_pchome_mapping_operator_preview
|
||||
)
|
||||
assert compatibility_facade.compact_mapping_item is reporter.compact_mapping_item
|
||||
assert (
|
||||
compatibility_facade._evidence_completeness
|
||||
is reporter._evidence_completeness
|
||||
)
|
||||
assert (
|
||||
compatibility_facade._build_external_benchmark_alignment
|
||||
is reporter._build_external_benchmark_alignment
|
||||
)
|
||||
assert (
|
||||
compatibility_facade._build_ai_automation_plan
|
||||
is reporter._build_ai_automation_plan
|
||||
)
|
||||
|
||||
payload = {
|
||||
"success": True,
|
||||
"generated_at": "2026-07-11T14:00:00+08:00",
|
||||
"stats": {},
|
||||
"opportunities": [],
|
||||
}
|
||||
summary = reporter.summarize_pchome_mapping_backlog(payload)
|
||||
preview = reporter.build_pchome_mapping_operator_preview(payload)
|
||||
|
||||
assert summary["result"] == "PASS"
|
||||
assert preview["result"] == "NO_DIRECT_MAPPING_TARGETS"
|
||||
assert preview["safety"]["read_only_preview"] is True
|
||||
assert preview["safety"]["executes_search"] is False
|
||||
assert preview["safety"]["writes_database"] is False
|
||||
|
||||
|
||||
def test_legacy_facade_is_smaller_after_fifth_p0_extraction():
|
||||
facade = ROOT / "services/pchome_mapping_backlog_service.py"
|
||||
|
||||
assert sum(1 for _ in facade.open(encoding="utf-8")) < 43_600
|
||||
assert sum(1 for _ in facade.open(encoding="utf-8")) < 43_200
|
||||
|
||||
Reference in New Issue
Block a user