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:
83
src/api.py
83
src/api.py
@@ -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"}
|
||||
|
||||
17
src/app.py
17
src/app.py
@@ -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)
|
||||
|
||||
@@ -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
115
src/db.py
@@ -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")
|
||||
|
||||
@@ -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
141
src/log_thread.py
Normal 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
|
||||
@@ -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
190
src/logic/ai_log.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
72
src/migrate.py
Normal 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" ")")
|
||||
@@ -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 ──────────────────────────────────────────
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user