49c22c9385
Clean re-implementation of the full product (not a port of the old M3 tree): - P0 foundation: 13-table schema + per-call sqlite factory (thread-safe), FastAPI app (health, JSON logging + secret redaction, CORS, startup guard). - P1 M1 Capture: mask/sheetKeep, geometry/rect (min-area rect), measure, png crop/inventory/overlay, EXIF decodeOrientedBitmap. - P2 M2 Photos: stateful capture session (batch, pick-cal, apply mask/crop to batch) + photos-ready lineup. - P3 M3 Create: real backend (crypto, woo client, taxonomy sync, pricing, inference Call1/Call2, review, publish, settings, slabs, photos) + real FE review/publish/settings screens consuming the API (no hardcoded Woo ids). - P4 gate exit: check_requirements + check_coverage both exit 0 (137/155 IDs, 0 gaps); backend 130/130, frontend 150/150, build green. Cite: docs/REQUIREMENTS.md (155 rows), docs/MILESTONES.md M1-M3.
183 lines
8.4 KiB
Python
183 lines
8.4 KiB
Python
"""Prompt revision REST (METADATA-106): append/activate/get for call1/call2.
|
||
|
||
Seed prompts live in inference (CALL1_SEED / CALL2_SEED); the shipped seed is
|
||
activated when nothing is active or the active revision is still seed-authored,
|
||
so deploys pick up seed updates without clobbering an owner revision.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
from datetime import datetime, timezone
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter
|
||
from fastapi.responses import JSONResponse
|
||
|
||
from .db import get_connection
|
||
|
||
router = APIRouter(prefix="/api/v1")
|
||
|
||
# Locked Call 1 seed (#86 / METADATA-40): expert helper + closed Woo lists only.
|
||
CALL1_SEED = (
|
||
"You are a wood identification expert for retail lumber slabs. Judge only from the photos "
|
||
"and the LISTS below. Use list ids only — never invent a name or id.\n"
|
||
"\n"
|
||
"Work in this order: (1) species, (2) wood categories, (3) edge type, (4) figure, "
|
||
"(5) feature tags. Decide wood categories before edge type — edge rules depend on product form.\n"
|
||
"\n"
|
||
"For each field, pick from its list and give a confidence 0–1 (1 = certain, 0 = no idea). "
|
||
"Prefer null or [] over a weak guess.\n"
|
||
"\n"
|
||
"Species — which tree species is this slab. Match grain, color, and figure to the species "
|
||
"list. If more than one entry is plausible, return only the single highest-confidence match "
|
||
"(one id). Null only when none are credible.\n"
|
||
"\n"
|
||
"Wood categories — product form / how it will be sold (live edge, dimensional, cookies & "
|
||
"burl slices, turning blanks, etc.). Choose every wood_categories id that clearly fits; "
|
||
"often one, sometimes more. [] if none fit. Finish this field before edge type.\n"
|
||
"\n"
|
||
"Edge type — the long-edge mill profile. One id from edge_terms, or null only if none fit.\n"
|
||
" Meanings (match the closest list name):\n"
|
||
" • Square / square edge — both long edges milled straight (2 milled long edges).\n"
|
||
" • Live one side / Live Edge 1 side — one long edge milled, the other natural bark or "
|
||
"waney (1 milled long edge).\n"
|
||
" • Live both / Live edge 2 sides — neither long edge milled; both natural "
|
||
"(0 milled long edges).\n"
|
||
" • Wild edge — use when the three mill-count cases do not fit, including cookies and burl "
|
||
"cuts/slices (no conventional slab long-edge pair). Prefer Wild for those forms rather than null.\n"
|
||
"\n"
|
||
"Figure — visible figure in the wood (curl, quilt, spalt, plain, burl, …). Zero or more ids "
|
||
"from figure_terms that clearly apply.\n"
|
||
"\n"
|
||
"Feature tags — defects and character marks the store tags for shoppers (knots, checks, "
|
||
"inclusions, …). Zero or more ids from feature_tags. If that list is empty, return [].\n"
|
||
"\n"
|
||
"Return one JSON object with exactly these keys:\n"
|
||
'{"species_id": <id|null>, "species_confidence": <0-1>, '
|
||
'"wood_category_ids": [<id>,...], "wood_category_confidence": <0-1>, '
|
||
'"edge_type_term_id": <id|null>, "edge_confidence": <0-1>, '
|
||
'"figure_term_ids": [<id>,...], "figure_confidence": <0-1>, '
|
||
'"feature_tag_ids": [<id>,...], "feature_tag_confidence": <0-1>}'
|
||
)
|
||
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}
|
||
_SEEDS = {"call1": CALL1_SEED, "call2": CALL2_SEED}
|
||
|
||
|
||
def _error(code: str, message: str, detail: Any = None) -> dict:
|
||
return {"error": {"code": code, "message": message, "detail": detail}}
|
||
|
||
|
||
def _now() -> str:
|
||
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:
|
||
"""Ensure each call has a prompt row and the shipped seed revision."""
|
||
for name, seed in _SEEDS.items():
|
||
prow = conn.execute("SELECT id, active_revision_id FROM prompts WHERE name=?", (name,)).fetchone()
|
||
if prow is None:
|
||
cur = conn.execute(
|
||
"INSERT INTO prompts(name, active_revision_id, created_at, updated_at) VALUES (?,?,?,?)",
|
||
(name, None, _now(), _now()),
|
||
)
|
||
pid, active_id = cur.lastrowid, None
|
||
else:
|
||
pid, active_id = prow[0], prow[1]
|
||
h = _hash(seed)
|
||
existing = conn.execute(
|
||
"SELECT id FROM prompt_revisions WHERE prompt_id=? AND content_hash=?", (pid, h)
|
||
).fetchone()
|
||
seed_rev_id = existing[0] if existing else conn.execute(
|
||
"INSERT INTO prompt_revisions(prompt_id, content, content_hash, created_at, created_by) VALUES (?,?,?,?,?)",
|
||
(pid, seed, h, _now(), "seed"),
|
||
).lastrowid
|
||
activate = active_id is None
|
||
if not activate and active_id != seed_rev_id:
|
||
act = conn.execute("SELECT created_by FROM prompt_revisions WHERE id=?", (active_id,)).fetchone()
|
||
activate = act is None or act[0] == "seed"
|
||
if activate and active_id != seed_rev_id:
|
||
conn.execute(
|
||
"UPDATE prompts SET active_revision_id=?, updated_at=? WHERE id=?",
|
||
(seed_rev_id, _now(), pid),
|
||
)
|
||
|
||
|
||
def active_prompt(conn, call: str) -> tuple[int, str] | None:
|
||
"""(revision_id, content) for the call's active prompt revision, or 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]
|
||
|
||
|
||
@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()
|
||
if conn.execute("SELECT id FROM prompt_revisions WHERE id=? AND prompt_id=?", (rev_id, prow[0])).fetchone() 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()
|
||
if prow["active_revision_id"] is not None
|
||
else None
|
||
)
|
||
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],
|
||
}
|