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
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:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user