From e7e045253d2055c16225dcd180fb45002bf3308a Mon Sep 17 00:00:00 2001 From: OoO Date: Tue, 19 May 2026 12:46:43 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A3=9C=20OpenClaw=20QA=20=E5=82=99=E6=8F=B4?= =?UTF-8?q?=E8=88=87=E5=B8=82=E5=A0=B4=E6=83=85=E5=A0=B1=E4=BA=A4=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO_NEXT_STEPS.txt | 4 +- ...s-platform-market-campaign-intelligence.md | 1 + routes/README.md | 2 +- routes/admin_observability_routes.py | 5 +- routes/market_intel_routes.py | 89 +++--- routes/openclaw_bot_routes.py | 2 +- services/llm_caller_registry.py | 2 + .../candidate_queue_review_handoff.py | 247 +++++++++++++++ services/market_intel/deployment_readiness.py | 57 ++-- services/market_intel/service.py | 2 +- services/openclaw_strategist_service.py | 21 +- templates/market_intel/disabled.html | 126 ++++++++ tests/test_admin_observability_routes.py | 52 ++++ tests/test_caller_registry.py | 3 + tests/test_market_intel_skeleton.py | 284 ++++++++++++++---- tests/test_openclaw_qa_routing.py | 99 +++++- 16 files changed, 853 insertions(+), 143 deletions(-) create mode 100644 services/market_intel/candidate_queue_review_handoff.py diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 13c6746..3fe7863 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.251 修正 OpenClaw Q&A 備援遙測:Ollama 主路徑仍為 GCP-A → GCP-B → 111,Gemini 只記為 `openclaw_qa_gemini_fallback`,NIM 只記為 `openclaw_qa_nim`;AI Calls 會把 legacy `openclaw_qa + gemini` 標成 Gemini 備援,避免再次誤判 Gemini-first。 - V10.251 穩定 `/growth_analysis` 正式站速度:成長分析快取從單 worker memory 擴充為 `data/growth_analysis_cache.pkl` 跨 worker 共享快取,避免 Gunicorn 冷 worker 偶發掃明細表造成 5 秒級 TTFB;補 `tests/test_cache_manager.py` 覆蓋 shared file roundtrip 與清除行為。 - V10.249 收斂 `/observability/ppt_audit_history` 手機與平板第一屏密度:將 4 個產線訊號從 hero 內移出成獨立狀態列,手機版維持 2 欄狀態卡並降低 hero 卡片間距;本機 10 個 AI 觀測台頁面 rendered visual contract 全數通過,PPT 頁 hero 高度 desktop/tablet/mobile 為 214/361/398px。 - V10.246 強化 `/observability/ppt_audit_history` 視覺 QA runtime 可讀性:功能開關、轉檔器與視覺模型改成中文 checklist,Vision QA 狀態卡直接顯示 runtime 就緒資訊,DB 產出狀態統一為「已產出」。 @@ -132,6 +133,7 @@ - Phase 61 candidate queue writer run readiness:新增 `services/market_intel/candidate_queue_writer_run_readiness.py`、POST `/api/market_intel/manual_sample_review/candidate_queue_writer_run_readiness` 與 UI readiness 按鈕,檢查 reviewed sample 檔案路徑、備份路徑、preflight 輸出、migration live smoke、shell-only token acknowledgement 與禁止 token 進 API;API/UI 不產檔、不讀 approval token、不執行 CLI、不連 DB、不寫 queue、不 commit、不掛 scheduler;版本同步至 V10.245。 - Phase 62 candidate queue writer run receipt:新增 `services/market_intel/candidate_queue_writer_run_receipt.py`、POST `/api/market_intel/manual_sample_review/candidate_queue_writer_run_receipt` 與 UI receipt 按鈕,審核 CLI 寫入後的 writer output、post-write smoke、dedupe key 一致性與 artifact 路徑;API/UI 不回吐 receipt 原文、不讀 approval token、不執行 CLI、不連 DB、不寫 queue、不掛 scheduler;版本同步至 V10.247。 - Phase 63 candidate queue writer run closeout:新增 `services/market_intel/candidate_queue_writer_run_closeout.py`、POST `/api/market_intel/manual_sample_review/candidate_queue_writer_run_closeout` 與 UI closeout 按鈕,在 receipt 通過後檢查 closeout artifact、操作員人工 queue review/read-only inventory 確認與安全 promotion gate;API/UI 不回吐原始 receipt、不讀 approval token、不執行 CLI、不連 DB、不寫 queue、不掛 scheduler;版本同步至 V10.248。 + - Phase 64 candidate queue review handoff:新增 `services/market_intel/candidate_queue_review_handoff.py`、POST `/api/market_intel/manual_sample_review/candidate_queue_review_handoff` 與 UI handoff 按鈕,將 writer closeout 轉成人工 queue review / read-only inventory 交接契約;API/UI 不讀 approval token、不查 DB、不更新 review_state、不補寫 queue、不掛 scheduler;版本同步至 V10.251。 - V10.248 補市場情報 390px preview panel QA:sample review 工具列改為 textarea + 可換行 action rail,移除舊的硬編 8 欄 grid;`check_responsive_overflow` 新增 `--screenshot-all`,本機 390x844 `/market_intel` 真頁面 QA 通過且 overflow=0。 - V10.250 補 Code Review Gemini 備援遙測護欄:Ollama 主路徑失敗時 `fallback_to` 明確指向 `code_review_openclaw_gemini`,測試鎖住「Gemini 不得記成 `code_review_openclaw` 主 caller」;AI Calls 觀測台會把 legacy `code_review_openclaw + gemini` 顯示成 Gemini 備援,避免誤判 Gemini-first。 - Schema smoke:`tests/test_market_intel_skeleton.py` 檢查 `Base.metadata` 內含 ADR-035 八張 `market_*` tables。 @@ -143,7 +145,7 @@ 【下次待辦】 - 正式端 seed writer token-hardening drift:優先白名單同步 main 的一次性環境 token 版本,避免 API 暴露固定 approval token hint。 - 正式推版前需實際執行 worktree scope review、`python backup_system.py`、commit/push 目標變更、讀 deployment SOP 與 ADR-011,且只跑 `/health` 與市場情報頁 smoke。 - - 下一步才可在明確批准且具備真實 reviewed sample JSON 後做正式 DB 的 queue writer CLI 小流量 operator run;先用 run readiness、run package、operator drill、post-write smoke、run receipt 與 run closeout 做順序與只讀驗證,預設 API/UI 不得寫 DB,也不得執行 migration。 + - 下一步才可在明確批准且具備真實 reviewed sample JSON 後做正式 DB 的 queue writer CLI 小流量 operator run;先用 run readiness、run package、operator drill、post-write smoke、run receipt、run closeout 與 queue review handoff 做順序與只讀驗證,預設 API/UI 不得寫 DB,也不得執行 migration。 - 市場情報 UI 後續頁面必須沿用 V2 暖紙、暖墨、等寬數字與點陣風格,禁止複製巨型分析頁 template 模式。 ================================================================================ diff --git a/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md b/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md index 40d651e..50c244d 100644 --- a/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md +++ b/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md @@ -189,6 +189,7 @@ EwoooC 目前已有 MOMO EDM / 節慶活動資料、`promo_products`、PChome - 2026-05-19 追加 candidate queue writer run readiness:`services.market_intel.candidate_queue_writer_run_readiness` 與 `/api/market_intel/manual_sample_review/candidate_queue_writer_run_readiness` 檢查正式 CLI 小流量寫入前的 reviewed sample 路徑、備份路徑、preflight 輸出、migration live smoke、shell-only token acknowledgement 與禁止 token 進 API。此 readiness 只輸出操作員證據缺口;API/UI 不產檔、不讀 approval token、不執行 CLI、不連 DB、不寫 queue、不 commit、不掛 scheduler。 - 2026-05-19 追加 candidate queue writer run receipt:`services.market_intel.candidate_queue_writer_run_receipt` 與 `/api/market_intel/manual_sample_review/candidate_queue_writer_run_receipt` 審核 CLI 小流量寫入後的 writer output、post-write smoke、dedupe key 一致性、artifact 路徑與 token 外洩風險。此 receipt 只輸出安全摘要;API/UI 不回吐 receipt 原文、不讀 approval token、不執行 CLI、不連 DB、不寫 queue、不 commit、不掛 scheduler。 - 2026-05-19 追加 candidate queue writer run closeout:`services.market_intel.candidate_queue_writer_run_closeout` 與 `/api/market_intel/manual_sample_review/candidate_queue_writer_run_closeout` 在 receipt 通過後整理 closeout gate、人工確認與下一階段 promotion 摘要。此 closeout 只允許放行到人工 queue review / read-only inventory;API/UI 不回吐原始 receipt、不讀 approval token、不執行 CLI、不連 DB、不寫 queue、不掛 scheduler。 +- 2026-05-19 追加 candidate queue review handoff:`services.market_intel.candidate_queue_review_handoff` 與 `/api/market_intel/manual_sample_review/candidate_queue_review_handoff` 將 closeout 後的 expected dedupe key、review contract、人工操作順序與 forbidden API actions 整理為交接包。此 handoff 不查 DB、不更新 `review_state`、不補寫 missing queue row、不讀 approval token、不掛 scheduler。 ### Phase 4:Coupang / Shopee Adapter diff --git a/routes/README.md b/routes/README.md index 66f9fef..9a36201 100644 --- a/routes/README.md +++ b/routes/README.md @@ -19,7 +19,7 @@ | `edm_routes.py` | EDM 與節慶儀表板 | `/edm`, `/festival` | | `monthly_routes.py` | 月結分析 | `/monthly_summary_analysis`, `/api/monthly_summary_data` | | `daily_sales_routes.py` | 當日業績 | `/daily_sales`, `/daily_sales/export*` | -| `market_intel_routes.py` | 市場情報 Phase 63 candidate queue writer run closeout | `/market_intel`, `/market_intel/*`, `/api/market_intel/status`, `/api/market_intel/schema`, `/api/market_intel/schema_smoke`, `/api/market_intel/schema_db_probe`, `/api/market_intel/platform_seed_db_diff`, `/api/market_intel/legacy_source_bridge`, `/api/market_intel/mcp_readiness`, `/api/market_intel/mcp_tool_contract`, `/api/market_intel/mcp_deploy_preflight`, `/api/market_intel/mcp_activation_runbook`, `/api/market_intel/mcp_fetch_gate`, `/api/market_intel/scheduler_plan`, `/api/market_intel/manual_sample_plan`, `/api/market_intel/manual_sample_acceptance`, `/api/market_intel/manual_sample_review`, `/api/market_intel/manual_sample_review/evaluate`, `/api/market_intel/manual_sample_review/candidate_handoff`, `/api/market_intel/manual_sample_review/candidate_queue_draft`, `/api/market_intel/manual_sample_review/candidate_queue_approval`, `/api/market_intel/manual_sample_review/candidate_queue_transaction`, `/api/market_intel/manual_sample_review/candidate_queue_writer_status`, `/api/market_intel/manual_sample_review/candidate_queue_writer_preflight`, `/api/market_intel/manual_sample_review/candidate_queue_writer_postwrite_smoke`, `/api/market_intel/manual_sample_review/candidate_queue_writer_operator_drill`, `/api/market_intel/manual_sample_review/candidate_queue_writer_run_package`, `/api/market_intel/manual_sample_review/candidate_queue_writer_run_readiness`, `/api/market_intel/manual_sample_review/candidate_queue_writer_run_receipt`, `/api/market_intel/manual_sample_review/candidate_queue_writer_run_closeout`, `/api/market_intel/match_review_plan`, `/api/market_intel/opportunity_plan`, `/api/market_intel/opportunity_scoring_plan`, `/api/market_intel/opportunity_evidence_plan`, `/api/market_intel/opportunity_alert_plan`, `/api/market_intel/adapters`, `/api/market_intel/dry_run_plan`, `/api/market_intel/discovery_plan`, `/api/market_intel/manual_discovery`, `/api/market_intel/candidate_preview`, `/api/market_intel/platform_seed_plan`, `/api/market_intel/platform_seed_write_guard`, `/api/market_intel/platform_seed_writer_plan`, `/api/market_intel/migration_blueprint`, `/api/market_intel/migration_apply_drill`, `/api/market_intel/migration_catalog_review`, `/api/market_intel/migration_live_smoke`, `/api/market_intel/live_db_inventory`, `/api/market_intel/seed_writer_cli_status`, `/api/market_intel/write_approval_runbook`, `/api/market_intel/deployment_readiness` | +| `market_intel_routes.py` | 市場情報 Phase 64 candidate queue review handoff | `/market_intel`, `/market_intel/*`, `/api/market_intel/status`, `/api/market_intel/schema`, `/api/market_intel/schema_smoke`, `/api/market_intel/schema_db_probe`, `/api/market_intel/platform_seed_db_diff`, `/api/market_intel/legacy_source_bridge`, `/api/market_intel/mcp_readiness`, `/api/market_intel/mcp_tool_contract`, `/api/market_intel/mcp_deploy_preflight`, `/api/market_intel/mcp_activation_runbook`, `/api/market_intel/mcp_fetch_gate`, `/api/market_intel/scheduler_plan`, `/api/market_intel/manual_sample_plan`, `/api/market_intel/manual_sample_acceptance`, `/api/market_intel/manual_sample_review`, `/api/market_intel/manual_sample_review/evaluate`, `/api/market_intel/manual_sample_review/candidate_handoff`, `/api/market_intel/manual_sample_review/candidate_queue_draft`, `/api/market_intel/manual_sample_review/candidate_queue_approval`, `/api/market_intel/manual_sample_review/candidate_queue_transaction`, `/api/market_intel/manual_sample_review/candidate_queue_writer_status`, `/api/market_intel/manual_sample_review/candidate_queue_writer_preflight`, `/api/market_intel/manual_sample_review/candidate_queue_writer_postwrite_smoke`, `/api/market_intel/manual_sample_review/candidate_queue_writer_operator_drill`, `/api/market_intel/manual_sample_review/candidate_queue_writer_run_package`, `/api/market_intel/manual_sample_review/candidate_queue_writer_run_readiness`, `/api/market_intel/manual_sample_review/candidate_queue_writer_run_receipt`, `/api/market_intel/manual_sample_review/candidate_queue_writer_run_closeout`, `/api/market_intel/manual_sample_review/candidate_queue_review_handoff`, `/api/market_intel/match_review_plan`, `/api/market_intel/opportunity_plan`, `/api/market_intel/opportunity_scoring_plan`, `/api/market_intel/opportunity_evidence_plan`, `/api/market_intel/opportunity_alert_plan`, `/api/market_intel/adapters`, `/api/market_intel/dry_run_plan`, `/api/market_intel/discovery_plan`, `/api/market_intel/manual_discovery`, `/api/market_intel/candidate_preview`, `/api/market_intel/platform_seed_plan`, `/api/market_intel/platform_seed_write_guard`, `/api/market_intel/platform_seed_writer_plan`, `/api/market_intel/migration_blueprint`, `/api/market_intel/migration_apply_drill`, `/api/market_intel/migration_catalog_review`, `/api/market_intel/migration_live_smoke`, `/api/market_intel/live_db_inventory`, `/api/market_intel/seed_writer_cli_status`, `/api/market_intel/write_approval_runbook`, `/api/market_intel/deployment_readiness` | | `api_routes.py` | 通用任務與查詢 API | `/api/run_task`, `/api/history/*` | | `export_routes.py` | 匯出功能 | `/api/export/*` | | `import_routes.py` | 匯入功能 | `/api/import_excel`, `/api/import/monthly_summary` | diff --git a/routes/admin_observability_routes.py b/routes/admin_observability_routes.py index 8838708..944954d 100644 --- a/routes/admin_observability_routes.py +++ b/routes/admin_observability_routes.py @@ -42,9 +42,11 @@ _PPT_AIDER_HEAL_ACTIVE = {} _GEMINI_BACKUP_CALLER_DISPLAY = { # 專用備援 caller 落地前的舊資料仍會存在;顯示時標成備援,避免誤判 Gemini-first。 'code_review_openclaw': 'code_review_openclaw_gemini', + 'openclaw_qa': 'openclaw_qa_gemini_fallback', } _GEMINI_BACKUP_CALLERS = { 'code_review_openclaw_gemini', + 'openclaw_qa_gemini_fallback', 'openclaw_bot_gemini', 'openclaw_bot_image_gemini', } @@ -759,7 +761,8 @@ def business_intel_dashboard(): # caller → agent 歸類規則(同 services/* 各 agent 真實 caller 值) _AGENT_CALLER_GROUPS = { 'openclaw': [ - 'openclaw_qa', 'openclaw_daily', 'openclaw_daily_insight', + 'openclaw_qa', 'openclaw_qa_gemini_fallback', 'openclaw_qa_nim', + 'openclaw_daily', 'openclaw_daily_insight', 'openclaw_meta', 'openclaw_monthly', 'openclaw_weekly', 'openclaw_bot_main', 'openclaw_bot_gemini', 'openclaw_bot_nim', 'sales_copy', 'code_review_openclaw', 'code_review_openclaw_gemini', diff --git a/routes/market_intel_routes.py b/routes/market_intel_routes.py index 569a043..4881174 100644 --- a/routes/market_intel_routes.py +++ b/routes/market_intel_routes.py @@ -17,6 +17,7 @@ from services.market_intel.candidate_queue_writer_run_package import build_candi from services.market_intel.candidate_queue_writer_run_readiness import build_candidate_queue_writer_run_readiness from services.market_intel.candidate_queue_writer_run_receipt import build_candidate_queue_writer_run_receipt from services.market_intel.candidate_queue_writer_run_closeout import build_candidate_queue_writer_run_closeout +from services.market_intel.candidate_queue_review_handoff import build_candidate_queue_review_handoff TAIPEI_TZ = timezone(timedelta(hours=8)) @@ -477,6 +478,35 @@ def _extract_candidate_queue_writer_run_payload(): ) +def _build_candidate_queue_writer_run_response(builder): + service = _service() + sample_result, operator_evidence, writer_output, postwrite_smoke_result, payload_error, limit = ( + _extract_candidate_queue_writer_run_payload() + ) + stack = _build_candidate_queue_writer_stack( + service=service, + sample_result=sample_result, + payload_error=payload_error, + limit=limit, + operator_evidence=operator_evidence, + ) + receipt = build_candidate_queue_writer_run_receipt( + transaction_preview=stack["transaction_preview"], + run_readiness=stack["run_readiness"], + writer_output=writer_output, + postwrite_smoke_result=postwrite_smoke_result, + operator_evidence=operator_evidence, + ) + closeout = build_candidate_queue_writer_run_closeout( + transaction_preview=stack["transaction_preview"], + run_receipt=receipt, + operator_evidence=operator_evidence, + ) + data = builder(stack, receipt, closeout, operator_evidence) + data["phase"] = service.phase + return jsonify(data), 400 if payload_error else 200 + + @market_intel_bp.route( "/api/market_intel/manual_sample_review/candidate_queue_writer_operator_drill", methods=["POST"], @@ -567,60 +597,29 @@ def market_intel_manual_sample_candidate_queue_writer_run_readiness(): @market_intel_bp.route("/api/market_intel/manual_sample_review/candidate_queue_writer_run_receipt", methods=["POST"]) @login_required def market_intel_manual_sample_candidate_queue_writer_run_receipt(): - service = _service() - sample_result, operator_evidence, writer_output, postwrite_smoke_result, payload_error, limit = ( - _extract_candidate_queue_writer_run_payload() + return _build_candidate_queue_writer_run_response( + lambda stack, receipt, closeout, operator_evidence: receipt ) - stack = _build_candidate_queue_writer_stack( - service=service, - sample_result=sample_result, - payload_error=payload_error, - limit=limit, - operator_evidence=operator_evidence, - ) - data = build_candidate_queue_writer_run_receipt( - transaction_preview=stack["transaction_preview"], - run_readiness=stack["run_readiness"], - writer_output=writer_output, - postwrite_smoke_result=postwrite_smoke_result, - operator_evidence=operator_evidence, - ) - data["phase"] = service.phase - status_code = 400 if payload_error else 200 - return jsonify(data), status_code - @market_intel_bp.route("/api/market_intel/manual_sample_review/candidate_queue_writer_run_closeout", methods=["POST"]) @login_required def market_intel_manual_sample_candidate_queue_writer_run_closeout(): - service = _service() - sample_result, operator_evidence, writer_output, postwrite_smoke_result, payload_error, limit = ( - _extract_candidate_queue_writer_run_payload() + return _build_candidate_queue_writer_run_response( + lambda stack, receipt, closeout, operator_evidence: closeout ) - stack = _build_candidate_queue_writer_stack( - service=service, - sample_result=sample_result, - payload_error=payload_error, - limit=limit, - operator_evidence=operator_evidence, + +@market_intel_bp.route("/api/market_intel/manual_sample_review/candidate_queue_review_handoff", methods=["POST"]) +@login_required +def market_intel_manual_sample_candidate_queue_review_handoff(): + return _build_candidate_queue_writer_run_response( + lambda stack, receipt, closeout, operator_evidence: build_candidate_queue_review_handoff( + transaction_preview=stack["transaction_preview"], + run_closeout=closeout, + operator_evidence=operator_evidence, + ) ) - receipt = build_candidate_queue_writer_run_receipt( - transaction_preview=stack["transaction_preview"], - run_readiness=stack["run_readiness"], - writer_output=writer_output, - postwrite_smoke_result=postwrite_smoke_result, - operator_evidence=operator_evidence, - ) - data = build_candidate_queue_writer_run_closeout( - transaction_preview=stack["transaction_preview"], - run_receipt=receipt, - operator_evidence=operator_evidence, - ) - data["phase"] = service.phase - status_code = 400 if payload_error else 200 - return jsonify(data), status_code @market_intel_bp.route("/api/market_intel/match_review_plan") diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index 6889369..5e0f5fd 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -8455,7 +8455,7 @@ def handle_cmd(cmd, arg, chat_id, reply_to): from database.manager import DatabaseManager from sqlalchemy import text as _sa agent_groups = [ - ('🤖 OpenClaw', ['openclaw_qa', 'openclaw_daily', 'openclaw_meta', 'openclaw_monthly', 'openclaw_weekly', 'openclaw_bot_main', 'openclaw_bot_gemini', 'openclaw_bot_nim', 'sales_copy', 'code_review_openclaw', 'openclaw_daily_insight']), + ('🤖 OpenClaw', ['openclaw_qa', 'openclaw_qa_gemini_fallback', 'openclaw_qa_nim', 'openclaw_daily', 'openclaw_meta', 'openclaw_monthly', 'openclaw_weekly', 'openclaw_bot_main', 'openclaw_bot_gemini', 'openclaw_bot_nim', 'sales_copy', 'code_review_openclaw', 'code_review_openclaw_gemini', 'openclaw_daily_insight']), ('🔍 Hermes', ['hermes_analyst', 'hermes_intent', 'code_review_hermes']), ('🧬 NemoTron', ['nemotron_dispatch']), ('🐘 ElephantAlpha', ['ea_engine', 'code_review_elephant']), diff --git a/services/llm_caller_registry.py b/services/llm_caller_registry.py index 1759143..876f55e 100644 --- a/services/llm_caller_registry.py +++ b/services/llm_caller_registry.py @@ -47,6 +47,8 @@ CALLER_REGISTRY: frozenset = frozenset({ 'openclaw_monthly', # 每月 1 日 'openclaw_meta', # Meta 自審 12:00 'openclaw_qa', # Telegram Q&A + 'openclaw_qa_gemini_fallback', # Telegram Q&A Gemini fallback + 'openclaw_qa_nim', # Telegram Q&A NIM fallback 'openclaw_daily_insight', # Phase 3 A8 拆分後的 200 字 Gemini 洞察 'openclaw_strategist', # Phase 10.5 mcp_router caller diff --git a/services/market_intel/candidate_queue_review_handoff.py b/services/market_intel/candidate_queue_review_handoff.py new file mode 100644 index 0000000..c05fea3 --- /dev/null +++ b/services/market_intel/candidate_queue_review_handoff.py @@ -0,0 +1,247 @@ +"""候選審核 queue review handoff preview。 + +本模組只在 writer run closeout 通過後,整理人工審核交接契約; +不查 DB、不更新 review_state、不讀 approval token、不執行 CLI、不掛 scheduler。 +""" + + +FORBIDDEN_TOKEN_KEYWORDS = ( + "approval_token", + "approval-token", + "market_intel_queue_write_approval", +) +SAFE_TOKEN_METADATA_KEYS = { + "approval_token_present", + "approval_token_valid", + "approval_token_secret_configured", +} +SAFE_APPROVAL_ENV_VAR = "MARKET_INTEL_QUEUE_WRITE_APPROVAL" + + +def _as_dict(value): + return value if isinstance(value, dict) else {} + + +def _as_list(value): + if value is None: + return [] + if isinstance(value, (list, tuple, set)): + return list(value) + return [value] + + +def _contains_forbidden_token_key(value): + if isinstance(value, dict): + for key, nested in value.items(): + normalized_key = str(key).lower() + if normalized_key in SAFE_TOKEN_METADATA_KEYS and isinstance(nested, bool): + continue + if normalized_key == "approval_env_var" and nested == SAFE_APPROVAL_ENV_VAR: + continue + if any(token_key in normalized_key for token_key in FORBIDDEN_TOKEN_KEYWORDS): + return True + if _contains_forbidden_token_key(nested): + return True + elif isinstance(value, list): + return any(_contains_forbidden_token_key(item) for item in value) + return False + + +def _dedupe_keys(transaction_preview, closeout): + keys = [] + for statement in _as_list(_as_dict(transaction_preview).get("statements")): + lookup = _as_dict(statement.get("lookup")) + parameter_preview = _as_dict(statement.get("parameter_preview")) + dedupe_key = lookup.get("dedupe_key") or parameter_preview.get("dedupe_key") + if dedupe_key: + keys.append(str(dedupe_key)) + if not keys: + keys = _as_list(_as_dict(_as_dict(closeout).get("receipt_summary")).get("expected_dedupe_keys")) + return sorted(set(str(key) for key in keys if key)) + + +def _closeout_summary(closeout): + closeout = _as_dict(closeout) + promotion_gate = _as_dict(closeout.get("promotion_gate")) + return { + "provided": bool(closeout), + "mode": closeout.get("mode"), + "closeout_passed": bool(closeout.get("closeout_passed")), + "ready_for_next_manual_phase": bool(closeout.get("ready_for_next_manual_phase")), + "promotion_allowed": bool(promotion_gate.get("allowed")), + "next_manual_phase": promotion_gate.get("next_manual_phase"), + "ready_for_api_database_write": bool(closeout.get("ready_for_api_database_write")), + "ready_for_scheduler_attach": bool(closeout.get("ready_for_scheduler_attach")), + "api_executes_cli": bool(closeout.get("api_executes_cli")), + "api_reads_approval_token": bool(closeout.get("api_reads_approval_token")), + "api_writes_file": bool(closeout.get("api_writes_file")), + "api_writes_database": bool(closeout.get("api_writes_database")), + "database_connection_opened": bool(closeout.get("database_connection_opened")), + "database_write_executed": bool(closeout.get("database_write_executed")), + "database_commit_executed": bool(closeout.get("database_commit_executed")), + "scheduler_attached": bool(closeout.get("scheduler_attached")), + } + + +def _operator_summary(operator_evidence): + operator_evidence = _as_dict(operator_evidence) + return { + "provided_keys": sorted(operator_evidence.keys()), + "operator_confirmed_queue_review_next": bool( + operator_evidence.get("operator_confirmed_queue_review_next") + ), + "operator_confirmed_no_scheduler_attach": bool( + operator_evidence.get("operator_confirmed_no_scheduler_attach") + ), + "operator_confirmed_no_api_db_write": bool( + operator_evidence.get("operator_confirmed_no_api_db_write") + ), + "approval_token_submitted_to_api": _contains_forbidden_token_key( + operator_evidence + ), + } + + +def _handoff_gates(closeout_summary, operator_summary, expected_dedupe_keys): + return [ + { + "key": "closeout_preview_provided", + "label": "必須提供上一階段 closeout preview", + "passed": bool( + closeout_summary["provided"] + and closeout_summary["mode"] + == "candidate_queue_writer_run_closeout_preview" + ), + }, + { + "key": "closeout_passed", + "label": "closeout 必須通過才可交接人工審核", + "passed": closeout_summary["closeout_passed"], + }, + { + "key": "closeout_promotes_manual_queue_review", + "label": "promotion 只能指向人工 queue review / read-only inventory", + "passed": bool( + closeout_summary["promotion_allowed"] + and closeout_summary["next_manual_phase"] + == "manual_queue_review_and_live_inventory_read_only" + ), + }, + { + "key": "expected_dedupe_keys_present", + "label": "交接包必須有 expected dedupe key 供人工比對", + "passed": bool(expected_dedupe_keys), + }, + { + "key": "closeout_no_api_write_or_scheduler", + "label": "closeout 不得允許 API/UI DB 寫入、CLI 或 scheduler", + "passed": bool( + not closeout_summary["ready_for_api_database_write"] + and not closeout_summary["ready_for_scheduler_attach"] + and not closeout_summary["api_executes_cli"] + and not closeout_summary["api_writes_database"] + and not closeout_summary["database_write_executed"] + and not closeout_summary["database_commit_executed"] + and not closeout_summary["scheduler_attached"] + ), + }, + { + "key": "operator_confirmed_review_is_manual", + "label": "操作員確認下一步只做人工審核,不由 API 更新 review_state", + "passed": bool( + operator_summary["operator_confirmed_queue_review_next"] + and operator_summary["operator_confirmed_no_api_db_write"] + and operator_summary["operator_confirmed_no_scheduler_attach"] + ), + }, + { + "key": "handoff_no_approval_token_submitted_to_api", + "label": "handoff payload 不得包含一次性 approval token key", + "passed": not operator_summary["approval_token_submitted_to_api"], + }, + ] + + +def build_candidate_queue_review_handoff( + *, + transaction_preview, + run_closeout, + operator_evidence=None, +): + """建立人工 queue review handoff;不執行任何副作用。""" + closeout_summary = _closeout_summary(run_closeout) + operator_summary = _operator_summary(operator_evidence) + expected_dedupe_keys = _dedupe_keys(transaction_preview, run_closeout) + gates = _handoff_gates(closeout_summary, operator_summary, expected_dedupe_keys) + blocked_reasons = [gate["key"] for gate in gates if not gate["passed"]] + handoff_ready = bool(not blocked_reasons) + + return { + "mode": "candidate_queue_review_handoff_preview", + "target_table": "market_alert_review_queue", + "handoff_ready": handoff_ready, + "ready_for_manual_queue_review": handoff_ready, + "ready_for_live_inventory_read_only": handoff_ready, + "ready_for_api_database_write": False, + "ready_for_scheduler_attach": False, + "api_executes_cli": False, + "api_reads_approval_token": False, + "api_writes_file": False, + "api_writes_database": False, + "api_updates_review_state": False, + "read_only_query_executed": False, + "database_connection_opened": False, + "database_session_created": False, + "explicit_transaction_opened": False, + "database_write_executed": False, + "database_commit_executed": False, + "database_rollback_executed": False, + "external_network_executed": False, + "scheduler_attached": False, + "writes_executed": False, + "would_write_database": False, + "expected_dedupe_keys": expected_dedupe_keys, + "blocked_reasons": blocked_reasons, + "gates": gates, + "closeout_summary": closeout_summary, + "operator_handoff_summary": operator_summary, + "review_contract": { + "expected_review_state": "needs_review", + "allowed_manual_actions": [ + "confirm_queue_row_exists", + "inspect_evidence_json", + "compare_dedupe_key", + "record_human_decision_outside_api", + ], + "forbidden_api_actions": [ + "update_review_state", + "insert_missing_queue_row", + "dispatch_alert", + "attach_scheduler", + ], + "required_columns": [ + "dedupe_key", + "review_state", + "priority_lane", + "source_url", + "evidence_json", + "created_at", + ], + }, + "next_operator_steps": [ + "以只讀 inventory 或人工 DB console 確認 queue row 存在", + "比對 expected dedupe key 與 review_state=needs_review", + "只在人工審核流程中記錄 confirmed / rejected / deferred 決策", + "若 queue row 不存在或 evidence 不一致,停回 writer receipt / closeout", + ], + "safe_boundaries": [ + "do_not_query_database_from_handoff_preview", + "do_not_update_review_state_from_api", + "do_not_insert_missing_queue_row_from_api", + "do_not_read_approval_token_from_api", + "do_not_execute_cli_from_handoff_preview", + "do_not_attach_scheduler_from_handoff_preview", + "no_remove_orphans", + "no_momo_db_lifecycle_change", + ], + } diff --git a/services/market_intel/deployment_readiness.py b/services/market_intel/deployment_readiness.py index 476f08f..76247ee 100644 --- a/services/market_intel/deployment_readiness.py +++ b/services/market_intel/deployment_readiness.py @@ -11,23 +11,14 @@ from services.market_intel.candidate_queue_writer_run_package import build_candi from services.market_intel.candidate_queue_writer_run_readiness import build_candidate_queue_writer_run_readiness from services.market_intel.candidate_queue_writer_run_receipt import build_candidate_queue_writer_run_receipt from services.market_intel.candidate_queue_writer_run_closeout import build_candidate_queue_writer_run_closeout +from services.market_intel.candidate_queue_review_handoff import build_candidate_queue_review_handoff + + +BLOCKED_RUN_REVIEW_KEYS = ("ready_for_api_database_write", "ready_for_scheduler_attach", "api_executes_cli", "api_reads_approval_token", "api_writes_file", "database_connection_opened", "database_write_executed", "database_commit_executed", "scheduler_attached") def _run_review_preview_safe(payload, mode): - blocked_keys = ( - "ready_for_api_database_write", - "ready_for_scheduler_attach", - "api_executes_cli", - "api_reads_approval_token", - "api_writes_file", - "database_connection_opened", - "database_write_executed", - "database_commit_executed", - "scheduler_attached", - ) - return bool( - payload["mode"] == mode and all(not payload.get(key) for key in blocked_keys) - ) + return bool(payload["mode"] == mode and all(not payload.get(key) for key in BLOCKED_RUN_REVIEW_KEYS)) def build_deployment_readiness_preview( @@ -47,21 +38,11 @@ def build_deployment_readiness_preview( manual_sample_plan = service.build_manual_sample_plan() manual_sample_acceptance = service.build_manual_sample_acceptance() manual_sample_review = service.build_manual_sample_review() - manual_sample_review_evaluation = service.build_manual_sample_review_evaluation( - sample_result={} - ) - manual_sample_candidate_handoff = service.build_manual_sample_candidate_handoff( - sample_result={} - ) - manual_sample_candidate_queue_draft = ( - service.build_manual_sample_candidate_queue_draft(sample_result={}) - ) - manual_sample_candidate_queue_approval = ( - service.build_manual_sample_candidate_queue_approval(sample_result={}) - ) - manual_sample_candidate_queue_transaction = ( - service.build_manual_sample_candidate_queue_transaction(sample_result={}) - ) + manual_sample_review_evaluation = service.build_manual_sample_review_evaluation(sample_result={}) + manual_sample_candidate_handoff = service.build_manual_sample_candidate_handoff(sample_result={}) + manual_sample_candidate_queue_draft = service.build_manual_sample_candidate_queue_draft(sample_result={}) + manual_sample_candidate_queue_approval = service.build_manual_sample_candidate_queue_approval(sample_result={}) + manual_sample_candidate_queue_transaction = service.build_manual_sample_candidate_queue_transaction(sample_result={}) candidate_queue_writer_preflight = build_candidate_queue_writer_preflight( transaction_preview=manual_sample_candidate_queue_transaction, execute_requested=False, @@ -70,11 +51,9 @@ def build_deployment_readiness_preview( transaction_preview=manual_sample_candidate_queue_transaction, writer_preflight=candidate_queue_writer_preflight, ) - candidate_queue_writer_postwrite_smoke = ( - build_candidate_queue_writer_postwrite_smoke( - transaction_preview=manual_sample_candidate_queue_transaction, - execute_requested=False, - ) + candidate_queue_writer_postwrite_smoke = build_candidate_queue_writer_postwrite_smoke( + transaction_preview=manual_sample_candidate_queue_transaction, + execute_requested=False, ) candidate_queue_writer_operator_drill = ( build_candidate_queue_writer_operator_drill( @@ -107,6 +86,10 @@ def build_deployment_readiness_preview( transaction_preview=manual_sample_candidate_queue_transaction, run_receipt=candidate_queue_writer_run_receipt, ) + candidate_queue_review_handoff = build_candidate_queue_review_handoff( + transaction_preview=manual_sample_candidate_queue_transaction, + run_closeout=candidate_queue_writer_run_closeout, + ) match_review_plan = service.build_match_review_plan() opportunity_plan = service.build_opportunity_plan() opportunity_scoring_plan = service.build_opportunity_scoring_plan() @@ -334,6 +317,10 @@ def build_deployment_readiness_preview( candidate_queue_writer_run_closeout, "candidate_queue_writer_run_closeout_preview", ), + "candidate_queue_review_handoff_preview_safe": _run_review_preview_safe( + candidate_queue_review_handoff, + "candidate_queue_review_handoff_preview", + ), "match_review_plan_preview_safe": bool( match_review_plan["mode"] == "match_review_plan_preview" and not match_review_plan["review_queue_created"] @@ -554,6 +541,7 @@ def build_deployment_readiness_preview( "/api/market_intel/manual_sample_review/candidate_queue_writer_run_readiness", "/api/market_intel/manual_sample_review/candidate_queue_writer_run_receipt", "/api/market_intel/manual_sample_review/candidate_queue_writer_run_closeout", + "/api/market_intel/manual_sample_review/candidate_queue_review_handoff", ], "status": status.to_dict(), "schema_smoke": schema_smoke, @@ -602,6 +590,7 @@ def build_deployment_readiness_preview( "candidate_queue_writer_run_readiness": candidate_queue_writer_run_readiness, "candidate_queue_writer_run_receipt": candidate_queue_writer_run_receipt, "candidate_queue_writer_run_closeout": candidate_queue_writer_run_closeout, + "candidate_queue_review_handoff": candidate_queue_review_handoff, "match_review_plan": match_review_plan, "opportunity_plan": opportunity_plan, "opportunity_scoring_plan": opportunity_scoring_plan, diff --git a/services/market_intel/service.py b/services/market_intel/service.py index 205411d..f8048c9 100644 --- a/services/market_intel/service.py +++ b/services/market_intel/service.py @@ -108,7 +108,7 @@ class MarketIntelRuntimeStatus: class MarketIntelService: """市場情報入口服務,先集中 feature gate 與安全狀態。""" - phase = "phase_63_candidate_queue_writer_run_closeout" + phase = "phase_64_candidate_queue_review_handoff" def get_runtime_status(self) -> MarketIntelRuntimeStatus: return MarketIntelRuntimeStatus( diff --git a/services/openclaw_strategist_service.py b/services/openclaw_strategist_service.py index cd3b3c7..c0e5010 100644 --- a/services/openclaw_strategist_service.py +++ b/services/openclaw_strategist_service.py @@ -208,7 +208,7 @@ def _legacy_gemini_first_qa( context: Optional[Dict[str, Any]], request_id: Optional[str] = None, ) -> str: - """legacy Gemini-first 緊急退路;正常情況只在 Ollama 主路徑失敗後使用。""" + """Gemini 備援/緊急退路;正常情況只在 Ollama 主路徑失敗後使用。""" system_prompt = ( "你是 MOMO Pro 電商情報策略師「OpenClaw」。以繁體中文(台灣用語)回覆使用者。" "嚴禁簡體字,嚴禁空洞套話。若使用者要求的資料需即時查詢," @@ -221,7 +221,12 @@ def _legacy_gemini_first_qa( text_reply = None if GEMINI_API_KEY: try: - text_reply = _call_gemini(system_prompt, user_prompt, temperature=0.5, caller="openclaw_qa") + text_reply = _call_gemini( + system_prompt, + user_prompt, + temperature=0.5, + caller="openclaw_qa_gemini_fallback", + ) except Exception as e: logger.warning("[OpenClaw] Gemini 呼叫失敗,備援 NVIDIA NIM:%s", e) @@ -955,6 +960,13 @@ def _save_action_items(actions: List[str], source_insight_id: Optional[int]) -> # Gemini 呼叫層 # ═══════════════════════════════════════════════════════════════════════════════ +def _gemini_nim_fallback_caller(caller: str) -> str: + """Gemini 失敗後的 NIM caller 命名;Q&A 備援要回到標準 openclaw_qa_nim。""" + if caller == "openclaw_qa_gemini_fallback": + return "openclaw_qa_nim" + return f"{caller}_nim" + + def _call_gemini( system_prompt: str, user_prompt: str, @@ -965,7 +977,8 @@ def _call_gemini( Args: caller: ai_calls.caller — 由外層 generate_*_report 傳入細分 caller - (openclaw_daily / openclaw_weekly / openclaw_monthly / openclaw_meta / openclaw_qa) + (openclaw_daily / openclaw_weekly / openclaw_monthly / openclaw_meta / + openclaw_qa_gemini_fallback) """ if not GEMINI_API_KEY: logger.warning("[OpenClaw] GEMINI_API_KEY 未設定") @@ -1005,7 +1018,7 @@ def _call_gemini( except Exception as e: logger.error("[OpenClaw] Gemini 呼叫失敗: %s", e) _ctx.set_error(f"{type(e).__name__}: {e}") - _ctx.fallback_to_caller(f"{caller}_nim") + _ctx.fallback_to_caller(_gemini_nim_fallback_caller(caller)) return None diff --git a/templates/market_intel/disabled.html b/templates/market_intel/disabled.html index ac85945..c7a7ce8 100644 --- a/templates/market_intel/disabled.html +++ b/templates/market_intel/disabled.html @@ -628,6 +628,9 @@ + @@ -965,6 +968,7 @@ const sampleCandidateQueueRunReadiness = sampleReviewRoot ? sampleReviewRoot.querySelector('[data-market-intel-sample-candidate-queue-run-readiness]') : null; const sampleCandidateQueueRunReceipt = sampleReviewRoot ? sampleReviewRoot.querySelector('[data-market-intel-sample-candidate-queue-run-receipt]') : null; const sampleCandidateQueueRunCloseout = sampleReviewRoot ? sampleReviewRoot.querySelector('[data-market-intel-sample-candidate-queue-run-closeout]') : null; + const sampleCandidateQueueReviewHandoff = sampleReviewRoot ? sampleReviewRoot.querySelector('[data-market-intel-sample-candidate-queue-review-handoff]') : null; const sampleReviewEndpoint = "{{ url_for('market_intel.market_intel_manual_sample_review') }}"; const sampleReviewEvaluateEndpoint = "{{ url_for('market_intel.market_intel_manual_sample_review_evaluate') }}"; const sampleCandidateHandoffEndpoint = "{{ url_for('market_intel.market_intel_manual_sample_candidate_handoff') }}"; @@ -979,6 +983,7 @@ const sampleCandidateQueueRunReadinessEndpoint = "{{ url_for('market_intel.market_intel_manual_sample_candidate_queue_writer_run_readiness') }}"; const sampleCandidateQueueRunReceiptEndpoint = "{{ url_for('market_intel.market_intel_manual_sample_candidate_queue_writer_run_receipt') }}"; const sampleCandidateQueueRunCloseoutEndpoint = "{{ url_for('market_intel.market_intel_manual_sample_candidate_queue_writer_run_closeout') }}"; + const sampleCandidateQueueReviewHandoffEndpoint = "{{ url_for('market_intel.market_intel_manual_sample_candidate_queue_review_handoff') }}"; const schedulerMeta = schedulerRoot ? schedulerRoot.querySelector('[data-market-intel-scheduler-meta]') : null; const schedulerBody = schedulerRoot ? schedulerRoot.querySelector('[data-market-intel-scheduler-body]') : null; const schedulerRefresh = schedulerRoot ? schedulerRoot.querySelector('[data-market-intel-scheduler-refresh]') : null; @@ -3006,6 +3011,124 @@ } }; + const renderCandidateQueueReviewHandoff = data => { + const blockers = (data.blocked_reasons || []).join(' / '); + const closeout = data.closeout_summary || {}; + const operator = data.operator_handoff_summary || {}; + const contract = data.review_contract || {}; + const gates = data.gates || []; + const steps = data.next_operator_steps || []; + sampleReviewMeta.innerHTML = [ + `mode=${data.mode || 'unknown'}`, + `handoff=${data.handoff_ready ? 'ready' : 'blocked'}`, + `closeout=${closeout.closeout_passed ? 'pass' : 'blocked'}`, + `expected=${(data.expected_dedupe_keys || []).length}`, + `api_write=${data.ready_for_api_database_write ? 'yes' : 'no'}` + ].map(item => `${escapeHtml(item)}`).join(''); + sampleReviewBody.innerHTML = ` +
此卡只產生人工 queue review handoff;不查 DB、不更新 review_state、不補寫 queue、不掛 scheduler。${blockers ? `阻擋:${escapeHtml(blockers)}` : ''}
+
expected dedupe keys:${escapeHtml((data.expected_dedupe_keys || []).join(', ') || 'none')}
+
+
+

HANDOFF GATES

+
${ + gates.map(gate => ` +
+
+ ${escapeHtml(gate.key)} + ${escapeHtml(gate.label)} +
+ ${gate.passed ? 'PASS' : 'BLOCK'} +
+ `).join('') || '
尚未提供 handoff gates。
' + }
+
+
+

REVIEW CONTRACT

+
+
+
+ expected_review_state + ${escapeHtml(contract.expected_review_state || 'needs_review')} +
+ MANUAL +
+ ${(contract.required_columns || []).map(item => ` +
+
${escapeHtml(item)}
+ FIELD +
+ `).join('')} +
+
+
+

OPERATOR

+
+ ${[ + ['operator_confirmed_queue_review_next', operator.operator_confirmed_queue_review_next], + ['operator_confirmed_no_scheduler_attach', operator.operator_confirmed_no_scheduler_attach], + ['operator_confirmed_no_api_db_write', operator.operator_confirmed_no_api_db_write], + ['approval_token_submitted_to_api', operator.approval_token_submitted_to_api] + ].map(([key, value]) => ` +
+
${escapeHtml(key)}
+ ${value ? 'YES' : 'NO'} +
+ `).join('')} +
+
+
+

NEXT STEPS

+
${ + steps.map((item, index) => ` +
+
+ ${escapeHtml(`step_${index + 1}`)} + ${escapeHtml(item)} +
+ MANUAL +
+ `).join('') || '
尚未提供下一步。
' + }
+
+
+ `; + }; + + const loadCandidateQueueReviewHandoff = async () => { + if (!sampleReviewMeta || !sampleReviewBody || !sampleReviewInput) return; + let parsed; + try { + parsed = JSON.parse(sampleReviewInput.value || '{}'); + } catch (error) { + sampleReviewMeta.innerHTML = 'json_error'; + sampleReviewBody.innerHTML = `
JSON 格式錯誤:${escapeHtml(error.message)}
`; + return; + } + + const body = parsed && parsed.sample_result + ? parsed + : { sample_result: parsed }; + sampleReviewBody.innerHTML = '
產生 queue review handoff 中...
'; + try { + const response = await fetch(sampleCandidateQueueReviewHandoffEndpoint, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify(body) + }); + const data = await response.json(); + if (!response.ok && !data.mode) throw new Error(`HTTP ${response.status}`); + renderCandidateQueueReviewHandoff(data); + } catch (error) { + sampleReviewMeta.innerHTML = 'error'; + sampleReviewBody.innerHTML = `
queue review handoff 產生失敗:${escapeHtml(error.message)}
`; + } + }; + const renderSchedulerMeta = data => { schedulerMeta.innerHTML = [ `mode=${data.mode || 'unknown'}`, @@ -4536,6 +4659,9 @@ if (sampleCandidateQueueRunCloseout) { sampleCandidateQueueRunCloseout.addEventListener('click', loadCandidateQueueRunCloseout); } + if (sampleCandidateQueueReviewHandoff) { + sampleCandidateQueueReviewHandoff.addEventListener('click', loadCandidateQueueReviewHandoff); + } if (schedulerRefresh) { schedulerRefresh.addEventListener('click', loadScheduler); } diff --git a/tests/test_admin_observability_routes.py b/tests/test_admin_observability_routes.py index 991d742..ef56571 100644 --- a/tests/test_admin_observability_routes.py +++ b/tests/test_admin_observability_routes.py @@ -127,6 +127,58 @@ def test_ai_calls_recent_row_marks_legacy_code_review_gemini_as_backup(): assert '舊 caller' in data['route_badges'] +def test_ai_calls_recent_row_marks_legacy_openclaw_qa_gemini_as_backup(): + from routes.admin_observability_routes import _build_ai_call_recent_row + + row = ( + 1460, + datetime(2026, 5, 19, 12, 12, 49), + 'openclaw_qa', + 'gemini', + 'gemini-2.5-flash', + 181, + 56, + 9158, + 'ok', + 0, + False, + False, + ) + + data = _build_ai_call_recent_row(row) + + assert data['caller'] == 'openclaw_qa' + assert data['caller_display'] == 'openclaw_qa_gemini_fallback' + assert data['provider'] == 'gemini' + assert 'Gemini 備援' in data['route_badges'] + assert '舊 caller' in data['route_badges'] + + +def test_ai_calls_recent_row_marks_new_openclaw_qa_gemini_fallback_as_backup(): + from routes.admin_observability_routes import _build_ai_call_recent_row + + row = ( + 1461, + datetime(2026, 5, 19, 12, 13, 49), + 'openclaw_qa_gemini_fallback', + 'gemini', + 'gemini-2.5-flash', + 181, + 56, + 9158, + 'ok', + 0, + False, + False, + ) + + data = _build_ai_call_recent_row(row) + + assert data['caller_display'] == 'openclaw_qa_gemini_fallback' + assert 'Gemini 備援' in data['route_badges'] + assert '舊 caller' not in data['route_badges'] + + # ────────────────────────────────────────────────────────────────────────── # /observability/promotion_review # ────────────────────────────────────────────────────────────────────────── diff --git a/tests/test_caller_registry.py b/tests/test_caller_registry.py index e61716f..5817de3 100644 --- a/tests/test_caller_registry.py +++ b/tests/test_caller_registry.py @@ -27,6 +27,7 @@ def test_registry_contains_core_callers(): # OpenClaw 'openclaw_daily', 'openclaw_weekly', 'openclaw_monthly', 'openclaw_meta', 'openclaw_qa', + 'openclaw_qa_gemini_fallback', 'openclaw_qa_nim', # MCP 'mcp_l1_grounding', 'mcp_collector', # Code Review @@ -53,6 +54,8 @@ def test_is_known_caller(): assert is_known_caller('hermes_analyst') is True assert is_known_caller('openclaw_qa') is True + assert is_known_caller('openclaw_qa_gemini_fallback') is True + assert is_known_caller('openclaw_qa_nim') is True assert is_known_caller('not_a_real_caller') is False assert is_known_caller('') is False assert is_known_caller('GeMiNi_Caller_Wrong_Case') is False diff --git a/tests/test_market_intel_skeleton.py b/tests/test_market_intel_skeleton.py index 60aa4b1..2591e99 100644 --- a/tests/test_market_intel_skeleton.py +++ b/tests/test_market_intel_skeleton.py @@ -677,6 +677,7 @@ def test_market_intel_preview_template_uses_safe_fetch_false_endpoint(): assert "data-market-intel-sample-candidate-queue-preflight" in template assert "data-market-intel-sample-candidate-queue-run-receipt" in template assert "data-market-intel-sample-candidate-queue-run-closeout" in template + assert "data-market-intel-sample-candidate-queue-review-handoff" in template assert "data-market-intel-approval" in template assert "data-market-intel-approval-gates" in template assert "data-market-intel-deploy" in template @@ -726,6 +727,10 @@ def test_market_intel_preview_template_uses_safe_fetch_false_endpoint(): "market_intel.market_intel_manual_sample_candidate_queue_writer_run_closeout" in template ) + assert ( + "market_intel.market_intel_manual_sample_candidate_queue_review_handoff" + in template + ) assert "X-CSRFToken" in template assert "market_intel.market_intel_scheduler_plan" in template assert "market_intel.market_intel_match_review_plan" in template @@ -762,7 +767,7 @@ def test_legacy_source_bridge_default_is_planned_only(): bridge = MarketIntelService().build_legacy_source_bridge() assert bridge["mode"] == "legacy_source_bridge_planned" - assert bridge["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert bridge["phase"] == "phase_64_candidate_queue_review_handoff" assert bridge["execute_requested"] is False assert bridge["read_only_query_executed"] is False assert bridge["database_connection_opened"] is False @@ -920,7 +925,7 @@ def test_mcp_tool_contract_preview_is_read_only_and_whitelisted(): contract = MarketIntelService().build_mcp_tool_contract() assert contract["mode"] == "mcp_tool_contract_preview" - assert contract["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert contract["phase"] == "phase_64_candidate_queue_review_handoff" assert contract["caller"] == "market_intel" assert contract["contract_ready"] is True assert contract["blocked_reasons"] == [] @@ -1053,7 +1058,7 @@ def test_mcp_activation_runbook_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "mcp_activation_runbook_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["deployment_actions_executed"] is False assert data["docker_command_executed"] is False assert data["ssh_command_executed"] is False @@ -1066,7 +1071,7 @@ def test_mcp_fetch_gate_default_blocks_external_fetch(): gate = MarketIntelService().build_mcp_fetch_gate(fetch_requested=True) assert gate["mode"] == "mcp_fetch_gate_planned" - assert gate["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert gate["phase"] == "phase_64_candidate_queue_review_handoff" assert gate["fetch_requested"] is True assert gate["manual_fetch_gate_open"] is False assert gate["network_request_allowed"] is False @@ -1136,7 +1141,7 @@ def test_mcp_fetch_gate_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "mcp_fetch_gate_planned" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["fetch_requested"] is False assert data["network_request_allowed"] is False assert data["external_network_executed"] is False @@ -1148,7 +1153,7 @@ def test_manual_sample_plan_preview_blocks_fetch_and_write(): plan = MarketIntelService().build_manual_sample_plan() assert plan["mode"] == "manual_sample_fetch_plan_preview" - assert plan["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert plan["phase"] == "phase_64_candidate_queue_review_handoff" assert plan["ready_for_manual_sample_fetch"] is False assert plan["sample_fetch_executed"] is False assert plan["external_network_executed"] is False @@ -1196,7 +1201,7 @@ def test_manual_sample_plan_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "manual_sample_fetch_plan_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["sample_fetch_executed"] is False assert data["external_network_executed"] is False assert data["database_write_executed"] is False @@ -1207,7 +1212,7 @@ def test_manual_sample_acceptance_preview_blocks_candidate_import(): acceptance = MarketIntelService().build_manual_sample_acceptance() assert acceptance["mode"] == "manual_sample_acceptance_preview" - assert acceptance["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert acceptance["phase"] == "phase_64_candidate_queue_review_handoff" assert acceptance["contract_ready"] is True assert acceptance["sample_result_loaded"] is False assert acceptance["sample_result_accepted"] is False @@ -1249,7 +1254,7 @@ def test_manual_sample_acceptance_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "manual_sample_acceptance_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["sample_result_loaded"] is False assert data["candidate_import_allowed"] is False assert data["external_network_executed"] is False @@ -1261,7 +1266,7 @@ def test_manual_sample_review_preview_is_planned_until_result_loaded(): review = MarketIntelService().build_manual_sample_review() assert review["mode"] == "manual_sample_review_preview" - assert review["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert review["phase"] == "phase_64_candidate_queue_review_handoff" assert review["contract_ready"] is True assert review["sample_result_loaded"] is False assert review["sample_result_reviewed"] is False @@ -1372,7 +1377,7 @@ def test_manual_sample_review_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "manual_sample_review_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["sample_result_loaded"] is False assert data["sample_result_reviewed"] is False assert data["candidate_import_allowed"] is False @@ -1411,7 +1416,7 @@ def test_manual_sample_review_evaluation_preview_accepts_payload_without_persist ) assert review["mode"] == "manual_sample_review_evaluation_preview" - assert review["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert review["phase"] == "phase_64_candidate_queue_review_handoff" assert review["review_request_type"] == "operator_posted_json" assert review["payload_received"] is True assert review["payload_valid_json_object"] is True @@ -1473,7 +1478,7 @@ def test_manual_sample_review_evaluate_route_is_post_only_and_no_write(): assert response.status_code == 200 assert data["mode"] == "manual_sample_review_evaluation_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["payload_received"] is True assert data["payload_valid_json_object"] is True assert data["payload_persisted"] is False @@ -1553,7 +1558,7 @@ def test_manual_sample_candidate_handoff_preview_creates_candidates_without_pers ) assert handoff["mode"] == "manual_sample_candidate_handoff_preview" - assert handoff["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert handoff["phase"] == "phase_64_candidate_queue_review_handoff" assert handoff["payload_received"] is True assert handoff["payload_valid_json_object"] is True assert handoff["payload_persisted"] is False @@ -1617,7 +1622,7 @@ def test_manual_sample_candidate_handoff_route_is_post_only_and_no_write(): assert response.status_code == 200 assert data["mode"] == "manual_sample_candidate_handoff_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["payload_received"] is True assert data["handoff_ready"] is True assert data["candidate_handoff_created"] is True @@ -1676,7 +1681,7 @@ def test_manual_sample_candidate_queue_draft_preview_builds_review_items_without ) assert queue_draft["mode"] == "manual_sample_candidate_queue_draft_preview" - assert queue_draft["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert queue_draft["phase"] == "phase_64_candidate_queue_review_handoff" assert queue_draft["payload_received"] is True assert queue_draft["payload_valid_json_object"] is True assert queue_draft["payload_persisted"] is False @@ -1750,7 +1755,7 @@ def test_manual_sample_candidate_queue_draft_route_is_post_only_and_no_write(): assert response.status_code == 200 assert data["mode"] == "manual_sample_candidate_queue_draft_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["payload_received"] is True assert data["handoff_ready"] is True assert data["queue_draft_ready"] is True @@ -1813,7 +1818,7 @@ def test_manual_sample_candidate_queue_approval_preview_blocks_write_and_maps_ro ) assert approval["mode"] == "manual_sample_candidate_queue_approval_preview" - assert approval["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert approval["phase"] == "phase_64_candidate_queue_review_handoff" assert approval["payload_received"] is True assert approval["payload_valid_json_object"] is True assert approval["payload_persisted"] is False @@ -1891,7 +1896,7 @@ def test_manual_sample_candidate_queue_approval_route_is_post_only_and_no_write( assert response.status_code == 200 assert data["mode"] == "manual_sample_candidate_queue_approval_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["payload_received"] is True assert data["approval_preview_created"] is True assert data["approval_request_created"] is False @@ -1954,7 +1959,7 @@ def test_manual_sample_candidate_queue_transaction_preview_blocks_execution(): ) assert transaction["mode"] == "manual_sample_candidate_queue_transaction_preview" - assert transaction["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert transaction["phase"] == "phase_64_candidate_queue_review_handoff" assert transaction["payload_received"] is True assert transaction["payload_valid_json_object"] is True assert transaction["payload_persisted"] is False @@ -2034,7 +2039,7 @@ def test_manual_sample_candidate_queue_transaction_route_is_post_only_and_no_wri assert response.status_code == 200 assert data["mode"] == "manual_sample_candidate_queue_transaction_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["payload_received"] is True assert data["transaction_preview_created"] is True assert data["transaction_ready"] is False @@ -3052,6 +3057,82 @@ def test_candidate_queue_writer_run_closeout_preview_promotes_only_manual_phase( ) +def test_candidate_queue_review_handoff_preview_is_manual_only(): + from services.market_intel.candidate_queue_review_handoff import ( + build_candidate_queue_review_handoff, + ) + from services.market_intel.candidate_queue_writer_run_closeout import ( + build_candidate_queue_writer_run_closeout, + ) + from services.market_intel.candidate_queue_writer_run_receipt import ( + build_candidate_queue_writer_run_receipt, + ) + + fixture = _build_candidate_queue_writer_receipt_fixture() + operator_evidence = { + **fixture["operator_evidence"], + "closeout_artifact_path": "artifacts/market_intel/closeout.json", + "operator_confirmed_queue_review_next": True, + "operator_confirmed_no_scheduler_attach": True, + "operator_confirmed_no_api_db_write": True, + } + receipt = build_candidate_queue_writer_run_receipt( + transaction_preview=fixture["transaction"], + run_readiness=fixture["run_readiness"], + writer_output=fixture["writer_output"], + postwrite_smoke_result=fixture["postwrite_smoke_result"], + operator_evidence=fixture["operator_evidence"], + ) + closeout = build_candidate_queue_writer_run_closeout( + transaction_preview=fixture["transaction"], + run_receipt=receipt, + operator_evidence=operator_evidence, + ) + handoff = build_candidate_queue_review_handoff( + transaction_preview=fixture["transaction"], + run_closeout=closeout, + operator_evidence=operator_evidence, + ) + token_leak = build_candidate_queue_review_handoff( + transaction_preview=fixture["transaction"], + run_closeout=closeout, + operator_evidence={**operator_evidence, "approval_token": TEST_APPROVAL_TOKEN}, + ) + + assert handoff["mode"] == "candidate_queue_review_handoff_preview" + assert handoff["handoff_ready"] is True + assert handoff["ready_for_manual_queue_review"] is True + assert handoff["ready_for_live_inventory_read_only"] is True + assert handoff["ready_for_api_database_write"] is False + assert handoff["ready_for_scheduler_attach"] is False + assert handoff["api_executes_cli"] is False + assert handoff["api_reads_approval_token"] is False + assert handoff["api_writes_database"] is False + assert handoff["api_updates_review_state"] is False + assert handoff["read_only_query_executed"] is False + assert handoff["database_connection_opened"] is False + assert handoff["database_write_executed"] is False + assert handoff["database_commit_executed"] is False + assert handoff["scheduler_attached"] is False + assert handoff["expected_dedupe_keys"] == fixture["expected_keys"] + assert handoff["review_contract"]["expected_review_state"] == "needs_review" + assert "update_review_state" in handoff["review_contract"]["forbidden_api_actions"] + assert "do_not_update_review_state_from_api" in handoff["safe_boundaries"] + assert handoff["blocked_reasons"] == [] + assert token_leak["handoff_ready"] is False + assert token_leak["operator_handoff_summary"][ + "approval_token_submitted_to_api" + ] is True + assert "handoff_no_approval_token_submitted_to_api" in token_leak[ + "blocked_reasons" + ] + assert TEST_APPROVAL_TOKEN not in json.dumps( + token_leak, + ensure_ascii=False, + sort_keys=True, + ) + + def test_candidate_queue_writer_preflight_route_is_post_only_and_no_write(): from routes.market_intel_routes import market_intel_bp @@ -3094,7 +3175,7 @@ def test_candidate_queue_writer_preflight_route_is_post_only_and_no_write(): assert response.status_code == 200 assert data["mode"] == "candidate_queue_writer_preflight_planned" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["execute_requested"] is False assert data["read_only_query_executed"] is False assert data["database_connection_opened"] is False @@ -3151,7 +3232,7 @@ def test_candidate_queue_writer_status_route_never_leaks_approval_token(monkeypa assert response.status_code == 200 assert data["mode"] == "candidate_queue_writer_cli_blocked" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["execute_requested"] is True assert data["apply_real_write_requested"] is True assert data["approval_token_present"] is False @@ -3240,7 +3321,7 @@ def test_candidate_queue_writer_postwrite_smoke_route_is_post_only_and_no_write( assert response.status_code == 200 assert data["mode"] == "candidate_queue_writer_postwrite_smoke_planned" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["execute_requested"] is False assert data["read_only_query_executed"] is False assert data["database_connection_opened"] is False @@ -3294,7 +3375,7 @@ def test_candidate_queue_writer_operator_drill_route_is_post_only_and_no_write() assert response.status_code == 200 assert data["mode"] == "candidate_queue_writer_operator_drill_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["operator_drill_ready"] is True assert data["api_executes_cli"] is False assert data["api_reads_approval_token"] is False @@ -3350,7 +3431,7 @@ def test_candidate_queue_writer_run_package_route_is_post_only_and_no_write(): assert response.status_code == 200 assert data["mode"] == "candidate_queue_writer_run_package_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["package_ready"] is True assert data["package_artifact_created"] is False assert data["api_writes_file"] is False @@ -3416,7 +3497,7 @@ def test_candidate_queue_writer_run_readiness_route_is_post_only_and_no_write(): assert response.status_code == 200 assert data["mode"] == "candidate_queue_writer_run_readiness_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["ready_for_cli_operator_run"] is True assert data["ready_for_api_database_write"] is False assert data["api_executes_cli"] is False @@ -3718,7 +3799,7 @@ def test_candidate_queue_writer_run_receipt_route_accepts_inline_payload_no_writ assert response.status_code == 200 assert data["mode"] == "candidate_queue_writer_run_receipt_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["receipt_passed"] is True assert data["ready_for_api_database_write"] is False assert data["ready_for_scheduler_attach"] is False @@ -3766,7 +3847,7 @@ def test_candidate_queue_writer_run_closeout_route_is_post_only_and_no_write(): assert response.status_code == 200 assert data["mode"] == "candidate_queue_writer_run_closeout_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["closeout_passed"] is True assert data["ready_for_next_manual_phase"] is True assert data["ready_for_api_database_write"] is False @@ -3782,6 +3863,56 @@ def test_candidate_queue_writer_run_closeout_route_is_post_only_and_no_write(): assert data["blocked_reasons"] == [] +def test_candidate_queue_review_handoff_route_is_post_only_and_no_write(): + from routes.market_intel_routes import market_intel_bp + + fixture = _build_candidate_queue_writer_receipt_fixture( + "sample-batch-handoff-route" + ) + app = Flask(__name__) + app.secret_key = "test-secret" + app.register_blueprint(market_intel_bp) + client = app.test_client() + with client.session_transaction() as session: + session["logged_in"] = True + + response = client.post( + "/api/market_intel/manual_sample_review/" + "candidate_queue_review_handoff", + json={ + "sample_result": fixture["sample_result"], + "operator_evidence": { + **fixture["operator_evidence"], + "closeout_artifact_path": "artifacts/market_intel/closeout.json", + "operator_confirmed_queue_review_next": True, + "operator_confirmed_no_scheduler_attach": True, + "operator_confirmed_no_api_db_write": True, + }, + "writer_output": fixture["writer_output"], + "postwrite_smoke_result": fixture["postwrite_smoke_result"], + }, + ) + data = response.get_json() + + assert response.status_code == 200 + assert data["mode"] == "candidate_queue_review_handoff_preview" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" + assert data["handoff_ready"] is True + assert data["ready_for_manual_queue_review"] is True + assert data["ready_for_api_database_write"] is False + assert data["ready_for_scheduler_attach"] is False + assert data["api_executes_cli"] is False + assert data["api_reads_approval_token"] is False + assert data["api_writes_database"] is False + assert data["api_updates_review_state"] is False + assert data["database_connection_opened"] is False + assert data["database_write_executed"] is False + assert data["database_commit_executed"] is False + assert data["scheduler_attached"] is False + assert data["review_contract"]["expected_review_state"] == "needs_review" + assert data["blocked_reasons"] == [] + + def test_candidate_queue_writer_run_receipt_route_is_post_only_and_no_write(): from routes.market_intel_routes import market_intel_bp @@ -3810,7 +3941,7 @@ def test_candidate_queue_writer_run_receipt_route_is_post_only_and_no_write(): assert response.status_code == 200 assert data["mode"] == "candidate_queue_writer_run_receipt_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["receipt_passed"] is True assert data["ready_for_next_manual_review"] is True assert data["ready_for_api_database_write"] is False @@ -3835,7 +3966,7 @@ def test_scheduler_plan_preview_blocks_job_attachment(): plan = MarketIntelService().build_scheduler_plan() assert plan["mode"] == "scheduler_attach_plan_preview" - assert plan["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert plan["phase"] == "phase_64_candidate_queue_review_handoff" assert plan["ready_to_attach_scheduler"] is False assert plan["scheduler_attached"] is False assert plan["scheduler_registration_executed"] is False @@ -3873,7 +4004,7 @@ def test_scheduler_plan_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "scheduler_attach_plan_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["scheduler_registration_executed"] is False assert data["crawler_job_started"] is False assert data["external_network_executed"] is False @@ -3884,7 +4015,7 @@ def test_match_review_plan_preview_blocks_auto_confirm(): plan = MarketIntelService().build_match_review_plan() assert plan["mode"] == "match_review_plan_preview" - assert plan["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert plan["phase"] == "phase_64_candidate_queue_review_handoff" assert plan["ready_for_review_queue"] is False assert plan["review_queue_created"] is False assert plan["auto_match_executed"] is False @@ -3920,7 +4051,7 @@ def test_match_review_plan_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "match_review_plan_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["review_queue_created"] is False assert data["auto_confirm_executed"] is False assert data["external_network_executed"] is False @@ -3931,7 +4062,7 @@ def test_opportunity_plan_preview_blocks_alerts_and_ai_summary(): plan = MarketIntelService().build_opportunity_plan() assert plan["mode"] == "opportunity_plan_preview" - assert plan["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert plan["phase"] == "phase_64_candidate_queue_review_handoff" assert plan["ready_for_opportunity_queue"] is False assert plan["opportunity_queue_created"] is False assert plan["threat_alert_dispatched"] is False @@ -3972,7 +4103,7 @@ def test_opportunity_plan_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "opportunity_plan_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["opportunity_queue_created"] is False assert data["threat_alert_dispatched"] is False assert data["ai_summary_generated"] is False @@ -3983,7 +4114,7 @@ def test_opportunity_scoring_plan_preview_blocks_scoring_and_alerts(): plan = MarketIntelService().build_opportunity_scoring_plan() assert plan["mode"] == "opportunity_scoring_plan_preview" - assert plan["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert plan["phase"] == "phase_64_candidate_queue_review_handoff" assert plan["ready_for_scoring_job"] is False assert plan["scoring_job_created"] is False assert plan["score_calculation_executed"] is False @@ -4031,7 +4162,7 @@ def test_opportunity_scoring_plan_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "opportunity_scoring_plan_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["scoring_job_created"] is False assert data["score_calculation_executed"] is False assert data["sample_scores_generated"] is False @@ -4043,7 +4174,7 @@ def test_opportunity_evidence_plan_preview_blocks_queries_and_alerts(): plan = MarketIntelService().build_opportunity_evidence_plan() assert plan["mode"] == "opportunity_evidence_plan_preview" - assert plan["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert plan["phase"] == "phase_64_candidate_queue_review_handoff" assert plan["ready_for_evidence_bundle"] is False assert plan["evidence_bundle_created"] is False assert plan["evidence_query_executed"] is False @@ -4089,7 +4220,7 @@ def test_opportunity_evidence_plan_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "opportunity_evidence_plan_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["evidence_bundle_created"] is False assert data["evidence_query_executed"] is False assert data["sample_evidence_generated"] is False @@ -4102,7 +4233,7 @@ def test_opportunity_alert_plan_preview_blocks_dispatch_and_llm_calls(): plan = MarketIntelService().build_opportunity_alert_plan() assert plan["mode"] == "opportunity_alert_plan_preview" - assert plan["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert plan["phase"] == "phase_64_candidate_queue_review_handoff" assert plan["ready_for_alert_candidates"] is False assert plan["alert_candidate_created"] is False assert plan["alert_queue_created"] is False @@ -4187,7 +4318,7 @@ def test_opportunity_alert_plan_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "opportunity_alert_plan_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["alert_candidate_created"] is False assert data["alert_queue_created"] is False assert data["review_queue_created"] is False @@ -4265,7 +4396,7 @@ def test_mcp_deploy_preflight_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "mcp_external_deploy_preflight_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["deployment_actions_executed"] is False assert data["docker_command_executed"] is False assert data["ssh_command_executed"] is False @@ -4280,7 +4411,7 @@ def test_mcp_readiness_default_is_planned_only(monkeypatch): readiness = MarketIntelService().build_mcp_readiness() assert readiness["mode"] == "mcp_readiness_planned" - assert readiness["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert readiness["phase"] == "phase_64_candidate_queue_review_handoff" assert readiness["execute_requested"] is False assert readiness["router_enabled"] is False assert readiness["external_mcp_complete"] is False @@ -4702,6 +4833,10 @@ def test_deployment_readiness_reports_app_only_release_gate(): readiness["checks"]["candidate_queue_writer_run_closeout_preview_safe"] is True ) + assert ( + readiness["checks"]["candidate_queue_review_handoff_preview_safe"] + is True + ) assert readiness["checks"]["match_review_plan_preview_safe"] is True assert readiness["checks"]["opportunity_plan_preview_safe"] is True assert readiness["checks"]["opportunity_scoring_plan_preview_safe"] is True @@ -4768,6 +4903,10 @@ def test_deployment_readiness_reports_app_only_release_gate(): "/api/market_intel/manual_sample_review/candidate_queue_writer_run_closeout" in readiness["production_smoke_targets"] ) + assert ( + "/api/market_intel/manual_sample_review/candidate_queue_review_handoff" + in readiness["production_smoke_targets"] + ) assert readiness["write_approval_runbook"]["ready_for_real_write"] is False assert readiness["write_approval_runbook"]["writes_executed"] is False assert readiness["migration_blueprint"]["migration_executed"] is False @@ -5292,6 +5431,45 @@ def test_deployment_readiness_reports_app_only_release_gate(): is False ) assert readiness["candidate_queue_writer_run_closeout"]["scheduler_attached"] is False + assert ( + readiness["candidate_queue_review_handoff"]["mode"] + == "candidate_queue_review_handoff_preview" + ) + assert readiness["candidate_queue_review_handoff"]["handoff_ready"] is False + assert ( + readiness["candidate_queue_review_handoff"]["ready_for_api_database_write"] + is False + ) + assert ( + readiness["candidate_queue_review_handoff"]["ready_for_scheduler_attach"] + is False + ) + assert readiness["candidate_queue_review_handoff"]["api_executes_cli"] is False + assert ( + readiness["candidate_queue_review_handoff"]["api_reads_approval_token"] + is False + ) + assert ( + readiness["candidate_queue_review_handoff"]["api_writes_database"] + is False + ) + assert ( + readiness["candidate_queue_review_handoff"]["api_updates_review_state"] + is False + ) + assert ( + readiness["candidate_queue_review_handoff"]["database_connection_opened"] + is False + ) + assert ( + readiness["candidate_queue_review_handoff"]["database_write_executed"] + is False + ) + assert ( + readiness["candidate_queue_review_handoff"]["database_commit_executed"] + is False + ) + assert readiness["candidate_queue_review_handoff"]["scheduler_attached"] is False assert readiness["scheduler_plan"]["scheduler_registration_executed"] is False assert readiness["scheduler_plan"]["crawler_job_started"] is False assert readiness["scheduler_plan"]["database_write_executed"] is False @@ -5396,7 +5574,7 @@ def test_migration_apply_drill_planned_is_safe_and_manual_only(): drill = MarketIntelService().build_migration_apply_drill() assert drill["mode"] == "migration_apply_drill_preview" - assert drill["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert drill["phase"] == "phase_64_candidate_queue_review_handoff" assert drill["execute_requested"] is False assert drill["schema_state"] == "planned_no_db_probe" assert drill["drill_ready_for_operator_review"] is True @@ -5511,7 +5689,7 @@ def test_migration_apply_drill_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "migration_apply_drill_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["execute_requested"] is False assert data["migration_executed"] is False assert data["rollback_executed"] is False @@ -5523,7 +5701,7 @@ def test_migration_catalog_review_planned_is_safe_and_diagnostic(): review = MarketIntelService().build_migration_catalog_review() assert review["mode"] == "migration_catalog_review_preview" - assert review["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert review["phase"] == "phase_64_candidate_queue_review_handoff" assert review["execute_requested"] is False assert review["catalog_state"] == "planned_no_probe" assert review["seed_state"] == "planned_no_probe" @@ -5638,7 +5816,7 @@ def test_migration_catalog_review_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "migration_catalog_review_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["execute_requested"] is False assert data["catalog_state"] == "planned_no_probe" assert data["migration_executed"] is False @@ -5651,7 +5829,7 @@ def test_migration_live_smoke_planned_is_preview_only(): smoke = MarketIntelService().build_migration_live_smoke() assert smoke["mode"] == "migration_live_smoke_preview" - assert smoke["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert smoke["phase"] == "phase_64_candidate_queue_review_handoff" assert smoke["execute_requested"] is False assert smoke["smoke_result"] == "planned_no_execution" assert smoke["live_smoke_passed"] is False @@ -5713,7 +5891,7 @@ def test_migration_live_smoke_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "migration_live_smoke_preview" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["execute_requested"] is False assert data["smoke_result"] == "planned_no_execution" assert data["migration_executed"] is False @@ -5726,7 +5904,7 @@ def test_live_db_inventory_planned_is_preview_only(): inventory = MarketIntelService().build_live_db_inventory() assert inventory["mode"] == "live_db_inventory_planned" - assert inventory["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert inventory["phase"] == "phase_64_candidate_queue_review_handoff" assert inventory["execute_requested"] is False assert inventory["read_only_query_executed"] is False assert inventory["database_connection_opened"] is False @@ -5870,7 +6048,7 @@ def test_live_db_inventory_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "live_db_inventory_planned" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["execute_requested"] is False assert data["read_only_query_executed"] is False assert data["database_write_executed"] is False @@ -6097,7 +6275,7 @@ def test_candidate_queue_writer_cli_script_outputs_blocked_gate(tmp_path): assert result.returncode == 0 assert data["mode"] == "candidate_queue_writer_cli_blocked" - assert data["phase"] == "phase_63_candidate_queue_writer_run_closeout" + assert data["phase"] == "phase_64_candidate_queue_review_handoff" assert data["execute_requested"] is False assert data["apply_real_write_requested"] is False assert data["writes_executed"] is False diff --git a/tests/test_openclaw_qa_routing.py b/tests/test_openclaw_qa_routing.py index c280c74..028950c 100644 --- a/tests/test_openclaw_qa_routing.py +++ b/tests/test_openclaw_qa_routing.py @@ -19,7 +19,7 @@ OpenClaw Q&A 路由 + 品質守門 unit tests import os import sys import time -from types import SimpleNamespace +from types import ModuleType, SimpleNamespace from typing import Any, Dict, Optional import pytest @@ -110,6 +110,49 @@ def _stub_ollama_generate( return FakeOllamaService, fake_resp +def _stub_gemini(monkeypatch, *, text: str = "Gemini 備援回覆", raise_error: bool = False): + """替 google.generativeai 裝測試替身,避免單測打外部 API。""" + google_module = ModuleType('google') + genai_module = ModuleType('google.generativeai') + + class FakeGenerativeModel: + def __init__(self, **kwargs): + self.kwargs = kwargs + + def generate_content(self, user_prompt, request_options=None): + if raise_error: + raise RuntimeError('gemini down') + return SimpleNamespace( + text=text, + usage_metadata=SimpleNamespace( + prompt_token_count=42, + candidates_token_count=16, + ), + ) + + genai_module.configure = lambda api_key=None: None + genai_module.GenerativeModel = FakeGenerativeModel + genai_module.types = SimpleNamespace(GenerationConfig=lambda **kwargs: kwargs) + google_module.generativeai = genai_module + monkeypatch.setitem(sys.modules, 'google', google_module) + monkeypatch.setitem(sys.modules, 'google.generativeai', genai_module) + + +def _stub_nim(monkeypatch, *, text: str = "NIM 備援回覆"): + """替 NVIDIA NIM HTTP 呼叫裝測試替身。""" + class FakeNimResponse: + def raise_for_status(self): + return None + + def json(self): + return { + "usage": {"prompt_tokens": 21, "completion_tokens": 9}, + "choices": [{"message": {"content": text}}], + } + + monkeypatch.setattr(svc.requests, 'post', lambda *args, **kwargs: FakeNimResponse()) + + # ───────────────────────────────────────────────────────────────────────────── # 1. _is_low_quality_response 純函式規則 # ───────────────────────────────────────────────────────────────────────────── @@ -396,7 +439,59 @@ class TestCallQwen3Telemetry: # ───────────────────────────────────────────────────────────────────────────── -# 5. 環境變數讀取即時性(runtime toggle) +# 5. Gemini / NIM 備援遙測:Gemini 不可被誤記為 openclaw_qa 主路徑 +# ───────────────────────────────────────────────────────────────────────────── + +class TestLegacyFallbackTelemetry: + + def test_gemini_backup_uses_dedicated_caller(self, monkeypatch, reset_state): + """Ollama 後的 Gemini 備援應記 openclaw_qa_gemini_fallback,不污染 openclaw_qa。""" + captured = reset_state + monkeypatch.setattr(svc, 'GEMINI_API_KEY', 'test-gemini-key') + monkeypatch.setattr(svc, 'NVIDIA_API_KEY', '') + _stub_gemini(monkeypatch, text="Gemini 備援:請先檢查近七日業績與競品價差。") + + out = svc._legacy_gemini_first_qa("本週業績如何?", None, request_id="qa-gemini") + + assert out.startswith("Gemini 備援") + assert _wait_async(captured, 1) + assert len(captured) == 1 + rec = captured[0] + assert rec['caller'] == 'openclaw_qa_gemini_fallback' + assert rec['provider'] == 'gemini' + assert rec['status'] == 'ok' + assert rec['fallback_to'] is None + assert not any( + r['caller'] == 'openclaw_qa' and r['provider'] == 'gemini' + for r in captured + ) + + def test_gemini_backup_failure_falls_to_standard_nim_caller(self, monkeypatch, reset_state): + """Gemini 備援失敗後,NIM 應記 openclaw_qa_nim,而非 fallback_fallback_nim。""" + captured = reset_state + monkeypatch.setattr(svc, 'GEMINI_API_KEY', 'test-gemini-key') + monkeypatch.setattr(svc, 'NVIDIA_API_KEY', 'test-nim-key') + _stub_gemini(monkeypatch, raise_error=True) + _stub_nim(monkeypatch, text="NIM 備援:請改看 /daily 與 /threats。") + + out = svc._legacy_gemini_first_qa("本週業績如何?", None, request_id="qa-nim") + + assert out.startswith("NIM 備援") + assert _wait_async(captured, 2) + assert [r['caller'] for r in captured] == [ + 'openclaw_qa_gemini_fallback', + 'openclaw_qa_nim', + ] + gemini_rec, nim_rec = captured + assert gemini_rec['provider'] == 'gemini' + assert gemini_rec['status'] == 'fallback' + assert gemini_rec['fallback_to'] == 'openclaw_qa_nim' + assert nim_rec['provider'] == 'nim' + assert nim_rec['status'] == 'ok' + + +# ───────────────────────────────────────────────────────────────────────────── +# 6. 環境變數讀取即時性(runtime toggle) # ───────────────────────────────────────────────────────────────────────────── class TestRuntimeToggle: