""" 场景契约 API — 统一 DSL 输入契约的 CRUD 与预置查询 """ from fastapi import APIRouter, Depends, HTTPException, Response, status, Query from sqlalchemy.orm import Session from pydantic import BaseModel, Field from typing import Any, Dict, List, Optional import logging import uuid from app.core.database import get_db from app.api.auth import get_current_user from app.models.user import User from app.models.scene_contract import SceneContract from app.services.scene_contract_service import ( build_system_prompt_from_contract, build_acceptance_prompt, validate_input_against_contract, get_preset_contract, list_preset_contracts_meta, PRESET_CONTRACTS, ContractPromptConfig, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1/scene-contracts", tags=["scene-contracts"]) # ── Pydantic schemas ── class DeliverableItem(BaseModel): name: str format: Optional[str] = None description: Optional[str] = None class ExampleItem(BaseModel): input: str output: str class ContractCreate(BaseModel): name: str description: Optional[str] = None goal: str role: Optional[str] = None input_description: Optional[str] = None input_schema: Optional[Dict[str, Any]] = None constraints: List[str] = Field(default_factory=list) forbidden_actions: List[str] = Field(default_factory=list) required_tools: List[str] = Field(default_factory=list) deliverables: List[DeliverableItem] = Field(default_factory=list) acceptance_criteria: List[str] = Field(default_factory=list) output_schema: Optional[Dict[str, Any]] = None examples: List[ExampleItem] = Field(default_factory=list) category: Optional[str] = None tags: List[str] = Field(default_factory=list) is_public: bool = False template_binding: Optional[str] = None class ContractUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None goal: Optional[str] = None role: Optional[str] = None input_description: Optional[str] = None input_schema: Optional[Dict[str, Any]] = None constraints: Optional[List[str]] = None forbidden_actions: Optional[List[str]] = None required_tools: Optional[List[str]] = None deliverables: Optional[List[DeliverableItem]] = None acceptance_criteria: Optional[List[str]] = None output_schema: Optional[Dict[str, Any]] = None examples: Optional[List[ExampleItem]] = None category: Optional[str] = None tags: Optional[List[str]] = None is_public: Optional[bool] = None template_binding: Optional[str] = None class ContractResponse(BaseModel): id: str name: str description: Optional[str] = None goal: str role: Optional[str] = None input_description: Optional[str] = None input_schema: Optional[Dict[str, Any]] = None constraints: List[str] = [] forbidden_actions: List[str] = [] required_tools: List[str] = [] deliverables: List[Dict[str, Any]] = [] acceptance_criteria: List[str] = [] output_schema: Optional[Dict[str, Any]] = None examples: List[Dict[str, Any]] = [] category: Optional[str] = None tags: List[str] = [] version: int = 1 is_public: int = 0 use_count: int = 0 user_id: Optional[str] = None template_binding: Optional[str] = None created_at: Optional[str] = None updated_at: Optional[str] = None class ContractMetaItem(BaseModel): id: str name: str description: Optional[str] = None category: Optional[str] = None tags: List[str] = [] goal: str = "" deliverable_count: int = 0 constraint_count: int = 0 class PromptGenerateRequest(BaseModel): contract_id: Optional[str] = None contract_data: Optional[Dict[str, Any]] = None config: Optional[Dict[str, Any]] = None class PromptGenerateResponse(BaseModel): system_prompt: str class AcceptanceEvaluateRequest(BaseModel): contract_id: Optional[str] = None contract_data: Optional[Dict[str, Any]] = None agent_output: str class AcceptanceEvaluateResponse(BaseModel): evaluation_prompt: str class InputValidateRequest(BaseModel): contract_id: Optional[str] = None contract_data: Optional[Dict[str, Any]] = None user_input: Dict[str, Any] class InputValidateResponse(BaseModel): valid: bool errors: List[str] = [] warnings: List[str] = [] # ── Predefined preset contracts ── @router.get("/presets", response_model=List[ContractMetaItem]) async def list_preset_contracts(current_user: User = Depends(get_current_user)): """列出所有预置场景契约""" _ = current_user return list_preset_contracts_meta() @router.get("/presets/{contract_id}") async def get_preset_contract_detail( contract_id: str, current_user: User = Depends(get_current_user), ): """获取单个预置契约的完整内容""" _ = current_user contract = get_preset_contract(contract_id) if not contract: raise HTTPException(status_code=404, detail=f"预置契约不存在: {contract_id}") return {"id": contract_id, **contract} # ── CRUD for user contracts ── @router.get("/", response_model=List[ContractResponse]) async def list_contracts( category: Optional[str] = Query(None), is_public: Optional[bool] = Query(None), template_binding: Optional[str] = Query(None), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """列出用户的场景契约""" query = db.query(SceneContract).filter( (SceneContract.user_id == current_user.id) | (SceneContract.is_public == 1) ) if category: query = query.filter(SceneContract.category == category) if is_public is not None: query = query.filter(SceneContract.is_public == (1 if is_public else 0)) if template_binding: query = query.filter(SceneContract.template_binding == template_binding) contracts = query.order_by(SceneContract.updated_at.desc()).all() return [c.to_dict() for c in contracts] @router.post("/", response_model=ContractResponse, status_code=status.HTTP_201_CREATED) async def create_contract( body: ContractCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """创建自定义场景契约""" contract = SceneContract( id=str(uuid.uuid4()), name=body.name, description=body.description, goal=body.goal, role=body.role, input_description=body.input_description, input_schema=body.input_schema, constraints=body.constraints, forbidden_actions=body.forbidden_actions, required_tools=body.required_tools, deliverables=[d.model_dump() for d in body.deliverables], acceptance_criteria=body.acceptance_criteria, output_schema=body.output_schema, examples=[e.model_dump() for e in body.examples], category=body.category, tags=body.tags, is_public=1 if body.is_public else 0, template_binding=body.template_binding, user_id=current_user.id, ) db.add(contract) db.commit() db.refresh(contract) return contract.to_dict() @router.get("/{contract_id}", response_model=ContractResponse) async def get_contract( contract_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """获取场景契约详情""" # 先查预置 preset = get_preset_contract(contract_id) if preset: return {"id": contract_id, "version": 1, "is_public": 1, "use_count": 0, "user_id": None, "template_binding": None, "created_at": None, "updated_at": None, **preset} contract = db.query(SceneContract).filter(SceneContract.id == contract_id).first() if not contract: raise HTTPException(status_code=404, detail="契约不存在") if contract.user_id != current_user.id and contract.is_public == 0: raise HTTPException(status_code=403, detail="无权访问此契约") return contract.to_dict() @router.put("/{contract_id}", response_model=ContractResponse) async def update_contract( contract_id: str, body: ContractUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """更新场景契约""" contract = db.query(SceneContract).filter(SceneContract.id == contract_id).first() if not contract: raise HTTPException(status_code=404, detail="契约不存在") if contract.user_id != current_user.id: raise HTTPException(status_code=403, detail="只能修改自己的契约") update_data = body.model_dump(exclude_unset=True) # Handle list/dict fields for field in ["deliverables", "examples"]: if field in update_data and update_data[field] is not None: update_data[field] = [item if isinstance(item, dict) else item.model_dump() for item in update_data[field]] if "is_public" in update_data and update_data["is_public"] is not None: update_data["is_public"] = 1 if update_data["is_public"] else 0 for key, value in update_data.items(): if value is not None: setattr(contract, key, value) contract.version = (contract.version or 1) + 1 db.commit() db.refresh(contract) return contract.to_dict() @router.delete("/{contract_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_contract( contract_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """删除场景契约""" contract = db.query(SceneContract).filter(SceneContract.id == contract_id).first() if not contract: raise HTTPException(status_code=404, detail="契约不存在") if contract.user_id != current_user.id: raise HTTPException(status_code=403, detail="只能删除自己的契约") db.delete(contract) db.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) # ── DSL 操作端点 ── @router.post("/generate-prompt", response_model=PromptGenerateResponse) async def generate_prompt_from_contract( body: PromptGenerateRequest, current_user: User = Depends(get_current_user), ): """从契约生成 system prompt""" _ = current_user # 获取契约数据 contract_data = body.contract_data if not contract_data and body.contract_id: preset = get_preset_contract(body.contract_id) if preset: contract_data = preset if not contract_data: raise HTTPException(status_code=400, detail="请提供 contract_id 或 contract_data") config = ContractPromptConfig(**(body.config or {})) prompt = build_system_prompt_from_contract(contract_data, config) return {"system_prompt": prompt} @router.post("/evaluate", response_model=AcceptanceEvaluateResponse) async def evaluate_output_against_contract( body: AcceptanceEvaluateRequest, current_user: User = Depends(get_current_user), ): """生成验收评估 prompt(用于评估 Agent 输出是否满足契约)""" _ = current_user contract_data = body.contract_data if not contract_data and body.contract_id: preset = get_preset_contract(body.contract_id) if preset: contract_data = preset if not contract_data: raise HTTPException(status_code=400, detail="请提供 contract_id 或 contract_data") prompt = build_acceptance_prompt(contract_data, body.agent_output) return {"evaluation_prompt": prompt} @router.post("/validate-input", response_model=InputValidateResponse) async def validate_input_against_contract_endpoint( body: InputValidateRequest, current_user: User = Depends(get_current_user), ): """根据契约的 input_schema 验证用户输入""" _ = current_user contract_data = body.contract_data if not contract_data and body.contract_id: preset = get_preset_contract(body.contract_id) if preset: contract_data = preset if not contract_data: raise HTTPException(status_code=400, detail="请提供 contract_id 或 contract_data") result = validate_input_against_contract(contract_data, body.user_input) return result