Single consolidated commit for four completed, validated phases (77, 78, 79, 80). The pipeline run left all work uncommitted because the harness commits only with PHASE_COMMIT=1 while child executors are forbidden from committing; the phases themselves all passed validation and moved to .agents/phases/complete/. Phase 77 — navbar view refresh - router.js dispatches bor:view-refresh on re-show / active re-click / popstate (gated on wasMounted; first show and boot exempt) - History / RAG / Sources / Tuning re-fetch on refresh (admin branch); Chat deliberately excluded (stream survival) - History "Refresh" button (admin-only, in-flight disable + status line) - New story suite tests/e2e/test_navbar_refresh.py (7 tests) Phase 78 — static background - Removed the animated glow layers; static 44px grid over the flat --bg canvas; default and reduced-motion renders byte-identical - Updated background/theme E2E suites; removed bg-glow test pins Phase 79 — API tokens - api_tokens model + migration 0012; hash-only token service - Admin tokens API + Tokens admin view; POST /api/token-auth; live-revoking require_user on chat / suggestions / document content - Frontend token gate with localStorage cache; anonymous E2E suites migrated to token login - New story suite tests/e2e/test_api_tokens.py (9 tests) Phase 80 — history suggestion chips - last_questions() endpoint with SEED fallback; startNewChat() refetch - Seed-semantics docs (config.py, .env.example, README) - Integration state matrix + E2E suite rewritten to the 4 chip states Also included: phase-76 report artifacts and the repo restore-test-db skill (previously untracked), scripts/* ruff fixes from phase 77. Final gate state (phase 80 final pass, covers everything above): - uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99% - uv run ruff check . && uv run pyright → clean, 0 errors - Per-phase story E2E suites green in isolation
187 lines
7.2 KiB
Python
187 lines
7.2 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, 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
|
||
)
|