Files

2357 lines
95 KiB
Python

"""New doclib HTTP server implementation backed by the public interface."""
from __future__ import annotations
import asyncio
import math
import os
import re
import signal
import sys
import time
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
from urllib.parse import urlparse
from fastapi import APIRouter, FastAPI, Request
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from ..config import config
from ..errors import InvalidRequestError, MineruError, NotFoundError, error_response, http_status_for
from ..filetypes import IMAGE_EXTENSIONS, TEXT_EXTENSIONS, TEXT_FILE_TYPES
from ..parser.page_range import (
expand_page_range as _expand_page_range,
format_page_range as _page_numbers_to_range_str,
normalize_page_range_input,
normalize_result_page_range,
)
from ..parser.tier import TierDependencyError, ensure_tier_runtime_dependencies
from docvortex.render.markdown import build_markdown_image
from docvortex.render.markdown import render_single_block
from docvortex.assets import parse_image_data_uri_strict, transcode_image
from docvortex.content.tree import iter_image_payloads
from ..types import (
DEPLOYMENT_TIERS,
TIERS,
BlockBase,
BlockType,
DeploymentTier,
PageBlock,
PageInfo,
Tier,
select_highest_cached_tier,
)
from ..model.download import verify_model_repo
from ..model.registry import ModelRepo, model_repos_for_tier
from docvortex.document.pdf import PDFDocument
from ..version import __version__
from .background.parse_server_health import get_health, get_managed_parse_server_tier
from .base import AsyncDoclibInterface
from .config_schema import validate_config_value
from .core.db import DatabaseManager
from .locators import ContentCursor, block_char_ref, block_ref, page_ref, parse_content_cursor
from .remote_api import REMOTE_API_KEY_CONFIG, resolve_remote_api_key
from .rows import (
ContentSearchResultRow,
DocRow,
ErrorBucketRow,
ExcludeRuleRow,
FilenameSearchResultRow,
FileRow,
ParseBatchRow,
ParseRow,
ParsingRuleRow,
RecentScanRow,
Sha256Row,
ShortIdRow,
WatchParseCountRow,
WatchStatsFileRow,
WatchTargetRow,
)
from .services.parse_svc import (
_json_batch_is_readable,
accessible_file_for_sha256,
filter_pages_by_user_range,
load_pages_from_done_batches,
parse_page_range_set,
)
from .telemetry.buckets import pages_bucket, results_bucket
from .types import (
FILE_STATUS_ACTIVE,
FILE_STATUS_DELETED,
FILE_STATUS_UNREACHABLE,
PARSE_STATUS_DONE,
PARSE_STATUS_FAILED,
PARSE_STATUS_PARSING,
PARSE_STATUS_PENDING,
PARSE_STATUS_SUPERSEDED,
RULE_TYPE_EXCLUDE,
RULE_TYPE_PARSING_RULE,
SCAN_KIND_WATCH,
SCAN_SOURCE_WATCH,
CleanupDeletedRequest,
CleanupDeletedResponse,
CleanupOrphansRequest,
CleanupOrphansResponse,
CleanupTempRequest,
CleanupTempResponse,
ConfigResponse,
ConfigSetRequest,
ConfigSetResponse,
ConfigSource,
ConfigUnsetResponse,
ConfigValueResponse,
ContentAsset,
ContentFormat,
ContentNextRequest,
ContentRange,
ContentRequestScope,
DocContentExportRequest,
DocContentExportResponse,
DocContentResponse,
DocInfo,
ErrorBucket,
ErrorSummary,
ExcludeRuleInfo,
ExcludeRuleListResponse,
ExcludeRuleRequest,
FileInfo,
FileInfoResponse,
FileStatus,
FindResponse,
FindResult,
ForgetPathRequest,
ForgetPathResponse,
ImageFormat,
InvalidateRequest,
InvalidateResponse,
ListDocsResponse,
ListFilesResponse,
ListParsesResponse,
LocalParseServerStatus,
ParseCoverage,
ParseInfo,
ParseRequest,
ParseResponse,
ParseServerStatus,
ParseStatus,
ParsingRuleInfo,
ParsingRuleListResponse,
ParsingRuleRequest,
RemoteParseServerStatus,
RemoteUsageResponse,
RemoveExcludeRuleResponse,
RemoveParsingRuleResponse,
RemoveWatchResponse,
ScanInfo,
ScanKind,
ScanListResponse,
ScanRequest,
ScanStatus,
SearchResponse,
SearchResult,
ServerStatusResponse,
ShutdownResponse,
TCPServerStatus,
TelemetryAction,
TelemetryActionResponse,
TelemetryObservation,
TelemetryObservationsRequest,
TelemetryObservationsResponse,
TelemetryPayload,
TelemetryPreviewResponse,
TelemetryStatusResponse,
TierParseInfo,
WatchInfo,
WatchListResponse,
WatchRequest,
WatchStats,
WorkerStatus,
)
from .utils.route_utils import get_route_info, has_route_info, route
@dataclass(frozen=True)
class _RenderedContent:
content: str
content_ranges: list[ContentRange]
truncated: bool
last_page_no: int
next_page_no: int | None = None
cut_inside_page: bool = False
@dataclass(frozen=True)
class _ReadPlan:
sha256: str
short_id: str
tier: Tier
page_range: str | None
after: str | None
locator: str | None
context: int
limit: int
format: ContentFormat
no_marker: bool
image_format: ImageFormat = "jpeg"
target: ContentCursor | None = None
next_mode: str = "parse"
_NO_MATCHING_DOC_SHA256 = "__mineru_no_matching_doc__"
_VISUAL_BLOCK_LABELS = {
BlockType.IMAGE: "Image block",
BlockType.TABLE: "Table block",
BlockType.CHART: "Chart block",
BlockType.EQUATION: "Formula block",
}
@dataclass(frozen=True)
class _BlockImageSource:
kind: str
data_uri: str | None = None
@dataclass(frozen=True)
class _LocatorParts:
short_id: str
tier: Tier | None = None
page_no: int | None = None
block_no: int | None = None
char_offset: int | None = None
class DoclibServer(AsyncDoclibInterface):
"""Async doclib server whose routes are declared on interface methods."""
def __init__(self, state: Any, *, title: str = "MinerU Doclib API") -> None:
self.state = state
self.app = FastAPI(
title=title,
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
)
self.app.add_exception_handler(MineruError, _mineru_error_handler)
self.app.add_exception_handler(Exception, _unexpected_error_handler)
self.app.include_router(self._build_router())
def _build_router(self) -> APIRouter:
endpoints = []
for attr_name in dir(self):
endpoint = getattr(self, attr_name)
if callable(endpoint) and has_route_info(endpoint):
route_info = get_route_info(endpoint)
endpoints.append((route_info.index, route_info, endpoint))
router = APIRouter(prefix="/api/v1")
for _, route_info, endpoint in sorted(endpoints):
router.add_api_route(
path=route_info.path,
endpoint=endpoint,
methods=[route_info.method],
tags=list(route_info.tags),
)
return router
@route("GET", "/server/status", tags=("server",))
async def get_server_status(self) -> ServerStatusResponse:
files_total = await self.state.db.fetchone("SELECT COUNT(*) as cnt FROM files WHERE status=?", (FILE_STATUS_ACTIVE,))
docs_total = await self.state.db.fetchone("SELECT COUNT(*) as cnt FROM docs")
active_scans = await self.state.db.fetchone(
"SELECT COUNT(*) as cnt FROM scans WHERE status IN (?, ?)",
("pending", "running"),
)
last_scan = await self.state.db.fetchone(
"SELECT MAX(COALESCE(finished_at, updated_at)) as ts FROM scans WHERE status IN (?, ?)",
("done", "failed"),
)
ingest_q = await self.state.db.fetchone(
"SELECT COUNT(*) as cnt FROM files WHERE sha256 IS NULL AND status=? AND error_code IS NULL",
(FILE_STATUS_ACTIVE,),
)
local_mode = (await self.state.config_svc.get("parse_server.local.mode")) or "disabled"
managed_tier = await get_managed_parse_server_tier(self.state.config_svc)
self_hosted_url = await self.state.config_svc.get("parse_server.local.self_hosted_url")
remote_url = await self.state.config_svc.get("parse_server.remote.url")
watches = await self.state.config_svc.list_watches()
uptime = time.time() - self.state.start_time if hasattr(self.state, "start_time") else 0
sqlite_path = getattr(self.state, "sqlite_path", "")
log_path = getattr(self.state, "log_path", "")
access_log_path = getattr(self.state, "access_log_path", "")
stdout_log_path = getattr(self.state, "stdout_log_path", "")
stderr_log_path = getattr(self.state, "stderr_log_path", "")
parse_server_stdout_log_path = getattr(self.state, "parse_server_stdout_log_path", "")
parse_server_stderr_log_path = getattr(self.state, "parse_server_stderr_log_path", "")
sqlite_journal_mode = await _sqlite_journal_mode(self.state.db)
health = get_health()
return ServerStatusResponse(
running=True,
pid=getattr(self.state, "pid", os.getpid()),
server_id=getattr(self.state, "server_id", ""),
uptime_seconds=uptime,
mineru_home=getattr(self.state, "mineru_home", ""),
version=__version__,
python_version=sys.version.split()[0],
socket_path=getattr(self.state, "socket_path", ""),
data_dir=getattr(self.state, "data_dir", ""),
sqlite_path=sqlite_path,
log_path=log_path,
access_log_path=access_log_path,
stdout_log_path=stdout_log_path,
stderr_log_path=stderr_log_path,
tcp=TCPServerStatus(
enabled=bool(getattr(self.state, "tcp_enabled", False)),
host=getattr(self.state, "tcp_host", "") or None,
port=getattr(self.state, "tcp_port", None),
),
active_scan_count=active_scans["cnt"] if active_scans else 0,
last_scan_at=last_scan["ts"] if last_scan else None,
sqlite_journal_mode=sqlite_journal_mode,
sqlite_size_bytes=_file_size(sqlite_path),
sqlite_wal_size_bytes=_file_size(f"{sqlite_path}-wal") if sqlite_path else None,
workers=WorkerStatus(
watch_running=bool(getattr(self.state.watch, "running", False)),
scan_running=bool(getattr(self.state.scan_workers, "running", False)),
scan_workers=int(getattr(self.state.scan_workers, "num_workers", 0) or 0),
ingest_running=bool(getattr(self.state.ingest_workers, "running", False)),
ingest_workers=int(getattr(self.state.ingest_workers, "num_workers", 0) or 0),
parse_running=bool(getattr(self.state.parse_workers, "running", False)),
parse_workers=int(getattr(self.state.parse_workers, "num_workers", 0) or 0),
device_monitor_running=bool(getattr(self.state.device_monitor, "running", False)),
compaction_running=bool(getattr(self.state.compaction, "running", False)),
health_check_running=bool(getattr(self.state.health_check, "running", False)),
),
files_total=files_total["cnt"] if files_total else 0,
docs_total=docs_total["cnt"] if docs_total else 0,
parse_queue_length=await self.state.parse_svc.get_queue_length(),
ingest_queue_length=ingest_q["cnt"] if ingest_q else 0,
parse_server=_parse_server_status(
local_mode=local_mode,
managed_tier=cast(Tier, managed_tier),
self_hosted_url=self_hosted_url if self_hosted_url else None,
remote_url=remote_url if remote_url else None,
health=health,
),
watch_count=len(watches),
watches=[_watch_info(row) for row in watches],
watch_stats=await _watch_stats(self.state.db, watches),
recent_scans=await _recent_scans(self.state.db),
error_summary=await _error_summary(self.state.db),
recent_logs=_tail_log(log_path, lines=25),
app_logs=_tail_log(log_path, lines=25),
access_logs=_tail_log(access_log_path, lines=10),
stdout_logs=_tail_log(stdout_log_path, lines=10),
stderr_logs=_tail_log(stderr_log_path, lines=10),
parse_server_stdout_logs=_tail_log(parse_server_stdout_log_path, lines=10),
parse_server_stderr_logs=_tail_log(parse_server_stderr_log_path, lines=10),
)
@route("POST", "/server/shutdown", tags=("server",))
async def shutdown_server(self) -> ShutdownResponse:
if hasattr(self.state, "shutdown"):
result = self.state.shutdown()
if hasattr(result, "__await__"):
await result
else:
asyncio.create_task(_signal_shutdown())
return ShutdownResponse(accepted=True, message="Server shutting down...")
@route("POST", "/parses", tags=("parse",))
async def ensure_parse(self, request: ParseRequest) -> ParseResponse:
start_ms = _now_ms()
await _record_telemetry_count(self.state, "parse.request.count")
try:
result = await self.state.parse_svc.request_parse(
request.path,
tier=request.tier,
page_range=request.page_range,
force=request.force,
remote=request.remote,
)
response = ParseResponse.model_validate(result)
status = _parse_route_status(response)
dims = {"status": status, "tier": response.tier}
await _record_telemetry_count(self.state, "parse.finished.count", dimensions=dims)
await _record_telemetry_duration(self.state, "parse.duration_bucket.count", start_ms, dimensions=dims)
return response
except Exception as exc:
dims = {"status": "failed", "tier": request.tier or "default", "error_code": _telemetry_error_code(exc)}
await _record_telemetry_count(self.state, "parse.finished.count", dimensions=dims)
await _record_telemetry_duration(self.state, "parse.duration_bucket.count", start_ms, dimensions=dims)
raise
@route("GET", "/parses", tags=("parse",))
async def list_parses(
self,
*,
ids: list[int] | None = None,
doc_ref: str | None = None,
tier: Tier | None = None,
status: ParseStatus | None = None,
page_range: str | None = None,
include_superseded: bool = False,
limit: int = 50,
offset: int = 0,
) -> ListParsesResponse:
resolved_sha256 = await self._resolve_doc_filter_sha256(doc_ref)
rows, total, limit, offset = await self._select_parse_rows(
ids=ids,
sha256=resolved_sha256,
tier=tier,
status=status,
include_superseded=include_superseded,
limit=limit,
offset=offset,
)
page_range = normalize_page_range_input(page_range)
if resolved_sha256 and tier and page_range:
doc = await self.state.db.fetchone("SELECT * FROM docs WHERE sha256=?", (resolved_sha256,))
if doc is None:
raise NotFoundError("doc_not_found", f"Document {resolved_sha256} not found.", "doc_ref")
page_range = _expand_page_range(page_range, doc["page_count"] or 1)
coverage_rows = [
row
for row in rows
if row["status"] != PARSE_STATUS_DONE
or _json_batch_is_readable(_effective_data_dir(self.state), row["sha256"], row["tier"], row)
]
coverage = _parse_coverage(page_range, coverage_rows) if resolved_sha256 and tier and page_range else None
return ListParsesResponse(
parses=[_parse_info(row, coverage=coverage) for row in rows],
coverage=coverage,
total=total,
limit=limit,
offset=offset,
)
@route("GET", "/parses/{parse_id}", tags=("parse",))
async def get_parse(self, parse_id: int) -> ParseInfo:
row = await self.state.db.fetchone(
"SELECT p.*, d.short_id AS short_id FROM parses p JOIN docs d ON d.sha256=p.sha256 WHERE p.id=?",
(parse_id,),
)
if row is None:
raise NotFoundError("job_not_found", f"Parse {parse_id} not found.", "parse_id")
return _parse_info(row)
@route("POST", "/invalidate", tags=("parse",))
async def invalidate(self, request: InvalidateRequest) -> InvalidateResponse:
if request.target != "parses":
raise InvalidRequestError("invalid_request", f"Unsupported invalidate target: {request.target}", "target")
doc_row = await self._doc_for_ref(request.doc_ref, param="doc_ref") if request.doc_ref else None
file_row: FileRow | None = None
sha256 = doc_row["sha256"] if doc_row else None
if sha256 is None and request.path:
file_row = await self.state.parse_svc.ensure_ingested(request.path)
sha256 = file_row["sha256"] if file_row and file_row.get("sha256") else None
if sha256 is None:
raise NotFoundError("file_not_found", "Document not found.", "path")
if (file_row and file_row["ext"] in TEXT_EXTENSIONS) or (doc_row and doc_row["file_type"] in TEXT_FILE_TYPES):
raise InvalidRequestError(
"parse_not_required",
"Text files do not require MinerU parsing. Read the file directly.",
"doc_ref" if doc_row else "path",
)
short_id = doc_row["short_id"] if doc_row else await self._short_id_for_sha256(sha256)
count = await self.state.parse_svc.invalidate(sha256, request.tier)
return InvalidateResponse(
target=request.target, sha256=sha256, short_id=short_id, tier=request.tier, invalidated_count=count
)
@route("POST", "/forget", tags=("files",))
async def forget_path(self, request: ForgetPathRequest) -> ForgetPathResponse:
result = await self.state.cleanup_svc.forget_path(request.path, dry_run=request.dry_run)
return ForgetPathResponse.model_validate(result)
@route("POST", "/scans", tags=("scan",))
async def create_scan(self, request: ScanRequest) -> ScanInfo:
await _record_telemetry_count(self.state, "scan.request.count")
return await self.state.scan_svc.create_scan(
request.path,
kind=request.kind,
source=request.source,
watch_id=request.watch_id,
)
@route("GET", "/scans", tags=("scan",))
async def list_scans(
self,
*,
limit: int = 50,
status: ScanStatus | None = None,
kind: ScanKind | None = None,
watch_id: int | None = None,
offset: int = 0,
) -> ScanListResponse:
limit = max(1, min(limit, 200))
offset = max(0, offset)
total = await self.state.scan_svc.count_scans(status=status, kind=kind, watch_id=watch_id)
scans = await self.state.scan_svc.list_scans(
limit=limit,
status=status,
kind=kind,
watch_id=watch_id,
offset=offset,
)
return ScanListResponse(scans=scans, total=total, limit=limit, offset=offset)
@route("GET", "/scans/{scan_id}", tags=("scan",))
async def get_scan(self, scan_id: int) -> ScanInfo:
return await self.state.scan_svc.get_scan(scan_id)
@route("GET", "/files", tags=("files",))
async def list_files(
self,
*,
status: FileStatus | None = None,
ext: str | None = None,
watch_id: int | None = None,
limit: int = 200,
offset: int = 0,
) -> ListFilesResponse:
limit = max(1, min(limit, 200))
offset = max(0, offset)
file_status = status or FILE_STATUS_ACTIVE
clauses = ["f.status=?"]
params: list[Any] = [file_status]
if ext:
clauses.append("f.ext=?")
params.append(ext.lstrip(".").lower())
if watch_id is not None:
clauses.append("f.watch_id=?")
params.append(watch_id)
where = " AND ".join(clauses)
total_row = await self.state.db.fetchone(f"SELECT COUNT(*) AS cnt FROM files f WHERE {where}", tuple(params))
rows = cast(
list[FileRow],
await self.state.db.fetchall(
f"""
SELECT f.*, d.short_id AS short_id
FROM files f
LEFT JOIN docs d ON d.sha256=f.sha256
WHERE {where}
ORDER BY f.updated_at DESC LIMIT ? OFFSET ?
""",
(*params, limit, offset),
),
)
total = total_row["cnt"] if total_row else 0
return ListFilesResponse(files=[_file_info(row) for row in rows], total=total, limit=limit, offset=offset)
@route("GET", "/docs", tags=("docs",))
async def list_docs(
self,
*,
file_type: str | None = None,
limit: int = 200,
offset: int = 0,
) -> ListDocsResponse:
limit = max(1, min(limit, 200))
offset = max(0, offset)
clauses: list[str] = []
params: list[Any] = []
if file_type:
clauses.append("d.file_type=?")
params.append(file_type.lstrip(".").lower())
clauses.append("EXISTS (SELECT 1 FROM files f WHERE f.sha256=d.sha256 AND f.status=?)")
params.append(FILE_STATUS_ACTIVE)
where = " AND ".join(clauses)
total_row = await self.state.db.fetchone(f"SELECT COUNT(*) AS cnt FROM docs d WHERE {where}", tuple(params))
rows = await self.state.db.fetchall(
f"SELECT d.* FROM docs d WHERE {where} ORDER BY d.updated_at DESC LIMIT ? OFFSET ?",
(*params, limit, offset),
)
total = total_row["cnt"] if total_row else 0
return ListDocsResponse(docs=[_doc_info(row) for row in rows], total=total, limit=limit, offset=offset)
@route("GET", "/docs/by-path", tags=("docs",))
async def get_doc_by_path(self, path: str) -> DocInfo:
await self.state.parse_svc.ensure_ingested(path)
row = await self.state.db.fetchone(
"SELECT d.* FROM docs d JOIN files f ON f.sha256=d.sha256 WHERE f.path=? AND f.status=?",
(path, FILE_STATUS_ACTIVE),
)
if row is None:
raise NotFoundError("doc_not_found", f"Document for file {path} not found.", "path")
return _doc_info(row)
@route("GET", "/docs/{doc_ref}", tags=("docs",))
async def get_doc(self, doc_ref: str, *, expand_files: bool = False) -> DocInfo:
row = await self._doc_for_ref(doc_ref, param="doc_ref")
files = await self._files_for_sha256(row["sha256"]) if expand_files else None
return _doc_info(row, files=files)
@route("GET", "/docs/{doc_ref}/content", tags=("docs",))
async def get_doc_content(
self,
doc_ref: str,
*,
tier: Tier,
page_range: str | None = None,
after: str | None = None,
limit: int = 30000,
format: str = "markdown",
no_marker: bool = False,
) -> DocContentResponse:
start_ms = _now_ms()
dims = {"content_mode": "read", "output_format": _telemetry_output_format(format), "tier": tier}
await _record_telemetry_count(self.state, "content.request.count", dimensions=dims)
try:
response = await self._execute_read_plan(
await self._build_read_plan_from_parse(
doc_ref,
tier=tier,
page_range=page_range,
after=after,
limit=limit,
format=format,
no_marker=no_marker,
)
)
finished_dims = dims | {"status": "succeeded"}
await _record_telemetry_count(self.state, "content.finished.count", dimensions=finished_dims)
await _record_telemetry_duration(self.state, "content.duration_bucket.count", start_ms, dimensions=finished_dims)
return response
except Exception:
finished_dims = dims | {"status": "failed"}
await _record_telemetry_count(self.state, "content.finished.count", dimensions=finished_dims)
await _record_telemetry_duration(self.state, "content.duration_bucket.count", start_ms, dimensions=finished_dims)
raise
@route("GET", "/content", tags=("docs",))
async def read_content(
self,
locator: str,
*,
context: int = 0,
limit: int = 30000,
format: ContentFormat = "markdown",
image_format: ImageFormat = "jpeg",
no_marker: bool = False,
) -> DocContentResponse:
start_ms = _now_ms()
dims = {"content_mode": "read", "output_format": _telemetry_output_format(format), "tier": "unknown"}
await _record_telemetry_count(self.state, "content.request.count", dimensions=dims)
try:
response = await self._execute_read_plan(
await self._build_read_plan_from_locator(
locator,
context=context,
limit=limit,
format=format,
image_format=image_format,
no_marker=no_marker,
)
)
finished_dims = dims | {"status": "succeeded", "tier": response.tier}
await _record_telemetry_count(self.state, "content.finished.count", dimensions=finished_dims)
await _record_telemetry_duration(self.state, "content.duration_bucket.count", start_ms, dimensions=finished_dims)
return response
except Exception:
finished_dims = dims | {"status": "failed"}
await _record_telemetry_count(self.state, "content.finished.count", dimensions=finished_dims)
await _record_telemetry_duration(self.state, "content.duration_bucket.count", start_ms, dimensions=finished_dims)
raise
async def _build_read_plan_from_parse(
self,
doc_ref: str,
*,
tier: Tier,
page_range: str | None,
after: str | None,
limit: int,
format: str,
no_marker: bool,
) -> _ReadPlan:
if format != "markdown":
raise InvalidRequestError("invalid_request", "Only markdown is currently implemented for parse content.", "format")
limit = max(1, limit)
doc = await self._doc_for_ref(doc_ref, param="doc_ref")
normalized_page_range = _normalize_content_page_range(page_range, after, doc)
after_cursor = _parse_after_cursor(after)
if after_cursor:
_validate_cursor_for_doc(after_cursor, doc, tier, normalized_page_range)
return _ReadPlan(
sha256=doc["sha256"],
tier=tier,
short_id=doc["short_id"],
page_range=normalized_page_range,
after=after,
limit=limit,
format="markdown",
no_marker=no_marker,
locator=None,
context=0,
target=None,
next_mode="parse",
)
async def _build_read_plan_from_locator(
self,
locator: str,
*,
context: int,
limit: int,
format: ContentFormat,
image_format: ImageFormat,
no_marker: bool,
) -> _ReadPlan:
limit = max(1, limit)
context = max(0, context)
cursor = _parse_doc_locator(locator)
doc = cast(DocRow | None, await self.state.db.fetchone("SELECT * FROM docs WHERE short_id=?", (cursor.short_id,)))
if doc is None:
raise NotFoundError("doc_not_found", f"Document {cursor.short_id} not found.", "locator")
if doc["file_type"] in TEXT_FILE_TYPES:
raise InvalidRequestError(
"parse_not_required",
"Text files do not require MinerU parsing. Read the file directly.",
"locator",
)
tier = cursor.tier or await self._default_read_tier(doc["sha256"])
if tier is None:
raise NotFoundError(
"tier_not_cached",
f"No current parsed tier is cached for document {cursor.short_id}; reparse the source document.",
"locator",
)
if format == "image" and context:
raise InvalidRequestError("context_not_applicable", "context is not supported for image reads.", "context")
if cursor.page_no is None and context:
raise InvalidRequestError("context_not_applicable", "context requires a page, block, or char locator.", "context")
page_range = _locator_page_range(cursor, doc, context)
return _ReadPlan(
sha256=doc["sha256"],
short_id=doc["short_id"],
tier=tier,
page_range=page_range,
after=_locator_after(cursor, tier),
locator=_canonical_locator(doc["short_id"], tier, cursor),
context=context,
limit=limit,
format=format,
image_format=image_format,
no_marker=no_marker,
target=ContentCursor(
short_id=doc["short_id"],
tier=tier,
page_no=cursor.page_no or 1,
block_no=cursor.block_no,
char_offset=cursor.char_offset,
)
if cursor.page_no is not None
else None,
next_mode="read",
)
@route("POST", "/docs/{doc_ref}/exports", tags=("docs",))
async def export_doc_content(self, doc_ref: str, request: DocContentExportRequest) -> DocContentExportResponse:
start_ms = _now_ms()
dims = {"content_mode": "export", "output_format": _telemetry_output_format(request.format), "tier": request.tier}
await _record_telemetry_count(self.state, "content.request.count", dimensions=dims)
try:
doc = await self._doc_for_ref(doc_ref, param="doc_ref")
content = await self._render_doc_content(
doc["sha256"],
tier=request.tier,
page_range=request.page_range,
format=request.format,
no_marker=request.no_marker,
)
output_path = os.path.abspath(request.output)
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)
finished_dims = dims | {"status": "succeeded"}
await _record_telemetry_count(self.state, "content.finished.count", dimensions=finished_dims)
await _record_telemetry_duration(self.state, "content.duration_bucket.count", start_ms, dimensions=finished_dims)
return DocContentExportResponse(
sha256=doc["sha256"], short_id=doc["short_id"], tier=request.tier, output=output_path
)
except Exception:
finished_dims = dims | {"status": "failed"}
await _record_telemetry_count(self.state, "content.finished.count", dimensions=finished_dims)
await _record_telemetry_duration(self.state, "content.duration_bucket.count", start_ms, dimensions=finished_dims)
raise
@route("GET", "/search", tags=("search",))
async def search(
self,
query: str,
*,
file_type: str | None = None,
tier: Tier | None = None,
min_tier: Tier | None = None,
limit: int = 20,
offset: int = 0,
) -> SearchResponse:
start_ms = _now_ms()
await _record_telemetry_count(self.state, "search.request.count")
try:
results, total = await self.state.search_svc.search(
query=query,
file_type=file_type,
tier=tier,
min_tier=min_tier,
limit=limit,
offset=offset,
)
response = SearchResponse(results=[_search_result(row) for row in results], total=total, query=query)
dims = {"status": "succeeded"}
await _record_telemetry_count(self.state, "search.finished.count", dimensions=dims)
await _record_telemetry_duration(self.state, "search.duration_bucket.count", start_ms, dimensions=dims)
await _record_telemetry_count(
self.state, "search.results_bucket.count", dimensions={"bucket": results_bucket(total)}
)
return response
except Exception:
dims = {"status": "failed"}
await _record_telemetry_count(self.state, "search.finished.count", dimensions=dims)
await _record_telemetry_duration(self.state, "search.duration_bucket.count", start_ms, dimensions=dims)
raise
@route("GET", "/find", tags=("search",))
async def find(self, query: str, *, ext: str | None = None, limit: int = 50) -> FindResponse:
start_ms = _now_ms()
await _record_telemetry_count(self.state, "find.request.count")
try:
results, total = await self.state.search_svc.search_filenames(
query=query,
ext=ext,
limit=limit,
refresh_file=self.state.parse_svc.refresh_file,
)
response = FindResponse(results=[_find_result(row) for row in results], total=total, query=query)
dims = {"status": "succeeded"}
await _record_telemetry_count(self.state, "find.finished.count", dimensions=dims)
await _record_telemetry_duration(self.state, "find.duration_bucket.count", start_ms, dimensions=dims)
await _record_telemetry_count(self.state, "find.results_bucket.count", dimensions={"bucket": results_bucket(total)})
return response
except Exception:
dims = {"status": "failed"}
await _record_telemetry_count(self.state, "find.finished.count", dimensions=dims)
await _record_telemetry_duration(self.state, "find.duration_bucket.count", start_ms, dimensions=dims)
raise
@route("GET", "/files/by-path", tags=("files",))
async def get_file_by_path(self, path: str) -> FileInfoResponse:
await self.state.parse_svc.ensure_ingested(path)
file_row = await self.state.db.fetchone(
"""
SELECT f.*, d.short_id AS short_id
FROM files f
LEFT JOIN docs d ON d.sha256=f.sha256
WHERE f.path=? AND f.status=?
""",
(path, FILE_STATUS_ACTIVE),
)
if file_row is None:
raise NotFoundError("file_not_found", f"File record {path} not found in doclib.", "path")
sha256 = file_row["sha256"]
doc_row = await self.state.db.fetchone("SELECT * FROM docs WHERE sha256=?", (sha256,)) if sha256 else None
parse_rows = (
await self.state.db.fetchall(
"""
SELECT p.*, d.short_id AS short_id
FROM parses p
JOIN docs d ON d.sha256=p.sha256
WHERE p.sha256=?
ORDER BY p.tier, p.created_at DESC
""",
(sha256,),
)
if sha256
else []
)
parsed_tiers = [
_tier_parse_info(row)
for row in parse_rows
if row["status"] == PARSE_STATUS_DONE
and _json_batch_is_readable(_effective_data_dir(self.state), row["sha256"], row["tier"], row)
]
active_parses = [
_parse_info(row) for row in parse_rows if row["status"] in {PARSE_STATUS_PENDING, PARSE_STATUS_PARSING}
]
return FileInfoResponse(
file=_file_info(file_row),
doc=_doc_info(doc_row) if doc_row else None,
parsed_tiers=parsed_tiers,
active_parses=active_parses,
)
@route("GET", "/remote-usage", tags=("remote",))
async def get_remote_usage(self) -> RemoteUsageResponse:
from ..parser.api_client import MinerUApiParser, _APITransportError, _V1APIError
remote_url = cast(str, await self.state.config_svc.get("parse_server.remote.url"))
resolved_api_key = await resolve_remote_api_key(self.state.config_svc)
client = MinerUApiParser(api_url=remote_url, api_key=resolved_api_key.value)
try:
payload = await client.get_usage_async()
except _APITransportError as exc:
code = "remote_timeout" if exc.timed_out else "remote_unreachable"
raise MineruError(
code,
f"Remote API transport failed during {exc.stage} after {exc.attempts} attempt(s) ({type(exc.cause).__name__}).",
) from exc
except _V1APIError as exc:
raise MineruError(exc.code, exc.message, exc.param) from exc
try:
return RemoteUsageResponse.model_validate(payload)
except ValidationError as exc:
raise MineruError("api_error", "Remote API returned an invalid usage response.") from exc
@route("GET", "/configs", tags=("config",))
async def get_config(self) -> ConfigResponse:
config, sources = await self.state.config_svc.get_all_with_sources()
resolved_api_key = await resolve_remote_api_key(self.state.config_svc)
if resolved_api_key.value is not None:
config[REMOTE_API_KEY_CONFIG] = resolved_api_key.value
sources[REMOTE_API_KEY_CONFIG] = resolved_api_key.source
return ConfigResponse(config=_mask_config(config), sources=sources)
@route("GET", "/configs/{key}", tags=("config",))
async def get_config_key(self, key: str) -> ConfigValueResponse:
value, source = await self._effective_config_value(key)
return ConfigValueResponse(key=key, value=_mask_config_value(key, value or ""), source=source)
@route("PUT", "/configs/{key}", tags=("config",))
async def set_config(self, key: str, request: ConfigSetRequest) -> ConfigSetResponse:
await self._validate_config_set(key, request.value)
await self.state.config_svc.set(key, request.value)
value, source = await self._effective_config_value(key)
return ConfigSetResponse(key=key, value=_mask_config_value(key, value or ""), source=source)
async def _effective_config_value(self, key: str) -> tuple[str | None, ConfigSource]:
if key == REMOTE_API_KEY_CONFIG:
resolved = await resolve_remote_api_key(self.state.config_svc)
source = "default" if resolved.source == "anonymous" else resolved.source
return resolved.value, source
return await self.state.config_svc.get(key), await self.state.config_svc.get_source(key)
async def _validate_config_set(self, key: str, value: str) -> None:
validate_config_value(key, value)
if key == "parse_server.local.mode":
if value == "managed":
tier = await get_managed_parse_server_tier(self.state.config_svc)
_ensure_managed_parse_server_tier_available(tier, key)
return
if key == "parse_server.local.managed_tier":
tier = _validate_managed_parse_server_tier(value, key)
_ensure_managed_parse_server_tier_available(tier, key)
@route("DELETE", "/configs/{key}", tags=("config",))
async def unset_config(self, key: str) -> ConfigUnsetResponse:
removed = await self.state.config_svc.unset(key)
value, source = await self._effective_config_value(key)
return ConfigUnsetResponse(key=key, value=_mask_config_value(key, value or ""), source=source, removed=removed)
@route("POST", "/watches", tags=("watches",))
async def add_watch(self, request: WatchRequest) -> WatchInfo:
await _record_telemetry_count(self.state, "watch.add.count")
try:
row = await self.state.config_svc.add_watch(request.path, removable=request.removable, label=request.label)
scan_svc = getattr(self.state, "scan_svc", None)
if scan_svc is not None:
await scan_svc.create_scan(row["path"], kind=SCAN_KIND_WATCH, source=SCAN_SOURCE_WATCH, watch_id=row["id"])
watch_loop = getattr(self.state, "watch", None)
if watch_loop is not None:
watch_loop.wakeup()
await _record_telemetry_count(self.state, "watch.add.finished.count", dimensions={"status": "succeeded"})
return _watch_info(row)
except Exception:
await _record_telemetry_count(self.state, "watch.add.finished.count", dimensions={"status": "failed"})
raise
@route("GET", "/watches", tags=("watches",))
async def list_watches(self) -> WatchListResponse:
return WatchListResponse(watches=[_watch_info(row) for row in await self.state.config_svc.list_watches()])
@route("DELETE", "/watches/{watch_id}", tags=("watches",))
async def remove_watch(self, watch_id: int) -> RemoveWatchResponse:
await _record_telemetry_count(self.state, "watch.remove.count")
try:
existing = await self.state.db.fetchone("SELECT id FROM watches WHERE id=?", (watch_id,))
if existing is None:
raise NotFoundError("watch_not_found", f"Watch target {watch_id} not found.", "watch_id")
await self.state.config_svc.remove_watch_by_id(watch_id)
watch_loop = getattr(self.state, "watch", None)
if watch_loop is not None:
watch_loop.wakeup()
await _record_telemetry_count(self.state, "watch.remove.finished.count", dimensions={"status": "succeeded"})
return RemoveWatchResponse(watch_id=watch_id, removed=True)
except Exception:
await _record_telemetry_count(self.state, "watch.remove.finished.count", dimensions={"status": "failed"})
raise
@route("POST", "/exclude-rules", tags=("rules",))
async def add_exclude_rule(self, request: ExcludeRuleRequest) -> ExcludeRuleInfo:
rule_id = await self.state.config_svc.add_rule(
request.name or "",
RULE_TYPE_EXCLUDE,
request.pattern,
priority=request.priority,
)
row = await self.state.db.fetchone("SELECT * FROM exclude_rules WHERE id=?", (rule_id,))
return _exclude_rule_info(row)
@route("GET", "/exclude-rules", tags=("rules",))
async def list_exclude_rules(self) -> ExcludeRuleListResponse:
rows = await self.state.config_svc.list_rules(RULE_TYPE_EXCLUDE)
return ExcludeRuleListResponse(rules=[_exclude_rule_info(row) for row in rows])
@route("DELETE", "/exclude-rules/{rule_id}", tags=("rules",))
async def remove_exclude_rule(self, rule_id: int) -> RemoveExcludeRuleResponse:
existing = await self.state.db.fetchone("SELECT id FROM exclude_rules WHERE id=?", (rule_id,))
if existing is None:
raise NotFoundError("rule_not_found", f"Exclude rule {rule_id} not found.", "rule_id")
await self.state.config_svc.remove_rule(rule_id, RULE_TYPE_EXCLUDE)
return RemoveExcludeRuleResponse(rule_id=rule_id, removed=True)
@route("POST", "/parsing-rules", tags=("rules",))
async def add_parsing_rule(self, request: ParsingRuleRequest) -> ParsingRuleInfo:
rule_id = await self.state.config_svc.add_rule(
request.name or "",
RULE_TYPE_PARSING_RULE,
request.pattern,
tier=request.tier,
page_range=request.page_range,
remote=request.remote,
priority=request.priority,
)
row = await self.state.db.fetchone("SELECT * FROM parsing_rules WHERE id=?", (rule_id,))
return _parsing_rule_info(row)
@route("GET", "/parsing-rules", tags=("rules",))
async def list_parsing_rules(self) -> ParsingRuleListResponse:
rows = await self.state.config_svc.list_rules(RULE_TYPE_PARSING_RULE)
return ParsingRuleListResponse(rules=[_parsing_rule_info(row) for row in rows])
@route("DELETE", "/parsing-rules/{rule_id}", tags=("rules",))
async def remove_parsing_rule(self, rule_id: int) -> RemoveParsingRuleResponse:
existing = await self.state.db.fetchone("SELECT id FROM parsing_rules WHERE id=?", (rule_id,))
if existing is None:
raise NotFoundError("rule_not_found", f"Parsing rule {rule_id} not found.", "rule_id")
await self.state.config_svc.remove_rule(rule_id, RULE_TYPE_PARSING_RULE)
return RemoveParsingRuleResponse(rule_id=rule_id, removed=True)
@route("POST", "/cleanup/deleted-files", tags=("cleanup",))
async def cleanup_deleted_files(self, request: CleanupDeletedRequest) -> CleanupDeletedResponse:
count = await self.state.cleanup_svc.cleanup_deleted(dry_run=request.dry_run)
return CleanupDeletedResponse(deleted_files=count, dry_run=request.dry_run)
@route("POST", "/cleanup/orphan-docs", tags=("cleanup",))
async def cleanup_orphan_docs(self, request: CleanupOrphansRequest) -> CleanupOrphansResponse:
count = await self.state.cleanup_svc.cleanup_orphans(dry_run=request.dry_run)
return CleanupOrphansResponse(orphan_docs=count, dry_run=request.dry_run)
@route("POST", "/cleanup/temp", tags=("cleanup",))
async def cleanup_temp_files(self, request: CleanupTempRequest) -> CleanupTempResponse:
count = await self.state.cleanup_svc.cleanup_temp_files(older_than_days=request.older_than_days)
return CleanupTempResponse(temp_files_removed=count)
@route("GET", "/telemetry/status", tags=("telemetry",))
async def get_telemetry_status(self) -> TelemetryStatusResponse:
status = await self.state.telemetry_svc.status()
return TelemetryStatusResponse.model_validate(status)
@route("GET", "/telemetry/preview", tags=("telemetry",))
async def get_telemetry_preview(self) -> TelemetryPreviewResponse:
body = await self.state.telemetry_svc.preview_body()
return TelemetryPreviewResponse(body=TelemetryPayload.model_validate(body))
@route("POST", "/telemetry/actions/{action}", tags=("telemetry",))
async def telemetry_action(self, action: TelemetryAction) -> TelemetryActionResponse:
if action == "enable":
result = await self.state.telemetry_svc.set_consent("enabled")
return TelemetryActionResponse(action=action, **result)
if action == "disable":
result = await self.state.telemetry_svc.set_consent("disabled")
return TelemetryActionResponse(action=action, **result)
status = await self.state.telemetry_svc.status()
if status["state"] != "enabled":
return TelemetryActionResponse(
action=action,
state=status["state"],
installation_id=status["installation_id"],
executed=False,
reason="telemetry_not_enabled",
)
flush_result = await self.state.telemetry_svc.flush_once()
return TelemetryActionResponse(
action=action,
state=status["state"],
installation_id=status["installation_id"],
executed=flush_result.status not in {"disabled", "locked", "no_metrics", "failed"},
reason=None if flush_result.status == "success" else flush_result.status,
flush_result={
"status": flush_result.status,
"attempted": flush_result.attempted,
"succeeded": flush_result.succeeded,
"discarded": flush_result.discarded,
},
)
@route("POST", "/observations", tags=("telemetry",))
async def record_observations(self, request: TelemetryObservationsRequest) -> TelemetryObservationsResponse:
accepted = 0
for observation in request.observations:
if observation.metric_name == "parse.wait":
await self._record_parse_wait_observation(observation)
accepted += 1
continue
if observation.duration_ms is None:
await self.state.telemetry_svc.record_count(
observation.metric_name,
value=observation.value,
dimensions=observation.dimensions,
)
else:
await self.state.telemetry_svc.record_duration_bucket(
observation.metric_name,
duration_ms=observation.duration_ms,
dimensions=observation.dimensions,
)
accepted += 1
return TelemetryObservationsResponse(accepted=accepted)
async def _record_parse_wait_observation(self, observation: TelemetryObservation) -> None:
if observation.duration_ms is None or not observation.parse_ids:
return
placeholders = ",".join("?" * len(observation.parse_ids))
rows = cast(
list[ParseRow],
await self.state.db.fetchall(
f"SELECT * FROM parses WHERE id IN ({placeholders}) ORDER BY id ASC",
tuple(observation.parse_ids),
),
)
if not rows:
return
tier = rows[0]["tier"] if rows[0].get("tier") else "unknown"
page_numbers: set[int] = set()
for row in rows:
try:
page_numbers |= parse_page_range_set(row["page_range"])
except Exception:
continue
status = observation.dimensions.get("status") or "unknown"
dims = {
"status": status,
"tier": tier,
"pages_bucket": pages_bucket(len(page_numbers) or 1),
}
await self.state.telemetry_svc.record_count("parse.wait.count", dimensions=dims)
await self.state.telemetry_svc.record_duration_bucket(
"parse.wait_duration_bucket.count",
duration_ms=observation.duration_ms,
dimensions=dims,
)
async def _select_parse_rows(
self,
*,
ids: list[int] | None,
sha256: str | None,
tier: Tier | None,
status: ParseStatus | None,
include_superseded: bool,
limit: int,
offset: int,
) -> tuple[list[ParseRow], int, int, int]:
limit = max(1, min(limit, 200))
offset = max(0, offset)
clauses: list[str] = []
params: list[object] = []
if ids:
placeholders = ",".join("?" * len(ids))
rows = cast(
list[ParseRow],
await self.state.db.fetchall(
f"""
SELECT p.*, d.short_id AS short_id
FROM parses p
JOIN docs d ON d.sha256=p.sha256
WHERE p.id IN ({placeholders})
""",
tuple(ids),
),
)
by_id = {row["id"]: row for row in rows}
ordered = [by_id[parse_id] for parse_id in ids if parse_id in by_id]
return ordered[offset : offset + limit], len(ordered), limit, offset
if sha256:
clauses.append("p.sha256=?")
params.append(sha256)
if tier:
clauses.append("p.tier=?")
params.append(tier)
if status:
clauses.append("p.status=?")
params.append(status)
elif not include_superseded:
clauses.append("p.status!=?")
params.append(PARSE_STATUS_SUPERSEDED)
sql = "SELECT p.*, d.short_id AS short_id FROM parses p JOIN docs d ON d.sha256=p.sha256"
if clauses:
sql += " WHERE " + " AND ".join(clauses)
count_sql = "SELECT COUNT(*) AS cnt FROM parses p JOIN docs d ON d.sha256=p.sha256"
if clauses:
count_sql += " WHERE " + " AND ".join(clauses)
total_row = await self.state.db.fetchone(count_sql, tuple(params))
sql += " ORDER BY p.created_at DESC LIMIT ? OFFSET ?"
rows = cast(list[ParseRow], await self.state.db.fetchall(sql, (*params, limit, offset)))
total = total_row["cnt"] if total_row else 0
return rows, total, limit, offset
async def _render_doc_content(
self,
sha256: str,
*,
tier: Tier,
page_range: str | None,
format: str,
no_marker: bool,
) -> str:
if format != "markdown":
raise InvalidRequestError("invalid_request", "Only markdown is currently implemented.", "format")
data_dir = _effective_data_dir(self.state)
rows = await self.state.db.fetchall(
"SELECT page_range, done_at FROM parses WHERE sha256=? AND tier=? AND status=? ORDER BY done_at DESC",
(sha256, tier, PARSE_STATUS_DONE),
)
doc = cast(DocRow | None, await self.state.db.fetchone("SELECT * FROM docs WHERE sha256=?", (sha256,)))
if doc is None:
raise NotFoundError("doc_not_found", f"Document {sha256} not found.", "doc_ref")
page_range = normalize_page_range_input(page_range)
if page_range and doc["page_count"] is not None:
page_range = _expand_page_range(page_range, doc["page_count"])
requested = parse_page_range_set(page_range) if page_range and page_range != "all" else None
loaded_pages = load_pages_from_done_batches(
data_dir,
sha256,
tier,
cast(list[ParseBatchRow], rows),
requested_page_numbers=requested,
)
if requested is not None:
loaded_pages = [page for page in loaded_pages if page.page_idx + 1 in requested]
if not loaded_pages:
raise NotFoundError("not_cached", "Requested parsed content is not cached.", "page_range")
output: list[str] = []
page_count = doc["page_count"]
for page in loaded_pages:
if not no_marker:
output.append(_page_marker(page.page_idx + 1, page_count))
output.extend(
text
for _, text in _page_markdown_blocks(
page,
short_id=doc["short_id"],
tier=tier,
)
)
return "\n\n".join(output)
async def _execute_read_plan(self, plan: _ReadPlan) -> DocContentResponse:
data_dir = _effective_data_dir(self.state)
rows = await self.state.db.fetchall(
"SELECT page_range, done_at FROM parses WHERE sha256=? AND tier=? AND status=? ORDER BY done_at DESC",
(plan.sha256, plan.tier, PARSE_STATUS_DONE),
)
loaded_pages = load_pages_from_done_batches(
data_dir,
plan.sha256,
plan.tier,
cast(list[ParseBatchRow], rows),
requested_page_numbers=parse_page_range_set(plan.page_range) if plan.page_range else None,
)
if plan.page_range:
loaded_pages = filter_pages_by_user_range(loaded_pages, plan.page_range)
if not loaded_pages:
raise NotFoundError("not_cached", "Requested parsed content is not cached.", "page_range")
doc = cast(DocRow | None, await self.state.db.fetchone("SELECT * FROM docs WHERE sha256=?", (plan.sha256,)))
if plan.format == "image":
return await self._render_image_response(plan, loaded_pages)
pages_for_render = _select_context_pages(loaded_pages, plan.target, plan.context)
rendered = _render_progressive_markdown(
pages_for_render,
short_id=plan.short_id,
tier=plan.tier,
after=_parse_after_cursor(plan.after),
limit=plan.limit,
add_markers=not plan.no_marker,
target=plan.target,
context=plan.context,
page_count=doc["page_count"] if doc else None,
)
if not rendered.content_ranges:
raise NotFoundError("not_cached", "Requested parsed content is not cached after cursor.", "after")
page_count = doc["page_count"] if doc else None
paginated = _is_paginated_doc(doc) if doc else True
next_request = (
_next_read_request(rendered, plan.short_id, plan.tier, page_count)
if plan.next_mode == "read"
else _next_content_request(
rendered=rendered,
request_page_range=plan.page_range,
after=plan.after,
page_count=page_count,
paginated=paginated,
)
)
return DocContentResponse(
sha256=plan.sha256,
short_id=plan.short_id,
tier=plan.tier,
format="markdown",
content=rendered.content,
request_scope=ContentRequestScope(
page_range=plan.page_range,
after=plan.after,
limit=plan.limit,
locator=plan.locator,
context=plan.context,
),
content_ranges=rendered.content_ranges,
truncated=rendered.truncated,
next_request=next_request,
)
async def _render_image_response(
self,
plan: _ReadPlan,
loaded_pages: list[PageInfo],
) -> DocContentResponse:
if plan.target is None or plan.target.page_no is None:
raise InvalidRequestError("format_not_supported", "image format requires a page or block locator.", "format")
if plan.page_range and len(parse_page_range_set(plan.page_range)) != 1:
raise InvalidRequestError("multi_page_image_not_supported", "image format supports only one page.", "locator")
page = _find_page(loaded_pages, plan.target.page_no)
if page is None:
raise NotFoundError("page_not_cached", f"Page {plan.target.page_no} is not cached.", "locator")
if plan.target.block_no is None:
asset = await self._render_source_image_asset(plan, page)
else:
block = _find_block_by_no(page, plan.target.block_no)
if block is None:
raise NotFoundError("block_not_found", f"Block {plan.target.block_no} not found.", "locator")
source = _resolve_block_image_source(block)
if source is None:
raise NotFoundError("asset_not_available", "Block image asset is not available.", "locator")
if source.kind == "source_bbox":
asset = await self._render_source_image_asset(plan, page)
else:
assert source.data_uri is not None
asset = self._render_base64_image_asset(plan, source.data_uri)
target_ref = plan.locator or page_ref(plan.short_id, plan.tier, plan.target.page_no)
return DocContentResponse(
sha256=plan.sha256,
short_id=plan.short_id,
tier=plan.tier,
format="image",
content="",
request_scope=ContentRequestScope(
page_range=plan.page_range,
after=plan.after,
limit=plan.limit,
locator=plan.locator,
context=plan.context,
image_format=plan.image_format,
),
content_ranges=[ContentRange(page_range=str(plan.target.page_no), start=target_ref, end=target_ref)],
truncated=False,
next_request=None,
asset=asset,
)
async def _sha256_for_path(self, path: str) -> str | None:
row = cast(
Sha256Row | None,
await self.state.db.fetchone("SELECT sha256 FROM files WHERE path=? AND status=?", (path, FILE_STATUS_ACTIVE)),
)
return row["sha256"] if row and row["sha256"] else None
async def _doc_for_ref(self, doc_ref: str, *, param: str) -> DocRow:
row = await self._maybe_doc_for_ref(doc_ref)
if row is None:
raise NotFoundError("doc_not_found", f"Document {doc_ref} not found.", param)
return row
async def _maybe_doc_for_ref(self, doc_ref: str) -> DocRow | None:
row = cast(DocRow | None, await self.state.db.fetchone("SELECT * FROM docs WHERE sha256=?", (doc_ref,)))
if row is not None:
return row
return cast(DocRow | None, await self.state.db.fetchone("SELECT * FROM docs WHERE short_id=?", (doc_ref,)))
async def _resolve_doc_filter_sha256(self, doc_ref: str | None) -> str | None:
if doc_ref is None:
return None
doc = await self._maybe_doc_for_ref(doc_ref)
if doc is None:
return _NO_MATCHING_DOC_SHA256
return doc["sha256"]
async def _short_id_for_sha256(self, sha256: str) -> str:
row = cast(ShortIdRow | None, await self.state.db.fetchone("SELECT short_id FROM docs WHERE sha256=?", (sha256,)))
if row is None or not row["short_id"]:
raise NotFoundError("doc_not_found", f"Document {sha256} not found.", "sha256")
return row["short_id"]
async def _files_for_sha256(self, sha256: str) -> list[FileInfo]:
rows = cast(
list[FileRow],
await self.state.db.fetchall(
"""
SELECT f.*, d.short_id AS short_id
FROM files f
JOIN docs d ON d.sha256=f.sha256
WHERE f.sha256=? AND f.status=?
""",
(sha256, FILE_STATUS_ACTIVE),
),
)
return [_file_info(row) for row in rows]
async def _default_read_tier(self, sha256: str) -> Tier | None:
rows = cast(
list[ParseRow],
await self.state.db.fetchall(
"SELECT * FROM parses WHERE sha256=? AND status=? ORDER BY done_at DESC",
(sha256, PARSE_STATUS_DONE),
),
)
tiers: set[Tier] = {
row["tier"]
for row in rows
if row["tier"] in TIERS
and row["tier"] != "flash"
and _json_batch_is_readable(_effective_data_dir(self.state), sha256, row["tier"], row)
}
if not tiers:
return None
return select_highest_cached_tier(tiers)
async def _render_source_image_asset(self, plan: _ReadPlan, page: PageInfo) -> ContentAsset:
"""查找源文件并调用公共图像输出,保留 Doclib 定位与错误协议。"""
if plan.target is None:
raise InvalidRequestError("format_not_supported", "image format requires a page or block locator.", "format")
file_row = await accessible_file_for_sha256(self.state.db, plan.sha256)
if file_row is None:
raise NotFoundError("no_accessible_file", "No active source file found for this document.", "locator")
source_path = file_row["path"]
source_ext = str(file_row.get("ext") or Path(source_path).suffix).lower().lstrip(".")
source_bytes = Path(source_path).read_bytes()
if source_ext == "pdf":
doc = PDFDocument(source_bytes)
elif source_ext in IMAGE_EXTENSIONS:
doc = PDFDocument.from_image(source_bytes)
else:
raise InvalidRequestError(
"format_not_supported",
"Page and bbox image output is only supported for PDF and image sources.",
"format",
)
with doc:
bbox = None
if plan.target.block_no is not None:
block = _find_block_by_no(page, plan.target.block_no)
if block is None:
raise NotFoundError("block_not_found", f"Block {plan.target.block_no} not found.", "locator")
if block.bbox is None or _is_empty_bbox(block.bbox):
raise InvalidRequestError("bbox_not_available", "Block bbox is not available for image output.", "locator")
bbox = block.bbox
artifact = doc.render_image(plan.target.page_no - 1, bbox=bbox, image_format=plan.image_format)
return _write_temp_asset(
self.state.data_dir,
plan.short_id,
artifact.extension,
artifact.data,
mime_type=artifact.mime_type,
width=artifact.width,
height=artifact.height,
)
def _render_base64_image_asset(self, plan: _ReadPlan, data_uri: str) -> ContentAsset:
"""通过公共素材接口解码并转码,Doclib 只负责临时资产写出。"""
image_bytes, _extension = parse_image_data_uri_strict(data_uri)
artifact = transcode_image(image_bytes, image_format=plan.image_format)
return _write_temp_asset(
self.state.data_dir,
plan.short_id,
artifact.extension,
artifact.data,
mime_type=artifact.mime_type,
width=artifact.width,
height=artifact.height,
)
_READ_LOCATOR_RE = re.compile(
r"^doc:(?P<short_id>[0-9a-fA-F]+)"
r"(?:/tier:(?P<tier>flash|basic|standard|advanced)"
r"(?:/page:(?P<page_no>[1-9][0-9]*)"
r"(?:/block:(?P<block_no>[1-9][0-9]*)(?:/char:(?P<char_offset>0|[1-9][0-9]*))?)?)?)?$"
)
def _parse_doc_locator(locator: str) -> _LocatorParts:
match = _READ_LOCATOR_RE.match(locator)
if match is None:
raise InvalidRequestError("invalid_locator", f"Invalid doclib locator: {locator}", "locator")
page_no = match.group("page_no")
block_no = match.group("block_no")
char_offset = match.group("char_offset")
return _LocatorParts(
short_id=match.group("short_id"),
tier=cast(Tier | None, match.group("tier")),
page_no=int(page_no) if page_no is not None else None,
block_no=int(block_no) if block_no is not None else None,
char_offset=int(char_offset) if char_offset is not None else None,
)
def _canonical_locator(short_id: str, tier: Tier, locator: _LocatorParts) -> str:
if locator.page_no is None:
return f"doc:{short_id}/tier:{tier}"
if locator.block_no is None:
return page_ref(short_id, tier, locator.page_no)
if locator.char_offset is None:
return block_ref(short_id, tier, locator.page_no, locator.block_no)
return block_char_ref(short_id, tier, locator.page_no, locator.block_no, locator.char_offset)
def _locator_after(locator: _LocatorParts, tier: Tier) -> str | None:
if locator.page_no is None:
return None
if locator.block_no is None:
return None
return _canonical_locator(locator.short_id, tier, locator) if locator.char_offset is not None else None
def _locator_page_range(locator: _LocatorParts, doc: DocRow, context: int) -> str | None:
page_count = doc.get("page_count") or 1
if locator.page_no is None:
return _normalize_content_page_range(None, None, doc)
if locator.page_no > page_count:
return _normalize_page_range(str(locator.page_no), page_count)
start = max(1, locator.page_no - context)
end = min(page_count, locator.page_no + context)
return _range_str(start, end)
def _parse_after_cursor(after: str | None) -> ContentCursor | None:
if not after:
return None
try:
return parse_content_cursor(after)
except ValueError as exc:
raise InvalidRequestError("invalid_locator", str(exc), "after") from None
def _validate_cursor_for_doc(cursor: ContentCursor, doc: DocRow, tier: Tier, page_range: str | None) -> None:
if cursor.short_id != doc["short_id"]:
raise InvalidRequestError("invalid_request", "after cursor does not belong to this document.", "after")
if cursor.tier != tier:
raise InvalidRequestError("invalid_request", "after cursor tier does not match request tier.", "after")
if page_range and cursor.page_no not in parse_page_range_set(page_range):
raise InvalidRequestError("invalid_request", "after cursor is outside requested page_range.", "after")
def _mask_config(config: dict[str, str]) -> dict[str, str]:
return {key: _mask_config_value(key, value) for key, value in config.items()}
def _mask_config_value(key: str, value: str) -> str:
if not _is_sensitive_config_key(key) or not value:
return value
if len(value) <= 12:
return "******"
return f"{value[:6]}******{value[-6:]}"
def _is_sensitive_config_key(key: str) -> bool:
lowered = key.lower()
return "api_key" in lowered or "token" in lowered or "secret" in lowered or "password" in lowered
async def _mineru_error_handler(_request: Request, exc: Exception) -> JSONResponse:
mineru_error = exc if isinstance(exc, MineruError) else MineruError("internal_error", str(exc))
return JSONResponse(status_code=http_status_for(mineru_error.code), content=error_response(mineru_error))
async def _unexpected_error_handler(_request: Request, exc: Exception) -> JSONResponse:
return JSONResponse(status_code=500, content=error_response(MineruError("internal_error", str(exc))))
async def _signal_shutdown() -> None:
await asyncio.sleep(0.1)
os.kill(os.getpid(), signal.SIGTERM)
def _parse_server_status(
*,
local_mode: str,
managed_tier: Tier,
self_hosted_url: str | None,
remote_url: str | None,
health: Any,
) -> ParseServerStatus:
local_url = health.managed_url if local_mode == "managed" else self_hosted_url
managed_proc = getattr(health, "managed_proc", None)
return ParseServerStatus(
local=LocalParseServerStatus(
mode=local_mode,
healthy=health.local.probe.healthy,
starting=health.local_starting,
started_at=health.local_started_at or None,
url=local_url,
port=_port_from_url(local_url),
managed_pid=managed_proc.pid if managed_proc else None,
managed_running=bool(managed_proc and managed_proc.poll() is None),
managed_tier=managed_tier,
self_hosted_url=self_hosted_url,
restart_count=getattr(health, "restart_count", 0),
max_restart_attempts=3,
last_probe_at=health.local.last_probe_at,
last_success_at=health.local.last_success_at,
last_failure_at=health.local.last_failure_at,
supported_tiers=health.local.probe.tiers,
error_code=health.local.probe.error_code,
error_msg=health.local.probe.error_msg,
),
remote=RemoteParseServerStatus(
healthy=health.remote.probe.healthy,
url=remote_url,
port=_port_from_url(remote_url),
last_probe_at=health.remote.last_probe_at,
last_success_at=health.remote.last_success_at,
last_failure_at=health.remote.last_failure_at,
supported_tiers=health.remote.probe.tiers,
error_code=health.remote.probe.error_code,
error_msg=health.remote.probe.error_msg,
),
)
def _ensure_managed_parse_server_tier_available(tier: DeploymentTier, param: str) -> None:
try:
ensure_tier_runtime_dependencies(tier)
except TierDependencyError as exc:
raise InvalidRequestError("parse_server_dependency_missing", str(exc), param) from exc
_ensure_managed_parse_server_models_ready(tier, param)
def _ensure_managed_parse_server_models_ready(tier: DeploymentTier, param: str) -> None:
missing_repos: list[str] = []
for repo in model_repos_for_tier(tier):
result = verify_model_repo(repo)
if result.ready:
continue
missing_repos.append(_format_missing_model_repo(repo, result.missing_paths))
if not missing_repos:
return
details = "; ".join(missing_repos)
raise InvalidRequestError(
"parse_server_model_not_ready",
f"Local managed tier '{tier}' requires model files that are not ready. {details}. "
f"Run: mineru-kit models download --tier {tier}",
param,
)
def _format_missing_model_repo(repo: ModelRepo, missing_paths: list[str]) -> str:
missing = ", ".join(missing_paths)
return f"{repo.name} missing: {missing}"
def _validate_managed_parse_server_tier(value: str, param: str) -> DeploymentTier:
if value not in DEPLOYMENT_TIERS:
raise InvalidRequestError(
"invalid_config_value",
"parse_server.local.managed_tier must be one of: basic, standard.",
param,
)
return cast(DeploymentTier, value)
def _port_from_url(url: str | None) -> int | None:
if not url:
return None
try:
parsed = urlparse(url)
if parsed.port is not None:
return parsed.port
if parsed.scheme == "http":
return 80
if parsed.scheme == "https":
return 443
except ValueError:
return None
return None
def _file_size(path: str) -> int | None:
if not path:
return None
try:
return os.path.getsize(path)
except OSError:
return None
async def _sqlite_journal_mode(db: DatabaseManager) -> str | None:
row = await db.fetchone("PRAGMA journal_mode")
if not row:
return None
return cast(str | None, row.get("journal_mode"))
def _is_paginated_doc(doc: DocRow) -> bool:
page_count = doc.get("page_count")
return page_count is not None and page_count > 1
def _normalize_content_page_range(page_range: str | None, after: str | None, doc: DocRow) -> str | None:
page_range = normalize_page_range_input(page_range)
page_count = doc.get("page_count") or 1
if _is_paginated_doc(doc):
if page_range and page_range.strip() == "all":
return _expand_page_range("all", page_count)
if page_range:
return _normalize_page_range(page_range, page_count)
if after:
cursor = _parse_after_cursor(after)
assert cursor is not None
end = min(page_count, cursor.page_no + 9)
return _range_str(cursor.page_no, end)
end = min(page_count, 10)
return _range_str(1, end)
if page_range and page_range.strip() == "all":
return None
return _normalize_page_range(page_range, page_count) if page_range else None
def _normalize_page_range(page_range: str, page_count: int) -> str:
return _expand_page_range(page_range, page_count)
def _select_context_pages(pages: list[PageInfo], target: ContentCursor | None, context: int) -> list[PageInfo]:
if target is None or target.page_no is None or context <= 0:
return pages
start = target.page_no - context
end = target.page_no + context
return [page for page in pages if start <= page.page_idx + 1 <= end]
def _find_page(pages: list[PageInfo], page_no: int) -> PageInfo | None:
return next((page for page in pages if page.page_idx + 1 == page_no), None)
def _find_block_by_no(page: PageInfo, block_no: int) -> PageBlock | None:
target_index = block_no - 1
return next((block for block in page.blocks if block.index == target_index), None)
def _is_empty_bbox(bbox: object) -> bool:
if not isinstance(bbox, (tuple, list)) or len(bbox) != 4:
return True
try:
values = [float(v) for v in bbox]
except (TypeError, ValueError):
return True
if not all(math.isfinite(value) for value in values):
return True
return values[0] >= values[2] or values[1] >= values[3]
def _resolve_block_image_source(block: BlockBase) -> _BlockImageSource | None:
if not _is_empty_bbox(block.bbox):
return _BlockImageSource(kind="source_bbox")
for payload in iter_image_payloads(block):
if payload.image_base64:
return _BlockImageSource(kind="base64", data_uri=payload.image_base64)
return None
def _make_doclib_image_renderer(
*,
short_id: str,
tier: Tier,
page_no: int,
) -> Callable[[BlockBase], str]:
def _render(block: BlockBase) -> str:
label = _VISUAL_BLOCK_LABELS.get(block.type, "Image block")
source = _resolve_block_image_source(block)
if source is not None:
block_no = block.index + 1 if block.index is not None else 0 # TODO: change after block.index's type changed.
return build_markdown_image(block_ref(short_id, tier, page_no, block_no), label)
remote_url = next((payload.image_url for payload in iter_image_payloads(block) if payload.image_url), None)
return build_markdown_image(remote_url, label) if remote_url is not None else f"![{label}]()"
return _render
def _write_temp_asset(
data_dir: str,
short_id: str,
ext: str,
image_bytes: bytes,
*,
mime_type: str,
width: int | None,
height: int | None,
) -> ContentAsset:
safe_ext = ext.lower().lstrip(".") or "png"
output_dir = Path(os.path.expanduser(data_dir)) / "temp" / "read-assets"
output_dir.mkdir(parents=True, exist_ok=True)
path = output_dir / f"{short_id}-{int(time.time() * 1000)}.{safe_ext}"
path.write_bytes(image_bytes)
return ContentAsset(
path=str(path),
mime_type=mime_type,
size_bytes=len(image_bytes),
width=width,
height=height,
)
def _render_progressive_markdown(
pages: list[PageInfo],
*,
short_id: str,
tier: Tier,
after: Any,
limit: int,
add_markers: bool,
target: ContentCursor | None = None,
context: int = 0,
page_count: int | None = None,
) -> _RenderedContent:
output: list[str] = []
ranges: list[ContentRange] = []
started = after is None
last_page_no = 0
next_page_no: int | None = None
cut_inside_page = False
for page in pages:
page_no = page.page_idx + 1
if after and page_no < after.page_no:
continue
page_blocks = _page_markdown_blocks(
page,
short_id=short_id,
tier=tier,
)
if target and target.block_no is not None and page_no == target.page_no:
start_block_no = max(1, target.block_no - context)
end_block_no = target.block_no + context
page_blocks = [(index, text) for index, text in page_blocks if start_block_no <= index + 1 <= end_block_no]
page_output: list[str] = []
page_start_cursor = page_ref(short_id, tier, page_no)
page_end_cursor = page_ref(short_id, tier, page_no)
page_started = False
empty_page_selected = False
for block_index, block_text in page_blocks:
block_no = block_index + 1
if after and page_no == after.page_no:
if after.block_no is None:
continue
if block_no < after.block_no:
continue
if block_no == after.block_no and after.char_offset is None:
continue
started = True
text = block_text
block_start_offset = 0
if after and page_no == after.page_no and after.block_no == block_no and after.char_offset is not None:
block_start_offset = after.char_offset
text = text[block_start_offset:]
if not text:
continue
block_start_cursor = (
block_char_ref(short_id, tier, page_no, block_no, block_start_offset)
if block_start_offset
else block_ref(short_id, tier, page_no, block_no)
)
if not page_started:
page_start_cursor = block_start_cursor if after and after.block_no is not None else page_start_cursor
page_started = True
candidate_page = [*page_output, text]
candidate_content = _join_markdown([*output, *candidate_page])
if len(candidate_content) <= limit:
page_output.append(text)
page_end_cursor = block_ref(short_id, tier, page_no, block_no)
continue
if page_output and not output:
cut_inside_page = True
break
if output:
next_page_no = page_no
return _RenderedContent(
content=_join_markdown(output),
content_ranges=_merge_content_ranges(ranges),
truncated=True,
last_page_no=last_page_no,
next_page_no=next_page_no,
cut_inside_page=False,
)
cut = _soft_cut(text, limit)
if cut <= 0:
cut = min(len(text), limit)
page_output.append(text[:cut])
end_offset = block_start_offset + cut
page_end_cursor = block_char_ref(short_id, tier, page_no, block_no, end_offset)
cut_inside_page = True
break
if not page_output and after and page_no == after.page_no:
continue
if not page_output and (target is None or target.block_no is None):
started = True
empty_page_selected = True
if add_markers:
page_output.append(_page_marker(page_no, page_count))
if not started or (not page_output and not empty_page_selected):
continue
if add_markers:
marker = _page_marker(page_no, page_count)
if not page_output or page_output[0] != marker:
page_output.insert(0, marker)
if page_output:
output.extend(page_output)
last_page_no = page_no
ranges.append(ContentRange(page_range=str(page_no), start=page_start_cursor, end=page_end_cursor))
if cut_inside_page:
next_page_no = page_no
return _RenderedContent(
content=_join_markdown(output),
content_ranges=_merge_content_ranges(ranges),
truncated=True,
last_page_no=last_page_no,
next_page_no=next_page_no,
cut_inside_page=True,
)
# Keep whole pages as the normal truncation boundary for paginated docs.
if len(_join_markdown(output)) >= limit:
next_page_no = page_no + 1
return _RenderedContent(
content=_join_markdown(output),
content_ranges=_merge_content_ranges(ranges),
truncated=next_page_no <= (pages[-1].page_idx + 1),
last_page_no=last_page_no,
next_page_no=next_page_no,
cut_inside_page=False,
)
return _RenderedContent(
content=_join_markdown(output),
content_ranges=_merge_content_ranges(ranges),
truncated=False,
last_page_no=last_page_no,
next_page_no=None,
cut_inside_page=False,
)
def _page_marker(page_no: int, page_count: int | None) -> str:
if page_count is None:
return f"<!-- page {page_no} -->"
return f"<!-- page {page_no} of {page_count} -->"
def _page_markdown_blocks(
page: PageInfo,
*,
short_id: str = "",
tier: Tier = "standard",
) -> list[tuple[int, str]]:
from ..config import config
result: list[tuple[int, str]] = []
image_renderer = _make_doclib_image_renderer(
short_id=short_id,
tier=tier,
page_no=page.page_idx + 1,
)
delimiters = config.render.latex_delimiters
for block in page.blocks:
rendered = render_single_block(
block,
delimiters=delimiters,
asset_base_url="",
image_renderer=image_renderer,
)
text = rendered.strip() if rendered else ""
if text:
# TODO: simplify after BlockBase.index type narrows to int (top-level blocks always have index)
block_index = block.index if block.index is not None else -1
result.append((block_index, text))
return result
def _join_markdown(parts: list[str]) -> str:
return "\n\n".join(part for part in parts if part)
def _soft_cut(text: str, limit: int) -> int:
if len(text) <= limit:
return len(text)
window = text[:limit]
for pattern in ("\n\n", "\n", "。", ". ", " "):
pos = window.rfind(pattern)
if pos > max(0, limit // 2):
return pos + len(pattern)
return limit
def _merge_content_ranges(ranges: list[ContentRange]) -> list[ContentRange]:
if not ranges:
return []
merged: list[ContentRange] = []
current = ranges[0]
for item in ranges[1:]:
if current.page_range and item.page_range:
current_page_numbers = parse_page_range_set(current.page_range)
item_page_numbers = parse_page_range_set(item.page_range)
if max(current_page_numbers) + 1 == min(item_page_numbers):
current = ContentRange(
page_range=_page_numbers_to_range_str(current_page_numbers | item_page_numbers),
start=current.start,
end=item.end,
)
continue
merged.append(current)
current = item
merged.append(current)
return merged
def _next_content_request(
*,
rendered: _RenderedContent,
request_page_range: str | None,
after: str | None,
page_count: int | None,
paginated: bool,
) -> ContentNextRequest | None:
if not rendered.content_ranges:
return None
if not paginated:
if rendered.truncated:
return ContentNextRequest(after=rendered.content_ranges[-1].end)
return None
total_page_count = page_count or rendered.last_page_no
if rendered.truncated:
if rendered.cut_inside_page:
start = rendered.next_page_no or rendered.last_page_no
end = _last_requested_page(request_page_range) or start
return ContentNextRequest(page_range=_range_str(start, end), after=rendered.content_ranges[-1].end)
start = rendered.next_page_no or (rendered.last_page_no + 1)
end = _last_requested_page(request_page_range) or min(total_page_count, start + 9)
if start <= end:
return ContentNextRequest(page_range=_range_str(start, end))
if after:
return ContentNextRequest(page_range=request_page_range, after=rendered.content_ranges[-1].end)
return None
start = rendered.last_page_no + 1
if start > total_page_count:
return None
end = min(total_page_count, start + 9)
return ContentNextRequest(page_range=_range_str(start, end))
def _next_read_request(
rendered: _RenderedContent,
short_id: str,
tier: Tier,
page_count: int | None,
) -> ContentNextRequest | None:
if not rendered.content_ranges:
return None
if rendered.truncated:
return ContentNextRequest(locator=rendered.content_ranges[-1].end)
total_page_count = page_count or rendered.last_page_no
next_page_no = rendered.last_page_no + 1
if next_page_no > total_page_count:
return None
return ContentNextRequest(locator=page_ref(short_id, tier, next_page_no))
def _last_requested_page(page_range: str | None) -> int | None:
if not page_range:
return None
page_numbers = parse_page_range_set(page_range)
return max(page_numbers) if page_numbers else None
def _range_str(start: int, end: int) -> str:
return _page_numbers_to_range_str(range(start, end + 1))
def _effective_data_dir(state: Any) -> str:
data_dir = getattr(state, "data_dir", "")
if data_dir:
return os.path.expanduser(data_dir)
return os.path.expanduser(config.doclib.data_dir)
def _tail_log(log_path: str, lines: int = 100) -> list[str]:
log_path = os.path.expanduser(log_path) if log_path else os.path.expanduser(config.doclib.log.resolved_app_path)
if not os.path.isfile(log_path):
return []
with open(log_path, encoding="utf-8", errors="replace") as fh:
return fh.readlines()[-lines:]
async def _watch_stats(db: DatabaseManager, watches: list[WatchTargetRow]) -> list[WatchStats]:
file_rows = cast(
list[WatchStatsFileRow],
await db.fetchall(
"SELECT watch_id, "
"COUNT(*) AS total_files, "
"SUM(CASE WHEN status=? THEN 1 ELSE 0 END) AS active_files, "
"SUM(CASE WHEN status=? THEN 1 ELSE 0 END) AS deleted_files, "
"SUM(CASE WHEN status=? THEN 1 ELSE 0 END) AS unreachable_files, "
"SUM(CASE WHEN status=? AND sha256 IS NULL AND error_code IS NULL THEN 1 ELSE 0 END) AS pending_ingest_files, "
"SUM(CASE WHEN error_code IS NOT NULL THEN 1 ELSE 0 END) AS file_error_count, "
"COUNT(DISTINCT sha256) AS doc_count "
"FROM files WHERE watch_id IS NOT NULL GROUP BY watch_id",
(FILE_STATUS_ACTIVE, FILE_STATUS_DELETED, FILE_STATUS_UNREACHABLE, FILE_STATUS_ACTIVE),
),
)
parse_rows = cast(
list[WatchParseCountRow],
await db.fetchall(
"SELECT f.watch_id AS watch_id, p.status AS status, COUNT(*) AS cnt "
"FROM parses p "
"JOIN (SELECT DISTINCT watch_id, sha256 FROM files WHERE watch_id IS NOT NULL AND sha256 IS NOT NULL) f "
"ON f.sha256 = p.sha256 "
"GROUP BY f.watch_id, p.status"
),
)
files_by_watch = {row["watch_id"]: row for row in file_rows}
parses_by_watch: dict[int, dict[str, int]] = {}
for row in parse_rows:
watch_id = row["watch_id"]
parses_by_watch.setdefault(watch_id, {})[row["status"]] = row["cnt"]
stats: list[WatchStats] = []
for watch in watches:
watch_id = watch["id"]
file_counts = files_by_watch.get(watch_id, {})
parse_counts = parses_by_watch.get(watch_id, {})
stats.append(
WatchStats(
watch_id=watch_id,
path=watch["path"],
label=watch.get("label"),
removable=bool(watch.get("removable", False)),
status=watch["status"],
total_files=file_counts.get("total_files", 0) or 0,
active_files=file_counts.get("active_files", 0) or 0,
deleted_files=file_counts.get("deleted_files", 0) or 0,
unreachable_files=file_counts.get("unreachable_files", 0) or 0,
pending_ingest_files=file_counts.get("pending_ingest_files", 0) or 0,
file_error_count=file_counts.get("file_error_count", 0) or 0,
doc_count=file_counts.get("doc_count", 0) or 0,
parse_pending_count=parse_counts.get(PARSE_STATUS_PENDING, 0),
parse_parsing_count=parse_counts.get(PARSE_STATUS_PARSING, 0),
parse_failed_count=parse_counts.get(PARSE_STATUS_FAILED, 0),
parse_done_count=parse_counts.get(PARSE_STATUS_DONE, 0),
last_scan_at=watch.get("last_scan_at"),
last_scan_files=watch.get("last_scan_files", 0) or 0,
)
)
return stats
async def _error_summary(db: DatabaseManager) -> ErrorSummary:
file_errors = await _error_buckets(db, "files")
doc_errors = await _error_buckets(db, "docs")
parse_errors = await _error_buckets(db, "parses")
return ErrorSummary(file_errors=file_errors, doc_errors=doc_errors, parse_errors=parse_errors)
async def _recent_scans(db: DatabaseManager, limit: int = 5) -> list[ScanInfo]:
rows = cast(
list[RecentScanRow],
await db.fetchall(
"SELECT id, path, kind, source, watch_id, status, files_seen, files_refreshed, files_new, files_changed, "
"files_deleted, files_unreachable, files_error, files_unsupported, files_excluded, error_code, error_msg, "
"started_at, finished_at, created_at, updated_at "
"FROM scans ORDER BY created_at DESC, id DESC LIMIT ?",
(limit,),
),
)
return [ScanInfo.model_validate(row) for row in rows]
async def _record_telemetry_count(state: Any, metric_name: str, *, dimensions: dict[str, str] | None = None) -> None:
telemetry_svc = getattr(state, "telemetry_svc", None)
if telemetry_svc is None:
return
await telemetry_svc.record_count(metric_name, dimensions=dimensions)
async def _record_telemetry_duration(
state: Any,
metric_name: str,
start_ms: int,
*,
dimensions: dict[str, str] | None = None,
) -> None:
telemetry_svc = getattr(state, "telemetry_svc", None)
if telemetry_svc is None:
return
await telemetry_svc.record_duration_bucket(metric_name, duration_ms=max(0, _now_ms() - start_ms), dimensions=dimensions)
def _parse_route_status(response: ParseResponse) -> str:
if response.cache_hit:
return "cached"
if response.created_parse_ids:
return "queued"
if response.reused_parse_ids:
return "reused"
return "direct"
def _telemetry_error_code(exc: Exception) -> str:
if isinstance(exc, MineruError):
return exc.code
return "internal_error"
def _telemetry_output_format(value: str) -> str:
if value == "markdown":
return "markdown"
if value == "image":
return "image"
return "other"
def _now_ms() -> int:
return int(time.time() * 1000)
async def _error_buckets(db: DatabaseManager, table: str) -> list[ErrorBucket]:
sql_by_table = {
"files": (
"SELECT error_code AS code, COUNT(*) AS cnt FROM files "
"WHERE error_code IS NOT NULL GROUP BY error_code ORDER BY cnt DESC, error_code"
),
"docs": (
"SELECT error_code AS code, COUNT(*) AS cnt FROM docs "
"WHERE error_code IS NOT NULL GROUP BY error_code ORDER BY cnt DESC, error_code"
),
"parses": (
"SELECT error_code AS code, COUNT(*) AS cnt FROM parses "
"WHERE error_code IS NOT NULL GROUP BY error_code ORDER BY cnt DESC, error_code"
),
}
rows = cast(list[ErrorBucketRow], await db.fetchall(sql_by_table[table]))
return [ErrorBucket(code=row["code"], count=row["cnt"]) for row in rows]
def _file_info(row: FileRow) -> FileInfo:
return FileInfo.model_validate(row)
def _doc_info(row: DocRow, *, files: list[FileInfo] | None = None) -> DocInfo:
data = dict(row)
data["files"] = files
return DocInfo.model_validate(data)
def _parse_info(row: ParseRow, *, coverage: ParseCoverage | None = None) -> ParseInfo:
data = dict(row)
data["page_range"] = normalize_result_page_range(row["page_range"])
data["coverage"] = coverage
return ParseInfo.model_validate(data)
def _tier_parse_info(row: ParseRow) -> TierParseInfo:
data = dict(row)
data["page_range"] = normalize_result_page_range(row["page_range"])
return TierParseInfo.model_validate(data)
def _watch_info(row: WatchTargetRow) -> WatchInfo:
data = dict(row)
data["removable"] = bool(data.get("removable", False))
data["enabled"] = bool(data.get("enabled", True))
data["recursive"] = bool(data.get("recursive", False))
return WatchInfo.model_validate(data)
def _exclude_rule_info(row: ExcludeRuleRow | None) -> ExcludeRuleInfo:
if row is None:
raise NotFoundError("rule_not_found", "Exclude rule not found.", "rule_id")
return ExcludeRuleInfo.model_validate(row)
def _parsing_rule_info(row: ParsingRuleRow | None) -> ParsingRuleInfo:
if row is None:
raise NotFoundError("rule_not_found", "Parsing rule not found.", "rule_id")
data = dict(row)
data["remote"] = bool(data.get("remote", False))
data["enabled"] = bool(data.get("enabled", True))
return ParsingRuleInfo.model_validate(data)
def _search_result(row: ContentSearchResultRow) -> SearchResult:
return SearchResult.model_validate(row)
def _find_result(row: FilenameSearchResultRow) -> FindResult:
return FindResult.model_validate(row)
def _parse_coverage(request_page_range: str, rows: list[ParseRow]) -> ParseCoverage:
requested_page_numbers = parse_page_range_set(request_page_range)
done_page_numbers: set[int] = set()
active_page_numbers: set[int] = set()
for row in rows:
row_page_numbers = parse_page_range_set(row["page_range"]) & requested_page_numbers
if row["status"] == PARSE_STATUS_DONE:
done_page_numbers |= row_page_numbers
elif row["status"] in {PARSE_STATUS_PENDING, PARSE_STATUS_PARSING}:
active_page_numbers |= row_page_numbers
active_page_numbers -= done_page_numbers
missing_page_numbers = requested_page_numbers - done_page_numbers - active_page_numbers
return ParseCoverage(
done_page_range=_page_numbers_to_range_str(done_page_numbers),
active_page_range=_page_numbers_to_range_str(active_page_numbers),
missing_page_range=_page_numbers_to_range_str(missing_page_numbers),
)