diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 3363716..3853591 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.751`;部署完成前不得宣稱正式環境已是 `V10.751`。 +本輪 source target 為 `V10.752`;部署完成前不得宣稱正式環境已是 `V10.752`。 舊 iCloud checkout 不是 Gitea dev worktree,不得拿來當最新版本真相。 ================================================================================ @@ -61,6 +61,7 @@ P0-2026-07-09. PixelRAG / MCP / RAG 全自動主線 - 已完成: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、model_error receipt、blocked page guard、confidence/evidence validation,且 DB write=0、primary human gate=0。 + - 已完成:PixelRAG VLM route readiness 與 auto-select model,execute 前自動讀 approved Ollama `/api/tags`,避免 configured model 缺失時盲打 generate;完全缺候選時寫 `model_route_not_ready` receipt。 - 進行中:MCP/RAG runtime health → AI automation smoke。 - 未開始: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 6123d34..bdada6f 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.751" +SYSTEM_VERSION = "V10.752" 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 3fcb42f..62cb6e5 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -117,6 +117,7 @@ - 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-09 起 PixelRAG VLM route readiness 必須在 execute 前可讀回:`/api/ai-automation/pixelrag-vlm-route-readiness` 與 `scripts/ops/report_pixelrag_vlm_route_readiness.py` 只讀 approved Ollama `/api/tags`,輸出 configured model、candidate model、reachable host、model_route_ready 與 next machine action;`/api/ai-automation/smoke` 需包含 `PixelRAG VLM route readiness`,`/api/ai-automation/scheduled-health-summary` 需輸出 `pixelrag_vlm_route_readiness` family。此 readback 不呼叫 `/api/generate`、不讀 secret、不寫 DB;worker execute 必須使用它自動避開缺模型路由,完全缺候選時寫 `model_route_not_ready` artifact receipt。 - 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。 @@ -880,6 +881,7 @@ POSTGRES_HOST=momo-db | 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.751 起 `/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;model_error 也必須寫 failure artifact receipt,receipt 檔內需自證 `artifact_write_performed=true` 與 `receipt_path`。此 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-07-09 | PixelRAG VLM route readiness 必須有 runtime monitoring 與 execute 前自動選模 | V10.752 起 `/api/ai-automation/pixelrag-vlm-route-readiness`、`scripts/ops/report_pixelrag_vlm_route_readiness.py`、`/api/ai-automation/smoke` 與 `/api/ai-automation/scheduled-health-summary` 必須輸出 read-only approved Ollama route/model readiness / `pixelrag_vlm_route_readiness` family;readback 只打 `/api/tags`,不呼叫 `/api/generate`,輸出 reachable host、configured model available count、candidate model、candidate host、model_route_ready 與 next machine action。`run_pixelrag_vlm_replay_worker.py --execute` 預設必須使用此 readback 自動選擇已安裝候選模型;完全沒有候選時寫 `model_route_not_ready` artifact receipt,不得盲打 missing model。 | | 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 2292dbe..63e406a 100644 --- a/docs/guides/ai_automation_mainline_work_items.md +++ b/docs/guides/ai_automation_mainline_work_items.md @@ -19,7 +19,8 @@ | 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. | | 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. | +| Completed | PixelRAG VLM route readiness and auto model select | `/api/ai-automation/pixelrag-vlm-route-readiness` and `scripts/ops/report_pixelrag_vlm_route_readiness.py` read approved Ollama `/api/tags`, expose configured/candidate model readiness, and let execute mode avoid missing-model blind generate calls. | +| 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, PixelRAG VLM route readiness, 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 diff --git a/docs/guides/ai_automation_session_sop.md b/docs/guides/ai_automation_session_sop.md index 4c30080..eeb8430 100644 --- a/docs/guides/ai_automation_session_sop.md +++ b/docs/guides/ai_automation_session_sop.md @@ -36,7 +36,9 @@ 或 `python scripts/ops/report_pixelrag_application_portfolio.py` 可讀回 area、priority、status、use cases、next machine action 與 forbidden guardrails;不得只存在聊天結論。 - 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 未完成時被誤報為全自動閉環。 +- PixelRAG VLM route readiness 必須確認 `/api/ai-automation/pixelrag-vlm-route-readiness` + 或 `python scripts/ops/report_pixelrag_vlm_route_readiness.py` 可讀回 approved Ollama hosts、configured model 是否安裝、candidate model、自動選模下一步、model_call=false、DB write=0;缺模型不得等 execute 才暴露。 +- AI automation smoke 必須包含 external MCP/RAG integration、PixelRAG RAG candidate replay、PixelRAG OCR/VLM replay contract、PixelRAG VLM route readiness 與 PixelRAG VLM replay worker family,避免 registry 已完成但 runtime flag / receipt replay / VLM route / 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 f21b4c2..1b50cc1 100644 --- a/docs/guides/browse_sh_crawler_playbook.md +++ b/docs/guides/browse_sh_crawler_playbook.md @@ -187,12 +187,14 @@ python scripts/ops/report_pixelrag_ocr_vlm_replay.py --platform shopee_tw --plat Ollama-first VLM replay worker: ```bash +python scripts/ops/report_pixelrag_vlm_route_readiness.py +python scripts/ops/report_pixelrag_vlm_route_readiness.py --model minicpm-v:latest --include-models 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。 +API readback: `/api/ai-automation/pixelrag-vlm-route-readiness` 與 `/api/ai-automation/pixelrag-vlm-replay-worker?platform=shopee_tw`。worker 預設為 dry-run,不呼叫模型、不寫 artifact;`execute=true&write_receipt=true` 才呼叫 Ollama VLM 並寫 artifact receipt。execute 前會自動讀 approved Ollama `/api/tags` 選擇已安裝候選模型;若完全沒有候選,會寫 `model_route_not_ready` artifact receipt,不盲打 generate。即使 execute,結果仍只是 candidate evidence;不得直接寫 `ai_insights`、正式價格表或競品價格歷史,且 missing confidence/evidence 會留在 replay / probe lane。 Application portfolio: diff --git a/routes/system_public_routes.py b/routes/system_public_routes.py index acf99d2..a529702 100644 --- a/routes/system_public_routes.py +++ b/routes/system_public_routes.py @@ -780,6 +780,12 @@ def ai_automation_pixelrag_vlm_replay_worker_api(): 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) + auto_select_model = str(request.args.get('auto_select_model', 'true')).strip().lower() not in { + '0', + 'false', + 'no', + } + route_readiness_timeout = request.args.get('route_readiness_timeout', 3, type=int) return jsonify(run_pixelrag_ollama_vlm_replay_worker( platform=platforms, max_age_hours=max(1, min(max_age_hours or 168, 720)), @@ -789,6 +795,29 @@ def ai_automation_pixelrag_vlm_replay_worker_api(): timeout=max(10, min(timeout or 90, 240)), execute=execute, write_receipt=bool(execute and write_receipt), + auto_select_model=auto_select_model, + route_readiness_timeout=max(1, min(route_readiness_timeout or 3, 20)), + )) + + +@system_public_bp.route('/api/ai-automation/pixelrag-vlm-route-readiness') +@login_required +def ai_automation_pixelrag_vlm_route_readiness_api(): + """Read-only PixelRAG VLM approved route/model readiness.""" + from services.pixelrag_vlm_route_readiness_service import ( + build_pixelrag_vlm_route_readiness, + ) + + include_models = str(request.args.get('include_models') or '').strip().lower() in { + '1', + 'true', + 'yes', + } + timeout = request.args.get('timeout', 3, type=int) + return jsonify(build_pixelrag_vlm_route_readiness( + model=str(request.args.get('model') or '').strip() or None, + timeout_seconds=max(1, min(timeout or 3, 20)), + include_models=include_models, )) diff --git a/scripts/ops/report_pixelrag_vlm_route_readiness.py b/scripts/ops/report_pixelrag_vlm_route_readiness.py new file mode 100644 index 0000000..c7b9b10 --- /dev/null +++ b/scripts/ops/report_pixelrag_vlm_route_readiness.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Report PixelRAG VLM route/model readiness.""" + +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_route_readiness_service import ( # noqa: E402 + build_pixelrag_vlm_route_readiness, +) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="讀回 PixelRAG VLM approved Ollama route / model readiness。" + ) + parser.add_argument("--model", help="要檢查的 configured VLM model。") + parser.add_argument( + "--timeout", + type=int, + default=3, + help="/api/tags probe timeout 秒數。", + ) + parser.add_argument( + "--include-models", + action="store_true", + help="輸出每個 host 的 model list。", + ) + args = parser.parse_args() + + payload = build_pixelrag_vlm_route_readiness( + model=args.model, + timeout_seconds=args.timeout, + include_models=args.include_models, + ) + 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/scripts/ops/run_pixelrag_vlm_replay_worker.py b/scripts/ops/run_pixelrag_vlm_replay_worker.py index 5416d80..ab78f94 100755 --- a/scripts/ops/run_pixelrag_vlm_replay_worker.py +++ b/scripts/ops/run_pixelrag_vlm_replay_worker.py @@ -76,6 +76,17 @@ def main() -> int: action="store_true", help="execute 後寫入 VLM replay artifact receipt;不寫 DB。", ) + parser.add_argument( + "--no-auto-select-model", + action="store_true", + help="停用 execute 前的 approved route / installed model 自動選擇。", + ) + parser.add_argument( + "--route-readiness-timeout", + type=int, + default=3, + help="auto-select model 的 /api/tags readiness timeout 秒數。", + ) args = parser.parse_args() payload = run_pixelrag_ollama_vlm_replay_worker( @@ -89,6 +100,8 @@ def main() -> int: timeout=args.timeout, execute=args.execute, write_receipt=bool(args.write_receipt and args.execute), + auto_select_model=not args.no_auto_select_model, + route_readiness_timeout=args.route_readiness_timeout, ) print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)) return 0 if payload.get("success") else 1 diff --git a/services/ai_automation_smoke_service.py b/services/ai_automation_smoke_service.py index 63db710..c8ffcb4 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_route_readiness = _find_check(source_result, "PixelRAG VLM route readiness") + pixelrag_vlm_route_readiness_details = pixelrag_vlm_route_readiness.get("details") or {} + if not pixelrag_vlm_route_readiness or not pixelrag_vlm_route_readiness_details: + pixelrag_vlm_route_readiness = _pixelrag_vlm_route_readiness_check() + pixelrag_vlm_route_readiness_details = pixelrag_vlm_route_readiness.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: @@ -4637,6 +4642,40 @@ def build_scheduled_automation_health_summary( "primary_human_gate_count": 0, }, }, + { + "key": "pixelrag_vlm_route_readiness", + "label": "PixelRAG VLM route readiness", + "status": pixelrag_vlm_route_readiness.get("status") or "warning", + "summary": ( + pixelrag_vlm_route_readiness.get("summary") + or "PixelRAG VLM route readiness has no latest readback." + ), + "next_machine_action": pixelrag_vlm_route_readiness_details.get("next_machine_action") + or "run_pixelrag_vlm_route_readiness_readback", + "details": { + "policy": pixelrag_vlm_route_readiness_details.get("policy"), + "host_count": int(pixelrag_vlm_route_readiness_details.get("host_count") or 0), + "reachable_host_count": int( + pixelrag_vlm_route_readiness_details.get("reachable_host_count") or 0 + ), + "configured_model": pixelrag_vlm_route_readiness_details.get("configured_model"), + "configured_model_available_count": int( + pixelrag_vlm_route_readiness_details.get("configured_model_available_count") or 0 + ), + "candidate_model": pixelrag_vlm_route_readiness_details.get("candidate_model"), + "candidate_host": pixelrag_vlm_route_readiness_details.get("candidate_host"), + "candidate_provider": pixelrag_vlm_route_readiness_details.get("candidate_provider"), + "model_route_ready": bool( + pixelrag_vlm_route_readiness_details.get("model_route_ready") + ), + "model_call_performed": bool( + pixelrag_vlm_route_readiness_details.get("model_call_performed") + ), + "writes_database": False, + "writes_database_count": 0, + "primary_human_gate_count": 0, + }, + }, { "key": "pixelrag_vlm_replay_worker", "label": "PixelRAG VLM replay worker", @@ -4658,6 +4697,9 @@ def build_scheduled_automation_health_summary( 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), + "model_route_not_ready_count": int( + pixelrag_vlm_replay_worker_details.get("model_route_not_ready_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 @@ -13425,6 +13467,7 @@ def _pixelrag_vlm_replay_worker_check() -> Dict[str, Any]: "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), + "model_route_not_ready_count": int(summary.get("model_route_not_ready_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")), @@ -13448,6 +13491,60 @@ def _pixelrag_vlm_replay_worker_check() -> Dict[str, Any]: ) +def _pixelrag_vlm_route_readiness_check() -> Dict[str, Any]: + """Read-only sentinel for approved Ollama VLM route/model readiness.""" + try: + from services.pixelrag_vlm_route_readiness_service import ( + build_pixelrag_vlm_route_readiness, + ) + + readback = build_pixelrag_vlm_route_readiness(timeout_seconds=3) + summary = readback.get("summary") or {} + host_count = int(summary.get("host_count") or 0) + reachable_host_count = int(summary.get("reachable_host_count") or 0) + configured_model = summary.get("configured_model") + configured_available = int(summary.get("configured_model_available_count") or 0) + candidate_model = summary.get("candidate_model") + status = readback.get("status") or "warning" + summary_text = ( + f"PixelRAG VLM route readiness hosts={reachable_host_count}/{host_count}, " + f"configured={configured_model}, configured_available={configured_available}, " + f"candidate={candidate_model or 'none'}" + ) + return _check( + "PixelRAG VLM route readiness", + status, + summary_text, + { + "policy": readback.get("policy"), + "host_count": host_count, + "reachable_host_count": reachable_host_count, + "configured_model": configured_model, + "configured_model_available_count": configured_available, + "candidate_model": candidate_model, + "candidate_host": summary.get("candidate_host"), + "candidate_provider": summary.get("candidate_provider"), + "model_route_ready": bool(summary.get("model_route_ready")), + "model_call_performed": bool(summary.get("model_call_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 route readiness", + "critical", + f"PixelRAG VLM route readiness 無法執行:{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(), @@ -13484,6 +13581,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_route_readiness_check(), _pixelrag_vlm_replay_worker_check(), ] worst = max(checks, key=lambda item: STATUS_RANK.get(item["status"], 2))["status"] diff --git a/services/pixelrag_vlm_replay_worker_service.py b/services/pixelrag_vlm_replay_worker_service.py index a249960..853e765 100644 --- a/services/pixelrag_vlm_replay_worker_service.py +++ b/services/pixelrag_vlm_replay_worker_service.py @@ -25,6 +25,9 @@ from services.pixelrag_ocr_vlm_replay_service import ( DEFAULT_CONFIDENCE_THRESHOLD, build_pixelrag_ocr_vlm_replay_contract, ) +from services.pixelrag_vlm_route_readiness_service import ( + build_pixelrag_vlm_route_readiness, +) POLICY = "controlled_pixelrag_ollama_vlm_replay_worker_v1" @@ -274,6 +277,39 @@ def _dry_run_item(item: Mapping[str, Any]) -> dict[str, Any]: } +def _model_route_not_ready_item( + item: Mapping[str, Any], + *, + output_root: Path, + route_readiness: Mapping[str, Any], + write_receipt: bool, +) -> dict[str, Any]: + summary = route_readiness.get("summary") or {} + worker_item = { + "platform": item.get("platform"), + "manifest_id": item.get("manifest_id"), + "source_receipt_path": item.get("source_receipt_path"), + "worker_status": "model_route_not_ready", + "model": summary.get("configured_model"), + "candidate_model": summary.get("candidate_model"), + "candidate_host": summary.get("candidate_host"), + "model_call_performed": False, + "artifact_write_performed": False, + "writes_database": False, + "route_readiness_status": route_readiness.get("status"), + "next_machine_action": route_readiness.get("next_machine_action") + or "install_or_configure_pixelrag_vlm_model_on_approved_ollama_host", + } + if write_receipt: + worker_item["receipt_path"] = _write_replay_receipt( + output_root=output_root, + item=item, + worker_item=worker_item, + ) + worker_item["artifact_write_performed"] = True + return worker_item + + def _execute_item( item: Mapping[str, Any], *, @@ -397,6 +433,8 @@ def run_pixelrag_ollama_vlm_replay_worker( timeout: int | None = None, execute: bool = False, write_receipt: bool = False, + auto_select_model: bool = True, + route_readiness_timeout: int | None = None, ) -> dict[str, Any]: """Run or dry-run the PixelRAG VLM replay worker.""" root = Path(artifact_root or DEFAULT_ARTIFACT_ROOT) @@ -407,7 +445,10 @@ def run_pixelrag_ollama_vlm_replay_worker( 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)) + readiness_timeout = max(1, min(int(route_readiness_timeout or 3), 20)) generated_at = datetime.now(timezone.utc).isoformat() + route_readiness: dict[str, Any] | None = None + model_route_ready = True contract = build_pixelrag_ocr_vlm_replay_contract( artifact_root=root, @@ -416,6 +457,17 @@ def run_pixelrag_ollama_vlm_replay_worker( limit=item_limit, ) replay_items = list(contract.get("replay_items") or []) + if execute and auto_select_model: + route_readiness = build_pixelrag_vlm_route_readiness( + model=selected_model, + timeout_seconds=readiness_timeout, + ) + route_summary = route_readiness.get("summary") or {} + candidate_model = str(route_summary.get("candidate_model") or "").strip() + model_route_ready = bool(route_summary.get("model_route_ready")) + if candidate_model: + selected_model = candidate_model + worker_items: list[dict[str, Any]] = [] for item in replay_items: if not item.get("ready_for_ollama_vlm_worker"): @@ -424,6 +476,14 @@ def run_pixelrag_ollama_vlm_replay_worker( if not execute: worker_items.append(_dry_run_item(item)) continue + if not model_route_ready and route_readiness is not None: + worker_items.append(_model_route_not_ready_item( + item, + output_root=output, + route_readiness=route_readiness, + write_receipt=write_receipt, + )) + continue worker_items.append(_execute_item( item, root=root, @@ -441,6 +501,7 @@ def run_pixelrag_ollama_vlm_replay_worker( 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") + route_not_ready_count = sum(1 for item in worker_items if item.get("worker_status") == "model_route_not_ready") 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")) @@ -451,7 +512,7 @@ def run_pixelrag_ollama_vlm_replay_worker( 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: + if parse_error_count or model_error_count or route_not_ready_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" @@ -462,6 +523,8 @@ def run_pixelrag_ollama_vlm_replay_worker( next_action = "run_pixelrag_visual_capture_worker" elif not execute and ready_count: next_action = "run_pixelrag_vlm_replay_worker_execute" + elif route_not_ready_count: + next_action = "install_or_configure_pixelrag_vlm_model_on_approved_ollama_host" elif model_error_count or parse_error_count: next_action = "repair_ollama_vlm_runtime_or_model_route" elif executed_warning_count: @@ -480,6 +543,7 @@ def run_pixelrag_ollama_vlm_replay_worker( "executed_ok_count": executed_ok_count, "executed_warning_count": executed_warning_count, "model_error_count": model_error_count, + "model_route_not_ready_count": route_not_ready_count, "parse_error_count": parse_error_count, "no_tile_count": no_tile_count, "receipt_written_count": receipt_written_count, @@ -502,11 +566,24 @@ def run_pixelrag_ollama_vlm_replay_worker( "limit": item_limit, "tile_limit": tiles, "model": selected_model, + "configured_model": str(model or DEFAULT_MODEL), "timeout_seconds": selected_timeout, "execute": bool(execute), "write_receipt": bool(write_receipt), + "auto_select_model": bool(auto_select_model), + "route_readiness_timeout_seconds": readiness_timeout, "summary": summary, "worker_items": worker_items, + "route_readiness": ( + { + "policy": route_readiness.get("policy"), + "status": route_readiness.get("status"), + "summary": route_readiness.get("summary"), + "next_machine_action": route_readiness.get("next_machine_action"), + } + if route_readiness + else None + ), "source_contract": { "policy": contract.get("policy"), "status": contract.get("status"), diff --git a/services/pixelrag_vlm_route_readiness_service.py b/services/pixelrag_vlm_route_readiness_service.py new file mode 100644 index 0000000..9cc4acf --- /dev/null +++ b/services/pixelrag_vlm_route_readiness_service.py @@ -0,0 +1,212 @@ +"""Read-only PixelRAG VLM route readiness. + +This module checks approved Ollama routes for installed VLM candidate models. +It does not call /api/generate, read secrets, write DB data, or change runtime +configuration. The worker can use the result to avoid blindly executing a +missing configured model. +""" + +from __future__ import annotations + +import os +from datetime import datetime, timezone +from typing import Any + +import requests + +from services.ollama_service import ( + OLLAMA_HOST_FALLBACK, + OLLAMA_HOST_PRIMARY, + OLLAMA_HOST_PRIMARY_PROXY, + OLLAMA_HOST_SECONDARY, + OLLAMA_HOST_SECONDARY_PROXY, + get_host_label, + get_provider_tag, +) + + +POLICY = "read_only_pixelrag_vlm_route_readiness_v1" +DEFAULT_TIMEOUT_SECONDS = 3 +DEFAULT_MODEL = ( + os.getenv("PIXELRAG_VLM_MODEL") + or os.getenv("PPT_VISION_MODEL") + or "minicpm-v:latest" +) +VISION_MODEL_CANDIDATES = ( + "minicpm-v:latest", + "gemma3:4b", + "llava:latest", +) + + +def _unique(values: list[str] | tuple[str, ...]) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for value in values: + clean = str(value or "").strip() + if not clean or clean in seen: + continue + seen.add(clean) + result.append(clean) + return result + + +def _approved_hosts() -> list[str]: + return _unique(( + OLLAMA_HOST_PRIMARY, + OLLAMA_HOST_PRIMARY_PROXY, + OLLAMA_HOST_SECONDARY, + OLLAMA_HOST_SECONDARY_PROXY, + OLLAMA_HOST_FALLBACK, + )) + + +def _candidate_models(configured_model: str) -> list[str]: + env_candidates = [ + item.strip() + for item in os.getenv("PIXELRAG_VLM_MODEL_CANDIDATES", "").split(",") + if item.strip() + ] + return _unique((configured_model, *env_candidates, *VISION_MODEL_CANDIDATES)) + + +def _fetch_models(host: str, timeout: int) -> dict[str, Any]: + clean_host = str(host or "").rstrip("/") + item: dict[str, Any] = { + "host": clean_host, + "host_label": get_host_label(clean_host), + "provider": get_provider_tag(clean_host), + "reachable": False, + "model_count": 0, + "models": [], + "error": "", + } + try: + response = requests.get(f"{clean_host}/api/tags", timeout=max(1, timeout)) + item["http_status"] = response.status_code + if response.status_code != 200: + item["error"] = f"HTTP {response.status_code}: {response.text[:180]}" + return item + payload = response.json() + models = [ + str(model.get("name") or "").strip() + for model in list(payload.get("models") or []) + if str(model.get("name") or "").strip() + ] + item.update({ + "reachable": True, + "model_count": len(models), + "models": models[:80], + }) + except Exception as exc: + item["error"] = f"{type(exc).__name__}: {str(exc)[:180]}" + return item + + +def build_pixelrag_vlm_route_readiness( + *, + model: str | None = None, + timeout_seconds: int | None = None, + include_models: bool = False, +) -> dict[str, Any]: + """Read approved Ollama tags and recommend an installed VLM model.""" + configured_model = str(model or DEFAULT_MODEL).strip() or DEFAULT_MODEL + timeout = max(1, min(int(timeout_seconds or DEFAULT_TIMEOUT_SECONDS), 20)) + generated_at = datetime.now(timezone.utc).isoformat() + candidates = _candidate_models(configured_model) + host_results = [_fetch_models(host, timeout) for host in _approved_hosts()] + + reachable_hosts = [item for item in host_results if item.get("reachable")] + configured_hosts = [ + item for item in reachable_hosts + if configured_model in set(item.get("models") or []) + ] + selected_model = "" + selected_host = "" + selected_provider = "" + selected_reason = "" + for candidate in candidates: + for host in reachable_hosts: + if candidate not in set(host.get("models") or []): + continue + selected_model = candidate + selected_host = str(host.get("host") or "") + selected_provider = str(host.get("provider") or "") + selected_reason = ( + "configured_model_available" + if candidate == configured_model + else "installed_candidate_fallback" + ) + break + if selected_model: + break + + model_route_ready = bool(selected_model and selected_host) + if not reachable_hosts: + status = "critical" + next_action = "repair_approved_ollama_host_connectivity" + elif not model_route_ready: + status = "critical" + next_action = "install_or_configure_pixelrag_vlm_model_on_approved_ollama_host" + elif selected_model != configured_model: + status = "warning" + next_action = "run_pixelrag_vlm_replay_worker_execute_with_selected_model" + else: + status = "ok" + next_action = "run_pixelrag_vlm_replay_worker_execute" + + public_host_results: list[dict[str, Any]] = [] + for item in host_results: + result = dict(item) + result["configured_model_available"] = configured_model in set(item.get("models") or []) + result["candidate_models_available"] = [ + candidate for candidate in candidates + if candidate in set(item.get("models") or []) + ] + if not include_models: + result.pop("models", None) + public_host_results.append(result) + + summary = { + "host_count": len(host_results), + "reachable_host_count": len(reachable_hosts), + "configured_model": configured_model, + "configured_model_available_count": len(configured_hosts), + "candidate_model": selected_model, + "candidate_host": selected_host, + "candidate_provider": selected_provider, + "candidate_selection_reason": selected_reason, + "model_route_ready": model_route_ready, + "generate_probe_performed": False, + "model_call_performed": False, + "writes_database_count": 0, + "primary_human_gate_count": 0, + } + return { + "success": status != "critical", + "policy": POLICY, + "status": status, + "generated_at": generated_at, + "timeout_seconds": timeout, + "configured_model": configured_model, + "candidate_models": candidates, + "summary": summary, + "hosts": public_host_results, + "controlled_apply": { + "network_call": True, + "model_call": False, + "db_write": False, + "writes_database": False, + "writes_database_count": 0, + "secret_read": False, + "production_price_write": False, + "primary_human_gate_count": 0, + }, + "next_machine_action": next_action, + } + + +__all__ = [ + "POLICY", + "build_pixelrag_vlm_route_readiness", +] diff --git a/tests/test_ai_automation_smoke_service.py b/tests/test_ai_automation_smoke_service.py index e203e27..8f57ce3 100644 --- a/tests/test_ai_automation_smoke_service.py +++ b/tests/test_ai_automation_smoke_service.py @@ -1434,12 +1434,13 @@ 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_route_readiness_check", lambda: smoke._check("pixelrag vlm route", "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": 33, "warning": 1, "critical": 1, "total": 35} + assert result["summary"] == {"ok": 34, "warning": 1, "critical": 1, "total": 36} def test_pchome_controlled_apply_drift_monitor_reports_verified_zero_drift(monkeypatch): @@ -3969,6 +3970,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_route_readiness_check", lambda: smoke._check("pixelrag vlm route", "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) @@ -4025,7 +4027,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": 35, "warning": 0, "critical": 0, "total": 35}, + "summary": {"ok": 36, "warning": 0, "critical": 0, "total": 36}, "checks": [ { "name": "PChome 受控落地 drift monitor", @@ -4151,6 +4153,24 @@ def test_scheduled_automation_health_summary_reads_history_without_side_effects( "next_machine_action": "run_ollama_first_vlm_replay_worker", }, }, + { + "name": "PixelRAG VLM route readiness", + "status": "ok", + "summary": "PixelRAG VLM route readiness hosts=1/5, configured=minicpm-v:latest, configured_available=1, candidate=minicpm-v:latest", + "details": { + "policy": "read_only_pixelrag_vlm_route_readiness_v1", + "host_count": 5, + "reachable_host_count": 1, + "configured_model": "minicpm-v:latest", + "configured_model_available_count": 1, + "candidate_model": "minicpm-v:latest", + "candidate_host": "http://34.21.145.224:11434", + "candidate_provider": "ollama_secondary", + "model_route_ready": True, + "model_call_performed": False, + "next_machine_action": "run_pixelrag_vlm_replay_worker_execute", + }, + }, { "name": "PixelRAG VLM replay worker", "status": "ok", @@ -4165,6 +4185,7 @@ def test_scheduled_automation_health_summary_reads_history_without_side_effects( "executed_ok_count": 0, "executed_warning_count": 0, "model_error_count": 0, + "model_route_not_ready_count": 0, "parse_error_count": 0, "receipt_written_count": 0, "model_call_performed": False, @@ -4189,7 +4210,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"] == 32 + assert summary["summary"]["total"] == 33 assert summary["summary"]["primary_human_gate_count"] == 0 assert summary["summary"]["writes_database_count"] == 0 assert pchome_family["status"] == "ok" @@ -5872,6 +5893,14 @@ 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_route_readiness_family = next( + item for item in summary["families"] + if item["key"] == "pixelrag_vlm_route_readiness" + ) + assert pixelrag_vlm_route_readiness_family["status"] == "ok" + assert pixelrag_vlm_route_readiness_family["details"]["model_route_ready"] is True + assert pixelrag_vlm_route_readiness_family["details"]["model_call_performed"] is False + assert pixelrag_vlm_route_readiness_family["details"]["writes_database_count"] == 0 pixelrag_vlm_replay_worker_family = next( item for item in summary["families"] if item["key"] == "pixelrag_vlm_replay_worker" @@ -6502,6 +6531,11 @@ def test_surface_html_readback_check_is_part_of_ai_smoke(monkeypatch): "ok", "pixelrag ocr vlm ok", )) + monkeypatch.setattr(smoke, "_pixelrag_vlm_route_readiness_check", lambda: smoke._check( + "PixelRAG VLM route readiness", + "ok", + "pixelrag vlm route ok", + )) monkeypatch.setattr(smoke, "_pixelrag_vlm_replay_worker_check", lambda: smoke._check( "PixelRAG VLM replay worker", "ok", @@ -6522,7 +6556,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"] == 35 + assert result["summary"]["total"] == 36 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 index 0d6aaf0..ea0f66c 100644 --- a/tests/test_pixelrag_vlm_replay_worker_service.py +++ b/tests/test_pixelrag_vlm_replay_worker_service.py @@ -106,6 +106,7 @@ def test_pixelrag_vlm_replay_worker_execute_writes_artifact_receipt(tmp_path, mo execute=True, write_receipt=True, tile_limit=1, + auto_select_model=False, ) assert payload["status"] == "ok" @@ -160,6 +161,7 @@ def test_pixelrag_vlm_replay_worker_writes_model_error_receipt(tmp_path, monkeyp execute=True, write_receipt=True, tile_limit=1, + auto_select_model=False, ) assert payload["status"] == "critical" @@ -175,6 +177,86 @@ def test_pixelrag_vlm_replay_worker_writes_model_error_receipt(tmp_path, monkeyp assert receipt["next_machine_action"] == "repair_ollama_vlm_runtime_or_model_route" +def test_pixelrag_vlm_replay_worker_auto_selects_installed_candidate(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", + ) + + monkeypatch.setattr( + service, + "build_pixelrag_vlm_route_readiness", + lambda **kwargs: { + "policy": "read_only_pixelrag_vlm_route_readiness_v1", + "status": "warning", + "summary": { + "configured_model": "minicpm-v:latest", + "candidate_model": "gemma3:4b", + "candidate_host": "http://34.21.145.224:11434", + "model_route_ready": True, + }, + "next_machine_action": "run_pixelrag_vlm_replay_worker_execute_with_selected_model", + }, + ) + + class FakeOllama: + def __init__(self, model): + self.model = model + + def generate(self, *args, **kwargs): + assert kwargs["model"] == "gemma3:4b" + 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:1"], + }, + }, + "quality": { + "overall_confidence": 0.90, + "missing_required_fields": [], + "requires_identity_matcher_replay": True, + "requires_promotion_gate": True, + }, + }), + model="gemma3:4b", + error=None, + total_duration=1.0, + host="http://34.21.145.224:11434", + input_tokens=10, + output_tokens=50, + ) + + monkeypatch.setattr(service, "OllamaService", FakeOllama) + payload = service.run_pixelrag_ollama_vlm_replay_worker( + artifact_root=tmp_path, + platform="shopee_tw", + model="minicpm-v:latest", + execute=True, + tile_limit=1, + auto_select_model=True, + ) + + assert payload["model"] == "gemma3:4b" + assert payload["configured_model"] == "minicpm-v:latest" + assert payload["route_readiness"]["summary"]["candidate_model"] == "gemma3:4b" + assert payload["summary"]["executed_ok_count"] == 1 + + def test_pixelrag_vlm_replay_worker_cli_outputs_machine_readable_json(tmp_path): _write_receipt( tmp_path, diff --git a/tests/test_pixelrag_vlm_route_readiness_service.py b/tests/test_pixelrag_vlm_route_readiness_service.py new file mode 100644 index 0000000..c24cfd9 --- /dev/null +++ b/tests/test_pixelrag_vlm_route_readiness_service.py @@ -0,0 +1,110 @@ +import json + + +class FakeResponse: + def __init__(self, status_code=200, payload=None, text=""): + self.status_code = status_code + self._payload = payload or {} + self.text = text + + def json(self): + return self._payload + + +def test_pixelrag_vlm_route_readiness_selects_installed_candidate(monkeypatch): + from services import pixelrag_vlm_route_readiness_service as service + + hosts = [ + "http://primary:11434", + "http://secondary:11434", + ] + monkeypatch.setattr(service, "_approved_hosts", lambda: hosts) + + def fake_get(url, timeout): + if url.startswith("http://primary"): + raise TimeoutError("primary timeout") + return FakeResponse( + payload={ + "models": [ + {"name": "gemma3:4b"}, + {"name": "bge-m3:latest"}, + ] + } + ) + + monkeypatch.setattr(service.requests, "get", fake_get) + payload = service.build_pixelrag_vlm_route_readiness( + model="minicpm-v:latest", + timeout_seconds=1, + ) + + assert payload["policy"] == "read_only_pixelrag_vlm_route_readiness_v1" + assert payload["status"] == "warning" + assert payload["summary"]["reachable_host_count"] == 1 + assert payload["summary"]["configured_model_available_count"] == 0 + assert payload["summary"]["candidate_model"] == "gemma3:4b" + assert payload["summary"]["model_route_ready"] is True + assert payload["controlled_apply"]["model_call"] is False + assert payload["controlled_apply"]["writes_database"] is False + assert payload["next_machine_action"] == "run_pixelrag_vlm_replay_worker_execute_with_selected_model" + + +def test_pixelrag_vlm_route_readiness_critical_when_no_candidate(monkeypatch): + from services import pixelrag_vlm_route_readiness_service as service + + monkeypatch.setattr(service, "_approved_hosts", lambda: ["http://secondary:11434"]) + monkeypatch.setattr( + service.requests, + "get", + lambda url, timeout: FakeResponse(payload={"models": [{"name": "bge-m3:latest"}]}), + ) + + payload = service.build_pixelrag_vlm_route_readiness(model="minicpm-v:latest") + + assert payload["status"] == "critical" + assert payload["success"] is False + assert payload["summary"]["model_route_ready"] is False + assert payload["next_machine_action"] == "install_or_configure_pixelrag_vlm_model_on_approved_ollama_host" + + +def test_pixelrag_vlm_route_readiness_route_returns_readback(monkeypatch): + from flask import Flask + from routes import system_public_routes as routes + from services import pixelrag_vlm_route_readiness_service as service + + monkeypatch.setattr( + service, + "build_pixelrag_vlm_route_readiness", + lambda **kwargs: { + "success": True, + "policy": "read_only_pixelrag_vlm_route_readiness_v1", + "status": "ok", + "summary": {"model_route_ready": True, "candidate_model": "gemma3:4b"}, + "next_machine_action": "run_pixelrag_vlm_replay_worker_execute", + }, + ) + + app = Flask(__name__) + with app.test_request_context( + "/api/ai-automation/pixelrag-vlm-route-readiness?model=minicpm-v:latest" + ): + response = routes.ai_automation_pixelrag_vlm_route_readiness_api.__wrapped__() + payload = response.get_json() + + assert payload["policy"] == "read_only_pixelrag_vlm_route_readiness_v1" + assert payload["summary"]["candidate_model"] == "gemma3:4b" + + +def test_pixelrag_vlm_route_readiness_cli_outputs_json(monkeypatch, tmp_path): + from services import pixelrag_vlm_route_readiness_service as service + + monkeypatch.setattr(service, "_approved_hosts", lambda: ["http://secondary:11434"]) + monkeypatch.setattr( + service.requests, + "get", + lambda url, timeout: FakeResponse(payload={"models": [{"name": "gemma3:4b"}]}), + ) + + payload = service.build_pixelrag_vlm_route_readiness(model="minicpm-v:latest") + assert json.dumps(payload, ensure_ascii=False) + assert payload["summary"]["candidate_model"] == "gemma3:4b"