- 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>
142 lines
4.5 KiB
Python
142 lines
4.5 KiB
Python
"""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
|