feat(rag): lite-model document summaries — non-markdown docs summarized at import, summary chunk retrieves and resolves to the full source doc
This commit is contained in:
+81
-2
@@ -9,6 +9,12 @@ the two-phase upsert:
|
||||
2. embed the new chunks in batches and attach the vectors
|
||||
3. commit — one transaction per file, so a failed embedding leaves the
|
||||
database untouched and the file is simply retried on the next run
|
||||
4. non-markdown files only (phase 30): generate a ``lite``-model summary
|
||||
and, best-effort, store it on ``documents.summary`` plus one extra
|
||||
embedded chunk (``is_summary``, position −1). The document row and its
|
||||
content chunks are already committed at this point, so a summary
|
||||
failure only means the file is indexed without a summary (logged and
|
||||
counted in ``summary_errors``) — it is never lost.
|
||||
|
||||
Scope (A9, revised 2026-08-21): any path containing a dot-prefixed
|
||||
component (hidden dirs — vendored caches like ``.esphome/.espressif/**`` —
|
||||
@@ -36,7 +42,8 @@ from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import Chunk, Document
|
||||
from app.rag.chunker import chunk_document, extract_title
|
||||
from app.rag.llm import EmbeddingError
|
||||
from app.rag.llm import EmbeddingError, LLMError
|
||||
from app.rag.summarizer import generate_summary
|
||||
|
||||
logger = logging.getLogger("app.importer")
|
||||
|
||||
@@ -54,6 +61,10 @@ class Embedder(Protocol):
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[list[float]]: ...
|
||||
|
||||
async def chat(self, messages: list[dict[str, str]], model: str | None = None) -> str: ...
|
||||
# ^ the one-shot completion the summarizer uses for the ``lite`` model
|
||||
# (phase 30, task 01); :class:`app.rag.llm.LLMClient` satisfies it.
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportSummary:
|
||||
@@ -67,6 +78,12 @@ class ImportSummary:
|
||||
errors: int = 0
|
||||
chunks: int = 0
|
||||
embed_batches: int = 0
|
||||
#: Non-markdown files whose lite summary was generated + indexed
|
||||
#: (phase 30). One ``is_summary`` chunk per success.
|
||||
summaries: int = 0
|
||||
#: Non-markdown files whose summary generation failed (best-effort —
|
||||
#: the document is still indexed, without a summary).
|
||||
summary_errors: int = 0
|
||||
#: Files walked, keyed by lowercased extension (``md``, ``yaml``, …).
|
||||
formats: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
@@ -80,7 +97,8 @@ class ImportSummary:
|
||||
def log(self) -> None:
|
||||
logger.info(
|
||||
"import: summary files=%d added=%d updated=%d unchanged=%d pruned=%d "
|
||||
"errors=%d chunks=%d embed_batches=%d formats=%s",
|
||||
"errors=%d chunks=%d embed_batches=%d summaries=%d summary_errors=%d "
|
||||
"formats=%s",
|
||||
self.files,
|
||||
self.added,
|
||||
self.updated,
|
||||
@@ -89,6 +107,8 @@ class ImportSummary:
|
||||
self.errors,
|
||||
self.chunks,
|
||||
self.embed_batches,
|
||||
self.summaries,
|
||||
self.summary_errors,
|
||||
self.format_counts(),
|
||||
)
|
||||
|
||||
@@ -268,6 +288,65 @@ async def _index_file(
|
||||
summary.chunks += len(chunks_text)
|
||||
logger.info("import: %s source=%s path=%s chunks=%d", verb, source, rel, len(chunks_text))
|
||||
|
||||
# Phase 30: markdown is already natural language, so only the other A9
|
||||
# formats (txt, yaml, yml, json, py) get a ``lite``-model summary.
|
||||
if full_path.suffix.lower() in (".md", ".markdown"):
|
||||
return
|
||||
await _store_summary(
|
||||
session, doc=doc, source=source, rel=rel, content=content, llm=llm, summary=summary
|
||||
)
|
||||
|
||||
|
||||
async def _store_summary(
|
||||
session: Session,
|
||||
*,
|
||||
doc: Document,
|
||||
source: str,
|
||||
rel: str,
|
||||
content: str,
|
||||
llm: Embedder,
|
||||
summary: ImportSummary,
|
||||
) -> None:
|
||||
"""Best-effort ``lite`` summary for one already-committed document.
|
||||
|
||||
Generates the summary (task 03), stores it on ``documents.summary``
|
||||
and indexes it as one extra embedded chunk (``is_summary``,
|
||||
position −1) that hybrid search can hit instead of badly-formatted
|
||||
raw text. Replacement is guaranteed: any pre-existing ``is_summary``
|
||||
chunk of this document is deleted first, so at most one summary chunk
|
||||
exists per document at a time.
|
||||
|
||||
Best-effort by contract: the doc row + content chunks are committed
|
||||
by the caller before this runs, so an :class:`LLMError` /
|
||||
:class:`EmbeddingError` only rolls back the summary rows — the file
|
||||
stays indexed, without a summary, and the failure is counted in
|
||||
``summary_errors`` (PLAN phase 30).
|
||||
"""
|
||||
try:
|
||||
text = await generate_summary(llm, source=source, path=rel, content=content)
|
||||
# Replacement: at most one summary chunk per document at a time.
|
||||
# Removing from the collection is what the ``delete-orphan``
|
||||
# cascade turns into a row delete on flush — and it keeps the
|
||||
# in-memory collection consistent (this session runs with
|
||||
# ``expire_on_commit=False``).
|
||||
for old in [c for c in doc.chunks if c.is_summary]:
|
||||
doc.chunks.remove(old)
|
||||
chunk = Chunk(document_id=doc.id, position=-1, content=text, is_summary=True)
|
||||
vector = (await llm.embed([text]))[0]
|
||||
chunk.embedding = vector
|
||||
doc.summary = text
|
||||
# Append through the relationship (the ``all`` cascade persists the
|
||||
# row) so the collection — live in this session because of
|
||||
# ``expire_on_commit=False`` — reflects the committed state.
|
||||
doc.chunks.append(chunk)
|
||||
session.commit()
|
||||
summary.summaries += 1
|
||||
logger.info("import: summary source=%s path=%s chars=%d", source, rel, len(text))
|
||||
except (LLMError, EmbeddingError) as e:
|
||||
session.rollback()
|
||||
summary.summary_errors += 1
|
||||
logger.error("import: summary failed source=%s path=%s — %s", source, rel, e)
|
||||
|
||||
|
||||
def _prune(session: Session, source_names: set[str], seen: set[tuple[str, str]]) -> int:
|
||||
"""Delete documents of *source_names* whose file is no longer in *seen*."""
|
||||
|
||||
+49
-5
@@ -1,10 +1,12 @@
|
||||
"""Async OpenAI-compatible client for the self-hosted aipi endpoint (PLAN A5).
|
||||
|
||||
Provides the embeddings surface (importer, retrieval) and chat streaming
|
||||
(PLAN A15) for the RAG pipeline. Chat streaming yields typed
|
||||
:class:`StreamPiece` values (phase 17): aipi's ``turbo`` model streams
|
||||
its reasoning as ``delta.reasoning_content`` chunks (deepseek/litellm
|
||||
wire convention, verified live 2026-08-23) **before** the answer's
|
||||
Provides the embeddings surface (importer, retrieval), one-shot chat
|
||||
completions (phase 30: the ``lite`` model summarizes non-markdown
|
||||
documents at import time), and chat streaming (PLAN A15) for the RAG
|
||||
pipeline. Chat streaming yields typed :class:`StreamPiece` values
|
||||
(phase 17): aipi's ``turbo`` model streams its reasoning as
|
||||
``delta.reasoning_content`` chunks (deepseek/litellm wire convention,
|
||||
verified live 2026-08-23) **before** the answer's
|
||||
``delta.content`` chunks, and reasoning counts against ``max_tokens``
|
||||
(an answer can in principle be empty).
|
||||
|
||||
@@ -187,6 +189,48 @@ class LLMClient:
|
||||
(vec,) = await self.embed([text])
|
||||
return vec
|
||||
|
||||
async def chat(
|
||||
self, messages: list[dict[str, str]], model: str | None = None
|
||||
) -> str:
|
||||
"""One-shot (non-streaming) completion (A5 extended, phase 30).
|
||||
|
||||
Short, low-temperature request (``temperature=0.2``, 2048-token
|
||||
cap — summaries and outlines are small, so a fixed budget is
|
||||
enough) against ``BOR_LLM_SUMMARY_MODEL`` (default ``lite``)
|
||||
unless *model* names another. Used by the document summarizer
|
||||
(phase 30) and the KB overview generator (phase 31).
|
||||
|
||||
Any transport/HTTP/malformed failure, a choiceless reply, or an
|
||||
empty/missing ``content`` field raises :class:`LLMError` — a
|
||||
silent empty summary must never be stored.
|
||||
"""
|
||||
try:
|
||||
resp = await self._client.chat.completions.create(
|
||||
model=model or self.settings.llm_summary_model,
|
||||
messages=cast("list[ChatCompletionMessageParam]", messages),
|
||||
temperature=0.2,
|
||||
max_tokens=2048,
|
||||
stream=False,
|
||||
)
|
||||
except LLMError:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 — wrap transport-level failures
|
||||
raise LLMError(
|
||||
f"chat completion from {self.settings.llm_base_url} failed: {e}"
|
||||
) from e
|
||||
if not resp.choices:
|
||||
raise LLMError(
|
||||
f"chat completion from {self.settings.llm_base_url} "
|
||||
"returned no choices"
|
||||
)
|
||||
content = resp.choices[0].message.content
|
||||
if content is None or not content.strip():
|
||||
raise LLMError(
|
||||
f"chat completion from {self.settings.llm_base_url} returned "
|
||||
"empty content — refusing to store a silent summary"
|
||||
)
|
||||
return content.strip()
|
||||
|
||||
async def chat_stream(
|
||||
self, messages: list[dict[str, str]]
|
||||
) -> AsyncIterator[StreamPiece]:
|
||||
|
||||
+12
-1
@@ -56,6 +56,7 @@ _LEXICAL_SQL = text(
|
||||
d.content AS doc_content,
|
||||
d.content_hash AS content_hash,
|
||||
d.indexed_at AS indexed_at,
|
||||
c.is_summary AS is_summary,
|
||||
ts_rank(c.tsv, to_tsquery('english', :tsquery)) AS rank
|
||||
FROM chunks c
|
||||
JOIN documents d ON d.id = c.document_id
|
||||
@@ -75,6 +76,11 @@ class RetrievedChunk:
|
||||
* ``cosine`` — vector similarity ``1 − distance`` (the honesty-gate
|
||||
input; ``0.0`` for lexical-only hits that have no vector rank).
|
||||
* ``fts_hit`` — the chunk matched the question's OR-tsquery.
|
||||
* ``is_summary`` — True for the lite-model summary chunk (phase 30,
|
||||
position −1): its parent *is* the source document, so a summary hit
|
||||
resolves to the full source document through the unchanged
|
||||
chunk→document mapping (A7 revised). Default ``False`` keeps every
|
||||
ordinary content chunk valid.
|
||||
"""
|
||||
|
||||
chunk_id: uuid.UUID
|
||||
@@ -84,6 +90,7 @@ class RetrievedChunk:
|
||||
document: Document
|
||||
cosine: float = 0.0
|
||||
fts_hit: bool = False
|
||||
is_summary: bool = False
|
||||
|
||||
|
||||
def lexical_tsquery(question: str) -> str | None:
|
||||
@@ -148,7 +155,9 @@ def _vector_candidates(
|
||||
"""Top-*limit* chunks by pgvector cosine distance (``<=>``).
|
||||
|
||||
``cosine = 1 − distance``. Chunks whose embedding is still NULL
|
||||
(two-phase import in progress) are skipped.
|
||||
(two-phase import in progress) are skipped. Each candidate carries
|
||||
its ``Chunk.is_summary`` flag (phase 30) so a summary hit stays
|
||||
identifiable after fusion.
|
||||
"""
|
||||
distance = Chunk.embedding.cosine_distance(question_embedding)
|
||||
rows = db.execute(
|
||||
@@ -166,6 +175,7 @@ def _vector_candidates(
|
||||
score=0.0, # fused score is filled in by :func:`fuse`
|
||||
document=doc,
|
||||
cosine=round(1.0 - float(dist), 6),
|
||||
is_summary=chunk.is_summary,
|
||||
)
|
||||
for chunk, dist, doc in rows
|
||||
]
|
||||
@@ -205,6 +215,7 @@ def _lexical_candidates(db: Session, question: str, limit: int) -> list[Retrieve
|
||||
document=doc,
|
||||
cosine=0.0, # no vector rank — lexical-only hit
|
||||
fts_hit=True,
|
||||
is_summary=row.is_summary,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Document summarizer (phase 30, task 03).
|
||||
|
||||
Builds the ``SUMMARY_MODE`` prompt for one document, calls the aipi
|
||||
``lite`` model through the one-shot ``LLMClient.chat`` (phase 30,
|
||||
task 01), and returns the validated summary text with a
|
||||
**code-deterministic** pointer line back to the source::
|
||||
|
||||
Source: <source>/<path>
|
||||
|
||||
The pointer is appended by this module, never model-generated — the
|
||||
model is told what to summarize, not to cite.
|
||||
|
||||
Quality contracts enforced here:
|
||||
|
||||
* **Capped input** — the document content is cut at
|
||||
``BOR_SUMMARY_MAX_CHARS`` (default 12 000) before the single model
|
||||
call; overflow is cut exactly at the cap and the shared
|
||||
``TRUNCATION_MARKER`` (``[…truncated…]``) is appended, so the model
|
||||
never sees more than the cap and the cut is visible.
|
||||
* **No silent summaries** — a reply that is empty after trimming raises
|
||||
:class:`LLMError` (the client already rejects empty content; the
|
||||
summarizer re-asserts defensively and never hands the importer a
|
||||
pointer-only row).
|
||||
|
||||
The ``SUMMARY_MODE`` marker follows the ``DEFLECT_MODE`` convention:
|
||||
the deterministic E2E mock LLM keys on it in the system prompt
|
||||
(``tests/e2e/mock_llm.py`` — wired in task 06).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.rag.llm import LLMError
|
||||
from app.rag.retriever import TRUNCATION_MARKER
|
||||
|
||||
#: System-prompt marker for summary generation — the E2E mock LLM keys on
|
||||
#: it (same convention as ``DEFLECT_MODE``, PLAN §6).
|
||||
SUMMARY_MODE = "SUMMARY_MODE"
|
||||
|
||||
#: Locked instruction for the ``lite`` model (phase 30): the summary is a
|
||||
#: natural-language retrieval target, so it must be plain, concrete, and
|
||||
#: strictly grounded in the document.
|
||||
SUMMARY_INSTRUCTION = (
|
||||
"Write a 3-6 sentence plain-text summary of this document in natural "
|
||||
"language. Cover what it configures/defines and its most important "
|
||||
"values. Do not use markdown. Do not invent anything that is not in "
|
||||
"the document."
|
||||
)
|
||||
|
||||
#: Full system prompt: marker first (the mock's key), then the instruction.
|
||||
SYSTEM_PROMPT = f"{SUMMARY_MODE}: {SUMMARY_INSTRUCTION}"
|
||||
|
||||
|
||||
class SummaryLLM(Protocol):
|
||||
"""The one-shot chat surface the summarizer needs.
|
||||
|
||||
:class:`app.rag.llm.LLMClient` satisfies it; unit tests pass a
|
||||
duck-typed fake (``chat`` + ``settings``) instead — same pattern as
|
||||
the importer's ``Embedder`` protocol.
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
|
||||
async def chat(
|
||||
self, messages: list[dict[str, str]], model: str | None = None
|
||||
) -> str: ...
|
||||
|
||||
|
||||
def _capped_content(content: str, max_chars: int | None) -> str:
|
||||
"""Document content for the user message, capped at *max_chars*.
|
||||
|
||||
The default cap is ``BOR_SUMMARY_MAX_CHARS``. Overflow is cut exactly
|
||||
at the cap and the shared ``TRUNCATION_MARKER`` is appended on its
|
||||
own line; content that fits (length ≤ cap) passes through unchanged.
|
||||
"""
|
||||
limit = max_chars if max_chars is not None else get_settings().summary_max_chars
|
||||
if len(content) <= limit:
|
||||
return content
|
||||
return content[:limit] + "\n" + TRUNCATION_MARKER
|
||||
|
||||
|
||||
def build_summary_prompt(
|
||||
source: str, path: str, content: str, max_chars: int | None = None
|
||||
) -> tuple[str, str]:
|
||||
"""The ``(system, user)`` message pair for one summary call.
|
||||
|
||||
* ``system`` — :data:`SYSTEM_PROMPT`: the ``SUMMARY_MODE`` marker +
|
||||
the locked instruction.
|
||||
* ``user`` — the document content, capped (see :func:`_capped_content`).
|
||||
|
||||
*source* and *path* are part of the signature so the call site reads
|
||||
like the document it summarizes (and for :func:`generate_summary`'s
|
||||
pointer) — the pointer is built in code and deliberately **not** part
|
||||
of the prompt, so the model cannot echo or mangle it.
|
||||
"""
|
||||
return SYSTEM_PROMPT, _capped_content(content, max_chars)
|
||||
|
||||
|
||||
async def generate_summary(
|
||||
llm: SummaryLLM, *, source: str, path: str, content: str
|
||||
) -> str:
|
||||
"""One-shot ``lite`` summary of *content*, ending in the pointer line.
|
||||
|
||||
Returns the model's text (trimmed) plus the deterministic
|
||||
``Source: <source>/<path>`` line — the pointer is appended by code,
|
||||
never model-generated. Raises :class:`LLMError` when the model
|
||||
returns nothing usable after trimming, and propagates any
|
||||
:class:`LLMError` the client raises (the importer's fail-soft path
|
||||
turns that into a logged, counted ``summary_errors`` entry).
|
||||
"""
|
||||
system, user = build_summary_prompt(source, path, content)
|
||||
raw = await llm.chat(
|
||||
[{"role": "system", "content": system}, {"role": "user", "content": user}],
|
||||
model=llm.settings.llm_summary_model,
|
||||
)
|
||||
summary = raw.strip()
|
||||
if not summary:
|
||||
raise LLMError(
|
||||
f"summary model returned empty content for {source}/{path} — "
|
||||
"refusing to store a silent summary"
|
||||
)
|
||||
return f"{summary}\nSource: {source}/{path}"
|
||||
Reference in New Issue
Block a user