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
}
}
}