PMFlow/backend/app/api/ai_routing.py

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)