diff --git a/agent99-register-tasks.ps1 b/agent99-register-tasks.ps1 index de6c6a91b..cc3a93bd5 100644 --- a/agent99-register-tasks.ps1 +++ b/agent99-register-tasks.ps1 @@ -177,6 +177,51 @@ Register-ScheduledTask ` -Description "Agent99 backup freshness, integrity-status, cron-contract, and Telegram alert guard." ` -Force | Out-Null +# Superseded by the canonical all-host performance guard. An older Host188-only +# task sent directly to a product bot and duplicated the controlled Perf lane. +# Quarantine only the one known action fingerprint; an unknown action fails +# closed instead of being disabled blindly. +$legacyHost188TaskName = "Wooo-Agent99-Host188-Performance-Guard" +$legacyHost188ActionSha256 = "a2f98560673ebed5aa1f828b4e577c8a34d1c277b2e498044c3526a4ffe15379" +$legacyHost188Task = Get-ScheduledTask ` + -TaskName $legacyHost188TaskName ` + -TaskPath "\" ` + -ErrorAction SilentlyContinue +if ($legacyHost188Task) { + $legacyHost188Actions = @($legacyHost188Task.Actions) + if ($legacyHost188Actions.Count -ne 1) { + throw "legacy_host188_sender_action_count_mismatch" + } + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + $argumentBytes = [Text.Encoding]::UTF8.GetBytes( + [string]$legacyHost188Actions[0].Arguments + ) + $actualLegacyActionSha256 = (($sha256.ComputeHash($argumentBytes) | + ForEach-Object { $_.ToString("x2") }) -join "") + } finally { + $sha256.Dispose() + } + if ($actualLegacyActionSha256 -ne $legacyHost188ActionSha256) { + throw "legacy_host188_sender_action_identity_mismatch" + } + if ([string]$legacyHost188Task.State -eq "Running") { + throw "legacy_host188_sender_running_refuse_quarantine" + } + if ([bool]$legacyHost188Task.Settings.Enabled) { + Disable-ScheduledTask ` + -TaskName $legacyHost188TaskName ` + -TaskPath "\" | Out-Null + } + $legacyHost188TaskAfter = Get-ScheduledTask ` + -TaskName $legacyHost188TaskName ` + -TaskPath "\" ` + -ErrorAction Stop + if ([bool]$legacyHost188TaskAfter.Settings.Enabled) { + throw "legacy_host188_sender_quarantine_post_verifier_failed" + } +} + $taskNames = @( "Wooo-Agent99-Startup-Recovery", "Wooo-Agent99-Heartbeat", diff --git a/apps/api/src/core/config.py b/apps/api/src/core/config.py index 086b33b75..2a9795f48 100644 --- a/apps/api/src/core/config.py +++ b/apps/api/src/core/config.py @@ -699,6 +699,19 @@ class Settings(BaseSettings): ) GEMINI_API_KEY: str = Field(default="", description="Google Gemini API key") CLAUDE_API_KEY: str = Field(default="", description="Anthropic Claude API key") + ENABLE_PAID_PROVIDER_CANARY_WORKER: bool = Field( + default=False, + description=( + "Let the singleton signal worker execute an already-approved, " + "single-use AIA-SRE-013 paid-provider canary run." + ), + ) + PAID_PROVIDER_CANARY_WORKER_INTERVAL_SECONDS: int = Field( + default=2, + ge=1, + le=30, + description="Polling interval for the bounded paid-provider canary worker.", + ) LOCAL_CODE_REVIEW_ALLOW_GEMINI_FALLBACK: bool = Field( default=False, description=( diff --git a/apps/api/src/jobs/paid_provider_canary_worker.py b/apps/api/src/jobs/paid_provider_canary_worker.py new file mode 100644 index 000000000..8d8ea5adb --- /dev/null +++ b/apps/api/src/jobs/paid_provider_canary_worker.py @@ -0,0 +1,157 @@ +"""Run one approved paid-provider canary from the singleton signal worker. + +The Gate 5 API only creates and approves the durable run. This worker is the +bounded executor handoff: it claims the exact ``running`` AIA-SRE-013 row and +invokes the existing single-use validation contract with the same run, +authorization, trace, and work-item identity. It never receives API keys as +arguments and never persists prompts or responses. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime +from typing import Any + +import structlog +from sqlalchemy import select + +from src.db.awooop_models import AwoooPRunState +from src.db.base import get_db_context +from src.services.paid_provider_canary_authorization import ( + PAID_CANARY_AGENT_ID, + PAID_CANARY_PROJECT_ID, + PAID_CANARY_TRIGGER_REF, +) + +logger = structlog.get_logger(__name__) + +_WORKER_OPERATOR_ID = "awoooi-paid-provider-canary-worker" +_DEFAULT_INTERVAL_SECONDS = 2.0 + + +async def _load_next_approved_run_id() -> str | None: + now = datetime.now(UTC).replace(tzinfo=None) + async with get_db_context(PAID_CANARY_PROJECT_ID) as db: + result = await db.execute( + select(AwoooPRunState.run_id) + .where( + AwoooPRunState.project_id == PAID_CANARY_PROJECT_ID, + AwoooPRunState.agent_id == PAID_CANARY_AGENT_ID, + AwoooPRunState.trigger_ref == PAID_CANARY_TRIGGER_REF, + AwoooPRunState.state == "running", + AwoooPRunState.is_shadow.is_(False), + AwoooPRunState.timeout_at.is_not(None), + AwoooPRunState.timeout_at > now, + ) + .order_by(AwoooPRunState.created_at.asc()) + .limit(1) + ) + run_id = result.scalar_one_or_none() + return str(run_id) if run_id is not None else None + + +async def _execute_canary(**kwargs: Any) -> dict[str, Any]: + from src.services.paid_provider_canary_validation import ( + run_paid_provider_canary_validation, + ) + + return await run_paid_provider_canary_validation(**kwargs) + + +async def run_paid_provider_canary_worker_once( + *, + run_loader: Callable[[], Awaitable[str | None]] | None = None, + executor: Callable[..., Awaitable[dict[str, Any]]] | None = None, +) -> dict[str, Any]: + """Execute at most one exact approved run and return a non-secret receipt.""" + + selected_run_loader = run_loader or _load_next_approved_run_id + selected_executor = executor or _execute_canary + run_id = await selected_run_loader() + if not run_id: + return { + "schema_version": "paid_provider_canary_worker_receipt_v1", + "status": "idle_no_approved_run", + "execution_attempted": False, + "raw_prompt_persisted": False, + "raw_response_persisted": False, + "secret_value_read_or_returned": False, + } + + try: + receipt = await selected_executor( + run_ref=run_id, + authorization_ref=run_id, + operator_id=_WORKER_OPERATOR_ID, + ) + except asyncio.CancelledError: + raise + except BaseException as exc: + logger.error( + "paid_provider_canary_worker_execution_failed", + run_id=run_id, + error_code=f"executor_{type(exc).__name__}"[:120], + ) + return { + "schema_version": "paid_provider_canary_worker_receipt_v1", + "status": "blocked_with_safe_next_action", + "run_id": run_id, + "execution_attempted": True, + "error_code": f"executor_{type(exc).__name__}"[:120], + "raw_prompt_persisted": False, + "raw_response_persisted": False, + "secret_value_read_or_returned": False, + } + + verifier = receipt.get("independent_verifier") or {} + return { + "schema_version": "paid_provider_canary_worker_receipt_v1", + "status": ( + "terminal_verified" + if verifier.get("passed") is True + else "terminal_failed_verifier" + ), + "run_id": run_id, + "execution_attempted": True, + "same_run_authorization": receipt.get("run_id") == run_id, + "independent_verifier_passed": verifier.get("passed") is True, + "raw_prompt_persisted": False, + "raw_response_persisted": False, + "secret_value_read_or_returned": False, + } + + +async def run_paid_provider_canary_worker_loop( + *, interval_seconds: float = _DEFAULT_INTERVAL_SECONDS, +) -> None: + """Poll for an approved run; single-use claims prevent duplicate calls.""" + + delay = max(1.0, min(30.0, float(interval_seconds))) + logger.info( + "paid_provider_canary_worker_started", + interval_seconds=delay, + project_id=PAID_CANARY_PROJECT_ID, + ) + while True: + try: + receipt = await run_paid_provider_canary_worker_once() + if receipt["execution_attempted"] is True: + logger.info( + "paid_provider_canary_worker_cycle_terminal", + run_id=receipt.get("run_id"), + status=receipt.get("status"), + independent_verifier_passed=receipt.get( + "independent_verifier_passed" + ), + ) + except asyncio.CancelledError: + raise + except BaseException as exc: + logger.error( + "paid_provider_canary_worker_cycle_failed", + error_code=f"cycle_{type(exc).__name__}"[:120], + ) + await asyncio.sleep(delay) + diff --git a/apps/api/src/models/ai.py b/apps/api/src/models/ai.py index 37e867dfa..47301f8c7 100644 --- a/apps/api/src/models/ai.py +++ b/apps/api/src/models/ai.py @@ -38,6 +38,7 @@ class AIRiskLevel(str, Enum): """AI 風險評估等級""" LOW = "low" MEDIUM = "medium" + HIGH = "high" CRITICAL = "critical" diff --git a/apps/api/src/services/ai_control.py b/apps/api/src/services/ai_control.py index b3b3bdad6..7344df4f3 100644 --- a/apps/api/src/services/ai_control.py +++ b/apps/api/src/services/ai_control.py @@ -22,6 +22,7 @@ Usage: """ import hashlib +import json import re from typing import Any @@ -585,21 +586,144 @@ async def get_status_summary() -> str: primary = await get_primary_provider() primary_str = f"🎯 {primary}" if primary else "🤖 智慧路由 (AIRouter 決定)" - # Provider 狀態 + # Provider 狀態:只讀既有 health/control receipts,不呼叫模型或端點。 + ollama_specs = ( + ("ollama_gcp_a", "GCP-A", "OLLAMA_URL"), + ("ollama_gcp_b", "GCP-B", "OLLAMA_SECONDARY_URL"), + ("ollama_local", "111", "OLLAMA_FALLBACK_URL"), + ) + ollama_health = {provider: "not_observed" for provider, _, _ in ollama_specs} + try: + from src.services.ollama_health_monitor import make_cache_key + + health_redis = await _get_redis() + for provider, _, setting_name in ollama_specs: + endpoint = str(getattr(settings, setting_name, "") or "").strip() + if not endpoint: + ollama_health[provider] = "not_configured" + continue + try: + raw = await health_redis.get(make_cache_key(endpoint)) + if raw: + if isinstance(raw, bytes): + raw = raw.decode() + receipt = json.loads(raw) + ollama_health[provider] = str( + receipt.get("status") or "invalid_receipt" + ) + except Exception as exc: + ollama_health[provider] = "readback_unavailable" + logger.warning( + "ai_control_ollama_health_receipt_read_failed", + provider=provider, + error_type=type(exc).__name__, + ) + except Exception as exc: + logger.warning( + "ai_control_ollama_health_readback_unavailable", + error_type=type(exc).__name__, + ) + provider_lines = [] - for p in [ - "ollama_gcp_a", - "ollama_gcp_b", - "ollama_local", - "claude", - "gemini", - ]: + for provider, label, _ in ollama_specs: try: - disabled = await is_provider_disabled(p) + control = "disabled" if await is_provider_disabled(provider) else "enabled" except Exception: - disabled = True - icon = "❌" if disabled else "✅" - provider_lines.append(f" {icon} {p}") + control = "readback_unavailable" + health = ollama_health[provider] + if control == "disabled": + icon = "⏸️" + elif health == "healthy": + icon = "🟢" + elif health in {"slow", "degraded"}: + icon = "🟠" + elif health == "offline": + icon = "🔴" + else: + icon = "⚪" + provider_lines.append( + f" {icon} {label} | health={health} | control={control}" + ) + + paid_control: dict[str, Any] = {} + route_lease = "readback_unavailable" + try: + paid_control = await get_paid_provider_canary_control_readback() + if paid_control.get("legacy_persistent_state_detected") is True: + route_lease = "legacy_persistent_state_detected" + elif ( + paid_control.get("lock_present") is True + and paid_control.get("claude_disabled") is False + and paid_control.get("gemini_disabled") is False + and int(paid_control.get("lock_ttl_seconds") or 0) > 0 + ): + route_lease = "active_run_owned_lease" + elif paid_control.get("disabled_verified") is True: + route_lease = "待單次 run-owned lease" + else: + route_lease = "inconsistent_fail_closed" + except Exception as exc: + logger.warning( + "ai_control_paid_control_status_readback_unavailable", + error_type=type(exc).__name__, + ) + + authentication: dict[str, dict[str, Any]] = {} + try: + from src.services.ai_rate_limiter import get_ai_rate_limiter + + limiter = get_ai_rate_limiter() + for provider in ("claude", "gemini"): + try: + authentication[provider] = ( + await limiter.get_provider_authentication_status(provider) + ) + except Exception: + authentication[provider] = { + "configured": None, + "authenticated": None, + "authentication_verified": False, + "verification_status": "authentication_readback_unavailable", + } + except Exception as exc: + logger.warning( + "ai_control_paid_authentication_status_readback_unavailable", + error_type=type(exc).__name__, + ) + + def _truth_text(value: Any) -> str: + if value is True: + return "true" + if value is False: + return "false" + return "unknown" + + for provider, label in (("claude", "Claude"), ("gemini", "Gemini")): + auth = authentication.get(provider) or {} + configured = auth.get("configured") + mode = str( + getattr(settings, f"{provider.upper()}_EXECUTION_MODE", "shadow") + ).lower() + percent = int( + getattr(settings, f"{provider.upper()}_CANARY_PERCENT", 0) or 0 + ) + if configured is True and auth.get("authenticated") is False: + icon = "⚠️" + elif configured is True and route_lease == "待單次 run-owned lease": + icon = "🧪" + elif configured is True and route_lease == "active_run_owned_lease": + icon = "🟢" + elif configured is True: + icon = "⚠️" + else: + icon = "⚪" + provider_lines.append( + f" {icon} {label} | configured={_truth_text(configured)} | " + f"authenticated={_truth_text(auth.get('authenticated'))} | " + "verification=" + f"{auth.get('verification_status', 'authentication_readback_unavailable')} | " + f"canary={mode} {percent}% | route_lease={route_lease}" + ) provider_lines.extend( f" ⚪ {provider} (shadow metadata only)" for provider in sorted(SHADOW_METADATA_ONLY_PROVIDERS) @@ -608,15 +732,31 @@ async def get_status_summary() -> str: # 費用統計 cost_lines = [] try: - r = await _get_redis() - for p in ["claude", "gemini"]: - val = await r.get(f"ai_rate:total_cost:{p}") - if val: - cost = float(val) - if cost > 0: - cost_lines.append(f" 💰 {p}: ${cost:.4f}") - except Exception: - cost_lines.append(" (費用統計不可用)") + from src.services.ai_rate_limiter import get_ai_rate_limiter + + limiter = get_ai_rate_limiter() + for provider in ("claude", "gemini"): + usage = await limiter.get_usage_stats(provider) + cost = float( + (usage.get("total_cost_usd") or {}).get("current") or 0.0 + ) + accounting = usage.get("accounting") or {} + receipt_backed = bool( + usage.get("readback_status") == "verified" + and accounting.get("complete") is True + and accounting.get("status") == "receipt_backed" + and accounting.get("degraded") is not True + ) + cost_lines.append( + f" 💰 {provider}: ${cost:.6f} | " + f"receipt={'verified' if receipt_backed else 'degraded'}" + ) + except Exception as exc: + logger.warning( + "ai_control_status_cost_readback_unavailable", + error_type=type(exc).__name__, + ) + cost_lines.append(" (accounting receipt readback unavailable)") providers_str = "\n".join(provider_lines) costs_str = "\n".join(cost_lines) if cost_lines else " (尚無費用記錄)" @@ -629,7 +769,8 @@ async def get_status_summary() -> str: f"費用統計:\n{costs_str}\n\n" f"/ai primary <provider> 切換\n" f"/ai router on/off 開關 AIRouter\n" - f"/ai enable/disable <provider> 控制 Provider" + f"/ai enable/disable <ollama provider> 控制 Ollama\n" + f"Claude/Gemini 僅由單次 run-owned canary lease 啟用" ) @@ -709,23 +850,49 @@ async def handle_ai_command(text: str) -> str: return f"⚠️ {provider} 已停用" if ok else "❌ 操作失敗" elif sub == "cost": - lines = ["💰 AI 費用統計\n"] + lines = ["💰 AI 費用 receipt\n"] try: - r = await _get_redis() + from src.services.ai_rate_limiter import get_ai_rate_limiter + + limiter = get_ai_rate_limiter() total = 0.0 - for p in ["claude", "gemini"]: - val = await r.get(f"ai_rate:total_cost:{p}") - if val: - cost = float(val) - total += cost - if cost > 0: - lines.append(f" {p}: ${cost:.4f}") - if total > 0: - lines.append(f"\n 總計: ${total:.4f}") - else: - lines.append(" (尚無費用記錄)") - except Exception as e: - lines.append(f" ⚠️ 費用查詢失敗: {e}") + all_receipt_backed = True + for provider in ("claude", "gemini"): + usage = await limiter.get_usage_stats(provider) + cost = float( + (usage.get("total_cost_usd") or {}).get("current") or 0.0 + ) + accounting = usage.get("accounting") or {} + receipt_backed = bool( + usage.get("readback_status") == "verified" + and accounting.get("complete") is True + and accounting.get("status") == "receipt_backed" + and accounting.get("degraded") is not True + ) + total += cost + all_receipt_backed = all_receipt_backed and receipt_backed + lines.append( + f" {provider}: ${cost:.6f} | " + f"receipt={'verified' if receipt_backed else 'degraded'}" + ) + lines.extend( + [ + f"\n 總計: ${total:.6f}", + ( + " ✅ receipt-backed accounting" + if all_receipt_backed + else " ⚠️ accounting receipt degraded;禁止假性估算" + ), + ] + ) + except Exception as exc: + logger.warning( + "ai_control_cost_readback_unavailable", + error_type=type(exc).__name__, + ) + lines.append( + " ⚠️ accounting readback unavailable;未以舊 Redis key 或估算值替代。" + ) return "\n".join(lines) elif sub == "help": diff --git a/apps/api/src/services/ai_providers/claude.py b/apps/api/src/services/ai_providers/claude.py index 93277c733..65181a8af 100644 --- a/apps/api/src/services/ai_providers/claude.py +++ b/apps/api/src/services/ai_providers/claude.py @@ -136,8 +136,10 @@ def _analysis_tool() -> dict[str, Any]: return { "name": "submit_analysis", "description": "Submit the RCA analysis result in structured format", + "strict": True, "input_schema": { "type": "object", + "additionalProperties": False, "properties": { "action_title": {"type": "string"}, "description": {"type": "string"}, @@ -159,6 +161,7 @@ def _analysis_tool() -> dict[str, Any]: }, "blast_radius": { "type": "object", + "additionalProperties": False, "properties": { "affected_pods": {"type": "integer"}, "estimated_downtime": {"type": "string"}, @@ -180,7 +183,11 @@ def _analysis_tool() -> dict[str, Any]: }, "reasoning": {"type": "string"}, "deviation_analysis": {"type": "string"}, - "confidence": {"type": "number"}, + "confidence": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + }, "affected_services": { "type": "array", "items": {"type": "string"}, diff --git a/apps/api/src/services/ai_providers/ollama.py b/apps/api/src/services/ai_providers/ollama.py index 03c3b3603..17a4168b8 100644 --- a/apps/api/src/services/ai_providers/ollama.py +++ b/apps/api/src/services/ai_providers/ollama.py @@ -13,6 +13,7 @@ from __future__ import annotations import hashlib import json +import re import time from collections.abc import Awaitable, Callable @@ -54,6 +55,24 @@ def _is_gcp_alert_lane(endpoint_url: str) -> bool: CloudTransportReceiptVerifier = Callable[..., Awaitable[str | None]] +def _safe_ollama_error_code(error: BaseException) -> str: + """Classify provider failures without returning endpoint or body text.""" + + if isinstance(error, httpx.TimeoutException): + return "provider_timeout" + if isinstance(error, httpx.HTTPStatusError): + status_code = int(error.response.status_code) + if 100 <= status_code <= 599: + return f"http_{status_code}" + return "provider_refusal" + if isinstance(error, httpx.TransportError | ConnectionError): + return "provider_transport_error" + error_type = type(error).__name__ + if re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,79}", error_type): + return f"unhandled_{error_type}" + return "provider_error_redacted" + + def _gcp_provider_identity(endpoint_url: str) -> str | None: endpoint = _normalized_url(endpoint_url) if endpoint == _normalized_url(getattr(settings, "OLLAMA_URL", "")): @@ -63,6 +82,51 @@ def _gcp_provider_identity(endpoint_url: str) -> str | None: return None +def _sre_chat_sanitization_receipt_block_reason( + *, + prompt: str, + context: dict, +) -> str | None: + """Mirror the router's formal real-chat receipt at the provider boundary.""" + + receipt = context.get("sre_chat_sanitization_receipt") + if not isinstance(receipt, dict): + return "sre_chat_sanitization_receipt_invalid" + + prompt_sha256 = hashlib.sha256(prompt.encode("utf-8")).hexdigest() + if receipt.get("prompt_sha256") != prompt_sha256: + return "sre_chat_sanitized_prompt_mismatch" + + correlation_fields = ("trace_id", "run_id", "work_item_id") + if ( + receipt.get("schema_version") != "sre_chat_sanitization_receipt_v1" + or receipt.get("status") != "verified" + or receipt.get("sanitizer") + != "src.services.sanitization_service.sanitize" + or receipt.get("source_label") != "awoooi_sre_group_chat" + or receipt.get("data_classification") != "sanitized" + or receipt.get("raw_input_forwarded") is not False + or receipt.get("raw_log_payload_forwarded") is not False + or receipt.get("secret_value_exposed") is not False + or receipt.get("private_network_value_exposed") is not False + or receipt.get("infrastructure_remediation_write_allowed") is not False + or receipt.get("provider_timeout_seconds") != 35 + or context.get("data_classification") != "sanitized" + or context.get("infrastructure_remediation_write_allowed") is not False + or context.get("provider_timeout_seconds") != 35 + or context.get("executor_invocation_allowed") is not False + or context.get("agent99_dispatch_allowed") is not False + or context.get("cross_domain_execution_allowed") is not False + or any( + not str(context.get(field) or "") + or receipt.get(field) != context.get(field) + for field in correlation_fields + ) + ): + return "sre_chat_sanitization_receipt_invalid" + return None + + async def _gcp_cloud_input_block_reason( *, prompt: str, @@ -96,6 +160,12 @@ async def _gcp_cloud_input_block_reason( ): return None return "gcp_synthetic_prompt_receipt_invalid" + if "sre_chat_sanitization_receipt" in cloud_context: + chat_block = _sre_chat_sanitization_receipt_block_reason( + prompt=prompt, + context=cloud_context, + ) + return f"gcp_{chat_block}" if chat_block else None receipt = cloud_context.get("cloud_sanitization_receipt") if not isinstance(receipt, dict): return "gcp_cloud_sanitization_receipt_missing" @@ -278,13 +348,37 @@ class OllamaProvider: except httpx.TimeoutException as e: latency = (time.perf_counter() - start) * 1000 - logger.warning("ollama_provider_timeout", error=str(e), latency_ms=round(latency, 1)) - return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error=f"Timeout: {e}") + error_code = _safe_ollama_error_code(e) + logger.warning( + "ollama_provider_timeout", + error_code=error_code, + error_type=type(e).__name__, + latency_ms=round(latency, 1), + ) + return AIResult( + raw_response="", + success=False, + provider=self.name, + latency_ms=latency, + error=error_code, + ) except Exception as e: latency = (time.perf_counter() - start) * 1000 - logger.warning("ollama_provider_failed", error=str(e), latency_ms=round(latency, 1)) - return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error=str(e)) + error_code = _safe_ollama_error_code(e) + logger.warning( + "ollama_provider_failed", + error_code=error_code, + error_type=type(e).__name__, + latency_ms=round(latency, 1), + ) + return AIResult( + raw_response="", + success=False, + provider=self.name, + latency_ms=latency, + error=error_code, + ) async def analyze_with_tools( self, @@ -429,11 +523,13 @@ class OllamaProvider: except Exception as e: latency = (time.perf_counter() - start) * 1000 + error_code = _safe_ollama_error_code(e) logger.warning( "ollama_agent_loop_failed", provider=self.name, agent_role=agent_role, - error=str(e), + error_code=error_code, + error_type=type(e).__name__, latency_ms=round(latency, 1), ) return AIResult( @@ -442,7 +538,7 @@ class OllamaProvider: provider=f"{self.name}_agent_loop", tokens=total_tokens, latency_ms=latency, - error=str(e), + error=error_code, ) async def health_check(self) -> bool: @@ -566,13 +662,37 @@ class OllamaLocalProvider(OllamaProvider): except httpx.TimeoutException as e: latency = (time.perf_counter() - start) * 1000 - logger.warning("ollama_local_provider_timeout", error=str(e), latency_ms=round(latency, 1)) - return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error=f"Timeout: {e}") + error_code = _safe_ollama_error_code(e) + logger.warning( + "ollama_local_provider_timeout", + error_code=error_code, + error_type=type(e).__name__, + latency_ms=round(latency, 1), + ) + return AIResult( + raw_response="", + success=False, + provider=self.name, + latency_ms=latency, + error=error_code, + ) except Exception as e: latency = (time.perf_counter() - start) * 1000 - logger.warning("ollama_local_provider_failed", error=str(e), latency_ms=round(latency, 1)) - return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error=str(e)) + error_code = _safe_ollama_error_code(e) + logger.warning( + "ollama_local_provider_failed", + error_code=error_code, + error_type=type(e).__name__, + latency_ms=round(latency, 1), + ) + return AIResult( + raw_response="", + success=False, + provider=self.name, + latency_ms=latency, + error=error_code, + ) async def health_check(self) -> bool: fallback_url = getattr(settings, "OLLAMA_FALLBACK_URL", "") diff --git a/apps/api/src/services/ai_router.py b/apps/api/src/services/ai_router.py index e838ebbea..084fa6609 100644 --- a/apps/api/src/services/ai_router.py +++ b/apps/api/src/services/ai_router.py @@ -1190,6 +1190,54 @@ class AIRouterExecutor: content = f"{prompt}{ctx_hash}" return f"llm_cache:{hashlib.sha256(content.encode()).hexdigest()[:16]}" + @staticmethod + def _sre_chat_sanitization_receipt_block_reason( + prompt: str, + context: dict | None, + ) -> str | None: + """Verify the formal prompt-bound receipt for real SRE conversation.""" + + root = context or {} + receipt = root.get("sre_chat_sanitization_receipt") + if not isinstance(receipt, dict): + # Preserve the established generic cloud boundary code for callers + # that provide neither an alert nor a real-chat receipt. + return "cloud_sanitization_receipt_missing" + + prompt_sha256 = hashlib.sha256(prompt.encode("utf-8")).hexdigest() + if receipt.get("prompt_sha256") != prompt_sha256: + return "sre_chat_sanitized_prompt_mismatch" + + correlation_fields = ("trace_id", "run_id", "work_item_id") + if ( + receipt.get("schema_version") + != "sre_chat_sanitization_receipt_v1" + or receipt.get("status") != "verified" + or receipt.get("sanitizer") + != "src.services.sanitization_service.sanitize" + or receipt.get("source_label") != "awoooi_sre_group_chat" + or receipt.get("data_classification") != "sanitized" + or receipt.get("raw_input_forwarded") is not False + or receipt.get("raw_log_payload_forwarded") is not False + or receipt.get("secret_value_exposed") is not False + or receipt.get("private_network_value_exposed") is not False + or receipt.get("infrastructure_remediation_write_allowed") is not False + or receipt.get("provider_timeout_seconds") != 35 + or root.get("data_classification") != "sanitized" + or root.get("infrastructure_remediation_write_allowed") is not False + or root.get("provider_timeout_seconds") != 35 + or root.get("executor_invocation_allowed") is not False + or root.get("agent99_dispatch_allowed") is not False + or root.get("cross_domain_execution_allowed") is not False + or any( + not str(root.get(field) or "") + or receipt.get(field) != root.get(field) + for field in correlation_fields + ) + ): + return "sre_chat_sanitization_receipt_invalid" + return None + @staticmethod def _paid_alert_execution_inputs( prompt: str, @@ -1202,16 +1250,24 @@ class AIRouterExecutor: for key in ("alert_type", "alertname", "alert_name", "incident_id", "signals") ) if not is_alert: - if ( - (context or {}).get("synthetic_validation") is True - and (context or {}).get("data_classification") == "sanitized" - and (context or {}).get("infrastructure_remediation_write_allowed") - is False - and (context or {}).get("synthetic_prompt_sha256") - == hashlib.sha256(prompt.encode("utf-8")).hexdigest() - ): + root = context or {} + if root.get("synthetic_validation") is True: + if ( + root.get("data_classification") == "sanitized" + and root.get("infrastructure_remediation_write_allowed") is False + and root.get("synthetic_prompt_sha256") + == hashlib.sha256(prompt.encode("utf-8")).hexdigest() + ): + return prompt, context, None + return None, None, "cloud_synthetic_prompt_receipt_invalid" + + chat_block = AIRouterExecutor._sre_chat_sanitization_receipt_block_reason( + prompt, + context, + ) + if chat_block is None: return prompt, context, None - return None, None, "cloud_sanitization_receipt_missing" + return None, None, chat_block cloud_context = (context or {}).get("paid_cloud_context") paid_prompt = (context or {}).get("paid_cloud_prompt") if not isinstance(cloud_context, dict) or not isinstance(paid_prompt, str): diff --git a/apps/api/src/services/awooop_ansible_audit_service.py b/apps/api/src/services/awooop_ansible_audit_service.py index 211f5a136..6f4efcd46 100644 --- a/apps/api/src/services/awooop_ansible_audit_service.py +++ b/apps/api/src/services/awooop_ansible_audit_service.py @@ -623,23 +623,22 @@ _CATALOG: tuple[dict[str, Any], ...] = ( "host_188", ], "keywords": [ - "openclawdown", - "clawbotdown", + "telegramcallbackingressunverified", + "telegramcallbackforwarderdrift", "openclaw callback forwarder", "telegram callback forwarder", "external callback forward unverified", ], "activation_keywords": [ - "openclawdown", - "clawbotdown", - "telegram callback forwarder", + "telegramcallbackingressunverified", + "telegramcallbackforwarderdrift", ], "supports_check_mode": True, "auto_apply_enabled": True, "approval_required": False, "risk_level": "medium", "canonical_asset_id": "service:openclaw:host188", - "catalog_revision": "2026-07-17-openclaw-callback-forwarder-v1", + "catalog_revision": "2026-07-17-openclaw-callback-overlay-v2", "cross_domain_fallback_allowed": False, }, { diff --git a/apps/api/src/services/awooop_ansible_post_verifier.py b/apps/api/src/services/awooop_ansible_post_verifier.py index 6408f6fac..578d94e3b 100644 --- a/apps/api/src/services/awooop_ansible_post_verifier.py +++ b/apps/api/src/services/awooop_ansible_post_verifier.py @@ -24,6 +24,11 @@ _SAFE_REMOTE_ID_RE = re.compile(r"^[A-Za-z0-9_.-]+$") _FIXED_PROXY_JUMP_BY_HOST = { "host_111": "wooo@192.168.0.110", } +_RUN_SCOPED_CLOSURE_BLOCKER_BY_CATALOG = { + "ansible:188-openclaw-callback-forwarder": ( + "fresh_callback_receipt_run_scope_unavailable_in_static_postcondition_registry" + ), +} @dataclass(frozen=True) @@ -240,41 +245,136 @@ _POSTCONDITION_REGISTRY: dict[str, tuple[AssetPostcondition, ...]] = { ), "ansible:188-openclaw-callback-forwarder": ( AssetPostcondition( - "host_188_openclaw_immutable_source_identity", + "host_188_openclaw_overlay_manifest_identity", "configuration", "host_188", - "wd=$(systemctl show clawbot.service --property=WorkingDirectory " - "--value); case \"$wd\" in /opt/openclaw|/home/ollama/clawbot-v5) ;; " - "*) exit 1 ;; esac; " - "test \"$(git -C \"$wd\" symbolic-ref --short HEAD)\" = main && " - "test \"$(git -C \"$wd\" rev-parse --verify HEAD)\" = " - "ffc45311b6403b30cb6ae6e1aa83dae1a047e9c9 && " - "test \"$(git -C \"$wd\" remote get-url origin)\" = " - "ssh://git@192.168.0.110:2222/wooo/clawbot-v5.git && " - "git -C \"$wd\" diff --quiet && " - "git -C \"$wd\" diff --cached --quiet && " - "test -z \"$(git -C \"$wd\" ls-files --others --exclude-standard)\"", + "set -euo pipefail; " + "root=/var/lib/awoooi-controlled-apply/openclaw-callback-forwarder/" + "payloads/ffc45311b6403b30cb6ae6e1aa83dae1a047e9c9 && " + "manifest=\"$root/manifest.json\" && " + "test -d \"$root\" && test ! -L \"$root\" && " + "test \"$(stat -c '%u:%g:%a' \"$root\")\" = '0:0:755' && " + "test -f \"$manifest\" && test ! -L \"$manifest\" && " + "test \"$(stat -c '%u:%g:%a' \"$manifest\")\" = '0:0:444' && " + "test \"$(sha256sum \"$manifest\" | cut -d' ' -f1)\" = " + "f06df6f3163e5ae7bf999624b011a3ae0bff096c9d0c8cb4c43f8b40be43ae2b", ), AssetPostcondition( - "host_188_openclaw_callback_dropin_enabled", + "host_188_openclaw_callback_overlay_enabled", "configuration", "host_188", - "test -r /etc/systemd/system/clawbot.service.d/" + "set -euo pipefail; " + "drop=/etc/systemd/system/clawbot.service.d/" "20-awoooi-callback-forwarder.conf && " - "grep -Fqx 'Environment=TELEGRAM_CALLBACK_FORWARD_ENABLED=true' " - "/etc/systemd/system/clawbot.service.d/" - "20-awoooi-callback-forwarder.conf", + "override=/etc/awoooi/openclaw-callback-forwarder/" + "docker-compose.callback.yml && " + "COMPOSE_FILE_key=COMPOSE_FILE && " + "test \"$COMPOSE_FILE_key\" = 'COMPOSE_FILE' && " + "wd=$(systemctl show clawbot.service --property=WorkingDirectory " + "--value) && " + "test -f \"$drop\" && test ! -L \"$drop\" && " + "test \"$(stat -c '%u:%g:%a' \"$drop\")\" = '0:0:644' && " + "drop_sha=$(sha256sum \"$drop\" | cut -d' ' -f1) && " + "case \"$wd:$drop_sha\" in " + "'/home/ollama/clawbot-v5:" + "419fb4f249f7e1370fbca87086fd725e57b1f5cf54da2f84caf9a21fea7111e3'" + "|'/opt/openclaw:" + "4a78d414e4b591f54232fab04529861a6042718a7627c4e17ccd5cc0cbd485f9') " + "true ;; *) false ;; esac && " + "test -f \"$override\" && test ! -L \"$override\" && " + "test \"$(stat -c '%u:%g:%a' \"$override\")\" = '0:0:444' && " + "test \"$(sha256sum \"$override\" | cut -d' ' -f1)\" = " + "f6fa7f6724c35f86f1c8dafac07e2680231dfa29a5d45534ed0515c58ac2de77", + ), + AssetPostcondition( + "host_188_openclaw_callback_payload_runtime", + "configuration", + "host_188", + "set -euo pipefail; " + "root=/var/lib/awoooi-controlled-apply/openclaw-callback-forwarder/" + "payloads/ffc45311b6403b30cb6ae6e1aa83dae1a047e9c9 && " + "telegram=\"$root/app/bot/telegram.py\" && " + "config=\"$root/app/core/config.py\" && " + "forwarder=\"$root/app/services/telegram_callback_forwarder.py\" && " + "test -f \"$telegram\" && test ! -L \"$telegram\" && " + "test \"$(stat -c '%u:%g:%a' \"$telegram\")\" = '0:0:444' && " + "test \"$(sha256sum \"$telegram\" | cut -d' ' -f1)\" = " + "af9600f32305ae73184651a8d3437063d7c8d35e3c3eb0910a2e27ac874c9302 && " + "test -f \"$config\" && test ! -L \"$config\" && " + "test \"$(stat -c '%u:%g:%a' \"$config\")\" = '0:0:444' && " + "test \"$(sha256sum \"$config\" | cut -d' ' -f1)\" = " + "e1b8b633f7b4780d3078d6c42a55e49d200255d3597a34e0cd2fd649b84eaea1 && " + "test -f \"$forwarder\" && test ! -L \"$forwarder\" && " + "test \"$(stat -c '%u:%g:%a' \"$forwarder\")\" = '0:0:444' && " + "test \"$(sha256sum \"$forwarder\" | cut -d' ' -f1)\" = " + "0d7693e34f63db4491d974f9f76a3a1c793d32cda193cb55ed3d8c86297b4fe3 && " + "ids=$(docker ps -q " + "--filter label=com.docker.compose.project=clawbot " + "--filter label=com.docker.compose.service=clawbot) && " + "test \"$(printf '%s\\n' \"$ids\" | sed '/^$/d' | wc -l | " + "tr -d ' ')\" = 1 && " + "id=\"$ids\" && " + "test \"$(docker inspect --format='{{.Config.WorkingDir}}' \"$id\")\" " + "= '/app' && " + "test \"$(docker exec \"$ids\" sha256sum " + "/app/app/bot/telegram.py | cut -d' ' -f1)\" = " + "af9600f32305ae73184651a8d3437063d7c8d35e3c3eb0910a2e27ac874c9302 && " + "test \"$(docker exec \"$ids\" sha256sum " + "/app/app/core/config.py | cut -d' ' -f1)\" = " + "e1b8b633f7b4780d3078d6c42a55e49d200255d3597a34e0cd2fd649b84eaea1 && " + "test \"$(docker exec \"$ids\" sha256sum " + "/app/app/services/telegram_callback_forwarder.py | cut -d' ' -f1)\" = " + "0d7693e34f63db4491d974f9f76a3a1c793d32cda193cb55ed3d8c86297b4fe3 && " + "test \"$(docker inspect --format='{{range .Mounts}}{{if eq " + ".Destination \"/app/app/bot/telegram.py\"}}{{.Source}}|" + "{{.Destination}}|{{.Type}}|{{.RW}}{{end}}{{end}}' \"$id\")\" = " + "\"$telegram|/app/app/bot/telegram.py|bind|false\" && " + "test \"$(docker inspect --format='{{range .Mounts}}{{if eq " + ".Destination \"/app/app/core/config.py\"}}{{.Source}}|" + "{{.Destination}}|{{.Type}}|{{.RW}}{{end}}{{end}}' \"$id\")\" = " + "\"$config|/app/app/core/config.py|bind|false\" && " + "test \"$(docker inspect --format='{{range .Mounts}}{{if eq " + ".Destination \"/app/app/services/telegram_callback_forwarder.py\"}}" + "{{.Source}}|{{.Destination}}|{{.Type}}|{{.RW}}{{end}}{{end}}' " + "\"$id\")\" = \"$forwarder|/app/app/services/" + "telegram_callback_forwarder.py|bind|false\"", + ), + AssetPostcondition( + "host_188_openclaw_callback_runtime_setting", + "configuration", + "host_188", + "set -euo pipefail; " + "ids=$(docker ps -q " + "--filter label=com.docker.compose.project=clawbot " + "--filter label=com.docker.compose.service=clawbot) && " + "test \"$(printf '%s\\n' \"$ids\" | sed '/^$/d' | wc -l | " + "tr -d ' ')\" = 1 && " + "docker exec \"$ids\" python -c \"import os; " + "from app.services.telegram_callback_forwarder import " + "TelegramCallbackForwarder; " + "TELEGRAM_CALLBACK_FORWARD_ENABLED = " + "os.environ.get('TELEGRAM_CALLBACK_FORWARD_ENABLED') == 'true'; " + "assert TELEGRAM_CALLBACK_FORWARD_ENABLED is True; " + "assert os.environ.get('TELEGRAM_CALLBACK_FORWARD_URL') == " + "'https://awoooi.wooo.work/api/v1/telegram/callback-forward'; " + "assert os.environ.get('TELEGRAM_CALLBACK_FORWARD_ALLOWED_HOSTS') == " + "'awoooi.wooo.work'; " + "assert os.environ.get('TELEGRAM_CALLBACK_FORWARD_TIMEOUT_SECONDS') == " + "'3.0'; " + "assert os.environ.get('TELEGRAM_CALLBACK_FORWARD_MAX_BODY_BYTES') == " + "'16384'\"", ), AssetPostcondition( "host_188_clawbot_systemd_runtime_active", "service", "host_188", - "systemctl is-active --quiet clawbot.service", + "set -euo pipefail; systemctl is-active --quiet clawbot.service", ), AssetPostcondition( "host_188_openclaw_local_health", "service", "host_188", + "set -euo pipefail; " "test \"$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 " "http://127.0.0.1:8088/health)\" = 200", ), @@ -282,6 +382,7 @@ _POSTCONDITION_REGISTRY: dict[str, tuple[AssetPostcondition, ...]] = { "host_188_awoooi_callback_receiver_public_health", "network", "host_188", + "set -euo pipefail; " "test \"$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 " "https://awoooi.wooo.work/api/v1/telegram/health)\" = 200", ), @@ -621,6 +722,9 @@ async def run_ansible_asset_post_verifier( probe_runner: ProbeRunner = _run_read_only_probe, ) -> dict[str, Any]: conditions = postconditions_for_catalog(catalog_id) + run_scoped_closure_blocker = _RUN_SCOPED_CLOSURE_BLOCKER_BY_CATALOG.get( + catalog_id + ) source_sha = os.getenv("AWOOOI_BUILD_COMMIT_SHA", "").strip().lower() base = { "schema_version": SCHEMA_VERSION, @@ -635,6 +739,10 @@ async def run_ansible_asset_post_verifier( "raw_output_stored": False, "command_exposed": False, "writes_on_verify": False, + "runtime_closure_receipt_required": bool(run_scoped_closure_blocker), + "runtime_closure_receipt_verified": ( + False if run_scoped_closure_blocker else None + ), } blockers: list[str] = [] if executor_returncode != 0: @@ -666,10 +774,13 @@ async def run_ansible_asset_post_verifier( ): blockers.append("post_verifier_known_hosts_missing") if blockers: + if run_scoped_closure_blocker: + blockers.append(run_scoped_closure_blocker) return { **base, "verification_result": "failed", "all_postconditions_passed": False, + "technical_postconditions_passed": False, "required_postcondition_count": len(conditions), "passed_postcondition_count": 0, "postconditions": [], @@ -725,11 +836,15 @@ async def run_ansible_asset_post_verifier( for receipt in receipts if receipt["passed"] is not True ) - verified = bool(conditions and passed_count == len(conditions) and not blockers) + technical_verified = bool(conditions and passed_count == len(conditions)) + if run_scoped_closure_blocker: + blockers.append(run_scoped_closure_blocker) + verified = bool(technical_verified and not blockers) return { **base, "verification_result": "success" if verified else "failed", "all_postconditions_passed": verified, + "technical_postconditions_passed": technical_verified, "required_postcondition_count": len(conditions), "passed_postcondition_count": passed_count, "postconditions": receipts, diff --git a/apps/api/src/services/chat_manager.py b/apps/api/src/services/chat_manager.py index 3650e5aac..c1458ebb2 100644 --- a/apps/api/src/services/chat_manager.py +++ b/apps/api/src/services/chat_manager.py @@ -1,68 +1,158 @@ -""" -AWOOOI Chat Manager - 雙 AI 對話核心 -====================================== -Phase 21.5 初版: 2026-03-31 ogt -Phase 22.6 重寫: 2026-04-03 ogt (老闆需求: 雙 AI 互動對話) -Phase 22.7 更新: 2026-04-03 ogt (老闆指示: OpenClaw→Gemini, NemoClaw→Ollama llama3.2:3b) -Phase 22.8 更新: 2026-04-09 ogt (老闆指示: NemoClaw→Ollama 111 deepseek-r1:14b,SRE 推理更強) -Phase 33 更新: 2026-05-05 ogt (ADR-110: OpenClaw chat 改走 GCP-A Ollama interactive lane) +"""AWOOOI SRE war-room conversation manager. -架構: -- OpenClaw (Ollama GCP-A interactive lane): SRE 首席顧問,精準分析 -- NemoClaw (Ollama interactive lane deepseek-r1:14b): 戰術參謀,深度推理 - -費用控管: -- OpenClaw/NemoClaw chat 預設免費 Ollama;Gemini 不再作為 ChatManager 預設路徑 -- 每次回覆顯示 token 用量 +Conversation generation uses the same controlled provider clients and router as +incident analysis. It never performs infrastructure remediation. The fixed +policy order is GCP-A Ollama -> GCP-B Ollama -> host111 Ollama -> Claude -> +Gemini; paid providers remain eligible only for an explicitly sanitized, +run-owned canary with a bounded cost contract. """ +from __future__ import annotations + import asyncio +import hashlib +import json import re +from dataclasses import dataclass, field +from decimal import Decimal, InvalidOperation +from html import escape +from typing import Any +from uuid import uuid4 -import httpx import structlog from src.core.config import get_settings from src.repositories.incident_repository import get_incident_repository from src.repositories.k8s_repository import get_k8s_repository -from src.services.ollama_endpoint_resolver import resolve_ollama_order +from src.services.ai_provider_policy import ( + PAID_PROVIDER_ORDER, + PRODUCTION_OLLAMA_ORDER, + PRODUCTION_PROVIDER_ORDER, +) +from src.services.ai_providers.interfaces import ( + AIResult, + is_provider_enabled_by_env, +) +from src.services.ai_router import get_ai_executor +from src.services.sanitization_service import sanitize from src.utils.timezone import now_taipei logger = structlog.get_logger(__name__) OPENCLAW_PERSONA = """你是 OpenClaw,AWOOOI 平台的 SRE AI 首席顧問。 -個性: 精準、果斷、專業,像老將一樣直接給出建議。 -語氣: 簡短有力,不廢話。繁體中文回應。不超過 300 字。 +個性: 精準、果斷、專業,直接給出有證據的判讀。 +語氣: 簡短有力,繁體中文,不超過 300 字。 稱呼用戶為「老闆」。 +你只能分析與討論;不得執行、宣稱執行或跨 domain 指派修復。 """ NEMOCLAW_PERSONA = """你是 NemoClaw,AWOOOI 平台的 AI 戰術參謀。 -個性: 分析型、從不同角度思考,會質疑假設。 -語氣: 帶點挑釁但建設性。不超過 200 字。 -稱呼用戶為「老闆」。評論 OpenClaw 的回應時,直接說「我補充」或「我有不同看法」。 -強制規則: -1. 全程使用繁體中文,禁止使用簡體中文、英文或其他語言。 -2. 禁止自稱 DeepSeek 或透露底層模型資訊。你的名字就是 NemoClaw。 -3. 專注於 SRE/DevOps/Kubernetes/可觀測性領域。 +個性: 分析型、質疑假設,補充不同角度。 +語氣: 建設性、繁體中文,不超過 200 字。 +稱呼用戶為「老闆」。評論 OpenClaw 時說「我補充」或「我有不同看法」。 +你只能分析與討論;不得執行、宣稱執行或跨 domain 指派修復。 """ +_CORRELATION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,191}$") +_RECEIPT_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,191}$") +_PROVIDER_STATUS_TERMS = ( + "狀態", + "可用", + "啟用", + "鎖定", + "硬鎖", + "provider status", + "route status", +) +_PROVIDER_TERMS = ( + "ollama", + "gcp-a", + "gcp-b", + "111", + "claude", + "gemini", + "provider", + "模型", + "路由", +) +_IPV4_OCTET = r"(?:25[0-5]|2[0-4]\d|1?\d?\d)" +_PRIVATE_IPV4 = re.compile( + rf"\b(?:10\.{_IPV4_OCTET}\.{_IPV4_OCTET}\.{_IPV4_OCTET}" + rf"|192\.168\.{_IPV4_OCTET}\.{_IPV4_OCTET}" + rf"|172\.(?:1[6-9]|2\d|3[01])\.{_IPV4_OCTET}\.{_IPV4_OCTET})\b" +) +_SANITIZER_PRIVATE_IP_MARKER = re.compile(r"\[PRIVATE_IP:[^\]\r\n]+\]") + + +def _cloud_chat_dlp(text: str) -> str: + """Remove RFC1918 values after the legacy sanitizer's visible marker.""" + + redacted = _SANITIZER_PRIVATE_IP_MARKER.sub("[PRIVATE_IP_REDACTED]", text) + return _PRIVATE_IPV4.sub("[PRIVATE_IP_REDACTED]", redacted) + + +@dataclass(frozen=True) +class ChatRouteResult: + """Content plus public-safe execution evidence for one chat generation.""" + + text: str + success: bool + provider: str + model: str + fallback_reason: str + policy_order: tuple[str, ...] = PRODUCTION_PROVIDER_ORDER + admitted_provider_order: tuple[str, ...] = PRODUCTION_OLLAMA_ORDER + cost_receipt_id: str | None = None + tokens: int = 0 + cost_usd: float = 0.0 + runtime_readback: dict[str, Any] = field(default_factory=dict) + infrastructure_remediation_write_allowed: bool = False + cross_domain_execution_allowed: bool = False + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": "sre_chat_provider_route_receipt_v1", + "success": self.success, + "text": self.text, + "provider": self.provider, + "model": self.model, + "fallback_reason": self.fallback_reason, + "policy_order": list(self.policy_order), + "admitted_provider_order": list(self.admitted_provider_order), + "cost_receipt_id": self.cost_receipt_id, + "tokens": self.tokens, + "cost_usd": round(self.cost_usd, 8), + "runtime_readback": dict(self.runtime_readback), + "infrastructure_remediation_write_allowed": False, + "cross_domain_execution_allowed": False, + } + + def render_html(self) -> str: + body = escape(self.text or "AI 路由目前無可驗證回應。", quote=False) + receipt = self.cost_receipt_id or "none" + evidence = ( + f"provider={self.provider} | model={self.model} | " + f"fallback={self.fallback_reason} | cost_receipt={receipt}" + ) + return f"{body}\n\n🤖 {escape(evidence, quote=False)}" + class ChatManager: - """AWOOOI 雙 AI 對話管理器""" - - def __init__(self): - pass # 2026-04-03 ogt: 移除 repo 實例化,leWOOOgo 規範禁止 Service 持有 repository + """AWOOOI SRE chat manager with a fail-closed provider boundary.""" async def get_system_context(self) -> str: - """收集系統即時上下文""" + """Collect a bounded live context for advisory conversation only.""" + now = now_taipei() k8s = get_k8s_repository() incidents = get_incident_repository() try: k8s_status = await k8s.get_pod_status_summary(namespace="awoooi-prod") - cluster_info = f"Cluster: {k8s_status['running']}/{k8s_status['total']} Pods Running" - if k8s_status.get('problem_pods'): + cluster_info = ( + f"Cluster: {k8s_status['running']}/{k8s_status['total']} Pods Running" + ) + if k8s_status.get("problem_pods"): cluster_info += f", {len(k8s_status['problem_pods'])} 異常" except Exception: cluster_info = "Cluster: 無法取得狀態" @@ -70,8 +160,13 @@ class ChatManager: try: active_incidents = await incidents.get_active() if active_incidents: - lines = [f"- {inc.incident_id}: {inc.status.value} (SEV {inc.severity.value})" - for inc in active_incidents[:3]] + lines = [ + ( + f"- {incident.incident_id}: {incident.status.value} " + f"(SEV {incident.severity.value})" + ) + for incident in active_incidents[:3] + ] incident_summary = "\n".join(lines) else: incident_summary = "無活躍告警" @@ -84,131 +179,651 @@ class ChatManager: f"- 活躍告警: {incident_summary}\n" ) - async def _call_openclaw(self, system_prompt: str, user_message: str) -> str | None: - """ - 呼叫 OpenClaw 對話 — Ollama interactive lane + @staticmethod + def _is_provider_status_question(message: str) -> bool: + normalized = message.casefold() + return any(term in normalized for term in _PROVIDER_TERMS) and any( + term in normalized for term in _PROVIDER_STATUS_TERMS + ) + + @staticmethod + def _safe_correlation(value: Any, fallback: str) -> str: + candidate = str(value or "").strip() + return candidate if _CORRELATION_ID.fullmatch(candidate) else fallback + + @staticmethod + def _paid_canary_context_is_bounded(route_context: dict[str, Any]) -> bool: + if route_context.get("synthetic_validation") is not True: + return False + if route_context.get("data_classification") != "sanitized": + return False + if route_context.get("infrastructure_remediation_write_allowed") is not False: + return False + if not all( + _CORRELATION_ID.fullmatch(str(route_context.get(field) or "")) + for field in ("trace_id", "run_id", "work_item_id") + ): + return False + try: + cost_cap = Decimal(str(route_context["paid_canary_max_cost_usd"])) + output_cap = int(route_context["paid_canary_max_output_tokens"]) + except (InvalidOperation, KeyError, TypeError, ValueError): + return False + return bool( + cost_cap.is_finite() + and Decimal("0") < cost_cap <= Decimal("0.25") + and 1 <= output_cap <= 512 + ) + + async def _paid_provider_candidates( + self, + route_context: dict[str, Any] | None, + ) -> tuple[list[str], dict[str, Any]]: + """Return paid lanes admitted before their atomic reservation step.""" + + payload = dict(route_context or {}) + readback: dict[str, Any] = { + "schema_version": "sre_chat_paid_canary_admission_v1", + "status": "not_requested", + "run_owned_lease_verified": False, + "cost_policy_preflight_verified": False, + "candidates": [], + } + if not self._paid_canary_context_is_bounded(payload): + readback["status"] = "bounded_canary_context_missing" + return [], readback + + run_id = str(payload["run_id"]) + try: + from src.services.ai_control import ( + get_paid_provider_canary_control_readback, + is_provider_disabled, + ) + + control = await get_paid_provider_canary_control_readback( + expected_owner=run_id, + ) + if not control.get("leased_enabled"): + readback["status"] = "run_owned_canary_not_admitted" + return [], readback + readback["run_owned_lease_verified"] = True + + from src.services.ai_rate_limiter import get_ai_rate_limiter + + limiter = get_ai_rate_limiter() + settings = get_settings() + admitted: list[str] = [] + provider_states: dict[str, str] = {} + for provider in PAID_PROVIDER_ORDER: + prefix = provider.upper() + configured = bool(getattr(settings, f"{prefix}_API_KEY", "")) + mode = ( + str(getattr(settings, f"{prefix}_EXECUTION_MODE", "shadow")) + .strip() + .lower() + ) + percent = max( + 0, + min(100, int(getattr(settings, f"{prefix}_CANARY_PERCENT", 0))), + ) + bucket = int(hashlib.sha256(run_id.encode()).hexdigest()[:8], 16) % 100 + if not ( + configured + and is_provider_enabled_by_env(provider) + and mode == "canary" + and percent > 0 + and bucket < percent + ): + provider_states[provider] = "configured_canary_not_selected" + continue + if await is_provider_disabled(provider, run_id=run_id): + provider_states[provider] = "run_owned_canary_not_admitted" + continue + usage = await limiter.get_usage_stats(provider) + pricing = usage.get("pricing_policy") or {} + if ( + pricing.get("supported") is not True + or usage.get("cost_exceeded") is True + ): + provider_states[provider] = "cost_policy_not_ready" + continue + admitted.append(provider) + provider_states[provider] = "admitted_atomic_reservation_pending" + + readback.update( + status=("admitted" if admitted else "no_paid_candidate_admitted"), + cost_policy_preflight_verified=bool(admitted), + candidates=list(admitted), + provider_states=provider_states, + ) + return admitted, readback + except Exception as error: + logger.warning( + "sre_chat_paid_canary_admission_unavailable", + error_type=type(error).__name__, + ) + readback["status"] = "paid_admission_readback_unavailable" + return [], readback + + async def get_provider_runtime_readback( + self, + route_context: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Read current provider policy/health without guessing a lock state.""" + + health = {provider: "not_checked" for provider in PRODUCTION_OLLAMA_ORDER} + selected_primary = "ollama_gcp_a" + observed_fallback: str | None = None + health_status = "unavailable" + try: + from src.services.ollama_failover_manager import ( + get_ollama_failover_manager, + ) + + route = await get_ollama_failover_manager().select_provider( + task_type="chat_status", + emit_side_effects=False, + ) + selected_primary = route.primary.provider_name + observed_fallback = ( + route.observed_fallback.provider_name + if route.observed_fallback + else None + ) + health["ollama_gcp_a"] = route.health_gcp_a.status.value + if route.health_gcp_b is not None: + health["ollama_gcp_b"] = route.health_gcp_b.status.value + if route.health_local is not None: + health["ollama_local"] = route.health_local.status.value + health_status = "verified" + except Exception as error: + logger.warning( + "sre_chat_ollama_runtime_readback_unavailable", + error_type=type(error).__name__, + ) + + paid_candidates, paid_admission = await self._paid_provider_candidates( + route_context + ) + bounded_context = self._paid_canary_context_is_bounded( + dict(route_context or {}) + ) + paid_control: dict[str, Any] = { + "readback_status": "unavailable", + "route_lease_state": "readback_unavailable", + "lock_present": False, + "lock_ttl_seconds": -2, + "disabled_verified": False, + } + try: + from src.services.ai_control import ( + get_paid_provider_canary_control_readback, + ) + + expected_owner = ( + str((route_context or {}).get("run_id") or "") + if bounded_context + else None + ) + control = await get_paid_provider_canary_control_readback( + expected_owner=expected_owner, + ) + if paid_admission.get("run_owned_lease_verified") is True: + route_lease_state = "active_for_this_run" + elif control.get("legacy_persistent_state_detected") is True: + route_lease_state = "legacy_persistent_state_detected" + elif control.get("lock_present") is True: + route_lease_state = "active_for_another_run" + elif control.get("disabled_verified") is True: + route_lease_state = "inactive_disabled_after_canary" + else: + route_lease_state = "inconsistent_fail_closed" + paid_control = { + "readback_status": "verified", + "route_lease_state": route_lease_state, + "lock_present": bool(control.get("lock_present")), + "lock_ttl_seconds": int(control.get("lock_ttl_seconds") or -1), + "claude_disabled": bool(control.get("claude_disabled", True)), + "gemini_disabled": bool(control.get("gemini_disabled", True)), + "disabled_verified": bool(control.get("disabled_verified")), + "persistent_enabled": bool(control.get("persistent_enabled")), + "legacy_persistent_state_detected": bool( + control.get("legacy_persistent_state_detected") + ), + } + except Exception as error: + logger.warning( + "sre_chat_paid_control_readback_unavailable", + error_type=type(error).__name__, + ) + + authentication_readbacks: dict[str, dict[str, Any]] = {} + try: + from src.services.ai_rate_limiter import get_ai_rate_limiter + + limiter = get_ai_rate_limiter() + raw_authentication = await asyncio.gather( + *( + limiter.get_provider_authentication_status(provider) + for provider in PAID_PROVIDER_ORDER + ), + return_exceptions=True, + ) + for provider, authentication in zip( + PAID_PROVIDER_ORDER, + raw_authentication, + strict=True, + ): + if isinstance(authentication, dict): + authentication_readbacks[provider] = authentication + else: + authentication_readbacks[provider] = { + "authenticated": None, + "authentication_verified": False, + "verification_status": "authentication_readback_unavailable", + } + except Exception as error: + logger.warning( + "sre_chat_paid_authentication_readback_unavailable", + error_type=type(error).__name__, + ) - 2026-04-10 Claude Code: 強制合併 OPENCLAW_PERSONA,確保字數限制與格式規範 - 2026-05-05 Codex: 改走 ADR-110 GCP-A/GCP-B/111 Ollama topology,避免個人聊天直打 Gemini - """ - # 強制在 system_prompt 前置 persona,確保 LLM 遵守字數與格式 - system_prompt = f"{OPENCLAW_PERSONA}\n{system_prompt}" settings = get_settings() + paid: dict[str, Any] = {} + provider_states = paid_admission.get("provider_states") or {} + for provider in PAID_PROVIDER_ORDER: + prefix = provider.upper() + configured = bool(getattr(settings, f"{prefix}_API_KEY", "")) + execution_mode = ( + str(getattr(settings, f"{prefix}_EXECUTION_MODE", "shadow")) + .strip() + .lower() + ) + canary_percent = int(getattr(settings, f"{prefix}_CANARY_PERCENT", 0)) + authentication = authentication_readbacks.get(provider) or { + "authenticated": None, + "authentication_verified": False, + "verification_status": "authentication_readback_unavailable", + } + if provider in paid_candidates: + runtime_state = "run_owned_canary_admitted" + elif not configured: + runtime_state = "not_configured" + elif not is_provider_enabled_by_env(provider): + runtime_state = "disabled_by_environment" + elif execution_mode != "canary" or canary_percent <= 0: + runtime_state = "configured_non_canary" + elif authentication.get("authenticated") is False: + runtime_state = "configured_canary_authentication_rejected" + elif authentication.get("authentication_verified") is not True: + runtime_state = "configured_canary_authentication_not_verified" + elif paid_control["route_lease_state"] == "inactive_disabled_after_canary": + runtime_state = ( + "configured_authenticated_canary_waiting_for_run_owned_lease" + ) + else: + runtime_state = str( + provider_states.get(provider) + or paid_admission.get("status") + or "runtime_readback_unavailable" + ) + paid[provider] = { + "configured": configured, + "environment_enabled": is_provider_enabled_by_env(provider), + "execution_mode": execution_mode, + "canary_percent": canary_percent, + "authenticated": authentication.get("authenticated"), + "authentication_verified": bool( + authentication.get("authentication_verified") is True + ), + "verification_status": str( + authentication.get("verification_status") + or "authentication_readback_unavailable" + ), + "route_lease_state": paid_control["route_lease_state"], + "runtime_state": runtime_state, + "run_owned_admitted": provider in paid_candidates, + } - model = settings.OPENCLAW_DEFAULT_MODEL - async with httpx.AsyncClient(timeout=40.0) as client: - for endpoint in resolve_ollama_order("interactive"): - if not endpoint.url: - continue - try: - resp = await client.post( - f"{endpoint.url}/api/chat", - json={ - "model": model, - "stream": False, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_message}, - ], - "options": {"num_predict": 900, "temperature": 0.2}, - }, - ) - resp.raise_for_status() - data = resp.json() - raw = data.get("message", {}).get("content", "").strip() - text = re.sub(r".*?", "", raw, flags=re.DOTALL).strip() or raw + return { + "schema_version": "sre_chat_provider_runtime_readback_v1", + "policy_order": list(PRODUCTION_PROVIDER_ORDER), + "selected_primary": selected_primary, + "observed_fallback": observed_fallback, + "health_readback_status": health_status, + "ollama_health": health, + "paid": paid, + "paid_admission": paid_admission, + "paid_control": paid_control, + "provider_route_switch_performed": False, + "infrastructure_remediation_write_allowed": False, + "cross_domain_execution_allowed": False, + } - eval_count = data.get("eval_count", 0) - prompt_eval_count = data.get("prompt_eval_count", 0) - total_tokens = eval_count + prompt_eval_count + @staticmethod + def _format_provider_runtime_readback(readback: dict[str, Any]) -> str: + health = readback.get("ollama_health") or {} + paid = readback.get("paid") or {} - logger.info( - "openclaw_ollama_chat_usage", - model=model, - endpoint=endpoint.url, - provider=endpoint.provider_name, - prompt_tokens=prompt_eval_count, - output_tokens=eval_count, - ) + def paid_line(label: str, provider: str) -> str: + state = paid.get(provider) or {} + authenticated = state.get("authenticated") + authenticated_text = ( + "true" + if authenticated is True + else "false" + if authenticated is False + else "unknown" + ) + return ( + f"{label}:configured={str(bool(state.get('configured'))).lower()}|" + f"authenticated={authenticated_text}|" + f"auth_verified={str(bool(state.get('authentication_verified'))).lower()}|" + f"verification={state.get('verification_status', 'authentication_readback_unavailable')}|" + f"canary={state.get('execution_mode', 'unknown')} " + f"{int(state.get('canary_percent') or 0)}%|" + f"route_lease={state.get('route_lease_state', 'readback_unavailable')}" + ) - return f"{text}\n\n🦙 {model} | {total_tokens} tokens | 免費" - except Exception as e: - logger.warning( - "openclaw_chat_endpoint_failed", - provider=endpoint.provider_name, - endpoint=endpoint.url, - error=str(e), - ) - logger.warning("openclaw_chat_failed_all_endpoints", model=model) - return None + return "\n".join( + [ + "AI 路由即時讀回", + "順序:GCP-A → GCP-B → 111 → Claude → Gemini", + ( + "Ollama:" + f"GCP-A={health.get('ollama_gcp_a', 'not_checked')};" + f"GCP-B={health.get('ollama_gcp_b', 'not_checked')};" + f"111={health.get('ollama_local', 'not_checked')}" + ), + paid_line("Claude", "claude"), + paid_line("Gemini", "gemini"), + "此讀回未切換 provider,也未執行任何主機或服務修復。", + ] + ) - async def _call_nemotron(self, system_prompt: str, user_message: str) -> str | None: - """ - 呼叫 NemoClaw 對話 — Ollama 111 deepseek-r1:14b + @staticmethod + def _result_provider(provider: str) -> str: + # The registry's GCP-A compatibility alias returns ``ollama``. Within + # this fixed executor order that identity can only be the first GCP-A hop. + return "ollama_gcp_a" if provider == "ollama" else provider - 2026-04-09 ogt: 改接 192.168.0.111 Ollama deepseek-r1:14b,SRE 推理能力最強 - deepseek-r1 含 標籤,需過濾後才回傳 + @staticmethod + def _result_model(provider: str, requested_ollama_model: str) -> str: + settings = get_settings() + if provider in {"ollama_gcp_a", "ollama_gcp_b"}: + return str(settings.OLLAMA_HEALTH_CHECK_MODEL) + if provider == "ollama_local": + return requested_ollama_model + if provider == "claude": + return str(settings.CLAUDE_MODEL) + if provider == "gemini": + return str(settings.GEMINI_MODEL) + return "none" - 2026-04-10 Claude Code: 強制合併 NEMOCLAW_PERSONA,確保字數限制與格式規範 - """ - # 強制在 system_prompt 前置 persona - system_prompt = f"{NEMOCLAW_PERSONA}\n{system_prompt}" + @staticmethod + def _fallback_reason(result: AIResult, provider: str) -> str: + if not result.success: + status = str(result.audit_metadata.get("status") or "").strip() + if status and re.fullmatch(r"[a-z0-9_:-]{1,96}", status): + return f"terminal_{status}" + return "canonical_route_exhausted_or_policy_blocked" + if result.from_cache: + return "current_policy_cache_hit" + try: + position = PRODUCTION_PROVIDER_ORDER.index(provider) + 1 + except ValueError: + return "provider_identity_outside_policy" + if position == 1: + return "canonical_primary_succeeded" + return f"canonical_position_{position}_after_prior_hops_no_success" - # 2026-05-05 Codex: ADR-110 interactive lane,由 resolver 管理 GCP-A/GCP-B/111 拓撲 - MODEL = "deepseek-r1:14b" + @staticmethod + def _cost_receipt_id(result: AIResult, provider: str) -> str | None: + if provider not in PAID_PROVIDER_ORDER: + return None + candidate = str( + result.audit_metadata.get("generation_receipt_id") or "" + ).strip() + return candidate if _RECEIPT_ID.fullmatch(candidate) else None - async with httpx.AsyncClient(timeout=120.0) as client: - for endpoint in resolve_ollama_order("interactive"): - if not endpoint.url: - continue - try: - resp = await client.post( - f"{endpoint.url}/api/chat", - json={ - "model": MODEL, - "stream": False, - # Ollama 0.24 separates deepseek-r1 thinking from final text. - # Chat callers expect message.content to contain the answer. - "think": False, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_message}, - ], - "options": {"num_predict": 1200}, - }, - ) - resp.raise_for_status() - data = resp.json() - raw = data.get("message", {}).get("content", "").strip() + @staticmethod + def _plain_response(raw_response: str) -> str: + text = re.sub( + r".*?", + "", + str(raw_response or ""), + flags=re.DOTALL, + ).strip() + if not text: + return "" + try: + payload = json.loads(text) + except (TypeError, ValueError): + return text + if isinstance(payload, dict): + for field_name in ("response", "message", "description", "reasoning"): + value = payload.get(field_name) + if isinstance(value, str) and value.strip(): + return value.strip() + return text - # 過濾 deepseek-r1 的 ... 推理區塊 - text = re.sub(r".*?", "", raw, flags=re.DOTALL).strip() - if not text: - text = raw # 萬一全是 think block,直接回傳原文 + async def route_chat( + self, + *, + role: str, + system_prompt: str, + user_message: str, + route_context: dict[str, Any] | None = None, + ) -> ChatRouteResult: + """Generate one advisory response through the controlled provider route.""" - eval_count = data.get("eval_count", 0) - prompt_eval_count = data.get("prompt_eval_count", 0) - total_tokens = eval_count + prompt_eval_count + if self._is_provider_status_question(user_message): + readback = await self.get_provider_runtime_readback(route_context) + return ChatRouteResult( + text=self._format_provider_runtime_readback(readback), + success=True, + provider="runtime_readback", + model="deterministic_policy", + fallback_reason="provider_status_request_no_generation", + admitted_provider_order=PRODUCTION_OLLAMA_ORDER, + runtime_readback=readback, + ) - logger.info( - "nemotron_ollama_usage", - model=MODEL, - provider=endpoint.provider_name, - prompt_tokens=prompt_eval_count, - output_tokens=eval_count, - ) + persona = OPENCLAW_PERSONA if role == "openclaw" else NEMOCLAW_PERSONA + requested_ollama_model = ( + str(get_settings().OPENCLAW_DEFAULT_MODEL) + if role == "openclaw" + else "deepseek-r1:14b" + ) + prompt = _cloud_chat_dlp( + sanitize( + ( + f"{persona}\n\n{system_prompt}\n\n" + f"使用者問題:\n{user_message}\n\n" + "只能回覆分析;不得呼叫 executor、不得聲稱完成修復。" + ), + source_label="awoooi_sre_group_chat", + ) + ) + prompt_sha256 = hashlib.sha256(prompt.encode("utf-8")).hexdigest() + seed = uuid4().hex + requested_context = dict(route_context or {}) + trace_id = self._safe_correlation( + requested_context.get("trace_id"), + f"sre-chat-trace:{seed}", + ) + run_id = self._safe_correlation( + requested_context.get("run_id"), + f"sre-chat-run:{seed}", + ) + work_item_id = self._safe_correlation( + requested_context.get("work_item_id"), + "SRE-CHAT-ADVISORY", + ) + context: dict[str, Any] = { + "trace_id": trace_id, + "run_id": run_id, + "work_item_id": work_item_id, + "intent_hint": "chat", + "task_type": "chat", + "data_classification": "sanitized", + # Ordinary SRE conversation is real traffic, never synthetic. Its + # GCP boundary is the exact prompt-bound chat receipt below. + "sre_chat_sanitization_receipt": { + "schema_version": "sre_chat_sanitization_receipt_v1", + "status": "verified", + "sanitizer": "src.services.sanitization_service.sanitize", + "source_label": "awoooi_sre_group_chat", + "data_classification": "sanitized", + "prompt_sha256": prompt_sha256, + "trace_id": trace_id, + "run_id": run_id, + "work_item_id": work_item_id, + "raw_input_forwarded": False, + "raw_log_payload_forwarded": False, + "secret_value_exposed": False, + "private_network_value_exposed": False, + "infrastructure_remediation_write_allowed": False, + }, + "ollama_model": requested_ollama_model, + "enforce_ollama_first": True, + "cloud_transport_auto_preflight": True, + "infrastructure_remediation_write_allowed": False, + "executor_invocation_allowed": False, + "agent99_dispatch_allowed": False, + "cross_domain_execution_allowed": False, + "canonical_asset_identity": { + "canonical_id": "service:awoooi:sre-war-room-chat", + "resolution_status": "resolved", + "asset_domain": "conversation", + "circuit_state": "closed", + }, + } - return f"{text}\n\n🦙 {MODEL} | {total_tokens} tokens | 免費" - except Exception as e: - logger.warning( - "nemotron_chat_endpoint_failed", - model=MODEL, - provider=endpoint.provider_name, - endpoint=endpoint.url, - error=str(e), - ) - logger.warning("nemotron_chat_failed_all_endpoints", model=MODEL) - return None + bounded_canary = self._paid_canary_context_is_bounded(requested_context) + require_local = False + if bounded_canary: + context.update( + # Synthetic markers are reserved for the explicitly bounded + # paid-provider canary contract and bind its exact prompt. + synthetic_validation=True, + synthetic_prompt_sha256=prompt_sha256, + paid_canary_max_cost_usd=str( + requested_context["paid_canary_max_cost_usd"] + ), + paid_canary_max_output_tokens=int( + requested_context["paid_canary_max_output_tokens"] + ), + ) + else: + # Interactive chat must not wait indefinitely on one cold Ollama + # hop. Paid validation canaries retain their separate 300s client + # contract and therefore do not inherit this interactive timeout. + context["provider_timeout_seconds"] = 35 + context["sre_chat_sanitization_receipt"]["provider_timeout_seconds"] = 35 + + paid_candidates, admission = await self._paid_provider_candidates( + {**requested_context, **context} if bounded_canary else None + ) + admitted_order = [*PRODUCTION_OLLAMA_ORDER, *paid_candidates] + result = await get_ai_executor().execute( + prompt, + provider_order=admitted_order, + context=context, + cache_ttl=300, + require_local=require_local, + ) + provider = self._result_provider(str(result.provider or "none")) + receipt_id = self._cost_receipt_id(result, provider) + paid_cost_cap = float(context.get("paid_canary_max_cost_usd") or 0.0) + paid_receipt_invalid = bool( + result.success + and provider in PAID_PROVIDER_ORDER + and ( + not receipt_id + or float(result.cost_usd or 0.0) <= 0.0 + or float(result.cost_usd or 0.0) > paid_cost_cap + ) + ) + reported_admitted_order = admitted_order + if paid_receipt_invalid: + logger.error( + "sre_chat_paid_result_missing_cost_receipt", + provider=provider, + ) + return ChatRouteResult( + text="付費 Provider 回應缺少費用 receipt,已拒絕呈現。", + success=False, + provider=provider, + model=self._result_model(provider, requested_ollama_model), + fallback_reason="paid_generation_cost_receipt_invalid", + admitted_provider_order=tuple(reported_admitted_order), + runtime_readback={"paid_admission": admission}, + ) + + body = self._plain_response(result.raw_response) + if not result.success or not body: + readback = await self.get_provider_runtime_readback( + requested_context if bounded_canary else None + ) + body = ( + "目前沒有可驗證的模型回應;以下狀態來自 runtime readback," + "不是推測,也未執行修復。\n" + + self._format_provider_runtime_readback(readback) + ) + else: + readback = {"paid_admission": admission} + + logger.info( + "sre_chat_provider_route_completed", + provider=provider, + model=self._result_model(provider, requested_ollama_model), + success=bool(result.success and body), + fallback_reason=self._fallback_reason(result, provider), + cost_receipt_present=bool(receipt_id), + infrastructure_remediation_write_allowed=False, + cross_domain_execution_allowed=False, + ) + return ChatRouteResult( + text=body, + success=bool(result.success and body), + provider=provider, + model=self._result_model(provider, requested_ollama_model), + fallback_reason=self._fallback_reason(result, provider), + admitted_provider_order=tuple(reported_admitted_order), + cost_receipt_id=receipt_id, + tokens=max(0, int(result.tokens or 0)), + cost_usd=max(0.0, float(result.cost_usd or 0.0)), + runtime_readback=readback, + ) + + async def _call_openclaw( + self, + system_prompt: str, + user_message: str, + route_context: dict[str, Any] | None = None, + ) -> str | None: + result = await self.route_chat( + role="openclaw", + system_prompt=system_prompt, + user_message=user_message, + route_context=route_context, + ) + return result.render_html() + + async def _call_nemotron( + self, + system_prompt: str, + user_message: str, + route_context: dict[str, Any] | None = None, + ) -> str | None: + result = await self.route_chat( + role="nemoclaw", + system_prompt=system_prompt, + user_message=user_message, + route_context=route_context, + ) + return result.render_html() async def generate_response( self, @@ -216,59 +831,43 @@ class ChatManager: username: str, # noqa: ARG002 message_text: str, ) -> str: - """ - 根據訊息決定回應模式: + """Generate the requested single- or dual-persona response.""" - @openclaw → 只有 OpenClaw 回應 - @nemo → 只有 NemoClaw 回應 - 其他 → OpenClaw 先回,NemoClaw 異步補充 - """ context = await self.get_system_context() text = message_text.strip() - # 模式 1: 指定 OpenClaw if text.lower().startswith("@openclaw"): - msg = text[9:].strip() or text - result = await self._call_openclaw(f"{OPENCLAW_PERSONA}\n{context}", msg) + message = text[9:].strip() or text + result = await self._call_openclaw(context, message) return f"🦞 OpenClaw:\n{result or '🔴 OpenClaw 無響應'}" - # 模式 2: 指定 NemoClaw if text.lower().startswith("@nemo"): - msg = text[5:].strip() or text - result = await self._call_nemotron(f"{NEMOCLAW_PERSONA}\n{context}", msg) - return f"🤖 NemoClaw:\n{result or '🔴 NemoClaw 無響應 (NIM 超時)'}" + message = text[5:].strip() or text + result = await self._call_nemotron(context, message) + return f"🤖 NemoClaw:\n{result or '🔴 NemoClaw 無響應'}" - # 模式 3: 雙 AI — OpenClaw 先答,NemoClaw 並行 - openclaw_task = asyncio.create_task( - self._call_openclaw(f"{OPENCLAW_PERSONA}\n{context}", text) - ) + openclaw_task = asyncio.create_task(self._call_openclaw(context, text)) nemo_task = asyncio.create_task( self._call_nemotron( - f"{NEMOCLAW_PERSONA}\n{context}", + context, f"老闆問了: {text}\n\n請從 NemoClaw 角度補充或評論。", ) ) - - # OpenClaw 最多等 40s(含 context 取得時間),NemoClaw 最多等 60s - # 2026-04-03 ogt: 移除 asyncio.shield — shield 會在超時後讓 task 繼續跑但無人等待,造成 silent leak try: openclaw_raw = await asyncio.wait_for(openclaw_task, timeout=40.0) except TimeoutError: openclaw_raw = None - openclaw_block = f"🦞 OpenClaw:\n{openclaw_raw or '🔴 無響應'}" try: nemo_raw = await asyncio.wait_for(nemo_task, timeout=60.0) except TimeoutError: nemo_raw = None - if nemo_raw: return f"{openclaw_block}\n\n🤖 NemoClaw:\n{nemo_raw}" return openclaw_block -# Singleton _chat_manager: ChatManager | None = None diff --git a/apps/api/src/services/controlled_alert_target_router.py b/apps/api/src/services/controlled_alert_target_router.py index 30c65c518..101f5ee4a 100644 --- a/apps/api/src/services/controlled_alert_target_router.py +++ b/apps/api/src/services/controlled_alert_target_router.py @@ -52,6 +52,10 @@ _WINDOWS_VMWARE_MARKERS = ( "vmx", ) _AGENT99_CONTROL_PLANE_HEALTH_ALERTS = {"agent99serviceunhealthy"} +_TELEGRAM_CALLBACK_FORWARDER_ALERTS = { + "telegramcallbackingressunverified", + "telegramcallbackforwarderdrift", +} _DISK_ALERTS = { "hostoutofdiskspace", "hostdiskusagehigh", @@ -760,6 +764,7 @@ def resolve_typed_alert_target( exact_openclaw_compose_target = bool( domain == "docker_compose_container" and identity.get("canonical_id") == "service:openclaw:host188" + and compact_alert in _TELEGRAM_CALLBACK_FORWARDER_ALERTS and _text_token(target_resource) in {"openclaw", "clawbot", "clawbot.service"} ) diff --git a/apps/api/src/services/notification_matrix.py b/apps/api/src/services/notification_matrix.py index 6f0c5c98f..39380f7ae 100644 --- a/apps/api/src/services/notification_matrix.py +++ b/apps/api/src/services/notification_matrix.py @@ -85,12 +85,39 @@ _SHARED_MONITORING_BOT_ALIAS = "tsenyang_bot" _SHARED_MONITORING_BOT_OWNER = "sre_shared_runtime" _SHARED_SRE_SIGNAL_FAMILIES = frozenset( { + "ai_agent", + "backup_restore", + "infrastructure", + "observability", "shared_infrastructure", "security_incident", "disaster_recovery", "incident_lifecycle", } ) +_SHARED_SRE_CROSS_PRODUCT_ROUTES = frozenset( + { + ( + "stockplatform-operational-incident-lifecycle", + "stockplatform-v2", + "incident_lifecycle", + ), + } +) +_SHARED_SRE_DESTINATION_ROLE = "shared_sre_war_room" +_SHARED_SRE_DURABLE_RECEIPT_BACKEND = ( + "awoooi.alert_operation_log+telegram_outbound_receipts" +) +_ALERT_CATEGORY_ZH_TW_BY_SIGNAL_FAMILY = { + "ai_agent": "AI Agent 自動化", + "backup_restore": "備份與還原", + "infrastructure": "基礎設施", + "observability": "可觀測性", + "shared_infrastructure": "共享基礎設施", + "security_incident": "資安事件", + "disaster_recovery": "災難復原", + "incident_lifecycle": "事故生命週期", +} _TRUSTED_ALERT_ROUTE_SOURCES = frozenset( { "hmac_alert_webhook", @@ -360,18 +387,44 @@ def build_canonical_telegram_routing_runtime_readback( requested_destination=route["allowed_destination"], registry=registry, ) - resolved_routes.append( - { - **route, - "decision": decision["decision"], - "decision_reason": decision["reason"], - } - ) + if decision["decision"] == "allow": + readiness_reason = "durable_delivery_receipt_not_observed" + elif route["egress_status"] == "disabled": + readiness_reason = "policy_disabled_no_egress_expected" + elif route["egress_status"] == "not_implemented": + readiness_reason = "policy_not_implemented_no_egress_expected" + else: + readiness_reason = "policy_blocked_no_egress_expected" + resolved_routes.append({ + **route, + "decision": decision["decision"], + "decision_reason": decision["reason"], + "alert_category_zh_tw": decision["alert_category_zh_tw"], + "destination_role": decision["destination_role"], + # This policy-only readback cannot prove a provider-acknowledged, + # durable delivery row. It must never promote route readiness + # merely because an allowlist entry and receipt backend exist. + "durable_delivery_receipt_present": False, + "ready": False, + "readiness_reason": readiness_reason, + }) allowed_route_count = sum( route["decision"] == "allow" for route in resolved_routes ) blocked_route_count = len(resolved_routes) - allowed_route_count + allowed_routes = [ + route for route in resolved_routes if route["decision"] == "allow" + ] + blocked_routes_fail_closed = all( + route["ready"] is False + and route["durable_delivery_receipt_present"] is False + for route in resolved_routes + if route["decision"] != "allow" + ) + all_allowed_route_delivery_receipts_ready = bool(allowed_routes) and all( + route["ready"] is True for route in allowed_routes + ) unknown_probe = resolve_canonical_telegram_route( "unknown-product", "unknown-signal", @@ -396,7 +449,13 @@ def build_canonical_telegram_routing_runtime_readback( "raw_numeric_destination_count" ], "destination_identity_exposed": False, - "all_product_delivery_receipts_ready": blocked_route_count == 0, + "all_product_delivery_receipts_ready": ( + all_allowed_route_delivery_receipts_ready + ), + "all_allowed_route_delivery_receipts_ready": ( + all_allowed_route_delivery_receipts_ready + ), + "blocked_routes_fail_closed": blocked_routes_fail_closed, }, "rollups": { **registry["rollups"], @@ -466,6 +525,17 @@ def resolve_canonical_telegram_route( ) route = dict(candidates[0]) + route["alert_category_zh_tw"] = _ALERT_CATEGORY_ZH_TW_BY_SIGNAL_FAMILY.get( + str(route.get("signal_family") or ""), + "其他營運告警", + ) + route["destination_role"] = ( + _SHARED_SRE_DESTINATION_ROLE + if route.get("chat_alias") == _SHARED_SRE_CHAT_ALIAS + else "product_owned_route" + if route.get("scope") == "product" + else "blocked_or_unresolved" + ) route["decision"] = ( "allow" if product is not None @@ -495,6 +565,8 @@ def _blocked_decision( ) -> dict[str, Any]: return { "route_id": None, + "alert_category_zh_tw": "未分類告警", + "destination_role": "blocked_or_unresolved", "product_id": product_id, "signal_family": signal_family, "severity": [severity], @@ -863,16 +935,25 @@ def _validate_shared_sre_boundary( ) -> None: if route["chat_alias"] != _SHARED_SRE_CHAT_ALIAS: return + route_identity = ( + route_id, + route["product_id"], + route["signal_family"], + ) + product_scope_allowed = route["product_id"] == "awoooi" or ( + route_identity in _SHARED_SRE_CROSS_PRODUCT_ROUTES + ) invalid = ( - route["product_id"] != "awoooi" + not product_scope_allowed or route["scope"] != "shared" or route["signal_family"] not in _SHARED_SRE_SIGNAL_FAMILIES or not set(route["severity"]).issubset({"P0", "P1"}) or route["bot_alias"] != _SHARED_MONITORING_BOT_ALIAS + or route["receipt_backend"] != _SHARED_SRE_DURABLE_RECEIPT_BACKEND ) if invalid: raise ValueError( - f"{label}: {route_id} violates shared SRE P0/P1 infra/security/DR/lifecycle boundary" + f"{label}: {route_id} violates shared SRE P0/P1 operational allowlist boundary" ) diff --git a/apps/api/src/services/paid_provider_canary_validation.py b/apps/api/src/services/paid_provider_canary_validation.py index 49529aa86..c82222040 100644 --- a/apps/api/src/services/paid_provider_canary_validation.py +++ b/apps/api/src/services/paid_provider_canary_validation.py @@ -126,6 +126,15 @@ def select_claude_canary_run_id(run_ref: str, percent: int) -> tuple[str, int]: raise ValueError("claude_canary_bucket_selection_failed") +def _ollama_lane_timeout_seconds() -> float: + """Use the production diagnose budget for cold-started Ollama models.""" + + configured = float( + getattr(settings, "OLLAMA_DIAGNOSE_TIMEOUT_SECONDS", 300) + ) + return max(30.0, min(600.0, configured)) + + def _score_result( result: Any, *, @@ -588,10 +597,23 @@ async def _run_paid_provider_canary_validation_core( execution_claim_owner, ) + ollama_timeout_seconds = _ollama_lane_timeout_seconds() providers = [ - ("ollama_gcp_a", ollama_gcp_a_provider or OllamaProvider(), 120.0), - ("ollama_gcp_b", ollama_gcp_b_provider or OllamaGcpBProvider(), 120.0), - ("ollama_local", ollama_local_provider or OllamaLocalProvider(), 120.0), + ( + "ollama_gcp_a", + ollama_gcp_a_provider or OllamaProvider(), + ollama_timeout_seconds, + ), + ( + "ollama_gcp_b", + ollama_gcp_b_provider or OllamaGcpBProvider(), + ollama_timeout_seconds, + ), + ( + "ollama_local", + ollama_local_provider or OllamaLocalProvider(), + ollama_timeout_seconds, + ), ("claude", claude_provider or ClaudeProvider(), 45.0), ("gemini", gemini_provider or GeminiProvider(), 45.0), ] diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index e1bf3e88d..fa6a7c245 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -1536,6 +1536,48 @@ async def list_ai_alert_card_delivery_readback( '{{ai_automation_alert_card,event_type}}', '' ) <> 'backup_restore_escrow_signal' + AND m.send_status = 'sent' + AND COALESCE(m.provider_message_id::text, '') <> '' + AND COALESCE( + COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) #>> + '{{canonical_route_receipt,schema_version}}', + '' + ) = 'telegram_canonical_egress_receipt_v1' + AND COALESCE( + COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) #>> + '{{canonical_route_receipt,decision}}', + '' + ) = 'allowed' + AND COALESCE( + COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) #>> + '{{canonical_route_receipt,provider_send_performed}}', + '' + ) = 'true' + AND COALESCE( + COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) #>> + '{{canonical_route_receipt,durable_receipt_persisted}}', + '' + ) = 'true' + AND COALESCE( + COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) #>> + '{{canonical_route_receipt,route_id}}', + '' + ) <> '' + AND COALESCE( + COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) #>> + '{{canonical_route_receipt,receipt_backend}}', + '' + ) NOT IN ('', 'none') + AND COALESCE( + COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) #>> + '{{canonical_route_receipt,alert_category_zh_tw}}', + '' + ) <> '' + AND COALESCE( + COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) #>> + '{{canonical_route_receipt,destination_role}}', + '' + ) <> '' ) AS learning_writeback_ready_total, MAX(m.sent_at) AS latest_sent_at, MAX(m.queued_at) AS latest_queued_at @@ -1620,6 +1662,8 @@ async def list_ai_alert_card_delivery_readback( -> 'ai_automation_alert_card' AS alert_card, COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) -> 'source_refs' AS source_refs, + COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) + -> 'canonical_route_receipt' AS canonical_route_receipt, COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) -> 'agent99_dispatch_receipt' AS agent99_dispatch_receipt, ar.error_detail AS agent99_current_receipt, @@ -1927,6 +1971,48 @@ async def _load_ai_alert_card_delivery_readback_direct( '{{ai_automation_alert_card,event_type}}', '' ) <> 'backup_restore_escrow_signal' + AND m.send_status = 'sent' + AND COALESCE(m.provider_message_id::text, '') <> '' + AND COALESCE( + COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) #>> + '{{canonical_route_receipt,schema_version}}', + '' + ) = 'telegram_canonical_egress_receipt_v1' + AND COALESCE( + COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) #>> + '{{canonical_route_receipt,decision}}', + '' + ) = 'allowed' + AND COALESCE( + COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) #>> + '{{canonical_route_receipt,provider_send_performed}}', + '' + ) = 'true' + AND COALESCE( + COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) #>> + '{{canonical_route_receipt,durable_receipt_persisted}}', + '' + ) = 'true' + AND COALESCE( + COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) #>> + '{{canonical_route_receipt,route_id}}', + '' + ) <> '' + AND COALESCE( + COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) #>> + '{{canonical_route_receipt,receipt_backend}}', + '' + ) NOT IN ('', 'none') + AND COALESCE( + COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) #>> + '{{canonical_route_receipt,alert_category_zh_tw}}', + '' + ) <> '' + AND COALESCE( + COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) #>> + '{{canonical_route_receipt,destination_role}}', + '' + ) <> '' ) AS learning_writeback_ready_total, MAX(m.sent_at) AS latest_sent_at, MAX(m.queued_at) AS latest_queued_at @@ -2028,6 +2114,10 @@ async def _load_ai_alert_card_delivery_readback_direct( COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) -> 'source_refs' )::text AS source_refs_json, + ( + COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) + -> 'canonical_route_receipt' + )::text AS canonical_route_receipt_json, ( COALESCE(m.source_envelope::jsonb, '{{}}'::jsonb) -> 'agent99_dispatch_receipt' @@ -2137,6 +2227,9 @@ def _ai_alert_card_direct_row(row: Mapping[str, Any]) -> dict[str, Any]: payload = dict(row) payload["alert_card"] = _json_object(payload.pop("alert_card_json", None)) payload["source_refs"] = _json_object(payload.pop("source_refs_json", None)) + payload["canonical_route_receipt"] = _json_object( + payload.pop("canonical_route_receipt_json", None) + ) payload["agent99_dispatch_receipt"] = _json_object( payload.pop("agent99_dispatch_receipt_json", None) ) @@ -2558,6 +2651,11 @@ def _ai_alert_card_delivery_item(row: Mapping[str, Any]) -> dict[str, Any]: """Convert one AI alert-card outbound mirror row into delivery evidence.""" alert_card = _as_dict(row.get("alert_card")) source_refs = _as_dict(row.get("source_refs")) + canonical_route_receipt = _as_dict(row.get("canonical_route_receipt")) + durable_delivery_receipt = _ai_alert_card_durable_delivery_receipt( + row, + canonical_route_receipt=canonical_route_receipt, + ) frozen_agent99_dispatch_receipt = _as_dict( row.get("agent99_dispatch_receipt") ) @@ -2580,6 +2678,7 @@ def _ai_alert_card_delivery_item(row: Mapping[str, Any]) -> dict[str, Any]: message_id=row.get("message_id"), run_id=run_id, project_id=project_id, + durable_delivery_receipt=durable_delivery_receipt, ) backup_restore_automation: dict[str, Any] | None = None gates = ( @@ -2702,6 +2801,16 @@ def _ai_alert_card_delivery_item(row: Mapping[str, Any]) -> dict[str, Any]: "event_type": event_type, "lane": lane, "target": target, + "alert_category_zh_tw": durable_delivery_receipt[ + "alert_category_zh_tw" + ], + "route_id": durable_delivery_receipt["route_id"], + "destination_role": durable_delivery_receipt["destination_role"], + "durable_delivery_receipt_present": durable_delivery_receipt[ + "durable_delivery_receipt_present" + ], + "delivery_receipt_status": durable_delivery_receipt["status"], + "delivery_receipt": durable_delivery_receipt, "gates": gates, "declared_gates": declared_gates, "declared_runtime_write_gate_count": ( @@ -2754,6 +2863,72 @@ def _ai_alert_card_delivery_item(row: Mapping[str, Any]) -> dict[str, Any]: } +def _ai_alert_card_durable_delivery_receipt( + row: Mapping[str, Any], + *, + canonical_route_receipt: Mapping[str, Any], +) -> dict[str, Any]: + """Project only provider-acknowledged, DB-persisted Telegram delivery truth.""" + + alert_category_zh_tw = str( + canonical_route_receipt.get("alert_category_zh_tw") or "" + ).strip() + route_id = str(canonical_route_receipt.get("route_id") or "").strip() + destination_role = str( + canonical_route_receipt.get("destination_role") or "" + ).strip() + receipt_backend = str( + canonical_route_receipt.get("receipt_backend") or "" + ).strip() + checks = { + "outbound_row_persisted": bool(row.get("message_id")), + "send_status_sent": str(row.get("send_status") or "") == "sent", + "provider_message_id_present": bool( + str(row.get("provider_message_id") or "").strip() + ), + "canonical_receipt_schema_valid": ( + canonical_route_receipt.get("schema_version") + == "telegram_canonical_egress_receipt_v1" + ), + "canonical_route_allowed": ( + canonical_route_receipt.get("decision") == "allowed" + ), + "provider_send_performed": ( + canonical_route_receipt.get("provider_send_performed") is True + ), + "durable_receipt_persisted": ( + canonical_route_receipt.get("durable_receipt_persisted") is True + ), + "alert_category_present": bool(alert_category_zh_tw), + "route_id_present": bool(route_id), + "destination_role_present": bool(destination_role), + "durable_receipt_backend_declared": bool( + receipt_backend and receipt_backend != "none" + ), + } + missing = [name for name, passed in checks.items() if not passed] + durable = not missing + return { + "schema_version": "telegram_ai_alert_card_delivery_receipt_readback_v1", + "status": "ready" if durable else "durable_delivery_receipt_missing", + "durable_delivery_receipt_present": durable, + "alert_category_zh_tw": alert_category_zh_tw, + "route_id": route_id, + "destination_role": destination_role, + "receipt_backend": receipt_backend, + "destination_alias": str( + canonical_route_receipt.get("destination_alias") or "" + ).strip(), + "sender_bot_alias": str( + canonical_route_receipt.get("sender_bot_alias") or "" + ).strip(), + "provider_message_id": str(row.get("provider_message_id") or "").strip(), + "checks": checks, + "missing_required_evidence": missing, + "ready": durable, + } + + def _slug_ref(value: Any, fallback: str) -> str: slug = re.sub(r"[^a-z0-9_.:-]+", "-", str(value or "").strip().lower()) slug = slug.strip("-") @@ -2767,12 +2942,23 @@ def _ai_alert_card_learning_writeback_refs( message_id: Any, run_id: Any, project_id: str, + durable_delivery_receipt: Mapping[str, Any], ) -> dict[str, Any]: """Build deterministic KM / PlayBook / RAG / MCP refs for one alert receipt.""" event_type = str(alert_card.get("event_type") or "") lane = str(alert_card.get("lane") or "") target = str(alert_card.get("target") or "") delivery_required = bool(alert_card.get("delivery_receipt_readback_required")) + alert_category_zh_tw = str( + durable_delivery_receipt.get("alert_category_zh_tw") or "" + ).strip() + route_id = str(durable_delivery_receipt.get("route_id") or "").strip() + destination_role = str( + durable_delivery_receipt.get("destination_role") or "" + ).strip() + durable_delivery_present = bool( + durable_delivery_receipt.get("durable_delivery_receipt_present") is True + ) event_slug = _slug_ref(event_type, "unknown-event") lane_slug = _slug_ref(lane, "unknown-lane") target_slug = _slug_ref(target, "unknown-target") @@ -2784,6 +2970,10 @@ def _ai_alert_card_learning_writeback_refs( ("event_type", event_type), ("lane", lane), ("delivery_receipt_readback_required", delivery_required), + ("alert_category_zh_tw", alert_category_zh_tw), + ("route_id", route_id), + ("destination_role", destination_role), + ("durable_delivery_receipt", durable_delivery_present), ) if value in ("", False, None) ] @@ -2799,6 +2989,10 @@ def _ai_alert_card_learning_writeback_refs( "receipt_id": str(message_id or ""), "run_id": str(run_id or ""), "project_id": project_id, + "alert_category_zh_tw": alert_category_zh_tw, + "route_id": route_id, + "destination_role": destination_role, + "durable_delivery_receipt_present": durable_delivery_present, "km_entry_ref": f"km://{project_id}/telegram-ai-alert-card/{event_slug}", "playbook_candidate_ref": ( f"playbook://{project_id}/telegram-ai-alert-card/{lane_slug}/{target_slug}" @@ -2819,11 +3013,11 @@ def _ai_alert_card_learning_writeback_refs( "alert_ids": alert_ids if isinstance(alert_ids, list) else [], "missing_required_fields": missing, "production_write_performed": False, - "runtime_send_performed": False, + "runtime_send_performed": durable_delivery_present, "next_action": ( "none" if status == "ready" - else "ensure_ai_alert_cards_include_event_type_lane_and_delivery_receipt" + else "persist_provider_acknowledged_canonical_delivery_receipt_with_card_route_metadata" ), } diff --git a/apps/api/src/services/sre_k3s_controlled_automation_work_items.py b/apps/api/src/services/sre_k3s_controlled_automation_work_items.py index c58ec14c7..859696a1a 100644 --- a/apps/api/src/services/sre_k3s_controlled_automation_work_items.py +++ b/apps/api/src/services/sre_k3s_controlled_automation_work_items.py @@ -5,6 +5,7 @@ from __future__ import annotations import json from pathlib import Path from typing import Any +from uuid import UUID from src.services.ai_provider_policy import PRODUCTION_PROVIDER_ORDER from src.services.snapshot_paths import default_operations_dir @@ -136,6 +137,31 @@ def load_sre_k3s_controlled_automation_work_items( raise ValueError(f"{path}: paid calls require explicit cost controls") if provider.get("production_provider_route_switch_allowed") is not False: raise ValueError(f"{path}: production provider switch must remain gated") + durable_gate5 = _dict(provider, "durable_gate5_authorization", path) + if durable_gate5.get("status") == "consumed_terminal_failed": + run_id = str(durable_gate5.get("run_id") or "") + authorization_ref = str( + durable_gate5.get("authorization_ref") or "" + ) + try: + UUID(run_id) + except ValueError as exc: + raise ValueError(f"{path}: durable Gate5 run_id invalid") from exc + if run_id != authorization_ref: + raise ValueError(f"{path}: durable Gate5 identity mismatch") + if durable_gate5.get("approval_durable") is not True: + raise ValueError(f"{path}: durable Gate5 approval not acknowledged") + if durable_gate5.get("single_use_claim_consumed") is not True: + raise ValueError(f"{path}: durable Gate5 claim not consumed") + if durable_gate5.get("terminal_state") != "failed": + raise ValueError(f"{path}: partial canary must remain failed") + latest_canary = _dict(provider, "latest_canary_receipt", path) + if latest_canary.get("run_id") != run_id: + raise ValueError(f"{path}: canary receipt run mismatch") + if latest_canary.get("paired_rollback_verified") is not True: + raise ValueError(f"{path}: paid-provider rollback not verified") + if latest_canary.get("persistent_paid_routes_enabled") is not False: + raise ValueError(f"{path}: paid routes must remain disabled") bridge = _dict(payload, "agent99_host_operations_bridge", path) if bridge.get("arbitrary_command_allowed") is not False: diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index 8f2088cd3..cb4e62e3d 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -1117,6 +1117,10 @@ def _telegram_send_delivery_succeeded(result: object) -> bool: != "telegram_canonical_egress_receipt_v1" or route_receipt.get("decision") != "allowed" or route_receipt.get("provider_send_performed") is not True + or ( + route_receipt.get("durable_receipt_required") is True + and route_receipt.get("durable_receipt_persisted") is not True + ) ): return False @@ -5823,6 +5827,13 @@ class TelegramGateway: "schema_version": "telegram_canonical_egress_receipt_v1", "decision": "blocked_no_egress", "classifier": classifier, + "route_id": None, + "receipt_backend": "none", + "alert_category_zh_tw": "未分類告警", + "destination_role": "blocked_or_unresolved", + "durable_receipt_required": False, + "durable_receipt_persisted": False, + "durable_receipt_status": "not_applicable_no_egress", "product_id": str(context.get("product_id") or "unknown")[:80], "signal_family": str(context.get("signal_family") or "unknown")[:80], "severity": str(context.get("severity") or "unknown")[:16], @@ -5839,6 +5850,7 @@ class TelegramGateway: route_context: object, legacy_notification_type: object, actual_bot_alias: str, + method: str, ) -> tuple[str | None, dict[str, object]]: context: Mapping[str, object] | None = ( route_context if isinstance(route_context, Mapping) else None @@ -5900,6 +5912,23 @@ class TelegramGateway: "sender_bot_alias_not_authorized_for_route", route_context=normalized_context, ) + route_id = str(decision.get("route_id") or "").strip() + receipt_backend = str(decision.get("receipt_backend") or "").strip() + alert_category_zh_tw = str( + decision.get("alert_category_zh_tw") or "" + ).strip() + destination_role = str(decision.get("destination_role") or "").strip() + if ( + not route_id + or not receipt_backend + or receipt_backend == "none" + or not alert_category_zh_tw + or not destination_role + ): + return None, self._blocked_route_receipt( + "canonical_route_card_readback_metadata_missing", + route_context=normalized_context, + ) canonical_chat_id = str(settings.SRE_GROUP_CHAT_ID or "") if not canonical_chat_id: @@ -5918,12 +5947,23 @@ class TelegramGateway: "schema_version": "telegram_canonical_egress_receipt_v1", "decision": "allowed", "classifier": "canonical_shared_route_allowed", + "route_id": route_id, + "receipt_backend": receipt_backend, + "alert_category_zh_tw": alert_category_zh_tw, + "destination_role": destination_role, **normalized_context, "destination_alias": "awoooi_sre_war_room", "destination_binding": _telegram_destination_binding( canonical_chat_id ), "sender_bot_alias": actual_bot_alias, + "durable_receipt_required": method in {"sendMessage", "sendPhoto"}, + "durable_receipt_persisted": False, + "durable_receipt_status": ( + "pending_provider_and_mirror" + if method in {"sendMessage", "sendPhoto"} + else "not_required_for_interaction_method" + ), "provider_send_performed": False, } @@ -6014,6 +6054,13 @@ class TelegramGateway: target_chat_id ), "sender_bot_alias": actual_bot_alias, + "durable_receipt_required": False, + "durable_receipt_persisted": False, + "durable_receipt_status": ( + "best_effort_mirror_pending" + if method in {"sendMessage", "sendPhoto"} + else "not_required_for_interaction_method" + ), "inbound_context_verified": True, "provider_send_performed": False, } @@ -6035,6 +6082,7 @@ class TelegramGateway: }, legacy_notification_type=None, actual_bot_alias="tsenyang_bot", + method="sendMessage", ) return chat_id or "" @@ -6492,13 +6540,23 @@ class TelegramGateway: triggered_by_state = :triggered_by_state, source_envelope = jsonb_set( jsonb_set( - source_envelope, - '{callback_reply,status}', - to_jsonb('callback_reply_sent'::text), + jsonb_set( + jsonb_set( + source_envelope, + '{callback_reply,status}', + to_jsonb('callback_reply_sent'::text), + true + ), + '{notification_policy,disposition}', + to_jsonb('sent'::text), + true + ), + '{canonical_route_receipt,durable_receipt_persisted}', + 'true'::jsonb, true ), - '{notification_policy,disposition}', - to_jsonb('sent'::text), + '{canonical_route_receipt,durable_receipt_status}', + to_jsonb('persisted'::text), true ) WHERE project_id = :project_id @@ -6829,6 +6887,7 @@ class TelegramGateway: route_context=route_context, legacy_notification_type=legacy_notification_type, actual_bot_alias=actual_bot_alias, + method=method, ) ) elif interaction_context is not None: @@ -7067,19 +7126,24 @@ class TelegramGateway: provider_message_id = str(provider_message_id_value) if canonical_route_receipt is not None: canonical_route_receipt["provider_send_performed"] = True + if canonical_route_receipt.get("durable_receipt_required") is True: + canonical_route_receipt["durable_receipt_status"] = ( + "provider_sent_mirror_pending" + ) result["_awoooi_canonical_route_receipt"] = ( canonical_route_receipt ) if not destination_binding_verified: - if controlled_reservation is not None: - result[ - "_awooop_outbound_mirror_acknowledged" - ] = False + result["_awooop_outbound_mirror_acknowledged"] = False result["_awooop_delivery_status"] = ( "provider_destination_mismatch" if provider_destination_binding else "provider_destination_unverified" ) + if canonical_route_receipt is not None: + canonical_route_receipt["durable_receipt_status"] = ( + "provider_destination_unverified" + ) result["_awooop_provider_send_performed"] = True elif ( controlled_reservation is not None @@ -7096,31 +7160,124 @@ class TelegramGateway: ) ) result["_awooop_outbound_mirror_acknowledged"] = finalized + if canonical_route_receipt is not None: + canonical_route_receipt["durable_receipt_persisted"] = ( + finalized + ) + canonical_route_receipt["durable_receipt_status"] = ( + "persisted" + if finalized + else "provider_sent_durable_receipt_pending" + ) result["_awooop_delivery_status"] = ( - "sent" if finalized else "pending_unknown" + "sent" + if finalized + else "provider_sent_durable_receipt_pending" ) result["_awooop_provider_send_performed"] = True else: - result["_awooop_delivery_status"] = "sent" result["_awooop_provider_send_performed"] = True - source_envelope_extra = ( - _acknowledge_callback_reply_source_envelope( - source_envelope_extra, - delivery_result=result, - provider_message_id=provider_message_id, + durable_receipt_required = bool( + canonical_route_receipt is not None + and canonical_route_receipt.get( + "durable_receipt_required" ) + is True ) - await self._mirror_outbound_message( - method=method, - payload=payload, - provider_message_id=provider_message_id, - source_envelope_extra=source_envelope_extra, - ) + if durable_receipt_required: + prospective_receipt = dict(canonical_route_receipt) + prospective_receipt["durable_receipt_persisted"] = True + prospective_receipt["durable_receipt_status"] = "persisted" + prospective_result = dict(result) + prospective_result["_awooop_delivery_status"] = "sent" + prospective_result[ + "_awooop_provider_send_performed" + ] = True + prospective_result[ + "_awoooi_canonical_route_receipt" + ] = prospective_receipt + prospective_source_envelope_extra = ( + _acknowledge_callback_reply_source_envelope( + source_envelope_extra, + delivery_result=prospective_result, + provider_message_id=provider_message_id, + ) + ) + mirror_persisted = await self._mirror_outbound_message( + method=method, + payload=payload, + provider_message_id=provider_message_id, + source_envelope_extra=( + prospective_source_envelope_extra + ), + ) + canonical_route_receipt[ + "durable_receipt_persisted" + ] = mirror_persisted + canonical_route_receipt["durable_receipt_status"] = ( + "persisted" + if mirror_persisted + else "provider_sent_durable_receipt_pending" + ) + result[ + "_awooop_outbound_mirror_acknowledged" + ] = mirror_persisted + result["_awooop_delivery_status"] = ( + "sent" + if mirror_persisted + else "provider_sent_durable_receipt_pending" + ) + if mirror_persisted: + source_envelope_extra = ( + prospective_source_envelope_extra + ) + else: + result["_awooop_delivery_status"] = "sent" + result["_awooop_provider_send_performed"] = True + if method in {"sendMessage", "sendPhoto"}: + source_envelope_extra = ( + _acknowledge_callback_reply_source_envelope( + source_envelope_extra, + delivery_result=result, + provider_message_id=provider_message_id, + ) + ) + mirror_persisted = ( + await self._mirror_outbound_message( + method=method, + payload=payload, + provider_message_id=provider_message_id, + source_envelope_extra=( + source_envelope_extra + ), + ) + ) + result[ + "_awooop_outbound_mirror_acknowledged" + ] = mirror_persisted + if canonical_route_receipt is not None: + canonical_route_receipt[ + "durable_receipt_persisted" + ] = mirror_persisted + canonical_route_receipt[ + "durable_receipt_status" + ] = ( + "best_effort_persisted" + if mirror_persisted + else "best_effort_mirror_failed" + ) + else: + result[ + "_awooop_outbound_mirror_acknowledged" + ] = False else: - if controlled_reservation is not None: - result["_awooop_outbound_mirror_acknowledged"] = False + result["_awooop_outbound_mirror_acknowledged"] = False if canonical_route_receipt is not None: canonical_route_receipt["provider_send_performed"] = True + canonical_route_receipt["durable_receipt_persisted"] = False + canonical_route_receipt["durable_receipt_status"] = ( + "provider_message_id_missing" + ) result["_awooop_delivery_status"] = ( "provider_message_id_missing" ) @@ -7225,15 +7382,23 @@ class TelegramGateway: payload: dict, provider_message_id: str, source_envelope_extra: dict[str, object] | None = None, - ) -> None: - """將 legacy Telegram 出站訊息鏡像到 AwoooP,不改變實際發送行為。""" - if method != "sendMessage": - return + ) -> bool: + """Persist provider-acknowledged message/photo truth into AwoooP.""" + if method not in {"sendMessage", "sendPhoto"}: + return False chat_id = str(payload.get("chat_id") or "") text = str(payload.get("text") or payload.get("caption") or "") - if not chat_id or not text: - return + if not chat_id or not text or not provider_message_id: + return False + + mirror_extra = dict(source_envelope_extra or {}) + route_receipt = mirror_extra.get("canonical_route_receipt") + if isinstance(route_receipt, dict): + persisted_route_receipt = dict(route_receipt) + persisted_route_receipt["durable_receipt_persisted"] = True + persisted_route_receipt["durable_receipt_status"] = "persisted" + mirror_extra["canonical_route_receipt"] = persisted_route_receipt try: from src.core.context import get_current_project_id @@ -7243,7 +7408,7 @@ class TelegramGateway: project_id = get_current_project_id() or "awoooi" run_id = _legacy_outbound_run_id(chat_id, provider_message_id) async with get_db_context(project_id) as db: - await record_outbound_message( + message_id = await record_outbound_message( db, project_id=project_id, run_id=run_id, @@ -7252,26 +7417,39 @@ class TelegramGateway: message_type=_infer_outbound_message_type( text, payload, - source_envelope_extra, + mirror_extra, ), content=text, source_envelope=_merge_outbound_source_envelope_extra( _outbound_source_envelope(method, payload), - source_envelope_extra, + mirror_extra, ), provider_message_id=provider_message_id, send_status="sent", triggered_by_state="legacy_gateway", is_shadow=False, ) + if not message_id: + return False + logger.info( + "telegram_outbound_mirror_persisted", + method=method, + destination_binding=_telegram_destination_binding(chat_id), + provider_message_id=provider_message_id, + durable_receipt_persisted=True, + ) + return True except Exception as exc: logger.warning( "telegram_outbound_mirror_failed", method=method, - chat_id=chat_id, + destination_binding=_telegram_destination_binding(chat_id), provider_message_id=provider_message_id, - error=str(exc), + error_code="durable_mirror_persistence_failed", + error_type=type(exc).__name__, + durable_receipt_persisted=False, ) + return False async def _record_callback_reply_failure( self, @@ -13435,18 +13613,35 @@ class TelegramGateway: def _clean_ai_reply(text: str, max_chars: int = 600) -> str: """清理 AI 回覆:移除 Markdown 語法,截斷超長內容""" import re - # 移除 Markdown bold/italic (**text**, *text*, __text__, _text_) + + # 移除 Markdown bold/italic。單底線是 provider / receipt ID 的 + # 合法字元,不能用 ``_(.+?)_`` 清掉,否則結構化路由證據會失真。 text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) text = re.sub(r'\*(.+?)\*', r'\1', text) text = re.sub(r'__(.+?)__', r'\1', text) - text = re.sub(r'_(.+?)_', r'\1', text) # 移除 Markdown header (#, ##, ###) text = re.sub(r'^#{1,3}\s+', '', text, flags=re.MULTILINE) # 移除 標籤(deepseek-r1) text = re.sub(r'.*?', '', text, flags=re.DOTALL).strip() - # 截斷 + # 截斷時保留結構化 provider receipt,否則真實的長 runtime + # readback 會把 provider/model/fallback/cost_receipt 尾端截掉。 if len(text) > max_chars: - text = text[:max_chars].rsplit('\n', 1)[0] + '…' + footer_match = re.search( + r'(?P