Refacto and add cp paste btn

This commit is contained in:
pjmalandrino 2026-03-21 15:03:10 +01:00
parent 7bb4f8e298
commit 2db251182f
23 changed files with 262 additions and 283 deletions

View file

@ -41,13 +41,16 @@ COPY --from=frontend-build /build/dist /usr/share/nginx/html
# Nginx config # Nginx config
COPY nginx.conf /etc/nginx/sites-enabled/default COPY nginx.conf /etc/nginx/sites-enabled/default
# Non-root user
RUN useradd --create-home --shell /bin/bash appuser
# Data directories # Data directories
RUN mkdir -p /app/uploads /app/data RUN mkdir -p /app/uploads /app/data && chown -R appuser:appuser /app
ENV UPLOAD_DIR=/app/uploads ENV UPLOAD_DIR=/app/uploads
ENV DB_PATH=/app/data/docling_studio.db ENV DB_PATH=/app/data/docling_studio.db
EXPOSE 3000 EXPOSE 3000
# Start both nginx and uvicorn # nginx needs to run as root for port binding, then drops to appuser for uvicorn
CMD ["sh", "-c", "nginx && exec uvicorn main:app --host 127.0.0.1 --port 8000"] CMD ["sh", "-c", "nginx && exec su appuser -c 'uvicorn main:app --host 127.0.0.1 --port 8000'"]

View file

@ -2,8 +2,8 @@ services:
document-parser: document-parser:
build: build:
context: ./document-parser context: ./document-parser
ports: expose:
- "8000:8000" - "8000"
volumes: volumes:
- uploads_data:/app/uploads - uploads_data:/app/uploads
- db_data:/app/data - db_data:/app/data

View file

@ -13,11 +13,14 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
RUN mkdir -p /app/uploads /app/data RUN useradd --create-home --shell /bin/bash appuser \
&& mkdir -p /app/uploads /app/data \
&& chown -R appuser:appuser /app
EXPOSE 8000 EXPOSE 8000
ENV UPLOAD_DIR=/app/uploads ENV UPLOAD_DIR=/app/uploads
ENV DB_PATH=/app/data/docling_studio.db ENV DB_PATH=/app/data/docling_studio.db
USER appuser
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -31,6 +31,10 @@ async def upload(file: UploadFile):
if not file.filename: if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided") raise HTTPException(status_code=400, detail="No filename provided")
# Reject early if Content-Length exceeds limit (before reading body)
if file.size and file.size > document_service.MAX_FILE_SIZE:
raise HTTPException(status_code=413, detail="File too large (max 50 MB)")
content = await file.read() content = await file.read()
try: try:

View file

@ -38,6 +38,10 @@ logger = logging.getLogger(__name__)
# Thread lock — DocumentConverter is not thread-safe # Thread lock — DocumentConverter is not thread-safe
_converter_lock = threading.Lock() _converter_lock = threading.Lock()
# US Letter page dimensions (points) — fallback when page size is unknown
_DEFAULT_PAGE_WIDTH = 612.0
_DEFAULT_PAGE_HEIGHT = 792.0
# Default converter (lazy-init on first request) # Default converter (lazy-init on first request)
_default_converter: DocumentConverter | None = None _default_converter: DocumentConverter | None = None
@ -62,6 +66,23 @@ class PageDetail:
elements: list[PageElement] = field(default_factory=list) elements: list[PageElement] = field(default_factory=list)
@dataclass
class ConversionOptions:
do_ocr: bool = True
do_table_structure: bool = True
table_mode: str = "accurate"
do_code_enrichment: bool = False
do_formula_enrichment: bool = False
do_picture_classification: bool = False
do_picture_description: bool = False
generate_picture_images: bool = False
generate_page_images: bool = False
images_scale: float = 1.0
def is_default(self) -> bool:
return self == ConversionOptions()
@dataclass @dataclass
class ConversionResult: class ConversionResult:
page_count: int page_count: int
@ -102,35 +123,26 @@ def _get_element_type(item: DocItem) -> str:
# Pipeline factory # Pipeline factory
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def build_converter( def build_converter(options: ConversionOptions | None = None) -> DocumentConverter:
do_ocr: bool = True,
do_table_structure: bool = True,
table_mode: str = "accurate",
do_code_enrichment: bool = False,
do_formula_enrichment: bool = False,
do_picture_classification: bool = False,
do_picture_description: bool = False,
generate_picture_images: bool = False,
generate_page_images: bool = False,
images_scale: float = 1.0,
) -> DocumentConverter:
"""Build a DocumentConverter with the given pipeline options.""" """Build a DocumentConverter with the given pipeline options."""
opts = options or ConversionOptions()
table_options = TableStructureOptions( table_options = TableStructureOptions(
do_cell_matching=True, do_cell_matching=True,
mode=TableFormerMode.ACCURATE if table_mode == "accurate" else TableFormerMode.FAST, mode=TableFormerMode.ACCURATE if opts.table_mode == "accurate" else TableFormerMode.FAST,
) )
pipeline_options = PdfPipelineOptions( pipeline_options = PdfPipelineOptions(
do_ocr=do_ocr, do_ocr=opts.do_ocr,
do_table_structure=do_table_structure, do_table_structure=opts.do_table_structure,
table_structure_options=table_options, table_structure_options=table_options,
do_code_enrichment=do_code_enrichment, do_code_enrichment=opts.do_code_enrichment,
do_formula_enrichment=do_formula_enrichment, do_formula_enrichment=opts.do_formula_enrichment,
do_picture_classification=do_picture_classification, do_picture_classification=opts.do_picture_classification,
do_picture_description=do_picture_description, do_picture_description=opts.do_picture_description,
generate_page_images=generate_page_images, generate_page_images=opts.generate_page_images,
generate_picture_images=generate_picture_images, generate_picture_images=opts.generate_picture_images,
images_scale=images_scale, images_scale=opts.images_scale,
) )
return DocumentConverter( return DocumentConverter(
@ -191,7 +203,7 @@ def _process_content_item(
try: try:
page_no = prov.page_no page_no = prov.page_no
if page_no not in pages: if page_no not in pages:
pages[page_no] = PageDetail(page_number=page_no, width=612.0, height=792.0) pages[page_no] = PageDetail(page_number=page_no, width=_DEFAULT_PAGE_WIDTH, height=_DEFAULT_PAGE_HEIGHT)
page_height = pages[page_no].height page_height = pages[page_no].height
@ -205,13 +217,13 @@ def _process_content_item(
if isinstance(item, TableItem): if isinstance(item, TableItem):
try: try:
content = item.export_to_markdown() content = item.export_to_markdown()
except Exception: except (AttributeError, ValueError):
pass pass
pages[page_no].elements.append( pages[page_no].elements.append(
PageElement(type=element_type, bbox=bbox, content=content, level=level) PageElement(type=element_type, bbox=bbox, content=content, level=level)
) )
except Exception: except (AttributeError, KeyError, TypeError, ValueError):
logger.warning( logger.warning(
"Skipping item %s on page %s", "Skipping item %s on page %s",
type(item).__name__, type(item).__name__,
@ -227,77 +239,54 @@ def _process_content_item(
# Main conversion entry point # Main conversion entry point
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _select_converter(options: ConversionOptions) -> DocumentConverter:
"""Return the cached default converter or build a custom one."""
if options.is_default():
return get_default_converter()
return build_converter(options)
def _build_fallback_pages(doc, page_count: int) -> list[PageDetail]:
"""Create empty PageDetail entries when extraction yields nothing."""
return [
PageDetail(
page_number=i + 1,
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else _DEFAULT_PAGE_WIDTH,
height=doc.pages[i + 1].size.height if (i + 1) in doc.pages else _DEFAULT_PAGE_HEIGHT,
)
for i in range(page_count)
]
def convert_document( def convert_document(
file_path: str, file_path: str,
*, options: ConversionOptions | None = None,
do_ocr: bool = True,
do_table_structure: bool = True,
table_mode: str = "accurate",
do_code_enrichment: bool = False,
do_formula_enrichment: bool = False,
do_picture_classification: bool = False,
do_picture_description: bool = False,
generate_picture_images: bool = False,
generate_page_images: bool = False,
images_scale: float = 1.0,
) -> ConversionResult: ) -> ConversionResult:
"""Convert a document and return structured results. """Convert a document and return structured results.
This is the main entry point for document parsing. Runs synchronously This is the main entry point for document parsing. Runs synchronously
(caller should use asyncio.to_thread for non-blocking execution). (caller should use asyncio.to_thread for non-blocking execution).
""" """
# Use cached default converter only when all options match defaults opts = options or ConversionOptions()
is_default = (
do_ocr and do_table_structure and table_mode == "accurate"
and not do_code_enrichment and not do_formula_enrichment
and not do_picture_classification and not do_picture_description
and not generate_picture_images and not generate_page_images
and images_scale == 1.0
)
with _converter_lock: with _converter_lock:
if is_default: conv = _select_converter(opts)
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) result = conv.convert(file_path)
doc = result.document doc = result.document
content_markdown = doc.export_to_markdown()
content_html = doc.export_to_html()
page_count = len(doc.pages) page_count = len(doc.pages)
pages_detail, skipped = extract_pages_detail(result) pages_detail, skipped = extract_pages_detail(result)
if not pages_detail and page_count > 0: if not pages_detail and page_count > 0:
pages_detail = [ pages_detail = _build_fallback_pages(doc, page_count)
PageDetail(
page_number=i + 1,
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else 612.0,
height=doc.pages[i + 1].size.height if (i + 1) in doc.pages else 792.0,
)
for i in range(page_count)
]
if skipped > 0: if skipped > 0:
logger.info("Parsed: %d pages, %d items skipped", page_count, skipped) logger.info("Parsed: %d pages, %d items skipped", page_count, skipped)
return ConversionResult( return ConversionResult(
page_count=page_count or len(pages_detail) or 1, page_count=page_count or len(pages_detail) or 1,
content_markdown=content_markdown, content_markdown=doc.export_to_markdown(),
content_html=content_html, content_html=doc.export_to_html(),
pages=pages_detail, pages=pages_detail,
skipped_items=skipped, skipped_items=skipped,
) )

View file

@ -48,8 +48,8 @@ app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[o.strip() for o in allowed_origins], allow_origins=[o.strip() for o in allowed_origins],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_headers=["*"], allow_headers=["Content-Type", "Authorization"],
) )
# Mount routers # Mount routers

View file

@ -35,6 +35,10 @@ CREATE TABLE IF NOT EXISTS analysis_jobs (
completed_at TEXT, completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE INDEX IF NOT EXISTS idx_analysis_jobs_status ON analysis_jobs(status);
CREATE INDEX IF NOT EXISTS idx_analysis_jobs_created_at ON analysis_jobs(created_at);
CREATE INDEX IF NOT EXISTS idx_documents_created_at ON documents(created_at);
""" """

View file

@ -8,7 +8,7 @@ import logging
from dataclasses import asdict from dataclasses import asdict
from domain.models import AnalysisJob from domain.models import AnalysisJob
from domain.parsing import ConversionResult, convert_document from domain.parsing import ConversionOptions, ConversionResult, convert_document
from persistence import analysis_repo, document_repo from persistence import analysis_repo, document_repo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -43,7 +43,7 @@ def _on_task_done(task: asyncio.Task) -> None:
return return
exc = task.exception() exc = task.exception()
if exc: if exc:
logger.error("Unhandled exception in analysis task: %s", exc, exc_info=exc) logger.error("Unhandled exception in analysis task: %s", exc, exc_info=True)
async def find_all() -> list[AnalysisJob]: async def find_all() -> list[AnalysisJob]:
@ -72,12 +72,12 @@ async def _run_analysis(
await analysis_repo.update_status(job) await analysis_repo.update_status(job)
logger.info("Analysis started: %s (file: %s)", job_id, filename) logger.info("Analysis started: %s (file: %s)", job_id, filename)
# Build kwargs from pipeline options # Build conversion options from pipeline dict
convert_kwargs = pipeline_options or {} options = ConversionOptions(**(pipeline_options or {}))
# Run blocking Docling conversion in a thread with timeout # Run blocking Docling conversion in a thread with timeout
result: ConversionResult = await asyncio.wait_for( result: ConversionResult = await asyncio.wait_for(
asyncio.to_thread(convert_document, file_path, **convert_kwargs), asyncio.to_thread(convert_document, file_path, options),
timeout=CONVERSION_TIMEOUT, timeout=CONVERSION_TIMEOUT,
) )

View file

@ -32,7 +32,8 @@ async def upload(filename: str, content_type: str, file_content: bytes) -> Docum
os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(UPLOAD_DIR, exist_ok=True)
safe_name = f"{uuid.uuid4()}_{filename}" ext = os.path.splitext(filename)[1] or ".pdf"
safe_name = f"{uuid.uuid4()}{ext}"
file_path = os.path.join(UPLOAD_DIR, safe_name) file_path = os.path.join(UPLOAD_DIR, safe_name)
with open(file_path, "wb") as f: with open(file_path, "wb") as f:

View file

@ -12,7 +12,7 @@ from docling.datamodel.pipeline_options import (
TableFormerMode, TableFormerMode,
) )
from domain.parsing import build_converter, convert_document, get_default_converter from domain.parsing import ConversionOptions, build_converter, convert_document, get_default_converter
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -42,62 +42,62 @@ class TestBuildConverter:
assert opts.images_scale == 1.0 assert opts.images_scale == 1.0
def test_ocr_disabled(self): def test_ocr_disabled(self):
conv = build_converter(do_ocr=False) conv = build_converter(ConversionOptions(do_ocr=False))
opts = self._get_pipeline_options(conv) opts = self._get_pipeline_options(conv)
assert opts.do_ocr is False assert opts.do_ocr is False
def test_table_mode_fast(self): def test_table_mode_fast(self):
conv = build_converter(table_mode="fast") conv = build_converter(ConversionOptions(table_mode="fast"))
opts = self._get_pipeline_options(conv) opts = self._get_pipeline_options(conv)
assert opts.table_structure_options.mode == TableFormerMode.FAST assert opts.table_structure_options.mode == TableFormerMode.FAST
def test_table_mode_accurate(self): def test_table_mode_accurate(self):
conv = build_converter(table_mode="accurate") conv = build_converter(ConversionOptions(table_mode="accurate"))
opts = self._get_pipeline_options(conv) opts = self._get_pipeline_options(conv)
assert opts.table_structure_options.mode == TableFormerMode.ACCURATE assert opts.table_structure_options.mode == TableFormerMode.ACCURATE
def test_table_structure_disabled(self): def test_table_structure_disabled(self):
conv = build_converter(do_table_structure=False) conv = build_converter(ConversionOptions(do_table_structure=False))
opts = self._get_pipeline_options(conv) opts = self._get_pipeline_options(conv)
assert opts.do_table_structure is False assert opts.do_table_structure is False
def test_code_enrichment_enabled(self): def test_code_enrichment_enabled(self):
conv = build_converter(do_code_enrichment=True) conv = build_converter(ConversionOptions(do_code_enrichment=True))
opts = self._get_pipeline_options(conv) opts = self._get_pipeline_options(conv)
assert opts.do_code_enrichment is True assert opts.do_code_enrichment is True
def test_formula_enrichment_enabled(self): def test_formula_enrichment_enabled(self):
conv = build_converter(do_formula_enrichment=True) conv = build_converter(ConversionOptions(do_formula_enrichment=True))
opts = self._get_pipeline_options(conv) opts = self._get_pipeline_options(conv)
assert opts.do_formula_enrichment is True assert opts.do_formula_enrichment is True
def test_picture_classification_enabled(self): def test_picture_classification_enabled(self):
conv = build_converter(do_picture_classification=True) conv = build_converter(ConversionOptions(do_picture_classification=True))
opts = self._get_pipeline_options(conv) opts = self._get_pipeline_options(conv)
assert opts.do_picture_classification is True assert opts.do_picture_classification is True
def test_picture_description_enabled(self): def test_picture_description_enabled(self):
conv = build_converter(do_picture_description=True) conv = build_converter(ConversionOptions(do_picture_description=True))
opts = self._get_pipeline_options(conv) opts = self._get_pipeline_options(conv)
assert opts.do_picture_description is True assert opts.do_picture_description is True
def test_generate_picture_images(self): def test_generate_picture_images(self):
conv = build_converter(generate_picture_images=True) conv = build_converter(ConversionOptions(generate_picture_images=True))
opts = self._get_pipeline_options(conv) opts = self._get_pipeline_options(conv)
assert opts.generate_picture_images is True assert opts.generate_picture_images is True
def test_generate_page_images(self): def test_generate_page_images(self):
conv = build_converter(generate_page_images=True) conv = build_converter(ConversionOptions(generate_page_images=True))
opts = self._get_pipeline_options(conv) opts = self._get_pipeline_options(conv)
assert opts.generate_page_images is True assert opts.generate_page_images is True
def test_images_scale(self): def test_images_scale(self):
conv = build_converter(images_scale=2.0) conv = build_converter(ConversionOptions(images_scale=2.0))
opts = self._get_pipeline_options(conv) opts = self._get_pipeline_options(conv)
assert opts.images_scale == 2.0 assert opts.images_scale == 2.0
def test_all_options_combined(self): def test_all_options_combined(self):
conv = build_converter( conv = build_converter(ConversionOptions(
do_ocr=False, do_ocr=False,
do_table_structure=True, do_table_structure=True,
table_mode="fast", table_mode="fast",
@ -108,7 +108,7 @@ class TestBuildConverter:
generate_picture_images=True, generate_picture_images=True,
generate_page_images=True, generate_page_images=True,
images_scale=1.5, images_scale=1.5,
) ))
opts = self._get_pipeline_options(conv) opts = self._get_pipeline_options(conv)
assert opts.do_ocr is False assert opts.do_ocr is False
assert opts.do_table_structure is True assert opts.do_table_structure is True
@ -158,7 +158,7 @@ class TestConvertDocumentRouting:
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", do_ocr=False) convert_document("/tmp/test.pdf", ConversionOptions(do_ocr=False))
mock_build.assert_called_once() mock_build.assert_called_once()
mock_get_default.assert_not_called() mock_get_default.assert_not_called()
@ -175,10 +175,10 @@ class TestConvertDocumentRouting:
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", table_mode="fast") opts = ConversionOptions(table_mode="fast")
convert_document("/tmp/test.pdf", opts)
mock_build.assert_called_once() mock_build.assert_called_once_with(opts)
assert mock_build.call_args.kwargs["table_mode"] == "fast"
@patch("domain.parsing.get_default_converter") @patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter") @patch("domain.parsing.build_converter")
@ -192,10 +192,10 @@ class TestConvertDocumentRouting:
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", do_code_enrichment=True) opts = ConversionOptions(do_code_enrichment=True)
convert_document("/tmp/test.pdf", opts)
mock_build.assert_called_once() mock_build.assert_called_once_with(opts)
assert mock_build.call_args.kwargs["do_code_enrichment"] is True
@patch("domain.parsing.get_default_converter") @patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter") @patch("domain.parsing.build_converter")
@ -209,7 +209,7 @@ class TestConvertDocumentRouting:
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", do_formula_enrichment=True) convert_document("/tmp/test.pdf", ConversionOptions(do_formula_enrichment=True))
mock_build.assert_called_once() mock_build.assert_called_once()
@ -225,7 +225,7 @@ class TestConvertDocumentRouting:
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", do_picture_classification=True) convert_document("/tmp/test.pdf", ConversionOptions(do_picture_classification=True))
mock_build.assert_called_once() mock_build.assert_called_once()
@ -241,7 +241,7 @@ class TestConvertDocumentRouting:
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", generate_picture_images=True) convert_document("/tmp/test.pdf", ConversionOptions(generate_picture_images=True))
mock_build.assert_called_once() mock_build.assert_called_once()
@ -257,10 +257,10 @@ class TestConvertDocumentRouting:
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", images_scale=2.0) opts = ConversionOptions(images_scale=2.0)
convert_document("/tmp/test.pdf", opts)
mock_build.assert_called_once() mock_build.assert_called_once_with(opts)
assert mock_build.call_args.kwargs["images_scale"] == 2.0
@patch("domain.parsing.get_default_converter") @patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter") @patch("domain.parsing.build_converter")
@ -274,8 +274,7 @@ class TestConvertDocumentRouting:
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
convert_document( opts = ConversionOptions(
"/tmp/test.pdf",
do_ocr=False, do_ocr=False,
do_table_structure=False, do_table_structure=False,
table_mode="fast", table_mode="fast",
@ -287,19 +286,9 @@ class TestConvertDocumentRouting:
generate_page_images=True, generate_page_images=True,
images_scale=1.5, images_scale=1.5,
) )
convert_document("/tmp/test.pdf", opts)
mock_build.assert_called_once_with( mock_build.assert_called_once_with(opts)
do_ocr=False,
do_table_structure=False,
table_mode="fast",
do_code_enrichment=True,
do_formula_enrichment=True,
do_picture_classification=True,
do_picture_description=True,
generate_picture_images=True,
generate_page_images=True,
images_scale=1.5,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -392,18 +381,15 @@ class TestServiceForwardsPipelineOptions:
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", opts) await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", opts)
mock_convert.assert_called_once_with( mock_convert.assert_called_once()
"/tmp/test.pdf", call_args = mock_convert.call_args
do_ocr=False, assert call_args[0][0] == "/tmp/test.pdf"
table_mode="fast", conv_opts = call_args[0][1]
do_code_enrichment=True, assert conv_opts.do_ocr is False
do_formula_enrichment=False, assert conv_opts.table_mode == "fast"
do_picture_classification=False, assert conv_opts.do_code_enrichment is True
do_picture_description=False, assert conv_opts.generate_picture_images is True
generate_picture_images=True, assert conv_opts.images_scale == 2.0
generate_page_images=False,
images_scale=2.0,
)
@patch("services.analysis_service.analysis_repo") @patch("services.analysis_service.analysis_repo")
@patch("services.analysis_service.document_repo") @patch("services.analysis_service.document_repo")
@ -428,8 +414,11 @@ class TestServiceForwardsPipelineOptions:
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", None) await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", None)
# Called with file_path only (no kwargs spread from empty dict) # Called with file_path and default ConversionOptions
mock_convert.assert_called_once_with("/tmp/test.pdf") mock_convert.assert_called_once()
call_args = mock_convert.call_args
assert call_args[0][0] == "/tmp/test.pdf"
assert call_args[0][1] == ConversionOptions()
@patch("services.analysis_service.analysis_repo") @patch("services.analysis_service.analysis_repo")
@patch("services.analysis_service.document_repo") @patch("services.analysis_service.document_repo")

View file

@ -2,7 +2,7 @@ FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build

View file

@ -6,6 +6,7 @@ export const useAnalysisStore = defineStore('analysis', () => {
const analyses = ref([]) const analyses = ref([])
const currentAnalysis = ref(null) const currentAnalysis = ref(null)
const running = ref(false) const running = ref(false)
const error = ref(null)
const pollingInterval = ref(null) const pollingInterval = ref(null)
const currentPages = computed(() => { const currentPages = computed(() => {
@ -17,16 +18,21 @@ export const useAnalysisStore = defineStore('analysis', () => {
} }
}) })
function clearError() { error.value = null }
async function load() { async function load() {
try { try {
error.value = null
analyses.value = await api.fetchAnalyses() analyses.value = await api.fetchAnalyses()
} catch (e) { } catch (e) {
error.value = e.message || 'Failed to load analyses'
console.error('Failed to load analyses', e) console.error('Failed to load analyses', e)
} }
} }
async function run(documentId, pipelineOptions = null) { async function run(documentId, pipelineOptions = null) {
running.value = true running.value = true
error.value = null
try { try {
const analysis = await api.createAnalysis(documentId, pipelineOptions) const analysis = await api.createAnalysis(documentId, pipelineOptions)
currentAnalysis.value = analysis currentAnalysis.value = analysis
@ -35,6 +41,7 @@ export const useAnalysisStore = defineStore('analysis', () => {
return analysis return analysis
} catch (e) { } catch (e) {
running.value = false running.value = false
error.value = e.message || 'Failed to start analysis'
console.error('Failed to start analysis', e) console.error('Failed to start analysis', e)
throw e throw e
} }
@ -46,7 +53,6 @@ export const useAnalysisStore = defineStore('analysis', () => {
try { try {
const updated = await api.fetchAnalysis(id) const updated = await api.fetchAnalysis(id)
currentAnalysis.value = updated currentAnalysis.value = updated
// Update in list
const idx = analyses.value.findIndex(a => a.id === id) const idx = analyses.value.findIndex(a => a.id === id)
if (idx !== -1) analyses.value[idx] = updated if (idx !== -1) analyses.value[idx] = updated
if (updated.status === 'COMPLETED' || updated.status === 'FAILED') { if (updated.status === 'COMPLETED' || updated.status === 'FAILED') {
@ -54,6 +60,7 @@ export const useAnalysisStore = defineStore('analysis', () => {
running.value = false running.value = false
} }
} catch (e) { } catch (e) {
error.value = e.message || 'Polling error'
console.error('Polling error', e) console.error('Polling error', e)
stopPolling() stopPolling()
running.value = false running.value = false
@ -72,6 +79,7 @@ export const useAnalysisStore = defineStore('analysis', () => {
try { try {
currentAnalysis.value = await api.fetchAnalysis(id) currentAnalysis.value = await api.fetchAnalysis(id)
} catch (e) { } catch (e) {
error.value = e.message || 'Failed to load analysis'
console.error('Failed to load analysis', e) console.error('Failed to load analysis', e)
} }
} }
@ -82,9 +90,10 @@ export const useAnalysisStore = defineStore('analysis', () => {
analyses.value = analyses.value.filter(a => a.id !== id) analyses.value = analyses.value.filter(a => a.id !== id)
if (currentAnalysis.value?.id === id) currentAnalysis.value = null if (currentAnalysis.value?.id === id) currentAnalysis.value = null
} catch (e) { } catch (e) {
error.value = e.message || 'Failed to delete analysis'
console.error('Failed to delete analysis', e) console.error('Failed to delete analysis', e)
} }
} }
return { analyses, currentAnalysis, currentPages, running, load, run, select, remove, stopPolling } return { analyses, currentAnalysis, currentPages, running, error, clearError, load, run, select, remove, stopPolling }
}) })

View file

@ -36,6 +36,20 @@
{{ el.type }} {{ el.type }}
</span> </span>
<span class="element-level" v-if="el.level">L{{ el.level }}</span> <span class="element-level" v-if="el.level">L{{ el.level }}</span>
<button
v-if="el.content"
class="copy-btn copy-btn-element"
:title="t('results.copy')"
@click.stop="copyElement(idx, el.content)"
>
<svg v-if="!copiedElements[idx]" viewBox="0 0 20 20" fill="currentColor" class="copy-icon">
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z"/>
<path d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"/>
</svg>
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="copy-icon copied">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
</button>
</div> </div>
<div class="element-content" v-if="el.content"> <div class="element-content" v-if="el.content">
<MarkdownViewer v-if="el.type === 'table'" :content="el.content" /> <MarkdownViewer v-if="el.type === 'table'" :content="el.content" />
@ -50,6 +64,15 @@
<!-- RAW MARKDOWN --> <!-- RAW MARKDOWN -->
<div v-else-if="activeTab === 'markdown'" class="raw-markdown"> <div v-else-if="activeTab === 'markdown'" class="raw-markdown">
<button class="copy-btn copy-btn-block" :title="t('results.copy')" @click="copyMarkdown">
<svg v-if="!copiedMarkdown" viewBox="0 0 20 20" fill="currentColor" class="copy-icon">
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z"/>
<path d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"/>
</svg>
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="copy-icon copied">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
</button>
<pre class="raw-content">{{ pageMarkdown }}</pre> <pre class="raw-content">{{ pageMarkdown }}</pre>
</div> </div>
@ -74,7 +97,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed, reactive } from 'vue'
import { useAnalysisStore } from '../store.js' import { useAnalysisStore } from '../store.js'
import MarkdownViewer from './MarkdownViewer.vue' import MarkdownViewer from './MarkdownViewer.vue'
import ImageGallery from './ImageGallery.vue' import ImageGallery from './ImageGallery.vue'
@ -158,6 +181,26 @@ function formatElement(el) {
return `${indent}${el.content}` return `${indent}${el.content}`
} }
} }
// --- Copy to clipboard ---
const copiedMarkdown = ref(false)
const copiedElements = reactive({})
async function copyMarkdown() {
try {
await navigator.clipboard.writeText(pageMarkdown.value)
copiedMarkdown.value = true
setTimeout(() => { copiedMarkdown.value = false }, 1500)
} catch { /* clipboard not available */ }
}
async function copyElement(idx, content) {
try {
await navigator.clipboard.writeText(content)
copiedElements[idx] = true
setTimeout(() => { copiedElements[idx] = false }, 1500)
} catch { /* clipboard not available */ }
}
</script> </script>
<style scoped> <style scoped>
@ -304,9 +347,53 @@ function formatElement(el) {
color: var(--text-muted); color: var(--text-muted);
} }
/* --- Copy button --- */
.copy-btn {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-muted);
transition: all var(--transition);
padding: 4px;
}
.copy-btn:hover {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-muted);
}
.copy-icon { width: 14px; height: 14px; }
.copy-icon.copied { color: var(--success); }
.copy-btn-element {
margin-left: auto;
opacity: 0;
transition: opacity var(--transition);
}
.element-card:hover .copy-btn-element { opacity: 1; }
.copy-btn-block {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
background: var(--bg-surface);
opacity: 0;
transition: opacity var(--transition);
}
.raw-markdown:hover .copy-btn-block { opacity: 1; }
/* --- Raw markdown --- */ /* --- Raw markdown --- */
.raw-markdown { .raw-markdown {
height: 100%; height: 100%;
position: relative;
} }
.raw-content { .raw-content {

View file

@ -6,23 +6,30 @@ export const useDocumentStore = defineStore('document', () => {
const documents = ref([]) const documents = ref([])
const selectedId = ref(null) const selectedId = ref(null)
const uploading = ref(false) const uploading = ref(false)
const error = ref(null)
function clearError() { error.value = null }
async function load() { async function load() {
try { try {
error.value = null
documents.value = await api.fetchDocuments() documents.value = await api.fetchDocuments()
} catch (e) { } catch (e) {
error.value = e.message || 'Failed to load documents'
console.error('Failed to load documents', e) console.error('Failed to load documents', e)
} }
} }
async function upload(file) { async function upload(file) {
uploading.value = true uploading.value = true
error.value = null
try { try {
const doc = await api.uploadDocument(file) const doc = await api.uploadDocument(file)
documents.value.unshift(doc) documents.value.unshift(doc)
selectedId.value = doc.id selectedId.value = doc.id
return doc return doc
} catch (e) { } catch (e) {
error.value = e.message || 'Failed to upload document'
console.error('Failed to upload document', e) console.error('Failed to upload document', e)
throw e throw e
} finally { } finally {
@ -36,6 +43,7 @@ export const useDocumentStore = defineStore('document', () => {
documents.value = documents.value.filter(d => d.id !== id) documents.value = documents.value.filter(d => d.id !== id)
if (selectedId.value === id) selectedId.value = null if (selectedId.value === id) selectedId.value = null
} catch (e) { } catch (e) {
error.value = e.message || 'Failed to delete document'
console.error('Failed to delete document', e) console.error('Failed to delete document', e)
} }
} }
@ -44,5 +52,5 @@ export const useDocumentStore = defineStore('document', () => {
selectedId.value = id selectedId.value = id
} }
return { documents, selectedId, uploading, load, upload, remove, select } return { documents, selectedId, uploading, error, clearError, load, upload, remove, select }
}) })

View file

@ -1,9 +0,0 @@
import { apiFetch } from '../../shared/api/http.js'
export function fetchAnalyses() {
return apiFetch('/api/analyses')
}
export function deleteAnalysis(id) {
return apiFetch(`/api/analyses/${id}`, { method: 'DELETE' })
}

View file

@ -1,32 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fetchAnalyses, deleteAnalysis } from './api.js'
vi.mock('../../shared/api/http.js', () => ({
apiFetch: vi.fn(),
}))
import { apiFetch } from '../../shared/api/http.js'
describe('history API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetchAnalyses calls GET /api/analyses', async () => {
const data = [{ id: '1' }, { id: '2' }]
apiFetch.mockResolvedValue(data)
const result = await fetchAnalyses()
expect(apiFetch).toHaveBeenCalledWith('/api/analyses')
expect(result).toEqual(data)
})
it('deleteAnalysis calls DELETE /api/analyses/:id', async () => {
apiFetch.mockResolvedValue(null)
await deleteAnalysis('5')
expect(apiFetch).toHaveBeenCalledWith('/api/analyses/5', { method: 'DELETE' })
})
})

View file

@ -7,11 +7,6 @@ vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockPush }), useRouter: () => ({ push: mockPush }),
})) }))
vi.mock('./api.js', () => ({
fetchAnalyses: vi.fn(),
deleteAnalysis: vi.fn(),
}))
vi.mock('../analysis/api.js', () => ({ vi.mock('../analysis/api.js', () => ({
fetchAnalyses: vi.fn(), fetchAnalyses: vi.fn(),
fetchAnalysis: vi.fn(), fetchAnalysis: vi.fn(),
@ -38,7 +33,7 @@ describe('History → Studio navigation', () => {
describe('History store provides data for navigation', () => { describe('History store provides data for navigation', () => {
it('analyses contain documentId for document selection', async () => { it('analyses contain documentId for document selection', async () => {
const { fetchAnalyses } = await import('./api.js') const { fetchAnalyses } = await import('../analysis/api.js')
fetchAnalyses.mockResolvedValue([ fetchAnalyses.mockResolvedValue([
{ id: 'a1', documentId: 'd1', documentFilename: 'test.pdf', status: 'COMPLETED' }, { id: 'a1', documentId: 'd1', documentFilename: 'test.pdf', status: 'COMPLETED' },
{ id: 'a2', documentId: 'd2', documentFilename: 'other.pdf', status: 'FAILED' }, { id: 'a2', documentId: 'd2', documentFilename: 'other.pdf', status: 'FAILED' },

View file

@ -1,26 +1 @@
import { defineStore } from 'pinia' export { useAnalysisStore as useHistoryStore } from '../analysis/store.js'
import { ref } from 'vue'
import * as api from './api.js'
export const useHistoryStore = defineStore('history', () => {
const analyses = ref([])
async function load() {
try {
analyses.value = await api.fetchAnalyses()
} catch (e) {
console.error('Failed to load history', e)
}
}
async function remove(id) {
try {
await api.deleteAnalysis(id)
analyses.value = analyses.value.filter(a => a.id !== id)
} catch (e) {
console.error('Failed to delete analysis', e)
}
}
return { analyses, load, remove }
})

View file

@ -1,66 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useHistoryStore } from './store.js' import { useHistoryStore } from './store.js'
import { useAnalysisStore } from '../analysis/store.js'
vi.mock('./api.js', () => ({
fetchAnalyses: vi.fn(),
deleteAnalysis: vi.fn(),
}))
import * as api from './api.js'
describe('useHistoryStore', () => { describe('useHistoryStore', () => {
beforeEach(() => { it('is a re-export of useAnalysisStore', () => {
setActivePinia(createPinia()) expect(useHistoryStore).toBe(useAnalysisStore)
vi.clearAllMocks()
})
it('starts with empty analyses', () => {
const store = useHistoryStore()
expect(store.analyses).toEqual([])
})
it('load() fetches analyses', async () => {
const data = [{ id: '1' }, { id: '2' }]
api.fetchAnalyses.mockResolvedValue(data)
const store = useHistoryStore()
await store.load()
expect(store.analyses).toEqual(data)
})
it('load() handles errors gracefully', async () => {
api.fetchAnalyses.mockRejectedValue(new Error('fail'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const store = useHistoryStore()
await store.load()
expect(store.analyses).toEqual([])
})
it('remove() deletes and removes from list', async () => {
api.deleteAnalysis.mockResolvedValue(null)
const store = useHistoryStore()
store.analyses = [{ id: '1' }, { id: '2' }, { id: '3' }]
await store.remove('2')
expect(store.analyses).toEqual([{ id: '1' }, { id: '3' }])
})
it('remove() handles errors gracefully', async () => {
api.deleteAnalysis.mockRejectedValue(new Error('fail'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const store = useHistoryStore()
store.analyses = [{ id: '1' }]
await store.remove('1')
// Should not remove on error
expect(store.analyses).toEqual([{ id: '1' }])
}) })
}) })

View file

@ -32,10 +32,10 @@
<script setup> <script setup>
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useHistoryStore } from '../store.js' import { useAnalysisStore } from '../../analysis/store.js'
import { useI18n } from '../../../shared/i18n.js' import { useI18n } from '../../../shared/i18n.js'
const store = useHistoryStore() const store = useAnalysisStore()
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()

View file

@ -74,6 +74,8 @@ const messages = {
'results.noMarkdown': 'Pas de contenu markdown', 'results.noMarkdown': 'Pas de contenu markdown',
'results.runAnalysis': 'Lancez une analyse pour voir les résultats', 'results.runAnalysis': 'Lancez une analyse pour voir les résultats',
'results.analysisFailed': "L'analyse a échoué", 'results.analysisFailed': "L'analyse a échoué",
'results.copy': 'Copier',
'results.copied': 'Copié !',
'results.page': 'Page', 'results.page': 'Page',
// Upload // Upload
@ -164,6 +166,8 @@ const messages = {
'results.noMarkdown': 'No markdown content', 'results.noMarkdown': 'No markdown content',
'results.runAnalysis': 'Run an analysis to see results', 'results.runAnalysis': 'Run an analysis to see results',
'results.analysisFailed': 'Analysis failed', 'results.analysisFailed': 'Analysis failed',
'results.copy': 'Copy',
'results.copied': 'Copied!',
'results.page': 'Page', 'results.page': 'Page',
'upload.drop': 'Drop a PDF here or click to upload', 'upload.drop': 'Drop a PDF here or click to upload',

View file

@ -4,6 +4,12 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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 / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }