feat(kb): edit + re-embed document summaries from the viewer (admin)
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user