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: