Files
aiagent/backend/app/api/scene_contracts.py
renjianbo beff3fac8d fix: delete agent 500 error + dynamic personality + deployment guide
- 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>
2026-06-29 01:17:21 +08:00

367 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
场景契约 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