458 lines
16 KiB
Python
458 lines
16 KiB
Python
"""AI routing and provider governance API."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, Literal, Optional
|
||
|
||
from fastapi import APIRouter, Depends
|
||
from fastapi.responses import JSONResponse
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ..core.database import get_db
|
||
from ..core.response import error_response, success_response
|
||
from ..dependencies.auth import get_current_user
|
||
from ..dependencies.permissions import require_platform_ai_ops
|
||
from ..dependencies.tenant import get_tenant_id
|
||
from ..models import User
|
||
from ..services.ai_function_runtime_profile_service import AIFunctionRuntimeProfileService
|
||
from ..services.ai_routing_governance_service import AIRoutingGovernanceService
|
||
from ..services.auth import can_approve_ai_routing_change, can_view_ai_policy
|
||
from ..services.operation_log_service import log_operation
|
||
from ..services.pmflow_change_review_service import PMFlowChangeReviewError, PMFlowChangeReviewService
|
||
from ..services.pmflow_launch_service import PMFlowLaunchError, PMFlowLaunchService
|
||
|
||
router = APIRouter(prefix="/ai-routing", tags=["ai_routing"])
|
||
|
||
|
||
class TeamPolicySceneUpdate(BaseModel):
|
||
key: str = Field(min_length=1, max_length=80)
|
||
policy: str = Field(min_length=1, max_length=300)
|
||
mode: Optional[str] = Field(default=None, max_length=80)
|
||
human_confirmation: bool = True
|
||
protected: bool = True
|
||
|
||
|
||
class TeamPolicyUpdateRequest(BaseModel):
|
||
strategy: Literal["quality_first", "balanced", "speed_first"] = "balanced"
|
||
scenes: list[TeamPolicySceneUpdate] = Field(default_factory=list)
|
||
automation_boundary: Optional[str] = Field(default=None, max_length=300)
|
||
budget_guard: dict[str, Any] = Field(default_factory=dict)
|
||
quality_targets: list[str] = Field(default_factory=list, max_length=8)
|
||
|
||
|
||
class CapabilitySlotUpdateRequest(BaseModel):
|
||
primary_engine_id: Optional[str] = Field(default=None, max_length=120)
|
||
fallback_engine_ids: list[str] = Field(default_factory=list, max_length=5)
|
||
failure_policy: Literal["upgrade_to_high", "try_fallback_then_fail", "explicit_fail"] = "try_fallback_then_fail"
|
||
allow_scene_downgrade: bool = False
|
||
|
||
|
||
class CapabilitySlotPublishRequest(BaseModel):
|
||
summary: Optional[str] = Field(default=None, max_length=200)
|
||
|
||
|
||
class CapabilitySlotRollbackRequest(BaseModel):
|
||
target_version: Optional[str] = Field(default=None, max_length=80)
|
||
|
||
|
||
class FunctionRuntimeProfileUpdateRequest(BaseModel):
|
||
required_slot: Optional[str] = Field(default=None, min_length=1, max_length=40)
|
||
max_tokens: Optional[int] = Field(default=None, ge=1, le=20000)
|
||
repair_max_tokens: Optional[int] = Field(default=None, ge=1, le=20000)
|
||
temperature: Optional[float] = Field(default=None, ge=0, le=1)
|
||
foreground_wait_seconds: Optional[int] = Field(default=None, ge=3, le=30)
|
||
provider_timeout_seconds: Optional[int] = Field(default=None, ge=10, le=300)
|
||
job_timeout_seconds: Optional[int] = Field(default=None, ge=10, le=900)
|
||
max_retries: Optional[int] = Field(default=None, ge=0, le=3)
|
||
large_input_policy: Optional[Literal["direct_generate", "preprocess_then_generate"]] = None
|
||
response_mode: Optional[str] = Field(default=None, max_length=32)
|
||
runtime_mode: Optional[Literal["active", "shadow", "bypass", "disabled"]] = None
|
||
quality_gate_mode: Optional[Literal["strict", "relaxed", "shadow", "off"]] = None
|
||
|
||
|
||
class FunctionRuntimeProfilePublishRequest(BaseModel):
|
||
summary: Optional[str] = Field(default=None, max_length=200)
|
||
|
||
|
||
def _forbidden(message: str) -> JSONResponse:
|
||
return JSONResponse(status_code=403, content=error_response(403, message))
|
||
|
||
|
||
def _service(db: AsyncSession, tenant_id: int) -> AIRoutingGovernanceService:
|
||
return AIRoutingGovernanceService(db, tenant_id=int(tenant_id))
|
||
|
||
|
||
def _profile_service(db: AsyncSession) -> AIFunctionRuntimeProfileService:
|
||
return AIFunctionRuntimeProfileService(db)
|
||
|
||
|
||
@router.get("/overview")
|
||
async def get_ai_routing_overview(
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_platform_ai_ops),
|
||
):
|
||
data = await _service(db, tenant_id).get_overview()
|
||
return success_response(data)
|
||
|
||
|
||
@router.get("/team-policy")
|
||
async def get_ai_team_policy(
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
if not can_view_ai_policy(str(current_user.role or "")):
|
||
return _forbidden("仅团队负责人可查看团队 AI 策略")
|
||
data = await _service(db, tenant_id).get_team_policy()
|
||
return success_response(data)
|
||
|
||
|
||
@router.put("/team-policy")
|
||
async def update_ai_team_policy(
|
||
body: TeamPolicyUpdateRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
if not can_approve_ai_routing_change(str(current_user.role or "")):
|
||
return _forbidden("仅 OWNER 可保存团队 AI 策略")
|
||
service = _service(db, tenant_id)
|
||
before = await service.get_team_policy()
|
||
after = await service.update_team_policy(
|
||
payload=body.model_dump(mode="json"),
|
||
updated_by=int(current_user.id),
|
||
)
|
||
await log_operation(
|
||
db,
|
||
tenant_id=int(tenant_id),
|
||
user_id=int(current_user.id),
|
||
project_id=None,
|
||
entity_type="ai_routing_policy",
|
||
entity_id="team-policy",
|
||
action="update",
|
||
before_data=before,
|
||
after_data=after,
|
||
remark="更新团队 AI 策略",
|
||
)
|
||
await db.commit()
|
||
return success_response(after)
|
||
|
||
|
||
@router.get("/admin-console")
|
||
async def get_ai_routing_admin_console(
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_platform_ai_ops),
|
||
):
|
||
data = await _service(db, tenant_id).get_admin_console()
|
||
return success_response(data)
|
||
|
||
|
||
@router.get("/capability-slots")
|
||
async def get_ai_capability_slots(
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_platform_ai_ops),
|
||
):
|
||
data = await _service(db, tenant_id).get_capability_slots()
|
||
return success_response(data)
|
||
|
||
|
||
@router.put("/capability-slots/{slot_key}")
|
||
async def update_ai_capability_slot(
|
||
slot_key: str,
|
||
body: CapabilitySlotUpdateRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_platform_ai_ops),
|
||
):
|
||
service = _service(db, tenant_id)
|
||
before = await service.get_capability_slots()
|
||
try:
|
||
after = await service.update_capability_slot(
|
||
slot_key=slot_key,
|
||
payload=body.model_dump(mode="json"),
|
||
updated_by=int(current_user.id),
|
||
)
|
||
except ValueError as exc:
|
||
await db.rollback()
|
||
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||
await log_operation(
|
||
db,
|
||
tenant_id=int(tenant_id),
|
||
user_id=int(current_user.id),
|
||
project_id=None,
|
||
entity_type="ai_capability_slot",
|
||
entity_id=str(slot_key),
|
||
action="draft_update",
|
||
before_data=before,
|
||
after_data=after,
|
||
remark="保存 AI 能力槽位草稿",
|
||
)
|
||
await db.commit()
|
||
return success_response(after)
|
||
|
||
|
||
@router.put("/capability-slots/{slot_key}/draft")
|
||
async def update_ai_capability_slot_draft(
|
||
slot_key: str,
|
||
body: CapabilitySlotUpdateRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_platform_ai_ops),
|
||
):
|
||
return await update_ai_capability_slot(
|
||
slot_key=slot_key,
|
||
body=body,
|
||
db=db,
|
||
tenant_id=tenant_id,
|
||
current_user=current_user,
|
||
)
|
||
|
||
|
||
@router.post("/capability-slots/validate")
|
||
async def validate_ai_capability_slots(
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_platform_ai_ops),
|
||
):
|
||
data = await _service(db, tenant_id).validate_capability_slots()
|
||
return success_response(data)
|
||
|
||
|
||
@router.post("/capability-slots/publish")
|
||
async def publish_ai_capability_slots(
|
||
body: CapabilitySlotPublishRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_platform_ai_ops),
|
||
):
|
||
service = _service(db, tenant_id)
|
||
before = await service.get_capability_slots()
|
||
try:
|
||
after = await service.publish_capability_slots(
|
||
published_by=int(current_user.id),
|
||
summary=body.summary,
|
||
)
|
||
except ValueError as exc:
|
||
await db.rollback()
|
||
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||
await log_operation(
|
||
db,
|
||
tenant_id=int(tenant_id),
|
||
user_id=int(current_user.id),
|
||
project_id=None,
|
||
entity_type="ai_capability_slot_release",
|
||
entity_id=str(after.get("active_version") or ""),
|
||
action="publish",
|
||
before_data=before,
|
||
after_data=after,
|
||
remark="发布 AI 能力槽位版本",
|
||
)
|
||
await db.commit()
|
||
return success_response(after)
|
||
|
||
|
||
@router.post("/capability-slots/rollback")
|
||
async def rollback_ai_capability_slots(
|
||
body: CapabilitySlotRollbackRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_platform_ai_ops),
|
||
):
|
||
service = _service(db, tenant_id)
|
||
before = await service.get_capability_slots()
|
||
try:
|
||
after = await service.rollback_capability_slots(
|
||
target_version=body.target_version,
|
||
rolled_back_by=int(current_user.id),
|
||
)
|
||
except ValueError as exc:
|
||
await db.rollback()
|
||
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||
await log_operation(
|
||
db,
|
||
tenant_id=int(tenant_id),
|
||
user_id=int(current_user.id),
|
||
project_id=None,
|
||
entity_type="ai_capability_slot_release",
|
||
entity_id=str(after.get("active_version") or ""),
|
||
action="rollback",
|
||
before_data=before,
|
||
after_data=after,
|
||
remark="回滚 AI 能力槽位版本",
|
||
)
|
||
await db.commit()
|
||
return success_response(after)
|
||
|
||
|
||
@router.get("/function-runtime-profiles")
|
||
async def get_ai_function_runtime_profiles(
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_platform_ai_ops),
|
||
):
|
||
_ = tenant_id
|
||
data = await _profile_service(db).get_profiles()
|
||
return success_response(data)
|
||
|
||
|
||
@router.put("/function-runtime-profiles/{function_name}/draft")
|
||
async def update_ai_function_runtime_profile_draft(
|
||
function_name: str,
|
||
body: FunctionRuntimeProfileUpdateRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_platform_ai_ops),
|
||
):
|
||
_ = tenant_id
|
||
service = _profile_service(db)
|
||
before = await service.get_profiles()
|
||
try:
|
||
after = await service.update_profile(
|
||
function_name=function_name,
|
||
payload=body.model_dump(exclude_none=True, mode="json"),
|
||
updated_by=int(current_user.id),
|
||
)
|
||
except ValueError as exc:
|
||
await db.rollback()
|
||
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||
await log_operation(
|
||
db,
|
||
tenant_id=int(tenant_id),
|
||
user_id=int(current_user.id),
|
||
project_id=None,
|
||
entity_type="ai_function_runtime_profile",
|
||
entity_id=str(function_name),
|
||
action="draft_update",
|
||
before_data=before,
|
||
after_data=after,
|
||
remark="保存函数运行合同草稿",
|
||
)
|
||
await db.commit()
|
||
return success_response(after)
|
||
|
||
|
||
@router.post("/function-runtime-profiles/validate")
|
||
async def validate_ai_function_runtime_profiles(
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_platform_ai_ops),
|
||
):
|
||
_ = tenant_id
|
||
data = await _profile_service(db).get_profiles()
|
||
return success_response(data.get("validation") or {})
|
||
|
||
|
||
@router.post("/function-runtime-profiles/{function_name}/smoke")
|
||
async def smoke_ai_function_runtime_profile(
|
||
function_name: str,
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_platform_ai_ops),
|
||
):
|
||
profile_service = _profile_service(db)
|
||
if not AIFunctionRuntimeProfileService.smoke_supported(function_name):
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content=error_response(
|
||
400,
|
||
"当前函数未接入受控 smoke,也不是发布必需 smoke;请处理发布门禁里标出的 smoke_required 函数。",
|
||
),
|
||
)
|
||
try:
|
||
if function_name == PMFlowLaunchService.DRAFT_FUNCTION_NAME:
|
||
draft_payload = await PMFlowLaunchService(db, tenant_id=int(tenant_id)).generate_draft_payload_for_readiness_check(
|
||
user_id=int(current_user.id),
|
||
profile_stage="draft",
|
||
)
|
||
summary = (
|
||
f"success: requirements={len(draft_payload.requirements)}, tasks={len(draft_payload.tasks)}, "
|
||
f"risks={len(draft_payload.risks)}, decisions={len(draft_payload.decisions)}"
|
||
)
|
||
elif function_name == PMFlowChangeReviewService.AI_EXTRACTION_FUNCTION_NAME:
|
||
extraction_payload = await PMFlowChangeReviewService(db, tenant_id=int(tenant_id)).generate_extraction_payload_for_readiness_check(
|
||
user_id=int(current_user.id),
|
||
profile_stage="draft",
|
||
)
|
||
summary = f"success: proposed_items={len(extraction_payload.get('proposed_items') or [])}"
|
||
else:
|
||
raise ValueError("当前函数暂未接入受控 smoke")
|
||
after = await profile_service.record_smoke(
|
||
function_name=function_name,
|
||
status="success",
|
||
summary=summary,
|
||
checked_by=int(current_user.id),
|
||
)
|
||
except (ValueError, PMFlowLaunchError, PMFlowChangeReviewError, RuntimeError) as exc:
|
||
after = await profile_service.record_smoke(
|
||
function_name=function_name,
|
||
status="failed",
|
||
summary=str(exc),
|
||
checked_by=int(current_user.id),
|
||
)
|
||
await db.commit()
|
||
payload = error_response(400, str(exc))
|
||
payload["data"] = after
|
||
return JSONResponse(status_code=400, content=payload)
|
||
await log_operation(
|
||
db,
|
||
tenant_id=int(tenant_id),
|
||
user_id=int(current_user.id),
|
||
project_id=None,
|
||
entity_type="ai_function_runtime_profile",
|
||
entity_id=str(function_name),
|
||
action="smoke",
|
||
before_data=None,
|
||
after_data=after,
|
||
remark="运行函数运行合同 smoke",
|
||
)
|
||
await db.commit()
|
||
return success_response(after)
|
||
|
||
|
||
@router.post("/function-runtime-profiles/publish")
|
||
async def publish_ai_function_runtime_profiles(
|
||
body: FunctionRuntimeProfilePublishRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_platform_ai_ops),
|
||
):
|
||
_ = tenant_id
|
||
service = _profile_service(db)
|
||
before = await service.get_profiles()
|
||
try:
|
||
after = await service.publish_profiles(
|
||
published_by=int(current_user.id),
|
||
summary=body.summary,
|
||
)
|
||
except ValueError as exc:
|
||
await db.rollback()
|
||
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||
await log_operation(
|
||
db,
|
||
tenant_id=int(tenant_id),
|
||
user_id=int(current_user.id),
|
||
project_id=None,
|
||
entity_type="ai_function_runtime_profile_release",
|
||
entity_id=str(after.get("active_version") or ""),
|
||
action="publish",
|
||
before_data=before,
|
||
after_data=after,
|
||
remark="发布函数运行合同版本",
|
||
)
|
||
await db.commit()
|
||
return success_response(after)
|
||
|
||
|
||
@router.post("/diagnostics/run")
|
||
async def run_ai_routing_diagnostics(
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
current_user: User = Depends(require_platform_ai_ops),
|
||
):
|
||
data = await _service(db, tenant_id).run_diagnostics()
|
||
return success_response(data)
|