"""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 json import time import urllib.error import urllib.request from typing import Any from fastapi import APIRouter from fastapi.responses import JSONResponse from .db import get_connection from .crypto import decrypt router = APIRouter(prefix="/api/v1") 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 (1024px) + taxonomy lists. 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 = [] for p in photos: image_parts.append({ "type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64.b64encode(p[0]).decode()}"}, }) 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 = [ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64.b64encode(p[0]).decode()}"}} 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}