52d94303bc
ci / test (push) Has been cancelled
Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
207 lines
8.6 KiB
Python
207 lines
8.6 KiB
Python
# -*- 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
|