feat(ui): clickable document viewer — open any cited document in the browser from chat chips and the sources table
This commit is contained in:
+48
-3
@@ -1,17 +1,32 @@
|
||||
"""GET /api/docs — the indexed document list (feeds the Sources page)."""
|
||||
"""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).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import get_db
|
||||
from app.models import Chunk, Document
|
||||
from app.schemas import DocList, DocSummary
|
||||
from app.schemas import DocContent, DocList, DocSummary
|
||||
|
||||
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)) -> DocList: # noqa: B008
|
||||
"""All indexed documents with per-document chunk counts.
|
||||
@@ -45,3 +60,33 @@ def list_documents(db: Session = Depends(get_db)) -> DocList: # noqa: B008
|
||||
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"}``.
|
||||
"""
|
||||
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),
|
||||
content=doc.content,
|
||||
indexed_at=doc.indexed_at.isoformat(),
|
||||
chunks=chunks,
|
||||
)
|
||||
|
||||
@@ -61,3 +61,15 @@ class DocList(BaseModel):
|
||||
"""Response of ``GET /api/docs`` (empty list → designed empty state)."""
|
||||
|
||||
documents: list[DocSummary]
|
||||
|
||||
|
||||
class DocContent(BaseModel):
|
||||
"""One indexed document's full content (feeds the viewer page, phase 10)."""
|
||||
|
||||
source: str
|
||||
path: str
|
||||
title: str
|
||||
format: str
|
||||
content: str
|
||||
indexed_at: str
|
||||
chunks: int
|
||||
|
||||
Reference in New Issue
Block a user