The data and domain layers for the chunks editor (#219-224 in 0.6.0). Chunks were previously stored as a JSON blob in analysis_jobs.chunks_json; this commit makes them first-class persisted entities with stable IDs, soft-delete, and an immutable audit log. Domain - Chunk: persistent entity with id, document_id, sequence, text, headings, source_page, bboxes, doc_items, token_count, timestamps, deleted_at (soft delete) - ChunkEdit: immutable audit row (action, actor, at, before, after, parents, children, reason) - ChunkPush: snapshot of which chunk_ids landed in which store at push - ChunkEditAction enum: insert/update/delete/merge/split - domain/chunk_editing.py: pure operations on a chunkset (insert, update, delete, merge, split). Each returns a new chunkset and the affected chunk(s); errors raise ChunkEditingError. Persistence - Three new tables: chunks, chunk_edits, chunk_pushes (FK + indexes) - SqliteChunkRepository (insert, insert_many, update, soft_delete, find_for_document, find_by_id; respects deleted_at) - SqliteChunkEditRepository (append-only audit log; paginated reads ordered newest-first; per-chunk history) - SqliteChunkPushRepository (per-(doc, store) latest snapshot) Ports - ChunkRepository, ChunkEditRepository, ChunkPushRepository protocols added to domain/ports.py Tests - 17 tests for the pure chunk-editing operations covering insert / update / delete / merge / split, sequence shifts, lineage, error paths (out-of-range, missing id, deleted target, cross-document) - 11 tests for the three repositories: round-trips, soft-delete filtering, history ordering, lineage round-trip, cascade-delete with document, find_latest semantics Service orchestration (ChunkEditingService — atomic chunk + audit write) and the API endpoints land in a follow-up commit on the same feature branch / next release. The data + domain foundation here is what unblocks #219-224. Refs #205
216 lines
6.8 KiB
Python
216 lines
6.8 KiB
Python
"""Pure-domain operations on a chunkset.
|
|
|
|
These functions take a chunkset as input and return a new chunkset
|
|
(plus, where appropriate, the chunks that were created). They do not
|
|
touch the database, do not record audit rows, and do not raise
|
|
infrastructure errors. The `ChunkEditingService` (in `services/`)
|
|
wraps each call with audit-record generation and atomic persistence.
|
|
|
|
All operations preserve `sequence` ordering: insertions and splits
|
|
shift subsequent sequences upward; deletions / merges leave gaps.
|
|
Sequences are 0-based and only required to be strictly increasing
|
|
within a document; gaps are explicitly allowed so we never rewrite
|
|
many rows for a single edit.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
|
|
from domain.models import Chunk
|
|
|
|
|
|
class ChunkEditingError(Exception):
|
|
"""Raised by chunk-editing operations on invalid input (missing id,
|
|
out-of-range offset, etc.). Subclasses `Exception` rather than
|
|
`DomainError` so the API layer can map all of them to 4xx without
|
|
a wider catch."""
|
|
|
|
|
|
def _utcnow() -> datetime:
|
|
return datetime.now(UTC)
|
|
|
|
|
|
def _new_id() -> str:
|
|
return uuid.uuid4().hex
|
|
|
|
|
|
def _index_of(chunks: list[Chunk], chunk_id: str) -> int:
|
|
for idx, c in enumerate(chunks):
|
|
if c.id == chunk_id:
|
|
return idx
|
|
raise ChunkEditingError(f"chunk not found: {chunk_id}")
|
|
|
|
|
|
def insert(
|
|
chunks: list[Chunk],
|
|
*,
|
|
at_position: int,
|
|
text: str,
|
|
document_id: str,
|
|
headings: list[str] | None = None,
|
|
source_page: int | None = None,
|
|
) -> tuple[list[Chunk], Chunk]:
|
|
"""Insert a fresh chunk at position `at_position`.
|
|
|
|
Returns the updated chunkset and the new chunk. Subsequent chunks'
|
|
sequences are shifted by +1.
|
|
"""
|
|
if at_position < 0 or at_position > len(chunks):
|
|
raise ChunkEditingError(f"insert position out of range: {at_position}")
|
|
now = _utcnow()
|
|
new_chunk = Chunk(
|
|
id=_new_id(),
|
|
document_id=document_id,
|
|
sequence=at_position,
|
|
text=text,
|
|
headings=list(headings or []),
|
|
source_page=source_page,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
out = list(chunks)
|
|
for c in out[at_position:]:
|
|
c.sequence += 1
|
|
out.insert(at_position, new_chunk)
|
|
return out, new_chunk
|
|
|
|
|
|
def update(
|
|
chunks: list[Chunk],
|
|
chunk_id: str,
|
|
*,
|
|
text: str | None = None,
|
|
headings: list[str] | None = None,
|
|
) -> tuple[list[Chunk], Chunk]:
|
|
"""Update text and/or headings of a chunk in-place.
|
|
|
|
Returns the updated chunkset and the modified chunk. The chunk's
|
|
id and sequence are preserved.
|
|
"""
|
|
idx = _index_of(chunks, chunk_id)
|
|
target = chunks[idx]
|
|
if target.deleted_at is not None:
|
|
raise ChunkEditingError(f"chunk is deleted: {chunk_id}")
|
|
if text is not None:
|
|
target.text = text
|
|
if headings is not None:
|
|
target.headings = list(headings)
|
|
target.updated_at = _utcnow()
|
|
return list(chunks), target
|
|
|
|
|
|
def delete(chunks: list[Chunk], chunk_id: str) -> tuple[list[Chunk], Chunk]:
|
|
"""Soft-delete a chunk. Returns the updated chunkset and the
|
|
deleted chunk (still present in the list, with `deleted_at` set)."""
|
|
idx = _index_of(chunks, chunk_id)
|
|
target = chunks[idx]
|
|
if target.deleted_at is not None:
|
|
return list(chunks), target # idempotent
|
|
target.deleted_at = _utcnow()
|
|
target.updated_at = target.deleted_at
|
|
return list(chunks), target
|
|
|
|
|
|
def merge(
|
|
chunks: list[Chunk],
|
|
chunk_ids: list[str],
|
|
*,
|
|
separator: str = "\n",
|
|
) -> tuple[list[Chunk], Chunk]:
|
|
"""Merge `chunk_ids` (in order) into a single new chunk.
|
|
|
|
The new chunk takes the headings of the first source and the
|
|
smallest source page. The `id`s of the sources are returned in
|
|
the new chunk's lineage via the audit row written by the service
|
|
layer.
|
|
|
|
Returns the updated chunkset and the new merged chunk. The sources
|
|
are removed from the chunkset (hard-removed from the list — the
|
|
service is responsible for soft-deleting their persisted rows so
|
|
history queries still resolve them).
|
|
"""
|
|
if len(chunk_ids) < 2:
|
|
raise ChunkEditingError("merge requires at least two chunks")
|
|
indices = [_index_of(chunks, cid) for cid in chunk_ids]
|
|
sources = [chunks[i] for i in indices]
|
|
if any(c.deleted_at for c in sources):
|
|
raise ChunkEditingError("cannot merge a deleted chunk")
|
|
|
|
document_id = sources[0].document_id
|
|
if any(c.document_id != document_id for c in sources):
|
|
raise ChunkEditingError("merge across documents is not allowed")
|
|
|
|
merged_text = separator.join(c.text for c in sources)
|
|
merged_headings = list(sources[0].headings)
|
|
merged_page = min((c.source_page for c in sources if c.source_page is not None), default=None)
|
|
merged_sequence = min(c.sequence for c in sources)
|
|
|
|
now = _utcnow()
|
|
new_chunk = Chunk(
|
|
id=_new_id(),
|
|
document_id=document_id,
|
|
sequence=merged_sequence,
|
|
text=merged_text,
|
|
headings=merged_headings,
|
|
source_page=merged_page,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
|
|
source_ids = {c.id for c in sources}
|
|
out = [c for c in chunks if c.id not in source_ids]
|
|
out.append(new_chunk)
|
|
out.sort(key=lambda c: c.sequence)
|
|
return out, new_chunk
|
|
|
|
|
|
def split(
|
|
chunks: list[Chunk], chunk_id: str, *, at_offset: int
|
|
) -> tuple[list[Chunk], Chunk, Chunk]:
|
|
"""Split a chunk's text at `at_offset` into two new chunks.
|
|
|
|
The source chunk is removed from the chunkset (the service-layer
|
|
persistence path soft-deletes its row so history queries can
|
|
resolve it). The two new chunks inherit headings and source_page
|
|
from the source.
|
|
|
|
Returns the updated chunkset and the two new chunks `(left, right)`.
|
|
"""
|
|
idx = _index_of(chunks, chunk_id)
|
|
target = chunks[idx]
|
|
if target.deleted_at is not None:
|
|
raise ChunkEditingError(f"chunk is deleted: {chunk_id}")
|
|
if at_offset <= 0 or at_offset >= len(target.text):
|
|
raise ChunkEditingError(f"split offset out of range for chunk of length {len(target.text)}")
|
|
|
|
now = _utcnow()
|
|
left = Chunk(
|
|
id=_new_id(),
|
|
document_id=target.document_id,
|
|
sequence=target.sequence,
|
|
text=target.text[:at_offset],
|
|
headings=list(target.headings),
|
|
source_page=target.source_page,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
right = Chunk(
|
|
id=_new_id(),
|
|
document_id=target.document_id,
|
|
sequence=target.sequence + 1,
|
|
text=target.text[at_offset:],
|
|
headings=list(target.headings),
|
|
source_page=target.source_page,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
|
|
out = [c for c in chunks if c.id != target.id]
|
|
for c in out:
|
|
if c.sequence > target.sequence:
|
|
c.sequence += 1
|
|
out.extend((left, right))
|
|
out.sort(key=lambda c: c.sequence)
|
|
return out, left, right
|