feat(design-explorer): rulebook binding + prose→claims extraction
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 27s
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 27s
Milestone #251 step 2 (#2259). The half of the drift panel that needed to be testable, which is why it is Python: the frontend has no test runner, so the fiddly extraction lives server-side and the browser only does set arithmetic over live token values. BINDING. A per-user setting `design_rulebook_id` names the rulebook that describes this install's design system. A setting rather than a column: no migration, discoverable in the Settings UI (rule #25), and honest about being a per-install choice rather than a property of the rulebook. No rulebook designated returns an empty set with rulebook_id: null — the NORMAL case for any install but the one that set it up (rule #115), which the client renders as an explanatory empty state rather than an error. The id comes back alongside the list so "not designated" and "designated but empty" stay distinguishable. EXTRACTION. No NLP. Rule statements are prose written for humans and should stay that way, so this takes only what is unambiguous in any prose — the hex colours and custom-property names a rule mentions. Anything subtler needs a rule author to opt into a structured form, deliberately left for when someone wants it. Three things earn their complexity: - SENTENCE-SCOPED NEGATION. A rule routinely states what the palette requires and what it forbids in consecutive sentences ("Parchment #E8E4D8 …, Vellum #C2BFB4 …. Pure white #FFFFFF is NEVER used."). Detecting negation across the whole statement would mark the required colours as forbidden — inverting the finding rather than missing it, which is worse. Per sentence, all four come out right. - HEX NORMALISATION is load-bearing, not tidiness. The rulebook writes #FFFFFF and components write #fff; if those don't compare equal the largest drift finding in the codebase — 67 hardcoded white text colours (#2275) — reads as zero. Alpha forms keep their alpha, since #fff and #ffff are different colours and collapsing them would manufacture equality. - SLASH SHORTHAND. Rulebooks write token families as --fs-radius-sm/md/lg/xl and --fs-obsidian/iron/slate/pewter. Both expand under one rule — prefix is everything up to and including the LAST hyphen of the first segment — which also handles --fs-dur-fast/base/slow. Verified against the real rule text: 18 tokens from three different shorthand shapes. how_to_apply is read alongside statement, because rulebooks routinely keep the statement declarative and put the concrete values in how_to_apply; ignoring it would miss the checkable half. Claims dedupe on (kind, value), first source winning, so a colour named by several rules is one expectation attributed to the rule that introduced it. Prose with nothing checkable yields nothing — most rules are judgement, not specification, and a panel that reported unparseable rules as problems would be unusable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
@@ -26,6 +26,7 @@ from scribe.routes.profile import profile_bp
|
||||
from scribe.routes.knowledge import knowledge_bp
|
||||
from scribe.routes.rulebooks import rulebooks_bp
|
||||
from scribe.routes.plugin import plugin_bp
|
||||
from scribe.routes.design import design_bp
|
||||
from scribe.routes.trash import trash_bp
|
||||
from scribe.routes.dashboard import dashboard_bp
|
||||
from scribe.routes.systems import systems_bp
|
||||
@@ -89,6 +90,7 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(knowledge_bp)
|
||||
app.register_blueprint(rulebooks_bp)
|
||||
app.register_blueprint(plugin_bp)
|
||||
app.register_blueprint(design_bp)
|
||||
app.register_blueprint(trash_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
app.register_blueprint(systems_bp)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Design-system surface — what the rulebook expects of the stylesheet.
|
||||
|
||||
The client owns the other half of the comparison: it reads live token values from
|
||||
the browser (see `utils/designTokens.ts`), which is the only place they exist
|
||||
resolved. This endpoint supplies the claims to check them against.
|
||||
"""
|
||||
from quart import Blueprint, jsonify
|
||||
|
||||
from scribe.auth import get_current_user_id, login_required
|
||||
from scribe.services import design_system as design_svc
|
||||
|
||||
design_bp = Blueprint("design", __name__, url_prefix="/api/design")
|
||||
|
||||
|
||||
@design_bp.get("/expectations")
|
||||
@login_required
|
||||
async def get_expectations():
|
||||
"""Checkable claims from the rulebook this install designated as its design system.
|
||||
|
||||
Returns `{"rulebook_id": int|null, "expectations": [...]}`.
|
||||
|
||||
`rulebook_id: null` is the NORMAL case, not an error — an install that has
|
||||
not designated a design rulebook has nothing to compare against, and the
|
||||
client shows an explanatory empty state (rule #115). Distinguishing it from
|
||||
"designated but empty" is why the id is returned alongside the list.
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
result = await design_svc.design_expectations(uid)
|
||||
return jsonify(result.as_dict())
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Design-system expectations — turning rulebook prose into checkable claims.
|
||||
|
||||
Milestone #251 step 2. The drift panel compares what the design rulebook SAYS
|
||||
against what the stylesheet and components actually DO. This module owns the
|
||||
first half: reading a rulebook's rules and extracting the claims that can be
|
||||
mechanically checked.
|
||||
|
||||
WHY THIS LIVES SERVER-SIDE. The frontend has no test runner — `vue-tsc --noEmit`
|
||||
is the entire check — and this is the one genuinely fiddly piece of the feature.
|
||||
Extraction happens here where pytest can assert on it; the comparison itself is
|
||||
set arithmetic and stays in the browser, where the live token values are.
|
||||
|
||||
WHY NOT NLP. Rule statements are prose written for humans, and they should stay
|
||||
that way — they are read by people far more often than they are parsed. So this
|
||||
extracts only what is unambiguous in ANY prose: the hex colours and CSS custom
|
||||
property names a rule mentions. Everything subtler (padding scales, type ramps)
|
||||
needs a rule author to opt into a structured form, which is deliberately left for
|
||||
when someone wants it rather than invented up front.
|
||||
|
||||
RULE #115. Nothing here assumes a design rulebook exists, or that it is this
|
||||
operator's. An install designates one; an install that hasn't gets an empty
|
||||
result and a panel that explains itself.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from scribe.models.rulebook import Rule
|
||||
from scribe.services.settings import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Which rulebook describes this install's design system. A plain setting rather
|
||||
# than a column: no migration, discoverable in the Settings UI (rule #25), and
|
||||
# honest about being a per-install choice rather than a property of the rulebook.
|
||||
DESIGN_RULEBOOK_SETTING = "design_rulebook_id"
|
||||
|
||||
# `#abc` and `#aabbcc`, plus the 4/8-digit alpha forms.
|
||||
_HEX = re.compile(r"#([0-9a-fA-F]{3,8})\b")
|
||||
|
||||
# A custom-property name as written in prose, including the slash shorthand the
|
||||
# rulebook uses: `--fs-radius-sm/md/lg/xl`, `--fs-obsidian/iron/slate/pewter`.
|
||||
_TOKEN = re.compile(r"(--[a-zA-Z][\w-]*(?:/[\w-]+)*)")
|
||||
|
||||
# Sentence-ish split. Rules use semicolons as hard breaks as often as periods.
|
||||
_SENTENCE_SPLIT = re.compile(r"(?<=[.;])\s+|\n+")
|
||||
|
||||
# Negation markers. Checked PER SENTENCE, which is the whole trick — see
|
||||
# _extract_from_sentence.
|
||||
_NEGATIONS = ("never", "not ", "no ", "avoid", "don't", "must not", "excluded")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Expectation:
|
||||
"""One mechanically-checkable claim a rule makes."""
|
||||
|
||||
kind: str # "token" | "color" | "prohibited_color"
|
||||
value: str # "--fs-obsidian" | "#14171a"
|
||||
rule_id: int
|
||||
rule_title: str
|
||||
context: str # the sentence it came from, for showing your work
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"kind": self.kind,
|
||||
"value": self.value,
|
||||
"rule_id": self.rule_id,
|
||||
"rule_title": self.rule_title,
|
||||
"context": self.context,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExpectationSet:
|
||||
rulebook_id: int | None = None
|
||||
expectations: list[Expectation] = field(default_factory=list)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"rulebook_id": self.rulebook_id,
|
||||
"expectations": [e.as_dict() for e in self.expectations],
|
||||
}
|
||||
|
||||
|
||||
def normalize_hex(value: str) -> str | None:
|
||||
"""Fold a hex colour to a comparable form, or None if it isn't one.
|
||||
|
||||
Load-bearing for the whole comparison: the rulebook writes `#FFFFFF` and the
|
||||
code writes `#fff`, and those must compare equal or the single largest drift
|
||||
finding (#2275) reads as zero. Expands 3-digit shorthand and lowercases.
|
||||
|
||||
Alpha forms (4 and 8 digit) keep their alpha — `#fff` and `#ffff` are not the
|
||||
same colour, and silently dropping the alpha would invent equality.
|
||||
"""
|
||||
match = _HEX.fullmatch(value.strip()) or _HEX.match(value.strip())
|
||||
if not match:
|
||||
return None
|
||||
digits = match.group(1).lower()
|
||||
if len(digits) in (3, 4):
|
||||
digits = "".join(c * 2 for c in digits)
|
||||
if len(digits) not in (6, 8):
|
||||
return None
|
||||
return f"#{digits}"
|
||||
|
||||
|
||||
def expand_token_shorthand(raw: str) -> list[str]:
|
||||
"""`--fs-radius-sm/md/lg/xl` -> the four names it stands for.
|
||||
|
||||
The rulebook writes token families in a slash shorthand, and both forms it
|
||||
uses expand correctly under one rule: take everything up to and including the
|
||||
LAST hyphen of the first segment as the prefix, then append each alternative.
|
||||
|
||||
--fs-radius-sm/md/lg/xl prefix `--fs-radius-` -> sm, md, lg, xl
|
||||
--fs-obsidian/iron/slate prefix `--fs-` -> obsidian, iron, slate
|
||||
--fs-dur-fast/base/slow prefix `--fs-dur-` -> fast, base, slow
|
||||
|
||||
A name with no slash is returned as-is.
|
||||
"""
|
||||
if "/" not in raw:
|
||||
return [raw]
|
||||
head, *rest = raw.split("/")
|
||||
cut = head.rfind("-")
|
||||
if cut <= 1: # no hyphen beyond the leading `--`
|
||||
return [head, *rest]
|
||||
prefix = head[: cut + 1]
|
||||
return [head, *[f"{prefix}{part}" for part in rest if part]]
|
||||
|
||||
|
||||
def _is_negated(sentence: str) -> bool:
|
||||
return any(marker in sentence.lower() for marker in _NEGATIONS)
|
||||
|
||||
|
||||
def _extract_from_sentence(sentence: str, rule: Rule) -> list[Expectation]:
|
||||
"""Claims in ONE sentence, with negation scoped to that sentence.
|
||||
|
||||
Sentence scope is what makes the prohibition detection usable. Rule 52 reads:
|
||||
|
||||
"Text tokens: Parchment #E8E4D8 …, Vellum #C2BFB4 …, Ash #9C9A92 ….
|
||||
Pure white #FFFFFF is NEVER used as text color."
|
||||
|
||||
Three colours the palette REQUIRES and one it FORBIDS, in one statement.
|
||||
Detecting negation across the whole statement would mark all four as
|
||||
forbidden; detecting it per sentence gets all four right.
|
||||
"""
|
||||
out: list[Expectation] = []
|
||||
negated = _is_negated(sentence)
|
||||
|
||||
for match in _HEX.finditer(sentence):
|
||||
value = normalize_hex(match.group(0))
|
||||
if not value:
|
||||
continue
|
||||
out.append(Expectation(
|
||||
kind="prohibited_color" if negated else "color",
|
||||
value=value,
|
||||
rule_id=int(rule.id),
|
||||
rule_title=rule.title,
|
||||
context=sentence.strip(),
|
||||
))
|
||||
|
||||
# Token names are not negated in practice — a rule says which tokens should
|
||||
# exist, never which must not — so they are recorded as expectations
|
||||
# regardless. If that ever changes, it needs its own kind rather than
|
||||
# borrowing the colour one.
|
||||
for match in _TOKEN.finditer(sentence):
|
||||
for name in expand_token_shorthand(match.group(1)):
|
||||
out.append(Expectation(
|
||||
kind="token",
|
||||
value=name,
|
||||
rule_id=int(rule.id),
|
||||
rule_title=rule.title,
|
||||
context=sentence.strip(),
|
||||
))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def extract_expectations(rules: list[Rule]) -> list[Expectation]:
|
||||
"""Every checkable claim across a set of rules, deduped on (kind, value).
|
||||
|
||||
First occurrence wins so the reported rule is the one that introduced the
|
||||
claim, which is usually the most specific place to send a reader.
|
||||
"""
|
||||
seen: set[tuple[str, str]] = set()
|
||||
out: list[Expectation] = []
|
||||
for rule in rules:
|
||||
text = " ".join(filter(None, [rule.statement or "", rule.how_to_apply or ""]))
|
||||
for sentence in _SENTENCE_SPLIT.split(text):
|
||||
if not sentence.strip():
|
||||
continue
|
||||
for expectation in _extract_from_sentence(sentence, rule):
|
||||
key = (expectation.kind, expectation.value)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(expectation)
|
||||
return out
|
||||
|
||||
|
||||
async def get_design_rulebook_id(user_id: int) -> int | None:
|
||||
"""The rulebook this install designated as its design system, if any."""
|
||||
raw = (await get_setting(user_id, DESIGN_RULEBOOK_SETTING, "")).strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
async def design_expectations(user_id: int) -> ExpectationSet:
|
||||
"""Checkable claims from the designated design rulebook.
|
||||
|
||||
Returns an empty set when no rulebook is designated — the normal case for
|
||||
any install but the one that set it up (rule #115). The caller shows an
|
||||
explanatory empty state rather than treating this as an error.
|
||||
"""
|
||||
rulebook_id = await get_design_rulebook_id(user_id)
|
||||
if rulebook_id is None:
|
||||
return ExpectationSet()
|
||||
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
|
||||
try:
|
||||
rules = await rulebooks_svc.list_rules(user_id, rulebook_id=rulebook_id)
|
||||
except Exception:
|
||||
logger.warning("Design rulebook %s could not be read", rulebook_id, exc_info=True)
|
||||
return ExpectationSet(rulebook_id=rulebook_id)
|
||||
|
||||
return ExpectationSet(rulebook_id=rulebook_id, expectations=extract_expectations(rules))
|
||||
Reference in New Issue
Block a user