8a8a89ed3e
- 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.
279 lines
9.0 KiB
Python
279 lines
9.0 KiB
Python
"""S0 FastAPI app foundation (APP-30, APP-45, APP-55, APP-65, SETTINGS-10/25, OPS-25).
|
|
Base /api/v1; health at /api/health (non-200 on storage not writable).
|
|
Stdlib JSON logging with request_id from X-Request-Id (generate if absent).
|
|
Startup guards refuse on bad WOO_BASE_URL or missing SLAB_AES_KEY.
|
|
No secrets in logs. Minimal, no S1+.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import uuid
|
|
from contextvars import ContextVar
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
|
|
from .db import get_connection, init_db, probe_images_writable, probe_writable
|
|
from . import settings as settings_mod
|
|
|
|
__version__ = "0.5.0-m3-s0"
|
|
|
|
# --- Structured JSON logging (stdlib only) ---
|
|
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
|
|
|
|
|
|
class _JsonFormatter(logging.Formatter):
|
|
"""JSON lines; redacts any secret-looking content (APP-65)."""
|
|
|
|
def format(self, record: logging.LogRecord) -> str:
|
|
rid = getattr(record, "request_id", None) or request_id_var.get("")
|
|
msg = record.getMessage()
|
|
# Redact secrets: never log creds, keys, aes material
|
|
lower = msg.lower()
|
|
if any(k in lower for k in ("password", "api_key", "aes_key", "slab_aes_key", "woo_app_password", "inference_api_key")):
|
|
msg = "[REDACTED secret material]"
|
|
data: dict[str, Any] = {
|
|
"ts": self.formatTime(record, self.datefmt),
|
|
"level": record.levelname,
|
|
"logger": record.name,
|
|
"request_id": rid,
|
|
"msg": msg,
|
|
}
|
|
if record.exc_info:
|
|
data["exc"] = self.formatException(record.exc_info)
|
|
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
|
|
def _configure_logging() -> None:
|
|
level = os.getenv("LOG_LEVEL", "INFO").upper()
|
|
handler = logging.StreamHandler(sys.stdout)
|
|
handler.setFormatter(_JsonFormatter())
|
|
root = logging.getLogger()
|
|
root.handlers.clear()
|
|
root.addHandler(handler)
|
|
root.setLevel(getattr(logging, level, logging.INFO))
|
|
# uvicorn access logs also json? for S0 keep simple; they inherit
|
|
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
|
|
|
|
|
_configure_logging()
|
|
logger = logging.getLogger("slab.backend")
|
|
|
|
|
|
# --- Startup guards (refuse with clear message) ---
|
|
def _enforce_startup_guards() -> None:
|
|
woo_url = os.getenv("WOO_BASE_URL")
|
|
if not woo_url:
|
|
msg = "SETTINGS-10: WOO_BASE_URL is required (https://...)"
|
|
logger.error(msg)
|
|
raise RuntimeError(msg)
|
|
if not woo_url.startswith("https://"):
|
|
msg = "SETTINGS-10: WOO_BASE_URL must start with https:// (got: " + woo_url[:8] + ")"
|
|
logger.error(msg)
|
|
raise RuntimeError(msg)
|
|
if not os.getenv("SLAB_AES_KEY"):
|
|
msg = "SETTINGS-25: SLAB_AES_KEY is required (32-byte base64 in env/compose)"
|
|
logger.error(msg)
|
|
raise RuntimeError(msg)
|
|
# db/images envs are required by db.py on use; guards here only per spec
|
|
|
|
|
|
_enforce_startup_guards()
|
|
|
|
|
|
# --- Request ID middleware (threads X-Request-Id or generates) --
|
|
|
|
|
|
class RequestIdMiddleware(BaseHTTPMiddleware):
|
|
async def dispatch(self, request: Request, call_next):
|
|
rid = request.headers.get("X-Request-Id") or str(uuid.uuid4())
|
|
token = request_id_var.set(rid)
|
|
try:
|
|
response = await call_next(request)
|
|
response.headers["X-Request-Id"] = rid
|
|
return response
|
|
finally:
|
|
request_id_var.reset(token)
|
|
|
|
|
|
# --- App ---
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
logger.info("startup: applying schema (idempotent)")
|
|
try:
|
|
init_db()
|
|
logger.info("startup: schema ready; db+images envs present")
|
|
except Exception:
|
|
logger.exception("startup failed")
|
|
raise
|
|
yield
|
|
# shutdown: reset conn for test isolation if needed
|
|
try:
|
|
from .db import _reset_for_tests
|
|
_reset_for_tests()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
app = FastAPI(
|
|
title="SlabUploader",
|
|
version=__version__,
|
|
docs_url=None,
|
|
redoc_url=None,
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=False,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
app.add_middleware(RequestIdMiddleware)
|
|
|
|
|
|
@app.exception_handler(HTTPException)
|
|
async def _unwrap_error(request: Request, exc: HTTPException):
|
|
if isinstance(exc.detail, dict) and "error" in exc.detail:
|
|
return JSONResponse(status_code=exc.status_code, content=exc.detail)
|
|
return JSONResponse(status_code=exc.status_code, content={"error": {"code": "validation" if exc.status_code == 400 else "internal", "message": str(exc.detail), "detail": None}})
|
|
|
|
|
|
def _error(code: str, message: str, detail: Any = None) -> dict:
|
|
return {"error": {"code": code, "message": message, "detail": detail}}
|
|
|
|
|
|
@app.get("/api/health")
|
|
async def health() -> dict[str, Any]:
|
|
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file)."""
|
|
db_ok = probe_writable()
|
|
img_ok = probe_images_writable()
|
|
if not (db_ok and img_ok):
|
|
logger.warning("health: storage not writable", extra={"request_id": request_id_var.get("")})
|
|
raise HTTPException(status_code=503, detail="storage not writable")
|
|
return {
|
|
"status": "ok",
|
|
"version": __version__,
|
|
"woo_reachable": False, # S0 stub; S4 will implement real check
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/meta")
|
|
async def meta() -> dict[str, Any]:
|
|
"""Per API.md §3."""
|
|
return {"version": __version__}
|
|
|
|
|
|
@app.get("/api/v1/settings")
|
|
async def get_settings() -> dict[str, Any]:
|
|
return settings_mod.get_settings()
|
|
|
|
|
|
@app.put("/api/v1/settings")
|
|
async def put_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
|
return settings_mod.update_settings(payload)
|
|
|
|
|
|
from . import taxonomy as taxonomy_mod
|
|
from .woo import (
|
|
WooClient,
|
|
WooAuthError,
|
|
WooConnectionError,
|
|
WooCredentialsMissing,
|
|
WooError,
|
|
)
|
|
|
|
|
|
def _woo_credentials() -> tuple[str, str | None, str | None]:
|
|
conn = get_connection()
|
|
base = os.environ.get("WOO_BASE_URL", "")
|
|
user_row = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()
|
|
pass_row = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()
|
|
return base, (user_row[0] if user_row else None), (pass_row[0] if pass_row else None)
|
|
|
|
|
|
@app.post("/api/v1/settings/test-woo")
|
|
async def test_woo() -> dict[str, Any]:
|
|
base, username, enc_password = _woo_credentials()
|
|
try:
|
|
client = WooClient.from_settings(base, username, enc_password)
|
|
except WooCredentialsMissing as e:
|
|
return {
|
|
"ok": False,
|
|
"detail": str(e),
|
|
"taxonomy_anchor": None,
|
|
"taxonomy_last_sync_at": None,
|
|
}
|
|
try:
|
|
result = taxonomy_mod.sync(client, get_connection())
|
|
except WooAuthError as e:
|
|
return {"ok": False, "detail": f"auth rejected by store: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
|
|
except WooConnectionError as e:
|
|
return {"ok": False, "detail": f"store unreachable: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
|
|
except WooError as e:
|
|
return {"ok": False, "detail": str(e), "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
|
|
return {
|
|
"ok": True,
|
|
"detail": "taxonomy synced",
|
|
"taxonomy_anchor": result["taxonomy_anchor"],
|
|
"taxonomy_last_sync_at": result["taxonomy_last_sync_at"],
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/admin/taxonomy")
|
|
async def get_taxonomy() -> dict[str, Any]:
|
|
return taxonomy_mod.get_cached(get_connection())
|
|
|
|
|
|
@app.post("/api/v1/admin/taxonomy/sync")
|
|
async def post_taxonomy_sync() -> dict[str, Any]:
|
|
base, username, enc_password = _woo_credentials()
|
|
try:
|
|
client = WooClient.from_settings(base, username, enc_password)
|
|
except WooCredentialsMissing as e:
|
|
return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e)))
|
|
try:
|
|
result = taxonomy_mod.sync(client, get_connection())
|
|
except WooError as e:
|
|
return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e)))
|
|
return {
|
|
"ok": True,
|
|
"detail": "taxonomy synced",
|
|
"taxonomy_anchor": result["taxonomy_anchor"],
|
|
"taxonomy_last_sync_at": result["taxonomy_last_sync_at"],
|
|
}
|
|
|
|
|
|
# expose for tests
|
|
|
|
from .slabs import router as slabs_router
|
|
app.include_router(slabs_router)
|
|
|
|
from .photos import router as photos_router
|
|
app.include_router(photos_router)
|
|
|
|
from .pricing import router as pricing_router
|
|
app.include_router(pricing_router)
|
|
|
|
from .review import router as review_router
|
|
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__"]
|