"""Notes: what the model wrote down, and what a person wrote for it. Longer and more specific than a memory, and not injected. A dozen notes would fill a context window on their own, so the model searches for the one it needs -- which is also why a note has a title worth reading: it is what a search result shows. """ from __future__ import annotations import logging from sqlalchemy import select from sqlalchemy.orm import Session as DBSession from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, CHUNK_NOTE, Note, User from lembas.services import sharing from lembas.services.library import retrieval log = logging.getLogger(__name__) INDEX = "notes_fts" MAX_TITLE_CHARS = 300 MAX_BODY_CHARS = 40_000 SNIPPET_CHARS = 800 def visible(db: DBSession, user: User | None): return select(Note).where(sharing.visible_to(Note, user)) def get(db: DBSession, note_id: str, user: User | None) -> Note | None: note = db.get(Note, note_id) if note is None or not sharing.can_read(db, note, user): return None return note def recent(db: DBSession, user: User | None, *, limit: int = 20) -> list[Note]: return list( db.scalars(visible(db, user).order_by(Note.updated_at.desc()).limit(limit)) ) def search( db: DBSession, user: User | None, needle: str, *, limit: int = 10, vector: list[float] | None = None, ) -> list[Note]: """Notes matching `needle` that this user may see, best match first. `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_NOTE, 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).where(Note.id.in_(list(order))))) rows.sort(key=lambda note: order.get(note.id, len(order))) return rows[:limit] def create( db: DBSession, *, owner: User, title: str, body: str, author: str = AUTHOR_USER ) -> Note: note = Note( owner_id=owner.id, title=(title.strip() or "Untitled")[:MAX_TITLE_CHARS], body=body.strip()[:MAX_BODY_CHARS], author=author if author in (AUTHOR_USER, AUTHOR_MODEL) else AUTHOR_USER, ) db.add(note) db.commit() return note def update(db: DBSession, note: Note, *, title: str | None = None, body: str | None = None) -> Note: """Change a note. Absent arguments are left alone, which is what lets a tool edit only the body without having to send the title back.""" if title is not None and title.strip(): note.title = title.strip()[:MAX_TITLE_CHARS] if body is not None: note.body = body.strip()[:MAX_BODY_CHARS] db.commit() return note def delete(db: DBSession, note: Note) -> None: sharing.forget_resource(db, note) db.delete(note) db.commit() def snippet(note: Note) -> str: text = (note.body or "").strip() if len(text) <= SNIPPET_CHARS: return text return text[:SNIPPET_CHARS].rstrip() + "…"