File size: 8,690 Bytes
734b5b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable

from src.core.models import JobConfig, JobResult, dataclass_to_dict
from src.core.toolbox import PDFToolbox
from src.services.local_logger import LocalLogger
from src.utils.file_utils import ensure_dir, stage_file


@dataclass
class PipelineContext:
    job_config: JobConfig
    job_dir: Path
    log_file: Path
    logger: LocalLogger
    toolbox: PDFToolbox
    current_pdf: Path
    stages: list = field(default_factory=list)
    errors: list = field(default_factory=list)
    state: dict = field(default_factory=dict)

    def make_stage_path(self, prefix: str, suffix: str = ".pdf") -> Path:
        return stage_file(self.job_dir, prefix, suffix=suffix)

    def finish_success(self, final_pdf: Path, manifest: Path) -> JobResult:
        self.logger.log("orchestrator", "info", "Job finished", success=True)
        return JobResult(
            self.job_config.job_id,
            True,
            final_pdf,
            self.log_file,
            manifest,
            self.stages,
            self.errors,
        )

    def finish_failure(self) -> JobResult:
        return JobResult(
            self.job_config.job_id,
            False,
            None,
            self.log_file,
            self.job_dir / "manifest.json",
            self.stages,
            self.errors,
        )


@dataclass
class PipelineStep:
    name: str
    action: Callable[[PipelineContext], None]
    enabled: Callable[[PipelineContext], bool] | None = None

    def should_run(self, context: PipelineContext) -> bool:
        if self.enabled is None:
            return True
        return self.enabled(context)


@dataclass
class PDFPipeline:
    name: str
    description: str
    steps: list[PipelineStep]

    def run(self, context: PipelineContext) -> PipelineContext:
        context.logger.log(
            "orchestrator",
            "info",
            "Pipeline started",
            pipeline=self.name,
            job_id=context.job_config.job_id,
        )
        for step in self.steps:
            if step.should_run(context):
                step.action(context)
        return context


def create_pipeline_context(

    job_config: JobConfig, toolbox: PDFToolbox, work_dir: Path

) -> PipelineContext:
    job_config.output_dir = ensure_dir(job_config.output_dir)
    job_dir = ensure_dir(job_config.output_dir / f"job-{job_config.job_id}")
    log_file = job_dir / "pipeline.jsonl"
    logger = LocalLogger(log_file)
    logger.log("orchestrator", "info", "Job started", job_id=job_config.job_id)
    return PipelineContext(
        job_config=job_config,
        job_dir=job_dir,
        log_file=log_file,
        logger=logger,
        toolbox=toolbox,
        current_pdf=job_config.input_pdf,
        state={"work_dir": str(work_dir)},
    )


def _inspect_step(context: PipelineContext) -> None:
    report = context.toolbox.inspect(context.current_pdf)
    buckets = context.toolbox.bucket_operations(context.job_config.operations)
    context.state["inspection_report"] = report
    context.state["operation_buckets"] = buckets
    context.stages.append({"stage": "inspect", "report": dataclass_to_dict(report)})
    context.logger.log(
        "inspect",
        "info",
        "Done",
        ocr_pages=report.ocr_required_pages,
        edit_operations=len(buckets.edit),
        annotation_operations=len(buckets.annotate),
    )


def _needs_ocr(context: PipelineContext) -> bool:
    report = context.state.get("inspection_report")
    if report is None:
        return context.job_config.force_ocr or context.job_config.run_ocr
    return (
        context.job_config.force_ocr
        or context.job_config.run_ocr
        or bool(report.ocr_required_pages)
    )


def _ocr_step(context: PipelineContext) -> None:
    output = context.make_stage_path("02-ocr")
    context.current_pdf = context.toolbox.run_ocr(
        context.current_pdf, output, context.job_config.ocr_language
    )
    context.stages.append({"stage": "ocr", "output": str(context.current_pdf)})
    context.logger.log("ocr", "info", "Done", output=str(context.current_pdf))


def _has_edit_ops(context: PipelineContext) -> bool:
    buckets = context.state.get("operation_buckets")
    return bool(buckets and buckets.edit)


def _edit_step(context: PipelineContext) -> None:
    buckets = context.state["operation_buckets"]
    output = context.make_stage_path("03-edit")
    context.current_pdf = context.toolbox.edit_pdf(
        context.current_pdf, output, buckets.edit
    )
    context.stages.append(
        {
            "stage": "edit",
            "output": str(context.current_pdf),
            "count": len(buckets.edit),
        }
    )
    context.logger.log("edit", "info", "Done", count=len(buckets.edit))


def _has_annotation_ops(context: PipelineContext) -> bool:
    buckets = context.state.get("operation_buckets")
    return bool(buckets and buckets.annotate)


def _annotate_step(context: PipelineContext) -> None:
    buckets = context.state["operation_buckets"]
    output = context.make_stage_path("04-annotate")
    context.current_pdf = context.toolbox.annotate_pdf(
        context.current_pdf, output, buckets.annotate
    )
    context.stages.append(
        {
            "stage": "annotate",
            "output": str(context.current_pdf),
            "count": len(buckets.annotate),
        }
    )
    context.logger.log("annotate", "info", "Done", count=len(buckets.annotate))


def _validate_step(context: PipelineContext) -> None:
    output = context.make_stage_path("05-validated")
    context.current_pdf, report = context.toolbox.validate_pdf(
        context.current_pdf, output
    )
    context.state["validation_report"] = report
    context.stages.append(
        {"stage": "validate", "output": str(context.current_pdf), "report": report}
    )
    context.logger.log("validate", "info", "Done", **report)


def _export_step(context: PipelineContext) -> None:
    final_pdf, manifest = context.toolbox.export_job(
        context.current_pdf,
        context.job_config,
        context.stages,
        context.log_file,
    )
    context.state["final_pdf"] = final_pdf
    context.state["manifest"] = manifest
    context.stages.append(
        {"stage": "export", "output": str(final_pdf), "manifest": str(manifest)}
    )
    context.logger.log("export", "info", "Done", final=str(final_pdf))


def build_pipeline_catalog(toolbox: PDFToolbox) -> dict[str, PDFPipeline]:
    inspect = PipelineStep("inspect", _inspect_step)
    ocr = PipelineStep("ocr", _ocr_step, enabled=_needs_ocr)
    edit = PipelineStep("edit", _edit_step, enabled=_has_edit_ops)
    annotate = PipelineStep("annotate", _annotate_step, enabled=_has_annotation_ops)
    validate = PipelineStep("validate", _validate_step)
    export = PipelineStep("export", _export_step)

    return {
        "full_document": PDFPipeline(
            name="full_document",
            description="Inspects the document, runs OCR if needed, applies edits and annotations, validates, then exports.",
            steps=[inspect, ocr, edit, annotate, validate, export],
        ),
        "text_edit": PDFPipeline(
            name="text_edit",
            description="Runs the edit-oriented document pipeline without annotation steps.",
            steps=[inspect, ocr, edit, validate, export],
        ),
        "annotation_review": PDFPipeline(
            name="annotation_review",
            description="Applies annotation and review operations on top of the current PDF, then validates and exports.",
            steps=[inspect, annotate, validate, export],
        ),
        "page_restructure": PDFPipeline(
            name="page_restructure",
            description="Handles page-level editing operations like rotate, delete, and reorder before validation and export.",
            steps=[inspect, edit, validate, export],
        ),
        "ocr_preflight": PDFPipeline(
            name="ocr_preflight",
            description="Runs inspection and OCR preparation only, then validates and exports an OCR-ready PDF.",
            steps=[inspect, ocr, validate, export],
        ),
    }


def list_available_pipeline_names(toolbox: PDFToolbox | None = None) -> list[str]:
    resolved_toolbox = toolbox or PDFToolbox()
    return list(build_pipeline_catalog(resolved_toolbox).keys())