Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
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
74 changes: 74 additions & 0 deletions extralit-server/tests/unit/contexts/ocr/test_docling_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,80 @@ def test_captions_are_kept_even_when_they_touch_a_figure(self, doc, ctx):

assert [t.text for t in doc.texts] == ["Figure 1."]

def test_a_caption_is_linked_to_the_figure_it_touches(self, doc, ctx):
blocks = [
LayoutBlock(label=DocItemLabel.PICTURE, bbox=bbox(t=100, b=300, left=0, right=500)),
LayoutBlock(label=DocItemLabel.CAPTION, bbox=bbox(t=310, b=320), text="Figure 1."),
]

append_blocks(doc, ctx, blocks)

assert doc.pictures[0].caption_text(doc) == "Figure 1."

def test_a_caption_above_its_table_binds_to_it(self, doc, ctx):
blocks = [
LayoutBlock(label=DocItemLabel.CAPTION, bbox=bbox(t=90, b=99), text="Table 1."),
LayoutBlock(label=DocItemLabel.TABLE, bbox=bbox(t=100, b=300)),
]

append_blocks(doc, ctx, blocks)

assert doc.tables[0].caption_text(doc) == "Table 1."

def test_the_nearer_neighbour_wins_when_a_caption_sits_between_two_figures(self, doc, ctx):
blocks = [
LayoutBlock(label=DocItemLabel.PICTURE, bbox=bbox(t=100, b=200)),
LayoutBlock(label=DocItemLabel.CAPTION, bbox=bbox(t=210, b=220), text="Belongs to the first."),
LayoutBlock(label=DocItemLabel.TEXT, bbox=bbox(t=230, b=240), text="Prose."),
LayoutBlock(label=DocItemLabel.PICTURE, bbox=bbox(t=300, b=400)),
]

append_blocks(doc, ctx, blocks)

assert doc.pictures[0].caption_text(doc) == "Belongs to the first."
assert doc.pictures[1].captions == []

def test_a_caption_reaches_past_one_line_of_prose(self, doc, ctx):
blocks = [
LayoutBlock(label=DocItemLabel.PICTURE, bbox=bbox(t=100, b=200)),
LayoutBlock(label=DocItemLabel.FOOTNOTE, bbox=bbox(t=205, b=208), text="a"),
LayoutBlock(label=DocItemLabel.CAPTION, bbox=bbox(t=210, b=220), text="Figure 1."),
]

append_blocks(doc, ctx, blocks)

assert doc.pictures[0].caption_text(doc) == "Figure 1."

def test_two_captions_on_one_figure_are_both_linked(self, doc, ctx):
blocks = [
LayoutBlock(label=DocItemLabel.CAPTION, bbox=bbox(t=90, b=99), text="Figure 1."),
LayoutBlock(label=DocItemLabel.PICTURE, bbox=bbox(t=100, b=200)),
LayoutBlock(label=DocItemLabel.CAPTION, bbox=bbox(t=210, b=220), text="A red square."),
]

append_blocks(doc, ctx, blocks)

assert [ref.resolve(doc).text for ref in doc.pictures[0].captions] == ["Figure 1.", "A red square."]

def test_a_caption_never_binds_across_a_page(self, doc, ctx):
append_blocks(doc, ctx, [LayoutBlock(label=DocItemLabel.PICTURE, bbox=bbox(t=700, b=780))])
page_two = PageContext(page_no=2, size=ctx.size)

append_blocks(doc, page_two, [LayoutBlock(label=DocItemLabel.CAPTION, bbox=bbox(t=10, b=20), text="Orphan.")])

assert doc.pictures[0].captions == []

def test_a_second_pass_over_a_page_links_nothing_twice(self, doc, ctx):
blocks = [
LayoutBlock(label=DocItemLabel.PICTURE, bbox=bbox(t=100, b=200)),
LayoutBlock(label=DocItemLabel.CAPTION, bbox=bbox(t=210, b=220), text="Figure 1."),
]
append_blocks(doc, ctx, blocks)

append_blocks(doc, ctx, [LayoutBlock(label=DocItemLabel.TEXT, bbox=bbox(t=400, b=420), text="More.")])

assert len(doc.pictures[0].captions) == 1

def test_every_item_carries_a_full_provenance_triple(self, doc, ctx):
blocks = [
LayoutBlock(label=DocItemLabel.TEXT, bbox=bbox(t=10, b=30), text="hello"),
Expand Down
Loading
Loading