"""Inference (S7): prompt revisions (REST), Call 1 vision classification, Call 2 content prose, test-inference, inference-off server description, inference_log. Cites METADATA-35..115, SETTINGS-30/45/90. """ import base64 import hashlib import io import json import time import urllib.error import urllib.request from typing import Any from fastapi import APIRouter from fastapi.responses import JSONResponse from PIL import Image from .db import get_connection from .crypto import decrypt router = APIRouter(prefix="/api/v1") # METADATA-35: Call 1 (and Call 2) send clean processed photos at 1024 px long edge or less. _INFER_MAX_LONG_EDGE = 1024 def fit_long_edge_png(png_bytes: bytes, max_long_edge: int = _INFER_MAX_LONG_EDGE) -> bytes: """Downscale a PNG so max(width, height) <= max_long_edge. Never upscale. Keep alpha. Used only for the inference request payload; stored processed_blob / Woo media stay full size. Unreadable or non-image bytes are returned unchanged so a bad blob cannot crash Call 1. """ if not png_bytes or max_long_edge < 1: return png_bytes try: im = Image.open(io.BytesIO(png_bytes)) im.load() except Exception: return png_bytes w, h = im.size long_edge = max(w, h) if long_edge <= max_long_edge: # Already within budget; re-encode only if we need a stable PNG (caller always base64s). return png_bytes scale = max_long_edge / float(long_edge) nw = max(1, int(round(w * scale))) nh = max(1, int(round(h * scale))) # Preserve alpha: convert palette/etc. to RGBA when needed, LANCZOS downscale. if im.mode not in ("RGB", "RGBA", "L", "LA"): im = im.convert("RGBA") out = im.resize((nw, nh), Image.Resampling.LANCZOS) buf = io.BytesIO() out.save(buf, format="PNG", optimize=True) return buf.getvalue() def _image_part_from_processed(png_bytes: bytes) -> dict: """Build one OpenAI-style image_url part from a processed PNG (METADATA-35 sized).""" fitted = fit_long_edge_png(png_bytes) b64 = base64.b64encode(fitted).decode() return {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}} CALL1_SEED = ( "Classify the slab's species, wood categories, edge type, and figure from the supplied " "photos, choosing ONLY from the supplied synced taxonomy lists (invent no category, term, " "or id outside them). Map free-text observations (character, inclusions, voids, checks) to " "the supplied feature-tag list only. Grade is NOT suggested — leave it out.\n" "Return a single JSON object with EXACTLY these top-level keys, in this shape:\n" "{\"species_id\": , \"species_confidence\": <0-1>, " "\"wood_category_ids\": [, ...], \"wood_category_confidence\": <0-1>, " "\"edge_type_term_id\": , \"edge_confidence\": <0-1>, " "\"figure_term_ids\": [, ...], \"figure_confidence\": <0-1>, " "\"character\": [...], \"inclusions\": [...], \"voids\": [...], \"checks\": [...]}\n" "Each *_confidence is a top-level sibling of its field (do NOT nest confidences under a " "'confidence' object). Use 0-1 numbers; set a field to null / [] when you cannot identify it." ) CALL2_SEED = ( "Write the long product description for the slab from the supplied photos and listing " "facts. The name and short description are already built, so do not repeat them. Use the " "brand voice and geo context given. The dimensions in the fact block are exact: use those " "strings and invent no numbers. Write prose a premium lumber buyer would read." ) _SEEDS = {"call1": CALL1_SEED, "call2": CALL2_SEED} _THRESHOLD = 0.7 def _error(code: str, message: str, detail: Any = None) -> dict: return {"error": {"code": code, "message": message, "detail": detail}} def _now() -> str: from datetime import datetime, timezone return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat() def _hash(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() def _seed_prompts(conn) -> None: for name, seed in _SEEDS.items(): if conn.execute("SELECT 1 FROM prompts WHERE name=?", (name,)).fetchone(): continue cur = conn.execute( "INSERT INTO prompts(name, active_revision_id, created_at, updated_at) VALUES (?,?,?,?)", (name, None, _now(), _now()), ) pid = cur.lastrowid cur2 = conn.execute( "INSERT INTO prompt_revisions(prompt_id, content, content_hash, created_at, created_by) VALUES (?,?,?,?,?)", (pid, seed, _hash(seed), _now(), "seed"), ) conn.execute("UPDATE prompts SET active_revision_id=? WHERE id=?", (cur2.lastrowid, pid)) def _active_prompt(conn, call: str) -> tuple[int, str] | None: row = conn.execute( "SELECT p.id, pr.id, pr.content FROM prompts p LEFT JOIN prompt_revisions pr ON p.active_revision_id=pr.id WHERE p.name=?", (call,), ).fetchone() if row is None or row[2] is None: return None return row[1], row[2] # ---------- Prompt revision REST (METADATA-106) ---------- @router.post("/prompts/{call}/revisions", status_code=201) async def append_revision(call: str, payload: dict[str, Any]) -> dict: if call not in _SEEDS: return JSONResponse(status_code=404, content=_error("not_found", "unknown call")) content = payload.get("content") if not content: return JSONResponse(status_code=400, content=_error("validation", "content required")) conn = get_connection() _seed_prompts(conn) prow = conn.execute("SELECT id FROM prompts WHERE name=?", (call,)).fetchone() cur = conn.execute( "INSERT INTO prompt_revisions(prompt_id, content, content_hash, created_at, created_by) VALUES (?,?,?,?,?)", (prow[0], content, _hash(content), _now(), "api"), ) conn.execute("UPDATE prompts SET active_revision_id=?, updated_at=? WHERE id=?", (cur.lastrowid, _now(), prow[0])) conn.commit() return {"id": cur.lastrowid, "prompt": call, "content_hash": _hash(content)} @router.post("/prompts/{call}/activate") async def activate_revision(call: str, payload: dict[str, Any]) -> dict: if call not in _SEEDS: return JSONResponse(status_code=404, content=_error("not_found", "unknown call")) rev_id = payload.get("revision_id") conn = get_connection() _seed_prompts(conn) prow = conn.execute("SELECT id FROM prompts WHERE name=?", (call,)).fetchone() row = conn.execute( "SELECT id FROM prompt_revisions WHERE id=? AND prompt_id=?", (rev_id, prow[0]) ).fetchone() if row is None: return JSONResponse(status_code=404, content=_error("not_found", "revision not found")) conn.execute("UPDATE prompts SET active_revision_id=?, updated_at=? WHERE id=?", (rev_id, _now(), prow[0])) conn.commit() return {"ok": True, "active_revision_id": rev_id} @router.get("/prompts/{call}") async def get_prompt(call: str) -> dict: if call not in _SEEDS: return JSONResponse(status_code=404, content=_error("not_found", "unknown call")) conn = get_connection() _seed_prompts(conn) prow = conn.execute("SELECT * FROM prompts WHERE name=?", (call,)).fetchone() revs = conn.execute( "SELECT id, content_hash, created_at, created_by FROM prompt_revisions WHERE prompt_id=? ORDER BY id", (prow[0],), ).fetchall() active = conn.execute( "SELECT content FROM prompt_revisions WHERE id=?", (prow["active_revision_id"],) ).fetchone() return { "name": call, "active_revision_id": prow["active_revision_id"], "active_content": active[0] if active else None, "revisions": [dict(r) for r in revs], } # ---------- Inference client (OpenAI-compatible, stdlib) ---------- class InferenceClient: def __init__(self, base_url: str, api_key: str, model: str, timeout: float = 60.0): self.base_url = base_url.rstrip("/") self.api_key = api_key self.model = model self.timeout = timeout def chat(self, messages: list[dict]) -> str: url = f"{self.base_url}/chat/completions" body = json.dumps({"model": self.model, "messages": messages}).encode("utf-8") req = urllib.request.Request( url, data=body, headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=self.timeout) as resp: data = json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: raise InferenceError(f"inference HTTP {e.code}") from e except urllib.error.URLError as e: raise InferenceConnectionError(f"inference unreachable: {e.reason}") from e return data["choices"][0]["message"]["content"] class InferenceError(Exception): pass class InferenceConnectionError(InferenceError): pass def _inference_creds(conn) -> tuple[str, str, str]: b = conn.execute("SELECT value_enc FROM settings WHERE key='inference_base_url'").fetchone() k = conn.execute("SELECT value_enc FROM settings WHERE key='inference_api_key'").fetchone() m = conn.execute("SELECT value FROM settings WHERE key='inference_model'").fetchone() return (decrypt(b[0]) if b and b[0] else ""), (decrypt(k[0]) if k and k[0] else ""), (m[0] if m and m[0] else "") def _enabled(conn, key: str) -> bool: row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() return bool(row and row[0] in ("true", "True", "1")) @router.post("/slabs/{slab_id}/inference/call1") async def call1(slab_id: str) -> dict: conn = get_connection() row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone() if row is None: return JSONResponse(status_code=404, content=_error("not_found", "Slab not found")) if not _enabled(conn, "inference_enabled"): return JSONResponse(status_code=409, content=_error("inference_disabled", "Inference is off")) base, key, model = _inference_creds(conn) if not base or not key: return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured")) client = InferenceClient(base, key, model) act = _active_prompt(conn, "call1") prompt_text = act[1] if act else CALL1_SEED # Build vision message with processed photos (METADATA-35: long edge <= 1024) + taxonomy. photos = conn.execute( "SELECT processed_blob FROM slab_photos WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8", (slab_id,), ).fetchall() image_parts = [_image_part_from_processed(p[0]) for p in photos] tax = _taxonomy_lists(conn) text = (f"{prompt_text}\n\nSYNCED TAXONOMY (only legal choices):\n{json.dumps(tax)}\n\n" f"SKU={row['sku']} length_in={row['length_in']} thickness_in={row['thickness_in']}") messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}] t0 = time.time() try: raw = client.chat(messages) except InferenceConnectionError as e: return JSONResponse(status_code=503, content=_error("inference_unreachable", str(e), {"recoverable": True})) except InferenceError as e: return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True})) latency = int((time.time() - t0) * 1000) parsed = _parse_json(raw) _log_call(conn, slab_id, 1, act[0] if act else None, model, messages, raw, latency) # Apply confidence gate (METADATA-45). suggestions = _gate(parsed) return {"ok": True, "suggestions": suggestions} def _taxonomy_lists(conn) -> dict: # METADATA-10: categories are leaves only; parents group only. Resolve groupers BY SLUG # (never hardcoded id, QUALITY-105) and match attribute names case-insensitively (the # live Woo attribute is 'Edge type', not 'Edge Type'). out = {"species_leaves": [], "wood_category_leaves": [], "edge_terms": [], "figure_terms": [], "grade_terms": [], "thickness_terms": [], "moisture_terms": [], "feat_tags": []} # species = leaves under the 'species' grouper sp = conn.execute("SELECT id FROM woo_categories WHERE slug='species'").fetchone() if sp: for r in conn.execute( "SELECT id,name FROM woo_categories WHERE parent_id=?", (sp[0],) ).fetchall(): out["species_leaves"].append({"id": r[0], "name": r[1]}) # wood categories = leaves under the 'wcat' grouper (leaves may sit under sub-groupers). wc = conn.execute("SELECT id FROM woo_categories WHERE slug='wcat'").fetchone() if wc: kids = [r[0] for r in conn.execute( "SELECT id FROM woo_categories WHERE parent_id=?", (wc[0],) ).fetchall()] leaf_ids = [] for cid in kids: has_kids = conn.execute( "SELECT 1 FROM woo_categories WHERE parent_id=? LIMIT 1", (cid,) ).fetchone() if has_kids: leaf_ids.extend(r[0] for r in conn.execute( "SELECT id FROM woo_categories WHERE parent_id=?", (cid,) ).fetchall()) else: leaf_ids.append(cid) if leaf_ids: q = ",".join("?" * len(leaf_ids)) for r in conn.execute( "SELECT id,name FROM woo_categories WHERE id IN (%s)" % q, leaf_ids ).fetchall(): out["wood_category_leaves"].append({"id": r[0], "name": r[1]}) # attribute terms, case-insensitive on the attribute name. Grade stays in the synced set # (owner reference) but the model is told not to suggest it (METADATA-40). for attr, key in (("Edge type", "edge_terms"), ("Figure", "figure_terms"), ("Grade", "grade_terms"), ("Thickness", "thickness_terms"), ("Moisture", "moisture_terms")): rows = conn.execute( "SELECT t.id,t.name FROM woo_attribute_terms t JOIN woo_attributes a ON t.attribute_id=a.id " "WHERE lower(a.name)=lower(?)", (attr,), ).fetchall() out[key] = [{"id": r[0], "name": r[1]} for r in rows] for r in conn.execute("SELECT id,name FROM woo_tags WHERE slug LIKE 'feat-%'").fetchall(): out["feat_tags"].append({"id": r[0], "name": r[1]}) return out def _parse_json(raw: str) -> dict: try: s = raw.strip() if s.startswith("```"): s = s.split("```")[1] if s.startswith("json"): s = s[4:] return json.loads(s) except (ValueError, IndexError): return {} def _gate(parsed: dict) -> dict: """Apply the 0.7 confidence gate; below-threshold fields are omitted (METADATA-45). Confidence is read from the flat '_confidence' keys; as a safety net it also understands the nested 'confidence: {: n}' shape some models emit, so a shape wobble cannot silently nuke every suggestion. Grade is not suggested (METADATA-40). """ # Map each field to its confidence key. The field name and the confidence key do NOT # share a stem (e.g. field 'species_id' -> key 'species_confidence'), so derive it # explicitly rather than f"{field}_confidence". conf_key = { "species_id": "species_confidence", "wood_category_ids": "wood_category_confidence", "edge_type_term_id": "edge_confidence", "figure_term_ids": "figure_confidence", } conf_obj = parsed.get("confidence") if isinstance(parsed.get("confidence"), dict) else {} def _conf(field: str) -> Any: k = conf_key.get(field, f"{field}_confidence") if k in conf_obj: return conf_obj[k] return parsed.get(k) def keep(field: str) -> Any: conf = _conf(field) if isinstance(conf, (int, float)) and conf >= _THRESHOLD and field in parsed: return parsed[field] return None out: dict[str, Any] = {} if v := keep("species_id"): out["species_id"] = v if v := keep("wood_category_ids"): out["wood_category_ids"] = v if v := keep("edge_type_term_id"): out["edge_type_term_id"] = v if v := keep("figure_term_ids"): out["figure_term_ids"] = v # grade intentionally not gated/suggested (METADATA-40: owner judgment). for k in ("character", "inclusions", "voids", "checks"): if parsed.get(k): out[k] = parsed[k] return out @router.post("/slabs/{slab_id}/inference/call2") async def call2(slab_id: str) -> dict: conn = get_connection() row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone() if row is None: return JSONResponse(status_code=404, content=_error("not_found", "Slab not found")) if not _enabled(conn, "content_llm_enabled"): return JSONResponse(status_code=409, content=_error("content_disabled", "Content generation is off")) base, key, model = _inference_creds(conn) if not base or not key: return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured")) client = InferenceClient(base, key, model) act = _active_prompt(conn, "call2") prompt_text = act[1] if act else CALL2_SEED photos = conn.execute( "SELECT processed_blob FROM slab_photos WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8", (slab_id,), ).fetchall() image_parts = [_image_part_from_processed(p[0]) for p in photos] brand = conn.execute("SELECT value FROM settings WHERE key='brand_voice'").fetchone() geo = conn.execute("SELECT value FROM settings WHERE key='geo_context'").fetchone() text = (f"{prompt_text}\n\nDIMENSION FACT BLOCK (exact, invent no numbers):\n" f"length={row['length_in']}in thickness={row['thickness_in']}in " f"avg_width={row['width_avg_in']}in width_min={row['width_min_in']}in width_max={row['width_max_in']}in " f"bdft={row['bdft']}\n\nBrand voice: {brand[0] if brand else ''}\nGeo: {geo[0] if geo else ''}") messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}] t0 = time.time() try: raw = client.chat(messages) except InferenceConnectionError as e: return JSONResponse(status_code=503, content=_error("inference_unreachable", str(e), {"recoverable": True})) except InferenceError as e: return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True})) latency = int((time.time() - t0) * 1000) _log_call(conn, slab_id, 2, act[0] if act else None, model, messages, raw, latency) # Store as description, mark content_source=llm. conn.execute("UPDATE slabs SET description=?, content_source='llm', updated_at=? WHERE id=?", (raw.strip(), _now(), slab_id)) conn.commit() return {"ok": True, "description": raw.strip()} def server_description(slab: dict) -> str: """Inference-off long description (METADATA-70).""" return ( f"A {slab.get('title', 'slab')} from the mill, measured at " f"{slab.get('length_in')}in long, {slab.get('width_avg_in')}in average width, and " f"{slab.get('thickness_in')}in thick ({slab.get('bdft')} bdft). " "Ready to ship." ) def _log_call(conn, slab_id: str, call: int, rev_id: int | None, model: str, request: list, response: str, latency_ms: int) -> None: conn.execute( "INSERT INTO inference_log(slab_id, call, prompt_revision_id, model, request_payload, response_payload, latency_ms, created_at) " "VALUES (?,?,?,?,?,?,?,?)", (slab_id, call, rev_id, model, json.dumps(request)[:200000], response, latency_ms, _now()), ) conn.commit() @router.post("/admin/test-inference") async def test_inference() -> dict: conn = get_connection() base, key, model = _inference_creds(conn) if not base or not key: return {"ok": False, "mode": "fail", "vision_capable": False, "detail": "Inference is not configured"} client = InferenceClient(base, key, model, timeout=20.0) t0 = time.time() try: client.chat([{"role": "user", "content": "Reply with the single word: ok"}]) except InferenceConnectionError as e: return {"ok": False, "mode": "fail", "vision_capable": False, "detail": str(e)} except InferenceError as e: return {"ok": False, "mode": "fail", "vision_capable": False, "detail": str(e)} detail = f"reachable in {int((time.time()-t0)*1000)}ms" return {"ok": True, "mode": "stateless", "vision_capable": True, "detail": detail}