Metadata-Version: 2.5
Name: metabook-py
Version: 3.1.2
Summary: metabook-py
Author-email: Emmanuel De Freitas <manny.defreitas7@gmail.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.13
Requires-Dist: fastapi>=0.115.0
Requires-Dist: fastmcp>=2.0.0
Requires-Dist: httpx>=0.27.0
Requires-Dist: huggingface-hub>=0.26.0
Requires-Dist: psycopg[binary]<4,>=3.2
Requires-Dist: pydantic-settings>=2.3.0
Requires-Dist: pydantic>=2.7.0
Requires-Dist: pymongo>=4.9
Requires-Dist: pypdf>=5.0
Requires-Dist: python-multipart>=0.0.9
Requires-Dist: tokenizers>=0.20.0
Requires-Dist: uvicorn[standard]>=0.30.0
Provides-Extra: rust
Requires-Dist: metabook-core; extra == 'rust'
Provides-Extra: tf
Requires-Dist: text-fabric>=13; extra == 'tf'
Description-Content-Type: text/markdown

<div align="center">
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.png">
    <source media="(prefers-color-scheme: light)" srcset="assets/logo-light.png">
    <img src="assets/logo-light.png" alt="metabook-py" width="160"/>
  </picture>

# metabook-py

[![PR](https://img.shields.io/github/actions/workflow/status/emmanuel-defreitas/metabook-py/pr.yml?branch=dev&label=PR%20checks)](https://github.com/emmanuel-defreitas/metabook-py/actions/workflows/pr.yml)
[![Matrix](https://img.shields.io/github/actions/workflow/status/emmanuel-defreitas/metabook-py/matrix.yml?label=matrix)](https://github.com/emmanuel-defreitas/metabook-py/actions/workflows/matrix.yml)
[![Release](https://img.shields.io/github/v/release/emmanuel-defreitas/metabook-py?include_prereleases&label=release)](https://github.com/emmanuel-defreitas/metabook-py/releases)
[![Python](https://img.shields.io/badge/python-3.13%2B-blue)](pyproject.toml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

**📚 X-ray any book's structure — chapters, paragraphs, sentences, clauses, words — as clean JSON. REST + MCP, Project Gutenberg search or your own EPUB. No book text ever leaves the API.**

[API docs](#-rest-api) · [Web app](#-web-app) · [MCP server](#-mcp-server) · [Desktop example](#-desktop-example-gpui) · [Workflow](.github/WORKFLOW.md)

  <img src="assets/demo.gif" alt="Metabook desktop example searching Pride and Prejudice and exploring its structural schema" width="860"/>
</div>

---

## Overview

Metabook is a **Book Structure API**: give it a title, an ISBN, a Gutenberg ID, or an EPUB file, and it returns the book's _structural schema_ — what kind of book it is and how it's shaped — without ever returning the text itself.

Under the hood it locates the book via [Gutendex](https://gutendex.com) (or walks your uploaded EPUB's spine), downloads and cleans the text, detects the structural schema, and counts everything at every level.

```
title / ISBN / Gutenberg ID ──► Gutendex ──► fetch + clean ──┐
                                                             ├──► detect schema ──► counts-only JSON
your .epub ──► Vercel Blob ──► package doc + spine XHTML ────┘
```

## ✨ Features

- **🔍 Fuzzy search** — find books by title or author via Gutendex, or go straight to an ISBN / Gutenberg ID
- **🧬 Schema detection** — classifies each book as `scripture`, `sectioned_book`, `standard_book`, `essay_collection`, or `flat`, with a confidence rating
- **🔢 Counts, not content** — chapters, paragraphs, sentences, clauses, and words per node; a word node is just its index. **Main-content text is never included in node responses**. Explicitly excluded Gutenberg front/back matter may include its original text
- **📤 Bring your own EPUB** — `POST /api/books/upload` stores the file in Vercel Blob and analyses it with the same pipeline
- **📄 Single documents** — analyse one paper or page (HTML, Markdown, PDF, plain text) into a section tree with counts per section ([schema](docs/single-document-schema.md))
- **🔌 Two interfaces, one service layer** — a FastAPI REST API and a FastMCP server share the same core
- **🖥️ Native desktop client** — a GPUI example app with an animated structure tree and a synced JSON code editor

### Detail levels

The `detail` parameter controls how deep the returned tree nests beneath each paragraph:

| `detail`    | Nested nodes                          |
| ----------- | ------------------------------------- |
| `paragraph` | paragraphs only (counts for the rest) |
| `sentence`  | + sentence nodes                      |
| `clause`    | + clause nodes                        |
| `word`      | + word nodes (index + position only)  |

### Token counts

Pass an optional `tokenizer` query parameter naming a Hugging Face tokenizer
repository (e.g. `bert-base-uncased`) and every node in the tree carries a
token count alongside its word count — special tokens excluded, so parent
totals equal the sum of their children. The response metadata echoes the
resolved tokenizer name and its vocabulary size. The tokenizer is fetched
lazily on first use (cached on disk and in memory afterwards); an unknown
name returns `422`, a transient fetch failure on cold start returns `503`.
When the parameter is omitted, no token counts are computed and the response
is unchanged.

```bash
curl "http://127.0.0.1:8000/api/books/structure?title=Pride+and+Prejudice&tokenizer=bert-base-uncased"
```

### Text-Fabric reference identifiers

The standalone `tf_ref` package resolves and serializes durable references against any loaded
Text-Fabric corpus by reading its section hierarchy at runtime:

```python
from tf_ref import TextFabricAdapter, normalize, resolve, serialize

corpus = TextFabricAdapter(tf_app)
node = resolve("Genesis:1:1!word3", corpus)
stored = serialize(node, corpus)  # e.g. bhsa@2021/Genesis:1:1!word3
assert normalize("Genesis:1:1!word3", corpus) == stored
```

- Short grammar: `[corpus[@version]/]section[:section...][!typeN[-N]]`; canonical URNs use
  `urn:tf:corpus[@version]:section[:section...][!typeN[-N]]`.
- A heading containing `:`, `/`, `!`, `@`, whitespace, or `"` is double-quoted; embedded quotes
  are doubled, as in `"say ""hi"""`. URNs percent-encode headings.
- Positions are 1-based and ranges are inclusive. A unit belongs to the section containing its
  first text slot, including units that continue into a later section.
- Selectors on partial section paths resolve normally; serializing the resulting node emits the
  canonical innermost section because a bare node does not retain its original broader path.
- Positional identifiers are stable only within one `(corpus id, version, language)` triple.
- Current ambiguities are limited to colliding sibling headings, partial-depth selector stability,
  strict RFC treatment of URN components, missing-language fallback, and multi-section spans.

Multi-section spans are intentionally deferred. A future form can name both endpoints after `!`
without changing today's section grammar; current ranges must stay within one addressed section.

## 🚀 Quick start

```bash
make setup              # install dependencies (uv sync)
make dev                # run the API with auto-reload on :8000
make test               # run the test suite
make docker-up          # or run it via docker compose
```

Then open the interactive docs at [`http://127.0.0.1:8000/api/docs`](http://127.0.0.1:8000/api/docs), or try:

```bash
curl "http://127.0.0.1:8000/api/books/structure?title=Pride+and+Prejudice&detail=sentence"
```

### EPUB uploads

Uploads need a Vercel Blob read-write token in the environment (or `.env` / `.env.local`):

```bash
export BLOB_READ_WRITE_TOKEN=vercel_blob_rw_...
```

If the project is linked to Vercel, `vercel env pull` writes the token to `.env.local` (gitignored), which the app loads automatically — values there override `.env`.

### PostgreSQL storage

Set `DATABASE_URL` in `.env.local` (gitignored) to persist metadata and complete
structural results. The desktop app sends uploads to the API; database credentials
stay in the API configuration. Use `sslmode=verify-full` so the client checks the
server certificate and hostname; `sslmode=require` encrypts the connection but
accepts any certificate. Aiven signs with a per-project CA: download `ca.pem` from
the service's overview page and point `sslrootcert` at it:

```dotenv
DATABASE_URL="postgres://avnadmin:YOUR_PASSWORD@YOUR_HOST:22637/defaultdb?sslmode=verify-full&sslrootcert=/path/to/ca.pem"
# Optional owner identifier for this single-user API instance:
DEFAULT_USER_ID="your-user-id"
```

API startup creates the additive schema in `public`: `books`, `metadata`, `author`,
`schemas`, `publisher`, and `license`, plus `metadata_authors` for ordered creators.
`books` uses quoted `userId`, `metadataId`, and `schemaId` columns; `token` is the
total token count (null when not requested), and `score` is heuristic detector
support. `userId` is nullable until an owner is configured; it does not implement
authentication. `metadata.authorId` points to the first creator, while the join
table preserves all creators. Publisher, rights/license, publication date, and
explicit `schema:numberOfPages` EPUB metadata are stored when supplied; unknown
values remain null. `metadata.LicenseId` is the license relationship.

Metadata, relationships, and the structural JSON commit in one transaction.
The database does not store extracted main-content prose. Structural JSON may retain
publication text outside explicit Gutenberg boundaries. Uploaded EPUB files remain
in the existing private Vercel Blob store. A failed PostgreSQL write returns 503
instead of reporting a successful save. A successful response includes `record_id`;
`GET /api/books/uploads/{record_id}/structure` reopens its committed result.
`GET /health` verifies the configured database connection.

Real database tests use an isolated temporary schema and remove it afterward:

```bash
TEST_DATABASE_URL='postgres://...' uv run pytest tests/test_postgres.py -m integration
```

### Browser libraries (MongoDB Atlas)

Vercel's existing `atlas-metabook` integration provides `MONGODB_URI`.
MongoDB takes precedence over `DATABASE_URL`; `MONGODB_DB` defaults to `metabook`.
The browser creates a UUID v4 in localStorage under `metabook.user-id` on first
visit, reuses it on later visits, and sends it as `X-Metabook-User-ID` to the API.
This is an anonymous library identifier, not account authentication. Clearing
browser storage loses the identifier; another browser has a separate library.
If localStorage is blocked, the identity lasts only for the page session.

`user_library` stores each owner's book/document metadata, authors, subjects
(tags), Open Library enrichment and schema/scan summary. Complete parsed results
are JSON files in the `parsed_results` GridFS bucket, supporting trees beyond
MongoDB's 16 MB document limit. Every list and read filters by owner UUID.
Gutenberg rescans replace that owner's library entry; previous GridFS versions
remain available to concurrent readers. File and URL analyses get new entries.
Responses are private and not cacheable. Missing UUIDs cannot access Mongo
library records; invalid UUIDs are rejected. Database failures are reported,
not silently presented as successful saves.

`GET /api/books/uploads` lists both books and documents; the existing
`/api/books/uploads/{id}/structure` route reopens either complete result. Legacy
ownerless MongoDB/PostgreSQL records remain untouched and aren't automatically
assigned to a browser. The PostgreSQL adapter remains available for non-browser
clients when MongoDB isn't configured. Browser clients require MongoDB.

### Single documents

Analyse one HTML, Markdown, PDF or plain-text document. Sections nest by
heading level (skipped levels nest directly; content before the first heading
goes in a level-0 preamble), and every section carries its own `counts` plus
`totals` for its subtree. Sentences and words use the same tokenizer as the
book endpoints. Full rules: [`docs/single-document-schema.md`](docs/single-document-schema.md).

```bash
curl -F "file=@paper.pdf" http://127.0.0.1:8000/api/documents/analyze
curl -H "content-type: application/json" -d '{"url": "https://example.com/post.html"}' \
  http://127.0.0.1:8000/api/documents/analyze-url
```

Limits: 20 MB, `text/html`, `application/xhtml+xml`, `text/markdown`,
`application/pdf`, `text/plain` only (extension used for
`application/octet-stream`). URL fetches time out after 10 s and refuse
private, loopback, link-local and other non-public addresses, re-checked on
every redirect.

Example — this Markdown:

```markdown
Draft, see https://example.org.

# Results

We measured it. It worked.

### Detail

- One
- Two
```

returns (zero counts omitted here for brevity; the API returns every field):

```jsonc
{
  "document": {
    "format": "markdown",
    "content_type": "text/markdown",
    "size_bytes": 96,
    "sha256": "e8fe6210…",
    "page_count": null,
    "filename": "notes.md",
    "source_url": null,
  },
  "structure": {
    "totals": {
      "headings": 2,
      "paragraphs": 2,
      "lists": 1,
      "list_items": 2,
      "links": 1,
      "sentences": 5,
      "words": 12,
      "sections": 3,
      "max_depth": 2,
    },
    "sections": [
      {
        "kind": "preamble",
        "level": 0,
        "index": 0,
        "path": "0",
        "counts": { "paragraphs": 1, "links": 1, "sentences": 1, "words": 5 },
        "children": [],
      },
      {
        "kind": "section",
        "level": 1,
        "index": 1,
        "path": "1",
        "counts": { "headings": 1, "paragraphs": 1, "sentences": 2, "words": 5 },
        "totals": { "headings": 2, "paragraphs": 1, "lists": 1, "list_items": 2, "sentences": 4, "words": 7 },
        "children": [
          {
            "kind": "section",
            "level": 3,
            "index": 1,
            "path": "1.1",
            "counts": { "headings": 1, "lists": 1, "list_items": 2, "sentences": 2, "words": 2 },
            "children": [],
          },
        ],
      },
    ],
  },
  "meta": {
    "schema_version": "1.0",
    "parser": "python",
    "processing_time_ms": 1,
    "analyzed_at": "2026-11-02T13:01:11Z",
  },
}
```

## 🌐 REST API

| Method | Endpoint                            | Purpose                                                                                                            |
| ------ | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `GET`  | `/api/books/structure`              | Analyse by `title`, `isbn`, or `gutenberg_id` (+ `detail`, `tokenizer`)                                            |
| `GET`  | `/api/books/search`                 | Browse Gutendex metadata by `q` (title/author), `isbn`, or `gutenberg_id`; supports `page` and optional `language` |
| `GET`  | `/api/books/structure/schemas`      | List the supported structural schemas                                                                              |
| `POST` | `/api/books/upload`                 | Upload an EPUB and analyse it                                                                                      |
| `GET`  | `/api/books/uploads`                | List saved books (owner-scoped `MONGODB_URI`)                                                          |
| `GET`  | `/api/books/uploads/{id}/structure` | Reopen a saved PostgreSQL scan                                                                                     |
| `POST` | `/api/documents/analyze`            | Analyse one uploaded document (multipart `file`)                                                                   |
| `POST` | `/api/documents/analyze-url`        | Fetch a public URL (`{"url": …}`) and analyse it                                                                   |
| `GET`  | `/api/health`                       | Database readiness + cache stats (`/health` remains an alias)                                                      |

Interactive OpenAPI docs live at `/api/docs`.

## 🌐 Web app

[`web/`](web) is a Next.js port of the desktop client (search, filters, EPUB upload,
saved library and the structure explorer). On Vercel, [`vercel.json`](vercel.json)
defines two services in one project: the web app answers every path, and `/api/*`
(plus `/mcp`, `/health` and `/openapi.json`) goes to this FastAPI app. Set the project's
framework preset to **Services** in the Vercel dashboard. Locally, run `make dev`
and then `npm run dev` in `web/`; see [web/README.md](web/README.md).

## 🤖 MCP server

A [FastMCP](https://gofastmcp.com) server is mounted at `/api/mcp` (with `/mcp` kept as an alias), so agents can use the same service layer through four tools:

| Tool                     | Description                                                       |
| ------------------------ | ----------------------------------------------------------------- |
| `search_book_structure`  | Analyse a book by title, ISBN, or Gutenberg ID                    |
| `upload_book_epub`       | Analyse an EPUB passed as base64 or a URL                         |
| `list_supported_schemas` | Enumerate the structural schemas                                  |
| `analyze_document`       | Analyse one HTML / Markdown / PDF / text document (base64 or URL) |

## ⏱️ Parser benchmark

`scripts/bench_parser.py` times the parser on locally cached Gutenberg texts
(no Gutendex calls), runs a full in-process request with the network mocked,
and prints a cProfile of the top hotspots:

```bash
mkdir -p .cache/bench
curl -L -o .cache/bench/pg10.txt   https://www.gutenberg.org/cache/epub/10/pg10.txt
curl -L -o .cache/bench/pg1022.txt https://www.gutenberg.org/cache/epub/1022/pg1022.txt
uv run python scripts/bench_parser.py
```

Medians of 5 runs, Python 3.13, Apple Silicon (arm64). "v3.1.0" is the
original parser; "Python" adds a set-lookup abbreviation regex in the shared
sentence splitter (the top hotspot: 46% of KJV parse time) and preprocesses
once per request instead of three times; "Rust" is `METABOOK_PARSER=rust`.
Book responses are byte-identical across all three.

| Book                                | Metric                   |   v3.1.0 | Python |   Rust |
| ----------------------------------- | ------------------------ | -------: | -----: | -----: |
| King James Bible (PG 10, 4.3 MB)    | parse                    | 1,033 ms | 622 ms | 500 ms |
|                                     | full request, cold cache | 1,125 ms | 662 ms | 526 ms |
| _Walking_, Thoreau (PG 1022, 87 KB) | parse                    |  13.2 ms | 6.5 ms | 4.4 ms |
|                                     | full request, cold cache |    36 ms |  29 ms |  26 ms |

With the Rust core, sentence splitting and word counting drop to ~25 ms of
KJV parse time. What remains is book-specific Python (verse/chapter splitting
~180 ms, schema detection ~70 ms, preprocessing ~55 ms per pass), which the
core does not cover yet. Real network adds ~1 s to a cold KJV request
(4.3 MB download).

Single-document analysis (`analyze_document`, best of 3):

| Input                        | Python |   Rust |
| ---------------------------- | -----: | -----: |
| KJV as `text/plain` (4.3 MB) | 246 ms |  44 ms |
| `paper.md` × 2000 (2.4 MB)   | 373 ms | 164 ms |

## 🦀 Native parsing core

`src/metabook_core` is a Rust port of the shared tokenizer (sentences,
words), the plain-text and Markdown adapters, and the section-tree builder,
exposed to Python with PyO3 and built with maturin. HTML and PDF stay on their
Python adapters and hand their block trees to the Rust builder.

```bash
make rust                       # uv sync --extra rust + build the CLI
METABOOK_PARSER=rust make dev   # serve with the native core
make rust-test                  # cargo test/clippy + pytest under both parsers
```

- `METABOOK_PARSER=python|rust` (default `python`; the Docker image defaults to
  `rust`). If `rust` is set but the extension isn't installed, the Python
  implementation runs and a warning is logged. Single-document responses report
  the backend that ran in `meta.parser`; book responses are unchanged.
- `tests/test_parity.py` asserts identical output from both backends on every
  fixture, edge cases, 120 seeded random documents, every book fixture at every
  detail level, and the full KJV. The port reproduces Python's `\s`, `\w`/`\b`
  and `isalpha` definitions, which differ from Rust's on characters such as
  `²` and U+001C–U+001F.
- A small CLI prints the same JSON for Markdown and plain-text files:

```bash
src/metabook_core/target/release/metabook analyze tests/fixtures/documents/paper.md
```

## 🌳 Branching and release

CI/release pipeline scaffolded from [exegia/corpora-py](https://github.com/exegia/corpora-py): same branch model, GitHub Actions workflows, composite actions, and `make`-driven release automation.

See [`.github/WORKFLOW.md`](.github/WORKFLOW.md) for the full branch model, versioning rules, and workflow reference. Quick summary:

```
<type>/<slug> --PR--> dev --(daily/manual)--> next --cut--> release/vX.Y.Z --draft PR--> main
                 (deleted on merge)         (preview)                       (deleted on release)
```

### Common commands

```bash
make help                # list all targets
make ci                  # everything CI runs on a PR (lint + test)
make pack                # build the publishable wheel
make rulesets-diff       # rulesets GitHub currently has
make rulesets-apply      # push .github/rulesets/*.json
```

### Bootstrap

There are no `dev` / `next` branches until the first wrapup. Run the **Release** workflow manually (`Actions → Release → Run workflow`), or locally:

```bash
make bootstrap-lanes
```

## 📄 License

[MIT](LICENSE) © Emmanuel De Freitas

### Explainable schema detection and cleanup

REST `GET /api/books/structure` and `POST /api/books/upload`, and MCP
`search_book_structure` and `upload_book_epub`, now include these additive
fields inside `structure`:

```json
{
  "schema": "standard_book",
  "schema_confidence": "medium",
  "schema_score": 0.5,
  "schema_detected": "standard_book",
  "schema_overridden": false,
  "schema_evidence": {
    "verse_number_lines": 0,
    "part_markers": 0,
    "chapter_word_markers": 2,
    "chapter_numeral_markers": 0,
    "caps_title_lines": 0,
    "title_byline_pairs": 0,
    "paragraph_blocks": 4
  },
  "schema_candidates": {
    "canonical_scripture": 0.0,
    "sectioned_book": 0.0,
    "standard_book": 0.5,
    "essay_or_story_collection": 0.0,
    "flat": 0.1
  }
}
```

Scores are **heuristic rule support, not calibrated probabilities**. Candidate
scores do not sum to one. Selection retains the established priority:
scripture, sectioned book, standard book, essay/story collection, then flat.
The evidence contains counts only, never matched source excerpts.

| Schema                      | Signals and eligibility                                                                                         |
| --------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `canonical_scripture`       | At least 10 line-leading chapter:verse numbers; weaker inputs score `0.03 × verse_count`                        |
| `sectioned_book`            | At least 2 part markers and 2 explicit chapter markers; support uses their combined count                       |
| `standard_book`             | At least 2 explicit chapter headings or standalone dotted numerals; support uses the larger count               |
| `essay_or_story_collection` | At least 2 all-caps title lines excluding structural keywords; repeated title + `By …` lines strengthen support |
| `flat`                      | Fallback, with score 0.2 when no structural signals exist and 0.1 when weak signals exist                       |

For eligible marked schemas, support is `min(0.9, 0.3 + 0.1 × marker_count)`.
Chapter and title candidates also expose subthreshold support for a single
marker. Collection support is capped at 0.65 without at least two title/byline
pairs, or 0.85 with them. When multiple schema families qualify, the selected
score is capped at 0.6; part and chapter support alone describe one family.
Confidence is `high` at 0.8 or above, `medium` at 0.5 or above, otherwise `low`.
Flat fallback now reports **low**, replacing the earlier unconditional high.
These rules can still misidentify unusual formatting.

Supply `schema_override` with any schema name from `/api/books/structure/schemas`
to choose a different builder. For example, against a local server:

```bash
curl 'http://localhost:8001/api/books/structure?gutenberg_id=1342&schema_override=flat'
```

For MCP, pass the same optional argument to either analysis tool. `schema`
echoes the requested builder; `schema_detected`, confidence, score, evidence,
and candidates continue to describe automatic detection. Invalid REST values
return 422; MCP validates its enum argument (direct service calls return
`invalid_schema_override`). Python callers can use
`detect_schema(text, schema_override=SchemaType.FLAT)`.

Detection and counting share idempotent preprocessing, including EPUB analysis.
The fetcher reuses that cleanup instead of implementing a second license strip.
It recognises modern starred Gutenberg START/END markers (including wrapped
marker titles) and legacy `End of Project Gutenberg's EBook …` / Etext footers.
It removes an explicitly labelled opening contents page, and preceding short
metadata, only after finding at least two plausible entries and a body boundary.
Thus contents chapter listings no longer inflate body counts or confidence in
recognised layouts. No license boundary is guessed when markers are absent.
Unlabelled or uncertain front matter, prefaces, contents-only inputs and contents
following real prose are retained. Sparse, unusual contents layouts may remain;
HTML paragraph/heading/line-break boundaries are preserved before cleanup;
unusual markup and navigation tables may still need publisher-specific handling.

All tree indices remain **one-based ordinals within the cleaned body**, restarting
within each parent (scripture retains chapter/verse numbers). They are not byte or
character offsets into the source download. Removing a contents page changes
these body ordinals and totals; no original-source offset mapping is promised.
The existing structural-only response contract remains in place.

Before release, review adoption in `emmanuel-defreitas/MetaBookSDK` and the
MetaBook app, especially the corrected flat confidence and changed counts.
External issue creation and publication are deferred to an authorised
review/release step.

Future feedback ideas, outside this change: dialogue share per chapter with
quoted spans represented only by positions, and sentence-length distributions.

The `/health` response and OpenAPI `info.version` report the installed package
version (or `pyproject.toml` for source-only deployments), so release verification
can compare the running API with the PyPI release.

### Uploaded EPUB catalog enrichment

New EPUB uploads perform a best-effort Open Library lookup using the existing
async Python HTTP client. A valid ISBN matches an edition; otherwise an
unambiguous normalized title and author match identifies a work. Edition matches
fill missing publisher, publication date and page count without replacing EPUB
values. Work matches never supply edition-specific facts. Covers, catalog IDs,
subjects and Dewey/Library of Congress classifications are saved under
`book.open_library`, with the match method and retrieval timestamp, and displayed
in the library and metadata view. Catalog metadata never changes parsed nodes,
structure, schema selection or confidence. Existing saved uploads are not backfilled.

Requests are cached for 24 hours (up to 512 lookup entries), limited to one request
per second per API process, and bounded by `OPEN_LIBRARY_TIMEOUT` (default 5 seconds,
including queue time). Failed, missing or ambiguous matches leave the upload intact.
Set `OPEN_LIBRARY_ENABLED=false` to disable external enrichment, or configure
`OPEN_LIBRARY_USER_AGENT` with your application name and contact information.
Only the ISBN or title and first author is sent; book contents are not sent.
For multi-worker deployments, coordinate the request limit across workers before
increasing traffic. See [Open Library API guidelines](https://openlibrary.org/developers/api).
