feat(moments): the human door onto mounts, mappings and per-moment telemetry (milestone 458 step 6, #4924)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Failing after 1m34s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 2m7s

Everything an agent can do with moments, a person can now see and change
in the app.

- Rule editor: a moment picker beside the trigger. Catalog moments are
  ticked; a named procedure's `skill.<name>` is typed and checked as the
  server checks it. `moments` is always sent, so unticking the last moment
  unmounts the rule.
- Settings, Moments section (General tab): for each moment, what it
  means, the actions that reach it on this install (shipped ones can be
  switched off, the install's own removed), how many rules are mounted on
  it, and deliveries and agent opens over the window. Below that: named
  procedures with mounts, switched-off defaults with Restore, and a form to
  add an action.
- retrieval_telemetry.moment_usage: per moment, `delivered`, `rules`,
  `opened` (agent pulls after the first delivery there; an upper bound, as
  by_source is) and `last_delivered_at`. No ratio, because a mount is a
  person's statement, not a ranker's guess. Guarded on its own, and also
  reported in retrieval_summary as `moment_usage`.
- rulebooks.mount_counts; mounted_moments now derives from it.
- GET /api/retrieval/moments carries `mounted` and `usage` (?days=).
  DELETE /moments/mappings also reads the mapping from query parameters,
  since the browser's DELETE sends no body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 14:42:30 -04:00
co-authored by Claude Opus 5.5
parent c489452a2d
commit b73a689849
12 changed files with 869 additions and 9 deletions
+61
View File
@@ -199,3 +199,64 @@ async def test_the_delivery_lookup_answers_by_home(world):
# The plugin's "can anything arrive" answer spans every home.
assert await rulebooks_svc.mounted_moments(uid) >= {"work.finish", "reply.report", "work.deliver"}
assert not (await rulebooks_svc.mounted_moments(sid)) & {"work.finish", "work.deliver"}
# Counted the same way for the Settings view: a rule mounted twice counts
# once per moment, and another user's mounts are not in the count.
counts = await rulebooks_svc.mount_counts(uid)
assert counts.get("work.deliver", 0) >= 1 and counts.get("reply.report", 0) >= 1
assert set(counts) == await rulebooks_svc.mounted_moments(uid)
assert not set(await rulebooks_svc.mount_counts(sid)) & {"work.finish", "work.deliver"}
async def test_moment_usage_counts_deliveries_and_the_agent_opens_that_followed(world):
"""The per-moment readout (step 6), on real SQL: an EXISTS inside a
COUNT(CASE …) is a shape no mock can vouch for, and the block's own guard
would turn a database refusal into a quiet `moment_usage_failed`."""
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete
from scribe.models.rule_usage import PULLED, SURFACED, RuleUsageEvent
from scribe.services.retrieval_pipeline import MOMENT_RULE_SOURCE
from scribe.services.retrieval_telemetry import moment_usage
uid, sid, rule = world["uid"], world["sid"], world["rule"]
other = await rulebooks_svc.create_rule(
rule.topic_id, uid, "Close the task with the push",
"A task closes in the turn its CI goes green.",
when_to_apply="the CI run on a pushed commit has just gone green",
)
now = datetime.now(timezone.utc)
def ev(user, rule_id, event, source, ago, detail=None):
return RuleUsageEvent(user_id=user, rule_id=rule_id, event=event, source=source,
detail=detail, created_at=now - ago)
async with async_session() as s:
await s.execute(delete(RuleUsageEvent).where(RuleUsageEvent.user_id.in_([uid, sid])))
s.add_all([
ev(uid, rule.id, SURFACED, MOMENT_RULE_SOURCE, timedelta(hours=3), "work.finish"),
ev(uid, rule.id, SURFACED, MOMENT_RULE_SOURCE, timedelta(hours=2), "work.finish"),
ev(uid, other.id, SURFACED, MOMENT_RULE_SOURCE, timedelta(hours=1), "work.finish"),
ev(uid, rule.id, SURFACED, MOMENT_RULE_SOURCE, timedelta(hours=1), "reply.report"),
# An agent opened `rule` after its first work.finish delivery, and
# BEFORE its reply.report one — so it counts at the first only.
ev(uid, rule.id, PULLED, "mcp_get_rule", timedelta(minutes=150)),
# A person opening `other` in the browser is not an agent open.
ev(uid, other.id, PULLED, "rest_rule", timedelta(minutes=5)),
# A ranked surfacing is not a delivery at a moment.
ev(uid, other.id, SURFACED, "pre_tool_rule", timedelta(minutes=5)),
# Nor is a delivery older than the window, or someone else's.
ev(uid, other.id, SURFACED, MOMENT_RULE_SOURCE, timedelta(days=3), "work.deliver"),
ev(sid, rule.id, SURFACED, MOMENT_RULE_SOURCE, timedelta(minutes=5), "work.finish"),
])
await s.commit()
block = await moment_usage(uid, now - timedelta(days=1))
assert "moment_usage_failed" not in block
assert set(block["by_moment"]) == {"work.finish", "reply.report"}
finish = block["by_moment"]["work.finish"]
assert (finish["delivered"], finish["rules"], finish["opened"]) == (3, 2, 1)
report = block["by_moment"]["reply.report"]
assert (report["delivered"], report["rules"], report["opened"]) == (1, 1, 0)
assert report["last_delivered_at"]
+77
View File
@@ -133,3 +133,80 @@ async def test_setting_a_dial_to_the_value_it_already_has_records_nothing():
# And the setting is left alone too: rewriting the same value would bump
# whatever timestamp the row carries for no reason.
setter.assert_not_called()
# ── the moments view (milestone 458 step 6) ─────────────────────────────
async def _moments_call(handler_name, path, method="GET", **kw):
from types import SimpleNamespace
from quart import Quart, g
from scribe.routes import retrieval as routes
app = Quart(__name__)
async with app.test_request_context(path, method=method, **kw):
g.user = SimpleNamespace(id=7)
resp = await getattr(routes, handler_name).__wrapped__()
if isinstance(resp, tuple):
return await resp[0].get_json(), resp[1]
return await resp.get_json(), 200
async def test_the_view_gets_the_catalog_with_counts_beside_it():
from unittest.mock import AsyncMock, patch
from scribe.routes import retrieval as routes
usage = AsyncMock(return_value={"by_moment": {"work.finish": {"delivered": 2}}})
with patch.object(routes.moment_actions_svc, "actions_by_moment",
AsyncMock(return_value={"actions": {}, "removed_defaults": []})), \
patch.object(routes.rulebooks_svc, "mount_counts", AsyncMock(return_value={"work.finish": 1})), \
patch.object(routes, "moment_usage", usage):
body, status = await _moments_call("moments_route", "/api/retrieval/moments",
query_string={"days": "7"})
assert status == 200
assert body["mounted"] == {"work.finish": 1}
assert body["usage"]["days"] == 7
assert body["usage"]["by_moment"]["work.finish"]["delivered"] == 2
assert {m["name"] for m in body["moments"]} >= {"work.finish", "reply.report"}
async def test_a_failed_mount_count_is_flagged_not_shown_as_nothing():
from unittest.mock import AsyncMock, patch
from scribe.routes import retrieval as routes
with patch.object(routes.moment_actions_svc, "actions_by_moment",
AsyncMock(return_value={"actions": {}, "removed_defaults": []})), \
patch.object(routes.rulebooks_svc, "mount_counts", AsyncMock(side_effect=RuntimeError("down"))), \
patch.object(routes, "moment_usage", AsyncMock(return_value={"by_moment": {}})):
body, status = await _moments_call("moments_route", "/api/retrieval/moments")
assert status == 200
assert body["mounted"] == {} and body["mounted_failed"] is True
assert body["usage"]["days"] == 30
async def test_a_mapping_removal_can_come_as_query_parameters():
"""The browser's DELETE carries no body (api/client.apiDelete), so the
mapping it removes arrives in the query string."""
from unittest.mock import AsyncMock, patch
from scribe.routes import retrieval as routes
unmap = AsyncMock(return_value={"change": "removed this install's mapping"})
with patch.object(routes.moment_actions_svc, "unmap_action", unmap):
body, status = await _moments_call(
"unmap_action_route", "/api/retrieval/moments/mappings", method="DELETE",
query_string={"tool": "Bash", "match": "make ship", "moment": "work.deliver"},
)
assert status == 200
assert unmap.await_args.args == (7, "Bash", "make ship", "work.deliver")
assert unmap.await_args.kwargs["actor"] == "human"
async def test_a_mapping_with_neither_body_nor_parameters_is_refused():
body, status = await _moments_call(
"unmap_action_route", "/api/retrieval/moments/mappings", method="DELETE",
)
assert status == 400