8e6ee31596
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.
715 lines
36 KiB
Python
715 lines
36 KiB
Python
# -*- 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
|