20bb569b00
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>
356 lines
12 KiB
Python
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()
|