chore: initialize agent-platform and add claude inference health check
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
# LLM
|
||||
ANTHROPIC_AUTH_TOKEN=sk-ant-CHANGE_ME
|
||||
ANTHROPIC_BASE_URL=https://api.anthropic.com
|
||||
ANTHROPIC_MODEL=claude-sonnet-4-6-20260217
|
||||
ANTHROPIC_TIMEOUT_CONNECT=10.0
|
||||
ANTHROPIC_TIMEOUT_READ=60.0
|
||||
LOCAL_MODEL_ENDPOINT=http://localhost:11434/v1
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql+asyncpg://agent:CHANGE_ME@localhost:5432/agent_platform
|
||||
DB_POOL_SIZE=10
|
||||
DB_MAX_OVERFLOW=20
|
||||
DB_POOL_TIMEOUT=30.0
|
||||
DB_POOL_RECYCLE=3600
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
REDIS_RATE_LIMIT_PER_MINUTE=60
|
||||
|
||||
# Milvus
|
||||
MILVUS_HOST=localhost
|
||||
MILVUS_PORT=19530
|
||||
MILVUS_COLLECTION_PREFIX=agent_platform
|
||||
|
||||
# Auth
|
||||
JWT_SECRET_KEY=GENERATE_ME_min_32_chars
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_EXPIRE_MINUTES=480
|
||||
|
||||
# Observability
|
||||
OTEL_SERVICE_NAME=agent-platform
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# App
|
||||
ENVIRONMENT=development
|
||||
ALLOWED_ORIGINS=[]
|
||||
|
||||
# Cost Budgets (Decimal)
|
||||
MAX_DAILY_COST_CNY=500.00
|
||||
MAX_MONTHLY_COST_CNY=2000.00
|
||||
COST_ALERT_THRESHOLD_PCT=0.80
|
||||
|
||||
# Audit Encryption
|
||||
AUDIT_KEYRING_JSON='{"k1":"GENERATE_ME_base64_32bytes"}'
|
||||
AUDIT_ACTIVE_KEY_ID=k1
|
||||
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
*.pyc
|
||||
@@ -0,0 +1,12 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.9
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: ["--fix"]
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.11.2
|
||||
hooks:
|
||||
- id: mypy
|
||||
args: ["--ignore-missing-imports"]
|
||||
@@ -0,0 +1,34 @@
|
||||
# agent-platform/AGENTS.md - 项目根主指令(硬化版)
|
||||
|
||||
## 目标
|
||||
企业智能体平台项目以可靠性、可审计性、可观测性和低风险变更为首要目标。
|
||||
|
||||
## 全局约束
|
||||
- Python 版本必须为 3.11+。
|
||||
- 所有新增函数必须有完整类型注解;禁止裸 `Any`。
|
||||
- Pydantic 的 `list`/`dict` 字段必须使用 `Field(default_factory=...)`。
|
||||
- 涉及货币、成本、比率的字段统一使用 `Decimal`,禁止 `float`。
|
||||
- 配置加载统一通过 `get_settings()` 惰性单例,禁止模块级实例化 `Settings()`。
|
||||
- 依赖安装与执行统一使用 `uv`,并保持 `uv.lock` 与代码同步。
|
||||
|
||||
## 代码组织
|
||||
- `core/` 放置领域逻辑,不依赖 `api/`。
|
||||
- `api/` 仅做协议层、鉴权、中间件与路由编排。
|
||||
- `db/` 负责仓储和迁移,不承载业务流程。
|
||||
- `profiles/` 仅存 SOP/prompt 资产,不写运行时代码。
|
||||
- `tests/` 按 `unit/integration/contracts` 分层。
|
||||
|
||||
## 质量门禁
|
||||
- 提交前必须通过:`ruff check`、`mypy`、`pytest --collect-only`。
|
||||
- 任何 schema、鉴权、审计、加密相关变更必须补充测试。
|
||||
- 禁止绕过失败门禁直接合并。
|
||||
|
||||
## 安全与审计
|
||||
- 严禁提交真实密钥、令牌、证书。
|
||||
- 审计 payload 必须加密存储,且支持 keyring 轮换。
|
||||
- 生产环境日志禁止输出敏感字段原文。
|
||||
|
||||
## 变更原则
|
||||
- 小步提交,保持可回滚。
|
||||
- 不在无关文件中引入格式化噪音。
|
||||
- 新增依赖必须说明必要性与替代方案不可行性。
|
||||
@@ -0,0 +1,46 @@
|
||||
.PHONY: up down logs restart test test-unit test-integration lint format \
|
||||
migrate seed health clean generate-audit-key
|
||||
|
||||
up:
|
||||
docker compose up -d --wait && sleep 3 && uv run alembic upgrade head
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
logs:
|
||||
docker compose logs -f api
|
||||
|
||||
restart:
|
||||
docker compose down && docker compose up -d --wait
|
||||
|
||||
test:
|
||||
uv run pytest tests/ -x --tb=short -m "not claude_api"
|
||||
|
||||
test-unit:
|
||||
uv run pytest tests/unit/ -v --cov=core --cov=api --cov-fail-under=80
|
||||
|
||||
test-integration:
|
||||
uv run pytest tests/integration/ -v --tb=long -m integration
|
||||
|
||||
lint:
|
||||
uv run ruff check . && uv run mypy core/ api/ --ignore-missing-imports
|
||||
|
||||
format:
|
||||
uv run ruff format . && uv run ruff check . --fix
|
||||
|
||||
migrate:
|
||||
uv run alembic upgrade head
|
||||
|
||||
seed:
|
||||
uv run python scripts/seed_data.py
|
||||
|
||||
health:
|
||||
@bash scripts/health_check.sh
|
||||
|
||||
clean:
|
||||
docker compose down -v
|
||||
find . -type d -name pycache -exec rm -rf {} + 2>/dev/null; true
|
||||
find . -name "*.pyc" -delete 2>/dev/null; true
|
||||
|
||||
generate-audit-key:
|
||||
uv run python scripts/gen_audit_key.py
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
[alembic]
|
||||
script_location = db/migrations
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url =
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,13 @@
|
||||
# api/AGENTS.md
|
||||
|
||||
## 范围
|
||||
`api/` 负责 FastAPI 路由、请求响应契约、中间件和权限控制。
|
||||
|
||||
## 约束
|
||||
- 路由层不承载业务规则,仅做参数校验与编排。
|
||||
- 权限、限流、鉴权失败返回明确 HTTP 状态码与错误码。
|
||||
- 合同模型字段禁止可变默认值,统一 `Field(default_factory=...)`。
|
||||
|
||||
## 工程要求
|
||||
- 新增路由必须包含至少一个契约测试。
|
||||
- 中间件顺序变更需说明兼容性影响。
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI(title="agent-platform-api")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,14 @@
|
||||
# core/AGENTS.md
|
||||
|
||||
## 范围
|
||||
`core/` 负责智能体引擎、LLM 路由、工具执行、校验、审计与可观测基础能力。
|
||||
|
||||
## 约束
|
||||
- 保持纯业务内核,禁止引入 API 框架对象。
|
||||
- 所有状态对象、输入输出契约需类型稳定、可序列化。
|
||||
- 成本、预算、阈值统一 `Decimal`。
|
||||
- 配置访问必须走 `get_settings()`。
|
||||
|
||||
## 工程要求
|
||||
- 复杂分支逻辑应有最小单元测试覆盖。
|
||||
- 审计与加密路径出现异常时必须返回可诊断错误,不可静默吞错。
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
from core.config import get_settings
|
||||
|
||||
|
||||
class Encryptor:
|
||||
"""AES-256-GCM encryptor/decryptor for audit payloads."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings = get_settings()
|
||||
parsed = json.loads(settings.AUDIT_KEYRING_JSON)
|
||||
self._keyring = {key_id: base64.b64decode(key_b64) for key_id, key_b64 in parsed.items()}
|
||||
|
||||
def encrypt(self, plaintext: bytes, key_id: str) -> bytes:
|
||||
key = self._keyring[key_id]
|
||||
nonce = os.urandom(12)
|
||||
ciphertext = AESGCM(key).encrypt(nonce, plaintext, None)
|
||||
return nonce + ciphertext
|
||||
|
||||
def decrypt(self, payload: bytes, key_id: str) -> bytes:
|
||||
key = self._keyring[key_id]
|
||||
nonce = payload[:12]
|
||||
ciphertext = payload[12:]
|
||||
return AESGCM(key).decrypt(nonce, ciphertext, None)
|
||||
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class Sanitizer:
|
||||
"""Simple sanitizer for masking potentially sensitive text."""
|
||||
|
||||
@staticmethod
|
||||
def mask(value: str) -> str:
|
||||
masked = value
|
||||
for token in ["sk-ant-", "password", "secret", "token", "apikey", "api_key"]:
|
||||
if token in masked.lower():
|
||||
return "[MASKED_ERROR]"
|
||||
return masked
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import functools
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from typing import Annotated, Self
|
||||
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
# LLM
|
||||
ANTHROPIC_AUTH_TOKEN: str = Field(..., description="Anthropic auth token")
|
||||
ANTHROPIC_BASE_URL: str = Field(
|
||||
default="https://api.anthropic.com",
|
||||
description="Anthropic API base URL",
|
||||
)
|
||||
ANTHROPIC_MODEL: str = Field(
|
||||
default="claude-sonnet-4-6-20260217",
|
||||
description="Anthropic model identifier",
|
||||
)
|
||||
ANTHROPIC_TIMEOUT_CONNECT: float = Field(default=10.0, description="Anthropic connect timeout")
|
||||
ANTHROPIC_TIMEOUT_READ: float = Field(default=60.0, description="Anthropic read timeout")
|
||||
LOCAL_MODEL_ENDPOINT: str = Field(
|
||||
default="http://localhost:11434/v1",
|
||||
description="Local model API endpoint",
|
||||
)
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = Field(..., description="Async PostgreSQL connection URL")
|
||||
DB_POOL_SIZE: int = Field(default=10, description="Database pool size")
|
||||
DB_MAX_OVERFLOW: int = Field(default=20, description="Database max overflow")
|
||||
DB_POOL_TIMEOUT: float = Field(default=30.0, description="Database pool timeout seconds")
|
||||
DB_POOL_RECYCLE: int = Field(default=3600, description="Database pool recycle seconds")
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = Field(default="redis://localhost:6379/0", description="Redis URL")
|
||||
REDIS_RATE_LIMIT_PER_MINUTE: int = Field(default=60, description="Rate limit per minute")
|
||||
|
||||
# Milvus
|
||||
MILVUS_HOST: str = Field(default="localhost", description="Milvus host")
|
||||
MILVUS_PORT: int = Field(default=19530, description="Milvus port")
|
||||
MILVUS_COLLECTION_PREFIX: str = Field(
|
||||
default="agent_platform",
|
||||
description="Milvus collection prefix",
|
||||
)
|
||||
|
||||
# Auth
|
||||
JWT_SECRET_KEY: str = Field(..., min_length=32, description="JWT secret key")
|
||||
JWT_ALGORITHM: str = Field(default="HS256", description="JWT algorithm")
|
||||
JWT_EXPIRE_MINUTES: int = Field(default=480, description="JWT expiry in minutes")
|
||||
|
||||
# Observability
|
||||
OTEL_SERVICE_NAME: str = Field(default="agent-platform", description="OTel service name")
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: str = Field(
|
||||
default="http://localhost:4317",
|
||||
description="OTLP endpoint",
|
||||
)
|
||||
LOG_LEVEL: str = Field(default="INFO", description="Application log level")
|
||||
|
||||
# App
|
||||
ENVIRONMENT: str = Field(default="development", description="Runtime environment")
|
||||
ALLOWED_ORIGINS: Annotated[list[str], NoDecode] = Field(
|
||||
default_factory=list,
|
||||
description="Allowed CORS origins",
|
||||
)
|
||||
|
||||
# Cost Budgets (Decimal)
|
||||
MAX_DAILY_COST_CNY: Decimal = Field(
|
||||
default=Decimal("500.00"),
|
||||
description="Daily budget in CNY",
|
||||
)
|
||||
MAX_MONTHLY_COST_CNY: Decimal = Field(
|
||||
default=Decimal("2000.00"),
|
||||
description="Monthly budget in CNY",
|
||||
)
|
||||
COST_ALERT_THRESHOLD_PCT: Decimal = Field(
|
||||
default=Decimal("0.80"),
|
||||
description="Cost alert threshold ratio",
|
||||
)
|
||||
|
||||
# Audit Encryption
|
||||
AUDIT_KEYRING_JSON: str = Field(..., description="JSON map of key_id -> base64(aes-256 key)")
|
||||
AUDIT_ACTIVE_KEY_ID: str = Field(default="k1", description="Active audit key ID")
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
@field_validator("DATABASE_URL")
|
||||
@classmethod
|
||||
def validate_database_url(cls, value: str) -> str:
|
||||
if not value.startswith("postgresql+asyncpg://"):
|
||||
raise ValueError("DATABASE_URL must start with postgresql+asyncpg://")
|
||||
return value
|
||||
|
||||
@field_validator("ENVIRONMENT")
|
||||
@classmethod
|
||||
def validate_environment(cls, value: str) -> str:
|
||||
allowed = {"development", "production", "test"}
|
||||
if value not in allowed:
|
||||
raise ValueError("ENVIRONMENT must be one of development/production/test")
|
||||
return value
|
||||
|
||||
@field_validator("ALLOWED_ORIGINS", mode="before")
|
||||
@classmethod
|
||||
def parse_allowed_origins(cls, value: object) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
if not all(isinstance(item, str) for item in value):
|
||||
raise ValueError("ALLOWED_ORIGINS list items must be strings")
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return []
|
||||
if stripped.startswith("["):
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(
|
||||
"ALLOWED_ORIGINS JSON must be a valid list of strings"
|
||||
) from exc
|
||||
if not isinstance(parsed, list) or not all(
|
||||
isinstance(item, str) for item in parsed
|
||||
):
|
||||
raise ValueError("ALLOWED_ORIGINS JSON must be a list of strings")
|
||||
return parsed
|
||||
return [item.strip() for item in stripped.split(",") if item.strip()]
|
||||
raise ValueError("ALLOWED_ORIGINS must be a list or string")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_audit_keyring(self) -> Self:
|
||||
parsed: object
|
||||
try:
|
||||
parsed = json.loads(self.AUDIT_KEYRING_JSON)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("AUDIT_KEYRING_JSON must be valid JSON") from exc
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("AUDIT_KEYRING_JSON must be a JSON object")
|
||||
|
||||
keyring: dict[str, str] = {}
|
||||
for key_id_obj, key_b64_obj in parsed.items():
|
||||
if not isinstance(key_id_obj, str) or not isinstance(key_b64_obj, str):
|
||||
raise ValueError(
|
||||
"AUDIT_KEYRING_JSON must be a map of string key_id to string base64"
|
||||
)
|
||||
keyring[key_id_obj] = key_b64_obj
|
||||
|
||||
if self.AUDIT_ACTIVE_KEY_ID not in keyring:
|
||||
raise ValueError("AUDIT_ACTIVE_KEY_ID must exist in AUDIT_KEYRING_JSON")
|
||||
|
||||
for key_id, key_b64 in keyring.items():
|
||||
try:
|
||||
key_bytes = base64.b64decode(key_b64, validate=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ValueError(f"Audit key '{key_id}' is not valid base64") from exc
|
||||
if len(key_bytes) != 32:
|
||||
raise ValueError(f"Audit key '{key_id}' must decode to exactly 32 bytes")
|
||||
|
||||
return self
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""Lazily load and cache application settings."""
|
||||
|
||||
return Settings() # type: ignore[call-arg]
|
||||
|
||||
|
||||
def is_production() -> bool:
|
||||
return get_settings().ENVIRONMENT == "production"
|
||||
|
||||
|
||||
def is_development() -> bool:
|
||||
return get_settings().ENVIRONMENT == "development"
|
||||
|
||||
|
||||
def is_test() -> bool:
|
||||
return get_settings().ENVIRONMENT == "test"
|
||||
@@ -0,0 +1,34 @@
|
||||
from core.tools.base import (
|
||||
BaseTool,
|
||||
Citation,
|
||||
DataScopeViolationError,
|
||||
RiskLevel,
|
||||
SideEffect,
|
||||
ToolDeclaration,
|
||||
ToolNotAllowedError,
|
||||
ToolNotFoundError,
|
||||
ToolResult,
|
||||
)
|
||||
from core.tools.doc_generator import WordDocGeneratorTool
|
||||
from core.tools.enterprise_info import EnterpriseInfoSearchTool
|
||||
from core.tools.executor import ToolExecutor
|
||||
from core.tools.rag_retriever import RagCaseRetrieverTool
|
||||
from core.tools.registry import ToolRegistry, get_registry
|
||||
|
||||
__all__ = [
|
||||
"BaseTool",
|
||||
"Citation",
|
||||
"DataScopeViolationError",
|
||||
"RiskLevel",
|
||||
"SideEffect",
|
||||
"ToolDeclaration",
|
||||
"ToolNotAllowedError",
|
||||
"ToolNotFoundError",
|
||||
"ToolResult",
|
||||
"WordDocGeneratorTool",
|
||||
"EnterpriseInfoSearchTool",
|
||||
"RagCaseRetrieverTool",
|
||||
"ToolExecutor",
|
||||
"ToolRegistry",
|
||||
"get_registry",
|
||||
]
|
||||
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from time import perf_counter
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from tenacity import AsyncRetrying, retry_if_exception_type, stop_after_attempt, wait_fixed
|
||||
|
||||
from core.audit.sanitizer import Sanitizer
|
||||
|
||||
|
||||
class RiskLevel(str, Enum): # noqa: UP042
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
class SideEffect(str, Enum): # noqa: UP042
|
||||
NONE = "none"
|
||||
READ = "read"
|
||||
WRITE = "write"
|
||||
DELETE = "delete"
|
||||
SEND = "send"
|
||||
EXTERNAL_API = "external_api"
|
||||
|
||||
|
||||
class Citation(BaseModel, frozen=True):
|
||||
id: str
|
||||
source: str
|
||||
relevance: Decimal = Field(ge=Decimal("0"), le=Decimal("1"))
|
||||
content_preview: str = ""
|
||||
section: str = ""
|
||||
verified: bool = False
|
||||
|
||||
|
||||
class ToolResult(BaseModel, frozen=True):
|
||||
success: bool
|
||||
data: Any = None
|
||||
error: str | None = None
|
||||
citations: list[Citation] = Field(default_factory=list)
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
data_ref: str | None = None
|
||||
duration_ms: float = 0.0
|
||||
|
||||
|
||||
class ToolDeclaration(BaseModel, frozen=True):
|
||||
name: str
|
||||
description: str
|
||||
risk_level: RiskLevel = RiskLevel.LOW
|
||||
side_effects: list[SideEffect] = Field(default_factory=lambda: [SideEffect.NONE])
|
||||
max_duration_ms: int = 30_000
|
||||
is_idempotent: bool = True
|
||||
|
||||
@property
|
||||
def requires_approval(self) -> bool:
|
||||
if self.risk_level in {RiskLevel.HIGH, RiskLevel.CRITICAL}:
|
||||
return True
|
||||
risky = {SideEffect.WRITE, SideEffect.DELETE, SideEffect.SEND}
|
||||
return any(effect in risky for effect in self.side_effects)
|
||||
|
||||
|
||||
class ToolNotAllowedError(Exception):
|
||||
def __init__(self, tool_name: str, profile_name: str) -> None:
|
||||
super().__init__(f"Tool '{tool_name}' is not allowed for profile '{profile_name}'")
|
||||
self.tool_name = tool_name
|
||||
self.profile_name = profile_name
|
||||
|
||||
|
||||
class DataScopeViolationError(Exception):
|
||||
def __init__(self, tool_name: str, requested_scope: str, allowed_scope: str) -> None:
|
||||
super().__init__(
|
||||
f"Tool '{tool_name}' requested scope '{requested_scope}' "
|
||||
f"beyond allowed '{allowed_scope}'"
|
||||
)
|
||||
self.tool_name = tool_name
|
||||
self.requested_scope = requested_scope
|
||||
self.allowed_scope = allowed_scope
|
||||
|
||||
|
||||
class ToolNotFoundError(Exception):
|
||||
def __init__(self, tool_name: str) -> None:
|
||||
super().__init__(f"Tool '{tool_name}' not found")
|
||||
self.tool_name = tool_name
|
||||
|
||||
|
||||
class BaseTool(ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def declaration(self) -> ToolDeclaration:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def input_schema(self) -> type[BaseModel]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def _execute_impl(
|
||||
self,
|
||||
params: BaseModel,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
data_scope: str,
|
||||
) -> ToolResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
raw_params: dict[str, Any],
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
data_scope: str,
|
||||
) -> ToolResult:
|
||||
started = perf_counter()
|
||||
try:
|
||||
validated = self.input_schema.model_validate(raw_params)
|
||||
except ValidationError as exc:
|
||||
duration_ms = (perf_counter() - started) * 1000.0
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error=f"invalid_params: {exc}",
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await self._execute_with_retry(validated, user_id, tenant_id, data_scope)
|
||||
except TimeoutError:
|
||||
duration_ms = (perf_counter() - started) * 1000.0
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error="tool_execution_timeout",
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
duration_ms = (perf_counter() - started) * 1000.0
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error=Sanitizer.mask(str(exc)),
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
duration_ms = (perf_counter() - started) * 1000.0
|
||||
return result.model_copy(update={"duration_ms": duration_ms})
|
||||
|
||||
async def _execute_with_retry(
|
||||
self,
|
||||
params: BaseModel,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
data_scope: str,
|
||||
) -> ToolResult:
|
||||
retrying = AsyncRetrying(
|
||||
stop=stop_after_attempt(2),
|
||||
wait=wait_fixed(1),
|
||||
retry=retry_if_exception_type((ConnectionError, TimeoutError, OSError)),
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
async for attempt in retrying:
|
||||
with attempt:
|
||||
timeout_s = self.declaration.max_duration_ms / 1000.0
|
||||
return await asyncio.wait_for(
|
||||
self._execute_impl(params, user_id, tenant_id, data_scope),
|
||||
timeout=timeout_s,
|
||||
)
|
||||
|
||||
return ToolResult(success=False, error="retry_exhausted")
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.config import is_production
|
||||
from core.tools.base import BaseTool, RiskLevel, SideEffect, ToolDeclaration, ToolResult
|
||||
|
||||
|
||||
class DocGeneratorInput(BaseModel):
|
||||
template_name: str
|
||||
content: dict[str, Any] = Field(default_factory=dict)
|
||||
output_format: Literal["docx", "pdf", "markdown"] = "docx"
|
||||
filename: str | None = None
|
||||
|
||||
|
||||
class WordDocGeneratorTool(BaseTool):
|
||||
@property
|
||||
def declaration(self) -> ToolDeclaration:
|
||||
return ToolDeclaration(
|
||||
name="word_doc_generator",
|
||||
description="Generate document files from structured content",
|
||||
risk_level=RiskLevel.MEDIUM,
|
||||
side_effects=[SideEffect.WRITE],
|
||||
max_duration_ms=15000,
|
||||
is_idempotent=False,
|
||||
)
|
||||
|
||||
@property
|
||||
def input_schema(self) -> type[BaseModel]:
|
||||
return DocGeneratorInput
|
||||
|
||||
async def _execute_impl(
|
||||
self,
|
||||
params: BaseModel,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
data_scope: str,
|
||||
) -> ToolResult:
|
||||
input_data = DocGeneratorInput.model_validate(params)
|
||||
output_dir = Path("/tmp/agent_platform")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
stem = input_data.filename or f"{input_data.template_name}_{uuid4().hex[:8]}"
|
||||
|
||||
if input_data.output_format == "markdown":
|
||||
return self._write_markdown(output_dir, stem, input_data)
|
||||
|
||||
if input_data.output_format == "pdf":
|
||||
result = self._write_markdown(output_dir, stem, input_data)
|
||||
meta = {**result.meta, "format_fallback": "markdown", "reason": "pdf_not_supported"}
|
||||
return result.model_copy(update={"meta": meta})
|
||||
|
||||
try:
|
||||
from docx import Document
|
||||
except Exception:
|
||||
if is_production():
|
||||
return ToolResult(success=False, error="python-docx not available")
|
||||
result = self._write_markdown(output_dir, stem, input_data)
|
||||
meta = {
|
||||
**result.meta,
|
||||
"format_fallback": "markdown",
|
||||
"reason": "python-docx import error",
|
||||
}
|
||||
return result.model_copy(update={"meta": meta})
|
||||
|
||||
file_path = output_dir / f"{stem}.docx"
|
||||
doc = Document()
|
||||
doc.add_heading(input_data.template_name, level=1)
|
||||
for key, value in input_data.content.items():
|
||||
doc.add_paragraph(f"{key}: {value}")
|
||||
doc.save(str(file_path))
|
||||
|
||||
rel_path = file_path.relative_to(Path("/tmp")).as_posix()
|
||||
return ToolResult(
|
||||
success=True,
|
||||
data_ref=rel_path,
|
||||
meta={"filename": file_path.name, "size_bytes": file_path.stat().st_size},
|
||||
)
|
||||
|
||||
def _write_markdown(
|
||||
self,
|
||||
output_dir: Path,
|
||||
stem: str,
|
||||
input_data: DocGeneratorInput,
|
||||
) -> ToolResult:
|
||||
file_path = output_dir / f"{stem}.md"
|
||||
lines = [f"# {input_data.template_name}", ""]
|
||||
for key, value in input_data.content.items():
|
||||
lines.append(f"- **{key}**: {value}")
|
||||
file_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
rel_path = file_path.relative_to(Path("/tmp")).as_posix()
|
||||
return ToolResult(
|
||||
success=True,
|
||||
data_ref=rel_path,
|
||||
meta={"filename": file_path.name, "size_bytes": file_path.stat().st_size},
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.config import is_production
|
||||
from core.tools.base import BaseTool, RiskLevel, SideEffect, ToolDeclaration, ToolResult
|
||||
|
||||
|
||||
class EnterpriseInfoInput(BaseModel):
|
||||
company_name: str
|
||||
include_financials: bool = False
|
||||
include_news: bool = True
|
||||
|
||||
|
||||
class EnterpriseInfoSearchTool(BaseTool):
|
||||
@property
|
||||
def declaration(self) -> ToolDeclaration:
|
||||
return ToolDeclaration(
|
||||
name="enterprise_info_search",
|
||||
description="Search enterprise basic info, financials, and recent news",
|
||||
risk_level=RiskLevel.LOW,
|
||||
side_effects=[SideEffect.READ, SideEffect.EXTERNAL_API],
|
||||
max_duration_ms=5000,
|
||||
is_idempotent=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def input_schema(self) -> type[BaseModel]:
|
||||
return EnterpriseInfoInput
|
||||
|
||||
async def _execute_impl(
|
||||
self,
|
||||
params: BaseModel,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
data_scope: str,
|
||||
) -> ToolResult:
|
||||
input_data = EnterpriseInfoInput.model_validate(params)
|
||||
endpoint = "http://localhost:8080/api/enterprise-info"
|
||||
payload: dict[str, Any] = {
|
||||
"company_name": input_data.company_name,
|
||||
"include_financials": input_data.include_financials,
|
||||
"include_news": input_data.include_news,
|
||||
"tenant_id": tenant_id,
|
||||
"user_id": user_id,
|
||||
}
|
||||
|
||||
if is_production():
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
resp = await client.post(endpoint, json=payload)
|
||||
resp.raise_for_status()
|
||||
return ToolResult(success=True, data=resp.json())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return ToolResult(success=False, error=str(exc))
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
resp = await client.post(endpoint, json=payload)
|
||||
resp.raise_for_status()
|
||||
return ToolResult(success=True, data=resp.json())
|
||||
except (httpx.ConnectError, httpx.ReadTimeout, ConnectionError, TimeoutError, OSError):
|
||||
return self._mock_response(input_data.company_name)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return ToolResult(success=False, error=str(exc))
|
||||
|
||||
def _mock_response(self, company_name: str) -> ToolResult:
|
||||
return ToolResult(
|
||||
success=True,
|
||||
data={
|
||||
"company_name": company_name,
|
||||
"industry": "制造业",
|
||||
"scale": "5000人",
|
||||
"revenue_range": "10-50亿",
|
||||
"news_summary": "近期无重大负面新闻",
|
||||
},
|
||||
meta={"mock": True, "reason": "external_api_unavailable"},
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from core.tools.base import ToolNotAllowedError, ToolResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.tools.registry import ToolRegistry
|
||||
|
||||
|
||||
class ToolExecutor:
|
||||
def __init__(self, registry: ToolRegistry, audit_logger: Any) -> None:
|
||||
self._registry = registry
|
||||
self._audit_logger = audit_logger
|
||||
|
||||
async def execute_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
raw_params: dict[str, Any],
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
data_scope: str,
|
||||
allowed_tools: list[str],
|
||||
trace_id: str,
|
||||
step_id: str,
|
||||
) -> ToolResult:
|
||||
if tool_name not in allowed_tools:
|
||||
await self._audit_logger.write_event(
|
||||
trace_id=trace_id,
|
||||
step_id=step_id,
|
||||
event_type="whitelist_violation",
|
||||
actor=user_id,
|
||||
tenant_id=tenant_id,
|
||||
tool_name=tool_name,
|
||||
status="violated",
|
||||
message=f"Tool '{tool_name}' not in profile whitelist",
|
||||
)
|
||||
raise ToolNotAllowedError(tool_name=tool_name, profile_name="profile_whitelist")
|
||||
|
||||
tool = self._registry.get(tool_name)
|
||||
result = await tool.execute(raw_params, user_id, tenant_id, data_scope)
|
||||
|
||||
await self._audit_logger.write_tool_call(
|
||||
trace_id=trace_id,
|
||||
step_id=step_id,
|
||||
tool_name=tool_name,
|
||||
result=result,
|
||||
actor=user_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.config import get_settings, is_production
|
||||
from core.tools.base import (
|
||||
BaseTool,
|
||||
Citation,
|
||||
RiskLevel,
|
||||
SideEffect,
|
||||
ToolDeclaration,
|
||||
ToolResult,
|
||||
)
|
||||
|
||||
|
||||
class RagRetrieverInput(BaseModel):
|
||||
query: str
|
||||
collection: str = "case_library"
|
||||
top_k: int = Field(default=5, ge=1, le=20)
|
||||
industry_filter: str | None = None
|
||||
tenant_id: str
|
||||
|
||||
|
||||
class RagCaseRetrieverTool(BaseTool):
|
||||
@property
|
||||
def declaration(self) -> ToolDeclaration:
|
||||
return ToolDeclaration(
|
||||
name="rag_case_retriever",
|
||||
description="Retrieve similar enterprise cases from Milvus",
|
||||
risk_level=RiskLevel.LOW,
|
||||
side_effects=[SideEffect.READ],
|
||||
max_duration_ms=8000,
|
||||
is_idempotent=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def input_schema(self) -> type[BaseModel]:
|
||||
return RagRetrieverInput
|
||||
|
||||
async def _execute_impl(
|
||||
self,
|
||||
params: BaseModel,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
data_scope: str,
|
||||
) -> ToolResult:
|
||||
input_data = RagRetrieverInput.model_validate(params)
|
||||
|
||||
if is_production():
|
||||
try:
|
||||
return self._real_search(input_data)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return ToolResult(success=False, error=str(exc))
|
||||
|
||||
try:
|
||||
return self._real_search(input_data)
|
||||
except Exception:
|
||||
return self._mock_response(input_data.query)
|
||||
|
||||
def _real_search(self, input_data: RagRetrieverInput) -> ToolResult:
|
||||
from pymilvus import MilvusClient
|
||||
|
||||
settings = get_settings()
|
||||
uri = f"http://{settings.MILVUS_HOST}:{settings.MILVUS_PORT}"
|
||||
client = MilvusClient(uri=uri, timeout=2.0)
|
||||
|
||||
filters = f"tenant_id == '{input_data.tenant_id}'"
|
||||
if input_data.industry_filter:
|
||||
filters = f"{filters} and industry == '{input_data.industry_filter}'"
|
||||
|
||||
result = client.query(
|
||||
collection_name=input_data.collection,
|
||||
filter=filters,
|
||||
limit=input_data.top_k,
|
||||
output_fields=["id", "source", "content_preview", "section", "score"],
|
||||
)
|
||||
|
||||
citations: list[Citation] = []
|
||||
for idx, row in enumerate(result):
|
||||
score_value = row.get("score", 0.5)
|
||||
citations.append(
|
||||
Citation(
|
||||
id=str(row.get("id", f"doc_{idx}")),
|
||||
source=str(row.get("source", "milvus")),
|
||||
relevance=Decimal(str(score_value)),
|
||||
content_preview=str(row.get("content_preview", "")),
|
||||
section=str(row.get("section", "")),
|
||||
verified=True,
|
||||
)
|
||||
)
|
||||
|
||||
return ToolResult(success=True, data={"query": input_data.query}, citations=citations)
|
||||
|
||||
def _mock_response(self, query: str) -> ToolResult:
|
||||
citations = [
|
||||
Citation(
|
||||
id="case-001",
|
||||
source="case_library/mock",
|
||||
relevance=Decimal("0.93"),
|
||||
content_preview="某制造企业通过流程改造将交付周期缩短 30%",
|
||||
section="背景",
|
||||
verified=True,
|
||||
),
|
||||
Citation(
|
||||
id="case-002",
|
||||
source="case_library/mock",
|
||||
relevance=Decimal("0.88"),
|
||||
content_preview="行业龙头部署智能质检后返工率下降",
|
||||
section="方案",
|
||||
verified=True,
|
||||
),
|
||||
Citation(
|
||||
id="case-003",
|
||||
source="case_library/mock",
|
||||
relevance=Decimal("0.84"),
|
||||
content_preview="区域工厂通过预测维护减少停机损失",
|
||||
section="结果",
|
||||
verified=False,
|
||||
),
|
||||
]
|
||||
return ToolResult(
|
||||
success=True,
|
||||
data={"query": query},
|
||||
citations=citations,
|
||||
meta={"mock": True},
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.tools.base import BaseTool, ToolNotFoundError
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._tools: dict[str, BaseTool] = {}
|
||||
|
||||
def register(self, tool: BaseTool) -> None:
|
||||
name = tool.declaration.name
|
||||
if name in self._tools:
|
||||
raise ValueError(f"Tool '{name}' is already registered")
|
||||
self._tools[name] = tool
|
||||
|
||||
def get(self, name: str) -> BaseTool:
|
||||
tool = self._tools.get(name)
|
||||
if tool is None:
|
||||
raise ToolNotFoundError(name)
|
||||
return tool
|
||||
|
||||
def validate_whitelist(self, tool_names: list[str]) -> None:
|
||||
missing = sorted([name for name in tool_names if name not in self._tools])
|
||||
if missing:
|
||||
raise ToolNotFoundError(",".join(missing))
|
||||
|
||||
def get_claude_tool_schemas(self, tool_names: list[str]) -> list[dict[str, object]]:
|
||||
self.validate_whitelist(tool_names)
|
||||
schemas: list[dict[str, object]] = []
|
||||
for name in tool_names:
|
||||
tool = self.get(name)
|
||||
schemas.append(
|
||||
{
|
||||
"name": tool.declaration.name,
|
||||
"description": tool.declaration.description,
|
||||
"input_schema": tool.input_schema.model_json_schema(),
|
||||
}
|
||||
)
|
||||
return schemas
|
||||
|
||||
def all_tool_names(self) -> list[str]:
|
||||
return sorted(self._tools.keys())
|
||||
|
||||
|
||||
_registry: ToolRegistry | None = None
|
||||
|
||||
|
||||
def get_registry() -> ToolRegistry:
|
||||
global _registry
|
||||
if _registry is None:
|
||||
_registry = ToolRegistry()
|
||||
return _registry
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import typing
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
create_async_engine as sa_create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from core.config import get_settings, is_development, is_test
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base for all ORM models."""
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def get_engine() -> AsyncEngine:
|
||||
"""Create and cache async engine from runtime settings."""
|
||||
|
||||
settings = get_settings()
|
||||
return sa_create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_size=settings.DB_POOL_SIZE,
|
||||
max_overflow=settings.DB_MAX_OVERFLOW,
|
||||
pool_timeout=settings.DB_POOL_TIMEOUT,
|
||||
pool_recycle=settings.DB_POOL_RECYCLE,
|
||||
)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||
"""Create and cache async session factory."""
|
||||
|
||||
engine = get_engine()
|
||||
return async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
AsyncSessionLocal = get_session_factory
|
||||
|
||||
|
||||
async def get_db() -> typing.AsyncGenerator[AsyncSession, None]:
|
||||
"""Dependency that yields an async database session."""
|
||||
|
||||
session_factory = get_session_factory()
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def init_db_for_dev() -> None:
|
||||
"""Initialize tables for development/test environment only."""
|
||||
|
||||
if not (is_development() or is_test()):
|
||||
raise RuntimeError("init_db_for_dev can only be called in development or test")
|
||||
|
||||
engine = get_engine()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from core.config import get_settings
|
||||
from db import models # noqa: F401
|
||||
from db.base import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
|
||||
def get_database_url() -> str:
|
||||
return get_settings().DATABASE_URL
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = get_database_url()
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=Base.metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
configuration = config.get_section(config.config_ini_section, {})
|
||||
configuration["sqlalchemy.url"] = get_database_url()
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=Base.metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,278 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 001_initial_schema
|
||||
Revises:
|
||||
Create Date: 2026-02-26
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "001_initial_schema"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto")
|
||||
|
||||
op.create_table(
|
||||
"trace_session",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column("trace_id", sa.String(length=128), nullable=False, unique=True),
|
||||
sa.Column("session_id", sa.String(length=128), nullable=False, unique=True),
|
||||
sa.Column("tenant_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("user_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("profile_type", sa.String(length=128), nullable=False),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default=sa.text("'pending'"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("final_output_ref", sa.String(length=512), nullable=True),
|
||||
sa.Column(
|
||||
"total_input_tokens",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
sa.Column(
|
||||
"total_output_tokens",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
sa.Column(
|
||||
"total_cost_cny",
|
||||
sa.Numeric(12, 4),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_trace_session_tenant_id", "trace_session", ["tenant_id"], unique=False)
|
||||
op.create_index(
|
||||
"ix_trace_session_tenant_created",
|
||||
"trace_session",
|
||||
["tenant_id", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"audit_event",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column("trace_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("step_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("actor", sa.String(length=128), nullable=False),
|
||||
sa.Column("tool_name", sa.String(length=128), nullable=True),
|
||||
sa.Column("status", sa.String(length=64), nullable=False),
|
||||
sa.Column("duration_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("error_message", sa.String(length=1000), nullable=True),
|
||||
sa.Column("evidence_score", sa.Numeric(5, 4), nullable=True),
|
||||
sa.Column("payload_ref", sa.String(length=512), nullable=True),
|
||||
sa.Column(
|
||||
"mock_flag",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column("tenant_id", sa.String(length=128), nullable=False),
|
||||
)
|
||||
op.create_index("ix_audit_event_trace_id", "audit_event", ["trace_id"], unique=False)
|
||||
op.create_index("ix_audit_event_tenant_id", "audit_event", ["tenant_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"audit_payload",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"audit_event_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("audit_event.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("payload_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("encrypted_data", sa.LargeBinary(), nullable=False),
|
||||
sa.Column("encryption_key_id", sa.String(length=64), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_audit_payload_audit_event_id",
|
||||
"audit_payload",
|
||||
["audit_event_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"pending_approval",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column("trace_id", sa.String(length=128), nullable=False, unique=True),
|
||||
sa.Column("session_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("step_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("tool_name", sa.String(length=128), nullable=False),
|
||||
sa.Column("action_description", sa.String(length=1000), nullable=False),
|
||||
sa.Column("risk_level", sa.String(length=64), nullable=False),
|
||||
sa.Column("requester_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("approver_role", sa.String(length=128), nullable=False),
|
||||
sa.Column("params_preview", sa.JSON(), nullable=False),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default=sa.text("'pending'"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"expired_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("(now() + interval '24 hours')"),
|
||||
),
|
||||
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("decider_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("decision_note", sa.String(length=1000), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_pending_approval_status_expired",
|
||||
"pending_approval",
|
||||
["status", "expired_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"cost_record",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column("trace_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("model", sa.String(length=128), nullable=False),
|
||||
sa.Column("input_tokens", sa.Integer(), nullable=False),
|
||||
sa.Column("output_tokens", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"cache_read_tokens",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
sa.Column(
|
||||
"cache_write_tokens",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
sa.Column("cost_usd", sa.Numeric(12, 6), nullable=False),
|
||||
sa.Column("cost_cny", sa.Numeric(12, 4), nullable=False),
|
||||
sa.Column(
|
||||
"invoke_mode",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default=sa.text("'realtime'"),
|
||||
),
|
||||
sa.Column(
|
||||
"recorded_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_cost_record_trace_id", "cost_record", ["trace_id"], unique=False)
|
||||
op.create_index("ix_cost_record_tenant_id", "cost_record", ["tenant_id"], unique=False)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION fn_audit_event_no_mutation() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'audit_event is append-only. % on id=%', TG_OP, OLD.id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER trg_audit_event_no_mutation
|
||||
BEFORE UPDATE OR DELETE ON audit_event
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION fn_audit_event_no_mutation();
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TRIGGER IF EXISTS trg_audit_event_no_mutation ON audit_event")
|
||||
op.execute("DROP FUNCTION IF EXISTS fn_audit_event_no_mutation")
|
||||
|
||||
op.drop_index("ix_cost_record_tenant_id", table_name="cost_record")
|
||||
op.drop_index("ix_cost_record_trace_id", table_name="cost_record")
|
||||
op.drop_table("cost_record")
|
||||
|
||||
op.drop_index("ix_pending_approval_status_expired", table_name="pending_approval")
|
||||
op.drop_table("pending_approval")
|
||||
|
||||
op.drop_index("ix_audit_payload_audit_event_id", table_name="audit_payload")
|
||||
op.drop_table("audit_payload")
|
||||
|
||||
op.drop_index("ix_audit_event_tenant_id", table_name="audit_event")
|
||||
op.drop_index("ix_audit_event_trace_id", table_name="audit_event")
|
||||
op.drop_table("audit_event")
|
||||
|
||||
op.drop_index("ix_trace_session_tenant_created", table_name="trace_session")
|
||||
op.drop_index("ix_trace_session_tenant_id", table_name="trace_session")
|
||||
op.drop_table("trace_session")
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import datetime # noqa: TC003
|
||||
from decimal import Decimal # noqa: TC003
|
||||
from uuid import UUID # noqa: TC003
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
Numeric,
|
||||
String,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from db.base import Base
|
||||
|
||||
|
||||
class SessionStatus(str, enum.Enum): # noqa: UP042
|
||||
pending = "pending"
|
||||
running = "running"
|
||||
waiting_approval = "waiting_approval"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class ApprovalStatus(str, enum.Enum): # noqa: UP042
|
||||
pending = "pending"
|
||||
approved = "approved"
|
||||
rejected = "rejected"
|
||||
expired = "expired"
|
||||
|
||||
|
||||
class InvokeMode(str, enum.Enum): # noqa: UP042
|
||||
realtime = "realtime"
|
||||
cached = "cached"
|
||||
batch = "batch"
|
||||
|
||||
|
||||
class TraceSession(Base):
|
||||
__tablename__ = "trace_session"
|
||||
__table_args__ = (Index("ix_trace_session_tenant_created", "tenant_id", "created_at"),)
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=text("gen_random_uuid()"),
|
||||
)
|
||||
trace_id: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
|
||||
session_id: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
|
||||
tenant_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
profile_type: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
status: Mapped[SessionStatus] = mapped_column(
|
||||
Enum(SessionStatus, name="session_status", native_enum=False),
|
||||
nullable=False,
|
||||
server_default=text("'pending'"),
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
final_output_ref: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
total_input_tokens: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default=text("0"),
|
||||
)
|
||||
total_output_tokens: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default=text("0"),
|
||||
)
|
||||
total_cost_cny: Mapped[Decimal] = mapped_column(
|
||||
Numeric(12, 4),
|
||||
nullable=False,
|
||||
server_default=text("0"),
|
||||
)
|
||||
|
||||
|
||||
class AuditEvent(Base):
|
||||
# APPEND-ONLY: enforced by DB trigger trg_audit_event_no_mutation
|
||||
__tablename__ = "audit_event"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=text("gen_random_uuid()"),
|
||||
)
|
||||
trace_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
step_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
actor: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
tool_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
evidence_score: Mapped[Decimal | None] = mapped_column(Numeric(5, 4), nullable=True)
|
||||
payload_ref: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
mock_flag: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
tenant_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
|
||||
|
||||
class AuditPayload(Base):
|
||||
__tablename__ = "audit_payload"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=text("gen_random_uuid()"),
|
||||
)
|
||||
audit_event_id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("audit_event.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
payload_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
encrypted_data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||
encryption_key_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
|
||||
|
||||
class PendingApproval(Base):
|
||||
__tablename__ = "pending_approval"
|
||||
__table_args__ = (Index("ix_pending_approval_status_expired", "status", "expired_at"),)
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=text("gen_random_uuid()"),
|
||||
)
|
||||
trace_id: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
|
||||
session_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
step_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
tool_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
action_description: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
risk_level: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
requester_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
approver_role: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
params_preview: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False)
|
||||
status: Mapped[ApprovalStatus] = mapped_column(
|
||||
Enum(ApprovalStatus, name="approval_status", native_enum=False),
|
||||
nullable=False,
|
||||
server_default=text("'pending'"),
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
expired_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=text("(now() + interval '24 hours')"),
|
||||
)
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
decider_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
decision_note: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
|
||||
|
||||
class CostRecord(Base):
|
||||
__tablename__ = "cost_record"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=text("gen_random_uuid()"),
|
||||
)
|
||||
trace_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
model: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
input_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
output_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
cache_read_tokens: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default=text("0"),
|
||||
)
|
||||
cache_write_tokens: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default=text("0"),
|
||||
)
|
||||
cost_usd: Mapped[Decimal] = mapped_column(Numeric(12, 6), nullable=False)
|
||||
cost_cny: Mapped[Decimal] = mapped_column(Numeric(12, 4), nullable=False)
|
||||
invoke_mode: Mapped[InvokeMode] = mapped_column(
|
||||
Enum(InvokeMode, name="invoke_mode", native_enum=False),
|
||||
nullable=False,
|
||||
server_default=text("'realtime'"),
|
||||
)
|
||||
recorded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
|
||||
|
||||
TraceSession.table = TraceSession.__table__
|
||||
AuditEvent.table = AuditEvent.__table__
|
||||
AuditPayload.table = AuditPayload.__table__
|
||||
PendingApproval.table = PendingApproval.__table__
|
||||
CostRecord.table = CostRecord.__table__
|
||||
@@ -0,0 +1,13 @@
|
||||
from db.repositories.audit_event import AuditEventRepository
|
||||
from db.repositories.audit_payload import AuditPayloadRepository
|
||||
from db.repositories.cost_record import CostRecordRepository
|
||||
from db.repositories.pending_approval import PendingApprovalRepository
|
||||
from db.repositories.trace_session import TraceSessionRepository
|
||||
|
||||
__all__ = [
|
||||
"TraceSessionRepository",
|
||||
"AuditEventRepository",
|
||||
"AuditPayloadRepository",
|
||||
"PendingApprovalRepository",
|
||||
"CostRecordRepository",
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from db.models import AuditEvent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class AuditEventRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def create(self, audit_event: AuditEvent) -> AuditEvent:
|
||||
self._session.add(audit_event)
|
||||
await self._session.flush()
|
||||
await self._session.refresh(audit_event)
|
||||
return audit_event
|
||||
|
||||
async def get_timeline_by_trace(self, trace_id: str) -> list[AuditEvent]:
|
||||
stmt = (
|
||||
select(AuditEvent)
|
||||
.where(AuditEvent.trace_id == trace_id)
|
||||
.order_by(AuditEvent.created_at.asc())
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_tenant_type(
|
||||
self,
|
||||
tenant_id: str,
|
||||
event_type: str,
|
||||
limit: int = 100,
|
||||
) -> list[AuditEvent]:
|
||||
stmt = (
|
||||
select(AuditEvent)
|
||||
.where(AuditEvent.tenant_id == tenant_id, AuditEvent.event_type == event_type)
|
||||
.order_by(AuditEvent.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from db.models import AuditPayload
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.audit.encryptor import Encryptor
|
||||
|
||||
|
||||
class AuditPayloadRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def create(self, payload: AuditPayload) -> AuditPayload:
|
||||
self._session.add(payload)
|
||||
await self._session.flush()
|
||||
await self._session.refresh(payload)
|
||||
return payload
|
||||
|
||||
async def get_by_event_id(self, audit_event_id: str) -> list[AuditPayload]:
|
||||
stmt = (
|
||||
select(AuditPayload)
|
||||
.where(AuditPayload.audit_event_id == audit_event_id)
|
||||
.order_by(AuditPayload.created_at.asc())
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def decrypt_payload(self, payload: AuditPayload, encryptor: Encryptor) -> bytes:
|
||||
return encryptor.decrypt(payload.encrypted_data, payload.encryption_key_id)
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from db.models import CostRecord
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class MonthlySummary(TypedDict):
|
||||
month: datetime
|
||||
total_cost_cny: Decimal
|
||||
total_cost_usd: Decimal
|
||||
|
||||
|
||||
class CostRecordRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def create(self, cost_record: CostRecord) -> CostRecord:
|
||||
self._session.add(cost_record)
|
||||
await self._session.flush()
|
||||
await self._session.refresh(cost_record)
|
||||
return cost_record
|
||||
|
||||
async def get_daily_by_tenant(self, tenant_id: str, target_day: date) -> Decimal:
|
||||
stmt = select(func.coalesce(func.sum(CostRecord.cost_cny), Decimal("0"))).where(
|
||||
CostRecord.tenant_id == tenant_id,
|
||||
func.date(CostRecord.recorded_at) == target_day,
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
value = result.scalar_one()
|
||||
return Decimal(value)
|
||||
|
||||
async def get_monthly_summary(self, tenant_id: str) -> list[MonthlySummary]:
|
||||
month_col = func.date_trunc("month", CostRecord.recorded_at).label("month")
|
||||
total_cny_col = func.coalesce(func.sum(CostRecord.cost_cny), Decimal("0")).label(
|
||||
"total_cost_cny"
|
||||
)
|
||||
total_usd_col = func.coalesce(func.sum(CostRecord.cost_usd), Decimal("0")).label(
|
||||
"total_cost_usd"
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(month_col, total_cny_col, total_usd_col)
|
||||
.where(CostRecord.tenant_id == tenant_id)
|
||||
.group_by(month_col)
|
||||
.order_by(month_col.desc())
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
|
||||
summaries: list[MonthlySummary] = []
|
||||
for month, total_cny, total_usd in result.all():
|
||||
summaries.append(
|
||||
{
|
||||
"month": month,
|
||||
"total_cost_cny": Decimal(total_cny),
|
||||
"total_cost_usd": Decimal(total_usd),
|
||||
}
|
||||
)
|
||||
return summaries
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from db.models import ApprovalStatus, PendingApproval
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class PendingApprovalRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def create(self, pending_approval: PendingApproval) -> PendingApproval:
|
||||
self._session.add(pending_approval)
|
||||
await self._session.flush()
|
||||
await self._session.refresh(pending_approval)
|
||||
return pending_approval
|
||||
|
||||
async def get_pending_by_trace(self, trace_id: str) -> PendingApproval | None:
|
||||
stmt = select(PendingApproval).where(
|
||||
PendingApproval.trace_id == trace_id,
|
||||
PendingApproval.status == ApprovalStatus.pending,
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def update_decision(
|
||||
self,
|
||||
trace_id: str,
|
||||
status: ApprovalStatus,
|
||||
decider_id: str,
|
||||
decision_note: str | None = None,
|
||||
) -> PendingApproval | None:
|
||||
pending = await self.get_pending_by_trace(trace_id)
|
||||
if pending is None:
|
||||
return None
|
||||
|
||||
pending.status = status
|
||||
pending.decider_id = decider_id
|
||||
pending.decision_note = decision_note
|
||||
pending.decided_at = datetime.now(UTC)
|
||||
|
||||
await self._session.flush()
|
||||
await self._session.refresh(pending)
|
||||
return pending
|
||||
|
||||
async def expire_stale(self, as_of: datetime | None = None) -> int:
|
||||
target = as_of if as_of is not None else datetime.now(UTC)
|
||||
|
||||
stmt = select(PendingApproval).where(
|
||||
PendingApproval.status == ApprovalStatus.pending,
|
||||
PendingApproval.expired_at < target,
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
for row in rows:
|
||||
row.status = ApprovalStatus.expired
|
||||
row.decided_at = target
|
||||
|
||||
await self._session.flush()
|
||||
return len(rows)
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from db.models import SessionStatus, TraceSession
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class TraceSessionRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def create(self, trace_session: TraceSession) -> TraceSession:
|
||||
self._session.add(trace_session)
|
||||
await self._session.flush()
|
||||
await self._session.refresh(trace_session)
|
||||
return trace_session
|
||||
|
||||
async def get_by_trace_id(self, trace_id: str) -> TraceSession | None:
|
||||
stmt = select(TraceSession).where(TraceSession.trace_id == trace_id)
|
||||
result = await self._session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def update_status(
|
||||
self,
|
||||
trace_id: str,
|
||||
status: SessionStatus,
|
||||
completed_at: datetime | None = None,
|
||||
final_output_ref: str | None = None,
|
||||
) -> TraceSession | None:
|
||||
trace = await self.get_by_trace_id(trace_id)
|
||||
if trace is None:
|
||||
return None
|
||||
|
||||
trace.status = status
|
||||
if completed_at is not None:
|
||||
trace.completed_at = completed_at
|
||||
if final_output_ref is not None:
|
||||
trace.final_output_ref = final_output_ref
|
||||
|
||||
await self._session.flush()
|
||||
await self._session.refresh(trace)
|
||||
return trace
|
||||
|
||||
async def list_by_tenant(
|
||||
self,
|
||||
tenant_id: str,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[TraceSession]:
|
||||
stmt = (
|
||||
select(TraceSession)
|
||||
.where(TraceSession.tenant_id == tenant_id)
|
||||
.order_by(TraceSession.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,177 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: agent_platform
|
||||
POSTGRES_USER: agent
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-CHANGE_ME}
|
||||
ports:
|
||||
- "15432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U agent -d agent_platform"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
|
||||
ports:
|
||||
- "16379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
etcd:
|
||||
image: quay.io/coreos/etcd:v3.5.5
|
||||
command: >-
|
||||
etcd -advertise-client-urls=http://127.0.0.1:2379
|
||||
-listen-client-urls=http://0.0.0.0:2379
|
||||
--data-dir /etcd
|
||||
ports:
|
||||
- "2379:2379"
|
||||
volumes:
|
||||
- etcd_data:/etcd
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "etcdctl endpoint health --endpoints=http://127.0.0.1:2379"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2024-01-13T07-53-03Z
|
||||
command: server /data --console-address :9001
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"mc ready local >/dev/null 2>&1 || curl -fsS http://localhost:9000/minio/health/live >/dev/null",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
milvus:
|
||||
image: milvusdb/milvus:v2.4.15
|
||||
command: ["milvus", "run", "standalone"]
|
||||
environment:
|
||||
ETCD_ENDPOINTS: etcd:2379
|
||||
MINIO_ADDRESS: minio:9000
|
||||
MILVUS_LOG_LEVEL: info
|
||||
depends_on:
|
||||
etcd:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "19531:19530"
|
||||
- "19091:9091"
|
||||
volumes:
|
||||
- milvus_data:/var/lib/milvus
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:9091/healthz"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
|
||||
otel-collector:
|
||||
image: otel/opentelemetry-collector-contrib:0.110.0
|
||||
command: ["--config=/etc/otel/config.yaml"]
|
||||
volumes:
|
||||
- ./docker/otel-collector-config.yml:/etc/otel/config.yaml:ro
|
||||
ports:
|
||||
- "4317:4317"
|
||||
- "4318:4318"
|
||||
- "13133:13133"
|
||||
- "8889:8889"
|
||||
healthcheck:
|
||||
test: ["CMD", "/otelcol-contrib", "--version"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 2
|
||||
start_period: 1s
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.54.0
|
||||
command: ["--config.file=/etc/prometheus/prometheus.yml"]
|
||||
volumes:
|
||||
- ./docker/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- prometheus_data:/prometheus
|
||||
ports:
|
||||
- "19090:9090"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O - http://localhost:9090/-/ready >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:11.2.0
|
||||
environment:
|
||||
GF_AUTH_ANONYMOUS_ENABLED: "true"
|
||||
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
|
||||
volumes:
|
||||
- ./core/observability/dashboards/:/var/lib/grafana/dashboards/
|
||||
- grafana_data:/var/lib/grafana
|
||||
ports:
|
||||
- "13000:3000"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/health >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.api
|
||||
target: builder
|
||||
command: uv run --frozen uvicorn api.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://agent:${POSTGRES_PASSWORD:-CHANGE_ME}@postgres:5432/agent_platform}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||
MILVUS_HOST: milvus
|
||||
MILVUS_PORT: 19530
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
milvus:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/.venv
|
||||
ports:
|
||||
- "18000:8000"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
etcd_data:
|
||||
minio_data:
|
||||
milvus_data:
|
||||
prometheus_data:
|
||||
grafana_data:
|
||||
@@ -0,0 +1,44 @@
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
ENV PATH="/app/.venv/bin:${PATH}"
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir uv
|
||||
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
COPY core ./core
|
||||
COPY api ./api
|
||||
COPY db ./db
|
||||
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
|
||||
FROM python:3.11-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd -m -u 1000 appuser
|
||||
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
COPY . /app
|
||||
|
||||
ENV PATH="/app/.venv/bin:${PATH}"
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=3s --retries=10 CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
RUN chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
CMD ["python", "-m", "uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,34 @@
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: 0.0.0.0:4317
|
||||
http:
|
||||
endpoint: 0.0.0.0:4318
|
||||
|
||||
processors:
|
||||
batch:
|
||||
timeout: 1s
|
||||
send_batch_size: 1024
|
||||
|
||||
exporters:
|
||||
prometheus:
|
||||
endpoint: 0.0.0.0:8889
|
||||
logging:
|
||||
verbosity: normal
|
||||
|
||||
extensions:
|
||||
health_check:
|
||||
endpoint: 0.0.0.0:13133
|
||||
|
||||
service:
|
||||
extensions: [health_check]
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [logging]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [prometheus, logging]
|
||||
@@ -0,0 +1,11 @@
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: agent-platform
|
||||
static_configs:
|
||||
- targets: [api:8000]
|
||||
|
||||
- job_name: otel-collector
|
||||
static_configs:
|
||||
- targets: [otel-collector:8889]
|
||||
@@ -0,0 +1,83 @@
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.25.0"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "agent-platform"
|
||||
version = "0.1.0"
|
||||
description = "Enterprise agent platform skeleton"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"anthropic>=0.50.0",
|
||||
"langgraph>=0.3.0",
|
||||
"langgraph-checkpoint-postgres>=0.1.0",
|
||||
"langchain-core>=0.3.0",
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
"python-multipart>=0.0.12",
|
||||
"pydantic>=2.8.0",
|
||||
"pydantic-settings>=2.4.0",
|
||||
"sqlalchemy[asyncio]>=2.0.30",
|
||||
"asyncpg>=0.29.0",
|
||||
"alembic>=1.13.0",
|
||||
"redis[hiredis]>=5.0.0",
|
||||
"pymilvus>=2.4.0",
|
||||
"opentelemetry-sdk>=1.27.0",
|
||||
"opentelemetry-exporter-otlp>=1.27.0",
|
||||
"opentelemetry-exporter-prometheus>=0.48b0",
|
||||
"opentelemetry-instrumentation-fastapi>=0.48b0",
|
||||
"opentelemetry-instrumentation-sqlalchemy>=0.48b0",
|
||||
"opentelemetry-instrumentation-httpx>=0.48b0",
|
||||
"prometheus-client>=0.21.0",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"cryptography>=42.0.0",
|
||||
"python-docx>=1.1.2",
|
||||
"httpx>=0.27.0",
|
||||
"aiofiles>=24.1.0",
|
||||
"tenacity>=9.0.0",
|
||||
"pyyaml>=6.0.2",
|
||||
"orjson>=3.10.0",
|
||||
"structlog>=24.4.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = [
|
||||
"pytest>=8.3.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"pytest-cov>=5.0.0",
|
||||
"pytest-mock>=3.14.0",
|
||||
"respx>=0.21.0",
|
||||
"testcontainers[postgresql]>=4.8.0",
|
||||
"ruff>=0.6.0",
|
||||
"mypy>=1.11.0",
|
||||
"pre-commit>=3.8.0",
|
||||
"types-pyyaml>=6.0.12",
|
||||
"types-passlib>=1.7.7",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
markers = [
|
||||
"claude_api: tests requiring Anthropic Claude API",
|
||||
"slow: slow tests",
|
||||
"integration: integration tests",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "UP", "B", "SIM", "TCH"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
no_implicit_optional = true
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["core", "api", "db"]
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from anthropic import Anthropic
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run an end-to-end Claude inference check via Anthropic-compatible endpoint."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default=os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6-20260217"),
|
||||
help="Model name to use (default: ANTHROPIC_MODEL env or claude-sonnet-4-6-20260217).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
default=os.getenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com"),
|
||||
help="Anthropic-compatible base URL (default: ANTHROPIC_BASE_URL env).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-tokens",
|
||||
type=int,
|
||||
default=32,
|
||||
help="Max output tokens for the check request.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prompt",
|
||||
default="Reply with exactly: CLAUDE_OK",
|
||||
help="Prompt for the check request.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
auth_token = os.getenv("ANTHROPIC_AUTH_TOKEN", "").strip()
|
||||
if not auth_token:
|
||||
print("ERROR: ANTHROPIC_AUTH_TOKEN is empty or missing.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
client = Anthropic(api_key=auth_token, base_url=args.base_url)
|
||||
try:
|
||||
response = client.messages.create(
|
||||
model=args.model,
|
||||
max_tokens=args.max_tokens,
|
||||
messages=[{"role": "user", "content": args.prompt}],
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"FAIL: Claude inference request failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
text_parts = [block.text for block in response.content if getattr(block, "type", "") == "text"]
|
||||
text = " ".join(text_parts).strip()
|
||||
|
||||
print("PASS: Claude inference request succeeded.")
|
||||
print(f"model={response.model}")
|
||||
print(f"stop_reason={response.stop_reason}")
|
||||
print(f"text={text}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import secrets
|
||||
|
||||
|
||||
def generate_base64_key() -> str:
|
||||
key_bytes = secrets.token_bytes(32)
|
||||
return base64.b64encode(key_bytes).decode("utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Generate AES-256-GCM audit keyring values")
|
||||
parser.add_argument("--key-id", default="k1", help="Key ID to generate")
|
||||
args = parser.parse_args()
|
||||
|
||||
key_b64 = generate_base64_key()
|
||||
keyring = {args.key_id: key_b64}
|
||||
|
||||
print("# Base64 key (32 bytes decoded):")
|
||||
print(key_b64)
|
||||
print("\n# JSON for AUDIT_KEYRING_JSON:")
|
||||
print(json.dumps(keyring, ensure_ascii=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
PASS=0
|
||||
TOTAL=6
|
||||
|
||||
print_ok() {
|
||||
echo "✅ $1"
|
||||
PASS=$((PASS + 1))
|
||||
}
|
||||
|
||||
print_fail() {
|
||||
echo "❌ $1"
|
||||
}
|
||||
|
||||
if docker compose exec -T postgres psql -U agent -d agent_platform -c "SELECT 1" >/dev/null 2>&1; then
|
||||
print_ok "PostgreSQL: psql -c \"SELECT 1\""
|
||||
else
|
||||
print_fail "PostgreSQL: psql -c \"SELECT 1\""
|
||||
fi
|
||||
|
||||
if redis-cli -u "${REDIS_URL:-redis://localhost:6379/0}" ping >/dev/null 2>&1; then
|
||||
print_ok "Redis: redis-cli -u \$REDIS_URL ping"
|
||||
else
|
||||
print_fail "Redis: redis-cli -u \$REDIS_URL ping"
|
||||
fi
|
||||
|
||||
if curl -sf "http://${MILVUS_HOST:-localhost}:9091/healthz" >/dev/null 2>&1; then
|
||||
print_ok "Milvus: curl -sf http://\$MILVUS_HOST:9091/healthz"
|
||||
else
|
||||
print_fail "Milvus: curl -sf http://\$MILVUS_HOST:9091/healthz"
|
||||
fi
|
||||
|
||||
if curl -sf "http://localhost:8000/health" >/dev/null 2>&1 || \
|
||||
curl -sf "http://localhost:18000/health" >/dev/null 2>&1; then
|
||||
print_ok "API /health: curl -sf http://localhost:8000/health"
|
||||
else
|
||||
print_fail "API /health: curl -sf http://localhost:8000/health"
|
||||
fi
|
||||
|
||||
if uv run python scripts/check_claude_inference.py >/dev/null 2>&1; then
|
||||
print_ok "Claude API: messages.create inference check"
|
||||
else
|
||||
print_fail "Claude API: messages.create inference check"
|
||||
fi
|
||||
|
||||
if curl -sf "http://localhost:13133/" >/dev/null 2>&1 || \
|
||||
curl -sf "http://localhost:13133/health/status" >/dev/null 2>&1; then
|
||||
print_ok "OTel collector: curl -sf http://localhost:13133/"
|
||||
else
|
||||
print_fail "OTel collector: curl -sf http://localhost:13133/"
|
||||
fi
|
||||
|
||||
echo "${PASS}/${TOTAL} passed"
|
||||
if [ "$PASS" -ne "$TOTAL" ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("seed_data: no-op")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
wait_for_healthy() {
|
||||
local timeout=120
|
||||
local elapsed=0
|
||||
|
||||
while [ "$elapsed" -lt "$timeout" ]; do
|
||||
local output
|
||||
output="$(docker compose ps --format '{{.Name}} {{.Status}}' || true)"
|
||||
|
||||
if [ -n "$output" ] && ! echo "$output" | grep -Eiq 'unhealthy|starting|restarting|created|exited|dead'; then
|
||||
echo "✅ docker compose 服务状态正常"
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep 3
|
||||
elapsed=$((elapsed + 3))
|
||||
done
|
||||
|
||||
echo "❌ 等待服务健康超时(${timeout}s)"
|
||||
docker compose ps
|
||||
return 1
|
||||
}
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "❌ 未检测到 docker"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
echo "❌ 未检测到 uv"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "⚠️ 未发现 .env,请基于 .env.example 创建后重试。"
|
||||
if [ -f .env.example ]; then
|
||||
cp .env.example .env
|
||||
echo "已自动复制 .env.example -> .env,请检查并修改敏感配置。"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "启动 Docker 服务..."
|
||||
docker compose up -d --wait
|
||||
wait_for_healthy
|
||||
|
||||
echo "同步依赖..."
|
||||
uv sync --frozen
|
||||
|
||||
echo "执行数据库迁移..."
|
||||
uv run alembic upgrade head
|
||||
|
||||
echo "执行种子数据脚本..."
|
||||
uv run python scripts/seed_data.py
|
||||
|
||||
echo "运行单元测试..."
|
||||
uv run pytest tests/unit/ -q
|
||||
|
||||
echo "✅ 环境初始化完成"
|
||||
echo "服务访问地址:"
|
||||
echo "- API: http://localhost:8000"
|
||||
echo "- API Health: http://localhost:8000/health"
|
||||
echo "- Grafana: http://localhost:3000"
|
||||
echo "- Prometheus: http://localhost:9090"
|
||||
echo "- MinIO Console: http://localhost:9001"
|
||||
echo "- Milvus: localhost:19530"
|
||||
@@ -0,0 +1,7 @@
|
||||
ENVIRONMENT=test
|
||||
DATABASE_URL=postgresql+asyncpg://test:test@localhost:5432/agent_platform_test
|
||||
REDIS_URL=redis://localhost:6379/1
|
||||
JWT_ALGORITHM=HS256
|
||||
LOG_LEVEL=WARNING
|
||||
MAX_DAILY_COST_CNY=999999.00
|
||||
# ANTHROPIC_API_KEY / JWT_SECRET_KEY / AUDIT_KEYRING_JSON 由 conftest fixture 注入
|
||||
@@ -0,0 +1,11 @@
|
||||
# tests/AGENTS.md
|
||||
|
||||
## 测试分层
|
||||
- `unit/`: 纯函数与小范围组件测试,禁止外部依赖。
|
||||
- `integration/`: 数据库、缓存、向量库、第三方 API 集成测试。
|
||||
- `contracts/`: API 请求响应与权限矩阵契约测试。
|
||||
|
||||
## 约束
|
||||
- 测试中密钥统一由 fixture 注入,不从真实环境读取。
|
||||
- 测试数据必须可重复、可清理。
|
||||
- 对审计、鉴权、预算阈值相关逻辑优先补全边界测试。
|
||||
@@ -0,0 +1,23 @@
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from core.config import get_settings
|
||||
|
||||
TEST_JWT_SECRET = "test_jwt_secret_key_32chars_padded"
|
||||
TEST_AUDIT_KEY_B64 = base64.b64encode(b"T" * 32).decode()
|
||||
TEST_KEYRING_JSON = json.dumps({"test_key_1": TEST_AUDIT_KEY_B64})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def settings_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "sk-ant-test-not-real")
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com")
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test_db")
|
||||
monkeypatch.setenv("JWT_SECRET_KEY", TEST_JWT_SECRET)
|
||||
monkeypatch.setenv("AUDIT_KEYRING_JSON", TEST_KEYRING_JSON)
|
||||
monkeypatch.setenv("AUDIT_ACTIVE_KEY_ID", "test_key_1")
|
||||
get_settings.cache_clear()
|
||||
yield
|
||||
get_settings.cache_clear()
|
||||
Vendored
@@ -0,0 +1,7 @@
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def test_integration_suite_is_discoverable() -> None:
|
||||
assert True
|
||||
@@ -0,0 +1,42 @@
|
||||
def test_audit_event_repo_no_mutation_methods() -> None:
|
||||
from db.repositories.audit_event import AuditEventRepository
|
||||
|
||||
methods = [m for m in dir(AuditEventRepository) if not m.startswith("_")]
|
||||
for bad in ["update", "delete", "remove", "modify", "patch", "edit"]:
|
||||
assert bad not in methods, f"AuditEventRepository 不能有 {bad} 方法"
|
||||
|
||||
|
||||
def test_all_cost_fields_are_numeric_type() -> None:
|
||||
import sqlalchemy as sa
|
||||
|
||||
from db.models import CostRecord
|
||||
|
||||
cols = {c.name: c for c in CostRecord.table.columns}
|
||||
assert isinstance(cols["cost_usd"].type, sa.Numeric)
|
||||
assert isinstance(cols["cost_cny"].type, sa.Numeric)
|
||||
|
||||
|
||||
def test_session_status_values() -> None:
|
||||
from db.models import SessionStatus
|
||||
|
||||
assert set(e.value for e in SessionStatus) == {
|
||||
"pending",
|
||||
"running",
|
||||
"waiting_approval",
|
||||
"completed",
|
||||
"failed",
|
||||
}
|
||||
|
||||
|
||||
def test_audit_event_has_mock_flag_column() -> None:
|
||||
from db.models import AuditEvent
|
||||
|
||||
cols = {c.name for c in AuditEvent.table.columns}
|
||||
assert "mock_flag" in cols
|
||||
|
||||
|
||||
def test_pending_approval_has_expired_at() -> None:
|
||||
from db.models import PendingApproval
|
||||
|
||||
cols = {c.name for c in PendingApproval.table.columns}
|
||||
assert "expired_at" in cols
|
||||
@@ -0,0 +1,509 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import builtins
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.tools.doc_generator import WordDocGeneratorTool
|
||||
from core.tools.enterprise_info import EnterpriseInfoInput, EnterpriseInfoSearchTool
|
||||
from core.tools.base import (
|
||||
BaseTool,
|
||||
RiskLevel,
|
||||
SideEffect,
|
||||
ToolDeclaration,
|
||||
ToolNotAllowedError,
|
||||
ToolNotFoundError,
|
||||
ToolResult,
|
||||
)
|
||||
from core.tools.rag_retriever import RagCaseRetrieverTool
|
||||
from core.tools.executor import ToolExecutor
|
||||
from core.tools.registry import ToolRegistry, get_registry
|
||||
|
||||
|
||||
class _SimpleInput(BaseModel):
|
||||
q: str = "x"
|
||||
|
||||
|
||||
class _RaisingTool(BaseTool):
|
||||
@property
|
||||
def declaration(self) -> ToolDeclaration:
|
||||
return ToolDeclaration(name="raising", description="raising")
|
||||
|
||||
@property
|
||||
def input_schema(self) -> type[BaseModel]:
|
||||
return _SimpleInput
|
||||
|
||||
async def _execute_impl(
|
||||
self,
|
||||
params: BaseModel,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
data_scope: str,
|
||||
) -> ToolResult:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
class _TimeoutTool(BaseTool):
|
||||
@property
|
||||
def declaration(self) -> ToolDeclaration:
|
||||
return ToolDeclaration(name="timeout", description="timeout", max_duration_ms=10)
|
||||
|
||||
@property
|
||||
def input_schema(self) -> type[BaseModel]:
|
||||
return _SimpleInput
|
||||
|
||||
async def _execute_impl(
|
||||
self,
|
||||
params: BaseModel,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
data_scope: str,
|
||||
) -> ToolResult:
|
||||
await asyncio.sleep(0.1)
|
||||
return ToolResult(success=True)
|
||||
|
||||
|
||||
class _RetryTool(BaseTool):
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
@property
|
||||
def declaration(self) -> ToolDeclaration:
|
||||
return ToolDeclaration(name="retry", description="retry")
|
||||
|
||||
@property
|
||||
def input_schema(self) -> type[BaseModel]:
|
||||
return _SimpleInput
|
||||
|
||||
async def _execute_impl(
|
||||
self,
|
||||
params: BaseModel,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
data_scope: str,
|
||||
) -> ToolResult:
|
||||
self.calls += 1
|
||||
raise ConnectionError("down")
|
||||
|
||||
|
||||
class _ValueErrorTool(BaseTool):
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
@property
|
||||
def declaration(self) -> ToolDeclaration:
|
||||
return ToolDeclaration(name="value", description="value")
|
||||
|
||||
@property
|
||||
def input_schema(self) -> type[BaseModel]:
|
||||
return _SimpleInput
|
||||
|
||||
async def _execute_impl(
|
||||
self,
|
||||
params: BaseModel,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
data_scope: str,
|
||||
) -> ToolResult:
|
||||
self.calls += 1
|
||||
raise ValueError("bad")
|
||||
|
||||
|
||||
class _SuccessTool(BaseTool):
|
||||
@property
|
||||
def declaration(self) -> ToolDeclaration:
|
||||
return ToolDeclaration(name="ok_tool", description="ok")
|
||||
|
||||
@property
|
||||
def input_schema(self) -> type[BaseModel]:
|
||||
return _SimpleInput
|
||||
|
||||
async def _execute_impl(
|
||||
self,
|
||||
params: BaseModel,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
data_scope: str,
|
||||
) -> ToolResult:
|
||||
return ToolResult(success=True, data={"ok": True})
|
||||
|
||||
|
||||
class _FailTool(BaseTool):
|
||||
@property
|
||||
def declaration(self) -> ToolDeclaration:
|
||||
return ToolDeclaration(name="fail_tool", description="fail")
|
||||
|
||||
@property
|
||||
def input_schema(self) -> type[BaseModel]:
|
||||
return _SimpleInput
|
||||
|
||||
async def _execute_impl(
|
||||
self,
|
||||
params: BaseModel,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
data_scope: str,
|
||||
) -> ToolResult:
|
||||
return ToolResult(success=False, error="x")
|
||||
|
||||
|
||||
class _AuditLogger:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[dict[str, Any]] = []
|
||||
self.tool_calls: list[dict[str, Any]] = []
|
||||
|
||||
async def write_event(self, **kwargs: Any) -> None:
|
||||
self.events.append(kwargs)
|
||||
|
||||
async def write_tool_call(self, **kwargs: Any) -> None:
|
||||
self.tool_calls.append(kwargs)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry() -> Iterator[ToolRegistry]:
|
||||
yield ToolRegistry()
|
||||
|
||||
|
||||
def test_execute_never_raises_when_impl_raises() -> None:
|
||||
tool = _RaisingTool()
|
||||
result = asyncio.run(tool.execute({"q": "x"}, "u", "t", "scope"))
|
||||
assert result.success is False
|
||||
|
||||
|
||||
def test_execute_timeout_returns_failure_result() -> None:
|
||||
tool = _TimeoutTool()
|
||||
result = asyncio.run(tool.execute({"q": "x"}, "u", "t", "scope"))
|
||||
assert result.success is False
|
||||
assert result.error == "tool_execution_timeout"
|
||||
|
||||
|
||||
def test_execute_retries_twice_on_connection_error() -> None:
|
||||
tool = _RetryTool()
|
||||
result = asyncio.run(tool.execute({"q": "x"}, "u", "t", "scope"))
|
||||
assert result.success is False
|
||||
assert tool.calls == 2
|
||||
|
||||
|
||||
def test_execute_does_not_retry_value_error() -> None:
|
||||
tool = _ValueErrorTool()
|
||||
result = asyncio.run(tool.execute({"q": "x"}, "u", "t", "scope"))
|
||||
assert result.success is False
|
||||
assert tool.calls == 1
|
||||
|
||||
|
||||
def test_execute_invalid_params_returns_failure() -> None:
|
||||
tool = EnterpriseInfoSearchTool()
|
||||
result = asyncio.run(tool.execute({}, "u", "t", "scope"))
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
|
||||
|
||||
def test_tool_result_citations_not_shared_between_instances() -> None:
|
||||
r1 = ToolResult(success=True)
|
||||
r2 = ToolResult(success=True)
|
||||
assert r1.citations is not r2.citations
|
||||
|
||||
|
||||
def test_tool_result_meta_not_shared_between_instances() -> None:
|
||||
r1 = ToolResult(success=True)
|
||||
r2 = ToolResult(success=True)
|
||||
assert r1.meta is not r2.meta
|
||||
|
||||
|
||||
def test_high_risk_requires_approval_true() -> None:
|
||||
d = ToolDeclaration(name="t", description="d", risk_level=RiskLevel.HIGH)
|
||||
assert d.requires_approval is True
|
||||
|
||||
|
||||
def test_write_side_effect_requires_approval_true() -> None:
|
||||
d = ToolDeclaration(name="t", description="d", side_effects=[SideEffect.WRITE])
|
||||
assert d.requires_approval is True
|
||||
|
||||
|
||||
def test_low_risk_read_requires_approval_false() -> None:
|
||||
d = ToolDeclaration(name="t", description="d", side_effects=[SideEffect.READ])
|
||||
assert d.requires_approval is False
|
||||
|
||||
|
||||
def test_executor_unlisted_tool_raises_not_allowed(registry: ToolRegistry) -> None:
|
||||
logger = _AuditLogger()
|
||||
executor = ToolExecutor(registry, logger)
|
||||
with pytest.raises(ToolNotAllowedError):
|
||||
asyncio.run(
|
||||
executor.execute_tool(
|
||||
tool_name="x",
|
||||
raw_params={},
|
||||
user_id="u",
|
||||
tenant_id="t",
|
||||
data_scope="scope",
|
||||
allowed_tools=["y"],
|
||||
trace_id="tr",
|
||||
step_id="s1",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_executor_unlisted_tool_writes_audit_event(registry: ToolRegistry) -> None:
|
||||
logger = _AuditLogger()
|
||||
executor = ToolExecutor(registry, logger)
|
||||
with pytest.raises(ToolNotAllowedError):
|
||||
asyncio.run(
|
||||
executor.execute_tool(
|
||||
tool_name="x",
|
||||
raw_params={},
|
||||
user_id="u",
|
||||
tenant_id="t",
|
||||
data_scope="scope",
|
||||
allowed_tools=[],
|
||||
trace_id="tr",
|
||||
step_id="s1",
|
||||
)
|
||||
)
|
||||
assert len(logger.events) == 1
|
||||
assert logger.events[0]["event_type"] == "whitelist_violation"
|
||||
|
||||
|
||||
def test_executor_listed_tool_calls_execute(registry: ToolRegistry) -> None:
|
||||
logger = _AuditLogger()
|
||||
tool = _SuccessTool()
|
||||
registry.register(tool)
|
||||
executor = ToolExecutor(registry, logger)
|
||||
result = asyncio.run(
|
||||
executor.execute_tool(
|
||||
tool_name="ok_tool",
|
||||
raw_params={"q": "x"},
|
||||
user_id="u",
|
||||
tenant_id="t",
|
||||
data_scope="scope",
|
||||
allowed_tools=["ok_tool"],
|
||||
trace_id="tr",
|
||||
step_id="s1",
|
||||
)
|
||||
)
|
||||
assert result.success is True
|
||||
|
||||
|
||||
def test_executor_always_writes_audit_even_on_failure(registry: ToolRegistry) -> None:
|
||||
logger = _AuditLogger()
|
||||
tool = _FailTool()
|
||||
registry.register(tool)
|
||||
executor = ToolExecutor(registry, logger)
|
||||
result = asyncio.run(
|
||||
executor.execute_tool(
|
||||
tool_name="fail_tool",
|
||||
raw_params={"q": "x"},
|
||||
user_id="u",
|
||||
tenant_id="t",
|
||||
data_scope="scope",
|
||||
allowed_tools=["fail_tool"],
|
||||
trace_id="tr",
|
||||
step_id="s1",
|
||||
)
|
||||
)
|
||||
assert result.success is False
|
||||
assert len(logger.tool_calls) == 1
|
||||
|
||||
|
||||
def test_enterprise_info_mock_has_mock_flag_in_meta(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
|
||||
async def _raise(*args: Any, **kwargs: Any) -> Any:
|
||||
raise ConnectionError("offline")
|
||||
|
||||
monkeypatch.setattr("httpx.AsyncClient.post", _raise)
|
||||
tool = EnterpriseInfoSearchTool()
|
||||
result = asyncio.run(
|
||||
tool.execute(
|
||||
{"company_name": "ACME", "include_financials": False, "include_news": True},
|
||||
"u",
|
||||
"t",
|
||||
"scope",
|
||||
)
|
||||
)
|
||||
assert result.success is True
|
||||
assert result.meta.get("mock") is True
|
||||
assert result.meta.get("reason") == "external_api_unavailable"
|
||||
|
||||
|
||||
def test_enterprise_info_production_no_mock_on_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ENVIRONMENT", "production")
|
||||
|
||||
async def _raise(*args: Any, **kwargs: Any) -> Any:
|
||||
raise ConnectionError("offline")
|
||||
|
||||
monkeypatch.setattr("httpx.AsyncClient.post", _raise)
|
||||
tool = EnterpriseInfoSearchTool()
|
||||
result = asyncio.run(
|
||||
tool.execute(
|
||||
{"company_name": "ACME", "include_financials": False, "include_news": True},
|
||||
"u",
|
||||
"t",
|
||||
"scope",
|
||||
)
|
||||
)
|
||||
assert result.success is False
|
||||
assert result.meta.get("mock") is None
|
||||
|
||||
|
||||
def test_registry_duplicate_register_raises() -> None:
|
||||
reg = ToolRegistry()
|
||||
tool = _SuccessTool()
|
||||
reg.register(tool)
|
||||
with pytest.raises(ValueError):
|
||||
reg.register(tool)
|
||||
|
||||
|
||||
def test_registry_unknown_tool_raises_not_found() -> None:
|
||||
reg = ToolRegistry()
|
||||
with pytest.raises(ToolNotFoundError):
|
||||
reg.get("missing")
|
||||
|
||||
|
||||
def test_registry_validate_whitelist_lists_all_missing() -> None:
|
||||
reg = ToolRegistry()
|
||||
reg.register(_SuccessTool())
|
||||
with pytest.raises(ToolNotFoundError) as exc_info:
|
||||
reg.validate_whitelist(["ok_tool", "m1", "m2"])
|
||||
assert "m1,m2" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_registry_claude_tool_schema_and_names() -> None:
|
||||
reg = ToolRegistry()
|
||||
reg.register(_SuccessTool())
|
||||
schemas = reg.get_claude_tool_schemas(["ok_tool"])
|
||||
assert schemas[0]["name"] == "ok_tool"
|
||||
assert reg.all_tool_names() == ["ok_tool"]
|
||||
|
||||
|
||||
def test_get_registry_singleton() -> None:
|
||||
assert get_registry() is get_registry()
|
||||
|
||||
|
||||
def test_doc_generator_markdown_success() -> None:
|
||||
tool = WordDocGeneratorTool()
|
||||
result = asyncio.run(
|
||||
tool.execute(
|
||||
{
|
||||
"template_name": "tpl",
|
||||
"content": {"k": "v"},
|
||||
"output_format": "markdown",
|
||||
"filename": "sample_md",
|
||||
},
|
||||
"u",
|
||||
"t",
|
||||
"scope",
|
||||
)
|
||||
)
|
||||
assert result.success is True
|
||||
assert result.data_ref == "agent_platform/sample_md.md"
|
||||
|
||||
|
||||
def test_doc_generator_pdf_fallback_to_markdown() -> None:
|
||||
tool = WordDocGeneratorTool()
|
||||
result = asyncio.run(
|
||||
tool.execute(
|
||||
{
|
||||
"template_name": "tpl",
|
||||
"content": {"k": "v"},
|
||||
"output_format": "pdf",
|
||||
"filename": "sample_pdf",
|
||||
},
|
||||
"u",
|
||||
"t",
|
||||
"scope",
|
||||
)
|
||||
)
|
||||
assert result.success is True
|
||||
assert result.meta.get("format_fallback") == "markdown"
|
||||
|
||||
|
||||
def test_doc_generator_non_production_import_error_falls_back(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
original_import = builtins.__import__
|
||||
|
||||
def fake_import(name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
if name == "docx":
|
||||
raise ImportError("mock missing")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
tool = WordDocGeneratorTool()
|
||||
result = asyncio.run(
|
||||
tool.execute(
|
||||
{"template_name": "tpl", "content": {"k": "v"}, "output_format": "docx"},
|
||||
"u",
|
||||
"t",
|
||||
"scope",
|
||||
)
|
||||
)
|
||||
assert result.success is True
|
||||
assert result.meta.get("format_fallback") == "markdown"
|
||||
|
||||
|
||||
def test_doc_generator_production_import_error_returns_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("ENVIRONMENT", "production")
|
||||
original_import = builtins.__import__
|
||||
|
||||
def fake_import(name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
if name == "docx":
|
||||
raise ImportError("mock missing")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
tool = WordDocGeneratorTool()
|
||||
result = asyncio.run(
|
||||
tool.execute(
|
||||
{"template_name": "tpl", "content": {"k": "v"}, "output_format": "docx"},
|
||||
"u",
|
||||
"t",
|
||||
"scope",
|
||||
)
|
||||
)
|
||||
assert result.success is False
|
||||
|
||||
|
||||
def test_rag_retriever_non_production_returns_mock(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
tool = RagCaseRetrieverTool()
|
||||
monkeypatch.setattr(
|
||||
tool,
|
||||
"_real_search",
|
||||
lambda params: (_ for _ in ()).throw(ConnectionError("offline")),
|
||||
)
|
||||
params = {
|
||||
"query": "abc",
|
||||
"collection": "case_library",
|
||||
"top_k": 3,
|
||||
"tenant_id": "tenant_a",
|
||||
}
|
||||
result = asyncio.run(tool.execute(params, "u", "t", "scope"))
|
||||
assert result.success is True
|
||||
assert result.meta.get("mock") is True
|
||||
assert len(result.citations) == 3
|
||||
|
||||
|
||||
def test_rag_retriever_production_no_mock_on_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ENVIRONMENT", "production")
|
||||
tool = RagCaseRetrieverTool()
|
||||
monkeypatch.setattr(
|
||||
tool,
|
||||
"_real_search",
|
||||
lambda params: (_ for _ in ()).throw(ConnectionError("offline")),
|
||||
)
|
||||
params = {
|
||||
"query": "abc",
|
||||
"collection": "case_library",
|
||||
"top_k": 3,
|
||||
"tenant_id": "tenant_a",
|
||||
}
|
||||
result = asyncio.run(tool.execute(params, "u", "t", "scope"))
|
||||
assert result.success is False
|
||||
assert result.meta.get("mock") is None
|
||||
Reference in New Issue
Block a user