V10.512 接上 Webcrumbs 比價 host data
All checks were successful
CD Pipeline / deploy (push) Successful in 1m8s

This commit is contained in:
OoO
2026-05-31 20:49:46 +08:00
parent 7cf0235ac7
commit 73d2d863e5
8 changed files with 253 additions and 21 deletions

View File

@@ -4,6 +4,7 @@
================================================================================
【已完成】
- V10.512 Webcrumbs live plugin 接上 MOMO/PChome 只讀 host data新增 `services/webcrumbs_host_data_service.py`,復用 `competitor_intel_repository.fetch_top_competitor_risks()` / coverage把 exact / total_price / price_alert_exact 價差摘要轉成 `StockPlatformSharedUI.marketSnapshot` / `aiCandidate`;不呼叫 LLM、不抓外站、不寫 DB無風險或失敗時仍輸出安全空狀態。
- V10.511 Webcrumbs live plugin 補 host data 安全空狀態:`/webcrumbs` 會注入 `StockPlatformSharedUI.marketSnapshot` / `aiCandidate` 的診斷空資料,避免 plugin fallback demo 數字被誤認成真實市場或 AI 建議。
- V10.510 Webcrumbs 從 runtime 接線推進到專案內 live plugin 試點:`/webcrumbs` 會設定 `StockPlatformSharedUI.allowedPluginUris` 並嵌入同源 `/webcrumbs-assets/plugins/finance.market-ticker-strip/0.1.0`、`finance.ai-candidate-card/0.1.0`,用同一頁同時驗 runtime、plugin proxy 與共享 UI loader 初始化。
- V10.509 新增市場情報 MCP Fetch Candidate Queue Writer Review Decision Approval Writer Preflight 安全預覽 gate只審核 human approval 通過後由操作員貼回的 writer preflight 摘要,確認 approval identity、writer_preflight_id、row count、dedupe keys、approved decision 到 target review_state 的逐列映射、decision/approval/preflight evidence refs、exact identity / variant / overwrite guard 與 operator boundaryAPI 不讀 token、不執行 CLI、不開 DB、不寫 preflight/approval/decision/match、不更新 review_state、不補 queue、不掛 scheduler只放行到後續 CLI review / run package 設計。

View File

@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.511"
SYSTEM_VERSION = "V10.512"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -63,7 +63,7 @@ style.css
## 驗收
- `/webcrumbs` 顯示 runtime URL、版本與 plugin base。
- `/webcrumbs` 會嵌入同源 `/webcrumbs-assets/plugins/...` 的 live plugin preview並由 momo-pro 注入診斷空狀態資料,避免 plugin fallback demo 數字在正式頁面被誤認成真實市場或 AI 建議。
- `/webcrumbs` 會嵌入同源 `/webcrumbs-assets/plugins/...` 的 live plugin preview並由 momo-pro 注入只讀 MOMO/PChome exact 價差摘要;若資料源不可用或無風險候選,改注入診斷空狀態,避免 plugin fallback demo 數字在正式頁面被誤認成真實市場或 AI 建議。
- `/webcrumbs-assets/loader/webcrumbs-compatible-loader.js` 回 200 且 content type 是 JavaScript。
- `ewoooc_base.html``WEBCRUMBS_ENABLED=true` 且 runtime URL 有效時輸出 `<script data-webcrumbs-runtime=...>`
- 任一試點頁嵌入 plugin 後,瀏覽器 console 不應有 `MISSING_URI``STYLE_LOAD_ERROR``SCRIPT_LOAD_ERROR`

View File

@@ -13,6 +13,7 @@
## 📅 詳細更新日誌 (考古存檔)
### 2026-05-31Webcrumbs 共用 UI Runtime 與市場情報 writer approval
- **V10.512 Webcrumbs MOMO/PChome host data**: 新增 `services/webcrumbs_host_data_service.py`,讓 `/webcrumbs` live plugin preview 復用 `competitor_intel_repository.fetch_top_competitor_risks()` 與 coverage將 exact / total_price / price_alert_exact 的只讀價差摘要轉成 `StockPlatformSharedUI.marketSnapshot` / `aiCandidate`;不呼叫 LLM、不抓外站、不寫 DB無風險或讀取失敗時仍輸出安全空狀態。
- **V10.511 Webcrumbs host data 安全空狀態**: `/webcrumbs` 會注入 `StockPlatformSharedUI.marketSnapshot` / `aiCandidate` 的診斷空資料,避免 Shared UI Hub plugin 因資料源未接入而 fallback 顯示假市場數字、假 AI 候選或假信心分數。
- **V10.510 Webcrumbs live plugin 試點**: `/webcrumbs` 診斷頁不再只顯示 runtime 設定,新增 `StockPlatformSharedUI.allowedPluginUris` 與同源 `/webcrumbs-assets/plugins/finance.market-ticker-strip/0.1.0``finance.ai-candidate-card/0.1.0` live plugin embed讓 shared-ui loader、plugin asset proxy 與頁面初始化時序能在 momo-pro 內直接驗收。
- **V10.509 市場情報 MCP Fetch Candidate Queue Writer Review Decision Approval Writer Preflight gate**: 新增 `/api/market_intel/mcp_fetch_candidate_queue_writer_review_decision_approval_writer_preflight` 與 UI preview只審核 human approval 通過後的 operator writer preflight 摘要;要求 approval linkage、writer_preflight_id、target operation、row count、dedupe keys、approved decision 到 target review_state 的逐列映射、decision/approval/preflight evidence refs、artifact paths、matched row exact-identity/variant/overwrite guard 與 operator confirmation 對齊,且 API 不讀 token、不執行 CLI、不開 DB、不寫 preflight/approval/decision/match、不更新 review_state、不補 queue、不掛 scheduler只放行到後續 CLI review / run package 設計。

View File

@@ -32,6 +32,7 @@ from config import (
)
from database.manager import DatabaseManager
from database.models import Product, PriceRecord
from services.webcrumbs_host_data_service import build_webcrumbs_marketplace_host_data
from services.json_storage import load_categories
from services.logger_manager import SystemLogger
from utils.security import safe_join
@@ -122,26 +123,28 @@ def _external_tool_payload(kind):
'uri': f'{plugin_base}/finance.ai-candidate-card/0.1.0',
},
]
plugin_seed_data = {
'marketSnapshot': [
{
'name': 'market data source not connected',
try:
plugin_seed_data = build_webcrumbs_marketplace_host_data(limit=5)
except Exception as exc:
sys_log.warning(f"[Webcrumbs] host data build skipped: {exc}")
plugin_seed_data = {
'marketSnapshot': [{
'name': 'MOMO/PChome data source unavailable',
'price': 'not_available',
'change_pct': 'not_available',
'freshness_status': 'diagnostic_empty',
'freshness_status': 'diagnostic_unavailable',
}],
'aiCandidate': {
'ticker': '-',
'name': 'MOMO/PChome host data unavailable',
'thesis': 'Webcrumbs runtime 已接入,但只讀比價摘要暫時不可用;本頁不顯示 fallback demo 數字。',
'confidence_score': 'not_available',
'risk_level': 'source_unavailable',
'release_status': 'blocked',
'evidence_refs': ['competitor_intel_repository_unavailable'],
'updated_at': SYSTEM_VERSION,
},
],
'aiCandidate': {
'ticker': '-',
'name': 'AI candidate data source not connected',
'thesis': '此頁僅驗證 Webcrumbs loader、同源 plugin proxy 與初始化時序;尚未接入正式市場資料或投資研究資料,因此不顯示假候選或假信心分數。',
'confidence_score': 'not_available',
'risk_level': 'source_not_connected',
'release_status': 'blocked',
'evidence_refs': ['formal_data_source_required'],
'updated_at': SYSTEM_VERSION,
},
}
}
return {
'key': 'webcrumbs',
'eyebrow': 'Shared UI Runtime',

View File

@@ -0,0 +1,159 @@
#!/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 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"] = {
"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")),
"row_count": 0,
"writes_database": False,
"calls_llm": False,
"fetches_external": False,
}
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": {
"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")),
"row_count": len(rows),
"writes_database": False,
"calls_llm": False,
"fetches_external": False,
},
}

View File

@@ -40,12 +40,13 @@ def test_external_tool_bridge_pages_are_diagnostic_not_blank():
assert "def webcrumbs_asset_proxy(asset_path)" in route_source
assert "WEBCRUMBS_ASSET_ALLOWED_PREFIXES" in route_source
assert "Webcrumbs 共用 UI Runtime" in route_source
assert "build_webcrumbs_marketplace_host_data" in route_source
assert "plugin_previews" in route_source
assert "finance.market-ticker-strip/0.1.0" in route_source
assert "finance.ai-candidate-card/0.1.0" in route_source
assert "plugin_seed_data" in route_source
assert "diagnostic_empty" in route_source
assert "source_not_connected" in route_source
assert "diagnostic_unavailable" in route_source
assert "source_unavailable" in route_source
assert "StockPlatformSharedUI.allowedPluginUris" in template
assert "StockPlatformSharedUI.marketSnapshot" in template
assert "StockPlatformSharedUI.aiCandidate" in template

View File

@@ -0,0 +1,67 @@
from services import webcrumbs_host_data_service as svc
def test_webcrumbs_host_data_maps_price_alert_exact_rows(monkeypatch):
engine = object()
monkeypatch.setattr(
svc,
"fetch_top_competitor_risks",
lambda passed_engine, limit: [
{
"sku": "SKU-REVIEW",
"name": "需人工覆核的非直接告警候選",
"momo_price": 999,
"pchome_price": 500,
"gap_pct": 99.8,
"match_score": 0.95,
"alert_tier": "identity_review",
"match_type": "exact",
"price_basis": "total_price",
},
{
"sku": "SKU-1",
"name": "Derma Angel 護妍天使 集中抗痘精華",
"momo_price": 420,
"pchome_price": 250,
"gap_pct": 68.0,
"match_score": 0.91,
"alert_tier": "price_alert_exact",
"match_type": "exact",
"price_basis": "total_price",
"pchome_id": "DABC123",
"pchome_name": "Derma Angel 集中抗痘精華",
"crawled_at": "05/31 20:50",
}
],
)
monkeypatch.setattr(
svc,
"fetch_competitor_coverage",
lambda passed_engine: {"valid_matches": 88, "match_rate": 12.3},
)
payload = svc.build_webcrumbs_marketplace_host_data(engine=engine, limit=5)
assert payload["marketSnapshot"][0]["name"].startswith("SKU-1")
assert payload["marketSnapshot"][0]["price"] == 250
assert payload["marketSnapshot"][0]["change_pct"] == 68.0
assert payload["aiCandidate"]["ticker"] == "SKU-1"
assert payload["aiCandidate"]["confidence_score"] == 0.91
assert "MOMO NT$420 vs PChome NT$250" in payload["aiCandidate"]["thesis"]
assert payload["aiCandidate"]["release_status"] == "review_required"
assert payload["metadata"]["writes_database"] is False
assert payload["metadata"]["calls_llm"] is False
assert payload["metadata"]["fetches_external"] is False
assert all(row["freshness_status"] == "price_alert_exact" for row in payload["marketSnapshot"])
def test_webcrumbs_host_data_uses_empty_state_without_risks(monkeypatch):
monkeypatch.setattr(svc, "fetch_top_competitor_risks", lambda engine, limit: [])
monkeypatch.setattr(svc, "fetch_competitor_coverage", lambda engine: {"valid_matches": 0})
payload = svc.build_webcrumbs_marketplace_host_data(engine=object(), limit=5)
assert payload["marketSnapshot"][0]["freshness_status"] == "no_current_exact_risk"
assert payload["aiCandidate"]["release_status"] == "blocked"
assert "非同款、單位價或變體候選" in payload["aiCandidate"]["thesis"]