"""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 CHUNK_REPORT, SOURCE_MANUAL, SOURCES, Report, User from lembas.services import sharing from lembas.services.library import retrieval 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 or has been shared. 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. It said "a later move to shared reports is a change of one line here", and it was: `sharing.visible_to` is that line. Every listing, search and detail page went through this already, which is what made the move safe. """ return select(Report).where(sharing.visible_to(Report, user)) def get(db: DBSession, report_id: str, user: User | None) -> Report | None: report = db.get(Report, report_id) if report is None or not sharing.can_read(db, report, user): return None return report def owned(db: DBSession, report_id: str, user: User | None) -> Report | None: """The same, but only when they own it. Sharing grants **reading**, so deleting and marking-as-read are the owner's alone. Two functions rather than a flag, because a route that wants one and calls the other is a bug you can see in the name. """ report = db.get(Report, report_id) if report is None or not sharing.can_write(report, user): 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, vector: list[float] | None = None, ) -> 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. `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_REPORT, vector=vector, 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 unannounced(db: DBSession, user: User | None) -> list[Report]: """Reports that have arrived and have not been announced yet. Separate from `unread_count`, which drives the dot: the dot may be shown for as long as something is unread, while an announcement fires once. Reading them apart is what stops the poll interrupting somebody every ten seconds with the same report until they open it. Ordered oldest first, so several arriving between two ticks are announced in the order they were filed. """ if user is None: return [] return list( db.scalars( select(Report) .where( Report.owner_id == user.id, Report.unread.is_(True), Report.unread_notified.is_(False), ) .order_by(Report.created_at) ) ) 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() # Here rather than at the scheduled-run site, because a report is filed from # two places -- a schedule delivering one, and a model calling `report_write` # in a chat nobody stayed on -- and both are arrivals somebody would want to # know about. `unread` is what says it is news; a report filed with it off # was made by the person reading this screen. if report.unread: from lembas.services import push as push_service push_service.announce_later( report.owner_id, title=report.title or "Report filed", body=report.summary or "A report is waiting for you.", url=f"/reports/{report.id}", kind="report", ) 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: # Shares carry no foreign key to their resource, so nothing cascades and # this has to be said. A grant left behind names a report that has gone -- # harmless now and a grant to whoever next holds that id later. sharing.forget_resource(db, report) 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() + "…"