Source code for cadrumo.application.ledger.extraction_draft_store
"""Encrypted store for pre-confirm extraction drafts.
An :class:`~application.ledger.invoice_draft_records.InvoiceDraft` is derived financial data: supplier
tax id, invoice number, taxable base, per-rate cuota. Persisting one is STORAGE
rather than processing, so it routes through the core's encrypted bucket-scoped
repository and never through the inference subpackage, which holds no storage
handle by contract.
Its pre-confirm, operator-correctable lifecycle does not change its sensitivity. A leaked draft would disclose
exactly what the confirmed invoice would, so it is stored at the same
``FINANCIAL`` classification rather than a softer one.
**A draft is not an invoice and this store must never become a second writer of
one.** It holds what a reader proposed, pending the operator's confirm; the sole
sanctioned :class:`~domain.invoices.models.Invoice` writer stays where it is. Keeping
the draft here is what lets an operator leave a review half-finished and return
to it without the extraction being re-run -- which is also why the store is keyed
by the evidence reference the draft came from rather than by the draft's own
content: correcting a field must update the review in place, not fork a second
one.
"""
from __future__ import annotations
from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Protocol
from pydantic import BaseModel, Field
from ...core.config import Settings
from ...core.errors.hierarchy import InternalInvariantError
from ...core.identity.bucket import BucketId
from ...core.models import STRICT_FROZEN_CONFIG
from ...core.time.clock import now
from ...core.time.utc import UtcInstant
from .invoice_draft_records import InvoiceDraft
__all__ = [
"ExtractionDraftDocument",
"ExtractionDraftRepositoryFactory",
"ExtractionDraftRepositoryProtocol",
"StoredExtractionDraft",
"bind_extraction_draft_repository_factory",
"extraction_draft_object_key",
"load_extraction_drafts",
"read_extraction_draft",
"write_extraction_draft",
]
[docs]
class StoredExtractionDraft(BaseModel):
"""One pending extraction draft, keyed by the evidence it was read from.
Attributes:
evidence_reference: The evidence or attachment id the draft was read
from. The key, so a correction updates the review in place rather
than forking a second one for the same document.
draft: The proposed fields, exactly as the reader produced them.
extractor: Which reader produced it, so a draft from a superseded
extractor is identifiable rather than silently trusted at confirm.
read_transports: Every transport that carried this document's reading.
Separate from ``extractor`` because they answer different questions
at different granularities. WHICH reader produced a value is a
per-field fact, already carried by the draft's own provenance
envelopes, and a document-level claim about it would be exactly the
laundering those envelopes exist to prevent. WHETHER any bytes left
the host is legitimately document-level: if any field went off-host
then the document did, so the fact is monotone over fields and
loses nothing by being aggregated here.
Empty means UNKNOWN, not on-host. A writer that cannot establish
where the read ran says nothing rather than claiming the safe
answer, and the consent withdrawal survey surfaces the uncertainty
instead of resolving it optimistically.
drafted_at: When the draft was written.
"""
model_config = STRICT_FROZEN_CONFIG
evidence_reference: str = Field(min_length=1)
draft: InvoiceDraft
extractor: str = Field(min_length=1)
read_transports: tuple[str, ...] = ()
drafted_at: UtcInstant
[docs]
class ExtractionDraftDocument(BaseModel):
"""One bucket's pending extraction drafts."""
model_config = STRICT_FROZEN_CONFIG
bucket_id: BucketId
drafts: tuple[StoredExtractionDraft, ...] = ()
[docs]
def extraction_draft_object_key(document: ExtractionDraftDocument) -> str:
"""Return the canonical natural object key for one draft document."""
return document.bucket_id
[docs]
class ExtractionDraftRepositoryProtocol(Protocol):
"""Persistence operations required by extraction-draft application policy."""
[docs]
def load(self, identifier: str) -> ExtractionDraftDocument | None:
"""Load the document stored under ``identifier``, when present."""
...
[docs]
def save(self, payload: ExtractionDraftDocument) -> None:
"""Persist one complete extraction-draft document."""
...
[docs]
class ExtractionDraftRepositoryFactory(Protocol):
"""Construct a repository port for one bucket and storage configuration."""
def __call__(self, *, bucket_id: str, settings: Settings) -> ExtractionDraftRepositoryProtocol:
"""Return the encrypted repository for ``bucket_id``."""
...
_BOUND_EXTRACTION_DRAFT_REPOSITORY_FACTORY: ContextVar[ExtractionDraftRepositoryFactory] = ContextVar(
"cadrumo_extraction_draft_repository_factory"
)
[docs]
@contextmanager
def bind_extraction_draft_repository_factory(
factory: ExtractionDraftRepositoryFactory,
) -> Generator[ExtractionDraftRepositoryFactory]:
"""Bind one outward-composed repository factory for the host context."""
token = _BOUND_EXTRACTION_DRAFT_REPOSITORY_FACTORY.set(factory)
try:
yield factory
finally:
_BOUND_EXTRACTION_DRAFT_REPOSITORY_FACTORY.reset(token)
def _repository(bucket_id: str, settings: Settings) -> ExtractionDraftRepositoryProtocol:
try:
factory = _BOUND_EXTRACTION_DRAFT_REPOSITORY_FACTORY.get()
except LookupError as error:
raise InternalInvariantError("extraction-draft persistence has not been composed") from error
return factory(bucket_id=bucket_id, settings=settings)
[docs]
def load_extraction_drafts(bucket_id: str, settings: Settings) -> ExtractionDraftDocument:
"""Load a bucket's pending drafts, or an empty document when none exist."""
document = _repository(bucket_id, settings).load(bucket_id)
return document if document is not None else ExtractionDraftDocument(bucket_id=bucket_id)
[docs]
def read_extraction_draft(
*,
bucket_id: str,
evidence_reference: str,
settings: Settings,
) -> StoredExtractionDraft | None:
"""Return the pending draft for this evidence reference, or ``None``."""
document = load_extraction_drafts(bucket_id, settings)
return next((row for row in document.drafts if row.evidence_reference == evidence_reference), None)
[docs]
def write_extraction_draft(
*,
bucket_id: str,
evidence_reference: str,
draft: InvoiceDraft,
extractor: str,
settings: Settings,
read_transports: tuple[str, ...] = (),
) -> ExtractionDraftDocument:
"""Persist a draft through the core's encrypted repository.
Replaces any pending draft for the same evidence reference. Two drafts for
one document are a re-read or a correction, not two proposals, and leaving
both would give the confirm boundary two answers with nothing saying which
the operator meant.
"""
document = load_extraction_drafts(bucket_id, settings)
retained = tuple(row for row in document.drafts if row.evidence_reference != evidence_reference)
updated = ExtractionDraftDocument(
bucket_id=bucket_id,
drafts=(
*retained,
StoredExtractionDraft(
evidence_reference=evidence_reference,
draft=draft,
extractor=extractor,
read_transports=read_transports,
drafted_at=now(),
),
),
)
_repository(bucket_id, settings).save(updated)
return updated