"""API routes for skill definitions — /api/v1/skills. Endpoints: skill definitions CRUD. Skills are orchestration metadata, NOT a permission source: they reference tool IDs that the agent and the user must already be permitted to use. """ from __future__ import annotations import logging import uuid from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.deps import get_current_user, require_permission from app.plugins.builtins.automation.models import SkillDefinitionDB from app.plugins.builtins.automation.schemas import ( SkillDefinitionCreate, SkillDefinitionListResponse, SkillDefinitionResponse, SkillDefinitionUpdate, ) from app.plugins.builtins.automation.services import SkillService logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1/skills", tags=["skills"]) # ─── Helper Functions ─── def _skill_to_response(s: SkillDefinitionDB) -> SkillDefinitionResponse: """Convert SkillDefinitionDB model to response schema.""" return SkillDefinitionResponse( id=str(s.id), name=s.name, description=s.description or "", instructions=s.instructions or "", allowed_tool_ids=[str(t) for t in (s.allowed_tool_ids or [])], context_policy=s.context_policy, category=s.category or "general", is_active=s.is_active, created_at=s.created_at.isoformat() if s.created_at else None, updated_at=s.updated_at.isoformat() if s.updated_at else None, ) # ─── CRUD Endpoints ─── @router.get( "", dependencies=[Depends(require_permission("automation:read"))], response_model=SkillDefinitionListResponse, ) async def list_skills( is_active: bool | None = Query(None), category: str | None = Query(None), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List skill definitions with optional filters.""" tenant_id = uuid.UUID(current_user["tenant_id"]) items, total = await SkillService.list( db, tenant_id, is_active=is_active, category=category, limit=limit, offset=offset ) return SkillDefinitionListResponse( items=[_skill_to_response(s) for s in items], total=total, ) @router.post( "/", dependencies=[Depends(require_permission("automation:write"))], response_model=SkillDefinitionResponse, status_code=201, ) async def create_skill( data: SkillDefinitionCreate, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Create a new skill definition.""" tenant_id = uuid.UUID(current_user["tenant_id"]) skill = await SkillService.create(db, tenant_id, data.model_dump()) return _skill_to_response(skill) @router.get( "/{skill_id}", dependencies=[Depends(require_permission("automation:read"))], response_model=SkillDefinitionResponse, ) async def get_skill( skill_id: str, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Get a single skill definition by ID.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: sid = uuid.UUID(skill_id) except (ValueError, TypeError): raise HTTPException(status_code=400, detail="Invalid skill ID") from None skill = await SkillService.get_by_id(db, tenant_id, sid) if skill is None: raise HTTPException(status_code=404, detail="Skill not found") return _skill_to_response(skill) @router.patch( "/{skill_id}", dependencies=[Depends(require_permission("automation:write"))], response_model=SkillDefinitionResponse, ) async def update_skill( skill_id: str, data: SkillDefinitionUpdate, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Update an existing skill definition.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: sid = uuid.UUID(skill_id) except (ValueError, TypeError): raise HTTPException(status_code=400, detail="Invalid skill ID") from None skill = await SkillService.update( db, tenant_id, sid, data.model_dump(exclude_none=True) ) if skill is None: raise HTTPException(status_code=404, detail="Skill not found") return _skill_to_response(skill) @router.delete( "/{skill_id}", dependencies=[Depends(require_permission("automation:delete"))], ) async def delete_skill( skill_id: str, current_user: dict[str, Any] = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Delete a skill definition.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: sid = uuid.UUID(skill_id) except (ValueError, TypeError): raise HTTPException(status_code=400, detail="Invalid skill ID") from None success = await SkillService.delete(db, tenant_id, sid) if not success: raise HTTPException(status_code=404, detail="Skill not found") return {"status": "ok"}