"""Reports: filing a finished piece of work, and finding it again. A report is written and read; it is never answered. That is the whole shape of the thing, and it is why this store is deliberately thinner than `services/library/`: there is no sharing, because a report is a record of what somebody's own model did on their behalf, and no revisions, because a report describes a moment rather than a document being worked on. `sharing.visible_to` is therefore absent on purpose rather than forgotten. If reports ever become shareable, `RESOURCE_TYPES` is where that starts, and every listing here has to go through the helper -- six independently written permission checks is how one of them ends up written slightly differently. """ from __future__ import annotations import logging from sqlalchemy import func, select from sqlalchemy.orm import Session as DBSession from lembas.db.models import SOURCE_MANUAL, SOURCES, Report, User from lembas.services.library.fts import search_ids log = logging.getLogger(__name__) INDEX = "reports_fts" MAX_TITLE_CHARS = 300 MAX_SUMMARY_CHARS = 500 MAX_BODY_CHARS = 60_000 SNIPPET_CHARS = 400 def visible(user: User | None): """Every report this person owns. Takes no session because it builds a query rather than running one, and takes `None` to mean nobody so an unauthenticated caller gets an empty result instead of an exception -- the same shape `sharing.visible_to` has, so a later move to shared reports is a change of one line here. """ if user is None: return select(Report).where(Report.id.is_(None)) return select(Report).where(Report.owner_id == user.id) def get(db: DBSession, report_id: str, user: User | None) -> Report | None: report = db.get(Report, report_id) if report is None or user is None or report.owner_id != user.id: return None return report def recent(db: DBSession, user: User | None, *, limit: int = 20) -> list[Report]: return list(db.scalars(visible(user).order_by(Report.created_at.desc()).limit(limit))) def search(db: DBSession, user: User | None, needle: str, *, limit: int = 20) -> list[Report]: """Reports matching `needle`, best match first. Ids come back from FTS and the rows are re-ordered by hit position, exactly as the library stores do -- the index knows about ranking and the ORM query knows about ownership, and neither is asked to do the other's job. """ hits = search_ids(db, INDEX, needle, limit=limit * 4) if not hits: return [] order = {hit.id: position for position, hit in enumerate(hits)} rows = list(db.scalars(visible(user).where(Report.id.in_(list(order))))) rows.sort(key=lambda report: order.get(report.id, len(order))) return rows[:limit] def unread_count(db: DBSession, user: User | None) -> int: if user is None: return 0 return int( db.scalar( select(func.count()).select_from(Report).where( Report.owner_id == user.id, Report.unread.is_(True) ) ) or 0 ) def _first_line(body: str) -> str: """A summary for a model that did not write one. Markdown headings are stripped rather than shown: a list of reports all beginning "# " reads as a bug, and the heading is nearly always the title again. """ for line in (body or "").splitlines(): stripped = line.strip().lstrip("#").strip() if stripped: return stripped[:MAX_SUMMARY_CHARS] return "" def create( db: DBSession, *, owner: User, title: str, body: str, summary: str = "", source: str = SOURCE_MANUAL, source_id: str = "", schedule_id: str = "", model_id: str = "", error: str = "", unread: bool = True, ) -> Report: """File a report. Trimming happens here rather than at the column so an over-long write from a tool is filed short with everything else intact, instead of failing the turn -- the rule `memories` already follows. `unread` defaults to True because every caller that matters is something that happened without the reader present. A report somebody typed themselves passes False. """ report = Report( owner_id=owner.id, title=(title.strip() or "Untitled report")[:MAX_TITLE_CHARS], summary=(summary.strip() or _first_line(body))[:MAX_SUMMARY_CHARS], body=(body or "").strip()[:MAX_BODY_CHARS], source=source if source in SOURCES else SOURCE_MANUAL, source_id=source_id or "", schedule_id=schedule_id or "", model_id=model_id or "", error=error or "", unread=unread, ) db.add(report) db.commit() return report def mark_read(db: DBSession, report: Report) -> Report: if report.unread: report.unread = False db.commit() return report def delete(db: DBSession, report: Report) -> None: db.delete(report) db.commit() def snippet(report: Report) -> str: text = (report.summary or report.body or "").strip() if len(text) <= SNIPPET_CHARS: return text return text[:SNIPPET_CHARS].rstrip() + "…"