Merge pull request #19 from scub-france/quality-focus
Add qualityt check and contributing doc
This commit is contained in:
commit
81076dd308
25 changed files with 1420 additions and 34 deletions
18
.editorconfig
Normal file
18
.editorconfig
Normal 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
23
CHANGELOG.md
Normal 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
97
CONTRIBUTING.md
Normal 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).
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import pytest
|
||||
|
||||
|
||||
pytest_plugins = ["pytest_asyncio"]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
30
document-parser/pyproject.toml
Normal file
30
document-parser/pyproject.toml
Normal 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"
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Tests for domain models."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
from domain.models import AnalysisJob, AnalysisStatus, Document
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"""Tests for API schemas — camelCase serialization and validation."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
|
|||
3
frontend/.prettierignore
Normal file
3
frontend/.prettierignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
dist/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
9
frontend/.prettierrc
Normal file
9
frontend/.prettierrc
Normal 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
26
frontend/eslint.config.js
Normal 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
20
frontend/jsconfig.json
Normal 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"]
|
||||
}
|
||||
1158
frontend/package-lock.json
generated
1158
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue