From 4af6b5b2313e343c0629102c70f120499b890d3e Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Thu, 2 Apr 2026 14:47:31 +0200 Subject: [PATCH] Add chunk-to-bbox hover highlighting in Prepare mode Extract bounding boxes from chunk doc_items provenance in the chunker, propagate through domain/service/API layers, and render highlighted bboxes on canvas when hovering a chunk card. Reset highlights on mode and page changes to prevent stale visual state. --- document-parser/api/analyses.py | 9 ++++- document-parser/api/schemas.py | 6 +++ document-parser/domain/value_objects.py | 7 ++++ document-parser/infra/local_chunker.py | 20 ++++++++-- document-parser/services/analysis_service.py | 1 + .../src/features/analysis/ui/BboxOverlay.vue | 40 ++++++++++++++----- .../src/features/chunking/ui/ChunkPanel.vue | 33 ++++++++++++++- frontend/src/pages/StudioPage.vue | 19 ++++++++- frontend/src/shared/types.ts | 6 +++ 9 files changed, 124 insertions(+), 17 deletions(-) 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/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 @@