WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
This commit is contained in:
+8
@@ -0,0 +1,8 @@
|
||||
title: Design Extract
|
||||
# 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.
|
||||
#
|
||||
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
|
||||
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
|
||||
# grows to fit the longest row, so the title may say what it means.
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
+714
@@ -0,0 +1,714 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Turn a design summary into a reviewable spec file, in two chained passes.
|
||||
#
|
||||
# PASS 1, mechanical, in-process: scrapes the summary drafting view and its
|
||||
# sheet, then reads it with ticketqc's regex/geometry parser -- no AI call,
|
||||
# no subprocess, nothing that leaves this process. What the parser could not
|
||||
# place lands verbatim in the spec's 'unresolved' list rather than being
|
||||
# dropped, which is what makes a narrow rule set honest instead of lossy.
|
||||
# Read-only as far as Revit is concerned, except for exporting the summary
|
||||
# view to a PNG for pass 2 (also read-only -- ExportImage writes no model
|
||||
# data).
|
||||
#
|
||||
# PASS 2, AI, out of process (owner, 2026-09-01, Discord): chained
|
||||
# automatically after pass 1 succeeds, `vibe.aiextract.launch` starts a
|
||||
# DETACHED process (`tools/aiextract_run.py`, DSV4F with vision) that fills
|
||||
# whatever pass 1 left null, empty or unresolved, plus the strand matrix
|
||||
# pass 1 never attempts. It can only ADD to the spec pass 1 wrote -- see
|
||||
# that module's docstring for the never-overwrite rule -- and this button
|
||||
# does not wait for it: the spec file is updated in place whenever that
|
||||
# process finishes, or left exactly as pass 1 wrote it if the AI pass fails.
|
||||
#
|
||||
# Every value this button writes is machine-read and UNVERIFIED. Check it
|
||||
# against the drawing before anything downstream (Ticket QC, Panel Lift Check)
|
||||
# is trusted to have used the right numbers.
|
||||
#
|
||||
# THE DECLARED SCOPE, AND WHY THIS BUTTON DOES NOT ENFORCE IT
|
||||
# -----------------------------------------------------------
|
||||
# The specs written here are what Ticket QC's design-intent comparison
|
||||
# enforces, so a design extracted here enters the scoped path. Until
|
||||
# 2026-08-31 this button offered EVERY design in the model with no mention of
|
||||
# the scope at all -- a side door: an out-of-scope design could be extracted,
|
||||
# reach Ticket QC, and be compared against, without ever being labelled.
|
||||
#
|
||||
# It is not a door that should be shut. The engineer may have a reason to
|
||||
# extract somebody else's design -- to read it, to compare against it, to
|
||||
# check a shared edge -- and a picker that hid designs would be exactly the
|
||||
# silent exclusion `vibe.scope` exists to refuse. So nothing is filtered and
|
||||
# nothing is blocked. Instead:
|
||||
#
|
||||
# * the picker row says OUTSIDE THE DECLARED SCOPE, at the moment of
|
||||
# choosing, before anything is written;
|
||||
# * the report carries the scope line every other report carries, and,
|
||||
# when one was actually extracted, the annotation saying the values in
|
||||
# it rest on knowledge nobody validated for that kind of piece;
|
||||
# * the fold opens ITSELF when that happened, by the standing display rule.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
|
||||
# pyRevit reads it only `if not self.meta`. It is kept in step so that
|
||||
# trimming a bundle file cannot bring an old name back. One line, no
|
||||
# `\n`: this button is a row in a `.pulldown`, and a row has no second
|
||||
# line to put one on.
|
||||
__title__ = 'Design Extract'
|
||||
|
||||
import io
|
||||
import os
|
||||
import traceback
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from Autodesk.Revit.DB import (BrowserOrganization, FilteredElementCollector,
|
||||
ViewSheet)
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import designspec, ticketqc, knowledge, specsave
|
||||
from vibe import aiextract, ticketvision
|
||||
from vibe import foldreport as fold
|
||||
from vibe import runlog
|
||||
from vibe import scope as scopelib
|
||||
|
||||
doc = revit.doc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
if not doc.PathName:
|
||||
runlog.record('Design Extract', doc_title=doc.Title,
|
||||
outcome='refused',
|
||||
report_text='Save the model first - the spec files live '
|
||||
'beside it.')
|
||||
forms.alert('Save the model first - the spec files live beside it.',
|
||||
exitscript=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- main
|
||||
# The main body is wrapped so an unexpected exception still leaves a run-log
|
||||
# record (outcome='error') before pyRevit shows its own error dialog.
|
||||
try:
|
||||
|
||||
# --------------------------------------------------------------- pick work
|
||||
know = knowledge.load(doc)
|
||||
grouped = ticketqc.assemblies_by_design(doc, know=know)
|
||||
existing = designspec.list_specs(doc)
|
||||
|
||||
DESIGN_FOLDER = knowledge.get(know, 'nomenclature.design_folder', '') or ''
|
||||
IGNORE = knowledge.get(know, 'nomenclature.ignore_sheet_prefixes', []) or []
|
||||
|
||||
# The declared scope, off the job knowledge, read the same way Job Recon,
|
||||
# Takeoff and Ticket QC read it. Used to LABEL, never to filter -- see the
|
||||
# note at the top of this file.
|
||||
the_scope = scopelib.of(know)
|
||||
|
||||
|
||||
def out_of_scope(design):
|
||||
"""True when this design number is declared to be somebody else's.
|
||||
|
||||
Through `as_mark`, so a design key that does not read as a mark at all
|
||||
is 'could not tell' and is never labelled as another engineer's.
|
||||
"""
|
||||
return not the_scope.includes(scopelib.as_mark(design))
|
||||
|
||||
|
||||
def sheets_by_folder():
|
||||
"""{'FOLDER / SUB': [ViewSheet, ...]} read from the project browser's own
|
||||
organization, so this picker is grouped the way the browser tree is.
|
||||
|
||||
A flat list of every drafting view is unusable on a real job -- there are
|
||||
hundreds, and their names are not the design numbers anyway.
|
||||
"""
|
||||
try:
|
||||
org = BrowserOrganization.GetCurrentBrowserOrganizationForSheets(doc)
|
||||
except Exception:
|
||||
org = None
|
||||
|
||||
out = {}
|
||||
for sheet in (FilteredElementCollector(doc).OfClass(ViewSheet)
|
||||
.WhereElementIsNotElementType().ToElements()):
|
||||
if sheet.IsTemplate:
|
||||
continue
|
||||
number = (sheet.SheetNumber or '').strip()
|
||||
if any(number.upper().startswith(str(p).upper()) for p in IGNORE):
|
||||
continue
|
||||
folder = ''
|
||||
if org is not None:
|
||||
try:
|
||||
folder = ' / '.join(
|
||||
[item.Name for item in org.GetFolderItems(sheet.Id)])
|
||||
except Exception:
|
||||
folder = ''
|
||||
out.setdefault(folder or '(no folder)', []).append(sheet)
|
||||
return out
|
||||
|
||||
|
||||
def packets(sheets):
|
||||
"""{design key: [ViewSheet, ...]} in page order.
|
||||
|
||||
One row per design packet, not per sheet: PNL1.0 / PNL1.A / PNL1.B are
|
||||
pages of design PNL1, and extracting any of them means extracting the
|
||||
packet.
|
||||
"""
|
||||
out = {}
|
||||
for sheet in sheets:
|
||||
key = designspec.design_key(sheet.SheetNumber, know)
|
||||
if key:
|
||||
out.setdefault(key, []).append(sheet)
|
||||
for key in out:
|
||||
out[key].sort(key=lambda s: (s.SheetNumber or ''))
|
||||
return out
|
||||
|
||||
|
||||
DESIGNS = {} # row label -> design key, so nothing has to be split
|
||||
|
||||
|
||||
def row(design, sheets=None):
|
||||
bits = []
|
||||
if sheets:
|
||||
cover = sheets[0]
|
||||
bits.append(cover.Name)
|
||||
if len(sheets) > 1:
|
||||
bits.append('{0} sheets ({1})'.format(
|
||||
len(sheets), ', '.join([s.SheetNumber for s in sheets[:6]])))
|
||||
else:
|
||||
bits.append('sheet {0}'.format(cover.SheetNumber))
|
||||
bits.append('{0} pieces'.format(len(grouped[design])) if design in grouped
|
||||
else 'NO pieces')
|
||||
if design in existing:
|
||||
bits.append('spec exists - will be overwritten')
|
||||
# At the point of choosing, and in capitals: this is the one moment the
|
||||
# engineer can decide he did not mean to extract somebody else's design.
|
||||
# It never removes the row.
|
||||
if out_of_scope(design):
|
||||
bits.append('OUTSIDE THE DECLARED SCOPE')
|
||||
label = '{0} - {1}'.format(design, ', '.join(bits))
|
||||
DESIGNS[label] = design
|
||||
return label
|
||||
|
||||
|
||||
groups = OrderedDict()
|
||||
|
||||
by_folder = sheets_by_folder()
|
||||
for folder in sorted(by_folder):
|
||||
found = packets(by_folder[folder])
|
||||
if found:
|
||||
groups['Sheets / ' + folder] = [row(key, found[key])
|
||||
for key in sorted(found)]
|
||||
|
||||
# Designs the pieces claim that no sheet in the design folder accounts for.
|
||||
# These would silently go unchecked, so they get their own group rather than
|
||||
# being left off the list.
|
||||
accounted = set()
|
||||
for folder in by_folder:
|
||||
if not DESIGN_FOLDER or DESIGN_FOLDER.upper() in folder.upper():
|
||||
accounted.update(packets(by_folder[folder]).keys())
|
||||
orphans = [d for d in sorted(grouped) if d not in accounted]
|
||||
if orphans:
|
||||
groups['Pieces with no design sheet'] = [row(d) for d in orphans]
|
||||
|
||||
if existing:
|
||||
leftover = [row(d) for d in sorted(existing) if d not in DESIGNS.values()]
|
||||
if leftover:
|
||||
groups['Specs already on disk'] = leftover
|
||||
|
||||
if not groups:
|
||||
runlog.record('Design Extract', doc_title=doc.Title, outcome='refused',
|
||||
report_text='No design numbers found in this model.')
|
||||
forms.alert('No design numbers found in this model.', exitscript=True)
|
||||
|
||||
kwargs = {
|
||||
'title': 'Extract which design summaries?',
|
||||
'group_selector_title': 'Browser folder',
|
||||
'multiselect': True,
|
||||
'button_name': 'Extract',
|
||||
'width': 900,
|
||||
'height': 650,
|
||||
}
|
||||
preferred = 'Sheets / ' + DESIGN_FOLDER
|
||||
if DESIGN_FOLDER and preferred in groups:
|
||||
kwargs['default_group'] = preferred
|
||||
|
||||
picked = forms.SelectFromList.show(groups, **kwargs)
|
||||
if not picked:
|
||||
runlog.record('Design Extract', doc_title=doc.Title, outcome='refused',
|
||||
report_text='Design picker closed -- no design chosen.')
|
||||
script.exit()
|
||||
designs = sorted(set([DESIGNS.get(p, p.split(' ')[0]) for p in picked]))
|
||||
|
||||
# What was OFFERED and left unpicked is not the scope's business -- the
|
||||
# engineer chose. What matters is what he DID pick that the declaration says
|
||||
# is somebody else's, because those specs are about to feed Ticket QC.
|
||||
picked_out = [d for d in designs if out_of_scope(d)]
|
||||
scope_line = the_scope.line(picked_out, total=len(designs), unit='design')
|
||||
scope_annotation = the_scope.annotation(picked_out, unit='design')
|
||||
|
||||
# ------------------------------------------------------------- gather + go
|
||||
output.print_md('# Extract design summaries')
|
||||
output.print_md('Model **{0}**'.format(doc.Title))
|
||||
output.print_md('Data folder: `{0}`'.format(designspec.root_dir(doc)))
|
||||
output.print_md(
|
||||
'**Every value below is machine-read and unverified.** A regex either '
|
||||
'matched a rule or it did not -- there is no judgement in between. What '
|
||||
"matched is filled in; every line of the summary that didn't is listed "
|
||||
'verbatim in the spec\'s `unresolved` section instead of being silently '
|
||||
'dropped -- and, from this press on, on this page as well. Read those, '
|
||||
'then check the spec in `{0}` against the drawing before Ticket QC or '
|
||||
'Panel Lift Check are trusted to have used the right numbers.'
|
||||
.format(designspec.sub_dir(doc, 'specs')))
|
||||
|
||||
# ---- the declared scope. Not folded, and above the results rather than
|
||||
# below them, because a spec is written to disk by the time this page exists
|
||||
# -- the reader is being told what he already has, and Ticket QC will pick it
|
||||
# up whether or not he scrolls.
|
||||
output.print_md('> ' + scope_line)
|
||||
if scope_annotation:
|
||||
output.print_md('> **' + scope_annotation + '**')
|
||||
for _note in the_scope.declaration_lines():
|
||||
output.print_md('_{0}_'.format(_note))
|
||||
|
||||
def _per_plant_layer_exists():
|
||||
"""True when this machine already has at least one per-plant folder.
|
||||
|
||||
Answers a different question from `knowledge.plant_id(doc)`: that asks
|
||||
which plant THIS job names; this asks whether the per-plant migration
|
||||
has already happened on THIS MACHINE at all, which is what decides
|
||||
whether the legacy machine-wide path is still a safe place to drop a
|
||||
starter.
|
||||
|
||||
A machine nobody has ever set up has no `<root>/<anything>/knowledge.yaml`
|
||||
-- writing the machine-wide starter there is exactly what this button has
|
||||
always done, and it is still right: a fresh install needs something to
|
||||
edit. But once a machine has even one real per-plant folder, the
|
||||
machine-wide file at `<root>/knowledge.yaml` is what
|
||||
`plant_knowledge_path(None)` hands back as the NO-PLANT FALLBACK
|
||||
(`plantpath.py`), so writing a blank template there would give a job
|
||||
that simply forgot to set `job.plant` a silent, empty plant layer
|
||||
instead of a loud "no plant declared." That is the exact trap this fix
|
||||
exists to close (QNA/seam-review-2026-08-31.md section 2), so on a
|
||||
migrated machine the safe move is to write nothing and say so.
|
||||
"""
|
||||
root = designspec.plant_root()
|
||||
try:
|
||||
names = os.listdir(root)
|
||||
except Exception:
|
||||
return False
|
||||
for name in names:
|
||||
if os.path.isfile(os.path.join(root, name, 'knowledge.yaml')):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# `write_plant_starter()` with no plant argument always targets the
|
||||
# machine-wide `<root>/knowledge.yaml` -- the exact file
|
||||
# `plant_knowledge_path(None)` returns as the fallback for any job that
|
||||
# omits `job.plant`. Calling it unconditionally, as this button used to,
|
||||
# recreated a blank template there on every press, which any no-plant job
|
||||
# would then silently read as its entire plant layer. `knowledge.plant_id`
|
||||
# is the pre-pass `knowledge.load()` itself uses to choose the plant layer
|
||||
# before the ordinary merge runs (see its docstring), so it is read the
|
||||
# same way here rather than re-derived.
|
||||
plant = knowledge.plant_id(doc)
|
||||
|
||||
if plant:
|
||||
# A named plant gets its own folder. `write_plant_starter` already
|
||||
# refuses to overwrite a file that exists (knowledge.py:6601 --
|
||||
# `if os.path.isfile(path) and not overwrite: return path, False`), so
|
||||
# calling it on every press is safe: it only ever fills a gap, never
|
||||
# clobbers a real plant layer.
|
||||
ppath, pcreated = knowledge.write_plant_starter(plant=plant)
|
||||
output.print_md('Plant standards: `{0}`{1}'.format(
|
||||
ppath, ' (just created - edit it)' if pcreated else ''))
|
||||
elif _per_plant_layer_exists():
|
||||
# No plant named, but this machine has already been migrated to
|
||||
# per-plant folders -- writing at the legacy path would recreate the
|
||||
# exact decoy this fix removes. Report the gap instead of filling it
|
||||
# with a blank file that looks like an answer.
|
||||
output.print_md(
|
||||
'Plant standards: **no `job.plant` set**, and this machine already '
|
||||
'has per-plant folders under `{0}` -- nothing written at the '
|
||||
'legacy machine-wide path, because that path is what a no-plant '
|
||||
'job reads as its plant layer. Set `job.plant` in this job\'s '
|
||||
'knowledge file.'.format(designspec.plant_root()))
|
||||
else:
|
||||
# A genuinely fresh machine: no per-plant folder exists anywhere yet,
|
||||
# so the machine-wide file is still the only plant layer this install
|
||||
# could possibly mean, exactly as it was before per-plant folders
|
||||
# existed. Safe to seed, and `write_plant_starter` still refuses to
|
||||
# overwrite if one is somehow already there.
|
||||
ppath, pcreated = knowledge.write_plant_starter()
|
||||
output.print_md('Plant standards: `{0}`{1}'.format(
|
||||
ppath, ' (just created - edit it)' if pcreated else ''))
|
||||
|
||||
kpath, created = knowledge.write_starter(doc, plant=plant)
|
||||
if created:
|
||||
output.print_md('Wrote a starter knowledge file: `{0}`'.format(kpath))
|
||||
|
||||
written, skipped = [], []
|
||||
for design in designs:
|
||||
try:
|
||||
evidence = designspec.gather_evidence(doc, design, know=know)
|
||||
except Exception as ex:
|
||||
skipped.append((design, '{0}: {1}'.format(type(ex).__name__, ex)))
|
||||
continue
|
||||
|
||||
if not evidence.get('summary_views'):
|
||||
skipped.append((design, 'no sheet in "{0}" numbers to {1}, or its '
|
||||
'sheets carry no views'.format(
|
||||
DESIGN_FOLDER or 'any folder', design)))
|
||||
continue
|
||||
designspec.write_json(designspec.evidence_path(doc, design), evidence)
|
||||
notes = sum([len(v.get('text_notes') or [])
|
||||
for v in evidence.get('summary_views') or []])
|
||||
|
||||
try:
|
||||
spec = designspec.build_spec(doc, design, know=know, evidence=evidence)
|
||||
if spec is None:
|
||||
skipped.append((design, 'no summary view to read'))
|
||||
continue
|
||||
path = designspec.save_spec(doc, design, spec)
|
||||
# The spec itself is kept, not just its counts: what is unresolved is
|
||||
# listed by name below, and the copy the engineer asks for is written
|
||||
# from the file this returned.
|
||||
written.append({'design': design, 'notes': notes, 'path': path,
|
||||
'spec': spec})
|
||||
except Exception as ex:
|
||||
skipped.append((design, '{0}: {1}'.format(type(ex).__name__, ex)))
|
||||
continue
|
||||
|
||||
# ----------------------------------------------- the AI pass, chained on
|
||||
#
|
||||
# Owner, 2026-09-01 (Discord), approving the two-stage-extraction
|
||||
# proposal: the mechanical reader above always runs first and never
|
||||
# guesses; whatever it left null, empty or unresolved is this second
|
||||
# pass's job, on DSV4F WITH VISION, never opus ("可以,不过别用opus,用
|
||||
# dsv4f带vision吧"). The strand matrix is one such field, not a
|
||||
# standing exception any more -- `build_spec` now reads it from
|
||||
# geometry (the drawn strand symbols against the dimension chain) and
|
||||
# takes it only when every identity check on that reading passes; the
|
||||
# AI pass picks it up here exactly when the geometry read refused, the
|
||||
# same as any other blank left by the mechanical reader. It runs
|
||||
# detached (`vibe.aiextract.launch`), the same
|
||||
# way the AI reader always has, so this button returns immediately --
|
||||
# the spec file above is updated IN PLACE whenever that process
|
||||
# finishes, or is left exactly as written above (plus one `unresolved`
|
||||
# line saying so) if the AI pass fails or times out. Nothing here waits
|
||||
# for it and nothing here re-reads the spec after kicking it off.
|
||||
#
|
||||
# Rendering the summary view to an image is Revit API work, done here
|
||||
# rather than inside `aiextract` (which otherwise has no reason to touch
|
||||
# a live View) -- OWNER MUST VERIFY: exercised only against a live Revit
|
||||
# session, not tonight.
|
||||
ai_image, ai_error = None, None
|
||||
try:
|
||||
ai_views, _ai_sheets = designspec.find_summary_views(doc, design,
|
||||
know=know)
|
||||
if ai_views:
|
||||
ai_image = ticketvision.render_view_png(
|
||||
doc, ai_views[0].Id, designspec.sub_dir(doc, 'log'))
|
||||
except Exception as ex:
|
||||
ai_error = ('could not render the summary view to an image ({0}: '
|
||||
'{1}) -- the AI pass will run text-only, and the strand '
|
||||
'matrix will very likely stay blank'
|
||||
.format(type(ex).__name__, ex))
|
||||
|
||||
try:
|
||||
aiextract.launch(doc, design, know=know,
|
||||
image_paths=[ai_image] if ai_image else [])
|
||||
written[-1]['ai_launched'] = True
|
||||
except Exception as ex:
|
||||
written[-1]['ai_launched'] = False
|
||||
started = ('the AI pass itself could not be started ({0}: {1})'
|
||||
.format(type(ex).__name__, ex))
|
||||
ai_error = (ai_error + '; ' + started) if ai_error else started
|
||||
written[-1]['ai_image'] = ai_image
|
||||
written[-1]['ai_error'] = ai_error
|
||||
|
||||
# ------------------------------------------------------- where the copy goes
|
||||
#
|
||||
# Asked AFTER the extraction has run and BEFORE any of it is shown. Owner,
|
||||
# 2026-08-30: "跑完extractdesign,出结果以前,让用户选择存designextraction文件的
|
||||
# 路径。用户也可以打开看到底是什么东西unresolved。"
|
||||
#
|
||||
# A COPY, NOT A MOVE. The spec is already written where the rest of the tool
|
||||
# looks for it: `designspec.list_specs` reads `_ticketqc/specs/` and nothing
|
||||
# else, so a spec saved only into a folder the engineer picked would look
|
||||
# extracted and be invisible to Ticket QC and Panel Lift Check. What this
|
||||
# dialog chooses is a SECOND copy -- to keep, to send, or just to open and
|
||||
# read.
|
||||
#
|
||||
# CANCELLING KEEPS THE WORK. PanelLiftCheck asks the same question and exits
|
||||
# on cancel, which is right there: nothing has been calculated yet, so a
|
||||
# cancel costs nothing. Here the extraction has already run and the files are
|
||||
# already on disk, so exiting would throw away work that only another press
|
||||
# could get back. Cancel therefore means "no second copy" and nothing else --
|
||||
# the results still print, pointing at the canonical file.
|
||||
copies = {}
|
||||
copy_problems = []
|
||||
in_place = []
|
||||
cancelled_save = False
|
||||
|
||||
if written:
|
||||
designs_written = [w['design'] for w in written]
|
||||
# Seeded with wherever the last extraction was saved; the job's data
|
||||
# folder when there is no such memory. Both are real places on this
|
||||
# machine -- a dialog opening on a path that does not exist is how a file
|
||||
# lands somewhere nobody meant. NOT the `specs` folder itself: the
|
||||
# default name is the name the canonical file already has, so opening
|
||||
# there would greet a single-design run with "PNL1.yaml already exists,
|
||||
# replace it?" -- a question about the file it just wrote.
|
||||
start_dir = (specsave.recall_dir(designspec.root_dir(doc))
|
||||
or designspec.root_dir(doc))
|
||||
chosen = forms.save_file(
|
||||
file_ext='yaml', init_dir=start_dir or '',
|
||||
default_name=specsave.default_name(designs_written),
|
||||
title='Save the design extraction as')
|
||||
if not chosen:
|
||||
cancelled_save = True
|
||||
else:
|
||||
by_design = dict([(w['design'], w['path']) for w in written])
|
||||
for design, target in specsave.targets(chosen, designs_written):
|
||||
# Saving onto the file itself. The dialog opens on the folder the
|
||||
# specs already live in, so this is an ordinary thing to do by
|
||||
# accident -- and rewriting a file with its own contents is at
|
||||
# best pointless. Say so instead of claiming a copy that is the
|
||||
# same path twice.
|
||||
if specsave.same_file(by_design[design], target):
|
||||
in_place.append(design)
|
||||
continue
|
||||
try:
|
||||
# Copied as text rather than re-rendered from the spec, so the
|
||||
# copy is character-for-character the file Ticket QC reads. A
|
||||
# second rendering could drift from the first and the two
|
||||
# would look like the same extraction.
|
||||
with io.open(by_design[design], 'r', encoding='utf-8') as fh:
|
||||
body = fh.read()
|
||||
with io.open(target, 'w', encoding='utf-8') as fh:
|
||||
fh.write(body)
|
||||
copies[design] = target
|
||||
except Exception as ex:
|
||||
copy_problems.append('{0}: {1}'.format(design, ex))
|
||||
specsave.remember_dir(designspec.root_dir(doc),
|
||||
os.path.dirname(chosen))
|
||||
|
||||
if written:
|
||||
output.print_md('## Written')
|
||||
output.print_table(
|
||||
[[w['design'], str(w['notes']),
|
||||
str(len(specsave.unresolved_entries(w['spec']))),
|
||||
os.path.basename(w['path'])] for w in written],
|
||||
columns=['design', 'text notes scraped', 'unresolved notes',
|
||||
'spec file'])
|
||||
|
||||
# THE LINKS ARE `file:///` HREFS. A bare Windows path in a markdown link
|
||||
# is a dead link in this panel -- backslashes and no scheme, so it renders
|
||||
# and navigates nowhere. Same fix as PanelLiftCheck: the visible text
|
||||
# stays the real path, because that is what gets copied into Explorer,
|
||||
# and only the href gets the URL form. `specsave.link` is that pattern.
|
||||
for w in written:
|
||||
line = '- **{0}** {1}'.format(w['design'],
|
||||
specsave.link(w['path']))
|
||||
if w['design'] in copies:
|
||||
line += ' — copy: {0}'.format(
|
||||
specsave.link(copies[w['design']]))
|
||||
output.print_md(line)
|
||||
|
||||
if cancelled_save:
|
||||
output.print_md(
|
||||
'> No second copy was saved -- the save dialog was cancelled. '
|
||||
'Nothing was lost: every spec above is on disk at the path shown, '
|
||||
'which is where Ticket QC reads it from.')
|
||||
if in_place:
|
||||
output.print_md(
|
||||
'> {0}: you picked the file the spec is already saved as, so '
|
||||
'nothing further was written. The path above is it.'
|
||||
.format(', '.join(in_place)))
|
||||
if len(written) > 1 and copies:
|
||||
output.print_md(
|
||||
'> More than one design was extracted, so the FOLDER you chose '
|
||||
'was used and each spec kept its own design number as its file '
|
||||
'name -- that name is how everything downstream finds it.')
|
||||
for problem in copy_problems:
|
||||
output.print_md('> The copy could not be written — {0}'
|
||||
.format(problem))
|
||||
|
||||
output.print_md(
|
||||
'Then check the fields the reader did fill against the drawing. '
|
||||
'Ticket QC picks specs up automatically, from the paths above.')
|
||||
|
||||
# --------------------------------------------------------- the AI pass
|
||||
output.print_md('---')
|
||||
output.print_md(
|
||||
'## AI pass (DSV4F, vision) -- chained automatically\n\n'
|
||||
'For each design above, a second, detached pass just launched: it '
|
||||
'reads whatever the mechanical reader left null, empty or '
|
||||
'unresolved, plus the strand matrix the mechanical reader never '
|
||||
'even attempts. It runs on DSV4F with vision, never opus (owner\'s '
|
||||
'ruling, 2026-09-01) -- and it can only ADD to the spec: a field '
|
||||
'the mechanical reader above already filled is never touched. It '
|
||||
'writes back to the SAME spec file above, in place, whenever it '
|
||||
'finishes; if it fails or times out the file is left exactly as '
|
||||
'the mechanical reader wrote it, with one line in `unresolved` '
|
||||
'saying so.')
|
||||
for w in written:
|
||||
bits = ['launched' if w.get('ai_launched') else 'NOT launched']
|
||||
if w.get('ai_image'):
|
||||
bits.append('drawing image rendered')
|
||||
else:
|
||||
bits.append('no drawing image -- text-only pass')
|
||||
output.print_md('- **{0}** — {1}'.format(w['design'],
|
||||
', '.join(bits)))
|
||||
if w.get('ai_error'):
|
||||
output.print_md(' > {0}'.format(w['ai_error']))
|
||||
output.print_md(
|
||||
'**Every value in these spec files remains machine-read and '
|
||||
'unverified** -- the AI pass does not change that, it only changes '
|
||||
'who read which line. Re-open the spec after the AI pass\'s window '
|
||||
'closes and check it against the drawing the same as above, '
|
||||
'especially the strand matrix and anything now stamped '
|
||||
'`dsv4f-vision` in `source.extractor`.')
|
||||
|
||||
# ------------------------------------------- what is unresolved, by name
|
||||
#
|
||||
# "用户也可以打开看到底是什么东西unresolved" -- so the notes themselves are
|
||||
# on the page, verbatim, not a number in a column. A count tells a reader
|
||||
# there is something to look at and nothing about whether to look now.
|
||||
#
|
||||
# Folded by the standing display rule (`vibe.foldreport`, set 2026-08-30):
|
||||
# everything below the answer starts closed, and a section carrying a
|
||||
# problem opens ITSELF. Unresolved notes are exactly that case, so a
|
||||
# design with any opens on its own; a design with none stays a one-line
|
||||
# header. The contract that buys is: if nothing here is open, nothing on
|
||||
# this page needs you.
|
||||
sections = []
|
||||
for w in written:
|
||||
entries = specsave.unresolved_entries(w['spec'])
|
||||
blanks = specsave.blank_fields(w['spec'])
|
||||
problems = designspec.validate_spec(w['spec'])
|
||||
section = fold.Section(
|
||||
key='unresolved-' + w['design'],
|
||||
# A plain dash. `foldreport._section_html` now restores the
|
||||
# reports' entities in a title the way it always did for a table
|
||||
# cell or a markdown block (fixed 2026-08-30, after this note was
|
||||
# written), so `—` would render here too -- but a plain dash
|
||||
# is right in the panel AND in the .md, so it stays.
|
||||
title='{0} - what the reader could not place'.format(w['design']),
|
||||
summary=specsave.summary_line(entries, blanks),
|
||||
problem=specsave.problem_sentence(entries, problems))
|
||||
reason = specsave.shared_reason(entries)
|
||||
if entries:
|
||||
# The mechanical reader gives every unplaced note the same reason.
|
||||
# Printed once it is an explanation; printed on all fourteen lines
|
||||
# it is what stops the list being read.
|
||||
if reason:
|
||||
section.md('_{0}_'.format(reason))
|
||||
for i, entry in enumerate(entries):
|
||||
text, why = entry
|
||||
line = '**{0}.** {1}'.format(i + 1, text)
|
||||
if why and not reason:
|
||||
line += ' — {0}'.format(why)
|
||||
section.md(line)
|
||||
else:
|
||||
section.md('Every text note on this design summary matched a '
|
||||
'rule. Nothing was dropped and nothing was left over.')
|
||||
if blanks:
|
||||
section.md(
|
||||
'**Still blank** — the summary filled none of these, '
|
||||
'and a check that reads one of them does not run: {0}'
|
||||
.format(', '.join(['`' + b + '`' for b in blanks])))
|
||||
if problems:
|
||||
section.md('**Validation:** ' + '; '.join(problems))
|
||||
# PLAIN PATH INSIDE THE FOLD, NOT A MARKDOWN LINK. `foldreport.md_html`
|
||||
# converts bold, code and italic and nothing else -- a markdown link
|
||||
# written here reaches the panel as the literal text `[path](file:///
|
||||
# ...)`, which is worse than no link at all. Measured on stubs,
|
||||
# 2026-08-30. The CLICKABLE link is in the list above, printed through
|
||||
# pyRevit's own markdown, which does render one.
|
||||
section.md('The spec: `{0}` (clickable in the list above)'
|
||||
.format(w['path']))
|
||||
if w['design'] in copies:
|
||||
section.md('Your copy: `{0}`'.format(copies[w['design']]))
|
||||
sections.append(section)
|
||||
|
||||
output.add_style(fold.STYLE)
|
||||
output.inject_script(fold.SCRIPT)
|
||||
opened = fold.sections_with_problems(sections)
|
||||
output.print_md('---')
|
||||
if opened:
|
||||
output.print_md(
|
||||
'**{0} of the {1} design(s) below opened themselves** — '
|
||||
'they have notes the reader could not place. The rest are '
|
||||
'folded; click a header to open one.'
|
||||
.format(len(opened), len(sections)))
|
||||
else:
|
||||
output.print_md(
|
||||
'**Nothing below needs you.** Every note on every summary matched '
|
||||
'a rule. The sections are folded; click a header to open one.')
|
||||
output.print_html(fold.html(sections))
|
||||
|
||||
if skipped:
|
||||
output.print_md('## Skipped')
|
||||
for design, why in skipped:
|
||||
output.print_md('- **{0}** - {1}'.format(design, why))
|
||||
|
||||
# ---------------------------------------------- review specs already on disk
|
||||
if existing:
|
||||
output.print_md('## Specs already on disk')
|
||||
rows = []
|
||||
for design in sorted(existing):
|
||||
spec = existing[design]
|
||||
problems = designspec.validate_spec(spec)
|
||||
row_cells = [design,
|
||||
spec.get('source', {}).get('extractor') or '?',
|
||||
spec.get('source', {}).get('reviewed_by') or 'NOT reviewed',
|
||||
'ok' if not problems else '; '.join(problems[:2]),
|
||||
str(len(spec.get('unresolved') or []))]
|
||||
# A spec on disk feeds Ticket QC whether this press wrote it or an
|
||||
# earlier one did, so the label belongs on the whole folder, not
|
||||
# only on what was extracted just now.
|
||||
if the_scope.is_declared:
|
||||
row_cells.append('OUT OF SCOPE' if out_of_scope(design) else '-')
|
||||
rows.append(row_cells)
|
||||
columns = ['design', 'extractor', 'reviewed by', 'validation',
|
||||
'unresolved notes']
|
||||
if the_scope.is_declared:
|
||||
columns.append('scope')
|
||||
output.print_table(rows, columns=columns)
|
||||
if the_scope.is_declared:
|
||||
output.print_md(
|
||||
'`scope` marks a spec whose design number falls outside {0}. '
|
||||
'Nothing is hidden and nothing is deleted -- Ticket QC will use '
|
||||
'these specs if it is pointed at those pieces, and the label is '
|
||||
'so that it is a decision rather than an accident.'
|
||||
.format(the_scope.label()))
|
||||
output.print_md(
|
||||
'`reviewed by` stays NOT reviewed until a person sets '
|
||||
'`source.reviewed_by` in the spec file by hand, which is the only '
|
||||
'thing that ever marks a spec as checked.')
|
||||
output.print_md(
|
||||
'`extractor` says which reader wrote the file: `regex` is the '
|
||||
'mechanical reader this button runs, `dsv4f-vision` is the second, '
|
||||
'AI pass this button now chains after it (`tools/aiextract_run.py`, '
|
||||
'DSV4F with vision -- owner\'s ruling, 2026-09-01), and `opus` / '
|
||||
'`claude-code` are what an earlier AI reader stamped before that '
|
||||
'ruling -- older files, read the same way. Nothing branches on the '
|
||||
'value; it is shown so a spec written by one reader is not mistaken '
|
||||
'for a spec written by another.')
|
||||
|
||||
# The extraction ran and everything is on the page. The record is 'ok'
|
||||
# with a short summary; record() never raises, so a logging failure cannot
|
||||
# turn a finished run into an error.
|
||||
runlog.record('Design Extract', doc_title=doc.Title, outcome='ok',
|
||||
report_text=('{0} design(s) written, {1} skipped; saved '
|
||||
'copy: {2}'
|
||||
.format(len(written), len(skipped),
|
||||
chosen if not cancelled_save else 'none')))
|
||||
|
||||
except Exception as e:
|
||||
# The deliberate exits above never reach here (script.exit() raises
|
||||
# SystemExit, which Exception does not catch) -- this is the
|
||||
# unexpected-exception path, and pyRevit shows its own dialog after
|
||||
# the record is written.
|
||||
runlog.record('Design Extract', doc_title=doc.Title,
|
||||
outcome='error', error=str(e),
|
||||
traceback_text=traceback.format_exc())
|
||||
raise
|
||||
Reference in New Issue
Block a user