445 lines
16 KiB
Python
445 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):
|
|
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)
|
|
|
|
|
|
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)
|
|
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()
|
|
return JSONResponse(status_code=400, content=error_response(400, str(exc), after))
|
|
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)
|