feat(500): Settings shows the shipped reply shapes, what adjusts each, and how often each arrives
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m19s
CI & Build / Python tests (push) Successful in 1m58s
CI & Build / Build & push image (push) Successful in 39s

Settings > General > Reply shapes. One menu row per shape (core, completion,
asks, plan): its title and when it arrives, how many of the operator's
preferences adjust it, and its deliveries over 30 days (in full / as a
reminder, "—" when the counts cannot be read). A verdict line leads. Opening a
row shows the shipped text, read-only because it is product, and the
preferences mounted on its moment; each opens in the rule editor, and "Adjust
this" opens a new preference already mounted on the shape's moment with a
starting trigger.

The rule editor takes an optional preset (kind, moments, trigger) and, opened
outside the Rules view, asks where a new record lives instead of dropping it.
GET /api/retrieval/reply-shapes now returns reply_shapes.overview: the
catalog plus mounted preferences (the same lookup that delivers them) and
counts from the reply_shape delivery rows. #5497 (step 5 of milestone 500).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 16:19:45 -04:00
co-authored by Claude Opus 5.5
parent b00dc7c4c2
commit 69dddb7551
8 changed files with 505 additions and 16 deletions
+9 -3
View File
@@ -149,10 +149,16 @@ async def moments_route():
async def reply_shapes_route():
"""The default reply shapes and the moment each rides (milestone 500).
The same payload `list_reply_shapes` returns, from the same service, so
the Settings view and the session cannot show different defaults.
`list_reply_shapes`' payload from the same service, so the Settings view
and the session cannot show different defaults — plus what only the
operator's view needs (step 5): per shape, the preferences mounted on its
moment and its deliveries over `days` (default 30, 1–90).
"""
return jsonify(shapes_svc.catalog())
try:
days = min(90, max(1, int(request.args.get("days") or shapes_svc.OVERVIEW_DAYS)))
except ValueError:
days = shapes_svc.OVERVIEW_DAYS
return jsonify(await shapes_svc.overview(get_current_user_id(), days=days))
async def _mapping_change(change):
+74
View File
@@ -287,3 +287,77 @@ async def record_delivery(user_id: int | None, forms: dict[str, str], *, via: st
await session.commit()
except Exception: # noqa: BLE001 - observation never breaks the observed
logger.debug("reply shape delivery not recorded", exc_info=True)
# ── The operator's view (milestone 500 step 5) ──────────────────────────
# The window the Settings view counts deliveries over — the same 30 days the
# moments panel beside it reads, so the two count the same stretch of work.
OVERVIEW_DAYS = 30
async def delivery_counts(user_id: int, *, days: int = OVERVIEW_DAYS) -> dict[str, dict[str, int]]:
"""How often each shape went out, in each form, over the last `days`.
Read from the `reply_shape` rows `record_delivery` writes — one per
delivery, naming each shape and whether it went whole or as its reminder.
A row whose details do not parse is skipped rather than guessed at.
"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.app_log import AppLog
since = datetime.now(timezone.utc) - timedelta(days=days)
counts: dict[str, dict[str, int]] = {k: {FULL: 0, POINTER: 0} for k in SHAPES}
async with async_session() as session:
rows = (await session.execute(
select(AppLog.details).where(
AppLog.category == "plugin", AppLog.action == "reply_shape",
AppLog.user_id == user_id, AppLog.created_at >= since,
)
)).scalars().all()
for raw in rows:
try:
forms = json.loads(raw or "{}").get("shapes") or {}
except (ValueError, AttributeError):
continue
for key, form in forms.items() if isinstance(forms, dict) else ():
if key in counts and form in counts[key]:
counts[key][form] += 1
return counts
async def overview(user_id: int, *, days: int = OVERVIEW_DAYS) -> dict:
"""The Settings view's read: every shape, the operator's preferences that
adjust it, and how often it was delivered.
A preference adjusts a shape by being MOUNTED on the shape's moment — the
same lookup that delivers it beside the shape — so what this lists is
exactly what arrives with it. Global homes only: a project's own
preferences are that project's, and its rules tab lists them.
The counts are telemetry and fail open to `deliveries_failed` with "—" in
the view; the preference lookup is the substance and is allowed to fail
the request, so the view shows an error instead of "no preferences".
"""
from scribe.services import rulebooks
data = catalog()
try:
counts: dict[str, dict[str, int]] | None = await delivery_counts(user_id, days=days)
except Exception: # noqa: BLE001 - a missing count is shown as unknown
logger.warning("reply shape delivery counts failed", exc_info=True)
counts = None
for row in data["shapes"]:
mounted = await rulebooks.rules_on_moments(user_id, [row["moment"]])
row["preferences"] = [
{"id": rule.id, "title": rule.title, "statement": rule.statement}
for rule, _at in mounted if rule.kind == "preference"
]
row["deliveries"] = counts.get(row["key"]) if counts is not None else None
data["days"] = days
data["deliveries_failed"] = counts is None
return data