Files
RAG/backend/app/persistence/documents.py
YoVinchen ecdb10c37a
Some checks failed
verify / verify (push) Has been cancelled
Make the governed RAG evidence path executable end to end
Separate local parsing from model indexing, bind review decisions to immutable manifests, persist vectors behind active profiles, and expose retrieval, chat, evaluation, and document workflows through the React workbench.

Constraint: Live Bailian authentication currently fails for all three configured capabilities

Rejected: Direct upload-to-embedding flow | bypasses local review and manifest binding

Confidence: high

Scope-risk: broad

Directive: Keep private-data deployment blocked until authentication, RBAC, and separate database roles land

Tested: make verify; fresh and replay Docker document smoke; worker recovery smoke; frozen synthetic evaluation; migration 0003-0004 roundtrip

Not-tested: Successful live Bailian calls, OCR, real multi-user authorization
2026-07-13 05:58:11 +08:00

1022 lines
30 KiB
Python

"""Short-transaction PostgreSQL persistence for governed document ingestion."""
from __future__ import annotations
import hashlib
import json
import re
import uuid
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Protocol, cast
import psycopg
from psycopg import Connection
from psycopg.rows import dict_row
from app.core.config import Settings
from app.core.secrets import SecretFileError
type DocumentRow = dict[str, Any]
type ConnectionFactory = Callable[[str, int], Connection[DocumentRow]]
_HASH = re.compile(r"^[0-9a-f]{64}$")
@dataclass(frozen=True, slots=True)
class DocumentActor:
subject: str
knowledge_base_id: uuid.UUID
access_scope_id: uuid.UUID
@dataclass(frozen=True, slots=True)
class DocumentUpload:
id: uuid.UUID
filename: str
declared_mime_type: str
expected_size: int
expected_sha256: str
storage_key: uuid.UUID
actual_size: int | None
actual_sha256: str | None
status: str
document_id: uuid.UUID | None
parse_job_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
completed_at: datetime | None
@dataclass(frozen=True, slots=True)
class SafeJob:
id: uuid.UUID
job_type: str
stage: str
status: str
progress: int
attempt: int
max_attempts: int
last_error_code: str | None
created_at: datetime
updated_at: datetime
finished_at: datetime | None
@dataclass(frozen=True, slots=True)
class DocumentSummary:
id: uuid.UUID
filename: str
mime_type: str
raw_sha256: str
status: str
active_version_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
@dataclass(frozen=True, slots=True)
class DocumentDetail:
document: DocumentSummary
version_count: int
page_count: int
block_count: int
chunk_count: int
@dataclass(frozen=True, slots=True)
class ReviewVersion:
id: uuid.UUID
review_state: str
review_revision: int
status: str
parser_profile_hash: str
chunk_profile_hash: str
cloud_policy_id: str
outbound_manifest_sha256: str | None
expected_chunk_count: int | None
error_code: str | None
created_at: datetime
completed_at: datetime | None
@dataclass(frozen=True, slots=True)
class ReviewPage:
id: uuid.UUID
ordinal: int
page_number: int | None
text: str
text_sha256: str
line_start: int
line_end: int
@dataclass(frozen=True, slots=True)
class ReviewBlock:
id: uuid.UUID
ordinal: int
kind: str
text: str
text_sha256: str
section_path: tuple[str, ...]
anchor_id: str
char_start: int
char_end: int
line_start: int
line_end: int
page_start: int | None
page_end: int | None
@dataclass(frozen=True, slots=True)
class ReviewChunk:
ordinal: int
display_text: str
cloud_text: str
cloud_text_sha256: str
embedding_text_sha256: str
token_count: int
page_start: int | None
page_end: int | None
section_path: tuple[str, ...]
approval_status: str
index_status: str
@dataclass(frozen=True, slots=True)
class ReviewBundle:
document: DocumentSummary
version: ReviewVersion | None
pages: tuple[ReviewPage, ...]
blocks: tuple[ReviewBlock, ...]
chunks: tuple[ReviewChunk, ...]
next_ordinal: int | None
@dataclass(frozen=True, slots=True)
class DocumentListPage:
items: tuple[DocumentSummary, ...]
next_cursor: uuid.UUID | None
@dataclass(frozen=True, slots=True)
class CompletedUpload:
upload: DocumentUpload
document: DocumentSummary
job: SafeJob
class DocumentPersistenceError(RuntimeError):
def __init__(self) -> None:
super().__init__("document persistence unavailable")
class IdempotencyConflictError(RuntimeError):
def __init__(self) -> None:
super().__init__("idempotency key conflicts with an earlier request")
class UploadStateConflictError(RuntimeError):
def __init__(self) -> None:
super().__init__("document upload state does not permit this operation")
class DocumentsRepository(Protocol):
def create_upload(
self,
*,
actor: DocumentActor,
idempotency_key_hash: str,
request_fingerprint: str,
filename: str,
declared_mime_type: str,
expected_size: int,
expected_sha256: str,
storage_key: uuid.UUID,
trace_id: uuid.UUID,
) -> tuple[DocumentUpload, bool]: ...
def get_upload(self, actor: DocumentActor, upload_id: uuid.UUID) -> DocumentUpload | None: ...
def mark_upload_stored(
self,
*,
actor: DocumentActor,
upload_id: uuid.UUID,
actual_size: int,
actual_sha256: str,
trace_id: uuid.UUID,
) -> DocumentUpload: ...
def complete_upload(
self,
*,
actor: DocumentActor,
upload_id: uuid.UUID,
trace_id: uuid.UUID,
) -> CompletedUpload: ...
def get_job(self, actor: DocumentActor, job_id: uuid.UUID) -> SafeJob | None: ...
def list_documents(
self,
actor: DocumentActor,
*,
cursor: uuid.UUID | None,
limit: int,
) -> DocumentListPage: ...
def get_document(
self, actor: DocumentActor, document_id: uuid.UUID
) -> DocumentDetail | None: ...
def get_review_bundle(
self,
actor: DocumentActor,
document_id: uuid.UUID,
*,
after_ordinal: int,
limit: int,
) -> ReviewBundle | None: ...
CREATE_UPLOAD_SQL = """
WITH inserted AS (
INSERT INTO rag.document_uploads (
actor_subject,
knowledge_base_id,
access_scope_id,
idempotency_key_hash,
request_fingerprint,
original_filename,
declared_mime_type,
expected_size,
expected_sha256,
storage_key
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (actor_subject, idempotency_key_hash) DO NOTHING
RETURNING *, true AS created
), audit AS (
INSERT INTO rag.document_upload_events (
upload_id, actor_subject, event_type, trace_id
)
SELECT id, actor_subject, 'CREATED', %s
FROM inserted
), selected AS (
SELECT *, false AS created
FROM rag.document_uploads
WHERE actor_subject = %s
AND knowledge_base_id = %s
AND access_scope_id = %s
AND idempotency_key_hash = %s
)
SELECT * FROM inserted
UNION ALL
SELECT * FROM selected
LIMIT 1
"""
GET_UPLOAD_SQL = """
SELECT *
FROM rag.document_uploads
WHERE id = %s
AND actor_subject = %s
AND knowledge_base_id = %s
AND access_scope_id = %s
LIMIT 1
"""
MARK_STORED_SQL = """
WITH locked AS (
SELECT *
FROM rag.document_uploads
WHERE id = %s
AND actor_subject = %s
AND knowledge_base_id = %s
AND access_scope_id = %s
AND status IN ('CREATED', 'STORED')
FOR UPDATE
), updated AS (
UPDATE rag.document_uploads AS upload
SET status = 'STORED',
actual_size = %s,
actual_sha256 = %s,
updated_at = now()
FROM locked
WHERE upload.id = locked.id
AND locked.expected_size = %s
AND locked.expected_sha256 = %s
RETURNING upload.*, locked.status AS previous_status
), audit AS (
INSERT INTO rag.document_upload_events (
upload_id, actor_subject, event_type, trace_id
)
SELECT id, actor_subject, 'STORED', %s
FROM updated
WHERE previous_status = 'CREATED'
)
SELECT * FROM updated
"""
COMPLETE_UPLOAD_SQL = """
WITH locked AS (
SELECT *
FROM rag.document_uploads
WHERE id = %s
AND actor_subject = %s
AND knowledge_base_id = %s
AND access_scope_id = %s
AND status IN ('STORED', 'COMPLETED')
FOR UPDATE
), upserted_document AS (
INSERT INTO rag.documents (
knowledge_base_id,
access_scope_id,
raw_sha256,
filename,
storage_key,
mime_type,
status
)
SELECT
knowledge_base_id,
access_scope_id,
actual_sha256,
original_filename,
storage_key::text,
declared_mime_type,
'QUARANTINED_LOCAL_REVIEW'
FROM locked
ON CONFLICT (knowledge_base_id, raw_sha256)
DO UPDATE SET updated_at = rag.documents.updated_at
WHERE rag.documents.access_scope_id = EXCLUDED.access_scope_id
RETURNING rag.documents.*
), upserted_job AS (
INSERT INTO rag.background_jobs (
job_type,
required_capability,
resource_type,
resource_id,
idempotency_key,
payload,
stage,
status,
max_attempts
)
SELECT
'PARSE_DOCUMENT',
'document_parse',
'document',
document.id,
'parse-document:' || document.id::text || ':' || document.raw_sha256,
jsonb_build_object(
'document_id', document.id::text,
'upload_id', locked.id::text
),
'PENDING',
'QUEUED',
3
FROM upserted_document AS document
CROSS JOIN locked
ON CONFLICT (job_type, idempotency_key)
DO UPDATE SET updated_at = rag.background_jobs.updated_at
RETURNING rag.background_jobs.*
), updated_upload AS (
UPDATE rag.document_uploads AS upload
SET status = 'COMPLETED',
document_id = document.id,
parse_job_id = job.id,
completed_at = COALESCE(upload.completed_at, now()),
updated_at = now()
FROM locked
CROSS JOIN upserted_document AS document
CROSS JOIN upserted_job AS job
WHERE upload.id = locked.id
RETURNING upload.*, locked.status AS previous_status
), audit AS (
INSERT INTO rag.document_upload_events (
upload_id, actor_subject, event_type, trace_id
)
SELECT id, actor_subject, 'COMPLETED', %s
FROM updated_upload
WHERE previous_status = 'STORED'
)
SELECT
upload.*,
document.filename AS document_filename,
document.mime_type AS document_mime_type,
document.raw_sha256 AS document_raw_sha256,
document.status AS document_status,
document.active_version_id AS document_active_version_id,
document.created_at AS document_created_at,
document.updated_at AS document_updated_at,
job.id AS job_id,
job.job_type AS job_job_type,
job.stage AS job_stage,
job.status AS job_status,
job.progress AS job_progress,
job.attempt AS job_attempt,
job.max_attempts AS job_max_attempts,
job.last_error_code AS job_last_error_code,
job.created_at AS job_created_at,
job.updated_at AS job_updated_at,
job.finished_at AS job_finished_at
FROM updated_upload AS upload
JOIN upserted_document AS document ON document.id = upload.document_id
JOIN upserted_job AS job ON job.id = upload.parse_job_id
"""
GET_JOB_SQL = """
SELECT
job.id,
job.job_type,
job.stage,
job.status,
job.progress,
job.attempt,
job.max_attempts,
job.last_error_code,
job.created_at,
job.updated_at,
job.finished_at
FROM rag.background_jobs AS job
WHERE job.id = %s
AND EXISTS (
SELECT 1
FROM rag.document_uploads AS upload
LEFT JOIN rag.document_versions AS version
ON job.job_type = 'EMBED_DOCUMENT'
AND job.resource_type = 'document_version'
AND job.resource_id = version.id
AND version.document_id = upload.document_id
WHERE upload.actor_subject = %s
AND upload.knowledge_base_id = %s
AND upload.access_scope_id = %s
AND (
upload.parse_job_id = job.id
OR version.id IS NOT NULL
)
)
LIMIT 1
"""
LIST_DOCUMENTS_SQL = """
SELECT
document.id,
document.filename,
document.mime_type,
document.raw_sha256,
document.status,
document.active_version_id,
document.created_at,
document.updated_at
FROM rag.documents AS document
WHERE document.knowledge_base_id = %s
AND document.access_scope_id = %s
AND document.deleted_at IS NULL
AND (%s::uuid IS NULL OR document.id < %s)
ORDER BY document.id DESC
LIMIT %s
"""
GET_DOCUMENT_SQL = """
SELECT
document.id,
document.filename,
document.mime_type,
document.raw_sha256,
document.status,
document.active_version_id,
document.created_at,
document.updated_at,
(SELECT count(*) FROM rag.document_versions AS version
WHERE version.document_id = document.id)::integer AS version_count,
(SELECT count(*) FROM rag.document_pages AS page
JOIN rag.document_versions AS version ON version.id = page.document_version_id
WHERE version.document_id = document.id)::integer AS page_count,
(SELECT count(*) FROM rag.document_blocks AS block
JOIN rag.document_versions AS version ON version.id = block.document_version_id
WHERE version.document_id = document.id)::integer AS block_count,
(SELECT count(*) FROM rag.chunks AS chunk
WHERE chunk.document_id = document.id)::integer AS chunk_count
FROM rag.documents AS document
WHERE document.id = %s
AND document.knowledge_base_id = %s
AND document.access_scope_id = %s
AND document.deleted_at IS NULL
LIMIT 1
"""
GET_LATEST_VERSION_SQL = """
SELECT
version.id,
version.review_state,
version.review_revision,
version.status,
version.parser_profile_hash,
version.chunk_profile_hash,
version.cloud_policy_id,
version.outbound_manifest_sha256,
version.expected_chunk_count,
version.error_code,
version.created_at,
version.completed_at
FROM rag.document_versions AS version
WHERE version.document_id = %s
ORDER BY version.created_at DESC, version.id DESC
LIMIT 1
"""
GET_REVIEW_PAGES_SQL = """
SELECT id, ordinal, page_number, display_text, text_sha256, line_start, line_end
FROM rag.document_pages
WHERE document_version_id = %s
AND ordinal > %s
ORDER BY ordinal
LIMIT %s
"""
GET_REVIEW_BLOCKS_SQL = """
SELECT
id, ordinal, block_kind, display_text, text_sha256, section_path,
anchor_id, char_start, char_end, line_start, line_end, page_start, page_end
FROM rag.document_blocks
WHERE document_version_id = %s
AND ordinal > %s
ORDER BY ordinal
LIMIT %s
"""
GET_REVIEW_CHUNKS_SQL = """
SELECT
ordinal, display_text, cloud_text, cloud_text_sha256, embedding_text_sha256,
token_count, page_start, page_end, section_path, approval_status, index_status
FROM rag.chunks
WHERE document_version_id = %s
AND ordinal > %s
AND deleted_at IS NULL
ORDER BY ordinal
LIMIT %s
"""
def idempotency_key_hash(actor: DocumentActor, key: uuid.UUID) -> str:
return _sha256(f"{actor.subject}\x1f{key}")
def upload_request_fingerprint(
*, filename: str, declared_mime_type: str, expected_size: int, expected_sha256: str
) -> str:
serialized = json.dumps(
{
"filename": filename,
"declared_mime_type": declared_mime_type,
"expected_size": expected_size,
"expected_sha256": expected_sha256,
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return _sha256(serialized)
def _sha256(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
def _default_connection_factory(dsn: str, timeout: int) -> Connection[DocumentRow]:
return psycopg.connect(
dsn,
connect_timeout=timeout,
row_factory=dict_row,
application_name="geological-rag-documents",
)
class PostgresDocumentsRepository:
def __init__(
self,
settings: Settings,
*,
connect_timeout: int = 5,
connection_factory: ConnectionFactory = _default_connection_factory,
) -> None:
self._settings = settings
self._connect_timeout = connect_timeout
self._connection_factory = connection_factory
def _dsn(self) -> str:
return (
self._settings.database_url()
.set(drivername="postgresql")
.render_as_string(hide_password=False)
)
def _one(self, statement: str, parameters: Sequence[object]) -> DocumentRow | None:
try:
with self._connection_factory(self._dsn(), self._connect_timeout) as connection:
with connection.transaction():
return connection.execute(statement, parameters).fetchone()
except (OSError, SecretFileError, psycopg.Error):
raise DocumentPersistenceError from None
def create_upload(
self,
*,
actor: DocumentActor,
idempotency_key_hash: str,
request_fingerprint: str,
filename: str,
declared_mime_type: str,
expected_size: int,
expected_sha256: str,
storage_key: uuid.UUID,
trace_id: uuid.UUID,
) -> tuple[DocumentUpload, bool]:
row = self._one(
CREATE_UPLOAD_SQL,
(
actor.subject,
actor.knowledge_base_id,
actor.access_scope_id,
idempotency_key_hash,
request_fingerprint,
filename,
declared_mime_type,
expected_size,
expected_sha256,
storage_key,
trace_id,
actor.subject,
actor.knowledge_base_id,
actor.access_scope_id,
idempotency_key_hash,
),
)
if row is None:
raise DocumentPersistenceError
if row.get("request_fingerprint") != request_fingerprint:
raise IdempotencyConflictError
return _upload(row), bool(row.get("created"))
def get_upload(self, actor: DocumentActor, upload_id: uuid.UUID) -> DocumentUpload | None:
row = self._one(GET_UPLOAD_SQL, _actor_resource(actor, upload_id))
return None if row is None else _upload(row)
def mark_upload_stored(
self,
*,
actor: DocumentActor,
upload_id: uuid.UUID,
actual_size: int,
actual_sha256: str,
trace_id: uuid.UUID,
) -> DocumentUpload:
row = self._one(
MARK_STORED_SQL,
(
upload_id,
actor.subject,
actor.knowledge_base_id,
actor.access_scope_id,
actual_size,
actual_sha256,
actual_size,
actual_sha256,
trace_id,
),
)
if row is None:
raise UploadStateConflictError
return _upload(row)
def complete_upload(
self,
*,
actor: DocumentActor,
upload_id: uuid.UUID,
trace_id: uuid.UUID,
) -> CompletedUpload:
row = self._one(
COMPLETE_UPLOAD_SQL,
(
upload_id,
actor.subject,
actor.knowledge_base_id,
actor.access_scope_id,
trace_id,
),
)
if row is None:
raise UploadStateConflictError
upload = _upload(row)
document = DocumentSummary(
id=_uuid(row, "document_id"),
filename=_text(row, "document_filename"),
mime_type=_text(row, "document_mime_type"),
raw_sha256=_hash(row, "document_raw_sha256"),
status=_text(row, "document_status"),
active_version_id=_optional_uuid(row, "document_active_version_id"),
created_at=_datetime(row, "document_created_at"),
updated_at=_datetime(row, "document_updated_at"),
)
job = _job(row, prefix="job_")
return CompletedUpload(upload, document, job)
def get_job(self, actor: DocumentActor, job_id: uuid.UUID) -> SafeJob | None:
row = self._one(GET_JOB_SQL, _actor_resource(actor, job_id))
return None if row is None else _job(row)
def list_documents(
self,
actor: DocumentActor,
*,
cursor: uuid.UUID | None,
limit: int,
) -> DocumentListPage:
rows = self._many(
LIST_DOCUMENTS_SQL,
(
actor.knowledge_base_id,
actor.access_scope_id,
cursor,
cursor,
limit + 1,
),
)
has_more = len(rows) > limit
items = tuple(_document(row) for row in rows[:limit])
next_cursor = items[-1].id if has_more and items else None
return DocumentListPage(items, next_cursor)
def get_document(self, actor: DocumentActor, document_id: uuid.UUID) -> DocumentDetail | None:
row = self._one(
GET_DOCUMENT_SQL,
(document_id, actor.knowledge_base_id, actor.access_scope_id),
)
if row is None:
return None
return DocumentDetail(
document=_document(row),
version_count=_integer(row, "version_count"),
page_count=_integer(row, "page_count"),
block_count=_integer(row, "block_count"),
chunk_count=_integer(row, "chunk_count"),
)
def get_review_bundle(
self,
actor: DocumentActor,
document_id: uuid.UUID,
*,
after_ordinal: int,
limit: int,
) -> ReviewBundle | None:
detail = self.get_document(actor, document_id)
if detail is None:
return None
try:
with self._connection_factory(self._dsn(), self._connect_timeout) as connection:
with connection.transaction():
version_row = connection.execute(
GET_LATEST_VERSION_SQL, (document_id,)
).fetchone()
if version_row is None:
return ReviewBundle(detail.document, None, (), (), (), None)
version = _review_version(version_row)
fetch_limit = limit + 1
pages_rows = list(
connection.execute(
GET_REVIEW_PAGES_SQL,
(version.id, after_ordinal, fetch_limit),
).fetchall()
)
block_rows = list(
connection.execute(
GET_REVIEW_BLOCKS_SQL,
(version.id, after_ordinal, fetch_limit),
).fetchall()
)
chunk_rows = list(
connection.execute(
GET_REVIEW_CHUNKS_SQL,
(version.id, after_ordinal, fetch_limit),
).fetchall()
)
except (OSError, SecretFileError, psycopg.Error):
raise DocumentPersistenceError from None
all_rows = (pages_rows, block_rows, chunk_rows)
has_more = any(len(rows) > limit for rows in all_rows)
maximum_ordinal = max(
(cast(int, row["ordinal"]) for rows in all_rows for row in rows[:limit]),
default=after_ordinal,
)
return ReviewBundle(
document=detail.document,
version=version,
pages=tuple(_review_page(row) for row in pages_rows[:limit]),
blocks=tuple(_review_block(row) for row in block_rows[:limit]),
chunks=tuple(_review_chunk(row) for row in chunk_rows[:limit]),
next_ordinal=maximum_ordinal if has_more else None,
)
def _many(self, statement: str, parameters: Sequence[object]) -> list[DocumentRow]:
try:
with self._connection_factory(self._dsn(), self._connect_timeout) as connection:
with connection.transaction():
return list(connection.execute(statement, parameters).fetchall())
except (OSError, SecretFileError, psycopg.Error):
raise DocumentPersistenceError from None
def _actor_resource(actor: DocumentActor, resource_id: uuid.UUID) -> tuple[object, ...]:
return (
resource_id,
actor.subject,
actor.knowledge_base_id,
actor.access_scope_id,
)
def _upload(row: Mapping[str, object]) -> DocumentUpload:
return DocumentUpload(
id=_uuid(row, "id"),
filename=_text(row, "original_filename"),
declared_mime_type=_text(row, "declared_mime_type"),
expected_size=_integer(row, "expected_size"),
expected_sha256=_hash(row, "expected_sha256"),
storage_key=_uuid(row, "storage_key"),
actual_size=_optional_integer(row, "actual_size"),
actual_sha256=_optional_hash(row, "actual_sha256"),
status=_text(row, "status"),
document_id=_optional_uuid(row, "document_id"),
parse_job_id=_optional_uuid(row, "parse_job_id"),
created_at=_datetime(row, "created_at"),
updated_at=_datetime(row, "updated_at"),
completed_at=_optional_datetime(row, "completed_at"),
)
def _document(row: Mapping[str, object]) -> DocumentSummary:
return DocumentSummary(
id=_uuid(row, "id"),
filename=_text(row, "filename"),
mime_type=_text(row, "mime_type"),
raw_sha256=_hash(row, "raw_sha256"),
status=_text(row, "status"),
active_version_id=_optional_uuid(row, "active_version_id"),
created_at=_datetime(row, "created_at"),
updated_at=_datetime(row, "updated_at"),
)
def _job(row: Mapping[str, object], *, prefix: str = "") -> SafeJob:
return SafeJob(
id=_uuid(row, f"{prefix}id" if prefix else "id"),
job_type=_text(row, f"{prefix}job_type"),
stage=_text(row, f"{prefix}stage"),
status=_text(row, f"{prefix}status"),
progress=_integer(row, f"{prefix}progress"),
attempt=_integer(row, f"{prefix}attempt"),
max_attempts=_integer(row, f"{prefix}max_attempts"),
last_error_code=_optional_text(row, f"{prefix}last_error_code"),
created_at=_datetime(row, f"{prefix}created_at"),
updated_at=_datetime(row, f"{prefix}updated_at"),
finished_at=_optional_datetime(row, f"{prefix}finished_at"),
)
def _review_version(row: Mapping[str, object]) -> ReviewVersion:
return ReviewVersion(
id=_uuid(row, "id"),
review_state=_text(row, "review_state"),
review_revision=_integer(row, "review_revision"),
status=_text(row, "status"),
parser_profile_hash=_hash(row, "parser_profile_hash"),
chunk_profile_hash=_hash(row, "chunk_profile_hash"),
cloud_policy_id=_text(row, "cloud_policy_id"),
outbound_manifest_sha256=_optional_hash(row, "outbound_manifest_sha256"),
expected_chunk_count=_optional_integer(row, "expected_chunk_count"),
error_code=_optional_text(row, "error_code"),
created_at=_datetime(row, "created_at"),
completed_at=_optional_datetime(row, "completed_at"),
)
def _review_page(row: Mapping[str, object]) -> ReviewPage:
return ReviewPage(
id=_uuid(row, "id"),
ordinal=_integer(row, "ordinal"),
page_number=_optional_integer(row, "page_number"),
text=_text(row, "display_text"),
text_sha256=_hash(row, "text_sha256"),
line_start=_integer(row, "line_start"),
line_end=_integer(row, "line_end"),
)
def _review_block(row: Mapping[str, object]) -> ReviewBlock:
section_path = row.get("section_path")
if not isinstance(section_path, list) or not all(
isinstance(value, str) for value in section_path
):
raise DocumentPersistenceError
return ReviewBlock(
id=_uuid(row, "id"),
ordinal=_integer(row, "ordinal"),
kind=_text(row, "block_kind"),
text=_text(row, "display_text"),
text_sha256=_hash(row, "text_sha256"),
section_path=tuple(section_path),
anchor_id=_hash(row, "anchor_id"),
char_start=_integer(row, "char_start"),
char_end=_integer(row, "char_end"),
line_start=_integer(row, "line_start"),
line_end=_integer(row, "line_end"),
page_start=_optional_integer(row, "page_start"),
page_end=_optional_integer(row, "page_end"),
)
def _review_chunk(row: Mapping[str, object]) -> ReviewChunk:
section_path = row.get("section_path")
if not isinstance(section_path, list) or not all(
isinstance(value, str) for value in section_path
):
raise DocumentPersistenceError
return ReviewChunk(
ordinal=_integer(row, "ordinal"),
display_text=_text(row, "display_text"),
cloud_text=_text(row, "cloud_text"),
cloud_text_sha256=_hash(row, "cloud_text_sha256"),
embedding_text_sha256=_hash(row, "embedding_text_sha256"),
token_count=_integer(row, "token_count"),
page_start=_optional_integer(row, "page_start"),
page_end=_optional_integer(row, "page_end"),
section_path=tuple(section_path),
approval_status=_text(row, "approval_status"),
index_status=_text(row, "index_status"),
)
def _uuid(row: Mapping[str, object], name: str) -> uuid.UUID:
value = row.get(name)
if isinstance(value, uuid.UUID):
return value
if isinstance(value, str):
try:
return uuid.UUID(value)
except ValueError:
pass
raise DocumentPersistenceError
def _optional_uuid(row: Mapping[str, object], name: str) -> uuid.UUID | None:
return None if row.get(name) is None else _uuid(row, name)
def _text(row: Mapping[str, object], name: str) -> str:
value = row.get(name)
if not isinstance(value, str) or not value.strip():
raise DocumentPersistenceError
return value
def _optional_text(row: Mapping[str, object], name: str) -> str | None:
return None if row.get(name) is None else _text(row, name)
def _hash(row: Mapping[str, object], name: str) -> str:
value = _text(row, name)
if _HASH.fullmatch(value) is None:
raise DocumentPersistenceError
return value
def _optional_hash(row: Mapping[str, object], name: str) -> str | None:
return None if row.get(name) is None else _hash(row, name)
def _integer(row: Mapping[str, object], name: str) -> int:
value = row.get(name)
if isinstance(value, bool) or not isinstance(value, int):
raise DocumentPersistenceError
return value
def _optional_integer(row: Mapping[str, object], name: str) -> int | None:
return None if row.get(name) is None else _integer(row, name)
def _datetime(row: Mapping[str, object], name: str) -> datetime:
value = row.get(name)
if not isinstance(value, datetime):
raise DocumentPersistenceError
return value
def _optional_datetime(row: Mapping[str, object], name: str) -> datetime | None:
return None if row.get(name) is None else _datetime(row, name)