From 45556f8fd14572fb9da7a166301729b4b60948f2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 5 Jun 2026 13:28:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(governance):=20=E6=96=B0=E5=A2=9E=20AI=20P?= =?UTF-8?q?rovider=20=E8=B7=AF=E7=94=B1=E7=9F=A9=E9=99=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/api/v1/agents.py | 31 ++ .../src/services/ai_provider_route_matrix.py | 254 ++++++++++++ ...i_agent_automation_backlog_snapshot_api.py | 18 +- ...agent_automation_inventory_snapshot_api.py | 14 +- .../tests/test_ai_provider_route_matrix.py | 345 ++++++++++++++++ .../test_ai_provider_route_matrix_api.py | 41 ++ apps/web/messages/en.json | 58 +++ apps/web/messages/zh-TW.json | 58 +++ .../tabs/automation-inventory-tab.tsx | 207 +++++++++- apps/web/src/lib/api-client.ts | 80 ++++ docs/LOGBOOK.md | 37 ++ ...AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md | 28 +- ...i_agent_automation_backlog_2026-06-04.json | 68 +-- ...ntory_snapshot_2026-06-04_static_seed.json | 71 +++- .../ai_provider_route_matrix_2026-06-05.json | 388 ++++++++++++++++++ .../ai_provider_route_matrix_v1.schema.json | 161 ++++++++ ...-04-15-MASTER-ai-autonomous-flywheel-v2.md | 19 + 17 files changed, 1819 insertions(+), 59 deletions(-) create mode 100644 apps/api/src/services/ai_provider_route_matrix.py create mode 100644 apps/api/tests/test_ai_provider_route_matrix.py create mode 100644 apps/api/tests/test_ai_provider_route_matrix_api.py create mode 100644 docs/evaluations/ai_provider_route_matrix_2026-06-05.json create mode 100644 docs/schemas/ai_provider_route_matrix_v1.schema.json diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index ecc106108..fa8f80e59 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -68,6 +68,9 @@ from src.services.gitea_workflow_runner_health import ( from src.services.observability_contract_matrix import ( load_latest_observability_contract_matrix, ) +from src.services.ai_provider_route_matrix import ( + load_latest_ai_provider_route_matrix, +) from src.services.package_supply_chain_inventory import ( load_latest_package_supply_chain_inventory, ) @@ -567,6 +570,34 @@ async def get_observability_contract_matrix() -> dict[str, Any]: ) from exc +@router.get( + "/ai-provider-route-matrix", + response_model=dict[str, Any], + summary="取得 AI Provider 路由只讀矩陣", + description=( + "讀取最新已提交的 AI Router / Ollama / OpenClaw / Nemotron / Gemini provider route matrix;" + "此端點不切換 provider、不呼叫 Gemini / NVIDIA / Claude、不改 USE_AI_ROUTER、" + "不修改 fallback order、不讀 Secret payload、不進 shadow / canary、" + "不觸發 workflow / deploy / reload / runtime execution。" + ), +) +async def get_ai_provider_route_matrix() -> dict[str, Any]: + """Return the latest read-only AI provider route matrix.""" + try: + return await asyncio.to_thread(load_latest_ai_provider_route_matrix) + except FileNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc + except (json.JSONDecodeError, ValueError) as exc: + logger.error("ai_provider_route_matrix_invalid", error=str(exc)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="AI Provider 路由只讀矩陣快照無效", + ) from exc + + @router.get( "/backup-dr-target-inventory", response_model=dict[str, Any], diff --git a/apps/api/src/services/ai_provider_route_matrix.py b/apps/api/src/services/ai_provider_route_matrix.py new file mode 100644 index 000000000..283a0e2de --- /dev/null +++ b/apps/api/src/services/ai_provider_route_matrix.py @@ -0,0 +1,254 @@ +""" +AI provider route matrix snapshot. + +Loads the latest committed, read-only AI Router / Ollama / OpenClaw / +Nemotron / Gemini provider route matrix. This module never switches providers, +calls paid APIs, probes external providers, reads secrets, changes runtime +routes, enters shadow/canary, triggers workflows, or executes runtime actions. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from src.services.snapshot_paths import default_evaluations_dir + +_DEFAULT_EVALUATIONS_DIR = default_evaluations_dir(Path(__file__)) +_SNAPSHOT_PATTERN = "ai_provider_route_matrix_*.json" +_SCHEMA_VERSION = "ai_provider_route_matrix_v1" + + +def load_latest_ai_provider_route_matrix( + evaluations_dir: Path | None = None, +) -> dict[str, Any]: + """Load the newest committed AI provider route matrix snapshot.""" + directory = evaluations_dir or _DEFAULT_EVALUATIONS_DIR + candidates = sorted(directory.glob(_SNAPSHOT_PATTERN)) + if not candidates: + raise FileNotFoundError(f"no AI provider route matrix snapshots found in {directory}") + + latest = candidates[-1] + with latest.open(encoding="utf-8") as handle: + payload = json.load(handle) + + if not isinstance(payload, dict): + raise ValueError(f"{latest}: expected JSON object") + _require_schema(payload, _SCHEMA_VERSION, str(latest)) + _require_read_only_boundaries(payload, str(latest)) + _require_operation_boundaries(payload, str(latest)) + _require_rollup_consistency(payload, str(latest)) + _require_route_evidence(payload, str(latest)) + _require_candidate_gates(payload, str(latest)) + _require_operator_denials(payload, str(latest)) + _require_no_plaintext_secret_payload_keys(payload, str(latest)) + return payload + + +def _require_schema(payload: dict[str, Any], expected: str, label: str) -> None: + actual = payload.get("schema_version") + if actual != expected: + raise ValueError(f"{label}: expected schema_version={expected}, got {actual!r}") + + +def _require_read_only_boundaries(payload: dict[str, Any], label: str) -> None: + program_status = payload.get("program_status") or {} + if program_status.get("read_only_mode") is not True: + raise ValueError(f"{label}: program_status.read_only_mode must be true") + + approval_boundaries = payload.get("approval_boundaries") or {} + allowed = sorted(key for key, value in approval_boundaries.items() if value is not False) + if allowed: + raise ValueError(f"{label}: approval boundaries must remain false: {allowed}") + + +def _require_operation_boundaries(payload: dict[str, Any], label: str) -> None: + boundaries = payload.get("operation_boundaries") or {} + if boundaries.get("read_only_api_allowed") is not True: + raise ValueError(f"{label}: read_only_api_allowed must be true") + + blocked_flags = { + "provider_switch_allowed", + "production_routing_change_allowed", + "use_ai_router_toggle_allowed", + "fallback_order_change_allowed", + "ollama_endpoint_change_allowed", + "paid_api_call_allowed", + "paid_api_frequency_increase_allowed", + "external_provider_probe_allowed", + "live_benchmark_allowed", + "shadow_or_canary_allowed", + "openclaw_replacement_allowed", + "nemotron_shadow_allowed", + "gemini_direct_call_allowed", + "secret_read_allowed", + "secret_plaintext_allowed", + "notification_send_allowed", + "workflow_trigger_allowed", + "deploy_trigger_allowed", + "reload_trigger_allowed", + "runtime_execution_allowed", + } + allowed = sorted(flag for flag in blocked_flags if boundaries.get(flag) is not False) + if allowed: + raise ValueError(f"{label}: operation boundaries must remain false: {allowed}") + + +def _require_rollup_consistency(payload: dict[str, Any], label: str) -> None: + routes = payload.get("provider_routes") or [] + gates = payload.get("candidate_gates") or [] + gaps = payload.get("source_gaps") or [] + rollups = payload.get("rollups") or {} + + if rollups.get("total_routes") != len(routes): + raise ValueError(f"{label}: rollups.total_routes must match provider_routes") + if rollups.get("by_kind") != _count_by(routes, "kind"): + raise ValueError(f"{label}: rollups.by_kind must match provider_routes") + if rollups.get("by_status") != _count_by(routes, "status"): + raise ValueError(f"{label}: rollups.by_status must match provider_routes") + if rollups.get("by_route_gate") != _count_by(routes, "route_gate"): + raise ValueError(f"{label}: rollups.by_route_gate must match provider_routes") + + requiring_action = sorted( + route.get("route_id") + for route in routes + if route.get("status") == "action_required" + ) + if sorted(rollups.get("route_ids_requiring_action") or []) != requiring_action: + raise ValueError(f"{label}: rollups.route_ids_requiring_action must match routes") + + approval_gates = sorted( + gate.get("gate_id") + for gate in gates + if gate.get("approval_required") is True + ) + if sorted(rollups.get("candidate_gate_ids_requiring_approval") or []) != approval_gates: + raise ValueError( + f"{label}: rollups.candidate_gate_ids_requiring_approval must match candidate_gates" + ) + + if sorted(rollups.get("source_gap_ids") or []) != sorted(gap.get("gap_id") for gap in gaps): + raise ValueError(f"{label}: rollups.source_gap_ids must match source_gaps") + + zero_count_fields = { + "provider_switch_allowed_count", + "paid_api_call_allowed_count", + "shadow_or_canary_allowed_count", + "runtime_route_change_allowed_count", + } + non_zero = sorted(field for field in zero_count_fields if rollups.get(field) != 0) + if non_zero: + raise ValueError(f"{label}: route permission rollup counts must remain 0: {non_zero}") + + +def _require_route_evidence(payload: dict[str, Any], label: str) -> None: + routes = payload.get("provider_routes") or [] + missing = sorted( + route.get("route_id") + for route in routes + if not route.get("current_policy") + or not route.get("provider_order") + or not route.get("fallback_policy") + or not route.get("evidence_refs") + or not route.get("next_action") + ) + if missing: + raise ValueError(f"{label}: provider_routes must include policy, order, evidence, next_action: {missing}") + + required_route_ids = { + "ai_router_execution_policy", + "ollama_global_endpoint_order", + "alert_ai_ollama_first_lane", + "openclaw_nemo_rca_lane", + "nemotron_tool_calling_candidate", + "gemini_final_fallback_policy", + } + present = {route.get("route_id") for route in routes} + missing_required = sorted(required_route_ids - present) + if missing_required: + raise ValueError(f"{label}: missing required provider routes: {missing_required}") + + blocked_candidates = sorted( + route.get("route_id") + for route in routes + if route.get("kind") == "nemotron_candidate" and route.get("status") != "blocked" + ) + if blocked_candidates: + raise ValueError(f"{label}: Nemotron candidates must remain blocked: {blocked_candidates}") + + +def _require_candidate_gates(payload: dict[str, Any], label: str) -> None: + gates = payload.get("candidate_gates") or [] + required_ids = { + "provider_switch_gate", + "nemotron_replay_gate", + "paid_provider_call_gate", + } + present = {gate.get("gate_id") for gate in gates} + missing = sorted(required_ids - present) + if missing: + raise ValueError(f"{label}: missing required candidate gates: {missing}") + + not_approval_required = sorted( + gate.get("gate_id") + for gate in gates + if gate.get("approval_required") is not True + ) + if not_approval_required: + raise ValueError(f"{label}: candidate gates must require approval: {not_approval_required}") + + +def _require_operator_denials(payload: dict[str, Any], label: str) -> None: + contract = payload.get("operator_contract") or {} + must_not_interpret_as = set(contract.get("must_not_interpret_as") or []) + required_denials = { + "provider 切換批准", + "production routing change 批准", + "Gemini / Claude / NVIDIA 付費呼叫批准", + "OpenClaw 取代或降級批准", + "Nemotron 進 shadow / canary 批准", + "fallback order 修改批准", + "Ollama endpoint / ConfigMap 修改批准", + "Secret payload 已讀取或可輸出", + "外部 live probe 或 benchmark 批准", + "workflow / deploy / reload 觸發批准", + "runtime execution 授權", + } + if not required_denials.issubset(must_not_interpret_as): + raise ValueError(f"{label}: operator_contract.must_not_interpret_as is missing required denials") + + provider_switch_policy = str(contract.get("provider_switch_policy") or "") + if "P1-004" not in provider_switch_policy or "只能盤點與顯示" not in provider_switch_policy: + raise ValueError(f"{label}: provider_switch_policy must preserve P1-004 read-only boundary") + + +def _require_no_plaintext_secret_payload_keys(value: Any, label: str, path: str = "$") -> None: + if isinstance(value, dict): + forbidden_key_fragments = { + "secret_value", + "token_value", + "authorization_header", + "private_key", + "webhook_secret", + "runner_token", + "gemini_api_key_value", + "nvidia_api_key_value", + "claude_api_key_value", + } + for key, nested in value.items(): + lowered = str(key).lower() + if any(fragment in lowered for fragment in forbidden_key_fragments): + raise ValueError(f"{label}: forbidden secret payload key at {path}.{key}") + _require_no_plaintext_secret_payload_keys(nested, label, f"{path}.{key}") + elif isinstance(value, list): + for index, nested in enumerate(value): + _require_no_plaintext_secret_payload_keys(nested, label, f"{path}[{index}]") + + +def _count_by(items: list[dict[str, Any]], key: str) -> dict[str, int]: + counts: dict[str, int] = {} + for item in items: + value = item.get(key) + counts[value] = counts.get(value, 0) + 1 + return counts diff --git a/apps/api/tests/test_ai_agent_automation_backlog_snapshot_api.py b/apps/api/tests/test_ai_agent_automation_backlog_snapshot_api.py index fdbb6c964..15a499093 100644 --- a/apps/api/tests/test_ai_agent_automation_backlog_snapshot_api.py +++ b/apps/api/tests/test_ai_agent_automation_backlog_snapshot_api.py @@ -16,16 +16,16 @@ def test_ai_agent_automation_backlog_snapshot_endpoint_returns_committed_snapsho assert response.status_code == 200 data = response.json() assert data["schema_version"] == "ai_agent_automation_backlog_v1" - assert data["program_status"]["overall_completion_percent"] == 83 + assert data["program_status"]["overall_completion_percent"] == 87 assert data["program_status"]["read_only_mode"] is True - assert data["program_status"]["current_task_id"] == "P1-003" - assert data["program_status"]["next_task_id"] == "P1-004" + assert data["program_status"]["current_task_id"] == "P1-004" + assert data["program_status"]["next_task_id"] == "P1-005" assert data["rollups"]["total_items"] == len(data["backlog_items"]) == 23 assert data["rollups"]["by_priority"]["P1"] == 21 - assert data["rollups"]["by_status"]["done"] == 19 + assert data["rollups"]["by_status"]["done"] == 20 assert data["rollups"]["by_gate_status"]["read_only_allowed"] == 20 - assert data["progress_summary"]["overall_percent"] == 83 - assert data["progress_summary"]["done_items"] == 19 + assert data["progress_summary"]["overall_percent"] == 87 + assert data["progress_summary"]["done_items"] == 20 assert data["progress_summary"]["total_items"] == 23 assert data["item_approval_boundary_rollup"]["total_items"] == 23 assert data["item_approval_boundary_rollup"]["items_requiring_explicit_approval"] == [ @@ -55,6 +55,12 @@ def test_ai_agent_automation_backlog_snapshot_endpoint_returns_committed_snapsho assert p1_003["status"] == "done" assert p1_003["next_review"] == "P1-004" assert "observability_contract_matrix_2026-06-05.json" in p1_003["evidence_refs"][0] + p1_004 = next(item for item in data["backlog_items"] if item["item_id"] == "AUTO-P1-004") + assert p1_004["status"] == "done" + assert p1_004["next_review"] == "P1-005" + assert p1_004["approval_boundary"]["mode"] == "production_change_blocked" + assert "provider_switch" in p1_004["approval_boundary"]["blocked_actions"] + assert "ai_provider_route_matrix_2026-06-05.json" in p1_004["evidence_refs"][0] p1_306 = next(item for item in data["backlog_items"] if item["item_id"] == "AUTO-P1-306") assert p1_306["approval_boundary"]["mode"] == "read_only_allowed" assert "runtime_execution" in p1_306["approval_boundary"]["blocked_actions"] diff --git a/apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py b/apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py index 34508b0ef..d97132907 100644 --- a/apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py +++ b/apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py @@ -18,13 +18,15 @@ def test_ai_agent_automation_inventory_snapshot_endpoint_returns_committed_snaps assert data["schema_version"] == "ai_agent_automation_inventory_snapshot_v1" assert data["program_status"]["overall_completion_percent"] == 100 assert data["program_status"]["read_only_mode"] is True - assert data["program_status"]["current_task_id"] == "P1-003" - assert data["program_status"]["next_task_id"] == "P1-004" - assert data["task_approval_boundary_rollup"]["total_tasks"] == len(data["tasks"]) == 29 + assert data["program_status"]["current_task_id"] == "P1-004" + assert data["program_status"]["next_task_id"] == "P1-005" + assert data["task_approval_boundary_rollup"]["total_tasks"] == len(data["tasks"]) == 30 assert data["task_approval_boundary_rollup"]["by_mode"]["read_only_allowed"] == 27 + assert data["task_approval_boundary_rollup"]["by_mode"]["production_change_blocked"] == 1 assert data["task_approval_boundary_rollup"]["tasks_requiring_explicit_approval"] == [ "P0-001", "P0-004", + "P1-004", ] assert data["approval_boundaries"]["sdk_installation_allowed"] is False assert data["approval_boundaries"]["paid_api_call_allowed"] is False @@ -41,6 +43,11 @@ def test_ai_agent_automation_inventory_snapshot_endpoint_returns_committed_snaps assert p1_003["status"] == "done" assert p1_003["approval_boundary"]["mode"] == "read_only_allowed" assert "observability_contract_matrix_2026-06-05.json" in p1_003["output"] + p1_004 = next(task for task in data["tasks"] if task["task_id"] == "P1-004") + assert p1_004["status"] == "done" + assert p1_004["approval_boundary"]["mode"] == "production_change_blocked" + assert "provider_switch" in p1_004["approval_boundary"]["blocked_actions"] + assert "ai_provider_route_matrix_2026-06-05.json" in p1_004["output"] assert any(task["task_id"] == "P1-204" for task in data["tasks"]) assert any(task["task_id"] == "P1-205" for task in data["tasks"]) assert any(task["task_id"] == "P1-206" for task in data["tasks"]) @@ -72,3 +79,4 @@ def test_ai_agent_automation_inventory_snapshot_endpoint_returns_committed_snaps assert any(evidence["evidence_id"] == "runtime_surface_inventory_api" for evidence in data["evidence"]) assert any(evidence["evidence_id"] == "gitea_workflow_runner_health_api" for evidence in data["evidence"]) assert any(evidence["evidence_id"] == "observability_contract_matrix_api" for evidence in data["evidence"]) + assert any(evidence["evidence_id"] == "ai_provider_route_matrix_api" for evidence in data["evidence"]) diff --git a/apps/api/tests/test_ai_provider_route_matrix.py b/apps/api/tests/test_ai_provider_route_matrix.py new file mode 100644 index 000000000..3f8f1195c --- /dev/null +++ b/apps/api/tests/test_ai_provider_route_matrix.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +import json + +import pytest + +from src.services.ai_provider_route_matrix import load_latest_ai_provider_route_matrix + + +def test_load_latest_ai_provider_route_matrix_reads_newest_file(tmp_path): + older = _snapshot(generated_at="2026-06-04T00:00:00+08:00", completion=40) + newer = _snapshot(generated_at="2026-06-05T00:00:00+08:00", completion=100) + (tmp_path / "ai_provider_route_matrix_2026-06-04.json").write_text( + json.dumps(older), + encoding="utf-8", + ) + (tmp_path / "ai_provider_route_matrix_2026-06-05.json").write_text( + json.dumps(newer), + encoding="utf-8", + ) + + loaded = load_latest_ai_provider_route_matrix(tmp_path) + + assert loaded["generated_at"] == "2026-06-05T00:00:00+08:00" + assert loaded["program_status"]["overall_completion_percent"] == 100 + assert loaded["rollups"]["total_routes"] == 6 + assert loaded["operation_boundaries"]["provider_switch_allowed"] is False + + +def test_ai_provider_route_matrix_requires_read_only_mode(tmp_path): + snapshot = _snapshot() + snapshot["program_status"]["read_only_mode"] = False + (tmp_path / "ai_provider_route_matrix_2026-06-05.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="read_only_mode"): + load_latest_ai_provider_route_matrix(tmp_path) + + +def test_ai_provider_route_matrix_blocks_provider_and_paid_call_changes(tmp_path): + snapshot = _snapshot() + snapshot["operation_boundaries"]["provider_switch_allowed"] = True + snapshot["operation_boundaries"]["paid_api_call_allowed"] = True + (tmp_path / "ai_provider_route_matrix_2026-06-05.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="operation boundaries"): + load_latest_ai_provider_route_matrix(tmp_path) + + +def test_ai_provider_route_matrix_requires_rollup_consistency(tmp_path): + snapshot = _snapshot() + snapshot["rollups"]["route_ids_requiring_action"] = [] + (tmp_path / "ai_provider_route_matrix_2026-06-05.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="route_ids_requiring_action"): + load_latest_ai_provider_route_matrix(tmp_path) + + +def test_ai_provider_route_matrix_requires_nemotron_to_stay_blocked(tmp_path): + snapshot = _snapshot() + snapshot["provider_routes"][4]["status"] = "verified" + _refresh_rollups(snapshot) + (tmp_path / "ai_provider_route_matrix_2026-06-05.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="Nemotron candidates"): + load_latest_ai_provider_route_matrix(tmp_path) + + +def test_ai_provider_route_matrix_requires_candidate_gates_to_require_approval(tmp_path): + snapshot = _snapshot() + snapshot["candidate_gates"][0]["approval_required"] = False + snapshot["rollups"]["candidate_gate_ids_requiring_approval"] = [ + "nemotron_replay_gate", + "paid_provider_call_gate", + ] + (tmp_path / "ai_provider_route_matrix_2026-06-05.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="candidate gates"): + load_latest_ai_provider_route_matrix(tmp_path) + + +def test_ai_provider_route_matrix_requires_operator_denials(tmp_path): + snapshot = _snapshot() + snapshot["operator_contract"]["must_not_interpret_as"].remove( + "provider 切換批准" + ) + (tmp_path / "ai_provider_route_matrix_2026-06-05.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="operator_contract"): + load_latest_ai_provider_route_matrix(tmp_path) + + +def test_ai_provider_route_matrix_rejects_secret_payload_keys(tmp_path): + snapshot = _snapshot() + snapshot["latest_observations"][0]["authorization_header"] = "redacted" + (tmp_path / "ai_provider_route_matrix_2026-06-05.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="forbidden secret payload key"): + load_latest_ai_provider_route_matrix(tmp_path) + + +def test_ai_provider_route_matrix_fails_when_missing(tmp_path): + with pytest.raises(FileNotFoundError): + load_latest_ai_provider_route_matrix(tmp_path) + + +def _snapshot( + *, + generated_at: str = "2026-06-05T00:00:00+08:00", + completion: int = 100, +) -> dict: + routes = [ + _route( + "ai_router_execution_policy", + "AI Router 執行決策核心", + "ai_router_core", + "action_required", + "route_preserved", + ), + _route( + "ollama_global_endpoint_order", + "Ollama GCP-A → GCP-B → 111", + "ollama_failover", + "verified", + "route_preserved", + ), + _route( + "alert_ai_ollama_first_lane", + "告警 AI Ollama-first Lane", + "alert_governance_lane", + "verified", + "route_preserved", + ), + _route( + "openclaw_nemo_rca_lane", + "OpenClaw Nemo RCA Lane", + "openclaw_nemo", + "action_required", + "review_required", + ), + _route( + "nemotron_tool_calling_candidate", + "Nemotron Tool Calling 候選", + "nemotron_candidate", + "blocked", + "candidate_blocked", + ), + _route( + "gemini_final_fallback_policy", + "Gemini Final Fallback", + "paid_cloud_fallback", + "verified", + "route_preserved", + ), + ] + gates = [ + _gate("provider_switch_gate", "production_change_blocked"), + _gate("nemotron_replay_gate", "blocked_by_evidence"), + _gate("paid_provider_call_gate", "cost_approval_required"), + ] + gaps = [ + { + "gap_id": "ai_router_comment_drift", + "display_name": "AI Router 註解與現況 drift", + "status": "action_required", + "severity": "medium", + "summary": "舊註解需整理,不能誤讀成現行路由。", + "evidence_refs": ["apps/api/src/services/ai_router.py"], + "next_action": "只整理 source truth,不改 provider logic。", + } + ] + return { + "schema_version": "ai_provider_route_matrix_v1", + "generated_at": generated_at, + "program_status": { + "overall_completion_percent": completion, + "current_priority": "P1", + "current_task_id": "P1-004", + "next_task_id": "P1-005", + "read_only_mode": True, + }, + "source_refs": ["docs/schemas/ai_provider_route_matrix_v1.schema.json"], + "rollups": { + "total_routes": len(routes), + "by_kind": _count_by(routes, "kind"), + "by_status": _count_by(routes, "status"), + "by_route_gate": _count_by(routes, "route_gate"), + "route_ids_requiring_action": [ + "ai_router_execution_policy", + "openclaw_nemo_rca_lane", + ], + "candidate_gate_ids_requiring_approval": [ + "nemotron_replay_gate", + "paid_provider_call_gate", + "provider_switch_gate", + ], + "source_gap_ids": ["ai_router_comment_drift"], + "read_only_denials_total": 12, + "provider_switch_allowed_count": 0, + "paid_api_call_allowed_count": 0, + "shadow_or_canary_allowed_count": 0, + "runtime_route_change_allowed_count": 0, + }, + "provider_routes": routes, + "candidate_gates": gates, + "source_gaps": gaps, + "latest_observations": [ + { + "observation_id": "provider_matrix_seed", + "status": "verified", + "summary": "只讀 route matrix seed。", + "evidence_refs": ["apps/api/src/services/ai_router.py"], + } + ], + "operator_contract": { + "display_mode": "read_only_ai_provider_route_matrix", + "must_not_interpret_as": [ + "provider 切換批准", + "production routing change 批准", + "Gemini / Claude / NVIDIA 付費呼叫批准", + "OpenClaw 取代或降級批准", + "Nemotron 進 shadow / canary 批准", + "fallback order 修改批准", + "Ollama endpoint / ConfigMap 修改批准", + "Secret payload 已讀取或可輸出", + "外部 live probe 或 benchmark 批准", + "workflow / deploy / reload 觸發批准", + "runtime execution 授權", + ], + "secret_display_policy": "只顯示 env var 名稱。", + "provider_switch_policy": "P1-004 只能盤點與顯示;切換 provider 需另行批准。", + "cost_policy": "付費呼叫需批准。", + "runtime_policy": "active runtime gate 維持 0。", + }, + "operation_boundaries": { + "read_only_api_allowed": True, + "provider_switch_allowed": False, + "production_routing_change_allowed": False, + "use_ai_router_toggle_allowed": False, + "fallback_order_change_allowed": False, + "ollama_endpoint_change_allowed": False, + "paid_api_call_allowed": False, + "paid_api_frequency_increase_allowed": False, + "external_provider_probe_allowed": False, + "live_benchmark_allowed": False, + "shadow_or_canary_allowed": False, + "openclaw_replacement_allowed": False, + "nemotron_shadow_allowed": False, + "gemini_direct_call_allowed": False, + "secret_read_allowed": False, + "secret_plaintext_allowed": False, + "notification_send_allowed": False, + "workflow_trigger_allowed": False, + "deploy_trigger_allowed": False, + "reload_trigger_allowed": False, + "runtime_execution_allowed": False, + }, + "approval_boundaries": { + "provider_switch_approved": False, + "production_routing_change_approved": False, + "cost_change_approved": False, + "shadow_or_canary_approved": False, + "external_provider_call_approved": False, + "openclaw_replacement_approved": False, + "nemotron_replay_approved": False, + "secret_access_approved": False, + "runtime_execution_approved": False, + }, + } + + +def _route( + route_id: str, + display_name: str, + kind: str, + status: str, + route_gate: str, +) -> dict: + return { + "route_id": route_id, + "display_name": display_name, + "kind": kind, + "status": status, + "risk_level": "critical", + "route_gate": route_gate, + "evidence_status": "committed_source", + "current_policy": "只讀盤點 provider route。", + "provider_order": ["ollama_gcp_a", "ollama_gcp_b", "ollama_local"], + "fallback_policy": "不切 provider。", + "evidence_refs": ["apps/api/src/services/ai_router.py"], + "next_action": "準備批准包,不改 runtime。", + } + + +def _gate(gate_id: str, status: str) -> dict: + return { + "gate_id": gate_id, + "display_name": gate_id, + "status": status, + "approval_required": True, + "summary": "需人工批准。", + "evidence_refs": ["docs/HARD_RULES.md"], + "next_action": "維持阻擋。", + } + + +def _count_by(items: list[dict], key: str) -> dict[str, int]: + counts: dict[str, int] = {} + for item in items: + value = item[key] + counts[value] = counts.get(value, 0) + 1 + return counts + + +def _refresh_rollups(snapshot: dict) -> None: + routes = snapshot["provider_routes"] + gates = snapshot["candidate_gates"] + snapshot["rollups"]["by_status"] = _count_by(routes, "status") + snapshot["rollups"]["by_route_gate"] = _count_by(routes, "route_gate") + snapshot["rollups"]["route_ids_requiring_action"] = sorted( + route["route_id"] for route in routes if route["status"] == "action_required" + ) + snapshot["rollups"]["candidate_gate_ids_requiring_approval"] = sorted( + gate["gate_id"] for gate in gates if gate["approval_required"] is True + ) diff --git a/apps/api/tests/test_ai_provider_route_matrix_api.py b/apps/api/tests/test_ai_provider_route_matrix_api.py new file mode 100644 index 000000000..e6541ba6a --- /dev/null +++ b/apps/api/tests/test_ai_provider_route_matrix_api.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api.v1.agents import router + + +def test_ai_provider_route_matrix_endpoint_returns_committed_snapshot(): + app = FastAPI() + app.include_router(router, prefix="/api/v1") + client = TestClient(app) + + response = client.get("/api/v1/agents/ai-provider-route-matrix") + + assert response.status_code == 200 + data = response.json() + assert data["schema_version"] == "ai_provider_route_matrix_v1" + assert data["program_status"]["current_task_id"] == "P1-004" + assert data["program_status"]["next_task_id"] == "P1-005" + assert data["program_status"]["read_only_mode"] is True + assert data["rollups"]["total_routes"] == len(data["provider_routes"]) == 7 + assert data["rollups"]["provider_switch_allowed_count"] == 0 + assert data["rollups"]["paid_api_call_allowed_count"] == 0 + assert data["rollups"]["shadow_or_canary_allowed_count"] == 0 + assert data["rollups"]["runtime_route_change_allowed_count"] == 0 + assert data["operation_boundaries"]["provider_switch_allowed"] is False + assert data["operation_boundaries"]["paid_api_call_allowed"] is False + assert data["operation_boundaries"]["runtime_execution_allowed"] is False + assert data["approval_boundaries"]["provider_switch_approved"] is False + assert data["approval_boundaries"]["cost_change_approved"] is False + assert any(route["route_id"] == "ollama_global_endpoint_order" for route in data["provider_routes"]) + assert any(route["route_id"] == "alert_ai_ollama_first_lane" for route in data["provider_routes"]) + nemotron = next( + route for route in data["provider_routes"] + if route["route_id"] == "nemotron_tool_calling_candidate" + ) + assert nemotron["status"] == "blocked" + assert nemotron["route_gate"] == "candidate_blocked" + assert data["operator_contract"]["display_mode"] == "read_only_ai_provider_route_matrix" + assert "provider 切換批准" in data["operator_contract"]["must_not_interpret_as"] diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index d5b24a497..350365c88 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -3307,6 +3307,64 @@ "deferred": "延後", "proposal_required": "需提案" } + }, + "providerRoute": { + "title": "AI 供應商路由矩陣", + "source": "{generated} · {current} → {next}", + "gatesTitle": "候選關卡", + "gapsTitle": "來源缺口 {count}", + "contractTitle": "不可誤讀合約", + "metrics": { + "routes": "路徑", + "actions": "需處置", + "gates": "需批准關卡", + "blocked": "阻擋候選", + "denied": "允許入口" + }, + "map": { + "ollama": "Ollama 三層", + "ollamaDetail": "GCP-A → GCP-B → 111,Gemini 僅在三層失敗後備援。", + "candidate": "候選狀態", + "candidateDetail": "Nemotron 仍被 replay / smoke gate 阻擋。", + "cost": "費用邊界", + "costDetail": "Gemini / Claude / NVIDIA 呼叫、quota 或頻率提升皆需批准。", + "safeBoundary": "安全邊界", + "safeBoundaryDetail": "供應商切換、shadow/canary、runtime 路由變更入口皆為 0。" + }, + "labels": { + "evidence": "證據" + }, + "values": { + "ai_router_core": "AI Router 核心", + "ollama_failover": "Ollama 備援", + "alert_governance_lane": "告警治理路徑", + "openclaw_nemo": "OpenClaw / Nemo", + "nemotron_candidate": "Nemotron 候選", + "paid_cloud_fallback": "付費雲端備援", + "legacy_registry": "舊版登錄表", + "verified": "已驗證", + "action_required": "需處置", + "blocked": "阻擋", + "route_preserved": "路由保留", + "review_required": "需複核", + "candidate_blocked": "候選阻擋", + "source_mismatch": "來源差異", + "committed_source": "已提交來源", + "committed_manifest": "已提交 manifest", + "blocked_replay_recorded": "Replay 阻擋已記錄", + "production_change_blocked": "生產變更阻擋", + "blocked_by_evidence": "證據阻擋", + "cost_approval_required": "費用需批准", + "proposal_required": "需提案", + "ollama": "Ollama", + "ollama_gcp_a": "Ollama GCP-A", + "ollama_gcp_b": "Ollama GCP-B", + "ollama_local": "Ollama 111", + "nemotron": "Nemotron", + "gemini": "Gemini", + "claude": "Claude", + "nvidia": "NVIDIA" + } } } }, diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index d5b24a497..350365c88 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -3307,6 +3307,64 @@ "deferred": "延後", "proposal_required": "需提案" } + }, + "providerRoute": { + "title": "AI 供應商路由矩陣", + "source": "{generated} · {current} → {next}", + "gatesTitle": "候選關卡", + "gapsTitle": "來源缺口 {count}", + "contractTitle": "不可誤讀合約", + "metrics": { + "routes": "路徑", + "actions": "需處置", + "gates": "需批准關卡", + "blocked": "阻擋候選", + "denied": "允許入口" + }, + "map": { + "ollama": "Ollama 三層", + "ollamaDetail": "GCP-A → GCP-B → 111,Gemini 僅在三層失敗後備援。", + "candidate": "候選狀態", + "candidateDetail": "Nemotron 仍被 replay / smoke gate 阻擋。", + "cost": "費用邊界", + "costDetail": "Gemini / Claude / NVIDIA 呼叫、quota 或頻率提升皆需批准。", + "safeBoundary": "安全邊界", + "safeBoundaryDetail": "供應商切換、shadow/canary、runtime 路由變更入口皆為 0。" + }, + "labels": { + "evidence": "證據" + }, + "values": { + "ai_router_core": "AI Router 核心", + "ollama_failover": "Ollama 備援", + "alert_governance_lane": "告警治理路徑", + "openclaw_nemo": "OpenClaw / Nemo", + "nemotron_candidate": "Nemotron 候選", + "paid_cloud_fallback": "付費雲端備援", + "legacy_registry": "舊版登錄表", + "verified": "已驗證", + "action_required": "需處置", + "blocked": "阻擋", + "route_preserved": "路由保留", + "review_required": "需複核", + "candidate_blocked": "候選阻擋", + "source_mismatch": "來源差異", + "committed_source": "已提交來源", + "committed_manifest": "已提交 manifest", + "blocked_replay_recorded": "Replay 阻擋已記錄", + "production_change_blocked": "生產變更阻擋", + "blocked_by_evidence": "證據阻擋", + "cost_approval_required": "費用需批准", + "proposal_required": "需提案", + "ollama": "Ollama", + "ollama_gcp_a": "Ollama GCP-A", + "ollama_gcp_b": "Ollama GCP-B", + "ollama_local": "Ollama 111", + "nemotron": "Nemotron", + "gemini": "Gemini", + "claude": "Claude", + "nvidia": "NVIDIA" + } } } }, diff --git a/apps/web/src/app/[locale]/governance/tabs/automation-inventory-tab.tsx b/apps/web/src/app/[locale]/governance/tabs/automation-inventory-tab.tsx index 6ce23c2e8..fe0bb49cf 100644 --- a/apps/web/src/app/[locale]/governance/tabs/automation-inventory-tab.tsx +++ b/apps/web/src/app/[locale]/governance/tabs/automation-inventory-tab.tsx @@ -33,6 +33,7 @@ import { GlassCard } from '@/components/ui/glass-card' import { StatusOrb } from '@/components/ui/status-orb' import { apiClient, + type AiProviderRouteMatrixSnapshot, type AiAgentAutomationBacklogSnapshot, type AiAgentAutomationInventorySnapshot, type BackupDrReadinessMatrixSnapshot, @@ -305,6 +306,7 @@ export function AutomationInventoryTab() { const [runtimeSurface, setRuntimeSurface] = useState(null) const [giteaHealth, setGiteaHealth] = useState(null) const [observabilityMatrix, setObservabilityMatrix] = useState(null) + const [providerRouteMatrix, setProviderRouteMatrix] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(false) @@ -320,6 +322,7 @@ export function AutomationInventoryTab() { apiClient.getRuntimeSurfaceInventory(), apiClient.getGiteaWorkflowRunnerHealth(), apiClient.getObservabilityContractMatrix(), + apiClient.getAiProviderRouteMatrix(), ] as const Promise.allSettled(requests) @@ -334,6 +337,7 @@ export function AutomationInventoryTab() { runtimeSurfaceResult, giteaHealthResult, observabilityMatrixResult, + providerRouteMatrixResult, ] = results setSnapshot(inventoryResult.status === 'fulfilled' ? inventoryResult.value : null) @@ -345,6 +349,7 @@ export function AutomationInventoryTab() { setRuntimeSurface(runtimeSurfaceResult.status === 'fulfilled' ? runtimeSurfaceResult.value : null) setGiteaHealth(giteaHealthResult.status === 'fulfilled' ? giteaHealthResult.value : null) setObservabilityMatrix(observabilityMatrixResult.status === 'fulfilled' ? observabilityMatrixResult.value : null) + setProviderRouteMatrix(providerRouteMatrixResult.status === 'fulfilled' ? providerRouteMatrixResult.value : null) setError([ inventoryResult, backlogResult, @@ -354,6 +359,7 @@ export function AutomationInventoryTab() { offsiteEscrowResult, giteaHealthResult, observabilityMatrixResult, + providerRouteMatrixResult, ].some(result => result.status === 'rejected')) }) .catch(() => setError(true)) @@ -489,6 +495,17 @@ export function AutomationInventoryTab() { }) }, [observabilityMatrix]) + const visibleProviderRoutes = useMemo(() => { + if (!providerRouteMatrix) return [] + const priority = { action_required: 0, blocked: 1, verified: 2 } as Record + return [...providerRouteMatrix.provider_routes].sort((a, b) => { + const left = priority[a.status] ?? 3 + const right = priority[b.status] ?? 3 + if (left !== right) return left - right + return a.route_id.localeCompare(b.route_id) + }) + }, [providerRouteMatrix]) + if (loading) { return (
@@ -502,7 +519,7 @@ export function AutomationInventoryTab() { ) } - if (error || !snapshot || !backlog || !backupTargets || !backupReadiness || !backupPolicy || !offsiteEscrow || !giteaHealth || !observabilityMatrix) { + if (error || !snapshot || !backlog || !backupTargets || !backupReadiness || !backupPolicy || !offsiteEscrow || !giteaHealth || !observabilityMatrix || !providerRouteMatrix) { return (
@@ -559,6 +576,16 @@ export function AutomationInventoryTab() { const observabilityProposalCount = observabilityMatrix.rollups.noise_reduction_opportunities_total const observabilityClassificationGaps = observabilityMatrix.rollups.classification_gap_ids.length const observabilityApprovalRequired = observabilityMatrix.rollups.approval_required_opportunity_ids.length + const providerRouteActions = providerRouteMatrix.rollups.route_ids_requiring_action.length + const providerRouteGateApprovals = providerRouteMatrix.rollups.candidate_gate_ids_requiring_approval.length + const providerSourceGaps = providerRouteMatrix.rollups.source_gap_ids.length + const providerBlockedRoutes = providerRouteMatrix.rollups.by_status.blocked ?? 0 + const providerRouteDeniedCount = ( + providerRouteMatrix.rollups.provider_switch_allowed_count + + providerRouteMatrix.rollups.paid_api_call_allowed_count + + providerRouteMatrix.rollups.shadow_or_canary_allowed_count + + providerRouteMatrix.rollups.runtime_route_change_allowed_count + ) const backlogProgressPercent = backlog.progress_summary.overall_percent const explicitApprovalItemCount = backlog.item_approval_boundary_rollup.items_requiring_explicit_approval.length const taskBoundaryCount = snapshot.task_approval_boundary_rollup.total_tasks @@ -683,6 +710,14 @@ export function AutomationInventoryTab() { } } + const providerRouteValueLabel = (value: string) => { + try { + return t(`providerRoute.values.${value}` as never) + } catch { + return value + } + } + return (
@@ -1653,6 +1688,172 @@ export function AutomationInventoryTab() {
+ +
+
+
+ + + {t('providerRoute.title')} + +
+
+ {t('providerRoute.source', { + generated: formatDateTime(providerRouteMatrix.generated_at), + current: providerRouteMatrix.program_status.current_task_id, + next: providerRouteMatrix.program_status.next_task_id, + })} +
+
+ +
+ } /> + 0 ? 'warn' : 'ok'} icon={} /> + } /> + 0 ? 'danger' : 'ok'} icon={} /> + } /> +
+ +
+ } + /> + } + /> + } + /> + } + /> +
+ +
+
+ {visibleProviderRoutes.map(routeItem => ( +
+
+
+ + {routeItem.display_name} + + +
+
+ + + +
+
+ {routeItem.provider_order.map(provider => ( + + ))} +
+
+ {routeItem.current_policy} +
+
+ {routeItem.fallback_policy} +
+
+ {routeItem.next_action} +
+
+ +
+
+
+ ))} +
+ +
+
+ + {t('providerRoute.gatesTitle')} + + {providerRouteMatrix.candidate_gates.map(gate => ( +
+
+ + {gate.display_name} + + +
+
+ {gate.summary} +
+
+ ))} +
+ +
+ + {t('providerRoute.gapsTitle', { count: providerSourceGaps })} + + {providerRouteMatrix.source_gaps.map(gap => ( +
+
+ + {gap.display_name} + + +
+
+ {gap.summary} +
+
+ ))} +
+ +
+ + {t('providerRoute.contractTitle')} + +
+ {providerRouteMatrix.operator_contract.provider_switch_policy} +
+
+ {providerRouteMatrix.operator_contract.cost_policy} +
+
+ {providerRouteMatrix.operator_contract.runtime_policy} +
+
+ {providerRouteMatrix.operator_contract.must_not_interpret_as.slice(0, 6).map(item => ( + + ))} +
+
+
+
+
+
+
@@ -1771,6 +1972,10 @@ export function AutomationInventoryTab() { .automation-inventory-observability-map-grid, .automation-inventory-observability-grid, .automation-inventory-observability-surface-grid, + .automation-inventory-provider-kpi-grid, + .automation-inventory-provider-map-grid, + .automation-inventory-provider-grid, + .automation-inventory-provider-route-grid, .automation-inventory-bottom-grid, .automation-inventory-task-grid { grid-template-columns: 1fr !important; diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 19f145747..bcc4bfd78 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -277,6 +277,11 @@ export const apiClient = { return handleResponse(res) }, + async getAiProviderRouteMatrix() { + const res = await fetch(`${API_BASE_URL}/agents/ai-provider-route-matrix`) + return handleResponse(res) + }, + async getBackupDrTargetInventory() { const res = await fetch(`${API_BASE_URL}/agents/backup-dr-target-inventory`) return handleResponse(res) @@ -1025,6 +1030,81 @@ export interface ObservabilityContractMatrixSnapshot { approval_boundaries: Record } +export interface AiProviderRouteMatrixSnapshot { + schema_version: 'ai_provider_route_matrix_v1' + generated_at: string + program_status: { + overall_completion_percent: number + current_priority: 'P0' | 'P1' | 'P2' | 'P3' + current_task_id: string + next_task_id: string + read_only_mode: true + } + source_refs: string[] + rollups: { + total_routes: number + by_kind: Record + by_status: Record + by_route_gate: Record + route_ids_requiring_action: string[] + candidate_gate_ids_requiring_approval: string[] + source_gap_ids: string[] + read_only_denials_total: number + provider_switch_allowed_count: number + paid_api_call_allowed_count: number + shadow_or_canary_allowed_count: number + runtime_route_change_allowed_count: number + } + provider_routes: Array<{ + route_id: string + display_name: string + kind: string + status: 'verified' | 'action_required' | 'blocked' + risk_level: 'low' | 'medium' | 'high' | 'critical' + route_gate: string + evidence_status: string + current_policy: string + provider_order: string[] + fallback_policy: string + evidence_refs: string[] + next_action: string + }> + candidate_gates: Array<{ + gate_id: string + display_name: string + status: string + approval_required: boolean + summary: string + evidence_refs: string[] + next_action: string + }> + source_gaps: Array<{ + gap_id: string + display_name: string + status: string + severity: 'low' | 'medium' | 'high' | 'critical' + summary: string + evidence_refs: string[] + next_action: string + }> + latest_observations: Array<{ + observation_id: string + status: string + summary: string + evidence_refs: string[] + }> + operator_contract: { + display_mode: 'read_only_ai_provider_route_matrix' + must_not_interpret_as: string[] + secret_display_policy: string + provider_switch_policy: string + cost_policy: string + runtime_policy: string + } + operation_boundaries: Record + approval_boundaries: Record +} + export interface BackupDrTargetInventorySnapshot { schema_version: 'backup_dr_target_inventory_v1' generated_at: string diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index ce51ef89b..39fc770cc 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,40 @@ +## 2026-06-05|P1-004 AI Provider 路由矩陣本地完成 + +**背景**:接續 P1-003 監控合約與降噪矩陣正式驗證,依工作清單推進 `P1-004`。本段只建立 AI Router / Ollama / OpenClaw / Nemotron / Gemini provider route 的 committed 只讀矩陣、API 與治理頁顯示;不切換 provider、不呼叫 Gemini / NVIDIA / Claude、不改 `USE_AI_ROUTER`、不修改 fallback order 或 `OLLAMA_*`、不讀 Secret payload、不進 shadow / canary、不觸發 workflow / deploy / reload / runtime execution。 + +**本輪完成**: +- 新增 `ai_provider_route_matrix_v1` schema 與 `docs/evaluations/ai_provider_route_matrix_2026-06-05.json`。 +- 新增 `GET /api/v1/agents/ai-provider-route-matrix` 與 service guard,強制驗證 read-only mode、operation / approval boundaries、rollup consistency、Nemotron candidate blocked、候選 gate approval required、operator denial 與禁止 secret payload key。 +- 治理頁 `/zh-TW/governance?tab=automation-inventory` 新增「AI 供應商路由矩陣」區塊,顯示 AI Router、Ollama 三層、OpenClaw / Nemo、Nemotron 候選、Gemini final fallback、legacy registry、來源缺口與不可誤讀合約。 +- 同步 automation backlog / inventory snapshot:current `P1-004`、next `P1-005`、backlog overall `87%`、P1 `95%`、done `20/23`、WS3 `100%`、inventory tasks `30`。 + +**目前數字**: +- Provider routes:`7`。 +- 需處置 routes:`2`(`legacy_models_json_policy`、`openclaw_nemo_rca_lane`)。 +- 需人工批准 candidate gates:`3`(provider switch、Nemotron replay、paid provider call)。 +- Source gaps:`3`。 +- Blocked candidates:`1`(Nemotron tool calling candidate)。 +- Provider switch / paid API call / shadow or canary / runtime route change allowed counts 全部 `0`。 + +**本地驗證**: +- JSON parse 通過:`ai_provider_route_matrix_2026-06-05.json`、`ai_provider_route_matrix_v1.schema.json`、automation backlog / inventory snapshots、zh-TW / en messages。 +- 目標測試通過:AI provider route matrix service / API、automation inventory / backlog snapshot service / API 共 `25 passed`。 +- Python py_compile 通過:`apps/api/src/services/ai_provider_route_matrix.py`、`apps/api/src/api/v1/agents.py`。 +- zh-TW / en i18n key 差異 `0`;web typecheck 通過;Next production build 通過;`source-control-owner-response-guard.py`、`security-mirror-progress-guard.py` 與 `git diff --check` 通過。 +- 本地 API readback:AI provider route matrix 回 `ai_provider_route_matrix_v1`、current `P1-004`、next `P1-005`、routes `7`、approval gates `3`;backlog 回 overall `87%`、done `20/23`;inventory 回 tasks `30`、`production_change_blocked=1`。 +- 本地 browser smoke:desktop `1440x1000` 與 mobile `390x844` 皆確認 `AI 供應商路由矩陣`、`P1-004`、`P1-005`、`87%`、`Ollama 三層`、`Nemotron 候選`、`不可誤讀合約` 可見;`horizontalOverflow=0`、overflowing elements `0`、危險互動入口 `0`。本地 dev server 既有 SSE / 404 / React dev warning 不作為正式 console error 判定,正式部署後以 production smoke 補判。 +- 本地截圖:`/tmp/awoooi-p1-004-provider-local-desktop.png`、`/tmp/awoooi-p1-004-provider-local-mobile.png`、`/tmp/awoooi-p1-004-provider-local-iab.png`;煙測紀錄:`/tmp/awoooi-p1-004-provider-local-smoke.json`。 +- 待補:正式部署與 production desktop / mobile verification。 + +**邊界**: +- Provider switch、production routing change、`USE_AI_ROUTER` 切換、fallback order 修改、`OLLAMA_*` endpoint / ConfigMap 修改、Gemini / Claude / NVIDIA 付費呼叫或頻率提高、OpenClaw 取代 / 降級、Nemotron shadow / canary、external live probe / benchmark、Secret payload read、通知發送、workflow / deploy / reload / runtime execution 全部仍未批准。 +- P1-004 UI / API 可見只代表只讀路由矩陣完成,不代表 provider routing、成本、runtime 或 security acceptance gate 已授權。 + +**下一步**: +1. 推送 `gitea main`,等待 Gitea code-review / CD 與 deploy marker。 +2. 正式部署後驗證 `/api/v1/agents/ai-provider-route-matrix`、automation backlog `87%` 與 governance desktop / mobile 無水平溢出。 +3. P1-005:偵測服務健康缺口與過期端點;仍不得重啟服務、改 endpoint 或做 active probe。 + ## 2026-06-05|P1-003 監控合約與降噪矩陣正式上線 **背景**:接續 P1-002 Gitea workflow / runner health contract 與決策摘要正式驗證,依工作清單推進 `P1-003`。本段只建立 Prometheus / Alertmanager / SigNoz / Grafana / Sentry / OTEL 的 committed observability matrix、只讀 API 與治理頁顯示,不修改 alert rules、不 reload Prometheus、不改 Alertmanager receiver / route、不建立 silence、不寫 Grafana、不改 SigNoz / Sentry webhook、不發 Telegram 測試通知。 diff --git a/docs/ai/AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md b/docs/ai/AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md index 73f7df735..6bbcf50c0 100644 --- a/docs/ai/AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md +++ b/docs/ai/AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md @@ -10,10 +10,10 @@ |---|---:|---|---| | Agent 市場治理 | 72% | 進行中 | `agent_market_governance_snapshot_v1`、API、UI 分頁、每週觀察流程 | | Nemotron 實際整合應用 | 30% | 完整回放前仍被關卡擋下 | `blocked_needs_evidence`,下一關是 `refresh_source_evidence_then_5_record_smoke_only` | -| 工具 / 服務 / 套件 AI 自動化 | 83% | P0 已完成,P1 套件 / 供應鏈主線已完成;備份 / DR 主線已完成到異地 / escrow 準備度顯示;任務批准邊界、進度彙總、P1-001 執行面只讀矩陣、P1-002 Gitea 工作流程 / runner 健康合約與 P1-003 監控合約 / 降噪矩陣已完成,下一主線是 P1-004 AI Router / provider route 盤點 | 狀態分類、盤點 schema、權限矩陣、靜態盤點種子、只讀 API、UI 骨架、驗證、自動化待辦 schema / 快照 / API / 分組 UI、Backup / DR 目標盤點、準備度矩陣、備份通知政策、Backup / DR 證據 UI、復原演練批准包模板、異地 / escrow 準備度狀態、任務批准邊界、確定性進度彙總、Python 套件 / 供應鏈只讀基線、JS pnpm/npm 只讀基線、Docker build surface 只讀基線、CVE / license / drift 嚴重度政策、定期依賴漂移與外部資料來源檢查設計、依賴升級批准包模板、runtime_surface_inventory_v1 schema / snapshot / API / UI、gitea_workflow_runner_health_v1 schema / snapshot / API / UI、observability_contract_matrix_v1 schema / snapshot / API / UI 已完成 | +| 工具 / 服務 / 套件 AI 自動化 | 87% | P0 已完成,P1 套件 / 供應鏈主線已完成;備份 / DR 主線已完成到異地 / escrow 準備度顯示;任務批准邊界、進度彙總、P1-001 執行面只讀矩陣、P1-002 Gitea 工作流程 / runner 健康合約、P1-003 監控合約 / 降噪矩陣與 P1-004 AI 供應商路由矩陣已完成,下一主線是 P1-005 服務健康缺口與過期端點偵測 | 狀態分類、盤點 schema、權限矩陣、靜態盤點種子、只讀 API、UI 骨架、驗證、自動化待辦 schema / 快照 / API / 分組 UI、Backup / DR 目標盤點、準備度矩陣、備份通知政策、Backup / DR 證據 UI、復原演練批准包模板、異地 / escrow 準備度狀態、任務批准邊界、確定性進度彙總、Python 套件 / 供應鏈只讀基線、JS pnpm/npm 只讀基線、Docker build surface 只讀基線、CVE / license / drift 嚴重度政策、定期依賴漂移與外部資料來源檢查設計、依賴升級批准包模板、runtime_surface_inventory_v1 schema / snapshot / API / UI、gitea_workflow_runner_health_v1 schema / snapshot / API / UI、observability_contract_matrix_v1 schema / snapshot / API / UI、ai_provider_route_matrix_v1 schema / snapshot / API / UI 已完成 | | 本工作清單與分析報告 | 100% | 已完成 | 本 MD 文件 | -AI Agent 自動化工作包目前完成度:**83%**。本工作清單文件本身完成度:**100%**。 +AI Agent 自動化工作包目前完成度:**87%**。本工作清單文件本身完成度:**100%**。 完成度計算模型: @@ -869,7 +869,7 @@ UI: | P1-001 | 完成 | 100 | OpenClaw | 盤點 API / Web / Worker / K8s runtime surface | `runtime_surface_inventory_v1` / `GET /api/v1/agents/runtime-surface-inventory` / 執行面只讀矩陣 | 只讀;不得查 Secret payload、不得 rollout / restart / scale / delete | | P1-002 | 完成 | 100 | Hermes | 盤點 Gitea 工作流程與 runner 健康合約 | `gitea_workflow_runner_health_v1` / `GET /api/v1/agents/gitea-workflow-runner-health` / Gitea 健康合約 UI | 只讀;不修改 workflow、不重啟 runner、不停止 container、不讀 Secret、不發通知 | | P1-003 | 完成 | 100 | Hermes | 盤點 Prometheus / Alertmanager / SigNoz / Grafana 監控合約 | `observability_contract_matrix_v1` / `GET /api/v1/agents/observability-contract-matrix` / 監控合約與降噪 UI | 只讀;不修改 alert rules、不改 receiver/route、不建立 silence、不寫 Grafana、不發通知 | -| P1-004 | 待辦 | 0 | OpenClaw | 盤點 AI Router / Ollama / Nemotron / Gemini provider 路徑 | 推理路由矩陣 | 不切 provider | +| P1-004 | 完成 | 100 | OpenClaw | 盤點 AI Router / Ollama / Nemotron / Gemini provider 路徑 | `ai_provider_route_matrix_v1` / `GET /api/v1/agents/ai-provider-route-matrix` / AI 供應商路由矩陣 UI | 只讀;不切 provider、不呼叫付費 API、不改 fallback order、不進 shadow/canary | | P1-005 | 待辦 | 0 | OpenClaw | 偵測服務健康缺口與過期端點 | 需處置清單 | 不重啟 | | P1-006 | 待辦 | 0 | Hermes | 在 UI 顯示 service health 證據卡 | 狀態卡 | 瀏覽器驗證 | | P1-007 | 待辦 | 0 | OpenClaw | 建立 service health 失敗限定 Telegram / AwoooP 對應 | 通知合約 | 不發成功洗版 | @@ -1099,11 +1099,27 @@ UI: 下一步:P1-004 盤點 AI Router / Ollama / Nemotron / Gemini provider 路徑。 ``` +本次同步: + +```text +進度:87%。 +目前優先級:P1。 +目前任務:P1-004 盤點 AI Router / Ollama / Nemotron / Gemini provider 路徑。 +狀態變更:待辦 -> 完成。 +證據:ai_provider_route_matrix_v1 schema / snapshot;GET /api/v1/agents/ai-provider-route-matrix;治理頁 AI 供應商路由矩陣區塊;automation backlog 87%;inventory tasks 30。 +目前數字:provider routes 7;需處置 routes 2;需人工批准 candidate gates 3;source gaps 3;blocked candidates 1;provider switch / paid API call / shadow or canary / runtime route change allowed counts 全部 0;backlog done 20/23;overall 87%;P1 95%;WS3 監控自動化 100%。 +驗證:JSON parse 通過;AI provider route matrix service / API、automation inventory / backlog snapshot service / API 目標測試 `25 passed`;Python py_compile 通過;zh-TW / en i18n key 差異 `0`;web typecheck 通過;Next production build 通過;source-control-owner-response guard、security-mirror-progress guard、git diff --check 通過;本地 API readback 回 `ai_provider_route_matrix_v1`、current `P1-004`、next `P1-005`、routes `7`、approval gates `3`;backlog 回 `87%`、done `20/23`;inventory 回 tasks `30`;本地 desktop `1440x1000` 與 mobile `390x844` browser smoke 通過,`AI 供應商路由矩陣`、`P1-004`、`P1-005`、`87%`、`Ollama 三層`、`Nemotron 候選`、`不可誤讀合約` 可見,`horizontalOverflow=0`、overflowing elements `0`、危險互動入口 `0`。 +待補驗證:正式部署與 production desktop / mobile verification。 +阻擋:provider switch、production routing change、USE_AI_ROUTER 切換、fallback order 修改、OLLAMA_* endpoint / ConfigMap 修改、Gemini / Claude / NVIDIA 付費呼叫或頻率提高、OpenClaw 取代 / 降級、Nemotron shadow / canary、external live probe / benchmark、Secret payload read、通知發送、workflow/deploy/reload/runtime execution 仍全部禁止。 +下一步:P1-005 偵測服務健康缺口與過期端點。 +``` + ## 13. 立即執行順序 -1. P1-004:盤點 AI Router / Ollama / Nemotron / Gemini provider 路徑。 -2. P1-005:偵測服務健康缺口與過期端點。 -3. P2 / P3 必須等 P1 服務、監控與 provider runtime surface 可見且關卡穩定後再做。 +1. P1-005:偵測服務健康缺口與過期端點。 +2. P1-006:在 UI 顯示 service health 證據卡。 +3. P1-007:建立 service health 失敗限定 Telegram / AwoooP 對應。 +4. P2 / P3 必須等 P1 服務、監控與 provider runtime surface 可見且關卡穩定後再做。 ## 14. 目前風險 diff --git a/docs/evaluations/ai_agent_automation_backlog_2026-06-04.json b/docs/evaluations/ai_agent_automation_backlog_2026-06-04.json index 5cc5f8518..b1f89f8a0 100644 --- a/docs/evaluations/ai_agent_automation_backlog_2026-06-04.json +++ b/docs/evaluations/ai_agent_automation_backlog_2026-06-04.json @@ -1,12 +1,12 @@ { "schema_version": "ai_agent_automation_backlog_v1", - "generated_at": "2026-06-05T12:34:00+08:00", + "generated_at": "2026-06-05T13:28:00+08:00", "source_inventory_snapshot_ref": "docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json", "program_status": { - "overall_completion_percent": 83, + "overall_completion_percent": 87, "current_priority": "P1", - "current_task_id": "P1-003", - "next_task_id": "P1-004", + "current_task_id": "P1-004", + "next_task_id": "P1-005", "read_only_mode": true }, "rollups": { @@ -17,8 +17,8 @@ "P3": 1 }, "by_status": { - "done": 19, - "planned": 4 + "done": 20, + "planned": 3 }, "by_gate_status": { "read_only_allowed": 20, @@ -368,27 +368,33 @@ { "item_id": "AUTO-P1-004", "priority": "P1", - "status": "planned", + "status": "done", "workstream_id": "WS3", "source_asset_id": "ai_router", "source_signal_kind": "runtime_evidence_gap", "title": "盤點 AI Router / provider route", "owner_agent": "openclaw", - "recommended_action": "只讀列出 OpenClaw、Ollama、Nemotron、Gemini 與候選 provider 路徑,不切換任何 provider。", + "recommended_action": "已建立 AI Router / Ollama / OpenClaw / Nemotron / Gemini provider route 只讀矩陣;不切換 provider、不呼叫付費 API、不進 shadow / canary。", "action_class": "observe", "gate_status": "production_change_blocked", "risk_level": "critical", "evidence_refs": [ - "docs/HARD_RULES.md", - "apps/api/src/api/v1/agents.py" + "docs/evaluations/ai_provider_route_matrix_2026-06-05.json", + "GET /api/v1/agents/ai-provider-route-matrix", + "apps/api/src/services/ai_router.py", + "apps/api/src/services/ollama_endpoint_resolver.py", + "apps/api/src/services/ollama_failover_manager.py", + "apps/api/src/services/openclaw.py", + "k8s/awoooi-prod/04-configmap.yaml", + "k8s/awoooi-prod/06-deployment-api.yaml" ], "acceptance_criteria": [ - "不修改 provider routing", - "不呼叫付費 API", - "所有候選仍維持 replay/shadow/canary gate", - "OpenClaw 保持目前生產決策核心" + "不修改 provider routing、不改 USE_AI_ROUTER、不改 fallback order", + "不呼叫 Gemini / Claude / NVIDIA 付費 API、不提高呼叫頻率", + "Nemotron / candidate provider 維持 replay / shadow / canary gate", + "OpenClaw 保持目前生產決策核心,UI/API 只呈現不可誤讀合約" ], - "next_review": "P1-004", + "next_review": "P1-005", "approval_boundary": { "mode": "production_change_blocked", "display_summary": "生產路由或 provider 變更被阻擋;需 replay、shadow/canary 證據與獨立人工批准。", @@ -1149,6 +1155,10 @@ "AUTO-P3-001" ], "items_with_blocked_operations": [ + "AUTO-P1-303", + "AUTO-P1-304", + "AUTO-P1-305", + "AUTO-P1-306", "AUTO-P1-001", "AUTO-P1-002", "AUTO-P1-003", @@ -1166,25 +1176,21 @@ "AUTO-P1-204", "AUTO-P1-205", "AUTO-P1-206", - "AUTO-P1-303", - "AUTO-P1-304", - "AUTO-P1-305", - "AUTO-P1-306", "AUTO-P2-004", "AUTO-P3-001" ] }, "progress_summary": { - "overall_percent": 83, - "done_items": 19, - "planned_items": 4, + "overall_percent": 87, + "done_items": 20, + "planned_items": 3, "total_items": 23, "formula": "round(done_items / total_items * 100),只有 status=done 計入完成;planned/in_progress/blocked/deferred/rejected 不計入。", "by_priority": [ { "priority": "P1", - "completion_percent": 90, - "done_items": 19, + "completion_percent": 95, + "done_items": 20, "total_items": 21 }, { @@ -1207,15 +1213,15 @@ "completion_percent": 67, "done_items": 2, "total_items": 3, - "next_task_id": "P1-001" + "next_task_id": "P3-001" }, { "workstream_id": "WS3", "display_name": "監控自動化", - "completion_percent": 75, - "done_items": 3, + "completion_percent": 100, + "done_items": 4, "total_items": 4, - "next_task_id": "P1-004" + "next_task_id": "complete" }, { "workstream_id": "WS4", @@ -1239,7 +1245,7 @@ "completion_percent": 0, "done_items": 0, "total_items": 1, - "next_task_id": "P2-001" + "next_task_id": "P2-004" }, { "workstream_id": "WS7", @@ -1247,7 +1253,7 @@ "completion_percent": 0, "done_items": 0, "total_items": 1, - "next_task_id": "P2-101" + "next_task_id": "P1-007" }, { "workstream_id": "WS8", @@ -1255,7 +1261,7 @@ "completion_percent": 100, "done_items": 2, "total_items": 2, - "next_task_id": "P1-004" + "next_task_id": "complete" } ] } diff --git a/docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json b/docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json index 47b505714..622254cad 100644 --- a/docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json +++ b/docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json @@ -1,11 +1,11 @@ { "schema_version": "ai_agent_automation_inventory_snapshot_v1", - "generated_at": "2026-06-05T12:34:00+08:00", + "generated_at": "2026-06-05T13:28:00+08:00", "program_status": { "overall_completion_percent": 100, "current_priority": "P1", - "current_task_id": "P1-003", - "next_task_id": "P1-004", + "current_task_id": "P1-004", + "next_task_id": "P1-005", "read_only_mode": true }, "status_taxonomy": { @@ -478,9 +478,9 @@ { "workstream_id": "WS3", "display_name": "監控自動化", - "completion_percent": 75, - "status": "in_progress", - "next_task_id": "P1-004" + "completion_percent": 100, + "status": "done", + "next_task_id": "P1-005" }, { "workstream_id": "WS4", @@ -513,9 +513,9 @@ { "workstream_id": "WS8", "display_name": "產品 UI", - "completion_percent": 94, + "completion_percent": 96, "status": "in_progress", - "next_task_id": "P1-004" + "next_task_id": "P1-006" } ], "tasks": [ @@ -1447,6 +1447,43 @@ "任何部署、排程、通知或外部呼叫變更" ] } + }, + { + "task_id": "P1-004", + "priority": "P1", + "status": "done", + "completion_percent": 100, + "owner_agent": "openclaw", + "title": "盤點 AI Router / provider route", + "output": "docs/evaluations/ai_provider_route_matrix_2026-06-05.json + GET /api/v1/agents/ai-provider-route-matrix", + "gate_status": "production_change_blocked", + "next_action": "完成 committed AI provider route matrix;下一步 P1-005 偵測服務健康缺口與過期端點。", + "approval_boundary": { + "mode": "production_change_blocked", + "display_summary": "生產路由或 provider 變更被阻擋;P1-004 只允許只讀盤點、顯示與批准包準備。", + "allowed_actions": [ + "盤點 provider route", + "整理 replay 前置證據", + "準備變更批准包", + "顯示治理 UI" + ], + "blocked_actions": [ + "production_write", + "runtime_execution", + "destructive_operation", + "secret_plaintext_collection", + "provider_switch", + "production_routing_change", + "shadow_or_canary_entry", + "paid_api_call" + ], + "requires_operator_approval_for": [ + "provider 切換", + "生產路由變更", + "shadow / canary 進入", + "付費 API 呼叫" + ] + } } ], "evidence": [ @@ -1774,6 +1811,13 @@ "source_ref": "GET /api/v1/agents/observability-contract-matrix", "status": "done", "summary": "只讀呈現 Prometheus / Alertmanager / Grafana / SigNoz / ClickHouse / Sentry 合約、降噪 proposal 與分類缺口;不修改 alert rules、不送通知、不讀 Secret。" + }, + { + "evidence_id": "ai_provider_route_matrix_api", + "title": "AI Provider 路由只讀 API", + "source_ref": "GET /api/v1/agents/ai-provider-route-matrix", + "status": "done", + "summary": "只讀呈現 AI Router / Ollama / OpenClaw / Nemotron / Gemini provider route、候選 Gate、來源缺口與不可誤讀合約;不切 provider、不呼叫付費 API、不讀 Secret。" } ], "approval_boundaries": { @@ -1784,15 +1828,17 @@ "destructive_operation_allowed": false }, "task_approval_boundary_rollup": { - "total_tasks": 29, + "total_tasks": 30, "by_mode": { "ready_for_operator_review": 1, "read_only_allowed": 27, - "approval_required": 1 + "approval_required": 1, + "production_change_blocked": 1 }, "tasks_requiring_explicit_approval": [ "P0-001", - "P0-004" + "P0-004", + "P1-004" ], "tasks_with_blocked_operations": [ "P0-001", @@ -1823,7 +1869,8 @@ "P1-203", "P1-204", "P1-205", - "P1-206" + "P1-206", + "P1-004" ] } } diff --git a/docs/evaluations/ai_provider_route_matrix_2026-06-05.json b/docs/evaluations/ai_provider_route_matrix_2026-06-05.json new file mode 100644 index 000000000..3b9ddd22a --- /dev/null +++ b/docs/evaluations/ai_provider_route_matrix_2026-06-05.json @@ -0,0 +1,388 @@ +{ + "schema_version": "ai_provider_route_matrix_v1", + "generated_at": "2026-06-05T13:18:00+08:00", + "program_status": { + "overall_completion_percent": 100, + "current_priority": "P1", + "current_task_id": "P1-004", + "next_task_id": "P1-005", + "read_only_mode": true + }, + "source_refs": [ + "docs/schemas/ai_provider_route_matrix_v1.schema.json", + "docs/HARD_RULES.md#cost-change-approval", + "docs/HARD_RULES.md#iwooos-security-governance", + "apps/api/src/services/ai_router.py", + "apps/api/src/services/ollama_endpoint_resolver.py", + "apps/api/src/services/ollama_failover_manager.py", + "apps/api/src/services/openclaw.py", + "apps/api/src/services/platform_operator_service.py", + "apps/api/src/services/ai_providers/openclaw_nemo.py", + "apps/api/src/services/ai_providers/nemotron.py", + "apps/api/src/services/ai_providers/permissions.py", + "apps/api/models.json", + "k8s/awoooi-prod/04-configmap.yaml", + "k8s/awoooi-prod/06-deployment-api.yaml", + "docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md", + "docs/ai/AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md" + ], + "rollups": { + "total_routes": 7, + "by_kind": { + "ai_router_core": 1, + "ollama_failover": 1, + "alert_governance_lane": 1, + "openclaw_nemo": 1, + "nemotron_candidate": 1, + "paid_cloud_fallback": 1, + "legacy_registry": 1 + }, + "by_status": { + "verified": 4, + "action_required": 2, + "blocked": 1 + }, + "by_route_gate": { + "route_preserved": 4, + "review_required": 1, + "candidate_blocked": 1, + "source_mismatch": 1 + }, + "route_ids_requiring_action": [ + "legacy_models_json_policy", + "openclaw_nemo_rca_lane" + ], + "candidate_gate_ids_requiring_approval": [ + "nemotron_replay_gate", + "paid_provider_call_gate", + "provider_switch_gate" + ], + "source_gap_ids": [ + "ai_router_comment_drift", + "legacy_model_registry_order_gap", + "machine_backlog_next_task_gap" + ], + "read_only_denials_total": 12, + "provider_switch_allowed_count": 0, + "paid_api_call_allowed_count": 0, + "shadow_or_canary_allowed_count": 0, + "runtime_route_change_allowed_count": 0 + }, + "provider_routes": [ + { + "route_id": "ai_router_execution_policy", + "display_name": "AI Router 執行決策核心", + "kind": "ai_router_core", + "status": "verified", + "risk_level": "critical", + "route_gate": "route_preserved", + "evidence_status": "committed_source", + "current_policy": "AIRouter 依 intent、risk、complexity 選 provider;CRITICAL / DELETE 仍強制 Claude,高複雜度或 HIGH 風險走 OpenClaw Nemo,低中複雜度走 Ollama。", + "provider_order": [ + "ollama", + "openclaw_nemo", + "gemini", + "claude" + ], + "fallback_policy": "一般 intent 使用 full fallback chain;DIAGNOSE 有專屬 chain,且 Ollama 初選會先交 OllamaFailoverManager 重評。", + "evidence_refs": [ + "apps/api/src/services/ai_router.py" + ], + "next_action": "只讀顯示路由矩陣;任何 provider override 或 fallback chain 變更都需另開批准包。" + }, + { + "route_id": "ollama_global_endpoint_order", + "display_name": "Ollama GCP-A → GCP-B → 111", + "kind": "ollama_failover", + "status": "verified", + "risk_level": "critical", + "route_gate": "route_preserved", + "evidence_status": "committed_manifest", + "current_policy": "生產 ConfigMap 與 deployment env 維持 OLLAMA_URL、OLLAMA_SECONDARY_URL、OLLAMA_FALLBACK_URL;policy order 是 GCP-A → GCP-B → 111,再到 Gemini final fallback。", + "provider_order": [ + "ollama_gcp_a", + "ollama_gcp_b", + "ollama_local", + "gemini" + ], + "fallback_policy": "OllamaFailoverManager 只在 primary 不健康時檢查後兩層;SLOW Ollama 仍優先於直接燒 Gemini quota。", + "evidence_refs": [ + "apps/api/src/services/ollama_endpoint_resolver.py", + "apps/api/src/services/ollama_failover_manager.py", + "k8s/awoooi-prod/04-configmap.yaml", + "k8s/awoooi-prod/06-deployment-api.yaml" + ], + "next_action": "後續 P1-005 可只讀比對 health 缺口;不得在本任務改 OLLAMA_* 或重啟任何 host / pod。" + }, + { + "route_id": "alert_ai_ollama_first_lane", + "display_name": "告警 AI Ollama-first Lane", + "kind": "alert_governance_lane", + "status": "verified", + "risk_level": "critical", + "route_gate": "route_preserved", + "evidence_status": "committed_source", + "current_policy": "告警與 AI governance lane 必須先走 GCP-A / GCP-B / 111;cloud fallback 開啟時才加入 OpenClaw Nemo 與 Gemini。", + "provider_order": [ + "ollama_gcp_a", + "ollama_gcp_b", + "ollama_local", + "openclaw_nemo", + "gemini" + ], + "fallback_policy": "ALERT_AI_ENFORCE_OLLAMA_FIRST=true;ALERT_AI_ALLOW_CLOUD_FALLBACK=true 只代表既有 final fallback,不代表新增付費呼叫批准。", + "evidence_refs": [ + "apps/api/src/services/openclaw.py", + "k8s/awoooi-prod/04-configmap.yaml", + "k8s/awoooi-prod/06-deployment-api.yaml" + ], + "next_action": "在 AwoooP Runs / timeline 補 route evidence 時只能讀取既有 route status,不得提高 Gemini 呼叫頻率。" + }, + { + "route_id": "openclaw_nemo_rca_lane", + "display_name": "OpenClaw Nemo RCA Lane", + "kind": "openclaw_nemo", + "status": "action_required", + "risk_level": "high", + "route_gate": "review_required", + "evidence_status": "committed_source", + "current_policy": "OpenClawNemoProvider 維持 AWOOOI API → OpenClaw 188 → NVIDIA NIM 的 RCA 委派路徑;OpenClaw 仍是目前生產仲裁核心。", + "provider_order": [ + "openclaw_nemo", + "gemini", + "claude" + ], + "fallback_policy": "OpenClaw degraded / low confidence response 會被視為 fallbackable;P1-004 只盤點,不更換 incumbent。", + "evidence_refs": [ + "apps/api/src/services/ai_providers/openclaw_nemo.py", + "apps/api/src/services/ai_router.py", + "docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md" + ], + "next_action": "後續以 replay / shadow / canary 證據比較候選;驗收前不得宣稱 OpenClaw 可被取代。" + }, + { + "route_id": "nemotron_tool_calling_candidate", + "display_name": "Nemotron Tool Calling 候選", + "kind": "nemotron_candidate", + "status": "blocked", + "risk_level": "critical", + "route_gate": "candidate_blocked", + "evidence_status": "blocked_replay_recorded", + "current_policy": "NemotronProvider 可作 tool calling / 離線 evaluator,但最近 replay / fast-model smoke 仍未過 latency 與品質 gate;不得進 production route、shadow 或 canary。", + "provider_order": [ + "nemotron" + ], + "fallback_policy": "只保留離線 smoke / replay / sanitized request pack;任何 NVIDIA/NIM live 呼叫需另有批准與成本邊界。", + "evidence_refs": [ + "apps/api/src/services/ai_providers/nemotron.py", + "apps/api/src/services/ai_providers/permissions.py", + "docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md", + "docs/ai/AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md" + ], + "next_action": "P3 才刷新 primary source evidence 與 5 筆 smoke;不得把 P1-004 matrix 當作 Nemotron 升級批准。" + }, + { + "route_id": "gemini_final_fallback_policy", + "display_name": "Gemini Final Fallback", + "kind": "paid_cloud_fallback", + "status": "verified", + "risk_level": "critical", + "route_gate": "route_preserved", + "evidence_status": "committed_source", + "current_policy": "Gemini 是 Ollama 三層失敗後的 final cloud fallback;既有 route 可顯示,但本任務不新增 paid probe、不直接呼叫 Gemini、不改 quota。", + "provider_order": [ + "gemini", + "claude" + ], + "fallback_policy": "所有新增付費 provider 呼叫、頻率提高或 quota 調整都需 cost approval;P1-004 只列路徑。", + "evidence_refs": [ + "docs/HARD_RULES.md#cost-change-approval", + "apps/api/src/services/openclaw.py", + "apps/api/src/services/ollama_failover_manager.py", + "apps/api/models.json" + ], + "next_action": "P2-004 才能提出成本與 fallback 優化;本階段保持現狀。" + }, + { + "route_id": "legacy_models_json_policy", + "display_name": "legacy models.json 路由註記", + "kind": "legacy_registry", + "status": "action_required", + "risk_level": "medium", + "route_gate": "source_mismatch", + "evidence_status": "committed_source", + "current_policy": "apps/api/models.json 仍保留 default_provider=ollama、fallback_order=ollama→gemini→claude、tool_calling_fallback_order=nvidia→gemini→claude 與 GCP-A direct endpoint 註記;生產實際 env 則經 110 proxy 與 AI Router / failover manager 決策。", + "provider_order": [ + "ollama", + "gemini", + "claude", + "nvidia" + ], + "fallback_policy": "此檔可作 legacy registry evidence,不可單獨視為生產路由真相;需與 k8s env、AI Router source、runtime route status 一起判讀。", + "evidence_refs": [ + "apps/api/models.json", + "k8s/awoooi-prod/04-configmap.yaml", + "k8s/awoooi-prod/06-deployment-api.yaml" + ], + "next_action": "後續做 source-control / owner response 時整理 legacy registry reconciliation,不在 P1-004 直接改路由設定。" + } + ], + "candidate_gates": [ + { + "gate_id": "provider_switch_gate", + "display_name": "Provider 切換 Gate", + "status": "production_change_blocked", + "approval_required": true, + "summary": "provider switch、production routing change、USE_AI_ROUTER 切換與 fallback order 修改全部需要獨立人工批准。", + "evidence_refs": [ + "docs/HARD_RULES.md#iwooos-security-governance", + "docs/HARD_RULES.md#cost-change-approval" + ], + "next_action": "若未來要切 provider,需先準備 replay、shadow/canary、成本與 rollback 批准包。" + }, + { + "gate_id": "nemotron_replay_gate", + "display_name": "Nemotron Replay Gate", + "status": "blocked_by_evidence", + "approval_required": true, + "summary": "Nemotron 目前僅保留為離線 specialist / evaluator;不得取代 OpenClaw 或進 shadow/canary。", + "evidence_refs": [ + "docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md", + "docs/ai/AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md" + ], + "next_action": "P3 refresh source evidence 後,僅能先做 5 筆 smoke。" + }, + { + "gate_id": "paid_provider_call_gate", + "display_name": "付費 Provider 呼叫 Gate", + "status": "cost_approval_required", + "approval_required": true, + "summary": "Gemini / Claude / NVIDIA external call、quota 或頻率提升都需先做成本影響與人工批准。", + "evidence_refs": [ + "docs/HARD_RULES.md#cost-change-approval", + "apps/api/models.json" + ], + "next_action": "P1-004 不做 live paid call;P2-004 才能整理成本優化提案。" + } + ], + "source_gaps": [ + { + "gap_id": "ai_router_comment_drift", + "display_name": "AI Router 註解與現況 drift", + "status": "action_required", + "severity": "medium", + "summary": "ai_router.py 同時保留 DIAGNOSE 過往變更註解與目前 override;人工閱讀時容易把舊註解誤讀成現行路由。", + "evidence_refs": [ + "apps/api/src/services/ai_router.py" + ], + "next_action": "後續文件同步時整理 route truth 表,不直接改 provider logic。" + }, + { + "gap_id": "legacy_model_registry_order_gap", + "display_name": "models.json 與 runtime env 判讀差異", + "status": "action_required", + "severity": "medium", + "summary": "models.json 是 legacy registry;生產 OLLAMA_* 以 k8s env 經 110 proxy 為準。若只看 models.json 會誤判 GCP-A direct endpoint。", + "evidence_refs": [ + "apps/api/models.json", + "k8s/awoooi-prod/06-deployment-api.yaml" + ], + "next_action": "建立 source truth precedence:runtime env / AI Router source / registry / proposal。" + }, + { + "gap_id": "machine_backlog_next_task_gap", + "display_name": "P1-005 / P1-006 機器待辦同步缺口", + "status": "proposal_required", + "severity": "low", + "summary": "工作清單 MD 列出 P1-005 / P1-006,但目前 committed backlog JSON 仍未納入這兩項;P1-004 完成後需在下一階段對齊。", + "evidence_refs": [ + "docs/ai/AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md", + "docs/evaluations/ai_agent_automation_backlog_2026-06-04.json" + ], + "next_action": "P1-005 啟動前先決定是否補 machine backlog item,避免 UI next_task 跟 MD 不一致。" + } + ], + "latest_observations": [ + { + "observation_id": "prod_manifest_ai_router_enabled", + "status": "verified", + "summary": "生產 manifest committed env 顯示 USE_AI_ROUTER=true,OLLAMA_* 三層 route 仍經 110 proxy,Gemini 僅為三層失敗後備援。", + "evidence_refs": [ + "k8s/awoooi-prod/04-configmap.yaml", + "k8s/awoooi-prod/06-deployment-api.yaml" + ] + }, + { + "observation_id": "operator_route_status_exists", + "status": "verified", + "summary": "platform operator service 已有只讀 ai-route-status,P1-004 不新增 live probe 或 paid probe。", + "evidence_refs": [ + "apps/api/src/services/platform_operator_service.py", + "apps/api/src/api/v1/platform/operator_runs.py" + ] + }, + { + "observation_id": "nemotron_candidate_blocked", + "status": "blocked", + "summary": "MASTER 記錄 Nemotron replay / fast-model smoke 未達取代門檻,只能保留為離線候選。", + "evidence_refs": [ + "docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md" + ] + } + ], + "operator_contract": { + "display_mode": "read_only_ai_provider_route_matrix", + "must_not_interpret_as": [ + "provider 切換批准", + "production routing change 批准", + "USE_AI_ROUTER 開關變更批准", + "Gemini / Claude / NVIDIA 付費呼叫批准", + "OpenClaw 取代或降級批准", + "Nemotron 進 shadow / canary 批准", + "fallback order 修改批准", + "Ollama endpoint / ConfigMap 修改批准", + "Secret payload 已讀取或可輸出", + "外部 live probe 或 benchmark 批准", + "workflow / deploy / reload 觸發批准", + "runtime execution 授權" + ], + "secret_display_policy": "只顯示 env var 名稱與 redacted metadata;不得輸出 API key、token、header 或 payload。", + "provider_switch_policy": "P1-004 只能盤點與顯示;切換 provider 需 replay、shadow/canary、成本、rollback 與獨立人工批准。", + "cost_policy": "新增或提高 Gemini / Claude / NVIDIA 呼叫皆需 cost approval;本 snapshot 不呼叫任何 paid provider。", + "runtime_policy": "UI/API 可見不等於 runtime route 已授權;active runtime gate 維持 0。" + }, + "operation_boundaries": { + "read_only_api_allowed": true, + "provider_switch_allowed": false, + "production_routing_change_allowed": false, + "use_ai_router_toggle_allowed": false, + "fallback_order_change_allowed": false, + "ollama_endpoint_change_allowed": false, + "paid_api_call_allowed": false, + "paid_api_frequency_increase_allowed": false, + "external_provider_probe_allowed": false, + "live_benchmark_allowed": false, + "shadow_or_canary_allowed": false, + "openclaw_replacement_allowed": false, + "nemotron_shadow_allowed": false, + "gemini_direct_call_allowed": false, + "secret_read_allowed": false, + "secret_plaintext_allowed": false, + "notification_send_allowed": false, + "workflow_trigger_allowed": false, + "deploy_trigger_allowed": false, + "reload_trigger_allowed": false, + "runtime_execution_allowed": false + }, + "approval_boundaries": { + "provider_switch_approved": false, + "production_routing_change_approved": false, + "cost_change_approved": false, + "shadow_or_canary_approved": false, + "external_provider_call_approved": false, + "openclaw_replacement_approved": false, + "nemotron_replay_approved": false, + "secret_access_approved": false, + "runtime_execution_approved": false + } +} diff --git a/docs/schemas/ai_provider_route_matrix_v1.schema.json b/docs/schemas/ai_provider_route_matrix_v1.schema.json new file mode 100644 index 000000000..7678a04e5 --- /dev/null +++ b/docs/schemas/ai_provider_route_matrix_v1.schema.json @@ -0,0 +1,161 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:awoooi:ai-provider-route-matrix-v1", + "title": "AWOOOI AI Provider 路由只讀矩陣 v1", + "description": "以 repo 內 committed AI Router / Ollama / OpenClaw / Nemotron / Gemini evidence 建立只讀 provider route matrix;不切 provider、不呼叫付費 API、不進 shadow / canary、不改 runtime route、不讀 secret payload。", + "type": "object", + "required": [ + "schema_version", + "generated_at", + "program_status", + "source_refs", + "rollups", + "provider_routes", + "candidate_gates", + "source_gaps", + "latest_observations", + "operator_contract", + "operation_boundaries", + "approval_boundaries" + ], + "properties": { + "schema_version": { + "type": "string", + "const": "ai_provider_route_matrix_v1" + }, + "generated_at": { + "type": "string", + "minLength": 1 + }, + "program_status": { + "type": "object", + "required": [ + "overall_completion_percent", + "current_priority", + "current_task_id", + "next_task_id", + "read_only_mode" + ], + "properties": { + "overall_completion_percent": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "current_priority": { + "type": "string", + "enum": [ + "P0", + "P1", + "P2", + "P3" + ] + }, + "current_task_id": { + "type": "string", + "minLength": 1 + }, + "next_task_id": { + "type": "string", + "minLength": 1 + }, + "read_only_mode": { + "type": "boolean", + "const": true + } + }, + "additionalProperties": false + }, + "source_refs": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "rollups": { + "type": "object", + "additionalProperties": true + }, + "provider_routes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "route_id", + "display_name", + "kind", + "status", + "risk_level", + "route_gate", + "evidence_status", + "current_policy", + "provider_order", + "fallback_policy", + "evidence_refs", + "next_action" + ], + "additionalProperties": true + } + }, + "candidate_gates": { + "type": "array", + "items": { + "type": "object", + "required": [ + "gate_id", + "display_name", + "status", + "approval_required", + "summary", + "evidence_refs", + "next_action" + ], + "additionalProperties": true + } + }, + "source_gaps": { + "type": "array", + "items": { + "type": "object", + "required": [ + "gap_id", + "display_name", + "status", + "severity", + "summary", + "evidence_refs", + "next_action" + ], + "additionalProperties": true + } + }, + "latest_observations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "operator_contract": { + "type": "object", + "additionalProperties": true + }, + "operation_boundaries": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + "approval_boundaries": { + "type": "object", + "additionalProperties": { + "type": "boolean", + "const": false + } + } + }, + "additionalProperties": false +} diff --git a/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md b/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md index a2a9bdd54..352aad1a7 100644 --- a/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md +++ b/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md @@ -3534,3 +3534,22 @@ Phase 6 完成後 2. 持續保持 S4.9 owner response gate `0%` 與 active runtime gate `0`,不得把 P1-003 UI / API 可見誤讀成 runtime 授權。 **裁決:** P1-003 只完成 read-only observability contract matrix 與降噪候選顯示。不得把 Prometheus rule count、Alertmanager grouping、SigNoz / Sentry heartbeat、Grafana dashboard JSON、OTEL/Event Exporter evidence 或 classification gap 解讀成 alert rule 變更、receiver/route 變更、OpenClaw receiver 授權、silence、dashboard import、webhook 修改、secret 讀取、通知發送、deploy/reload/workflow 或 runtime execution 批准。成功 smoke 不即時通知洗版;失敗與需處置才進批准流程。 + +### 2026-06-05 下午 (台北) — P1-004 AI Provider 路由矩陣本地完成 + +**觸發**:統帥批准繼續,要求依 `docs/ai/AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md` 的優先順序推進,並在每階段同步完成度、工作狀態、驗證節點與正式環境頁面檢查。 + +**已推進:** +- P1-004:新增 `ai_provider_route_matrix_v1` schema 與 `docs/evaluations/ai_provider_route_matrix_2026-06-05.json`,以 committed AI Router / Ollama / OpenClaw / Nemotron / Gemini evidence 建立只讀 provider route matrix。 +- P1-004:新增 `GET /api/v1/agents/ai-provider-route-matrix` 只讀 API 與 service guard,強制拒絕把 snapshot 誤讀成 provider switch、production routing change、`USE_AI_ROUTER` 切換、fallback order 修改、`OLLAMA_*` endpoint / ConfigMap 修改、付費 provider 呼叫、shadow / canary、OpenClaw 取代、Secret payload、外部 live probe、workflow / deploy / reload 或 runtime execution 授權。 +- P1-004:治理頁 `/zh-TW/governance?tab=automation-inventory` 新增「AI 供應商路由矩陣」區塊;顯示路由 KPI、Ollama 三層、候選關卡、來源缺口、不可誤讀合約與 0 allowed runtime route entries,不新增任何執行按鈕。 +- 目前數字:provider routes `7`;需處置 routes `2`;需人工批准 candidate gates `3`;source gaps `3`;blocked candidates `1`;provider switch / paid API call / shadow or canary / runtime route change allowed counts 全部 `0`;automation backlog done `20/23`、overall `87%`、P1 `95%`、WS3 `100%`;inventory tasks `30`。 +- 本地驗證:JSON parse 通過;AI provider route matrix service / API、automation inventory / backlog snapshot service / API 目標測試 `25 passed`;Python py_compile 通過;zh-TW / en i18n key 差異 `0`;web typecheck 通過;Next production build 通過;source-control-owner-response guard、security-mirror-progress guard、`git diff --check` 通過;本地 API readback 回 `ai_provider_route_matrix_v1`、current `P1-004`、next `P1-005`、routes `7`、approval gates `3`;backlog 回 `87%`、done `20/23`;inventory 回 tasks `30`;本地 desktop `1440x1000` 與 mobile `390x844` browser smoke 通過,`AI 供應商路由矩陣`、`P1-004`、`P1-005`、`87%`、`Ollama 三層`、`Nemotron 候選`、`不可誤讀合約` 可見,`horizontalOverflow=0`、overflowing elements `0`、危險互動入口 `0`。 +- 待補驗證:正式部署與 production desktop / mobile verification。 + +**下一步:** +1. 推送 `gitea main`,等待 Gitea code-review / CD 與 deploy marker。 +2. 正式部署後驗證 `/api/v1/agents/ai-provider-route-matrix`、automation backlog `87%`、governance desktop / mobile 無水平溢出與不可誤讀合約可見。 +3. P1-005:偵測服務健康缺口與過期端點;只能建立只讀 evidence 與需處置清單,不得重啟服務、改 endpoint 或做 active probe。 + +**裁決:** P1-004 只完成 read-only AI provider route matrix 與治理頁顯示。不得把 `ai_provider_route_matrix_v1`、Ollama 三層、Gemini final fallback、OpenClaw / Nemo RCA lane、Nemotron blocked candidate、legacy models registry 或 provider route source gaps 解讀成 provider 切換、付費呼叫、fallback 調整、OpenClaw 取代、Nemotron 升級、shadow / canary、Secret payload 讀取、workflow / deploy / reload 或 runtime execution 批准。