367 lines
12 KiB
Python
367 lines
12 KiB
Python
|
|
"""
|
|||
|
|
场景契约 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
|