feat(kb): edit + re-embed document summaries from the viewer (admin)
This commit is contained in:
+76
-1
@@ -4,6 +4,11 @@ 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
|
||||
|
||||
@@ -13,10 +18,12 @@ 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.schemas import DocContent, DocList, DocSummary
|
||||
from app.rag.llm import EmbeddingError, LLMClient
|
||||
from app.schemas import DocContent, DocList, DocSummary, SummaryResult, SummaryUpdate
|
||||
|
||||
router = APIRouter(tags=["kb"])
|
||||
|
||||
@@ -103,3 +110,71 @@ def get_document_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
|
||||
)
|
||||
|
||||
@@ -141,6 +141,40 @@ class DocContent(BaseModel):
|
||||
chunks: int
|
||||
|
||||
|
||||
class SummaryUpdate(BaseModel):
|
||||
"""``PATCH /api/documents/summary`` body (phase 57, task 01).
|
||||
|
||||
``source`` / ``path`` name the indexed document (the same pair the
|
||||
public ``GET /api/documents/content`` looks up); ``summary`` is the
|
||||
raw new text. The API strips it before storing — an
|
||||
empty/whitespace-only value is the *clear* operation (a first-class
|
||||
action, phase 57 D4), not a 422. Unconstrained on purpose: unknown
|
||||
pairs must 404 as "document not found" (row-lookup semantics),
|
||||
exactly like the public content endpoint.
|
||||
"""
|
||||
|
||||
source: str
|
||||
path: str
|
||||
summary: str
|
||||
|
||||
|
||||
class SummaryResult(BaseModel):
|
||||
"""``PATCH /api/documents/summary`` response (phase 57, task 01).
|
||||
|
||||
``summary`` is the stored text after the change (``null`` after a
|
||||
clear — the viewer's summary box hides on null) and ``chunks`` the
|
||||
document's post-change total chunk count: an update leaves the
|
||||
content chunks untouched (the count is unchanged — only the single
|
||||
``is_summary`` chunk is replaced), a clear drops one (the
|
||||
``is_summary`` chunk is deleted).
|
||||
"""
|
||||
|
||||
source: str
|
||||
path: str
|
||||
summary: str | None
|
||||
chunks: int
|
||||
|
||||
|
||||
class SteeringNoteIn(BaseModel):
|
||||
"""``POST /api/steering`` body: one tuning instruction (phase 15).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user