Refacto and add cp paste btn
This commit is contained in:
parent
7bb4f8e298
commit
2db251182f
23 changed files with 262 additions and 283 deletions
|
|
@ -41,13 +41,16 @@ COPY --from=frontend-build /build/dist /usr/share/nginx/html
|
|||
# Nginx config
|
||||
COPY nginx.conf /etc/nginx/sites-enabled/default
|
||||
|
||||
# Non-root user
|
||||
RUN useradd --create-home --shell /bin/bash appuser
|
||||
|
||||
# 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 DB_PATH=/app/data/docling_studio.db
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Start both nginx and uvicorn
|
||||
CMD ["sh", "-c", "nginx && exec uvicorn main:app --host 127.0.0.1 --port 8000"]
|
||||
# nginx needs to run as root for port binding, then drops to appuser for uvicorn
|
||||
CMD ["sh", "-c", "nginx && exec su appuser -c 'uvicorn main:app --host 127.0.0.1 --port 8000'"]
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ services:
|
|||
document-parser:
|
||||
build:
|
||||
context: ./document-parser
|
||||
ports:
|
||||
- "8000:8000"
|
||||
expose:
|
||||
- "8000"
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
- db_data:/app/data
|
||||
|
|
|
|||
|
|
@ -13,11 +13,14 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||
|
||||
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
|
||||
|
||||
ENV UPLOAD_DIR=/app/uploads
|
||||
ENV DB_PATH=/app/data/docling_studio.db
|
||||
|
||||
USER appuser
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ async def upload(file: UploadFile):
|
|||
if not file.filename:
|
||||
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()
|
||||
|
||||
try:
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -38,6 +38,10 @@ logger = logging.getLogger(__name__)
|
|||
# Thread lock — DocumentConverter is not thread-safe
|
||||
_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: DocumentConverter | None = None
|
||||
|
||||
|
|
@ -62,6 +66,23 @@ class PageDetail:
|
|||
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
|
||||
class ConversionResult:
|
||||
page_count: int
|
||||
|
|
@ -102,35 +123,26 @@ def _get_element_type(item: DocItem) -> str:
|
|||
# Pipeline factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_converter(
|
||||
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:
|
||||
def build_converter(options: ConversionOptions | None = None) -> DocumentConverter:
|
||||
"""Build a DocumentConverter with the given pipeline options."""
|
||||
opts = options or ConversionOptions()
|
||||
|
||||
table_options = TableStructureOptions(
|
||||
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(
|
||||
do_ocr=do_ocr,
|
||||
do_table_structure=do_table_structure,
|
||||
do_ocr=opts.do_ocr,
|
||||
do_table_structure=opts.do_table_structure,
|
||||
table_structure_options=table_options,
|
||||
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_page_images=generate_page_images,
|
||||
generate_picture_images=generate_picture_images,
|
||||
images_scale=images_scale,
|
||||
do_code_enrichment=opts.do_code_enrichment,
|
||||
do_formula_enrichment=opts.do_formula_enrichment,
|
||||
do_picture_classification=opts.do_picture_classification,
|
||||
do_picture_description=opts.do_picture_description,
|
||||
generate_page_images=opts.generate_page_images,
|
||||
generate_picture_images=opts.generate_picture_images,
|
||||
images_scale=opts.images_scale,
|
||||
)
|
||||
|
||||
return DocumentConverter(
|
||||
|
|
@ -191,7 +203,7 @@ def _process_content_item(
|
|||
try:
|
||||
page_no = prov.page_no
|
||||
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
|
||||
|
||||
|
|
@ -205,13 +217,13 @@ def _process_content_item(
|
|||
if isinstance(item, TableItem):
|
||||
try:
|
||||
content = item.export_to_markdown()
|
||||
except Exception:
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
pages[page_no].elements.append(
|
||||
PageElement(type=element_type, bbox=bbox, content=content, level=level)
|
||||
)
|
||||
except Exception:
|
||||
except (AttributeError, KeyError, TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Skipping item %s on page %s",
|
||||
type(item).__name__,
|
||||
|
|
@ -227,77 +239,54 @@ def _process_content_item(
|
|||
# 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(
|
||||
file_path: str,
|
||||
*,
|
||||
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,
|
||||
options: ConversionOptions | None = None,
|
||||
) -> ConversionResult:
|
||||
"""Convert a document and return structured results.
|
||||
|
||||
This is the main entry point for document parsing. Runs synchronously
|
||||
(caller should use asyncio.to_thread for non-blocking execution).
|
||||
"""
|
||||
# Use cached default converter only when all options match defaults
|
||||
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
|
||||
)
|
||||
opts = options or ConversionOptions()
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
conv = _select_converter(opts)
|
||||
result = conv.convert(file_path)
|
||||
|
||||
doc = result.document
|
||||
content_markdown = doc.export_to_markdown()
|
||||
content_html = doc.export_to_html()
|
||||
page_count = len(doc.pages)
|
||||
|
||||
pages_detail, skipped = extract_pages_detail(result)
|
||||
|
||||
if not pages_detail and page_count > 0:
|
||||
pages_detail = [
|
||||
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)
|
||||
]
|
||||
pages_detail = _build_fallback_pages(doc, page_count)
|
||||
|
||||
if skipped > 0:
|
||||
logger.info("Parsed: %d pages, %d items skipped", page_count, skipped)
|
||||
|
||||
return ConversionResult(
|
||||
page_count=page_count or len(pages_detail) or 1,
|
||||
content_markdown=content_markdown,
|
||||
content_html=content_html,
|
||||
content_markdown=doc.export_to_markdown(),
|
||||
content_html=doc.export_to_html(),
|
||||
pages=pages_detail,
|
||||
skipped_items=skipped,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ app.add_middleware(
|
|||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in allowed_origins],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization"],
|
||||
)
|
||||
|
||||
# Mount routers
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ CREATE TABLE IF NOT EXISTS analysis_jobs (
|
|||
completed_at TEXT,
|
||||
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);
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import logging
|
|||
from dataclasses import asdict
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -43,7 +43,7 @@ def _on_task_done(task: asyncio.Task) -> None:
|
|||
return
|
||||
exc = task.exception()
|
||||
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]:
|
||||
|
|
@ -72,12 +72,12 @@ async def _run_analysis(
|
|||
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 {}
|
||||
# Build conversion options from pipeline dict
|
||||
options = ConversionOptions(**(pipeline_options or {}))
|
||||
|
||||
# Run blocking Docling conversion in a thread with timeout
|
||||
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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ async def upload(filename: str, content_type: str, file_content: bytes) -> Docum
|
|||
|
||||
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)
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from docling.datamodel.pipeline_options import (
|
|||
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
|
||||
|
||||
def test_ocr_disabled(self):
|
||||
conv = build_converter(do_ocr=False)
|
||||
conv = build_converter(ConversionOptions(do_ocr=False))
|
||||
opts = self._get_pipeline_options(conv)
|
||||
assert opts.do_ocr is False
|
||||
|
||||
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)
|
||||
assert opts.table_structure_options.mode == TableFormerMode.FAST
|
||||
|
||||
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)
|
||||
assert opts.table_structure_options.mode == TableFormerMode.ACCURATE
|
||||
|
||||
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)
|
||||
assert opts.do_table_structure is False
|
||||
|
||||
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)
|
||||
assert opts.do_code_enrichment is True
|
||||
|
||||
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)
|
||||
assert opts.do_formula_enrichment is True
|
||||
|
||||
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)
|
||||
assert opts.do_picture_classification is True
|
||||
|
||||
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)
|
||||
assert opts.do_picture_description is True
|
||||
|
||||
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)
|
||||
assert opts.generate_picture_images is True
|
||||
|
||||
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)
|
||||
assert opts.generate_page_images is True
|
||||
|
||||
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)
|
||||
assert opts.images_scale == 2.0
|
||||
|
||||
def test_all_options_combined(self):
|
||||
conv = build_converter(
|
||||
conv = build_converter(ConversionOptions(
|
||||
do_ocr=False,
|
||||
do_table_structure=True,
|
||||
table_mode="fast",
|
||||
|
|
@ -108,7 +108,7 @@ class TestBuildConverter:
|
|||
generate_picture_images=True,
|
||||
generate_page_images=True,
|
||||
images_scale=1.5,
|
||||
)
|
||||
))
|
||||
opts = self._get_pipeline_options(conv)
|
||||
assert opts.do_ocr is False
|
||||
assert opts.do_table_structure is True
|
||||
|
|
@ -158,7 +158,7 @@ class TestConvertDocumentRouting:
|
|||
mock_conv.convert.return_value = mock_result
|
||||
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_get_default.assert_not_called()
|
||||
|
|
@ -175,10 +175,10 @@ class TestConvertDocumentRouting:
|
|||
mock_conv.convert.return_value = mock_result
|
||||
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()
|
||||
assert mock_build.call_args.kwargs["table_mode"] == "fast"
|
||||
mock_build.assert_called_once_with(opts)
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
|
|
@ -192,10 +192,10 @@ class TestConvertDocumentRouting:
|
|||
mock_conv.convert.return_value = mock_result
|
||||
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()
|
||||
assert mock_build.call_args.kwargs["do_code_enrichment"] is True
|
||||
mock_build.assert_called_once_with(opts)
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
|
|
@ -209,7 +209,7 @@ class TestConvertDocumentRouting:
|
|||
mock_conv.convert.return_value = mock_result
|
||||
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()
|
||||
|
||||
|
|
@ -225,7 +225,7 @@ class TestConvertDocumentRouting:
|
|||
mock_conv.convert.return_value = mock_result
|
||||
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()
|
||||
|
||||
|
|
@ -241,7 +241,7 @@ class TestConvertDocumentRouting:
|
|||
mock_conv.convert.return_value = mock_result
|
||||
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()
|
||||
|
||||
|
|
@ -257,10 +257,10 @@ class TestConvertDocumentRouting:
|
|||
mock_conv.convert.return_value = mock_result
|
||||
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()
|
||||
assert mock_build.call_args.kwargs["images_scale"] == 2.0
|
||||
mock_build.assert_called_once_with(opts)
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
|
|
@ -274,8 +274,7 @@ class TestConvertDocumentRouting:
|
|||
mock_conv.convert.return_value = mock_result
|
||||
mock_build.return_value = mock_conv
|
||||
|
||||
convert_document(
|
||||
"/tmp/test.pdf",
|
||||
opts = ConversionOptions(
|
||||
do_ocr=False,
|
||||
do_table_structure=False,
|
||||
table_mode="fast",
|
||||
|
|
@ -287,19 +286,9 @@ class TestConvertDocumentRouting:
|
|||
generate_page_images=True,
|
||||
images_scale=1.5,
|
||||
)
|
||||
convert_document("/tmp/test.pdf", opts)
|
||||
|
||||
mock_build.assert_called_once_with(
|
||||
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,
|
||||
)
|
||||
mock_build.assert_called_once_with(opts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -392,18 +381,15 @@ class TestServiceForwardsPipelineOptions:
|
|||
|
||||
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", opts)
|
||||
|
||||
mock_convert.assert_called_once_with(
|
||||
"/tmp/test.pdf",
|
||||
do_ocr=False,
|
||||
table_mode="fast",
|
||||
do_code_enrichment=True,
|
||||
do_formula_enrichment=False,
|
||||
do_picture_classification=False,
|
||||
do_picture_description=False,
|
||||
generate_picture_images=True,
|
||||
generate_page_images=False,
|
||||
images_scale=2.0,
|
||||
)
|
||||
mock_convert.assert_called_once()
|
||||
call_args = mock_convert.call_args
|
||||
assert call_args[0][0] == "/tmp/test.pdf"
|
||||
conv_opts = call_args[0][1]
|
||||
assert conv_opts.do_ocr is False
|
||||
assert conv_opts.table_mode == "fast"
|
||||
assert conv_opts.do_code_enrichment is True
|
||||
assert conv_opts.generate_picture_images is True
|
||||
assert conv_opts.images_scale == 2.0
|
||||
|
||||
@patch("services.analysis_service.analysis_repo")
|
||||
@patch("services.analysis_service.document_repo")
|
||||
|
|
@ -428,8 +414,11 @@ class TestServiceForwardsPipelineOptions:
|
|||
|
||||
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", None)
|
||||
|
||||
# Called with file_path only (no kwargs spread from empty dict)
|
||||
mock_convert.assert_called_once_with("/tmp/test.pdf")
|
||||
# Called with file_path and default ConversionOptions
|
||||
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.document_repo")
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ FROM node:20-alpine AS build
|
|||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
const analyses = ref([])
|
||||
const currentAnalysis = ref(null)
|
||||
const running = ref(false)
|
||||
const error = ref(null)
|
||||
const pollingInterval = ref(null)
|
||||
|
||||
const currentPages = computed(() => {
|
||||
|
|
@ -17,16 +18,21 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
}
|
||||
})
|
||||
|
||||
function clearError() { error.value = null }
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
error.value = null
|
||||
analyses.value = await api.fetchAnalyses()
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Failed to load analyses'
|
||||
console.error('Failed to load analyses', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function run(documentId, pipelineOptions = null) {
|
||||
running.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const analysis = await api.createAnalysis(documentId, pipelineOptions)
|
||||
currentAnalysis.value = analysis
|
||||
|
|
@ -35,6 +41,7 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
return analysis
|
||||
} catch (e) {
|
||||
running.value = false
|
||||
error.value = e.message || 'Failed to start analysis'
|
||||
console.error('Failed to start analysis', e)
|
||||
throw e
|
||||
}
|
||||
|
|
@ -46,7 +53,6 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
try {
|
||||
const updated = await api.fetchAnalysis(id)
|
||||
currentAnalysis.value = updated
|
||||
// Update in list
|
||||
const idx = analyses.value.findIndex(a => a.id === id)
|
||||
if (idx !== -1) analyses.value[idx] = updated
|
||||
if (updated.status === 'COMPLETED' || updated.status === 'FAILED') {
|
||||
|
|
@ -54,6 +60,7 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
running.value = false
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Polling error'
|
||||
console.error('Polling error', e)
|
||||
stopPolling()
|
||||
running.value = false
|
||||
|
|
@ -72,6 +79,7 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
try {
|
||||
currentAnalysis.value = await api.fetchAnalysis(id)
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Failed to load analysis'
|
||||
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)
|
||||
if (currentAnalysis.value?.id === id) currentAnalysis.value = null
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Failed to delete analysis'
|
||||
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 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -36,6 +36,20 @@
|
|||
{{ el.type }}
|
||||
</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 class="element-content" v-if="el.content">
|
||||
<MarkdownViewer v-if="el.type === 'table'" :content="el.content" />
|
||||
|
|
@ -50,6 +64,15 @@
|
|||
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
|
|
@ -74,7 +97,7 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, reactive } from 'vue'
|
||||
import { useAnalysisStore } from '../store.js'
|
||||
import MarkdownViewer from './MarkdownViewer.vue'
|
||||
import ImageGallery from './ImageGallery.vue'
|
||||
|
|
@ -158,6 +181,26 @@ function formatElement(el) {
|
|||
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>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -304,9 +347,53 @@ function formatElement(el) {
|
|||
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 {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.raw-content {
|
||||
|
|
|
|||
|
|
@ -6,23 +6,30 @@ export const useDocumentStore = defineStore('document', () => {
|
|||
const documents = ref([])
|
||||
const selectedId = ref(null)
|
||||
const uploading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
function clearError() { error.value = null }
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
error.value = null
|
||||
documents.value = await api.fetchDocuments()
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Failed to load documents'
|
||||
console.error('Failed to load documents', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(file) {
|
||||
uploading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const doc = await api.uploadDocument(file)
|
||||
documents.value.unshift(doc)
|
||||
selectedId.value = doc.id
|
||||
return doc
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Failed to upload document'
|
||||
console.error('Failed to upload document', e)
|
||||
throw e
|
||||
} finally {
|
||||
|
|
@ -36,6 +43,7 @@ export const useDocumentStore = defineStore('document', () => {
|
|||
documents.value = documents.value.filter(d => d.id !== id)
|
||||
if (selectedId.value === id) selectedId.value = null
|
||||
} catch (e) {
|
||||
error.value = e.message || 'Failed to delete document'
|
||||
console.error('Failed to delete document', e)
|
||||
}
|
||||
}
|
||||
|
|
@ -44,5 +52,5 @@ export const useDocumentStore = defineStore('document', () => {
|
|||
selectedId.value = id
|
||||
}
|
||||
|
||||
return { documents, selectedId, uploading, load, upload, remove, select }
|
||||
return { documents, selectedId, uploading, error, clearError, load, upload, remove, select }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
}
|
||||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
@ -7,11 +7,6 @@ vi.mock('vue-router', () => ({
|
|||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('./api.js', () => ({
|
||||
fetchAnalyses: vi.fn(),
|
||||
deleteAnalysis: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../analysis/api.js', () => ({
|
||||
fetchAnalyses: vi.fn(),
|
||||
fetchAnalysis: vi.fn(),
|
||||
|
|
@ -38,7 +33,7 @@ describe('History → Studio navigation', () => {
|
|||
|
||||
describe('History store provides data for navigation', () => {
|
||||
it('analyses contain documentId for document selection', async () => {
|
||||
const { fetchAnalyses } = await import('./api.js')
|
||||
const { fetchAnalyses } = await import('../analysis/api.js')
|
||||
fetchAnalyses.mockResolvedValue([
|
||||
{ id: 'a1', documentId: 'd1', documentFilename: 'test.pdf', status: 'COMPLETED' },
|
||||
{ id: 'a2', documentId: 'd2', documentFilename: 'other.pdf', status: 'FAILED' },
|
||||
|
|
|
|||
|
|
@ -1,26 +1 @@
|
|||
import { defineStore } from 'pinia'
|
||||
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 }
|
||||
})
|
||||
export { useAnalysisStore as useHistoryStore } from '../analysis/store.js'
|
||||
|
|
|
|||
|
|
@ -1,66 +1,9 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { useHistoryStore } from './store.js'
|
||||
|
||||
vi.mock('./api.js', () => ({
|
||||
fetchAnalyses: vi.fn(),
|
||||
deleteAnalysis: vi.fn(),
|
||||
}))
|
||||
|
||||
import * as api from './api.js'
|
||||
import { useAnalysisStore } from '../analysis/store.js'
|
||||
|
||||
describe('useHistoryStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
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' }])
|
||||
it('is a re-export of useAnalysisStore', () => {
|
||||
expect(useHistoryStore).toBe(useAnalysisStore)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -32,10 +32,10 @@
|
|||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useHistoryStore } from '../store.js'
|
||||
import { useAnalysisStore } from '../../analysis/store.js'
|
||||
import { useI18n } from '../../../shared/i18n.js'
|
||||
|
||||
const store = useHistoryStore()
|
||||
const store = useAnalysisStore()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ const messages = {
|
|||
'results.noMarkdown': 'Pas de contenu markdown',
|
||||
'results.runAnalysis': 'Lancez une analyse pour voir les résultats',
|
||||
'results.analysisFailed': "L'analyse a échoué",
|
||||
'results.copy': 'Copier',
|
||||
'results.copied': 'Copié !',
|
||||
'results.page': 'Page',
|
||||
|
||||
// Upload
|
||||
|
|
@ -164,6 +166,8 @@ const messages = {
|
|||
'results.noMarkdown': 'No markdown content',
|
||||
'results.runAnalysis': 'Run an analysis to see results',
|
||||
'results.analysisFailed': 'Analysis failed',
|
||||
'results.copy': 'Copy',
|
||||
'results.copied': 'Copied!',
|
||||
'results.page': 'Page',
|
||||
|
||||
'upload.drop': 'Drop a PDF here or click to upload',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue