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
134 lines
4.7 KiB
Python
134 lines
4.7 KiB
Python
"""Chunk repository — SQLite CRUD for first-class chunks (#205)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import asdict
|
|
from datetime import UTC, datetime
|
|
|
|
from domain.models import Chunk
|
|
from domain.value_objects import ChunkBbox, ChunkDocItem
|
|
from persistence.database import get_connection
|
|
|
|
|
|
def _parse_iso(value: str | None) -> datetime | None:
|
|
if value is None or value == "":
|
|
return None
|
|
parsed = datetime.fromisoformat(value)
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=UTC)
|
|
return parsed
|
|
|
|
|
|
def _row_to_chunk(row) -> Chunk:
|
|
headings = json.loads(row["headings"]) if row["headings"] else []
|
|
bboxes_raw = json.loads(row["bboxes"]) if row["bboxes"] else []
|
|
doc_items_raw = json.loads(row["doc_items"]) if row["doc_items"] else []
|
|
return Chunk(
|
|
id=row["id"],
|
|
document_id=row["document_id"],
|
|
sequence=row["sequence"],
|
|
text=row["text"],
|
|
headings=headings,
|
|
source_page=row["source_page"],
|
|
bboxes=[ChunkBbox(page=b["page"], bbox=b["bbox"]) for b in bboxes_raw],
|
|
doc_items=[ChunkDocItem(self_ref=d["self_ref"], label=d["label"]) for d in doc_items_raw],
|
|
token_count=row["token_count"],
|
|
created_at=_parse_iso(row["created_at"]) or datetime.now(UTC),
|
|
updated_at=_parse_iso(row["updated_at"]) or datetime.now(UTC),
|
|
deleted_at=_parse_iso(row["deleted_at"]),
|
|
)
|
|
|
|
|
|
def _chunk_to_params(c: Chunk) -> tuple:
|
|
return (
|
|
c.id,
|
|
c.document_id,
|
|
c.sequence,
|
|
c.text,
|
|
json.dumps(c.headings),
|
|
c.source_page,
|
|
json.dumps([asdict(b) for b in c.bboxes]),
|
|
json.dumps([asdict(d) for d in c.doc_items]),
|
|
c.token_count,
|
|
str(c.created_at),
|
|
str(c.updated_at),
|
|
str(c.deleted_at) if c.deleted_at else None,
|
|
)
|
|
|
|
|
|
_INSERT_SQL = """INSERT INTO chunks
|
|
(id, document_id, sequence, text, headings, source_page,
|
|
bboxes, doc_items, token_count, created_at, updated_at, deleted_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"""
|
|
|
|
|
|
class SqliteChunkRepository:
|
|
"""SQLite implementation of the ChunkRepository port."""
|
|
|
|
async def insert(self, chunk: Chunk) -> None:
|
|
async with get_connection() as db:
|
|
await db.execute(_INSERT_SQL, _chunk_to_params(chunk))
|
|
await db.commit()
|
|
|
|
async def insert_many(self, chunks: list[Chunk]) -> None:
|
|
if not chunks:
|
|
return
|
|
async with get_connection() as db:
|
|
await db.executemany(_INSERT_SQL, [_chunk_to_params(c) for c in chunks])
|
|
await db.commit()
|
|
|
|
async def update(self, chunk: Chunk) -> None:
|
|
async with get_connection() as db:
|
|
await db.execute(
|
|
"""UPDATE chunks SET
|
|
sequence = ?, text = ?, headings = ?, source_page = ?,
|
|
bboxes = ?, doc_items = ?, token_count = ?,
|
|
updated_at = ?, deleted_at = ?
|
|
WHERE id = ?""",
|
|
(
|
|
chunk.sequence,
|
|
chunk.text,
|
|
json.dumps(chunk.headings),
|
|
chunk.source_page,
|
|
json.dumps([asdict(b) for b in chunk.bboxes]),
|
|
json.dumps([asdict(d) for d in chunk.doc_items]),
|
|
chunk.token_count,
|
|
str(chunk.updated_at),
|
|
str(chunk.deleted_at) if chunk.deleted_at else None,
|
|
chunk.id,
|
|
),
|
|
)
|
|
await db.commit()
|
|
|
|
async def soft_delete(self, chunk_id: str, *, at: datetime) -> bool:
|
|
async with get_connection() as db:
|
|
cursor = await db.execute(
|
|
"UPDATE chunks SET deleted_at = ?, updated_at = ? WHERE id = ?",
|
|
(str(at), str(at), chunk_id),
|
|
)
|
|
await db.commit()
|
|
return cursor.rowcount > 0
|
|
|
|
async def find_for_document(
|
|
self,
|
|
document_id: str,
|
|
*,
|
|
include_deleted: bool = False,
|
|
) -> list[Chunk]:
|
|
clause = "" if include_deleted else " AND deleted_at IS NULL"
|
|
async with get_connection() as db:
|
|
cursor = await db.execute(
|
|
f"""SELECT * FROM chunks
|
|
WHERE document_id = ?{clause}
|
|
ORDER BY sequence ASC""",
|
|
(document_id,),
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return [_row_to_chunk(r) for r in rows]
|
|
|
|
async def find_by_id(self, chunk_id: str) -> Chunk | None:
|
|
async with get_connection() as db:
|
|
cursor = await db.execute("SELECT * FROM chunks WHERE id = ?", (chunk_id,))
|
|
row = await cursor.fetchone()
|
|
return _row_to_chunk(row) if row else None
|