Compare commits

...

2 Commits

Author SHA1 Message Date
ogt
26b5d229d4 feat(ai): add external mcp rag integration readback
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
2026-07-09 22:18:34 +08:00
ogt
c3824119cd feat(crawler): expand pixelrag marketplace automation
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
2026-07-09 22:09:00 +08:00
14 changed files with 1220 additions and 7 deletions

1
.gitignore vendored
View File

@@ -91,6 +91,7 @@ data/excel_exports/
data/*_cache/ data/*_cache/
data/ai_automation_smoke_history.jsonl data/ai_automation_smoke_history.jsonl
data/ai_automation/sitewide_visual_qa_latest.json data/ai_automation/sitewide_visual_qa_latest.json
runtime_artifacts/
# 上傳檔案 # 上傳檔案
web/static/uploads/ web/static/uploads/

View File

@@ -26,6 +26,8 @@
- Telegram 失敗必須可暫存與 replay。 - Telegram 失敗必須可暫存與 replay。
- EventRouter / AutoHeal 變更必須更新 `services/ai_automation_metrics.py` 指標或確認既有指標已覆蓋。 - EventRouter / AutoHeal 變更必須更新 `services/ai_automation_metrics.py` 指標或確認既有指標已覆蓋。
- AI 自動化閉環變更必須確認 `/api/ai-automation/smoke``/ai_automation_smoke` 仍能反映新狀態。 - AI 自動化閉環變更必須確認 `/api/ai-automation/smoke``/ai_automation_smoke` 仍能反映新狀態。
- 外部 MCP / RAG 能力導入內部治理時,必須確認 `/api/ai-automation/external-mcp-rag-integration`
`python scripts/ops/report_external_mcp_rag_integration.py` 可讀回每個能力的內部落點、狀態、資料邊界與下一個機器動作。
- AI 自動化 Prometheus 指標變更必須同步檢查 `docker/grafana/provisioning/dashboards/json/ai-automation-overview.json` 是否需要新增 panel 或查詢。 - AI 自動化 Prometheus 指標變更必須同步檢查 `docker/grafana/provisioning/dashboards/json/ai-automation-overview.json` 是否需要新增 panel 或查詢。
- 188 線上 active monitoring stack 以 `monitoring/prometheus.yml` 為準110 gateway 另有 `/home/wooo/monitoring/prometheus.yml`。若 dashboard 無資料,先確認 Prometheus `momo-app` target 與 `momo-network` 連線;所有 Blackbox HTTP target 必須打 `/health`,不可打 Dashboard 首頁 `/` - 188 線上 active monitoring stack 以 `monitoring/prometheus.yml` 為準110 gateway 另有 `/home/wooo/monitoring/prometheus.yml`。若 dashboard 無資料,先確認 Prometheus `momo-app` target 與 `momo-network` 連線;所有 Blackbox HTTP target 必須打 `/health`,不可打 Dashboard 首頁 `/`
- Smoke dashboard 會保存 JSONL 趨勢;若新增檢查項目,要確保 history compact record 仍保持小而可讀。 - Smoke dashboard 會保存 JSONL 趨勢;若新增檢查項目,要確保 history compact record 仍保持小而可讀。

View File

@@ -54,7 +54,7 @@ python scripts/tools/browse_sh_probe.py -- screenshot
## PixelRAG-style 視覺證據 fallback ## PixelRAG-style 視覺證據 fallback
2026-07-09 評估 PixelRAG 後,結論是「可導入,但不可直接取代正式爬蟲」。PixelRAG 的核心價值是把渲染後頁面截圖切成 tiles讓 AI 讀到 HTML parser 可能丟失的視覺結構;本專案第一階段採用這個視覺證據思路,不直接拉外部 runtime、不用外部 embedding API、不把像素結果寫入正式價格表。 2026-07-09 評估 PixelRAG 後,結論是「可導入,而且不應只限 MOMO/PChome」。PixelRAG 的核心價值是把渲染後頁面截圖切成 tiles讓 AI 讀到 HTML parser 可能丟失的視覺結構;本專案第一階段採用這個視覺證據思路,擴充成多電商 public marketplace visual evidence layer不直接拉外部 runtime、不用外部 embedding API、不把像素結果寫入正式價格表。
導入順序: 導入順序:
@@ -65,11 +65,44 @@ python scripts/tools/browse_sh_probe.py -- screenshot
5. Phase 4 若需要索引,優先設計 pgvector-compatible evidence metadataFAISS 只能先當本地研究/ADR 候選,不直接進 production。 5. Phase 4 若需要索引,優先設計 pgvector-compatible evidence metadataFAISS 只能先當本地研究/ADR 候選,不直接進 production。
6. Phase 5 才談 crawler fusion正式 `competitor_prices` / `competitor_price_history` 寫入仍需 matcher replay/canary 證據。 6. Phase 5 才談 crawler fusion正式 `competitor_prices` / `competitor_price_history` 寫入仍需 matcher replay/canary 證據。
多電商優先順序:
| Priority | Platform | PixelRAG 用途 | 邊界 |
|---|---|---|---|
| P0 | MOMO / PChome | 既有 structured crawler 失敗時自動產生 visual manifest queue | 正式價格仍以既有 parser/API/matcher 為準 |
| P1 | Shopee Taiwan / Coupang Taiwan | 抓公共搜尋頁商品卡、促銷 badge、店家/配送/評分視覺證據 | 不登入、不碰購物車、不繞過 anti-bot |
| P2 | Yahoo Shopping / ETMall | 補促銷券、快速到貨、活動價格等視覺訊號 | 先做 controlled probe再決定 parser |
| P3 | friDay / Rakuten Taiwan | 先建立 probe profile 與 search manifest | URL pattern 需 runtime receipt 驗證後再排程 |
已支援的 platform code
```text
momo
pchome
shopee_tw
coupang_tw
yahoo_shopping_tw
etmall_tw
friday_tw
rakuten_tw
```
自動觸發:
- `PChomeCrawler.fetch_region_page` 找不到商品 ID 時會 queue PixelRAG manifest。
- `PChomeCrawler.fetch_product_details` API 回傳但無可解析商品時會 queue 商品頁 manifest。
- `PChomeCrawler.search_products` 搜尋無商品 ID 或 request error 時會 queue 公開搜尋頁 manifest。
- queue 預設路徑:正式 app 容器 `/app/data/ai_automation/pixelrag_manifest_queue`,本機為 `runtime_artifacts/pixelrag_manifest_queue`
- queue 只寫 artifact不寫 DB、不寫正式價格、不讀 secretcapture worker 後續消費 manifest 產生 screenshot/tile receipt。
機器可讀評估: 機器可讀評估:
```bash ```bash
python scripts/ops/report_pixelrag_crawler_integration.py python scripts/ops/report_pixelrag_crawler_integration.py
python scripts/ops/report_pixelrag_crawler_integration.py --platform momo python scripts/ops/report_pixelrag_crawler_integration.py --platform momo
python scripts/ops/report_pixelrag_crawler_integration.py --ecommerce-expansion-plan
python scripts/ops/report_pixelrag_crawler_integration.py --platform shopee_tw --marketplace-keyword "防曬乳"
python scripts/ops/report_pixelrag_crawler_integration.py --platform coupang_tw --marketplace-keyword "iphone"
python scripts/ops/report_pixelrag_crawler_integration.py --capability ollama_multimodal_ready --capability pgvector_visual_ready python scripts/ops/report_pixelrag_crawler_integration.py --capability ollama_multimodal_ready --capability pgvector_visual_ready
python scripts/ops/report_pixelrag_crawler_integration.py \ python scripts/ops/report_pixelrag_crawler_integration.py \
--platform momo \ --platform momo \
@@ -84,6 +117,9 @@ python scripts/ops/report_pixelrag_crawler_integration.py \
```text ```text
/api/ai-automation/pixelrag-crawler-integration?platform=momo /api/ai-automation/pixelrag-crawler-integration?platform=momo
/api/ai-automation/pixelrag-crawler-integration?expansion_plan=true
/api/ai-automation/pixelrag-crawler-integration?platform=shopee_tw&marketplace_keyword=%E9%98%B2%E6%9B%AC%E4%B9%B3
/api/ai-automation/pixelrag-crawler-integration?platform=coupang_tw&marketplace_keyword=iphone
/api/ai-automation/pixelrag-crawler-integration?platform=pchome&manifest_url=https://24h.pchome.com.tw/prod/TEST-000000001&crawler=PChomeCrawler.search_products&trigger_reason=parser_empty /api/ai-automation/pixelrag-crawler-integration?platform=pchome&manifest_url=https://24h.pchome.com.tw/prod/TEST-000000001&crawler=PChomeCrawler.search_products&trigger_reason=parser_empty
/api/ai-automation/pixelrag-visual-evidence-readback?platform=pchome&manifest_id=4a93e95e5afb414bc8c3 /api/ai-automation/pixelrag-visual-evidence-readback?platform=pchome&manifest_id=4a93e95e5afb414bc8c3
``` ```

View File

@@ -640,10 +640,26 @@ def ai_automation_pixelrag_crawler_integration_api():
"""Read-only PixelRAG-style crawler visual evidence assessment.""" """Read-only PixelRAG-style crawler visual evidence assessment."""
from services.pixelrag_crawler_integration_service import ( from services.pixelrag_crawler_integration_service import (
build_pixelrag_crawler_integration_assessment, build_pixelrag_crawler_integration_assessment,
build_pixelrag_ecommerce_expansion_plan,
build_pixelrag_marketplace_search_manifest,
build_pixelrag_visual_evidence_manifest, build_pixelrag_visual_evidence_manifest,
) )
platforms = request.args.getlist('platform') or None platforms = request.args.getlist('platform') or None
if str(request.args.get('expansion_plan') or '').lower() in {'1', 'true', 'yes'}:
return jsonify(build_pixelrag_ecommerce_expansion_plan(
target_platforms=tuple(platforms) if platforms else None,
))
marketplace_keyword = str(request.args.get('marketplace_keyword') or '').strip()
if marketplace_keyword:
platform = (platforms or ['shopee_tw'])[0]
return jsonify(build_pixelrag_marketplace_search_manifest(
platform=platform,
keyword=marketplace_keyword,
crawler=str(request.args.get('crawler') or 'PixelRAGMarketplaceSearch.visual_fallback'),
trigger_reason=str(request.args.get('trigger_reason') or 'marketplace_search_visual_scan'),
evidence_intent=str(request.args.get('evidence_intent') or 'collect_public_marketplace_offer_cards'),
))
manifest_url = str(request.args.get('manifest_url') or '').strip() manifest_url = str(request.args.get('manifest_url') or '').strip()
if manifest_url: if manifest_url:
platform = (platforms or ['momo'])[0] platform = (platforms or ['momo'])[0]
@@ -673,6 +689,19 @@ def ai_automation_pixelrag_visual_evidence_readback_api():
)) ))
@system_public_bp.route('/api/ai-automation/external-mcp-rag-integration')
@login_required
def ai_automation_external_mcp_rag_integration_api():
"""Read-only external MCP/RAG absorption status for internal MCP/RAG planes."""
from services.external_mcp_rag_integration_service import (
build_external_mcp_rag_integration_readback,
)
return jsonify(build_external_mcp_rag_integration_readback(
target_family=str(request.args.get('family') or '').strip() or None,
))
@system_public_bp.route('/api/ai-automation/smoke/history/export') @system_public_bp.route('/api/ai-automation/smoke/history/export')
@login_required @login_required
def ai_automation_smoke_history_export(): def ai_automation_smoke_history_export():

View File

@@ -21,6 +21,12 @@ const DEFAULT_MAX_TILES = 80;
const ALLOWED_PLATFORM_HOSTS = { const ALLOWED_PLATFORM_HOSTS = {
momo: new Set(['m.momoshop.com.tw', 'www.momoshop.com.tw']), momo: new Set(['m.momoshop.com.tw', 'www.momoshop.com.tw']),
pchome: new Set(['24h.pchome.com.tw', 'ecshweb.pchome.com.tw', 'ecapi-cdn.pchome.com.tw']), pchome: new Set(['24h.pchome.com.tw', 'ecshweb.pchome.com.tw', 'ecapi-cdn.pchome.com.tw']),
shopee_tw: new Set(['shopee.tw', 'mall.shopee.tw']),
coupang_tw: new Set(['www.tw.coupang.com', 'tw.coupang.com']),
yahoo_shopping_tw: new Set(['tw.buy.yahoo.com']),
etmall_tw: new Set(['www.etmall.com.tw', 'etmall.com.tw']),
friday_tw: new Set(['shopping.friday.tw', 'ec-m.shopping.friday.tw', 'ec-w.shopping.friday.tw']),
rakuten_tw: new Set(['www.rakuten.com.tw', 'rakuten.com.tw']),
market_intel: new Set(), market_intel: new Set(),
external_market: new Set(), external_market: new Set(),
}; };

View File

@@ -26,6 +26,12 @@ DEFAULT_MAX_TILES = 80
ALLOWED_PLATFORM_HOSTS = { ALLOWED_PLATFORM_HOSTS = {
"momo": {"m.momoshop.com.tw", "www.momoshop.com.tw"}, "momo": {"m.momoshop.com.tw", "www.momoshop.com.tw"},
"pchome": {"24h.pchome.com.tw", "ecshweb.pchome.com.tw", "ecapi-cdn.pchome.com.tw"}, "pchome": {"24h.pchome.com.tw", "ecshweb.pchome.com.tw", "ecapi-cdn.pchome.com.tw"},
"shopee_tw": {"shopee.tw", "mall.shopee.tw"},
"coupang_tw": {"www.tw.coupang.com", "tw.coupang.com"},
"yahoo_shopping_tw": {"tw.buy.yahoo.com"},
"etmall_tw": {"www.etmall.com.tw", "etmall.com.tw"},
"friday_tw": {"shopping.friday.tw", "ec-m.shopping.friday.tw", "ec-w.shopping.friday.tw"},
"rakuten_tw": {"www.rakuten.com.tw", "rakuten.com.tw"},
"market_intel": set(), "market_intel": set(),
"external_market": set(), "external_market": set(),
} }

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Report external MCP/RAG absorption into internal MCP/RAG planes."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from services.external_mcp_rag_integration_service import ( # noqa: E402
build_external_mcp_rag_integration_readback,
)
def main() -> int:
parser = argparse.ArgumentParser(
description="輸出外部 MCP/RAG 導入內部 MCP/RAG 治理面的機器可讀狀態。"
)
parser.add_argument(
"--family",
choices=["external_mcp", "internal_mcp", "external_rag", "internal_rag"],
help="只輸出指定 capability family。",
)
args = parser.parse_args()
payload = build_external_mcp_rag_integration_readback(
target_family=args.family,
)
print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
return 0 if payload.get("success") else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -15,6 +15,8 @@ if str(ROOT) not in sys.path:
from services.pixelrag_crawler_integration_service import ( # noqa: E402 from services.pixelrag_crawler_integration_service import ( # noqa: E402
build_pixelrag_crawler_integration_assessment, build_pixelrag_crawler_integration_assessment,
build_pixelrag_ecommerce_expansion_plan,
build_pixelrag_marketplace_search_manifest,
build_pixelrag_visual_evidence_manifest, build_pixelrag_visual_evidence_manifest,
) )
@@ -62,6 +64,12 @@ def main() -> int:
help="覆寫 readiness 假設,用於模擬下一階段條件。", help="覆寫 readiness 假設,用於模擬下一階段條件。",
) )
parser.add_argument("--manifest-url", help="輸出單一 URL 的視覺證據 manifest。") parser.add_argument("--manifest-url", help="輸出單一 URL 的視覺證據 manifest。")
parser.add_argument("--marketplace-keyword", help="依平台與關鍵字輸出多電商視覺搜尋 manifest。")
parser.add_argument(
"--ecommerce-expansion-plan",
action="store_true",
help="輸出 MOMO/PChome 以外的 PixelRAG 多電商擴充計畫。",
)
parser.add_argument("--crawler", default="crawler_diagnostics", help="manifest 來源 crawler 名稱。") parser.add_argument("--crawler", default="crawler_diagnostics", help="manifest 來源 crawler 名稱。")
parser.add_argument( parser.add_argument(
"--trigger-reason", "--trigger-reason",
@@ -90,6 +98,43 @@ def main() -> int:
) )
args = parser.parse_args() args = parser.parse_args()
if args.ecommerce_expansion_plan:
payload = build_pixelrag_ecommerce_expansion_plan(
target_platforms=tuple(args.platforms) if args.platforms else None,
)
print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
return 0 if payload.get("success") else 1
if args.marketplace_keyword:
platform = (args.platforms or ["shopee_tw"])[0]
crawler = (
"PixelRAGMarketplaceSearch.visual_fallback"
if args.crawler == "crawler_diagnostics"
else args.crawler
)
trigger_reason = (
"marketplace_search_visual_scan"
if args.trigger_reason == "parser_empty_or_low_confidence"
else args.trigger_reason
)
evidence_intent = (
"collect_public_marketplace_offer_cards"
if args.evidence_intent == "recover_visual_offer_evidence"
else args.evidence_intent
)
payload = build_pixelrag_marketplace_search_manifest(
platform=platform,
keyword=args.marketplace_keyword,
crawler=crawler,
trigger_reason=trigger_reason,
evidence_intent=evidence_intent,
viewport=args.viewport,
page_size=args.page_size,
tile_size=args.tile_size,
)
print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
return 0 if payload.get("success") else 1
if args.manifest_url: if args.manifest_url:
platform = (args.platforms or ["momo"])[0] platform = (args.platforms or ["momo"])[0]
payload = build_pixelrag_visual_evidence_manifest( payload = build_pixelrag_visual_evidence_manifest(

View File

@@ -0,0 +1,308 @@
"""Read-only inventory for external MCP/RAG absorption into internal control planes."""
from __future__ import annotations
import os
from copy import deepcopy
from datetime import datetime, timezone
from typing import Any
POLICY = "read_only_external_mcp_rag_integration_readback_v1"
def _enabled(value: str | None) -> bool:
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
def _controlled_apply_boundary() -> dict[str, bool]:
return {
"network_call": False,
"db_write": False,
"secret_read": False,
"production_price_write": False,
"external_side_effect": False,
}
def _status_counts(items: list[dict[str, Any]]) -> dict[str, int]:
counts: dict[str, int] = {}
for item in items:
status = str(item.get("status") or "unknown")
counts[status] = counts.get(status, 0) + 1
return counts
def _mcp_runtime_snapshot() -> dict[str, Any]:
try:
from services.mcp_router import MCP_BASE_HOSTS, TOOL_REGISTRY, is_mcp_router_enabled
return {
"enabled": is_mcp_router_enabled(),
"servers": deepcopy(MCP_BASE_HOSTS),
"caller_count": len(TOOL_REGISTRY),
"tool_registry": {
caller: {
server: list(tools)
for server, tools in servers.items()
}
for caller, servers in TOOL_REGISTRY.items()
},
}
except Exception as exc:
return {
"enabled": False,
"servers": {},
"caller_count": 0,
"tool_registry": {},
"error": str(exc)[:300],
}
def _rag_runtime_snapshot() -> dict[str, Any]:
try:
from services.rag_service import (
RAG_DEFAULT_THRESHOLD,
RAG_DEFAULT_TOP_K,
RAG_EMBED_DIM,
RAG_EMBED_MODEL,
get_embedding_signature,
is_rag_enabled,
)
return {
"enabled": is_rag_enabled(),
"vector_store": "pgvector",
"embedding_model": RAG_EMBED_MODEL,
"embedding_dim": RAG_EMBED_DIM,
"embedding_signature": get_embedding_signature(),
"default_top_k": RAG_DEFAULT_TOP_K,
"default_threshold": RAG_DEFAULT_THRESHOLD,
}
except Exception as exc:
return {
"enabled": False,
"vector_store": "pgvector",
"embedding_model": "",
"embedding_dim": 0,
"embedding_signature": "",
"error": str(exc)[:300],
}
def _pixelrag_snapshot() -> dict[str, Any]:
try:
from services.pixelrag_crawler_integration_service import ECOMMERCE_PLATFORM_PROFILES
platforms = sorted(
platform
for platform in ECOMMERCE_PLATFORM_PROFILES
if platform not in {"market_intel", "external_market"}
)
return {
"enabled": True,
"platform_count": len(platforms),
"platforms": platforms,
"visual_rag_stage": "phase1_visual_evidence_receipts",
}
except Exception as exc:
return {
"enabled": False,
"platform_count": 0,
"platforms": [],
"visual_rag_stage": "unavailable",
"error": str(exc)[:300],
}
def _capability_inventory() -> list[dict[str, Any]]:
return [
{
"id": "mcp.omnisearch.tavily_exa",
"family": "external_mcp",
"external_capability": "Tavily / Exa style public web search through omnisearch MCP",
"internal_plane": "mcp_router",
"internal_entrypoint": "services.mcp_router.TOOL_REGISTRY[mcp_collector][omnisearch]",
"status": "integrated_self_hosted_gateway",
"writes": ["mcp_calls_log_async"],
"data_boundary": "public_search_summary_only",
"next_machine_action": "keep_mcp_router_health_and_cache_readback",
},
{
"id": "mcp.firecrawl.scrape",
"family": "external_mcp",
"external_capability": "Firecrawl style public page scrape",
"internal_plane": "mcp_router",
"internal_entrypoint": "services.mcp_router.TOOL_REGISTRY[*][firecrawl].scrape_url",
"status": "integrated_self_hosted_gateway",
"writes": ["mcp_calls_log_async"],
"data_boundary": "approved_public_url_only",
"next_machine_action": "enforce_source_contract_before_scheduler_attach",
},
{
"id": "mcp.postgres.query",
"family": "internal_mcp",
"external_capability": "SQL read interface exposed as MCP tool",
"internal_plane": "mcp_router",
"internal_entrypoint": "postgres.query for approved callers",
"status": "integrated_read_only_contract",
"writes": ["mcp_calls_log_async"],
"data_boundary": "read_only_query_allowlist",
"next_machine_action": "keep_disallowing_unknown_callers_and_write_tools",
},
{
"id": "mcp.filesystem.readonly",
"family": "internal_mcp",
"external_capability": "Filesystem MCP read diagnostics",
"internal_plane": "mcp_router",
"internal_entrypoint": "ops_diagnostics filesystem read-only tools",
"status": "integrated_read_only_contract",
"writes": ["mcp_calls_log_async"],
"data_boundary": "allowed_directories_read_only",
"next_machine_action": "keep_write_file_and_mutation_tools_rejected",
},
{
"id": "rag.pixelrag.visual_evidence",
"family": "external_rag",
"external_capability": "PixelRAG page screenshot and tile retrieval pattern",
"internal_plane": "pixelrag_visual_evidence + internal RAG candidate lane",
"internal_entrypoint": "services.pixelrag_crawler_integration_service",
"status": "integrated_phase1_visual_receipts",
"writes": ["artifact_file_only"],
"data_boundary": "public_page_screenshot_tiles_no_price_write",
"next_machine_action": "add_ocr_vlm_replay_before_promoting_to_pgvector_rag",
},
{
"id": "rag.pgvector.bge_m3",
"family": "internal_rag",
"external_capability": "General text RAG retrieval pattern",
"internal_plane": "rag_service",
"internal_entrypoint": "services.rag_service.RAGService.query",
"status": "internal_primary_ready",
"writes": ["rag_query_log_async"],
"data_boundary": "ai_insights_pgvector_embedding_signature_guard",
"next_machine_action": "keep_embedding_signature_and_feedback_guardrails",
},
{
"id": "rag.qwen3_vl_embedding",
"family": "external_rag",
"external_capability": "Multimodal visual embedding model for tile retrieval",
"internal_plane": "ollama_first_visual_embedding_benchmark",
"internal_entrypoint": "not_enabled_in_production",
"status": "deferred_until_ollama_first_verified",
"writes": [],
"data_boundary": "no_hosted_visual_embedding_api",
"next_machine_action": "benchmark_local_multimodal_embedding_then_design_pgvector_metadata",
},
{
"id": "rag.faiss_visual_index",
"family": "external_rag",
"external_capability": "FAISS visual retrieval index used by some PixelRAG pipelines",
"internal_plane": "pgvector_first_policy",
"internal_entrypoint": "not_enabled_in_production",
"status": "rejected_for_production_without_adr",
"writes": [],
"data_boundary": "pgvector_is_the_only_production_vector_store",
"next_machine_action": "use_pgvector_compatible_metadata_or_write_adr_before_any_faiss_trial",
},
{
"id": "mcp.gemini_grounding",
"family": "external_mcp",
"external_capability": "Gemini grounding as hosted external search fallback",
"internal_plane": "mcp_collector_final_fallback",
"internal_entrypoint": "services.mcp_collector_service guarded fallback",
"status": "disabled_by_default_fallback_only",
"writes": ["mcp_cache_only_when_explicitly_enabled"],
"data_boundary": "gemini_api_hard_disabled_by_default",
"next_machine_action": "prefer_self_hosted_mcp_or_ollama_static_fallback",
},
]
def build_external_mcp_rag_integration_readback(
*,
target_family: str | None = None,
) -> dict[str, Any]:
"""Describe how external MCP/RAG capabilities are absorbed internally."""
family_filter = str(target_family or "").strip().lower()
inventory = _capability_inventory()
if family_filter:
inventory = [
item
for item in inventory
if str(item.get("family") or "").lower() == family_filter
]
counts = _status_counts(inventory)
unresolved = [
item
for item in inventory
if str(item.get("status") or "").startswith(("deferred", "rejected", "disabled"))
]
absorbed = [
item
for item in inventory
if item not in unresolved
]
all_absorbed = len(unresolved) == 0
return {
"success": True,
"policy": POLICY,
"generated_at": datetime.now(timezone.utc).isoformat(),
"status": "partially_integrated" if not all_absorbed else "fully_integrated",
"answer_to_owner": (
"不是全部完成;已把主要外部 MCP/RAG 放進內部可治理 registry"
"其中 self-hosted MCP、pgvector RAG、PixelRAG phase1 已整合,"
"多模態 embedding / FAISS / Gemini grounding 仍需條件或維持停用。"
if not all_absorbed
else "目前 registry 內的外部 MCP/RAG 都已有內部治理落點。"
),
"completion": {
"total_capabilities": len(inventory),
"absorbed_count": len(absorbed),
"unresolved_count": len(unresolved),
"all_absorbed": all_absorbed,
"status_counts": counts,
},
"runtime": {
"mcp": _mcp_runtime_snapshot(),
"rag": _rag_runtime_snapshot(),
"pixelrag": _pixelrag_snapshot(),
"env_flags": {
"MCP_ROUTER_ENABLED": _enabled(os.getenv("MCP_ROUTER_ENABLED")),
"RAG_ENABLED": _enabled(os.getenv("RAG_ENABLED")),
"GEMINI_API_HARD_DISABLED": _enabled(os.getenv("GEMINI_API_HARD_DISABLED", "true")),
},
},
"inventory": inventory,
"next_machine_actions": [
{
"priority": "P0",
"action": "wire_pixelrag_receipts_to_internal_rag_candidate_replay",
"status": "ready_after_ocr_vlm_replay_contract",
},
{
"priority": "P0",
"action": "add_mcp_router_health_readback_to_ai_automation_smoke",
"status": "ready",
},
{
"priority": "P1",
"action": "benchmark_ollama_first_visual_embedding",
"status": "blocked_until_model_runtime_verified",
},
{
"priority": "P1",
"action": "promote_successful_marketplace_visual_receipts_to_source_contracts",
"status": "ready_for_shopee_warning_for_coupang",
},
],
"controlled_apply": _controlled_apply_boundary(),
}
__all__ = [
"POLICY",
"build_external_mcp_rag_integration_readback",
]

View File

@@ -18,6 +18,7 @@ import logging
from typing import List, Dict, Optional, Tuple from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from datetime import datetime from datetime import datetime
from urllib.parse import urlencode
import requests import requests
@@ -139,6 +140,7 @@ class PChomeCrawler:
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update(self.DEFAULT_HEADERS) self.session.headers.update(self.DEFAULT_HEADERS)
self._last_request_time = 0 self._last_request_time = 0
self.last_visual_evidence_action = None
def _rate_limit(self): def _rate_limit(self):
"""速率限制""" """速率限制"""
@@ -188,6 +190,58 @@ class PChomeCrawler:
raise last_error raise last_error
raise requests.RequestException(f"GET failed: {url}") raise requests.RequestException(f"GET failed: {url}")
def _public_search_url(self, keyword: str) -> str:
return f"{self.BASE_URL}/search/?{urlencode({'q': str(keyword or '').strip()})}"
def _emit_visual_evidence_action(
self,
*,
url: str,
crawler: str,
failure_reason: str,
parsed_item_count: int = 0,
missing_fields: Tuple[str, ...] = ("product_id", "title", "price"),
) -> Optional[dict]:
"""Queue a PixelRAG visual manifest for parser-empty crawler cases."""
try:
from services.pixelrag_crawler_integration_service import (
build_pixelrag_crawler_failure_action,
)
action = build_pixelrag_crawler_failure_action(
url=url,
platform="pchome",
crawler=crawler,
parser_success=False,
parsed_item_count=parsed_item_count,
missing_fields=missing_fields,
failure_reason=failure_reason,
persist_manifest=True,
)
self.last_visual_evidence_action = action
if action.get("success"):
logger.info(
"[PChome] PixelRAG visual evidence manifest queued | crawler=%s status=%s path=%s",
crawler,
action.get("status"),
((action.get("persistence") or {}).get("path") or ""),
)
else:
logger.warning(
"[PChome] PixelRAG visual evidence action failed | crawler=%s status=%s",
crawler,
action.get("status"),
)
return action
except Exception as exc:
logger.warning("[PChome] PixelRAG visual evidence action skipped: %s", exc)
self.last_visual_evidence_action = {
"success": False,
"status": "exception",
"error": str(exc)[:300],
}
return self.last_visual_evidence_action
def _normalize_product_id(self, product_id: str) -> str: def _normalize_product_id(self, product_id: str) -> str:
""" """
正規化商品 ID 格式 正規化商品 ID 格式
@@ -248,11 +302,22 @@ class PChomeCrawler:
product_ids = self._extract_product_ids_from_html(response.text) product_ids = self._extract_product_ids_from_html(response.text)
logger.info(f"{url} 取得 {len(product_ids)} 個商品 ID") logger.info(f"{url} 取得 {len(product_ids)} 個商品 ID")
if not product_ids:
self._emit_visual_evidence_action(
url=url,
crawler="PChomeCrawler.fetch_region_page",
failure_reason="HTML parser found no product IDs in region page",
)
return True, f"成功取得 {len(product_ids)} 個商品", product_ids return True, f"成功取得 {len(product_ids)} 個商品", product_ids
except requests.RequestException as e: except requests.RequestException as e:
logger.error(f"爬取 {url} 失敗: {e}") logger.error(f"爬取 {url} 失敗: {e}")
self._emit_visual_evidence_action(
url=url,
crawler="PChomeCrawler.fetch_region_page",
failure_reason=f"request_error: {e}",
)
return False, f"請求失敗: {str(e)}", [] return False, f"請求失敗: {str(e)}", []
def fetch_product_details(self, product_ids: List[str], batch_size: int = 20) -> Tuple[bool, str, List[PChomeProduct]]: def fetch_product_details(self, product_ids: List[str], batch_size: int = 20) -> Tuple[bool, str, List[PChomeProduct]]:
@@ -327,6 +392,14 @@ class PChomeCrawler:
message = f"成功取得 {len(all_products)} 個商品資料" message = f"成功取得 {len(all_products)} 個商品資料"
if failed_count > 0: if failed_count > 0:
message += f"{failed_count} 個失敗" message += f"{failed_count} 個失敗"
if product_ids and not all_products:
first_id = self._normalize_product_id(str(product_ids[0]))
self._emit_visual_evidence_action(
url=f"{self.BASE_URL}/prod/{first_id}",
crawler="PChomeCrawler.fetch_product_details",
failure_reason=message,
missing_fields=("title", "price", "product_id", "image_url"),
)
return len(all_products) > 0, message, all_products return len(all_products) > 0, message, all_products
@@ -487,6 +560,11 @@ class PChomeCrawler:
break break
if not product_ids: if not product_ids:
self._emit_visual_evidence_action(
url=self._public_search_url(keyword),
crawler="PChomeCrawler.search_products",
failure_reason="search API returned no product IDs",
)
return False, "沒有找到符合的商品", [] return False, "沒有找到符合的商品", []
# 取得詳細資料 # 取得詳細資料
@@ -497,6 +575,11 @@ class PChomeCrawler:
except requests.RequestException as e: except requests.RequestException as e:
logger.error(f"搜尋失敗: {e}") logger.error(f"搜尋失敗: {e}")
self._emit_visual_evidence_action(
url=self._public_search_url(keyword),
crawler="PChomeCrawler.search_products",
failure_reason=f"request_error: {e}",
)
return False, f"搜尋失敗: {str(e)}", [] return False, f"搜尋失敗: {str(e)}", []

View File

@@ -15,20 +15,116 @@ from copy import deepcopy
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Mapping from typing import Any, Mapping
from urllib.parse import urlparse from urllib.parse import quote_plus, urlparse
POLICY = "read_only_pixelrag_crawler_integration_assessment_v1" POLICY = "read_only_pixelrag_crawler_integration_assessment_v1"
MANIFEST_POLICY = "read_only_pixelrag_visual_evidence_manifest_v1" MANIFEST_POLICY = "read_only_pixelrag_visual_evidence_manifest_v1"
READBACK_POLICY = "read_only_pixelrag_visual_evidence_readback_v1" READBACK_POLICY = "read_only_pixelrag_visual_evidence_readback_v1"
ECOMMERCE_EXPANSION_POLICY = "read_only_pixelrag_ecommerce_expansion_plan_v1"
CRAWLER_FAILURE_ACTION_POLICY = "read_only_pixelrag_crawler_failure_action_v1"
RESULT_PHASE1_READY = "PIXELRAG_VISUAL_EVIDENCE_PHASE1_READY" RESULT_PHASE1_READY = "PIXELRAG_VISUAL_EVIDENCE_PHASE1_READY"
RESULT_BLOCKED_NO_CAPTURE = "PIXELRAG_BLOCKED_NO_VISUAL_CAPTURE" RESULT_BLOCKED_NO_CAPTURE = "PIXELRAG_BLOCKED_NO_VISUAL_CAPTURE"
RESULT_MANIFEST_READY = "PIXELRAG_VISUAL_EVIDENCE_MANIFEST_READY" RESULT_MANIFEST_READY = "PIXELRAG_VISUAL_EVIDENCE_MANIFEST_READY"
RESULT_MANIFEST_REJECTED = "PIXELRAG_VISUAL_EVIDENCE_MANIFEST_REJECTED" RESULT_MANIFEST_REJECTED = "PIXELRAG_VISUAL_EVIDENCE_MANIFEST_REJECTED"
ALLOWED_PLATFORMS = ("momo", "pchome", "market_intel", "external_market")
DEFAULT_VIEWPORT = {"name": "desktop-1440", "width": 1440, "height": 950} DEFAULT_VIEWPORT = {"name": "desktop-1440", "width": 1440, "height": 950}
DEFAULT_TILE_SIZE = {"width": 512, "height": 512} DEFAULT_TILE_SIZE = {"width": 512, "height": 512}
ECOMMERCE_PLATFORM_PROFILES: dict[str, dict[str, Any]] = {
"momo": {
"display_name": "MOMO",
"hosts": ["m.momoshop.com.tw", "www.momoshop.com.tw"],
"search_url_template": "https://m.momoshop.com.tw/search.momo?searchKeyword={query}",
"priority": "incumbent_structured_parser_plus_visual_fallback",
"best_for": ["rendered promo blocks", "bundle/spec cards", "parser-empty search pages"],
"data_targets": ["title", "price", "bundle_spec", "promo_badge", "image"],
},
"pchome": {
"display_name": "PChome 24h",
"hosts": ["24h.pchome.com.tw", "ecshweb.pchome.com.tw", "ecapi-cdn.pchome.com.tw"],
"search_url_template": "https://24h.pchome.com.tw/search/?q={query}",
"priority": "incumbent_api_parser_plus_visual_fallback",
"best_for": ["search result cards", "campaign badges", "rendered price cards"],
"data_targets": ["title", "price", "product_id", "campaign_badge", "stock_hint"],
},
"shopee_tw": {
"display_name": "Shopee Taiwan",
"hosts": ["shopee.tw", "mall.shopee.tw"],
"search_url_template": "https://shopee.tw/search?keyword={query}",
"priority": "P1_marketplace_expansion",
"best_for": ["JS-heavy marketplace cards", "seller badges", "shipping/promo chips"],
"data_targets": ["title", "price", "shop", "sold_count", "rating", "shipping_badge"],
},
"coupang_tw": {
"display_name": "Coupang Taiwan",
"hosts": ["www.tw.coupang.com", "tw.coupang.com"],
"search_url_template": "https://www.tw.coupang.com/search?q={query}",
"priority": "P1_marketplace_expansion",
"best_for": ["Rocket delivery badges", "discount panels", "spec tables"],
"data_targets": ["title", "price", "discount", "delivery_badge", "spec", "review_count"],
},
"yahoo_shopping_tw": {
"display_name": "Yahoo Shopping Taiwan",
"hosts": ["tw.buy.yahoo.com"],
"search_url_template": "https://tw.buy.yahoo.com/search/product?p={query}",
"priority": "P2_marketplace_expansion",
"best_for": ["search cards", "campaign coupons", "rating and sales badges"],
"data_targets": ["title", "price", "coupon", "rating", "sales_hint"],
},
"etmall_tw": {
"display_name": "ETMall",
"hosts": ["www.etmall.com.tw", "etmall.com.tw"],
"search_url_template": "https://www.etmall.com.tw/Search?keyword={query}",
"priority": "P2_marketplace_expansion",
"best_for": ["promotion blocks", "coupon and member badges"],
"data_targets": ["title", "price", "promo_badge", "member_price", "shipping_hint"],
},
"friday_tw": {
"display_name": "friDay Shopping",
"hosts": ["shopping.friday.tw", "ec-m.shopping.friday.tw", "ec-w.shopping.friday.tw"],
"search_url_template": "https://ec-m.shopping.friday.tw/search?keyword={query}",
"priority": "P3_probe_required",
"best_for": ["campaign cards", "member reward hints", "mobile rendered offers"],
"data_targets": ["title", "price", "reward_badge", "campaign_badge"],
},
"rakuten_tw": {
"display_name": "Rakuten Taiwan",
"hosts": ["www.rakuten.com.tw", "rakuten.com.tw"],
"search_url_template": "https://www.rakuten.com.tw/search/{query}/",
"priority": "P3_probe_required",
"best_for": ["storefront cards", "cross-store price comparisons", "coupon badges"],
"data_targets": ["title", "price", "shop", "coupon", "point_reward"],
},
"market_intel": {
"display_name": "Market intelligence URL",
"hosts": [],
"search_url_template": "",
"priority": "custom_url_only",
"best_for": ["controlled external market diagnostics"],
"data_targets": ["public_page_visual_evidence"],
},
"external_market": {
"display_name": "External market URL",
"hosts": [],
"search_url_template": "",
"priority": "custom_url_only",
"best_for": ["controlled public external URL diagnostics"],
"data_targets": ["public_page_visual_evidence"],
},
}
ALLOWED_PLATFORMS = tuple(ECOMMERCE_PLATFORM_PROFILES)
MARKETPLACE_EXPANSION_ORDER = (
"momo",
"pchome",
"shopee_tw",
"coupang_tw",
"yahoo_shopping_tw",
"etmall_tw",
"friday_tw",
"rakuten_tw",
)
def _default_artifact_root() -> str: def _default_artifact_root() -> str:
container_root = Path("/app/data/ai_automation") container_root = Path("/app/data/ai_automation")
if container_root.exists(): if container_root.exists():
@@ -36,7 +132,18 @@ def _default_artifact_root() -> str:
return "runtime_artifacts/pixelrag_visual_evidence" return "runtime_artifacts/pixelrag_visual_evidence"
def _default_manifest_queue_root() -> str:
container_root = Path("/app/data/ai_automation")
if container_root.exists():
return str(container_root / "pixelrag_manifest_queue")
return "runtime_artifacts/pixelrag_manifest_queue"
DEFAULT_ARTIFACT_ROOT = os.getenv("PIXELRAG_VISUAL_EVIDENCE_ROOT", _default_artifact_root()) DEFAULT_ARTIFACT_ROOT = os.getenv("PIXELRAG_VISUAL_EVIDENCE_ROOT", _default_artifact_root())
DEFAULT_MANIFEST_QUEUE_ROOT = os.getenv(
"PIXELRAG_VISUAL_EVIDENCE_MANIFEST_QUEUE_ROOT",
_default_manifest_queue_root(),
)
DEFAULT_ARTIFACT_MAX_AGE_HOURS = int(os.getenv("PIXELRAG_VISUAL_EVIDENCE_MAX_AGE_HOURS", "168")) DEFAULT_ARTIFACT_MAX_AGE_HOURS = int(os.getenv("PIXELRAG_VISUAL_EVIDENCE_MAX_AGE_HOURS", "168"))
VISUAL_FALLBACK_CONFIDENCE_TRIGGERS = { VISUAL_FALLBACK_CONFIDENCE_TRIGGERS = {
"low", "low",
@@ -188,6 +295,38 @@ def _safe_int(value: Any, default: int = 0) -> int:
return default return default
def _visual_barrier_reason(receipt: Mapping[str, Any]) -> str:
http_status = _safe_int(receipt.get("http_status"))
page_metrics = receipt.get("page_metrics") or {}
final_url = str(page_metrics.get("final_url") or "").lower()
title = str(page_metrics.get("title") or "").lower()
if http_status >= 400:
return f"http_status_{http_status}"
barrier_tokens = (
"access denied",
"verify/traffic/error",
"captcha",
"blocked",
"robot",
)
haystack = f"{final_url} {title}"
for token in barrier_tokens:
if token in haystack:
return token.replace("/", "_").replace(" ", "_")
return ""
def _platform_profile(platform: str) -> dict[str, Any] | None:
return ECOMMERCE_PLATFORM_PROFILES.get(str(platform or "").strip().lower())
def _search_url_for_platform(platform: str, keyword: str) -> str:
profile = _platform_profile(platform) or {}
template = str(profile.get("search_url_template") or "")
query = quote_plus(str(keyword or "").strip())
return template.format(query=query) if template and query else ""
def _build_phases(capabilities: Mapping[str, bool]) -> list[dict[str, Any]]: def _build_phases(capabilities: Mapping[str, bool]) -> list[dict[str, Any]]:
capture_ready = bool(capabilities["playwright_artifact_pipeline"]) capture_ready = bool(capabilities["playwright_artifact_pipeline"])
structured_ready = bool(capabilities["structured_crawler_api"]) structured_ready = bool(capabilities["structured_crawler_api"])
@@ -316,6 +455,131 @@ def should_emit_visual_evidence_fallback(
} }
def build_pixelrag_ecommerce_expansion_plan(
*,
target_platforms: tuple[str, ...] | list[str] | None = None,
) -> dict[str, Any]:
"""Return a machine-readable PixelRAG rollout plan for public commerce sites."""
requested = [
str(platform or "").strip().lower()
for platform in (target_platforms or MARKETPLACE_EXPANSION_ORDER)
if str(platform or "").strip()
]
selected = [
platform
for platform in requested
if platform in ECOMMERCE_PLATFORM_PROFILES
]
unknown = [
platform
for platform in requested
if platform not in ECOMMERCE_PLATFORM_PROFILES
]
profiles = []
for platform in selected:
profile = deepcopy(ECOMMERCE_PLATFORM_PROFILES[platform])
profile["platform"] = platform
profile["manifest_ready"] = bool(profile.get("search_url_template"))
profile["capture_scope"] = "public_read_only_no_login_no_cart"
profiles.append(profile)
return {
"success": True,
"policy": ECOMMERCE_EXPANSION_POLICY,
"generated_at": datetime.now(timezone.utc).isoformat(),
"status": "marketplace_profiles_ready",
"platform_count": len(profiles),
"unknown_platforms": unknown,
"platforms": profiles,
"advantages": [
"Covers JS-heavy cards and visual promotion badges that HTML parsers often miss.",
"Creates replayable screenshots and tiles before adding platform-specific parsers.",
"Lets AI compare public offer presentation across marketplaces without writing price truth.",
"Keeps each platform behind host allowlists and read-only artifact boundaries.",
],
"limitations": [
"Screenshots are slower and heavier than structured APIs.",
"Visual evidence is not a formal price source until OCR/VLM replay is verified.",
"Login-only, cart-only, coupon-personalized, or anti-bot-gated states are out of scope.",
"Search URL patterns still need runtime probes before production scheduling per platform.",
],
"next_actions": [
{
"priority": "P0",
"action": "capture_public_search_visual_receipts",
"target_platforms": ["shopee_tw", "coupang_tw"],
"status": "ready_for_controlled_probe",
},
{
"priority": "P1",
"action": "add_visual_card_extraction_replay",
"target_fields": ["title", "price", "promo_badge", "shop", "rating"],
"status": "requires_receipt_samples",
},
{
"priority": "P2",
"action": "promote_stable_platform_parser",
"status": "only_after_replay_beats_visual_only_diagnostics",
},
],
"controlled_apply": _controlled_apply_boundary(),
}
def build_pixelrag_marketplace_search_manifest(
*,
platform: str,
keyword: str,
crawler: str = "PixelRAGMarketplaceSearch.visual_fallback",
trigger_reason: str = "marketplace_search_visual_scan",
evidence_intent: str = "collect_public_marketplace_offer_cards",
viewport: Mapping[str, Any] | None = None,
page_size: Mapping[str, Any] | None = None,
tile_size: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Build a visual evidence manifest from a public marketplace keyword."""
platform_code = str(platform or "").strip().lower()
keyword_text = str(keyword or "").strip()
profile = _platform_profile(platform_code)
errors: list[str] = []
if not profile:
errors.append(f"platform must be one of: {', '.join(ALLOWED_PLATFORMS)}.")
if not keyword_text:
errors.append("keyword is required.")
search_url = _search_url_for_platform(platform_code, keyword_text)
if profile and not search_url:
errors.append("platform does not define a public search URL template.")
if errors:
return {
"success": False,
"policy": MANIFEST_POLICY,
"result": RESULT_MANIFEST_REJECTED,
"errors": errors,
"controlled_apply": _controlled_apply_boundary(),
}
manifest = build_pixelrag_visual_evidence_manifest(
url=search_url,
platform=platform_code,
crawler=crawler,
trigger_reason=trigger_reason,
evidence_intent=evidence_intent,
viewport=viewport,
page_size=page_size,
tile_size=tile_size,
)
if manifest.get("success"):
manifest["marketplace_profile"] = {
"platform": platform_code,
"display_name": profile.get("display_name"),
"hosts": profile.get("hosts"),
"priority": profile.get("priority"),
"data_targets": profile.get("data_targets"),
"keyword": keyword_text,
}
return manifest
def build_pixelrag_visual_evidence_manifest( def build_pixelrag_visual_evidence_manifest(
*, *,
url: str, url: str,
@@ -425,6 +689,144 @@ def build_pixelrag_visual_evidence_manifest(
} }
def persist_pixelrag_visual_evidence_manifest(
manifest: Mapping[str, Any],
*,
queue_root: str | Path | None = None,
) -> dict[str, Any]:
"""Persist a capture manifest to the local artifact queue."""
root = Path(queue_root or DEFAULT_MANIFEST_QUEUE_ROOT)
platform = str((manifest.get("capture_target") or {}).get("platform") or "unknown").strip().lower()
manifest_id = str(manifest.get("manifest_id") or "").strip()
errors: list[str] = []
if manifest.get("policy") != MANIFEST_POLICY:
errors.append(f"manifest policy must be {MANIFEST_POLICY}")
if not manifest.get("success"):
errors.append("manifest must be successful before persistence")
if not manifest_id:
errors.append("manifest_id is required")
if platform not in ALLOWED_PLATFORMS:
errors.append(f"platform must be one of: {', '.join(ALLOWED_PLATFORMS)}.")
if errors:
return {
"success": False,
"status": "rejected",
"errors": errors,
"queue_root": str(root),
"controlled_apply": {
"artifact_write": False,
"db_write": False,
"secret_read": False,
"production_price_write": False,
},
}
target_dir = root / platform
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / f"{manifest_id}.json"
temp_path = target_path.with_suffix(".json.tmp")
payload = dict(manifest)
payload["queued_at"] = datetime.now(timezone.utc).isoformat()
payload["queue_policy"] = CRAWLER_FAILURE_ACTION_POLICY
temp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temp_path.replace(target_path)
return {
"success": True,
"status": "queued",
"path": str(target_path),
"queue_root": str(root),
"controlled_apply": {
"artifact_write": True,
"db_write": False,
"secret_read": False,
"production_price_write": False,
},
}
def build_pixelrag_crawler_failure_action(
*,
url: str,
platform: str,
crawler: str,
parser_success: bool,
parsed_item_count: int = 0,
confidence_band: str = "",
missing_fields: tuple[str, ...] | list[str] | None = None,
failure_reason: str = "",
evidence_intent: str = "recover_visual_offer_evidence",
persist_manifest: bool = False,
queue_root: str | Path | None = None,
viewport: Mapping[str, Any] | None = None,
page_size: Mapping[str, Any] | None = None,
tile_size: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Turn a crawler parse failure into a queued PixelRAG capture action."""
selector = should_emit_visual_evidence_fallback(
parser_success=parser_success,
parsed_item_count=parsed_item_count,
confidence_band=confidence_band,
missing_fields=missing_fields,
failure_reason=failure_reason,
)
if not selector["should_emit"]:
return {
"success": True,
"policy": CRAWLER_FAILURE_ACTION_POLICY,
"status": "skipped_structured_evidence_sufficient",
"selector": selector,
"manifest": None,
"persistence": None,
"next_machine_action": "keep_structured_crawler_result",
"controlled_apply": _controlled_apply_boundary(),
}
manifest = build_pixelrag_visual_evidence_manifest(
url=url,
platform=platform,
crawler=crawler,
trigger_reason=selector["fallback_reason"],
evidence_intent=evidence_intent,
viewport=viewport,
page_size=page_size,
tile_size=tile_size,
)
persistence = None
if persist_manifest and manifest.get("success"):
persistence = persist_pixelrag_visual_evidence_manifest(
manifest,
queue_root=queue_root,
)
manifest_ready = bool(manifest.get("success"))
queued = bool((persistence or {}).get("success"))
return {
"success": manifest_ready and (not persist_manifest or queued),
"policy": CRAWLER_FAILURE_ACTION_POLICY,
"generated_at": datetime.now(timezone.utc).isoformat(),
"status": (
"manifest_persisted"
if queued
else ("manifest_ready" if manifest_ready else "manifest_rejected")
),
"selector": selector,
"manifest": manifest,
"persistence": persistence,
"next_machine_action": (
"run_pixelrag_visual_capture_worker"
if manifest_ready
else "fix_manifest_input"
),
"controlled_apply": {
"network_call": False,
"artifact_write": bool(queued),
"db_write": False,
"secret_read": False,
"production_price_write": False,
},
}
def build_pixelrag_crawler_integration_assessment( def build_pixelrag_crawler_integration_assessment(
*, *,
capabilities: Mapping[str, Any] | None = None, capabilities: Mapping[str, Any] | None = None,
@@ -634,7 +1036,19 @@ def build_pixelrag_visual_evidence_readback(
capture_target = receipt.get("capture_target") or {} capture_target = receipt.get("capture_target") or {}
tile_plan = receipt.get("tile_plan") or {} tile_plan = receipt.get("tile_plan") or {}
status = "ok" if receipt.get("status") == "captured" and missing_file_count == 0 and not stale else "warning" page_metrics = receipt.get("page_metrics") or {}
http_status = _safe_int(receipt.get("http_status"))
barrier_reason = _visual_barrier_reason(receipt)
status = (
"ok"
if (
receipt.get("status") == "captured"
and missing_file_count == 0
and not stale
and not barrier_reason
)
else "warning"
)
if errors: if errors:
status = "critical" status = "critical"
@@ -651,9 +1065,11 @@ def build_pixelrag_visual_evidence_readback(
"missing_file_count": missing_file_count, "missing_file_count": missing_file_count,
"planned_tile_count": _safe_int(tile_plan.get("planned_tile_count")), "planned_tile_count": _safe_int(tile_plan.get("planned_tile_count")),
"emitted_tile_count": _safe_int(tile_plan.get("emitted_tile_count")), "emitted_tile_count": _safe_int(tile_plan.get("emitted_tile_count")),
"http_status": _safe_int(receipt.get("http_status")), "http_status": http_status,
"age_hours": round(age_hours, 3) if age_hours is not None else None, "age_hours": round(age_hours, 3) if age_hours is not None else None,
"stale": stale, "stale": stale,
"visual_barrier_detected": bool(barrier_reason),
"visual_barrier_reason": barrier_reason,
"errors": errors, "errors": errors,
}, },
"latest_receipt": { "latest_receipt": {
@@ -661,14 +1077,18 @@ def build_pixelrag_visual_evidence_readback(
"generated_at": receipt.get("generated_at"), "generated_at": receipt.get("generated_at"),
"capture_status": receipt.get("status"), "capture_status": receipt.get("status"),
"capture_target": capture_target, "capture_target": capture_target,
"page_metrics": receipt.get("page_metrics") or {}, "page_metrics": page_metrics,
"tile_plan": tile_plan, "tile_plan": tile_plan,
"files": file_statuses, "files": file_statuses,
}, },
"next_machine_action": ( "next_machine_action": (
"keep_pixelrag_visual_evidence_receipt" "keep_pixelrag_visual_evidence_receipt"
if status == "ok" if status == "ok"
else (
"run_platform_probe_or_use_structured_api"
if barrier_reason
else "run_pixelrag_visual_capture_worker" else "run_pixelrag_visual_capture_worker"
)
), ),
"controlled_apply": { "controlled_apply": {
"network_call": False, "network_call": False,
@@ -680,6 +1100,9 @@ def build_pixelrag_visual_evidence_readback(
__all__ = [ __all__ = [
"CRAWLER_FAILURE_ACTION_POLICY",
"ECOMMERCE_EXPANSION_POLICY",
"ECOMMERCE_PLATFORM_PROFILES",
"MANIFEST_POLICY", "MANIFEST_POLICY",
"POLICY", "POLICY",
"READBACK_POLICY", "READBACK_POLICY",
@@ -688,7 +1111,11 @@ __all__ = [
"RESULT_MANIFEST_READY", "RESULT_MANIFEST_READY",
"RESULT_MANIFEST_REJECTED", "RESULT_MANIFEST_REJECTED",
"build_pixelrag_crawler_integration_assessment", "build_pixelrag_crawler_integration_assessment",
"build_pixelrag_crawler_failure_action",
"build_pixelrag_ecommerce_expansion_plan",
"build_pixelrag_marketplace_search_manifest",
"build_pixelrag_visual_evidence_readback", "build_pixelrag_visual_evidence_readback",
"build_pixelrag_visual_evidence_manifest", "build_pixelrag_visual_evidence_manifest",
"persist_pixelrag_visual_evidence_manifest",
"should_emit_visual_evidence_fallback", "should_emit_visual_evidence_fallback",
] ]

View File

@@ -0,0 +1,73 @@
import json
import subprocess
import sys
def test_external_mcp_rag_readback_reports_partial_truth():
from services.external_mcp_rag_integration_service import (
POLICY,
build_external_mcp_rag_integration_readback,
)
payload = build_external_mcp_rag_integration_readback()
inventory = {item["id"]: item for item in payload["inventory"]}
assert payload["policy"] == POLICY
assert payload["status"] == "partially_integrated"
assert payload["completion"]["all_absorbed"] is False
assert payload["completion"]["unresolved_count"] >= 1
assert "不是全部完成" in payload["answer_to_owner"]
assert inventory["rag.pixelrag.visual_evidence"]["status"] == "integrated_phase1_visual_receipts"
assert inventory["rag.qwen3_vl_embedding"]["status"] == "deferred_until_ollama_first_verified"
assert inventory["rag.faiss_visual_index"]["status"] == "rejected_for_production_without_adr"
assert inventory["mcp.gemini_grounding"]["status"] == "disabled_by_default_fallback_only"
assert payload["controlled_apply"]["db_write"] is False
assert payload["controlled_apply"]["network_call"] is False
def test_external_mcp_rag_readback_can_filter_family():
from services.external_mcp_rag_integration_service import (
build_external_mcp_rag_integration_readback,
)
payload = build_external_mcp_rag_integration_readback(target_family="external_rag")
assert payload["completion"]["total_capabilities"] == 3
assert {item["family"] for item in payload["inventory"]} == {"external_rag"}
def test_external_mcp_rag_cli_outputs_machine_readable_json():
completed = subprocess.run(
[
sys.executable,
"scripts/ops/report_external_mcp_rag_integration.py",
"--family",
"external_rag",
],
capture_output=True,
check=False,
text=True,
)
assert completed.returncode == 0
payload = json.loads(completed.stdout)
assert payload["success"] is True
assert payload["completion"]["total_capabilities"] == 3
assert payload["inventory"][0]["family"] == "external_rag"
def test_external_mcp_rag_ai_automation_route_returns_readback():
from flask import Flask
from routes import system_public_routes as routes
app = Flask(__name__)
with app.test_request_context(
"/api/ai-automation/external-mcp-rag-integration?family=external_mcp"
):
response = routes.ai_automation_external_mcp_rag_integration_api.__wrapped__()
payload = response.get_json()
assert payload["policy"] == "read_only_external_mcp_rag_integration_readback_v1"
assert payload["success"] is True
assert {item["family"] for item in payload["inventory"]} == {"external_mcp"}
assert payload["runtime"]["mcp"]["caller_count"] >= 1

View File

@@ -69,6 +69,32 @@ def test_pchome_search_scans_multiple_pages_until_limit(monkeypatch):
assert [product.product_id for product in products] == ["A001", "A002", "A003"] assert [product.product_id for product in products] == ["A001", "A002", "A003"]
def test_pchome_search_auto_queues_pixelrag_manifest_on_empty_result(monkeypatch, tmp_path):
from services import pixelrag_crawler_integration_service as pixelrag
from services.pchome_crawler import PChomeCrawler
monkeypatch.setattr(pixelrag, "DEFAULT_MANIFEST_QUEUE_ROOT", str(tmp_path))
crawler = PChomeCrawler(timeout=1, delay=0, max_retries=0)
class FakeSession:
headers = {}
def get(self, url, params=None, timeout=None):
return _FakeResponse({"Prods": []})
crawler.session = FakeSession()
success, message, products = crawler.search_products("找不到的商品", limit=3, max_pages=1)
assert success is False
assert message == "沒有找到符合的商品"
assert products == []
action = crawler.last_visual_evidence_action
assert action["status"] == "manifest_persisted"
assert action["manifest"]["capture_target"]["crawler"] == "PChomeCrawler.search_products"
assert action["persistence"]["path"].startswith(str(tmp_path))
def test_pchome_get_retries_transient_timeout(): def test_pchome_get_retries_transient_timeout():
from services.pchome_crawler import PChomeCrawler from services.pchome_crawler import PChomeCrawler

View File

@@ -124,6 +124,67 @@ def test_visual_fallback_selector_routes_parser_empty_and_low_confidence_cases()
assert clean_parser["fallback_reason"] == "structured_evidence_sufficient" assert clean_parser["fallback_reason"] == "structured_evidence_sufficient"
def test_pixelrag_ecommerce_expansion_plan_includes_shopee_and_coupang():
from services.pixelrag_crawler_integration_service import (
ECOMMERCE_EXPANSION_POLICY,
build_pixelrag_ecommerce_expansion_plan,
)
payload = build_pixelrag_ecommerce_expansion_plan()
platforms = {item["platform"]: item for item in payload["platforms"]}
assert payload["policy"] == ECOMMERCE_EXPANSION_POLICY
assert payload["status"] == "marketplace_profiles_ready"
assert platforms["shopee_tw"]["manifest_ready"] is True
assert platforms["coupang_tw"]["manifest_ready"] is True
assert "price" in platforms["shopee_tw"]["data_targets"]
assert payload["next_actions"][0]["target_platforms"] == ["shopee_tw", "coupang_tw"]
assert payload["controlled_apply"]["production_price_write"] is False
def test_marketplace_search_manifest_builds_shopee_visual_capture_target():
from services.pixelrag_crawler_integration_service import build_pixelrag_marketplace_search_manifest
payload = build_pixelrag_marketplace_search_manifest(
platform="shopee_tw",
keyword="防曬乳",
page_size={"width": 1440, "height": 1024},
tile_size={"width": 512, "height": 512},
)
assert payload["success"] is True
assert payload["capture_target"]["platform"] == "shopee_tw"
assert payload["capture_target"]["url"].startswith("https://shopee.tw/search?keyword=")
assert "%E9%98%B2%E6%9B%AC%E4%B9%B3" in payload["capture_target"]["url"]
assert payload["marketplace_profile"]["display_name"] == "Shopee Taiwan"
assert payload["tile_plan"]["tile_count"] == 6
def test_crawler_failure_action_persists_manifest_queue(tmp_path):
from services.pixelrag_crawler_integration_service import build_pixelrag_crawler_failure_action
payload = build_pixelrag_crawler_failure_action(
url="https://24h.pchome.com.tw/search/?q=test",
platform="pchome",
crawler="PChomeCrawler.search_products",
parser_success=False,
parsed_item_count=0,
missing_fields=["price", "product_id"],
failure_reason="search API returned no product IDs",
persist_manifest=True,
queue_root=tmp_path,
)
assert payload["success"] is True
assert payload["status"] == "manifest_persisted"
assert payload["next_machine_action"] == "run_pixelrag_visual_capture_worker"
assert payload["controlled_apply"]["artifact_write"] is True
manifest_path = payload["persistence"]["path"]
queued = json.loads(open(manifest_path, encoding="utf-8").read())
assert queued["capture_target"]["platform"] == "pchome"
assert queued["queue_policy"] == "read_only_pixelrag_crawler_failure_action_v1"
def test_visual_evidence_manifest_builds_tile_plan_without_runtime_writes(): def test_visual_evidence_manifest_builds_tile_plan_without_runtime_writes():
from services.pixelrag_crawler_integration_service import ( from services.pixelrag_crawler_integration_service import (
RESULT_MANIFEST_READY, RESULT_MANIFEST_READY,
@@ -226,6 +287,17 @@ def test_pixelrag_ai_automation_route_returns_assessment_and_manifest():
): ):
manifest_response = routes.ai_automation_pixelrag_crawler_integration_api.__wrapped__() manifest_response = routes.ai_automation_pixelrag_crawler_integration_api.__wrapped__()
manifest = manifest_response.get_json() manifest = manifest_response.get_json()
with app.test_request_context(
"/api/ai-automation/pixelrag-crawler-integration?expansion_plan=true"
):
expansion_response = routes.ai_automation_pixelrag_crawler_integration_api.__wrapped__()
expansion = expansion_response.get_json()
with app.test_request_context(
"/api/ai-automation/pixelrag-crawler-integration"
"?platform=shopee_tw&marketplace_keyword=防曬乳"
):
marketplace_response = routes.ai_automation_pixelrag_crawler_integration_api.__wrapped__()
marketplace = marketplace_response.get_json()
assert assessment["policy"] == "read_only_pixelrag_crawler_integration_assessment_v1" assert assessment["policy"] == "read_only_pixelrag_crawler_integration_assessment_v1"
assert assessment["target_platforms"] == ["momo"] assert assessment["target_platforms"] == ["momo"]
@@ -233,6 +305,10 @@ def test_pixelrag_ai_automation_route_returns_assessment_and_manifest():
assert manifest["policy"] == "read_only_pixelrag_visual_evidence_manifest_v1" assert manifest["policy"] == "read_only_pixelrag_visual_evidence_manifest_v1"
assert manifest["result"] == "PIXELRAG_VISUAL_EVIDENCE_MANIFEST_READY" assert manifest["result"] == "PIXELRAG_VISUAL_EVIDENCE_MANIFEST_READY"
assert manifest["capture_target"]["platform"] == "pchome" assert manifest["capture_target"]["platform"] == "pchome"
assert expansion["policy"] == "read_only_pixelrag_ecommerce_expansion_plan_v1"
assert expansion["next_actions"][0]["target_platforms"] == ["shopee_tw", "coupang_tw"]
assert marketplace["capture_target"]["platform"] == "shopee_tw"
assert marketplace["marketplace_profile"]["keyword"] == "防曬乳"
def _write_capture_receipt(root, *, platform="pchome", manifest_id="manifest-1"): def _write_capture_receipt(root, *, platform="pchome", manifest_id="manifest-1"):
@@ -303,6 +379,30 @@ def test_pixelrag_visual_evidence_readback_reports_latest_receipt(tmp_path):
assert payload["controlled_apply"]["db_write"] is False assert payload["controlled_apply"]["db_write"] is False
def test_pixelrag_visual_evidence_readback_warns_on_access_denied_page(tmp_path):
from services.pixelrag_crawler_integration_service import build_pixelrag_visual_evidence_readback
receipt_path = _write_capture_receipt(tmp_path, platform="coupang_tw", manifest_id="denied")
receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
receipt["http_status"] = 403
receipt["page_metrics"] = {
"final_url": "https://www.tw.coupang.com/search?q=iphone",
"title": "Access Denied",
}
receipt_path.write_text(json.dumps(receipt), encoding="utf-8")
payload = build_pixelrag_visual_evidence_readback(
artifact_root=tmp_path,
platform="coupang_tw",
manifest_id="denied",
)
assert payload["status"] == "warning"
assert payload["summary"]["visual_barrier_detected"] is True
assert payload["summary"]["visual_barrier_reason"] == "http_status_403"
assert payload["next_machine_action"] == "run_platform_probe_or_use_structured_api"
def test_pixelrag_visual_evidence_readback_warns_when_missing(tmp_path): def test_pixelrag_visual_evidence_readback_warns_when_missing(tmp_path):
from services.pixelrag_crawler_integration_service import build_pixelrag_visual_evidence_readback from services.pixelrag_crawler_integration_service import build_pixelrag_visual_evidence_readback
@@ -467,3 +567,34 @@ def test_pixelrag_python_capture_worker_dry_run_plans_artifact_outputs(tmp_path)
assert payload["controlled_apply"]["artifact_write"] is False assert payload["controlled_apply"]["artifact_write"] is False
assert payload["tile_plan"]["planned_tile_count"] == 4 assert payload["tile_plan"]["planned_tile_count"] == 4
assert payload["capture_target"]["platform"] == "pchome" assert payload["capture_target"]["platform"] == "pchome"
def test_pixelrag_python_capture_worker_accepts_shopee_manifest(tmp_path):
from services.pixelrag_crawler_integration_service import build_pixelrag_marketplace_search_manifest
manifest = build_pixelrag_marketplace_search_manifest(
platform="shopee_tw",
keyword="防曬乳",
page_size={"width": 1024, "height": 1024},
tile_size={"width": 512, "height": 512},
)
completed = subprocess.run(
[
sys.executable,
"scripts/ops/capture_pixelrag_visual_evidence.py",
"--manifest-json",
json.dumps(manifest),
"--output-dir",
str(tmp_path),
"--dry-run",
],
capture_output=True,
check=False,
text=True,
)
assert completed.returncode == 0
payload = json.loads(completed.stdout)
assert payload["status"] == "dry_run_ready"
assert payload["capture_target"]["platform"] == "shopee_tw"
assert payload["tile_plan"]["planned_tile_count"] == 4