diff --git a/backend/app.py b/backend/app.py index 155246a..1d18327 100644 --- a/backend/app.py +++ b/backend/app.py @@ -271,5 +271,8 @@ app.include_router(review_router) from .publish import router as publish_router app.include_router(publish_router) +from .inference import router as inference_router +app.include_router(inference_router) + # expose for tests __all__ = ["app", "__version__"] diff --git a/backend/inference.py b/backend/inference.py new file mode 100644 index 0000000..0022ec0 --- /dev/null +++ b/backend/inference.py @@ -0,0 +1,364 @@ +"""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, figure, and grade from the " + "supplied photos, choosing only from the supplied synced taxonomy lists, and return the " + "result fields as JSON: species_id, wood_category_ids, edge_type_term_id, figure_term_ids, " + "grade_term_id, character, inclusions, voids, checks. Report a confidence (0-1) for each " + "classified field. Map free-text observations to the supplied feature-tag list only. " + "Invent no category or term." +) +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: + out = {"species_leaves": [], "edge_terms": [], "figure_terms": [], "grade_terms": [], + "thickness_terms": [], "moisture_terms": [], "feat_tags": []} + for r in conn.execute("SELECT id,name FROM woo_categories WHERE parent_id IS NULL").fetchall(): + out["species_leaves"].append({"id": r[0], "name": r[1]}) + 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 a.name=?", + (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).""" + def keep(field: str, conf_key: str) -> Any: + conf = parsed.get(conf_key) + 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", "species_confidence"): + out["species_id"] = v + if v := keep("wood_category_ids", "wood_category_confidence"): + out["wood_category_ids"] = v + if v := keep("edge_type_term_id", "edge_confidence"): + out["edge_type_term_id"] = v + if v := keep("figure_term_ids", "figure_confidence"): + out["figure_term_ids"] = v + if v := keep("grade_term_id", "grade_confidence"): + out["grade_term_id"] = v + 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} diff --git a/backend/tests/test_inference.py b/backend/tests/test_inference.py new file mode 100644 index 0000000..9e2b2ce --- /dev/null +++ b/backend/tests/test_inference.py @@ -0,0 +1,128 @@ +"""S7 inference tests (METADATA-105/106, 45, 55, 70, SETTINGS-45).""" + +import base64 +import json +import os +import shutil +import tempfile + +import pytest +from fastapi.testclient import TestClient + +import backend.db as db_mod + + +@pytest.fixture +def env(monkeypatch): + tmp = tempfile.mkdtemp(prefix="inf_") + os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "i.db") + os.environ["SLAB_IMAGES_DIR"] = tmp + os.environ["WOO_BASE_URL"] = "https://x.com" + os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() + db_mod._reset_for_tests() + from backend.crypto import encrypt + + conn = db_mod.init_db() + conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_base_url',NULL,?)", (encrypt("https://infer.example.com"),)) + conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_api_key',NULL,?)", (encrypt("sk-test"),)) + conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_model','gpt-test',NULL)") + conn.commit() + from backend.app import app + + yield TestClient(app), conn + db_mod._reset_for_tests() + shutil.rmtree(tmp, ignore_errors=True) + + +def test_prompt_seed_and_revision(env): + client, conn = env + r = client.get("/api/v1/prompts/call1") + assert r.status_code == 200 + body = r.json() + assert body["active_content"] is not None + assert len(body["revisions"]) == 1 # seed only + + r = client.post("/api/v1/prompts/call1/revisions", json={"content": "revised prompt v2"}) + assert r.status_code == 201 + new_rev = r.json()["id"] + # new revision becomes active + r = client.get("/api/v1/prompts/call1") + assert r.json()["active_revision_id"] == new_rev + assert r.json()["active_content"] == "revised prompt v2" + assert len(r.json()["revisions"]) == 2 + + # activate back to seed + seed_rev = 1 + r = client.post("/api/v1/prompts/call1/activate", json={"revision_id": seed_rev}) + assert r.status_code == 200 + assert client.get("/api/v1/prompts/call1").json()["active_content"] is not None + + +def test_call1_confidence_gate(env, monkeypatch): + client, conn = env + # enable inference + conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','true',NULL)") + conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Black Walnut','black-walnut',NULL,100)") + conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-01',100,2,'calibrated',1,1,'t','t')") + conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')") + conn.commit() + from backend.inference import InferenceClient + + def fake_chat(self, messages): + return json.dumps({ + "species_id": 1, "species_confidence": 0.9, + "wood_category_ids": [1], "wood_category_confidence": 0.5, # below threshold + "grade_term_id": 7, "grade_confidence": 0.3, # below + "figure_term_ids": [2], "figure_confidence": 0.8, # at/above + }) + monkeypatch.setattr(InferenceClient, "chat", fake_chat) + r = client.post("/api/v1/slabs/s1/inference/call1") + assert r.status_code == 200, r.text + s = r.json()["suggestions"] + assert s.get("species_id") == 1 + assert s.get("figure_term_ids") == [2] + assert "wood_category_ids" not in s # gated out + assert "grade_term_id" not in s # gated out + # inference_log written with call=1 + assert conn.execute("SELECT call FROM inference_log").fetchone()["call"] == 1 + + +def test_call2_stores_description(env, monkeypatch): + client, conn = env + conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)") + conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-01',100,2,20,18,'Walnut Slab','ready',1,1,'t','t')") + conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')") + conn.commit() + from backend.inference import InferenceClient + + monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: "

A fine walnut slab.

") + r = client.post("/api/v1/slabs/s1/inference/call2") + assert r.status_code == 200, r.text + assert r.json()["description"] == "

A fine walnut slab.

" + row = conn.execute("SELECT description, content_source FROM slabs WHERE id='s1'").fetchone() + assert row["description"] == "

A fine walnut slab.

" + assert row["content_source"] == "llm" + + +def test_inference_off_call1_409(env): + client, conn = env + conn.execute("INSERT INTO slabs(id,sku,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-01','calibrated',1,1,'t','t')") + conn.commit() + r = client.post("/api/v1/slabs/s1/inference/call1") + assert r.status_code == 409 # inference_enabled default off + + +def test_server_description_inference_off(env): + from backend.inference import server_description + + d = server_description({"title": "Figured Walnut Slab", "length_in": 114.0, "width_avg_in": 24.0, "thickness_in": 1.75, "bdft": 18.4}) + assert "114.0in" in d and "18.4" in d + + +def test_test_inference_unconfigured(env): + client, conn = env + conn.execute("UPDATE settings SET value_enc=NULL WHERE key='inference_api_key'") + conn.commit() + r = client.post("/api/v1/admin/test-inference") + assert r.status_code == 200 + assert r.json()["ok"] is False and r.json()["mode"] == "fail" diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index b19ba3f..692f8f8 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -14,6 +14,7 @@ import SlabList from './create/slabs/SlabList.svelte'; import PhotoAttach from './create/photos/PhotoAttach.svelte'; import Review from './create/review/Review.svelte'; import Publish from './create/publish/Publish.svelte'; +import GenerateText from './create/inference/GenerateText.svelte'; const gate = typeof window !== 'undefined' ? supported() : 'window unavailable'; const session: CaptureSession = createCaptureSession(); @@ -26,6 +27,7 @@ import Publish from './create/publish/Publish.svelte'; let showPhotos = false; let showReview = false; let showPublish = false; + let showGenerate = false; const unsubCap = session.capture.subscribe((c) => { cap = c; @@ -104,6 +106,7 @@ import Publish from './create/publish/Publish.svelte'; + {#if showSettings} @@ -120,6 +123,9 @@ import Publish from './create/publish/Publish.svelte'; {#if showPublish} {/if} + {#if showGenerate} + + {/if} {/if} diff --git a/frontend/src/create/inference/GenerateText.svelte b/frontend/src/create/inference/GenerateText.svelte new file mode 100644 index 0000000..66baa50 --- /dev/null +++ b/frontend/src/create/inference/GenerateText.svelte @@ -0,0 +1,103 @@ + + +
+

Generate text

+

+ Content generation: {contentEnabled ? 'on' : 'off'} · endpoint: {reachable ? 'reachable' : 'unreachable'} +

+ {#if !contentEnabled || !reachable} +

The Generate text control is disabled: it appears only when content generation is + enabled and the inference endpoint is reachable.

+ {:else} +
+ + +
+
+ + +
+ {#if error}

{error}

{/if} + {#if description}

{description}

{/if} + {#if call1Suggestions} +
{JSON.stringify(call1Suggestions, null, 2)}
+ {/if} + {/if} + +
+ +