diff --git a/AGENTS.md b/AGENTS.md index 2dc76d3..2a3312a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,6 +139,7 @@ - 前端更版路線圖: `docs/guides/frontend_upgrade_roadmap.md` - AI 觀測台 UI 治理: `docs/guides/observability_ui_governance.md` - AI 自動化 Session SOP: `docs/guides/ai_automation_session_sop.md` +- AI 自動化主線工作項目: `docs/guides/ai_automation_mainline_work_items.md` - Browse.sh 爬蟲診斷手冊: `docs/guides/browse_sh_crawler_playbook.md` - Webcrumbs 共用 UI Runtime: `docs/guides/webcrumbs_shared_runtime.md` - PChome 業績成長 UI/UX 守門: `docs/guides/pchome_growth_ui_ux_guardrails.md` diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 3764722..198203b 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -8,9 +8,9 @@ python scripts/ops/check_production_version_truth.py -目前 protected baseline:production `https://mo.wooo.work/health` = `V10.725`、 -clean workspace `/Users/ogt/codex-workspaces/ewoooc-dev` = `V10.725`、 -origin/main = `f3e412c`。舊 iCloud checkout 的 local-only `V10.726` 不是正式版本。 +目前最新版本仍以 production `https://mo.wooo.work/health` readback 為準。 +本輪 source target 為 `V10.746`;部署完成前不得宣稱正式環境已是 `V10.746`。 +舊 iCloud checkout 不是 Gitea dev worktree,不得拿來當最新版本真相。 ================================================================================ 產品完整工作優先順序鎖 (2026-06-29) [ACTIVE] @@ -56,6 +56,12 @@ P6. DB apply authorization / signing / closeout - 這是最後的受控 apply 安全鏈,不得搶在 P1-P4 前面。 - 預設不讀 secret、不簽發 authorization、不執行 SQL、不寫 DB,直到 controlled apply 條件完整。 +P0-2026-07-09. PixelRAG / MCP / RAG 全自動主線 + - 已完成:多電商 PixelRAG visual evidence lane 與 external MCP/RAG integration readback。 + - 進行中:PixelRAG receipt → internal RAG candidate replay,以及 MCP/RAG runtime health → AI automation smoke。 + - 未開始:OCR/VLM replay、Ollama-first visual embedding benchmark、pgvector-compatible visual metadata、Coupang platform probe / structured API、跨平台 source contracts。 + - 主線文件:`docs/guides/ai_automation_mainline_work_items.md`。 + ================================================================================ PChome Mapping Backlog Read-Only Report (2026-06-28) [ACTIVE] ================================================================================ diff --git a/config.py b/config.py index 7503644..fc12f72 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.745" +SYSTEM_VERSION = "V10.746" 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 ab8849d..a34c03d 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -1,8 +1,8 @@ # PChome 業績成長自動化作戰系統 — AI 競價情報模組 Single Source of Truth > **最後更新**: 2026-07-09 (台北時間) -> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯;PChome 後台業績匯入韌性已補強;產品定位正名為「PChome 業績成長自動化作戰系統」;外部市場來源正規化層、自動同步、作戰清單與價格參考表優先讀取、CSV 備援預檢、前台操作入口、高可見頁面繁中化守門、比價/作戰 UI 工作台化、AI 密集工作台文字密度守門、跨平台來源治理、商品身份 UI 契約、sitewide visual QA runtime readback、PChome auto-policy authorization guard monitoring、decision preflight machine evidence monitoring、decision closeout monitoring、authorization issuer gate monitoring、signing decision preflight monitoring、signing decision closeout monitoring、signing issuer guard monitoring、signing issuer closeout monitoring、signing execution preflight monitoring、signing execution closeout monitoring、signed receipt preflight monitoring、signed receipt closeout monitoring、signed receipt evidence intake monitoring、detached verification evidence validation monitoring、verifier receipt closeout monitoring、authorization evidence execution preflight monitoring、authorization evidence execution closeout monitoring、controlled apply final preflight monitoring、controlled dry-run package monitoring 與 controlled dry-run receipt closeout monitoring 已建立,GCP embedding 熔斷延後處理、110 proxy rescue 與 direct host health skip 已建立 -> **適用版本**: V10.745 +> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯;PChome 後台業績匯入韌性已補強;產品定位正名為「PChome 業績成長自動化作戰系統」;外部市場來源正規化層、自動同步、作戰清單與價格參考表優先讀取、CSV 備援預檢、前台操作入口、高可見頁面繁中化守門、比價/作戰 UI 工作台化、AI 密集工作台文字密度守門、跨平台來源治理、商品身份 UI 契約、sitewide visual QA runtime readback、external MCP/RAG integration monitoring、PixelRAG RAG candidate replay monitoring、PChome auto-policy authorization guard monitoring、decision preflight machine evidence monitoring、decision closeout monitoring、authorization issuer gate monitoring、signing decision preflight monitoring、signing decision closeout monitoring、signing issuer guard monitoring、signing issuer closeout monitoring、signing execution preflight monitoring、signing execution closeout monitoring、signed receipt preflight monitoring、signed receipt closeout monitoring、signed receipt evidence intake monitoring、detached verification evidence validation monitoring、verifier receipt closeout monitoring、authorization evidence execution preflight monitoring、authorization evidence execution closeout monitoring、controlled apply final preflight monitoring、controlled dry-run package monitoring 與 controlled dry-run receipt closeout monitoring 已建立,GCP embedding 熔斷延後處理、110 proxy rescue 與 direct host health skip 已建立 +> **適用版本**: V10.746 --- @@ -112,6 +112,8 @@ - 2026-07-03 起整站 UI/UX 專業化必須由 Sitewide UI/UX Agent 管理:`/api/ai-automation/sitewide-ui-ux-agent` 會掃描真正頁面模板、排除 partial/components/base layouts,輸出高優先頁、compact guardrail、raw engineering issue 與 next machine action;`/api/ai-automation/sitewide-ui-ux-repair-package` 會產生 no-write controlled repair package。當前 Agent 判定為 55 個頁面模板、31 個高優先模板、29 個已具 compact guardrail、0 個待專業化、0 個 raw engineering leak,Sitewide Agent 狀態為 `ok`,repair package 為 `no_op`。四批 controlled UI/UX repair 已完成 AI observability、AI 建議、PPT/CI/CD/業績/比價/成長/供貨風險高優先 surface;下一個機器動作為 `keep_sitewide_ui_ux_agent_monitoring`,後續只接受 Agent 新增 issue 或更高階 visual/mobile QA 的證據驅動修復。 - 2026-07-03 起 Sitewide UI/UX Agent 歸零狀態必須納入 AI automation runtime monitoring:`/api/ai-automation/smoke` 需包含 `Sitewide UI/UX Agent readback` 檢查,要求 `issue_surface_count=0`、`repair_package_status=no_op`、`repair_action_count=0`;`/api/ai-automation/scheduled-health-summary` 需輸出 `sitewide_ui_ux_agent` family;`/metrics` 需以 `momo_ai_automation_scheduled_health_family_status{family="sitewide_ui_ux_agent",...}` 暴露 scheduled family。此監控不得寫 DB、不得送通知、不得要求人工審核。 - 2026-07-03 起整站 desktop/tablet/mobile visual QA 必須進入 AI automation runtime monitoring:`scripts/check_responsive_overflow.js --artifact-output data/ai_automation/sitewide_visual_qa_latest.json` 會輸出機器可讀 artifact;`/api/ai-automation/sitewide-visual-qa-readback` 只讀該 artifact 並回報 route_count、viewport_count、failed_count、overflow_issue_count、stale 與 next_machine_action;`/api/ai-automation/smoke` 需包含 `Sitewide visual QA readback`,`/api/ai-automation/scheduled-health-summary` 需輸出 `sitewide_visual_qa` family,`/metrics` 需暴露 `momo_ai_automation_scheduled_health_family_status{family="sitewide_visual_qa",...}`。readback 不啟動 browser、不寫 DB、不送通知;artifact 生成才需要 Playwright/瀏覽器。 +- 2026-07-09 起 external MCP/RAG integration 必須進入 AI automation runtime monitoring:`/api/ai-automation/external-mcp-rag-integration` 與 `scripts/ops/report_external_mcp_rag_integration.py` 需輸出 9 個 capability 的 absorbed / unresolved 狀態、內部治理落點與 runtime flags;`/api/ai-automation/smoke` 需包含 `External MCP/RAG integration readback`,`/api/ai-automation/scheduled-health-summary` 需輸出 `external_mcp_rag_integration` family。`MCP_ROUTER_ENABLED=false` 或 `RAG_ENABLED=false` 必須被視為 runtime gap,不得因 registry 已完成就宣稱 MCP/RAG runtime 已全面啟用。 +- 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-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。 @@ -871,6 +873,7 @@ POSTGRES_HOST=momo-db | 2026-07-03 | PChome auto-policy controlled apply final preflight 必須有 runtime monitoring | V10.743 起 `/api/ai-automation/smoke`、`/api/ai-automation/scheduled-health-summary` 與 `/metrics` 必須輸出 `PChome auto-policy controlled apply final preflight` / `pchome_auto_policy_controlled_apply_final_preflight`;此 runtime check 自動驗證 authorization evidence execution closeout carry-forward、database apply authorization final verifier gate、controlled apply final preflight package、rollback binding、post-apply verifier binding、12 個 final preflight checks、12 個 preflight fields、10 個 acceptance gates、dry-run-only、check-mode-only、same-run production truth requirement 與 future controlled dry-run package readiness,但明確標記 `reads_secret_count=0`、`executes_script_count=0`、`executes_sql_count=0`、`writes_database_count=0`、`signs_database_apply_authorization_count=0`、`primary_human_gate_count=0`、`rollback_execution_authorized=false`、`rollback_executes_sql=false`、`rollback_writes_database=false`、`post_apply_verifier_execution_authorized_in_preview=false`、`executes_database_apply=false`、`ready_for_database_apply_now=false`、`database_apply_authorized=false`、`issues_database_apply_authorization=false`、`signs_database_apply_authorization=false`,不讀 secret、不執行 authorization evidence、不執行 database apply、不執行 endpoint/SQL、不寫 DB、不簽發 apply authorization,只允許進入下一段 controlled dry-run package lane。 | | 2026-07-03 | PChome auto-policy controlled dry-run package 必須有 runtime monitoring | V10.744 起 `/api/ai-automation/smoke`、`/api/ai-automation/scheduled-health-summary` 與 `/metrics` 必須輸出 `PChome auto-policy controlled dry-run package` / `pchome_auto_policy_controlled_dry_run_package`;此 runtime check 自動驗證 controlled apply final preflight carry-forward、rollback binding、post-apply verifier binding、12 個 dry-run package checks、12 個 package fields、10 個 acceptance gates、receipt preview、result parser、non-executable command shape、same-run production truth 與 future dry-run receipt closeout readiness,但明確標記 `receipt_execution_performed=false`、`receipt_stdout_included=false`、`receipt_stderr_included=false`、`command_shape_execution_allowed=false`、`database_apply_authorized=false`、`reads_secret_count=0`、`executes_script_count=0`、`executes_sql_count=0`、`writes_database_count=0`、`signs_database_apply_authorization_count=0`、`primary_human_gate_count=0`、`executes_authorization_evidence=false`、`executes_database_apply=false`、`ready_for_database_apply_now=false`、`issues_database_apply_authorization=false`、`signs_database_apply_authorization=false`,不讀 secret、不執行 dry-run、不捕捉 stdout/stderr、不執行 authorization evidence、不執行 database apply、不執行 endpoint/SQL、不寫 DB、不簽發 apply authorization,只允許進入下一段 dry-run receipt closeout lane。 | | 2026-07-09 | PChome auto-policy controlled dry-run receipt closeout 必須有 runtime monitoring | V10.745 起 `/api/ai-automation/smoke`、`/api/ai-automation/scheduled-health-summary` 與 `/metrics` 必須輸出 `PChome auto-policy controlled dry-run receipt closeout` / `pchome_auto_policy_controlled_dry_run_receipt_closeout`;此 runtime check 自動驗證 controlled dry-run package carry-forward、dry-run result parser、receipt validation report、command-shape hash、rollback binding、post-apply verifier binding、12 個 receipt closeout checks、12 個 closeout fields、10 個 acceptance gates、same-run production truth 與 future runner readiness,但明確標記 `receipt_validation_status=preview_validated_not_executed`、`validation_execution_performed=false`、`validation_stdout_included=false`、`validation_stderr_included=false`、`validation_database_apply_authorized=false`、`reads_secret_count=0`、`executes_script_count=0`、`executes_sql_count=0`、`writes_database_count=0`、`signs_database_apply_authorization_count=0`、`primary_human_gate_count=0`、`executes_authorization_evidence=false`、`executes_database_apply=false`、`ready_for_database_apply_now=false`、`database_apply_authorized=false`、`issues_database_apply_authorization=false`、`signs_database_apply_authorization=false`,不讀 secret、不執行 dry-run、不捕捉 stdout/stderr、不執行 authorization evidence、不執行 database apply、不執行 endpoint/SQL、不寫 DB、不簽發 apply authorization,只允許進入下一段 controlled dry-run runner readiness lane。 | +| 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-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 new file mode 100644 index 0000000..d93ca16 --- /dev/null +++ b/docs/guides/ai_automation_mainline_work_items.md @@ -0,0 +1,38 @@ +# AI Automation Mainline Work Items + +> Updated: 2026-07-09 +> Scope: EwoooC / MOMO Pro System AI automation, PixelRAG, MCP/RAG, production UX. + +## Source Of Truth + +- Production readback stays authoritative after deploy: `/health`, container health, AI automation API readback, and smoke. +- Gitea is the only source-control truth. GitHub remains frozen and must not be used. +- Runtime truth must stay separate from source truth: a green test or registry does not mean MCP/RAG runtime is enabled. +- PixelRAG visual receipts are evidence, not formal price truth. They cannot write `competitor_prices`, `competitor_price_history`, or `ai_insights` without replay and promotion gates. + +## P0 + +| Status | Work item | Evidence / next machine action | +|---|---|---| +| Completed | Multi-commerce PixelRAG visual evidence lane for momo, pchome, shopee_tw, coupang_tw, yahoo_shopping_tw, etmall_tw, friday_tw, rakuten_tw | API and CLI can emit expansion plans and marketplace manifests; PChome parser failures can queue manifests. | +| 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. | +| In progress | 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. | +| 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 and PixelRAG RAG candidate replay 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 + +| Status | Work item | Evidence / next machine action | +|---|---|---| +| Not started | OCR/VLM replay contract for visual fields | Build a no-write replay that extracts title, price, promo badge, seller, rating, delivery badge from saved tiles and emits confidence/evidence only. | +| 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. | +| Not started | Marketplace source contracts beyond MOMO/PChome | Promote stable Shopee/Coupang/Yahoo/ETMall/friday/Rakuten receipts into source contracts only after provenance, rate-limit, public boundary, and replay evidence. | +| In progress | Professional product website UI/UX text reduction | Continue compact AI surfaces, visual QA, and user-facing copy guardrails; avoid engineering logs or work-session prose in the UI. | + +## Always Enforced + +- Manual review is exception-only for low-risk, machine-verifiable controlled apply. +- Break-glass still applies to secrets, destructive DB actions, force pushes, production provider switchovers, raw runtime volumes, and unverified core model replacement. +- AI automation smoke must expose health honestly: `MCP_ROUTER_ENABLED=false` or `RAG_ENABLED=false` is a runtime gap, not a source-code failure. diff --git a/docs/guides/ai_automation_session_sop.md b/docs/guides/ai_automation_session_sop.md index c552759..69733c7 100644 --- a/docs/guides/ai_automation_session_sop.md +++ b/docs/guides/ai_automation_session_sop.md @@ -28,6 +28,9 @@ - AI 自動化閉環變更必須確認 `/api/ai-automation/smoke` 與 `/ai_automation_smoke` 仍能反映新狀態。 - 外部 MCP / RAG 能力導入內部治理時,必須確認 `/api/ai-automation/external-mcp-rag-integration` 或 `python scripts/ops/report_external_mcp_rag_integration.py` 可讀回每個能力的內部落點、狀態、資料邊界與下一個機器動作。 +- PixelRAG 視覺 receipt 導入內部 RAG 時,必須確認 `/api/ai-automation/pixelrag-rag-candidate-replay` + 或 `python scripts/ops/report_pixelrag_rag_candidate_replay.py` 可讀回 eligible / blocked / invalid receipts,且 blocked page 不得被視為商品資料。 +- AI automation smoke 必須包含 external MCP/RAG integration 與 PixelRAG RAG candidate replay family,避免 registry 已完成但 runtime flag / receipt replay 未完成時被誤報為全自動閉環。 - 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 39f1bc6..0e8f8f0 100644 --- a/docs/guides/browse_sh_crawler_playbook.md +++ b/docs/guides/browse_sh_crawler_playbook.md @@ -122,6 +122,7 @@ python scripts/ops/report_pixelrag_crawler_integration.py \ /api/ai-automation/pixelrag-crawler-integration?platform=coupang_tw&marketplace_keyword=iphone /api/ai-automation/pixelrag-crawler-integration?platform=pchome&manifest_url=https://24h.pchome.com.tw/prod/TEST-000000001&crawler=PChomeCrawler.search_products&trigger_reason=parser_empty /api/ai-automation/pixelrag-visual-evidence-readback?platform=pchome&manifest_id=4a93e95e5afb414bc8c3 +/api/ai-automation/pixelrag-rag-candidate-replay?platform=shopee_tw&platform=coupang_tw ``` 視覺證據 capture worker: @@ -161,6 +162,15 @@ runtime_artifacts/pixelrag_visual_evidence///capture_rece API readback 只讀 receipt 和檔案存在狀態,不重新抓外站、不寫資料庫。 正式 app 容器內的 readback 預設讀 `/app/data/ai_automation/pixelrag_visual_evidence`;host worker 若直接在 188 執行,預設仍可用 `runtime_artifacts/pixelrag_visual_evidence`,需要給 API 回讀時再同步到容器掛載的 `data/ai_automation`。 +RAG candidate replay: + +```bash +python scripts/ops/report_pixelrag_rag_candidate_replay.py +python scripts/ops/report_pixelrag_rag_candidate_replay.py --platform shopee_tw --platform coupang_tw +``` + +此 replay 只把 receipt 轉成內部 RAG candidate preview;eligible receipt 仍需 OCR/VLM replay、identity matcher replay、PromotionGate 與 embedding signature guard,blocked / 403 / captcha / access denied receipt 只能進 platform probe 或 structured API 策略,不得當成商品資料。 + 安全邊界: - read-only;不登入、不下單、不加入購物車、不寫第三方狀態。 diff --git a/routes/system_public_routes.py b/routes/system_public_routes.py index d2813bf..5f61ccb 100644 --- a/routes/system_public_routes.py +++ b/routes/system_public_routes.py @@ -689,6 +689,28 @@ def ai_automation_pixelrag_visual_evidence_readback_api(): )) +@system_public_bp.route('/api/ai-automation/pixelrag-rag-candidate-replay') +@login_required +def ai_automation_pixelrag_rag_candidate_replay_api(): + """Read-only PixelRAG receipt to internal RAG candidate replay readback.""" + from services.pixelrag_rag_candidate_replay_service import ( + build_pixelrag_rag_candidate_replay_readback, + ) + + platforms = tuple( + str(item or '').strip() + for item in request.args.getlist('platform') + if str(item or '').strip() + ) + max_age_hours = request.args.get('max_age_hours', 168, type=int) + limit = request.args.get('limit', 50, type=int) + return jsonify(build_pixelrag_rag_candidate_replay_readback( + platform=platforms, + max_age_hours=max(1, min(max_age_hours or 168, 720)), + limit=max(1, min(limit or 50, 250)), + )) + + @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/report_pixelrag_rag_candidate_replay.py b/scripts/ops/report_pixelrag_rag_candidate_replay.py new file mode 100755 index 0000000..9520f8c --- /dev/null +++ b/scripts/ops/report_pixelrag_rag_candidate_replay.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Report PixelRAG visual receipts as internal RAG candidate replay payloads.""" + +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_rag_candidate_replay_service import ( # noqa: E402 + build_pixelrag_rag_candidate_replay_readback, +) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="輸出 PixelRAG receipt 對內部 RAG candidate replay 的機器可讀讀回。" + ) + parser.add_argument( + "--artifact-root", + help="PixelRAG visual evidence artifact root;預設使用 production/container 設定。", + ) + 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=50, + help="最多輸出 receipt candidate 數。", + ) + args = parser.parse_args() + + payload = build_pixelrag_rag_candidate_replay_readback( + artifact_root=args.artifact_root, + platform=tuple(args.platforms or ()), + max_age_hours=args.max_age_hours, + limit=args.limit, + ) + 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 37551ea..1038686 100644 --- a/services/ai_automation_smoke_service.py +++ b/services/ai_automation_smoke_service.py @@ -510,6 +510,16 @@ def build_scheduled_automation_health_summary( if not visual_qa_readback or not visual_qa_details: visual_qa_readback = _latest_sitewide_visual_qa_readback_check() visual_qa_details = visual_qa_readback.get("details") or {} + external_mcp_rag = _find_check(source_result, "External MCP/RAG integration readback") + external_mcp_rag_details = external_mcp_rag.get("details") or {} + if not external_mcp_rag or not external_mcp_rag_details: + external_mcp_rag = _external_mcp_rag_integration_check() + external_mcp_rag_details = external_mcp_rag.get("details") or {} + pixelrag_rag_replay = _find_check(source_result, "PixelRAG RAG candidate replay") + pixelrag_rag_replay_details = pixelrag_rag_replay.get("details") or {} + if not pixelrag_rag_replay or not pixelrag_rag_replay_details: + pixelrag_rag_replay = _pixelrag_rag_candidate_replay_check() + pixelrag_rag_replay_details = pixelrag_rag_replay.get("details") or {} smoke_status = source_result.get("status") or ("warning" if latest_history else "warning") freshness_family = _history_freshness_family( latest_history, @@ -4473,6 +4483,68 @@ def build_scheduled_automation_health_summary( "writes_database": False, }, }, + { + "key": "external_mcp_rag_integration", + "label": "External MCP/RAG integration", + "status": external_mcp_rag.get("status") or "warning", + "summary": ( + external_mcp_rag.get("summary") + or "External MCP/RAG integration has no latest readback." + ), + "next_machine_action": external_mcp_rag_details.get("next_machine_action") + or "refresh_external_mcp_rag_integration_readback", + "details": { + "policy": external_mcp_rag_details.get("policy"), + "integration_status": external_mcp_rag_details.get("integration_status"), + "total_capabilities": int( + external_mcp_rag_details.get("total_capabilities") or 0 + ), + "absorbed_count": int(external_mcp_rag_details.get("absorbed_count") or 0), + "unresolved_count": int( + external_mcp_rag_details.get("unresolved_count") or 0 + ), + "mcp_router_enabled": bool( + external_mcp_rag_details.get("mcp_router_enabled") + ), + "rag_enabled": bool(external_mcp_rag_details.get("rag_enabled")), + "gemini_hard_disabled": bool( + external_mcp_rag_details.get("gemini_hard_disabled") + ), + "writes_database": False, + "writes_database_count": 0, + "primary_human_gate_count": 0, + }, + }, + { + "key": "pixelrag_rag_candidate_replay", + "label": "PixelRAG RAG candidate replay", + "status": pixelrag_rag_replay.get("status") or "warning", + "summary": ( + pixelrag_rag_replay.get("summary") + or "PixelRAG RAG candidate replay has no latest readback." + ), + "next_machine_action": pixelrag_rag_replay_details.get("next_machine_action") + or "run_pixelrag_rag_candidate_replay_readback", + "details": { + "policy": pixelrag_rag_replay_details.get("policy"), + "receipt_count": int(pixelrag_rag_replay_details.get("receipt_count") or 0), + "eligible_count": int(pixelrag_rag_replay_details.get("eligible_count") or 0), + "blocked_count": int(pixelrag_rag_replay_details.get("blocked_count") or 0), + "invalid_count": int(pixelrag_rag_replay_details.get("invalid_count") or 0), + "visual_barrier_count": int( + pixelrag_rag_replay_details.get("visual_barrier_count") or 0 + ), + "missing_file_count": int( + pixelrag_rag_replay_details.get("missing_file_count") or 0 + ), + "blocked_pages_are_not_product_data": bool( + pixelrag_rag_replay_details.get("blocked_pages_are_not_product_data") + ), + "writes_database": False, + "writes_database_count": 0, + "primary_human_gate_count": 0, + }, + }, freshness_family, { "key": "daily_summary_delivery", @@ -12364,6 +12436,124 @@ def _latest_sitewide_visual_qa_readback_check() -> Dict[str, Any]: ) +def _external_mcp_rag_integration_check() -> Dict[str, Any]: + """Read-only sentinel for external MCP/RAG registry and runtime flags.""" + try: + from services.external_mcp_rag_integration_service import ( + build_external_mcp_rag_integration_readback, + ) + + readback = build_external_mcp_rag_integration_readback() + completion = readback.get("completion") or {} + runtime = readback.get("runtime") or {} + env_flags = runtime.get("env_flags") or {} + mcp_runtime = runtime.get("mcp") or {} + rag_runtime = runtime.get("rag") or {} + unresolved_count = int(completion.get("unresolved_count") or 0) + absorbed_count = int(completion.get("absorbed_count") or 0) + total_capabilities = int(completion.get("total_capabilities") or 0) + mcp_enabled = bool(env_flags.get("MCP_ROUTER_ENABLED") or mcp_runtime.get("enabled")) + rag_enabled = bool(env_flags.get("RAG_ENABLED") or rag_runtime.get("enabled")) + fully_integrated = readback.get("status") == "fully_integrated" + status = "ok" if fully_integrated and mcp_enabled and rag_enabled else "warning" + summary = ( + f"External MCP/RAG registry {absorbed_count}/{total_capabilities} absorbed; " + f"unresolved={unresolved_count}; runtime mcp={mcp_enabled} rag={rag_enabled}" + ) + return _check( + "External MCP/RAG integration readback", + status, + summary, + { + "policy": readback.get("policy"), + "integration_status": readback.get("status"), + "total_capabilities": total_capabilities, + "absorbed_count": absorbed_count, + "unresolved_count": unresolved_count, + "mcp_router_enabled": mcp_enabled, + "rag_enabled": rag_enabled, + "gemini_hard_disabled": bool(env_flags.get("GEMINI_API_HARD_DISABLED")), + "mcp_caller_count": int(mcp_runtime.get("caller_count") or 0), + "rag_vector_store": rag_runtime.get("vector_store"), + "next_machine_action": ( + "keep_external_mcp_rag_runtime_monitoring" + if status == "ok" + else "complete_mcp_router_health_and_rag_runtime_enablement" + ), + "writes_database": False, + "writes_database_count": 0, + "primary_human_gate_count": 0, + }, + ) + except Exception as exc: + return _check( + "External MCP/RAG integration readback", + "critical", + f"External MCP/RAG integration readback 無法執行:{exc}", + { + "writes_database": False, + "writes_database_count": 0, + "primary_human_gate_count": 0, + }, + ) + + +def _pixelrag_rag_candidate_replay_check() -> Dict[str, Any]: + """Read-only sentinel for PixelRAG receipts becoming internal RAG candidates.""" + try: + from services.pixelrag_rag_candidate_replay_service import ( + build_pixelrag_rag_candidate_replay_readback, + ) + + readback = build_pixelrag_rag_candidate_replay_readback() + summary = readback.get("summary") or {} + receipt_count = int(summary.get("receipt_count") or 0) + eligible_count = int(summary.get("eligible_count") or 0) + blocked_count = int(summary.get("blocked_count") or 0) + invalid_count = int(summary.get("invalid_count") or 0) + visual_barrier_count = int(summary.get("visual_barrier_count") or 0) + status = readback.get("status") or "warning" + summary_text = ( + f"PixelRAG RAG candidate replay receipts={receipt_count}, " + f"eligible={eligible_count}, blocked={blocked_count}, invalid={invalid_count}, " + f"barriers={visual_barrier_count}" + ) + return _check( + "PixelRAG RAG candidate replay", + status, + summary_text, + { + "policy": readback.get("policy"), + "receipt_count": receipt_count, + "eligible_count": eligible_count, + "blocked_count": blocked_count, + "invalid_count": invalid_count, + "visual_barrier_count": visual_barrier_count, + "tile_file_count": int(summary.get("tile_file_count") or 0), + "missing_file_count": int(summary.get("missing_file_count") or 0), + "stale_count": int(summary.get("stale_count") or 0), + "blocked_pages_are_not_product_data": bool( + (readback.get("replay_contract") or {}).get("blocked_pages_are_not_product_data") + ), + "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 RAG candidate replay", + "critical", + f"PixelRAG RAG candidate replay 無法執行:{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(), @@ -12396,6 +12586,8 @@ def collect_ai_automation_smoke(*, record_history: bool = True, history_limit: i _ai_surface_html_readback_check(), _sitewide_ui_ux_agent_check(), _sitewide_visual_qa_check(), + _external_mcp_rag_integration_check(), + _pixelrag_rag_candidate_replay_check(), ] worst = max(checks, key=lambda item: STATUS_RANK.get(item["status"], 2))["status"] result = { diff --git a/services/pixelrag_rag_candidate_replay_service.py b/services/pixelrag_rag_candidate_replay_service.py new file mode 100644 index 0000000..c390e21 --- /dev/null +++ b/services/pixelrag_rag_candidate_replay_service.py @@ -0,0 +1,338 @@ +"""Read-only PixelRAG receipt to internal RAG candidate replay bridge. + +The bridge only converts saved visual evidence receipts into machine-readable +candidate payloads. It does not OCR, call a model, write ai_insights, or promote +anything into the production price tables. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping + +from services.pixelrag_crawler_integration_service import ( + DEFAULT_ARTIFACT_MAX_AGE_HOURS, + DEFAULT_ARTIFACT_ROOT, + ECOMMERCE_PLATFORM_PROFILES, + _parse_iso_datetime, + _portable_receipt_file_path, + _receipt_candidates, + _safe_int, + _visual_barrier_reason, +) + + +POLICY = "read_only_pixelrag_rag_candidate_replay_v1" +INTERNAL_RAG_TARGET = "rag_service.learning_episode_candidate_preview" +DEFAULT_LIMIT = 50 + + +def _status_counts(items: list[dict[str, Any]]) -> dict[str, int]: + counts: dict[str, int] = {} + for item in items: + status = str(item.get("candidate_status") or "unknown") + counts[status] = counts.get(status, 0) + 1 + return counts + + +def _platform_from_receipt(receipt_path: Path, receipt: Mapping[str, Any]) -> str: + capture_target = receipt.get("capture_target") or {} + platform = str(capture_target.get("platform") or "").strip().lower() + if platform: + return platform + try: + return receipt_path.parent.parent.name + except IndexError: + return "unknown" + + +def _manifest_id_from_receipt(receipt_path: Path, receipt: Mapping[str, Any]) -> str: + return str(receipt.get("manifest_id") or receipt_path.parent.name or "").strip() + + +def _count_receipt_files(receipt_path: Path, receipt: Mapping[str, Any]) -> dict[str, Any]: + tile_file_count = 0 + missing_file_count = 0 + file_statuses: list[dict[str, Any]] = [] + for item in list(receipt.get("files") or []): + kind = item.get("kind") + path = _portable_receipt_file_path(receipt_path, item.get("path"), kind) + exists = path.exists() + if kind == "tile": + tile_file_count += 1 + if not exists: + missing_file_count += 1 + file_statuses.append({ + "kind": kind, + "path": str(path), + "exists": exists, + }) + return { + "tile_file_count": tile_file_count, + "missing_file_count": missing_file_count, + "files": file_statuses, + } + + +def _candidate_text( + *, + platform: str, + manifest_id: str, + receipt: Mapping[str, Any], + file_summary: Mapping[str, Any], + barrier_reason: str, +) -> str: + capture_target = receipt.get("capture_target") or {} + page_metrics = receipt.get("page_metrics") or {} + profile = ECOMMERCE_PLATFORM_PROFILES.get(platform) or {} + url = str(capture_target.get("url") or page_metrics.get("final_url") or "").strip() + title = str(page_metrics.get("title") or "").strip() + data_targets = ", ".join(str(item) for item in list(profile.get("data_targets") or [])[:8]) + parts = [ + f"platform={platform}", + f"manifest_id={manifest_id}", + f"title={title or 'unknown'}", + f"url={url or 'unknown'}", + f"http_status={_safe_int(receipt.get('http_status'))}", + f"capture_status={receipt.get('status') or 'unknown'}", + f"tile_file_count={int(file_summary.get('tile_file_count') or 0)}", + f"missing_file_count={int(file_summary.get('missing_file_count') or 0)}", + ] + if data_targets: + parts.append(f"intended_fields={data_targets}") + if barrier_reason: + parts.append(f"visual_barrier={barrier_reason}") + return " | ".join(parts) + + +def _candidate_from_receipt_path( + receipt_path: Path, + *, + now: datetime, + max_age_hours: int, +) -> dict[str, Any]: + errors: list[str] = [] + try: + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + receipt = {} + errors.append(str(exc)[:300]) + + platform = _platform_from_receipt(receipt_path, receipt) + manifest_id = _manifest_id_from_receipt(receipt_path, receipt) + generated_at = _parse_iso_datetime(receipt.get("generated_at")) + age_hours = ((now - generated_at).total_seconds() / 3600) if generated_at else None + stale = age_hours is None or age_hours > max_age_hours + file_summary = _count_receipt_files(receipt_path, receipt) + missing_file_count = int(file_summary.get("missing_file_count") or 0) + barrier_reason = _visual_barrier_reason(receipt) + capture_status = str(receipt.get("status") or "").strip().lower() + http_status = _safe_int(receipt.get("http_status")) + + blocked_reasons: list[str] = [] + if errors: + blocked_reasons.append("receipt_parse_error") + if capture_status != "captured": + blocked_reasons.append("receipt_not_captured") + if stale: + blocked_reasons.append("receipt_stale_or_missing_timestamp") + if missing_file_count: + blocked_reasons.append("receipt_missing_files") + if barrier_reason: + blocked_reasons.append(f"visual_barrier:{barrier_reason}") + if http_status >= 400 and not barrier_reason: + blocked_reasons.append(f"http_status_{http_status}") + + eligible = not blocked_reasons + candidate_status = "eligible" if eligible else ("invalid" if errors else "blocked") + capture_target = receipt.get("capture_target") or {} + page_metrics = receipt.get("page_metrics") or {} + + return { + "candidate_status": candidate_status, + "eligible_for_rag_candidate_replay": eligible, + "blocked_reasons": blocked_reasons, + "platform": platform, + "manifest_id": manifest_id, + "receipt_path": str(receipt_path), + "generated_at": receipt.get("generated_at"), + "age_hours": round(age_hours, 3) if age_hours is not None else None, + "stale": stale, + "http_status": http_status, + "capture_status": receipt.get("status"), + "url": capture_target.get("url") or page_metrics.get("final_url"), + "title": page_metrics.get("title"), + "visual_barrier_detected": bool(barrier_reason), + "visual_barrier_reason": barrier_reason, + "tile_file_count": int(file_summary.get("tile_file_count") or 0), + "missing_file_count": missing_file_count, + "rag_candidate_text": _candidate_text( + platform=platform, + manifest_id=manifest_id, + receipt=receipt, + file_summary=file_summary, + barrier_reason=barrier_reason, + ), + "internal_rag_target": INTERNAL_RAG_TARGET, + "promotion_boundary": { + "direct_ai_insights_write_allowed": False, + "direct_price_write_allowed": False, + "requires_ocr_or_vlm_replay": True, + "requires_identity_matcher_replay": True, + "requires_promotion_gate": True, + "requires_embedding_signature_guard": True, + }, + "next_machine_action": ( + "run_ocr_vlm_replay_then_promotion_gate" + if eligible + else ( + "run_platform_probe_or_use_structured_api" + if barrier_reason + else "refresh_pixelrag_visual_capture_receipt" + ) + ), + "file_summary": file_summary, + } + + +def _unique_receipt_candidates( + root: Path, + *, + platforms: tuple[str, ...] | list[str] | None, + limit: int, +) -> list[Path]: + seen: set[Path] = set() + candidates: list[Path] = [] + selected_platforms = [ + str(platform or "").strip().lower() + for platform in (platforms or []) + if str(platform or "").strip() + ] + if selected_platforms: + for platform in selected_platforms: + for path in _receipt_candidates(root, platform=platform): + resolved = path.resolve() + if resolved not in seen: + seen.add(resolved) + candidates.append(path) + else: + for path in _receipt_candidates(root): + resolved = path.resolve() + if resolved not in seen: + seen.add(resolved) + candidates.append(path) + return sorted(candidates, key=lambda path: path.stat().st_mtime, reverse=True)[:limit] + + +def build_pixelrag_rag_candidate_replay_readback( + *, + artifact_root: str | Path | None = None, + platform: str | tuple[str, ...] | list[str] | None = None, + max_age_hours: int | None = None, + limit: int | None = None, +) -> dict[str, Any]: + """Build a replayable RAG candidate readback from PixelRAG receipts.""" + root = Path(artifact_root or DEFAULT_ARTIFACT_ROOT) + 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)) + if isinstance(platform, str): + platforms = (platform.strip().lower(),) if platform.strip() else None + else: + platforms = tuple( + str(item or "").strip().lower() + for item in (platform or []) + if str(item or "").strip() + ) or None + now = datetime.now(timezone.utc) + + candidates = _unique_receipt_candidates(root, platforms=platforms, limit=item_limit) + replay_candidates = [ + _candidate_from_receipt_path(path, now=now, max_age_hours=max_age) + for path in candidates + ] + eligible_count = sum(1 for item in replay_candidates if item["candidate_status"] == "eligible") + blocked_count = sum(1 for item in replay_candidates if item["candidate_status"] == "blocked") + invalid_count = sum(1 for item in replay_candidates if item["candidate_status"] == "invalid") + visual_barrier_count = sum(1 for item in replay_candidates if item["visual_barrier_detected"]) + missing_file_count = sum(int(item.get("missing_file_count") or 0) for item in replay_candidates) + tile_file_count = sum(int(item.get("tile_file_count") or 0) for item in replay_candidates) + stale_count = sum(1 for item in replay_candidates if item.get("stale")) + + if invalid_count and invalid_count == len(replay_candidates): + status = "critical" + elif eligible_count == 0 or blocked_count or invalid_count or visual_barrier_count: + status = "warning" + else: + status = "ok" + + if not replay_candidates: + status = "warning" + + summary = { + "receipt_count": len(replay_candidates), + "eligible_count": eligible_count, + "blocked_count": blocked_count, + "invalid_count": invalid_count, + "visual_barrier_count": visual_barrier_count, + "tile_file_count": tile_file_count, + "missing_file_count": missing_file_count, + "stale_count": stale_count, + "status_counts": _status_counts(replay_candidates), + "platforms": sorted({str(item.get("platform") or "unknown") for item in replay_candidates}), + } + + return { + "success": invalid_count == 0, + "policy": POLICY, + "status": status, + "generated_at": now.isoformat(), + "artifact_root": str(root), + "max_age_hours": max_age, + "platform_filter": list(platforms or []), + "summary": summary, + "candidates": replay_candidates, + "replay_contract": { + "internal_rag_target": INTERNAL_RAG_TARGET, + "writes_database": False, + "writes_ai_insights": False, + "writes_price_tables": False, + "network_call": False, + "secret_read": False, + "promotion_gate_required_before_knowledge_write": True, + "ocr_or_vlm_required_before_field_extraction": True, + "blocked_pages_are_not_product_data": True, + }, + "next_machine_action": ( + "run_pixelrag_visual_capture_worker" + if not replay_candidates + else ( + "fix_invalid_pixelrag_receipts" + if invalid_count and not eligible_count + else ( + "run_platform_probe_or_use_structured_api" + if visual_barrier_count and not eligible_count + else ( + "run_ocr_vlm_replay_then_promotion_gate" + if eligible_count + else "refresh_pixelrag_visual_capture_receipt" + ) + ) + ) + ), + "controlled_apply": { + "network_call": False, + "db_write": False, + "secret_read": False, + "production_price_write": False, + "artifact_write": False, + }, + } + + +__all__ = [ + "INTERNAL_RAG_TARGET", + "POLICY", + "build_pixelrag_rag_candidate_replay_readback", +] diff --git a/tests/test_ai_automation_smoke_service.py b/tests/test_ai_automation_smoke_service.py index d30bf39..e5ce206 100644 --- a/tests/test_ai_automation_smoke_service.py +++ b/tests/test_ai_automation_smoke_service.py @@ -1308,11 +1308,13 @@ def test_collect_ai_automation_smoke_uses_worst_status(monkeypatch): monkeypatch.setattr(smoke, "_ai_surface_html_readback_check", lambda: smoke._check("surface", "ok", "ok")) monkeypatch.setattr(smoke, "_sitewide_ui_ux_agent_check", lambda: smoke._check("sitewide", "ok", "ok")) monkeypatch.setattr(smoke, "_sitewide_visual_qa_check", lambda: smoke._check("visual", "ok", "ok")) + 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")) result = smoke.collect_ai_automation_smoke(record_history=False) assert result["status"] == "critical" - assert result["summary"] == {"ok": 28, "warning": 1, "critical": 1, "total": 30} + assert result["summary"] == {"ok": 30, "warning": 1, "critical": 1, "total": 32} def test_pchome_controlled_apply_drift_monitor_reports_verified_zero_drift(monkeypatch): @@ -3839,6 +3841,8 @@ def test_collect_ai_automation_smoke_persists_recent_history(tmp_path, monkeypat monkeypatch.setattr(smoke, "_ai_surface_html_readback_check", lambda: smoke._check("surface", "ok", "ok")) monkeypatch.setattr(smoke, "_sitewide_ui_ux_agent_check", lambda: smoke._check("sitewide", "ok", "ok")) monkeypatch.setattr(smoke, "_sitewide_visual_qa_check", lambda: smoke._check("visual", "ok", "ok")) + 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")) first = smoke.collect_ai_automation_smoke(history_limit=5) second = smoke.collect_ai_automation_smoke(history_limit=5) @@ -3894,7 +3898,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": 30, "warning": 0, "critical": 0, "total": 30}, + "summary": {"ok": 32, "warning": 0, "critical": 0, "total": 32}, "checks": [ { "name": "PChome 受控落地 drift monitor", @@ -3955,10 +3959,10 @@ def test_scheduled_automation_health_summary_reads_history_without_side_effects( "repair_action_count": 0, }, }, - { - "name": "Sitewide visual QA readback", - "status": "ok", - "summary": "Sitewide visual QA ok,12 routes x 3 viewports 無 overflow 退化", + { + "name": "Sitewide visual QA readback", + "status": "ok", + "summary": "Sitewide visual QA ok,12 routes x 3 viewports 無 overflow 退化", "details": { "policy": "read_only_sitewide_visual_qa_readback_v1", "result_count": 36, @@ -3969,11 +3973,42 @@ def test_scheduled_automation_health_summary_reads_history_without_side_effects( "visual_offender_count": 0, "artifact_path": "data/ai_automation/sitewide_visual_qa_latest.json", "artifact_generated_at": datetime.now().isoformat(timespec="seconds"), - "stale": False, + "stale": False, + }, }, - } - ], - }, ensure_ascii=False) + "\n", + { + "name": "External MCP/RAG integration readback", + "status": "ok", + "summary": "External MCP/RAG registry 9/9 absorbed; unresolved=0; runtime mcp=True rag=True", + "details": { + "policy": "read_only_external_mcp_rag_integration_readback_v1", + "integration_status": "fully_integrated", + "total_capabilities": 9, + "absorbed_count": 9, + "unresolved_count": 0, + "mcp_router_enabled": True, + "rag_enabled": True, + "gemini_hard_disabled": True, + "next_machine_action": "keep_external_mcp_rag_runtime_monitoring", + }, + }, + { + "name": "PixelRAG RAG candidate replay", + "status": "ok", + "summary": "PixelRAG RAG candidate replay receipts=1, eligible=1, blocked=0, invalid=0, barriers=0", + "details": { + "policy": "read_only_pixelrag_rag_candidate_replay_v1", + "receipt_count": 1, + "eligible_count": 1, + "blocked_count": 0, + "invalid_count": 0, + "visual_barrier_count": 0, + "blocked_pages_are_not_product_data": True, + "next_machine_action": "run_ocr_vlm_replay_then_promotion_gate", + }, + } + ], + }, ensure_ascii=False) + "\n", encoding="utf-8", ) monkeypatch.setattr(smoke, "_HISTORY_PATH", str(history_path)) @@ -3989,7 +4024,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"] == 27 + assert summary["summary"]["total"] == 29 assert summary["summary"]["primary_human_gate_count"] == 0 assert summary["summary"]["writes_database_count"] == 0 assert pchome_family["status"] == "ok" @@ -5653,6 +5688,18 @@ def test_scheduled_automation_health_summary_reads_history_without_side_effects( assert visual_family["status"] == "ok" assert visual_family["details"]["route_count"] == 12 assert visual_family["details"]["failed_count"] == 0 + external_mcp_rag_family = next( + item for item in summary["families"] + if item["key"] == "external_mcp_rag_integration" + ) + assert external_mcp_rag_family["status"] == "ok" + assert external_mcp_rag_family["details"]["unresolved_count"] == 0 + pixelrag_rag_replay_family = next( + item for item in summary["families"] + if item["key"] == "pixelrag_rag_candidate_replay" + ) + assert pixelrag_rag_replay_family["status"] == "ok" + assert pixelrag_rag_replay_family["details"]["eligible_count"] == 1 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" @@ -6259,6 +6306,16 @@ def test_surface_html_readback_check_is_part_of_ai_smoke(monkeypatch): "visual ok", {"route_count": 12, "viewport_count": 3, "failed_count": 0}, )) + monkeypatch.setattr(smoke, "_external_mcp_rag_integration_check", lambda: smoke._check( + "External MCP/RAG integration readback", + "ok", + "external mcp rag ok", + )) + monkeypatch.setattr(smoke, "_pixelrag_rag_candidate_replay_check", lambda: smoke._check( + "PixelRAG RAG candidate replay", + "ok", + "pixelrag replay ok", + )) result = smoke.collect_ai_automation_smoke(record_history=False) @@ -6274,7 +6331,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"] == 30 + assert result["summary"]["total"] == 32 assert surface_check["status"] == "ok" assert surface_check["details"]["checked_surface_count"] == 10 assert sitewide_check["status"] == "ok" diff --git a/tests/test_pixelrag_rag_candidate_replay_service.py b/tests/test_pixelrag_rag_candidate_replay_service.py new file mode 100644 index 0000000..de9628a --- /dev/null +++ b/tests/test_pixelrag_rag_candidate_replay_service.py @@ -0,0 +1,138 @@ +import json +import subprocess +import sys +from datetime import datetime, timezone + + +def _write_receipt(root, *, platform, manifest_id, title, url, http_status=200): + receipt_dir = root / platform / manifest_id + tiles_dir = receipt_dir / "tiles" + tiles_dir.mkdir(parents=True) + screenshot = receipt_dir / "screenshot.png" + screenshot.write_bytes(b"png") + files = [{"kind": "screenshot", "path": str(screenshot)}] + for index in range(4): + tile = tiles_dir / f"tile-{index}.png" + tile.write_bytes(b"tile") + files.append({"kind": "tile", "path": str(tile)}) + receipt = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "status": "captured", + "manifest_id": manifest_id, + "http_status": http_status, + "capture_target": { + "platform": platform, + "url": url, + "crawler": "PixelRAGMarketplaceSearch.visual_fallback", + }, + "page_metrics": { + "title": title, + "final_url": url, + }, + "tile_plan": { + "planned_tile_count": 4, + "emitted_tile_count": 4, + }, + "files": files, + } + path = receipt_dir / "capture_receipt.json" + path.write_text(json.dumps(receipt, ensure_ascii=False), encoding="utf-8") + return path + + +def test_pixelrag_rag_candidate_replay_splits_eligible_and_blocked_receipts(tmp_path): + from services.pixelrag_rag_candidate_replay_service import ( + POLICY, + build_pixelrag_rag_candidate_replay_readback, + ) + + _write_receipt( + tmp_path, + platform="shopee_tw", + manifest_id="shopee-ok", + title="Shopee 防曬乳", + url="https://shopee.tw/search?keyword=%E9%98%B2%E6%9B%AC%E4%B9%B3", + ) + _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 = build_pixelrag_rag_candidate_replay_readback(artifact_root=tmp_path) + by_platform = {item["platform"]: item for item in payload["candidates"]} + + assert payload["policy"] == POLICY + assert payload["status"] == "warning" + assert payload["summary"]["receipt_count"] == 2 + assert payload["summary"]["eligible_count"] == 1 + assert payload["summary"]["blocked_count"] == 1 + assert payload["summary"]["visual_barrier_count"] == 1 + assert by_platform["shopee_tw"]["eligible_for_rag_candidate_replay"] is True + assert by_platform["shopee_tw"]["promotion_boundary"]["direct_ai_insights_write_allowed"] is False + assert "Shopee 防曬乳" in by_platform["shopee_tw"]["rag_candidate_text"] + assert by_platform["coupang_tw"]["eligible_for_rag_candidate_replay"] is False + assert by_platform["coupang_tw"]["visual_barrier_reason"] == "http_status_403" + assert by_platform["coupang_tw"]["next_machine_action"] == "run_platform_probe_or_use_structured_api" + assert payload["replay_contract"]["blocked_pages_are_not_product_data"] is True + assert payload["controlled_apply"]["db_write"] is False + + +def test_pixelrag_rag_candidate_replay_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/report_pixelrag_rag_candidate_replay.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"]["eligible_count"] == 1 + assert payload["candidates"][0]["platform"] == "shopee_tw" + + +def test_pixelrag_rag_candidate_replay_route_returns_readback(tmp_path, monkeypatch): + from flask import Flask + from routes import system_public_routes as routes + from services import pixelrag_rag_candidate_replay_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-rag-candidate-replay?platform=shopee_tw" + ): + response = routes.ai_automation_pixelrag_rag_candidate_replay_api.__wrapped__() + payload = response.get_json() + + assert payload["policy"] == "read_only_pixelrag_rag_candidate_replay_v1" + assert payload["summary"]["receipt_count"] == 1 + assert payload["summary"]["eligible_count"] == 1 + assert payload["replay_contract"]["writes_database"] is False