Files
LLeMbas/src/lembas/services/reports.py
T
Jaroslav Beneš 1b8c9f948c Grants that outlive what they name, and a rule you can read
sharing.forget_principal has existed since shares did, documented as the thing
that stops a recycled id inheriting somebody's grant, and was called by nobody.
Deleting a group left every grant naming it; deleting an account left both the
grants to it and the grants of its own work -- that second half is the one
nothing else could catch, since their rows cascade and the shares of those rows
have nothing to cascade from. Both now run before the delete, while the rows are
still findable, and a deleted resource forgets its own.

library.share defaulted to False, which meant sharing shipped documented as done
and unreachable: the panel only renders for somebody holding it, so out of the
box nobody could share anything and nothing said why. It is on.

The panel itself was checkboxes inside the resource's *save form*, listing every
group and every account on the instance, unpaginated, on every detail page -- and
a tick only took effect if you also saved the resource. It is its own routes now:
search, one grant per POST, the panel re-rendered from what is stored. Anything
already shared stays listed whatever the search says, or removing a grant would
mean searching for the name it was given to.

Reports join the shareable set and memories still do not: a finished piece of
work is the thing somebody most wants to hand over, and a record about a person
is not content to pass round. reports.visible became sharing.visible_to, which is
the one line its own docstring predicted. Two things fell out: `owned` beside
`get`, because sharing grants reading and deleting is the owner's alone; and
reading somebody else's report no longer clears their unread dot.

Permissions gained the answer to "what can this person actually do?" --
explain() is resolve()'s working shown rather than thrown away, naming admin, the
baseline, or the groups that granted each one. That is the simulation the union
rule exists to make unnecessary, and until now the only way to get it was to open
every group and read the grids by eye. Users and groups are list-plus-detail, and
membership is edited from one side: it was on both, and a full-form POST from
either overwrote what the other had shown.

Read and write are split for notes, memory and skills -- checked on the tool's
declared risk, after the gate so it can only narrow, and defaulting on.

Quotas are the union rule applied to numbers, with the corner that makes it
interesting: zero means "no limit" and wins outright, or a group saying unlimited
would count for less than one saying a million. Absent means "no opinion".
_narrower folds a group's ceiling with the instance's and is deliberately not
min, for the same reason. Five axes, enforced where each is knowable -- before a
reply is built, before a second one starts, on an agent reply's clock, before a
minute of GPU, and beside the helper cap -- and usage is recorded even for a
reply that was stopped or errored, because an endpoint charges either way.

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

233 lines
7.8 KiB
Python

"""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() + "…"