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.
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
title: "Ticket\nQC"
|
||||
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
|
||||
# on each click, so an edit to a module this button uses is visible without
|
||||
# a Revit restart. It trades press latency (engine construction instead of
|
||||
# engine reuse) for restart-freedom, and it is the hottest button here --
|
||||
# 51 commits to its six modules in the seven days to 2026-08-30.
|
||||
#
|
||||
# Read docs/architecture-migration.md section 8 before trusting or removing
|
||||
# this. Two things it says that are not obvious: pyRevit's DEFAULT is already
|
||||
# a clean engine, and this extension gets one today only because
|
||||
# extension.json has no `url:` and so is never registered as a package --
|
||||
# add a url and every button silently switches to the shared engine. This key
|
||||
# is the pin against that, not a new capability.
|
||||
#
|
||||
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
|
||||
engine:
|
||||
clean: true
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# Two lines, short ones. A large ribbon button is as wide as its widest line
|
||||
# of text, so a long title makes one button dwarf its neighbours -- which is
|
||||
# exactly what the owner objected to. Keep every line under ~9 characters.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,742 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Check shop tickets against their design spec.
|
||||
#
|
||||
# Reads the specs written by Extract Design plus the project's knowledge.yaml,
|
||||
# then compares every piece of the chosen design numbers against them.
|
||||
#
|
||||
# Every CHECK here is pure arithmetic - the same model always gives the same
|
||||
# answer, and every row of the checklist table is computed. One thing in the
|
||||
# report is not: with the run-time option `vision_escalation` on, the checks
|
||||
# code cannot settle (5.5) are escalated to a small model reading one rendered
|
||||
# view. That result is quarantined - its own list, its own codes, its own
|
||||
# section, and it can never set a checklist row. See vibe.ticketvision and
|
||||
# docs/ticket-checklist.md section 8bis.
|
||||
#
|
||||
# Read-only. Nothing here opens a transaction, including the image export.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Ticket\nQC'
|
||||
|
||||
import os
|
||||
|
||||
from Autodesk.Revit.DB import AssemblyInstance, ElementId
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import ticketqc as Q
|
||||
from vibe import configdump
|
||||
from vibe import designspec, knowledge
|
||||
from vibe import scope as scopelib
|
||||
from vibe import ticketchecks as TC
|
||||
from vibe import ticketvision as TV
|
||||
from vibe import ticketpage as TP
|
||||
from vibe import foldreport as fold
|
||||
from vibe import compat
|
||||
|
||||
doc = revit.doc
|
||||
uidoc = revit.uidoc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
# ------------------------------------------------------------------ context
|
||||
# The published checklist has to exist before anything else here is worth
|
||||
# doing: every row this button ever prints comes off it. On a knowledge-less
|
||||
# trial install (tools/make_dist.py ships no knowledge/catalogue.yaml) this
|
||||
# is the ordinary, expected state -- refuse loudly and say so, rather than
|
||||
# rendering a report with zero rows that would look exactly like a piece with
|
||||
# nothing wrong on it. See vibe.ticketpage's CHECKLIST_UNAVAILABLE_REASON.
|
||||
if TP.CHECKLIST_UNAVAILABLE_REASON:
|
||||
forms.alert(TP.CHECKLIST_UNAVAILABLE_REASON, title='Ticket QC',
|
||||
exitscript=True)
|
||||
|
||||
# The knowledge file has to come first: it holds the mapping from role to this
|
||||
# office's parameter names, and without it nothing can even be grouped.
|
||||
know = knowledge.load(doc)
|
||||
|
||||
# A project config that has gone missing means this run would use a
|
||||
# configuration nobody chose, and look entirely normal doing it.
|
||||
for problem in knowledge.config_problems(doc, know):
|
||||
forms.alert(problem, title='Ticket QC', exitscript=True)
|
||||
|
||||
missing, not_applicable = knowledge.unmapped_roles(know)
|
||||
if missing:
|
||||
forms.alert(
|
||||
'These parameter roles are not mapped in knowledge.yaml, so the '
|
||||
'checks that need them cannot run:\n\n {0}\n\n'
|
||||
'Edit the "parameters:" section of\n{1}\n\n'
|
||||
'Run Self Check for the full picture.'
|
||||
.format('\n '.join(missing), designspec.knowledge_path(doc)),
|
||||
title='Ticket QC', exitscript=('panel.design_number' in missing))
|
||||
|
||||
# ------------------------------------------------------------- the selection
|
||||
# The selection decides, exactly as Panel Lift Check does: assemblies picked
|
||||
# straight through, a picked member resolves to its owning assembly, and
|
||||
# nothing picked means a checklist of every assembly. The one deliberate
|
||||
# difference from Panel Lift Check: there is no "batch or single" question
|
||||
# here -- Ticket QC is always a batch of one or more pieces.
|
||||
|
||||
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
|
||||
assemblies = [el for el in selection if isinstance(el, AssemblyInstance)]
|
||||
|
||||
if not assemblies:
|
||||
owners = []
|
||||
for el in selection:
|
||||
try:
|
||||
owner = doc.GetElement(el.AssemblyInstanceId)
|
||||
except Exception:
|
||||
owner = None
|
||||
if isinstance(owner, AssemblyInstance) and owner not in owners:
|
||||
owners.append(owner)
|
||||
assemblies = owners
|
||||
|
||||
specs = designspec.list_specs(doc) if doc.PathName else {}
|
||||
summaries, _sheets = Q.collect_context(doc)
|
||||
grouped = Q.assemblies_by_design(doc, know=know)
|
||||
|
||||
# ------------------------------------------------------------- the scope
|
||||
#
|
||||
# The DECLARED SCOPE, off the job knowledge. Two different things are done
|
||||
# with it, and the difference is the whole point:
|
||||
#
|
||||
# * the CHECKLIST this button offers when nothing is preselected is the
|
||||
# tool choosing what to look at, so it offers what the scope declares.
|
||||
# What it left off the list is stated on the report, by kind.
|
||||
# * a piece the USER preselected is judged whatever its prefix. The
|
||||
# selection is the request, and dropping it would be the silent
|
||||
# exclusion this whole tool exists to refuse. It is judged and the
|
||||
# report says the verdict rests on knowledge nobody validated for that
|
||||
# kind of piece.
|
||||
the_scope = scopelib.of(know)
|
||||
scope_line = None # what was left off the checklist
|
||||
scope_annotation = None # what was judged outside the scope anyway
|
||||
|
||||
if not assemblies:
|
||||
# Nothing preselected: a checklist of every assembly, usable at 238
|
||||
# pieces -- mark, design number, and whether a spec or only a regex
|
||||
# summary backs it (the information the old design dialog showed,
|
||||
# moved onto the pieces).
|
||||
items = []
|
||||
for design, asm_list in sorted(grouped.items()):
|
||||
for asm in sorted(asm_list, key=lambda a: a.AssemblyTypeName):
|
||||
if design in specs:
|
||||
state = 'spec'
|
||||
elif design in summaries:
|
||||
state = 'regex fallback'
|
||||
else:
|
||||
state = 'NO spec/summary'
|
||||
items.append((asm, '{0} - {1} - {2}'
|
||||
.format(asm.AssemblyTypeName, design, state)))
|
||||
items.sort(key=lambda r: r[1])
|
||||
offered, left_alone = [], []
|
||||
for row in items:
|
||||
(offered if the_scope.includes(row[0].AssemblyTypeName)
|
||||
else left_alone).append(row)
|
||||
scope_line = the_scope.line(
|
||||
[r[0].AssemblyTypeName for r in left_alone], total=len(items),
|
||||
unit='piece')
|
||||
items = offered
|
||||
if not items:
|
||||
forms.alert('There is nothing to check.\n\n{0}'.format(scope_line),
|
||||
title='Ticket QC', exitscript=True)
|
||||
names = [r[1] for r in items]
|
||||
picked = forms.SelectFromList.show(
|
||||
names, multiselect=True, title='Ticket QC - choose pieces',
|
||||
button_name='Check tickets')
|
||||
if not picked:
|
||||
script.exit()
|
||||
picked_set = set(picked)
|
||||
assemblies = [r[0] for r in items if r[1] in picked_set]
|
||||
if not assemblies:
|
||||
script.exit()
|
||||
|
||||
if not grouped and not assemblies:
|
||||
forms.alert(
|
||||
'No assembly carries a value for the "panel.design_number" role '
|
||||
'(mapped to: {0}).\n\nEither the mapping is wrong or this model does '
|
||||
'not group pieces that way.'
|
||||
.format(Q.role_label(know, 'panel.design_number')), exitscript=True)
|
||||
|
||||
# --------------------------------------------------------------------- run
|
||||
# Per-piece design intent (Phase 3): the picked pieces may span designs, so
|
||||
# each assembly's design number is looked up on its own -- not under one
|
||||
# design's summary. Progress is visible and cancellable: context is
|
||||
# collected once (not per piece), each piece runs in turn, and the bar
|
||||
# advances per piece so the user sees movement rather than a frozen window.
|
||||
# The first piece of an untouched design pays Revit's one-time geometry
|
||||
# generation, which can take a while -- the title says so.
|
||||
context = Q.collect_context(doc)
|
||||
results, missing = [], []
|
||||
total = len(assemblies)
|
||||
bar_title = 'Checking tickets ({0} pieces) - first of a design can be slow'
|
||||
with forms.ProgressBar(title=bar_title.format(total),
|
||||
cancellable=True) as pb:
|
||||
for i, asm in enumerate(assemblies):
|
||||
if pb.cancelled:
|
||||
break
|
||||
pb.update_progress(i, total)
|
||||
batch, miss = Q.run_for_assemblies(
|
||||
doc, [asm], specs=specs, know=know, context=context)
|
||||
results.extend(batch)
|
||||
missing.extend(miss)
|
||||
pb.update_progress(total, total)
|
||||
|
||||
# ------------------------------------------- the escalation, when asked for
|
||||
# Checks no comparison can settle (5.5) escalate to a small model reading one
|
||||
# rendered view. Its answer is NEVER a pass and NEVER a fail: the checklist
|
||||
# row stays as it was and a WARN says a person must look
|
||||
# (docs/ticket-checklist.md section 8bis). Kept in `model_read`, never in
|
||||
# `findings`, so nothing computed and nothing read can be mistaken for each
|
||||
# other. OFF unless the run option says otherwise -- a model call spends
|
||||
# money and leaves the building.
|
||||
_vision_on, _ = TV.run_settings(designspec.root_dir(doc))
|
||||
for r in results:
|
||||
r['model_read'] = []
|
||||
if _vision_on and results:
|
||||
with forms.ProgressBar(title='Escalating to a model reading the drawing',
|
||||
cancellable=True) as pb:
|
||||
for i, r in enumerate(results):
|
||||
if pb.cancelled:
|
||||
break
|
||||
pb.update_progress(i, len(results))
|
||||
try:
|
||||
r['model_read'] = TV.read_for_ticket(
|
||||
r['ticket'], know, designspec.root_dir(doc))
|
||||
except Exception as _ex:
|
||||
r['model_read'] = [TV.not_run_row(
|
||||
'AI-BARMARK', 'the drawing',
|
||||
'the escalation itself failed ({0})'
|
||||
.format(type(_ex).__name__))]
|
||||
|
||||
# ------------------------------------------------------------------ report
|
||||
#
|
||||
# THE CHECKLIST TABLE IS THE PAGE. Everything else is appendix.
|
||||
#
|
||||
# Owner, 2026-08-30, after reading a real run:
|
||||
#
|
||||
# "大量的配置信息也都往后放,并且默认折叠。我最在乎的是那张检查的表。
|
||||
# 那张检查的表放在最前面,并且字儿可以再大一点。OK用绿色,
|
||||
# WARN,FOR_ENGINEER用黄色,NOTRUN,FAIL用红色。"
|
||||
#
|
||||
# So: the tables first and big; the design intent, the scope, the knowledge
|
||||
# path, the tally and the caveats after them, folded. The fold rule is not
|
||||
# this button's -- it lives in `vibe.foldreport` and it is the standing
|
||||
# pattern for every report this tool prints: default folded, a section that
|
||||
# carries a problem opens itself, and a folded header still says in one line
|
||||
# what is inside it. The reader's contract is that if nothing below the
|
||||
# tables is open, nothing below the tables needs him.
|
||||
#
|
||||
# WHY THE HTML IS HAND-WRITTEN AND LOOKS DATED
|
||||
# ---------------------------------------------
|
||||
# This output panel is a `System.Windows.Forms.WebBrowser` -- the IE engine
|
||||
# in WinForms, in IE7 document mode unless the host sets
|
||||
# FEATURE_BROWSER_EMULATION, which pyRevit does not. `<details>` /
|
||||
# `<summary>` DO NOT EXIST here and fail SILENTLY: an unknown element
|
||||
# renders inline and the "collapsed" content is simply always visible. So
|
||||
# the fold is an inline `onclick` plus `style.display`, which is IE5-era DOM
|
||||
# and works in every mode this control can be in.
|
||||
#
|
||||
# The checklist table itself uses NO script and NO HTML5 -- a plain <table>
|
||||
# with inline colours. If the toggle is dead, the product is still on the
|
||||
# page, in full, in colour. That is the whole point of putting it outside
|
||||
# the fold machinery.
|
||||
#
|
||||
# WHAT THE COLOURS SAY (vibe.ticketpage.state_color owns the grouping):
|
||||
# OK green
|
||||
# WARN, FOR ENGINEER yellow
|
||||
# NOT RUN, FAIL RED -- both of them, deliberately. An unrun check
|
||||
# tells you nothing about the piece, so it must never
|
||||
# look calmer than a failed one. Silence is not a pass.
|
||||
|
||||
# Muting: what this job has DECLARED it does not want asked, with a reason
|
||||
# (`checks.muted` in the job knowledge, edited on the Project Config screen).
|
||||
# It filters ROWS ONLY. `r['findings']` is untouched from here to the JSON
|
||||
# writer and to the clean/failing/warning tally, so a muted check that
|
||||
# failed still fails the ticket and still lands on disk.
|
||||
# THE MUTES, RESOLVED TO MASTER IDS AS THEY ARE READ.
|
||||
#
|
||||
# Owner, 2026-08-31: "以后引用需要前面加 plant-member。" A mute may be written
|
||||
# as a bare master id (`3.3` -- every existing one is) or qualified against a
|
||||
# plant's own board (`sample_plant-wall_panel 3.3`). Both end up keyed by the
|
||||
# MASTER id, which is the whole point: when the plant renumbers its board
|
||||
# tomorrow, the mute follows the QUESTION and not the POSITION. A ruling can
|
||||
# no longer be orphaned by a renumbering.
|
||||
_plant_id = knowledge.plant_id(doc)
|
||||
muted = TP.muted_checks(know, resolver=knowledge.resolve_check_reference,
|
||||
plant=_plant_id)
|
||||
_mute_problems = TP.muted_reference_problems(know, plant=_plant_id)
|
||||
|
||||
# Applicability: what a KNOWLEDGE LAYER declares is not a question about this
|
||||
# kind of piece, so the check was never run (`checks.not_applicable`). It is
|
||||
# not muting and it is not scope -- see the block over
|
||||
# `ticketpage.apply_applicability` for where each of the five states sits.
|
||||
#
|
||||
# WHICH LAYER DECLARED IT is computed rather than typed. `declared_by` in the
|
||||
# entry says who RULED; this says which FILE the tool read it from, which is
|
||||
# the file somebody has to edit to change it. It is resolved once per press,
|
||||
# not per row -- it re-reads the layer files.
|
||||
try:
|
||||
_layers = configdump.layer_values(doc)
|
||||
except Exception:
|
||||
_layers = []
|
||||
|
||||
|
||||
def _declaring_layer(item, condition):
|
||||
if not _layers or not condition:
|
||||
return None
|
||||
# KEYS, NOT A DOTTED PATH: a checklist row number contains a dot.
|
||||
for branch in ('not_applicable', 'only_applies_to'):
|
||||
found = knowledge.declaring_layer(
|
||||
_layers, ['checks', branch, item, condition])
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
for r in results:
|
||||
_rows = TC.checklist_states(r['findings'], r.get('run_log'))
|
||||
# A model read never sets a status. It only marks the row it looked at,
|
||||
# so a reader following that row knows to read the quarantined section
|
||||
# below rather than mistaking silence for a verdict.
|
||||
_rows = TV.annotate_rows(_rows, r.get('model_read'))
|
||||
# THE SAME RESOLUTION `run_all` USED. Two independent resolutions of
|
||||
# "does this row apply" is how a page comes to describe a run that did
|
||||
# not happen, so both sides call one function.
|
||||
_decisions, _classification, _member, _profile = \
|
||||
TC.applicability_decisions_for(r['ticket'], know)
|
||||
for _num in _decisions:
|
||||
_d = _decisions[_num]
|
||||
if _d.get('not_applicable'):
|
||||
_d['layer'] = _declaring_layer(_num, _d.get('condition'))
|
||||
_rows, _na_notes = TP.apply_applicability(_rows, _decisions)
|
||||
r['applicability_notes'] = _na_notes
|
||||
r['applicability_problems'] = TP.applicability_problems(_decisions)
|
||||
r['classification'] = _classification
|
||||
r['member'] = _member
|
||||
r['profile'] = _profile
|
||||
r['off_board'] = TP.off_board(_decisions)
|
||||
_kept, _muted_notes = TP.apply_muting(_rows, muted)
|
||||
r['rows'] = _kept
|
||||
# The same rows BEFORE muting. Nothing on the page reads this; it is what
|
||||
# lets `qc-run.json` record the state a muted row really reported as a
|
||||
# STATE rather than as a sentence buried in an English note. A grey row
|
||||
# whose original answer a machine cannot read back has thrown the answer
|
||||
# away, which is the thing muting is specifically not allowed to do.
|
||||
r['rows_unmuted'] = _rows
|
||||
r['muted_notes'] = _muted_notes
|
||||
# LAST, AND IT ONLY REORDERS AND LABELS. The stable master id stays at
|
||||
# `row[0]` -- `checklist_details`, the note folds, the JSON and every
|
||||
# rule key on it -- and the plant's own number rides at `row[5]`.
|
||||
r['rows'] = TP.profile_view(r['rows'], _profile)
|
||||
r['rows_unmuted'] = TP.profile_view(r['rows_unmuted'], _profile)
|
||||
r['details'] = TP.checklist_details(r['findings'])
|
||||
r['counts'] = TP.checklist_tally(r['rows'])
|
||||
|
||||
|
||||
def _loudness(r):
|
||||
"""(fails, not-runs, warns, for-engineer) -- how much this ticket wants."""
|
||||
c = r['counts']
|
||||
return (c.get('FAIL', 0), c.get('NOT RUN', 0), c.get('WARN', 0),
|
||||
c.get('FOR ENGINEER', 0))
|
||||
|
||||
|
||||
loud, all_green = [], []
|
||||
for r in results:
|
||||
(loud if any(_loudness(r)) else all_green).append(r)
|
||||
loud.sort(key=lambda r: (-_loudness(r)[0], -_loudness(r)[1],
|
||||
-_loudness(r)[2], r['ticket']['mark']))
|
||||
|
||||
clean, failed, warned = [], [], []
|
||||
tally = {}
|
||||
for r in results:
|
||||
bad = [f for f in r['findings'] if f.severity != 'OK']
|
||||
for f in bad:
|
||||
tally[f.code] = tally.get(f.code, 0) + 1
|
||||
if not bad:
|
||||
clean.append(r)
|
||||
elif any(f.severity == 'FAIL' for f in bad):
|
||||
failed.append(r)
|
||||
else:
|
||||
warned.append(r)
|
||||
|
||||
output.add_style(TP.CHECKLIST_STYLE)
|
||||
output.add_style(fold.STYLE)
|
||||
output.inject_script(fold.SCRIPT)
|
||||
|
||||
# ---- the headline, then the tables. Nothing folded above this line.
|
||||
output.print_md('# Shop ticket QC')
|
||||
output.print_md('Model **{0}** · {1} ticket(s) across {2} design '
|
||||
'number(s) · {3} clean · {4} failing '
|
||||
'· {5} warning only'
|
||||
.format(doc.Title, len(results),
|
||||
len(set([r['design'] for r in results])),
|
||||
len(clean), len(failed), len(warned)))
|
||||
output.print_html(TP.checklist_key_html())
|
||||
|
||||
for r in loud:
|
||||
t = r['ticket']
|
||||
head = '**{0}** [{1}] {2} x {3} — {4}'.format(
|
||||
t['mark'], r['design'],
|
||||
Q.fmt_feet(t.get('length')), Q.fmt_feet(t.get('width')),
|
||||
TP.checklist_headline(r['counts']))
|
||||
if t.get('sheet') is not None:
|
||||
head += ' ' + output.linkify(t['sheet'].Id, 'open ticket')
|
||||
if t.get('panel_id'):
|
||||
head += ' ' + output.linkify(ElementId(t['panel_id']), 'panel')
|
||||
output.print_md(head)
|
||||
output.print_html(TP.checklist_table_html(r['rows'], r['details']))
|
||||
|
||||
# A ticket whose every row is green needs no table -- there is nothing on it
|
||||
# to read. It gets a line, and the line says the table was all green rather
|
||||
# than that the ticket was "clean", which is a different and weaker claim.
|
||||
if all_green:
|
||||
# "green" is a claim, so it is only made when it is true. A piece whose
|
||||
# quiet rows include one a knowledge layer switched off has not had every
|
||||
# question answered, and saying so here is cheaper than a reader finding
|
||||
# out from the section below.
|
||||
_quiet = sum([r['counts'].get(TP.NOT_APPLICABLE_STATE, 0)
|
||||
for r in all_green])
|
||||
output.print_md('**{0} ticket(s) with nothing to read on the checklist:** '
|
||||
'{1}{2}'
|
||||
.format(len(all_green),
|
||||
', '.join([r['ticket']['mark']
|
||||
for r in all_green]),
|
||||
(' — and {0} row(s) across them were NOT '
|
||||
'ASKED because a knowledge layer declares they '
|
||||
'do not apply; see below'.format(_quiet))
|
||||
if _quiet else ' — every row green'))
|
||||
|
||||
# ------------------------------------------------- everything else, folded
|
||||
#
|
||||
# Built as `foldreport.Section`s so the same content renders as HTML here and
|
||||
# as plain text into the file below -- one source, so the page and the file
|
||||
# cannot say different things.
|
||||
sections = []
|
||||
|
||||
|
||||
def _section(key, title, summary, blocks, problem=None):
|
||||
if not blocks:
|
||||
return
|
||||
sections.append(fold.Section(key, title, summary=summary, blocks=blocks,
|
||||
problem=problem))
|
||||
|
||||
|
||||
# -- pieces that could not be checked. Always a problem when it is not empty:
|
||||
# 'could not be checked' is a result, not an absence.
|
||||
if missing:
|
||||
_section('missing', 'Pieces that could not be checked',
|
||||
'{0} piece(s)'.format(len(missing)),
|
||||
[(fold.MD, '- **{0}** (id {1}): {2}'
|
||||
.format(m.get('mark', '?'), m.get('id', '?'),
|
||||
m.get('why', 'unknown')))
|
||||
for m in missing],
|
||||
problem='{0} piece(s) were not judged at all'.format(len(missing)))
|
||||
|
||||
# -- weaker readings. A caveat that actually bites, so it opens itself.
|
||||
regex_designs = sorted(set([r['design'] for r in results
|
||||
if r['spec_source'] == 'regex']))
|
||||
if regex_designs:
|
||||
_section('regex', 'Designs read with regexes, not an extracted spec',
|
||||
', '.join(regex_designs),
|
||||
[(fold.MD, '**{0}** had no extracted spec, so the summary was '
|
||||
'read with regexes instead. That reading is weaker '
|
||||
'-- run **Extract Design** on them.'
|
||||
.format(', '.join(regex_designs)))],
|
||||
problem='{0} design(s) are being judged against a weaker '
|
||||
'reading'.format(len(regex_designs)))
|
||||
|
||||
# -- WHICH PROFILE RAN, and what it switched off.
|
||||
#
|
||||
# Required once per run, per piece, and as loud as the scope line: a
|
||||
# mistaken applicability rule is a safety check disabled in silence, and the
|
||||
# only thing standing between that and a signed report is this section. So it
|
||||
# names the classification AND the rows, and it opens itself whenever
|
||||
# anything was switched off or any declaration was refused -- unlike muting,
|
||||
# which folds when it is tidy, because a mute leaves the check running.
|
||||
_na_notes, _na_problems, _profile_lines = [], [], []
|
||||
_off_board = []
|
||||
_seen_na = set()
|
||||
for r in results:
|
||||
# WHICH BOARD, THEN WHICH KIND OF PIECE. The profile line says what the
|
||||
# numbers in the table even mean; the classification line says how the
|
||||
# piece was placed within it. A renumbered table with neither has shown
|
||||
# the reader a checklist without telling him whose.
|
||||
_profile_lines.append(TP.profile_line(
|
||||
r['ticket'].get('mark') or '(no mark)', _plant_id,
|
||||
r.get('member'), r.get('profile'), r.get('off_board')))
|
||||
for _num in (r.get('off_board') or []):
|
||||
if _num not in _off_board:
|
||||
_off_board.append(_num)
|
||||
_profile_lines.append(TP.classification_line(
|
||||
r['ticket'].get('mark') or '(no mark)', r.get('classification'),
|
||||
r.get('applicability_notes')))
|
||||
for note in (r.get('applicability_notes') or []):
|
||||
if note[0] not in _seen_na:
|
||||
_seen_na.add(note[0])
|
||||
_na_notes.append(note)
|
||||
for problem in (r.get('applicability_problems') or []):
|
||||
if problem not in _na_problems:
|
||||
_na_problems.append(problem)
|
||||
_pp = (r.get('profile') or {}).get('problem')
|
||||
if _pp and ('profile', _pp) not in _na_problems:
|
||||
_na_problems.append(('profile', _pp))
|
||||
for _written, _why in _mute_problems:
|
||||
_entry = ('mute {0!r}'.format(_written), _why)
|
||||
if _entry not in _na_problems:
|
||||
_na_problems.append(_entry)
|
||||
|
||||
_na_blocks = [(fold.MD, line) for line in _profile_lines]
|
||||
if _na_notes:
|
||||
_na_blocks.append((fold.MD, '**Rows not asked, and who said so:**'))
|
||||
_na_blocks.extend([(fold.MD, line)
|
||||
for line in TP.applicability_lines(_na_notes)])
|
||||
if _na_problems:
|
||||
_na_blocks.append((fold.MD, '**Declarations that were REFUSED, so their '
|
||||
'rows RAN:**'))
|
||||
_na_blocks.extend([(fold.MD, '**{0}** — {1}'.format(num, text))
|
||||
for (num, text) in _na_problems])
|
||||
_na_blocks.append(
|
||||
(fold.MD, '_A row shown as NOT APPLICABLE was **not run at all**. That '
|
||||
'is not muting (a muted check runs and is repainted grey), not '
|
||||
'scope (which sets aside a whole piece), and not dormancy (a '
|
||||
'check whose enabling knowledge is unset). It is a knowledge '
|
||||
'layer declaring that this question is not about this kind of '
|
||||
'piece. A row that FAILED is never switched off -- the '
|
||||
'declaration is declined and says so. A row scoped to a panel '
|
||||
'type, on a piece nothing could classify, is NOT RUN in red, '
|
||||
'never quiet: nobody could ask it._'))
|
||||
_section('applicability', 'Which checklist ran on each piece, and what it '
|
||||
'did not ask',
|
||||
'{0} row(s) not asked; {1} declaration(s) refused'
|
||||
.format(len(_na_notes), len(_na_problems)),
|
||||
_na_blocks,
|
||||
problem=TP.applicability_problem(_na_notes, _na_problems))
|
||||
|
||||
# -- muted on this job. Folded when the muting is tidy; open when it is not.
|
||||
_all_muted = []
|
||||
_seen_muted = set()
|
||||
for r in results:
|
||||
for note in r['muted_notes']:
|
||||
if note[0] not in _seen_muted:
|
||||
_seen_muted.add(note[0])
|
||||
_all_muted.append(note)
|
||||
if _all_muted:
|
||||
_section('muted', 'Checklist items muted on this job',
|
||||
'{0} item(s): {1}'.format(
|
||||
len(_all_muted), ', '.join([n[0] for n in _all_muted])),
|
||||
[(fold.MD, line) for line in TP.muting_lines(_all_muted)] +
|
||||
[(fold.MD, '_Muting does not remove a row -- it stays on the '
|
||||
'table, repainted grey as MUTED, with this note '
|
||||
'saying what the check really reported. The check '
|
||||
'still runs every time; only the presentation '
|
||||
'changes. It never removes a finding: everything '
|
||||
'below still reaches the machine-readable JSON '
|
||||
'(`qc-run.json`, written every press) and still '
|
||||
'counts in the header line, and a muted item that '
|
||||
'FAILED keeps its own red on the table -- muting '
|
||||
'never softens a failure._')],
|
||||
problem=TP.muting_problem(_all_muted))
|
||||
|
||||
# -- the issue tally. Routine; folded.
|
||||
if tally:
|
||||
_section('tally', 'Issues by code',
|
||||
'{0} code(s) across {1} ticket(s)'
|
||||
.format(len(tally), sum(tally.values())),
|
||||
[(fold.TABLE, ['issue', 'tickets'],
|
||||
[[c, str(n)] for c, n in
|
||||
sorted(tally.items(), key=lambda kv: -kv[1])])])
|
||||
|
||||
# -- what each design is being held to. Configuration: folded, by the rule.
|
||||
seen, intent_rows = [], []
|
||||
for r in results:
|
||||
intent = r['intent']
|
||||
if intent['name'] in seen:
|
||||
continue
|
||||
seen.append(intent['name'])
|
||||
env = intent.get('envelope') or {}
|
||||
|
||||
def bound(key, _env=env):
|
||||
return _env[key][1] if key in _env else '-'
|
||||
|
||||
intent_rows.append([
|
||||
intent['name'], r['spec_source'],
|
||||
Q._fmt_num(intent.get('fc')), Q._fmt_num(intent.get('fci')),
|
||||
Q._fmt_num(intent.get('pcf')),
|
||||
Q.strand_signature(intent.get('strands') or []),
|
||||
intent.get('wwf_mesh') or '-',
|
||||
'{0} .. {1}'.format(bound('MIN_LENGTH'), bound('MAX_LENGTH')),
|
||||
'{0} .. {1}'.format(bound('MIN_WIDTH'), bound('MAX_WIDTH')),
|
||||
])
|
||||
_section('intent', 'Design intent being enforced',
|
||||
'{0} design(s), from {1}'.format(
|
||||
len(intent_rows),
|
||||
', '.join(sorted(set([r[1] for r in intent_rows]))) or '-'),
|
||||
[(fold.TABLE,
|
||||
['design', 'from', "f'c", "f'ci", 'pcf', 'strands', 'W.W.F.',
|
||||
'length', 'width'], intent_rows)])
|
||||
|
||||
# -- scope and knowledge. The context every report carries; folded unless
|
||||
# the scope actually removed or annotated something.
|
||||
if scope_annotation is None:
|
||||
scope_annotation = the_scope.annotation(
|
||||
[str(a.AssemblyTypeName) for a in assemblies
|
||||
if not the_scope.includes(a.AssemblyTypeName)], unit='piece')
|
||||
_scope_blocks = [(fold.MD, '> ' + (scope_line or
|
||||
the_scope.line([], unit='piece')))]
|
||||
if scope_annotation:
|
||||
_scope_blocks.append((fold.MD, '> ' + scope_annotation))
|
||||
for _note in the_scope.declaration_lines():
|
||||
_scope_blocks.append((fold.MD, '_{0}_'.format(_note)))
|
||||
if doc.PathName:
|
||||
_scope_blocks.append((fold.MD, 'Knowledge file: `{0}`'
|
||||
.format(designspec.knowledge_path(doc))))
|
||||
_section('scope', 'Scope and knowledge',
|
||||
'what this run was and was not asked to judge',
|
||||
_scope_blocks,
|
||||
problem=(scope_annotation or None))
|
||||
|
||||
# -- lifting/handling provenance. Only when HANDLE-* actually fired.
|
||||
if any(f.code.startswith('HANDLE-') for r in results for f in r['findings']):
|
||||
_section('handling', 'How the lifting/handling result was obtained',
|
||||
'`HANDLE-*` rows on this run',
|
||||
[(fold.MD,
|
||||
'A current **Panel Lift Check** or sweep result is reused when '
|
||||
'one is on file; otherwise a default-stage check runs on the '
|
||||
'spot, rate limited so this does not hang on a large design. '
|
||||
'`HANDLE-STALE` means a saved result no longer matches the '
|
||||
'piece and was not used. A result not tagged "reviewed Panel '
|
||||
'Lift Check" is good enough to find a problem, not good enough '
|
||||
'to sign -- see docs/lift-check.md.')])
|
||||
|
||||
# ------------------------------- read by a model, quarantined from the above
|
||||
# Its own section, its own columns, and a column that names what read it. A
|
||||
# computed verdict and a model-read one fail differently -- a wrong computed
|
||||
# answer is a bug, a wrong read is a hallucination -- so a reader has to be
|
||||
# able to tell them apart at a glance. They are never in the same table, and
|
||||
# this section opens itself: a model read is never a verdict, so somebody has
|
||||
# to look at it.
|
||||
_read = [(r['ticket']['mark'], f)
|
||||
for r in results for f in (r.get('model_read') or [])]
|
||||
if _read:
|
||||
_head, _read_rows = TV.report_block(_read)
|
||||
_section('modelread', 'Read by a model (never a verdict)',
|
||||
'{0} row(s) a person must judge'.format(len(_read_rows)),
|
||||
[(fold.MD, _head),
|
||||
(fold.TABLE, list(TV.REPORT_COLUMNS), _read_rows)],
|
||||
problem='a model was asked to look at {0} thing(s); nothing '
|
||||
'here is a pass or a fail'.format(len(_read_rows)))
|
||||
elif _vision_on:
|
||||
output.print_md('> The vision escalation was on and produced nothing, '
|
||||
'which should not happen - every escalation reports a '
|
||||
'reason even when it does not run.')
|
||||
|
||||
# ---- THE MACHINE-READABLE COPY. Written on EVERY press, escalation or not.
|
||||
# The same separation as the page, on disk, for anything that reads the run
|
||||
# programmatically: two keys, never one list, and `computed` on every entry
|
||||
# so even a consumer that merges them cannot lose the difference. When no
|
||||
# model was consulted `model_read` is an empty list -- which is itself the
|
||||
# answer to "was one?", and is why this no longer waits for a vision row:
|
||||
# the muted section promises every run's findings reach this file, and
|
||||
# vision defaults off.
|
||||
#
|
||||
# AND THE DISPOSITION, added 2026-08-31. Until then this file held findings
|
||||
# and NOTHING ELSE -- no checklist rows, no mutes, no `run_all` log -- so a
|
||||
# consumer reading it could not tell a row that PASSED from a row NOBODY
|
||||
# ASKED. Both were an absence of findings. That is this project's cardinal
|
||||
# failure reproduced inside its own output, and `tickets` below is the fix:
|
||||
# every checklist row of every piece, with its state and, where it produced
|
||||
# no verdict, its reason verbatim. See the block above `qc_json`.
|
||||
#
|
||||
# NOTHING HERE CHANGES WHAT A HUMAN SEES. The page is already printed by this
|
||||
# point and `last-ticketqc.txt` below is written from the same `r['rows']`
|
||||
# this reads; `qc_json` builds a new document and never touches a row, a
|
||||
# finding or a note.
|
||||
#
|
||||
# NOTE ON MUTING: this is built from `r['findings']`, which muting never
|
||||
# touches. A muted checklist row is a rendering decision; the finding
|
||||
# behind it still reaches this file -- and now the ROW does too, in grey,
|
||||
# saying what the check really reported.
|
||||
try:
|
||||
_doc = TV.qc_json([f for r in results for f in r['findings']],
|
||||
[f for r in results
|
||||
for f in (r.get('model_read') or [])],
|
||||
tickets=results,
|
||||
envelope=Q.run_envelope(TV.RUN_SCHEMA, doc=doc))
|
||||
_path = TV.write_run_json(designspec.root_dir(doc), _doc)
|
||||
output.print_md('_Machine-readable: `{0}`_'.format(_path))
|
||||
except Exception as _ex:
|
||||
output.print_md('_(the JSON copy could not be written: {0})_'
|
||||
.format(type(_ex).__name__))
|
||||
|
||||
# ---- THE EVIDENCE FILE. Migration step 1, docs/architecture-migration.md.
|
||||
#
|
||||
# Everything this press MEASURED, as a versioned JSON document with no live
|
||||
# Revit object in it, beside the model at `_ticketqc/evidence/run-<stamp>.json`.
|
||||
# One file per press, never one per piece.
|
||||
#
|
||||
# NOTHING READS IT. Not this button, not `ticketchecks`, not a runner -- the
|
||||
# reader is step 2. That is why this can ship on its own: a file nobody reads
|
||||
# cannot change what the press above reported. No judgement moved, `read_ticket`
|
||||
# is untouched, and `Q.ticket_to_json` builds a new dict rather than editing
|
||||
# the ticket the checks were run against.
|
||||
#
|
||||
# It carries no findings, deliberately. `qc-run.json` above owns the verdicts;
|
||||
# this file owns the inputs, and keeping the two apart is what will let step 2
|
||||
# re-judge one against the other.
|
||||
#
|
||||
# Wrapped exactly as the JSON copy above is: a failure here prints a line and
|
||||
# loses nothing. The report is already computed by this point.
|
||||
try:
|
||||
_ev_doc = Q.run_evidence(results, doc=doc)
|
||||
_ev_path = Q.write_evidence(
|
||||
designspec.sub_dir(doc, Q.EVIDENCE_FOLDER), _ev_doc)
|
||||
output.print_md('_Evidence (nothing reads it yet): `{0}`_'
|
||||
.format(_ev_path))
|
||||
except Exception as _ex:
|
||||
output.print_md('_(the evidence file could not be written: {0})_'
|
||||
.format(type(_ex).__name__))
|
||||
|
||||
# ---- FOLDED IS NOT GONE. The whole page, nothing folded, every checklist
|
||||
# table in full text with long notes wrapped rather than cut, written to a
|
||||
# file every press. A fold that will not open then costs a file open, never
|
||||
# a lost sentence.
|
||||
full_path = None
|
||||
try:
|
||||
full_path = os.path.join(designspec.root_dir(doc), 'last-ticketqc.txt')
|
||||
_lines = ['SHOP TICKET QC -- ' + str(doc.Title), '']
|
||||
for r in loud + all_green:
|
||||
_t = r['ticket']
|
||||
_lines.append('')
|
||||
_lines.append('{0} [{1}] {2}'.format(
|
||||
_t['mark'], r['design'],
|
||||
TP.checklist_headline(r['counts']).replace('·', '-')))
|
||||
_lines.append('-' * 78)
|
||||
_lines.append(TP.checklist_table_text(r['rows'], r['details']))
|
||||
_lines.append('')
|
||||
_lines.append(fold.text(sections, show_all=True))
|
||||
payload = compat.as_text('\n'.join(_lines))
|
||||
handle = open(full_path, 'wb')
|
||||
try:
|
||||
handle.write(payload.encode('utf-8'))
|
||||
finally:
|
||||
handle.close()
|
||||
except Exception as _exc:
|
||||
full_path = None
|
||||
output.print_md('> The full report could not be written to disk ({0}). '
|
||||
'Everything is still on this page; use the fold headers.'
|
||||
.format(_exc))
|
||||
|
||||
if sections:
|
||||
opened = fold.sections_with_problems(sections)
|
||||
output.print_md('---')
|
||||
if opened:
|
||||
output.print_md('**{0} of the {1} section(s) below opened themselves '
|
||||
'— they have something in them that needs you.** '
|
||||
'The rest are folded; click a header to open one.'
|
||||
.format(len(opened), len(sections)))
|
||||
else:
|
||||
output.print_md('**Nothing below the tables needs you.** Every section '
|
||||
'is folded; click a header to open one.')
|
||||
if full_path:
|
||||
output.print_md('_The whole report, nothing folded, is in_ `{0}`'
|
||||
.format(full_path))
|
||||
output.print_html(fold.html(sections))
|
||||
Reference in New Issue
Block a user