Merge pull request #32 from scub-france/feature/include-chunking
Feature/include chunking
This commit is contained in:
commit
e74c788ad4
34 changed files with 1593 additions and 139 deletions
|
|
@ -7,7 +7,7 @@ from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
|
||||||
from api.schemas import AnalysisResponse, CreateAnalysisRequest
|
from api.schemas import AnalysisResponse, ChunkResponse, CreateAnalysisRequest, RechunkRequest
|
||||||
from services.analysis_service import AnalysisService
|
from services.analysis_service import AnalysisService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -30,6 +30,8 @@ def _to_response(job) -> AnalysisResponse:
|
||||||
content_markdown=job.content_markdown,
|
content_markdown=job.content_markdown,
|
||||||
content_html=job.content_html,
|
content_html=job.content_html,
|
||||||
pages_json=job.pages_json,
|
pages_json=job.pages_json,
|
||||||
|
chunks_json=job.chunks_json,
|
||||||
|
has_document_json=job.document_json is not None,
|
||||||
error_message=job.error_message,
|
error_message=job.error_message,
|
||||||
started_at=str(job.started_at) if job.started_at else None,
|
started_at=str(job.started_at) if job.started_at else None,
|
||||||
completed_at=str(job.completed_at) if job.completed_at else None,
|
completed_at=str(job.completed_at) if job.completed_at else None,
|
||||||
|
|
@ -47,8 +49,16 @@ async def create_analysis(body: CreateAnalysisRequest, service: ServiceDep):
|
||||||
if body.pipelineOptions:
|
if body.pipelineOptions:
|
||||||
pipeline_opts = body.pipelineOptions.model_dump()
|
pipeline_opts = body.pipelineOptions.model_dump()
|
||||||
|
|
||||||
|
chunking_opts = None
|
||||||
|
if body.chunkingOptions:
|
||||||
|
chunking_opts = body.chunkingOptions.model_dump()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
job = await service.create(body.documentId, pipeline_options=pipeline_opts)
|
job = await service.create(
|
||||||
|
body.documentId,
|
||||||
|
pipeline_options=pipeline_opts,
|
||||||
|
chunking_options=chunking_opts,
|
||||||
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||||
|
|
||||||
|
|
@ -71,6 +81,24 @@ async def get_analysis(job_id: str, service: ServiceDep):
|
||||||
return _to_response(job)
|
return _to_response(job)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{job_id}/rechunk", response_model=list[ChunkResponse])
|
||||||
|
async def rechunk_analysis(job_id: str, body: RechunkRequest, service: ServiceDep):
|
||||||
|
"""Re-chunk a completed analysis with new chunking options."""
|
||||||
|
try:
|
||||||
|
chunks = await service.rechunk(job_id, body.chunkingOptions.model_dump())
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
return [
|
||||||
|
ChunkResponse(
|
||||||
|
text=c.text,
|
||||||
|
headings=c.headings,
|
||||||
|
source_page=c.source_page,
|
||||||
|
token_count=c.token_count,
|
||||||
|
)
|
||||||
|
for c in chunks
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{job_id}", status_code=204)
|
@router.delete("/{job_id}", status_code=204)
|
||||||
async def delete_analysis(job_id: str, service: ServiceDep):
|
async def delete_analysis(job_id: str, service: ServiceDep):
|
||||||
"""Delete an analysis job."""
|
"""Delete an analysis job."""
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ def _to_camel(name: str) -> str:
|
||||||
|
|
||||||
class _CamelModel(BaseModel):
|
class _CamelModel(BaseModel):
|
||||||
"""Base model that serializes field names to camelCase."""
|
"""Base model that serializes field names to camelCase."""
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
alias_generator=_to_camel,
|
alias_generator=_to_camel,
|
||||||
populate_by_name=True,
|
populate_by_name=True,
|
||||||
|
|
@ -42,6 +43,8 @@ class AnalysisResponse(_CamelModel):
|
||||||
content_markdown: str | None = None
|
content_markdown: str | None = None
|
||||||
content_html: str | None = None
|
content_html: str | None = None
|
||||||
pages_json: str | None = None
|
pages_json: str | None = None
|
||||||
|
chunks_json: str | None = None
|
||||||
|
has_document_json: bool = False
|
||||||
error_message: str | None = None
|
error_message: str | None = None
|
||||||
started_at: str | datetime | None = None
|
started_at: str | datetime | None = None
|
||||||
completed_at: str | datetime | None = None
|
completed_at: str | datetime | None = None
|
||||||
|
|
@ -50,6 +53,7 @@ class AnalysisResponse(_CamelModel):
|
||||||
|
|
||||||
class PipelineOptionsRequest(BaseModel):
|
class PipelineOptionsRequest(BaseModel):
|
||||||
"""Docling pipeline configuration options."""
|
"""Docling pipeline configuration options."""
|
||||||
|
|
||||||
do_ocr: bool = True
|
do_ocr: bool = True
|
||||||
do_table_structure: bool = True
|
do_table_structure: bool = True
|
||||||
table_mode: str = "accurate" # "accurate" or "fast"
|
table_mode: str = "accurate" # "accurate" or "fast"
|
||||||
|
|
@ -76,6 +80,41 @@ class PipelineOptionsRequest(BaseModel):
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ChunkingOptionsRequest(BaseModel):
|
||||||
|
"""Docling chunking configuration options."""
|
||||||
|
|
||||||
|
chunker_type: str = "hybrid" # "hybrid", "hierarchical"
|
||||||
|
max_tokens: int = 512
|
||||||
|
merge_peers: bool = True
|
||||||
|
repeat_table_header: bool = True
|
||||||
|
|
||||||
|
@field_validator("chunker_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_chunker_type(cls, v: str) -> str:
|
||||||
|
if v not in ("hybrid", "hierarchical"):
|
||||||
|
raise ValueError('chunker_type must be "hybrid" or "hierarchical"')
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("max_tokens")
|
||||||
|
@classmethod
|
||||||
|
def validate_max_tokens(cls, v: int) -> int:
|
||||||
|
if v < 64 or v > 8192:
|
||||||
|
raise ValueError("max_tokens must be between 64 and 8192")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ChunkResponse(_CamelModel):
|
||||||
|
text: str
|
||||||
|
headings: list[str] = []
|
||||||
|
source_page: int | None = None
|
||||||
|
token_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
class CreateAnalysisRequest(BaseModel):
|
class CreateAnalysisRequest(BaseModel):
|
||||||
documentId: str # camelCase to match existing frontend contract
|
documentId: str # camelCase to match existing frontend contract
|
||||||
pipelineOptions: PipelineOptionsRequest | None = None
|
pipelineOptions: PipelineOptionsRequest | None = None
|
||||||
|
chunkingOptions: ChunkingOptionsRequest | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RechunkRequest(BaseModel):
|
||||||
|
chunkingOptions: ChunkingOptionsRequest
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1 @@
|
||||||
|
|
||||||
|
|
||||||
pytest_plugins = ["pytest_asyncio"]
|
pytest_plugins = ["pytest_asyncio"]
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,11 @@ def to_topleft_list(bbox: BoundingBox, page_height: float) -> list[float]:
|
||||||
if right <= left or bottom <= top:
|
if right <= left or bottom <= top:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Degenerate bbox skipped: [%.1f, %.1f, %.1f, %.1f] (page_height=%.1f)",
|
"Degenerate bbox skipped: [%.1f, %.1f, %.1f, %.1f] (page_height=%.1f)",
|
||||||
left, top, right, bottom, page_height,
|
left,
|
||||||
|
top,
|
||||||
|
right,
|
||||||
|
bottom,
|
||||||
|
page_height,
|
||||||
)
|
)
|
||||||
return list(EMPTY_BBOX)
|
return list(EMPTY_BBOX)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ class AnalysisJob:
|
||||||
content_markdown: str | None = None
|
content_markdown: str | None = None
|
||||||
content_html: str | None = None
|
content_html: str | None = None
|
||||||
pages_json: str | None = None
|
pages_json: str | None = None
|
||||||
|
document_json: str | None = None
|
||||||
|
chunks_json: str | None = None
|
||||||
error_message: str | None = None
|
error_message: str | None = None
|
||||||
started_at: datetime | None = None
|
started_at: datetime | None = None
|
||||||
completed_at: datetime | None = None
|
completed_at: datetime | None = None
|
||||||
|
|
@ -55,12 +57,19 @@ class AnalysisJob:
|
||||||
self.started_at = _utcnow()
|
self.started_at = _utcnow()
|
||||||
|
|
||||||
def mark_completed(
|
def mark_completed(
|
||||||
self, markdown: str, html: str, pages_json: str,
|
self,
|
||||||
|
markdown: str,
|
||||||
|
html: str,
|
||||||
|
pages_json: str,
|
||||||
|
document_json: str | None = None,
|
||||||
|
chunks_json: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.status = AnalysisStatus.COMPLETED
|
self.status = AnalysisStatus.COMPLETED
|
||||||
self.content_markdown = markdown
|
self.content_markdown = markdown
|
||||||
self.content_html = html
|
self.content_html = html
|
||||||
self.pages_json = pages_json
|
self.pages_json = pages_json
|
||||||
|
self.document_json = document_json
|
||||||
|
self.chunks_json = chunks_json
|
||||||
self.completed_at = _utcnow()
|
self.completed_at = _utcnow()
|
||||||
|
|
||||||
def mark_failed(self, error: str) -> None:
|
def mark_failed(self, error: str) -> None:
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,12 @@ from __future__ import annotations
|
||||||
from typing import TYPE_CHECKING, Protocol
|
from typing import TYPE_CHECKING, Protocol
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from domain.value_objects import ConversionOptions, ConversionResult
|
from domain.value_objects import (
|
||||||
|
ChunkingOptions,
|
||||||
|
ChunkResult,
|
||||||
|
ConversionOptions,
|
||||||
|
ConversionResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DocumentConverter(Protocol):
|
class DocumentConverter(Protocol):
|
||||||
|
|
@ -20,5 +25,20 @@ class DocumentConverter(Protocol):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def convert(
|
async def convert(
|
||||||
self, file_path: str, options: ConversionOptions,
|
self,
|
||||||
|
file_path: str,
|
||||||
|
options: ConversionOptions,
|
||||||
) -> ConversionResult: ...
|
) -> ConversionResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentChunker(Protocol):
|
||||||
|
"""Port for document chunking.
|
||||||
|
|
||||||
|
Takes a serialized DoclingDocument (JSON) and returns chunks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def chunk(
|
||||||
|
self,
|
||||||
|
document_json: str,
|
||||||
|
options: ChunkingOptions,
|
||||||
|
) -> list[ChunkResult]: ...
|
||||||
|
|
|
||||||
|
|
@ -49,3 +49,23 @@ class ConversionResult:
|
||||||
content_html: str
|
content_html: str
|
||||||
pages: list[PageDetail]
|
pages: list[PageDetail]
|
||||||
skipped_items: int = 0
|
skipped_items: int = 0
|
||||||
|
document_json: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChunkingOptions:
|
||||||
|
chunker_type: str = "hybrid" # "hybrid", "hierarchical", "page"
|
||||||
|
max_tokens: int = 512
|
||||||
|
merge_peers: bool = True
|
||||||
|
repeat_table_header: bool = True
|
||||||
|
|
||||||
|
def is_default(self) -> bool:
|
||||||
|
return self == ChunkingOptions()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChunkResult:
|
||||||
|
text: str
|
||||||
|
headings: list[str] = field(default_factory=list)
|
||||||
|
source_page: int | None = None
|
||||||
|
token_count: int = 0
|
||||||
|
|
|
||||||
84
document-parser/infra/local_chunker.py
Normal file
84
document-parser/infra/local_chunker.py
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
"""Local Docling chunker — runs chunking in-process using docling-core.
|
||||||
|
|
||||||
|
This adapter implements the DocumentChunker port. It deserializes a
|
||||||
|
DoclingDocument from JSON, applies the requested chunker, and returns
|
||||||
|
domain ChunkResult objects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from docling_core.transforms.chunker import HierarchicalChunker
|
||||||
|
from docling_core.transforms.chunker.hybrid_chunker import HybridChunker
|
||||||
|
from docling_core.types.doc.document import DoclingDocument
|
||||||
|
|
||||||
|
from domain.value_objects import ChunkingOptions, ChunkResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_sync(document_json: str, options: ChunkingOptions) -> list[ChunkResult]:
|
||||||
|
if not document_json or not document_json.strip():
|
||||||
|
raise ValueError("Empty document JSON — nothing to chunk")
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc_data = json.loads(document_json)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ValueError(f"Malformed document JSON: {e}") from e
|
||||||
|
|
||||||
|
doc = DoclingDocument.model_validate(doc_data)
|
||||||
|
|
||||||
|
chunker = _build_chunker(options)
|
||||||
|
results: list[ChunkResult] = []
|
||||||
|
|
||||||
|
for chunk in chunker.chunk(doc):
|
||||||
|
source_page = None
|
||||||
|
token_count = 0
|
||||||
|
|
||||||
|
if hasattr(chunk, "meta") and chunk.meta and chunk.meta.doc_items:
|
||||||
|
for doc_item in chunk.meta.doc_items:
|
||||||
|
if hasattr(doc_item, "prov") and doc_item.prov:
|
||||||
|
source_page = doc_item.prov[0].page_no
|
||||||
|
break
|
||||||
|
|
||||||
|
if hasattr(chunker, "tokenizer") and chunker.tokenizer:
|
||||||
|
token_count = chunker.tokenizer.count_tokens(chunk.text)
|
||||||
|
|
||||||
|
headings = list(chunk.meta.headings) if chunk.meta and chunk.meta.headings else []
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
ChunkResult(
|
||||||
|
text=chunk.text,
|
||||||
|
headings=headings,
|
||||||
|
source_page=source_page,
|
||||||
|
token_count=token_count,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Chunked document into %d chunks (chunker=%s)", len(results), options.chunker_type)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _build_chunker(options: ChunkingOptions) -> HierarchicalChunker | HybridChunker:
|
||||||
|
if options.chunker_type == "hierarchical":
|
||||||
|
return HierarchicalChunker()
|
||||||
|
|
||||||
|
return HybridChunker(
|
||||||
|
max_tokens=options.max_tokens,
|
||||||
|
merge_peers=options.merge_peers,
|
||||||
|
repeat_table_header=options.repeat_table_header,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalChunker:
|
||||||
|
"""Adapter that runs docling-core chunking locally."""
|
||||||
|
|
||||||
|
async def chunk(
|
||||||
|
self,
|
||||||
|
document_json: str,
|
||||||
|
options: ChunkingOptions,
|
||||||
|
) -> list[ChunkResult]:
|
||||||
|
return await asyncio.to_thread(_chunk_sync, document_json, options)
|
||||||
|
|
@ -9,6 +9,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
|
|
@ -83,6 +84,7 @@ def _get_element_type(item: DocItem) -> str:
|
||||||
# Pipeline factory
|
# Pipeline factory
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _build_docling_converter(options: ConversionOptions) -> DoclingConverter:
|
def _build_docling_converter(options: ConversionOptions) -> DoclingConverter:
|
||||||
table_options = TableStructureOptions(
|
table_options = TableStructureOptions(
|
||||||
do_cell_matching=True,
|
do_cell_matching=True,
|
||||||
|
|
@ -126,6 +128,7 @@ def _select_converter(options: ConversionOptions) -> DoclingConverter:
|
||||||
# Page extraction
|
# Page extraction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
|
def _extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
|
||||||
pages: dict[int, PageDetail] = {}
|
pages: dict[int, PageDetail] = {}
|
||||||
document = doc_result.document
|
document = doc_result.document
|
||||||
|
|
@ -149,7 +152,9 @@ def _extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
|
||||||
|
|
||||||
|
|
||||||
def _process_content_item(
|
def _process_content_item(
|
||||||
item: DocItem | GroupItem, level: int, pages: dict[int, PageDetail],
|
item: DocItem | GroupItem,
|
||||||
|
level: int,
|
||||||
|
pages: dict[int, PageDetail],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if isinstance(item, GroupItem):
|
if isinstance(item, GroupItem):
|
||||||
return True
|
return True
|
||||||
|
|
@ -163,9 +168,13 @@ def _process_content_item(
|
||||||
if page_no not in pages:
|
if page_no not in pages:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Page %d not found in document metadata — using US Letter fallback (%sx%s pt)",
|
"Page %d not found in document metadata — using US Letter fallback (%sx%s pt)",
|
||||||
page_no, _DEFAULT_PAGE_WIDTH, _DEFAULT_PAGE_HEIGHT,
|
page_no,
|
||||||
|
_DEFAULT_PAGE_WIDTH,
|
||||||
|
_DEFAULT_PAGE_HEIGHT,
|
||||||
|
)
|
||||||
|
pages[page_no] = PageDetail(
|
||||||
|
page_number=page_no, width=_DEFAULT_PAGE_WIDTH, height=_DEFAULT_PAGE_HEIGHT
|
||||||
)
|
)
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -199,6 +208,7 @@ def _process_content_item(
|
||||||
# Synchronous conversion (called via asyncio.to_thread)
|
# Synchronous conversion (called via asyncio.to_thread)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResult:
|
def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResult:
|
||||||
with _converter_lock:
|
with _converter_lock:
|
||||||
conv = _select_converter(options)
|
conv = _select_converter(options)
|
||||||
|
|
@ -213,7 +223,9 @@ def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResul
|
||||||
PageDetail(
|
PageDetail(
|
||||||
page_number=i + 1,
|
page_number=i + 1,
|
||||||
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else _DEFAULT_PAGE_WIDTH,
|
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,
|
height=doc.pages[i + 1].size.height
|
||||||
|
if (i + 1) in doc.pages
|
||||||
|
else _DEFAULT_PAGE_HEIGHT,
|
||||||
)
|
)
|
||||||
for i in range(page_count)
|
for i in range(page_count)
|
||||||
]
|
]
|
||||||
|
|
@ -227,6 +239,7 @@ def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResul
|
||||||
content_html=doc.export_to_html(),
|
content_html=doc.export_to_html(),
|
||||||
pages=pages_detail,
|
pages=pages_detail,
|
||||||
skipped_items=skipped,
|
skipped_items=skipped,
|
||||||
|
document_json=json.dumps(doc.export_to_dict()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -234,10 +247,13 @@ def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResul
|
||||||
# Public adapter class
|
# Public adapter class
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class LocalConverter:
|
class LocalConverter:
|
||||||
"""Adapter that runs Docling locally as a Python library."""
|
"""Adapter that runs Docling locally as a Python library."""
|
||||||
|
|
||||||
async def convert(
|
async def convert(
|
||||||
self, file_path: str, options: ConversionOptions,
|
self,
|
||||||
|
file_path: str,
|
||||||
|
options: ConversionOptions,
|
||||||
) -> ConversionResult:
|
) -> ConversionResult:
|
||||||
return await asyncio.to_thread(_convert_sync, file_path, options)
|
return await asyncio.to_thread(_convert_sync, file_path, options)
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,9 @@ class ServeConverter:
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
async def convert(
|
async def convert(
|
||||||
self, file_path: str, options: ConversionOptions,
|
self,
|
||||||
|
file_path: str,
|
||||||
|
options: ConversionOptions,
|
||||||
) -> ConversionResult:
|
) -> ConversionResult:
|
||||||
"""Convert a document by uploading it to Docling Serve."""
|
"""Convert a document by uploading it to Docling Serve."""
|
||||||
path = Path(file_path)
|
path = Path(file_path)
|
||||||
|
|
@ -202,7 +204,9 @@ def _add_element(item: dict, pages: dict[int, PageDetail]) -> None:
|
||||||
page_no = prov.get("page_no", 1)
|
page_no = prov.get("page_no", 1)
|
||||||
if page_no not in pages:
|
if page_no not in pages:
|
||||||
pages[page_no] = PageDetail(
|
pages[page_no] = PageDetail(
|
||||||
page_number=page_no, width=612.0, height=792.0,
|
page_number=page_no,
|
||||||
|
width=612.0,
|
||||||
|
height=792.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
bbox_data = prov.get("bbox", {})
|
bbox_data = prov.get("bbox", {})
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ class Settings:
|
||||||
conversion_timeout: int = 600
|
conversion_timeout: int = 600
|
||||||
upload_dir: str = "./uploads"
|
upload_dir: str = "./uploads"
|
||||||
db_path: str = "./data/docling_studio.db"
|
db_path: str = "./data/docling_studio.db"
|
||||||
cors_origins: list[str] = field(default_factory=lambda: ["http://localhost:3000", "http://localhost:5173"])
|
cors_origins: list[str] = field(
|
||||||
|
default_factory=lambda: ["http://localhost:3000", "http://localhost:5173"]
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> Settings:
|
def from_env(cls) -> Settings:
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ def _build_converter():
|
||||||
"""Build the converter adapter based on configuration."""
|
"""Build the converter adapter based on configuration."""
|
||||||
if settings.conversion_engine == "remote":
|
if settings.conversion_engine == "remote":
|
||||||
from infra.serve_converter import ServeConverter
|
from infra.serve_converter import ServeConverter
|
||||||
|
|
||||||
logger.info("Using remote Docling Serve at %s", settings.docling_serve_url)
|
logger.info("Using remote Docling Serve at %s", settings.docling_serve_url)
|
||||||
return ServeConverter(
|
return ServeConverter(
|
||||||
base_url=settings.docling_serve_url,
|
base_url=settings.docling_serve_url,
|
||||||
|
|
@ -47,14 +48,26 @@ def _build_converter():
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
from infra.local_converter import LocalConverter
|
from infra.local_converter import LocalConverter
|
||||||
|
|
||||||
logger.info("Using local Docling converter")
|
logger.info("Using local Docling converter")
|
||||||
return LocalConverter()
|
return LocalConverter()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_chunker():
|
||||||
|
"""Build the chunker adapter — only available in local mode."""
|
||||||
|
if settings.conversion_engine == "local":
|
||||||
|
from infra.local_chunker import LocalChunker
|
||||||
|
|
||||||
|
return LocalChunker()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _build_analysis_service() -> AnalysisService:
|
def _build_analysis_service() -> AnalysisService:
|
||||||
converter = _build_converter()
|
converter = _build_converter()
|
||||||
|
chunker = _build_chunker()
|
||||||
return AnalysisService(
|
return AnalysisService(
|
||||||
converter=converter,
|
converter=converter,
|
||||||
|
chunker=chunker,
|
||||||
conversion_timeout=settings.conversion_timeout,
|
conversion_timeout=settings.conversion_timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -63,6 +76,7 @@ def _build_analysis_service() -> AnalysisService:
|
||||||
# FastAPI app
|
# FastAPI app
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
await init_db()
|
await init_db()
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ def _parse_dt(value: str | None) -> datetime | None:
|
||||||
|
|
||||||
|
|
||||||
def _row_to_job(row) -> AnalysisJob:
|
def _row_to_job(row) -> AnalysisJob:
|
||||||
|
keys = row.keys()
|
||||||
return AnalysisJob(
|
return AnalysisJob(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
document_id=row["document_id"],
|
document_id=row["document_id"],
|
||||||
|
|
@ -23,11 +24,13 @@ def _row_to_job(row) -> AnalysisJob:
|
||||||
content_markdown=row["content_markdown"],
|
content_markdown=row["content_markdown"],
|
||||||
content_html=row["content_html"],
|
content_html=row["content_html"],
|
||||||
pages_json=row["pages_json"],
|
pages_json=row["pages_json"],
|
||||||
|
document_json=row["document_json"] if "document_json" in keys else None,
|
||||||
|
chunks_json=row["chunks_json"] if "chunks_json" in keys else None,
|
||||||
error_message=row["error_message"],
|
error_message=row["error_message"],
|
||||||
started_at=_parse_dt(row["started_at"]),
|
started_at=_parse_dt(row["started_at"]),
|
||||||
completed_at=_parse_dt(row["completed_at"]),
|
completed_at=_parse_dt(row["completed_at"]),
|
||||||
created_at=_parse_dt(row["created_at"]) or datetime.now(),
|
created_at=_parse_dt(row["created_at"]) or datetime.now(),
|
||||||
document_filename=row["filename"] if "filename" in row.keys() else None, # noqa: SIM118
|
document_filename=row["filename"] if "filename" in keys else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -60,9 +63,7 @@ async def find_all(*, limit: int = 200, offset: int = 0) -> list[AnalysisJob]:
|
||||||
|
|
||||||
async def find_by_id(job_id: str) -> AnalysisJob | None:
|
async def find_by_id(job_id: str) -> AnalysisJob | None:
|
||||||
async with get_connection() as db:
|
async with get_connection() as db:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(f"{_SELECT_WITH_DOC} WHERE aj.id = ?", (job_id,))
|
||||||
f"{_SELECT_WITH_DOC} WHERE aj.id = ?", (job_id,)
|
|
||||||
)
|
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
return _row_to_job(row) if row else None
|
return _row_to_job(row) if row else None
|
||||||
|
|
||||||
|
|
@ -72,17 +73,36 @@ async def update_status(job: AnalysisJob) -> None:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""UPDATE analysis_jobs
|
"""UPDATE analysis_jobs
|
||||||
SET status = ?, content_markdown = ?, content_html = ?,
|
SET status = ?, content_markdown = ?, content_html = ?,
|
||||||
pages_json = ?, error_message = ?, started_at = ?, completed_at = ?
|
pages_json = ?, document_json = ?, chunks_json = ?,
|
||||||
|
error_message = ?, started_at = ?, completed_at = ?
|
||||||
WHERE id = ?""",
|
WHERE id = ?""",
|
||||||
(job.status.value, job.content_markdown, job.content_html,
|
(
|
||||||
job.pages_json, job.error_message,
|
job.status.value,
|
||||||
str(job.started_at) if job.started_at else None,
|
job.content_markdown,
|
||||||
str(job.completed_at) if job.completed_at else None,
|
job.content_html,
|
||||||
job.id),
|
job.pages_json,
|
||||||
|
job.document_json,
|
||||||
|
job.chunks_json,
|
||||||
|
job.error_message,
|
||||||
|
str(job.started_at) if job.started_at else None,
|
||||||
|
str(job.completed_at) if job.completed_at else None,
|
||||||
|
job.id,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_chunks(job_id: str, chunks_json: str) -> bool:
|
||||||
|
"""Update only the chunks_json column for a completed analysis."""
|
||||||
|
async with get_connection() as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"UPDATE analysis_jobs SET chunks_json = ? WHERE id = ?",
|
||||||
|
(chunks_json, job_id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
async def delete(job_id: str) -> bool:
|
async def delete(job_id: str) -> bool:
|
||||||
async with get_connection() as db:
|
async with get_connection() as db:
|
||||||
cursor = await db.execute("DELETE FROM analysis_jobs WHERE id = ?", (job_id,))
|
cursor = await db.execute("DELETE FROM analysis_jobs WHERE id = ?", (job_id,))
|
||||||
|
|
@ -93,8 +113,6 @@ async def delete(job_id: str) -> bool:
|
||||||
async def delete_by_document(document_id: str) -> int:
|
async def delete_by_document(document_id: str) -> int:
|
||||||
"""Delete all analysis jobs for a given document. Returns count deleted."""
|
"""Delete all analysis jobs for a given document. Returns count deleted."""
|
||||||
async with get_connection() as db:
|
async with get_connection() as db:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute("DELETE FROM analysis_jobs WHERE document_id = ?", (document_id,))
|
||||||
"DELETE FROM analysis_jobs WHERE document_id = ?", (document_id,)
|
|
||||||
)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return cursor.rowcount
|
return cursor.rowcount
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ CREATE TABLE IF NOT EXISTS analysis_jobs (
|
||||||
content_markdown TEXT,
|
content_markdown TEXT,
|
||||||
content_html TEXT,
|
content_html TEXT,
|
||||||
pages_json TEXT,
|
pages_json TEXT,
|
||||||
|
document_json TEXT,
|
||||||
|
chunks_json TEXT,
|
||||||
error_message TEXT,
|
error_message TEXT,
|
||||||
started_at TEXT,
|
started_at TEXT,
|
||||||
completed_at TEXT,
|
completed_at TEXT,
|
||||||
|
|
@ -42,11 +44,29 @@ CREATE INDEX IF NOT EXISTS idx_documents_created_at ON documents(created_at);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_MIGRATIONS = [
|
||||||
|
("document_json", "ALTER TABLE analysis_jobs ADD COLUMN document_json TEXT"),
|
||||||
|
("chunks_json", "ALTER TABLE analysis_jobs ADD COLUMN chunks_json TEXT"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_migrations(db: aiosqlite.Connection) -> None:
|
||||||
|
"""Add columns that may be missing in older databases."""
|
||||||
|
cursor = await db.execute("PRAGMA table_info(analysis_jobs)")
|
||||||
|
existing = {row[1] for row in await cursor.fetchall()}
|
||||||
|
for col_name, ddl in _MIGRATIONS:
|
||||||
|
if col_name not in existing:
|
||||||
|
await db.execute(ddl)
|
||||||
|
logger.info("Migration: added column %s to analysis_jobs", col_name)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def init_db() -> None:
|
async def init_db() -> None:
|
||||||
"""Create database file and tables if they don't exist."""
|
"""Create database file and tables if they don't exist."""
|
||||||
os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True)
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
await db.executescript(_SCHEMA)
|
await db.executescript(_SCHEMA)
|
||||||
|
await _run_migrations(db)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("Database initialized at %s", DB_PATH)
|
logger.info("Database initialized at %s", DB_PATH)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,15 @@ async def insert(doc: Document) -> None:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""INSERT INTO documents (id, filename, content_type, file_size, page_count, storage_path, created_at)
|
"""INSERT INTO documents (id, filename, content_type, file_size, page_count, storage_path, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(doc.id, doc.filename, doc.content_type, doc.file_size,
|
(
|
||||||
doc.page_count, doc.storage_path, str(doc.created_at)),
|
doc.id,
|
||||||
|
doc.filename,
|
||||||
|
doc.content_type,
|
||||||
|
doc.file_size,
|
||||||
|
doc.page_count,
|
||||||
|
doc.storage_path,
|
||||||
|
str(doc.created_at),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,24 +13,46 @@ import logging
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from domain.models import AnalysisJob
|
from domain.models import AnalysisJob, AnalysisStatus
|
||||||
from domain.value_objects import ConversionOptions, ConversionResult
|
from domain.value_objects import ChunkingOptions, ChunkResult, ConversionOptions, ConversionResult
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from domain.ports import DocumentConverter
|
from domain.ports import DocumentChunker, DocumentConverter
|
||||||
from persistence import analysis_repo, document_repo
|
from persistence import analysis_repo, document_repo
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_to_dict(c: ChunkResult) -> dict:
|
||||||
|
"""Serialize ChunkResult to a camelCase dict matching the frontend API contract."""
|
||||||
|
return {
|
||||||
|
"text": c.text,
|
||||||
|
"headings": c.headings,
|
||||||
|
"sourcePage": c.source_page,
|
||||||
|
"tokenCount": c.token_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class AnalysisService:
|
class AnalysisService:
|
||||||
"""Orchestrates document analysis using an injected converter."""
|
"""Orchestrates document analysis using an injected converter."""
|
||||||
|
|
||||||
def __init__(self, converter: DocumentConverter, conversion_timeout: int = 600):
|
def __init__(
|
||||||
|
self,
|
||||||
|
converter: DocumentConverter,
|
||||||
|
chunker: DocumentChunker | None = None,
|
||||||
|
conversion_timeout: int = 600,
|
||||||
|
):
|
||||||
self._converter = converter
|
self._converter = converter
|
||||||
|
self._chunker = chunker
|
||||||
self._conversion_timeout = conversion_timeout
|
self._conversion_timeout = conversion_timeout
|
||||||
|
|
||||||
async def create(self, document_id: str, *, pipeline_options: dict | None = None) -> AnalysisJob:
|
async def create(
|
||||||
|
self,
|
||||||
|
document_id: str,
|
||||||
|
*,
|
||||||
|
pipeline_options: dict | None = None,
|
||||||
|
chunking_options: dict | None = None,
|
||||||
|
) -> AnalysisJob:
|
||||||
"""Create a new analysis job and launch background processing."""
|
"""Create a new analysis job and launch background processing."""
|
||||||
doc = await document_repo.find_by_id(document_id)
|
doc = await document_repo.find_by_id(document_id)
|
||||||
if not doc:
|
if not doc:
|
||||||
|
|
@ -41,7 +63,13 @@ class AnalysisService:
|
||||||
await analysis_repo.insert(job)
|
await analysis_repo.insert(job)
|
||||||
|
|
||||||
task = asyncio.create_task(
|
task = asyncio.create_task(
|
||||||
self._run_analysis(job.id, doc.storage_path, doc.filename, pipeline_options)
|
self._run_analysis(
|
||||||
|
job.id,
|
||||||
|
doc.storage_path,
|
||||||
|
doc.filename,
|
||||||
|
pipeline_options,
|
||||||
|
chunking_options,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
task.add_done_callback(functools.partial(_on_task_done, job_id=job.id))
|
task.add_done_callback(functools.partial(_on_task_done, job_id=job.id))
|
||||||
|
|
||||||
|
|
@ -56,10 +84,35 @@ class AnalysisService:
|
||||||
async def delete(self, job_id: str) -> bool:
|
async def delete(self, job_id: str) -> bool:
|
||||||
return await analysis_repo.delete(job_id)
|
return await analysis_repo.delete(job_id)
|
||||||
|
|
||||||
|
async def rechunk(self, job_id: str, chunking_options: dict) -> list[ChunkResult]:
|
||||||
|
"""Re-chunk an existing completed analysis with new options."""
|
||||||
|
job = await analysis_repo.find_by_id(job_id)
|
||||||
|
if not job:
|
||||||
|
raise ValueError(f"Analysis not found: {job_id}")
|
||||||
|
if job.status != AnalysisStatus.COMPLETED:
|
||||||
|
raise ValueError(f"Analysis is not completed: {job_id}")
|
||||||
|
if not job.document_json:
|
||||||
|
raise ValueError(f"No document data available for re-chunking: {job_id}")
|
||||||
|
if not self._chunker:
|
||||||
|
raise ValueError("Chunking is not available")
|
||||||
|
|
||||||
|
options = ChunkingOptions(**chunking_options)
|
||||||
|
chunks = await self._chunker.chunk(job.document_json, options)
|
||||||
|
|
||||||
|
chunks_json = json.dumps([_chunk_to_dict(c) for c in chunks])
|
||||||
|
await analysis_repo.update_chunks(job_id, chunks_json)
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
async def _run_analysis(
|
async def _run_analysis(
|
||||||
self, job_id: str, file_path: str, filename: str, pipeline_options: dict | None = None,
|
self,
|
||||||
|
job_id: str,
|
||||||
|
file_path: str,
|
||||||
|
filename: str,
|
||||||
|
pipeline_options: dict | None = None,
|
||||||
|
chunking_options: dict | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Background task: run conversion and update job status."""
|
"""Background task: run conversion and optionally chunk."""
|
||||||
try:
|
try:
|
||||||
job = await analysis_repo.find_by_id(job_id)
|
job = await analysis_repo.find_by_id(job_id)
|
||||||
if not job:
|
if not job:
|
||||||
|
|
@ -79,10 +132,19 @@ class AnalysisService:
|
||||||
|
|
||||||
pages_json = json.dumps([asdict(p) for p in result.pages])
|
pages_json = json.dumps([asdict(p) for p in result.pages])
|
||||||
|
|
||||||
|
chunks_json = None
|
||||||
|
if chunking_options and self._chunker and result.document_json:
|
||||||
|
chunk_opts = ChunkingOptions(**chunking_options)
|
||||||
|
chunks = await self._chunker.chunk(result.document_json, chunk_opts)
|
||||||
|
chunks_json = json.dumps([_chunk_to_dict(c) for c in chunks])
|
||||||
|
logger.info("Chunking produced %d chunks for job %s", len(chunks), job_id)
|
||||||
|
|
||||||
job.mark_completed(
|
job.mark_completed(
|
||||||
markdown=result.content_markdown,
|
markdown=result.content_markdown,
|
||||||
html=result.content_html,
|
html=result.content_html,
|
||||||
pages_json=pages_json,
|
pages_json=pages_json,
|
||||||
|
document_json=result.document_json,
|
||||||
|
chunks_json=chunks_json,
|
||||||
)
|
)
|
||||||
await analysis_repo.update_status(job)
|
await analysis_repo.update_status(job)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,12 @@ class TestDocumentEndpoints:
|
||||||
@patch("services.document_service.find_by_id", new_callable=AsyncMock)
|
@patch("services.document_service.find_by_id", new_callable=AsyncMock)
|
||||||
def test_get_document(self, mock_find, client):
|
def test_get_document(self, mock_find, client):
|
||||||
mock_find.return_value = Document(
|
mock_find.return_value = Document(
|
||||||
id="d1", filename="test.pdf", content_type="application/pdf",
|
id="d1",
|
||||||
file_size=2048, page_count=3, storage_path="/tmp/test",
|
filename="test.pdf",
|
||||||
|
content_type="application/pdf",
|
||||||
|
file_size=2048,
|
||||||
|
page_count=3,
|
||||||
|
storage_path="/tmp/test",
|
||||||
)
|
)
|
||||||
|
|
||||||
resp = client.get("/api/documents/d1")
|
resp = client.get("/api/documents/d1")
|
||||||
|
|
@ -74,8 +78,10 @@ class TestDocumentEndpoints:
|
||||||
@patch("services.document_service.upload", new_callable=AsyncMock)
|
@patch("services.document_service.upload", new_callable=AsyncMock)
|
||||||
def test_upload_document(self, mock_upload, client):
|
def test_upload_document(self, mock_upload, client):
|
||||||
mock_upload.return_value = Document(
|
mock_upload.return_value = Document(
|
||||||
id="new-1", filename="uploaded.pdf",
|
id="new-1",
|
||||||
content_type="application/pdf", file_size=512,
|
filename="uploaded.pdf",
|
||||||
|
content_type="application/pdf",
|
||||||
|
file_size=512,
|
||||||
storage_path="/tmp/uploaded",
|
storage_path="/tmp/uploaded",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -115,9 +121,11 @@ class TestDocumentEndpoints:
|
||||||
|
|
||||||
class TestAnalysisEndpoints:
|
class TestAnalysisEndpoints:
|
||||||
def test_list_analyses(self, client, mock_analysis_service):
|
def test_list_analyses(self, client, mock_analysis_service):
|
||||||
mock_analysis_service.find_all = AsyncMock(return_value=[
|
mock_analysis_service.find_all = AsyncMock(
|
||||||
AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"),
|
return_value=[
|
||||||
])
|
AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
resp = client.get("/api/analyses")
|
resp = client.get("/api/analyses")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
@ -146,37 +154,52 @@ class TestAnalysisEndpoints:
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
def test_create_analysis(self, client, mock_analysis_service):
|
def test_create_analysis(self, client, mock_analysis_service):
|
||||||
mock_analysis_service.create = AsyncMock(return_value=AnalysisJob(
|
mock_analysis_service.create = AsyncMock(
|
||||||
id="j1", document_id="d1", document_filename="test.pdf",
|
return_value=AnalysisJob(
|
||||||
))
|
id="j1",
|
||||||
|
document_id="d1",
|
||||||
|
document_filename="test.pdf",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
resp = client.post("/api/analyses", json={"documentId": "d1"})
|
resp = client.post("/api/analyses", json={"documentId": "d1"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["id"] == "j1"
|
assert data["id"] == "j1"
|
||||||
assert data["documentId"] == "d1"
|
assert data["documentId"] == "d1"
|
||||||
mock_analysis_service.create.assert_called_once_with("d1", pipeline_options=None)
|
mock_analysis_service.create.assert_called_once_with(
|
||||||
|
"d1",
|
||||||
|
pipeline_options=None,
|
||||||
|
chunking_options=None,
|
||||||
|
)
|
||||||
|
|
||||||
def test_create_analysis_with_pipeline_options(self, client, mock_analysis_service):
|
def test_create_analysis_with_pipeline_options(self, client, mock_analysis_service):
|
||||||
mock_analysis_service.create = AsyncMock(return_value=AnalysisJob(
|
mock_analysis_service.create = AsyncMock(
|
||||||
id="j2", document_id="d1", document_filename="test.pdf",
|
return_value=AnalysisJob(
|
||||||
))
|
id="j2",
|
||||||
|
document_id="d1",
|
||||||
|
document_filename="test.pdf",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
resp = client.post("/api/analyses", json={
|
resp = client.post(
|
||||||
"documentId": "d1",
|
"/api/analyses",
|
||||||
"pipelineOptions": {
|
json={
|
||||||
"do_ocr": False,
|
"documentId": "d1",
|
||||||
"do_table_structure": True,
|
"pipelineOptions": {
|
||||||
"table_mode": "fast",
|
"do_ocr": False,
|
||||||
"do_code_enrichment": True,
|
"do_table_structure": True,
|
||||||
"do_formula_enrichment": False,
|
"table_mode": "fast",
|
||||||
"do_picture_classification": False,
|
"do_code_enrichment": True,
|
||||||
"do_picture_description": False,
|
"do_formula_enrichment": False,
|
||||||
"generate_picture_images": True,
|
"do_picture_classification": False,
|
||||||
"generate_page_images": False,
|
"do_picture_description": False,
|
||||||
"images_scale": 2.0,
|
"generate_picture_images": True,
|
||||||
}
|
"generate_page_images": False,
|
||||||
})
|
"images_scale": 2.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["id"] == "j2"
|
assert data["id"] == "j2"
|
||||||
|
|
@ -191,14 +214,17 @@ class TestAnalysisEndpoints:
|
||||||
|
|
||||||
def test_create_analysis_with_partial_pipeline_options(self, client, mock_analysis_service):
|
def test_create_analysis_with_partial_pipeline_options(self, client, mock_analysis_service):
|
||||||
"""Pipeline options should use defaults for unspecified fields."""
|
"""Pipeline options should use defaults for unspecified fields."""
|
||||||
mock_analysis_service.create = AsyncMock(return_value=AnalysisJob(
|
mock_analysis_service.create = AsyncMock(
|
||||||
id="j3", document_id="d1", document_filename="test.pdf",
|
return_value=AnalysisJob(
|
||||||
))
|
id="j3",
|
||||||
|
document_id="d1",
|
||||||
|
document_filename="test.pdf",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
resp = client.post("/api/analyses", json={
|
resp = client.post(
|
||||||
"documentId": "d1",
|
"/api/analyses", json={"documentId": "d1", "pipelineOptions": {"do_ocr": False}}
|
||||||
"pipelineOptions": {"do_ocr": False}
|
)
|
||||||
})
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
opts = mock_analysis_service.create.call_args.kwargs["pipeline_options"]
|
opts = mock_analysis_service.create.call_args.kwargs["pipeline_options"]
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from domain.bbox import EMPTY_BBOX, to_topleft_list
|
||||||
# Standard conversions
|
# Standard conversions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestToTopleftListStandard:
|
class TestToTopleftListStandard:
|
||||||
"""Normal bbox conversions (happy path)."""
|
"""Normal bbox conversions (happy path)."""
|
||||||
|
|
||||||
|
|
@ -29,10 +30,10 @@ class TestToTopleftListStandard:
|
||||||
result = to_topleft_list(bbox, page_height=792.0)
|
result = to_topleft_list(bbox, page_height=792.0)
|
||||||
|
|
||||||
# After conversion: new_t = 792 - 700 = 92, new_b = 792 - 600 = 192
|
# After conversion: new_t = 792 - 700 = 92, new_b = 792 - 600 = 192
|
||||||
assert result[0] == 50 # l unchanged
|
assert result[0] == 50 # l unchanged
|
||||||
assert result[1] == pytest.approx(92.0) # t = page_height - old_t
|
assert result[1] == pytest.approx(92.0) # t = page_height - old_t
|
||||||
assert result[2] == 200 # r unchanged
|
assert result[2] == 200 # r unchanged
|
||||||
assert result[3] == pytest.approx(192.0) # b = page_height - old_b
|
assert result[3] == pytest.approx(192.0) # b = page_height - old_b
|
||||||
|
|
||||||
def test_result_has_positive_dimensions(self):
|
def test_result_has_positive_dimensions(self):
|
||||||
"""Converted bbox should always have b > t (positive height)."""
|
"""Converted bbox should always have b > t (positive height)."""
|
||||||
|
|
@ -60,6 +61,7 @@ class TestToTopleftListStandard:
|
||||||
# Page format variations
|
# Page format variations
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestPageFormats:
|
class TestPageFormats:
|
||||||
"""Verify correct conversion across different page sizes."""
|
"""Verify correct conversion across different page sizes."""
|
||||||
|
|
||||||
|
|
@ -105,6 +107,7 @@ class TestPageFormats:
|
||||||
# Degenerate / edge-case bboxes
|
# Degenerate / edge-case bboxes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestDegenerateBboxes:
|
class TestDegenerateBboxes:
|
||||||
"""Bboxes that are invalid or degenerate should return EMPTY_BBOX."""
|
"""Bboxes that are invalid or degenerate should return EMPTY_BBOX."""
|
||||||
|
|
||||||
|
|
@ -151,6 +154,7 @@ class TestDegenerateBboxes:
|
||||||
# Precision and boundary values
|
# Precision and boundary values
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestPrecision:
|
class TestPrecision:
|
||||||
"""Floating-point precision and edge values."""
|
"""Floating-point precision and edge values."""
|
||||||
|
|
||||||
|
|
|
||||||
292
document-parser/tests/test_chunking.py
Normal file
292
document-parser/tests/test_chunking.py
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
"""Tests for chunking feature — domain, schemas, service, and API endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import asdict
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from api.schemas import ChunkingOptionsRequest, ChunkResponse, RechunkRequest
|
||||||
|
from domain.models import AnalysisJob, AnalysisStatus
|
||||||
|
from domain.value_objects import ChunkingOptions, ChunkResult
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Domain: value objects
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestChunkingOptions:
|
||||||
|
def test_defaults(self):
|
||||||
|
opts = ChunkingOptions()
|
||||||
|
assert opts.chunker_type == "hybrid"
|
||||||
|
assert opts.max_tokens == 512
|
||||||
|
assert opts.merge_peers is True
|
||||||
|
assert opts.repeat_table_header is True
|
||||||
|
|
||||||
|
def test_custom_values(self):
|
||||||
|
opts = ChunkingOptions(chunker_type="hierarchical", max_tokens=256, merge_peers=False)
|
||||||
|
assert opts.chunker_type == "hierarchical"
|
||||||
|
assert opts.max_tokens == 256
|
||||||
|
assert opts.merge_peers is False
|
||||||
|
|
||||||
|
def test_is_default(self):
|
||||||
|
assert ChunkingOptions().is_default()
|
||||||
|
assert not ChunkingOptions(max_tokens=256).is_default()
|
||||||
|
|
||||||
|
|
||||||
|
class TestChunkResult:
|
||||||
|
def test_defaults(self):
|
||||||
|
chunk = ChunkResult(text="hello")
|
||||||
|
assert chunk.text == "hello"
|
||||||
|
assert chunk.headings == []
|
||||||
|
assert chunk.source_page is None
|
||||||
|
assert chunk.token_count == 0
|
||||||
|
|
||||||
|
def test_full_values(self):
|
||||||
|
chunk = ChunkResult(
|
||||||
|
text="content",
|
||||||
|
headings=["Title", "Section"],
|
||||||
|
source_page=3,
|
||||||
|
token_count=42,
|
||||||
|
)
|
||||||
|
assert chunk.headings == ["Title", "Section"]
|
||||||
|
assert chunk.source_page == 3
|
||||||
|
assert chunk.token_count == 42
|
||||||
|
|
||||||
|
def test_serializable(self):
|
||||||
|
chunk = ChunkResult(text="x", headings=["h1"], source_page=1, token_count=10)
|
||||||
|
data = asdict(chunk)
|
||||||
|
assert data == {"text": "x", "headings": ["h1"], "source_page": 1, "token_count": 10}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Domain: AnalysisJob with chunking fields
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalysisJobChunking:
|
||||||
|
def test_default_chunking_fields(self):
|
||||||
|
job = AnalysisJob()
|
||||||
|
assert job.document_json is None
|
||||||
|
assert job.chunks_json is None
|
||||||
|
|
||||||
|
def test_mark_completed_with_chunks(self):
|
||||||
|
job = AnalysisJob()
|
||||||
|
job.mark_running()
|
||||||
|
job.mark_completed(
|
||||||
|
markdown="# Title",
|
||||||
|
html="<h1>Title</h1>",
|
||||||
|
pages_json="[]",
|
||||||
|
document_json='{"name": "doc"}',
|
||||||
|
chunks_json='[{"text": "chunk1"}]',
|
||||||
|
)
|
||||||
|
assert job.status == AnalysisStatus.COMPLETED
|
||||||
|
assert job.document_json == '{"name": "doc"}'
|
||||||
|
assert job.chunks_json == '[{"text": "chunk1"}]'
|
||||||
|
|
||||||
|
def test_mark_completed_without_chunks(self):
|
||||||
|
job = AnalysisJob()
|
||||||
|
job.mark_running()
|
||||||
|
job.mark_completed(markdown="md", html="html", pages_json="[]")
|
||||||
|
assert job.document_json is None
|
||||||
|
assert job.chunks_json is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestChunkingOptionsRequest:
|
||||||
|
def test_defaults(self):
|
||||||
|
opts = ChunkingOptionsRequest()
|
||||||
|
assert opts.chunker_type == "hybrid"
|
||||||
|
assert opts.max_tokens == 512
|
||||||
|
assert opts.merge_peers is True
|
||||||
|
assert opts.repeat_table_header is True
|
||||||
|
|
||||||
|
def test_custom_values(self):
|
||||||
|
opts = ChunkingOptionsRequest(chunker_type="hierarchical", max_tokens=1024)
|
||||||
|
assert opts.chunker_type == "hierarchical"
|
||||||
|
assert opts.max_tokens == 1024
|
||||||
|
|
||||||
|
def test_invalid_chunker_type(self):
|
||||||
|
with pytest.raises(ValueError, match="chunker_type"):
|
||||||
|
ChunkingOptionsRequest(chunker_type="invalid")
|
||||||
|
|
||||||
|
def test_max_tokens_too_low(self):
|
||||||
|
with pytest.raises(ValueError, match="max_tokens"):
|
||||||
|
ChunkingOptionsRequest(max_tokens=10)
|
||||||
|
|
||||||
|
def test_max_tokens_too_high(self):
|
||||||
|
with pytest.raises(ValueError, match="max_tokens"):
|
||||||
|
ChunkingOptionsRequest(max_tokens=10000)
|
||||||
|
|
||||||
|
def test_boundary_max_tokens(self):
|
||||||
|
opts_low = ChunkingOptionsRequest(max_tokens=64)
|
||||||
|
assert opts_low.max_tokens == 64
|
||||||
|
opts_high = ChunkingOptionsRequest(max_tokens=8192)
|
||||||
|
assert opts_high.max_tokens == 8192
|
||||||
|
|
||||||
|
|
||||||
|
class TestChunkResponse:
|
||||||
|
def test_serializes_to_camel_case(self):
|
||||||
|
resp = ChunkResponse(text="hello", headings=["H1"], source_page=1, token_count=5)
|
||||||
|
data = resp.model_dump(by_alias=True)
|
||||||
|
assert "sourcePage" in data
|
||||||
|
assert "tokenCount" in data
|
||||||
|
assert data["text"] == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRechunkRequest:
|
||||||
|
def test_parses(self):
|
||||||
|
req = RechunkRequest(chunkingOptions=ChunkingOptionsRequest(max_tokens=256))
|
||||||
|
assert req.chunkingOptions.max_tokens == 256
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# API endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
return TestClient(app, raise_server_exceptions=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_analysis_service(client):
|
||||||
|
mock_svc = MagicMock()
|
||||||
|
original = getattr(app.state, "analysis_service", None)
|
||||||
|
app.state.analysis_service = mock_svc
|
||||||
|
yield mock_svc
|
||||||
|
app.state.analysis_service = original
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateAnalysisWithChunking:
|
||||||
|
def test_create_with_chunking_options(self, client, mock_analysis_service):
|
||||||
|
mock_analysis_service.create = AsyncMock(
|
||||||
|
return_value=AnalysisJob(
|
||||||
|
id="j1",
|
||||||
|
document_id="d1",
|
||||||
|
document_filename="test.pdf",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/analyses",
|
||||||
|
json={
|
||||||
|
"documentId": "d1",
|
||||||
|
"chunkingOptions": {
|
||||||
|
"chunker_type": "hybrid",
|
||||||
|
"max_tokens": 256,
|
||||||
|
"merge_peers": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
call_kwargs = mock_analysis_service.create.call_args
|
||||||
|
chunking = call_kwargs.kwargs["chunking_options"]
|
||||||
|
assert chunking["chunker_type"] == "hybrid"
|
||||||
|
assert chunking["max_tokens"] == 256
|
||||||
|
assert chunking["merge_peers"] is False
|
||||||
|
|
||||||
|
def test_create_without_chunking_options(self, client, mock_analysis_service):
|
||||||
|
mock_analysis_service.create = AsyncMock(
|
||||||
|
return_value=AnalysisJob(
|
||||||
|
id="j1",
|
||||||
|
document_id="d1",
|
||||||
|
document_filename="test.pdf",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post("/api/analyses", json={"documentId": "d1"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
call_kwargs = mock_analysis_service.create.call_args
|
||||||
|
assert call_kwargs.kwargs["chunking_options"] is None
|
||||||
|
|
||||||
|
def test_response_includes_chunking_fields(self, client, mock_analysis_service):
|
||||||
|
job = AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
|
||||||
|
job.mark_running()
|
||||||
|
job.mark_completed(
|
||||||
|
markdown="# Title",
|
||||||
|
html="<h1>Title</h1>",
|
||||||
|
pages_json="[]",
|
||||||
|
document_json='{"name": "doc"}',
|
||||||
|
chunks_json=json.dumps([asdict(ChunkResult(text="chunk1", token_count=5))]),
|
||||||
|
)
|
||||||
|
mock_analysis_service.find_by_id = AsyncMock(return_value=job)
|
||||||
|
|
||||||
|
resp = client.get("/api/analyses/j1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["hasDocumentJson"] is True
|
||||||
|
assert data["chunksJson"] is not None
|
||||||
|
chunks = json.loads(data["chunksJson"])
|
||||||
|
assert len(chunks) == 1
|
||||||
|
assert chunks[0]["text"] == "chunk1"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRechunkEndpoint:
|
||||||
|
def test_rechunk_success(self, client, mock_analysis_service):
|
||||||
|
mock_analysis_service.rechunk = AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
ChunkResult(text="chunk1", headings=["H1"], source_page=1, token_count=10),
|
||||||
|
ChunkResult(text="chunk2", headings=["H1", "H2"], source_page=2, token_count=20),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/analyses/j1/rechunk",
|
||||||
|
json={
|
||||||
|
"chunkingOptions": {"chunker_type": "hybrid", "max_tokens": 128},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 2
|
||||||
|
assert data[0]["text"] == "chunk1"
|
||||||
|
assert data[0]["sourcePage"] == 1
|
||||||
|
assert data[0]["tokenCount"] == 10
|
||||||
|
assert data[1]["headings"] == ["H1", "H2"]
|
||||||
|
|
||||||
|
def test_rechunk_not_completed(self, client, mock_analysis_service):
|
||||||
|
mock_analysis_service.rechunk = AsyncMock(
|
||||||
|
side_effect=ValueError("Analysis is not completed: j1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/analyses/j1/rechunk",
|
||||||
|
json={
|
||||||
|
"chunkingOptions": {"chunker_type": "hybrid"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_rechunk_no_document_json(self, client, mock_analysis_service):
|
||||||
|
mock_analysis_service.rechunk = AsyncMock(
|
||||||
|
side_effect=ValueError("No document data available for re-chunking: j1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/analyses/j1/rechunk",
|
||||||
|
json={
|
||||||
|
"chunkingOptions": {"chunker_type": "hierarchical"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_rechunk_invalid_chunker_type(self, client, mock_analysis_service):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/analyses/j1/rechunk",
|
||||||
|
json={
|
||||||
|
"chunkingOptions": {"chunker_type": "invalid"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
@ -23,6 +23,7 @@ from infra.local_converter import (
|
||||||
# build_converter — verifies Docling pipeline options are wired correctly
|
# build_converter — verifies Docling pipeline options are wired correctly
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestBuildConverter:
|
class TestBuildConverter:
|
||||||
"""Verify that build_converter produces a DocumentConverter with the right PdfPipelineOptions."""
|
"""Verify that build_converter produces a DocumentConverter with the right PdfPipelineOptions."""
|
||||||
|
|
||||||
|
|
@ -101,18 +102,20 @@ class TestBuildConverter:
|
||||||
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(ConversionOptions(
|
conv = build_converter(
|
||||||
do_ocr=False,
|
ConversionOptions(
|
||||||
do_table_structure=True,
|
do_ocr=False,
|
||||||
table_mode="fast",
|
do_table_structure=True,
|
||||||
do_code_enrichment=True,
|
table_mode="fast",
|
||||||
do_formula_enrichment=True,
|
do_code_enrichment=True,
|
||||||
do_picture_classification=True,
|
do_formula_enrichment=True,
|
||||||
do_picture_description=True,
|
do_picture_classification=True,
|
||||||
generate_picture_images=True,
|
do_picture_description=True,
|
||||||
generate_page_images=True,
|
generate_picture_images=True,
|
||||||
images_scale=1.5,
|
generate_page_images=True,
|
||||||
))
|
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
|
||||||
|
|
@ -130,6 +133,7 @@ class TestBuildConverter:
|
||||||
# convert_document — default vs custom converter routing
|
# convert_document — default vs custom converter routing
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestConvertDocumentRouting:
|
class TestConvertDocumentRouting:
|
||||||
"""Verify convert_document uses default converter for default opts, custom otherwise."""
|
"""Verify convert_document uses default converter for default opts, custom otherwise."""
|
||||||
|
|
||||||
|
|
@ -142,6 +146,7 @@ class TestConvertDocumentRouting:
|
||||||
mock_result.document.iterate_items.return_value = []
|
mock_result.document.iterate_items.return_value = []
|
||||||
mock_result.document.export_to_markdown.return_value = ""
|
mock_result.document.export_to_markdown.return_value = ""
|
||||||
mock_result.document.export_to_html.return_value = ""
|
mock_result.document.export_to_html.return_value = ""
|
||||||
|
mock_result.document.export_to_dict.return_value = {}
|
||||||
mock_conv.convert.return_value = mock_result
|
mock_conv.convert.return_value = mock_result
|
||||||
mock_get_default.return_value = mock_conv
|
mock_get_default.return_value = mock_conv
|
||||||
|
|
||||||
|
|
@ -159,6 +164,7 @@ class TestConvertDocumentRouting:
|
||||||
mock_result.document.iterate_items.return_value = []
|
mock_result.document.iterate_items.return_value = []
|
||||||
mock_result.document.export_to_markdown.return_value = ""
|
mock_result.document.export_to_markdown.return_value = ""
|
||||||
mock_result.document.export_to_html.return_value = ""
|
mock_result.document.export_to_html.return_value = ""
|
||||||
|
mock_result.document.export_to_dict.return_value = {}
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -176,6 +182,7 @@ class TestConvertDocumentRouting:
|
||||||
mock_result.document.iterate_items.return_value = []
|
mock_result.document.iterate_items.return_value = []
|
||||||
mock_result.document.export_to_markdown.return_value = ""
|
mock_result.document.export_to_markdown.return_value = ""
|
||||||
mock_result.document.export_to_html.return_value = ""
|
mock_result.document.export_to_html.return_value = ""
|
||||||
|
mock_result.document.export_to_dict.return_value = {}
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -193,6 +200,7 @@ class TestConvertDocumentRouting:
|
||||||
mock_result.document.iterate_items.return_value = []
|
mock_result.document.iterate_items.return_value = []
|
||||||
mock_result.document.export_to_markdown.return_value = ""
|
mock_result.document.export_to_markdown.return_value = ""
|
||||||
mock_result.document.export_to_html.return_value = ""
|
mock_result.document.export_to_html.return_value = ""
|
||||||
|
mock_result.document.export_to_dict.return_value = {}
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -210,6 +218,7 @@ class TestConvertDocumentRouting:
|
||||||
mock_result.document.iterate_items.return_value = []
|
mock_result.document.iterate_items.return_value = []
|
||||||
mock_result.document.export_to_markdown.return_value = ""
|
mock_result.document.export_to_markdown.return_value = ""
|
||||||
mock_result.document.export_to_html.return_value = ""
|
mock_result.document.export_to_html.return_value = ""
|
||||||
|
mock_result.document.export_to_dict.return_value = {}
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -226,6 +235,7 @@ class TestConvertDocumentRouting:
|
||||||
mock_result.document.iterate_items.return_value = []
|
mock_result.document.iterate_items.return_value = []
|
||||||
mock_result.document.export_to_markdown.return_value = ""
|
mock_result.document.export_to_markdown.return_value = ""
|
||||||
mock_result.document.export_to_html.return_value = ""
|
mock_result.document.export_to_html.return_value = ""
|
||||||
|
mock_result.document.export_to_dict.return_value = {}
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -242,6 +252,7 @@ class TestConvertDocumentRouting:
|
||||||
mock_result.document.iterate_items.return_value = []
|
mock_result.document.iterate_items.return_value = []
|
||||||
mock_result.document.export_to_markdown.return_value = ""
|
mock_result.document.export_to_markdown.return_value = ""
|
||||||
mock_result.document.export_to_html.return_value = ""
|
mock_result.document.export_to_html.return_value = ""
|
||||||
|
mock_result.document.export_to_dict.return_value = {}
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -258,6 +269,7 @@ class TestConvertDocumentRouting:
|
||||||
mock_result.document.iterate_items.return_value = []
|
mock_result.document.iterate_items.return_value = []
|
||||||
mock_result.document.export_to_markdown.return_value = ""
|
mock_result.document.export_to_markdown.return_value = ""
|
||||||
mock_result.document.export_to_html.return_value = ""
|
mock_result.document.export_to_html.return_value = ""
|
||||||
|
mock_result.document.export_to_dict.return_value = {}
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -275,6 +287,7 @@ class TestConvertDocumentRouting:
|
||||||
mock_result.document.iterate_items.return_value = []
|
mock_result.document.iterate_items.return_value = []
|
||||||
mock_result.document.export_to_markdown.return_value = ""
|
mock_result.document.export_to_markdown.return_value = ""
|
||||||
mock_result.document.export_to_html.return_value = ""
|
mock_result.document.export_to_html.return_value = ""
|
||||||
|
mock_result.document.export_to_dict.return_value = {}
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -299,30 +312,37 @@ class TestConvertDocumentRouting:
|
||||||
# Service layer — pipeline options forwarding
|
# Service layer — pipeline options forwarding
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestServiceForwardsPipelineOptions:
|
class TestServiceForwardsPipelineOptions:
|
||||||
"""Verify analysis_service.create and _run_analysis forward pipeline options."""
|
"""Verify analysis_service.create and _run_analysis forward pipeline options."""
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_doc(self):
|
def mock_doc(self):
|
||||||
from domain.models import Document
|
from domain.models import Document
|
||||||
|
|
||||||
return Document(id="d1", filename="test.pdf", storage_path="/tmp/test.pdf")
|
return Document(id="d1", filename="test.pdf", storage_path="/tmp/test.pdf")
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_job(self):
|
def mock_job(self):
|
||||||
from domain.models import AnalysisJob
|
from domain.models import AnalysisJob
|
||||||
|
|
||||||
return AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
|
return AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
|
||||||
|
|
||||||
@patch("services.analysis_service.document_repo")
|
@patch("services.analysis_service.document_repo")
|
||||||
@patch("services.analysis_service.analysis_repo")
|
@patch("services.analysis_service.analysis_repo")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_passes_pipeline_options_to_run(
|
async def test_create_passes_pipeline_options_to_run(
|
||||||
self, mock_analysis_repo, mock_doc_repo, mock_doc,
|
self,
|
||||||
|
mock_analysis_repo,
|
||||||
|
mock_doc_repo,
|
||||||
|
mock_doc,
|
||||||
):
|
):
|
||||||
mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc)
|
mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc)
|
||||||
mock_analysis_repo.insert = AsyncMock()
|
mock_analysis_repo.insert = AsyncMock()
|
||||||
|
|
||||||
mock_converter = AsyncMock()
|
mock_converter = AsyncMock()
|
||||||
from services.analysis_service import AnalysisService
|
from services.analysis_service import AnalysisService
|
||||||
|
|
||||||
svc = AnalysisService(converter=mock_converter)
|
svc = AnalysisService(converter=mock_converter)
|
||||||
|
|
||||||
opts = {"do_ocr": False, "table_mode": "fast"}
|
opts = {"do_ocr": False, "table_mode": "fast"}
|
||||||
|
|
@ -335,13 +355,17 @@ class TestServiceForwardsPipelineOptions:
|
||||||
@patch("services.analysis_service.analysis_repo")
|
@patch("services.analysis_service.analysis_repo")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_passes_none_when_no_options(
|
async def test_create_passes_none_when_no_options(
|
||||||
self, mock_analysis_repo, mock_doc_repo, mock_doc,
|
self,
|
||||||
|
mock_analysis_repo,
|
||||||
|
mock_doc_repo,
|
||||||
|
mock_doc,
|
||||||
):
|
):
|
||||||
mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc)
|
mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc)
|
||||||
mock_analysis_repo.insert = AsyncMock()
|
mock_analysis_repo.insert = AsyncMock()
|
||||||
|
|
||||||
mock_converter = AsyncMock()
|
mock_converter = AsyncMock()
|
||||||
from services.analysis_service import AnalysisService
|
from services.analysis_service import AnalysisService
|
||||||
|
|
||||||
svc = AnalysisService(converter=mock_converter)
|
svc = AnalysisService(converter=mock_converter)
|
||||||
|
|
||||||
with patch("services.analysis_service.asyncio.create_task") as mock_task:
|
with patch("services.analysis_service.asyncio.create_task") as mock_task:
|
||||||
|
|
@ -352,7 +376,10 @@ class TestServiceForwardsPipelineOptions:
|
||||||
@patch("services.analysis_service.document_repo")
|
@patch("services.analysis_service.document_repo")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_analysis_forwards_options_to_convert(
|
async def test_run_analysis_forwards_options_to_convert(
|
||||||
self, mock_doc_repo, mock_analysis_repo, mock_job,
|
self,
|
||||||
|
mock_doc_repo,
|
||||||
|
mock_analysis_repo,
|
||||||
|
mock_job,
|
||||||
):
|
):
|
||||||
from domain.value_objects import ConversionResult, PageDetail
|
from domain.value_objects import ConversionResult, PageDetail
|
||||||
|
|
||||||
|
|
@ -369,6 +396,7 @@ class TestServiceForwardsPipelineOptions:
|
||||||
)
|
)
|
||||||
|
|
||||||
from services.analysis_service import AnalysisService
|
from services.analysis_service import AnalysisService
|
||||||
|
|
||||||
svc = AnalysisService(converter=mock_converter)
|
svc = AnalysisService(converter=mock_converter)
|
||||||
|
|
||||||
opts = {
|
opts = {
|
||||||
|
|
@ -399,7 +427,10 @@ class TestServiceForwardsPipelineOptions:
|
||||||
@patch("services.analysis_service.document_repo")
|
@patch("services.analysis_service.document_repo")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_analysis_uses_defaults_when_no_options(
|
async def test_run_analysis_uses_defaults_when_no_options(
|
||||||
self, mock_doc_repo, mock_analysis_repo, mock_job,
|
self,
|
||||||
|
mock_doc_repo,
|
||||||
|
mock_analysis_repo,
|
||||||
|
mock_job,
|
||||||
):
|
):
|
||||||
from domain.value_objects import ConversionResult, PageDetail
|
from domain.value_objects import ConversionResult, PageDetail
|
||||||
|
|
||||||
|
|
@ -416,6 +447,7 @@ class TestServiceForwardsPipelineOptions:
|
||||||
)
|
)
|
||||||
|
|
||||||
from services.analysis_service import AnalysisService
|
from services.analysis_service import AnalysisService
|
||||||
|
|
||||||
svc = AnalysisService(converter=mock_converter)
|
svc = AnalysisService(converter=mock_converter)
|
||||||
|
|
||||||
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", None)
|
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", None)
|
||||||
|
|
@ -429,7 +461,10 @@ class TestServiceForwardsPipelineOptions:
|
||||||
@patch("services.analysis_service.document_repo")
|
@patch("services.analysis_service.document_repo")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_analysis_marks_failed_on_error(
|
async def test_run_analysis_marks_failed_on_error(
|
||||||
self, mock_doc_repo, mock_analysis_repo, mock_job,
|
self,
|
||||||
|
mock_doc_repo,
|
||||||
|
mock_analysis_repo,
|
||||||
|
mock_job,
|
||||||
):
|
):
|
||||||
mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
|
mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
|
||||||
mock_analysis_repo.update_status = AsyncMock()
|
mock_analysis_repo.update_status = AsyncMock()
|
||||||
|
|
@ -438,6 +473,7 @@ class TestServiceForwardsPipelineOptions:
|
||||||
mock_converter.convert.side_effect = RuntimeError("Docling crashed")
|
mock_converter.convert.side_effect = RuntimeError("Docling crashed")
|
||||||
|
|
||||||
from services.analysis_service import AnalysisService
|
from services.analysis_service import AnalysisService
|
||||||
|
|
||||||
svc = AnalysisService(converter=mock_converter)
|
svc = AnalysisService(converter=mock_converter)
|
||||||
|
|
||||||
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", {"do_ocr": False})
|
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", {"do_ocr": False})
|
||||||
|
|
@ -453,6 +489,7 @@ class TestServiceForwardsPipelineOptions:
|
||||||
# API endpoint — full request/response with pipeline options
|
# API endpoint — full request/response with pipeline options
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestAnalysisEndpointPipelineOptions:
|
class TestAnalysisEndpointPipelineOptions:
|
||||||
"""Integration-level tests for the analysis creation endpoint with pipeline options."""
|
"""Integration-level tests for the analysis creation endpoint with pipeline options."""
|
||||||
|
|
||||||
|
|
@ -461,6 +498,7 @@ class TestAnalysisEndpointPipelineOptions:
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from main import app
|
from main import app
|
||||||
|
|
||||||
return TestClient(app, raise_server_exceptions=False)
|
return TestClient(app, raise_server_exceptions=False)
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -468,6 +506,7 @@ class TestAnalysisEndpointPipelineOptions:
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from main import app
|
from main import app
|
||||||
|
|
||||||
mock = MagicMock()
|
mock = MagicMock()
|
||||||
original = getattr(app.state, "analysis_service", None)
|
original = getattr(app.state, "analysis_service", None)
|
||||||
app.state.analysis_service = mock
|
app.state.analysis_service = mock
|
||||||
|
|
@ -476,20 +515,25 @@ class TestAnalysisEndpointPipelineOptions:
|
||||||
|
|
||||||
def test_no_pipeline_options_sends_none(self, client, mock_svc):
|
def test_no_pipeline_options_sends_none(self, client, mock_svc):
|
||||||
from domain.models import AnalysisJob
|
from domain.models import AnalysisJob
|
||||||
|
|
||||||
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
||||||
|
|
||||||
client.post("/api/analyses", json={"documentId": "d1"})
|
client.post("/api/analyses", json={"documentId": "d1"})
|
||||||
|
|
||||||
mock_svc.create.assert_called_once_with("d1", pipeline_options=None)
|
mock_svc.create.assert_called_once_with("d1", pipeline_options=None, chunking_options=None)
|
||||||
|
|
||||||
def test_empty_pipeline_options_object_uses_defaults(self, client, mock_svc):
|
def test_empty_pipeline_options_object_uses_defaults(self, client, mock_svc):
|
||||||
from domain.models import AnalysisJob
|
from domain.models import AnalysisJob
|
||||||
|
|
||||||
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
||||||
|
|
||||||
client.post("/api/analyses", json={
|
client.post(
|
||||||
"documentId": "d1",
|
"/api/analyses",
|
||||||
"pipelineOptions": {},
|
json={
|
||||||
})
|
"documentId": "d1",
|
||||||
|
"pipelineOptions": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
opts = mock_svc.create.call_args.kwargs["pipeline_options"]
|
opts = mock_svc.create.call_args.kwargs["pipeline_options"]
|
||||||
assert opts["do_ocr"] is True
|
assert opts["do_ocr"] is True
|
||||||
|
|
@ -501,12 +545,16 @@ class TestAnalysisEndpointPipelineOptions:
|
||||||
|
|
||||||
def test_partial_pipeline_options_merges_with_defaults(self, client, mock_svc):
|
def test_partial_pipeline_options_merges_with_defaults(self, client, mock_svc):
|
||||||
from domain.models import AnalysisJob
|
from domain.models import AnalysisJob
|
||||||
|
|
||||||
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
||||||
|
|
||||||
client.post("/api/analyses", json={
|
client.post(
|
||||||
"documentId": "d1",
|
"/api/analyses",
|
||||||
"pipelineOptions": {"do_ocr": False, "images_scale": 1.5},
|
json={
|
||||||
})
|
"documentId": "d1",
|
||||||
|
"pipelineOptions": {"do_ocr": False, "images_scale": 1.5},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
opts = mock_svc.create.call_args.kwargs["pipeline_options"]
|
opts = mock_svc.create.call_args.kwargs["pipeline_options"]
|
||||||
assert opts["do_ocr"] is False
|
assert opts["do_ocr"] is False
|
||||||
|
|
@ -522,6 +570,7 @@ class TestAnalysisEndpointPipelineOptions:
|
||||||
|
|
||||||
def test_full_pipeline_options(self, client, mock_svc):
|
def test_full_pipeline_options(self, client, mock_svc):
|
||||||
from domain.models import AnalysisJob
|
from domain.models import AnalysisJob
|
||||||
|
|
||||||
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
|
|
@ -547,18 +596,25 @@ class TestAnalysisEndpointPipelineOptions:
|
||||||
assert opts == payload["pipelineOptions"]
|
assert opts == payload["pipelineOptions"]
|
||||||
|
|
||||||
def test_invalid_pipeline_option_type_rejected(self, client, mock_svc):
|
def test_invalid_pipeline_option_type_rejected(self, client, mock_svc):
|
||||||
resp = client.post("/api/analyses", json={
|
resp = client.post(
|
||||||
"documentId": "d1",
|
"/api/analyses",
|
||||||
"pipelineOptions": {"do_ocr": "not-a-bool"},
|
json={
|
||||||
})
|
"documentId": "d1",
|
||||||
|
"pipelineOptions": {"do_ocr": "not-a-bool"},
|
||||||
|
},
|
||||||
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
def test_unknown_pipeline_option_ignored(self, client, mock_svc):
|
def test_unknown_pipeline_option_ignored(self, client, mock_svc):
|
||||||
from domain.models import AnalysisJob
|
from domain.models import AnalysisJob
|
||||||
|
|
||||||
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
|
||||||
|
|
||||||
resp = client.post("/api/analyses", json={
|
resp = client.post(
|
||||||
"documentId": "d1",
|
"/api/analyses",
|
||||||
"pipelineOptions": {"do_ocr": True, "unknown_field": True},
|
json={
|
||||||
})
|
"documentId": "d1",
|
||||||
|
"pipelineOptions": {"do_ocr": True, "unknown_field": True},
|
||||||
|
},
|
||||||
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"""Tests for persistence repositories using a temporary SQLite database."""
|
"""Tests for persistence repositories using a temporary SQLite database."""
|
||||||
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from domain.models import AnalysisJob, AnalysisStatus, Document
|
from domain.models import AnalysisJob, AnalysisStatus, Document
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"""Tests for API schemas — camelCase serialization and validation."""
|
"""Tests for API schemas — camelCase serialization and validation."""
|
||||||
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from api.schemas import (
|
from api.schemas import (
|
||||||
|
|
@ -97,7 +96,10 @@ class TestPipelineOptionsRequest:
|
||||||
|
|
||||||
def test_custom_values(self):
|
def test_custom_values(self):
|
||||||
opts = PipelineOptionsRequest(
|
opts = PipelineOptionsRequest(
|
||||||
do_ocr=False, table_mode="fast", do_code_enrichment=True, images_scale=2.0,
|
do_ocr=False,
|
||||||
|
table_mode="fast",
|
||||||
|
do_code_enrichment=True,
|
||||||
|
images_scale=2.0,
|
||||||
)
|
)
|
||||||
assert opts.do_ocr is False
|
assert opts.do_ocr is False
|
||||||
assert opts.table_mode == "fast"
|
assert opts.table_mode == "fast"
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from infra.serve_converter import (
|
||||||
# Unit tests — form data building
|
# Unit tests — form data building
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestBuildFormData:
|
class TestBuildFormData:
|
||||||
def test_default_options(self):
|
def test_default_options(self):
|
||||||
data = _build_form_data(ConversionOptions())
|
data = _build_form_data(ConversionOptions())
|
||||||
|
|
@ -36,7 +37,9 @@ class TestBuildFormData:
|
||||||
|
|
||||||
def test_custom_options(self):
|
def test_custom_options(self):
|
||||||
opts = ConversionOptions(
|
opts = ConversionOptions(
|
||||||
do_ocr=False, table_mode="fast", images_scale=2.0,
|
do_ocr=False,
|
||||||
|
table_mode="fast",
|
||||||
|
images_scale=2.0,
|
||||||
generate_picture_images=True,
|
generate_picture_images=True,
|
||||||
)
|
)
|
||||||
data = _build_form_data(opts)
|
data = _build_form_data(opts)
|
||||||
|
|
@ -50,6 +53,7 @@ class TestBuildFormData:
|
||||||
# Unit tests — response parsing
|
# Unit tests — response parsing
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestParseResponse:
|
class TestParseResponse:
|
||||||
def test_minimal_response(self):
|
def test_minimal_response(self):
|
||||||
data = {
|
data = {
|
||||||
|
|
@ -82,12 +86,34 @@ class TestParseResponse:
|
||||||
{
|
{
|
||||||
"label": "title",
|
"label": "title",
|
||||||
"text": "Title",
|
"text": "Title",
|
||||||
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 20, "r": 200, "b": 40, "coord_origin": "TOPLEFT"}}],
|
"prov": [
|
||||||
|
{
|
||||||
|
"page_no": 1,
|
||||||
|
"bbox": {
|
||||||
|
"l": 10,
|
||||||
|
"t": 20,
|
||||||
|
"r": 200,
|
||||||
|
"b": 40,
|
||||||
|
"coord_origin": "TOPLEFT",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "paragraph",
|
"label": "paragraph",
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 50, "r": 200, "b": 70, "coord_origin": "TOPLEFT"}}],
|
"prov": [
|
||||||
|
{
|
||||||
|
"page_no": 1,
|
||||||
|
"bbox": {
|
||||||
|
"l": 10,
|
||||||
|
"t": 50,
|
||||||
|
"r": 200,
|
||||||
|
"b": 70,
|
||||||
|
"coord_origin": "TOPLEFT",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"tables": [],
|
"tables": [],
|
||||||
|
|
@ -112,7 +138,9 @@ class TestParseResponse:
|
||||||
"1": {"size": {"width": 612.0, "height": 792.0}},
|
"1": {"size": {"width": 612.0, "height": 792.0}},
|
||||||
"2": {"size": {"width": 595.0, "height": 842.0}},
|
"2": {"size": {"width": 595.0, "height": 842.0}},
|
||||||
},
|
},
|
||||||
"texts": [], "tables": [], "pictures": [],
|
"texts": [],
|
||||||
|
"tables": [],
|
||||||
|
"pictures": [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -135,7 +163,9 @@ class TestParseResponse:
|
||||||
def test_json_content_as_string(self):
|
def test_json_content_as_string(self):
|
||||||
json_doc = {
|
json_doc = {
|
||||||
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
|
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
|
||||||
"texts": [], "tables": [], "pictures": [],
|
"texts": [],
|
||||||
|
"tables": [],
|
||||||
|
"pictures": [],
|
||||||
}
|
}
|
||||||
data = {
|
data = {
|
||||||
"document": {
|
"document": {
|
||||||
|
|
@ -171,10 +201,40 @@ class TestParseResponse:
|
||||||
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
|
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
|
||||||
"texts": [],
|
"texts": [],
|
||||||
"tables": [
|
"tables": [
|
||||||
{"label": "table", "text": "", "prov": [{"page_no": 1, "bbox": {"l": 10, "t": 10, "r": 300, "b": 200, "coord_origin": "TOPLEFT"}}]},
|
{
|
||||||
|
"label": "table",
|
||||||
|
"text": "",
|
||||||
|
"prov": [
|
||||||
|
{
|
||||||
|
"page_no": 1,
|
||||||
|
"bbox": {
|
||||||
|
"l": 10,
|
||||||
|
"t": 10,
|
||||||
|
"r": 300,
|
||||||
|
"b": 200,
|
||||||
|
"coord_origin": "TOPLEFT",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"pictures": [
|
"pictures": [
|
||||||
{"label": "picture", "text": "", "prov": [{"page_no": 1, "bbox": {"l": 50, "t": 300, "r": 250, "b": 500, "coord_origin": "TOPLEFT"}}]},
|
{
|
||||||
|
"label": "picture",
|
||||||
|
"text": "",
|
||||||
|
"prov": [
|
||||||
|
{
|
||||||
|
"page_no": 1,
|
||||||
|
"bbox": {
|
||||||
|
"l": 50,
|
||||||
|
"t": 300,
|
||||||
|
"r": 250,
|
||||||
|
"b": 500,
|
||||||
|
"coord_origin": "TOPLEFT",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -189,14 +249,19 @@ class TestParseResponse:
|
||||||
# Unit tests — bbox extraction
|
# Unit tests — bbox extraction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestExtractBbox:
|
class TestExtractBbox:
|
||||||
def test_topleft_passthrough(self):
|
def test_topleft_passthrough(self):
|
||||||
bbox = _extract_bbox({"l": 10, "t": 20, "r": 100, "b": 50, "coord_origin": "TOPLEFT"}, 792.0)
|
bbox = _extract_bbox(
|
||||||
|
{"l": 10, "t": 20, "r": 100, "b": 50, "coord_origin": "TOPLEFT"}, 792.0
|
||||||
|
)
|
||||||
assert bbox == [10, 20, 100, 50]
|
assert bbox == [10, 20, 100, 50]
|
||||||
|
|
||||||
def test_bottomleft_conversion(self):
|
def test_bottomleft_conversion(self):
|
||||||
# In BOTTOMLEFT: t (top of box) has higher y than b (bottom of box)
|
# In BOTTOMLEFT: t (top of box) has higher y than b (bottom of box)
|
||||||
bbox = _extract_bbox({"l": 10, "t": 772, "r": 100, "b": 742, "coord_origin": "BOTTOMLEFT"}, 792.0)
|
bbox = _extract_bbox(
|
||||||
|
{"l": 10, "t": 772, "r": 100, "b": 742, "coord_origin": "BOTTOMLEFT"}, 792.0
|
||||||
|
)
|
||||||
# new_top = 792 - 772 = 20, new_bottom = 792 - 742 = 50
|
# new_top = 792 - 772 = 20, new_bottom = 792 - 742 = 50
|
||||||
assert bbox == [10, 20, 100, 50]
|
assert bbox == [10, 20, 100, 50]
|
||||||
|
|
||||||
|
|
@ -217,9 +282,11 @@ class TestExtractBbox:
|
||||||
# Unit tests — label mapping
|
# Unit tests — label mapping
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestLabelMapping:
|
class TestLabelMapping:
|
||||||
def test_known_labels(self):
|
def test_known_labels(self):
|
||||||
from infra.serve_converter import _LABEL_MAP
|
from infra.serve_converter import _LABEL_MAP
|
||||||
|
|
||||||
assert _LABEL_MAP["table"] == "table"
|
assert _LABEL_MAP["table"] == "table"
|
||||||
assert _LABEL_MAP["picture"] == "picture"
|
assert _LABEL_MAP["picture"] == "picture"
|
||||||
assert _LABEL_MAP["figure"] == "picture"
|
assert _LABEL_MAP["figure"] == "picture"
|
||||||
|
|
@ -232,6 +299,7 @@ class TestLabelMapping:
|
||||||
|
|
||||||
def test_unknown_label_defaults_to_text(self):
|
def test_unknown_label_defaults_to_text(self):
|
||||||
from infra.serve_converter import _LABEL_MAP
|
from infra.serve_converter import _LABEL_MAP
|
||||||
|
|
||||||
assert _LABEL_MAP.get("unknown_thing", "text") == "text"
|
assert _LABEL_MAP.get("unknown_thing", "text") == "text"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -239,6 +307,7 @@ class TestLabelMapping:
|
||||||
# Unit tests — ServeConverter
|
# Unit tests — ServeConverter
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestServeConverter:
|
class TestServeConverter:
|
||||||
def test_headers_with_api_key(self):
|
def test_headers_with_api_key(self):
|
||||||
conv = ServeConverter(base_url="http://localhost:5001", api_key="secret")
|
conv = ServeConverter(base_url="http://localhost:5001", api_key="secret")
|
||||||
|
|
@ -257,6 +326,7 @@ class TestServeConverter:
|
||||||
# Integration tests — HTTP calls (mocked)
|
# Integration tests — HTTP calls (mocked)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestServeConverterConvert:
|
class TestServeConverterConvert:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_successful_conversion(self, tmp_path):
|
async def test_successful_conversion(self, tmp_path):
|
||||||
|
|
@ -270,7 +340,22 @@ class TestServeConverterConvert:
|
||||||
"json_content": {
|
"json_content": {
|
||||||
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
|
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
|
||||||
"texts": [
|
"texts": [
|
||||||
{"label": "title", "text": "Converted", "prov": [{"page_no": 1, "bbox": {"l": 10, "t": 20, "r": 200, "b": 40, "coord_origin": "TOPLEFT"}}]},
|
{
|
||||||
|
"label": "title",
|
||||||
|
"text": "Converted",
|
||||||
|
"prov": [
|
||||||
|
{
|
||||||
|
"page_no": 1,
|
||||||
|
"bbox": {
|
||||||
|
"l": 10,
|
||||||
|
"t": 20,
|
||||||
|
"r": 200,
|
||||||
|
"b": 40,
|
||||||
|
"coord_origin": "TOPLEFT",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"tables": [],
|
"tables": [],
|
||||||
"pictures": [],
|
"pictures": [],
|
||||||
|
|
@ -312,7 +397,9 @@ class TestServeConverterConvert:
|
||||||
|
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||||
"Server Error", request=MagicMock(), response=MagicMock(status_code=500),
|
"Server Error",
|
||||||
|
request=MagicMock(),
|
||||||
|
response=MagicMock(status_code=500),
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_client = AsyncMock()
|
mock_client = AsyncMock()
|
||||||
|
|
@ -322,8 +409,10 @@ class TestServeConverterConvert:
|
||||||
|
|
||||||
conv = ServeConverter(base_url="http://localhost:5001")
|
conv = ServeConverter(base_url="http://localhost:5001")
|
||||||
|
|
||||||
with patch("infra.serve_converter.httpx.AsyncClient", return_value=mock_client), \
|
with (
|
||||||
pytest.raises(httpx.HTTPStatusError):
|
patch("infra.serve_converter.httpx.AsyncClient", return_value=mock_client),
|
||||||
|
pytest.raises(httpx.HTTPStatusError),
|
||||||
|
):
|
||||||
await conv.convert(str(test_file), ConversionOptions())
|
await conv.convert(str(test_file), ConversionOptions())
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -358,6 +447,7 @@ class TestServeConverterConvert:
|
||||||
# Integration — converter wiring in main.py
|
# Integration — converter wiring in main.py
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestConverterWiring:
|
class TestConverterWiring:
|
||||||
def test_local_engine_builds_local_converter(self):
|
def test_local_engine_builds_local_converter(self):
|
||||||
from infra.local_converter import LocalConverter
|
from infra.local_converter import LocalConverter
|
||||||
|
|
@ -365,14 +455,19 @@ class TestConverterWiring:
|
||||||
|
|
||||||
with patch("main.settings", Settings(conversion_engine="local")):
|
with patch("main.settings", Settings(conversion_engine="local")):
|
||||||
from main import _build_converter
|
from main import _build_converter
|
||||||
|
|
||||||
converter = _build_converter()
|
converter = _build_converter()
|
||||||
assert isinstance(converter, LocalConverter)
|
assert isinstance(converter, LocalConverter)
|
||||||
|
|
||||||
def test_remote_engine_builds_serve_converter(self):
|
def test_remote_engine_builds_serve_converter(self):
|
||||||
from infra.settings import Settings
|
from infra.settings import Settings
|
||||||
|
|
||||||
with patch("main.settings", Settings(conversion_engine="remote", docling_serve_url="http://serve:5001")):
|
with patch(
|
||||||
|
"main.settings",
|
||||||
|
Settings(conversion_engine="remote", docling_serve_url="http://serve:5001"),
|
||||||
|
):
|
||||||
from main import _build_converter
|
from main import _build_converter
|
||||||
|
|
||||||
converter = _build_converter()
|
converter = _build_converter()
|
||||||
assert isinstance(converter, ServeConverter)
|
assert isinstance(converter, ServeConverter)
|
||||||
assert converter._base_url == "http://serve:5001"
|
assert converter._base_url == "http://serve:5001"
|
||||||
|
|
@ -380,8 +475,16 @@ class TestConverterWiring:
|
||||||
def test_remote_engine_passes_api_key(self):
|
def test_remote_engine_passes_api_key(self):
|
||||||
from infra.settings import Settings
|
from infra.settings import Settings
|
||||||
|
|
||||||
with patch("main.settings", Settings(conversion_engine="remote", docling_serve_url="http://serve:5001", docling_serve_api_key="my-key")):
|
with patch(
|
||||||
|
"main.settings",
|
||||||
|
Settings(
|
||||||
|
conversion_engine="remote",
|
||||||
|
docling_serve_url="http://serve:5001",
|
||||||
|
docling_serve_api_key="my-key",
|
||||||
|
),
|
||||||
|
):
|
||||||
from main import _build_converter
|
from main import _build_converter
|
||||||
|
|
||||||
converter = _build_converter()
|
converter = _build_converter()
|
||||||
assert isinstance(converter, ServeConverter)
|
assert isinstance(converter, ServeConverter)
|
||||||
assert converter._api_key == "my-key"
|
assert converter._api_key == "my-key"
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,34 @@
|
||||||
import type { Analysis, PipelineOptions } from '../../shared/types'
|
import type { Analysis, Chunk, ChunkingOptions, PipelineOptions } from '../../shared/types'
|
||||||
import { apiFetch } from '../../shared/api/http'
|
import { apiFetch } from '../../shared/api/http'
|
||||||
|
|
||||||
export function createAnalysis(
|
export function createAnalysis(
|
||||||
documentId: string,
|
documentId: string,
|
||||||
pipelineOptions: PipelineOptions | null = null,
|
pipelineOptions: PipelineOptions | null = null,
|
||||||
|
chunkingOptions: ChunkingOptions | null = null,
|
||||||
): Promise<Analysis> {
|
): Promise<Analysis> {
|
||||||
const body: Record<string, unknown> = { documentId }
|
const body: Record<string, unknown> = { documentId }
|
||||||
if (pipelineOptions) {
|
if (pipelineOptions) {
|
||||||
body.pipelineOptions = pipelineOptions
|
body.pipelineOptions = pipelineOptions
|
||||||
}
|
}
|
||||||
|
if (chunkingOptions) {
|
||||||
|
body.chunkingOptions = chunkingOptions
|
||||||
|
}
|
||||||
return apiFetch<Analysis>('/api/analyses', {
|
return apiFetch<Analysis>('/api/analyses', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function rechunkAnalysis(
|
||||||
|
jobId: string,
|
||||||
|
chunkingOptions: ChunkingOptions,
|
||||||
|
): Promise<Chunk[]> {
|
||||||
|
return apiFetch<Chunk[]>(`/api/analyses/${jobId}/rechunk`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ chunkingOptions }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchAnalyses(): Promise<Analysis[]> {
|
export function fetchAnalyses(): Promise<Analysis[]> {
|
||||||
return apiFetch<Analysis[]>('/api/analyses')
|
return apiFetch<Analysis[]>('/api/analyses')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => {
|
||||||
const store = useAnalysisStore()
|
const store = useAnalysisStore()
|
||||||
await store.run('d1')
|
await store.run('d1')
|
||||||
|
|
||||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null)
|
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, null)
|
||||||
store.stopPolling()
|
store.stopPolling()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -155,7 +155,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => {
|
||||||
}
|
}
|
||||||
await store.run('d1', opts)
|
await store.run('d1', opts)
|
||||||
|
|
||||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', opts)
|
expect(api.createAnalysis).toHaveBeenCalledWith('d1', opts, null)
|
||||||
store.stopPolling()
|
store.stopPolling()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -168,7 +168,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => {
|
||||||
const opts = { do_ocr: false }
|
const opts = { do_ocr: false }
|
||||||
await store.run('d1', opts)
|
await store.run('d1', opts)
|
||||||
|
|
||||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', { do_ocr: false })
|
expect(api.createAnalysis).toHaveBeenCalledWith('d1', { do_ocr: false }, null)
|
||||||
store.stopPolling()
|
store.stopPolling()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ describe('useAnalysisStore', () => {
|
||||||
expect(store.currentAnalysis).toEqual(job)
|
expect(store.currentAnalysis).toEqual(job)
|
||||||
expect(store.analyses[0]).toEqual(job)
|
expect(store.analyses[0]).toEqual(job)
|
||||||
expect(store.running).toBe(true)
|
expect(store.running).toBe(true)
|
||||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null)
|
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, null)
|
||||||
|
|
||||||
// Advance timer to trigger polling
|
// Advance timer to trigger polling
|
||||||
await vi.advanceTimersByTimeAsync(2000)
|
await vi.advanceTimersByTimeAsync(2000)
|
||||||
|
|
@ -90,7 +90,7 @@ describe('useAnalysisStore', () => {
|
||||||
const options = { do_ocr: false, table_mode: 'fast' }
|
const options = { do_ocr: false, table_mode: 'fast' }
|
||||||
await store.run('d1', options)
|
await store.run('d1', options)
|
||||||
|
|
||||||
expect(api.createAnalysis).toHaveBeenCalledWith('d1', options)
|
expect(api.createAnalysis).toHaveBeenCalledWith('d1', options, null)
|
||||||
|
|
||||||
store.stopPolling()
|
store.stopPolling()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { Analysis, Page, PipelineOptions } from '../../shared/types'
|
import type { Analysis, Chunk, ChunkingOptions, Page, PipelineOptions } from '../../shared/types'
|
||||||
import * as api from './api'
|
import * as api from './api'
|
||||||
|
|
||||||
export const useAnalysisStore = defineStore('analysis', () => {
|
export const useAnalysisStore = defineStore('analysis', () => {
|
||||||
|
|
@ -35,14 +35,44 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentChunks = computed<Chunk[]>(() => {
|
||||||
|
if (!currentAnalysis.value?.chunksJson) return []
|
||||||
|
try {
|
||||||
|
return JSON.parse(currentAnalysis.value.chunksJson) as Chunk[]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const rechunking = ref(false)
|
||||||
|
|
||||||
|
async function rechunk(jobId: string, chunkingOptions: ChunkingOptions): Promise<Chunk[]> {
|
||||||
|
rechunking.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const chunks = await api.rechunkAnalysis(jobId, chunkingOptions)
|
||||||
|
if (currentAnalysis.value?.id === jobId) {
|
||||||
|
currentAnalysis.value = await api.fetchAnalysis(jobId)
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message || 'Failed to rechunk'
|
||||||
|
console.error('Failed to rechunk', e)
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
rechunking.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function run(
|
async function run(
|
||||||
documentId: string,
|
documentId: string,
|
||||||
pipelineOptions: PipelineOptions | null = null,
|
pipelineOptions: PipelineOptions | null = null,
|
||||||
|
chunkingOptions: ChunkingOptions | null = null,
|
||||||
): Promise<Analysis> {
|
): Promise<Analysis> {
|
||||||
running.value = true
|
running.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const analysis = await api.createAnalysis(documentId, pipelineOptions)
|
const analysis = await api.createAnalysis(documentId, pipelineOptions, chunkingOptions)
|
||||||
currentAnalysis.value = analysis
|
currentAnalysis.value = analysis
|
||||||
analyses.value.unshift(analysis)
|
analyses.value.unshift(analysis)
|
||||||
startPolling(analysis.id)
|
startPolling(analysis.id)
|
||||||
|
|
@ -118,11 +148,14 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
||||||
analyses,
|
analyses,
|
||||||
currentAnalysis,
|
currentAnalysis,
|
||||||
currentPages,
|
currentPages,
|
||||||
|
currentChunks,
|
||||||
running,
|
running,
|
||||||
|
rechunking,
|
||||||
error,
|
error,
|
||||||
clearError,
|
clearError,
|
||||||
load,
|
load,
|
||||||
run,
|
run,
|
||||||
|
rechunk,
|
||||||
select,
|
select,
|
||||||
remove,
|
remove,
|
||||||
stopPolling,
|
stopPolling,
|
||||||
|
|
|
||||||
52
frontend/src/features/chunking/api.test.ts
Normal file
52
frontend/src/features/chunking/api.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { rechunkAnalysis, createAnalysis } from '../analysis/api'
|
||||||
|
|
||||||
|
vi.mock('../../shared/api/http', () => ({
|
||||||
|
apiFetch: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { apiFetch } from '../../shared/api/http'
|
||||||
|
|
||||||
|
describe('chunking API', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createAnalysis sends chunkingOptions when provided', async () => {
|
||||||
|
const job = { id: '1', documentId: 'doc-1', status: 'PENDING' }
|
||||||
|
apiFetch.mockResolvedValue(job)
|
||||||
|
|
||||||
|
const chunkingOpts = { chunker_type: 'hybrid' as const, max_tokens: 256 }
|
||||||
|
await createAnalysis('doc-1', null, chunkingOpts)
|
||||||
|
|
||||||
|
expect(apiFetch).toHaveBeenCalledWith('/api/analyses', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ documentId: 'doc-1', chunkingOptions: chunkingOpts }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createAnalysis omits chunkingOptions when null', async () => {
|
||||||
|
apiFetch.mockResolvedValue({ id: '1' })
|
||||||
|
|
||||||
|
await createAnalysis('doc-1', null, null)
|
||||||
|
|
||||||
|
expect(apiFetch).toHaveBeenCalledWith('/api/analyses', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ documentId: 'doc-1' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rechunkAnalysis sends POST to rechunk endpoint', async () => {
|
||||||
|
const chunks = [{ text: 'chunk1', headings: [], sourcePage: 1, tokenCount: 10 }]
|
||||||
|
apiFetch.mockResolvedValue(chunks)
|
||||||
|
|
||||||
|
const opts = { chunker_type: 'hybrid' as const, max_tokens: 512 }
|
||||||
|
const result = await rechunkAnalysis('job-1', opts)
|
||||||
|
|
||||||
|
expect(apiFetch).toHaveBeenCalledWith('/api/analyses/job-1/rechunk', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ chunkingOptions: opts }),
|
||||||
|
})
|
||||||
|
expect(result).toEqual(chunks)
|
||||||
|
})
|
||||||
|
})
|
||||||
1
frontend/src/features/chunking/index.ts
Normal file
1
frontend/src/features/chunking/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default as ChunkPanel } from './ui/ChunkPanel.vue'
|
||||||
135
frontend/src/features/chunking/store.test.ts
Normal file
135
frontend/src/features/chunking/store.test.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useAnalysisStore } from '../analysis/store'
|
||||||
|
|
||||||
|
vi.mock('../analysis/api', () => ({
|
||||||
|
createAnalysis: vi.fn(),
|
||||||
|
fetchAnalyses: vi.fn().mockResolvedValue([]),
|
||||||
|
fetchAnalysis: vi.fn(),
|
||||||
|
deleteAnalysis: vi.fn(),
|
||||||
|
rechunkAnalysis: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as api from '../analysis/api'
|
||||||
|
|
||||||
|
describe('analysis store — chunking', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('currentChunks parses chunksJson from current analysis', () => {
|
||||||
|
const store = useAnalysisStore()
|
||||||
|
const chunks = [
|
||||||
|
{ text: 'chunk1', headings: ['H1'], sourcePage: 1, tokenCount: 10 },
|
||||||
|
{ text: 'chunk2', headings: [], sourcePage: 2, tokenCount: 20 },
|
||||||
|
]
|
||||||
|
store.currentAnalysis = {
|
||||||
|
id: 'j1',
|
||||||
|
documentId: 'd1',
|
||||||
|
documentFilename: null,
|
||||||
|
status: 'COMPLETED',
|
||||||
|
contentMarkdown: null,
|
||||||
|
contentHtml: null,
|
||||||
|
pagesJson: null,
|
||||||
|
chunksJson: JSON.stringify(chunks),
|
||||||
|
hasDocumentJson: true,
|
||||||
|
errorMessage: null,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
createdAt: '2024-01-01',
|
||||||
|
}
|
||||||
|
expect(store.currentChunks).toEqual(chunks)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('currentChunks returns empty array when no chunksJson', () => {
|
||||||
|
const store = useAnalysisStore()
|
||||||
|
store.currentAnalysis = {
|
||||||
|
id: 'j1',
|
||||||
|
documentId: 'd1',
|
||||||
|
documentFilename: null,
|
||||||
|
status: 'COMPLETED',
|
||||||
|
contentMarkdown: null,
|
||||||
|
contentHtml: null,
|
||||||
|
pagesJson: null,
|
||||||
|
chunksJson: null,
|
||||||
|
hasDocumentJson: false,
|
||||||
|
errorMessage: null,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
createdAt: '2024-01-01',
|
||||||
|
}
|
||||||
|
expect(store.currentChunks).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rechunk calls API and refreshes analysis', async () => {
|
||||||
|
const store = useAnalysisStore()
|
||||||
|
const chunks = [{ text: 'c1', headings: [], sourcePage: 1, tokenCount: 5 }]
|
||||||
|
vi.mocked(api.rechunkAnalysis).mockResolvedValue(chunks)
|
||||||
|
vi.mocked(api.fetchAnalysis).mockResolvedValue({
|
||||||
|
id: 'j1',
|
||||||
|
documentId: 'd1',
|
||||||
|
documentFilename: null,
|
||||||
|
status: 'COMPLETED',
|
||||||
|
contentMarkdown: null,
|
||||||
|
contentHtml: null,
|
||||||
|
pagesJson: null,
|
||||||
|
chunksJson: JSON.stringify(chunks),
|
||||||
|
hasDocumentJson: true,
|
||||||
|
errorMessage: null,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
createdAt: '2024-01-01',
|
||||||
|
})
|
||||||
|
|
||||||
|
store.currentAnalysis = {
|
||||||
|
id: 'j1',
|
||||||
|
documentId: 'd1',
|
||||||
|
documentFilename: null,
|
||||||
|
status: 'COMPLETED',
|
||||||
|
contentMarkdown: null,
|
||||||
|
contentHtml: null,
|
||||||
|
pagesJson: null,
|
||||||
|
chunksJson: null,
|
||||||
|
hasDocumentJson: true,
|
||||||
|
errorMessage: null,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
createdAt: '2024-01-01',
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await store.rechunk('j1', { chunker_type: 'hybrid', max_tokens: 256 })
|
||||||
|
|
||||||
|
expect(api.rechunkAnalysis).toHaveBeenCalledWith('j1', {
|
||||||
|
chunker_type: 'hybrid',
|
||||||
|
max_tokens: 256,
|
||||||
|
})
|
||||||
|
expect(result).toEqual(chunks)
|
||||||
|
expect(store.rechunking).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('run passes chunkingOptions to API', async () => {
|
||||||
|
const store = useAnalysisStore()
|
||||||
|
vi.mocked(api.createAnalysis).mockResolvedValue({
|
||||||
|
id: 'j1',
|
||||||
|
documentId: 'd1',
|
||||||
|
documentFilename: null,
|
||||||
|
status: 'PENDING',
|
||||||
|
contentMarkdown: null,
|
||||||
|
contentHtml: null,
|
||||||
|
pagesJson: null,
|
||||||
|
chunksJson: null,
|
||||||
|
hasDocumentJson: false,
|
||||||
|
errorMessage: null,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
createdAt: '2024-01-01',
|
||||||
|
})
|
||||||
|
|
||||||
|
await store.run('d1', null, { chunker_type: 'hierarchical' })
|
||||||
|
|
||||||
|
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, {
|
||||||
|
chunker_type: 'hierarchical',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
336
frontend/src/features/chunking/ui/ChunkPanel.vue
Normal file
336
frontend/src/features/chunking/ui/ChunkPanel.vue
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
<template>
|
||||||
|
<div class="chunk-panel">
|
||||||
|
<!-- Chunking config -->
|
||||||
|
<div class="chunk-config">
|
||||||
|
<div class="config-section">
|
||||||
|
<label class="config-label">{{ t('chunking.settings') }}</label>
|
||||||
|
|
||||||
|
<div class="config-row">
|
||||||
|
<label class="config-label-sm">{{ t('chunking.chunkerType') }}</label>
|
||||||
|
<select class="config-select" v-model="options.chunker_type">
|
||||||
|
<option value="hybrid">Hybrid</option>
|
||||||
|
<option value="hierarchical">Hierarchical</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-row">
|
||||||
|
<label class="config-label-sm">{{ t('chunking.maxTokens') }}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="config-input"
|
||||||
|
v-model.number="options.max_tokens"
|
||||||
|
min="64"
|
||||||
|
max="8192"
|
||||||
|
step="64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-toggle-row" v-if="options.chunker_type === 'hybrid'">
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" v-model="options.merge_peers" class="toggle-input" />
|
||||||
|
<span class="toggle-switch" />
|
||||||
|
<span class="toggle-text">{{ t('chunking.mergePeers') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-toggle-row" v-if="options.chunker_type === 'hybrid'">
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" v-model="options.repeat_table_header" class="toggle-input" />
|
||||||
|
<span class="toggle-switch" />
|
||||||
|
<span class="toggle-text">{{ t('chunking.repeatTableHeader') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="chunk-btn primary"
|
||||||
|
:disabled="!canRechunk || analysisStore.rechunking"
|
||||||
|
@click="doRechunk"
|
||||||
|
>
|
||||||
|
<div v-if="analysisStore.rechunking" class="spinner-sm" />
|
||||||
|
{{ analysisStore.rechunking ? t('chunking.chunking') : t('chunking.run') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chunks list -->
|
||||||
|
<div class="chunk-results" v-if="analysisStore.currentChunks.length">
|
||||||
|
<div class="chunk-summary">
|
||||||
|
{{ analysisStore.currentChunks.length }} {{ t('chunking.chunks') }}
|
||||||
|
</div>
|
||||||
|
<div class="chunk-list">
|
||||||
|
<div
|
||||||
|
class="chunk-card"
|
||||||
|
v-for="(chunk, idx) in analysisStore.currentChunks"
|
||||||
|
:key="idx"
|
||||||
|
>
|
||||||
|
<div class="chunk-header">
|
||||||
|
<span class="chunk-index">#{{ idx + 1 }}</span>
|
||||||
|
<span class="chunk-tokens" v-if="chunk.tokenCount">
|
||||||
|
{{ chunk.tokenCount }} tokens
|
||||||
|
</span>
|
||||||
|
<span class="chunk-page" v-if="chunk.sourcePage">
|
||||||
|
p.{{ chunk.sourcePage }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="chunk-headings" v-if="chunk.headings.length">
|
||||||
|
<span class="chunk-heading" v-for="h in chunk.headings" :key="h">{{ h }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="chunk-text">{{ chunk.text }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chunk-empty" v-else-if="!analysisStore.rechunking">
|
||||||
|
<p>{{ t('chunking.noChunks') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, computed } from 'vue'
|
||||||
|
import { useAnalysisStore } from '../../analysis/store'
|
||||||
|
import { useI18n } from '../../../shared/i18n'
|
||||||
|
import type { ChunkingOptions } from '../../../shared/types'
|
||||||
|
|
||||||
|
const analysisStore = useAnalysisStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const options = reactive<Required<ChunkingOptions>>({
|
||||||
|
chunker_type: 'hybrid',
|
||||||
|
max_tokens: 512,
|
||||||
|
merge_peers: true,
|
||||||
|
repeat_table_header: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const canRechunk = computed(() => {
|
||||||
|
const analysis = analysisStore.currentAnalysis
|
||||||
|
return analysis?.status === 'COMPLETED' && analysis.hasDocumentJson
|
||||||
|
})
|
||||||
|
|
||||||
|
async function doRechunk() {
|
||||||
|
if (!analysisStore.currentAnalysis) return
|
||||||
|
await analysisStore.rechunk(analysisStore.currentAnalysis.id, { ...options })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chunk-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-config {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-label-sm {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-select,
|
||||||
|
.config-input {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch {
|
||||||
|
width: 32px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 9px;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-input:checked + .toggle-switch {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-input:checked + .toggle-switch::after {
|
||||||
|
transform: translateX(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-btn.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-btn.primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-results {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-summary {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-index {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-tokens,
|
||||||
|
.chunk-page {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-headings {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-heading {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg, rgba(99, 102, 241, 0.1));
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-sm {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -37,6 +37,15 @@
|
||||||
>
|
>
|
||||||
{{ t('studio.verify') }}
|
{{ t('studio.verify') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="chunkingEnabled"
|
||||||
|
class="toggle-btn"
|
||||||
|
:class="{ active: mode === 'preparer' }"
|
||||||
|
@click="mode = 'preparer'"
|
||||||
|
:disabled="!analysisStore.currentAnalysis"
|
||||||
|
>
|
||||||
|
{{ t('studio.prepare') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
|
|
@ -280,6 +289,11 @@
|
||||||
@highlight-element="highlightedElementIndex = $event"
|
@highlight-element="highlightedElementIndex = $event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- PREPARER MODE (feature-flipped) -->
|
||||||
|
<div v-if="mode === 'preparer' && chunkingEnabled" class="prepare-panel">
|
||||||
|
<ChunkPanel />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -293,6 +307,8 @@ import { useAnalysisStore } from '../features/analysis/store'
|
||||||
import { DocumentUpload, DocumentList } from '../features/document/index'
|
import { DocumentUpload, DocumentList } from '../features/document/index'
|
||||||
import { ResultTabs } from '../features/analysis/index'
|
import { ResultTabs } from '../features/analysis/index'
|
||||||
import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue'
|
import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue'
|
||||||
|
import { ChunkPanel } from '../features/chunking'
|
||||||
|
import { useFeatureFlag } from '../features/feature-flags'
|
||||||
import { getPreviewUrl } from '../features/document/api'
|
import { getPreviewUrl } from '../features/document/api'
|
||||||
import { useI18n } from '../shared/i18n'
|
import { useI18n } from '../shared/i18n'
|
||||||
import type { PipelineOptions } from '../shared/types'
|
import type { PipelineOptions } from '../shared/types'
|
||||||
|
|
@ -302,6 +318,7 @@ const router = useRouter()
|
||||||
const documentStore = useDocumentStore()
|
const documentStore = useDocumentStore()
|
||||||
const analysisStore = useAnalysisStore()
|
const analysisStore = useAnalysisStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const chunkingEnabled = useFeatureFlag('chunking')
|
||||||
|
|
||||||
const mode = ref('configurer')
|
const mode = ref('configurer')
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,18 @@ const messages: Messages = {
|
||||||
'history.emptyDocs': 'Aucun document. Importez un document depuis le Studio.',
|
'history.emptyDocs': 'Aucun document. Importez un document depuis le Studio.',
|
||||||
'history.open': 'Ouvrir',
|
'history.open': 'Ouvrir',
|
||||||
|
|
||||||
|
// Chunking
|
||||||
|
'studio.prepare': 'Préparer',
|
||||||
|
'chunking.settings': 'Chunking',
|
||||||
|
'chunking.chunkerType': 'Type de chunker',
|
||||||
|
'chunking.maxTokens': 'Tokens max',
|
||||||
|
'chunking.mergePeers': 'Fusionner les pairs',
|
||||||
|
'chunking.repeatTableHeader': 'Répéter en-têtes tableaux',
|
||||||
|
'chunking.run': 'Chunker',
|
||||||
|
'chunking.chunking': 'Chunking...',
|
||||||
|
'chunking.chunks': 'chunks',
|
||||||
|
'chunking.noChunks': 'Lancez le chunking pour préparer les segments.',
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
'settings.title': 'Paramètres',
|
'settings.title': 'Paramètres',
|
||||||
'settings.apiUrl': 'API URL',
|
'settings.apiUrl': 'API URL',
|
||||||
|
|
@ -185,6 +197,17 @@ const messages: Messages = {
|
||||||
'history.emptyDocs': 'No documents yet. Upload a document from the Studio.',
|
'history.emptyDocs': 'No documents yet. Upload a document from the Studio.',
|
||||||
'history.open': 'Open',
|
'history.open': 'Open',
|
||||||
|
|
||||||
|
'studio.prepare': 'Prepare',
|
||||||
|
'chunking.settings': 'Chunking',
|
||||||
|
'chunking.chunkerType': 'Chunker type',
|
||||||
|
'chunking.maxTokens': 'Max tokens',
|
||||||
|
'chunking.mergePeers': 'Merge peers',
|
||||||
|
'chunking.repeatTableHeader': 'Repeat table headers',
|
||||||
|
'chunking.run': 'Chunk',
|
||||||
|
'chunking.chunking': 'Chunking...',
|
||||||
|
'chunking.chunks': 'chunks',
|
||||||
|
'chunking.noChunks': 'Run chunking to prepare segments.',
|
||||||
|
|
||||||
'settings.title': 'Settings',
|
'settings.title': 'Settings',
|
||||||
'settings.apiUrl': 'API URL',
|
'settings.apiUrl': 'API URL',
|
||||||
'settings.version': 'Version',
|
'settings.version': 'Version',
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,28 @@ export interface Analysis {
|
||||||
contentMarkdown: string | null
|
contentMarkdown: string | null
|
||||||
contentHtml: string | null
|
contentHtml: string | null
|
||||||
pagesJson: string | null
|
pagesJson: string | null
|
||||||
|
chunksJson: string | null
|
||||||
|
hasDocumentJson: boolean
|
||||||
errorMessage: string | null
|
errorMessage: string | null
|
||||||
startedAt: string | null
|
startedAt: string | null
|
||||||
completedAt: string | null
|
completedAt: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChunkingOptions {
|
||||||
|
chunker_type?: 'hybrid' | 'hierarchical'
|
||||||
|
max_tokens?: number
|
||||||
|
merge_peers?: boolean
|
||||||
|
repeat_table_header?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Chunk {
|
||||||
|
text: string
|
||||||
|
headings: string[]
|
||||||
|
sourcePage: number | null
|
||||||
|
tokenCount: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface PageElement {
|
export interface PageElement {
|
||||||
type: string
|
type: string
|
||||||
bbox: [number, number, number, number]
|
bbox: [number, number, number, number]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue