diff --git a/Dockerfile b/Dockerfile index 4562860..4c33624 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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'"] diff --git a/docker-compose.yml b/docker-compose.yml index 86819f4..6e75d5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/document-parser/Dockerfile b/document-parser/Dockerfile index 6ab8e94..76cc697 100644 --- a/document-parser/Dockerfile +++ b/document-parser/Dockerfile @@ -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"] diff --git a/document-parser/api/documents.py b/document-parser/api/documents.py index 32e2dc3..1a41823 100644 --- a/document-parser/api/documents.py +++ b/document-parser/api/documents.py @@ -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: diff --git a/document-parser/data/docling_studio.db b/document-parser/data/docling_studio.db index ef5cdbb..a4f5f99 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 30366e3..8c77b85 100644 --- a/document-parser/domain/parsing.py +++ b/document-parser/domain/parsing.py @@ -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, ) diff --git a/document-parser/main.py b/document-parser/main.py index f1b4038..dd19148 100644 --- a/document-parser/main.py +++ b/document-parser/main.py @@ -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 diff --git a/document-parser/persistence/database.py b/document-parser/persistence/database.py index dc8d95d..6aaa37b 100644 --- a/document-parser/persistence/database.py +++ b/document-parser/persistence/database.py @@ -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); """ diff --git a/document-parser/services/analysis_service.py b/document-parser/services/analysis_service.py index f061980..9a6f5e7 100644 --- a/document-parser/services/analysis_service.py +++ b/document-parser/services/analysis_service.py @@ -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, ) diff --git a/document-parser/services/document_service.py b/document-parser/services/document_service.py index dd3b6c4..f36825d 100644 --- a/document-parser/services/document_service.py +++ b/document-parser/services/document_service.py @@ -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: diff --git a/document-parser/tests/test_pipeline_options.py b/document-parser/tests/test_pipeline_options.py index 6e619e8..7da273f 100644 --- a/document-parser/tests/test_pipeline_options.py +++ b/document-parser/tests/test_pipeline_options.py @@ -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") diff --git a/frontend/Dockerfile b/frontend/Dockerfile index c1c36cb..ea2b68a 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/src/features/analysis/store.js b/frontend/src/features/analysis/store.js index e01025e..3e41c89 100644 --- a/frontend/src/features/analysis/store.js +++ b/frontend/src/features/analysis/store.js @@ -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 } }) diff --git a/frontend/src/features/analysis/ui/ResultTabs.vue b/frontend/src/features/analysis/ui/ResultTabs.vue index 1a3a9e7..e1aab63 100644 --- a/frontend/src/features/analysis/ui/ResultTabs.vue +++ b/frontend/src/features/analysis/ui/ResultTabs.vue @@ -36,6 +36,20 @@ {{ el.type }} L{{ el.level }} +
@@ -50,6 +64,15 @@
+
{{ pageMarkdown }}
@@ -74,7 +97,7 @@