#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Host data adapter for Webcrumbs shared-ui plugins. This module is intentionally read-only. It converts the canonical MOMO/PChome competitor intelligence payload into the small ``window.StockPlatformSharedUI`` shape consumed by the shared plugin runtime. """ from __future__ import annotations from typing import Any from services.competitor_intel_repository import ( fetch_competitor_coverage, fetch_competitor_review_queue, fetch_top_competitor_risks, summarize_review_decision_envelopes, ) def _num(value: Any, default: float = 0.0) -> float: try: return float(value) except (TypeError, ValueError): return default def _money(value: Any) -> str: return f"NT${_num(value):,.0f}" def _short_text(value: Any, limit: int = 42) -> str: text = str(value or "").strip() if len(text) <= limit: return text return f"{text[:limit - 1]}…" def _risk_level(gap_pct: float) -> str: if gap_pct >= 15: return "high" if gap_pct >= 8: return "medium" return "watch" def _is_direct_price_alert(item: dict) -> bool: return ( str(item.get("match_type") or "") == "exact" and str(item.get("price_basis") or "") == "total_price" and str(item.get("alert_tier") or "") == "price_alert_exact" ) def _empty_payload(reason: str = "no_price_alert_exact") -> dict: return { "marketSnapshot": [ { "name": "MOMO/PChome exact price alert", "price": "not_available", "change_pct": "not_available", "freshness_status": reason, } ], "aiCandidate": { "ticker": "-", "name": "目前沒有可直接告警的 exact 同款價差", "thesis": "資料源已接入,但目前沒有符合 exact / total_price / price_alert_exact 的高風險候選;非同款、單位價或變體候選仍須留在人工覆核隊列。", "confidence_score": "not_available", "risk_level": "none", "release_status": "blocked", "evidence_refs": ["competitor_prices", "price_records", reason], "updated_at": "read_only", }, } def _coverage_metadata(coverage: dict, row_count: int) -> dict: return { "source": "competitor_intel_repository", "matched_count": int(coverage.get("valid_matches") or coverage.get("matched_count") or 0), "coverage_rate": _num(coverage.get("match_rate")), "identity_coverage_rate": _num(coverage.get("match_rate")), "decision_ready_count": int( coverage.get("decision_ready_matches") or coverage.get("fresh_matches") or coverage.get("fresh_match_count") or 0 ), "decision_ready_rate": _num(coverage.get("decision_ready_rate")), "decision_support_count": int(coverage.get("decision_support_count") or 0), "decision_support_rate": _num(coverage.get("decision_support_rate")), "decision_support_non_exact_count": int( coverage.get("decision_support_non_exact_count") or 0 ), "catalog_comparable_count": int(coverage.get("catalog_comparable_count") or 0), "catalog_comparable_rate": _num(coverage.get("catalog_comparable_rate")), "catalog_variant_review_count": int(coverage.get("catalog_variant_review_count") or 0), "catalog_unit_review_count": int(coverage.get("catalog_unit_review_count") or 0), "catalog_identity_review_count": int(coverage.get("catalog_identity_review_count") or 0), "catalog_review_plan": coverage.get("catalog_review_plan") or {}, "unit_comparable_count": int(coverage.get("unit_comparable_count") or 0), "fresh_match_count": int(coverage.get("fresh_matches") or coverage.get("fresh_match_count") or 0), "fresh_match_rate": _num(coverage.get("fresh_match_rate")), "stale_match_count": int(coverage.get("stale_matches") or coverage.get("stale_match_count") or 0), "pending_match_count": int(coverage.get("pending") or coverage.get("pending_match_count") or 0), "row_count": row_count, "writes_database": False, "calls_llm": False, "fetches_external": False, } def _review_decision_brief(engine, limit: int) -> dict: """Read the shared review decision brief without blocking host-data preview.""" try: review_queue = fetch_competitor_review_queue(engine, limit=min(max(limit, 1), 8)) return summarize_review_decision_envelopes(review_queue, limit=min(max(limit, 1), 5)) except Exception: return summarize_review_decision_envelopes([], limit=min(max(limit, 1), 5)) def _attach_review_brief(payload: dict, review_brief: dict) -> dict: payload["reviewDecisionBrief"] = review_brief metadata = payload.setdefault("metadata", {}) metadata["review_queue_count"] = len(review_brief.get("items") or []) metadata["hitl_count"] = int(review_brief.get("hitl_count") or 0) metadata["auto_execute_blocked_count"] = int( review_brief.get("auto_execute_blocked_count") or 0 ) metadata["decision_envelope_source"] = "competitor_intel_repository" return payload def build_webcrumbs_marketplace_host_data(engine=None, limit: int = 5) -> dict: """Build read-only host data for the Webcrumbs diagnostic page. Args: engine: Optional SQLAlchemy engine. When omitted, the default momo-pro database manager is used. limit: Maximum number of price-alert rows exposed to the shared-ui preview. Kept small because this powers a diagnostic surface. """ if engine is None: from database.manager import DatabaseManager engine = DatabaseManager().engine limit = max(1, min(int(limit or 5), 8)) raw_risks = fetch_top_competitor_risks(engine, limit=max(limit * 4, 20)) or [] risks = [item for item in raw_risks if _is_direct_price_alert(item)][:limit] coverage = fetch_competitor_coverage(engine) or {} review_brief = _review_decision_brief(engine, limit=limit) if not risks: payload = _empty_payload("no_current_exact_risk") payload["metadata"] = _coverage_metadata(coverage, row_count=0) return _attach_review_brief(payload, review_brief) rows = [] for item in risks[:limit]: gap_pct = _num(item.get("gap_pct")) momo_price = _num(item.get("momo_price")) pchome_price = _num(item.get("pchome_price")) rows.append({ "name": f"{item.get('sku') or '-'} {_short_text(item.get('name'))}", "price": pchome_price, "change_pct": gap_pct, "freshness_status": item.get("alert_tier") or "price_alert_exact", "momo_price": momo_price, "pchome_price": pchome_price, "match_score": _num(item.get("match_score")), "competitor_product_id": item.get("pchome_id") or "", "competitor_product_name": _short_text(item.get("pchome_name"), 48), }) top = risks[0] top_gap = _num(top.get("gap_pct")) top_score = _num(top.get("match_score")) sku = str(top.get("sku") or "-") evidence_refs = ["competitor_prices", "price_records", "exact", "total_price", "price_alert_exact"] payload = { "marketSnapshot": rows, "aiCandidate": { "ticker": sku, "name": _short_text(top.get("name"), 58), "thesis": ( f"MOMO {_money(top.get('momo_price'))} vs PChome {_money(top.get('pchome_price'))}," f"價差 {top_gap:+.1f}%(MOMO - PChome)。" f"此候選已通過 exact / total_price / price_alert_exact 只讀過濾," f"match_score={top_score:.2f};仍需人工確認促銷、庫存與商品頁條件後再採取價格或曝光調整。" ), "confidence_score": round(top_score, 2), "risk_level": _risk_level(top_gap), "release_status": "review_required", "evidence_refs": evidence_refs, "updated_at": top.get("crawled_at") or "latest_competitor_prices", }, "metadata": _coverage_metadata(coverage, row_count=len(rows)), } return _attach_review_brief(payload, review_brief)