From fe83dcdf790c30d3baf70628ec5c9d80ad83e165 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Mon, 27 Apr 2026 17:22:19 +0200 Subject: [PATCH] feat(settings): paste-image size/type limits for #195 (#196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: rename Clean Architecture → Hexagonal Architecture (ports & adapters) Le backend suit le pattern ports & adapters (ports dans domain/ports.py, adaptateurs dans infra/), pas Clean Architecture au sens Uncle Bob. Aligne la terminologie dans README, docs/architecture.md, ADR guide, audit master, fiche audit 01, et la nav mkdocs. Les noms de fichiers et la commande /audit:clean-architecture restent stables pour preserver les liens croises et les skills existants. * feat(settings): add paste-image size/type limits surfaced via /api/health Introduces MAX_PASTE_IMAGE_SIZE_MB (default 10) and PASTE_ALLOWED_IMAGE_TYPES (default image/png,image/jpeg,image/webp) env vars so the upcoming Verify-mode clipboard-paste handler can validate client-side against the same limits the backend enforces. Follows the existing MAX_FILE_SIZE_MB pattern. Ships the accepted design doc at docs/design/195-copy-paste-image-verify-mode.md. Refs #195 --- README.md | 2 +- docs/architecture.md | 6 +- docs/architecture/adr-guide.md | 2 +- docs/audit/audits/01-clean-architecture.md | 4 +- docs/audit/master.md | 4 +- .../195-copy-paste-image-verify-mode.md | 404 ++++++++++++++++++ document-parser/api/schemas.py | 2 + document-parser/infra/settings.py | 15 + document-parser/main.py | 4 + document-parser/tests/test_settings.py | 14 + mkdocs.yml | 2 +- 11 files changed, 449 insertions(+), 10 deletions(-) create mode 100644 docs/design/195-copy-paste-image-verify-mode.md diff --git a/README.md b/README.md index 936dc10..3d6d6a7 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Upload a PDF, configure the extraction pipeline, and visualize the results — t | **frontend** | Vue 3, TypeScript, Vite, Pinia | UI, PDF viewer, results display | | **document-parser** | FastAPI, Docling, SQLite, pdf2image | REST API, document parsing, storage | -### Backend structure (clean architecture) +### Backend structure (hexagonal architecture — ports & adapters) ``` document-parser/ diff --git a/docs/architecture.md b/docs/architecture.md index 764795a..231bdf5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,7 +8,7 @@ Two services communicating via REST. The frontend is a Vue 3 SPA served by Nginx ### Zooming into the backend -The schema above shows the macro view. Inside the backend, the code follows a **Clean Architecture** with strict layer boundaries: +The schema above shows the macro view. Inside the backend, the code follows a **Hexagonal Architecture** (ports & adapters) with strict layer boundaries: ``` ┌──────────────────────────────────────────────────────┐ @@ -34,9 +34,9 @@ The schema above shows the macro view. Inside the backend, the code follows a ** Dependencies flow **inward**: `api → services → domain`. The domain layer has zero knowledge of HTTP or database. -## Backend — Clean Architecture +## Backend — Hexagonal Architecture (ports & adapters) -The backend follows a strict layered architecture. Dependencies flow inward: API → Services → Domain. The domain layer has zero knowledge of HTTP or database. +The backend follows the hexagonal / ports-and-adapters pattern. The domain layer defines **ports** (abstract protocols in `domain/ports.py`); `infra/` provides **adapters** that implement them. Dependencies flow inward: API → Services → Domain. The domain layer has zero knowledge of HTTP, database, or any framework. ``` document-parser/ diff --git a/docs/architecture/adr-guide.md b/docs/architecture/adr-guide.md index b428a86..7953eaf 100644 --- a/docs/architecture/adr-guide.md +++ b/docs/architecture/adr-guide.md @@ -34,7 +34,7 @@ These decisions were made before the ADR process was introduced. They are docume | Decision | Rationale | Date | |----------|-----------|------| -| Clean Architecture (hexagonal) for backend | Decouple domain from framework — enable converter swapping (local/remote) | 2025-01 | +| Hexagonal Architecture (ports & adapters) for backend | Decouple domain from framework — enable converter swapping (local/remote) via ports | 2025-01 | | FastAPI over Django | Async-first, lightweight, Pydantic-native — better fit for a single-purpose API | 2025-01 | | Vue 3 + Pinia over React | Composition API + built-in reactivity — smaller bundle for this use case | 2025-01 | | SQLite over PostgreSQL | Single-file DB, zero ops — appropriate for a document processing tool | 2025-01 | diff --git a/docs/audit/audits/01-clean-architecture.md b/docs/audit/audits/01-clean-architecture.md index 3e8538d..9bda091 100644 --- a/docs/audit/audits/01-clean-architecture.md +++ b/docs/audit/audits/01-clean-architecture.md @@ -1,6 +1,6 @@ -# Audit 01 — Clean Architecture +# Audit 01 — Hexagonal Architecture (ports & adapters) -**Objectif** : verifier que le backend respecte le flux de dependances strict `api -> services -> domain` et que chaque couche a une responsabilite claire. +**Objectif** : verifier que le backend respecte le pattern hexagonal (ports dans `domain/ports.py`, adaptateurs dans `infra/`), le flux de dependances strict `api -> services -> domain`, et que chaque couche a une responsabilite claire. **Cible** : `document-parser/` (hors `.venv/`, `__pycache__/`, `tests/`) diff --git a/docs/audit/master.md b/docs/audit/master.md index 38d5427..7209c9f 100644 --- a/docs/audit/master.md +++ b/docs/audit/master.md @@ -74,7 +74,7 @@ Les audits sont executes dans l'ordre ci-dessous. Chacun est une fiche autonome | # | Audit | Fichier | Focus | |---|-------|---------|-------| -| 01 | Clean Architecture | [01-clean-architecture.md](audits/01-clean-architecture.md) | Respect des couches, flux de dependances | +| 01 | Hexagonal Architecture | [01-clean-architecture.md](audits/01-clean-architecture.md) | Ports & adapters, respect des couches, flux de dependances | | 02 | DDD | [02-ddd.md](audits/02-ddd.md) | Bounded contexts, entites, value objects, ubiquitous language | | 03 | Clean Code | [03-clean-code.md](audits/03-clean-code.md) | Nommage, taille, lisibilite | | 04 | KISS | [04-kiss.md](audits/04-kiss.md) | Simplicite, pas de sur-ingenierie | @@ -161,7 +161,7 @@ Le fichier `reports/release-X.Y.Z/summary.md` consolide tous les audits : | # | Audit | Score | CRIT | MAJ | MIN | INFO | Verdict | |---|-------|-------|------|-----|-----|------|---------| -| 01 | Clean Architecture | XX | N | N | N | N | GO | +| 01 | Hexagonal Architecture | XX | N | N | N | N | GO | | 02 | DDD | XX | N | N | N | N | GO | | ... | ... | ... | ... | ... | ... | ... | ... | diff --git a/docs/design/195-copy-paste-image-verify-mode.md b/docs/design/195-copy-paste-image-verify-mode.md new file mode 100644 index 0000000..57e227e --- /dev/null +++ b/docs/design/195-copy-paste-image-verify-mode.md @@ -0,0 +1,404 @@ +# Design: Copy paste image in Verify mode + + + +- **Issue:** #195 +- **Title on issue:** [ENHANCEMENT] Copy paste image in Verify mode +- **Author:** Pier-Jean Malandrino +- **Date:** 2026-04-23 +- **Status:** Accepted +- **Target milestone:** 0.5.0 +- **Impacted layers:** · | shared | app> · · +- **Audit dimensions likely touched:** +- **ADR spawned?:** *(write an ADR when choosing a library, moving a boundary, or deciding **not** to do something — see `docs/architecture/adr-guide.md`)* + +--- + +## 1. Problem + + + +TODO: Why this issue exists. Link the user story, incident, or upstream discussion that motivates it. + +Today in Verify mode regarding image handling: TODO — describe the current baseline (upload-only? no paste target? no drag-drop?). + +## 2. Goals + + + +Users should be able to copy/paste images directly into Verify mode (e.g. from clipboard) instead of only via file upload. + +- [ ] Define paste source (OS clipboard, drag-drop, screenshot) +- [ ] Define target area in Verify mode UI +- [ ] Define supported image formats and size limits +- [ ] Size / type limits are env-var configurable, carry sane defaults, and are documented in `README.md` + `docs/deployment/*` (e.g. `MAX_PASTE_IMAGE_SIZE_MB`, `PASTE_ALLOWED_IMAGE_TYPES`) + +## 3. Non-goals + + + +- ... +- ... + +## 4. Context & constraints + + + +### SQLite & storage limits (must be enforced upstream of the DB) + +Pasted images follow the existing `documents` pattern: bytes land on disk +under `UPLOAD_DIR`, the row stores `storage_path: TEXT`. We do **not** +store base64 or BLOBs. Even so, app-level size guards must stay below +SQLite's structural limits so a malformed request can never wedge the +engine. + +Relevant SQLite defaults (see https://www.sqlite.org/limits.html): + +| SQLite limit | Default | Relevance | +|---|---|---| +| `SQLITE_MAX_LENGTH` | 1 GB (1 × 10⁹ bytes) | Max size of any single `TEXT` / `BLOB` cell. Irrelevant as long as we keep bytes off the DB. | +| `SQLITE_MAX_SQL_LENGTH` | 1 MB | Max length of an SQL statement incl. inlined literals. Always use parameter binding — never inline image bytes. | +| Page cache / WAL growth | n/a | Large writes bloat WAL until checkpoint; another reason to stay off-DB. | + +Our own app-level limits guard against ever reaching those ceilings. +All such limits **must**: (1) carry a sane default in `infra/settings.py`, +(2) be overridable via env var, (3) be documented in `README.md` and +`docs/deployment/*`. This is consistent with how `MAX_FILE_SIZE_MB` +(default 50) is handled today. + +## 5. Proposed design + + + +### 5.1 Domain + +### 5.2 Persistence + +### 5.3 Infra adapters + +Extend `document-parser/infra/settings.py` with paste-specific limits. +Follow the existing `MAX_FILE_SIZE_MB` pattern: typed field on the +settings dataclass, `os.environ.get(...)` with a string default, cast +at load time. + +| Setting | Env var | Default | Allowed | Notes | +|---|---|---|---|---| +| `max_paste_image_size_mb` | `MAX_PASTE_IMAGE_SIZE_MB` | `10` | positive int, `0` = unlimited | Must be ≤ `MAX_FILE_SIZE_MB`; upload validator rejects larger payloads before any DB write. | +| `paste_allowed_image_types` | `PASTE_ALLOWED_IMAGE_TYPES` | `image/png,image/jpeg,image/webp` | comma-separated MIME list | Enforced server-side; frontend uses the same list via `/api/health`. | + +Validation happens in the API layer (upload handler) **before** the +bytes reach persistence. Any future move to BLOB storage would still +rely on these guards — they are the contract that prevents us ever +approaching `SQLITE_MAX_LENGTH`. + +### 5.4 Services + +### 5.5 API + +### 5.6 Frontend — feature module + +### 5.7 Cross-cutting (feature flags, i18n, shared types) + +## 6. Alternatives considered + + + +### Alternative A — + +- **Summary:** +- **Why not:** + +### Alternative B — + +- **Summary:** +- **Why not:** + +## 7. API & data contract + + + +## 8. Risks & mitigations + + + +| Risk | Audit dimension | Likelihood | Impact | How we notice | Mitigation / rollback | +|------|-----------------|------------|--------|---------------|------------------------| +| | | | | | | + +## 9. Testing strategy + + + +## 10. Rollout & observability + + + +## 11. Open questions + + + +- ... +- ... + +## 12. References + + + +- **Issue:** https://github.com/scub-france/Docling-Studio/issues/195 +- **Related PRs / commits:** +- **ADRs:** +- **Project docs:** + - Architecture: `docs/architecture.md` + - Coding standards: `docs/architecture/coding-standards.md` + - ADR guide / template: `docs/architecture/adr-guide.md`, `docs/architecture/adr-template.md` + - Audit master: `docs/audit/master.md` + - E2E conventions: `e2e/CONVENTIONS.md` +- **External:** diff --git a/document-parser/api/schemas.py b/document-parser/api/schemas.py index 1ed8b4e..f66febf 100644 --- a/document-parser/api/schemas.py +++ b/document-parser/api/schemas.py @@ -34,6 +34,8 @@ class HealthResponse(_CamelModel): database: str max_page_count: int | None = None max_file_size_mb: int | None = None + max_paste_image_size_mb: int | None = None + paste_allowed_image_types: list[str] = Field(default_factory=list) ingestion_available: bool = False # True when the live-reasoning runner (docling-agent + Ollama) is # available: RAG_ENABLED=true AND deps importable. Doesn't imply Ollama diff --git a/document-parser/infra/settings.py b/document-parser/infra/settings.py index f1aeeb4..fb98a08 100644 --- a/document-parser/infra/settings.py +++ b/document-parser/infra/settings.py @@ -38,6 +38,10 @@ class Settings: embedding_dimension: int = 384 # Granite Embedding 30M / all-MiniLM-L6-v2 upload_dir: str = "./uploads" db_path: str = "./data/docling_studio.db" + max_paste_image_size_mb: int = 10 # clipboard-paste image limit in MB (0 = unlimited) + paste_allowed_image_types: list[str] = field( + default_factory=lambda: ["image/png", "image/jpeg", "image/webp"] + ) cors_origins: list[str] = field( default_factory=lambda: ["http://localhost:3000", "http://localhost:5173"] ) @@ -60,6 +64,12 @@ class Settings: errors.append(f"max_file_size must be >= 0 (got {self.max_file_size})") if self.max_file_size_mb < 0: errors.append(f"max_file_size_mb must be >= 0 (got {self.max_file_size_mb})") + if self.max_paste_image_size_mb < 0: + errors.append( + f"max_paste_image_size_mb must be >= 0 (got {self.max_paste_image_size_mb})" + ) + if not self.paste_allowed_image_types: + errors.append("paste_allowed_image_types must not be empty") if self.rate_limit_rpm < 0: errors.append(f"rate_limit_rpm must be >= 0 (got {self.rate_limit_rpm})") if self.batch_page_size < 0: @@ -93,6 +103,9 @@ class Settings: 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") + paste_types_raw = os.environ.get( + "PASTE_ALLOWED_IMAGE_TYPES", "image/png,image/jpeg,image/webp" + ) return cls( app_version=os.environ.get("APP_VERSION", "dev"), conversion_engine=os.environ.get("CONVERSION_ENGINE", "local"), @@ -126,6 +139,8 @@ class Settings: embedding_dimension=int(os.environ.get("EMBEDDING_DIMENSION", "384")), upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"), db_path=os.environ.get("DB_PATH", "./data/docling_studio.db"), + max_paste_image_size_mb=int(os.environ.get("MAX_PASTE_IMAGE_SIZE_MB", "10")), + paste_allowed_image_types=[t.strip() for t in paste_types_raw.split(",") if t.strip()], cors_origins=[o.strip() for o in cors_raw.split(",")], ) diff --git a/document-parser/main.py b/document-parser/main.py index 288fa5e..d156587 100644 --- a/document-parser/main.py +++ b/document-parser/main.py @@ -249,6 +249,10 @@ async def health() -> HealthResponse: database=db_status, max_page_count=settings.max_page_count if settings.max_page_count > 0 else None, max_file_size_mb=settings.max_file_size_mb if settings.max_file_size_mb > 0 else None, + max_paste_image_size_mb=( + settings.max_paste_image_size_mb if settings.max_paste_image_size_mb > 0 else None + ), + paste_allowed_image_types=settings.paste_allowed_image_types, ingestion_available=getattr(app.state, "ingestion_service", None) is not None, # True when the live-reasoning runner is wired (flag on + deps present). # The actual Ollama reachability is checked lazily at call-time to avoid diff --git a/document-parser/tests/test_settings.py b/document-parser/tests/test_settings.py index 4326790..9525c51 100644 --- a/document-parser/tests/test_settings.py +++ b/document-parser/tests/test_settings.py @@ -18,6 +18,8 @@ class TestSettingsDefaults: assert s.lock_timeout == 300 assert s.max_page_count == 0 assert s.max_file_size_mb == 50 + assert s.max_paste_image_size_mb == 10 + assert s.paste_allowed_image_types == ["image/png", "image/jpeg", "image/webp"] assert s.batch_page_size == 0 assert s.opensearch_default_limit == 1000 assert s.upload_dir == "./uploads" @@ -76,6 +78,18 @@ class TestSettingsValidation: with pytest.raises(ValueError, match="max_file_size_mb must be >= 0"): Settings(max_file_size_mb=-1) + def test_negative_max_paste_image_size_mb_rejected(self): + import pytest + + with pytest.raises(ValueError, match="max_paste_image_size_mb must be >= 0"): + Settings(max_paste_image_size_mb=-1) + + def test_empty_paste_allowed_image_types_rejected(self): + import pytest + + with pytest.raises(ValueError, match="paste_allowed_image_types must not be empty"): + Settings(paste_allowed_image_types=[]) + def test_zero_max_file_size_mb_accepted(self): s = Settings(max_file_size_mb=0) assert s.max_file_size_mb == 0 diff --git a/mkdocs.yml b/mkdocs.yml index 6b680c8..9804d4a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -57,7 +57,7 @@ nav: - Audit: - Audit Master: audit/master.md - Audits: - - Clean Architecture: audit/audits/01-clean-architecture.md + - Hexagonal Architecture: audit/audits/01-clean-architecture.md - DDD: audit/audits/02-ddd.md - Clean Code: audit/audits/03-clean-code.md - KISS: audit/audits/04-kiss.md