Add per-request AI logging, DB batch queue, WS entity updates, and UI polish

- log_thread.py: thread-safe ContextVar bridge so executor threads can log
  individual LLM calls and archive searches back to the event loop
- ai_log.py: init_thread_logging(), notify_entity_update(); WS now pushes
  entity_update messages when book data changes after any plugin or batch run
- batch.py: replace batch_pending.json with batch_queue SQLite table;
  run_batch_consumer() reads queue dynamically so new books can be added
  while batch is running; add_to_queue() deduplicates
- migrate.py: fix _migrate_v1 (clear-on-startup bug); add _migrate_v2 for
  batch_queue table
- _client.py / archive.py / identification.py: wrap each LLM API call and
  archive search with log_thread start/finish entries
- api.py: POST /api/batch returns {already_running, added}; notify_entity_update
  after identify pipeline
- models.default.yaml: strengthen ai_identify confidence-scoring instructions;
  warn against placeholder data
- detail-render.js: book log entries show clickable ID + spine thumbnail;
  book spine/title images open full-screen popup
- events.js: batch-start handles already_running+added; open-img-popup action
- init.js: entity_update WS handler; image popup close listeners
- overlays.css / index.html: full-screen image popup overlay
- eslint.config.js: add new globals; fix no-redeclare/no-unused-vars for
  multi-file global architecture; all lint errors resolved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 12:10:54 +03:00
parent fd32be729f
commit b94f222c96
41 changed files with 2566 additions and 586 deletions

View File

@@ -8,9 +8,10 @@ No SQL here; no business logic here.
import asyncio
import dataclasses
import json
import time
from typing import Any, TypeVar
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
from fastapi import APIRouter, File, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
from mashumaro.codecs import BasicDecoder
import db
@@ -55,8 +56,12 @@ async def _parse(decoder: BasicDecoder[_T], request: Request) -> _T:
@router.get("/api/config")
def api_config() -> dict[str, Any]:
cfg = get_config()
logic.set_max_entries(cfg.ui.ai_log_max_entries)
return {
"boundary_grab_px": get_config().ui.boundary_grab_px,
"boundary_grab_px": cfg.ui.boundary_grab_px,
"spine_padding_pct": cfg.ui.spine_padding_pct,
"ai_log_max_entries": cfg.ui.ai_log_max_entries,
"plugins": plugin_registry.get_manifest(),
}
@@ -333,8 +338,9 @@ async def book_photo(book_id: str, image: UploadFile = File(...)) -> dict[str, A
@router.get("/api/books/{book_id}/spine")
def book_spine(book_id: str) -> Any:
padding = get_config().ui.spine_padding_pct
with db.connection() as c:
path, crop = book_spine_source(c, book_id)
path, crop = book_spine_source(c, book_id, padding)
return serve_crop(path, crop)
@@ -365,6 +371,26 @@ async def process_book(book_id: str) -> dict[str, Any]:
return dataclasses.asdict(book)
@router.post("/api/books/{book_id}/identify")
async def identify_book(book_id: str) -> dict[str, Any]:
"""Run the full identification pipeline (VLM -> archives -> main model) for a single book."""
with db.connection() as c:
if not db.get_book(c, book_id):
raise HTTPException(404, "Book not found")
loop = asyncio.get_event_loop()
started = time.time()
entry_id = logic.log_start("identify_pipeline", "books", book_id, "pipeline", book_id)
try:
result = await loop.run_in_executor(logic.batch_executor, logic.run_identify_pipeline, book_id)
logic.log_finish(entry_id, "ok", result.ai_title or "", started)
except Exception as exc:
logic.log_finish(entry_id, "error", str(exc), started)
raise
result_dict = dataclasses.asdict(result)
logic.notify_entity_update("books", book_id, result_dict)
return result_dict
# ── Universal plugin endpoint ─────────────────────────────────────────────────
@@ -393,14 +419,15 @@ async def run_plugin(entity_type: str, entity_id: str, plugin_id: str) -> dict[s
@router.post("/api/batch")
async def start_batch() -> dict[str, Any]:
if logic.batch_state["running"]:
return {"already_running": True}
with db.connection() as c:
ids = db.get_unidentified_book_ids(c)
if not ids:
return {"started": False, "reason": "no_unidentified_books"}
asyncio.create_task(logic.run_batch(ids))
return {"started": True, "total": len(ids)}
added = logic.add_to_queue(ids)
if logic.batch_state["running"]:
return {"already_running": True, "added": added}
asyncio.create_task(logic.run_batch_consumer())
return {"started": True, "added": added}
@router.get("/api/batch/status")
@@ -408,6 +435,48 @@ def batch_status() -> dict[str, Any]:
return dict(logic.batch_state)
@router.websocket("/ws/batch")
async def ws_batch(websocket: WebSocket) -> None:
"""Stream batch_state snapshots as JSON until the batch finishes or the client disconnects.
Sends the current state immediately on connect, then pushes each subsequent
update until running transitions to false.
"""
await websocket.accept()
q = logic.subscribe_batch()
try:
await websocket.send_json(dict(logic.batch_state))
while logic.batch_state["running"]:
state = await q.get()
await websocket.send_json(state)
if not state["running"]:
break
except WebSocketDisconnect:
pass
finally:
logic.unsubscribe_batch(q)
@router.websocket("/ws/ai-log")
async def ws_ai_log(websocket: WebSocket) -> None:
"""Stream AI request log entries as JSON.
Sends a snapshot of all current entries on connect, then pushes each new
update message until the client disconnects.
"""
await websocket.accept()
q = logic.subscribe_log()
try:
await websocket.send_json({"type": "snapshot", "entries": logic.get_snapshot()})
while True:
msg = await q.get()
await websocket.send_json(msg)
except WebSocketDisconnect:
pass
finally:
logic.unsubscribe_log(q)
# ── Reorder ───────────────────────────────────────────────────────────────────
_REORDER_TABLES = {"rooms", "cabinets", "shelves", "books"}

View File

@@ -8,18 +8,21 @@ Usage:
poetry run serve
"""
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
import logic
import plugins as plugin_registry
from api import router
from config import get_config, load_config
from db import init_db
from files import IMAGES_DIR, init_dirs
from errors import BadRequestError, ConfigError, ImageReadError, NotFoundError
from migrate import run_migration
@asynccontextmanager
@@ -27,8 +30,22 @@ async def lifespan(app: FastAPI):
load_config()
init_dirs()
init_db()
run_migration()
plugin_registry.load_plugins(get_config())
cfg = get_config()
logic.load_from_db(cfg.ui.ai_log_max_entries)
logic.init_thread_logging(asyncio.get_running_loop())
pending = logic.get_pending_batch()
if pending:
asyncio.create_task(logic.run_batch_consumer())
yield
# Graceful shutdown: cancel the running batch task so uvicorn isn't blocked,
# then release executor threads (running threads finish naturally in the background).
task = logic.get_batch_task()
if task is not None and not task.done():
task.cancel()
logic.batch_executor.shutdown(wait=False, cancel_futures=True)
logic.archive_executor.shutdown(wait=False, cancel_futures=True)
app = FastAPI(lifespan=lifespan)

View File

@@ -53,6 +53,7 @@ class AIFunctionConfig:
max_image_px: int = 1600
confidence_threshold: float = 0.8
name: str = ""
is_vlm: bool = False
@dataclass
@@ -76,6 +77,8 @@ class FunctionsConfig:
@dataclass
class UIConfig:
boundary_grab_px: int = 14
spine_padding_pct: float = 0.10
ai_log_max_entries: int = 100
@dataclass

115
src/db.py
View File

@@ -5,11 +5,13 @@ No file I/O, no config, no business logic. All SQL lives here.
import json
import sqlite3
import time
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Any
from mashumaro.codecs import BasicDecoder
@@ -67,7 +69,24 @@ CREATE TABLE IF NOT EXISTS books (
title_confidence REAL DEFAULT 0,
analyzed_at TEXT,
created_at TEXT NOT NULL,
candidates TEXT DEFAULT NULL
candidates TEXT DEFAULT NULL,
ai_blocks TEXT DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS ai_log (
id TEXT PRIMARY KEY,
ts REAL NOT NULL,
plugin_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
model TEXT NOT NULL,
request TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'running',
response TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS batch_queue (
book_id TEXT PRIMARY KEY,
added_at REAL NOT NULL
);
"""
@@ -413,11 +432,12 @@ def create_book(db: sqlite3.Connection, shelf_id: str) -> BookRow:
"analyzed_at": None,
"created_at": now(),
"candidates": None,
"ai_blocks": None,
}
db.execute(
"INSERT INTO books VALUES(:id,:shelf_id,:position,:image_filename,:title,:author,:year,:isbn,:publisher,"
":notes,:raw_text,:ai_title,:ai_author,:ai_year,:ai_isbn,:ai_publisher,:identification_status,"
":title_confidence,:analyzed_at,:created_at,:candidates)",
":title_confidence,:analyzed_at,:created_at,:candidates,:ai_blocks)",
data,
)
return _book_dec.decode(data)
@@ -494,6 +514,10 @@ def set_book_candidates(db: sqlite3.Connection, book_id: str, candidates_json: s
db.execute("UPDATE books SET candidates=? WHERE id=?", [candidates_json, book_id])
def set_book_ai_blocks(db: sqlite3.Connection, book_id: str, ai_blocks_json: str) -> None:
db.execute("UPDATE books SET ai_blocks=? WHERE id=?", [ai_blocks_json, book_id])
def get_book_rank(db: sqlite3.Connection, book_id: str) -> int:
"""0-based rank of book among its siblings sorted by position."""
row = db.execute("SELECT shelf_id FROM books WHERE id=?", [book_id]).fetchone()
@@ -513,3 +537,90 @@ def get_unidentified_book_ids(db: sqlite3.Connection) -> list[str]:
def reorder_entities(db: sqlite3.Connection, table: str, ids: list[str]) -> None:
for i, entity_id in enumerate(ids, 1):
db.execute(f"UPDATE {table} SET position=? WHERE id=?", [i, entity_id])
# ── AI log ────────────────────────────────────────────────────────────────────
def insert_ai_log_entry(
db: sqlite3.Connection,
entry_id: str,
ts: float,
plugin_id: str,
entity_type: str,
entity_id: str,
model: str,
request: str,
) -> None:
"""Insert a new AI log entry with status='running'."""
db.execute(
"INSERT OR IGNORE INTO ai_log"
" (id, ts, plugin_id, entity_type, entity_id, model, request) VALUES (?,?,?,?,?,?,?)",
[entry_id, ts, plugin_id, entity_type, entity_id, model, request],
)
def update_ai_log_entry(db: sqlite3.Connection, entry_id: str, status: str, response: str, duration_ms: int) -> None:
"""Update an AI log entry with the final status and response."""
db.execute(
"UPDATE ai_log SET status=?, response=?, duration_ms=? WHERE id=?",
[status, response, duration_ms, entry_id],
)
def get_ai_log_entries(db: sqlite3.Connection, limit: int) -> list[dict[str, Any]]:
"""Return the most recent AI log entries, oldest first."""
rows = db.execute(
"SELECT id, ts, plugin_id, entity_type, entity_id, model, request, status, response, duration_ms"
" FROM ai_log ORDER BY ts DESC LIMIT ?",
[limit],
).fetchall()
return [dict(r) for r in reversed(rows)]
# ── Batch queue ────────────────────────────────────────────────────────────────
def add_to_batch_queue(db: sqlite3.Connection, book_ids: list[str]) -> None:
"""Insert book IDs into the batch queue, ignoring duplicates.
Args:
db: Open database connection (must be writable).
book_ids: Book IDs to enqueue.
"""
ts = time.time()
db.executemany(
"INSERT OR IGNORE INTO batch_queue (book_id, added_at) VALUES (?,?)", [(bid, ts) for bid in book_ids]
)
def remove_from_batch_queue(db: sqlite3.Connection, book_id: str) -> None:
"""Remove a single book ID from the batch queue.
Args:
db: Open database connection (must be writable).
book_id: Book ID to dequeue.
"""
db.execute("DELETE FROM batch_queue WHERE book_id=?", [book_id])
def get_batch_queue(db: sqlite3.Connection) -> list[str]:
"""Return all queued book IDs ordered by insertion time (oldest first).
Args:
db: Open database connection.
Returns:
List of book ID strings.
"""
rows = db.execute("SELECT book_id FROM batch_queue ORDER BY added_at").fetchall()
return [str(r[0]) for r in rows]
def clear_batch_queue(db: sqlite3.Connection) -> None:
"""Remove all entries from the batch queue.
Args:
db: Open database connection (must be writable).
"""
db.execute("DELETE FROM batch_queue")

View File

@@ -154,6 +154,21 @@ class NoRawTextError(BadRequestError):
return f"Book {self.book_id!r} has no raw text; run text recognizer first"
class NoPipelinePluginError(BadRequestError):
"""Raised when the identification pipeline requires a plugin category with no registered plugins.
Attributes:
plugin_category: The plugin category (e.g. 'text_recognizer') that has no registered plugins.
"""
def __init__(self, plugin_category: str) -> None:
super().__init__()
self.plugin_category = plugin_category
def __str__(self) -> str:
return f"No {self.plugin_category!r} plugin configured; add one to functions.*.yaml"
class InvalidPluginEntityError(BadRequestError):
"""Raised when a plugin category does not support the requested entity type.

141
src/log_thread.py Normal file
View File

@@ -0,0 +1,141 @@
"""Thread-safe AI logging helpers for use from thread pool workers.
Provides start_entry() / finish_entry() that schedule log operations on the
event loop via call_soon_threadsafe, making them safe to call from executor
threads. Also provides a ContextVar so plugin/entity context flows through
asyncio.run_in_executor() calls automatically.
Initialized by logic/ai_log.py at app startup via set_app_loop().
Importable by both logic/ and plugins/ without circular dependencies.
"""
import concurrent.futures
import time
from collections.abc import Callable
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Any
try:
import asyncio as _asyncio
_AbstractEventLoop = _asyncio.AbstractEventLoop
except ImportError: # pragma: no cover
_AbstractEventLoop = Any # type: ignore[assignment,misc]
import asyncio
@dataclass
class _LogCtx:
plugin_id: str
entity_type: str
entity_id: str
# ContextVar propagated automatically into asyncio executor threads.
_ctx: ContextVar[_LogCtx | None] = ContextVar("_log_ctx", default=None)
# Initialized at startup by set_app_loop().
_loop: asyncio.AbstractEventLoop | None = None
_log_start_fn: Callable[..., str] | None = None
_log_finish_fn: Callable[..., None] | None = None
def set_app_loop(
loop: asyncio.AbstractEventLoop,
log_start: Callable[..., str],
log_finish: Callable[..., None],
) -> None:
"""Store the running event loop and logging callables.
Must be called once at app startup from the async context.
Args:
loop: The running asyncio event loop.
log_start: Synchronous log_start function from logic.ai_log.
log_finish: Synchronous log_finish function from logic.ai_log.
"""
global _loop, _log_start_fn, _log_finish_fn
_loop = loop
_log_start_fn = log_start
_log_finish_fn = log_finish
def set_log_ctx(plugin_id: str, entity_type: str, entity_id: str) -> None:
"""Set the current log context for this thread/task.
Call before run_in_executor() to propagate context into executor threads.
Or call directly inside a thread to set context for subsequent calls in
the same thread.
Args:
plugin_id: Plugin ID to attribute log entries to.
entity_type: Entity type (e.g. ``"books"``).
entity_id: Entity ID.
"""
_ctx.set(_LogCtx(plugin_id=plugin_id, entity_type=entity_type, entity_id=entity_id))
def start_entry(model: str, request_summary: str) -> str:
"""Start a log entry from a thread pool worker.
Reads context from the ContextVar set by set_log_ctx(). Schedules
log_start on the event loop and blocks briefly to obtain the entry ID.
Returns empty string if context or loop is unavailable.
Args:
model: Model name used for the request.
request_summary: Short human-readable description.
Returns:
Log entry ID string, or ``""`` if logging is unavailable.
"""
ctx = _ctx.get()
if ctx is None or _loop is None or _log_start_fn is None:
return ""
fut: concurrent.futures.Future[str] = concurrent.futures.Future()
fn = _log_start_fn
pid, et, eid = ctx.plugin_id, ctx.entity_type, ctx.entity_id
def _call() -> None:
try:
entry_id = fn(pid, et, eid, model, request_summary)
fut.set_result(entry_id)
except Exception as exc: # noqa: BLE001
fut.set_exception(exc)
_loop.call_soon_threadsafe(_call)
try:
return fut.result(timeout=5)
except Exception:
return ""
def finish_entry(entry_id: str, status: str, response: str, started_at: float) -> None:
"""Finish a log entry from a thread pool worker (fire-and-forget).
Schedules log_finish on the event loop. Does nothing if entry_id is empty
or the loop is unavailable.
Args:
entry_id: ID returned by start_entry().
status: ``"ok"`` or ``"error"``.
response: Short summary of response or error message.
started_at: ``time.time()`` value recorded before the request.
"""
if not entry_id or _loop is None or _log_finish_fn is None:
return
fn = _log_finish_fn
_loop.call_soon_threadsafe(fn, entry_id, status, response, started_at)
def timed_start(model: str, request_summary: str) -> tuple[str, float]:
"""Convenience wrapper: start an entry and record the start time.
Returns:
Tuple of (entry_id, started_at) for passing to finish_entry().
"""
started_at = time.time()
entry_id = start_entry(model, request_summary)
return entry_id, started_at

View File

@@ -2,13 +2,37 @@
import asyncio
import dataclasses
import time
from typing import Any
import log_thread
import plugins as plugin_registry
from errors import InvalidPluginEntityError, PluginNotFoundError, PluginTargetMismatchError
from models import PluginLookupResult
from logic.ai_log import (
get_snapshot,
init_thread_logging,
load_from_db,
log_finish,
log_start,
notify_entity_update,
set_max_entries,
subscribe_log,
unsubscribe_log,
)
from logic.archive import run_archive_searcher, run_archive_searcher_bg
from logic.batch import archive_executor, batch_executor, batch_state, process_book_sync, run_batch
from logic.batch import (
add_to_queue,
archive_executor,
batch_executor,
batch_state,
get_batch_task,
get_pending_batch,
process_book_sync,
run_batch_consumer,
subscribe_batch,
unsubscribe_batch,
)
from logic.boundaries import book_spine_source, bounds_for_index, run_boundary_detector, shelf_source
from logic.identification import (
AI_FIELDS,
@@ -17,6 +41,7 @@ from logic.identification import (
compute_status,
dismiss_field,
run_book_identifier,
run_identify_pipeline,
run_text_recognizer,
save_user_fields,
)
@@ -24,6 +49,7 @@ from logic.images import prep_img_b64, crop_save, serve_crop
__all__ = [
"AI_FIELDS",
"add_to_queue",
"apply_ai_result",
"archive_executor",
"batch_executor",
@@ -35,17 +61,31 @@ __all__ = [
"crop_save",
"dismiss_field",
"dispatch_plugin",
"get_batch_task",
"get_pending_batch",
"get_snapshot",
"init_thread_logging",
"load_from_db",
"log_finish",
"log_start",
"notify_entity_update",
"prep_img_b64",
"process_book_sync",
"run_archive_searcher",
"run_archive_searcher_bg",
"run_batch",
"run_batch_consumer",
"run_book_identifier",
"run_boundary_detector",
"run_identify_pipeline",
"run_text_recognizer",
"save_user_fields",
"serve_crop",
"set_max_entries",
"shelf_source",
"prep_img_b64",
"subscribe_batch",
"subscribe_log",
"unsubscribe_batch",
"unsubscribe_log",
]
@@ -58,6 +98,10 @@ async def dispatch_plugin(
) -> dict[str, Any]:
"""Validate plugin/entity compatibility, run the plugin, and trigger auto-queue follow-ups.
Sets the log context ContextVar before each run_in_executor call so that
AIClient and archive runner logging is attributed to the correct plugin and entity.
After a successful run, broadcasts an entity_update to WebSocket subscribers.
Args:
plugin_id: The plugin ID string (used in error reporting).
lookup: Discriminated tuple from plugins.get_plugin(); (None, None) if not found.
@@ -84,25 +128,65 @@ async def dispatch_plugin(
raise PluginTargetMismatchError(plugin.plugin_id, "shelves", plugin.target)
if entity_type == "shelves" and plugin.target != "books":
raise PluginTargetMismatchError(plugin.plugin_id, "books", plugin.target)
result = await loop.run_in_executor(None, run_boundary_detector, plugin, entity_type, entity_id)
return dataclasses.asdict(result)
started = time.time()
entry_id = log_start(plugin_id, entity_type, entity_id, plugin.model, entity_id)
log_thread.set_log_ctx(plugin_id, entity_type, entity_id)
try:
result = await loop.run_in_executor(None, run_boundary_detector, plugin, entity_type, entity_id)
log_finish(entry_id, "ok", "done", started)
except Exception as exc:
log_finish(entry_id, "error", str(exc), started)
raise
result_dict = dataclasses.asdict(result)
notify_entity_update(entity_type, entity_id, result_dict)
return result_dict
case ("text_recognizer", plugin):
if entity_type != "books":
raise InvalidPluginEntityError("text_recognizer", entity_type)
result = await loop.run_in_executor(None, run_text_recognizer, plugin, entity_id)
started = time.time()
entry_id = log_start(plugin_id, entity_type, entity_id, plugin.model, entity_id)
log_thread.set_log_ctx(plugin_id, entity_type, entity_id)
try:
result = await loop.run_in_executor(None, run_text_recognizer, plugin, entity_id)
log_finish(entry_id, "ok", result.raw_text[:120] if result.raw_text else "", started)
except Exception as exc:
log_finish(entry_id, "error", str(exc), started)
raise
for ap in plugin_registry.get_auto_queue("archive_searchers"):
loop.run_in_executor(archive_executor, run_archive_searcher_bg, ap, entity_id)
return dataclasses.asdict(result)
result_dict = dataclasses.asdict(result)
notify_entity_update(entity_type, entity_id, result_dict)
return result_dict
case ("book_identifier", plugin):
if entity_type != "books":
raise InvalidPluginEntityError("book_identifier", entity_type)
result = await loop.run_in_executor(None, run_book_identifier, plugin, entity_id)
return dataclasses.asdict(result)
started = time.time()
entry_id = log_start(plugin_id, entity_type, entity_id, plugin.model, entity_id)
log_thread.set_log_ctx(plugin_id, entity_type, entity_id)
try:
result = await loop.run_in_executor(None, run_book_identifier, plugin, entity_id)
log_finish(entry_id, "ok", result.ai_title or "", started)
except Exception as exc:
log_finish(entry_id, "error", str(exc), started)
raise
result_dict = dataclasses.asdict(result)
notify_entity_update(entity_type, entity_id, result_dict)
return result_dict
case ("archive_searcher", plugin):
if entity_type != "books":
raise InvalidPluginEntityError("archive_searcher", entity_type)
result = await loop.run_in_executor(archive_executor, run_archive_searcher, plugin, entity_id)
return dataclasses.asdict(result)
started = time.time()
entry_id = log_start(plugin_id, entity_type, entity_id, "", entity_id)
log_thread.set_log_ctx(plugin_id, entity_type, entity_id)
try:
result = await loop.run_in_executor(archive_executor, run_archive_searcher, plugin, entity_id)
log_finish(entry_id, "ok", "done", started)
except Exception as exc:
log_finish(entry_id, "error", str(exc), started)
raise
result_dict = dataclasses.asdict(result)
notify_entity_update(entity_type, entity_id, result_dict)
return result_dict

190
src/logic/ai_log.py Normal file
View File

@@ -0,0 +1,190 @@
"""AI request log: ring buffer with WebSocket pub-sub for live UI updates.
Entries are persisted to the ai_log table so they survive service restarts.
Call load_from_db() once at startup after init_db() to populate the ring buffer.
Call init_thread_logging() once at startup to enable logging from executor threads.
"""
import asyncio
import time
from collections import deque
from typing import Any
import db
import log_thread
from models import AiLogEntry
# Ring buffer; max size set at runtime by set_max_entries().
_log: deque[AiLogEntry] = deque(maxlen=100)
_log_subs: set[asyncio.Queue[dict[str, Any]]] = set()
_next_id: list[int] = [0]
def set_max_entries(n: int) -> None:
"""Resize the ring buffer.
Args:
n: Maximum number of entries to retain.
"""
global _log
_log = deque(_log, maxlen=n)
def subscribe_log() -> asyncio.Queue[dict[str, Any]]:
"""Register a subscriber for AI log updates.
Returns:
Queue that will receive update messages as dicts with keys
``type`` (``"snapshot"`` or ``"update"``) and either ``entries``
or ``entry``.
"""
q: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
_log_subs.add(q)
return q
def unsubscribe_log(q: asyncio.Queue[dict[str, Any]]) -> None:
"""Remove a subscriber queue.
Args:
q: Queue previously returned by subscribe_log().
"""
_log_subs.discard(q)
def get_snapshot() -> list[AiLogEntry]:
"""Return a copy of the current log for snapshot delivery on WS connect.
Returns:
List of AiLogEntry dicts, oldest first.
"""
return list(_log)
def load_from_db(limit: int = 100) -> None:
"""Populate the in-memory ring buffer from the database.
Call once at startup after init_db(). Does not push WS notifications.
Any numeric IDs loaded from the DB advance _next_id to avoid collisions.
Args:
limit: Maximum number of entries to load (most recent).
"""
with db.connection() as c:
rows = db.get_ai_log_entries(c, limit)
for row in rows:
entry: AiLogEntry = {
"id": str(row["id"]),
"ts": float(str(row["ts"])),
"plugin_id": str(row["plugin_id"]),
"entity_type": str(row["entity_type"]),
"entity_id": str(row["entity_id"]),
"model": str(row["model"]),
"request": str(row["request"]),
"status": str(row["status"]),
"response": str(row["response"]),
"duration_ms": int(str(row["duration_ms"])),
}
_log.append(entry)
try:
num = int(entry["id"])
if num >= _next_id[0]:
_next_id[0] = num + 1
except ValueError:
pass
def log_start(plugin_id: str, entity_type: str, entity_id: str, model: str, request_summary: str) -> str:
"""Record the start of an AI request and return its log entry ID.
Must be called from the asyncio event loop thread. Persists the entry to DB.
Args:
plugin_id: Plugin that is running.
entity_type: Entity type (e.g. ``"books"``).
entity_id: Entity ID.
model: Model name used for the request.
request_summary: Short human-readable description of the request.
Returns:
Opaque string ID for the log entry, to be passed to log_finish().
"""
_next_id[0] += 1
entry_id = str(_next_id[0])
ts = time.time()
entry: AiLogEntry = {
"id": entry_id,
"ts": ts,
"plugin_id": plugin_id,
"entity_type": entity_type,
"entity_id": entity_id,
"model": model,
"request": request_summary,
"status": "running",
"response": "",
"duration_ms": 0,
}
_log.append(entry)
_notify({"type": "update", "entry": dict(entry)})
try:
with db.transaction() as c:
db.insert_ai_log_entry(c, entry_id, ts, plugin_id, entity_type, entity_id, model, request_summary)
except Exception:
pass # log persistence is best-effort
return entry_id
def log_finish(entry_id: str, status: str, response: str, started_at: float) -> None:
"""Update a log entry with the result of an AI request.
Must be called from the asyncio event loop thread. Persists the update to DB.
Args:
entry_id: ID returned by log_start().
status: ``"ok"`` or ``"error"``.
response: Short summary of the response or error message.
started_at: ``time.time()`` value recorded before the request.
"""
duration_ms = int((time.time() - started_at) * 1000)
for entry in _log:
if entry["id"] == entry_id:
entry["status"] = status
entry["response"] = response
entry["duration_ms"] = duration_ms
_notify({"type": "update", "entry": dict(entry)})
break
try:
with db.transaction() as c:
db.update_ai_log_entry(c, entry_id, status, response, duration_ms)
except Exception:
pass # log persistence is best-effort
def init_thread_logging(loop: asyncio.AbstractEventLoop) -> None:
"""Enable log_start / log_finish calls from executor threads.
Must be called once at app startup after the event loop is running.
Stores the loop and function references in log_thread for use from workers.
Args:
loop: The running asyncio event loop.
"""
log_thread.set_app_loop(loop, log_start, log_finish)
def notify_entity_update(entity_type: str, entity_id: str, data: dict[str, Any]) -> None:
"""Broadcast an entity update to all AI-log WebSocket subscribers.
Must be called from the asyncio event loop thread.
Args:
entity_type: Entity type string (e.g. ``"books"``).
entity_id: Entity ID.
data: Dict representation of the updated entity row.
"""
_notify({"type": "entity_update", "entity_type": entity_type, "entity_id": entity_id, "data": data})
def _notify(msg: dict[str, Any]) -> None:
for q in _log_subs:
q.put_nowait(msg)

View File

@@ -1,8 +1,10 @@
"""Archive search plugin runner."""
import json
import time
import db
import log_thread
from errors import BookNotFoundError
from models import ArchiveSearcherPlugin, BookRow, CandidateRecord
from logic.identification import build_query
@@ -11,6 +13,9 @@ from logic.identification import build_query
def run_archive_searcher(plugin: ArchiveSearcherPlugin, book_id: str) -> BookRow:
"""Run an archive search for a book and merge results into the candidates list.
Sets the log context for this thread so individual HTTP requests logged inside
the plugin are attributed to the correct plugin and entity.
Args:
plugin: The archive searcher plugin to execute.
book_id: ID of the book to search for.
@@ -21,6 +26,7 @@ def run_archive_searcher(plugin: ArchiveSearcherPlugin, book_id: str) -> BookRow
Raises:
BookNotFoundError: If book_id does not exist.
"""
log_thread.set_log_ctx(plugin.plugin_id, "books", book_id)
with db.transaction() as c:
book = db.get_book(c, book_id)
if not book:
@@ -28,7 +34,14 @@ def run_archive_searcher(plugin: ArchiveSearcherPlugin, book_id: str) -> BookRow
query = build_query(book)
if not query:
return book
results: list[CandidateRecord] = plugin.search(query)
started = time.time()
entry_id = log_thread.start_entry("", f"search: {query[:80]}")
try:
results: list[CandidateRecord] = plugin.search(query)
log_thread.finish_entry(entry_id, "ok", f"{len(results)} result(s)", started)
except Exception as exc:
log_thread.finish_entry(entry_id, "error", str(exc), started)
raise
existing: list[CandidateRecord] = json.loads(book.candidates or "[]")
existing = [cd for cd in existing if cd.get("source") != plugin.plugin_id]
existing.extend(results)

View File

@@ -1,66 +1,168 @@
"""Batch processing pipeline: auto-queue text recognition and archive search."""
import asyncio
import dataclasses
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any
import db
import plugins as plugin_registry
from logic.ai_log import log_finish, log_start, notify_entity_update
from logic.identification import run_identify_pipeline
from models import BatchState
from logic.identification import run_text_recognizer
from logic.archive import run_archive_searcher
batch_state: BatchState = {"running": False, "total": 0, "done": 0, "errors": 0, "current": ""}
batch_executor = ThreadPoolExecutor(max_workers=1)
archive_executor = ThreadPoolExecutor(max_workers=8)
# WebSocket subscribers: each is a queue that receives batch_state snapshots.
_batch_subs: set[asyncio.Queue[dict[str, Any]]] = set()
# Tracked asyncio task for the running batch (for cancellation on shutdown).
_batch_task: asyncio.Task[None] | None = None
def subscribe_batch() -> asyncio.Queue[dict[str, Any]]:
"""Register a new subscriber for batch state updates.
Returns:
A queue that will receive a dict snapshot after each state change.
"""
q: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
_batch_subs.add(q)
return q
def unsubscribe_batch(q: asyncio.Queue[dict[str, Any]]) -> None:
"""Remove a subscriber queue from batch state notifications.
Args:
q: Queue previously returned by subscribe_batch().
"""
_batch_subs.discard(q)
def get_batch_task() -> "asyncio.Task[None] | None":
"""Return the currently running batch asyncio task, or None.
Returns:
The running Task, or None if no batch is active.
"""
return _batch_task
def get_pending_batch() -> list[str]:
"""Return pending book IDs from the database batch queue.
Used at startup to resume an interrupted batch.
Returns:
List of book IDs in queue order, or [] if queue is empty.
"""
with db.connection() as c:
return db.get_batch_queue(c)
def add_to_queue(book_ids: list[str]) -> int:
"""Add books to the DB batch queue, skipping duplicates.
Args:
book_ids: Candidate book IDs to enqueue.
Returns:
Number of books actually added (not already in queue).
"""
with db.connection() as c:
existing = set(db.get_batch_queue(c))
new_ids = [bid for bid in book_ids if bid not in existing]
if new_ids:
with db.transaction() as c:
db.add_to_batch_queue(c, new_ids)
return len(new_ids)
def _notify_subs() -> None:
snap: dict[str, Any] = {
"running": batch_state["running"],
"total": batch_state["total"],
"done": batch_state["done"],
"errors": batch_state["errors"],
"current": batch_state["current"],
}
for q in _batch_subs:
q.put_nowait(snap)
def process_book_sync(book_id: str) -> None:
"""Run the full auto-queue pipeline for a single book synchronously.
"""Run the full identification pipeline for a single book synchronously.
Runs all auto_queue text_recognizers (if book has no raw_text yet), then all
auto_queue archive_searchers. Exceptions from individual plugins are suppressed.
Exceptions from the pipeline propagate to the caller.
Args:
book_id: ID of the book to process.
Raises:
Any exception raised by run_identify_pipeline.
"""
with db.connection() as c:
book = db.get_book(c, book_id)
has_text = bool((book.raw_text if book else "").strip())
if not has_text:
for p in plugin_registry.get_auto_queue("text_recognizers"):
try:
run_text_recognizer(p, book_id)
except Exception:
pass
for p in plugin_registry.get_auto_queue("archive_searchers"):
try:
run_archive_searcher(p, book_id)
except Exception:
pass
run_identify_pipeline(book_id)
async def run_batch(book_ids: list[str]) -> None:
"""Process a list of books through the auto-queue pipeline sequentially.
async def run_batch_consumer() -> None:
"""Process books from the DB batch queue until the queue is empty.
Updates batch_state throughout execution. Exceptions from individual books
are counted in batch_state['errors'] and do not abort the run.
Args:
book_ids: List of book IDs to process.
Reads pending book IDs from the database queue. Each book is processed
sequentially via process_book_sync in the batch_executor. New books may
be added to the queue while this consumer is running and will be picked up
automatically. Batch state is broadcast to WebSocket subscribers after each
book. Individual book errors are counted but do not abort the run.
"""
global _batch_task
_batch_task = asyncio.current_task()
loop = asyncio.get_event_loop()
batch_state["running"] = True
batch_state["total"] = len(book_ids)
batch_state["done"] = 0
batch_state["errors"] = 0
for bid in book_ids:
batch_state["current"] = bid
try:
await loop.run_in_executor(batch_executor, process_book_sync, bid)
except Exception:
batch_state["errors"] += 1
batch_state["done"] += 1
batch_state["running"] = False
batch_state["current"] = ""
with db.connection() as c:
pending = db.get_batch_queue(c)
batch_state["total"] = len(pending)
_notify_subs()
try:
while True:
with db.connection() as c:
pending = db.get_batch_queue(c)
if not pending:
break
bid = pending[0]
batch_state["current"] = bid
batch_state["total"] = batch_state["done"] + len(pending)
_notify_subs()
wall_start = time.time()
entry_id = log_start("identify_pipeline", "books", bid, "pipeline", bid)
try:
await loop.run_in_executor(batch_executor, process_book_sync, bid)
log_finish(entry_id, "ok", "", wall_start)
# Push entity update so connected clients see the new book data.
with db.connection() as c:
book = db.get_book(c, bid)
if book is not None:
notify_entity_update("books", bid, dataclasses.asdict(book))
except asyncio.CancelledError:
log_finish(entry_id, "error", "cancelled", wall_start)
raise
except Exception as exc:
log_finish(entry_id, "error", str(exc), wall_start)
batch_state["errors"] += 1
with db.transaction() as c:
db.remove_from_batch_queue(c, bid)
batch_state["done"] += 1
_notify_subs()
finally:
batch_state["running"] = False
batch_state["current"] = ""
_notify_subs()
_batch_task = None

View File

@@ -64,15 +64,22 @@ def shelf_source(c: sqlite3.Connection, shelf_id: str) -> tuple[Path, tuple[floa
return IMAGES_DIR / cab.photo_filename, (0.0, y0, 1.0, y1)
def book_spine_source(c: sqlite3.Connection, book_id: str) -> tuple[Path, tuple[float, float, float, float]]:
def book_spine_source(
c: sqlite3.Connection,
book_id: str,
padding_pct: float = 0.0,
) -> tuple[Path, tuple[float, float, float, float]]:
"""Return the image path and crop fractions for a book's spine image.
Composes the shelf's image source with the book's horizontal position within
the shelf's book boundaries.
the shelf's book boundaries, then expands the x-extent by padding_pct of
the book width on each side to account for book inclination.
Args:
c: Open database connection.
book_id: ID of the book to resolve.
padding_pct: Fraction of book width to add on each horizontal side
(e.g. 0.10 adds 10% on left and right). Clamped to image edges.
Returns:
(image_path, crop_frac) — always returns a crop (never None).
@@ -93,6 +100,11 @@ def book_spine_source(c: sqlite3.Connection, book_id: str) -> tuple[Path, tuple[
idx = db.get_book_rank(c, book_id)
x0, x1 = bounds_for_index(shelf.book_boundaries, idx)
if padding_pct > 0.0:
pad = (x1 - x0) * padding_pct
x0 = max(0.0, x0 - pad)
x1 = min(1.0, x1 + pad)
if base_crop is None:
return base_path, (x0, 0.0, x1, 1.0)
else:

View File

@@ -1,17 +1,24 @@
"""Book identification logic: status computation, AI result application, plugin runners."""
import json
import re
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import db
import log_thread
from config import get_config
from db import now
from errors import BookNotFoundError, NoRawTextError
from errors import BookNotFoundError, NoPipelinePluginError, NoRawTextError
from logic.boundaries import book_spine_source
from logic.images import prep_img_b64
from models import (
AIIdentifyResult,
ArchiveSearcherPlugin,
BookIdentifierPlugin,
BookRow,
CandidateRecord,
IdentifyBlock,
TextRecognizeResult,
TextRecognizerPlugin,
)
@@ -19,6 +26,9 @@ from models import (
AI_FIELDS = ("title", "author", "year", "isbn", "publisher")
_APPROVED_REQUIRED = ("title", "author", "year")
_ARCHIVE_PIPELINE_WORKERS = 8
_ARCHIVE_PIPELINE_TIMEOUT = 60.0
def compute_status(book: BookRow) -> str:
"""Return the identification_status string derived from current book field values.
@@ -173,7 +183,8 @@ def run_text_recognizer(plugin: TextRecognizerPlugin, book_id: str) -> BookRow:
book = db.get_book(c, book_id)
if not book:
raise BookNotFoundError(book_id)
spine_path, spine_crop = book_spine_source(c, book_id)
padding = get_config().ui.spine_padding_pct
spine_path, spine_crop = book_spine_source(c, book_id, padding)
b64, mt = prep_img_b64(spine_path, spine_crop, max_px=plugin.max_image_px)
result: TextRecognizeResult = plugin.recognize(b64, mt)
raw_text = result.get("raw_text") or ""
@@ -198,9 +209,10 @@ def run_text_recognizer(plugin: TextRecognizerPlugin, book_id: str) -> BookRow:
def run_book_identifier(plugin: BookIdentifierPlugin, book_id: str) -> BookRow:
"""Identify a book using AI and update ai_* fields and candidates.
"""Identify a book using the AI identifier plugin and update ai_blocks and ai_* fields.
Requires raw_text to have been populated by a text recognizer first.
Standalone mode: passes empty archive results and no images.
For the full multi-step pipeline use run_identify_pipeline instead.
Args:
plugin: The book identifier plugin to execute.
@@ -220,26 +232,242 @@ def run_book_identifier(plugin: BookIdentifierPlugin, book_id: str) -> BookRow:
raw_text = (book.raw_text or "").strip()
if not raw_text:
raise NoRawTextError(book_id)
result: AIIdentifyResult = plugin.identify(raw_text)
# apply_ai_result manages its own transaction
apply_ai_result(book_id, result, plugin.confidence_threshold)
with db.transaction() as c:
blocks: list[IdentifyBlock] = plugin.identify(raw_text, [], [])
db.set_book_ai_blocks(c, book_id, json.dumps(blocks, ensure_ascii=False))
top_score = float(blocks[0].get("score") or 0.0) if blocks else 0.0
if blocks and top_score >= plugin.confidence_threshold:
top = blocks[0]
db.set_book_ai_fields(
c,
book_id,
top.get("title") or "",
top.get("author") or "",
top.get("year") or "",
top.get("isbn") or "",
top.get("publisher") or "",
)
db.set_book_confidence(c, book_id, top_score, now())
book = db.get_book(c, book_id)
if not book:
raise BookNotFoundError(book_id)
cand: CandidateRecord = {
"source": plugin.plugin_id,
"title": (result.get("title") or "").strip(),
"author": (result.get("author") or "").strip(),
"year": (result.get("year") or "").strip(),
"isbn": (result.get("isbn") or "").strip(),
"publisher": (result.get("publisher") or "").strip(),
}
existing: list[CandidateRecord] = json.loads(book.candidates or "[]")
existing = [cd for cd in existing if cd.get("source") != plugin.plugin_id]
existing.append(cand)
db.set_book_candidates(c, book_id, json.dumps(existing))
db.set_book_status(c, book_id, compute_status(book))
updated = db.get_book(c, book_id)
if not updated:
raise BookNotFoundError(book_id)
return updated
# ── Identification pipeline ───────────────────────────────────────────────────
def _normalize_field(value: str) -> str:
"""Lowercase, strip punctuation, and collapse spaces for candidate deduplication.
Args:
value: Raw field string.
Returns:
Normalized string.
"""
v = value.lower()
v = re.sub(r"[^\w\s]", "", v)
return " ".join(v.split())
def _candidate_key(c: CandidateRecord) -> tuple[str, str, str, str, str]:
return (
_normalize_field(c.get("title") or ""),
_normalize_field(c.get("author") or ""),
_normalize_field(c.get("year") or ""),
_normalize_field(c.get("isbn") or ""),
_normalize_field(c.get("publisher") or ""),
)
def _deduplicate_candidates(candidates: list[CandidateRecord]) -> list[CandidateRecord]:
"""Merge candidates that are identical after normalization, unioning their sources.
Two candidates match if title, author, year, isbn, and publisher all match
case-insensitively with punctuation removed and spaces normalized. Candidates
differing in any field (e.g. same title+author but different year) are kept separate.
Args:
candidates: Raw candidate list from multiple archive sources.
Returns:
Deduplicated list; first occurrence order preserved; sources merged with ', '.
"""
seen: dict[tuple[str, str, str, str, str], CandidateRecord] = {}
for cand in candidates:
key = _candidate_key(cand)
if key in seen:
existing_src = seen[key].get("source") or ""
new_src = cand.get("source") or ""
if new_src and new_src not in existing_src:
seen[key]["source"] = f"{existing_src}, {new_src}" if existing_src else new_src
else:
seen[key] = {
"source": cand.get("source") or "",
"title": cand.get("title") or "",
"author": cand.get("author") or "",
"year": cand.get("year") or "",
"isbn": cand.get("isbn") or "",
"publisher": cand.get("publisher") or "",
}
return list(seen.values())
def _get_book_images(book_id: str, max_image_px: int) -> list[tuple[str, str]]:
"""Collect spine and title-page images for a book, encoded as base64.
Silently skips images that cannot be loaded.
Args:
book_id: ID of the book.
max_image_px: Maximum pixel dimension for downscaling.
Returns:
List of (base64_string, mime_type) tuples; may be empty.
"""
images: list[tuple[str, str]] = []
padding = get_config().ui.spine_padding_pct
with db.connection() as c:
try:
spine_path, spine_crop = book_spine_source(c, book_id, padding)
b64, mt = prep_img_b64(spine_path, spine_crop, max_px=max_image_px)
images.append((b64, mt))
except Exception:
pass
book = db.get_book(c, book_id)
if book and book.image_filename:
from files import IMAGES_DIR
try:
b64_tp, mt_tp = prep_img_b64(IMAGES_DIR / book.image_filename, max_px=max_image_px)
images.append((b64_tp, mt_tp))
except Exception:
pass
return images
def _search_with_log(searcher: ArchiveSearcherPlugin, query: str, book_id: str) -> list[CandidateRecord]:
"""Run one archive search call with thread-safe logging."""
log_thread.set_log_ctx(searcher.plugin_id, "books", book_id)
started = time.time()
entry_id = log_thread.start_entry("", f"search: {query[:80]}")
try:
results = searcher.search(query)
log_thread.finish_entry(entry_id, "ok", f"{len(results)} result(s)", started)
return results
except Exception as exc:
log_thread.finish_entry(entry_id, "error", str(exc), started)
raise
def run_identify_pipeline(book_id: str) -> BookRow:
"""Run the full identification pipeline: VLM recognition -> archives -> main model.
Steps:
1. VLM text recognizer reads the spine image -> raw_text and structured fields.
2. All archive searchers run in parallel using title+author and title-only queries.
3. Archive results are deduplicated by normalized full-field match.
4. The main identifier model receives raw_text, deduplicated archive results, and
(if is_vlm is True) the spine and title-page images.
5. The model returns ranked IdentifyBlock list stored in books.ai_blocks (never cleared).
6. The top block (if score >= confidence_threshold) updates books.ai_* fields.
Args:
book_id: ID of the book to identify.
Returns:
Updated BookRow after completing the pipeline.
Raises:
BookNotFoundError: If book_id does not exist.
NoPipelinePluginError: If no text_recognizer or book_identifier is configured.
"""
import plugins as plugin_registry
with db.connection() as c:
if not db.get_book(c, book_id):
raise BookNotFoundError(book_id)
recognizers = plugin_registry.get_all_text_recognizers()
if not recognizers:
raise NoPipelinePluginError("text_recognizer")
recognizer = recognizers[0]
identifiers = plugin_registry.get_all_book_identifiers()
if not identifiers:
raise NoPipelinePluginError("book_identifier")
identifier = identifiers[0]
# Step 1: VLM recognition — set log context so AIClient.call() attributes the LLM call
log_thread.set_log_ctx(recognizer.plugin_id, "books", book_id)
book = run_text_recognizer(recognizer, book_id)
raw_text = (book.raw_text or "").strip()
candidates: list[CandidateRecord] = json.loads(book.candidates or "[]")
vlm_cand = next((c for c in candidates if c.get("source") == recognizer.plugin_id), None)
title = (vlm_cand.get("title") or "").strip() if vlm_cand else ""
author = (vlm_cand.get("author") or "").strip() if vlm_cand else ""
queries: list[str] = []
if title and author:
queries.append(f"{author} {title}")
if title:
queries.append(title)
if not queries and raw_text:
queries.append(raw_text[:200])
# Step 2: Parallel archive search — each call sets its own log context via _search_with_log
searchers = plugin_registry.get_all_archive_searchers()
all_archive: list[CandidateRecord] = []
if searchers and queries:
unique_queries = list(dict.fromkeys(queries))
with ThreadPoolExecutor(max_workers=_ARCHIVE_PIPELINE_WORKERS) as pool:
futs = {
pool.submit(_search_with_log, s, q, book_id): s.plugin_id for s in searchers for q in unique_queries
}
for fut in as_completed(futs, timeout=_ARCHIVE_PIPELINE_TIMEOUT):
try:
all_archive.extend(fut.result())
except Exception:
pass
# Step 3: Deduplicate
deduped = _deduplicate_candidates(all_archive)
# Step 4: Collect images if identifier is a VLM
images: list[tuple[str, str]] = []
if identifier.is_vlm:
images = _get_book_images(book_id, identifier.max_image_px)
# Step 5: Call main identifier — set log context so AIClient.call() logs the LLM call
log_thread.set_log_ctx(identifier.plugin_id, "books", book_id)
blocks: list[IdentifyBlock] = identifier.identify(raw_text, deduped, images)
# Step 6: Persist results (ai_blocks are never removed; overwritten each pipeline run)
with db.transaction() as c:
db.set_book_ai_blocks(c, book_id, json.dumps(blocks, ensure_ascii=False))
top_score = float(blocks[0].get("score") or 0.0) if blocks else 0.0
if blocks and top_score >= identifier.confidence_threshold:
top = blocks[0]
db.set_book_ai_fields(
c,
book_id,
top.get("title") or "",
top.get("author") or "",
top.get("year") or "",
top.get("isbn") or "",
top.get("publisher") or "",
)
db.set_book_confidence(c, book_id, top_score, now())
updated_book = db.get_book(c, book_id)
if not updated_book:
raise BookNotFoundError(book_id)
db.set_book_status(c, book_id, compute_status(updated_book))
final = db.get_book(c, book_id)
if not final:
raise BookNotFoundError(book_id)
return final

72
src/migrate.py Normal file
View File

@@ -0,0 +1,72 @@
"""Database migration functions.
Each migration is idempotent and safe to run on a database that has already been migrated.
Run via run_migration() called from app startup after init_db().
"""
import sqlite3
from db import DB_PATH
def run_migration() -> None:
"""Apply all pending schema migrations in order.
Currently applies:
- v1: Add ai_blocks column to books; clear AI-derived data while preserving user data.
- v2: Add batch_queue table for persistent batch processing queue.
Migrations are idempotent — running them on an already-migrated database is a no-op.
"""
c = sqlite3.connect(DB_PATH)
c.row_factory = sqlite3.Row
c.execute("PRAGMA foreign_keys = ON")
try:
_migrate_v1(c)
_migrate_v2(c)
c.commit()
except Exception:
c.rollback()
raise
finally:
c.close()
def _migrate_v1(c: sqlite3.Connection) -> None:
"""Add ai_blocks column and clear stale AI data from all books (first run only).
- Adds ai_blocks TEXT DEFAULT NULL column if it does not exist.
- On first run only (when the column is absent): clears raw_text, ai_*, title_confidence,
analyzed_at, candidates, ai_blocks from all books (these are regenerated by the new pipeline).
- For user_approved books: copies user fields back to ai_* so that
compute_status() still returns 'user_approved' after the ai_* clear.
This migration assumes the database already has the base books schema.
It is a no-op if ai_blocks already exists.
"""
cols = {row["name"] for row in c.execute("PRAGMA table_info(books)")}
if "ai_blocks" not in cols:
c.execute("ALTER TABLE books ADD COLUMN ai_blocks TEXT DEFAULT NULL")
# Clear AI-derived fields only when first adding the column.
c.execute(
"UPDATE books SET "
"raw_text='', ai_title='', ai_author='', ai_year='', ai_isbn='', ai_publisher='', "
"title_confidence=0, analyzed_at=NULL, candidates=NULL, ai_blocks=NULL"
)
# For user_approved books, restore ai_* = user fields so status stays user_approved.
c.execute(
"UPDATE books SET "
"ai_title=title, ai_author=author, ai_year=year, ai_isbn=isbn, ai_publisher=publisher "
"WHERE identification_status='user_approved'"
)
def _migrate_v2(c: sqlite3.Connection) -> None:
"""Add batch_queue table for persistent batch processing queue.
Replaces data/batch_pending.json with a DB table so batch state survives
across restarts alongside all other persistent data.
"""
c.execute("CREATE TABLE IF NOT EXISTS batch_queue (" "book_id TEXT PRIMARY KEY," "added_at REAL NOT NULL" ")")

View File

@@ -29,6 +29,16 @@ class AIIdentifyResult(TypedDict, total=False):
confidence: float
class IdentifyBlock(TypedDict, total=False):
title: str
author: str
year: str
isbn: str
publisher: str
score: float
sources: list[str]
# ── Candidate + AI config ─────────────────────────────────────────────────────
@@ -48,6 +58,7 @@ class AIConfig(TypedDict):
max_image_px: int
confidence_threshold: float
extra_body: dict[str, Any]
is_vlm: bool
# ── Application state ─────────────────────────────────────────────────────────
@@ -61,6 +72,19 @@ class BatchState(TypedDict):
current: str
class AiLogEntry(TypedDict):
id: str
ts: float
plugin_id: str
entity_type: str
entity_id: str
model: str
request: str
status: str # "running" | "ok" | "error"
response: str
duration_ms: int
# ── Plugin manifest ───────────────────────────────────────────────────────────
@@ -84,6 +108,9 @@ class BoundaryDetectorPlugin(Protocol):
auto_queue: bool
target: str
@property
def model(self) -> str: ...
@property
def max_image_px(self) -> int: ...
@@ -95,6 +122,9 @@ class TextRecognizerPlugin(Protocol):
name: str
auto_queue: bool
@property
def model(self) -> str: ...
@property
def max_image_px(self) -> int: ...
@@ -106,10 +136,24 @@ class BookIdentifierPlugin(Protocol):
name: str
auto_queue: bool
@property
def model(self) -> str: ...
@property
def max_image_px(self) -> int: ...
@property
def confidence_threshold(self) -> float: ...
def identify(self, raw_text: str) -> AIIdentifyResult: ...
@property
def is_vlm(self) -> bool: ...
def identify(
self,
raw_text: str,
archive_results: list["CandidateRecord"],
images: list[tuple[str, str]],
) -> list["IdentifyBlock"]: ...
class ArchiveSearcherPlugin(Protocol):
@@ -197,6 +241,7 @@ class BookRow:
analyzed_at: str | None
created_at: str
candidates: str | None
ai_blocks: str | None
# ── API request payload dataclasses ──────────────────────────────────────────

View File

@@ -70,6 +70,7 @@ def _build_ai_cfg(model_cfg: ModelConfig, cred_cfg: CredentialConfig, func: AIFu
max_image_px=func.max_image_px,
confidence_threshold=func.confidence_threshold,
extra_body=model_cfg.extra_body,
is_vlm=func.is_vlm,
)
@@ -227,6 +228,21 @@ def get_auto_queue(
return []
def get_all_text_recognizers() -> list[TextRecognizerPlugin]:
"""Return all registered text recognizer plugins."""
return list(_text_recognizers.values())
def get_all_book_identifiers() -> list[BookIdentifierPlugin]:
"""Return all registered book identifier plugins."""
return list(_book_identifiers.values())
def get_all_archive_searchers() -> list[ArchiveSearcherPlugin]:
"""Return all registered archive searcher plugins."""
return list(_archive_searchers.values())
def get_plugin(plugin_id: str) -> PluginLookupResult:
"""Find a plugin by ID across all categories. Returns a discriminated (category, plugin) tuple."""
if plugin_id in _boundary_detectors:

View File

@@ -2,12 +2,14 @@
Caches openai.OpenAI instances per (base_url, api_key) to avoid re-creating on each call.
AIClient wraps the raw API call: fills prompt template, encodes images, parses JSON response.
Individual LLM API calls are logged via log_thread if a log context is set.
"""
import json
import re
import time
from string import Template
from typing import Any, cast
from typing import Any, Literal, cast, overload
import openai
from openai.types.chat import ChatCompletionMessageParam
@@ -17,6 +19,7 @@ from openai.types.chat.chat_completion_content_part_image_param import (
)
from openai.types.chat.chat_completion_content_part_text_param import ChatCompletionContentPartTextParam
import log_thread
from models import AIConfig
# Module-level cache of openai.OpenAI instances keyed by (base_url, api_key)
@@ -48,6 +51,24 @@ def _parse_json(text: str) -> dict[str, Any]:
return cast(dict[str, Any], result)
def _parse_json_list(text: str) -> list[Any]:
"""Extract and parse the first JSON array found in text.
Raises ValueError if no JSON array is found or the JSON is malformed.
"""
text = text.strip()
m = re.search(r"\[.*\]", text, re.DOTALL)
if not m:
raise ValueError(f"No JSON array found in AI response: {text[:200]!r}")
try:
result = json.loads(m.group())
except json.JSONDecodeError as exc:
raise ValueError(f"Failed to parse AI response as JSON: {exc}") from exc
if not isinstance(result, list):
raise ValueError(f"Expected JSON array, got {type(result).__name__}")
return cast(list[Any], result)
ContentPart = ChatCompletionContentPartImageParam | ChatCompletionContentPartTextParam
@@ -62,16 +83,41 @@ class AIClient:
self.cfg = cfg
self.output_format = output_format
@overload
def call(
self,
prompt_template: str,
images: list[tuple[str, str]],
text_vars: dict[str, str] | None = None,
) -> dict[str, Any]:
output_is_list: Literal[False] = False,
) -> dict[str, Any]: ...
@overload
def call(
self,
prompt_template: str,
images: list[tuple[str, str]],
text_vars: dict[str, str] | None,
output_is_list: Literal[True],
) -> list[Any]: ...
def call(
self,
prompt_template: str,
images: list[tuple[str, str]],
text_vars: dict[str, str] | None = None,
output_is_list: bool = False,
) -> dict[str, Any] | list[Any]:
"""Substitute template vars, call API with optional images, return parsed JSON.
images: list of (base64_str, mime_type) tuples.
text_vars: extra ${KEY} substitutions beyond ${OUTPUT_FORMAT}.
Args:
prompt_template: Prompt string with ${KEY} placeholders.
images: List of (base64_str, mime_type) tuples.
text_vars: Extra ${KEY} substitutions beyond ${OUTPUT_FORMAT}.
output_is_list: If True, parse the response as a JSON array instead of object.
Returns:
Parsed JSON — dict if output_is_list is False, list otherwise.
"""
vars_: dict[str, str] = {"OUTPUT_FORMAT": self.output_format}
if text_vars:
@@ -87,8 +133,17 @@ class AIClient:
]
parts.append(ChatCompletionContentPartTextParam(type="text", text=prompt))
messages: list[ChatCompletionMessageParam] = [{"role": "user", "content": parts}]
r = client.chat.completions.create(
model=self.cfg["model"], max_tokens=2048, messages=messages, extra_body=self.cfg["extra_body"]
)
raw = r.choices[0].message.content or ""
started = time.time()
entry_id = log_thread.start_entry(self.cfg["model"], prompt[:120])
try:
r = client.chat.completions.create(
model=self.cfg["model"], max_tokens=4096, messages=messages, extra_body=self.cfg["extra_body"]
)
raw = r.choices[0].message.content or ""
log_thread.finish_entry(entry_id, "ok", raw[:120], started)
except Exception as exc:
log_thread.finish_entry(entry_id, "error", str(exc), started)
raise
if output_is_list:
return _parse_json_list(raw)
return _parse_json(raw)

View File

@@ -1,23 +1,38 @@
"""Book identifier plugin — raw spine text → bibliographic metadata.
"""Book identifier plugin — VLM result + archive candidates → ranked identification blocks.
Input: raw_text string (from text_recognizer).
Output: {"title": "...", "author": "...", "year": "...", "isbn": "...",
"publisher": "...", "confidence": 0.95}
confidence — float 0-1; results below confidence_threshold are discarded by logic.py.
Result added to books.candidates and books.ai_* fields.
Input: raw_text string (from text_recognizer), archive_results (deduplicated candidates),
images (list of (b64, mime) pairs if is_vlm).
Output: list of IdentifyBlock dicts ranked by descending confidence score.
Result stored as books.ai_blocks JSON.
"""
from models import AIConfig, AIIdentifyResult
import json
from typing import Any, TypeGuard
from models import AIConfig, CandidateRecord, IdentifyBlock
from ._client import AIClient
def _is_str_dict(v: object) -> TypeGuard[dict[str, Any]]:
return isinstance(v, dict)
def _is_any_list(v: object) -> TypeGuard[list[Any]]:
return isinstance(v, list)
class BookIdentifierPlugin:
"""Identifies a book from spine text using a VLM with web-search capability."""
"""Identifies a book by combining VLM spine text with archive search results."""
category = "book_identifiers"
OUTPUT_FORMAT = (
'{"title": "...", "author": "...", "year": "...", ' '"isbn": "...", "publisher": "...", "confidence": 0.95}'
'[{"title": "The Master and Margarita", "author": "Mikhail Bulgakov", '
'"year": "1967", "isbn": "", "publisher": "YMCA Press", '
'"score": 0.95, "sources": ["rusneb", "openlibrary"]}, '
'{"title": "Master i Margarita", "author": "M. Bulgakov", '
'"year": "2005", "isbn": "978-5-17-123456-7", "publisher": "AST", '
'"score": 0.72, "sources": ["web"]}]'
)
def __init__(
@@ -36,21 +51,67 @@ class BookIdentifierPlugin:
self._client = AIClient(ai_config, self.OUTPUT_FORMAT)
self._prompt_text = prompt_text
def identify(self, raw_text: str) -> AIIdentifyResult:
"""Returns AIIdentifyResult with title/author/year/isbn/publisher/confidence."""
raw = self._client.call(self._prompt_text, [], text_vars={"RAW_TEXT": raw_text})
result = AIIdentifyResult(
title=str(raw.get("title") or ""),
author=str(raw.get("author") or ""),
year=str(raw.get("year") or ""),
isbn=str(raw.get("isbn") or ""),
publisher=str(raw.get("publisher") or ""),
def identify(
self,
raw_text: str,
archive_results: list[CandidateRecord],
images: list[tuple[str, str]],
) -> list[IdentifyBlock]:
"""Call the AI model to produce ranked identification blocks.
Args:
raw_text: Verbatim text read from the book spine.
archive_results: Deduplicated candidates from archive searchers.
images: (base64, mime_type) pairs; non-empty only when is_vlm is True.
Returns:
List of IdentifyBlock dicts ranked by descending score.
"""
archive_json = json.dumps(archive_results, ensure_ascii=False)
raw = self._client.call(
self._prompt_text,
images,
text_vars={"RAW_TEXT": raw_text, "ARCHIVE_RESULTS": archive_json},
output_is_list=True,
)
conf = raw.get("confidence")
if conf is not None:
result["confidence"] = float(conf)
return result
blocks: list[IdentifyBlock] = []
for item in raw:
if not _is_str_dict(item):
continue
sources: list[str] = []
sources_val = item.get("sources")
if _is_any_list(sources_val):
for sv in sources_val:
if isinstance(sv, str):
sources.append(sv)
block = IdentifyBlock(
title=str(item.get("title") or "").strip(),
author=str(item.get("author") or "").strip(),
year=str(item.get("year") or "").strip(),
isbn=str(item.get("isbn") or "").strip(),
publisher=str(item.get("publisher") or "").strip(),
score=float(item.get("score") or 0.0),
sources=sources,
)
blocks.append(block)
return sorted(blocks, key=lambda b: b.get("score", 0.0), reverse=True)
@property
def model(self) -> str:
"""AI model name used for identification."""
return self._client.cfg["model"]
@property
def max_image_px(self) -> int:
"""Maximum pixel dimension for images passed to the AI model."""
return self._client.cfg["max_image_px"]
@property
def confidence_threshold(self) -> float:
"""Minimum score threshold for the top block to set ai_* fields."""
return self._client.cfg["confidence_threshold"]
@property
def is_vlm(self) -> bool:
"""True if images should be included in the request."""
return self._client.cfg["is_vlm"]

View File

@@ -41,6 +41,10 @@ class BoundaryDetectorBooksPlugin:
boundaries: list[float] = [float(b) for b in raw_bounds if isinstance(b, (int, float))]
return BoundaryDetectResult(boundaries=boundaries)
@property
def model(self) -> str:
return self._client.cfg["model"]
@property
def max_image_px(self) -> int:
return self._client.cfg["max_image_px"]

View File

@@ -46,6 +46,10 @@ class BoundaryDetectorShelvesPlugin:
result["confidence"] = float(conf)
return result
@property
def model(self) -> str:
return self._client.cfg["model"]
@property
def max_image_px(self) -> int:
return self._client.cfg["max_image_px"]

View File

@@ -51,6 +51,10 @@ class TextRecognizerPlugin:
other=str(raw.get("other") or ""),
)
@property
def model(self) -> str:
return self._client.cfg["model"]
@property
def max_image_px(self) -> int:
return self._client.cfg["max_image_px"]