452 lines
17 KiB
Python
452 lines
17 KiB
Python
"""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.",
|
|
],
|
|
}
|