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:
@@ -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())
|
||||
Reference in New Issue
Block a user