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/ai_automation_smoke_history.jsonl
data/ai_automation/sitewide_visual_qa_latest.json
runtime_artifacts/
# 上傳檔案
web/static/uploads/

View File

@@ -26,6 +26,8 @@
- Telegram 失敗必須可暫存與 replay。
- EventRouter / AutoHeal 變更必須更新 `services/ai_automation_metrics.py` 指標或確認既有指標已覆蓋。
- 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 或查詢。
- 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 仍保持小而可讀。

View File

@@ -54,7 +54,7 @@ python scripts/tools/browse_sh_probe.py -- screenshot
## 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。
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
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 --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 \
--platform momo \
@@ -84,6 +117,9 @@ python scripts/ops/report_pixelrag_crawler_integration.py \
```text
/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-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."""
from services.pixelrag_crawler_integration_service import (
build_pixelrag_crawler_integration_assessment,
build_pixelrag_ecommerce_expansion_plan,
build_pixelrag_marketplace_search_manifest,
build_pixelrag_visual_evidence_manifest,
)
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()
if manifest_url:
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')
@login_required
def ai_automation_smoke_history_export():

View File

@@ -21,6 +21,12 @@ const DEFAULT_MAX_TILES = 80;
const ALLOWED_PLATFORM_HOSTS = {
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']),
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(),
external_market: new Set(),
};

View File

@@ -26,6 +26,12 @@ DEFAULT_MAX_TILES = 80
ALLOWED_PLATFORM_HOSTS = {
"momo": {"m.momoshop.com.tw", "www.momoshop.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(),
"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
build_pixelrag_crawler_integration_assessment,
build_pixelrag_ecommerce_expansion_plan,
build_pixelrag_marketplace_search_manifest,
build_pixelrag_visual_evidence_manifest,
)
@@ -62,6 +64,12 @@ def main() -> int:
help="覆寫 readiness 假設,用於模擬下一階段條件。",
)
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(
"--trigger-reason",
@@ -90,6 +98,43 @@ def main() -> int:
)
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:
platform = (args.platforms or ["momo"])[0]
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 dataclasses import dataclass, asdict
from datetime import datetime
from urllib.parse import urlencode
import requests
@@ -139,6 +140,7 @@ class PChomeCrawler:
self.session = requests.Session()
self.session.headers.update(self.DEFAULT_HEADERS)
self._last_request_time = 0
self.last_visual_evidence_action = None
def _rate_limit(self):
"""速率限制"""
@@ -188,6 +190,58 @@ class PChomeCrawler:
raise last_error
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:
"""
正規化商品 ID 格式
@@ -248,11 +302,22 @@ class PChomeCrawler:
product_ids = self._extract_product_ids_from_html(response.text)
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
except requests.RequestException as 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)}", []
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)} 個商品資料"
if failed_count > 0:
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
@@ -487,6 +560,11 @@ class PChomeCrawler:
break
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, "沒有找到符合的商品", []
# 取得詳細資料
@@ -497,6 +575,11 @@ class PChomeCrawler:
except requests.RequestException as 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)}", []

View File

@@ -15,20 +15,116 @@ from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
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"
MANIFEST_POLICY = "read_only_pixelrag_visual_evidence_manifest_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_BLOCKED_NO_CAPTURE = "PIXELRAG_BLOCKED_NO_VISUAL_CAPTURE"
RESULT_MANIFEST_READY = "PIXELRAG_VISUAL_EVIDENCE_MANIFEST_READY"
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_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:
container_root = Path("/app/data/ai_automation")
if container_root.exists():
@@ -36,7 +132,18 @@ def _default_artifact_root() -> str:
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_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"))
VISUAL_FALLBACK_CONFIDENCE_TRIGGERS = {
"low",
@@ -188,6 +295,38 @@ def _safe_int(value: Any, default: int = 0) -> int:
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]]:
capture_ready = bool(capabilities["playwright_artifact_pipeline"])
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(
*,
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(
*,
capabilities: Mapping[str, Any] | None = None,
@@ -634,7 +1036,19 @@ def build_pixelrag_visual_evidence_readback(
capture_target = receipt.get("capture_target") 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:
status = "critical"
@@ -651,9 +1065,11 @@ def build_pixelrag_visual_evidence_readback(
"missing_file_count": missing_file_count,
"planned_tile_count": _safe_int(tile_plan.get("planned_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,
"stale": stale,
"visual_barrier_detected": bool(barrier_reason),
"visual_barrier_reason": barrier_reason,
"errors": errors,
},
"latest_receipt": {
@@ -661,14 +1077,18 @@ def build_pixelrag_visual_evidence_readback(
"generated_at": receipt.get("generated_at"),
"capture_status": receipt.get("status"),
"capture_target": capture_target,
"page_metrics": receipt.get("page_metrics") or {},
"page_metrics": page_metrics,
"tile_plan": tile_plan,
"files": file_statuses,
},
"next_machine_action": (
"keep_pixelrag_visual_evidence_receipt"
if status == "ok"
else "run_pixelrag_visual_capture_worker"
else (
"run_platform_probe_or_use_structured_api"
if barrier_reason
else "run_pixelrag_visual_capture_worker"
)
),
"controlled_apply": {
"network_call": False,
@@ -680,6 +1100,9 @@ def build_pixelrag_visual_evidence_readback(
__all__ = [
"CRAWLER_FAILURE_ACTION_POLICY",
"ECOMMERCE_EXPANSION_POLICY",
"ECOMMERCE_PLATFORM_PROFILES",
"MANIFEST_POLICY",
"POLICY",
"READBACK_POLICY",
@@ -688,7 +1111,11 @@ __all__ = [
"RESULT_MANIFEST_READY",
"RESULT_MANIFEST_REJECTED",
"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_manifest",
"persist_pixelrag_visual_evidence_manifest",
"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"]
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():
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"
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():
from services.pixelrag_crawler_integration_service import (
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 = 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["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["result"] == "PIXELRAG_VISUAL_EVIDENCE_MANIFEST_READY"
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"):
@@ -303,6 +379,30 @@ def test_pixelrag_visual_evidence_readback_reports_latest_receipt(tmp_path):
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):
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["tile_plan"]["planned_tile_count"] == 4
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