Files
LLeMbas/src/lembas/services/library/documents.py
T
Jaroslav Beneš 20bb569b00 Finding a thing that does not use your words
Three pieces, and the first one is that they are all optional.

Extraction stops being constants. Upload size, image edge, JPEG quality, PDF
pages, extracted characters, orphan age and the text-extension list are settings
now, read through a process-level snapshot rather than a session -- `prepare` and
everything under it are called from routes, tool runners and the startup sweep,
and several of those have no session in hand. Two things deliberately stayed
constants: the decompression-bomb guard, which is a guard and not a preference,
and ORPHAN_AGE, which would have been evaluated at import if it stayed in the
signature and pinned the shipped 24 hours whatever anybody set.

An embedding model is picked from the models an administrator flagged for it, and
one that has since lost its flag is *named* rather than dropped from the picker:
a setting that vanishes is one nobody can tell from a setting never made. Nothing
here is required. Choosing none means no chunk rows, no requests, and
retrieval.search returning exactly what fts.search_ids returns in exactly that
order -- asserted, because it is what makes this safe to land on an instance that
never asked for it.

The two rankings are fused by reciprocal rank fusion: ranks and not scores,
because bm25 is a corpus-dependent negative and cosine is 0..1, and normalising
them onto one scale means picking a constant nobody can tune without a labelled
set they do not have. RRF's one constant is famously insensitive and degrades to
whichever list is non-empty -- which is what turns "no embedding model" into a
branch that does not exist.

A record scores as its best chunk rather than its average, or a long document
about something else outranks a short one that says the thing. Width and model
are stored beside every vector and a mismatch is skipped, because vectors from
two spaces score against each other perfectly happily and mean nothing -- a
search that works and is wrong is the worst failure this can have, and a model
change now leaves stale rows ignored rather than trusted.

Indexing is fired and forgotten, and how a change is noticed is a session event
rather than a call in each of the ten library writers. That is a departure from
this codebase's taste for explicit seams, for the reason tool_label is a Jinja
global: a step every writer has to remember is one that gets forgotten, and here
forgetting is silent -- the record saves, keyword search still finds it, and only
its recall goes stale. Chunks are embedded before anything is deleted, so a
failure leaves the old index rather than half a new one.

Also: `embeddings` joins the model capabilities, and the three tool flags that
had shipped with no checkbox -- canvas, scheduling and helpers -- have one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:15:21 +02:00

356 lines
12 KiB
Python

"""The knowledge library: documents a person has collected.
Ingestion is deliberately **not** written here. A knowledge document and a chat
attachment are the same processing problem -- sniff the bytes, downscale the
image, extract the PDF once -- so both go through
``services.files.prepare``. Keeping one pipeline is what guarantees the same
PDF produces the same text whichever way it arrived, and it is why `Document`
carries the same content columns as `Attachment`.
"""
from __future__ import annotations
import logging
import secrets
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.config import settings
from lembas.db.models import (
CHUNK_DOCUMENT,
SOURCE_LINK,
SOURCE_UPLOAD,
Document,
KnowledgeBase,
User,
)
from lembas.services import files as files_service
from lembas.services import sharing
from lembas.services.fetch import Fetched
from lembas.services.library import retrieval
log = logging.getLogger(__name__)
INDEX = "documents_fts"
# What a first base is called when one has to be invented -- on the first
# upload, or for documents that predate bases existing.
DEFAULT_BASE_NAME = "My documents"
# How much of a document's text a search result carries back to the model. A
# whole 100-page extract would swallow the context window; this is enough to
# judge relevance and to answer from, and `knowledge_get` fetches the rest.
SNIPPET_CHARS = 1200
def library_dir() -> Path:
"""Where library files live, beside but separate from chat attachments."""
path = settings.uploads_dir / "library"
path.mkdir(parents=True, exist_ok=True)
return path
def stored_path(stored_name: str) -> Path | None:
"""Resolve a stored name, refusing anything outside the library directory.
The same check as ``services.files.stored_path``, against a different root.
"""
if not stored_name or "/" in stored_name or "\\" in stored_name or stored_name.startswith("."):
return None
base = library_dir().resolve()
path = (base / stored_name).resolve()
try:
path.relative_to(base)
except ValueError:
return None
return path if path.is_file() else None
# --- Bases -------------------------------------------------------------------
def visible_bases(db: DBSession, user: User | None):
return select(KnowledgeBase).where(sharing.visible_to(KnowledgeBase, user))
def get_base(db: DBSession, base_id: str, user: User | None) -> KnowledgeBase | None:
base = db.get(KnowledgeBase, base_id)
if base is None or not sharing.can_read(db, base, user):
return None
return base
def create_base(
db: DBSession, *, owner: User, name: str, description: str = ""
) -> KnowledgeBase:
name = " ".join((name or "").split())[:200] or DEFAULT_BASE_NAME
existing = db.scalar(
select(KnowledgeBase).where(
KnowledgeBase.owner_id == owner.id, KnowledgeBase.name == name
)
)
if existing is not None:
raise ValueError(f"You already have a knowledge base called {name!r}.")
base = KnowledgeBase(owner_id=owner.id, name=name, description=description.strip()[:2000])
db.add(base)
db.commit()
return base
def default_base(db: DBSession, owner: User) -> KnowledgeBase:
"""The base a document goes into when none was chosen.
Made on demand rather than at registration, so an account that never uses
the library never grows an empty one.
"""
base = db.scalar(
select(KnowledgeBase)
.where(KnowledgeBase.owner_id == owner.id)
.order_by(KnowledgeBase.created_at)
)
if base is not None:
return base
base = KnowledgeBase(owner_id=owner.id, name=DEFAULT_BASE_NAME)
db.add(base)
db.commit()
return base
def delete_base(db: DBSession, base: KnowledgeBase) -> None:
"""Delete a base and everything in it.
The documents go too -- a base is a place, not a label, and leaving its
contents behind with nowhere to live would need an "unfiled" concept that
exists only to hold the wreckage of deletes.
"""
for document in list(base.documents):
path = stored_path(document.stored_name)
if path is not None:
path.unlink(missing_ok=True)
sharing.forget_resource(db, base)
db.delete(base)
db.commit()
def sweep_unfiled(db: DBSession) -> int:
"""File documents that predate knowledge bases into their owner's default.
`Document.base_id` is nullable only because the column had to be added to a
table that already had rows. This is what makes "always set" true in
practice, and it runs at startup beside the orphaned-upload sweep.
"""
# Empty string as well as NULL: an earlier release added the column with a
# type-derived default, so a deployment that upgraded through it has rows
# holding "" rather than NULL. Both mean the same thing here.
unfiled = list(
db.scalars(select(Document).where((Document.base_id.is_(None)) | (Document.base_id == "")))
)
if not unfiled:
return 0
bases: dict[str, KnowledgeBase] = {}
for document in unfiled:
owner = db.get(User, document.owner_id)
if owner is None:
continue
if owner.id not in bases:
bases[owner.id] = default_base(db, owner)
document.base_id = bases[owner.id].id
db.commit()
log.info("filed %d document(s) that predated knowledge bases", len(unfiled))
return len(unfiled)
# --- Creating ----------------------------------------------------------------
def store_upload(
db: DBSession,
*,
owner: User,
payload: bytes,
filename: str,
title: str = "",
base: KnowledgeBase | None = None,
) -> Document:
"""Add an uploaded file to the library. Raises files.FileError if unusable."""
prepared = files_service.prepare(payload, filename)
base = base or default_base(db, owner)
stored_name = f"{secrets.token_hex(16)}{prepared.extension}"
(library_dir() / stored_name).write_bytes(prepared.payload)
display = files_service.safe_display_name(filename)
document = Document(
owner_id=owner.id,
base_id=base.id,
title=(title.strip() or display)[:300],
source=SOURCE_UPLOAD,
filename=display,
stored_name=stored_name,
media_type=prepared.media_type,
size_bytes=len(prepared.payload),
kind=prepared.kind,
width=prepared.width,
height=prepared.height,
extracted_text=prepared.extracted_text,
pages=prepared.pages,
truncated=prepared.truncated,
extraction_error=prepared.extraction_error,
)
db.add(document)
db.commit()
log.info("library: stored %r (%s) for %s", document.title, document.kind, owner.email)
return document
def store_page(
db: DBSession, *, owner: User, page: Fetched, base: KnowledgeBase | None = None
) -> Document:
"""Add a fetched web page to the library.
Saved as text rather than as the original HTML: the point of keeping it is
what it said, and the markup would have to be reduced again on every read.
"""
base = base or default_base(db, owner)
document = Document(
owner_id=owner.id,
base_id=base.id,
title=page.title[:300] or page.url[:300],
source=SOURCE_LINK,
source_url=page.url,
filename="",
media_type="text/plain",
size_bytes=len(page.text.encode("utf-8")),
kind="text",
extracted_text=page.text,
truncated=page.truncated,
)
db.add(document)
db.commit()
log.info("library: saved page %r for %s", document.title, owner.email)
return document
# --- Reading -----------------------------------------------------------------
def visible(db: DBSession, user: User | None, *, base_ids: list[str] | None = None):
"""Documents this user may see, optionally narrowed to some bases.
Visibility comes from the base, not the document: a document is readable by
whoever can read the base it lives in. That is the whole reason bases are
shareable and documents are not.
"""
condition = Document.base_id.in_(
select(KnowledgeBase.id).where(sharing.visible_to(KnowledgeBase, user))
)
query = select(Document).where(condition)
if base_ids:
# Still filtered by visibility above, so naming a base you cannot see
# returns nothing rather than granting access to it.
query = query.where(Document.base_id.in_(base_ids))
return query
def get(db: DBSession, document_id: str, user: User | None) -> Document | None:
document = db.get(Document, document_id)
if document is None:
return None
base = db.get(KnowledgeBase, document.base_id) if document.base_id else None
if base is None or not sharing.can_read(db, base, user):
return None
return document
def can_write(document: Document, user: User | None) -> bool:
"""Whether this person may change a document's text.
Ownership, through the same helper every other library store uses. Sharing
grants **reading only**, so being able to see a document through somebody
else's base is never enough to rewrite it -- and reading is already settled
by `get`, which resolves visibility through the base.
Its own function rather than `sharing.can_write` at the call site because
`Document` is the one store whose visibility does not come from itself, and
a reader arriving at a bare `sharing.can_write(document, …)` would have to
go and check whether that is the right question.
"""
return sharing.can_write(document, user)
def set_text(db: DBSession, document: Document, text: str) -> Document:
"""Replace the extracted text a person reads and a model searches.
The stored file is untouched: the bytes are the record, and this is what was
made of them. That is the same line PDF extraction draws -- extracted once
at upload, so a reply cannot change because a parser was upgraded -- and it
is why editing this is safe for transcripts: `files.copy_document` copies
the text when a document is attached, so an edit only changes what future
searches find.
`extraction_error` is cleared, because replacing a failed extraction by hand
is the main reason to want this at all; leaving the old apology beside the
new text would be the page contradicting itself.
The commit fires the `documents_fts` UPDATE trigger, so search stays correct
with nothing else to do. See `db/migrations.py:ensure_fts`.
"""
ceiling = files_service.limits().max_extracted_chars
document.extracted_text = text[:ceiling]
document.truncated = len(text) > ceiling
document.extraction_error = ""
db.commit()
return document
def search(
db: DBSession,
user: User | None,
needle: str,
*,
limit: int = 10,
base_ids: list[str] | None = None,
vector: list[float] | None = None,
) -> list[Document]:
"""Documents matching `needle` that this user may see, best match first.
The index is searched first and the visibility filter applied to the rows
it returned. That order matters: filtering afterwards is what makes it
impossible for a hit on somebody else's document to leak, even as a count.
`vector` is the query already embedded, or None. It comes from the caller
rather than being worked out here because this is synchronous and embedding
is an HTTP request -- see `services/library/retrieval.py`. None means the
keyword search exactly as it always was.
"""
hits = retrieval.search(
db, INDEX, needle, kind=CHUNK_DOCUMENT, vector=vector, limit=limit * 4
)
if not hits:
return []
order = {hit.id: position for position, hit in enumerate(hits)}
rows = list(
db.scalars(
visible(db, user, base_ids=base_ids).where(Document.id.in_(list(order)))
)
)
rows.sort(key=lambda document: order.get(document.id, len(order)))
return rows[:limit]
def snippet(document: Document) -> str:
"""The part of a document a search result carries."""
text = (document.extracted_text or "").strip()
if len(text) <= SNIPPET_CHARS:
return text
return text[:SNIPPET_CHARS].rstrip() + "…"
# --- Removing ----------------------------------------------------------------
def delete(db: DBSession, document: Document) -> None:
path = stored_path(document.stored_name)
if path is not None:
path.unlink(missing_ok=True)
db.delete(document)
db.commit()