Merge pull request #29 from scub-france/release/0.3.0
Some checks failed
Release Docker Images / Build & push — local (push) Has been cancelled
Release Docker Images / Build & push — remote (push) Has been cancelled

Release/0.3.0
This commit is contained in:
Pier-Jean Malandrino 2026-04-07 09:00:02 +02:00 committed by GitHub
commit 11eaeb4cd6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
100 changed files with 5760 additions and 935 deletions

View file

@ -1,3 +1,27 @@
# Docker Compose build target: "local" or "remote" (selects Dockerfile target)
# CONVERSION_MODE=local
# Conversion engine: "local" (in-process Docling) or "remote" (Docling Serve)
# Set automatically by the Docker target — override only for local dev
# CONVERSION_ENGINE=local
# Docling Serve settings (remote mode only)
# DOCLING_SERVE_URL=http://localhost:5001
# DOCLING_SERVE_API_KEY=
# Max seconds per conversion (default: 600)
# CONVERSION_TIMEOUT=600
# Max parallel analysis jobs (default: 3)
# MAX_CONCURRENT_ANALYSES=3
# Deployment mode: "self-hosted" (default) or "huggingface"
# Shows disclaimer banner when set to "huggingface"
# DEPLOYMENT_MODE=self-hosted
# Application version (set automatically by CI/Docker build)
# APP_VERSION=dev
# CORS (comma-separated origins, only needed for custom deployments) # CORS (comma-separated origins, only needed for custom deployments)
# CORS_ORIGINS=http://localhost:3000,https://your-domain.com # CORS_ORIGINS=http://localhost:3000,https://your-domain.com

View file

@ -1,4 +1,4 @@
name: Release Docker Image name: Release Docker Images
on: on:
push: push:
@ -11,12 +11,16 @@ env:
jobs: jobs:
release: release:
name: Build & push Docker image name: Build & push — ${{ matrix.target }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write packages: write
strategy:
matrix:
target: [remote, local]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -28,22 +32,28 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version from tag - name: Docker metadata
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=semver,pattern={{version}} type=semver,pattern={{version}},suffix=-${{ matrix.target }}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}},suffix=-${{ matrix.target }}
type=raw,value=latest type=raw,value=latest-${{ matrix.target }}
- name: Extract version from tag
id: version
run: echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v6 - uses: docker/build-push-action@v6
with: with:
context: . context: .
target: ${{ matrix.target }}
push: true push: true
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
build-args: APP_VERSION=${{ steps.version.outputs.value }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha,scope=${{ matrix.target }}
cache-to: type=gha,mode=max cache-to: type=gha,mode=max,scope=${{ matrix.target }}

5
.gitignore vendored
View file

@ -11,6 +11,8 @@ site/
.vscode/ .vscode/
.run/ .run/
.claude/ .claude/
CLAUDE.md
**/CLAUDE.md
*.iml *.iml
# OS # OS
@ -32,6 +34,9 @@ __pycache__/
.venv/ .venv/
*.egg-info/ *.egg-info/
# Excalidraw working files
docs/excalidraw/
# Logs # Logs
*.log *.log
hs_err_pid* hs_err_pid*

View file

@ -4,6 +4,38 @@ All notable changes to Docling Studio will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
## [0.3.0] - 2026-04-07
### Added
- Chunking support: domain objects, persistence, API endpoints, and frontend Prepare mode
- Chunk-to-bbox hover highlighting in Prepare mode
- Page filtering and collapsible config in Prepare mode
- Feature flipping mechanism
- Reusable pagination composable and PaginationBar component
- Version display in sidebar, settings page, and health endpoint
### Fixed
- Feature flag health check blocked by CORS
- Zombie jobs and unprotected JSON parse
- Upload error not displayed in DocumentUpload component
- Serve API contract: send `to_formats` as repeated form fields
- Audit findings: security, robustness, dead code, domain-infra violation
### Changed
- Refactored backend to hexagonal architecture for converter extensibility
- Added ServeConverter adapter for remote Docling Serve integration
- Moved `@vitest/mocker` from dependencies to devDependencies
## [0.2.0] - 2025-05-14
### Added
- Multi-arch Docker image release pipeline (GitHub Actions)
- Docker image published to `ghcr.io/scub-france/Docling-Studio`
## [0.1.0] - 2025-01-01 ## [0.1.0] - 2025-01-01
### Added ### Added

View file

@ -22,7 +22,13 @@ Thank you for your interest in contributing to Docling Studio! This guide will h
```bash ```bash
cd document-parser cd document-parser
python -m venv .venv && source .venv/bin/activate python -m venv .venv && source .venv/bin/activate
# Remote mode (lightweight — delegates to Docling Serve)
pip install -r requirements.txt pip install -r requirements.txt
# Local mode (full — runs Docling in-process)
pip install -r requirements-local.txt
pip install ruff pytest pytest-asyncio httpx # dev tools pip install ruff pytest pytest-asyncio httpx # dev tools
uvicorn main:app --reload --port 8000 uvicorn main:app --reload --port 8000
``` ```
@ -61,11 +67,11 @@ npx prettier --write src/ # auto-format
## Running Tests ## Running Tests
```bash ```bash
# Backend (99 tests) # Backend (199 tests)
cd document-parser cd document-parser
pytest tests/ -v pytest tests/ -v
# Frontend (81 tests) # Frontend (129 tests)
cd frontend cd frontend
npm run test:run npm run test:run
``` ```
@ -80,6 +86,79 @@ All tests must pass before submitting a PR.
4. Describe **what** changed and **why** in the PR description 4. Describe **what** changed and **why** in the PR description
5. Ensure CI passes (tests + build) 5. Ensure CI passes (tests + build)
## Branching Strategy
We follow a simplified Git Flow:
| Branch | Purpose |
|--------|---------|
| `main` | Always stable — latest release merged back |
| `release/X.Y.Z` | Release preparation (freeze, bugfixes, changelog) |
| `feature/*` | New features — PR to `main` |
| `fix/*` | Bug fixes — PR to `main` (or `release/*` for pre-release fixes) |
| `hotfix/X.Y.Z` | Urgent fix on a released version — PR to `main` |
Rules:
- All PRs target `main` (never stack branches on other feature branches)
- `release/*` branches are created from `main` when preparing a release
- `hotfix/*` branches are created from the release tag
## Versioning
We use [Semantic Versioning](https://semver.org/): `MAJOR.MINOR.PATCH`.
- **Source of truth**: the git tag (`vX.Y.Z`)
- `package.json` version should match the current release branch
- The build injects the version automatically (Vite `__APP_VERSION__` for frontend, `APP_VERSION` env var for backend)
## Release Process
1. **Create the release branch** from `main`:
```bash
git checkout main && git pull
git checkout -b release/X.Y.Z
```
2. **On the release branch**, only:
- Bug fixes
- Move `[Unreleased]` to `[X.Y.Z] - YYYY-MM-DD` in `CHANGELOG.md`
- Update `version` in `frontend/package.json`
3. **Merge into `main`** via PR, then **tag on `main`**:
```bash
git checkout main && git pull
git tag vX.Y.Z
git push origin vX.Y.Z
```
4. The tag triggers the **release workflow** which builds and pushes the Docker image to `ghcr.io`.
### Docker Image Tags
Each release produces two image variants:
| Tag | Description |
|-----|-------------|
| `X.Y.Z-remote` | Exact version — lightweight (Docling Serve) |
| `X.Y.Z-local` | Exact version — full (in-process Docling) |
| `X.Y-remote` | Latest patch of this minor — lightweight |
| `X.Y-local` | Latest patch of this minor — full |
| `latest-remote` | Latest stable — lightweight |
| `latest-local` | Latest stable — full |
### Hotfix
```bash
git checkout vX.Y.Z # from the release tag
git checkout -b hotfix/X.Y.Z+1
# fix, commit, PR to main
git tag vX.Y.Z+1 # tag on main after merge
```
### Changelog
We follow [Keep a Changelog](https://keepachangelog.com/). Every PR should add a line under `[Unreleased]` in `CHANGELOG.md`. The release branch moves `[Unreleased]` to the versioned section.
## Pull Request Guidelines ## Pull Request Guidelines
- Keep PRs focused — one feature or fix per PR - Keep PRs focused — one feature or fix per PR

View file

@ -1,33 +1,36 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ============================================================================= # =============================================================================
# Docling Studio — single-image build (frontend + backend) # Docling Studio — single-image build (frontend + backend, multi-target)
# #
# Usage: # Usage:
# docker build -t docling-studio . # docker build --target remote -t docling-studio:remote .
# docker run -p 3000:3000 docling-studio # docker build --target local -t docling-studio:local .
# ============================================================================= # =============================================================================
# --- Stage 1: Build frontend assets --- # --- Stage 1: Build frontend assets ---
FROM node:20-alpine AS frontend-build FROM node:20-alpine AS frontend-build
ARG APP_VERSION=dev
WORKDIR /build WORKDIR /build
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm ci RUN npm ci
COPY frontend/ . COPY frontend/ .
RUN npm run build RUN VITE_APP_VERSION=${APP_VERSION} npm run build
# --- Stage 2: Runtime (Python + Nginx) --- # --- Stage 2: Base runtime (Python + Nginx) ---
FROM python:3.12-slim FROM python:3.12-slim AS base
# System deps: poppler (pdf2image), nginx, and OpenCV runtime libs ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
# System deps: poppler (pdf2image), nginx
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
poppler-utils \ poppler-utils \
libgl1 \
libglib2.0-0 \
nginx \ nginx \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Python deps # Python deps (common)
WORKDIR /app WORKDIR /app
COPY document-parser/requirements.txt . COPY document-parser/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
@ -52,5 +55,24 @@ ENV DB_PATH=/app/data/docling_studio.db
EXPOSE 3000 EXPOSE 3000
# nginx needs to run as root for port binding, then drops to appuser for uvicorn
CMD ["sh", "-c", "nginx && exec su appuser -c 'uvicorn main:app --host 127.0.0.1 --port 8000'"] CMD ["sh", "-c", "nginx && exec su appuser -c 'uvicorn main:app --host 127.0.0.1 --port 8000'"]
# --- Remote: lightweight, delegates to Docling Serve ---
FROM base AS remote
ENV CONVERSION_ENGINE=remote
# --- Local: full Docling in-process ---
FROM base AS local
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
COPY document-parser/requirements-local.txt .
RUN pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu \
&& pip install --no-cache-dir -r requirements-local.txt
RUN chown -R appuser:appuser /app \
&& chown -R appuser:appuser /usr/local/lib/python3.12/site-packages/rapidocr/models
ENV CONVERSION_ENGINE=local

View file

@ -5,6 +5,7 @@
![Node](https://img.shields.io/badge/node-20+-green) ![Node](https://img.shields.io/badge/node-20+-green)
![Docling](https://img.shields.io/badge/powered%20by-Docling-orange) ![Docling](https://img.shields.io/badge/powered%20by-Docling-orange)
![CI](https://github.com/scub-france/Docling-Studio/actions/workflows/ci.yml/badge.svg) ![CI](https://github.com/scub-france/Docling-Studio/actions/workflows/ci.yml/badge.svg)
[![GitHub Stars](https://img.shields.io/github/stars/scub-france/Docling-Studio?style=flat-square&logo=github&label=Stars)](https://github.com/scub-france/Docling-Studio)
A visual document analysis studio powered by [Docling](https://github.com/DS4SD/docling). A visual document analysis studio powered by [Docling](https://github.com/DS4SD/docling).
Upload a PDF, configure the extraction pipeline, and visualize the results — text, tables, images, formulas, bounding boxes — all from your browser. Upload a PDF, configure the extraction pipeline, and visualize the results — text, tables, images, formulas, bounding boxes — all from your browser.
@ -48,8 +49,8 @@ document-parser/
├── main.py # FastAPI app, CORS, lifespan ├── main.py # FastAPI app, CORS, lifespan
├── domain/ # Pure domain — no HTTP, no DB ├── domain/ # Pure domain — no HTTP, no DB
│ ├── models.py # Document, AnalysisJob dataclasses │ ├── models.py # Document, AnalysisJob dataclasses
│ ├── parsing.py # Docling conversion & page extraction │ ├── ports.py # Abstract protocols (converter, chunker)
│ └── bbox.py # Bounding box coordinate normalization │ └── value_objects.py # ConversionResult, PageDetail, ChunkResult
├── api/ # HTTP layer (FastAPI routers) ├── api/ # HTTP layer (FastAPI routers)
│ ├── schemas.py # Pydantic DTOs (camelCase serialization) │ ├── schemas.py # Pydantic DTOs (camelCase serialization)
│ ├── documents.py # /api/documents endpoints │ ├── documents.py # /api/documents endpoints
@ -61,7 +62,7 @@ document-parser/
├── services/ # Use case orchestration ├── services/ # Use case orchestration
│ ├── document_service.py # Upload, delete, preview │ ├── document_service.py # Upload, delete, preview
│ └── analysis_service.py # Async Docling processing │ └── analysis_service.py # Async Docling processing
└── tests/ # 99 tests (pytest) └── tests/ # 199 tests (pytest)
``` ```
### Frontend structure (feature-based) ### Frontend structure (feature-based)
@ -85,22 +86,42 @@ frontend/src/
## Quick Start ## Quick Start
### Docker (fastest) Docling Studio ships two Docker image variants:
| Variant | Image tag | Size | Description |
|---------|-----------|------|-------------|
| **remote** | `latest-remote` | ~270 MB | Lightweight — delegates to an external [Docling Serve](https://github.com/DS4SD/docling-serve) instance |
| **local** | `latest-local` | ~1.9 GB | Full — runs Docling in-process, CPU-only (downloads ML models on first run) |
### Docker — remote mode (fastest)
```bash ```bash
docker run -p 3000:3000 ghcr.io/scub-france/docling-studio:latest docker run -p 3000:3000 \
-e DOCLING_SERVE_URL=http://your-docling-serve:5001 \
ghcr.io/scub-france/docling-studio:latest-remote
``` ```
Open [http://localhost:3000](http://localhost:3000) ### Docker — local mode (self-contained)
```bash
docker run -p 3000:3000 ghcr.io/scub-france/docling-studio:latest-local
```
> **Note:** The first analysis takes longer as Docling downloads its ML models (~400 MB). Subsequent runs are fast. > **Note:** The first analysis takes longer as Docling downloads its ML models (~400 MB). Subsequent runs are fast.
Open [http://localhost:3000](http://localhost:3000)
### Docker Compose (for development) ### Docker Compose (for development)
```bash ```bash
git clone https://github.com/scub-france/Docling-Studio.git git clone https://github.com/scub-france/Docling-Studio.git
cd Docling-Studio cd Docling-Studio
# Local mode (default)
docker compose up --build docker compose up --build
# Remote mode
CONVERSION_MODE=remote DOCLING_SERVE_URL=http://your-docling-serve:5001 docker compose up --build
``` ```
### Local Development ### Local Development
@ -109,7 +130,13 @@ docker compose up --build
```bash ```bash
cd document-parser cd document-parser
python -m venv .venv && source .venv/bin/activate python -m venv .venv && source .venv/bin/activate
# Remote mode (lightweight)
pip install -r requirements.txt pip install -r requirements.txt
# Local mode (with Docling)
pip install -r requirements-local.txt
uvicorn main:app --reload --port 8000 uvicorn main:app --reload --port 8000
``` ```
@ -123,12 +150,12 @@ npm run dev
### Running Tests ### Running Tests
```bash ```bash
# Backend (99 tests) # Backend (199 tests)
cd document-parser cd document-parser
pip install pytest pytest-asyncio httpx pip install pytest pytest-asyncio httpx
pytest tests/ -v pytest tests/ -v
# Frontend (81 tests) # Frontend (129 tests)
cd frontend cd frontend
npm run test:run npm run test:run
``` ```
@ -156,6 +183,9 @@ All configuration is done via environment variables. See [`.env.example`](.env.e
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `CONVERSION_ENGINE` | `local` | `local` (in-process Docling) or `remote` (Docling Serve) |
| `DOCLING_SERVE_URL` | `http://localhost:5001` | Docling Serve endpoint (remote mode only) |
| `DOCLING_SERVE_API_KEY` | — | API key for Docling Serve (optional) |
| `CORS_ORIGINS` | `http://localhost:3000,...` | CORS allowed origins (comma-separated) | | `CORS_ORIGINS` | `http://localhost:3000,...` | CORS allowed origins (comma-separated) |
| `UPLOAD_DIR` | `./uploads` | File storage directory | | `UPLOAD_DIR` | `./uploads` | File storage directory |
| `DB_PATH` | `./data/docling_studio.db` | SQLite database path | | `DB_PATH` | `./data/docling_studio.db` | SQLite database path |
@ -167,14 +197,11 @@ GitHub Actions pipelines (see [`.github/workflows/`](.github/workflows/)):
| Workflow | Trigger | What it does | | Workflow | Trigger | What it does |
|----------|---------|--------------| |----------|---------|--------------|
| **CI** | push to `main`, pull requests | Lint + type check + Backend tests (99) + Frontend tests (81) + build | | **CI** | push to `main`, pull requests | Lint + type check + Backend tests + Frontend tests + build |
| **Release** | push tag `v*` | Build & push multi-arch Docker image to `ghcr.io` | | **Release** | push tag `v*` | Build & push **two** multi-arch Docker images (`remote` + `local`) to `ghcr.io` |
| **Docs** | push to `main` (docs changes) | Build & deploy MkDocs to GitHub Pages |
To publish a new version: We follow [Semantic Versioning](https://semver.org/) with a simplified Git Flow. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full release process.
```bash
git tag v0.2.0
git push origin v0.2.0
```
## Performance & System Requirements ## Performance & System Requirements
@ -186,12 +213,11 @@ git push origin v0.2.0
### Docker Desktop settings ### Docker Desktop settings
The document parser needs **at least 4 GB of RAM**: | | Remote image | Local image |
|---|---|---|
| Resource | Minimum | Recommended | | **Image size** | ~270 MB | ~1.9 GB |
|----------|---------|-------------| | **Memory** | 2 GB | 6 GB (recommended 8 GB+) |
| Memory | 6 GB | 8 GB+ | | **CPUs** | 2 | 4 (recommended 8+) |
| CPUs | 4 | 8+ |
### Platform support ### Platform support

View file

@ -2,6 +2,7 @@ services:
document-parser: document-parser:
build: build:
context: ./document-parser context: ./document-parser
target: ${CONVERSION_MODE:-local}
expose: expose:
- "8000" - "8000"
volumes: volumes:
@ -9,6 +10,8 @@ services:
- db_data:/app/data - db_data:/app/data
environment: environment:
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000,http://localhost:5173} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000,http://localhost:5173}
DOCLING_SERVE_URL: ${DOCLING_SERVE_URL:-}
DOCLING_SERVE_API_KEY: ${DOCLING_SERVE_API_KEY:-}
deploy: deploy:
resources: resources:
limits: limits:

View file

@ -2,7 +2,7 @@
## Overview ## Overview
![Docling Studio architecture](images/archi.png){ width="700" } ![Docling Studio architecture](images/global.png){ width="700" }
Two services communicating via REST. The frontend is a Vue 3 SPA served by Nginx in production. The backend is a FastAPI app that wraps Docling's document conversion engine. Two services communicating via REST. The frontend is a Vue 3 SPA served by Nginx in production. The backend is a FastAPI app that wraps Docling's document conversion engine.
@ -40,37 +40,47 @@ The backend follows a strict layered architecture. Dependencies flow inward: API
``` ```
document-parser/ document-parser/
├── main.py # FastAPI app, CORS, lifespan ├── main.py # FastAPI app, CORS, lifespan, health endpoint
├── domain/ # Pure domain — no HTTP, no DB ├── domain/ # Pure domain — no HTTP, no DB
│ ├── models.py # Document, AnalysisJob dataclasses │ ├── models.py # Document, AnalysisJob dataclasses
│ ├── parsing.py # Docling conversion & page extraction │ ├── ports.py # Abstract protocols (DocumentConverter, DocumentChunker)
│ ├── value_objects.py # ConversionResult, ChunkingOptions, ChunkResult
│ └── bbox.py # Bounding box coordinate normalization │ └── bbox.py # Bounding box coordinate normalization
├── api/ # HTTP layer (FastAPI routers) ├── api/ # HTTP layer (FastAPI routers)
│ ├── schemas.py # Pydantic DTOs (camelCase serialization) │ ├── schemas.py # Pydantic DTOs (camelCase serialization)
│ ├── documents.py # /api/documents endpoints │ ├── documents.py # /api/documents endpoints
│ └── analyses.py # /api/analyses endpoints │ └── analyses.py # /api/analyses endpoints (create, rechunk, delete)
├── persistence/ # Data layer (SQLite via aiosqlite) ├── persistence/ # Data layer (SQLite via aiosqlite)
│ ├── database.py # Connection management, schema init │ ├── database.py # Connection management, schema init
│ ├── document_repo.py # Document CRUD │ ├── document_repo.py # Document CRUD
│ └── analysis_repo.py # AnalysisJob CRUD │ └── analysis_repo.py # AnalysisJob CRUD
├── infra/ # Infrastructure adapters
│ ├── settings.py # Environment-based configuration
│ ├── local_converter.py # In-process Docling converter (local mode)
│ ├── serve_converter.py # HTTP client for Docling Serve (remote mode)
│ ├── local_chunker.py # In-process chunking (HierarchicalChunker, HybridChunker)
│ ├── rate_limiter.py # Sliding-window rate limiting middleware
│ └── bbox.py # Bbox coordinate normalization helpers
├── services/ # Use case orchestration ├── services/ # Use case orchestration
│ ├── document_service.py # Upload, delete, preview │ ├── document_service.py # Upload, delete, preview
│ └── analysis_service.py # Async Docling processing │ └── analysis_service.py # Async Docling processing + chunking
└── tests/ # pytest └── tests/ # pytest (199 tests)
``` ```
### Layer responsibilities ### Layer responsibilities
| Layer | Role | Depends on | | Layer | Role | Depends on |
|-------|------|------------| |-------|------|------------|
| **domain** | Dataclasses, bbox math, Docling conversion | Nothing (pure Python) | | **domain** | Dataclasses, value objects, abstract ports | Nothing (pure Python) |
| **persistence** | SQLite CRUD, aiosqlite | domain (models) | | **persistence** | SQLite CRUD, aiosqlite | domain (models) |
| **services** | Orchestrate use cases, call Docling | domain + persistence | | **infra** | Adapters: converters, chunker, rate limiter, settings | domain (ports, value objects) |
| **services** | Orchestrate use cases, call converters/chunkers | domain + persistence + infra |
| **api** | HTTP endpoints, Pydantic DTOs, error handling | services | | **api** | HTTP endpoints, Pydantic DTOs, error handling | services |
### API contract ### API contract
@ -101,7 +111,9 @@ frontend/src/
│ │ ├── AnalysisPanel.vue │ │ ├── AnalysisPanel.vue
│ │ ├── StructureViewer.vue │ │ ├── StructureViewer.vue
│ │ └── ... │ │ └── ...
│ ├── chunking/ # Chunk panel UI + rechunk action
│ ├── document/ # Document store, API, upload │ ├── document/ # Document store, API, upload
│ ├── feature-flags/ # Feature flag store (reads /api/health)
│ ├── history/ # History store, navigation │ ├── history/ # History store, navigation
│ └── settings/ # Theme, locale, API URL │ └── settings/ # Theme, locale, API URL
@ -126,3 +138,73 @@ Backend response → Pinia store state → Vue reactivity → UI update
- **TypeScript strict mode** with shared interfaces in `shared/types.ts`. - **TypeScript strict mode** with shared interfaces in `shared/types.ts`.
- **No component library** — custom CSS with CSS variables for theming. - **No component library** — custom CSS with CSS variables for theming.
- **vue-tsc** in CI to catch type errors before merge. - **vue-tsc** in CI to catch type errors before merge.
## Feature Flags
The frontend adapts its UI based on the backend's capabilities. On startup, the feature flag store fetches `/api/health` and reads the `engine` and `deploymentMode` fields.
| Flag | Condition | Effect |
|------|-----------|--------|
| `chunking` | `engine === 'local'` | Shows chunking options in the analysis panel |
| `disclaimer` | `deploymentMode === 'huggingface'` | Shows a disclaimer banner at the top of the app |
This allows the same frontend build to work with both local and remote backends without conditional compilation.
## Rate Limiting
The backend applies a sliding-window rate limiter as middleware:
- **60 requests** per **60 seconds** per client IP
- The `/api/health` endpoint is excluded
- When the limit is exceeded, the API returns `429 Too Many Requests` with a `Retry-After` header
## Analysis Lifecycle
An analysis job follows this state machine:
```
PENDING → RUNNING → COMPLETED
→ FAILED
```
| Status | Description |
|--------|-------------|
| `PENDING` | Job created, waiting for a processing slot |
| `RUNNING` | Docling conversion in progress |
| `COMPLETED` | Conversion finished — results available (markdown, HTML, pages, chunks) |
| `FAILED` | Conversion error — `error_message` contains details |
The backend limits parallel jobs via `MAX_CONCURRENT_ANALYSES` (default: 3) to avoid overloading the CPU during Docling processing.
## Local vs Remote Mode
The backend supports two conversion engines, selected via the `CONVERSION_ENGINE` environment variable:
| | Local | Remote |
|---|---|---|
| **Engine** | In-process Docling (PyTorch) | HTTP client to [Docling Serve](https://github.com/DS4SD/docling-serve) |
| **Chunking** | Available (in-process) | Not available |
| **Docker image** | `latest-local` (~1.9 GB) | `latest-remote` (~270 MB) |
| **ML models** | Downloaded on first run (~400 MB) | Managed by Docling Serve |
| **CPU/RAM** | 4+ CPUs, 6+ GB RAM | 2 CPUs, 2 GB RAM |
The converter is selected at startup in `main.py` via `_build_converter()`. The chunker (`_build_chunker()`) is only instantiated in local mode — in remote mode, the chunking feature flag is disabled and the UI hides the chunking panel.
## Health Endpoint
`GET /api/health` returns the backend status:
```json
{
"status": "ok",
"engine": "local",
"version": "0.3.0",
"deploymentMode": "self-hosted"
}
```
The frontend uses this response to:
1. Verify the backend is reachable
2. Evaluate feature flags (chunking, disclaimer)
3. Display the app version

View file

@ -38,7 +38,7 @@ The frontend converts PDF points to CSS pixels, then the canvas renders at `devi
## Transformation 1 — `to_topleft_list()` ## Transformation 1 — `to_topleft_list()`
**File:** `document-parser/domain/bbox.py` **File:** `document-parser/infra/bbox.py`
Normalizes any Docling bbox to `[left, top, right, bottom]` in TOPLEFT coordinates. Normalizes any Docling bbox to `[left, top, right, bottom]` in TOPLEFT coordinates.

View file

@ -20,7 +20,13 @@
```bash ```bash
cd document-parser cd document-parser
python -m venv .venv && source .venv/bin/activate python -m venv .venv && source .venv/bin/activate
# Remote mode (lightweight — delegates to Docling Serve)
pip install -r requirements.txt pip install -r requirements.txt
# Local mode (full — runs Docling in-process)
pip install -r requirements-local.txt
pip install ruff pytest pytest-asyncio httpx pip install ruff pytest pytest-asyncio httpx
uvicorn main:app --reload --port 8000 uvicorn main:app --reload --port 8000
``` ```

View file

@ -1,33 +1,69 @@
# Getting Started # Getting Started
## Docker Compose (recommended) Docling Studio ships two Docker image variants:
| Variant | Image tag | Size | Description |
|---------|-----------|------|-------------|
| **remote** | `latest-remote` | ~270 MB | Lightweight — delegates to an external [Docling Serve](https://github.com/DS4SD/docling-serve) instance |
| **local** | `latest-local` | ~1.9 GB | Full — runs Docling in-process, CPU-only (downloads ML models on first run) |
![Docker architecture](images/docker.png){ width="600" }
## Docker — remote mode (fastest)
```bash
docker run -p 3000:3000 \
-e DOCLING_SERVE_URL=http://your-docling-serve:5001 \
ghcr.io/scub-france/docling-studio:latest-remote
```
## Docker — local mode (self-contained)
```bash
docker run -p 3000:3000 ghcr.io/scub-france/docling-studio:latest-local
```
> **Note:** The first analysis takes longer as Docling downloads its ML models (~400 MB). Subsequent runs are fast.
Open [http://localhost:3000](http://localhost:3000).
## Docker Compose (recommended for development)
```bash ```bash
git clone https://github.com/scub-france/Docling-Studio.git git clone https://github.com/scub-france/Docling-Studio.git
cd Docling-Studio cd Docling-Studio
docker compose up --build
```
Open [http://localhost:3000](http://localhost:3000). # Local mode (default)
docker compose up --build
# Remote mode
CONVERSION_MODE=remote DOCLING_SERVE_URL=http://your-docling-serve:5001 docker compose up --build
```
## Local Development ## Local Development
### Backend (Python 3.12+) === "Backend (Python 3.12+)"
```bash ```bash
cd document-parser cd document-parser
python -m venv .venv && source .venv/bin/activate python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn main:app --reload --port 8000
```
### Frontend (Node 20+) # Remote mode (lightweight)
pip install -r requirements.txt
```bash # Local mode (with Docling)
cd frontend pip install -r requirements-local.txt
npm install
npm run dev uvicorn main:app --reload --port 8000
``` ```
=== "Frontend (Node 20+)"
```bash
cd frontend
npm install
npm run dev
```
The frontend runs on `http://localhost:3000` and proxies API calls to `http://localhost:8000`. The frontend runs on `http://localhost:3000` and proxies API calls to `http://localhost:8000`.
@ -65,22 +101,51 @@ These options map directly to Docling's [`PdfPipelineOptions`](https://docling-p
| `generate_page_images` | `false` | Rasterize each page as an image | | `generate_page_images` | `false` | Rasterize each page as an image |
| `images_scale` | `1.0` | Scale factor for generated images (0.110) | | `images_scale` | `1.0` | Scale factor for generated images (0.110) |
## Chunking Options
!!! note
Chunking is only available in **local** mode. The chunking UI is hidden when using remote mode (Docling Serve).
After a document is analyzed, you can split the extracted content into semantic chunks. Chunking can be configured at analysis time or re-run later with different options via the **rechunk** action.
| Option | Default | Description |
|--------|---------|-------------|
| `chunker_type` | `hybrid` | `hybrid` (semantic + structural), `hierarchical` (heading-based), or `page` (one chunk per page) |
| `max_tokens` | `512` | Maximum tokens per chunk |
| `merge_peers` | `true` | Merge sibling elements under the same heading |
| `repeat_table_header` | `true` | Repeat table headers when a table is split across chunks |
Each chunk includes:
- **text** — the chunk content
- **headings** — heading hierarchy leading to the chunk
- **source_page** — the page number the chunk originates from
- **token_count** — number of tokens in the chunk
- **bboxes** — bounding boxes of the chunk's source elements (page + coordinates)
## Configuration ## Configuration
All configuration is done via environment variables: All configuration is done via environment variables:
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `CONVERSION_ENGINE` | `local` | `local` (in-process Docling) or `remote` (Docling Serve) |
| `DOCLING_SERVE_URL` | `http://localhost:5001` | Docling Serve endpoint (remote mode only) |
| `DOCLING_SERVE_API_KEY` | — | API key for Docling Serve (optional) |
| `CORS_ORIGINS` | `http://localhost:3000,...` | CORS allowed origins | | `CORS_ORIGINS` | `http://localhost:3000,...` | CORS allowed origins |
| `UPLOAD_DIR` | `./uploads` | File storage directory | | `UPLOAD_DIR` | `./uploads` | File storage directory |
| `DB_PATH` | `./data/docling_studio.db` | SQLite database path | | `DB_PATH` | `./data/docling_studio.db` | SQLite database path |
| `CONVERSION_TIMEOUT` | `600` | Max seconds per Docling conversion | | `CONVERSION_TIMEOUT` | `600` | Max seconds per Docling conversion |
| `MAX_CONCURRENT_ANALYSES` | `3` | Maximum parallel analysis jobs |
| `DEPLOYMENT_MODE` | `self-hosted` | `self-hosted` or `huggingface` (shows disclaimer banner) |
| `APP_VERSION` | `dev` | Application version (set automatically by CI/Docker) |
## System Requirements ## System Requirements
| Resource | Minimum | Recommended | | | Remote image | Local image |
|----------|---------|-------------| |---|---|---|
| Memory | 6 GB | 8 GB+ | | **Image size** | ~270 MB | ~1.9 GB |
| CPUs | 4 | 8+ | | **Memory** | 2 GB | 6 GB (recommended 8 GB+) |
| **CPUs** | 2 | 4 (recommended 8+) |
All Docker images are multi-arch (`linux/amd64` + `linux/arm64`). No GPU required. All Docker images are multi-arch (`linux/amd64` + `linux/arm64`). No GPU required.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

BIN
docs/images/docker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

BIN
docs/images/global.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

BIN
docs/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 KiB

View file

@ -4,18 +4,23 @@ A visual document analysis studio powered by [Docling](https://github.com/DS4SD/
Upload a PDF, configure the extraction pipeline, and visualize the results — text, tables, images, formulas, bounding boxes — all from your browser. Upload a PDF, configure the extraction pipeline, and visualize the results — text, tables, images, formulas, bounding boxes — all from your browser.
![Docling Studio architecture](images/archi.png){ width="600" } ![Docling Studio architecture](images/global.png){ width="600" }
![Docling Studio — Execution Result](screenshots/DS-execution-result.png) ![Docling Studio — Execution Result](screenshots/DS-execution-result.png)
## Features ## Features
- **PDF viewer** with page navigation, bounding box overlay, and resizable results panel - **PDF viewer** with page navigation, bounding box overlay, and resizable results panel
- **Configurable Docling pipeline** — OCR, table extraction, code/formula enrichment, picture classification & description - **Configurable Docling pipeline** — OCR, table extraction, code/formula enrichment, picture classification & description, image generation
- **Bounding box visualization** — color-coded element overlay directly on the PDF - **Bounding box visualization** — color-coded element overlay directly on the PDF
- **Chunking** — split extracted content into semantic chunks (hierarchical, hybrid, or page-based) with configurable token limits
- **Markdown & HTML export** of extracted content - **Markdown & HTML export** of extracted content
- **Document management** — upload, list, delete - **Document management** — upload, list, delete
- **Analysis history** — re-visit and open past analyses - **Analysis history** — re-visit and open past analyses
- **Feature flags** — capabilities adapt to the conversion engine (local vs remote)
- **Rate limiting** — 60 requests per minute per IP to protect the backend
- **Deployment modes** — self-hosted (default) or HuggingFace Spaces (with disclaimer banner)
- **Health endpoint**`/api/health` reports engine type, deployment mode, and database status
- **Dark / Light theme** and **FR / EN** localization - **Dark / Light theme** and **FR / EN** localization
## Tech Stack ## Tech Stack
@ -31,7 +36,7 @@ Upload a PDF, configure the extraction pipeline, and visualize the results — t
```bash ```bash
# Docker (fastest) # Docker (fastest)
docker run -p 3000:3000 ghcr.io/scub-france/docling-studio:latest docker run -p 3000:3000 ghcr.io/scub-france/docling-studio:latest-local
``` ```
Open [http://localhost:3000](http://localhost:3000) and upload a PDF. Open [http://localhost:3000](http://localhost:3000) and upload a PDF.

View file

@ -1,9 +1,17 @@
FROM python:3.12-slim # syntax=docker/dockerfile:1
# =============================================================================
# Docling Studio — backend image (multi-target: remote / local)
#
# Usage:
# docker build --target remote -t docling-studio-backend:remote .
# docker build --target local -t docling-studio-backend:local .
# =============================================================================
# --- Base: common deps for both targets ---
FROM python:3.12-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
poppler-utils \ poppler-utils \
libgl1 \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
@ -24,3 +32,25 @@ ENV DB_PATH=/app/data/docling_studio.db
USER appuser USER appuser
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# --- Remote: lightweight, delegates to Docling Serve ---
FROM base AS remote
ENV CONVERSION_ENGINE=remote
# --- Local: full Docling in-process ---
FROM base AS local
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements-local.txt .
RUN pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu \
&& pip install --no-cache-dir -r requirements-local.txt
RUN chown -R appuser:appuser /app \
&& chown -R appuser:appuser /usr/local/lib/python3.12/site-packages/rapidocr/models
USER appuser
ENV CONVERSION_ENGINE=local

View file

@ -3,16 +3,30 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Annotated
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from api.schemas import AnalysisResponse, CreateAnalysisRequest from api.schemas import (
from services import analysis_service AnalysisResponse,
ChunkBboxResponse,
ChunkResponse,
CreateAnalysisRequest,
RechunkRequest,
)
from services.analysis_service import AnalysisService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/analyses", tags=["analyses"]) router = APIRouter(prefix="/api/analyses", tags=["analyses"])
def _get_service(request: Request) -> AnalysisService:
return request.app.state.analysis_service
ServiceDep = Annotated[AnalysisService, Depends(_get_service)]
def _to_response(job) -> AnalysisResponse: def _to_response(job) -> AnalysisResponse:
return AnalysisResponse( return AnalysisResponse(
id=job.id, id=job.id,
@ -22,6 +36,8 @@ def _to_response(job) -> AnalysisResponse:
content_markdown=job.content_markdown, content_markdown=job.content_markdown,
content_html=job.content_html, content_html=job.content_html,
pages_json=job.pages_json, pages_json=job.pages_json,
chunks_json=job.chunks_json,
has_document_json=job.document_json is not None,
error_message=job.error_message, error_message=job.error_message,
started_at=str(job.started_at) if job.started_at else None, started_at=str(job.started_at) if job.started_at else None,
completed_at=str(job.completed_at) if job.completed_at else None, completed_at=str(job.completed_at) if job.completed_at else None,
@ -30,7 +46,7 @@ def _to_response(job) -> AnalysisResponse:
@router.post("", response_model=AnalysisResponse) @router.post("", response_model=AnalysisResponse)
async def create_analysis(body: CreateAnalysisRequest): async def create_analysis(body: CreateAnalysisRequest, service: ServiceDep) -> AnalysisResponse:
"""Create a new analysis job for a document.""" """Create a new analysis job for a document."""
if not body.documentId or not body.documentId.strip(): if not body.documentId or not body.documentId.strip():
raise HTTPException(status_code=400, detail="documentId is required") raise HTTPException(status_code=400, detail="documentId is required")
@ -39,8 +55,16 @@ async def create_analysis(body: CreateAnalysisRequest):
if body.pipelineOptions: if body.pipelineOptions:
pipeline_opts = body.pipelineOptions.model_dump() pipeline_opts = body.pipelineOptions.model_dump()
chunking_opts = None
if body.chunkingOptions:
chunking_opts = body.chunkingOptions.model_dump()
try: try:
job = await analysis_service.create(body.documentId, pipeline_options=pipeline_opts) job = await service.create(
body.documentId,
pipeline_options=pipeline_opts,
chunking_options=chunking_opts,
)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e raise HTTPException(status_code=404, detail=str(e)) from e
@ -48,24 +72,45 @@ async def create_analysis(body: CreateAnalysisRequest):
@router.get("", response_model=list[AnalysisResponse]) @router.get("", response_model=list[AnalysisResponse])
async def list_analyses(): async def list_analyses(service: ServiceDep) -> list[AnalysisResponse]:
"""List all analysis jobs.""" """List all analysis jobs."""
jobs = await analysis_service.find_all() jobs = await service.find_all()
return [_to_response(j) for j in jobs] return [_to_response(j) for j in jobs]
@router.get("/{job_id}", response_model=AnalysisResponse) @router.get("/{job_id}", response_model=AnalysisResponse)
async def get_analysis(job_id: str): async def get_analysis(job_id: str, service: ServiceDep) -> AnalysisResponse:
"""Get a single analysis job.""" """Get a single analysis job."""
job = await analysis_service.find_by_id(job_id) job = await service.find_by_id(job_id)
if not job: if not job:
raise HTTPException(status_code=404, detail="Analysis not found") raise HTTPException(status_code=404, detail="Analysis not found")
return _to_response(job) return _to_response(job)
@router.post("/{job_id}/rechunk", response_model=list[ChunkResponse])
async def rechunk_analysis(
job_id: str, body: RechunkRequest, service: ServiceDep
) -> list[ChunkResponse]:
"""Re-chunk a completed analysis with new chunking options."""
try:
chunks = await service.rechunk(job_id, body.chunkingOptions.model_dump())
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return [
ChunkResponse(
text=c.text,
headings=c.headings,
source_page=c.source_page,
token_count=c.token_count,
bboxes=[ChunkBboxResponse(page=b.page, bbox=b.bbox) for b in c.bboxes],
)
for c in chunks
]
@router.delete("/{job_id}", status_code=204) @router.delete("/{job_id}", status_code=204)
async def delete_analysis(job_id: str): async def delete_analysis(job_id: str, service: ServiceDep) -> None:
"""Delete an analysis job.""" """Delete an analysis job."""
deleted = await analysis_service.delete(job_id) deleted = await service.delete(job_id)
if not deleted: if not deleted:
raise HTTPException(status_code=404, detail="Analysis not found") raise HTTPException(status_code=404, detail="Analysis not found")

View file

@ -13,6 +13,8 @@ from services import document_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/documents", tags=["documents"]) router = APIRouter(prefix="/api/documents", tags=["documents"])
_READ_CHUNK_SIZE = 64 * 1024 # 64 KB
def _to_response(doc) -> DocumentResponse: def _to_response(doc) -> DocumentResponse:
return DocumentResponse( return DocumentResponse(
@ -25,8 +27,8 @@ def _to_response(doc) -> DocumentResponse:
) )
@router.post("/upload", response_model=DocumentResponse) @router.post("/upload", response_model=DocumentResponse, status_code=200)
async def upload(file: UploadFile): async def upload(file: UploadFile) -> DocumentResponse:
"""Upload a PDF document.""" """Upload a PDF document."""
if not file.filename: if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided") raise HTTPException(status_code=400, detail="No filename provided")
@ -35,7 +37,15 @@ async def upload(file: UploadFile):
if file.size and file.size > document_service.MAX_FILE_SIZE: if file.size and file.size > document_service.MAX_FILE_SIZE:
raise HTTPException(status_code=413, detail="File too large (max 50 MB)") raise HTTPException(status_code=413, detail="File too large (max 50 MB)")
content = await file.read() # Read in chunks to avoid holding the full upload in a single allocation
chunks: list[bytes] = []
total = 0
while chunk := await file.read(_READ_CHUNK_SIZE):
total += len(chunk)
if total > document_service.MAX_FILE_SIZE:
raise HTTPException(status_code=413, detail="File too large (max 50 MB)")
chunks.append(chunk)
content = b"".join(chunks)
try: try:
doc = await document_service.upload( doc = await document_service.upload(
@ -44,20 +54,20 @@ async def upload(file: UploadFile):
file_content=content, file_content=content,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=413, detail=str(e)) from e raise HTTPException(status_code=400, detail=str(e)) from e
return _to_response(doc) return _to_response(doc)
@router.get("", response_model=list[DocumentResponse]) @router.get("", response_model=list[DocumentResponse])
async def list_documents(): async def list_documents() -> list[DocumentResponse]:
"""List all documents.""" """List all documents."""
docs = await document_service.find_all() docs = await document_service.find_all()
return [_to_response(d) for d in docs] return [_to_response(d) for d in docs]
@router.get("/{doc_id}", response_model=DocumentResponse) @router.get("/{doc_id}", response_model=DocumentResponse)
async def get_document(doc_id: str): async def get_document(doc_id: str) -> DocumentResponse:
"""Get a single document.""" """Get a single document."""
doc = await document_service.find_by_id(doc_id) doc = await document_service.find_by_id(doc_id)
if not doc: if not doc:
@ -66,7 +76,7 @@ async def get_document(doc_id: str):
@router.delete("/{doc_id}", status_code=204) @router.delete("/{doc_id}", status_code=204)
async def delete_document(doc_id: str): async def delete_document(doc_id: str) -> None:
"""Delete a document and its file.""" """Delete a document and its file."""
deleted = await document_service.delete(doc_id) deleted = await document_service.delete(doc_id)
if not deleted: if not deleted:
@ -78,12 +88,18 @@ async def preview(
doc_id: str, doc_id: str,
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
dpi: int = Query(150, ge=72, le=300), dpi: int = Query(150, ge=72, le=300),
): ) -> Response:
"""Generate a PNG preview of a specific PDF page.""" """Generate a PNG preview of a specific PDF page."""
doc = await document_service.find_by_id(doc_id) doc = await document_service.find_by_id(doc_id)
if not doc: if not doc:
raise HTTPException(status_code=404, detail="Document not found") raise HTTPException(status_code=404, detail="Document not found")
if doc.page_count and page > doc.page_count:
raise HTTPException(
status_code=400,
detail=f"Page {page} out of range (document has {doc.page_count} pages)",
)
try: try:
with open(doc.storage_path, "rb") as f: with open(doc.storage_path, "rb") as f:
file_content = f.read() file_content = f.read()
@ -91,6 +107,11 @@ async def preview(
return Response(content=png_bytes, media_type="image/png") return Response(content=png_bytes, media_type="image/png")
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e raise HTTPException(status_code=404, detail=str(e)) from e
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail="PDF file not found on disk") from exc
except OSError as exc:
logger.exception("I/O error generating preview for %s", doc_id)
raise HTTPException(status_code=422, detail="Failed to read PDF file") from exc
except Exception as exc: except Exception as exc:
logger.exception("Failed to generate preview") logger.exception("Unexpected error generating preview for %s", doc_id)
raise HTTPException(status_code=422, detail="Failed to generate preview") from exc raise HTTPException(status_code=422, detail="Failed to generate preview") from exc

View file

@ -8,7 +8,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, ConfigDict, field_validator from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator
def _to_camel(name: str) -> str: def _to_camel(name: str) -> str:
@ -18,6 +18,7 @@ def _to_camel(name: str) -> str:
class _CamelModel(BaseModel): class _CamelModel(BaseModel):
"""Base model that serializes field names to camelCase.""" """Base model that serializes field names to camelCase."""
model_config = ConfigDict( model_config = ConfigDict(
alias_generator=_to_camel, alias_generator=_to_camel,
populate_by_name=True, populate_by_name=True,
@ -28,6 +29,7 @@ class _CamelModel(BaseModel):
class DocumentResponse(_CamelModel): class DocumentResponse(_CamelModel):
id: str id: str
filename: str filename: str
status: str = "uploaded" # Document status (always "uploaded" for now)
content_type: str | None = None content_type: str | None = None
file_size: int | None = None file_size: int | None = None
page_count: int | None = None page_count: int | None = None
@ -42,6 +44,8 @@ class AnalysisResponse(_CamelModel):
content_markdown: str | None = None content_markdown: str | None = None
content_html: str | None = None content_html: str | None = None
pages_json: str | None = None pages_json: str | None = None
chunks_json: str | None = None
has_document_json: bool = False
error_message: str | None = None error_message: str | None = None
started_at: str | datetime | None = None started_at: str | datetime | None = None
completed_at: str | datetime | None = None completed_at: str | datetime | None = None
@ -50,16 +54,40 @@ class AnalysisResponse(_CamelModel):
class PipelineOptionsRequest(BaseModel): class PipelineOptionsRequest(BaseModel):
"""Docling pipeline configuration options.""" """Docling pipeline configuration options."""
do_ocr: bool = True
do_table_structure: bool = True model_config = ConfigDict(populate_by_name=True)
table_mode: str = "accurate" # "accurate" or "fast"
do_code_enrichment: bool = False do_ocr: bool = Field(default=True, validation_alias=AliasChoices("do_ocr", "doOcr"))
do_formula_enrichment: bool = False do_table_structure: bool = Field(
do_picture_classification: bool = False default=True, validation_alias=AliasChoices("do_table_structure", "doTableStructure")
do_picture_description: bool = False )
generate_picture_images: bool = False table_mode: str = Field(
generate_page_images: bool = False default="accurate", validation_alias=AliasChoices("table_mode", "tableMode")
images_scale: float = 1.0 )
do_code_enrichment: bool = Field(
default=False, validation_alias=AliasChoices("do_code_enrichment", "doCodeEnrichment")
)
do_formula_enrichment: bool = Field(
default=False, validation_alias=AliasChoices("do_formula_enrichment", "doFormulaEnrichment")
)
do_picture_classification: bool = Field(
default=False,
validation_alias=AliasChoices("do_picture_classification", "doPictureClassification"),
)
do_picture_description: bool = Field(
default=False,
validation_alias=AliasChoices("do_picture_description", "doPictureDescription"),
)
generate_picture_images: bool = Field(
default=False,
validation_alias=AliasChoices("generate_picture_images", "generatePictureImages"),
)
generate_page_images: bool = Field(
default=False, validation_alias=AliasChoices("generate_page_images", "generatePageImages")
)
images_scale: float = Field(
default=1.0, validation_alias=AliasChoices("images_scale", "imagesScale")
)
@field_validator("table_mode") @field_validator("table_mode")
@classmethod @classmethod
@ -76,6 +104,61 @@ class PipelineOptionsRequest(BaseModel):
return v return v
class ChunkingOptionsRequest(BaseModel):
"""Docling chunking configuration options."""
model_config = ConfigDict(populate_by_name=True)
chunker_type: str = Field(
default="hybrid", validation_alias=AliasChoices("chunker_type", "chunkerType")
)
max_tokens: int = Field(default=512, validation_alias=AliasChoices("max_tokens", "maxTokens"))
merge_peers: bool = Field(
default=True, validation_alias=AliasChoices("merge_peers", "mergePeers")
)
repeat_table_header: bool = Field(
default=True, validation_alias=AliasChoices("repeat_table_header", "repeatTableHeader")
)
@field_validator("chunker_type")
@classmethod
def validate_chunker_type(cls, v: str) -> str:
if v not in ("hybrid", "hierarchical"):
raise ValueError('chunker_type must be "hybrid" or "hierarchical"')
return v
@field_validator("max_tokens")
@classmethod
def validate_max_tokens(cls, v: int) -> int:
if v < 64 or v > 8192:
raise ValueError("max_tokens must be between 64 and 8192")
return v
class ChunkBboxResponse(_CamelModel):
page: int
bbox: list[float]
class ChunkResponse(_CamelModel):
text: str
headings: list[str] = []
source_page: int | None = None
token_count: int = 0
bboxes: list[ChunkBboxResponse] = []
class CreateAnalysisRequest(BaseModel): class CreateAnalysisRequest(BaseModel):
documentId: str # camelCase to match existing frontend contract documentId: str = Field(validation_alias=AliasChoices("documentId", "document_id"))
pipelineOptions: PipelineOptionsRequest | None = None pipelineOptions: PipelineOptionsRequest | None = Field(
default=None, validation_alias=AliasChoices("pipelineOptions", "pipeline_options")
)
chunkingOptions: ChunkingOptionsRequest | None = Field(
default=None, validation_alias=AliasChoices("chunkingOptions", "chunking_options")
)
class RechunkRequest(BaseModel):
chunkingOptions: ChunkingOptionsRequest = Field(
validation_alias=AliasChoices("chunkingOptions", "chunking_options")
)

View file

@ -1,3 +1 @@
pytest_plugins = ["pytest_asyncio"] pytest_plugins = ["pytest_asyncio"]

View file

@ -42,6 +42,8 @@ class AnalysisJob:
content_markdown: str | None = None content_markdown: str | None = None
content_html: str | None = None content_html: str | None = None
pages_json: str | None = None pages_json: str | None = None
document_json: str | None = None
chunks_json: str | None = None
error_message: str | None = None error_message: str | None = None
started_at: datetime | None = None started_at: datetime | None = None
completed_at: datetime | None = None completed_at: datetime | None = None
@ -51,19 +53,29 @@ class AnalysisJob:
document_filename: str | None = None document_filename: str | None = None
def mark_running(self) -> None: def mark_running(self) -> None:
"""Transition to RUNNING and record the start timestamp."""
self.status = AnalysisStatus.RUNNING self.status = AnalysisStatus.RUNNING
self.started_at = _utcnow() self.started_at = _utcnow()
def mark_completed( def mark_completed(
self, markdown: str, html: str, pages_json: str, self,
markdown: str,
html: str,
pages_json: str,
document_json: str | None = None,
chunks_json: str | None = None,
) -> None: ) -> None:
"""Transition to COMPLETED with conversion results."""
self.status = AnalysisStatus.COMPLETED self.status = AnalysisStatus.COMPLETED
self.content_markdown = markdown self.content_markdown = markdown
self.content_html = html self.content_html = html
self.pages_json = pages_json self.pages_json = pages_json
self.document_json = document_json
self.chunks_json = chunks_json
self.completed_at = _utcnow() self.completed_at = _utcnow()
def mark_failed(self, error: str) -> None: def mark_failed(self, error: str) -> None:
"""Transition to FAILED with an error message."""
self.status = AnalysisStatus.FAILED self.status = AnalysisStatus.FAILED
self.error_message = error self.error_message = error
self.completed_at = _utcnow() self.completed_at = _utcnow()

View file

@ -0,0 +1,44 @@
"""Domain ports — abstract interfaces that infrastructure must implement.
These protocols define what the domain NEEDS, not how it's done.
Infrastructure adapters (local Docling, Docling Serve, etc.) implement these.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Protocol
if TYPE_CHECKING:
from domain.value_objects import (
ChunkingOptions,
ChunkResult,
ConversionOptions,
ConversionResult,
)
class DocumentConverter(Protocol):
"""Port for document conversion.
Any implementation (local Docling lib, remote Docling Serve, mock, etc.)
must satisfy this contract.
"""
async def convert(
self,
file_path: str,
options: ConversionOptions,
) -> ConversionResult: ...
class DocumentChunker(Protocol):
"""Port for document chunking.
Takes a serialized DoclingDocument (JSON) and returns chunks.
"""
async def chunk(
self,
document_json: str,
options: ChunkingOptions,
) -> list[ChunkResult]: ...

View file

@ -0,0 +1,80 @@
"""Domain value objects — pure data structures for document conversion.
These types define the contract between the domain and infrastructure layers.
They have ZERO external dependencies (no docling, no HTTP, no DB).
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class PageElement:
type: str
bbox: list[float]
content: str
level: int = 0
@dataclass
class PageDetail:
page_number: int
width: float
height: float
elements: list[PageElement] = field(default_factory=list)
@dataclass
class ConversionOptions:
do_ocr: bool = True
do_table_structure: bool = True
table_mode: str = "accurate"
do_code_enrichment: bool = False
do_formula_enrichment: bool = False
do_picture_classification: bool = False
do_picture_description: bool = False
generate_picture_images: bool = False
generate_page_images: bool = False
images_scale: float = 1.0
def is_default(self) -> bool:
"""Return True if all options match their defaults."""
return self == ConversionOptions()
@dataclass
class ConversionResult:
page_count: int
content_markdown: str
content_html: str
pages: list[PageDetail]
skipped_items: int = 0
document_json: str | None = None
@dataclass
class ChunkingOptions:
chunker_type: str = "hybrid" # "hybrid", "hierarchical", "page"
max_tokens: int = 512
merge_peers: bool = True
repeat_table_header: bool = True
def is_default(self) -> bool:
"""Return True if all options match their defaults."""
return self == ChunkingOptions()
@dataclass
class ChunkBbox:
page: int
bbox: list[float] # [left, top, right, bottom] in TOPLEFT origin
@dataclass
class ChunkResult:
text: str
headings: list[str] = field(default_factory=list)
source_page: int | None = None
token_count: int = 0
bboxes: list[ChunkBbox] = field(default_factory=list)

View file

View file

@ -43,7 +43,11 @@ def to_topleft_list(bbox: BoundingBox, page_height: float) -> list[float]:
if right <= left or bottom <= top: if right <= left or bottom <= top:
logger.debug( logger.debug(
"Degenerate bbox skipped: [%.1f, %.1f, %.1f, %.1f] (page_height=%.1f)", "Degenerate bbox skipped: [%.1f, %.1f, %.1f, %.1f] (page_height=%.1f)",
left, top, right, bottom, page_height, left,
top,
right,
bottom,
page_height,
) )
return list(EMPTY_BBOX) return list(EMPTY_BBOX)

View file

@ -0,0 +1,96 @@
"""Local Docling chunker — runs chunking in-process using docling-core.
This adapter implements the DocumentChunker port. It deserializes a
DoclingDocument from JSON, applies the requested chunker, and returns
domain ChunkResult objects.
"""
from __future__ import annotations
import asyncio
import json
import logging
from docling_core.transforms.chunker import HierarchicalChunker
from docling_core.transforms.chunker.hybrid_chunker import HybridChunker
from docling_core.types.doc.document import DoclingDocument
from domain.value_objects import ChunkBbox, ChunkingOptions, ChunkResult
from infra.bbox import EMPTY_BBOX, to_topleft_list
logger = logging.getLogger(__name__)
def _chunk_sync(document_json: str, options: ChunkingOptions) -> list[ChunkResult]:
if not document_json or not document_json.strip():
raise ValueError("Empty document JSON — nothing to chunk")
try:
doc_data = json.loads(document_json)
except json.JSONDecodeError as e:
raise ValueError(f"Malformed document JSON: {e}") from e
doc = DoclingDocument.model_validate(doc_data)
chunker = _build_chunker(options)
results: list[ChunkResult] = []
for chunk in chunker.chunk(doc):
source_page = None
token_count = 0
bboxes: list[ChunkBbox] = []
if hasattr(chunk, "meta") and chunk.meta and chunk.meta.doc_items:
for doc_item in chunk.meta.doc_items:
if not hasattr(doc_item, "prov") or not doc_item.prov:
continue
for prov in doc_item.prov:
page_no = prov.page_no
if source_page is None:
source_page = page_no
if prov.bbox:
page_obj = doc.pages.get(page_no)
if page_obj:
bbox = to_topleft_list(prov.bbox, page_obj.size.height)
if bbox != EMPTY_BBOX:
bboxes.append(ChunkBbox(page=page_no, bbox=bbox))
if hasattr(chunker, "tokenizer") and chunker.tokenizer:
token_count = chunker.tokenizer.count_tokens(chunk.text)
headings = list(chunk.meta.headings) if chunk.meta and chunk.meta.headings else []
results.append(
ChunkResult(
text=chunk.text,
headings=headings,
source_page=source_page,
token_count=token_count,
bboxes=bboxes,
)
)
logger.info("Chunked document into %d chunks (chunker=%s)", len(results), options.chunker_type)
return results
def _build_chunker(options: ChunkingOptions) -> HierarchicalChunker | HybridChunker:
if options.chunker_type == "hierarchical":
return HierarchicalChunker()
return HybridChunker(
max_tokens=options.max_tokens,
merge_peers=options.merge_peers,
repeat_table_header=options.repeat_table_header,
)
class LocalChunker:
"""Adapter that runs docling-core chunking locally."""
async def chunk(
self,
document_json: str,
options: ChunkingOptions,
) -> list[ChunkResult]:
return await asyncio.to_thread(_chunk_sync, document_json, options)

View file

@ -1,15 +1,17 @@
"""Docling document extraction logic — pure domain, no HTTP concerns. """Local Docling converter — runs Docling as a Python library in-process.
Wraps the Docling library to convert documents and extract structured This adapter implements the DocumentConverter port using the Docling library
per-page elements with bounding boxes and hierarchy levels. directly. It wraps the blocking DocumentConverter in asyncio.to_thread for
non-blocking execution.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import contextlib import contextlib
import json
import logging import logging
import threading import threading
from dataclasses import dataclass, field
from docling.datamodel.base_models import InputFormat from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import ( from docling.datamodel.pipeline_options import (
@ -17,7 +19,8 @@ from docling.datamodel.pipeline_options import (
TableFormerMode, TableFormerMode,
TableStructureOptions, TableStructureOptions,
) )
from docling.document_converter import DocumentConverter, PdfFormatOption from docling.document_converter import DocumentConverter as DoclingConverter
from docling.document_converter import PdfFormatOption
from docling_core.types.doc import ( from docling_core.types.doc import (
CodeItem, CodeItem,
DocItem, DocItem,
@ -32,11 +35,17 @@ from docling_core.types.doc import (
TitleItem, TitleItem,
) )
from domain.bbox import to_topleft_list from domain.value_objects import (
ConversionOptions,
ConversionResult,
PageDetail,
PageElement,
)
from infra.bbox import to_topleft_list
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Thread lock — DocumentConverter is not thread-safe # Thread lock — DoclingConverter is not thread-safe
_converter_lock = threading.Lock() _converter_lock = threading.Lock()
# US Letter page dimensions (points) — fallback when page size is unknown # US Letter page dimensions (points) — fallback when page size is unknown
@ -44,61 +53,13 @@ _DEFAULT_PAGE_WIDTH = 612.0
_DEFAULT_PAGE_HEIGHT = 792.0 _DEFAULT_PAGE_HEIGHT = 792.0
# Default converter (lazy-init on first request) # Default converter (lazy-init on first request)
_default_converter: DocumentConverter | None = None _default_converter: DoclingConverter | None = None
# ---------------------------------------------------------------------------
# Domain value objects
# ---------------------------------------------------------------------------
@dataclass
class PageElement:
type: str
bbox: list[float]
content: str
level: int = 0
@dataclass
class PageDetail:
page_number: int
width: float
height: float
elements: list[PageElement] = field(default_factory=list)
@dataclass
class ConversionOptions:
do_ocr: bool = True
do_table_structure: bool = True
table_mode: str = "accurate"
do_code_enrichment: bool = False
do_formula_enrichment: bool = False
do_picture_classification: bool = False
do_picture_description: bool = False
generate_picture_images: bool = False
generate_page_images: bool = False
images_scale: float = 1.0
def is_default(self) -> bool:
return self == ConversionOptions()
@dataclass
class ConversionResult:
page_count: int
content_markdown: str
content_html: str
pages: list[PageDetail]
skipped_items: int = 0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Element type detection # Element type detection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Mapping from Docling type to element type string.
# Order matters: most specific types before their parents.
_ELEMENT_TYPE_MAP: list[tuple[type, str]] = [ _ELEMENT_TYPE_MAP: list[tuple[type, str]] = [
(TableItem, "table"), (TableItem, "table"),
(PictureItem, "picture"), (PictureItem, "picture"),
@ -113,7 +74,6 @@ _ELEMENT_TYPE_MAP: list[tuple[type, str]] = [
def _get_element_type(item: DocItem) -> str: def _get_element_type(item: DocItem) -> str:
"""Determine element type via isinstance on Docling's type hierarchy."""
for cls, type_name in _ELEMENT_TYPE_MAP: for cls, type_name in _ELEMENT_TYPE_MAP:
if isinstance(item, cls): if isinstance(item, cls):
return type_name return type_name
@ -124,51 +84,52 @@ def _get_element_type(item: DocItem) -> str:
# Pipeline factory # Pipeline factory
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def build_converter(options: ConversionOptions | None = None) -> DocumentConverter:
"""Build a DocumentConverter with the given pipeline options."""
opts = options or ConversionOptions()
def _build_docling_converter(options: ConversionOptions) -> DoclingConverter:
table_options = TableStructureOptions( table_options = TableStructureOptions(
do_cell_matching=True, do_cell_matching=True,
mode=TableFormerMode.ACCURATE if opts.table_mode == "accurate" else TableFormerMode.FAST, mode=TableFormerMode.ACCURATE if options.table_mode == "accurate" else TableFormerMode.FAST,
) )
pipeline_options = PdfPipelineOptions( pipeline_options = PdfPipelineOptions(
do_ocr=opts.do_ocr, do_ocr=options.do_ocr,
do_table_structure=opts.do_table_structure, do_table_structure=options.do_table_structure,
table_structure_options=table_options, table_structure_options=table_options,
do_code_enrichment=opts.do_code_enrichment, do_code_enrichment=options.do_code_enrichment,
do_formula_enrichment=opts.do_formula_enrichment, do_formula_enrichment=options.do_formula_enrichment,
do_picture_classification=opts.do_picture_classification, do_picture_classification=options.do_picture_classification,
do_picture_description=opts.do_picture_description, do_picture_description=options.do_picture_description,
generate_page_images=opts.generate_page_images, generate_page_images=options.generate_page_images,
generate_picture_images=opts.generate_picture_images, generate_picture_images=options.generate_picture_images,
images_scale=opts.images_scale, images_scale=options.images_scale,
) )
return DocumentConverter( return DoclingConverter(
format_options={ format_options={
InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options), InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options),
} }
) )
def get_default_converter() -> DocumentConverter: def _get_default_converter() -> DoclingConverter:
global _default_converter global _default_converter
if _default_converter is None: if _default_converter is None:
_default_converter = build_converter() _default_converter = _build_docling_converter(ConversionOptions())
return _default_converter return _default_converter
def _select_converter(options: ConversionOptions) -> DoclingConverter:
if options.is_default():
return _get_default_converter()
return _build_docling_converter(options)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Page extraction # Page extraction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
"""Extract per-page element details with bounding boxes from Docling result.
Returns (pages, skipped_count) for transparent error reporting. def _extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
"""
pages: dict[int, PageDetail] = {} pages: dict[int, PageDetail] = {}
document = doc_result.document document = doc_result.document
skipped = 0 skipped = 0
@ -191,9 +152,10 @@ def extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
def _process_content_item( def _process_content_item(
item: DocItem | GroupItem, level: int, pages: dict[int, PageDetail], item: DocItem | GroupItem,
level: int,
pages: dict[int, PageDetail],
) -> bool: ) -> bool:
"""Process a single content item and add it to the appropriate page."""
if isinstance(item, GroupItem): if isinstance(item, GroupItem):
return True return True
@ -204,14 +166,15 @@ def _process_content_item(
try: try:
page_no = prov.page_no page_no = prov.page_no
if page_no not in pages: if page_no not in pages:
# Fallback: page was not found in document.pages (corrupted PDF or
# Docling edge case). US Letter dimensions are used as a safe default.
# This may cause slight bbox misalignment on non-Letter pages (e.g. A4).
logger.warning( logger.warning(
"Page %d not found in document metadata — using US Letter fallback (%sx%s pt)", "Page %d not found in document metadata — using US Letter fallback (%sx%s pt)",
page_no, _DEFAULT_PAGE_WIDTH, _DEFAULT_PAGE_HEIGHT, page_no,
_DEFAULT_PAGE_WIDTH,
_DEFAULT_PAGE_HEIGHT,
)
pages[page_no] = PageDetail(
page_number=page_no, width=_DEFAULT_PAGE_WIDTH, height=_DEFAULT_PAGE_HEIGHT
) )
pages[page_no] = PageDetail(page_number=page_no, width=_DEFAULT_PAGE_WIDTH, height=_DEFAULT_PAGE_HEIGHT)
page_height = pages[page_no].height page_height = pages[page_no].height
@ -242,49 +205,30 @@ def _process_content_item(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Main conversion entry point # Synchronous conversion (called via asyncio.to_thread)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _select_converter(options: ConversionOptions) -> DocumentConverter:
"""Return the cached default converter or build a custom one."""
if options.is_default():
return get_default_converter()
return build_converter(options)
def _build_fallback_pages(doc, page_count: int) -> list[PageDetail]:
"""Create empty PageDetail entries when extraction yields nothing."""
return [
PageDetail(
page_number=i + 1,
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else _DEFAULT_PAGE_WIDTH,
height=doc.pages[i + 1].size.height if (i + 1) in doc.pages else _DEFAULT_PAGE_HEIGHT,
)
for i in range(page_count)
]
def convert_document(
file_path: str,
options: ConversionOptions | None = None,
) -> ConversionResult:
"""Convert a document and return structured results.
This is the main entry point for document parsing. Runs synchronously
(caller should use asyncio.to_thread for non-blocking execution).
"""
opts = options or ConversionOptions()
def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResult:
with _converter_lock: with _converter_lock:
conv = _select_converter(opts) conv = _select_converter(options)
result = conv.convert(file_path) result = conv.convert(file_path)
doc = result.document doc = result.document
page_count = len(doc.pages) page_count = len(doc.pages)
pages_detail, skipped = extract_pages_detail(result) pages_detail, skipped = _extract_pages_detail(result)
if not pages_detail and page_count > 0: if not pages_detail and page_count > 0:
pages_detail = _build_fallback_pages(doc, page_count) pages_detail = [
PageDetail(
page_number=i + 1,
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else _DEFAULT_PAGE_WIDTH,
height=doc.pages[i + 1].size.height
if (i + 1) in doc.pages
else _DEFAULT_PAGE_HEIGHT,
)
for i in range(page_count)
]
if skipped > 0: if skipped > 0:
logger.info("Parsed: %d pages, %d items skipped", page_count, skipped) logger.info("Parsed: %d pages, %d items skipped", page_count, skipped)
@ -295,4 +239,21 @@ def convert_document(
content_html=doc.export_to_html(), content_html=doc.export_to_html(),
pages=pages_detail, pages=pages_detail,
skipped_items=skipped, skipped_items=skipped,
document_json=json.dumps(doc.export_to_dict()),
) )
# ---------------------------------------------------------------------------
# Public adapter class
# ---------------------------------------------------------------------------
class LocalConverter:
"""Adapter that runs Docling locally as a Python library."""
async def convert(
self,
file_path: str,
options: ConversionOptions,
) -> ConversionResult:
return await asyncio.to_thread(_convert_sync, file_path, options)

View file

@ -0,0 +1,89 @@
"""Lightweight in-memory rate limiter middleware for FastAPI.
Uses a sliding-window counter per client IP. No external dependency
required suitable for single-process deployments with SQLite.
For multi-process or distributed setups, replace with a Redis-backed
solution (e.g. slowapi).
"""
from __future__ import annotations
import logging
import time
from collections import defaultdict
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import JSONResponse, Response
if TYPE_CHECKING:
from starlette.requests import Request
logger = logging.getLogger(__name__)
@dataclass
class _ClientBucket:
"""Sliding window of request timestamps for a single client."""
timestamps: list[float] = field(default_factory=list)
def count_recent(self, window: float, now: float) -> int:
"""Remove expired entries and return the count of recent requests."""
cutoff = now - window
self.timestamps = [t for t in self.timestamps if t > cutoff]
return len(self.timestamps)
def add(self, now: float) -> None:
self.timestamps.append(now)
class RateLimiterMiddleware(BaseHTTPMiddleware):
"""Per-IP rate limiter using in-memory sliding windows.
Args:
app: The ASGI application.
requests_per_window: Max requests allowed per window.
window_seconds: Size of the sliding window in seconds.
exclude_paths: Paths exempt from rate limiting (e.g. health checks).
"""
def __init__(
self,
app,
*,
requests_per_window: int = 60,
window_seconds: float = 60.0,
exclude_paths: tuple[str, ...] = ("/api/health",),
):
super().__init__(app)
self._max_requests = requests_per_window
self._window = window_seconds
self._exclude = exclude_paths
self._buckets: dict[str, _ClientBucket] = defaultdict(_ClientBucket)
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if request.url.path in self._exclude:
return await call_next(request)
client_ip = request.client.host if request.client else "unknown"
now = time.monotonic()
bucket = self._buckets[client_ip]
recent = bucket.count_recent(self._window, now)
if recent >= self._max_requests:
retry_after = int(self._window)
logger.warning(
"Rate limit exceeded for %s (%d/%d)", client_ip, recent, self._max_requests
)
return JSONResponse(
status_code=429,
content={"detail": "Too many requests"},
headers={"Retry-After": str(retry_after)},
)
bucket.add(now)
return await call_next(request)

View file

@ -0,0 +1,245 @@
"""Remote Docling Serve converter — delegates conversion via HTTP.
This adapter implements the DocumentConverter port by calling a remote
Docling Serve instance's REST API (v1).
API contract based on docling-serve source code:
- Options are sent as individual multipart form fields (not a JSON blob)
- Response contains document.md_content, document.html_content, document.json_content
- json_content is a serialized DoclingDocument with texts[], tables[], pictures[]
- Bounding boxes use {l, t, r, b, coord_origin} format
"""
from __future__ import annotations
import json
import logging
import mimetypes
from pathlib import Path
import httpx
from docling_core.types.doc.base import BoundingBox, CoordOrigin
from domain.value_objects import (
ConversionOptions,
ConversionResult,
PageDetail,
PageElement,
)
from infra.bbox import to_topleft_list
logger = logging.getLogger(__name__)
_API_PREFIX = "/v1"
_DEFAULT_TIMEOUT = 600.0
# Docling Serve label → our element type
_LABEL_MAP = {
"table": "table",
"picture": "picture",
"figure": "picture",
"title": "title",
"section_header": "section_header",
"list_item": "list",
"formula": "formula",
"code": "code",
"caption": "text",
"footnote": "text",
"page_header": "text",
"page_footer": "text",
"paragraph": "text",
"text": "text",
"reference": "text",
}
class ServeConverter:
"""Adapter that delegates document conversion to a remote Docling Serve instance."""
def __init__(
self,
base_url: str,
api_key: str | None = None,
timeout: float = _DEFAULT_TIMEOUT,
):
self._base_url = base_url.rstrip("/")
self._api_key = api_key
self._timeout = timeout
def _headers(self) -> dict[str, str]:
headers: dict[str, str] = {}
if self._api_key:
headers["X-Api-Key"] = self._api_key
return headers
async def convert(
self,
file_path: str,
options: ConversionOptions,
) -> ConversionResult:
"""Convert a document by uploading it to Docling Serve."""
path = Path(file_path)
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
form_data = _build_form_data(options)
url = f"{self._base_url}{_API_PREFIX}/convert/file"
async with httpx.AsyncClient(timeout=self._timeout) as client:
with open(path, "rb") as f:
response = await client.post(
url,
files={"files": (path.name, f, content_type)},
data=form_data,
headers=self._headers(),
)
response.raise_for_status()
result_data = response.json()
return _parse_response(result_data)
async def health_check(self) -> bool:
"""Check if Docling Serve is reachable."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{self._base_url}/version",
headers=self._headers(),
)
return resp.status_code == 200
except httpx.HTTPError:
logger.warning("Docling Serve health check failed at %s", self._base_url, exc_info=True)
return False
def _build_form_data(options: ConversionOptions) -> dict[str, str | list[str]]:
"""Build form fields matching Docling Serve's multipart form contract.
Array fields (to_formats) are sent as lists httpx encodes them as
repeated form keys (to_formats=md&to_formats=html&to_formats=json).
"""
return {
"to_formats": ["md", "html", "json"],
"do_ocr": str(options.do_ocr).lower(),
"do_table_structure": str(options.do_table_structure).lower(),
"table_mode": options.table_mode,
"do_code_enrichment": str(options.do_code_enrichment).lower(),
"do_formula_enrichment": str(options.do_formula_enrichment).lower(),
"do_picture_classification": str(options.do_picture_classification).lower(),
"do_picture_description": str(options.do_picture_description).lower(),
"include_images": str(options.generate_picture_images).lower(),
"generate_page_images": str(options.generate_page_images).lower(),
"images_scale": str(options.images_scale),
}
def _parse_response(data: dict) -> ConversionResult:
"""Parse Docling Serve v1 ConvertDocumentResponse into our domain ConversionResult."""
document = data.get("document", {})
content_md = document.get("md_content") or ""
content_html = document.get("html_content") or ""
# json_content contains the full DoclingDocument with pages, elements, bboxes
json_content = document.get("json_content")
if isinstance(json_content, str):
try:
json_content = json.loads(json_content)
except json.JSONDecodeError:
logger.warning("Failed to parse json_content as JSON, ignoring structured data")
json_content = None
pages: list[PageDetail] = []
if json_content:
pages = _extract_pages_from_docling_document(json_content)
page_count = len(pages) if pages else 1
document_json = json.dumps(json_content) if json_content else None
return ConversionResult(
page_count=page_count,
content_markdown=content_md,
content_html=content_html,
pages=pages,
document_json=document_json,
)
def _extract_pages_from_docling_document(doc: dict) -> list[PageDetail]:
"""Extract pages with elements from a serialized DoclingDocument.
DoclingDocument structure:
- pages: {page_no: {size: {width, height}}}
- texts: [{label, text, prov: [{page_no, bbox: {l,t,r,b,coord_origin}}]}]
- tables: [{label, prov: [...], data: {...}}]
- pictures: [{label, prov: [...]}]
"""
pages_dict: dict[int, PageDetail] = {}
# Build page dimensions
for page_key, page_data in doc.get("pages", {}).items():
page_no = int(page_key)
size = page_data.get("size", {})
pages_dict[page_no] = PageDetail(
page_number=page_no,
width=size.get("width", 612.0),
height=size.get("height", 792.0),
)
# Process all element arrays
for item in doc.get("texts", []):
_add_element(item, pages_dict)
for item in doc.get("tables", []):
_add_element(item, pages_dict)
for item in doc.get("pictures", []):
_add_element(item, pages_dict)
return sorted(pages_dict.values(), key=lambda p: p.page_number)
def _add_element(item: dict, pages: dict[int, PageDetail]) -> None:
"""Add an element from a DoclingDocument array to the correct page."""
label = item.get("label", "text")
element_type = _LABEL_MAP.get(label, "text")
content = item.get("text", "") or ""
for prov in item.get("prov", []):
page_no = prov.get("page_no", 1)
if page_no not in pages:
pages[page_no] = PageDetail(
page_number=page_no,
width=612.0,
height=792.0,
)
bbox_data = prov.get("bbox", {})
bbox = _extract_bbox(bbox_data, pages[page_no].height)
pages[page_no].elements.append(
PageElement(type=element_type, bbox=bbox, content=content, level=0)
)
def _extract_bbox(bbox_data: dict, page_height: float) -> list[float]:
"""Extract and normalize bbox to TOPLEFT [l, t, r, b] format.
Delegates to the canonical to_topleft_list function via a docling-core
BoundingBox, ensuring consistent coordinate handling across all converters.
"""
if not isinstance(bbox_data, dict):
return [0.0, 0.0, 0.0, 0.0]
origin_str = bbox_data.get("coord_origin", "TOPLEFT")
origin = CoordOrigin.BOTTOMLEFT if origin_str == "BOTTOMLEFT" else CoordOrigin.TOPLEFT
bbox = BoundingBox(
l=bbox_data.get("l", 0.0),
t=bbox_data.get("t", 0.0),
r=bbox_data.get("r", 0.0),
b=bbox_data.get("b", 0.0),
coord_origin=origin,
)
return to_topleft_list(bbox, page_height)

View file

@ -0,0 +1,43 @@
"""Centralized application settings — loaded from environment variables."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Settings:
app_version: str = "dev"
conversion_engine: str = "local" # "local" or "remote"
deployment_mode: str = "self-hosted" # "self-hosted" or "huggingface"
docling_serve_url: str = "http://localhost:5001"
docling_serve_api_key: str | None = None
conversion_timeout: int = 600
max_concurrent_analyses: int = 3
upload_dir: str = "./uploads"
db_path: str = "./data/docling_studio.db"
cors_origins: list[str] = field(
default_factory=lambda: ["http://localhost:3000", "http://localhost:5173"]
)
@classmethod
def from_env(cls) -> Settings:
"""Build a Settings instance from environment variables."""
cors_raw = os.environ.get("CORS_ORIGINS", "http://localhost:3000,http://localhost:5173")
return cls(
app_version=os.environ.get("APP_VERSION", "dev"),
conversion_engine=os.environ.get("CONVERSION_ENGINE", "local"),
deployment_mode=os.environ.get("DEPLOYMENT_MODE", "self-hosted"),
docling_serve_url=os.environ.get("DOCLING_SERVE_URL", "http://localhost:5001"),
docling_serve_api_key=os.environ.get("DOCLING_SERVE_API_KEY"),
conversion_timeout=int(os.environ.get("CONVERSION_TIMEOUT", "600")),
max_concurrent_analyses=int(os.environ.get("MAX_CONCURRENT_ANALYSES", "3")),
upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"),
db_path=os.environ.get("DB_PATH", "./data/docling_studio.db"),
cors_origins=[o.strip() for o in cors_raw.split(",")],
)
# Module-level singleton — import this from other modules.
settings = Settings.from_env()

View file

@ -1,14 +1,18 @@
"""Docling Studio — unified FastAPI backend. """Docling Studio — unified FastAPI backend.
Single service replacing both the Spring Boot backend and the document parser. Single service providing document management (upload, CRUD), analysis
Provides document management (upload, CRUD), analysis orchestration (async Docling orchestration (async Docling processing), and PDF preview all backed
processing), and PDF preview all backed by SQLite. by SQLite.
Conversion engine is selected via CONVERSION_ENGINE env var:
- "local" Docling runs in-process as a Python library (default)
- "remote" delegates to a Docling Serve instance via HTTP
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import os from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
@ -16,7 +20,10 @@ from fastapi.middleware.cors import CORSMiddleware
from api.analyses import router as analyses_router from api.analyses import router as analyses_router
from api.documents import router as documents_router from api.documents import router as documents_router
from persistence.database import init_db from infra.rate_limiter import RateLimiterMiddleware
from infra.settings import settings
from persistence.database import get_connection, init_db
from services.analysis_service import AnalysisService
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@ -25,11 +32,53 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _build_converter():
"""Build the converter adapter based on configuration."""
if settings.conversion_engine == "remote":
from infra.serve_converter import ServeConverter
logger.info("Using remote Docling Serve at %s", settings.docling_serve_url)
return ServeConverter(
base_url=settings.docling_serve_url,
api_key=settings.docling_serve_api_key,
)
else:
from infra.local_converter import LocalConverter
logger.info("Using local Docling converter")
return LocalConverter()
def _build_chunker():
"""Build the chunker adapter — only available in local mode."""
if settings.conversion_engine == "local":
from infra.local_chunker import LocalChunker
return LocalChunker()
return None
def _build_analysis_service() -> AnalysisService:
converter = _build_converter()
chunker = _build_chunker()
return AnalysisService(
converter=converter,
chunker=chunker,
conversion_timeout=settings.conversion_timeout,
max_concurrent=settings.max_concurrent_analyses,
)
# ---------------------------------------------------------------------------
# FastAPI app
# ---------------------------------------------------------------------------
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Startup: initialize database. Shutdown: nothing special needed."""
await init_db() await init_db()
logger.info("Docling Studio backend ready") app.state.analysis_service = _build_analysis_service()
logger.info("Docling Studio backend ready (engine=%s)", settings.conversion_engine)
yield yield
@ -39,25 +88,35 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
# CORS — configurable via env, defaults for local dev
allowed_origins = os.environ.get(
"CORS_ORIGINS", "http://localhost:3000,http://localhost:5173"
).split(",")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[o.strip() for o in allowed_origins], allow_origins=settings.cors_origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["GET", "POST", "DELETE", "OPTIONS"], allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"], allow_headers=["Content-Type", "Authorization"],
) )
app.add_middleware(RateLimiterMiddleware, requests_per_window=100, window_seconds=60)
# Mount routers
app.include_router(documents_router) app.include_router(documents_router)
app.include_router(analyses_router) app.include_router(analyses_router)
@app.get("/health") @app.get("/api/health")
def health(): async def health() -> dict[str, str]:
"""Health check endpoint.""" """Health check endpoint — verifies database connectivity."""
return {"status": "ok"} db_status = "ok"
try:
async with get_connection() as db:
await db.execute("SELECT 1")
except Exception:
db_status = "error"
logger.warning("Health check: database unreachable", exc_info=True)
status = "ok" if db_status == "ok" else "degraded"
return {
"status": status,
"version": settings.app_version,
"engine": settings.conversion_engine,
"deploymentMode": settings.deployment_mode,
"database": db_status,
}

View file

@ -2,11 +2,21 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime
from domain.models import AnalysisJob, AnalysisStatus from domain.models import AnalysisJob, AnalysisStatus
from persistence.database import get_connection from persistence.database import get_connection
def _parse_dt(value: str | None) -> datetime | None:
"""Parse an ISO-format datetime string back into a datetime object."""
if not value:
return None
return datetime.fromisoformat(value)
def _row_to_job(row) -> AnalysisJob: def _row_to_job(row) -> AnalysisJob:
keys = row.keys()
return AnalysisJob( return AnalysisJob(
id=row["id"], id=row["id"],
document_id=row["document_id"], document_id=row["document_id"],
@ -14,11 +24,13 @@ def _row_to_job(row) -> AnalysisJob:
content_markdown=row["content_markdown"], content_markdown=row["content_markdown"],
content_html=row["content_html"], content_html=row["content_html"],
pages_json=row["pages_json"], pages_json=row["pages_json"],
document_json=row["document_json"] if "document_json" in keys else None,
chunks_json=row["chunks_json"] if "chunks_json" in keys else None,
error_message=row["error_message"], error_message=row["error_message"],
started_at=row["started_at"], started_at=_parse_dt(row["started_at"]),
completed_at=row["completed_at"], completed_at=_parse_dt(row["completed_at"]),
created_at=row["created_at"], created_at=_parse_dt(row["created_at"]) or datetime.now(UTC),
document_filename=row["filename"] if "filename" in row.keys() else None, # noqa: SIM118 document_filename=row["filename"] if "filename" in keys else None,
) )
@ -30,6 +42,7 @@ _SELECT_WITH_DOC = """
async def insert(job: AnalysisJob) -> None: async def insert(job: AnalysisJob) -> None:
"""Persist a new analysis job record."""
async with get_connection() as db: async with get_connection() as db:
await db.execute( await db.execute(
"""INSERT INTO analysis_jobs (id, document_id, status, created_at) """INSERT INTO analysis_jobs (id, document_id, status, created_at)
@ -40,6 +53,7 @@ async def insert(job: AnalysisJob) -> None:
async def find_all(*, limit: int = 200, offset: int = 0) -> list[AnalysisJob]: async def find_all(*, limit: int = 200, offset: int = 0) -> list[AnalysisJob]:
"""Return analysis jobs with document info, newest first."""
async with get_connection() as db: async with get_connection() as db:
cursor = await db.execute( cursor = await db.execute(
f"{_SELECT_WITH_DOC} ORDER BY aj.created_at DESC LIMIT ? OFFSET ?", f"{_SELECT_WITH_DOC} ORDER BY aj.created_at DESC LIMIT ? OFFSET ?",
@ -50,31 +64,51 @@ async def find_all(*, limit: int = 200, offset: int = 0) -> list[AnalysisJob]:
async def find_by_id(job_id: str) -> AnalysisJob | None: async def find_by_id(job_id: str) -> AnalysisJob | None:
"""Find an analysis job by ID (with document filename), or return None."""
async with get_connection() as db: async with get_connection() as db:
cursor = await db.execute( cursor = await db.execute(f"{_SELECT_WITH_DOC} WHERE aj.id = ?", (job_id,))
f"{_SELECT_WITH_DOC} WHERE aj.id = ?", (job_id,)
)
row = await cursor.fetchone() row = await cursor.fetchone()
return _row_to_job(row) if row else None return _row_to_job(row) if row else None
async def update_status(job: AnalysisJob) -> None: async def update_status(job: AnalysisJob) -> None:
"""Persist all mutable fields of an analysis job (status, results, timestamps)."""
async with get_connection() as db: async with get_connection() as db:
await db.execute( await db.execute(
"""UPDATE analysis_jobs """UPDATE analysis_jobs
SET status = ?, content_markdown = ?, content_html = ?, SET status = ?, content_markdown = ?, content_html = ?,
pages_json = ?, error_message = ?, started_at = ?, completed_at = ? pages_json = ?, document_json = ?, chunks_json = ?,
error_message = ?, started_at = ?, completed_at = ?
WHERE id = ?""", WHERE id = ?""",
(job.status.value, job.content_markdown, job.content_html, (
job.pages_json, job.error_message, job.status.value,
str(job.started_at) if job.started_at else None, job.content_markdown,
str(job.completed_at) if job.completed_at else None, job.content_html,
job.id), job.pages_json,
job.document_json,
job.chunks_json,
job.error_message,
str(job.started_at) if job.started_at else None,
str(job.completed_at) if job.completed_at else None,
job.id,
),
) )
await db.commit() await db.commit()
async def update_chunks(job_id: str, chunks_json: str) -> bool:
"""Update only the chunks_json column for a completed analysis."""
async with get_connection() as db:
cursor = await db.execute(
"UPDATE analysis_jobs SET chunks_json = ? WHERE id = ?",
(chunks_json, job_id),
)
await db.commit()
return cursor.rowcount > 0
async def delete(job_id: str) -> bool: async def delete(job_id: str) -> bool:
"""Delete an analysis job by ID. Returns True if a row was removed."""
async with get_connection() as db: async with get_connection() as db:
cursor = await db.execute("DELETE FROM analysis_jobs WHERE id = ?", (job_id,)) cursor = await db.execute("DELETE FROM analysis_jobs WHERE id = ?", (job_id,))
await db.commit() await db.commit()
@ -84,8 +118,6 @@ async def delete(job_id: str) -> bool:
async def delete_by_document(document_id: str) -> int: async def delete_by_document(document_id: str) -> int:
"""Delete all analysis jobs for a given document. Returns count deleted.""" """Delete all analysis jobs for a given document. Returns count deleted."""
async with get_connection() as db: async with get_connection() as db:
cursor = await db.execute( cursor = await db.execute("DELETE FROM analysis_jobs WHERE document_id = ?", (document_id,))
"DELETE FROM analysis_jobs WHERE document_id = ?", (document_id,)
)
await db.commit() await db.commit()
return cursor.rowcount return cursor.rowcount

View file

@ -4,13 +4,16 @@ from __future__ import annotations
import logging import logging
import os import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import aiosqlite import aiosqlite
from infra.settings import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DB_PATH = os.environ.get("DB_PATH", "./data/docling_studio.db") DB_PATH = settings.db_path
_SCHEMA = """ _SCHEMA = """
CREATE TABLE IF NOT EXISTS documents ( CREATE TABLE IF NOT EXISTS documents (
@ -30,6 +33,8 @@ CREATE TABLE IF NOT EXISTS analysis_jobs (
content_markdown TEXT, content_markdown TEXT,
content_html TEXT, content_html TEXT,
pages_json TEXT, pages_json TEXT,
document_json TEXT,
chunks_json TEXT,
error_message TEXT, error_message TEXT,
started_at TEXT, started_at TEXT,
completed_at TEXT, completed_at TEXT,
@ -42,11 +47,29 @@ CREATE INDEX IF NOT EXISTS idx_documents_created_at ON documents(created_at);
""" """
_MIGRATIONS = [
("document_json", "ALTER TABLE analysis_jobs ADD COLUMN document_json TEXT"),
("chunks_json", "ALTER TABLE analysis_jobs ADD COLUMN chunks_json TEXT"),
]
async def _run_migrations(db: aiosqlite.Connection) -> None:
"""Add columns that may be missing in older databases."""
cursor = await db.execute("PRAGMA table_info(analysis_jobs)")
existing = {row[1] for row in await cursor.fetchall()}
for col_name, ddl in _MIGRATIONS:
if col_name not in existing:
await db.execute(ddl)
logger.info("Migration: added column %s to analysis_jobs", col_name)
await db.commit()
async def init_db() -> None: async def init_db() -> None:
"""Create database file and tables if they don't exist.""" """Create database file and tables if they don't exist."""
os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True) os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True)
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
await db.executescript(_SCHEMA) await db.executescript(_SCHEMA)
await _run_migrations(db)
await db.commit() await db.commit()
logger.info("Database initialized at %s", DB_PATH) logger.info("Database initialized at %s", DB_PATH)
@ -60,7 +83,7 @@ async def get_db() -> aiosqlite.Connection:
@asynccontextmanager @asynccontextmanager
async def get_connection(): async def get_connection() -> AsyncIterator[aiosqlite.Connection]:
"""Context manager that opens and auto-closes a database connection.""" """Context manager that opens and auto-closes a database connection."""
db = await get_db() db = await get_db()
try: try:

View file

@ -2,11 +2,18 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime
from domain.models import Document from domain.models import Document
from persistence.database import get_connection from persistence.database import get_connection
def _row_to_document(row) -> Document: def _row_to_document(row) -> Document:
created = row["created_at"]
if isinstance(created, str):
created = datetime.fromisoformat(created)
if created.tzinfo is None:
created = created.replace(tzinfo=UTC)
return Document( return Document(
id=row["id"], id=row["id"],
filename=row["filename"], filename=row["filename"],
@ -14,22 +21,31 @@ def _row_to_document(row) -> Document:
file_size=row["file_size"], file_size=row["file_size"],
page_count=row["page_count"], page_count=row["page_count"],
storage_path=row["storage_path"], storage_path=row["storage_path"],
created_at=row["created_at"], created_at=created,
) )
async def insert(doc: Document) -> None: async def insert(doc: Document) -> None:
"""Persist a new document record."""
async with get_connection() as db: async with get_connection() as db:
await db.execute( await db.execute(
"""INSERT INTO documents (id, filename, content_type, file_size, page_count, storage_path, created_at) """INSERT INTO documents (id, filename, content_type, file_size, page_count, storage_path, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?)""",
(doc.id, doc.filename, doc.content_type, doc.file_size, (
doc.page_count, doc.storage_path, str(doc.created_at)), doc.id,
doc.filename,
doc.content_type,
doc.file_size,
doc.page_count,
doc.storage_path,
str(doc.created_at),
),
) )
await db.commit() await db.commit()
async def find_all(*, limit: int = 200, offset: int = 0) -> list[Document]: async def find_all(*, limit: int = 200, offset: int = 0) -> list[Document]:
"""Return documents ordered by creation date (newest first)."""
async with get_connection() as db: async with get_connection() as db:
cursor = await db.execute( cursor = await db.execute(
"SELECT * FROM documents ORDER BY created_at DESC LIMIT ? OFFSET ?", "SELECT * FROM documents ORDER BY created_at DESC LIMIT ? OFFSET ?",
@ -40,6 +56,7 @@ async def find_all(*, limit: int = 200, offset: int = 0) -> list[Document]:
async def find_by_id(doc_id: str) -> Document | None: async def find_by_id(doc_id: str) -> Document | None:
"""Find a document by its ID, or return None."""
async with get_connection() as db: async with get_connection() as db:
cursor = await db.execute("SELECT * FROM documents WHERE id = ?", (doc_id,)) cursor = await db.execute("SELECT * FROM documents WHERE id = ?", (doc_id,))
row = await cursor.fetchone() row = await cursor.fetchone()
@ -47,6 +64,7 @@ async def find_by_id(doc_id: str) -> Document | None:
async def update_page_count(doc_id: str, page_count: int) -> None: async def update_page_count(doc_id: str, page_count: int) -> None:
"""Update the page count after conversion has determined it."""
async with get_connection() as db: async with get_connection() as db:
await db.execute( await db.execute(
"UPDATE documents SET page_count = ? WHERE id = ?", "UPDATE documents SET page_count = ? WHERE id = ?",
@ -56,6 +74,7 @@ async def update_page_count(doc_id: str, page_count: int) -> None:
async def delete(doc_id: str) -> bool: async def delete(doc_id: str) -> bool:
"""Delete a document by ID. Returns True if a row was removed."""
async with get_connection() as db: async with get_connection() as db:
cursor = await db.execute("DELETE FROM documents WHERE id = ?", (doc_id,)) cursor = await db.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
await db.commit() await db.commit()

View file

@ -0,0 +1,2 @@
-r requirements.txt
docling>=2.80.0,<3.0.0

View file

@ -1,4 +1,3 @@
docling>=2.80.0,<3.0.0
docling-core>=2.0.0,<3.0.0 docling-core>=2.0.0,<3.0.0
fastapi>=0.115.0,<1.0.0 fastapi>=0.115.0,<1.0.0
uvicorn[standard]>=0.32.0,<1.0.0 uvicorn[standard]>=0.32.0,<1.0.0
@ -6,3 +5,4 @@ python-multipart>=0.0.12
pdf2image>=1.17.0,<2.0.0 pdf2image>=1.17.0,<2.0.0
pillow>=10.0.0,<11.0.0 pillow>=10.0.0,<11.0.0
aiosqlite>=0.20.0,<1.0.0 aiosqlite>=0.20.0,<1.0.0
httpx>=0.27.0,<1.0.0

View file

@ -1,108 +1,215 @@
"""Analysis service — async document parsing orchestration.""" """Analysis service — async document parsing orchestration.
Uses an injected DocumentConverter (port) so the service is decoupled
from the conversion implementation (local Docling lib vs remote Docling Serve).
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import functools
import json import json
import logging import logging
from dataclasses import asdict from dataclasses import asdict
from typing import TYPE_CHECKING
from domain.models import AnalysisJob from domain.models import AnalysisJob, AnalysisStatus
from domain.parsing import ConversionOptions, ConversionResult, convert_document from domain.value_objects import ChunkingOptions, ChunkResult, ConversionOptions, ConversionResult
if TYPE_CHECKING:
from domain.ports import DocumentChunker, DocumentConverter
from persistence import analysis_repo, document_repo from persistence import analysis_repo, document_repo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Maximum time (seconds) allowed for a single document conversion.
CONVERSION_TIMEOUT = int(__import__("os").environ.get("CONVERSION_TIMEOUT", "600")) def _chunk_to_dict(c: ChunkResult) -> dict:
"""Serialize ChunkResult to a camelCase dict matching the frontend API contract."""
return {
"text": c.text,
"headings": c.headings,
"sourcePage": c.source_page,
"tokenCount": c.token_count,
"bboxes": [{"page": b.page, "bbox": b.bbox} for b in c.bboxes],
}
async def create(document_id: str, *, pipeline_options: dict | None = None) -> AnalysisJob: # Maximum number of concurrent analysis jobs to prevent resource exhaustion.
"""Create a new analysis job and launch background processing.""" _DEFAULT_MAX_CONCURRENT = 3
doc = await document_repo.find_by_id(document_id)
if not doc:
raise ValueError(f"Document not found: {document_id}")
job = AnalysisJob(document_id=document_id)
job.document_filename = doc.filename
await analysis_repo.insert(job)
# Fire background task with error logging callback
task = asyncio.create_task(
_run_analysis(job.id, doc.storage_path, doc.filename, pipeline_options)
)
task.add_done_callback(_on_task_done)
return job
def _on_task_done(task: asyncio.Task) -> None: class AnalysisService:
"""Log unhandled exceptions from background analysis tasks.""" """Orchestrates document analysis using an injected converter."""
def __init__(
self,
converter: DocumentConverter,
chunker: DocumentChunker | None = None,
conversion_timeout: int = 600,
max_concurrent: int = _DEFAULT_MAX_CONCURRENT,
):
self._converter = converter
self._chunker = chunker
self._conversion_timeout = conversion_timeout
self._semaphore = asyncio.Semaphore(max_concurrent)
async def create(
self,
document_id: str,
*,
pipeline_options: dict | None = None,
chunking_options: dict | None = None,
) -> AnalysisJob:
"""Create a new analysis job and launch background processing."""
doc = await document_repo.find_by_id(document_id)
if not doc:
raise ValueError(f"Document not found: {document_id}")
job = AnalysisJob(document_id=document_id)
job.document_filename = doc.filename
await analysis_repo.insert(job)
task = asyncio.create_task(
self._run_analysis(
job.id,
doc.storage_path,
doc.filename,
pipeline_options,
chunking_options,
)
)
task.add_done_callback(functools.partial(_on_task_done, job_id=job.id))
return job
async def find_all(self) -> list[AnalysisJob]:
"""Return all analysis jobs, newest first."""
return await analysis_repo.find_all()
async def find_by_id(self, job_id: str) -> AnalysisJob | None:
"""Find an analysis job by ID, or return None."""
return await analysis_repo.find_by_id(job_id)
async def delete(self, job_id: str) -> bool:
"""Delete an analysis job. Returns True if it existed."""
return await analysis_repo.delete(job_id)
async def rechunk(self, job_id: str, chunking_options: dict) -> list[ChunkResult]:
"""Re-chunk an existing completed analysis with new options."""
job = await analysis_repo.find_by_id(job_id)
if not job:
raise ValueError(f"Analysis not found: {job_id}")
if job.status != AnalysisStatus.COMPLETED:
raise ValueError(f"Analysis is not completed: {job_id}")
if not job.document_json:
raise ValueError(f"No document data available for re-chunking: {job_id}")
if not self._chunker:
raise ValueError("Chunking is not available")
options = ChunkingOptions(**chunking_options)
chunks = await self._chunker.chunk(job.document_json, options)
chunks_json = json.dumps([_chunk_to_dict(c) for c in chunks])
await analysis_repo.update_chunks(job_id, chunks_json)
return chunks
async def _run_analysis(
self,
job_id: str,
file_path: str,
filename: str,
pipeline_options: dict | None = None,
chunking_options: dict | None = None,
) -> None:
"""Background task: run conversion and optionally chunk.
Acquires the concurrency semaphore to limit parallel conversions
and prevent CPU/memory exhaustion on modest hardware.
"""
async with self._semaphore:
await self._run_analysis_inner(
job_id, file_path, filename, pipeline_options, chunking_options
)
async def _run_analysis_inner(
self,
job_id: str,
file_path: str,
filename: str,
pipeline_options: dict | None = None,
chunking_options: dict | None = None,
) -> None:
"""Inner analysis logic — called under the concurrency semaphore."""
try:
job = await analysis_repo.find_by_id(job_id)
if not job:
logger.error("Analysis job %s not found", job_id)
return
job.mark_running()
await analysis_repo.update_status(job)
logger.info("Analysis started: %s (file: %s)", job_id, filename)
options = ConversionOptions(**(pipeline_options or {}))
result: ConversionResult = await asyncio.wait_for(
self._converter.convert(file_path, options),
timeout=self._conversion_timeout,
)
pages_json = json.dumps([asdict(p) for p in result.pages])
chunks_json = None
if chunking_options and self._chunker and result.document_json:
chunk_opts = ChunkingOptions(**chunking_options)
chunks = await self._chunker.chunk(result.document_json, chunk_opts)
chunks_json = json.dumps([_chunk_to_dict(c) for c in chunks])
logger.info("Chunking produced %d chunks for job %s", len(chunks), job_id)
job.mark_completed(
markdown=result.content_markdown,
html=result.content_html,
pages_json=pages_json,
document_json=result.document_json,
chunks_json=chunks_json,
)
await analysis_repo.update_status(job)
if result.page_count:
await document_repo.update_page_count(job.document_id, result.page_count)
logger.info("Analysis completed: %s (%d pages)", job_id, result.page_count)
except TimeoutError:
logger.error("Analysis timed out after %ds: %s", self._conversion_timeout, job_id)
await _mark_failed(job_id, f"Conversion timed out after {self._conversion_timeout}s")
except Exception as e:
logger.exception("Analysis failed: %s", job_id)
await _mark_failed(job_id, str(e))
_background_tasks: set[asyncio.Task] = set()
def _on_task_done(task: asyncio.Task, *, job_id: str) -> None:
"""Log unhandled exceptions from background analysis tasks and mark job as FAILED."""
if task.cancelled(): if task.cancelled():
logger.warning("Analysis task was cancelled") logger.warning("Analysis task was cancelled: %s", job_id)
_schedule_mark_failed(job_id, "Task was cancelled")
return return
exc = task.exception() exc = task.exception()
if exc: if exc:
logger.error("Unhandled exception in analysis task: %s", exc, exc_info=True) logger.error("Unhandled exception in analysis task %s: %s", job_id, exc, exc_info=True)
_schedule_mark_failed(job_id, str(exc))
async def find_all() -> list[AnalysisJob]: def _schedule_mark_failed(job_id: str, error: str) -> None:
return await analysis_repo.find_all() """Schedule _mark_failed as a tracked background task."""
t = asyncio.ensure_future(_mark_failed(job_id, error))
_background_tasks.add(t)
async def find_by_id(job_id: str) -> AnalysisJob | None: t.add_done_callback(_background_tasks.discard)
return await analysis_repo.find_by_id(job_id)
async def delete(job_id: str) -> bool:
return await analysis_repo.delete(job_id)
async def _run_analysis(
job_id: str, file_path: str, filename: str, pipeline_options: dict | None = None,
) -> None:
"""Background task: run Docling conversion and update job status."""
try:
job = await analysis_repo.find_by_id(job_id)
if not job:
logger.error("Analysis job %s not found", job_id)
return
job.mark_running()
await analysis_repo.update_status(job)
logger.info("Analysis started: %s (file: %s)", job_id, filename)
# Build conversion options from pipeline dict
options = ConversionOptions(**(pipeline_options or {}))
# Run blocking Docling conversion in a thread with timeout
result: ConversionResult = await asyncio.wait_for(
asyncio.to_thread(convert_document, file_path, options),
timeout=CONVERSION_TIMEOUT,
)
pages_json = json.dumps([asdict(p) for p in result.pages])
job.mark_completed(
markdown=result.content_markdown,
html=result.content_html,
pages_json=pages_json,
)
await analysis_repo.update_status(job)
# Update document page count if available
if result.page_count:
await document_repo.update_page_count(job.document_id, result.page_count)
logger.info("Analysis completed: %s (%d pages)", job_id, result.page_count)
except TimeoutError:
logger.error("Analysis timed out after %ds: %s", CONVERSION_TIMEOUT, job_id)
await _mark_failed(job_id, f"Conversion timed out after {CONVERSION_TIMEOUT}s")
except Exception as e:
logger.exception("Analysis failed: %s", job_id)
await _mark_failed(job_id, str(e))
async def _mark_failed(job_id: str, error: str) -> None: async def _mark_failed(job_id: str, error: str) -> None:
@ -112,5 +219,7 @@ async def _mark_failed(job_id: str, error: str) -> None:
if job: if job:
job.mark_failed(error) job.mark_failed(error)
await analysis_repo.update_status(job) await analysis_repo.update_status(job)
except OSError:
logger.exception("Database I/O error marking job %s as failed", job_id)
except Exception: except Exception:
logger.exception("Could not mark job %s as failed", job_id) logger.exception("Unexpected error marking job %s as failed", job_id)

View file

@ -10,11 +10,12 @@ import uuid
from pdf2image import convert_from_bytes, pdfinfo_from_bytes from pdf2image import convert_from_bytes, pdfinfo_from_bytes
from domain.models import Document from domain.models import Document
from infra.settings import settings
from persistence import analysis_repo, document_repo from persistence import analysis_repo, document_repo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "./uploads") UPLOAD_DIR = settings.upload_dir
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
@ -22,8 +23,14 @@ MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
_PDF_MAGIC = b"%PDF" _PDF_MAGIC = b"%PDF"
_UPLOAD_CHUNK_SIZE = 64 * 1024 # 64 KB chunks for streaming writes
async def upload(filename: str, content_type: str, file_content: bytes) -> Document: async def upload(filename: str, content_type: str, file_content: bytes) -> Document:
"""Save uploaded file to disk and persist metadata.""" """Save uploaded file to disk and persist metadata.
Writes the file in fixed-size chunks to keep peak memory usage low.
"""
if len(file_content) > MAX_FILE_SIZE: if len(file_content) > MAX_FILE_SIZE:
raise ValueError("File too large (max 50 MB)") raise ValueError("File too large (max 50 MB)")
@ -32,12 +39,14 @@ async def upload(filename: str, content_type: str, file_content: bytes) -> Docum
os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(UPLOAD_DIR, exist_ok=True)
ext = os.path.splitext(filename)[1] or ".pdf" ext = ".pdf" # Content already validated as PDF
safe_name = f"{uuid.uuid4()}{ext}" safe_name = f"{uuid.uuid4()}{ext}"
file_path = os.path.join(UPLOAD_DIR, safe_name) file_path = os.path.join(UPLOAD_DIR, safe_name)
# Write in chunks to avoid doubling memory usage for large files
with open(file_path, "wb") as f: with open(file_path, "wb") as f:
f.write(file_content) for offset in range(0, len(file_content), _UPLOAD_CHUNK_SIZE):
f.write(file_content[offset : offset + _UPLOAD_CHUNK_SIZE])
# Count PDF pages # Count PDF pages
page_count = _count_pages(file_content) page_count = _count_pages(file_content)
@ -54,10 +63,12 @@ async def upload(filename: str, content_type: str, file_content: bytes) -> Docum
async def find_all() -> list[Document]: async def find_all() -> list[Document]:
"""Return all documents, newest first."""
return await document_repo.find_all() return await document_repo.find_all()
async def find_by_id(doc_id: str) -> Document | None: async def find_by_id(doc_id: str) -> Document | None:
"""Find a document by its ID, or return None."""
return await document_repo.find_by_id(doc_id) return await document_repo.find_by_id(doc_id)
@ -70,12 +81,20 @@ async def delete(doc_id: str) -> bool:
# Delete associated analyses first (cascade) # Delete associated analyses first (cascade)
await analysis_repo.delete_by_document(doc_id) await analysis_repo.delete_by_document(doc_id)
# Delete file from disk # Delete file from disk (only if inside UPLOAD_DIR)
try: try:
if os.path.exists(doc.storage_path): real_path = os.path.realpath(doc.storage_path)
os.unlink(doc.storage_path) real_upload_dir = os.path.realpath(UPLOAD_DIR)
if real_path.startswith(real_upload_dir + os.sep) and os.path.exists(real_path):
os.unlink(real_path)
elif os.path.exists(doc.storage_path):
logger.warning("Refused to delete file outside upload dir: %s", doc.storage_path)
except FileNotFoundError:
logger.info("File already removed: %s", doc.storage_path)
except PermissionError:
logger.error("Permission denied deleting file: %s", doc.storage_path)
except OSError: except OSError:
logger.warning("Could not delete file: %s", doc.storage_path) logger.warning("Could not delete file: %s", doc.storage_path, exc_info=True)
return await document_repo.delete(doc_id) return await document_repo.delete(doc_id)
@ -96,6 +115,9 @@ def _count_pages(file_content: bytes) -> int | None:
try: try:
info = pdfinfo_from_bytes(file_content) info = pdfinfo_from_bytes(file_content)
return info.get("Pages") return info.get("Pages")
except Exception: except (FileNotFoundError, OSError) as exc:
logger.warning("Could not count pages", exc_info=True) logger.warning("Could not count pages: %s", exc)
return None
except Exception:
logger.warning("Unexpected error counting pages", exc_info=True)
return None return None

View file

@ -0,0 +1,114 @@
"""Tests for AnalysisService — callbacks, concurrency, and orchestration."""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from services.analysis_service import AnalysisService, _on_task_done
class TestOnTaskDone:
"""Bug #1: _on_task_done must call _mark_failed when the task raises."""
@pytest.mark.asyncio
async def test_exception_marks_job_failed(self):
"""When a background task raises, the job should be marked FAILED."""
job_id = "job-123"
# Create a task that raises
async def failing_task():
raise RuntimeError("boom")
task = asyncio.create_task(failing_task())
await asyncio.sleep(0) # let the task fail
with patch("services.analysis_service._mark_failed", new_callable=AsyncMock) as mock_mark:
_on_task_done(task, job_id=job_id)
# ensure_future schedules it; give the event loop a tick
await asyncio.sleep(0)
mock_mark.assert_called_once_with(job_id, "boom")
@pytest.mark.asyncio
async def test_cancelled_task_marks_job_failed(self):
"""When a background task is cancelled, the job should be marked FAILED."""
job_id = "job-456"
async def slow_task():
await asyncio.sleep(999)
import contextlib
task = asyncio.create_task(slow_task())
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
with patch("services.analysis_service._mark_failed", new_callable=AsyncMock) as mock_mark:
_on_task_done(task, job_id=job_id)
await asyncio.sleep(0)
mock_mark.assert_called_once_with(job_id, "Task was cancelled")
@pytest.mark.asyncio
async def test_successful_task_does_not_mark_failed(self):
"""When a background task succeeds, _mark_failed should not be called."""
job_id = "job-789"
async def ok_task():
return "done"
task = asyncio.create_task(ok_task())
await task
with patch("services.analysis_service._mark_failed", new_callable=AsyncMock) as mock_mark:
_on_task_done(task, job_id=job_id)
await asyncio.sleep(0)
mock_mark.assert_not_called()
class TestAnalysisServiceConcurrency:
"""Verify that the semaphore limits concurrent analysis jobs."""
def test_semaphore_initialized_with_max_concurrent(self):
converter = MagicMock()
service = AnalysisService(converter=converter, max_concurrent=5)
assert service._semaphore._value == 5
def test_default_max_concurrent(self):
converter = MagicMock()
service = AnalysisService(converter=converter)
assert service._semaphore._value == 3
@pytest.mark.asyncio
async def test_semaphore_limits_parallel_jobs(self):
"""Only max_concurrent jobs should run in parallel; others must wait."""
call_order: list[str] = []
blocker = asyncio.Event()
converter = MagicMock()
service = AnalysisService(converter=converter, max_concurrent=1)
async def fake_inner(self, *args, **kwargs):
call_order.append("start")
await blocker.wait()
call_order.append("end")
with patch.object(AnalysisService, "_run_analysis_inner", fake_inner):
t1 = asyncio.create_task(service._run_analysis("j1", "/f", "f.pdf"))
t2 = asyncio.create_task(service._run_analysis("j2", "/f", "f.pdf"))
await asyncio.sleep(0.05)
# With max_concurrent=1, only one task should have started
assert call_order.count("start") == 1
blocker.set()
await asyncio.gather(t1, t2)
# Both should have completed
assert call_order.count("start") == 2
assert call_order.count("end") == 2

View file

@ -1,6 +1,6 @@
"""Tests for FastAPI API endpoints using TestClient.""" """Tests for FastAPI API endpoints using TestClient."""
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@ -14,11 +14,24 @@ def client():
return TestClient(app, raise_server_exceptions=False) return TestClient(app, raise_server_exceptions=False)
@pytest.fixture
def mock_analysis_service(client):
"""Inject a mock AnalysisService into app.state for the duration of the test."""
mock_svc = MagicMock()
original = getattr(app.state, "analysis_service", None)
app.state.analysis_service = mock_svc
yield mock_svc
app.state.analysis_service = original
class TestHealthEndpoint: class TestHealthEndpoint:
def test_health(self, client): def test_health(self, client):
resp = client.get("/health") resp = client.get("/api/health")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json() == {"status": "ok"} data = resp.json()
assert data["status"] in ("ok", "degraded")
assert "engine" in data
assert "database" in data
class TestDocumentEndpoints: class TestDocumentEndpoints:
@ -41,8 +54,12 @@ class TestDocumentEndpoints:
@patch("services.document_service.find_by_id", new_callable=AsyncMock) @patch("services.document_service.find_by_id", new_callable=AsyncMock)
def test_get_document(self, mock_find, client): def test_get_document(self, mock_find, client):
mock_find.return_value = Document( mock_find.return_value = Document(
id="d1", filename="test.pdf", content_type="application/pdf", id="d1",
file_size=2048, page_count=3, storage_path="/tmp/test", filename="test.pdf",
content_type="application/pdf",
file_size=2048,
page_count=3,
storage_path="/tmp/test",
) )
resp = client.get("/api/documents/d1") resp = client.get("/api/documents/d1")
@ -62,8 +79,10 @@ class TestDocumentEndpoints:
@patch("services.document_service.upload", new_callable=AsyncMock) @patch("services.document_service.upload", new_callable=AsyncMock)
def test_upload_document(self, mock_upload, client): def test_upload_document(self, mock_upload, client):
mock_upload.return_value = Document( mock_upload.return_value = Document(
id="new-1", filename="uploaded.pdf", id="new-1",
content_type="application/pdf", file_size=512, filename="uploaded.pdf",
content_type="application/pdf",
file_size=512,
storage_path="/tmp/uploaded", storage_path="/tmp/uploaded",
) )
@ -84,7 +103,20 @@ class TestDocumentEndpoints:
"/api/documents/upload", "/api/documents/upload",
files={"file": ("big.pdf", b"x", "application/pdf")}, files={"file": ("big.pdf", b"x", "application/pdf")},
) )
assert resp.status_code == 413 assert resp.status_code == 400
@patch("services.document_service.find_by_id", new_callable=AsyncMock)
def test_preview_page_out_of_range(self, mock_find, client):
mock_find.return_value = Document(
id="d1",
filename="test.pdf",
page_count=3,
storage_path="/tmp/test.pdf",
)
resp = client.get("/api/documents/d1/preview?page=10")
assert resp.status_code == 400
assert "out of range" in resp.json()["detail"]
@patch("services.document_service.delete", new_callable=AsyncMock) @patch("services.document_service.delete", new_callable=AsyncMock)
def test_delete_document(self, mock_delete, client): def test_delete_document(self, mock_delete, client):
@ -102,11 +134,12 @@ class TestDocumentEndpoints:
class TestAnalysisEndpoints: class TestAnalysisEndpoints:
@patch("services.analysis_service.find_all", new_callable=AsyncMock) def test_list_analyses(self, client, mock_analysis_service):
def test_list_analyses(self, mock_find_all, client): mock_analysis_service.find_all = AsyncMock(
mock_find_all.return_value = [ return_value=[
AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"), AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"),
] ]
)
resp = client.get("/api/analyses") resp = client.get("/api/analyses")
assert resp.status_code == 200 assert resp.status_code == 200
@ -117,11 +150,10 @@ class TestAnalysisEndpoints:
assert data[0]["documentFilename"] == "test.pdf" assert data[0]["documentFilename"] == "test.pdf"
assert data[0]["status"] == "PENDING" assert data[0]["status"] == "PENDING"
@patch("services.analysis_service.find_by_id", new_callable=AsyncMock) def test_get_analysis(self, client, mock_analysis_service):
def test_get_analysis(self, mock_find, client):
job = AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf") job = AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
job.mark_running() job.mark_running()
mock_find.return_value = job mock_analysis_service.find_by_id = AsyncMock(return_value=job)
resp = client.get("/api/analyses/j1") resp = client.get("/api/analyses/j1")
assert resp.status_code == 200 assert resp.status_code == 200
@ -129,17 +161,19 @@ class TestAnalysisEndpoints:
assert data["status"] == "RUNNING" assert data["status"] == "RUNNING"
assert data["startedAt"] is not None assert data["startedAt"] is not None
@patch("services.analysis_service.find_by_id", new_callable=AsyncMock) def test_get_analysis_not_found(self, client, mock_analysis_service):
def test_get_analysis_not_found(self, mock_find, client): mock_analysis_service.find_by_id = AsyncMock(return_value=None)
mock_find.return_value = None
resp = client.get("/api/analyses/missing") resp = client.get("/api/analyses/missing")
assert resp.status_code == 404 assert resp.status_code == 404
@patch("services.analysis_service.create", new_callable=AsyncMock) def test_create_analysis(self, client, mock_analysis_service):
def test_create_analysis(self, mock_create, client): mock_analysis_service.create = AsyncMock(
mock_create.return_value = AnalysisJob( return_value=AnalysisJob(
id="j1", document_id="d1", document_filename="test.pdf", id="j1",
document_id="d1",
document_filename="test.pdf",
)
) )
resp = client.post("/api/analyses", json={"documentId": "d1"}) resp = client.post("/api/analyses", json={"documentId": "d1"})
@ -147,34 +181,44 @@ class TestAnalysisEndpoints:
data = resp.json() data = resp.json()
assert data["id"] == "j1" assert data["id"] == "j1"
assert data["documentId"] == "d1" assert data["documentId"] == "d1"
mock_create.assert_called_once_with("d1", pipeline_options=None) mock_analysis_service.create.assert_called_once_with(
"d1",
@patch("services.analysis_service.create", new_callable=AsyncMock) pipeline_options=None,
def test_create_analysis_with_pipeline_options(self, mock_create, client): chunking_options=None,
mock_create.return_value = AnalysisJob(
id="j2", document_id="d1", document_filename="test.pdf",
) )
resp = client.post("/api/analyses", json={ def test_create_analysis_with_pipeline_options(self, client, mock_analysis_service):
"documentId": "d1", mock_analysis_service.create = AsyncMock(
"pipelineOptions": { return_value=AnalysisJob(
"do_ocr": False, id="j2",
"do_table_structure": True, document_id="d1",
"table_mode": "fast", document_filename="test.pdf",
"do_code_enrichment": True, )
"do_formula_enrichment": False, )
"do_picture_classification": False,
"do_picture_description": False, resp = client.post(
"generate_picture_images": True, "/api/analyses",
"generate_page_images": False, json={
"images_scale": 2.0, "documentId": "d1",
} "pipelineOptions": {
}) "do_ocr": False,
"do_table_structure": True,
"table_mode": "fast",
"do_code_enrichment": True,
"do_formula_enrichment": False,
"do_picture_classification": False,
"do_picture_description": False,
"generate_picture_images": True,
"generate_page_images": False,
"images_scale": 2.0,
},
},
)
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["id"] == "j2" assert data["id"] == "j2"
call_kwargs = mock_create.call_args call_kwargs = mock_analysis_service.create.call_args
opts = call_kwargs.kwargs["pipeline_options"] opts = call_kwargs.kwargs["pipeline_options"]
assert opts["do_ocr"] is False assert opts["do_ocr"] is False
assert opts["table_mode"] == "fast" assert opts["table_mode"] == "fast"
@ -182,51 +226,50 @@ class TestAnalysisEndpoints:
assert opts["generate_picture_images"] is True assert opts["generate_picture_images"] is True
assert opts["images_scale"] == 2.0 assert opts["images_scale"] == 2.0
@patch("services.analysis_service.create", new_callable=AsyncMock) def test_create_analysis_with_partial_pipeline_options(self, client, mock_analysis_service):
def test_create_analysis_with_partial_pipeline_options(self, mock_create, client):
"""Pipeline options should use defaults for unspecified fields.""" """Pipeline options should use defaults for unspecified fields."""
mock_create.return_value = AnalysisJob( mock_analysis_service.create = AsyncMock(
id="j3", document_id="d1", document_filename="test.pdf", return_value=AnalysisJob(
id="j3",
document_id="d1",
document_filename="test.pdf",
)
) )
resp = client.post("/api/analyses", json={ resp = client.post(
"documentId": "d1", "/api/analyses", json={"documentId": "d1", "pipelineOptions": {"do_ocr": False}}
"pipelineOptions": {"do_ocr": False} )
})
assert resp.status_code == 200 assert resp.status_code == 200
opts = mock_create.call_args.kwargs["pipeline_options"] opts = mock_analysis_service.create.call_args.kwargs["pipeline_options"]
assert opts["do_ocr"] is False assert opts["do_ocr"] is False
# Defaults # Defaults
assert opts["do_table_structure"] is True assert opts["do_table_structure"] is True
assert opts["table_mode"] == "accurate" assert opts["table_mode"] == "accurate"
assert opts["do_code_enrichment"] is False assert opts["do_code_enrichment"] is False
@patch("services.analysis_service.create", new_callable=AsyncMock) def test_create_analysis_document_not_found(self, client, mock_analysis_service):
def test_create_analysis_document_not_found(self, mock_create, client): mock_analysis_service.create = AsyncMock(side_effect=ValueError("Document not found: d99"))
mock_create.side_effect = ValueError("Document not found: d99")
resp = client.post("/api/analyses", json={"documentId": "d99"}) resp = client.post("/api/analyses", json={"documentId": "d99"})
assert resp.status_code == 404 assert resp.status_code == 404
def test_create_analysis_empty_document_id(self, client): def test_create_analysis_empty_document_id(self, client, mock_analysis_service):
resp = client.post("/api/analyses", json={"documentId": ""}) resp = client.post("/api/analyses", json={"documentId": ""})
assert resp.status_code == 400 assert resp.status_code == 400
def test_create_analysis_whitespace_document_id(self, client): def test_create_analysis_whitespace_document_id(self, client, mock_analysis_service):
resp = client.post("/api/analyses", json={"documentId": " "}) resp = client.post("/api/analyses", json={"documentId": " "})
assert resp.status_code == 400 assert resp.status_code == 400
@patch("services.analysis_service.delete", new_callable=AsyncMock) def test_delete_analysis(self, client, mock_analysis_service):
def test_delete_analysis(self, mock_delete, client): mock_analysis_service.delete = AsyncMock(return_value=True)
mock_delete.return_value = True
resp = client.delete("/api/analyses/j1") resp = client.delete("/api/analyses/j1")
assert resp.status_code == 204 assert resp.status_code == 204
@patch("services.analysis_service.delete", new_callable=AsyncMock) def test_delete_analysis_not_found(self, client, mock_analysis_service):
def test_delete_analysis_not_found(self, mock_delete, client): mock_analysis_service.delete = AsyncMock(return_value=False)
mock_delete.return_value = False
resp = client.delete("/api/analyses/missing") resp = client.delete("/api/analyses/missing")
assert resp.status_code == 404 assert resp.status_code == 404

View file

@ -8,12 +8,13 @@ misaligned overlays in the UI.
import pytest import pytest
from docling_core.types.doc.base import BoundingBox, CoordOrigin from docling_core.types.doc.base import BoundingBox, CoordOrigin
from domain.bbox import EMPTY_BBOX, to_topleft_list from infra.bbox import EMPTY_BBOX, to_topleft_list
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Standard conversions # Standard conversions
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestToTopleftListStandard: class TestToTopleftListStandard:
"""Normal bbox conversions (happy path).""" """Normal bbox conversions (happy path)."""
@ -29,10 +30,10 @@ class TestToTopleftListStandard:
result = to_topleft_list(bbox, page_height=792.0) result = to_topleft_list(bbox, page_height=792.0)
# After conversion: new_t = 792 - 700 = 92, new_b = 792 - 600 = 192 # After conversion: new_t = 792 - 700 = 92, new_b = 792 - 600 = 192
assert result[0] == 50 # l unchanged assert result[0] == 50 # l unchanged
assert result[1] == pytest.approx(92.0) # t = page_height - old_t assert result[1] == pytest.approx(92.0) # t = page_height - old_t
assert result[2] == 200 # r unchanged assert result[2] == 200 # r unchanged
assert result[3] == pytest.approx(192.0) # b = page_height - old_b assert result[3] == pytest.approx(192.0) # b = page_height - old_b
def test_result_has_positive_dimensions(self): def test_result_has_positive_dimensions(self):
"""Converted bbox should always have b > t (positive height).""" """Converted bbox should always have b > t (positive height)."""
@ -60,6 +61,7 @@ class TestToTopleftListStandard:
# Page format variations # Page format variations
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestPageFormats: class TestPageFormats:
"""Verify correct conversion across different page sizes.""" """Verify correct conversion across different page sizes."""
@ -105,6 +107,7 @@ class TestPageFormats:
# Degenerate / edge-case bboxes # Degenerate / edge-case bboxes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDegenerateBboxes: class TestDegenerateBboxes:
"""Bboxes that are invalid or degenerate should return EMPTY_BBOX.""" """Bboxes that are invalid or degenerate should return EMPTY_BBOX."""
@ -151,6 +154,7 @@ class TestDegenerateBboxes:
# Precision and boundary values # Precision and boundary values
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestPrecision: class TestPrecision:
"""Floating-point precision and edge values.""" """Floating-point precision and edge values."""

View file

@ -0,0 +1,359 @@
"""Tests for chunking feature — domain, schemas, service, and API endpoints."""
from __future__ import annotations
import json
from dataclasses import asdict
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi.testclient import TestClient
from api.schemas import ChunkBboxResponse, ChunkingOptionsRequest, ChunkResponse, RechunkRequest
from domain.models import AnalysisJob, AnalysisStatus
from domain.value_objects import ChunkBbox, ChunkingOptions, ChunkResult
from main import app
# ---------------------------------------------------------------------------
# Domain: value objects
# ---------------------------------------------------------------------------
class TestChunkingOptions:
def test_defaults(self):
opts = ChunkingOptions()
assert opts.chunker_type == "hybrid"
assert opts.max_tokens == 512
assert opts.merge_peers is True
assert opts.repeat_table_header is True
def test_custom_values(self):
opts = ChunkingOptions(chunker_type="hierarchical", max_tokens=256, merge_peers=False)
assert opts.chunker_type == "hierarchical"
assert opts.max_tokens == 256
assert opts.merge_peers is False
def test_is_default(self):
assert ChunkingOptions().is_default()
assert not ChunkingOptions(max_tokens=256).is_default()
class TestChunkResult:
def test_defaults(self):
chunk = ChunkResult(text="hello")
assert chunk.text == "hello"
assert chunk.headings == []
assert chunk.source_page is None
assert chunk.token_count == 0
def test_full_values(self):
chunk = ChunkResult(
text="content",
headings=["Title", "Section"],
source_page=3,
token_count=42,
)
assert chunk.headings == ["Title", "Section"]
assert chunk.source_page == 3
assert chunk.token_count == 42
def test_serializable(self):
chunk = ChunkResult(text="x", headings=["h1"], source_page=1, token_count=10)
data = asdict(chunk)
assert data == {
"text": "x",
"headings": ["h1"],
"source_page": 1,
"token_count": 10,
"bboxes": [],
}
class TestChunkBbox:
def test_construction(self):
bbox = ChunkBbox(page=1, bbox=[10.0, 20.0, 100.0, 80.0])
assert bbox.page == 1
assert bbox.bbox == [10.0, 20.0, 100.0, 80.0]
def test_serializable(self):
bbox = ChunkBbox(page=2, bbox=[0.0, 0.0, 50.0, 50.0])
data = asdict(bbox)
assert data == {"page": 2, "bbox": [0.0, 0.0, 50.0, 50.0]}
def test_chunk_result_with_bboxes(self):
chunk = ChunkResult(
text="content",
bboxes=[
ChunkBbox(page=1, bbox=[10, 20, 100, 80]),
ChunkBbox(page=2, bbox=[50, 50, 150, 250]),
],
)
assert len(chunk.bboxes) == 2
assert chunk.bboxes[0].page == 1
class TestChunkBboxResponse:
def test_serializes(self):
resp = ChunkBboxResponse(page=1, bbox=[10.0, 20.0, 100.0, 80.0])
data = resp.model_dump(by_alias=True)
assert data == {"page": 1, "bbox": [10.0, 20.0, 100.0, 80.0]}
def test_chunk_response_with_bboxes(self):
resp = ChunkResponse(
text="hello",
bboxes=[ChunkBboxResponse(page=1, bbox=[10, 20, 100, 80])],
)
data = resp.model_dump(by_alias=True)
assert len(data["bboxes"]) == 1
assert data["bboxes"][0]["page"] == 1
# ---------------------------------------------------------------------------
# Domain: AnalysisJob with chunking fields
# ---------------------------------------------------------------------------
class TestAnalysisJobChunking:
def test_default_chunking_fields(self):
job = AnalysisJob()
assert job.document_json is None
assert job.chunks_json is None
def test_mark_completed_with_chunks(self):
job = AnalysisJob()
job.mark_running()
job.mark_completed(
markdown="# Title",
html="<h1>Title</h1>",
pages_json="[]",
document_json='{"name": "doc"}',
chunks_json='[{"text": "chunk1"}]',
)
assert job.status == AnalysisStatus.COMPLETED
assert job.document_json == '{"name": "doc"}'
assert job.chunks_json == '[{"text": "chunk1"}]'
def test_mark_completed_without_chunks(self):
job = AnalysisJob()
job.mark_running()
job.mark_completed(markdown="md", html="html", pages_json="[]")
assert job.document_json is None
assert job.chunks_json is None
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class TestChunkingOptionsRequest:
def test_defaults(self):
opts = ChunkingOptionsRequest()
assert opts.chunker_type == "hybrid"
assert opts.max_tokens == 512
assert opts.merge_peers is True
assert opts.repeat_table_header is True
def test_custom_values(self):
opts = ChunkingOptionsRequest(chunker_type="hierarchical", max_tokens=1024)
assert opts.chunker_type == "hierarchical"
assert opts.max_tokens == 1024
def test_invalid_chunker_type(self):
with pytest.raises(ValueError, match="chunker_type"):
ChunkingOptionsRequest(chunker_type="invalid")
def test_max_tokens_too_low(self):
with pytest.raises(ValueError, match="max_tokens"):
ChunkingOptionsRequest(max_tokens=10)
def test_max_tokens_too_high(self):
with pytest.raises(ValueError, match="max_tokens"):
ChunkingOptionsRequest(max_tokens=10000)
def test_boundary_max_tokens(self):
opts_low = ChunkingOptionsRequest(max_tokens=64)
assert opts_low.max_tokens == 64
opts_high = ChunkingOptionsRequest(max_tokens=8192)
assert opts_high.max_tokens == 8192
class TestChunkResponse:
def test_serializes_to_camel_case(self):
resp = ChunkResponse(text="hello", headings=["H1"], source_page=1, token_count=5)
data = resp.model_dump(by_alias=True)
assert "sourcePage" in data
assert "tokenCount" in data
assert data["text"] == "hello"
class TestRechunkRequest:
def test_parses(self):
req = RechunkRequest(chunkingOptions=ChunkingOptionsRequest(max_tokens=256))
assert req.chunkingOptions.max_tokens == 256
# ---------------------------------------------------------------------------
# API endpoints
# ---------------------------------------------------------------------------
@pytest.fixture
def client():
return TestClient(app, raise_server_exceptions=False)
@pytest.fixture
def mock_analysis_service(client):
mock_svc = MagicMock()
original = getattr(app.state, "analysis_service", None)
app.state.analysis_service = mock_svc
yield mock_svc
app.state.analysis_service = original
class TestCreateAnalysisWithChunking:
def test_create_with_chunking_options(self, client, mock_analysis_service):
mock_analysis_service.create = AsyncMock(
return_value=AnalysisJob(
id="j1",
document_id="d1",
document_filename="test.pdf",
)
)
resp = client.post(
"/api/analyses",
json={
"documentId": "d1",
"chunkingOptions": {
"chunker_type": "hybrid",
"max_tokens": 256,
"merge_peers": False,
},
},
)
assert resp.status_code == 200
call_kwargs = mock_analysis_service.create.call_args
chunking = call_kwargs.kwargs["chunking_options"]
assert chunking["chunker_type"] == "hybrid"
assert chunking["max_tokens"] == 256
assert chunking["merge_peers"] is False
def test_create_without_chunking_options(self, client, mock_analysis_service):
mock_analysis_service.create = AsyncMock(
return_value=AnalysisJob(
id="j1",
document_id="d1",
document_filename="test.pdf",
)
)
resp = client.post("/api/analyses", json={"documentId": "d1"})
assert resp.status_code == 200
call_kwargs = mock_analysis_service.create.call_args
assert call_kwargs.kwargs["chunking_options"] is None
def test_response_includes_chunking_fields(self, client, mock_analysis_service):
job = AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
job.mark_running()
job.mark_completed(
markdown="# Title",
html="<h1>Title</h1>",
pages_json="[]",
document_json='{"name": "doc"}',
chunks_json=json.dumps([asdict(ChunkResult(text="chunk1", token_count=5))]),
)
mock_analysis_service.find_by_id = AsyncMock(return_value=job)
resp = client.get("/api/analyses/j1")
assert resp.status_code == 200
data = resp.json()
assert data["hasDocumentJson"] is True
assert data["chunksJson"] is not None
chunks = json.loads(data["chunksJson"])
assert len(chunks) == 1
assert chunks[0]["text"] == "chunk1"
class TestRechunkEndpoint:
def test_rechunk_success(self, client, mock_analysis_service):
mock_analysis_service.rechunk = AsyncMock(
return_value=[
ChunkResult(text="chunk1", headings=["H1"], source_page=1, token_count=10),
ChunkResult(text="chunk2", headings=["H1", "H2"], source_page=2, token_count=20),
]
)
resp = client.post(
"/api/analyses/j1/rechunk",
json={
"chunkingOptions": {"chunker_type": "hybrid", "max_tokens": 128},
},
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["text"] == "chunk1"
assert data[0]["sourcePage"] == 1
assert data[0]["tokenCount"] == 10
assert data[1]["headings"] == ["H1", "H2"]
def test_rechunk_not_completed(self, client, mock_analysis_service):
mock_analysis_service.rechunk = AsyncMock(
side_effect=ValueError("Analysis is not completed: j1"),
)
resp = client.post(
"/api/analyses/j1/rechunk",
json={
"chunkingOptions": {"chunker_type": "hybrid"},
},
)
assert resp.status_code == 400
def test_rechunk_no_document_json(self, client, mock_analysis_service):
mock_analysis_service.rechunk = AsyncMock(
side_effect=ValueError("No document data available for re-chunking: j1"),
)
resp = client.post(
"/api/analyses/j1/rechunk",
json={
"chunkingOptions": {"chunker_type": "hierarchical"},
},
)
assert resp.status_code == 400
def test_rechunk_returns_bboxes(self, client, mock_analysis_service):
mock_analysis_service.rechunk = AsyncMock(
return_value=[
ChunkResult(
text="chunk1",
source_page=1,
token_count=10,
bboxes=[ChunkBbox(page=1, bbox=[10, 20, 100, 80])],
),
]
)
resp = client.post(
"/api/analyses/j1/rechunk",
json={"chunkingOptions": {"chunker_type": "hybrid"}},
)
assert resp.status_code == 200
data = resp.json()
assert len(data[0]["bboxes"]) == 1
assert data[0]["bboxes"][0]["page"] == 1
assert data[0]["bboxes"][0]["bbox"] == [10, 20, 100, 80]
def test_rechunk_invalid_chunker_type(self, client, mock_analysis_service):
resp = client.post(
"/api/analyses/j1/rechunk",
json={
"chunkingOptions": {"chunker_type": "invalid"},
},
)
assert resp.status_code == 422

View file

@ -0,0 +1,137 @@
"""Tests for document_service — upload, preview, page counting, and deletion."""
from __future__ import annotations
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from domain.models import Document
from services import document_service
class TestUploadValidation:
@pytest.mark.asyncio
async def test_rejects_oversized_file(self):
content = b"x" * (document_service.MAX_FILE_SIZE + 1)
with pytest.raises(ValueError, match="File too large"):
await document_service.upload("big.pdf", "application/pdf", content)
@pytest.mark.asyncio
async def test_rejects_non_pdf(self):
content = b"NOT-A-PDF-FILE"
with pytest.raises(ValueError, match="not a PDF"):
await document_service.upload("fake.pdf", "application/pdf", content)
@pytest.mark.asyncio
async def test_accepts_valid_pdf(self, tmp_path, monkeypatch):
monkeypatch.setattr(document_service, "UPLOAD_DIR", str(tmp_path))
mock_insert = AsyncMock()
with (
patch("persistence.document_repo.insert", mock_insert),
patch.object(document_service, "_count_pages", return_value=5),
):
content = b"%PDF-1.4 fake pdf content"
doc = await document_service.upload("test.pdf", "application/pdf", content)
assert doc.filename == "test.pdf"
assert doc.file_size == len(content)
assert doc.page_count == 5
mock_insert.assert_called_once()
# Verify file was actually written to disk
assert os.path.exists(doc.storage_path)
with open(doc.storage_path, "rb") as f:
assert f.read() == content
class TestGeneratePreview:
def test_raises_on_invalid_page(self):
"""generate_preview should raise ValueError when page is out of range."""
with (
patch("services.document_service.convert_from_bytes", return_value=[]),
pytest.raises(ValueError, match="Page 1 not found"),
):
document_service.generate_preview(b"%PDF-fake", page=1)
def test_returns_png_bytes(self):
"""generate_preview should return PNG bytes from pdf2image."""
mock_image = MagicMock()
mock_image.save = MagicMock(side_effect=lambda buf, format: buf.write(b"PNG-DATA"))
with patch("services.document_service.convert_from_bytes", return_value=[mock_image]):
result = document_service.generate_preview(b"%PDF-fake", page=1, dpi=72)
assert result == b"PNG-DATA"
class TestCountPages:
def test_returns_page_count(self):
with patch(
"services.document_service.pdfinfo_from_bytes",
return_value={"Pages": 42},
):
assert document_service._count_pages(b"pdf") == 42
def test_returns_none_on_error(self):
with patch(
"services.document_service.pdfinfo_from_bytes",
side_effect=FileNotFoundError("poppler not found"),
):
assert document_service._count_pages(b"pdf") is None
class TestDelete:
@pytest.mark.asyncio
async def test_delete_removes_file_and_records(self, tmp_path, monkeypatch):
monkeypatch.setattr(document_service, "UPLOAD_DIR", str(tmp_path))
# Create a fake file
fake_file = tmp_path / "test.pdf"
fake_file.write_bytes(b"content")
doc = Document(
id="doc-1",
filename="test.pdf",
storage_path=str(fake_file),
)
with (
patch("persistence.document_repo.find_by_id", AsyncMock(return_value=doc)),
patch("persistence.analysis_repo.delete_by_document", AsyncMock(return_value=2)),
patch("persistence.document_repo.delete", AsyncMock(return_value=True)),
):
result = await document_service.delete("doc-1")
assert result is True
assert not fake_file.exists()
@pytest.mark.asyncio
async def test_delete_refuses_file_outside_upload_dir(self, tmp_path, monkeypatch):
"""Files outside UPLOAD_DIR should not be deleted (path traversal protection)."""
monkeypatch.setattr(document_service, "UPLOAD_DIR", str(tmp_path / "uploads"))
os.makedirs(tmp_path / "uploads", exist_ok=True)
# File is outside the upload dir
outside_file = tmp_path / "secret.txt"
outside_file.write_bytes(b"secret")
doc = Document(id="doc-1", filename="x.pdf", storage_path=str(outside_file))
with (
patch("persistence.document_repo.find_by_id", AsyncMock(return_value=doc)),
patch("persistence.analysis_repo.delete_by_document", AsyncMock(return_value=0)),
patch("persistence.document_repo.delete", AsyncMock(return_value=True)),
):
await document_service.delete("doc-1")
# File should NOT have been deleted
assert outside_file.exists()
@pytest.mark.asyncio
async def test_delete_not_found_returns_false(self):
with patch("persistence.document_repo.find_by_id", AsyncMock(return_value=None)):
result = await document_service.delete("missing")
assert result is False

View file

@ -1,26 +1,37 @@
"""Tests for pipeline options — build_converter, convert_document routing, service forwarding.""" """Tests for pipeline options — build_converter, convert_document routing, service forwarding.
Requires the ``docling`` library (heavy, includes torch). Tests are skipped
automatically when docling is not installed (e.g. in lightweight CI environments
that only install docling-core).
"""
from __future__ import annotations from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import ( docling = pytest.importorskip("docling", reason="docling library not installed")
from docling.datamodel.base_models import InputFormat # noqa: E402
from docling.datamodel.pipeline_options import ( # noqa: E402
PdfPipelineOptions, PdfPipelineOptions,
TableFormerMode, TableFormerMode,
) )
from domain.parsing import ( from domain.value_objects import ConversionOptions # noqa: E402
ConversionOptions, from infra.local_converter import ( # noqa: E402
build_converter, _build_docling_converter as build_converter,
convert_document, )
from infra.local_converter import ( # noqa: E402
_convert_sync as convert_document,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# build_converter — verifies Docling pipeline options are wired correctly # build_converter — verifies Docling pipeline options are wired correctly
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestBuildConverter: class TestBuildConverter:
"""Verify that build_converter produces a DocumentConverter with the right PdfPipelineOptions.""" """Verify that build_converter produces a DocumentConverter with the right PdfPipelineOptions."""
@ -30,7 +41,7 @@ class TestBuildConverter:
return fmt_opt.pipeline_options return fmt_opt.pipeline_options
def test_defaults(self): def test_defaults(self):
conv = build_converter() conv = build_converter(ConversionOptions())
opts = self._get_pipeline_options(conv) opts = self._get_pipeline_options(conv)
assert opts.do_ocr is True assert opts.do_ocr is True
assert opts.do_table_structure is True assert opts.do_table_structure is True
@ -99,18 +110,20 @@ class TestBuildConverter:
assert opts.images_scale == 2.0 assert opts.images_scale == 2.0
def test_all_options_combined(self): def test_all_options_combined(self):
conv = build_converter(ConversionOptions( conv = build_converter(
do_ocr=False, ConversionOptions(
do_table_structure=True, do_ocr=False,
table_mode="fast", do_table_structure=True,
do_code_enrichment=True, table_mode="fast",
do_formula_enrichment=True, do_code_enrichment=True,
do_picture_classification=True, do_formula_enrichment=True,
do_picture_description=True, do_picture_classification=True,
generate_picture_images=True, do_picture_description=True,
generate_page_images=True, generate_picture_images=True,
images_scale=1.5, generate_page_images=True,
)) images_scale=1.5,
)
)
opts = self._get_pipeline_options(conv) opts = self._get_pipeline_options(conv)
assert opts.do_ocr is False assert opts.do_ocr is False
assert opts.do_table_structure is True assert opts.do_table_structure is True
@ -128,11 +141,12 @@ class TestBuildConverter:
# convert_document — default vs custom converter routing # convert_document — default vs custom converter routing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestConvertDocumentRouting: class TestConvertDocumentRouting:
"""Verify convert_document uses default converter for default opts, custom otherwise.""" """Verify convert_document uses default converter for default opts, custom otherwise."""
@patch("domain.parsing.get_default_converter") @patch("infra.local_converter._get_default_converter")
@patch("domain.parsing.build_converter") @patch("infra.local_converter._build_docling_converter")
def test_uses_default_converter_with_all_defaults(self, mock_build, mock_get_default): def test_uses_default_converter_with_all_defaults(self, mock_build, mock_get_default):
mock_conv = MagicMock() mock_conv = MagicMock()
mock_result = MagicMock() mock_result = MagicMock()
@ -140,16 +154,17 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = "" mock_result.document.export_to_html.return_value = ""
mock_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_get_default.return_value = mock_conv mock_get_default.return_value = mock_conv
convert_document("/tmp/test.pdf") convert_document("/tmp/test.pdf", ConversionOptions())
mock_get_default.assert_called_once() mock_get_default.assert_called_once()
mock_build.assert_not_called() mock_build.assert_not_called()
@patch("domain.parsing.get_default_converter") @patch("infra.local_converter._get_default_converter")
@patch("domain.parsing.build_converter") @patch("infra.local_converter._build_docling_converter")
def test_uses_custom_converter_when_ocr_disabled(self, mock_build, mock_get_default): def test_uses_custom_converter_when_ocr_disabled(self, mock_build, mock_get_default):
mock_conv = MagicMock() mock_conv = MagicMock()
mock_result = MagicMock() mock_result = MagicMock()
@ -157,6 +172,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = "" mock_result.document.export_to_html.return_value = ""
mock_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -165,8 +181,8 @@ class TestConvertDocumentRouting:
mock_build.assert_called_once() mock_build.assert_called_once()
mock_get_default.assert_not_called() mock_get_default.assert_not_called()
@patch("domain.parsing.get_default_converter") @patch("infra.local_converter._get_default_converter")
@patch("domain.parsing.build_converter") @patch("infra.local_converter._build_docling_converter")
def test_uses_custom_converter_when_table_mode_fast(self, mock_build, mock_get_default): def test_uses_custom_converter_when_table_mode_fast(self, mock_build, mock_get_default):
mock_conv = MagicMock() mock_conv = MagicMock()
mock_result = MagicMock() mock_result = MagicMock()
@ -174,6 +190,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = "" mock_result.document.export_to_html.return_value = ""
mock_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -182,8 +199,8 @@ class TestConvertDocumentRouting:
mock_build.assert_called_once_with(opts) mock_build.assert_called_once_with(opts)
@patch("domain.parsing.get_default_converter") @patch("infra.local_converter._get_default_converter")
@patch("domain.parsing.build_converter") @patch("infra.local_converter._build_docling_converter")
def test_uses_custom_converter_when_code_enrichment_on(self, mock_build, mock_get_default): def test_uses_custom_converter_when_code_enrichment_on(self, mock_build, mock_get_default):
mock_conv = MagicMock() mock_conv = MagicMock()
mock_result = MagicMock() mock_result = MagicMock()
@ -191,6 +208,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = "" mock_result.document.export_to_html.return_value = ""
mock_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -199,8 +217,8 @@ class TestConvertDocumentRouting:
mock_build.assert_called_once_with(opts) mock_build.assert_called_once_with(opts)
@patch("domain.parsing.get_default_converter") @patch("infra.local_converter._get_default_converter")
@patch("domain.parsing.build_converter") @patch("infra.local_converter._build_docling_converter")
def test_uses_custom_converter_when_formula_enrichment_on(self, mock_build, mock_get_default): def test_uses_custom_converter_when_formula_enrichment_on(self, mock_build, mock_get_default):
mock_conv = MagicMock() mock_conv = MagicMock()
mock_result = MagicMock() mock_result = MagicMock()
@ -208,6 +226,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = "" mock_result.document.export_to_html.return_value = ""
mock_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -215,8 +234,8 @@ class TestConvertDocumentRouting:
mock_build.assert_called_once() mock_build.assert_called_once()
@patch("domain.parsing.get_default_converter") @patch("infra.local_converter._get_default_converter")
@patch("domain.parsing.build_converter") @patch("infra.local_converter._build_docling_converter")
def test_uses_custom_converter_when_picture_options_on(self, mock_build, mock_get_default): def test_uses_custom_converter_when_picture_options_on(self, mock_build, mock_get_default):
mock_conv = MagicMock() mock_conv = MagicMock()
mock_result = MagicMock() mock_result = MagicMock()
@ -224,6 +243,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = "" mock_result.document.export_to_html.return_value = ""
mock_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -231,8 +251,8 @@ class TestConvertDocumentRouting:
mock_build.assert_called_once() mock_build.assert_called_once()
@patch("domain.parsing.get_default_converter") @patch("infra.local_converter._get_default_converter")
@patch("domain.parsing.build_converter") @patch("infra.local_converter._build_docling_converter")
def test_uses_custom_converter_when_generate_images_on(self, mock_build, mock_get_default): def test_uses_custom_converter_when_generate_images_on(self, mock_build, mock_get_default):
mock_conv = MagicMock() mock_conv = MagicMock()
mock_result = MagicMock() mock_result = MagicMock()
@ -240,6 +260,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = "" mock_result.document.export_to_html.return_value = ""
mock_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -247,8 +268,8 @@ class TestConvertDocumentRouting:
mock_build.assert_called_once() mock_build.assert_called_once()
@patch("domain.parsing.get_default_converter") @patch("infra.local_converter._get_default_converter")
@patch("domain.parsing.build_converter") @patch("infra.local_converter._build_docling_converter")
def test_uses_custom_converter_when_images_scale_changed(self, mock_build, mock_get_default): def test_uses_custom_converter_when_images_scale_changed(self, mock_build, mock_get_default):
mock_conv = MagicMock() mock_conv = MagicMock()
mock_result = MagicMock() mock_result = MagicMock()
@ -256,6 +277,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = "" mock_result.document.export_to_html.return_value = ""
mock_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -264,8 +286,8 @@ class TestConvertDocumentRouting:
mock_build.assert_called_once_with(opts) mock_build.assert_called_once_with(opts)
@patch("domain.parsing.get_default_converter") @patch("infra.local_converter._get_default_converter")
@patch("domain.parsing.build_converter") @patch("infra.local_converter._build_docling_converter")
def test_forwards_all_options_to_build_converter(self, mock_build, mock_get_default): def test_forwards_all_options_to_build_converter(self, mock_build, mock_get_default):
mock_conv = MagicMock() mock_conv = MagicMock()
mock_result = MagicMock() mock_result = MagicMock()
@ -273,6 +295,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = "" mock_result.document.export_to_html.return_value = ""
mock_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -297,77 +320,92 @@ class TestConvertDocumentRouting:
# Service layer — pipeline options forwarding # Service layer — pipeline options forwarding
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestServiceForwardsPipelineOptions: class TestServiceForwardsPipelineOptions:
"""Verify analysis_service.create and _run_analysis forward pipeline options.""" """Verify analysis_service.create and _run_analysis forward pipeline options."""
@pytest.fixture @pytest.fixture
def mock_doc(self): def mock_doc(self):
from domain.models import Document from domain.models import Document
return Document(id="d1", filename="test.pdf", storage_path="/tmp/test.pdf") return Document(id="d1", filename="test.pdf", storage_path="/tmp/test.pdf")
@pytest.fixture @pytest.fixture
def mock_job(self): def mock_job(self):
from domain.models import AnalysisJob from domain.models import AnalysisJob
return AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf") return AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
@patch("services.analysis_service.document_repo") @patch("services.analysis_service.document_repo")
@patch("services.analysis_service.analysis_repo") @patch("services.analysis_service.analysis_repo")
@patch("services.analysis_service._run_analysis")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_passes_pipeline_options_to_run( async def test_create_passes_pipeline_options_to_run(
self, mock_run, mock_analysis_repo, mock_doc_repo, mock_doc, self,
mock_analysis_repo,
mock_doc_repo,
mock_doc,
): ):
mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc) mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc)
mock_analysis_repo.insert = AsyncMock() mock_analysis_repo.insert = AsyncMock()
# Patch _run_analysis as a coroutine that we can inspect
mock_run.return_value = None
from services import analysis_service mock_converter = AsyncMock()
from services.analysis_service import AnalysisService
svc = AnalysisService(converter=mock_converter)
opts = {"do_ocr": False, "table_mode": "fast"} opts = {"do_ocr": False, "table_mode": "fast"}
# We need to patch asyncio.create_task to capture the coroutine args
with patch("services.analysis_service.asyncio.create_task") as mock_task: with patch("services.analysis_service.asyncio.create_task") as mock_task:
await analysis_service.create("d1", pipeline_options=opts) await svc.create("d1", pipeline_options=opts)
# create_task should have been called with _run_analysis(...)
mock_task.assert_called_once() mock_task.assert_called_once()
@patch("services.analysis_service.document_repo") @patch("services.analysis_service.document_repo")
@patch("services.analysis_service.analysis_repo") @patch("services.analysis_service.analysis_repo")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_passes_none_when_no_options( async def test_create_passes_none_when_no_options(
self, mock_analysis_repo, mock_doc_repo, mock_doc, self,
mock_analysis_repo,
mock_doc_repo,
mock_doc,
): ):
mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc) mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc)
mock_analysis_repo.insert = AsyncMock() mock_analysis_repo.insert = AsyncMock()
from services import analysis_service mock_converter = AsyncMock()
from services.analysis_service import AnalysisService
svc = AnalysisService(converter=mock_converter)
with patch("services.analysis_service.asyncio.create_task") as mock_task: with patch("services.analysis_service.asyncio.create_task") as mock_task:
await analysis_service.create("d1") await svc.create("d1")
mock_task.assert_called_once() mock_task.assert_called_once()
@patch("services.analysis_service.analysis_repo") @patch("services.analysis_service.analysis_repo")
@patch("services.analysis_service.document_repo") @patch("services.analysis_service.document_repo")
@patch("services.analysis_service.convert_document")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_analysis_forwards_options_to_convert( async def test_run_analysis_forwards_options_to_convert(
self, mock_convert, mock_doc_repo, mock_analysis_repo, mock_job, self,
mock_doc_repo,
mock_analysis_repo,
mock_job,
): ):
from domain.parsing import ConversionResult, PageDetail from domain.value_objects import ConversionResult, PageDetail
mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job) mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
mock_analysis_repo.update_status = AsyncMock() mock_analysis_repo.update_status = AsyncMock()
mock_doc_repo.update_page_count = AsyncMock() mock_doc_repo.update_page_count = AsyncMock()
mock_convert.return_value = ConversionResult(
mock_converter = AsyncMock()
mock_converter.convert.return_value = ConversionResult(
page_count=1, page_count=1,
content_markdown="# Test", content_markdown="# Test",
content_html="<h1>Test</h1>", content_html="<h1>Test</h1>",
pages=[PageDetail(page_number=1, width=612.0, height=792.0)], pages=[PageDetail(page_number=1, width=612.0, height=792.0)],
) )
from services.analysis_service import _run_analysis from services.analysis_service import AnalysisService
svc = AnalysisService(converter=mock_converter)
opts = { opts = {
"do_ocr": False, "do_ocr": False,
@ -381,10 +419,10 @@ class TestServiceForwardsPipelineOptions:
"images_scale": 2.0, "images_scale": 2.0,
} }
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", opts) await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", opts)
mock_convert.assert_called_once() mock_converter.convert.assert_called_once()
call_args = mock_convert.call_args call_args = mock_converter.convert.call_args
assert call_args[0][0] == "/tmp/test.pdf" assert call_args[0][0] == "/tmp/test.pdf"
conv_opts = call_args[0][1] conv_opts = call_args[0][1]
assert conv_opts.do_ocr is False assert conv_opts.do_ocr is False
@ -395,47 +433,58 @@ class TestServiceForwardsPipelineOptions:
@patch("services.analysis_service.analysis_repo") @patch("services.analysis_service.analysis_repo")
@patch("services.analysis_service.document_repo") @patch("services.analysis_service.document_repo")
@patch("services.analysis_service.convert_document")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_analysis_uses_defaults_when_no_options( async def test_run_analysis_uses_defaults_when_no_options(
self, mock_convert, mock_doc_repo, mock_analysis_repo, mock_job, self,
mock_doc_repo,
mock_analysis_repo,
mock_job,
): ):
from domain.parsing import ConversionResult, PageDetail from domain.value_objects import ConversionResult, PageDetail
mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job) mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
mock_analysis_repo.update_status = AsyncMock() mock_analysis_repo.update_status = AsyncMock()
mock_doc_repo.update_page_count = AsyncMock() mock_doc_repo.update_page_count = AsyncMock()
mock_convert.return_value = ConversionResult(
mock_converter = AsyncMock()
mock_converter.convert.return_value = ConversionResult(
page_count=1, page_count=1,
content_markdown="", content_markdown="",
content_html="", content_html="",
pages=[PageDetail(page_number=1, width=612.0, height=792.0)], pages=[PageDetail(page_number=1, width=612.0, height=792.0)],
) )
from services.analysis_service import _run_analysis from services.analysis_service import AnalysisService
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", None) svc = AnalysisService(converter=mock_converter)
# Called with file_path and default ConversionOptions await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", None)
mock_convert.assert_called_once()
call_args = mock_convert.call_args mock_converter.convert.assert_called_once()
call_args = mock_converter.convert.call_args
assert call_args[0][0] == "/tmp/test.pdf" assert call_args[0][0] == "/tmp/test.pdf"
assert call_args[0][1] == ConversionOptions() assert call_args[0][1] == ConversionOptions()
@patch("services.analysis_service.analysis_repo") @patch("services.analysis_service.analysis_repo")
@patch("services.analysis_service.document_repo") @patch("services.analysis_service.document_repo")
@patch("services.analysis_service.convert_document")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_analysis_marks_failed_on_error( async def test_run_analysis_marks_failed_on_error(
self, mock_convert, mock_doc_repo, mock_analysis_repo, mock_job, self,
mock_doc_repo,
mock_analysis_repo,
mock_job,
): ):
mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job) mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
mock_analysis_repo.update_status = AsyncMock() mock_analysis_repo.update_status = AsyncMock()
mock_convert.side_effect = RuntimeError("Docling crashed")
from services.analysis_service import _run_analysis mock_converter = AsyncMock()
mock_converter.convert.side_effect = RuntimeError("Docling crashed")
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", {"do_ocr": False}) from services.analysis_service import AnalysisService
svc = AnalysisService(converter=mock_converter)
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", {"do_ocr": False})
# Should have called update_status twice: RUNNING then FAILED # Should have called update_status twice: RUNNING then FAILED
assert mock_analysis_repo.update_status.call_count == 2 assert mock_analysis_repo.update_status.call_count == 2
@ -448,6 +497,7 @@ class TestServiceForwardsPipelineOptions:
# API endpoint — full request/response with pipeline options # API endpoint — full request/response with pipeline options
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestAnalysisEndpointPipelineOptions: class TestAnalysisEndpointPipelineOptions:
"""Integration-level tests for the analysis creation endpoint with pipeline options.""" """Integration-level tests for the analysis creation endpoint with pipeline options."""
@ -456,28 +506,44 @@ class TestAnalysisEndpointPipelineOptions:
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from main import app from main import app
return TestClient(app, raise_server_exceptions=False) return TestClient(app, raise_server_exceptions=False)
@patch("services.analysis_service.create", new_callable=AsyncMock) @pytest.fixture
def test_no_pipeline_options_sends_none(self, mock_create, client): def mock_svc(self, client):
from unittest.mock import MagicMock
from main import app
mock = MagicMock()
original = getattr(app.state, "analysis_service", None)
app.state.analysis_service = mock
yield mock
app.state.analysis_service = original
def test_no_pipeline_options_sends_none(self, client, mock_svc):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
client.post("/api/analyses", json={"documentId": "d1"}) client.post("/api/analyses", json={"documentId": "d1"})
mock_create.assert_called_once_with("d1", pipeline_options=None) mock_svc.create.assert_called_once_with("d1", pipeline_options=None, chunking_options=None)
@patch("services.analysis_service.create", new_callable=AsyncMock) def test_empty_pipeline_options_object_uses_defaults(self, client, mock_svc):
def test_empty_pipeline_options_object_uses_defaults(self, mock_create, client):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
client.post("/api/analyses", json={ mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
"documentId": "d1",
"pipelineOptions": {},
})
opts = mock_create.call_args.kwargs["pipeline_options"] client.post(
"/api/analyses",
json={
"documentId": "d1",
"pipelineOptions": {},
},
)
opts = mock_svc.create.call_args.kwargs["pipeline_options"]
assert opts["do_ocr"] is True assert opts["do_ocr"] is True
assert opts["do_table_structure"] is True assert opts["do_table_structure"] is True
assert opts["table_mode"] == "accurate" assert opts["table_mode"] == "accurate"
@ -485,20 +551,22 @@ class TestAnalysisEndpointPipelineOptions:
assert opts["do_formula_enrichment"] is False assert opts["do_formula_enrichment"] is False
assert opts["images_scale"] == 1.0 assert opts["images_scale"] == 1.0
@patch("services.analysis_service.create", new_callable=AsyncMock) def test_partial_pipeline_options_merges_with_defaults(self, client, mock_svc):
def test_partial_pipeline_options_merges_with_defaults(self, mock_create, client):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
client.post("/api/analyses", json={ mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
"documentId": "d1",
"pipelineOptions": {"do_ocr": False, "images_scale": 1.5},
})
opts = mock_create.call_args.kwargs["pipeline_options"] client.post(
"/api/analyses",
json={
"documentId": "d1",
"pipelineOptions": {"do_ocr": False, "images_scale": 1.5},
},
)
opts = mock_svc.create.call_args.kwargs["pipeline_options"]
assert opts["do_ocr"] is False assert opts["do_ocr"] is False
assert opts["images_scale"] == 1.5 assert opts["images_scale"] == 1.5
# All other fields should have defaults
assert opts["do_table_structure"] is True assert opts["do_table_structure"] is True
assert opts["table_mode"] == "accurate" assert opts["table_mode"] == "accurate"
assert opts["do_code_enrichment"] is False assert opts["do_code_enrichment"] is False
@ -508,10 +576,10 @@ class TestAnalysisEndpointPipelineOptions:
assert opts["generate_picture_images"] is False assert opts["generate_picture_images"] is False
assert opts["generate_page_images"] is False assert opts["generate_page_images"] is False
@patch("services.analysis_service.create", new_callable=AsyncMock) def test_full_pipeline_options(self, client, mock_svc):
def test_full_pipeline_options(self, mock_create, client):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
payload = { payload = {
"documentId": "d1", "documentId": "d1",
@ -532,25 +600,29 @@ class TestAnalysisEndpointPipelineOptions:
resp = client.post("/api/analyses", json=payload) resp = client.post("/api/analyses", json=payload)
assert resp.status_code == 200 assert resp.status_code == 200
opts = mock_create.call_args.kwargs["pipeline_options"] opts = mock_svc.create.call_args.kwargs["pipeline_options"]
assert opts == payload["pipelineOptions"] assert opts == payload["pipelineOptions"]
@patch("services.analysis_service.create", new_callable=AsyncMock) def test_invalid_pipeline_option_type_rejected(self, client, mock_svc):
def test_invalid_pipeline_option_type_rejected(self, mock_create, client): resp = client.post(
resp = client.post("/api/analyses", json={ "/api/analyses",
"documentId": "d1", json={
"pipelineOptions": {"do_ocr": "not-a-bool"}, "documentId": "d1",
}) "pipelineOptions": {"do_ocr": "not-a-bool"},
},
)
assert resp.status_code == 422 assert resp.status_code == 422
@patch("services.analysis_service.create", new_callable=AsyncMock) def test_unknown_pipeline_option_ignored(self, client, mock_svc):
def test_unknown_pipeline_option_ignored(self, mock_create, client):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
resp = client.post("/api/analyses", json={ mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
"documentId": "d1",
"pipelineOptions": {"do_ocr": True, "unknown_field": True}, resp = client.post(
}) "/api/analyses",
# Pydantic ignores extra fields by default json={
"documentId": "d1",
"pipelineOptions": {"do_ocr": True, "unknown_field": True},
},
)
assert resp.status_code == 200 assert resp.status_code == 200

View file

@ -0,0 +1,93 @@
"""Tests for the in-memory rate limiter middleware."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from infra.rate_limiter import RateLimiterMiddleware, _ClientBucket
class TestClientBucket:
def test_count_recent_filters_old_entries(self):
bucket = _ClientBucket(timestamps=[1.0, 2.0, 3.0, 10.0])
count = bucket.count_recent(window=5.0, now=12.0)
assert count == 1 # only 10.0 is within [7.0, 12.0]
def test_count_recent_keeps_all_when_within_window(self):
bucket = _ClientBucket(timestamps=[10.0, 11.0, 12.0])
count = bucket.count_recent(window=60.0, now=15.0)
assert count == 3
def test_add(self):
bucket = _ClientBucket()
bucket.add(1.0)
bucket.add(2.0)
assert len(bucket.timestamps) == 2
@pytest.fixture
def limited_app():
"""FastAPI app with a very low rate limit for testing."""
app = FastAPI()
app.add_middleware(
RateLimiterMiddleware,
requests_per_window=3,
window_seconds=60,
exclude_paths=("/health",),
)
@app.get("/test")
def test_endpoint():
return {"ok": True}
@app.get("/health")
def health():
return {"status": "ok"}
return app
@pytest.fixture
def client(limited_app):
return TestClient(limited_app)
class TestRateLimiterMiddleware:
def test_allows_requests_under_limit(self, client):
for _ in range(3):
resp = client.get("/test")
assert resp.status_code == 200
def test_blocks_requests_over_limit(self, client):
for _ in range(3):
client.get("/test")
resp = client.get("/test")
assert resp.status_code == 429
assert resp.json()["detail"] == "Too many requests"
assert "Retry-After" in resp.headers
def test_health_excluded_from_limit(self, client):
# Exhaust the limit
for _ in range(3):
client.get("/test")
# Health should still work
resp = client.get("/health")
assert resp.status_code == 200
def test_window_resets(self, client):
"""After the window expires, requests should be allowed again."""
for _ in range(3):
client.get("/test")
assert client.get("/test").status_code == 429
# Simulate time passing beyond the window
with patch("time.monotonic", return_value=1e12):
resp = client.get("/test")
assert resp.status_code == 200

View file

@ -1,6 +1,5 @@
"""Tests for persistence repositories using a temporary SQLite database.""" """Tests for persistence repositories using a temporary SQLite database."""
import pytest import pytest
from domain.models import AnalysisJob, AnalysisStatus, Document from domain.models import AnalysisJob, AnalysisStatus, Document

View file

@ -1,6 +1,5 @@
"""Tests for API schemas — camelCase serialization and validation.""" """Tests for API schemas — camelCase serialization and validation."""
import pytest import pytest
from api.schemas import ( from api.schemas import (
@ -97,7 +96,10 @@ class TestPipelineOptionsRequest:
def test_custom_values(self): def test_custom_values(self):
opts = PipelineOptionsRequest( opts = PipelineOptionsRequest(
do_ocr=False, table_mode="fast", do_code_enrichment=True, images_scale=2.0, do_ocr=False,
table_mode="fast",
do_code_enrichment=True,
images_scale=2.0,
) )
assert opts.do_ocr is False assert opts.do_ocr is False
assert opts.table_mode == "fast" assert opts.table_mode == "fast"

View file

@ -0,0 +1,504 @@
"""Tests for the ServeConverter adapter (Docling Serve HTTP client)."""
from __future__ import annotations
import importlib
import json
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from domain.value_objects import ConversionOptions, ConversionResult
from infra.serve_converter import (
ServeConverter,
_build_form_data,
_extract_bbox,
_parse_response,
)
def _has_docling() -> bool:
"""Return True if the heavy docling library is available."""
return importlib.util.find_spec("docling") is not None
# ---------------------------------------------------------------------------
# Unit tests — form data building
# ---------------------------------------------------------------------------
class TestBuildFormData:
def test_default_options(self):
data = _build_form_data(ConversionOptions())
assert data["do_ocr"] == "true"
assert data["do_table_structure"] == "true"
assert data["table_mode"] == "accurate"
assert data["do_code_enrichment"] == "false"
assert data["do_formula_enrichment"] == "false"
assert data["do_picture_classification"] == "false"
assert data["do_picture_description"] == "false"
assert data["include_images"] == "false"
assert data["generate_page_images"] == "false"
assert data["images_scale"] == "1.0"
assert set(data["to_formats"]) == {"md", "html", "json"}
def test_custom_options(self):
opts = ConversionOptions(
do_ocr=False,
table_mode="fast",
images_scale=2.0,
generate_picture_images=True,
)
data = _build_form_data(opts)
assert data["do_ocr"] == "false"
assert data["table_mode"] == "fast"
assert data["images_scale"] == "2.0"
assert data["include_images"] == "true"
# ---------------------------------------------------------------------------
# Unit tests — response parsing
# ---------------------------------------------------------------------------
class TestParseResponse:
def test_minimal_response(self):
data = {
"document": {
"md_content": "# Hello",
"html_content": "<h1>Hello</h1>",
"json_content": {
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
"texts": [],
"tables": [],
"pictures": [],
},
}
}
result = _parse_response(data)
assert isinstance(result, ConversionResult)
assert result.content_markdown == "# Hello"
assert result.content_html == "<h1>Hello</h1>"
assert result.page_count == 1
assert result.pages[0].width == 612.0
assert result.document_json is not None
def test_response_with_elements(self):
data = {
"document": {
"md_content": "# Title\nText",
"html_content": "<h1>Title</h1><p>Text</p>",
"json_content": {
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
"texts": [
{
"label": "title",
"text": "Title",
"prov": [
{
"page_no": 1,
"bbox": {
"l": 10,
"t": 20,
"r": 200,
"b": 40,
"coord_origin": "TOPLEFT",
},
}
],
},
{
"label": "paragraph",
"text": "Text",
"prov": [
{
"page_no": 1,
"bbox": {
"l": 10,
"t": 50,
"r": 200,
"b": 70,
"coord_origin": "TOPLEFT",
},
}
],
},
],
"tables": [],
"pictures": [],
},
}
}
result = _parse_response(data)
assert len(result.pages[0].elements) == 2
assert result.pages[0].elements[0].type == "title"
assert result.pages[0].elements[0].content == "Title"
assert result.pages[0].elements[0].bbox == [10, 20, 200, 40]
assert result.pages[0].elements[1].type == "text"
def test_multi_page(self):
data = {
"document": {
"md_content": "",
"html_content": "",
"json_content": {
"pages": {
"1": {"size": {"width": 612.0, "height": 792.0}},
"2": {"size": {"width": 595.0, "height": 842.0}},
},
"texts": [],
"tables": [],
"pictures": [],
},
}
}
result = _parse_response(data)
assert result.page_count == 2
assert result.pages[1].width == 595.0
def test_no_json_content(self):
data = {
"document": {
"md_content": "text",
"html_content": "<p>text</p>",
}
}
result = _parse_response(data)
assert result.content_markdown == "text"
assert result.pages == []
assert result.page_count == 1
assert result.document_json is None
def test_json_content_as_string(self):
json_doc = {
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
"texts": [],
"tables": [],
"pictures": [],
}
data = {
"document": {
"md_content": "",
"html_content": "",
"json_content": json.dumps(json_doc),
}
}
result = _parse_response(data)
assert result.page_count == 1
def test_json_content_malformed_string_falls_back(self):
"""Bug #5: malformed JSON string in json_content must not crash."""
data = {
"document": {
"md_content": "# Hello",
"html_content": "<h1>Hello</h1>",
"json_content": "NOT VALID JSON {{{",
}
}
result = _parse_response(data)
assert isinstance(result, ConversionResult)
assert result.content_markdown == "# Hello"
assert result.pages == []
assert result.page_count == 1
def test_tables_and_pictures(self):
data = {
"document": {
"md_content": "",
"html_content": "",
"json_content": {
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
"texts": [],
"tables": [
{
"label": "table",
"text": "",
"prov": [
{
"page_no": 1,
"bbox": {
"l": 10,
"t": 10,
"r": 300,
"b": 200,
"coord_origin": "TOPLEFT",
},
}
],
},
],
"pictures": [
{
"label": "picture",
"text": "",
"prov": [
{
"page_no": 1,
"bbox": {
"l": 50,
"t": 300,
"r": 250,
"b": 500,
"coord_origin": "TOPLEFT",
},
}
],
},
],
},
}
}
result = _parse_response(data)
types = [e.type for e in result.pages[0].elements]
assert "table" in types
assert "picture" in types
# ---------------------------------------------------------------------------
# Unit tests — bbox extraction
# ---------------------------------------------------------------------------
class TestExtractBbox:
def test_topleft_passthrough(self):
bbox = _extract_bbox(
{"l": 10, "t": 20, "r": 100, "b": 50, "coord_origin": "TOPLEFT"}, 792.0
)
assert bbox == [10, 20, 100, 50]
def test_bottomleft_conversion(self):
# In BOTTOMLEFT: t (top of box) has higher y than b (bottom of box)
bbox = _extract_bbox(
{"l": 10, "t": 772, "r": 100, "b": 742, "coord_origin": "BOTTOMLEFT"}, 792.0
)
# new_top = 792 - 772 = 20, new_bottom = 792 - 742 = 50
assert bbox == [10, 20, 100, 50]
def test_missing_coord_origin_defaults_topleft(self):
bbox = _extract_bbox({"l": 10, "t": 20, "r": 100, "b": 50}, 792.0)
assert bbox == [10, 20, 100, 50]
def test_empty_dict(self):
bbox = _extract_bbox({}, 792.0)
assert bbox == [0.0, 0.0, 0.0, 0.0]
def test_non_dict_returns_zeros(self):
bbox = _extract_bbox("invalid", 792.0)
assert bbox == [0.0, 0.0, 0.0, 0.0]
# ---------------------------------------------------------------------------
# Unit tests — label mapping
# ---------------------------------------------------------------------------
class TestLabelMapping:
def test_known_labels(self):
from infra.serve_converter import _LABEL_MAP
assert _LABEL_MAP["table"] == "table"
assert _LABEL_MAP["picture"] == "picture"
assert _LABEL_MAP["figure"] == "picture"
assert _LABEL_MAP["title"] == "title"
assert _LABEL_MAP["section_header"] == "section_header"
assert _LABEL_MAP["list_item"] == "list"
assert _LABEL_MAP["formula"] == "formula"
assert _LABEL_MAP["code"] == "code"
assert _LABEL_MAP["paragraph"] == "text"
def test_unknown_label_defaults_to_text(self):
from infra.serve_converter import _LABEL_MAP
assert _LABEL_MAP.get("unknown_thing", "text") == "text"
# ---------------------------------------------------------------------------
# Unit tests — ServeConverter
# ---------------------------------------------------------------------------
class TestServeConverter:
def test_headers_with_api_key(self):
conv = ServeConverter(base_url="http://localhost:5001", api_key="secret")
assert conv._headers() == {"X-Api-Key": "secret"}
def test_headers_without_api_key(self):
conv = ServeConverter(base_url="http://localhost:5001")
assert conv._headers() == {}
def test_base_url_trailing_slash_stripped(self):
conv = ServeConverter(base_url="http://localhost:5001/")
assert conv._base_url == "http://localhost:5001"
# ---------------------------------------------------------------------------
# Integration tests — HTTP calls (mocked)
# ---------------------------------------------------------------------------
class TestServeConverterConvert:
@pytest.mark.asyncio
async def test_successful_conversion(self, tmp_path):
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"%PDF-1.4 fake content")
serve_response = {
"document": {
"md_content": "# Converted",
"html_content": "<h1>Converted</h1>",
"json_content": {
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
"texts": [
{
"label": "title",
"text": "Converted",
"prov": [
{
"page_no": 1,
"bbox": {
"l": 10,
"t": 20,
"r": 200,
"b": 40,
"coord_origin": "TOPLEFT",
},
}
],
},
],
"tables": [],
"pictures": [],
},
}
}
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = serve_response
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
conv = ServeConverter(base_url="http://localhost:5001", api_key="test-key")
with patch("infra.serve_converter.httpx.AsyncClient", return_value=mock_client):
result = await conv.convert(str(test_file), ConversionOptions())
assert isinstance(result, ConversionResult)
assert result.content_markdown == "# Converted"
assert result.page_count == 1
assert len(result.pages[0].elements) == 1
assert result.pages[0].elements[0].type == "title"
# Verify form fields sent as dict with list for repeated keys
call_kwargs = mock_client.post.call_args
sent_data = call_kwargs.kwargs.get("data", {})
assert sent_data["do_ocr"] == "true"
assert set(sent_data["to_formats"]) == {"md", "html", "json"}
@pytest.mark.asyncio
async def test_http_error_raises(self, tmp_path):
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"%PDF-1.4 fake content")
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Server Error",
request=MagicMock(),
response=MagicMock(status_code=500),
)
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
conv = ServeConverter(base_url="http://localhost:5001")
with (
patch("infra.serve_converter.httpx.AsyncClient", return_value=mock_client),
pytest.raises(httpx.HTTPStatusError),
):
await conv.convert(str(test_file), ConversionOptions())
@pytest.mark.asyncio
async def test_health_check_success(self):
mock_response = MagicMock()
mock_response.status_code = 200
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
conv = ServeConverter(base_url="http://localhost:5001")
with patch("infra.serve_converter.httpx.AsyncClient", return_value=mock_client):
assert await conv.health_check() is True
@pytest.mark.asyncio
async def test_health_check_failure(self):
mock_client = AsyncMock()
mock_client.get.side_effect = httpx.ConnectError("Connection refused")
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
conv = ServeConverter(base_url="http://localhost:5001")
with patch("infra.serve_converter.httpx.AsyncClient", return_value=mock_client):
assert await conv.health_check() is False
# ---------------------------------------------------------------------------
# Integration — converter wiring in main.py
# ---------------------------------------------------------------------------
class TestConverterWiring:
@pytest.mark.skipif(
not _has_docling(),
reason="docling library not installed",
)
def test_local_engine_builds_local_converter(self):
from infra.local_converter import LocalConverter
from infra.settings import Settings
with patch("main.settings", Settings(conversion_engine="local")):
from main import _build_converter
converter = _build_converter()
assert isinstance(converter, LocalConverter)
def test_remote_engine_builds_serve_converter(self):
from infra.settings import Settings
with patch(
"main.settings",
Settings(conversion_engine="remote", docling_serve_url="http://serve:5001"),
):
from main import _build_converter
converter = _build_converter()
assert isinstance(converter, ServeConverter)
assert converter._base_url == "http://serve:5001"
def test_remote_engine_passes_api_key(self):
from infra.settings import Settings
with patch(
"main.settings",
Settings(
conversion_engine="remote",
docling_serve_url="http://serve:5001",
docling_serve_api_key="my-key",
),
):
from main import _build_converter
converter = _build_converter()
assert isinstance(converter, ServeConverter)
assert converter._api_key == "my-key"

View file

@ -0,0 +1,79 @@
"""Tests for Settings — environment variable parsing and defaults."""
from __future__ import annotations
from infra.settings import Settings
class TestSettingsDefaults:
def test_default_values(self):
s = Settings()
assert s.app_version == "dev"
assert s.conversion_engine == "local"
assert s.deployment_mode == "self-hosted"
assert s.docling_serve_url == "http://localhost:5001"
assert s.docling_serve_api_key is None
assert s.conversion_timeout == 600
assert s.upload_dir == "./uploads"
assert s.db_path == "./data/docling_studio.db"
assert "http://localhost:3000" in s.cors_origins
def test_frozen(self):
"""Settings should be immutable."""
import pytest
s = Settings()
with pytest.raises(AttributeError):
s.upload_dir = "/other" # type: ignore[misc]
class TestSettingsFromEnv:
def test_reads_env_vars(self, monkeypatch):
monkeypatch.setenv("APP_VERSION", "1.2.3")
monkeypatch.setenv("CONVERSION_ENGINE", "remote")
monkeypatch.setenv("DEPLOYMENT_MODE", "huggingface")
monkeypatch.setenv("DOCLING_SERVE_URL", "http://serve:9000")
monkeypatch.setenv("DOCLING_SERVE_API_KEY", "secret-key")
monkeypatch.setenv("CONVERSION_TIMEOUT", "120")
monkeypatch.setenv("UPLOAD_DIR", "/data/uploads")
monkeypatch.setenv("DB_PATH", "/data/test.db")
monkeypatch.setenv("CORS_ORIGINS", "http://a.com, http://b.com")
s = Settings.from_env()
assert s.app_version == "1.2.3"
assert s.conversion_engine == "remote"
assert s.deployment_mode == "huggingface"
assert s.docling_serve_url == "http://serve:9000"
assert s.docling_serve_api_key == "secret-key"
assert s.conversion_timeout == 120
assert s.upload_dir == "/data/uploads"
assert s.db_path == "/data/test.db"
assert s.cors_origins == ["http://a.com", "http://b.com"]
def test_defaults_when_env_empty(self, monkeypatch):
"""When no env vars set, from_env returns sensible defaults."""
for key in (
"APP_VERSION",
"CONVERSION_ENGINE",
"DEPLOYMENT_MODE",
"DOCLING_SERVE_URL",
"DOCLING_SERVE_API_KEY",
"CONVERSION_TIMEOUT",
"UPLOAD_DIR",
"DB_PATH",
"CORS_ORIGINS",
):
monkeypatch.delenv(key, raising=False)
s = Settings.from_env()
assert s.app_version == "dev"
assert s.conversion_engine == "local"
assert s.conversion_timeout == 600
def test_cors_origins_split(self, monkeypatch):
monkeypatch.setenv("CORS_ORIGINS", "http://a.com,http://b.com,http://c.com")
s = Settings.from_env()
assert len(s.cors_origins) == 3
assert s.cors_origins[2] == "http://c.com"

2
frontend/env.d.ts vendored
View file

@ -1,5 +1,7 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare const __APP_VERSION__: string
declare module '*.vue' { declare module '*.vue' {
import type { DefineComponent } from 'vue' import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown> const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>

View file

@ -9,6 +9,9 @@ export default [
{ {
files: ['src/**/*.{ts,js,vue}'], files: ['src/**/*.{ts,js,vue}'],
languageOptions: { languageOptions: {
globals: {
__APP_VERSION__: 'readonly',
},
parserOptions: { parserOptions: {
parser: tseslint.parser, parser: tseslint.parser,
}, },

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Docling Studio</title> <title>Docling Studio</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/png" href="/logo.png">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -4,6 +4,12 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }

View file

@ -1,14 +1,13 @@
{ {
"name": "docling-studio", "name": "docling-studio",
"version": "0.1.0", "version": "0.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "docling-studio", "name": "docling-studio",
"version": "0.1.0", "version": "0.3.0",
"dependencies": { "dependencies": {
"@vitest/mocker": "^4.1.2",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"marked": "^17.0.4", "marked": "^17.0.4",
"pinia": "^2.3.0", "pinia": "^2.3.0",
@ -19,6 +18,7 @@
"@eslint/js": "^9.0.0", "@eslint/js": "^9.0.0",
"@types/dompurify": "^3.2.0", "@types/dompurify": "^3.2.0",
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5",
"@vitest/mocker": "^4.1.2",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-plugin-vue": "^9.32.0", "eslint-plugin-vue": "^9.32.0",
"prettier": "^3.4.0", "prettier": "^3.4.0",
@ -78,6 +78,7 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"aix" "aix"
@ -93,6 +94,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@ -108,6 +110,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@ -123,6 +126,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@ -138,6 +142,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@ -153,6 +158,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@ -168,6 +174,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
@ -183,6 +190,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
@ -198,6 +206,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -213,6 +222,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -228,6 +238,7 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -243,6 +254,7 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -258,6 +270,7 @@
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -273,6 +286,7 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -288,6 +302,7 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -303,6 +318,7 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -318,6 +334,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -333,6 +350,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"netbsd" "netbsd"
@ -348,6 +366,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"netbsd" "netbsd"
@ -363,6 +382,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"openbsd" "openbsd"
@ -378,6 +398,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"openbsd" "openbsd"
@ -393,6 +414,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"openharmony" "openharmony"
@ -408,6 +430,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"sunos" "sunos"
@ -423,6 +446,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@ -438,6 +462,7 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@ -453,6 +478,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@ -661,6 +687,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@ -673,6 +700,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@ -685,6 +713,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@ -697,6 +726,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@ -709,6 +739,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
@ -721,6 +752,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
@ -733,6 +765,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -745,6 +778,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -757,6 +791,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -769,6 +804,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -781,6 +817,7 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -793,6 +830,7 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -805,6 +843,7 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -817,6 +856,7 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -829,6 +869,7 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -841,6 +882,7 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -853,6 +895,7 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -865,6 +908,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -877,6 +921,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@ -889,6 +934,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"openbsd" "openbsd"
@ -901,6 +947,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"openharmony" "openharmony"
@ -913,6 +960,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@ -925,6 +973,7 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@ -937,6 +986,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@ -949,6 +999,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@ -989,7 +1040,8 @@
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true
}, },
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
@ -1307,6 +1359,7 @@
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
"integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
"dev": true,
"dependencies": { "dependencies": {
"@vitest/spy": "4.1.2", "@vitest/spy": "4.1.2",
"estree-walker": "^3.0.3", "estree-walker": "^3.0.3",
@ -1372,6 +1425,7 @@
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
"integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
"dev": true,
"funding": { "funding": {
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
@ -1828,7 +1882,7 @@
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"devOptional": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
@ -2055,6 +2109,7 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"dependencies": { "dependencies": {
"@types/estree": "^1.0.0" "@types/estree": "^1.0.0"
} }
@ -2099,7 +2154,7 @@
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"devOptional": true, "dev": true,
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
}, },
@ -2163,6 +2218,7 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"optional": true, "optional": true,
"os": [ "os": [
@ -2540,7 +2596,7 @@
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"devOptional": true, "dev": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -2655,7 +2711,7 @@
"version": "4.59.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"devOptional": true, "dev": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@ -2797,7 +2853,7 @@
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"devOptional": true, "dev": true,
"dependencies": { "dependencies": {
"fdir": "^6.5.0", "fdir": "^6.5.0",
"picomatch": "^4.0.3" "picomatch": "^4.0.3"
@ -2909,7 +2965,7 @@
"version": "6.4.1", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"devOptional": true, "dev": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",

View file

@ -1,6 +1,6 @@
{ {
"name": "docling-studio", "name": "docling-studio",
"version": "0.1.0", "version": "0.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -16,7 +16,6 @@
"format:check": "prettier --check src/" "format:check": "prettier --check src/"
}, },
"dependencies": { "dependencies": {
"@vitest/mocker": "^4.1.2",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"marked": "^17.0.4", "marked": "^17.0.4",
"pinia": "^2.3.0", "pinia": "^2.3.0",
@ -25,6 +24,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.0.0", "@eslint/js": "^9.0.0",
"@vitest/mocker": "^4.1.2",
"@types/dompurify": "^3.2.0", "@types/dompurify": "^3.2.0",
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5",
"eslint": "^9.0.0", "eslint": "^9.0.0",

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 KiB

View file

@ -1,22 +1,41 @@
<template> <template>
<div class="app-layout"> <div class="app-layout">
<header class="topbar"> <header class="topbar">
<button class="burger-btn" @click="sidebarOpen = !sidebarOpen" :title="sidebarOpen ? t('nav.collapse') : t('nav.expand')"> <button
class="burger-btn"
@click="sidebarOpen = !sidebarOpen"
:title="sidebarOpen ? t('nav.collapse') : t('nav.expand')"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="burger-icon"> <svg viewBox="0 0 20 20" fill="currentColor" class="burger-icon">
<path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/> <path
fill-rule="evenodd"
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
clip-rule="evenodd"
/>
</svg> </svg>
</button> </button>
<div class="topbar-logo"> <div class="topbar-logo">
<span class="topbar-logo-icon">D</span> <img src="/logo.png" alt="Docling Studio" class="topbar-logo-icon" />
<span class="topbar-logo-text">Docling Studio</span> <span class="topbar-logo-text">Docling Studio</span>
</div> </div>
<div class="topbar-spacer" /> <div class="topbar-spacer" />
<button class="new-analysis-btn" @click="newAnalysis"> <button class="new-analysis-btn" @click="newAnalysis">
<svg viewBox="0 0 20 20" fill="currentColor" class="new-analysis-icon"><path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"/></svg> <svg viewBox="0 0 20 20" fill="currentColor" class="new-analysis-icon">
<path
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
/>
</svg>
{{ t('topbar.newAnalysis') }} {{ t('topbar.newAnalysis') }}
</button> </button>
</header> </header>
<div v-if="showDisclaimer" class="disclaimer-banner" role="alert">
{{ t('disclaimer.banner') }}
<button class="disclaimer-close" @click="dismissDisclaimer" aria-label="Close">
&times;
</button>
</div>
<div class="app-body"> <div class="app-body">
<AppSidebar :open="sidebarOpen" /> <AppSidebar :open="sidebarOpen" />
<main class="main"> <main class="main">
@ -27,11 +46,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed } from 'vue'
import { RouterView, useRouter } from 'vue-router' import { RouterView, useRouter } from 'vue-router'
import { AppSidebar } from '../shared/ui/index' import { AppSidebar } from '../shared/ui/index'
import { useSettingsStore } from '../features/settings/store' import { useSettingsStore } from '../features/settings/store'
import { useDocumentStore } from '../features/document/store' import { useDocumentStore } from '../features/document/store'
import { useFeatureFlag } from '../features/feature-flags'
import { useI18n } from '../shared/i18n' import { useI18n } from '../shared/i18n'
useSettingsStore() useSettingsStore()
@ -40,6 +60,13 @@ const router = useRouter()
const documentStore = useDocumentStore() const documentStore = useDocumentStore()
const sidebarOpen = ref(true) const sidebarOpen = ref(true)
const disclaimerEnabled = useFeatureFlag('disclaimer')
const disclaimerDismissed = ref(false)
const showDisclaimer = computed(() => disclaimerEnabled.value && !disclaimerDismissed.value)
function dismissDisclaimer() {
disclaimerDismissed.value = true
}
function newAnalysis() { function newAnalysis() {
documentStore.selectedId = null documentStore.selectedId = null
@ -52,26 +79,26 @@ function newAnalysis() {
:root { :root {
/* Dark mode palette - MistralAI Studio inspired */ /* Dark mode palette - MistralAI Studio inspired */
--bg: #0A0A0B; --bg: #0a0a0b;
--bg-surface: #111113; --bg-surface: #111113;
--bg-elevated: #1A1A1D; --bg-elevated: #1a1a1d;
--bg-hover: #222226; --bg-hover: #222226;
--accent: #F97316; --accent: #f97316;
--accent-hover: #FB923C; --accent-hover: #fb923c;
--accent-muted: rgba(249, 115, 22, 0.15); --accent-muted: rgba(249, 115, 22, 0.15);
--text: #ECECEF; --text: #ececef;
--text-secondary: #A1A1AA; --text-secondary: #a1a1aa;
--text-muted: #63636E; --text-muted: #63636e;
--border: #27272A; --border: #27272a;
--border-light: #3F3F46; --border-light: #3f3f46;
--success: #22C55E; --success: #22c55e;
--error: #EF4444; --error: #ef4444;
--warning: #EAB308; --warning: #eab308;
--info: #3B82F6; --info: #3b82f6;
--radius: 8px; --radius: 8px;
--radius-sm: 6px; --radius-sm: 6px;
@ -83,32 +110,40 @@ function newAnalysis() {
} }
html.light { html.light {
--bg: #FAFAFA; --bg: #fafafa;
--bg-surface: #FFFFFF; --bg-surface: #ffffff;
--bg-elevated: #F4F4F5; --bg-elevated: #f4f4f5;
--bg-hover: #E4E4E7; --bg-hover: #e4e4e7;
--accent: #F97316; --accent: #f97316;
--accent-hover: #EA580C; --accent-hover: #ea580c;
--accent-muted: rgba(249, 115, 22, 0.10); --accent-muted: rgba(249, 115, 22, 0.1);
--text: #18181B; --text: #18181b;
--text-secondary: #52525B; --text-secondary: #52525b;
--text-muted: #A1A1AA; --text-muted: #a1a1aa;
--border: #E4E4E7; --border: #e4e4e7;
--border-light: #D4D4D8; --border-light: #d4d4d8;
--success: #16A34A; --success: #16a34a;
--error: #DC2626; --error: #dc2626;
--warning: #CA8A04; --warning: #ca8a04;
--info: #2563EB; --info: #2563eb;
} }
* { margin: 0; padding: 0; box-sizing: border-box; } * {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body { body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
sans-serif;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
line-height: 1.5; line-height: 1.5;
@ -123,6 +158,36 @@ body {
height: 100vh; height: 100vh;
} }
.disclaimer-banner {
background: #f59e0b;
color: #1a1a1d;
font-size: 13px;
font-weight: 500;
text-align: center;
padding: 8px 40px 8px 16px;
position: relative;
flex-shrink: 0;
}
.disclaimer-close {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #1a1a1d;
font-size: 18px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
opacity: 0.7;
}
.disclaimer-close:hover {
opacity: 1;
}
.topbar { .topbar {
height: var(--topbar-height); height: var(--topbar-height);
min-height: var(--topbar-height); min-height: var(--topbar-height);
@ -167,16 +232,10 @@ body {
} }
.topbar-logo-icon { .topbar-logo-icon {
width: 26px; width: 28px;
height: 26px; height: 28px;
background: var(--accent); border-radius: 6px;
color: white; object-fit: contain;
display: flex;
align-items: center;
justify-content: center;
border-radius: 5px;
font-weight: 700;
font-size: 13px;
} }
.topbar-logo-text { .topbar-logo-text {

View file

@ -1,9 +1,14 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { router } from './router' import { router } from './router'
import { useFeatureFlagStore } from '../features/feature-flags'
import App from './App.vue' import App from './App.vue'
const app = createApp(App) const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
const featureFlags = useFeatureFlagStore()
featureFlags.load()
app.mount('#app') app.mount('#app')

View file

@ -1,20 +1,31 @@
import type { Analysis, PipelineOptions } from '../../shared/types' import type { Analysis, Chunk, ChunkingOptions, PipelineOptions } from '../../shared/types'
import { apiFetch } from '../../shared/api/http' import { apiFetch } from '../../shared/api/http'
export function createAnalysis( export function createAnalysis(
documentId: string, documentId: string,
pipelineOptions: PipelineOptions | null = null, pipelineOptions: PipelineOptions | null = null,
chunkingOptions: ChunkingOptions | null = null,
): Promise<Analysis> { ): Promise<Analysis> {
const body: Record<string, unknown> = { documentId } const body: Record<string, unknown> = { documentId }
if (pipelineOptions) { if (pipelineOptions) {
body.pipelineOptions = pipelineOptions body.pipelineOptions = pipelineOptions
} }
if (chunkingOptions) {
body.chunkingOptions = chunkingOptions
}
return apiFetch<Analysis>('/api/analyses', { return apiFetch<Analysis>('/api/analyses', {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} }
export function rechunkAnalysis(jobId: string, chunkingOptions: ChunkingOptions): Promise<Chunk[]> {
return apiFetch<Chunk[]>(`/api/analyses/${jobId}/rechunk`, {
method: 'POST',
body: JSON.stringify({ chunkingOptions }),
})
}
export function fetchAnalyses(): Promise<Analysis[]> { export function fetchAnalyses(): Promise<Analysis[]> {
return apiFetch<Analysis[]>('/api/analyses') return apiFetch<Analysis[]>('/api/analyses')
} }

View file

@ -19,9 +19,10 @@ describe('computeScale', () => {
}) })
it('sx equals sy when aspect ratio is preserved', () => { it('sx equals sy when aspect ratio is preserved', () => {
const pageW = 612, pageH = 792 const pageW = 612,
pageH = 792
const displayW = 700 const displayW = 700
const displayH = displayW * pageH / pageW const displayH = (displayW * pageH) / pageW
const s = computeScale(displayW, displayH, pageW, pageH) const s = computeScale(displayW, displayH, pageW, pageH)
expect(s.sx).toBeCloseTo(s.sy, 5) expect(s.sx).toBeCloseTo(s.sy, 5)
}) })
@ -165,17 +166,17 @@ describe('pointInRect', () => {
}) })
it('returns true for point on edge', () => { it('returns true for point on edge', () => {
expect(pointInRect(10, 20, rect)).toBe(true) // top-left expect(pointInRect(10, 20, rect)).toBe(true) // top-left
expect(pointInRect(110, 80, rect)).toBe(true) // bottom-right expect(pointInRect(110, 80, rect)).toBe(true) // bottom-right
expect(pointInRect(10, 80, rect)).toBe(true) // bottom-left expect(pointInRect(10, 80, rect)).toBe(true) // bottom-left
expect(pointInRect(110, 20, rect)).toBe(true) // top-right expect(pointInRect(110, 20, rect)).toBe(true) // top-right
}) })
it('returns false for point outside', () => { it('returns false for point outside', () => {
expect(pointInRect(5, 50, rect)).toBe(false) // left expect(pointInRect(5, 50, rect)).toBe(false) // left
expect(pointInRect(50, 15, rect)).toBe(false) // above expect(pointInRect(50, 15, rect)).toBe(false) // above
expect(pointInRect(115, 50, rect)).toBe(false) // right expect(pointInRect(115, 50, rect)).toBe(false) // right
expect(pointInRect(50, 85, rect)).toBe(false) // below expect(pointInRect(50, 85, rect)).toBe(false) // below
}) })
it('returns false for EMPTY_RECT (degenerate bbox not hoverable)', () => { it('returns false for EMPTY_RECT (degenerate bbox not hoverable)', () => {
@ -203,8 +204,10 @@ describe('pointInRect', () => {
describe('integration: bbox pipeline', () => { describe('integration: bbox pipeline', () => {
it('full A4 page: element is clickable at correct position', () => { it('full A4 page: element is clickable at correct position', () => {
// Simulate: A4 page rendered at 700px wide // Simulate: A4 page rendered at 700px wide
const pageW = 595.28, pageH = 841.89 const pageW = 595.28,
const displayW = 700, displayH = 700 * pageH / pageW pageH = 841.89
const displayW = 700,
displayH = (700 * pageH) / pageW
const scale = computeScale(displayW, displayH, pageW, pageH) const scale = computeScale(displayW, displayH, pageW, pageH)
// Element at center of page in PDF points // Element at center of page in PDF points
@ -212,8 +215,8 @@ describe('integration: bbox pipeline', () => {
const rect = bboxToRect(bbox, scale) const rect = bboxToRect(bbox, scale)
// Center of the element in display pixels // Center of the element in display pixels
const cx = (200 + 400) / 2 * scale.sx const cx = ((200 + 400) / 2) * scale.sx
const cy = (350 + 500) / 2 * scale.sy const cy = ((350 + 500) / 2) * scale.sy
expect(pointInRect(cx, cy, rect)).toBe(true) expect(pointInRect(cx, cy, rect)).toBe(true)
}) })

View file

@ -20,7 +20,6 @@ import * as api from './api'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('createAnalysis — pipeline options body construction', () => { describe('createAnalysis — pipeline options body construction', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
// Reset the real module to get the unmocked version // Reset the real module to get the unmocked version
@ -107,7 +106,6 @@ describe('createAnalysis — pipeline options body construction', () => {
}) })
}) })
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Store → API integration — pipeline options forwarding // Store → API integration — pipeline options forwarding
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -131,7 +129,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => {
const store = useAnalysisStore() const store = useAnalysisStore()
await store.run('d1') await store.run('d1')
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null) expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, null)
store.stopPolling() store.stopPolling()
}) })
@ -155,7 +153,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => {
} }
await store.run('d1', opts) await store.run('d1', opts)
expect(api.createAnalysis).toHaveBeenCalledWith('d1', opts) expect(api.createAnalysis).toHaveBeenCalledWith('d1', opts, null)
store.stopPolling() store.stopPolling()
}) })
@ -168,7 +166,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => {
const opts = { do_ocr: false } const opts = { do_ocr: false }
await store.run('d1', opts) await store.run('d1', opts)
expect(api.createAnalysis).toHaveBeenCalledWith('d1', { do_ocr: false }) expect(api.createAnalysis).toHaveBeenCalledWith('d1', { do_ocr: false }, null)
store.stopPolling() store.stopPolling()
}) })

View file

@ -70,7 +70,7 @@ describe('useAnalysisStore', () => {
expect(store.currentAnalysis).toEqual(job) expect(store.currentAnalysis).toEqual(job)
expect(store.analyses[0]).toEqual(job) expect(store.analyses[0]).toEqual(job)
expect(store.running).toBe(true) expect(store.running).toBe(true)
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null) expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, null)
// Advance timer to trigger polling // Advance timer to trigger polling
await vi.advanceTimersByTimeAsync(2000) await vi.advanceTimersByTimeAsync(2000)
@ -90,7 +90,7 @@ describe('useAnalysisStore', () => {
const options = { do_ocr: false, table_mode: 'fast' } const options = { do_ocr: false, table_mode: 'fast' }
await store.run('d1', options) await store.run('d1', options)
expect(api.createAnalysis).toHaveBeenCalledWith('d1', options) expect(api.createAnalysis).toHaveBeenCalledWith('d1', options, null)
store.stopPolling() store.stopPolling()
}) })

View file

@ -1,6 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { Analysis, Page, PipelineOptions } from '../../shared/types' import type { Analysis, Chunk, ChunkingOptions, Page, PipelineOptions } from '../../shared/types'
import * as api from './api' import * as api from './api'
export const useAnalysisStore = defineStore('analysis', () => { export const useAnalysisStore = defineStore('analysis', () => {
@ -9,6 +9,8 @@ export const useAnalysisStore = defineStore('analysis', () => {
const running = ref(false) const running = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null) const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null)
const pollingTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
const MAX_POLLING_DURATION = 10 * 60 * 1000 // 10 minutes
const currentPages = computed<Page[]>(() => { const currentPages = computed<Page[]>(() => {
if (!currentAnalysis.value?.pagesJson) return [] if (!currentAnalysis.value?.pagesJson) return []
@ -33,14 +35,44 @@ export const useAnalysisStore = defineStore('analysis', () => {
} }
} }
const currentChunks = computed<Chunk[]>(() => {
if (!currentAnalysis.value?.chunksJson) return []
try {
return JSON.parse(currentAnalysis.value.chunksJson) as Chunk[]
} catch {
return []
}
})
const rechunking = ref(false)
async function rechunk(jobId: string, chunkingOptions: ChunkingOptions): Promise<Chunk[]> {
rechunking.value = true
error.value = null
try {
const chunks = await api.rechunkAnalysis(jobId, chunkingOptions)
if (currentAnalysis.value?.id === jobId) {
currentAnalysis.value = await api.fetchAnalysis(jobId)
}
return chunks
} catch (e) {
error.value = (e as Error).message || 'Failed to rechunk'
console.error('Failed to rechunk', e)
throw e
} finally {
rechunking.value = false
}
}
async function run( async function run(
documentId: string, documentId: string,
pipelineOptions: PipelineOptions | null = null, pipelineOptions: PipelineOptions | null = null,
chunkingOptions: ChunkingOptions | null = null,
): Promise<Analysis> { ): Promise<Analysis> {
running.value = true running.value = true
error.value = null error.value = null
try { try {
const analysis = await api.createAnalysis(documentId, pipelineOptions) const analysis = await api.createAnalysis(documentId, pipelineOptions, chunkingOptions)
currentAnalysis.value = analysis currentAnalysis.value = analysis
analyses.value.unshift(analysis) analyses.value.unshift(analysis)
startPolling(analysis.id) startPolling(analysis.id)
@ -72,6 +104,13 @@ export const useAnalysisStore = defineStore('analysis', () => {
running.value = false running.value = false
} }
}, 2000) }, 2000)
pollingTimeout.value = setTimeout(() => {
if (pollingInterval.value) {
error.value = 'Analysis timed out'
stopPolling()
running.value = false
}
}, MAX_POLLING_DURATION)
} }
function stopPolling(): void { function stopPolling(): void {
@ -79,6 +118,10 @@ export const useAnalysisStore = defineStore('analysis', () => {
clearInterval(pollingInterval.value) clearInterval(pollingInterval.value)
pollingInterval.value = null pollingInterval.value = null
} }
if (pollingTimeout.value) {
clearTimeout(pollingTimeout.value)
pollingTimeout.value = null
}
} }
async function select(id: string): Promise<void> { async function select(id: string): Promise<void> {
@ -105,11 +148,14 @@ export const useAnalysisStore = defineStore('analysis', () => {
analyses, analyses,
currentAnalysis, currentAnalysis,
currentPages, currentPages,
currentChunks,
running, running,
rechunking,
error, error,
clearError, clearError,
load, load,
run, run,
rechunk,
select, select,
remove, remove,
stopPolling, stopPolling,

View file

@ -20,14 +20,14 @@
</div> </div>
<div class="panel-actions" v-if="selectedDoc"> <div class="panel-actions" v-if="selectedDoc">
<button <button class="btn-analyze" :disabled="analysisStore.running" @click="runAnalysis">
class="btn-analyze"
:disabled="analysisStore.running"
@click="runAnalysis"
>
<div v-if="analysisStore.running" class="spinner" /> <div v-if="analysisStore.running" class="spinner" />
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="btn-icon"> <svg v-else viewBox="0 0 20 20" fill="currentColor" class="btn-icon">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/> <path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z"
clip-rule="evenodd"
/>
</svg> </svg>
{{ analysisStore.running ? 'Analyzing...' : 'Analyze' }} {{ analysisStore.running ? 'Analyzing...' : 'Analyze' }}
</button> </button>
@ -55,7 +55,7 @@ const analysisStore = useAnalysisStore()
const currentPage = ref(1) const currentPage = ref(1)
const selectedDoc = computed(() => { const selectedDoc = computed(() => {
return documentStore.documents.find(d => d.id === documentStore.selectedId) return documentStore.documents.find((d) => d.id === documentStore.selectedId)
}) })
const statusClass = computed(() => { const statusClass = computed(() => {
@ -64,7 +64,7 @@ const statusClass = computed(() => {
'status-pending': status === 'PENDING', 'status-pending': status === 'PENDING',
'status-running': status === 'RUNNING', 'status-running': status === 'RUNNING',
'status-completed': status === 'COMPLETED', 'status-completed': status === 'COMPLETED',
'status-failed': status === 'FAILED' 'status-failed': status === 'FAILED',
} }
}) })
@ -115,20 +115,32 @@ async function runAnalysis() {
transition: background var(--transition); transition: background var(--transition);
} }
.btn-analyze:hover:not(:disabled) { background: var(--accent-hover); } .btn-analyze:hover:not(:disabled) {
.btn-analyze:disabled { opacity: 0.6; cursor: not-allowed; } background: var(--accent-hover);
.btn-icon { width: 18px; height: 18px; } }
.btn-analyze:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-icon {
width: 18px;
height: 18px;
}
.spinner { .spinner {
width: 18px; width: 18px;
height: 18px; height: 18px;
border: 2px solid rgba(255,255,255,0.3); border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white; border-top-color: white;
border-radius: 50%; border-radius: 50%;
animation: spin 0.6s linear infinite; animation: spin 0.6s linear infinite;
} }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin {
to {
transform: rotate(360deg);
}
}
.status-row { .status-row {
display: flex; display: flex;
@ -143,8 +155,20 @@ async function runAnalysis() {
border-radius: 12px; border-radius: 12px;
} }
.status-pending { background: rgba(234, 179, 8, 0.15); color: var(--warning); } .status-pending {
.status-running { background: rgba(59, 130, 246, 0.15); color: var(--info); } background: rgba(234, 179, 8, 0.15);
.status-completed { background: rgba(34, 197, 94, 0.15); color: var(--success); } color: var(--warning);
.status-failed { background: rgba(239, 68, 68, 0.15); color: var(--error); } }
.status-running {
background: rgba(59, 130, 246, 0.15);
color: var(--info);
}
.status-completed {
background: rgba(34, 197, 94, 0.15);
color: var(--success);
}
.status-failed {
background: rgba(239, 68, 68, 0.15);
color: var(--error);
}
</style> </style>

View file

@ -20,14 +20,13 @@
ref="canvasRef" ref="canvasRef"
class="overlay-canvas" class="overlay-canvas"
@mousemove="onMouseMove" @mousemove="onMouseMove"
@mouseleave="hoveredElement = null; emit('highlight-element', -1)" @mouseleave="onMouseLeave"
/> />
<div <div v-if="hoveredElement" class="tooltip" :style="tooltipStyle">
v-if="hoveredElement" <span
class="tooltip" class="tooltip-type"
:style="tooltipStyle" :style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }"
> >
<span class="tooltip-type" :style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }">
{{ hoveredElement.type }} {{ hoveredElement.type }}
</span> </span>
<span class="tooltip-content">{{ hoveredElement.content?.substring(0, 150) }}</span> <span class="tooltip-content">{{ hoveredElement.content?.substring(0, 150) }}</span>
@ -38,7 +37,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick, reactive, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, watch, nextTick, reactive, onMounted, onBeforeUnmount } from 'vue'
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling' import { computeScale, bboxToRect, pointInRect } from '../bboxScaling'
import type { Page, PageElement } from '../../../shared/types' import type { Page, PageElement, ChunkBbox } from '../../../shared/types'
const ELEMENT_COLORS: Record<string, string> = { const ELEMENT_COLORS: Record<string, string> = {
title: '#EF4444', title: '#EF4444',
@ -49,14 +48,18 @@ const ELEMENT_COLORS: Record<string, string> = {
list: '#06B6D4', list: '#06B6D4',
formula: '#EC4899', formula: '#EC4899',
code: '#14B8A6', code: '#14B8A6',
caption: '#EAB308' caption: '#EAB308',
} }
const props = defineProps<{ const props = withDefaults(
imageEl: HTMLImageElement | null defineProps<{
pageData: Page | null imageEl: HTMLImageElement | null
highlightedIndex: number pageData: Page | null
}>() highlightedIndex: number
highlightedBboxes: ChunkBbox[]
}>(),
{ highlightedBboxes: () => [] },
)
const emit = defineEmits<{ const emit = defineEmits<{
'highlight-element': [index: number] 'highlight-element': [index: number]
@ -112,22 +115,49 @@ function draw(): void {
if (!props.pageData) return if (!props.pageData) return
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height) const scale = computeScale(
img.clientWidth,
img.clientHeight,
props.pageData.width,
props.pageData.height,
)
const hasHighlight = props.highlightedIndex >= 0
const hasChunkHighlight = props.highlightedBboxes.length > 0
for (const el of visibleElements.value) { for (const el of visibleElements.value) {
const rect = bboxToRect(el.bbox, scale) const rect = bboxToRect(el.bbox, scale)
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
const elContentIdx = contentElements.value.indexOf(el) const elContentIdx = contentElements.value.indexOf(el)
const isHighlighted = props.highlightedIndex >= 0 && elContentIdx === props.highlightedIndex const isHighlighted = hasHighlight && elContentIdx === props.highlightedIndex
const dimmed = (hasHighlight || hasChunkHighlight) && !isHighlighted
ctx.strokeStyle = color ctx.strokeStyle = dimmed ? color + '40' : color
ctx.lineWidth = isHighlighted ? 3 : 2 ctx.lineWidth = isHighlighted ? 3 : 2
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h) ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
ctx.fillStyle = color + (isHighlighted ? '40' : '20') ctx.fillStyle = color + (isHighlighted ? '40' : dimmed ? '08' : '20')
ctx.fillRect(rect.x, rect.y, rect.w, rect.h) ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
} }
// Draw chunk-highlighted bboxes on top with accent color
if (hasChunkHighlight) {
const CHUNK_COLOR = '#F59E0B'
for (const cb of props.highlightedBboxes) {
const rect = bboxToRect(cb.bbox, scale)
ctx.strokeStyle = CHUNK_COLOR
ctx.lineWidth = 3
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
ctx.fillStyle = CHUNK_COLOR + '30'
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
}
}
}
function onMouseLeave(): void {
hoveredElement.value = null
emit('highlight-element', -1)
} }
function onMouseMove(e: MouseEvent): void { function onMouseMove(e: MouseEvent): void {
@ -139,7 +169,12 @@ function onMouseMove(e: MouseEvent): void {
const mx = e.clientX - canvasRect.left const mx = e.clientX - canvasRect.left
const my = e.clientY - canvasRect.top const my = e.clientY - canvasRect.top
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height) const scale = computeScale(
img.clientWidth,
img.clientHeight,
props.pageData.width,
props.pageData.height,
)
let found: PageElement | null = null let found: PageElement | null = null
let foundIdx = -1 let foundIdx = -1
@ -156,7 +191,7 @@ function onMouseMove(e: MouseEvent): void {
if (found) { if (found) {
tooltipStyle.value = { tooltipStyle.value = {
left: `${Math.min(mx + 12, canvas.width - 250)}px`, left: `${Math.min(mx + 12, canvas.width - 250)}px`,
top: `${my + 12}px` top: `${my + 12}px`,
} }
} }
} }
@ -173,9 +208,18 @@ onBeforeUnmount(() => {
}) })
// Redraw when data or image changes // Redraw when data or image changes
watch([() => props.pageData, () => props.imageEl, () => props.highlightedIndex, hiddenTypes], () => { watch(
nextTick(draw) [
}) () => props.pageData,
() => props.imageEl,
() => props.highlightedIndex,
() => props.highlightedBboxes,
hiddenTypes,
],
() => {
nextTick(draw)
},
)
// Expose draw so parent can call it after image load // Expose draw so parent can call it after image load
defineExpose({ draw }) defineExpose({ draw })
@ -219,8 +263,13 @@ defineExpose({ draw })
opacity: 0.9; opacity: 0.9;
} }
.legend-chip:hover { opacity: 1; background: var(--bg-hover); } .legend-chip:hover {
.legend-chip.dimmed { opacity: 0.35; } opacity: 1;
background: var(--bg-hover);
}
.legend-chip.dimmed {
opacity: 0.35;
}
.legend-dot { .legend-dot {
width: 6px; width: 6px;
@ -252,7 +301,7 @@ defineExpose({ draw })
max-width: 250px; max-width: 250px;
pointer-events: none; pointer-events: none;
z-index: 10; z-index: 10;
box-shadow: 0 4px 12px rgba(0,0,0,0.3); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
} }
.tooltip-type { .tooltip-type {

View file

@ -4,11 +4,7 @@
{{ t('results.noImages') }} {{ t('results.noImages') }}
</div> </div>
<div v-else class="gallery-grid"> <div v-else class="gallery-grid">
<div <div v-for="(img, idx) in images" :key="idx" class="gallery-card">
v-for="(img, idx) in images"
:key="idx"
class="gallery-card"
>
<div class="card-header"> <div class="card-header">
<span class="card-type">Picture</span> <span class="card-type">Picture</span>
<span class="card-page">{{ t('results.page') }} {{ img.page }}</span> <span class="card-page">{{ t('results.page') }} {{ img.page }}</span>
@ -18,7 +14,7 @@
</div> </div>
<div class="card-bbox"> <div class="card-bbox">
<span class="bbox-label">bbox:</span> <span class="bbox-label">bbox:</span>
<span class="bbox-value">{{ img.bbox.map(v => Math.round(v)).join(', ') }}</span> <span class="bbox-value">{{ img.bbox.map((v) => Math.round(v)).join(', ') }}</span>
</div> </div>
</div> </div>
</div> </div>
@ -33,13 +29,13 @@ import type { Page, PageElement } from '../../../shared/types'
const { t } = useI18n() const { t } = useI18n()
const props = defineProps({ const props = defineProps({
pages: { type: Array as () => Page[], default: () => [] } pages: { type: Array as () => Page[], default: () => [] },
}) })
const images = computed(() => { const images = computed(() => {
const result: (PageElement & { page: number })[] = [] const result: (PageElement & { page: number })[] = []
for (const page of props.pages) { for (const page of props.pages) {
for (const el of (page.elements || [])) { for (const el of page.elements || []) {
if (el.type === 'picture') { if (el.type === 'picture') {
result.push({ ...el, page: page.page_number }) result.push({ ...el, page: page.page_number })
} }
@ -109,5 +105,7 @@ const images = computed(() => {
color: var(--text-muted); color: var(--text-muted);
} }
.bbox-label { margin-right: 4px; } .bbox-label {
margin-right: 4px;
}
</style> </style>

View file

@ -33,11 +33,19 @@ const rendered = computed(() => {
font-weight: 600; font-weight: 600;
} }
.markdown-viewer :deep(h1) { font-size: 24px; } .markdown-viewer :deep(h1) {
.markdown-viewer :deep(h2) { font-size: 20px; } font-size: 24px;
.markdown-viewer :deep(h3) { font-size: 16px; } }
.markdown-viewer :deep(h2) {
font-size: 20px;
}
.markdown-viewer :deep(h3) {
font-size: 16px;
}
.markdown-viewer :deep(p) { margin: 8px 0; } .markdown-viewer :deep(p) {
margin: 8px 0;
}
.markdown-viewer :deep(code) { .markdown-viewer :deep(code) {
background: var(--bg-elevated); background: var(--bg-elevated);

View file

@ -14,7 +14,9 @@
<!-- Page chip --> <!-- Page chip -->
<div class="page-indicator" v-if="totalPages > 0"> <div class="page-indicator" v-if="totalPages > 0">
<span class="page-chip">{{ t('results.pageOf', { current: currentPage, total: totalPages }) }}</span> <span class="page-chip">{{
t('results.pageOf', { current: currentPage, total: totalPages })
}}</span>
</div> </div>
<div class="tab-content"> <div class="tab-content">
@ -32,7 +34,10 @@
@mouseleave="$emit('highlight-element', -1)" @mouseleave="$emit('highlight-element', -1)"
> >
<div class="element-header"> <div class="element-header">
<span class="element-type" :style="{ color: ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text }"> <span
class="element-type"
:style="{ color: ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text }"
>
{{ el.type }} {{ el.type }}
</span> </span>
<span class="element-level" v-if="el.level">L{{ el.level }}</span> <span class="element-level" v-if="el.level">L{{ el.level }}</span>
@ -42,12 +47,23 @@
:title="t('results.copy')" :title="t('results.copy')"
@click.stop="copyElement(idx, el.content)" @click.stop="copyElement(idx, el.content)"
> >
<svg v-if="!copiedElements[idx]" viewBox="0 0 20 20" fill="currentColor" class="copy-icon"> <svg
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z"/> v-if="!copiedElements[idx]"
<path d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"/> viewBox="0 0 20 20"
fill="currentColor"
class="copy-icon"
>
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z" />
<path
d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"
/>
</svg> </svg>
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="copy-icon copied"> <svg v-else viewBox="0 0 20 20" fill="currentColor" class="copy-icon copied">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> <path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg> </svg>
</button> </button>
</div> </div>
@ -57,7 +73,7 @@
<span v-else>{{ el.content }}</span> <span v-else>{{ el.content }}</span>
</div> </div>
<div class="element-bbox"> <div class="element-bbox">
{{ el.bbox.map(v => Math.round(v)).join(', ') }} {{ el.bbox.map((v) => Math.round(v)).join(', ') }}
</div> </div>
</div> </div>
</div> </div>
@ -66,11 +82,17 @@
<div v-else-if="activeTab === 'markdown'" class="raw-markdown"> <div v-else-if="activeTab === 'markdown'" class="raw-markdown">
<button class="copy-btn copy-btn-block" :title="t('results.copy')" @click="copyMarkdown"> <button class="copy-btn copy-btn-block" :title="t('results.copy')" @click="copyMarkdown">
<svg v-if="!copiedMarkdown" viewBox="0 0 20 20" fill="currentColor" class="copy-icon"> <svg v-if="!copiedMarkdown" viewBox="0 0 20 20" fill="currentColor" class="copy-icon">
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z"/> <path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z" />
<path d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"/> <path
d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"
/>
</svg> </svg>
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="copy-icon copied"> <svg v-else viewBox="0 0 20 20" fill="currentColor" class="copy-icon copied">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> <path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg> </svg>
</button> </button>
<pre class="raw-content">{{ pageMarkdown }}</pre> <pre class="raw-content">{{ pageMarkdown }}</pre>
@ -85,12 +107,26 @@
<span>{{ t('studio.analysisRunning') }}</span> <span>{{ t('studio.analysisRunning') }}</span>
</div> </div>
<div v-else-if="store.currentAnalysis?.status === 'FAILED'" class="result-placeholder error"> <div v-else-if="store.currentAnalysis?.status === 'FAILED'" class="result-placeholder error">
<svg viewBox="0 0 20 20" fill="currentColor" class="error-icon"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg> <svg viewBox="0 0 20 20" fill="currentColor" class="error-icon">
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
<span>{{ store.currentAnalysis.errorMessage || t('results.analysisFailed') }}</span> <span>{{ store.currentAnalysis.errorMessage || t('results.analysisFailed') }}</span>
</div> </div>
<div v-else class="result-placeholder"> <div v-else class="result-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon"> <svg
<path d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v16.5c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9zm3.75 11.625a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/> viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="empty-icon"
>
<path
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v16.5c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9zm3.75 11.625a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg> </svg>
<span>{{ t('results.runAnalysis') }}</span> <span>{{ t('results.runAnalysis') }}</span>
</div> </div>
@ -113,12 +149,12 @@ const ELEMENT_COLORS: Record<string, string> = {
list: '#06B6D4', list: '#06B6D4',
formula: '#EC4899', formula: '#EC4899',
code: '#14B8A6', code: '#14B8A6',
caption: '#EAB308' caption: '#EAB308',
} }
const props = defineProps({ const props = defineProps({
currentPage: { type: Number, default: 1 }, currentPage: { type: Number, default: 1 },
highlightedIndex: { type: Number, default: -1 } highlightedIndex: { type: Number, default: -1 },
}) })
defineEmits(['highlight-element']) defineEmits(['highlight-element'])
@ -130,17 +166,17 @@ const activeTab = ref('elements')
const tabs = computed(() => [ const tabs = computed(() => [
{ id: 'elements', label: t('results.elements') }, { id: 'elements', label: t('results.elements') },
{ id: 'markdown', label: t('results.markdown') }, { id: 'markdown', label: t('results.markdown') },
{ id: 'images', label: t('results.images') } { id: 'images', label: t('results.images') },
]) ])
const totalPages = computed(() => store.currentPages.length) const totalPages = computed(() => store.currentPages.length)
const currentPageData = computed(() => { const currentPageData = computed(() => {
return store.currentPages.find(p => p.page_number === props.currentPage) || null return store.currentPages.find((p) => p.page_number === props.currentPage) || null
}) })
const currentElements = computed(() => { const currentElements = computed(() => {
return (currentPageData.value?.elements || []).filter(el => el.content) return (currentPageData.value?.elements || []).filter((el) => el.content)
}) })
const currentPageAsArray = computed(() => { const currentPageAsArray = computed(() => {
@ -153,7 +189,7 @@ const pageMarkdown = computed(() => {
if (!page) return '' if (!page) return ''
return (page.elements || []) return (page.elements || [])
.map(el => formatElement(el)) .map((el) => formatElement(el))
.filter(Boolean) .filter(Boolean)
.join('\n\n') .join('\n\n')
}) })
@ -191,16 +227,24 @@ async function copyMarkdown() {
try { try {
await navigator.clipboard.writeText(pageMarkdown.value) await navigator.clipboard.writeText(pageMarkdown.value)
copiedMarkdown.value = true copiedMarkdown.value = true
setTimeout(() => { copiedMarkdown.value = false }, 1500) setTimeout(() => {
} catch { /* clipboard not available */ } copiedMarkdown.value = false
}, 1500)
} catch {
/* clipboard not available */
}
} }
async function copyElement(idx: number, content: string) { async function copyElement(idx: number, content: string) {
try { try {
await navigator.clipboard.writeText(content) await navigator.clipboard.writeText(content)
copiedElements[idx] = true copiedElements[idx] = true
setTimeout(() => { copiedElements[idx] = false }, 1500) setTimeout(() => {
} catch { /* clipboard not available */ } copiedElements[idx] = false
}, 1500)
} catch {
/* clipboard not available */
}
} }
</script> </script>
@ -234,7 +278,9 @@ async function copyElement(idx: number, content: string) {
white-space: nowrap; white-space: nowrap;
} }
.tab-btn:hover { color: var(--text-secondary); } .tab-btn:hover {
color: var(--text-secondary);
}
.tab-btn.active { .tab-btn.active {
color: var(--accent); color: var(--accent);
border-bottom-color: var(--accent); border-bottom-color: var(--accent);
@ -368,8 +414,13 @@ async function copyElement(idx: number, content: string) {
background: var(--accent-muted); background: var(--accent-muted);
} }
.copy-icon { width: 14px; height: 14px; } .copy-icon {
.copy-icon.copied { color: var(--success); } width: 14px;
height: 14px;
}
.copy-icon.copied {
color: var(--success);
}
.copy-btn-element { .copy-btn-element {
margin-left: auto; margin-left: auto;
@ -377,7 +428,9 @@ async function copyElement(idx: number, content: string) {
transition: opacity var(--transition); transition: opacity var(--transition);
} }
.element-card:hover .copy-btn-element { opacity: 1; } .element-card:hover .copy-btn-element {
opacity: 1;
}
.copy-btn-block { .copy-btn-block {
position: absolute; position: absolute;
@ -389,7 +442,9 @@ async function copyElement(idx: number, content: string) {
transition: opacity var(--transition); transition: opacity var(--transition);
} }
.raw-markdown:hover .copy-btn-block { opacity: 1; } .raw-markdown:hover .copy-btn-block {
opacity: 1;
}
/* --- Raw markdown --- */ /* --- Raw markdown --- */
.raw-markdown { .raw-markdown {
@ -423,9 +478,18 @@ async function copyElement(idx: number, content: string) {
font-size: 14px; font-size: 14px;
} }
.result-placeholder.error { color: var(--error); } .result-placeholder.error {
.error-icon { width: 32px; height: 32px; } color: var(--error);
.empty-icon { width: 48px; height: 48px; color: var(--border-light); } }
.error-icon {
width: 32px;
height: 32px;
}
.empty-icon {
width: 48px;
height: 48px;
color: var(--border-light);
}
.spinner-large { .spinner-large {
width: 32px; width: 32px;
@ -436,5 +500,9 @@ async function copyElement(idx: number, content: string) {
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin {
to {
transform: rotate(360deg);
}
}
</style> </style>

View file

@ -44,12 +44,11 @@
@mouseleave="hoveredElement = null" @mouseleave="hoveredElement = null"
/> />
<!-- Tooltip --> <!-- Tooltip -->
<div <div v-if="hoveredElement" class="tooltip" :style="tooltipStyle">
v-if="hoveredElement" <span
class="tooltip" class="tooltip-type"
:style="tooltipStyle" :style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }"
> >
<span class="tooltip-type" :style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }">
{{ hoveredElement.type }} {{ hoveredElement.type }}
</span> </span>
<span class="tooltip-content">{{ hoveredElement.content?.substring(0, 150) }}</span> <span class="tooltip-content">{{ hoveredElement.content?.substring(0, 150) }}</span>
@ -71,12 +70,12 @@ const ELEMENT_COLORS: Record<string, string> = {
picture: '#22C55E', picture: '#22C55E',
list: '#06B6D4', list: '#06B6D4',
formula: '#EC4899', formula: '#EC4899',
caption: '#EAB308' caption: '#EAB308',
} }
const props = defineProps({ const props = defineProps({
pages: { type: Array as () => Page[], default: () => [] }, pages: { type: Array as () => Page[], default: () => [] },
documentId: String documentId: String,
}) })
const selectedPage = ref(1) const selectedPage = ref(1)
@ -89,12 +88,12 @@ const tooltipStyle = ref<Record<string, string>>({})
const imageSize = ref({ width: 0, height: 0 }) const imageSize = ref({ width: 0, height: 0 })
const currentPageData = computed(() => { const currentPageData = computed(() => {
return props.pages.find(p => p.page_number === selectedPage.value) return props.pages.find((p) => p.page_number === selectedPage.value)
}) })
const visibleElements = computed(() => { const visibleElements = computed(() => {
if (!currentPageData.value) return [] if (!currentPageData.value) return []
return currentPageData.value.elements.filter(e => !hiddenTypes.has(e.type)) return currentPageData.value.elements.filter((e) => !hiddenTypes.has(e.type))
}) })
const previewUrl = computed(() => { const previewUrl = computed(() => {
@ -110,7 +109,7 @@ function toggleType(type: string) {
function countElements(type: string) { function countElements(type: string) {
if (!currentPageData.value) return 0 if (!currentPageData.value) return 0
return currentPageData.value.elements.filter(e => e.type === type).length return currentPageData.value.elements.filter((e) => e.type === type).length
} }
function onImageLoad() { function onImageLoad() {
@ -174,7 +173,7 @@ function onMouseMove(e: MouseEvent) {
if (found) { if (found) {
tooltipStyle.value = { tooltipStyle.value = {
left: `${Math.min(mx + 12, canvas.width - 250)}px`, left: `${Math.min(mx + 12, canvas.width - 250)}px`,
top: `${my + 12}px` top: `${my + 12}px`,
} }
} }
} }
@ -213,7 +212,9 @@ watch([() => props.pages, selectedPage, hiddenTypes], () => {
transition: all var(--transition); transition: all var(--transition);
} }
.page-btn:hover { background: var(--bg-hover); } .page-btn:hover {
background: var(--bg-hover);
}
.page-btn.active { .page-btn.active {
background: var(--accent-muted); background: var(--accent-muted);
border-color: var(--accent); border-color: var(--accent);
@ -240,8 +241,12 @@ watch([() => props.pages, selectedPage, hiddenTypes], () => {
transition: all var(--transition); transition: all var(--transition);
} }
.legend-item:hover { background: var(--bg-hover); } .legend-item:hover {
.legend-item.dimmed { opacity: 0.4; } background: var(--bg-hover);
}
.legend-item.dimmed {
opacity: 0.4;
}
.legend-dot { .legend-dot {
width: 8px; width: 8px;
@ -286,7 +291,7 @@ watch([() => props.pages, selectedPage, hiddenTypes], () => {
max-width: 250px; max-width: 250px;
pointer-events: none; pointer-events: none;
z-index: 10; z-index: 10;
box-shadow: 0 4px 12px rgba(0,0,0,0.3); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
} }
.tooltip-type { .tooltip-type {

View file

@ -0,0 +1,52 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { rechunkAnalysis, createAnalysis } from '../analysis/api'
vi.mock('../../shared/api/http', () => ({
apiFetch: vi.fn(),
}))
import { apiFetch } from '../../shared/api/http'
describe('chunking API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('createAnalysis sends chunkingOptions when provided', async () => {
const job = { id: '1', documentId: 'doc-1', status: 'PENDING' }
apiFetch.mockResolvedValue(job)
const chunkingOpts = { chunker_type: 'hybrid' as const, max_tokens: 256 }
await createAnalysis('doc-1', null, chunkingOpts)
expect(apiFetch).toHaveBeenCalledWith('/api/analyses', {
method: 'POST',
body: JSON.stringify({ documentId: 'doc-1', chunkingOptions: chunkingOpts }),
})
})
it('createAnalysis omits chunkingOptions when null', async () => {
apiFetch.mockResolvedValue({ id: '1' })
await createAnalysis('doc-1', null, null)
expect(apiFetch).toHaveBeenCalledWith('/api/analyses', {
method: 'POST',
body: JSON.stringify({ documentId: 'doc-1' }),
})
})
it('rechunkAnalysis sends POST to rechunk endpoint', async () => {
const chunks = [{ text: 'chunk1', headings: [], sourcePage: 1, tokenCount: 10 }]
apiFetch.mockResolvedValue(chunks)
const opts = { chunker_type: 'hybrid' as const, max_tokens: 512 }
const result = await rechunkAnalysis('job-1', opts)
expect(apiFetch).toHaveBeenCalledWith('/api/analyses/job-1/rechunk', {
method: 'POST',
body: JSON.stringify({ chunkingOptions: opts }),
})
expect(result).toEqual(chunks)
})
})

View file

@ -0,0 +1 @@
export { default as ChunkPanel } from './ui/ChunkPanel.vue'

View file

@ -0,0 +1,141 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAnalysisStore } from '../analysis/store'
vi.mock('../analysis/api', () => ({
createAnalysis: vi.fn(),
fetchAnalyses: vi.fn().mockResolvedValue([]),
fetchAnalysis: vi.fn(),
deleteAnalysis: vi.fn(),
rechunkAnalysis: vi.fn(),
}))
import * as api from '../analysis/api'
describe('analysis store — chunking', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('currentChunks parses chunksJson from current analysis', () => {
const store = useAnalysisStore()
const chunks = [
{
text: 'chunk1',
headings: ['H1'],
sourcePage: 1,
tokenCount: 10,
bboxes: [{ page: 1, bbox: [10, 20, 100, 80] }],
},
{ text: 'chunk2', headings: [], sourcePage: 2, tokenCount: 20, bboxes: [] },
]
store.currentAnalysis = {
id: 'j1',
documentId: 'd1',
documentFilename: null,
status: 'COMPLETED',
contentMarkdown: null,
contentHtml: null,
pagesJson: null,
chunksJson: JSON.stringify(chunks),
hasDocumentJson: true,
errorMessage: null,
startedAt: null,
completedAt: null,
createdAt: '2024-01-01',
}
expect(store.currentChunks).toEqual(chunks)
})
it('currentChunks returns empty array when no chunksJson', () => {
const store = useAnalysisStore()
store.currentAnalysis = {
id: 'j1',
documentId: 'd1',
documentFilename: null,
status: 'COMPLETED',
contentMarkdown: null,
contentHtml: null,
pagesJson: null,
chunksJson: null,
hasDocumentJson: false,
errorMessage: null,
startedAt: null,
completedAt: null,
createdAt: '2024-01-01',
}
expect(store.currentChunks).toEqual([])
})
it('rechunk calls API and refreshes analysis', async () => {
const store = useAnalysisStore()
const chunks = [{ text: 'c1', headings: [], sourcePage: 1, tokenCount: 5, bboxes: [] }]
vi.mocked(api.rechunkAnalysis).mockResolvedValue(chunks)
vi.mocked(api.fetchAnalysis).mockResolvedValue({
id: 'j1',
documentId: 'd1',
documentFilename: null,
status: 'COMPLETED',
contentMarkdown: null,
contentHtml: null,
pagesJson: null,
chunksJson: JSON.stringify(chunks),
hasDocumentJson: true,
errorMessage: null,
startedAt: null,
completedAt: null,
createdAt: '2024-01-01',
})
store.currentAnalysis = {
id: 'j1',
documentId: 'd1',
documentFilename: null,
status: 'COMPLETED',
contentMarkdown: null,
contentHtml: null,
pagesJson: null,
chunksJson: null,
hasDocumentJson: true,
errorMessage: null,
startedAt: null,
completedAt: null,
createdAt: '2024-01-01',
}
const result = await store.rechunk('j1', { chunker_type: 'hybrid', max_tokens: 256 })
expect(api.rechunkAnalysis).toHaveBeenCalledWith('j1', {
chunker_type: 'hybrid',
max_tokens: 256,
})
expect(result).toEqual(chunks)
expect(store.rechunking).toBe(false)
})
it('run passes chunkingOptions to API', async () => {
const store = useAnalysisStore()
vi.mocked(api.createAnalysis).mockResolvedValue({
id: 'j1',
documentId: 'd1',
documentFilename: null,
status: 'PENDING',
contentMarkdown: null,
contentHtml: null,
pagesJson: null,
chunksJson: null,
hasDocumentJson: false,
errorMessage: null,
startedAt: null,
completedAt: null,
createdAt: '2024-01-01',
})
await store.run('d1', null, { chunker_type: 'hierarchical' })
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, {
chunker_type: 'hierarchical',
})
})
})

View file

@ -0,0 +1,437 @@
<template>
<div class="chunk-panel">
<!-- Chunking config collapsible -->
<div class="chunk-config">
<button class="config-toggle" @click="configOpen = !configOpen">
<svg
class="config-chevron"
:class="{ open: configOpen }"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
<span class="config-label">{{ t('chunking.settings') }}</span>
</button>
<div v-if="configOpen" class="config-body">
<div class="config-row">
<label class="config-label-sm">{{ t('chunking.chunkerType') }}</label>
<select class="config-select" v-model="options.chunker_type">
<option value="hybrid">Hybrid</option>
<option value="hierarchical">Hierarchical</option>
</select>
</div>
<div class="config-row">
<label class="config-label-sm">{{ t('chunking.maxTokens') }}</label>
<input
type="number"
class="config-input"
v-model.number="options.max_tokens"
min="64"
max="8192"
step="64"
/>
</div>
<div class="config-toggle-row" v-if="options.chunker_type === 'hybrid'">
<label class="toggle-label">
<input type="checkbox" v-model="options.merge_peers" class="toggle-input" />
<span class="toggle-switch" />
<span class="toggle-text">{{ t('chunking.mergePeers') }}</span>
</label>
</div>
<div class="config-toggle-row" v-if="options.chunker_type === 'hybrid'">
<label class="toggle-label">
<input type="checkbox" v-model="options.repeat_table_header" class="toggle-input" />
<span class="toggle-switch" />
<span class="toggle-text">{{ t('chunking.repeatTableHeader') }}</span>
</label>
</div>
<button
class="chunk-btn primary"
:disabled="!canRechunk || analysisStore.rechunking"
@click="doRechunk"
>
<div v-if="analysisStore.rechunking" class="spinner-sm" />
{{ analysisStore.rechunking ? t('chunking.chunking') : t('chunking.run') }}
</button>
</div>
</div>
<!-- Chunks list -->
<div class="chunk-results" v-if="pageChunks.length">
<div class="chunk-summary">{{ pagination.totalItems.value }} {{ t('chunking.chunks') }}</div>
<div class="chunk-list">
<div
class="chunk-card"
v-for="(chunk, localIdx) in pagination.paginatedItems.value"
:key="globalIndex(localIdx)"
:class="{ highlighted: hoveredChunkIdx === globalIndex(localIdx) }"
@mouseenter="onChunkHover(chunk, localIdx)"
@mouseleave="onChunkLeave"
>
<div class="chunk-header">
<span class="chunk-index">#{{ globalIndex(localIdx) + 1 }}</span>
<span class="chunk-tokens" v-if="chunk.tokenCount">
{{ chunk.tokenCount }} tokens
</span>
<span class="chunk-page" v-if="chunk.sourcePage"> p.{{ chunk.sourcePage }} </span>
</div>
<div class="chunk-headings" v-if="chunk.headings.length">
<span class="chunk-heading" v-for="h in chunk.headings" :key="h">{{ h }}</span>
</div>
<div class="chunk-text">{{ chunk.text }}</div>
</div>
</div>
</div>
<div class="chunk-empty" v-else-if="!analysisStore.rechunking">
<p>
{{
analysisStore.currentChunks.length ? t('chunking.noChunksOnPage') : t('chunking.noChunks')
}}
</p>
</div>
<!-- Pagination -->
<PaginationBar
:page="pagination.page.value"
:page-count="pagination.pageCount.value"
:page-size="pagination.pageSize.value"
@update:page="pagination.goTo($event)"
@update:page-size="pagination.setPageSize($event)"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useAnalysisStore } from '../../analysis/store'
import { useI18n } from '../../../shared/i18n'
import { usePagination } from '../../../shared/composables/usePagination'
import { PaginationBar } from '../../../shared/ui'
import type { Chunk, ChunkBbox, ChunkingOptions } from '../../../shared/types'
const props = defineProps<{
currentPage: number
}>()
const emit = defineEmits<{
'highlight-bboxes': [bboxes: ChunkBbox[]]
}>()
const analysisStore = useAnalysisStore()
const { t } = useI18n()
const configOpen = ref(true)
const options = reactive<Required<ChunkingOptions>>({
chunker_type: 'hybrid',
max_tokens: 512,
merge_peers: true,
repeat_table_header: true,
})
const canRechunk = computed(() => {
const analysis = analysisStore.currentAnalysis
return analysis?.status === 'COMPLETED' && analysis.hasDocumentJson
})
const pageChunks = computed(() =>
analysisStore.currentChunks.filter((c) => c.sourcePage === props.currentPage),
)
const pagination = usePagination(pageChunks, { pageSize: 20 })
function globalIndex(localIdx: number): number {
return (pagination.page.value - 1) * pagination.pageSize.value + localIdx
}
const hoveredChunkIdx = ref(-1)
function onChunkHover(chunk: Chunk, localIdx: number) {
hoveredChunkIdx.value = globalIndex(localIdx)
const pageBboxes = chunk.bboxes.filter((b) => b.page === props.currentPage)
emit('highlight-bboxes', pageBboxes)
}
function onChunkLeave() {
hoveredChunkIdx.value = -1
emit('highlight-bboxes', [])
}
async function doRechunk() {
if (!analysisStore.currentAnalysis) return
await analysisStore.rechunk(analysisStore.currentAnalysis.id, { ...options })
}
</script>
<style scoped>
.chunk-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.chunk-config {
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.config-toggle {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 10px 16px;
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
}
.config-toggle:hover {
color: var(--text);
}
.config-chevron {
width: 14px;
height: 14px;
transition: transform 0.2s;
transform: rotate(0deg);
}
.config-chevron.open {
transform: rotate(90deg);
}
.config-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.config-body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 16px 16px;
}
.config-label-sm {
font-size: 12px;
color: var(--text-secondary);
}
.config-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.config-select,
.config-input {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 10px;
font-size: 13px;
color: var(--text);
width: 100%;
}
.config-toggle-row {
display: flex;
align-items: center;
gap: 8px;
}
.toggle-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text);
}
.toggle-input {
display: none;
}
.toggle-switch {
width: 32px;
height: 18px;
background: var(--bg-tertiary);
border-radius: 9px;
position: relative;
transition: background 0.2s;
flex-shrink: 0;
}
.toggle-switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle-input:checked + .toggle-switch {
background: var(--accent);
}
.toggle-input:checked + .toggle-switch::after {
transform: translateX(14px);
}
.chunk-btn {
padding: 8px 16px;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 13px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-top: 4px;
}
.chunk-btn.primary {
background: var(--accent);
color: white;
}
.chunk-btn.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chunk-results {
flex: 1;
overflow-y: auto;
padding: 12px;
min-height: 0;
}
.chunk-summary {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.chunk-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.chunk-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
cursor: default;
transition:
border-color 0.15s,
background 0.15s;
}
.chunk-card:hover {
border-color: #f59e0b;
}
.chunk-card.highlighted {
border-color: #f59e0b;
background: rgba(245, 158, 11, 0.08);
}
.chunk-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.chunk-index {
font-size: 11px;
font-weight: 700;
color: var(--accent);
}
.chunk-tokens,
.chunk-page {
font-size: 11px;
color: var(--text-secondary);
background: var(--bg-tertiary);
padding: 1px 6px;
border-radius: 4px;
}
.chunk-headings {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.chunk-heading {
font-size: 11px;
color: var(--accent);
background: var(--accent-bg, rgba(99, 102, 241, 0.1));
padding: 1px 6px;
border-radius: 4px;
}
.chunk-text {
font-size: 12px;
color: var(--text);
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
max-height: 120px;
overflow-y: auto;
}
.chunk-empty {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: var(--text-secondary);
font-size: 13px;
}
.spinner-sm {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -24,7 +24,10 @@ describe('useDocumentStore', () => {
}) })
it('load() fetches and sets documents', async () => { it('load() fetches and sets documents', async () => {
const docs = [{ id: '1', filename: 'a.pdf' }, { id: '2', filename: 'b.pdf' }] const docs = [
{ id: '1', filename: 'a.pdf' },
{ id: '2', filename: 'b.pdf' },
]
api.fetchDocuments.mockResolvedValue(docs) api.fetchDocuments.mockResolvedValue(docs)
const store = useDocumentStore() const store = useDocumentStore()
@ -61,7 +64,12 @@ describe('useDocumentStore', () => {
it('upload() sets uploading to true during upload', async () => { it('upload() sets uploading to true during upload', async () => {
let resolveUpload let resolveUpload
api.uploadDocument.mockImplementation(() => new Promise(r => { resolveUpload = r })) api.uploadDocument.mockImplementation(
() =>
new Promise((r) => {
resolveUpload = r
}),
)
const store = useDocumentStore() const store = useDocumentStore()
const promise = store.upload(new File([], 'test.pdf')) const promise = store.upload(new File([], 'test.pdf'))

View file

@ -3,6 +3,8 @@ import { ref } from 'vue'
import type { Document } from '../../shared/types' import type { Document } from '../../shared/types'
import * as api from './api' import * as api from './api'
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
export const useDocumentStore = defineStore('document', () => { export const useDocumentStore = defineStore('document', () => {
const documents = ref<Document[]>([]) const documents = ref<Document[]>([])
const selectedId = ref<string | null>(null) const selectedId = ref<string | null>(null)
@ -24,6 +26,10 @@ export const useDocumentStore = defineStore('document', () => {
} }
async function upload(file: File): Promise<Document> { async function upload(file: File): Promise<Document> {
if (file.size > MAX_FILE_SIZE) {
error.value = 'File too large (max 50 MB)'
throw new Error(error.value)
}
uploading.value = true uploading.value = true
error.value = null error.value = null
try { try {

View file

@ -9,15 +9,28 @@
> >
<div class="doc-info"> <div class="doc-info">
<svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor"> <svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/> <path
fill-rule="evenodd"
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
clip-rule="evenodd"
/>
</svg> </svg>
<div class="doc-meta"> <div class="doc-meta">
<span class="doc-name">{{ doc.filename }}</span> <span class="doc-name">{{ doc.filename }}</span>
<span class="doc-size">{{ formatSize(doc.fileSize) }}{{ doc.pageCount ? `${doc.pageCount} pages` : '' }}</span> <span class="doc-size"
>{{ formatSize(doc.fileSize)
}}{{ doc.pageCount ? `${doc.pageCount} pages` : '' }}</span
>
</div> </div>
</div> </div>
<button class="doc-delete" @click.stop="store.remove(doc.id)" title="Delete"> <button class="doc-delete" @click.stop="store.remove(doc.id)" title="Delete">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg> <svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button> </button>
</div> </div>
</div> </div>
@ -101,7 +114,15 @@ const store = useDocumentStore()
transition: all var(--transition); transition: all var(--transition);
} }
.doc-item:hover .doc-delete { opacity: 1; } .doc-item:hover .doc-delete {
.doc-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); } opacity: 1;
.doc-delete svg { width: 14px; height: 14px; } }
.doc-delete:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.1);
}
.doc-delete svg {
width: 14px;
height: 14px;
}
</style> </style>

View file

@ -13,11 +13,18 @@
<span>{{ t('upload.uploading') }}</span> <span>{{ t('upload.uploading') }}</span>
</div> </div>
<div v-else class="upload-state"> <div v-else class="upload-state">
<svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg
class="upload-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" /> <path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" />
</svg> </svg>
<span class="upload-text">{{ t('upload.drop') }}</span> <span class="upload-text">{{ t('upload.drop') }}</span>
<span class="upload-hint">{{ t('upload.maxSize') }}</span> <span class="upload-hint">{{ t('upload.maxSize') }}</span>
<span v-if="store.error" class="upload-error">{{ store.error }}</span>
</div> </div>
</div> </div>
</template> </template>
@ -41,9 +48,14 @@ function openFilePicker() {
async function onFileSelect(e: Event) { async function onFileSelect(e: Event) {
const target = e.target as HTMLInputElement const target = e.target as HTMLInputElement
const file = target.files?.[0] const file = target.files?.[0]
if (file) { if (file && (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'))) {
const doc = await store.upload(file) try {
if (doc) emit('uploaded', doc.id) store.clearError()
const doc = await store.upload(file)
if (doc) emit('uploaded', doc.id)
} catch {
// error is already set in store.upload
}
} }
target.value = '' target.value = ''
} }
@ -51,9 +63,14 @@ async function onFileSelect(e: Event) {
async function onDrop(e: DragEvent) { async function onDrop(e: DragEvent) {
dragging.value = false dragging.value = false
const file = e.dataTransfer?.files?.[0] const file = e.dataTransfer?.files?.[0]
if (file && file.type === 'application/pdf') { if (file && (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'))) {
const doc = await store.upload(file) try {
if (doc) emit('uploaded', doc.id) store.clearError()
const doc = await store.upload(file)
if (doc) emit('uploaded', doc.id)
} catch {
// error is already set in store.upload
}
} }
} }
</script> </script>
@ -103,6 +120,12 @@ async function onDrop(e: DragEvent) {
color: var(--text-muted); color: var(--text-muted);
} }
.upload-error {
font-size: 13px;
color: var(--error, #e53e3e);
font-weight: 500;
}
.spinner { .spinner {
width: 24px; width: 24px;
height: 24px; height: 24px;
@ -113,6 +136,8 @@ async function onDrop(e: DragEvent) {
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
</style> </style>

View file

@ -4,10 +4,26 @@
<span class="preview-label">Page {{ page }}</span> <span class="preview-label">Page {{ page }}</span>
<div class="preview-nav"> <div class="preview-nav">
<button class="nav-btn" :disabled="page <= 1" @click="$emit('update:page', page - 1)"> <button class="nav-btn" :disabled="page <= 1" @click="$emit('update:page', page - 1)">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg> <svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</button> </button>
<button class="nav-btn" :disabled="!!(pageCount && page >= pageCount)" @click="$emit('update:page', page + 1)"> <button
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg> class="nav-btn"
:disabled="!!(pageCount && page >= pageCount)"
@click="$emit('update:page', page + 1)"
>
<svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
</button> </button>
</div> </div>
</div> </div>
@ -30,7 +46,7 @@ import { getPreviewUrl } from '../api'
const props = defineProps({ const props = defineProps({
documentId: String, documentId: String,
page: { type: Number, default: 1 }, page: { type: Number, default: 1 },
pageCount: Number pageCount: Number,
}) })
defineEmits(['update:page', 'imageLoaded']) defineEmits(['update:page', 'imageLoaded'])
@ -76,9 +92,18 @@ const previewUrl = computed(() => {
transition: all var(--transition); transition: all var(--transition);
} }
.nav-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); } .nav-btn:hover:not(:disabled) {
.nav-btn:disabled { opacity: 0.3; cursor: default; } background: var(--bg-hover);
.nav-btn svg { width: 16px; height: 16px; } color: var(--text);
}
.nav-btn:disabled {
opacity: 0.3;
cursor: default;
}
.nav-btn svg {
width: 16px;
height: 16px;
}
.preview-image-wrapper { .preview-image-wrapper {
background: var(--bg-elevated); background: var(--bg-elevated);

View file

@ -0,0 +1,2 @@
export { useFeatureFlagStore, type FeatureFlag } from './store'
export { useFeatureFlag } from './useFeatureFlag'

View file

@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useFeatureFlagStore } from './store'
const mockApiFetch = vi.fn()
vi.mock('../../shared/api/http', () => ({
apiFetch: (...args: unknown[]) => mockApiFetch(...args),
}))
describe('useFeatureFlagStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockApiFetch.mockReset()
})
it('starts unloaded with flags disabled', () => {
const store = useFeatureFlagStore()
expect(store.loaded).toBe(false)
expect(store.isEnabled('chunking')).toBe(false)
expect(store.isEnabled('disclaimer')).toBe(false)
})
it('enables chunking when engine is local', async () => {
mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'local' })
const store = useFeatureFlagStore()
await store.load()
expect(store.engine).toBe('local')
expect(store.loaded).toBe(true)
expect(store.isEnabled('chunking')).toBe(true)
})
it('disables chunking when engine is remote', async () => {
mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'remote' })
const store = useFeatureFlagStore()
await store.load()
expect(store.engine).toBe('remote')
expect(store.isEnabled('chunking')).toBe(false)
})
it('enables disclaimer when deploymentMode is huggingface', async () => {
mockApiFetch.mockResolvedValue({
status: 'ok',
engine: 'local',
deploymentMode: 'huggingface',
})
const store = useFeatureFlagStore()
await store.load()
expect(store.deploymentMode).toBe('huggingface')
expect(store.isEnabled('disclaimer')).toBe(true)
})
it('disables disclaimer when deploymentMode is self-hosted', async () => {
mockApiFetch.mockResolvedValue({
status: 'ok',
engine: 'local',
deploymentMode: 'self-hosted',
})
const store = useFeatureFlagStore()
await store.load()
expect(store.isEnabled('disclaimer')).toBe(false)
})
it('defaults deploymentMode to self-hosted when missing', async () => {
mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'local' })
const store = useFeatureFlagStore()
await store.load()
expect(store.deploymentMode).toBe('self-hosted')
expect(store.isEnabled('disclaimer')).toBe(false)
})
it('handles health endpoint failure gracefully', async () => {
mockApiFetch.mockRejectedValue(new Error('Network error'))
const store = useFeatureFlagStore()
await store.load()
expect(store.loaded).toBe(true)
expect(store.error).toBe('Network error')
expect(store.isEnabled('chunking')).toBe(false)
expect(store.isEnabled('disclaimer')).toBe(false)
})
})

View file

@ -0,0 +1,68 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiFetch } from '../../shared/api/http'
type ConversionEngine = 'local' | 'remote'
type DeploymentMode = 'self-hosted' | 'huggingface'
interface HealthResponse {
status: string
engine: ConversionEngine
deploymentMode?: DeploymentMode
}
export type FeatureFlag = 'chunking' | 'disclaimer'
interface FeatureFlagDef {
description: string
isEnabled: (ctx: FeatureFlagContext) => boolean
}
interface FeatureFlagContext {
engine: ConversionEngine | null
deploymentMode: DeploymentMode | null
}
const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = {
chunking: {
description: 'Document chunking for RAG preparation',
isEnabled: (ctx) => ctx.engine === 'local',
},
disclaimer: {
description: 'Show shared-instance disclaimer banner',
isEnabled: (ctx) => ctx.deploymentMode === 'huggingface',
},
}
export const useFeatureFlagStore = defineStore('feature-flags', () => {
const engine = ref<ConversionEngine | null>(null)
const deploymentMode = ref<DeploymentMode | null>(null)
const loaded = ref(false)
const error = ref<string | null>(null)
const context = computed<FeatureFlagContext>(() => ({
engine: engine.value,
deploymentMode: deploymentMode.value,
}))
function isEnabled(flag: FeatureFlag): boolean {
if (!loaded.value) return false
const def = featureRegistry[flag]
return def.isEnabled(context.value)
}
async function load(): Promise<void> {
try {
const data = await apiFetch<HealthResponse>('/api/health')
engine.value = data.engine
deploymentMode.value = data.deploymentMode ?? 'self-hosted'
loaded.value = true
error.value = null
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load feature flags'
loaded.value = true
}
}
return { engine, deploymentMode, loaded, error, isEnabled, load }
})

View file

@ -0,0 +1,28 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useFeatureFlag } from './useFeatureFlag'
import { useFeatureFlagStore } from './store'
vi.mock('@/shared/api/http', () => ({ apiFetch: vi.fn() }))
describe('useFeatureFlag', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('returns false before flags are loaded', () => {
const flag = useFeatureFlag('chunking')
expect(flag.value).toBe(false)
})
it('returns reactive value matching store state', () => {
const store = useFeatureFlagStore()
store.$patch({ loaded: true, engine: 'local' })
const flag = useFeatureFlag('chunking')
expect(flag.value).toBe(true)
store.$patch({ engine: 'remote' })
expect(flag.value).toBe(false)
})
})

View file

@ -0,0 +1,7 @@
import { computed } from 'vue'
import { useFeatureFlagStore, type FeatureFlag } from './store'
export function useFeatureFlag(flag: FeatureFlag) {
const store = useFeatureFlagStore()
return computed(() => store.isEnabled(flag))
}

View file

@ -13,7 +13,16 @@
<div class="item-main"> <div class="item-main">
<div class="item-header"> <div class="item-header">
<span class="item-filename">{{ analysis.documentFilename }}</span> <span class="item-filename">{{ analysis.documentFilename }}</span>
<span class="item-status" :class="statusClass(analysis.status)"> <span
v-if="analysis.status === 'COMPLETED'"
class="item-status status-completed"
:title="analysis.status"
>
<svg class="status-dot" viewBox="0 0 8 8" fill="currentColor">
<circle cx="4" cy="4" r="4" />
</svg>
</span>
<span v-else class="item-status" :class="statusClass(analysis.status)">
{{ analysis.status }} {{ analysis.status }}
</span> </span>
</div> </div>
@ -23,7 +32,13 @@
</div> </div>
</div> </div>
<button class="item-delete" @click.stop="store.remove(analysis.id)" title="Delete"> <button class="item-delete" @click.stop="store.remove(analysis.id)" title="Delete">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg> <svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</button> </button>
</div> </div>
</div> </div>
@ -49,7 +64,7 @@ function statusClass(status: string) {
'status-pending': status === 'PENDING', 'status-pending': status === 'PENDING',
'status-running': status === 'RUNNING', 'status-running': status === 'RUNNING',
'status-completed': status === 'COMPLETED', 'status-completed': status === 'COMPLETED',
'status-failed': status === 'FAILED' 'status-failed': status === 'FAILED',
} }
} }
@ -81,22 +96,31 @@ function duration(analysis: Analysis) {
.history-items { .history-items {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 6px;
padding: 12px;
} }
.history-item { .history-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 14px 20px; padding: 12px 16px;
border-bottom: 1px solid var(--border); background: var(--bg-surface);
transition: background var(--transition); border: 1px solid var(--border);
border-radius: var(--radius-sm);
transition: all 150ms ease;
cursor: pointer; cursor: pointer;
} }
.history-item:hover { background: var(--bg-hover); } .history-item:hover {
border-color: var(--accent);
background: var(--bg-elevated);
}
.item-main { flex: 1; min-width: 0; } .item-main {
flex: 1;
min-width: 0;
}
.item-header { .item-header {
display: flex; display: flex;
@ -122,10 +146,28 @@ function duration(analysis: Analysis) {
flex-shrink: 0; flex-shrink: 0;
} }
.status-pending { background: rgba(234, 179, 8, 0.15); color: var(--warning); } .status-dot {
.status-running { background: rgba(59, 130, 246, 0.15); color: var(--info); } width: 8px;
.status-completed { background: rgba(34, 197, 94, 0.15); color: var(--success); } height: 8px;
.status-failed { background: rgba(239, 68, 68, 0.15); color: var(--error); } }
.status-pending {
background: rgba(234, 179, 8, 0.15);
color: var(--warning);
}
.status-running {
background: rgba(59, 130, 246, 0.15);
color: var(--info);
}
.status-completed {
background: none;
color: var(--success);
padding: 0;
}
.status-failed {
background: rgba(239, 68, 68, 0.15);
color: var(--error);
}
.item-meta { .item-meta {
font-size: 12px; font-size: 12px;
@ -146,7 +188,15 @@ function duration(analysis: Analysis) {
transition: all var(--transition); transition: all var(--transition);
} }
.history-item:hover .item-delete { opacity: 1; } .history-item:hover .item-delete {
.item-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); } opacity: 1;
.item-delete svg { width: 16px; height: 16px; } }
.item-delete:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.1);
}
.item-delete svg {
width: 16px;
height: 16px;
}
</style> </style>

View file

@ -2,13 +2,29 @@ import { defineStore } from 'pinia'
import { ref, watch, watchEffect } from 'vue' import { ref, watch, watchEffect } from 'vue'
import type { Locale, Theme } from '../../shared/types' import type { Locale, Theme } from '../../shared/types'
function safeGetItem(key: string): string | null {
try {
return localStorage.getItem(key)
} catch {
return null
}
}
function safeSetItem(key: string, value: string): void {
try {
localStorage.setItem(key, value)
} catch {
// localStorage unavailable (private browsing, quota exceeded)
}
}
export const useSettingsStore = defineStore('settings', () => { export const useSettingsStore = defineStore('settings', () => {
const apiUrl = ref('http://localhost:8000') const apiUrl = ref('http://localhost:8000')
const theme = ref<Theme>((localStorage.getItem('docling-theme') as Theme) || 'dark') const theme = ref<Theme>((safeGetItem('docling-theme') as Theme) || 'dark')
const locale = ref<Locale>((localStorage.getItem('docling-locale') as Locale) || 'fr') const locale = ref<Locale>((safeGetItem('docling-locale') as Locale) || 'fr')
watch(theme, (v) => localStorage.setItem('docling-theme', v)) watch(theme, (v) => safeSetItem('docling-theme', v))
watch(locale, (v) => localStorage.setItem('docling-locale', v)) watch(locale, (v) => safeSetItem('docling-locale', v))
watchEffect(() => { watchEffect(() => {
document.documentElement.classList.toggle('light', theme.value === 'light') document.documentElement.classList.toggle('light', theme.value === 'light')

View file

@ -15,14 +15,18 @@
<div class="setting-group"> <div class="setting-group">
<label class="setting-label">{{ t('settings.language') }}</label> <label class="setting-label">{{ t('settings.language') }}</label>
<div class="setting-toggle"> <div class="setting-toggle">
<button :class="{ active: store.locale === 'fr' }" @click="store.setLocale('fr')">FR</button> <button :class="{ active: store.locale === 'fr' }" @click="store.setLocale('fr')">
<button :class="{ active: store.locale === 'en' }" @click="store.setLocale('en')">EN</button> FR
</button>
<button :class="{ active: store.locale === 'en' }" @click="store.setLocale('en')">
EN
</button>
</div> </div>
</div> </div>
<div class="setting-group"> <div class="setting-group">
<label class="setting-label">{{ t('settings.version') }}</label> <label class="setting-label">{{ t('settings.version') }}</label>
<span class="setting-value">0.1.0</span> <span class="setting-value">{{ version }}</span>
</div> </div>
</div> </div>
</template> </template>
@ -31,6 +35,7 @@
import { useSettingsStore } from '../store' import { useSettingsStore } from '../store'
import { useI18n } from '../../../shared/i18n' import { useI18n } from '../../../shared/i18n'
const version = __APP_VERSION__
const store = useSettingsStore() const store = useSettingsStore()
const { t } = useI18n() const { t } = useI18n()
</script> </script>
@ -39,14 +44,14 @@ const { t } = useI18n()
.settings-panel { .settings-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 24px;
max-width: 500px; max-width: 320px;
} }
.setting-group { .setting-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 8px;
} }
.setting-label { .setting-label {
@ -58,33 +63,36 @@ const { t } = useI18n()
} }
.setting-toggle { .setting-toggle {
display: flex; display: inline-flex;
gap: 2px;
background: var(--bg-elevated); background: var(--bg-elevated);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
overflow: hidden; padding: 3px;
width: fit-content;
} }
.setting-toggle button { .setting-toggle button {
flex: 1; padding: 6px 16px;
padding: 8px 16px;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-muted);
background: transparent; background: transparent;
border: none; border: none;
border-radius: calc(var(--radius-sm) - 2px);
cursor: pointer; cursor: pointer;
transition: all var(--transition); transition: all 200ms ease;
} }
.setting-toggle button:hover { .setting-toggle button:hover {
color: var(--text); color: var(--text-secondary);
background: var(--bg-hover); background: var(--bg-hover);
} }
.setting-toggle button.active { .setting-toggle button.active {
background: var(--accent-muted); background: var(--accent-muted);
color: var(--accent); color: var(--accent);
font-weight: 600;
} }
.setting-input { .setting-input {

View file

@ -8,14 +8,14 @@
{{ t('history.emptyDocs') }} {{ t('history.emptyDocs') }}
</div> </div>
<div v-else class="doc-items"> <div v-else class="doc-items">
<div <div v-for="doc in docStore.documents" :key="doc.id" class="doc-row">
v-for="doc in docStore.documents"
:key="doc.id"
class="doc-row"
>
<div class="doc-row-info"> <div class="doc-row-info">
<svg class="doc-row-icon" viewBox="0 0 20 20" fill="currentColor"> <svg class="doc-row-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/> <path
fill-rule="evenodd"
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
clip-rule="evenodd"
/>
</svg> </svg>
<div class="doc-row-meta"> <div class="doc-row-meta">
<span class="doc-row-name">{{ doc.filename }}</span> <span class="doc-row-name">{{ doc.filename }}</span>
@ -27,7 +27,13 @@
</div> </div>
</div> </div>
<button class="doc-row-delete" @click="docStore.remove(doc.id)"> <button class="doc-row-delete" @click="docStore.remove(doc.id)">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg> <svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</button> </button>
</div> </div>
</div> </div>
@ -89,19 +95,25 @@ onMounted(() => {
.doc-items { .doc-items {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 6px;
padding: 12px;
} }
.doc-row { .doc-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 14px 20px; padding: 12px 16px;
border-bottom: 1px solid var(--border); background: var(--bg-surface);
transition: background var(--transition); border: 1px solid var(--border);
border-radius: var(--radius-sm);
transition: all 150ms ease;
} }
.doc-row:hover { background: var(--bg-hover); } .doc-row:hover {
border-color: var(--accent);
background: var(--bg-elevated);
}
.doc-row-info { .doc-row-info {
display: flex; display: flex;
@ -152,7 +164,15 @@ onMounted(() => {
transition: all var(--transition); transition: all var(--transition);
} }
.doc-row:hover .doc-row-delete { opacity: 1; } .doc-row:hover .doc-row-delete {
.doc-row-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); } opacity: 1;
.doc-row-delete svg { width: 16px; height: 16px; } }
.doc-row-delete:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.1);
}
.doc-row-delete svg {
width: 16px;
height: 16px;
}
</style> </style>

View file

@ -2,9 +2,7 @@
<div class="home-page"> <div class="home-page">
<div class="home-center"> <div class="home-center">
<div class="hero"> <div class="hero">
<div class="hero-icon"> <img src="/logo.png" alt="Docling Studio" class="hero-logo" />
<span class="hero-d">D</span>
</div>
<h1 class="hero-title">{{ t('home.title') }}</h1> <h1 class="hero-title">{{ t('home.title') }}</h1>
<p class="hero-subtitle">{{ t('home.subtitle') }}</p> <p class="hero-subtitle">{{ t('home.subtitle') }}</p>
</div> </div>
@ -17,10 +15,24 @@
<!-- Stats --> <!-- Stats -->
<div class="home-stats" v-if="docCount > 0 || analysisCount > 0"> <div class="home-stats" v-if="docCount > 0 || analysisCount > 0">
<div class="stat-card" @click="$router.push('/documents')"> <div class="stat-card" @click="$router.push('/documents')">
<svg class="stat-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
clip-rule="evenodd"
/>
</svg>
<div class="stat-value">{{ docCount }}</div> <div class="stat-value">{{ docCount }}</div>
<div class="stat-label">{{ t('home.documents') }}</div> <div class="stat-label">{{ t('home.documents') }}</div>
</div> </div>
<div class="stat-card" @click="$router.push('/history')"> <div class="stat-card" @click="$router.push('/history')">
<svg class="stat-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
clip-rule="evenodd"
/>
</svg>
<div class="stat-value">{{ analysisCount }}</div> <div class="stat-value">{{ analysisCount }}</div>
<div class="stat-label">{{ t('home.analyses') }}</div> <div class="stat-label">{{ t('home.analyses') }}</div>
</div> </div>
@ -37,14 +49,22 @@
@click="openInStudio(doc)" @click="openInStudio(doc)"
> >
<svg class="recent-icon" viewBox="0 0 20 20" fill="currentColor"> <svg class="recent-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/> <path
fill-rule="evenodd"
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
clip-rule="evenodd"
/>
</svg> </svg>
<div class="recent-meta"> <div class="recent-meta">
<span class="recent-name">{{ doc.filename }}</span> <span class="recent-name">{{ doc.filename }}</span>
<span class="recent-detail">{{ doc.pageCount ? doc.pageCount + ' pages' : '' }}</span> <span class="recent-detail">{{ doc.pageCount ? doc.pageCount + ' pages' : '' }}</span>
</div> </div>
<svg class="recent-arrow" viewBox="0 0 20 20" fill="currentColor"> <svg class="recent-arrow" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/> <path
fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg> </svg>
</button> </button>
</div> </div>
@ -114,21 +134,12 @@ onMounted(() => {
text-align: center; text-align: center;
} }
.hero-icon { .hero-logo {
width: 72px;
height: 72px;
object-fit: contain;
margin-bottom: 4px; margin-bottom: 4px;
}
.hero-d {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
background: var(--accent);
color: white;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
font-weight: 700;
font-size: 26px;
} }
.hero-title { .hero-title {
@ -171,10 +182,21 @@ onMounted(() => {
} }
.stat-card:hover { .stat-card:hover {
border-color: var(--border-light); border-color: var(--accent);
background: var(--bg-elevated); background: var(--bg-elevated);
} }
.stat-icon {
width: 20px;
height: 20px;
color: var(--text-muted);
margin-bottom: 4px;
}
.stat-card:hover .stat-icon {
color: var(--accent);
}
.stat-value { .stat-value {
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 700;

View file

@ -2,9 +2,7 @@
<!-- STATE 1: No document selected Import view --> <!-- STATE 1: No document selected Import view -->
<div v-if="!selectedDoc" class="import-page"> <div v-if="!selectedDoc" class="import-page">
<div class="import-center"> <div class="import-center">
<div class="import-logo"> <img src="/logo.png" alt="Docling Studio" class="import-logo-img" />
<span class="logo-icon">D</span>
</div>
<h1 class="import-title">{{ t('studio.title') }}</h1> <h1 class="import-title">{{ t('studio.title') }}</h1>
<p class="import-subtitle">{{ t('studio.subtitle') }}</p> <p class="import-subtitle">{{ t('studio.subtitle') }}</p>
<DocumentUpload /> <DocumentUpload />
@ -27,6 +25,13 @@
:class="{ active: mode === 'configurer' }" :class="{ active: mode === 'configurer' }"
@click="mode = 'configurer'" @click="mode = 'configurer'"
> >
<svg class="toggle-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
clip-rule="evenodd"
/>
</svg>
{{ t('studio.configure') }} {{ t('studio.configure') }}
</button> </button>
<button <button
@ -35,13 +40,38 @@
@click="mode = 'verifier'" @click="mode = 'verifier'"
:disabled="!analysisStore.currentAnalysis" :disabled="!analysisStore.currentAnalysis"
> >
<svg class="toggle-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
{{ t('studio.verify') }} {{ t('studio.verify') }}
</button> </button>
<button
v-if="chunkingEnabled"
class="toggle-btn"
:class="{ active: mode === 'preparer' }"
@click="mode = 'preparer'"
:disabled="!analysisStore.currentAnalysis"
>
<svg class="toggle-icon" viewBox="0 0 20 20" fill="currentColor">
<path
d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
/>
</svg>
{{ t('studio.prepare') }}
</button>
</div> </div>
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
<button class="topbar-btn" @click="addMore"> <button class="topbar-btn" @click="addMore">
<svg viewBox="0 0 20 20" fill="currentColor" class="btn-icon"><path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"/></svg> <svg viewBox="0 0 20 20" fill="currentColor" class="btn-icon">
<path
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
/>
</svg>
{{ t('studio.addFiles') }} {{ t('studio.addFiles') }}
</button> </button>
<button <button
@ -51,7 +81,13 @@
v-if="mode === 'configurer'" v-if="mode === 'configurer'"
> >
<div v-if="analysisStore.running" class="spinner-sm" /> <div v-if="analysisStore.running" class="spinner-sm" />
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="btn-icon"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/></svg> <svg v-else viewBox="0 0 20 20" fill="currentColor" class="btn-icon">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z"
clip-rule="evenodd"
/>
</svg>
{{ analysisStore.running ? t('studio.analyzing') : t('studio.run') }} {{ analysisStore.running ? t('studio.analyzing') : t('studio.run') }}
</button> </button>
</div> </div>
@ -60,7 +96,13 @@
<!-- Document info bar --> <!-- Document info bar -->
<div class="doc-infobar"> <div class="doc-infobar">
<div class="doc-info-left"> <div class="doc-info-left">
<svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg> <svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
clip-rule="evenodd"
/>
</svg>
<span class="doc-filename">{{ selectedDoc.filename }}</span> <span class="doc-filename">{{ selectedDoc.filename }}</span>
<span class="doc-status-chip loaded">{{ t('studio.loaded') }}</span> <span class="doc-status-chip loaded">{{ t('studio.loaded') }}</span>
</div> </div>
@ -86,7 +128,13 @@
<div class="pdf-viewer-panel"> <div class="pdf-viewer-panel">
<div class="pdf-nav-bar"> <div class="pdf-nav-bar">
<button class="pdf-nav-btn" :disabled="currentPage <= 1" @click="currentPage--"> <button class="pdf-nav-btn" :disabled="currentPage <= 1" @click="currentPage--">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg> <svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</button> </button>
<div class="pdf-page-input-wrap"> <div class="pdf-page-input-wrap">
<input <input
@ -99,8 +147,18 @@
/> />
</div> </div>
<span class="pdf-page-total">/ {{ selectedDoc.pageCount || '?' }}</span> <span class="pdf-page-total">/ {{ selectedDoc.pageCount || '?' }}</span>
<button class="pdf-nav-btn" :disabled="!selectedDoc.pageCount || currentPage >= selectedDoc.pageCount" @click="currentPage++"> <button
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg> class="pdf-nav-btn"
:disabled="!selectedDoc.pageCount || currentPage >= selectedDoc.pageCount"
@click="currentPage++"
>
<svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
</button> </button>
<span class="pdf-separator" /> <span class="pdf-separator" />
<span class="pdf-zoom">100%</span> <span class="pdf-zoom">100%</span>
@ -111,7 +169,14 @@
:class="{ active: visualMode }" :class="{ active: visualMode }"
@click="visualMode = !visualMode" @click="visualMode = !visualMode"
> >
<svg viewBox="0 0 20 20" fill="currentColor" class="btn-icon"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg> <svg viewBox="0 0 20 20" fill="currentColor" class="btn-icon">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
<path
fill-rule="evenodd"
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
clip-rule="evenodd"
/>
</svg>
{{ t('studio.visual') }} {{ t('studio.visual') }}
</button> </button>
</template> </template>
@ -127,11 +192,12 @@
@load="onPdfImageLoad" @load="onPdfImageLoad"
/> />
<BboxOverlay <BboxOverlay
v-if="visualMode && hasAnalysisResults" v-if="(visualMode || mode === 'preparer') && hasAnalysisResults"
ref="bboxOverlayRef" ref="bboxOverlayRef"
:image-el="pdfImageRef" :image-el="pdfImageRef"
:page-data="currentPageData" :page-data="currentPageData"
:highlighted-index="highlightedElementIndex" :highlighted-index="highlightedElementIndex"
:highlighted-bboxes="highlightedChunkBboxes"
@highlight-element="highlightedElementIndex = $event" @highlight-element="highlightedElementIndex = $event"
/> />
</div> </div>
@ -139,10 +205,7 @@
</div> </div>
<!-- Resize handle --> <!-- Resize handle -->
<div <div class="resize-handle" @mousedown="onResizeStart">
class="resize-handle"
@mousedown="onResizeStart"
>
<div class="resize-grip" /> <div class="resize-grip" />
</div> </div>
@ -170,16 +233,26 @@
<span class="toggle-switch" /> <span class="toggle-switch" />
<span class="toggle-text">{{ t('config.ocr') }}</span> <span class="toggle-text">{{ t('config.ocr') }}</span>
</label> </label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.ocrHint') }}</span>?</span> <span class="config-hint"
><span class="config-tooltip">{{ t('config.ocrHint') }}</span
>?</span
>
</div> </div>
<div class="config-toggle-row"> <div class="config-toggle-row">
<label class="toggle-label"> <label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_table_structure" class="toggle-input" /> <input
type="checkbox"
v-model="pipelineOptions.do_table_structure"
class="toggle-input"
/>
<span class="toggle-switch" /> <span class="toggle-switch" />
<span class="toggle-text">{{ t('config.tableStructure') }}</span> <span class="toggle-text">{{ t('config.tableStructure') }}</span>
</label> </label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.tableStructureHint') }}</span>?</span> <span class="config-hint"
><span class="config-tooltip">{{ t('config.tableStructureHint') }}</span
>?</span
>
</div> </div>
<div class="config-sub-option" v-if="pipelineOptions.do_table_structure"> <div class="config-sub-option" v-if="pipelineOptions.do_table_structure">
@ -197,20 +270,34 @@
<div class="config-toggle-row"> <div class="config-toggle-row">
<label class="toggle-label"> <label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_code_enrichment" class="toggle-input" /> <input
type="checkbox"
v-model="pipelineOptions.do_code_enrichment"
class="toggle-input"
/>
<span class="toggle-switch" /> <span class="toggle-switch" />
<span class="toggle-text">{{ t('config.codeEnrichment') }}</span> <span class="toggle-text">{{ t('config.codeEnrichment') }}</span>
</label> </label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.codeEnrichmentHint') }}</span>?</span> <span class="config-hint"
><span class="config-tooltip">{{ t('config.codeEnrichmentHint') }}</span
>?</span
>
</div> </div>
<div class="config-toggle-row"> <div class="config-toggle-row">
<label class="toggle-label"> <label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_formula_enrichment" class="toggle-input" /> <input
type="checkbox"
v-model="pipelineOptions.do_formula_enrichment"
class="toggle-input"
/>
<span class="toggle-switch" /> <span class="toggle-switch" />
<span class="toggle-text">{{ t('config.formulaEnrichment') }}</span> <span class="toggle-text">{{ t('config.formulaEnrichment') }}</span>
</label> </label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.formulaEnrichmentHint') }}</span>?</span> <span class="config-hint"
><span class="config-tooltip">{{ t('config.formulaEnrichmentHint') }}</span
>?</span
>
</div> </div>
</div> </div>
@ -220,41 +307,72 @@
<div class="config-toggle-row"> <div class="config-toggle-row">
<label class="toggle-label"> <label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_picture_classification" class="toggle-input" /> <input
type="checkbox"
v-model="pipelineOptions.do_picture_classification"
class="toggle-input"
/>
<span class="toggle-switch" /> <span class="toggle-switch" />
<span class="toggle-text">{{ t('config.pictureClassification') }}</span> <span class="toggle-text">{{ t('config.pictureClassification') }}</span>
</label> </label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.pictureClassificationHint') }}</span>?</span> <span class="config-hint"
><span class="config-tooltip">{{ t('config.pictureClassificationHint') }}</span
>?</span
>
</div> </div>
<div class="config-toggle-row"> <div class="config-toggle-row">
<label class="toggle-label"> <label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_picture_description" class="toggle-input" /> <input
type="checkbox"
v-model="pipelineOptions.do_picture_description"
class="toggle-input"
/>
<span class="toggle-switch" /> <span class="toggle-switch" />
<span class="toggle-text">{{ t('config.pictureDescription') }}</span> <span class="toggle-text">{{ t('config.pictureDescription') }}</span>
</label> </label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.pictureDescriptionHint') }}</span>?</span> <span class="config-hint"
><span class="config-tooltip">{{ t('config.pictureDescriptionHint') }}</span
>?</span
>
</div> </div>
<div class="config-toggle-row"> <div class="config-toggle-row">
<label class="toggle-label"> <label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.generate_picture_images" class="toggle-input" /> <input
type="checkbox"
v-model="pipelineOptions.generate_picture_images"
class="toggle-input"
/>
<span class="toggle-switch" /> <span class="toggle-switch" />
<span class="toggle-text">{{ t('config.generatePictureImages') }}</span> <span class="toggle-text">{{ t('config.generatePictureImages') }}</span>
</label> </label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.generatePictureImagesHint') }}</span>?</span> <span class="config-hint"
><span class="config-tooltip">{{ t('config.generatePictureImagesHint') }}</span
>?</span
>
</div> </div>
<div class="config-toggle-row"> <div class="config-toggle-row">
<label class="toggle-label"> <label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.generate_page_images" class="toggle-input" /> <input
type="checkbox"
v-model="pipelineOptions.generate_page_images"
class="toggle-input"
/>
<span class="toggle-switch" /> <span class="toggle-switch" />
<span class="toggle-text">{{ t('config.generatePageImages') }}</span> <span class="toggle-text">{{ t('config.generatePageImages') }}</span>
</label> </label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.generatePageImagesHint') }}</span>?</span> <span class="config-hint"
><span class="config-tooltip">{{ t('config.generatePageImagesHint') }}</span
>?</span
>
</div> </div>
<div class="config-sub-option" v-if="pipelineOptions.generate_picture_images || pipelineOptions.generate_page_images"> <div
class="config-sub-option"
v-if="pipelineOptions.generate_picture_images || pipelineOptions.generate_page_images"
>
<label class="config-label-sm">{{ t('config.imagesScale') }}</label> <label class="config-label-sm">{{ t('config.imagesScale') }}</label>
<select class="config-select" v-model.number="pipelineOptions.images_scale"> <select class="config-select" v-model.number="pipelineOptions.images_scale">
<option :value="0.5">0.5x</option> <option :value="0.5">0.5x</option>
@ -280,6 +398,14 @@
@highlight-element="highlightedElementIndex = $event" @highlight-element="highlightedElementIndex = $event"
/> />
</div> </div>
<!-- PREPARER MODE (feature-flipped) -->
<div v-if="mode === 'preparer' && chunkingEnabled" class="prepare-panel">
<ChunkPanel
:current-page="currentPage"
@highlight-bboxes="highlightedChunkBboxes = $event"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -293,20 +419,24 @@ import { useAnalysisStore } from '../features/analysis/store'
import { DocumentUpload, DocumentList } from '../features/document/index' import { DocumentUpload, DocumentList } from '../features/document/index'
import { ResultTabs } from '../features/analysis/index' import { ResultTabs } from '../features/analysis/index'
import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue' import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue'
import { ChunkPanel } from '../features/chunking'
import { useFeatureFlag } from '../features/feature-flags'
import { getPreviewUrl } from '../features/document/api' import { getPreviewUrl } from '../features/document/api'
import { useI18n } from '../shared/i18n' import { useI18n } from '../shared/i18n'
import type { PipelineOptions } from '../shared/types' import type { ChunkBbox, PipelineOptions } from '../shared/types'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const documentStore = useDocumentStore() const documentStore = useDocumentStore()
const analysisStore = useAnalysisStore() const analysisStore = useAnalysisStore()
const { t } = useI18n() const { t } = useI18n()
const chunkingEnabled = useFeatureFlag('chunking')
const mode = ref('configurer') const mode = ref('configurer')
const currentPage = ref(1) const currentPage = ref(1)
const visualMode = ref(false) const visualMode = ref(false)
const highlightedElementIndex = ref(-1) const highlightedElementIndex = ref(-1)
const highlightedChunkBboxes = ref<ChunkBbox[]>([])
const pdfImageRef = ref<HTMLImageElement | null>(null) const pdfImageRef = ref<HTMLImageElement | null>(null)
const bboxOverlayRef = ref<InstanceType<typeof BboxOverlay> | null>(null) const bboxOverlayRef = ref<InstanceType<typeof BboxOverlay> | null>(null)
@ -356,12 +486,14 @@ const pipelineOptions = reactive<PipelineOptions>({
}) })
const hasAnalysisResults = computed(() => { const hasAnalysisResults = computed(() => {
return analysisStore.currentAnalysis?.status === 'COMPLETED' && analysisStore.currentPages?.length > 0 return (
analysisStore.currentAnalysis?.status === 'COMPLETED' && analysisStore.currentPages?.length > 0
)
}) })
const currentPageData = computed(() => { const currentPageData = computed(() => {
if (!analysisStore.currentPages) return null if (!analysisStore.currentPages) return null
return analysisStore.currentPages.find(p => p.page_number === currentPage.value) || null return analysisStore.currentPages.find((p) => p.page_number === currentPage.value) || null
}) })
function onPdfImageLoad() { function onPdfImageLoad() {
@ -369,7 +501,7 @@ function onPdfImageLoad() {
} }
const selectedDoc = computed(() => { const selectedDoc = computed(() => {
return documentStore.documents.find(d => d.id === documentStore.selectedId) return documentStore.documents.find((d) => d.id === documentStore.selectedId)
}) })
const previewUrl = computed(() => { const previewUrl = computed(() => {
@ -393,13 +525,26 @@ function addMore() {
documentStore.selectedId = null documentStore.selectedId = null
} }
// Auto-switch to verifier when analysis completes + refresh document data (pageCount) // Clear highlights when switching modes or pages
watch(() => analysisStore.currentAnalysis?.status, (status) => { watch(mode, () => {
if (status === 'COMPLETED') { highlightedElementIndex.value = -1
mode.value = 'verifier' highlightedChunkBboxes.value = []
documentStore.load()
}
}) })
watch(currentPage, () => {
highlightedElementIndex.value = -1
highlightedChunkBboxes.value = []
})
// Auto-switch to verifier when analysis completes + refresh document data (pageCount)
watch(
() => analysisStore.currentAnalysis?.status,
(status) => {
if (status === 'COMPLETED') {
mode.value = 'verifier'
documentStore.load()
}
},
)
onMounted(async () => { onMounted(async () => {
await documentStore.load() await documentStore.load()
@ -447,21 +592,12 @@ onBeforeUnmount(() => {
gap: 20px; gap: 20px;
} }
.import-logo { .import-logo-img {
width: 64px;
height: 64px;
object-fit: contain;
margin-bottom: 8px; margin-bottom: 8px;
}
.import-logo .logo-icon {
width: 48px;
height: 48px;
background: var(--accent);
color: white;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius); border-radius: var(--radius);
font-weight: 700;
font-size: 22px;
} }
.import-title { .import-title {
@ -516,45 +652,72 @@ onBeforeUnmount(() => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; gap: 20px;
min-width: 0;
} }
.topbar-title { .topbar-title {
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: var(--text); color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
} }
.mode-toggle { .mode-toggle {
display: flex; display: flex;
gap: 2px;
background: var(--bg-elevated); background: var(--bg-elevated);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: 1px solid var(--border); border: 1px solid var(--border);
overflow: hidden; padding: 3px;
} }
.toggle-btn { .toggle-btn {
padding: 6px 16px; display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-muted);
background: transparent; background: transparent;
border: none; border: none;
border-radius: calc(var(--radius-sm) - 2px);
cursor: pointer; cursor: pointer;
transition: all var(--transition); transition: all 200ms ease;
}
.toggle-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
opacity: 0.6;
transition: opacity 200ms ease;
} }
.toggle-btn:hover:not(:disabled) { .toggle-btn:hover:not(:disabled) {
color: var(--text); color: var(--text-secondary);
background: var(--bg-hover);
}
.toggle-btn:hover:not(:disabled) .toggle-icon {
opacity: 0.8;
} }
.toggle-btn.active { .toggle-btn.active {
background: var(--bg); background: var(--accent-muted);
color: var(--text); color: var(--accent);
box-shadow: 0 1px 3px rgba(0,0,0,0.3); font-weight: 600;
}
.toggle-btn.active .toggle-icon {
opacity: 1;
} }
.toggle-btn:disabled { .toggle-btn:disabled {
opacity: 0.4; opacity: 0.35;
cursor: not-allowed; cursor: not-allowed;
} }
@ -607,7 +770,7 @@ onBeforeUnmount(() => {
.spinner-sm { .spinner-sm {
width: 14px; width: 14px;
height: 14px; height: 14px;
border: 2px solid rgba(255,255,255,0.3); border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white; border-top-color: white;
border-radius: 50%; border-radius: 50%;
animation: spin 0.6s linear infinite; animation: spin 0.6s linear infinite;
@ -679,8 +842,12 @@ onBeforeUnmount(() => {
border-radius: 50%; border-radius: 50%;
} }
.info-dot.success { background: var(--success); } .info-dot.success {
.info-dot.error { background: var(--error); } background: var(--success);
}
.info-dot.error {
background: var(--error);
}
.spinner-xs { .spinner-xs {
width: 12px; width: 12px;
@ -779,9 +946,18 @@ onBeforeUnmount(() => {
transition: all var(--transition); transition: all var(--transition);
} }
.pdf-nav-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); } .pdf-nav-btn:hover:not(:disabled) {
.pdf-nav-btn:disabled { opacity: 0.3; cursor: default; } background: var(--bg-hover);
.pdf-nav-btn svg { width: 16px; height: 16px; } color: var(--text);
}
.pdf-nav-btn:disabled {
opacity: 0.3;
cursor: default;
}
.pdf-nav-btn svg {
width: 16px;
height: 16px;
}
.pdf-page-input-wrap { .pdf-page-input-wrap {
display: flex; display: flex;
@ -880,7 +1056,7 @@ onBeforeUnmount(() => {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
display: block; display: block;
box-shadow: 0 2px 20px rgba(0,0,0,0.4); box-shadow: 0 2px 20px rgba(0, 0, 0, 0.4);
border-radius: 2px; border-radius: 2px;
} }
@ -1119,12 +1295,17 @@ onBeforeUnmount(() => {
} }
/* Verify panel */ /* Verify panel */
.verify-panel { .verify-panel,
.prepare-panel {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin {
to {
transform: rotate(360deg);
}
}
</style> </style>

View file

@ -15,9 +15,12 @@ describe('apiFetch', () => {
await apiFetch('/api/test') await apiFetch('/api/test')
expect(spy).toHaveBeenCalledWith('/api/test', expect.objectContaining({ expect(spy).toHaveBeenCalledWith(
headers: expect.objectContaining({ 'Content-Type': 'application/json' }), '/api/test',
})) expect.objectContaining({
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
}),
)
}) })
it('skips Content-Type when skipContentType is true', async () => { it('skips Content-Type when skipContentType is true', async () => {
@ -73,10 +76,13 @@ describe('apiFetch', () => {
const body = JSON.stringify({ documentId: '42' }) const body = JSON.stringify({ documentId: '42' })
await apiFetch('/api/analyses', { method: 'POST', body }) await apiFetch('/api/analyses', { method: 'POST', body })
expect(spy).toHaveBeenCalledWith('/api/analyses', expect.objectContaining({ expect(spy).toHaveBeenCalledWith(
method: 'POST', '/api/analyses',
body, expect.objectContaining({
})) method: 'POST',
body,
}),
)
}) })
it('merges custom headers with Content-Type', async () => { it('merges custom headers with Content-Type', async () => {

View file

@ -0,0 +1,149 @@
import { describe, it, expect } from 'vitest'
import { ref, nextTick } from 'vue'
import { usePagination } from './usePagination'
function makeItems(n: number): string[] {
return Array.from({ length: n }, (_, i) => `item-${i + 1}`)
}
describe('usePagination', () => {
it('returns all items when total fits in one page', () => {
const items = ref(makeItems(5))
const { paginatedItems, pageCount, page } = usePagination(items, { pageSize: 20 })
expect(paginatedItems.value).toHaveLength(5)
expect(pageCount.value).toBe(1)
expect(page.value).toBe(1)
})
it('slices items according to page size', () => {
const items = ref(makeItems(25))
const { paginatedItems, pageCount } = usePagination(items, { pageSize: 10 })
expect(paginatedItems.value).toHaveLength(10)
expect(paginatedItems.value[0]).toBe('item-1')
expect(pageCount.value).toBe(3)
})
it('navigates with next and prev', () => {
const items = ref(makeItems(30))
const { paginatedItems, page, next, prev } = usePagination(items, { pageSize: 10 })
next()
expect(page.value).toBe(2)
expect(paginatedItems.value[0]).toBe('item-11')
next()
expect(page.value).toBe(3)
expect(paginatedItems.value[0]).toBe('item-21')
prev()
expect(page.value).toBe(2)
expect(paginatedItems.value[0]).toBe('item-11')
})
it('clamps page within valid range', () => {
const items = ref(makeItems(10))
const { page, prev, next, goTo } = usePagination(items, { pageSize: 5 })
prev()
expect(page.value).toBe(1)
next()
next()
expect(page.value).toBe(2)
goTo(99)
expect(page.value).toBe(2)
goTo(0)
expect(page.value).toBe(1)
})
it('goTo navigates to a specific page', () => {
const items = ref(makeItems(50))
const { page, paginatedItems, goTo } = usePagination(items, { pageSize: 10 })
goTo(3)
expect(page.value).toBe(3)
expect(paginatedItems.value[0]).toBe('item-21')
goTo(5)
expect(page.value).toBe(5)
expect(paginatedItems.value[0]).toBe('item-41')
})
it('resets to page 1 when items change', async () => {
const items = ref(makeItems(30))
const { page, next } = usePagination(items, { pageSize: 10 })
next()
next()
expect(page.value).toBe(3)
items.value = makeItems(5)
await nextTick()
expect(page.value).toBe(1)
})
it('resets to page 1 when page size changes', async () => {
const items = ref(makeItems(50))
const { page, next, setPageSize } = usePagination(items, { pageSize: 10 })
next()
next()
expect(page.value).toBe(3)
setPageSize(20)
await nextTick()
expect(page.value).toBe(1)
})
it('handles last page with fewer items', () => {
const items = ref(makeItems(23))
const { paginatedItems, goTo } = usePagination(items, { pageSize: 10 })
goTo(3)
expect(paginatedItems.value).toHaveLength(3)
expect(paginatedItems.value[0]).toBe('item-21')
})
it('handles empty items', () => {
const items = ref<string[]>([])
const { paginatedItems, pageCount, totalItems } = usePagination(items)
expect(paginatedItems.value).toHaveLength(0)
expect(pageCount.value).toBe(1)
expect(totalItems.value).toBe(0)
})
it('exposes totalItems as reactive count', async () => {
const items = ref(makeItems(10))
const { totalItems } = usePagination(items)
expect(totalItems.value).toBe(10)
items.value = makeItems(42)
await nextTick()
expect(totalItems.value).toBe(42)
})
it('defaults page size to 20', () => {
const items = ref(makeItems(50))
const { pageSize, paginatedItems } = usePagination(items)
expect(pageSize.value).toBe(20)
expect(paginatedItems.value).toHaveLength(20)
})
it('ignores invalid page size', () => {
const items = ref(makeItems(10))
const { pageSize, setPageSize } = usePagination(items, { pageSize: 5 })
setPageSize(0)
expect(pageSize.value).toBe(5)
setPageSize(-10)
expect(pageSize.value).toBe(5)
})
})

View file

@ -0,0 +1,71 @@
import { ref, computed, watch, type Ref } from 'vue'
export interface PaginationOptions {
pageSize?: number
}
export interface PaginationReturn<T> {
page: Ref<number>
pageSize: Ref<number>
pageCount: Ref<number>
paginatedItems: Ref<T[]>
totalItems: Ref<number>
next: () => void
prev: () => void
goTo: (target: number) => void
setPageSize: (size: number) => void
}
export function usePagination<T>(
items: Ref<T[]>,
options: PaginationOptions = {},
): PaginationReturn<T> {
const page = ref(1)
const pageSize = ref(options.pageSize ?? 20)
const totalItems = computed(() => items.value.length)
const pageCount = computed(() => Math.max(1, Math.ceil(totalItems.value / pageSize.value)))
const paginatedItems = computed(() => {
const start = (page.value - 1) * pageSize.value
return items.value.slice(start, start + pageSize.value)
})
// Reset to page 1 when source data or page size changes
watch([items, pageSize], () => {
page.value = 1
})
function clamp(target: number): number {
return Math.max(1, Math.min(target, pageCount.value))
}
function goTo(target: number): void {
page.value = clamp(target)
}
function next(): void {
goTo(page.value + 1)
}
function prev(): void {
goTo(page.value - 1)
}
function setPageSize(size: number): void {
if (size > 0) pageSize.value = size
}
return {
page,
pageSize,
pageCount,
paginatedItems,
totalItems,
next,
prev,
goTo,
setPageSize,
}
}

View file

@ -20,7 +20,8 @@ const messages: Messages = {
// Home // Home
'home.title': 'Docling Studio', 'home.title': 'Docling Studio',
'home.subtitle': 'Analysez, explorez et validez la structure de vos documents PDF grâce à Docling.', 'home.subtitle':
'Analysez, explorez et validez la structure de vos documents PDF grâce à Docling.',
'home.documents': 'Documents', 'home.documents': 'Documents',
'home.analyses': 'Analyses', 'home.analyses': 'Analyses',
'home.recentDocs': 'Documents récents', 'home.recentDocs': 'Documents récents',
@ -45,26 +46,34 @@ const messages: Messages = {
'config.model': 'Modèle', 'config.model': 'Modèle',
'config.pipeline': 'Pipeline', 'config.pipeline': 'Pipeline',
'config.ocr': 'OCR', 'config.ocr': 'OCR',
'config.ocrHint': "Applique la reconnaissance optique de caractères sur les pages scannées ou les images intégrées. Indispensable pour les PDF non-natifs.", 'config.ocrHint':
'Applique la reconnaissance optique de caractères sur les pages scannées ou les images intégrées. Indispensable pour les PDF non-natifs.',
'config.tableStructure': 'Extraction des tableaux', 'config.tableStructure': 'Extraction des tableaux',
'config.tableStructureHint': "Détecte les tableaux dans le document et reconstruit leur structure lignes/colonnes via le modèle TableFormer, avec correspondance des cellules.", 'config.tableStructureHint':
'Détecte les tableaux dans le document et reconstruit leur structure lignes/colonnes via le modèle TableFormer, avec correspondance des cellules.',
'config.tableMode': 'Mode tableaux', 'config.tableMode': 'Mode tableaux',
'config.tableModeAccurate': 'Précis', 'config.tableModeAccurate': 'Précis',
'config.tableModeFast': 'Rapide', 'config.tableModeFast': 'Rapide',
'config.enrichment': 'Enrichissement', 'config.enrichment': 'Enrichissement',
'config.codeEnrichment': 'Code', 'config.codeEnrichment': 'Code',
'config.codeEnrichmentHint': "Active un modèle OCR spécialisé pour les blocs de code, préservant l'indentation et la syntaxe.", 'config.codeEnrichmentHint':
"Active un modèle OCR spécialisé pour les blocs de code, préservant l'indentation et la syntaxe.",
'config.formulaEnrichment': 'Formules', 'config.formulaEnrichment': 'Formules',
'config.formulaEnrichmentHint': "Reconnaît les formules mathématiques et les convertit en LaTeX via un modèle dédié.", 'config.formulaEnrichmentHint':
'Reconnaît les formules mathématiques et les convertit en LaTeX via un modèle dédié.',
'config.pictures': 'Images', 'config.pictures': 'Images',
'config.pictureClassification': 'Classification', 'config.pictureClassification': 'Classification',
'config.pictureClassificationHint': "Classe chaque image détectée par type (graphique, photo, diagramme, logo…) via un modèle de classification.", 'config.pictureClassificationHint':
'Classe chaque image détectée par type (graphique, photo, diagramme, logo…) via un modèle de classification.',
'config.pictureDescription': 'Description', 'config.pictureDescription': 'Description',
'config.pictureDescriptionHint': "Génère une description textuelle de chaque image via un Vision Language Model (VLM). Utile pour l'accessibilité et l'indexation.", 'config.pictureDescriptionHint':
"Génère une description textuelle de chaque image via un Vision Language Model (VLM). Utile pour l'accessibilité et l'indexation.",
'config.generatePictureImages': 'Extraire les images', 'config.generatePictureImages': 'Extraire les images',
'config.generatePictureImagesHint': "Extrait les images détectées du document et les sauvegarde en tant que fichiers séparés. Nécessaire pour l'export d'images.", 'config.generatePictureImagesHint':
"Extrait les images détectées du document et les sauvegarde en tant que fichiers séparés. Nécessaire pour l'export d'images.",
'config.generatePageImages': 'Images de pages', 'config.generatePageImages': 'Images de pages',
'config.generatePageImagesHint': "Rasterise chaque page du PDF en image. Utile pour la visualisation ou le post-traitement visuel.", 'config.generatePageImagesHint':
'Rasterise chaque page du PDF en image. Utile pour la visualisation ou le post-traitement visuel.',
'config.imagesScale': 'Échelle images', 'config.imagesScale': 'Échelle images',
'config.documents': 'Documents', 'config.documents': 'Documents',
@ -95,6 +104,23 @@ const messages: Messages = {
'history.emptyDocs': 'Aucun document. Importez un document depuis le Studio.', 'history.emptyDocs': 'Aucun document. Importez un document depuis le Studio.',
'history.open': 'Ouvrir', 'history.open': 'Ouvrir',
// Chunking
'studio.prepare': 'Préparer',
'chunking.settings': 'Chunking',
'chunking.chunkerType': 'Type de chunker',
'chunking.maxTokens': 'Tokens max',
'chunking.mergePeers': 'Fusionner les pairs',
'chunking.repeatTableHeader': 'Répéter en-têtes tableaux',
'chunking.run': 'Chunker',
'chunking.chunking': 'Chunking...',
'chunking.chunks': 'chunks',
'chunking.noChunks': 'Lancez le chunking pour préparer les segments.',
'chunking.noChunksOnPage': 'Aucun chunk sur cette page.',
// Pagination
'pagination.pageOf': 'Page {current} sur {total}',
'pagination.perPage': '/ page',
// Settings // Settings
'settings.title': 'Paramètres', 'settings.title': 'Paramètres',
'settings.apiUrl': 'API URL', 'settings.apiUrl': 'API URL',
@ -103,6 +129,10 @@ const messages: Messages = {
'settings.themeDark': 'Sombre', 'settings.themeDark': 'Sombre',
'settings.themeLight': 'Clair', 'settings.themeLight': 'Clair',
'settings.language': 'Langue', 'settings.language': 'Langue',
// Disclaimer
'disclaimer.banner':
'Instance de d\u00e9monstration \u2014 les documents upload\u00e9s sont partag\u00e9s et temporaires (max 50 Mo). Ne pas envoyer de fichiers confidentiels.',
}, },
en: { en: {
'nav.home': 'Home', 'nav.home': 'Home',
@ -116,7 +146,8 @@ const messages: Messages = {
'topbar.newAnalysis': 'New analysis', 'topbar.newAnalysis': 'New analysis',
'home.title': 'Docling Studio', 'home.title': 'Docling Studio',
'home.subtitle': 'Analyze, explore and validate the structure of your PDF documents with Docling.', 'home.subtitle':
'Analyze, explore and validate the structure of your PDF documents with Docling.',
'home.documents': 'Documents', 'home.documents': 'Documents',
'home.analyses': 'Analyses', 'home.analyses': 'Analyses',
'home.recentDocs': 'Recent documents', 'home.recentDocs': 'Recent documents',
@ -138,26 +169,34 @@ const messages: Messages = {
'config.model': 'Model', 'config.model': 'Model',
'config.pipeline': 'Pipeline', 'config.pipeline': 'Pipeline',
'config.ocr': 'OCR', 'config.ocr': 'OCR',
'config.ocrHint': 'Applies Optical Character Recognition on scanned pages or embedded images. Essential for non-native PDFs.', 'config.ocrHint':
'Applies Optical Character Recognition on scanned pages or embedded images. Essential for non-native PDFs.',
'config.tableStructure': 'Table extraction', 'config.tableStructure': 'Table extraction',
'config.tableStructureHint': 'Detects tables in the document and reconstructs their row/column structure using the TableFormer model, with cell matching.', 'config.tableStructureHint':
'Detects tables in the document and reconstructs their row/column structure using the TableFormer model, with cell matching.',
'config.tableMode': 'Table mode', 'config.tableMode': 'Table mode',
'config.tableModeAccurate': 'Accurate', 'config.tableModeAccurate': 'Accurate',
'config.tableModeFast': 'Fast', 'config.tableModeFast': 'Fast',
'config.enrichment': 'Enrichment', 'config.enrichment': 'Enrichment',
'config.codeEnrichment': 'Code', 'config.codeEnrichment': 'Code',
'config.codeEnrichmentHint': 'Activates a specialized OCR model for code blocks, preserving indentation and syntax.', 'config.codeEnrichmentHint':
'Activates a specialized OCR model for code blocks, preserving indentation and syntax.',
'config.formulaEnrichment': 'Formulas', 'config.formulaEnrichment': 'Formulas',
'config.formulaEnrichmentHint': 'Recognizes mathematical formulas and converts them to LaTeX using a dedicated model.', 'config.formulaEnrichmentHint':
'Recognizes mathematical formulas and converts them to LaTeX using a dedicated model.',
'config.pictures': 'Pictures', 'config.pictures': 'Pictures',
'config.pictureClassification': 'Classification', 'config.pictureClassification': 'Classification',
'config.pictureClassificationHint': 'Classifies each detected image by type (chart, photo, diagram, logo…) using a classification model.', 'config.pictureClassificationHint':
'Classifies each detected image by type (chart, photo, diagram, logo…) using a classification model.',
'config.pictureDescription': 'Description', 'config.pictureDescription': 'Description',
'config.pictureDescriptionHint': 'Generates a text description for each image using a Vision Language Model (VLM). Useful for accessibility and indexing.', 'config.pictureDescriptionHint':
'Generates a text description for each image using a Vision Language Model (VLM). Useful for accessibility and indexing.',
'config.generatePictureImages': 'Extract pictures', 'config.generatePictureImages': 'Extract pictures',
'config.generatePictureImagesHint': 'Extracts detected images from the document and saves them as separate files. Required for image export.', 'config.generatePictureImagesHint':
'Extracts detected images from the document and saves them as separate files. Required for image export.',
'config.generatePageImages': 'Page images', 'config.generatePageImages': 'Page images',
'config.generatePageImagesHint': 'Rasterizes each PDF page as an image. Useful for visual preview or post-processing.', 'config.generatePageImagesHint':
'Rasterizes each PDF page as an image. Useful for visual preview or post-processing.',
'config.imagesScale': 'Images scale', 'config.imagesScale': 'Images scale',
'config.documents': 'Documents', 'config.documents': 'Documents',
@ -185,6 +224,21 @@ const messages: Messages = {
'history.emptyDocs': 'No documents yet. Upload a document from the Studio.', 'history.emptyDocs': 'No documents yet. Upload a document from the Studio.',
'history.open': 'Open', 'history.open': 'Open',
'studio.prepare': 'Prepare',
'chunking.settings': 'Chunking',
'chunking.chunkerType': 'Chunker type',
'chunking.maxTokens': 'Max tokens',
'chunking.mergePeers': 'Merge peers',
'chunking.repeatTableHeader': 'Repeat table headers',
'chunking.run': 'Chunk',
'chunking.chunking': 'Chunking...',
'chunking.chunks': 'chunks',
'chunking.noChunks': 'Run chunking to prepare segments.',
'chunking.noChunksOnPage': 'No chunks on this page.',
'pagination.pageOf': 'Page {current} of {total}',
'pagination.perPage': '/ page',
'settings.title': 'Settings', 'settings.title': 'Settings',
'settings.apiUrl': 'API URL', 'settings.apiUrl': 'API URL',
'settings.version': 'Version', 'settings.version': 'Version',
@ -192,6 +246,10 @@ const messages: Messages = {
'settings.themeDark': 'Dark', 'settings.themeDark': 'Dark',
'settings.themeLight': 'Light', 'settings.themeLight': 'Light',
'settings.language': 'Language', 'settings.language': 'Language',
// Disclaimer
'disclaimer.banner':
'Demo instance \u2014 uploaded documents are shared and temporary (max 50 MB). Do not upload confidential files.',
}, },
} }
@ -201,7 +259,7 @@ export function useI18n() {
function t(key: string, params: Record<string, string | number> = {}): string { function t(key: string, params: Record<string, string | number> = {}): string {
let str = messages[settings.locale]?.[key] || messages['fr'][key] || key let str = messages[settings.locale]?.[key] || messages['fr'][key] || key
for (const [k, v] of Object.entries(params)) { for (const [k, v] of Object.entries(params)) {
str = str.replace(`{${k}}`, String(v)) str = str.replaceAll(`{${k}}`, String(v))
} }
return str return str
} }

View file

@ -30,12 +30,34 @@ export interface Analysis {
contentMarkdown: string | null contentMarkdown: string | null
contentHtml: string | null contentHtml: string | null
pagesJson: string | null pagesJson: string | null
chunksJson: string | null
hasDocumentJson: boolean
errorMessage: string | null errorMessage: string | null
startedAt: string | null startedAt: string | null
completedAt: string | null completedAt: string | null
createdAt: string createdAt: string
} }
export interface ChunkingOptions {
chunker_type?: 'hybrid' | 'hierarchical'
max_tokens?: number
merge_peers?: boolean
repeat_table_header?: boolean
}
export interface ChunkBbox {
page: number
bbox: [number, number, number, number]
}
export interface Chunk {
text: string
headings: string[]
sourcePage: number | null
tokenCount: number
bboxes: ChunkBbox[]
}
export interface PageElement { export interface PageElement {
type: string type: string
bbox: [number, number, number, number] bbox: [number, number, number, number]

View file

@ -2,33 +2,71 @@
<aside class="sidebar" :class="{ open }"> <aside class="sidebar" :class="{ open }">
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<RouterLink to="/" class="nav-item" :class="{ active: route.name === 'home' }"> <RouterLink to="/" class="nav-item" :class="{ active: route.name === 'home' }">
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"/></svg> <svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
<path
d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"
/>
</svg>
<span class="nav-label">{{ t('nav.home') }}</span> <span class="nav-label">{{ t('nav.home') }}</span>
</RouterLink> </RouterLink>
<RouterLink to="/studio" class="nav-item" :class="{ active: route.name === 'studio' }"> <RouterLink to="/studio" class="nav-item" :class="{ active: route.name === 'studio' }">
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838l-2.727 1.17 1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zm5.99 7.176A9.026 9.026 0 007 15.96v-4.5l.61.26a2.5 2.5 0 001.98 0l.61-.26v4.5a9.026 9.026 0 00-1.7.613z"/></svg> <svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
<path
d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838l-2.727 1.17 1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zm5.99 7.176A9.026 9.026 0 007 15.96v-4.5l.61.26a2.5 2.5 0 001.98 0l.61-.26v4.5a9.026 9.026 0 00-1.7.613z"
/>
</svg>
<span class="nav-label">{{ t('nav.studio') }}</span> <span class="nav-label">{{ t('nav.studio') }}</span>
</RouterLink> </RouterLink>
<RouterLink to="/documents" class="nav-item" :class="{ active: route.name === 'documents' }"> <RouterLink to="/documents" class="nav-item" :class="{ active: route.name === 'documents' }">
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg> <svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
clip-rule="evenodd"
/>
</svg>
<span class="nav-label">{{ t('nav.documents') }}</span> <span class="nav-label">{{ t('nav.documents') }}</span>
</RouterLink> </RouterLink>
<RouterLink to="/history" class="nav-item" :class="{ active: route.name === 'history' }"> <RouterLink to="/history" class="nav-item" :class="{ active: route.name === 'history' }">
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg> <svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
clip-rule="evenodd"
/>
</svg>
<span class="nav-label">{{ t('nav.history') }}</span> <span class="nav-label">{{ t('nav.history') }}</span>
</RouterLink> </RouterLink>
<RouterLink to="/settings" class="nav-item" :class="{ active: route.name === 'settings' }"> <RouterLink to="/settings" class="nav-item" :class="{ active: route.name === 'settings' }">
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/></svg> <svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
clip-rule="evenodd"
/>
</svg>
<span class="nav-label">{{ t('nav.settings') }}</span> <span class="nav-label">{{ t('nav.settings') }}</span>
</RouterLink> </RouterLink>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<span class="version">v0.1.0</span> <a
class="github-badge"
href="https://github.com/scub-france/Docling-Studio"
target="_blank"
rel="noopener"
>
<img
src="https://img.shields.io/github/stars/scub-france/Docling-Studio?style=social"
alt="GitHub Stars"
height="20"
/>
</a>
<span class="version">v{{ version }}</span>
</div> </div>
</aside> </aside>
</template> </template>
@ -37,11 +75,12 @@
import { RouterLink, useRoute } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
import { useI18n } from '../i18n' import { useI18n } from '../i18n'
const version = __APP_VERSION__
const route = useRoute() const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
defineProps({ defineProps({
open: { type: Boolean, default: false } open: { type: Boolean, default: false },
}) })
</script> </script>
@ -55,7 +94,9 @@ defineProps({
overflow: hidden; overflow: hidden;
width: 0; width: 0;
min-width: 0; min-width: 0;
transition: width 250ms ease, min-width 250ms ease; transition:
width 250ms ease,
min-width 250ms ease;
} }
.sidebar.open { .sidebar.open {
@ -111,6 +152,17 @@ defineProps({
overflow: hidden; overflow: hidden;
} }
.github-badge {
display: block;
margin-bottom: 8px;
opacity: 0.7;
transition: opacity var(--transition);
}
.github-badge:hover {
opacity: 1;
}
.version { .version {
font-size: 12px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);

View file

@ -0,0 +1,135 @@
<template>
<div v-if="pageCount > 1" class="pagination-bar">
<div class="pagination-nav">
<button class="pagination-btn" :disabled="page <= 1" @click="$emit('update:page', page - 1)">
<svg viewBox="0 0 20 20" fill="currentColor" class="pagination-icon">
<path
fill-rule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</button>
<span class="pagination-info">
{{ t('pagination.pageOf', { current: String(page), total: String(pageCount) }) }}
</span>
<button
class="pagination-btn"
:disabled="page >= pageCount"
@click="$emit('update:page', page + 1)"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="pagination-icon">
<path
fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<div class="pagination-size">
<select
:value="pageSize"
class="pagination-select"
@change="$emit('update:pageSize', Number(($event.target as HTMLSelectElement).value))"
>
<option v-for="size in pageSizeOptions" :key="size" :value="size">
{{ size }} {{ t('pagination.perPage') }}
</option>
</select>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from '../i18n'
defineProps<{
page: number
pageCount: number
pageSize: number
}>()
defineEmits<{
'update:page': [value: number]
'update:pageSize': [value: number]
}>()
const { t } = useI18n()
const pageSizeOptions = [5, 10, 20, 50]
</script>
<style scoped>
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-top: 1px solid var(--border);
gap: 8px;
flex-shrink: 0;
}
.pagination-nav {
display: flex;
align-items: center;
gap: 4px;
}
.pagination-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
cursor: pointer;
transition: all 0.15s;
}
.pagination-btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.pagination-btn:disabled {
opacity: 0.35;
cursor: default;
}
.pagination-icon {
width: 14px;
height: 14px;
}
.pagination-info {
font-size: 12px;
color: var(--text-muted);
padding: 0 6px;
white-space: nowrap;
}
.pagination-size {
flex-shrink: 0;
}
.pagination-select {
font-size: 11px;
padding: 3px 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text-muted);
cursor: pointer;
}
.pagination-select:hover {
border-color: var(--accent);
}
</style>

View file

@ -1 +1,2 @@
export { default as AppSidebar } from './AppSidebar.vue' export { default as AppSidebar } from './AppSidebar.vue'
export { default as PaginationBar } from './PaginationBar.vue'

View file

@ -1,14 +1,24 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { readFileSync } from 'fs'
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
define: {
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || pkg.version),
},
server: { server: {
port: 3000, port: 3000,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8000', target: 'http://localhost:8000',
changeOrigin: true changeOrigin: true
},
'/health': {
target: 'http://localhost:8000',
changeOrigin: true
} }
} }
} }