"""GET /api/docs — the indexed document list (feeds the Sources page). GET /api/documents/content — one indexed document's full content (feeds the clickable document viewer, phase 10). DB-only by design: the (source, path) pair is looked up as a row, so there is no filesystem access and no path-traversal surface — ``../``-style values simply aren't rows (→ 404). PATCH /api/documents/summary — the admin summary editor (phase 57): update or clear ``documents.summary`` and re-embed the ``is_summary`` chunk (embed first, mutate second — a failed LLM call leaves the row and chunk untouched; the content chunks are never re-embedded, D4). """ from __future__ import annotations from pathlib import Path from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import func, select from sqlalchemy.orm import Session from app.api.sync import _sanitize_error from app.core.auth import require_admin, require_user from app.db import get_db from app.models import Chunk, Document from app.rag.llm import EmbeddingError, LLMClient from app.schemas import DocContent, DocList, DocSummary, SummaryResult, SummaryUpdate router = APIRouter(tags=["kb"]) def doc_format(path: str) -> str: """Lowercased path suffix without its dot (``kubernetes.md`` → ``md``, ``notes/deep.Markdown`` → ``markdown``); ``text`` when the path has no suffix — the value shown in the viewer's format badge.""" return Path(path).suffix.lower().lstrip(".") or "text" @router.get("/docs", response_model=DocList) def list_indexed_documents( db: Session = Depends(get_db), # noqa: B008 _admin: None = Depends(require_admin), # noqa: B008 ) -> DocList: """All indexed documents with per-document chunk counts. Admin-only (phase 16 — the catalog is what the admin sign-in gates; the document viewer below is user-gated since phase 79, the shared chats being the only anonymous surface). Anonymous callers get 403 ``admin only`` and the Sources page renders its sign-in gate instead. An empty list means the knowledge base has not been imported yet — the Sources page renders its designed empty state in that case. """ rows = db.execute( select( Document.id, Document.source, Document.path, Document.title, func.count(Chunk.id).label("chunks"), Document.indexed_at, ) .outerjoin(Chunk, Chunk.document_id == Document.id) .group_by(Document.id, Document.source, Document.path, Document.title, Document.indexed_at) .order_by(Document.source, Document.path) ).all() return DocList( documents=[ DocSummary( id=str(row.id), source=row.source, path=row.path, title=row.title, chunks=row.chunks, indexed_at=row.indexed_at.isoformat(), ) for row in rows ] ) @router.get("/documents/content", response_model=DocContent) def get_document_content( source: str, path: str, db: Session = Depends(get_db), # noqa: B008 _user: None = Depends(require_user), # noqa: B008 # phase 79: admin or live token ) -> DocContent: """Full content of one indexed document, looked up by ``(source, path)``. Stateless (A10) and database-only: unknown pairs — including traversal strings such as ``../../etc/passwd`` — are just non-existent rows and map to 404 ``{detail: "document not found"}``. User-gated (phase 79 — SUPERSEDES the phase-16 "deliberately PUBLIC (soft rule)" note, owner decision 2026-08-22): the viewer content is token-or-admin like the rest of the app surface — chat cites documents and a signed-in user (admin or token holder) opens a cited document by direct URL. The ONLY anonymous content left is the shared chats. """ row = db.execute( select(Document, func.count(Chunk.id).label("chunks")) .outerjoin(Chunk, Chunk.document_id == Document.id) .where(Document.source == source, Document.path == path) .group_by(Document.id) ).first() if row is None: raise HTTPException(status_code=404, detail="document not found") doc, chunks = row return DocContent( source=doc.source, path=doc.path, title=doc.title, format=doc_format(doc.path), summary=doc.summary, content=doc.content, indexed_at=doc.indexed_at.isoformat(), chunks=chunks, ) @router.patch("/documents/summary", response_model=SummaryResult) async def update_document_summary( payload: SummaryUpdate, db: Session = Depends(get_db), # noqa: B008 _admin: None = Depends(require_admin), # noqa: B008 ) -> SummaryResult: """Update or clear a document's stored summary and re-embed it. Admin-only (phase 57, D4) — the document viewer itself stays PUBLIC (phase 16 owner decision); only this edit affordance is gated. The re-embed scope is the ``is_summary`` chunk only (D4): the summary is the only text that changed, so the document's content chunks keep their existing embeddings — the total chunk count is unchanged by an update. Fail-before-write (phase 57 locked decision): when the stripped text is non-empty it is embedded **before** any DB mutation — an embedding failure returns 503 with a sanitized ``detail`` naming the failure (the ``ModelUnavailableError`` handling of ``app/api/git_sources.py``) and leaves the row and chunk untouched. An empty/whitespace-only ``summary`` clears instead: ``documents.summary = NULL`` and the ``is_summary`` chunk (if any) is deleted. """ doc = db.scalar( select(Document).where( Document.source == payload.source, Document.path == payload.path ) ) if doc is None: raise HTTPException(status_code=404, detail="document not found") summary_chunk = db.scalar( select(Chunk).where(Chunk.document_id == doc.id, Chunk.is_summary.is_(True)) ) text = payload.summary.strip() if text: # Embed first, mutate second — a failed LLM call must never # leave a half-updated row (phase 57 locked decision). llm = LLMClient() try: vector = (await llm.embed([text]))[0] except EmbeddingError as e: raise HTTPException(status_code=503, detail=_sanitize_error(str(e))) from None if summary_chunk is None: # Markdown doc, or a phase-30 fail-soft import that indexed # without a summary chunk — create the position −1 chunk. summary_chunk = Chunk(document_id=doc.id, position=-1, is_summary=True) db.add(summary_chunk) summary_chunk.content = text summary_chunk.embedding = vector doc.summary = text else: if summary_chunk is not None: db.delete(summary_chunk) doc.summary = None db.commit() chunks = db.scalar( select(func.count(Chunk.id)) .select_from(Document) .outerjoin(Chunk, Chunk.document_id == Document.id) .where(Document.id == doc.id) ) or 0 return SummaryResult( source=doc.source, path=doc.path, summary=doc.summary, chunks=chunks )