Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion backend/app/agents/branching.py
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,7 @@ def knowledge_version_for_upload(
branch = _ensure_knowledge_branch(db, tenant_id, agent.id, kb)
source_version = ensure_knowledge_base_version(db, kb, branch.head_version)
next_version = _next_knowledge_branch_version(branch)
branch.base_version = _knowledge_branch_root_version(branch)
target_version = ensure_knowledge_base_version(db, kb, next_version)
target_version.capability_scope = source_version.capability_scope
clone_knowledge_version_assets(
Expand Down Expand Up @@ -1232,14 +1233,23 @@ def _current_knowledge_version(kb: KnowledgeBase) -> str:


def _next_knowledge_branch_version(branch: AgentKnowledgeBranch) -> str:
prefix = f"{branch.base_version}-branch.{_safe_version_id(branch.agent_id)}."
prefix = (
f"{_knowledge_branch_root_version(branch)}-branch."
f"{_safe_version_id(branch.agent_id)}."
)
if branch.head_version.startswith(prefix):
suffix = branch.head_version.removeprefix(prefix)
if suffix.isdigit():
return f"{prefix}{int(suffix) + 1}"
return f"{prefix}1"


def _knowledge_branch_root_version(branch: AgentKnowledgeBranch) -> str:
marker = f"-branch.{_safe_version_id(branch.agent_id)}."
base_version = branch.base_version.split(marker, 1)[0].strip()
return base_version or "1.0.0"


def _retag_knowledge_version(
db: Session,
tenant_id: str,
Expand Down
3 changes: 2 additions & 1 deletion backend/app/api/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from starlette.background import BackgroundTask

from app.agents.branching import model_for_agent, visible_published_skills
from app.skills.nesting import discoverable_sops
from app.channels.service_outbox import stage_channel_delivery
from app.core import AgentLoop
from app.core.cancellation import cancel_chat_turn
Expand Down Expand Up @@ -942,7 +943,7 @@ def list_slash_commands(
agent_id,
current_user,
)
skills = visible_published_skills(db, tenant_id, agent.id)
skills = discoverable_sops(visible_published_skills(db, tenant_id, agent.id))
manifest = CapabilityManifestBuilder(db).build(
tenant_id,
agent.id,
Expand Down
171 changes: 171 additions & 0 deletions backend/app/api/evolution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
from __future__ import annotations

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session

from app.db import get_session
from app.db.models import EvolutionProposal, User
from app.evolution import EvolutionService
from app.evolution.schema import (
EvolutionActionRequest,
EvolutionAnalyzeRequest,
EvolutionProposalRead,
EvolutionRejectRequest,
)
from app.security.auth import get_current_user
from app.security.permissions import ensure_agent_scope_manager
from app.security.tenant import ensure_tenant


router = APIRouter(prefix="/api/enterprise", tags=["enterprise:evolution"])


@router.get(
"/agents/{agent_id}/evolution/proposals",
response_model=list[EvolutionProposalRead],
)
def list_evolution_proposals(
agent_id: str,
tenant_id: str = Query(...),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> list[EvolutionProposalRead]:
ensure_tenant(db, tenant_id)
ensure_agent_scope_manager(db, tenant_id, agent_id, current_user)
return [_proposal_read(row) for row in EvolutionService(db).list(tenant_id, agent_id)]


@router.post(
"/agents/{agent_id}/evolution:analyze",
response_model=EvolutionProposalRead,
)
def analyze_evolution_candidate(
agent_id: str,
request: EvolutionAnalyzeRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> EvolutionProposalRead:
ensure_tenant(db, request.tenant_id)
ensure_agent_scope_manager(db, request.tenant_id, agent_id, current_user)
return _proposal_read(EvolutionService(db).analyze(agent_id, request, current_user))


@router.get(
"/evolution/proposals/{proposal_id}",
response_model=EvolutionProposalRead,
)
def get_evolution_proposal(
proposal_id: str,
tenant_id: str = Query(...),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> EvolutionProposalRead:
row = _proposal(db, tenant_id, proposal_id)
ensure_agent_scope_manager(db, tenant_id, row.agent_id, current_user)
return _proposal_read(row)


@router.post(
"/evolution/proposals/{proposal_id}:evaluate",
response_model=EvolutionProposalRead,
)
def evaluate_evolution_proposal(
proposal_id: str,
request: EvolutionActionRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> EvolutionProposalRead:
row = _proposal(db, request.tenant_id, proposal_id)
ensure_agent_scope_manager(db, request.tenant_id, row.agent_id, current_user)
return _proposal_read(EvolutionService(db).evaluate(row))


@router.post(
"/evolution/proposals/{proposal_id}:approve",
response_model=EvolutionProposalRead,
)
def approve_evolution_proposal(
proposal_id: str,
request: EvolutionActionRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> EvolutionProposalRead:
row = _proposal(db, request.tenant_id, proposal_id)
ensure_agent_scope_manager(db, request.tenant_id, row.agent_id, current_user)
return _proposal_read(EvolutionService(db).approve(row, current_user))


@router.post(
"/evolution/proposals/{proposal_id}:reject",
response_model=EvolutionProposalRead,
)
def reject_evolution_proposal(
proposal_id: str,
request: EvolutionRejectRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> EvolutionProposalRead:
row = _proposal(db, request.tenant_id, proposal_id)
ensure_agent_scope_manager(db, request.tenant_id, row.agent_id, current_user)
return _proposal_read(EvolutionService(db).reject(row, current_user, request.reason))


@router.post(
"/evolution/proposals/{proposal_id}:rollback",
response_model=EvolutionProposalRead,
)
def rollback_evolution_proposal(
proposal_id: str,
request: EvolutionActionRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> EvolutionProposalRead:
row = _proposal(db, request.tenant_id, proposal_id)
ensure_agent_scope_manager(db, request.tenant_id, row.agent_id, current_user)
return _proposal_read(EvolutionService(db).rollback(row, current_user))


def _proposal(db: Session, tenant_id: str, proposal_id: str) -> EvolutionProposal:
ensure_tenant(db, tenant_id)
row = db.get(EvolutionProposal, proposal_id)
if not row or row.tenant_id != tenant_id:
raise HTTPException(
status_code=404,
detail={
"code": "EVOLUTION_PROPOSAL_NOT_FOUND",
"message": "未找到自进化候选",
},
)
return row


def _proposal_read(row: EvolutionProposal) -> EvolutionProposalRead:
return EvolutionProposalRead(
id=row.id,
tenant_id=row.tenant_id,
agent_id=row.agent_id,
resource_type=row.resource_type, # type: ignore[arg-type]
resource_id=row.resource_id,
resource_key=row.resource_key,
resource_name=row.resource_name,
base_version=row.base_version,
status=row.status,
trigger_type=row.trigger_type,
risk_level=row.risk_level,
hypothesis=row.hypothesis,
rationale=row.rationale,
expected_outcome=row.expected_outcome,
source_feedback_ids=list(row.source_feedback_ids_json or []),
evidence=list(row.evidence_json or []),
candidate=dict(row.candidate_json or {}),
diff=list(row.diff_json or []),
evaluation=dict(row.evaluation_json or {}),
error=row.error,
created_by_user_id=row.created_by_user_id,
reviewed_by_user_id=row.reviewed_by_user_id,
created_at=row.created_at.isoformat(),
updated_at=row.updated_at.isoformat(),
reviewed_at=row.reviewed_at.isoformat() if row.reviewed_at else None,
published_at=row.published_at.isoformat() if row.published_at else None,
rolled_back_at=row.rolled_back_at.isoformat() if row.rolled_back_at else None,
)
81 changes: 78 additions & 3 deletions backend/app/api/knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,11 @@ def _resolve_upload_knowledge_base(
current_user: object | None = None,
creator_metadata: dict[str, Any] | None = None,
) -> KnowledgeBase:
agent = ensure_agent_scope_manager(db, request.tenant_id, agent_id, current_user)
agent = (
ensure_agent_scope_manager(db, request.tenant_id, agent_id, current_user)
if agent_id
else None
)
if request.knowledge_base_id:
knowledge_base = db.get(KnowledgeBase, request.knowledge_base_id)
if (
Expand Down Expand Up @@ -453,9 +457,56 @@ def update_document(
request: KnowledgeDocumentUpdateRequest,
db: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
agent_id: str | None = Query(None),
) -> KnowledgeDocumentRead:
row = _get_document(db, request.tenant_id, document_id)
_ensure_open_gallery_knowledge_admin(db, request.tenant_id, row.knowledge_base_id, current_user)
source_row = _get_document(db, request.tenant_id, document_id)
if request.expected_updated_at and source_row.updated_at.isoformat() != request.expected_updated_at:
raise HTTPException(
status_code=409,
detail="文档已被其他人修改,请刷新后再保存。",
)
resolved_agent_id = agent_id if isinstance(agent_id, str) and agent_id else None
agent = (
ensure_agent_scope_manager(
db,
request.tenant_id,
resolved_agent_id,
current_user,
)
if resolved_agent_id
else None
)
if agent and not agent.is_overall:
version = knowledge_version_for_upload(
db,
request.tenant_id,
source_row.knowledge_base_id,
agent.id,
metadata_json=user_creator_metadata(current_user),
)
db.commit()
row = _document_for_version(db, source_row, version.id)
else:
_ensure_open_gallery_knowledge_admin(
db,
request.tenant_id,
source_row.knowledge_base_id,
current_user,
)
row = source_row

if request.content_md is not None:
try:
row = KnowledgeService(db).replace_document_content(
row,
request.content_md,
title=request.title,
status=request.status,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return document_read(row)

metadata = dict(row.metadata_json or {})
if request.metadata is not None:
metadata = metadata_preserving_creator(row.metadata_json, request.metadata)
Expand All @@ -477,6 +528,30 @@ def update_document(
return document_read(row)


def _document_for_version(
db: Session,
source: KnowledgeDocument,
version_id: str,
) -> KnowledgeDocument:
if source.knowledge_base_version_id == version_id:
return source
rows = db.exec(
select(KnowledgeDocument)
.where(
KnowledgeDocument.tenant_id == source.tenant_id,
KnowledgeDocument.knowledge_base_id == source.knowledge_base_id,
KnowledgeDocument.knowledge_base_version_id == version_id,
KnowledgeDocument.filename == source.filename,
KnowledgeDocument.file_type == source.file_type,
)
.order_by(KnowledgeDocument.created_at.asc())
).all()
if not rows:
raise HTTPException(status_code=404, detail="Knowledge document branch copy not found")
exact = [row for row in rows if row.title == source.title and row.created_at == source.created_at]
return exact[0] if exact else rows[0]


@router.get(
"/documents/{document_id}/buckets",
response_model=list[KnowledgeBucketRead],
Expand Down
45 changes: 45 additions & 0 deletions backend/app/api/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
)
from app.skills.stream_jobs import SkillStreamEvent, SkillStreamJob, stream_jobs
from app.skills.step_ids import skill_card_with_unique_step_ids
from app.skills.nesting import SopNestingError, validate_sop_nesting

router = APIRouter(
prefix="/api/enterprise/skills",
Expand Down Expand Up @@ -205,6 +206,19 @@ def create_skill(
normalized_content, _warnings = skill_card_with_unique_step_ids(request.content)
content = normalized_content.model_dump()
agent = ensure_agent_scope_manager(db, request.tenant_id, agent_id, current_user)
try:
validate_sop_nesting(
normalized_content.skill_id,
content,
visible_skill_rows(
db,
request.tenant_id,
agent.id if agent else None,
include_inactive=True,
),
)
except SopNestingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
row = Skill(
tenant_id=request.tenant_id,
skill_id=normalized_content.skill_id,
Expand Down Expand Up @@ -285,6 +299,19 @@ def update_skill(
row = _get_skill(db, request.tenant_id, skill_id)
normalized_content, _warnings = skill_card_with_unique_step_ids(request.content)
agent = ensure_agent_scope_manager(db, request.tenant_id, agent_id, current_user)
try:
validate_sop_nesting(
normalized_content.skill_id,
normalized_content.model_dump(),
visible_skill_rows(
db,
request.tenant_id,
agent.id if agent else None,
include_inactive=True,
),
)
except SopNestingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if agent and not agent.is_overall:
binding = db.exec(
select(AgentResourceBinding).where(
Expand Down Expand Up @@ -343,6 +370,24 @@ def publish_skill(
) -> SkillRead:
row = _get_skill(db, tenant_id, skill_id)
agent = ensure_agent_scope_manager(db, tenant_id, agent_id, current_user)
validation_content = (
ensure_agent_skill_branch(db, tenant_id, agent.id, row).content_json
if agent and not agent.is_overall
else row.content_json
)
try:
validate_sop_nesting(
row.skill_id,
validation_content,
visible_skill_rows(
db,
tenant_id,
agent.id if agent else None,
include_inactive=True,
),
)
except SopNestingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if agent and not agent.is_overall:
branch = ensure_agent_skill_branch(db, tenant_id, agent.id, row)
branch.status = "active"
Expand Down
Loading