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
+78 -5
View File
@@ -139,18 +139,91 @@ def test_both_doors_read_one_catalog():
assert routes.shapes_svc is reply_shapes
async def test_the_route_returns_the_catalog():
async def test_the_route_returns_the_operators_overview_with_a_bounded_window():
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from quart import Quart, g
from scribe.routes import retrieval as routes
view = AsyncMock(return_value={"shapes": [], "total": 0})
app = Quart(__name__)
async with app.test_request_context("/api/retrieval/reply-shapes"):
g.user = SimpleNamespace(id=7)
resp = await routes.reply_shapes_route.__wrapped__()
assert await resp.get_json() == reply_shapes.catalog()
for raw, days in (("", 30), ("7", 7), ("900", 90), ("x", 30)):
async with app.test_request_context("/api/retrieval/reply-shapes", query_string={"days": raw}):
g.user = SimpleNamespace(id=7)
with patch.object(routes.shapes_svc, "overview", view):
resp = await routes.reply_shapes_route.__wrapped__()
assert await resp.get_json() == {"shapes": [], "total": 0}
assert view.await_args.kwargs == {"days": days}
# ── the operator's view (milestone 500 step 5) ──────────────────────────
async def test_the_overview_lists_the_preferences_mounted_on_each_shapes_moment():
"""What adjusts a shape is what is mounted beside it — and only
preferences: a rule on the same moment binds, it does not reshape."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
pref = SimpleNamespace(id=173, title="Problem first", statement="…", kind="preference")
rule = SimpleNamespace(id=11, title="Definition of done", statement="…", kind="rule")
async def mounted(user_id, moments, project_id=None):
return [(pref, "reply.report"), (rule, "reply.report")] if moments == ["reply.report"] else []
counts = {k: {"full": 0, "pointer": 0} for k in reply_shapes.SHAPES}
counts["core"] = {"full": 2, "pointer": 9}
with patch("scribe.services.rulebooks.rules_on_moments", mounted), \
patch.object(reply_shapes, "delivery_counts", AsyncMock(return_value=counts)):
data = await reply_shapes.overview(7, days=30)
rows = {r["key"]: r for r in data["shapes"]}
assert rows["core"]["preferences"] == [{"id": 173, "title": "Problem first", "statement": "…"}]
assert rows["core"]["deliveries"] == {"full": 2, "pointer": 9}
assert rows["completion"]["preferences"] == []
assert data["days"] == 30 and data["deliveries_failed"] is False
async def test_counts_that_fail_read_as_unknown_not_zero():
from unittest.mock import AsyncMock, patch
async def mounted(user_id, moments, project_id=None):
return []
with patch("scribe.services.rulebooks.rules_on_moments", mounted), \
patch.object(reply_shapes, "delivery_counts", AsyncMock(side_effect=RuntimeError("db"))):
data = await reply_shapes.overview(7)
assert data["deliveries_failed"] is True
assert all(r["deliveries"] is None for r in data["shapes"])
async def test_delivery_counts_tally_each_shape_by_form_and_skip_what_does_not_parse():
import json
from unittest.mock import MagicMock, patch
rows = [json.dumps({"shapes": {"core": "full"}, "via": "turn"}),
json.dumps({"shapes": {"core": "pointer", "completion": "full"}, "via": "hook"}),
json.dumps({"shapes": {"core": "pointer", "bogus": "full"}, "via": "turn"}),
"not json", None]
result = MagicMock()
result.scalars.return_value.all.return_value = rows
class _Session:
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def execute(self, _stmt):
return result
with patch("scribe.models.async_session", lambda: _Session()):
counts = await reply_shapes.delivery_counts(7, days=30)
assert counts["core"] == {"full": 1, "pointer": 2}
assert counts["completion"] == {"full": 1, "pointer": 0}
assert "bogus" not in counts
# ── delivery (milestone 500 step 3) ─────────────────────────────────────