Skip to content

fast_api_app

zocrates.fast_api_app

FastAPI Server for the Zocrates research assistant.

Exposes REST endpoints for registering the Zotero directory, performing syncs, checking server status, and a streaming Server-Sent Events (SSE) chat endpoint.

AGENT_DIR module-attribute

AGENT_DIR = os.path.dirname(os.path.abspath(__file__))

ALLOW_ORIGINS module-attribute

ALLOW_ORIGINS = [o.strip() for o in os.getenv('ALLOW_ORIGINS', '').split(',') if o.strip()] if os.getenv('ALLOW_ORIGINS') else list(DEFAULT_ALLOW_ORIGINS)

BACKEND_HOST module-attribute

BACKEND_HOST = os.getenv('BACKEND_HOST', '127.0.0.1')

BACKEND_PORT module-attribute

BACKEND_PORT = _resolve_backend_port()

DEFAULT_CITATION_VERIFICATION_MODEL module-attribute

DEFAULT_CITATION_VERIFICATION_MODEL = '~typesafe/jev-latest'

DEFAULT_ENABLE_PREDICTIVE_GHOST_INPUT module-attribute

DEFAULT_ENABLE_PREDICTIVE_GHOST_INPUT = True

DEFAULT_LLM_MAX_TOKENS module-attribute

DEFAULT_LLM_MAX_TOKENS = 16384

DEFAULT_LLM_MODEL module-attribute

DEFAULT_LLM_MODEL = 'openrouter/~z-ai/glm-flash-latest'

DEFAULT_LLM_NUM_RETRIES module-attribute

DEFAULT_LLM_NUM_RETRIES = 4

DEFAULT_LLM_TEMPERATURE module-attribute

DEFAULT_LLM_TEMPERATURE = None

DEFAULT_PREDICTIVE_PROMPT_MODEL module-attribute

DEFAULT_PREDICTIVE_PROMPT_MODEL = 'openrouter/~deepseek/deepseek-flash-latest'

DEFAULT_SENTENCE_ATTRIBUTION_MODEL module-attribute

DEFAULT_SENTENCE_ATTRIBUTION_MODEL = ''

GRAPH_VIEW_TEMPLATE_PATH module-attribute

GRAPH_VIEW_TEMPLATE_PATH = TEMPLATES_DIR / 'graph_view.html'

HITL_SNAPSHOT module-attribute

HITL_SNAPSHOT = 'hitl_snapshot'

RESEARCH_TURN_HISTORY module-attribute

RESEARCH_TURN_HISTORY = 'research_turn_history'

TEMPLATES_DIR module-attribute

TEMPLATES_DIR = Path(__file__).parent / 'templates'

USER_PROMPT module-attribute

USER_PROMPT = 'user_prompt'

USER_RESEARCH_PROFILE_STR module-attribute

USER_RESEARCH_PROFILE_STR = 'user_research_profile_str'

_ASSET_EXTENSION_MEDIA_TYPES module-attribute

_ASSET_EXTENSION_MEDIA_TYPES = {'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml'}

_EDGE_TASKS module-attribute

_EDGE_TASKS = set()

_SAFE_KEY_RE module-attribute

_SAFE_KEY_RE = re.compile('^[A-Za-z0-9_-]+\\Z')

_SYNC_PROGRESS_MIN_INTERVAL_SEC module-attribute

_SYNC_PROGRESS_MIN_INTERVAL_SEC = 0.25

_SYNC_TERMINAL_EVENTS module-attribute

_SYNC_TERMINAL_EVENTS = frozenset({'done', 'aborted', 'auto_aborted', 'error'})

__version__ module-attribute

__version__ = version('zocrates')

_sub_logger module-attribute

_sub_logger = logging.getLogger(_logger_name)

_sync_job module-attribute

_sync_job = None

_sync_job_lock module-attribute

_sync_job_lock = asyncio.Lock()

app module-attribute

app = FastAPI(title='zocrates', description='API for interacting with Zocrates', lifespan=lifespan)

logger module-attribute

logger = logging.getLogger(__name__)

BatchConvertRequest

Bases: BaseModel

Request body for POST /batch-convert.

BatchIndexRequest

Bases: BaseModel

Request body for POST /batch-index.

ChatRequest

Bases: BaseModel

Request body for POST /chat.

ConfigRequest

Bases: BaseModel

Request body for POST /config.

ConvertRequest

Bases: BaseModel

Request body for POST /convert/{item_key}.

ConverterChangeRequest

Bases: BaseModel

Request body for POST /rag/converter.

ConverterTestRequest

Bases: BaseModel

Request body for POST /converters/test-connection.

CreateSessionRequest

Bases: BaseModel

Request body for POST /sessions.

DecisionModelTestRequest

Bases: BaseModel

Request body for POST /llm/test_decision_model.

DiagnosticReportRequest

Bases: BaseModel

Request body for POST /diagnostics/report.

DiscoverCleanupBatchRequest

Bases: BaseModel

Request body for POST /discover/cleanup_batch.

DiscoverPrepareBatchRequest

Bases: BaseModel

Request body for POST /discover/prepare_batch.

DiscoverResolveRequest

Bases: BaseModel

Request body for POST /discover/resolve.

DiscoverSearchRequest

Bases: BaseModel

Request body for POST /discover/search.

EmbeddingModelChangeRequest

Bases: BaseModel

Request body for POST /rag/model.

EmbeddingTestRequest

Bases: BaseModel

Request body for POST /llm/test_embedding.

GraphRecomputeRequest

Bases: BaseModel

Request body for POST /graph/recompute.

ItemDeleteRequest

Bases: BaseModel

Request body for POST /items/delete.

LLMTestRequest

Bases: BaseModel

Request body for POST /llm/test.

MCPReadRequest

Bases: BaseModel

Request body for POST /mcp/documents/{item_key}/read.

MCPSearchRequest

Bases: BaseModel

Request body for POST /mcp/search.

OpenAlexTestRequest

Bases: BaseModel

Request body for POST /discovery/test_openalex.

OpenRouterKeyTestRequest

Bases: BaseModel

Request body for POST /llm/test_openrouter_key.

RAGResetRequest

Bases: BaseModel

Request body for POST /rag/reset.

Attributes:

Name Type Description
target str

"active" (default) to reset the active collection, "all" to wipe every collection and the FTS database, or a specific collection name string.

RegisterRequest

Bases: BaseModel

Request body for POST /register.

ResumeChatRequest

Bases: BaseModel

Payload for resuming an interrupted human-in-the-loop chat invocation.

RewindSessionRequest

Bases: BaseModel

Request body for POST /sessions/{session_id}/rewind.

SentenceAttributionRequest

Bases: BaseModel

Request body for POST /citations/sentence_attribution.

SnippetItem

Bases: BaseModel

A user-curated text or image snippet extracted from an attachment.

SuggestPromptRequest

Bases: BaseModel

Request body for POST /sessions/{session_id}/suggest_next_prompt.

SyncRequest

Bases: BaseModel

Request body for POST /sync.

Attributes:

Name Type Description
item_ids list[int] | None

Specific item IDs to ingest. If None, a full library scan is performed.

_HealthCheckAccessFilter

Bases: Filter

Suppress successful GET /health heartbeat access lines from uvicorn.access.

_broadcast_sync_event

_broadcast_sync_event(job, event)

Fan an SSE event out to every subscriber queue of the job.

Source code in zocrates/fast_api_app.py
def _broadcast_sync_event(job: dict[str, Any], event: dict[str, str]) -> None:
    """Fan an SSE event out to every subscriber queue of the job."""
    for queue in list(job["subscribers"]):
        queue.put_nowait(event)

_cancel_if_last_subscriber

_cancel_if_last_subscriber(job, queue)

Set the cooperative cancel flag only when no other subscriber remains.

Shared by all three disconnect/cancel sites (body-iteration is_disconnected() poll, sse_starlette client_close_handler, and the generator's finally safety net) so a multi-window attach always keeps the job alive for the remaining windows, and a finished job is never cancelled.

Source code in zocrates/fast_api_app.py
def _cancel_if_last_subscriber(job: dict[str, Any], queue: asyncio.Queue) -> None:
    """Set the cooperative cancel flag only when no other subscriber remains.

    Shared by all three disconnect/cancel sites (body-iteration
    ``is_disconnected()`` poll, sse_starlette ``client_close_handler``, and the
    generator's ``finally`` safety net) so a multi-window attach always keeps
    the job alive for the remaining windows, and a finished job is never
    cancelled.
    """
    remaining = job["subscribers"] - {queue}
    if not remaining and job.get("active", False) and job.get("terminal") is None:
        job["cancel_event"].set()

_dispatch_edge_evaluation

_dispatch_edge_evaluation(item_key, syncer)

Dispatch background edge evaluation with strong reference retention and error logging.

Source code in zocrates/fast_api_app.py
def _dispatch_edge_evaluation(item_key: str, syncer: Any) -> None:
    """Dispatch background edge evaluation with strong reference retention and error logging."""
    async def _runner() -> None:
        """Evaluate paper relationships and persist edges in the background."""
        await asyncio.to_thread(evaluate_and_persist_edges, item_key, syncer, True)

    task = asyncio.create_task(_runner())
    _EDGE_TASKS.add(task)

    def _on_done(t: asyncio.Task) -> None:
        """Handle completion of background edge evaluation task.

        Args:
            t: The completed asyncio Task instance.
        """
        _EDGE_TASKS.discard(t)
        if not t.cancelled():
            exc = t.exception()
            if exc:
                logger.warning("Background edge evaluation failed for %s: %s", item_key, exc)

    task.add_done_callback(_on_done)

_extract_request_token

_extract_request_token(request)

Extract authentication token from Authorization header, X-Zocrates-Auth header, or query parameter.

Source code in zocrates/fast_api_app.py
def _extract_request_token(request: Request) -> str | None:
    """Extract authentication token from Authorization header, X-Zocrates-Auth header, or query parameter."""
    auth_header = request.headers.get("Authorization") or request.headers.get("authorization")
    if auth_header and auth_header.lower().startswith("bearer "):
        return auth_header[7:].strip()
    custom_header = request.headers.get("X-Zocrates-Auth") or request.headers.get("x-zocrates-auth")
    if custom_header:
        return custom_header.strip()
    # Query parameter 'token' is strictly allowed only for GET /assets/ and GET /graph/view
    if request.url.path.startswith("/assets/") or request.url.path == "/graph/view":
        query_token = request.query_params.get("token")
        if query_token:
            return query_token.strip()
    return None

_get_live_zotero_db

_get_live_zotero_db()

Retrieve live ZoteroDB from active LibrarySyncer or fallback to disk resolution.

Source code in zocrates/fast_api_app.py
def _get_live_zotero_db() -> Any | None:
    """Retrieve live ZoteroDB from active LibrarySyncer or fallback to disk resolution."""
    try:
        from zocrates.rag.service_manager import get_syncer
        syncer = get_syncer()
        if hasattr(syncer, "db") and syncer.db is not None:
            return syncer.db
    except Exception:
        pass

    try:
        from zocrates.config import resolve_zotero_data_dir
        from zocrates.rag.zotero_db import ZoteroDB
        z_dir = resolve_zotero_data_dir()
        if z_dir:
            sql_path = Path(z_dir) / "zotero.sqlite"
            st_dir = Path(z_dir) / "storage"
            if sql_path.exists():
                return ZoteroDB(sqlite_path=sql_path, storage_dir=st_dir)
    except Exception:
        pass

    return None

_get_unresolved_session_interrupts

_get_unresolved_session_interrupts(session)

Scan session events and return set of interrupt IDs waiting for FunctionResponse.

Source code in zocrates/fast_api_app.py
def _get_unresolved_session_interrupts(session: Any) -> set[str]:
    """Scan session events and return set of interrupt IDs waiting for FunctionResponse."""
    if not session or not getattr(session, "events", None):
        return set()
    unresolved: set[str] = set()
    for ev in session.events:
        if getattr(ev, "long_running_tool_ids", None):
            unresolved.update(ev.long_running_tool_ids)
        if ev.content and ev.content.parts:
            for p in ev.content.parts:
                fr = getattr(p, "function_response", None)
                if fr and getattr(fr, "id", None) in unresolved:
                    unresolved.discard(fr.id)
    return unresolved

_is_request_authenticated

_is_request_authenticated(request)

Return True if the request carries a valid Zocrates authentication token.

Source code in zocrates/fast_api_app.py
def _is_request_authenticated(request: Request) -> bool:
    """Return True if the request carries a valid Zocrates authentication token."""
    provided = _extract_request_token(request)
    if not provided:
        return False
    expected = get_auth_token()
    if not expected:
        return False
    return secrets.compare_digest(provided.encode("utf-8"), expected.encode("utf-8"))

_make_sync_client_close_handler

_make_sync_client_close_handler(job, queue)

Build the sse_starlette client_close_handler_callable for one subscriber.

Dispatched by sse_starlette when the client's http.disconnect arrives. Cooperatively cancel only if the disconnecting subscriber was the last one attached (a multi-window attach keeps the job alive for remaining windows).

Source code in zocrates/fast_api_app.py
def _make_sync_client_close_handler(
    job: dict[str, Any], queue: asyncio.Queue
) -> Callable[[Any], Awaitable[None]]:
    """Build the sse_starlette ``client_close_handler_callable`` for one subscriber.

    Dispatched by sse_starlette when the client's ``http.disconnect`` arrives.
    Cooperatively cancel only if the disconnecting subscriber was the last one
    attached (a multi-window attach keeps the job alive for remaining windows).
    """

    async def _on_client_close(_message: Any) -> None:
        """Handle client disconnect by evaluating cooperative job cancellation.

        Args:
            _message: Disconnect event payload from the ASGI transport.
        """
        if job.get("active", False):
            logger.info(
                "Client closed /sync/stream connection; cooperative cancel evaluated."
            )
            _cancel_if_last_subscriber(job, queue)

    return _on_client_close

_maybe_hot_swap_agent_models

_maybe_hot_swap_agent_models(zotero_dir, context_label)

Silently hot-swap coordinator agent models if zocrates_config.json changed on disk.

Source code in zocrates/fast_api_app.py
def _maybe_hot_swap_agent_models(zotero_dir: Path | str | None, context_label: str) -> None:
    """Silently hot-swap coordinator agent models if zocrates_config.json changed on disk."""
    if not zotero_dir:
        return
    try:
        if check_config_freshness(zotero_dir):
            from zocrates.agents.app import set_agent_models
            from zocrates.llm import get_llm_model

            new_model = get_llm_model(Path(zotero_dir) if zotero_dir else None)
            set_agent_models(new_model, zotero_data_dir=zotero_dir)
            logger.info("Silently hot-swapped agent model from updated config on %s", context_label)
    except Exception as exc:
        from zocrates.config import rearm_config_freshness

        rearm_config_freshness()
        logger.warning("Config freshness check failed on %s: %s", context_label, exc)

_parent_watchdog

_parent_watchdog(parent_pid, poll_interval=2.0, exit_fn=os._exit)

Monitor parent Zotero process and self-terminate backend when parent process exits.

Source code in zocrates/fast_api_app.py
def _parent_watchdog(
    parent_pid: int,
    poll_interval: float = 2.0,
    exit_fn: Any = os._exit,
) -> None:
    """Monitor parent Zotero process and self-terminate backend when parent process exits."""
    logger.info("Parent watchdog started monitoring PID %d", parent_pid)
    if sys.platform == "win32":
        import ctypes

        kernel32 = ctypes.windll.kernel32
        SYNCHRONIZE = 0x00100000
        handle = kernel32.OpenProcess(SYNCHRONIZE, False, parent_pid)
        if not handle:
            logger.info("Parent process %d handle unavailable. Exiting backend.", parent_pid)
            _safe_cleanup_snapshots()
            exit_fn(0)
            return
        try:
            timeout_ms = max(1, int(poll_interval * 1000))
            while True:
                # 0x00000102 is WAIT_TIMEOUT
                ret = kernel32.WaitForSingleObject(handle, timeout_ms)
                if ret != 0x00000102:
                    logger.info("Parent Zotero process %d terminated. Exiting backend.", parent_pid)
                    _safe_cleanup_snapshots()
                    exit_fn(0)
                    return
        finally:
            kernel32.CloseHandle(handle)
    else:
        while True:
            time.sleep(poll_interval)
            try:
                os.kill(parent_pid, 0)
            except ProcessLookupError:
                logger.info("Parent Zotero process %d terminated. Exiting backend.", parent_pid)
                _safe_cleanup_snapshots()
                exit_fn(0)
                return
            except PermissionError:
                pass
            except OSError as exc:
                if exc.errno == errno.ESRCH:
                    logger.info("Parent Zotero process %d terminated. Exiting backend.", parent_pid)
                    _safe_cleanup_snapshots()
                    exit_fn(0)
                    return

_parse_finish_reason

_parse_finish_reason(fr)

Extract finish reason as a clean string.

Source code in zocrates/utils/session_formatting.py
def parse_finish_reason(fr: Any) -> str | None:
    """Extract finish reason as a clean string."""
    if not fr:
        return None
    if hasattr(fr, "name"):
        return str(fr.name)
    s = str(fr)
    if "." in s:
        return s.split(".")[-1]
    return s

_parse_usage_metadata

_parse_usage_metadata(usage_meta)

Extract token counts into a normalized dictionary.

Handles attribute-based ADK objects and plain dictionaries. Returns None if all primary token counts are absent.

Source code in zocrates/utils/session_formatting.py
def parse_usage_metadata(usage_meta: Any) -> dict[str, int] | None:
    """Extract token counts into a normalized dictionary.

    Handles attribute-based ADK objects and plain dictionaries.
    Returns None if all primary token counts are absent.
    """
    if not usage_meta:
        return None

    prompt_tokens = getattr(usage_meta, "prompt_token_count", None)
    candidates_tokens = getattr(usage_meta, "candidates_token_count", None)
    thoughts_tokens = getattr(usage_meta, "thoughts_token_count", None)
    total_tokens = getattr(usage_meta, "total_token_count", None)
    cached_tokens = getattr(usage_meta, "cached_content_token_count", None)

    if prompt_tokens is None and isinstance(usage_meta, dict):
        prompt_tokens = usage_meta.get("prompt_token_count") or usage_meta.get("prompt_tokens")
        candidates_tokens = usage_meta.get("candidates_token_count") or usage_meta.get("candidates_tokens")
        thoughts_tokens = usage_meta.get("thoughts_token_count") or usage_meta.get("thoughts_tokens")
        total_tokens = usage_meta.get("total_token_count") or usage_meta.get("total_tokens")
        cached_tokens = usage_meta.get("cached_content_token_count") or usage_meta.get("cached_tokens")

    if all(x is None for x in (prompt_tokens, candidates_tokens, total_tokens)):
        return None

    p = int(prompt_tokens or 0)
    c = int(candidates_tokens or 0)
    tot = int(total_tokens or 0)
    if tot == 0 and (p > 0 or c > 0):
        tot = p + c

    return {
        "prompt_tokens": p,
        "candidates_tokens": c,
        "thoughts_tokens": int(thoughts_tokens or 0),
        "cached_tokens": int(cached_tokens or 0),
        "total_tokens": tot,
    }

_process_request_snippets async

_process_request_snippets(snippets, coord_model, zotero_dir)

Process incoming text and image snippets for Google ADK execution.

Returns:

Type Description
tuple[list[Part], str, list[dict[str, Any]]]

tuple of (multimodal_parts, formatted_text_context, notices_to_yield)

Source code in zocrates/fast_api_app.py
async def _process_request_snippets(
    snippets: list[SnippetItem] | None,
    coord_model: str,
    zotero_dir: Path | None,
) -> tuple[list[types.Part], str, list[dict[str, Any]]]:
    """Process incoming text and image snippets for Google ADK execution.

    Returns:
        tuple of (multimodal_parts, formatted_text_context, notices_to_yield)
    """
    if not snippets:
        return [], "", []

    from zocrates.llm import is_model_vision_capable

    multimodal_parts: list[types.Part] = []
    text_snippet_blocks: list[str] = []
    notices: list[dict[str, Any]] = []

    model_has_vision = is_model_vision_capable(coord_model, zotero_data_dir=zotero_dir)

    for snip in snippets:
        auth_attr = f' authors="{snip.author_str.strip()}"' if snip.author_str and snip.author_str.strip() else ""
        if snip.type == "text" and snip.text:
            text_snippet_blocks.append(
                f'<text_snippet paper="{snip.paper_title or "Unknown"}"{auth_attr} page="{snip.page or "?"}" key="{snip.parent_key or snip.item_key or ""}">'
                f"\n{snip.text.strip()}\n</text_snippet>"
            )

        elif snip.type == "image":
            if not snip.image_data:
                text_snippet_blocks.append(
                    f'<image_snippet paper="{snip.paper_title or "Unknown"}"{auth_attr} page="{snip.page or "?"}" key="{snip.parent_key or snip.item_key or ""}">'
                    f"\n[Attached image area payload was empty or could not be transferred.]\n</image_snippet>"
                )
                continue

            # Parse data URI safely
            raw_bytes = None
            mime_type = getattr(snip, "mime_type", None) or "image/jpeg"
            try:
                if "," in snip.image_data:
                    header, _, b64_data = snip.image_data.partition(",")
                    if match := re.search(r"data:(image/[a-zA-Z0-9.+_-]+)", header):
                        mime_type = match.group(1)
                    raw_bytes = base64.b64decode(b64_data)
                elif snip.image_data.startswith("data:image/"):
                    # Malformed URI without comma
                    raw_bytes = None
                else:
                    raw_bytes = base64.b64decode(snip.image_data)
            except Exception as b64_err:
                logger.warning("Failed to decode base64 snippet image: %s", b64_err)

            if not raw_bytes:
                text_snippet_blocks.append(
                    f'<image_snippet paper="{snip.paper_title or "Unknown"}"{auth_attr} page="{snip.page or "?"}" key="{snip.parent_key or snip.item_key or ""}">'
                    f"\n[Attached image area could not be decoded.]\n</image_snippet>"
                )
                continue

            if model_has_vision:
                # Direct multimodal injection into Google ADK
                part = types.Part.from_bytes(data=raw_bytes, mime_type=mime_type)
                multimodal_parts.append(part)
                text_snippet_blocks.append(
                    f'<image_snippet paper="{snip.paper_title or "Unknown"}"{auth_attr} page="{snip.page or "?"}" key="{snip.parent_key or snip.item_key or ""}">'
                    f"\n[Attached visual image of selected area]\n</image_snippet>"
                )
            else:
                text_snippet_blocks.append(
                    f'<image_snippet paper="{snip.paper_title or "Unknown"}"{auth_attr} page="{snip.page or "?"}" key="{snip.parent_key or snip.item_key or ""}">'
                    f"\n[Attached image area: untranscribed image on page {snip.page or '?'}. The active coordinator model ({coord_model}) does not support vision/image inputs. If needed, inspect the surrounding document text or section via retrieval tools.]\n</image_snippet>"
                )
                notices.append({
                    "notice": f"Image area attached, but active model ({coord_model}) is text-only. The agent has been notified."
                })

    formatted_context = ""
    if text_snippet_blocks:
        formatted_context = (
            "\n\n# Context: User-Selected Focal Snippets from Attachment\n\n"
            + "\n\n".join(text_snippet_blocks)
            + "\n\n"
        )

    return multimodal_parts, formatted_context, notices

_resolve_conversion_meta_db_path

_resolve_conversion_meta_db_path()

Locate conversion_meta.db from active syncer, DocumentProcessor, or config.

Source code in zocrates/fast_api_app.py
def _resolve_conversion_meta_db_path() -> Path | None:
    """Locate conversion_meta.db from active syncer, DocumentProcessor, or config."""
    # 1. Active syncer instance
    try:
        syncer = get_syncer()
        if syncer and getattr(syncer, "processor", None):
            cache_dir = getattr(syncer.processor, "_cache_dir", None)
            if cache_dir:
                p = Path(cache_dir) / "conversion_meta.db"
                if p.exists():
                    return p
    except Exception:
        pass

    # 2. DocumentProcessor default cache dir
    try:
        from zocrates.rag.document_processor import DocumentProcessor

        cache_dir = DocumentProcessor._get_default_cache_dir()
        if cache_dir:
            p = Path(cache_dir) / "conversion_meta.db"
            if p.exists():
                return p
    except Exception:
        pass

    # 3. Config auto-discovery
    try:
        from zocrates.config import get_corpus_dir, resolve_zotero_data_dir

        zotero_dir = getattr(app.state, "zotero_data_dir", None) or resolve_zotero_data_dir()
        if zotero_dir:
            p = get_corpus_dir(zotero_dir) / "conversion_meta.db"
            if p.exists():
                return p
    except Exception:
        pass

    return None

_run_sync_job async

_run_sync_job(job)

Run the full-library sync job and broadcast throttled progress events.

Source code in zocrates/fast_api_app.py
async def _run_sync_job(job: dict[str, Any]) -> None:
    """Run the full-library sync job and broadcast throttled progress events."""
    global _sync_job
    state = job["state"]
    loop = asyncio.get_running_loop()
    last_emit = 0.0

    def progress_callback(info: dict[str, Any]) -> None:
        """Update sync state and broadcast throttled progress events.

        Args:
            info: State dictionary containing updated sync progress metrics.
        """
        nonlocal last_emit
        state.update(info)
        now = loop.time()
        # Coalesce emissions to >= 250ms intervals; each item boundary that is
        # slower than the interval still emits immediately (snapshot carries
        # the latest counts, so throttled-away boundaries lose nothing).
        if now - last_emit >= _SYNC_PROGRESS_MIN_INTERVAL_SEC:
            last_emit = now
            _broadcast_sync_event(
                job,
                {"event": "progress", "data": json.dumps(_snapshot_sync_state(job))},
            )

    def cancel_check() -> bool:
        """Check if sync job cancellation was requested.

        Returns:
            True if the cancellation event is set, False otherwise.
        """
        return job["cancel_event"].is_set()

    terminal: dict[str, str] = {
        "event": "error",
        "data": json.dumps({"error": "sync job cancelled before completion"}),
    }
    try:
        syncer = get_syncer()
        state["stage"] = "syncing"
        results = await syncer.full_sync(
            progress_callback=progress_callback, cancel_check=cancel_check
        )
        state.update({
            "processed": results.get("processed", 0),
            "skipped": results.get("skipped", 0),
            "errors": results.get("errors", 0),
            "total": results.get("total", state.get("total", 0)),
        })
        if results.get("auto_aborted"):
            state["stage"] = "auto_aborted"
            terminal = {
                "event": "auto_aborted",
                "data": json.dumps(_snapshot_sync_state(job)),
            }
        elif results.get("cancelled"):
            state["stage"] = "aborted"
            terminal = {
                "event": "aborted",
                "data": json.dumps(_snapshot_sync_state(job)),
            }
        else:
            state["stage"] = "done"
            terminal = {
                "event": "done",
                "data": json.dumps(_snapshot_sync_state(job)),
            }
    except Exception as exc:
        logger.error("Sync stream job failed: %s", exc, exc_info=True)
        state["stage"] = "error"
        terminal = {
            "event": "error",
            "data": json.dumps({**_snapshot_sync_state(job), "error": str(exc)}),
        }
    finally:
        job["active"] = False
        job["terminal"] = terminal
        # Wake every subscriber with the terminal state, then clear the
        # single-flight registry so a follow-up /sync/stream starts a new job.
        _broadcast_sync_event(job, terminal)
        async with _sync_job_lock:
            if _sync_job is job:
                _sync_job = None

_safe_cleanup_snapshots

_safe_cleanup_snapshots()
Source code in zocrates/fast_api_app.py
def _safe_cleanup_snapshots() -> None:
    try:
        from zocrates.rag.zotero_db import _cleanup_sqlite_snapshots

        _cleanup_sqlite_snapshots()
    except Exception:
        pass

_snapshot_sync_state

_snapshot_sync_state(job)

Return a JSON-serializable snapshot of the job's progress state.

Source code in zocrates/fast_api_app.py
def _snapshot_sync_state(job: dict[str, Any]) -> dict[str, Any]:
    """Return a JSON-serializable snapshot of the job's progress state."""
    snapshot = dict(job["state"])
    snapshot["elapsed_ms"] = int((time.perf_counter() - job["started"]) * 1000)
    return snapshot

_status_zotero_data_dir_hint

_status_zotero_data_dir_hint()

Best-effort Zotero data directory for the unregistered /status branch.

The Zotero launcher's port-probe attach classifier compares this field against Zotero.DataDirectory.dir; the registered branch reports str(syncer._zotero_data_dir), so the unregistered branch must too. Resolution order: auto-init app state -> ZOTERO_DATA_DIR env -> discovery. Returns None when nothing can be resolved (field still present so the classifier sees an explicit miss rather than an absent key).

Source code in zocrates/fast_api_app.py
def _status_zotero_data_dir_hint() -> str | None:
    """Best-effort Zotero data directory for the unregistered /status branch.

    The Zotero launcher's port-probe attach classifier compares this field
    against ``Zotero.DataDirectory.dir``; the registered branch reports
    ``str(syncer._zotero_data_dir)``, so the unregistered branch must too.
    Resolution order: auto-init app state -> ZOTERO_DATA_DIR env -> discovery.
    Returns ``None`` when nothing can be resolved (field still present so the
    classifier sees an explicit miss rather than an absent key).
    """
    state_dir = getattr(app.state, "zotero_data_dir", None)
    if state_dir:
        return str(state_dir)
    env_dir = os.getenv("ZOTERO_DATA_DIR")
    if env_dir:
        return env_dir
    try:
        from zocrates.config import get_zocrates_data_dir

        return str(get_zocrates_data_dir().parent)
    except Exception:  # noqa: BLE001 - hint is best-effort by design
        return None

_stream_runner_events async

_stream_runner_events(event_generator, zotero_dir=None, session_id=None, request_start_time=None, prompt_text=None)

Format ADK Runner event stream into JSON-encoded SSE events.

Source code in zocrates/fast_api_app.py
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
async def _stream_runner_events(
    event_generator: Any,
    zotero_dir: Path | None = None,
    session_id: str | None = None,
    request_start_time: float | None = None,
    prompt_text: str | None = None,
) -> AsyncIterator[dict[str, str]]:
    """Format ADK Runner event stream into JSON-encoded SSE events."""
    total_prompt_tokens = 0
    total_candidates_tokens = 0
    total_thoughts_tokens = 0
    total_cached_tokens = 0
    total_tokens = 0
    current_turn_cost = 0.0
    streamed_agent_models: dict[str, str] = {}
    current_invocation_id: str | None = None

    try:
        async for event in event_generator:
            if getattr(event, "invocation_id", None):
                current_invocation_id = event.invocation_id

            request_input_fc = None
            if getattr(event, "long_running_tool_ids", None) and event.content and event.content.parts:
                for part in event.content.parts:
                    fc = getattr(part, "function_call", None)
                    if fc and getattr(fc, "name", None) == "adk_request_input":
                        request_input_fc = fc
                        break

            if request_input_fc is not None:
                fc_args = getattr(request_input_fc, "args", {}) or {}
                if not isinstance(fc_args, dict):
                    fc_args = {}
                req_payload = {
                    "event_type": "request_input",
                    "author": event.author or "Search Agent",
                    "interrupt_id": request_input_fc.id,
                    "invocation_id": getattr(event, "invocation_id", None),
                    "message": fc_args.get(
                        "message",
                        "Deep research limit reached. Extend retrieval or synthesize current findings?",
                    ),
                    "payload": fc_args.get("payload", {}),
                }
                yield {"data": json.dumps(req_payload)}

            payload = {
                "id": event.id,
                "author": event.author or "system",
                "is_final": event.is_final_response(),
                "partial": bool(getattr(event, "partial", False)),
                "parts": [],
            }
            if getattr(event, "invocation_id", None):
                payload["invocation_id"] = event.invocation_id
            if getattr(event, "model_version", None):
                payload["model_version"] = event.model_version
            if getattr(event, "finish_reason", None):
                payload["finish_reason"] = _parse_finish_reason(event.finish_reason)

            if getattr(event, "usage_metadata", None):
                parsed_u = _parse_usage_metadata(event.usage_metadata)
                if parsed_u:
                    p_cnt = parsed_u.get("prompt_tokens", 0)
                    c_cnt = parsed_u.get("candidates_tokens", 0)
                    th_cnt = parsed_u.get("thoughts_tokens", 0)
                    ca_cnt = parsed_u.get("cached_tokens", 0)
                    tot_cnt = parsed_u.get("total_tokens", 0)

                    total_prompt_tokens += p_cnt
                    total_candidates_tokens += c_cnt
                    total_thoughts_tokens += th_cnt
                    total_cached_tokens += ca_cnt
                    total_tokens += tot_cnt

                    evt_model = getattr(event, "model_version", None)
                    if not evt_model:
                        try:
                            from zocrates.config import DEFAULT_LLM_MODEL, load_zocrates_config

                            cfg = load_zocrates_config(zotero_dir)
                            role_overrides = cfg.get("override_agent_models") or {}
                            auth_str = (getattr(event, "author", "") or "").lower()
                            if "online_search" in auth_str:
                                evt_model = role_overrides.get("online_search")
                            elif "online_synth" in auth_str:
                                evt_model = role_overrides.get("online_synthesizer")
                            elif "library_search" in auth_str or "search" in auth_str:
                                evt_model = role_overrides.get("library_search")
                            elif "library_synth" in auth_str or "synthes" in auth_str:
                                evt_model = role_overrides.get("library_synthesizer")
                            elif "coord" in auth_str:
                                evt_model = role_overrides.get("coordinator")
                            if not evt_model:
                                evt_model = cfg.get("llm_model") or DEFAULT_LLM_MODEL
                        except Exception:
                            evt_model = "deepseek/deepseek-chat"

                    evt_auth = getattr(event, "author", None)
                    if evt_auth and evt_model:
                        try:
                            from zocrates.utils.cost_calculator import normalize_agent_role

                            norm_role = normalize_agent_role(evt_auth)
                            if norm_role:
                                streamed_agent_models[norm_role] = evt_model
                        except Exception:
                            pass

                    evt_cost = calculate_event_cost(
                        evt_model,
                        prompt_tokens=p_cnt,
                        candidates_tokens=c_cnt,
                        cached_tokens=ca_cnt,
                        zotero_data_dir=zotero_dir,
                    )
                    current_turn_cost += evt_cost

            if total_tokens > 0:
                payload["usage_metadata"] = {
                    "prompt_tokens": total_prompt_tokens,
                    "candidates_tokens": total_candidates_tokens,
                    "thoughts_tokens": total_thoughts_tokens,
                    "cached_tokens": total_cached_tokens,
                    "total_tokens": total_tokens,
                    "cost_usd": round(current_turn_cost, 6),
                    "turn_cost_usd": round(current_turn_cost, 6),
                    "agent_models": dict(streamed_agent_models),
                }

            if event.content and event.content.parts:
                current_th_chunk: list[str] = []
                for part in event.content.parts:
                    if getattr(part, "thought", False):
                        thought_val = getattr(part, "thought", None)
                        thought_str = (
                            getattr(part, "text", "")
                            if isinstance(thought_val, bool)
                            else str(thought_val or "")
                        )
                        if thought_str:
                            current_th_chunk.append(thought_str)
                    else:
                        if current_th_chunk:
                            joined_th = "".join(current_th_chunk)
                            if not getattr(event, "partial", False):
                                joined_th = joined_th.strip()
                            if joined_th:
                                payload["parts"].append({"thought": joined_th})
                            current_th_chunk = []

                        part_data = {}
                        if part.text:
                            part_data["text"] = part.text
                        if part.function_call:
                            if getattr(part.function_call, "name", None) not in ("adk_request_input", "finish_task") and not getattr(event, "partial", False):
                                part_data["function_call"] = {
                                    "name": part.function_call.name,
                                    "args": part.function_call.args,
                                    "id": getattr(part.function_call, "id", None),
                                }
                        if part.function_response:
                            if getattr(part.function_response, "name", None) != "finish_task":
                                part_data["function_response"] = {
                                    "name": part.function_response.name,
                                    "response": part.function_response.response,
                                    "id": getattr(part.function_response, "id", None),
                                }
                        if part_data:
                            payload["parts"].append(part_data)

                if current_th_chunk:
                    joined_th = "".join(current_th_chunk)
                    if not getattr(event, "partial", False):
                        joined_th = joined_th.strip()
                    if joined_th:
                        payload["parts"].append({"thought": joined_th})

            if event.is_final_response():
                try:
                    from zocrates.agents.common.state_keys import RETRIEVED_EVIDENCE
                    from zocrates.agents.library_search.citation_resolver import resolve_citations_and_references
                    from zocrates.config import (
                        get_citations_enabled,
                        get_considered_sources_disclosure,
                        get_references_section_enabled,
                    )

                    citations_enabled = get_citations_enabled(zotero_dir)
                    references_enabled = get_references_section_enabled(zotero_dir)
                    if session_id:
                        runner = getattr(app.state, "runner", None)
                        session_service = getattr(runner, "session_service", None) if runner else services.get_session_service(zotero_dir)
                        if session_service:
                            app_name = getattr(app.state, "agent_app_name", "zocrates")
                            raw_sess = session_service.get_session(
                                app_name=app_name, user_id="default_user", session_id=session_id
                            )
                            sess = await raw_sess if inspect.isawaitable(raw_sess) else raw_sess
                            if sess and getattr(sess, "state", None):
                                if citations_enabled or references_enabled:
                                    audit_history = list(sess.state.get(RETRIEVED_EVIDENCE) or [])
                                    if audit_history:
                                        last_audit = audit_history[-1]
                                        target_inv_id = getattr(event, "invocation_id", None) or current_invocation_id
                                        audit_inv_id = last_audit.get("invocation_id")
                                        # Scope resolution strictly to the current turn's search.
                                        # Conversational turns without a search pipeline run MUST NOT append stale sources from previous turns.
                                        if audit_inv_id != target_inv_id:
                                            logger.debug(
                                                "Skipping citation resolution: search audit invocation_id (%s) does not match current turn (%s)",
                                                audit_inv_id,
                                                target_inv_id,
                                            )
                                        else:
                                            ranked_chunks = last_audit.get("evidence_chunks") or []
                                            citations = last_audit.get("citations") or []
                                            references = last_audit.get("references") or []
                                            enriched_text = last_audit.get("enriched_text")
                                            text_indices = [
                                                i for i, p in enumerate(payload["parts"]) if "text" in p
                                            ]
                                            raw_final_text = "".join(
                                                payload["parts"][i]["text"] for i in text_indices
                                            )

                                            # Fallback in-process resolution if not already enriched by search_pipeline
                                            if not enriched_text and ranked_chunks:
                                                considered_disclosure = get_considered_sources_disclosure(zotero_dir)
                                                resolved = resolve_citations_and_references(
                                                    text=raw_final_text,
                                                    ranked_chunks=ranked_chunks,
                                                    zotero_dir=zotero_dir,
                                                    references_enabled=references_enabled,
                                                    considered_disclosure=considered_disclosure,
                                                    citations_enabled=citations_enabled,
                                                    support_probabilities=last_audit.get("support_probabilities"),
                                                )
                                                enriched_text = resolved["enriched_text"]
                                                citations = resolved["citations"]
                                                references = resolved["references"]
                                                last_audit["citations"] = citations
                                                last_audit["references"] = references
                                                last_audit["cited_chunk_ids"] = resolved["cited_chunk_ids"]
                                                last_audit["enriched_text"] = enriched_text

                                            if text_indices and enriched_text:
                                                payload["parts"][text_indices[0]]["text"] = enriched_text
                                                for idx in text_indices[1:]:
                                                    payload["parts"][idx]["text"] = ""
                                                payload["parts"] = [
                                                    p for p in payload["parts"] if p.get("text") != "" or "text" not in p
                                                ]

                                            if enriched_text:
                                                payload["enriched_text"] = enriched_text
                                                if "metadata" not in payload:
                                                    payload["metadata"] = {}
                                                payload["metadata"]["enriched_text"] = enriched_text
                                                if hasattr(event, "custom_metadata"):
                                                    if event.custom_metadata is None:
                                                        event.custom_metadata = {}
                                                    event.custom_metadata["enriched_text"] = enriched_text

                                            if citations or references:
                                                payload["citations"] = citations
                                                payload["references"] = references
                                                if "metadata" not in payload:
                                                    payload["metadata"] = {}
                                                payload["metadata"]["citations"] = citations
                                                payload["metadata"]["references"] = references

                                            if hasattr(event, "custom_metadata"):
                                                if event.custom_metadata is None:
                                                    event.custom_metadata = {}
                                                event.custom_metadata["citations"] = citations
                                                event.custom_metadata["references"] = references

                                            citation_verification = last_audit.get("citation_verification_telemetry")
                                            if citation_verification:
                                                payload["citation_verification"] = citation_verification
                                                if "metadata" not in payload:
                                                    payload["metadata"] = {}
                                                payload["metadata"]["citation_verification"] = citation_verification
                                                if hasattr(event, "custom_metadata"):
                                                    if event.custom_metadata is None:
                                                        event.custom_metadata = {}
                                                    event.custom_metadata["citation_verification"] = citation_verification

                                # Check for discovered candidate papers in state (scoped strictly to current invocation)
                                target_inv_id = getattr(event, "invocation_id", None) or current_invocation_id
                                cand_inv_id = sess.state.get("candidates_invocation_id")
                                if (
                                    target_inv_id
                                    and cand_inv_id
                                    and target_inv_id == cand_inv_id
                                ):
                                    candidates = (
                                        sess.state.get("candidates")
                                        or sess.state.get("selected_candidates")
                                        or (
                                            sess.state.get("metadata", {}).get("candidates")
                                            if isinstance(sess.state.get("metadata"), dict)
                                            else None
                                        )
                                    )
                                    if candidates and isinstance(candidates, list):
                                        if "metadata" not in payload:
                                            payload["metadata"] = {}
                                        candidate_list = [
                                            c.to_dict() if hasattr(c, "to_dict") else c for c in candidates
                                        ]
                                        payload["metadata"]["candidates"] = candidate_list
                                        payload["candidates"] = candidate_list

                                        if hasattr(event, "custom_metadata"):
                                            if event.custom_metadata is None:
                                                event.custom_metadata = {}
                                            event.custom_metadata["candidates"] = candidate_list
                except Exception as cit_err:
                    logger.warning("Error resolving citations on final response: %s", cit_err)

            if payload["parts"] or total_tokens > 0 or request_input_fc is None:
                yield {"data": json.dumps(payload, default=str)}

    except Exception as err:
        logger.error("Error running agent: %s", err, exc_info=True)
        if not current_invocation_id and session_id:
            try:
                runner = getattr(app.state, "runner", None)
                session_service = getattr(runner, "session_service", None) if runner else services.get_session_service(zotero_dir)
                if session_service:
                    app_name = getattr(app.state, "agent_app_name", "zocrates")
                    raw_sess = session_service.get_session(
                        app_name=app_name, user_id="default_user", session_id=session_id
                    )
                    sess = await raw_sess if inspect.isawaitable(raw_sess) else raw_sess
                    if sess and getattr(sess, "events", None):
                        for evt in reversed(sess.events):
                            if getattr(evt, "author", None) == "user" and getattr(evt, "invocation_id", None):
                                is_match = True
                                if request_start_time is not None or prompt_text is not None:
                                    is_match = False
                                    evt_ts = getattr(evt, "timestamp", None)
                                    if request_start_time is not None and isinstance(evt_ts, (int, float)):
                                        if evt_ts >= (request_start_time - 5.0):
                                            is_match = True

                                    if not is_match and prompt_text is not None:
                                        evt_text = ""
                                        evt_content = getattr(evt, "content", None)
                                        if evt_content and getattr(evt_content, "parts", None):
                                            parts_texts = []
                                            for p in evt_content.parts:
                                                if getattr(p, "thought", False):
                                                    continue
                                                if isinstance(p, str):
                                                    parts_texts.append(p)
                                                else:
                                                    txt = getattr(p, "text", None)
                                                    if txt and not isinstance(txt, MagicMock):
                                                        parts_texts.append(str(txt))
                                            evt_text = "".join(parts_texts)
                                        elif isinstance(evt_content, str):
                                            evt_text = evt_content
                                        if evt_text and (prompt_text.strip() == evt_text.strip() or prompt_text.strip() in evt_text):
                                            is_match = True

                                if is_match:
                                    current_invocation_id = evt.invocation_id
                                    break
            except Exception as sess_err:
                logger.warning("Failed to lookup fallback invocation_id for session %s: %s", session_id, sess_err)

        from zocrates.llm import format_llm_exception
        from zocrates.config import load_zocrates_config

        cfg = load_zocrates_config()
        provider = cfg.get("llm_provider", "openrouter_llm")
        base_url = cfg.get("llm_base_url")
        formatted_msg = format_llm_exception(err, provider=provider, base_url=base_url)
        err_payload: dict[str, Any] = {
            "error": formatted_msg,
            "author": "system",
            "is_final": True,
        }
        if current_invocation_id:
            err_payload["invocation_id"] = current_invocation_id
        yield {
            "data": json.dumps(err_payload)
        }

_sync_stream_event_generator

_sync_stream_event_generator(job, queue, raw_request)

Stream one subscriber's view of the sync job; disconnect cancels cooperatively.

Source code in zocrates/fast_api_app.py
def _sync_stream_event_generator(
    job: dict[str, Any],
    queue: asyncio.Queue,
    raw_request: Request,
) -> AsyncIterator[dict[str, str]]:
    """Stream one subscriber's view of the sync job; disconnect cancels cooperatively."""

    async def generator() -> AsyncIterator[dict[str, str]]:
        """Yield sync progress events and cooperative cancellation signals.

        Yields:
            SSE event dictionaries from the subscriber queue.
        """
        terminal_sent = False
        try:
            while True:
                if await raw_request.is_disconnected():
                    logger.info(
                        "Client disconnected from /sync/stream; cooperative cancel evaluated."
                    )
                    _cancel_if_last_subscriber(job, queue)
                    break
                try:
                    event = await asyncio.wait_for(queue.get(), timeout=0.5)
                except (asyncio.TimeoutError, TimeoutError):
                    # Job may have finished between queue drain and terminal
                    # fan-out; break cleanly instead of hanging.
                    task = job.get("task")
                    if task is not None and task.done() and queue.empty():
                        # Race guard: the terminal event may have been dropped
                        # by the cancelled Queue.get above; deliver it before
                        # breaking so the stream always terminates cleanly.
                        if not terminal_sent and job.get("terminal") is not None:
                            terminal_sent = True
                            yield job["terminal"]
                        break
                    continue
                yield event
                if event is job.get("terminal") or event.get("event") in _SYNC_TERMINAL_EVENTS:
                    terminal_sent = True
                    break
        finally:
            job["subscribers"].discard(queue)
            # Transport-level safety net: when the SSE response task is
            # cancelled (client vanished), sse_starlette cancels this
            # generator directly — the body-iteration is_disconnected() check
            # above may never observe the disconnect. If this was the last
            # subscriber and the job is still running, cooperatively cancel.
            _cancel_if_last_subscriber(job, queue)

    return generator()

_trigger_shutdown

_trigger_shutdown()

Terminate the process after sending the shutdown response.

Source code in zocrates/fast_api_app.py
def _trigger_shutdown() -> None:
    """Terminate the process after sending the shutdown response."""
    logger.info("Executing scheduled backend shutdown.")

    def _force_exit() -> None:
        _safe_cleanup_snapshots()
        os._exit(0)

    try:
        import signal

        if hasattr(signal, "SIGTERM") and sys.platform != "win32":
            threading.Timer(2.0, _force_exit).start()
            _safe_cleanup_snapshots()
            os.kill(os.getpid(), signal.SIGTERM)
            return
    except Exception as exc:
        logger.warning("Error signaling SIGTERM: %s. Forcing exit.", exc)
    _safe_cleanup_snapshots()
    os._exit(0)

_validate_item_key

_validate_item_key(item_key)

Validate item key to prevent path traversal.

Source code in zocrates/fast_api_app.py
def _validate_item_key(item_key: str) -> bool:
    """Validate item key to prevent path traversal."""
    return bool(item_key and _SAFE_KEY_RE.match(item_key))

_verify_mcp_access

_verify_mcp_access()

Validate that MCP access is enabled and backend services are initialized.

Returns the active LibrarySyncer instance if registered. Raises: HTTPException 403: if MCP server access is disabled in configuration. HTTPException 503: if the Zotero library is not yet registered or synced.

Source code in zocrates/fast_api_app.py
def _verify_mcp_access() -> Any:
    """Validate that MCP access is enabled and backend services are initialized.

    Returns the active LibrarySyncer instance if registered.
    Raises:
        HTTPException 403: if MCP server access is disabled in configuration.
        HTTPException 503: if the Zotero library is not yet registered or synced.
    """
    syncer = None
    try:
        syncer = get_syncer()
    except RuntimeError:
        syncer = None

    data_dir = getattr(syncer, "_zotero_data_dir", None) if syncer else None
    if not is_mcp_server_enabled(data_dir):
        raise HTTPException(
            status_code=403,
            detail=(
                "MCP server access is disabled in Zocrates preferences. "
                "Please enable it in Zotero Preferences -> Zocrates to allow "
                "external AI tools to query your library."
            ),
        )

    if syncer is None:
        raise HTTPException(
            status_code=503,
            detail=(
                "Zotero library is not yet registered or synced. "
                "Please open Zotero with Zocrates enabled."
            ),
        )

    return syncer

_write_auth_sidecar

_write_auth_sidecar(auth_file, token)

Write auth token to sidecar file with 0o600 permissions (best-effort).

Source code in zocrates/config.py
def _write_auth_sidecar(auth_file: Path, token: str) -> None:
    """Write auth token to sidecar file with 0o600 permissions (best-effort)."""
    try:
        auth_file.parent.mkdir(parents=True, exist_ok=True)
        fd = os.open(str(auth_file), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        try:
            with os.fdopen(fd, "w", encoding="utf-8") as f:
                f.write(token + "\n")
        except Exception:
            with contextlib.suppress(OSError):
                os.close(fd)
            raise
        with contextlib.suppress(OSError):
            os.chmod(auth_file, 0o600)
    except OSError as exc:
        logger.debug("Could not write backend.auth sidecar at %s: %s", auth_file, exc)

batch_convert async

batch_convert(batch_req, raw_request)

Batch re-convert documents with Server-Sent Events (SSE) progress streaming.

Processes documents serially to avoid CPU/VRAM thrashing and cloud rate limits.

Source code in zocrates/fast_api_app.py
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
@app.post("/batch-convert", dependencies=[Depends(verify_auth)])
async def batch_convert(
        batch_req: BatchConvertRequest,
        raw_request: Request,
) -> EventSourceResponse:
    """Batch re-convert documents with Server-Sent Events (SSE) progress streaming.

    Processes documents serially to avoid CPU/VRAM thrashing and cloud rate limits.
    """
    try:
        syncer = get_syncer()
    except RuntimeError as exc:
        raise HTTPException(
            status_code=400,
            detail="Zocrates backend is not registered. Call /register first.",
        ) from exc

    from zocrates.rag.chunker import chunk_markdown
    from zocrates.config import (
        CHUNK_MAX_CHARS,
        CHUNK_OVERLAP_CHARS,
        CONVERSION_TIMEOUT_SECONDS,
        get_conversion_timeout_seconds,
        get_document_converter_id,
    )
    from zocrates.rag.document_processor import _file_sha256

    active_id = str(
        batch_req.converter_id
        or syncer.processor._document_converter_id
        or get_document_converter_id(syncer._zotero_data_dir)
        or "markitdown"
    )

    try:
        syncer.processor._get_converter_by_id(active_id)
    except KeyError as exc:
        raise HTTPException(
            status_code=400,
            detail={
                "error": "unknown_converter",
                "message": f"Converter '{active_id}' is not registered.",
                "converter_id": active_id,
            },
        ) from exc

    logger.info(
        "Batch convert requested for %d keys with converter='%s' (force=%s): %s",
        len(batch_req.item_keys),
        active_id,
        batch_req.force,
        batch_req.item_keys,
    )

    # Resolve items and attachments
    targets: list[dict[str, Any]] = []
    for key in batch_req.item_keys:
        clean_key = key.strip()
        if not clean_key:
            continue

        att = syncer.db.get_attachment_by_key(clean_key)
        parent_item = None
        if att:
            if att.parent_item_id:
                parent_item = syncer.db.get_item(att.parent_item_id)
            elif att.parent_item_key:
                parent_item = syncer.db.get_item_by_key(att.parent_item_key)
            title = (
                parent_item.title
                if (parent_item and isinstance(getattr(parent_item, "title", None), str))
                else (att.path.name if att.path else clean_key)
            )
            targets.append({
                "attachment": att,
                "parent_item": parent_item,
                "title": title,
                "original_key": clean_key,
            })
        else:
            parent_item = syncer.db.get_item_by_key(clean_key)
            if parent_item:
                atts = syncer.db.get_attachments(parent_item.item_id)
                primary_att = None
                if atts:
                    pdf_atts = [
                        a
                        for a in atts
                        if (
                                   a.content_type == "application/pdf"
                                   or (a.path and a.path.suffix.lower() == ".pdf")
                           )
                           and a.path
                           and a.path.exists()
                    ]
                    if pdf_atts:
                        primary_att = pdf_atts[0]
                    else:
                        valid_atts = [a for a in atts if a.path and a.path.exists()]
                        if valid_atts:
                            primary_att = valid_atts[0]
                title = parent_item.title if isinstance(getattr(parent_item, "title", None), str) else clean_key
                targets.append({
                    "attachment": primary_att,
                    "parent_item": parent_item,
                    "title": title,
                    "original_key": clean_key,
                })
            else:
                targets.append({
                    "attachment": None,
                    "parent_item": None,
                    "title": clean_key,
                    "original_key": clean_key,
                })

    async def sse_batch_generator() -> AsyncIterator[dict[str, Any]]:
        """Stream batch document conversion progress events over Server-Sent Events.

        Yields:
            Dictionary event payloads formatted for EventSourceResponse.
        """
        total = len(targets)
        processed = 0
        skipped = 0
        failed = 0
        failed_items: list[dict[str, Any]] = []

        logger.info(
            "Starting batch conversion loop for %d target documents with engine '%s'",
            total,
            active_id,
        )

        if total == 0:
            yield {
                "event": "complete",
                "data": json.dumps({
                    "total": 0,
                    "processed": 0,
                    "skipped": 0,
                    "failed": 0,
                    "failed_items": [],
                }),
            }
            return

        for idx, target in enumerate(targets, 1):
            if await raw_request.is_disconnected():
                logger.info("Client disconnected during batch convert. Stopping loop.")
                break

            att = target["attachment"]
            parent_item = target["parent_item"]
            title = target["title"]
            orig_key = target["original_key"]

            if not att or not att.path or not att.path.exists():
                failed += 1
                err_msg = f"No valid attachment file found on disk for key '{orig_key}'"
                logger.error("[%d/%d] Attachment error for '%s' (%s): %s", idx, total, title, orig_key, err_msg)
                failed_items.append({"item_key": orig_key, "title": title, "error": err_msg})
                yield {
                    "event": "progress",
                    "data": json.dumps({
                        "processed": processed,
                        "skipped": skipped,
                        "failed": failed,
                        "total": total,
                        "current_index": idx,
                        "current_key": orig_key,
                        "current_title": title,
                        "status": "failed",
                        "error": err_msg,
                    }),
                }
                continue

            # Determine effective converter (route non-PDFs to fallback for Datalab)
            is_pdf = att.content_type == "application/pdf" or (att.path and att.path.suffix.lower() == ".pdf")
            if active_id == "datalab" and not is_pdf:
                effective_id = syncer.processor.datalab_non_pdf_fallback or "markitdown"
            else:
                effective_id = active_id

            # Check if already converted with effective converter (incremental filtering)
            if not batch_req.force:
                meta = syncer.processor.get_metadata(att.item_key)
                if meta and meta.get("converter_id") == effective_id:
                    try:
                        current_hash = _file_sha256(att.path)
                        if meta.get("source_hash") == current_hash:
                            skipped += 1
                            logger.info("[%d/%d] Skipping already converted item %s ('%s') with %s", idx, total,
                                        att.item_key, title, effective_id)
                            yield {
                                "event": "progress",
                                "data": json.dumps({
                                    "processed": processed,
                                    "skipped": skipped,
                                    "failed": failed,
                                    "total": total,
                                    "current_index": idx,
                                    "current_key": att.item_key,
                                    "current_title": title,
                                    "status": "skipped",
                                    "message": f"Already converted with {effective_id}",
                                    "error": None,
                                }),
                            }
                            continue
                    except Exception:
                        pass

            # Execute conversion serially
            p_type = parent_item.item_type if (parent_item and isinstance(getattr(parent_item, "item_type", None), str)) else None
            p_title = parent_item.title if (parent_item and isinstance(getattr(parent_item, "title", None), str)) else title
            p_colls = parent_item.collections if (parent_item and isinstance(getattr(parent_item, "collections", None), list)) else None
            p_authors = parent_item.creators if (parent_item and isinstance(getattr(parent_item, "creators", None), list)) else None
            p_year = parent_item.year if (parent_item and isinstance(getattr(parent_item, "year", None), (str, int))) else None
            p_pub = parent_item.publication if (parent_item and isinstance(getattr(parent_item, "publication", None), str)) else None
            p_doi = parent_item.doi if (parent_item and isinstance(getattr(parent_item, "doi", None), str)) else None
            p_url = parent_item.url if (parent_item and isinstance(getattr(parent_item, "url", None), str)) else None
            p_abstract = parent_item.abstract if (parent_item and isinstance(getattr(parent_item, "abstract", None), str)) else None

            logger.info("[%d/%d] Converting item %s ('%s') using engine '%s' (effective: '%s')...", idx, total,
                        att.item_key, p_title, active_id, effective_id)
            yield {
                "event": "progress",
                "data": json.dumps({
                    "processed": processed,
                    "skipped": skipped,
                    "failed": failed,
                    "total": total,
                    "current_index": idx,
                    "current_key": att.item_key,
                    "current_title": title,
                    "status": "converting",
                    "error": None,
                }),
            }

            try:
                markdown = await asyncio.wait_for(
                    asyncio.to_thread(
                        syncer.processor.convert_with,
                        item_key=att.item_key,
                        converter_id=effective_id,
                        source_path=att.path,
                        content_type=att.content_type,
                        parent_item_type=p_type,
                        title=p_title,
                        collections=p_colls,
                        parent_item_key=att.parent_item_key,
                        authors=p_authors,
                        year=p_year,
                        publication=p_pub,
                        doi=p_doi,
                        url=p_url,
                        abstract=p_abstract,
                    ),
                    timeout=get_conversion_timeout_seconds(syncer._zotero_data_dir),
                )

                yield {
                    "event": "progress",
                    "data": json.dumps({
                        "processed": processed,
                        "skipped": skipped,
                        "failed": failed,
                        "total": total,
                        "current_index": idx,
                        "current_key": att.item_key,
                        "current_title": title,
                        "status": "indexing",
                        "error": None,
                    }),
                }

                # Post-conversion VLM figure enrichment pass
                try:
                    from zocrates.rag.vlm_captioner import enrich_document_bundle

                    bundle_dir = syncer.processor._cache_dir / att.item_key
                    enriched = await asyncio.to_thread(
                        enrich_document_bundle,
                        bundle_dir,
                        syncer._zotero_data_dir,
                    )
                    if enriched:
                        markdown = enriched
                except Exception as exc:
                    logger.warning(
                        "VLM figure enrichment failed for %s: %s; proceeding with base markdown",
                        att.item_key,
                        exc,
                    )

                # Update ToC on final post-enrichment markdown
                try:
                    from zocrates.rag.toc_generator import generate_toc, save_toc_files

                    bundle_dir = syncer.processor._cache_dir / att.item_key
                    meta = syncer.processor.get_metadata(att.item_key)
                    abs_text = (meta.get("abstract") if meta else None) or p_abstract
                    abs_src = (meta.get("abstract_source") if meta else None) or ("zotero" if p_abstract else "extractive")
                    t_json, t_md = generate_toc(
                        markdown,
                        title=p_title,
                        abstract=abs_text,
                        abstract_source=abs_src,
                        item_key=att.item_key,
                        parent_item_key=att.parent_item_key,
                    )
                    save_toc_files(bundle_dir, t_json, t_md)
                except Exception as exc:
                    logger.warning(
                        "Post-enrichment ToC generation failed for %s: %s",
                        att.item_key,
                        exc,
                    )

                chunks = chunk_markdown(
                    markdown,
                    max_chars=CHUNK_MAX_CHARS,
                    overlap_chars=CHUNK_OVERLAP_CHARS,
                    document_title=p_title,
                )
                if chunks:
                    meta_dict = {
                        "content_type": att.content_type,
                    }
                    if att.parent_item_key:
                        meta_dict["parent_item_key"] = att.parent_item_key
                    if att.parent_item_id:
                        meta_dict["parent_item_id"] = att.parent_item_id
                    else:
                        meta_dict["is_standalone"] = True

                    await asyncio.to_thread(
                        syncer.store.ingest_document,
                        att.item_key,
                        att.parent_item_id or att.item_id,
                        chunks,
                        meta_dict,
                    )
                    syncer.processor.update_indexed_at(att.item_key)
                    _dispatch_edge_evaluation(att.item_key, syncer)

                processed += 1
                logger.info(
                    "[%d/%d] Successfully converted and indexed item %s ('%s') into %d chunks (%d chars)",
                    idx,
                    total,
                    att.item_key,
                    p_title,
                    len(chunks),
                    len(markdown),
                )
                yield {
                    "event": "progress",
                    "data": json.dumps({
                        "processed": processed,
                        "skipped": skipped,
                        "failed": failed,
                        "total": total,
                        "current_index": idx,
                        "current_key": att.item_key,
                        "current_title": title,
                        "status": "completed",
                        "error": None,
                    }),
                }

            except asyncio.TimeoutError:
                t_sec = get_conversion_timeout_seconds(syncer._zotero_data_dir)
                timeout_msg = (
                    f"Conversion timed out after {t_sec}s with engine '{effective_id}'."
                )
                try:
                    source_hash = _file_sha256(att.path)
                    syncer.processor.record_failure(
                        att.item_key, effective_id, timeout_msg, source_hash
                    )
                except Exception:
                    pass
                failed += 1
                logger.error("[%d/%d] Conversion timeout on item %s ('%s'): %s", idx, total, att.item_key, p_title,
                             timeout_msg)
                failed_items.append({"item_key": att.item_key, "title": title, "error": timeout_msg})
                yield {
                    "event": "progress",
                    "data": json.dumps({
                        "processed": processed,
                        "skipped": skipped,
                        "failed": failed,
                        "total": total,
                        "current_index": idx,
                        "current_key": att.item_key,
                        "current_title": title,
                        "status": "failed",
                        "error": timeout_msg,
                    }),
                }
            except Exception as exc:
                err_msg = str(exc)
                try:
                    source_hash = _file_sha256(att.path)
                    syncer.processor.record_failure(
                        att.item_key, effective_id, err_msg, source_hash
                    )
                except Exception:
                    pass
                failed += 1
                logger.error("[%d/%d] Conversion failed on item %s ('%s') with engine '%s': %s", idx, total,
                             att.item_key, p_title, effective_id, err_msg, exc_info=True)
                failed_items.append({"item_key": att.item_key, "title": title, "error": err_msg})
                yield {
                    "event": "progress",
                    "data": json.dumps({
                        "processed": processed,
                        "skipped": skipped,
                        "failed": failed,
                        "total": total,
                        "current_index": idx,
                        "current_key": att.item_key,
                        "current_title": title,
                        "status": "failed",
                        "error": err_msg,
                    }),
                }

        logger.info(
            "Batch conversion finished: %d processed, %d skipped, %d failed out of %d total targets",
            processed,
            skipped,
            failed,
            total,
        )

        yield {
            "event": "complete",
            "data": json.dumps({
                "total": total,
                "processed": processed,
                "skipped": skipped,
                "failed": failed,
                "failed_items": failed_items,
            }),
        }

    return EventSourceResponse(sse_batch_generator())

batch_index async

batch_index(batch_req, raw_request)

Batch index cached documents into the vector store with Server-Sent Events (SSE) progress streaming.

Source code in zocrates/fast_api_app.py
@app.post("/batch-index", dependencies=[Depends(verify_auth)])
async def batch_index(
        batch_req: BatchIndexRequest,
        raw_request: Request,
) -> EventSourceResponse:
    """Batch index cached documents into the vector store with Server-Sent Events (SSE) progress streaming."""
    try:
        syncer = get_syncer()
    except RuntimeError as exc:
        raise HTTPException(
            status_code=400,
            detail="Zocrates backend is not registered. Call /register first.",
        ) from exc

    from zocrates.rag.chunker import chunk_markdown
    from zocrates.config import CHUNK_MAX_CHARS, CHUNK_OVERLAP_CHARS

    targets: list[dict[str, Any]] = []

    if batch_req.all_pending:
        raw_attachments = syncer.db.get_all_convertible_attachments()
        meta_map = syncer.processor.get_all_metadata()
        ingested_keys = syncer.store.get_all_ingested_item_keys()

        for att in raw_attachments:
            ak = att["attachment_key"]
            if ak in meta_map and ak not in ingested_keys:
                md_path = syncer.processor.cache_dir / ak / "document.md"
                if md_path.exists():
                    parent_item = syncer.db.get_item_by_key(att["parent_key"]) if att["parent_key"] else None
                    title = parent_item.title if parent_item and parent_item.title else (att["filename"] or ak)
                    targets.append({
                        "item_key": ak,
                        "parent_item_key": att["parent_key"] or "",
                        "parent_item": parent_item,
                        "content_type": att.get("content_type"),
                        "title": title,
                        "md_path": md_path,
                    })
    elif batch_req.keys:
        for key in batch_req.keys:
            clean_key = key.strip()
            if not clean_key:
                continue
            md_path = syncer.processor.cache_dir / clean_key / "document.md"
            if md_path.exists():
                att = syncer.db.get_attachment_by_key(clean_key)
                parent_item = None
                if att and att.parent_item_id:
                    parent_item = syncer.db.get_item(att.parent_item_id)
                elif att and att.parent_item_key:
                    parent_item = syncer.db.get_item_by_key(att.parent_item_key)
                title = parent_item.title if parent_item and parent_item.title else (
                    att.path.name if att and att.path else clean_key)
                targets.append({
                    "item_key": clean_key,
                    "parent_item_key": (att.parent_item_key if att else "") or (
                        parent_item.item_key if parent_item else ""),
                    "parent_item": parent_item,
                    "content_type": att.content_type if att else None,
                    "title": title,
                    "md_path": md_path,
                })
            else:
                parent_item = syncer.db.get_item_by_key(clean_key)
                if parent_item:
                    atts = syncer.db.get_attachments(parent_item.item_id)
                    for att in atts:
                        a_md = syncer.processor.cache_dir / att.item_key / "document.md"
                        if a_md.exists():
                            title = parent_item.title or att.item_key
                            targets.append({
                                "item_key": att.item_key,
                                "parent_item_key": clean_key,
                                "parent_item": parent_item,
                                "content_type": att.content_type,
                                "title": title,
                                "md_path": a_md,
                            })

    async def sse_index_generator() -> AsyncIterator[dict[str, Any]]:
        """Stream batch indexing progress events over Server-Sent Events.

        Yields:
            Dictionary event payloads formatted for EventSourceResponse.
        """
        total = len(targets)
        processed = 0
        failed = 0
        failed_items: list[dict[str, Any]] = []

        logger.info("Starting batch indexing SSE loop for %d documents", total)

        yield {
            "event": "batch_start",
            "data": json.dumps({"total": total}),
        }

        if total == 0:
            yield {
                "event": "batch_complete",
                "data": json.dumps({
                    "total": 0,
                    "processed": 0,
                    "failed": 0,
                    "failed_items": [],
                }),
            }
            return

        for idx, target in enumerate(targets, 1):
            if await raw_request.is_disconnected():
                logger.info("Client disconnected during batch index. Stopping loop.")
                break

            ak = target["item_key"]
            title = target["title"]
            md_path = target["md_path"]
            parent_item = target["parent_item"]
            parent_key = target["parent_item_key"]
            content_type = target["content_type"]

            yield {
                "event": "item_start",
                "data": json.dumps({
                    "index": idx,
                    "total": total,
                    "key": ak,
                    "title": title,
                }),
            }

            try:
                markdown_text = await asyncio.to_thread(md_path.read_text, "utf-8")
                chunks = chunk_markdown(
                    markdown_text,
                    max_chars=CHUNK_MAX_CHARS,
                    overlap_chars=CHUNK_OVERLAP_CHARS,
                    document_title=title,
                )

                if chunks:
                    meta_dict = {
                        "content_type": content_type,
                    }
                    if parent_key:
                        meta_dict["parent_item_key"] = parent_key
                    if parent_item and parent_item.item_id:
                        meta_dict["parent_item_id"] = parent_item.item_id
                        item_id = parent_item.item_id
                    else:
                        meta_dict["is_standalone"] = True
                        item_id = 0

                    await asyncio.to_thread(
                        syncer.store.ingest_document,
                        ak,
                        item_id,
                        chunks,
                        meta_dict,
                    )
                    syncer.processor.update_indexed_at(ak)
                    _dispatch_edge_evaluation(ak, syncer)

                processed += 1
                logger.info(
                    "[%d/%d] Successfully indexed item %s ('%s') into %d chunks",
                    idx,
                    total,
                    ak,
                    title,
                    len(chunks),
                )
                yield {
                    "event": "item_success",
                    "data": json.dumps({
                        "index": idx,
                        "total": total,
                        "key": ak,
                        "title": title,
                        "chunks": len(chunks),
                        "processed": processed,
                    }),
                }
            except Exception as exc:
                failed += 1
                err_msg = str(exc)
                logger.error(
                    "[%d/%d] Indexing failed for item %s ('%s'): %s",
                    idx,
                    total,
                    ak,
                    title,
                    exc,
                    exc_info=True,
                )
                failed_items.append({"key": ak, "title": title, "error": err_msg})
                yield {
                    "event": "item_error",
                    "data": json.dumps({
                        "index": idx,
                        "total": total,
                        "key": ak,
                        "title": title,
                        "error": err_msg,
                    }),
                }
            finally:
                markdown_text = None
                chunks = None
                meta_dict = None
                gc.collect()

            await asyncio.sleep(0)

        yield {
            "event": "batch_complete",
            "data": json.dumps({
                "total": total,
                "processed": processed,
                "failed": failed,
                "failed_items": failed_items,
            }),
        }

    return EventSourceResponse(sse_index_generator())

calculate_event_cost

calculate_event_cost(model_name, prompt_tokens, candidates_tokens, cached_tokens=0, zotero_data_dir=None)

Calculate the USD cost for a single model generation event.

Parameters:

Name Type Description Default
model_name str | None

The identifier of the model used.

required
prompt_tokens int

Total prompt token count (including cached tokens).

required
candidates_tokens int

Completion token count (includes thoughts; not double-counted).

required
cached_tokens int

Number of prompt tokens read from cache.

0
zotero_data_dir Path | str | None

Optional Zotero data directory for catalog discovery.

None

Returns:

Type Description
float

USD cost as a float.

Source code in zocrates/utils/cost_calculator.py
def calculate_event_cost(
    model_name: str | None,
    prompt_tokens: int,
    candidates_tokens: int,
    cached_tokens: int = 0,
    zotero_data_dir: Path | str | None = None,
) -> float:
    """Calculate the USD cost for a single model generation event.

    Args:
        model_name: The identifier of the model used.
        prompt_tokens: Total prompt token count (including cached tokens).
        candidates_tokens: Completion token count (includes thoughts; not double-counted).
        cached_tokens: Number of prompt tokens read from cache.
        zotero_data_dir: Optional Zotero data directory for catalog discovery.

    Returns:
        USD cost as a float.
    """
    if not model_name:
        return 0.0
    lowered = str(model_name).lower().strip()
    if lowered.endswith(":free") or any(lowered.startswith(p) for p in ("ollama/", "ollama_chat/", "vllm/", "local/", "local:", "test/")):
        return 0.0

    prompt_rate, completion_rate, cache_read_rate = get_model_rates(
        model_name, zotero_data_dir=zotero_data_dir
    )
    prompt_tokens = max(0, int(prompt_tokens or 0))
    cached_tokens = max(0, int(cached_tokens or 0))
    candidates_tokens = max(0, int(candidates_tokens or 0))

    uncached_prompt = max(0, prompt_tokens - cached_tokens)
    cost = (
        (uncached_prompt * prompt_rate)
        + (cached_tokens * cache_read_rate)
        + (candidates_tokens * completion_rate)
    )
    return float(cost)

chat_endpoint async

chat_endpoint(request)

Streaming chat endpoint using Server-Sent Events (SSE).

Raises:

Type Description
HTTPException

400 if the backend is not yet registered.

Returns:

Type Description
EventSourceResponse

An EventSourceResponse streaming JSON-encoded data: payloads,

EventSourceResponse

each containing id, author, is_final, and parts keys.

Source code in zocrates/fast_api_app.py
@app.post("/chat", dependencies=[Depends(verify_auth)])
async def chat_endpoint(request: ChatRequest) -> EventSourceResponse:
    """Streaming chat endpoint using Server-Sent Events (SSE).

    Raises:
        HTTPException: 400 if the backend is not yet registered.

    Returns:
        An ``EventSourceResponse`` streaming JSON-encoded ``data:`` payloads,
        each containing ``id``, ``author``, ``is_final``, and ``parts`` keys.
    """
    try:
        syncer = get_syncer()
    except RuntimeError as e:
        raise HTTPException(status_code=400, detail=str(e)) from e

    zotero_dir = getattr(syncer, "_zotero_data_dir", None)
    _maybe_hot_swap_agent_models(zotero_dir, "before chat")

    runner: Runner = app.state.runner

    session_getter = getattr(getattr(runner, "session_service", None), "get_session", None)
    session = None
    if session_getter is not None:
        raw_session = session_getter(
            app_name="zocrates", user_id="default_user", session_id=request.session_id
        )
        if inspect.isawaitable(raw_session):
            session = await raw_session
        else:
            session = raw_session
    if session:
        pending_interrupts = _get_unresolved_session_interrupts(session)
        if pending_interrupts:
            raise HTTPException(
                status_code=409,
                detail="Session has a pending research extension request. Please respond to the checkpoint or cancel.",
            )

    active_ids = getattr(app.state, "active_chat_session_ids", None)
    if active_ids is not None:
        active_ids.add(request.session_id)

    async def sse_event_generator() -> AsyncIterator[dict[str, Any]]:
        """Resolve context items, run the ADK agent, and yield SSE payloads.

        Expands collection keys to their member item keys, builds a
        ``state_delta`` dict for the runner, then streams all ADK events as
        JSON-encoded ``data:`` payloads. Errors are surfaced as
        ``{"error": ...}`` payloads so the client can display them without
        crashing.
        """
        try:
            syncer = get_syncer()

            # Resolve active items context and keys
            has_library_context = False
            active_collections = []
            active_context_items = []
            seen_keys = set()

            for item in request.active_items or []:
                itype = (
                    item.type
                    if hasattr(item, "type") and item.type is not None
                    else (item.get("type", "item") if isinstance(item, dict) else "item")
                )
                key = (
                    item.key
                    if hasattr(item, "key") and item.key is not None
                    else (item.get("key", "") if isinstance(item, dict) else "")
                )
                title = (
                    item.title
                    if hasattr(item, "title") and item.title is not None
                    else (item.get("title", "") if isinstance(item, dict) else "")
                )

                if itype == "library":
                    has_library_context = True
                elif itype == "collection":
                    col_name = title.removeprefix("Collection: ").strip() if title else key
                    active_collections.append(col_name)
                    try:
                        keys = syncer.db.get_item_keys_in_collection(key) or syncer.db.get_item_keys_in_collection(col_name)
                    except Exception as e:
                        logger.error(
                            "Failed to fetch collection keys for %s: %s", key, e
                        )
                        keys = []
                    for k in keys:
                        if k not in seen_keys:
                            seen_keys.add(k)
                            active_context_items.append(resolve_active_item_entry(syncer, k))
                else:
                    k = key
                    if k and k not in seen_keys:
                        seen_keys.add(k)
                        doc_title = title or None
                        active_context_items.append(resolve_active_item_entry(syncer, k, doc_title))

            if has_library_context:
                active_context_items = None
                context_str = "Active selection context: Entire Zotero Library."
                active_context_items_str = context_str
            elif active_collections:
                context_str = f"Active selection context: Zotero Collections ({', '.join(active_collections)})."
                active_context_items_str = f"{context_str}\n{format_active_items(active_context_items, include_keys=False)}"
            else:
                if not active_context_items and request.snippets:
                    for snip in request.snippets:
                        p_key = snip.parent_key or snip.item_key
                        if p_key and p_key not in seen_keys:
                            seen_keys.add(p_key)
                            doc_title = snip.paper_title or None
                            entry = resolve_active_item_entry(syncer, p_key, doc_title)
                            if entry:
                                active_context_items.append(entry)
                active_context_items_str = format_active_items(active_context_items, include_keys=False)

            coord_model_str = None
            try:
                from zocrates.config import DEFAULT_LLM_MODEL, load_zocrates_config

                cfg = load_zocrates_config(zotero_dir)
                coord_model_str = (
                    (cfg.get("override_agent_models") or {}).get("coordinator")
                    or cfg.get("llm_model")
                    or DEFAULT_LLM_MODEL
                )
            except Exception:
                coord_model_str = "deepseek/deepseek-chat"

            try:
                from zocrates.agents.app import coordinator_agent

                if hasattr(coordinator_agent, "model"):
                    c_model = coordinator_agent.model
                    if hasattr(c_model, "model") and c_model.model:
                        coord_model_str = str(c_model.model)
                    elif isinstance(c_model, str) and c_model:
                        coord_model_str = c_model
            except Exception:
                pass

            multimodal_parts, focal_snippets_str, snippet_notices = await _process_request_snippets(
                request.snippets,
                coord_model=coord_model_str,
                zotero_dir=zotero_dir,
            )

            try:
                from zocrates.config import get_user_research_profile

                user_profile = get_user_research_profile()
            except Exception as e:
                logger.warning("Failed to load user research profile: %s", e)
                user_profile = ""
            user_research_profile_str = format_user_research_profile_context(user_profile)

            state_delta = {
                "active_context_items": active_context_items,
                "active_context_items_str": active_context_items_str,
                "focal_snippets_str": focal_snippets_str,
                "snippets": [s.model_dump(by_alias=True) for s in request.snippets] if request.snippets else [],
                USER_PROMPT: request.prompt,
                USER_RESEARCH_PROFILE_STR: user_research_profile_str,
            }

            user_parts: list[types.Part] = [types.Part.from_text(text=request.prompt)]
            if multimodal_parts:
                user_parts.extend(multimodal_parts)

            run_config = RunConfig(streaming_mode=StreamingMode.SSE)
            runner_gen = runner.run_async(
                user_id="default_user",
                session_id=request.session_id,
                state_delta=state_delta,
                new_message=types.Content(
                    role="user", parts=user_parts
                ),
                run_config=run_config,
            )

            for notice in snippet_notices:
                yield {"data": json.dumps(notice)}

            request_start_time = time.time()
            async for sse_item in _stream_runner_events(
                runner_gen,
                zotero_dir=zotero_dir,
                session_id=request.session_id,
                request_start_time=request_start_time,
                prompt_text=request.prompt,
            ):
                yield sse_item
        finally:
            if active_ids is not None:
                active_ids.discard(request.session_id)

    try:
        return EventSourceResponse(sse_event_generator())
    except Exception:
        if active_ids is not None:
            active_ids.discard(request.session_id)
        raise

check_config_freshness

check_config_freshness(zotero_data_dir=None)

Check if zocrates_config.json on disk has been modified since last check.

If _last_config_mtime is uninitialized (0.0), initializes it and returns False. If current mtime > _last_config_mtime, updates _last_config_mtime and returns True. Otherwise returns False. Handles OSError safely.

Source code in zocrates/config.py
def check_config_freshness(zotero_data_dir: Path | str | None = None) -> bool:
    """Check if zocrates_config.json on disk has been modified since last check.

    If _last_config_mtime is uninitialized (0.0), initializes it and returns False.
    If current mtime > _last_config_mtime, updates _last_config_mtime and returns True.
    Otherwise returns False. Handles OSError safely.
    """
    global _last_config_mtime
    resolved_dir = resolve_zotero_data_dir(zotero_data_dir)
    if resolved_dir is None:
        return False
    config_path = get_config_path(resolved_dir)
    try:
        if not config_path.exists():
            return False
        current_mtime = config_path.stat().st_mtime
        if _last_config_mtime == 0.0:
            _last_config_mtime = current_mtime
            return False
        if current_mtime > _last_config_mtime:
            _last_config_mtime = current_mtime
            return True
        return False
    except OSError:
        return False

cleanup_stale_staging_directories

cleanup_stale_staging_directories(max_age_seconds=7200)

Remove staging subdirectories older than max_age_seconds (default: 2 hours).

Parameters:

Name Type Description Default
max_age_seconds int

Age threshold in seconds.

7200

Returns:

Type Description
int

Number of directories removed.

Source code in zocrates/discovery/staging.py
def cleanup_stale_staging_directories(max_age_seconds: int = 7200) -> int:
    """
    Remove staging subdirectories older than `max_age_seconds` (default: 2 hours).

    Args:
        max_age_seconds: Age threshold in seconds.

    Returns:
        Number of directories removed.
    """
    root = get_staging_root()
    if not root.exists():
        return 0

    removed = 0
    now = time.time()
    for item in root.iterdir():
        if item.is_dir():
            try:
                mtime = item.stat().st_mtime
                if (now - mtime) > max_age_seconds:
                    shutil.rmtree(item, ignore_errors=True)
                    removed += 1
            except Exception as e:
                logger.debug("Failed removing stale staging directory %s: %s", item, e)
    return removed

convert_item async

convert_item(item_key, request=None)

Convert a single item using the specified or active converter strategy.

When force=False (default), checks whether the item is already cached with the target converter and identical source file hash. If so, returns {"status": "skipped", ...} without re-converting.

When force=True or when un-converted/stale, destroys any existing Markdown bundle for the item, converts fresh, writes the bundle + frontmatter, and immediately re-indexes the vector store.

Source code in zocrates/fast_api_app.py
@app.post("/convert/{item_key}", dependencies=[Depends(verify_auth)])
async def convert_item(item_key: str, request: ConvertRequest | None = None) -> dict:
    """Convert a single item using the specified or active converter strategy.

    When ``force=False`` (default), checks whether the item is already cached
    with the target converter and identical source file hash. If so, returns
    ``{"status": "skipped", ...}`` without re-converting.

    When ``force=True`` or when un-converted/stale, destroys any existing Markdown
    bundle for the item, converts fresh, writes the bundle + frontmatter, and
    immediately re-indexes the vector store.
    """
    from zocrates.rag.chunker import chunk_markdown
    from zocrates.config import (
        CHUNK_MAX_CHARS,
        CHUNK_OVERLAP_CHARS,
        get_document_converter_id,
    )
    from zocrates.rag.document_processor import UnsupportedConversionError

    try:
        syncer = get_syncer()
    except RuntimeError as exc:
        raise HTTPException(
            status_code=400,
            detail="Zocrates backend is not registered. Call /register first.",
        ) from exc

    attachment = syncer.db.get_attachment_by_key(item_key)
    if not attachment or not attachment.path or not attachment.path.exists():
        # Retry with brief backoff in case WAL checkpoint is finishing
        for _ in range(4):
            await asyncio.sleep(0.3)
            attachment = syncer.db.get_attachment_by_key(item_key)
            if attachment and attachment.path and attachment.path.exists():
                break

    if not attachment or not attachment.path or not attachment.path.exists():
        # Fallback: check if storage/<item_key>/ contains an attachment file directly
        storage_dir = getattr(syncer.db, "_storage_dir", None)
        if storage_dir and (storage_dir / item_key).is_dir():
            candidates = [
                f for f in (storage_dir / item_key).iterdir()
                if f.is_file() and not f.name.startswith(".")
            ]
            if candidates:
                target_file = sorted(
                    candidates,
                    key=lambda f: 0 if f.suffix.lower() == ".pdf" else 1,
                )[0]
                from zocrates.rag.zotero_db import ZoteroAttachment

                attachment = ZoteroAttachment(
                    item_id=0,
                    item_key=item_key,
                    parent_item_id=None,
                    parent_item_key=None,
                    content_type=(
                        "application/pdf"
                        if target_file.suffix.lower() == ".pdf"
                        else "application/octet-stream"
                    ),
                    path=target_file,
                )

    if not attachment or not attachment.path or not attachment.path.exists():
        raise HTTPException(
            status_code=404,
            detail=f"No valid attachment file found on disk for item key '{item_key}'",
        )

    requested_id = request.converter_id if request else None
    active_id = str(
        requested_id
        or syncer.processor._document_converter_id
        or get_document_converter_id(syncer._zotero_data_dir)
        or "markitdown"
    )

    parent_item = None
    if attachment.parent_item_id:
        parent_item = syncer.db.get_item(attachment.parent_item_id)
    elif attachment.parent_item_key:
        parent_item = syncer.db.get_item_by_key(attachment.parent_item_key)

    p_type = parent_item.item_type if (parent_item and isinstance(getattr(parent_item, "item_type", None), str)) else None
    p_title = parent_item.title if (parent_item and isinstance(getattr(parent_item, "title", None), str)) else item_key
    p_colls = parent_item.collections if (parent_item and isinstance(getattr(parent_item, "collections", None), list)) else None
    p_authors = parent_item.creators if (parent_item and isinstance(getattr(parent_item, "creators", None), list)) else None
    p_year = parent_item.year if (parent_item and isinstance(getattr(parent_item, "year", None), (str, int))) else None
    p_pub = parent_item.publication if (parent_item and isinstance(getattr(parent_item, "publication", None), str)) else None
    p_doi = parent_item.doi if (parent_item and isinstance(getattr(parent_item, "doi", None), str)) else None
    p_url = parent_item.url if (parent_item and isinstance(getattr(parent_item, "url", None), str)) else None
    p_abstract = parent_item.abstract if (parent_item and isinstance(getattr(parent_item, "abstract", None), str)) else None

    target_cache_key = (
        attachment.parent_item_key if attachment.parent_item_key else attachment.item_key
    )
    target_item_id = (
        attachment.parent_item_id if attachment.parent_item_id else attachment.item_id
    )

    is_pdf = attachment.content_type == "application/pdf" or (
            attachment.path and attachment.path.suffix.lower() == ".pdf"
    )
    effective_id = active_id
    if active_id == "datalab" and not is_pdf:
        effective_id = syncer.processor.datalab_non_pdf_fallback or "markitdown"

    force = request.force if request else False
    if not force and syncer.processor.is_cached(attachment.item_key):
        from zocrates.rag.document_processor import _file_sha256

        meta = syncer.processor.get_metadata(attachment.item_key)
        if meta and meta.get("converter_id") == effective_id:
            try:
                current_hash = _file_sha256(attachment.path)
                if meta.get("source_hash") == current_hash:
                    logger.info(
                        "Skipping already converted item %s ('%s') with %s",
                        attachment.item_key,
                        p_title,
                        effective_id,
                    )
                    return {
                        "status": "skipped",
                        "message": f"Already converted with {effective_id}",
                        "item_key": attachment.item_key,
                        "parent_item_key": attachment.parent_item_key,
                        "title": p_title,
                        "converter_id": effective_id,
                        "already_converted": True,
                    }
            except Exception as exc:
                logger.warning(
                    "Failed to verify source hash for %s: %s",
                    attachment.item_key,
                    exc,
                )

    try:
        from zocrates.config import get_conversion_timeout_seconds

        timeout_sec = get_conversion_timeout_seconds(syncer._zotero_data_dir)
        markdown = await asyncio.wait_for(
            asyncio.to_thread(
                syncer.processor.convert_with,
                item_key=attachment.item_key,
                converter_id=effective_id,
                source_path=attachment.path,
                content_type=attachment.content_type,
                parent_item_type=p_type,
                title=p_title,
                collections=p_colls,
                parent_item_key=attachment.parent_item_key,
                authors=p_authors,
                year=p_year,
                publication=p_pub,
                doi=p_doi,
                url=p_url,
                abstract=p_abstract,
            ),
            timeout=timeout_sec,
        )
    except asyncio.TimeoutError as exc:
        timeout_msg = (
            f"Conversion timed out after {timeout_sec}s with engine '{effective_id}'. "
            "For large or math-heavy PDFs on CPU, try Docling Fast Mode or MarkItDown."
        )
        try:
            from zocrates.rag.document_processor import _file_sha256

            source_hash = _file_sha256(attachment.path)
            syncer.processor.record_failure(
                attachment.item_key, effective_id, timeout_msg, source_hash
            )
        except Exception:
            pass
        raise HTTPException(
            status_code=504,
            detail={
                "error": "conversion_timeout",
                "message": timeout_msg,
                "converter_id": effective_id,
                "item_key": attachment.item_key,
            },
        ) from exc
    except UnsupportedConversionError as exc:
        raise HTTPException(
            status_code=400,
            detail={
                "error": "unsupported_content_type",
                "message": str(exc),
                "converter_id": effective_id,
                "item_key": attachment.item_key,
            },
        ) from exc
    except KeyError as exc:
        raise HTTPException(
            status_code=400,
            detail={
                "error": "unknown_converter",
                "message": str(exc),
                "converter_id": effective_id,
                "item_key": attachment.item_key,
            },
        ) from exc
    except Exception as exc:
        raise HTTPException(
            status_code=500,
            detail={
                "error": "conversion_failed",
                "message": str(exc),
                "converter_id": effective_id,
                "item_key": attachment.item_key,
            },
        ) from exc

    # Post-conversion VLM figure enrichment pass
    try:
        from zocrates.rag.vlm_captioner import enrich_document_bundle

        bundle_dir = syncer.processor._cache_dir / attachment.item_key
        enriched = await asyncio.to_thread(
            enrich_document_bundle,
            bundle_dir,
            syncer._zotero_data_dir,
        )
        if enriched:
            markdown = enriched
    except Exception as exc:
        logger.warning(
            "VLM figure enrichment failed for %s: %s; proceeding with base markdown",
            attachment.item_key,
            exc,
        )

    # Update ToC on final post-enrichment markdown
    try:
        from zocrates.rag.toc_generator import generate_toc, save_toc_files

        bundle_dir = syncer.processor._cache_dir / attachment.item_key
        meta = syncer.processor.get_metadata(attachment.item_key)
        abs_text = (meta.get("abstract") if meta else None) or p_abstract
        abs_src = (meta.get("abstract_source") if meta else None) or ("zotero" if p_abstract else "extractive")
        t_json, t_md = generate_toc(
            markdown,
            title=p_title,
            abstract=abs_text,
            abstract_source=abs_src,
            item_key=attachment.item_key,
            parent_item_key=attachment.parent_item_key,
        )
        save_toc_files(bundle_dir, t_json, t_md)
    except Exception as exc:
        logger.warning(
            "Post-enrichment ToC generation failed for %s: %s",
            attachment.item_key,
            exc,
        )

    # Immediate re-indexing into vector store
    chunks = chunk_markdown(
        markdown,
        max_chars=CHUNK_MAX_CHARS,
        overlap_chars=CHUNK_OVERLAP_CHARS,
        document_title=p_title,
    )
    if chunks:
        meta_dict: dict[str, Any] = {
            "content_type": attachment.content_type,
        }
        if attachment.parent_item_key:
            meta_dict["parent_item_key"] = attachment.parent_item_key
        if attachment.parent_item_id:
            meta_dict["parent_item_id"] = attachment.parent_item_id
        else:
            meta_dict["is_standalone"] = True

        syncer.store.ingest_document(
            attachment.item_key,
            attachment.parent_item_id or attachment.item_id,
            chunks,
            meta_dict,
        )
        syncer.processor.update_indexed_at(attachment.item_key)
        _dispatch_edge_evaluation(attachment.item_key, syncer)

    return {
        "status": "success",
        "item_key": attachment.item_key,
        "converter_id": effective_id,
        "char_count": len(markdown),
        "chunk_count": len(chunks),
    }

cost_estimate async

cost_estimate(converter_id='datalab', item_keys=None, force=False)

Estimate page count and USD cost for a paid converter across targeted items.

Source code in zocrates/fast_api_app.py
@app.get("/cost-estimate", dependencies=[Depends(verify_auth)])
async def cost_estimate(
        converter_id: str = "datalab",
        item_keys: str | None = None,
        force: bool = False,
) -> dict:
    """Estimate page count and USD cost for a paid converter across targeted items."""
    from zocrates.rag.document_processor import _file_sha256
    from zocrates.rag.pdf_utils import count_pdf_pages

    try:
        syncer = get_syncer()
    except RuntimeError as exc:
        raise HTTPException(
            status_code=400,
            detail="Zocrates backend is not registered. Call /register first.",
        ) from exc

    COST_PER_PAGE_MAP = {
        "markitdown": 0.0,
        "docling_local": 0.0,
        "datalab": 0.004,
        "gemini_vision": 0.0005,
        "google_document_ai": 0.0015,
    }
    cost_per_page = COST_PER_PAGE_MAP.get(converter_id, 0.0)

    target_keys = (
        [k.strip() for k in item_keys.split(",") if k.strip()]
        if item_keys
        else None
    )

    logger.info(
        "Cost estimate requested for converter='%s', item_keys=%s, force=%s",
        converter_id,
        target_keys,
        force,
    )

    attachments = []
    if target_keys:
        for k in target_keys:
            att = syncer.db.get_attachment_by_key(k)
            if not att or not att.path or not att.path.exists():
                parent_item = syncer.db.get_item_by_key(k)
                if parent_item:
                    atts = syncer.db.get_attachments(parent_item.item_id)
                    if atts:
                        pdf_atts = [
                            a
                            for a in atts
                            if (
                                       a.content_type == "application/pdf"
                                       or (a.path and a.path.suffix.lower() == ".pdf")
                               )
                               and a.path
                               and a.path.exists()
                        ]
                        if pdf_atts:
                            att = pdf_atts[0]
                        else:
                            valid_atts = [a for a in atts if a.path and a.path.exists()]
                            if valid_atts:
                                att = valid_atts[0]
            if att and att.path and att.path.exists():
                attachments.append(att)
    else:
        raw_attachments = syncer.db.get_all_convertible_attachments()
        grouped_by_parent: dict[str, list[dict[str, Any]]] = {}
        standalone: list[dict[str, Any]] = []

        for a in raw_attachments:
            if a["parent_key"]:
                grouped_by_parent.setdefault(a["parent_key"], []).append(a)
            else:
                standalone.append(a)

        for _p_key, att_list in grouped_by_parent.items():
            pdf_atts = [
                a
                for a in att_list
                if a["content_type"] == "application/pdf"
                   or a["path"].suffix.lower() == ".pdf"
            ]
            primary = pdf_atts[0] if pdf_atts else att_list[0]
            from zocrates.rag.zotero_db import ZoteroAttachment
            attachments.append(
                ZoteroAttachment(
                    item_id=primary["attachment_id"],
                    item_key=primary["attachment_key"],
                    parent_item_id=primary["parent_item_id"],
                    parent_item_key=primary["parent_key"],
                    content_type=primary["content_type"],
                    path=primary["path"],
                )
            )

        for s in standalone:
            from zocrates.rag.zotero_db import ZoteroAttachment
            attachments.append(
                ZoteroAttachment(
                    item_id=s["attachment_id"],
                    item_key=s["attachment_key"],
                    parent_item_id=None,
                    parent_item_key=None,
                    content_type=s["content_type"],
                    path=s["path"],
                )
            )

    already_converted = []
    eligible_attachments = []

    for att in attachments:
        is_pdf = att.content_type == "application/pdf" or (
                att.path and att.path.suffix.lower() == ".pdf"
        )
        if converter_id == "datalab" and not is_pdf:
            effective_id = syncer.processor.datalab_non_pdf_fallback or "markitdown"
        else:
            effective_id = converter_id

        if not force:
            meta = syncer.processor.get_metadata(att.item_key)
            if meta and meta.get("converter_id") == effective_id:
                try:
                    current_hash = _file_sha256(att.path)
                    if meta.get("source_hash") == current_hash:
                        already_converted.append(att)
                        continue
                except Exception:
                    pass
        eligible_attachments.append(att)

    total_pages = sum(
        count_pdf_pages(att.path)
        for att in eligible_attachments
        if att.path
        and (
                att.content_type == "application/pdf"
                or att.path.suffix.lower() == ".pdf"
        )
    )
    estimated_cost = round(total_pages * cost_per_page, 4)

    from zocrates.config import (
        get_datalab_merge_cross_page,
        get_enable_vlm_captioning,
        get_vlm_captioning_backup_model,
        get_vlm_captioning_local_model,
        get_vlm_captioning_model,
        get_vlm_captioning_provider,
        get_vlm_max_images_per_doc,
    )

    merge_cross_page = get_datalab_merge_cross_page(syncer._zotero_data_dir)
    enable_vlm = get_enable_vlm_captioning(syncer._zotero_data_dir)
    vlm_info: dict[str, Any] = {
        "enabled": enable_vlm,
    }
    if enable_vlm:
        vlm_prov = get_vlm_captioning_provider(syncer._zotero_data_dir)
        vlm_model = get_vlm_captioning_model(syncer._zotero_data_dir)
        vlm_local = get_vlm_captioning_local_model(syncer._zotero_data_dir)
        is_free = (vlm_prov == "ollama") or (":free" in (vlm_model or "").lower())
        active_model = (vlm_local if vlm_prov == "ollama" and vlm_local else vlm_model) or "default"
        vlm_info.update({
            "provider": vlm_prov,
            "model": active_model,
            "cloud_model": vlm_model,
            "backup_model": get_vlm_captioning_backup_model(syncer._zotero_data_dir),
            "local_model": vlm_local,
            "max_images_per_doc": get_vlm_max_images_per_doc(syncer._zotero_data_dir),
            "is_free_tier": is_free,
        })

    logger.info(
        "Cost estimate result: %d target docs, %d already converted, %d eligible docs (%d pages, $%.4f USD), vlm_enabled=%s",
        len(attachments),
        len(already_converted),
        len(eligible_attachments),
        total_pages,
        estimated_cost,
        enable_vlm,
    )

    return {
        "status": "success",
        "converter_id": converter_id,
        "datalab_merge_cross_page": merge_cross_page,
        "target_documents": len(attachments),
        "already_converted_count": len(already_converted),
        "eligible_documents": len(eligible_attachments),
        "total_pages": total_pages,
        "cost_per_page_usd": cost_per_page,
        "estimated_cost_usd": estimated_cost,
        "vlm_captioning": vlm_info,
    }

create_user_session async

create_user_session(req=None)

Create a new chat session for the default user.

Parameters:

Name Type Description Default
req CreateSessionRequest | None

Optional request body. If omitted, a UUID-based session ID and "New Chat" title are used.

None

Returns:

Type Description
dict

Dict with keys status and session (containing id,

dict

title, created_at, and an empty messages list).

Raises:

Type Description
HTTPException

500 on unexpected session service error.

Source code in zocrates/fast_api_app.py
@app.post("/sessions", dependencies=[Depends(verify_auth)])
async def create_user_session(req: CreateSessionRequest | None = None) -> dict:
    """Create a new chat session for the default user.

    Args:
        req: Optional request body. If omitted, a UUID-based session ID and
            ``"New Chat"`` title are used.

    Returns:
        Dict with keys ``status`` and ``session`` (containing ``id``,
        ``title``, ``created_at``, and an empty ``messages`` list).

    Raises:
        HTTPException: 500 on unexpected session service error.
    """
    session_service = services.get_session_service()
    app_name = getattr(app.state, "agent_app_name", "zocrates")
    import uuid

    sid = req.session_id if (req and req.session_id) else f"session-{uuid.uuid4().hex[:12]}"
    title = req.title if (req and req.title) else "New Chat"
    try:
        sess = await session_service.create_session(
            app_name=app_name,
            user_id="default_user",
            session_id=sid,
            state={"session_title": title},
        )
        return {
            "status": "success",
            "session": {
                "id": sess.id,
                "title": title,
                "created_at": getattr(sess, "created_at", None),
                "messages": [],
            },
        }
    except Exception as exc:
        logger.error("Failed to create session %s: %s", sid, exc)
        raise HTTPException(status_code=500, detail=f"Failed to create session: {exc}") from exc

decision_tool_install

decision_tool_install()

Install Laya tool via uv.

Source code in zocrates/fast_api_app.py
@app.post("/llm/decision_tool/install", dependencies=[Depends(verify_auth)])
def decision_tool_install() -> dict:
    """Install Laya tool via uv."""
    from zocrates.rag.decision_service import install_laya_tool

    return install_laya_tool()

decision_tool_start

decision_tool_start()

Start local Laya daemon.

Source code in zocrates/fast_api_app.py
@app.post("/llm/decision_tool/start", dependencies=[Depends(verify_auth)])
def decision_tool_start() -> dict:
    """Start local Laya daemon."""
    from zocrates.config import load_zocrates_config
    from zocrates.rag.decision_service import start_laya_daemon

    cfg = load_zocrates_config()
    port = int(cfg.get("citation_verification_local_port", 8005))
    return start_laya_daemon(port=port)

decision_tool_status

decision_tool_status()

Get status of local Laya decision tool (uv, installation, daemon running).

Source code in zocrates/fast_api_app.py
@app.get("/llm/decision_tool/status", dependencies=[Depends(verify_auth)])
def decision_tool_status() -> dict:
    """Get status of local Laya decision tool (uv, installation, daemon running)."""
    from zocrates.rag.decision_service import get_decision_tool_status

    return get_decision_tool_status()

decision_tool_stop

decision_tool_stop()

Stop local Laya daemon.

Source code in zocrates/fast_api_app.py
@app.post("/llm/decision_tool/stop", dependencies=[Depends(verify_auth)])
def decision_tool_stop() -> dict:
    """Stop local Laya daemon."""
    from zocrates.config import load_zocrates_config
    from zocrates.rag.decision_service import stop_laya_daemon

    cfg = load_zocrates_config()
    port = int(cfg.get("citation_verification_local_port", 8005))
    return stop_laya_daemon(port=port)

delete_items async

delete_items(request)

Purge items across all datastores (cache bundle, metadata DB, vector/FTS store, root index).

Source code in zocrates/fast_api_app.py
@app.post("/items/delete", dependencies=[Depends(verify_auth)])
async def delete_items(request: ItemDeleteRequest) -> dict:
    """Purge items across all datastores (cache bundle, metadata DB, vector/FTS store, root index)."""
    try:
        syncer = get_syncer()
    except RuntimeError as exc:
        raise HTTPException(
            status_code=400,
            detail="Zocrates backend is not registered. Call /register first.",
        ) from exc

    valid_keys = [k for k in request.item_keys if _validate_item_key(k)]

    def _purge_worker() -> list[str]:
        """Purge requested items from disk and sync indexes synchronously.

        Returns:
            List of successfully purged item keys.
        """
        purged_keys: list[str] = []
        for key in valid_keys:
            try:
                syncer.purge_item(key, regenerate_index=False)
                purged_keys.append(key)
            except Exception as exc:
                logger.error("Failed to purge item %s: %s", key, exc)
        if purged_keys:
            syncer.processor.regenerate_root_index()
        return purged_keys

    purged = await asyncio.to_thread(_purge_worker)

    return {"status": "success", "purged": purged}

delete_user_session async

delete_user_session(session_id)

Delete a chat session from the session database.

Parameters:

Name Type Description Default
session_id str

The session ID to delete.

required

Returns:

Type Description
dict

Dict with keys status and message confirming deletion.

Raises:

Type Description
HTTPException

500 on unexpected session service error.

Source code in zocrates/fast_api_app.py
@app.delete("/sessions/{session_id}", dependencies=[Depends(verify_auth)])
async def delete_user_session(session_id: str) -> dict:
    """Delete a chat session from the session database.

    Args:
        session_id: The session ID to delete.

    Returns:
        Dict with keys ``status`` and ``message`` confirming deletion.

    Raises:
        HTTPException: 500 on unexpected session service error.
    """
    session_service = services.get_session_service()
    app_name = getattr(app.state, "agent_app_name", "zocrates")
    try:
        await session_service.delete_session(
            app_name=app_name, user_id="default_user", session_id=session_id
        )
        return {"status": "success", "message": f"Session {session_id} deleted."}
    except Exception as exc:
        logger.error("Failed to delete session %s: %s", session_id, exc)
        raise HTTPException(status_code=500, detail=f"Failed to delete session: {exc}") from exc

discover_cleanup_batch

discover_cleanup_batch(req)

Clean up temporary staging directory for a completed or aborted discovery import batch.

Source code in zocrates/fast_api_app.py
@app.post("/discover/cleanup_batch", dependencies=[Depends(verify_auth)])
def discover_cleanup_batch(req: DiscoverCleanupBatchRequest) -> dict[str, str]:
    """Clean up temporary staging directory for a completed or aborted discovery import batch."""
    token = req.batch_token.strip() if req.batch_token else ""
    if not token or not re.match(r"^[a-zA-Z0-9_\-]+$", token):
        raise HTTPException(status_code=400, detail="Invalid batch token format")

    staging_root = get_staging_root().resolve()
    target_dir = (staging_root / token).resolve()

    if not target_dir.is_relative_to(staging_root) or target_dir == staging_root:
        raise HTTPException(status_code=400, detail="Invalid batch token path traversal")

    if target_dir.exists() and target_dir.is_dir():
        shutil.rmtree(target_dir, ignore_errors=True)

    return {"status": "cleaned"}

discover_prepare_batch async

discover_prepare_batch(req)

Re-check deduplication against live library and stage candidate PDFs for batch import.

Source code in zocrates/fast_api_app.py
@app.post("/discover/prepare_batch", dependencies=[Depends(verify_auth)])
async def discover_prepare_batch(req: DiscoverPrepareBatchRequest) -> dict[str, Any]:
    """Re-check deduplication against live library and stage candidate PDFs for batch import."""
    import secrets
    import httpx
    from zocrates.discovery.dedup import Deduplicator, normalize_arxiv_id, normalize_doi, normalize_title
    from zocrates.discovery.models import CandidatePaper, StagedItem

    if len(req.candidates) > 50:
        raise HTTPException(status_code=400, detail="Batch size exceeds maximum of 50 candidates.")

    # 1. Instantiate deduplicator against live library in threadpool
    zotero_db = await asyncio.to_thread(_get_live_zotero_db)
    if zotero_db is None:
        logger.warning(
            "Live ZoteroDB not available for import dedup re-check; proceeding with intra-batch dedup only"
        )
    deduplicator = await asyncio.to_thread(Deduplicator, zotero_db=zotero_db)

    # 2. Re-check each candidate against live library
    skipped_duplicates: list[str] = []
    novel_candidates: list[CandidatePaper] = []

    for raw in req.candidates:
        candidate = raw if isinstance(raw, CandidatePaper) else CandidatePaper.from_dict(raw)
        if not candidate.candidate_id:
            candidate.candidate_id = f"cand_{secrets.token_hex(6)}"

        if deduplicator.is_in_library(candidate):
            skipped_duplicates.append(candidate.candidate_id or candidate.title)
        else:
            novel_candidates.append(candidate)
            # Track within-batch so subsequent duplicates in the same payload are also caught
            if candidate.doi:
                norm_d = normalize_doi(candidate.doi)
                if norm_d:
                    deduplicator.existing_dois.add(norm_d)
            if candidate.arxiv_id:
                norm_a = normalize_arxiv_id(candidate.arxiv_id)
                if norm_a:
                    deduplicator.existing_arxiv_ids.add(norm_a)
            if candidate.title:
                norm_t = normalize_title(candidate.title)
                if norm_t:
                    deduplicator.existing_records.append({
                        "title": candidate.title,
                        "normalized_title": norm_t,
                        "year": candidate.year,
                        "creators": candidate.authors,
                    })

    # 3. Create batch staging directory
    batch_token = secrets.token_hex(16)
    staging_root = get_staging_root()
    staging_dir = staging_root / batch_token
    staging_dir.mkdir(parents=True, exist_ok=True)

    # 4. Download and stage PDFs for novel candidates
    staged_items: list[StagedItem] = []
    async with httpx.AsyncClient(
        timeout=httpx.Timeout(connect=15.0, read=30.0, write=15.0, pool=30.0),
        follow_redirects=True,
    ) as client:
        for candidate in novel_candidates:
            staged_path = ""
            if candidate.oa_pdf_url:
                try:
                    res_path = await download_and_stage_pdf(candidate, staging_dir, client=client)
                    if res_path:
                        staged_path = res_path
                except Exception as exc:
                    logger.warning("Failed downloading PDF for candidate %s: %s", candidate.candidate_id, exc)
                    staged_path = ""

            staged_item = StagedItem(
                candidate_id=candidate.candidate_id,
                staged_path=staged_path,
                metadata=candidate.to_dict(),
            )
            staged_items.append(staged_item)

    return {
        "batch_token": batch_token,
        "staged_items": [item.to_dict() for item in staged_items],
        "skipped_duplicates": skipped_duplicates,
    }

discover_resolve async

discover_resolve(req)

Resolve a single identifier (DOI, arXiv ID, or OpenAlex ID) and verify library presence.

Source code in zocrates/fast_api_app.py
@app.post("/discover/resolve", dependencies=[Depends(verify_auth)])
async def discover_resolve(req: DiscoverResolveRequest) -> dict[str, Any]:
    """Resolve a single identifier (DOI, arXiv ID, or OpenAlex ID) and verify library presence."""
    try:
        engine = await asyncio.to_thread(get_default_discovery_engine)
        candidate, in_library = await engine.resolve_identifier(req.identifier)
        return {
            "candidate": candidate.to_dict() if candidate else None,
            "in_library": in_library,
        }
    except Exception as exc:
        logger.error("Failed resolving identifier %r: %s", req.identifier, exc, exc_info=True)
        raise HTTPException(status_code=500, detail=f"Identifier resolution failed: {exc}")
discover_search(req)

Search for scholarly literature and return novel candidates.

Source code in zocrates/fast_api_app.py
@app.post("/discover/search", dependencies=[Depends(verify_auth)])
async def discover_search(req: DiscoverSearchRequest) -> dict[str, Any]:
    """Search for scholarly literature and return novel candidates."""
    try:
        engine = await asyncio.to_thread(get_default_discovery_engine)
        results = await engine.search(
            query=req.query,
            limit=req.limit,
            year_from=req.year_from,
            year_to=req.year_to,
            is_oa_only=req.is_oa_only,
        )
        return {"candidates": [c.to_dict() for c in results]}
    except Exception as exc:
        logger.error("Failed executing discovery search: %s", exc, exc_info=True)
        raise HTTPException(status_code=500, detail=f"Discovery search failed: {exc}")

download_and_stage_pdf async

download_and_stage_pdf(candidate, staging_dir, client=None)

Download and stage an Open Access PDF for a candidate paper.

Enforces
  • SSRF Protection: strictly requires https:// URL scheme.
  • Safe Filename: sanitizes candidate_id against directory traversal.
  • Directory Containment: ensures target path is strictly within staging_dir.
  • Magic Byte Verification: first 5 bytes must equal b"%PDF-".
  • Streaming Byte Cap: aborts if cumulative bytes exceed MAX_PDF_BYTES (100 MB).

Parameters:

Name Type Description Default
candidate CandidatePaper

CandidatePaper instance.

required
staging_dir Path

Target batch directory Path.

required
client AsyncClient | None

Optional existing httpx.AsyncClient for connection reuse.

None

Returns:

Type Description
str | None

Absolute file path string on success, or None on failure/non-PDF.

Source code in zocrates/discovery/staging.py
async def download_and_stage_pdf(
    candidate: CandidatePaper,
    staging_dir: Path,
    client: httpx.AsyncClient | None = None,
) -> str | None:
    """
    Download and stage an Open Access PDF for a candidate paper.

    Enforces:
      - SSRF Protection: strictly requires https:// URL scheme.
      - Safe Filename: sanitizes candidate_id against directory traversal.
      - Directory Containment: ensures target path is strictly within staging_dir.
      - Magic Byte Verification: first 5 bytes must equal b"%PDF-".
      - Streaming Byte Cap: aborts if cumulative bytes exceed MAX_PDF_BYTES (100 MB).

    Args:
        candidate: CandidatePaper instance.
        staging_dir: Target batch directory Path.
        client: Optional existing httpx.AsyncClient for connection reuse.

    Returns:
        Absolute file path string on success, or None on failure/non-PDF.
    """
    urls_to_try: list[str] = []
    if candidate.oa_pdf_url and isinstance(candidate.oa_pdf_url, str):
        urls_to_try.append(candidate.oa_pdf_url.strip())
    for alt in getattr(candidate, "oa_pdf_urls", []):
        if alt and isinstance(alt, str):
            clean_alt = alt.strip()
            if clean_alt and clean_alt not in urls_to_try:
                urls_to_try.append(clean_alt)

    if not urls_to_try:
        return None

    raw_id = candidate.candidate_id.strip() if candidate.candidate_id else "candidate"
    safe_id = re.sub(r"[^a-zA-Z0-9_\-]", "_", raw_id)
    if not safe_id:
        safe_id = "candidate"
    id_hash = hashlib.sha1(raw_id.encode("utf-8")).hexdigest()[:8]
    filename = f"{safe_id}_{id_hash}.pdf"

    resolved_staging_dir = Path(staging_dir).resolve()
    dest_path = (resolved_staging_dir / filename).resolve()

    try:
        if not dest_path.is_relative_to(resolved_staging_dir) or dest_path == resolved_staging_dir:
            logger.warning("Path traversal attempt in filename %r", filename)
            return None
    except AttributeError:
        if resolved_staging_dir not in dest_path.parents:
            return None

    resolved_staging_dir.mkdir(parents=True, exist_ok=True)

    async def _try_download(c: httpx.AsyncClient) -> str | None:
        """Attempt downloading candidate PDF across candidate URLs sequentially.

        Args:
            c: Asynchronous HTTP client instance to use for downloads.

        Returns:
            Absolute file path string if downloaded successfully, or None.
        """
        for raw_url in urls_to_try:
            url = raw_url
            if url.lower().startswith("http://"):
                url = "https://" + url[7:]
            elif not url.lower().startswith("https://"):
                logger.warning(
                    "SSRF Protection: Rejected non-https URL %r for candidate %s",
                    url,
                    candidate.candidate_id,
                )
                continue

            res = await _stream_to_file(c, url, dest_path)
            if res and Path(res).exists() and Path(res).stat().st_size > 0:
                return res
        return None

    if client is not None:
        return await _try_download(client)
    else:
        async with httpx.AsyncClient(timeout=DEFAULT_DOWNLOAD_TIMEOUT, follow_redirects=True) as own_client:
            return await _try_download(own_client)

evaluate_and_persist_edges

evaluate_and_persist_edges(item_key, syncer, sync_bundle_files=True)

Evaluate and persist relations for an item using hybrid search and ground truth.

  1. Looks up item_key metadata, keywords, and tags.
  2. Builds high-signal search query via build_search_query.
  3. Runs syncer.store.search isolating body chunks and excluding Chunk 0 frontmatter.
  4. Groups candidates by parent key, strictly excluding own parent key and sibling attachments.
  5. Clears candidates through the canonical Evidence Gate and exponential score rescaling.
  6. Persists edges transactionally with ON CONFLICT max score and undirected symmetrization.
  7. Syncs manual links and shared user tags if enabled.
  8. If sync_bundle_files is True, regenerates item_relations.md for item_key and all connected neighbors.

Parameters:

Name Type Description Default
item_key str

Unique Zotero attachment or item key.

required
syncer Any

Active LibrarySyncer instance with vector store and database.

required
sync_bundle_files bool

If True, regenerates item_relations.md on disk.

True

Returns:

Type Description
list[dict[str, Any]]

List of all verified relation dictionaries for item_key.

Source code in zocrates/rag/graph_engine.py
def evaluate_and_persist_edges(
    item_key: str,
    syncer: Any,
    sync_bundle_files: bool = True,
) -> list[dict[str, Any]]:
    """Evaluate and persist relations for an item using hybrid search and ground truth.

    1. Looks up item_key metadata, keywords, and tags.
    2. Builds high-signal search query via build_search_query.
    3. Runs syncer.store.search isolating body chunks and excluding Chunk 0 frontmatter.
    4. Groups candidates by parent key, strictly excluding own parent key and sibling attachments.
    5. Clears candidates through the canonical Evidence Gate and exponential score rescaling.
    6. Persists edges transactionally with ON CONFLICT max score and undirected symmetrization.
    7. Syncs manual links and shared user tags if enabled.
    8. If sync_bundle_files is True, regenerates item_relations.md for item_key and all connected neighbors.

    Args:
        item_key: Unique Zotero attachment or item key.
        syncer: Active LibrarySyncer instance with vector store and database.
        sync_bundle_files: If True, regenerates item_relations.md on disk.

    Returns:
        List of all verified relation dictionaries for item_key.
    """
    zotero_dir = getattr(syncer, "_zotero_data_dir", None) or getattr(syncer, "zotero_dir", None)
    if zotero_dir:
        zotero_dir = Path(zotero_dir)

    processor = getattr(syncer, "processor", None)
    cache_dir = getattr(processor, "_cache_dir", None) or (get_corpus_dir(zotero_dir) if zotero_dir else None)
    if not cache_dir:
        return []

    cache_dir = Path(cache_dir)
    meta_db_path = cache_dir / "conversion_meta.db"
    if not meta_db_path.exists():
        return []

    cfg = get_graph_relations_config(zotero_dir)
    if not cfg.get("enabled", True):
        return []

    conn = sqlite3.connect(str(meta_db_path), timeout=5)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode = WAL;")
    conn.execute("PRAGMA busy_timeout = 5000;")

    try:
        item_row = conn.execute(
            """
            SELECT item_key, parent_item_key, title, abstract, keywords, content_type
            FROM conversion_meta
            WHERE item_key = ?
            """,
            (item_key,),
        ).fetchone()

        if not item_row:
            return []

        title = str(item_row["title"] or "").strip()
        abstract = str(item_row["abstract"] or "").strip()
        keywords: list[str] = []

        if item_row["keywords"]:
            try:
                parsed_kw = json.loads(item_row["keywords"])
                if isinstance(parsed_kw, list):
                    keywords = [str(k) for k in parsed_kw]
            except Exception:
                keywords = []

        if not keywords:
            # Fall back to extracting keywords from document.md
            doc_path = cache_dir / item_key / "document.md"
            if doc_path.exists():
                try:
                    doc_text = doc_path.read_text(encoding="utf-8", errors="ignore")
                    digest = extract_structural_digest(doc_text)
                    keywords = extract_statistical_keywords(title, digest, max_keywords=6)
                except Exception:
                    pass
            if not keywords and abstract:
                keywords = extract_statistical_keywords(title, abstract, max_keywords=6)

        tags = get_item_tags(zotero_dir, item_key=item_key) if zotero_dir else []
        query = build_search_query(title=title, keywords=keywords, tags=tags)

        # Build mapping for parent isolation and primary bundle resolution
        all_meta_rows = conn.execute(
            "SELECT item_key, parent_item_key, content_type FROM conversion_meta"
        ).fetchall()

        parent_map: dict[str, str] = {}
        parent_to_bundle: dict[str, str] = {}

        for r in all_meta_rows:
            k = r["item_key"]
            pk = r["parent_item_key"] or k
            is_pdf = r["content_type"] == "application/pdf"
            parent_map[k] = pk
            if pk not in parent_to_bundle or is_pdf:
                parent_to_bundle[pk] = k

        source_parent_key = parent_map.get(item_key, item_key)

        # Hybrid search edge discovery
        strategy = cfg.get("strategy", "hybrid_corroborated")
        if strategy != "curated_only" and query and len(query.strip()) >= 3 and hasattr(syncer, "store"):
            candidate_pool_size = int(cfg.get("candidate_pool_size", 50))
            min_hybrid_score = float(cfg.get("min_hybrid_score", 0.015))
            score_tau = float(cfg.get("score_tau", 0.10))

            try:
                search_results = syncer.store.search(
                    query=query,
                    top_k=candidate_pool_size,
                    final_k=candidate_pool_size,
                    chunk_type_filter=["text", "table", "equation", "code", "figure"],
                )
            except Exception as exc:
                logger.warning("Graph hybrid search failed for %s: %s", item_key, exc)
                search_results = []

            # Group chunks by target parent key, strictly excluding own parent and sibling attachments
            by_target: dict[str, list[Any]] = {}
            for chunk in search_results:
                chunk_key = chunk.item_key if hasattr(chunk, "item_key") else chunk.get("item_key")
                target_parent = parent_map.get(chunk_key, chunk_key)

                # Strict sibling guard: exclude self-matches, child notes, and companion attachments
                if target_parent == source_parent_key or chunk_key == item_key:
                    continue

                by_target.setdefault(target_parent, []).append(chunk)

            # Evaluate each candidate group against the Evidence Gate
            for target_parent, chunks in by_target.items():
                is_valid, agg_score, max_sem, methods = check_evidence_gate(
                    chunks, min_hybrid_score=min_hybrid_score
                )
                if not is_valid:
                    continue

                target_bundle_key = parent_to_bundle.get(target_parent, target_parent)
                rescaled_score = rescale_hybrid_score(
                    raw_rrf_score=agg_score,
                    min_hybrid_score=min_hybrid_score,
                    score_tau=score_tau,
                )

                # Check if manual link exists to determine promotion
                is_manual_corroborated = False
                if cfg.get("include_human_curated", True) and zotero_dir:
                    try:
                        from zocrates.rag.zotero_db import _open_db

                        sqlite_path = zotero_dir / "zotero.sqlite"
                        if sqlite_path.exists():
                            with _open_db(sqlite_path) as z_conn:
                                q = """
                                    SELECT 1 FROM itemRelations ir
                                    JOIN items i ON ir.itemID = i.itemID
                                    WHERE (i.key = ? AND ir.object LIKE '%/items/' || ?)
                                       OR (i.key = ? AND ir.object LIKE '%/items/' || ?)
                                """
                                row = z_conn.execute(
                                    q,
                                    (source_parent_key, target_parent, target_parent, source_parent_key),
                                ).fetchone()
                                if row:
                                    is_manual_corroborated = True
                    except Exception:
                        pass

                if is_manual_corroborated:
                    rel_type = "human_curated_and_hybrid_search"
                    final_score = 1.0
                else:
                    rel_type = "hybrid_search"
                    final_score = rescaled_score

                match_evidence = json.dumps(
                    {
                        "raw_rrf_score": round(agg_score, 4),
                        "matched_chunks": len(chunks),
                        "max_semantic_score": round(max_sem, 4),
                        "retrieval_methods": sorted(list(methods)),
                    }
                )

                upsert_edge(
                    conn=conn,
                    source_key=item_key,
                    target_key=target_bundle_key,
                    score=final_score,
                    relation_type=rel_type,
                    match_evidence=match_evidence,
                    symmetrize=True,
                )

        # Ground truth ingestion: manual links & shared tags
        if cfg.get("include_human_curated", True) and zotero_dir:
            sync_manual_relations(conn=conn, zotero_dir=zotero_dir, target_key=item_key)

        if cfg.get("include_shared_tags", True) and zotero_dir:
            sync_shared_tags(
                conn=conn,
                zotero_dir=zotero_dir,
                min_shared_tags=int(cfg.get("min_shared_tags", 2)),
                target_key=item_key,
                score=float(cfg.get("shared_tags_score", 0.70)),
            )

        # Query all verified edges for item_key
        edges_cursor = conn.execute(
            """
            SELECT source_key, target_key, score, relation_type, match_evidence, updated_at
            FROM item_relations
            WHERE source_key = ?
            ORDER BY score DESC, target_key ASC
            """,
            (item_key,),
        )
        persisted_edges = [dict(r) for r in edges_cursor.fetchall()]

        # Atomic bundle file synchronization
        if sync_bundle_files and cfg.get("sync_bundle_files", True):
            try:
                from zocrates.rag.bundle_catalog import regenerate_item_relations_md

                # Regenerate for item_key
                regenerate_item_relations_md(item_key=item_key, cache_dir=cache_dir)

                # Regenerate for all connected neighbors
                connected_neighbors = {e["target_key"] for e in persisted_edges if e.get("target_key")}
                for neighbor_key in connected_neighbors:
                    regenerate_item_relations_md(item_key=neighbor_key, cache_dir=cache_dir)
            except Exception as exc:
                logger.warning(
                    "Failed to synchronize item_relations.md files for %s: %s", item_key, exc
                )

        return persisted_edges
    finally:
        conn.close()

format_active_items

format_active_items(active_items, include_keys=True)

Format active context items from session state for insertion into agent prompts.

Supports a hierarchical format when a parent item has multiple converted attachments and/or child notes. When a parent has exactly one converted attachment and no notes the output is byte-identical to the legacy flat format so existing prompts and tests are unaffected.

Parameters:

Name Type Description Default
active_items list[dict[str, Any]] | None

List of item dictionaries representing active selections in Zotero UI. Each entry may optionally include a children list of attachment/note dicts (see fast_api_app.py).

required
include_keys bool

Whether to include [Key: ...], [Attachment Key: ...], and [Note Key: ...] identifiers. Set to False for synthesizer/coordinator prompts to hide internal keys.

True

Returns:

Type Description
str

Formatted multi-line Markdown string.

Source code in zocrates/agents/common/prompts.py
def format_active_items(
    active_items: list[dict[str, Any]] | None,
    include_keys: bool = True,
) -> str:
    """Format active context items from session state for insertion into agent prompts.

    Supports a hierarchical format when a parent item has multiple converted
    attachments and/or child notes.  When a parent has exactly one converted
    attachment and no notes the output is byte-identical to the legacy flat
    format so existing prompts and tests are unaffected.

    Args:
        active_items: List of item dictionaries representing active selections in
            Zotero UI.  Each entry may optionally include a ``children`` list
            of attachment/note dicts (see ``fast_api_app.py``).
        include_keys: Whether to include ``[Key: ...]``, ``[Attachment Key: ...]``,
            and ``[Note Key: ...]`` identifiers.  Set to ``False`` for
            synthesizer/coordinator prompts to hide internal keys.

    Returns:
        Formatted multi-line Markdown string.
    """
    if not active_items:
        return "No active paper is currently selected in the Zotero UI."

    lines: list[str] = []
    for item in active_items:
        key = item.get("item_key") or item.get("key", "unknown")
        title = item.get("title", "Untitled")

        year = item.get("year", "")
        year_str = f" ({year})" if year else ""
        children: list[dict[str, Any]] = item.get("children") or []
        has_notes = any(c.get("is_note") for c in children)
        use_hierarchy = len(children) > 1 or has_notes

        if use_hierarchy:
            # --- Parent line ---
            if include_keys:
                lines.append(f'- [Key: {key}] Title: "{title}"{year_str}')
            else:
                lines.append(f'- Title: "{title}"{year_str}')

            # --- Child lines ---
            for child in children:
                c_key = child.get("item_key", "unknown")
                c_title = child.get("title", "Untitled")
                c_type = child.get("content_type", "")

                if child.get("is_note"):
                    word_count = child.get("word_count", 0)
                    inline_text: str | None = child.get("inline_text")
                    if inline_text:
                        # Short note: inline content as blockquote.
                        if include_keys:
                            lines.append(
                                f'  - [Note Key: {c_key}] "{c_title}" ({word_count} words) [text/note]:'
                            )
                        else:
                            lines.append(f'  - Note: "{c_title}":')
                        for inline_line in inline_text.splitlines():
                            lines.append(f"    > {inline_line}")
                    else:
                        # Longer note: reference only.
                        if include_keys:
                            lines.append(
                                f'  - [Note Key: {c_key}] "{c_title}" (~{word_count} words) [text/note]'
                            )
                        else:
                            lines.append(f'  - Note: "{c_title}" [text/note]')
                else:
                    # Attachment child.
                    if include_keys:
                        lines.append(f'  - [Attachment Key: {c_key}] "{c_title}" [{c_type}]')
                    else:
                        lines.append(f'  - Attachment: "{c_title}" [{c_type}]')
        else:
            # --- Flat format (backward-compatible, single attachment or no children) ---
            if include_keys:
                lines.append(f'- [Key: {key}] Title: "{title}"{year_str}')
            else:
                lines.append(f'- Title: "{title}"{year_str}')

    return "\n".join(lines)

format_session_for_ui

format_session_for_ui(session, default_model=None, zotero_data_dir=None)

Format an ADK Session object into the UI JSON schema expected by sidebar.js.

Groups events by conversational turn (starting with each user event), accumulates all intermediate thoughts, tool calls, and returns into intermediate_steps, aggregates token usage across all steps of the turn, computes multi-agent turn costs in USD and agent model mappings, and emits exactly two messages per turn (user + assistant).

Parameters:

Name Type Description Default
session Any

An ADK Session object with id, state, and events attributes.

required
default_model str | None

Optional default model name fallback.

None
zotero_data_dir Path | str | None

Optional Zotero data directory for catalog discovery.

None

Returns:

Type Description
dict[str, Any]

Dict with keys id, title, created_at, updated_at,

dict[str, Any]

total_cost_usd, total_tokens, total_turns,

dict[str, Any]

session_metadata, and messages.

Source code in zocrates/utils/session_formatting.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
def format_session_for_ui(
    session: Any,
    default_model: str | None = None,
    zotero_data_dir: Path | str | None = None,
) -> dict[str, Any]:
    """Format an ADK Session object into the UI JSON schema expected by sidebar.js.

    Groups events by conversational turn (starting with each `user` event),
    accumulates all intermediate thoughts, tool calls, and returns into
    `intermediate_steps`, aggregates token usage across all steps of the turn,
    computes multi-agent turn costs in USD and agent model mappings,
    and emits exactly two messages per turn (user + assistant).

    Args:
        session: An ADK ``Session`` object with ``id``, ``state``, and
            ``events`` attributes.
        default_model: Optional default model name fallback.
        zotero_data_dir: Optional Zotero data directory for catalog discovery.

    Returns:
        Dict with keys ``id``, ``title``, ``created_at``, ``updated_at``,
        ``total_cost_usd``, ``total_tokens``, ``total_turns``,
        ``session_metadata``, and ``messages``.
    """
    events = getattr(session, "events", []) or []
    has_rewinds = any(
        isinstance(getattr(getattr(e, "actions", None), "rewind_before_invocation_id", None), str)
        for e in events
    )
    if has_rewinds:
        events = _apply_rewinds(events)
    events = [e for e in events if not _is_compaction_event(e)]
    messages: list[dict[str, Any]] = []

    turns: list[tuple[Any | None, list[Any]]] = []
    current_user_evt = None
    current_asst_evts: list[Any] = []
    leading_asst_evts: list[Any] = []
    session_total_cost = 0.0
    session_total_tokens = 0

    for evt in events:
        author = getattr(evt, "author", "assistant")
        is_user = (author == "user")

        if is_user:
            u_txt, _, _, u_frs = extract_event_parts(evt)
            if not u_txt.strip() and u_frs:
                # FR-only user event (e.g. resume FunctionResponse).
                # Fold into assistant events to avoid creating an empty user bubble or splitting turn.
                if current_user_evt is not None:
                    current_asst_evts.append(evt)
                else:
                    leading_asst_evts.append(evt)
                continue

            if current_user_evt is not None:
                turns.append((current_user_evt, current_asst_evts))
            current_user_evt = evt
            current_asst_evts = []
        else:
            if current_user_evt is not None:
                current_asst_evts.append(evt)
            else:
                leading_asst_evts.append(evt)

    if current_user_evt is not None:
        turns.append((current_user_evt, current_asst_evts))

    # If there are no user turns at all, treat leading assistant events as turns
    if not turns and leading_asst_evts:
        turns.append((None, leading_asst_evts))
    elif leading_asst_evts:
        for evt in leading_asst_evts:
            txt, ths, _, _ = extract_event_parts(evt)
            if txt or ths:
                evt_cost, evt_models = aggregate_turn_cost(
                    [evt],
                    default_model=default_model,
                    zotero_data_dir=zotero_data_dir,
                )
                session_total_cost += evt_cost
                lead_u = parse_usage_metadata(getattr(evt, "usage_metadata", None))
                if lead_u:
                    lead_u["cost_usd"] = round(evt_cost, 6)
                    lead_u["agent_models"] = evt_models
                    session_total_tokens += lead_u.get("total_tokens", 0)
                messages.append({
                    "id": getattr(evt, "id", None),
                    "role": "assistant",
                    "text": txt,
                    "thought": "\n\n".join(ths) if ths else None,
                    "author": format_author_name(getattr(evt, "author", None)),
                    "timestamp": getattr(evt, "timestamp", None),
                    "model": getattr(evt, "model_version", None),
                    "usage": lead_u,
                    "turn_cost_usd": round(evt_cost, 6),
                    "agent_models": evt_models,
                    "intermediate_steps": [],
                })

    for user_evt, asst_evts in turns:
        context_items = None
        snippets = None
        if user_evt is not None:
            user_text, _, _, _ = extract_event_parts(user_evt)
            user_ts = getattr(user_evt, "timestamp", None)
            actions = getattr(user_evt, "actions", None)
            state_delta = getattr(actions, "state_delta", {}) if actions else {}
            context_items = state_delta.get("active_context_items") if isinstance(state_delta, dict) else None
            snippets = state_delta.get("snippets") if isinstance(state_delta, dict) else None

            messages.append({
                "id": getattr(user_evt, "id", None),
                "role": "user",
                "text": user_text,
                "author": "User",
                "timestamp": user_ts,
                "active_context_items": context_items,
                "snippets": snippets,
                "invocation_id": getattr(user_evt, "invocation_id", None),
            })

        if not asst_evts:
            continue

        # Aggregate token usage across all assistant events in the turn
        turn_prompt = 0
        turn_candidates = 0
        turn_thoughts = 0
        turn_cached = 0
        turn_total = 0
        has_usage = False

        for evt in asst_evts:
            u = parse_usage_metadata(getattr(evt, "usage_metadata", None))
            if u:
                has_usage = True
                turn_prompt += u.get("prompt_tokens", 0)
                turn_candidates += u.get("candidates_tokens", 0)
                turn_thoughts += u.get("thoughts_tokens", 0)
                turn_cached += u.get("cached_tokens", 0)
                turn_total += u.get("total_tokens", 0)

        aggregated_usage = {
            "prompt_tokens": turn_prompt,
            "candidates_tokens": turn_candidates,
            "thoughts_tokens": turn_thoughts,
            "cached_tokens": turn_cached,
            "total_tokens": turn_total,
        } if has_usage else None

        # Determine the final response event and text
        text_events: list[tuple[Any, str]] = []
        for evt in asst_evts:
            txt, _, _, _ = extract_event_parts(evt)
            if txt:
                text_events.append((evt, txt))

        final_evt = None
        final_text = ""
        SYNTHESIZER_AUTHORS = (
            "library_synthesizer_agent",
            "library_synthesizer",
            "online_synthesizer_agent",
            "online_synthesizer",
            "synthesizer_agent",
            "synthesizer",
        )
        for evt, txt in text_events:
            auth = getattr(evt, "author", "") or ""
            if auth in SYNTHESIZER_AUTHORS:
                final_evt = evt
                final_text = txt
                break

        if not final_evt and text_events:
            final_evt, final_text = text_events[-1]

        ev_meta = getattr(final_evt, "custom_metadata", None) or {}
        if not isinstance(ev_meta, dict):
            ev_meta = {}
        citations = ev_meta.get("citations")
        references = ev_meta.get("references")
        candidates = ev_meta.get("candidates")
        citation_verification = ev_meta.get("citation_verification")
        enriched_text = ev_meta.get("enriched_text")

        if getattr(session, "state", None):
            audit_history = session.state.get("retrieved_evidence") or []
            inv_id = getattr(final_evt or asst_evts[-1], "invocation_id", None)
            for entry in reversed(audit_history):
                # Strictly scope to matching invocation_id to prevent leaking across turns
                entry_inv = entry.get("invocation_id")
                if (inv_id and entry_inv == inv_id) or (not inv_id and not entry_inv):
                    if not citations:
                        citations = entry.get("citations")
                    if not references:
                        references = entry.get("references")
                    if not enriched_text:
                        enriched_text = entry.get("enriched_text")
                    if not citation_verification:
                        citation_verification = entry.get("citation_verification_telemetry")
                    break

        if enriched_text:
            final_text = enriched_text

        if not candidates and getattr(session, "state", None):
            inv_id = getattr(final_evt or asst_evts[-1], "invocation_id", None)
            cand_inv_id = session.state.get("candidates_invocation_id")
            if inv_id and cand_inv_id and inv_id == cand_inv_id:
                candidates = session.state.get("candidates") or session.state.get("selected_candidates")

        # Compute multi-agent turn cost and model mapping
        turn_cost, agent_models = aggregate_turn_cost(
            asst_evts,
            default_model=default_model,
            zotero_data_dir=zotero_data_dir,
        )

        # Inspect citation_verification assessments for sentence attribution accounting
        if citation_verification and isinstance(citation_verification, dict):
            sa_cost = 0.0
            sa_prompt = 0
            sa_completion = 0
            sa_total = 0
            has_sa = False
            for assessment in citation_verification.get("assessments", []):
                if not isinstance(assessment, dict):
                    continue
                sa = assessment.get("sentence_attribution")
                if sa and isinstance(sa, dict) and sa.get("status") == "ok":
                    has_sa = True
                    try:
                        sa_cost += float(sa.get("cost_usd") or 0.0)
                    except (ValueError, TypeError):
                        pass
                    u = sa.get("usage") or {}
                    if isinstance(u, dict):
                        try:
                            p = int(u.get("prompt_tokens") or 0)
                        except (ValueError, TypeError):
                            p = 0
                        try:
                            c = int(u.get("completion_tokens") or 0)
                        except (ValueError, TypeError):
                            c = 0
                        try:
                            t = int(u.get("total_tokens") or (p + c))
                        except (ValueError, TypeError):
                            t = p + c
                        sa_prompt += p
                        sa_completion += c
                        sa_total += t

            if has_sa:
                turn_cost += sa_cost
                if aggregated_usage is None:
                    aggregated_usage = {
                        "prompt_tokens": sa_prompt,
                        "candidates_tokens": sa_completion,
                        "thoughts_tokens": 0,
                        "cached_tokens": 0,
                        "total_tokens": sa_total,
                    }
                else:
                    aggregated_usage["prompt_tokens"] = aggregated_usage.get("prompt_tokens", 0) + sa_prompt
                    aggregated_usage["candidates_tokens"] = aggregated_usage.get("candidates_tokens", 0) + sa_completion
                    aggregated_usage["total_tokens"] = aggregated_usage.get("total_tokens", 0) + sa_total

        session_total_cost += turn_cost
        if aggregated_usage:
            session_total_tokens += aggregated_usage.get("total_tokens", 0)

        if aggregated_usage is not None:
            aggregated_usage["cost_usd"] = round(turn_cost, 6)
            aggregated_usage["agent_models"] = agent_models
        elif agent_models:
            aggregated_usage = {
                "prompt_tokens": 0,
                "candidates_tokens": 0,
                "thoughts_tokens": 0,
                "cached_tokens": 0,
                "total_tokens": 0,
                "cost_usd": round(turn_cost, 6),
                "agent_models": agent_models,
            }

        # Extract intermediate steps
        intermediate_steps: list[dict[str, Any]] = []
        all_thoughts: list[str] = []
        for evt in asst_evts:
            auth = format_author_name(getattr(evt, "author", None))
            ts = getattr(evt, "timestamp", None)
            txt, thoughts, calls, responses = extract_event_parts(evt)

            for th in thoughts:
                all_thoughts.append(th)
                intermediate_steps.append({
                    "type": "thought",
                    "author": auth,
                    "content": th,
                    "timestamp": ts,
                })

            if txt and evt != final_evt:
                intermediate_steps.append({
                    "type": "agent_text",
                    "author": auth,
                    "text": txt,
                    "timestamp": ts,
                })

            for call in calls:
                if call.get("name") != "finish_task":
                    intermediate_steps.append({
                        "type": "tool_call",
                        "author": auth,
                        "name": call.get("name"),
                        "args": call.get("args"),
                        "call_id": call.get("id"),
                        "timestamp": ts,
                    })

            for resp in responses:
                if resp.get("name") != "finish_task":
                    intermediate_steps.append({
                        "type": "tool_return",
                        "name": resp.get("name"),
                        "response": resp.get("response"),
                        "call_id": resp.get("id"),
                        "timestamp": ts,
                    })

        # Final metadata
        final_author = format_author_name(getattr(final_evt, "author", None)) if final_evt else "Zocrates"
        final_model = getattr(final_evt, "model_version", None) if final_evt else None
        if not final_model:
            for evt in reversed(asst_evts):
                if getattr(evt, "model_version", None):
                    final_model = evt.model_version
                    break

        final_finish = getattr(final_evt, "finish_reason", None) if final_evt else None
        if not final_finish:
            for evt in reversed(asst_evts):
                if getattr(evt, "finish_reason", None):
                    final_finish = evt.finish_reason
                    break
        final_finish = parse_finish_reason(final_finish)

        final_ts = getattr(final_evt or asst_evts[-1], "timestamp", None)

        # Duration
        duration_sec = None
        try:
            start_ts = getattr(user_evt, "timestamp", None) if user_evt else None
            end_ts = final_ts or getattr(asst_evts[-1], "timestamp", None)
            if start_ts is not None and end_ts is not None:
                duration_sec = round(float(end_ts) - float(start_ts), 2)
                if duration_sec < 0:
                    duration_sec = None
        except Exception:
            pass



        messages.append({
            "id": getattr(final_evt or asst_evts[-1], "id", None),
            "role": "assistant",
            "text": final_text,
            "thought": "\n\n".join(all_thoughts) if all_thoughts else None,
            "author": final_author,
            "model": final_model,
            "intermediate_steps": intermediate_steps,
            "usage": aggregated_usage,
            "turn_cost_usd": round(turn_cost, 6),
            "agent_models": agent_models,
            "finish_reason": final_finish,
            "timestamp": final_ts,
            "duration": duration_sec,
            "active_context_items": context_items,
            "invocation_id": getattr(final_evt or asst_evts[-1], "invocation_id", None),
            "citations": citations,
            "references": references,
            "candidates": candidates,
            "citation_verification": citation_verification,
        })

    title = session.state.get("session_title", "New Chat") if getattr(session, "state", None) else "New Chat"
    if not title or title == "New Chat":
        if messages and messages[0].get("text"):
            first_text = messages[0]["text"].strip()
            title = first_text[:24] + ("..." if len(first_text) > 24 else "")

    # Check for unresolved HITL interrupts
    pending_hitl = None
    unresolved_interrupts: set[str] = set()
    latest_hitl_event = None
    latest_hitl_fc = None

    for ev in events:
        if getattr(ev, "long_running_tool_ids", None):
            unresolved_interrupts.update(ev.long_running_tool_ids)
            if ev.content and ev.content.parts:
                for p in ev.content.parts:
                    fc = getattr(p, "function_call", None)
                    if fc and getattr(fc, "name", None) == "adk_request_input" and getattr(fc, "id", None) in unresolved_interrupts:
                        latest_hitl_event = ev
                        latest_hitl_fc = fc
        if ev.content and ev.content.parts:
            for p in ev.content.parts:
                fr = getattr(p, "function_response", None)
                if fr and getattr(fr, "id", None) in unresolved_interrupts:
                    unresolved_interrupts.discard(fr.id)
                    if latest_hitl_fc and getattr(latest_hitl_fc, "id", None) == fr.id:
                        latest_hitl_event = None
                        latest_hitl_fc = None

    if unresolved_interrupts and latest_hitl_fc is not None:
        fc_args = getattr(latest_hitl_fc, "args", {}) or {}
        if not isinstance(fc_args, dict):
            fc_args = {}
        pending_hitl = {
            "interrupt_id": latest_hitl_fc.id,
            "invocation_id": getattr(latest_hitl_event, "invocation_id", None),
            "message": fc_args.get("message"),
            "payload": fc_args.get("payload", {}),
        }
        if messages:
            messages[-1]["pending_hitl"] = pending_hitl

    valid_turns_count = sum(
        1 for m in messages
        if m.get("role") == "assistant"
        and m.get("text")
        and not (m.get("text") or "").startswith("Hello! I am Zocrates")
    )

    res = {
        "id": session.id,
        "title": title,
        "created_at": getattr(session, "created_at", None),
        "updated_at": getattr(session, "updated_at", None),
        "total_cost_usd": round(session_total_cost, 6),
        "total_tokens": session_total_tokens,
        "total_turns": valid_turns_count,
        "session_metadata": {
            "total_cost_usd": round(session_total_cost, 6),
            "total_tokens": session_total_tokens,
            "total_turns": valid_turns_count,
        },
        "messages": messages,
    }
    if pending_hitl:
        res["pending_hitl"] = pending_hitl
    return res

format_user_research_profile_context

format_user_research_profile_context(profile)

Format the user research profile for injection into agent instructions.

Strictly conditional: returns an empty string when the profile is empty or whitespace-only, so inactive profiles add zero tokens to the prompt.

Source code in zocrates/agents/coordinator/prompts.py
def format_user_research_profile_context(profile: str | None) -> str:
    """Format the user research profile for injection into agent instructions.

    Strictly conditional: returns an empty string when the profile is empty or
    whitespace-only, so inactive profiles add zero tokens to the prompt.
    """
    if not profile or not profile.strip():
        return ""
    return "# User Research Profile & Directives\n" + profile.strip() + "\n\n"

generate_diagnostic_report async

generate_diagnostic_report(request)

Generate a multi-layer sanitized, GitHub-ready diagnostic report on user request.

Source code in zocrates/fast_api_app.py
@app.post("/diagnostics/report", dependencies=[Depends(verify_auth)])
async def generate_diagnostic_report(request: DiagnosticReportRequest) -> dict[str, Any]:
    """Generate a multi-layer sanitized, GitHub-ready diagnostic report on user request."""
    from zocrates.utils.diagnostics import build_diagnostic_report_bundle

    try:
        syncer = get_syncer()
        zot_dir: Path | None = Path(syncer._zotero_data_dir)
    except RuntimeError:
        hint = _status_zotero_data_dir_hint()
        zot_dir = Path(hint) if hint else None

    sess = None
    if request.session_id:
        runner: Runner | None = getattr(app.state, "runner", None)
        session_service = (
            getattr(runner, "session_service", None)
            if runner
            else services.get_session_service()
        )
        if session_service:
            app_name = getattr(app.state, "agent_app_name", "zocrates")
            try:
                raw_sess = session_service.get_session(
                    app_name=app_name,
                    user_id="default_user",
                    session_id=request.session_id,
                )
                sess = await raw_sess if inspect.isawaitable(raw_sess) else raw_sess
            except Exception as exc:
                logger.debug(
                    "diagnostics report could not load session %s: %s",
                    request.session_id,
                    exc,
                )

    def _build_sync() -> dict[str, Any]:
        return build_diagnostic_report_bundle(
            zotero_data_dir=zot_dir,
            session=sess,
            session_id=request.session_id,
            invocation_id=request.invocation_id,
            error_message=request.error_message,
            include_prompts=request.include_prompts,
            include_item_titles=request.include_item_titles,
            include_logs=request.include_logs,
            log_tail_lines=request.log_tail_lines,
            frontend_metadata=request.frontend_metadata,
        )

    return await anyio.to_thread.run_sync(_build_sync)

generate_root_index_md

generate_root_index_md(cache_dir)

Regenerate root-level index.md catalog from conversion_meta.db.

Parameters:

Name Type Description Default
cache_dir Path

Corpus directory containing conversion_meta.db.

required
Source code in zocrates/rag/bundle_catalog.py
def generate_root_index_md(cache_dir: Path) -> None:
    """Regenerate root-level index.md catalog from conversion_meta.db.

    Args:
        cache_dir: Corpus directory containing conversion_meta.db.
    """
    db_path = cache_dir / "conversion_meta.db"
    if not db_path.exists():
        return

    lines = []
    with contextlib.closing(sqlite3.connect(str(db_path), timeout=5.0)) as conn:
        conn.row_factory = sqlite3.Row
        conn.execute("PRAGMA busy_timeout = 5000;")
        rows = conn.execute(
            "SELECT item_key, title, parent_item_type, converter_id, converter_version, "
            "converted_at, source_path, char_count "
            "FROM conversion_meta ORDER BY converted_at DESC"
        ).fetchall()

        lines = [
            "# Zocrates Knowledge Base Catalog",
            "",
            f"Total Converted Documents: {len(rows)}",
            "",
            "| Item Key | Title | Type | Converter | Converted At | Source Path | Chars |",
            "| :--- | :--- | :--- | :--- | :--- | :--- | :--- |",
        ]

        title_pattern = re.compile(r"^title:\s*['\"]?(.*?)['\"]?\s*$", re.IGNORECASE)

        for r in rows:
            key = r["item_key"]
            raw_title = r["title"]

            # If title is missing in DB, attempt fast scan from document.md frontmatter
            if not raw_title or not str(raw_title).strip():
                doc_file = cache_dir / key / "document.md"
                if doc_file.exists():
                    try:
                        with doc_file.open("r", encoding="utf-8") as f:
                            for _ in range(30):
                                line = f.readline()
                                if not line:
                                    break
                                m = title_pattern.match(line.strip())
                                if m and m.group(1).strip():
                                    raw_title = m.group(1).strip()
                                    # Lazily backfill to DB
                                    conn.execute(
                                        "UPDATE conversion_meta SET title = ? WHERE item_key = ?",
                                        (raw_title, key),
                                    )
                                    conn.commit()
                                    break
                    except Exception:
                        pass

            if not raw_title or not str(raw_title).strip():
                raw_title = (r["source_path"] and Path(r["source_path"]).stem) or "Untitled"

            clean_title = (
                str(raw_title)
                .replace("|", "\\|")
                .replace("\r", " ")
                .replace("\n", " ")
                .strip()
            )
            p_type = r["parent_item_type"] or "document"
            conv = f"{r['converter_id']} (v{r['converter_version']})"
            conv_at = r["converted_at"]
            stored_path = (r["source_path"] or "").strip()
            if stored_path.startswith("zotero://note/"):
                rel_source = f"[Zotero Note]({stored_path})"
            elif stored_path.startswith("storage/"):
                rel_source = f"[Source Document](../../{stored_path})"
            elif stored_path.startswith("../../storage/"):
                rel_source = f"[Source Document]({stored_path})"
            elif stored_path:
                rel_source = f"[Source Document]({stored_path})"
            else:
                rel_source = ""
            count = r["char_count"]
            lines.append(
                f"| [{key}](./{key}/document.md) | {clean_title} | {p_type} | {conv} | {conv_at} | {rel_source} | {count} |"
            )

    lines.append("")
    root_index = cache_dir / "index.md"
    root_index.write_text("\n".join(lines), encoding="utf-8")

    # Keep AGENTS.md synchronized with root catalog
    generate_agents_md(cache_dir)

get_auth_token

get_auth_token(zotero_data_dir=None)

Resolve or generate the local backend authentication token.

Resolution order
  1. ZOCRATES_AUTH_TOKEN environment variable.
  2. Sidecar file <zocrates-data>/backend.auth.
  3. Cached in-memory token, or newly generated 256-bit hex token (secrets.token_hex(32)) persisted to backend.auth with mode 0o600.
Source code in zocrates/config.py
def get_auth_token(zotero_data_dir: Path | str | None = None) -> str:
    """Resolve or generate the local backend authentication token.

    Resolution order:
      1. ``ZOCRATES_AUTH_TOKEN`` environment variable.
      2. Sidecar file ``<zocrates-data>/backend.auth``.
      3. Cached in-memory token, or newly generated 256-bit hex token
         (``secrets.token_hex(32)``) persisted to ``backend.auth`` with mode ``0o600``.
    """
    global _CACHED_AUTH_TOKEN

    env_token = os.getenv("ZOCRATES_AUTH_TOKEN", "").strip()
    if env_token:
        if zotero_data_dir is not None:
            auth_file = Path(zotero_data_dir) / ZOCRATES_DATA_SUBDIR / "backend.auth"
            if not auth_file.exists():
                _write_auth_sidecar(auth_file, env_token)
        return env_token

    if zotero_data_dir is not None:
        resolved_dir = Path(zotero_data_dir)
        auth_file = resolved_dir / ZOCRATES_DATA_SUBDIR / "backend.auth"
        try:
            if auth_file.is_file():
                file_token = auth_file.read_text(encoding="utf-8").strip()
                if file_token:
                    _CACHED_AUTH_TOKEN = file_token
                    return file_token
        except OSError:
            pass
        token = _CACHED_AUTH_TOKEN or secrets.token_hex(32)
        _write_auth_sidecar(auth_file, token)
        _CACHED_AUTH_TOKEN = token
        return token

    if _CACHED_AUTH_TOKEN:
        return _CACHED_AUTH_TOKEN

    resolved_dir = _resolve_explicit_zotero_data_dir(None)
    if resolved_dir is not None:
        auth_file = resolved_dir / ZOCRATES_DATA_SUBDIR / "backend.auth"
        try:
            if auth_file.is_file():
                file_token = auth_file.read_text(encoding="utf-8").strip()
                if file_token:
                    _CACHED_AUTH_TOKEN = file_token
                    return file_token
        except OSError:
            pass
        token = secrets.token_hex(32)
        _write_auth_sidecar(auth_file, token)
        _CACHED_AUTH_TOKEN = token
        return token

    _CACHED_AUTH_TOKEN = secrets.token_hex(32)
    return _CACHED_AUTH_TOKEN

get_config async

get_config()

Return active LLM and embedding configuration.

Source code in zocrates/fast_api_app.py
@app.get("/config", dependencies=[Depends(verify_auth)])
async def get_config() -> dict:
    """Return active LLM and embedding configuration."""
    from zocrates.config import (
        DEFAULT_AUTO_RENDER_MERMAID_DIAGRAMS,
        DEFAULT_BACKEND_PORT,
        DEFAULT_CITATIONS_ENABLED,
        DEFAULT_ENABLE_MCP_SERVER,
        DEFAULT_OVERRIDE_AGENT_MODELS,
        DEFAULT_REFERENCES_SECTION_ENABLED,
        DEFAULT_SENTENCE_ATTRIBUTION_MODEL,
        get_auto_spawn_backend,
        get_compaction_config,
        get_datalab_api_key,
        get_datalab_merge_cross_page,
        get_openalex_api_key,
        get_secure_api_key,
        get_session_maintenance_config,
        load_zocrates_config,
        resolve_zotero_data_dir,
    )

    import copy

    zot_dir = getattr(app.state, "zotero_data_dir", None) or resolve_zotero_data_dir()
    _maybe_hot_swap_agent_models(zot_dir, "GET /config")

    cfg = load_zocrates_config()
    has_key = bool(get_secure_api_key("openrouter_api_key", ("OPENROUTER_API_KEY", "OPEN_ROUTER_API_KEY")))
    has_datalab = bool(get_datalab_api_key())
    has_openalex = bool(get_openalex_api_key())
    session_storage_stats = {
        "size_bytes": 0,
        "session_count": 0,
        "freelist_pages": 0,
        "total_pages": 0,
        "page_size": 4096,
    }
    if zot_dir:
        from zocrates.utils.session_maintenance import get_session_storage_stats
        session_db_path = Path(zot_dir) / "zocrates-data" / "sessions.db"
        sess_service = services.get_session_service()
        app_name = getattr(app.state, "agent_app_name", "zocrates")
        try:
            session_storage_stats = await get_session_storage_stats(
                db_path=session_db_path,
                session_service=sess_service,
                app_name=app_name,
                user_id="default_user",
            )
        except Exception as exc:
            logger.debug("Failed to get session storage stats for llm config: %s", exc)

    return {
        "status": "success",
        "llm_provider": cfg.get("llm_provider", "openrouter_llm"),
        "llm_model": cfg.get("llm_model", DEFAULT_LLM_MODEL),
        "llm_base_url": cfg.get("llm_base_url"),
        "llm_temperature": cfg.get("llm_temperature", DEFAULT_LLM_TEMPERATURE),
        "llm_max_tokens": cfg.get("llm_max_tokens", DEFAULT_LLM_MAX_TOKENS),
        "llm_num_retries": cfg.get("llm_num_retries", DEFAULT_LLM_NUM_RETRIES),
        "llm_extra_options": cfg.get("llm_extra_options", {}),
        "override_agent_models": cfg.get(
            "override_agent_models", copy.deepcopy(DEFAULT_OVERRIDE_AGENT_MODELS)
        ),
        "embedding_provider": cfg.get("embedding_provider", "local"),
        "embedding_model": cfg.get("embedding_model", "jinaai/jina-embeddings-v5-text-nano"),
        "has_openrouter_key": has_key,
        "has_datalab_key": has_datalab,
        "has_openalex_api_key": has_openalex,
        "datalab_merge_cross_page": get_datalab_merge_cross_page(zot_dir, cfg=cfg),
        "citations_enabled": cfg.get("citations_enabled", DEFAULT_CITATIONS_ENABLED),
        "references_section_enabled": cfg.get("references_section_enabled", DEFAULT_REFERENCES_SECTION_ENABLED),
        "auto_render_mermaid_diagrams": cfg.get("auto_render_mermaid_diagrams", DEFAULT_AUTO_RENDER_MERMAID_DIAGRAMS),
        "auto_spawn_backend": get_auto_spawn_backend(zot_dir, cfg=cfg),
        "backend_port": cfg.get("backend_port", DEFAULT_BACKEND_PORT),
        "enable_mcp_server": bool(cfg.get("enable_mcp_server", DEFAULT_ENABLE_MCP_SERVER)),
        "scholar_polite_email": str(cfg.get("scholar_polite_email", "")),
        "user_research_profile": str(cfg.get("user_research_profile") or ""),
        "enable_predictive_ghost_input": bool(cfg.get("enable_predictive_ghost_input", DEFAULT_ENABLE_PREDICTIVE_GHOST_INPUT)),
        "predictive_prompt_model": str(cfg.get("predictive_prompt_model") or DEFAULT_PREDICTIVE_PROMPT_MODEL),
        "sentence_attribution_model": cfg.get("sentence_attribution_model", DEFAULT_SENTENCE_ATTRIBUTION_MODEL),
        "citation_verification_enabled": bool(cfg.get("citation_verification_enabled", False)),
        "citation_verification_provider": str(cfg.get("citation_verification_provider", "openrouter")),
        "citation_verification_model": str(cfg.get("citation_verification_model", DEFAULT_CITATION_VERIFICATION_MODEL)),
        "citation_verification_threshold": float(cfg.get("citation_verification_threshold", 0.60)),
        "citation_verification_local_url": str(cfg.get("citation_verification_local_url", "http://127.0.0.1:8005/v1/systemone")),
        "citation_verification_local_port": int(cfg.get("citation_verification_local_port", 8005)),
        "citation_verification_auto_start_local": bool(cfg.get("citation_verification_auto_start_local", True)),
        "citation_verification_timeout_sec": float(cfg.get("citation_verification_timeout_sec", 3.0)),
        "citation_verification_turn_budget_sec": float(cfg.get("citation_verification_turn_budget_sec", 10.0)),
        **get_compaction_config(),
        **get_session_maintenance_config(zot_dir),
        "session_storage_stats": session_storage_stats,
    }

get_conversion_timeout_seconds

get_conversion_timeout_seconds(zotero_data_dir=None, cfg=None)

Return the timeout in seconds for document conversion operations.

Checks CONVERSION_TIMEOUT_SECONDS environment variable first; if unset, falls back to conversion_timeout_seconds in zocrates_config.json (defaulting to 360s to accommodate CPU-based layout inference in Docling).

Source code in zocrates/config.py
def get_conversion_timeout_seconds(
    zotero_data_dir: Path | None = None,
    cfg: dict[str, Any] | None = None,
) -> int:
    """Return the timeout in seconds for document conversion operations.

    Checks ``CONVERSION_TIMEOUT_SECONDS`` environment variable first;
    if unset, falls back to ``conversion_timeout_seconds`` in ``zocrates_config.json``
    (defaulting to 360s to accommodate CPU-based layout inference in Docling).
    """
    env_val = os.getenv("CONVERSION_TIMEOUT_SECONDS")
    if env_val is not None:
        try:
            return max(30, int(env_val))
        except ValueError:
            pass
    if cfg is None:
        cfg = load_zocrates_config(zotero_data_dir)
    try:
        return max(
            30,
            int(
                cfg.get(
                    "conversion_timeout_seconds",
                    DEFAULT_CONVERSION_TIMEOUT_SECONDS,
                )
            ),
        )
    except (ValueError, TypeError):
        return DEFAULT_CONVERSION_TIMEOUT_SECONDS

get_default_discovery_engine

get_default_discovery_engine(zotero_data_dir=None, polite_email=None, openalex_api_key=None)

Instantiate a configured DiscoveryEngine with local Zotero deduplication.

Source code in zocrates/discovery/engine.py
def get_default_discovery_engine(
    zotero_data_dir: Path | str | None = None,
    polite_email: str | None = None,
    openalex_api_key: str | None = None,
) -> DiscoveryEngine:
    """Instantiate a configured DiscoveryEngine with local Zotero deduplication."""
    from zocrates.discovery.abstract_fallback import AbstractFallback
    from zocrates.discovery.dedup import Deduplicator
    from zocrates.discovery.providers.base import DEFAULT_POLITE_EMAIL
    from zocrates.discovery.providers.openalex import OpenAlexProvider
    from zocrates.discovery.providers.unpaywall import UnpaywallResolver
    from zocrates.config import get_openalex_api_key, get_scholar_polite_email, resolve_zotero_data_dir

    resolved_dir = zotero_data_dir or resolve_zotero_data_dir()
    path_dir = Path(resolved_dir) if resolved_dir else None
    email = polite_email
    if not email:
        try:
            email = get_scholar_polite_email(path_dir)
        except Exception:
            pass
    if not email:
        email = DEFAULT_POLITE_EMAIL

    api_key = openalex_api_key
    if not api_key:
        try:
            api_key = get_openalex_api_key(path_dir)
        except Exception:
            pass

    zotero_db = None
    if resolved_dir:
        sqlite_path = Path(resolved_dir) / "zotero.sqlite"
        storage_dir = Path(resolved_dir) / "storage"
        if sqlite_path.exists():
            try:
                from zocrates.rag.zotero_db import ZoteroDB

                zotero_db = ZoteroDB(sqlite_path=sqlite_path, storage_dir=storage_dir)
            except Exception as e:
                logger.debug("Could not initialize ZoteroDB for discovery deduplication: %s", e)

    deduplicator = Deduplicator(zotero_db=zotero_db)
    provider = OpenAlexProvider(email=email, api_key=api_key)
    abstract_fallback = AbstractFallback(email=email)
    unpaywall = UnpaywallResolver(email=email)

    return DiscoveryEngine(
        provider=provider,
        deduplicator=deduplicator,
        abstract_fallback=abstract_fallback,
        unpaywall=unpaywall,
        email=email,
    )

get_document_toc_core

get_document_toc_core(item_key)

Retrieve the human- and agent-readable Table of Contents, Title, Abstract, Figures, and Tables.

Parameters:

Name Type Description Default
item_key str

Unique Zotero item key.

required

Returns:

Type Description
dict[str, Any]

Dictionary with 'status', 'item_key', and 'toc_content'.

Source code in zocrates/agents/common/toolkit.py
def get_document_toc_core(item_key: str) -> dict[str, Any]:
    """Retrieve the human- and agent-readable Table of Contents, Title, Abstract, Figures, and Tables.

    Args:
        item_key: Unique Zotero item key.

    Returns:
        Dictionary with 'status', 'item_key', and 'toc_content'.
    """
    if not _validate_item_key(item_key):
        return {"status": "error", "message": f"Invalid item key: {item_key}"}

    try:
        resolved = _resolve_bundle_dir(item_key)
        if not resolved:
            return {
                "status": "error",
                "message": f"Document bundle for {item_key} not found or not yet converted.",
            }

        bundle_dir, resolved_key, res_notice = resolved
        toc_md_path = bundle_dir / "toc.md"
        if toc_md_path.exists():
            result: dict[str, Any] = {
                "status": "success",
                "item_key": resolved_key,
                "toc_content": toc_md_path.read_text(encoding="utf-8"),
            }
            if res_notice:
                result["notice"] = res_notice
            return result

        # Lazy generation fallback if document.md exists but toc.md was not yet generated
        doc_md_path = bundle_dir / "document.md"
        if doc_md_path.exists():
            syncer = get_syncer()
            doc_text = doc_md_path.read_text(encoding="utf-8")
            meta = syncer.processor.get_metadata(resolved_key)
            title = meta.get("title") if meta else None
            parent_item_key = meta.get("parent_item_key") if meta else None
            from zocrates.rag.toc_generator import generate_toc, save_toc_files

            toc_json, toc_md = generate_toc(
                doc_text,
                title=title,
                item_key=resolved_key,
                parent_item_key=parent_item_key,
            )
            save_toc_files(bundle_dir, toc_json, toc_md)
            result = {
                "status": "success",
                "item_key": resolved_key,
                "toc_content": toc_md,
            }
            if res_notice:
                result["notice"] = res_notice
            return result

        return {
            "status": "error",
            "message": f"Document bundle for {item_key} has no document.md or toc.md.",
        }
    except Exception as exc:
        logger.error("Failed in get_document_toc_core for %s: %s", item_key, exc)
        return {"status": "error", "message": str(exc)}

get_graph_data

get_graph_data(center_key=None, collection_key=None, center_keys=None, collection_keys=None, depth=Query(default=2, ge=1, le=5), min_score=Query(default=0.5, ge=0.0, le=1.0), include_curated=True, include_tags=True)

Retrieve nodes and links for paper relations network graph visualizer.

Source code in zocrates/fast_api_app.py
4601
4602
4603
4604
4605
4606
4607
4608
4609
4610
4611
4612
4613
4614
4615
4616
4617
4618
4619
4620
4621
4622
4623
4624
4625
4626
4627
4628
4629
4630
4631
4632
4633
4634
4635
4636
4637
4638
4639
4640
4641
4642
4643
4644
4645
4646
4647
4648
4649
4650
4651
4652
4653
4654
4655
4656
4657
4658
4659
4660
4661
4662
4663
4664
4665
4666
4667
4668
4669
4670
4671
4672
4673
4674
4675
4676
4677
4678
4679
4680
4681
4682
4683
4684
4685
4686
4687
4688
4689
4690
4691
4692
4693
4694
4695
4696
4697
4698
4699
4700
4701
4702
4703
4704
4705
4706
4707
4708
4709
4710
4711
4712
4713
4714
4715
4716
4717
4718
4719
4720
4721
4722
4723
4724
4725
4726
4727
4728
4729
4730
4731
4732
4733
4734
4735
4736
4737
4738
4739
4740
4741
4742
4743
4744
4745
4746
4747
4748
4749
4750
4751
4752
4753
4754
4755
4756
4757
4758
4759
4760
4761
4762
4763
4764
4765
4766
4767
4768
4769
4770
4771
4772
4773
4774
4775
4776
4777
4778
4779
4780
4781
4782
4783
4784
4785
4786
4787
4788
4789
4790
4791
4792
4793
4794
4795
4796
4797
4798
4799
4800
4801
4802
4803
4804
4805
4806
4807
4808
4809
4810
4811
4812
4813
4814
4815
4816
4817
4818
4819
4820
4821
4822
4823
4824
4825
4826
4827
4828
4829
4830
4831
4832
4833
4834
4835
4836
4837
4838
4839
4840
4841
4842
4843
4844
4845
4846
4847
4848
4849
4850
4851
4852
4853
4854
4855
4856
4857
4858
4859
4860
4861
4862
4863
4864
4865
4866
4867
4868
4869
4870
4871
4872
4873
4874
4875
4876
4877
4878
4879
4880
4881
4882
4883
4884
4885
4886
4887
4888
4889
4890
4891
4892
4893
4894
4895
4896
4897
4898
4899
4900
4901
4902
4903
4904
4905
4906
4907
4908
4909
4910
4911
4912
4913
4914
4915
4916
4917
4918
4919
4920
4921
4922
4923
4924
4925
4926
4927
4928
4929
4930
4931
4932
4933
4934
4935
4936
4937
4938
4939
4940
4941
4942
4943
4944
4945
4946
4947
4948
4949
4950
4951
4952
4953
4954
4955
4956
4957
4958
4959
@app.get("/graph/data", dependencies=[Depends(verify_auth)])
def get_graph_data(
    center_key: str | None = None,
    collection_key: str | None = None,
    center_keys: str | None = None,
    collection_keys: str | None = None,
    depth: int = Query(default=2, ge=1, le=5),
    min_score: float = Query(default=0.50, ge=0.0, le=1.0),
    include_curated: bool = True,
    include_tags: bool = True,
) -> dict:
    """Retrieve nodes and links for paper relations network graph visualizer."""
    db_path = _resolve_conversion_meta_db_path()
    if not db_path or not db_path.exists():
        return {"nodes": [], "links": []}

    # Optional ZoteroDB for collection filtering and year resolution
    syncer = None
    zdb = None
    try:
        syncer = get_syncer()
        if syncer and getattr(syncer, "db", None):
            zdb = syncer.db
    except Exception:
        pass

    if zdb is None:
        try:
            from zocrates.config import resolve_zotero_data_dir
            from zocrates.rag.zotero_db import ZoteroDB

            z_dir = getattr(app.state, "zotero_data_dir", None) or resolve_zotero_data_dir()
            if z_dir:
                sqlite_path = z_dir / "zotero.sqlite"
                storage_dir = z_dir / "storage"
                if sqlite_path.exists():
                    zdb = ZoteroDB(sqlite_path=sqlite_path, storage_dir=storage_dir)
        except Exception:
            pass

    # Combine collection_key and collection_keys into target collection keys
    target_collections: set[str] = set()
    for col_arg in (collection_key, collection_keys):
        if col_arg:
            for part in col_arg.split(","):
                part_clean = part.strip()
                if part_clean:
                    target_collections.add(part_clean)

    collection_keys_set: set[str] | None = None
    if target_collections:
        collection_keys_set = set()
        if zdb:
            for col in target_collections:
                try:
                    collection_keys_set.update(zdb.get_item_keys_in_collection(col))
                except Exception as exc:
                    logger.warning("Failed to resolve collection keys for %s: %s", col, exc)
        else:
            logger.warning("Collections requested (%s) but ZoteroDB is not available", target_collections)

    import sqlite3

    try:
        conn = sqlite3.connect(str(db_path))
        conn.row_factory = sqlite3.Row
    except Exception as exc:
        logger.error("Failed to connect to %s: %s", db_path, exc)
        return {"nodes": [], "links": []}

    try:
        # 1. Read nodes from conversion_meta
        try:
            cursor = conn.execute(
                """
                SELECT item_key, title, content_type, parent_item_key, parent_item_type, keywords, abstract
                FROM conversion_meta
                """
            )
            meta_rows = cursor.fetchall()
        except sqlite3.OperationalError:
            # Fallback if abstract column doesn't exist
            try:
                cursor = conn.execute(
                    """
                    SELECT item_key, title, content_type, parent_item_key, parent_item_type, keywords
                    FROM conversion_meta
                    """
                )
                meta_rows = cursor.fetchall()
            except sqlite3.OperationalError:
                return {"nodes": [], "links": []}

        raw_nodes_map: dict[str, dict[str, Any]] = {}
        for row in meta_rows:
            i_key = row["item_key"]
            p_key = row["parent_item_key"]

            # Filter by collection if collections provided
            if collection_keys_set is not None:
                if i_key not in collection_keys_set and (not p_key or p_key not in collection_keys_set):
                    continue

            # Parse keywords
            keywords: list[str] = []
            raw_kw = row["keywords"]
            if raw_kw:
                try:
                    parsed_kw = json.loads(raw_kw)
                    if isinstance(parsed_kw, list):
                        keywords = [str(k).strip() for k in parsed_kw if str(k).strip()]
                    elif isinstance(parsed_kw, str):
                        keywords = [parsed_kw.strip()] if parsed_kw.strip() else []
                except Exception:
                    keywords = [k.strip() for k in str(raw_kw).split(",") if k.strip()]

            # Extract bibliographic metadata from ZoteroDB and conversion_meta
            year: int | str | None = None
            authors: list[str] = []
            publication: str | None = None
            fallback_abstract: str | None = (
                row["abstract"] if "abstract" in row.keys() and row["abstract"] else None
            )
            raw_abstract: str | None = None

            if zdb:
                try:
                    item_obj = None
                    if p_key:
                        item_obj = zdb.get_item_by_key(p_key)
                    if not item_obj:
                        item_obj = zdb.get_item_by_key(i_key)
                    if item_obj:
                        if getattr(item_obj, "year", None):
                            try:
                                year = int(item_obj.year)
                            except (ValueError, TypeError):
                                year = item_obj.year
                        elif getattr(item_obj, "date", None):
                            m = re.search(r"\b(19\d\d|20\d\d)\b", str(item_obj.date))
                            if m:
                                try:
                                    year = int(m.group(1))
                                except (ValueError, TypeError):
                                    year = m.group(1)
                        if getattr(item_obj, "creators", None):
                            authors = [str(c).strip() for c in item_obj.creators if str(c).strip()]
                        if getattr(item_obj, "publication", None):
                            publication = item_obj.publication
                        if getattr(item_obj, "abstract", None):
                            raw_abstract = item_obj.abstract
                except Exception:
                    pass

            if not raw_abstract:
                raw_abstract = fallback_abstract

            abstract_full: str | None = None
            if raw_abstract and isinstance(raw_abstract, str) and raw_abstract.strip():
                abstract_full = raw_abstract.strip()

            node_type = row["parent_item_type"] or row["content_type"] or "document"
            raw_nodes_map[i_key] = {
                "id": i_key,
                "title": row["title"] or i_key,
                "year": year,
                "type": node_type,
                "keywords": keywords,
                "authors": authors,
                "publication": publication,
                "abstract": abstract_full,
                "parent_item_key": p_key,
            }

        # 2. Read edges from item_relations
        try:
            cursor = conn.execute(
                """
                SELECT source_key, target_key, score, relation_type, match_evidence
                FROM item_relations
                """
            )
            edge_rows = cursor.fetchall()
        except sqlite3.OperationalError:
            edge_rows = []

        # Filter edges by score, relation_types, and node presence
        pair_to_rows: dict[tuple[str, str], list[sqlite3.Row]] = {}
        for row in edge_rows:
            score = float(row["score"])
            if score < min_score:
                continue

            rel_type = row["relation_type"]
            if not include_curated and rel_type in ("human_curated", "human_curated_and_hybrid_search"):
                continue
            if not include_tags and rel_type == "shared_tags":
                continue

            s = row["source_key"]
            t = row["target_key"]
            if s == t:
                continue
            if s not in raw_nodes_map or t not in raw_nodes_map:
                continue

            pair = (min(s, t), max(s, t))
            pair_to_rows.setdefault(pair, []).append(row)

        PRECEDENCE = {
            "human_curated_and_hybrid_search": 4,
            "human_curated": 3,
            "hybrid_search": 2,
            "shared_tags": 1,
        }

        deduplicated_links: list[dict[str, Any]] = []
        for pair, p_rows in pair_to_rows.items():
            best_row = max(
                p_rows,
                key=lambda r: (PRECEDENCE.get(r["relation_type"], 0), float(r["score"])),
            )
            best_rel_type = best_row["relation_type"]
            best_score = float(best_row["score"])

            raw_rrf = None
            evidence_summary = None

            for r in p_rows:
                ev_str = r["match_evidence"]
                if ev_str:
                    try:
                        ev_data = json.loads(ev_str)
                        if isinstance(ev_data, dict):
                            if "raw_rrf_score" in ev_data:
                                raw_rrf = float(ev_data["raw_rrf_score"])
                            elif "raw_rrf" in ev_data:
                                raw_rrf = float(ev_data["raw_rrf"])

                            if not evidence_summary:
                                if ev_data.get("source") == "zotero_itemRelations":
                                    evidence_summary = "Human-curated connection in Zotero"
                                elif ev_data.get("source") == "zotero_itemTags":
                                    tags = ev_data.get("matched_tags", [])
                                    evidence_summary = f"Shared tags: {', '.join(tags)}" if tags else "Shared tags in Zotero"
                                elif "matched_chunks" in ev_data:
                                    chunks = ev_data.get("matched_chunks", 0)
                                    methods = ev_data.get("retrieval_methods", [])
                                    methods_str = " + ".join(methods) if methods else "dense + BM25"
                                    evidence_summary = f"Hybrid search match ({methods_str}, {chunks} chunks)"
                                elif "evidence" in ev_data:
                                    evidence_summary = str(ev_data["evidence"])
                        elif isinstance(ev_data, str) and not evidence_summary:
                            evidence_summary = ev_data
                    except Exception:
                        if not evidence_summary:
                            evidence_summary = ev_str

            if not evidence_summary:
                if best_rel_type == "human_curated":
                    evidence_summary = "Human-curated connection in Zotero"
                elif best_rel_type == "shared_tags":
                    evidence_summary = "Shared tags in Zotero"
                elif best_rel_type == "human_curated_and_hybrid_search":
                    evidence_summary = "Dual-corroborated (curated and hybrid search)"
                else:
                    evidence_summary = f"{best_rel_type} match"

            deduplicated_links.append(
                {
                    "source": pair[0],
                    "target": pair[1],
                    "score": round(best_score, 4),
                    "raw_rrf": raw_rrf,
                    "relation_type": best_rel_type,
                    "evidence": evidence_summary,
                }
            )

        # 3. BFS Ego-network traversal if center_key or center_keys is provided
        target_centers: set[str] = set()
        for c_arg in (center_key, center_keys):
            if c_arg:
                for part in c_arg.split(","):
                    part_clean = part.strip()
                    if part_clean:
                        target_centers.add(part_clean)

        if target_centers:
            effective_centers: set[str] = set()
            for tk in target_centers:
                if tk in raw_nodes_map:
                    effective_centers.add(tk)
                else:
                    for k, n in raw_nodes_map.items():
                        if n.get("parent_item_key") == tk:
                            effective_centers.add(k)

            if not effective_centers:
                return {"nodes": [], "links": []}

            adj: dict[str, set[str]] = {k: set() for k in raw_nodes_map}
            for link in deduplicated_links:
                adj[link["source"]].add(link["target"])
                adj[link["target"]].add(link["source"])

            visited = set(effective_centers)
            queue = [(k, 0) for k in effective_centers]
            while queue:
                curr, d = queue.pop(0)
                if d < depth:
                    for nbr in adj.get(curr, set()):
                        if nbr not in visited:
                            visited.add(nbr)
                            queue.append((nbr, d + 1))

            active_nodes_map = {k: raw_nodes_map[k] for k in visited if k in raw_nodes_map}
            filtered_links = [
                link for link in deduplicated_links
                if link["source"] in visited and link["target"] in visited
            ]
        else:
            effective_centers = set()
            active_nodes_map = raw_nodes_map
            filtered_links = deduplicated_links

        # 4. Compute degree for each active node
        incident_counts = {node_id: 0 for node_id in active_nodes_map}
        for link in filtered_links:
            if link["source"] in incident_counts:
                incident_counts[link["source"]] += 1
            if link["target"] in incident_counts:
                incident_counts[link["target"]] += 1

        nodes_list = []
        for node_id, node_data in active_nodes_map.items():
            is_center = bool(node_id in effective_centers)
            node_obj = {
                "id": node_id,
                "title": node_data["title"],
                "year": node_data["year"],
                "type": node_data["type"],
                "keywords": node_data["keywords"],
                "authors": node_data.get("authors", []),
                "publication": node_data.get("publication"),
                "abstract": node_data.get("abstract"),
                "degree": incident_counts.get(node_id, 0),
                "is_center": is_center,
            }
            if node_data.get("parent_item_key"):
                node_obj["parent_item_key"] = node_data["parent_item_key"]
            nodes_list.append(node_obj)

        return {
            "nodes": nodes_list,
            "links": filtered_links,
        }
    finally:
        conn.close()

get_graph_view async

get_graph_view()

Serve the self-contained HTML5 Canvas paper network graph visualizer.

Source code in zocrates/fast_api_app.py
@app.get("/graph/view", response_class=HTMLResponse, dependencies=[Depends(verify_auth)])
async def get_graph_view() -> HTMLResponse:
    """Serve the self-contained HTML5 Canvas paper network graph visualizer."""
    if not GRAPH_VIEW_TEMPLATE_PATH.exists():
        raise HTTPException(status_code=404, detail="Graph view template not found.")
    content = GRAPH_VIEW_TEMPLATE_PATH.read_text(encoding="utf-8")
    return HTMLResponse(content=content)

get_item_status async

get_item_status(item_key)

Get conversion and vector store status for a given Zotero item key.

Source code in zocrates/fast_api_app.py
@app.get("/items/{item_key}/status", dependencies=[Depends(verify_auth)])
async def get_item_status(item_key: str) -> dict:
    """Get conversion and vector store status for a given Zotero item key."""
    try:
        syncer = get_syncer()

        attachments_info = []
        item = syncer.db.get_item_by_key(item_key)
        resolved_title = item.title if item and item.title else item_key
        resolved_parent_title = item.title if item and item.title else ""

        if item:
            atts = syncer.db.get_attachments(item.item_id)
            for att in atts:
                ak = att.item_key
                c_cached = syncer.processor.is_cached(ak)
                c_indexed = syncer.store.is_ingested(ak)
                c_meta = syncer.processor.get_metadata(ak)
                c_failures = syncer.processor.get_failures(ak)
                if c_meta:
                    c_meta["markdown_path"] = str(syncer.processor.cache_dir / ak / "document.md")
                doc_title = resolved_title or (att.path.name if att.path else ak)
                attachments_info.append({
                    "item_key": ak,
                    "parent_item_key": item_key,
                    "title": doc_title,
                    "parent_title": resolved_parent_title,
                    "filename": att.path.name if att.path else ak,
                    "content_type": att.content_type,
                    "is_cached": c_cached,
                    "is_indexed": c_indexed,
                    "metadata": c_meta,
                    "failed_converters": c_failures,
                })

        if not attachments_info:
            att = syncer.db.get_attachment_by_key(item_key)
            if att:
                ak = att.item_key
                c_cached = syncer.processor.is_cached(ak)
                c_indexed = syncer.store.is_ingested(ak)
                c_meta = syncer.processor.get_metadata(ak)
                c_failures = syncer.processor.get_failures(ak)
                if c_meta:
                    c_meta["markdown_path"] = str(syncer.processor.cache_dir / ak / "document.md")

                p_item = None
                if att.parent_item_key:
                    p_item = syncer.db.get_item_by_key(att.parent_item_key)
                elif att.parent_item_id:
                    p_item = syncer.db.get_item(att.parent_item_id)

                p_title = p_item.title if p_item and p_item.title else ""
                doc_title = p_title or (att.path.name if att.path else ak)
                resolved_title = doc_title
                resolved_parent_title = p_title

                attachments_info.append({
                    "item_key": ak,
                    "parent_item_key": att.parent_item_key or (p_item.item_key if p_item else ""),
                    "title": doc_title,
                    "parent_title": p_title,
                    "filename": att.path.name if att.path else ak,
                    "content_type": att.content_type,
                    "is_cached": c_cached,
                    "is_indexed": c_indexed,
                    "metadata": c_meta,
                    "failed_converters": c_failures,
                })

        primary = attachments_info[0] if attachments_info else {}
        is_cached = any(a["is_cached"] for a in attachments_info) if attachments_info else False
        is_indexed = any(a["is_indexed"] for a in attachments_info) if attachments_info else False
        meta = primary.get("metadata")
        failed_converters = primary.get("failed_converters") if primary else syncer.processor.get_failures(item_key)

    except RuntimeError:
        is_cached = False
        is_indexed = False
        meta = None
        failed_converters = {}
        attachments_info = []
        resolved_title = item_key
        resolved_parent_title = ""

    return {
        "status": "success",
        "item_key": item_key,
        "title": resolved_title,
        "parent_title": resolved_parent_title,
        "is_cached": is_cached,
        "is_indexed": is_indexed,
        "metadata": meta,
        "failed_converters": failed_converters or {},
        "attachments": attachments_info,
    }

get_library_conversion_status

get_library_conversion_status()

Get aggregate conversion status and list of unconverted documents across the entire library.

Source code in zocrates/fast_api_app.py
@app.get("/library/conversion-status", dependencies=[Depends(verify_auth)])
def get_library_conversion_status() -> dict:
    """Get aggregate conversion status and list of unconverted documents across the entire library."""
    try:
        syncer = get_syncer()
    except RuntimeError as exc:
        raise HTTPException(
            status_code=400,
            detail="Zocrates backend is not registered. Call /register first.",
        ) from exc

    raw_attachments = syncer.db.get_all_convertible_attachments()
    meta_map = syncer.processor.get_all_metadata()

    # Deduplicate: if an item has multiple attachments, prioritize PDF attachment
    grouped_by_parent: dict[str, list[dict[str, Any]]] = {}
    standalone: list[dict[str, Any]] = []

    for att in raw_attachments:
        if att["parent_key"]:
            grouped_by_parent.setdefault(att["parent_key"], []).append(att)
        else:
            standalone.append(att)

    primary_attachments: list[dict[str, Any]] = []
    for _p_key, att_list in grouped_by_parent.items():
        pdf_atts = [
            a
            for a in att_list
            if a["content_type"] == "application/pdf"
               or a["path"].suffix.lower() == ".pdf"
        ]
        primary = pdf_atts[0] if pdf_atts else att_list[0]
        primary_attachments.append(primary)

    primary_attachments.extend(standalone)

    total_documents = len(primary_attachments)
    ingested_keys = syncer.store.get_all_ingested_item_keys()

    indexed_count = 0
    pending_index_count = 0
    unconverted_count = 0
    converted_count = 0
    converter_counts = {
        "Datalab (Cloud)": 0,
        "Docling (Local)": 0,
        "MarkItDown (Local)": 0,
        "Other": 0,
    }
    unconverted_items: list[dict[str, Any]] = []
    pending_index_items: list[dict[str, Any]] = []

    for att in primary_attachments:
        ak = att["attachment_key"]
        meta = meta_map.get(ak)
        title = att["parent_title"] or att["filename"] or ak
        is_indexed = ak in ingested_keys

        if meta:
            converted_count += 1
            cid = meta.get("converter_id", "")
            if cid == "datalab":
                converter_counts["Datalab (Cloud)"] += 1
            elif cid in ("docling_local", "docling"):
                converter_counts["Docling (Local)"] += 1
            elif cid == "markitdown":
                converter_counts["MarkItDown (Local)"] += 1
            else:
                converter_counts["Other"] += 1

            if is_indexed:
                indexed_count += 1
            else:
                pending_index_count += 1
                pending_index_items.append({
                    "key": ak,
                    "parent_key": att["parent_key"],
                    "title": title,
                    "filename": att["filename"],
                    "content_type": att["content_type"],
                    "converter_id": meta.get("converter_id"),
                })
        else:
            unconverted_count += 1
            unconverted_items.append({
                "key": ak,
                "parent_key": att["parent_key"],
                "title": title,
                "filename": att["filename"],
                "content_type": att["content_type"],
            })

    return {
        "status": "success",
        "total_documents": total_documents,
        "indexed_count": indexed_count,
        "pending_index_count": pending_index_count,
        "unconverted_count": unconverted_count,
        "converted_count": converted_count,
        "converter_counts": converter_counts,
        "unconverted_items": unconverted_items,
        "pending_index_items": pending_index_items,
    }

get_log_dir

get_log_dir(zotero_data_dir)

Return (and create if needed) the Zocrates log directory.

Parameters:

Name Type Description Default
zotero_data_dir Path

Root Zotero data directory.

required

Returns:

Type Description
Path

Path to <zocrates_data>/logs/, created if absent.

Source code in zocrates/config.py
def get_log_dir(zotero_data_dir: Path) -> Path:
    """Return (and create if needed) the Zocrates log directory.

    Args:
        zotero_data_dir: Root Zotero data directory.

    Returns:
        Path to ``<zocrates_data>/logs/``, created if absent.
    """
    log_dir = get_zocrates_data_dir(zotero_data_dir) / "logs"
    log_dir.mkdir(parents=True, exist_ok=True)
    return log_dir
get_related_items_core(item_key, ceiling=25)

Retrieve verified related papers for a given document from the knowledge graph.

Parameters:

Name Type Description Default
item_key str

Unique Zotero item or attachment key.

required
ceiling int

Maximum number of related items to return (default: 25).

25

Returns:

Type Description
dict[str, Any]

Dictionary with status, item_key, total_count, and list of related items:

dict[str, Any]

{ "status": "success", "item_key": resolved_key, "total_count": len(related_items), "related_items": [ { "item_key": t_key, "title": title, "relation_type": rel_type, "score": score, "parent_item_key": parent_key, }, ... ],

dict[str, Any]

}

Source code in zocrates/agents/common/toolkit.py
def get_related_items_core(
    item_key: str,
    ceiling: int = 25,
) -> dict[str, Any]:
    """Retrieve verified related papers for a given document from the knowledge graph.

    Args:
        item_key: Unique Zotero item or attachment key.
        ceiling: Maximum number of related items to return (default: 25).

    Returns:
        Dictionary with status, item_key, total_count, and list of related items:
        {
            "status": "success",
            "item_key": resolved_key,
            "total_count": len(related_items),
            "related_items": [
                {
                    "item_key": t_key,
                    "title": title,
                    "relation_type": rel_type,
                    "score": score,
                    "parent_item_key": parent_key,
                }, ...
            ],
            # Optional: "notice": disambiguation notice if res_notice present
        }
    """
    if not _validate_item_key(item_key):
        return {"status": "error", "message": f"Invalid item key: {item_key}"}

    try:
        resolved = _resolve_bundle_dir(item_key)
        if not resolved:
            return {
                "status": "error",
                "message": f"Document bundle for {item_key} not found or not yet converted.",
            }

        bundle_dir, resolved_key, res_notice = resolved

        cache_dir = bundle_dir.parent if bundle_dir else None
        if not cache_dir:
            try:
                syncer = get_syncer()
                if syncer and getattr(syncer, "processor", None):
                    cache_dir = getattr(syncer.processor, "_cache_dir", None)
            except Exception:
                pass

        related_items: list[dict[str, Any]] = []

        if cache_dir:
            meta_db_path = cache_dir / "conversion_meta.db"
            if meta_db_path.exists():
                try:
                    with contextlib.closing(sqlite3.connect(str(meta_db_path), timeout=5.0)) as conn:
                        conn.row_factory = sqlite3.Row
                        conn.execute("PRAGMA busy_timeout = 5000;")
                        try:
                            rows = conn.execute(
                                """
                                SELECT target_key, score, relation_type
                                FROM item_relations
                                WHERE source_key = ?
                                ORDER BY score DESC, target_key ASC
                                """,
                                (resolved_key,),
                            ).fetchall()
                        except sqlite3.OperationalError:
                            rows = []

                        # Coalesce multiple relation types for the same target_key using strict precedence:
                        # human_curated_and_hybrid_search (1.0) > human_curated (1.0) > hybrid_search > shared_tags (0.70)
                        best_by_target: dict[str, dict[str, Any]] = {}
                        for r in rows:
                            t_key = r["target_key"]
                            if not t_key:
                                continue
                            rel_type = str(r["relation_type"])
                            score = float(r["score"])
                            rank = (RELATION_TYPE_PRECEDENCE.get(rel_type, 0), score)

                            if t_key not in best_by_target:
                                best_by_target[t_key] = {
                                    "target_key": t_key,
                                    "score": score,
                                    "relation_type": rel_type,
                                }
                            else:
                                prev = best_by_target[t_key]
                                prev_rank = (
                                    RELATION_TYPE_PRECEDENCE.get(str(prev["relation_type"]), 0),
                                    float(prev["score"]),
                                )
                                if rank > prev_rank:
                                    best_by_target[t_key] = {
                                        "target_key": t_key,
                                        "score": score,
                                        "relation_type": rel_type,
                                    }

                        # Order by score DESC, target_key ASC with ceiling
                        sorted_targets = sorted(
                            best_by_target.values(),
                            key=lambda x: (-float(x["score"]), str(x["target_key"])),
                        )[:ceiling]

                        target_keys = [t["target_key"] for t in sorted_targets]
                        meta_by_key: dict[str, dict[str, Any]] = {}
                        if target_keys:
                            placeholders = ",".join("?" for _ in target_keys)
                            try:
                                meta_rows = conn.execute(
                                    f"SELECT item_key, title, parent_item_key FROM conversion_meta WHERE item_key IN ({placeholders})",
                                    tuple(target_keys),
                                ).fetchall()
                                for mr in meta_rows:
                                    meta_by_key[mr["item_key"]] = dict(mr)
                            except sqlite3.OperationalError:
                                pass

                        for t in sorted_targets:
                            t_key = t["target_key"]
                            mr = meta_by_key.get(t_key)
                            raw_title = mr.get("title") if mr else None
                            title = raw_title.strip() if raw_title and str(raw_title).strip() else t_key
                            raw_pk = mr.get("parent_item_key") if mr else None
                            parent_key = raw_pk.strip() if raw_pk and str(raw_pk).strip() else None

                            related_items.append(
                                {
                                    "item_key": t_key,
                                    "title": title,
                                    "relation_type": t["relation_type"],
                                    "score": round(float(t["score"]), 4),
                                    "parent_item_key": parent_key,
                                }
                            )
                except Exception as exc:
                    logger.error("Failed to query relations for %s: %s", resolved_key, exc)
                    return {"status": "error", "message": f"Database error querying relations: {exc}"}

        res: dict[str, Any] = {
            "status": "success",
            "item_key": resolved_key,
            "total_count": len(related_items),
            "related_items": related_items,
        }

        if res_notice:
            res["notice"] = res_notice

        return res
    except Exception as exc:
        logger.error("Failed in get_related_items_core for %s: %s", item_key, exc)
        return {"status": "error", "message": str(exc)}

get_sessions_storage_stats_endpoint async

get_sessions_storage_stats_endpoint()

Return physical database size and session count for sessions.db.

Source code in zocrates/fast_api_app.py
@app.get("/sessions/storage_stats", dependencies=[Depends(verify_auth)])
async def get_sessions_storage_stats_endpoint() -> dict:
    """Return physical database size and session count for sessions.db."""
    zotero_dir = getattr(app.state, "zotero_data_dir", None)
    if not zotero_dir:
        from zocrates.config import resolve_zotero_data_dir
        zotero_dir = resolve_zotero_data_dir()
    session_service = services.get_session_service(zotero_dir)
    app_name = getattr(app.state, "agent_app_name", "zocrates")
    db_path = None
    if zotero_dir:
        db_path = Path(zotero_dir).expanduser().resolve() / "zocrates-data" / "sessions.db"
    elif hasattr(session_service, "db_url"):
        url = str(session_service.db_url)
        prefix = "sqlite+aiosqlite:///"
        if url.startswith(prefix):
            db_path = Path(url[len(prefix):])

    if not db_path:
        return {
            "status": "success",
            "size_bytes": 0,
            "session_count": 0,
            "freelist_pages": 0,
            "total_pages": 0,
            "page_size": 4096,
        }

    from zocrates.utils.session_maintenance import get_session_storage_stats
    stats = await get_session_storage_stats(
        db_path=db_path,
        session_service=session_service,
        app_name=app_name,
        user_id="default_user",
    )
    return {
        "status": "success",
        "size_bytes": stats.get("size_bytes", 0),
        "session_count": stats.get("session_count", 0),
        **stats,
    }

get_staging_root

get_staging_root()

Return the root directory for temporary discovery batch staging.

Prefers /staging/ (which is within user home, accessible to sandboxed Flatpak Zotero instances having filesystem=home permissions), falling back to ~/.zocrates/staging, and finally system temp.

Source code in zocrates/discovery/staging.py
def get_staging_root() -> Path:
    """Return the root directory for temporary discovery batch staging.

    Prefers <zocrates-data>/staging/ (which is within user home, accessible to sandboxed
    Flatpak Zotero instances having filesystem=home permissions), falling back to
    ~/.zocrates/staging, and finally system temp.
    """
    try:
        from zocrates.config import get_zocrates_data_dir

        root = get_zocrates_data_dir() / "staging"
        root.mkdir(parents=True, exist_ok=True)
        return root
    except Exception:
        pass

    try:
        root = Path.home() / ".zocrates" / "staging"
        root.mkdir(parents=True, exist_ok=True)
        return root
    except Exception:
        pass

    root = Path(tempfile.gettempdir()) / "zocrates_staging"
    root.mkdir(parents=True, exist_ok=True)
    return root

get_syncer

get_syncer()

Get the active LibrarySyncer instance. Raises RuntimeError if not registered.

Source code in zocrates/rag/service_manager.py
def get_syncer() -> "LibrarySyncer":
    """Get the active LibrarySyncer instance. Raises RuntimeError if not registered."""
    if _syncer is None:
        raise RuntimeError(
            "Zocrates backend services have not been registered/initialized yet. "
            "Please register your Zotero data directory first."
        )
    return _syncer

get_user_session async

get_user_session(session_id)

Retrieve message history for a specific chat session.

Parameters:

Name Type Description Default
session_id str

The session ID to retrieve.

required

Returns:

Type Description
dict

Dict with keys status and session (formatted by

dict

format_session_for_ui).

Raises:

Type Description
HTTPException

404 if session_id does not exist; 500 on error.

Source code in zocrates/fast_api_app.py
@app.get("/sessions/{session_id}", dependencies=[Depends(verify_auth)])
async def get_user_session(session_id: str) -> dict:
    """Retrieve message history for a specific chat session.

    Args:
        session_id: The session ID to retrieve.

    Returns:
        Dict with keys ``status`` and ``session`` (formatted by
        ``format_session_for_ui``).

    Raises:
        HTTPException: 404 if session_id does not exist; 500 on error.
    """
    session_service = services.get_session_service()
    app_name = getattr(app.state, "agent_app_name", "zocrates")
    try:
        sess = await session_service.get_session(
            app_name=app_name, user_id="default_user", session_id=session_id
        )
        if not sess:
            raise HTTPException(status_code=404, detail=f"Session {session_id} not found.")
        return {"status": "success", "session": format_session_for_ui(sess)}
    except HTTPException:
        raise
    except Exception as exc:
        logger.error("Failed to get session %s: %s", session_id, exc)
        raise HTTPException(status_code=500, detail=f"Failed to get session: {exc}") from exc

health async

health(request)

Fast zero-I/O heartbeat endpoint for sidebar liveness and model hot-swapping.

Source code in zocrates/fast_api_app.py
@app.get("/health")
async def health(request: Request) -> dict:
    """Fast zero-I/O heartbeat endpoint for sidebar liveness and model hot-swapping."""
    try:
        syncer = get_syncer()
        zotero_dir: str | None = str(syncer._zotero_data_dir)
        registered = True
    except RuntimeError:
        syncer = None
        zotero_dir = _status_zotero_data_dir_hint()
        registered = False

    if not _is_request_authenticated(request):
        return {
            "status": "ready" if registered else "not_registered",
            "zotero_data_dir": zotero_dir,
        }

    target_dir = syncer._zotero_data_dir if registered and syncer is not None else zotero_dir
    _maybe_hot_swap_agent_models(target_dir, "health check")

    from zocrates.config import load_zocrates_config

    cfg = load_zocrates_config(target_dir)
    return {
        "status": "ready" if registered else "not_registered",
        "zotero_data_dir": str(syncer._zotero_data_dir) if registered and syncer is not None else zotero_dir,
        "model": cfg.get("llm_model") or DEFAULT_LLM_MODEL,
        "llm_provider": cfg.get("llm_provider") or "openrouter_llm",
    }

initialize_services

initialize_services(zotero_data_dir)

Initialize the LibrarySyncer and database connections.

Thread-safe: uses a lock to prevent concurrent initialization. Tears down the previous instance before creating a new one.

Source code in zocrates/rag/service_manager.py
def initialize_services(zotero_data_dir: Path) -> "LibrarySyncer":
    """Initialize the LibrarySyncer and database connections.

    Thread-safe: uses a lock to prevent concurrent initialization.
    Tears down the previous instance before creating a new one.
    """
    global _syncer
    with _init_lock:
        if _syncer is not None:
            logger.info("Tearing down previous LibrarySyncer instance.")
            try:
                _syncer.close()
            except Exception as exc:
                logger.warning("Error closing previous syncer: %s", exc)

        sqlite_path = zotero_data_dir / "zotero.sqlite"
        if not sqlite_path.exists():
            raise FileNotFoundError(f"zotero.sqlite not found at {sqlite_path}")

        logger.info(
            "Initializing Zocrates services with data directory: %s",
            zotero_data_dir,
        )
        from zocrates.utils.services import get_session_service
        get_session_service(zotero_data_dir)

        from zocrates.rag.library_syncer import LibrarySyncer

        _syncer = LibrarySyncer(zotero_data_dir=zotero_data_dir, sqlite_path=sqlite_path)

        # Ensure default zocrates_config.json is persisted if not present
        import copy
        from zocrates.config import (
            DEFAULT_OVERRIDE_AGENT_MODELS,
            DEFAULT_SEARCH_HARNESS,
            get_config_path,
            save_zocrates_config,
        )
        config_path = get_config_path(zotero_data_dir)
        if not config_path.exists():
            save_zocrates_config(
                {
                    "document_converter_id": "markitdown",
                    "document_converter_mode": "local",
                    "embedding_provider": "local",
                    "embedding_model": "jinaai/jina-embeddings-v5-text-nano",
                    "force_cpu": False,
                    "override_agent_models": copy.deepcopy(
                        DEFAULT_OVERRIDE_AGENT_MODELS
                    ),
                    "search_harness": copy.deepcopy(DEFAULT_SEARCH_HARNESS),
                },
                zotero_data_dir,
            )

        return _syncer

is_mcp_server_enabled

is_mcp_server_enabled(zotero_data_dir=None, cfg=None)

Return whether local MCP server access is enabled from zocrates_config.json.

Source code in zocrates/config.py
def is_mcp_server_enabled(
    zotero_data_dir: Path | None = None,
    cfg: dict[str, Any] | None = None,
) -> bool:
    """Return whether local MCP server access is enabled from zocrates_config.json."""
    if cfg is None:
        cfg = load_zocrates_config(zotero_data_dir)
    return bool(cfg.get("enable_mcp_server", DEFAULT_ENABLE_MCP_SERVER))

lifespan async

lifespan(app)

Manage the FastAPI application lifespan: initialize resources on startup and clean up on shutdown.

On startup: imports the ADK app and root agent, auto-initializes services if ZOTERO_DATA_DIR is set and valid, spawns the parent watchdog if ZOTERO_PID is set, constructs the Runner (binding it to the registered session and artifact services), and stores it on app.state for use by the /chat endpoint.

On shutdown: closes the active LibrarySyncer to release Chroma and database connections.

Parameters:

Name Type Description Default
app FastAPI

The FastAPI application instance whose state is mutated.

required

Yields:

Type Description
AsyncIterator[None]

Control to the application while it is running.

Source code in zocrates/fast_api_app.py
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    """Manage the FastAPI application lifespan: initialize resources on startup and clean up on shutdown.

    On startup: imports the ADK app and root agent, auto-initializes services if
    ``ZOTERO_DATA_DIR`` is set and valid, spawns the parent watchdog if
    ``ZOTERO_PID`` is set, constructs the ``Runner`` (binding it to the registered
    session and artifact services), and stores it on ``app.state`` for use by
    the ``/chat`` endpoint.

    On shutdown: closes the active ``LibrarySyncer`` to release Chroma and
    database connections.

    Args:
        app: The FastAPI application instance whose ``state`` is mutated.

    Yields:
        Control to the application while it is running.
    """
    from zocrates.agents.app import app as adk_app

    # Auto-initialize services if ZOTERO_DATA_DIR is provided
    zotero_dir_env = os.getenv("ZOTERO_DATA_DIR")
    if zotero_dir_env:
        data_path = Path(zotero_dir_env).expanduser().resolve()
        sqlite_path = data_path / "zotero.sqlite"
        storage_path = data_path / "storage"
        if data_path.exists() and sqlite_path.exists() and storage_path.is_dir():
            try:
                syncer = initialize_services(data_path)
                setup_file_logging(get_log_dir(data_path))
                app.state.zotero_data_dir = str(data_path)
                get_auth_token(data_path)
                logger.info("Auto-initialized services from ZOTERO_DATA_DIR: %s", data_path)

                # Run synchronous startup maintenance before runner traffic begins (fail-open)
                try:
                    from zocrates.utils.session_maintenance import run_session_database_maintenance
                    from zocrates.config import get_session_maintenance_config

                    session_service = services.get_session_service()
                    maintenance_res = await run_session_database_maintenance(
                        zotero_data_dir=data_path,
                        session_service=session_service,
                        config=get_session_maintenance_config(data_path),
                        active_session_ids=getattr(app.state, "active_chat_session_ids", None),
                        app_name=adk_app.name,
                    )
                    logger.info("Startup session database maintenance complete: %s", maintenance_res)
                except Exception as maint_err:
                    logger.warning("Startup session maintenance encountered non-fatal error: %s", maint_err)

                # Launch non-blocking background metadata staleness sweep
                sweep_task = asyncio.create_task(asyncio.to_thread(syncer.sync_metadata_staleness))
                app.state.metadata_sweep_task = sweep_task

                def _log_sweep_result(t: asyncio.Task) -> None:
                    """Log completion or failure of startup metadata staleness sweep.

                    Args:
                        t: The completed asyncio Task instance.
                    """
                    try:
                        res = t.result()
                        logger.info("Startup metadata staleness sweep complete: %s", res)
                    except Exception as sweep_err:
                        logger.warning("Startup metadata staleness sweep encountered error: %s", sweep_err)

                sweep_task.add_done_callback(_log_sweep_result)
            except Exception as exc:
                logger.warning("Failed to auto-initialize services from ZOTERO_DATA_DIR %s: %s", data_path, exc)

    runner = Runner(
        app=adk_app,
        session_service=services.get_session_service(),
        artifact_service=services.get_artifact_service(),
        auto_create_session=True,
    )
    app.state.runner = runner
    app.state.agent_app_name = adk_app.name
    app.state.active_chat_session_ids = set()

    # Start parent watchdog if ZOTERO_PID is present and > 0
    zotero_pid_env = os.getenv("ZOTERO_PID")
    if zotero_pid_env and zotero_pid_env.strip().isdigit():
        parent_pid = int(zotero_pid_env.strip())
        if parent_pid > 0:
            watchdog_thread = threading.Thread(
                target=_parent_watchdog,
                args=(parent_pid,),
                daemon=True,
                name="ZoteroParentWatchdog",
            )
            watchdog_thread.start()
            app.state.parent_watchdog_thread = watchdog_thread
            logger.info("Spawned parent watchdog thread monitoring ZOTERO_PID %d", parent_pid)

    # Auto-start local Laya daemon if configured
    try:
        from zocrates.config import load_zocrates_config
        cfg = load_zocrates_config()
        if (
            cfg.get("citation_verification_enabled")
            and cfg.get("citation_verification_provider") == "local_laya"
            and cfg.get("citation_verification_auto_start_local", True)
        ):
            from zocrates.rag.decision_service import is_laya_installed, start_laya_daemon
            if is_laya_installed():
                port = int(cfg.get("citation_verification_local_port", 8005))
                await asyncio.to_thread(start_laya_daemon, port=port)
    except Exception as exc:
        logger.warning("Could not auto-start Laya daemon on startup: %s", exc)

    # Clean up stale discovery staging directories (> 2 hours old)
    try:
        removed = cleanup_stale_staging_directories()
        if removed > 0:
            logger.info("Cleaned up %d stale discovery staging directories on startup.", removed)
    except Exception as exc:
        logger.warning("Could not sweep stale discovery staging directories: %s", exc)

    yield

    # Shutdown cleanup
    try:
        from zocrates.config import load_zocrates_config
        from zocrates.rag.decision_service import stop_laya_daemon
        cfg = load_zocrates_config()
        port = int(cfg.get("citation_verification_local_port", 8005))
        await asyncio.to_thread(stop_laya_daemon, port=port)
    except Exception as exc:
        logger.warning("Error stopping Laya daemon during shutdown: %s", exc)

    try:
        from zocrates.rag.service_manager import get_syncer

        syncer = get_syncer()
        syncer.close()
        logger.info("Zocrates backend shutdown complete.")
    except RuntimeError:
        pass  # Not initialized, nothing to clean up
    except Exception as exc:
        logger.warning("Error during shutdown cleanup: %s", exc)

    _safe_cleanup_snapshots()

list_collections async

list_collections()

List all versioned vector collections, their dimensions, and chunk counts.

Raises:

Type Description
HTTPException

400 if not registered; 500 on unexpected error.

Returns:

Type Description
dict

Dict with keys status, active_collection, and collections

dict

(list of collection metadata dicts).

Source code in zocrates/fast_api_app.py
@app.get("/rag/collections", dependencies=[Depends(verify_auth)])
async def list_collections() -> dict:
    """List all versioned vector collections, their dimensions, and chunk counts.

    Raises:
        HTTPException: 400 if not registered; 500 on unexpected error.

    Returns:
        Dict with keys ``status``, ``active_collection``, and ``collections``
        (list of collection metadata dicts).
    """
    try:
        syncer = get_syncer()
        collections = syncer.store.list_collections()
        return {
            "status": "success",
            "active_collection": syncer.store.active_collection_name,
            "collections": collections,
        }
    except RuntimeError as e:
        raise HTTPException(status_code=400, detail=str(e)) from e
    except Exception as e:
        raise HTTPException(
            status_code=500, detail=f"Failed to list collections: {e}"
        ) from e

list_converters async

list_converters()

List all registered document converter strategies and their active status.

Source code in zocrates/fast_api_app.py
@app.get("/converters", dependencies=[Depends(verify_auth)])
async def list_converters() -> dict:
    """List all registered document converter strategies and their active status."""
    from zocrates.config import (
        get_datalab_api_key,
        get_document_converter_id,
        get_document_converter_mode,
        load_zocrates_config,
    )
    from zocrates.rag.document_converters import (
        DatalabConverter,
        DirectReadConverter,
        DoclingLocalConverter,
        MarkItDownConverter,
    )

    try:
        syncer = get_syncer()
        converters_list = syncer.processor._converters
        active_id = (
                syncer.processor._document_converter_id
                or get_document_converter_id(syncer._zotero_data_dir)
                or "markitdown"
        )
        converter_mode = get_document_converter_mode(syncer._zotero_data_dir)
        datalab_key_present = bool(get_datalab_api_key(syncer._zotero_data_dir))
    except RuntimeError:
        converters_list = [
            DirectReadConverter(),
            DoclingLocalConverter(),
            MarkItDownConverter(),
            DatalabConverter(),
        ]
        cfg = load_zocrates_config()
        active_id = cfg.get("document_converter_id", "markitdown")
        converter_mode = cfg.get("document_converter_mode", "local")
        datalab_key_present = bool(get_datalab_api_key())

    converters_info = [
        {
            "id": c.converter_id,
            "version": c.converter_version,
            "is_remote": getattr(c, "is_remote", False),
            "has_api_key": datalab_key_present if c.converter_id == "datalab" else True,
            "is_active": (
                    c.converter_id == active_id
                    or (c.converter_id == "docling_local" and active_id == "docling")
            ),
        }
        for c in converters_list
    ]

    return {
        "status": "success",
        "document_converter_id": active_id,
        "document_converter_mode": converter_mode,
        "has_datalab_key": datalab_key_present,
        "converters": converters_info,
    }

list_openrouter_models async

list_openrouter_models(target_type='llm', refresh=False, free_only=False, modality=None, min_context=0)

Return filtered OpenRouter models catalog (LLMs, Cloud Embeddings, or Decision Models).

Source code in zocrates/fast_api_app.py
@app.get("/llm/openrouter_models", dependencies=[Depends(verify_auth)])
async def list_openrouter_models(
        target_type: str = "llm",
        refresh: bool = False,
        free_only: bool = False,
        modality: str | None = None,
        min_context: int = 0,
) -> dict:
    """Return filtered OpenRouter models catalog (LLMs, Cloud Embeddings, or Decision Models)."""
    from zocrates.llm import get_openrouter_models
    from zocrates.config import get_secure_api_key

    has_key = bool(get_secure_api_key("openrouter_api_key", ("OPENROUTER_API_KEY", "OPEN_ROUTER_API_KEY")))
    if not has_key:
        return {"status": "no_key", "models": []}

    try:
        models = await get_openrouter_models(
            free_only=free_only,
            modality=modality,
            min_context=min_context,
            target_type=target_type,
            force_refresh=refresh,
        )
        return {"status": "success", "models": models}
    except Exception as e:
        logger.error("Failed to list OpenRouter models: %s", e)
        raise HTTPException(status_code=500, detail=f"Failed to fetch OpenRouter models: {e}") from e

list_user_sessions async

list_user_sessions()

List all chat session manifests for the default user.

Returns:

Type Description
dict

Dict with keys status and sessions (list of session summary

dict

dicts with id, title, created_at, and updated_at).

Raises:

Type Description
HTTPException

500 on unexpected session service error.

Source code in zocrates/fast_api_app.py
@app.get("/sessions", dependencies=[Depends(verify_auth)])
async def list_user_sessions() -> dict:
    """List all chat session manifests for the default user.

    Returns:
        Dict with keys ``status`` and ``sessions`` (list of session summary
        dicts with ``id``, ``title``, ``created_at``, and ``updated_at``).

    Raises:
        HTTPException: 500 on unexpected session service error.
    """
    zotero_dir = getattr(app.state, "zotero_data_dir", None)
    session_service = services.get_session_service(zotero_dir)
    app_name = getattr(app.state, "agent_app_name", "zocrates")
    try:
        session_list = await session_service.list_sessions(
            app_name=app_name, user_id="default_user"
        )

        sessions_list_obj = getattr(session_list, "sessions", []) or []
        missing_ids = [
            sess.id
            for sess in sessions_list_obj
            if not sess.state or not sess.state.get("session_title") or sess.state.get("session_title") == "New Chat"
        ]

        # Fallback: Query first user message title for sessions without an explicit title
        first_msgs = {}
        if missing_ids:
            try:
                from zocrates.config import get_zocrates_data_dir
                db_path = get_zocrates_data_dir(zotero_dir) / "sessions.db"
                if db_path.exists():
                    import sqlite3
                    chunk_size = 500
                    with contextlib.closing(
                        sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=5.0)
                    ) as conn:
                        cursor = conn.cursor()
                        for i in range(0, len(missing_ids), chunk_size):
                            chunk = missing_ids[i : i + chunk_size]
                            placeholders = ",".join("?" for _ in chunk)
                            cursor.execute(
                                f"""
                                SELECT session_id, event_data
                                FROM events
                                WHERE session_id IN ({placeholders}) AND app_name = ?
                                ORDER BY timestamp ASC
                                """,
                                (*chunk, app_name),
                            )
                            for sid, edata in cursor.fetchall():
                                if sid in first_msgs:
                                    continue
                                try:
                                    d = json.loads(edata)
                                    if d.get("author") != "user" and d.get("role") != "user":
                                        continue
                                    parts = d.get("content", {}).get("parts", [])
                                    txt = ""
                                    for p in parts:
                                        if p.get("text"):
                                            txt = p["text"].strip()
                                            break
                                    if txt:
                                        first_msgs[sid] = txt[:30] + ("..." if len(txt) > 30 else "")
                                except Exception:
                                    pass
            except Exception as e:
                logger.debug("Could not fetch first messages for session titles: %s", e)

        sessions_data = []
        for sess in sessions_list_obj:
            title = sess.state.get("session_title") if sess.state else None
            if not title or title == "New Chat":
                title = first_msgs.get(sess.id) or "New Chat"
            sessions_data.append({
                "id": sess.id,
                "title": title,
                "created_at": getattr(sess, "created_at", None),
                "updated_at": getattr(sess, "last_update_time", None) or getattr(sess, "updated_at", None),
            })
        return {"status": "success", "sessions": sessions_data}
    except Exception as exc:
        logger.error("Failed to list sessions: %s", exc)
        raise HTTPException(status_code=500, detail=f"Failed to list sessions: {exc}") from exc

log_requests async

log_requests(request, call_next)

Log all incoming HTTP requests and outgoing responses.

Source code in zocrates/fast_api_app.py
@app.middleware("http")
async def log_requests(
    request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
    """Log all incoming HTTP requests and outgoing responses."""
    is_health_probe = request.method == "GET" and request.url.path == "/health"
    start_time = time.perf_counter()
    url_path = request.url.path
    if request.url.query:
        safe_query = re.sub(r"(^|[?&])(token=)[^&]+", r"\1\2[REDACTED]", request.url.query, flags=re.IGNORECASE)
        url_path += f"?{safe_query}"

    if not is_health_probe:
        logger.info("--> %s %s", request.method, url_path)
    try:
        response = await call_next(request)
        if not is_health_probe:
            process_time = (time.perf_counter() - start_time) * 1000.0
            logger.info(
                "<-- %s %s [%d] (%.2fms)",
                request.method,
                url_path,
                response.status_code,
                process_time,
            )
        return response
    except Exception as exc:
        process_time = (time.perf_counter() - start_time) * 1000.0
        logger.error(
            "<-- %s %s [EXCEPTION: %s] (%.2fms)",
            request.method,
            url_path,
            exc,
            process_time,
        )
        raise

mcp_get_catalog

mcp_get_catalog()

MCP Catalog endpoint: returns root-level knowledge base catalog index.md.

Synchronous def dispatches to worker thread pool.

Source code in zocrates/fast_api_app.py
@app.get("/mcp/catalog", dependencies=[Depends(verify_auth)])
def mcp_get_catalog() -> dict[str, Any]:
    """MCP Catalog endpoint: returns root-level knowledge base catalog index.md.

    Synchronous def dispatches to worker thread pool.
    """
    syncer = _verify_mcp_access()
    cache_dir = Path(syncer.processor._cache_dir)
    index_file = cache_dir / "index.md"
    if not index_file.exists():
        generate_root_index_md(cache_dir)
    if not index_file.exists():
        index_file.write_text("# Zocrates Knowledge Base Catalog\n\nTotal Converted Documents: 0\n", encoding="utf-8")

    return {
        "status": "success",
        "content": index_file.read_text(encoding="utf-8"),
    }

mcp_get_relations

mcp_get_relations(item_key, ceiling=25)

MCP Relations endpoint: retrieves verified related papers from the knowledge graph.

Clamps ceiling between 1 and 50. Synchronous def dispatches to worker thread pool.

Source code in zocrates/fast_api_app.py
@app.get("/mcp/documents/{item_key}/relations", dependencies=[Depends(verify_auth)])
def mcp_get_relations(
    item_key: str,
    ceiling: int = 25,
) -> dict[str, Any]:
    """MCP Relations endpoint: retrieves verified related papers from the knowledge graph.

    Clamps ceiling between 1 and 50. Synchronous def dispatches to worker thread pool.
    """
    _verify_mcp_access()

    if not _validate_item_key(item_key):
        raise HTTPException(status_code=400, detail=f"Invalid item key format: '{item_key}'")

    clamped_ceiling = min(max(1, ceiling), 50)
    res = get_related_items_core(item_key=item_key, ceiling=clamped_ceiling)
    if res.get("status") == "error":
        msg = res.get("message", "Failed to retrieve related items.")
        lower_msg = msg.lower()
        if "invalid" in lower_msg:
            raise HTTPException(status_code=400, detail=msg)
        if "not found" in lower_msg or "not yet converted" in lower_msg:
            raise HTTPException(status_code=404, detail=msg)
        raise HTTPException(status_code=500, detail=msg)

    return res

mcp_get_toc

mcp_get_toc(item_key)

MCP Table of Contents endpoint: retrieves structured sections, figures, tables, and abstract.

Synchronous def dispatches to worker thread pool.

Source code in zocrates/fast_api_app.py
@app.get("/mcp/documents/{item_key}/toc", dependencies=[Depends(verify_auth)])
def mcp_get_toc(item_key: str) -> dict[str, Any]:
    """MCP Table of Contents endpoint: retrieves structured sections, figures, tables, and abstract.

    Synchronous def dispatches to worker thread pool.
    """
    _verify_mcp_access()

    if not _validate_item_key(item_key):
        raise HTTPException(status_code=400, detail=f"Invalid item key format: '{item_key}'")

    res = get_document_toc_core(item_key=item_key)
    if res.get("status") == "error":
        msg = res.get("message", "Failed to retrieve table of contents.")
        lower_msg = msg.lower()
        if "invalid" in lower_msg:
            raise HTTPException(status_code=400, detail=msg)
        if "not found" in lower_msg or "not yet converted" in lower_msg:
            raise HTTPException(status_code=404, detail=msg)
        raise HTTPException(status_code=500, detail=msg)

    return res

mcp_read_document

mcp_read_document(item_key, request, req=None)

MCP Read Document endpoint: reads targeted sections, figures, tables, or abstract.

Augments asset entries with an absolute url pointing to /assets/{item_key}/{path}. Synchronous def dispatches to worker thread pool.

Source code in zocrates/fast_api_app.py
@app.post("/mcp/documents/{item_key}/read", dependencies=[Depends(verify_auth)])
def mcp_read_document(
    item_key: str,
    request: Request,
    req: MCPReadRequest | None = None,
) -> dict[str, Any]:
    """MCP Read Document endpoint: reads targeted sections, figures, tables, or abstract.

    Augments asset entries with an absolute `url` pointing to `/assets/{item_key}/{path}`.
    Synchronous def dispatches to worker thread pool.
    """
    _verify_mcp_access()

    if not _validate_item_key(item_key):
        raise HTTPException(status_code=400, detail=f"Invalid item key format: '{item_key}'")

    if req is None:
        req = MCPReadRequest()

    res = read_document_core(item_key=item_key, asset_id=req.asset_id, page=req.page)
    if res.get("status") == "error":
        msg = res.get("message", "")
        if not msg and res.get("unresolved_asset_ids"):
            msg = f"Asset IDs {res.get('unresolved_asset_ids')} not found in document {item_key}."
            raise HTTPException(status_code=404, detail=msg)
        if not msg:
            msg = res.get("notice") or "Failed to read document."
        lower_msg = msg.lower()
        if "invalid" in lower_msg:
            raise HTTPException(status_code=400, detail=msg)
        if "not found" in lower_msg or "not yet converted" in lower_msg or "no abstract" in lower_msg:
            raise HTTPException(status_code=404, detail=msg)
        raise HTTPException(status_code=500, detail=msg)

    base_url = str(request.base_url).rstrip("/")
    resolved_key = res.get("item_key", item_key)
    for asset in res.get("assets", []):
        if isinstance(asset, dict) and asset.get("path"):
            asset_path = str(asset["path"]).lstrip("/")
            asset["url"] = f"{base_url}/assets/{resolved_key}/{asset_path}"

    return res
mcp_search(request)

MCP Search endpoint: hybrid semantic + keyword search over indexed library chunks.

Clamps limit between 1 and 20. Synchronous def dispatches to worker thread pool.

Source code in zocrates/fast_api_app.py
@app.post("/mcp/search", dependencies=[Depends(verify_auth)])
def mcp_search(request: MCPSearchRequest) -> dict[str, Any]:
    """MCP Search endpoint: hybrid semantic + keyword search over indexed library chunks.

    Clamps limit between 1 and 20. Synchronous def dispatches to worker thread pool.
    """
    _verify_mcp_access()

    if isinstance(request.item_key, str):
        if (
            request.item_key.strip().lower() not in ("all", "*", "library", "entire_library")
            and not _validate_item_key(request.item_key)
        ):
            raise HTTPException(status_code=400, detail=f"Invalid item key format: '{request.item_key}'")
    elif isinstance(request.item_key, list):
        for k in request.item_key:
            if (
                isinstance(k, str)
                and k.strip().lower() not in ("all", "*", "library", "entire_library")
                and not _validate_item_key(k)
            ):
                raise HTTPException(status_code=400, detail=f"Invalid item key format: '{k}'")

    limit = min(max(1, request.limit), 20)
    res = search_corpus_core(
        query=request.query,
        item_key=request.item_key,
        chunk_type=request.chunk_type,
        limit=limit,
        exclude_chunk_ids=request.exclude_chunk_ids,
    )
    if res.get("status") == "error":
        msg = res.get("message", "Search failed.")
        if "invalid" in msg.lower():
            raise HTTPException(status_code=400, detail=msg)
        raise HTTPException(status_code=500, detail=msg)

    return res

patch_adk_active_task_scope_rewind

patch_adk_active_task_scope_rewind()

Patch ADK _find_active_task_scope to filter out rewound events before checking active scopes.

When an in-flight subagent pipeline (which stamps events with an isolation_scope, e.g. 'search_library_pipeline:xyz' or 'search_online_pipeline:xyz') is rewound via rewind_before_invocation_id, its unfinished isolation scope remains in session.events. ADK's native _find_active_task_scope walks session.events directly without taking rewinds into account, causing subsequent user turns to mistakenly be stamped with the dead subagent's isolation scope.

This patch ensures _find_active_task_scope evaluates active task scopes against the live events filtered by _apply_rewinds(session.events).

Source code in zocrates/agents/common/adk_compat.py
def patch_adk_active_task_scope_rewind() -> None:
    """Patch ADK _find_active_task_scope to filter out rewound events before checking active scopes.

    When an in-flight subagent pipeline (which stamps events with an isolation_scope,
    e.g. 'search_library_pipeline:xyz' or 'search_online_pipeline:xyz') is rewound via
    rewind_before_invocation_id, its unfinished isolation scope remains in session.events.
    ADK's native _find_active_task_scope walks session.events directly without taking rewinds
    into account, causing subsequent user turns to mistakenly be stamped with the dead
    subagent's isolation scope.

    This patch ensures _find_active_task_scope evaluates active task scopes against
    the live events filtered by _apply_rewinds(session.events).
    """
    global _adk_active_task_scope_rewind_patched
    if _adk_active_task_scope_rewind_patched:
        return

    try:
        import google.adk.runners as adk_runners
        from google.adk.flows.llm_flows.contents import _apply_rewinds

        orig_find_active_task_scope = adk_runners._find_active_task_scope

        class _RewoundSessionProxy:
            """Lightweight proxy exposing rewound events while preserving session attributes."""

            def __init__(self, session: Any, live_events: list[Any]) -> None:
                """Initialize session proxy wrapping live non-rewound events.

                Args:
                    session: The underlying ADK session instance.
                    live_events: Filtered list of active session events after applying rewinds.
                """
                self._session = session
                self.events = live_events

            def __getattr__(self, name: str) -> Any:
                """Delegate attribute lookups to the underlying session instance.

                Args:
                    name: Attribute name to resolve on the underlying session.

                Returns:
                    The attribute value from the underlying session.
                """
                return getattr(self._session, name)

        def patched_find_active_task_scope(session: Any) -> tuple[str, str] | None:
            """Find the active task scope by filtering out rewound events first.

            Args:
                session: The ADK session to inspect for an active task scope.

            Returns:
                A tuple of (scope_id, task_name) if an active scope exists, otherwise None.
            """
            if not session or getattr(session, "events", None) is None:
                return None
            live_events = _apply_rewinds(session.events)
            proxy = _RewoundSessionProxy(session, live_events)
            return orig_find_active_task_scope(proxy)

        adk_runners._find_active_task_scope = patched_find_active_task_scope
        _adk_active_task_scope_rewind_patched = True
        logger.debug(
            "Patched google.adk.runners._find_active_task_scope to respect _apply_rewinds"
        )
    except Exception as err:
        logger.warning("Could not patch ADK _find_active_task_scope: %s", err)

patch_adk_request_input_context_filter

patch_adk_request_input_context_filter()

Patch ADK contents filter to exclude adk_request_input function calls/responses from agent LLM context.

In dynamic workflows with Human-In-The-Loop (HITL), RequestInput emits an Event with a function call named 'adk_request_input' and the user resume response emits a function response named 'adk_request_input'. While ADK natively filters out internal framework events like 'adk_framework' and 'adk_request_credential' from being included in an agent's LLM context, it omits 'adk_request_input'. This causes ADK's _fencing module to wrap the checkpoint call and response in cross-agent fencing (<<>>) and inject them into search_subagent's prompt during extension passes.

This patch ensures 'adk_request_input' events are filtered out from LLM context generation, preserving clean continuation prompts for subagents.

Source code in zocrates/agents/common/adk_compat.py
def patch_adk_request_input_context_filter() -> None:
    """Patch ADK contents filter to exclude adk_request_input function calls/responses from agent LLM context.

    In dynamic workflows with Human-In-The-Loop (HITL), RequestInput emits an Event
    with a function call named 'adk_request_input' and the user resume response emits
    a function response named 'adk_request_input'. While ADK natively filters out
    internal framework events like 'adk_framework' and 'adk_request_credential' from
    being included in an agent's LLM context, it omits 'adk_request_input'. This causes
    ADK's _fencing module to wrap the checkpoint call and response in cross-agent
    fencing (<<<BEGIN_QUOTED_AGENT_CONTENT>>>) and inject them into search_subagent's
    prompt during extension passes.

    This patch ensures 'adk_request_input' events are filtered out from LLM context
    generation, preserving clean continuation prompts for subagents.
    """
    global _adk_request_input_context_filter_patched
    if _adk_request_input_context_filter_patched:
        return

    try:
        import google.adk.flows.llm_flows.contents as adk_contents

        orig_should_include = adk_contents._should_include_event_in_context

        def _is_request_input_event(event: Any) -> bool:
            """Check if an event contains an adk_request_input tool call or response.

            Args:
                event: The event to inspect for request-input parts.

            Returns:
                True if the event contains an adk_request_input call/response, False otherwise.
            """
            content = getattr(event, "content", None)
            parts = getattr(content, "parts", None) if content else None
            if not parts:
                return False
            for part in parts:
                fc = getattr(part, "function_call", None)
                if fc and getattr(fc, "name", None) == "adk_request_input":
                    return True
                fr = getattr(part, "function_response", None)
                if fr and getattr(fr, "name", None) == "adk_request_input":
                    return True
            return False

        def patched_should_include_event_in_context(
            current_branch: str | None,
            event: Any,
            isolation_scope: str | None = None,
            *,
            include_thoughts: bool = False,
            **kwargs: Any,
        ) -> bool:
            """Filter out adk_request_input events before delegating to the original ADK context filter.

            Args:
                current_branch: The current execution branch in the session flow.
                event: The session event being evaluated for inclusion in LLM context.
                isolation_scope: Active isolation scope identifier, if any.
                include_thoughts: Whether to retain agent reasoning/thought parts.
                **kwargs: Additional parameters forwarded to the original ADK filter.

            Returns:
                False if event is an adk_request_input event, otherwise the original filter decision.
            """
            if _is_request_input_event(event):
                return False
            return orig_should_include(
                current_branch,
                event,
                isolation_scope=isolation_scope,
                include_thoughts=include_thoughts,
                **kwargs,
            )

        adk_contents._should_include_event_in_context = patched_should_include_event_in_context
        _adk_request_input_context_filter_patched = True
        logger.debug(
            "Patched google.adk.flows.llm_flows.contents._should_include_event_in_context to filter adk_request_input"
        )
    except Exception as err:
        logger.warning("Could not patch ADK contents _should_include_event_in_context: %s", err)

patch_compaction_subagent_isolation

patch_compaction_subagent_isolation()

Ensure token compaction only executes for the root coordinator agent, never for subagents, and eliminate duplicate post-invocation compaction runs.

In multi-agent systems, subagents (e.g. library_search_agent, online_search_agent) execute inside isolated pipelines. If token-threshold compaction runs inside subagents, it mistakenly compacts the parent session's history before every internal tool call, causing massive latency overhead and redundant compaction cycles.

Additionally, Google ADK 2.8.0 contains an upstream context propagation bug where BaseAgent._create_invocation_context performs a shallow model_copy() of InvocationContext, causing token_compaction_checked = True to only be set on the child context and never reaching the root context in runners.py. This causes runners.py to pass skip_token_compaction=False to _run_post_invocation_compaction, triggering a second, completely redundant compaction cycle. This patch links parent contexts and guards _run_compaction_for_token_threshold so compaction never executes more than once per turn.

Source code in zocrates/agents/common/adk_compat.py
def patch_compaction_subagent_isolation() -> None:
    """Ensure token compaction only executes for the root coordinator agent, never for subagents,
    and eliminate duplicate post-invocation compaction runs.

    In multi-agent systems, subagents (e.g. library_search_agent, online_search_agent)
    execute inside isolated pipelines. If token-threshold compaction runs inside subagents,
    it mistakenly compacts the parent session's history before every internal tool call,
    causing massive latency overhead and redundant compaction cycles.

    Additionally, Google ADK 2.8.0 contains an upstream context propagation bug where
    `BaseAgent._create_invocation_context` performs a shallow `model_copy()` of InvocationContext,
    causing `token_compaction_checked = True` to only be set on the child context and never reaching
    the root context in `runners.py`. This causes runners.py to pass `skip_token_compaction=False` to
    `_run_post_invocation_compaction`, triggering a second, completely redundant compaction cycle.
    This patch links parent contexts and guards `_run_compaction_for_token_threshold` so compaction
    never executes more than once per turn.
    """
    global _compaction_subagent_isolation_patched
    if _compaction_subagent_isolation_patched:
        return

    try:
        from google.adk.agents.base_agent import BaseAgent
        import google.adk.apps.compaction as adk_app_compaction
        import google.adk.flows.llm_flows.compaction as adk_compaction

        # 1. Link child InvocationContext to parent context so flags propagate back to root runner
        orig_create_invocation_context = BaseAgent._create_invocation_context

        def patched_create_invocation_context(agent_self: Any, parent_context: Any) -> Any:
            """Create child invocation context and attach a reference to its parent context.

            Args:
                agent_self: The agent instance creating the invocation context.
                parent_context: The parent invocation context to link against.

            Returns:
                The created child invocation context with `_parent_context` attached.
            """
            child = orig_create_invocation_context(agent_self, parent_context)
            child._parent_context = parent_context
            return child

        BaseAgent._create_invocation_context = patched_create_invocation_context

        # 2. Patch CompactionRequestProcessor to isolate token compaction to root coordinator
        # and propagate token_compaction_checked flag to parent contexts
        orig_run_async = adk_compaction.CompactionRequestProcessor.run_async

        async def patched_run_async(self: Any, invocation_context: Any, llm_request: Any) -> Any:
            """Execute compaction request processor only for root coordinator agent instances.

            Args:
                self: The CompactionRequestProcessor instance.
                invocation_context: Current invocation context.
                llm_request: The pending LLM request.

            Yields:
                Events yielded by the original compaction request processor.
            """
            # Skip if running inside an isolation scope (subagent pipeline)
            if getattr(invocation_context, "isolation_scope", None) is not None:
                return
                yield

            # Skip if agent is a subagent (only coordinator_agent compacts session history)
            agent = getattr(invocation_context, "agent", None)
            if agent and getattr(agent, "name", None) not in ("coordinator_agent", "coordinator"):
                return
                yield

            async for event in orig_run_async(self, invocation_context, llm_request):
                yield event

            # Propagate token_compaction_checked back to root invocation context in runner loop
            if getattr(invocation_context, "token_compaction_checked", False):
                curr = getattr(invocation_context, "_parent_context", None)
                while curr is not None:
                    curr.token_compaction_checked = True
                    curr = getattr(curr, "_parent_context", None)

        adk_compaction.CompactionRequestProcessor.run_async = patched_run_async
        adk_compaction.request_processor.run_async = patched_run_async.__get__(
            adk_compaction.request_processor, adk_compaction.CompactionRequestProcessor
        )

        # 3. Guard post-invocation token-threshold compaction against duplicate execution
        orig_token_compaction = adk_app_compaction._run_compaction_for_token_threshold

        async def patched_run_compaction_for_token_threshold(
            app: Any, session: Any, session_service: Any
        ) -> bool:
            """Guard post-invocation token-threshold compaction against duplicate execution per turn.

            Args:
                app: The ADK application instance.
                session: The active session being evaluated for compaction.
                session_service: Session service handling session persistence.

            Returns:
                True if compaction was executed, False if skipped or not triggered.
            """
            if session and getattr(session, "events", None) and _has_compaction_for_active_turn(session.events):
                logger.debug(
                    "Skipping duplicate post-invocation token-threshold compaction: "
                    "a compaction event already exists for the active turn."
                )
                return False
            return await orig_token_compaction(app, session, session_service)

        adk_app_compaction._run_compaction_for_token_threshold = patched_run_compaction_for_token_threshold

        # 4. Patch Runner._run_post_invocation_compaction:
        # - Suppress compaction when invocation is interrupted mid-turn for HITL (e.g. search_budget_checkpoint)
        # - Always skip token-threshold compaction in post-invocation since it is strictly evaluated pre-invocation,
        #   preventing duplicate runs, empty Phoenix spans, and unnecessary end-of-turn latency.
        from google.adk.runners import Runner

        orig_post_compaction = Runner._run_post_invocation_compaction

        async def patched_post_invocation_compaction(
            runner_self: Any,
            *,
            session: Any,
            skip_token_compaction: bool,
        ) -> None:
            """Run adaptive post-turn compaction and suppress redundant post-invocation compaction.

            Args:
                runner_self: The Runner instance executing post-invocation steps.
                session: The active session.
                skip_token_compaction: Whether to skip token-threshold compaction.
            """
            if session and getattr(session, "events", None):
                last_ev = session.events[-1]
                if last_ev.content and any(getattr(p, "function_call", None) for p in last_ev.content.parts):
                    logger.debug("Skipping post-invocation compaction: invocation was interrupted mid-turn for HITL.")
                    return
                if getattr(last_ev, "actions", None) and (
                    getattr(last_ev.actions, "requested_tool_confirmations", None)
                    or getattr(last_ev.actions, "requested_auth_configs", None)
                ):
                    logger.debug("Skipping post-invocation compaction: invocation awaiting tool confirmation.")
                    return

            try:
                from zocrates.agents.common.compaction import run_adaptive_post_invocation_compaction

                await run_adaptive_post_invocation_compaction(runner_self, session)
            except Exception as exc:
                logger.warning("Adaptive post-invocation compaction failed open: %s", exc)

            await orig_post_compaction(
                runner_self,
                session=session,
                skip_token_compaction=True,
            )

        Runner._run_post_invocation_compaction = patched_post_invocation_compaction

        _compaction_subagent_isolation_patched = True
        logger.debug("Patched ADK CompactionRequestProcessor, Runner, and post-invocation token compaction guards")
    except Exception as err:
        logger.warning("Could not patch ADK CompactionRequestProcessor: %s", err)

read_document_core

read_document_core(item_key, asset_id=None, page=None)

Read full document Markdown or targeted sections, figures, tables, or abstract from the OKF bundle.

Accepts exact asset IDs from toc.md ('s0', 's1', 'f1', 't1'), 'abstract' (or 'summary') to read the document's abstract, a list of IDs, or fuzzy heading names. Supports Range-Aware Pre-Exclusion by returning all intersecting vector chunk IDs.

Parameters:

Name Type Description Default
item_key str

Unique Zotero item key.

required
asset_id str | list[str] | None

Asset ID, list of asset IDs, or fuzzy heading string. If None, reads full document paginated.

None
page int | None

1-indexed page number if content exceeds WORDS_PER_PAGE.

None

Returns:

Type Description
dict[str, Any]

Dictionary with 'status', 'item_key', 'assets', and 'intersecting_chunk_ids'.

Source code in zocrates/agents/common/toolkit.py
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
def read_document_core(
    item_key: str,
    asset_id: str | list[str] | None = None,
    page: int | None = None,
) -> dict[str, Any]:
    """Read full document Markdown or targeted sections, figures, tables, or abstract from the OKF bundle.

    Accepts exact asset IDs from toc.md ('s0', 's1', 'f1', 't1'), 'abstract' (or 'summary') to read the
    document's abstract, a list of IDs, or fuzzy heading names.
    Supports Range-Aware Pre-Exclusion by returning all intersecting vector chunk IDs.

    Args:
        item_key: Unique Zotero item key.
        asset_id: Asset ID, list of asset IDs, or fuzzy heading string. If None, reads full document paginated.
        page: 1-indexed page number if content exceeds WORDS_PER_PAGE.

    Returns:
        Dictionary with 'status', 'item_key', 'assets', and 'intersecting_chunk_ids'.
    """
    if not _validate_item_key(item_key):
        return {"status": "error", "message": f"Invalid item key: {item_key}"}

    try:
        resolved = _resolve_bundle_dir(item_key)
        if not resolved:
            return {
                "status": "error",
                "message": f"Document bundle for {item_key} not found or not yet converted.",
            }

        bundle_dir, resolved_key, read_notice = resolved
        doc_md_path = bundle_dir / "document.md"
        if not doc_md_path.exists():
            return {
                "status": "error",
                "message": f"Document markdown for {item_key} not found.",
            }

        syncer = get_syncer()

        # Ensure toc.json exists
        toc_json_path = bundle_dir / "toc.json"
        if not toc_json_path.exists():
            doc_text = doc_md_path.read_text(encoding="utf-8")
            meta = syncer.processor.get_metadata(resolved_key)
            title = meta.get("title") if meta else None
            parent_item_key = meta.get("parent_item_key") if meta else None
            from zocrates.rag.toc_generator import generate_toc, save_toc_files

            toc_json_data, toc_md = generate_toc(
                doc_text,
                title=title,
                item_key=resolved_key,
                parent_item_key=parent_item_key,
            )
            save_toc_files(bundle_dir, toc_json_data, toc_md)
        else:
            toc_json_data = json.loads(toc_json_path.read_text(encoding="utf-8"))

        doc_title = (
            toc_json_data.get("title")
            or (title if "title" in locals() and title else "")
            or ""
        )
        doc_authors = toc_json_data.get("authors")
        doc_year = toc_json_data.get("year")

        if (doc_authors is None or doc_year is None or not doc_title or doc_title.lower() in ("snapshot", "full text", "attachment")) and hasattr(syncer, "processor") and hasattr(syncer.processor, "get_metadata"):
            try:
                meta = syncer.processor.get_metadata(resolved_key)
                if isinstance(meta, dict):
                    if not doc_title and meta.get("title"):
                        doc_title = meta.get("title")
                    if doc_authors is None and meta.get("authors"):
                        doc_authors = meta.get("authors")
                    if doc_year is None and meta.get("year"):
                        doc_year = meta.get("year")
                    parent_key = meta.get("parent_item_key")
                    if parent_key and syncer.db:
                        parent_item = syncer.db.get_item_by_key(parent_key)
                        if parent_item:
                            if not doc_title or doc_title.lower() in ("snapshot", "full text", "attachment"):
                                doc_title = parent_item.title
                            if doc_authors is None and parent_item.creators:
                                doc_authors = parent_item.creators
                            if doc_year is None and parent_item.year:
                                doc_year = parent_item.year
            except Exception:
                pass

        if doc_authors is None or doc_year is None or not doc_title:
            try:
                doc_text_head = doc_md_path.read_text(encoding="utf-8")
                if doc_text_head.startswith("---"):
                    import yaml
                    parts = doc_text_head.split("---", 2)
                    if len(parts) >= 3:
                        fm = yaml.safe_load(parts[1])
                        if isinstance(fm, dict):
                            if (not doc_title or doc_title.lower() in ("snapshot", "full text", "attachment")) and fm.get("title"):
                                doc_title = str(fm["title"]).strip()
                            if doc_authors is None and fm.get("authors"):
                                doc_authors = fm["authors"]
                            if doc_year is None and fm.get("year"):
                                doc_year = str(fm["year"])
                            elif doc_year is None and fm.get("date"):
                                m = re.search(r"\b(19\d\d|20\d\d)\b", str(fm["date"]))
                                if m:
                                    doc_year = m.group(1)
            except Exception:
                pass

        doc_text = doc_md_path.read_text(encoding="utf-8")
        doc_lines = doc_text.splitlines(keepends=True)

        def slice_lines(start_line: int, end_line: int) -> str:
            """Extract a 1-based inclusive line range from document lines.

            Args:
                start_line: 1-based start line index.
                end_line: 1-based end line index.

            Returns:
                Joined line content for the specified range.
            """
            s_idx = max(0, start_line - 1)
            e_idx = max(0, end_line)
            return "".join(doc_lines[s_idx:e_idx])

        # Precompute hierarchical breadcrumb headings for section assets
        def _is_fm_or_title(h_text: str) -> bool:
            clean_h = h_text.strip().lower()
            if not clean_h or clean_h in ("frontmatter", "title", "document body"):
                return True
            if re.match(r"^(?:figure|table)\s+\d", clean_h, re.IGNORECASE):
                return True
            if doc_title:
                norm_h = re.sub(r"[^\w\s]", "", clean_h).replace(" ", "")
                norm_t = re.sub(r"[^\w\s]", "", doc_title.lower()).replace(" ", "")
                if norm_h and norm_h == norm_t:
                    return True
            return False

        sec_breadcrumb_headings: dict[int, str] = {}
        heading_stack: list[dict[str, Any]] = []

        for sec in toc_json_data.get("sections", []):
            s_id = sec.get("id", "")
            raw_h = sec.get("heading", "")
            lvl = int(sec.get("level") or 1)

            if sec.get("level") in (0, "0") or s_id == "s0" or _is_fm_or_title(raw_h):
                sec_breadcrumb_headings[id(sec)] = raw_h
                continue

            while heading_stack and heading_stack[-1]["level"] >= lvl:
                heading_stack.pop()

            if " > " in raw_h:
                computed_h = raw_h
                leaf_h = raw_h.split(" > ")[-1].strip()
            else:
                parents = [item["heading"] for item in heading_stack]
                if parents:
                    computed_h = f"{' > '.join(parents)} > {raw_h}"
                else:
                    computed_h = raw_h
                leaf_h = raw_h

            sec_breadcrumb_headings[id(sec)] = computed_h
            heading_stack.append({"level": lvl, "heading": leaf_h})

        def _get_sec_heading(s_dict: dict[str, Any]) -> str:
            return sec_breadcrumb_headings.get(id(s_dict), s_dict.get("heading", ""))

        # Case 1: Whole document paginated mode (asset_id is None)
        if asset_id is None:
            if not doc_lines:
                doc_lines = [""]

            # Partition lines into pages of up to WORDS_PER_PAGE words
            pages: list[tuple[int, int]] = []
            cur_start_idx = 0
            cur_words = 0
            for idx, line in enumerate(doc_lines):
                lw = len(line.split())
                if cur_words > 0 and cur_words + lw > WORDS_PER_PAGE:
                    pages.append((cur_start_idx + 1, idx))
                    cur_start_idx = idx
                    cur_words = lw
                else:
                    cur_words += lw

            pages.append((cur_start_idx + 1, len(doc_lines)))
            total_pages = len(pages)
            req_page = max(1, min(page or 1, total_pages))
            page_start_line, page_end_line = pages[req_page - 1]
            content = slice_lines(page_start_line, page_end_line)
            page_words = content.split()
            has_more = req_page < total_pages

            inter_chunk_ids = syncer.store.get_intersecting_chunk_ids(
                resolved_key, page_start_line, page_end_line
            )
            asset_obj = {
                "asset_id": "full",
                "asset_type": "document",
                "heading": "Full Document",
                "content": content,
                "word_count": len(page_words),
                "path": None,
                "structured_content": None,
                "page": req_page,
                "total_pages": total_pages,
                "has_more": has_more,
            }
            full_doc_result: dict[str, Any] = {
                "status": "success",
                "item_key": resolved_key,
                "title": doc_title,
                "assets": [asset_obj],
                "intersecting_chunk_ids": inter_chunk_ids,
            }
            if doc_authors is not None:
                full_doc_result["authors"] = doc_authors
            if doc_year is not None:
                full_doc_result["year"] = doc_year
            if read_notice:
                full_doc_result["notice"] = read_notice
            return full_doc_result

        # Helper to resolve a single asset ID or fuzzy heading
        def _resolve_single_asset(aid: str, req_page: int | None = None) -> tuple[dict[str, Any], list[str]]:
            """Resolve a single asset ID or section heading into an asset payload and intersecting chunks.

            Args:
                aid: Asset ID (e.g. 's1', 'f2', 't1') or section heading title.
                req_page: Optional 1-based page number for paginated section reading.

            Returns:
                A tuple containing the asset dictionary and a list of intersecting chunk IDs.

            Raises:
                KeyError: If the requested asset ID or heading is not found in the TOC manifest.
            """
            clean_aid = aid.strip()
            clean_lower = clean_aid.lower()

            # 0. Virtual Abstract / Summary match ("abstract", "summary")
            if clean_lower in ("abstract", "summary"):
                # Delegate to an explicit section if present
                for sec in toc_json_data.get("sections", []):
                    if sec.get("heading", "").lower().strip() in ("abstract", "summary"):
                        start_l = sec.get("start_line", 1)
                        end_l = sec.get("end_line", start_l)
                        raw_content = slice_lines(start_l, end_l)
                        inter_ids = syncer.store.get_intersecting_chunk_ids(resolved_key, start_l, end_l)
                        words = raw_content.split()
                        total_w = len(words)
                        tot_pages = max(1, (total_w + WORDS_PER_PAGE - 1) // WORDS_PER_PAGE)
                        p = max(1, min(req_page or 1, tot_pages))
                        if tot_pages > 1:
                            p_words = words[(p - 1) * WORDS_PER_PAGE : p * WORDS_PER_PAGE]
                            p_content = " ".join(p_words)
                            has_m = p < tot_pages
                        else:
                            p_content = raw_content
                            p_words = words
                            has_m = False

                        return {
                            "asset_id": sec["id"],
                            "asset_type": "section",
                            "heading": _get_sec_heading(sec),
                            "content": p_content,
                            "word_count": len(p_words),
                            "path": None,
                            "structured_content": None,
                            "page": p,
                            "total_pages": tot_pages,
                            "has_more": has_m,
                        }, inter_ids

                # Fallback to metadata abstract from toc.json
                abstract_text = (toc_json_data.get("abstract") or "").strip()
                abs_src = (toc_json_data.get("abstract_source") or "").strip().lower()
                if abstract_text and abstract_text != "No abstract or summary available." and abs_src != "none":
                    words = abstract_text.split()
                    total_w = len(words)
                    tot_pages = max(1, (total_w + WORDS_PER_PAGE - 1) // WORDS_PER_PAGE)
                    p = max(1, min(req_page or 1, tot_pages))
                    if tot_pages > 1:
                        p_words = words[(p - 1) * WORDS_PER_PAGE : p * WORDS_PER_PAGE]
                        p_content = " ".join(p_words)
                        has_m = p < tot_pages
                    else:
                        p_content = abstract_text
                        p_words = words
                        has_m = False

                    return {
                        "asset_id": "abstract",
                        "asset_type": "abstract",
                        "heading": "Abstract",
                        "content": p_content,
                        "word_count": len(p_words),
                        "path": None,
                        "structured_content": None,
                        "page": p,
                        "total_pages": tot_pages,
                        "has_more": has_m,
                    }, []

                raise KeyError(f"No abstract or summary available for document {resolved_key}.")

            # 1. Section match (s0, s1, ...)
            for sec in toc_json_data.get("sections", []):
                if sec.get("id") == clean_aid:
                    start_l = sec.get("start_line", 1)
                    end_l = sec.get("end_line", start_l)
                    raw_content = slice_lines(start_l, end_l)
                    inter_ids = syncer.store.get_intersecting_chunk_ids(resolved_key, start_l, end_l)
                    words = raw_content.split()
                    total_w = len(words)
                    tot_pages = max(1, (total_w + WORDS_PER_PAGE - 1) // WORDS_PER_PAGE)
                    p = max(1, min(req_page or 1, tot_pages))
                    if tot_pages > 1:
                        p_words = words[(p - 1) * WORDS_PER_PAGE : p * WORDS_PER_PAGE]
                        p_content = " ".join(p_words)
                        has_m = p < tot_pages
                    else:
                        p_content = raw_content
                        p_words = words
                        has_m = False

                    return {
                        "asset_id": sec["id"],
                        "asset_type": "section",
                        "heading": _get_sec_heading(sec),
                        "content": p_content,
                        "word_count": len(p_words),
                        "path": None,
                        "structured_content": None,
                        "page": p,
                        "total_pages": tot_pages,
                        "has_more": has_m,
                    }, inter_ids

            # 2. Figure match (f1, f2, ...)
            for fig in toc_json_data.get("figures", []):
                if fig.get("id") == clean_aid:
                    start_l = fig.get("start_line", 1)
                    end_l = fig.get("end_line", start_l)
                    raw_content = slice_lines(start_l, end_l)
                    inter_ids = syncer.store.get_intersecting_chunk_ids(resolved_key, start_l, end_l)
                    return {
                        "asset_id": fig["id"],
                        "asset_type": "figure",
                        "heading": fig.get("caption", "") or "",
                        "content": raw_content,
                        "word_count": len(raw_content.split()),
                        "path": fig.get("path"),
                        "structured_content": None,
                        "page": 1,
                        "total_pages": 1,
                        "has_more": False,
                    }, inter_ids

            # 3. Table match (t1, t2, ...)
            for tab in toc_json_data.get("tables", []):
                if tab.get("id") == clean_aid:
                    start_l = tab.get("start_line", 1)
                    end_l = tab.get("end_line", start_l)
                    raw_content = slice_lines(start_l, end_l)
                    inter_ids = syncer.store.get_intersecting_chunk_ids(resolved_key, start_l, end_l)
                    csv_content = None
                    if tab.get("path"):
                        csv_p = (bundle_dir / tab["path"]).resolve()
                        if csv_p.is_relative_to(bundle_dir.resolve()) and csv_p.exists():
                            csv_content = csv_p.read_text(encoding="utf-8")
                    return {
                        "asset_id": tab["id"],
                        "asset_type": "table",
                        "heading": tab.get("caption", "") or "",
                        "content": raw_content,
                        "word_count": len(raw_content.split()),
                        "path": tab.get("path"),
                        "structured_content": csv_content,
                        "page": 1,
                        "total_pages": 1,
                        "has_more": False,
                    }, inter_ids

            # 4. Fuzzy heading matching fallback
            norm_aid = re.sub(r"[^\w\s]", "", clean_aid).lower().strip()
            best_sec = None
            for sec in toc_json_data.get("sections", []):
                h = sec.get("heading", "")
                norm_h = re.sub(r"[^\w\s]", "", h).lower().strip()
                b_h = _get_sec_heading(sec)
                norm_b = re.sub(r"[^\w\s]", "", b_h).lower().strip()
                if (
                    norm_aid == norm_h
                    or (norm_aid and norm_aid in norm_h)
                    or norm_aid == norm_b
                    or (norm_aid and norm_aid in norm_b)
                ):
                    best_sec = sec
                    break

            if best_sec is not None:
                start_l = best_sec.get("start_line", 1)
                end_l = best_sec.get("end_line", start_l)
                raw_content = slice_lines(start_l, end_l)
                inter_ids = syncer.store.get_intersecting_chunk_ids(resolved_key, start_l, end_l)
                words = raw_content.split()
                total_w = len(words)
                tot_pages = max(1, (total_w + WORDS_PER_PAGE - 1) // WORDS_PER_PAGE)
                p = max(1, min(req_page or 1, tot_pages))
                if tot_pages > 1:
                    p_words = words[(p - 1) * WORDS_PER_PAGE : p * WORDS_PER_PAGE]
                    p_content = " ".join(p_words)
                    has_m = p < tot_pages
                else:
                    p_content = raw_content
                    p_words = words
                    has_m = False
                return {
                    "asset_id": best_sec["id"],
                    "asset_type": "section",
                    "heading": _get_sec_heading(best_sec),
                    "content": p_content,
                    "word_count": len(p_words),
                    "path": None,
                    "structured_content": None,
                    "page": p,
                    "total_pages": tot_pages,
                    "has_more": has_m,
                }, inter_ids

            raise KeyError(f"Asset ID or heading '{clean_aid}' not found in document bundle.")

        norm_asset_ids = normalize_asset_ids(asset_id)

        # Case 1: Whole document paginated mode (norm_asset_ids is None)
        # Note: if asset_id was None, it was already handled above.
        if norm_asset_ids is None:
            return {
                "status": "error",
                "message": f"Invalid asset_id: '{asset_id}'",
            }

        # Case 2: Single asset string
        if len(norm_asset_ids) == 1:
            aid = norm_asset_ids[0]
            try:
                single_asset, inter_ids = _resolve_single_asset(aid, req_page=page)
                single_result: dict[str, Any] = {
                    "status": "success",
                    "item_key": resolved_key,
                    "title": doc_title,
                    "assets": [single_asset],
                    "intersecting_chunk_ids": inter_ids,
                }
                if doc_authors is not None:
                    single_result["authors"] = doc_authors
                if doc_year is not None:
                    single_result["year"] = doc_year
                if read_notice:
                    single_result["notice"] = read_notice
                return single_result
            except KeyError as err:
                return {"status": "error", "message": str(err)}

        # Case 3: List of asset IDs
        assets: list[dict[str, Any]] = []
        all_inter: list[str] = []
        total_words_accum = 0
        unresolved_ids: list[str] = []

        for aid in norm_asset_ids:
            try:
                asset_obj, inter_ids = _resolve_single_asset(aid, req_page=1)
                all_inter.extend(inter_ids)
                if total_words_accum + asset_obj["word_count"] > MULTI_ASSET_WORD_CAP:
                    remaining = max(0, MULTI_ASSET_WORD_CAP - total_words_accum)
                    if remaining > 0:
                        w_slice = asset_obj["content"].split()[:remaining]
                        asset_obj["content"] = " ".join(w_slice)
                        asset_obj["word_count"] = len(w_slice)
                        asset_obj["has_more"] = True
                        assets.append(asset_obj)
                    break
                else:
                    total_words_accum += asset_obj["word_count"]
                    assets.append(asset_obj)
            except KeyError:
                unresolved_ids.append(aid)

        # Deduplicate intersecting IDs preserving order
        seen: set[str] = set()
        deduped_inter = [cid for cid in all_inter if not (cid in seen or seen.add(cid))]

        status = "success" if assets else "error"
        result_payload: dict[str, Any] = {
            "status": status,
            "item_key": resolved_key,
            "title": doc_title,
            "assets": assets,
            "intersecting_chunk_ids": deduped_inter,
        }
        if doc_authors is not None:
            result_payload["authors"] = doc_authors
        if doc_year is not None:
            result_payload["year"] = doc_year
        if unresolved_ids:
            result_payload["unresolved_asset_ids"] = unresolved_ids
            result_payload["notice"] = (
                f"[Warning: Asset IDs {unresolved_ids} could not be resolved in document {resolved_key}. "
                "Verify asset IDs against get_document_toc output.]"
            )
        elif read_notice:
            result_payload["notice"] = read_notice

        return result_payload
    except Exception as exc:
        logger.error("Failed in read_document_core for %s: %s", item_key, exc)
        return {"status": "error", "message": str(exc)}

recompute_graph async

recompute_graph(req=None)

Recompute and persist paper relation edges for specified or all items.

Parameters:

Name Type Description Default
req GraphRecomputeRequest | None

Optional request specifying target item keys. If not provided, queries all item keys from conversion_meta.db.

None

Returns:

Type Description
dict

Dict with status and recomputed_count.

Source code in zocrates/fast_api_app.py
@app.post("/graph/recompute", dependencies=[Depends(verify_auth)])
async def recompute_graph(req: GraphRecomputeRequest | None = None) -> dict:
    """Recompute and persist paper relation edges for specified or all items.

    Args:
        req: Optional request specifying target item keys. If not provided,
             queries all item keys from conversion_meta.db.

    Returns:
        Dict with status and recomputed_count.
    """
    try:
        syncer = get_syncer()
    except RuntimeError as exc:
        raise HTTPException(
            status_code=400,
            detail="Zocrates backend is not registered. Call /register first.",
        ) from exc

    keys: list[str] = []
    if req and req.item_keys is not None:
        keys = [k.strip() for k in req.item_keys if k and k.strip()]
    else:
        if hasattr(syncer, "processor") and hasattr(syncer.processor, "get_all_meta_keys"):
            keys = sorted(syncer.processor.get_all_meta_keys())
        else:
            db_path = _resolve_conversion_meta_db_path()
            if db_path and db_path.exists():
                import sqlite3

                with sqlite3.connect(str(db_path)) as conn:
                    rows = conn.execute("SELECT DISTINCT item_key FROM conversion_meta").fetchall()
                    keys = [r[0] for r in rows if r[0]]

    success_count = 0
    failed_keys: list[str] = []
    for k in keys:
        try:
            await asyncio.to_thread(evaluate_and_persist_edges, k, syncer, True)
            success_count += 1
        except Exception as exc:
            logger.warning("Failed to recompute edges for %s: %s", k, exc)
            failed_keys.append(k)

    return {
        "status": "success" if not failed_keys else "partial_success",
        "recomputed_count": success_count,
        "failed_keys": failed_keys,
    }

register async

register(request)

Register the user's Zotero Data Directory and initialize pipeline services.

Validates the directory structure and initializes all pipeline services.

Raises:

Type Description
HTTPException

400 if the directory, zotero.sqlite, or storage/ subdirectory do not exist; 500 on unexpected initialization failure.

Returns:

Type Description
dict

Dict with keys status, message, and data_directory.

Source code in zocrates/fast_api_app.py
@app.post("/register", dependencies=[Depends(verify_auth)])
async def register(request: RegisterRequest) -> dict:
    """Register the user's Zotero Data Directory and initialize pipeline services.

    Validates the directory structure and initializes all pipeline services.

    Raises:
        HTTPException: 400 if the directory, ``zotero.sqlite``, or ``storage/``
            subdirectory do not exist; 500 on unexpected initialization failure.

    Returns:
        Dict with keys ``status``, ``message``, and ``data_directory``.
    """
    path = Path(request.zotero_data_dir).expanduser().resolve()
    if not path.exists():
        raise HTTPException(
            status_code=400, detail=f"The specified directory does not exist: {path}"
        )

    sqlite_path = path / "zotero.sqlite"
    if not sqlite_path.exists():
        raise HTTPException(
            status_code=400,
            detail=f"Zotero SQLite database file not found at {sqlite_path}",
        )

    storage_dir = path / "storage"
    if not storage_dir.is_dir():
        raise HTTPException(
            status_code=400,
            detail=f"Path does not appear to be a valid Zotero data directory: "
                   f"missing 'storage/' subdirectory at {path}",
        )

    try:
        if request.scholar_polite_email and request.scholar_polite_email.strip():
            from zocrates.config import update_config
            update_config({"scholar_polite_email": request.scholar_polite_email.strip()}, zotero_data_dir=path)

        if request.openalex_api_key and request.openalex_api_key.strip():
            from zocrates.config import save_openalex_api_key
            save_openalex_api_key(request.openalex_api_key.strip())

        if request.openrouter_api_key and request.openrouter_api_key.strip():
            from zocrates.config import save_secure_api_key
            save_secure_api_key(
                request.openrouter_api_key.strip(),
                key_name="openrouter_api_key",
                env_var="OPENROUTER_API_KEY",
            )

        if request.datalab_api_key and request.datalab_api_key.strip():
            from zocrates.config import save_datalab_api_key
            save_datalab_api_key(request.datalab_api_key.strip())

        syncer = initialize_services(path)
        setup_file_logging(get_log_dir(path))
        auth_file = path / "zocrates-data" / "backend.auth"
        if not auth_file.exists():
            _write_auth_sidecar(auth_file, get_auth_token())

        # Refresh Runner to use registered DatabaseSessionService
        from zocrates.agents.app import app as adk_app
        session_service = services.get_session_service()

        # Run synchronous startup maintenance before runner traffic begins (fail-open)
        try:
            from zocrates.utils.session_maintenance import run_session_database_maintenance
            from zocrates.config import get_session_maintenance_config
            maintenance_res = await run_session_database_maintenance(
                zotero_data_dir=path,
                session_service=session_service,
                config=get_session_maintenance_config(path),
                active_session_ids=getattr(app.state, "active_chat_session_ids", None),
                app_name=adk_app.name,
            )
            logger.info("Startup session database maintenance complete: %s", maintenance_res)
        except Exception as maint_err:
            logger.warning("Startup session maintenance encountered non-fatal error: %s", maint_err)

        app.state.runner = Runner(
            app=adk_app,
            session_service=session_service,
            artifact_service=services.get_artifact_service(),
            auto_create_session=True,
        )
        app.state.agent_app_name = adk_app.name

        # Launch non-blocking background metadata staleness sweep
        sweep_task = asyncio.create_task(asyncio.to_thread(syncer.sync_metadata_staleness))
        app.state.metadata_sweep_task = sweep_task

        def _log_sweep_result(t: asyncio.Task) -> None:
            """Log completion or failure of registration metadata staleness sweep.

            Args:
                t: The completed asyncio Task instance.
            """
            try:
                res = t.result()
                logger.info("Startup metadata staleness sweep complete: %s", res)
            except Exception as sweep_err:
                logger.warning("Startup metadata staleness sweep encountered error: %s", sweep_err)

        sweep_task.add_done_callback(_log_sweep_result)

        return {
            "status": "success",
            "message": "Zocrates backend registered successfully.",
            "data_directory": str(path),
        }

    except Exception as e:
        raise HTTPException(
            status_code=500, detail=f"Failed to initialize services: {e}"
        ) from e

request_validation_exception_handler async

request_validation_exception_handler(request, exc)

Handle request validation errors with human-readable detail and structured error list.

Source code in zocrates/fast_api_app.py
@app.exception_handler(RequestValidationError)
async def request_validation_exception_handler(
    request: Request, exc: RequestValidationError
) -> JSONResponse:
    """Handle request validation errors with human-readable detail and structured error list."""
    error_messages = []
    for err in exc.errors():
        loc = " -> ".join(str(part) for part in err.get("loc", []))
        msg = err.get("msg", "Invalid value")
        if loc:
            error_messages.append(f"{loc}: {msg}")
        else:
            error_messages.append(msg)
    detail_str = "; ".join(error_messages) if error_messages else "Request validation failed"

    logger.warning(
        "Validation error on %s %s: %s",
        request.method,
        request.url.path,
        detail_str,
    )
    return JSONResponse(
        status_code=422,
        content={
            "detail": detail_str,
            "errors": jsonable_encoder(exc.errors()),
        },
    )

reset_collections async

reset_collections(request=None)

Reset vector store collections ("active", "all", or a specific name).

Raises:

Type Description
HTTPException

400 if not registered; 500 on unexpected error.

Returns:

Type Description
dict

Dict from VectorStore.reset_collection with keys status,

dict

target, deleted_collections, fts_reset, and

dict

active_collection.

Source code in zocrates/fast_api_app.py
@app.post("/rag/reset", dependencies=[Depends(verify_auth)])
async def reset_collections(request: RAGResetRequest | None = None) -> dict:
    """Reset vector store collections (``"active"``, ``"all"``, or a specific name).

    Raises:
        HTTPException: 400 if not registered; 500 on unexpected error.

    Returns:
        Dict from ``VectorStore.reset_collection`` with keys ``status``,
        ``target``, ``deleted_collections``, ``fts_reset``, and
        ``active_collection``.
    """
    try:
        syncer = get_syncer()
        target = request.target if request else "active"
        result = syncer.store.reset_collection(target=target)
        return result
    except RuntimeError as e:
        raise HTTPException(status_code=400, detail=str(e)) from e
    except Exception as e:
        raise HTTPException(
            status_code=500, detail=f"Failed to reset collection: {e}"
        ) from e

resolve_active_item_entry

resolve_active_item_entry(syncer, key, title=None)

Resolve a Zotero item key into an enriched active-context entry dict.

Queries the syncer DB for the parent item's converted attachments and child notes, producing a children list compatible with format_active_items. Attachment entries are only included when their OKF bundle (document.md) exists on disk. Child notes are summarized non-LLM: notes shorter than 50 words are inlined into inline_text (capped at 300 chars), longer notes carry inline_text=None and are referenced by key/word count only.

Parameters:

Name Type Description Default
syncer Any

The active LibrarySyncer (needed for .db and .processor access).

required
key str

Zotero item key of the parent item.

required
title str | None

Optional human-readable title override; falls back to the DB item title, then "Untitled".

None

Returns:

Type Description
dict[str, Any]

Enriched item dict with item_key, title, optional year,

dict[str, Any]

and optional children. Any syncer/DB failure yields a minimal

dict[str, Any]

{"item_key": key, "title": doc_title} fallback.

Source code in zocrates/agents/common/prompts.py
def resolve_active_item_entry(
    syncer: Any,
    key: str,
    title: str | None = None,
) -> dict[str, Any]:
    """Resolve a Zotero item key into an enriched active-context entry dict.

    Queries the syncer DB for the parent item's converted attachments and child
    notes, producing a ``children`` list compatible with ``format_active_items``.
    Attachment entries are only included when their OKF bundle (``document.md``)
    exists on disk.  Child notes are summarized non-LLM: notes shorter than 50
    words are inlined into ``inline_text`` (capped at 300 chars), longer notes
    carry ``inline_text=None`` and are referenced by key/word count only.

    Args:
        syncer: The active ``LibrarySyncer`` (needed for ``.db`` and
            ``.processor`` access).
        key: Zotero item key of the parent item.
        title: Optional human-readable title override; falls back to the DB
            item title, then ``"Untitled"``.

    Returns:
        Enriched item dict with ``item_key``, ``title``, optional ``year``,
        and optional ``children``.  Any syncer/DB failure yields a minimal
        ``{"item_key": key, "title": doc_title}`` fallback.
    """
    doc_title = title or "Untitled"
    item_obj = None
    try:
        item_obj = syncer.db.get_item_by_key(key)
        if not doc_title or doc_title == "Untitled":
            doc_title = item_obj.title if item_obj and item_obj.title else "Untitled"
    except Exception:
        if not doc_title or doc_title == "Untitled":
            doc_title = "Untitled"

    children: list[dict[str, Any]] = []
    if item_obj is not None:
        try:
            for att in syncer.db.get_attachments(item_obj.item_id):
                att_bundle = syncer.processor._cache_dir / att.item_key / "document.md"
                if att_bundle.exists():
                    children.append({
                        "item_key": att.item_key,
                        "title": att.title or (att.path.name if att.path else "Attachment"),
                        "content_type": att.content_type,
                        "is_note": False,
                    })
        except Exception:
            pass

        try:
            for n in syncer.db.get_notes_for_item(item_obj.item_id):
                raw_text = re.sub(r"<[^>]+>", " ", n.note_html)
                plain_text = html.unescape(raw_text)
                plain_text = re.sub(r"\s+", " ", plain_text).strip()
                words = len(plain_text.split())
                if words < 50:
                    inline_text: str | None = plain_text
                    if len(inline_text) > 300:
                        inline_text = inline_text[:300].rstrip() + "\u2026"
                else:
                    inline_text = None
                children.append({
                    "item_key": n.note_key,
                    "title": n.title or "Note",
                    "content_type": "text/note",
                    "is_note": True,
                    "word_count": words,
                    "inline_text": inline_text,
                })
        except Exception:
            pass

    entry: dict[str, Any] = {"item_key": key, "title": doc_title}
    if item_obj is not None and getattr(item_obj, "year", None) and isinstance(item_obj.year, str):
        entry["year"] = item_obj.year
    if children:
        entry["children"] = children
    return entry

resume_chat_endpoint async

resume_chat_endpoint(request)

Resume an interrupted chat invocation awaiting Human-In-The-Loop user input.

Source code in zocrates/fast_api_app.py
@app.post("/chat/resume", dependencies=[Depends(verify_auth)])
async def resume_chat_endpoint(request: ResumeChatRequest) -> EventSourceResponse:
    """Resume an interrupted chat invocation awaiting Human-In-The-Loop user input."""
    try:
        syncer = get_syncer()
    except RuntimeError as e:
        raise HTTPException(status_code=400, detail=str(e)) from e

    zotero_dir = getattr(syncer, "_zotero_data_dir", None)
    runner: Runner = app.state.runner

    session = await runner.session_service.get_session(
        app_name="zocrates", user_id="default_user", session_id=request.session_id
    )
    if not session:
        raise HTTPException(status_code=404, detail="Session not found.")

    pending_interrupts = _get_unresolved_session_interrupts(session)
    if request.interrupt_id not in pending_interrupts:
        raise HTTPException(
            status_code=409,
            detail="Invalid or stale interrupt ID. The session is not waiting for this interrupt.",
        )

    fr_part = types.Part(
        function_response=types.FunctionResponse(
            name="adk_request_input",
            id=request.interrupt_id,
            response=request.response,
        )
    )
    new_message = types.Content(role="user", parts=[fr_part])
    run_config = RunConfig(streaming_mode=StreamingMode.SSE)

    from zocrates.agents.library_search.search_pipeline import search_library_pipeline_node
    from zocrates.agents.online_search.search_pipeline import search_online_pipeline_node

    target_node = search_library_pipeline_node
    snapshot = getattr(session, "state", {}).get(HITL_SNAPSHOT) if getattr(session, "state", None) else None
    if snapshot and isinstance(snapshot, dict):
        if snapshot.get("pipeline_type") == "online_search":
            target_node = search_online_pipeline_node
        elif str(snapshot.get("isolation_scope", "")).startswith("search_online_pipeline:"):
            target_node = search_online_pipeline_node

    if target_node == search_library_pipeline_node and getattr(session, "events", None):
        for ev in reversed(session.events):
            scope = getattr(ev, "isolation_scope", None)
            if scope:
                if str(scope).startswith("search_online_pipeline:"):
                    target_node = search_online_pipeline_node
                    break
                elif str(scope).startswith(("search_library_pipeline:", "search_pipeline:")):
                    target_node = search_library_pipeline_node
                    break

    runner_gen = runner._run_node_async(
        user_id="default_user",
        session_id=request.session_id,
        invocation_id=request.invocation_id,
        new_message=new_message,
        run_config=run_config,
        node=target_node,
    )

    active_ids = getattr(app.state, "active_chat_session_ids", None)
    if active_ids is not None:
        active_ids.add(request.session_id)

    request_start_time = time.time()

    async def sse_event_generator() -> AsyncIterator[dict[str, Any]]:
        """Stream resumed runner events over Server-Sent Events with session cleanup.

        Yields:
            SSE event payloads formatted for EventSourceResponse.
        """
        try:
            async for sse_item in _stream_runner_events(
                runner_gen,
                zotero_dir=zotero_dir,
                session_id=request.session_id,
                request_start_time=request_start_time,
            ):
                yield sse_item
        finally:
            if active_ids is not None:
                active_ids.discard(request.session_id)

    try:
        return EventSourceResponse(sse_event_generator())
    except Exception:
        if active_ids is not None:
            active_ids.discard(request.session_id)
        raise

rewind_user_session async

rewind_user_session(session_id, request)

Rewind a chat session to before the specified invocation.

Parameters:

Name Type Description Default
session_id str

The session ID to rewind.

required
request RewindSessionRequest

Request containing the invocation_id to rewind before.

required

Returns:

Type Description
dict

Dict with keys status, message, and session (formatted by

dict

format_session_for_ui).

Raises:

Type Description
HTTPException

404 if session_id or invocation_id not found; 500 on error.

Source code in zocrates/fast_api_app.py
@app.post("/sessions/{session_id}/rewind", dependencies=[Depends(verify_auth)])
async def rewind_user_session(session_id: str, request: RewindSessionRequest) -> dict:
    """Rewind a chat session to before the specified invocation.

    Args:
        session_id: The session ID to rewind.
        request: Request containing the invocation_id to rewind before.

    Returns:
        Dict with keys ``status``, ``message``, and ``session`` (formatted by
        ``format_session_for_ui``).

    Raises:
        HTTPException: 404 if session_id or invocation_id not found; 500 on error.
    """
    runner: Runner = getattr(app.state, "runner", None)
    session_service = services.get_session_service()
    app_name = getattr(app.state, "agent_app_name", "zocrates")
    if not runner:
        raise HTTPException(status_code=500, detail="Agent runner not initialized.")
    try:
        await runner.rewind_async(
            user_id="default_user",
            session_id=session_id,
            rewind_before_invocation_id=request.invocation_id,
        )
        sess = await session_service.get_session(
            app_name=app_name, user_id="default_user", session_id=session_id
        )
        if not sess:
            raise HTTPException(status_code=404, detail=f"Session {session_id} not found after rewind.")
        return {
            "status": "success",
            "message": f"Session {session_id} rewound before invocation {request.invocation_id}.",
            "session": format_session_for_ui(sess),
        }
    except ValueError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    except HTTPException:
        raise
    except Exception as exc:
        logger.error("Failed to rewind session %s: %s", session_id, exc)
        raise HTTPException(status_code=500, detail=f"Failed to rewind session: {exc}") from exc

run_sessions_maintenance_endpoint async

run_sessions_maintenance_endpoint()

Run on-demand session database retention pruning and space reclamation.

Source code in zocrates/fast_api_app.py
@app.post("/sessions/maintenance", dependencies=[Depends(verify_auth)])
async def run_sessions_maintenance_endpoint() -> dict:
    """Run on-demand session database retention pruning and space reclamation."""
    zotero_dir = getattr(app.state, "zotero_data_dir", None)
    if not zotero_dir:
        from zocrates.config import resolve_zotero_data_dir
        zotero_dir = resolve_zotero_data_dir()
    session_service = services.get_session_service(zotero_dir)
    app_name = getattr(app.state, "agent_app_name", "zocrates")

    if not zotero_dir and hasattr(session_service, "db_url"):
        url = str(session_service.db_url)
        prefix = "sqlite+aiosqlite:///"
        if url.startswith(prefix):
            p = Path(url[len(prefix):])
            if p.parent.name == "zocrates-data":
                zotero_dir = p.parent.parent

    if not zotero_dir:
        raise HTTPException(
            status_code=400,
            detail="Backend is not registered with a Zotero data directory.",
        )

    from zocrates.utils.session_maintenance import run_session_database_maintenance
    from zocrates.config import get_session_maintenance_config
    active_ids = getattr(app.state, "active_chat_session_ids", None)
    cfg = get_session_maintenance_config(zotero_dir)
    results = await run_session_database_maintenance(
        zotero_data_dir=zotero_dir,
        session_service=session_service,
        config=cfg,
        active_session_ids=active_ids,
        app_name=app_name,
        user_id="default_user",
    )
    return {
        "status": "success",
        "results": results,
        **results,
    }

search_corpus_core

search_corpus_core(query, item_key=None, chunk_type=None, limit=10, exclude_chunk_ids=None)

Hybrid (dense vector + FTS5 BM25) search over indexed library chunks with compound pre-filtering.

Parameters:

Name Type Description Default
query str

The search query string.

required
item_key str | list[str] | None

Single item key or list of item keys to filter by. Defaults to searching all items.

None
chunk_type str | list[str] | None

Chunk type filter ('frontmatter', 'figure', 'table', 'equation', 'code', 'text').

None
limit int

Maximum number of results to return.

10
exclude_chunk_ids set[str] | list[str] | None

Set or list of chunk IDs to exclude from search results.

None

Returns:

Type Description
dict[str, Any]

Dictionary with 'status' and list of clean agent 'results'.

Source code in zocrates/agents/common/toolkit.py
def search_corpus_core(
    query: str,
    item_key: str | list[str] | None = None,
    chunk_type: str | list[str] | None = None,
    limit: int = 10,
    exclude_chunk_ids: set[str] | list[str] | None = None,
) -> dict[str, Any]:
    """Hybrid (dense vector + FTS5 BM25) search over indexed library chunks with compound pre-filtering.

    Args:
        query: The search query string.
        item_key: Single item key or list of item keys to filter by. Defaults to searching all items.
        chunk_type: Chunk type filter ('frontmatter', 'figure', 'table', 'equation', 'code', 'text').
        limit: Maximum number of results to return.
        exclude_chunk_ids: Set or list of chunk IDs to exclude from search results.

    Returns:
        Dictionary with 'status' and list of clean agent 'results'.
    """
    if not query or not query.strip():
        return {"status": "success", "results": []}

    try:
        syncer = get_syncer()

        # Normalize item_key filter
        norm_item_keys: list[str] | None = None
        if isinstance(item_key, str):
            if item_key.strip().lower() in ("all", "*", "library", "entire_library"):
                norm_item_keys = None
            elif _validate_item_key(item_key):
                norm_item_keys = [item_key]
        elif isinstance(item_key, list):
            norm_item_keys = [
                k
                for k in item_key
                if isinstance(k, str)
                and k.strip().lower() not in ("all", "*", "library", "entire_library")
                and _validate_item_key(k)
            ]
            if not norm_item_keys:
                norm_item_keys = None

        # Normalize chunk_type filter
        norm_chunk_types: list[str] | None = None
        if isinstance(chunk_type, str):
            ct = chunk_type.strip().lower()
            if ct in ("none", "null", "all", "*", ""):
                norm_chunk_types = None
            else:
                norm_chunk_types = [ct]
        elif isinstance(chunk_type, list):
            filtered = [
                t.strip().lower()
                for t in chunk_type
                if isinstance(t, str) and t.strip().lower() not in ("none", "null", "all", "*", "")
            ]
            norm_chunk_types = filtered if filtered else None

        # Normalize exclude_chunk_ids
        norm_exclude: set[str] | None = None
        if exclude_chunk_ids:
            norm_exclude = set(exclude_chunk_ids)

        search_results = syncer.store.search(
            query=query,
            final_k=limit,
            item_key_filter=norm_item_keys,
            chunk_type_filter=norm_chunk_types,
            exclude_chunk_ids=norm_exclude,
        )

        cand_keys = {r.item_key for r in search_results if r.item_key}
        if syncer.db and cand_keys:
            active_keys = syncer.db.get_active_item_keys(list(cand_keys))
            if isinstance(active_keys, (set, list, tuple, frozenset)):
                search_results = [r for r in search_results if r.item_key in active_keys]

        # Cache item titles and metadata
        title_cache: dict[str, str] = {}
        authors_cache: dict[str, list[str]] = {}
        year_cache: dict[str, str] = {}
        serialized: list[dict[str, Any]] = []

        for r in search_results:
            if r.item_key not in title_cache:
                item = None
                if syncer.db and r.item_id:
                    cand = syncer.db.get_item(r.item_id)
                    cand_key = (
                        getattr(cand, "item_key", getattr(cand, "key", None))
                        if cand
                        else None
                    )
                    if cand and (
                        cand_key == r.item_key or not isinstance(cand_key, str)
                    ):
                        item = cand
                if not item and syncer.db and r.item_key:
                    item = syncer.db.get_item_by_key(r.item_key)

                parent_key = getattr(r, "parent_item_key", None)
                if not parent_key and syncer.processor:
                    meta = syncer.processor.get_metadata(r.item_key)
                    if isinstance(meta, dict):
                        parent_key = meta.get("parent_item_key")

                parent_item = None
                if parent_key and syncer.db:
                    parent_item = syncer.db.get_item_by_key(parent_key)

                raw_title = item.title if item else ""
                if (not raw_title or raw_title.lower() in ("snapshot", "full text", "attachment")) and parent_item:
                    raw_title = parent_item.title
                if not raw_title and syncer.processor:
                    meta = syncer.processor.get_metadata(r.item_key)
                    if isinstance(meta, dict) and meta.get("title"):
                        raw_title = meta["title"]
                title_cache[r.item_key] = raw_title or "Untitled Document"

                extracted_authors = getattr(item, "creators", []) or [] if item else []
                if not extracted_authors and parent_item:
                    extracted_authors = getattr(parent_item, "creators", []) or []
                if not isinstance(extracted_authors, (list, tuple)):
                    extracted_authors = []
                elif not extracted_authors and getattr(r, "creators", None):
                    extracted_authors = list(r.creators)

                # Fallback to document.md frontmatter for authors
                if not extracted_authors:
                    bundle_res = _resolve_bundle_dir(r.item_key)
                    if bundle_res:
                        doc_md = bundle_res[0] / "document.md"
                        if doc_md.exists():
                            try:
                                import yaml
                                lines = doc_md.read_text(encoding="utf-8").split("---", 2)
                                if len(lines) >= 3:
                                    fm = yaml.safe_load(lines[1])
                                    if isinstance(fm, dict) and fm.get("authors"):
                                        extracted_authors = fm["authors"]
                            except Exception:
                                pass
                authors_cache[r.item_key] = list(extracted_authors)

                extracted_year = getattr(item, "year", "") or "" if item else ""
                if not extracted_year and parent_item:
                    extracted_year = getattr(parent_item, "year", "") or ""
                if not isinstance(extracted_year, str):
                    extracted_year = ""
                elif not extracted_year and getattr(r, "year", None):
                    extracted_year = str(r.year)

                # Fallback to document.md frontmatter for year
                if not extracted_year:
                    bundle_res = _resolve_bundle_dir(r.item_key)
                    if bundle_res:
                        doc_md = bundle_res[0] / "document.md"
                        if doc_md.exists():
                            try:
                                import yaml
                                lines = doc_md.read_text(encoding="utf-8").split("---", 2)
                                if len(lines) >= 3:
                                    fm = yaml.safe_load(lines[1])
                                    if isinstance(fm, dict):
                                        if fm.get("year"):
                                            extracted_year = str(fm["year"])
                                        elif fm.get("date"):
                                            m = re.search(r"\b(19\d\d|20\d\d)\b", str(fm["date"]))
                                            if m:
                                                extracted_year = m.group(1)
                            except Exception:
                                pass
                year_cache[r.item_key] = str(extracted_year) if extracted_year else ""

            heading_str = " > ".join(r.heading_path) if r.heading_path else ""
            word_count = len(r.chunk_text.split()) if r.chunk_text else 0

            serialized.append(
                {
                    "chunk_id": r.chunk_id or f"{r.item_key}::{r.chunk_index}",
                    "item_key": r.item_key,
                    "title": title_cache[r.item_key],
                    "authors": authors_cache[r.item_key],
                    "year": year_cache[r.item_key],
                    "chunk_type": r.chunk_type,
                    "heading": heading_str,
                    "text": r.chunk_text,
                    "word_count": word_count,
                    "image_path": r.image_path,
                    "cosine_similarity": r.raw_semantic_score,
                    "rrf_score": r.score,
                    "line_start": r.line_start,
                    "line_end": r.line_end,
                }
            )

        return {"status": "success", "results": serialized}
    except Exception as exc:
        logger.error("Failed in search_corpus_core: %s", exc)
        return {"status": "error", "message": str(exc)}

sentence_attribution_endpoint async

sentence_attribution_endpoint(request)

Evaluate sentence-level attribution for a claim against source text.

Uses Option B (Pre-Segmented Numbered Spans) with nvidia/nemotron-3.5-lightning:free. When session_id is provided, updates matching assessment in sessions.db.

Parameters:

Name Type Description Default
request SentenceAttributionRequest

Sentence attribution request body.

required

Returns:

Type Description
dict[str, Any]

Dictionary with status, supporting_indices, spans,

dict[str, Any]

unsupported_aspects, model, and latency_ms.

Source code in zocrates/fast_api_app.py
@app.post("/citations/sentence_attribution", dependencies=[Depends(verify_auth)])
async def sentence_attribution_endpoint(request: SentenceAttributionRequest) -> dict[str, Any]:
    """Evaluate sentence-level attribution for a claim against source text.

    Uses Option B (Pre-Segmented Numbered Spans) with nvidia/nemotron-3.5-lightning:free.
    When session_id is provided, updates matching assessment in sessions.db.

    Args:
        request: Sentence attribution request body.

    Returns:
        Dictionary with status, supporting_indices, spans,
        unsupported_aspects, model, and latency_ms.
    """
    from zocrates.agents.library_search.sentence_attribution import (
        compute_sentence_attribution,
        persist_sentence_attribution_to_session,
    )
    from zocrates.config import (
        get_secure_api_key,
        get_sentence_attribution_model,
        load_zocrates_config,
    )

    try:
        syncer = get_syncer()
        zot_dir = getattr(syncer, "_zotero_data_dir", None)
    except Exception:
        zot_dir = None

    cfg = load_zocrates_config(zot_dir)
    model = get_sentence_attribution_model(cfg, zotero_data_dir=zot_dir)
    provider = cfg.get("llm_provider", "openrouter_llm")
    is_local = (
        provider in ("ollama", "vllm", "openai_compatible")
        or model.startswith(("ollama/", "ollama_chat/", "vllm/", "local/", "openai/"))
    )

    api_key = get_secure_api_key(
        "openrouter_api_key", ("OPENROUTER_API_KEY", "OPEN_ROUTER_API_KEY")
    )
    if not is_local and not api_key:
        raise HTTPException(
            status_code=400,
            detail="OpenRouter API key is not configured. Please configure it in Zotero Preferences -> Zocrates.",
        )

    base_url = None
    if is_local:
        from zocrates.llm import _format_base_url

        raw_url = cfg.get("llm_base_url")
        if provider == "ollama":
            base_url = _format_base_url(raw_url or "http://localhost:11434", "ollama")
        elif provider in ("vllm", "openai_compatible"):
            base_url = _format_base_url(raw_url or "http://localhost:8000/v1", provider)
        else:
            base_url = _format_base_url(raw_url or "http://localhost:11434", "ollama")

    result = await compute_sentence_attribution(
        claim=request.claim,
        source_text=request.source_text,
        api_key=api_key,
        model=model,
        base_url=base_url,
        zotero_data_dir=zot_dir,
    )

    if request.session_id:
        try:
            runner: Runner | None = getattr(app.state, "runner", None)
            session_service = (
                getattr(runner, "session_service", None)
                if runner
                else services.get_session_service(zot_dir)
            )
            app_name = getattr(app.state, "agent_app_name", "zocrates")
            await persist_sentence_attribution_to_session(
                session_id=request.session_id,
                invocation_id=request.invocation_id,
                chunk_index=request.chunk_index,
                occurrence=request.occurrence,
                claim=request.claim,
                attribution_data=result,
                session_service=session_service,
                app_name=app_name,
            )
        except Exception as persist_exc:
            logger.debug(
                "Could not persist sentence attribution to session %s: %s",
                request.session_id,
                persist_exc,
            )

    return result

serve_bundle_asset async

serve_bundle_asset(item_key, asset_path)

Serve a bundle asset file (image) for a given Zotero item key.

Resolves item_key to the markdown-cache bundle directory that is already managed by the active LibrarySyncer. The path is URL-decoded before resolution and validated against the bundle root to prevent traversal.

Only the extensions in _ASSET_EXTENSION_MEDIA_TYPES (.png, .jpg, .jpeg, .gif, .webp, .svg) are served; anything else returns 404.

Returns:

Type Description
Response

Raw image bytes with the appropriate Content-Type and a

Response

Cache-Control: max-age=3600 header (bundles are content-addressed

Response

by SHA-256 staleness tracking so the response is effectively immutable).

Raises:

Type Description
HTTPException 404

on unknown/invalid key, traversal attempt, missing bundle or file, or disallowed file extension.

Source code in zocrates/fast_api_app.py
@app.get("/assets/{item_key}/{asset_path:path}", dependencies=[Depends(verify_auth)])
async def serve_bundle_asset(item_key: str, asset_path: str) -> Response:
    """Serve a bundle asset file (image) for a given Zotero item key.

    Resolves ``item_key`` to the markdown-cache bundle directory that is already
    managed by the active ``LibrarySyncer``.  The path is URL-decoded before
    resolution and validated against the bundle root to prevent traversal.

    Only the extensions in ``_ASSET_EXTENSION_MEDIA_TYPES`` (.png, .jpg,
    .jpeg, .gif, .webp, .svg) are served; anything else returns 404.

    Returns:
        Raw image bytes with the appropriate Content-Type and a
        ``Cache-Control: max-age=3600`` header (bundles are content-addressed
        by SHA-256 staleness tracking so the response is effectively immutable).

    Raises:
        HTTPException 404: on unknown/invalid key, traversal attempt, missing
            bundle or file, or disallowed file extension.
    """
    # 1. Validate item_key to the existing alphanumeric-key grammar
    if not _validate_item_key(item_key):
        raise HTTPException(status_code=404, detail="Unknown item key.")

    # 2. Resolve cache root directly from the active syncer (LBYL — no fallback
    #    to on-demand note materialization; this is a pure byte-serving endpoint)
    try:
        syncer = get_syncer()
        cache_dir: Path = syncer.processor._cache_dir
    except RuntimeError:
        raise HTTPException(status_code=404, detail="Backend not registered.")

    bundle_dir = cache_dir / item_key
    if not bundle_dir.is_dir():
        try:
            from zocrates.agents.common.toolkit import _resolve_bundle_dir
            resolved_info = _resolve_bundle_dir(item_key)
            if resolved_info:
                bundle_dir = resolved_info[0]
            else:
                raise HTTPException(status_code=404, detail="Bundle not found.")
        except HTTPException:
            raise
        except Exception:
            raise HTTPException(status_code=404, detail="Bundle not found.")

    # 3. URL-decode the path segment (the evidence formatter percent-encodes
    #    spaces and other reserved characters for URL safety)
    decoded_path = urllib.parse.unquote(asset_path)

    # 4. Resolve and validate: block traversal attempts and absolute overrides
    resolved = (bundle_dir / decoded_path).resolve()
    if not resolved.is_relative_to(bundle_dir.resolve()):
        raise HTTPException(status_code=404, detail="Asset path not permitted.")

    # 5. Extension allowlist with explicit media_type mapping
    suffix = resolved.suffix.lower()
    media_type = _ASSET_EXTENSION_MEDIA_TYPES.get(suffix)
    if media_type is None:
        raise HTTPException(status_code=404, detail="File type not served.")

    # 6. File existence check
    if not resolved.is_file():
        raise HTTPException(status_code=404, detail="Asset not found.")

    # 7. Serve bytes with cache header (bundles are SHA-256 content-addressed)
    return Response(
        content=resolved.read_bytes(),
        media_type=media_type,
        headers={"Cache-Control": "max-age=3600"},
    )

setup_file_logging

setup_file_logging(log_dir)

Attach a RotatingFileHandler to the root logger.

Writes to <log_dir>/zocrates.log (10 MB per file, 5 backups). Safe to call multiple times — skips setup if the handler is already attached.

Parameters:

Name Type Description Default
log_dir Path

Directory in which to create zocrates.log. Must already exist (use get_log_dir() to ensure it is created).

required
Source code in zocrates/utils/telemetry.py
def setup_file_logging(log_dir: Path) -> None:
    """Attach a RotatingFileHandler to the root logger.

    Writes to ``<log_dir>/zocrates.log`` (10 MB per file, 5 backups).
    Safe to call multiple times — skips setup if the handler is already
    attached.

    Args:
        log_dir: Directory in which to create ``zocrates.log``. Must
            already exist (use ``get_log_dir()`` to ensure it is created).
    """
    log_path = log_dir / "zocrates.log"

    root_logger = logging.getLogger()

    # Avoid duplicate handlers if called more than once
    for handler in root_logger.handlers:
        if isinstance(handler, logging.handlers.RotatingFileHandler) and (
            getattr(handler, "baseFilename", None) == str(log_path)
        ):
            return

    handler = logging.handlers.RotatingFileHandler(
        log_path,
        maxBytes=10 * 1024 * 1024,  # 10 MB
        backupCount=5,
        encoding="utf-8",
    )
    handler.setFormatter(
        logging.Formatter(
            "%(asctime)s  %(levelname)-8s  %(name)s  %(message)s",
            datefmt="%Y-%m-%dT%H:%M:%S",
        )
    )
    handler.setLevel(logging.DEBUG)
    root_logger.addHandler(handler)
    logging.info("File logging enabled: %s", log_path)

setup_telemetry

setup_telemetry()

Configure GenAI prompt/response logging and distributed tracing via OpenTelemetry.

Sets up tracing via a local OTLP collector (e.g. Arize Phoenix) if LOCAL_TRACING=true, OTEL_EXPORTER_OTLP_ENDPOINT is set, or a local Phoenix server is running. When no local collector is available, tracing is left unconfigured (returns None).

When LOGS_BUCKET_NAME is set, prompt/response content capture is disabled (metadata-only logging mode for compliance).

Returns:

Type Description
str | None

The active telemetry endpoint or LOGS_BUCKET_NAME if enabled,

str | None

None otherwise.

Source code in zocrates/utils/telemetry.py
def setup_telemetry() -> str | None:
    """Configure GenAI prompt/response logging and distributed tracing via OpenTelemetry.

    Sets up tracing via a local OTLP collector (e.g. Arize Phoenix) if
    ``LOCAL_TRACING=true``, ``OTEL_EXPORTER_OTLP_ENDPOINT`` is set, or a local
    Phoenix server is running. When no local collector is available, tracing is
    left unconfigured (returns ``None``).

    When ``LOGS_BUCKET_NAME`` is set, prompt/response content capture is
    disabled (metadata-only logging mode for compliance).

    Returns:
        The active telemetry endpoint or ``LOGS_BUCKET_NAME`` if enabled,
        ``None`` otherwise.
    """
    global _telemetry_configured, _active_telemetry_endpoint
    if _telemetry_configured:
        return _active_telemetry_endpoint or os.environ.get("LOGS_BUCKET_NAME")

    # Ensure environment variables from .env are loaded
    load_dotenv()

    # Patch ADK experimental semconv to preserve thought=True in gen_ai.output.messages
    _patch_adk_semconv_to_part()

    # Capture full prompts/responses and tool payloads in trace span attributes for local debugging.
    os.environ.setdefault("ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS", "true")
    if os.environ.get("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "").lower() in ("true", "1"):
        os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "SPAN_AND_EVENT"
    else:
        os.environ.setdefault("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "SPAN_AND_EVENT")

    os.environ.setdefault("OTEL_SEMCONV_STABILITY_OPT_IN", "gen_ai_latest_experimental")
    os.environ.setdefault("ADK_EXPERIMENTAL_TELEMETRY", "true")

    bucket = os.environ.get("LOGS_BUCKET_NAME")
    capture_content = os.environ.get(
        "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "SPAN_AND_EVENT"
    )
    if bucket and capture_content != "false":
        logging.info(
            "Prompt-response logging enabled - mode: NO_CONTENT (metadata only, no prompts/responses)"
        )
        os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "NO_CONTENT"
        os.environ.setdefault("OTEL_INSTRUMENTATION_GENAI_UPLOAD_FORMAT", "jsonl")
        os.environ.setdefault("OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK", "upload")
        commit_sha = os.environ.get("COMMIT_SHA", "dev")
        os.environ.setdefault(
            "OTEL_RESOURCE_ATTRIBUTES",
            f"service.namespace=zocrates,service.version={commit_sha}",
        )
        path = os.environ.get("GENAI_TELEMETRY_PATH", "completions")
        os.environ.setdefault(
            "OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH",
            f"gs://{bucket}/{path}",
        )
    else:
        logging.info(
            "Prompt-response logging disabled (set LOGS_BUCKET_NAME=gs://your-bucket and OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=NO_CONTENT to enable)"
        )

    # Check for local OTLP collector settings (e.g. Arize Phoenix)
    explicit_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or os.environ.get(
        "PHOENIX_COLLECTOR_ENDPOINT"
    )
    local_tracing_env = os.environ.get("LOCAL_TRACING", "").strip().lower()
    is_explicit_disabled = local_tracing_env in ("false", "0", "no")
    is_explicit_enabled = local_tracing_env in ("true", "1", "yes")

    local_endpoint: str | None = None
    if not is_explicit_disabled:
        if explicit_endpoint:
            local_endpoint = explicit_endpoint
        elif is_explicit_enabled:
            # If Phoenix HTTP port 6006 is open, or gRPC 4317 is not open, default to Phoenix HTTP endpoint
            if _is_port_open("127.0.0.1", 6006) or not _is_port_open("127.0.0.1", 4317):
                local_endpoint = "http://localhost:6006/v1/traces"
            else:
                local_endpoint = "http://localhost:4317"
        elif _is_port_open("127.0.0.1", 6006):
            # Auto-detect running Arize Phoenix instance on default port 6006
            local_endpoint = "http://localhost:6006/v1/traces"
        elif _is_port_open("127.0.0.1", 4317):
            # Auto-detect running OTLP gRPC collector on port 4317
            local_endpoint = "http://localhost:4317"

    local_otel_configured = False
    if local_endpoint:
        # Normalize Phoenix HTTP endpoint if port 6006 was passed without /v1/traces
        if ("6006" in local_endpoint or "v1/traces" in local_endpoint) and not local_endpoint.endswith("/v1/traces"):
            local_endpoint = f"{local_endpoint.rstrip('/')}/v1/traces"

        try:
            from opentelemetry import trace
            from opentelemetry.sdk.resources import Resource
            from opentelemetry.sdk.trace import TracerProvider
            from opentelemetry.sdk.trace.export import BatchSpanProcessor

            project_name = (
                os.environ.get("PHOENIX_PROJECT_NAME")
                or os.environ.get("OTEL_SERVICE_NAME")
                or "zocrates"
            )

            resource = Resource.create(
                attributes={
                    "service.name": "zocrates",
                    "service.namespace": "local-dev",
                    "openinference.project.name": project_name,
                }
            )

            # Select protocol based on endpoint structure
            if "v1/traces" in local_endpoint or "6006" in local_endpoint:
                from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
                    OTLPSpanExporter,
                )
            else:
                from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
                    OTLPSpanExporter,
                )

            provider = TracerProvider(resource=resource)
            provider.add_span_processor(PhoenixEnrichmentSpanProcessor())
            exporter = OTLPSpanExporter(endpoint=local_endpoint)
            processor = BatchSpanProcessor(exporter)
            provider.add_span_processor(processor)
            trace.set_tracer_provider(provider)

            _active_telemetry_endpoint = local_endpoint
            _telemetry_configured = True
            atexit.register(flush_telemetry)

            logging.info(
                "Local OTLP tracing enabled. Exporting traces to: %s (project: %s)",
                local_endpoint,
                project_name,
            )
            local_otel_configured = True
        except Exception as err:
            logging.warning("Failed to initialize local OTLP tracing: %s", err)

    # Set up GenAI SDK instrumentation
    _setup_instrumentation_lib_if_installed()

    return bucket or (local_endpoint if local_otel_configured else None)

shutdown_endpoint async

shutdown_endpoint()

Gracefully terminate the Zocrates backend server.

Source code in zocrates/fast_api_app.py
@app.post("/shutdown", dependencies=[Depends(verify_auth)])
async def shutdown_endpoint() -> dict:
    """Gracefully terminate the Zocrates backend server."""
    logger.info("Received POST /shutdown request. Scheduling process termination.")
    loop = asyncio.get_running_loop()
    loop.call_later(0.5, _trigger_shutdown)
    return {"status": "ok", "message": "Backend shutting down"}

status async

status(request)

Retrieve the current initialization, hardware specs, and index status of Zocrates.

Returns:

Type Description
dict

Dict with keys status, zotero_data_dir, cached_documents_count,

dict

cached_document_keys, model, hardware, and embedding_info

dict

when authenticated, or a minimal safe probe payload when unauthenticated.

dict

status is "ready" if registered, "not_registered" otherwise.

Source code in zocrates/fast_api_app.py
@app.get("/status")
async def status(request: Request) -> dict:
    """Retrieve the current initialization, hardware specs, and index status of Zocrates.

    Returns:
        Dict with keys ``status``, ``zotero_data_dir``, ``cached_documents_count``,
        ``cached_document_keys``, ``model``, ``hardware``, and ``embedding_info``
        when authenticated, or a minimal safe probe payload when unauthenticated.
        ``status`` is ``"ready"`` if registered, ``"not_registered"`` otherwise.
    """
    project_dir = str(Path(__file__).resolve().parent.parent)
    if not _is_request_authenticated(request):
        try:
            syncer = get_syncer()
            zotero_dir = str(syncer._zotero_data_dir)
            registered = True
        except RuntimeError:
            zotero_dir = _status_zotero_data_dir_hint()
            registered = False
        return {
            "status": "ready" if registered else "not_registered",
            "zotero_data_dir": zotero_dir,
            "version": __version__,
        }

    from zocrates.rag.hardware import (
        get_docling_hardware_assessment,
        get_system_hardware_info,
    )

    hw_info = get_system_hardware_info().to_dict()
    hw_info["docling_assessment"] = get_docling_hardware_assessment()

    from zocrates.config import (
        DEFAULT_SENTENCE_ATTRIBUTION_MODEL,
        get_auto_render_mermaid_diagrams,
        get_auto_spawn_backend,
        get_backend_port,
        get_collection_name,
        get_compaction_config,
        get_conversion_timeout_seconds,
        get_datalab_api_key,
        get_datalab_merge_cross_page,
        get_datalab_non_pdf_fallback,
        get_docling_mode,
        get_enable_vlm_captioning,
        get_force_cpu_pref,
        get_ollama_num_ctx,
        get_openalex_api_key,
        get_scholar_polite_email,
        get_session_maintenance_config,
        get_vlm_captioning_backup_model,
        get_vlm_captioning_local_model,
        get_vlm_captioning_model,
        get_vlm_captioning_provider,
        get_vlm_max_images_per_doc,
        is_mcp_server_enabled,
        load_zocrates_config,
    )

    try:
        syncer = get_syncer()
        cfg = load_zocrates_config(syncer._zotero_data_dir)
        force_cpu = get_force_cpu_pref(syncer._zotero_data_dir, cfg=cfg)
        project_dir = str(Path(__file__).resolve().parent.parent)

        # Pre-populate dynamic config
        mode = os.getenv("EMBEDDING_MODE", cfg.get("embedding_provider", "local"))
        req_model = "jinaai/jina-embeddings-v5-text-nano"

        cached_keys = syncer.processor.list_cached_keys()
        embedder = syncer.store._embedder

        from zocrates.utils.session_maintenance import get_session_storage_stats
        session_db_path = Path(syncer._zotero_data_dir) / "zocrates-data" / "sessions.db"
        sess_service = services.get_session_service()
        app_name = getattr(app.state, "agent_app_name", "zocrates")
        try:
            session_storage_stats = await get_session_storage_stats(
                db_path=session_db_path,
                session_service=sess_service,
                app_name=app_name,
                user_id="default_user",
            )
        except Exception as exc:
            logger.debug("Failed to get session storage stats for status: %s", exc)
            session_storage_stats = {
                "size_bytes": 0,
                "session_count": 0,
                "freelist_pages": 0,
                "total_pages": 0,
                "page_size": 4096,
            }

        emb_info = {
            "mode": mode,
            "active_model": getattr(embedder, "_model_name", req_model),
            "requested_model": req_model,
            "dimension": getattr(embedder, "dimension", 768),
            "device": "Cloud API" if mode == "cloud" else "CPU",
            "is_fallback": False,
            "validation_error": None,
            "active_collection": syncer.store.active_collection_name,
            "force_cpu": True,
        }

        return {
            "status": "ready",
            "version": __version__,
            "zotero_data_dir": str(syncer._zotero_data_dir),
            "project_dir": project_dir,
            "cached_documents_count": len(cached_keys),
            "cached_document_keys": cached_keys,
            "model": cfg.get("llm_model") or DEFAULT_LLM_MODEL,
            "llm_provider": cfg.get("llm_provider") or "openrouter_llm",
            "document_converter_id": cfg.get("document_converter_id", "markitdown"),
            "document_converter_mode": cfg.get("document_converter_mode", "local"),
            "conversion_timeout_seconds": get_conversion_timeout_seconds(syncer._zotero_data_dir, cfg=cfg),
            "datalab_non_pdf_fallback": get_datalab_non_pdf_fallback(syncer._zotero_data_dir, cfg=cfg),
            "datalab_merge_cross_page": get_datalab_merge_cross_page(syncer._zotero_data_dir, cfg=cfg),
            "has_datalab_key": bool(get_datalab_api_key(syncer._zotero_data_dir)),
            "has_openalex_api_key": bool(get_openalex_api_key(syncer._zotero_data_dir)),
            "docling_mode": get_docling_mode(syncer._zotero_data_dir, cfg=cfg),
            "enable_vlm_captioning": get_enable_vlm_captioning(syncer._zotero_data_dir, cfg=cfg),
            "vlm_captioning_provider": get_vlm_captioning_provider(syncer._zotero_data_dir, cfg=cfg),
            "vlm_captioning_model": get_vlm_captioning_model(syncer._zotero_data_dir, cfg=cfg),
            "vlm_captioning_backup_model": get_vlm_captioning_backup_model(syncer._zotero_data_dir, cfg=cfg),
            "vlm_captioning_local_model": get_vlm_captioning_local_model(syncer._zotero_data_dir, cfg=cfg),
            "vlm_max_images_per_doc": get_vlm_max_images_per_doc(syncer._zotero_data_dir, cfg=cfg),
            "auto_render_mermaid_diagrams": get_auto_render_mermaid_diagrams(syncer._zotero_data_dir, cfg=cfg),
            "auto_spawn_backend": get_auto_spawn_backend(syncer._zotero_data_dir, cfg=cfg),
            "backend_port": get_backend_port(syncer._zotero_data_dir, cfg=cfg),
            "enable_mcp_server": is_mcp_server_enabled(syncer._zotero_data_dir, cfg=cfg),
            "scholar_polite_email": get_scholar_polite_email(syncer._zotero_data_dir, cfg=cfg),
            "sentence_attribution_model": cfg.get("sentence_attribution_model", DEFAULT_SENTENCE_ATTRIBUTION_MODEL),
            "user_research_profile": str(cfg.get("user_research_profile") or ""),
            "ollama_num_ctx": get_ollama_num_ctx(syncer._zotero_data_dir, cfg=cfg),
            "citation_verification_enabled": bool(cfg.get("citation_verification_enabled", False)),
            "citation_verification_provider": str(cfg.get("citation_verification_provider", "openrouter")),
            "citation_verification_model": str(cfg.get("citation_verification_model", DEFAULT_CITATION_VERIFICATION_MODEL)),
            "citation_verification_threshold": float(cfg.get("citation_verification_threshold", 0.60)),
            "citation_verification_local_url": str(cfg.get("citation_verification_local_url", "http://127.0.0.1:8005/v1/systemone")),
            "citation_verification_local_port": int(cfg.get("citation_verification_local_port", 8005)),
            "citation_verification_auto_start_local": bool(cfg.get("citation_verification_auto_start_local", True)),
            "citation_verification_timeout_sec": float(cfg.get("citation_verification_timeout_sec", 3.0)),
            "citation_verification_turn_budget_sec": float(cfg.get("citation_verification_turn_budget_sec", 10.0)),
            **get_compaction_config(syncer._zotero_data_dir, cfg=cfg),
            **get_session_maintenance_config(syncer._zotero_data_dir, cfg=cfg),
            "session_storage_stats": session_storage_stats,
            "hardware": hw_info,
            "embedding_info": emb_info,
        }
    except RuntimeError:
        cfg = load_zocrates_config()
        force_cpu = get_force_cpu_pref(cfg=cfg)
        project_dir = str(Path(__file__).resolve().parent.parent)

        # Pre-populate dynamic config
        mode = os.getenv("EMBEDDING_MODE", cfg.get("embedding_provider", "local"))
        req_model = "jinaai/jina-embeddings-v5-text-nano"

        dim = 768
        emb_info = {
            "mode": mode,
            "active_model": req_model,
            "requested_model": req_model,
            "dimension": dim,
            "device": "Cloud API" if mode == "cloud" else "CPU",
            "is_fallback": False,
            "validation_error": None,
            "active_collection": get_collection_name(req_model, dim),
            "force_cpu": True,
        }

        return {
            "status": "not_registered",
            "version": __version__,
            "project_dir": project_dir,
            "message": "Call /register with your Zotero data directory to initialize.",
            # Always report the data-dir hint so the Zotero launcher's port-probe
            # attach classifier can identify OUR backend even before /register
            # runs (a missing field was misclassified as a foreign service).
            "zotero_data_dir": _status_zotero_data_dir_hint(),
            "model": cfg.get("llm_model") or DEFAULT_LLM_MODEL,
            "llm_provider": cfg.get("llm_provider") or "openrouter_llm",
            "document_converter_id": cfg.get("document_converter_id", "markitdown"),
            "document_converter_mode": cfg.get("document_converter_mode", "local"),
            "datalab_non_pdf_fallback": get_datalab_non_pdf_fallback(cfg=cfg),
            "datalab_merge_cross_page": get_datalab_merge_cross_page(cfg=cfg),
            "has_datalab_key": bool(get_datalab_api_key()),
            "has_openalex_api_key": bool(get_openalex_api_key()),
            "docling_mode": get_docling_mode(cfg=cfg),
            "enable_vlm_captioning": get_enable_vlm_captioning(cfg=cfg),
            "vlm_captioning_provider": get_vlm_captioning_provider(cfg=cfg),
            "vlm_captioning_model": get_vlm_captioning_model(cfg=cfg),
            "vlm_captioning_backup_model": get_vlm_captioning_backup_model(cfg=cfg),
            "vlm_captioning_local_model": get_vlm_captioning_local_model(cfg=cfg),
            "vlm_max_images_per_doc": get_vlm_max_images_per_doc(cfg=cfg),
            "auto_render_mermaid_diagrams": get_auto_render_mermaid_diagrams(cfg=cfg),
            "auto_spawn_backend": get_auto_spawn_backend(cfg=cfg),
            "backend_port": get_backend_port(cfg=cfg),
            "enable_mcp_server": is_mcp_server_enabled(cfg=cfg),
            "scholar_polite_email": get_scholar_polite_email(cfg=cfg),
            "sentence_attribution_model": cfg.get("sentence_attribution_model", DEFAULT_SENTENCE_ATTRIBUTION_MODEL),
            "user_research_profile": str(cfg.get("user_research_profile") or ""),
            "ollama_num_ctx": get_ollama_num_ctx(cfg=cfg),
            "citation_verification_enabled": bool(cfg.get("citation_verification_enabled", False)),
            "citation_verification_provider": str(cfg.get("citation_verification_provider", "openrouter")),
            "citation_verification_model": str(cfg.get("citation_verification_model", DEFAULT_CITATION_VERIFICATION_MODEL)),
            "citation_verification_threshold": float(cfg.get("citation_verification_threshold", 0.60)),
            "citation_verification_local_url": str(cfg.get("citation_verification_local_url", "http://127.0.0.1:8005/v1/systemone")),
            "citation_verification_local_port": int(cfg.get("citation_verification_local_port", 8005)),
            "citation_verification_auto_start_local": bool(cfg.get("citation_verification_auto_start_local", True)),
            "citation_verification_timeout_sec": float(cfg.get("citation_verification_timeout_sec", 3.0)),
            "citation_verification_turn_budget_sec": float(cfg.get("citation_verification_turn_budget_sec", 10.0)),
            **get_compaction_config(cfg=cfg),
            **get_session_maintenance_config(cfg=cfg),
            "session_storage_stats": {
                "size_bytes": 0,
                "session_count": 0,
                "freelist_pages": 0,
                "total_pages": 0,
                "page_size": 4096,
            },
            "hardware": hw_info,
            "embedding_info": emb_info,
        }

suggest_next_prompt async

suggest_next_prompt(session_id, request)

Predict the next logical user prompt based on recent research turns.

Runs after turn finalization so it never stalls the main SSE stream.

Parameters:

Name Type Description Default
session_id str

The session whose research turn history is inspected.

required
request SuggestPromptRequest

Request containing the invocation_id of the turn that just ended.

required

Returns:

Type Description
dict[str, Any]

Dict with keys status (success, no_suggestion, disabled or

dict[str, Any]

error) and suggested_prompt (str or None). Responses from a

dict[str, Any]

predictor call also carry telemetry (model, latency, usage, cost) or None.

Source code in zocrates/fast_api_app.py
@app.post("/sessions/{session_id}/suggest_next_prompt", dependencies=[Depends(verify_auth)])
async def suggest_next_prompt(session_id: str, request: SuggestPromptRequest) -> dict[str, Any]:
    """Predict the next logical user prompt based on recent research turns.

    Runs after turn finalization so it never stalls the main SSE stream.

    Args:
        session_id: The session whose research turn history is inspected.
        request: Request containing the ``invocation_id`` of the turn that just ended.

    Returns:
        Dict with keys ``status`` (``success``, ``no_suggestion``, ``disabled`` or
        ``error``) and ``suggested_prompt`` (``str`` or ``None``). Responses from a
        predictor call also carry ``telemetry`` (model, latency, usage, cost) or ``None``.
    """
    from zocrates.agents.coordinator.predictor import predict_next_user_prompt
    from zocrates.config import get_secure_api_key, load_zocrates_config

    try:
        syncer = get_syncer()
    except RuntimeError as e:
        raise HTTPException(status_code=400, detail=str(e)) from e

    zot_dir = getattr(syncer, "_zotero_data_dir", None)
    cfg = load_zocrates_config(zot_dir)
    if not cfg.get("enable_predictive_ghost_input", DEFAULT_ENABLE_PREDICTIVE_GHOST_INPUT):
        return {"status": "disabled", "suggested_prompt": None}

    runner: Runner = getattr(app.state, "runner", None)
    session_service = (
        getattr(runner, "session_service", None) if runner else services.get_session_service()
    )
    if not session_service:
        return {"status": "error", "suggested_prompt": None}

    app_name = getattr(app.state, "agent_app_name", "zocrates")
    try:
        raw_sess = session_service.get_session(
            app_name=app_name, user_id="default_user", session_id=session_id
        )
        sess = await raw_sess if inspect.isawaitable(raw_sess) else raw_sess
    except Exception as exc:
        logger.warning("suggest_next_prompt could not load session %s: %s", session_id, exc)
        return {"status": "error", "suggested_prompt": None}
    if not sess or not getattr(sess, "state", None):
        return {"status": "no_suggestion", "suggested_prompt": None}

    # Exact invocation gate: the turn that just ended must have executed a research pipeline.
    history = list(sess.state.get(RESEARCH_TURN_HISTORY) or [])
    if not history or history[-1].get("invocation_id") != request.invocation_id:
        return {"status": "no_suggestion", "suggested_prompt": None}

    pred_model = cfg.get("predictive_prompt_model") or DEFAULT_PREDICTIVE_PROMPT_MODEL
    api_key = get_secure_api_key("openrouter_api_key", ("OPENROUTER_API_KEY", "OPEN_ROUTER_API_KEY"))
    is_local = pred_model.startswith(("ollama/", "ollama_chat/", "vllm/", "local/"))
    if not is_local and not api_key:
        return {"status": "disabled", "suggested_prompt": None}

    telemetry: dict[str, Any] = {}
    suggestion = await predict_next_user_prompt(
        research_history=history[-2:],
        model=pred_model,
        api_key=api_key,
        telemetry_out=telemetry,
    )
    logger.info(
        "Predictive next-prompt: %s in %.1fms (tokens=%d, cost=$%.6f)",
        suggestion,
        telemetry.get("latency_ms", 0),
        (telemetry.get("usage") or {}).get("total_tokens", 0),
        telemetry.get("cost_usd", 0.0),
    )
    if not suggestion:
        return {"status": "no_suggestion", "suggested_prompt": None, "telemetry": telemetry or None}

    return {"status": "success", "suggested_prompt": suggestion, "telemetry": telemetry or None}

sync_abort async

sync_abort()

Cooperatively cancel the active sync job (fail-open, deterministic abort).

Source code in zocrates/fast_api_app.py
@app.post("/sync/abort", dependencies=[Depends(verify_auth)])
async def sync_abort() -> dict:
    """Cooperatively cancel the active sync job (fail-open, deterministic abort)."""
    try:
        async with _sync_job_lock:
            job = _sync_job
        if job is None or not job.get("active", False):
            return {"status": "no_active_job"}
        job["cancel_event"].set()
        return {"status": "abort_requested"}
    except Exception as exc:
        logger.warning("Failed to request sync abort: %s", exc)
        return {"status": "no_active_job"}

sync_library async

sync_library(request)

Manually trigger an incremental sync or full library scan.

Raises:

Type Description
HTTPException

400 if the backend is not yet registered; 500 on unexpected sync failure.

Returns:

Type Description
dict

Dict with keys status, mode ("incremental" or "full"),

dict

and results.

Source code in zocrates/fast_api_app.py
@app.post("/sync", dependencies=[Depends(verify_auth)])
async def sync_library(request: SyncRequest) -> dict:
    """Manually trigger an incremental sync or full library scan.

    Raises:
        HTTPException: 400 if the backend is not yet registered; 500 on
            unexpected sync failure.

    Returns:
        Dict with keys ``status``, ``mode`` (``"incremental"`` or ``"full"``),
        and ``results``.
    """
    try:
        syncer = get_syncer()
        if request.item_ids:
            results = await syncer.ingest_items(request.item_ids)
            return {"status": "success", "mode": "incremental", "results": results}
        else:
            results = await syncer.full_sync()
            return {"status": "success", "mode": "full", "results": results}
    except RuntimeError as e:
        raise HTTPException(status_code=400, detail=str(e)) from e
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Sync failed: {e}") from e

sync_stream async

sync_stream(raw_request)

Start (or attach to) the SSE full-library sync job.

Streams throttled progress events (progress) plus a terminal event (done / aborted / auto_aborted / error). A second request while a job is running attaches to the running job's fan-out. Client disconnect cooperatively cancels the job.

Source code in zocrates/fast_api_app.py
@app.post("/sync/stream", dependencies=[Depends(verify_auth)])
async def sync_stream(raw_request: Request) -> EventSourceResponse:
    """Start (or attach to) the SSE full-library sync job.

    Streams throttled progress events (``progress``) plus a terminal event
    (``done`` / ``aborted`` / ``auto_aborted`` / ``error``). A second request
    while a job is running attaches to the running job's fan-out. Client
    disconnect cooperatively cancels the job.
    """
    global _sync_job
    try:
        get_syncer()
    except RuntimeError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    async with _sync_job_lock:
        job = _sync_job
        attached = job is not None and job.get("active", False)
        if not attached:
            job = {
                "task": None,
                "cancel_event": asyncio.Event(),
                "subscribers": set(),
                "state": {
                    "stage": "starting",
                    "processed": 0,
                    "skipped": 0,
                    "errors": 0,
                    "total": 0,
                    "current_title": "",
                },
                "started": time.perf_counter(),
                "terminal": None,
                "active": True,
            }
            _sync_job = job
            job["task"] = asyncio.create_task(_run_sync_job(job))

    queue: asyncio.Queue = asyncio.Queue()
    job["subscribers"].add(queue)
    queue.put_nowait({
        "event": "sync_start",
        "data": json.dumps({**_snapshot_sync_state(job), "attached": attached}),
    })
    return EventSourceResponse(
        _sync_stream_event_generator(job, queue, raw_request),
        client_close_handler_callable=_make_sync_client_close_handler(job, queue),
    )

test_converter_connection async

test_converter_connection(request)

Test connection and credentials for a document converter.

Source code in zocrates/fast_api_app.py
@app.post("/converters/test-connection", dependencies=[Depends(verify_auth)])
async def test_converter_connection(request: ConverterTestRequest) -> dict:
    """Test connection and credentials for a document converter."""
    from zocrates.config import get_datalab_api_key

    conv_id = request.converter_id
    if conv_id == "datalab":
        key = request.datalab_api_key or request.api_key or get_datalab_api_key()
        if not key or not str(key).strip():
            return {
                "status": "error",
                "message": "Datalab API key is missing. Please provide an API key.",
            }
        try:
            try:
                from datalab_sdk import DatalabAuthenticationError, DatalabClient
            except ImportError:
                from datalab_sdk import DatalabClient  # type: ignore
                DatalabAuthenticationError = ()  # type: ignore

            client = DatalabClient(api_key=str(key).strip())
            # Perform zero-credit authenticated live check against Datalab API
            try:
                await asyncio.to_thread(client.list_files, limit=1)
                return {
                    "status": "success",
                    "message": "Connected to Datalab API successfully. API key is valid.",
                }
            except Exception as net_err:
                # If network check failed due to invalid auth, report exact error
                err_str = str(net_err)
                err_lower = err_str.lower()
                is_auth_error = (
                    isinstance(net_err, DatalabAuthenticationError)
                    or type(net_err).__name__ == "DatalabAuthenticationError"
                    or "invalid api key" in err_lower
                    or "access token" in err_lower
                    or "unauthorized" in err_lower
                    or "401" in err_str
                    or "forbidden" in err_lower
                )
                if is_auth_error:
                    return {
                        "status": "error",
                        "message": f"Datalab API key rejected (Unauthorized): {err_str}",
                    }
                # Otherwise, if offline or temporary network issue, indicate SDK configured
                return {
                    "status": "success",
                    "message": f"Datalab SDK initialized with provided key (network probe notice: {err_str})",
                }
        except Exception as e:
            err_str = str(e)
            err_lower = err_str.lower()
            if (
                type(e).__name__ == "DatalabAuthenticationError"
                or "invalid api key" in err_lower
                or "access token" in err_lower
                or "unauthorized" in err_lower
                or "401" in err_str
                or "forbidden" in err_lower
            ):
                return {
                    "status": "error",
                    "message": f"Datalab API key rejected (Unauthorized): {err_str}",
                }
            return {
                "status": "error",
                "message": f"Datalab connection test failed: {e}",
            }
    elif conv_id in ("markitdown", "direct_read", "docling_local"):
        return {
            "status": "success",
            "message": f"Converter '{conv_id}' is available locally.",
        }
    else:
        return {
            "status": "error",
            "message": f"Unknown converter ID '{conv_id}'.",
        }

test_decision_model

test_decision_model(request=None)

Test connectivity to decision model (remote OpenRouter or local Laya).

Source code in zocrates/fast_api_app.py
@app.post("/llm/test_decision_model", dependencies=[Depends(verify_auth)])
def test_decision_model(request: DecisionModelTestRequest | None = None) -> dict:
    """Test connectivity to decision model (remote OpenRouter or local Laya)."""
    from zocrates.rag.decision_service import test_decision_model_connectivity

    if request:
        return test_decision_model_connectivity(
            provider=request.provider or "openrouter",
            model=request.model,
            local_url=request.local_url,
            openrouter_api_key=request.openrouter_api_key,
        )
    return test_decision_model_connectivity()

test_embedding async

test_embedding(request=None)

Test connectivity to the requested or currently configured embedding model.

Source code in zocrates/fast_api_app.py
@app.post("/llm/test_embedding", dependencies=[Depends(verify_auth)])
async def test_embedding(request: EmbeddingTestRequest | None = None) -> dict:
    """Test connectivity to the requested or currently configured embedding model."""
    from zocrates.llm import test_embedding_connection

    if request:
        return await test_embedding_connection(
            provider=request.embedding_provider,
            model=request.embedding_model,
            openrouter_api_key=request.openrouter_api_key,
        )
    return await test_embedding_connection()

test_llm async

test_llm(request=None)

Test connectivity to the requested or currently configured LLM.

Source code in zocrates/fast_api_app.py
@app.post("/llm/test", dependencies=[Depends(verify_auth)])
async def test_llm(request: LLMTestRequest | None = None) -> dict:
    """Test connectivity to the requested or currently configured LLM."""
    from zocrates.llm import test_llm_connection

    if request:
        return await test_llm_connection(
            provider=request.llm_provider,
            model=request.llm_model,
            base_url=request.llm_base_url,
            openrouter_api_key=request.openrouter_api_key,
        )
    return await test_llm_connection()

test_openalex async

test_openalex(request=None)

Test connection and credentials for OpenAlex API checking rate-limit.

Source code in zocrates/fast_api_app.py
@app.post("/discovery/test_openalex", dependencies=[Depends(verify_auth)])
async def test_openalex(request: OpenAlexTestRequest | None = None) -> dict[str, Any]:
    """Test connection and credentials for OpenAlex API checking rate-limit."""
    import httpx
    from zocrates.config import get_openalex_api_key, get_scholar_polite_email
    from zocrates.discovery.providers.base import make_user_agent

    key = (request.openalex_api_key or "").strip() if request and request.openalex_api_key else ""
    if not key:
        key = get_openalex_api_key() or ""

    if not key or not key.strip():
        return {
            "status": "error",
            "message": "OpenAlex API key is missing. Please provide an API key.",
        }

    clean_key = key.strip()
    headers = {
        "Authorization": f"Bearer {clean_key}",
        "User-Agent": make_user_agent(get_scholar_polite_email()),
        "Accept": "application/json",
    }

    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            resp = await client.get("https://api.openalex.org/rate-limit", headers=headers)
            if resp.status_code == 200:
                data = resp.json()
                return {
                    "status": "success",
                    "message": "Connected to OpenAlex API successfully. API key is valid.",
                    "rate_limit": data,
                }
            elif resp.status_code in (401, 403):
                return {
                    "status": "error",
                    "message": f"OpenAlex API key rejected (HTTP {resp.status_code}).",
                }
            else:
                return {
                    "status": "error",
                    "message": f"OpenAlex API check failed: HTTP {resp.status_code} - {resp.text[:200]}",
                }
    except Exception as exc:
        return {
            "status": "error",
            "message": f"Connection error: {exc}",
        }

test_openrouter_key async

test_openrouter_key(request=None)

Test validity and quota/usage of an OpenRouter API key.

Source code in zocrates/fast_api_app.py
@app.post("/llm/test_openrouter_key", dependencies=[Depends(verify_auth)])
async def test_openrouter_key(request: OpenRouterKeyTestRequest | None = None) -> dict[str, Any]:
    """Test validity and quota/usage of an OpenRouter API key."""
    import httpx
    from zocrates.config import get_secure_api_key

    key = (request.openrouter_api_key or "").strip() if request and request.openrouter_api_key else ""
    if not key:
        key = (get_secure_api_key("openrouter_api_key", ("OPENROUTER_API_KEY", "OPEN_ROUTER_API_KEY")) or "").strip()

    if not key:
        return {
            "status": "error",
            "message": "OpenRouter API key is missing. Please enter an API key.",
        }

    headers = {
        "Authorization": f"Bearer {key}",
        "HTTP-Referer": "https://github.com/alexplaka/Zocrates",
        "X-OpenRouter-Title": "Zocrates",
    }

    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            resp = await client.get("https://openrouter.ai/api/v1/auth/key", headers=headers)
            if resp.status_code == 200:
                payload = resp.json()
                data = payload.get("data", {}) if isinstance(payload, dict) else {}
                details_parts: list[str] = []
                if "limit" in data and data["limit"] is not None:
                    limit_val = data["limit"]
                    details_parts.append(f"Limit: ${limit_val}" if isinstance(limit_val, (int, float)) else f"Limit: {limit_val}")
                if "usage" in data and data["usage"] is not None:
                    usage_val = data["usage"]
                    details_parts.append(f"Usage: ${usage_val}" if isinstance(usage_val, (int, float)) else f"Usage: {usage_val}")

                details_str = f" ({', '.join(details_parts)})" if details_parts else ""
                return {
                    "status": "success",
                    "message": f"Connected to OpenRouter successfully. API key is valid.{details_str}",
                    "data": data,
                }
            elif resp.status_code == 401:
                err_detail = "Invalid API key"
                try:
                    err_json = resp.json()
                    if isinstance(err_json, dict):
                        if "error" in err_json:
                            err_obj = err_json["error"]
                            if isinstance(err_obj, dict):
                                err_detail = err_obj.get("message", str(err_obj))
                            else:
                                err_detail = str(err_obj)
                        elif "message" in err_json:
                            err_detail = err_json["message"]
                except Exception:
                    err_detail = resp.text[:200] or "Invalid credentials"
                return {
                    "status": "error",
                    "message": f"OpenRouter API key rejected (401 Unauthorized): {err_detail}",
                }
            else:
                return {
                    "status": "error",
                    "message": f"OpenRouter API check failed: HTTP {resp.status_code} - {resp.text[:200]}",
                }
    except Exception as exc:
        return {
            "status": "error",
            "message": f"Failed to connect to OpenRouter: {exc}",
        }

update_active_converter async

update_active_converter(request)

Dynamically switch the active document converter strategy and/or mode.

Persists the new converter ID, mode, and fallback to the Zocrates config file and updates the running LibrarySyncer's processor.

Source code in zocrates/fast_api_app.py
@app.post("/rag/converter", dependencies=[Depends(verify_auth)])
async def update_active_converter(request: ConverterChangeRequest) -> dict:
    """Dynamically switch the active document converter strategy and/or mode.

    Persists the new converter ID, mode, and fallback to the Zocrates config file and
    updates the running ``LibrarySyncer``'s processor.
    """
    try:
        from zocrates.config import (
            get_datalab_merge_cross_page,
            get_datalab_non_pdf_fallback,
            get_docling_mode,
            get_document_converter_id,
            get_document_converter_mode,
            get_enable_vlm_captioning,
            get_vlm_captioning_backup_model,
            get_vlm_captioning_local_model,
            get_vlm_captioning_model,
            get_vlm_captioning_provider,
            get_vlm_max_images_per_doc,
            save_zocrates_config,
        )

        target_id = request.document_converter_id or request.converter_id
        target_mode = request.document_converter_mode
        target_docling_mode = request.docling_mode
        target_datalab_fallback = request.datalab_non_pdf_fallback
        target_datalab_merge_cross_page = request.datalab_merge_cross_page
        target_datalab_key = request.datalab_api_key
        target_conversion_timeout = request.conversion_timeout_seconds

        target_vlm_enable = request.enable_vlm_captioning
        target_vlm_provider = request.vlm_captioning_provider
        target_vlm_model = request.vlm_captioning_model
        target_vlm_backup_model = request.vlm_captioning_backup_model
        target_vlm_local_model = request.vlm_captioning_local_model
        target_vlm_max_images = request.vlm_max_images_per_doc

        config_updates: dict[str, Any] = {}
        if target_id:
            config_updates["document_converter_id"] = target_id
        if target_mode:
            config_updates["document_converter_mode"] = target_mode
        if target_docling_mode:
            config_updates["docling_mode"] = target_docling_mode
        if target_datalab_fallback:
            config_updates["datalab_non_pdf_fallback"] = target_datalab_fallback
        if target_datalab_merge_cross_page is not None:
            config_updates["datalab_merge_cross_page"] = target_datalab_merge_cross_page
        if target_datalab_key is not None:
            config_updates["datalab_api_key"] = target_datalab_key
        if target_conversion_timeout is not None:
            config_updates["conversion_timeout_seconds"] = max(30, int(target_conversion_timeout))

        if target_vlm_enable is not None:
            config_updates["enable_vlm_captioning"] = target_vlm_enable
        if target_vlm_provider:
            config_updates["vlm_captioning_provider"] = target_vlm_provider
        if target_vlm_model:
            config_updates["vlm_captioning_model"] = target_vlm_model
        if target_vlm_backup_model:
            config_updates["vlm_captioning_backup_model"] = target_vlm_backup_model
        if target_vlm_local_model is not None:
            config_updates["vlm_captioning_local_model"] = target_vlm_local_model
        if target_vlm_max_images is not None:
            config_updates["vlm_max_images_per_doc"] = target_vlm_max_images

        if config_updates:
            save_zocrates_config(config_updates)

        if target_id:
            try:
                syncer = get_syncer()
                syncer.processor._document_converter_id = target_id
            except RuntimeError:
                pass

        if target_datalab_fallback:
            try:
                syncer = get_syncer()
                syncer.processor.datalab_non_pdf_fallback = target_datalab_fallback
            except RuntimeError:
                pass

        try:
            syncer = get_syncer()
            data_dir = syncer._zotero_data_dir
            current_id = (
                    syncer.processor._document_converter_id
                    or target_id
                    or get_document_converter_id(data_dir)
                    or "markitdown"
            )
            current_mode = target_mode or get_document_converter_mode(data_dir)
            current_docling = target_docling_mode or get_docling_mode(data_dir)
            current_datalab_fallback = (
                    getattr(syncer.processor, "datalab_non_pdf_fallback", None)
                    or target_datalab_fallback
                    or get_datalab_non_pdf_fallback(data_dir)
            )
        except RuntimeError:
            data_dir = None
            current_id = target_id or get_document_converter_id() or "markitdown"
            current_mode = target_mode or get_document_converter_mode()
            current_docling = target_docling_mode or get_docling_mode()
            current_datalab_fallback = target_datalab_fallback or get_datalab_non_pdf_fallback()

        return {
            "status": "success",
            "document_converter_id": current_id,
            "document_converter_mode": current_mode,
            "conversion_timeout_seconds": get_conversion_timeout_seconds(data_dir),
            "docling_mode": current_docling,
            "datalab_non_pdf_fallback": current_datalab_fallback,
            "datalab_merge_cross_page": get_datalab_merge_cross_page(data_dir),
            "enable_vlm_captioning": get_enable_vlm_captioning(data_dir),
            "vlm_captioning_provider": get_vlm_captioning_provider(data_dir),
            "vlm_captioning_model": get_vlm_captioning_model(data_dir),
            "vlm_captioning_backup_model": get_vlm_captioning_backup_model(data_dir),
            "vlm_captioning_local_model": get_vlm_captioning_local_model(data_dir),
            "vlm_max_images_per_doc": get_vlm_max_images_per_doc(data_dir),
        }
    except Exception as e:
        raise HTTPException(
            status_code=500, detail=f"Failed to update active converter: {e}"
        ) from e

update_config async

update_config(request)

Update LLM and embedding settings, save API key if provided, and hot-swap active agent models.

Source code in zocrates/fast_api_app.py
@app.post("/config", dependencies=[Depends(verify_auth)])
async def update_config(request: ConfigRequest) -> dict:
    """Update LLM and embedding settings, save API key if provided, and hot-swap active agent models."""
    from zocrates.agents.app import set_agent_models
    from zocrates.llm import get_llm_model
    from zocrates.config import (
        DEFAULT_SENTENCE_ATTRIBUTION_MODEL,
        get_secure_api_key,
        load_zocrates_config,
        save_openalex_api_key,
        save_secure_api_key,
        save_zocrates_config,
    )

    cfg = load_zocrates_config()
    updates = {}
    if request.llm_provider is not None:
        updates["llm_provider"] = request.llm_provider
    if request.llm_model is not None:
        updates["llm_model"] = request.llm_model
    if request.llm_base_url is not None:
        updates["llm_base_url"] = request.llm_base_url
    if request.llm_temperature is not None:
        updates["llm_temperature"] = request.llm_temperature
    if request.llm_max_tokens is not None:
        updates["llm_max_tokens"] = request.llm_max_tokens
    if request.llm_num_retries is not None:
        updates["llm_num_retries"] = request.llm_num_retries
    if request.llm_extra_options is not None:
        updates["llm_extra_options"] = request.llm_extra_options
    if request.ollama_num_ctx is not None:
        updates["ollama_num_ctx"] = request.ollama_num_ctx
    if request.override_agent_models is not None:
        updates["override_agent_models"] = request.override_agent_models
    if request.reranker_model is not None:
        updates["reranker_model"] = request.reranker_model
    if request.embedding_provider is not None:
        updates["embedding_provider"] = request.embedding_provider
        if request.embedding_provider == "local" and not request.embedding_model:
            updates["embedding_model"] = "jinaai/jina-embeddings-v5-text-nano"
        elif request.embedding_model:
            updates["embedding_model"] = request.embedding_model

    if request.openrouter_api_key is not None:
        save_secure_api_key(
            request.openrouter_api_key,
            key_name="openrouter_api_key",
            env_var="OPENROUTER_API_KEY",
        )
    if request.openalex_api_key is not None:
        save_openalex_api_key(request.openalex_api_key.strip())
    if request.datalab_api_key is not None:
        from zocrates.config import save_datalab_api_key
        save_datalab_api_key(request.datalab_api_key.strip() if request.datalab_api_key else None)
    if request.datalab_merge_cross_page is not None:
        updates["datalab_merge_cross_page"] = request.datalab_merge_cross_page
    if request.citations_enabled is not None:
        updates["citations_enabled"] = request.citations_enabled
    if request.references_section_enabled is not None:
        updates["references_section_enabled"] = request.references_section_enabled
    if request.auto_render_mermaid_diagrams is not None:
        updates["auto_render_mermaid_diagrams"] = request.auto_render_mermaid_diagrams
    if request.enable_mcp_server is not None:
        updates["enable_mcp_server"] = request.enable_mcp_server
    if request.enable_predictive_ghost_input is not None:
        updates["enable_predictive_ghost_input"] = request.enable_predictive_ghost_input
    if request.predictive_prompt_model is not None:
        updates["predictive_prompt_model"] = (
            request.predictive_prompt_model.strip() or DEFAULT_PREDICTIVE_PROMPT_MODEL
        )
    if request.sentence_attribution_model is not None:
        updates["sentence_attribution_model"] = request.sentence_attribution_model.strip()
    if request.citation_verification_enabled is not None:
        updates["citation_verification_enabled"] = request.citation_verification_enabled
    if request.citation_verification_provider is not None:
        updates["citation_verification_provider"] = request.citation_verification_provider
    if request.citation_verification_model is not None:
        updates["citation_verification_model"] = request.citation_verification_model
    if request.citation_verification_threshold is not None:
        updates["citation_verification_threshold"] = request.citation_verification_threshold
    if request.citation_verification_local_url is not None:
        updates["citation_verification_local_url"] = request.citation_verification_local_url
    if request.citation_verification_local_port is not None:
        updates["citation_verification_local_port"] = request.citation_verification_local_port
    if request.citation_verification_auto_start_local is not None:
        updates["citation_verification_auto_start_local"] = request.citation_verification_auto_start_local
    if request.citation_verification_timeout_sec is not None:
        updates["citation_verification_timeout_sec"] = request.citation_verification_timeout_sec
    if request.citation_verification_turn_budget_sec is not None:
        updates["citation_verification_turn_budget_sec"] = request.citation_verification_turn_budget_sec
    if request.scholar_polite_email is not None:
        updates["scholar_polite_email"] = request.scholar_polite_email.strip()
    if request.user_research_profile is not None:
        max_chars = int(cfg.get("max_research_profile_chars", 1000))
        if len(request.user_research_profile) > max_chars:
            raise HTTPException(
                status_code=400,
                detail=(
                    f"user_research_profile exceeds the maximum of {max_chars} characters "
                    f"({len(request.user_research_profile)} given)."
                ),
            )
        updates["user_research_profile"] = request.user_research_profile
    if request.enable_adaptive_compaction is not None:
        updates["enable_adaptive_compaction"] = request.enable_adaptive_compaction
    if request.adaptive_compaction_retention_turns is not None:
        updates["adaptive_compaction_retention_turns"] = request.adaptive_compaction_retention_turns
    if request.adaptive_compaction_min_turns is not None:
        updates["adaptive_compaction_min_turns"] = request.adaptive_compaction_min_turns
    if request.adaptive_compaction_token_floor is not None:
        updates["adaptive_compaction_token_floor"] = request.adaptive_compaction_token_floor
    if request.adaptive_compaction_token_ceiling is not None:
        updates["adaptive_compaction_token_ceiling"] = request.adaptive_compaction_token_ceiling
    if request.adaptive_compaction_threshold is not None:
        updates["adaptive_compaction_threshold"] = request.adaptive_compaction_threshold
    if request.adaptive_compaction_decision_model is not None:
        model_str = request.adaptive_compaction_decision_model.strip()
        from zocrates.config import DEFAULT_ADAPTIVE_COMPACTION_DECISION_MODEL, is_local_decision_model
        if is_local_decision_model(model_str):
            raise HTTPException(
                status_code=400,
                detail=(
                    "Local Laya is not supported for adaptive compaction decisions "
                    "due to capacity limits on multi-rule meta-rubrics. "
                    f"Please specify a remote decision model (e.g. '{DEFAULT_ADAPTIVE_COMPACTION_DECISION_MODEL}')."
                ),
            )
        updates["adaptive_compaction_decision_model"] = model_str
    if request.compaction_summarizer_model is not None:
        updates["compaction_summarizer_model"] = request.compaction_summarizer_model.strip()
    if request.max_saved_sessions is not None:
        updates["max_saved_sessions"] = request.max_saved_sessions
    if request.session_retention_days is not None:
        updates["session_retention_days"] = request.session_retention_days
    if request.session_db_auto_vacuum_on_startup is not None:
        updates["session_db_auto_vacuum_on_startup"] = request.session_db_auto_vacuum_on_startup
    if request.prune_empty_sessions is not None:
        updates["prune_empty_sessions"] = request.prune_empty_sessions
    if "backend_port" in request.model_fields_set:
        updates["backend_port"] = request.backend_port
    if request.auto_spawn_backend is not None:
        updates["auto_spawn_backend"] = request.auto_spawn_backend

    if updates:
        save_zocrates_config(updates)

    if (
        updates.get("citation_verification_enabled", cfg.get("citation_verification_enabled"))
        and updates.get("citation_verification_provider", cfg.get("citation_verification_provider")) == "local_laya"
        and updates.get("citation_verification_auto_start_local", cfg.get("citation_verification_auto_start_local", True))
    ):
        try:
            from zocrates.rag.decision_service import is_laya_installed, start_laya_daemon
            if is_laya_installed():
                port = int(updates.get("citation_verification_local_port", cfg.get("citation_verification_local_port", 8005)))
                await asyncio.to_thread(start_laya_daemon, port=port)
        except Exception as exc:
            logger.warning("Could not auto-start Laya daemon on config update: %s", exc)

    new_model = get_llm_model()
    set_agent_models(new_model)

    if request.embedding_provider is not None or request.embedding_model is not None:
        try:
            from zocrates.config import get_collection_name
            from zocrates.rag.embeddings import get_embedder

            new_embedder = get_embedder()
            active_col = get_collection_name(new_embedder._model_name, new_embedder.dimension)
            syncer = get_syncer()
            with syncer.store._rw_lock:
                syncer.store._embedder = new_embedder
                syncer.store.active_collection_name = active_col
                syncer.store._collection = syncer.store._client.get_or_create_collection(
                    name=active_col,
                    metadata={
                        "hnsw:space": "cosine",
                        "model_name": str(new_embedder._model_name),
                        "dimension": str(new_embedder.dimension),
                    },
                )
        except Exception as exc:
            logger.warning("Could not hot-swap embedder during update_config: %s", exc)

    cfg = load_zocrates_config()
    has_key = bool(get_secure_api_key("openrouter_api_key", ("OPENROUTER_API_KEY", "OPEN_ROUTER_API_KEY")))

    return {
        "status": "success",
        "config": {
            "llm_provider": cfg.get("llm_provider"),
            "llm_model": cfg.get("llm_model"),
            "llm_base_url": cfg.get("llm_base_url"),
            "llm_temperature": cfg.get("llm_temperature"),
            "llm_max_tokens": cfg.get("llm_max_tokens"),
            "llm_num_retries": cfg.get("llm_num_retries", DEFAULT_LLM_NUM_RETRIES),
            "override_agent_models": cfg.get("override_agent_models"),
            "reranker_model": cfg.get("reranker_model"),
            "embedding_provider": cfg.get("embedding_provider"),
            "embedding_model": cfg.get("embedding_model"),
            "theme": cfg.get("theme"),
            "auto_render_mermaid_diagrams": cfg.get("auto_render_mermaid_diagrams"),
            "enable_mcp_server": cfg.get("enable_mcp_server"),
            "enable_predictive_ghost_input": cfg.get("enable_predictive_ghost_input"),
            "predictive_prompt_model": cfg.get("predictive_prompt_model"),
            "sentence_attribution_model": cfg.get("sentence_attribution_model", DEFAULT_SENTENCE_ATTRIBUTION_MODEL),
            "datalab_merge_cross_page": cfg.get("datalab_merge_cross_page"),
            "user_research_profile": cfg.get("user_research_profile", ""),
            "citation_verification_enabled": cfg.get("citation_verification_enabled"),
            "citation_verification_provider": cfg.get("citation_verification_provider"),
            "citation_verification_model": cfg.get("citation_verification_model"),
            "citation_verification_threshold": cfg.get("citation_verification_threshold"),
            "citation_verification_local_url": cfg.get("citation_verification_local_url"),
            "citation_verification_local_port": cfg.get("citation_verification_local_port"),
            "citation_verification_auto_start_local": cfg.get("citation_verification_auto_start_local"),
            "citation_verification_timeout_sec": cfg.get("citation_verification_timeout_sec"),
            "citation_verification_turn_budget_sec": cfg.get("citation_verification_turn_budget_sec"),
            "has_openrouter_key": has_key,
        },
    }

update_embedding_model async

update_embedding_model(request)

Dynamically switch the embedding mode (local vs OpenRouter-hosted cloud).

Persists the new mode to the Zocrates config file and hot-swaps the embedder on the running LibrarySyncer without restarting the server.

Raises:

Type Description
HTTPException

400 on invalid dimension, 500 on unexpected error.

Returns:

Type Description
dict

Dict with keys status, active_model, dimension,

dict

active_collection, and force_cpu.

Source code in zocrates/fast_api_app.py
@app.post("/rag/model", dependencies=[Depends(verify_auth)])
async def update_embedding_model(request: EmbeddingModelChangeRequest) -> dict:
    """Dynamically switch the embedding mode (local vs OpenRouter-hosted cloud).

    Persists the new mode to the Zocrates config file and hot-swaps the
    embedder on the running ``LibrarySyncer`` without restarting the server.

    Raises:
        HTTPException: 400 on invalid dimension, 500 on unexpected error.

    Returns:
        Dict with keys ``status``, ``active_model``, ``dimension``,
        ``active_collection``, and ``force_cpu``.
    """
    if request.dimension is not None:
        if request.dimension > 4096 or request.dimension < 64:
            raise HTTPException(
                status_code=400,
                detail="Embedding dimension must be between 64 and 4096.",
            )
        target_mode = request.mode or os.getenv("EMBEDDING_MODE", "local")
        if target_mode == "local" and request.dimension != 768:
            raise HTTPException(
                status_code=400,
                detail="Local Jina embeddings model only supports dimension 768.",
            )

    try:
        from zocrates.config import (
            get_collection_name,
            load_zocrates_config,
            save_zocrates_config,
        )

        cfg = load_zocrates_config()
        mode = request.mode or os.getenv("EMBEDDING_MODE", cfg.get("embedding_provider", "local"))

        config_updates = {}
        if request.mode:
            config_updates["embedding_provider"] = request.mode
            os.environ["EMBEDDING_MODE"] = request.mode
        if request.model_name:
            config_updates["embedding_model"] = request.model_name

        if mode == "local" and request.dimension is not None:
            config_updates["embedding_dimension"] = 768
        elif mode != "local" and request.dimension:
            config_updates["embedding_dimension"] = request.dimension
        elif request.model_name and request.dimension is None:
            # Switch-time dimension resolution (Phase 7 Stage B): resolve the
            # new slug's dimension from the known-dimensions registry so a
            # model switch is correct from the first boot (no stale 768 from
            # the previous model, no stray wrongly-dimensioned collection).
            # Slugs absent from the registry fall back to first-embed
            # auto-detection, which persists the real dimension.
            from zocrates.rag.embeddings import KNOWN_EMBEDDING_DIMENSIONS

            norm_name = request.model_name.removeprefix("openrouter/").removeprefix("openai/")
            for known_key, known_dim in KNOWN_EMBEDDING_DIMENSIONS.items():
                if norm_name == known_key or norm_name.endswith(known_key):
                    config_updates["embedding_dimension"] = known_dim
                    break
        if request.cloud_api_key:
            config_updates["cloud_api_key"] = request.cloud_api_key
        if request.openrouter_api_key:
            config_updates["openrouter_api_key"] = request.openrouter_api_key

        if config_updates:
            save_zocrates_config(config_updates)

        cfg = load_zocrates_config()
        active_mode = os.getenv("EMBEDDING_MODE", cfg.get("embedding_provider", "local"))

        from zocrates.rag.embeddings import LocalEmbedder, OpenRouterEmbedder

        if active_mode == "cloud":
            kwargs_embed: dict[str, Any] = {
                "model_name": request.model_name or cfg.get("embedding_model")
            }
            if request.dimension is not None:
                kwargs_embed["dimension"] = request.dimension
            embedder = OpenRouterEmbedder(**kwargs_embed)
        else:
            embedder = LocalEmbedder()

        active_col = get_collection_name(embedder._model_name, embedder.dimension)

        try:
            syncer = get_syncer()
            with syncer.store._rw_lock:
                syncer.store._embedder = embedder
                syncer.store.active_collection_name = active_col
                syncer.store._collection = (
                    syncer.store._client.get_or_create_collection(
                        name=active_col,
                        metadata={
                            "hnsw:space": "cosine",
                            "model_name": str(embedder._model_name),
                            "dimension": str(embedder.dimension),
                        },
                    )
                )
        except RuntimeError as r_err:
            if "not registered" in str(r_err).lower():
                logger.info("Syncer not registered yet; embedder model config updated.")
            else:
                raise

        return {
            "status": "success",
            "requested_model": request.model_name or embedder._model_name,
            "active_model": embedder._model_name,
            "dimension": embedder.dimension,
            "is_fallback": False,
            "validation_error": None,
            "active_collection": active_col,
            "force_cpu": True,
        }
    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(
            status_code=500, detail=f"Failed to update embedding model: {e}"
        ) from e

verify_auth async

verify_auth(request)

FastAPI dependency enforcing valid local authentication token.

Source code in zocrates/fast_api_app.py
async def verify_auth(request: Request) -> None:
    """FastAPI dependency enforcing valid local authentication token."""
    if not _is_request_authenticated(request):
        raise HTTPException(status_code=401, detail="Unauthorized")