"""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)