feat(kb): edit + re-embed document summaries from the viewer (admin)

This commit is contained in:
2026-08-31 23:52:22 -04:00
parent d94f3d5a52
commit 140b97ebf3
8 changed files with 1540 additions and 9 deletions
+350 -8
View File
@@ -1,19 +1,40 @@
"""Integration tests: GET /api/documents/content — the viewer's data source.
"""Integration tests: the document-content API — the viewer's data source.
Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient:
* 200 with the full field set for a seeded document (all formats);
* ``summary`` surfaced for summarized docs, ``null`` for markdown (phase 36);
* anonymous access stays 200 (phase 16 soft rule — public viewer);
* 404 for an unknown (source, path) pair;
* 404 for traversal-style ``path`` values (no filesystem access → no leak).
* GET: 200 with the full field set for a seeded document (all formats);
* GET: ``summary`` surfaced for summarized docs, ``null`` for markdown
(phase 36);
* GET: anonymous access stays 200 (phase 16 soft rule — public viewer);
* GET: 404 for an unknown (source, path) pair;
* GET: 404 for traversal-style ``path`` values (no filesystem access → no
leak).
PATCH /api/documents/summary (phase 57, task 01) — the admin summary
editor, on the same DB-backed fixtures:
* update: ``documents.summary`` + the ``is_summary`` chunk's content
replaced, fresh embedding, content chunks untouched (count unchanged —
D4 re-embed scope);
* clear: empty/whitespace text → ``summary`` NULL + ``is_summary`` chunk
deleted (idempotent, no embed call);
* markdown doc (no prior ``is_summary`` chunk) → one created at
position −1 with the new embedding;
* 404 unknown (source, path) (incl. traversal strings); 403 anonymous;
* embed failure → 503 sanitized detail, DB byte-for-byte untouched
(fail-before-write — embed before any mutation).
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import text
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select, text
from sqlalchemy.orm import Session
import app.api.docs as docs_api
from app.models import Chunk, Document
from app.rag.llm import EmbeddingError
from tests.fakes import FakeEmbedder
def _seed_doc(
@@ -24,8 +45,14 @@ def _seed_doc(
content: str = "# Kubernetes\n\nTalos on 3 nodes.",
chunks: int = 2,
summary: str | None = None,
summary_chunk: bool = False,
) -> None:
"""Truncate the KB and insert one document with ``chunks`` chunk rows."""
"""Truncate the KB and insert one document with ``chunks`` chunk rows.
``summary_chunk`` additionally indexes the phase-30 ``is_summary``
chunk at position −1 with the seeded vector (requires ``summary`` —
the phase-30 invariant: the chunk mirrors ``documents.summary``).
"""
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
doc = Document(
@@ -45,9 +72,324 @@ def _seed_doc(
Chunk(document_id=doc.id, position=i, content=f"chunk {i}", embedding=[0.01] * 768)
for i in range(chunks)
)
if summary_chunk:
assert summary is not None
db.add(
Chunk(
document_id=doc.id,
position=-1,
content=summary,
embedding=[0.01] * 768,
is_summary=True,
)
)
db.commit()
# ---------------------------------------------------------------------------
# PATCH /api/documents/summary (phase 57, task 01)
# ---------------------------------------------------------------------------
class _DeadEmbedder:
"""An ``LLMClient`` stand-in whose ``embed`` always fails — the
dead-endpoint path (phase 57 fail-before-write). The message mirrors
``LLMClient.embed``'s transport wrap, with embedded credentials that
the sanitizer must mask."""
def __init__(self) -> None:
self.calls: list[list[str]] = []
async def embed(self, texts: list[str]) -> list[list[float]]:
self.calls.append(list(texts))
raise EmbeddingError(
"embeddings request to https://user:secret@aipi.example.com/v1 "
"failed: connection refused"
)
def _seed_yaml_doc(db, *, summary: str, summary_chunk: bool) -> None:
"""One phase-30-style non-markdown doc: 2 content chunks + (optionally)
its ``is_summary`` chunk."""
_seed_doc(
db,
path="container_gitlab/gitlab-compose.yaml",
title="gitlab-compose",
content="services:\n gitlab:\n image: gitlab/gitlab-ce",
summary=summary,
summary_chunk=summary_chunk,
)
def _doc_id(db, path: str):
return db.scalar(select(Document.id).where(Document.path == path))
def _chunk_snapshots(db, doc_id) -> dict:
"""``{chunk id: (position, content, embedding, is_summary)}``.
Before/after comparisons prove exactly which rows a PATCH touched.
Embeddings are compared read-back to read-back: pgvector stores
float4, so raw Python floats do not round-trip exactly (the house
style elsewhere is ``is not None`` + dimension checks)."""
rows = db.scalars(select(Chunk).where(Chunk.document_id == doc_id)).all()
return {
c.id: (
c.position,
c.content,
list(c.embedding) if c.embedding is not None else None,
c.is_summary,
)
for c in rows
}
def test_summary_patch_update_reembeds_summary_chunk(
admin_client: TestClient, client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session
) -> None:
"""Admin PATCH with new text (phase 57, D4): ``documents.summary`` and
the ``is_summary`` chunk's content are replaced and the chunk gets a
fresh embedding — in place (same row id); the content chunks are
untouched (same ids, positions, contents, vectors) and the total
count is unchanged. One ``embed`` call, only with the new text."""
old = "GitLab CE runs in a Podman compose stack on the homelab NAS."
new = "GitLab CE is now backed by external PostgreSQL and MinIO."
_seed_yaml_doc(db, summary=old, summary_chunk=True)
doc_id = _doc_id(db, "container_gitlab/gitlab-compose.yaml")
db.expire_all()
before = _chunk_snapshots(db, doc_id)
assert len(before) == 3
try:
fake = FakeEmbedder()
monkeypatch.setattr(docs_api, "LLMClient", lambda: fake)
r = admin_client.patch(
"/api/documents/summary",
json={
"source": "Homelab",
"path": "container_gitlab/gitlab-compose.yaml",
"summary": new,
},
)
assert r.status_code == 200, r.text
body = r.json()
assert set(body) == {"source", "path", "summary", "chunks"}
assert body["source"] == "Homelab"
assert body["path"] == "container_gitlab/gitlab-compose.yaml"
assert body["summary"] == new
assert body["chunks"] == 3 # 2 content + 1 is_summary — unchanged by an update
db.expire_all()
row = db.get(Document, doc_id)
assert row is not None
assert row.summary == new
after = _chunk_snapshots(db, doc_id)
assert set(after) == set(before) # same rows: nothing added or deleted
sc = next(cid for cid, v in after.items() if v[3])
assert sc in before # replaced in place — the same row id
pos, content, vec, _ = after[sc]
assert pos == -1
assert content == new
assert vec is not None and len(vec) == 768
assert vec != before[sc][2] # fresh embedding, not the seeded one
for cid, v in after.items():
if not v[3]:
assert v == before[cid] # content chunks untouched, byte for byte
assert fake.calls == [[new]] # one embed call, the new text only
# The public viewer's data source now carries the new summary
# verbatim (anonymous — the viewer stays public, phase 16).
g = client.get(
"/api/documents/content",
params={"source": "Homelab", "path": "container_gitlab/gitlab-compose.yaml"},
)
assert g.status_code == 200
assert g.json()["summary"] == new
finally:
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
def test_summary_patch_clear(
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session
) -> None:
"""PATCH with empty/whitespace text clears (phase 57, D4):
``documents.summary`` → NULL and the ``is_summary`` chunk is deleted;
the content chunks are untouched. A second clear is an idempotent 200
and clearing never calls the embedder."""
old = "GitLab CE runs in a Podman compose stack on the homelab NAS."
_seed_yaml_doc(db, summary=old, summary_chunk=True)
doc_id = _doc_id(db, "container_gitlab/gitlab-compose.yaml")
db.expire_all()
before = _chunk_snapshots(db, doc_id)
assert len(before) == 3
try:
fake = FakeEmbedder()
monkeypatch.setattr(docs_api, "LLMClient", lambda: fake)
for body_summary in (" ", ""): # whitespace, then a true empty string
r = admin_client.patch(
"/api/documents/summary",
json={
"source": "Homelab",
"path": "container_gitlab/gitlab-compose.yaml",
"summary": body_summary,
},
)
assert r.status_code == 200, r.text
assert r.json() == {
"source": "Homelab",
"path": "container_gitlab/gitlab-compose.yaml",
"summary": None,
"chunks": 2,
}
db.expire_all()
row = db.get(Document, doc_id)
assert row is not None
assert row.summary is None
after = _chunk_snapshots(db, doc_id)
assert len(after) == 2 # the is_summary chunk row is gone (count −1)
for cid, v in after.items():
assert not v[3]
assert v == before[cid] # content chunks untouched, byte for byte
assert fake.calls == [] # clearing never embeds
finally:
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
def test_summary_patch_markdown_doc_creates_summary_chunk(
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session
) -> None:
"""A markdown doc (no ``is_summary`` chunk by construction — phase 30)
gets exactly one created at position −1 with the new embedding; the
content chunks are untouched and the count goes 2 → 3. This is also
the recovery path for a phase-30 fail-soft import (document indexed
without its summary chunk)."""
new = "Talos Kubernetes on 3 nodes with a CNI of choice."
_seed_doc(db, summary=None, summary_chunk=False) # the default kubernetes.md
doc_id = _doc_id(db, "kubernetes.md")
db.expire_all()
before = _chunk_snapshots(db, doc_id)
assert len(before) == 2
try:
fake = FakeEmbedder()
monkeypatch.setattr(docs_api, "LLMClient", lambda: fake)
r = admin_client.patch(
"/api/documents/summary",
json={"source": "Homelab", "path": "kubernetes.md", "summary": new},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["source"] == "Homelab"
assert body["path"] == "kubernetes.md"
assert body["summary"] == new
assert body["chunks"] == 3 # 2 content + the new is_summary
db.expire_all()
row = db.get(Document, doc_id)
assert row is not None
assert row.summary == new
after = _chunk_snapshots(db, doc_id)
assert len(after) == 3
for cid, v in before.items():
assert after[cid] == v # content chunks untouched, byte for byte
sc = next(cid for cid in after if cid not in before)
pos, content, vec, is_summary = after[sc]
assert (pos, is_summary) == (-1, True)
assert content == new
assert vec is not None and len(vec) == 768
assert fake.calls == [[new]]
finally:
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
def test_summary_patch_404_unknown_pair(admin_client: TestClient, db: Session) -> None:
"""Unknown (source, path) pairs — including traversal strings — are
just missing rows: 404 ``document not found`` (the same shape as the
public GET). A pair that exists under a DIFFERENT source is 404 too."""
_seed_yaml_doc(db, summary="old", summary_chunk=True)
try:
for source, path in (
("Homelab", "nope/missing.md"),
("Deployments", "container_gitlab/gitlab-compose.yaml"),
("Homelab", "../../etc/passwd"),
):
r = admin_client.patch(
"/api/documents/summary",
json={"source": source, "path": path, "summary": "whatever"},
)
assert r.status_code == 404, (source, path)
assert r.json() == {"detail": "document not found"}, (source, path)
finally:
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
def test_summary_patch_403_anonymous(client: TestClient, db: Session) -> None:
"""The edit affordance is admin-only (phase 57, D4 — the viewer stays
public): an anonymous PATCH gets 403 ``admin only`` and touches
nothing."""
old = "GitLab CE runs in a Podman compose stack on the homelab NAS."
_seed_yaml_doc(db, summary=old, summary_chunk=True)
doc_id = _doc_id(db, "container_gitlab/gitlab-compose.yaml")
try:
r = client.patch(
"/api/documents/summary",
json={
"source": "Homelab",
"path": "container_gitlab/gitlab-compose.yaml",
"summary": "not allowed",
},
)
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
db.expire_all()
row = db.get(Document, doc_id)
assert row is not None
assert row.summary == old # untouched
assert len(_chunk_snapshots(db, doc_id)) == 3
finally:
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
def test_summary_patch_embed_failure_503_db_untouched(
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session
) -> None:
"""Fail-before-write (phase 57 locked decision): a dead embedding
endpoint → 503 with a sanitized detail (credentials masked, the
reason survives — the ``git_sources.py`` ``ModelUnavailableError``
style) and the row + every chunk byte-for-byte as before."""
old = "GitLab CE runs in a Podman compose stack on the homelab NAS."
_seed_yaml_doc(db, summary=old, summary_chunk=True)
doc_id = _doc_id(db, "container_gitlab/gitlab-compose.yaml")
db.expire_all()
before = _chunk_snapshots(db, doc_id)
try:
dead = _DeadEmbedder()
monkeypatch.setattr(docs_api, "LLMClient", lambda: dead)
r = admin_client.patch(
"/api/documents/summary",
json={
"source": "Homelab",
"path": "container_gitlab/gitlab-compose.yaml",
"summary": "new text",
},
)
assert r.status_code == 503
detail = r.json()["detail"]
assert "*****@aipi.example.com" in detail # credentials masked
assert "user:secret" not in detail
assert "connection refused" in detail # the reason survives
assert dead.calls == [["new text"]] # the embed was attempted…
db.expire_all()
row = db.get(Document, doc_id)
assert row is not None
assert row.summary == old # …and failed before any mutation
assert _chunk_snapshots(db, doc_id) == before # every row, byte for byte
finally:
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
def test_content_200_all_fields(client, db) -> None:
_seed_doc(db)
try: