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)
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
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
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>
This commit is contained in:
@@ -21,6 +21,7 @@ from scribe.models.rulebook import (
|
||||
RuleRelation, rule_moments as rule_moments_t, rule_systems as rule_systems_t,
|
||||
)
|
||||
from scribe.models.lesson_rule_link import LessonNoRule, LessonRuleLink
|
||||
from scribe.models.rule_moment_judgment import RuleMomentJudgment
|
||||
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.repo_binding import RepoBinding
|
||||
@@ -104,8 +105,12 @@ logger = logging.getLogger(__name__)
|
||||
# arrives at. A mount is a judgment about when a rule applies that nothing in
|
||||
# the rule's text records, and losing it would put back exactly the misses the
|
||||
# mounts were made to fix.
|
||||
# v23 (2026-10) added rule_moment_judgments (milestone 458 step 7): the
|
||||
# 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.
|
||||
# Bump when the serialized schema changes.
|
||||
BACKUP_VERSION = 22
|
||||
BACKUP_VERSION = 23
|
||||
|
||||
# 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
|
||||
@@ -154,6 +159,8 @@ _BACKED_UP = [
|
||||
"moment_mappings",
|
||||
# v22 (2026-10): the moments each rule arrives at (milestone 458).
|
||||
"rule_moments",
|
||||
# v23 (2026-10): proposals and judgments about those mounts (step 7).
|
||||
"rule_moment_judgments",
|
||||
]
|
||||
|
||||
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
||||
@@ -253,6 +260,8 @@ _COLUMN_EXCLUSIONS: dict[str, set[str]] = {
|
||||
"rule_relations": {"id", "created_at"},
|
||||
# The pair is the row; everything else is the judgment and its evidence.
|
||||
"lesson_rule_links": {"id"},
|
||||
# The same shape: the (rule, moment) pair is the row.
|
||||
"rule_moment_judgments": {"id"},
|
||||
# Keyed by the lesson itself; nothing to exclude, every column travels.
|
||||
"lesson_no_rule": set(),
|
||||
"note_usage_events": {"id"},
|
||||
@@ -346,6 +355,7 @@ _IMPORT_COLUMN_EXCLUSIONS: dict[str, set[str]] = {
|
||||
"note_supersessions": {"id", "created_at"},
|
||||
"rule_relations": {"id", "created_at"},
|
||||
"lesson_rule_links": {"id"},
|
||||
"rule_moment_judgments": {"id"},
|
||||
"lesson_no_rule": set(),
|
||||
"note_usage_events": {"id"},
|
||||
"rule_usage_events": {"id"},
|
||||
@@ -755,6 +765,20 @@ def _rule_moment_rows(rows) -> list[dict]:
|
||||
return [{"rule_id": rule_id, "moment": moment} for rule_id, moment in rows]
|
||||
|
||||
|
||||
def _rule_moment_judgment_rows(rows) -> list[dict]:
|
||||
"""Proposals and judgments about a rule's moments (v23). The rule id is a
|
||||
SOURCE id, remapped at restore; the moment is a catalog name."""
|
||||
return [
|
||||
{
|
||||
"rule_id": r.rule_id, "moment": r.moment, "state": r.state,
|
||||
"source": r.source, "note": r.note, "evidence": r.evidence,
|
||||
"judged_at": r.judged_at.isoformat() if r.judged_at else None,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _rule_system_rows(rows) -> list[dict]:
|
||||
"""A rule's area tags, carried by canonical SLUG for the same reason the
|
||||
Systems are: the catalog is global and its ids are per-install."""
|
||||
@@ -850,6 +874,9 @@ async def export_full_backup() -> dict:
|
||||
rule_moment_rows = (await session.execute(
|
||||
select(rule_moments_t.c.rule_id, rule_moments_t.c.moment)
|
||||
)).all()
|
||||
rule_moment_judgments = (await session.execute(
|
||||
select(RuleMomentJudgment)
|
||||
)).scalars().all()
|
||||
rule_relations = (await session.execute(select(RuleRelation))).scalars().all()
|
||||
lesson_rule_links = (await session.execute(select(LessonRuleLink))).scalars().all()
|
||||
lesson_no_rule = (await session.execute(select(LessonNoRule))).scalars().all()
|
||||
@@ -917,6 +944,7 @@ async def export_full_backup() -> dict:
|
||||
"canonical_systems": _canonical_system_rows(canonical_systems),
|
||||
"rule_systems": _rule_system_rows(rule_system_rows),
|
||||
"rule_moments": _rule_moment_rows(rule_moment_rows),
|
||||
"rule_moment_judgments": _rule_moment_judgment_rows(rule_moment_judgments),
|
||||
"rule_relations": _rule_relation_rows(rule_relations),
|
||||
"lesson_rule_links": _lesson_rule_link_rows(lesson_rule_links),
|
||||
"lesson_no_rule": _lesson_no_rule_rows(lesson_no_rule),
|
||||
@@ -1055,6 +1083,9 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
select(rule_moments_t.c.rule_id, rule_moments_t.c.moment)
|
||||
.where(rule_moments_t.c.rule_id.in_(_rule_ids))
|
||||
)).all() if _rule_ids else []
|
||||
rule_moment_judgments = (await session.execute(
|
||||
select(RuleMomentJudgment).where(RuleMomentJudgment.rule_id.in_(_rule_ids))
|
||||
)).scalars().all() if _rule_ids else []
|
||||
# Scoped through the RULE, not the version's user_id. That column is
|
||||
# the ACTOR (milestone 323), so filtering on it would carry the
|
||||
# versions this user wrote on someone ELSE's rule and drop the ones
|
||||
@@ -1137,6 +1168,7 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
"canonical_systems": _canonical_system_rows(canonical_systems),
|
||||
"rule_systems": _rule_system_rows(rule_system_rows),
|
||||
"rule_moments": _rule_moment_rows(rule_moment_rows),
|
||||
"rule_moment_judgments": _rule_moment_judgment_rows(rule_moment_judgments),
|
||||
"rule_relations": _rule_relation_rows(rule_relations),
|
||||
"lesson_rule_links": _lesson_rule_link_rows(lesson_rule_links),
|
||||
"lesson_no_rule": _lesson_no_rule_rows(lesson_no_rule),
|
||||
@@ -1535,6 +1567,25 @@ def _build_lesson_rule_link(row: dict, maps: _Maps) -> LessonRuleLink | None:
|
||||
)
|
||||
|
||||
|
||||
def _build_rule_moment_judgment(row: dict, maps: _Maps) -> RuleMomentJudgment | None:
|
||||
"""Skipped when its rule did not restore — the judgment is about that
|
||||
rule, and a remap miss would hang it on whatever took the number."""
|
||||
rule = maps.rules.get(row.get("rule_id", 0))
|
||||
if rule is None or row.get("moment") is None:
|
||||
return None
|
||||
return RuleMomentJudgment(
|
||||
rule_id=rule,
|
||||
moment=row["moment"],
|
||||
state=row.get("state") or "suggested",
|
||||
source=row.get("source") or "pass",
|
||||
note=row.get("note") or None,
|
||||
evidence=row.get("evidence"),
|
||||
# Absent stays absent: a suggestion was never judged.
|
||||
judged_at=_dt_or_none(row.get("judged_at")),
|
||||
created_at=_dt(row.get("created_at")),
|
||||
)
|
||||
|
||||
|
||||
def _build_rule_version(row: dict, maps: _Maps) -> RuleVersion | None:
|
||||
rid = maps.rules.get(row.get("rule_id", 0))
|
||||
if rid is None:
|
||||
@@ -1949,7 +2000,7 @@ async def _restore_v2(data: dict) -> dict:
|
||||
"repo_bindings": 0,
|
||||
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
|
||||
"code_shape_uses": 0, "canonical_systems": 0,
|
||||
"rule_systems": 0, "rule_moments": 0,
|
||||
"rule_systems": 0, "rule_moments": 0, "rule_moment_judgments": 0,
|
||||
"rule_relations": 0, "rule_versions": 0,
|
||||
"retrieval_tuning_events": 0, "lesson_rule_links": 0,
|
||||
"lesson_no_rule": 0, "moment_mappings": 0,
|
||||
@@ -2160,6 +2211,15 @@ async def _restore_v2(data: dict) -> dict:
|
||||
))
|
||||
stats["rule_moments"] += 1
|
||||
|
||||
# Proposals and judgments about those mounts (v23); archives before
|
||||
# v23 carry none, and every rule restores unjudged.
|
||||
for rj in data.get("rule_moment_judgments", []):
|
||||
judgment = _build_rule_moment_judgment(rj, maps)
|
||||
if judgment is None:
|
||||
continue
|
||||
session.add(judgment)
|
||||
stats["rule_moment_judgments"] += 1
|
||||
|
||||
for rr in data.get("rule_relations", []):
|
||||
relation = _build_rule_relation(rr, maps)
|
||||
if relation is None:
|
||||
|
||||
Reference in New Issue
Block a user