Newer
Older
navi-1 / debug / eval / api.py
"""FastAPI router for the eval system. Mounted from navi/main.py."""

from __future__ import annotations

from typing import Literal

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

from navi.api.deps import (
    get_backend_registry,
    get_profile_registry,
    get_session_store,
)
from navi.config import settings

from .db import EvalDB
from .judge import JUDGE_VERSION, RUBRIC_VERSION
from .runner import get_registry, start_run
from .schema import (
    RunRequest,
    RunStatus,
    SessionDetail,
    SessionOverview,
    StatsResponse,
)


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


_db: EvalDB | None = None


def _get_db() -> EvalDB:
    global _db
    if _db is None:
        if not settings.database_url:
            raise HTTPException(
                status_code=503,
                detail="DATABASE_URL is not set; eval system requires postgres",
            )
        _db = EvalDB(settings.database_url)
    return _db


# ── Feedback (Phase 1) ───────────────────────────────────────────────────


class FeedbackIn(BaseModel):
    session_id: str
    message_index: int = Field(ge=0)
    # 0 = clear (delete row); -1 / +1 = thumbs down / up.
    rating: Literal[-1, 0, 1]


@router.post("/feedback")
async def set_feedback(payload: FeedbackIn) -> dict:
    db = _get_db()
    if payload.rating == 0:
        await db.clear_feedback(payload.session_id, payload.message_index)
    else:
        await db.set_feedback(
            payload.session_id, payload.message_index, payload.rating
        )
    return {"ok": True}


@router.get("/feedback/{session_id}")
async def list_feedback(session_id: str) -> dict:
    db = _get_db()
    return {"feedback": await db.list_feedback(session_id)}


# ── Sessions overview / detail ───────────────────────────────────────────


@router.get("/sessions", response_model=list[SessionOverview])
async def list_sessions(
    limit: int = Query(50, ge=1, le=500),
    offset: int = Query(0, ge=0),
    profile: str | None = None,
    status: Literal["evaluated", "pending", "stale"] | None = None,
) -> list[SessionOverview]:
    db = _get_db()
    rows = await db.list_sessions_overview(
        judge_version=JUDGE_VERSION,
        rubric_version=RUBRIC_VERSION,
        limit=limit,
        offset=offset,
        profile=profile,
        status=status,
    )
    return [SessionOverview.model_validate(r) for r in rows]


@router.get("/sessions/{session_id}", response_model=SessionDetail)
async def get_session_detail(session_id: str) -> SessionDetail:
    db = _get_db()
    session_store = get_session_store()
    session = await session_store.get(session_id)
    if session is None:
        raise HTTPException(404, f"session not found: {session_id}")

    feedback = await db.list_feedback(session_id)
    evaluations = await db.list_evaluations(session_id)

    return SessionDetail(
        session_id=session.id,
        profile_id=session.profile_id,
        name=session.name,
        created_at=session.created_at,
        last_active=session.last_active,
        msg_count=len(session.messages),
        feedback=feedback,
        evaluations=evaluations,
    )


# ── Stats ────────────────────────────────────────────────────────────────


@router.get("/stats", response_model=StatsResponse)
async def get_stats(
    days: int = Query(30, ge=1, le=365),
    by_complexity_bucket: bool = False,
) -> StatsResponse:
    db = _get_db()
    raw = await db.aggregate_stats(
        judge_version=JUDGE_VERSION,
        rubric_version=RUBRIC_VERSION,
        days=days,
        by_complexity_bucket=by_complexity_bucket,
    )
    return StatsResponse.model_validate(raw)


# ── Run trigger / status (background tasks) ─────────────────────────────


@router.post("/run", response_model=RunStatus)
async def trigger_run(req: RunRequest) -> RunStatus:
    db = _get_db()
    return start_run(
        req=req,
        db=db,
        session_store=get_session_store(),
        backend_registry=get_backend_registry(),
        profile_registry=get_profile_registry(),
    )


@router.get("/run/{run_id}", response_model=RunStatus)
async def get_run_status(run_id: str) -> RunStatus:
    status = get_registry().get(run_id)
    if status is None:
        raise HTTPException(404, f"run not found: {run_id}")
    return status


@router.get("/runs", response_model=list[RunStatus])
async def list_runs() -> list[RunStatus]:
    return get_registry().list_runs()