WillTools: precast shop-ticket QC and panel handling tools
ci / test (push) Has been cancelled

Public demo snapshot of an in-house pyRevit extension for precast concrete
shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel
handling solver (PCI Chapter 8).

- No client, job, plant or product data: knowledge layers ship as
  structural templates only.
- Ticket QC's model-read escalation targets a LAN model server endpoint
  (configurable), not a cloud provider.
- Built from the private development repo via tools/make_dist.py.
This commit is contained in:
2026-09-03 16:33:47 -05:00
commit 52d94303bc
281 changed files with 155656 additions and 0 deletions
+287
View File
@@ -0,0 +1,287 @@
#!/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 the other out-of-process tools do:
* 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.
ENDPOINT
--------
The model server lives on the office LAN (default `http://192.168.99.151`,
override with `VISION_ENDPOINT` or an `endpoint` key in the request). The
endpoint speaks the OpenAI chat/completions wire format; the model name
(`will-tool-ai` by default) is whatever that server advertises. 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. An unreachable server is a NOT_RUN
with that reason.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import sys
import time
import urllib.error
import urllib.request
# The LAN vision server this tool talks to. Nothing here calls a cloud
# provider: the endpoint is a local model server on the office network, and
# the model name is the one that server advertises. Override either with the
# environment when the server moves.
DEFAULT_MODEL = "will-tool-ai"
DEFAULT_ENDPOINT = "http://192.168.99.151/v1/chat/completions"
# 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.")
# OpenAI-compatible chat format: image_url carries the base64 data URI.
mime = "image/png"
if item.get("image", "").lower().endswith((".jpg", ".jpeg")):
mime = "image/jpeg"
return [
{"type": "image_url",
"image_url": {"url": "data:{0};base64,{1}".format(mime, data)}},
{"type": "text", "text": "\n".join(lines)},
]
def ask(endpoint, 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:
body = json.dumps({
"model": model,
"max_tokens": MAX_TOKENS,
"messages": [
{"role": "system", "content": skill_text},
{"role": "user", "content": content},
],
}).encode("utf-8")
req = urllib.request.Request(endpoint, data=body, headers={
"Content-Type": "application/json",
})
with urllib.request.urlopen(req, timeout=180) as resp:
reply = json.loads(resp.read().decode("utf-8"))
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 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__))
try:
text = "".join(
c.get("text", "") for c in reply["choices"][0]["message"]["content"]
if isinstance(c, dict) and c.get("type") == "text")
except (KeyError, IndexError, TypeError):
text = reply.get("choices", [{}])[0].get("message", {}).get("content", "")
if isinstance(text, str):
pass
else:
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 = reply.get("usage", {})
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"] = usage.get("prompt_tokens")
row["output_tokens"] = usage.get("completion_tokens")
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
endpoint = (request.get("endpoint") or
os.environ.get("VISION_ENDPOINT") or DEFAULT_ENDPOINT)
# The endpoint is a bare host[:port] in some configs; accept both that
# and a full URL, and always hit the chat/completions path.
if not endpoint.startswith("http"):
endpoint = "http://{0}/v1/chat/completions".format(endpoint)
for i, item in enumerate(items):
out["results"].insert(i, ask(endpoint, 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())