- Fix delete agent 500: clean up FK records (agent_llm_logs, permissions, schedules, executions, team_members) and unbind goals/tasks before delete - Remove hardcoded personality templates in Android, replace with dynamic system prompt generation from name + description - Set promptSectionsEnabled=false to bypass PromptComposer for personality - Add Tencent Cloud Linux deployment guide (Docker Compose) - Accumulated backend service updates, frontend UI fixes, Android app changes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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
|