Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
30a08df
build(server): add liteparse + chonkie, bake the retrieval payloads i…
JonnyTran Aug 26, 2026
99ec6a2
build(server): fetch the lance extension instead of INSTALL-ing it
JonnyTran Aug 26, 2026
b8b3948
build(server): split the builder stage and apply the uv Docker guide
JonnyTran Aug 27, 2026
a25b574
feat(ocr): read `items` rows back as typed elements, with a liftable …
JonnyTran Aug 27, 2026
9db0f0f
build(server): pull the lance fetch out of the per-build path, bump u…
JonnyTran Aug 27, 2026
6c23323
fix(server): verify the lance extension over TLS against a pinned digest
JonnyTran Aug 27, 2026
59ba87d
fix(ocr): keep a table's caption in the table element
JonnyTran Aug 27, 2026
b282657
build: advance the hf-space submodule, and keep --seed after all
JonnyTran Aug 27, 2026
c6568d3
refactor(ocr): project elements columnar, as one DuckDB statement
JonnyTran Aug 31, 2026
5af997b
refactor(ocr): delete the docling reimplementations, render elements …
JonnyTran Sep 3, 2026
cb55a37
refactor(ocr): collapse the element projection to two CTEs
JonnyTran Sep 4, 2026
c9648d4
docs: rule against reimplementing what the libraries already do
JonnyTran Sep 4, 2026
2406ed8
refactor(ocr): unwind the element layer; docling's chunker owns it
JonnyTran Sep 7, 2026
15c6195
build(server): take the lance extension from PyPI instead of fetching…
JonnyTran Sep 9, 2026
5e99252
docs: update gotchas section for clarity on helper function guidelines
JonnyTran Sep 9, 2026
ed31a81
fix(ocr): file page headers and footers under the furniture content l…
JonnyTran Sep 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,6 @@ tag atomically. The version lives in three files — always change it with
`python scripts/bump_version.py set --version X.Y.Z`, never by hand.

See `docs/architecture/deployment.md` for the full pipeline

## Gotchas & Rules
- **Check the library before writing a helper.** Before adding any function that renders, serializes, parses or walks a `DoclingDocument`, or splits chunks or fuses scores.
4 changes: 4 additions & 0 deletions extralit-server/docker/server/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Allowlist: the context is a wheel and one script, whatever stray build output lands here.
*
!scripts/*.sh
!dist/*.whl
53 changes: 34 additions & 19 deletions extralit-server/docker/server/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,23 +1,34 @@
FROM python:3.12-slim AS builder

# Install uv
COPY --from=ghcr.io/astral-sh/uv:0.7.12 /uv /uvx /bin/
# Copying extralit distribution files
COPY dist/*.whl /packages/
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
ENV MAMBA_ROOT_PREFIX=/opt/venv
ENV CONDA_PREFIX=/opt/venv
ENV UV_CACHE_DIR=/home/extralit/.cache/uv
COPY --from=ghcr.io/astral-sh/uv:0.12.6 /uv /uvx /bin/

RUN --mount=type=cache,target=/home/extralit/.cache/uv \
apt-get update && \
ENV VIRTUAL_ENV=/opt/venv \
PATH="/opt/venv/bin:$PATH" \
UV_CACHE_DIR=/root/.cache/uv \
# Pay for the .pyc files once here instead of on every worker's first import.
UV_COMPILE_BYTECODE=1 \
# The cache mount is a different filesystem from /opt/venv, so hardlinking cannot work.
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0

# psycopg2 is built from source. The toolchain stays in this stage — the runtime image copies
# nothing out of it but the venv, so there is nothing to purge afterwards.
RUN apt-get update && \
apt-get upgrade -y && \
apt-get install -y --no-install-recommends libc6-dev libpq-dev gcc && \
for wheel in /packages/*.whl; do uv pip install "$wheel"[postgresql]; done && \
apt-get purge -y --auto-remove libc6-dev libpq-dev gcc && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /packages
rm -rf /var/lib/apt/lists/*

# --seed, though nothing here needs pip: without it `pip` falls through to the base image's
# /usr/local/bin/pip, which installs where /opt/venv/bin/python cannot see it. Dropping it saves
# 5 MB of 1847 and hands any derived image a silent wrong-interpreter install.
RUN uv venv --seed "$VIRTUAL_ENV"

# The wheel is the only input that differs between two builds of the same tree, so it enters
# last — and as a bind mount, which leaves no copy of it behind in the stage.
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=dist,target=/packages \
for wheel in /packages/*.whl; do uv pip install "$wheel"[postgresql]; done

FROM python:3.12-slim

Expand All @@ -39,21 +50,25 @@ RUN mkdir -p "$EXTRALIT_HOME_PATH" && \
chown extralit:extralit "$EXTRALIT_HOME_PATH" && \
apt-get update && \
apt-get upgrade -y && \
apt-get install -y --no-install-recommends libpq-dev libgl1 libglib2.0-0 poppler-utils && \
apt-get install -y --no-install-recommends libpq-dev libgl1 libglib2.0-0 poppler-utils tesseract-ocr-eng && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
VOLUME $EXTRALIT_HOME_PATH

COPY scripts/start_extralit_server.sh /home/extralit
# liteparse would otherwise download the language data on the first scanned page it meets.
ENV TESSDATA_PREFIX=/usr/share/tesseract-ocr/5/tessdata
RUN test -f "$TESSDATA_PREFIX/eng.traineddata"

# Layers below are ordered by how often they change, because a rebuilt layer rebuilds every layer
# under it.
COPY --chmod=0755 scripts/start_extralit_server.sh /home/extralit/
# Destination folder must be the same as the builder one. Otherwise installed script won't work (since the installation fixes the path inside the script)
COPY --chown=extralit:extralit --from=builder /opt/venv /opt/venv

ENV VIRTUAL_ENV=/opt/venv
ENV PATH="/opt/venv/bin:$PATH"
ENV MAMBA_ROOT_PREFIX=/opt/venv
ENV CONDA_PREFIX=/opt/venv

WORKDIR /home/extralit
RUN chmod +x start_extralit_server.sh

USER extralit

Expand Down
11 changes: 11 additions & 0 deletions extralit-server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,20 @@ dependencies = [
"lancedb>=0.37.1",
"pylance>=10.0.0",
"duckdb>=1.5.4",
# ships lance.duckdb_extension in the wheel and pins duckdb to the release it was built for
"duckdb-extension-lance>=1.5.5",
"docling-core>=2.91.0,<3.0.0",
"pyarrow>=23.0.1",
"pdf-inspector>=1.14.2",
# abi3 wheels from 2.14, so the block extractor is not tied to one CPython
"liteparse>=2.14.0",
# what docling_core.transforms.chunker imports; the [chunking] extra would add transformers
"semchunk>=2.2",
"tree-sitter>=0.25,<0.27",
"tree-sitter-python",
"tree-sitter-c",
"tree-sitter-javascript",
"tree-sitter-typescript",
"xxhash>=3.6.0",
"obstore>=0.11.0",
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
Size,
TableCell,
)
from docling_core.types.doc.document import DocumentOrigin, NodeItem
from docling_core.types.doc.document import DocumentOrigin, FloatingItem, NodeItem, PictureItem, TableItem

PDF_MIMETYPE = "application/pdf"

Expand All @@ -34,6 +34,9 @@
#: Captions and footnotes legitimately overlap their figure, so containment must not drop them.
CONTAINMENT_EXEMPT_LABELS = frozenset({DocItemLabel.CAPTION, DocItemLabel.FOOTNOTE})

#: How far either side of a caption its figure or table may sit, nearest first, before after.
CAPTION_REACH = (-1, 1, -2, 2)


@dataclass(frozen=True)
class LayoutBlock:
Expand Down Expand Up @@ -153,6 +156,29 @@ def sort_body_by_position(doc: DoclingDocument) -> None:
doc.body.children.sort(key=lambda ref: _sort_key(doc, ref.resolve(doc)))


def _page_of(item: NodeItem) -> Optional[int]:
provs = getattr(item, "prov", None) or []
return provs[0].page_no if provs else None


def link_captions(doc: DoclingDocument) -> None:
"""Attach each unlinked caption to the nearest table or picture on its page, looking back first.

docling serializes a linked caption inside its owner and nowhere else, so this is what keeps a
caption out of the prose stream and inside the table or figure that it names.
"""
body = [ref.resolve(doc) for ref in doc.body.children]
linked = {ref.cref for item in body if isinstance(item, FloatingItem) for ref in item.captions}
for index, item in enumerate(body):
if getattr(item, "label", None) != DocItemLabel.CAPTION or item.self_ref in linked:
continue
for offset in CAPTION_REACH:
owner = body[index + offset] if 0 <= index + offset < len(body) else None
if isinstance(owner, (TableItem, PictureItem)) and _page_of(owner) == _page_of(item):
owner.captions.append(item.get_ref())
break


def append_blocks(
doc: DoclingDocument,
ctx: PageContext,
Expand Down Expand Up @@ -185,4 +211,5 @@ def append_blocks(
added.append(item)

sort_body_by_position(doc)
link_captions(doc)
return added
8 changes: 6 additions & 2 deletions extralit-server/src/extralit_server/contexts/ocr/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from typing import Optional

from docling_core.types.doc import DocItemLabel, DoclingDocument
from docling_core.types.doc import ContentLayer, DocItemLabel, DoclingDocument
from docling_core.types.doc.document import NodeItem

from extralit_server.contexts.ocr.docling_builder import (
Expand All @@ -15,6 +15,9 @@
make_prov,
)

# The labels docling itself files as furniture; a label alone does not keep them out of chunks.
FURNITURE_LABELS = frozenset({DocItemLabel.PAGE_HEADER, DocItemLabel.PAGE_FOOTER})


def add_text_block(
doc: DoclingDocument,
Expand All @@ -36,4 +39,5 @@ def add_text_block(
if block.label == DocItemLabel.SECTION_HEADER:
return doc.add_heading(text=text, level=block.level or 1, prov=prov, parent=parent)

return doc.add_text(label=block.label, text=text, prov=prov, parent=parent)
layer = ContentLayer.FURNITURE if block.label in FURNITURE_LABELS else ContentLayer.BODY
return doc.add_text(label=block.label, text=text, prov=prov, parent=parent, content_layer=layer)
4 changes: 2 additions & 2 deletions extralit-server/tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import pytest
import pytest_asyncio
from httpx import AsyncClient
from httpx import ASGITransport, AsyncClient

from extralit_server.constants import API_KEY_HEADER_NAME
from extralit_server.database import get_async_db
Expand Down Expand Up @@ -39,7 +39,7 @@ async def override_get_async_db():

api_v1.dependency_overrides[get_async_db] = override_get_async_db

async with AsyncClient(app=app, base_url="http://testserver") as client:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client:
yield client

# Pop only what this fixture registered. `tests/unit/conftest.py`'s async_client writes
Expand Down
44 changes: 26 additions & 18 deletions extralit-server/tests/unit/api/handlers/v1/test_datasets.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import math
import uuid
from datetime import datetime
Expand Down Expand Up @@ -82,6 +83,11 @@
from sqlalchemy.ext.asyncio import AsyncSession


def nan_body(payload: dict, headers: dict) -> dict:
"""Post a body the JSON spec forbids. `json=` cannot: httpx encodes with allow_nan=False."""
return {"content": json.dumps(payload), "headers": {**headers, "Content-Type": "application/json"}}


@pytest.mark.asyncio
class TestSuiteDatasets:
async def test_list_current_user_datasets(self, async_client: "AsyncClient", owner_auth_header: dict) -> None:
Expand Down Expand Up @@ -1959,7 +1965,7 @@ async def test_create_dataset_records_with_metadata_nan_values(
}

response = await async_client.post(
f"/api/v1/datasets/{dataset.id}/records/bulk", headers=owner_auth_header, json=records_json
f"/api/v1/datasets/{dataset.id}/records/bulk", **nan_body(records_json, owner_auth_header)
)

assert response.status_code == 422
Expand Down Expand Up @@ -3027,23 +3033,25 @@ async def test_update_dataset_records_with_metadata_nan_value(

response = await async_client.put(
f"/api/v1/datasets/{dataset.id}/records/bulk",
headers=owner_auth_header,
json={
"items": [
{
"id": str(records[0].id),
"metadata": {"terms": math.nan},
},
{
"id": str(records[1].id),
"metadata": {"float": math.nan},
},
{
"id": str(records[2].id),
"metadata": {"terms": "a"},
},
]
},
**nan_body(
{
"items": [
{
"id": str(records[0].id),
"metadata": {"terms": math.nan},
},
{
"id": str(records[1].id),
"metadata": {"float": math.nan},
},
{
"id": str(records[2].id),
"metadata": {"terms": "a"},
},
]
},
owner_auth_header,
),
)

assert response.status_code == 422
Expand Down
4 changes: 2 additions & 2 deletions extralit-server/tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import pytest
import pytest_asyncio
from httpx import AsyncClient
from httpx import ASGITransport, AsyncClient
from opensearchpy import OpenSearch
from sqlalchemy.engine.interfaces import IsolationLevel

Expand Down Expand Up @@ -95,7 +95,7 @@ async def override_get_search_engine():
}
)

async with AsyncClient(app=app, base_url="http://testserver") as async_client:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as async_client:
yield async_client

# Clear from `api_v1` — that is where the overrides above were registered. Clearing
Expand Down
Loading
Loading