Files
awoooi/apps/api/src/models/awooop_contracts.py
Your Name 8629ac709b
Some checks failed
run-migration / migrate (push) Failing after 59s
Code Review / ai-code-review (push) Successful in 1m8s
Type Sync Check / check-type-sync (push) Successful in 2m27s
feat(awooop): Phase 1-8 完整實作 — AwoooP Agent Platform 六平面架構
## Phase 1-3: Control Plane + Contract System
- awooop_phase1_control_plane_2026-05-04.sql: 12 張核心表 + RLS
- awooop_phase1_batch1_rls_2026-05-04.sql: 全部 FORCE RLS + GRANT
- packages/awooop-contracts/: 六合約 JSON Schema + golden fixtures
- src/models/awooop_contracts.py: Pydantic v2 contract models(extra=forbid)
- src/repositories/contract_repository.py: contract lifecycle(draft→published→active)
- src/services/contract_service.py: HMAC publish sig + Redis multi-sig activate
- src/services/schema_validator.py: LLM output validator(retry×3, E-SCHEMA-001)

## Phase 2: Tenant Isolation
- awooop_phase2_budget_ledger_2026-05-04.sql: budget_ledger + RLS
- src/services/budget_service.py: Token Budget Hard Kill 三層防線
- src/core/context.py: PROJECT_ID ContextVar(31 background loop 自動繼承)
- src/db/base.py + models.py: project_id 欄位 + RLS set_config 注入
- src/hermes/nl_gateway.py: project_id Redis key 前綴(Phase A 雙寫)
- src/services/anomaly_counter.py: per-project 改造(Phase A fallback)

## Phase 4: Platform Shell in Shadow Mode
- awooop_phase4_run_state_2026-05-04.sql: run_state + step_journal + idempotency
- src/services/run_state_machine.py: 8-state FSM + SKIP LOCKED + stale reaper
- src/services/platform_runtime.py: UUID v7 + W3C trace_id + shadow_execute
- src/services/audit_sink.py: PII/secret redaction 9 patterns
- src/api/v1/platform/runs.py: POST/GET /v1/platform/runs(Router→Service 架構)
- src/workers/platform_worker.py: SKIP LOCKED worker + heartbeat + reaper loop
- src/main.py: platform router + lifespan worker start/stop

## Phase 5: MCP Gateway 五閘門
- awooop_phase5_mcp_gateway_2026-05-04.sql: 4 表 + RLS
- src/plugins/mcp/gateway.py: McpGateway(Gate 1~5, E-MCP-GATE-001~009)
- src/plugins/mcp/redaction_middleware.py: 雙層 redaction + 16K 截斷
- src/plugins/mcp/registry.py: __provider name mangling(ADR-116)
- src/plugins/mcp/credential_resolver.py: k8s secret ref 解析
- tests/test_mcp_credential_isolation.py: 10 個迴歸測試(secret leak 防再現)

## Phase 6-8: EwoooC + Channel Hub + Approval Token
- awooop_phase6_ewoooc_onboarding_2026-05-04.sql: ewoooc tenant + 4 read-only MCP tools
- awooop_phase7_channel_hub_2026-05-04.sql: conversation_event + outbound_message
- src/services/provider_proxy.py: ProviderProxy + PlatformEnvelope(ADR-115)
- src/services/channel_hub.py: Telegram inbound mirror + Progressive Feedback(30s)
- src/services/awooop_approval_token.py: HS256 + jti NX replay 防護 + suggest mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 19:31:53 +08:00

438 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
AwoooP Contract Pydantic Models
================================
Phase 3: 六合約家族 Pydantic v2 驗證模型ADR-112
2026-05-04 ogt + Claude Sonnet 4.6
六合約家族:
1. ProjectTenantContract — 租戶/專案能力邊界
2. AgentContract — Agent 模型、工具、治理
3. MCPGatewayContract — MCP 工具閘道
4. PolicyRoutingContract — LLM 路由規則
5. RuntimeRunStateContract — Run FSM 狀態
6. ChannelEventContract — Channel 事件(冪等)
所有含 artifact ref 的欄位都附 sha256ADR-112 artifact integrity
"""
from __future__ import annotations
import re
from datetime import datetime
from enum import Enum
from typing import Any
from uuid import UUID
from pydantic import BaseModel, Field, field_validator, model_validator
# ─────────────────────────────────────────────────────────────────────────────
# 共用型別
# ─────────────────────────────────────────────────────────────────────────────
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
_PROJECT_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{1,63}$")
_AGENT_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{1,127}$")
_UUID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
)
def _validate_sha256(v: str | None, field_name: str = "sha256") -> str | None:
if v is None:
return v
if not _SHA256_RE.match(v):
raise ValueError(f"{field_name} 必須為 64 位 hex 字串")
return v
class MigrationMode(str, Enum):
LEGACY = "legacy_awoooi_default"
SHADOW = "shadow"
CANARY = "canary"
ACTIVE = "active"
class ChannelType(str, Enum):
TELEGRAM = "telegram"
SLACK = "slack"
WEBHOOK = "webhook"
API = "api"
class Provider(str, Enum):
ANTHROPIC = "anthropic"
OPENAI = "openai"
OLLAMA = "ollama"
GEMINI = "gemini"
NVIDIA = "nvidia"
OPENROUTER = "openrouter"
class RunState(str, Enum):
PENDING = "pending"
RUNNING = "running"
WAITING_APPROVAL = "waiting_approval"
WAITING_TOOL = "waiting_tool"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
TIMEOUT = "timeout"
class AuthScheme(str, Enum):
NONE = "none"
BEARER = "bearer"
HMAC = "hmac"
class Transport(str, Enum):
STDIO = "stdio"
HTTP = "http"
SSE = "sse"
class EventType(str, Enum):
MESSAGE_RECEIVED = "message_received"
CALLBACK_QUERY = "callback_query"
COMMAND_INVOKED = "command_invoked"
WEBHOOK_POST = "webhook_post"
API_REQUEST = "api_request"
APPROVAL_RESPONSE = "approval_response"
# ─────────────────────────────────────────────────────────────────────────────
# 1. Project Tenant Contract
# ─────────────────────────────────────────────────────────────────────────────
class ProjectTenantContract(BaseModel):
"""租戶/專案合約ADR-111/115"""
model_config = {"extra": "forbid"}
project_id: str = Field(..., description="全局唯一租戶識別符")
display_name: str = Field(..., min_length=1, max_length=256)
migration_mode: MigrationMode = MigrationMode.LEGACY
budget_limit_usd: float | None = Field(None, ge=0)
allowed_channels: list[ChannelType] = Field(default_factory=list)
is_active: bool = True
metadata: dict[str, Any] = Field(default_factory=dict)
@field_validator("project_id")
@classmethod
def validate_project_id(cls, v: str) -> str:
if not _PROJECT_ID_RE.match(v):
raise ValueError("project_id 只允許 a-z, 0-9, _, -,長度 2-64")
return v
@field_validator("allowed_channels")
@classmethod
def validate_unique_channels(cls, v: list[ChannelType]) -> list[ChannelType]:
if len(v) != len(set(v)):
raise ValueError("allowed_channels 不可包含重複項目")
return v
# ─────────────────────────────────────────────────────────────────────────────
# 2. Agent Contract
# ─────────────────────────────────────────────────────────────────────────────
class ArtifactRef(BaseModel):
"""含 SHA-256 的 artifact 參照ADR-112 artifact integrity"""
model_config = {"extra": "forbid"}
artifact_id: str
sha256: str = Field(..., description="SHA-256 hex digest64 位)")
@field_validator("sha256")
@classmethod
def validate_sha256(cls, v: str) -> str:
return _validate_sha256(v, "sha256") # type: ignore[return-value]
class ToolRef(BaseModel):
"""Agent 工具參照"""
model_config = {"extra": "allow"}
tool_name: str
mcp_gateway_id: str | None = None
sha256: str | None = None
@field_validator("sha256")
@classmethod
def validate_sha256(cls, v: str | None) -> str | None:
return _validate_sha256(v, "tool sha256")
class AgentContract(BaseModel):
"""Agent 合約ADR-112"""
model_config = {"extra": "forbid"}
agent_id: str = Field(..., description="Agent 識別符")
agent_name: str = Field(..., min_length=1, max_length=256)
model: str = Field(..., min_length=1, max_length=128)
provider: Provider
max_tokens: int | None = Field(None, ge=1, le=200000)
temperature: float | None = Field(None, ge=0.0, le=2.0)
system_prompt_ref: ArtifactRef | None = None
tools: list[ToolRef] = Field(default_factory=list)
budget_limit_usd_per_run: float | None = Field(None, ge=0)
require_approval: bool = False
approval_timeout_seconds: int | None = Field(None, ge=60, le=86400)
max_parallel_runs: int = Field(1, ge=1, le=100)
tags: list[str] = Field(default_factory=list)
@field_validator("agent_id")
@classmethod
def validate_agent_id(cls, v: str) -> str:
if not _AGENT_ID_RE.match(v):
raise ValueError("agent_id 只允許 a-z, 0-9, _, -,長度 2-128")
return v
@model_validator(mode="after")
def validate_approval_config(self) -> AgentContract:
if self.require_approval and self.approval_timeout_seconds is None:
self.approval_timeout_seconds = 300
return self
# ─────────────────────────────────────────────────────────────────────────────
# 3. MCP Gateway Contract
# ─────────────────────────────────────────────────────────────────────────────
class ToolExposed(BaseModel):
"""Gateway 暴露的工具定義"""
model_config = {"extra": "forbid"}
tool_name: str
description: str | None = None
schema_sha256: str = Field(..., description="工具 input schema SHA-256")
is_destructive: bool = False
@field_validator("schema_sha256")
@classmethod
def validate_schema_sha256(cls, v: str) -> str:
return _validate_sha256(v, "schema_sha256") # type: ignore[return-value]
class MCPGatewayContract(BaseModel):
"""MCP Gateway 合約ADR-113"""
model_config = {"extra": "forbid"}
gateway_id: str
gateway_name: str = Field(..., min_length=1, max_length=256)
transport: Transport
endpoint: str | None = None
auth_scheme: AuthScheme = AuthScheme.NONE
hmac_secret_ref: str | None = None
tools_exposed: list[ToolExposed] = Field(default_factory=list)
rate_limit_rpm: int | None = Field(None, ge=1)
timeout_seconds: int = Field(30, ge=1, le=300)
is_enabled: bool = True
@model_validator(mode="after")
def validate_http_endpoint(self) -> MCPGatewayContract:
if self.transport in (Transport.HTTP, Transport.SSE) and not self.endpoint:
raise ValueError(f"transport={self.transport} 時 endpoint 為必填")
return self
# ─────────────────────────────────────────────────────────────────────────────
# 4. Policy Routing Contract
# ─────────────────────────────────────────────────────────────────────────────
class TimeRange(BaseModel):
model_config = {"extra": "forbid"}
start_utc: str = Field(..., pattern=r"^[0-2][0-9]:[0-5][0-9]$")
end_utc: str = Field(..., pattern=r"^[0-2][0-9]:[0-5][0-9]$")
class RoutingCondition(BaseModel):
model_config = {"extra": "forbid"}
task_types: list[str] = Field(default_factory=list)
max_prompt_tokens: int | None = Field(None, ge=1)
time_range: TimeRange | None = None
class RoutingRule(BaseModel):
model_config = {"extra": "forbid"}
rule_id: str
priority: int = Field(..., ge=0, le=9999)
provider: Provider
model: str
condition: RoutingCondition | None = None
weight: int = Field(100, ge=1, le=100)
class RetryPolicy(BaseModel):
model_config = {"extra": "forbid"}
max_retries: int = Field(3, ge=0, le=10)
backoff_base_seconds: float = Field(1.0, ge=0.1, le=60)
retry_on_provider_errors: bool = True
class PolicyRoutingContract(BaseModel):
"""路由/政策合約"""
model_config = {"extra": "forbid"}
policy_id: str
policy_name: str = Field(..., min_length=1, max_length=256)
routing_rules: list[RoutingRule] = Field(..., min_length=1)
fallback_provider: Provider | None = None
fallback_model: str | None = None
max_cost_per_run_usd: float | None = Field(None, ge=0)
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
effective_from: datetime | None = None
effective_to: datetime | None = None
# ─────────────────────────────────────────────────────────────────────────────
# 5. Runtime Run State Contract
# ─────────────────────────────────────────────────────────────────────────────
class RunTrigger(BaseModel):
model_config = {"extra": "forbid"}
trigger_type: str = Field(
..., pattern="^(channel_event|schedule|api|sub_agent|retry)$"
)
channel_event_id: str | None = None
schedule_id: str | None = None
triggered_by: str | None = None
class RuntimeRunStateContract(BaseModel):
"""Run 狀態機合約ADR-106 Phase 3"""
model_config = {"extra": "forbid"}
run_id: str = Field(..., description="UUID v7")
project_id: str
agent_id: str
state: RunState
trace_id: str | None = None
parent_run_id: str | None = None
trigger: RunTrigger | None = None
input_sha256: str | None = None
output_sha256: str | None = None
started_at: datetime | None = None
completed_at: datetime | None = None
timeout_at: datetime | None = None
error_code: str | None = None
cost_usd: float | None = Field(None, ge=0)
step_count: int = Field(0, ge=0)
@field_validator("run_id", "parent_run_id")
@classmethod
def validate_uuid(cls, v: str | None) -> str | None:
if v is None:
return v
if not _UUID_RE.match(v):
raise ValueError("必須為標準 UUID 格式")
return v
@field_validator("input_sha256", "output_sha256")
@classmethod
def validate_sha256_fields(cls, v: str | None) -> str | None:
return _validate_sha256(v)
@field_validator("project_id")
@classmethod
def validate_project_id(cls, v: str) -> str:
if not _PROJECT_ID_RE.match(v):
raise ValueError("project_id 格式不合法")
return v
# ─────────────────────────────────────────────────────────────────────────────
# 6. Channel Event Contract
# ─────────────────────────────────────────────────────────────────────────────
class AttachmentRef(BaseModel):
model_config = {"extra": "forbid"}
attachment_type: str = Field(..., pattern="^(photo|document|audio|video)$")
file_id: str
sha256: str | None = None
@field_validator("sha256")
@classmethod
def validate_sha256(cls, v: str | None) -> str | None:
return _validate_sha256(v, "attachment sha256")
class ChannelEventContract(BaseModel):
"""Channel Event 合約ADR-114 冪等去重)"""
model_config = {"extra": "forbid"}
event_id: str = Field(..., description="Platform 生成的 UUID")
project_id: str
channel_type: ChannelType
event_type: EventType
provider_event_id: str | None = Field(None, max_length=256)
user_id: str | None = None
chat_id: str | None = None
payload: dict[str, Any] = Field(..., min_length=1)
text: str | None = Field(None, max_length=4096)
attachments: list[AttachmentRef] = Field(default_factory=list)
run_id: str | None = None
is_duplicate: bool = False
received_at: datetime
@field_validator("event_id", "run_id")
@classmethod
def validate_uuid(cls, v: str | None) -> str | None:
if v is None:
return v
if not _UUID_RE.match(v):
raise ValueError("必須為標準 UUID 格式")
return v
@field_validator("project_id")
@classmethod
def validate_project_id(cls, v: str) -> str:
if not _PROJECT_ID_RE.match(v):
raise ValueError("project_id 格式不合法")
return v
# ─────────────────────────────────────────────────────────────────────────────
# Contract family dispatcher
# ─────────────────────────────────────────────────────────────────────────────
CONTRACT_FAMILY_MODELS: dict[str, type[BaseModel]] = {
"project_tenant": ProjectTenantContract,
"agent": AgentContract,
"mcp_gateway": MCPGatewayContract,
"policy_routing": PolicyRoutingContract,
"runtime_run_state": RuntimeRunStateContract,
"channel_event": ChannelEventContract,
}
VALID_CONTRACT_FAMILIES = frozenset(CONTRACT_FAMILY_MODELS.keys())
def validate_contract_body(family: str, body: dict[str, Any]) -> BaseModel:
"""
依 contract_family 驗證 body_json。
驗證失敗拋出 pydantic.ValidationError。
"""
model_cls = CONTRACT_FAMILY_MODELS.get(family)
if model_cls is None:
raise ValueError(
f"未知 contract_family: {family!r}"
f"合法值:{sorted(VALID_CONTRACT_FAMILIES)}"
)
return model_cls.model_validate(body)