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:
job = await analysis_service.create(body.documentId, pipeline_options=pipeline_opts)
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)

View file

@ -44,7 +44,7 @@ async def upload(file: UploadFile):
file_content=content,
)
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)
@ -90,7 +90,7 @@ async def preview(
png_bytes = document_service.generate_preview(file_content, page=page, dpi=dpi)
return Response(content=png_bytes, media_type="image/png")
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception:
raise HTTPException(status_code=404, detail=str(e)) from e
except Exception as exc:
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"]

View file

@ -5,10 +5,10 @@ from __future__ import annotations
import enum
import uuid
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"
RUNNING = "RUNNING"
COMPLETED = "COMPLETED"
@ -16,7 +16,7 @@ class AnalysisStatus(str, enum.Enum):
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
return datetime.now(UTC)
def _new_id() -> str:

View file

@ -6,17 +6,18 @@ per-page elements with bounding boxes and hierarchy levels.
from __future__ import annotations
import contextlib
import logging
import threading
from dataclasses import dataclass, field
from docling.document_converter import DocumentConverter, PdfFormatOption
from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import (
PdfPipelineOptions,
TableFormerMode,
TableStructureOptions,
)
from docling.document_converter import DocumentConverter, PdfFormatOption
from docling_core.types.doc import (
CodeItem,
DocItem,
@ -215,10 +216,8 @@ def _process_content_item(
content = getattr(item, "text", "") or ""
if isinstance(item, TableItem):
try:
with contextlib.suppress(AttributeError, ValueError):
content = item.export_to_markdown()
except (AttributeError, ValueError):
pass
pages[page_no].elements.append(
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.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.documents import router as documents_router
from persistence.database import init_db
logging.basicConfig(
level=logging.INFO,

View file

@ -18,7 +18,7 @@ def _row_to_job(row) -> AnalysisJob:
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,
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)
except asyncio.TimeoutError:
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")

View file

@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from domain.models import AnalysisJob, AnalysisStatus, Document
from domain.models import AnalysisJob, Document
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)
result = to_topleft_list(bbox, page_height=800.0)
l, t, r, b = result
assert r > l, "width should be positive"
left, t, r, b = result
assert r > left, "width should be positive"
assert b > t, "height should be positive"
def test_full_page_bbox_bottomleft(self):

View file

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

View file

@ -2,8 +2,7 @@
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch, call
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from docling.datamodel.base_models import InputFormat
@ -12,8 +11,11 @@ from docling.datamodel.pipeline_options import (
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
@ -452,6 +454,7 @@ class TestAnalysisEndpointPipelineOptions:
@pytest.fixture
def client(self):
from fastapi.testclient import TestClient
from main import app
return TestClient(app, raise_server_exceptions=False)

View file

@ -1,11 +1,10 @@
"""Tests for persistence repositories using a temporary SQLite database."""
import os
import pytest
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

View file

@ -1,6 +1,5 @@
"""Tests for API schemas — camelCase serialization and validation."""
from datetime import datetime
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",
"preview": "vite preview",
"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": {
"dompurify": "^3.3.3",
@ -18,7 +22,11 @@
"vue-router": "^4.6.4"
},
"devDependencies": {
"@eslint/js": "^9.0.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",
"vitest": "^2.1.0"
}

View file

@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { createAnalysis } from './api.js'
import { useAnalysisStore } from './store.js'
vi.mock('../../shared/api/http.js', () => ({
@ -14,7 +13,6 @@ vi.mock('./api.js', () => ({
deleteAnalysis: vi.fn(),
}))
import { apiFetch } from '../../shared/api/http.js'
import * as api from './api.js'
// ---------------------------------------------------------------------------
@ -22,9 +20,6 @@ import * as api from './api.js'
// ---------------------------------------------------------------------------
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(() => {
vi.clearAllMocks()