feat(moments): a mount that keeps arriving where it does not apply proposes its own removal (milestone 458 step 7b, #4955)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m19s
CI & Build / Python tests (push) Successful in 2m3s
CI & Build / Build & push image (push) Successful in 18s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m19s
CI & Build / Python tests (push) Successful in 2m3s
CI & Build / Build & push image (push) Successful in 18s
The open-after-moment signal proposes a mount; nothing proposed taking one off, so a wrong mount was noise at every occurrence until someone happened to notice. rule_misfired(rule_id, moment, why, reached_by) records a report against a MOUNTED pair, counted per distinct day (the MCP door carries no session id) on a new rule_moment_judgments.misfire column (migration 0119, backup v24). At three days the response carries a line asking the agent to offer the operator the fix - reject takes the rule off, unmap_action stops the action reaching the moment, confirm keeps the mount and stops the asking - and Settings > Moments lists it as an unmount proposal with the reasons and the actions that reached it. A re-mount clears the count. Taught in moments.md, missed-retrieval.md and the reply hold's wording. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -218,6 +218,9 @@ _WRITE_TOOLS = frozenset({
|
||||
# Proposing moments for a rule writes a judgment row; judging one mounts
|
||||
# or unmounts the rule (step 7).
|
||||
"propose_rule_moments", "judge_rule_moments",
|
||||
# A misfire report writes a counted row that can become an unmount
|
||||
# proposal (step 7b).
|
||||
"rule_misfired",
|
||||
# A reviewer's verdicts on logged menu lines (#4772) — rows carrying free
|
||||
# prose the agent authored, `rule_outcome`'s reason for being a write.
|
||||
"judge_menu",
|
||||
|
||||
@@ -147,10 +147,13 @@ async def propose_rule_moments(proposals: list[dict]) -> dict:
|
||||
async def rule_moment_proposals(rule_id: int = 0) -> dict:
|
||||
"""The moment proposals waiting on the operator, grouped by rule.
|
||||
|
||||
Each rule carries what it is mounted on now and its proposals: the moment,
|
||||
where the proposal came from (`pass` — read from the rule; `signal` — the
|
||||
rule kept being opened just after that moment fired), the reason, and the
|
||||
evidence counts. `rule_id` narrows to one rule.
|
||||
Each rule carries what it is mounted on now and its proposals. Each
|
||||
proposal is a `mount` or an `unmount`, with the moment, where it came
|
||||
from (`pass` — read from the rule; `signal` — the rule kept being opened
|
||||
just after that moment fired; `misfire` — sessions reported the mount
|
||||
arriving where it did not apply, with their `reasons` and the actions
|
||||
that `reached_by` the moment), the reason, and the evidence counts.
|
||||
`rule_id` narrows to one rule.
|
||||
"""
|
||||
return await judgments_svc.pending(current_user_id(), rule_id=rule_id or None)
|
||||
|
||||
@@ -160,14 +163,54 @@ async def judge_rule_moments(judgments: list[dict]) -> dict:
|
||||
|
||||
Each item is `{"rule_id": N, "moment": "work.finish", "verdict":
|
||||
"confirm" | "reject", "note": "why"}`. Confirm mounts the rule on that
|
||||
moment beside what it already has; reject records that it does not belong
|
||||
there (and unmounts it if it was mounted), so neither the pass nor the
|
||||
signal proposes the pair again. Moment `""` judges a "no moment fits"
|
||||
moment beside what it already has — on an `unmount` proposal, it keeps
|
||||
the mount and stops the misfire question; reject records that it does not
|
||||
belong there (and unmounts it if it was mounted), so neither the pass nor
|
||||
the signal proposes the pair again. Moment `""` judges a "no moment fits"
|
||||
answer. Put the operator's reason in `note`.
|
||||
"""
|
||||
return await judgments_svc.judge(current_user_id(), judgments)
|
||||
|
||||
|
||||
async def rule_misfired(rule_id: int, moment: str, why: str,
|
||||
reached_by: str = "", project_id: int = 0) -> dict:
|
||||
"""Report that a rule MOUNTED on a moment arrived there and did not apply.
|
||||
|
||||
A mount delivers its rule every time the moment fires, whatever the work
|
||||
is about — so a mount that is wrong is noise at every occurrence, and
|
||||
nothing else notices. Call this when a line said a rule arrived *at* a
|
||||
moment ("at work.verify, reached by `actions_run_read`"), you read it,
|
||||
and it does not govern what you were doing there. It costs one call and
|
||||
asks nothing of the operator; reports gather, counted once per day, and
|
||||
once a pair has them on three distinct days the response carries a line
|
||||
asking you to offer the operator the fix — take the rule off the moment,
|
||||
or unmap the action when it is the action that is wrong here.
|
||||
|
||||
A rule that applied, even one you were already following, is not a
|
||||
misfire. A rule that arrived by resemblance rather than a mount is
|
||||
refused with the fix for that (its trigger).
|
||||
|
||||
Args:
|
||||
rule_id: the rule the line named.
|
||||
moment: the moment it arrived at, as the line names it.
|
||||
why: what you were doing and why the rule did not bear on it. Required
|
||||
— it is what tells the operator whether the rule or the action
|
||||
is wrong.
|
||||
reached_by: the action the line says reached the moment (`git push`,
|
||||
`status=done`). Counted, so the operator can see which action
|
||||
keeps bringing it.
|
||||
project_id: the project you are working in (0 = none).
|
||||
|
||||
Returns `recorded`, `days` so far against the `bar`, and `context`: a line
|
||||
to act on once the bar is crossed, else "". A mount the operator chose to
|
||||
keep says so under `kept`.
|
||||
"""
|
||||
return await judgments_svc.misfired(
|
||||
current_user_id(), rule_id, moment, why=why, reached_by=reached_by,
|
||||
project_id=project_id or None,
|
||||
)
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
mcp.tool(name="list_moments")(list_moments)
|
||||
mcp.tool(name="map_action")(map_action)
|
||||
@@ -176,3 +219,4 @@ def register(mcp) -> None:
|
||||
mcp.tool(name="propose_rule_moments")(propose_rule_moments)
|
||||
mcp.tool(name="rule_moment_proposals")(rule_moment_proposals)
|
||||
mcp.tool(name="judge_rule_moments")(judge_rule_moments)
|
||||
mcp.tool(name="rule_misfired")(rule_misfired)
|
||||
|
||||
@@ -60,6 +60,11 @@ class RuleMomentJudgment(Base, CreatedAtMixin):
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# The co-occurrence evidence (signal source), in lesson_rules' shape.
|
||||
evidence: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
# The other direction (step 7b, migration 0119): sessions saying this
|
||||
# MOUNTED rule arrived at the moment and did not apply. lesson_rules'
|
||||
# evidence shape keyed per day, plus the reasons given and the actions
|
||||
# that reached the moment; `kept_at` once the operator kept the mount.
|
||||
misfire: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
judged_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
@@ -76,6 +81,7 @@ class RuleMomentJudgment(Base, CreatedAtMixin):
|
||||
"source": self.source,
|
||||
"note": self.note or "",
|
||||
"evidence": self.evidence or {},
|
||||
"misfire": self.misfire or {},
|
||||
"judged_at": iso(self.judged_at),
|
||||
"created_at": iso(self.created_at),
|
||||
}
|
||||
|
||||
@@ -109,8 +109,11 @@ logger = logging.getLogger(__name__)
|
||||
# proposals waiting on a mount, and the rejections and "no moment fits"
|
||||
# answers that stop a pass or the open-after-moment signal proposing the same
|
||||
# pair again. Losing them puts every answered question back on the list.
|
||||
# v24 (2026-10) added rule_moment_judgments.misfire (milestone 458 step 7b):
|
||||
# the reports that a mounted rule arrived where it did not apply, and the
|
||||
# operator's "keep it" that stops them being proposed as an unmount again.
|
||||
# Bump when the serialized schema changes.
|
||||
BACKUP_VERSION = 23
|
||||
BACKUP_VERSION = 24
|
||||
|
||||
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
|
||||
# below, these two lists must together account for the entire schema — which is
|
||||
@@ -772,6 +775,7 @@ def _rule_moment_judgment_rows(rows) -> list[dict]:
|
||||
{
|
||||
"rule_id": r.rule_id, "moment": r.moment, "state": r.state,
|
||||
"source": r.source, "note": r.note, "evidence": r.evidence,
|
||||
"misfire": r.misfire,
|
||||
"judged_at": r.judged_at.isoformat() if r.judged_at else None,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
@@ -1580,6 +1584,8 @@ def _build_rule_moment_judgment(row: dict, maps: _Maps) -> RuleMomentJudgment |
|
||||
source=row.get("source") or "pass",
|
||||
note=row.get("note") or None,
|
||||
evidence=row.get("evidence"),
|
||||
# Archives before v24 carry no misfire reports.
|
||||
misfire=row.get("misfire"),
|
||||
# Absent stays absent: a suggestion was never judged.
|
||||
judged_at=_dt_or_none(row.get("judged_at")),
|
||||
created_at=_dt(row.get("created_at")),
|
||||
|
||||
@@ -486,17 +486,19 @@ def situation_key(arm: str, text: str) -> str:
|
||||
"""A fingerprint of one situation, so a repeat counts once.
|
||||
|
||||
`arm` is part of the key — "p" for a prompt, "w" for a file being written,
|
||||
"s" for a session (the rule↔moment signal, milestone 458 step 7) —
|
||||
"s" for a session (the rule↔moment signal, milestone 458 step 7), "d"
|
||||
for a day (the misfire signal, step 7b, whose door carries no session) —
|
||||
because each names a different kind of situation and must not collide.
|
||||
On the prompt arm the text is the prompt: lowercased, split into word
|
||||
tokens of _MIN_TOKEN or more, de-duplicated and sorted, so the same ask
|
||||
re-sent with different spacing, punctuation, case or word order is one
|
||||
situation. On the write arm the text is the PATH, not the code: every
|
||||
edit to one file is one situation, however much the code differs between
|
||||
them. The session arm keys on the session id as given. Empty when there
|
||||
is nothing to key on.
|
||||
them. The session and day arms key on the id or date as given — a date's
|
||||
"10" and "05" are shorter than _MIN_TOKEN, so tokenising it would make
|
||||
every day of a year one situation. Empty when there is nothing to key on.
|
||||
"""
|
||||
if arm in ("w", "s"):
|
||||
if arm in ("w", "s", "d"):
|
||||
basis = (text or "").strip()
|
||||
else:
|
||||
tokens = sorted({t for t in re.findall(r"[a-z0-9]+", (text or "").lower())
|
||||
|
||||
@@ -181,13 +181,22 @@ def reply_hold_reason(held: list[dict]) -> str:
|
||||
trigger = f"; it applies when {item['trigger']}" if item.get("trigger") else ""
|
||||
parts.append(f"“{item['title']}” ({why}{trigger}) — get_rule({item['rule_id']})")
|
||||
noun = "a standing rule" if len(held) == 1 else "standing rules"
|
||||
# A mount that does not bear on this reply is a misfire worth one call:
|
||||
# reports gather into an unmount proposal for the operator (step 7b).
|
||||
mounted = [item for item in held if item.get("moment")]
|
||||
misfire = (
|
||||
" If one mounted on a moment has nothing to do with this reply, "
|
||||
f"rule_misfired({mounted[0]['rule_id']}, \"{mounted[0]['moment']}\", why) "
|
||||
"records that."
|
||||
if mounted else ""
|
||||
)
|
||||
return (
|
||||
f"Held for one read before this reply goes out: {noun} this session "
|
||||
f"has not opened: " + "; ".join(parts) + ". Read "
|
||||
+ ("it" if len(held) == 1 else "them")
|
||||
+ ", then send the reply — unchanged if it already does what the rule "
|
||||
"asks, which is a judgement only you can make. This check runs once "
|
||||
"per rule; the rewrite is never held."
|
||||
"asks, which is a judgement only you can make." + misfire
|
||||
+ " This check runs once per rule; the rewrite is never held."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,16 @@ Neither source mounts anything by itself. A suggestion that delivered its rule
|
||||
would manufacture the opens it counts, and the operator's corpus is theirs to
|
||||
mount.
|
||||
|
||||
THE MISFIRE (step 7b) is the same machinery pointed the other way. A session
|
||||
that was handed a mounted rule at a moment where it did not apply says so
|
||||
(`misfired`), with why and the action that reached the moment. Reports count
|
||||
per distinct DAY — the MCP door is stateless and no session id reaches it, and
|
||||
a misfire seen on three days is a pattern where three in one afternoon may be
|
||||
one piece of work. At the bar the pair becomes an UNMOUNT proposal; the
|
||||
operator takes the rule off (reject), or unmaps the action, or keeps it
|
||||
(confirm), which stops the asking. A rule left unopened is never counted:
|
||||
most delivered rules go unopened, and the reply hold forces an open.
|
||||
|
||||
EDITS ARE JUDGMENTS TOO. A person changing a rule's moments directly says
|
||||
which moments it belongs on, so `record_mount_change` (called from the one
|
||||
write path, `rulebooks.set_rule_moments`) confirms what was added and rejects
|
||||
@@ -68,6 +78,17 @@ SIGNAL_WINDOW_SECONDS = 180
|
||||
# propose every rule onto both.
|
||||
SIGNAL_SKIP = frozenset({"work.run", "work.change"})
|
||||
|
||||
# The reasons a misfire proposal carries, newest kept. Enough for the operator
|
||||
# to see whether the reports agree; few enough to read in the panel.
|
||||
MISFIRE_REASONS = 5
|
||||
# Distinct actions counted per pair. A moment is reached by a handful of
|
||||
# actions; past this the counts stop telling the operator which one to unmap.
|
||||
_MISFIRE_ACTIONS_CAP = 10
|
||||
_MISFIRE_WHY_CHARS = 400
|
||||
# On a mounted pair that has no judgment row — mounted before the rows were
|
||||
# kept (migration 0118) — when its first misfire gives it one.
|
||||
_UNRECORDED_MOUNT_NOTE = "mounted before judgments were recorded"
|
||||
|
||||
|
||||
def _clean_moment(name) -> str:
|
||||
"""A catalog moment, normalised; "" stays "" (no moment fits)."""
|
||||
@@ -279,22 +300,73 @@ async def pending(user_id: int, rule_id: int | None = None, limit: int = 200) ->
|
||||
"proposals": [],
|
||||
})
|
||||
entry["proposals"].append({
|
||||
"proposal": "mount",
|
||||
"moment": judgment.moment,
|
||||
"source": judgment.source,
|
||||
"why": judgment.note or "",
|
||||
"evidence": lesson_rules.evidence_summary(judgment.evidence),
|
||||
"created_at": judgment.created_at.isoformat() if judgment.created_at else None,
|
||||
})
|
||||
rules = list(grouped.values())
|
||||
for judgment, rule, mounted in await _misfire_proposals(user_id, rule_id, limit):
|
||||
entry = grouped.setdefault(rule.id, {
|
||||
**_brief(rule),
|
||||
"mounted": sorted(mounted, key=lambda n: (order.get(n, len(order)), n)),
|
||||
"proposals": [],
|
||||
})
|
||||
entry["proposals"].append(_unmount_item(judgment))
|
||||
rules = sorted(grouped.values(), key=lambda r: r["id"])
|
||||
return {"rules": rules, "total": sum(len(r["proposals"]) for r in rules)}
|
||||
|
||||
|
||||
def _misfire_due_clause():
|
||||
"""Proposed by the misfire signal and not yet kept by the operator."""
|
||||
mf = RuleMomentJudgment.misfire
|
||||
return mf["proposed_at"].astext.isnot(None), mf["kept_at"].astext.is_(None)
|
||||
|
||||
|
||||
async def _misfire_proposals(user_id: int, rule_id: int | None, limit: int):
|
||||
"""The unmount proposals: pairs past the misfire bar, unanswered, and
|
||||
still mounted — a pair taken off since has had its answer."""
|
||||
from scribe.services.rulebooks import _owned_rules_clause
|
||||
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
select(RuleMomentJudgment, Rule)
|
||||
.join(Rule, Rule.id == RuleMomentJudgment.rule_id)
|
||||
.where(_owned_rules_clause(user_id), *_misfire_due_clause())
|
||||
)
|
||||
if rule_id:
|
||||
stmt = stmt.where(Rule.id == int(rule_id))
|
||||
rows = (await session.execute(
|
||||
stmt.order_by(Rule.id, RuleMomentJudgment.moment).limit(limit)
|
||||
)).all()
|
||||
mounts = await _mounts(session, {r.id for _, r in rows})
|
||||
return [(j, r, mounts.get(r.id, set())) for j, r in rows
|
||||
if j.moment in mounts.get(r.id, set())]
|
||||
|
||||
|
||||
def _unmount_item(judgment: RuleMomentJudgment) -> dict:
|
||||
mf = judgment.misfire or {}
|
||||
reasons = list(mf.get("reasons") or [])
|
||||
return {
|
||||
"proposal": "unmount",
|
||||
"moment": judgment.moment,
|
||||
"source": "misfire",
|
||||
"why": reasons[-1]["why"] if reasons else "",
|
||||
"reasons": reasons,
|
||||
"reached_by": dict(mf.get("reached_by") or {}),
|
||||
"evidence": lesson_rules.evidence_summary(mf),
|
||||
"created_at": mf.get("first_at"),
|
||||
}
|
||||
|
||||
|
||||
async def judge(user_id: int, judgments: list[dict]) -> dict:
|
||||
"""Confirm or reject proposals — or any (rule, moment) pair, proposed or not.
|
||||
|
||||
Confirm MOUNTS the rule on the moment (alongside what it already has);
|
||||
reject records that it does not belong there and, if it was mounted,
|
||||
unmounts it. Both go through `rulebooks.set_rule_moments`, the one write
|
||||
Confirm MOUNTS the rule on the moment (alongside what it already has) —
|
||||
on a pair already mounted, it KEEPS the mount and answers a misfire
|
||||
proposal; reject records that it does not belong there and, if it was
|
||||
mounted, unmounts it. Both go through `rulebooks.set_rule_moments`, the one write
|
||||
path, which records the judgment with this item's `note`. A moment of ""
|
||||
judges the "no moment fits" answer itself. A bad item is refused alone.
|
||||
"""
|
||||
@@ -382,11 +454,16 @@ async def record_mount_change(
|
||||
|
||||
for moment in added:
|
||||
put(moment, CONFIRMED, _MOUNTED_NOTE)
|
||||
# A fresh mount is a fresh judgment: misfires counted against an
|
||||
# earlier mount of the pair are not evidence against this one.
|
||||
rows[(rule_id, moment)].misfire = None
|
||||
for moment in removed:
|
||||
put(moment, REJECTED, _UNMOUNTED_NOTE)
|
||||
for moment in explicit:
|
||||
if verdict in (CONFIRMED, REJECTED):
|
||||
put(moment, verdict, "")
|
||||
if verdict == CONFIRMED and moment in after:
|
||||
_keep(rows[(rule_id, moment)], note, now)
|
||||
if after:
|
||||
none_row = rows.get((rule_id, NO_MOMENT))
|
||||
if none_row is not None and none_row.state == CONFIRMED:
|
||||
@@ -394,6 +471,15 @@ async def record_mount_change(
|
||||
none_row.note = _OVERTURNED_NOTE
|
||||
|
||||
|
||||
def _keep(row: RuleMomentJudgment, note: str, now: datetime) -> None:
|
||||
"""The operator kept a mount the misfire signal proposed taking off. The
|
||||
reports stay; the asking stops."""
|
||||
mf = row.misfire
|
||||
if not isinstance(mf, dict) or not mf.get("proposed_at") or mf.get("kept_at"):
|
||||
return
|
||||
row.misfire = {**mf, "kept_at": now.isoformat(), "kept_note": note or ""}
|
||||
|
||||
|
||||
# ── The signal ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -525,3 +611,114 @@ async def opened_after(
|
||||
user_id, rule_id, moments, situation=session_id.strip(), project_id=project_id,
|
||||
)
|
||||
return {"moments": moments, "context": context}
|
||||
|
||||
|
||||
# ── The misfire: a mounted rule that arrived where it does not apply ─────────
|
||||
|
||||
|
||||
def misfire_situation(now: datetime) -> str:
|
||||
"""One situation per UTC day. The MCP door carries no session id; a day
|
||||
is the coarser, honest unit, and the slower of the two to cross the bar."""
|
||||
return lesson_rules.situation_key("d", now.date().isoformat())
|
||||
|
||||
|
||||
def add_misfire(misfire, key: str, project_id: int | None, now: datetime, *,
|
||||
why: str, reached_by: str) -> dict:
|
||||
"""A NEW misfire dict with this report counted. Pure, like
|
||||
`lesson_rules.add_evidence`, whose counting it reuses: the per-day
|
||||
situations and the bar are the same as every other proposal's."""
|
||||
ev = lesson_rules.add_evidence(misfire, key, project_id, now)
|
||||
reasons = list(ev.get("reasons") or [])
|
||||
reasons.append({"why": why[:_MISFIRE_WHY_CHARS], "reached_by": reached_by,
|
||||
"at": now.isoformat()})
|
||||
ev["reasons"] = reasons[-MISFIRE_REASONS:]
|
||||
actions = dict(ev.get("reached_by") or {})
|
||||
if reached_by and (reached_by in actions or len(actions) < _MISFIRE_ACTIONS_CAP):
|
||||
actions[reached_by] = int(actions.get(reached_by) or 0) + 1
|
||||
ev["reached_by"] = actions
|
||||
return ev
|
||||
|
||||
|
||||
def _unmount_line(rule: Rule, moment: str, misfire: dict) -> str:
|
||||
days = len(misfire.get("situations") or [])
|
||||
actions = ", ".join(f"`{a}` ×{n}" for a, n in sorted(
|
||||
(misfire.get("reached_by") or {}).items(), key=lambda kv: -kv[1]))
|
||||
kind = rule.kind or "rule"
|
||||
return (
|
||||
f"> {kind.capitalize()} #{rule.id} \"{rule.title}\" has been reported arriving at "
|
||||
f"`{moment}` where it did not apply, on {days} distinct days"
|
||||
+ (f" (reached by {actions})" if actions else "")
|
||||
+ ". Offer the operator the fix in one line. If the rule does not belong "
|
||||
f"at that moment, `judge_rule_moments([{{\"rule_id\": {rule.id}, \"moment\": "
|
||||
f"\"{moment}\", \"verdict\": \"reject\", \"note\": \"why\"}}])` takes it off. "
|
||||
"If it is the ACTION that is not that moment here, `unmap_action` for "
|
||||
"it instead (`list_moments` shows the mapping). If they keep it, "
|
||||
"`\"confirm\"` with their why stops the asking."
|
||||
)
|
||||
|
||||
|
||||
async def misfired(
|
||||
user_id: int, rule_id: int, moment: str, *, why: str,
|
||||
reached_by: str = "", project_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Record that a mounted rule arrived at `moment` where it did not apply.
|
||||
|
||||
Only a MOUNT can misfire: a rule that arrived by resemblance is a
|
||||
retrieval question, answered by its trigger. `why` is required — a report
|
||||
without its reason cannot tell the operator what to fix. Returns the
|
||||
count so far and, once the pair crosses the bar, `context`: the line
|
||||
asking the reader to offer the unmount. A kept mount still counts its
|
||||
reports and asks nothing.
|
||||
"""
|
||||
try:
|
||||
name = moments_svc.require_moment((moment or "").strip())
|
||||
except ValueError as exc:
|
||||
return {"recorded": False, "error": str(exc)}
|
||||
why = (why or "").strip() if isinstance(why, str) else ""
|
||||
if not why:
|
||||
return {"recorded": False, "error": (
|
||||
"say why it did not apply here — the reason is what tells the "
|
||||
"operator whether the rule or the action is wrong")}
|
||||
rid = _int(rule_id)
|
||||
reached_by = (reached_by or "").strip()[:200] if isinstance(reached_by, str) else ""
|
||||
now = datetime.now(timezone.utc)
|
||||
async with async_session() as session:
|
||||
owned = await _owned_rules(session, user_id, [rid] if rid else [])
|
||||
rule = owned.get(rid) if rid else None
|
||||
if rule is None:
|
||||
return {"recorded": False, "error": "not a rule you own"}
|
||||
mounted = (await _mounts(session, [rid])).get(rid, set())
|
||||
if name not in mounted:
|
||||
return {"recorded": False, "error": (
|
||||
f"rule {rid} is not mounted on {name}"
|
||||
+ (f" (it is on {', '.join(sorted(mounted))})" if mounted else "")
|
||||
+ ". A rule that arrived by its words rather than a mount is "
|
||||
"fixed through its trigger — update_rule(when_to_apply=...).")}
|
||||
row = (await _rows(session, [rid])).get((rid, name))
|
||||
if row is None:
|
||||
row = RuleMomentJudgment(rule_id=rid, moment=name, state=CONFIRMED,
|
||||
source="edit", note=_UNRECORDED_MOUNT_NOTE,
|
||||
judged_at=now)
|
||||
session.add(row)
|
||||
mf = add_misfire(row.misfire, misfire_situation(now), project_id, now,
|
||||
why=why, reached_by=reached_by)
|
||||
kept = bool(mf.get("kept_at"))
|
||||
due = not kept and lesson_rules.proposal_due(mf, now)
|
||||
if due:
|
||||
mf["proposed_at"] = now.isoformat()
|
||||
mf["proposed_count"] = int(mf.get("proposed_count") or 0) + 1
|
||||
row.misfire = mf
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
return {"recorded": False, "error": "a report for this pair landed at the same moment; try again"}
|
||||
out = {
|
||||
"recorded": True, "rule_id": rid, "moment": name,
|
||||
"days": len(mf.get("situations") or []),
|
||||
"bar": lesson_rules.PROPOSE_SITUATIONS,
|
||||
"context": _unmount_line(rule, name, mf) if due else "",
|
||||
}
|
||||
if kept:
|
||||
out["kept"] = {"at": mf.get("kept_at"), "note": mf.get("kept_note") or ""}
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user