Files
FabledScribe/src/scribe/models/rule_moment_judgment.py
T
bvandeusenandClaude Opus 5.5 dfcf4df2e9
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 19s
CI & Build / integration (push) Failing after 43s
CI & Build / Python tests (push) Failing after 46s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Build & push image (push) Skipped
feat(moments): mount the corpus by proposal - a pass and an open-after-moment signal, both stopping at the operator (milestone 458 step 7, #4925)
A rule written before moments existed is mounted on nothing. Step 7 records,
per (rule, moment), whether it belongs there and who said so:

- rule_moment_judgments (migration 0118, backup v23): suggested / confirmed /
  rejected, from a pass, the signal, or an edit. Moment "" is "no moment fits".
- The pass: rules_to_mount lists unjudged rules; propose_rule_moments records
  suggestions that mount nothing; rule_moment_proposals and
  judge_rule_moments put them to the operator. A confirm mounts, a reject is
  kept so the pair is never proposed again. Same service behind REST and a
  "Waiting on you" panel in Settings > Moments.
- Edits are judgments: set_rule_moments, the one mount write path, confirms
  what was added and rejects what was removed in the same transaction.
- The signal: scribe_moment.sh keeps a per-session acts ledger; when a rule
  is opened, scribe_record_opened.sh sends the last three minutes of it to
  /api/plugin/rule-opened. The acts resolve through the install's mappings;
  work.run and work.change are not evidence. Counted per distinct session
  with lesson_rules' evidence model, and once due the open returns one line
  asking the reader to offer the mount.
- scribe_session_end.sh removes the session's scribe-moment files.

Plugin 2026.10.05.2003.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:03:53 -04:00

82 lines
3.5 KiB
Python

from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import CreatedAtMixin, iso
from scribe.models.lesson_rule_link import CONFIRMED, LINK_STATES, REJECTED, SUGGESTED
# The same three states as a lesson's link to a rule, for the same reasons —
# re-exported so a reader of this table need not know where they were first
# named. CHECK ck_rule_moment_judgments_state (migration 0118, rule 36).
JUDGMENT_STATES = LINK_STATES
__all__ = [
"CONFIRMED", "JUDGMENT_STATES", "NO_MOMENT", "REJECTED", "SUGGESTED",
"SOURCES", "RuleMomentJudgment",
]
# The moment recorded for "this rule is about WHAT, not WHEN": no moment in
# the catalog is when it applies, and it is right to reach it by meaning. A
# row rather than an absence, for the reason LessonNoRule is one — a rule
# looked at and found to have no moment must not read as one nobody looked at,
# or every pass over the corpus would propose it again.
NO_MOMENT = ""
# Where a judgment came from. `pass` — an agent read the rule and proposed;
# `signal` — the rule kept being opened just after a moment fired; `edit` — a
# person changed the rule's moments directly, which is a judgment too.
SOURCES = ("pass", "signal", "edit")
class RuleMomentJudgment(Base, CreatedAtMixin):
"""Whether a rule belongs on a moment, and who said so (milestone 458 step 7).
A mount (`rule_moments`) is the current answer; this is the reasoning
behind it and the answers that are NOT mounts. One row per (rule, moment):
- ``suggested`` — proposed by a pass, or by repeated opens after the
moment fired. Delivers nothing: a suggestion that mounted itself would
manufacture the opens it counts.
- ``confirmed`` — a judgment put the rule on the moment. The mount exists.
- ``rejected`` — a judgment said it does not belong there, kept with its
reason so the pair is never proposed again, by either source.
Moment ``""`` (NO_MOMENT), confirmed, is "no moment fits this rule".
"""
__tablename__ = "rule_moment_judgments"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
rule_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), index=True,
)
moment: Mapped[str] = mapped_column(Text)
state: Mapped[str] = mapped_column(Text, default=SUGGESTED, server_default=SUGGESTED)
source: Mapped[str] = mapped_column(Text, default="pass", server_default="pass")
# Why — for a suggestion, what the proposer read in the rule; for a
# judgment, why it was confirmed or rejected.
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)
judged_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
__table_args__ = (
UniqueConstraint("rule_id", "moment", name="uq_rule_moment_judgments_pair"),
)
def to_dict(self) -> dict:
return {
"rule_id": self.rule_id,
"moment": self.moment,
"state": self.state,
"source": self.source,
"note": self.note or "",
"evidence": self.evidence or {},
"judged_at": iso(self.judged_at),
"created_at": iso(self.created_at),
}