WillTools: precast shop-ticket QC and panel handling tools
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.
This commit is contained in:
2026-09-03 16:33:47 -05:00
commit 52d94303bc
281 changed files with 155656 additions and 0 deletions
@@ -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}** &middot; {1} ticket(s) across {2} design '
'number(s) &middot; {3} clean &middot; {4} failing '
'&middot; {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} &nbsp;&mdash;&nbsp; {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 += ' &nbsp; ' + output.linkify(t['sheet'].Id, 'open ticket')
if t.get('panel_id'):
head += ' &nbsp; ' + 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]),
(' &mdash; 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 ' &mdash; 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}** &mdash; {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('&middot;', '-')))
_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 '
'&mdash; 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))