- ① 后端删除 app/ai_provider/(38 文件,含 anthropic/openai 格式、billing、audio、embedding)、services/ai_provider_admin.py、core/config/model_config.py、tests/ai_provider/(21 文件) - ② 连带退役 application/agent/(旧 Agent 循环/steer/follow-up/确认注册表)、application/skill_executor.py、app/skills/(python skills 孤儿)、endpoints/audio.py、application/imports.py(ProviderManager 消费方);settings 端点收敛为生成偏好 - ③ runs.py 修剪:AgentRunRegistry/steer/follow-up/confirmations 端点退役,保留 list/get/events/cancel/stream;safety 确认改为直接落库裁决(pending→approved/denied) - ④ 前端删除:models 页、ModelGatewayView、gateway.ts、GatewayPanel(玄关)、ModelGatewayPanel/ModelStrategyForm、audio.ts/useVoiceInput、网关类型与 mock;设置页收敛为外观/语言/通知 - ⑤ 配置收敛:config/models|providers|pricing.example.yaml 归档 shenshi/config/;.env.example 删 provider keys/MODEL_ENDPOINT_POOL/MODEL_CONFIG_PATH,收敛 SHENSHI_* - ⑥ 验收:rg 审计灵台 Provider SDK import 与 ai_provider 引用归零;后端 528 passed(2 个预存失败经 HEAD 复跑验证与本手术无关)、前端 type-check+201 passed、神识 go vet+test、BFF 4 passed
150 lines
5.3 KiB
Python
150 lines
5.3 KiB
Python
"""安全 API(api-spec §16)。
|
||
|
||
audit-events 按人物/枢过滤查询审计时间线;confirmations 的 approve/deny 落库裁决
|
||
高风险工具确认(pending → approved/denied),客户端随后以 confirmed=True 重新调用。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import UTC, datetime
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||
from sqlalchemy import select, update
|
||
|
||
from app.api.v1.deps import get_optional_tool_runtime
|
||
from app.application.tool_runtime.runtime import ToolRuntime
|
||
from app.db.session import AsyncSessionLocal
|
||
from app.models.safety import PendingConfirmationRecord
|
||
from app.schemas.admin import AuditEntry
|
||
from app.schemas.common import Page
|
||
from app.schemas.safety import (
|
||
CreateToolPolicyRequest,
|
||
SafetyPolicyItem,
|
||
ToolPolicyResponse,
|
||
UpdateToolPolicyRequest,
|
||
)
|
||
from app.services import audit as audit_service
|
||
from app.services import governance as governance_service
|
||
|
||
router = APIRouter(prefix="/safety", tags=["safety"])
|
||
|
||
|
||
# —— §16 审计事件查询 ——
|
||
|
||
|
||
@router.get("/audit-events", response_model=Page[AuditEntry])
|
||
async def list_audit_events(
|
||
character_id: str | None = Query(default=None, description="按人物过滤。"),
|
||
pivot: str | None = Query(default=None, description="按枢过滤(契约字典 §5.9)。"),
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=50, ge=1, le=200),
|
||
) -> Page[AuditEntry]:
|
||
return await audit_service.query_audit_events(
|
||
character_id=character_id, pivot=pivot, page=page, page_size=page_size
|
||
)
|
||
|
||
|
||
# —— §16 工具策略治理 ——
|
||
|
||
|
||
async def _refresh_governor(runtime: ToolRuntime | None) -> None:
|
||
"""策略写操作后刷新 ToolRuntime 的司命决策器;tool_runtime 未就绪则跳过(重启后从 db 加载)。"""
|
||
if runtime is not None:
|
||
await runtime.reload_governor()
|
||
|
||
|
||
@router.get("/policies", response_model=list[SafetyPolicyItem])
|
||
async def list_safety_policies() -> list[SafetyPolicyItem]:
|
||
return await governance_service.list_safety_policies()
|
||
|
||
|
||
@router.post("/policies", response_model=ToolPolicyResponse, status_code=status.HTTP_201_CREATED)
|
||
async def create_safety_policy(
|
||
payload: CreateToolPolicyRequest,
|
||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||
) -> ToolPolicyResponse:
|
||
response = await governance_service.create_tool_policy(payload)
|
||
await _refresh_governor(runtime)
|
||
return response
|
||
|
||
|
||
@router.patch("/policies/{tool}", response_model=ToolPolicyResponse)
|
||
async def update_safety_policy(
|
||
tool: str,
|
||
payload: UpdateToolPolicyRequest,
|
||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||
) -> ToolPolicyResponse:
|
||
response = await governance_service.update_tool_policy(tool, payload)
|
||
await _refresh_governor(runtime)
|
||
return response
|
||
|
||
|
||
@router.delete("/policies/{tool}", status_code=status.HTTP_204_NO_CONTENT)
|
||
async def delete_safety_policy(
|
||
tool: str,
|
||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||
) -> Response:
|
||
await governance_service.delete_tool_policy(tool)
|
||
await _refresh_governor(runtime)
|
||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||
|
||
|
||
# —— §16 高风险工具确认 ——
|
||
|
||
|
||
def _not_found(confirmation_id: str) -> HTTPException:
|
||
return HTTPException(
|
||
status_code=404,
|
||
detail={
|
||
"error": {"code": "not_found", "message": f"确认不存在或已被消费: {confirmation_id}"}
|
||
},
|
||
)
|
||
|
||
|
||
def _cancelled(confirmation_id: str) -> dict[str, str]:
|
||
return {"status": "cancelled", "confirmation_id": confirmation_id}
|
||
|
||
|
||
@router.post("/confirmations/{confirmation_id}/approve")
|
||
async def approve(confirmation_id: str) -> dict[str, bool | str]:
|
||
"""批准工具执行:pending 记录落库为 approved,客户端以 confirmed=True 重新调用。"""
|
||
record = await _resolve_confirmation_record(confirmation_id, approved=True)
|
||
return {"status": "resolved", "approved": True, "run_id": record.run_id}
|
||
|
||
|
||
@router.post("/confirmations/{confirmation_id}/deny")
|
||
async def deny(confirmation_id: str) -> dict[str, str]:
|
||
"""拒绝工具执行:pending 记录落库为 denied。"""
|
||
await _resolve_confirmation_record(confirmation_id, approved=False)
|
||
return {"status": "cancelled", "confirmation_id": confirmation_id}
|
||
|
||
|
||
async def _resolve_confirmation_record(
|
||
confirmation_id: str, *, approved: bool
|
||
) -> PendingConfirmationRecord:
|
||
async with AsyncSessionLocal() as session:
|
||
result = await session.execute(
|
||
select(PendingConfirmationRecord).where(PendingConfirmationRecord.id == confirmation_id)
|
||
)
|
||
record = result.scalar_one_or_none()
|
||
if record is None or record.run_id is None:
|
||
raise _not_found(confirmation_id)
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
stmt = (
|
||
update(PendingConfirmationRecord)
|
||
.where(
|
||
PendingConfirmationRecord.id == confirmation_id,
|
||
PendingConfirmationRecord.status == "pending",
|
||
)
|
||
.values(
|
||
status="approved" if approved else "denied",
|
||
resolved_at=datetime.now(UTC),
|
||
)
|
||
)
|
||
updated = await session.execute(stmt)
|
||
await session.commit()
|
||
if updated.rowcount == 0:
|
||
raise _not_found(confirmation_id)
|
||
return record
|