Newer
Older
navi-1 / navi / api / routes / skills.py
"""Skills and protocols — the catalog behind the agent's methodologies.

Reads are open to every authenticated user for shared entries and to the owner
for personal ones. Writes follow the ownership rule: a user owns their personal
entries; shared entries exist only by an admin's hand.

The document format (YAML front matter + markdown body) is described in
``navi.skills.models``; the API stores it verbatim and lets the client edit it.
"""

from typing import Annotated, Literal

import structlog
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field

from navi.api.deps import get_skill_store
from navi.auth import User
from navi.auth.deps import require_user
from navi.skills import AREAS, KINDS, SkillEntry, SkillError, SkillStore

log = structlog.get_logger()

router = APIRouter(prefix="/skills", tags=["skills"])

Visibility = Literal["personal", "shared"]
Kind = Literal["skill", "protocol"]


def _may_read(entry: SkillEntry, user: User) -> bool:
    return entry.visibility == "shared" or entry.owner_id == user.id


def _may_write(entry: SkillEntry, user: User) -> bool:
    if entry.owner_id == user.id and entry.visibility == "personal":
        return True
    return entry.visibility == "shared" and user.role == "admin"


def _document_of(entry: SkillEntry) -> dict | None:
    """The entry's document, parsed, for a client that shows it as a document.

    A stored row can hold text that no longer parses (an old draft, a hand-
    edited row); ``None`` tells the client to fall back to the raw content.
    """
    try:
        doc = entry.doc
    except SkillError:
        return None
    return {
        "kind": doc.kind,
        "name": doc.name,
        "description": doc.description,
        "triggers": doc.triggers,
        "tags": doc.tags,
        "area": doc.area,
        "steps": doc.steps,
        "preconditions": doc.preconditions,
        "post_checks": doc.post_checks,
        "on_deviation": doc.on_deviation,
        "on_blocked": doc.on_blocked,
        "on_already_done": doc.on_already_done,
        "body": doc.body,
    }


def _payload(entry: SkillEntry, user: User, *, include_content: bool = True) -> dict:
    data = entry.to_dict(include_content=include_content)
    data["can_edit"] = _may_write(entry, user)
    if include_content:
        # Only one entry is ever sent with its content, so the parsed form
        # costs one parse and saves every client its own YAML reader.
        data["document"] = _document_of(entry)
    return data


class CreateSkillRequest(BaseModel):
    content: str = Field(description="Markdown document with a YAML front matter block.")
    visibility: Visibility = "personal"
    reason: str | None = Field(default=None, max_length=1000)


class UpdateSkillRequest(BaseModel):
    content: str
    reason: str | None = Field(default=None, max_length=1000)
    # Only an admin may move an entry between personal and shared.
    visibility: Visibility | None = None


@router.get("")
async def list_skills(
    user: Annotated[User, Depends(require_user)],
    store: Annotated[SkillStore, Depends(get_skill_store)],
    kind: Annotated[Kind | None, Query()] = None,
    q: Annotated[str | None, Query(max_length=200)] = None,
    tag: Annotated[str | None, Query(max_length=100)] = None,
    area: Annotated[str | None, Query()] = None,
    limit: Annotated[int, Query(ge=1, le=500)] = 100,
) -> dict:
    entries = await store.list(
        user_id=user.id, kind=kind, query=q, tag=tag, area=area, limit=limit,
    )
    return {
        # Documents can be long; the list carries the catalog card only and the
        # full text is fetched when one is opened.
        "items": [_payload(e, user, include_content=False) for e in entries],
        "areas": list(AREAS),
        "kinds": list(KINDS),
    }


@router.get("/proposals/{proposal_id}")
async def get_proposal(
    proposal_id: str,
    user: Annotated[User, Depends(require_user)],
    store: Annotated[SkillStore, Depends(get_skill_store)],
) -> dict:
    """A pending (or decided) protocol proposal, for the chat card to render.

    Declared before ``/{skill_id}`` — FastAPI matches routes in order, and the
    other path would swallow ``proposals`` as an entry id.
    """
    proposal = await store.get_proposal(proposal_id)
    if proposal is None or (proposal.owner_id != user.id and user.role != "admin"):
        raise HTTPException(status_code=404, detail="Proposal not found")
    return proposal.to_dict()


@router.get("/{skill_id}")
async def get_skill(
    skill_id: str,
    user: Annotated[User, Depends(require_user)],
    store: Annotated[SkillStore, Depends(get_skill_store)],
) -> dict:
    entry = await store.get(skill_id)
    if entry is None or not _may_read(entry, user):
        raise HTTPException(status_code=404, detail="Skill not found")
    return _payload(entry, user)


@router.post("", status_code=201)
async def create_skill(
    payload: CreateSkillRequest,
    user: Annotated[User, Depends(require_user)],
    store: Annotated[SkillStore, Depends(get_skill_store)],
) -> dict:
    if payload.visibility == "shared" and user.role != "admin":
        raise HTTPException(
            status_code=403,
            detail="Only an admin can create shared skills and protocols",
        )
    try:
        entry = await store.create(
            owner_id=user.id,
            content=payload.content,
            visibility=payload.visibility,
            edited_by=user.id,
            reason=payload.reason,
        )
    except SkillError as e:
        raise HTTPException(status_code=400, detail=str(e)) from e
    return _payload(entry, user)


@router.put("/{skill_id}")
async def update_skill(
    skill_id: str,
    payload: UpdateSkillRequest,
    user: Annotated[User, Depends(require_user)],
    store: Annotated[SkillStore, Depends(get_skill_store)],
) -> dict:
    entry = await store.get(skill_id)
    if entry is None or not _may_read(entry, user):
        raise HTTPException(status_code=404, detail="Skill not found")
    if not _may_write(entry, user):
        raise HTTPException(status_code=403, detail="You cannot edit this entry")
    if payload.visibility is not None and payload.visibility != entry.visibility:
        if user.role != "admin":
            raise HTTPException(status_code=403, detail="Only an admin can change visibility")
        try:
            entry = await store.set_visibility(
                skill_id, payload.visibility, edited_by=user.id,
            ) or entry
        except SkillError as e:
            raise HTTPException(status_code=400, detail=str(e)) from e
    try:
        updated = await store.update(
            skill_id, content=payload.content, edited_by=user.id, reason=payload.reason,
        )
    except SkillError as e:
        raise HTTPException(status_code=400, detail=str(e)) from e
    if updated is None:
        raise HTTPException(status_code=404, detail="Skill not found")
    return _payload(updated, user)


@router.delete("/{skill_id}", status_code=204)
async def delete_skill(
    skill_id: str,
    user: Annotated[User, Depends(require_user)],
    store: Annotated[SkillStore, Depends(get_skill_store)],
) -> None:
    entry = await store.get(skill_id)
    if entry is None or not _may_read(entry, user):
        raise HTTPException(status_code=404, detail="Skill not found")
    if not _may_write(entry, user):
        raise HTTPException(status_code=403, detail="You cannot delete this entry")
    if not await store.delete(skill_id):
        raise HTTPException(status_code=404, detail="Skill not found")
    log.info("skills.deleted", skill_id=skill_id, user_id=user.id)


@router.get("/{skill_id}/versions")
async def list_versions(
    skill_id: str,
    user: Annotated[User, Depends(require_user)],
    store: Annotated[SkillStore, Depends(get_skill_store)],
    limit: Annotated[int, Query(ge=1, le=200)] = 50,
) -> dict:
    entry = await store.get(skill_id)
    if entry is None or not _may_read(entry, user):
        raise HTTPException(status_code=404, detail="Skill not found")
    return {"items": await store.list_versions(skill_id, limit=limit)}