feat(inference): S7 Call 1/Call 2 + prompt revision REST + test-inference + Generate text UI
- backend/inference.py: prompt seed (rev 0) + append-only revision REST
(POST /prompts/{call}/revisions, /activate, GET; METADATA-105/106). Call 1 vision
classification (1024px processed photos + synced taxonomy, 0.7 confidence gate,
METADATA-35/40/45), Call 2 long description (user-triggered, METADATA-55/60/65),
inference_log per call (METADATA-66), inference-off server_description (METADATA-70),
test-inference probe (SETTINGS-45). Stdlib urllib, no new deps.
- frontend: create/inference/GenerateText.svelte (control visible only when content
enabled + endpoint reachable, METADATA-110; Call 2 + Call 1 buttons) + dev toggle.
- tests: 6 inference tests (seed+revision, call1 gate, call2 stores desc, off->409,
server desc, test-inference unconfigured).
Verified: pytest 54 passed; gates exit 0; npm build ok.
This commit is contained in:
@@ -271,5 +271,8 @@ app.include_router(review_router)
|
|||||||
from .publish import router as publish_router
|
from .publish import router as publish_router
|
||||||
app.include_router(publish_router)
|
app.include_router(publish_router)
|
||||||
|
|
||||||
|
from .inference import router as inference_router
|
||||||
|
app.include_router(inference_router)
|
||||||
|
|
||||||
# expose for tests
|
# expose for tests
|
||||||
__all__ = ["app", "__version__"]
|
__all__ = ["app", "__version__"]
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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: "<p>A fine walnut slab.</p>")
|
||||||
|
r = client.post("/api/v1/slabs/s1/inference/call2")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["description"] == "<p>A fine walnut slab.</p>"
|
||||||
|
row = conn.execute("SELECT description, content_source FROM slabs WHERE id='s1'").fetchone()
|
||||||
|
assert row["description"] == "<p>A fine walnut slab.</p>"
|
||||||
|
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"
|
||||||
@@ -14,6 +14,7 @@ import SlabList from './create/slabs/SlabList.svelte';
|
|||||||
import PhotoAttach from './create/photos/PhotoAttach.svelte';
|
import PhotoAttach from './create/photos/PhotoAttach.svelte';
|
||||||
import Review from './create/review/Review.svelte';
|
import Review from './create/review/Review.svelte';
|
||||||
import Publish from './create/publish/Publish.svelte';
|
import Publish from './create/publish/Publish.svelte';
|
||||||
|
import GenerateText from './create/inference/GenerateText.svelte';
|
||||||
|
|
||||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||||
const session: CaptureSession = createCaptureSession();
|
const session: CaptureSession = createCaptureSession();
|
||||||
@@ -26,6 +27,7 @@ import Publish from './create/publish/Publish.svelte';
|
|||||||
let showPhotos = false;
|
let showPhotos = false;
|
||||||
let showReview = false;
|
let showReview = false;
|
||||||
let showPublish = false;
|
let showPublish = false;
|
||||||
|
let showGenerate = false;
|
||||||
|
|
||||||
const unsubCap = session.capture.subscribe((c) => {
|
const unsubCap = session.capture.subscribe((c) => {
|
||||||
cap = c;
|
cap = c;
|
||||||
@@ -104,6 +106,7 @@ import Publish from './create/publish/Publish.svelte';
|
|||||||
<button on:click={() => (showPhotos = !showPhotos)}>Photos (dev)</button>
|
<button on:click={() => (showPhotos = !showPhotos)}>Photos (dev)</button>
|
||||||
<button on:click={() => (showReview = !showReview)}>Review (dev)</button>
|
<button on:click={() => (showReview = !showReview)}>Review (dev)</button>
|
||||||
<button on:click={() => (showPublish = !showPublish)}>Publish (dev)</button>
|
<button on:click={() => (showPublish = !showPublish)}>Publish (dev)</button>
|
||||||
|
<button on:click={() => (showGenerate = !showGenerate)}>Generate (dev)</button>
|
||||||
</div>
|
</div>
|
||||||
{#if showSettings}
|
{#if showSettings}
|
||||||
<Settings />
|
<Settings />
|
||||||
@@ -120,6 +123,9 @@ import Publish from './create/publish/Publish.svelte';
|
|||||||
{#if showPublish}
|
{#if showPublish}
|
||||||
<Publish />
|
<Publish />
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if showGenerate}
|
||||||
|
<GenerateText />
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let slabId = '';
|
||||||
|
let contentEnabled = false;
|
||||||
|
let reachable = false;
|
||||||
|
let description: string | null = null;
|
||||||
|
let call1Suggestions: Record<string, unknown> | null = null;
|
||||||
|
let error = '';
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
async function checkReadiness() {
|
||||||
|
contentEnabled = false;
|
||||||
|
reachable = false;
|
||||||
|
try {
|
||||||
|
const [inf, test] = await Promise.all([
|
||||||
|
fetch('/api/v1/settings').then((r) => r.json()),
|
||||||
|
fetch('/api/v1/admin/test-inference', { method: 'POST' }).then((r) => r.json()),
|
||||||
|
]);
|
||||||
|
contentEnabled = inf.content_llm_enabled === 'true' || inf.content_llm_enabled === true;
|
||||||
|
reachable = test.ok === true;
|
||||||
|
} catch {
|
||||||
|
reachable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
if (!slabId) {
|
||||||
|
error = 'Enter a slab id.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
description = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2`, { method: 'POST' });
|
||||||
|
const body = await res.json();
|
||||||
|
if (res.ok) description = body.description;
|
||||||
|
else error = body.error?.message || `call2 failed (${res.status})`;
|
||||||
|
} catch {
|
||||||
|
error = 'network error';
|
||||||
|
}
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function classify() {
|
||||||
|
if (!slabId) {
|
||||||
|
error = 'Enter a slab id.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
call1Suggestions = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call1`, { method: 'POST' });
|
||||||
|
const body = await res.json();
|
||||||
|
if (res.ok) call1Suggestions = body.suggestions;
|
||||||
|
else error = body.error?.message || `call1 failed (${res.status})`;
|
||||||
|
} catch {
|
||||||
|
error = 'network error';
|
||||||
|
}
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkReadiness();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="inference">
|
||||||
|
<h2>Generate text</h2>
|
||||||
|
<p class="state">
|
||||||
|
Content generation: {contentEnabled ? 'on' : 'off'} · endpoint: {reachable ? 'reachable' : 'unreachable'}
|
||||||
|
</p>
|
||||||
|
{#if !contentEnabled || !reachable}
|
||||||
|
<p class="note">The Generate text control is disabled: it appears only when content generation is
|
||||||
|
enabled and the inference endpoint is reachable.</p>
|
||||||
|
{:else}
|
||||||
|
<div>
|
||||||
|
<label>Slab ID</label>
|
||||||
|
<input bind:value={slabId} placeholder="uuid" />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button on:click={generate} disabled={busy}>{busy ? 'Working…' : 'Generate description (Call 2)'}</button>
|
||||||
|
<button on:click={classify} disabled={busy}>Classify metadata (Call 1)</button>
|
||||||
|
</div>
|
||||||
|
{#if error}<p class="err">{error}</p>{/if}
|
||||||
|
{#if description}<p class="desc">{description}</p>{/if}
|
||||||
|
{#if call1Suggestions}
|
||||||
|
<pre class="sug">{JSON.stringify(call1Suggestions, null, 2)}</pre>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<button class="mini" on:click={checkReadiness}>Recheck readiness</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.inference { max-width: 560px; color: #eee; }
|
||||||
|
label { display: block; margin: 8px 0 4px; }
|
||||||
|
input { width: 100%; padding: 6px; }
|
||||||
|
.row { margin-top: 12px; display: flex; gap: 8px; }
|
||||||
|
.state { color: #aaa; }
|
||||||
|
.note { color: #e8a; }
|
||||||
|
.err { color: #e88; }
|
||||||
|
.desc { margin-top: 12px; }
|
||||||
|
.sug { background: #1c1c1c; padding: 8px; }
|
||||||
|
.mini { margin-top: 12px; font-size: 12px; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user