---
phase: 22-ingestion-pipeline
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/ztlctl/services/transcription.py
  - src/ztlctl/config/models.py
  - src/ztlctl/config/settings.py
  - tests/services/test_transcription.py
autonomous: true
requirements: [INGP-01, INGP-02, INGP-05]

must_haves:
  truths:
    - "TranscriptionService accepts audio/video/transcript file paths and returns structured transcription output"
    - "faster-whisper is an optional dependency with graceful ImportError handling"
    - "VTT and SRT transcript files are parsed into plain text without external parser dependencies"
    - "MediaIngestConfig exposes whisper_model, language, and compute_type with sensible defaults"
  artifacts:
    - path: "src/ztlctl/services/transcription.py"
      provides: "Isolated transcription module with whisper integration and transcript parsing"
      exports: ["TranscriptionService", "TranscriptionResult"]
    - path: "src/ztlctl/config/models.py"
      provides: "MediaIngestConfig Pydantic model"
      contains: "class MediaIngestConfig"
    - path: "tests/services/test_transcription.py"
      provides: "Tests for transcription service"
      min_lines: 80
  key_links:
    - from: "src/ztlctl/services/transcription.py"
      to: "faster-whisper"
      via: "guarded import with try/except ImportError"
      pattern: "try.*import.*faster_whisper"
    - from: "src/ztlctl/config/models.py"
      to: "src/ztlctl/config/settings.py"
      via: "MediaIngestConfig composed into IngestConfig"
      pattern: "media.*MediaIngestConfig"
---

<objective>
Create the transcription service module and media config section — the foundational building blocks for media ingestion.

Purpose: Isolate all whisper/transcript parsing logic in a single module with a clean interface, and add the config model that controls transcription behavior. Plan 02 will wire these into IngestService and ActionRegistry.

Output: `transcription.py` with TranscriptionService, `MediaIngestConfig` in config/models.py, test coverage.
</objective>

<execution_context>
@~/.claude/get-shit-done/workflows/execute-plan.md
@~/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/22-ingestion-pipeline/22-CONTEXT.md

@src/ztlctl/services/ingest.py
@src/ztlctl/config/models.py
@src/ztlctl/config/settings.py
@src/ztlctl/services/base.py

<interfaces>
<!-- Key types and contracts the executor needs. -->

From src/ztlctl/config/models.py:
```python
class IngestConfig(BaseModel):
    model_config = {"frozen": True}
    enabled: bool = True
    auto_reweave: bool = True
    default_target_type: str = "reference"
    providers: dict[str, Any] = Field(default_factory=dict)
```

From src/ztlctl/config/settings.py:
```python
class ZtlSettings(BaseSettings):
    ingest: IngestConfig = Field(default_factory=IngestConfig)
```

From src/ztlctl/services/base.py:
```python
class BaseService:
    def __init__(self, vault: Vault) -> None:
        self._vault = vault
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: TranscriptionService with whisper integration and transcript parsing</name>
  <files>src/ztlctl/services/transcription.py, tests/services/test_transcription.py</files>
  <read_first>src/ztlctl/services/base.py, src/ztlctl/services/ingest.py, src/ztlctl/config/models.py</read_first>
  <behavior>
    - Test: TranscriptionResult dataclass has fields: text (str), language (str|None), duration_seconds (float|None), segments (list), capture_agent (str), modalities (list[str])
    - Test: _parse_vtt() strips WebVTT headers and timestamps, joins text lines
    - Test: _parse_srt() strips SRT sequence numbers and timestamps, joins text lines
    - Test: _detect_media_type() returns "audio" for mp3/m4a/wav/ogg/flac, "video" for mp4/mkv/webm, None for unsupported
    - Test: transcribe_file() with transcript file (.vtt) parses and returns TranscriptionResult with capture_agent="transcript-parser" and modalities=["text"]
    - Test: transcribe_file() with transcript file (.srt) parses and returns TranscriptionResult
    - Test: transcribe_file() with transcript file (.txt) reads raw text and returns TranscriptionResult
    - Test: transcribe_file() with unsupported extension returns error
    - Test: transcribe_file() with nonexistent file returns error
    - Test: When faster-whisper is not installed, transcribe_file() on audio files returns error with helpful message
  </behavior>
  <action>
    Create `src/ztlctl/services/transcription.py` with:

    1. A `TranscriptionResult` dataclass (frozen=True) with fields: `text` (str), `language` (str | None), `duration_seconds` (float | None), `segments` (list[dict[str, Any]]), `capture_agent` (str), `modalities` (list[str]).

    2. A `TranscriptionService` class (NOT extending BaseService — this is a stateless utility, not a vault service) with:
       - `AUDIO_EXTENSIONS = frozenset({".mp3", ".m4a", ".wav", ".ogg", ".flac"})`
       - `VIDEO_EXTENSIONS = frozenset({".mp4", ".mkv", ".webm"})`
       - `TRANSCRIPT_EXTENSIONS = frozenset({".txt", ".vtt", ".srt"})`
       - `SUPPORTED_EXTENSIONS = AUDIO_EXTENSIONS | VIDEO_EXTENSIONS | TRANSCRIPT_EXTENSIONS`
       - `__init__(self, *, whisper_model: str = "base", language: str | None = None, compute_type: str = "int8")`
       - `transcribe_file(self, path: Path) -> TranscriptionResult | ServiceError` — dispatches to `_transcribe_media()` for audio/video or `_parse_transcript()` for txt/vtt/srt
       - `_transcribe_media(self, path: Path, media_type: str) -> TranscriptionResult | ServiceError` — uses faster-whisper with guarded import (`try: from faster_whisper import WhisperModel` inside the method). Returns ServiceError with code="DEPENDENCY_MISSING" and message including `uv add --group media faster-whisper` if not installed.
       - `_parse_transcript(self, path: Path) -> TranscriptionResult | ServiceError` — reads file, dispatches to `_parse_vtt`/`_parse_srt`/raw text based on suffix
       - `_parse_vtt(self, text: str) -> str` — regex-based: strip `WEBVTT` header line, strip lines matching `\d\d:\d\d`, strip blank lines, join remaining
       - `_parse_srt(self, text: str) -> str` — regex-based: strip lines that are just digits (sequence numbers), strip lines matching `\d\d:\d\d.*-->`, strip blank lines, join remaining
       - `_detect_media_type(self, path: Path) -> str | None` — returns "audio", "video", or None based on suffix

    3. Use `@traced` decorator on `transcribe_file` (import from `ztlctl.services.telemetry`). Use `trace_span` context manager inside `_transcribe_media` for the whisper model loading and transcription sub-stages.

    4. For the whisper integration in `_transcribe_media`: load model with `WhisperModel(self._whisper_model, compute_type=self._compute_type)`, call `model.transcribe(str(path), language=self._language)`, iterate segments to build text and segment list, capture `info.language` and `info.duration`.

    Tests: Mock faster-whisper entirely — never import the real package. Test VTT/SRT parsing with inline fixture strings. Test the error paths (missing file, unsupported extension, missing dependency).
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run pytest tests/services/test_transcription.py -x -v</automated>
  </verify>
  <acceptance_criteria>TranscriptionService handles audio/video/transcript files, returns TranscriptionResult or ServiceError, faster-whisper import is guarded, VTT/SRT parsing works with regex-only approach</acceptance_criteria>
  <done>TranscriptionService with all file type handling, guarded whisper import, VTT/SRT parsing, full test coverage</done>
</task>

<task type="auto">
  <name>Task 2: MediaIngestConfig model and settings wiring</name>
  <files>src/ztlctl/config/models.py, src/ztlctl/config/settings.py</files>
  <read_first>src/ztlctl/config/models.py, src/ztlctl/config/settings.py</read_first>
  <action>
    1. In `src/ztlctl/config/models.py`, add `MediaIngestConfig` (per INGP-05):
       ```python
       class MediaIngestConfig(BaseModel):
           """[ingest.media] section."""
           model_config = {"frozen": True}
           whisper_model: str = "base"
           language: str | None = None
           compute_type: str = "int8"
       ```

    2. Add a `media` field to the existing `IngestConfig`:
       ```python
       class IngestConfig(BaseModel):
           # ... existing fields ...
           media: MediaIngestConfig = Field(default_factory=MediaIngestConfig)
       ```

    3. In `src/ztlctl/config/settings.py`, add `MediaIngestConfig` to the import from `ztlctl.config.models` (it's composed via IngestConfig, so no field change needed on ZtlSettings — just ensure the import is present for type visibility if needed).

    4. Verify existing tests still pass — the new config has defaults so no existing TOML files break.
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run pytest tests/config/ -x -v && uv run mypy src/ztlctl/config/models.py src/ztlctl/config/settings.py</automated>
  </verify>
  <acceptance_criteria>MediaIngestConfig model exists with whisper_model/language/compute_type fields, IngestConfig composes it as `media` field, all existing config tests pass, mypy clean</acceptance_criteria>
  <done>`[ingest.media]` config section with whisper_model (default "base"), language (default None), compute_type (default "int8") — accessible via settings.ingest.media</done>
</task>

</tasks>

<verification>
- `uv run pytest tests/services/test_transcription.py tests/config/ -x` passes
- `uv run mypy src/ztlctl/services/transcription.py src/ztlctl/config/models.py` clean
- `uv run ruff check src/ztlctl/services/transcription.py src/ztlctl/config/models.py` clean
- TranscriptionService can be instantiated with config values from MediaIngestConfig
</verification>

<success_criteria>
- TranscriptionService handles all supported file types (audio, video, transcript)
- faster-whisper is optional — graceful error when not installed
- VTT/SRT parsing is regex-only, no external dependencies
- MediaIngestConfig accessible via settings.ingest.media
- All tests pass, mypy strict, ruff clean
</success_criteria>

<output>
After completion, create `.planning/phases/22-ingestion-pipeline/22-01-SUMMARY.md`
</output>
