Add qualityt check and contributing doc

This commit is contained in:
pjmalandrino 2026-03-21 15:34:54 +01:00
parent 411cf38a1a
commit 577225d10e
25 changed files with 1420 additions and 34 deletions

18
.editorconfig Normal file
View file

@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.py]
indent_size = 4
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab

23
CHANGELOG.md Normal file
View file

@ -0,0 +1,23 @@
# Changelog
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.1.0] - 2025-01-01
### Added
- Initial release of Docling Studio
- PDF upload and document management
- Configurable Docling pipeline (OCR, tables, code, formulas, images)
- Bounding box visualization with color-coded overlays
- Per-page results synchronized with PDF viewer
- Markdown and HTML export
- Analysis history with re-visit capability
- Dark/Light theme support
- French and English localization
- Docker and Docker Compose deployment
- CI/CD with GitHub Actions (tests + multi-arch Docker build)
- Health check endpoint (`/health`)
- SQLite-backed persistence

97
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,97 @@
# Contributing to Docling Studio
Thank you for your interest in contributing to Docling Studio! This guide will help you get started.
## Getting Started
1. **Fork** the repository on GitHub
2. **Clone** your fork locally:
```bash
git clone https://github.com/<your-username>/Docling-Studio.git
cd Docling-Studio
```
3. **Create a branch** for your work:
```bash
git checkout -b feature/my-feature
```
## Development Setup
### Backend (Python 3.12+)
```bash
cd document-parser
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pip install ruff pytest pytest-asyncio httpx # dev tools
uvicorn main:app --reload --port 8000
```
### Frontend (Node 20+)
```bash
cd frontend
npm install
npm run dev
```
## Code Quality
### Backend — Ruff
We use [Ruff](https://docs.astral.sh/ruff/) for linting and formatting Python code.
```bash
cd document-parser
ruff check . # lint
ruff check . --fix # lint with auto-fix
ruff format . # format
```
### Frontend — ESLint + Prettier
```bash
cd frontend
npx eslint src/ # lint
npx prettier --check src/ # check formatting
npx prettier --write src/ # auto-format
```
## Running Tests
```bash
# Backend (99 tests)
cd document-parser
pytest tests/ -v
# Frontend (87 tests)
cd frontend
npm run test:run
```
All tests must pass before submitting a PR.
## Submitting Changes
1. **Commit** with clear, descriptive messages
2. **Push** your branch to your fork
3. Open a **Pull Request** against `main`
4. Describe **what** changed and **why** in the PR description
5. Ensure CI passes (tests + build)
## Pull Request Guidelines
- Keep PRs focused — one feature or fix per PR
- Add tests for new functionality
- Update documentation if behavior changes
- Follow existing code style and conventions
## Reporting Issues
- Use GitHub Issues to report bugs or request features
- Include steps to reproduce for bugs
- Mention your OS, Python/Node version, and Docker version if relevant
## License
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).

View file

@ -42,7 +42,7 @@ async def create_analysis(body: CreateAnalysisRequest):
try: try:
job = await analysis_service.create(body.documentId, pipeline_options=pipeline_opts) job = await analysis_service.create(body.documentId, pipeline_options=pipeline_opts)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e)) from e
return _to_response(job) return _to_response(job)

View file

@ -44,7 +44,7 @@ async def upload(file: UploadFile):
file_content=content, file_content=content,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=413, detail=str(e)) raise HTTPException(status_code=413, detail=str(e)) from e
return _to_response(doc) return _to_response(doc)
@ -90,7 +90,7 @@ async def preview(
png_bytes = document_service.generate_preview(file_content, page=page, dpi=dpi) png_bytes = document_service.generate_preview(file_content, page=page, dpi=dpi)
return Response(content=png_bytes, media_type="image/png") return Response(content=png_bytes, media_type="image/png")
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e)) from e
except Exception: except Exception as exc:
logger.exception("Failed to generate preview") logger.exception("Failed to generate preview")
raise HTTPException(status_code=422, detail="Failed to generate preview") raise HTTPException(status_code=422, detail="Failed to generate preview") from exc

View file

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

View file

@ -5,10 +5,10 @@ from __future__ import annotations
import enum import enum
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import UTC, datetime
class AnalysisStatus(str, enum.Enum): class AnalysisStatus(enum.StrEnum):
PENDING = "PENDING" PENDING = "PENDING"
RUNNING = "RUNNING" RUNNING = "RUNNING"
COMPLETED = "COMPLETED" COMPLETED = "COMPLETED"
@ -16,7 +16,7 @@ class AnalysisStatus(str, enum.Enum):
def _utcnow() -> datetime: def _utcnow() -> datetime:
return datetime.now(timezone.utc) return datetime.now(UTC)
def _new_id() -> str: def _new_id() -> str:

View file

@ -6,17 +6,18 @@ per-page elements with bounding boxes and hierarchy levels.
from __future__ import annotations from __future__ import annotations
import contextlib
import logging import logging
import threading import threading
from dataclasses import dataclass, field from dataclasses import dataclass, field
from docling.document_converter import DocumentConverter, PdfFormatOption
from docling.datamodel.base_models import InputFormat from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import ( from docling.datamodel.pipeline_options import (
PdfPipelineOptions, PdfPipelineOptions,
TableFormerMode, TableFormerMode,
TableStructureOptions, TableStructureOptions,
) )
from docling.document_converter import DocumentConverter, PdfFormatOption
from docling_core.types.doc import ( from docling_core.types.doc import (
CodeItem, CodeItem,
DocItem, DocItem,
@ -215,10 +216,8 @@ def _process_content_item(
content = getattr(item, "text", "") or "" content = getattr(item, "text", "") or ""
if isinstance(item, TableItem): if isinstance(item, TableItem):
try: with contextlib.suppress(AttributeError, ValueError):
content = item.export_to_markdown() content = item.export_to_markdown()
except (AttributeError, ValueError):
pass
pages[page_no].elements.append( pages[page_no].elements.append(
PageElement(type=element_type, bbox=bbox, content=content, level=level) PageElement(type=element_type, bbox=bbox, content=content, level=level)

View file

@ -14,9 +14,9 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from persistence.database import init_db
from api.documents import router as documents_router
from api.analyses import router as analyses_router from api.analyses import router as analyses_router
from api.documents import router as documents_router
from persistence.database import init_db
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,

View file

@ -18,7 +18,7 @@ def _row_to_job(row) -> AnalysisJob:
started_at=row["started_at"], started_at=row["started_at"],
completed_at=row["completed_at"], completed_at=row["completed_at"],
created_at=row["created_at"], created_at=row["created_at"],
document_filename=row["filename"] if "filename" in row.keys() else None, document_filename=row["filename"] if "filename" in row.keys() else None, # noqa: SIM118
) )

View file

@ -0,0 +1,30 @@
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
"B", # flake8-bugbear
"SIM", # flake8-simplify
"TCH", # flake8-type-checking
"RUF", # ruff-specific
]
ignore = [
"E501", # line too long (handled by formatter)
"B008", # do not perform function calls in argument defaults (FastAPI Depends)
"N815", # camelCase in Pydantic models (intentional — matches frontend API contract)
"TC003", # datetime import used at runtime by Pydantic
]
[tool.ruff.lint.isort]
known-first-party = ["api", "domain", "persistence", "services"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"

View file

@ -96,7 +96,7 @@ async def _run_analysis(
logger.info("Analysis completed: %s (%d pages)", job_id, result.page_count) logger.info("Analysis completed: %s (%d pages)", job_id, result.page_count)
except asyncio.TimeoutError: except TimeoutError:
logger.error("Analysis timed out after %ds: %s", CONVERSION_TIMEOUT, job_id) 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") await _mark_failed(job_id, f"Conversion timed out after {CONVERSION_TIMEOUT}s")

View file

@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from domain.models import AnalysisJob, AnalysisStatus, Document from domain.models import AnalysisJob, Document
from main import app from main import app

View file

@ -33,8 +33,8 @@ class TestToTopleftList:
bbox = BoundingBox(l=10, t=500, r=300, b=100, coord_origin=CoordOrigin.BOTTOMLEFT) bbox = BoundingBox(l=10, t=500, r=300, b=100, coord_origin=CoordOrigin.BOTTOMLEFT)
result = to_topleft_list(bbox, page_height=800.0) result = to_topleft_list(bbox, page_height=800.0)
l, t, r, b = result left, t, r, b = result
assert r > l, "width should be positive" assert r > left, "width should be positive"
assert b > t, "height should be positive" assert b > t, "height should be positive"
def test_full_page_bbox_bottomleft(self): def test_full_page_bbox_bottomleft(self):

View file

@ -1,6 +1,6 @@
"""Tests for domain models.""" """Tests for domain models."""
from datetime import datetime, timezone from datetime import datetime
from domain.models import AnalysisJob, AnalysisStatus, Document from domain.models import AnalysisJob, AnalysisStatus, Document

View file

@ -2,8 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch, call
import pytest import pytest
from docling.datamodel.base_models import InputFormat from docling.datamodel.base_models import InputFormat
@ -12,8 +11,11 @@ from docling.datamodel.pipeline_options import (
TableFormerMode, TableFormerMode,
) )
from domain.parsing import ConversionOptions, build_converter, convert_document, get_default_converter from domain.parsing import (
ConversionOptions,
build_converter,
convert_document,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# build_converter — verifies Docling pipeline options are wired correctly # build_converter — verifies Docling pipeline options are wired correctly
@ -452,6 +454,7 @@ class TestAnalysisEndpointPipelineOptions:
@pytest.fixture @pytest.fixture
def client(self): def client(self):
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from main import app from main import app
return TestClient(app, raise_server_exceptions=False) return TestClient(app, raise_server_exceptions=False)

View file

@ -1,11 +1,10 @@
"""Tests for persistence repositories using a temporary SQLite database.""" """Tests for persistence repositories using a temporary SQLite database."""
import os
import pytest import pytest
from domain.models import AnalysisJob, AnalysisStatus, Document from domain.models import AnalysisJob, AnalysisStatus, Document
from persistence import document_repo, analysis_repo from persistence import analysis_repo, document_repo
from persistence.database import init_db from persistence.database import init_db

View file

@ -1,6 +1,5 @@
"""Tests for API schemas — camelCase serialization and validation.""" """Tests for API schemas — camelCase serialization and validation."""
from datetime import datetime
import pytest import pytest

3
frontend/.prettierignore Normal file
View file

@ -0,0 +1,3 @@
dist/
node_modules/
package-lock.json

9
frontend/.prettierrc Normal file
View file

@ -0,0 +1,9 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always",
"vueIndentScriptAndStyle": false
}

26
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,26 @@
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
export default [
js.configs.recommended,
...pluginVue.configs['flat/recommended'],
{
files: ['src/**/*.{js,vue}'],
rules: {
'no-console': ['warn', { allow: ['warn', 'error'] }],
'no-debugger': 'error',
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'vue/multi-word-component-names': 'off',
'vue/require-default-prop': 'off',
// Formatting handled by Prettier
'vue/max-attributes-per-line': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/html-closing-bracket-spacing': 'off',
'vue/html-self-closing': 'off',
'vue/attributes-order': 'off',
},
},
{
ignores: ['dist/', 'node_modules/', '*.config.js'],
},
]

20
frontend/jsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": false,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.js", "src/**/*.vue"],
"exclude": ["node_modules", "dist"]
}

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,11 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest --watch", "test": "vitest --watch",
"test:run": "vitest run" "test:run": "vitest run",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write src/",
"format:check": "prettier --check src/"
}, },
"dependencies": { "dependencies": {
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
@ -18,7 +22,11 @@
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.0.0",
"@vitejs/plugin-vue": "^5.2.0", "@vitejs/plugin-vue": "^5.2.0",
"eslint": "^9.0.0",
"eslint-plugin-vue": "^9.32.0",
"prettier": "^3.4.0",
"vite": "^5.4.0", "vite": "^5.4.0",
"vitest": "^2.1.0" "vitest": "^2.1.0"
} }

View file

@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import { createAnalysis } from './api.js'
import { useAnalysisStore } from './store.js' import { useAnalysisStore } from './store.js'
vi.mock('../../shared/api/http.js', () => ({ vi.mock('../../shared/api/http.js', () => ({
@ -14,7 +13,6 @@ vi.mock('./api.js', () => ({
deleteAnalysis: vi.fn(), deleteAnalysis: vi.fn(),
})) }))
import { apiFetch } from '../../shared/api/http.js'
import * as api from './api.js' import * as api from './api.js'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -22,9 +20,6 @@ import * as api from './api.js'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('createAnalysis — pipeline options body construction', () => { describe('createAnalysis — pipeline options body construction', () => {
// For these tests we need the REAL createAnalysis, not the mock.
// So we re-import the actual module.
let realCreateAnalysis
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()