#!/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_top_competitor_risks, ) 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")), "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 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 {} if not risks: payload = _empty_payload("no_current_exact_risk") payload["metadata"] = _coverage_metadata(coverage, row_count=0) return payload 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"] return { "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)), }