Claude commited on
Commit
652df4e
·
unverified ·
1 Parent(s): 3213f50

Sprint 7: persistence layer and job orchestration

Browse files

Complete persistence and job lifecycle management:

Persistence:
- file_store.py: artifact storage abstraction for all job outputs (raw
payload, canonical JSON, ALTO XML, PAGE XML, viewer JSON, events log,
input images). Provider profile CRUD. Handles local and /data paths
- db.py: SQLite with auto-creating schema for jobs and providers tables.
Job CRUD with full state serialization (status, timestamps, warnings,
artifact flags). Provider record storage

Job system:
- models.py: Job model with 5-state machine (queued→running→succeeded/
failed/partial_success). Duration tracking, summary view, artifact flags
- events.py: EventLog with context-manager step() that auto-times each
pipeline phase. skip() for non-eligible exports. Failure capture.
13 named JobSteps matching §23.1
- service.py: JobService orchestrates the full pipeline:
1.receive_file → 5.save_raw → 6.normalize → 7.enrich → 8.validate →
9.compute_readiness → 10.export_alto → 11.export_page → 13.persist
Exports are conditional on eligibility. Failed jobs captured with error
and events. Enricher pipeline configurable

34 new tests (file_store: 15, database: 8, events: 7, job service: 5
integration tests including full lifecycle, failure capture, events
logging, and canonical JSON validation from disk).
411 total tests passing.

https://claude.ai/code/session_01Cuzvc9Pjfo5u46eT3ta2Cg

src/app/jobs/events.py CHANGED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Job events — structured log of each pipeline step."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from contextlib import contextmanager
7
+ from datetime import datetime, timezone
8
+ from enum import Enum
9
+ from typing import Generator
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+
14
+ class JobStep(str, Enum):
15
+ """Named steps in the processing pipeline."""
16
+
17
+ RECEIVE_FILE = "receive_file"
18
+ CREATE_CONTEXT = "create_context"
19
+ RESOLVE_PROVIDER = "resolve_provider"
20
+ EXECUTE_RUNTIME = "execute_runtime"
21
+ SAVE_RAW = "save_raw"
22
+ NORMALIZE = "normalize"
23
+ ENRICH = "enrich"
24
+ VALIDATE = "validate"
25
+ COMPUTE_READINESS = "compute_readiness"
26
+ EXPORT_ALTO = "export_alto"
27
+ EXPORT_PAGE = "export_page"
28
+ BUILD_VIEWER = "build_viewer"
29
+ PERSIST = "persist"
30
+
31
+
32
+ class JobEvent(BaseModel):
33
+ """A single pipeline step event."""
34
+
35
+ step: JobStep
36
+ status: str = "started" # started, completed, failed, skipped
37
+ started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
38
+ completed_at: datetime | None = None
39
+ duration_ms: float | None = None
40
+ message: str | None = None
41
+ error: str | None = None
42
+
43
+
44
+ class EventLog:
45
+ """Collects events during job execution."""
46
+
47
+ def __init__(self) -> None:
48
+ self._events: list[JobEvent] = []
49
+
50
+ @property
51
+ def events(self) -> list[JobEvent]:
52
+ return list(self._events)
53
+
54
+ def add(self, event: JobEvent) -> None:
55
+ self._events.append(event)
56
+
57
+ @contextmanager
58
+ def step(self, step: JobStep) -> Generator[JobEvent, None, None]:
59
+ """Context manager that auto-times a pipeline step."""
60
+ event = JobEvent(step=step, status="started")
61
+ self._events.append(event)
62
+ t0 = time.monotonic()
63
+ try:
64
+ yield event
65
+ elapsed = (time.monotonic() - t0) * 1000
66
+ # Since JobEvent is a Pydantic model, update via index
67
+ idx = len(self._events) - 1
68
+ self._events[idx] = event.model_copy(update={
69
+ "status": "completed",
70
+ "completed_at": datetime.now(timezone.utc),
71
+ "duration_ms": elapsed,
72
+ })
73
+ except Exception as exc:
74
+ elapsed = (time.monotonic() - t0) * 1000
75
+ idx = len(self._events) - 1
76
+ self._events[idx] = event.model_copy(update={
77
+ "status": "failed",
78
+ "completed_at": datetime.now(timezone.utc),
79
+ "duration_ms": elapsed,
80
+ "error": str(exc),
81
+ })
82
+ raise
83
+
84
+ def skip(self, step: JobStep, reason: str) -> None:
85
+ """Record a skipped step."""
86
+ self._events.append(JobEvent(
87
+ step=step,
88
+ status="skipped",
89
+ completed_at=datetime.now(timezone.utc),
90
+ message=reason,
91
+ ))
92
+
93
+ def to_dicts(self) -> list[dict]:
94
+ """Serialize all events for persistence."""
95
+ return [e.model_dump(mode="json") for e in self._events]
96
+
97
+ @property
98
+ def has_failures(self) -> bool:
99
+ return any(e.status == "failed" for e in self._events)
100
+
101
+ @property
102
+ def total_duration_ms(self) -> float:
103
+ return sum(e.duration_ms or 0 for e in self._events)
src/app/jobs/models.py CHANGED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Job models — represents a single OCR processing run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class JobStatus(str, Enum):
13
+ """State machine for job lifecycle."""
14
+
15
+ QUEUED = "queued"
16
+ RUNNING = "running"
17
+ SUCCEEDED = "succeeded"
18
+ FAILED = "failed"
19
+ PARTIAL_SUCCESS = "partial_success"
20
+
21
+
22
+ class Job(BaseModel):
23
+ """A single OCR processing job."""
24
+
25
+ job_id: str = Field(min_length=1)
26
+ status: JobStatus = JobStatus.QUEUED
27
+
28
+ # Input
29
+ provider_id: str = Field(min_length=1)
30
+ provider_family: str = Field(min_length=1)
31
+ source_filename: str | None = None
32
+ image_width: int | None = Field(default=None, gt=0)
33
+ image_height: int | None = Field(default=None, gt=0)
34
+
35
+ # Results
36
+ has_raw_payload: bool = False
37
+ has_canonical: bool = False
38
+ has_alto: bool = False
39
+ has_page_xml: bool = False
40
+ has_viewer: bool = False
41
+
42
+ # Timing
43
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
44
+ started_at: datetime | None = None
45
+ completed_at: datetime | None = None
46
+
47
+ # Errors and warnings
48
+ error: str | None = None
49
+ warnings: list[str] = Field(default_factory=list)
50
+
51
+ @property
52
+ def duration_ms(self) -> float | None:
53
+ if self.started_at and self.completed_at:
54
+ return (self.completed_at - self.started_at).total_seconds() * 1000
55
+ return None
56
+
57
+ def to_summary(self) -> dict[str, Any]:
58
+ """Lightweight summary for list views."""
59
+ return {
60
+ "job_id": self.job_id,
61
+ "status": self.status.value,
62
+ "provider_id": self.provider_id,
63
+ "source_filename": self.source_filename,
64
+ "created_at": self.created_at.isoformat(),
65
+ "duration_ms": self.duration_ms,
66
+ "has_alto": self.has_alto,
67
+ "has_page_xml": self.has_page_xml,
68
+ "error": self.error,
69
+ }
src/app/jobs/service.py CHANGED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Job service — orchestrates the full processing pipeline.
2
+
3
+ Pipeline steps (§23.1):
4
+ 1. receive file
5
+ 2. create execution context
6
+ 3. resolve provider
7
+ 4. execute runtime (skipped in V1 — raw payload provided externally)
8
+ 5. save raw payload
9
+ 6. normalize
10
+ 7. enrich
11
+ 8. validate
12
+ 9. compute readiness
13
+ 10. export ALTO
14
+ 11. export PAGE
15
+ 12. build viewer (placeholder)
16
+ 13. persist
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import uuid
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ from src.app.domain.models import CanonicalDocument, RawProviderPayload
26
+ from src.app.domain.models.geometry import GeometryContext
27
+ from src.app.enrichers import EnricherPipeline
28
+ from src.app.enrichers.bbox_repair_light import BboxRepairLightEnricher
29
+ from src.app.enrichers.hyphenation_basic import HyphenationBasicEnricher
30
+ from src.app.enrichers.lang_propagation import LangPropagationEnricher
31
+ from src.app.enrichers.polygon_to_bbox import PolygonToBboxEnricher
32
+ from src.app.enrichers.reading_order_simple import ReadingOrderSimpleEnricher
33
+ from src.app.enrichers.text_consistency import TextConsistencyEnricher
34
+ from src.app.jobs.events import EventLog, JobStep
35
+ from src.app.jobs.models import Job, JobStatus
36
+ from src.app.normalization.pipeline import normalize
37
+ from src.app.persistence.db import Database
38
+ from src.app.persistence.file_store import FileStore
39
+ from src.app.policies.document_policy import DocumentPolicy
40
+ from src.app.policies.export_policy import check_alto_export, check_page_export
41
+ from src.app.serializers.alto_xml import serialize_alto
42
+ from src.app.serializers.page_xml import serialize_page_xml
43
+ from src.app.validators.export_eligibility_validator import compute_export_eligibility
44
+ from src.app.validators.structural_validator import validate_structure
45
+
46
+
47
+ def _default_enricher_pipeline() -> EnricherPipeline:
48
+ return EnricherPipeline([
49
+ PolygonToBboxEnricher(),
50
+ BboxRepairLightEnricher(),
51
+ LangPropagationEnricher(),
52
+ ReadingOrderSimpleEnricher(),
53
+ HyphenationBasicEnricher(),
54
+ TextConsistencyEnricher(),
55
+ ])
56
+
57
+
58
+ class JobService:
59
+ """Orchestrates the full document processing pipeline."""
60
+
61
+ def __init__(self, db: Database, file_store: FileStore) -> None:
62
+ self._db = db
63
+ self._store = file_store
64
+
65
+ def create_job(
66
+ self,
67
+ provider_id: str,
68
+ provider_family: str,
69
+ source_filename: str | None = None,
70
+ ) -> Job:
71
+ """Create a new job record."""
72
+ job = Job(
73
+ job_id=f"job_{uuid.uuid4().hex[:12]}",
74
+ provider_id=provider_id,
75
+ provider_family=provider_family,
76
+ source_filename=source_filename,
77
+ )
78
+ self._db.save_job(job)
79
+ return job
80
+
81
+ def run_job(
82
+ self,
83
+ job: Job,
84
+ raw_payload: RawProviderPayload,
85
+ image_width: int,
86
+ image_height: int,
87
+ *,
88
+ image_path: Path | None = None,
89
+ policy: DocumentPolicy | None = None,
90
+ enricher_pipeline: EnricherPipeline | None = None,
91
+ ) -> Job:
92
+ """Execute the full pipeline for a job.
93
+
94
+ In V1, the raw_payload is provided externally (runtime execution
95
+ is not yet integrated). The pipeline runs synchronously.
96
+ """
97
+ if policy is None:
98
+ policy = DocumentPolicy()
99
+ if enricher_pipeline is None:
100
+ enricher_pipeline = _default_enricher_pipeline()
101
+
102
+ events = EventLog()
103
+ job = job.model_copy(update={
104
+ "status": JobStatus.RUNNING,
105
+ "started_at": datetime.now(timezone.utc),
106
+ "image_width": image_width,
107
+ "image_height": image_height,
108
+ })
109
+ self._db.save_job(job)
110
+
111
+ canonical: CanonicalDocument | None = None
112
+ warnings: list[str] = []
113
+
114
+ try:
115
+ # Step 1: receive file
116
+ with events.step(JobStep.RECEIVE_FILE):
117
+ if image_path and image_path.exists():
118
+ self._store.save_input_image(job.job_id, image_path)
119
+
120
+ # Step 5: save raw payload
121
+ with events.step(JobStep.SAVE_RAW):
122
+ self._store.save_raw_payload(
123
+ job.job_id, raw_payload.model_dump(mode="json")
124
+ )
125
+ job = job.model_copy(update={"has_raw_payload": True})
126
+
127
+ # Step 6: normalize
128
+ with events.step(JobStep.NORMALIZE):
129
+ geo_ctx = GeometryContext(
130
+ source_width=image_width, source_height=image_height
131
+ )
132
+ canonical = normalize(
133
+ raw_payload,
134
+ family=job.provider_family,
135
+ geometry_context=geo_ctx,
136
+ document_id=job.job_id,
137
+ source_filename=job.source_filename,
138
+ )
139
+
140
+ # Step 7: enrich
141
+ with events.step(JobStep.ENRICH):
142
+ canonical = enricher_pipeline.run(canonical, policy)
143
+
144
+ # Step 8: validate
145
+ with events.step(JobStep.VALIDATE):
146
+ struct_report = validate_structure(
147
+ canonical, bbox_tolerance=policy.bbox_containment_tolerance
148
+ )
149
+ for entry in struct_report.warnings:
150
+ warnings.append(f"[structural] {entry.path}: {entry.message}")
151
+ for entry in struct_report.errors:
152
+ warnings.append(f"[structural:ERROR] {entry.path}: {entry.message}")
153
+
154
+ # Step 9: compute readiness + export eligibility
155
+ with events.step(JobStep.COMPUTE_READINESS):
156
+ eligibility = compute_export_eligibility(canonical, policy)
157
+
158
+ # Save canonical
159
+ self._store.save_canonical(
160
+ job.job_id, canonical.model_dump(mode="json")
161
+ )
162
+ job = job.model_copy(update={"has_canonical": True})
163
+
164
+ # Step 10: export ALTO
165
+ alto_decision = check_alto_export(eligibility, policy)
166
+ if alto_decision.allowed:
167
+ with events.step(JobStep.EXPORT_ALTO):
168
+ alto_bytes = serialize_alto(canonical)
169
+ self._store.save_alto(job.job_id, alto_bytes)
170
+ job = job.model_copy(update={"has_alto": True})
171
+ else:
172
+ events.skip(JobStep.EXPORT_ALTO, alto_decision.reason)
173
+
174
+ # Step 11: export PAGE
175
+ page_decision = check_page_export(eligibility, policy)
176
+ if page_decision.allowed:
177
+ with events.step(JobStep.EXPORT_PAGE):
178
+ page_bytes = serialize_page_xml(canonical)
179
+ self._store.save_page_xml(job.job_id, page_bytes)
180
+ job = job.model_copy(update={"has_page_xml": True})
181
+ else:
182
+ events.skip(JobStep.EXPORT_PAGE, page_decision.reason)
183
+
184
+ # Step 12: build viewer (placeholder — full impl in Sprint 10)
185
+ events.skip(JobStep.BUILD_VIEWER, "Not yet implemented")
186
+
187
+ # Step 13: persist final state
188
+ with events.step(JobStep.PERSIST):
189
+ self._store.save_events(job.job_id, events.to_dicts())
190
+
191
+ # Determine final status
192
+ if job.has_alto or job.has_page_xml:
193
+ if job.has_alto and job.has_page_xml:
194
+ final_status = JobStatus.SUCCEEDED
195
+ else:
196
+ final_status = JobStatus.PARTIAL_SUCCESS
197
+ else:
198
+ final_status = JobStatus.PARTIAL_SUCCESS
199
+
200
+ job = job.model_copy(update={
201
+ "status": final_status,
202
+ "completed_at": datetime.now(timezone.utc),
203
+ "warnings": warnings,
204
+ })
205
+
206
+ except Exception as exc:
207
+ job = job.model_copy(update={
208
+ "status": JobStatus.FAILED,
209
+ "completed_at": datetime.now(timezone.utc),
210
+ "error": str(exc),
211
+ "warnings": warnings,
212
+ })
213
+ # Save events even on failure
214
+ self._store.save_events(job.job_id, events.to_dicts())
215
+
216
+ self._db.save_job(job)
217
+ return job
218
+
219
+ def get_job(self, job_id: str) -> Job | None:
220
+ return self._db.get_job(job_id)
221
+
222
+ def list_jobs(self, limit: int = 100, offset: int = 0) -> list[Job]:
223
+ return self._db.list_jobs(limit, offset)
src/app/persistence/db.py CHANGED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQLite database — lightweight persistence for jobs and providers.
2
+
3
+ Uses synchronous sqlite3 for V1 simplicity. The schema auto-creates
4
+ on first access.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sqlite3
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from src.app.jobs.models import Job, JobStatus
15
+
16
+ _SCHEMA = """
17
+ CREATE TABLE IF NOT EXISTS jobs (
18
+ job_id TEXT PRIMARY KEY,
19
+ status TEXT NOT NULL DEFAULT 'queued',
20
+ provider_id TEXT NOT NULL,
21
+ provider_family TEXT NOT NULL,
22
+ source_filename TEXT,
23
+ image_width INTEGER,
24
+ image_height INTEGER,
25
+ has_raw_payload INTEGER NOT NULL DEFAULT 0,
26
+ has_canonical INTEGER NOT NULL DEFAULT 0,
27
+ has_alto INTEGER NOT NULL DEFAULT 0,
28
+ has_page_xml INTEGER NOT NULL DEFAULT 0,
29
+ has_viewer INTEGER NOT NULL DEFAULT 0,
30
+ created_at TEXT NOT NULL,
31
+ started_at TEXT,
32
+ completed_at TEXT,
33
+ error TEXT,
34
+ warnings TEXT NOT NULL DEFAULT '[]'
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS providers (
38
+ provider_id TEXT PRIMARY KEY,
39
+ data TEXT NOT NULL,
40
+ created_at TEXT NOT NULL,
41
+ updated_at TEXT NOT NULL
42
+ );
43
+ """
44
+
45
+
46
+ class Database:
47
+ """Thin wrapper around sqlite3 for jobs and providers."""
48
+
49
+ def __init__(self, db_path: Path) -> None:
50
+ self._path = db_path
51
+ self._path.parent.mkdir(parents=True, exist_ok=True)
52
+ self._conn: sqlite3.Connection | None = None
53
+
54
+ def connect(self) -> None:
55
+ self._conn = sqlite3.connect(str(self._path))
56
+ self._conn.row_factory = sqlite3.Row
57
+ self._conn.executescript(_SCHEMA)
58
+
59
+ def close(self) -> None:
60
+ if self._conn:
61
+ self._conn.close()
62
+ self._conn = None
63
+
64
+ @property
65
+ def conn(self) -> sqlite3.Connection:
66
+ if self._conn is None:
67
+ self.connect()
68
+ return self._conn # type: ignore[return-value]
69
+
70
+ # -- Jobs -----------------------------------------------------------------
71
+
72
+ def save_job(self, job: Job) -> None:
73
+ self.conn.execute(
74
+ """INSERT OR REPLACE INTO jobs
75
+ (job_id, status, provider_id, provider_family, source_filename,
76
+ image_width, image_height, has_raw_payload, has_canonical,
77
+ has_alto, has_page_xml, has_viewer, created_at, started_at,
78
+ completed_at, error, warnings)
79
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
80
+ (
81
+ job.job_id,
82
+ job.status.value,
83
+ job.provider_id,
84
+ job.provider_family,
85
+ job.source_filename,
86
+ job.image_width,
87
+ job.image_height,
88
+ int(job.has_raw_payload),
89
+ int(job.has_canonical),
90
+ int(job.has_alto),
91
+ int(job.has_page_xml),
92
+ int(job.has_viewer),
93
+ job.created_at.isoformat(),
94
+ job.started_at.isoformat() if job.started_at else None,
95
+ job.completed_at.isoformat() if job.completed_at else None,
96
+ job.error,
97
+ json.dumps(job.warnings),
98
+ ),
99
+ )
100
+ self.conn.commit()
101
+
102
+ def get_job(self, job_id: str) -> Job | None:
103
+ row = self.conn.execute(
104
+ "SELECT * FROM jobs WHERE job_id = ?", (job_id,)
105
+ ).fetchone()
106
+ if row is None:
107
+ return None
108
+ return self._row_to_job(row)
109
+
110
+ def list_jobs(self, limit: int = 100, offset: int = 0) -> list[Job]:
111
+ rows = self.conn.execute(
112
+ "SELECT * FROM jobs ORDER BY created_at DESC LIMIT ? OFFSET ?",
113
+ (limit, offset),
114
+ ).fetchall()
115
+ return [self._row_to_job(r) for r in rows]
116
+
117
+ @staticmethod
118
+ def _row_to_job(row: sqlite3.Row) -> Job:
119
+ from datetime import datetime
120
+
121
+ def _parse_dt(v: str | None) -> Any:
122
+ if v is None:
123
+ return None
124
+ return datetime.fromisoformat(v)
125
+
126
+ return Job(
127
+ job_id=row["job_id"],
128
+ status=JobStatus(row["status"]),
129
+ provider_id=row["provider_id"],
130
+ provider_family=row["provider_family"],
131
+ source_filename=row["source_filename"],
132
+ image_width=row["image_width"],
133
+ image_height=row["image_height"],
134
+ has_raw_payload=bool(row["has_raw_payload"]),
135
+ has_canonical=bool(row["has_canonical"]),
136
+ has_alto=bool(row["has_alto"]),
137
+ has_page_xml=bool(row["has_page_xml"]),
138
+ has_viewer=bool(row["has_viewer"]),
139
+ created_at=_parse_dt(row["created_at"]),
140
+ started_at=_parse_dt(row["started_at"]),
141
+ completed_at=_parse_dt(row["completed_at"]),
142
+ error=row["error"],
143
+ warnings=json.loads(row["warnings"]),
144
+ )
145
+
146
+ # -- Providers ------------------------------------------------------------
147
+
148
+ def save_provider_record(self, provider_id: str, data: dict) -> None:
149
+ from datetime import datetime, timezone
150
+
151
+ now = datetime.now(timezone.utc).isoformat()
152
+ self.conn.execute(
153
+ """INSERT OR REPLACE INTO providers (provider_id, data, created_at, updated_at)
154
+ VALUES (?, ?, COALESCE((SELECT created_at FROM providers WHERE provider_id = ?), ?), ?)""",
155
+ (provider_id, json.dumps(data, default=str), provider_id, now, now),
156
+ )
157
+ self.conn.commit()
158
+
159
+ def get_provider_record(self, provider_id: str) -> dict | None:
160
+ row = self.conn.execute(
161
+ "SELECT data FROM providers WHERE provider_id = ?", (provider_id,)
162
+ ).fetchone()
163
+ if row is None:
164
+ return None
165
+ return json.loads(row["data"])
166
+
167
+ def list_provider_records(self) -> list[dict]:
168
+ rows = self.conn.execute(
169
+ "SELECT data FROM providers ORDER BY created_at"
170
+ ).fetchall()
171
+ return [json.loads(r["data"]) for r in rows]
172
+
173
+ def delete_provider_record(self, provider_id: str) -> bool:
174
+ cursor = self.conn.execute(
175
+ "DELETE FROM providers WHERE provider_id = ?", (provider_id,)
176
+ )
177
+ self.conn.commit()
178
+ return cursor.rowcount > 0
src/app/persistence/file_store.py CHANGED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """File store — abstraction for persisting job artifacts on disk.
2
+
3
+ Storage layout:
4
+ {STORAGE_ROOT}/
5
+ jobs/
6
+ {job_id}/
7
+ input.{ext} — original uploaded image
8
+ raw_payload.json — RawProviderPayload
9
+ canonical.json — CanonicalDocument
10
+ alto.xml — ALTO XML export
11
+ page.xml — PAGE XML export
12
+ viewer.json — ViewerProjection
13
+ events.json — job events log
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import shutil
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+
24
+ class FileStore:
25
+ """Manages artifact persistence for jobs."""
26
+
27
+ def __init__(self, storage_root: Path) -> None:
28
+ self._root = storage_root
29
+ self._jobs_dir = storage_root / "jobs"
30
+ self._providers_dir = storage_root / "providers"
31
+
32
+ def ensure_dirs(self) -> None:
33
+ """Create required directories."""
34
+ self._jobs_dir.mkdir(parents=True, exist_ok=True)
35
+ self._providers_dir.mkdir(parents=True, exist_ok=True)
36
+
37
+ # -- Job directory --------------------------------------------------------
38
+
39
+ def job_dir(self, job_id: str) -> Path:
40
+ d = self._jobs_dir / job_id
41
+ d.mkdir(parents=True, exist_ok=True)
42
+ return d
43
+
44
+ # -- Save artifacts -------------------------------------------------------
45
+
46
+ def save_input_image(self, job_id: str, source_path: Path) -> Path:
47
+ """Copy the input image into the job directory."""
48
+ dest = self.job_dir(job_id) / f"input{source_path.suffix}"
49
+ shutil.copy2(source_path, dest)
50
+ return dest
51
+
52
+ def save_json(self, job_id: str, filename: str, data: Any) -> Path:
53
+ """Save a JSON-serializable object."""
54
+ dest = self.job_dir(job_id) / filename
55
+ dest.write_text(json.dumps(data, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
56
+ return dest
57
+
58
+ def save_bytes(self, job_id: str, filename: str, data: bytes) -> Path:
59
+ """Save raw bytes (e.g. XML)."""
60
+ dest = self.job_dir(job_id) / filename
61
+ dest.write_bytes(data)
62
+ return dest
63
+
64
+ def save_raw_payload(self, job_id: str, data: dict) -> Path:
65
+ return self.save_json(job_id, "raw_payload.json", data)
66
+
67
+ def save_canonical(self, job_id: str, data: dict) -> Path:
68
+ return self.save_json(job_id, "canonical.json", data)
69
+
70
+ def save_alto(self, job_id: str, xml_bytes: bytes) -> Path:
71
+ return self.save_bytes(job_id, "alto.xml", xml_bytes)
72
+
73
+ def save_page_xml(self, job_id: str, xml_bytes: bytes) -> Path:
74
+ return self.save_bytes(job_id, "page.xml", xml_bytes)
75
+
76
+ def save_viewer(self, job_id: str, data: dict) -> Path:
77
+ return self.save_json(job_id, "viewer.json", data)
78
+
79
+ def save_events(self, job_id: str, events: list[dict]) -> Path:
80
+ return self.save_json(job_id, "events.json", events)
81
+
82
+ # -- Load artifacts -------------------------------------------------------
83
+
84
+ def load_json(self, job_id: str, filename: str) -> Any:
85
+ """Load a JSON file from the job directory. Returns None if not found."""
86
+ path = self._jobs_dir / job_id / filename
87
+ if not path.exists():
88
+ return None
89
+ return json.loads(path.read_text(encoding="utf-8"))
90
+
91
+ def load_bytes(self, job_id: str, filename: str) -> bytes | None:
92
+ """Load raw bytes. Returns None if not found."""
93
+ path = self._jobs_dir / job_id / filename
94
+ if not path.exists():
95
+ return None
96
+ return path.read_bytes()
97
+
98
+ def load_raw_payload(self, job_id: str) -> dict | None:
99
+ return self.load_json(job_id, "raw_payload.json")
100
+
101
+ def load_canonical(self, job_id: str) -> dict | None:
102
+ return self.load_json(job_id, "canonical.json")
103
+
104
+ def load_alto(self, job_id: str) -> bytes | None:
105
+ return self.load_bytes(job_id, "alto.xml")
106
+
107
+ def load_page_xml(self, job_id: str) -> bytes | None:
108
+ return self.load_bytes(job_id, "page.xml")
109
+
110
+ def load_viewer(self, job_id: str) -> dict | None:
111
+ return self.load_json(job_id, "viewer.json")
112
+
113
+ def load_events(self, job_id: str) -> list[dict] | None:
114
+ return self.load_json(job_id, "events.json")
115
+
116
+ # -- Input image ----------------------------------------------------------
117
+
118
+ def get_input_image_path(self, job_id: str) -> Path | None:
119
+ """Find the input image for a job (any extension)."""
120
+ d = self._jobs_dir / job_id
121
+ if not d.exists():
122
+ return None
123
+ for f in d.iterdir():
124
+ if f.stem == "input" and f.suffix in (".png", ".jpg", ".jpeg", ".tif", ".tiff", ".webp"):
125
+ return f
126
+ return None
127
+
128
+ # -- Provider profiles ----------------------------------------------------
129
+
130
+ def save_provider(self, provider_id: str, data: dict) -> Path:
131
+ dest = self._providers_dir / f"{provider_id}.json"
132
+ dest.write_text(json.dumps(data, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
133
+ return dest
134
+
135
+ def load_provider(self, provider_id: str) -> dict | None:
136
+ path = self._providers_dir / f"{provider_id}.json"
137
+ if not path.exists():
138
+ return None
139
+ return json.loads(path.read_text(encoding="utf-8"))
140
+
141
+ def list_providers(self) -> list[str]:
142
+ if not self._providers_dir.exists():
143
+ return []
144
+ return [f.stem for f in self._providers_dir.glob("*.json")]
145
+
146
+ def delete_provider(self, provider_id: str) -> bool:
147
+ path = self._providers_dir / f"{provider_id}.json"
148
+ if path.exists():
149
+ path.unlink()
150
+ return True
151
+ return False
152
+
153
+ # -- Listing --------------------------------------------------------------
154
+
155
+ def list_jobs(self) -> list[str]:
156
+ if not self._jobs_dir.exists():
157
+ return []
158
+ return sorted(
159
+ [d.name for d in self._jobs_dir.iterdir() if d.is_dir()],
160
+ reverse=True,
161
+ )
162
+
163
+ def job_exists(self, job_id: str) -> bool:
164
+ return (self._jobs_dir / job_id).exists()
165
+
166
+ def job_has_artifact(self, job_id: str, filename: str) -> bool:
167
+ return (self._jobs_dir / job_id / filename).exists()
tests/integration/test_job_service.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integration test: full job lifecycle via JobService."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from src.app.domain.models import RawProviderPayload
9
+ from src.app.jobs.models import JobStatus
10
+ from src.app.jobs.service import JobService
11
+ from src.app.persistence.db import Database
12
+ from src.app.persistence.file_store import FileStore
13
+
14
+
15
+ class TestJobService:
16
+ def _setup(self, tmp_storage: Path) -> tuple[JobService, Database, FileStore]:
17
+ db = Database(tmp_storage / "test.db")
18
+ db.connect()
19
+ store = FileStore(tmp_storage)
20
+ store.ensure_dirs()
21
+ return JobService(db, store), db, store
22
+
23
+ def test_full_pipeline_succeeds(self, tmp_storage: Path, fixtures_dir: Path) -> None:
24
+ svc, db, store = self._setup(tmp_storage)
25
+
26
+ with open(fixtures_dir / "paddle_ocr_sample.json") as f:
27
+ payload = json.load(f)
28
+
29
+ raw = RawProviderPayload(
30
+ provider_id="paddleocr",
31
+ adapter_id="adapter.word_box_json.v1",
32
+ runtime_type="local",
33
+ payload=payload,
34
+ image_width=2480, image_height=3508,
35
+ )
36
+
37
+ # Create job
38
+ job = svc.create_job(
39
+ provider_id="paddleocr",
40
+ provider_family="word_box_json",
41
+ source_filename="test.png",
42
+ )
43
+ assert job.status == JobStatus.QUEUED
44
+
45
+ # Run pipeline
46
+ result = svc.run_job(job, raw, image_width=2480, image_height=3508)
47
+
48
+ assert result.status == JobStatus.SUCCEEDED
49
+ assert result.has_raw_payload
50
+ assert result.has_canonical
51
+ assert result.has_alto
52
+ assert result.has_page_xml
53
+ assert result.error is None
54
+ assert result.duration_ms is not None
55
+ assert result.duration_ms > 0
56
+
57
+ # Verify artifacts on disk
58
+ assert store.load_raw_payload(result.job_id) is not None
59
+ assert store.load_canonical(result.job_id) is not None
60
+ assert store.load_alto(result.job_id) is not None
61
+ assert store.load_page_xml(result.job_id) is not None
62
+ assert store.load_events(result.job_id) is not None
63
+
64
+ # Verify in database
65
+ db_job = svc.get_job(result.job_id)
66
+ assert db_job is not None
67
+ assert db_job.status == JobStatus.SUCCEEDED
68
+
69
+ db.close()
70
+
71
+ def test_job_appears_in_list(self, tmp_storage: Path, fixtures_dir: Path) -> None:
72
+ svc, db, store = self._setup(tmp_storage)
73
+
74
+ with open(fixtures_dir / "paddle_ocr_sample.json") as f:
75
+ payload = json.load(f)
76
+
77
+ raw = RawProviderPayload(
78
+ provider_id="paddleocr", adapter_id="v1", runtime_type="local",
79
+ payload=payload, image_width=2480, image_height=3508,
80
+ )
81
+
82
+ job = svc.create_job("paddleocr", "word_box_json")
83
+ svc.run_job(job, raw, image_width=2480, image_height=3508)
84
+
85
+ jobs = svc.list_jobs()
86
+ assert len(jobs) >= 1
87
+ assert any(j.job_id == job.job_id for j in jobs)
88
+ db.close()
89
+
90
+ def test_failed_job_captured(self, tmp_storage: Path) -> None:
91
+ svc, db, store = self._setup(tmp_storage)
92
+
93
+ raw = RawProviderPayload(
94
+ provider_id="bad", adapter_id="v1", runtime_type="local",
95
+ payload={"not": "a list"}, # will cause adapter to fail
96
+ )
97
+
98
+ job = svc.create_job("bad", "word_box_json")
99
+ result = svc.run_job(job, raw, image_width=100, image_height=100)
100
+
101
+ assert result.status == JobStatus.FAILED
102
+ assert result.error is not None
103
+ assert "list payload" in result.error
104
+
105
+ # Should still be in DB
106
+ db_job = svc.get_job(result.job_id)
107
+ assert db_job is not None
108
+ assert db_job.status == JobStatus.FAILED
109
+
110
+ # Events should be saved even on failure
111
+ events = store.load_events(result.job_id)
112
+ assert events is not None
113
+ db.close()
114
+
115
+ def test_events_log_all_steps(self, tmp_storage: Path, fixtures_dir: Path) -> None:
116
+ svc, db, store = self._setup(tmp_storage)
117
+
118
+ with open(fixtures_dir / "paddle_ocr_sample.json") as f:
119
+ payload = json.load(f)
120
+
121
+ raw = RawProviderPayload(
122
+ provider_id="paddleocr", adapter_id="v1", runtime_type="local",
123
+ payload=payload, image_width=2480, image_height=3508,
124
+ )
125
+
126
+ job = svc.create_job("paddleocr", "word_box_json")
127
+ svc.run_job(job, raw, image_width=2480, image_height=3508)
128
+
129
+ events = store.load_events(job.job_id)
130
+ assert events is not None
131
+ steps = [e["step"] for e in events]
132
+ assert "receive_file" in steps
133
+ assert "save_raw" in steps
134
+ assert "normalize" in steps
135
+ assert "enrich" in steps
136
+ assert "validate" in steps
137
+ assert "compute_readiness" in steps
138
+ assert "export_alto" in steps
139
+ assert "export_page" in steps
140
+ assert "persist" in steps
141
+ db.close()
142
+
143
+ def test_canonical_json_on_disk_is_valid(self, tmp_storage: Path, fixtures_dir: Path) -> None:
144
+ svc, db, store = self._setup(tmp_storage)
145
+
146
+ with open(fixtures_dir / "paddle_ocr_sample.json") as f:
147
+ payload = json.load(f)
148
+
149
+ raw = RawProviderPayload(
150
+ provider_id="paddleocr", adapter_id="v1", runtime_type="local",
151
+ payload=payload, image_width=2480, image_height=3508,
152
+ )
153
+
154
+ job = svc.create_job("paddleocr", "word_box_json")
155
+ svc.run_job(job, raw, image_width=2480, image_height=3508)
156
+
157
+ # Load canonical from disk and validate it
158
+ from src.app.domain.models import CanonicalDocument
159
+ canon_data = store.load_canonical(job.job_id)
160
+ doc = CanonicalDocument.model_validate(canon_data)
161
+ assert doc.document_id == job.job_id
162
+ assert len(doc.pages) == 1
163
+ db.close()
tests/unit/test_database.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the SQLite database layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from src.app.jobs.models import Job, JobStatus
8
+ from src.app.persistence.db import Database
9
+
10
+
11
+ class TestDatabase:
12
+ def test_connect_creates_tables(self, tmp_storage: Path) -> None:
13
+ db = Database(tmp_storage / "test.db")
14
+ db.connect()
15
+ # Should not raise
16
+ db.conn.execute("SELECT * FROM jobs LIMIT 1")
17
+ db.conn.execute("SELECT * FROM providers LIMIT 1")
18
+ db.close()
19
+
20
+ def test_save_and_get_job(self, tmp_storage: Path) -> None:
21
+ db = Database(tmp_storage / "test.db")
22
+ db.connect()
23
+
24
+ job = Job(
25
+ job_id="job_001",
26
+ provider_id="paddle",
27
+ provider_family="word_box_json",
28
+ source_filename="test.png",
29
+ )
30
+ db.save_job(job)
31
+
32
+ loaded = db.get_job("job_001")
33
+ assert loaded is not None
34
+ assert loaded.job_id == "job_001"
35
+ assert loaded.status == JobStatus.QUEUED
36
+ assert loaded.provider_id == "paddle"
37
+ assert loaded.source_filename == "test.png"
38
+ db.close()
39
+
40
+ def test_update_job(self, tmp_storage: Path) -> None:
41
+ db = Database(tmp_storage / "test.db")
42
+ db.connect()
43
+
44
+ job = Job(job_id="job_002", provider_id="p", provider_family="f")
45
+ db.save_job(job)
46
+
47
+ updated = job.model_copy(update={
48
+ "status": JobStatus.SUCCEEDED,
49
+ "has_alto": True,
50
+ })
51
+ db.save_job(updated)
52
+
53
+ loaded = db.get_job("job_002")
54
+ assert loaded is not None
55
+ assert loaded.status == JobStatus.SUCCEEDED
56
+ assert loaded.has_alto is True
57
+ db.close()
58
+
59
+ def test_list_jobs(self, tmp_storage: Path) -> None:
60
+ db = Database(tmp_storage / "test.db")
61
+ db.connect()
62
+
63
+ for i in range(5):
64
+ db.save_job(Job(job_id=f"job_{i:03d}", provider_id="p", provider_family="f"))
65
+
66
+ jobs = db.list_jobs(limit=3)
67
+ assert len(jobs) == 3
68
+
69
+ all_jobs = db.list_jobs()
70
+ assert len(all_jobs) == 5
71
+ db.close()
72
+
73
+ def test_get_nonexistent_job(self, tmp_storage: Path) -> None:
74
+ db = Database(tmp_storage / "test.db")
75
+ db.connect()
76
+ assert db.get_job("nonexistent") is None
77
+ db.close()
78
+
79
+ def test_job_with_warnings(self, tmp_storage: Path) -> None:
80
+ db = Database(tmp_storage / "test.db")
81
+ db.connect()
82
+
83
+ job = Job(
84
+ job_id="job_warn",
85
+ provider_id="p",
86
+ provider_family="f",
87
+ warnings=["bbox overflow", "missing confidence"],
88
+ )
89
+ db.save_job(job)
90
+
91
+ loaded = db.get_job("job_warn")
92
+ assert loaded is not None
93
+ assert len(loaded.warnings) == 2
94
+ assert "bbox overflow" in loaded.warnings
95
+ db.close()
96
+
97
+ def test_job_with_error(self, tmp_storage: Path) -> None:
98
+ db = Database(tmp_storage / "test.db")
99
+ db.connect()
100
+
101
+ job = Job(
102
+ job_id="job_err",
103
+ provider_id="p",
104
+ provider_family="f",
105
+ status=JobStatus.FAILED,
106
+ error="Provider timeout",
107
+ )
108
+ db.save_job(job)
109
+
110
+ loaded = db.get_job("job_err")
111
+ assert loaded is not None
112
+ assert loaded.status == JobStatus.FAILED
113
+ assert loaded.error == "Provider timeout"
114
+ db.close()
115
+
116
+ def test_provider_crud(self, tmp_storage: Path) -> None:
117
+ db = Database(tmp_storage / "test.db")
118
+ db.connect()
119
+
120
+ data = {"provider_id": "paddle", "family": "word_box_json"}
121
+ db.save_provider_record("paddle", data)
122
+
123
+ loaded = db.get_provider_record("paddle")
124
+ assert loaded is not None
125
+ assert loaded["provider_id"] == "paddle"
126
+
127
+ all_providers = db.list_provider_records()
128
+ assert len(all_providers) == 1
129
+
130
+ assert db.delete_provider_record("paddle")
131
+ assert db.get_provider_record("paddle") is None
132
+ db.close()
tests/unit/test_file_store.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the file store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from src.app.persistence.file_store import FileStore
8
+
9
+
10
+ class TestFileStore:
11
+ def test_ensure_dirs(self, tmp_storage: Path) -> None:
12
+ store = FileStore(tmp_storage)
13
+ store.ensure_dirs()
14
+ assert (tmp_storage / "jobs").exists()
15
+ assert (tmp_storage / "providers").exists()
16
+
17
+ def test_save_and_load_json(self, tmp_storage: Path) -> None:
18
+ store = FileStore(tmp_storage)
19
+ data = {"key": "value", "number": 42}
20
+ store.save_json("job_001", "test.json", data)
21
+ loaded = store.load_json("job_001", "test.json")
22
+ assert loaded == data
23
+
24
+ def test_save_and_load_bytes(self, tmp_storage: Path) -> None:
25
+ store = FileStore(tmp_storage)
26
+ data = b"<alto>test</alto>"
27
+ store.save_bytes("job_001", "alto.xml", data)
28
+ loaded = store.load_bytes("job_001", "alto.xml")
29
+ assert loaded == data
30
+
31
+ def test_load_nonexistent_returns_none(self, tmp_storage: Path) -> None:
32
+ store = FileStore(tmp_storage)
33
+ assert store.load_json("missing", "test.json") is None
34
+ assert store.load_bytes("missing", "test.xml") is None
35
+
36
+ def test_save_raw_payload(self, tmp_storage: Path) -> None:
37
+ store = FileStore(tmp_storage)
38
+ store.save_raw_payload("job_001", {"payload": [1, 2, 3]})
39
+ loaded = store.load_raw_payload("job_001")
40
+ assert loaded == {"payload": [1, 2, 3]}
41
+
42
+ def test_save_canonical(self, tmp_storage: Path) -> None:
43
+ store = FileStore(tmp_storage)
44
+ store.save_canonical("job_001", {"document_id": "doc1"})
45
+ loaded = store.load_canonical("job_001")
46
+ assert loaded["document_id"] == "doc1"
47
+
48
+ def test_save_alto(self, tmp_storage: Path) -> None:
49
+ store = FileStore(tmp_storage)
50
+ xml = b"<?xml version='1.0'?><alto/>"
51
+ store.save_alto("job_001", xml)
52
+ assert store.load_alto("job_001") == xml
53
+
54
+ def test_save_page_xml(self, tmp_storage: Path) -> None:
55
+ store = FileStore(tmp_storage)
56
+ xml = b"<?xml version='1.0'?><PcGts/>"
57
+ store.save_page_xml("job_001", xml)
58
+ assert store.load_page_xml("job_001") == xml
59
+
60
+ def test_save_events(self, tmp_storage: Path) -> None:
61
+ store = FileStore(tmp_storage)
62
+ events = [{"step": "normalize", "status": "completed"}]
63
+ store.save_events("job_001", events)
64
+ loaded = store.load_events("job_001")
65
+ assert loaded == events
66
+
67
+ def test_list_jobs(self, tmp_storage: Path) -> None:
68
+ store = FileStore(tmp_storage)
69
+ store.save_json("job_aaa", "test.json", {})
70
+ store.save_json("job_bbb", "test.json", {})
71
+ jobs = store.list_jobs()
72
+ assert "job_aaa" in jobs
73
+ assert "job_bbb" in jobs
74
+
75
+ def test_job_exists(self, tmp_storage: Path) -> None:
76
+ store = FileStore(tmp_storage)
77
+ assert not store.job_exists("job_001")
78
+ store.save_json("job_001", "test.json", {})
79
+ assert store.job_exists("job_001")
80
+
81
+ def test_job_has_artifact(self, tmp_storage: Path) -> None:
82
+ store = FileStore(tmp_storage)
83
+ store.save_json("job_001", "canonical.json", {})
84
+ assert store.job_has_artifact("job_001", "canonical.json")
85
+ assert not store.job_has_artifact("job_001", "alto.xml")
86
+
87
+ def test_provider_crud(self, tmp_storage: Path) -> None:
88
+ store = FileStore(tmp_storage)
89
+ store.ensure_dirs()
90
+ store.save_provider("paddle", {"provider_id": "paddle", "family": "word_box_json"})
91
+ assert store.load_provider("paddle") is not None
92
+ assert "paddle" in store.list_providers()
93
+ assert store.delete_provider("paddle")
94
+ assert store.load_provider("paddle") is None
95
+
96
+ def test_save_input_image(self, tmp_storage: Path, tmp_path: Path) -> None:
97
+ store = FileStore(tmp_storage)
98
+ img = tmp_path / "test.png"
99
+ img.write_bytes(b"\x89PNG\r\n\x1a\n")
100
+ result = store.save_input_image("job_001", img)
101
+ assert result.exists()
102
+ assert result.name == "input.png"
103
+ assert store.get_input_image_path("job_001") == result
tests/unit/test_job_events.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for job events and the event log."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from src.app.jobs.events import EventLog, JobStep
6
+
7
+
8
+ class TestEventLog:
9
+ def test_step_context_manager(self) -> None:
10
+ log = EventLog()
11
+ with log.step(JobStep.NORMALIZE) as event:
12
+ pass # simulate work
13
+ assert len(log.events) == 1
14
+ assert log.events[0].step == JobStep.NORMALIZE
15
+ assert log.events[0].status == "completed"
16
+ assert log.events[0].duration_ms is not None
17
+ assert log.events[0].duration_ms >= 0
18
+
19
+ def test_step_failure(self) -> None:
20
+ log = EventLog()
21
+ try:
22
+ with log.step(JobStep.EXECUTE_RUNTIME):
23
+ raise RuntimeError("boom")
24
+ except RuntimeError:
25
+ pass
26
+ assert log.events[0].status == "failed"
27
+ assert log.events[0].error == "boom"
28
+
29
+ def test_skip(self) -> None:
30
+ log = EventLog()
31
+ log.skip(JobStep.EXPORT_ALTO, "Not eligible")
32
+ assert log.events[0].status == "skipped"
33
+ assert log.events[0].message == "Not eligible"
34
+
35
+ def test_has_failures(self) -> None:
36
+ log = EventLog()
37
+ with log.step(JobStep.NORMALIZE):
38
+ pass
39
+ assert not log.has_failures
40
+
41
+ try:
42
+ with log.step(JobStep.VALIDATE):
43
+ raise ValueError("bad")
44
+ except ValueError:
45
+ pass
46
+ assert log.has_failures
47
+
48
+ def test_total_duration(self) -> None:
49
+ log = EventLog()
50
+ with log.step(JobStep.NORMALIZE):
51
+ pass
52
+ with log.step(JobStep.VALIDATE):
53
+ pass
54
+ assert log.total_duration_ms >= 0
55
+
56
+ def test_to_dicts(self) -> None:
57
+ log = EventLog()
58
+ with log.step(JobStep.NORMALIZE):
59
+ pass
60
+ log.skip(JobStep.EXPORT_ALTO, "skip")
61
+ dicts = log.to_dicts()
62
+ assert len(dicts) == 2
63
+ assert dicts[0]["step"] == "normalize"
64
+ assert dicts[1]["step"] == "export_alto"
65
+
66
+ def test_multiple_steps_in_order(self) -> None:
67
+ log = EventLog()
68
+ for step in [JobStep.RECEIVE_FILE, JobStep.NORMALIZE, JobStep.VALIDATE]:
69
+ with log.step(step):
70
+ pass
71
+ steps = [e.step for e in log.events]
72
+ assert steps == [JobStep.RECEIVE_FILE, JobStep.NORMALIZE, JobStep.VALIDATE]