diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 566b88d..1fd171e 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -9,7 +9,7 @@ python scripts/ops/check_production_version_truth.py 目前最新版本仍以 production `https://mo.wooo.work/health` readback 為準。 -本輪 source target 為 `V10.748`;部署完成前不得宣稱正式環境已是 `V10.748`。 +本輪 source target 為 `V10.749`;部署完成前不得宣稱正式環境已是 `V10.749`。 舊 iCloud checkout 不是 Gitea dev worktree,不得拿來當最新版本真相。 ================================================================================ @@ -60,8 +60,9 @@ P0-2026-07-09. PixelRAG / MCP / RAG 全自動主線 - 已完成:多電商 PixelRAG visual evidence lane 與 external MCP/RAG integration readback。 - 已完成:PixelRAG receipt → internal RAG candidate replay,以及 OCR/VLM replay contract no-write readback。 - 已完成:PixelRAG application portfolio,把可整合/可運用場景整理成 API/CLI 可讀的 priority lane、status、next machine action 與 forbidden guardrail。 + - 已完成:PixelRAG Ollama-first VLM replay worker,提供 dry-run/execute、artifact receipt、blocked page guard、confidence/evidence validation,且 DB write=0、primary human gate=0。 - 進行中:MCP/RAG runtime health → AI automation smoke。 - - 未開始:Ollama-first OCR/VLM extraction worker、Ollama-first visual embedding benchmark、pgvector-compatible visual metadata、Coupang platform probe / structured API、跨平台 source contracts。 + - 未開始:Ollama-first visual embedding benchmark、pgvector-compatible visual metadata、Coupang platform probe / structured API、跨平台 source contracts。 - 主線文件:`docs/guides/ai_automation_mainline_work_items.md`。 ================================================================================ diff --git a/config.py b/config.py index 05c9347..982dab1 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.748" +SYSTEM_VERSION = "V10.749" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index 81ba400..80aee45 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -116,6 +116,7 @@ - 2026-07-09 起 PixelRAG visual receipts 必須可轉為內部 RAG candidate replay readback:`/api/ai-automation/pixelrag-rag-candidate-replay` 與 `scripts/ops/report_pixelrag_rag_candidate_replay.py` 只讀 `capture_receipt.json`,拆分 eligible / blocked / invalid receipts,輸出 tile/missing/barrier count、candidate text、promotion boundary 與 next machine action;`/api/ai-automation/smoke` 需包含 `PixelRAG RAG candidate replay`,`/api/ai-automation/scheduled-health-summary` 需輸出 `pixelrag_rag_candidate_replay` family。blocked / 403 / captcha / access denied / verify traffic page 只能進 platform probe 或 structured API 策略,不得當作商品資料、不得寫正式價格表、不得直接寫 `ai_insights`。 - 2026-07-09 起 PixelRAG visual receipts 進入 OCR/VLM 前必須先輸出 no-write replay contract:`/api/ai-automation/pixelrag-ocr-vlm-replay` 與 `scripts/ops/report_pixelrag_ocr_vlm_replay.py` 只讀 saved tiles,輸出欄位 schema、輸出 schema、confidence/evidence validation rules、Ollama-first route contract 與 next machine action;`/api/ai-automation/smoke` 需包含 `PixelRAG OCR/VLM replay contract`,`/api/ai-automation/scheduled-health-summary` 需輸出 `pixelrag_ocr_vlm_replay` family。此 contract 不執行 OCR/VLM、不呼叫模型、不讀 secret、不連外、不寫 DB、不寫 `ai_insights`、不寫正式價格表;blocked / 403 / captcha / access denied / verify traffic page 只能進 platform probe 或 structured API 策略。 - 2026-07-09 起 PixelRAG 可整合/可運用範圍必須有機器可讀 application portfolio:`/api/ai-automation/pixelrag-application-portfolio` 與 `scripts/ops/report_pixelrag_application_portfolio.py` 需輸出 commerce、RAG、UX、ops、marketing、governance lanes,每條 lane 必須包含 priority、status、integrates_with、use_cases、current_capability、next_machine_action、no-write 邊界與 forbidden guardrails。此 portfolio 不抓外站、不呼叫模型、不讀 secret、不寫 DB;其用途是把「還可以整合哪些」變成可排程、可驗證、可拒絕違規場景的主線工作項目。 +- 2026-07-09 起 PixelRAG ready receipts 必須有 Ollama-first VLM replay worker:`/api/ai-automation/pixelrag-vlm-replay-worker` 與 `scripts/ops/run_pixelrag_vlm_replay_worker.py` 預設 dry-run,`execute=true&write_receipt=true` 才呼叫 approved Ollama VLM route 並寫 artifact receipt;`/api/ai-automation/smoke` 需包含 `PixelRAG VLM replay worker`,`/api/ai-automation/scheduled-health-summary` 需輸出 `pixelrag_vlm_replay_worker` family。此 worker 不讀 secret、不寫 DB、不寫 `ai_insights`、不寫正式價格表;blocked / 403 / captcha / access denied receipt 自動跳 platform probe 或 structured API,ready receipt 的 VLM 結果仍須 identity matcher replay 與 PromotionGate 才能進候選知識層。 - 2026-07-02 起 `/ai_intelligence` 商品明細與單品作戰詳情的四格價格證據必須可測:PChome 價格、MOMO 參考價、差距、可信度需以 `data-evidence` 固定,並以 `aria-label="價格證據"` 對應可掃描區塊;候選待確認或缺資料只能顯示「候選待確認 / 待補」,不得捏造價格或讓使用者打開 raw payload 才知道判斷依據。 - 2026-07-02 起 `/ai_intelligence` 必須是密集 AI 工作台,不得退回大段文字說明頁:首屏與明細可見內容只保留短狀態、數字、四格證據與下一步按鈕;KPI note、benchmark detail、alert 副句、策略說明、decision copy、來源長句與單品 reason list 不得佔用第一層視覺。`tests/test_ai_intelligence_text_density_guardrails.py` 必須鎖住 `data-density-guardrail="compact-ai-workbench"`、短任務文案、detail meta 與 hidden explanatory copy。 - 2026-07-02 起 `/observability/overview` 也必須採密集 AI 觀測工作台:首屏以 `data-density-guardrail="compact-observability-workbench"`、`AI 觀測 / 風險優先 / 下一步` 與 golden signals 先呈現狀態、數字與操作入口;hero lede、signal note、route desc、host meta 與資料來源長句不得佔用第一層視覺。`tests/test_observability_text_density_guardrails.py` 必須鎖住 compact marker 與 hidden explanatory copy。 @@ -878,6 +879,7 @@ POSTGRES_HOST=momo-db | 2026-07-09 | PixelRAG / external MCP/RAG 必須有 runtime monitoring 與 candidate replay | V10.746 起 `/api/ai-automation/smoke`、`/api/ai-automation/scheduled-health-summary` 必須輸出 `External MCP/RAG integration readback` / `external_mcp_rag_integration` 與 `PixelRAG RAG candidate replay` / `pixelrag_rag_candidate_replay`;external MCP/RAG readback 回報 9 個 capability 的 absorbed / unresolved 與 `MCP_ROUTER_ENABLED`、`RAG_ENABLED` runtime flags,PixelRAG replay 只讀 visual receipt,拆分 eligible / blocked / invalid,並明確標記 blocked page 不是商品資料。此路徑不讀 secret、不呼叫外部網路、不寫 DB、不寫 `ai_insights`、不寫正式價格表;eligible receipt 仍需 OCR/VLM replay、identity matcher replay、PromotionGate 與 embedding signature guard。 | | 2026-07-09 | PixelRAG OCR/VLM replay contract 必須有 runtime monitoring | V10.747 起 `/api/ai-automation/pixelrag-ocr-vlm-replay`、`scripts/ops/report_pixelrag_ocr_vlm_replay.py`、`/api/ai-automation/smoke` 與 `/api/ai-automation/scheduled-health-summary` 必須輸出 no-write OCR/VLM replay contract / `pixelrag_ocr_vlm_replay` family;readback 只讀 saved tiles 與 RAG candidate replay,輸出 ready / blocked / invalid contracts、field schema、output schema、validation rules、Ollama-first route contract、blocked page guard 與 next machine action。此階段明確標記 `extraction_execution_performed=false`、`ocr_execution_performed=false`、`vlm_execution_performed=false`、`writes_database=false`、`writes_ai_insights=false`、`writes_price_tables=false`、`network_call=false`、`secret_read=false`、`primary_human_gate_count=0`;ready receipt 才能進下一段 `run_ollama_first_vlm_replay_worker`,blocked receipt 只能進 platform probe 或 structured API 策略。 | | 2026-07-09 | PixelRAG application portfolio 必須把可整合場景轉成主線工作項目 | V10.748 起 `/api/ai-automation/pixelrag-application-portfolio` 與 `scripts/ops/report_pixelrag_application_portfolio.py` 必須輸出 PixelRAG 在 commerce、RAG、UX、ops、marketing、governance 的可整合/可運用 lanes;每條 lane 需有 priority、status、integrates_with、use_cases、current_capability、next_machine_action、no-write 邊界與 forbidden guardrails。此 readback 依據 PixelRAG visual-RAG pattern、Google Merchant product data、Google Product structured data 與 Baymard product list UX 轉成內部工作項目;它不抓外站、不呼叫模型、不讀 secret、不寫 DB、不把像素結果當正式價格。 | +| 2026-07-09 | PixelRAG Ollama-first VLM replay worker 必須有 runtime monitoring | V10.749 起 `/api/ai-automation/pixelrag-vlm-replay-worker`、`scripts/ops/run_pixelrag_vlm_replay_worker.py`、`/api/ai-automation/smoke` 與 `/api/ai-automation/scheduled-health-summary` 必須輸出 controlled VLM replay worker / `pixelrag_vlm_replay_worker` family;readback 預設 dry-run,不呼叫模型、不寫 artifact,execute 模式只讀 saved tiles、呼叫 approved Ollama VLM route、驗證 JSON field confidence/evidence refs,並只寫 artifact receipt。此 worker 明確標記 `writes_database=false`、`writes_ai_insights=false`、`writes_price_tables=false`、`secret_read=false`、`primary_human_gate_count=0`;blocked page 不得輸出商品欄位,ready VLM 結果仍需 identity matcher replay 與 PromotionGate。 | | 2026-06-29 | PChome DB apply 授權 lane 必須先通過 no-write guard / decision preflight / decision closeout / issuer gate / signing-decision preflight / signing-decision closeout / signing-issuer guard | V10.725 的 PChome mapping backlog auto-policy 已新增 `/api/ai/pchome-growth/mapping-backlog/auto-policy-db-apply-authorization-lane-guard`、`/api/ai/pchome-growth/mapping-backlog/auto-policy-db-apply-authorization-decision-preflight`、`/api/ai/pchome-growth/mapping-backlog/auto-policy-db-apply-authorization-decision-closeout`、`/api/ai/pchome-growth/mapping-backlog/auto-policy-db-apply-authorization-issuer-gate`、`/api/ai/pchome-growth/mapping-backlog/auto-policy-db-apply-authorization-signing-decision-preflight`、`/api/ai/pchome-growth/mapping-backlog/auto-policy-db-apply-authorization-signing-decision-closeout` 與 `/api/ai/pchome-growth/mapping-backlog/auto-policy-db-apply-authorization-signing-issuer-guard`;這些 endpoint 只驗證 final exact request package、same-run production truth requirement、secret rejection、rollback boundary、lane entry requirements、decision input requirements、rejection policy、post-apply verifier、future authorization decision package、final nonsecret authorization envelope、signing decision preflight inputs、unsigned signing decision package 與 signable request boundary,不讀 secret、不執行 shell/SQL、不寫 DB,也不簽發 database apply authorization。 | | 2026-06-29 | PChome DB apply 授權簽署發行者 lane 必須先產出 final signable request package | V10.725 的 PChome mapping backlog auto-policy 新增 `/api/ai/pchome-growth/mapping-backlog/auto-policy-db-apply-authorization-signing-issuer-closeout`;此 endpoint 只把 signing-issuer guard 的 signable request boundary 收斂成 final signable request package 與 closeout contract,確認 fresh production truth、post-apply verifier、migration hash、secret boundary 與 no-side-effect checks,不讀 secret、不簽發 authorization、不執行 shell/SQL、不寫 DB,也不代表正式 DB apply 已授權。 | | 2026-06-29 | PChome DB apply 授權簽署執行 lane 必須先通過 operator-held secret boundary preflight | V10.725 的 PChome mapping backlog auto-policy 新增 `/api/ai/pchome-growth/mapping-backlog/auto-policy-db-apply-authorization-signing-execution-preflight`;此 endpoint 只把 final signable request package 轉成 future signing execution preflight package、operator-held secret boundary contract、nonsecret signing inputs、command-shape preview、rollback boundary 與 abort conditions,不讀 secret、不接受 plaintext secret、不簽發 authorization、不執行 shell/SQL、不寫 DB,也不代表正式 DB apply 已授權。 | diff --git a/docs/guides/ai_automation_mainline_work_items.md b/docs/guides/ai_automation_mainline_work_items.md index b24a1dd..2292dbe 100644 --- a/docs/guides/ai_automation_mainline_work_items.md +++ b/docs/guides/ai_automation_mainline_work_items.md @@ -18,7 +18,8 @@ | Completed | External MCP/RAG capability inventory absorbed into internal governance readback | `/api/ai-automation/external-mcp-rag-integration` and `scripts/ops/report_external_mcp_rag_integration.py` expose 9 capabilities, absorbed/unresolved counts, and runtime flags. | | Completed | PixelRAG receipts to internal RAG candidate replay | `/api/ai-automation/pixelrag-rag-candidate-replay` and `scripts/ops/report_pixelrag_rag_candidate_replay.py` read receipts, split eligible vs blocked, and require OCR/VLM replay plus PromotionGate before knowledge writes. | | Completed | PixelRAG application portfolio and integration lanes | `/api/ai-automation/pixelrag-application-portfolio` and `scripts/ops/report_pixelrag_application_portfolio.py` expose commerce, RAG, UX, ops, marketing, and governance uses with priority, status, next machine action, and forbidden guardrails. | -| In progress | MCP/RAG runtime health in AI automation smoke | `/api/ai-automation/smoke` and `/api/ai-automation/scheduled-health-summary` include external MCP/RAG integration, PixelRAG RAG candidate replay, and PixelRAG OCR/VLM replay contract families. | +| Completed | PixelRAG Ollama-first VLM replay worker | `/api/ai-automation/pixelrag-vlm-replay-worker` and `scripts/ops/run_pixelrag_vlm_replay_worker.py` dry-run or execute ready visual receipts against approved Ollama VLM routes, emit evidence-bound artifact receipts, and keep blocked pages out of product data. | +| In progress | MCP/RAG runtime health in AI automation smoke | `/api/ai-automation/smoke` and `/api/ai-automation/scheduled-health-summary` include external MCP/RAG integration, PixelRAG RAG candidate replay, PixelRAG OCR/VLM replay contract, and PixelRAG VLM replay worker families. | | In progress | Formal production deploy/readback discipline | Every mainline change must update version, push Gitea main/dev, deploy to 188 without touching `momo-db`, and read back `/health` plus new endpoints. | ## P1 @@ -26,7 +27,7 @@ | Status | Work item | Evidence / next machine action | |---|---|---| | Completed | OCR/VLM replay contract for visual fields | `/api/ai-automation/pixelrag-ocr-vlm-replay` and `scripts/ops/report_pixelrag_ocr_vlm_replay.py` turn saved tiles into no-write field contracts, output schemas, validation rules, and Ollama-first worker actions. | -| Not started | Ollama-first OCR/VLM extraction worker | Execute the replay contract against approved local multimodal/OCR models and emit confidence/evidence receipts without writing formal price truth. | +| Completed | Ollama-first OCR/VLM extraction worker | `run_pixelrag_vlm_replay_worker.py --execute --write-receipt` executes the replay contract against approved Ollama multimodal routes and emits confidence/evidence artifact receipts without writing formal price truth. | | Not started | Ollama-first multimodal embedding benchmark | Verify local Qwen3-VL or equivalent visual embedding on GCP-A -> GCP-B -> 111 before any visual vector retrieval. | | Not started | pgvector-compatible visual evidence metadata | Design metadata-first retrieval without FAISS in production unless ADR approves a different store. | | Not started | Coupang platform probe / structured API strategy | Treat 403 as platform barrier; prefer public structured data or approved probe, never count blocked pages as product data. | diff --git a/docs/guides/ai_automation_session_sop.md b/docs/guides/ai_automation_session_sop.md index 459d619..4c30080 100644 --- a/docs/guides/ai_automation_session_sop.md +++ b/docs/guides/ai_automation_session_sop.md @@ -34,7 +34,9 @@ 或 `python scripts/ops/report_pixelrag_ocr_vlm_replay.py` 可讀回 ready / blocked / invalid replay contracts、field schema、validation rules 與 Ollama-first 下一步,且目前不執行 OCR/VLM、不寫正式價格。 - PixelRAG 可整合/可運用場景盤點必須確認 `/api/ai-automation/pixelrag-application-portfolio` 或 `python scripts/ops/report_pixelrag_application_portfolio.py` 可讀回 area、priority、status、use cases、next machine action 與 forbidden guardrails;不得只存在聊天結論。 -- AI automation smoke 必須包含 external MCP/RAG integration、PixelRAG RAG candidate replay 與 PixelRAG OCR/VLM replay contract family,避免 registry 已完成但 runtime flag / receipt replay / VLM contract 未完成時被誤報為全自動閉環。 +- PixelRAG VLM replay worker 必須確認 `/api/ai-automation/pixelrag-vlm-replay-worker` + 或 `python scripts/ops/run_pixelrag_vlm_replay_worker.py` 可讀回 dry-run/execute、model_call、artifact receipt、blocked/ready 分流、DB write=0 與 primary human gate=0;execute 結果仍只是 candidate evidence。 +- AI automation smoke 必須包含 external MCP/RAG integration、PixelRAG RAG candidate replay、PixelRAG OCR/VLM replay contract 與 PixelRAG VLM replay worker family,避免 registry 已完成但 runtime flag / receipt replay / VLM worker 未完成時被誤報為全自動閉環。 - 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 仍保持小而可讀。 diff --git a/docs/guides/browse_sh_crawler_playbook.md b/docs/guides/browse_sh_crawler_playbook.md index 1949117..f21b4c2 100644 --- a/docs/guides/browse_sh_crawler_playbook.md +++ b/docs/guides/browse_sh_crawler_playbook.md @@ -184,6 +184,16 @@ python scripts/ops/report_pixelrag_ocr_vlm_replay.py --platform shopee_tw --plat 此 contract 只把 saved tiles、欄位 schema、輸出 schema、confidence/evidence 規則與 Ollama-first worker 下一步打包;目前不執行 OCR/VLM、不呼叫模型、不寫 RAG、不寫 `ai_insights`、不寫正式價格表。ready receipt 進 `run_ollama_first_vlm_replay_worker`,blocked / 403 / captcha / access denied receipt 進 platform probe 或 structured API 策略。 +Ollama-first VLM replay worker: + +```bash +python scripts/ops/run_pixelrag_vlm_replay_worker.py +python scripts/ops/run_pixelrag_vlm_replay_worker.py --platform shopee_tw --platform coupang_tw +python scripts/ops/run_pixelrag_vlm_replay_worker.py --platform shopee_tw --execute --write-receipt --limit 1 +``` + +API readback: `/api/ai-automation/pixelrag-vlm-replay-worker?platform=shopee_tw`。預設為 dry-run,不呼叫模型、不寫 artifact;`execute=true&write_receipt=true` 才呼叫 Ollama VLM 並寫 artifact receipt。即使 execute,結果仍只是 candidate evidence;不得直接寫 `ai_insights`、正式價格表或競品價格歷史,且 missing confidence/evidence 會留在 replay / probe lane。 + Application portfolio: ```bash diff --git a/routes/system_public_routes.py b/routes/system_public_routes.py index f0cf283..acf99d2 100644 --- a/routes/system_public_routes.py +++ b/routes/system_public_routes.py @@ -753,6 +753,45 @@ def ai_automation_pixelrag_application_portfolio_api(): )) +@system_public_bp.route('/api/ai-automation/pixelrag-vlm-replay-worker') +@login_required +def ai_automation_pixelrag_vlm_replay_worker_api(): + """Dry-run or execute the PixelRAG Ollama-first VLM replay worker.""" + from services.pixelrag_vlm_replay_worker_service import ( + run_pixelrag_ollama_vlm_replay_worker, + ) + + platforms = tuple( + str(item or '').strip() + for item in request.args.getlist('platform') + if str(item or '').strip() + ) + execute = str(request.args.get('execute') or '').strip().lower() in { + '1', + 'true', + 'yes', + } + write_receipt = str(request.args.get('write_receipt') or '').strip().lower() in { + '1', + 'true', + 'yes', + } + max_age_hours = request.args.get('max_age_hours', 168, type=int) + limit = request.args.get('limit', 25, type=int) + tile_limit = request.args.get('tile_limit', 4, type=int) + timeout = request.args.get('timeout', 90, type=int) + return jsonify(run_pixelrag_ollama_vlm_replay_worker( + platform=platforms, + max_age_hours=max(1, min(max_age_hours or 168, 720)), + limit=max(1, min(limit or 25, 250)), + tile_limit=max(1, min(tile_limit or 4, 12)), + model=str(request.args.get('model') or '').strip() or None, + timeout=max(10, min(timeout or 90, 240)), + execute=execute, + write_receipt=bool(execute and write_receipt), + )) + + @system_public_bp.route('/api/ai-automation/external-mcp-rag-integration') @login_required def ai_automation_external_mcp_rag_integration_api(): diff --git a/scripts/ops/run_pixelrag_vlm_replay_worker.py b/scripts/ops/run_pixelrag_vlm_replay_worker.py new file mode 100755 index 0000000..5416d80 --- /dev/null +++ b/scripts/ops/run_pixelrag_vlm_replay_worker.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Run or dry-run the PixelRAG Ollama VLM replay worker.""" + +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.pixelrag_vlm_replay_worker_service import ( # noqa: E402 + DEFAULT_MODEL, + run_pixelrag_ollama_vlm_replay_worker, +) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="執行或 dry-run PixelRAG Ollama-first VLM replay worker。" + ) + parser.add_argument( + "--artifact-root", + help="PixelRAG visual evidence artifact root;預設使用 production/container 設定。", + ) + parser.add_argument( + "--output-root", + help="VLM replay artifact receipt output root。", + ) + parser.add_argument( + "--platform", + action="append", + dest="platforms", + help="限制平台,可重複指定,例如 --platform shopee_tw --platform coupang_tw。", + ) + parser.add_argument( + "--max-age-hours", + type=int, + default=168, + help="receipt 最大新鮮度小時數。", + ) + parser.add_argument( + "--limit", + type=int, + default=25, + help="最多處理 receipt 數。", + ) + parser.add_argument( + "--tile-limit", + type=int, + default=4, + help="每個 receipt 最多送入 VLM 的 tile 數。", + ) + parser.add_argument( + "--model", + default=DEFAULT_MODEL, + help="Ollama VLM model。", + ) + parser.add_argument( + "--timeout", + type=int, + default=90, + help="單次 Ollama generate timeout 秒數。", + ) + parser.add_argument( + "--execute", + action="store_true", + help="真的呼叫 Ollama VLM;未指定時只做 no-write dry-run。", + ) + parser.add_argument( + "--write-receipt", + action="store_true", + help="execute 後寫入 VLM replay artifact receipt;不寫 DB。", + ) + args = parser.parse_args() + + payload = run_pixelrag_ollama_vlm_replay_worker( + artifact_root=args.artifact_root, + output_root=args.output_root, + platform=tuple(args.platforms or ()), + max_age_hours=args.max_age_hours, + limit=args.limit, + tile_limit=args.tile_limit, + model=args.model, + timeout=args.timeout, + execute=args.execute, + write_receipt=bool(args.write_receipt and args.execute), + ) + 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()) diff --git a/services/ai_automation_smoke_service.py b/services/ai_automation_smoke_service.py index 88990f8..63db710 100644 --- a/services/ai_automation_smoke_service.py +++ b/services/ai_automation_smoke_service.py @@ -542,6 +542,11 @@ def build_scheduled_automation_health_summary( if not pixelrag_ocr_vlm_replay or not pixelrag_ocr_vlm_replay_details: pixelrag_ocr_vlm_replay = _pixelrag_ocr_vlm_replay_check() pixelrag_ocr_vlm_replay_details = pixelrag_ocr_vlm_replay.get("details") or {} + pixelrag_vlm_replay_worker = _find_check(source_result, "PixelRAG VLM replay worker") + pixelrag_vlm_replay_worker_details = pixelrag_vlm_replay_worker.get("details") or {} + if not pixelrag_vlm_replay_worker or not pixelrag_vlm_replay_worker_details: + pixelrag_vlm_replay_worker = _pixelrag_vlm_replay_worker_check() + pixelrag_vlm_replay_worker_details = pixelrag_vlm_replay_worker.get("details") or {} smoke_status = source_result.get("status") or ("warning" if latest_history else "warning") freshness_family = _history_freshness_family( latest_history, @@ -4632,6 +4637,42 @@ def build_scheduled_automation_health_summary( "primary_human_gate_count": 0, }, }, + { + "key": "pixelrag_vlm_replay_worker", + "label": "PixelRAG VLM replay worker", + "status": pixelrag_vlm_replay_worker.get("status") or "warning", + "summary": ( + pixelrag_vlm_replay_worker.get("summary") + or "PixelRAG VLM replay worker has no latest readback." + ), + "next_machine_action": pixelrag_vlm_replay_worker_details.get("next_machine_action") + or "run_pixelrag_vlm_replay_worker_dry_run", + "details": { + "policy": pixelrag_vlm_replay_worker_details.get("policy"), + "receipt_count": int(pixelrag_vlm_replay_worker_details.get("receipt_count") or 0), + "ready_count": int(pixelrag_vlm_replay_worker_details.get("ready_count") or 0), + "dry_run_count": int(pixelrag_vlm_replay_worker_details.get("dry_run_count") or 0), + "executed_count": int(pixelrag_vlm_replay_worker_details.get("executed_count") or 0), + "executed_ok_count": int(pixelrag_vlm_replay_worker_details.get("executed_ok_count") or 0), + "executed_warning_count": int( + pixelrag_vlm_replay_worker_details.get("executed_warning_count") or 0 + ), + "model_error_count": int(pixelrag_vlm_replay_worker_details.get("model_error_count") or 0), + "parse_error_count": int(pixelrag_vlm_replay_worker_details.get("parse_error_count") or 0), + "receipt_written_count": int( + pixelrag_vlm_replay_worker_details.get("receipt_written_count") or 0 + ), + "model_call_performed": bool( + pixelrag_vlm_replay_worker_details.get("model_call_performed") + ), + "artifact_write_performed": bool( + pixelrag_vlm_replay_worker_details.get("artifact_write_performed") + ), + "writes_database": False, + "writes_database_count": 0, + "primary_human_gate_count": 0, + }, + }, freshness_family, { "key": "daily_summary_delivery", @@ -13350,6 +13391,63 @@ def _pixelrag_ocr_vlm_replay_check() -> Dict[str, Any]: ) +def _pixelrag_vlm_replay_worker_check() -> Dict[str, Any]: + """Dry-run sentinel for the Ollama-first PixelRAG VLM replay worker.""" + try: + from services.pixelrag_vlm_replay_worker_service import ( + run_pixelrag_ollama_vlm_replay_worker, + ) + + readback = run_pixelrag_ollama_vlm_replay_worker(execute=False) + summary = readback.get("summary") or {} + receipt_count = int(summary.get("receipt_count") or 0) + ready_count = int(summary.get("ready_count") or 0) + dry_run_count = int(summary.get("dry_run_count") or 0) + skipped_count = int(summary.get("skipped_count") or 0) + executed_count = int(summary.get("executed_count") or 0) + status = readback.get("status") or "warning" + summary_text = ( + f"PixelRAG VLM replay worker receipts={receipt_count}, " + f"ready={ready_count}, dry_run={dry_run_count}, skipped={skipped_count}, " + f"executed={executed_count}" + ) + return _check( + "PixelRAG VLM replay worker", + status, + summary_text, + { + "policy": readback.get("policy"), + "receipt_count": receipt_count, + "ready_count": ready_count, + "skipped_count": skipped_count, + "dry_run_count": dry_run_count, + "executed_count": executed_count, + "executed_ok_count": int(summary.get("executed_ok_count") or 0), + "executed_warning_count": int(summary.get("executed_warning_count") or 0), + "model_error_count": int(summary.get("model_error_count") or 0), + "parse_error_count": int(summary.get("parse_error_count") or 0), + "receipt_written_count": int(summary.get("receipt_written_count") or 0), + "model_call_performed": bool(summary.get("model_call_performed")), + "artifact_write_performed": bool(summary.get("artifact_write_performed")), + "next_machine_action": readback.get("next_machine_action"), + "writes_database": False, + "writes_database_count": 0, + "primary_human_gate_count": 0, + }, + ) + except Exception as exc: + return _check( + "PixelRAG VLM replay worker", + "critical", + f"PixelRAG VLM replay worker 無法執行 dry-run:{exc}", + { + "writes_database": False, + "writes_database_count": 0, + "primary_human_gate_count": 0, + }, + ) + + def collect_ai_automation_smoke(*, record_history: bool = True, history_limit: int = 20) -> Dict[str, Any]: checks: List[Dict[str, Any]] = [ _event_router_check(), @@ -13386,6 +13484,7 @@ def collect_ai_automation_smoke(*, record_history: bool = True, history_limit: i _external_mcp_rag_integration_check(), _pixelrag_rag_candidate_replay_check(), _pixelrag_ocr_vlm_replay_check(), + _pixelrag_vlm_replay_worker_check(), ] worst = max(checks, key=lambda item: STATUS_RANK.get(item["status"], 2))["status"] result = { diff --git a/services/pixelrag_vlm_replay_worker_service.py b/services/pixelrag_vlm_replay_worker_service.py new file mode 100644 index 0000000..d6704b0 --- /dev/null +++ b/services/pixelrag_vlm_replay_worker_service.py @@ -0,0 +1,532 @@ +"""Ollama-first PixelRAG VLM replay worker. + +This worker executes the next machine action emitted by the PixelRAG +OCR/VLM replay contract. It reads saved screenshot tiles, calls approved +Ollama hosts, validates evidence-bound JSON fields, and optionally writes an +artifact receipt. It never writes DB rows, AI insights, or price truth. +""" + +from __future__ import annotations + +import base64 +import json +import os +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping + +from services.ollama_service import OllamaService, get_host_label, get_provider_tag +from services.pixelrag_crawler_integration_service import ( + DEFAULT_ARTIFACT_MAX_AGE_HOURS, + DEFAULT_ARTIFACT_ROOT, +) +from services.pixelrag_ocr_vlm_replay_service import ( + DEFAULT_CONFIDENCE_THRESHOLD, + build_pixelrag_ocr_vlm_replay_contract, +) + + +POLICY = "controlled_pixelrag_ollama_vlm_replay_worker_v1" +DEFAULT_LIMIT = 25 +DEFAULT_TILE_LIMIT = 4 +DEFAULT_TIMEOUT_SECONDS = 90 +DEFAULT_OUTPUT_ROOT = os.getenv( + "PIXELRAG_VLM_REPLAY_RECEIPT_ROOT", + "/app/data/ai_automation/pixelrag_vlm_replay_receipts" + if Path("/app/data").exists() + else "runtime_artifacts/pixelrag_vlm_replay_receipts", +) +DEFAULT_MODEL = ( + os.getenv("PIXELRAG_VLM_MODEL") + or os.getenv("PPT_VISION_MODEL") + or "minicpm-v:latest" +) +RAW_EXCERPT_LIMIT = 500 + + +def _normalise_platforms( + platform: str | tuple[str, ...] | list[str] | None, +) -> tuple[str, ...]: + if isinstance(platform, str): + value = platform.strip().lower() + return (value,) if value else () + return tuple( + str(item or "").strip().lower() + for item in (platform or ()) + if str(item or "").strip() + ) + + +def _safe_segment(value: Any) -> str: + text = str(value or "unknown").strip().lower() + text = re.sub(r"[^a-z0-9._-]+", "-", text) + return text.strip("-") or "unknown" + + +def _resolve_tile_path(path: str, root: Path) -> Path: + tile_path = Path(str(path or "").strip()) + if tile_path.is_absolute(): + return tile_path + return root / tile_path + + +def _tile_images(item: Mapping[str, Any], *, root: Path, tile_limit: int) -> tuple[list[str], list[dict[str, Any]]]: + images: list[str] = [] + evidence: list[dict[str, Any]] = [] + for tile in list(item.get("input_tiles") or [])[:tile_limit]: + evidence_ref = str(tile.get("evidence_ref") or "") + path = _resolve_tile_path(str(tile.get("path") or ""), root) + tile_evidence = { + "evidence_ref": evidence_ref, + "path": str(path), + "exists": path.exists(), + "loaded": False, + } + if path.exists(): + raw = path.read_bytes() + images.append(base64.b64encode(raw).decode("ascii")) + tile_evidence["loaded"] = True + tile_evidence["byte_size"] = len(raw) + evidence.append(tile_evidence) + return images, evidence + + +def _extract_json_object(content: str) -> dict[str, Any]: + text = str(content or "").strip() + if not text: + raise ValueError("empty_model_output") + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE) + text = re.sub(r"\s*```$", "", text) + try: + parsed = json.loads(text) + except json.JSONDecodeError: + start = text.find("{") + end = text.rfind("}") + if start < 0 or end <= start: + raise + parsed = json.loads(text[start:end + 1]) + if not isinstance(parsed, dict): + raise ValueError("model_output_not_json_object") + return parsed + + +def _prompt_for_item(item: Mapping[str, Any]) -> str: + field_contract = list(item.get("field_contract") or []) + compact_contract = [ + { + "field": field.get("field"), + "type": field.get("type"), + "required": bool(field.get("required")), + "min_confidence": field.get("min_confidence"), + "evidence_requirement": field.get("evidence_requirement"), + } + for field in field_contract + ] + metadata = { + "platform": item.get("platform"), + "manifest_id": item.get("manifest_id"), + "url": item.get("url"), + "title_hint": item.get("title_hint"), + "http_status": item.get("http_status"), + "field_contract": compact_contract, + "input_evidence_refs": [ + tile.get("evidence_ref") for tile in list(item.get("input_tiles") or []) + ], + } + return ( + "You are a strict public marketplace offer-card VLM extractor.\n" + "Return only valid JSON. Do not use markdown. Do not guess.\n" + "Use only visible tile evidence and cite evidence_refs like tile:1.\n" + "If the tile is access denied, captcha, login, traffic verification, or not a product/search card, " + "set blocked_page_detected=true and leave product fields empty.\n" + "Required JSON schema:\n" + "{\n" + ' "blocked_page_detected": false,\n' + ' "fields": {"field_name": {"value": null, "confidence": 0.0, "evidence_refs": []}},\n' + ' "quality": {"overall_confidence": 0.0, "missing_required_fields": [], ' + '"requires_identity_matcher_replay": true, "requires_promotion_gate": true},\n' + ' "notes": []\n' + "}\n" + "Metadata and field contract:\n" + f"{json.dumps(metadata, ensure_ascii=False, sort_keys=True)}" + ) + + +def _field_value_present(value: Any) -> bool: + if value is None: + return False + if isinstance(value, str): + return bool(value.strip()) + return True + + +def _validate_model_payload( + parsed: Mapping[str, Any], + item: Mapping[str, Any], +) -> dict[str, Any]: + fields = parsed.get("fields") if isinstance(parsed.get("fields"), Mapping) else {} + quality = parsed.get("quality") if isinstance(parsed.get("quality"), Mapping) else {} + missing_required: list[str] = [] + field_evidence_missing: list[str] = [] + low_confidence_fields: list[str] = [] + blocked_detected = bool(parsed.get("blocked_page_detected")) + + for contract in list(item.get("field_contract") or []): + field_name = str(contract.get("field") or "") + field_payload = fields.get(field_name) if isinstance(fields, Mapping) else {} + if not isinstance(field_payload, Mapping): + field_payload = {} + value = field_payload.get("value") + evidence_refs = list(field_payload.get("evidence_refs") or []) + try: + confidence = float(field_payload.get("confidence") or 0) + except (TypeError, ValueError): + confidence = 0.0 + min_confidence = float(contract.get("min_confidence") or DEFAULT_CONFIDENCE_THRESHOLD) + present = _field_value_present(value) + if present and not evidence_refs: + field_evidence_missing.append(field_name) + if present and confidence < min_confidence: + low_confidence_fields.append(field_name) + if contract.get("required") and (blocked_detected or not present or confidence < min_confidence): + missing_required.append(field_name) + + declared_missing = [ + str(field) + for field in list(quality.get("missing_required_fields") or []) + if str(field).strip() + ] + for field in declared_missing: + if field not in missing_required: + missing_required.append(field) + + return { + "blocked_page_detected": blocked_detected, + "missing_required_fields": missing_required, + "field_evidence_missing": field_evidence_missing, + "low_confidence_fields": low_confidence_fields, + "valid_for_identity_matcher_replay": ( + not blocked_detected + and not missing_required + and not field_evidence_missing + ), + "requires_identity_matcher_replay": bool( + quality.get("requires_identity_matcher_replay", True) + ), + "requires_promotion_gate": bool(quality.get("requires_promotion_gate", True)), + } + + +def _write_replay_receipt( + *, + output_root: Path, + item: Mapping[str, Any], + worker_item: Mapping[str, Any], +) -> str: + target = ( + output_root + / _safe_segment(item.get("platform")) + / _safe_segment(item.get("manifest_id")) + / "vlm_replay_receipt.json" + ) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + json.dumps(worker_item, ensure_ascii=False, indent=2, sort_keys=True), + encoding="utf-8", + ) + return str(target) + + +def _skipped_item(item: Mapping[str, Any]) -> dict[str, Any]: + return { + "platform": item.get("platform"), + "manifest_id": item.get("manifest_id"), + "source_receipt_path": item.get("source_receipt_path"), + "worker_status": "skipped_blocked_or_not_ready", + "replay_status": item.get("replay_status"), + "blocked_reasons": list(item.get("blocked_reasons") or []), + "model_call_performed": False, + "artifact_write_performed": False, + "writes_database": False, + "next_machine_action": item.get("next_machine_action") + or "run_platform_probe_or_use_structured_api", + } + + +def _dry_run_item(item: Mapping[str, Any]) -> dict[str, Any]: + return { + "platform": item.get("platform"), + "manifest_id": item.get("manifest_id"), + "source_receipt_path": item.get("source_receipt_path"), + "worker_status": "dry_run_ready", + "ready_for_execution": True, + "tile_input_count": len(list(item.get("input_tiles") or [])), + "field_contract_count": int(item.get("field_contract_count") or 0), + "model_call_performed": False, + "artifact_write_performed": False, + "writes_database": False, + "next_machine_action": "run_pixelrag_vlm_replay_worker_execute", + } + + +def _execute_item( + item: Mapping[str, Any], + *, + root: Path, + output_root: Path, + model: str, + timeout: int, + tile_limit: int, + write_receipt: bool, +) -> dict[str, Any]: + images, tile_evidence = _tile_images(item, root=root, tile_limit=tile_limit) + base: dict[str, Any] = { + "platform": item.get("platform"), + "manifest_id": item.get("manifest_id"), + "source_receipt_path": item.get("source_receipt_path"), + "worker_status": "executing", + "model": model, + "tile_evidence": tile_evidence, + "tile_image_count": len(images), + "model_call_performed": bool(images), + "artifact_write_performed": False, + "writes_database": False, + } + if not images: + base.update({ + "worker_status": "skipped_no_loadable_tiles", + "next_machine_action": "refresh_pixelrag_visual_capture_receipt", + }) + return base + + response = OllamaService(model=model).generate( + _prompt_for_item(item), + model=model, + temperature=0.1, + timeout=max(10, int(timeout or DEFAULT_TIMEOUT_SECONDS)), + options={"num_predict": 700, "num_ctx": 4096}, + images=images, + ) + base.update({ + "host": response.host, + "host_label": get_host_label(response.host or ""), + "provider": get_provider_tag(response.host or ""), + "actual_model": response.model, + "input_tokens": int(response.input_tokens or 0), + "output_tokens": int(response.output_tokens or 0), + "total_duration": response.total_duration, + }) + if not response.success: + base.update({ + "worker_status": "model_error", + "model_error": str(response.error or "")[:RAW_EXCERPT_LIMIT], + "next_machine_action": "repair_ollama_vlm_runtime_or_model_route", + }) + return base + + try: + parsed = _extract_json_object(response.content) + except Exception as exc: + base.update({ + "worker_status": "model_output_parse_error", + "parse_error": str(exc)[:RAW_EXCERPT_LIMIT], + "raw_model_output_excerpt": str(response.content or "")[:RAW_EXCERPT_LIMIT], + "next_machine_action": "tighten_pixelrag_vlm_prompt_or_model", + }) + if write_receipt: + base["receipt_path"] = _write_replay_receipt( + output_root=output_root, + item=item, + worker_item=base, + ) + base["artifact_write_performed"] = True + return base + + validation = _validate_model_payload(parsed, item) + missing_required = list(validation.get("missing_required_fields") or []) + evidence_missing = list(validation.get("field_evidence_missing") or []) + blocked_detected = bool(validation.get("blocked_page_detected")) + status = "executed_ok" + next_action = "run_identity_matcher_replay_then_promotion_gate" + if blocked_detected: + status = "executed_warning" + next_action = "run_platform_probe_or_use_structured_api" + elif missing_required or evidence_missing: + status = "executed_warning" + next_action = "rerun_vlm_replay_with_more_tiles_or_ocr" + + base.update({ + "worker_status": status, + "parsed_output": parsed, + "validation": validation, + "required_field_missing_count": len(missing_required), + "field_evidence_missing_count": len(evidence_missing), + "next_machine_action": next_action, + }) + if write_receipt: + base["receipt_path"] = _write_replay_receipt( + output_root=output_root, + item=item, + worker_item=base, + ) + base["artifact_write_performed"] = True + return base + + +def run_pixelrag_ollama_vlm_replay_worker( + *, + artifact_root: str | Path | None = None, + output_root: str | Path | None = None, + platform: str | tuple[str, ...] | list[str] | None = None, + max_age_hours: int | None = None, + limit: int | None = None, + tile_limit: int | None = None, + model: str | None = None, + timeout: int | None = None, + execute: bool = False, + write_receipt: bool = False, +) -> dict[str, Any]: + """Run or dry-run the PixelRAG VLM replay worker.""" + root = Path(artifact_root or DEFAULT_ARTIFACT_ROOT) + output = Path(output_root or DEFAULT_OUTPUT_ROOT) + platforms = _normalise_platforms(platform) + max_age = max(1, int(max_age_hours or DEFAULT_ARTIFACT_MAX_AGE_HOURS)) + item_limit = max(1, min(int(limit or DEFAULT_LIMIT), 250)) + tiles = max(1, min(int(tile_limit or DEFAULT_TILE_LIMIT), 12)) + selected_model = str(model or DEFAULT_MODEL) + selected_timeout = max(10, int(timeout or DEFAULT_TIMEOUT_SECONDS)) + generated_at = datetime.now(timezone.utc).isoformat() + + contract = build_pixelrag_ocr_vlm_replay_contract( + artifact_root=root, + platform=platforms, + max_age_hours=max_age, + limit=item_limit, + ) + replay_items = list(contract.get("replay_items") or []) + worker_items: list[dict[str, Any]] = [] + for item in replay_items: + if not item.get("ready_for_ollama_vlm_worker"): + worker_items.append(_skipped_item(item)) + continue + if not execute: + worker_items.append(_dry_run_item(item)) + continue + worker_items.append(_execute_item( + item, + root=root, + output_root=output, + model=selected_model, + timeout=selected_timeout, + tile_limit=tiles, + write_receipt=write_receipt, + )) + + ready_count = sum(1 for item in replay_items if item.get("ready_for_ollama_vlm_worker")) + skipped_count = sum(1 for item in worker_items if item.get("worker_status") == "skipped_blocked_or_not_ready") + dry_run_count = sum(1 for item in worker_items if item.get("worker_status") == "dry_run_ready") + executed_count = sum(1 for item in worker_items if str(item.get("worker_status") or "").startswith("executed_")) + executed_ok_count = sum(1 for item in worker_items if item.get("worker_status") == "executed_ok") + executed_warning_count = sum(1 for item in worker_items if item.get("worker_status") == "executed_warning") + model_error_count = sum(1 for item in worker_items if item.get("worker_status") == "model_error") + parse_error_count = sum(1 for item in worker_items if item.get("worker_status") == "model_output_parse_error") + no_tile_count = sum(1 for item in worker_items if item.get("worker_status") == "skipped_no_loadable_tiles") + receipt_written_count = sum(1 for item in worker_items if item.get("receipt_path")) + required_missing_count = sum( + int(item.get("required_field_missing_count") or 0) + for item in worker_items + ) + model_call_performed = any(bool(item.get("model_call_performed")) for item in worker_items) + artifact_write_performed = any(bool(item.get("artifact_write_performed")) for item in worker_items) + + if parse_error_count or model_error_count or no_tile_count: + status = "critical" if ready_count and executed_ok_count == 0 and execute else "warning" + elif executed_warning_count or skipped_count or dry_run_count or (not replay_items): + status = "warning" + else: + status = "ok" + + if not replay_items: + next_action = "run_pixelrag_visual_capture_worker" + elif not execute and ready_count: + next_action = "run_pixelrag_vlm_replay_worker_execute" + elif model_error_count or parse_error_count: + next_action = "repair_ollama_vlm_runtime_or_model_route" + elif executed_warning_count: + next_action = "rerun_vlm_replay_with_more_tiles_or_platform_probe" + elif executed_ok_count: + next_action = "run_identity_matcher_replay_then_promotion_gate" + else: + next_action = "run_platform_probe_or_use_structured_api" + + summary = { + "receipt_count": len(replay_items), + "ready_count": ready_count, + "skipped_count": skipped_count, + "dry_run_count": dry_run_count, + "executed_count": executed_count, + "executed_ok_count": executed_ok_count, + "executed_warning_count": executed_warning_count, + "model_error_count": model_error_count, + "parse_error_count": parse_error_count, + "no_tile_count": no_tile_count, + "receipt_written_count": receipt_written_count, + "required_field_missing_count": required_missing_count, + "model_call_performed": model_call_performed, + "artifact_write_performed": artifact_write_performed, + "writes_database_count": 0, + "primary_human_gate_count": 0, + "platforms": sorted({str(item.get("platform") or "unknown") for item in replay_items}), + } + return { + "success": status != "critical", + "policy": POLICY, + "status": status, + "generated_at": generated_at, + "artifact_root": str(root), + "output_root": str(output), + "platform_filter": list(platforms), + "max_age_hours": max_age, + "limit": item_limit, + "tile_limit": tiles, + "model": selected_model, + "timeout_seconds": selected_timeout, + "execute": bool(execute), + "write_receipt": bool(write_receipt), + "summary": summary, + "worker_items": worker_items, + "source_contract": { + "policy": contract.get("policy"), + "status": contract.get("status"), + "summary": contract.get("summary"), + "next_machine_action": contract.get("next_machine_action"), + }, + "controlled_apply": { + "network_call": bool(execute and model_call_performed), + "model_call": bool(execute and model_call_performed), + "artifact_write": artifact_write_performed, + "db_write": False, + "writes_database": False, + "writes_database_count": 0, + "secret_read": False, + "production_price_write": False, + "primary_human_gate_count": 0, + }, + "promotion_boundary": { + "writes_ai_insights": False, + "writes_price_tables": False, + "requires_identity_matcher_replay": True, + "requires_promotion_gate": True, + "visual_fields_are_candidate_evidence_only": True, + }, + "next_machine_action": next_action, + } + + +__all__ = [ + "DEFAULT_MODEL", + "POLICY", + "run_pixelrag_ollama_vlm_replay_worker", +] diff --git a/tests/test_ai_automation_smoke_service.py b/tests/test_ai_automation_smoke_service.py index c3fcc76..e203e27 100644 --- a/tests/test_ai_automation_smoke_service.py +++ b/tests/test_ai_automation_smoke_service.py @@ -1434,11 +1434,12 @@ def test_collect_ai_automation_smoke_uses_worst_status(monkeypatch): monkeypatch.setattr(smoke, "_external_mcp_rag_integration_check", lambda: smoke._check("external mcp rag", "ok", "ok")) monkeypatch.setattr(smoke, "_pixelrag_rag_candidate_replay_check", lambda: smoke._check("pixelrag replay", "ok", "ok")) monkeypatch.setattr(smoke, "_pixelrag_ocr_vlm_replay_check", lambda: smoke._check("pixelrag ocr vlm", "ok", "ok")) + monkeypatch.setattr(smoke, "_pixelrag_vlm_replay_worker_check", lambda: smoke._check("pixelrag vlm worker", "ok", "ok")) result = smoke.collect_ai_automation_smoke(record_history=False) assert result["status"] == "critical" - assert result["summary"] == {"ok": 32, "warning": 1, "critical": 1, "total": 34} + assert result["summary"] == {"ok": 33, "warning": 1, "critical": 1, "total": 35} def test_pchome_controlled_apply_drift_monitor_reports_verified_zero_drift(monkeypatch): @@ -3968,6 +3969,7 @@ def test_collect_ai_automation_smoke_persists_recent_history(tmp_path, monkeypat monkeypatch.setattr(smoke, "_external_mcp_rag_integration_check", lambda: smoke._check("external mcp rag", "ok", "ok")) monkeypatch.setattr(smoke, "_pixelrag_rag_candidate_replay_check", lambda: smoke._check("pixelrag replay", "ok", "ok")) monkeypatch.setattr(smoke, "_pixelrag_ocr_vlm_replay_check", lambda: smoke._check("pixelrag ocr vlm", "ok", "ok")) + monkeypatch.setattr(smoke, "_pixelrag_vlm_replay_worker_check", lambda: smoke._check("pixelrag vlm worker", "ok", "ok")) first = smoke.collect_ai_automation_smoke(history_limit=5) second = smoke.collect_ai_automation_smoke(history_limit=5) @@ -4023,7 +4025,7 @@ def test_scheduled_automation_health_summary_reads_history_without_side_effects( json.dumps({ "generated_at": datetime.now().isoformat(timespec="seconds"), "status": "ok", - "summary": {"ok": 34, "warning": 0, "critical": 0, "total": 34}, + "summary": {"ok": 35, "warning": 0, "critical": 0, "total": 35}, "checks": [ { "name": "PChome 受控落地 drift monitor", @@ -4148,6 +4150,27 @@ def test_scheduled_automation_health_summary_reads_history_without_side_effects( "blocked_pages_are_not_product_data": True, "next_machine_action": "run_ollama_first_vlm_replay_worker", }, + }, + { + "name": "PixelRAG VLM replay worker", + "status": "ok", + "summary": "PixelRAG VLM replay worker receipts=1, ready=1, dry_run=1, skipped=0, executed=0", + "details": { + "policy": "controlled_pixelrag_ollama_vlm_replay_worker_v1", + "receipt_count": 1, + "ready_count": 1, + "skipped_count": 0, + "dry_run_count": 1, + "executed_count": 0, + "executed_ok_count": 0, + "executed_warning_count": 0, + "model_error_count": 0, + "parse_error_count": 0, + "receipt_written_count": 0, + "model_call_performed": False, + "artifact_write_performed": False, + "next_machine_action": "run_pixelrag_vlm_replay_worker_execute", + }, } ], }, ensure_ascii=False) + "\n", @@ -4166,7 +4189,7 @@ def test_scheduled_automation_health_summary_reads_history_without_side_effects( ) assert summary["policy"] == "read_only_ai_automation_scheduled_health_summary" assert summary["status"] == "ok" - assert summary["summary"]["total"] == 31 + assert summary["summary"]["total"] == 32 assert summary["summary"]["primary_human_gate_count"] == 0 assert summary["summary"]["writes_database_count"] == 0 assert pchome_family["status"] == "ok" @@ -5849,6 +5872,15 @@ def test_scheduled_automation_health_summary_reads_history_without_side_effects( assert pixelrag_ocr_vlm_replay_family["status"] == "ok" assert pixelrag_ocr_vlm_replay_family["details"]["replay_ready_count"] == 1 assert pixelrag_ocr_vlm_replay_family["details"]["extraction_execution_performed"] is False + pixelrag_vlm_replay_worker_family = next( + item for item in summary["families"] + if item["key"] == "pixelrag_vlm_replay_worker" + ) + assert pixelrag_vlm_replay_worker_family["status"] == "ok" + assert pixelrag_vlm_replay_worker_family["details"]["ready_count"] == 1 + assert pixelrag_vlm_replay_worker_family["details"]["dry_run_count"] == 1 + assert pixelrag_vlm_replay_worker_family["details"]["model_call_performed"] is False + assert pixelrag_vlm_replay_worker_family["details"]["writes_database_count"] == 0 assert summary["scheduled_outputs"]["telegram_send_in_preview"] is False assert summary["scheduled_outputs"]["writes_database"] is False assert summary["automation_policy"]["primary_flow"] == "ai_controlled" @@ -6470,6 +6502,11 @@ def test_surface_html_readback_check_is_part_of_ai_smoke(monkeypatch): "ok", "pixelrag ocr vlm ok", )) + monkeypatch.setattr(smoke, "_pixelrag_vlm_replay_worker_check", lambda: smoke._check( + "PixelRAG VLM replay worker", + "ok", + "pixelrag vlm worker ok", + )) result = smoke.collect_ai_automation_smoke(record_history=False) @@ -6485,7 +6522,7 @@ def test_surface_html_readback_check_is_part_of_ai_smoke(monkeypatch): item for item in result["checks"] if item["name"] == "Sitewide visual QA readback" ) - assert result["summary"]["total"] == 34 + assert result["summary"]["total"] == 35 assert surface_check["status"] == "ok" assert surface_check["details"]["checked_surface_count"] == 10 assert sitewide_check["status"] == "ok" diff --git a/tests/test_pixelrag_vlm_replay_worker_service.py b/tests/test_pixelrag_vlm_replay_worker_service.py new file mode 100644 index 0000000..fdc0df0 --- /dev/null +++ b/tests/test_pixelrag_vlm_replay_worker_service.py @@ -0,0 +1,181 @@ +import json +import subprocess +import sys +from types import SimpleNamespace + +from tests.test_pixelrag_ocr_vlm_replay_service import _write_receipt + + +def test_pixelrag_vlm_replay_worker_dry_run_keeps_blocked_pages_out(tmp_path): + from services.pixelrag_vlm_replay_worker_service import ( + POLICY, + run_pixelrag_ollama_vlm_replay_worker, + ) + + _write_receipt( + tmp_path, + platform="shopee_tw", + manifest_id="shopee-ok", + title="Shopee 防曬乳", + url="https://shopee.tw/search?keyword=sunscreen", + ) + _write_receipt( + tmp_path, + platform="coupang_tw", + manifest_id="coupang-403", + title="Access Denied", + url="https://www.tw.coupang.com/search?q=iphone", + http_status=403, + ) + + payload = run_pixelrag_ollama_vlm_replay_worker( + artifact_root=tmp_path, + platform=("shopee_tw", "coupang_tw"), + ) + + assert payload["policy"] == POLICY + assert payload["status"] == "warning" + assert payload["execute"] is False + assert payload["summary"]["receipt_count"] == 2 + assert payload["summary"]["ready_count"] == 1 + assert payload["summary"]["dry_run_count"] == 1 + assert payload["summary"]["skipped_count"] == 1 + assert payload["summary"]["model_call_performed"] is False + assert payload["summary"]["artifact_write_performed"] is False + assert payload["summary"]["writes_database_count"] == 0 + assert payload["controlled_apply"]["primary_human_gate_count"] == 0 + assert payload["next_machine_action"] == "run_pixelrag_vlm_replay_worker_execute" + by_platform = {item["platform"]: item for item in payload["worker_items"]} + assert by_platform["shopee_tw"]["worker_status"] == "dry_run_ready" + assert by_platform["coupang_tw"]["worker_status"] == "skipped_blocked_or_not_ready" + + +def test_pixelrag_vlm_replay_worker_execute_writes_artifact_receipt(tmp_path, monkeypatch): + from services import pixelrag_vlm_replay_worker_service as service + + _write_receipt( + tmp_path, + platform="shopee_tw", + manifest_id="shopee-ok", + title="Shopee 防曬乳", + url="https://shopee.tw/search?keyword=sunscreen", + ) + + class FakeOllama: + def __init__(self, model): + self.model = model + + def generate(self, *args, **kwargs): + return SimpleNamespace( + success=True, + content=json.dumps({ + "blocked_page_detected": False, + "fields": { + "title": { + "value": "防曬乳 SPF50", + "confidence": 0.92, + "evidence_refs": ["tile:1"], + }, + "price": { + "value": "399", + "confidence": 0.88, + "evidence_refs": ["tile:2"], + }, + }, + "quality": { + "overall_confidence": 0.89, + "missing_required_fields": [], + "requires_identity_matcher_replay": True, + "requires_promotion_gate": True, + }, + "notes": [], + }), + model="minicpm-v:latest", + error=None, + total_duration=1.5, + host="http://34.87.90.216:11434", + input_tokens=12, + output_tokens=80, + ) + + monkeypatch.setattr(service, "OllamaService", FakeOllama) + payload = service.run_pixelrag_ollama_vlm_replay_worker( + artifact_root=tmp_path, + output_root=tmp_path / "receipts", + platform="shopee_tw", + execute=True, + write_receipt=True, + tile_limit=1, + ) + + assert payload["status"] == "ok" + assert payload["summary"]["executed_count"] == 1 + assert payload["summary"]["executed_ok_count"] == 1 + assert payload["summary"]["receipt_written_count"] == 1 + assert payload["summary"]["writes_database_count"] == 0 + item = payload["worker_items"][0] + assert item["worker_status"] == "executed_ok" + assert item["provider"] == "gcp_ollama" + assert item["validation"]["valid_for_identity_matcher_replay"] is True + receipt_path = tmp_path / "receipts" / "shopee_tw" / "shopee-ok" / "vlm_replay_receipt.json" + assert receipt_path.exists() + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + assert receipt["parsed_output"]["fields"]["title"]["value"] == "防曬乳 SPF50" + + +def test_pixelrag_vlm_replay_worker_cli_outputs_machine_readable_json(tmp_path): + _write_receipt( + tmp_path, + platform="shopee_tw", + manifest_id="shopee-ok", + title="Shopee 防曬乳", + url="https://shopee.tw/search?keyword=sunscreen", + ) + + completed = subprocess.run( + [ + sys.executable, + "scripts/ops/run_pixelrag_vlm_replay_worker.py", + "--artifact-root", + str(tmp_path), + "--platform", + "shopee_tw", + ], + capture_output=True, + check=False, + text=True, + ) + + assert completed.returncode == 0 + payload = json.loads(completed.stdout) + assert payload["summary"]["receipt_count"] == 1 + assert payload["summary"]["dry_run_count"] == 1 + assert payload["controlled_apply"]["model_call"] is False + assert payload["controlled_apply"]["writes_database"] is False + + +def test_pixelrag_vlm_replay_worker_route_returns_readback(tmp_path, monkeypatch): + from flask import Flask + from routes import system_public_routes as routes + from services import pixelrag_vlm_replay_worker_service as service + + _write_receipt( + tmp_path, + platform="shopee_tw", + manifest_id="shopee-ok", + title="Shopee 防曬乳", + url="https://shopee.tw/search?keyword=sunscreen", + ) + monkeypatch.setattr(service, "DEFAULT_ARTIFACT_ROOT", str(tmp_path)) + + app = Flask(__name__) + with app.test_request_context( + "/api/ai-automation/pixelrag-vlm-replay-worker?platform=shopee_tw" + ): + response = routes.ai_automation_pixelrag_vlm_replay_worker_api.__wrapped__() + payload = response.get_json() + + assert payload["policy"] == "controlled_pixelrag_ollama_vlm_replay_worker_v1" + assert payload["summary"]["receipt_count"] == 1 + assert payload["summary"]["dry_run_count"] == 1 + assert payload["controlled_apply"]["model_call"] is False