pltobing's picture
chore(repo): finalize licensing and documentation
51e84d2
Raw History Blame Contribute Delete
15.8 kB
# License: Apache-2.0 License
# Created by: Patrick Lumbantobing, VertoX-AI
# Copyright (c) 2026 VertoX-AI. All rights reserved.
#
# This work is licensed under the Apache-2.0 License.
# To view a copy of this license, visit
# https://www.apache.org/licenses/LICENSE-2.0
"""Orchestrate reusable metadata annotation with injected alignment backends."""
from __future__ import annotations
import json
import math
import os
import tempfile
import time
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import cast
from speech_text_data_aligners.core.errors import (
BackendOutputError,
ConfigurationError,
SpeechTextAlignerError,
failure_from_error,
)
from speech_text_data_aligners.core.extraction import (
BOUNDARY_COLUMNS,
extract_boundary_fields,
)
from speech_text_data_aligners.core.models import (
AlignmentFailure,
AlignmentOutcome,
AlignmentRequest,
)
from speech_text_data_aligners.core.normalization import lexicalize
from speech_text_data_aligners.core.protocols import AlignmentBackend
from speech_text_data_aligners.core.validation import (
find_path_aliases,
validate_alignment_result,
)
from speech_text_data_aligners.data.audio import (
make_alignment_request,
resolve_audio_path,
)
from speech_text_data_aligners.data.metadata import (
MetadataTable,
read_metadata,
write_annotated_metadata,
)
from speech_text_data_aligners.runtime.receipts import (
RunReceipt,
canonical_fingerprint,
capture_host,
sha256_file,
write_receipt,
)
_PROJECT_VERSION = "0.1.0"
@dataclass(frozen=True, slots=True)
class AnnotationOptions:
"""Control failure policy and artifact publication for annotation.
Parameters
----------
strict: Abort on the first row failure and publish no output CSV.
overwrite: Explicitly permit replacement of output/diagnostic/receipt files.
diagnostics_path: Optional failure JSON path; failures default beside output.
receipt_path: Optional run-receipt JSON path.
"""
strict: bool = False
overwrite: bool = False
diagnostics_path: Path | None = None
receipt_path: Path | None = None
_DEFAULT_ANNOTATION_OPTIONS = AnnotationOptions()
@dataclass(frozen=True, slots=True)
class AnnotationRun:
"""Describe one completed annotation publication and all ordered outcomes.
Parameters
----------
output_csv: Published enriched metadata path.
outcomes: Exactly one ordered outcome per source row.
diagnostics_path: Published failure artifact, if failures occurred.
receipt: Captured deterministic run receipt value.
receipt_path: Published receipt path when requested.
"""
output_csv: Path
outcomes: tuple[AlignmentOutcome, ...]
diagnostics_path: Path | None
receipt: RunReceipt
receipt_path: Path | None
@property
def failures(self) -> tuple[AlignmentFailure, ...]:
"""Return structured failures in original row order.
Returns:
Tuple containing only failed variants.
"""
return tuple(
outcome.failure for outcome in self.outcomes if outcome.failure is not None
)
@dataclass(frozen=True, slots=True)
class _PreparedRows:
"""Hold one source table, valid requests, row positions, and preflight failures."""
table: MetadataTable
requests: tuple[AlignmentRequest, ...]
request_positions: tuple[int, ...]
initial_outcomes: tuple[AlignmentOutcome | None, ...]
def _raise_failure(failure: AlignmentFailure) -> None:
"""Abort strict orchestration for one already-structured row failure.
Parameters
----------
failure: Failure emitted by preflight, a backend, or validation.
Raises
------
SpeechTextAlignerError: Always, with the stable failure code in safe context.
"""
raise SpeechTextAlignerError(
failure.message,
context=(("failure_code", failure.code), ("request_id", failure.request_id)),
retryable=failure.retryable,
)
def _prepare_rows(
metadata_csv: Path,
audio_dir: Path,
*,
strict: bool,
) -> _PreparedRows:
"""Read metadata and form ordered requests while structuring row preflight errors.
Parameters
----------
metadata_csv: Source metadata CSV.
audio_dir: Directory containing ``<audio_id>.wav`` files.
strict: Raise immediately instead of retaining a row failure.
Returns
-------
Source table plus valid requests and per-row placeholder outcomes.
Raises
------
MetadataError: If source metadata is malformed.
SpeechTextAlignerError: On the first row preflight failure in strict mode.
"""
table = read_metadata(metadata_csv)
requests: list[AlignmentRequest] = []
positions: list[int] = []
outcomes: list[AlignmentOutcome | None] = [None] * len(table.rows)
for ordinal in range(len(table.rows)):
row = table.row_dict(ordinal)
request_id = f"{ordinal:06d}"
try:
# Lexical preflight ensures empty text never reaches an expensive backend.
lexicalize(row["en_transcript"])
request = make_alignment_request(
request_id=request_id,
audio_path=resolve_audio_path(audio_dir, row["audio_id"]),
transcript=row["en_transcript"],
context=(("line_id", row["line_id"]), ("audio_id", row["audio_id"])),
)
except SpeechTextAlignerError as error:
failure = failure_from_error(error, request_id=request_id)
if strict:
_raise_failure(failure)
outcomes[ordinal] = AlignmentOutcome(failure=failure)
else:
requests.append(request)
positions.append(ordinal)
return _PreparedRows(table, tuple(requests), tuple(positions), tuple(outcomes))
def _execute_backend(
prepared: _PreparedRows,
backend: AlignmentBackend,
*,
strict: bool,
) -> tuple[AlignmentOutcome, ...]:
"""Run one injected backend and merge validated outcomes into source row order.
Parameters
----------
prepared: Metadata rows and successfully prepared requests.
backend: Injected protocol implementation.
strict: Abort rather than retaining the first backend/validation failure.
Returns
-------
Exactly one outcome for every original source row.
Raises
------
SpeechTextAlignerError: For strict row failures or whole-backend failure.
BackendOutputError: In strict mode when batch count/order is malformed.
"""
merged = list(prepared.initial_outcomes)
if not prepared.requests:
return tuple(outcome for outcome in merged if outcome is not None)
try:
backend_outcomes = backend.align_batch(prepared.requests)
if len(backend_outcomes) != len(prepared.requests):
raise BackendOutputError(
"backend returned an outcome count that does not match requests"
)
except SpeechTextAlignerError as error:
if strict:
raise
backend_outcomes = tuple(
AlignmentOutcome(
failure=failure_from_error(error, request_id=request.request_id)
)
for request in prepared.requests
)
for request, position, outcome in zip(
prepared.requests, prepared.request_positions, backend_outcomes, strict=True
):
try:
if outcome.request_id != request.request_id:
raise BackendOutputError(
"backend outcomes are reordered or misidentified"
)
if outcome.failure is not None:
if strict:
_raise_failure(outcome.failure)
merged[position] = outcome
continue
assert outcome.result is not None
validated = validate_alignment_result(request, outcome.result)
merged[position] = AlignmentOutcome(result=validated)
except SpeechTextAlignerError as error:
failure = failure_from_error(error, request_id=request.request_id)
if strict:
_raise_failure(failure)
merged[position] = AlignmentOutcome(failure=failure)
if any(outcome is None for outcome in merged):
raise BackendOutputError("internal outcome merge left an unpopulated row")
return tuple(outcome for outcome in merged if outcome is not None)
def _write_failures(
failures: tuple[AlignmentFailure, ...],
path: Path,
*,
overwrite: bool,
) -> None:
"""Atomically write structured failures as deterministic JSON.
Parameters
----------
failures: Ordered row failures.
path: Diagnostic artifact destination.
overwrite: Explicit replacement authorization.
Raises
------
BackendOutputError: If overwrite is unauthorized or publication fails.
Side Effects:
Creates and atomically publishes a JSON diagnostic artifact.
"""
if path.exists() and not overwrite:
raise BackendOutputError(
"diagnostic artifact exists without overwrite permission"
)
path.parent.mkdir(parents=True, exist_ok=True)
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
newline="\n",
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
delete=False,
) as stream:
temporary_path = Path(stream.name)
json.dump(
[asdict(failure) for failure in failures],
stream,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary_path, path)
except OSError as error:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
raise BackendOutputError(
f"cannot publish diagnostic artifact: {path}"
) from error
def _preflight_annotation_paths(
metadata_csv: Path,
output_csv: Path,
options: AnnotationOptions,
) -> Path:
"""Validate the complete annotation publication plan before backend execution.
Parameters
----------
metadata_csv: Immutable source metadata path.
output_csv: Annotated CSV destination.
options: Diagnostic, receipt, and overwrite publication controls.
Returns
-------
Effective diagnostics destination, including the deterministic default.
Raises
------
ConfigurationError: If logical artifacts alias or an existing destination
lacks overwrite authorization.
Side Effects:
Reads filesystem identity/existence metadata only; no artifact is published.
"""
diagnostics_path = options.diagnostics_path or output_csv.with_name(
f"{output_csv.stem}.diagnostics.json"
)
paths = {
"source metadata CSV": metadata_csv,
"annotated output CSV": output_csv,
"diagnostics JSON": diagnostics_path,
}
if options.receipt_path is not None:
paths["run receipt JSON"] = options.receipt_path
aliases = find_path_aliases(paths)
if aliases:
left, right = aliases[0]
raise ConfigurationError(f"annotation artifact paths alias: {left} and {right}")
if not options.overwrite:
existing = tuple(
name
for name, path in paths.items()
if name != "source metadata CSV" and path.exists()
)
if existing:
raise ConfigurationError(
f"annotation artifact exists without overwrite permission: {existing[0]}"
)
return diagnostics_path
def annotate_metadata(
*,
metadata_csv: Path,
audio_dir: Path,
output_csv: Path,
backend: AlignmentBackend,
options: AnnotationOptions = _DEFAULT_ANNOTATION_OPTIONS,
) -> AnnotationRun:
"""Annotate metadata through an injected backend and atomically publish results.
In continue-on-error mode every source row remains ordered and failed rows receive
six empty cells plus a separate structured diagnostic artifact. Strict mode aborts
before publishing the output CSV when any row fails.
Parameters
----------
metadata_csv: Immutable source metadata CSV.
audio_dir: Directory containing WAVs resolved from ``audio_id``.
output_csv: New annotated CSV destination.
backend: Injected backend-neutral protocol implementation.
options: Strict/continue and artifact publication controls.
Returns
-------
Completed annotation run with ordered outcomes and captured receipt.
Raises
------
SpeechTextAlignerError: For malformed metadata, unsafe paths, strict failures,
backend protocol violations, or artifact publication errors.
Side Effects:
Publishes the output CSV and, when applicable/requested, diagnostics and a run
receipt. It never modifies the source CSV.
"""
diagnostics_path = _preflight_annotation_paths(metadata_csv, output_csv, options)
started_wall = datetime.now(UTC)
started_monotonic = time.monotonic()
prepared = _prepare_rows(metadata_csv, audio_dir, strict=options.strict)
outcomes = _execute_backend(prepared, backend, strict=options.strict)
requests = {request.request_id: request for request in prepared.requests}
annotations: list[dict[str, str | float | None]] = []
for outcome in outcomes:
if outcome.result is None:
annotations.append(dict.fromkeys(BOUNDARY_COLUMNS, None))
else:
request = requests[outcome.request_id]
annotations.append(
cast(
"dict[str, str | float | None]",
extract_boundary_fields(request, outcome.result).as_dict(),
)
)
failures = tuple(
outcome.failure for outcome in outcomes if outcome.failure is not None
)
write_annotated_metadata(
prepared.table,
output_csv,
tuple(annotations),
overwrite=options.overwrite,
)
if failures:
_write_failures(failures, diagnostics_path, overwrite=options.overwrite)
finished_wall = datetime.now(UTC)
elapsed = time.monotonic() - started_monotonic
receipt = RunReceipt(
schema_version="1",
project_version=_PROJECT_VERSION,
operation="annotate",
backend_names=(backend.name,),
input_path=str(metadata_csv),
input_sha256=sha256_file(metadata_csv),
request_ids=tuple(outcome.request_id for outcome in outcomes),
host=capture_host(),
config_sha256=canonical_fingerprint(
{"backend": backend.name, "strict": options.strict}
),
started_at_utc=started_wall.isoformat(),
finished_at_utc=finished_wall.isoformat(),
elapsed_s=elapsed if math.isfinite(elapsed) else 0.0,
success_count=sum(outcome.succeeded for outcome in outcomes),
failure_count=len(failures),
artifacts=(str(output_csv),) + ((str(diagnostics_path),) if failures else ()),
)
if options.receipt_path is not None:
write_receipt(receipt, options.receipt_path, overwrite=options.overwrite)
return AnnotationRun(
output_csv=output_csv,
outcomes=outcomes,
diagnostics_path=diagnostics_path if failures else None,
receipt=receipt,
receipt_path=options.receipt_path,
)