feat(rulings): a command or edit touching an area's files shows its rulings, once per session (milestone 444 step 4, #4757)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m6s
CI & Build / Python tests (push) Failing after 1m22s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m6s
CI & Build / Python tests (push) Failing after 1m22s
CI & Build / Build & push image (push) Skipped
A System's rulings (the Rulings section of its description) now reach the work by path, not by similarity. Both PreToolUse arms resolve the files a command or edit names to the Systems whose path_patterns cover them, and the first touch in a session shows each area's rulings in one line; a repeat is a one-line reference. A lookup, so no floor, no budget, no retrieval_logs row. - services/system_rulings: parse_rulings, command_paths (reads and writes, relative to the repo root from any cwd; flags, URLs, globs skipped), rulings_for_paths - /tool-rules takes root, cwd and seen_ruling_systems; /prior-art takes seen_ruling_systems; both return ruling_system_ids - hooks share <sid>.rulings.ids (cleared on compaction by the ledger naming convention); the Bash hook sends the repo root and cwd - system_usage_events (migration 0114): surfacings by source, pulls from get_system; carried by backup (v20) through the system map - writing-records: rulings also arrive when the area's files are touched Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ from scribe.models.rule_version import RuleVersion
|
||||
from scribe.models.design_system import DesignSystem, DesignToken
|
||||
from scribe.models.note_usage import NoteUsageEvent
|
||||
from scribe.models.rule_usage import RuleUsageEvent
|
||||
from scribe.models.system_usage import SystemUsageEvent
|
||||
from scribe.models.retrieval_tuning import RetrievalTuningEvent
|
||||
from scribe.models.canonical_system import CanonicalSystem
|
||||
from scribe.models.rulebook import RuleRelation, rule_systems as rule_systems_t
|
||||
@@ -88,8 +89,12 @@ logger = logging.getLogger(__name__)
|
||||
# v19 (2026-10) added lesson_no_rule (#4631): the "no rule fits" answer and its
|
||||
# reason. Without it a restored lesson that was judged to stand alone reads as
|
||||
# never judged, and lands back on the unjudged list.
|
||||
# v20 (2026-10) added systems.path_patterns and system_usage_events (milestone
|
||||
# 444): the files that are each area, and whether an area's rulings were read
|
||||
# once shown. The usage rows restore through the SYSTEM map, for the reason
|
||||
# the rule twin restores through the rule map.
|
||||
# Bump when the serialized schema changes.
|
||||
BACKUP_VERSION = 19
|
||||
BACKUP_VERSION = 20
|
||||
|
||||
# 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
|
||||
@@ -131,6 +136,9 @@ _BACKED_UP = [
|
||||
"lesson_rule_links",
|
||||
# v19 (2026-10): "no rule fits" answers (#4631).
|
||||
"lesson_no_rule",
|
||||
# v20 (2026-10): System usage telemetry (milestone 444), for the reason
|
||||
# its note and rule twins travel.
|
||||
"system_usage_events",
|
||||
]
|
||||
|
||||
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
||||
@@ -231,6 +239,7 @@ _COLUMN_EXCLUSIONS: dict[str, set[str]] = {
|
||||
"note_usage_events": {"id"},
|
||||
# Same as the note twin: the surrogate key is re-issued on insert.
|
||||
"rule_usage_events": {"id"},
|
||||
"system_usage_events": {"id"},
|
||||
# Same again — and everything else travels, because each remaining column
|
||||
# is part of the argument: what moved, from what, to what, by whom, why.
|
||||
"retrieval_tuning_events": {"id"},
|
||||
@@ -319,6 +328,7 @@ _IMPORT_COLUMN_EXCLUSIONS: dict[str, set[str]] = {
|
||||
"lesson_no_rule": set(),
|
||||
"note_usage_events": {"id"},
|
||||
"rule_usage_events": {"id"},
|
||||
"system_usage_events": {"id"},
|
||||
"retrieval_tuning_events": {"id"},
|
||||
"design_systems": {
|
||||
"id", "deleted_at", "deleted_batch_id", "created_at", "updated_at",
|
||||
@@ -444,6 +454,17 @@ def _usage_event_rows(rows) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
def _system_usage_event_rows(rows) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"user_id": r.user_id, "system_id": r.system_id, "event": r.event,
|
||||
"source": r.source, "project_id": r.project_id,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _rule_usage_event_rows(rows) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
@@ -802,6 +823,9 @@ async def export_full_backup() -> dict:
|
||||
rule_usage_events = (
|
||||
await session.execute(select(RuleUsageEvent))
|
||||
).scalars().all()
|
||||
system_usage_events = (
|
||||
await session.execute(select(SystemUsageEvent))
|
||||
).scalars().all()
|
||||
# Oldest first, so a restored history reads in the order the dials
|
||||
# actually moved — the sequence IS the argument when a surface has been
|
||||
# walked up and down.
|
||||
@@ -854,6 +878,7 @@ async def export_full_backup() -> dict:
|
||||
"design_tokens": _design_token_rows(design_tokens),
|
||||
"note_usage_events": _usage_event_rows(usage_events),
|
||||
"rule_usage_events": _rule_usage_event_rows(rule_usage_events),
|
||||
"system_usage_events": _system_usage_event_rows(system_usage_events),
|
||||
"retrieval_tuning_events": _retrieval_tuning_event_rows(
|
||||
retrieval_tuning_events
|
||||
),
|
||||
@@ -991,6 +1016,12 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
rule_usage_events = (await session.execute(
|
||||
select(RuleUsageEvent).where(RuleUsageEvent.rule_id.in_(_rule_ids))
|
||||
)).scalars().all() if _rule_ids else []
|
||||
# Scoped through the SYSTEM, for the reason the rule usage events
|
||||
# above are scoped through the rule: `user_id` is who it fired for.
|
||||
_system_ids = [sy.id for sy in systems]
|
||||
system_usage_events = (await session.execute(
|
||||
select(SystemUsageEvent).where(SystemUsageEvent.system_id.in_(_system_ids))
|
||||
)).scalars().all() if _system_ids else []
|
||||
# Scoped on user_id, and here that IS the right column — unlike the
|
||||
# rule usage events directly above. These record changes to this user's
|
||||
# OWN retrieval settings, which is what `user_id` means on this table;
|
||||
@@ -1054,6 +1085,7 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
"design_tokens": _design_token_rows(design_tokens),
|
||||
"note_usage_events": _usage_event_rows(usage_events),
|
||||
"rule_usage_events": _rule_usage_event_rows(rule_usage_events),
|
||||
"system_usage_events": _system_usage_event_rows(system_usage_events),
|
||||
"retrieval_tuning_events": _retrieval_tuning_event_rows(
|
||||
retrieval_tuning_events
|
||||
),
|
||||
@@ -1566,6 +1598,22 @@ def _build_rule_usage_event(row: dict, maps: _Maps) -> RuleUsageEvent | None:
|
||||
)
|
||||
|
||||
|
||||
def _build_system_usage_event(row: dict, maps: _Maps) -> SystemUsageEvent | None:
|
||||
"""Resolved through the SYSTEM map — see `_build_rule_usage_event`."""
|
||||
sid = maps.systems.get(row.get("system_id", 0))
|
||||
if sid is None:
|
||||
return None
|
||||
return SystemUsageEvent(
|
||||
user_id=maps.users.get(row.get("user_id") or 0),
|
||||
system_id=sid,
|
||||
event=row.get("event", ""),
|
||||
source=row.get("source", ""),
|
||||
project_id=(maps.projects.get(row["project_id"])
|
||||
if row.get("project_id") else None),
|
||||
created_at=_dt(row.get("created_at")),
|
||||
)
|
||||
|
||||
|
||||
def _build_repo_binding(row: dict, maps: _Maps) -> RepoBinding | None:
|
||||
"""Small, but losing these means every bound repo quietly stops loading
|
||||
its project at session start."""
|
||||
@@ -1816,6 +1864,7 @@ async def _restore_v2(data: dict) -> dict:
|
||||
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
|
||||
"systems": 0, "record_systems": 0, "design_systems": 0,
|
||||
"design_tokens": 0, "note_usage_events": 0, "rule_usage_events": 0,
|
||||
"system_usage_events": 0,
|
||||
"repo_bindings": 0,
|
||||
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
|
||||
"code_shape_uses": 0, "canonical_systems": 0,
|
||||
@@ -2105,6 +2154,14 @@ async def _restore_v2(data: dict) -> dict:
|
||||
session.add(event)
|
||||
stats["rule_usage_events"] += 1
|
||||
|
||||
# And the System twin, after the Systems (step 16 fills their map).
|
||||
for ev in data.get("system_usage_events", []):
|
||||
event = _build_system_usage_event(ev, maps)
|
||||
if event is None:
|
||||
continue
|
||||
session.add(event)
|
||||
stats["system_usage_events"] += 1
|
||||
|
||||
# 20. Repo bindings
|
||||
for rb_data in data.get("repo_bindings", []):
|
||||
binding = _build_repo_binding(rb_data, maps)
|
||||
|
||||
@@ -48,6 +48,7 @@ from scribe.services.retrieval_telemetry import record_retrieval
|
||||
from scribe.services.settings import get_setting
|
||||
from scribe.services.systems import system_names_for
|
||||
from scribe.services.text import elide
|
||||
from scribe.services import system_rulings as system_rulings_svc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -2187,6 +2188,7 @@ async def build_write_path_hint(
|
||||
exclude_derive: list[str] | None = None,
|
||||
exclude_rule_ids: list[int] | None = None,
|
||||
held_rule_ids: list[int] | None = None,
|
||||
seen_ruling_systems: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
|
||||
|
||||
@@ -2253,7 +2255,7 @@ async def build_write_path_hint(
|
||||
cfg = await get_writepath_config(user_id)
|
||||
empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg,
|
||||
"suggested": [], "divergence": [], "derive": [], "derive_keys": [],
|
||||
"rule_ids": []}
|
||||
"rule_ids": [], "ruling_system_ids": []}
|
||||
path = (path or "").strip()
|
||||
if not cfg["enabled"] or not path:
|
||||
return empty
|
||||
@@ -2590,9 +2592,21 @@ async def build_write_path_hint(
|
||||
design_text, design_dedup = await _design_arm(
|
||||
user_id, project_id, path, set(exclude_derive or []),
|
||||
)
|
||||
# The rulings arm (milestone 444), decided here for the design arm's
|
||||
# reasons: a lookup by path, so a write that matched no prior art still
|
||||
# carries its area's rulings, and it never switches the ranked arms on.
|
||||
rulings = await system_rulings_svc.rulings_for_paths(
|
||||
user_id, project_id, [path], seen=seen_ruling_systems,
|
||||
source="rulings_write_path",
|
||||
)
|
||||
if not staleness and not synced and not menu and not suggested and not divergence and not derive:
|
||||
if design_text:
|
||||
return {**empty, "context": design_text, "derive_keys": [design_dedup]}
|
||||
if design_text or rulings["lines"]:
|
||||
return {
|
||||
**empty,
|
||||
"context": "\n".join(rulings["lines"] + ([design_text] if design_text else [])),
|
||||
"derive_keys": [design_dedup] if design_dedup else [],
|
||||
"ruling_system_ids": rulings["system_ids"],
|
||||
}
|
||||
return empty
|
||||
|
||||
owners = await owner_names_for({
|
||||
@@ -2614,7 +2628,9 @@ async def build_write_path_hint(
|
||||
# Seeded with the staleness line, which is decided above the early
|
||||
# return and so cannot wait for this list to exist.
|
||||
lines: list[str] = list(staleness)
|
||||
# First after staleness: it BINDS, where everything below is prior art.
|
||||
# First after staleness: rulings and the design system BIND, where
|
||||
# everything below is prior art.
|
||||
lines.extend(rulings["lines"])
|
||||
if design_text:
|
||||
lines.append(design_text)
|
||||
sync_note_ids: list[int] = []
|
||||
@@ -2890,6 +2906,7 @@ async def build_write_path_hint(
|
||||
),
|
||||
"rule_ids": rule_ids,
|
||||
"checkpoint": checkpoint,
|
||||
"ruling_system_ids": rulings["system_ids"],
|
||||
}
|
||||
|
||||
|
||||
@@ -2901,18 +2918,46 @@ async def build_tool_rule_hint(
|
||||
project_id: int = 0,
|
||||
exclude_rule_ids: list[int] | None = None,
|
||||
held_rule_ids: list[int] | None = None,
|
||||
root: str = "",
|
||||
cwd: str = "",
|
||||
seen_ruling_systems: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Standing rules that may apply to the action about to be taken — matched
|
||||
directly (`_tool_rule_hint`, where the design is written), then reached
|
||||
through a linked lesson (`_rules_via_lessons`, #4633)."""
|
||||
through a linked lesson (`_rules_via_lessons`, #4633) — and the rulings of
|
||||
any area whose files the command names (milestone 444).
|
||||
|
||||
`root` and `cwd` are the repo's absolute root and the command's working
|
||||
directory, from the hook; they are what turn the paths a command names
|
||||
into the repo-relative paths a System's patterns are written in."""
|
||||
out = await _tool_rule_hint(
|
||||
user_id, tool_name, command, project_id=project_id,
|
||||
exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids,
|
||||
)
|
||||
return await _add_rules_via_lessons(
|
||||
out = await _add_rules_via_lessons(
|
||||
user_id, out, project_id=project_id, exclude_rule_ids=exclude_rule_ids,
|
||||
held_rule_ids=held_rule_ids, where=f"to this {tool_name} call",
|
||||
)
|
||||
# Its own arm, not part of the rule search: a lookup by path, with no
|
||||
# floor and no budget, so it adds to the rule lines rather than competing
|
||||
# with them. First, because a ruling is the operator's own decision.
|
||||
# `ruling_system_ids` is set only when a ruling was shown: this arm fires
|
||||
# on every command, and the hook reads an absent list as an empty one.
|
||||
try:
|
||||
paths = system_rulings_svc.command_paths(command, root=root, cwd=cwd) if project_id else []
|
||||
if paths and (await get_writepath_config(user_id)).get("enabled"):
|
||||
rulings = await system_rulings_svc.rulings_for_paths(
|
||||
user_id, project_id, paths,
|
||||
seen=seen_ruling_systems, source="rulings_pre_tool",
|
||||
)
|
||||
if rulings["lines"]:
|
||||
out["context"] = "\n".join(
|
||||
rulings["lines"] + ([out["context"]] if out.get("context") else [])
|
||||
)
|
||||
out["ruling_system_ids"] = rulings["system_ids"]
|
||||
except Exception:
|
||||
logger.debug("pre-tool rulings arm failed", exc_info=True)
|
||||
return out
|
||||
|
||||
|
||||
async def _tool_rule_hint(
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""An area's rulings, delivered when its files are touched (milestone 444).
|
||||
|
||||
A RULING is the operator's decision about how one area of the work must behave.
|
||||
It lives in a `Rulings` section at the end of the area's System description
|
||||
(writing-records.md says how one is written). The description already rides
|
||||
every record filed under the System; this module is the other half: the first
|
||||
command or edit in a session that touches the System's files
|
||||
(`System.path_patterns`) shows its rulings, one line per System.
|
||||
|
||||
A LOOKUP, NOT A SEARCH. A shell command scores against prose decisions at noise
|
||||
level, so nothing here is ranked: a path either falls under a System's
|
||||
patterns or it does not. It takes no budget and no floor from the retrieval
|
||||
arms beside it, and writes no retrieval_logs row — there is no score
|
||||
distribution for it to join. Its surfacings go to `system_usage_events`.
|
||||
|
||||
ONCE IN FULL, THEN A REFERENCE (the #3750 shape). The hook keeps the Systems
|
||||
already shown this session and passes them back; a repeat renders as one short
|
||||
line rather than the rulings again, and is not counted as a surfacing.
|
||||
|
||||
Fails open everywhere: a delivery aid must never break the act it rides on.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import posixpath
|
||||
import re
|
||||
import shlex
|
||||
|
||||
from scribe.services import systems as systems_svc
|
||||
from scribe.services.system_usage import record_system_surfaced
|
||||
from scribe.services.text import elide
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The section heading: `Rulings`, optionally as a markdown heading, bold, or
|
||||
# with a trailing colon. The LAST one wins — the section sits at the end.
|
||||
_HEADING = re.compile(r"^[ \t]*(?:#{1,6}[ \t]*)?\**Rulings\**[ \t]*:?[ \t]*$", re.I | re.M)
|
||||
_BULLET = re.compile(r"^[ \t]*[-*][ \t]+(.*)$")
|
||||
|
||||
# Per line, so one System with a long list cannot crowd the others out.
|
||||
RULING_CHARS = 300
|
||||
RULINGS_PER_SYSTEM = 8
|
||||
|
||||
# A command is not a file list: these bound how much of it is read as paths.
|
||||
_MAX_PATHS = 40
|
||||
_LINE_SUFFIX = re.compile(r":\d+(?::\d+)?$")
|
||||
_HAS_EXTENSION = re.compile(r"\.[A-Za-z0-9]{1,10}$")
|
||||
_ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
||||
|
||||
|
||||
def parse_rulings(description: str | None) -> list[str]:
|
||||
"""The rulings in a System description, one string per ruling.
|
||||
|
||||
A bullet under the last `Rulings` heading is a ruling; an indented line
|
||||
after one continues it. The section ends at the first line that is
|
||||
neither — a heading or paragraph written after it is not a ruling.
|
||||
"""
|
||||
text = description or ""
|
||||
found = list(_HEADING.finditer(text))
|
||||
if not found:
|
||||
return []
|
||||
out: list[str] = []
|
||||
for line in text[found[-1].end():].splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
bullet = _BULLET.match(line)
|
||||
if bullet:
|
||||
if bullet.group(1).strip():
|
||||
out.append(bullet.group(1).strip())
|
||||
elif out and line[:1] in (" ", "\t"):
|
||||
out[-1] = f"{out[-1]} {line.strip()}"
|
||||
else:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _relative(token: str, root: str, cwd_rel: str) -> str:
|
||||
"""`token` as a repo-relative path, or "" when it is not one."""
|
||||
if token.startswith("/"):
|
||||
if not root or not token.startswith(root.rstrip("/") + "/"):
|
||||
return ""
|
||||
token = token[len(root.rstrip("/")) + 1:]
|
||||
elif cwd_rel:
|
||||
token = f"{cwd_rel}/{token}"
|
||||
path = posixpath.normpath(token)
|
||||
if path in (".", "") or path == ".." or path.startswith("../"):
|
||||
return ""
|
||||
return path
|
||||
|
||||
|
||||
def command_paths(command: str, *, root: str = "", cwd: str = "") -> list[str]:
|
||||
"""The repo-relative paths a shell command names, in order, deduplicated.
|
||||
|
||||
Reads and writes alike: a session forms its picture of an area by reading
|
||||
it, which is exactly when that area's rulings should reach it. A token is
|
||||
taken as a path when it has a `/` or a file extension; flags, URLs and
|
||||
globs are not paths. `root` is the repo's absolute root and `cwd` the
|
||||
command's working directory, both from the hook — an absolute path
|
||||
outside the root, or a relative one that climbs out of it, is dropped.
|
||||
"""
|
||||
command = command or ""
|
||||
try:
|
||||
tokens = shlex.split(command, comments=False, posix=True)
|
||||
except ValueError:
|
||||
tokens = command.split()
|
||||
root = (root or "").rstrip("/")
|
||||
cwd_rel = ""
|
||||
if root and cwd:
|
||||
cwd = cwd.rstrip("/")
|
||||
if cwd.startswith(root + "/"):
|
||||
cwd_rel = cwd[len(root) + 1:]
|
||||
out: list[str] = []
|
||||
for raw in tokens:
|
||||
token = raw.strip().strip("'\"").rstrip(";,)|&")
|
||||
if token.startswith("-"):
|
||||
if "=" not in token:
|
||||
continue
|
||||
token = token.split("=", 1)[1]
|
||||
elif _ASSIGNMENT.match(token):
|
||||
token = token.split("=", 1)[1]
|
||||
if not token or "://" in token or any(c in token for c in "*?[]{}$`<>"):
|
||||
continue
|
||||
# `file.py:120` and a test id `file.py::test_x` name the file.
|
||||
token = _LINE_SUFFIX.sub("", token.split("::", 1)[0])
|
||||
if "/" not in token and not _HAS_EXTENSION.search(token):
|
||||
continue
|
||||
path = _relative(token, root, cwd_rel)
|
||||
if path and path not in out:
|
||||
out.append(path)
|
||||
if len(out) >= _MAX_PATHS:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _full_line(system, path: str, rulings: list[str]) -> str:
|
||||
shown = [elide(r, RULING_CHARS)[0] for r in rulings[:RULINGS_PER_SYSTEM]]
|
||||
more = len(rulings) - len(shown)
|
||||
listed = " ".join(f"({i}) {r}" for i, r in enumerate(shown, 1))
|
||||
if more > 0:
|
||||
listed += f" (+{more} more in `get_system({system.id})`)"
|
||||
return (
|
||||
f"> Rulings for `{path}` — the operator's decisions about "
|
||||
f"{system.name} (System {system.id}): {listed} "
|
||||
"Work here keeps to them; something that would depart from one is a "
|
||||
"question for the operator, not an option to choose. "
|
||||
"(Shown once per session.)"
|
||||
)
|
||||
|
||||
|
||||
def _reference_line(system, path: str) -> str:
|
||||
return (
|
||||
f"> `{path}` is in {system.name} (System {system.id}) — its rulings "
|
||||
"were shown earlier this session and still apply."
|
||||
)
|
||||
|
||||
|
||||
async def rulings_for_paths(
|
||||
user_id: int,
|
||||
project_id: int,
|
||||
paths: list[str],
|
||||
*,
|
||||
seen: list[int] | set[int] | None = None,
|
||||
source: str,
|
||||
) -> dict:
|
||||
"""Rulings lines for the Systems whose files `paths` touch.
|
||||
|
||||
Returns {"lines": [...], "system_ids": [...]} — `system_ids` are the
|
||||
Systems shown IN FULL on this call, for the hook to add to the session's
|
||||
seen list and for the usage ledger; a System in `seen` gets a reference
|
||||
line and is in neither. A System with patterns but no rulings says
|
||||
nothing: there is no decision to deliver, and the charter already rides
|
||||
its records.
|
||||
"""
|
||||
out: dict = {"lines": [], "system_ids": []}
|
||||
if not project_id or not paths:
|
||||
return out
|
||||
try:
|
||||
already = {int(s) for s in (seen or [])}
|
||||
for system, hit in await systems_svc.systems_for_paths(user_id, project_id, paths):
|
||||
rulings = parse_rulings(system.description)
|
||||
if not rulings:
|
||||
continue
|
||||
if system.id in already:
|
||||
out["lines"].append(_reference_line(system, hit[0]))
|
||||
else:
|
||||
out["lines"].append(_full_line(system, hit[0], rulings))
|
||||
out["system_ids"].append(system.id)
|
||||
if out["system_ids"]:
|
||||
record_system_surfaced(
|
||||
user_id=user_id, system_ids=out["system_ids"], source=source,
|
||||
project_id=project_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("rulings arm failed", exc_info=True)
|
||||
return {"lines": [], "system_ids": []}
|
||||
return out
|
||||
@@ -0,0 +1,67 @@
|
||||
"""System usage telemetry — were an area's rulings read once shown?
|
||||
|
||||
The twin of `note_usage` and `rule_usage` for Systems (milestone 444). The
|
||||
rulings arm shows a System's rulings when a command or edit touches the
|
||||
System's files; a pull is somebody then opening the System (`get_system`). The
|
||||
ratio says whether delivering rulings by path earns its line.
|
||||
|
||||
Fire-and-forget like its siblings: telemetry never adds latency to, or
|
||||
breaks, the surface it observes, and failures report through the shared
|
||||
canary rather than vanishing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.system_usage import PULLED, SURFACED, SystemUsageEvent
|
||||
from scribe.services.background import report_telemetry_failure, spawn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _insert_events(rows: list[dict]) -> None:
|
||||
"""Persist usage rows. Best-effort: failures degrade, visibly."""
|
||||
try:
|
||||
async with async_session() as session:
|
||||
session.add_all([SystemUsageEvent(**row) for row in rows])
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await report_telemetry_failure("system_usage", "write")
|
||||
|
||||
|
||||
def _rows(user_id, system_ids, event: str, source: str, project_id) -> list[dict]:
|
||||
pid = int(project_id or 0) or None
|
||||
return [
|
||||
{"user_id": user_id, "system_id": int(sid), "event": event,
|
||||
"source": source, "project_id": pid}
|
||||
for sid in system_ids
|
||||
]
|
||||
|
||||
|
||||
def record_system_surfaced(
|
||||
*, user_id: int | None, system_ids: list[int], source: str,
|
||||
project_id: int | None = None,
|
||||
) -> None:
|
||||
"""Fire-and-forget: these Systems' rulings were shown in full. A repeat
|
||||
rendered as a short reference is not a surfacing and is not recorded."""
|
||||
try:
|
||||
rows = _rows(user_id, system_ids, SURFACED, source, project_id)
|
||||
except Exception:
|
||||
logger.debug("system usage payload build failed", exc_info=True)
|
||||
return
|
||||
if rows:
|
||||
spawn(_insert_events(rows), site="system_usage_write")
|
||||
|
||||
|
||||
def record_system_pulled(
|
||||
*, user_id: int | None, system_id: int, source: str,
|
||||
project_id: int | None = None,
|
||||
) -> None:
|
||||
"""Fire-and-forget: a System was opened in full."""
|
||||
try:
|
||||
rows = _rows(user_id, [system_id], PULLED, source, project_id)
|
||||
except Exception:
|
||||
logger.debug("system usage payload build failed", exc_info=True)
|
||||
return
|
||||
spawn(_insert_events(rows), site="system_usage_write")
|
||||
Reference in New Issue
Block a user