Merge pull request #40 from scub-france/fix/audit-release-0.3.0
Fix/audit release 0.3.0
This commit is contained in:
commit
f4e11645c7
19 changed files with 55 additions and 30 deletions
|
|
@ -1,4 +1,8 @@
|
|||
# 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)
|
||||
|
|
|
|||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -55,5 +55,5 @@ jobs:
|
|||
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 }}
|
||||
|
|
|
|||
|
|
@ -48,8 +48,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
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ document-parser/
|
|||
|
||||
| Layer | Role | Depends on |
|
||||
|-------|------|------------|
|
||||
| **domain** | Dataclasses, bbox math, Docling conversion | Nothing (pure Python) |
|
||||
| **domain** | Dataclasses, value objects, ports | Nothing (pure Python) |
|
||||
| **persistence** | SQLite CRUD, aiosqlite | domain (models) |
|
||||
| **services** | Orchestrate use cases, call Docling | domain + persistence |
|
||||
| **api** | HTTP endpoints, Pydantic DTOs, error handling | services |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ 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.bbox import EMPTY_BBOX, to_topleft_list
|
||||
from infra.bbox import EMPTY_BBOX, to_topleft_list
|
||||
from domain.value_objects import ChunkBbox, ChunkingOptions, ChunkResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ from docling_core.types.doc import (
|
|||
TitleItem,
|
||||
)
|
||||
|
||||
from domain.bbox import to_topleft_list
|
||||
from infra.bbox import to_topleft_list
|
||||
from domain.value_objects import (
|
||||
ConversionOptions,
|
||||
ConversionResult,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import mimetypes
|
|||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from docling_core.types.doc.base import BoundingBox, CoordOrigin
|
||||
|
||||
from domain.value_objects import (
|
||||
ConversionOptions,
|
||||
|
|
@ -25,6 +26,7 @@ from domain.value_objects import (
|
|||
PageDetail,
|
||||
PageElement,
|
||||
)
|
||||
from infra.bbox import to_topleft_list
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -126,6 +128,7 @@ def _build_form_data(options: ConversionOptions) -> dict[str, str | list[str]]:
|
|||
"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),
|
||||
}
|
||||
|
||||
|
|
@ -152,11 +155,14 @@ def _parse_response(data: dict) -> ConversionResult:
|
|||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -218,21 +224,22 @@ def _add_element(item: dict, pages: dict[int, PageDetail]) -> None:
|
|||
|
||||
|
||||
def _extract_bbox(bbox_data: dict, page_height: float) -> list[float]:
|
||||
"""Extract and normalize bbox to TOPLEFT [l, t, r, b] format."""
|
||||
"""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]
|
||||
|
||||
left = bbox_data.get("l", 0.0)
|
||||
top = bbox_data.get("t", 0.0)
|
||||
right = bbox_data.get("r", 0.0)
|
||||
bottom = bbox_data.get("b", 0.0)
|
||||
coord_origin = bbox_data.get("coord_origin", "TOPLEFT")
|
||||
origin_str = bbox_data.get("coord_origin", "TOPLEFT")
|
||||
origin = CoordOrigin.BOTTOMLEFT if origin_str == "BOTTOMLEFT" else CoordOrigin.TOPLEFT
|
||||
|
||||
if coord_origin == "BOTTOMLEFT":
|
||||
# In BOTTOMLEFT: top has higher y, bottom has lower y
|
||||
# In TOPLEFT: flip both — new_top = page_height - old_top
|
||||
new_top = page_height - top
|
||||
new_bottom = page_height - bottom
|
||||
top, bottom = new_top, new_bottom
|
||||
|
||||
return [left, top, right, bottom]
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from domain.models import AnalysisJob, AnalysisStatus
|
||||
from persistence.database import get_connection
|
||||
|
|
@ -29,7 +29,7 @@ def _row_to_job(row) -> AnalysisJob:
|
|||
error_message=row["error_message"],
|
||||
started_at=_parse_dt(row["started_at"]),
|
||||
completed_at=_parse_dt(row["completed_at"]),
|
||||
created_at=_parse_dt(row["created_at"]) or datetime.now(),
|
||||
created_at=_parse_dt(row["created_at"]) or datetime.now(UTC),
|
||||
document_filename=row["filename"] if "filename" in keys else None,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from domain.models import Document
|
||||
from persistence.database import get_connection
|
||||
|
|
@ -12,6 +12,8 @@ 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"],
|
||||
|
|
|
|||
|
|
@ -1,3 +1,2 @@
|
|||
-r requirements.txt
|
||||
docling>=2.80.0,<3.0.0
|
||||
docling-core>=2.0.0,<3.0.0
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
docling-core>=2.0.0,<3.0.0
|
||||
fastapi>=0.115.0,<1.0.0
|
||||
uvicorn[standard]>=0.32.0,<1.0.0
|
||||
python-multipart>=0.0.12
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ 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
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ class TestBuildFormData:
|
|||
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"}
|
||||
|
||||
|
|
@ -74,6 +75,7 @@ class TestParseResponse:
|
|||
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 = {
|
||||
|
|
@ -159,6 +161,7 @@ class TestParseResponse:
|
|||
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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,10 @@ function openFilePicker() {
|
|||
async function onFileSelect(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file) {
|
||||
if (
|
||||
file &&
|
||||
(file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'))
|
||||
) {
|
||||
try {
|
||||
store.clearError()
|
||||
const doc = await store.upload(file)
|
||||
|
|
|
|||
|
|
@ -233,7 +233,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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue