Files
willtools-demo/WillTools.extension/lib/vibe/aiextract.py
T
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:32:15 -05:00

317 lines
14 KiB
Python

# -*- coding: utf-8 -*-
"""Launch the second, AI extraction pass over a scraped design summary.
OWNER'S RULING, 2026-09-01 (Discord), approving the two-stage-extraction
proposal: "可以,不过别用opus,用dsv4f带vision吧" -- yes, but not opus, use
DSV4F with vision. That REPLACES the opus-via-Claude-Code-CLI path this
module used to launch. `source.extractor` is now stamped 'dsv4f-vision' by
the new pass (see `tools/aiextract_run.py`, which does the stamping -- this
module only launches it); specs written under the earlier rulings still say
'opus' or, before the model was named at all, 'claude-code'. Nothing reads
that field to make a decision (see `ExtractDesign/script.py` and
`specformat._SOURCE_NOTE`), so a reader only ever has to tolerate whichever
of the four values it finds, never branch on it.
WHERE THE OLD OPUS-CLI PATH WENT, AND WHY IT WAS DELETED RATHER THAN KEPT
BEHIND A FLAG
--------------------------------------------------------------------------
The old `PROMPT` / `build_prompt()` / `launch()` here spawned the Claude
Code CLI (`claude -p --model opus`) with a prompt written against an OLDER
spec shape -- it still asked for `spacings_in` / `positions_in` /
`strands_per_row` under `reinforcing.strand_layout`, three fields that
stopped existing the day the spec grew its `columns_in` / `rows` matrix
shape (commit `73fdd9a`, "spec 改成人类可读 YAML + 矩阵 + 去掉 AI") -- because
once the mechanical reader stopped calling this module, nothing ever ran
that prompt again to notice it had drifted. A dead code path that LOOKS
live is worse than an absent one: keeping it "behind a flag" would mean
shipping a second, silently-wrong extraction contract that only gets
exercised the day somebody flips the flag back on, which is exactly the
kind of quiet failure this codebase's other rules (see CLAUDE.md's
"'Could not judge' and 'judged, found nothing' are different answers")
exist to prevent. It is deleted outright. `find_claude()` (below) is
UNRELATED to that decision and stays: Self Check and the GOD_MOD button
both still use it to report whether the Claude Code CLI is installed on
this machine, independent of anything this module extracts.
The AI pass runs as a separate detached CPython process, same as the old
one did. That is still deliberate, for the same two reasons:
* a pushbutton script runs on Revit's UI thread, and a model call taking
a couple of minutes would freeze Revit for a couple of minutes;
* the extraction touches no Revit API at all -- it reads JSON and PNG
files and writes a spec file -- so there is no reason for it to run
inside Revit. `tools/aiextract_run.py` is plain CPython (dsh is talked
to over local HTTP, and IronPython 2.7 has no HTTP client worth
trusting with that).
THE NEVER-OVERWRITE CONTRACT lives in `aiextract_run.apply_ai_fill`, not
here: this module's whole job is gathering paths and starting that process.
See that module's docstring for the merge rules and for the DSV4F pricing
note (off-peak is the owner's own daytime use of the harness; a button
press is inherently daytime, so this never competes with that window).
IronPython 2.7: no f-strings, no pathlib.
"""
# pylint: disable=import-error,invalid-name,broad-except
import os
import io
from vibe import designspec
# The CLI is installed per-user and lands in a different place depending on how
# it was installed, so it is looked up rather than assumed. Set the
# CLAUDE_CODE_EXE environment variable to override on an unusual machine.
#
# UNRELATED TO THE AI EXTRACTION PASS below -- see the module docstring.
# `find_claude()` only ever answers "is the Claude Code CLI installed here",
# for Self Check and GOD_MOD.
_CLAUDE_NAMES = ('claude.exe', 'claude.cmd', 'claude.bat', 'claude')
_CLAUDE_DIRS = (
os.path.join(os.path.expanduser('~'), '.local', 'bin'),
os.path.join(os.environ.get('APPDATA', ''), 'npm'),
os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Programs', 'claude'),
os.path.join(os.path.expanduser('~'), 'AppData', 'Roaming', 'npm'),
)
def find_claude():
"""Absolute path to the Claude Code CLI, or None when it is not installed."""
override = os.environ.get('CLAUDE_CODE_EXE')
if override and os.path.isfile(override):
return override
dirs = list(_CLAUDE_DIRS) + os.environ.get('PATH', '').split(os.pathsep)
for folder in dirs:
if not folder:
continue
for name in _CLAUDE_NAMES:
candidate = os.path.join(folder, name)
if os.path.isfile(candidate):
return candidate
return None
def _datum_clause(know):
"""Tell the extractor which rail the strand chain is measured from.
Plant knowledge, not a guess: reading the chain from the wrong end mirrors
the whole pattern, and nothing downstream would look wrong.
"""
from vibe import knowledge as K
datum = K.get(know, 'rails.strand_datum', 'left_rail')
fixed = K.get(know, 'rails.left_rail_is_fixed', None)
where = {
'left_rail': 'the LEFT rail -- the left-hand edge where the concrete '
'starts. The first segment of the chain is that edge to '
'the first strand.',
'right_rail': 'the RIGHT rail -- the right-hand edge where the '
'concrete starts, reading right to left.',
'centerline': 'the CENTERLINE of the piece.',
}.get(datum, datum)
if fixed is True:
rail = ' That rail is the bed\'s fixed rail.'
elif fixed is False:
rail = (' That rail is NOT the bed\'s fixed rail on this job, so do '
'not assume dimensions accumulate away from it.')
else:
rail = (' Nobody has recorded whether that rail is the bed\'s fixed '
'rail, so do not reason about which edge tolerance '
'accumulates from.')
return '{0} (datum = "{1}").{2}'.format(where, datum, rail)
def prepare(doc, design, know=None):
"""Scrape the summary and write the evidence file. Returns its path.
know carries the plant's nomenclature, which decides which sheets make up
this design's packet, and its rails, which decide how the strand chain
reads.
"""
if know is None:
from vibe import knowledge as K
know = K.load(doc)
evidence = designspec.gather_evidence(doc, design, know=know)
path = designspec.evidence_path(doc, design)
designspec.write_json(path, evidence)
return path, evidence
# dsh's local HTTP RPC. Overridable per-machine/testing, same idiom as
# WILLTOOLS_JOBS and CLAUDE_CODE_EXE elsewhere in this file.
DSH_URL_ENV = 'WILLTOOLS_DSH_URL'
DEFAULT_DSH_BASE_URL = 'http://127.0.0.1:3080'
# The model name and timeout are resolved on the CPython side
# (`aiextract_run.resolve_model`) so the real ~/.dsh/settings.yaml can be
# read there -- IronPython has no reason to parse that file too, and a
# second parser is a second place for the two to disagree. Set either
# override here if it ever needs to travel from inside Revit instead
# (job.plant knowledge, say); today neither does.
MODEL_ENV = 'WILLTOOLS_DSV4F_MODEL'
TIMEOUT_ENV = 'WILLTOOLS_DSV4F_TIMEOUT_S'
# Vision model on a long prompt + drawing PNG measured well over 180s to
# produce even a first assistant message on 2026-09-03 -- the old 180s
# default cancelled a session that was still working, never one that had
# failed. Keep in lockstep with the identically-named constant in
# tools/aiextract_run.py.
DEFAULT_TIMEOUT_S = 900
def _repo_root():
"""The checkout root, from this file's own path.
Same idiom as `liftexport.panellift_src()`: three `dirname()` calls off
this file strip vibe/lib/WillTools.extension, landing on the folder that
holds both `WillTools.extension` and `tools`.
"""
here = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))) # WillTools.extension
return os.path.dirname(here)
def _job_dict(doc, design, know, image_paths):
"""The plain-data job description `tools/aiextract_run.py` reads.
Pure data assembly -- no process, no file write -- so it can be
inspected on its own. `launch()` is the only caller that also writes it
to disk and starts the process.
"""
return {
'design_number': design,
'evidence_path': designspec.evidence_path(doc, design),
'spec_path': designspec.spec_path(doc, design, 'yaml'),
'image_paths': list(image_paths or []),
'datum_clause': _datum_clause(know),
'dsh_base_url': os.environ.get(DSH_URL_ENV) or DEFAULT_DSH_BASE_URL,
'model': os.environ.get(MODEL_ENV) or None,
'timeout_s': int(os.environ.get(TIMEOUT_ENV) or DEFAULT_TIMEOUT_S),
'log_path': designspec.log_path(doc, design),
}
def launch(doc, design, work_dir=None, know=None, image_paths=None):
"""Start a detached process that runs the DSV4F-vision AI pass.
Returns the started `Process`. The window stays open (same as the old
opus-CLI path did) so the prompt, the reply and the merge outcome can be
read; the spec on disk is updated in place -- or left exactly as the
mechanical reader wrote it, with one new `unresolved` line -- regardless
of whether anybody is watching the window.
`image_paths` are PNGs of the design's summary view for the model to
read with its own vision tool (see `tools/aiextract_run.py`'s prompt) --
rendering them is Revit API work and is the caller's job (the
ExtractDesign button), not this module's: this file already has no
other reason to touch a live View.
"""
if know is None:
from vibe import knowledge as K
know = K.load(doc)
from vibe import liftexport
from System.Diagnostics import Process, ProcessStartInfo
python_exe = liftexport.find_python(know)
if not python_exe:
raise IOError(
'No CPython 3 found to run the AI pass with. Set '
'handling.python_exe in this job\'s knowledge file, or the '
'WILLTOOLS_PYTHON environment variable -- the same setting '
'Ticket QC\'s vision escalation uses.')
root = _repo_root()
runner_script = os.path.join(root, 'tools', 'aiextract_run.py')
job = _job_dict(doc, design, know, image_paths)
job_path = os.path.join(designspec.sub_dir(doc, 'log'),
designspec._safe(design) + '.aijob.json')
designspec.write_json(job_path, job)
runner = os.path.join(designspec.sub_dir(doc, 'log'),
designspec._safe(design) + '.run.cmd')
log = designspec.log_path(doc, design)
with io.open(runner, 'w', encoding='utf-8') as fh:
fh.write(u'@echo off\r\n')
fh.write(u'chcp 65001 >nul\r\n')
fh.write(u'set "PYTHONIOENCODING=utf-8"\r\n')
fh.write(u'title AI pass (DSV4F vision) - {0}\r\n'.format(design))
fh.write(u'cd /d "{0}"\r\n'.format(work_dir or root))
fh.write(u'echo Running the DSV4F-vision AI pass for {0} ...\r\n'
.format(design))
fh.write(u'echo.\r\n')
fh.write(u'"{0}" "{1}" --job "{2}" > "{3}" 2>&1\r\n'
.format(python_exe, runner_script, job_path, log))
fh.write(u'type "{0}"\r\n'.format(log))
fh.write(u'echo.\r\n')
fh.write(u'echo [done - spec updated in place: specs\\{0}.yaml]\r\n'
.format(design))
fh.write(u'pause >nul\r\n')
psi = ProcessStartInfo()
wt = os.path.join(os.environ.get('LOCALAPPDATA', ''),
'Microsoft', 'WindowsApps', 'wt.exe')
if os.path.isfile(wt):
psi.FileName = wt
psi.Arguments = 'cmd.exe /c "{0}"'.format(runner)
else:
psi.FileName = 'cmd.exe'
psi.Arguments = '/c "{0}"'.format(runner)
psi.UseShellExecute = True
return Process.Start(psi)
# ------------------------------------------------------- independent check
def cross_check(spec, regex_spec):
"""Compare the extracted spec against the old regex reading of the summary.
Two readings that agree are worth more than either alone. This is not a
correctness proof -- both can be wrong the same way -- but a disagreement
is always worth a human glance before the spec is trusted.
"""
from vibe import ticketqc
rows = []
def row(label, ai, rx):
same = (ai is None and rx is None)
if not same and ai is not None and rx is not None:
try:
same = abs(float(ai) - float(rx)) < 0.5
except Exception:
same = str(ai).strip().upper() == str(rx).strip().upper()
rows.append({'field': label, 'ai': ai, 'regex': rx, 'agree': bool(same)})
row("f'c", designspec.get_path(spec, 'concrete.fc_psi'), regex_spec.get('fc'))
row("f'ci", designspec.get_path(spec, 'concrete.fci_psi'), regex_spec.get('fci'))
row('unit weight', designspec.get_path(spec, 'concrete.unit_weight_pcf'),
regex_spec.get('pcf'))
def mesh(value):
import re as _re
if not value:
return None
return _re.sub(r'\s+', '', str(value).upper()).replace('.0', '')
row('W.W.F. mesh', mesh(designspec.get_path(spec, 'reinforcing.wwf.mesh')),
mesh(regex_spec.get('wwf_mesh')))
ai_groups = designspec.get_path(spec, 'reinforcing.strand_groups', []) or []
ai_sig = ticketqc.strand_signature([
{'qty': g.get('qty'), 'dia': g.get('dia_in'),
'ksi': g.get('grade_ksi'), 'it': g.get('it_lb')} for g in ai_groups])
row('strand groups', ai_sig,
ticketqc.strand_signature(regex_spec.get('strands') or []))
envelope = regex_spec.get('envelope') or {}
for dim, key in (('length', 'LENGTH'), ('width', 'WIDTH')):
for bound in ('min', 'max'):
rx = envelope.get('{0}_{1}'.format(bound.upper(), key))
row('{0} {1}'.format(dim, bound),
designspec.get_path(spec, 'envelope.{0}.{1}_ft'.format(dim, bound)),
rx[0] if rx else None)
return rows