V10.512 接上 Webcrumbs 比價 host data
All checks were successful
CD Pipeline / deploy (push) Successful in 1m8s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m8s
This commit is contained in:
@@ -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 boundary;API 不讀 token、不執行 CLI、不開 DB、不寫 preflight/approval/decision/match、不更新 review_state、不補 queue、不掛 scheduler,只放行到後續 CLI review / run package 設計。
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
## 📅 詳細更新日誌 (考古存檔)
|
||||
|
||||
### 2026-05-31:Webcrumbs 共用 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 設計。
|
||||
|
||||
@@ -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',
|
||||
|
||||
159
services/webcrumbs_host_data_service.py
Normal file
159
services/webcrumbs_host_data_service.py
Normal 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,
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
67
tests/test_webcrumbs_host_data_service.py
Normal file
67
tests/test_webcrumbs_host_data_service.py
Normal 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"]
|
||||
Reference in New Issue
Block a user