from __future__ import annotations import shutil from pathlib import Path from typing import Callable, List, Optional from urllib.parse import quote from .schemas import ( AuroraJob, AuroraResult, InputFileDescriptor, MediaType, OutputFileDescriptor, ProcessingStep, ) from .subagents import ( ClarityAgent, EchoAgent, KoreAgent, PipelineCancelledError, PixisAgent, PlateAgent, SubagentContext, VeraAgent, sha256_file, ) ProgressCallback = Callable[[int, str, Optional[ProcessingStep]], None] CancelCheck = Callable[[], bool] class JobCancelledError(RuntimeError): pass class AuroraOrchestrator: def __init__(self, outputs_root: Path, public_base_url: str) -> None: self.outputs_root = outputs_root self.public_base_url = public_base_url.rstrip("/") def _build_pipeline(self, media_type: MediaType, forensic: bool, priority: str = "balanced") -> List[object]: if media_type == "video": pipeline: List[object] = [VeraAgent(), PlateAgent()] elif media_type == "audio": pipeline = [EchoAgent()] elif media_type == "photo": pipeline = [VeraAgent(), PixisAgent(), PlateAgent()] else: pipeline = [ClarityAgent()] if forensic: pipeline.append(KoreAgent()) return pipeline def _file_url(self, job_id: str, name: str) -> str: return f"{self.public_base_url}/api/aurora/files/{quote(job_id)}/{quote(name)}" def _artifact_type(self, path: Path, media_type: MediaType) -> str: lowered = path.name.lower() if lowered.endswith("forensic_log.json"): return "forensic_log" if lowered.endswith("forensic_signature.json"): return "forensic_signature" if "transcript" in lowered: return "transcript" if "plate_detection" in lowered: return "plate_detections" return media_type def run( self, job: AuroraJob, progress_callback: Optional[ProgressCallback] = None, cancel_check: Optional[CancelCheck] = None, ) -> AuroraResult: forensic_mode = job.mode == "forensic" meta_early = job.metadata if isinstance(job.metadata, dict) else {} priority_early = str(meta_early.get("priority") or "balanced").strip().lower() or "balanced" pipeline = self._build_pipeline(job.media_type, forensic_mode, priority_early) output_dir = self.outputs_root / job.job_id output_dir.mkdir(parents=True, exist_ok=True) meta = job.metadata if isinstance(job.metadata, dict) else {} export_options = meta.get("export_options") if isinstance(meta.get("export_options"), dict) else {} priority = str(meta.get("priority") or "balanced").strip().lower() or "balanced" ctx = SubagentContext( job_id=job.job_id, mode=job.mode, media_type=job.media_type, input_hash=job.input_hash, output_dir=output_dir, priority=priority, export_options=export_options, cancel_check=cancel_check, ) current_path = Path(job.input_path) processing_log: List[ProcessingStep] = [] extra_artifacts: List[Path] = [] digital_signature: Optional[str] = None total = max(1, len(pipeline)) for idx, subagent in enumerate(pipeline, start=1): if cancel_check and cancel_check(): raise JobCancelledError(f"Job {job.job_id} cancelled") stage_from = int(((idx - 1) / total) * 95) stage_to = int((idx / total) * 95) def _stage_progress(fraction: float, stage_label: str) -> None: if not progress_callback: return bounded = max(0.0, min(1.0, float(fraction))) progress = stage_from + int((stage_to - stage_from) * bounded) progress_callback(progress, stage_label, None) stage_ctx = SubagentContext( job_id=ctx.job_id, mode=ctx.mode, media_type=ctx.media_type, input_hash=ctx.input_hash, output_dir=ctx.output_dir, priority=ctx.priority, export_options=ctx.export_options, cancel_check=ctx.cancel_check, stage_progress=_stage_progress if progress_callback else None, ) try: run_result = subagent.run(stage_ctx, current_path) except PipelineCancelledError as exc: raise JobCancelledError(str(exc)) from exc current_path = run_result.output_path processing_log.extend(run_result.steps) extra_artifacts.extend(run_result.artifacts) if run_result.metadata.get("digital_signature"): digital_signature = run_result.metadata["digital_signature"] stage = run_result.steps[-1].step if run_result.steps else f"stage_{idx}" progress = int((idx / total) * 95) if progress_callback: for step in run_result.steps: progress_callback(progress, stage, step) if cancel_check and cancel_check(): raise JobCancelledError(f"Job {job.job_id} cancelled") final_media = output_dir / f"aurora_result{current_path.suffix or '.bin'}" if current_path != final_media: if current_path.parent == output_dir: current_path.rename(final_media) else: shutil.move(str(current_path), str(final_media)) result_hash = sha256_file(final_media) output_files: List[OutputFileDescriptor] = [ OutputFileDescriptor( type=job.media_type, name=final_media.name, url=self._file_url(job.job_id, final_media.name), hash=result_hash, ) ] for artifact in extra_artifacts: output_files.append( OutputFileDescriptor( type=self._artifact_type(artifact, job.media_type), name=artifact.name, url=self._file_url(job.job_id, artifact.name), hash=sha256_file(artifact), ) ) if forensic_mode and not digital_signature: digest = result_hash.split(":", 1)[-1][:48] digital_signature = f"ed25519:{digest}" if progress_callback: progress_callback(100, "completed", None) return AuroraResult( mode=job.mode, job_id=job.job_id, media_type=job.media_type, input_file=InputFileDescriptor( name=job.file_name, hash=job.input_hash, ), processing_log=processing_log, output_files=output_files, digital_signature=digital_signature, metadata={ "pipeline": [type(agent).__name__ for agent in pipeline], "forensic_mode": forensic_mode, "export_options": export_options, }, )