Merge pull request #33 from scub-france/feature/include-chunking

Feature/include chunking
This commit is contained in:
Pier-Jean Malandrino 2026-04-02 15:21:15 +02:00 committed by GitHub
commit 84645f0d70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 742 additions and 68 deletions

View file

@ -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
]

View file

@ -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):

View file

@ -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)

View file

@ -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,
)
)

View file

@ -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],
}

View file

@ -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",

View file

@ -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",

View file

@ -38,7 +38,7 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, reactive, onMounted, onBeforeUnmount } from 'vue'
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling'
import type { Page, PageElement } from '../../../shared/types'
import type { Page, PageElement, ChunkBbox } from '../../../shared/types'
const ELEMENT_COLORS: Record<string, string> = {
title: '#EF4444',
@ -52,11 +52,15 @@ const ELEMENT_COLORS: Record<string, string> = {
caption: '#EAB308'
}
const props = defineProps<{
imageEl: HTMLImageElement | null
pageData: Page | null
highlightedIndex: number
}>()
const props = withDefaults(
defineProps<{
imageEl: HTMLImageElement | null
pageData: Page | null
highlightedIndex: number
highlightedBboxes: ChunkBbox[]
}>(),
{ highlightedBboxes: () => [] },
)
const emit = defineEmits<{
'highlight-element': [index: number]
@ -114,6 +118,8 @@ function draw(): void {
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
const hasChunkHighlight = props.highlightedBboxes.length > 0
for (const el of visibleElements.value) {
const rect = bboxToRect(el.bbox, scale)
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
@ -121,13 +127,29 @@ function draw(): void {
const elContentIdx = contentElements.value.indexOf(el)
const isHighlighted = props.highlightedIndex >= 0 && elContentIdx === props.highlightedIndex
ctx.strokeStyle = color
// Dim non-highlighted elements when a chunk is hovered
const dimmed = hasChunkHighlight && !isHighlighted
ctx.strokeStyle = dimmed ? color + '40' : color
ctx.lineWidth = isHighlighted ? 3 : 2
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
ctx.fillStyle = color + (isHighlighted ? '40' : '20')
ctx.fillStyle = color + (isHighlighted ? '40' : dimmed ? '08' : '20')
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
}
// Draw chunk-highlighted bboxes on top with accent color
if (hasChunkHighlight) {
const CHUNK_COLOR = '#F59E0B'
for (const cb of props.highlightedBboxes) {
const rect = bboxToRect(cb.bbox, scale)
ctx.strokeStyle = CHUNK_COLOR
ctx.lineWidth = 3
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
ctx.fillStyle = CHUNK_COLOR + '30'
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
}
}
}
function onMouseMove(e: MouseEvent): void {
@ -173,7 +195,7 @@ onBeforeUnmount(() => {
})
// Redraw when data or image changes
watch([() => props.pageData, () => props.imageEl, () => props.highlightedIndex, hiddenTypes], () => {
watch([() => props.pageData, () => props.imageEl, () => props.highlightedIndex, () => props.highlightedBboxes, hiddenTypes], () => {
nextTick(draw)
})

View file

@ -21,8 +21,14 @@ describe('analysis store — chunking', () => {
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 },
{
text: 'chunk1',
headings: ['H1'],
sourcePage: 1,
tokenCount: 10,
bboxes: [{ page: 1, bbox: [10, 20, 100, 80] }],
},
{ text: 'chunk2', headings: [], sourcePage: 2, tokenCount: 20, bboxes: [] },
]
store.currentAnalysis = {
id: 'j1',
@ -64,7 +70,7 @@ describe('analysis store — chunking', () => {
it('rechunk calls API and refreshes analysis', async () => {
const store = useAnalysisStore()
const chunks = [{ text: 'c1', headings: [], sourcePage: 1, tokenCount: 5 }]
const chunks = [{ text: 'c1', headings: [], sourcePage: 1, tokenCount: 5, bboxes: [] }]
vi.mocked(api.rechunkAnalysis).mockResolvedValue(chunks)
vi.mocked(api.fetchAnalysis).mockResolvedValue({
id: 'j1',

View file

@ -1,10 +1,24 @@
<template>
<div class="chunk-panel">
<!-- Chunking config -->
<!-- Chunking config collapsible -->
<div class="chunk-config">
<div class="config-section">
<label class="config-label">{{ t('chunking.settings') }}</label>
<button class="config-toggle" @click="configOpen = !configOpen">
<svg
class="config-chevron"
:class="{ open: configOpen }"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
<span class="config-label">{{ t('chunking.settings') }}</span>
</button>
<div v-if="configOpen" class="config-body">
<div class="config-row">
<label class="config-label-sm">{{ t('chunking.chunkerType') }}</label>
<select class="config-select" v-model="options.chunker_type">
@ -40,31 +54,34 @@
<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>
<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>
</div>
<!-- Chunks list -->
<div class="chunk-results" v-if="analysisStore.currentChunks.length">
<div class="chunk-results" v-if="pageChunks.length">
<div class="chunk-summary">
{{ analysisStore.currentChunks.length }} {{ t('chunking.chunks') }}
{{ pagination.totalItems.value }} {{ t('chunking.chunks') }}
</div>
<div class="chunk-list">
<div
class="chunk-card"
v-for="(chunk, idx) in analysisStore.currentChunks"
:key="idx"
v-for="(chunk, localIdx) in pagination.paginatedItems.value"
:key="globalIndex(localIdx)"
:class="{ highlighted: hoveredChunkIdx === globalIndex(localIdx) }"
@mouseenter="onChunkHover(chunk, localIdx)"
@mouseleave="onChunkLeave"
>
<div class="chunk-header">
<span class="chunk-index">#{{ idx + 1 }}</span>
<span class="chunk-index">#{{ globalIndex(localIdx) + 1 }}</span>
<span class="chunk-tokens" v-if="chunk.tokenCount">
{{ chunk.tokenCount }} tokens
</span>
@ -81,20 +98,47 @@
</div>
<div class="chunk-empty" v-else-if="!analysisStore.rechunking">
<p>{{ t('chunking.noChunks') }}</p>
<p>
{{
analysisStore.currentChunks.length
? t('chunking.noChunksOnPage')
: t('chunking.noChunks')
}}
</p>
</div>
<!-- Pagination -->
<PaginationBar
:page="pagination.page.value"
:page-count="pagination.pageCount.value"
:page-size="pagination.pageSize.value"
@update:page="pagination.goTo($event)"
@update:page-size="pagination.setPageSize($event)"
/>
</div>
</template>
<script setup lang="ts">
import { reactive, computed } from 'vue'
import { ref, reactive, computed } from 'vue'
import { useAnalysisStore } from '../../analysis/store'
import { useI18n } from '../../../shared/i18n'
import type { ChunkingOptions } from '../../../shared/types'
import { usePagination } from '../../../shared/composables/usePagination'
import { PaginationBar } from '../../../shared/ui'
import type { Chunk, ChunkBbox, ChunkingOptions } from '../../../shared/types'
const props = defineProps<{
currentPage: number
}>()
const emit = defineEmits<{
'highlight-bboxes': [bboxes: ChunkBbox[]]
}>()
const analysisStore = useAnalysisStore()
const { t } = useI18n()
const configOpen = ref(true)
const options = reactive<Required<ChunkingOptions>>({
chunker_type: 'hybrid',
max_tokens: 512,
@ -107,6 +151,28 @@ const canRechunk = computed(() => {
return analysis?.status === 'COMPLETED' && analysis.hasDocumentJson
})
const pageChunks = computed(() =>
analysisStore.currentChunks.filter((c) => c.sourcePage === props.currentPage),
)
const pagination = usePagination(pageChunks, { pageSize: 20 })
function globalIndex(localIdx: number): number {
return (pagination.page.value - 1) * pagination.pageSize.value + localIdx
}
const hoveredChunkIdx = ref(-1)
function onChunkHover(chunk: Chunk, localIdx: number) {
hoveredChunkIdx.value = globalIndex(localIdx)
const pageBboxes = chunk.bboxes.filter((b) => b.page === props.currentPage)
emit('highlight-bboxes', pageBboxes)
}
function onChunkLeave() {
hoveredChunkIdx.value = -1
emit('highlight-bboxes', [])
}
async function doRechunk() {
if (!analysisStore.currentAnalysis) return
await analysisStore.rechunk(analysisStore.currentAnalysis.id, { ...options })
@ -122,17 +188,35 @@ async function doRechunk() {
}
.chunk-config {
padding: 16px;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 12px;
flex-shrink: 0;
}
.config-section {
.config-toggle {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
gap: 6px;
width: 100%;
padding: 10px 16px;
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
}
.config-toggle:hover {
color: var(--text);
}
.config-chevron {
width: 14px;
height: 14px;
transition: transform 0.2s;
transform: rotate(0deg);
}
.config-chevron.open {
transform: rotate(90deg);
}
.config-label {
@ -140,7 +224,13 @@ async function doRechunk() {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
}
.config-body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 16px 16px;
}
.config-label-sm {
@ -225,6 +315,7 @@ async function doRechunk() {
align-items: center;
justify-content: center;
gap: 6px;
margin-top: 4px;
}
.chunk-btn.primary {
@ -241,6 +332,7 @@ async function doRechunk() {
flex: 1;
overflow-y: auto;
padding: 12px;
min-height: 0;
}
.chunk-summary {
@ -263,6 +355,17 @@ async function doRechunk() {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
cursor: default;
transition: border-color 0.15s, background 0.15s;
}
.chunk-card:hover {
border-color: #F59E0B;
}
.chunk-card.highlighted {
border-color: #F59E0B;
background: rgba(245, 158, 11, 0.08);
}
.chunk-header {
@ -331,6 +434,8 @@ async function doRechunk() {
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -7,10 +7,6 @@ vi.mock('../../shared/api/http', () => ({
apiFetch: (...args: unknown[]) => mockApiFetch(...args),
}))
vi.mock('../settings/store', () => ({
useSettingsStore: () => ({ apiUrl: 'http://localhost:8000' }),
}))
describe('useFeatureFlagStore', () => {
beforeEach(() => {
setActivePinia(createPinia())

View file

@ -1,7 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiFetch } from '../../shared/api/http'
import { useSettingsStore } from '../settings/store'
type ConversionEngine = 'local' | 'remote'
@ -44,9 +43,8 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
}
async function load(): Promise<void> {
const settings = useSettingsStore()
try {
const data = await apiFetch<HealthResponse>(`${settings.apiUrl}/health`)
const data = await apiFetch<HealthResponse>('/health')
engine.value = data.engine
loaded.value = true
error.value = null

View file

@ -136,11 +136,12 @@
@load="onPdfImageLoad"
/>
<BboxOverlay
v-if="visualMode && hasAnalysisResults"
v-if="(visualMode || mode === 'preparer') && hasAnalysisResults"
ref="bboxOverlayRef"
:image-el="pdfImageRef"
:page-data="currentPageData"
:highlighted-index="highlightedElementIndex"
:highlighted-bboxes="highlightedChunkBboxes"
@highlight-element="highlightedElementIndex = $event"
/>
</div>
@ -292,7 +293,10 @@
<!-- PREPARER MODE (feature-flipped) -->
<div v-if="mode === 'preparer' && chunkingEnabled" class="prepare-panel">
<ChunkPanel />
<ChunkPanel
:current-page="currentPage"
@highlight-bboxes="highlightedChunkBboxes = $event"
/>
</div>
</div>
</div>
@ -311,7 +315,7 @@ import { ChunkPanel } from '../features/chunking'
import { useFeatureFlag } from '../features/feature-flags'
import { getPreviewUrl } from '../features/document/api'
import { useI18n } from '../shared/i18n'
import type { PipelineOptions } from '../shared/types'
import type { ChunkBbox, PipelineOptions } from '../shared/types'
const route = useRoute()
const router = useRouter()
@ -324,6 +328,7 @@ const mode = ref('configurer')
const currentPage = ref(1)
const visualMode = ref(false)
const highlightedElementIndex = ref(-1)
const highlightedChunkBboxes = ref<ChunkBbox[]>([])
const pdfImageRef = ref<HTMLImageElement | null>(null)
const bboxOverlayRef = ref<InstanceType<typeof BboxOverlay> | null>(null)
@ -410,6 +415,16 @@ function addMore() {
documentStore.selectedId = null
}
// Clear highlights when switching modes or pages
watch(mode, () => {
highlightedElementIndex.value = -1
highlightedChunkBboxes.value = []
})
watch(currentPage, () => {
highlightedElementIndex.value = -1
highlightedChunkBboxes.value = []
})
// Auto-switch to verifier when analysis completes + refresh document data (pageCount)
watch(() => analysisStore.currentAnalysis?.status, (status) => {
if (status === 'COMPLETED') {
@ -1136,7 +1151,8 @@ onBeforeUnmount(() => {
}
/* Verify panel */
.verify-panel {
.verify-panel,
.prepare-panel {
height: 100%;
overflow: hidden;
display: flex;

View file

@ -0,0 +1,149 @@
import { describe, it, expect } from 'vitest'
import { ref, nextTick } from 'vue'
import { usePagination } from './usePagination'
function makeItems(n: number): string[] {
return Array.from({ length: n }, (_, i) => `item-${i + 1}`)
}
describe('usePagination', () => {
it('returns all items when total fits in one page', () => {
const items = ref(makeItems(5))
const { paginatedItems, pageCount, page } = usePagination(items, { pageSize: 20 })
expect(paginatedItems.value).toHaveLength(5)
expect(pageCount.value).toBe(1)
expect(page.value).toBe(1)
})
it('slices items according to page size', () => {
const items = ref(makeItems(25))
const { paginatedItems, pageCount } = usePagination(items, { pageSize: 10 })
expect(paginatedItems.value).toHaveLength(10)
expect(paginatedItems.value[0]).toBe('item-1')
expect(pageCount.value).toBe(3)
})
it('navigates with next and prev', () => {
const items = ref(makeItems(30))
const { paginatedItems, page, next, prev } = usePagination(items, { pageSize: 10 })
next()
expect(page.value).toBe(2)
expect(paginatedItems.value[0]).toBe('item-11')
next()
expect(page.value).toBe(3)
expect(paginatedItems.value[0]).toBe('item-21')
prev()
expect(page.value).toBe(2)
expect(paginatedItems.value[0]).toBe('item-11')
})
it('clamps page within valid range', () => {
const items = ref(makeItems(10))
const { page, prev, next, goTo } = usePagination(items, { pageSize: 5 })
prev()
expect(page.value).toBe(1)
next()
next()
expect(page.value).toBe(2)
goTo(99)
expect(page.value).toBe(2)
goTo(0)
expect(page.value).toBe(1)
})
it('goTo navigates to a specific page', () => {
const items = ref(makeItems(50))
const { page, paginatedItems, goTo } = usePagination(items, { pageSize: 10 })
goTo(3)
expect(page.value).toBe(3)
expect(paginatedItems.value[0]).toBe('item-21')
goTo(5)
expect(page.value).toBe(5)
expect(paginatedItems.value[0]).toBe('item-41')
})
it('resets to page 1 when items change', async () => {
const items = ref(makeItems(30))
const { page, next } = usePagination(items, { pageSize: 10 })
next()
next()
expect(page.value).toBe(3)
items.value = makeItems(5)
await nextTick()
expect(page.value).toBe(1)
})
it('resets to page 1 when page size changes', async () => {
const items = ref(makeItems(50))
const { page, next, setPageSize } = usePagination(items, { pageSize: 10 })
next()
next()
expect(page.value).toBe(3)
setPageSize(20)
await nextTick()
expect(page.value).toBe(1)
})
it('handles last page with fewer items', () => {
const items = ref(makeItems(23))
const { paginatedItems, goTo } = usePagination(items, { pageSize: 10 })
goTo(3)
expect(paginatedItems.value).toHaveLength(3)
expect(paginatedItems.value[0]).toBe('item-21')
})
it('handles empty items', () => {
const items = ref<string[]>([])
const { paginatedItems, pageCount, totalItems } = usePagination(items)
expect(paginatedItems.value).toHaveLength(0)
expect(pageCount.value).toBe(1)
expect(totalItems.value).toBe(0)
})
it('exposes totalItems as reactive count', async () => {
const items = ref(makeItems(10))
const { totalItems } = usePagination(items)
expect(totalItems.value).toBe(10)
items.value = makeItems(42)
await nextTick()
expect(totalItems.value).toBe(42)
})
it('defaults page size to 20', () => {
const items = ref(makeItems(50))
const { pageSize, paginatedItems } = usePagination(items)
expect(pageSize.value).toBe(20)
expect(paginatedItems.value).toHaveLength(20)
})
it('ignores invalid page size', () => {
const items = ref(makeItems(10))
const { pageSize, setPageSize } = usePagination(items, { pageSize: 5 })
setPageSize(0)
expect(pageSize.value).toBe(5)
setPageSize(-10)
expect(pageSize.value).toBe(5)
})
})

View file

@ -0,0 +1,71 @@
import { ref, computed, watch, type Ref } from 'vue'
export interface PaginationOptions {
pageSize?: number
}
export interface PaginationReturn<T> {
page: Ref<number>
pageSize: Ref<number>
pageCount: Ref<number>
paginatedItems: Ref<T[]>
totalItems: Ref<number>
next: () => void
prev: () => void
goTo: (target: number) => void
setPageSize: (size: number) => void
}
export function usePagination<T>(
items: Ref<T[]>,
options: PaginationOptions = {},
): PaginationReturn<T> {
const page = ref(1)
const pageSize = ref(options.pageSize ?? 20)
const totalItems = computed(() => items.value.length)
const pageCount = computed(() => Math.max(1, Math.ceil(totalItems.value / pageSize.value)))
const paginatedItems = computed(() => {
const start = (page.value - 1) * pageSize.value
return items.value.slice(start, start + pageSize.value)
})
// Reset to page 1 when source data or page size changes
watch([items, pageSize], () => {
page.value = 1
})
function clamp(target: number): number {
return Math.max(1, Math.min(target, pageCount.value))
}
function goTo(target: number): void {
page.value = clamp(target)
}
function next(): void {
goTo(page.value + 1)
}
function prev(): void {
goTo(page.value - 1)
}
function setPageSize(size: number): void {
if (size > 0) pageSize.value = size
}
return {
page,
pageSize,
pageCount,
paginatedItems,
totalItems,
next,
prev,
goTo,
setPageSize,
}
}

View file

@ -106,6 +106,11 @@ const messages: Messages = {
'chunking.chunking': 'Chunking...',
'chunking.chunks': 'chunks',
'chunking.noChunks': 'Lancez le chunking pour préparer les segments.',
'chunking.noChunksOnPage': 'Aucun chunk sur cette page.',
// Pagination
'pagination.pageOf': 'Page {current} sur {total}',
'pagination.perPage': '/ page',
// Settings
'settings.title': 'Paramètres',
@ -207,6 +212,10 @@ const messages: Messages = {
'chunking.chunking': 'Chunking...',
'chunking.chunks': 'chunks',
'chunking.noChunks': 'Run chunking to prepare segments.',
'chunking.noChunksOnPage': 'No chunks on this page.',
'pagination.pageOf': 'Page {current} of {total}',
'pagination.perPage': '/ page',
'settings.title': 'Settings',
'settings.apiUrl': 'API URL',

View file

@ -45,11 +45,17 @@ export interface ChunkingOptions {
repeat_table_header?: boolean
}
export interface ChunkBbox {
page: number
bbox: [number, number, number, number]
}
export interface Chunk {
text: string
headings: string[]
sourcePage: number | null
tokenCount: number
bboxes: ChunkBbox[]
}
export interface PageElement {

View file

@ -0,0 +1,135 @@
<template>
<div v-if="pageCount > 1" class="pagination-bar">
<div class="pagination-nav">
<button class="pagination-btn" :disabled="page <= 1" @click="$emit('update:page', page - 1)">
<svg viewBox="0 0 20 20" fill="currentColor" class="pagination-icon">
<path
fill-rule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</button>
<span class="pagination-info">
{{ t('pagination.pageOf', { current: String(page), total: String(pageCount) }) }}
</span>
<button
class="pagination-btn"
:disabled="page >= pageCount"
@click="$emit('update:page', page + 1)"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="pagination-icon">
<path
fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<div class="pagination-size">
<select
:value="pageSize"
class="pagination-select"
@change="$emit('update:pageSize', Number(($event.target as HTMLSelectElement).value))"
>
<option v-for="size in pageSizeOptions" :key="size" :value="size">
{{ size }} {{ t('pagination.perPage') }}
</option>
</select>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from '../i18n'
defineProps<{
page: number
pageCount: number
pageSize: number
}>()
defineEmits<{
'update:page': [value: number]
'update:pageSize': [value: number]
}>()
const { t } = useI18n()
const pageSizeOptions = [5, 10, 20, 50]
</script>
<style scoped>
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-top: 1px solid var(--border);
gap: 8px;
flex-shrink: 0;
}
.pagination-nav {
display: flex;
align-items: center;
gap: 4px;
}
.pagination-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
cursor: pointer;
transition: all 0.15s;
}
.pagination-btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.pagination-btn:disabled {
opacity: 0.35;
cursor: default;
}
.pagination-icon {
width: 14px;
height: 14px;
}
.pagination-info {
font-size: 12px;
color: var(--text-muted);
padding: 0 6px;
white-space: nowrap;
}
.pagination-size {
flex-shrink: 0;
}
.pagination-select {
font-size: 11px;
padding: 3px 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text-muted);
cursor: pointer;
}
.pagination-select:hover {
border-color: var(--accent);
}
</style>

View file

@ -1 +1,2 @@
export { default as AppSidebar } from './AppSidebar.vue'
export { default as PaginationBar } from './PaginationBar.vue'

View file

@ -9,6 +9,10 @@ export default defineConfig({
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
},
'/health': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
}