diff --git a/document-parser/api/analyses.py b/document-parser/api/analyses.py index 123f12f..8ce3294 100644 --- a/document-parser/api/analyses.py +++ b/document-parser/api/analyses.py @@ -7,7 +7,13 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Request -from api.schemas import AnalysisResponse, ChunkResponse, CreateAnalysisRequest, RechunkRequest +from api.schemas import ( + AnalysisResponse, + ChunkBboxResponse, + ChunkResponse, + CreateAnalysisRequest, + RechunkRequest, +) from services.analysis_service import AnalysisService logger = logging.getLogger(__name__) @@ -94,6 +100,7 @@ async def rechunk_analysis(job_id: str, body: RechunkRequest, service: ServiceDe headings=c.headings, source_page=c.source_page, token_count=c.token_count, + bboxes=[ChunkBboxResponse(page=b.page, bbox=b.bbox) for b in c.bboxes], ) for c in chunks ] diff --git a/document-parser/api/schemas.py b/document-parser/api/schemas.py index bf01fd5..7d7d5d7 100644 --- a/document-parser/api/schemas.py +++ b/document-parser/api/schemas.py @@ -103,11 +103,17 @@ class ChunkingOptionsRequest(BaseModel): return v +class ChunkBboxResponse(_CamelModel): + page: int + bbox: list[float] + + class ChunkResponse(_CamelModel): text: str headings: list[str] = [] source_page: int | None = None token_count: int = 0 + bboxes: list[ChunkBboxResponse] = [] class CreateAnalysisRequest(BaseModel): diff --git a/document-parser/domain/value_objects.py b/document-parser/domain/value_objects.py index 19af834..26e19fc 100644 --- a/document-parser/domain/value_objects.py +++ b/document-parser/domain/value_objects.py @@ -63,9 +63,16 @@ class ChunkingOptions: return self == ChunkingOptions() +@dataclass +class ChunkBbox: + page: int + bbox: list[float] # [left, top, right, bottom] in TOPLEFT origin + + @dataclass class ChunkResult: text: str headings: list[str] = field(default_factory=list) source_page: int | None = None token_count: int = 0 + bboxes: list[ChunkBbox] = field(default_factory=list) diff --git a/document-parser/infra/local_chunker.py b/document-parser/infra/local_chunker.py index 7024496..500b691 100644 --- a/document-parser/infra/local_chunker.py +++ b/document-parser/infra/local_chunker.py @@ -15,7 +15,8 @@ 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 +from domain.bbox import EMPTY_BBOX, to_topleft_list +from domain.value_objects import ChunkBbox, ChunkingOptions, ChunkResult logger = logging.getLogger(__name__) @@ -37,12 +38,22 @@ def _chunk_sync(document_json: str, options: ChunkingOptions) -> list[ChunkResul for chunk in chunker.chunk(doc): source_page = None token_count = 0 + bboxes: list[ChunkBbox] = [] 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 not hasattr(doc_item, "prov") or not doc_item.prov: + continue + for prov in doc_item.prov: + page_no = prov.page_no + if source_page is None: + source_page = page_no + if prov.bbox: + page_obj = doc.pages.get(page_no) + if page_obj: + bbox = to_topleft_list(prov.bbox, page_obj.size.height) + if bbox != EMPTY_BBOX: + bboxes.append(ChunkBbox(page=page_no, bbox=bbox)) if hasattr(chunker, "tokenizer") and chunker.tokenizer: token_count = chunker.tokenizer.count_tokens(chunk.text) @@ -55,6 +66,7 @@ def _chunk_sync(document_json: str, options: ChunkingOptions) -> list[ChunkResul headings=headings, source_page=source_page, token_count=token_count, + bboxes=bboxes, ) ) diff --git a/document-parser/services/analysis_service.py b/document-parser/services/analysis_service.py index 9c99934..0f4ea6a 100644 --- a/document-parser/services/analysis_service.py +++ b/document-parser/services/analysis_service.py @@ -30,6 +30,7 @@ def _chunk_to_dict(c: ChunkResult) -> dict: "headings": c.headings, "sourcePage": c.source_page, "tokenCount": c.token_count, + "bboxes": [{"page": b.page, "bbox": b.bbox} for b in c.bboxes], } diff --git a/document-parser/tests/test_chunking.py b/document-parser/tests/test_chunking.py index b429d35..52e84c8 100644 --- a/document-parser/tests/test_chunking.py +++ b/document-parser/tests/test_chunking.py @@ -9,9 +9,9 @@ from unittest.mock import AsyncMock, MagicMock import pytest from fastapi.testclient import TestClient -from api.schemas import ChunkingOptionsRequest, ChunkResponse, RechunkRequest +from api.schemas import ChunkBboxResponse, ChunkingOptionsRequest, ChunkResponse, RechunkRequest from domain.models import AnalysisJob, AnalysisStatus -from domain.value_objects import ChunkingOptions, ChunkResult +from domain.value_objects import ChunkBbox, ChunkingOptions, ChunkResult from main import app # --------------------------------------------------------------------------- @@ -60,7 +60,52 @@ class TestChunkResult: 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} + assert data == { + "text": "x", + "headings": ["h1"], + "source_page": 1, + "token_count": 10, + "bboxes": [], + } + + +class TestChunkBbox: + def test_construction(self): + bbox = ChunkBbox(page=1, bbox=[10.0, 20.0, 100.0, 80.0]) + assert bbox.page == 1 + assert bbox.bbox == [10.0, 20.0, 100.0, 80.0] + + def test_serializable(self): + bbox = ChunkBbox(page=2, bbox=[0.0, 0.0, 50.0, 50.0]) + data = asdict(bbox) + assert data == {"page": 2, "bbox": [0.0, 0.0, 50.0, 50.0]} + + def test_chunk_result_with_bboxes(self): + chunk = ChunkResult( + text="content", + bboxes=[ + ChunkBbox(page=1, bbox=[10, 20, 100, 80]), + ChunkBbox(page=2, bbox=[50, 50, 150, 250]), + ], + ) + assert len(chunk.bboxes) == 2 + assert chunk.bboxes[0].page == 1 + + +class TestChunkBboxResponse: + def test_serializes(self): + resp = ChunkBboxResponse(page=1, bbox=[10.0, 20.0, 100.0, 80.0]) + data = resp.model_dump(by_alias=True) + assert data == {"page": 1, "bbox": [10.0, 20.0, 100.0, 80.0]} + + def test_chunk_response_with_bboxes(self): + resp = ChunkResponse( + text="hello", + bboxes=[ChunkBboxResponse(page=1, bbox=[10, 20, 100, 80])], + ) + data = resp.model_dump(by_alias=True) + assert len(data["bboxes"]) == 1 + assert data["bboxes"][0]["page"] == 1 # --------------------------------------------------------------------------- @@ -282,6 +327,28 @@ class TestRechunkEndpoint: ) assert resp.status_code == 400 + def test_rechunk_returns_bboxes(self, client, mock_analysis_service): + mock_analysis_service.rechunk = AsyncMock( + return_value=[ + ChunkResult( + text="chunk1", + source_page=1, + token_count=10, + bboxes=[ChunkBbox(page=1, bbox=[10, 20, 100, 80])], + ), + ] + ) + + resp = client.post( + "/api/analyses/j1/rechunk", + json={"chunkingOptions": {"chunker_type": "hybrid"}}, + ) + assert resp.status_code == 200 + data = resp.json() + assert len(data[0]["bboxes"]) == 1 + assert data[0]["bboxes"][0]["page"] == 1 + assert data[0]["bboxes"][0]["bbox"] == [10, 20, 100, 80] + def test_rechunk_invalid_chunker_type(self, client, mock_analysis_service): resp = client.post( "/api/analyses/j1/rechunk", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f8a34bc..fa8af36 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,6 @@ "name": "docling-studio", "version": "0.1.0", "dependencies": { - "@vitest/mocker": "^4.1.2", "dompurify": "^3.3.3", "marked": "^17.0.4", "pinia": "^2.3.0", @@ -19,6 +18,7 @@ "@eslint/js": "^9.0.0", "@types/dompurify": "^3.2.0", "@vitejs/plugin-vue": "^6.0.5", + "@vitest/mocker": "^4.1.2", "eslint": "^9.0.0", "eslint-plugin-vue": "^9.32.0", "prettier": "^3.4.0", @@ -78,6 +78,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "aix" @@ -93,6 +94,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -108,6 +110,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -123,6 +126,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "android" @@ -138,6 +142,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -153,6 +158,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -168,6 +174,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -183,6 +190,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -198,6 +206,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -213,6 +222,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -228,6 +238,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "linux" @@ -243,6 +254,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -258,6 +270,7 @@ "cpu": [ "mips64el" ], + "dev": true, "optional": true, "os": [ "linux" @@ -273,6 +286,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -288,6 +302,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -303,6 +318,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -318,6 +334,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -333,6 +350,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -348,6 +366,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -363,6 +382,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -378,6 +398,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -393,6 +414,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openharmony" @@ -408,6 +430,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "sunos" @@ -423,6 +446,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -438,6 +462,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -453,6 +478,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -661,6 +687,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -673,6 +700,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -685,6 +713,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -697,6 +726,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -709,6 +739,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -721,6 +752,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -733,6 +765,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -745,6 +778,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -757,6 +791,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -769,6 +804,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -781,6 +817,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -793,6 +830,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -805,6 +843,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -817,6 +856,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -829,6 +869,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -841,6 +882,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -853,6 +895,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -865,6 +908,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -877,6 +921,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -889,6 +934,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -901,6 +947,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openharmony" @@ -913,6 +960,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -925,6 +973,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -937,6 +986,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -949,6 +999,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -989,7 +1040,8 @@ "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true }, "node_modules/@types/json-schema": { "version": "7.0.15", @@ -1307,6 +1359,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, "dependencies": { "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", @@ -1372,6 +1425,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, "funding": { "url": "https://opencollective.com/vitest" } @@ -1828,7 +1882,7 @@ "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -2055,6 +2109,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, "dependencies": { "@types/estree": "^1.0.0" } @@ -2099,7 +2154,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "devOptional": true, + "dev": true, "engines": { "node": ">=12.0.0" }, @@ -2163,6 +2218,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -2540,7 +2596,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "devOptional": true, + "dev": true, "engines": { "node": ">=12" }, @@ -2655,7 +2711,7 @@ "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "devOptional": true, + "dev": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -2797,7 +2853,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "devOptional": true, + "dev": true, "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" @@ -2909,7 +2965,7 @@ "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "devOptional": true, + "dev": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/frontend/src/features/analysis/ui/BboxOverlay.vue b/frontend/src/features/analysis/ui/BboxOverlay.vue index c2c0c17..6a17dbd 100644 --- a/frontend/src/features/analysis/ui/BboxOverlay.vue +++ b/frontend/src/features/analysis/ui/BboxOverlay.vue @@ -38,7 +38,7 @@ + + diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index a8769d5..46da3f7 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -1 +1,2 @@ export { default as AppSidebar } from './AppSidebar.vue' +export { default as PaginationBar } from './PaginationBar.vue' diff --git a/frontend/vite.config.js b/frontend/vite.config.js index a537ebb..9a8b9da 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -9,6 +9,10 @@ export default defineConfig({ '/api': { target: 'http://localhost:8000', changeOrigin: true + }, + '/health': { + target: 'http://localhost:8000', + changeOrigin: true } } }