Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:35:15 -05:00

1548 lines
72 KiB
Python

#!/usr/bin/env python3
"""aiextract_run - the DSV4F-vision half of Design Extract, run out of process.
python tools/aiextract_run.py --job <job.json>
Design Extract now chains two extraction passes over one design summary
(owner, Discord, 2026-09-01, approving the proposal: "可以,不过别用opus,用
dsv4f带vision吧" -- yes, but not opus, use DSV4F with vision):
1. the mechanical reader (`vibe.designspec.build_spec`) -- regex and
geometry only, never guesses, leaves everything it cannot place as
`null` or in `unresolved`. It never even attempts the strand matrix:
its own docstring says a wrong grid is worse than an honest blank.
2. THIS SCRIPT -- reads whatever the mechanical pass left behind, plus
the strand matrix (drawn geometry, not text, which is why it needs
vision), by putting a rendered PNG of the summary view in front of a
vision-capable model and asking it to fill in the gaps.
It exists as a separate CPython process, run detached from Revit, for the
same reason `vibe.aiextract`'s old opus-CLI path did: the extension runs on
IronPython 2.7 inside Revit's UI thread, a model call can take a couple of
minutes, and this script touches no Revit API at all -- it reads a job file,
an evidence JSON file and (optionally) some PNGs, talks to a local HTTP
server, and writes a spec file. `vibe.aiextract.launch()` is the IronPython
side of this boundary: it gathers the paths, writes the job file, and starts
this script in a visible detached window so its reasoning stays readable.
THE RULE THAT MAKES THIS SAFE TO RUN UNSUPERVISED: NEVER OVERWRITE, NEVER
HALF-MERGE
--------------------------------------------------------------------------
The mechanical reader outranks this script on anything it actually matched.
`apply_ai_fill()` below is the enforcement, and it does not trust the model
to have followed the prompt's own copy of this rule -- it re-checks every
field against the spec on disk before accepting a proposed value:
* a scalar field is filled only if it is currently `None` (or an empty
string);
* a "structured" list -- `strand_groups`, `strand_layout.columns_in`,
`strand_layout.rows` -- is a single coherent value, and is filled only
if it is currently empty; a spec with even one entry already there is
left exactly as it was;
* `notes` and `unresolved` are collections of independent lines, so new,
distinct entries are appended rather than gating on emptiness -- but
nothing already in either list is ever changed or dropped;
* `confidence` entries are accepted only for paths this pass actually
just filled, never for a path the model merely commented on.
A run that fails for any reason -- dsh unreachable, the session times out,
the reply cannot be parsed as the agreed JSON shape -- leaves the spec's
FIELD VALUES exactly as the mechanical reader wrote them and appends exactly
one line to `unresolved` saying the AI pass did not run and why. There is no
partial-merge outcome: either every field this pass proposes is validated
and applied, or none of them are.
DSV4F PRICING
-------------
DSV4F has an off-peak pricing window the owner otherwise reserves for his own
manual daytime use of the harness. This script only ever runs from a button
press inside Revit, which is inherently a daytime, on-peak action -- it is
never scheduled to run at all, let alone into the off-peak window, so it
never competes with that reservation.
THE DSH WIRE CONTRACT
----------------------
dsh serves a local HTTP RPC at `POST http://127.0.0.1:3080/api/<method>`,
body `{"type": "client-request", "rpcId": <uuid>, "method": "session.create"
| "session.prompt" | "session.history" | "session.list", "payload": {...}}`,
with the rpcId echoed back. CONFIRMED against the documented contract
(owner, 2026-09-01).
The RESPONSE envelope's first two levels are CONFIRMED against a REAL dsh
reply: `session.create` on a live WPA5/sample_job run (owner, 2026-09-01)
answered `{'type': 'server-response', 'rpcId': ..., 'result': {'ok': True,
'value': {'sessionId': 'session-5969e01e-...', 'agentPreset': 'standard'}}}`
-- TWO levels of nesting below `result` (`result.ok`, `result.value.sessionId`),
not one. The first version of this script read the wrong level
(`result.sessionId`) and declared `DshError: session.create did not return
a session id` on a call that had, in fact, fully succeeded server-side --
the session was live on dsh with nobody in this process able to see it. See
`_rpc_value()`, which enforces and centralises this exact shape, and
`cancel_session()` / `run()`'s failure path, added the same day to limit how
long a session like that stays orphaned once ordinary in-flight failures
(not this specific parsing bug, which is now fixed) leave one behind.
MEASURED LIVE against a real dsh at http://127.0.0.1:3080, 2026-09-03
(supersedes every earlier guess about `session.prompt` / `session.history`,
below -- those guesses are what produced the `invalid payload for
session.prompt` failure on every prior run):
1. `session.prompt` payload must be
`{"sessionId": <id>, "mode": "queue", "content": [{"type": "text",
"text": <prompt>}]}`. `send_prompt()` sends exactly this now.
2. `session.history` payload is `{"sessionId": <id>, "maxMessages": N}` --
N is an EVENT count, not a message count: the server returns the last N
raw events, and one turn produces on the order of 20,000 of them, almost
all `assistant/chunk` streaming deltas. `result.value` is
`{"events": [{"event": {"type": <str>, "data": {...}}}, ...],
"hasMore": bool, "projections": {...}}` -- there is NO `messages` key and
NO `status`/`state` key anywhere in it. Event types seen: `user/message`
(`data.content` is a list of `{"type": "text", "text": ...}` blocks),
`assistant/chunk` (streaming deltas -- IGNORED entirely, see
`_history_messages()`), `assistant/message` (`data.message` is
`{"role": "assistant", "content": [...]}`, one per completed step of the
turn -- the LAST one of a turn holds the final answer), `tool/call`,
`tool/result`, `step/start`, `step/end`, `turn/start`, plus a few config
events. `_history_messages()` projects the events shape into
`{"role", "text"}` pairs; it still tolerates the old guessed
`{"messages": [...]}` shape too, for the same reason `_rpc_value` stays
permissive elsewhere -- but the events shape is what a live server
actually sends and takes precedence when both are present.
3. `session.list` payload `{}` -> `result.value = {"items": [{"sessionId":
<id>, "running": bool, "agentPreset": ..., "cwd": ..., "updatedAt": ...,
"projections": ..., "blank": ...}, ...]}`. `running` is true while a turn
is in progress and false once it has finished -- THIS is the done signal
`wait_for_reply()` polls (`session_running()`), not a status field on
history, because `session.history` carries no such field at all.
Every RPC call is funnelled through `_rpc()` / `_rpc_value()` /
`_http_post_json()` so any further correction stays in one place.
`resolve_model()`'s settings.yaml scan WAS confirmed against the real file
(owner, 2026-09-01): `~/.dsh/settings.yaml` nests a model's `id:` and its
`input:` list several keys apart inside provider/model list structure --
`_iter_mappings()` walks the whole parsed tree rather than assuming any
particular shape above the two keys. See its docstring.
4. THE ACTUAL BUG (found 2026-09-03, reading dsh's own source at
`%APPDATA%/npm/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/
dsh-host-apiproxy/lib/types/api/sessions.schema.js`, and confirmed on a
live throwaway session): `session.create`'s payload zod schema
(`sessionCreateRequestSchema`) is `{workspaceId?, cwd?, sessionId?,
agentPreset?}` -- there is NO `model` field at all. The `{"model": ...}`
key this file used to send there was silently stripped by zod's default
unknown-key handling, so every session inherited whatever
`agent-default-model` names in settings.yaml (on this machine:
`commandcode` / `deepseek/deepseek-v4-flash`, no image input) regardless
of what was asked for -- exactly the failure the task that added this
section was opened to chase down. A session's model is set by a
SEPARATE, later RPC: `session.selectModel`, payload `{"sessionId",
"provider", "model", "reasoningEffort"?}` -> `result.value = {"selected":
{"provider", "model", "reasoningEffort"?}}` (`sessionSelectModelRequestSchema`
/ `sessionSelectModelValueSchema`, same file). `provider` is REQUIRED --
the model catalog's grouping key (`~/.dsh/settings.yaml`'s `providers:`
mapping, keyed by provider id -- `commandcode` hosts both
`deepseek/deepseek-v4-flash` and `deepseek/deepseek-v4-flash-vision-exp`
on this machine), never inferable from the model id string alone. Live
proof, 2026-09-03: `session.create` -> `session.selectModel({provider:
"commandcode", model: "deepseek/deepseek-v4-flash-vision-exp"})` ->
`session.models` immediately echoed that pair back as `current`, and the
turn's own `request/context` history event (`{"type": "request/context",
"data": {"provider": "commandcode", "model":
"deepseek/deepseek-v4-flash-vision-exp", "contextWindow": 1000000}}`) --
the event that says what the turn ACTUALLY ran on, not merely what was
asked for -- named that exact pair. `select_model()` sends the RPC;
`verify_session_model()` re-reads history afterwards and checks that
event before this file ever trusts a reply, because a request that dsh
accepted is not proof the turn ran on it -- a silent fallback to the
account default looks identical at every earlier layer.
IronPython does not run this file -- it is plain CPython 3, same rule as
`tools/rvt.py` and `tools/vision_read.py`.
"""
from __future__ import annotations
import argparse
import copy
import io
import json
import os
import re
import sys
import time
import types
import urllib.error
import urllib.request
import uuid
from pathlib import Path
# --------------------------------------------------------------- vibe import
#
# `specformat.py` and `specsave.py` are the Revit-free half of the design
# spec pipeline (see their own docstrings, and CLAUDE.md's `vibe` layout
# section) -- pure data in, text out -- but `vibe/__init__.py` imports the
# Revit API at module scope the instant ANY submodule of `vibe` is imported
# the ordinary way. `WillTools.extension/tests/conftest.py` solves this for
# pytest by registering a bare package stub before the first import; the
# same trick, unchanged, lets this standalone script reach `specformat`
# without a Revit licence or an IronPython interpreter.
_REPO_ROOT = Path(__file__).resolve().parent.parent
_VIBE_DIR = _REPO_ROOT / 'WillTools.extension' / 'lib' / 'vibe'
if 'vibe' not in sys.modules:
_stub = types.ModuleType('vibe')
_stub.__path__ = [str(_VIBE_DIR)]
_stub.__file__ = str(_VIBE_DIR / '__init__.py')
sys.modules['vibe'] = _stub
from vibe import specformat # noqa: E402 (see the stub above)
# ------------------------------------------------------------------- config
DEFAULT_DSH_BASE_URL = 'http://127.0.0.1:3080'
# 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
# WillTools.extension/lib/vibe/aiextract.py.
DEFAULT_TIMEOUT_S = 900
POLL_INTERVAL_S = 3
MODEL_ENV = 'WILLTOOLS_DSV4F_MODEL'
# `session.selectModel` requires an explicit provider id alongside the model
# id (see the module docstring's point 4) -- there is no fallback constant
# for this the way `FALLBACK_VISION_MODEL` is one for the model, because a
# wrong provider guess is exactly the silent-mismatch failure this file was
# rewritten to stop; `resolve_model()` fails closed (`provider is None`)
# rather than invent one.
PROVIDER_ENV = 'WILLTOOLS_DSV4F_PROVIDER'
# Named because the owner named it ("dsv4f带vision") -- not because this
# script has ever confirmed it against a real settings.yaml. See
# `resolve_model()` and the module docstring's owner-must-verify note.
FALLBACK_VISION_MODEL = 'dsv4f-vision'
# ---------------------------------------------------------------- the prompt
#
# Every field name below is copied from `specformat.SPEC_TEMPLATE` (via
# AI_REPLY_TEMPLATE, further down) rather than retyped, for the same reason
# rule 0 gives: a renamed field is read as absent by every check downstream,
# and the check then silently does not run. The OLD prompt in
# `vibe.aiextract` (the opus-CLI path this replaces) had drifted from that
# template -- it still asked for `spacings_in` / `positions_in` /
# `strands_per_row`, which stopped existing the day the spec grew the
# `columns_in` / `rows` matrix shape (commit `73fdd9a`, "spec 改成人类可读
# YAML + 矩阵 + 去掉 AI") -- because nothing called that prompt any more to
# notice. This one is built from the live template so it cannot drift the
# same way without a test noticing (see `test_aiextract_run.py`).
_PROMPT_HEADER = u"""You are the SECOND pass of a two-stage precast concrete design-summary
extractor, running as DSV4F with vision. A mechanical regex/geometry reader
already made a first pass over this design and wrote everything it could
match with certainty into the spec below. Your only job is the leftovers:
whatever that reader left null, empty, or listed under "unresolved" -- and,
because you have vision and it deliberately does not, the strand matrix,
which is drawn geometry, not text, and was never something the mechanical
reader should have guessed at.
THE RULE THAT MATTERS MOST: A VALUE ALREADY IN THE SPEC BELOW IS NOT YOURS
TO TOUCH. The mechanical reader outranks you on anything it actually
matched. If a field already carries a non-null value, or a list already has
entries, leave it out of your reply entirely -- even if you would have read
it differently. Only propose a value for a field that is null, an empty
list, or named in "unresolved" below. A host-side merge enforces this
regardless of what you send back, so there is no advantage to repeating or
"correcting" an already-filled field: your proposal for it is simply
discarded, and repeating values only wastes your own context.
"""
_PROMPT_RULES = u"""Rules that matter:
1. NEVER invent a value. If you cannot place a piece of text confidently,
add an entry to "unresolved" of the form {{"text": "<verbatim>", "reason":
"<why you would not encode it>"}}. A missing value becomes a visible
warning downstream; a wrong value silently passes and could reach the
plant.
2. The min/max envelope table is written as separate text notes: a label
such as "MAX. LENGTH" and, beside it at nearly the same y, its value such
as "49'- 3\\"". Pair them by position. Record both the decimal feet
(min_ft, max_ft) and the original string (min_text, max_text). Only
propose these if the corresponding field above is still null -- the
mechanical reader usually gets this one, so most of the time you will
have nothing to add here.
3. There can be MORE THAN ONE strand group. Shielded or de-tensioned
strands are called out separately, e.g. "(14) 1/2\\" ... @ 31,600 # I.T."
together with "(4) ... @ 5,000 # I.T.". Emit one object per group and set
"role" to what the text says it is (e.g. "main", "shielded"), or null if
unstated. dia_in is a decimal: 1/2 -> 0.5, 9/16 -> 0.5625. Propose the
WHOLE "strand_groups" list only if it is currently empty -- it is one
coherent value, not something to patch entry by entry.
4. Units: psi for strengths, pcf for unit weight, pounds for I.T., inches
for diameters and thickness, decimal FEET for the envelope.
4a. THE STRAND MATRIX -- the whole point of your having vision. Fill
reinforcing.strand_layout the same way a detailer would read it off the
drawing:
columns_in -- strand positions ACROSS the piece, as cumulative
distance in inches from the datum (below), same numbers
a detailer would read off the dimension chain.
rows -- one entry per layer through the piece's thickness:
{{"from_bottom_in": <number>, "strands": [true/false, ...]}}
-- "strands" has exactly len(columns_in) entries, true
where that row actually has a strand at that column,
false where it does not. A design where every column has
a strand at every row -- the ordinary case -- has
"strands" all true; a design that is not a full grid
says so column by column instead of forcing a human to
reverse-engineer it from a total count.
total_in -- the chain total; should equal the piece width. Say so in
"unresolved" if it plainly does not.
note -- anything about the chain a human should double check.
How to read the chain:
a. Dimensions at (nearly) the same y are pieces of ONE chain --
concatenate them left to right in increasing x, even when the
evidence hands them to you as several separate dimension elements.
Quote the concatenated chain, not the separate pieces.
b. One chain per row. A section cut through two strand rows normally
carries two chains -- the top row's drawn ABOVE the section, the
bottom row's BELOW it -- while a full grid (every row the same)
carries a single chain shared by every row. A real chain either
closes to the far edge (total == piece width) or stops at the last
strand (total == that strand's position); it does not fall short of
both.
c. A horizontal chain whose total is neither the width nor consistent
with a strand row is NOT a strand chain -- mild bars, blocking
diagrams and similar callouts can draw their own dimension chain
nearby. Ignore it rather than forcing it to be a row.
d. Row depths come from the VERTICAL chain, cumulative from the bottom
face -- its segments sum to the piece thickness.
e. Count check before you answer: the strands you place across every
row MUST equal the stated quantity in the strand group text, and,
when "symbols" are present in the evidence, the number of strand
symbols too. If they do not agree, leave the matrix EMPTY and say in
"unresolved" which count disagreed with which -- do not force a
placement just to make the arithmetic close.
f. "symbols" (when the evidence carries them) give the centre of every
drawn detail component, in view feet -- the drawn strand marks are
among them. Cluster their y to find the rows, and their x-gaps to
match against a chain's segments; drafting views are true scale, so
a 1.0 ft gap between symbols reads as a `1'-0"` segment.
g. If "unresolved" already carries an entry for "STRAND PATTERN (matrix)",
read its "reason" FIRST -- it is the mechanical reader's
own geometric attempt telling you exactly what did not add up (a
symbol drawn as the wrong kind, a chain that would not close, ...).
Your job is to resolve THAT disagreement, not to re-derive the
matrix from scratch and hope it happens to agree.
Only propose strand_layout.columns_in / rows / total_in if they are
CURRENTLY EMPTY -- never propose reinforcing.strand_layout.datum: that is
plant knowledge (which rail the chain is measured from), not something
you can read off one drawing, and it is filled in already. The datum in
force for this design:
{datum}
Do not change your reading to make the numbers look nicer from the other
end; if the chain plainly does not start where the datum says, record
that in "unresolved" and leave the datum alone.
QUOTE THE DIMENSION CHAIN VERBATIM in your prose summary below the JSON
block -- the exact numbers you read off the drawing, in order -- so a
person can hold your quote next to the drawing and check it without
redoing your reading.
5. Prose instructions that are not a number -- "USE MC STANDARD
REINFORCEMENT FOR CUT DOWN PANELS", "STRIPPING & BLOCKING DETAIL < 25'"
-- belong in "notes" as written. Do not try to encode them. Only add
entries here for lines nothing above already covers.
6. Fill "confidence" with an entry for every field you propose a value for
(and ONLY for fields you propose a value for), mapping the dotted field
path to "high", "medium" or "low". Use "high" only when the drawing
states the value unambiguously.
7. Never set anything under "source" -- extractor, reviewed_by and the rest
are the host's to stamp, not yours. Your reply carries none of it.
"""
_PROMPT_OUTPUT = u"""Reply with exactly one fenced ```json code block matching the shape given
above (omit or leave null anything you are not proposing -- do not resend a
value already present in the spec you were given), followed by a short
prose summary: what you filled and why, the strand matrix's dimension chain
quoted verbatim so a person can check it against the drawing, and anything
you are adding to "unresolved" and why. Do not ask questions -- if something
is ambiguous, leave it out of the JSON and say so in the summary and in
"unresolved".
"""
def _json_block(value):
return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False)
def build_prompt(evidence, regex_spec, datum_clause, image_paths=None):
"""The exact text sent to the model. Pure -- no Revit, no dsh, no I/O.
Kept as one function so the prompt actually sent can be captured and
reviewed (see `--print-prompt`) rather than only existing implicitly
inside `run()`.
"""
image_paths = list(image_paths or [])
parts = [_PROMPT_HEADER]
parts.append(
u"\nEVIDENCE (a deterministic scrape of this design's summary view "
u"and its sheet -- every text note with its X/Y position, every "
u"dimension with its segments, and one real assembly of this "
u"design so you can sanity-check magnitudes; nothing in it has "
u"been interpreted):\n\n" + _json_block(evidence))
parts.append(
u"\nTHE SPEC AS THE MECHANICAL READER LEFT IT (this is what you "
u"may not overwrite -- see the rule above):\n\n" +
_json_block(regex_spec))
parts.append(
u"\nTHE SHAPE YOUR REPLY MUST FIT. Reply with a JSON object using "
u"EXACTLY these field names -- do not rename, do not add fields, "
u"do not drop fields:\n\n" + _json_block(AI_REPLY_TEMPLATE))
parts.append(u"\n" + (_PROMPT_RULES.format(datum=datum_clause)))
if image_paths:
lines = u"\n".join(u" - {0}".format(p) for p in image_paths)
parts.append(
u"\nDRAWING IMAGES -- read each with your read_image tool. "
u"These are file paths on this machine, not embedded bytes:\n\n"
+ lines +
u"\n\nUse these for anything the deterministic evidence above "
u"cannot give you -- above all the strand matrix, which is a "
u"drawn grid, not text.\n")
else:
parts.append(
u"\nNo drawing image was available for this pass -- text and "
u"the evidence above only. Do not attempt the strand matrix "
u"without a drawing to read it from; leave strand_layout empty "
u"and say why in \"unresolved\" if it matters.\n")
parts.append(u"\n" + _PROMPT_OUTPUT)
return u"\n".join(parts)
# ------------------------------------------------------------ the AI's shape
#
# What the model is allowed to propose values for. Deliberately narrower
# than SPEC_TEMPLATE: no "source", no "design_number", no "schema" -- those
# are the host's to stamp (see CONTENT_PATHS below, which this mirrors
# exactly) and never travel over the wire to the model at all.
AI_REPLY_TEMPLATE = {
'piece_description': None,
'concrete': {'fc_psi': None, 'fci_psi': None,
'unit_weight_pcf': None, 'weight_class': None},
'reinforcing': {
'mild_grade_ksi': None,
'wwf': {'mesh': None, 'layers': None, 'per_face': None},
'strand_groups': [], # [{qty, dia_in, grade_ksi, it_lb,
# role, note}]
'strand_layout': {
'columns_in': [], # cumulative in from datum
'rows': [], # [{from_bottom_in, strands: [bool,..]}]
'total_in': None,
'note': None,
},
},
'envelope': {'length': {'min_ft': None, 'max_ft': None,
'min_text': None, 'max_text': None},
'width': {'min_ft': None, 'max_ft': None,
'min_text': None, 'max_text': None}},
'thickness_in': None,
'notes': [],
'unresolved': [],
'confidence': {},
}
# The scalar / single-structured-value paths the AI may fill -- one path per
# leaf in AI_REPLY_TEMPLATE except the two list-of-independent-entries
# fields, which get their own merge rule (see ATOMIC_LIST_PATHS) and
# "confidence", which is merged separately because it depends on what else
# was just filled.
CONTENT_PATHS = (
'piece_description',
'concrete.fc_psi', 'concrete.fci_psi',
'concrete.unit_weight_pcf', 'concrete.weight_class',
'reinforcing.mild_grade_ksi',
'reinforcing.wwf.mesh', 'reinforcing.wwf.layers',
'reinforcing.wwf.per_face',
'reinforcing.strand_groups',
'reinforcing.strand_layout.columns_in',
'reinforcing.strand_layout.rows',
'reinforcing.strand_layout.total_in',
'reinforcing.strand_layout.note',
'envelope.length.min_ft', 'envelope.length.max_ft',
'envelope.length.min_text', 'envelope.length.max_text',
'envelope.width.min_ft', 'envelope.width.max_ft',
'envelope.width.min_text', 'envelope.width.max_text',
'thickness_in',
)
# These three are each ONE coherent value (a grid, not independent lines):
# filled only if currently empty, and replaced whole -- never patched
# entry-by-entry, and never touched at all once anything is there.
STRUCTURED_LIST_PATHS = (
'reinforcing.strand_groups',
'reinforcing.strand_layout.columns_in',
'reinforcing.strand_layout.rows',
)
# These two are collections of INDEPENDENT lines: new, distinct entries are
# appended; nothing already present is ever changed or dropped.
ATOMIC_LIST_PATHS = ('notes', 'unresolved')
# ------------------------------------------------------------- merge logic
#
# The part of this file that is actually tested (see test_aiextract_run.py)
# and the part that makes an otherwise-unsupervised process safe to run:
# regardless of what the model sends back, nothing here can overwrite a
# value the mechanical reader placed, and nothing here can produce a
# half-merged file -- `apply_ai_fill` either fills a field or it does not,
# there is no partial write of one field.
def _is_empty(value):
if value is None:
return True
if isinstance(value, str) and value.strip() == '':
return True
if isinstance(value, (list, dict)) and len(value) == 0:
return True
return False
def _set_path(tree, dotted, value):
parts = dotted.split('.')
node = tree
for part in parts[:-1]:
nxt = node.get(part)
if not isinstance(nxt, dict):
nxt = {}
node[part] = nxt
node = nxt
node[parts[-1]] = value
def _entry_key(entry):
"""A dedupe key for one 'unresolved' / 'notes' entry, tolerant of shape.
Mirrors `vibe.specsave.unresolved_entries`'s tolerance: an entry may be
a bare string (an older writer) or a {'text':, 'reason':} dict.
"""
if isinstance(entry, dict):
text = entry.get('text')
else:
text = entry
return re.sub(r'\s+', ' ', (u'{0}'.format(text or ''))).strip().lower()
def _merge_atomic_entries(current, proposed):
"""New entries from `proposed` not already present in `current`, in order."""
seen = set(_entry_key(e) for e in (current or []))
added = []
for entry in (proposed or []):
key = _entry_key(entry)
if key and key not in seen:
added.append(entry)
seen.add(key)
return added
def apply_ai_fill(spec, ai_payload):
"""Merge one AI proposal into a regex-written spec.
Returns (merged_spec, filled_paths) -- a NEW dict; `spec` is untouched,
which is what lets a failed or bad reply be handed the original spec
back unmodified. `filled_paths` is the honest record of what actually
changed (`source.ai_filled` downstream), and it is used again inside
this function to gate which "confidence" entries are accepted -- a
confidence rating for a field the model did not just fill is noise, not
provenance, and is dropped.
"""
merged = copy.deepcopy(spec)
ai_payload = ai_payload if isinstance(ai_payload, dict) else {}
filled = []
for path in CONTENT_PATHS:
proposed = specformat.get_path(ai_payload, path)
if proposed is None:
continue
if path in STRUCTURED_LIST_PATHS:
current = specformat.get_path(merged, path, []) or []
if current:
continue # already placed -- untouchable
if not isinstance(proposed, list) or not proposed:
continue
else:
current = specformat.get_path(merged, path)
if not _is_empty(current):
continue # already placed -- untouchable
_set_path(merged, path, proposed)
filled.append(path)
for path in ATOMIC_LIST_PATHS:
proposed = specformat.get_path(ai_payload, path, [])
if not proposed:
continue
current = specformat.get_path(merged, path, []) or []
added = _merge_atomic_entries(current, proposed)
if added:
_set_path(merged, path, list(current) + added)
filled.append(path)
proposed_conf = ai_payload.get('confidence')
if isinstance(proposed_conf, dict):
current_conf = dict(specformat.get_path(merged, 'confidence', {}) or {})
changed = False
for field_path, level in proposed_conf.items():
if field_path in filled and field_path not in current_conf:
current_conf[field_path] = level
changed = True
if changed:
merged['confidence'] = current_conf
return merged, filled
FAILURE_REASON_PREFIX = 'AI pass (dsv4f-vision) did not run'
def apply_failure_note(spec, reason):
"""The failure outcome: field values untouched, one line in unresolved.
Never raises, never drops an existing note, and never adds a second
line for the exact same reason on a retry (`_entry_key` dedupes by
normalised text, same as the ordinary unresolved-entry merge).
"""
merged = copy.deepcopy(spec)
note = {'text': FAILURE_REASON_PREFIX, 'reason': reason}
current = specformat.get_path(merged, 'unresolved', []) or []
added = _merge_atomic_entries(current, [note])
if added:
merged['unresolved'] = list(current) + added
return merged
# ----------------------------------------------------------- dsh RPC client
#
# See the module docstring's "THE DSH WIRE CONTRACT ASSUMED HERE" section --
# every assumption about the wire format is contained to this section, and
# every call goes through `_rpc()` / `_http_post_json()` so it can be fixed
# in one place. Tests fake `transport` (a plain callable), never the
# network, which is the HTTP boundary the task asked to fake at.
class DshError(Exception):
"""dsh reached, but the RPC failed or answered something unusable."""
class DshTimeout(DshError):
"""dsh did not answer (connect/read timeout, or no reply within budget)."""
def _http_post_json(url, body, timeout):
"""The one real network call in this file. Swappable via `transport=`."""
data = json.dumps(body).encode('utf-8')
req = urllib.request.Request(
url, data=data, headers={'Content-Type': 'application/json'},
method='POST')
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode('utf-8', 'replace')
except urllib.error.HTTPError as exc:
raw = exc.read().decode('utf-8', 'replace')
except urllib.error.URLError as exc:
if isinstance(getattr(exc, 'reason', None), TimeoutError):
raise DshTimeout('{0}: {1}'.format(url, exc.reason))
raise DshError('could not reach dsh at {0}: {1}'.format(url, exc.reason))
except TimeoutError as exc:
raise DshTimeout('{0}: {1}'.format(url, exc))
try:
return json.loads(raw)
except ValueError:
raise DshError('dsh returned non-JSON from {0}: {1!r}'
.format(url, raw[:200]))
def _rpc_value(res, method):
"""Unwrap a dsh response envelope down to its `result.value` payload,
raising a DshError that names EXACTLY which layer was missing or the
wrong shape -- never a silent `None` a caller then has to re-check.
CONFIRMED against a REAL dsh reply (owner, WPA5/sample_job run,
2026-09-01)::
{'type': 'server-response', 'rpcId': '3d7ad...',
'result': {'ok': True,
'value': {'sessionId': 'session-5969e01e-...',
'agentPreset': 'standard'}}}
That is `session.create`'s envelope, confirmed to this exact depth --
it is what `create_session` failed to reach before this fix, because the
code that used to live here (and in `_history_messages` /
`_history_status`, which each re-derived their own partial version of
this same unwrap) stopped one level too shallow, at `result.sessionId`
rather than `result.value.sessionId`. Centralising it here means the
next shape drift gets fixed in one place instead of three, and every
caller now sees the same failure mode instead of three different ones.
`session.prompt`, `session.history` and `session.list` share this exact
two-level `result.ok` / `result.value` envelope -- MEASURED against a
live dsh, 2026-09-03 (see the module docstring's wire-contract section),
not merely assumed any more.
"""
if not isinstance(res, dict):
raise DshError('dsh {0} response was not a JSON object: {1!r}'
.format(method, res))
result = res.get('result')
if not isinstance(result, dict):
raise DshError('dsh {0} response had no usable "result" object '
'(got {1}): {2!r}'
.format(method, type(result).__name__, res))
if not result.get('ok'):
raise DshError('dsh {0} response result.ok was not truthy ({1!r}): '
'{2!r}'.format(method, result.get('ok'), res))
value = result.get('value')
if not isinstance(value, dict):
raise DshError('dsh {0} response result.value was not an object '
'(got {1}): {2!r}'
.format(method, type(value).__name__, res))
return value
def _rpc(base_url, method, payload, timeout, transport=None):
"""One `{"type": "client-request", ...}` round trip -> the method's
`result.value`, ALREADY UNWRAPPED (see `_rpc_value`). Raises DshError on
any failure: an rpcId mismatch, a top-level `error`, or a `result.value`
that is missing or the wrong shape.
Checks the echoed `rpcId` the same way `tools/rvt.py` checks its own
correlation id against the bridge -- cheap insurance against exactly the
class of bug that cost real time there (REQUEST_CORRELATION in that
file), and free to add here since dsh's contract already carries an id.
Every caller gets the unwrapped payload from this ONE place -- see
`_rpc_value`'s docstring for why that centralisation is the actual fix,
not just this function's own change.
"""
transport = transport or _http_post_json
rpc_id = uuid.uuid4().hex
body = {'type': 'client-request', 'rpcId': rpc_id, 'method': method,
'payload': payload}
url = base_url.rstrip('/') + '/api/' + method
res = transport(url, body, timeout)
if isinstance(res, dict):
got = res.get('rpcId')
if got is not None and got != rpc_id:
raise DshError('dsh answered a different request on {0} '
'(asked {1}, answered {2})'
.format(method, rpc_id, got))
if res.get('error'):
raise DshError('dsh {0} failed: {1}'.format(method, res['error']))
return _rpc_value(res, method)
def create_session(base_url, timeout, transport=None):
"""Ask dsh for a new session; return its id, or raise DshError.
Sends an EMPTY payload -- `session.create`'s real schema
(`sessionCreateRequestSchema`, see the module docstring's point 4) is
`{workspaceId?, cwd?, sessionId?, agentPreset?}`. There used to be a
`model` argument here and a `{"model": model}` payload built from it;
that key has never been part of the schema, so zod silently stripped it
and every session came up on whatever `agent-default-model` names
instead. Setting a session's model is `select_model()`'s job now, as a
separate RPC after this one returns -- see `run()`.
`_rpc` has already confirmed `result.ok` and `result.value` by the time
this runs -- the ONLY thing left to validate here is that `value` itself
actually carries a usable `sessionId` (a non-empty string), because that
is the one part of the shape `_rpc_value` cannot check on session.create's
behalf: it does not know which key inside `value` matters to this
particular method.
"""
value = _rpc(base_url, 'session.create', {}, timeout, transport)
session_id = value.get('sessionId')
if not isinstance(session_id, str) or not session_id.strip():
raise DshError('session.create response value had no usable '
'"sessionId" (got {0!r}): {1!r}'
.format(session_id, value))
return session_id
def select_model(base_url, session_id, provider, model, timeout,
transport=None):
"""`session.selectModel` -- the RPC that actually pins a session's
model. See the module docstring's point 4 for how this was found:
`session.create` accepts no `model` field at all, so a freshly created
session keeps running on the account's `agent-default-model` until this
is called.
Payload `{"sessionId", "provider", "model"}` -> `result.value =
{"selected": {"provider", "model", "reasoningEffort"?}}`
(`sessionSelectModelRequestSchema` / `sessionSelectModelValueSchema`,
`dsh-host-apiproxy`). `provider` is required by that schema -- there is
no default and no way to omit it, which is why `resolve_model()` fails
closed rather than calling this with a guessed one.
Accepting this RPC is NOT proof the turn actually ran on the requested
model -- see `verify_session_model()`, which `run()` calls afterwards
to check the session's own history rather than trusting this echo.
"""
payload = {'sessionId': session_id, 'provider': provider, 'model': model}
return _rpc(base_url, 'session.selectModel', payload, timeout, transport)
def cancel_session(base_url, session_id, timeout, transport=None):
"""Best-effort `session.cancel` -- swallows every error, never raises.
ORPHAN-SESSION RISK: `session.create` succeeding server-side is not the
same event as this script noticing it succeeded -- anything that fails
AFTER a valid `session_id` comes back (the prompt call, the history
poll, an unparsable reply) still leaves that session open on the dsh
server with nothing left in this process to close it, because the whole
point of `run()`'s failure path is that it gives up and writes the
documented "left untouched" outcome rather than retrying. `run()` calls
this from that failure path whenever it already has a trusted
`session_id` in hand (never a guessed one -- there is nothing to cancel
when `create_session` itself never returned).
This is deliberately the only cleanup attempted. A `session.create` that
fails before returning an id (the bug this file was fixed for) is not a
case this function can help with -- the id was never something the
caller could see, only something visible with hindsight once
`_rpc_value`'s error message is read; there is nothing here to guess it
from safely, and guessing wrong would risk cancelling an unrelated
session. See the module docstring / owner report for that residual risk.
Returns True if the cancel RPC came back without raising, False
otherwise -- purely informational for the log; neither outcome changes
`run()`'s documented failure result.
"""
try:
_rpc(base_url, 'session.cancel', {'sessionId': session_id}, timeout,
transport)
return True
except Exception: # noqa: BLE001
return False
def send_prompt(base_url, session_id, text, timeout, transport=None):
payload = {'sessionId': session_id, 'mode': 'queue',
'content': [{'type': 'text', 'text': text}]}
return _rpc(base_url, 'session.prompt', payload, timeout, transport)
def _text_from_content_blocks(blocks, kinds=('text',)):
"""Join the `text` of every block in `blocks` whose `type` is in `kinds`.
Used for both `user/message`'s `data.content` (kinds defaults to just
"text") and `assistant/message`'s `data.message.content`, where
"reasoning" blocks sit beside "text" ones and must be dropped -- the
reasoning is the model's scratch work, never part of the settled reply.
"""
parts = []
for block in (blocks or []):
if isinstance(block, dict) and block.get('type') in kinds \
and isinstance(block.get('text'), str):
parts.append(block['text'])
return ''.join(parts)
def _history_messages(value):
"""`value` is what `_rpc(..., 'session.history', ...)` returns -- already
unwrapped to `result.value` by `_rpc_value`.
MEASURED against a live dsh, 2026-09-03 (see the module docstring): the
real shape is `{"events": [{"event": {"type": ..., "data": {...}}}, ...],
"hasMore": bool, "projections": {...}}`, not `{"messages": [...]}`. This
projects that events list down to the two event types that matter --
`user/message` and `assistant/message` -- as an ordered list of
`{"role": "user"|"assistant", "text": str}` dicts. Everything else
(`assistant/chunk` streaming deltas above all -- there are ~20,000 of
them in one turn's history) is skipped entirely, not just ignored by a
later filter, so callers never have to know these event types exist.
Still tolerates the OLD guessed `{"messages": [...]}` shape (a bare list
of `{role, content|text}` dicts) so tests written against that guess
keep passing -- but the events shape takes precedence when both keys
are present, since that is what a live server actually sends.
"""
if isinstance(value, dict) and isinstance(value.get('events'), list):
messages = []
for entry in value['events']:
event = entry.get('event') if isinstance(entry, dict) else None
if not isinstance(event, dict):
continue
etype = event.get('type')
data = event.get('data') or {}
if etype == 'user/message':
text = _text_from_content_blocks(data.get('content'))
messages.append({'role': 'user', 'text': text})
elif etype == 'assistant/message':
message = data.get('message') or {}
text = _text_from_content_blocks(message.get('content'),
kinds=('text',))
messages.append({'role': 'assistant', 'text': text})
# every other event type (assistant/chunk, tool/call,
# tool/result, step/*, turn/start, config, ...) is skipped.
return messages
if isinstance(value, dict) and isinstance(value.get('messages'), list):
return [{'role': _message_role(m), 'text': _message_text(m)}
for m in value['messages']]
if isinstance(value, list):
return [{'role': _message_role(m), 'text': _message_text(m)}
for m in value]
return []
def _message_role(message):
return (message.get('role') or message.get('type') or '').lower()
def _message_text(message):
if isinstance(message.get('text'), str):
return message['text']
content = message.get('content')
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for chunk in content:
if isinstance(chunk, dict) and isinstance(chunk.get('text'), str):
parts.append(chunk['text'])
elif isinstance(chunk, str):
parts.append(chunk)
return ''.join(parts)
return ''
def session_running(base_url, session_id, timeout, transport=None):
"""Whether `session_id` currently has a turn in progress, per
`session.list` -- the done-signal `wait_for_reply()` polls.
MEASURED against a live dsh, 2026-09-03: `session.list` payload `{}` ->
`result.value = {"items": [{"sessionId": ..., "running": bool, ...}]}`.
Returns True/False from the matching item's `running` field, or None if
the session is not listed at all, or the shape is not what is expected
-- callers must treat None as "cannot tell", never as False, the same
"could not judge vs judged and found nothing" discipline as everywhere
else in this tool.
"""
try:
value = _rpc(base_url, 'session.list', {}, timeout, transport)
except DshError:
return None
items = value.get('items') if isinstance(value, dict) else None
if not isinstance(items, list):
return None
for item in items:
if isinstance(item, dict) and item.get('sessionId') == session_id:
running = item.get('running')
return running if isinstance(running, bool) else None
return None
def wait_for_reply(base_url, session_id, baseline_count, timeout_s,
transport=None, sleep=time.sleep, clock=time.time,
poll_interval=POLL_INTERVAL_S):
"""The model's final reply text, or raises DshTimeout.
Polls `session.history` (asking for enough events -- 50,000 -- to cover
a whole turn, per the module docstring's measured event-count-not-
message-count contract) and projects it with `_history_messages`. The
candidate reply is the text of the LAST assistant message after
`baseline_count` -- a turn that calls tools produces several
`assistant/message` events, one per step, and only the final one holds
the settled answer.
Done when `session_running()` says the turn has finished (`running` is
False) and a non-empty candidate exists. If `session_running()` cannot
tell (returns None -- session not listed, or an unexpected shape), falls
back to the old stability check: the same non-empty text on two
consecutive polls, which tolerates a reply arriving mid-update without
declaring victory on a still-streaming partial answer.
"""
deadline = clock() + timeout_s
last_text = None
while clock() < deadline:
value = _rpc(base_url, 'session.history',
{'sessionId': session_id, 'maxMessages': 50000},
min(30, timeout_s), transport)
messages = _history_messages(value)
assistant_all = [m for m in messages if m['role'] == 'assistant']
assistant_msgs = assistant_all[baseline_count:]
if assistant_msgs:
text = assistant_msgs[-1]['text']
running = session_running(base_url, session_id,
min(30, timeout_s), transport)
if running is not None:
if running is False and text:
return text
elif text and text == last_text:
return text
last_text = text
sleep(poll_interval)
raise DshTimeout('no settled assistant reply on session {0} within {1}s'
.format(session_id, timeout_s))
# ------------------------------------------------------ model verification
#
# THE CHECK THIS TASK WAS OPENED TO ADD. See the module docstring's point 4:
# neither `session.create`'s response nor `session.selectModel`'s own
# response is proof a turn actually ran on the requested model -- both only
# echo what was asked for (or, for `session.create`, ignore it silently).
# The one event that says what a turn ACTUALLY used is `request/context`,
# emitted into the session's own history once the turn starts. `run()` calls
# `verify_session_model()` after `wait_for_reply()` settles and BEFORE it
# ever parses the reply as data -- a wrong-model reply can look exactly like
# a right-model one (confident, well-formatted, plausible numbers), so this
# has to gate on the model, never on whether the reply merely parsed.
def _extract_context_events(history_value):
"""Every `request/context` event's `{"provider", "model"}` pair, oldest
first. `history_value` is already unwrapped to `result.value` (same
input shape as `_history_messages`) -- MEASURED live, 2026-09-03: the
event is `{"type": "request/context", "data": {"provider": ...,
"model": ..., "contextWindow": ...}}`, one per LLM call the turn makes
(a compaction or a retry can add more than one -- the caller wants the
LAST).
"""
events = history_value.get('events') if isinstance(history_value, dict) \
else None
if not isinstance(events, list):
return []
out = []
for entry in events:
event = entry.get('event') if isinstance(entry, dict) else None
if not isinstance(event, dict) or event.get('type') != 'request/context':
continue
data = event.get('data') or {}
provider, model = data.get('provider'), data.get('model')
if isinstance(provider, str) and isinstance(model, str):
out.append({'provider': provider, 'model': model})
return out
def verify_session_model(base_url, session_id, expected_provider,
expected_model, timeout, transport=None):
"""Raise `DshError` -- naming BOTH the requested and the actual pair --
unless `session_id` really ran on `expected_provider` / `expected_model`,
per its own `request/context` history event.
Re-fetches `session.history` rather than trusting anything already in
hand from `wait_for_reply()`'s polling, so this stays correct regardless
of how that loop settled. Also raises when NO `request/context` event
exists yet at all -- a session with no such event has not verifiably run
on anything, and this refuses to treat "cannot tell" as "must be fine"
the same way the rest of this codebase refuses to collapse "could not
judge" into a pass (see CLAUDE.md's `knowledge.same_face()` example).
Returns `(actual_provider, actual_model)` on success, for the caller to
log.
"""
value = _rpc(base_url, 'session.history',
{'sessionId': session_id, 'maxMessages': 50000},
timeout, transport)
seen = _extract_context_events(value)
if not seen:
raise DshError(
'could not verify session {0}\'s model -- no "request/context" '
'event in its history yet (requested {1}/{2}); refusing to '
'trust an unverified session rather than risk a confident '
'text-only fill from the wrong model'.format(
session_id, expected_provider, expected_model))
actual = seen[-1]
if actual['provider'] != expected_provider or actual['model'] != expected_model:
raise DshError(
'session {0} actually ran on {1}/{2}, not the requested {3}/{4} '
'-- refusing to proceed: a non-vision model filling this prompt '
'with a confident-looking text-only reply is exactly the silent '
'failure this check exists to catch'.format(
session_id, actual['provider'], actual['model'],
expected_provider, expected_model))
return actual['provider'], actual['model']
# ------------------------------------------------------- extracting the JSON
_JSON_FENCE_RE = re.compile(r'```(?:json)?\s*(\{.*?\})\s*```', re.DOTALL)
def _first_balanced_object(text, start):
depth = 0
for i in range(start, len(text)):
ch = text[i]
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
return text[start:i + 1]
raise ValueError('unbalanced JSON object in reply')
def extract_json_payload(reply_text):
"""The model's structured proposal, pulled out of its prose reply.
Looks for a fenced ```json block first (the prompt asks for exactly
one); falls back to the first balanced {...} in the text. Raises
ValueError -- never returns {} -- when nothing parses, because a reply
that cannot be read as data must never be silently treated as an empty,
harmless one: that is exactly the "half-merged" outcome this whole
module exists to avoid, so it must surface as a failure, not a no-op
success.
"""
reply_text = reply_text or ''
match = _JSON_FENCE_RE.search(reply_text)
if match:
candidate = match.group(1)
else:
start = reply_text.find('{')
if start == -1:
raise ValueError('no JSON object found in the reply')
candidate = _first_balanced_object(reply_text, start)
payload = json.loads(candidate)
if not isinstance(payload, dict):
raise ValueError('the reply\'s JSON was not an object: {0!r}'
.format(type(payload).__name__))
return payload
# --------------------------------------------------------------- model name
def resolve_model(job):
"""Which model AND which dsh provider to ask for. Fails closed on
either rather than guessing wrong.
Returns `(model, provider, why)`. `session.selectModel` needs BOTH --
see the module docstring's point 4: `provider` is a required field of
its own, never inferable from the model id string, and this refuses to
invent one the way it will (loudly) invent a fallback MODEL -- a wrong
provider guess is exactly the silent-session-runs-on-something-else
failure this file was rewritten to stop, and `run()`'s
`verify_session_model()` check downstream cannot save a session that
never legitimately requested the right model to begin with.
Model order: an explicit job['model'] (aiextract.py can set this once
the owner has confirmed the real name); the WILLTOOLS_DSV4F_MODEL env
var; a real parse of ~/.dsh/settings.yaml for a model id whose `input`
list names "image"; the FALLBACK_VISION_MODEL constant, which is a guess
and is flagged as one in the returned `why` string every time it is
used, so it shows up in the log rather than passing quietly.
Provider order: an explicit job['provider']; the WILLTOOLS_DSV4F_PROVIDER
env var; whichever provider the settings.yaml scan above already
associated with the chosen model (free, when the model came from that
same scan); failing that, a SEPARATE settings.yaml lookup
(`_find_provider_for_model_id`) for whatever provider block actually
lists the resolved model id -- covers an explicit/env model whose
provider was never looked up by the vision scan. `provider` comes back
`None`, never a guess, when none of those found one.
"""
explicit_model = job.get('model')
env_model = os.environ.get(MODEL_ENV)
scanned_provider = None
if explicit_model:
model, model_confirmed = explicit_model, True
elif env_model:
model, model_confirmed = env_model, True
else:
scanned_model, scanned_provider, scan_why = \
_scan_settings_for_vision_model()
if scanned_model:
model, model_confirmed = scanned_model, True
else:
model, model_confirmed = FALLBACK_VISION_MODEL, False
explicit_provider = job.get('provider')
env_provider = os.environ.get(PROVIDER_ENV)
if explicit_provider:
provider, provider_why = explicit_provider, None
elif env_provider:
provider, provider_why = env_provider, None
elif scanned_provider:
provider, provider_why = scanned_provider, None
else:
provider, provider_why = _find_provider_for_model_id(model)
why_parts = []
if not model_confirmed:
why_parts.append(
'could not confirm the vision model name from {0} or {1} ({2}) '
'-- using the unconfirmed fallback {3!r}. See '
'aiextract_run.py\'s module docstring.'.format(
MODEL_ENV, '~/.dsh/settings.yaml', scan_why,
FALLBACK_VISION_MODEL))
if not provider:
why_parts.append(
'could not determine which dsh provider serves model {0!r} '
'({1}) -- session.selectModel requires an explicit provider id '
'and this refuses to guess one. Set {2} explicitly, or {3} to '
'name a different model whose provider IS in settings.yaml.'
.format(model, provider_why, PROVIDER_ENV, MODEL_ENV))
why = ' '.join(why_parts) if why_parts else None
return model, provider, why
def _iter_mappings(node):
"""Every dict anywhere in a parsed YAML tree, at any depth.
dsh's real settings.yaml nests a model's `id:` and its `input:` list
several keys apart inside deep, provider-specific structure (measured on
the owner's machine: `providers: [...] -> models: [...] -> - id: ... /
(other keys) / input: [text, image]`). Walking every mapping rather than
assuming a fixed shape above it is what survives that -- the two keys
only need to be IN THE SAME MAPPING, never adjacent lines.
"""
if isinstance(node, dict):
yield node
for value in node.values():
for found in _iter_mappings(value):
yield found
elif isinstance(node, list):
for item in node:
for found in _iter_mappings(item):
yield found
def _iter_model_entries(node, provider_id=None):
"""Every dict that looks like ONE model catalog entry (carries an `id`),
paired with the provider id it sits under -- `None` when it sits under
no recognisable provider block at all.
A `providers` key names a provider block in the two shapes this file has
had reason to handle: a MAPPING keyed by provider id (the real
`~/.dsh/settings.yaml` on this machine, confirmed 2026-09-03:
`llm-pi-ai: providers: {commandcode: {models: [...]}, ...}` -- the dict
key IS the provider id `session.selectModel` wants), or a LIST of
provider objects that name themselves via their own `id` or `name` field
(an earlier guessed shape some fixtures here still use). Either way,
once a provider block is found everything nested under it -- at any
depth, same `_iter_mappings` tolerance -- is tagged with that provider id
until a closer `providers` block overrides it.
"""
if isinstance(node, dict):
providers = node.get('providers')
if isinstance(providers, dict):
for pid, pconf in providers.items():
for found in _iter_model_entries(pconf, provider_id=pid):
yield found
elif isinstance(providers, list):
for item in providers:
if isinstance(item, dict):
pid = item.get('id') or item.get('name')
for found in _iter_model_entries(item, provider_id=pid):
yield found
for key, value in node.items():
if key == 'providers':
continue # already walked, above
for found in _iter_model_entries(value, provider_id=provider_id):
yield found
if isinstance(node.get('id'), str) and node.get('id').strip():
yield provider_id, node
elif isinstance(node, list):
for item in node:
for found in _iter_model_entries(item, provider_id=provider_id):
yield found
def _declares_image_input(mapping):
value = mapping.get('input')
if not isinstance(value, list):
return False
return any(isinstance(v, str) and v.strip().lower() == 'image'
for v in value)
def _load_settings_yaml(settings_path=None):
"""Parse ~/.dsh/settings.yaml (or `settings_path`); `(data, None)` or
`(None, why)` -- shared by both settings.yaml lookups below so a missing
file, a missing PyYAML, or a YAML syntax error says the same thing in
both places rather than being spelled out twice.
"""
path = settings_path or os.path.join(
os.path.expanduser('~'), '.dsh', 'settings.yaml')
if not os.path.isfile(path):
return None, 'no settings file at {0}'.format(path)
try:
import yaml
except ImportError as exc:
return None, ('PyYAML is not installed ({0}) -- see requirements.txt'
.format(exc))
try:
with io.open(path, 'r', encoding='utf-8') as fh:
return yaml.safe_load(fh), None
except Exception as exc: # noqa: BLE001
return None, 'could not parse {0}: {1}'.format(path, exc)
def _scan_settings_for_vision_model(settings_path=None):
"""The (model id, provider id) in ~/.dsh/settings.yaml whose `input`
list names "image" -- a REAL `yaml.safe_load`, not a line-scan.
PyYAML is a host-CPython dependency of this repo already (see
requirements.txt), so there is no reason to approximate parsing a YAML
file this script can just parse. `_iter_model_entries` finds every
mapping in the tree that carries BOTH an `id` and an `input`, tagged
with whichever provider block it sits under; nothing here assumes which
key of the surrounding structure holds the model list, only that `id`
and `input` live in the same mapping, which is the one thing dsh's
settings.yaml is stable about across its otherwise-nested shape.
When more than one entry declares image input, one whose id contains
"vision" wins without asking -- the owner's own naming convention
("dsv4f带vision", `deepseek-v4-flash-vision-exp`) makes that safe.
Otherwise this refuses to guess and lists every candidate id, the same
discipline `vibe.joblocate` uses when more than one job folder qualifies
and nothing breaks the tie -- `provider` comes back `None` alongside the
refusal, same as the model does.
Returns `(model_id, provider_id, why)` -- `why` is `None` only when
`model_id` was found unambiguously; `provider_id` may still be `None`
even then, if that particular entry sat outside any recognisable
`providers` block (see `_iter_model_entries`).
"""
path = settings_path or os.path.join(
os.path.expanduser('~'), '.dsh', 'settings.yaml')
data, load_why = _load_settings_yaml(settings_path)
if load_why:
return None, None, load_why
candidates = []
providers_by_id = {}
seen = set()
for provider_id, mapping in _iter_model_entries(data):
model_id = mapping.get('id')
if not isinstance(model_id, str) or not model_id.strip():
continue
if not _declares_image_input(mapping):
continue
if model_id not in seen:
candidates.append(model_id)
providers_by_id[model_id] = provider_id
seen.add(model_id)
if not candidates:
return None, None, ('no entry in {0} has an id with an input: list '
'naming "image"'.format(path))
if len(candidates) == 1:
return candidates[0], providers_by_id[candidates[0]], None
vision_named = [c for c in candidates if 'vision' in c.lower()]
if len(vision_named) == 1:
return vision_named[0], providers_by_id[vision_named[0]], None
pool = vision_named or candidates
return None, None, (
'{0} image-capable model id(s) in {1} and none is distinguishable '
'by name ({2}) -- nothing here guesses. Name one explicitly via {3}.'
.format(len(pool), path, ', '.join(pool), MODEL_ENV))
def _find_provider_for_model_id(model_id, settings_path=None):
"""Which provider block in ~/.dsh/settings.yaml lists `model_id` --
for a model that came from an explicit job/env override rather than the
vision scan above, which is the one case `resolve_model()` has a model
id in hand but no provider tag that came free with it.
Deliberately does not care whether the entry declares image input --
unlike `_scan_settings_for_vision_model`, the caller already knows which
model it wants; this only answers "who serves it". Returns
`(provider_id, None)` on the first match, or `(None, why)` -- never
guesses between two providers that happen to list the same id, the
same refuse-the-tie discipline as everywhere else in this file; `why`
names the id it could not place.
"""
path = settings_path or os.path.join(
os.path.expanduser('~'), '.dsh', 'settings.yaml')
data, load_why = _load_settings_yaml(settings_path)
if load_why:
return None, load_why
found = set()
for provider_id, mapping in _iter_model_entries(data):
if mapping.get('id') == model_id and provider_id:
found.add(provider_id)
if not found:
return None, 'no entry for model id {0!r} in {1}'.format(
model_id, path)
if len(found) > 1:
return None, ('model id {0!r} is listed under more than one '
'provider in {1} ({2}) -- nothing here guesses'
.format(model_id, path, ', '.join(sorted(found))))
return next(iter(found)), None
# ------------------------------------------------------------------ file IO
def _read_text(path):
with io.open(path, 'r', encoding='utf-8') as fh:
return fh.read()
def _read_json(path):
with io.open(path, 'r', encoding='utf-8') as fh:
return json.load(fh)
def _write_spec(spec_path, spec):
text = specformat.spec_text(spec)
with io.open(spec_path, 'w', encoding='utf-8') as fh:
fh.write(text)
# --------------------------------------------------------------------- run
def run(job, transport=None, sleep=time.sleep, clock=time.time, log=None):
"""The whole AI pass for one design. Never raises for an ordinary
failure (unreachable dsh, timeout, unparsable reply) -- those become
the documented "left untouched, note added" outcome. Only a bad job
file (unreadable spec/evidence path) escapes, because no dsh behaviour
could produce that and it means this script was invoked wrong.
Returns a small dict describing what happened, for the caller's log.
"""
log = log if log is not None else (lambda *_a: None)
spec_path = job['spec_path']
original_text = _read_text(spec_path)
spec = specformat.normalize_spec(specformat.parse_spec_text(original_text))
# Read up front, outside the try below -- these are plain `job.get()`
# calls that cannot themselves fail, and the except block needs them
# (for the orphan-session cleanup call) regardless of which line inside
# the try raised, including one that runs before this point used to.
base_url = job.get('dsh_base_url') or DEFAULT_DSH_BASE_URL
timeout_s = int(job.get('timeout_s') or DEFAULT_TIMEOUT_S)
session_id = None
try:
evidence = _read_json(job['evidence_path'])
model, provider, model_why = resolve_model(job)
if model_why:
log(model_why)
if not provider:
# Fail BEFORE spending a session on it -- see the module
# docstring's point 4: `session.selectModel` requires an
# explicit provider id and this refuses to guess one, so there
# is nothing a session could correctly be pinned to yet.
raise DshError(
'no dsh provider resolved for model {0!r} -- '
'session.selectModel requires one and this pass will not '
'guess (see the note above)'.format(model))
image_paths = job.get('image_paths') or []
prompt = build_prompt(evidence=evidence, regex_spec=spec,
datum_clause=job.get('datum_clause') or
'(no datum recorded -- treat any strand '
'reading as unresolved rather than guessing '
'which rail it is measured from)',
image_paths=image_paths)
log('--- prompt ---\n' + prompt + '\n--- end prompt ---')
session_id = create_session(base_url, timeout_s, transport)
select_model(base_url, session_id, provider, model, timeout_s,
transport)
log('session: {0} (requested model {1}/{2})'.format(
session_id, provider, model))
before = _rpc(base_url, 'session.history',
{'sessionId': session_id, 'maxMessages': 50000},
timeout_s, transport)
baseline = len([m for m in _history_messages(before)
if m['role'] == 'assistant'])
send_prompt(base_url, session_id, prompt, timeout_s, transport)
reply_text = wait_for_reply(base_url, session_id, baseline, timeout_s,
transport, sleep=sleep, clock=clock)
log('--- reply ---\n' + reply_text + '\n--- end reply ---')
actual_provider, actual_model = verify_session_model(
base_url, session_id, provider, model, timeout_s, transport)
log('confirmed session {0} ran on {1}/{2}'.format(
session_id, actual_provider, actual_model))
payload = extract_json_payload(reply_text)
except Exception as exc: # noqa: BLE001
reason = '{0}: {1}'.format(type(exc).__name__, exc)
log('AI pass failed: ' + reason)
if session_id:
# A session was created successfully before something LATER in
# this same pass failed -- see `cancel_session`'s docstring for
# why this is the one orphan-cleanup case this script can
# attempt safely (a trusted id already in hand, not a guessed
# one). Best-effort: its own failure never masks `reason` above.
cancelled = cancel_session(base_url, session_id, timeout_s,
transport)
log('session {0}: {1} after the failure above'.format(
session_id, 'cancelled' if cancelled else
'could not be cancelled -- may be an orphan on the dsh '
'server'))
_write_spec(spec_path, apply_failure_note(spec, reason))
return {'ok': False, 'error': reason}
merged, filled = apply_ai_fill(spec, payload)
merged['source']['extractor'] = 'dsv4f-vision'
merged['source']['ai_filled'] = filled
_write_spec(spec_path, merged)
log('filled: {0}'.format(', '.join(filled) if filled else '(nothing new)'))
return {'ok': True, 'filled': filled}
# --------------------------------------------------------------------- CLI
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--job', required=True,
help='path to the job JSON written by vibe.aiextract')
ap.add_argument('--print-prompt', action='store_true',
help='print the prompt that would be sent, and exit '
'-- no dsh call, no file written')
args = ap.parse_args(argv)
with io.open(args.job, 'r', encoding='utf-8') as fh:
job = json.load(fh)
log_lines = []
def log(msg):
log_lines.append(msg)
print(msg)
if args.print_prompt:
spec = specformat.normalize_spec(
specformat.parse_spec_text(_read_text(job['spec_path'])))
evidence = _read_json(job['evidence_path'])
print(build_prompt(evidence=evidence, regex_spec=spec,
datum_clause=job.get('datum_clause') or '',
image_paths=job.get('image_paths') or []))
return 0
result = run(job, log=log)
log_path = job.get('log_path')
if log_path:
try:
with io.open(log_path, 'a', encoding='utf-8') as fh:
fh.write(u'\n'.join(log_lines) + u'\n')
except OSError:
pass
print(json.dumps(result, indent=2))
return 0 if result.get('ok') else 1
if __name__ == '__main__':
raise SystemExit(main())