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_ORIGINS=http://localhost:3000,https://your-domain.com

View file

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

5
.gitignore vendored
View file

@ -11,6 +11,8 @@ site/
.vscode/
.run/
.claude/
CLAUDE.md
**/CLAUDE.md
*.iml
# OS
@ -32,6 +34,9 @@ __pycache__/
.venv/
*.egg-info/
# Excalidraw working files
docs/excalidraw/
# Logs
*.log
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/).
## [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
### Added

View file

@ -22,7 +22,13 @@ Thank you for your interest in contributing to Docling Studio! This guide will h
```bash
cd document-parser
python -m venv .venv && source .venv/bin/activate
# Remote mode (lightweight — delegates to Docling Serve)
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
uvicorn main:app --reload --port 8000
```
@ -61,11 +67,11 @@ npx prettier --write src/ # auto-format
## Running Tests
```bash
# Backend (99 tests)
# Backend (199 tests)
cd document-parser
pytest tests/ -v
# Frontend (81 tests)
# Frontend (129 tests)
cd frontend
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
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
- Keep PRs focused — one feature or fix per PR

View file

@ -1,33 +1,36 @@
# syntax=docker/dockerfile:1
# =============================================================================
# Docling Studio — single-image build (frontend + backend)
# Docling Studio — single-image build (frontend + backend, multi-target)
#
# Usage:
# docker build -t docling-studio .
# docker run -p 3000:3000 docling-studio
# docker build --target remote -t docling-studio:remote .
# docker build --target local -t docling-studio:local .
# =============================================================================
# --- Stage 1: Build frontend assets ---
FROM node:20-alpine AS frontend-build
ARG APP_VERSION=dev
WORKDIR /build
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
RUN VITE_APP_VERSION=${APP_VERSION} npm run build
# --- Stage 2: Runtime (Python + Nginx) ---
FROM python:3.12-slim
# --- Stage 2: Base runtime (Python + Nginx) ---
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 \
poppler-utils \
libgl1 \
libglib2.0-0 \
nginx \
&& rm -rf /var/lib/apt/lists/*
# Python deps
# Python deps (common)
WORKDIR /app
COPY document-parser/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
# 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'"]
# --- 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)
![Docling](https://img.shields.io/badge/powered%20by-Docling-orange)
![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).
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
├── domain/ # Pure domain — no HTTP, no DB
│ ├── models.py # Document, AnalysisJob dataclasses
│ ├── parsing.py # Docling conversion & page extraction
│ └── bbox.py # Bounding box coordinate normalization
│ ├── ports.py # Abstract protocols (converter, chunker)
│ └── value_objects.py # ConversionResult, PageDetail, ChunkResult
├── api/ # HTTP layer (FastAPI routers)
│ ├── schemas.py # Pydantic DTOs (camelCase serialization)
│ ├── documents.py # /api/documents endpoints
@ -61,7 +62,7 @@ document-parser/
├── services/ # Use case orchestration
│ ├── document_service.py # Upload, delete, preview
│ └── analysis_service.py # Async Docling processing
└── tests/ # 99 tests (pytest)
└── tests/ # 199 tests (pytest)
```
### Frontend structure (feature-based)
@ -85,22 +86,42 @@ frontend/src/
## 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
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.
Open [http://localhost:3000](http://localhost:3000)
### Docker Compose (for development)
```bash
git clone https://github.com/scub-france/Docling-Studio.git
cd Docling-Studio
# 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
@ -109,7 +130,13 @@ docker compose up --build
```bash
cd document-parser
python -m venv .venv && source .venv/bin/activate
# Remote mode (lightweight)
pip install -r requirements.txt
# Local mode (with Docling)
pip install -r requirements-local.txt
uvicorn main:app --reload --port 8000
```
@ -123,12 +150,12 @@ npm run dev
### Running Tests
```bash
# Backend (99 tests)
# Backend (199 tests)
cd document-parser
pip install pytest pytest-asyncio httpx
pytest tests/ -v
# Frontend (81 tests)
# Frontend (129 tests)
cd frontend
npm run test:run
```
@ -156,6 +183,9 @@ All configuration is done via environment variables. See [`.env.example`](.env.e
| 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) |
| `UPLOAD_DIR` | `./uploads` | File storage directory |
| `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 |
|----------|---------|--------------|
| **CI** | push to `main`, pull requests | Lint + type check + Backend tests (99) + Frontend tests (81) + build |
| **Release** | push tag `v*` | Build & push multi-arch Docker image to `ghcr.io` |
| **CI** | push to `main`, pull requests | Lint + type check + Backend tests + Frontend tests + build |
| **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:
```bash
git tag v0.2.0
git push origin v0.2.0
```
We follow [Semantic Versioning](https://semver.org/) with a simplified Git Flow. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full release process.
## Performance & System Requirements
@ -186,12 +213,11 @@ git push origin v0.2.0
### Docker Desktop settings
The document parser needs **at least 4 GB of RAM**:
| Resource | Minimum | Recommended |
|----------|---------|-------------|
| Memory | 6 GB | 8 GB+ |
| CPUs | 4 | 8+ |
| | Remote image | Local image |
|---|---|---|
| **Image size** | ~270 MB | ~1.9 GB |
| **Memory** | 2 GB | 6 GB (recommended 8 GB+) |
| **CPUs** | 2 | 4 (recommended 8+) |
### Platform support

View file

@ -2,6 +2,7 @@ services:
document-parser:
build:
context: ./document-parser
target: ${CONVERSION_MODE:-local}
expose:
- "8000"
volumes:
@ -9,6 +10,8 @@ services:
- db_data:/app/data
environment:
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:
resources:
limits:

View file

@ -2,7 +2,7 @@
## 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.
@ -40,37 +40,47 @@ The backend follows a strict layered architecture. Dependencies flow inward: API
```
document-parser/
├── main.py # FastAPI app, CORS, lifespan
├── main.py # FastAPI app, CORS, lifespan, health endpoint
├── domain/ # Pure domain — no HTTP, no DB
│ ├── 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
├── api/ # HTTP layer (FastAPI routers)
│ ├── schemas.py # Pydantic DTOs (camelCase serialization)
│ ├── documents.py # /api/documents endpoints
│ └── analyses.py # /api/analyses endpoints
│ └── analyses.py # /api/analyses endpoints (create, rechunk, delete)
├── persistence/ # Data layer (SQLite via aiosqlite)
│ ├── database.py # Connection management, schema init
│ ├── document_repo.py # Document 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
│ ├── 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 | 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) |
| **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 contract
@ -101,7 +111,9 @@ frontend/src/
│ │ ├── AnalysisPanel.vue
│ │ ├── StructureViewer.vue
│ │ └── ...
│ ├── chunking/ # Chunk panel UI + rechunk action
│ ├── document/ # Document store, API, upload
│ ├── feature-flags/ # Feature flag store (reads /api/health)
│ ├── history/ # History store, navigation
│ └── 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`.
- **No component library** — custom CSS with CSS variables for theming.
- **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()`
**File:** `document-parser/domain/bbox.py`
**File:** `document-parser/infra/bbox.py`
Normalizes any Docling bbox to `[left, top, right, bottom]` in TOPLEFT coordinates.

View file

@ -20,7 +20,13 @@
```bash
cd document-parser
python -m venv .venv && source .venv/bin/activate
# Remote mode (lightweight — delegates to Docling Serve)
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
uvicorn main:app --reload --port 8000
```

View file

@ -1,33 +1,69 @@
# 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
git clone https://github.com/scub-france/Docling-Studio.git
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
### Backend (Python 3.12+)
=== "Backend (Python 3.12+)"
```bash
cd document-parser
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn main:app --reload --port 8000
```
```bash
cd document-parser
python -m venv .venv && source .venv/bin/activate
### Frontend (Node 20+)
# Remote mode (lightweight)
pip install -r requirements.txt
```bash
cd frontend
npm install
npm run dev
```
# Local mode (with Docling)
pip install -r requirements-local.txt
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`.
@ -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 |
| `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
All configuration is done via environment variables:
| 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 |
| `UPLOAD_DIR` | `./uploads` | File storage directory |
| `DB_PATH` | `./data/docling_studio.db` | SQLite database path |
| `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
| Resource | Minimum | Recommended |
|----------|---------|-------------|
| Memory | 6 GB | 8 GB+ |
| CPUs | 4 | 8+ |
| | Remote image | Local image |
|---|---|---|
| **Image size** | ~270 MB | ~1.9 GB |
| **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.

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.
![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)
## Features
- **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
- **Chunking** — split extracted content into semantic chunks (hierarchical, hybrid, or page-based) with configurable token limits
- **Markdown & HTML export** of extracted content
- **Document management** — upload, list, delete
- **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
## Tech Stack
@ -31,7 +36,7 @@ Upload a PDF, configure the extraction pipeline, and visualize the results — t
```bash
# 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.

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 \
poppler-utils \
libgl1 \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
@ -24,3 +32,25 @@ ENV DB_PATH=/app/data/docling_studio.db
USER appuser
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
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 services import analysis_service
from api.schemas import (
AnalysisResponse,
ChunkBboxResponse,
ChunkResponse,
CreateAnalysisRequest,
RechunkRequest,
)
from services.analysis_service import AnalysisService
logger = logging.getLogger(__name__)
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:
return AnalysisResponse(
id=job.id,
@ -22,6 +36,8 @@ def _to_response(job) -> AnalysisResponse:
content_markdown=job.content_markdown,
content_html=job.content_html,
pages_json=job.pages_json,
chunks_json=job.chunks_json,
has_document_json=job.document_json is not None,
error_message=job.error_message,
started_at=str(job.started_at) if job.started_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)
async def create_analysis(body: CreateAnalysisRequest):
async def create_analysis(body: CreateAnalysisRequest, service: ServiceDep) -> AnalysisResponse:
"""Create a new analysis job for a document."""
if not body.documentId or not body.documentId.strip():
raise HTTPException(status_code=400, detail="documentId is required")
@ -39,8 +55,16 @@ async def create_analysis(body: CreateAnalysisRequest):
if body.pipelineOptions:
pipeline_opts = body.pipelineOptions.model_dump()
chunking_opts = None
if body.chunkingOptions:
chunking_opts = body.chunkingOptions.model_dump()
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:
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])
async def list_analyses():
async def list_analyses(service: ServiceDep) -> list[AnalysisResponse]:
"""List all analysis jobs."""
jobs = await analysis_service.find_all()
jobs = await service.find_all()
return [_to_response(j) for j in jobs]
@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."""
job = await analysis_service.find_by_id(job_id)
job = await service.find_by_id(job_id)
if not job:
raise HTTPException(status_code=404, detail="Analysis not found")
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)
async def delete_analysis(job_id: str):
async def delete_analysis(job_id: str, service: ServiceDep) -> None:
"""Delete an analysis job."""
deleted = await analysis_service.delete(job_id)
deleted = await service.delete(job_id)
if not deleted:
raise HTTPException(status_code=404, detail="Analysis not found")

View file

@ -13,6 +13,8 @@ from services import document_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/documents", tags=["documents"])
_READ_CHUNK_SIZE = 64 * 1024 # 64 KB
def _to_response(doc) -> DocumentResponse:
return DocumentResponse(
@ -25,8 +27,8 @@ def _to_response(doc) -> DocumentResponse:
)
@router.post("/upload", response_model=DocumentResponse)
async def upload(file: UploadFile):
@router.post("/upload", response_model=DocumentResponse, status_code=200)
async def upload(file: UploadFile) -> DocumentResponse:
"""Upload a PDF document."""
if not file.filename:
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:
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:
doc = await document_service.upload(
@ -44,20 +54,20 @@ async def upload(file: UploadFile):
file_content=content,
)
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)
@router.get("", response_model=list[DocumentResponse])
async def list_documents():
async def list_documents() -> list[DocumentResponse]:
"""List all documents."""
docs = await document_service.find_all()
return [_to_response(d) for d in docs]
@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."""
doc = await document_service.find_by_id(doc_id)
if not doc:
@ -66,7 +76,7 @@ async def get_document(doc_id: str):
@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."""
deleted = await document_service.delete(doc_id)
if not deleted:
@ -78,12 +88,18 @@ async def preview(
doc_id: str,
page: int = Query(1, ge=1),
dpi: int = Query(150, ge=72, le=300),
):
) -> Response:
"""Generate a PNG preview of a specific PDF page."""
doc = await document_service.find_by_id(doc_id)
if not doc:
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:
with open(doc.storage_path, "rb") as f:
file_content = f.read()
@ -91,6 +107,11 @@ async def preview(
return Response(content=png_bytes, media_type="image/png")
except ValueError as 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:
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

View file

@ -8,7 +8,7 @@ from __future__ import annotations
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:
@ -18,6 +18,7 @@ def _to_camel(name: str) -> str:
class _CamelModel(BaseModel):
"""Base model that serializes field names to camelCase."""
model_config = ConfigDict(
alias_generator=_to_camel,
populate_by_name=True,
@ -28,6 +29,7 @@ class _CamelModel(BaseModel):
class DocumentResponse(_CamelModel):
id: str
filename: str
status: str = "uploaded" # Document status (always "uploaded" for now)
content_type: str | None = None
file_size: int | None = None
page_count: int | None = None
@ -42,6 +44,8 @@ class AnalysisResponse(_CamelModel):
content_markdown: str | None = None
content_html: str | None = None
pages_json: str | None = None
chunks_json: str | None = None
has_document_json: bool = False
error_message: str | None = None
started_at: str | datetime | None = None
completed_at: str | datetime | None = None
@ -50,16 +54,40 @@ class AnalysisResponse(_CamelModel):
class PipelineOptionsRequest(BaseModel):
"""Docling pipeline configuration options."""
do_ocr: bool = True
do_table_structure: bool = True
table_mode: str = "accurate" # "accurate" or "fast"
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
model_config = ConfigDict(populate_by_name=True)
do_ocr: bool = Field(default=True, validation_alias=AliasChoices("do_ocr", "doOcr"))
do_table_structure: bool = Field(
default=True, validation_alias=AliasChoices("do_table_structure", "doTableStructure")
)
table_mode: str = Field(
default="accurate", validation_alias=AliasChoices("table_mode", "tableMode")
)
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")
@classmethod
@ -76,6 +104,61 @@ class PipelineOptionsRequest(BaseModel):
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):
documentId: str # camelCase to match existing frontend contract
pipelineOptions: PipelineOptionsRequest | None = None
documentId: str = Field(validation_alias=AliasChoices("documentId", "document_id"))
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"]

View file

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

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
per-page elements with bounding boxes and hierarchy levels.
This adapter implements the DocumentConverter port using the Docling library
directly. It wraps the blocking DocumentConverter in asyncio.to_thread for
non-blocking execution.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import threading
from dataclasses import dataclass, field
from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import (
@ -17,7 +19,8 @@ from docling.datamodel.pipeline_options import (
TableFormerMode,
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 (
CodeItem,
DocItem,
@ -32,11 +35,17 @@ from docling_core.types.doc import (
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__)
# Thread lock — DocumentConverter is not thread-safe
# Thread lock — DoclingConverter is not thread-safe
_converter_lock = threading.Lock()
# 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 converter (lazy-init on first request)
_default_converter: DocumentConverter | 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
_default_converter: DoclingConverter | None = None
# ---------------------------------------------------------------------------
# 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]] = [
(TableItem, "table"),
(PictureItem, "picture"),
@ -113,7 +74,6 @@ _ELEMENT_TYPE_MAP: list[tuple[type, 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:
if isinstance(item, cls):
return type_name
@ -124,51 +84,52 @@ def _get_element_type(item: DocItem) -> str:
# 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(
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(
do_ocr=opts.do_ocr,
do_table_structure=opts.do_table_structure,
do_ocr=options.do_ocr,
do_table_structure=options.do_table_structure,
table_structure_options=table_options,
do_code_enrichment=opts.do_code_enrichment,
do_formula_enrichment=opts.do_formula_enrichment,
do_picture_classification=opts.do_picture_classification,
do_picture_description=opts.do_picture_description,
generate_page_images=opts.generate_page_images,
generate_picture_images=opts.generate_picture_images,
images_scale=opts.images_scale,
do_code_enrichment=options.do_code_enrichment,
do_formula_enrichment=options.do_formula_enrichment,
do_picture_classification=options.do_picture_classification,
do_picture_description=options.do_picture_description,
generate_page_images=options.generate_page_images,
generate_picture_images=options.generate_picture_images,
images_scale=options.images_scale,
)
return DocumentConverter(
return DoclingConverter(
format_options={
InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options),
}
)
def get_default_converter() -> DocumentConverter:
def _get_default_converter() -> DoclingConverter:
global _default_converter
if _default_converter is None:
_default_converter = build_converter()
_default_converter = _build_docling_converter(ConversionOptions())
return _default_converter
def _select_converter(options: ConversionOptions) -> DoclingConverter:
if options.is_default():
return _get_default_converter()
return _build_docling_converter(options)
# ---------------------------------------------------------------------------
# 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] = {}
document = doc_result.document
skipped = 0
@ -191,9 +152,10 @@ def extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
def _process_content_item(
item: DocItem | GroupItem, level: int, pages: dict[int, PageDetail],
item: DocItem | GroupItem,
level: int,
pages: dict[int, PageDetail],
) -> bool:
"""Process a single content item and add it to the appropriate page."""
if isinstance(item, GroupItem):
return True
@ -204,14 +166,15 @@ def _process_content_item(
try:
page_no = prov.page_no
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(
"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
@ -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:
conv = _select_converter(opts)
conv = _select_converter(options)
result = conv.convert(file_path)
doc = result.document
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:
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:
logger.info("Parsed: %d pages, %d items skipped", page_count, skipped)
@ -295,4 +239,21 @@ def convert_document(
content_html=doc.export_to_html(),
pages=pages_detail,
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.
Single service replacing both the Spring Boot backend and the document parser.
Provides document management (upload, CRUD), analysis orchestration (async Docling
processing), and PDF preview all backed by SQLite.
Single service providing document management (upload, CRUD), analysis
orchestration (async Docling processing), and PDF preview all backed
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
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
@ -16,7 +20,10 @@ from fastapi.middleware.cors import CORSMiddleware
from api.analyses import router as analyses_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(
level=logging.INFO,
@ -25,11 +32,53 @@ logging.basicConfig(
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
async def lifespan(app: FastAPI):
"""Startup: initialize database. Shutdown: nothing special needed."""
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
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
@ -39,25 +88,35 @@ app = FastAPI(
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(
CORSMiddleware,
allow_origins=[o.strip() for o in allowed_origins],
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
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(analyses_router)
@app.get("/health")
def health():
"""Health check endpoint."""
return {"status": "ok"}
@app.get("/api/health")
async def health() -> dict[str, str]:
"""Health check endpoint — verifies database connectivity."""
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 datetime import UTC, datetime
from domain.models import AnalysisJob, AnalysisStatus
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:
keys = row.keys()
return AnalysisJob(
id=row["id"],
document_id=row["document_id"],
@ -14,11 +24,13 @@ def _row_to_job(row) -> AnalysisJob:
content_markdown=row["content_markdown"],
content_html=row["content_html"],
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"],
started_at=row["started_at"],
completed_at=row["completed_at"],
created_at=row["created_at"],
document_filename=row["filename"] if "filename" in row.keys() else None, # noqa: SIM118
started_at=_parse_dt(row["started_at"]),
completed_at=_parse_dt(row["completed_at"]),
created_at=_parse_dt(row["created_at"]) or datetime.now(UTC),
document_filename=row["filename"] if "filename" in keys else None,
)
@ -30,6 +42,7 @@ _SELECT_WITH_DOC = """
async def insert(job: AnalysisJob) -> None:
"""Persist a new analysis job record."""
async with get_connection() as db:
await db.execute(
"""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]:
"""Return analysis jobs with document info, newest first."""
async with get_connection() as db:
cursor = await db.execute(
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:
"""Find an analysis job by ID (with document filename), or return None."""
async with get_connection() as db:
cursor = await db.execute(
f"{_SELECT_WITH_DOC} WHERE aj.id = ?", (job_id,)
)
cursor = await db.execute(f"{_SELECT_WITH_DOC} WHERE aj.id = ?", (job_id,))
row = await cursor.fetchone()
return _row_to_job(row) if row else 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:
await db.execute(
"""UPDATE analysis_jobs
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 = ?""",
(job.status.value, job.content_markdown, job.content_html,
job.pages_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),
(
job.status.value,
job.content_markdown,
job.content_html,
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()
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:
"""Delete an analysis job by ID. Returns True if a row was removed."""
async with get_connection() as db:
cursor = await db.execute("DELETE FROM analysis_jobs WHERE id = ?", (job_id,))
await db.commit()
@ -84,8 +118,6 @@ async def delete(job_id: str) -> bool:
async def delete_by_document(document_id: str) -> int:
"""Delete all analysis jobs for a given document. Returns count deleted."""
async with get_connection() as db:
cursor = await db.execute(
"DELETE FROM analysis_jobs WHERE document_id = ?", (document_id,)
)
cursor = await db.execute("DELETE FROM analysis_jobs WHERE document_id = ?", (document_id,))
await db.commit()
return cursor.rowcount

View file

@ -4,13 +4,16 @@ from __future__ import annotations
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import aiosqlite
from infra.settings import settings
logger = logging.getLogger(__name__)
DB_PATH = os.environ.get("DB_PATH", "./data/docling_studio.db")
DB_PATH = settings.db_path
_SCHEMA = """
CREATE TABLE IF NOT EXISTS documents (
@ -30,6 +33,8 @@ CREATE TABLE IF NOT EXISTS analysis_jobs (
content_markdown TEXT,
content_html TEXT,
pages_json TEXT,
document_json TEXT,
chunks_json TEXT,
error_message TEXT,
started_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:
"""Create database file and tables if they don't exist."""
os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True)
async with aiosqlite.connect(DB_PATH) as db:
await db.executescript(_SCHEMA)
await _run_migrations(db)
await db.commit()
logger.info("Database initialized at %s", DB_PATH)
@ -60,7 +83,7 @@ async def get_db() -> aiosqlite.Connection:
@asynccontextmanager
async def get_connection():
async def get_connection() -> AsyncIterator[aiosqlite.Connection]:
"""Context manager that opens and auto-closes a database connection."""
db = await get_db()
try:

View file

@ -2,11 +2,18 @@
from __future__ import annotations
from datetime import UTC, datetime
from domain.models import Document
from persistence.database import get_connection
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(
id=row["id"],
filename=row["filename"],
@ -14,22 +21,31 @@ def _row_to_document(row) -> Document:
file_size=row["file_size"],
page_count=row["page_count"],
storage_path=row["storage_path"],
created_at=row["created_at"],
created_at=created,
)
async def insert(doc: Document) -> None:
"""Persist a new document record."""
async with get_connection() as db:
await db.execute(
"""INSERT INTO documents (id, filename, content_type, file_size, page_count, storage_path, created_at)
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()
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:
cursor = await db.execute(
"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:
"""Find a document by its ID, or return None."""
async with get_connection() as db:
cursor = await db.execute("SELECT * FROM documents WHERE id = ?", (doc_id,))
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:
"""Update the page count after conversion has determined it."""
async with get_connection() as db:
await db.execute(
"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:
"""Delete a document by ID. Returns True if a row was removed."""
async with get_connection() as db:
cursor = await db.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
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
fastapi>=0.115.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
pillow>=10.0.0,<11.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
import asyncio
import functools
import json
import logging
from dataclasses import asdict
from typing import TYPE_CHECKING
from domain.models import AnalysisJob
from domain.parsing import ConversionOptions, ConversionResult, convert_document
from domain.models import AnalysisJob, AnalysisStatus
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
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:
"""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)
# 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
# Maximum number of concurrent analysis jobs to prevent resource exhaustion.
_DEFAULT_MAX_CONCURRENT = 3
def _on_task_done(task: asyncio.Task) -> None:
"""Log unhandled exceptions from background analysis tasks."""
class AnalysisService:
"""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():
logger.warning("Analysis task was cancelled")
logger.warning("Analysis task was cancelled: %s", job_id)
_schedule_mark_failed(job_id, "Task was cancelled")
return
exc = task.exception()
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]:
return await analysis_repo.find_all()
async def find_by_id(job_id: str) -> AnalysisJob | None:
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))
def _schedule_mark_failed(job_id: str, error: str) -> None:
"""Schedule _mark_failed as a tracked background task."""
t = asyncio.ensure_future(_mark_failed(job_id, error))
_background_tasks.add(t)
t.add_done_callback(_background_tasks.discard)
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:
job.mark_failed(error)
await analysis_repo.update_status(job)
except OSError:
logger.exception("Database I/O error marking job %s as failed", job_id)
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 domain.models import Document
from infra.settings import settings
from persistence import analysis_repo, document_repo
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
@ -22,8 +23,14 @@ MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
_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:
"""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:
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)
ext = os.path.splitext(filename)[1] or ".pdf"
ext = ".pdf" # Content already validated as PDF
safe_name = f"{uuid.uuid4()}{ext}"
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:
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
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]:
"""Return all documents, newest first."""
return await document_repo.find_all()
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)
@ -70,12 +81,20 @@ async def delete(doc_id: str) -> bool:
# Delete associated analyses first (cascade)
await analysis_repo.delete_by_document(doc_id)
# Delete file from disk
# Delete file from disk (only if inside UPLOAD_DIR)
try:
if os.path.exists(doc.storage_path):
os.unlink(doc.storage_path)
real_path = os.path.realpath(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:
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)
@ -96,6 +115,9 @@ def _count_pages(file_content: bytes) -> int | None:
try:
info = pdfinfo_from_bytes(file_content)
return info.get("Pages")
except Exception:
logger.warning("Could not count pages", exc_info=True)
except (FileNotFoundError, OSError) as exc:
logger.warning("Could not count pages: %s", exc)
return None
except Exception:
logger.warning("Unexpected error counting pages", exc_info=True)
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."""
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi.testclient import TestClient
@ -14,11 +14,24 @@ def client():
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:
def test_health(self, client):
resp = client.get("/health")
resp = client.get("/api/health")
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:
@ -41,8 +54,12 @@ class TestDocumentEndpoints:
@patch("services.document_service.find_by_id", new_callable=AsyncMock)
def test_get_document(self, mock_find, client):
mock_find.return_value = Document(
id="d1", filename="test.pdf", content_type="application/pdf",
file_size=2048, page_count=3, storage_path="/tmp/test",
id="d1",
filename="test.pdf",
content_type="application/pdf",
file_size=2048,
page_count=3,
storage_path="/tmp/test",
)
resp = client.get("/api/documents/d1")
@ -62,8 +79,10 @@ class TestDocumentEndpoints:
@patch("services.document_service.upload", new_callable=AsyncMock)
def test_upload_document(self, mock_upload, client):
mock_upload.return_value = Document(
id="new-1", filename="uploaded.pdf",
content_type="application/pdf", file_size=512,
id="new-1",
filename="uploaded.pdf",
content_type="application/pdf",
file_size=512,
storage_path="/tmp/uploaded",
)
@ -84,7 +103,20 @@ class TestDocumentEndpoints:
"/api/documents/upload",
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)
def test_delete_document(self, mock_delete, client):
@ -102,11 +134,12 @@ class TestDocumentEndpoints:
class TestAnalysisEndpoints:
@patch("services.analysis_service.find_all", new_callable=AsyncMock)
def test_list_analyses(self, mock_find_all, client):
mock_find_all.return_value = [
AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"),
]
def test_list_analyses(self, client, mock_analysis_service):
mock_analysis_service.find_all = AsyncMock(
return_value=[
AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"),
]
)
resp = client.get("/api/analyses")
assert resp.status_code == 200
@ -117,11 +150,10 @@ class TestAnalysisEndpoints:
assert data[0]["documentFilename"] == "test.pdf"
assert data[0]["status"] == "PENDING"
@patch("services.analysis_service.find_by_id", new_callable=AsyncMock)
def test_get_analysis(self, mock_find, client):
def test_get_analysis(self, client, mock_analysis_service):
job = AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
job.mark_running()
mock_find.return_value = job
mock_analysis_service.find_by_id = AsyncMock(return_value=job)
resp = client.get("/api/analyses/j1")
assert resp.status_code == 200
@ -129,17 +161,19 @@ class TestAnalysisEndpoints:
assert data["status"] == "RUNNING"
assert data["startedAt"] is not None
@patch("services.analysis_service.find_by_id", new_callable=AsyncMock)
def test_get_analysis_not_found(self, mock_find, client):
mock_find.return_value = None
def test_get_analysis_not_found(self, client, mock_analysis_service):
mock_analysis_service.find_by_id = AsyncMock(return_value=None)
resp = client.get("/api/analyses/missing")
assert resp.status_code == 404
@patch("services.analysis_service.create", new_callable=AsyncMock)
def test_create_analysis(self, mock_create, client):
mock_create.return_value = AnalysisJob(
id="j1", document_id="d1", document_filename="test.pdf",
def test_create_analysis(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"})
@ -147,34 +181,44 @@ class TestAnalysisEndpoints:
data = resp.json()
assert data["id"] == "j1"
assert data["documentId"] == "d1"
mock_create.assert_called_once_with("d1", pipeline_options=None)
@patch("services.analysis_service.create", new_callable=AsyncMock)
def test_create_analysis_with_pipeline_options(self, mock_create, client):
mock_create.return_value = AnalysisJob(
id="j2", document_id="d1", document_filename="test.pdf",
mock_analysis_service.create.assert_called_once_with(
"d1",
pipeline_options=None,
chunking_options=None,
)
resp = client.post("/api/analyses", json={
"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,
}
})
def test_create_analysis_with_pipeline_options(self, client, mock_analysis_service):
mock_analysis_service.create = AsyncMock(
return_value=AnalysisJob(
id="j2",
document_id="d1",
document_filename="test.pdf",
)
)
resp = client.post(
"/api/analyses",
json={
"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
data = resp.json()
assert data["id"] == "j2"
call_kwargs = mock_create.call_args
call_kwargs = mock_analysis_service.create.call_args
opts = call_kwargs.kwargs["pipeline_options"]
assert opts["do_ocr"] is False
assert opts["table_mode"] == "fast"
@ -182,51 +226,50 @@ class TestAnalysisEndpoints:
assert opts["generate_picture_images"] is True
assert opts["images_scale"] == 2.0
@patch("services.analysis_service.create", new_callable=AsyncMock)
def test_create_analysis_with_partial_pipeline_options(self, mock_create, client):
def test_create_analysis_with_partial_pipeline_options(self, client, mock_analysis_service):
"""Pipeline options should use defaults for unspecified fields."""
mock_create.return_value = AnalysisJob(
id="j3", document_id="d1", document_filename="test.pdf",
mock_analysis_service.create = AsyncMock(
return_value=AnalysisJob(
id="j3",
document_id="d1",
document_filename="test.pdf",
)
)
resp = client.post("/api/analyses", json={
"documentId": "d1",
"pipelineOptions": {"do_ocr": False}
})
resp = client.post(
"/api/analyses", json={"documentId": "d1", "pipelineOptions": {"do_ocr": False}}
)
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
# Defaults
assert opts["do_table_structure"] is True
assert opts["table_mode"] == "accurate"
assert opts["do_code_enrichment"] is False
@patch("services.analysis_service.create", new_callable=AsyncMock)
def test_create_analysis_document_not_found(self, mock_create, client):
mock_create.side_effect = ValueError("Document not found: d99")
def test_create_analysis_document_not_found(self, client, mock_analysis_service):
mock_analysis_service.create = AsyncMock(side_effect=ValueError("Document not found: d99"))
resp = client.post("/api/analyses", json={"documentId": "d99"})
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": ""})
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": " "})
assert resp.status_code == 400
@patch("services.analysis_service.delete", new_callable=AsyncMock)
def test_delete_analysis(self, mock_delete, client):
mock_delete.return_value = True
def test_delete_analysis(self, client, mock_analysis_service):
mock_analysis_service.delete = AsyncMock(return_value=True)
resp = client.delete("/api/analyses/j1")
assert resp.status_code == 204
@patch("services.analysis_service.delete", new_callable=AsyncMock)
def test_delete_analysis_not_found(self, mock_delete, client):
mock_delete.return_value = False
def test_delete_analysis_not_found(self, client, mock_analysis_service):
mock_analysis_service.delete = AsyncMock(return_value=False)
resp = client.delete("/api/analyses/missing")
assert resp.status_code == 404

View file

@ -8,12 +8,13 @@ misaligned overlays in the UI.
import pytest
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
# ---------------------------------------------------------------------------
class TestToTopleftListStandard:
"""Normal bbox conversions (happy path)."""
@ -29,10 +30,10 @@ class TestToTopleftListStandard:
result = to_topleft_list(bbox, page_height=792.0)
# After conversion: new_t = 792 - 700 = 92, new_b = 792 - 600 = 192
assert result[0] == 50 # l unchanged
assert result[1] == pytest.approx(92.0) # t = page_height - old_t
assert result[2] == 200 # r unchanged
assert result[3] == pytest.approx(192.0) # b = page_height - old_b
assert result[0] == 50 # l unchanged
assert result[1] == pytest.approx(92.0) # t = page_height - old_t
assert result[2] == 200 # r unchanged
assert result[3] == pytest.approx(192.0) # b = page_height - old_b
def test_result_has_positive_dimensions(self):
"""Converted bbox should always have b > t (positive height)."""
@ -60,6 +61,7 @@ class TestToTopleftListStandard:
# Page format variations
# ---------------------------------------------------------------------------
class TestPageFormats:
"""Verify correct conversion across different page sizes."""
@ -105,6 +107,7 @@ class TestPageFormats:
# Degenerate / edge-case bboxes
# ---------------------------------------------------------------------------
class TestDegenerateBboxes:
"""Bboxes that are invalid or degenerate should return EMPTY_BBOX."""
@ -151,6 +154,7 @@ class TestDegenerateBboxes:
# Precision and boundary values
# ---------------------------------------------------------------------------
class TestPrecision:
"""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 unittest.mock import AsyncMock, MagicMock, patch
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,
TableFormerMode,
)
from domain.parsing import (
ConversionOptions,
build_converter,
convert_document,
from domain.value_objects import ConversionOptions # noqa: E402
from infra.local_converter import ( # noqa: E402
_build_docling_converter as build_converter,
)
from infra.local_converter import ( # noqa: E402
_convert_sync as convert_document,
)
# ---------------------------------------------------------------------------
# build_converter — verifies Docling pipeline options are wired correctly
# ---------------------------------------------------------------------------
class TestBuildConverter:
"""Verify that build_converter produces a DocumentConverter with the right PdfPipelineOptions."""
@ -30,7 +41,7 @@ class TestBuildConverter:
return fmt_opt.pipeline_options
def test_defaults(self):
conv = build_converter()
conv = build_converter(ConversionOptions())
opts = self._get_pipeline_options(conv)
assert opts.do_ocr is True
assert opts.do_table_structure is True
@ -99,18 +110,20 @@ class TestBuildConverter:
assert opts.images_scale == 2.0
def test_all_options_combined(self):
conv = build_converter(ConversionOptions(
do_ocr=False,
do_table_structure=True,
table_mode="fast",
do_code_enrichment=True,
do_formula_enrichment=True,
do_picture_classification=True,
do_picture_description=True,
generate_picture_images=True,
generate_page_images=True,
images_scale=1.5,
))
conv = build_converter(
ConversionOptions(
do_ocr=False,
do_table_structure=True,
table_mode="fast",
do_code_enrichment=True,
do_formula_enrichment=True,
do_picture_classification=True,
do_picture_description=True,
generate_picture_images=True,
generate_page_images=True,
images_scale=1.5,
)
)
opts = self._get_pipeline_options(conv)
assert opts.do_ocr is False
assert opts.do_table_structure is True
@ -128,11 +141,12 @@ class TestBuildConverter:
# convert_document — default vs custom converter routing
# ---------------------------------------------------------------------------
class TestConvertDocumentRouting:
"""Verify convert_document uses default converter for default opts, custom otherwise."""
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
@patch("infra.local_converter._get_default_converter")
@patch("infra.local_converter._build_docling_converter")
def test_uses_default_converter_with_all_defaults(self, mock_build, mock_get_default):
mock_conv = MagicMock()
mock_result = MagicMock()
@ -140,16 +154,17 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.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_get_default.return_value = mock_conv
convert_document("/tmp/test.pdf")
convert_document("/tmp/test.pdf", ConversionOptions())
mock_get_default.assert_called_once()
mock_build.assert_not_called()
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
@patch("infra.local_converter._get_default_converter")
@patch("infra.local_converter._build_docling_converter")
def test_uses_custom_converter_when_ocr_disabled(self, mock_build, mock_get_default):
mock_conv = MagicMock()
mock_result = MagicMock()
@ -157,6 +172,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.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_build.return_value = mock_conv
@ -165,8 +181,8 @@ class TestConvertDocumentRouting:
mock_build.assert_called_once()
mock_get_default.assert_not_called()
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
@patch("infra.local_converter._get_default_converter")
@patch("infra.local_converter._build_docling_converter")
def test_uses_custom_converter_when_table_mode_fast(self, mock_build, mock_get_default):
mock_conv = MagicMock()
mock_result = MagicMock()
@ -174,6 +190,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.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_build.return_value = mock_conv
@ -182,8 +199,8 @@ class TestConvertDocumentRouting:
mock_build.assert_called_once_with(opts)
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
@patch("infra.local_converter._get_default_converter")
@patch("infra.local_converter._build_docling_converter")
def test_uses_custom_converter_when_code_enrichment_on(self, mock_build, mock_get_default):
mock_conv = MagicMock()
mock_result = MagicMock()
@ -191,6 +208,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.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_build.return_value = mock_conv
@ -199,8 +217,8 @@ class TestConvertDocumentRouting:
mock_build.assert_called_once_with(opts)
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
@patch("infra.local_converter._get_default_converter")
@patch("infra.local_converter._build_docling_converter")
def test_uses_custom_converter_when_formula_enrichment_on(self, mock_build, mock_get_default):
mock_conv = MagicMock()
mock_result = MagicMock()
@ -208,6 +226,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.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_build.return_value = mock_conv
@ -215,8 +234,8 @@ class TestConvertDocumentRouting:
mock_build.assert_called_once()
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
@patch("infra.local_converter._get_default_converter")
@patch("infra.local_converter._build_docling_converter")
def test_uses_custom_converter_when_picture_options_on(self, mock_build, mock_get_default):
mock_conv = MagicMock()
mock_result = MagicMock()
@ -224,6 +243,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.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_build.return_value = mock_conv
@ -231,8 +251,8 @@ class TestConvertDocumentRouting:
mock_build.assert_called_once()
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
@patch("infra.local_converter._get_default_converter")
@patch("infra.local_converter._build_docling_converter")
def test_uses_custom_converter_when_generate_images_on(self, mock_build, mock_get_default):
mock_conv = MagicMock()
mock_result = MagicMock()
@ -240,6 +260,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.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_build.return_value = mock_conv
@ -247,8 +268,8 @@ class TestConvertDocumentRouting:
mock_build.assert_called_once()
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
@patch("infra.local_converter._get_default_converter")
@patch("infra.local_converter._build_docling_converter")
def test_uses_custom_converter_when_images_scale_changed(self, mock_build, mock_get_default):
mock_conv = MagicMock()
mock_result = MagicMock()
@ -256,6 +277,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.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_build.return_value = mock_conv
@ -264,8 +286,8 @@ class TestConvertDocumentRouting:
mock_build.assert_called_once_with(opts)
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
@patch("infra.local_converter._get_default_converter")
@patch("infra.local_converter._build_docling_converter")
def test_forwards_all_options_to_build_converter(self, mock_build, mock_get_default):
mock_conv = MagicMock()
mock_result = MagicMock()
@ -273,6 +295,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.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_build.return_value = mock_conv
@ -297,77 +320,92 @@ class TestConvertDocumentRouting:
# Service layer — pipeline options forwarding
# ---------------------------------------------------------------------------
class TestServiceForwardsPipelineOptions:
"""Verify analysis_service.create and _run_analysis forward pipeline options."""
@pytest.fixture
def mock_doc(self):
from domain.models import Document
return Document(id="d1", filename="test.pdf", storage_path="/tmp/test.pdf")
@pytest.fixture
def mock_job(self):
from domain.models import AnalysisJob
return AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
@patch("services.analysis_service.document_repo")
@patch("services.analysis_service.analysis_repo")
@patch("services.analysis_service._run_analysis")
@pytest.mark.asyncio
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_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"}
# We need to patch asyncio.create_task to capture the coroutine args
with patch("services.analysis_service.asyncio.create_task") as mock_task:
await analysis_service.create("d1", pipeline_options=opts)
# create_task should have been called with _run_analysis(...)
await svc.create("d1", pipeline_options=opts)
mock_task.assert_called_once()
@patch("services.analysis_service.document_repo")
@patch("services.analysis_service.analysis_repo")
@pytest.mark.asyncio
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_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:
await analysis_service.create("d1")
await svc.create("d1")
mock_task.assert_called_once()
@patch("services.analysis_service.analysis_repo")
@patch("services.analysis_service.document_repo")
@patch("services.analysis_service.convert_document")
@pytest.mark.asyncio
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.update_status = 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,
content_markdown="# Test",
content_html="<h1>Test</h1>",
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 = {
"do_ocr": False,
@ -381,10 +419,10 @@ class TestServiceForwardsPipelineOptions:
"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()
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"
conv_opts = call_args[0][1]
assert conv_opts.do_ocr is False
@ -395,47 +433,58 @@ class TestServiceForwardsPipelineOptions:
@patch("services.analysis_service.analysis_repo")
@patch("services.analysis_service.document_repo")
@patch("services.analysis_service.convert_document")
@pytest.mark.asyncio
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.update_status = 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,
content_markdown="",
content_html="",
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
mock_convert.assert_called_once()
call_args = mock_convert.call_args
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", None)
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][1] == ConversionOptions()
@patch("services.analysis_service.analysis_repo")
@patch("services.analysis_service.document_repo")
@patch("services.analysis_service.convert_document")
@pytest.mark.asyncio
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.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
assert mock_analysis_repo.update_status.call_count == 2
@ -448,6 +497,7 @@ class TestServiceForwardsPipelineOptions:
# API endpoint — full request/response with pipeline options
# ---------------------------------------------------------------------------
class TestAnalysisEndpointPipelineOptions:
"""Integration-level tests for the analysis creation endpoint with pipeline options."""
@ -456,28 +506,44 @@ class TestAnalysisEndpointPipelineOptions:
from fastapi.testclient import TestClient
from main import app
return TestClient(app, raise_server_exceptions=False)
@patch("services.analysis_service.create", new_callable=AsyncMock)
def test_no_pipeline_options_sends_none(self, mock_create, client):
@pytest.fixture
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
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"})
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, mock_create, client):
def test_empty_pipeline_options_object_uses_defaults(self, client, mock_svc):
from domain.models import AnalysisJob
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
client.post("/api/analyses", json={
"documentId": "d1",
"pipelineOptions": {},
})
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
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_table_structure"] is True
assert opts["table_mode"] == "accurate"
@ -485,20 +551,22 @@ class TestAnalysisEndpointPipelineOptions:
assert opts["do_formula_enrichment"] is False
assert opts["images_scale"] == 1.0
@patch("services.analysis_service.create", new_callable=AsyncMock)
def test_partial_pipeline_options_merges_with_defaults(self, mock_create, client):
def test_partial_pipeline_options_merges_with_defaults(self, client, mock_svc):
from domain.models import AnalysisJob
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
client.post("/api/analyses", json={
"documentId": "d1",
"pipelineOptions": {"do_ocr": False, "images_scale": 1.5},
})
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
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["images_scale"] == 1.5
# All other fields should have defaults
assert opts["do_table_structure"] is True
assert opts["table_mode"] == "accurate"
assert opts["do_code_enrichment"] is False
@ -508,10 +576,10 @@ class TestAnalysisEndpointPipelineOptions:
assert opts["generate_picture_images"] is False
assert opts["generate_page_images"] is False
@patch("services.analysis_service.create", new_callable=AsyncMock)
def test_full_pipeline_options(self, mock_create, client):
def test_full_pipeline_options(self, client, mock_svc):
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 = {
"documentId": "d1",
@ -532,25 +600,29 @@ class TestAnalysisEndpointPipelineOptions:
resp = client.post("/api/analyses", json=payload)
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"]
@patch("services.analysis_service.create", new_callable=AsyncMock)
def test_invalid_pipeline_option_type_rejected(self, mock_create, client):
resp = client.post("/api/analyses", json={
"documentId": "d1",
"pipelineOptions": {"do_ocr": "not-a-bool"},
})
def test_invalid_pipeline_option_type_rejected(self, client, mock_svc):
resp = client.post(
"/api/analyses",
json={
"documentId": "d1",
"pipelineOptions": {"do_ocr": "not-a-bool"},
},
)
assert resp.status_code == 422
@patch("services.analysis_service.create", new_callable=AsyncMock)
def test_unknown_pipeline_option_ignored(self, mock_create, client):
def test_unknown_pipeline_option_ignored(self, client, mock_svc):
from domain.models import AnalysisJob
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
resp = client.post("/api/analyses", json={
"documentId": "d1",
"pipelineOptions": {"do_ocr": True, "unknown_field": True},
})
# Pydantic ignores extra fields by default
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
resp = client.post(
"/api/analyses",
json={
"documentId": "d1",
"pipelineOptions": {"do_ocr": True, "unknown_field": True},
},
)
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."""
import pytest
from domain.models import AnalysisJob, AnalysisStatus, Document

View file

@ -1,6 +1,5 @@
"""Tests for API schemas — camelCase serialization and validation."""
import pytest
from api.schemas import (
@ -97,7 +96,10 @@ class TestPipelineOptionsRequest:
def test_custom_values(self):
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.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" />
declare const __APP_VERSION__: string
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>

View file

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

View file

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

View file

@ -4,6 +4,12 @@ server {
root /usr/share/nginx/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 / {
try_files $uri $uri/ /index.html;
}

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "docling-studio",
"version": "0.1.0",
"version": "0.3.0",
"private": true,
"type": "module",
"scripts": {
@ -16,7 +16,6 @@
"format:check": "prettier --check src/"
},
"dependencies": {
"@vitest/mocker": "^4.1.2",
"dompurify": "^3.3.3",
"marked": "^17.0.4",
"pinia": "^2.3.0",
@ -25,6 +24,7 @@
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@vitest/mocker": "^4.1.2",
"@types/dompurify": "^3.2.0",
"@vitejs/plugin-vue": "^6.0.5",
"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>
<div class="app-layout">
<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">
<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>
</button>
<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>
</div>
<div class="topbar-spacer" />
<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') }}
</button>
</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">
<AppSidebar :open="sidebarOpen" />
<main class="main">
@ -27,11 +46,12 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { RouterView, useRouter } from 'vue-router'
import { AppSidebar } from '../shared/ui/index'
import { useSettingsStore } from '../features/settings/store'
import { useDocumentStore } from '../features/document/store'
import { useFeatureFlag } from '../features/feature-flags'
import { useI18n } from '../shared/i18n'
useSettingsStore()
@ -40,6 +60,13 @@ const router = useRouter()
const documentStore = useDocumentStore()
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() {
documentStore.selectedId = null
@ -52,26 +79,26 @@ function newAnalysis() {
:root {
/* Dark mode palette - MistralAI Studio inspired */
--bg: #0A0A0B;
--bg: #0a0a0b;
--bg-surface: #111113;
--bg-elevated: #1A1A1D;
--bg-elevated: #1a1a1d;
--bg-hover: #222226;
--accent: #F97316;
--accent-hover: #FB923C;
--accent: #f97316;
--accent-hover: #fb923c;
--accent-muted: rgba(249, 115, 22, 0.15);
--text: #ECECEF;
--text-secondary: #A1A1AA;
--text-muted: #63636E;
--text: #ececef;
--text-secondary: #a1a1aa;
--text-muted: #63636e;
--border: #27272A;
--border-light: #3F3F46;
--border: #27272a;
--border-light: #3f3f46;
--success: #22C55E;
--error: #EF4444;
--warning: #EAB308;
--info: #3B82F6;
--success: #22c55e;
--error: #ef4444;
--warning: #eab308;
--info: #3b82f6;
--radius: 8px;
--radius-sm: 6px;
@ -83,32 +110,40 @@ function newAnalysis() {
}
html.light {
--bg: #FAFAFA;
--bg-surface: #FFFFFF;
--bg-elevated: #F4F4F5;
--bg-hover: #E4E4E7;
--bg: #fafafa;
--bg-surface: #ffffff;
--bg-elevated: #f4f4f5;
--bg-hover: #e4e4e7;
--accent: #F97316;
--accent-hover: #EA580C;
--accent-muted: rgba(249, 115, 22, 0.10);
--accent: #f97316;
--accent-hover: #ea580c;
--accent-muted: rgba(249, 115, 22, 0.1);
--text: #18181B;
--text-secondary: #52525B;
--text-muted: #A1A1AA;
--text: #18181b;
--text-secondary: #52525b;
--text-muted: #a1a1aa;
--border: #E4E4E7;
--border-light: #D4D4D8;
--border: #e4e4e7;
--border-light: #d4d4d8;
--success: #16A34A;
--error: #DC2626;
--warning: #CA8A04;
--info: #2563EB;
--success: #16a34a;
--error: #dc2626;
--warning: #ca8a04;
--info: #2563eb;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
@ -123,6 +158,36 @@ body {
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 {
height: var(--topbar-height);
min-height: var(--topbar-height);
@ -167,16 +232,10 @@ body {
}
.topbar-logo-icon {
width: 26px;
height: 26px;
background: var(--accent);
color: white;
display: flex;
align-items: center;
justify-content: center;
border-radius: 5px;
font-weight: 700;
font-size: 13px;
width: 28px;
height: 28px;
border-radius: 6px;
object-fit: contain;
}
.topbar-logo-text {

View file

@ -1,9 +1,14 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { router } from './router'
import { useFeatureFlagStore } from '../features/feature-flags'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.use(router)
const featureFlags = useFeatureFlagStore()
featureFlags.load()
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'
export function createAnalysis(
documentId: string,
pipelineOptions: PipelineOptions | null = null,
chunkingOptions: ChunkingOptions | null = null,
): Promise<Analysis> {
const body: Record<string, unknown> = { documentId }
if (pipelineOptions) {
body.pipelineOptions = pipelineOptions
}
if (chunkingOptions) {
body.chunkingOptions = chunkingOptions
}
return apiFetch<Analysis>('/api/analyses', {
method: 'POST',
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[]> {
return apiFetch<Analysis[]>('/api/analyses')
}

View file

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

View file

@ -20,7 +20,6 @@ import * as api from './api'
// ---------------------------------------------------------------------------
describe('createAnalysis — pipeline options body construction', () => {
beforeEach(() => {
vi.clearAllMocks()
// 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
// ---------------------------------------------------------------------------
@ -131,7 +129,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => {
const store = useAnalysisStore()
await store.run('d1')
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null)
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, null)
store.stopPolling()
})
@ -155,7 +153,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => {
}
await store.run('d1', opts)
expect(api.createAnalysis).toHaveBeenCalledWith('d1', opts)
expect(api.createAnalysis).toHaveBeenCalledWith('d1', opts, null)
store.stopPolling()
})
@ -168,7 +166,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => {
const opts = { do_ocr: false }
await store.run('d1', opts)
expect(api.createAnalysis).toHaveBeenCalledWith('d1', { do_ocr: false })
expect(api.createAnalysis).toHaveBeenCalledWith('d1', { do_ocr: false }, null)
store.stopPolling()
})

View file

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

View file

@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
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'
export const useAnalysisStore = defineStore('analysis', () => {
@ -9,6 +9,8 @@ export const useAnalysisStore = defineStore('analysis', () => {
const running = ref(false)
const error = ref<string | 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[]>(() => {
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(
documentId: string,
pipelineOptions: PipelineOptions | null = null,
chunkingOptions: ChunkingOptions | null = null,
): Promise<Analysis> {
running.value = true
error.value = null
try {
const analysis = await api.createAnalysis(documentId, pipelineOptions)
const analysis = await api.createAnalysis(documentId, pipelineOptions, chunkingOptions)
currentAnalysis.value = analysis
analyses.value.unshift(analysis)
startPolling(analysis.id)
@ -72,6 +104,13 @@ export const useAnalysisStore = defineStore('analysis', () => {
running.value = false
}
}, 2000)
pollingTimeout.value = setTimeout(() => {
if (pollingInterval.value) {
error.value = 'Analysis timed out'
stopPolling()
running.value = false
}
}, MAX_POLLING_DURATION)
}
function stopPolling(): void {
@ -79,6 +118,10 @@ export const useAnalysisStore = defineStore('analysis', () => {
clearInterval(pollingInterval.value)
pollingInterval.value = null
}
if (pollingTimeout.value) {
clearTimeout(pollingTimeout.value)
pollingTimeout.value = null
}
}
async function select(id: string): Promise<void> {
@ -105,11 +148,14 @@ export const useAnalysisStore = defineStore('analysis', () => {
analyses,
currentAnalysis,
currentPages,
currentChunks,
running,
rechunking,
error,
clearError,
load,
run,
rechunk,
select,
remove,
stopPolling,

View file

@ -20,14 +20,14 @@
</div>
<div class="panel-actions" v-if="selectedDoc">
<button
class="btn-analyze"
:disabled="analysisStore.running"
@click="runAnalysis"
>
<button class="btn-analyze" :disabled="analysisStore.running" @click="runAnalysis">
<div v-if="analysisStore.running" class="spinner" />
<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>
{{ analysisStore.running ? 'Analyzing...' : 'Analyze' }}
</button>
@ -55,7 +55,7 @@ const analysisStore = useAnalysisStore()
const currentPage = ref(1)
const selectedDoc = computed(() => {
return documentStore.documents.find(d => d.id === documentStore.selectedId)
return documentStore.documents.find((d) => d.id === documentStore.selectedId)
})
const statusClass = computed(() => {
@ -64,7 +64,7 @@ const statusClass = computed(() => {
'status-pending': status === 'PENDING',
'status-running': status === 'RUNNING',
'status-completed': status === 'COMPLETED',
'status-failed': status === 'FAILED'
'status-failed': status === 'FAILED',
}
})
@ -115,20 +115,32 @@ async function runAnalysis() {
transition: background var(--transition);
}
.btn-analyze:hover:not(:disabled) { background: var(--accent-hover); }
.btn-analyze:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-icon { width: 18px; height: 18px; }
.btn-analyze:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-analyze:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-icon {
width: 18px;
height: 18px;
}
.spinner {
width: 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-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.status-row {
display: flex;
@ -143,8 +155,20 @@ async function runAnalysis() {
border-radius: 12px;
}
.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: rgba(34, 197, 94, 0.15); color: var(--success); }
.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: rgba(34, 197, 94, 0.15);
color: var(--success);
}
.status-failed {
background: rgba(239, 68, 68, 0.15);
color: var(--error);
}
</style>

View file

@ -20,14 +20,13 @@
ref="canvasRef"
class="overlay-canvas"
@mousemove="onMouseMove"
@mouseleave="hoveredElement = null; emit('highlight-element', -1)"
@mouseleave="onMouseLeave"
/>
<div
v-if="hoveredElement"
class="tooltip"
:style="tooltipStyle"
>
<span class="tooltip-type" :style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }">
<div v-if="hoveredElement" class="tooltip" :style="tooltipStyle">
<span
class="tooltip-type"
:style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }"
>
{{ hoveredElement.type }}
</span>
<span class="tooltip-content">{{ hoveredElement.content?.substring(0, 150) }}</span>
@ -38,7 +37,7 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, reactive, onMounted, onBeforeUnmount } from 'vue'
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> = {
title: '#EF4444',
@ -49,14 +48,18 @@ const ELEMENT_COLORS: Record<string, string> = {
list: '#06B6D4',
formula: '#EC4899',
code: '#14B8A6',
caption: '#EAB308'
caption: '#EAB308',
}
const props = defineProps<{
imageEl: HTMLImageElement | null
pageData: Page | null
highlightedIndex: number
}>()
const props = withDefaults(
defineProps<{
imageEl: HTMLImageElement | null
pageData: Page | null
highlightedIndex: number
highlightedBboxes: ChunkBbox[]
}>(),
{ highlightedBboxes: () => [] },
)
const emit = defineEmits<{
'highlight-element': [index: number]
@ -112,22 +115,49 @@ function draw(): void {
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) {
const rect = bboxToRect(el.bbox, scale)
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
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.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)
}
// 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 {
@ -139,7 +169,12 @@ function onMouseMove(e: MouseEvent): void {
const mx = e.clientX - canvasRect.left
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 foundIdx = -1
@ -156,7 +191,7 @@ function onMouseMove(e: MouseEvent): void {
if (found) {
tooltipStyle.value = {
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
watch([() => props.pageData, () => props.imageEl, () => props.highlightedIndex, hiddenTypes], () => {
nextTick(draw)
})
watch(
[
() => props.pageData,
() => props.imageEl,
() => props.highlightedIndex,
() => props.highlightedBboxes,
hiddenTypes,
],
() => {
nextTick(draw)
},
)
// Expose draw so parent can call it after image load
defineExpose({ draw })
@ -219,8 +263,13 @@ defineExpose({ draw })
opacity: 0.9;
}
.legend-chip:hover { opacity: 1; background: var(--bg-hover); }
.legend-chip.dimmed { opacity: 0.35; }
.legend-chip:hover {
opacity: 1;
background: var(--bg-hover);
}
.legend-chip.dimmed {
opacity: 0.35;
}
.legend-dot {
width: 6px;
@ -252,7 +301,7 @@ defineExpose({ draw })
max-width: 250px;
pointer-events: none;
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 {

View file

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

View file

@ -33,11 +33,19 @@ const rendered = computed(() => {
font-weight: 600;
}
.markdown-viewer :deep(h1) { font-size: 24px; }
.markdown-viewer :deep(h2) { font-size: 20px; }
.markdown-viewer :deep(h3) { font-size: 16px; }
.markdown-viewer :deep(h1) {
font-size: 24px;
}
.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) {
background: var(--bg-elevated);

View file

@ -14,7 +14,9 @@
<!-- Page chip -->
<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 class="tab-content">
@ -32,7 +34,10 @@
@mouseleave="$emit('highlight-element', -1)"
>
<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 }}
</span>
<span class="element-level" v-if="el.level">L{{ el.level }}</span>
@ -42,12 +47,23 @@
:title="t('results.copy')"
@click.stop="copyElement(idx, el.content)"
>
<svg v-if="!copiedElements[idx]" 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
v-if="!copiedElements[idx]"
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 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>
</button>
</div>
@ -57,7 +73,7 @@
<span v-else>{{ el.content }}</span>
</div>
<div class="element-bbox">
{{ el.bbox.map(v => Math.round(v)).join(', ') }}
{{ el.bbox.map((v) => Math.round(v)).join(', ') }}
</div>
</div>
</div>
@ -66,11 +82,17 @@
<div v-else-if="activeTab === 'markdown'" class="raw-markdown">
<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">
<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="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 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>
</button>
<pre class="raw-content">{{ pageMarkdown }}</pre>
@ -85,12 +107,26 @@
<span>{{ t('studio.analysisRunning') }}</span>
</div>
<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>
</div>
<div v-else class="result-placeholder">
<svg 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
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>
<span>{{ t('results.runAnalysis') }}</span>
</div>
@ -113,12 +149,12 @@ const ELEMENT_COLORS: Record<string, string> = {
list: '#06B6D4',
formula: '#EC4899',
code: '#14B8A6',
caption: '#EAB308'
caption: '#EAB308',
}
const props = defineProps({
currentPage: { type: Number, default: 1 },
highlightedIndex: { type: Number, default: -1 }
highlightedIndex: { type: Number, default: -1 },
})
defineEmits(['highlight-element'])
@ -130,17 +166,17 @@ const activeTab = ref('elements')
const tabs = computed(() => [
{ id: 'elements', label: t('results.elements') },
{ 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 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(() => {
return (currentPageData.value?.elements || []).filter(el => el.content)
return (currentPageData.value?.elements || []).filter((el) => el.content)
})
const currentPageAsArray = computed(() => {
@ -153,7 +189,7 @@ const pageMarkdown = computed(() => {
if (!page) return ''
return (page.elements || [])
.map(el => formatElement(el))
.map((el) => formatElement(el))
.filter(Boolean)
.join('\n\n')
})
@ -191,16 +227,24 @@ async function copyMarkdown() {
try {
await navigator.clipboard.writeText(pageMarkdown.value)
copiedMarkdown.value = true
setTimeout(() => { copiedMarkdown.value = false }, 1500)
} catch { /* clipboard not available */ }
setTimeout(() => {
copiedMarkdown.value = false
}, 1500)
} catch {
/* clipboard not available */
}
}
async function copyElement(idx: number, content: string) {
try {
await navigator.clipboard.writeText(content)
copiedElements[idx] = true
setTimeout(() => { copiedElements[idx] = false }, 1500)
} catch { /* clipboard not available */ }
setTimeout(() => {
copiedElements[idx] = false
}, 1500)
} catch {
/* clipboard not available */
}
}
</script>
@ -234,7 +278,9 @@ async function copyElement(idx: number, content: string) {
white-space: nowrap;
}
.tab-btn:hover { color: var(--text-secondary); }
.tab-btn:hover {
color: var(--text-secondary);
}
.tab-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
@ -368,8 +414,13 @@ async function copyElement(idx: number, content: string) {
background: var(--accent-muted);
}
.copy-icon { width: 14px; height: 14px; }
.copy-icon.copied { color: var(--success); }
.copy-icon {
width: 14px;
height: 14px;
}
.copy-icon.copied {
color: var(--success);
}
.copy-btn-element {
margin-left: auto;
@ -377,7 +428,9 @@ async function copyElement(idx: number, content: string) {
transition: opacity var(--transition);
}
.element-card:hover .copy-btn-element { opacity: 1; }
.element-card:hover .copy-btn-element {
opacity: 1;
}
.copy-btn-block {
position: absolute;
@ -389,7 +442,9 @@ async function copyElement(idx: number, content: string) {
transition: opacity var(--transition);
}
.raw-markdown:hover .copy-btn-block { opacity: 1; }
.raw-markdown:hover .copy-btn-block {
opacity: 1;
}
/* --- Raw markdown --- */
.raw-markdown {
@ -423,9 +478,18 @@ async function copyElement(idx: number, content: string) {
font-size: 14px;
}
.result-placeholder.error { color: var(--error); }
.error-icon { width: 32px; height: 32px; }
.empty-icon { width: 48px; height: 48px; color: var(--border-light); }
.result-placeholder.error {
color: var(--error);
}
.error-icon {
width: 32px;
height: 32px;
}
.empty-icon {
width: 48px;
height: 48px;
color: var(--border-light);
}
.spinner-large {
width: 32px;
@ -436,5 +500,9 @@ async function copyElement(idx: number, content: string) {
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -44,12 +44,11 @@
@mouseleave="hoveredElement = null"
/>
<!-- Tooltip -->
<div
v-if="hoveredElement"
class="tooltip"
:style="tooltipStyle"
>
<span class="tooltip-type" :style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }">
<div v-if="hoveredElement" class="tooltip" :style="tooltipStyle">
<span
class="tooltip-type"
:style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }"
>
{{ hoveredElement.type }}
</span>
<span class="tooltip-content">{{ hoveredElement.content?.substring(0, 150) }}</span>
@ -71,12 +70,12 @@ const ELEMENT_COLORS: Record<string, string> = {
picture: '#22C55E',
list: '#06B6D4',
formula: '#EC4899',
caption: '#EAB308'
caption: '#EAB308',
}
const props = defineProps({
pages: { type: Array as () => Page[], default: () => [] },
documentId: String
documentId: String,
})
const selectedPage = ref(1)
@ -89,12 +88,12 @@ const tooltipStyle = ref<Record<string, string>>({})
const imageSize = ref({ width: 0, height: 0 })
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(() => {
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(() => {
@ -110,7 +109,7 @@ function toggleType(type: string) {
function countElements(type: string) {
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() {
@ -174,7 +173,7 @@ function onMouseMove(e: MouseEvent) {
if (found) {
tooltipStyle.value = {
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);
}
.page-btn:hover { background: var(--bg-hover); }
.page-btn:hover {
background: var(--bg-hover);
}
.page-btn.active {
background: var(--accent-muted);
border-color: var(--accent);
@ -240,8 +241,12 @@ watch([() => props.pages, selectedPage, hiddenTypes], () => {
transition: all var(--transition);
}
.legend-item:hover { background: var(--bg-hover); }
.legend-item.dimmed { opacity: 0.4; }
.legend-item:hover {
background: var(--bg-hover);
}
.legend-item.dimmed {
opacity: 0.4;
}
.legend-dot {
width: 8px;
@ -286,7 +291,7 @@ watch([() => props.pages, selectedPage, hiddenTypes], () => {
max-width: 250px;
pointer-events: none;
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 {

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 () => {
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)
const store = useDocumentStore()
@ -61,7 +64,12 @@ describe('useDocumentStore', () => {
it('upload() sets uploading to true during upload', async () => {
let resolveUpload
api.uploadDocument.mockImplementation(() => new Promise(r => { resolveUpload = r }))
api.uploadDocument.mockImplementation(
() =>
new Promise((r) => {
resolveUpload = r
}),
)
const store = useDocumentStore()
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 * as api from './api'
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
export const useDocumentStore = defineStore('document', () => {
const documents = ref<Document[]>([])
const selectedId = ref<string | null>(null)
@ -24,6 +26,10 @@ export const useDocumentStore = defineStore('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
error.value = null
try {

View file

@ -9,15 +9,28 @@
>
<div class="doc-info">
<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>
<div class="doc-meta">
<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>
<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>
</div>
</div>
@ -101,7 +114,15 @@ const store = useDocumentStore()
transition: all var(--transition);
}
.doc-item:hover .doc-delete { opacity: 1; }
.doc-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); }
.doc-delete svg { width: 14px; height: 14px; }
.doc-item:hover .doc-delete {
opacity: 1;
}
.doc-delete:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.1);
}
.doc-delete svg {
width: 14px;
height: 14px;
}
</style>

View file

@ -13,11 +13,18 @@
<span>{{ t('upload.uploading') }}</span>
</div>
<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" />
</svg>
<span class="upload-text">{{ t('upload.drop') }}</span>
<span class="upload-hint">{{ t('upload.maxSize') }}</span>
<span v-if="store.error" class="upload-error">{{ store.error }}</span>
</div>
</div>
</template>
@ -41,9 +48,14 @@ function openFilePicker() {
async function onFileSelect(e: Event) {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
const doc = await store.upload(file)
if (doc) emit('uploaded', doc.id)
if (file && (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'))) {
try {
store.clearError()
const doc = await store.upload(file)
if (doc) emit('uploaded', doc.id)
} catch {
// error is already set in store.upload
}
}
target.value = ''
}
@ -51,9 +63,14 @@ async function onFileSelect(e: Event) {
async function onDrop(e: DragEvent) {
dragging.value = false
const file = e.dataTransfer?.files?.[0]
if (file && file.type === 'application/pdf') {
const doc = await store.upload(file)
if (doc) emit('uploaded', doc.id)
if (file && (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'))) {
try {
store.clearError()
const doc = await store.upload(file)
if (doc) emit('uploaded', doc.id)
} catch {
// error is already set in store.upload
}
}
}
</script>
@ -103,6 +120,12 @@ async function onDrop(e: DragEvent) {
color: var(--text-muted);
}
.upload-error {
font-size: 13px;
color: var(--error, #e53e3e);
font-weight: 500;
}
.spinner {
width: 24px;
height: 24px;
@ -113,6 +136,8 @@ async function onDrop(e: DragEvent) {
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -4,10 +4,26 @@
<span class="preview-label">Page {{ page }}</span>
<div class="preview-nav">
<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 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
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>
</div>
</div>
@ -30,7 +46,7 @@ import { getPreviewUrl } from '../api'
const props = defineProps({
documentId: String,
page: { type: Number, default: 1 },
pageCount: Number
pageCount: Number,
})
defineEmits(['update:page', 'imageLoaded'])
@ -76,9 +92,18 @@ const previewUrl = computed(() => {
transition: all var(--transition);
}
.nav-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
.nav-btn:disabled { opacity: 0.3; cursor: default; }
.nav-btn svg { width: 16px; height: 16px; }
.nav-btn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text);
}
.nav-btn:disabled {
opacity: 0.3;
cursor: default;
}
.nav-btn svg {
width: 16px;
height: 16px;
}
.preview-image-wrapper {
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-header">
<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 }}
</span>
</div>
@ -23,7 +32,13 @@
</div>
</div>
<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>
</div>
</div>
@ -49,7 +64,7 @@ function statusClass(status: string) {
'status-pending': status === 'PENDING',
'status-running': status === 'RUNNING',
'status-completed': status === 'COMPLETED',
'status-failed': status === 'FAILED'
'status-failed': status === 'FAILED',
}
}
@ -81,22 +96,31 @@ function duration(analysis: Analysis) {
.history-items {
display: flex;
flex-direction: column;
gap: 2px;
gap: 6px;
padding: 12px;
}
.history-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
transition: background var(--transition);
padding: 12px 16px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
transition: all 150ms ease;
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 {
display: flex;
@ -122,10 +146,28 @@ function duration(analysis: Analysis) {
flex-shrink: 0;
}
.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: rgba(34, 197, 94, 0.15); color: var(--success); }
.status-failed { background: rgba(239, 68, 68, 0.15); color: var(--error); }
.status-dot {
width: 8px;
height: 8px;
}
.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 {
font-size: 12px;
@ -146,7 +188,15 @@ function duration(analysis: Analysis) {
transition: all var(--transition);
}
.history-item:hover .item-delete { opacity: 1; }
.item-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); }
.item-delete svg { width: 16px; height: 16px; }
.history-item:hover .item-delete {
opacity: 1;
}
.item-delete:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.1);
}
.item-delete svg {
width: 16px;
height: 16px;
}
</style>

View file

@ -2,13 +2,29 @@ import { defineStore } from 'pinia'
import { ref, watch, watchEffect } from 'vue'
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', () => {
const apiUrl = ref('http://localhost:8000')
const theme = ref<Theme>((localStorage.getItem('docling-theme') as Theme) || 'dark')
const locale = ref<Locale>((localStorage.getItem('docling-locale') as Locale) || 'fr')
const theme = ref<Theme>((safeGetItem('docling-theme') as Theme) || 'dark')
const locale = ref<Locale>((safeGetItem('docling-locale') as Locale) || 'fr')
watch(theme, (v) => localStorage.setItem('docling-theme', v))
watch(locale, (v) => localStorage.setItem('docling-locale', v))
watch(theme, (v) => safeSetItem('docling-theme', v))
watch(locale, (v) => safeSetItem('docling-locale', v))
watchEffect(() => {
document.documentElement.classList.toggle('light', theme.value === 'light')

View file

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

View file

@ -8,14 +8,14 @@
{{ t('history.emptyDocs') }}
</div>
<div v-else class="doc-items">
<div
v-for="doc in docStore.documents"
:key="doc.id"
class="doc-row"
>
<div v-for="doc in docStore.documents" :key="doc.id" class="doc-row">
<div class="doc-row-info">
<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>
<div class="doc-row-meta">
<span class="doc-row-name">{{ doc.filename }}</span>
@ -27,7 +27,13 @@
</div>
</div>
<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>
</div>
</div>
@ -89,19 +95,25 @@ onMounted(() => {
.doc-items {
display: flex;
flex-direction: column;
gap: 2px;
gap: 6px;
padding: 12px;
}
.doc-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
transition: background var(--transition);
padding: 12px 16px;
background: var(--bg-surface);
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 {
display: flex;
@ -152,7 +164,15 @@ onMounted(() => {
transition: all var(--transition);
}
.doc-row:hover .doc-row-delete { opacity: 1; }
.doc-row-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); }
.doc-row-delete svg { width: 16px; height: 16px; }
.doc-row:hover .doc-row-delete {
opacity: 1;
}
.doc-row-delete:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.1);
}
.doc-row-delete svg {
width: 16px;
height: 16px;
}
</style>

View file

@ -2,9 +2,7 @@
<div class="home-page">
<div class="home-center">
<div class="hero">
<div class="hero-icon">
<span class="hero-d">D</span>
</div>
<img src="/logo.png" alt="Docling Studio" class="hero-logo" />
<h1 class="hero-title">{{ t('home.title') }}</h1>
<p class="hero-subtitle">{{ t('home.subtitle') }}</p>
</div>
@ -17,10 +15,24 @@
<!-- Stats -->
<div class="home-stats" v-if="docCount > 0 || analysisCount > 0">
<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-label">{{ t('home.documents') }}</div>
</div>
<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-label">{{ t('home.analyses') }}</div>
</div>
@ -37,14 +49,22 @@
@click="openInStudio(doc)"
>
<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>
<div class="recent-meta">
<span class="recent-name">{{ doc.filename }}</span>
<span class="recent-detail">{{ doc.pageCount ? doc.pageCount + ' pages' : '' }}</span>
</div>
<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>
</button>
</div>
@ -114,21 +134,12 @@ onMounted(() => {
text-align: center;
}
.hero-icon {
.hero-logo {
width: 72px;
height: 72px;
object-fit: contain;
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);
font-weight: 700;
font-size: 26px;
}
.hero-title {
@ -171,10 +182,21 @@ onMounted(() => {
}
.stat-card:hover {
border-color: var(--border-light);
border-color: var(--accent);
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 {
font-size: 28px;
font-weight: 700;

View file

@ -2,9 +2,7 @@
<!-- STATE 1: No document selected Import view -->
<div v-if="!selectedDoc" class="import-page">
<div class="import-center">
<div class="import-logo">
<span class="logo-icon">D</span>
</div>
<img src="/logo.png" alt="Docling Studio" class="import-logo-img" />
<h1 class="import-title">{{ t('studio.title') }}</h1>
<p class="import-subtitle">{{ t('studio.subtitle') }}</p>
<DocumentUpload />
@ -27,6 +25,13 @@
:class="{ active: 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') }}
</button>
<button
@ -35,13 +40,38 @@
@click="mode = 'verifier'"
: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') }}
</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 class="topbar-actions">
<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') }}
</button>
<button
@ -51,7 +81,13 @@
v-if="mode === 'configurer'"
>
<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') }}
</button>
</div>
@ -60,7 +96,13 @@
<!-- Document info bar -->
<div class="doc-infobar">
<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-status-chip loaded">{{ t('studio.loaded') }}</span>
</div>
@ -86,7 +128,13 @@
<div class="pdf-viewer-panel">
<div class="pdf-nav-bar">
<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>
<div class="pdf-page-input-wrap">
<input
@ -99,8 +147,18 @@
/>
</div>
<span class="pdf-page-total">/ {{ selectedDoc.pageCount || '?' }}</span>
<button 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
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>
<span class="pdf-separator" />
<span class="pdf-zoom">100%</span>
@ -111,7 +169,14 @@
:class="{ active: 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') }}
</button>
</template>
@ -127,11 +192,12 @@
@load="onPdfImageLoad"
/>
<BboxOverlay
v-if="visualMode && hasAnalysisResults"
v-if="(visualMode || mode === 'preparer') && hasAnalysisResults"
ref="bboxOverlayRef"
:image-el="pdfImageRef"
:page-data="currentPageData"
:highlighted-index="highlightedElementIndex"
:highlighted-bboxes="highlightedChunkBboxes"
@highlight-element="highlightedElementIndex = $event"
/>
</div>
@ -139,10 +205,7 @@
</div>
<!-- Resize handle -->
<div
class="resize-handle"
@mousedown="onResizeStart"
>
<div class="resize-handle" @mousedown="onResizeStart">
<div class="resize-grip" />
</div>
@ -170,16 +233,26 @@
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.ocr') }}</span>
</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 class="config-toggle-row">
<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-text">{{ t('config.tableStructure') }}</span>
</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 class="config-sub-option" v-if="pipelineOptions.do_table_structure">
@ -197,20 +270,34 @@
<div class="config-toggle-row">
<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-text">{{ t('config.codeEnrichment') }}</span>
</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 class="config-toggle-row">
<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-text">{{ t('config.formulaEnrichment') }}</span>
</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>
@ -220,41 +307,72 @@
<div class="config-toggle-row">
<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-text">{{ t('config.pictureClassification') }}</span>
</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 class="config-toggle-row">
<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-text">{{ t('config.pictureDescription') }}</span>
</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 class="config-toggle-row">
<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-text">{{ t('config.generatePictureImages') }}</span>
</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 class="config-toggle-row">
<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-text">{{ t('config.generatePageImages') }}</span>
</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 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>
<select class="config-select" v-model.number="pipelineOptions.images_scale">
<option :value="0.5">0.5x</option>
@ -280,6 +398,14 @@
@highlight-element="highlightedElementIndex = $event"
/>
</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>
@ -293,20 +419,24 @@ import { useAnalysisStore } from '../features/analysis/store'
import { DocumentUpload, DocumentList } from '../features/document/index'
import { ResultTabs } from '../features/analysis/index'
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 { useI18n } from '../shared/i18n'
import type { PipelineOptions } from '../shared/types'
import type { ChunkBbox, PipelineOptions } from '../shared/types'
const route = useRoute()
const router = useRouter()
const documentStore = useDocumentStore()
const analysisStore = useAnalysisStore()
const { t } = useI18n()
const chunkingEnabled = useFeatureFlag('chunking')
const mode = ref('configurer')
const currentPage = ref(1)
const visualMode = ref(false)
const highlightedElementIndex = ref(-1)
const highlightedChunkBboxes = ref<ChunkBbox[]>([])
const pdfImageRef = ref<HTMLImageElement | null>(null)
const bboxOverlayRef = ref<InstanceType<typeof BboxOverlay> | null>(null)
@ -356,12 +486,14 @@ const pipelineOptions = reactive<PipelineOptions>({
})
const hasAnalysisResults = computed(() => {
return analysisStore.currentAnalysis?.status === 'COMPLETED' && analysisStore.currentPages?.length > 0
return (
analysisStore.currentAnalysis?.status === 'COMPLETED' && analysisStore.currentPages?.length > 0
)
})
const currentPageData = computed(() => {
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() {
@ -369,7 +501,7 @@ function onPdfImageLoad() {
}
const selectedDoc = computed(() => {
return documentStore.documents.find(d => d.id === documentStore.selectedId)
return documentStore.documents.find((d) => d.id === documentStore.selectedId)
})
const previewUrl = computed(() => {
@ -393,13 +525,26 @@ function addMore() {
documentStore.selectedId = null
}
// Auto-switch to verifier when analysis completes + refresh document data (pageCount)
watch(() => analysisStore.currentAnalysis?.status, (status) => {
if (status === 'COMPLETED') {
mode.value = 'verifier'
documentStore.load()
}
// Clear highlights when switching modes or pages
watch(mode, () => {
highlightedElementIndex.value = -1
highlightedChunkBboxes.value = []
})
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 () => {
await documentStore.load()
@ -447,21 +592,12 @@ onBeforeUnmount(() => {
gap: 20px;
}
.import-logo {
.import-logo-img {
width: 64px;
height: 64px;
object-fit: contain;
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);
font-weight: 700;
font-size: 22px;
}
.import-title {
@ -516,45 +652,72 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
gap: 20px;
min-width: 0;
}
.topbar-title {
font-size: 15px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.mode-toggle {
display: flex;
gap: 2px;
background: var(--bg-elevated);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
overflow: hidden;
padding: 3px;
}
.toggle-btn {
padding: 6px 16px;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
color: var(--text-muted);
background: transparent;
border: none;
border-radius: calc(var(--radius-sm) - 2px);
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) {
color: var(--text);
color: var(--text-secondary);
background: var(--bg-hover);
}
.toggle-btn:hover:not(:disabled) .toggle-icon {
opacity: 0.8;
}
.toggle-btn.active {
background: var(--bg);
color: var(--text);
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
background: var(--accent-muted);
color: var(--accent);
font-weight: 600;
}
.toggle-btn.active .toggle-icon {
opacity: 1;
}
.toggle-btn:disabled {
opacity: 0.4;
opacity: 0.35;
cursor: not-allowed;
}
@ -607,7 +770,7 @@ onBeforeUnmount(() => {
.spinner-sm {
width: 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-radius: 50%;
animation: spin 0.6s linear infinite;
@ -679,8 +842,12 @@ onBeforeUnmount(() => {
border-radius: 50%;
}
.info-dot.success { background: var(--success); }
.info-dot.error { background: var(--error); }
.info-dot.success {
background: var(--success);
}
.info-dot.error {
background: var(--error);
}
.spinner-xs {
width: 12px;
@ -779,9 +946,18 @@ onBeforeUnmount(() => {
transition: all var(--transition);
}
.pdf-nav-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
.pdf-nav-btn:disabled { opacity: 0.3; cursor: default; }
.pdf-nav-btn svg { width: 16px; height: 16px; }
.pdf-nav-btn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text);
}
.pdf-nav-btn:disabled {
opacity: 0.3;
cursor: default;
}
.pdf-nav-btn svg {
width: 16px;
height: 16px;
}
.pdf-page-input-wrap {
display: flex;
@ -880,7 +1056,7 @@ onBeforeUnmount(() => {
max-width: 100%;
height: auto;
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;
}
@ -1119,12 +1295,17 @@ onBeforeUnmount(() => {
}
/* Verify panel */
.verify-panel {
.verify-panel,
.prepare-panel {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -15,9 +15,12 @@ describe('apiFetch', () => {
await apiFetch('/api/test')
expect(spy).toHaveBeenCalledWith('/api/test', expect.objectContaining({
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
}))
expect(spy).toHaveBeenCalledWith(
'/api/test',
expect.objectContaining({
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
}),
)
})
it('skips Content-Type when skipContentType is true', async () => {
@ -73,10 +76,13 @@ describe('apiFetch', () => {
const body = JSON.stringify({ documentId: '42' })
await apiFetch('/api/analyses', { method: 'POST', body })
expect(spy).toHaveBeenCalledWith('/api/analyses', expect.objectContaining({
method: 'POST',
body,
}))
expect(spy).toHaveBeenCalledWith(
'/api/analyses',
expect.objectContaining({
method: 'POST',
body,
}),
)
})
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.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.analyses': 'Analyses',
'home.recentDocs': 'Documents récents',
@ -45,26 +46,34 @@ const messages: Messages = {
'config.model': 'Modèle',
'config.pipeline': 'Pipeline',
'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.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.tableModeAccurate': 'Précis',
'config.tableModeFast': 'Rapide',
'config.enrichment': 'Enrichissement',
'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.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.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.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.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.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.documents': 'Documents',
@ -95,6 +104,23 @@ const messages: Messages = {
'history.emptyDocs': 'Aucun document. Importez un document depuis le Studio.',
'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.title': 'Paramètres',
'settings.apiUrl': 'API URL',
@ -103,6 +129,10 @@ const messages: Messages = {
'settings.themeDark': 'Sombre',
'settings.themeLight': 'Clair',
'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: {
'nav.home': 'Home',
@ -116,7 +146,8 @@ const messages: Messages = {
'topbar.newAnalysis': 'New analysis',
'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.analyses': 'Analyses',
'home.recentDocs': 'Recent documents',
@ -138,26 +169,34 @@ const messages: Messages = {
'config.model': 'Model',
'config.pipeline': 'Pipeline',
'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.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.tableModeAccurate': 'Accurate',
'config.tableModeFast': 'Fast',
'config.enrichment': 'Enrichment',
'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.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.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.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.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.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.documents': 'Documents',
@ -185,6 +224,21 @@ const messages: Messages = {
'history.emptyDocs': 'No documents yet. Upload a document from the Studio.',
'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.apiUrl': 'API URL',
'settings.version': 'Version',
@ -192,6 +246,10 @@ const messages: Messages = {
'settings.themeDark': 'Dark',
'settings.themeLight': 'Light',
'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 {
let str = messages[settings.locale]?.[key] || messages['fr'][key] || key
for (const [k, v] of Object.entries(params)) {
str = str.replace(`{${k}}`, String(v))
str = str.replaceAll(`{${k}}`, String(v))
}
return str
}

View file

@ -30,12 +30,34 @@ export interface Analysis {
contentMarkdown: string | null
contentHtml: string | null
pagesJson: string | null
chunksJson: string | null
hasDocumentJson: boolean
errorMessage: string | null
startedAt: string | null
completedAt: string | null
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 {
type: string
bbox: [number, number, number, number]

View file

@ -2,33 +2,71 @@
<aside class="sidebar" :class="{ open }">
<nav class="sidebar-nav">
<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>
</RouterLink>
<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>
</RouterLink>
<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>
</RouterLink>
<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>
</RouterLink>
<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>
</RouterLink>
</nav>
<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>
</aside>
</template>
@ -37,11 +75,12 @@
import { RouterLink, useRoute } from 'vue-router'
import { useI18n } from '../i18n'
const version = __APP_VERSION__
const route = useRoute()
const { t } = useI18n()
defineProps({
open: { type: Boolean, default: false }
open: { type: Boolean, default: false },
})
</script>
@ -55,7 +94,9 @@ defineProps({
overflow: hidden;
width: 0;
min-width: 0;
transition: width 250ms ease, min-width 250ms ease;
transition:
width 250ms ease,
min-width 250ms ease;
}
.sidebar.open {
@ -111,6 +152,17 @@ defineProps({
overflow: hidden;
}
.github-badge {
display: block;
margin-bottom: 8px;
opacity: 0.7;
transition: opacity var(--transition);
}
.github-badge:hover {
opacity: 1;
}
.version {
font-size: 12px;
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 PaginationBar } from './PaginationBar.vue'

View file

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