181 lines
6.9 KiB
Python
181 lines
6.9 KiB
Python
"""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
|
||
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_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 sign-in gates; the
|
||
document viewer itself stays public, see below). 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
|
||
) -> 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"}``.
|
||
|
||
Deliberately PUBLIC for anonymous callers (phase 16 soft rule, owner
|
||
decision 2026-08-22): the *catalog* (``GET /api/docs``) is what the
|
||
sign-in gates, not the viewer — chat cites documents and anyone may
|
||
open a cited document by direct URL.
|
||
"""
|
||
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
|
||
)
|