Reply shapes ride the moments (milestone 500, steps 1–5) #209
@@ -159,6 +159,9 @@ _READ_ONLY_TOOLS = frozenset({
|
|||||||
# retrieval_telemetry's reason, and needed by a read key so that a line
|
# retrieval_telemetry's reason, and needed by a read key so that a line
|
||||||
# naming a moment can be understood by whoever was shown it.
|
# naming a moment can be understood by whoever was shown it.
|
||||||
"list_moments",
|
"list_moments",
|
||||||
|
# The default reply shapes (milestone 500). A pure read of a constant, and
|
||||||
|
# one a read key needs: a session shown a shape may want the rest.
|
||||||
|
"list_reply_shapes",
|
||||||
# The platform catalog and a project's answers (milestone 463). A pure
|
# The platform catalog and a project's answers (milestone 463). A pure
|
||||||
# read; set_project_platforms is the write.
|
# read; set_project_platforms is the write.
|
||||||
"list_platforms",
|
"list_platforms",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|||||||
from scribe.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
from scribe.services import moment_actions as actions_svc
|
from scribe.services import moment_actions as actions_svc
|
||||||
from scribe.services import moments as moments_svc
|
from scribe.services import moments as moments_svc
|
||||||
|
from scribe.services import reply_shapes as shapes_svc
|
||||||
from scribe.services import rule_moment_judgments as judgments_svc
|
from scribe.services import rule_moment_judgments as judgments_svc
|
||||||
|
|
||||||
|
|
||||||
@@ -47,6 +48,26 @@ async def list_moments() -> dict:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def list_reply_shapes() -> dict:
|
||||||
|
"""The default shapes of a reply, and the moment each one arrives at.
|
||||||
|
|
||||||
|
You do not need this to write a reply: the shapes come to you. The core
|
||||||
|
arrives with the turn, and each kind's shape arrives at the moment before
|
||||||
|
that kind of reply is written (closing a task, putting a question, opening
|
||||||
|
a plan). Read this to see them all at once, to answer the operator about
|
||||||
|
what the default is, or before writing a preference that changes one.
|
||||||
|
|
||||||
|
An operator's own adjustment to a shape is a `preference` mounted on that
|
||||||
|
shape's `moment` (create_preference with `moments=[...]`); it arrives
|
||||||
|
beside the default, and where the two differ the preference is what the
|
||||||
|
operator asked for.
|
||||||
|
|
||||||
|
Each shape carries `key`, `title`, `moment` (and what the moment `means`),
|
||||||
|
`delivered` (when it arrives) and `text`.
|
||||||
|
"""
|
||||||
|
return shapes_svc.catalog()
|
||||||
|
|
||||||
|
|
||||||
async def map_action(tool: str, moment: str, match: str = "", reason: str = "") -> dict:
|
async def map_action(tool: str, moment: str, match: str = "", reason: str = "") -> dict:
|
||||||
"""Make an action reach a moment on this install — the in-session fix for a missed moment.
|
"""Make an action reach a moment on this install — the in-session fix for a missed moment.
|
||||||
|
|
||||||
@@ -213,6 +234,7 @@ async def rule_misfired(rule_id: int, moment: str, why: str,
|
|||||||
|
|
||||||
def register(mcp) -> None:
|
def register(mcp) -> None:
|
||||||
mcp.tool(name="list_moments")(list_moments)
|
mcp.tool(name="list_moments")(list_moments)
|
||||||
|
mcp.tool(name="list_reply_shapes")(list_reply_shapes)
|
||||||
mcp.tool(name="map_action")(map_action)
|
mcp.tool(name="map_action")(map_action)
|
||||||
mcp.tool(name="unmap_action")(unmap_action)
|
mcp.tool(name="unmap_action")(unmap_action)
|
||||||
mcp.tool(name="rules_to_mount")(rules_to_mount)
|
mcp.tool(name="rules_to_mount")(rules_to_mount)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from quart import Blueprint, jsonify, request
|
|||||||
from scribe.auth import get_current_user_id, login_required
|
from scribe.auth import get_current_user_id, login_required
|
||||||
from scribe.services import moment_actions as moment_actions_svc
|
from scribe.services import moment_actions as moment_actions_svc
|
||||||
from scribe.services import moments as moments_svc
|
from scribe.services import moments as moments_svc
|
||||||
|
from scribe.services import reply_shapes as shapes_svc
|
||||||
from scribe.services import rule_moment_judgments as judgments_svc
|
from scribe.services import rule_moment_judgments as judgments_svc
|
||||||
from scribe.services import rulebooks as rulebooks_svc
|
from scribe.services import rulebooks as rulebooks_svc
|
||||||
from scribe.services.retrieval_telemetry import moment_usage
|
from scribe.services.retrieval_telemetry import moment_usage
|
||||||
@@ -143,6 +144,17 @@ async def moments_route():
|
|||||||
return jsonify(out)
|
return jsonify(out)
|
||||||
|
|
||||||
|
|
||||||
|
@retrieval_bp.route("/reply-shapes", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
return jsonify(shapes_svc.catalog())
|
||||||
|
|
||||||
|
|
||||||
async def _mapping_change(change):
|
async def _mapping_change(change):
|
||||||
"""map/unmap from the browser: the MCP tools' service, recorded as human.
|
"""map/unmap from the browser: the MCP tools' service, recorded as human.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""The default shapes of a reply, and the moment each one rides (milestone 500).
|
||||||
|
|
||||||
|
WHY THIS EXISTS
|
||||||
|
|
||||||
|
A reply is where the person the work is for finds out what happened, and they
|
||||||
|
were not there while it was done. Milestone 409 wrote the shapes that make a
|
||||||
|
reply readable to them — conclusion first, the shortest reply that carries the
|
||||||
|
answer, where the work stands — and put them in one bundled skill. A skill
|
||||||
|
arrives only when the agent decides to load it, so in a long session the shape
|
||||||
|
faded out of context exactly when replies were getting longer.
|
||||||
|
|
||||||
|
So the shapes are product content held here, on the server, and delivered by
|
||||||
|
the moments they belong to rather than by the agent remembering to ask:
|
||||||
|
|
||||||
|
- the CORE applies to every reply, so it rides every turn — in full once per
|
||||||
|
session and after a compaction, and as a one-line pointer otherwise;
|
||||||
|
- each SLICE belongs to one kind of reply, and arrives at the moment that
|
||||||
|
comes just before that kind is written: closing a task comes before a
|
||||||
|
completion report, a structured question before an ask, opening a plan
|
||||||
|
before a plan is put up for review.
|
||||||
|
|
||||||
|
The moment already knows which kind of reply is coming, so there is no "pick
|
||||||
|
the type, then load its shape" step for an agent to skip.
|
||||||
|
|
||||||
|
ONE COPY
|
||||||
|
|
||||||
|
This module is the single source. The `reporting-back` skill keeps the
|
||||||
|
long-form reference (a worked example, the reasoning) and points here; the
|
||||||
|
hooks carry timing and transport and say nothing of their own about shape. An
|
||||||
|
operator's adjustments are `preference` records mounted on the same moments,
|
||||||
|
and arrive beside the default — where the two differ, the preference is what
|
||||||
|
the operator asked for.
|
||||||
|
|
||||||
|
HOW TO WRITE ONE
|
||||||
|
|
||||||
|
Domain-neutral (rule 115): the work may be software, configuration,
|
||||||
|
infrastructure or anything else a person drives through an agent. "Evidence"
|
||||||
|
is what the reader could open to check; "verified" is whatever checking means
|
||||||
|
for that work. The test beside this module refuses software-only vocabulary.
|
||||||
|
|
||||||
|
Short, because it is paid for in every session's context. The core's budget is
|
||||||
|
asserted; a sentence added to it should replace one.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from scribe.services import moments
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReplyShape:
|
||||||
|
"""One piece of the default reply shape, and when it arrives."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
"""Stable identifier: what a delivery records and the Settings view keys on."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
"""What the piece is, as the Settings view names it."""
|
||||||
|
|
||||||
|
moment: str
|
||||||
|
"""The catalog moment this piece rides — where an operator's own
|
||||||
|
preferences for it are mounted too."""
|
||||||
|
|
||||||
|
delivered: str
|
||||||
|
"""When it arrives, said plainly for the Settings view."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
"""The shape itself, as the agent reads it."""
|
||||||
|
|
||||||
|
|
||||||
|
CORE_KEY = "core"
|
||||||
|
|
||||||
|
# ~550 tokens at four characters a token. The core is paid for in every
|
||||||
|
# session, so this is the ceiling the test holds it to — not a target. It
|
||||||
|
# shipped at ~1,800, leaving room for the one-line judgement (milestone 500
|
||||||
|
# step 2) and little else.
|
||||||
|
CORE_BUDGET_CHARS = 2200
|
||||||
|
# A slice is paid for only at its moment, and once per session; it can afford
|
||||||
|
# more than the core and still has to stay a slice, not the old skill again.
|
||||||
|
SLICE_BUDGET_CHARS = 1400
|
||||||
|
|
||||||
|
|
||||||
|
_CORE = """\
|
||||||
|
Shape the reply around where the work stands, not the order you did things in. \
|
||||||
|
The reader was not there while you worked.
|
||||||
|
|
||||||
|
- **Conclusion first** — the result, the verdict or the question, then what supports it.
|
||||||
|
- **The shortest reply that carries the answer.** Length is work handed back to the \
|
||||||
|
reader. It is earned by a comparison they asked for, options that need laying side by \
|
||||||
|
side, or numbers that are the point.
|
||||||
|
- **One topic per section; bold the few things that matter.**
|
||||||
|
- **Plain words** — the reader's vocabulary, not names you coined while working.
|
||||||
|
- **Place the work** — the task or plan it belongs to, by id and title, read from the \
|
||||||
|
record rather than recalled. Work with no record behind it says so.
|
||||||
|
- **Answer the standing questions, even with "nothing"**: does anything need them, and \
|
||||||
|
what happens next. A section that explains earns its place only when it changes what \
|
||||||
|
they do or decide; the rest belongs in the record's log.
|
||||||
|
- **A decision they have made is the input to the work.** Act on it; reopen it only \
|
||||||
|
for new evidence, said once.
|
||||||
|
- **Holding an action until they say yes?** Put it near the top, under "Approval requested".
|
||||||
|
- **End with the one thing they need to decide or do, in bold** — or say there is none.
|
||||||
|
|
||||||
|
By kind:
|
||||||
|
- **Answer** — the answer, then what they could open to check it. An evaluation is \
|
||||||
|
verdict · what exists · the gaps · a recommendation.
|
||||||
|
- **Proposal** — two or three approaches, the trade-off of each, one recommendation. \
|
||||||
|
A review is findings ranked by how much they matter.
|
||||||
|
- **Progress** — a line or two. **Blocked** — what stopped, what you tried, what you need.
|
||||||
|
- **Where are we** — the plan and its progress · done · open · needs you · next.
|
||||||
|
|
||||||
|
Before sending, read it as someone who was not there, reading quickly; then once more \
|
||||||
|
for what can go."""
|
||||||
|
|
||||||
|
|
||||||
|
_COMPLETION = """\
|
||||||
|
A completion report, from the `placement` the closing call returned:
|
||||||
|
|
||||||
|
- **Where this sits** — the plan and the step, or the task alone when it has no plan.
|
||||||
|
- **What now works** — outcomes the reader would notice ("you can now…", "X no longer…"), \
|
||||||
|
not the steps behind them; those belong in the task's log.
|
||||||
|
- **How / why** — only the decisions worth knowing, and how it was verified. Anything \
|
||||||
|
that could not be verified is said here rather than left to read as passed.
|
||||||
|
- **Needs you** — an action, an approval, a decision, or "nothing". It takes two tests: \
|
||||||
|
is it theirs to decide, and is work waiting on it? A question you could settle by \
|
||||||
|
reading or measuring something is work not yet done — settle it, say which way you went, \
|
||||||
|
and leave them free to overrule.
|
||||||
|
- **Next** — from `placement.next`, or say the plan is finished. An offer to fix \
|
||||||
|
something you found goes here."""
|
||||||
|
|
||||||
|
|
||||||
|
_ASKS = """\
|
||||||
|
Asking them to decide or to act — pick the shape:
|
||||||
|
|
||||||
|
- **Decision** — the question first · two to four options, each with what it changes · \
|
||||||
|
your recommendation first among them.
|
||||||
|
- **Clarification** — "My reading is X · the gap is Y · unless you say otherwise I'll do Z."
|
||||||
|
- **Handoff** (only they can do it) — the action · why it needs them · what it unblocks · \
|
||||||
|
what you will do after.
|
||||||
|
- **Approval** (ready, and holding for a yes) — under "Approval requested": exactly what \
|
||||||
|
happens on yes, one numbered item per change so they can approve part · why it needs \
|
||||||
|
them · how it is undone.
|
||||||
|
- **Conflict** (what you are about to do clashes with a rule, a plan or an earlier \
|
||||||
|
decision) — what it says · what you were about to do · where they clash · A or B?
|
||||||
|
|
||||||
|
Before asking, check whether you can find the answer yourself: something you can read \
|
||||||
|
or measure is a fact to check, not a question to send. When an option rests on existing \
|
||||||
|
behaviour, say whose call that was — theirs (cite it) or a past session's nobody confirmed."""
|
||||||
|
|
||||||
|
|
||||||
|
_PLAN = """\
|
||||||
|
A plan put up for review, before starting:
|
||||||
|
|
||||||
|
- **Goal** — what done looks like, and why, in a sentence or two.
|
||||||
|
- **Steps** — in order, each small enough to check on its own.
|
||||||
|
- **How you will know it works** — something observable, not "it is written".
|
||||||
|
- **Open questions** — only the ones that are theirs to answer."""
|
||||||
|
|
||||||
|
|
||||||
|
SHAPES: dict[str, ReplyShape] = {s.key: s for s in (
|
||||||
|
ReplyShape(CORE_KEY, "Every reply", "reply.report",
|
||||||
|
"every turn — in full once per session and after a compaction, "
|
||||||
|
"otherwise as a one-line pointer", _CORE),
|
||||||
|
ReplyShape("completion", "Completion report", "work.finish",
|
||||||
|
"when a piece of work is closed", _COMPLETION),
|
||||||
|
ReplyShape("asks", "Asking them to decide or act", "reply.ask",
|
||||||
|
"when a structured question is put to them", _ASKS),
|
||||||
|
ReplyShape("plan", "A plan for review", "work.plan",
|
||||||
|
"when a plan is opened", _PLAN),
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
def core() -> ReplyShape:
|
||||||
|
return SHAPES[CORE_KEY]
|
||||||
|
|
||||||
|
|
||||||
|
def for_moments(names: list[str]) -> list[ReplyShape]:
|
||||||
|
"""The slices that ride these moments, in catalog order. The core is not a
|
||||||
|
slice: it rides every turn, whatever moment the turn reaches."""
|
||||||
|
wanted = set(names)
|
||||||
|
return [s for s in SHAPES.values() if s.key != CORE_KEY and s.moment in wanted]
|
||||||
|
|
||||||
|
|
||||||
|
def catalog() -> dict:
|
||||||
|
"""The shapes as plain data, for the MCP tool and the REST door alike."""
|
||||||
|
return {
|
||||||
|
"shapes": [
|
||||||
|
{"key": s.key, "title": s.title, "moment": s.moment,
|
||||||
|
"means": moments.MOMENTS[s.moment].means,
|
||||||
|
"delivered": s.delivered, "text": s.text}
|
||||||
|
for s in SHAPES.values()
|
||||||
|
],
|
||||||
|
"total": len(SHAPES),
|
||||||
|
}
|
||||||
@@ -498,6 +498,22 @@ async def rule_row(rule_id: int):
|
|||||||
return await s.get(Rule, rule_id)
|
return await s.get(Rule, rule_id)
|
||||||
|
|
||||||
|
|
||||||
|
# Software-only vocabulary. Scribe's product text speaks for any work a person
|
||||||
|
# drives through an agent — configuration, infrastructure, documents — so a
|
||||||
|
# catalog or a shipped shape that reads as software-only is wrong on most of
|
||||||
|
# the installs it ships to. The actions particular to one kind of work belong
|
||||||
|
# in data (mappings, rulebooks, preferences), not in the product's words.
|
||||||
|
DEV_ONLY = (r"\bCI\b", r"\bcommit", r"\bpull request", r"\bcode\b", r"\btest",
|
||||||
|
r"\bgit\b", r"\brepo(s|sitor\w*)?\b", r"\bbranch", r"\bcompil", r"\bbuild\b")
|
||||||
|
|
||||||
|
|
||||||
|
def dev_only_hits(text: str) -> list[str]:
|
||||||
|
"""The DEV_ONLY patterns this text contains, case-insensitively."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
return [w for w in DEV_ONLY if re.search(w, text, re.IGNORECASE)]
|
||||||
|
|
||||||
|
|
||||||
def skill_text(name: str) -> str:
|
def skill_text(name: str) -> str:
|
||||||
"""Everything a bundled skill states: its SKILL.md, then each reference file.
|
"""Everything a bundled skill states: its SKILL.md, then each reference file.
|
||||||
|
|
||||||
|
|||||||
+6
-13
@@ -10,7 +10,7 @@ import re
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from scribe.services import moments
|
from scribe.services import moments
|
||||||
from tests.helpers import FakeMCP
|
from tests.helpers import FakeMCP, dev_only_hits
|
||||||
|
|
||||||
_NAME = re.compile(r"^[a-z]+\.[a-z]+$")
|
_NAME = re.compile(r"^[a-z]+\.[a-z]+$")
|
||||||
|
|
||||||
@@ -38,26 +38,18 @@ def test_the_skill_family_is_not_a_catalog_entry():
|
|||||||
assert not any(k.startswith(moments.SKILL_PREFIX) for k in moments.MOMENTS)
|
assert not any(k.startswith(moments.SKILL_PREFIX) for k in moments.MOMENTS)
|
||||||
|
|
||||||
|
|
||||||
# Software-only vocabulary, the same guard the completion query carries. The
|
|
||||||
# moments are named for any work a person drives through an agent; the actions
|
|
||||||
# particular to one kind of work belong in the mappings, not in the meaning.
|
|
||||||
_DEV_ONLY = (r"\bCI\b", r"\bcommit", r"\bpull request", r"\bcode\b", r"\btest",
|
|
||||||
r"\bgit\b", r"\brepo(s|sitor\w*)?\b", r"\bbranch", r"\bcompil", r"\bbuild\b")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("field", ["means", "reached_by"])
|
@pytest.mark.parametrize("field", ["means", "reached_by"])
|
||||||
def test_the_catalog_assumes_no_particular_domain(field):
|
def test_the_catalog_assumes_no_particular_domain(field):
|
||||||
found = {
|
found = {
|
||||||
m.name: hits
|
m.name: hits
|
||||||
for m in [*moments.MOMENTS.values(), moments.SKILL_FAMILY]
|
for m in [*moments.MOMENTS.values(), moments.SKILL_FAMILY]
|
||||||
if (hits := [w for w in _DEV_ONLY
|
if (hits := dev_only_hits(getattr(m, field)))
|
||||||
if re.search(w, getattr(m, field), re.IGNORECASE)])
|
|
||||||
}
|
}
|
||||||
assert not found, f"software-only vocabulary in `{field}`: {found}"
|
assert not found, f"software-only vocabulary in `{field}`: {found}"
|
||||||
|
|
||||||
|
|
||||||
def test_the_guard_can_fail():
|
def test_the_guard_can_fail():
|
||||||
assert re.search(_DEV_ONLY[1], "after the commit lands", re.IGNORECASE)
|
assert dev_only_hits("after the commit lands")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("name", list(moments.MOMENTS))
|
@pytest.mark.parametrize("name", list(moments.MOMENTS))
|
||||||
@@ -116,11 +108,12 @@ def test_the_tools_are_registered_and_classified():
|
|||||||
mcp = FakeMCP()
|
mcp = FakeMCP()
|
||||||
tool.register(mcp)
|
tool.register(mcp)
|
||||||
assert mcp.names == [
|
assert mcp.names == [
|
||||||
"list_moments", "map_action", "unmap_action",
|
"list_moments", "list_reply_shapes", "map_action", "unmap_action",
|
||||||
"rules_to_mount", "propose_rule_moments", "rule_moment_proposals",
|
"rules_to_mount", "propose_rule_moments", "rule_moment_proposals",
|
||||||
"judge_rule_moments", "rule_misfired",
|
"judge_rule_moments", "rule_misfired",
|
||||||
]
|
]
|
||||||
assert {"list_moments", "rules_to_mount", "rule_moment_proposals"} <= _READ_ONLY_TOOLS
|
assert {"list_moments", "list_reply_shapes",
|
||||||
|
"rules_to_mount", "rule_moment_proposals"} <= _READ_ONLY_TOOLS
|
||||||
# A confirm mounts a rule, so a read key must not reach it.
|
# A confirm mounts a rule, so a read key must not reach it.
|
||||||
assert {"map_action", "unmap_action",
|
assert {"map_action", "unmap_action",
|
||||||
"propose_rule_moments", "judge_rule_moments", "rule_misfired"} <= _WRITE_TOOLS
|
"propose_rule_moments", "judge_rule_moments", "rule_misfired"} <= _WRITE_TOOLS
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""The default reply shapes and the moments they ride (milestone 500 step 1).
|
||||||
|
|
||||||
|
What this pins is what delivery will depend on: every shape rides a moment that
|
||||||
|
exists, the core is small enough to pay for on every turn, no slice grows back
|
||||||
|
into the skill it replaced, the text speaks for any kind of work, and both
|
||||||
|
doors hand out the same shapes.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scribe.services import moments, reply_shapes
|
||||||
|
from tests.helpers import FakeMCP, dev_only_hits
|
||||||
|
|
||||||
|
|
||||||
|
def test_there_is_a_core_and_at_least_one_slice():
|
||||||
|
"""The sweeps below are vacuous over an empty catalog (rule 167)."""
|
||||||
|
assert reply_shapes.CORE_KEY in reply_shapes.SHAPES
|
||||||
|
assert len(reply_shapes.SHAPES) >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_shape_is_keyed_by_its_own_key():
|
||||||
|
for key, shape in reply_shapes.SHAPES.items():
|
||||||
|
assert key == shape.key
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", list(reply_shapes.SHAPES))
|
||||||
|
def test_every_shape_rides_a_catalog_moment(key):
|
||||||
|
"""A shape on a moment nothing reaches would never arrive — and an
|
||||||
|
operator's preference mounted beside it would sit on a dead moment too."""
|
||||||
|
assert reply_shapes.SHAPES[key].moment in moments.MOMENTS
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_core_rides_the_reply_moment():
|
||||||
|
"""It is the reply's shape; a preference about every reply is mounted
|
||||||
|
on `reply.report`, so that is where the core says it lives."""
|
||||||
|
assert reply_shapes.core().moment == "reply.report"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_two_slices_share_a_moment():
|
||||||
|
"""One moment, one default. Two slices on a moment would arrive together
|
||||||
|
and leave the reader to reconcile them."""
|
||||||
|
slices = [s.moment for s in reply_shapes.SHAPES.values() if s.key != reply_shapes.CORE_KEY]
|
||||||
|
assert len(slices) == len(set(slices))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_core_fits_its_budget():
|
||||||
|
"""It is paid for in every session. The budget is the ceiling, so a
|
||||||
|
sentence added to the core has to replace one."""
|
||||||
|
assert len(reply_shapes.core().text) <= reply_shapes.CORE_BUDGET_CHARS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", [k for k in reply_shapes.SHAPES if k != reply_shapes.CORE_KEY])
|
||||||
|
def test_each_slice_fits_its_budget(key):
|
||||||
|
assert len(reply_shapes.SHAPES[key].text) <= reply_shapes.SLICE_BUDGET_CHARS
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_budget_guard_can_fail():
|
||||||
|
long = "x" * (reply_shapes.CORE_BUDGET_CHARS + 1)
|
||||||
|
assert not len(long) <= reply_shapes.CORE_BUDGET_CHARS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", list(reply_shapes.SHAPES))
|
||||||
|
def test_no_shape_assumes_a_particular_domain(key):
|
||||||
|
shape = reply_shapes.SHAPES[key]
|
||||||
|
assert not dev_only_hits(shape.title + "\n" + shape.text), key
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", list(reply_shapes.SHAPES))
|
||||||
|
def test_every_shape_says_what_it_is_and_when_it_arrives(key):
|
||||||
|
shape = reply_shapes.SHAPES[key]
|
||||||
|
assert shape.title.strip() and shape.delivered.strip() and shape.text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_core_carries_the_length_discipline():
|
||||||
|
"""Milestone 409's live read: replies that had every section were still
|
||||||
|
too long. Delivery cannot fix that; the core has to say it."""
|
||||||
|
text = reply_shapes.core().text.lower()
|
||||||
|
assert "shortest reply that carries the answer" in text
|
||||||
|
assert "what can go" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_core_is_not_a_slice():
|
||||||
|
"""It rides every turn, whatever moment the turn reaches — so a moment
|
||||||
|
lookup never hands it out a second time."""
|
||||||
|
every = [s.moment for s in reply_shapes.SHAPES.values()]
|
||||||
|
assert reply_shapes.CORE_KEY not in {s.key for s in reply_shapes.for_moments(every)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_moment_brings_its_own_slice_and_nothing_else():
|
||||||
|
got = reply_shapes.for_moments(["work.finish", "work.run"])
|
||||||
|
assert [s.key for s in got] == ["completion"]
|
||||||
|
assert reply_shapes.for_moments([]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_payload_carries_every_shape_with_its_moments_meaning():
|
||||||
|
data = reply_shapes.catalog()
|
||||||
|
assert [s["key"] for s in data["shapes"]] == list(reply_shapes.SHAPES)
|
||||||
|
assert data["total"] == len(reply_shapes.SHAPES)
|
||||||
|
for row in data["shapes"]:
|
||||||
|
assert row["means"] == moments.MOMENTS[row["moment"]].means
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_tool_returns_the_catalog():
|
||||||
|
from scribe.mcp.tools import moments as tool
|
||||||
|
|
||||||
|
assert await tool.list_reply_shapes() == reply_shapes.catalog()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_tool_is_registered_as_a_read():
|
||||||
|
from scribe.mcp.server import _READ_ONLY_TOOLS
|
||||||
|
from scribe.mcp.tools import moments as tool
|
||||||
|
|
||||||
|
mcp = FakeMCP()
|
||||||
|
tool.register(mcp)
|
||||||
|
assert "list_reply_shapes" in mcp.names
|
||||||
|
assert "list_reply_shapes" in _READ_ONLY_TOOLS
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_doors_read_one_catalog():
|
||||||
|
"""Rule 33 parity: the session and the Settings view cannot show
|
||||||
|
different defaults."""
|
||||||
|
from scribe.mcp.tools import moments as tool
|
||||||
|
from scribe.routes import retrieval as routes
|
||||||
|
|
||||||
|
assert tool.shapes_svc is reply_shapes
|
||||||
|
assert routes.shapes_svc is reply_shapes
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_route_returns_the_catalog():
|
||||||
|
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("/api/retrieval/reply-shapes"):
|
||||||
|
g.user = SimpleNamespace(id=7)
|
||||||
|
resp = await routes.reply_shapes_route.__wrapped__()
|
||||||
|
assert await resp.get_json() == reply_shapes.catalog()
|
||||||
@@ -46,6 +46,7 @@ def test_every_endpoint_is_reachable_on_the_app():
|
|||||||
"/api/retrieval/moments/mappings",
|
"/api/retrieval/moments/mappings",
|
||||||
"/api/retrieval/moments/proposals",
|
"/api/retrieval/moments/proposals",
|
||||||
"/api/retrieval/moments/proposals/judge",
|
"/api/retrieval/moments/proposals/judge",
|
||||||
|
"/api/retrieval/reply-shapes",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user