#!/usr/bin/env python3 """aiextract_run - the DSV4F-vision half of Design Extract, run out of process. python tools/aiextract_run.py --job 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/`, body `{"type": "client-request", "rpcId": , "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": , "mode": "queue", "content": [{"type": "text", "text": }]}`. `send_prompt()` sends exactly this now. 2. `session.history` payload is `{"sessionId": , "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": , "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": , "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": "", "reason": ""}}. 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": , "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())