chore: initialize agent-platform and add claude inference health check

This commit is contained in:
BrunoGaoSZ
2026-02-26 06:56:12 +01:00
commit ae78a9bbf2
83 changed files with 6576 additions and 0 deletions
+46
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
.env
.venv/
__pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
*.pyc
+12
View File
@@ -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"]
+34
View File
@@ -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 轮换。
- 生产环境日志禁止输出敏感字段原文。
## 变更原则
- 小步提交,保持可回滚。
- 不在无关文件中引入格式化噪音。
- 新增依赖必须说明必要性与替代方案不可行性。
+46
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
# agent-platform
Enterprise agent platform project skeleton.
+37
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
# api/AGENTS.md
## 范围
`api/` 负责 FastAPI 路由、请求响应契约、中间件和权限控制。
## 约束
- 路由层不承载业务规则,仅做参数校验与编排。
- 权限、限流、鉴权失败返回明确 HTTP 状态码与错误码。
- 合同模型字段禁止可变默认值,统一 `Field(default_factory=...)`。
## 工程要求
- 新增路由必须包含至少一个契约测试。
- 中间件顺序变更需说明兼容性影响。
View File
View File
View File
+10
View File
@@ -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"}
View File
View File
View File
View File
+14
View File
@@ -0,0 +1,14 @@
# core/AGENTS.md
## 范围
`core/` 负责智能体引擎、LLM 路由、工具执行、校验、审计与可观测基础能力。
## 约束
- 保持纯业务内核,禁止引入 API 框架对象。
- 所有状态对象、输入输出契约需类型稳定、可序列化。
- 成本、预算、阈值统一 `Decimal`。
- 配置访问必须走 `get_settings()`。
## 工程要求
- 复杂分支逻辑应有最小单元测试覆盖。
- 审计与加密路径出现异常时必须返回可诊断错误,不可静默吞错。
View File
View File
View File
+30
View File
@@ -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)
+13
View File
@@ -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
View File
@@ -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"
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
+34
View File
@@ -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",
]
+172
View File
@@ -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")
+101
View File
@@ -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},
)
+81
View File
@@ -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"},
)
+52
View File
@@ -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
+128
View File
@@ -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},
)
+52
View File
@@ -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
View File
View File
View File
+64
View File
@@ -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)
+74
View File
@@ -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()
View File
@@ -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
View File
@@ -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__
View File
+13
View File
@@ -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",
]
+45
View File
@@ -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())
+35
View File
@@ -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)
+67
View File
@@ -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
+67
View File
@@ -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)
+65
View File
@@ -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())
+177
View File
@@ -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:
View File
+44
View File
@@ -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"]
+34
View File
@@ -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]
+11
View File
@@ -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]
View File
View File
+83
View File
@@ -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"]
View File
+69
View File
@@ -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())
+30
View File
@@ -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()
+57
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
def main() -> None:
print("seed_data: no-op")
if __name__ == "__main__":
main()
+67
View File
@@ -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"
+7
View File
@@ -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 注入
+11
View File
@@ -0,0 +1,11 @@
# tests/AGENTS.md
## 测试分层
- `unit/`: 纯函数与小范围组件测试,禁止外部依赖。
- `integration/`: 数据库、缓存、向量库、第三方 API 集成测试。
- `contracts/`: API 请求响应与权限矩阵契约测试。
## 约束
- 测试中密钥统一由 fixture 注入,不从真实环境读取。
- 测试数据必须可重复、可清理。
- 对审计、鉴权、预算阈值相关逻辑优先补全边界测试。
+23
View File
@@ -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()
View File
View File
View File
@@ -0,0 +1,7 @@
import pytest
pytestmark = pytest.mark.integration
def test_integration_suite_is_discoverable() -> None:
assert True
View File
+42
View File
@@ -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
+509
View File
@@ -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
Generated
+3385
View File
File diff suppressed because it is too large Load Diff