WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user