commit
11eaeb4cd6
100 changed files with 5760 additions and 935 deletions
24
.env.example
24
.env.example
|
|
@ -1,3 +1,27 @@
|
||||||
|
# Docker Compose build target: "local" or "remote" (selects Dockerfile target)
|
||||||
|
# CONVERSION_MODE=local
|
||||||
|
|
||||||
|
# Conversion engine: "local" (in-process Docling) or "remote" (Docling Serve)
|
||||||
|
# Set automatically by the Docker target — override only for local dev
|
||||||
|
# CONVERSION_ENGINE=local
|
||||||
|
|
||||||
|
# Docling Serve settings (remote mode only)
|
||||||
|
# DOCLING_SERVE_URL=http://localhost:5001
|
||||||
|
# DOCLING_SERVE_API_KEY=
|
||||||
|
|
||||||
|
# Max seconds per conversion (default: 600)
|
||||||
|
# CONVERSION_TIMEOUT=600
|
||||||
|
|
||||||
|
# Max parallel analysis jobs (default: 3)
|
||||||
|
# MAX_CONCURRENT_ANALYSES=3
|
||||||
|
|
||||||
|
# Deployment mode: "self-hosted" (default) or "huggingface"
|
||||||
|
# Shows disclaimer banner when set to "huggingface"
|
||||||
|
# DEPLOYMENT_MODE=self-hosted
|
||||||
|
|
||||||
|
# Application version (set automatically by CI/Docker build)
|
||||||
|
# APP_VERSION=dev
|
||||||
|
|
||||||
# CORS (comma-separated origins, only needed for custom deployments)
|
# CORS (comma-separated origins, only needed for custom deployments)
|
||||||
# CORS_ORIGINS=http://localhost:3000,https://your-domain.com
|
# CORS_ORIGINS=http://localhost:3000,https://your-domain.com
|
||||||
|
|
||||||
|
|
|
||||||
26
.github/workflows/release.yml
vendored
26
.github/workflows/release.yml
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
name: Release Docker Image
|
name: Release Docker Images
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|
@ -11,12 +11,16 @@ env:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
name: Build & push Docker image
|
name: Build & push — ${{ matrix.target }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
target: [remote, local]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|
@ -28,22 +32,28 @@ jobs:
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Extract version from tag
|
- name: Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}},suffix=-${{ matrix.target }}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}},suffix=-${{ matrix.target }}
|
||||||
type=raw,value=latest
|
type=raw,value=latest-${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: version
|
||||||
|
run: echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
- uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
target: ${{ matrix.target }}
|
||||||
push: true
|
push: true
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
build-args: APP_VERSION=${{ steps.version.outputs.value }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha,scope=${{ matrix.target }}
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -11,6 +11,8 @@ site/
|
||||||
.vscode/
|
.vscode/
|
||||||
.run/
|
.run/
|
||||||
.claude/
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
**/CLAUDE.md
|
||||||
*.iml
|
*.iml
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
|
|
@ -32,6 +34,9 @@ __pycache__/
|
||||||
.venv/
|
.venv/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
|
||||||
|
# Excalidraw working files
|
||||||
|
docs/excalidraw/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
hs_err_pid*
|
hs_err_pid*
|
||||||
|
|
|
||||||
32
CHANGELOG.md
32
CHANGELOG.md
|
|
@ -4,6 +4,38 @@ All notable changes to Docling Studio will be documented in this file.
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-04-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Chunking support: domain objects, persistence, API endpoints, and frontend Prepare mode
|
||||||
|
- Chunk-to-bbox hover highlighting in Prepare mode
|
||||||
|
- Page filtering and collapsible config in Prepare mode
|
||||||
|
- Feature flipping mechanism
|
||||||
|
- Reusable pagination composable and PaginationBar component
|
||||||
|
- Version display in sidebar, settings page, and health endpoint
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Feature flag health check blocked by CORS
|
||||||
|
- Zombie jobs and unprotected JSON parse
|
||||||
|
- Upload error not displayed in DocumentUpload component
|
||||||
|
- Serve API contract: send `to_formats` as repeated form fields
|
||||||
|
- Audit findings: security, robustness, dead code, domain-infra violation
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refactored backend to hexagonal architecture for converter extensibility
|
||||||
|
- Added ServeConverter adapter for remote Docling Serve integration
|
||||||
|
- Moved `@vitest/mocker` from dependencies to devDependencies
|
||||||
|
|
||||||
|
## [0.2.0] - 2025-05-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Multi-arch Docker image release pipeline (GitHub Actions)
|
||||||
|
- Docker image published to `ghcr.io/scub-france/Docling-Studio`
|
||||||
|
|
||||||
## [0.1.0] - 2025-01-01
|
## [0.1.0] - 2025-01-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,13 @@ Thank you for your interest in contributing to Docling Studio! This guide will h
|
||||||
```bash
|
```bash
|
||||||
cd document-parser
|
cd document-parser
|
||||||
python -m venv .venv && source .venv/bin/activate
|
python -m venv .venv && source .venv/bin/activate
|
||||||
|
|
||||||
|
# Remote mode (lightweight — delegates to Docling Serve)
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Local mode (full — runs Docling in-process)
|
||||||
|
pip install -r requirements-local.txt
|
||||||
|
|
||||||
pip install ruff pytest pytest-asyncio httpx # dev tools
|
pip install ruff pytest pytest-asyncio httpx # dev tools
|
||||||
uvicorn main:app --reload --port 8000
|
uvicorn main:app --reload --port 8000
|
||||||
```
|
```
|
||||||
|
|
@ -61,11 +67,11 @@ npx prettier --write src/ # auto-format
|
||||||
## Running Tests
|
## Running Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend (99 tests)
|
# Backend (199 tests)
|
||||||
cd document-parser
|
cd document-parser
|
||||||
pytest tests/ -v
|
pytest tests/ -v
|
||||||
|
|
||||||
# Frontend (81 tests)
|
# Frontend (129 tests)
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run test:run
|
npm run test:run
|
||||||
```
|
```
|
||||||
|
|
@ -80,6 +86,79 @@ All tests must pass before submitting a PR.
|
||||||
4. Describe **what** changed and **why** in the PR description
|
4. Describe **what** changed and **why** in the PR description
|
||||||
5. Ensure CI passes (tests + build)
|
5. Ensure CI passes (tests + build)
|
||||||
|
|
||||||
|
## Branching Strategy
|
||||||
|
|
||||||
|
We follow a simplified Git Flow:
|
||||||
|
|
||||||
|
| Branch | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `main` | Always stable — latest release merged back |
|
||||||
|
| `release/X.Y.Z` | Release preparation (freeze, bugfixes, changelog) |
|
||||||
|
| `feature/*` | New features — PR to `main` |
|
||||||
|
| `fix/*` | Bug fixes — PR to `main` (or `release/*` for pre-release fixes) |
|
||||||
|
| `hotfix/X.Y.Z` | Urgent fix on a released version — PR to `main` |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- All PRs target `main` (never stack branches on other feature branches)
|
||||||
|
- `release/*` branches are created from `main` when preparing a release
|
||||||
|
- `hotfix/*` branches are created from the release tag
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
We use [Semantic Versioning](https://semver.org/): `MAJOR.MINOR.PATCH`.
|
||||||
|
|
||||||
|
- **Source of truth**: the git tag (`vX.Y.Z`)
|
||||||
|
- `package.json` version should match the current release branch
|
||||||
|
- The build injects the version automatically (Vite `__APP_VERSION__` for frontend, `APP_VERSION` env var for backend)
|
||||||
|
|
||||||
|
## Release Process
|
||||||
|
|
||||||
|
1. **Create the release branch** from `main`:
|
||||||
|
```bash
|
||||||
|
git checkout main && git pull
|
||||||
|
git checkout -b release/X.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **On the release branch**, only:
|
||||||
|
- Bug fixes
|
||||||
|
- Move `[Unreleased]` to `[X.Y.Z] - YYYY-MM-DD` in `CHANGELOG.md`
|
||||||
|
- Update `version` in `frontend/package.json`
|
||||||
|
|
||||||
|
3. **Merge into `main`** via PR, then **tag on `main`**:
|
||||||
|
```bash
|
||||||
|
git checkout main && git pull
|
||||||
|
git tag vX.Y.Z
|
||||||
|
git push origin vX.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
4. The tag triggers the **release workflow** which builds and pushes the Docker image to `ghcr.io`.
|
||||||
|
|
||||||
|
### Docker Image Tags
|
||||||
|
|
||||||
|
Each release produces two image variants:
|
||||||
|
|
||||||
|
| Tag | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| `X.Y.Z-remote` | Exact version — lightweight (Docling Serve) |
|
||||||
|
| `X.Y.Z-local` | Exact version — full (in-process Docling) |
|
||||||
|
| `X.Y-remote` | Latest patch of this minor — lightweight |
|
||||||
|
| `X.Y-local` | Latest patch of this minor — full |
|
||||||
|
| `latest-remote` | Latest stable — lightweight |
|
||||||
|
| `latest-local` | Latest stable — full |
|
||||||
|
|
||||||
|
### Hotfix
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout vX.Y.Z # from the release tag
|
||||||
|
git checkout -b hotfix/X.Y.Z+1
|
||||||
|
# fix, commit, PR to main
|
||||||
|
git tag vX.Y.Z+1 # tag on main after merge
|
||||||
|
```
|
||||||
|
|
||||||
|
### Changelog
|
||||||
|
|
||||||
|
We follow [Keep a Changelog](https://keepachangelog.com/). Every PR should add a line under `[Unreleased]` in `CHANGELOG.md`. The release branch moves `[Unreleased]` to the versioned section.
|
||||||
|
|
||||||
## Pull Request Guidelines
|
## Pull Request Guidelines
|
||||||
|
|
||||||
- Keep PRs focused — one feature or fix per PR
|
- Keep PRs focused — one feature or fix per PR
|
||||||
|
|
|
||||||
44
Dockerfile
44
Dockerfile
|
|
@ -1,33 +1,36 @@
|
||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Docling Studio — single-image build (frontend + backend)
|
# Docling Studio — single-image build (frontend + backend, multi-target)
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# docker build -t docling-studio .
|
# docker build --target remote -t docling-studio:remote .
|
||||||
# docker run -p 3000:3000 docling-studio
|
# docker build --target local -t docling-studio:local .
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# --- Stage 1: Build frontend assets ---
|
# --- Stage 1: Build frontend assets ---
|
||||||
FROM node:20-alpine AS frontend-build
|
FROM node:20-alpine AS frontend-build
|
||||||
|
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY frontend/ .
|
COPY frontend/ .
|
||||||
RUN npm run build
|
RUN VITE_APP_VERSION=${APP_VERSION} npm run build
|
||||||
|
|
||||||
# --- Stage 2: Runtime (Python + Nginx) ---
|
# --- Stage 2: Base runtime (Python + Nginx) ---
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim AS base
|
||||||
|
|
||||||
# System deps: poppler (pdf2image), nginx, and OpenCV runtime libs
|
ARG APP_VERSION=dev
|
||||||
|
ENV APP_VERSION=${APP_VERSION}
|
||||||
|
|
||||||
|
# System deps: poppler (pdf2image), nginx
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
poppler-utils \
|
poppler-utils \
|
||||||
libgl1 \
|
|
||||||
libglib2.0-0 \
|
|
||||||
nginx \
|
nginx \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Python deps
|
# Python deps (common)
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY document-parser/requirements.txt .
|
COPY document-parser/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
@ -52,5 +55,24 @@ ENV DB_PATH=/app/data/docling_studio.db
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# nginx needs to run as root for port binding, then drops to appuser for uvicorn
|
|
||||||
CMD ["sh", "-c", "nginx && exec su appuser -c 'uvicorn main:app --host 127.0.0.1 --port 8000'"]
|
CMD ["sh", "-c", "nginx && exec su appuser -c 'uvicorn main:app --host 127.0.0.1 --port 8000'"]
|
||||||
|
|
||||||
|
# --- Remote: lightweight, delegates to Docling Serve ---
|
||||||
|
FROM base AS remote
|
||||||
|
ENV CONVERSION_ENGINE=remote
|
||||||
|
|
||||||
|
# --- Local: full Docling in-process ---
|
||||||
|
FROM base AS local
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY document-parser/requirements-local.txt .
|
||||||
|
RUN pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu \
|
||||||
|
&& pip install --no-cache-dir -r requirements-local.txt
|
||||||
|
|
||||||
|
RUN chown -R appuser:appuser /app \
|
||||||
|
&& chown -R appuser:appuser /usr/local/lib/python3.12/site-packages/rapidocr/models
|
||||||
|
ENV CONVERSION_ENGINE=local
|
||||||
|
|
|
||||||
68
README.md
68
README.md
|
|
@ -5,6 +5,7 @@
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
[](https://github.com/scub-france/Docling-Studio)
|
||||||
|
|
||||||
A visual document analysis studio powered by [Docling](https://github.com/DS4SD/docling).
|
A visual document analysis studio powered by [Docling](https://github.com/DS4SD/docling).
|
||||||
Upload a PDF, configure the extraction pipeline, and visualize the results — text, tables, images, formulas, bounding boxes — all from your browser.
|
Upload a PDF, configure the extraction pipeline, and visualize the results — text, tables, images, formulas, bounding boxes — all from your browser.
|
||||||
|
|
@ -48,8 +49,8 @@ document-parser/
|
||||||
├── main.py # FastAPI app, CORS, lifespan
|
├── main.py # FastAPI app, CORS, lifespan
|
||||||
├── domain/ # Pure domain — no HTTP, no DB
|
├── domain/ # Pure domain — no HTTP, no DB
|
||||||
│ ├── models.py # Document, AnalysisJob dataclasses
|
│ ├── models.py # Document, AnalysisJob dataclasses
|
||||||
│ ├── parsing.py # Docling conversion & page extraction
|
│ ├── ports.py # Abstract protocols (converter, chunker)
|
||||||
│ └── bbox.py # Bounding box coordinate normalization
|
│ └── value_objects.py # ConversionResult, PageDetail, ChunkResult
|
||||||
├── api/ # HTTP layer (FastAPI routers)
|
├── api/ # HTTP layer (FastAPI routers)
|
||||||
│ ├── schemas.py # Pydantic DTOs (camelCase serialization)
|
│ ├── schemas.py # Pydantic DTOs (camelCase serialization)
|
||||||
│ ├── documents.py # /api/documents endpoints
|
│ ├── documents.py # /api/documents endpoints
|
||||||
|
|
@ -61,7 +62,7 @@ document-parser/
|
||||||
├── services/ # Use case orchestration
|
├── services/ # Use case orchestration
|
||||||
│ ├── document_service.py # Upload, delete, preview
|
│ ├── document_service.py # Upload, delete, preview
|
||||||
│ └── analysis_service.py # Async Docling processing
|
│ └── analysis_service.py # Async Docling processing
|
||||||
└── tests/ # 99 tests (pytest)
|
└── tests/ # 199 tests (pytest)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend structure (feature-based)
|
### Frontend structure (feature-based)
|
||||||
|
|
@ -85,22 +86,42 @@ frontend/src/
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Docker (fastest)
|
Docling Studio ships two Docker image variants:
|
||||||
|
|
||||||
|
| Variant | Image tag | Size | Description |
|
||||||
|
|---------|-----------|------|-------------|
|
||||||
|
| **remote** | `latest-remote` | ~270 MB | Lightweight — delegates to an external [Docling Serve](https://github.com/DS4SD/docling-serve) instance |
|
||||||
|
| **local** | `latest-local` | ~1.9 GB | Full — runs Docling in-process, CPU-only (downloads ML models on first run) |
|
||||||
|
|
||||||
|
### Docker — remote mode (fastest)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -p 3000:3000 ghcr.io/scub-france/docling-studio:latest
|
docker run -p 3000:3000 \
|
||||||
|
-e DOCLING_SERVE_URL=http://your-docling-serve:5001 \
|
||||||
|
ghcr.io/scub-france/docling-studio:latest-remote
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000)
|
### Docker — local mode (self-contained)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -p 3000:3000 ghcr.io/scub-france/docling-studio:latest-local
|
||||||
|
```
|
||||||
|
|
||||||
> **Note:** The first analysis takes longer as Docling downloads its ML models (~400 MB). Subsequent runs are fast.
|
> **Note:** The first analysis takes longer as Docling downloads its ML models (~400 MB). Subsequent runs are fast.
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000)
|
||||||
|
|
||||||
### Docker Compose (for development)
|
### Docker Compose (for development)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/scub-france/Docling-Studio.git
|
git clone https://github.com/scub-france/Docling-Studio.git
|
||||||
cd Docling-Studio
|
cd Docling-Studio
|
||||||
|
|
||||||
|
# Local mode (default)
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
|
|
||||||
|
# Remote mode
|
||||||
|
CONVERSION_MODE=remote DOCLING_SERVE_URL=http://your-docling-serve:5001 docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Local Development
|
### Local Development
|
||||||
|
|
@ -109,7 +130,13 @@ docker compose up --build
|
||||||
```bash
|
```bash
|
||||||
cd document-parser
|
cd document-parser
|
||||||
python -m venv .venv && source .venv/bin/activate
|
python -m venv .venv && source .venv/bin/activate
|
||||||
|
|
||||||
|
# Remote mode (lightweight)
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Local mode (with Docling)
|
||||||
|
pip install -r requirements-local.txt
|
||||||
|
|
||||||
uvicorn main:app --reload --port 8000
|
uvicorn main:app --reload --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -123,12 +150,12 @@ npm run dev
|
||||||
### Running Tests
|
### Running Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend (99 tests)
|
# Backend (199 tests)
|
||||||
cd document-parser
|
cd document-parser
|
||||||
pip install pytest pytest-asyncio httpx
|
pip install pytest pytest-asyncio httpx
|
||||||
pytest tests/ -v
|
pytest tests/ -v
|
||||||
|
|
||||||
# Frontend (81 tests)
|
# Frontend (129 tests)
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run test:run
|
npm run test:run
|
||||||
```
|
```
|
||||||
|
|
@ -156,6 +183,9 @@ All configuration is done via environment variables. See [`.env.example`](.env.e
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
|
| `CONVERSION_ENGINE` | `local` | `local` (in-process Docling) or `remote` (Docling Serve) |
|
||||||
|
| `DOCLING_SERVE_URL` | `http://localhost:5001` | Docling Serve endpoint (remote mode only) |
|
||||||
|
| `DOCLING_SERVE_API_KEY` | — | API key for Docling Serve (optional) |
|
||||||
| `CORS_ORIGINS` | `http://localhost:3000,...` | CORS allowed origins (comma-separated) |
|
| `CORS_ORIGINS` | `http://localhost:3000,...` | CORS allowed origins (comma-separated) |
|
||||||
| `UPLOAD_DIR` | `./uploads` | File storage directory |
|
| `UPLOAD_DIR` | `./uploads` | File storage directory |
|
||||||
| `DB_PATH` | `./data/docling_studio.db` | SQLite database path |
|
| `DB_PATH` | `./data/docling_studio.db` | SQLite database path |
|
||||||
|
|
@ -167,14 +197,11 @@ GitHub Actions pipelines (see [`.github/workflows/`](.github/workflows/)):
|
||||||
|
|
||||||
| Workflow | Trigger | What it does |
|
| Workflow | Trigger | What it does |
|
||||||
|----------|---------|--------------|
|
|----------|---------|--------------|
|
||||||
| **CI** | push to `main`, pull requests | Lint + type check + Backend tests (99) + Frontend tests (81) + build |
|
| **CI** | push to `main`, pull requests | Lint + type check + Backend tests + Frontend tests + build |
|
||||||
| **Release** | push tag `v*` | Build & push multi-arch Docker image to `ghcr.io` |
|
| **Release** | push tag `v*` | Build & push **two** multi-arch Docker images (`remote` + `local`) to `ghcr.io` |
|
||||||
|
| **Docs** | push to `main` (docs changes) | Build & deploy MkDocs to GitHub Pages |
|
||||||
|
|
||||||
To publish a new version:
|
We follow [Semantic Versioning](https://semver.org/) with a simplified Git Flow. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full release process.
|
||||||
```bash
|
|
||||||
git tag v0.2.0
|
|
||||||
git push origin v0.2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance & System Requirements
|
## Performance & System Requirements
|
||||||
|
|
||||||
|
|
@ -186,12 +213,11 @@ git push origin v0.2.0
|
||||||
|
|
||||||
### Docker Desktop settings
|
### Docker Desktop settings
|
||||||
|
|
||||||
The document parser needs **at least 4 GB of RAM**:
|
| | Remote image | Local image |
|
||||||
|
|---|---|---|
|
||||||
| Resource | Minimum | Recommended |
|
| **Image size** | ~270 MB | ~1.9 GB |
|
||||||
|----------|---------|-------------|
|
| **Memory** | 2 GB | 6 GB (recommended 8 GB+) |
|
||||||
| Memory | 6 GB | 8 GB+ |
|
| **CPUs** | 2 | 4 (recommended 8+) |
|
||||||
| CPUs | 4 | 8+ |
|
|
||||||
|
|
||||||
### Platform support
|
### Platform support
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ services:
|
||||||
document-parser:
|
document-parser:
|
||||||
build:
|
build:
|
||||||
context: ./document-parser
|
context: ./document-parser
|
||||||
|
target: ${CONVERSION_MODE:-local}
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -9,6 +10,8 @@ services:
|
||||||
- db_data:/app/data
|
- db_data:/app/data
|
||||||
environment:
|
environment:
|
||||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000,http://localhost:5173}
|
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000,http://localhost:5173}
|
||||||
|
DOCLING_SERVE_URL: ${DOCLING_SERVE_URL:-}
|
||||||
|
DOCLING_SERVE_API_KEY: ${DOCLING_SERVE_API_KEY:-}
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
{ width="700" }
|
{ width="700" }
|
||||||
|
|
||||||
Two services communicating via REST. The frontend is a Vue 3 SPA served by Nginx in production. The backend is a FastAPI app that wraps Docling's document conversion engine.
|
Two services communicating via REST. The frontend is a Vue 3 SPA served by Nginx in production. The backend is a FastAPI app that wraps Docling's document conversion engine.
|
||||||
|
|
||||||
|
|
@ -40,37 +40,47 @@ The backend follows a strict layered architecture. Dependencies flow inward: API
|
||||||
|
|
||||||
```
|
```
|
||||||
document-parser/
|
document-parser/
|
||||||
├── main.py # FastAPI app, CORS, lifespan
|
├── main.py # FastAPI app, CORS, lifespan, health endpoint
|
||||||
│
|
│
|
||||||
├── domain/ # Pure domain — no HTTP, no DB
|
├── domain/ # Pure domain — no HTTP, no DB
|
||||||
│ ├── models.py # Document, AnalysisJob dataclasses
|
│ ├── models.py # Document, AnalysisJob dataclasses
|
||||||
│ ├── parsing.py # Docling conversion & page extraction
|
│ ├── ports.py # Abstract protocols (DocumentConverter, DocumentChunker)
|
||||||
|
│ ├── value_objects.py # ConversionResult, ChunkingOptions, ChunkResult
|
||||||
│ └── bbox.py # Bounding box coordinate normalization
|
│ └── bbox.py # Bounding box coordinate normalization
|
||||||
│
|
│
|
||||||
├── api/ # HTTP layer (FastAPI routers)
|
├── api/ # HTTP layer (FastAPI routers)
|
||||||
│ ├── schemas.py # Pydantic DTOs (camelCase serialization)
|
│ ├── schemas.py # Pydantic DTOs (camelCase serialization)
|
||||||
│ ├── documents.py # /api/documents endpoints
|
│ ├── documents.py # /api/documents endpoints
|
||||||
│ └── analyses.py # /api/analyses endpoints
|
│ └── analyses.py # /api/analyses endpoints (create, rechunk, delete)
|
||||||
│
|
│
|
||||||
├── persistence/ # Data layer (SQLite via aiosqlite)
|
├── persistence/ # Data layer (SQLite via aiosqlite)
|
||||||
│ ├── database.py # Connection management, schema init
|
│ ├── database.py # Connection management, schema init
|
||||||
│ ├── document_repo.py # Document CRUD
|
│ ├── document_repo.py # Document CRUD
|
||||||
│ └── analysis_repo.py # AnalysisJob CRUD
|
│ └── analysis_repo.py # AnalysisJob CRUD
|
||||||
│
|
│
|
||||||
|
├── infra/ # Infrastructure adapters
|
||||||
|
│ ├── settings.py # Environment-based configuration
|
||||||
|
│ ├── local_converter.py # In-process Docling converter (local mode)
|
||||||
|
│ ├── serve_converter.py # HTTP client for Docling Serve (remote mode)
|
||||||
|
│ ├── local_chunker.py # In-process chunking (HierarchicalChunker, HybridChunker)
|
||||||
|
│ ├── rate_limiter.py # Sliding-window rate limiting middleware
|
||||||
|
│ └── bbox.py # Bbox coordinate normalization helpers
|
||||||
|
│
|
||||||
├── services/ # Use case orchestration
|
├── services/ # Use case orchestration
|
||||||
│ ├── document_service.py # Upload, delete, preview
|
│ ├── document_service.py # Upload, delete, preview
|
||||||
│ └── analysis_service.py # Async Docling processing
|
│ └── analysis_service.py # Async Docling processing + chunking
|
||||||
│
|
│
|
||||||
└── tests/ # pytest
|
└── tests/ # pytest (199 tests)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Layer responsibilities
|
### Layer responsibilities
|
||||||
|
|
||||||
| Layer | Role | Depends on |
|
| Layer | Role | Depends on |
|
||||||
|-------|------|------------|
|
|-------|------|------------|
|
||||||
| **domain** | Dataclasses, bbox math, Docling conversion | Nothing (pure Python) |
|
| **domain** | Dataclasses, value objects, abstract ports | Nothing (pure Python) |
|
||||||
| **persistence** | SQLite CRUD, aiosqlite | domain (models) |
|
| **persistence** | SQLite CRUD, aiosqlite | domain (models) |
|
||||||
| **services** | Orchestrate use cases, call Docling | domain + persistence |
|
| **infra** | Adapters: converters, chunker, rate limiter, settings | domain (ports, value objects) |
|
||||||
|
| **services** | Orchestrate use cases, call converters/chunkers | domain + persistence + infra |
|
||||||
| **api** | HTTP endpoints, Pydantic DTOs, error handling | services |
|
| **api** | HTTP endpoints, Pydantic DTOs, error handling | services |
|
||||||
|
|
||||||
### API contract
|
### API contract
|
||||||
|
|
@ -101,7 +111,9 @@ frontend/src/
|
||||||
│ │ ├── AnalysisPanel.vue
|
│ │ ├── AnalysisPanel.vue
|
||||||
│ │ ├── StructureViewer.vue
|
│ │ ├── StructureViewer.vue
|
||||||
│ │ └── ...
|
│ │ └── ...
|
||||||
|
│ ├── chunking/ # Chunk panel UI + rechunk action
|
||||||
│ ├── document/ # Document store, API, upload
|
│ ├── document/ # Document store, API, upload
|
||||||
|
│ ├── feature-flags/ # Feature flag store (reads /api/health)
|
||||||
│ ├── history/ # History store, navigation
|
│ ├── history/ # History store, navigation
|
||||||
│ └── settings/ # Theme, locale, API URL
|
│ └── settings/ # Theme, locale, API URL
|
||||||
│
|
│
|
||||||
|
|
@ -126,3 +138,73 @@ Backend response → Pinia store state → Vue reactivity → UI update
|
||||||
- **TypeScript strict mode** with shared interfaces in `shared/types.ts`.
|
- **TypeScript strict mode** with shared interfaces in `shared/types.ts`.
|
||||||
- **No component library** — custom CSS with CSS variables for theming.
|
- **No component library** — custom CSS with CSS variables for theming.
|
||||||
- **vue-tsc** in CI to catch type errors before merge.
|
- **vue-tsc** in CI to catch type errors before merge.
|
||||||
|
|
||||||
|
## Feature Flags
|
||||||
|
|
||||||
|
The frontend adapts its UI based on the backend's capabilities. On startup, the feature flag store fetches `/api/health` and reads the `engine` and `deploymentMode` fields.
|
||||||
|
|
||||||
|
| Flag | Condition | Effect |
|
||||||
|
|------|-----------|--------|
|
||||||
|
| `chunking` | `engine === 'local'` | Shows chunking options in the analysis panel |
|
||||||
|
| `disclaimer` | `deploymentMode === 'huggingface'` | Shows a disclaimer banner at the top of the app |
|
||||||
|
|
||||||
|
This allows the same frontend build to work with both local and remote backends without conditional compilation.
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
The backend applies a sliding-window rate limiter as middleware:
|
||||||
|
|
||||||
|
- **60 requests** per **60 seconds** per client IP
|
||||||
|
- The `/api/health` endpoint is excluded
|
||||||
|
- When the limit is exceeded, the API returns `429 Too Many Requests` with a `Retry-After` header
|
||||||
|
|
||||||
|
## Analysis Lifecycle
|
||||||
|
|
||||||
|
An analysis job follows this state machine:
|
||||||
|
|
||||||
|
```
|
||||||
|
PENDING → RUNNING → COMPLETED
|
||||||
|
→ FAILED
|
||||||
|
```
|
||||||
|
|
||||||
|
| Status | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `PENDING` | Job created, waiting for a processing slot |
|
||||||
|
| `RUNNING` | Docling conversion in progress |
|
||||||
|
| `COMPLETED` | Conversion finished — results available (markdown, HTML, pages, chunks) |
|
||||||
|
| `FAILED` | Conversion error — `error_message` contains details |
|
||||||
|
|
||||||
|
The backend limits parallel jobs via `MAX_CONCURRENT_ANALYSES` (default: 3) to avoid overloading the CPU during Docling processing.
|
||||||
|
|
||||||
|
## Local vs Remote Mode
|
||||||
|
|
||||||
|
The backend supports two conversion engines, selected via the `CONVERSION_ENGINE` environment variable:
|
||||||
|
|
||||||
|
| | Local | Remote |
|
||||||
|
|---|---|---|
|
||||||
|
| **Engine** | In-process Docling (PyTorch) | HTTP client to [Docling Serve](https://github.com/DS4SD/docling-serve) |
|
||||||
|
| **Chunking** | Available (in-process) | Not available |
|
||||||
|
| **Docker image** | `latest-local` (~1.9 GB) | `latest-remote` (~270 MB) |
|
||||||
|
| **ML models** | Downloaded on first run (~400 MB) | Managed by Docling Serve |
|
||||||
|
| **CPU/RAM** | 4+ CPUs, 6+ GB RAM | 2 CPUs, 2 GB RAM |
|
||||||
|
|
||||||
|
The converter is selected at startup in `main.py` via `_build_converter()`. The chunker (`_build_chunker()`) is only instantiated in local mode — in remote mode, the chunking feature flag is disabled and the UI hides the chunking panel.
|
||||||
|
|
||||||
|
## Health Endpoint
|
||||||
|
|
||||||
|
`GET /api/health` returns the backend status:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"engine": "local",
|
||||||
|
"version": "0.3.0",
|
||||||
|
"deploymentMode": "self-hosted"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend uses this response to:
|
||||||
|
|
||||||
|
1. Verify the backend is reachable
|
||||||
|
2. Evaluate feature flags (chunking, disclaimer)
|
||||||
|
3. Display the app version
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ The frontend converts PDF points to CSS pixels, then the canvas renders at `devi
|
||||||
|
|
||||||
## Transformation 1 — `to_topleft_list()`
|
## Transformation 1 — `to_topleft_list()`
|
||||||
|
|
||||||
**File:** `document-parser/domain/bbox.py`
|
**File:** `document-parser/infra/bbox.py`
|
||||||
|
|
||||||
Normalizes any Docling bbox to `[left, top, right, bottom]` in TOPLEFT coordinates.
|
Normalizes any Docling bbox to `[left, top, right, bottom]` in TOPLEFT coordinates.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,13 @@
|
||||||
```bash
|
```bash
|
||||||
cd document-parser
|
cd document-parser
|
||||||
python -m venv .venv && source .venv/bin/activate
|
python -m venv .venv && source .venv/bin/activate
|
||||||
|
|
||||||
|
# Remote mode (lightweight — delegates to Docling Serve)
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Local mode (full — runs Docling in-process)
|
||||||
|
pip install -r requirements-local.txt
|
||||||
|
|
||||||
pip install ruff pytest pytest-asyncio httpx
|
pip install ruff pytest pytest-asyncio httpx
|
||||||
uvicorn main:app --reload --port 8000
|
uvicorn main:app --reload --port 8000
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,69 @@
|
||||||
# Getting Started
|
# Getting Started
|
||||||
|
|
||||||
## Docker Compose (recommended)
|
Docling Studio ships two Docker image variants:
|
||||||
|
|
||||||
|
| Variant | Image tag | Size | Description |
|
||||||
|
|---------|-----------|------|-------------|
|
||||||
|
| **remote** | `latest-remote` | ~270 MB | Lightweight — delegates to an external [Docling Serve](https://github.com/DS4SD/docling-serve) instance |
|
||||||
|
| **local** | `latest-local` | ~1.9 GB | Full — runs Docling in-process, CPU-only (downloads ML models on first run) |
|
||||||
|
|
||||||
|
{ width="600" }
|
||||||
|
|
||||||
|
## Docker — remote mode (fastest)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -p 3000:3000 \
|
||||||
|
-e DOCLING_SERVE_URL=http://your-docling-serve:5001 \
|
||||||
|
ghcr.io/scub-france/docling-studio:latest-remote
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker — local mode (self-contained)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -p 3000:3000 ghcr.io/scub-france/docling-studio:latest-local
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** The first analysis takes longer as Docling downloads its ML models (~400 MB). Subsequent runs are fast.
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000).
|
||||||
|
|
||||||
|
## Docker Compose (recommended for development)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/scub-france/Docling-Studio.git
|
git clone https://github.com/scub-france/Docling-Studio.git
|
||||||
cd Docling-Studio
|
cd Docling-Studio
|
||||||
docker compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000).
|
# Local mode (default)
|
||||||
|
docker compose up --build
|
||||||
|
|
||||||
|
# Remote mode
|
||||||
|
CONVERSION_MODE=remote DOCLING_SERVE_URL=http://your-docling-serve:5001 docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
### Backend (Python 3.12+)
|
=== "Backend (Python 3.12+)"
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd document-parser
|
cd document-parser
|
||||||
python -m venv .venv && source .venv/bin/activate
|
python -m venv .venv && source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
|
||||||
uvicorn main:app --reload --port 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend (Node 20+)
|
# Remote mode (lightweight)
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
```bash
|
# Local mode (with Docling)
|
||||||
cd frontend
|
pip install -r requirements-local.txt
|
||||||
npm install
|
|
||||||
npm run dev
|
uvicorn main:app --reload --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "Frontend (Node 20+)"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
The frontend runs on `http://localhost:3000` and proxies API calls to `http://localhost:8000`.
|
The frontend runs on `http://localhost:3000` and proxies API calls to `http://localhost:8000`.
|
||||||
|
|
||||||
|
|
@ -65,22 +101,51 @@ These options map directly to Docling's [`PdfPipelineOptions`](https://docling-p
|
||||||
| `generate_page_images` | `false` | Rasterize each page as an image |
|
| `generate_page_images` | `false` | Rasterize each page as an image |
|
||||||
| `images_scale` | `1.0` | Scale factor for generated images (0.1–10) |
|
| `images_scale` | `1.0` | Scale factor for generated images (0.1–10) |
|
||||||
|
|
||||||
|
## Chunking Options
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
Chunking is only available in **local** mode. The chunking UI is hidden when using remote mode (Docling Serve).
|
||||||
|
|
||||||
|
After a document is analyzed, you can split the extracted content into semantic chunks. Chunking can be configured at analysis time or re-run later with different options via the **rechunk** action.
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `chunker_type` | `hybrid` | `hybrid` (semantic + structural), `hierarchical` (heading-based), or `page` (one chunk per page) |
|
||||||
|
| `max_tokens` | `512` | Maximum tokens per chunk |
|
||||||
|
| `merge_peers` | `true` | Merge sibling elements under the same heading |
|
||||||
|
| `repeat_table_header` | `true` | Repeat table headers when a table is split across chunks |
|
||||||
|
|
||||||
|
Each chunk includes:
|
||||||
|
|
||||||
|
- **text** — the chunk content
|
||||||
|
- **headings** — heading hierarchy leading to the chunk
|
||||||
|
- **source_page** — the page number the chunk originates from
|
||||||
|
- **token_count** — number of tokens in the chunk
|
||||||
|
- **bboxes** — bounding boxes of the chunk's source elements (page + coordinates)
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All configuration is done via environment variables:
|
All configuration is done via environment variables:
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
|
| `CONVERSION_ENGINE` | `local` | `local` (in-process Docling) or `remote` (Docling Serve) |
|
||||||
|
| `DOCLING_SERVE_URL` | `http://localhost:5001` | Docling Serve endpoint (remote mode only) |
|
||||||
|
| `DOCLING_SERVE_API_KEY` | — | API key for Docling Serve (optional) |
|
||||||
| `CORS_ORIGINS` | `http://localhost:3000,...` | CORS allowed origins |
|
| `CORS_ORIGINS` | `http://localhost:3000,...` | CORS allowed origins |
|
||||||
| `UPLOAD_DIR` | `./uploads` | File storage directory |
|
| `UPLOAD_DIR` | `./uploads` | File storage directory |
|
||||||
| `DB_PATH` | `./data/docling_studio.db` | SQLite database path |
|
| `DB_PATH` | `./data/docling_studio.db` | SQLite database path |
|
||||||
| `CONVERSION_TIMEOUT` | `600` | Max seconds per Docling conversion |
|
| `CONVERSION_TIMEOUT` | `600` | Max seconds per Docling conversion |
|
||||||
|
| `MAX_CONCURRENT_ANALYSES` | `3` | Maximum parallel analysis jobs |
|
||||||
|
| `DEPLOYMENT_MODE` | `self-hosted` | `self-hosted` or `huggingface` (shows disclaimer banner) |
|
||||||
|
| `APP_VERSION` | `dev` | Application version (set automatically by CI/Docker) |
|
||||||
|
|
||||||
## System Requirements
|
## System Requirements
|
||||||
|
|
||||||
| Resource | Minimum | Recommended |
|
| | Remote image | Local image |
|
||||||
|----------|---------|-------------|
|
|---|---|---|
|
||||||
| Memory | 6 GB | 8 GB+ |
|
| **Image size** | ~270 MB | ~1.9 GB |
|
||||||
| CPUs | 4 | 8+ |
|
| **Memory** | 2 GB | 6 GB (recommended 8 GB+) |
|
||||||
|
| **CPUs** | 2 | 4 (recommended 8+) |
|
||||||
|
|
||||||
All Docker images are multi-arch (`linux/amd64` + `linux/arm64`). No GPU required.
|
All Docker images are multi-arch (`linux/amd64` + `linux/arm64`). No GPU required.
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB |
BIN
docs/images/docker.png
Normal file
BIN
docs/images/docker.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 243 KiB |
BIN
docs/images/global.png
Normal file
BIN
docs/images/global.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 430 KiB |
BIN
docs/images/logo.png
Normal file
BIN
docs/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 858 KiB |
|
|
@ -4,18 +4,23 @@ A visual document analysis studio powered by [Docling](https://github.com/DS4SD/
|
||||||
|
|
||||||
Upload a PDF, configure the extraction pipeline, and visualize the results — text, tables, images, formulas, bounding boxes — all from your browser.
|
Upload a PDF, configure the extraction pipeline, and visualize the results — text, tables, images, formulas, bounding boxes — all from your browser.
|
||||||
|
|
||||||
{ width="600" }
|
{ width="600" }
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **PDF viewer** with page navigation, bounding box overlay, and resizable results panel
|
- **PDF viewer** with page navigation, bounding box overlay, and resizable results panel
|
||||||
- **Configurable Docling pipeline** — OCR, table extraction, code/formula enrichment, picture classification & description
|
- **Configurable Docling pipeline** — OCR, table extraction, code/formula enrichment, picture classification & description, image generation
|
||||||
- **Bounding box visualization** — color-coded element overlay directly on the PDF
|
- **Bounding box visualization** — color-coded element overlay directly on the PDF
|
||||||
|
- **Chunking** — split extracted content into semantic chunks (hierarchical, hybrid, or page-based) with configurable token limits
|
||||||
- **Markdown & HTML export** of extracted content
|
- **Markdown & HTML export** of extracted content
|
||||||
- **Document management** — upload, list, delete
|
- **Document management** — upload, list, delete
|
||||||
- **Analysis history** — re-visit and open past analyses
|
- **Analysis history** — re-visit and open past analyses
|
||||||
|
- **Feature flags** — capabilities adapt to the conversion engine (local vs remote)
|
||||||
|
- **Rate limiting** — 60 requests per minute per IP to protect the backend
|
||||||
|
- **Deployment modes** — self-hosted (default) or HuggingFace Spaces (with disclaimer banner)
|
||||||
|
- **Health endpoint** — `/api/health` reports engine type, deployment mode, and database status
|
||||||
- **Dark / Light theme** and **FR / EN** localization
|
- **Dark / Light theme** and **FR / EN** localization
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
@ -31,7 +36,7 @@ Upload a PDF, configure the extraction pipeline, and visualize the results — t
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Docker (fastest)
|
# Docker (fastest)
|
||||||
docker run -p 3000:3000 ghcr.io/scub-france/docling-studio:latest
|
docker run -p 3000:3000 ghcr.io/scub-france/docling-studio:latest-local
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) and upload a PDF.
|
Open [http://localhost:3000](http://localhost:3000) and upload a PDF.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
FROM python:3.12-slim
|
# syntax=docker/dockerfile:1
|
||||||
|
# =============================================================================
|
||||||
|
# Docling Studio — backend image (multi-target: remote / local)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker build --target remote -t docling-studio-backend:remote .
|
||||||
|
# docker build --target local -t docling-studio-backend:local .
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- Base: common deps for both targets ---
|
||||||
|
FROM python:3.12-slim AS base
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
poppler-utils \
|
poppler-utils \
|
||||||
libgl1 \
|
|
||||||
libglib2.0-0 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
@ -24,3 +32,25 @@ ENV DB_PATH=/app/data/docling_studio.db
|
||||||
|
|
||||||
USER appuser
|
USER appuser
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
||||||
|
# --- Remote: lightweight, delegates to Docling Serve ---
|
||||||
|
FROM base AS remote
|
||||||
|
ENV CONVERSION_ENGINE=remote
|
||||||
|
|
||||||
|
# --- Local: full Docling in-process ---
|
||||||
|
FROM base AS local
|
||||||
|
|
||||||
|
USER root
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements-local.txt .
|
||||||
|
RUN pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu \
|
||||||
|
&& pip install --no-cache-dir -r requirements-local.txt
|
||||||
|
|
||||||
|
RUN chown -R appuser:appuser /app \
|
||||||
|
&& chown -R appuser:appuser /usr/local/lib/python3.12/site-packages/rapidocr/models
|
||||||
|
USER appuser
|
||||||
|
ENV CONVERSION_ENGINE=local
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,30 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
|
||||||
from api.schemas import AnalysisResponse, CreateAnalysisRequest
|
from api.schemas import (
|
||||||
from services import analysis_service
|
AnalysisResponse,
|
||||||
|
ChunkBboxResponse,
|
||||||
|
ChunkResponse,
|
||||||
|
CreateAnalysisRequest,
|
||||||
|
RechunkRequest,
|
||||||
|
)
|
||||||
|
from services.analysis_service import AnalysisService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/analyses", tags=["analyses"])
|
router = APIRouter(prefix="/api/analyses", tags=["analyses"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_service(request: Request) -> AnalysisService:
|
||||||
|
return request.app.state.analysis_service
|
||||||
|
|
||||||
|
|
||||||
|
ServiceDep = Annotated[AnalysisService, Depends(_get_service)]
|
||||||
|
|
||||||
|
|
||||||
def _to_response(job) -> AnalysisResponse:
|
def _to_response(job) -> AnalysisResponse:
|
||||||
return AnalysisResponse(
|
return AnalysisResponse(
|
||||||
id=job.id,
|
id=job.id,
|
||||||
|
|
@ -22,6 +36,8 @@ def _to_response(job) -> AnalysisResponse:
|
||||||
content_markdown=job.content_markdown,
|
content_markdown=job.content_markdown,
|
||||||
content_html=job.content_html,
|
content_html=job.content_html,
|
||||||
pages_json=job.pages_json,
|
pages_json=job.pages_json,
|
||||||
|
chunks_json=job.chunks_json,
|
||||||
|
has_document_json=job.document_json is not None,
|
||||||
error_message=job.error_message,
|
error_message=job.error_message,
|
||||||
started_at=str(job.started_at) if job.started_at else None,
|
started_at=str(job.started_at) if job.started_at else None,
|
||||||
completed_at=str(job.completed_at) if job.completed_at else None,
|
completed_at=str(job.completed_at) if job.completed_at else None,
|
||||||
|
|
@ -30,7 +46,7 @@ def _to_response(job) -> AnalysisResponse:
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=AnalysisResponse)
|
@router.post("", response_model=AnalysisResponse)
|
||||||
async def create_analysis(body: CreateAnalysisRequest):
|
async def create_analysis(body: CreateAnalysisRequest, service: ServiceDep) -> AnalysisResponse:
|
||||||
"""Create a new analysis job for a document."""
|
"""Create a new analysis job for a document."""
|
||||||
if not body.documentId or not body.documentId.strip():
|
if not body.documentId or not body.documentId.strip():
|
||||||
raise HTTPException(status_code=400, detail="documentId is required")
|
raise HTTPException(status_code=400, detail="documentId is required")
|
||||||
|
|
@ -39,8 +55,16 @@ async def create_analysis(body: CreateAnalysisRequest):
|
||||||
if body.pipelineOptions:
|
if body.pipelineOptions:
|
||||||
pipeline_opts = body.pipelineOptions.model_dump()
|
pipeline_opts = body.pipelineOptions.model_dump()
|
||||||
|
|
||||||
|
chunking_opts = None
|
||||||
|
if body.chunkingOptions:
|
||||||
|
chunking_opts = body.chunkingOptions.model_dump()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
job = await analysis_service.create(body.documentId, pipeline_options=pipeline_opts)
|
job = await service.create(
|
||||||
|
body.documentId,
|
||||||
|
pipeline_options=pipeline_opts,
|
||||||
|
chunking_options=chunking_opts,
|
||||||
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||||
|
|
||||||
|
|
@ -48,24 +72,45 @@ async def create_analysis(body: CreateAnalysisRequest):
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[AnalysisResponse])
|
@router.get("", response_model=list[AnalysisResponse])
|
||||||
async def list_analyses():
|
async def list_analyses(service: ServiceDep) -> list[AnalysisResponse]:
|
||||||
"""List all analysis jobs."""
|
"""List all analysis jobs."""
|
||||||
jobs = await analysis_service.find_all()
|
jobs = await service.find_all()
|
||||||
return [_to_response(j) for j in jobs]
|
return [_to_response(j) for j in jobs]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{job_id}", response_model=AnalysisResponse)
|
@router.get("/{job_id}", response_model=AnalysisResponse)
|
||||||
async def get_analysis(job_id: str):
|
async def get_analysis(job_id: str, service: ServiceDep) -> AnalysisResponse:
|
||||||
"""Get a single analysis job."""
|
"""Get a single analysis job."""
|
||||||
job = await analysis_service.find_by_id(job_id)
|
job = await service.find_by_id(job_id)
|
||||||
if not job:
|
if not job:
|
||||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||||
return _to_response(job)
|
return _to_response(job)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{job_id}/rechunk", response_model=list[ChunkResponse])
|
||||||
|
async def rechunk_analysis(
|
||||||
|
job_id: str, body: RechunkRequest, service: ServiceDep
|
||||||
|
) -> list[ChunkResponse]:
|
||||||
|
"""Re-chunk a completed analysis with new chunking options."""
|
||||||
|
try:
|
||||||
|
chunks = await service.rechunk(job_id, body.chunkingOptions.model_dump())
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
return [
|
||||||
|
ChunkResponse(
|
||||||
|
text=c.text,
|
||||||
|
headings=c.headings,
|
||||||
|
source_page=c.source_page,
|
||||||
|
token_count=c.token_count,
|
||||||
|
bboxes=[ChunkBboxResponse(page=b.page, bbox=b.bbox) for b in c.bboxes],
|
||||||
|
)
|
||||||
|
for c in chunks
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{job_id}", status_code=204)
|
@router.delete("/{job_id}", status_code=204)
|
||||||
async def delete_analysis(job_id: str):
|
async def delete_analysis(job_id: str, service: ServiceDep) -> None:
|
||||||
"""Delete an analysis job."""
|
"""Delete an analysis job."""
|
||||||
deleted = await analysis_service.delete(job_id)
|
deleted = await service.delete(job_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ from services import document_service
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/documents", tags=["documents"])
|
router = APIRouter(prefix="/api/documents", tags=["documents"])
|
||||||
|
|
||||||
|
_READ_CHUNK_SIZE = 64 * 1024 # 64 KB
|
||||||
|
|
||||||
|
|
||||||
def _to_response(doc) -> DocumentResponse:
|
def _to_response(doc) -> DocumentResponse:
|
||||||
return DocumentResponse(
|
return DocumentResponse(
|
||||||
|
|
@ -25,8 +27,8 @@ def _to_response(doc) -> DocumentResponse:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload", response_model=DocumentResponse)
|
@router.post("/upload", response_model=DocumentResponse, status_code=200)
|
||||||
async def upload(file: UploadFile):
|
async def upload(file: UploadFile) -> DocumentResponse:
|
||||||
"""Upload a PDF document."""
|
"""Upload a PDF document."""
|
||||||
if not file.filename:
|
if not file.filename:
|
||||||
raise HTTPException(status_code=400, detail="No filename provided")
|
raise HTTPException(status_code=400, detail="No filename provided")
|
||||||
|
|
@ -35,7 +37,15 @@ async def upload(file: UploadFile):
|
||||||
if file.size and file.size > document_service.MAX_FILE_SIZE:
|
if file.size and file.size > document_service.MAX_FILE_SIZE:
|
||||||
raise HTTPException(status_code=413, detail="File too large (max 50 MB)")
|
raise HTTPException(status_code=413, detail="File too large (max 50 MB)")
|
||||||
|
|
||||||
content = await file.read()
|
# Read in chunks to avoid holding the full upload in a single allocation
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
total = 0
|
||||||
|
while chunk := await file.read(_READ_CHUNK_SIZE):
|
||||||
|
total += len(chunk)
|
||||||
|
if total > document_service.MAX_FILE_SIZE:
|
||||||
|
raise HTTPException(status_code=413, detail="File too large (max 50 MB)")
|
||||||
|
chunks.append(chunk)
|
||||||
|
content = b"".join(chunks)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
doc = await document_service.upload(
|
doc = await document_service.upload(
|
||||||
|
|
@ -44,20 +54,20 @@ async def upload(file: UploadFile):
|
||||||
file_content=content,
|
file_content=content,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=413, detail=str(e)) from e
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
|
||||||
return _to_response(doc)
|
return _to_response(doc)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[DocumentResponse])
|
@router.get("", response_model=list[DocumentResponse])
|
||||||
async def list_documents():
|
async def list_documents() -> list[DocumentResponse]:
|
||||||
"""List all documents."""
|
"""List all documents."""
|
||||||
docs = await document_service.find_all()
|
docs = await document_service.find_all()
|
||||||
return [_to_response(d) for d in docs]
|
return [_to_response(d) for d in docs]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{doc_id}", response_model=DocumentResponse)
|
@router.get("/{doc_id}", response_model=DocumentResponse)
|
||||||
async def get_document(doc_id: str):
|
async def get_document(doc_id: str) -> DocumentResponse:
|
||||||
"""Get a single document."""
|
"""Get a single document."""
|
||||||
doc = await document_service.find_by_id(doc_id)
|
doc = await document_service.find_by_id(doc_id)
|
||||||
if not doc:
|
if not doc:
|
||||||
|
|
@ -66,7 +76,7 @@ async def get_document(doc_id: str):
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{doc_id}", status_code=204)
|
@router.delete("/{doc_id}", status_code=204)
|
||||||
async def delete_document(doc_id: str):
|
async def delete_document(doc_id: str) -> None:
|
||||||
"""Delete a document and its file."""
|
"""Delete a document and its file."""
|
||||||
deleted = await document_service.delete(doc_id)
|
deleted = await document_service.delete(doc_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
|
|
@ -78,12 +88,18 @@ async def preview(
|
||||||
doc_id: str,
|
doc_id: str,
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
dpi: int = Query(150, ge=72, le=300),
|
dpi: int = Query(150, ge=72, le=300),
|
||||||
):
|
) -> Response:
|
||||||
"""Generate a PNG preview of a specific PDF page."""
|
"""Generate a PNG preview of a specific PDF page."""
|
||||||
doc = await document_service.find_by_id(doc_id)
|
doc = await document_service.find_by_id(doc_id)
|
||||||
if not doc:
|
if not doc:
|
||||||
raise HTTPException(status_code=404, detail="Document not found")
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
|
||||||
|
if doc.page_count and page > doc.page_count:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Page {page} out of range (document has {doc.page_count} pages)",
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(doc.storage_path, "rb") as f:
|
with open(doc.storage_path, "rb") as f:
|
||||||
file_content = f.read()
|
file_content = f.read()
|
||||||
|
|
@ -91,6 +107,11 @@ async def preview(
|
||||||
return Response(content=png_bytes, media_type="image/png")
|
return Response(content=png_bytes, media_type="image/png")
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail="PDF file not found on disk") from exc
|
||||||
|
except OSError as exc:
|
||||||
|
logger.exception("I/O error generating preview for %s", doc_id)
|
||||||
|
raise HTTPException(status_code=422, detail="Failed to read PDF file") from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Failed to generate preview")
|
logger.exception("Unexpected error generating preview for %s", doc_id)
|
||||||
raise HTTPException(status_code=422, detail="Failed to generate preview") from exc
|
raise HTTPException(status_code=422, detail="Failed to generate preview") from exc
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
def _to_camel(name: str) -> str:
|
def _to_camel(name: str) -> str:
|
||||||
|
|
@ -18,6 +18,7 @@ def _to_camel(name: str) -> str:
|
||||||
|
|
||||||
class _CamelModel(BaseModel):
|
class _CamelModel(BaseModel):
|
||||||
"""Base model that serializes field names to camelCase."""
|
"""Base model that serializes field names to camelCase."""
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
alias_generator=_to_camel,
|
alias_generator=_to_camel,
|
||||||
populate_by_name=True,
|
populate_by_name=True,
|
||||||
|
|
@ -28,6 +29,7 @@ class _CamelModel(BaseModel):
|
||||||
class DocumentResponse(_CamelModel):
|
class DocumentResponse(_CamelModel):
|
||||||
id: str
|
id: str
|
||||||
filename: str
|
filename: str
|
||||||
|
status: str = "uploaded" # Document status (always "uploaded" for now)
|
||||||
content_type: str | None = None
|
content_type: str | None = None
|
||||||
file_size: int | None = None
|
file_size: int | None = None
|
||||||
page_count: int | None = None
|
page_count: int | None = None
|
||||||
|
|
@ -42,6 +44,8 @@ class AnalysisResponse(_CamelModel):
|
||||||
content_markdown: str | None = None
|
content_markdown: str | None = None
|
||||||
content_html: str | None = None
|
content_html: str | None = None
|
||||||
pages_json: str | None = None
|
pages_json: str | None = None
|
||||||
|
chunks_json: str | None = None
|
||||||
|
has_document_json: bool = False
|
||||||
error_message: str | None = None
|
error_message: str | None = None
|
||||||
started_at: str | datetime | None = None
|
started_at: str | datetime | None = None
|
||||||
completed_at: str | datetime | None = None
|
completed_at: str | datetime | None = None
|
||||||
|
|
@ -50,16 +54,40 @@ class AnalysisResponse(_CamelModel):
|
||||||
|
|
||||||
class PipelineOptionsRequest(BaseModel):
|
class PipelineOptionsRequest(BaseModel):
|
||||||
"""Docling pipeline configuration options."""
|
"""Docling pipeline configuration options."""
|
||||||
do_ocr: bool = True
|
|
||||||
do_table_structure: bool = True
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
table_mode: str = "accurate" # "accurate" or "fast"
|
|
||||||
do_code_enrichment: bool = False
|
do_ocr: bool = Field(default=True, validation_alias=AliasChoices("do_ocr", "doOcr"))
|
||||||
do_formula_enrichment: bool = False
|
do_table_structure: bool = Field(
|
||||||
do_picture_classification: bool = False
|
default=True, validation_alias=AliasChoices("do_table_structure", "doTableStructure")
|
||||||
do_picture_description: bool = False
|
)
|
||||||
generate_picture_images: bool = False
|
table_mode: str = Field(
|
||||||
generate_page_images: bool = False
|
default="accurate", validation_alias=AliasChoices("table_mode", "tableMode")
|
||||||
images_scale: float = 1.0
|
)
|
||||||
|
do_code_enrichment: bool = Field(
|
||||||
|
default=False, validation_alias=AliasChoices("do_code_enrichment", "doCodeEnrichment")
|
||||||
|
)
|
||||||
|
do_formula_enrichment: bool = Field(
|
||||||
|
default=False, validation_alias=AliasChoices("do_formula_enrichment", "doFormulaEnrichment")
|
||||||
|
)
|
||||||
|
do_picture_classification: bool = Field(
|
||||||
|
default=False,
|
||||||
|
validation_alias=AliasChoices("do_picture_classification", "doPictureClassification"),
|
||||||
|
)
|
||||||
|
do_picture_description: bool = Field(
|
||||||
|
default=False,
|
||||||
|
validation_alias=AliasChoices("do_picture_description", "doPictureDescription"),
|
||||||
|
)
|
||||||
|
generate_picture_images: bool = Field(
|
||||||
|
default=False,
|
||||||
|
validation_alias=AliasChoices("generate_picture_images", "generatePictureImages"),
|
||||||
|
)
|
||||||
|
generate_page_images: bool = Field(
|
||||||
|
default=False, validation_alias=AliasChoices("generate_page_images", "generatePageImages")
|
||||||
|
)
|
||||||
|
images_scale: float = Field(
|
||||||
|
default=1.0, validation_alias=AliasChoices("images_scale", "imagesScale")
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("table_mode")
|
@field_validator("table_mode")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -76,6 +104,61 @@ class PipelineOptionsRequest(BaseModel):
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ChunkingOptionsRequest(BaseModel):
|
||||||
|
"""Docling chunking configuration options."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
chunker_type: str = Field(
|
||||||
|
default="hybrid", validation_alias=AliasChoices("chunker_type", "chunkerType")
|
||||||
|
)
|
||||||
|
max_tokens: int = Field(default=512, validation_alias=AliasChoices("max_tokens", "maxTokens"))
|
||||||
|
merge_peers: bool = Field(
|
||||||
|
default=True, validation_alias=AliasChoices("merge_peers", "mergePeers")
|
||||||
|
)
|
||||||
|
repeat_table_header: bool = Field(
|
||||||
|
default=True, validation_alias=AliasChoices("repeat_table_header", "repeatTableHeader")
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("chunker_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_chunker_type(cls, v: str) -> str:
|
||||||
|
if v not in ("hybrid", "hierarchical"):
|
||||||
|
raise ValueError('chunker_type must be "hybrid" or "hierarchical"')
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("max_tokens")
|
||||||
|
@classmethod
|
||||||
|
def validate_max_tokens(cls, v: int) -> int:
|
||||||
|
if v < 64 or v > 8192:
|
||||||
|
raise ValueError("max_tokens must be between 64 and 8192")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ChunkBboxResponse(_CamelModel):
|
||||||
|
page: int
|
||||||
|
bbox: list[float]
|
||||||
|
|
||||||
|
|
||||||
|
class ChunkResponse(_CamelModel):
|
||||||
|
text: str
|
||||||
|
headings: list[str] = []
|
||||||
|
source_page: int | None = None
|
||||||
|
token_count: int = 0
|
||||||
|
bboxes: list[ChunkBboxResponse] = []
|
||||||
|
|
||||||
|
|
||||||
class CreateAnalysisRequest(BaseModel):
|
class CreateAnalysisRequest(BaseModel):
|
||||||
documentId: str # camelCase to match existing frontend contract
|
documentId: str = Field(validation_alias=AliasChoices("documentId", "document_id"))
|
||||||
pipelineOptions: PipelineOptionsRequest | None = None
|
pipelineOptions: PipelineOptionsRequest | None = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("pipelineOptions", "pipeline_options")
|
||||||
|
)
|
||||||
|
chunkingOptions: ChunkingOptionsRequest | None = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("chunkingOptions", "chunking_options")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RechunkRequest(BaseModel):
|
||||||
|
chunkingOptions: ChunkingOptionsRequest = Field(
|
||||||
|
validation_alias=AliasChoices("chunkingOptions", "chunking_options")
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1 @@
|
||||||
|
|
||||||
|
|
||||||
pytest_plugins = ["pytest_asyncio"]
|
pytest_plugins = ["pytest_asyncio"]
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ class AnalysisJob:
|
||||||
content_markdown: str | None = None
|
content_markdown: str | None = None
|
||||||
content_html: str | None = None
|
content_html: str | None = None
|
||||||
pages_json: str | None = None
|
pages_json: str | None = None
|
||||||
|
document_json: str | None = None
|
||||||
|
chunks_json: str | None = None
|
||||||
error_message: str | None = None
|
error_message: str | None = None
|
||||||
started_at: datetime | None = None
|
started_at: datetime | None = None
|
||||||
completed_at: datetime | None = None
|
completed_at: datetime | None = None
|
||||||
|
|
@ -51,19 +53,29 @@ class AnalysisJob:
|
||||||
document_filename: str | None = None
|
document_filename: str | None = None
|
||||||
|
|
||||||
def mark_running(self) -> None:
|
def mark_running(self) -> None:
|
||||||
|
"""Transition to RUNNING and record the start timestamp."""
|
||||||
self.status = AnalysisStatus.RUNNING
|
self.status = AnalysisStatus.RUNNING
|
||||||
self.started_at = _utcnow()
|
self.started_at = _utcnow()
|
||||||
|
|
||||||
def mark_completed(
|
def mark_completed(
|
||||||
self, markdown: str, html: str, pages_json: str,
|
self,
|
||||||
|
markdown: str,
|
||||||
|
html: str,
|
||||||
|
pages_json: str,
|
||||||
|
document_json: str | None = None,
|
||||||
|
chunks_json: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Transition to COMPLETED with conversion results."""
|
||||||
self.status = AnalysisStatus.COMPLETED
|
self.status = AnalysisStatus.COMPLETED
|
||||||
self.content_markdown = markdown
|
self.content_markdown = markdown
|
||||||
self.content_html = html
|
self.content_html = html
|
||||||
self.pages_json = pages_json
|
self.pages_json = pages_json
|
||||||
|
self.document_json = document_json
|
||||||
|
self.chunks_json = chunks_json
|
||||||
self.completed_at = _utcnow()
|
self.completed_at = _utcnow()
|
||||||
|
|
||||||
def mark_failed(self, error: str) -> None:
|
def mark_failed(self, error: str) -> None:
|
||||||
|
"""Transition to FAILED with an error message."""
|
||||||
self.status = AnalysisStatus.FAILED
|
self.status = AnalysisStatus.FAILED
|
||||||
self.error_message = error
|
self.error_message = error
|
||||||
self.completed_at = _utcnow()
|
self.completed_at = _utcnow()
|
||||||
|
|
|
||||||
44
document-parser/domain/ports.py
Normal file
44
document-parser/domain/ports.py
Normal 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]: ...
|
||||||
80
document-parser/domain/value_objects.py
Normal file
80
document-parser/domain/value_objects.py
Normal 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)
|
||||||
0
document-parser/infra/__init__.py
Normal file
0
document-parser/infra/__init__.py
Normal file
|
|
@ -43,7 +43,11 @@ def to_topleft_list(bbox: BoundingBox, page_height: float) -> list[float]:
|
||||||
if right <= left or bottom <= top:
|
if right <= left or bottom <= top:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Degenerate bbox skipped: [%.1f, %.1f, %.1f, %.1f] (page_height=%.1f)",
|
"Degenerate bbox skipped: [%.1f, %.1f, %.1f, %.1f] (page_height=%.1f)",
|
||||||
left, top, right, bottom, page_height,
|
left,
|
||||||
|
top,
|
||||||
|
right,
|
||||||
|
bottom,
|
||||||
|
page_height,
|
||||||
)
|
)
|
||||||
return list(EMPTY_BBOX)
|
return list(EMPTY_BBOX)
|
||||||
|
|
||||||
96
document-parser/infra/local_chunker.py
Normal file
96
document-parser/infra/local_chunker.py
Normal 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)
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
"""Docling document extraction logic — pure domain, no HTTP concerns.
|
"""Local Docling converter — runs Docling as a Python library in-process.
|
||||||
|
|
||||||
Wraps the Docling library to convert documents and extract structured
|
This adapter implements the DocumentConverter port using the Docling library
|
||||||
per-page elements with bounding boxes and hierarchy levels.
|
directly. It wraps the blocking DocumentConverter in asyncio.to_thread for
|
||||||
|
non-blocking execution.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
from docling.datamodel.base_models import InputFormat
|
from docling.datamodel.base_models import InputFormat
|
||||||
from docling.datamodel.pipeline_options import (
|
from docling.datamodel.pipeline_options import (
|
||||||
|
|
@ -17,7 +19,8 @@ from docling.datamodel.pipeline_options import (
|
||||||
TableFormerMode,
|
TableFormerMode,
|
||||||
TableStructureOptions,
|
TableStructureOptions,
|
||||||
)
|
)
|
||||||
from docling.document_converter import DocumentConverter, PdfFormatOption
|
from docling.document_converter import DocumentConverter as DoclingConverter
|
||||||
|
from docling.document_converter import PdfFormatOption
|
||||||
from docling_core.types.doc import (
|
from docling_core.types.doc import (
|
||||||
CodeItem,
|
CodeItem,
|
||||||
DocItem,
|
DocItem,
|
||||||
|
|
@ -32,11 +35,17 @@ from docling_core.types.doc import (
|
||||||
TitleItem,
|
TitleItem,
|
||||||
)
|
)
|
||||||
|
|
||||||
from domain.bbox import to_topleft_list
|
from domain.value_objects import (
|
||||||
|
ConversionOptions,
|
||||||
|
ConversionResult,
|
||||||
|
PageDetail,
|
||||||
|
PageElement,
|
||||||
|
)
|
||||||
|
from infra.bbox import to_topleft_list
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Thread lock — DocumentConverter is not thread-safe
|
# Thread lock — DoclingConverter is not thread-safe
|
||||||
_converter_lock = threading.Lock()
|
_converter_lock = threading.Lock()
|
||||||
|
|
||||||
# US Letter page dimensions (points) — fallback when page size is unknown
|
# US Letter page dimensions (points) — fallback when page size is unknown
|
||||||
|
|
@ -44,61 +53,13 @@ _DEFAULT_PAGE_WIDTH = 612.0
|
||||||
_DEFAULT_PAGE_HEIGHT = 792.0
|
_DEFAULT_PAGE_HEIGHT = 792.0
|
||||||
|
|
||||||
# Default converter (lazy-init on first request)
|
# Default converter (lazy-init on first request)
|
||||||
_default_converter: DocumentConverter | None = None
|
_default_converter: DoclingConverter | None = None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Domain value objects
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PageElement:
|
|
||||||
type: str
|
|
||||||
bbox: list[float]
|
|
||||||
content: str
|
|
||||||
level: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PageDetail:
|
|
||||||
page_number: int
|
|
||||||
width: float
|
|
||||||
height: float
|
|
||||||
elements: list[PageElement] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ConversionOptions:
|
|
||||||
do_ocr: bool = True
|
|
||||||
do_table_structure: bool = True
|
|
||||||
table_mode: str = "accurate"
|
|
||||||
do_code_enrichment: bool = False
|
|
||||||
do_formula_enrichment: bool = False
|
|
||||||
do_picture_classification: bool = False
|
|
||||||
do_picture_description: bool = False
|
|
||||||
generate_picture_images: bool = False
|
|
||||||
generate_page_images: bool = False
|
|
||||||
images_scale: float = 1.0
|
|
||||||
|
|
||||||
def is_default(self) -> bool:
|
|
||||||
return self == ConversionOptions()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ConversionResult:
|
|
||||||
page_count: int
|
|
||||||
content_markdown: str
|
|
||||||
content_html: str
|
|
||||||
pages: list[PageDetail]
|
|
||||||
skipped_items: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Element type detection
|
# Element type detection
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Mapping from Docling type to element type string.
|
|
||||||
# Order matters: most specific types before their parents.
|
|
||||||
_ELEMENT_TYPE_MAP: list[tuple[type, str]] = [
|
_ELEMENT_TYPE_MAP: list[tuple[type, str]] = [
|
||||||
(TableItem, "table"),
|
(TableItem, "table"),
|
||||||
(PictureItem, "picture"),
|
(PictureItem, "picture"),
|
||||||
|
|
@ -113,7 +74,6 @@ _ELEMENT_TYPE_MAP: list[tuple[type, str]] = [
|
||||||
|
|
||||||
|
|
||||||
def _get_element_type(item: DocItem) -> str:
|
def _get_element_type(item: DocItem) -> str:
|
||||||
"""Determine element type via isinstance on Docling's type hierarchy."""
|
|
||||||
for cls, type_name in _ELEMENT_TYPE_MAP:
|
for cls, type_name in _ELEMENT_TYPE_MAP:
|
||||||
if isinstance(item, cls):
|
if isinstance(item, cls):
|
||||||
return type_name
|
return type_name
|
||||||
|
|
@ -124,51 +84,52 @@ def _get_element_type(item: DocItem) -> str:
|
||||||
# Pipeline factory
|
# Pipeline factory
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def build_converter(options: ConversionOptions | None = None) -> DocumentConverter:
|
|
||||||
"""Build a DocumentConverter with the given pipeline options."""
|
|
||||||
opts = options or ConversionOptions()
|
|
||||||
|
|
||||||
|
def _build_docling_converter(options: ConversionOptions) -> DoclingConverter:
|
||||||
table_options = TableStructureOptions(
|
table_options = TableStructureOptions(
|
||||||
do_cell_matching=True,
|
do_cell_matching=True,
|
||||||
mode=TableFormerMode.ACCURATE if opts.table_mode == "accurate" else TableFormerMode.FAST,
|
mode=TableFormerMode.ACCURATE if options.table_mode == "accurate" else TableFormerMode.FAST,
|
||||||
)
|
)
|
||||||
|
|
||||||
pipeline_options = PdfPipelineOptions(
|
pipeline_options = PdfPipelineOptions(
|
||||||
do_ocr=opts.do_ocr,
|
do_ocr=options.do_ocr,
|
||||||
do_table_structure=opts.do_table_structure,
|
do_table_structure=options.do_table_structure,
|
||||||
table_structure_options=table_options,
|
table_structure_options=table_options,
|
||||||
do_code_enrichment=opts.do_code_enrichment,
|
do_code_enrichment=options.do_code_enrichment,
|
||||||
do_formula_enrichment=opts.do_formula_enrichment,
|
do_formula_enrichment=options.do_formula_enrichment,
|
||||||
do_picture_classification=opts.do_picture_classification,
|
do_picture_classification=options.do_picture_classification,
|
||||||
do_picture_description=opts.do_picture_description,
|
do_picture_description=options.do_picture_description,
|
||||||
generate_page_images=opts.generate_page_images,
|
generate_page_images=options.generate_page_images,
|
||||||
generate_picture_images=opts.generate_picture_images,
|
generate_picture_images=options.generate_picture_images,
|
||||||
images_scale=opts.images_scale,
|
images_scale=options.images_scale,
|
||||||
)
|
)
|
||||||
|
|
||||||
return DocumentConverter(
|
return DoclingConverter(
|
||||||
format_options={
|
format_options={
|
||||||
InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options),
|
InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_default_converter() -> DocumentConverter:
|
def _get_default_converter() -> DoclingConverter:
|
||||||
global _default_converter
|
global _default_converter
|
||||||
if _default_converter is None:
|
if _default_converter is None:
|
||||||
_default_converter = build_converter()
|
_default_converter = _build_docling_converter(ConversionOptions())
|
||||||
return _default_converter
|
return _default_converter
|
||||||
|
|
||||||
|
|
||||||
|
def _select_converter(options: ConversionOptions) -> DoclingConverter:
|
||||||
|
if options.is_default():
|
||||||
|
return _get_default_converter()
|
||||||
|
return _build_docling_converter(options)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Page extraction
|
# Page extraction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
|
|
||||||
"""Extract per-page element details with bounding boxes from Docling result.
|
|
||||||
|
|
||||||
Returns (pages, skipped_count) for transparent error reporting.
|
def _extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
|
||||||
"""
|
|
||||||
pages: dict[int, PageDetail] = {}
|
pages: dict[int, PageDetail] = {}
|
||||||
document = doc_result.document
|
document = doc_result.document
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
|
@ -191,9 +152,10 @@ def extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
|
||||||
|
|
||||||
|
|
||||||
def _process_content_item(
|
def _process_content_item(
|
||||||
item: DocItem | GroupItem, level: int, pages: dict[int, PageDetail],
|
item: DocItem | GroupItem,
|
||||||
|
level: int,
|
||||||
|
pages: dict[int, PageDetail],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Process a single content item and add it to the appropriate page."""
|
|
||||||
if isinstance(item, GroupItem):
|
if isinstance(item, GroupItem):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
@ -204,14 +166,15 @@ def _process_content_item(
|
||||||
try:
|
try:
|
||||||
page_no = prov.page_no
|
page_no = prov.page_no
|
||||||
if page_no not in pages:
|
if page_no not in pages:
|
||||||
# Fallback: page was not found in document.pages (corrupted PDF or
|
|
||||||
# Docling edge case). US Letter dimensions are used as a safe default.
|
|
||||||
# This may cause slight bbox misalignment on non-Letter pages (e.g. A4).
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Page %d not found in document metadata — using US Letter fallback (%sx%s pt)",
|
"Page %d not found in document metadata — using US Letter fallback (%sx%s pt)",
|
||||||
page_no, _DEFAULT_PAGE_WIDTH, _DEFAULT_PAGE_HEIGHT,
|
page_no,
|
||||||
|
_DEFAULT_PAGE_WIDTH,
|
||||||
|
_DEFAULT_PAGE_HEIGHT,
|
||||||
|
)
|
||||||
|
pages[page_no] = PageDetail(
|
||||||
|
page_number=page_no, width=_DEFAULT_PAGE_WIDTH, height=_DEFAULT_PAGE_HEIGHT
|
||||||
)
|
)
|
||||||
pages[page_no] = PageDetail(page_number=page_no, width=_DEFAULT_PAGE_WIDTH, height=_DEFAULT_PAGE_HEIGHT)
|
|
||||||
|
|
||||||
page_height = pages[page_no].height
|
page_height = pages[page_no].height
|
||||||
|
|
||||||
|
|
@ -242,49 +205,30 @@ def _process_content_item(
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Main conversion entry point
|
# Synchronous conversion (called via asyncio.to_thread)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _select_converter(options: ConversionOptions) -> DocumentConverter:
|
|
||||||
"""Return the cached default converter or build a custom one."""
|
|
||||||
if options.is_default():
|
|
||||||
return get_default_converter()
|
|
||||||
return build_converter(options)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_fallback_pages(doc, page_count: int) -> list[PageDetail]:
|
|
||||||
"""Create empty PageDetail entries when extraction yields nothing."""
|
|
||||||
return [
|
|
||||||
PageDetail(
|
|
||||||
page_number=i + 1,
|
|
||||||
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else _DEFAULT_PAGE_WIDTH,
|
|
||||||
height=doc.pages[i + 1].size.height if (i + 1) in doc.pages else _DEFAULT_PAGE_HEIGHT,
|
|
||||||
)
|
|
||||||
for i in range(page_count)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def convert_document(
|
|
||||||
file_path: str,
|
|
||||||
options: ConversionOptions | None = None,
|
|
||||||
) -> ConversionResult:
|
|
||||||
"""Convert a document and return structured results.
|
|
||||||
|
|
||||||
This is the main entry point for document parsing. Runs synchronously
|
|
||||||
(caller should use asyncio.to_thread for non-blocking execution).
|
|
||||||
"""
|
|
||||||
opts = options or ConversionOptions()
|
|
||||||
|
|
||||||
|
def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResult:
|
||||||
with _converter_lock:
|
with _converter_lock:
|
||||||
conv = _select_converter(opts)
|
conv = _select_converter(options)
|
||||||
result = conv.convert(file_path)
|
result = conv.convert(file_path)
|
||||||
|
|
||||||
doc = result.document
|
doc = result.document
|
||||||
page_count = len(doc.pages)
|
page_count = len(doc.pages)
|
||||||
pages_detail, skipped = extract_pages_detail(result)
|
pages_detail, skipped = _extract_pages_detail(result)
|
||||||
|
|
||||||
if not pages_detail and page_count > 0:
|
if not pages_detail and page_count > 0:
|
||||||
pages_detail = _build_fallback_pages(doc, page_count)
|
pages_detail = [
|
||||||
|
PageDetail(
|
||||||
|
page_number=i + 1,
|
||||||
|
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else _DEFAULT_PAGE_WIDTH,
|
||||||
|
height=doc.pages[i + 1].size.height
|
||||||
|
if (i + 1) in doc.pages
|
||||||
|
else _DEFAULT_PAGE_HEIGHT,
|
||||||
|
)
|
||||||
|
for i in range(page_count)
|
||||||
|
]
|
||||||
|
|
||||||
if skipped > 0:
|
if skipped > 0:
|
||||||
logger.info("Parsed: %d pages, %d items skipped", page_count, skipped)
|
logger.info("Parsed: %d pages, %d items skipped", page_count, skipped)
|
||||||
|
|
@ -295,4 +239,21 @@ def convert_document(
|
||||||
content_html=doc.export_to_html(),
|
content_html=doc.export_to_html(),
|
||||||
pages=pages_detail,
|
pages=pages_detail,
|
||||||
skipped_items=skipped,
|
skipped_items=skipped,
|
||||||
|
document_json=json.dumps(doc.export_to_dict()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public adapter class
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class LocalConverter:
|
||||||
|
"""Adapter that runs Docling locally as a Python library."""
|
||||||
|
|
||||||
|
async def convert(
|
||||||
|
self,
|
||||||
|
file_path: str,
|
||||||
|
options: ConversionOptions,
|
||||||
|
) -> ConversionResult:
|
||||||
|
return await asyncio.to_thread(_convert_sync, file_path, options)
|
||||||
89
document-parser/infra/rate_limiter.py
Normal file
89
document-parser/infra/rate_limiter.py
Normal 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)
|
||||||
245
document-parser/infra/serve_converter.py
Normal file
245
document-parser/infra/serve_converter.py
Normal 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)
|
||||||
43
document-parser/infra/settings.py
Normal file
43
document-parser/infra/settings.py
Normal 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()
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
"""Docling Studio — unified FastAPI backend.
|
"""Docling Studio — unified FastAPI backend.
|
||||||
|
|
||||||
Single service replacing both the Spring Boot backend and the document parser.
|
Single service providing document management (upload, CRUD), analysis
|
||||||
Provides document management (upload, CRUD), analysis orchestration (async Docling
|
orchestration (async Docling processing), and PDF preview — all backed
|
||||||
processing), and PDF preview — all backed by SQLite.
|
by SQLite.
|
||||||
|
|
||||||
|
Conversion engine is selected via CONVERSION_ENGINE env var:
|
||||||
|
- "local" → Docling runs in-process as a Python library (default)
|
||||||
|
- "remote" → delegates to a Docling Serve instance via HTTP
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
@ -16,7 +20,10 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from api.analyses import router as analyses_router
|
from api.analyses import router as analyses_router
|
||||||
from api.documents import router as documents_router
|
from api.documents import router as documents_router
|
||||||
from persistence.database import init_db
|
from infra.rate_limiter import RateLimiterMiddleware
|
||||||
|
from infra.settings import settings
|
||||||
|
from persistence.database import get_connection, init_db
|
||||||
|
from services.analysis_service import AnalysisService
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
|
|
@ -25,11 +32,53 @@ logging.basicConfig(
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_converter():
|
||||||
|
"""Build the converter adapter based on configuration."""
|
||||||
|
if settings.conversion_engine == "remote":
|
||||||
|
from infra.serve_converter import ServeConverter
|
||||||
|
|
||||||
|
logger.info("Using remote Docling Serve at %s", settings.docling_serve_url)
|
||||||
|
return ServeConverter(
|
||||||
|
base_url=settings.docling_serve_url,
|
||||||
|
api_key=settings.docling_serve_api_key,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
from infra.local_converter import LocalConverter
|
||||||
|
|
||||||
|
logger.info("Using local Docling converter")
|
||||||
|
return LocalConverter()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_chunker():
|
||||||
|
"""Build the chunker adapter — only available in local mode."""
|
||||||
|
if settings.conversion_engine == "local":
|
||||||
|
from infra.local_chunker import LocalChunker
|
||||||
|
|
||||||
|
return LocalChunker()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_analysis_service() -> AnalysisService:
|
||||||
|
converter = _build_converter()
|
||||||
|
chunker = _build_chunker()
|
||||||
|
return AnalysisService(
|
||||||
|
converter=converter,
|
||||||
|
chunker=chunker,
|
||||||
|
conversion_timeout=settings.conversion_timeout,
|
||||||
|
max_concurrent=settings.max_concurrent_analyses,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FastAPI app
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
"""Startup: initialize database. Shutdown: nothing special needed."""
|
|
||||||
await init_db()
|
await init_db()
|
||||||
logger.info("Docling Studio backend ready")
|
app.state.analysis_service = _build_analysis_service()
|
||||||
|
logger.info("Docling Studio backend ready (engine=%s)", settings.conversion_engine)
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -39,25 +88,35 @@ app = FastAPI(
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS — configurable via env, defaults for local dev
|
|
||||||
allowed_origins = os.environ.get(
|
|
||||||
"CORS_ORIGINS", "http://localhost:3000,http://localhost:5173"
|
|
||||||
).split(",")
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=[o.strip() for o in allowed_origins],
|
allow_origins=settings.cors_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||||
allow_headers=["Content-Type", "Authorization"],
|
allow_headers=["Content-Type", "Authorization"],
|
||||||
)
|
)
|
||||||
|
app.add_middleware(RateLimiterMiddleware, requests_per_window=100, window_seconds=60)
|
||||||
|
|
||||||
# Mount routers
|
|
||||||
app.include_router(documents_router)
|
app.include_router(documents_router)
|
||||||
app.include_router(analyses_router)
|
app.include_router(analyses_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/api/health")
|
||||||
def health():
|
async def health() -> dict[str, str]:
|
||||||
"""Health check endpoint."""
|
"""Health check endpoint — verifies database connectivity."""
|
||||||
return {"status": "ok"}
|
db_status = "ok"
|
||||||
|
try:
|
||||||
|
async with get_connection() as db:
|
||||||
|
await db.execute("SELECT 1")
|
||||||
|
except Exception:
|
||||||
|
db_status = "error"
|
||||||
|
logger.warning("Health check: database unreachable", exc_info=True)
|
||||||
|
|
||||||
|
status = "ok" if db_status == "ok" else "degraded"
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"version": settings.app_version,
|
||||||
|
"engine": settings.conversion_engine,
|
||||||
|
"deploymentMode": settings.deployment_mode,
|
||||||
|
"database": db_status,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,21 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from domain.models import AnalysisJob, AnalysisStatus
|
from domain.models import AnalysisJob, AnalysisStatus
|
||||||
from persistence.database import get_connection
|
from persistence.database import get_connection
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_dt(value: str | None) -> datetime | None:
|
||||||
|
"""Parse an ISO-format datetime string back into a datetime object."""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return datetime.fromisoformat(value)
|
||||||
|
|
||||||
|
|
||||||
def _row_to_job(row) -> AnalysisJob:
|
def _row_to_job(row) -> AnalysisJob:
|
||||||
|
keys = row.keys()
|
||||||
return AnalysisJob(
|
return AnalysisJob(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
document_id=row["document_id"],
|
document_id=row["document_id"],
|
||||||
|
|
@ -14,11 +24,13 @@ def _row_to_job(row) -> AnalysisJob:
|
||||||
content_markdown=row["content_markdown"],
|
content_markdown=row["content_markdown"],
|
||||||
content_html=row["content_html"],
|
content_html=row["content_html"],
|
||||||
pages_json=row["pages_json"],
|
pages_json=row["pages_json"],
|
||||||
|
document_json=row["document_json"] if "document_json" in keys else None,
|
||||||
|
chunks_json=row["chunks_json"] if "chunks_json" in keys else None,
|
||||||
error_message=row["error_message"],
|
error_message=row["error_message"],
|
||||||
started_at=row["started_at"],
|
started_at=_parse_dt(row["started_at"]),
|
||||||
completed_at=row["completed_at"],
|
completed_at=_parse_dt(row["completed_at"]),
|
||||||
created_at=row["created_at"],
|
created_at=_parse_dt(row["created_at"]) or datetime.now(UTC),
|
||||||
document_filename=row["filename"] if "filename" in row.keys() else None, # noqa: SIM118
|
document_filename=row["filename"] if "filename" in keys else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -30,6 +42,7 @@ _SELECT_WITH_DOC = """
|
||||||
|
|
||||||
|
|
||||||
async def insert(job: AnalysisJob) -> None:
|
async def insert(job: AnalysisJob) -> None:
|
||||||
|
"""Persist a new analysis job record."""
|
||||||
async with get_connection() as db:
|
async with get_connection() as db:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""INSERT INTO analysis_jobs (id, document_id, status, created_at)
|
"""INSERT INTO analysis_jobs (id, document_id, status, created_at)
|
||||||
|
|
@ -40,6 +53,7 @@ async def insert(job: AnalysisJob) -> None:
|
||||||
|
|
||||||
|
|
||||||
async def find_all(*, limit: int = 200, offset: int = 0) -> list[AnalysisJob]:
|
async def find_all(*, limit: int = 200, offset: int = 0) -> list[AnalysisJob]:
|
||||||
|
"""Return analysis jobs with document info, newest first."""
|
||||||
async with get_connection() as db:
|
async with get_connection() as db:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
f"{_SELECT_WITH_DOC} ORDER BY aj.created_at DESC LIMIT ? OFFSET ?",
|
f"{_SELECT_WITH_DOC} ORDER BY aj.created_at DESC LIMIT ? OFFSET ?",
|
||||||
|
|
@ -50,31 +64,51 @@ async def find_all(*, limit: int = 200, offset: int = 0) -> list[AnalysisJob]:
|
||||||
|
|
||||||
|
|
||||||
async def find_by_id(job_id: str) -> AnalysisJob | None:
|
async def find_by_id(job_id: str) -> AnalysisJob | None:
|
||||||
|
"""Find an analysis job by ID (with document filename), or return None."""
|
||||||
async with get_connection() as db:
|
async with get_connection() as db:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(f"{_SELECT_WITH_DOC} WHERE aj.id = ?", (job_id,))
|
||||||
f"{_SELECT_WITH_DOC} WHERE aj.id = ?", (job_id,)
|
|
||||||
)
|
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
return _row_to_job(row) if row else None
|
return _row_to_job(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def update_status(job: AnalysisJob) -> None:
|
async def update_status(job: AnalysisJob) -> None:
|
||||||
|
"""Persist all mutable fields of an analysis job (status, results, timestamps)."""
|
||||||
async with get_connection() as db:
|
async with get_connection() as db:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""UPDATE analysis_jobs
|
"""UPDATE analysis_jobs
|
||||||
SET status = ?, content_markdown = ?, content_html = ?,
|
SET status = ?, content_markdown = ?, content_html = ?,
|
||||||
pages_json = ?, error_message = ?, started_at = ?, completed_at = ?
|
pages_json = ?, document_json = ?, chunks_json = ?,
|
||||||
|
error_message = ?, started_at = ?, completed_at = ?
|
||||||
WHERE id = ?""",
|
WHERE id = ?""",
|
||||||
(job.status.value, job.content_markdown, job.content_html,
|
(
|
||||||
job.pages_json, job.error_message,
|
job.status.value,
|
||||||
str(job.started_at) if job.started_at else None,
|
job.content_markdown,
|
||||||
str(job.completed_at) if job.completed_at else None,
|
job.content_html,
|
||||||
job.id),
|
job.pages_json,
|
||||||
|
job.document_json,
|
||||||
|
job.chunks_json,
|
||||||
|
job.error_message,
|
||||||
|
str(job.started_at) if job.started_at else None,
|
||||||
|
str(job.completed_at) if job.completed_at else None,
|
||||||
|
job.id,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_chunks(job_id: str, chunks_json: str) -> bool:
|
||||||
|
"""Update only the chunks_json column for a completed analysis."""
|
||||||
|
async with get_connection() as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"UPDATE analysis_jobs SET chunks_json = ? WHERE id = ?",
|
||||||
|
(chunks_json, job_id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
async def delete(job_id: str) -> bool:
|
async def delete(job_id: str) -> bool:
|
||||||
|
"""Delete an analysis job by ID. Returns True if a row was removed."""
|
||||||
async with get_connection() as db:
|
async with get_connection() as db:
|
||||||
cursor = await db.execute("DELETE FROM analysis_jobs WHERE id = ?", (job_id,))
|
cursor = await db.execute("DELETE FROM analysis_jobs WHERE id = ?", (job_id,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
@ -84,8 +118,6 @@ async def delete(job_id: str) -> bool:
|
||||||
async def delete_by_document(document_id: str) -> int:
|
async def delete_by_document(document_id: str) -> int:
|
||||||
"""Delete all analysis jobs for a given document. Returns count deleted."""
|
"""Delete all analysis jobs for a given document. Returns count deleted."""
|
||||||
async with get_connection() as db:
|
async with get_connection() as db:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute("DELETE FROM analysis_jobs WHERE document_id = ?", (document_id,))
|
||||||
"DELETE FROM analysis_jobs WHERE document_id = ?", (document_id,)
|
|
||||||
)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return cursor.rowcount
|
return cursor.rowcount
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,16 @@ from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
|
from infra.settings import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DB_PATH = os.environ.get("DB_PATH", "./data/docling_studio.db")
|
DB_PATH = settings.db_path
|
||||||
|
|
||||||
_SCHEMA = """
|
_SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS documents (
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
|
@ -30,6 +33,8 @@ CREATE TABLE IF NOT EXISTS analysis_jobs (
|
||||||
content_markdown TEXT,
|
content_markdown TEXT,
|
||||||
content_html TEXT,
|
content_html TEXT,
|
||||||
pages_json TEXT,
|
pages_json TEXT,
|
||||||
|
document_json TEXT,
|
||||||
|
chunks_json TEXT,
|
||||||
error_message TEXT,
|
error_message TEXT,
|
||||||
started_at TEXT,
|
started_at TEXT,
|
||||||
completed_at TEXT,
|
completed_at TEXT,
|
||||||
|
|
@ -42,11 +47,29 @@ CREATE INDEX IF NOT EXISTS idx_documents_created_at ON documents(created_at);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_MIGRATIONS = [
|
||||||
|
("document_json", "ALTER TABLE analysis_jobs ADD COLUMN document_json TEXT"),
|
||||||
|
("chunks_json", "ALTER TABLE analysis_jobs ADD COLUMN chunks_json TEXT"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_migrations(db: aiosqlite.Connection) -> None:
|
||||||
|
"""Add columns that may be missing in older databases."""
|
||||||
|
cursor = await db.execute("PRAGMA table_info(analysis_jobs)")
|
||||||
|
existing = {row[1] for row in await cursor.fetchall()}
|
||||||
|
for col_name, ddl in _MIGRATIONS:
|
||||||
|
if col_name not in existing:
|
||||||
|
await db.execute(ddl)
|
||||||
|
logger.info("Migration: added column %s to analysis_jobs", col_name)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def init_db() -> None:
|
async def init_db() -> None:
|
||||||
"""Create database file and tables if they don't exist."""
|
"""Create database file and tables if they don't exist."""
|
||||||
os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True)
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
await db.executescript(_SCHEMA)
|
await db.executescript(_SCHEMA)
|
||||||
|
await _run_migrations(db)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("Database initialized at %s", DB_PATH)
|
logger.info("Database initialized at %s", DB_PATH)
|
||||||
|
|
||||||
|
|
@ -60,7 +83,7 @@ async def get_db() -> aiosqlite.Connection:
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def get_connection():
|
async def get_connection() -> AsyncIterator[aiosqlite.Connection]:
|
||||||
"""Context manager that opens and auto-closes a database connection."""
|
"""Context manager that opens and auto-closes a database connection."""
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,18 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from domain.models import Document
|
from domain.models import Document
|
||||||
from persistence.database import get_connection
|
from persistence.database import get_connection
|
||||||
|
|
||||||
|
|
||||||
def _row_to_document(row) -> Document:
|
def _row_to_document(row) -> Document:
|
||||||
|
created = row["created_at"]
|
||||||
|
if isinstance(created, str):
|
||||||
|
created = datetime.fromisoformat(created)
|
||||||
|
if created.tzinfo is None:
|
||||||
|
created = created.replace(tzinfo=UTC)
|
||||||
return Document(
|
return Document(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
filename=row["filename"],
|
filename=row["filename"],
|
||||||
|
|
@ -14,22 +21,31 @@ def _row_to_document(row) -> Document:
|
||||||
file_size=row["file_size"],
|
file_size=row["file_size"],
|
||||||
page_count=row["page_count"],
|
page_count=row["page_count"],
|
||||||
storage_path=row["storage_path"],
|
storage_path=row["storage_path"],
|
||||||
created_at=row["created_at"],
|
created_at=created,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def insert(doc: Document) -> None:
|
async def insert(doc: Document) -> None:
|
||||||
|
"""Persist a new document record."""
|
||||||
async with get_connection() as db:
|
async with get_connection() as db:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""INSERT INTO documents (id, filename, content_type, file_size, page_count, storage_path, created_at)
|
"""INSERT INTO documents (id, filename, content_type, file_size, page_count, storage_path, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(doc.id, doc.filename, doc.content_type, doc.file_size,
|
(
|
||||||
doc.page_count, doc.storage_path, str(doc.created_at)),
|
doc.id,
|
||||||
|
doc.filename,
|
||||||
|
doc.content_type,
|
||||||
|
doc.file_size,
|
||||||
|
doc.page_count,
|
||||||
|
doc.storage_path,
|
||||||
|
str(doc.created_at),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def find_all(*, limit: int = 200, offset: int = 0) -> list[Document]:
|
async def find_all(*, limit: int = 200, offset: int = 0) -> list[Document]:
|
||||||
|
"""Return documents ordered by creation date (newest first)."""
|
||||||
async with get_connection() as db:
|
async with get_connection() as db:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT * FROM documents ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
"SELECT * FROM documents ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||||
|
|
@ -40,6 +56,7 @@ async def find_all(*, limit: int = 200, offset: int = 0) -> list[Document]:
|
||||||
|
|
||||||
|
|
||||||
async def find_by_id(doc_id: str) -> Document | None:
|
async def find_by_id(doc_id: str) -> Document | None:
|
||||||
|
"""Find a document by its ID, or return None."""
|
||||||
async with get_connection() as db:
|
async with get_connection() as db:
|
||||||
cursor = await db.execute("SELECT * FROM documents WHERE id = ?", (doc_id,))
|
cursor = await db.execute("SELECT * FROM documents WHERE id = ?", (doc_id,))
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
|
|
@ -47,6 +64,7 @@ async def find_by_id(doc_id: str) -> Document | None:
|
||||||
|
|
||||||
|
|
||||||
async def update_page_count(doc_id: str, page_count: int) -> None:
|
async def update_page_count(doc_id: str, page_count: int) -> None:
|
||||||
|
"""Update the page count after conversion has determined it."""
|
||||||
async with get_connection() as db:
|
async with get_connection() as db:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"UPDATE documents SET page_count = ? WHERE id = ?",
|
"UPDATE documents SET page_count = ? WHERE id = ?",
|
||||||
|
|
@ -56,6 +74,7 @@ async def update_page_count(doc_id: str, page_count: int) -> None:
|
||||||
|
|
||||||
|
|
||||||
async def delete(doc_id: str) -> bool:
|
async def delete(doc_id: str) -> bool:
|
||||||
|
"""Delete a document by ID. Returns True if a row was removed."""
|
||||||
async with get_connection() as db:
|
async with get_connection() as db:
|
||||||
cursor = await db.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
|
cursor = await db.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
|
||||||
2
document-parser/requirements-local.txt
Normal file
2
document-parser/requirements-local.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
-r requirements.txt
|
||||||
|
docling>=2.80.0,<3.0.0
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
docling>=2.80.0,<3.0.0
|
|
||||||
docling-core>=2.0.0,<3.0.0
|
docling-core>=2.0.0,<3.0.0
|
||||||
fastapi>=0.115.0,<1.0.0
|
fastapi>=0.115.0,<1.0.0
|
||||||
uvicorn[standard]>=0.32.0,<1.0.0
|
uvicorn[standard]>=0.32.0,<1.0.0
|
||||||
|
|
@ -6,3 +5,4 @@ python-multipart>=0.0.12
|
||||||
pdf2image>=1.17.0,<2.0.0
|
pdf2image>=1.17.0,<2.0.0
|
||||||
pillow>=10.0.0,<11.0.0
|
pillow>=10.0.0,<11.0.0
|
||||||
aiosqlite>=0.20.0,<1.0.0
|
aiosqlite>=0.20.0,<1.0.0
|
||||||
|
httpx>=0.27.0,<1.0.0
|
||||||
|
|
|
||||||
|
|
@ -1,108 +1,215 @@
|
||||||
"""Analysis service — async document parsing orchestration."""
|
"""Analysis service — async document parsing orchestration.
|
||||||
|
|
||||||
|
Uses an injected DocumentConverter (port) so the service is decoupled
|
||||||
|
from the conversion implementation (local Docling lib vs remote Docling Serve).
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import functools
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from domain.models import AnalysisJob
|
from domain.models import AnalysisJob, AnalysisStatus
|
||||||
from domain.parsing import ConversionOptions, ConversionResult, convert_document
|
from domain.value_objects import ChunkingOptions, ChunkResult, ConversionOptions, ConversionResult
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from domain.ports import DocumentChunker, DocumentConverter
|
||||||
from persistence import analysis_repo, document_repo
|
from persistence import analysis_repo, document_repo
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Maximum time (seconds) allowed for a single document conversion.
|
|
||||||
CONVERSION_TIMEOUT = int(__import__("os").environ.get("CONVERSION_TIMEOUT", "600"))
|
def _chunk_to_dict(c: ChunkResult) -> dict:
|
||||||
|
"""Serialize ChunkResult to a camelCase dict matching the frontend API contract."""
|
||||||
|
return {
|
||||||
|
"text": c.text,
|
||||||
|
"headings": c.headings,
|
||||||
|
"sourcePage": c.source_page,
|
||||||
|
"tokenCount": c.token_count,
|
||||||
|
"bboxes": [{"page": b.page, "bbox": b.bbox} for b in c.bboxes],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def create(document_id: str, *, pipeline_options: dict | None = None) -> AnalysisJob:
|
# Maximum number of concurrent analysis jobs to prevent resource exhaustion.
|
||||||
"""Create a new analysis job and launch background processing."""
|
_DEFAULT_MAX_CONCURRENT = 3
|
||||||
doc = await document_repo.find_by_id(document_id)
|
|
||||||
if not doc:
|
|
||||||
raise ValueError(f"Document not found: {document_id}")
|
|
||||||
|
|
||||||
job = AnalysisJob(document_id=document_id)
|
|
||||||
job.document_filename = doc.filename
|
|
||||||
await analysis_repo.insert(job)
|
|
||||||
|
|
||||||
# Fire background task with error logging callback
|
|
||||||
task = asyncio.create_task(
|
|
||||||
_run_analysis(job.id, doc.storage_path, doc.filename, pipeline_options)
|
|
||||||
)
|
|
||||||
task.add_done_callback(_on_task_done)
|
|
||||||
|
|
||||||
return job
|
|
||||||
|
|
||||||
|
|
||||||
def _on_task_done(task: asyncio.Task) -> None:
|
class AnalysisService:
|
||||||
"""Log unhandled exceptions from background analysis tasks."""
|
"""Orchestrates document analysis using an injected converter."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
converter: DocumentConverter,
|
||||||
|
chunker: DocumentChunker | None = None,
|
||||||
|
conversion_timeout: int = 600,
|
||||||
|
max_concurrent: int = _DEFAULT_MAX_CONCURRENT,
|
||||||
|
):
|
||||||
|
self._converter = converter
|
||||||
|
self._chunker = chunker
|
||||||
|
self._conversion_timeout = conversion_timeout
|
||||||
|
self._semaphore = asyncio.Semaphore(max_concurrent)
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
document_id: str,
|
||||||
|
*,
|
||||||
|
pipeline_options: dict | None = None,
|
||||||
|
chunking_options: dict | None = None,
|
||||||
|
) -> AnalysisJob:
|
||||||
|
"""Create a new analysis job and launch background processing."""
|
||||||
|
doc = await document_repo.find_by_id(document_id)
|
||||||
|
if not doc:
|
||||||
|
raise ValueError(f"Document not found: {document_id}")
|
||||||
|
|
||||||
|
job = AnalysisJob(document_id=document_id)
|
||||||
|
job.document_filename = doc.filename
|
||||||
|
await analysis_repo.insert(job)
|
||||||
|
|
||||||
|
task = asyncio.create_task(
|
||||||
|
self._run_analysis(
|
||||||
|
job.id,
|
||||||
|
doc.storage_path,
|
||||||
|
doc.filename,
|
||||||
|
pipeline_options,
|
||||||
|
chunking_options,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
task.add_done_callback(functools.partial(_on_task_done, job_id=job.id))
|
||||||
|
|
||||||
|
return job
|
||||||
|
|
||||||
|
async def find_all(self) -> list[AnalysisJob]:
|
||||||
|
"""Return all analysis jobs, newest first."""
|
||||||
|
return await analysis_repo.find_all()
|
||||||
|
|
||||||
|
async def find_by_id(self, job_id: str) -> AnalysisJob | None:
|
||||||
|
"""Find an analysis job by ID, or return None."""
|
||||||
|
return await analysis_repo.find_by_id(job_id)
|
||||||
|
|
||||||
|
async def delete(self, job_id: str) -> bool:
|
||||||
|
"""Delete an analysis job. Returns True if it existed."""
|
||||||
|
return await analysis_repo.delete(job_id)
|
||||||
|
|
||||||
|
async def rechunk(self, job_id: str, chunking_options: dict) -> list[ChunkResult]:
|
||||||
|
"""Re-chunk an existing completed analysis with new options."""
|
||||||
|
job = await analysis_repo.find_by_id(job_id)
|
||||||
|
if not job:
|
||||||
|
raise ValueError(f"Analysis not found: {job_id}")
|
||||||
|
if job.status != AnalysisStatus.COMPLETED:
|
||||||
|
raise ValueError(f"Analysis is not completed: {job_id}")
|
||||||
|
if not job.document_json:
|
||||||
|
raise ValueError(f"No document data available for re-chunking: {job_id}")
|
||||||
|
if not self._chunker:
|
||||||
|
raise ValueError("Chunking is not available")
|
||||||
|
|
||||||
|
options = ChunkingOptions(**chunking_options)
|
||||||
|
chunks = await self._chunker.chunk(job.document_json, options)
|
||||||
|
|
||||||
|
chunks_json = json.dumps([_chunk_to_dict(c) for c in chunks])
|
||||||
|
await analysis_repo.update_chunks(job_id, chunks_json)
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
async def _run_analysis(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
file_path: str,
|
||||||
|
filename: str,
|
||||||
|
pipeline_options: dict | None = None,
|
||||||
|
chunking_options: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Background task: run conversion and optionally chunk.
|
||||||
|
|
||||||
|
Acquires the concurrency semaphore to limit parallel conversions
|
||||||
|
and prevent CPU/memory exhaustion on modest hardware.
|
||||||
|
"""
|
||||||
|
async with self._semaphore:
|
||||||
|
await self._run_analysis_inner(
|
||||||
|
job_id, file_path, filename, pipeline_options, chunking_options
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run_analysis_inner(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
file_path: str,
|
||||||
|
filename: str,
|
||||||
|
pipeline_options: dict | None = None,
|
||||||
|
chunking_options: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Inner analysis logic — called under the concurrency semaphore."""
|
||||||
|
try:
|
||||||
|
job = await analysis_repo.find_by_id(job_id)
|
||||||
|
if not job:
|
||||||
|
logger.error("Analysis job %s not found", job_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
job.mark_running()
|
||||||
|
await analysis_repo.update_status(job)
|
||||||
|
logger.info("Analysis started: %s (file: %s)", job_id, filename)
|
||||||
|
|
||||||
|
options = ConversionOptions(**(pipeline_options or {}))
|
||||||
|
|
||||||
|
result: ConversionResult = await asyncio.wait_for(
|
||||||
|
self._converter.convert(file_path, options),
|
||||||
|
timeout=self._conversion_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
pages_json = json.dumps([asdict(p) for p in result.pages])
|
||||||
|
|
||||||
|
chunks_json = None
|
||||||
|
if chunking_options and self._chunker and result.document_json:
|
||||||
|
chunk_opts = ChunkingOptions(**chunking_options)
|
||||||
|
chunks = await self._chunker.chunk(result.document_json, chunk_opts)
|
||||||
|
chunks_json = json.dumps([_chunk_to_dict(c) for c in chunks])
|
||||||
|
logger.info("Chunking produced %d chunks for job %s", len(chunks), job_id)
|
||||||
|
|
||||||
|
job.mark_completed(
|
||||||
|
markdown=result.content_markdown,
|
||||||
|
html=result.content_html,
|
||||||
|
pages_json=pages_json,
|
||||||
|
document_json=result.document_json,
|
||||||
|
chunks_json=chunks_json,
|
||||||
|
)
|
||||||
|
await analysis_repo.update_status(job)
|
||||||
|
|
||||||
|
if result.page_count:
|
||||||
|
await document_repo.update_page_count(job.document_id, result.page_count)
|
||||||
|
|
||||||
|
logger.info("Analysis completed: %s (%d pages)", job_id, result.page_count)
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
|
logger.error("Analysis timed out after %ds: %s", self._conversion_timeout, job_id)
|
||||||
|
await _mark_failed(job_id, f"Conversion timed out after {self._conversion_timeout}s")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Analysis failed: %s", job_id)
|
||||||
|
await _mark_failed(job_id, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
_background_tasks: set[asyncio.Task] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _on_task_done(task: asyncio.Task, *, job_id: str) -> None:
|
||||||
|
"""Log unhandled exceptions from background analysis tasks and mark job as FAILED."""
|
||||||
if task.cancelled():
|
if task.cancelled():
|
||||||
logger.warning("Analysis task was cancelled")
|
logger.warning("Analysis task was cancelled: %s", job_id)
|
||||||
|
_schedule_mark_failed(job_id, "Task was cancelled")
|
||||||
return
|
return
|
||||||
exc = task.exception()
|
exc = task.exception()
|
||||||
if exc:
|
if exc:
|
||||||
logger.error("Unhandled exception in analysis task: %s", exc, exc_info=True)
|
logger.error("Unhandled exception in analysis task %s: %s", job_id, exc, exc_info=True)
|
||||||
|
_schedule_mark_failed(job_id, str(exc))
|
||||||
|
|
||||||
|
|
||||||
async def find_all() -> list[AnalysisJob]:
|
def _schedule_mark_failed(job_id: str, error: str) -> None:
|
||||||
return await analysis_repo.find_all()
|
"""Schedule _mark_failed as a tracked background task."""
|
||||||
|
t = asyncio.ensure_future(_mark_failed(job_id, error))
|
||||||
|
_background_tasks.add(t)
|
||||||
async def find_by_id(job_id: str) -> AnalysisJob | None:
|
t.add_done_callback(_background_tasks.discard)
|
||||||
return await analysis_repo.find_by_id(job_id)
|
|
||||||
|
|
||||||
|
|
||||||
async def delete(job_id: str) -> bool:
|
|
||||||
return await analysis_repo.delete(job_id)
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_analysis(
|
|
||||||
job_id: str, file_path: str, filename: str, pipeline_options: dict | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Background task: run Docling conversion and update job status."""
|
|
||||||
try:
|
|
||||||
job = await analysis_repo.find_by_id(job_id)
|
|
||||||
if not job:
|
|
||||||
logger.error("Analysis job %s not found", job_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
job.mark_running()
|
|
||||||
await analysis_repo.update_status(job)
|
|
||||||
logger.info("Analysis started: %s (file: %s)", job_id, filename)
|
|
||||||
|
|
||||||
# Build conversion options from pipeline dict
|
|
||||||
options = ConversionOptions(**(pipeline_options or {}))
|
|
||||||
|
|
||||||
# Run blocking Docling conversion in a thread with timeout
|
|
||||||
result: ConversionResult = await asyncio.wait_for(
|
|
||||||
asyncio.to_thread(convert_document, file_path, options),
|
|
||||||
timeout=CONVERSION_TIMEOUT,
|
|
||||||
)
|
|
||||||
|
|
||||||
pages_json = json.dumps([asdict(p) for p in result.pages])
|
|
||||||
|
|
||||||
job.mark_completed(
|
|
||||||
markdown=result.content_markdown,
|
|
||||||
html=result.content_html,
|
|
||||||
pages_json=pages_json,
|
|
||||||
)
|
|
||||||
await analysis_repo.update_status(job)
|
|
||||||
|
|
||||||
# Update document page count if available
|
|
||||||
if result.page_count:
|
|
||||||
await document_repo.update_page_count(job.document_id, result.page_count)
|
|
||||||
|
|
||||||
logger.info("Analysis completed: %s (%d pages)", job_id, result.page_count)
|
|
||||||
|
|
||||||
except TimeoutError:
|
|
||||||
logger.error("Analysis timed out after %ds: %s", CONVERSION_TIMEOUT, job_id)
|
|
||||||
await _mark_failed(job_id, f"Conversion timed out after {CONVERSION_TIMEOUT}s")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Analysis failed: %s", job_id)
|
|
||||||
await _mark_failed(job_id, str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def _mark_failed(job_id: str, error: str) -> None:
|
async def _mark_failed(job_id: str, error: str) -> None:
|
||||||
|
|
@ -112,5 +219,7 @@ async def _mark_failed(job_id: str, error: str) -> None:
|
||||||
if job:
|
if job:
|
||||||
job.mark_failed(error)
|
job.mark_failed(error)
|
||||||
await analysis_repo.update_status(job)
|
await analysis_repo.update_status(job)
|
||||||
|
except OSError:
|
||||||
|
logger.exception("Database I/O error marking job %s as failed", job_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Could not mark job %s as failed", job_id)
|
logger.exception("Unexpected error marking job %s as failed", job_id)
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,12 @@ import uuid
|
||||||
from pdf2image import convert_from_bytes, pdfinfo_from_bytes
|
from pdf2image import convert_from_bytes, pdfinfo_from_bytes
|
||||||
|
|
||||||
from domain.models import Document
|
from domain.models import Document
|
||||||
|
from infra.settings import settings
|
||||||
from persistence import analysis_repo, document_repo
|
from persistence import analysis_repo, document_repo
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "./uploads")
|
UPLOAD_DIR = settings.upload_dir
|
||||||
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -22,8 +23,14 @@ MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||||
_PDF_MAGIC = b"%PDF"
|
_PDF_MAGIC = b"%PDF"
|
||||||
|
|
||||||
|
|
||||||
|
_UPLOAD_CHUNK_SIZE = 64 * 1024 # 64 KB chunks for streaming writes
|
||||||
|
|
||||||
|
|
||||||
async def upload(filename: str, content_type: str, file_content: bytes) -> Document:
|
async def upload(filename: str, content_type: str, file_content: bytes) -> Document:
|
||||||
"""Save uploaded file to disk and persist metadata."""
|
"""Save uploaded file to disk and persist metadata.
|
||||||
|
|
||||||
|
Writes the file in fixed-size chunks to keep peak memory usage low.
|
||||||
|
"""
|
||||||
if len(file_content) > MAX_FILE_SIZE:
|
if len(file_content) > MAX_FILE_SIZE:
|
||||||
raise ValueError("File too large (max 50 MB)")
|
raise ValueError("File too large (max 50 MB)")
|
||||||
|
|
||||||
|
|
@ -32,12 +39,14 @@ async def upload(filename: str, content_type: str, file_content: bytes) -> Docum
|
||||||
|
|
||||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||||
|
|
||||||
ext = os.path.splitext(filename)[1] or ".pdf"
|
ext = ".pdf" # Content already validated as PDF
|
||||||
safe_name = f"{uuid.uuid4()}{ext}"
|
safe_name = f"{uuid.uuid4()}{ext}"
|
||||||
file_path = os.path.join(UPLOAD_DIR, safe_name)
|
file_path = os.path.join(UPLOAD_DIR, safe_name)
|
||||||
|
|
||||||
|
# Write in chunks to avoid doubling memory usage for large files
|
||||||
with open(file_path, "wb") as f:
|
with open(file_path, "wb") as f:
|
||||||
f.write(file_content)
|
for offset in range(0, len(file_content), _UPLOAD_CHUNK_SIZE):
|
||||||
|
f.write(file_content[offset : offset + _UPLOAD_CHUNK_SIZE])
|
||||||
|
|
||||||
# Count PDF pages
|
# Count PDF pages
|
||||||
page_count = _count_pages(file_content)
|
page_count = _count_pages(file_content)
|
||||||
|
|
@ -54,10 +63,12 @@ async def upload(filename: str, content_type: str, file_content: bytes) -> Docum
|
||||||
|
|
||||||
|
|
||||||
async def find_all() -> list[Document]:
|
async def find_all() -> list[Document]:
|
||||||
|
"""Return all documents, newest first."""
|
||||||
return await document_repo.find_all()
|
return await document_repo.find_all()
|
||||||
|
|
||||||
|
|
||||||
async def find_by_id(doc_id: str) -> Document | None:
|
async def find_by_id(doc_id: str) -> Document | None:
|
||||||
|
"""Find a document by its ID, or return None."""
|
||||||
return await document_repo.find_by_id(doc_id)
|
return await document_repo.find_by_id(doc_id)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -70,12 +81,20 @@ async def delete(doc_id: str) -> bool:
|
||||||
# Delete associated analyses first (cascade)
|
# Delete associated analyses first (cascade)
|
||||||
await analysis_repo.delete_by_document(doc_id)
|
await analysis_repo.delete_by_document(doc_id)
|
||||||
|
|
||||||
# Delete file from disk
|
# Delete file from disk (only if inside UPLOAD_DIR)
|
||||||
try:
|
try:
|
||||||
if os.path.exists(doc.storage_path):
|
real_path = os.path.realpath(doc.storage_path)
|
||||||
os.unlink(doc.storage_path)
|
real_upload_dir = os.path.realpath(UPLOAD_DIR)
|
||||||
|
if real_path.startswith(real_upload_dir + os.sep) and os.path.exists(real_path):
|
||||||
|
os.unlink(real_path)
|
||||||
|
elif os.path.exists(doc.storage_path):
|
||||||
|
logger.warning("Refused to delete file outside upload dir: %s", doc.storage_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.info("File already removed: %s", doc.storage_path)
|
||||||
|
except PermissionError:
|
||||||
|
logger.error("Permission denied deleting file: %s", doc.storage_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
logger.warning("Could not delete file: %s", doc.storage_path)
|
logger.warning("Could not delete file: %s", doc.storage_path, exc_info=True)
|
||||||
|
|
||||||
return await document_repo.delete(doc_id)
|
return await document_repo.delete(doc_id)
|
||||||
|
|
||||||
|
|
@ -96,6 +115,9 @@ def _count_pages(file_content: bytes) -> int | None:
|
||||||
try:
|
try:
|
||||||
info = pdfinfo_from_bytes(file_content)
|
info = pdfinfo_from_bytes(file_content)
|
||||||
return info.get("Pages")
|
return info.get("Pages")
|
||||||
except Exception:
|
except (FileNotFoundError, OSError) as exc:
|
||||||
logger.warning("Could not count pages", exc_info=True)
|
logger.warning("Could not count pages: %s", exc)
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Unexpected error counting pages", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
114
document-parser/tests/test_analysis_service.py
Normal file
114
document-parser/tests/test_analysis_service.py
Normal 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
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Tests for FastAPI API endpoints using TestClient."""
|
"""Tests for FastAPI API endpoints using TestClient."""
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
@ -14,11 +14,24 @@ def client():
|
||||||
return TestClient(app, raise_server_exceptions=False)
|
return TestClient(app, raise_server_exceptions=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_analysis_service(client):
|
||||||
|
"""Inject a mock AnalysisService into app.state for the duration of the test."""
|
||||||
|
mock_svc = MagicMock()
|
||||||
|
original = getattr(app.state, "analysis_service", None)
|
||||||
|
app.state.analysis_service = mock_svc
|
||||||
|
yield mock_svc
|
||||||
|
app.state.analysis_service = original
|
||||||
|
|
||||||
|
|
||||||
class TestHealthEndpoint:
|
class TestHealthEndpoint:
|
||||||
def test_health(self, client):
|
def test_health(self, client):
|
||||||
resp = client.get("/health")
|
resp = client.get("/api/health")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json() == {"status": "ok"}
|
data = resp.json()
|
||||||
|
assert data["status"] in ("ok", "degraded")
|
||||||
|
assert "engine" in data
|
||||||
|
assert "database" in data
|
||||||
|
|
||||||
|
|
||||||
class TestDocumentEndpoints:
|
class TestDocumentEndpoints:
|
||||||
|
|
@ -41,8 +54,12 @@ class TestDocumentEndpoints:
|
||||||
@patch("services.document_service.find_by_id", new_callable=AsyncMock)
|
@patch("services.document_service.find_by_id", new_callable=AsyncMock)
|
||||||
def test_get_document(self, mock_find, client):
|
def test_get_document(self, mock_find, client):
|
||||||
mock_find.return_value = Document(
|
mock_find.return_value = Document(
|
||||||
id="d1", filename="test.pdf", content_type="application/pdf",
|
id="d1",
|
||||||
file_size=2048, page_count=3, storage_path="/tmp/test",
|
filename="test.pdf",
|
||||||
|
content_type="application/pdf",
|
||||||
|
file_size=2048,
|
||||||
|
page_count=3,
|
||||||
|
storage_path="/tmp/test",
|
||||||
)
|
)
|
||||||
|
|
||||||
resp = client.get("/api/documents/d1")
|
resp = client.get("/api/documents/d1")
|
||||||
|
|
@ -62,8 +79,10 @@ class TestDocumentEndpoints:
|
||||||
@patch("services.document_service.upload", new_callable=AsyncMock)
|
@patch("services.document_service.upload", new_callable=AsyncMock)
|
||||||
def test_upload_document(self, mock_upload, client):
|
def test_upload_document(self, mock_upload, client):
|
||||||
mock_upload.return_value = Document(
|
mock_upload.return_value = Document(
|
||||||
id="new-1", filename="uploaded.pdf",
|
id="new-1",
|
||||||
content_type="application/pdf", file_size=512,
|
filename="uploaded.pdf",
|
||||||
|
content_type="application/pdf",
|
||||||
|
file_size=512,
|
||||||
storage_path="/tmp/uploaded",
|
storage_path="/tmp/uploaded",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -84,7 +103,20 @@ class TestDocumentEndpoints:
|
||||||
"/api/documents/upload",
|
"/api/documents/upload",
|
||||||
files={"file": ("big.pdf", b"x", "application/pdf")},
|
files={"file": ("big.pdf", b"x", "application/pdf")},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 413
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
@patch("services.document_service.find_by_id", new_callable=AsyncMock)
|
||||||
|
def test_preview_page_out_of_range(self, mock_find, client):
|
||||||
|
mock_find.return_value = Document(
|
||||||
|
id="d1",
|
||||||
|
filename="test.pdf",
|
||||||
|
page_count=3,
|
||||||
|
storage_path="/tmp/test.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/api/documents/d1/preview?page=10")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "out of range" in resp.json()["detail"]
|
||||||
|
|
||||||
@patch("services.document_service.delete", new_callable=AsyncMock)
|
@patch("services.document_service.delete", new_callable=AsyncMock)
|
||||||
def test_delete_document(self, mock_delete, client):
|
def test_delete_document(self, mock_delete, client):
|
||||||
|
|
@ -102,11 +134,12 @@ class TestDocumentEndpoints:
|
||||||
|
|
||||||
|
|
||||||
class TestAnalysisEndpoints:
|
class TestAnalysisEndpoints:
|
||||||
@patch("services.analysis_service.find_all", new_callable=AsyncMock)
|
def test_list_analyses(self, client, mock_analysis_service):
|
||||||
def test_list_analyses(self, mock_find_all, client):
|
mock_analysis_service.find_all = AsyncMock(
|
||||||
mock_find_all.return_value = [
|
return_value=[
|
||||||
AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"),
|
AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"),
|
||||||
]
|
]
|
||||||
|
)
|
||||||
|
|
||||||
resp = client.get("/api/analyses")
|
resp = client.get("/api/analyses")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
@ -117,11 +150,10 @@ class TestAnalysisEndpoints:
|
||||||
assert data[0]["documentFilename"] == "test.pdf"
|
assert data[0]["documentFilename"] == "test.pdf"
|
||||||
assert data[0]["status"] == "PENDING"
|
assert data[0]["status"] == "PENDING"
|
||||||
|
|
||||||
@patch("services.analysis_service.find_by_id", new_callable=AsyncMock)
|
def test_get_analysis(self, client, mock_analysis_service):
|
||||||
def test_get_analysis(self, mock_find, client):
|
|
||||||
job = AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
|
job = AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
|
||||||
job.mark_running()
|
job.mark_running()
|
||||||
mock_find.return_value = job
|
mock_analysis_service.find_by_id = AsyncMock(return_value=job)
|
||||||
|
|
||||||
resp = client.get("/api/analyses/j1")
|
resp = client.get("/api/analyses/j1")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
@ -129,17 +161,19 @@ class TestAnalysisEndpoints:
|
||||||
assert data["status"] == "RUNNING"
|
assert data["status"] == "RUNNING"
|
||||||
assert data["startedAt"] is not None
|
assert data["startedAt"] is not None
|
||||||
|
|
||||||
@patch("services.analysis_service.find_by_id", new_callable=AsyncMock)
|
def test_get_analysis_not_found(self, client, mock_analysis_service):
|
||||||
def test_get_analysis_not_found(self, mock_find, client):
|
mock_analysis_service.find_by_id = AsyncMock(return_value=None)
|
||||||
mock_find.return_value = None
|
|
||||||
|
|
||||||
resp = client.get("/api/analyses/missing")
|
resp = client.get("/api/analyses/missing")
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
def test_create_analysis(self, client, mock_analysis_service):
|
||||||
def test_create_analysis(self, mock_create, client):
|
mock_analysis_service.create = AsyncMock(
|
||||||
mock_create.return_value = AnalysisJob(
|
return_value=AnalysisJob(
|
||||||
id="j1", document_id="d1", document_filename="test.pdf",
|
id="j1",
|
||||||
|
document_id="d1",
|
||||||
|
document_filename="test.pdf",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
resp = client.post("/api/analyses", json={"documentId": "d1"})
|
resp = client.post("/api/analyses", json={"documentId": "d1"})
|
||||||
|
|
@ -147,34 +181,44 @@ class TestAnalysisEndpoints:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["id"] == "j1"
|
assert data["id"] == "j1"
|
||||||
assert data["documentId"] == "d1"
|
assert data["documentId"] == "d1"
|
||||||
mock_create.assert_called_once_with("d1", pipeline_options=None)
|
mock_analysis_service.create.assert_called_once_with(
|
||||||
|
"d1",
|
||||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
pipeline_options=None,
|
||||||
def test_create_analysis_with_pipeline_options(self, mock_create, client):
|
chunking_options=None,
|
||||||
mock_create.return_value = AnalysisJob(
|
|
||||||
id="j2", document_id="d1", document_filename="test.pdf",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
resp = client.post("/api/analyses", json={
|
def test_create_analysis_with_pipeline_options(self, client, mock_analysis_service):
|
||||||
"documentId": "d1",
|
mock_analysis_service.create = AsyncMock(
|
||||||
"pipelineOptions": {
|
return_value=AnalysisJob(
|
||||||
"do_ocr": False,
|
id="j2",
|
||||||
"do_table_structure": True,
|
document_id="d1",
|
||||||
"table_mode": "fast",
|
document_filename="test.pdf",
|
||||||
"do_code_enrichment": True,
|
)
|
||||||
"do_formula_enrichment": False,
|
)
|
||||||
"do_picture_classification": False,
|
|
||||||
"do_picture_description": False,
|
resp = client.post(
|
||||||
"generate_picture_images": True,
|
"/api/analyses",
|
||||||
"generate_page_images": False,
|
json={
|
||||||
"images_scale": 2.0,
|
"documentId": "d1",
|
||||||
}
|
"pipelineOptions": {
|
||||||
})
|
"do_ocr": False,
|
||||||
|
"do_table_structure": True,
|
||||||
|
"table_mode": "fast",
|
||||||
|
"do_code_enrichment": True,
|
||||||
|
"do_formula_enrichment": False,
|
||||||
|
"do_picture_classification": False,
|
||||||
|
"do_picture_description": False,
|
||||||
|
"generate_picture_images": True,
|
||||||
|
"generate_page_images": False,
|
||||||
|
"images_scale": 2.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["id"] == "j2"
|
assert data["id"] == "j2"
|
||||||
|
|
||||||
call_kwargs = mock_create.call_args
|
call_kwargs = mock_analysis_service.create.call_args
|
||||||
opts = call_kwargs.kwargs["pipeline_options"]
|
opts = call_kwargs.kwargs["pipeline_options"]
|
||||||
assert opts["do_ocr"] is False
|
assert opts["do_ocr"] is False
|
||||||
assert opts["table_mode"] == "fast"
|
assert opts["table_mode"] == "fast"
|
||||||
|
|
@ -182,51 +226,50 @@ class TestAnalysisEndpoints:
|
||||||
assert opts["generate_picture_images"] is True
|
assert opts["generate_picture_images"] is True
|
||||||
assert opts["images_scale"] == 2.0
|
assert opts["images_scale"] == 2.0
|
||||||
|
|
||||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
def test_create_analysis_with_partial_pipeline_options(self, client, mock_analysis_service):
|
||||||
def test_create_analysis_with_partial_pipeline_options(self, mock_create, client):
|
|
||||||
"""Pipeline options should use defaults for unspecified fields."""
|
"""Pipeline options should use defaults for unspecified fields."""
|
||||||
mock_create.return_value = AnalysisJob(
|
mock_analysis_service.create = AsyncMock(
|
||||||
id="j3", document_id="d1", document_filename="test.pdf",
|
return_value=AnalysisJob(
|
||||||
|
id="j3",
|
||||||
|
document_id="d1",
|
||||||
|
document_filename="test.pdf",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
resp = client.post("/api/analyses", json={
|
resp = client.post(
|
||||||
"documentId": "d1",
|
"/api/analyses", json={"documentId": "d1", "pipelineOptions": {"do_ocr": False}}
|
||||||
"pipelineOptions": {"do_ocr": False}
|
)
|
||||||
})
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
opts = mock_create.call_args.kwargs["pipeline_options"]
|
opts = mock_analysis_service.create.call_args.kwargs["pipeline_options"]
|
||||||
assert opts["do_ocr"] is False
|
assert opts["do_ocr"] is False
|
||||||
# Defaults
|
# Defaults
|
||||||
assert opts["do_table_structure"] is True
|
assert opts["do_table_structure"] is True
|
||||||
assert opts["table_mode"] == "accurate"
|
assert opts["table_mode"] == "accurate"
|
||||||
assert opts["do_code_enrichment"] is False
|
assert opts["do_code_enrichment"] is False
|
||||||
|
|
||||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
def test_create_analysis_document_not_found(self, client, mock_analysis_service):
|
||||||
def test_create_analysis_document_not_found(self, mock_create, client):
|
mock_analysis_service.create = AsyncMock(side_effect=ValueError("Document not found: d99"))
|
||||||
mock_create.side_effect = ValueError("Document not found: d99")
|
|
||||||
|
|
||||||
resp = client.post("/api/analyses", json={"documentId": "d99"})
|
resp = client.post("/api/analyses", json={"documentId": "d99"})
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
def test_create_analysis_empty_document_id(self, client):
|
def test_create_analysis_empty_document_id(self, client, mock_analysis_service):
|
||||||
resp = client.post("/api/analyses", json={"documentId": ""})
|
resp = client.post("/api/analyses", json={"documentId": ""})
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
def test_create_analysis_whitespace_document_id(self, client):
|
def test_create_analysis_whitespace_document_id(self, client, mock_analysis_service):
|
||||||
resp = client.post("/api/analyses", json={"documentId": " "})
|
resp = client.post("/api/analyses", json={"documentId": " "})
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
@patch("services.analysis_service.delete", new_callable=AsyncMock)
|
def test_delete_analysis(self, client, mock_analysis_service):
|
||||||
def test_delete_analysis(self, mock_delete, client):
|
mock_analysis_service.delete = AsyncMock(return_value=True)
|
||||||
mock_delete.return_value = True
|
|
||||||
|
|
||||||
resp = client.delete("/api/analyses/j1")
|
resp = client.delete("/api/analyses/j1")
|
||||||
assert resp.status_code == 204
|
assert resp.status_code == 204
|
||||||
|
|
||||||
@patch("services.analysis_service.delete", new_callable=AsyncMock)
|
def test_delete_analysis_not_found(self, client, mock_analysis_service):
|
||||||
def test_delete_analysis_not_found(self, mock_delete, client):
|
mock_analysis_service.delete = AsyncMock(return_value=False)
|
||||||
mock_delete.return_value = False
|
|
||||||
|
|
||||||
resp = client.delete("/api/analyses/missing")
|
resp = client.delete("/api/analyses/missing")
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,13 @@ misaligned overlays in the UI.
|
||||||
import pytest
|
import pytest
|
||||||
from docling_core.types.doc.base import BoundingBox, CoordOrigin
|
from docling_core.types.doc.base import BoundingBox, CoordOrigin
|
||||||
|
|
||||||
from domain.bbox import EMPTY_BBOX, to_topleft_list
|
from infra.bbox import EMPTY_BBOX, to_topleft_list
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Standard conversions
|
# Standard conversions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestToTopleftListStandard:
|
class TestToTopleftListStandard:
|
||||||
"""Normal bbox conversions (happy path)."""
|
"""Normal bbox conversions (happy path)."""
|
||||||
|
|
||||||
|
|
@ -29,10 +30,10 @@ class TestToTopleftListStandard:
|
||||||
result = to_topleft_list(bbox, page_height=792.0)
|
result = to_topleft_list(bbox, page_height=792.0)
|
||||||
|
|
||||||
# After conversion: new_t = 792 - 700 = 92, new_b = 792 - 600 = 192
|
# After conversion: new_t = 792 - 700 = 92, new_b = 792 - 600 = 192
|
||||||
assert result[0] == 50 # l unchanged
|
assert result[0] == 50 # l unchanged
|
||||||
assert result[1] == pytest.approx(92.0) # t = page_height - old_t
|
assert result[1] == pytest.approx(92.0) # t = page_height - old_t
|
||||||
assert result[2] == 200 # r unchanged
|
assert result[2] == 200 # r unchanged
|
||||||
assert result[3] == pytest.approx(192.0) # b = page_height - old_b
|
assert result[3] == pytest.approx(192.0) # b = page_height - old_b
|
||||||
|
|
||||||
def test_result_has_positive_dimensions(self):
|
def test_result_has_positive_dimensions(self):
|
||||||
"""Converted bbox should always have b > t (positive height)."""
|
"""Converted bbox should always have b > t (positive height)."""
|
||||||
|
|
@ -60,6 +61,7 @@ class TestToTopleftListStandard:
|
||||||
# Page format variations
|
# Page format variations
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestPageFormats:
|
class TestPageFormats:
|
||||||
"""Verify correct conversion across different page sizes."""
|
"""Verify correct conversion across different page sizes."""
|
||||||
|
|
||||||
|
|
@ -105,6 +107,7 @@ class TestPageFormats:
|
||||||
# Degenerate / edge-case bboxes
|
# Degenerate / edge-case bboxes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestDegenerateBboxes:
|
class TestDegenerateBboxes:
|
||||||
"""Bboxes that are invalid or degenerate should return EMPTY_BBOX."""
|
"""Bboxes that are invalid or degenerate should return EMPTY_BBOX."""
|
||||||
|
|
||||||
|
|
@ -151,6 +154,7 @@ class TestDegenerateBboxes:
|
||||||
# Precision and boundary values
|
# Precision and boundary values
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestPrecision:
|
class TestPrecision:
|
||||||
"""Floating-point precision and edge values."""
|
"""Floating-point precision and edge values."""
|
||||||
|
|
||||||
|
|
|
||||||
359
document-parser/tests/test_chunking.py
Normal file
359
document-parser/tests/test_chunking.py
Normal 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
|
||||||
137
document-parser/tests/test_document_service.py
Normal file
137
document-parser/tests/test_document_service.py
Normal 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
|
||||||
|
|
@ -1,26 +1,37 @@
|
||||||
"""Tests for pipeline options — build_converter, convert_document routing, service forwarding."""
|
"""Tests for pipeline options — build_converter, convert_document routing, service forwarding.
|
||||||
|
|
||||||
|
Requires the ``docling`` library (heavy, includes torch). Tests are skipped
|
||||||
|
automatically when docling is not installed (e.g. in lightweight CI environments
|
||||||
|
that only install docling-core).
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from docling.datamodel.base_models import InputFormat
|
|
||||||
from docling.datamodel.pipeline_options import (
|
docling = pytest.importorskip("docling", reason="docling library not installed")
|
||||||
|
|
||||||
|
from docling.datamodel.base_models import InputFormat # noqa: E402
|
||||||
|
from docling.datamodel.pipeline_options import ( # noqa: E402
|
||||||
PdfPipelineOptions,
|
PdfPipelineOptions,
|
||||||
TableFormerMode,
|
TableFormerMode,
|
||||||
)
|
)
|
||||||
|
|
||||||
from domain.parsing import (
|
from domain.value_objects import ConversionOptions # noqa: E402
|
||||||
ConversionOptions,
|
from infra.local_converter import ( # noqa: E402
|
||||||
build_converter,
|
_build_docling_converter as build_converter,
|
||||||
convert_document,
|
)
|
||||||
|
from infra.local_converter import ( # noqa: E402
|
||||||
|
_convert_sync as convert_document,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# build_converter — verifies Docling pipeline options are wired correctly
|
# build_converter — verifies Docling pipeline options are wired correctly
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestBuildConverter:
|
class TestBuildConverter:
|
||||||
"""Verify that build_converter produces a DocumentConverter with the right PdfPipelineOptions."""
|
"""Verify that build_converter produces a DocumentConverter with the right PdfPipelineOptions."""
|
||||||
|
|
||||||
|
|
@ -30,7 +41,7 @@ class TestBuildConverter:
|
||||||
return fmt_opt.pipeline_options
|
return fmt_opt.pipeline_options
|
||||||
|
|
||||||
def test_defaults(self):
|
def test_defaults(self):
|
||||||
conv = build_converter()
|
conv = build_converter(ConversionOptions())
|
||||||
opts = self._get_pipeline_options(conv)
|
opts = self._get_pipeline_options(conv)
|
||||||
assert opts.do_ocr is True
|
assert opts.do_ocr is True
|
||||||
assert opts.do_table_structure is True
|
assert opts.do_table_structure is True
|
||||||
|
|
@ -99,18 +110,20 @@ class TestBuildConverter:
|
||||||
assert opts.images_scale == 2.0
|
assert opts.images_scale == 2.0
|
||||||
|
|
||||||
def test_all_options_combined(self):
|
def test_all_options_combined(self):
|
||||||
conv = build_converter(ConversionOptions(
|
conv = build_converter(
|
||||||
do_ocr=False,
|
ConversionOptions(
|
||||||
do_table_structure=True,
|
do_ocr=False,
|
||||||
table_mode="fast",
|
do_table_structure=True,
|
||||||
do_code_enrichment=True,
|
table_mode="fast",
|
||||||
do_formula_enrichment=True,
|
do_code_enrichment=True,
|
||||||
do_picture_classification=True,
|
do_formula_enrichment=True,
|
||||||
do_picture_description=True,
|
do_picture_classification=True,
|
||||||
generate_picture_images=True,
|
do_picture_description=True,
|
||||||
generate_page_images=True,
|
generate_picture_images=True,
|
||||||
images_scale=1.5,
|
generate_page_images=True,
|
||||||
))
|
images_scale=1.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
opts = self._get_pipeline_options(conv)
|
opts = self._get_pipeline_options(conv)
|
||||||
assert opts.do_ocr is False
|
assert opts.do_ocr is False
|
||||||
assert opts.do_table_structure is True
|
assert opts.do_table_structure is True
|
||||||
|
|
@ -128,11 +141,12 @@ class TestBuildConverter:
|
||||||
# convert_document — default vs custom converter routing
|
# convert_document — default vs custom converter routing
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestConvertDocumentRouting:
|
class TestConvertDocumentRouting:
|
||||||
"""Verify convert_document uses default converter for default opts, custom otherwise."""
|
"""Verify convert_document uses default converter for default opts, custom otherwise."""
|
||||||
|
|
||||||
@patch("domain.parsing.get_default_converter")
|
@patch("infra.local_converter._get_default_converter")
|
||||||
@patch("domain.parsing.build_converter")
|
@patch("infra.local_converter._build_docling_converter")
|
||||||
def test_uses_default_converter_with_all_defaults(self, mock_build, mock_get_default):
|
def test_uses_default_converter_with_all_defaults(self, mock_build, mock_get_default):
|
||||||
mock_conv = MagicMock()
|
mock_conv = MagicMock()
|
||||||
mock_result = MagicMock()
|
mock_result = MagicMock()
|
||||||
|
|
@ -140,16 +154,17 @@ class TestConvertDocumentRouting:
|
||||||
mock_result.document.iterate_items.return_value = []
|
mock_result.document.iterate_items.return_value = []
|
||||||
mock_result.document.export_to_markdown.return_value = ""
|
mock_result.document.export_to_markdown.return_value = ""
|
||||||
mock_result.document.export_to_html.return_value = ""
|
mock_result.document.export_to_html.return_value = ""
|
||||||
|
mock_result.document.export_to_dict.return_value = {}
|
||||||
mock_conv.convert.return_value = mock_result
|
mock_conv.convert.return_value = mock_result
|
||||||
mock_get_default.return_value = mock_conv
|
mock_get_default.return_value = mock_conv
|
||||||
|
|
||||||
convert_document("/tmp/test.pdf")
|
convert_document("/tmp/test.pdf", ConversionOptions())
|
||||||
|
|
||||||
mock_get_default.assert_called_once()
|
mock_get_default.assert_called_once()
|
||||||
mock_build.assert_not_called()
|
mock_build.assert_not_called()
|
||||||
|
|
||||||
@patch("domain.parsing.get_default_converter")
|
@patch("infra.local_converter._get_default_converter")
|
||||||
@patch("domain.parsing.build_converter")
|
@patch("infra.local_converter._build_docling_converter")
|
||||||
def test_uses_custom_converter_when_ocr_disabled(self, mock_build, mock_get_default):
|
def test_uses_custom_converter_when_ocr_disabled(self, mock_build, mock_get_default):
|
||||||
mock_conv = MagicMock()
|
mock_conv = MagicMock()
|
||||||
mock_result = MagicMock()
|
mock_result = MagicMock()
|
||||||
|
|
@ -157,6 +172,7 @@ class TestConvertDocumentRouting:
|
||||||
mock_result.document.iterate_items.return_value = []
|
mock_result.document.iterate_items.return_value = []
|
||||||
mock_result.document.export_to_markdown.return_value = ""
|
mock_result.document.export_to_markdown.return_value = ""
|
||||||
mock_result.document.export_to_html.return_value = ""
|
mock_result.document.export_to_html.return_value = ""
|
||||||
|
mock_result.document.export_to_dict.return_value = {}
|
||||||
mock_conv.convert.return_value = mock_result
|
mock_conv.convert.return_value = mock_result
|
||||||
mock_build.return_value = mock_conv
|
mock_build.return_value = mock_conv
|
||||||
|
|
||||||
|
|
@ -165,8 +181,8 @@ class TestConvertDocumentRouting:
|
||||||
mock_build.assert_called_once()
|
mock_build.assert_called_once()
|
||||||
mock_get_default.assert_not_called()
|
mock_get_default.assert_not_called()
|
||||||
|
|
||||||
@patch("domain.parsing.get_default_converter")
|
@patch("infra.local_converter._get_default_converter")
|
||||||
@patch("domain.parsing.build_converter")
|
@patch("infra.local_converter._build_docling_converter")
|
||||||
def test_uses_custom_converter_when_table_mode_fast(self, mock_build, mock_get_default):
|
def test_uses_custom_converter_when_table_mode_fast(self, mock_build, mock_get_default):
|
||||||
mock_conv = MagicMock()
|
mock_conv = MagicMock()
|
||||||
mock_result = MagicMock()
|
mock_result = MagicMock()
|
||||||
|
|
@ -174,6 +190,7 @@ class TestConvertDocumentRouting:
|
||||||
mock_result.document.iterate_items.return_value = []
|
mock_result.document.iterate_items.return_value = []
|
||||||
mock_result.document.export_to_markdown.return_value = ""
|
mock_result.document.export_to_markdown.return_value = ""
|
||||||
mock_result.document.export_to_html.return_value = ""
|
mock_result.document.export_to_html.return_value = ""
|
||||||
|
mock_result.document.export_to_dict.return_value = {}
|
||||||
mock_conv.convert.return_value = mock_result
|
mock_conv.convert.return_value = mock_result
|
||||||
mock_build.return_value = mock_conv
|
mock_build.return_value = mock_conv
|
||||||
|
|
||||||
|
|
@ -182,8 +199,8 @@ class TestConvertDocumentRouting:
|
||||||
|
|
||||||
mock_build.assert_called_once_with(opts)
|
mock_build.assert_called_once_with(opts)
|
||||||
|
|
||||||
@patch("domain.parsing.get_default_converter")
|
@patch("infra.local_converter._get_default_converter")
|
||||||
@patch("domain.parsing.build_converter")
|
@patch("infra.local_converter._build_docling_converter")
|
||||||
def test_uses_custom_converter_when_code_enrichment_on(self, mock_build, mock_get_default):
|
def test_uses_custom_converter_when_code_enrichment_on(self, mock_build, mock_get_default):
|
||||||
mock_conv = MagicMock()
|
mock_conv = MagicMock()
|
||||||
mock_result = MagicMock()
|
mock_result = MagicMock()
|
||||||
|
|
@ -191,6 +208,7 @@ class TestConvertDocumentRouting:
|
||||||
mock_result.document.iterate_items.return_value = []
|
mock_result.document.iterate_items.return_value = []
|
||||||
mock_result.document.export_to_markdown.return_value = ""
|
mock_result.document.export_to_markdown.return_value = ""
|
||||||
mock_result.document.export_to_html.return_value = ""
|
mock_result.document.export_to_html.return_value = ""
|
||||||
|
mock_result.document.export_to_dict.return_value = {}
|
||||||
mock_conv.convert.return_value = mock_result
|
mock_conv.convert.return_value = mock_result
|
||||||
mock_build.return_value = mock_conv
|
mock_build.return_value = mock_conv
|
||||||
|
|
||||||
|
|
@ -199,8 +217,8 @@ class TestConvertDocumentRouting:
|
||||||
|
|
||||||
mock_build.assert_called_once_with(opts)
|
mock_build.assert_called_once_with(opts)
|
||||||
|
|
||||||
@patch("domain.parsing.get_default_converter")
|
@patch("infra.local_converter._get_default_converter")
|
||||||
@patch("domain.parsing.build_converter")
|
@patch("infra.local_converter._build_docling_converter")
|
||||||
def test_uses_custom_converter_when_formula_enrichment_on(self, mock_build, mock_get_default):
|
def test_uses_custom_converter_when_formula_enrichment_on(self, mock_build, mock_get_default):
|
||||||
mock_conv = MagicMock()
|
mock_conv = MagicMock()
|
||||||
mock_result = MagicMock()
|
mock_result = MagicMock()
|
||||||
|
|
@ -208,6 +226,7 @@ class TestConvertDocumentRouting:
|
||||||
mock_result.document.iterate_items.return_value = []
|
mock_result.document.iterate_items.return_value = []
|
||||||
mock_result.document.export_to_markdown.return_value = ""
|
mock_result.document.export_to_markdown.return_value = ""
|
||||||
mock_result.document.export_to_html.return_value = ""
|
mock_result.document.export_to_html.return_value = ""
|
||||||
|
mock_result.document.export_to_dict.return_value = {}
|
||||||
mock_conv.convert.return_value = mock_result
|
mock_conv.convert.return_value = mock_result
|
||||||
mock_build.return_value = mock_conv
|
mock_build.return_value = mock_conv
|
||||||
|
|
||||||
|
|
@ -215,8 +234,8 @@ class TestConvertDocumentRouting:
|
||||||
|
|
||||||
mock_build.assert_called_once()
|
mock_build.assert_called_once()
|
||||||
|
|
||||||
@patch("domain.parsing.get_default_converter")
|
@patch("infra.local_converter._get_default_converter")
|
||||||
@patch("domain.parsing.build_converter")
|
@patch("infra.local_converter._build_docling_converter")
|
||||||
def test_uses_custom_converter_when_picture_options_on(self, mock_build, mock_get_default):
|
def test_uses_custom_converter_when_picture_options_on(self, mock_build, mock_get_default):
|
||||||
mock_conv = MagicMock()
|
mock_conv = MagicMock()
|
||||||
mock_result = MagicMock()
|
mock_result = MagicMock()
|
||||||
|
|
@ -224,6 +243,7 @@ class TestConvertDocumentRouting:
|
||||||
mock_result.document.iterate_items.return_value = []
|
mock_result.document.iterate_items.return_value = []
|
||||||
mock_result.document.export_to_markdown.return_value = ""
|
mock_result.document.export_to_markdown.return_value = ""
|
||||||
mock_result.document.export_to_html.return_value = ""
|
mock_result.document.export_to_html.return_value = ""
|
||||||
|
mock_result.document.export_to_dict.return_value = {}
|
||||||
mock_conv.convert.return_value = mock_result
|
mock_conv.convert.return_value = mock_result
|
||||||
mock_build.return_value = mock_conv
|
mock_build.return_value = mock_conv
|
||||||
|
|
||||||
|
|
@ -231,8 +251,8 @@ class TestConvertDocumentRouting:
|
||||||
|
|
||||||
mock_build.assert_called_once()
|
mock_build.assert_called_once()
|
||||||
|
|
||||||
@patch("domain.parsing.get_default_converter")
|
@patch("infra.local_converter._get_default_converter")
|
||||||
@patch("domain.parsing.build_converter")
|
@patch("infra.local_converter._build_docling_converter")
|
||||||
def test_uses_custom_converter_when_generate_images_on(self, mock_build, mock_get_default):
|
def test_uses_custom_converter_when_generate_images_on(self, mock_build, mock_get_default):
|
||||||
mock_conv = MagicMock()
|
mock_conv = MagicMock()
|
||||||
mock_result = MagicMock()
|
mock_result = MagicMock()
|
||||||
|
|
@ -240,6 +260,7 @@ class TestConvertDocumentRouting:
|
||||||
mock_result.document.iterate_items.return_value = []
|
mock_result.document.iterate_items.return_value = []
|
||||||
mock_result.document.export_to_markdown.return_value = ""
|
mock_result.document.export_to_markdown.return_value = ""
|
||||||
mock_result.document.export_to_html.return_value = ""
|
mock_result.document.export_to_html.return_value = ""
|
||||||
|
mock_result.document.export_to_dict.return_value = {}
|
||||||
mock_conv.convert.return_value = mock_result
|
mock_conv.convert.return_value = mock_result
|
||||||
mock_build.return_value = mock_conv
|
mock_build.return_value = mock_conv
|
||||||
|
|
||||||
|
|
@ -247,8 +268,8 @@ class TestConvertDocumentRouting:
|
||||||
|
|
||||||
mock_build.assert_called_once()
|
mock_build.assert_called_once()
|
||||||
|
|
||||||
@patch("domain.parsing.get_default_converter")
|
@patch("infra.local_converter._get_default_converter")
|
||||||
@patch("domain.parsing.build_converter")
|
@patch("infra.local_converter._build_docling_converter")
|
||||||
def test_uses_custom_converter_when_images_scale_changed(self, mock_build, mock_get_default):
|
def test_uses_custom_converter_when_images_scale_changed(self, mock_build, mock_get_default):
|
||||||
mock_conv = MagicMock()
|
mock_conv = MagicMock()
|
||||||
mock_result = MagicMock()
|
mock_result = MagicMock()
|
||||||
|
|
@ -256,6 +277,7 @@ class TestConvertDocumentRouting:
|
||||||
mock_result.document.iterate_items.return_value = []
|
mock_result.document.iterate_items.return_value = []
|
||||||
mock_result.document.export_to_markdown.return_value = ""
|
mock_result.document.export_to_markdown.return_value = ""
|
||||||
mock_result.document.export_to_html.return_value = ""
|
mock_result.document.export_to_html.return_value = ""
|
||||||
|
mock_result.document.export_to_dict.return_value = {}
|
||||||
mock_conv.convert.return_value = mock_result
|
mock_conv.convert.return_value = mock_result
|
||||||
mock_build.return_value = mock_conv
|
mock_build.return_value = mock_conv
|
||||||
|
|
||||||
|
|
@ -264,8 +286,8 @@ class TestConvertDocumentRouting:
|
||||||
|
|
||||||
mock_build.assert_called_once_with(opts)
|
mock_build.assert_called_once_with(opts)
|
||||||
|
|
||||||
@patch("domain.parsing.get_default_converter")
|
@patch("infra.local_converter._get_default_converter")
|
||||||
@patch("domain.parsing.build_converter")
|
@patch("infra.local_converter._build_docling_converter")
|
||||||
def test_forwards_all_options_to_build_converter(self, mock_build, mock_get_default):
|
def test_forwards_all_options_to_build_converter(self, mock_build, mock_get_default):
|
||||||
mock_conv = MagicMock()
|
mock_conv = MagicMock()
|
||||||
mock_result = MagicMock()
|
mock_result = MagicMock()
|
||||||
|
|
@ -273,6 +295,7 @@ class TestConvertDocumentRouting:
|
||||||
mock_result.document.iterate_items.return_value = []
|
mock_result.document.iterate_items.return_value = []
|
||||||
mock_result.document.export_to_markdown.return_value = ""
|
mock_result.document.export_to_markdown.return_value = ""
|
||||||
mock_result.document.export_to_html.return_value = ""
|
mock_result.document.export_to_html.return_value = ""
|
||||||
|
mock_result.document.export_to_dict.return_value = {}
|
||||||
mock_conv.convert.return_value = mock_result
|
mock_conv.convert.return_value = mock_result
|
||||||
mock_build.return_value = mock_conv
|
mock_build.return_value = mock_conv
|
||||||
|
|
||||||
|
|
@ -297,77 +320,92 @@ class TestConvertDocumentRouting:
|
||||||
# Service layer — pipeline options forwarding
|
# Service layer — pipeline options forwarding
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestServiceForwardsPipelineOptions:
|
class TestServiceForwardsPipelineOptions:
|
||||||
"""Verify analysis_service.create and _run_analysis forward pipeline options."""
|
"""Verify analysis_service.create and _run_analysis forward pipeline options."""
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_doc(self):
|
def mock_doc(self):
|
||||||
from domain.models import Document
|
from domain.models import Document
|
||||||
|
|
||||||
return Document(id="d1", filename="test.pdf", storage_path="/tmp/test.pdf")
|
return Document(id="d1", filename="test.pdf", storage_path="/tmp/test.pdf")
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_job(self):
|
def mock_job(self):
|
||||||
from domain.models import AnalysisJob
|
from domain.models import AnalysisJob
|
||||||
|
|
||||||
return AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
|
return AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
|
||||||
|
|
||||||
@patch("services.analysis_service.document_repo")
|
@patch("services.analysis_service.document_repo")
|
||||||
@patch("services.analysis_service.analysis_repo")
|
@patch("services.analysis_service.analysis_repo")
|
||||||
@patch("services.analysis_service._run_analysis")
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_passes_pipeline_options_to_run(
|
async def test_create_passes_pipeline_options_to_run(
|
||||||
self, mock_run, mock_analysis_repo, mock_doc_repo, mock_doc,
|
self,
|
||||||
|
mock_analysis_repo,
|
||||||
|
mock_doc_repo,
|
||||||
|
mock_doc,
|
||||||
):
|
):
|
||||||
mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc)
|
mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc)
|
||||||
mock_analysis_repo.insert = AsyncMock()
|
mock_analysis_repo.insert = AsyncMock()
|
||||||
# Patch _run_analysis as a coroutine that we can inspect
|
|
||||||
mock_run.return_value = None
|
|
||||||
|
|
||||||
from services import analysis_service
|
mock_converter = AsyncMock()
|
||||||
|
from services.analysis_service import AnalysisService
|
||||||
|
|
||||||
|
svc = AnalysisService(converter=mock_converter)
|
||||||
|
|
||||||
opts = {"do_ocr": False, "table_mode": "fast"}
|
opts = {"do_ocr": False, "table_mode": "fast"}
|
||||||
|
|
||||||
# We need to patch asyncio.create_task to capture the coroutine args
|
|
||||||
with patch("services.analysis_service.asyncio.create_task") as mock_task:
|
with patch("services.analysis_service.asyncio.create_task") as mock_task:
|
||||||
await analysis_service.create("d1", pipeline_options=opts)
|
await svc.create("d1", pipeline_options=opts)
|
||||||
|
|
||||||
# create_task should have been called with _run_analysis(...)
|
|
||||||
mock_task.assert_called_once()
|
mock_task.assert_called_once()
|
||||||
|
|
||||||
@patch("services.analysis_service.document_repo")
|
@patch("services.analysis_service.document_repo")
|
||||||
@patch("services.analysis_service.analysis_repo")
|
@patch("services.analysis_service.analysis_repo")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_passes_none_when_no_options(
|
async def test_create_passes_none_when_no_options(
|
||||||
self, mock_analysis_repo, mock_doc_repo, mock_doc,
|
self,
|
||||||
|
mock_analysis_repo,
|
||||||
|
mock_doc_repo,
|
||||||
|
mock_doc,
|
||||||
):
|
):
|
||||||
mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc)
|
mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc)
|
||||||
mock_analysis_repo.insert = AsyncMock()
|
mock_analysis_repo.insert = AsyncMock()
|
||||||
|
|
||||||
from services import analysis_service
|
mock_converter = AsyncMock()
|
||||||
|
from services.analysis_service import AnalysisService
|
||||||
|
|
||||||
|
svc = AnalysisService(converter=mock_converter)
|
||||||
|
|
||||||
with patch("services.analysis_service.asyncio.create_task") as mock_task:
|
with patch("services.analysis_service.asyncio.create_task") as mock_task:
|
||||||
await analysis_service.create("d1")
|
await svc.create("d1")
|
||||||
mock_task.assert_called_once()
|
mock_task.assert_called_once()
|
||||||
|
|
||||||
@patch("services.analysis_service.analysis_repo")
|
@patch("services.analysis_service.analysis_repo")
|
||||||
@patch("services.analysis_service.document_repo")
|
@patch("services.analysis_service.document_repo")
|
||||||
@patch("services.analysis_service.convert_document")
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_analysis_forwards_options_to_convert(
|
async def test_run_analysis_forwards_options_to_convert(
|
||||||
self, mock_convert, mock_doc_repo, mock_analysis_repo, mock_job,
|
self,
|
||||||
|
mock_doc_repo,
|
||||||
|
mock_analysis_repo,
|
||||||
|
mock_job,
|
||||||
):
|
):
|
||||||
from domain.parsing import ConversionResult, PageDetail
|
from domain.value_objects import ConversionResult, PageDetail
|
||||||
|
|
||||||
mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
|
mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
|
||||||
mock_analysis_repo.update_status = AsyncMock()
|
mock_analysis_repo.update_status = AsyncMock()
|
||||||
mock_doc_repo.update_page_count = AsyncMock()
|
mock_doc_repo.update_page_count = AsyncMock()
|
||||||
mock_convert.return_value = ConversionResult(
|
|
||||||
|
mock_converter = AsyncMock()
|
||||||
|
mock_converter.convert.return_value = ConversionResult(
|
||||||
page_count=1,
|
page_count=1,
|
||||||
content_markdown="# Test",
|
content_markdown="# Test",
|
||||||
content_html="<h1>Test</h1>",
|
content_html="<h1>Test</h1>",
|
||||||
pages=[PageDetail(page_number=1, width=612.0, height=792.0)],
|
pages=[PageDetail(page_number=1, width=612.0, height=792.0)],
|
||||||
)
|
)
|
||||||
|
|
||||||
from services.analysis_service import _run_analysis
|
from services.analysis_service import AnalysisService
|
||||||
|
|
||||||
|
svc = AnalysisService(converter=mock_converter)
|
||||||
|
|
||||||
opts = {
|
opts = {
|
||||||
"do_ocr": False,
|
"do_ocr": False,
|
||||||
|
|
@ -381,10 +419,10 @@ class TestServiceForwardsPipelineOptions:
|
||||||
"images_scale": 2.0,
|
"images_scale": 2.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", opts)
|
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", opts)
|
||||||
|
|
||||||
mock_convert.assert_called_once()
|
mock_converter.convert.assert_called_once()
|
||||||
call_args = mock_convert.call_args
|
call_args = mock_converter.convert.call_args
|
||||||
assert call_args[0][0] == "/tmp/test.pdf"
|
assert call_args[0][0] == "/tmp/test.pdf"
|
||||||
conv_opts = call_args[0][1]
|
conv_opts = call_args[0][1]
|
||||||
assert conv_opts.do_ocr is False
|
assert conv_opts.do_ocr is False
|
||||||
|
|
@ -395,47 +433,58 @@ class TestServiceForwardsPipelineOptions:
|
||||||
|
|
||||||
@patch("services.analysis_service.analysis_repo")
|
@patch("services.analysis_service.analysis_repo")
|
||||||
@patch("services.analysis_service.document_repo")
|
@patch("services.analysis_service.document_repo")
|
||||||
@patch("services.analysis_service.convert_document")
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_analysis_uses_defaults_when_no_options(
|
async def test_run_analysis_uses_defaults_when_no_options(
|
||||||
self, mock_convert, mock_doc_repo, mock_analysis_repo, mock_job,
|
self,
|
||||||
|
mock_doc_repo,
|
||||||
|
mock_analysis_repo,
|
||||||
|
mock_job,
|
||||||
):
|
):
|
||||||
from domain.parsing import ConversionResult, PageDetail
|
from domain.value_objects import ConversionResult, PageDetail
|
||||||
|
|
||||||
mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
|
mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
|
||||||
mock_analysis_repo.update_status = AsyncMock()
|
mock_analysis_repo.update_status = AsyncMock()
|
||||||
mock_doc_repo.update_page_count = AsyncMock()
|
mock_doc_repo.update_page_count = AsyncMock()
|
||||||
mock_convert.return_value = ConversionResult(
|
|
||||||
|
mock_converter = AsyncMock()
|
||||||
|
mock_converter.convert.return_value = ConversionResult(
|
||||||
page_count=1,
|
page_count=1,
|
||||||
content_markdown="",
|
content_markdown="",
|
||||||
content_html="",
|
content_html="",
|
||||||
pages=[PageDetail(page_number=1, width=612.0, height=792.0)],
|
pages=[PageDetail(page_number=1, width=612.0, height=792.0)],
|
||||||
)
|
)
|
||||||
|
|
||||||
from services.analysis_service import _run_analysis
|
from services.analysis_service import AnalysisService
|
||||||
|
|
||||||
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", None)
|
svc = AnalysisService(converter=mock_converter)
|
||||||
|
|
||||||
# Called with file_path and default ConversionOptions
|
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", None)
|
||||||
mock_convert.assert_called_once()
|
|
||||||
call_args = mock_convert.call_args
|
mock_converter.convert.assert_called_once()
|
||||||
|
call_args = mock_converter.convert.call_args
|
||||||
assert call_args[0][0] == "/tmp/test.pdf"
|
assert call_args[0][0] == "/tmp/test.pdf"
|
||||||
assert call_args[0][1] == ConversionOptions()
|
assert call_args[0][1] == ConversionOptions()
|
||||||
|
|
||||||
@patch("services.analysis_service.analysis_repo")
|
@patch("services.analysis_service.analysis_repo")
|
||||||
@patch("services.analysis_service.document_repo")
|
@patch("services.analysis_service.document_repo")
|
||||||
@patch("services.analysis_service.convert_document")
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_analysis_marks_failed_on_error(
|
async def test_run_analysis_marks_failed_on_error(
|
||||||
self, mock_convert, mock_doc_repo, mock_analysis_repo, mock_job,
|
self,
|
||||||
|
mock_doc_repo,
|
||||||
|
mock_analysis_repo,
|
||||||
|
mock_job,
|
||||||
):
|
):
|
||||||
mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
|
mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
|
||||||
mock_analysis_repo.update_status = AsyncMock()
|
mock_analysis_repo.update_status = AsyncMock()
|
||||||
mock_convert.side_effect = RuntimeError("Docling crashed")
|
|
||||||
|
|
||||||
from services.analysis_service import _run_analysis
|
mock_converter = AsyncMock()
|
||||||
|
mock_converter.convert.side_effect = RuntimeError("Docling crashed")
|
||||||
|
|
||||||
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", {"do_ocr": False})
|
from services.analysis_service import AnalysisService
|
||||||
|
|
||||||
|
svc = AnalysisService(converter=mock_converter)
|
||||||
|
|
||||||
|
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", {"do_ocr": False})
|
||||||
|
|
||||||
# Should have called update_status twice: RUNNING then FAILED
|
# Should have called update_status twice: RUNNING then FAILED
|
||||||
assert mock_analysis_repo.update_status.call_count == 2
|
assert mock_analysis_repo.update_status.call_count == 2
|
||||||
|
|
@ -448,6 +497,7 @@ class TestServiceForwardsPipelineOptions:
|
||||||
# API endpoint — full request/response with pipeline options
|
# API endpoint — full request/response with pipeline options
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestAnalysisEndpointPipelineOptions:
|
class TestAnalysisEndpointPipelineOptions:
|
||||||
"""Integration-level tests for the analysis creation endpoint with pipeline options."""
|
"""Integration-level tests for the analysis creation endpoint with pipeline options."""
|
||||||
|
|
||||||
|
|
@ -456,28 +506,44 @@ class TestAnalysisEndpointPipelineOptions:
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from main import app
|
from main import app
|
||||||
|
|
||||||
return TestClient(app, raise_server_exceptions=False)
|
return TestClient(app, raise_server_exceptions=False)
|
||||||
|
|
||||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
@pytest.fixture
|
||||||
def test_no_pipeline_options_sends_none(self, mock_create, client):
|
def mock_svc(self, client):
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
mock = MagicMock()
|
||||||
|
original = getattr(app.state, "analysis_service", None)
|
||||||
|
app.state.analysis_service = mock
|
||||||
|
yield mock
|
||||||
|
app.state.analysis_service = original
|
||||||
|
|
||||||
|
def test_no_pipeline_options_sends_none(self, client, mock_svc):
|
||||||
from domain.models import AnalysisJob
|
from domain.models import AnalysisJob
|
||||||
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
|
|
||||||
|
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
||||||
|
|
||||||
client.post("/api/analyses", json={"documentId": "d1"})
|
client.post("/api/analyses", json={"documentId": "d1"})
|
||||||
|
|
||||||
mock_create.assert_called_once_with("d1", pipeline_options=None)
|
mock_svc.create.assert_called_once_with("d1", pipeline_options=None, chunking_options=None)
|
||||||
|
|
||||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
def test_empty_pipeline_options_object_uses_defaults(self, client, mock_svc):
|
||||||
def test_empty_pipeline_options_object_uses_defaults(self, mock_create, client):
|
|
||||||
from domain.models import AnalysisJob
|
from domain.models import AnalysisJob
|
||||||
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
|
|
||||||
|
|
||||||
client.post("/api/analyses", json={
|
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
||||||
"documentId": "d1",
|
|
||||||
"pipelineOptions": {},
|
|
||||||
})
|
|
||||||
|
|
||||||
opts = mock_create.call_args.kwargs["pipeline_options"]
|
client.post(
|
||||||
|
"/api/analyses",
|
||||||
|
json={
|
||||||
|
"documentId": "d1",
|
||||||
|
"pipelineOptions": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
opts = mock_svc.create.call_args.kwargs["pipeline_options"]
|
||||||
assert opts["do_ocr"] is True
|
assert opts["do_ocr"] is True
|
||||||
assert opts["do_table_structure"] is True
|
assert opts["do_table_structure"] is True
|
||||||
assert opts["table_mode"] == "accurate"
|
assert opts["table_mode"] == "accurate"
|
||||||
|
|
@ -485,20 +551,22 @@ class TestAnalysisEndpointPipelineOptions:
|
||||||
assert opts["do_formula_enrichment"] is False
|
assert opts["do_formula_enrichment"] is False
|
||||||
assert opts["images_scale"] == 1.0
|
assert opts["images_scale"] == 1.0
|
||||||
|
|
||||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
def test_partial_pipeline_options_merges_with_defaults(self, client, mock_svc):
|
||||||
def test_partial_pipeline_options_merges_with_defaults(self, mock_create, client):
|
|
||||||
from domain.models import AnalysisJob
|
from domain.models import AnalysisJob
|
||||||
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
|
|
||||||
|
|
||||||
client.post("/api/analyses", json={
|
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
||||||
"documentId": "d1",
|
|
||||||
"pipelineOptions": {"do_ocr": False, "images_scale": 1.5},
|
|
||||||
})
|
|
||||||
|
|
||||||
opts = mock_create.call_args.kwargs["pipeline_options"]
|
client.post(
|
||||||
|
"/api/analyses",
|
||||||
|
json={
|
||||||
|
"documentId": "d1",
|
||||||
|
"pipelineOptions": {"do_ocr": False, "images_scale": 1.5},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
opts = mock_svc.create.call_args.kwargs["pipeline_options"]
|
||||||
assert opts["do_ocr"] is False
|
assert opts["do_ocr"] is False
|
||||||
assert opts["images_scale"] == 1.5
|
assert opts["images_scale"] == 1.5
|
||||||
# All other fields should have defaults
|
|
||||||
assert opts["do_table_structure"] is True
|
assert opts["do_table_structure"] is True
|
||||||
assert opts["table_mode"] == "accurate"
|
assert opts["table_mode"] == "accurate"
|
||||||
assert opts["do_code_enrichment"] is False
|
assert opts["do_code_enrichment"] is False
|
||||||
|
|
@ -508,10 +576,10 @@ class TestAnalysisEndpointPipelineOptions:
|
||||||
assert opts["generate_picture_images"] is False
|
assert opts["generate_picture_images"] is False
|
||||||
assert opts["generate_page_images"] is False
|
assert opts["generate_page_images"] is False
|
||||||
|
|
||||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
def test_full_pipeline_options(self, client, mock_svc):
|
||||||
def test_full_pipeline_options(self, mock_create, client):
|
|
||||||
from domain.models import AnalysisJob
|
from domain.models import AnalysisJob
|
||||||
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
|
|
||||||
|
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"documentId": "d1",
|
"documentId": "d1",
|
||||||
|
|
@ -532,25 +600,29 @@ class TestAnalysisEndpointPipelineOptions:
|
||||||
resp = client.post("/api/analyses", json=payload)
|
resp = client.post("/api/analyses", json=payload)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
opts = mock_create.call_args.kwargs["pipeline_options"]
|
opts = mock_svc.create.call_args.kwargs["pipeline_options"]
|
||||||
assert opts == payload["pipelineOptions"]
|
assert opts == payload["pipelineOptions"]
|
||||||
|
|
||||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
def test_invalid_pipeline_option_type_rejected(self, client, mock_svc):
|
||||||
def test_invalid_pipeline_option_type_rejected(self, mock_create, client):
|
resp = client.post(
|
||||||
resp = client.post("/api/analyses", json={
|
"/api/analyses",
|
||||||
"documentId": "d1",
|
json={
|
||||||
"pipelineOptions": {"do_ocr": "not-a-bool"},
|
"documentId": "d1",
|
||||||
})
|
"pipelineOptions": {"do_ocr": "not-a-bool"},
|
||||||
|
},
|
||||||
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
def test_unknown_pipeline_option_ignored(self, client, mock_svc):
|
||||||
def test_unknown_pipeline_option_ignored(self, mock_create, client):
|
|
||||||
from domain.models import AnalysisJob
|
from domain.models import AnalysisJob
|
||||||
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
|
|
||||||
|
|
||||||
resp = client.post("/api/analyses", json={
|
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
||||||
"documentId": "d1",
|
|
||||||
"pipelineOptions": {"do_ocr": True, "unknown_field": True},
|
resp = client.post(
|
||||||
})
|
"/api/analyses",
|
||||||
# Pydantic ignores extra fields by default
|
json={
|
||||||
|
"documentId": "d1",
|
||||||
|
"pipelineOptions": {"do_ocr": True, "unknown_field": True},
|
||||||
|
},
|
||||||
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
|
||||||
93
document-parser/tests/test_rate_limiter.py
Normal file
93
document-parser/tests/test_rate_limiter.py
Normal 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
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"""Tests for persistence repositories using a temporary SQLite database."""
|
"""Tests for persistence repositories using a temporary SQLite database."""
|
||||||
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from domain.models import AnalysisJob, AnalysisStatus, Document
|
from domain.models import AnalysisJob, AnalysisStatus, Document
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"""Tests for API schemas — camelCase serialization and validation."""
|
"""Tests for API schemas — camelCase serialization and validation."""
|
||||||
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from api.schemas import (
|
from api.schemas import (
|
||||||
|
|
@ -97,7 +96,10 @@ class TestPipelineOptionsRequest:
|
||||||
|
|
||||||
def test_custom_values(self):
|
def test_custom_values(self):
|
||||||
opts = PipelineOptionsRequest(
|
opts = PipelineOptionsRequest(
|
||||||
do_ocr=False, table_mode="fast", do_code_enrichment=True, images_scale=2.0,
|
do_ocr=False,
|
||||||
|
table_mode="fast",
|
||||||
|
do_code_enrichment=True,
|
||||||
|
images_scale=2.0,
|
||||||
)
|
)
|
||||||
assert opts.do_ocr is False
|
assert opts.do_ocr is False
|
||||||
assert opts.table_mode == "fast"
|
assert opts.table_mode == "fast"
|
||||||
|
|
|
||||||
504
document-parser/tests/test_serve_converter.py
Normal file
504
document-parser/tests/test_serve_converter.py
Normal 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"
|
||||||
79
document-parser/tests/test_settings.py
Normal file
79
document-parser/tests/test_settings.py
Normal 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
2
frontend/env.d.ts
vendored
|
|
@ -1,5 +1,7 @@
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string
|
||||||
|
|
||||||
declare module '*.vue' {
|
declare module '*.vue' {
|
||||||
import type { DefineComponent } from 'vue'
|
import type { DefineComponent } from 'vue'
|
||||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ export default [
|
||||||
{
|
{
|
||||||
files: ['src/**/*.{ts,js,vue}'],
|
files: ['src/**/*.{ts,js,vue}'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
__APP_VERSION__: 'readonly',
|
||||||
|
},
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
parser: tseslint.parser,
|
parser: tseslint.parser,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Docling Studio</title>
|
<title>Docling Studio</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/png" href="/logo.png">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@ server {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
76
frontend/package-lock.json
generated
76
frontend/package-lock.json
generated
|
|
@ -1,14 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "docling-studio",
|
"name": "docling-studio",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "docling-studio",
|
"name": "docling-studio",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/mocker": "^4.1.2",
|
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
"pinia": "^2.3.0",
|
"pinia": "^2.3.0",
|
||||||
|
|
@ -19,6 +18,7 @@
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^9.0.0",
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.5",
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
|
"@vitest/mocker": "^4.1.2",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-plugin-vue": "^9.32.0",
|
"eslint-plugin-vue": "^9.32.0",
|
||||||
"prettier": "^3.4.0",
|
"prettier": "^3.4.0",
|
||||||
|
|
@ -78,6 +78,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"aix"
|
"aix"
|
||||||
|
|
@ -93,6 +94,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
|
|
@ -108,6 +110,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
|
|
@ -123,6 +126,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
|
|
@ -138,6 +142,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
|
|
@ -153,6 +158,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
|
|
@ -168,6 +174,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
|
|
@ -183,6 +190,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
|
|
@ -198,6 +206,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -213,6 +222,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -228,6 +238,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -243,6 +254,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -258,6 +270,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -273,6 +286,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -288,6 +302,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -303,6 +318,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -318,6 +334,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -333,6 +350,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
|
|
@ -348,6 +366,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
|
|
@ -363,6 +382,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
|
|
@ -378,6 +398,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
|
|
@ -393,6 +414,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openharmony"
|
"openharmony"
|
||||||
|
|
@ -408,6 +430,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"sunos"
|
"sunos"
|
||||||
|
|
@ -423,6 +446,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
|
@ -438,6 +462,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
|
@ -453,6 +478,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
|
@ -661,6 +687,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
|
|
@ -673,6 +700,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
|
|
@ -685,6 +713,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
|
|
@ -697,6 +726,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
|
|
@ -709,6 +739,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
|
|
@ -721,6 +752,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
|
|
@ -733,6 +765,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -745,6 +778,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -757,6 +791,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -769,6 +804,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -781,6 +817,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -793,6 +830,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -805,6 +843,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -817,6 +856,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -829,6 +869,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -841,6 +882,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -853,6 +895,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -865,6 +908,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -877,6 +921,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -889,6 +934,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
|
|
@ -901,6 +947,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openharmony"
|
"openharmony"
|
||||||
|
|
@ -913,6 +960,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
|
@ -925,6 +973,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
|
@ -937,6 +986,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
|
@ -949,6 +999,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
|
@ -989,7 +1040,8 @@
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
|
|
@ -1307,6 +1359,7 @@
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
|
||||||
"integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
|
"integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/spy": "4.1.2",
|
"@vitest/spy": "4.1.2",
|
||||||
"estree-walker": "^3.0.3",
|
"estree-walker": "^3.0.3",
|
||||||
|
|
@ -1372,6 +1425,7 @@
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
|
||||||
"integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
|
"integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
|
||||||
|
"dev": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
|
|
@ -1828,7 +1882,7 @@
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"esbuild": "bin/esbuild"
|
"esbuild": "bin/esbuild"
|
||||||
|
|
@ -2055,6 +2109,7 @@
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "^1.0.0"
|
"@types/estree": "^1.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -2099,7 +2154,7 @@
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -2163,6 +2218,7 @@
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2540,7 +2596,7 @@
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -2655,7 +2711,7 @@
|
||||||
"version": "4.59.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
|
|
@ -2797,7 +2853,7 @@
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.3"
|
"picomatch": "^4.0.3"
|
||||||
|
|
@ -2909,7 +2965,7 @@
|
||||||
"version": "6.4.1",
|
"version": "6.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "docling-studio",
|
"name": "docling-studio",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
"format:check": "prettier --check src/"
|
"format:check": "prettier --check src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/mocker": "^4.1.2",
|
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
"pinia": "^2.3.0",
|
"pinia": "^2.3.0",
|
||||||
|
|
@ -25,6 +24,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^9.0.0",
|
||||||
|
"@vitest/mocker": "^4.1.2",
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.5",
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
|
|
|
||||||
BIN
frontend/public/logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 858 KiB |
|
|
@ -1,22 +1,41 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="app-layout">
|
<div class="app-layout">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<button class="burger-btn" @click="sidebarOpen = !sidebarOpen" :title="sidebarOpen ? t('nav.collapse') : t('nav.expand')">
|
<button
|
||||||
|
class="burger-btn"
|
||||||
|
@click="sidebarOpen = !sidebarOpen"
|
||||||
|
:title="sidebarOpen ? t('nav.collapse') : t('nav.expand')"
|
||||||
|
>
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor" class="burger-icon">
|
<svg viewBox="0 0 20 20" fill="currentColor" class="burger-icon">
|
||||||
<path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/>
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="topbar-logo">
|
<div class="topbar-logo">
|
||||||
<span class="topbar-logo-icon">D</span>
|
<img src="/logo.png" alt="Docling Studio" class="topbar-logo-icon" />
|
||||||
<span class="topbar-logo-text">Docling Studio</span>
|
<span class="topbar-logo-text">Docling Studio</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-spacer" />
|
<div class="topbar-spacer" />
|
||||||
<button class="new-analysis-btn" @click="newAnalysis">
|
<button class="new-analysis-btn" @click="newAnalysis">
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor" class="new-analysis-icon"><path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"/></svg>
|
<svg viewBox="0 0 20 20" fill="currentColor" class="new-analysis-icon">
|
||||||
|
<path
|
||||||
|
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
{{ t('topbar.newAnalysis') }}
|
{{ t('topbar.newAnalysis') }}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div v-if="showDisclaimer" class="disclaimer-banner" role="alert">
|
||||||
|
{{ t('disclaimer.banner') }}
|
||||||
|
<button class="disclaimer-close" @click="dismissDisclaimer" aria-label="Close">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="app-body">
|
<div class="app-body">
|
||||||
<AppSidebar :open="sidebarOpen" />
|
<AppSidebar :open="sidebarOpen" />
|
||||||
<main class="main">
|
<main class="main">
|
||||||
|
|
@ -27,11 +46,12 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { RouterView, useRouter } from 'vue-router'
|
import { RouterView, useRouter } from 'vue-router'
|
||||||
import { AppSidebar } from '../shared/ui/index'
|
import { AppSidebar } from '../shared/ui/index'
|
||||||
import { useSettingsStore } from '../features/settings/store'
|
import { useSettingsStore } from '../features/settings/store'
|
||||||
import { useDocumentStore } from '../features/document/store'
|
import { useDocumentStore } from '../features/document/store'
|
||||||
|
import { useFeatureFlag } from '../features/feature-flags'
|
||||||
import { useI18n } from '../shared/i18n'
|
import { useI18n } from '../shared/i18n'
|
||||||
|
|
||||||
useSettingsStore()
|
useSettingsStore()
|
||||||
|
|
@ -40,6 +60,13 @@ const router = useRouter()
|
||||||
const documentStore = useDocumentStore()
|
const documentStore = useDocumentStore()
|
||||||
|
|
||||||
const sidebarOpen = ref(true)
|
const sidebarOpen = ref(true)
|
||||||
|
const disclaimerEnabled = useFeatureFlag('disclaimer')
|
||||||
|
const disclaimerDismissed = ref(false)
|
||||||
|
const showDisclaimer = computed(() => disclaimerEnabled.value && !disclaimerDismissed.value)
|
||||||
|
|
||||||
|
function dismissDisclaimer() {
|
||||||
|
disclaimerDismissed.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function newAnalysis() {
|
function newAnalysis() {
|
||||||
documentStore.selectedId = null
|
documentStore.selectedId = null
|
||||||
|
|
@ -52,26 +79,26 @@ function newAnalysis() {
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Dark mode palette - MistralAI Studio inspired */
|
/* Dark mode palette - MistralAI Studio inspired */
|
||||||
--bg: #0A0A0B;
|
--bg: #0a0a0b;
|
||||||
--bg-surface: #111113;
|
--bg-surface: #111113;
|
||||||
--bg-elevated: #1A1A1D;
|
--bg-elevated: #1a1a1d;
|
||||||
--bg-hover: #222226;
|
--bg-hover: #222226;
|
||||||
|
|
||||||
--accent: #F97316;
|
--accent: #f97316;
|
||||||
--accent-hover: #FB923C;
|
--accent-hover: #fb923c;
|
||||||
--accent-muted: rgba(249, 115, 22, 0.15);
|
--accent-muted: rgba(249, 115, 22, 0.15);
|
||||||
|
|
||||||
--text: #ECECEF;
|
--text: #ececef;
|
||||||
--text-secondary: #A1A1AA;
|
--text-secondary: #a1a1aa;
|
||||||
--text-muted: #63636E;
|
--text-muted: #63636e;
|
||||||
|
|
||||||
--border: #27272A;
|
--border: #27272a;
|
||||||
--border-light: #3F3F46;
|
--border-light: #3f3f46;
|
||||||
|
|
||||||
--success: #22C55E;
|
--success: #22c55e;
|
||||||
--error: #EF4444;
|
--error: #ef4444;
|
||||||
--warning: #EAB308;
|
--warning: #eab308;
|
||||||
--info: #3B82F6;
|
--info: #3b82f6;
|
||||||
|
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--radius-sm: 6px;
|
--radius-sm: 6px;
|
||||||
|
|
@ -83,32 +110,40 @@ function newAnalysis() {
|
||||||
}
|
}
|
||||||
|
|
||||||
html.light {
|
html.light {
|
||||||
--bg: #FAFAFA;
|
--bg: #fafafa;
|
||||||
--bg-surface: #FFFFFF;
|
--bg-surface: #ffffff;
|
||||||
--bg-elevated: #F4F4F5;
|
--bg-elevated: #f4f4f5;
|
||||||
--bg-hover: #E4E4E7;
|
--bg-hover: #e4e4e7;
|
||||||
|
|
||||||
--accent: #F97316;
|
--accent: #f97316;
|
||||||
--accent-hover: #EA580C;
|
--accent-hover: #ea580c;
|
||||||
--accent-muted: rgba(249, 115, 22, 0.10);
|
--accent-muted: rgba(249, 115, 22, 0.1);
|
||||||
|
|
||||||
--text: #18181B;
|
--text: #18181b;
|
||||||
--text-secondary: #52525B;
|
--text-secondary: #52525b;
|
||||||
--text-muted: #A1A1AA;
|
--text-muted: #a1a1aa;
|
||||||
|
|
||||||
--border: #E4E4E7;
|
--border: #e4e4e7;
|
||||||
--border-light: #D4D4D8;
|
--border-light: #d4d4d8;
|
||||||
|
|
||||||
--success: #16A34A;
|
--success: #16a34a;
|
||||||
--error: #DC2626;
|
--error: #dc2626;
|
||||||
--warning: #CA8A04;
|
--warning: #ca8a04;
|
||||||
--info: #2563EB;
|
--info: #2563eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family:
|
||||||
|
'Inter',
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
sans-serif;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|
@ -123,6 +158,36 @@ body {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.disclaimer-banner {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #1a1a1d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 40px 8px 16px;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer-close {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #1a1a1d;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
height: var(--topbar-height);
|
height: var(--topbar-height);
|
||||||
min-height: var(--topbar-height);
|
min-height: var(--topbar-height);
|
||||||
|
|
@ -167,16 +232,10 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-logo-icon {
|
.topbar-logo-icon {
|
||||||
width: 26px;
|
width: 28px;
|
||||||
height: 26px;
|
height: 28px;
|
||||||
background: var(--accent);
|
border-radius: 6px;
|
||||||
color: white;
|
object-fit: contain;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-logo-text {
|
.topbar-logo-text {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
|
import { useFeatureFlagStore } from '../features/feature-flags'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|
||||||
|
const featureFlags = useFeatureFlagStore()
|
||||||
|
featureFlags.load()
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,31 @@
|
||||||
import type { Analysis, PipelineOptions } from '../../shared/types'
|
import type { Analysis, Chunk, ChunkingOptions, PipelineOptions } from '../../shared/types'
|
||||||
import { apiFetch } from '../../shared/api/http'
|
import { apiFetch } from '../../shared/api/http'
|
||||||
|
|
||||||
export function createAnalysis(
|
export function createAnalysis(
|
||||||
documentId: string,
|
documentId: string,
|
||||||
pipelineOptions: PipelineOptions | null = null,
|
pipelineOptions: PipelineOptions | null = null,
|
||||||
|
chunkingOptions: ChunkingOptions | null = null,
|
||||||
): Promise<Analysis> {
|
): Promise<Analysis> {
|
||||||
const body: Record<string, unknown> = { documentId }
|
const body: Record<string, unknown> = { documentId }
|
||||||
if (pipelineOptions) {
|
if (pipelineOptions) {
|
||||||
body.pipelineOptions = pipelineOptions
|
body.pipelineOptions = pipelineOptions
|
||||||
}
|
}
|
||||||
|
if (chunkingOptions) {
|
||||||
|
body.chunkingOptions = chunkingOptions
|
||||||
|
}
|
||||||
return apiFetch<Analysis>('/api/analyses', {
|
return apiFetch<Analysis>('/api/analyses', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function rechunkAnalysis(jobId: string, chunkingOptions: ChunkingOptions): Promise<Chunk[]> {
|
||||||
|
return apiFetch<Chunk[]>(`/api/analyses/${jobId}/rechunk`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ chunkingOptions }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchAnalyses(): Promise<Analysis[]> {
|
export function fetchAnalyses(): Promise<Analysis[]> {
|
||||||
return apiFetch<Analysis[]>('/api/analyses')
|
return apiFetch<Analysis[]>('/api/analyses')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,10 @@ describe('computeScale', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sx equals sy when aspect ratio is preserved', () => {
|
it('sx equals sy when aspect ratio is preserved', () => {
|
||||||
const pageW = 612, pageH = 792
|
const pageW = 612,
|
||||||
|
pageH = 792
|
||||||
const displayW = 700
|
const displayW = 700
|
||||||
const displayH = displayW * pageH / pageW
|
const displayH = (displayW * pageH) / pageW
|
||||||
const s = computeScale(displayW, displayH, pageW, pageH)
|
const s = computeScale(displayW, displayH, pageW, pageH)
|
||||||
expect(s.sx).toBeCloseTo(s.sy, 5)
|
expect(s.sx).toBeCloseTo(s.sy, 5)
|
||||||
})
|
})
|
||||||
|
|
@ -165,17 +166,17 @@ describe('pointInRect', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns true for point on edge', () => {
|
it('returns true for point on edge', () => {
|
||||||
expect(pointInRect(10, 20, rect)).toBe(true) // top-left
|
expect(pointInRect(10, 20, rect)).toBe(true) // top-left
|
||||||
expect(pointInRect(110, 80, rect)).toBe(true) // bottom-right
|
expect(pointInRect(110, 80, rect)).toBe(true) // bottom-right
|
||||||
expect(pointInRect(10, 80, rect)).toBe(true) // bottom-left
|
expect(pointInRect(10, 80, rect)).toBe(true) // bottom-left
|
||||||
expect(pointInRect(110, 20, rect)).toBe(true) // top-right
|
expect(pointInRect(110, 20, rect)).toBe(true) // top-right
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns false for point outside', () => {
|
it('returns false for point outside', () => {
|
||||||
expect(pointInRect(5, 50, rect)).toBe(false) // left
|
expect(pointInRect(5, 50, rect)).toBe(false) // left
|
||||||
expect(pointInRect(50, 15, rect)).toBe(false) // above
|
expect(pointInRect(50, 15, rect)).toBe(false) // above
|
||||||
expect(pointInRect(115, 50, rect)).toBe(false) // right
|
expect(pointInRect(115, 50, rect)).toBe(false) // right
|
||||||
expect(pointInRect(50, 85, rect)).toBe(false) // below
|
expect(pointInRect(50, 85, rect)).toBe(false) // below
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns false for EMPTY_RECT (degenerate bbox not hoverable)', () => {
|
it('returns false for EMPTY_RECT (degenerate bbox not hoverable)', () => {
|
||||||
|
|
@ -203,8 +204,10 @@ describe('pointInRect', () => {
|
||||||
describe('integration: bbox pipeline', () => {
|
describe('integration: bbox pipeline', () => {
|
||||||
it('full A4 page: element is clickable at correct position', () => {
|
it('full A4 page: element is clickable at correct position', () => {
|
||||||
// Simulate: A4 page rendered at 700px wide
|
// Simulate: A4 page rendered at 700px wide
|
||||||
const pageW = 595.28, pageH = 841.89
|
const pageW = 595.28,
|
||||||
const displayW = 700, displayH = 700 * pageH / pageW
|
pageH = 841.89
|
||||||
|
const displayW = 700,
|
||||||
|
displayH = (700 * pageH) / pageW
|
||||||
|
|
||||||
const scale = computeScale(displayW, displayH, pageW, pageH)
|
const scale = computeScale(displayW, displayH, pageW, pageH)
|
||||||
// Element at center of page in PDF points
|
// Element at center of page in PDF points
|
||||||
|
|
@ -212,8 +215,8 @@ describe('integration: bbox pipeline', () => {
|
||||||
const rect = bboxToRect(bbox, scale)
|
const rect = bboxToRect(bbox, scale)
|
||||||
|
|
||||||
// Center of the element in display pixels
|
// Center of the element in display pixels
|
||||||
const cx = (200 + 400) / 2 * scale.sx
|
const cx = ((200 + 400) / 2) * scale.sx
|
||||||
const cy = (350 + 500) / 2 * scale.sy
|
const cy = ((350 + 500) / 2) * scale.sy
|
||||||
|
|
||||||
expect(pointInRect(cx, cy, rect)).toBe(true)
|
expect(pointInRect(cx, cy, rect)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import * as api from './api'
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('createAnalysis — pipeline options body construction', () => {
|
describe('createAnalysis — pipeline options body construction', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
// Reset the real module to get the unmocked version
|
// Reset the real module to get the unmocked version
|
||||||
|
|
@ -107,7 +106,6 @@ describe('createAnalysis — pipeline options body construction', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Store → API integration — pipeline options forwarding
|
// Store → API integration — pipeline options forwarding
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -131,7 +129,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => {
|
||||||
const store = useAnalysisStore()
|
const store = useAnalysisStore()
|
||||||
await store.run('d1')
|
await store.run('d1')
|
||||||
|
|
||||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null)
|
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, null)
|
||||||
store.stopPolling()
|
store.stopPolling()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -155,7 +153,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => {
|
||||||
}
|
}
|
||||||
await store.run('d1', opts)
|
await store.run('d1', opts)
|
||||||
|
|
||||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', opts)
|
expect(api.createAnalysis).toHaveBeenCalledWith('d1', opts, null)
|
||||||
store.stopPolling()
|
store.stopPolling()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -168,7 +166,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => {
|
||||||
const opts = { do_ocr: false }
|
const opts = { do_ocr: false }
|
||||||
await store.run('d1', opts)
|
await store.run('d1', opts)
|
||||||
|
|
||||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', { do_ocr: false })
|
expect(api.createAnalysis).toHaveBeenCalledWith('d1', { do_ocr: false }, null)
|
||||||
store.stopPolling()
|
store.stopPolling()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ describe('useAnalysisStore', () => {
|
||||||
expect(store.currentAnalysis).toEqual(job)
|
expect(store.currentAnalysis).toEqual(job)
|
||||||
expect(store.analyses[0]).toEqual(job)
|
expect(store.analyses[0]).toEqual(job)
|
||||||
expect(store.running).toBe(true)
|
expect(store.running).toBe(true)
|
||||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null)
|
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, null)
|
||||||
|
|
||||||
// Advance timer to trigger polling
|
// Advance timer to trigger polling
|
||||||
await vi.advanceTimersByTimeAsync(2000)
|
await vi.advanceTimersByTimeAsync(2000)
|
||||||
|
|
@ -90,7 +90,7 @@ describe('useAnalysisStore', () => {
|
||||||
const options = { do_ocr: false, table_mode: 'fast' }
|
const options = { do_ocr: false, table_mode: 'fast' }
|
||||||
await store.run('d1', options)
|
await store.run('d1', options)
|
||||||
|
|
||||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', options)
|
expect(api.createAnalysis).toHaveBeenCalledWith('d1', options, null)
|
||||||
|
|
||||||
store.stopPolling()
|
store.stopPolling()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { Analysis, Page, PipelineOptions } from '../../shared/types'
|
import type { Analysis, Chunk, ChunkingOptions, Page, PipelineOptions } from '../../shared/types'
|
||||||
import * as api from './api'
|
import * as api from './api'
|
||||||
|
|
||||||
export const useAnalysisStore = defineStore('analysis', () => {
|
export const useAnalysisStore = defineStore('analysis', () => {
|
||||||
|
|
@ -9,6 +9,8 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
||||||
const running = ref(false)
|
const running = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null)
|
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const pollingTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const MAX_POLLING_DURATION = 10 * 60 * 1000 // 10 minutes
|
||||||
|
|
||||||
const currentPages = computed<Page[]>(() => {
|
const currentPages = computed<Page[]>(() => {
|
||||||
if (!currentAnalysis.value?.pagesJson) return []
|
if (!currentAnalysis.value?.pagesJson) return []
|
||||||
|
|
@ -33,14 +35,44 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentChunks = computed<Chunk[]>(() => {
|
||||||
|
if (!currentAnalysis.value?.chunksJson) return []
|
||||||
|
try {
|
||||||
|
return JSON.parse(currentAnalysis.value.chunksJson) as Chunk[]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const rechunking = ref(false)
|
||||||
|
|
||||||
|
async function rechunk(jobId: string, chunkingOptions: ChunkingOptions): Promise<Chunk[]> {
|
||||||
|
rechunking.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const chunks = await api.rechunkAnalysis(jobId, chunkingOptions)
|
||||||
|
if (currentAnalysis.value?.id === jobId) {
|
||||||
|
currentAnalysis.value = await api.fetchAnalysis(jobId)
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message || 'Failed to rechunk'
|
||||||
|
console.error('Failed to rechunk', e)
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
rechunking.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function run(
|
async function run(
|
||||||
documentId: string,
|
documentId: string,
|
||||||
pipelineOptions: PipelineOptions | null = null,
|
pipelineOptions: PipelineOptions | null = null,
|
||||||
|
chunkingOptions: ChunkingOptions | null = null,
|
||||||
): Promise<Analysis> {
|
): Promise<Analysis> {
|
||||||
running.value = true
|
running.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const analysis = await api.createAnalysis(documentId, pipelineOptions)
|
const analysis = await api.createAnalysis(documentId, pipelineOptions, chunkingOptions)
|
||||||
currentAnalysis.value = analysis
|
currentAnalysis.value = analysis
|
||||||
analyses.value.unshift(analysis)
|
analyses.value.unshift(analysis)
|
||||||
startPolling(analysis.id)
|
startPolling(analysis.id)
|
||||||
|
|
@ -72,6 +104,13 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
||||||
running.value = false
|
running.value = false
|
||||||
}
|
}
|
||||||
}, 2000)
|
}, 2000)
|
||||||
|
pollingTimeout.value = setTimeout(() => {
|
||||||
|
if (pollingInterval.value) {
|
||||||
|
error.value = 'Analysis timed out'
|
||||||
|
stopPolling()
|
||||||
|
running.value = false
|
||||||
|
}
|
||||||
|
}, MAX_POLLING_DURATION)
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPolling(): void {
|
function stopPolling(): void {
|
||||||
|
|
@ -79,6 +118,10 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
||||||
clearInterval(pollingInterval.value)
|
clearInterval(pollingInterval.value)
|
||||||
pollingInterval.value = null
|
pollingInterval.value = null
|
||||||
}
|
}
|
||||||
|
if (pollingTimeout.value) {
|
||||||
|
clearTimeout(pollingTimeout.value)
|
||||||
|
pollingTimeout.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function select(id: string): Promise<void> {
|
async function select(id: string): Promise<void> {
|
||||||
|
|
@ -105,11 +148,14 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
||||||
analyses,
|
analyses,
|
||||||
currentAnalysis,
|
currentAnalysis,
|
||||||
currentPages,
|
currentPages,
|
||||||
|
currentChunks,
|
||||||
running,
|
running,
|
||||||
|
rechunking,
|
||||||
error,
|
error,
|
||||||
clearError,
|
clearError,
|
||||||
load,
|
load,
|
||||||
run,
|
run,
|
||||||
|
rechunk,
|
||||||
select,
|
select,
|
||||||
remove,
|
remove,
|
||||||
stopPolling,
|
stopPolling,
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-actions" v-if="selectedDoc">
|
<div class="panel-actions" v-if="selectedDoc">
|
||||||
<button
|
<button class="btn-analyze" :disabled="analysisStore.running" @click="runAnalysis">
|
||||||
class="btn-analyze"
|
|
||||||
:disabled="analysisStore.running"
|
|
||||||
@click="runAnalysis"
|
|
||||||
>
|
|
||||||
<div v-if="analysisStore.running" class="spinner" />
|
<div v-if="analysisStore.running" class="spinner" />
|
||||||
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="btn-icon">
|
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="btn-icon">
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/>
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ analysisStore.running ? 'Analyzing...' : 'Analyze' }}
|
{{ analysisStore.running ? 'Analyzing...' : 'Analyze' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -55,7 +55,7 @@ const analysisStore = useAnalysisStore()
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
|
|
||||||
const selectedDoc = computed(() => {
|
const selectedDoc = computed(() => {
|
||||||
return documentStore.documents.find(d => d.id === documentStore.selectedId)
|
return documentStore.documents.find((d) => d.id === documentStore.selectedId)
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusClass = computed(() => {
|
const statusClass = computed(() => {
|
||||||
|
|
@ -64,7 +64,7 @@ const statusClass = computed(() => {
|
||||||
'status-pending': status === 'PENDING',
|
'status-pending': status === 'PENDING',
|
||||||
'status-running': status === 'RUNNING',
|
'status-running': status === 'RUNNING',
|
||||||
'status-completed': status === 'COMPLETED',
|
'status-completed': status === 'COMPLETED',
|
||||||
'status-failed': status === 'FAILED'
|
'status-failed': status === 'FAILED',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -115,20 +115,32 @@ async function runAnalysis() {
|
||||||
transition: background var(--transition);
|
transition: background var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-analyze:hover:not(:disabled) { background: var(--accent-hover); }
|
.btn-analyze:hover:not(:disabled) {
|
||||||
.btn-analyze:disabled { opacity: 0.6; cursor: not-allowed; }
|
background: var(--accent-hover);
|
||||||
.btn-icon { width: 18px; height: 18px; }
|
}
|
||||||
|
.btn-analyze:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.btn-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border: 2px solid rgba(255,255,255,0.3);
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
border-top-color: white;
|
border-top-color: white;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.6s linear infinite;
|
animation: spin 0.6s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.status-row {
|
.status-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -143,8 +155,20 @@ async function runAnalysis() {
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pending { background: rgba(234, 179, 8, 0.15); color: var(--warning); }
|
.status-pending {
|
||||||
.status-running { background: rgba(59, 130, 246, 0.15); color: var(--info); }
|
background: rgba(234, 179, 8, 0.15);
|
||||||
.status-completed { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
color: var(--warning);
|
||||||
.status-failed { background: rgba(239, 68, 68, 0.15); color: var(--error); }
|
}
|
||||||
|
.status-running {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
.status-completed {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
.status-failed {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,13 @@
|
||||||
ref="canvasRef"
|
ref="canvasRef"
|
||||||
class="overlay-canvas"
|
class="overlay-canvas"
|
||||||
@mousemove="onMouseMove"
|
@mousemove="onMouseMove"
|
||||||
@mouseleave="hoveredElement = null; emit('highlight-element', -1)"
|
@mouseleave="onMouseLeave"
|
||||||
/>
|
/>
|
||||||
<div
|
<div v-if="hoveredElement" class="tooltip" :style="tooltipStyle">
|
||||||
v-if="hoveredElement"
|
<span
|
||||||
class="tooltip"
|
class="tooltip-type"
|
||||||
:style="tooltipStyle"
|
:style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }"
|
||||||
>
|
>
|
||||||
<span class="tooltip-type" :style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }">
|
|
||||||
{{ hoveredElement.type }}
|
{{ hoveredElement.type }}
|
||||||
</span>
|
</span>
|
||||||
<span class="tooltip-content">{{ hoveredElement.content?.substring(0, 150) }}</span>
|
<span class="tooltip-content">{{ hoveredElement.content?.substring(0, 150) }}</span>
|
||||||
|
|
@ -38,7 +37,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick, reactive, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, nextTick, reactive, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling'
|
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling'
|
||||||
import type { Page, PageElement } from '../../../shared/types'
|
import type { Page, PageElement, ChunkBbox } from '../../../shared/types'
|
||||||
|
|
||||||
const ELEMENT_COLORS: Record<string, string> = {
|
const ELEMENT_COLORS: Record<string, string> = {
|
||||||
title: '#EF4444',
|
title: '#EF4444',
|
||||||
|
|
@ -49,14 +48,18 @@ const ELEMENT_COLORS: Record<string, string> = {
|
||||||
list: '#06B6D4',
|
list: '#06B6D4',
|
||||||
formula: '#EC4899',
|
formula: '#EC4899',
|
||||||
code: '#14B8A6',
|
code: '#14B8A6',
|
||||||
caption: '#EAB308'
|
caption: '#EAB308',
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
imageEl: HTMLImageElement | null
|
defineProps<{
|
||||||
pageData: Page | null
|
imageEl: HTMLImageElement | null
|
||||||
highlightedIndex: number
|
pageData: Page | null
|
||||||
}>()
|
highlightedIndex: number
|
||||||
|
highlightedBboxes: ChunkBbox[]
|
||||||
|
}>(),
|
||||||
|
{ highlightedBboxes: () => [] },
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'highlight-element': [index: number]
|
'highlight-element': [index: number]
|
||||||
|
|
@ -112,22 +115,49 @@ function draw(): void {
|
||||||
|
|
||||||
if (!props.pageData) return
|
if (!props.pageData) return
|
||||||
|
|
||||||
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
|
const scale = computeScale(
|
||||||
|
img.clientWidth,
|
||||||
|
img.clientHeight,
|
||||||
|
props.pageData.width,
|
||||||
|
props.pageData.height,
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasHighlight = props.highlightedIndex >= 0
|
||||||
|
const hasChunkHighlight = props.highlightedBboxes.length > 0
|
||||||
|
|
||||||
for (const el of visibleElements.value) {
|
for (const el of visibleElements.value) {
|
||||||
const rect = bboxToRect(el.bbox, scale)
|
const rect = bboxToRect(el.bbox, scale)
|
||||||
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
|
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
|
||||||
|
|
||||||
const elContentIdx = contentElements.value.indexOf(el)
|
const elContentIdx = contentElements.value.indexOf(el)
|
||||||
const isHighlighted = props.highlightedIndex >= 0 && elContentIdx === props.highlightedIndex
|
const isHighlighted = hasHighlight && elContentIdx === props.highlightedIndex
|
||||||
|
const dimmed = (hasHighlight || hasChunkHighlight) && !isHighlighted
|
||||||
|
|
||||||
ctx.strokeStyle = color
|
ctx.strokeStyle = dimmed ? color + '40' : color
|
||||||
ctx.lineWidth = isHighlighted ? 3 : 2
|
ctx.lineWidth = isHighlighted ? 3 : 2
|
||||||
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
|
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
|
||||||
|
|
||||||
ctx.fillStyle = color + (isHighlighted ? '40' : '20')
|
ctx.fillStyle = color + (isHighlighted ? '40' : dimmed ? '08' : '20')
|
||||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
|
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw chunk-highlighted bboxes on top with accent color
|
||||||
|
if (hasChunkHighlight) {
|
||||||
|
const CHUNK_COLOR = '#F59E0B'
|
||||||
|
for (const cb of props.highlightedBboxes) {
|
||||||
|
const rect = bboxToRect(cb.bbox, scale)
|
||||||
|
ctx.strokeStyle = CHUNK_COLOR
|
||||||
|
ctx.lineWidth = 3
|
||||||
|
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
|
||||||
|
ctx.fillStyle = CHUNK_COLOR + '30'
|
||||||
|
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseLeave(): void {
|
||||||
|
hoveredElement.value = null
|
||||||
|
emit('highlight-element', -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseMove(e: MouseEvent): void {
|
function onMouseMove(e: MouseEvent): void {
|
||||||
|
|
@ -139,7 +169,12 @@ function onMouseMove(e: MouseEvent): void {
|
||||||
const mx = e.clientX - canvasRect.left
|
const mx = e.clientX - canvasRect.left
|
||||||
const my = e.clientY - canvasRect.top
|
const my = e.clientY - canvasRect.top
|
||||||
|
|
||||||
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
|
const scale = computeScale(
|
||||||
|
img.clientWidth,
|
||||||
|
img.clientHeight,
|
||||||
|
props.pageData.width,
|
||||||
|
props.pageData.height,
|
||||||
|
)
|
||||||
|
|
||||||
let found: PageElement | null = null
|
let found: PageElement | null = null
|
||||||
let foundIdx = -1
|
let foundIdx = -1
|
||||||
|
|
@ -156,7 +191,7 @@ function onMouseMove(e: MouseEvent): void {
|
||||||
if (found) {
|
if (found) {
|
||||||
tooltipStyle.value = {
|
tooltipStyle.value = {
|
||||||
left: `${Math.min(mx + 12, canvas.width - 250)}px`,
|
left: `${Math.min(mx + 12, canvas.width - 250)}px`,
|
||||||
top: `${my + 12}px`
|
top: `${my + 12}px`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -173,9 +208,18 @@ onBeforeUnmount(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Redraw when data or image changes
|
// Redraw when data or image changes
|
||||||
watch([() => props.pageData, () => props.imageEl, () => props.highlightedIndex, hiddenTypes], () => {
|
watch(
|
||||||
nextTick(draw)
|
[
|
||||||
})
|
() => props.pageData,
|
||||||
|
() => props.imageEl,
|
||||||
|
() => props.highlightedIndex,
|
||||||
|
() => props.highlightedBboxes,
|
||||||
|
hiddenTypes,
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
nextTick(draw)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// Expose draw so parent can call it after image load
|
// Expose draw so parent can call it after image load
|
||||||
defineExpose({ draw })
|
defineExpose({ draw })
|
||||||
|
|
@ -219,8 +263,13 @@ defineExpose({ draw })
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-chip:hover { opacity: 1; background: var(--bg-hover); }
|
.legend-chip:hover {
|
||||||
.legend-chip.dimmed { opacity: 0.35; }
|
opacity: 1;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
.legend-chip.dimmed {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
.legend-dot {
|
.legend-dot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
|
@ -252,7 +301,7 @@ defineExpose({ draw })
|
||||||
max-width: 250px;
|
max-width: 250px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-type {
|
.tooltip-type {
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,7 @@
|
||||||
{{ t('results.noImages') }}
|
{{ t('results.noImages') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="gallery-grid">
|
<div v-else class="gallery-grid">
|
||||||
<div
|
<div v-for="(img, idx) in images" :key="idx" class="gallery-card">
|
||||||
v-for="(img, idx) in images"
|
|
||||||
:key="idx"
|
|
||||||
class="gallery-card"
|
|
||||||
>
|
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-type">Picture</span>
|
<span class="card-type">Picture</span>
|
||||||
<span class="card-page">{{ t('results.page') }} {{ img.page }}</span>
|
<span class="card-page">{{ t('results.page') }} {{ img.page }}</span>
|
||||||
|
|
@ -18,7 +14,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card-bbox">
|
<div class="card-bbox">
|
||||||
<span class="bbox-label">bbox:</span>
|
<span class="bbox-label">bbox:</span>
|
||||||
<span class="bbox-value">{{ img.bbox.map(v => Math.round(v)).join(', ') }}</span>
|
<span class="bbox-value">{{ img.bbox.map((v) => Math.round(v)).join(', ') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -33,13 +29,13 @@ import type { Page, PageElement } from '../../../shared/types'
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
pages: { type: Array as () => Page[], default: () => [] }
|
pages: { type: Array as () => Page[], default: () => [] },
|
||||||
})
|
})
|
||||||
|
|
||||||
const images = computed(() => {
|
const images = computed(() => {
|
||||||
const result: (PageElement & { page: number })[] = []
|
const result: (PageElement & { page: number })[] = []
|
||||||
for (const page of props.pages) {
|
for (const page of props.pages) {
|
||||||
for (const el of (page.elements || [])) {
|
for (const el of page.elements || []) {
|
||||||
if (el.type === 'picture') {
|
if (el.type === 'picture') {
|
||||||
result.push({ ...el, page: page.page_number })
|
result.push({ ...el, page: page.page_number })
|
||||||
}
|
}
|
||||||
|
|
@ -109,5 +105,7 @@ const images = computed(() => {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bbox-label { margin-right: 4px; }
|
.bbox-label {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,19 @@ const rendered = computed(() => {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-viewer :deep(h1) { font-size: 24px; }
|
.markdown-viewer :deep(h1) {
|
||||||
.markdown-viewer :deep(h2) { font-size: 20px; }
|
font-size: 24px;
|
||||||
.markdown-viewer :deep(h3) { font-size: 16px; }
|
}
|
||||||
|
.markdown-viewer :deep(h2) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.markdown-viewer :deep(h3) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-viewer :deep(p) { margin: 8px 0; }
|
.markdown-viewer :deep(p) {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-viewer :deep(code) {
|
.markdown-viewer :deep(code) {
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@
|
||||||
|
|
||||||
<!-- Page chip -->
|
<!-- Page chip -->
|
||||||
<div class="page-indicator" v-if="totalPages > 0">
|
<div class="page-indicator" v-if="totalPages > 0">
|
||||||
<span class="page-chip">{{ t('results.pageOf', { current: currentPage, total: totalPages }) }}</span>
|
<span class="page-chip">{{
|
||||||
|
t('results.pageOf', { current: currentPage, total: totalPages })
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
|
|
@ -32,7 +34,10 @@
|
||||||
@mouseleave="$emit('highlight-element', -1)"
|
@mouseleave="$emit('highlight-element', -1)"
|
||||||
>
|
>
|
||||||
<div class="element-header">
|
<div class="element-header">
|
||||||
<span class="element-type" :style="{ color: ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text }">
|
<span
|
||||||
|
class="element-type"
|
||||||
|
:style="{ color: ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text }"
|
||||||
|
>
|
||||||
{{ el.type }}
|
{{ el.type }}
|
||||||
</span>
|
</span>
|
||||||
<span class="element-level" v-if="el.level">L{{ el.level }}</span>
|
<span class="element-level" v-if="el.level">L{{ el.level }}</span>
|
||||||
|
|
@ -42,12 +47,23 @@
|
||||||
:title="t('results.copy')"
|
:title="t('results.copy')"
|
||||||
@click.stop="copyElement(idx, el.content)"
|
@click.stop="copyElement(idx, el.content)"
|
||||||
>
|
>
|
||||||
<svg v-if="!copiedElements[idx]" viewBox="0 0 20 20" fill="currentColor" class="copy-icon">
|
<svg
|
||||||
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z"/>
|
v-if="!copiedElements[idx]"
|
||||||
<path d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"/>
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
class="copy-icon"
|
||||||
|
>
|
||||||
|
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z" />
|
||||||
|
<path
|
||||||
|
d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="copy-icon copied">
|
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="copy-icon copied">
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -57,7 +73,7 @@
|
||||||
<span v-else>{{ el.content }}</span>
|
<span v-else>{{ el.content }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="element-bbox">
|
<div class="element-bbox">
|
||||||
{{ el.bbox.map(v => Math.round(v)).join(', ') }}
|
{{ el.bbox.map((v) => Math.round(v)).join(', ') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -66,11 +82,17 @@
|
||||||
<div v-else-if="activeTab === 'markdown'" class="raw-markdown">
|
<div v-else-if="activeTab === 'markdown'" class="raw-markdown">
|
||||||
<button class="copy-btn copy-btn-block" :title="t('results.copy')" @click="copyMarkdown">
|
<button class="copy-btn copy-btn-block" :title="t('results.copy')" @click="copyMarkdown">
|
||||||
<svg v-if="!copiedMarkdown" viewBox="0 0 20 20" fill="currentColor" class="copy-icon">
|
<svg v-if="!copiedMarkdown" viewBox="0 0 20 20" fill="currentColor" class="copy-icon">
|
||||||
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z"/>
|
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z" />
|
||||||
<path d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"/>
|
<path
|
||||||
|
d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="copy-icon copied">
|
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="copy-icon copied">
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<pre class="raw-content">{{ pageMarkdown }}</pre>
|
<pre class="raw-content">{{ pageMarkdown }}</pre>
|
||||||
|
|
@ -85,12 +107,26 @@
|
||||||
<span>{{ t('studio.analysisRunning') }}</span>
|
<span>{{ t('studio.analysisRunning') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="store.currentAnalysis?.status === 'FAILED'" class="result-placeholder error">
|
<div v-else-if="store.currentAnalysis?.status === 'FAILED'" class="result-placeholder error">
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor" class="error-icon"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
<svg viewBox="0 0 20 20" fill="currentColor" class="error-icon">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
<span>{{ store.currentAnalysis.errorMessage || t('results.analysisFailed') }}</span>
|
<span>{{ store.currentAnalysis.errorMessage || t('results.analysisFailed') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="result-placeholder">
|
<div v-else class="result-placeholder">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
|
<svg
|
||||||
<path d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v16.5c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9zm3.75 11.625a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
class="empty-icon"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v16.5c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9zm3.75 11.625a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>{{ t('results.runAnalysis') }}</span>
|
<span>{{ t('results.runAnalysis') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -113,12 +149,12 @@ const ELEMENT_COLORS: Record<string, string> = {
|
||||||
list: '#06B6D4',
|
list: '#06B6D4',
|
||||||
formula: '#EC4899',
|
formula: '#EC4899',
|
||||||
code: '#14B8A6',
|
code: '#14B8A6',
|
||||||
caption: '#EAB308'
|
caption: '#EAB308',
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
currentPage: { type: Number, default: 1 },
|
currentPage: { type: Number, default: 1 },
|
||||||
highlightedIndex: { type: Number, default: -1 }
|
highlightedIndex: { type: Number, default: -1 },
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['highlight-element'])
|
defineEmits(['highlight-element'])
|
||||||
|
|
@ -130,17 +166,17 @@ const activeTab = ref('elements')
|
||||||
const tabs = computed(() => [
|
const tabs = computed(() => [
|
||||||
{ id: 'elements', label: t('results.elements') },
|
{ id: 'elements', label: t('results.elements') },
|
||||||
{ id: 'markdown', label: t('results.markdown') },
|
{ id: 'markdown', label: t('results.markdown') },
|
||||||
{ id: 'images', label: t('results.images') }
|
{ id: 'images', label: t('results.images') },
|
||||||
])
|
])
|
||||||
|
|
||||||
const totalPages = computed(() => store.currentPages.length)
|
const totalPages = computed(() => store.currentPages.length)
|
||||||
|
|
||||||
const currentPageData = computed(() => {
|
const currentPageData = computed(() => {
|
||||||
return store.currentPages.find(p => p.page_number === props.currentPage) || null
|
return store.currentPages.find((p) => p.page_number === props.currentPage) || null
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentElements = computed(() => {
|
const currentElements = computed(() => {
|
||||||
return (currentPageData.value?.elements || []).filter(el => el.content)
|
return (currentPageData.value?.elements || []).filter((el) => el.content)
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentPageAsArray = computed(() => {
|
const currentPageAsArray = computed(() => {
|
||||||
|
|
@ -153,7 +189,7 @@ const pageMarkdown = computed(() => {
|
||||||
if (!page) return ''
|
if (!page) return ''
|
||||||
|
|
||||||
return (page.elements || [])
|
return (page.elements || [])
|
||||||
.map(el => formatElement(el))
|
.map((el) => formatElement(el))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n\n')
|
.join('\n\n')
|
||||||
})
|
})
|
||||||
|
|
@ -191,16 +227,24 @@ async function copyMarkdown() {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(pageMarkdown.value)
|
await navigator.clipboard.writeText(pageMarkdown.value)
|
||||||
copiedMarkdown.value = true
|
copiedMarkdown.value = true
|
||||||
setTimeout(() => { copiedMarkdown.value = false }, 1500)
|
setTimeout(() => {
|
||||||
} catch { /* clipboard not available */ }
|
copiedMarkdown.value = false
|
||||||
|
}, 1500)
|
||||||
|
} catch {
|
||||||
|
/* clipboard not available */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyElement(idx: number, content: string) {
|
async function copyElement(idx: number, content: string) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(content)
|
await navigator.clipboard.writeText(content)
|
||||||
copiedElements[idx] = true
|
copiedElements[idx] = true
|
||||||
setTimeout(() => { copiedElements[idx] = false }, 1500)
|
setTimeout(() => {
|
||||||
} catch { /* clipboard not available */ }
|
copiedElements[idx] = false
|
||||||
|
}, 1500)
|
||||||
|
} catch {
|
||||||
|
/* clipboard not available */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -234,7 +278,9 @@ async function copyElement(idx: number, content: string) {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-btn:hover { color: var(--text-secondary); }
|
.tab-btn:hover {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
.tab-btn.active {
|
.tab-btn.active {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
border-bottom-color: var(--accent);
|
border-bottom-color: var(--accent);
|
||||||
|
|
@ -368,8 +414,13 @@ async function copyElement(idx: number, content: string) {
|
||||||
background: var(--accent-muted);
|
background: var(--accent-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-icon { width: 14px; height: 14px; }
|
.copy-icon {
|
||||||
.copy-icon.copied { color: var(--success); }
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
.copy-icon.copied {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
.copy-btn-element {
|
.copy-btn-element {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
|
@ -377,7 +428,9 @@ async function copyElement(idx: number, content: string) {
|
||||||
transition: opacity var(--transition);
|
transition: opacity var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.element-card:hover .copy-btn-element { opacity: 1; }
|
.element-card:hover .copy-btn-element {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.copy-btn-block {
|
.copy-btn-block {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -389,7 +442,9 @@ async function copyElement(idx: number, content: string) {
|
||||||
transition: opacity var(--transition);
|
transition: opacity var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.raw-markdown:hover .copy-btn-block { opacity: 1; }
|
.raw-markdown:hover .copy-btn-block {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Raw markdown --- */
|
/* --- Raw markdown --- */
|
||||||
.raw-markdown {
|
.raw-markdown {
|
||||||
|
|
@ -423,9 +478,18 @@ async function copyElement(idx: number, content: string) {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-placeholder.error { color: var(--error); }
|
.result-placeholder.error {
|
||||||
.error-icon { width: 32px; height: 32px; }
|
color: var(--error);
|
||||||
.empty-icon { width: 48px; height: 48px; color: var(--border-light); }
|
}
|
||||||
|
.error-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
.empty-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
.spinner-large {
|
.spinner-large {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
|
|
@ -436,5 +500,9 @@ async function copyElement(idx: number, content: string) {
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,11 @@
|
||||||
@mouseleave="hoveredElement = null"
|
@mouseleave="hoveredElement = null"
|
||||||
/>
|
/>
|
||||||
<!-- Tooltip -->
|
<!-- Tooltip -->
|
||||||
<div
|
<div v-if="hoveredElement" class="tooltip" :style="tooltipStyle">
|
||||||
v-if="hoveredElement"
|
<span
|
||||||
class="tooltip"
|
class="tooltip-type"
|
||||||
:style="tooltipStyle"
|
:style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }"
|
||||||
>
|
>
|
||||||
<span class="tooltip-type" :style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }">
|
|
||||||
{{ hoveredElement.type }}
|
{{ hoveredElement.type }}
|
||||||
</span>
|
</span>
|
||||||
<span class="tooltip-content">{{ hoveredElement.content?.substring(0, 150) }}</span>
|
<span class="tooltip-content">{{ hoveredElement.content?.substring(0, 150) }}</span>
|
||||||
|
|
@ -71,12 +70,12 @@ const ELEMENT_COLORS: Record<string, string> = {
|
||||||
picture: '#22C55E',
|
picture: '#22C55E',
|
||||||
list: '#06B6D4',
|
list: '#06B6D4',
|
||||||
formula: '#EC4899',
|
formula: '#EC4899',
|
||||||
caption: '#EAB308'
|
caption: '#EAB308',
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
pages: { type: Array as () => Page[], default: () => [] },
|
pages: { type: Array as () => Page[], default: () => [] },
|
||||||
documentId: String
|
documentId: String,
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedPage = ref(1)
|
const selectedPage = ref(1)
|
||||||
|
|
@ -89,12 +88,12 @@ const tooltipStyle = ref<Record<string, string>>({})
|
||||||
const imageSize = ref({ width: 0, height: 0 })
|
const imageSize = ref({ width: 0, height: 0 })
|
||||||
|
|
||||||
const currentPageData = computed(() => {
|
const currentPageData = computed(() => {
|
||||||
return props.pages.find(p => p.page_number === selectedPage.value)
|
return props.pages.find((p) => p.page_number === selectedPage.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const visibleElements = computed(() => {
|
const visibleElements = computed(() => {
|
||||||
if (!currentPageData.value) return []
|
if (!currentPageData.value) return []
|
||||||
return currentPageData.value.elements.filter(e => !hiddenTypes.has(e.type))
|
return currentPageData.value.elements.filter((e) => !hiddenTypes.has(e.type))
|
||||||
})
|
})
|
||||||
|
|
||||||
const previewUrl = computed(() => {
|
const previewUrl = computed(() => {
|
||||||
|
|
@ -110,7 +109,7 @@ function toggleType(type: string) {
|
||||||
|
|
||||||
function countElements(type: string) {
|
function countElements(type: string) {
|
||||||
if (!currentPageData.value) return 0
|
if (!currentPageData.value) return 0
|
||||||
return currentPageData.value.elements.filter(e => e.type === type).length
|
return currentPageData.value.elements.filter((e) => e.type === type).length
|
||||||
}
|
}
|
||||||
|
|
||||||
function onImageLoad() {
|
function onImageLoad() {
|
||||||
|
|
@ -174,7 +173,7 @@ function onMouseMove(e: MouseEvent) {
|
||||||
if (found) {
|
if (found) {
|
||||||
tooltipStyle.value = {
|
tooltipStyle.value = {
|
||||||
left: `${Math.min(mx + 12, canvas.width - 250)}px`,
|
left: `${Math.min(mx + 12, canvas.width - 250)}px`,
|
||||||
top: `${my + 12}px`
|
top: `${my + 12}px`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -213,7 +212,9 @@ watch([() => props.pages, selectedPage, hiddenTypes], () => {
|
||||||
transition: all var(--transition);
|
transition: all var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-btn:hover { background: var(--bg-hover); }
|
.page-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
.page-btn.active {
|
.page-btn.active {
|
||||||
background: var(--accent-muted);
|
background: var(--accent-muted);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
|
@ -240,8 +241,12 @@ watch([() => props.pages, selectedPage, hiddenTypes], () => {
|
||||||
transition: all var(--transition);
|
transition: all var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-item:hover { background: var(--bg-hover); }
|
.legend-item:hover {
|
||||||
.legend-item.dimmed { opacity: 0.4; }
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
.legend-item.dimmed {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
.legend-dot {
|
.legend-dot {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|
@ -286,7 +291,7 @@ watch([() => props.pages, selectedPage, hiddenTypes], () => {
|
||||||
max-width: 250px;
|
max-width: 250px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-type {
|
.tooltip-type {
|
||||||
|
|
|
||||||
52
frontend/src/features/chunking/api.test.ts
Normal file
52
frontend/src/features/chunking/api.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
1
frontend/src/features/chunking/index.ts
Normal file
1
frontend/src/features/chunking/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default as ChunkPanel } from './ui/ChunkPanel.vue'
|
||||||
141
frontend/src/features/chunking/store.test.ts
Normal file
141
frontend/src/features/chunking/store.test.ts
Normal 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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
437
frontend/src/features/chunking/ui/ChunkPanel.vue
Normal file
437
frontend/src/features/chunking/ui/ChunkPanel.vue
Normal 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>
|
||||||
|
|
@ -24,7 +24,10 @@ describe('useDocumentStore', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('load() fetches and sets documents', async () => {
|
it('load() fetches and sets documents', async () => {
|
||||||
const docs = [{ id: '1', filename: 'a.pdf' }, { id: '2', filename: 'b.pdf' }]
|
const docs = [
|
||||||
|
{ id: '1', filename: 'a.pdf' },
|
||||||
|
{ id: '2', filename: 'b.pdf' },
|
||||||
|
]
|
||||||
api.fetchDocuments.mockResolvedValue(docs)
|
api.fetchDocuments.mockResolvedValue(docs)
|
||||||
|
|
||||||
const store = useDocumentStore()
|
const store = useDocumentStore()
|
||||||
|
|
@ -61,7 +64,12 @@ describe('useDocumentStore', () => {
|
||||||
|
|
||||||
it('upload() sets uploading to true during upload', async () => {
|
it('upload() sets uploading to true during upload', async () => {
|
||||||
let resolveUpload
|
let resolveUpload
|
||||||
api.uploadDocument.mockImplementation(() => new Promise(r => { resolveUpload = r }))
|
api.uploadDocument.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((r) => {
|
||||||
|
resolveUpload = r
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const store = useDocumentStore()
|
const store = useDocumentStore()
|
||||||
const promise = store.upload(new File([], 'test.pdf'))
|
const promise = store.upload(new File([], 'test.pdf'))
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { ref } from 'vue'
|
||||||
import type { Document } from '../../shared/types'
|
import type { Document } from '../../shared/types'
|
||||||
import * as api from './api'
|
import * as api from './api'
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
|
||||||
|
|
||||||
export const useDocumentStore = defineStore('document', () => {
|
export const useDocumentStore = defineStore('document', () => {
|
||||||
const documents = ref<Document[]>([])
|
const documents = ref<Document[]>([])
|
||||||
const selectedId = ref<string | null>(null)
|
const selectedId = ref<string | null>(null)
|
||||||
|
|
@ -24,6 +26,10 @@ export const useDocumentStore = defineStore('document', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upload(file: File): Promise<Document> {
|
async function upload(file: File): Promise<Document> {
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
error.value = 'File too large (max 50 MB)'
|
||||||
|
throw new Error(error.value)
|
||||||
|
}
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,28 @@
|
||||||
>
|
>
|
||||||
<div class="doc-info">
|
<div class="doc-info">
|
||||||
<svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor">
|
<svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/>
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="doc-meta">
|
<div class="doc-meta">
|
||||||
<span class="doc-name">{{ doc.filename }}</span>
|
<span class="doc-name">{{ doc.filename }}</span>
|
||||||
<span class="doc-size">{{ formatSize(doc.fileSize) }}{{ doc.pageCount ? ` — ${doc.pageCount} pages` : '' }}</span>
|
<span class="doc-size"
|
||||||
|
>{{ formatSize(doc.fileSize)
|
||||||
|
}}{{ doc.pageCount ? ` — ${doc.pageCount} pages` : '' }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="doc-delete" @click.stop="store.remove(doc.id)" title="Delete">
|
<button class="doc-delete" @click.stop="store.remove(doc.id)" title="Delete">
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
|
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -101,7 +114,15 @@ const store = useDocumentStore()
|
||||||
transition: all var(--transition);
|
transition: all var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-item:hover .doc-delete { opacity: 1; }
|
.doc-item:hover .doc-delete {
|
||||||
.doc-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); }
|
opacity: 1;
|
||||||
.doc-delete svg { width: 14px; height: 14px; }
|
}
|
||||||
|
.doc-delete:hover {
|
||||||
|
color: var(--error);
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
.doc-delete svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,18 @@
|
||||||
<span>{{ t('upload.uploading') }}</span>
|
<span>{{ t('upload.uploading') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="upload-state">
|
<div v-else class="upload-state">
|
||||||
<svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg
|
||||||
|
class="upload-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
<path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" />
|
<path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="upload-text">{{ t('upload.drop') }}</span>
|
<span class="upload-text">{{ t('upload.drop') }}</span>
|
||||||
<span class="upload-hint">{{ t('upload.maxSize') }}</span>
|
<span class="upload-hint">{{ t('upload.maxSize') }}</span>
|
||||||
|
<span v-if="store.error" class="upload-error">{{ store.error }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -41,9 +48,14 @@ function openFilePicker() {
|
||||||
async function onFileSelect(e: Event) {
|
async function onFileSelect(e: Event) {
|
||||||
const target = e.target as HTMLInputElement
|
const target = e.target as HTMLInputElement
|
||||||
const file = target.files?.[0]
|
const file = target.files?.[0]
|
||||||
if (file) {
|
if (file && (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'))) {
|
||||||
const doc = await store.upload(file)
|
try {
|
||||||
if (doc) emit('uploaded', doc.id)
|
store.clearError()
|
||||||
|
const doc = await store.upload(file)
|
||||||
|
if (doc) emit('uploaded', doc.id)
|
||||||
|
} catch {
|
||||||
|
// error is already set in store.upload
|
||||||
|
}
|
||||||
}
|
}
|
||||||
target.value = ''
|
target.value = ''
|
||||||
}
|
}
|
||||||
|
|
@ -51,9 +63,14 @@ async function onFileSelect(e: Event) {
|
||||||
async function onDrop(e: DragEvent) {
|
async function onDrop(e: DragEvent) {
|
||||||
dragging.value = false
|
dragging.value = false
|
||||||
const file = e.dataTransfer?.files?.[0]
|
const file = e.dataTransfer?.files?.[0]
|
||||||
if (file && file.type === 'application/pdf') {
|
if (file && (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'))) {
|
||||||
const doc = await store.upload(file)
|
try {
|
||||||
if (doc) emit('uploaded', doc.id)
|
store.clearError()
|
||||||
|
const doc = await store.upload(file)
|
||||||
|
if (doc) emit('uploaded', doc.id)
|
||||||
|
} catch {
|
||||||
|
// error is already set in store.upload
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -103,6 +120,12 @@ async function onDrop(e: DragEvent) {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-error {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--error, #e53e3e);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
|
@ -113,6 +136,8 @@ async function onDrop(e: DragEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,26 @@
|
||||||
<span class="preview-label">Page {{ page }}</span>
|
<span class="preview-label">Page {{ page }}</span>
|
||||||
<div class="preview-nav">
|
<div class="preview-nav">
|
||||||
<button class="nav-btn" :disabled="page <= 1" @click="$emit('update:page', page - 1)">
|
<button class="nav-btn" :disabled="page <= 1" @click="$emit('update:page', page - 1)">
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn" :disabled="!!(pageCount && page >= pageCount)" @click="$emit('update:page', page + 1)">
|
<button
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
class="nav-btn"
|
||||||
|
:disabled="!!(pageCount && page >= pageCount)"
|
||||||
|
@click="$emit('update:page', page + 1)"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -30,7 +46,7 @@ import { getPreviewUrl } from '../api'
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
documentId: String,
|
documentId: String,
|
||||||
page: { type: Number, default: 1 },
|
page: { type: Number, default: 1 },
|
||||||
pageCount: Number
|
pageCount: Number,
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['update:page', 'imageLoaded'])
|
defineEmits(['update:page', 'imageLoaded'])
|
||||||
|
|
@ -76,9 +92,18 @@ const previewUrl = computed(() => {
|
||||||
transition: all var(--transition);
|
transition: all var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
|
.nav-btn:hover:not(:disabled) {
|
||||||
.nav-btn:disabled { opacity: 0.3; cursor: default; }
|
background: var(--bg-hover);
|
||||||
.nav-btn svg { width: 16px; height: 16px; }
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.nav-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.nav-btn svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-image-wrapper {
|
.preview-image-wrapper {
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
|
|
|
||||||
2
frontend/src/features/feature-flags/index.ts
Normal file
2
frontend/src/features/feature-flags/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { useFeatureFlagStore, type FeatureFlag } from './store'
|
||||||
|
export { useFeatureFlag } from './useFeatureFlag'
|
||||||
80
frontend/src/features/feature-flags/store.test.ts
Normal file
80
frontend/src/features/feature-flags/store.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
68
frontend/src/features/feature-flags/store.ts
Normal file
68
frontend/src/features/feature-flags/store.ts
Normal 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 }
|
||||||
|
})
|
||||||
28
frontend/src/features/feature-flags/useFeatureFlag.test.ts
Normal file
28
frontend/src/features/feature-flags/useFeatureFlag.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
7
frontend/src/features/feature-flags/useFeatureFlag.ts
Normal file
7
frontend/src/features/feature-flags/useFeatureFlag.ts
Normal 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))
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,16 @@
|
||||||
<div class="item-main">
|
<div class="item-main">
|
||||||
<div class="item-header">
|
<div class="item-header">
|
||||||
<span class="item-filename">{{ analysis.documentFilename }}</span>
|
<span class="item-filename">{{ analysis.documentFilename }}</span>
|
||||||
<span class="item-status" :class="statusClass(analysis.status)">
|
<span
|
||||||
|
v-if="analysis.status === 'COMPLETED'"
|
||||||
|
class="item-status status-completed"
|
||||||
|
:title="analysis.status"
|
||||||
|
>
|
||||||
|
<svg class="status-dot" viewBox="0 0 8 8" fill="currentColor">
|
||||||
|
<circle cx="4" cy="4" r="4" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span v-else class="item-status" :class="statusClass(analysis.status)">
|
||||||
{{ analysis.status }}
|
{{ analysis.status }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -23,7 +32,13 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="item-delete" @click.stop="store.remove(analysis.id)" title="Delete">
|
<button class="item-delete" @click.stop="store.remove(analysis.id)" title="Delete">
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -49,7 +64,7 @@ function statusClass(status: string) {
|
||||||
'status-pending': status === 'PENDING',
|
'status-pending': status === 'PENDING',
|
||||||
'status-running': status === 'RUNNING',
|
'status-running': status === 'RUNNING',
|
||||||
'status-completed': status === 'COMPLETED',
|
'status-completed': status === 'COMPLETED',
|
||||||
'status-failed': status === 'FAILED'
|
'status-failed': status === 'FAILED',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,22 +96,31 @@ function duration(analysis: Analysis) {
|
||||||
.history-items {
|
.history-items {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 6px;
|
||||||
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-item {
|
.history-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 14px 20px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid var(--border);
|
background: var(--bg-surface);
|
||||||
transition: background var(--transition);
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: all 150ms ease;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-item:hover { background: var(--bg-hover); }
|
.history-item:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
.item-main { flex: 1; min-width: 0; }
|
.item-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.item-header {
|
.item-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -122,10 +146,28 @@ function duration(analysis: Analysis) {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pending { background: rgba(234, 179, 8, 0.15); color: var(--warning); }
|
.status-dot {
|
||||||
.status-running { background: rgba(59, 130, 246, 0.15); color: var(--info); }
|
width: 8px;
|
||||||
.status-completed { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
height: 8px;
|
||||||
.status-failed { background: rgba(239, 68, 68, 0.15); color: var(--error); }
|
}
|
||||||
|
|
||||||
|
.status-pending {
|
||||||
|
background: rgba(234, 179, 8, 0.15);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
.status-running {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
.status-completed {
|
||||||
|
background: none;
|
||||||
|
color: var(--success);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.status-failed {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
.item-meta {
|
.item-meta {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
@ -146,7 +188,15 @@ function duration(analysis: Analysis) {
|
||||||
transition: all var(--transition);
|
transition: all var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-item:hover .item-delete { opacity: 1; }
|
.history-item:hover .item-delete {
|
||||||
.item-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); }
|
opacity: 1;
|
||||||
.item-delete svg { width: 16px; height: 16px; }
|
}
|
||||||
|
.item-delete:hover {
|
||||||
|
color: var(--error);
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
.item-delete svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,29 @@ import { defineStore } from 'pinia'
|
||||||
import { ref, watch, watchEffect } from 'vue'
|
import { ref, watch, watchEffect } from 'vue'
|
||||||
import type { Locale, Theme } from '../../shared/types'
|
import type { Locale, Theme } from '../../shared/types'
|
||||||
|
|
||||||
|
function safeGetItem(key: string): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeSetItem(key: string, value: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, value)
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable (private browsing, quota exceeded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', () => {
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
const apiUrl = ref('http://localhost:8000')
|
const apiUrl = ref('http://localhost:8000')
|
||||||
const theme = ref<Theme>((localStorage.getItem('docling-theme') as Theme) || 'dark')
|
const theme = ref<Theme>((safeGetItem('docling-theme') as Theme) || 'dark')
|
||||||
const locale = ref<Locale>((localStorage.getItem('docling-locale') as Locale) || 'fr')
|
const locale = ref<Locale>((safeGetItem('docling-locale') as Locale) || 'fr')
|
||||||
|
|
||||||
watch(theme, (v) => localStorage.setItem('docling-theme', v))
|
watch(theme, (v) => safeSetItem('docling-theme', v))
|
||||||
watch(locale, (v) => localStorage.setItem('docling-locale', v))
|
watch(locale, (v) => safeSetItem('docling-locale', v))
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
document.documentElement.classList.toggle('light', theme.value === 'light')
|
document.documentElement.classList.toggle('light', theme.value === 'light')
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,18 @@
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<label class="setting-label">{{ t('settings.language') }}</label>
|
<label class="setting-label">{{ t('settings.language') }}</label>
|
||||||
<div class="setting-toggle">
|
<div class="setting-toggle">
|
||||||
<button :class="{ active: store.locale === 'fr' }" @click="store.setLocale('fr')">FR</button>
|
<button :class="{ active: store.locale === 'fr' }" @click="store.setLocale('fr')">
|
||||||
<button :class="{ active: store.locale === 'en' }" @click="store.setLocale('en')">EN</button>
|
FR
|
||||||
|
</button>
|
||||||
|
<button :class="{ active: store.locale === 'en' }" @click="store.setLocale('en')">
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<label class="setting-label">{{ t('settings.version') }}</label>
|
<label class="setting-label">{{ t('settings.version') }}</label>
|
||||||
<span class="setting-value">0.1.0</span>
|
<span class="setting-value">{{ version }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -31,6 +35,7 @@
|
||||||
import { useSettingsStore } from '../store'
|
import { useSettingsStore } from '../store'
|
||||||
import { useI18n } from '../../../shared/i18n'
|
import { useI18n } from '../../../shared/i18n'
|
||||||
|
|
||||||
|
const version = __APP_VERSION__
|
||||||
const store = useSettingsStore()
|
const store = useSettingsStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -39,14 +44,14 @@ const { t } = useI18n()
|
||||||
.settings-panel {
|
.settings-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 24px;
|
||||||
max-width: 500px;
|
max-width: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-group {
|
.setting-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-label {
|
.setting-label {
|
||||||
|
|
@ -58,33 +63,36 @@ const { t } = useI18n()
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-toggle {
|
.setting-toggle {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
padding: 3px;
|
||||||
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-toggle button {
|
.setting-toggle button {
|
||||||
flex: 1;
|
padding: 6px 16px;
|
||||||
padding: 8px 16px;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-muted);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
|
border-radius: calc(var(--radius-sm) - 2px);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--transition);
|
transition: all 200ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-toggle button:hover {
|
.setting-toggle button:hover {
|
||||||
color: var(--text);
|
color: var(--text-secondary);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-toggle button.active {
|
.setting-toggle button.active {
|
||||||
background: var(--accent-muted);
|
background: var(--accent-muted);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-input {
|
.setting-input {
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,14 @@
|
||||||
{{ t('history.emptyDocs') }}
|
{{ t('history.emptyDocs') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="doc-items">
|
<div v-else class="doc-items">
|
||||||
<div
|
<div v-for="doc in docStore.documents" :key="doc.id" class="doc-row">
|
||||||
v-for="doc in docStore.documents"
|
|
||||||
:key="doc.id"
|
|
||||||
class="doc-row"
|
|
||||||
>
|
|
||||||
<div class="doc-row-info">
|
<div class="doc-row-info">
|
||||||
<svg class="doc-row-icon" viewBox="0 0 20 20" fill="currentColor">
|
<svg class="doc-row-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/>
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="doc-row-meta">
|
<div class="doc-row-meta">
|
||||||
<span class="doc-row-name">{{ doc.filename }}</span>
|
<span class="doc-row-name">{{ doc.filename }}</span>
|
||||||
|
|
@ -27,7 +27,13 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="doc-row-delete" @click="docStore.remove(doc.id)">
|
<button class="doc-row-delete" @click="docStore.remove(doc.id)">
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -89,19 +95,25 @@ onMounted(() => {
|
||||||
.doc-items {
|
.doc-items {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 6px;
|
||||||
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-row {
|
.doc-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 14px 20px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid var(--border);
|
background: var(--bg-surface);
|
||||||
transition: background var(--transition);
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: all 150ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-row:hover { background: var(--bg-hover); }
|
.doc-row:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
.doc-row-info {
|
.doc-row-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -152,7 +164,15 @@ onMounted(() => {
|
||||||
transition: all var(--transition);
|
transition: all var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-row:hover .doc-row-delete { opacity: 1; }
|
.doc-row:hover .doc-row-delete {
|
||||||
.doc-row-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); }
|
opacity: 1;
|
||||||
.doc-row-delete svg { width: 16px; height: 16px; }
|
}
|
||||||
|
.doc-row-delete:hover {
|
||||||
|
color: var(--error);
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
.doc-row-delete svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@
|
||||||
<div class="home-page">
|
<div class="home-page">
|
||||||
<div class="home-center">
|
<div class="home-center">
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<div class="hero-icon">
|
<img src="/logo.png" alt="Docling Studio" class="hero-logo" />
|
||||||
<span class="hero-d">D</span>
|
|
||||||
</div>
|
|
||||||
<h1 class="hero-title">{{ t('home.title') }}</h1>
|
<h1 class="hero-title">{{ t('home.title') }}</h1>
|
||||||
<p class="hero-subtitle">{{ t('home.subtitle') }}</p>
|
<p class="hero-subtitle">{{ t('home.subtitle') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -17,10 +15,24 @@
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="home-stats" v-if="docCount > 0 || analysisCount > 0">
|
<div class="home-stats" v-if="docCount > 0 || analysisCount > 0">
|
||||||
<div class="stat-card" @click="$router.push('/documents')">
|
<div class="stat-card" @click="$router.push('/documents')">
|
||||||
|
<svg class="stat-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
<div class="stat-value">{{ docCount }}</div>
|
<div class="stat-value">{{ docCount }}</div>
|
||||||
<div class="stat-label">{{ t('home.documents') }}</div>
|
<div class="stat-label">{{ t('home.documents') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card" @click="$router.push('/history')">
|
<div class="stat-card" @click="$router.push('/history')">
|
||||||
|
<svg class="stat-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
<div class="stat-value">{{ analysisCount }}</div>
|
<div class="stat-value">{{ analysisCount }}</div>
|
||||||
<div class="stat-label">{{ t('home.analyses') }}</div>
|
<div class="stat-label">{{ t('home.analyses') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -37,14 +49,22 @@
|
||||||
@click="openInStudio(doc)"
|
@click="openInStudio(doc)"
|
||||||
>
|
>
|
||||||
<svg class="recent-icon" viewBox="0 0 20 20" fill="currentColor">
|
<svg class="recent-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/>
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="recent-meta">
|
<div class="recent-meta">
|
||||||
<span class="recent-name">{{ doc.filename }}</span>
|
<span class="recent-name">{{ doc.filename }}</span>
|
||||||
<span class="recent-detail">{{ doc.pageCount ? doc.pageCount + ' pages' : '' }}</span>
|
<span class="recent-detail">{{ doc.pageCount ? doc.pageCount + ' pages' : '' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<svg class="recent-arrow" viewBox="0 0 20 20" fill="currentColor">
|
<svg class="recent-arrow" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -114,21 +134,12 @@ onMounted(() => {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-icon {
|
.hero-logo {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: contain;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
|
||||||
|
|
||||||
.hero-d {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
font-weight: 700;
|
|
||||||
font-size: 26px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-title {
|
.hero-title {
|
||||||
|
|
@ -171,10 +182,21 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card:hover {
|
.stat-card:hover {
|
||||||
border-color: var(--border-light);
|
border-color: var(--accent);
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover .stat-icon {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@
|
||||||
<!-- STATE 1: No document selected — Import view -->
|
<!-- STATE 1: No document selected — Import view -->
|
||||||
<div v-if="!selectedDoc" class="import-page">
|
<div v-if="!selectedDoc" class="import-page">
|
||||||
<div class="import-center">
|
<div class="import-center">
|
||||||
<div class="import-logo">
|
<img src="/logo.png" alt="Docling Studio" class="import-logo-img" />
|
||||||
<span class="logo-icon">D</span>
|
|
||||||
</div>
|
|
||||||
<h1 class="import-title">{{ t('studio.title') }}</h1>
|
<h1 class="import-title">{{ t('studio.title') }}</h1>
|
||||||
<p class="import-subtitle">{{ t('studio.subtitle') }}</p>
|
<p class="import-subtitle">{{ t('studio.subtitle') }}</p>
|
||||||
<DocumentUpload />
|
<DocumentUpload />
|
||||||
|
|
@ -27,6 +25,13 @@
|
||||||
:class="{ active: mode === 'configurer' }"
|
:class="{ active: mode === 'configurer' }"
|
||||||
@click="mode = 'configurer'"
|
@click="mode = 'configurer'"
|
||||||
>
|
>
|
||||||
|
<svg class="toggle-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
{{ t('studio.configure') }}
|
{{ t('studio.configure') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|
@ -35,13 +40,38 @@
|
||||||
@click="mode = 'verifier'"
|
@click="mode = 'verifier'"
|
||||||
:disabled="!analysisStore.currentAnalysis"
|
:disabled="!analysisStore.currentAnalysis"
|
||||||
>
|
>
|
||||||
|
<svg class="toggle-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
{{ t('studio.verify') }}
|
{{ t('studio.verify') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="chunkingEnabled"
|
||||||
|
class="toggle-btn"
|
||||||
|
:class="{ active: mode === 'preparer' }"
|
||||||
|
@click="mode = 'preparer'"
|
||||||
|
:disabled="!analysisStore.currentAnalysis"
|
||||||
|
>
|
||||||
|
<svg class="toggle-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ t('studio.prepare') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
<button class="topbar-btn" @click="addMore">
|
<button class="topbar-btn" @click="addMore">
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor" class="btn-icon"><path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"/></svg>
|
<svg viewBox="0 0 20 20" fill="currentColor" class="btn-icon">
|
||||||
|
<path
|
||||||
|
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
{{ t('studio.addFiles') }}
|
{{ t('studio.addFiles') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|
@ -51,7 +81,13 @@
|
||||||
v-if="mode === 'configurer'"
|
v-if="mode === 'configurer'"
|
||||||
>
|
>
|
||||||
<div v-if="analysisStore.running" class="spinner-sm" />
|
<div v-if="analysisStore.running" class="spinner-sm" />
|
||||||
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="btn-icon"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/></svg>
|
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="btn-icon">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
{{ analysisStore.running ? t('studio.analyzing') : t('studio.run') }}
|
{{ analysisStore.running ? t('studio.analyzing') : t('studio.run') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -60,7 +96,13 @@
|
||||||
<!-- Document info bar -->
|
<!-- Document info bar -->
|
||||||
<div class="doc-infobar">
|
<div class="doc-infobar">
|
||||||
<div class="doc-info-left">
|
<div class="doc-info-left">
|
||||||
<svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg>
|
<svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
<span class="doc-filename">{{ selectedDoc.filename }}</span>
|
<span class="doc-filename">{{ selectedDoc.filename }}</span>
|
||||||
<span class="doc-status-chip loaded">{{ t('studio.loaded') }}</span>
|
<span class="doc-status-chip loaded">{{ t('studio.loaded') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -86,7 +128,13 @@
|
||||||
<div class="pdf-viewer-panel">
|
<div class="pdf-viewer-panel">
|
||||||
<div class="pdf-nav-bar">
|
<div class="pdf-nav-bar">
|
||||||
<button class="pdf-nav-btn" :disabled="currentPage <= 1" @click="currentPage--">
|
<button class="pdf-nav-btn" :disabled="currentPage <= 1" @click="currentPage--">
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="pdf-page-input-wrap">
|
<div class="pdf-page-input-wrap">
|
||||||
<input
|
<input
|
||||||
|
|
@ -99,8 +147,18 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="pdf-page-total">/ {{ selectedDoc.pageCount || '?' }}</span>
|
<span class="pdf-page-total">/ {{ selectedDoc.pageCount || '?' }}</span>
|
||||||
<button class="pdf-nav-btn" :disabled="!selectedDoc.pageCount || currentPage >= selectedDoc.pageCount" @click="currentPage++">
|
<button
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
class="pdf-nav-btn"
|
||||||
|
:disabled="!selectedDoc.pageCount || currentPage >= selectedDoc.pageCount"
|
||||||
|
@click="currentPage++"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span class="pdf-separator" />
|
<span class="pdf-separator" />
|
||||||
<span class="pdf-zoom">100%</span>
|
<span class="pdf-zoom">100%</span>
|
||||||
|
|
@ -111,7 +169,14 @@
|
||||||
:class="{ active: visualMode }"
|
:class="{ active: visualMode }"
|
||||||
@click="visualMode = !visualMode"
|
@click="visualMode = !visualMode"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor" class="btn-icon"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg>
|
<svg viewBox="0 0 20 20" fill="currentColor" class="btn-icon">
|
||||||
|
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
{{ t('studio.visual') }}
|
{{ t('studio.visual') }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -127,11 +192,12 @@
|
||||||
@load="onPdfImageLoad"
|
@load="onPdfImageLoad"
|
||||||
/>
|
/>
|
||||||
<BboxOverlay
|
<BboxOverlay
|
||||||
v-if="visualMode && hasAnalysisResults"
|
v-if="(visualMode || mode === 'preparer') && hasAnalysisResults"
|
||||||
ref="bboxOverlayRef"
|
ref="bboxOverlayRef"
|
||||||
:image-el="pdfImageRef"
|
:image-el="pdfImageRef"
|
||||||
:page-data="currentPageData"
|
:page-data="currentPageData"
|
||||||
:highlighted-index="highlightedElementIndex"
|
:highlighted-index="highlightedElementIndex"
|
||||||
|
:highlighted-bboxes="highlightedChunkBboxes"
|
||||||
@highlight-element="highlightedElementIndex = $event"
|
@highlight-element="highlightedElementIndex = $event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -139,10 +205,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Resize handle -->
|
<!-- Resize handle -->
|
||||||
<div
|
<div class="resize-handle" @mousedown="onResizeStart">
|
||||||
class="resize-handle"
|
|
||||||
@mousedown="onResizeStart"
|
|
||||||
>
|
|
||||||
<div class="resize-grip" />
|
<div class="resize-grip" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -170,16 +233,26 @@
|
||||||
<span class="toggle-switch" />
|
<span class="toggle-switch" />
|
||||||
<span class="toggle-text">{{ t('config.ocr') }}</span>
|
<span class="toggle-text">{{ t('config.ocr') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="config-hint"><span class="config-tooltip">{{ t('config.ocrHint') }}</span>?</span>
|
<span class="config-hint"
|
||||||
|
><span class="config-tooltip">{{ t('config.ocrHint') }}</span
|
||||||
|
>?</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="config-toggle-row">
|
<div class="config-toggle-row">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<input type="checkbox" v-model="pipelineOptions.do_table_structure" class="toggle-input" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="pipelineOptions.do_table_structure"
|
||||||
|
class="toggle-input"
|
||||||
|
/>
|
||||||
<span class="toggle-switch" />
|
<span class="toggle-switch" />
|
||||||
<span class="toggle-text">{{ t('config.tableStructure') }}</span>
|
<span class="toggle-text">{{ t('config.tableStructure') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="config-hint"><span class="config-tooltip">{{ t('config.tableStructureHint') }}</span>?</span>
|
<span class="config-hint"
|
||||||
|
><span class="config-tooltip">{{ t('config.tableStructureHint') }}</span
|
||||||
|
>?</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="config-sub-option" v-if="pipelineOptions.do_table_structure">
|
<div class="config-sub-option" v-if="pipelineOptions.do_table_structure">
|
||||||
|
|
@ -197,20 +270,34 @@
|
||||||
|
|
||||||
<div class="config-toggle-row">
|
<div class="config-toggle-row">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<input type="checkbox" v-model="pipelineOptions.do_code_enrichment" class="toggle-input" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="pipelineOptions.do_code_enrichment"
|
||||||
|
class="toggle-input"
|
||||||
|
/>
|
||||||
<span class="toggle-switch" />
|
<span class="toggle-switch" />
|
||||||
<span class="toggle-text">{{ t('config.codeEnrichment') }}</span>
|
<span class="toggle-text">{{ t('config.codeEnrichment') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="config-hint"><span class="config-tooltip">{{ t('config.codeEnrichmentHint') }}</span>?</span>
|
<span class="config-hint"
|
||||||
|
><span class="config-tooltip">{{ t('config.codeEnrichmentHint') }}</span
|
||||||
|
>?</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="config-toggle-row">
|
<div class="config-toggle-row">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<input type="checkbox" v-model="pipelineOptions.do_formula_enrichment" class="toggle-input" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="pipelineOptions.do_formula_enrichment"
|
||||||
|
class="toggle-input"
|
||||||
|
/>
|
||||||
<span class="toggle-switch" />
|
<span class="toggle-switch" />
|
||||||
<span class="toggle-text">{{ t('config.formulaEnrichment') }}</span>
|
<span class="toggle-text">{{ t('config.formulaEnrichment') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="config-hint"><span class="config-tooltip">{{ t('config.formulaEnrichmentHint') }}</span>?</span>
|
<span class="config-hint"
|
||||||
|
><span class="config-tooltip">{{ t('config.formulaEnrichmentHint') }}</span
|
||||||
|
>?</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -220,41 +307,72 @@
|
||||||
|
|
||||||
<div class="config-toggle-row">
|
<div class="config-toggle-row">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<input type="checkbox" v-model="pipelineOptions.do_picture_classification" class="toggle-input" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="pipelineOptions.do_picture_classification"
|
||||||
|
class="toggle-input"
|
||||||
|
/>
|
||||||
<span class="toggle-switch" />
|
<span class="toggle-switch" />
|
||||||
<span class="toggle-text">{{ t('config.pictureClassification') }}</span>
|
<span class="toggle-text">{{ t('config.pictureClassification') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="config-hint"><span class="config-tooltip">{{ t('config.pictureClassificationHint') }}</span>?</span>
|
<span class="config-hint"
|
||||||
|
><span class="config-tooltip">{{ t('config.pictureClassificationHint') }}</span
|
||||||
|
>?</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="config-toggle-row">
|
<div class="config-toggle-row">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<input type="checkbox" v-model="pipelineOptions.do_picture_description" class="toggle-input" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="pipelineOptions.do_picture_description"
|
||||||
|
class="toggle-input"
|
||||||
|
/>
|
||||||
<span class="toggle-switch" />
|
<span class="toggle-switch" />
|
||||||
<span class="toggle-text">{{ t('config.pictureDescription') }}</span>
|
<span class="toggle-text">{{ t('config.pictureDescription') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="config-hint"><span class="config-tooltip">{{ t('config.pictureDescriptionHint') }}</span>?</span>
|
<span class="config-hint"
|
||||||
|
><span class="config-tooltip">{{ t('config.pictureDescriptionHint') }}</span
|
||||||
|
>?</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="config-toggle-row">
|
<div class="config-toggle-row">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<input type="checkbox" v-model="pipelineOptions.generate_picture_images" class="toggle-input" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="pipelineOptions.generate_picture_images"
|
||||||
|
class="toggle-input"
|
||||||
|
/>
|
||||||
<span class="toggle-switch" />
|
<span class="toggle-switch" />
|
||||||
<span class="toggle-text">{{ t('config.generatePictureImages') }}</span>
|
<span class="toggle-text">{{ t('config.generatePictureImages') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="config-hint"><span class="config-tooltip">{{ t('config.generatePictureImagesHint') }}</span>?</span>
|
<span class="config-hint"
|
||||||
|
><span class="config-tooltip">{{ t('config.generatePictureImagesHint') }}</span
|
||||||
|
>?</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="config-toggle-row">
|
<div class="config-toggle-row">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<input type="checkbox" v-model="pipelineOptions.generate_page_images" class="toggle-input" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="pipelineOptions.generate_page_images"
|
||||||
|
class="toggle-input"
|
||||||
|
/>
|
||||||
<span class="toggle-switch" />
|
<span class="toggle-switch" />
|
||||||
<span class="toggle-text">{{ t('config.generatePageImages') }}</span>
|
<span class="toggle-text">{{ t('config.generatePageImages') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="config-hint"><span class="config-tooltip">{{ t('config.generatePageImagesHint') }}</span>?</span>
|
<span class="config-hint"
|
||||||
|
><span class="config-tooltip">{{ t('config.generatePageImagesHint') }}</span
|
||||||
|
>?</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="config-sub-option" v-if="pipelineOptions.generate_picture_images || pipelineOptions.generate_page_images">
|
<div
|
||||||
|
class="config-sub-option"
|
||||||
|
v-if="pipelineOptions.generate_picture_images || pipelineOptions.generate_page_images"
|
||||||
|
>
|
||||||
<label class="config-label-sm">{{ t('config.imagesScale') }}</label>
|
<label class="config-label-sm">{{ t('config.imagesScale') }}</label>
|
||||||
<select class="config-select" v-model.number="pipelineOptions.images_scale">
|
<select class="config-select" v-model.number="pipelineOptions.images_scale">
|
||||||
<option :value="0.5">0.5x</option>
|
<option :value="0.5">0.5x</option>
|
||||||
|
|
@ -280,6 +398,14 @@
|
||||||
@highlight-element="highlightedElementIndex = $event"
|
@highlight-element="highlightedElementIndex = $event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- PREPARER MODE (feature-flipped) -->
|
||||||
|
<div v-if="mode === 'preparer' && chunkingEnabled" class="prepare-panel">
|
||||||
|
<ChunkPanel
|
||||||
|
:current-page="currentPage"
|
||||||
|
@highlight-bboxes="highlightedChunkBboxes = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -293,20 +419,24 @@ import { useAnalysisStore } from '../features/analysis/store'
|
||||||
import { DocumentUpload, DocumentList } from '../features/document/index'
|
import { DocumentUpload, DocumentList } from '../features/document/index'
|
||||||
import { ResultTabs } from '../features/analysis/index'
|
import { ResultTabs } from '../features/analysis/index'
|
||||||
import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue'
|
import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue'
|
||||||
|
import { ChunkPanel } from '../features/chunking'
|
||||||
|
import { useFeatureFlag } from '../features/feature-flags'
|
||||||
import { getPreviewUrl } from '../features/document/api'
|
import { getPreviewUrl } from '../features/document/api'
|
||||||
import { useI18n } from '../shared/i18n'
|
import { useI18n } from '../shared/i18n'
|
||||||
import type { PipelineOptions } from '../shared/types'
|
import type { ChunkBbox, PipelineOptions } from '../shared/types'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const documentStore = useDocumentStore()
|
const documentStore = useDocumentStore()
|
||||||
const analysisStore = useAnalysisStore()
|
const analysisStore = useAnalysisStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const chunkingEnabled = useFeatureFlag('chunking')
|
||||||
|
|
||||||
const mode = ref('configurer')
|
const mode = ref('configurer')
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const visualMode = ref(false)
|
const visualMode = ref(false)
|
||||||
const highlightedElementIndex = ref(-1)
|
const highlightedElementIndex = ref(-1)
|
||||||
|
const highlightedChunkBboxes = ref<ChunkBbox[]>([])
|
||||||
const pdfImageRef = ref<HTMLImageElement | null>(null)
|
const pdfImageRef = ref<HTMLImageElement | null>(null)
|
||||||
const bboxOverlayRef = ref<InstanceType<typeof BboxOverlay> | null>(null)
|
const bboxOverlayRef = ref<InstanceType<typeof BboxOverlay> | null>(null)
|
||||||
|
|
||||||
|
|
@ -356,12 +486,14 @@ const pipelineOptions = reactive<PipelineOptions>({
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasAnalysisResults = computed(() => {
|
const hasAnalysisResults = computed(() => {
|
||||||
return analysisStore.currentAnalysis?.status === 'COMPLETED' && analysisStore.currentPages?.length > 0
|
return (
|
||||||
|
analysisStore.currentAnalysis?.status === 'COMPLETED' && analysisStore.currentPages?.length > 0
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentPageData = computed(() => {
|
const currentPageData = computed(() => {
|
||||||
if (!analysisStore.currentPages) return null
|
if (!analysisStore.currentPages) return null
|
||||||
return analysisStore.currentPages.find(p => p.page_number === currentPage.value) || null
|
return analysisStore.currentPages.find((p) => p.page_number === currentPage.value) || null
|
||||||
})
|
})
|
||||||
|
|
||||||
function onPdfImageLoad() {
|
function onPdfImageLoad() {
|
||||||
|
|
@ -369,7 +501,7 @@ function onPdfImageLoad() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedDoc = computed(() => {
|
const selectedDoc = computed(() => {
|
||||||
return documentStore.documents.find(d => d.id === documentStore.selectedId)
|
return documentStore.documents.find((d) => d.id === documentStore.selectedId)
|
||||||
})
|
})
|
||||||
|
|
||||||
const previewUrl = computed(() => {
|
const previewUrl = computed(() => {
|
||||||
|
|
@ -393,13 +525,26 @@ function addMore() {
|
||||||
documentStore.selectedId = null
|
documentStore.selectedId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-switch to verifier when analysis completes + refresh document data (pageCount)
|
// Clear highlights when switching modes or pages
|
||||||
watch(() => analysisStore.currentAnalysis?.status, (status) => {
|
watch(mode, () => {
|
||||||
if (status === 'COMPLETED') {
|
highlightedElementIndex.value = -1
|
||||||
mode.value = 'verifier'
|
highlightedChunkBboxes.value = []
|
||||||
documentStore.load()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
watch(currentPage, () => {
|
||||||
|
highlightedElementIndex.value = -1
|
||||||
|
highlightedChunkBboxes.value = []
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-switch to verifier when analysis completes + refresh document data (pageCount)
|
||||||
|
watch(
|
||||||
|
() => analysisStore.currentAnalysis?.status,
|
||||||
|
(status) => {
|
||||||
|
if (status === 'COMPLETED') {
|
||||||
|
mode.value = 'verifier'
|
||||||
|
documentStore.load()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await documentStore.load()
|
await documentStore.load()
|
||||||
|
|
@ -447,21 +592,12 @@ onBeforeUnmount(() => {
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-logo {
|
.import-logo-img {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: contain;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
|
||||||
|
|
||||||
.import-logo .logo-icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-weight: 700;
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-title {
|
.import-title {
|
||||||
|
|
@ -516,45 +652,72 @@ onBeforeUnmount(() => {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-title {
|
.topbar-title {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-toggle {
|
.mode-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
overflow: hidden;
|
padding: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-btn {
|
.toggle-btn {
|
||||||
padding: 6px 16px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-muted);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
|
border-radius: calc(var(--radius-sm) - 2px);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--transition);
|
transition: all 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-btn:hover:not(:disabled) {
|
.toggle-btn:hover:not(:disabled) {
|
||||||
color: var(--text);
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn:hover:not(:disabled) .toggle-icon {
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-btn.active {
|
.toggle-btn.active {
|
||||||
background: var(--bg);
|
background: var(--accent-muted);
|
||||||
color: var(--text);
|
color: var(--accent);
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn.active .toggle-icon {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-btn:disabled {
|
.toggle-btn:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.35;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -607,7 +770,7 @@ onBeforeUnmount(() => {
|
||||||
.spinner-sm {
|
.spinner-sm {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border: 2px solid rgba(255,255,255,0.3);
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
border-top-color: white;
|
border-top-color: white;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.6s linear infinite;
|
animation: spin 0.6s linear infinite;
|
||||||
|
|
@ -679,8 +842,12 @@ onBeforeUnmount(() => {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-dot.success { background: var(--success); }
|
.info-dot.success {
|
||||||
.info-dot.error { background: var(--error); }
|
background: var(--success);
|
||||||
|
}
|
||||||
|
.info-dot.error {
|
||||||
|
background: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
.spinner-xs {
|
.spinner-xs {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
|
|
@ -779,9 +946,18 @@ onBeforeUnmount(() => {
|
||||||
transition: all var(--transition);
|
transition: all var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdf-nav-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
|
.pdf-nav-btn:hover:not(:disabled) {
|
||||||
.pdf-nav-btn:disabled { opacity: 0.3; cursor: default; }
|
background: var(--bg-hover);
|
||||||
.pdf-nav-btn svg { width: 16px; height: 16px; }
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.pdf-nav-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.pdf-nav-btn svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.pdf-page-input-wrap {
|
.pdf-page-input-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -880,7 +1056,7 @@ onBeforeUnmount(() => {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
display: block;
|
display: block;
|
||||||
box-shadow: 0 2px 20px rgba(0,0,0,0.4);
|
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.4);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1119,12 +1295,17 @@ onBeforeUnmount(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Verify panel */
|
/* Verify panel */
|
||||||
.verify-panel {
|
.verify-panel,
|
||||||
|
.prepare-panel {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,12 @@ describe('apiFetch', () => {
|
||||||
|
|
||||||
await apiFetch('/api/test')
|
await apiFetch('/api/test')
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledWith('/api/test', expect.objectContaining({
|
expect(spy).toHaveBeenCalledWith(
|
||||||
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
|
'/api/test',
|
||||||
}))
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('skips Content-Type when skipContentType is true', async () => {
|
it('skips Content-Type when skipContentType is true', async () => {
|
||||||
|
|
@ -73,10 +76,13 @@ describe('apiFetch', () => {
|
||||||
const body = JSON.stringify({ documentId: '42' })
|
const body = JSON.stringify({ documentId: '42' })
|
||||||
await apiFetch('/api/analyses', { method: 'POST', body })
|
await apiFetch('/api/analyses', { method: 'POST', body })
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledWith('/api/analyses', expect.objectContaining({
|
expect(spy).toHaveBeenCalledWith(
|
||||||
method: 'POST',
|
'/api/analyses',
|
||||||
body,
|
expect.objectContaining({
|
||||||
}))
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('merges custom headers with Content-Type', async () => {
|
it('merges custom headers with Content-Type', async () => {
|
||||||
|
|
|
||||||
149
frontend/src/shared/composables/usePagination.test.ts
Normal file
149
frontend/src/shared/composables/usePagination.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
71
frontend/src/shared/composables/usePagination.ts
Normal file
71
frontend/src/shared/composables/usePagination.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,8 @@ const messages: Messages = {
|
||||||
|
|
||||||
// Home
|
// Home
|
||||||
'home.title': 'Docling Studio',
|
'home.title': 'Docling Studio',
|
||||||
'home.subtitle': 'Analysez, explorez et validez la structure de vos documents PDF grâce à Docling.',
|
'home.subtitle':
|
||||||
|
'Analysez, explorez et validez la structure de vos documents PDF grâce à Docling.',
|
||||||
'home.documents': 'Documents',
|
'home.documents': 'Documents',
|
||||||
'home.analyses': 'Analyses',
|
'home.analyses': 'Analyses',
|
||||||
'home.recentDocs': 'Documents récents',
|
'home.recentDocs': 'Documents récents',
|
||||||
|
|
@ -45,26 +46,34 @@ const messages: Messages = {
|
||||||
'config.model': 'Modèle',
|
'config.model': 'Modèle',
|
||||||
'config.pipeline': 'Pipeline',
|
'config.pipeline': 'Pipeline',
|
||||||
'config.ocr': 'OCR',
|
'config.ocr': 'OCR',
|
||||||
'config.ocrHint': "Applique la reconnaissance optique de caractères sur les pages scannées ou les images intégrées. Indispensable pour les PDF non-natifs.",
|
'config.ocrHint':
|
||||||
|
'Applique la reconnaissance optique de caractères sur les pages scannées ou les images intégrées. Indispensable pour les PDF non-natifs.',
|
||||||
'config.tableStructure': 'Extraction des tableaux',
|
'config.tableStructure': 'Extraction des tableaux',
|
||||||
'config.tableStructureHint': "Détecte les tableaux dans le document et reconstruit leur structure lignes/colonnes via le modèle TableFormer, avec correspondance des cellules.",
|
'config.tableStructureHint':
|
||||||
|
'Détecte les tableaux dans le document et reconstruit leur structure lignes/colonnes via le modèle TableFormer, avec correspondance des cellules.',
|
||||||
'config.tableMode': 'Mode tableaux',
|
'config.tableMode': 'Mode tableaux',
|
||||||
'config.tableModeAccurate': 'Précis',
|
'config.tableModeAccurate': 'Précis',
|
||||||
'config.tableModeFast': 'Rapide',
|
'config.tableModeFast': 'Rapide',
|
||||||
'config.enrichment': 'Enrichissement',
|
'config.enrichment': 'Enrichissement',
|
||||||
'config.codeEnrichment': 'Code',
|
'config.codeEnrichment': 'Code',
|
||||||
'config.codeEnrichmentHint': "Active un modèle OCR spécialisé pour les blocs de code, préservant l'indentation et la syntaxe.",
|
'config.codeEnrichmentHint':
|
||||||
|
"Active un modèle OCR spécialisé pour les blocs de code, préservant l'indentation et la syntaxe.",
|
||||||
'config.formulaEnrichment': 'Formules',
|
'config.formulaEnrichment': 'Formules',
|
||||||
'config.formulaEnrichmentHint': "Reconnaît les formules mathématiques et les convertit en LaTeX via un modèle dédié.",
|
'config.formulaEnrichmentHint':
|
||||||
|
'Reconnaît les formules mathématiques et les convertit en LaTeX via un modèle dédié.',
|
||||||
'config.pictures': 'Images',
|
'config.pictures': 'Images',
|
||||||
'config.pictureClassification': 'Classification',
|
'config.pictureClassification': 'Classification',
|
||||||
'config.pictureClassificationHint': "Classe chaque image détectée par type (graphique, photo, diagramme, logo…) via un modèle de classification.",
|
'config.pictureClassificationHint':
|
||||||
|
'Classe chaque image détectée par type (graphique, photo, diagramme, logo…) via un modèle de classification.',
|
||||||
'config.pictureDescription': 'Description',
|
'config.pictureDescription': 'Description',
|
||||||
'config.pictureDescriptionHint': "Génère une description textuelle de chaque image via un Vision Language Model (VLM). Utile pour l'accessibilité et l'indexation.",
|
'config.pictureDescriptionHint':
|
||||||
|
"Génère une description textuelle de chaque image via un Vision Language Model (VLM). Utile pour l'accessibilité et l'indexation.",
|
||||||
'config.generatePictureImages': 'Extraire les images',
|
'config.generatePictureImages': 'Extraire les images',
|
||||||
'config.generatePictureImagesHint': "Extrait les images détectées du document et les sauvegarde en tant que fichiers séparés. Nécessaire pour l'export d'images.",
|
'config.generatePictureImagesHint':
|
||||||
|
"Extrait les images détectées du document et les sauvegarde en tant que fichiers séparés. Nécessaire pour l'export d'images.",
|
||||||
'config.generatePageImages': 'Images de pages',
|
'config.generatePageImages': 'Images de pages',
|
||||||
'config.generatePageImagesHint': "Rasterise chaque page du PDF en image. Utile pour la visualisation ou le post-traitement visuel.",
|
'config.generatePageImagesHint':
|
||||||
|
'Rasterise chaque page du PDF en image. Utile pour la visualisation ou le post-traitement visuel.',
|
||||||
'config.imagesScale': 'Échelle images',
|
'config.imagesScale': 'Échelle images',
|
||||||
'config.documents': 'Documents',
|
'config.documents': 'Documents',
|
||||||
|
|
||||||
|
|
@ -95,6 +104,23 @@ const messages: Messages = {
|
||||||
'history.emptyDocs': 'Aucun document. Importez un document depuis le Studio.',
|
'history.emptyDocs': 'Aucun document. Importez un document depuis le Studio.',
|
||||||
'history.open': 'Ouvrir',
|
'history.open': 'Ouvrir',
|
||||||
|
|
||||||
|
// Chunking
|
||||||
|
'studio.prepare': 'Préparer',
|
||||||
|
'chunking.settings': 'Chunking',
|
||||||
|
'chunking.chunkerType': 'Type de chunker',
|
||||||
|
'chunking.maxTokens': 'Tokens max',
|
||||||
|
'chunking.mergePeers': 'Fusionner les pairs',
|
||||||
|
'chunking.repeatTableHeader': 'Répéter en-têtes tableaux',
|
||||||
|
'chunking.run': 'Chunker',
|
||||||
|
'chunking.chunking': 'Chunking...',
|
||||||
|
'chunking.chunks': 'chunks',
|
||||||
|
'chunking.noChunks': 'Lancez le chunking pour préparer les segments.',
|
||||||
|
'chunking.noChunksOnPage': 'Aucun chunk sur cette page.',
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
'pagination.pageOf': 'Page {current} sur {total}',
|
||||||
|
'pagination.perPage': '/ page',
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
'settings.title': 'Paramètres',
|
'settings.title': 'Paramètres',
|
||||||
'settings.apiUrl': 'API URL',
|
'settings.apiUrl': 'API URL',
|
||||||
|
|
@ -103,6 +129,10 @@ const messages: Messages = {
|
||||||
'settings.themeDark': 'Sombre',
|
'settings.themeDark': 'Sombre',
|
||||||
'settings.themeLight': 'Clair',
|
'settings.themeLight': 'Clair',
|
||||||
'settings.language': 'Langue',
|
'settings.language': 'Langue',
|
||||||
|
|
||||||
|
// Disclaimer
|
||||||
|
'disclaimer.banner':
|
||||||
|
'Instance de d\u00e9monstration \u2014 les documents upload\u00e9s sont partag\u00e9s et temporaires (max 50 Mo). Ne pas envoyer de fichiers confidentiels.',
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
'nav.home': 'Home',
|
'nav.home': 'Home',
|
||||||
|
|
@ -116,7 +146,8 @@ const messages: Messages = {
|
||||||
'topbar.newAnalysis': 'New analysis',
|
'topbar.newAnalysis': 'New analysis',
|
||||||
|
|
||||||
'home.title': 'Docling Studio',
|
'home.title': 'Docling Studio',
|
||||||
'home.subtitle': 'Analyze, explore and validate the structure of your PDF documents with Docling.',
|
'home.subtitle':
|
||||||
|
'Analyze, explore and validate the structure of your PDF documents with Docling.',
|
||||||
'home.documents': 'Documents',
|
'home.documents': 'Documents',
|
||||||
'home.analyses': 'Analyses',
|
'home.analyses': 'Analyses',
|
||||||
'home.recentDocs': 'Recent documents',
|
'home.recentDocs': 'Recent documents',
|
||||||
|
|
@ -138,26 +169,34 @@ const messages: Messages = {
|
||||||
'config.model': 'Model',
|
'config.model': 'Model',
|
||||||
'config.pipeline': 'Pipeline',
|
'config.pipeline': 'Pipeline',
|
||||||
'config.ocr': 'OCR',
|
'config.ocr': 'OCR',
|
||||||
'config.ocrHint': 'Applies Optical Character Recognition on scanned pages or embedded images. Essential for non-native PDFs.',
|
'config.ocrHint':
|
||||||
|
'Applies Optical Character Recognition on scanned pages or embedded images. Essential for non-native PDFs.',
|
||||||
'config.tableStructure': 'Table extraction',
|
'config.tableStructure': 'Table extraction',
|
||||||
'config.tableStructureHint': 'Detects tables in the document and reconstructs their row/column structure using the TableFormer model, with cell matching.',
|
'config.tableStructureHint':
|
||||||
|
'Detects tables in the document and reconstructs their row/column structure using the TableFormer model, with cell matching.',
|
||||||
'config.tableMode': 'Table mode',
|
'config.tableMode': 'Table mode',
|
||||||
'config.tableModeAccurate': 'Accurate',
|
'config.tableModeAccurate': 'Accurate',
|
||||||
'config.tableModeFast': 'Fast',
|
'config.tableModeFast': 'Fast',
|
||||||
'config.enrichment': 'Enrichment',
|
'config.enrichment': 'Enrichment',
|
||||||
'config.codeEnrichment': 'Code',
|
'config.codeEnrichment': 'Code',
|
||||||
'config.codeEnrichmentHint': 'Activates a specialized OCR model for code blocks, preserving indentation and syntax.',
|
'config.codeEnrichmentHint':
|
||||||
|
'Activates a specialized OCR model for code blocks, preserving indentation and syntax.',
|
||||||
'config.formulaEnrichment': 'Formulas',
|
'config.formulaEnrichment': 'Formulas',
|
||||||
'config.formulaEnrichmentHint': 'Recognizes mathematical formulas and converts them to LaTeX using a dedicated model.',
|
'config.formulaEnrichmentHint':
|
||||||
|
'Recognizes mathematical formulas and converts them to LaTeX using a dedicated model.',
|
||||||
'config.pictures': 'Pictures',
|
'config.pictures': 'Pictures',
|
||||||
'config.pictureClassification': 'Classification',
|
'config.pictureClassification': 'Classification',
|
||||||
'config.pictureClassificationHint': 'Classifies each detected image by type (chart, photo, diagram, logo…) using a classification model.',
|
'config.pictureClassificationHint':
|
||||||
|
'Classifies each detected image by type (chart, photo, diagram, logo…) using a classification model.',
|
||||||
'config.pictureDescription': 'Description',
|
'config.pictureDescription': 'Description',
|
||||||
'config.pictureDescriptionHint': 'Generates a text description for each image using a Vision Language Model (VLM). Useful for accessibility and indexing.',
|
'config.pictureDescriptionHint':
|
||||||
|
'Generates a text description for each image using a Vision Language Model (VLM). Useful for accessibility and indexing.',
|
||||||
'config.generatePictureImages': 'Extract pictures',
|
'config.generatePictureImages': 'Extract pictures',
|
||||||
'config.generatePictureImagesHint': 'Extracts detected images from the document and saves them as separate files. Required for image export.',
|
'config.generatePictureImagesHint':
|
||||||
|
'Extracts detected images from the document and saves them as separate files. Required for image export.',
|
||||||
'config.generatePageImages': 'Page images',
|
'config.generatePageImages': 'Page images',
|
||||||
'config.generatePageImagesHint': 'Rasterizes each PDF page as an image. Useful for visual preview or post-processing.',
|
'config.generatePageImagesHint':
|
||||||
|
'Rasterizes each PDF page as an image. Useful for visual preview or post-processing.',
|
||||||
'config.imagesScale': 'Images scale',
|
'config.imagesScale': 'Images scale',
|
||||||
'config.documents': 'Documents',
|
'config.documents': 'Documents',
|
||||||
|
|
||||||
|
|
@ -185,6 +224,21 @@ const messages: Messages = {
|
||||||
'history.emptyDocs': 'No documents yet. Upload a document from the Studio.',
|
'history.emptyDocs': 'No documents yet. Upload a document from the Studio.',
|
||||||
'history.open': 'Open',
|
'history.open': 'Open',
|
||||||
|
|
||||||
|
'studio.prepare': 'Prepare',
|
||||||
|
'chunking.settings': 'Chunking',
|
||||||
|
'chunking.chunkerType': 'Chunker type',
|
||||||
|
'chunking.maxTokens': 'Max tokens',
|
||||||
|
'chunking.mergePeers': 'Merge peers',
|
||||||
|
'chunking.repeatTableHeader': 'Repeat table headers',
|
||||||
|
'chunking.run': 'Chunk',
|
||||||
|
'chunking.chunking': 'Chunking...',
|
||||||
|
'chunking.chunks': 'chunks',
|
||||||
|
'chunking.noChunks': 'Run chunking to prepare segments.',
|
||||||
|
'chunking.noChunksOnPage': 'No chunks on this page.',
|
||||||
|
|
||||||
|
'pagination.pageOf': 'Page {current} of {total}',
|
||||||
|
'pagination.perPage': '/ page',
|
||||||
|
|
||||||
'settings.title': 'Settings',
|
'settings.title': 'Settings',
|
||||||
'settings.apiUrl': 'API URL',
|
'settings.apiUrl': 'API URL',
|
||||||
'settings.version': 'Version',
|
'settings.version': 'Version',
|
||||||
|
|
@ -192,6 +246,10 @@ const messages: Messages = {
|
||||||
'settings.themeDark': 'Dark',
|
'settings.themeDark': 'Dark',
|
||||||
'settings.themeLight': 'Light',
|
'settings.themeLight': 'Light',
|
||||||
'settings.language': 'Language',
|
'settings.language': 'Language',
|
||||||
|
|
||||||
|
// Disclaimer
|
||||||
|
'disclaimer.banner':
|
||||||
|
'Demo instance \u2014 uploaded documents are shared and temporary (max 50 MB). Do not upload confidential files.',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,7 +259,7 @@ export function useI18n() {
|
||||||
function t(key: string, params: Record<string, string | number> = {}): string {
|
function t(key: string, params: Record<string, string | number> = {}): string {
|
||||||
let str = messages[settings.locale]?.[key] || messages['fr'][key] || key
|
let str = messages[settings.locale]?.[key] || messages['fr'][key] || key
|
||||||
for (const [k, v] of Object.entries(params)) {
|
for (const [k, v] of Object.entries(params)) {
|
||||||
str = str.replace(`{${k}}`, String(v))
|
str = str.replaceAll(`{${k}}`, String(v))
|
||||||
}
|
}
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,34 @@ export interface Analysis {
|
||||||
contentMarkdown: string | null
|
contentMarkdown: string | null
|
||||||
contentHtml: string | null
|
contentHtml: string | null
|
||||||
pagesJson: string | null
|
pagesJson: string | null
|
||||||
|
chunksJson: string | null
|
||||||
|
hasDocumentJson: boolean
|
||||||
errorMessage: string | null
|
errorMessage: string | null
|
||||||
startedAt: string | null
|
startedAt: string | null
|
||||||
completedAt: string | null
|
completedAt: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChunkingOptions {
|
||||||
|
chunker_type?: 'hybrid' | 'hierarchical'
|
||||||
|
max_tokens?: number
|
||||||
|
merge_peers?: boolean
|
||||||
|
repeat_table_header?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChunkBbox {
|
||||||
|
page: number
|
||||||
|
bbox: [number, number, number, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Chunk {
|
||||||
|
text: string
|
||||||
|
headings: string[]
|
||||||
|
sourcePage: number | null
|
||||||
|
tokenCount: number
|
||||||
|
bboxes: ChunkBbox[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface PageElement {
|
export interface PageElement {
|
||||||
type: string
|
type: string
|
||||||
bbox: [number, number, number, number]
|
bbox: [number, number, number, number]
|
||||||
|
|
|
||||||
|
|
@ -2,33 +2,71 @@
|
||||||
<aside class="sidebar" :class="{ open }">
|
<aside class="sidebar" :class="{ open }">
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<RouterLink to="/" class="nav-item" :class="{ active: route.name === 'home' }">
|
<RouterLink to="/" class="nav-item" :class="{ active: route.name === 'home' }">
|
||||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"/></svg>
|
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
<span class="nav-label">{{ t('nav.home') }}</span>
|
<span class="nav-label">{{ t('nav.home') }}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<RouterLink to="/studio" class="nav-item" :class="{ active: route.name === 'studio' }">
|
<RouterLink to="/studio" class="nav-item" :class="{ active: route.name === 'studio' }">
|
||||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838l-2.727 1.17 1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zm5.99 7.176A9.026 9.026 0 007 15.96v-4.5l.61.26a2.5 2.5 0 001.98 0l.61-.26v4.5a9.026 9.026 0 00-1.7.613z"/></svg>
|
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838l-2.727 1.17 1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zm5.99 7.176A9.026 9.026 0 007 15.96v-4.5l.61.26a2.5 2.5 0 001.98 0l.61-.26v4.5a9.026 9.026 0 00-1.7.613z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
<span class="nav-label">{{ t('nav.studio') }}</span>
|
<span class="nav-label">{{ t('nav.studio') }}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<RouterLink to="/documents" class="nav-item" :class="{ active: route.name === 'documents' }">
|
<RouterLink to="/documents" class="nav-item" :class="{ active: route.name === 'documents' }">
|
||||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg>
|
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
<span class="nav-label">{{ t('nav.documents') }}</span>
|
<span class="nav-label">{{ t('nav.documents') }}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<RouterLink to="/history" class="nav-item" :class="{ active: route.name === 'history' }">
|
<RouterLink to="/history" class="nav-item" :class="{ active: route.name === 'history' }">
|
||||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
|
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
<span class="nav-label">{{ t('nav.history') }}</span>
|
<span class="nav-label">{{ t('nav.history') }}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<RouterLink to="/settings" class="nav-item" :class="{ active: route.name === 'settings' }">
|
<RouterLink to="/settings" class="nav-item" :class="{ active: route.name === 'settings' }">
|
||||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/></svg>
|
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
<span class="nav-label">{{ t('nav.settings') }}</span>
|
<span class="nav-label">{{ t('nav.settings') }}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<span class="version">v0.1.0</span>
|
<a
|
||||||
|
class="github-badge"
|
||||||
|
href="https://github.com/scub-france/Docling-Studio"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="https://img.shields.io/github/stars/scub-france/Docling-Studio?style=social"
|
||||||
|
alt="GitHub Stars"
|
||||||
|
height="20"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<span class="version">v{{ version }}</span>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -37,11 +75,12 @@
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
|
|
||||||
|
const version = __APP_VERSION__
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
open: { type: Boolean, default: false }
|
open: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -55,7 +94,9 @@ defineProps({
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 0;
|
width: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
transition: width 250ms ease, min-width 250ms ease;
|
transition:
|
||||||
|
width 250ms ease,
|
||||||
|
min-width 250ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.open {
|
.sidebar.open {
|
||||||
|
|
@ -111,6 +152,17 @@ defineProps({
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.github-badge {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-badge:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.version {
|
.version {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|
|
||||||
135
frontend/src/shared/ui/PaginationBar.vue
Normal file
135
frontend/src/shared/ui/PaginationBar.vue
Normal 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>
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export { default as AppSidebar } from './AppSidebar.vue'
|
export { default as AppSidebar } from './AppSidebar.vue'
|
||||||
|
export { default as PaginationBar } from './PaginationBar.vue'
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,24 @@
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
|
||||||
|
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || pkg.version),
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8000',
|
target: 'http://localhost:8000',
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/health': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue