diff --git a/document-parser/api/documents.py b/document-parser/api/documents.py
index 2e94c0b..32e2dc3 100644
--- a/document-parser/api/documents.py
+++ b/document-parser/api/documents.py
@@ -87,6 +87,6 @@ async def preview(
return Response(content=png_bytes, media_type="image/png")
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
- except Exception as e:
+ except Exception:
logger.exception("Failed to generate preview")
- raise HTTPException(status_code=422, detail=f"Failed to generate preview: {e}")
+ raise HTTPException(status_code=422, detail="Failed to generate preview")
diff --git a/document-parser/api/schemas.py b/document-parser/api/schemas.py
index 2030bfa..4e30153 100644
--- a/document-parser/api/schemas.py
+++ b/document-parser/api/schemas.py
@@ -8,7 +8,7 @@ from __future__ import annotations
from datetime import datetime
-from pydantic import BaseModel, ConfigDict
+from pydantic import BaseModel, ConfigDict, field_validator
def _to_camel(name: str) -> str:
@@ -61,6 +61,20 @@ class PipelineOptionsRequest(BaseModel):
generate_page_images: bool = False
images_scale: float = 1.0
+ @field_validator("table_mode")
+ @classmethod
+ def validate_table_mode(cls, v: str) -> str:
+ if v not in ("accurate", "fast"):
+ raise ValueError('table_mode must be "accurate" or "fast"')
+ return v
+
+ @field_validator("images_scale")
+ @classmethod
+ def validate_images_scale(cls, v: float) -> float:
+ if v <= 0 or v > 10:
+ raise ValueError("images_scale must be between 0 (exclusive) and 10")
+ return v
+
class CreateAnalysisRequest(BaseModel):
documentId: str # camelCase to match existing frontend contract
diff --git a/document-parser/data/docling_studio.db b/document-parser/data/docling_studio.db
index 3b36468..e08b47d 100644
Binary files a/document-parser/data/docling_studio.db and b/document-parser/data/docling_studio.db differ
diff --git a/document-parser/domain/parsing.py b/document-parser/domain/parsing.py
index 8177cc2..30366e3 100644
--- a/document-parser/domain/parsing.py
+++ b/document-parser/domain/parsing.py
@@ -255,23 +255,23 @@ def convert_document(
and images_scale == 1.0
)
- if is_default:
- conv = get_default_converter()
- else:
- conv = build_converter(
- do_ocr=do_ocr,
- do_table_structure=do_table_structure,
- table_mode=table_mode,
- do_code_enrichment=do_code_enrichment,
- do_formula_enrichment=do_formula_enrichment,
- do_picture_classification=do_picture_classification,
- do_picture_description=do_picture_description,
- generate_picture_images=generate_picture_images,
- generate_page_images=generate_page_images,
- images_scale=images_scale,
- )
-
with _converter_lock:
+ if is_default:
+ conv = get_default_converter()
+ else:
+ conv = build_converter(
+ do_ocr=do_ocr,
+ do_table_structure=do_table_structure,
+ table_mode=table_mode,
+ do_code_enrichment=do_code_enrichment,
+ do_formula_enrichment=do_formula_enrichment,
+ do_picture_classification=do_picture_classification,
+ do_picture_description=do_picture_description,
+ generate_picture_images=generate_picture_images,
+ generate_page_images=generate_page_images,
+ images_scale=images_scale,
+ )
+
result = conv.convert(file_path)
doc = result.document
diff --git a/document-parser/persistence/analysis_repo.py b/document-parser/persistence/analysis_repo.py
index fa3202e..2277744 100644
--- a/document-parser/persistence/analysis_repo.py
+++ b/document-parser/persistence/analysis_repo.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from domain.models import AnalysisJob, AnalysisStatus
-from persistence.database import get_db
+from persistence.database import get_connection
def _row_to_job(row) -> AnalysisJob:
@@ -30,45 +30,36 @@ _SELECT_WITH_DOC = """
async def insert(job: AnalysisJob) -> None:
- db = await get_db()
- try:
+ async with get_connection() as db:
await db.execute(
"""INSERT INTO analysis_jobs (id, document_id, status, created_at)
VALUES (?, ?, ?, ?)""",
(job.id, job.document_id, job.status.value, str(job.created_at)),
)
await db.commit()
- finally:
- await db.close()
-async def find_all() -> list[AnalysisJob]:
- db = await get_db()
- try:
+async def find_all(*, limit: int = 200, offset: int = 0) -> list[AnalysisJob]:
+ async with get_connection() as db:
cursor = await db.execute(
- f"{_SELECT_WITH_DOC} ORDER BY aj.created_at DESC"
+ f"{_SELECT_WITH_DOC} ORDER BY aj.created_at DESC LIMIT ? OFFSET ?",
+ (limit, offset),
)
rows = await cursor.fetchall()
return [_row_to_job(r) for r in rows]
- finally:
- await db.close()
async def find_by_id(job_id: str) -> AnalysisJob | None:
- db = await get_db()
- try:
+ async with get_connection() as db:
cursor = await db.execute(
f"{_SELECT_WITH_DOC} WHERE aj.id = ?", (job_id,)
)
row = await cursor.fetchone()
return _row_to_job(row) if row else None
- finally:
- await db.close()
async def update_status(job: AnalysisJob) -> None:
- db = await get_db()
- try:
+ async with get_connection() as db:
await db.execute(
"""UPDATE analysis_jobs
SET status = ?, content_markdown = ?, content_html = ?,
@@ -81,28 +72,20 @@ async def update_status(job: AnalysisJob) -> None:
job.id),
)
await db.commit()
- finally:
- await db.close()
async def delete(job_id: str) -> bool:
- db = await get_db()
- try:
+ async with get_connection() as db:
cursor = await db.execute("DELETE FROM analysis_jobs WHERE id = ?", (job_id,))
await db.commit()
return cursor.rowcount > 0
- finally:
- await db.close()
async def delete_by_document(document_id: str) -> int:
"""Delete all analysis jobs for a given document. Returns count deleted."""
- db = await get_db()
- try:
+ async with get_connection() as db:
cursor = await db.execute(
"DELETE FROM analysis_jobs WHERE document_id = ?", (document_id,)
)
await db.commit()
return cursor.rowcount
- finally:
- await db.close()
diff --git a/document-parser/persistence/database.py b/document-parser/persistence/database.py
index fa7f154..dc8d95d 100644
--- a/document-parser/persistence/database.py
+++ b/document-parser/persistence/database.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
import os
+from contextlib import asynccontextmanager
import aiosqlite
@@ -24,7 +25,7 @@ CREATE TABLE IF NOT EXISTS documents (
CREATE TABLE IF NOT EXISTS analysis_jobs (
id TEXT PRIMARY KEY,
- document_id TEXT NOT NULL REFERENCES documents(id),
+ document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'PENDING',
content_markdown TEXT,
content_html TEXT,
@@ -52,3 +53,13 @@ async def get_db() -> aiosqlite.Connection:
db.row_factory = aiosqlite.Row
await db.execute("PRAGMA foreign_keys = ON")
return db
+
+
+@asynccontextmanager
+async def get_connection():
+ """Context manager that opens and auto-closes a database connection."""
+ db = await get_db()
+ try:
+ yield db
+ finally:
+ await db.close()
diff --git a/document-parser/persistence/document_repo.py b/document-parser/persistence/document_repo.py
index 94a91f3..3649dc1 100644
--- a/document-parser/persistence/document_repo.py
+++ b/document-parser/persistence/document_repo.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from domain.models import Document
-from persistence.database import get_db
+from persistence.database import get_connection
def _row_to_document(row) -> Document:
@@ -19,8 +19,7 @@ def _row_to_document(row) -> Document:
async def insert(doc: Document) -> None:
- db = await get_db()
- try:
+ async with get_connection() as db:
await db.execute(
"""INSERT INTO documents (id, filename, content_type, file_size, page_count, storage_path, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
@@ -28,49 +27,36 @@ async def insert(doc: Document) -> None:
doc.page_count, doc.storage_path, str(doc.created_at)),
)
await db.commit()
- finally:
- await db.close()
-async def find_all() -> list[Document]:
- db = await get_db()
- try:
+async def find_all(*, limit: int = 200, offset: int = 0) -> list[Document]:
+ async with get_connection() as db:
cursor = await db.execute(
- "SELECT * FROM documents ORDER BY created_at DESC"
+ "SELECT * FROM documents ORDER BY created_at DESC LIMIT ? OFFSET ?",
+ (limit, offset),
)
rows = await cursor.fetchall()
return [_row_to_document(r) for r in rows]
- finally:
- await db.close()
async def find_by_id(doc_id: str) -> Document | None:
- db = await get_db()
- try:
+ async with get_connection() as db:
cursor = await db.execute("SELECT * FROM documents WHERE id = ?", (doc_id,))
row = await cursor.fetchone()
return _row_to_document(row) if row else None
- finally:
- await db.close()
async def update_page_count(doc_id: str, page_count: int) -> None:
- db = await get_db()
- try:
+ async with get_connection() as db:
await db.execute(
"UPDATE documents SET page_count = ? WHERE id = ?",
(page_count, doc_id),
)
await db.commit()
- finally:
- await db.close()
async def delete(doc_id: str) -> bool:
- db = await get_db()
- try:
+ async with get_connection() as db:
cursor = await db.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
await db.commit()
return cursor.rowcount > 0
- finally:
- await db.close()
diff --git a/document-parser/services/analysis_service.py b/document-parser/services/analysis_service.py
index 4f2612f..f061980 100644
--- a/document-parser/services/analysis_service.py
+++ b/document-parser/services/analysis_service.py
@@ -13,6 +13,9 @@ from persistence import analysis_repo, document_repo
logger = logging.getLogger(__name__)
+# Maximum time (seconds) allowed for a single document conversion.
+CONVERSION_TIMEOUT = int(__import__("os").environ.get("CONVERSION_TIMEOUT", "600"))
+
async def create(document_id: str, *, pipeline_options: dict | None = None) -> AnalysisJob:
"""Create a new analysis job and launch background processing."""
@@ -24,12 +27,25 @@ async def create(document_id: str, *, pipeline_options: dict | None = None) -> A
job.document_filename = doc.filename
await analysis_repo.insert(job)
- # Fire-and-forget background task
- asyncio.create_task(_run_analysis(job.id, doc.storage_path, doc.filename, pipeline_options))
+ # Fire background task with error logging callback
+ task = asyncio.create_task(
+ _run_analysis(job.id, doc.storage_path, doc.filename, pipeline_options)
+ )
+ task.add_done_callback(_on_task_done)
return job
+def _on_task_done(task: asyncio.Task) -> None:
+ """Log unhandled exceptions from background analysis tasks."""
+ if task.cancelled():
+ logger.warning("Analysis task was cancelled")
+ return
+ exc = task.exception()
+ if exc:
+ logger.error("Unhandled exception in analysis task: %s", exc, exc_info=exc)
+
+
async def find_all() -> list[AnalysisJob]:
return await analysis_repo.find_all()
@@ -46,22 +62,23 @@ async def _run_analysis(
job_id: str, file_path: str, filename: str, pipeline_options: dict | None = None,
) -> None:
"""Background task: run Docling conversion and update job status."""
- job = await analysis_repo.find_by_id(job_id)
- if not job:
- logger.error("Analysis job %s not found", job_id)
- return
-
- job.mark_running()
- await analysis_repo.update_status(job)
- logger.info("Analysis started: %s (file: %s)", job_id, filename)
-
try:
+ job = await analysis_repo.find_by_id(job_id)
+ if not job:
+ logger.error("Analysis job %s not found", job_id)
+ return
+
+ job.mark_running()
+ await analysis_repo.update_status(job)
+ logger.info("Analysis started: %s (file: %s)", job_id, filename)
+
# Build kwargs from pipeline options
convert_kwargs = pipeline_options or {}
- # Run blocking Docling conversion in a thread
- result: ConversionResult = await asyncio.to_thread(
- convert_document, file_path, **convert_kwargs,
+ # Run blocking Docling conversion in a thread with timeout
+ result: ConversionResult = await asyncio.wait_for(
+ asyncio.to_thread(convert_document, file_path, **convert_kwargs),
+ timeout=CONVERSION_TIMEOUT,
)
pages_json = json.dumps([asdict(p) for p in result.pages])
@@ -79,7 +96,21 @@ async def _run_analysis(
logger.info("Analysis completed: %s (%d pages)", job_id, result.page_count)
+ except asyncio.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")
+
except Exception as e:
logger.exception("Analysis failed: %s", job_id)
- job.mark_failed(str(e))
- await analysis_repo.update_status(job)
+ await _mark_failed(job_id, str(e))
+
+
+async def _mark_failed(job_id: str, error: str) -> None:
+ """Safely mark a job as failed, handling DB errors gracefully."""
+ try:
+ job = await analysis_repo.find_by_id(job_id)
+ if job:
+ job.mark_failed(error)
+ await analysis_repo.update_status(job)
+ except Exception:
+ logger.exception("Could not mark job %s as failed", job_id)
diff --git a/document-parser/services/document_service.py b/document-parser/services/document_service.py
index d1bdeba..dd3b6c4 100644
--- a/document-parser/services/document_service.py
+++ b/document-parser/services/document_service.py
@@ -18,11 +18,18 @@ UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "./uploads")
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
+# PDF magic bytes: %PDF
+_PDF_MAGIC = b"%PDF"
+
+
async def upload(filename: str, content_type: str, file_content: bytes) -> Document:
"""Save uploaded file to disk and persist metadata."""
if len(file_content) > MAX_FILE_SIZE:
raise ValueError("File too large (max 50 MB)")
+ if not file_content[:4].startswith(_PDF_MAGIC):
+ raise ValueError("Invalid file: not a PDF document")
+
os.makedirs(UPLOAD_DIR, exist_ok=True)
safe_name = f"{uuid.uuid4()}_{filename}"
diff --git a/document-parser/tests/test_schemas.py b/document-parser/tests/test_schemas.py
index 1a79c6a..b59c170 100644
--- a/document-parser/tests/test_schemas.py
+++ b/document-parser/tests/test_schemas.py
@@ -1,7 +1,9 @@
-"""Tests for API schemas — camelCase serialization."""
+"""Tests for API schemas — camelCase serialization and validation."""
from datetime import datetime
+import pytest
+
from api.schemas import (
AnalysisResponse,
CreateAnalysisRequest,
@@ -109,6 +111,28 @@ class TestPipelineOptionsRequest:
assert data["do_ocr"] is False
assert data["do_table_structure"] is True # default preserved
+ def test_invalid_table_mode_rejected(self):
+ with pytest.raises(ValueError, match='table_mode must be "accurate" or "fast"'):
+ PipelineOptionsRequest(table_mode="invalid")
+
+ def test_negative_images_scale_rejected(self):
+ with pytest.raises(ValueError, match="images_scale must be between"):
+ PipelineOptionsRequest(images_scale=-1.0)
+
+ def test_zero_images_scale_rejected(self):
+ with pytest.raises(ValueError, match="images_scale must be between"):
+ PipelineOptionsRequest(images_scale=0)
+
+ def test_excessive_images_scale_rejected(self):
+ with pytest.raises(ValueError, match="images_scale must be between"):
+ PipelineOptionsRequest(images_scale=11.0)
+
+ def test_boundary_images_scale_accepted(self):
+ opts = PipelineOptionsRequest(images_scale=0.1)
+ assert opts.images_scale == 0.1
+ opts2 = PipelineOptionsRequest(images_scale=10.0)
+ assert opts2.images_scale == 10.0
+
class TestCreateAnalysisRequest:
def test_parses_document_id(self):
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 79887c8..1fd0de5 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,6 +8,7 @@
"name": "docling-studio",
"version": "0.1.0",
"dependencies": {
+ "dompurify": "^3.3.3",
"marked": "^17.0.4",
"pinia": "^2.3.0",
"vue": "^3.4.0",
@@ -765,6 +766,12 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "optional": true
+ },
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@@ -1064,6 +1071,14 @@
"node": ">=6"
}
},
+ "node_modules/dompurify": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
+ "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index e297ee5..6440716 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -11,10 +11,11 @@
"test:run": "vitest run"
},
"dependencies": {
- "vue": "^3.4.0",
- "vue-router": "^4.6.4",
+ "dompurify": "^3.3.3",
+ "marked": "^17.0.4",
"pinia": "^2.3.0",
- "marked": "^17.0.4"
+ "vue": "^3.4.0",
+ "vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
diff --git a/frontend/src/app/router/index.js b/frontend/src/app/router/index.js
index 610e825..c7140e2 100644
--- a/frontend/src/app/router/index.js
+++ b/frontend/src/app/router/index.js
@@ -15,6 +15,11 @@ const routes = [
path: '/settings',
name: 'settings',
component: () => import('../../pages/SettingsPage.vue')
+ },
+ {
+ path: '/:pathMatch(.*)*',
+ name: 'not-found',
+ redirect: '/'
}
]
diff --git a/frontend/src/features/analysis/ui/ImageGallery.vue b/frontend/src/features/analysis/ui/ImageGallery.vue
index 9e57560..d82c55d 100644
--- a/frontend/src/features/analysis/ui/ImageGallery.vue
+++ b/frontend/src/features/analysis/ui/ImageGallery.vue
@@ -38,7 +38,7 @@ const props = defineProps({
const images = computed(() => {
const result = []
for (const page of props.pages) {
- for (const el of page.elements) {
+ for (const el of (page.elements || [])) {
if (el.type === 'picture') {
result.push({ ...el, page: page.page_number })
}
@@ -86,7 +86,7 @@ const images = computed(() => {
.card-type {
font-size: 12px;
font-weight: 600;
- color: #22C55E;
+ color: var(--success);
text-transform: uppercase;
}
diff --git a/frontend/src/features/analysis/ui/MarkdownViewer.vue b/frontend/src/features/analysis/ui/MarkdownViewer.vue
index e3301cf..83c7647 100644
--- a/frontend/src/features/analysis/ui/MarkdownViewer.vue
+++ b/frontend/src/features/analysis/ui/MarkdownViewer.vue
@@ -5,6 +5,7 @@
diff --git a/frontend/src/features/analysis/ui/ResultTabs.vue b/frontend/src/features/analysis/ui/ResultTabs.vue
index 13bf849..3a0ed8f 100644
--- a/frontend/src/features/analysis/ui/ResultTabs.vue
+++ b/frontend/src/features/analysis/ui/ResultTabs.vue
@@ -77,7 +77,7 @@ const pageMarkdown = computed(() => {
const page = currentPageData.value
if (!page) return ''
- return page.elements
+ return (page.elements || [])
.map(el => formatElement(el))
.filter(Boolean)
.join('\n\n')
diff --git a/frontend/src/features/document/ui/DocumentList.vue b/frontend/src/features/document/ui/DocumentList.vue
index 12f526b..28b2c56 100644
--- a/frontend/src/features/document/ui/DocumentList.vue
+++ b/frontend/src/features/document/ui/DocumentList.vue
@@ -25,13 +25,8 @@