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:
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
|
||||
Reference in New Issue
Block a user