WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ask a small vision model one question about one rendered ticket view.
|
||||
|
||||
python tools/vision_read.py <request.json> <response.json>
|
||||
|
||||
This is the CPython half of the Ticket QC vision escalation. It exists as a
|
||||
separate process for the same two reasons `vibe.aiextract` does:
|
||||
|
||||
* the extension runs on IronPython 2.7 inside Revit's UI thread, and there
|
||||
is no HTTP client there worth trusting with a model call;
|
||||
* nothing here touches the Revit API. It reads PNGs and JSON and writes
|
||||
JSON, so it has no business being inside Revit.
|
||||
|
||||
The two halves talk through files, never imports. `vibe.ticketvision` builds
|
||||
the request, this writes the response, and neither imports the other.
|
||||
|
||||
WHAT THIS MAY AND MAY NOT DO
|
||||
----------------------------
|
||||
It may return a verdict from a model that looked at a picture. It may not
|
||||
return anything that renders like a computed result. Every row it writes
|
||||
carries `source: "model-read"`, and a row it could not obtain is
|
||||
`status: "NOT_RUN"` with a reason -- never a pass, never silence. Reaching
|
||||
the model is not the same as the model being right, and neither is the same
|
||||
as this program having run at all; the three are kept separate on purpose.
|
||||
|
||||
CREDENTIALS
|
||||
-----------
|
||||
Read from the environment by the Anthropic SDK: `ANTHROPIC_API_KEY`, or an
|
||||
`ant auth login` profile. There is no key in this file, no key in the request
|
||||
JSON, no key in the response JSON, and no key in anything printed. Nothing
|
||||
here ever reads a key into a variable, so nothing here can leak one. A missing
|
||||
credential is a NOT_RUN with that reason.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Haiku, because the ruling was Haiku: the work code cannot do goes to the
|
||||
# small model, and what the small model cannot settle goes to a person. It is
|
||||
# also the only sane choice per-sheet on a model with four figures of them.
|
||||
DEFAULT_MODEL = "claude-haiku-4-5"
|
||||
|
||||
# The three answers the skill allows. Anything else the model says is not an
|
||||
# answer, and is treated as a refusal rather than coerced into one.
|
||||
ANSWERS = ("clear", "problem_seen", "cannot_tell")
|
||||
|
||||
# One image, one question, one JSON object back. 400 output tokens is roughly
|
||||
# four times the longest legal answer -- enough that a well-formed reply is
|
||||
# never truncated, small enough that a runaway one costs nothing.
|
||||
MAX_TOKENS = 400
|
||||
|
||||
# The API downscales any image whose long edge exceeds 1568 px, so a bigger
|
||||
# render buys nothing and costs upload time. The extension renders to 1500;
|
||||
# this is the ceiling it is staying under, restated here because the two
|
||||
# halves cannot import each other and a silent drift between them would show
|
||||
# up as unexplained illegibility.
|
||||
IMAGE_LONG_EDGE_CEILING_PX = 1568
|
||||
|
||||
|
||||
def _not_run(item, reason):
|
||||
return {
|
||||
"id": item.get("id"),
|
||||
"question_id": item.get("question_id"),
|
||||
"status": "NOT_RUN",
|
||||
"reason": reason,
|
||||
"answer": None,
|
||||
"where": None,
|
||||
"why": None,
|
||||
"source": "model-read",
|
||||
}
|
||||
|
||||
|
||||
def _read_json(path):
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def _write_json(path, obj):
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(obj, fh, indent=2, sort_keys=True)
|
||||
|
||||
|
||||
def load_skill(path):
|
||||
"""The instructions the model reads, from the file that is versioned with
|
||||
the code. Deliberately not a string in this module: a prompt buried in a
|
||||
function is a prompt nobody reviews."""
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def parse_answer(text, question_id):
|
||||
"""The model's reply -> a result row, or None when it is not an answer.
|
||||
|
||||
Strict on purpose. A reply that is prose, or JSON with an answer word the
|
||||
skill does not define, is NOT coerced into the nearest legal value -- it
|
||||
is a refusal, because a coerced verdict is exactly the hallucination this
|
||||
whole path is built to keep out of the report.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
body = text.strip()
|
||||
if body.startswith("```"):
|
||||
body = body.split("\n", 1)[-1]
|
||||
if body.rstrip().endswith("```"):
|
||||
body = body.rstrip()[:-3]
|
||||
start, end = body.find("{"), body.rfind("}")
|
||||
if start < 0 or end <= start:
|
||||
return None
|
||||
try:
|
||||
obj = json.loads(body[start:end + 1])
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(obj, dict):
|
||||
return None
|
||||
answer = obj.get("answer")
|
||||
if answer not in ANSWERS:
|
||||
return None
|
||||
if obj.get("question_id") not in (None, question_id):
|
||||
# It answered a different question than the one it was asked. That is
|
||||
# not a near-miss to be salvaged; it means the reply is not about this
|
||||
# image.
|
||||
return None
|
||||
why = obj.get("why")
|
||||
where = obj.get("where")
|
||||
return {
|
||||
"answer": answer,
|
||||
"where": where if isinstance(where, str) and where.strip() else None,
|
||||
"why": why if isinstance(why, str) and why.strip() else None,
|
||||
}
|
||||
|
||||
|
||||
def build_user_content(item):
|
||||
"""One image block, then the question. Image first: the guidance is that a
|
||||
question about an image reads better after the image."""
|
||||
with open(item["image"], "rb") as fh:
|
||||
data = base64.standard_b64encode(fh.read()).decode("utf-8")
|
||||
lines = [
|
||||
"question_id: " + str(item.get("question_id")),
|
||||
"",
|
||||
"This is one view rendered off a precast shop ticket.",
|
||||
]
|
||||
if item.get("view_kind"):
|
||||
lines.append("The view is a " + str(item["view_kind"]) + ".")
|
||||
if item.get("effective_dpi"):
|
||||
lines.append(
|
||||
"It was rendered at about {0:.0f} dots per inch of the printed "
|
||||
"sheet. If that was not enough to read it, say so."
|
||||
.format(float(item["effective_dpi"])))
|
||||
lines.append("")
|
||||
lines.append("Answer the question named above, as one JSON object.")
|
||||
return [
|
||||
{"type": "image",
|
||||
"source": {"type": "base64", "media_type": "image/png",
|
||||
"data": data}},
|
||||
{"type": "text", "text": "\n".join(lines)},
|
||||
]
|
||||
|
||||
|
||||
def ask(client, model, skill_text, item):
|
||||
"""One call. Returns a result row; never raises for an API problem."""
|
||||
try:
|
||||
content = build_user_content(item)
|
||||
except OSError as ex:
|
||||
return _not_run(item, "the rendered image could not be read: "
|
||||
"{0}".format(type(ex).__name__))
|
||||
|
||||
started = time.time()
|
||||
try:
|
||||
message = client.messages.create(
|
||||
model=model,
|
||||
max_tokens=MAX_TOKENS,
|
||||
system=skill_text,
|
||||
messages=[{"role": "user", "content": content}],
|
||||
)
|
||||
except Exception as ex: # noqa: BLE001 - see below
|
||||
# Every failure to REACH the model is one outcome: NOT RUN, with the
|
||||
# reason. Never a pass. The exception type is reported and the message
|
||||
# is not, because an SDK error string can carry request detail and
|
||||
# this file's whole discipline is that nothing sensitive is written
|
||||
# to a file the tool keeps.
|
||||
return _not_run(item, "the model could not be reached: {0}"
|
||||
.format(type(ex).__name__))
|
||||
|
||||
text = "".join([b.text for b in message.content
|
||||
if getattr(b, "type", None) == "text"])
|
||||
parsed = parse_answer(text, item.get("question_id"))
|
||||
if parsed is None:
|
||||
return _not_run(item, "the model's reply was not one of the three "
|
||||
"answers the skill defines")
|
||||
|
||||
usage = getattr(message, "usage", None)
|
||||
row = _not_run(item, None)
|
||||
row.update(parsed)
|
||||
row["status"] = "OK"
|
||||
row["reason"] = None
|
||||
row["model"] = model
|
||||
row["elapsed_s"] = round(time.time() - started, 2)
|
||||
row["input_tokens"] = getattr(usage, "input_tokens", None)
|
||||
row["output_tokens"] = getattr(usage, "output_tokens", None)
|
||||
return row
|
||||
|
||||
|
||||
def run(request):
|
||||
"""The request dict -> the response dict. No I/O beyond the images."""
|
||||
items = request.get("items") or []
|
||||
model = request.get("model") or DEFAULT_MODEL
|
||||
out = {"model": model, "source": "model-read", "results": []}
|
||||
|
||||
max_calls = request.get("max_calls")
|
||||
if isinstance(max_calls, int) and max_calls >= 0 and len(items) > max_calls:
|
||||
# The bound is the caller's, and it is enforced here as well as there.
|
||||
# Over-budget items are NOT RUN naming the bound -- a dropped item
|
||||
# would read as an item that passed.
|
||||
for item in items[max_calls:]:
|
||||
out["results"].append(_not_run(
|
||||
item, "this run's vision call budget ({0}) was already spent"
|
||||
.format(max_calls)))
|
||||
items = items[:max_calls]
|
||||
|
||||
try:
|
||||
skill_text = load_skill(request["skill"])
|
||||
except (OSError, KeyError) as ex:
|
||||
out["results"][:0] = [_not_run(
|
||||
item, "the ticketqc-vision skill file could not be read "
|
||||
"({0})".format(type(ex).__name__)) for item in items]
|
||||
return out
|
||||
|
||||
try:
|
||||
import anthropic
|
||||
except ImportError:
|
||||
out["results"][:0] = [_not_run(
|
||||
item, "the anthropic SDK is not installed for this Python -- "
|
||||
"pip install anthropic") for item in items]
|
||||
return out
|
||||
|
||||
try:
|
||||
client = anthropic.Anthropic()
|
||||
except Exception as ex: # noqa: BLE001
|
||||
out["results"][:0] = [_not_run(
|
||||
item, "no Anthropic credential is available ({0}) -- set "
|
||||
"ANTHROPIC_API_KEY in the environment, or run "
|
||||
"`ant auth login`".format(type(ex).__name__))
|
||||
for item in items]
|
||||
return out
|
||||
|
||||
for i, item in enumerate(items):
|
||||
out["results"].insert(i, ask(client, model, skill_text, item))
|
||||
return out
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("request", help="request JSON written by ticketvision")
|
||||
ap.add_argument("response", help="where to write the response JSON")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
request = _read_json(args.request)
|
||||
response = run(request)
|
||||
_write_json(args.response, response)
|
||||
|
||||
ok = len([r for r in response["results"] if r["status"] == "OK"])
|
||||
print("{0} answered, {1} NOT RUN".format(
|
||||
ok, len(response["results"]) - ok))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user