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.
317 lines
14 KiB
Python
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
|