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_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:
|
||||
push:
|
||||
|
|
@ -11,12 +11,16 @@ env:
|
|||
|
||||
jobs:
|
||||
release:
|
||||
name: Build & push Docker image
|
||||
name: Build & push — ${{ matrix.target }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
target: [remote, local]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
|
@ -28,22 +32,28 @@ jobs:
|
|||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from tag
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
type=semver,pattern={{version}},suffix=-${{ matrix.target }}
|
||||
type=semver,pattern={{major}}.{{minor}},suffix=-${{ matrix.target }}
|
||||
type=raw,value=latest-${{ matrix.target }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: ${{ matrix.target }}
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: APP_VERSION=${{ steps.version.outputs.value }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=gha,scope=${{ matrix.target }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -11,6 +11,8 @@ site/
|
|||
.vscode/
|
||||
.run/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
**/CLAUDE.md
|
||||
*.iml
|
||||
|
||||
# OS
|
||||
|
|
@ -32,6 +34,9 @@ __pycache__/
|
|||
.venv/
|
||||
*.egg-info/
|
||||
|
||||
# Excalidraw working files
|
||||
docs/excalidraw/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
hs_err_pid*
|
||||
|
|
|
|||
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/).
|
||||
|
||||
## [0.3.0] - 2026-04-07
|
||||
|
||||
### Added
|
||||
|
||||
- Chunking support: domain objects, persistence, API endpoints, and frontend Prepare mode
|
||||
- Chunk-to-bbox hover highlighting in Prepare mode
|
||||
- Page filtering and collapsible config in Prepare mode
|
||||
- Feature flipping mechanism
|
||||
- Reusable pagination composable and PaginationBar component
|
||||
- Version display in sidebar, settings page, and health endpoint
|
||||
|
||||
### Fixed
|
||||
|
||||
- Feature flag health check blocked by CORS
|
||||
- Zombie jobs and unprotected JSON parse
|
||||
- Upload error not displayed in DocumentUpload component
|
||||
- Serve API contract: send `to_formats` as repeated form fields
|
||||
- Audit findings: security, robustness, dead code, domain-infra violation
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactored backend to hexagonal architecture for converter extensibility
|
||||
- Added ServeConverter adapter for remote Docling Serve integration
|
||||
- Moved `@vitest/mocker` from dependencies to devDependencies
|
||||
|
||||
## [0.2.0] - 2025-05-14
|
||||
|
||||
### Added
|
||||
|
||||
- Multi-arch Docker image release pipeline (GitHub Actions)
|
||||
- Docker image published to `ghcr.io/scub-france/Docling-Studio`
|
||||
|
||||
## [0.1.0] - 2025-01-01
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -22,7 +22,13 @@ Thank you for your interest in contributing to Docling Studio! This guide will h
|
|||
```bash
|
||||
cd document-parser
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
|
||||
# Remote mode (lightweight — delegates to Docling Serve)
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Local mode (full — runs Docling in-process)
|
||||
pip install -r requirements-local.txt
|
||||
|
||||
pip install ruff pytest pytest-asyncio httpx # dev tools
|
||||
uvicorn main:app --reload --port 8000
|
||||
```
|
||||
|
|
@ -61,11 +67,11 @@ npx prettier --write src/ # auto-format
|
|||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Backend (99 tests)
|
||||
# Backend (199 tests)
|
||||
cd document-parser
|
||||
pytest tests/ -v
|
||||
|
||||
# Frontend (81 tests)
|
||||
# Frontend (129 tests)
|
||||
cd frontend
|
||||
npm run test:run
|
||||
```
|
||||
|
|
@ -80,6 +86,79 @@ All tests must pass before submitting a PR.
|
|||
4. Describe **what** changed and **why** in the PR description
|
||||
5. Ensure CI passes (tests + build)
|
||||
|
||||
## Branching Strategy
|
||||
|
||||
We follow a simplified Git Flow:
|
||||
|
||||
| Branch | Purpose |
|
||||
|--------|---------|
|
||||
| `main` | Always stable — latest release merged back |
|
||||
| `release/X.Y.Z` | Release preparation (freeze, bugfixes, changelog) |
|
||||
| `feature/*` | New features — PR to `main` |
|
||||
| `fix/*` | Bug fixes — PR to `main` (or `release/*` for pre-release fixes) |
|
||||
| `hotfix/X.Y.Z` | Urgent fix on a released version — PR to `main` |
|
||||
|
||||
Rules:
|
||||
- All PRs target `main` (never stack branches on other feature branches)
|
||||
- `release/*` branches are created from `main` when preparing a release
|
||||
- `hotfix/*` branches are created from the release tag
|
||||
|
||||
## Versioning
|
||||
|
||||
We use [Semantic Versioning](https://semver.org/): `MAJOR.MINOR.PATCH`.
|
||||
|
||||
- **Source of truth**: the git tag (`vX.Y.Z`)
|
||||
- `package.json` version should match the current release branch
|
||||
- The build injects the version automatically (Vite `__APP_VERSION__` for frontend, `APP_VERSION` env var for backend)
|
||||
|
||||
## Release Process
|
||||
|
||||
1. **Create the release branch** from `main`:
|
||||
```bash
|
||||
git checkout main && git pull
|
||||
git checkout -b release/X.Y.Z
|
||||
```
|
||||
|
||||
2. **On the release branch**, only:
|
||||
- Bug fixes
|
||||
- Move `[Unreleased]` to `[X.Y.Z] - YYYY-MM-DD` in `CHANGELOG.md`
|
||||
- Update `version` in `frontend/package.json`
|
||||
|
||||
3. **Merge into `main`** via PR, then **tag on `main`**:
|
||||
```bash
|
||||
git checkout main && git pull
|
||||
git tag vX.Y.Z
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
4. The tag triggers the **release workflow** which builds and pushes the Docker image to `ghcr.io`.
|
||||
|
||||
### Docker Image Tags
|
||||
|
||||
Each release produces two image variants:
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `X.Y.Z-remote` | Exact version — lightweight (Docling Serve) |
|
||||
| `X.Y.Z-local` | Exact version — full (in-process Docling) |
|
||||
| `X.Y-remote` | Latest patch of this minor — lightweight |
|
||||
| `X.Y-local` | Latest patch of this minor — full |
|
||||
| `latest-remote` | Latest stable — lightweight |
|
||||
| `latest-local` | Latest stable — full |
|
||||
|
||||
### Hotfix
|
||||
|
||||
```bash
|
||||
git checkout vX.Y.Z # from the release tag
|
||||
git checkout -b hotfix/X.Y.Z+1
|
||||
# fix, commit, PR to main
|
||||
git tag vX.Y.Z+1 # tag on main after merge
|
||||
```
|
||||
|
||||
### Changelog
|
||||
|
||||
We follow [Keep a Changelog](https://keepachangelog.com/). Every PR should add a line under `[Unreleased]` in `CHANGELOG.md`. The release branch moves `[Unreleased]` to the versioned section.
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
- Keep PRs focused — one feature or fix per PR
|
||||
|
|
|
|||
44
Dockerfile
44
Dockerfile
|
|
@ -1,33 +1,36 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
# =============================================================================
|
||||
# Docling Studio — single-image build (frontend + backend)
|
||||
# Docling Studio — single-image build (frontend + backend, multi-target)
|
||||
#
|
||||
# Usage:
|
||||
# docker build -t docling-studio .
|
||||
# docker run -p 3000:3000 docling-studio
|
||||
# docker build --target remote -t docling-studio:remote .
|
||||
# docker build --target local -t docling-studio:local .
|
||||
# =============================================================================
|
||||
|
||||
# --- Stage 1: Build frontend assets ---
|
||||
FROM node:20-alpine AS frontend-build
|
||||
|
||||
ARG APP_VERSION=dev
|
||||
|
||||
WORKDIR /build
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
RUN VITE_APP_VERSION=${APP_VERSION} npm run build
|
||||
|
||||
# --- Stage 2: Runtime (Python + Nginx) ---
|
||||
FROM python:3.12-slim
|
||||
# --- Stage 2: Base runtime (Python + Nginx) ---
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
# System deps: poppler (pdf2image), nginx, and OpenCV runtime libs
|
||||
ARG APP_VERSION=dev
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
|
||||
# System deps: poppler (pdf2image), nginx
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
poppler-utils \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
nginx \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python deps
|
||||
# Python deps (common)
|
||||
WORKDIR /app
|
||||
COPY document-parser/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
|
@ -52,5 +55,24 @@ ENV DB_PATH=/app/data/docling_studio.db
|
|||
|
||||
EXPOSE 3000
|
||||
|
||||
# nginx needs to run as root for port binding, then drops to appuser for uvicorn
|
||||
CMD ["sh", "-c", "nginx && exec su appuser -c 'uvicorn main:app --host 127.0.0.1 --port 8000'"]
|
||||
|
||||
# --- Remote: lightweight, delegates to Docling Serve ---
|
||||
FROM base AS remote
|
||||
ENV CONVERSION_ENGINE=remote
|
||||
|
||||
# --- Local: full Docling in-process ---
|
||||
FROM base AS local
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY document-parser/requirements-local.txt .
|
||||
RUN pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu \
|
||||
&& pip install --no-cache-dir -r requirements-local.txt
|
||||
|
||||
RUN chown -R appuser:appuser /app \
|
||||
&& chown -R appuser:appuser /usr/local/lib/python3.12/site-packages/rapidocr/models
|
||||
ENV CONVERSION_ENGINE=local
|
||||
|
|
|
|||
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).
|
||||
Upload a PDF, configure the extraction pipeline, and visualize the results — text, tables, images, formulas, bounding boxes — all from your browser.
|
||||
|
|
@ -48,8 +49,8 @@ document-parser/
|
|||
├── main.py # FastAPI app, CORS, lifespan
|
||||
├── domain/ # Pure domain — no HTTP, no DB
|
||||
│ ├── models.py # Document, AnalysisJob dataclasses
|
||||
│ ├── parsing.py # Docling conversion & page extraction
|
||||
│ └── bbox.py # Bounding box coordinate normalization
|
||||
│ ├── ports.py # Abstract protocols (converter, chunker)
|
||||
│ └── value_objects.py # ConversionResult, PageDetail, ChunkResult
|
||||
├── api/ # HTTP layer (FastAPI routers)
|
||||
│ ├── schemas.py # Pydantic DTOs (camelCase serialization)
|
||||
│ ├── documents.py # /api/documents endpoints
|
||||
|
|
@ -61,7 +62,7 @@ document-parser/
|
|||
├── services/ # Use case orchestration
|
||||
│ ├── document_service.py # Upload, delete, preview
|
||||
│ └── analysis_service.py # Async Docling processing
|
||||
└── tests/ # 99 tests (pytest)
|
||||
└── tests/ # 199 tests (pytest)
|
||||
```
|
||||
|
||||
### Frontend structure (feature-based)
|
||||
|
|
@ -85,22 +86,42 @@ frontend/src/
|
|||
|
||||
## Quick Start
|
||||
|
||||
### Docker (fastest)
|
||||
Docling Studio ships two Docker image variants:
|
||||
|
||||
| Variant | Image tag | Size | Description |
|
||||
|---------|-----------|------|-------------|
|
||||
| **remote** | `latest-remote` | ~270 MB | Lightweight — delegates to an external [Docling Serve](https://github.com/DS4SD/docling-serve) instance |
|
||||
| **local** | `latest-local` | ~1.9 GB | Full — runs Docling in-process, CPU-only (downloads ML models on first run) |
|
||||
|
||||
### Docker — remote mode (fastest)
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 ghcr.io/scub-france/docling-studio:latest
|
||||
docker run -p 3000:3000 \
|
||||
-e DOCLING_SERVE_URL=http://your-docling-serve:5001 \
|
||||
ghcr.io/scub-france/docling-studio:latest-remote
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000)
|
||||
### Docker — local mode (self-contained)
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 ghcr.io/scub-france/docling-studio:latest-local
|
||||
```
|
||||
|
||||
> **Note:** The first analysis takes longer as Docling downloads its ML models (~400 MB). Subsequent runs are fast.
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
### Docker Compose (for development)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/scub-france/Docling-Studio.git
|
||||
cd Docling-Studio
|
||||
|
||||
# Local mode (default)
|
||||
docker compose up --build
|
||||
|
||||
# Remote mode
|
||||
CONVERSION_MODE=remote DOCLING_SERVE_URL=http://your-docling-serve:5001 docker compose up --build
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
|
@ -109,7 +130,13 @@ docker compose up --build
|
|||
```bash
|
||||
cd document-parser
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
|
||||
# Remote mode (lightweight)
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Local mode (with Docling)
|
||||
pip install -r requirements-local.txt
|
||||
|
||||
uvicorn main:app --reload --port 8000
|
||||
```
|
||||
|
||||
|
|
@ -123,12 +150,12 @@ npm run dev
|
|||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Backend (99 tests)
|
||||
# Backend (199 tests)
|
||||
cd document-parser
|
||||
pip install pytest pytest-asyncio httpx
|
||||
pytest tests/ -v
|
||||
|
||||
# Frontend (81 tests)
|
||||
# Frontend (129 tests)
|
||||
cd frontend
|
||||
npm run test:run
|
||||
```
|
||||
|
|
@ -156,6 +183,9 @@ All configuration is done via environment variables. See [`.env.example`](.env.e
|
|||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CONVERSION_ENGINE` | `local` | `local` (in-process Docling) or `remote` (Docling Serve) |
|
||||
| `DOCLING_SERVE_URL` | `http://localhost:5001` | Docling Serve endpoint (remote mode only) |
|
||||
| `DOCLING_SERVE_API_KEY` | — | API key for Docling Serve (optional) |
|
||||
| `CORS_ORIGINS` | `http://localhost:3000,...` | CORS allowed origins (comma-separated) |
|
||||
| `UPLOAD_DIR` | `./uploads` | File storage directory |
|
||||
| `DB_PATH` | `./data/docling_studio.db` | SQLite database path |
|
||||
|
|
@ -167,14 +197,11 @@ GitHub Actions pipelines (see [`.github/workflows/`](.github/workflows/)):
|
|||
|
||||
| Workflow | Trigger | What it does |
|
||||
|----------|---------|--------------|
|
||||
| **CI** | push to `main`, pull requests | Lint + type check + Backend tests (99) + Frontend tests (81) + build |
|
||||
| **Release** | push tag `v*` | Build & push multi-arch Docker image to `ghcr.io` |
|
||||
| **CI** | push to `main`, pull requests | Lint + type check + Backend tests + Frontend tests + build |
|
||||
| **Release** | push tag `v*` | Build & push **two** multi-arch Docker images (`remote` + `local`) to `ghcr.io` |
|
||||
| **Docs** | push to `main` (docs changes) | Build & deploy MkDocs to GitHub Pages |
|
||||
|
||||
To publish a new version:
|
||||
```bash
|
||||
git tag v0.2.0
|
||||
git push origin v0.2.0
|
||||
```
|
||||
We follow [Semantic Versioning](https://semver.org/) with a simplified Git Flow. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full release process.
|
||||
|
||||
## Performance & System Requirements
|
||||
|
||||
|
|
@ -186,12 +213,11 @@ git push origin v0.2.0
|
|||
|
||||
### Docker Desktop settings
|
||||
|
||||
The document parser needs **at least 4 GB of RAM**:
|
||||
|
||||
| Resource | Minimum | Recommended |
|
||||
|----------|---------|-------------|
|
||||
| Memory | 6 GB | 8 GB+ |
|
||||
| CPUs | 4 | 8+ |
|
||||
| | Remote image | Local image |
|
||||
|---|---|---|
|
||||
| **Image size** | ~270 MB | ~1.9 GB |
|
||||
| **Memory** | 2 GB | 6 GB (recommended 8 GB+) |
|
||||
| **CPUs** | 2 | 4 (recommended 8+) |
|
||||
|
||||
### Platform support
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ services:
|
|||
document-parser:
|
||||
build:
|
||||
context: ./document-parser
|
||||
target: ${CONVERSION_MODE:-local}
|
||||
expose:
|
||||
- "8000"
|
||||
volumes:
|
||||
|
|
@ -9,6 +10,8 @@ services:
|
|||
- db_data:/app/data
|
||||
environment:
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000,http://localhost:5173}
|
||||
DOCLING_SERVE_URL: ${DOCLING_SERVE_URL:-}
|
||||
DOCLING_SERVE_API_KEY: ${DOCLING_SERVE_API_KEY:-}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## 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.
|
||||
|
||||
|
|
@ -40,37 +40,47 @@ The backend follows a strict layered architecture. Dependencies flow inward: API
|
|||
|
||||
```
|
||||
document-parser/
|
||||
├── main.py # FastAPI app, CORS, lifespan
|
||||
├── main.py # FastAPI app, CORS, lifespan, health endpoint
|
||||
│
|
||||
├── domain/ # Pure domain — no HTTP, no DB
|
||||
│ ├── models.py # Document, AnalysisJob dataclasses
|
||||
│ ├── parsing.py # Docling conversion & page extraction
|
||||
│ ├── ports.py # Abstract protocols (DocumentConverter, DocumentChunker)
|
||||
│ ├── value_objects.py # ConversionResult, ChunkingOptions, ChunkResult
|
||||
│ └── bbox.py # Bounding box coordinate normalization
|
||||
│
|
||||
├── api/ # HTTP layer (FastAPI routers)
|
||||
│ ├── schemas.py # Pydantic DTOs (camelCase serialization)
|
||||
│ ├── documents.py # /api/documents endpoints
|
||||
│ └── analyses.py # /api/analyses endpoints
|
||||
│ └── analyses.py # /api/analyses endpoints (create, rechunk, delete)
|
||||
│
|
||||
├── persistence/ # Data layer (SQLite via aiosqlite)
|
||||
│ ├── database.py # Connection management, schema init
|
||||
│ ├── document_repo.py # Document CRUD
|
||||
│ └── analysis_repo.py # AnalysisJob CRUD
|
||||
│
|
||||
├── infra/ # Infrastructure adapters
|
||||
│ ├── settings.py # Environment-based configuration
|
||||
│ ├── local_converter.py # In-process Docling converter (local mode)
|
||||
│ ├── serve_converter.py # HTTP client for Docling Serve (remote mode)
|
||||
│ ├── local_chunker.py # In-process chunking (HierarchicalChunker, HybridChunker)
|
||||
│ ├── rate_limiter.py # Sliding-window rate limiting middleware
|
||||
│ └── bbox.py # Bbox coordinate normalization helpers
|
||||
│
|
||||
├── services/ # Use case orchestration
|
||||
│ ├── document_service.py # Upload, delete, preview
|
||||
│ └── analysis_service.py # Async Docling processing
|
||||
│ └── analysis_service.py # Async Docling processing + chunking
|
||||
│
|
||||
└── tests/ # pytest
|
||||
└── tests/ # pytest (199 tests)
|
||||
```
|
||||
|
||||
### Layer responsibilities
|
||||
|
||||
| Layer | Role | Depends on |
|
||||
|-------|------|------------|
|
||||
| **domain** | Dataclasses, bbox math, Docling conversion | Nothing (pure Python) |
|
||||
| **domain** | Dataclasses, value objects, abstract ports | Nothing (pure Python) |
|
||||
| **persistence** | SQLite CRUD, aiosqlite | domain (models) |
|
||||
| **services** | Orchestrate use cases, call Docling | domain + persistence |
|
||||
| **infra** | Adapters: converters, chunker, rate limiter, settings | domain (ports, value objects) |
|
||||
| **services** | Orchestrate use cases, call converters/chunkers | domain + persistence + infra |
|
||||
| **api** | HTTP endpoints, Pydantic DTOs, error handling | services |
|
||||
|
||||
### API contract
|
||||
|
|
@ -101,7 +111,9 @@ frontend/src/
|
|||
│ │ ├── AnalysisPanel.vue
|
||||
│ │ ├── StructureViewer.vue
|
||||
│ │ └── ...
|
||||
│ ├── chunking/ # Chunk panel UI + rechunk action
|
||||
│ ├── document/ # Document store, API, upload
|
||||
│ ├── feature-flags/ # Feature flag store (reads /api/health)
|
||||
│ ├── history/ # History store, navigation
|
||||
│ └── settings/ # Theme, locale, API URL
|
||||
│
|
||||
|
|
@ -126,3 +138,73 @@ Backend response → Pinia store state → Vue reactivity → UI update
|
|||
- **TypeScript strict mode** with shared interfaces in `shared/types.ts`.
|
||||
- **No component library** — custom CSS with CSS variables for theming.
|
||||
- **vue-tsc** in CI to catch type errors before merge.
|
||||
|
||||
## Feature Flags
|
||||
|
||||
The frontend adapts its UI based on the backend's capabilities. On startup, the feature flag store fetches `/api/health` and reads the `engine` and `deploymentMode` fields.
|
||||
|
||||
| Flag | Condition | Effect |
|
||||
|------|-----------|--------|
|
||||
| `chunking` | `engine === 'local'` | Shows chunking options in the analysis panel |
|
||||
| `disclaimer` | `deploymentMode === 'huggingface'` | Shows a disclaimer banner at the top of the app |
|
||||
|
||||
This allows the same frontend build to work with both local and remote backends without conditional compilation.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
The backend applies a sliding-window rate limiter as middleware:
|
||||
|
||||
- **60 requests** per **60 seconds** per client IP
|
||||
- The `/api/health` endpoint is excluded
|
||||
- When the limit is exceeded, the API returns `429 Too Many Requests` with a `Retry-After` header
|
||||
|
||||
## Analysis Lifecycle
|
||||
|
||||
An analysis job follows this state machine:
|
||||
|
||||
```
|
||||
PENDING → RUNNING → COMPLETED
|
||||
→ FAILED
|
||||
```
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| `PENDING` | Job created, waiting for a processing slot |
|
||||
| `RUNNING` | Docling conversion in progress |
|
||||
| `COMPLETED` | Conversion finished — results available (markdown, HTML, pages, chunks) |
|
||||
| `FAILED` | Conversion error — `error_message` contains details |
|
||||
|
||||
The backend limits parallel jobs via `MAX_CONCURRENT_ANALYSES` (default: 3) to avoid overloading the CPU during Docling processing.
|
||||
|
||||
## Local vs Remote Mode
|
||||
|
||||
The backend supports two conversion engines, selected via the `CONVERSION_ENGINE` environment variable:
|
||||
|
||||
| | Local | Remote |
|
||||
|---|---|---|
|
||||
| **Engine** | In-process Docling (PyTorch) | HTTP client to [Docling Serve](https://github.com/DS4SD/docling-serve) |
|
||||
| **Chunking** | Available (in-process) | Not available |
|
||||
| **Docker image** | `latest-local` (~1.9 GB) | `latest-remote` (~270 MB) |
|
||||
| **ML models** | Downloaded on first run (~400 MB) | Managed by Docling Serve |
|
||||
| **CPU/RAM** | 4+ CPUs, 6+ GB RAM | 2 CPUs, 2 GB RAM |
|
||||
|
||||
The converter is selected at startup in `main.py` via `_build_converter()`. The chunker (`_build_chunker()`) is only instantiated in local mode — in remote mode, the chunking feature flag is disabled and the UI hides the chunking panel.
|
||||
|
||||
## Health Endpoint
|
||||
|
||||
`GET /api/health` returns the backend status:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"engine": "local",
|
||||
"version": "0.3.0",
|
||||
"deploymentMode": "self-hosted"
|
||||
}
|
||||
```
|
||||
|
||||
The frontend uses this response to:
|
||||
|
||||
1. Verify the backend is reachable
|
||||
2. Evaluate feature flags (chunking, disclaimer)
|
||||
3. Display the app version
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ The frontend converts PDF points to CSS pixels, then the canvas renders at `devi
|
|||
|
||||
## Transformation 1 — `to_topleft_list()`
|
||||
|
||||
**File:** `document-parser/domain/bbox.py`
|
||||
**File:** `document-parser/infra/bbox.py`
|
||||
|
||||
Normalizes any Docling bbox to `[left, top, right, bottom]` in TOPLEFT coordinates.
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,13 @@
|
|||
```bash
|
||||
cd document-parser
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
|
||||
# Remote mode (lightweight — delegates to Docling Serve)
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Local mode (full — runs Docling in-process)
|
||||
pip install -r requirements-local.txt
|
||||
|
||||
pip install ruff pytest pytest-asyncio httpx
|
||||
uvicorn main:app --reload --port 8000
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,33 +1,69 @@
|
|||
# Getting Started
|
||||
|
||||
## Docker Compose (recommended)
|
||||
Docling Studio ships two Docker image variants:
|
||||
|
||||
| Variant | Image tag | Size | Description |
|
||||
|---------|-----------|------|-------------|
|
||||
| **remote** | `latest-remote` | ~270 MB | Lightweight — delegates to an external [Docling Serve](https://github.com/DS4SD/docling-serve) instance |
|
||||
| **local** | `latest-local` | ~1.9 GB | Full — runs Docling in-process, CPU-only (downloads ML models on first run) |
|
||||
|
||||
{ width="600" }
|
||||
|
||||
## Docker — remote mode (fastest)
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 \
|
||||
-e DOCLING_SERVE_URL=http://your-docling-serve:5001 \
|
||||
ghcr.io/scub-france/docling-studio:latest-remote
|
||||
```
|
||||
|
||||
## Docker — local mode (self-contained)
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 ghcr.io/scub-france/docling-studio:latest-local
|
||||
```
|
||||
|
||||
> **Note:** The first analysis takes longer as Docling downloads its ML models (~400 MB). Subsequent runs are fast.
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
## Docker Compose (recommended for development)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/scub-france/Docling-Studio.git
|
||||
cd Docling-Studio
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000).
|
||||
# Local mode (default)
|
||||
docker compose up --build
|
||||
|
||||
# Remote mode
|
||||
CONVERSION_MODE=remote DOCLING_SERVE_URL=http://your-docling-serve:5001 docker compose up --build
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
### Backend (Python 3.12+)
|
||||
=== "Backend (Python 3.12+)"
|
||||
|
||||
```bash
|
||||
cd document-parser
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload --port 8000
|
||||
```
|
||||
```bash
|
||||
cd document-parser
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
|
||||
### Frontend (Node 20+)
|
||||
# Remote mode (lightweight)
|
||||
pip install -r requirements.txt
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
# Local mode (with Docling)
|
||||
pip install -r requirements-local.txt
|
||||
|
||||
uvicorn main:app --reload --port 8000
|
||||
```
|
||||
|
||||
=== "Frontend (Node 20+)"
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The frontend runs on `http://localhost:3000` and proxies API calls to `http://localhost:8000`.
|
||||
|
||||
|
|
@ -65,22 +101,51 @@ These options map directly to Docling's [`PdfPipelineOptions`](https://docling-p
|
|||
| `generate_page_images` | `false` | Rasterize each page as an image |
|
||||
| `images_scale` | `1.0` | Scale factor for generated images (0.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
|
||||
|
||||
All configuration is done via environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CONVERSION_ENGINE` | `local` | `local` (in-process Docling) or `remote` (Docling Serve) |
|
||||
| `DOCLING_SERVE_URL` | `http://localhost:5001` | Docling Serve endpoint (remote mode only) |
|
||||
| `DOCLING_SERVE_API_KEY` | — | API key for Docling Serve (optional) |
|
||||
| `CORS_ORIGINS` | `http://localhost:3000,...` | CORS allowed origins |
|
||||
| `UPLOAD_DIR` | `./uploads` | File storage directory |
|
||||
| `DB_PATH` | `./data/docling_studio.db` | SQLite database path |
|
||||
| `CONVERSION_TIMEOUT` | `600` | Max seconds per Docling conversion |
|
||||
| `MAX_CONCURRENT_ANALYSES` | `3` | Maximum parallel analysis jobs |
|
||||
| `DEPLOYMENT_MODE` | `self-hosted` | `self-hosted` or `huggingface` (shows disclaimer banner) |
|
||||
| `APP_VERSION` | `dev` | Application version (set automatically by CI/Docker) |
|
||||
|
||||
## System Requirements
|
||||
|
||||
| Resource | Minimum | Recommended |
|
||||
|----------|---------|-------------|
|
||||
| Memory | 6 GB | 8 GB+ |
|
||||
| CPUs | 4 | 8+ |
|
||||
| | Remote image | Local image |
|
||||
|---|---|---|
|
||||
| **Image size** | ~270 MB | ~1.9 GB |
|
||||
| **Memory** | 2 GB | 6 GB (recommended 8 GB+) |
|
||||
| **CPUs** | 2 | 4 (recommended 8+) |
|
||||
|
||||
All Docker images are multi-arch (`linux/amd64` + `linux/arm64`). No GPU required.
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB |
BIN
docs/images/docker.png
Normal file
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.
|
||||
|
||||
{ width="600" }
|
||||
{ width="600" }
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- **PDF viewer** with page navigation, bounding box overlay, and resizable results panel
|
||||
- **Configurable Docling pipeline** — OCR, table extraction, code/formula enrichment, picture classification & description
|
||||
- **Configurable Docling pipeline** — OCR, table extraction, code/formula enrichment, picture classification & description, image generation
|
||||
- **Bounding box visualization** — color-coded element overlay directly on the PDF
|
||||
- **Chunking** — split extracted content into semantic chunks (hierarchical, hybrid, or page-based) with configurable token limits
|
||||
- **Markdown & HTML export** of extracted content
|
||||
- **Document management** — upload, list, delete
|
||||
- **Analysis history** — re-visit and open past analyses
|
||||
- **Feature flags** — capabilities adapt to the conversion engine (local vs remote)
|
||||
- **Rate limiting** — 60 requests per minute per IP to protect the backend
|
||||
- **Deployment modes** — self-hosted (default) or HuggingFace Spaces (with disclaimer banner)
|
||||
- **Health endpoint** — `/api/health` reports engine type, deployment mode, and database status
|
||||
- **Dark / Light theme** and **FR / EN** localization
|
||||
|
||||
## Tech Stack
|
||||
|
|
@ -31,7 +36,7 @@ Upload a PDF, configure the extraction pipeline, and visualize the results — t
|
|||
|
||||
```bash
|
||||
# Docker (fastest)
|
||||
docker run -p 3000:3000 ghcr.io/scub-france/docling-studio:latest
|
||||
docker run -p 3000:3000 ghcr.io/scub-france/docling-studio:latest-local
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) and upload a PDF.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
FROM python:3.12-slim
|
||||
# syntax=docker/dockerfile:1
|
||||
# =============================================================================
|
||||
# Docling Studio — backend image (multi-target: remote / local)
|
||||
#
|
||||
# Usage:
|
||||
# docker build --target remote -t docling-studio-backend:remote .
|
||||
# docker build --target local -t docling-studio-backend:local .
|
||||
# =============================================================================
|
||||
|
||||
# --- Base: common deps for both targets ---
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
poppler-utils \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
|
@ -24,3 +32,25 @@ ENV DB_PATH=/app/data/docling_studio.db
|
|||
|
||||
USER appuser
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
# --- Remote: lightweight, delegates to Docling Serve ---
|
||||
FROM base AS remote
|
||||
ENV CONVERSION_ENGINE=remote
|
||||
|
||||
# --- Local: full Docling in-process ---
|
||||
FROM base AS local
|
||||
|
||||
USER root
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements-local.txt .
|
||||
RUN pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu \
|
||||
&& pip install --no-cache-dir -r requirements-local.txt
|
||||
|
||||
RUN chown -R appuser:appuser /app \
|
||||
&& chown -R appuser:appuser /usr/local/lib/python3.12/site-packages/rapidocr/models
|
||||
USER appuser
|
||||
ENV CONVERSION_ENGINE=local
|
||||
|
|
|
|||
|
|
@ -3,16 +3,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from api.schemas import AnalysisResponse, CreateAnalysisRequest
|
||||
from services import analysis_service
|
||||
from api.schemas import (
|
||||
AnalysisResponse,
|
||||
ChunkBboxResponse,
|
||||
ChunkResponse,
|
||||
CreateAnalysisRequest,
|
||||
RechunkRequest,
|
||||
)
|
||||
from services.analysis_service import AnalysisService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/analyses", tags=["analyses"])
|
||||
|
||||
|
||||
def _get_service(request: Request) -> AnalysisService:
|
||||
return request.app.state.analysis_service
|
||||
|
||||
|
||||
ServiceDep = Annotated[AnalysisService, Depends(_get_service)]
|
||||
|
||||
|
||||
def _to_response(job) -> AnalysisResponse:
|
||||
return AnalysisResponse(
|
||||
id=job.id,
|
||||
|
|
@ -22,6 +36,8 @@ def _to_response(job) -> AnalysisResponse:
|
|||
content_markdown=job.content_markdown,
|
||||
content_html=job.content_html,
|
||||
pages_json=job.pages_json,
|
||||
chunks_json=job.chunks_json,
|
||||
has_document_json=job.document_json is not None,
|
||||
error_message=job.error_message,
|
||||
started_at=str(job.started_at) if job.started_at else None,
|
||||
completed_at=str(job.completed_at) if job.completed_at else None,
|
||||
|
|
@ -30,7 +46,7 @@ def _to_response(job) -> AnalysisResponse:
|
|||
|
||||
|
||||
@router.post("", response_model=AnalysisResponse)
|
||||
async def create_analysis(body: CreateAnalysisRequest):
|
||||
async def create_analysis(body: CreateAnalysisRequest, service: ServiceDep) -> AnalysisResponse:
|
||||
"""Create a new analysis job for a document."""
|
||||
if not body.documentId or not body.documentId.strip():
|
||||
raise HTTPException(status_code=400, detail="documentId is required")
|
||||
|
|
@ -39,8 +55,16 @@ async def create_analysis(body: CreateAnalysisRequest):
|
|||
if body.pipelineOptions:
|
||||
pipeline_opts = body.pipelineOptions.model_dump()
|
||||
|
||||
chunking_opts = None
|
||||
if body.chunkingOptions:
|
||||
chunking_opts = body.chunkingOptions.model_dump()
|
||||
|
||||
try:
|
||||
job = await analysis_service.create(body.documentId, pipeline_options=pipeline_opts)
|
||||
job = await service.create(
|
||||
body.documentId,
|
||||
pipeline_options=pipeline_opts,
|
||||
chunking_options=chunking_opts,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
|
|
@ -48,24 +72,45 @@ async def create_analysis(body: CreateAnalysisRequest):
|
|||
|
||||
|
||||
@router.get("", response_model=list[AnalysisResponse])
|
||||
async def list_analyses():
|
||||
async def list_analyses(service: ServiceDep) -> list[AnalysisResponse]:
|
||||
"""List all analysis jobs."""
|
||||
jobs = await analysis_service.find_all()
|
||||
jobs = await service.find_all()
|
||||
return [_to_response(j) for j in jobs]
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=AnalysisResponse)
|
||||
async def get_analysis(job_id: str):
|
||||
async def get_analysis(job_id: str, service: ServiceDep) -> AnalysisResponse:
|
||||
"""Get a single analysis job."""
|
||||
job = await analysis_service.find_by_id(job_id)
|
||||
job = await service.find_by_id(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
return _to_response(job)
|
||||
|
||||
|
||||
@router.post("/{job_id}/rechunk", response_model=list[ChunkResponse])
|
||||
async def rechunk_analysis(
|
||||
job_id: str, body: RechunkRequest, service: ServiceDep
|
||||
) -> list[ChunkResponse]:
|
||||
"""Re-chunk a completed analysis with new chunking options."""
|
||||
try:
|
||||
chunks = await service.rechunk(job_id, body.chunkingOptions.model_dump())
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
return [
|
||||
ChunkResponse(
|
||||
text=c.text,
|
||||
headings=c.headings,
|
||||
source_page=c.source_page,
|
||||
token_count=c.token_count,
|
||||
bboxes=[ChunkBboxResponse(page=b.page, bbox=b.bbox) for b in c.bboxes],
|
||||
)
|
||||
for c in chunks
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/{job_id}", status_code=204)
|
||||
async def delete_analysis(job_id: str):
|
||||
async def delete_analysis(job_id: str, service: ServiceDep) -> None:
|
||||
"""Delete an analysis job."""
|
||||
deleted = await analysis_service.delete(job_id)
|
||||
deleted = await service.delete(job_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ from services import document_service
|
|||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/documents", tags=["documents"])
|
||||
|
||||
_READ_CHUNK_SIZE = 64 * 1024 # 64 KB
|
||||
|
||||
|
||||
def _to_response(doc) -> DocumentResponse:
|
||||
return DocumentResponse(
|
||||
|
|
@ -25,8 +27,8 @@ def _to_response(doc) -> DocumentResponse:
|
|||
)
|
||||
|
||||
|
||||
@router.post("/upload", response_model=DocumentResponse)
|
||||
async def upload(file: UploadFile):
|
||||
@router.post("/upload", response_model=DocumentResponse, status_code=200)
|
||||
async def upload(file: UploadFile) -> DocumentResponse:
|
||||
"""Upload a PDF document."""
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="No filename provided")
|
||||
|
|
@ -35,7 +37,15 @@ async def upload(file: UploadFile):
|
|||
if file.size and file.size > document_service.MAX_FILE_SIZE:
|
||||
raise HTTPException(status_code=413, detail="File too large (max 50 MB)")
|
||||
|
||||
content = await file.read()
|
||||
# Read in chunks to avoid holding the full upload in a single allocation
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while chunk := await file.read(_READ_CHUNK_SIZE):
|
||||
total += len(chunk)
|
||||
if total > document_service.MAX_FILE_SIZE:
|
||||
raise HTTPException(status_code=413, detail="File too large (max 50 MB)")
|
||||
chunks.append(chunk)
|
||||
content = b"".join(chunks)
|
||||
|
||||
try:
|
||||
doc = await document_service.upload(
|
||||
|
|
@ -44,20 +54,20 @@ async def upload(file: UploadFile):
|
|||
file_content=content,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=413, detail=str(e)) from e
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
return _to_response(doc)
|
||||
|
||||
|
||||
@router.get("", response_model=list[DocumentResponse])
|
||||
async def list_documents():
|
||||
async def list_documents() -> list[DocumentResponse]:
|
||||
"""List all documents."""
|
||||
docs = await document_service.find_all()
|
||||
return [_to_response(d) for d in docs]
|
||||
|
||||
|
||||
@router.get("/{doc_id}", response_model=DocumentResponse)
|
||||
async def get_document(doc_id: str):
|
||||
async def get_document(doc_id: str) -> DocumentResponse:
|
||||
"""Get a single document."""
|
||||
doc = await document_service.find_by_id(doc_id)
|
||||
if not doc:
|
||||
|
|
@ -66,7 +76,7 @@ async def get_document(doc_id: str):
|
|||
|
||||
|
||||
@router.delete("/{doc_id}", status_code=204)
|
||||
async def delete_document(doc_id: str):
|
||||
async def delete_document(doc_id: str) -> None:
|
||||
"""Delete a document and its file."""
|
||||
deleted = await document_service.delete(doc_id)
|
||||
if not deleted:
|
||||
|
|
@ -78,12 +88,18 @@ async def preview(
|
|||
doc_id: str,
|
||||
page: int = Query(1, ge=1),
|
||||
dpi: int = Query(150, ge=72, le=300),
|
||||
):
|
||||
) -> Response:
|
||||
"""Generate a PNG preview of a specific PDF page."""
|
||||
doc = await document_service.find_by_id(doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
if doc.page_count and page > doc.page_count:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Page {page} out of range (document has {doc.page_count} pages)",
|
||||
)
|
||||
|
||||
try:
|
||||
with open(doc.storage_path, "rb") as f:
|
||||
file_content = f.read()
|
||||
|
|
@ -91,6 +107,11 @@ async def preview(
|
|||
return Response(content=png_bytes, media_type="image/png")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="PDF file not found on disk") from exc
|
||||
except OSError as exc:
|
||||
logger.exception("I/O error generating preview for %s", doc_id)
|
||||
raise HTTPException(status_code=422, detail="Failed to read PDF file") from exc
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to generate preview")
|
||||
logger.exception("Unexpected error generating preview for %s", doc_id)
|
||||
raise HTTPException(status_code=422, detail="Failed to generate preview") from exc
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from __future__ import annotations
|
|||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
def _to_camel(name: str) -> str:
|
||||
|
|
@ -18,6 +18,7 @@ def _to_camel(name: str) -> str:
|
|||
|
||||
class _CamelModel(BaseModel):
|
||||
"""Base model that serializes field names to camelCase."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
alias_generator=_to_camel,
|
||||
populate_by_name=True,
|
||||
|
|
@ -28,6 +29,7 @@ class _CamelModel(BaseModel):
|
|||
class DocumentResponse(_CamelModel):
|
||||
id: str
|
||||
filename: str
|
||||
status: str = "uploaded" # Document status (always "uploaded" for now)
|
||||
content_type: str | None = None
|
||||
file_size: int | None = None
|
||||
page_count: int | None = None
|
||||
|
|
@ -42,6 +44,8 @@ class AnalysisResponse(_CamelModel):
|
|||
content_markdown: str | None = None
|
||||
content_html: str | None = None
|
||||
pages_json: str | None = None
|
||||
chunks_json: str | None = None
|
||||
has_document_json: bool = False
|
||||
error_message: str | None = None
|
||||
started_at: str | datetime | None = None
|
||||
completed_at: str | datetime | None = None
|
||||
|
|
@ -50,16 +54,40 @@ class AnalysisResponse(_CamelModel):
|
|||
|
||||
class PipelineOptionsRequest(BaseModel):
|
||||
"""Docling pipeline configuration options."""
|
||||
do_ocr: bool = True
|
||||
do_table_structure: bool = True
|
||||
table_mode: str = "accurate" # "accurate" or "fast"
|
||||
do_code_enrichment: bool = False
|
||||
do_formula_enrichment: bool = False
|
||||
do_picture_classification: bool = False
|
||||
do_picture_description: bool = False
|
||||
generate_picture_images: bool = False
|
||||
generate_page_images: bool = False
|
||||
images_scale: float = 1.0
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
do_ocr: bool = Field(default=True, validation_alias=AliasChoices("do_ocr", "doOcr"))
|
||||
do_table_structure: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("do_table_structure", "doTableStructure")
|
||||
)
|
||||
table_mode: str = Field(
|
||||
default="accurate", validation_alias=AliasChoices("table_mode", "tableMode")
|
||||
)
|
||||
do_code_enrichment: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("do_code_enrichment", "doCodeEnrichment")
|
||||
)
|
||||
do_formula_enrichment: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("do_formula_enrichment", "doFormulaEnrichment")
|
||||
)
|
||||
do_picture_classification: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("do_picture_classification", "doPictureClassification"),
|
||||
)
|
||||
do_picture_description: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("do_picture_description", "doPictureDescription"),
|
||||
)
|
||||
generate_picture_images: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("generate_picture_images", "generatePictureImages"),
|
||||
)
|
||||
generate_page_images: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("generate_page_images", "generatePageImages")
|
||||
)
|
||||
images_scale: float = Field(
|
||||
default=1.0, validation_alias=AliasChoices("images_scale", "imagesScale")
|
||||
)
|
||||
|
||||
@field_validator("table_mode")
|
||||
@classmethod
|
||||
|
|
@ -76,6 +104,61 @@ class PipelineOptionsRequest(BaseModel):
|
|||
return v
|
||||
|
||||
|
||||
class ChunkingOptionsRequest(BaseModel):
|
||||
"""Docling chunking configuration options."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
chunker_type: str = Field(
|
||||
default="hybrid", validation_alias=AliasChoices("chunker_type", "chunkerType")
|
||||
)
|
||||
max_tokens: int = Field(default=512, validation_alias=AliasChoices("max_tokens", "maxTokens"))
|
||||
merge_peers: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("merge_peers", "mergePeers")
|
||||
)
|
||||
repeat_table_header: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("repeat_table_header", "repeatTableHeader")
|
||||
)
|
||||
|
||||
@field_validator("chunker_type")
|
||||
@classmethod
|
||||
def validate_chunker_type(cls, v: str) -> str:
|
||||
if v not in ("hybrid", "hierarchical"):
|
||||
raise ValueError('chunker_type must be "hybrid" or "hierarchical"')
|
||||
return v
|
||||
|
||||
@field_validator("max_tokens")
|
||||
@classmethod
|
||||
def validate_max_tokens(cls, v: int) -> int:
|
||||
if v < 64 or v > 8192:
|
||||
raise ValueError("max_tokens must be between 64 and 8192")
|
||||
return v
|
||||
|
||||
|
||||
class ChunkBboxResponse(_CamelModel):
|
||||
page: int
|
||||
bbox: list[float]
|
||||
|
||||
|
||||
class ChunkResponse(_CamelModel):
|
||||
text: str
|
||||
headings: list[str] = []
|
||||
source_page: int | None = None
|
||||
token_count: int = 0
|
||||
bboxes: list[ChunkBboxResponse] = []
|
||||
|
||||
|
||||
class CreateAnalysisRequest(BaseModel):
|
||||
documentId: str # camelCase to match existing frontend contract
|
||||
pipelineOptions: PipelineOptionsRequest | None = None
|
||||
documentId: str = Field(validation_alias=AliasChoices("documentId", "document_id"))
|
||||
pipelineOptions: PipelineOptionsRequest | None = Field(
|
||||
default=None, validation_alias=AliasChoices("pipelineOptions", "pipeline_options")
|
||||
)
|
||||
chunkingOptions: ChunkingOptionsRequest | None = Field(
|
||||
default=None, validation_alias=AliasChoices("chunkingOptions", "chunking_options")
|
||||
)
|
||||
|
||||
|
||||
class RechunkRequest(BaseModel):
|
||||
chunkingOptions: ChunkingOptionsRequest = Field(
|
||||
validation_alias=AliasChoices("chunkingOptions", "chunking_options")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,3 +1 @@
|
|||
|
||||
|
||||
pytest_plugins = ["pytest_asyncio"]
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ class AnalysisJob:
|
|||
content_markdown: str | None = None
|
||||
content_html: str | None = None
|
||||
pages_json: str | None = None
|
||||
document_json: str | None = None
|
||||
chunks_json: str | None = None
|
||||
error_message: str | None = None
|
||||
started_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
|
|
@ -51,19 +53,29 @@ class AnalysisJob:
|
|||
document_filename: str | None = None
|
||||
|
||||
def mark_running(self) -> None:
|
||||
"""Transition to RUNNING and record the start timestamp."""
|
||||
self.status = AnalysisStatus.RUNNING
|
||||
self.started_at = _utcnow()
|
||||
|
||||
def mark_completed(
|
||||
self, markdown: str, html: str, pages_json: str,
|
||||
self,
|
||||
markdown: str,
|
||||
html: str,
|
||||
pages_json: str,
|
||||
document_json: str | None = None,
|
||||
chunks_json: str | None = None,
|
||||
) -> None:
|
||||
"""Transition to COMPLETED with conversion results."""
|
||||
self.status = AnalysisStatus.COMPLETED
|
||||
self.content_markdown = markdown
|
||||
self.content_html = html
|
||||
self.pages_json = pages_json
|
||||
self.document_json = document_json
|
||||
self.chunks_json = chunks_json
|
||||
self.completed_at = _utcnow()
|
||||
|
||||
def mark_failed(self, error: str) -> None:
|
||||
"""Transition to FAILED with an error message."""
|
||||
self.status = AnalysisStatus.FAILED
|
||||
self.error_message = error
|
||||
self.completed_at = _utcnow()
|
||||
|
|
|
|||
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:
|
||||
logger.debug(
|
||||
"Degenerate bbox skipped: [%.1f, %.1f, %.1f, %.1f] (page_height=%.1f)",
|
||||
left, top, right, bottom, page_height,
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
page_height,
|
||||
)
|
||||
return list(EMPTY_BBOX)
|
||||
|
||||
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
|
||||
per-page elements with bounding boxes and hierarchy levels.
|
||||
This adapter implements the DocumentConverter port using the Docling library
|
||||
directly. It wraps the blocking DocumentConverter in asyncio.to_thread for
|
||||
non-blocking execution.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from docling.datamodel.base_models import InputFormat
|
||||
from docling.datamodel.pipeline_options import (
|
||||
|
|
@ -17,7 +19,8 @@ from docling.datamodel.pipeline_options import (
|
|||
TableFormerMode,
|
||||
TableStructureOptions,
|
||||
)
|
||||
from docling.document_converter import DocumentConverter, PdfFormatOption
|
||||
from docling.document_converter import DocumentConverter as DoclingConverter
|
||||
from docling.document_converter import PdfFormatOption
|
||||
from docling_core.types.doc import (
|
||||
CodeItem,
|
||||
DocItem,
|
||||
|
|
@ -32,11 +35,17 @@ from docling_core.types.doc import (
|
|||
TitleItem,
|
||||
)
|
||||
|
||||
from domain.bbox import to_topleft_list
|
||||
from domain.value_objects import (
|
||||
ConversionOptions,
|
||||
ConversionResult,
|
||||
PageDetail,
|
||||
PageElement,
|
||||
)
|
||||
from infra.bbox import to_topleft_list
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Thread lock — DocumentConverter is not thread-safe
|
||||
# Thread lock — DoclingConverter is not thread-safe
|
||||
_converter_lock = threading.Lock()
|
||||
|
||||
# US Letter page dimensions (points) — fallback when page size is unknown
|
||||
|
|
@ -44,61 +53,13 @@ _DEFAULT_PAGE_WIDTH = 612.0
|
|||
_DEFAULT_PAGE_HEIGHT = 792.0
|
||||
|
||||
# Default converter (lazy-init on first request)
|
||||
_default_converter: DocumentConverter | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domain value objects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class PageElement:
|
||||
type: str
|
||||
bbox: list[float]
|
||||
content: str
|
||||
level: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class PageDetail:
|
||||
page_number: int
|
||||
width: float
|
||||
height: float
|
||||
elements: list[PageElement] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversionOptions:
|
||||
do_ocr: bool = True
|
||||
do_table_structure: bool = True
|
||||
table_mode: str = "accurate"
|
||||
do_code_enrichment: bool = False
|
||||
do_formula_enrichment: bool = False
|
||||
do_picture_classification: bool = False
|
||||
do_picture_description: bool = False
|
||||
generate_picture_images: bool = False
|
||||
generate_page_images: bool = False
|
||||
images_scale: float = 1.0
|
||||
|
||||
def is_default(self) -> bool:
|
||||
return self == ConversionOptions()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversionResult:
|
||||
page_count: int
|
||||
content_markdown: str
|
||||
content_html: str
|
||||
pages: list[PageDetail]
|
||||
skipped_items: int = 0
|
||||
_default_converter: DoclingConverter | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Element type detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Mapping from Docling type to element type string.
|
||||
# Order matters: most specific types before their parents.
|
||||
_ELEMENT_TYPE_MAP: list[tuple[type, str]] = [
|
||||
(TableItem, "table"),
|
||||
(PictureItem, "picture"),
|
||||
|
|
@ -113,7 +74,6 @@ _ELEMENT_TYPE_MAP: list[tuple[type, str]] = [
|
|||
|
||||
|
||||
def _get_element_type(item: DocItem) -> str:
|
||||
"""Determine element type via isinstance on Docling's type hierarchy."""
|
||||
for cls, type_name in _ELEMENT_TYPE_MAP:
|
||||
if isinstance(item, cls):
|
||||
return type_name
|
||||
|
|
@ -124,51 +84,52 @@ def _get_element_type(item: DocItem) -> str:
|
|||
# Pipeline factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_converter(options: ConversionOptions | None = None) -> DocumentConverter:
|
||||
"""Build a DocumentConverter with the given pipeline options."""
|
||||
opts = options or ConversionOptions()
|
||||
|
||||
def _build_docling_converter(options: ConversionOptions) -> DoclingConverter:
|
||||
table_options = TableStructureOptions(
|
||||
do_cell_matching=True,
|
||||
mode=TableFormerMode.ACCURATE if opts.table_mode == "accurate" else TableFormerMode.FAST,
|
||||
mode=TableFormerMode.ACCURATE if options.table_mode == "accurate" else TableFormerMode.FAST,
|
||||
)
|
||||
|
||||
pipeline_options = PdfPipelineOptions(
|
||||
do_ocr=opts.do_ocr,
|
||||
do_table_structure=opts.do_table_structure,
|
||||
do_ocr=options.do_ocr,
|
||||
do_table_structure=options.do_table_structure,
|
||||
table_structure_options=table_options,
|
||||
do_code_enrichment=opts.do_code_enrichment,
|
||||
do_formula_enrichment=opts.do_formula_enrichment,
|
||||
do_picture_classification=opts.do_picture_classification,
|
||||
do_picture_description=opts.do_picture_description,
|
||||
generate_page_images=opts.generate_page_images,
|
||||
generate_picture_images=opts.generate_picture_images,
|
||||
images_scale=opts.images_scale,
|
||||
do_code_enrichment=options.do_code_enrichment,
|
||||
do_formula_enrichment=options.do_formula_enrichment,
|
||||
do_picture_classification=options.do_picture_classification,
|
||||
do_picture_description=options.do_picture_description,
|
||||
generate_page_images=options.generate_page_images,
|
||||
generate_picture_images=options.generate_picture_images,
|
||||
images_scale=options.images_scale,
|
||||
)
|
||||
|
||||
return DocumentConverter(
|
||||
return DoclingConverter(
|
||||
format_options={
|
||||
InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_default_converter() -> DocumentConverter:
|
||||
def _get_default_converter() -> DoclingConverter:
|
||||
global _default_converter
|
||||
if _default_converter is None:
|
||||
_default_converter = build_converter()
|
||||
_default_converter = _build_docling_converter(ConversionOptions())
|
||||
return _default_converter
|
||||
|
||||
|
||||
def _select_converter(options: ConversionOptions) -> DoclingConverter:
|
||||
if options.is_default():
|
||||
return _get_default_converter()
|
||||
return _build_docling_converter(options)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
|
||||
"""Extract per-page element details with bounding boxes from Docling result.
|
||||
|
||||
Returns (pages, skipped_count) for transparent error reporting.
|
||||
"""
|
||||
def _extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
|
||||
pages: dict[int, PageDetail] = {}
|
||||
document = doc_result.document
|
||||
skipped = 0
|
||||
|
|
@ -191,9 +152,10 @@ def extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
|
|||
|
||||
|
||||
def _process_content_item(
|
||||
item: DocItem | GroupItem, level: int, pages: dict[int, PageDetail],
|
||||
item: DocItem | GroupItem,
|
||||
level: int,
|
||||
pages: dict[int, PageDetail],
|
||||
) -> bool:
|
||||
"""Process a single content item and add it to the appropriate page."""
|
||||
if isinstance(item, GroupItem):
|
||||
return True
|
||||
|
||||
|
|
@ -204,14 +166,15 @@ def _process_content_item(
|
|||
try:
|
||||
page_no = prov.page_no
|
||||
if page_no not in pages:
|
||||
# Fallback: page was not found in document.pages (corrupted PDF or
|
||||
# Docling edge case). US Letter dimensions are used as a safe default.
|
||||
# This may cause slight bbox misalignment on non-Letter pages (e.g. A4).
|
||||
logger.warning(
|
||||
"Page %d not found in document metadata — using US Letter fallback (%sx%s pt)",
|
||||
page_no, _DEFAULT_PAGE_WIDTH, _DEFAULT_PAGE_HEIGHT,
|
||||
page_no,
|
||||
_DEFAULT_PAGE_WIDTH,
|
||||
_DEFAULT_PAGE_HEIGHT,
|
||||
)
|
||||
pages[page_no] = PageDetail(
|
||||
page_number=page_no, width=_DEFAULT_PAGE_WIDTH, height=_DEFAULT_PAGE_HEIGHT
|
||||
)
|
||||
pages[page_no] = PageDetail(page_number=page_no, width=_DEFAULT_PAGE_WIDTH, height=_DEFAULT_PAGE_HEIGHT)
|
||||
|
||||
page_height = pages[page_no].height
|
||||
|
||||
|
|
@ -242,49 +205,30 @@ def _process_content_item(
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main conversion entry point
|
||||
# Synchronous conversion (called via asyncio.to_thread)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _select_converter(options: ConversionOptions) -> DocumentConverter:
|
||||
"""Return the cached default converter or build a custom one."""
|
||||
if options.is_default():
|
||||
return get_default_converter()
|
||||
return build_converter(options)
|
||||
|
||||
|
||||
def _build_fallback_pages(doc, page_count: int) -> list[PageDetail]:
|
||||
"""Create empty PageDetail entries when extraction yields nothing."""
|
||||
return [
|
||||
PageDetail(
|
||||
page_number=i + 1,
|
||||
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else _DEFAULT_PAGE_WIDTH,
|
||||
height=doc.pages[i + 1].size.height if (i + 1) in doc.pages else _DEFAULT_PAGE_HEIGHT,
|
||||
)
|
||||
for i in range(page_count)
|
||||
]
|
||||
|
||||
|
||||
def convert_document(
|
||||
file_path: str,
|
||||
options: ConversionOptions | None = None,
|
||||
) -> ConversionResult:
|
||||
"""Convert a document and return structured results.
|
||||
|
||||
This is the main entry point for document parsing. Runs synchronously
|
||||
(caller should use asyncio.to_thread for non-blocking execution).
|
||||
"""
|
||||
opts = options or ConversionOptions()
|
||||
|
||||
def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResult:
|
||||
with _converter_lock:
|
||||
conv = _select_converter(opts)
|
||||
conv = _select_converter(options)
|
||||
result = conv.convert(file_path)
|
||||
|
||||
doc = result.document
|
||||
page_count = len(doc.pages)
|
||||
pages_detail, skipped = extract_pages_detail(result)
|
||||
pages_detail, skipped = _extract_pages_detail(result)
|
||||
|
||||
if not pages_detail and page_count > 0:
|
||||
pages_detail = _build_fallback_pages(doc, page_count)
|
||||
pages_detail = [
|
||||
PageDetail(
|
||||
page_number=i + 1,
|
||||
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else _DEFAULT_PAGE_WIDTH,
|
||||
height=doc.pages[i + 1].size.height
|
||||
if (i + 1) in doc.pages
|
||||
else _DEFAULT_PAGE_HEIGHT,
|
||||
)
|
||||
for i in range(page_count)
|
||||
]
|
||||
|
||||
if skipped > 0:
|
||||
logger.info("Parsed: %d pages, %d items skipped", page_count, skipped)
|
||||
|
|
@ -295,4 +239,21 @@ def convert_document(
|
|||
content_html=doc.export_to_html(),
|
||||
pages=pages_detail,
|
||||
skipped_items=skipped,
|
||||
document_json=json.dumps(doc.export_to_dict()),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public adapter class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LocalConverter:
|
||||
"""Adapter that runs Docling locally as a Python library."""
|
||||
|
||||
async def convert(
|
||||
self,
|
||||
file_path: str,
|
||||
options: ConversionOptions,
|
||||
) -> ConversionResult:
|
||||
return await asyncio.to_thread(_convert_sync, file_path, options)
|
||||
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.
|
||||
|
||||
Single service replacing both the Spring Boot backend and the document parser.
|
||||
Provides document management (upload, CRUD), analysis orchestration (async Docling
|
||||
processing), and PDF preview — all backed by SQLite.
|
||||
Single service providing document management (upload, CRUD), analysis
|
||||
orchestration (async Docling processing), and PDF preview — all backed
|
||||
by SQLite.
|
||||
|
||||
Conversion engine is selected via CONVERSION_ENGINE env var:
|
||||
- "local" → Docling runs in-process as a Python library (default)
|
||||
- "remote" → delegates to a Docling Serve instance via HTTP
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
|
@ -16,7 +20,10 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
|
||||
from api.analyses import router as analyses_router
|
||||
from api.documents import router as documents_router
|
||||
from persistence.database import init_db
|
||||
from infra.rate_limiter import RateLimiterMiddleware
|
||||
from infra.settings import settings
|
||||
from persistence.database import get_connection, init_db
|
||||
from services.analysis_service import AnalysisService
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
|
|
@ -25,11 +32,53 @@ logging.basicConfig(
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_converter():
|
||||
"""Build the converter adapter based on configuration."""
|
||||
if settings.conversion_engine == "remote":
|
||||
from infra.serve_converter import ServeConverter
|
||||
|
||||
logger.info("Using remote Docling Serve at %s", settings.docling_serve_url)
|
||||
return ServeConverter(
|
||||
base_url=settings.docling_serve_url,
|
||||
api_key=settings.docling_serve_api_key,
|
||||
)
|
||||
else:
|
||||
from infra.local_converter import LocalConverter
|
||||
|
||||
logger.info("Using local Docling converter")
|
||||
return LocalConverter()
|
||||
|
||||
|
||||
def _build_chunker():
|
||||
"""Build the chunker adapter — only available in local mode."""
|
||||
if settings.conversion_engine == "local":
|
||||
from infra.local_chunker import LocalChunker
|
||||
|
||||
return LocalChunker()
|
||||
return None
|
||||
|
||||
|
||||
def _build_analysis_service() -> AnalysisService:
|
||||
converter = _build_converter()
|
||||
chunker = _build_chunker()
|
||||
return AnalysisService(
|
||||
converter=converter,
|
||||
chunker=chunker,
|
||||
conversion_timeout=settings.conversion_timeout,
|
||||
max_concurrent=settings.max_concurrent_analyses,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI app
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup: initialize database. Shutdown: nothing special needed."""
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
await init_db()
|
||||
logger.info("Docling Studio backend ready")
|
||||
app.state.analysis_service = _build_analysis_service()
|
||||
logger.info("Docling Studio backend ready (engine=%s)", settings.conversion_engine)
|
||||
yield
|
||||
|
||||
|
||||
|
|
@ -39,25 +88,35 @@ app = FastAPI(
|
|||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS — configurable via env, defaults for local dev
|
||||
allowed_origins = os.environ.get(
|
||||
"CORS_ORIGINS", "http://localhost:3000,http://localhost:5173"
|
||||
).split(",")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in allowed_origins],
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization"],
|
||||
)
|
||||
app.add_middleware(RateLimiterMiddleware, requests_per_window=100, window_seconds=60)
|
||||
|
||||
# Mount routers
|
||||
app.include_router(documents_router)
|
||||
app.include_router(analyses_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "ok"}
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict[str, str]:
|
||||
"""Health check endpoint — verifies database connectivity."""
|
||||
db_status = "ok"
|
||||
try:
|
||||
async with get_connection() as db:
|
||||
await db.execute("SELECT 1")
|
||||
except Exception:
|
||||
db_status = "error"
|
||||
logger.warning("Health check: database unreachable", exc_info=True)
|
||||
|
||||
status = "ok" if db_status == "ok" else "degraded"
|
||||
return {
|
||||
"status": status,
|
||||
"version": settings.app_version,
|
||||
"engine": settings.conversion_engine,
|
||||
"deploymentMode": settings.deployment_mode,
|
||||
"database": db_status,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,21 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from domain.models import AnalysisJob, AnalysisStatus
|
||||
from persistence.database import get_connection
|
||||
|
||||
|
||||
def _parse_dt(value: str | None) -> datetime | None:
|
||||
"""Parse an ISO-format datetime string back into a datetime object."""
|
||||
if not value:
|
||||
return None
|
||||
return datetime.fromisoformat(value)
|
||||
|
||||
|
||||
def _row_to_job(row) -> AnalysisJob:
|
||||
keys = row.keys()
|
||||
return AnalysisJob(
|
||||
id=row["id"],
|
||||
document_id=row["document_id"],
|
||||
|
|
@ -14,11 +24,13 @@ def _row_to_job(row) -> AnalysisJob:
|
|||
content_markdown=row["content_markdown"],
|
||||
content_html=row["content_html"],
|
||||
pages_json=row["pages_json"],
|
||||
document_json=row["document_json"] if "document_json" in keys else None,
|
||||
chunks_json=row["chunks_json"] if "chunks_json" in keys else None,
|
||||
error_message=row["error_message"],
|
||||
started_at=row["started_at"],
|
||||
completed_at=row["completed_at"],
|
||||
created_at=row["created_at"],
|
||||
document_filename=row["filename"] if "filename" in row.keys() else None, # noqa: SIM118
|
||||
started_at=_parse_dt(row["started_at"]),
|
||||
completed_at=_parse_dt(row["completed_at"]),
|
||||
created_at=_parse_dt(row["created_at"]) or datetime.now(UTC),
|
||||
document_filename=row["filename"] if "filename" in keys else None,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -30,6 +42,7 @@ _SELECT_WITH_DOC = """
|
|||
|
||||
|
||||
async def insert(job: AnalysisJob) -> None:
|
||||
"""Persist a new analysis job record."""
|
||||
async with get_connection() as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO analysis_jobs (id, document_id, status, created_at)
|
||||
|
|
@ -40,6 +53,7 @@ async def insert(job: AnalysisJob) -> None:
|
|||
|
||||
|
||||
async def find_all(*, limit: int = 200, offset: int = 0) -> list[AnalysisJob]:
|
||||
"""Return analysis jobs with document info, newest first."""
|
||||
async with get_connection() as db:
|
||||
cursor = await db.execute(
|
||||
f"{_SELECT_WITH_DOC} ORDER BY aj.created_at DESC LIMIT ? OFFSET ?",
|
||||
|
|
@ -50,31 +64,51 @@ async def find_all(*, limit: int = 200, offset: int = 0) -> list[AnalysisJob]:
|
|||
|
||||
|
||||
async def find_by_id(job_id: str) -> AnalysisJob | None:
|
||||
"""Find an analysis job by ID (with document filename), or return None."""
|
||||
async with get_connection() as db:
|
||||
cursor = await db.execute(
|
||||
f"{_SELECT_WITH_DOC} WHERE aj.id = ?", (job_id,)
|
||||
)
|
||||
cursor = await db.execute(f"{_SELECT_WITH_DOC} WHERE aj.id = ?", (job_id,))
|
||||
row = await cursor.fetchone()
|
||||
return _row_to_job(row) if row else None
|
||||
|
||||
|
||||
async def update_status(job: AnalysisJob) -> None:
|
||||
"""Persist all mutable fields of an analysis job (status, results, timestamps)."""
|
||||
async with get_connection() as db:
|
||||
await db.execute(
|
||||
"""UPDATE analysis_jobs
|
||||
SET status = ?, content_markdown = ?, content_html = ?,
|
||||
pages_json = ?, error_message = ?, started_at = ?, completed_at = ?
|
||||
pages_json = ?, document_json = ?, chunks_json = ?,
|
||||
error_message = ?, started_at = ?, completed_at = ?
|
||||
WHERE id = ?""",
|
||||
(job.status.value, job.content_markdown, job.content_html,
|
||||
job.pages_json, job.error_message,
|
||||
str(job.started_at) if job.started_at else None,
|
||||
str(job.completed_at) if job.completed_at else None,
|
||||
job.id),
|
||||
(
|
||||
job.status.value,
|
||||
job.content_markdown,
|
||||
job.content_html,
|
||||
job.pages_json,
|
||||
job.document_json,
|
||||
job.chunks_json,
|
||||
job.error_message,
|
||||
str(job.started_at) if job.started_at else None,
|
||||
str(job.completed_at) if job.completed_at else None,
|
||||
job.id,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def update_chunks(job_id: str, chunks_json: str) -> bool:
|
||||
"""Update only the chunks_json column for a completed analysis."""
|
||||
async with get_connection() as db:
|
||||
cursor = await db.execute(
|
||||
"UPDATE analysis_jobs SET chunks_json = ? WHERE id = ?",
|
||||
(chunks_json, job_id),
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
async def delete(job_id: str) -> bool:
|
||||
"""Delete an analysis job by ID. Returns True if a row was removed."""
|
||||
async with get_connection() as db:
|
||||
cursor = await db.execute("DELETE FROM analysis_jobs WHERE id = ?", (job_id,))
|
||||
await db.commit()
|
||||
|
|
@ -84,8 +118,6 @@ async def delete(job_id: str) -> bool:
|
|||
async def delete_by_document(document_id: str) -> int:
|
||||
"""Delete all analysis jobs for a given document. Returns count deleted."""
|
||||
async with get_connection() as db:
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM analysis_jobs WHERE document_id = ?", (document_id,)
|
||||
)
|
||||
cursor = await db.execute("DELETE FROM analysis_jobs WHERE document_id = ?", (document_id,))
|
||||
await db.commit()
|
||||
return cursor.rowcount
|
||||
|
|
|
|||
|
|
@ -4,13 +4,16 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from infra.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_PATH = os.environ.get("DB_PATH", "./data/docling_studio.db")
|
||||
DB_PATH = settings.db_path
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
|
|
@ -30,6 +33,8 @@ CREATE TABLE IF NOT EXISTS analysis_jobs (
|
|||
content_markdown TEXT,
|
||||
content_html TEXT,
|
||||
pages_json TEXT,
|
||||
document_json TEXT,
|
||||
chunks_json TEXT,
|
||||
error_message TEXT,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
|
|
@ -42,11 +47,29 @@ CREATE INDEX IF NOT EXISTS idx_documents_created_at ON documents(created_at);
|
|||
"""
|
||||
|
||||
|
||||
_MIGRATIONS = [
|
||||
("document_json", "ALTER TABLE analysis_jobs ADD COLUMN document_json TEXT"),
|
||||
("chunks_json", "ALTER TABLE analysis_jobs ADD COLUMN chunks_json TEXT"),
|
||||
]
|
||||
|
||||
|
||||
async def _run_migrations(db: aiosqlite.Connection) -> None:
|
||||
"""Add columns that may be missing in older databases."""
|
||||
cursor = await db.execute("PRAGMA table_info(analysis_jobs)")
|
||||
existing = {row[1] for row in await cursor.fetchall()}
|
||||
for col_name, ddl in _MIGRATIONS:
|
||||
if col_name not in existing:
|
||||
await db.execute(ddl)
|
||||
logger.info("Migration: added column %s to analysis_jobs", col_name)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Create database file and tables if they don't exist."""
|
||||
os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True)
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.executescript(_SCHEMA)
|
||||
await _run_migrations(db)
|
||||
await db.commit()
|
||||
logger.info("Database initialized at %s", DB_PATH)
|
||||
|
||||
|
|
@ -60,7 +83,7 @@ async def get_db() -> aiosqlite.Connection:
|
|||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_connection():
|
||||
async def get_connection() -> AsyncIterator[aiosqlite.Connection]:
|
||||
"""Context manager that opens and auto-closes a database connection."""
|
||||
db = await get_db()
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -2,11 +2,18 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from domain.models import Document
|
||||
from persistence.database import get_connection
|
||||
|
||||
|
||||
def _row_to_document(row) -> Document:
|
||||
created = row["created_at"]
|
||||
if isinstance(created, str):
|
||||
created = datetime.fromisoformat(created)
|
||||
if created.tzinfo is None:
|
||||
created = created.replace(tzinfo=UTC)
|
||||
return Document(
|
||||
id=row["id"],
|
||||
filename=row["filename"],
|
||||
|
|
@ -14,22 +21,31 @@ def _row_to_document(row) -> Document:
|
|||
file_size=row["file_size"],
|
||||
page_count=row["page_count"],
|
||||
storage_path=row["storage_path"],
|
||||
created_at=row["created_at"],
|
||||
created_at=created,
|
||||
)
|
||||
|
||||
|
||||
async def insert(doc: Document) -> None:
|
||||
"""Persist a new document record."""
|
||||
async with get_connection() as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO documents (id, filename, content_type, file_size, page_count, storage_path, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(doc.id, doc.filename, doc.content_type, doc.file_size,
|
||||
doc.page_count, doc.storage_path, str(doc.created_at)),
|
||||
(
|
||||
doc.id,
|
||||
doc.filename,
|
||||
doc.content_type,
|
||||
doc.file_size,
|
||||
doc.page_count,
|
||||
doc.storage_path,
|
||||
str(doc.created_at),
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def find_all(*, limit: int = 200, offset: int = 0) -> list[Document]:
|
||||
"""Return documents ordered by creation date (newest first)."""
|
||||
async with get_connection() as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM documents ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
|
|
@ -40,6 +56,7 @@ async def find_all(*, limit: int = 200, offset: int = 0) -> list[Document]:
|
|||
|
||||
|
||||
async def find_by_id(doc_id: str) -> Document | None:
|
||||
"""Find a document by its ID, or return None."""
|
||||
async with get_connection() as db:
|
||||
cursor = await db.execute("SELECT * FROM documents WHERE id = ?", (doc_id,))
|
||||
row = await cursor.fetchone()
|
||||
|
|
@ -47,6 +64,7 @@ async def find_by_id(doc_id: str) -> Document | None:
|
|||
|
||||
|
||||
async def update_page_count(doc_id: str, page_count: int) -> None:
|
||||
"""Update the page count after conversion has determined it."""
|
||||
async with get_connection() as db:
|
||||
await db.execute(
|
||||
"UPDATE documents SET page_count = ? WHERE id = ?",
|
||||
|
|
@ -56,6 +74,7 @@ async def update_page_count(doc_id: str, page_count: int) -> None:
|
|||
|
||||
|
||||
async def delete(doc_id: str) -> bool:
|
||||
"""Delete a document by ID. Returns True if a row was removed."""
|
||||
async with get_connection() as db:
|
||||
cursor = await db.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
|
||||
await db.commit()
|
||||
|
|
|
|||
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
|
||||
fastapi>=0.115.0,<1.0.0
|
||||
uvicorn[standard]>=0.32.0,<1.0.0
|
||||
|
|
@ -6,3 +5,4 @@ python-multipart>=0.0.12
|
|||
pdf2image>=1.17.0,<2.0.0
|
||||
pillow>=10.0.0,<11.0.0
|
||||
aiosqlite>=0.20.0,<1.0.0
|
||||
httpx>=0.27.0,<1.0.0
|
||||
|
|
|
|||
|
|
@ -1,108 +1,215 @@
|
|||
"""Analysis service — async document parsing orchestration."""
|
||||
"""Analysis service — async document parsing orchestration.
|
||||
|
||||
Uses an injected DocumentConverter (port) so the service is decoupled
|
||||
from the conversion implementation (local Docling lib vs remote Docling Serve).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import asdict
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from domain.models import AnalysisJob
|
||||
from domain.parsing import ConversionOptions, ConversionResult, convert_document
|
||||
from domain.models import AnalysisJob, AnalysisStatus
|
||||
from domain.value_objects import ChunkingOptions, ChunkResult, ConversionOptions, ConversionResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from domain.ports import DocumentChunker, DocumentConverter
|
||||
from persistence import analysis_repo, document_repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum time (seconds) allowed for a single document conversion.
|
||||
CONVERSION_TIMEOUT = int(__import__("os").environ.get("CONVERSION_TIMEOUT", "600"))
|
||||
|
||||
def _chunk_to_dict(c: ChunkResult) -> dict:
|
||||
"""Serialize ChunkResult to a camelCase dict matching the frontend API contract."""
|
||||
return {
|
||||
"text": c.text,
|
||||
"headings": c.headings,
|
||||
"sourcePage": c.source_page,
|
||||
"tokenCount": c.token_count,
|
||||
"bboxes": [{"page": b.page, "bbox": b.bbox} for b in c.bboxes],
|
||||
}
|
||||
|
||||
|
||||
async def create(document_id: str, *, pipeline_options: dict | None = None) -> AnalysisJob:
|
||||
"""Create a new analysis job and launch background processing."""
|
||||
doc = await document_repo.find_by_id(document_id)
|
||||
if not doc:
|
||||
raise ValueError(f"Document not found: {document_id}")
|
||||
|
||||
job = AnalysisJob(document_id=document_id)
|
||||
job.document_filename = doc.filename
|
||||
await analysis_repo.insert(job)
|
||||
|
||||
# Fire background task with error logging callback
|
||||
task = asyncio.create_task(
|
||||
_run_analysis(job.id, doc.storage_path, doc.filename, pipeline_options)
|
||||
)
|
||||
task.add_done_callback(_on_task_done)
|
||||
|
||||
return job
|
||||
# Maximum number of concurrent analysis jobs to prevent resource exhaustion.
|
||||
_DEFAULT_MAX_CONCURRENT = 3
|
||||
|
||||
|
||||
def _on_task_done(task: asyncio.Task) -> None:
|
||||
"""Log unhandled exceptions from background analysis tasks."""
|
||||
class AnalysisService:
|
||||
"""Orchestrates document analysis using an injected converter."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
converter: DocumentConverter,
|
||||
chunker: DocumentChunker | None = None,
|
||||
conversion_timeout: int = 600,
|
||||
max_concurrent: int = _DEFAULT_MAX_CONCURRENT,
|
||||
):
|
||||
self._converter = converter
|
||||
self._chunker = chunker
|
||||
self._conversion_timeout = conversion_timeout
|
||||
self._semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
document_id: str,
|
||||
*,
|
||||
pipeline_options: dict | None = None,
|
||||
chunking_options: dict | None = None,
|
||||
) -> AnalysisJob:
|
||||
"""Create a new analysis job and launch background processing."""
|
||||
doc = await document_repo.find_by_id(document_id)
|
||||
if not doc:
|
||||
raise ValueError(f"Document not found: {document_id}")
|
||||
|
||||
job = AnalysisJob(document_id=document_id)
|
||||
job.document_filename = doc.filename
|
||||
await analysis_repo.insert(job)
|
||||
|
||||
task = asyncio.create_task(
|
||||
self._run_analysis(
|
||||
job.id,
|
||||
doc.storage_path,
|
||||
doc.filename,
|
||||
pipeline_options,
|
||||
chunking_options,
|
||||
)
|
||||
)
|
||||
task.add_done_callback(functools.partial(_on_task_done, job_id=job.id))
|
||||
|
||||
return job
|
||||
|
||||
async def find_all(self) -> list[AnalysisJob]:
|
||||
"""Return all analysis jobs, newest first."""
|
||||
return await analysis_repo.find_all()
|
||||
|
||||
async def find_by_id(self, job_id: str) -> AnalysisJob | None:
|
||||
"""Find an analysis job by ID, or return None."""
|
||||
return await analysis_repo.find_by_id(job_id)
|
||||
|
||||
async def delete(self, job_id: str) -> bool:
|
||||
"""Delete an analysis job. Returns True if it existed."""
|
||||
return await analysis_repo.delete(job_id)
|
||||
|
||||
async def rechunk(self, job_id: str, chunking_options: dict) -> list[ChunkResult]:
|
||||
"""Re-chunk an existing completed analysis with new options."""
|
||||
job = await analysis_repo.find_by_id(job_id)
|
||||
if not job:
|
||||
raise ValueError(f"Analysis not found: {job_id}")
|
||||
if job.status != AnalysisStatus.COMPLETED:
|
||||
raise ValueError(f"Analysis is not completed: {job_id}")
|
||||
if not job.document_json:
|
||||
raise ValueError(f"No document data available for re-chunking: {job_id}")
|
||||
if not self._chunker:
|
||||
raise ValueError("Chunking is not available")
|
||||
|
||||
options = ChunkingOptions(**chunking_options)
|
||||
chunks = await self._chunker.chunk(job.document_json, options)
|
||||
|
||||
chunks_json = json.dumps([_chunk_to_dict(c) for c in chunks])
|
||||
await analysis_repo.update_chunks(job_id, chunks_json)
|
||||
|
||||
return chunks
|
||||
|
||||
async def _run_analysis(
|
||||
self,
|
||||
job_id: str,
|
||||
file_path: str,
|
||||
filename: str,
|
||||
pipeline_options: dict | None = None,
|
||||
chunking_options: dict | None = None,
|
||||
) -> None:
|
||||
"""Background task: run conversion and optionally chunk.
|
||||
|
||||
Acquires the concurrency semaphore to limit parallel conversions
|
||||
and prevent CPU/memory exhaustion on modest hardware.
|
||||
"""
|
||||
async with self._semaphore:
|
||||
await self._run_analysis_inner(
|
||||
job_id, file_path, filename, pipeline_options, chunking_options
|
||||
)
|
||||
|
||||
async def _run_analysis_inner(
|
||||
self,
|
||||
job_id: str,
|
||||
file_path: str,
|
||||
filename: str,
|
||||
pipeline_options: dict | None = None,
|
||||
chunking_options: dict | None = None,
|
||||
) -> None:
|
||||
"""Inner analysis logic — called under the concurrency semaphore."""
|
||||
try:
|
||||
job = await analysis_repo.find_by_id(job_id)
|
||||
if not job:
|
||||
logger.error("Analysis job %s not found", job_id)
|
||||
return
|
||||
|
||||
job.mark_running()
|
||||
await analysis_repo.update_status(job)
|
||||
logger.info("Analysis started: %s (file: %s)", job_id, filename)
|
||||
|
||||
options = ConversionOptions(**(pipeline_options or {}))
|
||||
|
||||
result: ConversionResult = await asyncio.wait_for(
|
||||
self._converter.convert(file_path, options),
|
||||
timeout=self._conversion_timeout,
|
||||
)
|
||||
|
||||
pages_json = json.dumps([asdict(p) for p in result.pages])
|
||||
|
||||
chunks_json = None
|
||||
if chunking_options and self._chunker and result.document_json:
|
||||
chunk_opts = ChunkingOptions(**chunking_options)
|
||||
chunks = await self._chunker.chunk(result.document_json, chunk_opts)
|
||||
chunks_json = json.dumps([_chunk_to_dict(c) for c in chunks])
|
||||
logger.info("Chunking produced %d chunks for job %s", len(chunks), job_id)
|
||||
|
||||
job.mark_completed(
|
||||
markdown=result.content_markdown,
|
||||
html=result.content_html,
|
||||
pages_json=pages_json,
|
||||
document_json=result.document_json,
|
||||
chunks_json=chunks_json,
|
||||
)
|
||||
await analysis_repo.update_status(job)
|
||||
|
||||
if result.page_count:
|
||||
await document_repo.update_page_count(job.document_id, result.page_count)
|
||||
|
||||
logger.info("Analysis completed: %s (%d pages)", job_id, result.page_count)
|
||||
|
||||
except TimeoutError:
|
||||
logger.error("Analysis timed out after %ds: %s", self._conversion_timeout, job_id)
|
||||
await _mark_failed(job_id, f"Conversion timed out after {self._conversion_timeout}s")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Analysis failed: %s", job_id)
|
||||
await _mark_failed(job_id, str(e))
|
||||
|
||||
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _on_task_done(task: asyncio.Task, *, job_id: str) -> None:
|
||||
"""Log unhandled exceptions from background analysis tasks and mark job as FAILED."""
|
||||
if task.cancelled():
|
||||
logger.warning("Analysis task was cancelled")
|
||||
logger.warning("Analysis task was cancelled: %s", job_id)
|
||||
_schedule_mark_failed(job_id, "Task was cancelled")
|
||||
return
|
||||
exc = task.exception()
|
||||
if exc:
|
||||
logger.error("Unhandled exception in analysis task: %s", exc, exc_info=True)
|
||||
logger.error("Unhandled exception in analysis task %s: %s", job_id, exc, exc_info=True)
|
||||
_schedule_mark_failed(job_id, str(exc))
|
||||
|
||||
|
||||
async def find_all() -> list[AnalysisJob]:
|
||||
return await analysis_repo.find_all()
|
||||
|
||||
|
||||
async def find_by_id(job_id: str) -> AnalysisJob | None:
|
||||
return await analysis_repo.find_by_id(job_id)
|
||||
|
||||
|
||||
async def delete(job_id: str) -> bool:
|
||||
return await analysis_repo.delete(job_id)
|
||||
|
||||
|
||||
async def _run_analysis(
|
||||
job_id: str, file_path: str, filename: str, pipeline_options: dict | None = None,
|
||||
) -> None:
|
||||
"""Background task: run Docling conversion and update job status."""
|
||||
try:
|
||||
job = await analysis_repo.find_by_id(job_id)
|
||||
if not job:
|
||||
logger.error("Analysis job %s not found", job_id)
|
||||
return
|
||||
|
||||
job.mark_running()
|
||||
await analysis_repo.update_status(job)
|
||||
logger.info("Analysis started: %s (file: %s)", job_id, filename)
|
||||
|
||||
# Build conversion options from pipeline dict
|
||||
options = ConversionOptions(**(pipeline_options or {}))
|
||||
|
||||
# Run blocking Docling conversion in a thread with timeout
|
||||
result: ConversionResult = await asyncio.wait_for(
|
||||
asyncio.to_thread(convert_document, file_path, options),
|
||||
timeout=CONVERSION_TIMEOUT,
|
||||
)
|
||||
|
||||
pages_json = json.dumps([asdict(p) for p in result.pages])
|
||||
|
||||
job.mark_completed(
|
||||
markdown=result.content_markdown,
|
||||
html=result.content_html,
|
||||
pages_json=pages_json,
|
||||
)
|
||||
await analysis_repo.update_status(job)
|
||||
|
||||
# Update document page count if available
|
||||
if result.page_count:
|
||||
await document_repo.update_page_count(job.document_id, result.page_count)
|
||||
|
||||
logger.info("Analysis completed: %s (%d pages)", job_id, result.page_count)
|
||||
|
||||
except TimeoutError:
|
||||
logger.error("Analysis timed out after %ds: %s", CONVERSION_TIMEOUT, job_id)
|
||||
await _mark_failed(job_id, f"Conversion timed out after {CONVERSION_TIMEOUT}s")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Analysis failed: %s", job_id)
|
||||
await _mark_failed(job_id, str(e))
|
||||
def _schedule_mark_failed(job_id: str, error: str) -> None:
|
||||
"""Schedule _mark_failed as a tracked background task."""
|
||||
t = asyncio.ensure_future(_mark_failed(job_id, error))
|
||||
_background_tasks.add(t)
|
||||
t.add_done_callback(_background_tasks.discard)
|
||||
|
||||
|
||||
async def _mark_failed(job_id: str, error: str) -> None:
|
||||
|
|
@ -112,5 +219,7 @@ async def _mark_failed(job_id: str, error: str) -> None:
|
|||
if job:
|
||||
job.mark_failed(error)
|
||||
await analysis_repo.update_status(job)
|
||||
except OSError:
|
||||
logger.exception("Database I/O error marking job %s as failed", job_id)
|
||||
except Exception:
|
||||
logger.exception("Could not mark job %s as failed", job_id)
|
||||
logger.exception("Unexpected error marking job %s as failed", job_id)
|
||||
|
|
|
|||
|
|
@ -10,11 +10,12 @@ import uuid
|
|||
from pdf2image import convert_from_bytes, pdfinfo_from_bytes
|
||||
|
||||
from domain.models import Document
|
||||
from infra.settings import settings
|
||||
from persistence import analysis_repo, document_repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "./uploads")
|
||||
UPLOAD_DIR = settings.upload_dir
|
||||
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||
|
||||
|
||||
|
|
@ -22,8 +23,14 @@ MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
|||
_PDF_MAGIC = b"%PDF"
|
||||
|
||||
|
||||
_UPLOAD_CHUNK_SIZE = 64 * 1024 # 64 KB chunks for streaming writes
|
||||
|
||||
|
||||
async def upload(filename: str, content_type: str, file_content: bytes) -> Document:
|
||||
"""Save uploaded file to disk and persist metadata."""
|
||||
"""Save uploaded file to disk and persist metadata.
|
||||
|
||||
Writes the file in fixed-size chunks to keep peak memory usage low.
|
||||
"""
|
||||
if len(file_content) > MAX_FILE_SIZE:
|
||||
raise ValueError("File too large (max 50 MB)")
|
||||
|
||||
|
|
@ -32,12 +39,14 @@ async def upload(filename: str, content_type: str, file_content: bytes) -> Docum
|
|||
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
ext = os.path.splitext(filename)[1] or ".pdf"
|
||||
ext = ".pdf" # Content already validated as PDF
|
||||
safe_name = f"{uuid.uuid4()}{ext}"
|
||||
file_path = os.path.join(UPLOAD_DIR, safe_name)
|
||||
|
||||
# Write in chunks to avoid doubling memory usage for large files
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(file_content)
|
||||
for offset in range(0, len(file_content), _UPLOAD_CHUNK_SIZE):
|
||||
f.write(file_content[offset : offset + _UPLOAD_CHUNK_SIZE])
|
||||
|
||||
# Count PDF pages
|
||||
page_count = _count_pages(file_content)
|
||||
|
|
@ -54,10 +63,12 @@ async def upload(filename: str, content_type: str, file_content: bytes) -> Docum
|
|||
|
||||
|
||||
async def find_all() -> list[Document]:
|
||||
"""Return all documents, newest first."""
|
||||
return await document_repo.find_all()
|
||||
|
||||
|
||||
async def find_by_id(doc_id: str) -> Document | None:
|
||||
"""Find a document by its ID, or return None."""
|
||||
return await document_repo.find_by_id(doc_id)
|
||||
|
||||
|
||||
|
|
@ -70,12 +81,20 @@ async def delete(doc_id: str) -> bool:
|
|||
# Delete associated analyses first (cascade)
|
||||
await analysis_repo.delete_by_document(doc_id)
|
||||
|
||||
# Delete file from disk
|
||||
# Delete file from disk (only if inside UPLOAD_DIR)
|
||||
try:
|
||||
if os.path.exists(doc.storage_path):
|
||||
os.unlink(doc.storage_path)
|
||||
real_path = os.path.realpath(doc.storage_path)
|
||||
real_upload_dir = os.path.realpath(UPLOAD_DIR)
|
||||
if real_path.startswith(real_upload_dir + os.sep) and os.path.exists(real_path):
|
||||
os.unlink(real_path)
|
||||
elif os.path.exists(doc.storage_path):
|
||||
logger.warning("Refused to delete file outside upload dir: %s", doc.storage_path)
|
||||
except FileNotFoundError:
|
||||
logger.info("File already removed: %s", doc.storage_path)
|
||||
except PermissionError:
|
||||
logger.error("Permission denied deleting file: %s", doc.storage_path)
|
||||
except OSError:
|
||||
logger.warning("Could not delete file: %s", doc.storage_path)
|
||||
logger.warning("Could not delete file: %s", doc.storage_path, exc_info=True)
|
||||
|
||||
return await document_repo.delete(doc_id)
|
||||
|
||||
|
|
@ -96,6 +115,9 @@ def _count_pages(file_content: bytes) -> int | None:
|
|||
try:
|
||||
info = pdfinfo_from_bytes(file_content)
|
||||
return info.get("Pages")
|
||||
except Exception:
|
||||
logger.warning("Could not count pages", exc_info=True)
|
||||
except (FileNotFoundError, OSError) as exc:
|
||||
logger.warning("Could not count pages: %s", exc)
|
||||
return None
|
||||
except Exception:
|
||||
logger.warning("Unexpected error counting pages", exc_info=True)
|
||||
return None
|
||||
|
|
|
|||
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."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
|
@ -14,11 +14,24 @@ def client():
|
|||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_analysis_service(client):
|
||||
"""Inject a mock AnalysisService into app.state for the duration of the test."""
|
||||
mock_svc = MagicMock()
|
||||
original = getattr(app.state, "analysis_service", None)
|
||||
app.state.analysis_service = mock_svc
|
||||
yield mock_svc
|
||||
app.state.analysis_service = original
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
def test_health(self, client):
|
||||
resp = client.get("/health")
|
||||
resp = client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "ok"}
|
||||
data = resp.json()
|
||||
assert data["status"] in ("ok", "degraded")
|
||||
assert "engine" in data
|
||||
assert "database" in data
|
||||
|
||||
|
||||
class TestDocumentEndpoints:
|
||||
|
|
@ -41,8 +54,12 @@ class TestDocumentEndpoints:
|
|||
@patch("services.document_service.find_by_id", new_callable=AsyncMock)
|
||||
def test_get_document(self, mock_find, client):
|
||||
mock_find.return_value = Document(
|
||||
id="d1", filename="test.pdf", content_type="application/pdf",
|
||||
file_size=2048, page_count=3, storage_path="/tmp/test",
|
||||
id="d1",
|
||||
filename="test.pdf",
|
||||
content_type="application/pdf",
|
||||
file_size=2048,
|
||||
page_count=3,
|
||||
storage_path="/tmp/test",
|
||||
)
|
||||
|
||||
resp = client.get("/api/documents/d1")
|
||||
|
|
@ -62,8 +79,10 @@ class TestDocumentEndpoints:
|
|||
@patch("services.document_service.upload", new_callable=AsyncMock)
|
||||
def test_upload_document(self, mock_upload, client):
|
||||
mock_upload.return_value = Document(
|
||||
id="new-1", filename="uploaded.pdf",
|
||||
content_type="application/pdf", file_size=512,
|
||||
id="new-1",
|
||||
filename="uploaded.pdf",
|
||||
content_type="application/pdf",
|
||||
file_size=512,
|
||||
storage_path="/tmp/uploaded",
|
||||
)
|
||||
|
||||
|
|
@ -84,7 +103,20 @@ class TestDocumentEndpoints:
|
|||
"/api/documents/upload",
|
||||
files={"file": ("big.pdf", b"x", "application/pdf")},
|
||||
)
|
||||
assert resp.status_code == 413
|
||||
assert resp.status_code == 400
|
||||
|
||||
@patch("services.document_service.find_by_id", new_callable=AsyncMock)
|
||||
def test_preview_page_out_of_range(self, mock_find, client):
|
||||
mock_find.return_value = Document(
|
||||
id="d1",
|
||||
filename="test.pdf",
|
||||
page_count=3,
|
||||
storage_path="/tmp/test.pdf",
|
||||
)
|
||||
|
||||
resp = client.get("/api/documents/d1/preview?page=10")
|
||||
assert resp.status_code == 400
|
||||
assert "out of range" in resp.json()["detail"]
|
||||
|
||||
@patch("services.document_service.delete", new_callable=AsyncMock)
|
||||
def test_delete_document(self, mock_delete, client):
|
||||
|
|
@ -102,11 +134,12 @@ class TestDocumentEndpoints:
|
|||
|
||||
|
||||
class TestAnalysisEndpoints:
|
||||
@patch("services.analysis_service.find_all", new_callable=AsyncMock)
|
||||
def test_list_analyses(self, mock_find_all, client):
|
||||
mock_find_all.return_value = [
|
||||
AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"),
|
||||
]
|
||||
def test_list_analyses(self, client, mock_analysis_service):
|
||||
mock_analysis_service.find_all = AsyncMock(
|
||||
return_value=[
|
||||
AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"),
|
||||
]
|
||||
)
|
||||
|
||||
resp = client.get("/api/analyses")
|
||||
assert resp.status_code == 200
|
||||
|
|
@ -117,11 +150,10 @@ class TestAnalysisEndpoints:
|
|||
assert data[0]["documentFilename"] == "test.pdf"
|
||||
assert data[0]["status"] == "PENDING"
|
||||
|
||||
@patch("services.analysis_service.find_by_id", new_callable=AsyncMock)
|
||||
def test_get_analysis(self, mock_find, client):
|
||||
def test_get_analysis(self, client, mock_analysis_service):
|
||||
job = AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
|
||||
job.mark_running()
|
||||
mock_find.return_value = job
|
||||
mock_analysis_service.find_by_id = AsyncMock(return_value=job)
|
||||
|
||||
resp = client.get("/api/analyses/j1")
|
||||
assert resp.status_code == 200
|
||||
|
|
@ -129,17 +161,19 @@ class TestAnalysisEndpoints:
|
|||
assert data["status"] == "RUNNING"
|
||||
assert data["startedAt"] is not None
|
||||
|
||||
@patch("services.analysis_service.find_by_id", new_callable=AsyncMock)
|
||||
def test_get_analysis_not_found(self, mock_find, client):
|
||||
mock_find.return_value = None
|
||||
def test_get_analysis_not_found(self, client, mock_analysis_service):
|
||||
mock_analysis_service.find_by_id = AsyncMock(return_value=None)
|
||||
|
||||
resp = client.get("/api/analyses/missing")
|
||||
assert resp.status_code == 404
|
||||
|
||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
||||
def test_create_analysis(self, mock_create, client):
|
||||
mock_create.return_value = AnalysisJob(
|
||||
id="j1", document_id="d1", document_filename="test.pdf",
|
||||
def test_create_analysis(self, client, mock_analysis_service):
|
||||
mock_analysis_service.create = AsyncMock(
|
||||
return_value=AnalysisJob(
|
||||
id="j1",
|
||||
document_id="d1",
|
||||
document_filename="test.pdf",
|
||||
)
|
||||
)
|
||||
|
||||
resp = client.post("/api/analyses", json={"documentId": "d1"})
|
||||
|
|
@ -147,34 +181,44 @@ class TestAnalysisEndpoints:
|
|||
data = resp.json()
|
||||
assert data["id"] == "j1"
|
||||
assert data["documentId"] == "d1"
|
||||
mock_create.assert_called_once_with("d1", pipeline_options=None)
|
||||
|
||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
||||
def test_create_analysis_with_pipeline_options(self, mock_create, client):
|
||||
mock_create.return_value = AnalysisJob(
|
||||
id="j2", document_id="d1", document_filename="test.pdf",
|
||||
mock_analysis_service.create.assert_called_once_with(
|
||||
"d1",
|
||||
pipeline_options=None,
|
||||
chunking_options=None,
|
||||
)
|
||||
|
||||
resp = client.post("/api/analyses", json={
|
||||
"documentId": "d1",
|
||||
"pipelineOptions": {
|
||||
"do_ocr": False,
|
||||
"do_table_structure": True,
|
||||
"table_mode": "fast",
|
||||
"do_code_enrichment": True,
|
||||
"do_formula_enrichment": False,
|
||||
"do_picture_classification": False,
|
||||
"do_picture_description": False,
|
||||
"generate_picture_images": True,
|
||||
"generate_page_images": False,
|
||||
"images_scale": 2.0,
|
||||
}
|
||||
})
|
||||
def test_create_analysis_with_pipeline_options(self, client, mock_analysis_service):
|
||||
mock_analysis_service.create = AsyncMock(
|
||||
return_value=AnalysisJob(
|
||||
id="j2",
|
||||
document_id="d1",
|
||||
document_filename="test.pdf",
|
||||
)
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/analyses",
|
||||
json={
|
||||
"documentId": "d1",
|
||||
"pipelineOptions": {
|
||||
"do_ocr": False,
|
||||
"do_table_structure": True,
|
||||
"table_mode": "fast",
|
||||
"do_code_enrichment": True,
|
||||
"do_formula_enrichment": False,
|
||||
"do_picture_classification": False,
|
||||
"do_picture_description": False,
|
||||
"generate_picture_images": True,
|
||||
"generate_page_images": False,
|
||||
"images_scale": 2.0,
|
||||
},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == "j2"
|
||||
|
||||
call_kwargs = mock_create.call_args
|
||||
call_kwargs = mock_analysis_service.create.call_args
|
||||
opts = call_kwargs.kwargs["pipeline_options"]
|
||||
assert opts["do_ocr"] is False
|
||||
assert opts["table_mode"] == "fast"
|
||||
|
|
@ -182,51 +226,50 @@ class TestAnalysisEndpoints:
|
|||
assert opts["generate_picture_images"] is True
|
||||
assert opts["images_scale"] == 2.0
|
||||
|
||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
||||
def test_create_analysis_with_partial_pipeline_options(self, mock_create, client):
|
||||
def test_create_analysis_with_partial_pipeline_options(self, client, mock_analysis_service):
|
||||
"""Pipeline options should use defaults for unspecified fields."""
|
||||
mock_create.return_value = AnalysisJob(
|
||||
id="j3", document_id="d1", document_filename="test.pdf",
|
||||
mock_analysis_service.create = AsyncMock(
|
||||
return_value=AnalysisJob(
|
||||
id="j3",
|
||||
document_id="d1",
|
||||
document_filename="test.pdf",
|
||||
)
|
||||
)
|
||||
|
||||
resp = client.post("/api/analyses", json={
|
||||
"documentId": "d1",
|
||||
"pipelineOptions": {"do_ocr": False}
|
||||
})
|
||||
resp = client.post(
|
||||
"/api/analyses", json={"documentId": "d1", "pipelineOptions": {"do_ocr": False}}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
opts = mock_create.call_args.kwargs["pipeline_options"]
|
||||
opts = mock_analysis_service.create.call_args.kwargs["pipeline_options"]
|
||||
assert opts["do_ocr"] is False
|
||||
# Defaults
|
||||
assert opts["do_table_structure"] is True
|
||||
assert opts["table_mode"] == "accurate"
|
||||
assert opts["do_code_enrichment"] is False
|
||||
|
||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
||||
def test_create_analysis_document_not_found(self, mock_create, client):
|
||||
mock_create.side_effect = ValueError("Document not found: d99")
|
||||
def test_create_analysis_document_not_found(self, client, mock_analysis_service):
|
||||
mock_analysis_service.create = AsyncMock(side_effect=ValueError("Document not found: d99"))
|
||||
|
||||
resp = client.post("/api/analyses", json={"documentId": "d99"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_create_analysis_empty_document_id(self, client):
|
||||
def test_create_analysis_empty_document_id(self, client, mock_analysis_service):
|
||||
resp = client.post("/api/analyses", json={"documentId": ""})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_create_analysis_whitespace_document_id(self, client):
|
||||
def test_create_analysis_whitespace_document_id(self, client, mock_analysis_service):
|
||||
resp = client.post("/api/analyses", json={"documentId": " "})
|
||||
assert resp.status_code == 400
|
||||
|
||||
@patch("services.analysis_service.delete", new_callable=AsyncMock)
|
||||
def test_delete_analysis(self, mock_delete, client):
|
||||
mock_delete.return_value = True
|
||||
def test_delete_analysis(self, client, mock_analysis_service):
|
||||
mock_analysis_service.delete = AsyncMock(return_value=True)
|
||||
|
||||
resp = client.delete("/api/analyses/j1")
|
||||
assert resp.status_code == 204
|
||||
|
||||
@patch("services.analysis_service.delete", new_callable=AsyncMock)
|
||||
def test_delete_analysis_not_found(self, mock_delete, client):
|
||||
mock_delete.return_value = False
|
||||
def test_delete_analysis_not_found(self, client, mock_analysis_service):
|
||||
mock_analysis_service.delete = AsyncMock(return_value=False)
|
||||
|
||||
resp = client.delete("/api/analyses/missing")
|
||||
assert resp.status_code == 404
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ misaligned overlays in the UI.
|
|||
import pytest
|
||||
from docling_core.types.doc.base import BoundingBox, CoordOrigin
|
||||
|
||||
from domain.bbox import EMPTY_BBOX, to_topleft_list
|
||||
from infra.bbox import EMPTY_BBOX, to_topleft_list
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Standard conversions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToTopleftListStandard:
|
||||
"""Normal bbox conversions (happy path)."""
|
||||
|
||||
|
|
@ -29,10 +30,10 @@ class TestToTopleftListStandard:
|
|||
result = to_topleft_list(bbox, page_height=792.0)
|
||||
|
||||
# After conversion: new_t = 792 - 700 = 92, new_b = 792 - 600 = 192
|
||||
assert result[0] == 50 # l unchanged
|
||||
assert result[1] == pytest.approx(92.0) # t = page_height - old_t
|
||||
assert result[2] == 200 # r unchanged
|
||||
assert result[3] == pytest.approx(192.0) # b = page_height - old_b
|
||||
assert result[0] == 50 # l unchanged
|
||||
assert result[1] == pytest.approx(92.0) # t = page_height - old_t
|
||||
assert result[2] == 200 # r unchanged
|
||||
assert result[3] == pytest.approx(192.0) # b = page_height - old_b
|
||||
|
||||
def test_result_has_positive_dimensions(self):
|
||||
"""Converted bbox should always have b > t (positive height)."""
|
||||
|
|
@ -60,6 +61,7 @@ class TestToTopleftListStandard:
|
|||
# Page format variations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPageFormats:
|
||||
"""Verify correct conversion across different page sizes."""
|
||||
|
||||
|
|
@ -105,6 +107,7 @@ class TestPageFormats:
|
|||
# Degenerate / edge-case bboxes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDegenerateBboxes:
|
||||
"""Bboxes that are invalid or degenerate should return EMPTY_BBOX."""
|
||||
|
||||
|
|
@ -151,6 +154,7 @@ class TestDegenerateBboxes:
|
|||
# Precision and boundary values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPrecision:
|
||||
"""Floating-point precision and edge values."""
|
||||
|
||||
|
|
|
|||
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 unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from docling.datamodel.base_models import InputFormat
|
||||
from docling.datamodel.pipeline_options import (
|
||||
|
||||
docling = pytest.importorskip("docling", reason="docling library not installed")
|
||||
|
||||
from docling.datamodel.base_models import InputFormat # noqa: E402
|
||||
from docling.datamodel.pipeline_options import ( # noqa: E402
|
||||
PdfPipelineOptions,
|
||||
TableFormerMode,
|
||||
)
|
||||
|
||||
from domain.parsing import (
|
||||
ConversionOptions,
|
||||
build_converter,
|
||||
convert_document,
|
||||
from domain.value_objects import ConversionOptions # noqa: E402
|
||||
from infra.local_converter import ( # noqa: E402
|
||||
_build_docling_converter as build_converter,
|
||||
)
|
||||
from infra.local_converter import ( # noqa: E402
|
||||
_convert_sync as convert_document,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_converter — verifies Docling pipeline options are wired correctly
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildConverter:
|
||||
"""Verify that build_converter produces a DocumentConverter with the right PdfPipelineOptions."""
|
||||
|
||||
|
|
@ -30,7 +41,7 @@ class TestBuildConverter:
|
|||
return fmt_opt.pipeline_options
|
||||
|
||||
def test_defaults(self):
|
||||
conv = build_converter()
|
||||
conv = build_converter(ConversionOptions())
|
||||
opts = self._get_pipeline_options(conv)
|
||||
assert opts.do_ocr is True
|
||||
assert opts.do_table_structure is True
|
||||
|
|
@ -99,18 +110,20 @@ class TestBuildConverter:
|
|||
assert opts.images_scale == 2.0
|
||||
|
||||
def test_all_options_combined(self):
|
||||
conv = build_converter(ConversionOptions(
|
||||
do_ocr=False,
|
||||
do_table_structure=True,
|
||||
table_mode="fast",
|
||||
do_code_enrichment=True,
|
||||
do_formula_enrichment=True,
|
||||
do_picture_classification=True,
|
||||
do_picture_description=True,
|
||||
generate_picture_images=True,
|
||||
generate_page_images=True,
|
||||
images_scale=1.5,
|
||||
))
|
||||
conv = build_converter(
|
||||
ConversionOptions(
|
||||
do_ocr=False,
|
||||
do_table_structure=True,
|
||||
table_mode="fast",
|
||||
do_code_enrichment=True,
|
||||
do_formula_enrichment=True,
|
||||
do_picture_classification=True,
|
||||
do_picture_description=True,
|
||||
generate_picture_images=True,
|
||||
generate_page_images=True,
|
||||
images_scale=1.5,
|
||||
)
|
||||
)
|
||||
opts = self._get_pipeline_options(conv)
|
||||
assert opts.do_ocr is False
|
||||
assert opts.do_table_structure is True
|
||||
|
|
@ -128,11 +141,12 @@ class TestBuildConverter:
|
|||
# convert_document — default vs custom converter routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConvertDocumentRouting:
|
||||
"""Verify convert_document uses default converter for default opts, custom otherwise."""
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
@patch("infra.local_converter._get_default_converter")
|
||||
@patch("infra.local_converter._build_docling_converter")
|
||||
def test_uses_default_converter_with_all_defaults(self, mock_build, mock_get_default):
|
||||
mock_conv = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
|
|
@ -140,16 +154,17 @@ class TestConvertDocumentRouting:
|
|||
mock_result.document.iterate_items.return_value = []
|
||||
mock_result.document.export_to_markdown.return_value = ""
|
||||
mock_result.document.export_to_html.return_value = ""
|
||||
mock_result.document.export_to_dict.return_value = {}
|
||||
mock_conv.convert.return_value = mock_result
|
||||
mock_get_default.return_value = mock_conv
|
||||
|
||||
convert_document("/tmp/test.pdf")
|
||||
convert_document("/tmp/test.pdf", ConversionOptions())
|
||||
|
||||
mock_get_default.assert_called_once()
|
||||
mock_build.assert_not_called()
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
@patch("infra.local_converter._get_default_converter")
|
||||
@patch("infra.local_converter._build_docling_converter")
|
||||
def test_uses_custom_converter_when_ocr_disabled(self, mock_build, mock_get_default):
|
||||
mock_conv = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
|
|
@ -157,6 +172,7 @@ class TestConvertDocumentRouting:
|
|||
mock_result.document.iterate_items.return_value = []
|
||||
mock_result.document.export_to_markdown.return_value = ""
|
||||
mock_result.document.export_to_html.return_value = ""
|
||||
mock_result.document.export_to_dict.return_value = {}
|
||||
mock_conv.convert.return_value = mock_result
|
||||
mock_build.return_value = mock_conv
|
||||
|
||||
|
|
@ -165,8 +181,8 @@ class TestConvertDocumentRouting:
|
|||
mock_build.assert_called_once()
|
||||
mock_get_default.assert_not_called()
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
@patch("infra.local_converter._get_default_converter")
|
||||
@patch("infra.local_converter._build_docling_converter")
|
||||
def test_uses_custom_converter_when_table_mode_fast(self, mock_build, mock_get_default):
|
||||
mock_conv = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
|
|
@ -174,6 +190,7 @@ class TestConvertDocumentRouting:
|
|||
mock_result.document.iterate_items.return_value = []
|
||||
mock_result.document.export_to_markdown.return_value = ""
|
||||
mock_result.document.export_to_html.return_value = ""
|
||||
mock_result.document.export_to_dict.return_value = {}
|
||||
mock_conv.convert.return_value = mock_result
|
||||
mock_build.return_value = mock_conv
|
||||
|
||||
|
|
@ -182,8 +199,8 @@ class TestConvertDocumentRouting:
|
|||
|
||||
mock_build.assert_called_once_with(opts)
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
@patch("infra.local_converter._get_default_converter")
|
||||
@patch("infra.local_converter._build_docling_converter")
|
||||
def test_uses_custom_converter_when_code_enrichment_on(self, mock_build, mock_get_default):
|
||||
mock_conv = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
|
|
@ -191,6 +208,7 @@ class TestConvertDocumentRouting:
|
|||
mock_result.document.iterate_items.return_value = []
|
||||
mock_result.document.export_to_markdown.return_value = ""
|
||||
mock_result.document.export_to_html.return_value = ""
|
||||
mock_result.document.export_to_dict.return_value = {}
|
||||
mock_conv.convert.return_value = mock_result
|
||||
mock_build.return_value = mock_conv
|
||||
|
||||
|
|
@ -199,8 +217,8 @@ class TestConvertDocumentRouting:
|
|||
|
||||
mock_build.assert_called_once_with(opts)
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
@patch("infra.local_converter._get_default_converter")
|
||||
@patch("infra.local_converter._build_docling_converter")
|
||||
def test_uses_custom_converter_when_formula_enrichment_on(self, mock_build, mock_get_default):
|
||||
mock_conv = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
|
|
@ -208,6 +226,7 @@ class TestConvertDocumentRouting:
|
|||
mock_result.document.iterate_items.return_value = []
|
||||
mock_result.document.export_to_markdown.return_value = ""
|
||||
mock_result.document.export_to_html.return_value = ""
|
||||
mock_result.document.export_to_dict.return_value = {}
|
||||
mock_conv.convert.return_value = mock_result
|
||||
mock_build.return_value = mock_conv
|
||||
|
||||
|
|
@ -215,8 +234,8 @@ class TestConvertDocumentRouting:
|
|||
|
||||
mock_build.assert_called_once()
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
@patch("infra.local_converter._get_default_converter")
|
||||
@patch("infra.local_converter._build_docling_converter")
|
||||
def test_uses_custom_converter_when_picture_options_on(self, mock_build, mock_get_default):
|
||||
mock_conv = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
|
|
@ -224,6 +243,7 @@ class TestConvertDocumentRouting:
|
|||
mock_result.document.iterate_items.return_value = []
|
||||
mock_result.document.export_to_markdown.return_value = ""
|
||||
mock_result.document.export_to_html.return_value = ""
|
||||
mock_result.document.export_to_dict.return_value = {}
|
||||
mock_conv.convert.return_value = mock_result
|
||||
mock_build.return_value = mock_conv
|
||||
|
||||
|
|
@ -231,8 +251,8 @@ class TestConvertDocumentRouting:
|
|||
|
||||
mock_build.assert_called_once()
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
@patch("infra.local_converter._get_default_converter")
|
||||
@patch("infra.local_converter._build_docling_converter")
|
||||
def test_uses_custom_converter_when_generate_images_on(self, mock_build, mock_get_default):
|
||||
mock_conv = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
|
|
@ -240,6 +260,7 @@ class TestConvertDocumentRouting:
|
|||
mock_result.document.iterate_items.return_value = []
|
||||
mock_result.document.export_to_markdown.return_value = ""
|
||||
mock_result.document.export_to_html.return_value = ""
|
||||
mock_result.document.export_to_dict.return_value = {}
|
||||
mock_conv.convert.return_value = mock_result
|
||||
mock_build.return_value = mock_conv
|
||||
|
||||
|
|
@ -247,8 +268,8 @@ class TestConvertDocumentRouting:
|
|||
|
||||
mock_build.assert_called_once()
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
@patch("infra.local_converter._get_default_converter")
|
||||
@patch("infra.local_converter._build_docling_converter")
|
||||
def test_uses_custom_converter_when_images_scale_changed(self, mock_build, mock_get_default):
|
||||
mock_conv = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
|
|
@ -256,6 +277,7 @@ class TestConvertDocumentRouting:
|
|||
mock_result.document.iterate_items.return_value = []
|
||||
mock_result.document.export_to_markdown.return_value = ""
|
||||
mock_result.document.export_to_html.return_value = ""
|
||||
mock_result.document.export_to_dict.return_value = {}
|
||||
mock_conv.convert.return_value = mock_result
|
||||
mock_build.return_value = mock_conv
|
||||
|
||||
|
|
@ -264,8 +286,8 @@ class TestConvertDocumentRouting:
|
|||
|
||||
mock_build.assert_called_once_with(opts)
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
@patch("infra.local_converter._get_default_converter")
|
||||
@patch("infra.local_converter._build_docling_converter")
|
||||
def test_forwards_all_options_to_build_converter(self, mock_build, mock_get_default):
|
||||
mock_conv = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
|
|
@ -273,6 +295,7 @@ class TestConvertDocumentRouting:
|
|||
mock_result.document.iterate_items.return_value = []
|
||||
mock_result.document.export_to_markdown.return_value = ""
|
||||
mock_result.document.export_to_html.return_value = ""
|
||||
mock_result.document.export_to_dict.return_value = {}
|
||||
mock_conv.convert.return_value = mock_result
|
||||
mock_build.return_value = mock_conv
|
||||
|
||||
|
|
@ -297,77 +320,92 @@ class TestConvertDocumentRouting:
|
|||
# Service layer — pipeline options forwarding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestServiceForwardsPipelineOptions:
|
||||
"""Verify analysis_service.create and _run_analysis forward pipeline options."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_doc(self):
|
||||
from domain.models import Document
|
||||
|
||||
return Document(id="d1", filename="test.pdf", storage_path="/tmp/test.pdf")
|
||||
|
||||
@pytest.fixture
|
||||
def mock_job(self):
|
||||
from domain.models import AnalysisJob
|
||||
|
||||
return AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
|
||||
|
||||
@patch("services.analysis_service.document_repo")
|
||||
@patch("services.analysis_service.analysis_repo")
|
||||
@patch("services.analysis_service._run_analysis")
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_passes_pipeline_options_to_run(
|
||||
self, mock_run, mock_analysis_repo, mock_doc_repo, mock_doc,
|
||||
self,
|
||||
mock_analysis_repo,
|
||||
mock_doc_repo,
|
||||
mock_doc,
|
||||
):
|
||||
mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc)
|
||||
mock_analysis_repo.insert = AsyncMock()
|
||||
# Patch _run_analysis as a coroutine that we can inspect
|
||||
mock_run.return_value = None
|
||||
|
||||
from services import analysis_service
|
||||
mock_converter = AsyncMock()
|
||||
from services.analysis_service import AnalysisService
|
||||
|
||||
svc = AnalysisService(converter=mock_converter)
|
||||
|
||||
opts = {"do_ocr": False, "table_mode": "fast"}
|
||||
|
||||
# We need to patch asyncio.create_task to capture the coroutine args
|
||||
with patch("services.analysis_service.asyncio.create_task") as mock_task:
|
||||
await analysis_service.create("d1", pipeline_options=opts)
|
||||
|
||||
# create_task should have been called with _run_analysis(...)
|
||||
await svc.create("d1", pipeline_options=opts)
|
||||
mock_task.assert_called_once()
|
||||
|
||||
@patch("services.analysis_service.document_repo")
|
||||
@patch("services.analysis_service.analysis_repo")
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_passes_none_when_no_options(
|
||||
self, mock_analysis_repo, mock_doc_repo, mock_doc,
|
||||
self,
|
||||
mock_analysis_repo,
|
||||
mock_doc_repo,
|
||||
mock_doc,
|
||||
):
|
||||
mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc)
|
||||
mock_analysis_repo.insert = AsyncMock()
|
||||
|
||||
from services import analysis_service
|
||||
mock_converter = AsyncMock()
|
||||
from services.analysis_service import AnalysisService
|
||||
|
||||
svc = AnalysisService(converter=mock_converter)
|
||||
|
||||
with patch("services.analysis_service.asyncio.create_task") as mock_task:
|
||||
await analysis_service.create("d1")
|
||||
await svc.create("d1")
|
||||
mock_task.assert_called_once()
|
||||
|
||||
@patch("services.analysis_service.analysis_repo")
|
||||
@patch("services.analysis_service.document_repo")
|
||||
@patch("services.analysis_service.convert_document")
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_analysis_forwards_options_to_convert(
|
||||
self, mock_convert, mock_doc_repo, mock_analysis_repo, mock_job,
|
||||
self,
|
||||
mock_doc_repo,
|
||||
mock_analysis_repo,
|
||||
mock_job,
|
||||
):
|
||||
from domain.parsing import ConversionResult, PageDetail
|
||||
from domain.value_objects import ConversionResult, PageDetail
|
||||
|
||||
mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
|
||||
mock_analysis_repo.update_status = AsyncMock()
|
||||
mock_doc_repo.update_page_count = AsyncMock()
|
||||
mock_convert.return_value = ConversionResult(
|
||||
|
||||
mock_converter = AsyncMock()
|
||||
mock_converter.convert.return_value = ConversionResult(
|
||||
page_count=1,
|
||||
content_markdown="# Test",
|
||||
content_html="<h1>Test</h1>",
|
||||
pages=[PageDetail(page_number=1, width=612.0, height=792.0)],
|
||||
)
|
||||
|
||||
from services.analysis_service import _run_analysis
|
||||
from services.analysis_service import AnalysisService
|
||||
|
||||
svc = AnalysisService(converter=mock_converter)
|
||||
|
||||
opts = {
|
||||
"do_ocr": False,
|
||||
|
|
@ -381,10 +419,10 @@ class TestServiceForwardsPipelineOptions:
|
|||
"images_scale": 2.0,
|
||||
}
|
||||
|
||||
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", opts)
|
||||
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", opts)
|
||||
|
||||
mock_convert.assert_called_once()
|
||||
call_args = mock_convert.call_args
|
||||
mock_converter.convert.assert_called_once()
|
||||
call_args = mock_converter.convert.call_args
|
||||
assert call_args[0][0] == "/tmp/test.pdf"
|
||||
conv_opts = call_args[0][1]
|
||||
assert conv_opts.do_ocr is False
|
||||
|
|
@ -395,47 +433,58 @@ class TestServiceForwardsPipelineOptions:
|
|||
|
||||
@patch("services.analysis_service.analysis_repo")
|
||||
@patch("services.analysis_service.document_repo")
|
||||
@patch("services.analysis_service.convert_document")
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_analysis_uses_defaults_when_no_options(
|
||||
self, mock_convert, mock_doc_repo, mock_analysis_repo, mock_job,
|
||||
self,
|
||||
mock_doc_repo,
|
||||
mock_analysis_repo,
|
||||
mock_job,
|
||||
):
|
||||
from domain.parsing import ConversionResult, PageDetail
|
||||
from domain.value_objects import ConversionResult, PageDetail
|
||||
|
||||
mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
|
||||
mock_analysis_repo.update_status = AsyncMock()
|
||||
mock_doc_repo.update_page_count = AsyncMock()
|
||||
mock_convert.return_value = ConversionResult(
|
||||
|
||||
mock_converter = AsyncMock()
|
||||
mock_converter.convert.return_value = ConversionResult(
|
||||
page_count=1,
|
||||
content_markdown="",
|
||||
content_html="",
|
||||
pages=[PageDetail(page_number=1, width=612.0, height=792.0)],
|
||||
)
|
||||
|
||||
from services.analysis_service import _run_analysis
|
||||
from services.analysis_service import AnalysisService
|
||||
|
||||
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", None)
|
||||
svc = AnalysisService(converter=mock_converter)
|
||||
|
||||
# Called with file_path and default ConversionOptions
|
||||
mock_convert.assert_called_once()
|
||||
call_args = mock_convert.call_args
|
||||
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", None)
|
||||
|
||||
mock_converter.convert.assert_called_once()
|
||||
call_args = mock_converter.convert.call_args
|
||||
assert call_args[0][0] == "/tmp/test.pdf"
|
||||
assert call_args[0][1] == ConversionOptions()
|
||||
|
||||
@patch("services.analysis_service.analysis_repo")
|
||||
@patch("services.analysis_service.document_repo")
|
||||
@patch("services.analysis_service.convert_document")
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_analysis_marks_failed_on_error(
|
||||
self, mock_convert, mock_doc_repo, mock_analysis_repo, mock_job,
|
||||
self,
|
||||
mock_doc_repo,
|
||||
mock_analysis_repo,
|
||||
mock_job,
|
||||
):
|
||||
mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
|
||||
mock_analysis_repo.update_status = AsyncMock()
|
||||
mock_convert.side_effect = RuntimeError("Docling crashed")
|
||||
|
||||
from services.analysis_service import _run_analysis
|
||||
mock_converter = AsyncMock()
|
||||
mock_converter.convert.side_effect = RuntimeError("Docling crashed")
|
||||
|
||||
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", {"do_ocr": False})
|
||||
from services.analysis_service import AnalysisService
|
||||
|
||||
svc = AnalysisService(converter=mock_converter)
|
||||
|
||||
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", {"do_ocr": False})
|
||||
|
||||
# Should have called update_status twice: RUNNING then FAILED
|
||||
assert mock_analysis_repo.update_status.call_count == 2
|
||||
|
|
@ -448,6 +497,7 @@ class TestServiceForwardsPipelineOptions:
|
|||
# API endpoint — full request/response with pipeline options
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAnalysisEndpointPipelineOptions:
|
||||
"""Integration-level tests for the analysis creation endpoint with pipeline options."""
|
||||
|
||||
|
|
@ -456,28 +506,44 @@ class TestAnalysisEndpointPipelineOptions:
|
|||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
||||
def test_no_pipeline_options_sends_none(self, mock_create, client):
|
||||
@pytest.fixture
|
||||
def mock_svc(self, client):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from main import app
|
||||
|
||||
mock = MagicMock()
|
||||
original = getattr(app.state, "analysis_service", None)
|
||||
app.state.analysis_service = mock
|
||||
yield mock
|
||||
app.state.analysis_service = original
|
||||
|
||||
def test_no_pipeline_options_sends_none(self, client, mock_svc):
|
||||
from domain.models import AnalysisJob
|
||||
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
|
||||
|
||||
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
||||
|
||||
client.post("/api/analyses", json={"documentId": "d1"})
|
||||
|
||||
mock_create.assert_called_once_with("d1", pipeline_options=None)
|
||||
mock_svc.create.assert_called_once_with("d1", pipeline_options=None, chunking_options=None)
|
||||
|
||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
||||
def test_empty_pipeline_options_object_uses_defaults(self, mock_create, client):
|
||||
def test_empty_pipeline_options_object_uses_defaults(self, client, mock_svc):
|
||||
from domain.models import AnalysisJob
|
||||
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
|
||||
|
||||
client.post("/api/analyses", json={
|
||||
"documentId": "d1",
|
||||
"pipelineOptions": {},
|
||||
})
|
||||
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
||||
|
||||
opts = mock_create.call_args.kwargs["pipeline_options"]
|
||||
client.post(
|
||||
"/api/analyses",
|
||||
json={
|
||||
"documentId": "d1",
|
||||
"pipelineOptions": {},
|
||||
},
|
||||
)
|
||||
|
||||
opts = mock_svc.create.call_args.kwargs["pipeline_options"]
|
||||
assert opts["do_ocr"] is True
|
||||
assert opts["do_table_structure"] is True
|
||||
assert opts["table_mode"] == "accurate"
|
||||
|
|
@ -485,20 +551,22 @@ class TestAnalysisEndpointPipelineOptions:
|
|||
assert opts["do_formula_enrichment"] is False
|
||||
assert opts["images_scale"] == 1.0
|
||||
|
||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
||||
def test_partial_pipeline_options_merges_with_defaults(self, mock_create, client):
|
||||
def test_partial_pipeline_options_merges_with_defaults(self, client, mock_svc):
|
||||
from domain.models import AnalysisJob
|
||||
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
|
||||
|
||||
client.post("/api/analyses", json={
|
||||
"documentId": "d1",
|
||||
"pipelineOptions": {"do_ocr": False, "images_scale": 1.5},
|
||||
})
|
||||
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
||||
|
||||
opts = mock_create.call_args.kwargs["pipeline_options"]
|
||||
client.post(
|
||||
"/api/analyses",
|
||||
json={
|
||||
"documentId": "d1",
|
||||
"pipelineOptions": {"do_ocr": False, "images_scale": 1.5},
|
||||
},
|
||||
)
|
||||
|
||||
opts = mock_svc.create.call_args.kwargs["pipeline_options"]
|
||||
assert opts["do_ocr"] is False
|
||||
assert opts["images_scale"] == 1.5
|
||||
# All other fields should have defaults
|
||||
assert opts["do_table_structure"] is True
|
||||
assert opts["table_mode"] == "accurate"
|
||||
assert opts["do_code_enrichment"] is False
|
||||
|
|
@ -508,10 +576,10 @@ class TestAnalysisEndpointPipelineOptions:
|
|||
assert opts["generate_picture_images"] is False
|
||||
assert opts["generate_page_images"] is False
|
||||
|
||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
||||
def test_full_pipeline_options(self, mock_create, client):
|
||||
def test_full_pipeline_options(self, client, mock_svc):
|
||||
from domain.models import AnalysisJob
|
||||
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
|
||||
|
||||
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
||||
|
||||
payload = {
|
||||
"documentId": "d1",
|
||||
|
|
@ -532,25 +600,29 @@ class TestAnalysisEndpointPipelineOptions:
|
|||
resp = client.post("/api/analyses", json=payload)
|
||||
assert resp.status_code == 200
|
||||
|
||||
opts = mock_create.call_args.kwargs["pipeline_options"]
|
||||
opts = mock_svc.create.call_args.kwargs["pipeline_options"]
|
||||
assert opts == payload["pipelineOptions"]
|
||||
|
||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
||||
def test_invalid_pipeline_option_type_rejected(self, mock_create, client):
|
||||
resp = client.post("/api/analyses", json={
|
||||
"documentId": "d1",
|
||||
"pipelineOptions": {"do_ocr": "not-a-bool"},
|
||||
})
|
||||
def test_invalid_pipeline_option_type_rejected(self, client, mock_svc):
|
||||
resp = client.post(
|
||||
"/api/analyses",
|
||||
json={
|
||||
"documentId": "d1",
|
||||
"pipelineOptions": {"do_ocr": "not-a-bool"},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
||||
def test_unknown_pipeline_option_ignored(self, mock_create, client):
|
||||
def test_unknown_pipeline_option_ignored(self, client, mock_svc):
|
||||
from domain.models import AnalysisJob
|
||||
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
|
||||
|
||||
resp = client.post("/api/analyses", json={
|
||||
"documentId": "d1",
|
||||
"pipelineOptions": {"do_ocr": True, "unknown_field": True},
|
||||
})
|
||||
# Pydantic ignores extra fields by default
|
||||
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
||||
|
||||
resp = client.post(
|
||||
"/api/analyses",
|
||||
json={
|
||||
"documentId": "d1",
|
||||
"pipelineOptions": {"do_ocr": True, "unknown_field": True},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
|
|
|||
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."""
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.models import AnalysisJob, AnalysisStatus, Document
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"""Tests for API schemas — camelCase serialization and validation."""
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
from api.schemas import (
|
||||
|
|
@ -97,7 +96,10 @@ class TestPipelineOptionsRequest:
|
|||
|
||||
def test_custom_values(self):
|
||||
opts = PipelineOptionsRequest(
|
||||
do_ocr=False, table_mode="fast", do_code_enrichment=True, images_scale=2.0,
|
||||
do_ocr=False,
|
||||
table_mode="fast",
|
||||
do_code_enrichment=True,
|
||||
images_scale=2.0,
|
||||
)
|
||||
assert opts.do_ocr is False
|
||||
assert opts.table_mode == "fast"
|
||||
|
|
|
|||
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" />
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ export default [
|
|||
{
|
||||
files: ['src/**/*.{ts,js,vue}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
__APP_VERSION__: 'readonly',
|
||||
},
|
||||
parserOptions: {
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Docling Studio</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="icon" type="image/png" href="/logo.png">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ server {
|
|||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
|
|
|||
76
frontend/package-lock.json
generated
76
frontend/package-lock.json
generated
|
|
@ -1,14 +1,13 @@
|
|||
{
|
||||
"name": "docling-studio",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "docling-studio",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@vitest/mocker": "^4.1.2",
|
||||
"dompurify": "^3.3.3",
|
||||
"marked": "^17.0.4",
|
||||
"pinia": "^2.3.0",
|
||||
|
|
@ -19,6 +18,7 @@
|
|||
"@eslint/js": "^9.0.0",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vitest/mocker": "^4.1.2",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"prettier": "^3.4.0",
|
||||
|
|
@ -78,6 +78,7 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
|
|
@ -93,6 +94,7 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
|
|
@ -108,6 +110,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
|
|
@ -123,6 +126,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
|
|
@ -138,6 +142,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
|
|
@ -153,6 +158,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
|
|
@ -168,6 +174,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
|
|
@ -183,6 +190,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
|
|
@ -198,6 +206,7 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -213,6 +222,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -228,6 +238,7 @@
|
|||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -243,6 +254,7 @@
|
|||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -258,6 +270,7 @@
|
|||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -273,6 +286,7 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -288,6 +302,7 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -303,6 +318,7 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -318,6 +334,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -333,6 +350,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
|
|
@ -348,6 +366,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
|
|
@ -363,6 +382,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
|
|
@ -378,6 +398,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
|
|
@ -393,6 +414,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
|
|
@ -408,6 +430,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
|
|
@ -423,6 +446,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
|
|
@ -438,6 +462,7 @@
|
|||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
|
|
@ -453,6 +478,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
|
|
@ -661,6 +687,7 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
|
|
@ -673,6 +700,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
|
|
@ -685,6 +713,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
|
|
@ -697,6 +726,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
|
|
@ -709,6 +739,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
|
|
@ -721,6 +752,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
|
|
@ -733,6 +765,7 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -745,6 +778,7 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -757,6 +791,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -769,6 +804,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -781,6 +817,7 @@
|
|||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -793,6 +830,7 @@
|
|||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -805,6 +843,7 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -817,6 +856,7 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -829,6 +869,7 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -841,6 +882,7 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -853,6 +895,7 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -865,6 +908,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -877,6 +921,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -889,6 +934,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
|
|
@ -901,6 +947,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
|
|
@ -913,6 +960,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
|
|
@ -925,6 +973,7 @@
|
|||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
|
|
@ -937,6 +986,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
|
|
@ -949,6 +999,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
|
|
@ -989,7 +1040,8 @@
|
|||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
|
|
@ -1307,6 +1359,7 @@
|
|||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
|
||||
"integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.2",
|
||||
"estree-walker": "^3.0.3",
|
||||
|
|
@ -1372,6 +1425,7 @@
|
|||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
|
||||
"integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
|
|
@ -1828,7 +1882,7 @@
|
|||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
|
|
@ -2055,6 +2109,7 @@
|
|||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
|
|
@ -2099,7 +2154,7 @@
|
|||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
|
|
@ -2163,6 +2218,7 @@
|
|||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2540,7 +2596,7 @@
|
|||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -2655,7 +2711,7 @@
|
|||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
|
|
@ -2797,7 +2853,7 @@
|
|||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3"
|
||||
|
|
@ -2909,7 +2965,7 @@
|
|||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "docling-studio",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
@ -16,7 +16,6 @@
|
|||
"format:check": "prettier --check src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitest/mocker": "^4.1.2",
|
||||
"dompurify": "^3.3.3",
|
||||
"marked": "^17.0.4",
|
||||
"pinia": "^2.3.0",
|
||||
|
|
@ -25,6 +24,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@vitest/mocker": "^4.1.2",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"eslint": "^9.0.0",
|
||||
|
|
|
|||
BIN
frontend/public/logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 858 KiB |
|
|
@ -1,22 +1,41 @@
|
|||
<template>
|
||||
<div class="app-layout">
|
||||
<header class="topbar">
|
||||
<button class="burger-btn" @click="sidebarOpen = !sidebarOpen" :title="sidebarOpen ? t('nav.collapse') : t('nav.expand')">
|
||||
<button
|
||||
class="burger-btn"
|
||||
@click="sidebarOpen = !sidebarOpen"
|
||||
:title="sidebarOpen ? t('nav.collapse') : t('nav.expand')"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="burger-icon">
|
||||
<path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="topbar-logo">
|
||||
<span class="topbar-logo-icon">D</span>
|
||||
<img src="/logo.png" alt="Docling Studio" class="topbar-logo-icon" />
|
||||
<span class="topbar-logo-text">Docling Studio</span>
|
||||
</div>
|
||||
<div class="topbar-spacer" />
|
||||
<button class="new-analysis-btn" @click="newAnalysis">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="new-analysis-icon"><path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"/></svg>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="new-analysis-icon">
|
||||
<path
|
||||
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('topbar.newAnalysis') }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="showDisclaimer" class="disclaimer-banner" role="alert">
|
||||
{{ t('disclaimer.banner') }}
|
||||
<button class="disclaimer-close" @click="dismissDisclaimer" aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="app-body">
|
||||
<AppSidebar :open="sidebarOpen" />
|
||||
<main class="main">
|
||||
|
|
@ -27,11 +46,12 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { RouterView, useRouter } from 'vue-router'
|
||||
import { AppSidebar } from '../shared/ui/index'
|
||||
import { useSettingsStore } from '../features/settings/store'
|
||||
import { useDocumentStore } from '../features/document/store'
|
||||
import { useFeatureFlag } from '../features/feature-flags'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
|
||||
useSettingsStore()
|
||||
|
|
@ -40,6 +60,13 @@ const router = useRouter()
|
|||
const documentStore = useDocumentStore()
|
||||
|
||||
const sidebarOpen = ref(true)
|
||||
const disclaimerEnabled = useFeatureFlag('disclaimer')
|
||||
const disclaimerDismissed = ref(false)
|
||||
const showDisclaimer = computed(() => disclaimerEnabled.value && !disclaimerDismissed.value)
|
||||
|
||||
function dismissDisclaimer() {
|
||||
disclaimerDismissed.value = true
|
||||
}
|
||||
|
||||
function newAnalysis() {
|
||||
documentStore.selectedId = null
|
||||
|
|
@ -52,26 +79,26 @@ function newAnalysis() {
|
|||
|
||||
:root {
|
||||
/* Dark mode palette - MistralAI Studio inspired */
|
||||
--bg: #0A0A0B;
|
||||
--bg: #0a0a0b;
|
||||
--bg-surface: #111113;
|
||||
--bg-elevated: #1A1A1D;
|
||||
--bg-elevated: #1a1a1d;
|
||||
--bg-hover: #222226;
|
||||
|
||||
--accent: #F97316;
|
||||
--accent-hover: #FB923C;
|
||||
--accent: #f97316;
|
||||
--accent-hover: #fb923c;
|
||||
--accent-muted: rgba(249, 115, 22, 0.15);
|
||||
|
||||
--text: #ECECEF;
|
||||
--text-secondary: #A1A1AA;
|
||||
--text-muted: #63636E;
|
||||
--text: #ececef;
|
||||
--text-secondary: #a1a1aa;
|
||||
--text-muted: #63636e;
|
||||
|
||||
--border: #27272A;
|
||||
--border-light: #3F3F46;
|
||||
--border: #27272a;
|
||||
--border-light: #3f3f46;
|
||||
|
||||
--success: #22C55E;
|
||||
--error: #EF4444;
|
||||
--warning: #EAB308;
|
||||
--info: #3B82F6;
|
||||
--success: #22c55e;
|
||||
--error: #ef4444;
|
||||
--warning: #eab308;
|
||||
--info: #3b82f6;
|
||||
|
||||
--radius: 8px;
|
||||
--radius-sm: 6px;
|
||||
|
|
@ -83,32 +110,40 @@ function newAnalysis() {
|
|||
}
|
||||
|
||||
html.light {
|
||||
--bg: #FAFAFA;
|
||||
--bg-surface: #FFFFFF;
|
||||
--bg-elevated: #F4F4F5;
|
||||
--bg-hover: #E4E4E7;
|
||||
--bg: #fafafa;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-elevated: #f4f4f5;
|
||||
--bg-hover: #e4e4e7;
|
||||
|
||||
--accent: #F97316;
|
||||
--accent-hover: #EA580C;
|
||||
--accent-muted: rgba(249, 115, 22, 0.10);
|
||||
--accent: #f97316;
|
||||
--accent-hover: #ea580c;
|
||||
--accent-muted: rgba(249, 115, 22, 0.1);
|
||||
|
||||
--text: #18181B;
|
||||
--text-secondary: #52525B;
|
||||
--text-muted: #A1A1AA;
|
||||
--text: #18181b;
|
||||
--text-secondary: #52525b;
|
||||
--text-muted: #a1a1aa;
|
||||
|
||||
--border: #E4E4E7;
|
||||
--border-light: #D4D4D8;
|
||||
--border: #e4e4e7;
|
||||
--border-light: #d4d4d8;
|
||||
|
||||
--success: #16A34A;
|
||||
--error: #DC2626;
|
||||
--warning: #CA8A04;
|
||||
--info: #2563EB;
|
||||
--success: #16a34a;
|
||||
--error: #dc2626;
|
||||
--warning: #ca8a04;
|
||||
--info: #2563eb;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-family:
|
||||
'Inter',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
|
|
@ -123,6 +158,36 @@ body {
|
|||
height: 100vh;
|
||||
}
|
||||
|
||||
.disclaimer-banner {
|
||||
background: #f59e0b;
|
||||
color: #1a1a1d;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
padding: 8px 40px 8px 16px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.disclaimer-close {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: #1a1a1d;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.disclaimer-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: var(--topbar-height);
|
||||
min-height: var(--topbar-height);
|
||||
|
|
@ -167,16 +232,10 @@ body {
|
|||
}
|
||||
|
||||
.topbar-logo-icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.topbar-logo-text {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { router } from './router'
|
||||
import { useFeatureFlagStore } from '../features/feature-flags'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
const featureFlags = useFeatureFlagStore()
|
||||
featureFlags.load()
|
||||
|
||||
app.mount('#app')
|
||||
|
|
|
|||
|
|
@ -1,20 +1,31 @@
|
|||
import type { Analysis, PipelineOptions } from '../../shared/types'
|
||||
import type { Analysis, Chunk, ChunkingOptions, PipelineOptions } from '../../shared/types'
|
||||
import { apiFetch } from '../../shared/api/http'
|
||||
|
||||
export function createAnalysis(
|
||||
documentId: string,
|
||||
pipelineOptions: PipelineOptions | null = null,
|
||||
chunkingOptions: ChunkingOptions | null = null,
|
||||
): Promise<Analysis> {
|
||||
const body: Record<string, unknown> = { documentId }
|
||||
if (pipelineOptions) {
|
||||
body.pipelineOptions = pipelineOptions
|
||||
}
|
||||
if (chunkingOptions) {
|
||||
body.chunkingOptions = chunkingOptions
|
||||
}
|
||||
return apiFetch<Analysis>('/api/analyses', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function rechunkAnalysis(jobId: string, chunkingOptions: ChunkingOptions): Promise<Chunk[]> {
|
||||
return apiFetch<Chunk[]>(`/api/analyses/${jobId}/rechunk`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ chunkingOptions }),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchAnalyses(): Promise<Analysis[]> {
|
||||
return apiFetch<Analysis[]>('/api/analyses')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ describe('computeScale', () => {
|
|||
})
|
||||
|
||||
it('sx equals sy when aspect ratio is preserved', () => {
|
||||
const pageW = 612, pageH = 792
|
||||
const pageW = 612,
|
||||
pageH = 792
|
||||
const displayW = 700
|
||||
const displayH = displayW * pageH / pageW
|
||||
const displayH = (displayW * pageH) / pageW
|
||||
const s = computeScale(displayW, displayH, pageW, pageH)
|
||||
expect(s.sx).toBeCloseTo(s.sy, 5)
|
||||
})
|
||||
|
|
@ -165,17 +166,17 @@ describe('pointInRect', () => {
|
|||
})
|
||||
|
||||
it('returns true for point on edge', () => {
|
||||
expect(pointInRect(10, 20, rect)).toBe(true) // top-left
|
||||
expect(pointInRect(110, 80, rect)).toBe(true) // bottom-right
|
||||
expect(pointInRect(10, 80, rect)).toBe(true) // bottom-left
|
||||
expect(pointInRect(110, 20, rect)).toBe(true) // top-right
|
||||
expect(pointInRect(10, 20, rect)).toBe(true) // top-left
|
||||
expect(pointInRect(110, 80, rect)).toBe(true) // bottom-right
|
||||
expect(pointInRect(10, 80, rect)).toBe(true) // bottom-left
|
||||
expect(pointInRect(110, 20, rect)).toBe(true) // top-right
|
||||
})
|
||||
|
||||
it('returns false for point outside', () => {
|
||||
expect(pointInRect(5, 50, rect)).toBe(false) // left
|
||||
expect(pointInRect(50, 15, rect)).toBe(false) // above
|
||||
expect(pointInRect(115, 50, rect)).toBe(false) // right
|
||||
expect(pointInRect(50, 85, rect)).toBe(false) // below
|
||||
expect(pointInRect(5, 50, rect)).toBe(false) // left
|
||||
expect(pointInRect(50, 15, rect)).toBe(false) // above
|
||||
expect(pointInRect(115, 50, rect)).toBe(false) // right
|
||||
expect(pointInRect(50, 85, rect)).toBe(false) // below
|
||||
})
|
||||
|
||||
it('returns false for EMPTY_RECT (degenerate bbox not hoverable)', () => {
|
||||
|
|
@ -203,8 +204,10 @@ describe('pointInRect', () => {
|
|||
describe('integration: bbox pipeline', () => {
|
||||
it('full A4 page: element is clickable at correct position', () => {
|
||||
// Simulate: A4 page rendered at 700px wide
|
||||
const pageW = 595.28, pageH = 841.89
|
||||
const displayW = 700, displayH = 700 * pageH / pageW
|
||||
const pageW = 595.28,
|
||||
pageH = 841.89
|
||||
const displayW = 700,
|
||||
displayH = (700 * pageH) / pageW
|
||||
|
||||
const scale = computeScale(displayW, displayH, pageW, pageH)
|
||||
// Element at center of page in PDF points
|
||||
|
|
@ -212,8 +215,8 @@ describe('integration: bbox pipeline', () => {
|
|||
const rect = bboxToRect(bbox, scale)
|
||||
|
||||
// Center of the element in display pixels
|
||||
const cx = (200 + 400) / 2 * scale.sx
|
||||
const cy = (350 + 500) / 2 * scale.sy
|
||||
const cx = ((200 + 400) / 2) * scale.sx
|
||||
const cy = ((350 + 500) / 2) * scale.sy
|
||||
|
||||
expect(pointInRect(cx, cy, rect)).toBe(true)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import * as api from './api'
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('createAnalysis — pipeline options body construction', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset the real module to get the unmocked version
|
||||
|
|
@ -107,7 +106,6 @@ describe('createAnalysis — pipeline options body construction', () => {
|
|||
})
|
||||
})
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store → API integration — pipeline options forwarding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -131,7 +129,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => {
|
|||
const store = useAnalysisStore()
|
||||
await store.run('d1')
|
||||
|
||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null)
|
||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, null)
|
||||
store.stopPolling()
|
||||
})
|
||||
|
||||
|
|
@ -155,7 +153,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => {
|
|||
}
|
||||
await store.run('d1', opts)
|
||||
|
||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', opts)
|
||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', opts, null)
|
||||
store.stopPolling()
|
||||
})
|
||||
|
||||
|
|
@ -168,7 +166,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => {
|
|||
const opts = { do_ocr: false }
|
||||
await store.run('d1', opts)
|
||||
|
||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', { do_ocr: false })
|
||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', { do_ocr: false }, null)
|
||||
store.stopPolling()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ describe('useAnalysisStore', () => {
|
|||
expect(store.currentAnalysis).toEqual(job)
|
||||
expect(store.analyses[0]).toEqual(job)
|
||||
expect(store.running).toBe(true)
|
||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null)
|
||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, null)
|
||||
|
||||
// Advance timer to trigger polling
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
|
|
@ -90,7 +90,7 @@ describe('useAnalysisStore', () => {
|
|||
const options = { do_ocr: false, table_mode: 'fast' }
|
||||
await store.run('d1', options)
|
||||
|
||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', options)
|
||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', options, null)
|
||||
|
||||
store.stopPolling()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Analysis, Page, PipelineOptions } from '../../shared/types'
|
||||
import type { Analysis, Chunk, ChunkingOptions, Page, PipelineOptions } from '../../shared/types'
|
||||
import * as api from './api'
|
||||
|
||||
export const useAnalysisStore = defineStore('analysis', () => {
|
||||
|
|
@ -9,6 +9,8 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
const running = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
const pollingTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
const MAX_POLLING_DURATION = 10 * 60 * 1000 // 10 minutes
|
||||
|
||||
const currentPages = computed<Page[]>(() => {
|
||||
if (!currentAnalysis.value?.pagesJson) return []
|
||||
|
|
@ -33,14 +35,44 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
}
|
||||
}
|
||||
|
||||
const currentChunks = computed<Chunk[]>(() => {
|
||||
if (!currentAnalysis.value?.chunksJson) return []
|
||||
try {
|
||||
return JSON.parse(currentAnalysis.value.chunksJson) as Chunk[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const rechunking = ref(false)
|
||||
|
||||
async function rechunk(jobId: string, chunkingOptions: ChunkingOptions): Promise<Chunk[]> {
|
||||
rechunking.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const chunks = await api.rechunkAnalysis(jobId, chunkingOptions)
|
||||
if (currentAnalysis.value?.id === jobId) {
|
||||
currentAnalysis.value = await api.fetchAnalysis(jobId)
|
||||
}
|
||||
return chunks
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message || 'Failed to rechunk'
|
||||
console.error('Failed to rechunk', e)
|
||||
throw e
|
||||
} finally {
|
||||
rechunking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function run(
|
||||
documentId: string,
|
||||
pipelineOptions: PipelineOptions | null = null,
|
||||
chunkingOptions: ChunkingOptions | null = null,
|
||||
): Promise<Analysis> {
|
||||
running.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const analysis = await api.createAnalysis(documentId, pipelineOptions)
|
||||
const analysis = await api.createAnalysis(documentId, pipelineOptions, chunkingOptions)
|
||||
currentAnalysis.value = analysis
|
||||
analyses.value.unshift(analysis)
|
||||
startPolling(analysis.id)
|
||||
|
|
@ -72,6 +104,13 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
running.value = false
|
||||
}
|
||||
}, 2000)
|
||||
pollingTimeout.value = setTimeout(() => {
|
||||
if (pollingInterval.value) {
|
||||
error.value = 'Analysis timed out'
|
||||
stopPolling()
|
||||
running.value = false
|
||||
}
|
||||
}, MAX_POLLING_DURATION)
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
|
|
@ -79,6 +118,10 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
clearInterval(pollingInterval.value)
|
||||
pollingInterval.value = null
|
||||
}
|
||||
if (pollingTimeout.value) {
|
||||
clearTimeout(pollingTimeout.value)
|
||||
pollingTimeout.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function select(id: string): Promise<void> {
|
||||
|
|
@ -105,11 +148,14 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
analyses,
|
||||
currentAnalysis,
|
||||
currentPages,
|
||||
currentChunks,
|
||||
running,
|
||||
rechunking,
|
||||
error,
|
||||
clearError,
|
||||
load,
|
||||
run,
|
||||
rechunk,
|
||||
select,
|
||||
remove,
|
||||
stopPolling,
|
||||
|
|
|
|||
|
|
@ -20,14 +20,14 @@
|
|||
</div>
|
||||
|
||||
<div class="panel-actions" v-if="selectedDoc">
|
||||
<button
|
||||
class="btn-analyze"
|
||||
:disabled="analysisStore.running"
|
||||
@click="runAnalysis"
|
||||
>
|
||||
<button class="btn-analyze" :disabled="analysisStore.running" @click="runAnalysis">
|
||||
<div v-if="analysisStore.running" class="spinner" />
|
||||
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="btn-icon">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{{ analysisStore.running ? 'Analyzing...' : 'Analyze' }}
|
||||
</button>
|
||||
|
|
@ -55,7 +55,7 @@ const analysisStore = useAnalysisStore()
|
|||
const currentPage = ref(1)
|
||||
|
||||
const selectedDoc = computed(() => {
|
||||
return documentStore.documents.find(d => d.id === documentStore.selectedId)
|
||||
return documentStore.documents.find((d) => d.id === documentStore.selectedId)
|
||||
})
|
||||
|
||||
const statusClass = computed(() => {
|
||||
|
|
@ -64,7 +64,7 @@ const statusClass = computed(() => {
|
|||
'status-pending': status === 'PENDING',
|
||||
'status-running': status === 'RUNNING',
|
||||
'status-completed': status === 'COMPLETED',
|
||||
'status-failed': status === 'FAILED'
|
||||
'status-failed': status === 'FAILED',
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -115,20 +115,32 @@ async function runAnalysis() {
|
|||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.btn-analyze:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
.btn-analyze:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn-icon { width: 18px; height: 18px; }
|
||||
.btn-analyze:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.btn-analyze:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
|
|
@ -143,8 +155,20 @@ async function runAnalysis() {
|
|||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.status-pending { background: rgba(234, 179, 8, 0.15); color: var(--warning); }
|
||||
.status-running { background: rgba(59, 130, 246, 0.15); color: var(--info); }
|
||||
.status-completed { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||
.status-failed { background: rgba(239, 68, 68, 0.15); color: var(--error); }
|
||||
.status-pending {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: var(--warning);
|
||||
}
|
||||
.status-running {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--info);
|
||||
}
|
||||
.status-completed {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
.status-failed {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--error);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -20,14 +20,13 @@
|
|||
ref="canvasRef"
|
||||
class="overlay-canvas"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseleave="hoveredElement = null; emit('highlight-element', -1)"
|
||||
@mouseleave="onMouseLeave"
|
||||
/>
|
||||
<div
|
||||
v-if="hoveredElement"
|
||||
class="tooltip"
|
||||
:style="tooltipStyle"
|
||||
>
|
||||
<span class="tooltip-type" :style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }">
|
||||
<div v-if="hoveredElement" class="tooltip" :style="tooltipStyle">
|
||||
<span
|
||||
class="tooltip-type"
|
||||
:style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }"
|
||||
>
|
||||
{{ hoveredElement.type }}
|
||||
</span>
|
||||
<span class="tooltip-content">{{ hoveredElement.content?.substring(0, 150) }}</span>
|
||||
|
|
@ -38,7 +37,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, reactive, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling'
|
||||
import type { Page, PageElement } from '../../../shared/types'
|
||||
import type { Page, PageElement, ChunkBbox } from '../../../shared/types'
|
||||
|
||||
const ELEMENT_COLORS: Record<string, string> = {
|
||||
title: '#EF4444',
|
||||
|
|
@ -49,14 +48,18 @@ const ELEMENT_COLORS: Record<string, string> = {
|
|||
list: '#06B6D4',
|
||||
formula: '#EC4899',
|
||||
code: '#14B8A6',
|
||||
caption: '#EAB308'
|
||||
caption: '#EAB308',
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
imageEl: HTMLImageElement | null
|
||||
pageData: Page | null
|
||||
highlightedIndex: number
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
imageEl: HTMLImageElement | null
|
||||
pageData: Page | null
|
||||
highlightedIndex: number
|
||||
highlightedBboxes: ChunkBbox[]
|
||||
}>(),
|
||||
{ highlightedBboxes: () => [] },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'highlight-element': [index: number]
|
||||
|
|
@ -112,22 +115,49 @@ function draw(): void {
|
|||
|
||||
if (!props.pageData) return
|
||||
|
||||
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
|
||||
const scale = computeScale(
|
||||
img.clientWidth,
|
||||
img.clientHeight,
|
||||
props.pageData.width,
|
||||
props.pageData.height,
|
||||
)
|
||||
|
||||
const hasHighlight = props.highlightedIndex >= 0
|
||||
const hasChunkHighlight = props.highlightedBboxes.length > 0
|
||||
|
||||
for (const el of visibleElements.value) {
|
||||
const rect = bboxToRect(el.bbox, scale)
|
||||
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
|
||||
|
||||
const elContentIdx = contentElements.value.indexOf(el)
|
||||
const isHighlighted = props.highlightedIndex >= 0 && elContentIdx === props.highlightedIndex
|
||||
const isHighlighted = hasHighlight && elContentIdx === props.highlightedIndex
|
||||
const dimmed = (hasHighlight || hasChunkHighlight) && !isHighlighted
|
||||
|
||||
ctx.strokeStyle = color
|
||||
ctx.strokeStyle = dimmed ? color + '40' : color
|
||||
ctx.lineWidth = isHighlighted ? 3 : 2
|
||||
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
|
||||
|
||||
ctx.fillStyle = color + (isHighlighted ? '40' : '20')
|
||||
ctx.fillStyle = color + (isHighlighted ? '40' : dimmed ? '08' : '20')
|
||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
|
||||
}
|
||||
|
||||
// Draw chunk-highlighted bboxes on top with accent color
|
||||
if (hasChunkHighlight) {
|
||||
const CHUNK_COLOR = '#F59E0B'
|
||||
for (const cb of props.highlightedBboxes) {
|
||||
const rect = bboxToRect(cb.bbox, scale)
|
||||
ctx.strokeStyle = CHUNK_COLOR
|
||||
ctx.lineWidth = 3
|
||||
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
|
||||
ctx.fillStyle = CHUNK_COLOR + '30'
|
||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseLeave(): void {
|
||||
hoveredElement.value = null
|
||||
emit('highlight-element', -1)
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent): void {
|
||||
|
|
@ -139,7 +169,12 @@ function onMouseMove(e: MouseEvent): void {
|
|||
const mx = e.clientX - canvasRect.left
|
||||
const my = e.clientY - canvasRect.top
|
||||
|
||||
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
|
||||
const scale = computeScale(
|
||||
img.clientWidth,
|
||||
img.clientHeight,
|
||||
props.pageData.width,
|
||||
props.pageData.height,
|
||||
)
|
||||
|
||||
let found: PageElement | null = null
|
||||
let foundIdx = -1
|
||||
|
|
@ -156,7 +191,7 @@ function onMouseMove(e: MouseEvent): void {
|
|||
if (found) {
|
||||
tooltipStyle.value = {
|
||||
left: `${Math.min(mx + 12, canvas.width - 250)}px`,
|
||||
top: `${my + 12}px`
|
||||
top: `${my + 12}px`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -173,9 +208,18 @@ onBeforeUnmount(() => {
|
|||
})
|
||||
|
||||
// Redraw when data or image changes
|
||||
watch([() => props.pageData, () => props.imageEl, () => props.highlightedIndex, hiddenTypes], () => {
|
||||
nextTick(draw)
|
||||
})
|
||||
watch(
|
||||
[
|
||||
() => props.pageData,
|
||||
() => props.imageEl,
|
||||
() => props.highlightedIndex,
|
||||
() => props.highlightedBboxes,
|
||||
hiddenTypes,
|
||||
],
|
||||
() => {
|
||||
nextTick(draw)
|
||||
},
|
||||
)
|
||||
|
||||
// Expose draw so parent can call it after image load
|
||||
defineExpose({ draw })
|
||||
|
|
@ -219,8 +263,13 @@ defineExpose({ draw })
|
|||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.legend-chip:hover { opacity: 1; background: var(--bg-hover); }
|
||||
.legend-chip.dimmed { opacity: 0.35; }
|
||||
.legend-chip:hover {
|
||||
opacity: 1;
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.legend-chip.dimmed {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 6px;
|
||||
|
|
@ -252,7 +301,7 @@ defineExpose({ draw })
|
|||
max-width: 250px;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.tooltip-type {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,7 @@
|
|||
{{ t('results.noImages') }}
|
||||
</div>
|
||||
<div v-else class="gallery-grid">
|
||||
<div
|
||||
v-for="(img, idx) in images"
|
||||
:key="idx"
|
||||
class="gallery-card"
|
||||
>
|
||||
<div v-for="(img, idx) in images" :key="idx" class="gallery-card">
|
||||
<div class="card-header">
|
||||
<span class="card-type">Picture</span>
|
||||
<span class="card-page">{{ t('results.page') }} {{ img.page }}</span>
|
||||
|
|
@ -18,7 +14,7 @@
|
|||
</div>
|
||||
<div class="card-bbox">
|
||||
<span class="bbox-label">bbox:</span>
|
||||
<span class="bbox-value">{{ img.bbox.map(v => Math.round(v)).join(', ') }}</span>
|
||||
<span class="bbox-value">{{ img.bbox.map((v) => Math.round(v)).join(', ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -33,13 +29,13 @@ import type { Page, PageElement } from '../../../shared/types'
|
|||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
pages: { type: Array as () => Page[], default: () => [] }
|
||||
pages: { type: Array as () => Page[], default: () => [] },
|
||||
})
|
||||
|
||||
const images = computed(() => {
|
||||
const result: (PageElement & { page: number })[] = []
|
||||
for (const page of props.pages) {
|
||||
for (const el of (page.elements || [])) {
|
||||
for (const el of page.elements || []) {
|
||||
if (el.type === 'picture') {
|
||||
result.push({ ...el, page: page.page_number })
|
||||
}
|
||||
|
|
@ -109,5 +105,7 @@ const images = computed(() => {
|
|||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.bbox-label { margin-right: 4px; }
|
||||
.bbox-label {
|
||||
margin-right: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -33,11 +33,19 @@ const rendered = computed(() => {
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-viewer :deep(h1) { font-size: 24px; }
|
||||
.markdown-viewer :deep(h2) { font-size: 20px; }
|
||||
.markdown-viewer :deep(h3) { font-size: 16px; }
|
||||
.markdown-viewer :deep(h1) {
|
||||
font-size: 24px;
|
||||
}
|
||||
.markdown-viewer :deep(h2) {
|
||||
font-size: 20px;
|
||||
}
|
||||
.markdown-viewer :deep(h3) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.markdown-viewer :deep(p) { margin: 8px 0; }
|
||||
.markdown-viewer :deep(p) {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.markdown-viewer :deep(code) {
|
||||
background: var(--bg-elevated);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@
|
|||
|
||||
<!-- Page chip -->
|
||||
<div class="page-indicator" v-if="totalPages > 0">
|
||||
<span class="page-chip">{{ t('results.pageOf', { current: currentPage, total: totalPages }) }}</span>
|
||||
<span class="page-chip">{{
|
||||
t('results.pageOf', { current: currentPage, total: totalPages })
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
|
|
@ -32,7 +34,10 @@
|
|||
@mouseleave="$emit('highlight-element', -1)"
|
||||
>
|
||||
<div class="element-header">
|
||||
<span class="element-type" :style="{ color: ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text }">
|
||||
<span
|
||||
class="element-type"
|
||||
:style="{ color: ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text }"
|
||||
>
|
||||
{{ el.type }}
|
||||
</span>
|
||||
<span class="element-level" v-if="el.level">L{{ el.level }}</span>
|
||||
|
|
@ -42,12 +47,23 @@
|
|||
:title="t('results.copy')"
|
||||
@click.stop="copyElement(idx, el.content)"
|
||||
>
|
||||
<svg v-if="!copiedElements[idx]" viewBox="0 0 20 20" fill="currentColor" class="copy-icon">
|
||||
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z"/>
|
||||
<path d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"/>
|
||||
<svg
|
||||
v-if="!copiedElements[idx]"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="copy-icon"
|
||||
>
|
||||
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z" />
|
||||
<path
|
||||
d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="copy-icon copied">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -57,7 +73,7 @@
|
|||
<span v-else>{{ el.content }}</span>
|
||||
</div>
|
||||
<div class="element-bbox">
|
||||
{{ el.bbox.map(v => Math.round(v)).join(', ') }}
|
||||
{{ el.bbox.map((v) => Math.round(v)).join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -66,11 +82,17 @@
|
|||
<div v-else-if="activeTab === 'markdown'" class="raw-markdown">
|
||||
<button class="copy-btn copy-btn-block" :title="t('results.copy')" @click="copyMarkdown">
|
||||
<svg v-if="!copiedMarkdown" viewBox="0 0 20 20" fill="currentColor" class="copy-icon">
|
||||
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z"/>
|
||||
<path d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"/>
|
||||
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z" />
|
||||
<path
|
||||
d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="copy-icon copied">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<pre class="raw-content">{{ pageMarkdown }}</pre>
|
||||
|
|
@ -85,12 +107,26 @@
|
|||
<span>{{ t('studio.analysisRunning') }}</span>
|
||||
</div>
|
||||
<div v-else-if="store.currentAnalysis?.status === 'FAILED'" class="result-placeholder error">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="error-icon"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="error-icon">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ store.currentAnalysis.errorMessage || t('results.analysisFailed') }}</span>
|
||||
</div>
|
||||
<div v-else class="result-placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
|
||||
<path d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v16.5c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9zm3.75 11.625a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="empty-icon"
|
||||
>
|
||||
<path
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v16.5c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9zm3.75 11.625a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ t('results.runAnalysis') }}</span>
|
||||
</div>
|
||||
|
|
@ -113,12 +149,12 @@ const ELEMENT_COLORS: Record<string, string> = {
|
|||
list: '#06B6D4',
|
||||
formula: '#EC4899',
|
||||
code: '#14B8A6',
|
||||
caption: '#EAB308'
|
||||
caption: '#EAB308',
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
currentPage: { type: Number, default: 1 },
|
||||
highlightedIndex: { type: Number, default: -1 }
|
||||
highlightedIndex: { type: Number, default: -1 },
|
||||
})
|
||||
|
||||
defineEmits(['highlight-element'])
|
||||
|
|
@ -130,17 +166,17 @@ const activeTab = ref('elements')
|
|||
const tabs = computed(() => [
|
||||
{ id: 'elements', label: t('results.elements') },
|
||||
{ id: 'markdown', label: t('results.markdown') },
|
||||
{ id: 'images', label: t('results.images') }
|
||||
{ id: 'images', label: t('results.images') },
|
||||
])
|
||||
|
||||
const totalPages = computed(() => store.currentPages.length)
|
||||
|
||||
const currentPageData = computed(() => {
|
||||
return store.currentPages.find(p => p.page_number === props.currentPage) || null
|
||||
return store.currentPages.find((p) => p.page_number === props.currentPage) || null
|
||||
})
|
||||
|
||||
const currentElements = computed(() => {
|
||||
return (currentPageData.value?.elements || []).filter(el => el.content)
|
||||
return (currentPageData.value?.elements || []).filter((el) => el.content)
|
||||
})
|
||||
|
||||
const currentPageAsArray = computed(() => {
|
||||
|
|
@ -153,7 +189,7 @@ const pageMarkdown = computed(() => {
|
|||
if (!page) return ''
|
||||
|
||||
return (page.elements || [])
|
||||
.map(el => formatElement(el))
|
||||
.map((el) => formatElement(el))
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
})
|
||||
|
|
@ -191,16 +227,24 @@ async function copyMarkdown() {
|
|||
try {
|
||||
await navigator.clipboard.writeText(pageMarkdown.value)
|
||||
copiedMarkdown.value = true
|
||||
setTimeout(() => { copiedMarkdown.value = false }, 1500)
|
||||
} catch { /* clipboard not available */ }
|
||||
setTimeout(() => {
|
||||
copiedMarkdown.value = false
|
||||
}, 1500)
|
||||
} catch {
|
||||
/* clipboard not available */
|
||||
}
|
||||
}
|
||||
|
||||
async function copyElement(idx: number, content: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
copiedElements[idx] = true
|
||||
setTimeout(() => { copiedElements[idx] = false }, 1500)
|
||||
} catch { /* clipboard not available */ }
|
||||
setTimeout(() => {
|
||||
copiedElements[idx] = false
|
||||
}, 1500)
|
||||
} catch {
|
||||
/* clipboard not available */
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -234,7 +278,9 @@ async function copyElement(idx: number, content: string) {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-btn:hover { color: var(--text-secondary); }
|
||||
.tab-btn:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.tab-btn.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
|
|
@ -368,8 +414,13 @@ async function copyElement(idx: number, content: string) {
|
|||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.copy-icon { width: 14px; height: 14px; }
|
||||
.copy-icon.copied { color: var(--success); }
|
||||
.copy-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.copy-icon.copied {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.copy-btn-element {
|
||||
margin-left: auto;
|
||||
|
|
@ -377,7 +428,9 @@ async function copyElement(idx: number, content: string) {
|
|||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.element-card:hover .copy-btn-element { opacity: 1; }
|
||||
.element-card:hover .copy-btn-element {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.copy-btn-block {
|
||||
position: absolute;
|
||||
|
|
@ -389,7 +442,9 @@ async function copyElement(idx: number, content: string) {
|
|||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.raw-markdown:hover .copy-btn-block { opacity: 1; }
|
||||
.raw-markdown:hover .copy-btn-block {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* --- Raw markdown --- */
|
||||
.raw-markdown {
|
||||
|
|
@ -423,9 +478,18 @@ async function copyElement(idx: number, content: string) {
|
|||
font-size: 14px;
|
||||
}
|
||||
|
||||
.result-placeholder.error { color: var(--error); }
|
||||
.error-icon { width: 32px; height: 32px; }
|
||||
.empty-icon { width: 48px; height: 48px; color: var(--border-light); }
|
||||
.result-placeholder.error {
|
||||
color: var(--error);
|
||||
}
|
||||
.error-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--border-light);
|
||||
}
|
||||
|
||||
.spinner-large {
|
||||
width: 32px;
|
||||
|
|
@ -436,5 +500,9 @@ async function copyElement(idx: number, content: string) {
|
|||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -44,12 +44,11 @@
|
|||
@mouseleave="hoveredElement = null"
|
||||
/>
|
||||
<!-- Tooltip -->
|
||||
<div
|
||||
v-if="hoveredElement"
|
||||
class="tooltip"
|
||||
:style="tooltipStyle"
|
||||
>
|
||||
<span class="tooltip-type" :style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }">
|
||||
<div v-if="hoveredElement" class="tooltip" :style="tooltipStyle">
|
||||
<span
|
||||
class="tooltip-type"
|
||||
:style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }"
|
||||
>
|
||||
{{ hoveredElement.type }}
|
||||
</span>
|
||||
<span class="tooltip-content">{{ hoveredElement.content?.substring(0, 150) }}</span>
|
||||
|
|
@ -71,12 +70,12 @@ const ELEMENT_COLORS: Record<string, string> = {
|
|||
picture: '#22C55E',
|
||||
list: '#06B6D4',
|
||||
formula: '#EC4899',
|
||||
caption: '#EAB308'
|
||||
caption: '#EAB308',
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
pages: { type: Array as () => Page[], default: () => [] },
|
||||
documentId: String
|
||||
documentId: String,
|
||||
})
|
||||
|
||||
const selectedPage = ref(1)
|
||||
|
|
@ -89,12 +88,12 @@ const tooltipStyle = ref<Record<string, string>>({})
|
|||
const imageSize = ref({ width: 0, height: 0 })
|
||||
|
||||
const currentPageData = computed(() => {
|
||||
return props.pages.find(p => p.page_number === selectedPage.value)
|
||||
return props.pages.find((p) => p.page_number === selectedPage.value)
|
||||
})
|
||||
|
||||
const visibleElements = computed(() => {
|
||||
if (!currentPageData.value) return []
|
||||
return currentPageData.value.elements.filter(e => !hiddenTypes.has(e.type))
|
||||
return currentPageData.value.elements.filter((e) => !hiddenTypes.has(e.type))
|
||||
})
|
||||
|
||||
const previewUrl = computed(() => {
|
||||
|
|
@ -110,7 +109,7 @@ function toggleType(type: string) {
|
|||
|
||||
function countElements(type: string) {
|
||||
if (!currentPageData.value) return 0
|
||||
return currentPageData.value.elements.filter(e => e.type === type).length
|
||||
return currentPageData.value.elements.filter((e) => e.type === type).length
|
||||
}
|
||||
|
||||
function onImageLoad() {
|
||||
|
|
@ -174,7 +173,7 @@ function onMouseMove(e: MouseEvent) {
|
|||
if (found) {
|
||||
tooltipStyle.value = {
|
||||
left: `${Math.min(mx + 12, canvas.width - 250)}px`,
|
||||
top: `${my + 12}px`
|
||||
top: `${my + 12}px`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -213,7 +212,9 @@ watch([() => props.pages, selectedPage, hiddenTypes], () => {
|
|||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.page-btn:hover { background: var(--bg-hover); }
|
||||
.page-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.page-btn.active {
|
||||
background: var(--accent-muted);
|
||||
border-color: var(--accent);
|
||||
|
|
@ -240,8 +241,12 @@ watch([() => props.pages, selectedPage, hiddenTypes], () => {
|
|||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.legend-item:hover { background: var(--bg-hover); }
|
||||
.legend-item.dimmed { opacity: 0.4; }
|
||||
.legend-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.legend-item.dimmed {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 8px;
|
||||
|
|
@ -286,7 +291,7 @@ watch([() => props.pages, selectedPage, hiddenTypes], () => {
|
|||
max-width: 250px;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.tooltip-type {
|
||||
|
|
|
|||
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 () => {
|
||||
const docs = [{ id: '1', filename: 'a.pdf' }, { id: '2', filename: 'b.pdf' }]
|
||||
const docs = [
|
||||
{ id: '1', filename: 'a.pdf' },
|
||||
{ id: '2', filename: 'b.pdf' },
|
||||
]
|
||||
api.fetchDocuments.mockResolvedValue(docs)
|
||||
|
||||
const store = useDocumentStore()
|
||||
|
|
@ -61,7 +64,12 @@ describe('useDocumentStore', () => {
|
|||
|
||||
it('upload() sets uploading to true during upload', async () => {
|
||||
let resolveUpload
|
||||
api.uploadDocument.mockImplementation(() => new Promise(r => { resolveUpload = r }))
|
||||
api.uploadDocument.mockImplementation(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolveUpload = r
|
||||
}),
|
||||
)
|
||||
|
||||
const store = useDocumentStore()
|
||||
const promise = store.upload(new File([], 'test.pdf'))
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { ref } from 'vue'
|
|||
import type { Document } from '../../shared/types'
|
||||
import * as api from './api'
|
||||
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
|
||||
|
||||
export const useDocumentStore = defineStore('document', () => {
|
||||
const documents = ref<Document[]>([])
|
||||
const selectedId = ref<string | null>(null)
|
||||
|
|
@ -24,6 +26,10 @@ export const useDocumentStore = defineStore('document', () => {
|
|||
}
|
||||
|
||||
async function upload(file: File): Promise<Document> {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
error.value = 'File too large (max 50 MB)'
|
||||
throw new Error(error.value)
|
||||
}
|
||||
uploading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -9,15 +9,28 @@
|
|||
>
|
||||
<div class="doc-info">
|
||||
<svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div class="doc-meta">
|
||||
<span class="doc-name">{{ doc.filename }}</span>
|
||||
<span class="doc-size">{{ formatSize(doc.fileSize) }}{{ doc.pageCount ? ` — ${doc.pageCount} pages` : '' }}</span>
|
||||
<span class="doc-size"
|
||||
>{{ formatSize(doc.fileSize)
|
||||
}}{{ doc.pageCount ? ` — ${doc.pageCount} pages` : '' }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<button class="doc-delete" @click.stop="store.remove(doc.id)" title="Delete">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -101,7 +114,15 @@ const store = useDocumentStore()
|
|||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.doc-item:hover .doc-delete { opacity: 1; }
|
||||
.doc-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); }
|
||||
.doc-delete svg { width: 14px; height: 14px; }
|
||||
.doc-item:hover .doc-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
.doc-delete:hover {
|
||||
color: var(--error);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
.doc-delete svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -13,11 +13,18 @@
|
|||
<span>{{ t('upload.uploading') }}</span>
|
||||
</div>
|
||||
<div v-else class="upload-state">
|
||||
<svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<svg
|
||||
class="upload-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" />
|
||||
</svg>
|
||||
<span class="upload-text">{{ t('upload.drop') }}</span>
|
||||
<span class="upload-hint">{{ t('upload.maxSize') }}</span>
|
||||
<span v-if="store.error" class="upload-error">{{ store.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -41,9 +48,14 @@ function openFilePicker() {
|
|||
async function onFileSelect(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file) {
|
||||
const doc = await store.upload(file)
|
||||
if (doc) emit('uploaded', doc.id)
|
||||
if (file && (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'))) {
|
||||
try {
|
||||
store.clearError()
|
||||
const doc = await store.upload(file)
|
||||
if (doc) emit('uploaded', doc.id)
|
||||
} catch {
|
||||
// error is already set in store.upload
|
||||
}
|
||||
}
|
||||
target.value = ''
|
||||
}
|
||||
|
|
@ -51,9 +63,14 @@ async function onFileSelect(e: Event) {
|
|||
async function onDrop(e: DragEvent) {
|
||||
dragging.value = false
|
||||
const file = e.dataTransfer?.files?.[0]
|
||||
if (file && file.type === 'application/pdf') {
|
||||
const doc = await store.upload(file)
|
||||
if (doc) emit('uploaded', doc.id)
|
||||
if (file && (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'))) {
|
||||
try {
|
||||
store.clearError()
|
||||
const doc = await store.upload(file)
|
||||
if (doc) emit('uploaded', doc.id)
|
||||
} catch {
|
||||
// error is already set in store.upload
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -103,6 +120,12 @@ async function onDrop(e: DragEvent) {
|
|||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.upload-error {
|
||||
font-size: 13px;
|
||||
color: var(--error, #e53e3e);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
|
@ -113,6 +136,8 @@ async function onDrop(e: DragEvent) {
|
|||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -4,10 +4,26 @@
|
|||
<span class="preview-label">Page {{ page }}</span>
|
||||
<div class="preview-nav">
|
||||
<button class="nav-btn" :disabled="page <= 1" @click="$emit('update:page', page - 1)">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="nav-btn" :disabled="!!(pageCount && page >= pageCount)" @click="$emit('update:page', page + 1)">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
||||
<button
|
||||
class="nav-btn"
|
||||
:disabled="!!(pageCount && page >= pageCount)"
|
||||
@click="$emit('update:page', page + 1)"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -30,7 +46,7 @@ import { getPreviewUrl } from '../api'
|
|||
const props = defineProps({
|
||||
documentId: String,
|
||||
page: { type: Number, default: 1 },
|
||||
pageCount: Number
|
||||
pageCount: Number,
|
||||
})
|
||||
|
||||
defineEmits(['update:page', 'imageLoaded'])
|
||||
|
|
@ -76,9 +92,18 @@ const previewUrl = computed(() => {
|
|||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.nav-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
|
||||
.nav-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.nav-btn svg { width: 16px; height: 16px; }
|
||||
.nav-btn:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
.nav-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
.nav-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.preview-image-wrapper {
|
||||
background: var(--bg-elevated);
|
||||
|
|
|
|||
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-header">
|
||||
<span class="item-filename">{{ analysis.documentFilename }}</span>
|
||||
<span class="item-status" :class="statusClass(analysis.status)">
|
||||
<span
|
||||
v-if="analysis.status === 'COMPLETED'"
|
||||
class="item-status status-completed"
|
||||
:title="analysis.status"
|
||||
>
|
||||
<svg class="status-dot" viewBox="0 0 8 8" fill="currentColor">
|
||||
<circle cx="4" cy="4" r="4" />
|
||||
</svg>
|
||||
</span>
|
||||
<span v-else class="item-status" :class="statusClass(analysis.status)">
|
||||
{{ analysis.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -23,7 +32,13 @@
|
|||
</div>
|
||||
</div>
|
||||
<button class="item-delete" @click.stop="store.remove(analysis.id)" title="Delete">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -49,7 +64,7 @@ function statusClass(status: string) {
|
|||
'status-pending': status === 'PENDING',
|
||||
'status-running': status === 'RUNNING',
|
||||
'status-completed': status === 'COMPLETED',
|
||||
'status-failed': status === 'FAILED'
|
||||
'status-failed': status === 'FAILED',
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -81,22 +96,31 @@ function duration(analysis: Analysis) {
|
|||
.history-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background var(--transition);
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all 150ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-item:hover { background: var(--bg-hover); }
|
||||
.history-item:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.item-main { flex: 1; min-width: 0; }
|
||||
.item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
|
|
@ -122,10 +146,28 @@ function duration(analysis: Analysis) {
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-pending { background: rgba(234, 179, 8, 0.15); color: var(--warning); }
|
||||
.status-running { background: rgba(59, 130, 246, 0.15); color: var(--info); }
|
||||
.status-completed { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||
.status-failed { background: rgba(239, 68, 68, 0.15); color: var(--error); }
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: var(--warning);
|
||||
}
|
||||
.status-running {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--info);
|
||||
}
|
||||
.status-completed {
|
||||
background: none;
|
||||
color: var(--success);
|
||||
padding: 0;
|
||||
}
|
||||
.status-failed {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
font-size: 12px;
|
||||
|
|
@ -146,7 +188,15 @@ function duration(analysis: Analysis) {
|
|||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.history-item:hover .item-delete { opacity: 1; }
|
||||
.item-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); }
|
||||
.item-delete svg { width: 16px; height: 16px; }
|
||||
.history-item:hover .item-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
.item-delete:hover {
|
||||
color: var(--error);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
.item-delete svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,29 @@ import { defineStore } from 'pinia'
|
|||
import { ref, watch, watchEffect } from 'vue'
|
||||
import type { Locale, Theme } from '../../shared/types'
|
||||
|
||||
function safeGetItem(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function safeSetItem(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// localStorage unavailable (private browsing, quota exceeded)
|
||||
}
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const apiUrl = ref('http://localhost:8000')
|
||||
const theme = ref<Theme>((localStorage.getItem('docling-theme') as Theme) || 'dark')
|
||||
const locale = ref<Locale>((localStorage.getItem('docling-locale') as Locale) || 'fr')
|
||||
const theme = ref<Theme>((safeGetItem('docling-theme') as Theme) || 'dark')
|
||||
const locale = ref<Locale>((safeGetItem('docling-locale') as Locale) || 'fr')
|
||||
|
||||
watch(theme, (v) => localStorage.setItem('docling-theme', v))
|
||||
watch(locale, (v) => localStorage.setItem('docling-locale', v))
|
||||
watch(theme, (v) => safeSetItem('docling-theme', v))
|
||||
watch(locale, (v) => safeSetItem('docling-locale', v))
|
||||
|
||||
watchEffect(() => {
|
||||
document.documentElement.classList.toggle('light', theme.value === 'light')
|
||||
|
|
|
|||
|
|
@ -15,14 +15,18 @@
|
|||
<div class="setting-group">
|
||||
<label class="setting-label">{{ t('settings.language') }}</label>
|
||||
<div class="setting-toggle">
|
||||
<button :class="{ active: store.locale === 'fr' }" @click="store.setLocale('fr')">FR</button>
|
||||
<button :class="{ active: store.locale === 'en' }" @click="store.setLocale('en')">EN</button>
|
||||
<button :class="{ active: store.locale === 'fr' }" @click="store.setLocale('fr')">
|
||||
FR
|
||||
</button>
|
||||
<button :class="{ active: store.locale === 'en' }" @click="store.setLocale('en')">
|
||||
EN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">{{ t('settings.version') }}</label>
|
||||
<span class="setting-value">0.1.0</span>
|
||||
<span class="setting-value">{{ version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -31,6 +35,7 @@
|
|||
import { useSettingsStore } from '../store'
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
|
||||
const version = __APP_VERSION__
|
||||
const store = useSettingsStore()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
|
@ -39,14 +44,14 @@ const { t } = useI18n()
|
|||
.settings-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
max-width: 500px;
|
||||
gap: 24px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
|
|
@ -58,33 +63,36 @@ const { t } = useI18n()
|
|||
}
|
||||
|
||||
.setting-toggle {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
padding: 3px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.setting-toggle button {
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
padding: 6px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
.setting-toggle button:hover {
|
||||
color: var(--text);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.setting-toggle button.active {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.setting-input {
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@
|
|||
{{ t('history.emptyDocs') }}
|
||||
</div>
|
||||
<div v-else class="doc-items">
|
||||
<div
|
||||
v-for="doc in docStore.documents"
|
||||
:key="doc.id"
|
||||
class="doc-row"
|
||||
>
|
||||
<div v-for="doc in docStore.documents" :key="doc.id" class="doc-row">
|
||||
<div class="doc-row-info">
|
||||
<svg class="doc-row-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div class="doc-row-meta">
|
||||
<span class="doc-row-name">{{ doc.filename }}</span>
|
||||
|
|
@ -27,7 +27,13 @@
|
|||
</div>
|
||||
</div>
|
||||
<button class="doc-row-delete" @click="docStore.remove(doc.id)">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -89,19 +95,25 @@ onMounted(() => {
|
|||
.doc-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.doc-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background var(--transition);
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.doc-row:hover { background: var(--bg-hover); }
|
||||
.doc-row:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.doc-row-info {
|
||||
display: flex;
|
||||
|
|
@ -152,7 +164,15 @@ onMounted(() => {
|
|||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.doc-row:hover .doc-row-delete { opacity: 1; }
|
||||
.doc-row-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); }
|
||||
.doc-row-delete svg { width: 16px; height: 16px; }
|
||||
.doc-row:hover .doc-row-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
.doc-row-delete:hover {
|
||||
color: var(--error);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
.doc-row-delete svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
<div class="home-page">
|
||||
<div class="home-center">
|
||||
<div class="hero">
|
||||
<div class="hero-icon">
|
||||
<span class="hero-d">D</span>
|
||||
</div>
|
||||
<img src="/logo.png" alt="Docling Studio" class="hero-logo" />
|
||||
<h1 class="hero-title">{{ t('home.title') }}</h1>
|
||||
<p class="hero-subtitle">{{ t('home.subtitle') }}</p>
|
||||
</div>
|
||||
|
|
@ -17,10 +15,24 @@
|
|||
<!-- Stats -->
|
||||
<div class="home-stats" v-if="docCount > 0 || analysisCount > 0">
|
||||
<div class="stat-card" @click="$router.push('/documents')">
|
||||
<svg class="stat-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div class="stat-value">{{ docCount }}</div>
|
||||
<div class="stat-label">{{ t('home.documents') }}</div>
|
||||
</div>
|
||||
<div class="stat-card" @click="$router.push('/history')">
|
||||
<svg class="stat-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div class="stat-value">{{ analysisCount }}</div>
|
||||
<div class="stat-label">{{ t('home.analyses') }}</div>
|
||||
</div>
|
||||
|
|
@ -37,14 +49,22 @@
|
|||
@click="openInStudio(doc)"
|
||||
>
|
||||
<svg class="recent-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div class="recent-meta">
|
||||
<span class="recent-name">{{ doc.filename }}</span>
|
||||
<span class="recent-detail">{{ doc.pageCount ? doc.pageCount + ' pages' : '' }}</span>
|
||||
</div>
|
||||
<svg class="recent-arrow" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -114,21 +134,12 @@ onMounted(() => {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-icon {
|
||||
.hero-logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: contain;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.hero-d {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-radius: var(--radius-lg);
|
||||
font-weight: 700;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
|
|
@ -171,10 +182,21 @@ onMounted(() => {
|
|||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: var(--border-light);
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-card:hover .stat-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
<!-- STATE 1: No document selected — Import view -->
|
||||
<div v-if="!selectedDoc" class="import-page">
|
||||
<div class="import-center">
|
||||
<div class="import-logo">
|
||||
<span class="logo-icon">D</span>
|
||||
</div>
|
||||
<img src="/logo.png" alt="Docling Studio" class="import-logo-img" />
|
||||
<h1 class="import-title">{{ t('studio.title') }}</h1>
|
||||
<p class="import-subtitle">{{ t('studio.subtitle') }}</p>
|
||||
<DocumentUpload />
|
||||
|
|
@ -27,6 +25,13 @@
|
|||
:class="{ active: mode === 'configurer' }"
|
||||
@click="mode = 'configurer'"
|
||||
>
|
||||
<svg class="toggle-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('studio.configure') }}
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -35,13 +40,38 @@
|
|||
@click="mode = 'verifier'"
|
||||
:disabled="!analysisStore.currentAnalysis"
|
||||
>
|
||||
<svg class="toggle-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('studio.verify') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="chunkingEnabled"
|
||||
class="toggle-btn"
|
||||
:class="{ active: mode === 'preparer' }"
|
||||
@click="mode = 'preparer'"
|
||||
:disabled="!analysisStore.currentAnalysis"
|
||||
>
|
||||
<svg class="toggle-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('studio.prepare') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<button class="topbar-btn" @click="addMore">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="btn-icon"><path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"/></svg>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="btn-icon">
|
||||
<path
|
||||
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('studio.addFiles') }}
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -51,7 +81,13 @@
|
|||
v-if="mode === 'configurer'"
|
||||
>
|
||||
<div v-if="analysisStore.running" class="spinner-sm" />
|
||||
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="btn-icon"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/></svg>
|
||||
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="btn-icon">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{{ analysisStore.running ? t('studio.analyzing') : t('studio.run') }}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -60,7 +96,13 @@
|
|||
<!-- Document info bar -->
|
||||
<div class="doc-infobar">
|
||||
<div class="doc-info-left">
|
||||
<svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg>
|
||||
<svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="doc-filename">{{ selectedDoc.filename }}</span>
|
||||
<span class="doc-status-chip loaded">{{ t('studio.loaded') }}</span>
|
||||
</div>
|
||||
|
|
@ -86,7 +128,13 @@
|
|||
<div class="pdf-viewer-panel">
|
||||
<div class="pdf-nav-bar">
|
||||
<button class="pdf-nav-btn" :disabled="currentPage <= 1" @click="currentPage--">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="pdf-page-input-wrap">
|
||||
<input
|
||||
|
|
@ -99,8 +147,18 @@
|
|||
/>
|
||||
</div>
|
||||
<span class="pdf-page-total">/ {{ selectedDoc.pageCount || '?' }}</span>
|
||||
<button class="pdf-nav-btn" :disabled="!selectedDoc.pageCount || currentPage >= selectedDoc.pageCount" @click="currentPage++">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
||||
<button
|
||||
class="pdf-nav-btn"
|
||||
:disabled="!selectedDoc.pageCount || currentPage >= selectedDoc.pageCount"
|
||||
@click="currentPage++"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="pdf-separator" />
|
||||
<span class="pdf-zoom">100%</span>
|
||||
|
|
@ -111,7 +169,14 @@
|
|||
:class="{ active: visualMode }"
|
||||
@click="visualMode = !visualMode"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="btn-icon"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="btn-icon">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('studio.visual') }}
|
||||
</button>
|
||||
</template>
|
||||
|
|
@ -127,11 +192,12 @@
|
|||
@load="onPdfImageLoad"
|
||||
/>
|
||||
<BboxOverlay
|
||||
v-if="visualMode && hasAnalysisResults"
|
||||
v-if="(visualMode || mode === 'preparer') && hasAnalysisResults"
|
||||
ref="bboxOverlayRef"
|
||||
:image-el="pdfImageRef"
|
||||
:page-data="currentPageData"
|
||||
:highlighted-index="highlightedElementIndex"
|
||||
:highlighted-bboxes="highlightedChunkBboxes"
|
||||
@highlight-element="highlightedElementIndex = $event"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -139,10 +205,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Resize handle -->
|
||||
<div
|
||||
class="resize-handle"
|
||||
@mousedown="onResizeStart"
|
||||
>
|
||||
<div class="resize-handle" @mousedown="onResizeStart">
|
||||
<div class="resize-grip" />
|
||||
</div>
|
||||
|
||||
|
|
@ -170,16 +233,26 @@
|
|||
<span class="toggle-switch" />
|
||||
<span class="toggle-text">{{ t('config.ocr') }}</span>
|
||||
</label>
|
||||
<span class="config-hint"><span class="config-tooltip">{{ t('config.ocrHint') }}</span>?</span>
|
||||
<span class="config-hint"
|
||||
><span class="config-tooltip">{{ t('config.ocrHint') }}</span
|
||||
>?</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="config-toggle-row">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" v-model="pipelineOptions.do_table_structure" class="toggle-input" />
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="pipelineOptions.do_table_structure"
|
||||
class="toggle-input"
|
||||
/>
|
||||
<span class="toggle-switch" />
|
||||
<span class="toggle-text">{{ t('config.tableStructure') }}</span>
|
||||
</label>
|
||||
<span class="config-hint"><span class="config-tooltip">{{ t('config.tableStructureHint') }}</span>?</span>
|
||||
<span class="config-hint"
|
||||
><span class="config-tooltip">{{ t('config.tableStructureHint') }}</span
|
||||
>?</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="config-sub-option" v-if="pipelineOptions.do_table_structure">
|
||||
|
|
@ -197,20 +270,34 @@
|
|||
|
||||
<div class="config-toggle-row">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" v-model="pipelineOptions.do_code_enrichment" class="toggle-input" />
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="pipelineOptions.do_code_enrichment"
|
||||
class="toggle-input"
|
||||
/>
|
||||
<span class="toggle-switch" />
|
||||
<span class="toggle-text">{{ t('config.codeEnrichment') }}</span>
|
||||
</label>
|
||||
<span class="config-hint"><span class="config-tooltip">{{ t('config.codeEnrichmentHint') }}</span>?</span>
|
||||
<span class="config-hint"
|
||||
><span class="config-tooltip">{{ t('config.codeEnrichmentHint') }}</span
|
||||
>?</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="config-toggle-row">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" v-model="pipelineOptions.do_formula_enrichment" class="toggle-input" />
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="pipelineOptions.do_formula_enrichment"
|
||||
class="toggle-input"
|
||||
/>
|
||||
<span class="toggle-switch" />
|
||||
<span class="toggle-text">{{ t('config.formulaEnrichment') }}</span>
|
||||
</label>
|
||||
<span class="config-hint"><span class="config-tooltip">{{ t('config.formulaEnrichmentHint') }}</span>?</span>
|
||||
<span class="config-hint"
|
||||
><span class="config-tooltip">{{ t('config.formulaEnrichmentHint') }}</span
|
||||
>?</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -220,41 +307,72 @@
|
|||
|
||||
<div class="config-toggle-row">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" v-model="pipelineOptions.do_picture_classification" class="toggle-input" />
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="pipelineOptions.do_picture_classification"
|
||||
class="toggle-input"
|
||||
/>
|
||||
<span class="toggle-switch" />
|
||||
<span class="toggle-text">{{ t('config.pictureClassification') }}</span>
|
||||
</label>
|
||||
<span class="config-hint"><span class="config-tooltip">{{ t('config.pictureClassificationHint') }}</span>?</span>
|
||||
<span class="config-hint"
|
||||
><span class="config-tooltip">{{ t('config.pictureClassificationHint') }}</span
|
||||
>?</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="config-toggle-row">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" v-model="pipelineOptions.do_picture_description" class="toggle-input" />
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="pipelineOptions.do_picture_description"
|
||||
class="toggle-input"
|
||||
/>
|
||||
<span class="toggle-switch" />
|
||||
<span class="toggle-text">{{ t('config.pictureDescription') }}</span>
|
||||
</label>
|
||||
<span class="config-hint"><span class="config-tooltip">{{ t('config.pictureDescriptionHint') }}</span>?</span>
|
||||
<span class="config-hint"
|
||||
><span class="config-tooltip">{{ t('config.pictureDescriptionHint') }}</span
|
||||
>?</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="config-toggle-row">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" v-model="pipelineOptions.generate_picture_images" class="toggle-input" />
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="pipelineOptions.generate_picture_images"
|
||||
class="toggle-input"
|
||||
/>
|
||||
<span class="toggle-switch" />
|
||||
<span class="toggle-text">{{ t('config.generatePictureImages') }}</span>
|
||||
</label>
|
||||
<span class="config-hint"><span class="config-tooltip">{{ t('config.generatePictureImagesHint') }}</span>?</span>
|
||||
<span class="config-hint"
|
||||
><span class="config-tooltip">{{ t('config.generatePictureImagesHint') }}</span
|
||||
>?</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="config-toggle-row">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" v-model="pipelineOptions.generate_page_images" class="toggle-input" />
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="pipelineOptions.generate_page_images"
|
||||
class="toggle-input"
|
||||
/>
|
||||
<span class="toggle-switch" />
|
||||
<span class="toggle-text">{{ t('config.generatePageImages') }}</span>
|
||||
</label>
|
||||
<span class="config-hint"><span class="config-tooltip">{{ t('config.generatePageImagesHint') }}</span>?</span>
|
||||
<span class="config-hint"
|
||||
><span class="config-tooltip">{{ t('config.generatePageImagesHint') }}</span
|
||||
>?</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="config-sub-option" v-if="pipelineOptions.generate_picture_images || pipelineOptions.generate_page_images">
|
||||
<div
|
||||
class="config-sub-option"
|
||||
v-if="pipelineOptions.generate_picture_images || pipelineOptions.generate_page_images"
|
||||
>
|
||||
<label class="config-label-sm">{{ t('config.imagesScale') }}</label>
|
||||
<select class="config-select" v-model.number="pipelineOptions.images_scale">
|
||||
<option :value="0.5">0.5x</option>
|
||||
|
|
@ -280,6 +398,14 @@
|
|||
@highlight-element="highlightedElementIndex = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- PREPARER MODE (feature-flipped) -->
|
||||
<div v-if="mode === 'preparer' && chunkingEnabled" class="prepare-panel">
|
||||
<ChunkPanel
|
||||
:current-page="currentPage"
|
||||
@highlight-bboxes="highlightedChunkBboxes = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -293,20 +419,24 @@ import { useAnalysisStore } from '../features/analysis/store'
|
|||
import { DocumentUpload, DocumentList } from '../features/document/index'
|
||||
import { ResultTabs } from '../features/analysis/index'
|
||||
import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue'
|
||||
import { ChunkPanel } from '../features/chunking'
|
||||
import { useFeatureFlag } from '../features/feature-flags'
|
||||
import { getPreviewUrl } from '../features/document/api'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
import type { PipelineOptions } from '../shared/types'
|
||||
import type { ChunkBbox, PipelineOptions } from '../shared/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const documentStore = useDocumentStore()
|
||||
const analysisStore = useAnalysisStore()
|
||||
const { t } = useI18n()
|
||||
const chunkingEnabled = useFeatureFlag('chunking')
|
||||
|
||||
const mode = ref('configurer')
|
||||
const currentPage = ref(1)
|
||||
const visualMode = ref(false)
|
||||
const highlightedElementIndex = ref(-1)
|
||||
const highlightedChunkBboxes = ref<ChunkBbox[]>([])
|
||||
const pdfImageRef = ref<HTMLImageElement | null>(null)
|
||||
const bboxOverlayRef = ref<InstanceType<typeof BboxOverlay> | null>(null)
|
||||
|
||||
|
|
@ -356,12 +486,14 @@ const pipelineOptions = reactive<PipelineOptions>({
|
|||
})
|
||||
|
||||
const hasAnalysisResults = computed(() => {
|
||||
return analysisStore.currentAnalysis?.status === 'COMPLETED' && analysisStore.currentPages?.length > 0
|
||||
return (
|
||||
analysisStore.currentAnalysis?.status === 'COMPLETED' && analysisStore.currentPages?.length > 0
|
||||
)
|
||||
})
|
||||
|
||||
const currentPageData = computed(() => {
|
||||
if (!analysisStore.currentPages) return null
|
||||
return analysisStore.currentPages.find(p => p.page_number === currentPage.value) || null
|
||||
return analysisStore.currentPages.find((p) => p.page_number === currentPage.value) || null
|
||||
})
|
||||
|
||||
function onPdfImageLoad() {
|
||||
|
|
@ -369,7 +501,7 @@ function onPdfImageLoad() {
|
|||
}
|
||||
|
||||
const selectedDoc = computed(() => {
|
||||
return documentStore.documents.find(d => d.id === documentStore.selectedId)
|
||||
return documentStore.documents.find((d) => d.id === documentStore.selectedId)
|
||||
})
|
||||
|
||||
const previewUrl = computed(() => {
|
||||
|
|
@ -393,13 +525,26 @@ function addMore() {
|
|||
documentStore.selectedId = null
|
||||
}
|
||||
|
||||
// Auto-switch to verifier when analysis completes + refresh document data (pageCount)
|
||||
watch(() => analysisStore.currentAnalysis?.status, (status) => {
|
||||
if (status === 'COMPLETED') {
|
||||
mode.value = 'verifier'
|
||||
documentStore.load()
|
||||
}
|
||||
// Clear highlights when switching modes or pages
|
||||
watch(mode, () => {
|
||||
highlightedElementIndex.value = -1
|
||||
highlightedChunkBboxes.value = []
|
||||
})
|
||||
watch(currentPage, () => {
|
||||
highlightedElementIndex.value = -1
|
||||
highlightedChunkBboxes.value = []
|
||||
})
|
||||
|
||||
// Auto-switch to verifier when analysis completes + refresh document data (pageCount)
|
||||
watch(
|
||||
() => analysisStore.currentAnalysis?.status,
|
||||
(status) => {
|
||||
if (status === 'COMPLETED') {
|
||||
mode.value = 'verifier'
|
||||
documentStore.load()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await documentStore.load()
|
||||
|
|
@ -447,21 +592,12 @@ onBeforeUnmount(() => {
|
|||
gap: 20px;
|
||||
}
|
||||
|
||||
.import-logo {
|
||||
.import-logo-img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.import-logo .logo-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.import-title {
|
||||
|
|
@ -516,45 +652,72 @@ onBeforeUnmount(() => {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 6px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.toggle-btn:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.toggle-btn:hover:not(:disabled) .toggle-icon {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toggle-btn.active .toggle-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toggle-btn:disabled {
|
||||
opacity: 0.4;
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
|
@ -607,7 +770,7 @@ onBeforeUnmount(() => {
|
|||
.spinner-sm {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
|
|
@ -679,8 +842,12 @@ onBeforeUnmount(() => {
|
|||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.info-dot.success { background: var(--success); }
|
||||
.info-dot.error { background: var(--error); }
|
||||
.info-dot.success {
|
||||
background: var(--success);
|
||||
}
|
||||
.info-dot.error {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.spinner-xs {
|
||||
width: 12px;
|
||||
|
|
@ -779,9 +946,18 @@ onBeforeUnmount(() => {
|
|||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.pdf-nav-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
|
||||
.pdf-nav-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.pdf-nav-btn svg { width: 16px; height: 16px; }
|
||||
.pdf-nav-btn:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
.pdf-nav-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
.pdf-nav-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.pdf-page-input-wrap {
|
||||
display: flex;
|
||||
|
|
@ -880,7 +1056,7 @@ onBeforeUnmount(() => {
|
|||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
box-shadow: 0 2px 20px rgba(0,0,0,0.4);
|
||||
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.4);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
|
|
@ -1119,12 +1295,17 @@ onBeforeUnmount(() => {
|
|||
}
|
||||
|
||||
/* Verify panel */
|
||||
.verify-panel {
|
||||
.verify-panel,
|
||||
.prepare-panel {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -15,9 +15,12 @@ describe('apiFetch', () => {
|
|||
|
||||
await apiFetch('/api/test')
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('/api/test', expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
|
||||
}))
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'/api/test',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('skips Content-Type when skipContentType is true', async () => {
|
||||
|
|
@ -73,10 +76,13 @@ describe('apiFetch', () => {
|
|||
const body = JSON.stringify({ documentId: '42' })
|
||||
await apiFetch('/api/analyses', { method: 'POST', body })
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('/api/analyses', expect.objectContaining({
|
||||
method: 'POST',
|
||||
body,
|
||||
}))
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'/api/analyses',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('merges custom headers with Content-Type', async () => {
|
||||
|
|
|
|||
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.title': 'Docling Studio',
|
||||
'home.subtitle': 'Analysez, explorez et validez la structure de vos documents PDF grâce à Docling.',
|
||||
'home.subtitle':
|
||||
'Analysez, explorez et validez la structure de vos documents PDF grâce à Docling.',
|
||||
'home.documents': 'Documents',
|
||||
'home.analyses': 'Analyses',
|
||||
'home.recentDocs': 'Documents récents',
|
||||
|
|
@ -45,26 +46,34 @@ const messages: Messages = {
|
|||
'config.model': 'Modèle',
|
||||
'config.pipeline': 'Pipeline',
|
||||
'config.ocr': 'OCR',
|
||||
'config.ocrHint': "Applique la reconnaissance optique de caractères sur les pages scannées ou les images intégrées. Indispensable pour les PDF non-natifs.",
|
||||
'config.ocrHint':
|
||||
'Applique la reconnaissance optique de caractères sur les pages scannées ou les images intégrées. Indispensable pour les PDF non-natifs.',
|
||||
'config.tableStructure': 'Extraction des tableaux',
|
||||
'config.tableStructureHint': "Détecte les tableaux dans le document et reconstruit leur structure lignes/colonnes via le modèle TableFormer, avec correspondance des cellules.",
|
||||
'config.tableStructureHint':
|
||||
'Détecte les tableaux dans le document et reconstruit leur structure lignes/colonnes via le modèle TableFormer, avec correspondance des cellules.',
|
||||
'config.tableMode': 'Mode tableaux',
|
||||
'config.tableModeAccurate': 'Précis',
|
||||
'config.tableModeFast': 'Rapide',
|
||||
'config.enrichment': 'Enrichissement',
|
||||
'config.codeEnrichment': 'Code',
|
||||
'config.codeEnrichmentHint': "Active un modèle OCR spécialisé pour les blocs de code, préservant l'indentation et la syntaxe.",
|
||||
'config.codeEnrichmentHint':
|
||||
"Active un modèle OCR spécialisé pour les blocs de code, préservant l'indentation et la syntaxe.",
|
||||
'config.formulaEnrichment': 'Formules',
|
||||
'config.formulaEnrichmentHint': "Reconnaît les formules mathématiques et les convertit en LaTeX via un modèle dédié.",
|
||||
'config.formulaEnrichmentHint':
|
||||
'Reconnaît les formules mathématiques et les convertit en LaTeX via un modèle dédié.',
|
||||
'config.pictures': 'Images',
|
||||
'config.pictureClassification': 'Classification',
|
||||
'config.pictureClassificationHint': "Classe chaque image détectée par type (graphique, photo, diagramme, logo…) via un modèle de classification.",
|
||||
'config.pictureClassificationHint':
|
||||
'Classe chaque image détectée par type (graphique, photo, diagramme, logo…) via un modèle de classification.',
|
||||
'config.pictureDescription': 'Description',
|
||||
'config.pictureDescriptionHint': "Génère une description textuelle de chaque image via un Vision Language Model (VLM). Utile pour l'accessibilité et l'indexation.",
|
||||
'config.pictureDescriptionHint':
|
||||
"Génère une description textuelle de chaque image via un Vision Language Model (VLM). Utile pour l'accessibilité et l'indexation.",
|
||||
'config.generatePictureImages': 'Extraire les images',
|
||||
'config.generatePictureImagesHint': "Extrait les images détectées du document et les sauvegarde en tant que fichiers séparés. Nécessaire pour l'export d'images.",
|
||||
'config.generatePictureImagesHint':
|
||||
"Extrait les images détectées du document et les sauvegarde en tant que fichiers séparés. Nécessaire pour l'export d'images.",
|
||||
'config.generatePageImages': 'Images de pages',
|
||||
'config.generatePageImagesHint': "Rasterise chaque page du PDF en image. Utile pour la visualisation ou le post-traitement visuel.",
|
||||
'config.generatePageImagesHint':
|
||||
'Rasterise chaque page du PDF en image. Utile pour la visualisation ou le post-traitement visuel.',
|
||||
'config.imagesScale': 'Échelle images',
|
||||
'config.documents': 'Documents',
|
||||
|
||||
|
|
@ -95,6 +104,23 @@ const messages: Messages = {
|
|||
'history.emptyDocs': 'Aucun document. Importez un document depuis le Studio.',
|
||||
'history.open': 'Ouvrir',
|
||||
|
||||
// Chunking
|
||||
'studio.prepare': 'Préparer',
|
||||
'chunking.settings': 'Chunking',
|
||||
'chunking.chunkerType': 'Type de chunker',
|
||||
'chunking.maxTokens': 'Tokens max',
|
||||
'chunking.mergePeers': 'Fusionner les pairs',
|
||||
'chunking.repeatTableHeader': 'Répéter en-têtes tableaux',
|
||||
'chunking.run': 'Chunker',
|
||||
'chunking.chunking': 'Chunking...',
|
||||
'chunking.chunks': 'chunks',
|
||||
'chunking.noChunks': 'Lancez le chunking pour préparer les segments.',
|
||||
'chunking.noChunksOnPage': 'Aucun chunk sur cette page.',
|
||||
|
||||
// Pagination
|
||||
'pagination.pageOf': 'Page {current} sur {total}',
|
||||
'pagination.perPage': '/ page',
|
||||
|
||||
// Settings
|
||||
'settings.title': 'Paramètres',
|
||||
'settings.apiUrl': 'API URL',
|
||||
|
|
@ -103,6 +129,10 @@ const messages: Messages = {
|
|||
'settings.themeDark': 'Sombre',
|
||||
'settings.themeLight': 'Clair',
|
||||
'settings.language': 'Langue',
|
||||
|
||||
// Disclaimer
|
||||
'disclaimer.banner':
|
||||
'Instance de d\u00e9monstration \u2014 les documents upload\u00e9s sont partag\u00e9s et temporaires (max 50 Mo). Ne pas envoyer de fichiers confidentiels.',
|
||||
},
|
||||
en: {
|
||||
'nav.home': 'Home',
|
||||
|
|
@ -116,7 +146,8 @@ const messages: Messages = {
|
|||
'topbar.newAnalysis': 'New analysis',
|
||||
|
||||
'home.title': 'Docling Studio',
|
||||
'home.subtitle': 'Analyze, explore and validate the structure of your PDF documents with Docling.',
|
||||
'home.subtitle':
|
||||
'Analyze, explore and validate the structure of your PDF documents with Docling.',
|
||||
'home.documents': 'Documents',
|
||||
'home.analyses': 'Analyses',
|
||||
'home.recentDocs': 'Recent documents',
|
||||
|
|
@ -138,26 +169,34 @@ const messages: Messages = {
|
|||
'config.model': 'Model',
|
||||
'config.pipeline': 'Pipeline',
|
||||
'config.ocr': 'OCR',
|
||||
'config.ocrHint': 'Applies Optical Character Recognition on scanned pages or embedded images. Essential for non-native PDFs.',
|
||||
'config.ocrHint':
|
||||
'Applies Optical Character Recognition on scanned pages or embedded images. Essential for non-native PDFs.',
|
||||
'config.tableStructure': 'Table extraction',
|
||||
'config.tableStructureHint': 'Detects tables in the document and reconstructs their row/column structure using the TableFormer model, with cell matching.',
|
||||
'config.tableStructureHint':
|
||||
'Detects tables in the document and reconstructs their row/column structure using the TableFormer model, with cell matching.',
|
||||
'config.tableMode': 'Table mode',
|
||||
'config.tableModeAccurate': 'Accurate',
|
||||
'config.tableModeFast': 'Fast',
|
||||
'config.enrichment': 'Enrichment',
|
||||
'config.codeEnrichment': 'Code',
|
||||
'config.codeEnrichmentHint': 'Activates a specialized OCR model for code blocks, preserving indentation and syntax.',
|
||||
'config.codeEnrichmentHint':
|
||||
'Activates a specialized OCR model for code blocks, preserving indentation and syntax.',
|
||||
'config.formulaEnrichment': 'Formulas',
|
||||
'config.formulaEnrichmentHint': 'Recognizes mathematical formulas and converts them to LaTeX using a dedicated model.',
|
||||
'config.formulaEnrichmentHint':
|
||||
'Recognizes mathematical formulas and converts them to LaTeX using a dedicated model.',
|
||||
'config.pictures': 'Pictures',
|
||||
'config.pictureClassification': 'Classification',
|
||||
'config.pictureClassificationHint': 'Classifies each detected image by type (chart, photo, diagram, logo…) using a classification model.',
|
||||
'config.pictureClassificationHint':
|
||||
'Classifies each detected image by type (chart, photo, diagram, logo…) using a classification model.',
|
||||
'config.pictureDescription': 'Description',
|
||||
'config.pictureDescriptionHint': 'Generates a text description for each image using a Vision Language Model (VLM). Useful for accessibility and indexing.',
|
||||
'config.pictureDescriptionHint':
|
||||
'Generates a text description for each image using a Vision Language Model (VLM). Useful for accessibility and indexing.',
|
||||
'config.generatePictureImages': 'Extract pictures',
|
||||
'config.generatePictureImagesHint': 'Extracts detected images from the document and saves them as separate files. Required for image export.',
|
||||
'config.generatePictureImagesHint':
|
||||
'Extracts detected images from the document and saves them as separate files. Required for image export.',
|
||||
'config.generatePageImages': 'Page images',
|
||||
'config.generatePageImagesHint': 'Rasterizes each PDF page as an image. Useful for visual preview or post-processing.',
|
||||
'config.generatePageImagesHint':
|
||||
'Rasterizes each PDF page as an image. Useful for visual preview or post-processing.',
|
||||
'config.imagesScale': 'Images scale',
|
||||
'config.documents': 'Documents',
|
||||
|
||||
|
|
@ -185,6 +224,21 @@ const messages: Messages = {
|
|||
'history.emptyDocs': 'No documents yet. Upload a document from the Studio.',
|
||||
'history.open': 'Open',
|
||||
|
||||
'studio.prepare': 'Prepare',
|
||||
'chunking.settings': 'Chunking',
|
||||
'chunking.chunkerType': 'Chunker type',
|
||||
'chunking.maxTokens': 'Max tokens',
|
||||
'chunking.mergePeers': 'Merge peers',
|
||||
'chunking.repeatTableHeader': 'Repeat table headers',
|
||||
'chunking.run': 'Chunk',
|
||||
'chunking.chunking': 'Chunking...',
|
||||
'chunking.chunks': 'chunks',
|
||||
'chunking.noChunks': 'Run chunking to prepare segments.',
|
||||
'chunking.noChunksOnPage': 'No chunks on this page.',
|
||||
|
||||
'pagination.pageOf': 'Page {current} of {total}',
|
||||
'pagination.perPage': '/ page',
|
||||
|
||||
'settings.title': 'Settings',
|
||||
'settings.apiUrl': 'API URL',
|
||||
'settings.version': 'Version',
|
||||
|
|
@ -192,6 +246,10 @@ const messages: Messages = {
|
|||
'settings.themeDark': 'Dark',
|
||||
'settings.themeLight': 'Light',
|
||||
'settings.language': 'Language',
|
||||
|
||||
// Disclaimer
|
||||
'disclaimer.banner':
|
||||
'Demo instance \u2014 uploaded documents are shared and temporary (max 50 MB). Do not upload confidential files.',
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -201,7 +259,7 @@ export function useI18n() {
|
|||
function t(key: string, params: Record<string, string | number> = {}): string {
|
||||
let str = messages[settings.locale]?.[key] || messages['fr'][key] || key
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
str = str.replace(`{${k}}`, String(v))
|
||||
str = str.replaceAll(`{${k}}`, String(v))
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,12 +30,34 @@ export interface Analysis {
|
|||
contentMarkdown: string | null
|
||||
contentHtml: string | null
|
||||
pagesJson: string | null
|
||||
chunksJson: string | null
|
||||
hasDocumentJson: boolean
|
||||
errorMessage: string | null
|
||||
startedAt: string | null
|
||||
completedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ChunkingOptions {
|
||||
chunker_type?: 'hybrid' | 'hierarchical'
|
||||
max_tokens?: number
|
||||
merge_peers?: boolean
|
||||
repeat_table_header?: boolean
|
||||
}
|
||||
|
||||
export interface ChunkBbox {
|
||||
page: number
|
||||
bbox: [number, number, number, number]
|
||||
}
|
||||
|
||||
export interface Chunk {
|
||||
text: string
|
||||
headings: string[]
|
||||
sourcePage: number | null
|
||||
tokenCount: number
|
||||
bboxes: ChunkBbox[]
|
||||
}
|
||||
|
||||
export interface PageElement {
|
||||
type: string
|
||||
bbox: [number, number, number, number]
|
||||
|
|
|
|||
|
|
@ -2,33 +2,71 @@
|
|||
<aside class="sidebar" :class="{ open }">
|
||||
<nav class="sidebar-nav">
|
||||
<RouterLink to="/" class="nav-item" :class="{ active: route.name === 'home' }">
|
||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"/></svg>
|
||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="nav-label">{{ t('nav.home') }}</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/studio" class="nav-item" :class="{ active: route.name === 'studio' }">
|
||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838l-2.727 1.17 1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zm5.99 7.176A9.026 9.026 0 007 15.96v-4.5l.61.26a2.5 2.5 0 001.98 0l.61-.26v4.5a9.026 9.026 0 00-1.7.613z"/></svg>
|
||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838l-2.727 1.17 1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zm5.99 7.176A9.026 9.026 0 007 15.96v-4.5l.61.26a2.5 2.5 0 001.98 0l.61-.26v4.5a9.026 9.026 0 00-1.7.613z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="nav-label">{{ t('nav.studio') }}</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/documents" class="nav-item" :class="{ active: route.name === 'documents' }">
|
||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg>
|
||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="nav-label">{{ t('nav.documents') }}</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/history" class="nav-item" :class="{ active: route.name === 'history' }">
|
||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
|
||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="nav-label">{{ t('nav.history') }}</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/settings" class="nav-item" :class="{ active: route.name === 'settings' }">
|
||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/></svg>
|
||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="nav-label">{{ t('nav.settings') }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">v0.1.0</span>
|
||||
<a
|
||||
class="github-badge"
|
||||
href="https://github.com/scub-france/Docling-Studio"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<img
|
||||
src="https://img.shields.io/github/stars/scub-france/Docling-Studio?style=social"
|
||||
alt="GitHub Stars"
|
||||
height="20"
|
||||
/>
|
||||
</a>
|
||||
<span class="version">v{{ version }}</span>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
|
@ -37,11 +75,12 @@
|
|||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const version = __APP_VERSION__
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false }
|
||||
open: { type: Boolean, default: false },
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -55,7 +94,9 @@ defineProps({
|
|||
overflow: hidden;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
transition: width 250ms ease, min-width 250ms ease;
|
||||
transition:
|
||||
width 250ms ease,
|
||||
min-width 250ms ease;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
|
|
@ -111,6 +152,17 @@ defineProps({
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.github-badge {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.7;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.github-badge:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
|
|
|
|||
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 PaginationBar } from './PaginationBar.vue'
|
||||
|
|
|
|||
|
|
@ -1,14 +1,24 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || pkg.version),
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue