# -*- coding: utf-8 -*- """PLANT EXTRACTION RULE PACKS: one producer's drawing habits, kept out of the generic reader and loaded only where the plant knowledge says so. WHY THIS EXISTS --------------- `ticketqc.parse_design_summary` reads a design summary with rules that are true of precast drawings generally -- an f'c line, a strand callout, a mesh callout. Every text note it cannot place lands in the spec's `unresolved` list verbatim, which is what stops a narrow rule set from being a lossy one. But a real sheet carries a second population of notes that are not general at all. They are one producer's house style: the title of a standard detail, the label above a legend, a standing instruction that appears on every sheet that producer draws. The generic reader cannot place them and should not try -- they are not facts about precast, they are facts about an office. Left alone they are not harmless. Seven of them on one sheet turn `unresolved` into a list nobody reads, and the day a genuinely unread value lands there it is the eighth line of a list the reader has already learned to skim. A signal that always fires is not a signal -- the same argument `.setup-opts` makes for a check that always FAILs, and the same one `checks.muted` makes for a row that is permanently NOT RUN. WHAT A PACK MAY DO, AND WHAT IT MAY NEVER DO -------------------------------------------- A pack classifies a note the generic rules did not match. It may say: * `note` -- prose that belongs in the spec's `notes` list. An instruction, a condition, a standing rule. It is CARRIED, verbatim, into a field a person reads. * `furniture` -- drawing furniture that carries no data at all: the bare title of a legend or a detail. It STAYS in `unresolved`, with the pack's reason in place of the blanket one. A pack may NEVER remove a note from the spec, and there is no third verdict that makes one disappear. `furniture` is not an ignore list: the line is still written out, still readable, still countable -- only the sentence beside it changes, from "no rule matched this" to "this is a legend title, it carries no data". An exclusion the code chose for itself is the one failure this project has already paid for (see `vibe/scope.py`), and nothing here may repeat it. WHEN UNSURE, A PACK SAYS `note`, NEVER `furniture` -------------------------------------------------- The two errors are not symmetric, in the same way `vibe/scope.py`'s two are not. A note wrongly called `note` is copied into a field a person reads and costs them one line of attention. A note wrongly called `furniture` has been declared meaningless by a regex, and the reader is told there is nothing there. So a rule that is not certain writes `note`, and a title that carries any condition beyond its own name (`STRIPPING & BLOCKING DETAIL < 25'`) is a note, not furniture -- the condition is the part that could matter. WHICH PACKS LOAD IS DECLARED, NEVER INFERRED -------------------------------------------- From the PLANT layer, `extract.rule_packs`. Not from `plant.id`, even though that key exists and would be enough to guess with: which house style a job is drawn in is a thing somebody knows and can write down, and a guess that is right today is a guess that goes wrong silently the first time a job is drawn to another producer's standard on this plant's template. extract: rule_packs: [mc] Undeclared means NO PACK LOADS and every note behaves exactly as it did before this module existed -- unmatched notes land in `unresolved` under the generic reason. That is the honest default: a machine that has been told nothing about an office's habits should claim to recognise nothing. A declared name this module does not know is REFUSED, loudly, and no pack loads at all. Same discipline as `tools/setup_machine.py`'s `.setup-opts` check: an opt file that has gone quiet about a line nobody honours is worse than no opt file, because the reader believes something is in force that is not. `problems` comes back beside the packs and the caller prints it. Nothing here imports a module named in knowledge. `PACKS` is a whitelist of names this package ships; a name outside it is an error, never an import. IronPython 2.7: no f-strings, no pathlib, no typing, no dataclasses. """ # Declared name -> module basename inside this package. A WHITELIST: the # knowledge layer chooses among these, it never names a module to import. PACKS = { 'mc': 'mc', } KEY = 'extract.rule_packs' # What a pack may return from `classify`. `note` is carried into the spec's # notes; `furniture` stays in unresolved with a better reason. There is no # verdict that drops a line -- see the module docstring. NOTE = 'note' FURNITURE = 'furniture' KINDS = (NOTE, FURNITURE) class Placement(object): """One pack's verdict on one note. `rule` is the pack-qualified id of the rule that fired (`mc/legend-title`) so a reader of the spec can find the line of code that made the claim, and argue with it. A placement that cannot say which rule made it is not accepted -- an unattributable classification is exactly the kind nobody can correct. """ __slots__ = ('kind', 'rule', 'reason') def __init__(self, kind, rule, reason): if kind not in KINDS: raise ValueError('unknown placement kind: %r' % (kind,)) if not rule: raise ValueError('a placement must name the rule that made it') self.kind = kind self.rule = rule self.reason = reason def __repr__(self): return 'Placement(%r, %r)' % (self.kind, self.rule) def load(know, get=None): """(packs, problems) for the rule packs this knowledge declares. `packs` is a list of imported pack modules, in declared order. `problems` is a list of one-line strings naming anything refused; when it is non-empty `packs` is EMPTY -- a half-loaded rule set would classify some notes and not others, and the reader could not tell which sentences to trust. All or nothing, and say why. """ if get is None: from vibe import knowledge as K get = K.get declared = get(know, KEY, None) or [] if isinstance(declared, str): declared = [declared] problems = [] names = [] for raw in declared: name = (raw or '').strip().lower() if not name: problems.append('an empty name in %s -- remove the entry or ' 'name a pack' % (KEY,)) elif name not in PACKS: problems.append("%s names '%s', which this build does not ship. " 'Known packs: %s' % (KEY, name, ', '.join(sorted(PACKS)) or '(none)')) elif name in names: problems.append("%s names '%s' twice" % (KEY, name)) else: names.append(name) if problems: return [], problems packs = [] for name in names: packs.append(__import__('vibe.extractrules.' + PACKS[name], globals(), locals(), ['classify'], 0)) return packs, [] def classify(text, packs): """The first pack that recognises `text`, or None. First, not best: packs are tried in the order they were declared, and the reader can see that order in the job file. Nothing here merges two packs' opinions -- a note that two house styles both claim is a fact about the declaration, not something this function should quietly average. """ for pack in (packs or []): placed = pack.classify(text) if placed is not None: return placed return None def apply_to(entries, packs): """Split `unresolved` entries into (notes, unresolved) using the packs. `entries` are the dicts `ticketqc.unresolved_in_view` returns -- each carries at least 'text' and 'reason'. Every entry comes back in exactly one of the two lists and none is dropped: `len(notes) + len(unresolved)` always equals `len(entries)`, which `test_extractrules` asserts directly because it is the property the whole module rests on. A `note` gains 'rule' so the spec says which rule claimed it. A `furniture` keeps its place in unresolved and only its 'reason' changes. """ notes = [] unresolved = [] for entry in (entries or []): placed = classify(entry.get('text') or '', packs) if placed is None: unresolved.append(entry) continue out = dict(entry) out['rule'] = placed.rule if placed.reason: out['reason'] = placed.reason if placed.kind == NOTE: notes.append(out) else: unresolved.append(out) return notes, unresolved