docling-studio/document-parser/domain/lifecycle.py
Pier-Jean Malandrino c878ee5f3b feat(#202): introduce Document lifecycle state machine
Adds a first-class lifecycle state to every document, distinct from
AnalysisJob.status. The lifecycle describes the document as a whole and
is the foundation for the doc-centric pivot in 0.6.0.

Domain
- DocumentLifecycleState enum (Uploaded/Parsed/Chunked/Ingested/Stale/Failed)
- Document.lifecycle_state and lifecycle_state_at fields
- Document.transition_to() validates against a transition table
  (domain/lifecycle.py) and returns a DocumentLifecycleChanged event
- InvalidLifecycleTransitionError on disallowed transitions

Persistence
- ALTER TABLE documents to add the two columns (default 'Uploaded')
- New index idx_documents_lifecycle_state for filter perf
- _COLUMN_MIGRATIONS refactored to support multiple tables
- _POST_MIGRATION_DDL list for indexes on freshly-added columns
- SqliteDocumentRepository.update_lifecycle()

Services
- AnalysisService drives transitions on parse / chunk / re-chunk / fail
  via _transition_document(); idempotent and resilient (logs WARN and
  continues if a stale state is somehow encountered)

API
- DocumentResponse exposes lifecycleState + lifecycleStateAt
  (additive — existing 'status' field kept for backwards compat)

Frontend
- Document type extended with lifecycleState and lifecycleStateAt
- DocumentLifecycleState union literal mirroring the backend enum

Tests
- 24 new tests in test_lifecycle.py covering transitions, idempotency,
  invariant preservation, and event emission
- test_repos.py: round-trip + every-enum-value check + update_lifecycle
- test_chunking.py: rechunk path now mocks document_repo correctly

Refs #202
2026-04-29 15:34:25 +02:00

83 lines
3.2 KiB
Python

"""Document lifecycle state machine — pure domain logic.
Defines the canonical state model for a Document in Docling Studio:
Uploaded → Parsed → Chunked → Ingested → (Stale|Chunked) → Ingested
Stale is set by the auto-detect logic (#204) — never reached by a manual
action. Failed is reachable from any state and represents a pipeline error.
This module contains:
- The transition table (which (from → to) pairs are allowed).
- `is_allowed_transition`: the pure check.
- `assert_transition`: raises `InvalidLifecycleTransitionError` when not allowed.
The dataclass that carries the state lives in `domain.models.Document`. The
domain event emitted on transition lives in `domain.events`.
"""
from __future__ import annotations
from domain.exceptions import InvalidLifecycleTransitionError
from domain.value_objects import DocumentLifecycleState
# Allowed transitions: from → set of allowed targets.
# Failed is always reachable as a terminal-ish state (see notes below).
# Self-loops are allowed only where they make pipeline sense (e.g. re-chunk).
_TRANSITIONS: dict[DocumentLifecycleState, frozenset[DocumentLifecycleState]] = {
DocumentLifecycleState.UPLOADED: frozenset(
{
DocumentLifecycleState.PARSED,
DocumentLifecycleState.CHUNKED, # parse + chunk in one pipeline call
DocumentLifecycleState.FAILED,
}
),
DocumentLifecycleState.PARSED: frozenset(
{
DocumentLifecycleState.PARSED, # idempotent re-parse
DocumentLifecycleState.CHUNKED,
DocumentLifecycleState.FAILED,
}
),
DocumentLifecycleState.CHUNKED: frozenset(
{
DocumentLifecycleState.CHUNKED, # re-chunk
DocumentLifecycleState.INGESTED,
DocumentLifecycleState.FAILED,
}
),
DocumentLifecycleState.INGESTED: frozenset(
{
DocumentLifecycleState.STALE,
DocumentLifecycleState.CHUNKED, # re-chunk after ingest
DocumentLifecycleState.INGESTED, # re-push (idempotent)
DocumentLifecycleState.FAILED,
}
),
DocumentLifecycleState.STALE: frozenset(
{
DocumentLifecycleState.INGESTED,
DocumentLifecycleState.CHUNKED,
DocumentLifecycleState.FAILED,
}
),
# Failed is a recoverable state — the operator (or a retry) can move
# back to a non-terminal state by re-running the relevant pipeline step.
DocumentLifecycleState.FAILED: frozenset(
{
DocumentLifecycleState.UPLOADED,
DocumentLifecycleState.PARSED,
DocumentLifecycleState.CHUNKED,
}
),
}
def is_allowed_transition(source: DocumentLifecycleState, target: DocumentLifecycleState) -> bool:
"""Return True iff transitioning from `source` to `target` is allowed."""
return target in _TRANSITIONS.get(source, frozenset())
def assert_transition(source: DocumentLifecycleState, target: DocumentLifecycleState) -> None:
"""Raise `InvalidLifecycleTransitionError` if the transition is not allowed."""
if not is_allowed_transition(source, target):
raise InvalidLifecycleTransitionError(source=source, target=target)