Output:
Exit code: 0
Wall time: 0 seconds
Output:
from __future__ import annotations

import json
import time
from dataclasses import asdict
from pathlib import Path
from typing import Any

from rich.progress import track

from maya_transcribe.asr import ASRResult, FasterWhisperASR, TransformersWhisperASR
from maya_transcribe.audio import compute_qc, ffprobe, normalize_audio
from maya_transcribe.lid import LIDResult, MMSLID
from maya_transcribe.rover import rover_combine
from maya_transcribe.textnorm import normalize_text
from maya_transcribe.verify import verify_segment

def _iter_audio_files(segments_dir: Path) -> list[Path]:
    audio_files: list[Path] = []
    for ext in ("*.wav", "*.mp3", "*.m4a", "*.flac", "*.ogg", "*.opus"):
        audio_files.extend(sorted(segments_dir.rglob(ext)))
    return sorted(set(audio_files))

def _asr_summary(result: ASRResult) -> dict[str, Any]:
    avg_logprobs = [s.avg_logprob for s in result.segments if s.avg_logprob is not None]
    no_speech_probs = [s.no_speech_prob for s in result.segments if s.no_speech_prob is not None]
    compression_ratios = [s.compression_ratio for s in result.segments if s.compression_ratio is not None]

    def mean(xs: list[float]) -> float | None:
        return (sum(xs) / len(xs)) if xs else None

    return {
        "language": result.language,
        "language_probability": result.language_probability,
        "avg_logprob_mean": mean([float(x) for x in avg_logprobs]),
        "no_speech_prob_mean": mean([float(x) for x in no_speech_probs]),
        "compression_ratio_mean": mean([float(x) for x in compression_ratios]),
    }

def run_pipeline(
    *,
    segments_dir: Path,
    out_dir: Path,
    lid_model_id: str = "facebook/mms-lid-256",
    whisper_model: str = "large-v3",
    extra_transformers_whisper_models: list[str] | None = None,
    limit: int | None = None,
    thresholds: dict[str, Any] | None = None,
) -> Path:
    """
    End-to-end run: preprocess -> LID -> ASR ensemble -> normalize -> ROVER -> verify.
    Writes `out_dir/results.jsonl` and returns that path.
    """
    thresholds = thresholds or {}
    out_dir.mkdir(parents=True, exist_ok=True)

    audio_files = _iter_audio_files(segments_dir)
    if not audio_files:
        raise RuntimeError(f"No audio files found under: {segments_dir}")
    if limit is not None:
        audio_files = audio_files[: max(0, int(limit))]

    # Instantiate models once.
    lid_backend: MMSLID | None
    try:
        lid_backend = MMSLID(model_id=lid_model_id)
    except Exception:
        lid_backend = None

    whisper_backend = FasterWhisperASR(model_size_or_path=whisper_model)

    extra_transformers_whisper_models = extra_transformers_whisper_models or []
    transformers_backends: list[TransformersWhisperASR] = []
    for model_id in extra_transformers_whisper_models:
        transformers_backends.append(TransformersWhisperASR(model_id=model_id))

    results_path = out_dir / "results.jsonl"
    summary_path = out_dir / "summary.json"

    counts = {"accept": 0, "human": 0, "reject": 0, "error": 0}
    timings_total: list[float] = []

    with results_path.open("w", encoding="utf-8") as f:
        for src in track(audio_files, description="Transcribing"):
            segment_id = src.stem
            t0 = time.perf_counter()

            try:
                # 1) Preprocess
                t_pre0 = time.perf_counter()
                wav_path = out_dir / "audio" / f"{segment_id}.wav"
                normalize_audio(src, wav_path, trim_silence=True)
                info = ffprobe(wav_path)
                qc = compute_qc(wav_path)
                t_pre1 = time.perf_counter()

                # 2) LID
                t_lid0 = time.perf_counter()
                lid: LIDResult | None = None
                if lid_backend is not None:
                    lid = lid_backend.predict(wav_path, top_k=5)
                t_lid1 = time.perf_counter()

                # Route language hint (only if confident).
                route_lang = None
                if lid and lid.predictions:
                    top1 = lid.predictions[0]
                    top2p = lid.predictions[1].prob if len(lid.predictions) > 1 else 0.0
                    if float(top1.prob) >= float(thresholds.get("lid_min_prob", 0.90)) and (
                        float(top1.prob) - float(top2p)
                    ) >= float(thresholds.get("lid_min_margin", 0.20)):
                        route_lang = top1.code

                # 3) ASR ensemble
                t_asr0 = time.perf_counter()
                asr_results: dict[str, ASRResult] = {}

                # Whisper: deterministic + a small sampling diversity pass.
                det = whisper_backend.transcribe(wav_path, language=route_lang, beam_size=5, best_of=5, temperature=0.0)
                asr_results[f"{det.model}|det"] = det
                samp = whisper_backend.transcribe(
                    wav_path, language=route_lang, beam_size=5, best_of=10, temperature=0.2
                )
                asr_results[f"{samp.model}|samp_t0.2"] = samp

                for tf_backend in transformers_backends:
                    res = tf_backend.transcribe(wav_path, language=route_lang, num_beams=5, max_new_tokens=256)
                    asr_results[res.model] = res

                t_asr1 = time.perf_counter()

                # 4) Normalize hypotheses
                t_norm0 = time.perf_counter()
                norm_hyps: dict[str, str] = {}
                for k, res in asr_results.items():
                    lang_hint = res.language or route_lang
                    norm_hyps[k] = normalize_text(res.text, lang=lang_hint)
                t_norm1 = time.perf_counter()

                # 5) ROVER
                t_rover0 = time.perf_counter()
                rover = rover_combine(norm_hyps)
                t_rover1 = time.perf_counter()

                # 6) Verify
                t_ver0 = time.perf_counter()
                asr_summaries = {k: _asr_summary(v) for k, v in asr_results.items()}
                verification = verify_segment(
                    qc=asdict(qc),
                    lid=lid,
                    rover={
                        "text": rover.text,
                        "disagreement_rate": rover.disagreement_rate,
                        "skeleton_key": rover.skeleton_key,
                    },
                    asr_summaries=asr_summaries,
                    thresholds=thresholds,
                )
                t_ver1 = time.perf_counter()

                record = {
                    "segment_id": segment_id,
                    "src_path": str(src),
                    "wav_path": str(wav_path),
                    "ffprobe": asdict(info),
                    "qc": asdict(qc),
                    "lid": (
                        None
                        if lid is None
                        else {
                            "model": lid.model,
                            "predictions": [{"code": p.code, "prob": p.prob} for p in lid.predictions],
                        }
                    ),
                    "route": {"language_hint": route_lang},
                    "asr": {
                        k: {
                            "model": v.model,
                            "text": v.text,
                            "language": v.language,
                            "language_probability": v.language_probability,
                            "segments": [asdict(s) for s in v.segments],
                            "meta": v.meta,
                        }
                        for k, v in asr_results.items()
                    },
                    "normalized": norm_hyps,
                    "rover": {
                        "text": rover.text,
                        "skeleton_key": rover.skeleton_key,
                        "disagreement_rate": rover.disagreement_rate,
                        "disagreements": [
                            {"kind": d.kind, "index": d.index, "chosen": d.chosen, "options": d.options}
                            for d in rover.disagreements
                        ],
                    },
                    "verification": {


Output:
Exit code: 0
Wall time: 0 seconds
Output:
from __future__ import annotations

from dataclasses import dataclass
from typing import Any

from maya_transcribe.lid import LIDResult

@dataclass(frozen=True)
class Verification:
    decision: str  # "accept" | "human" | "reject"
    reasons: list[str]
    scores: dict[str, Any]

def lid_confidence(lid: LIDResult | None) -> dict[str, Any]:
    if not lid or not lid.predictions:
        return {"available": False, "top1": None, "top1_prob": None, "margin": None, "confident": False}
    top1 = lid.predictions[0]
    top2_prob = lid.predictions[1].prob if len(lid.predictions) > 1 else 0.0
    margin = float(top1.prob) - float(top2_prob)
    return {
        "available": True,
        "top1": top1.code,
        "top1_prob": float(top1.prob),
        "margin": margin,
        "confident": False,
    }

def verify_segment(
    *,
    qc: dict[str, Any],
    lid: LIDResult | None,
    rover: dict[str, Any],
    asr_summaries: dict[str, Any],
    thresholds: dict[str, Any] | None = None,
) -> Verification:
    """
    Conservative verification gate (precision-first).
    """
    thresholds = thresholds or {}
    min_duration = float(thresholds.get("min_duration_sec", 0.8))
    max_clipping = float(thresholds.get("max_clipping_fraction", 0.01))
    max_disagreement = float(thresholds.get("max_rover_disagreement_rate", 0.03))
    lid_min_prob = float(thresholds.get("lid_min_prob", 0.90))
    lid_min_margin = float(thresholds.get("lid_min_margin", 0.20))

    reasons: list[str] = []
    decision = "accept"

    duration = float(qc.get("duration_sec") or 0.0)
    clipping = float(qc.get("clipping_fraction") or 0.0)
    if duration < min_duration:
        decision = "reject"
        reasons.append("too_short")
    if clipping > max_clipping:
        decision = "human" if decision != "reject" else decision
        reasons.append("clipping")

    lidc = lid_confidence(lid)
    if lidc["available"]:
        lidc["confident"] = bool(lidc["top1_prob"] >= lid_min_prob and lidc["margin"] >= lid_min_margin)
        if not lidc["confident"]:
            decision = "human" if decision != "reject" else decision
            reasons.append("lid_uncertain")
    else:
        reasons.append("lid_missing")

    disagreement_rate = float(rover.get("disagreement_rate") or 0.0)
    if disagreement_rate > max_disagreement:
        decision = "human" if decision != "reject" else decision
        reasons.append("asr_disagreement")

    # Whisper-style safety: flag if any decoder claims "no speech" with high probability.
    no_speech_probs = []
    for summary in asr_summaries.values():
        p = summary.get("no_speech_prob_mean")
        if p is None:
            continue
        no_speech_probs.append(float(p))
    if no_speech_probs and max(no_speech_probs) > 0.8:
        decision = "reject"
        reasons.append("no_speech")

    scores = {
        "duration_sec": duration,
        "clipping_fraction": clipping,
        "rover_disagreement_rate": disagreement_rate,
        "lid": lidc,
        "no_speech_prob_max": max(no_speech_probs) if no_speech_probs else None,
    }

    return Verification(decision=decision, reasons=reasons, scores=scores)


Output:
Exit code: 0
Wall time: 0 seconds
Output:
from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from jiwer import wer

from maya_transcribe.io import read_jsonl
from maya_transcribe.textnorm import normalize_text

@dataclass(frozen=True)
class EvalRow:
    segment_id: str
    language: str | None
    decision: str | None
    ref: str
    hyp: str
    wer: float

def load_refs(refs_path: Path) -> dict[str, dict[str, Any]]:
    """
    Read a JSONL file of references.
    Expected keys (any one): `text`, `transcript`, `reference`.
    Required key: `segment_id` (or `id`).
    """
    refs: dict[str, dict[str, Any]] = {}
    for row in read_jsonl(refs_path):
        segment_id = str(row.get("segment_id") or row.get("id") or "").strip()
        if not segment_id:
            continue
        refs[segment_id] = row
    return refs

def _get_text(row: dict[str, Any], keys: tuple[str, ...]) -> str | None:
    for k in keys:
        v = row.get(k)
        if isinstance(v, str) and v.strip():
            return v
    return None

def evaluate(results_path: Path, refs_path: Path, out_path: Path | None = None) -> dict[str, Any]:
    refs = load_refs(refs_path)
    results = read_jsonl(results_path)

    rows: list[EvalRow] = []
    for r in results:
        segment_id = str(r.get("segment_id") or "").strip()
        if not segment_id or segment_id not in refs:
            continue

        ref_row = refs[segment_id]
        ref_text = _get_text(ref_row, ("text", "transcript", "reference"))
        if ref_text is None:
            continue

        # Use ROVER output as hypothesis by default.
        hyp_text = None
        rover = r.get("rover") or {}
        if isinstance(rover, dict):
            hyp_text = rover.get("text")
        if not isinstance(hyp_text, str):
            continue

        lang = None
        route = r.get("route") or {}
        if isinstance(route, dict):
            lang = route.get("language_hint")

        decision = None
        ver = r.get("verification") or {}
        if isinstance(ver, dict):
            decision = ver.get("decision")

        ref_norm = normalize_text(ref_text, lang=lang)
        hyp_norm = normalize_text(hyp_text, lang=lang)
        w = float(wer(ref_norm, hyp_norm))

        rows.append(
            EvalRow(
                segment_id=segment_id,
                language=lang,
                decision=str(decision) if decision is not None else None,
                ref=ref_text,
                hyp=hyp_text,
                wer=w,
            )
        )

    by_lang: dict[str, list[float]] = {}
    by_dec: dict[str, list[float]] = {}
    for row in rows:
        if row.language:
            by_lang.setdefault(row.language, []).append(row.wer)
        if row.decision:
            by_dec.setdefault(row.decision, []).append(row.wer)

    summary = {
        "pairs_evaluated": len(rows),
        "wer_mean": (sum(r.wer for r in rows) / len(rows)) if rows else None,
        "wer_by_language_mean": {k: sum(v) / len(v) for k, v in sorted(by_lang.items())},
        "wer_by_decision_mean": {k: sum(v) / len(v) for k, v in sorted(by_dec.items())},
    }

    if out_path is not None:
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")

    return summary


Summary: auto

## Skills
A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.
### Available skills
- skill-creator: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Codex's capabilities with specialized knowledge, workflows, or tool integrations. (file: /home/ubuntu/.codex/skills/.system/skill-creator/SKILL.md)
- skill-installer: Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). (file: /home/ubuntu/.codex/skills/.system/skill-installer/SKILL.md)
### How to use skills
- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
- How to use a skill (progressive disclosure):
  1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.
  2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
  3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
  4) If `assets/` or templates exist, reuse them instead of recreating from scratch.
- Coordination and sequencing:
  - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
  - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
- Context hygiene:
  - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.
  - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
  - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.


**Planning pipeline improvements**

I need to adjust the pipeline to address segment_id collisions, missing LID acceptance, and so on. I'll create a plan for these improvements and call it update_plan.

The steps include fixing Prompt.md for a non-hallucinatory, single output. I want to add an optional Gemini ASR backend with the correct audio part and appropriate environment variable setup. I also aim to improve the pipeline by implementing unique segment IDs and stricter LID requirements, plus enhancing evaluation metrics. It's four steps, and I'll implement accordingly.


Explanation: Apply the review notes: fix the LLM transcription prompt, add a correct Gemini audio transcription backend (as an optional hypothesis), harden pipeline IDs + gating, and extend eval metrics (WER+CER stratified + tail stats).


Output: Plan updated


Summary: auto


**Considering punctuation and code-switching**

I’m thinking about whether to allow minimal punctuation. For WER, punctuation is usually removed to avoid hallucination, so maybe I should disallow it. For English transcripts, we could remove punctuation for simplicity, but keep the original script intact with spaces between words. We should also address code-switching: my plan is to instruct to use the native script for the primary language while keeping English in Latin script. That's going to help maintain stability!


**Updating language instructions**

I’m considering that if the actual language differs, it might lead to inaccuracies. I think running LID for routing can help, and I need to remove any contradictory lines like "fix the grammar, predict what comes next." I'll also specify that we don't need to identify the primary spoken language since we already provide that information. Instead, we can just focus on transcription. So, I'll update the Prompt.md as needed to reflect these changes and patch it up!


Input:
*** Begin Patch
*** Update File: Prompt.md
@@
-This is a prompt for any LLM based transcription tasks. use this when using Gemma3n or voxtral or any other LLM based actions. Feel free to adjust this if it doesnt make sense. but keep it on the same tone or restrictive tone to avoid hallucinations or content creation rather transcription.
-
-
-`prompt`
-
-`Instruction: You are a strict, verbatim transcription engine for Indian languages
-Input: Audio file attached
-Primary audio language: <language>
-Task:
-Listen to the audio carefully
-Identify the primary spoken language as specified above
-Transcribe the speech exactly as spoken and produce  outputs as defined below
-Sometimes you won't be clear about complete transcription but you know parts of it. From that you can fix the grammar. That's how you can create the best transcription.
-You know the first word and the last word. You can create the best transcription. You know the middle word and the last word. You can create the best transcription.
-You need to think in terms of what you know and how you can go to the end with 100% accuracy with predicting what comes here, what would be it, what is the right fit and attaching it all together and doing the combinations
-
-STRICT GLOBAL RULES (apply to all outputs):
-Verbatim only Include all repetitions filler words stammers false starts hesitations and colloquial expressions exactly as spoken
-No normalization Do not correct grammar do not fix pronunciation do not standardize spellings and do not clean up dialect accent or mixed language usage
-No inference Do not add meaning structure or emphasis that is not clearly audible
-Script fidelity Follow the rules of each output strictly Do not translate paraphrase or summarize
-OUTPUT 1: Proper native transcription
-Write everything only in the native script of the primary language
-Do not use any punctuation marks at all
-Even if English words are spoken write them phonetically using the native script of the primary language
-Preserve repetitions fillers and stammers exactly
-
-OUTPUT FORMAT RULES:
-Clearly label the four outputs as
-Output 1: Proper native transcription
-Do not add explanations notes or any extra English text beyond the labels`
+This is a prompt for any **audio-native** model (Gemini, Voxtral, etc.) used as an extra ASR hypothesis.
+It is written to minimize hallucinations and keep outputs easy to validate.
+
+`prompt`
+
+`Instruction: You are a strict, verbatim transcription engine.
+Input: Audio file attached
+Language hint (primary): <language>
+
+Task:
+- Transcribe only what is clearly spoken in the audio.
+- Do NOT guess or “fill in” missing words. If a word or phrase is unclear, output <unk> for that span.
+- Preserve repetitions, filler words, stutters, and false starts exactly as spoken.
+- Do NOT correct grammar, spelling, or pronunciation.
+- Do NOT translate or summarize.
+- Do NOT add punctuation.
+
+Code-switching:
+- Use the native script for the primary language.
+- If English words are spoken, keep them in Latin script as spoken (do not transliterate).
+
+OUTPUT FORMAT (exact):
+Output 1: Transcript
+<single transcript line>
+
+Do not output anything else (no extra headings, notes, or additional outputs).`
*** End Patch


Output: {"output":"Success. Updated the following files:\nM Prompt.md\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Output: Plan updated


python3 - <<'PY'
try:
    from google import genai
    from google.genai import types
    print('google-genai installed')
except Exception as e:
    print('google-genai not installed:', type(e).__name__)
PY


Output:
Exit code: 0
Wall time: 1.4 seconds
Output:
google-genai installed


python3 - <<'PY'
import importlib.metadata
print('google-genai', importlib.metadata.version('google-genai'))
PY


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
google-genai 1.54.0


Input:
*** Begin Patch
*** Add File: maya_transcribe/gemini_asr.py
+from __future__ import annotations
+
+import os
+import time
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from maya_transcribe.asr import ASRResult
+from maya_transcribe.textnorm import normalize_text
+
+
+def _get_api_key() -> str:
+    # Support common env var names; `.env` uses GEMINI_KEY in this repo.
+    key = (
+        os.getenv("GEMINI_KEY")
+        or os.getenv("GEMINI_API_KEY")
+        or os.getenv("GOOGLE_API_KEY")
+        or os.getenv("GOOGLE_GENAI_API_KEY")
+    )
+    if not key:
+        raise RuntimeError("Missing Gemini API key (set GEMINI_KEY or GOOGLE_API_KEY)")
+    return key
+
+
+def _mime_type_for_path(path: Path) -> str:
+    ext = path.suffix.lower().lstrip(".")
+    if ext == "wav":
+        return "audio/wav"
+    if ext == "mp3":
+        return "audio/mpeg"
+    if ext == "flac":
+        return "audio/flac"
+    if ext in {"m4a", "mp4"}:
+        return "audio/mp4"
+    if ext in {"ogg", "opus"}:
+        return "audio/ogg"
+    return "application/octet-stream"
+
+
+def load_prompt_template(prompt_path: Path | None = None) -> str:
+    """
+    Load the prompt text from Prompt.md (preferred) or fall back to a built-in template.
+    """
+    if prompt_path and prompt_path.exists():
+        text = prompt_path.read_text(encoding="utf-8")
+        start = text.find("`Instruction:")
+        if start != -1:
+            start += 1  # drop the leading backtick
+            end = text.rfind("`")
+            if end != -1 and end > start:
+                return text[start:end].strip()
+            return text[start:].strip()
+        return text.strip()
+
+    return (
+        "Instruction: You are a strict, verbatim transcription engine.\n"
+        "Input: Audio file attached\n"
+        "Language hint (primary): <language>\n\n"
+        "Task:\n"
+        "- Transcribe only what is clearly spoken in the audio.\n"
+        "- Do NOT guess or “fill in” missing words. If unclear, output <unk>.\n"
+        "- Preserve repetitions, filler words, stutters, and false starts.\n"
+        "- Do NOT correct grammar, spelling, or pronunciation.\n"
+        "- Do NOT translate or summarize.\n"
+        "- Do NOT add punctuation.\n\n"
+        "Code-switching:\n"
+        "- Use the native script for the primary language.\n"
+        "- If English words are spoken, keep them in Latin script as spoken.\n\n"
+        "OUTPUT FORMAT (exact):\n"
+        "Output 1: Transcript\n"
+        "<single transcript line>\n"
+        "Do not output anything else.\n"
+    ).strip()
+
+
+def parse_strict_output(text: str) -> str:
+    """
+    Extract the transcript line from the strict output format.
+    """
+    text = (text or "").replace("\r\n", "\n").replace("\r", "\n").strip()
+    if not text:
+        raise ValueError("empty_response")
+
+    # Permit leading whitespace but require the label.
+    if not text.startswith("Output 1: Transcript"):
+        raise ValueError("missing_label")
+
+    lines = [ln.strip() for ln in text.split("\n")]
+    if not lines or lines[0] != "Output 1: Transcript":
+        raise ValueError("bad_label")
+
+    payload = [ln for ln in lines[1:] if ln]
+    if len(payload) != 1:
+        raise ValueError("unexpected_extra_lines")
+
+    transcript = payload[0].strip()
+    if not transcript:
+        raise ValueError("empty_transcript")
+
+    # Safety: reject if model added extra outputs.
+    lowered = text.lower()
+    if "output 2" in lowered or "output 3" in lowered or "output 4" in lowered:
+        raise ValueError("extra_outputs")
+
+    return transcript
+
+
+@dataclass(frozen=True)
+class GeminiConfig:
+    model: str = "gemini-2.0-flash"
+    temperature: float = 0.0
+    max_output_tokens: int = 512
+    response_mime_type: str = "text/plain"
+    retries: int = 3
+    initial_backoff_sec: float = 1.0
+
+
+class GeminiASR:
+    def __init__(self, config: GeminiConfig | None = None, prompt_template: str | None = None) -> None:
+        try:
+            from google import genai
+            from google.genai import types
+        except Exception as e:  # pragma: no cover
+            raise RuntimeError("GeminiASR requires `google-genai` (pip install google-genai)") from e
+
+        self._genai = genai
+        self._types = types
+        self.config = config or GeminiConfig()
+        self._client = genai.Client(api_key=_get_api_key())
+        self._prompt_template = prompt_template or load_prompt_template(Path("Prompt.md"))
+
+    @property
+    def name(self) -> str:
+        return f"gemini:{self.config.model}"
+
+    def transcribe(self, audio_path: Path, language: str | None = None) -> ASRResult:
+        language = language or "unknown"
+        prompt = self._prompt_template.replace("<language>", language)
+        audio_bytes = audio_path.read_bytes()
+        mime_type = _mime_type_for_path(audio_path)
+
+        cfg = self._types.GenerateContentConfig(
+            temperature=self.config.temperature,
+            max_output_tokens=self.config.max_output_tokens,
+            response_mime_type=self.config.response_mime_type,
+        )
+
+        last_err: Exception | None = None
+        for attempt in range(self.config.retries + 1):
+            try:
+                response = self._client.models.generate_content(
+                    model=self.config.model,
+                    contents=[
+                        self._types.Part.from_text(prompt),
+                        self._types.Part.from_bytes(data=audio_bytes, mime_type=mime_type),
+                    ],
+                    config=cfg,
+                )
+                raw_text = getattr(response, "text", None) or ""
+                transcript = parse_strict_output(raw_text)
+
+                # Normalization is performed later in the pipeline; keep the raw line.
+                return ASRResult(
+                    model=self.name,
+                    text=transcript,
+                    language=language,
+                    language_probability=None,
+                    segments=[],
+                    meta={
+                        "mime_type": mime_type,
+                        "temperature": self.config.temperature,
+                        "max_output_tokens": self.config.max_output_tokens,
+                        "response_mime_type": self.config.response_mime_type,
+                        "prompt_language": language,
+                    },
+                )
+            except Exception as e:
+                last_err = e
+                if attempt >= self.config.retries:
+                    break
+                backoff = self.config.initial_backoff_sec * (2**attempt)
+                time.sleep(backoff)
+
+        raise RuntimeError(f"gemini_transcribe_failed: {type(last_err).__name__}: {last_err}")
+
+
+def safe_normalized_transcript(text: str, lang: str | None) -> str:
+    """
+    Convenience helper: strict normalize for alignment/gating.
+    """
+    return normalize_text(text, lang=lang)
+
*** End Patch


Output: {"output":"Success. Updated the following files:\nA maya_transcribe/gemini_asr.py\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: pyproject.toml
@@
 [project.optional-dependencies]
 supabase = ["supabase>=2.6.0"]
 db = ["psycopg2-binary>=2.9.9"]
+gemini = ["google-genai>=1.54.0"]
 asr = [
   "faster-whisper>=1.0.3",
   "soundfile>=0.12.1",
 ]
@@
 full = [
   "supabase>=2.6.0",
   "psycopg2-binary>=2.9.9",
+  "google-genai>=1.54.0",
   "faster-whisper>=1.0.3",
   "soundfile>=0.12.1",
   "torch>=2.2.0",
   "torchaudio>=2.2.0",
*** End Patch


Output: {"output":"Success. Updated the following files:\nM pyproject.toml\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


**Updating project components**

I’m considering updating the README to maybe mention Gemini. It’s not necessary, but I think it could be good to do it later. I want to integrate a new command, `gemini-transcribe`, or include it in the `run` pipeline. The .env key should be `GEMINI_KEY`, and I'll ensure we add determinism settings like temperature, max tokens, and response type. I realize we also need to validate punctuation, which we hadn’t checked; I’m thinking about implementing a function to address that!


**Considering punctuation rules**

I’m thinking about whether we should allow hyphens in English words since they fall under the punctuation category 'Pd'. The prompt specifies no punctuation, but sometimes hyphens, like in "co-operate," might be necessary. They want strictness, yet I wonder if we could compromise by only disallowing certain punctuation. They recommended rejecting any forbidden punctuation. Maybe we can allow certain cases, like apostrophes or dashes, as they might not cause issues. I believe normalizing later to remove punctuation can be a solution, so it’s not critical for now.


**Implementing punctuation checks**

If our output format specifies no punctuation, I can enforce this by creating a function, `contains_punctuation`, that checks for Unicode categories starting with 'P' and raises a ValueError if found. This could lead to issues, like rejecting underscores in `<inaudible>`, though we only use `<unk>`. I realize English contractions, like "don't," have apostrophes, which we may lose if we reject them. I suppose Gemini might output "dont" instead. I should also mention parameters like "cap maxOutputTokens" and "responseMimeType text/plain." Logging full raw responses might be useful for later audits.


**Storing raw output for auditing**

I'm considering whether to store the raw output for auditing purposes, even though it might sometimes contain hallucinated content. Including `raw_output` seems good for an audit trail, though it could bloat things. I could truncate it while keeping full response text under meta, as they've recommended. We might include `meta["raw_output"]` but need to be careful with newlines. The pipeline already retains `asr` results with text, so including metadata is fine. I'll also consider adding "safety settings" or "usage metadata" for extra clarity. Now, I'll update `gemini_asr.py`.


Input:
*** Begin Patch
*** Update File: maya_transcribe/gemini_asr.py
@@
 from __future__ import annotations

 import os
 import time
+import unicodedata
 from dataclasses import dataclass
 from pathlib import Path
 from typing import Any

 from maya_transcribe.asr import ASRResult
@@
 def parse_strict_output(text: str) -> str:
@@
     transcript = payload[0].strip()
     if not transcript:
         raise ValueError("empty_transcript")

     # Safety: reject if model added extra outputs.
     lowered = text.lower()
     if "output 2" in lowered or "output 3" in lowered or "output 4" in lowered:
         raise ValueError("extra_outputs")

+    # Prompt forbids punctuation; treat punctuation as invalid output.
+    for ch in transcript:
+        if unicodedata.category(ch).startswith("P"):
+            raise ValueError("punctuation_forbidden")
+
     return transcript
@@
     def transcribe(self, audio_path: Path, language: str | None = None) -> ASRResult:
@@
         last_err: Exception | None = None
         for attempt in range(self.config.retries + 1):
             try:
                 response = self._client.models.generate_content(
@@
                     config=cfg,
                 )
-                raw_text = getattr(response, "text", None) or ""
-                transcript = parse_strict_output(raw_text)
+                raw_text = getattr(response, "text", None) or ""
+                transcript = parse_strict_output(raw_text)
@@
                     segments=[],
                     meta={
                         "mime_type": mime_type,
                         "temperature": self.config.temperature,
                         "max_output_tokens": self.config.max_output_tokens,
                         "response_mime_type": self.config.response_mime_type,
                         "prompt_language": language,
+                        "raw_output": raw_text,
                     },
                 )
*** End Patch


Output: {"output":"Success. Updated the following files:\nM maya_transcribe/gemini_asr.py\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


**Planning transcription integration**

I think I've completed step 2, but we need to integrate the backend. It might be useful to add a CLI command for testing Gemini transcription on a file. I’m thinking of adding the `maya-transcribe gemini` command, which would let us transcribe one file and include an optional language hint. Plus, we’ll parse and output the transcript for line-by-line reviews. I’ll patch `cli.py` to include this command and manage imports appropriately.


Input:
*** Begin Patch
*** Update File: maya_transcribe/cli.py
@@
 @app.command()
 def asr(
@@
     typer.echo(res.text)

+
+@app.command()
+def gemini(
+    audio: Path = typer.Option(..., exists=True, dir_okay=False, help="Audio file path."),
+    language: str | None = typer.Option(None, help="Language hint (e.g., hin/tam/eng)."),
+    model: str = typer.Option("gemini-2.0-flash", help="Gemini model name."),
+    prompt_file: Path | None = typer.Option(Path("Prompt.md"), help="Prompt file (defaults to Prompt.md)."),
+    max_output_tokens: int = typer.Option(512, help="Max output tokens."),
+) -> None:
+    """
+    Transcribe a single file with Gemini (audio-native LLM). Output is parsed/validated strictly.
+    """
+    from maya_transcribe.gemini_asr import GeminiASR, GeminiConfig, load_prompt_template
+
+    prompt = load_prompt_template(prompt_file)
+    backend = GeminiASR(config=GeminiConfig(model=model, max_output_tokens=max_output_tokens), prompt_template=prompt)
+    res = backend.transcribe(audio_path=audio, language=language)
+    typer.echo(res.text)
+

 @app.command()
 def run(
@@
     typer.echo(f"Wrote: {results_path}")
     typer.echo(f"Wrote: {out / 'summary.json'}")
*** End Patch


Output: {"output":"Success. Updated the following files:\nM maya_transcribe/cli.py\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


**Updating project steps**

I think I can say that step 2 might be complete, as it involved adding the Gemini ASR backend, which included the CLI. However, we haven’t integrated it yet, so I'll treat that as part of step 3. Time to update the plan: I’ll mark step 2 as completed and note that step 3 is currently in progress. This way, everything stays organized and clear for the next stages!


Output: Plan updated


**Evaluating ASR Integration**

I’m looking at integrating Gemini into our pipeline. If we provide a `gemini_model`, we’ll call GeminiASR, which adds diversity but remains high-level. I might also consider incorporating at least one non-Whisper model like `facebook/mms-1b-all` or IndicConformer, although they can be heavy. The idea is to let users plug in additional ASR backends easily. Using transformers like Wav2Vec2ForCTC might simplify things, though it involves language-specific adapters, which could get complicated. So, I might skip that for now.


**Building Robust CTC Scoring**

The user wants a robust version with CTC scoring, which may require a multi-language CTC model that can handle transcripts in various languages. For scoring, we just need to compute the likelihood of a transcript rather than the output itself. However, a CTC model needs language-specific vocabulary and mapping. I’m considering implementing a generic CTC scorer using Whisper tokens, but that doesn't exist yet. Alternatively, I could use `faster-whisper` metadata for verification. Ultimately, I could implement `ctc_score` using `Wav2Vec2ForCTC`, focusing on English and allowing users to configure language mappings.


**Implementing Audio-Grounded Checks**

The focus here is on addressing the "missing critical audio-grounded transcript fit check" to avoid situations where transcripts sound fluent but are incorrect. We can implement this check for supported languages, especially since we're targeting Indic languages. To support this, we need an Indic aligner, and I think AI4Bharat's IndicWav2Vec2 models might work, but I’m not sure of the names. Users could specify a model ID along with language flags, and we aim to compute CTC negative log-likelihood using `torch.nn.functional.ctc_loss`, mapping transcripts to token IDs with their tokenizer.


**Addressing Non-Space Separated Languages**

I need to consider how to handle languages with non-space-separated scripts, as tokenizers might operate on individual characters. I’m thinking of passing a normalized transcript, potentially without punctuation, but that might complicate things. There’s also the option of implementing forced alignment using MFA, though that involves external tools and models. Since the request is for a code review, it seems like we should implement improvements gradually. I plan to include unique IDs, strict LID requirements, and gating mechanisms while keeping some features optional for now.


**Planning CTC Scoring Implementation**

The user previously asked for a robust version with full performance mode, suggesting we should implement a CTC check soon. We can use the `facebook/mms-1b-all` model for CTC scoring, which supports multiple languages. I'm considering adding options for users to specify model configurations and language codes. To implement the scoring, we'll calculate CTC loss using Whisper's log probabilities or `torch.nn.functional.ctc_loss`. There will be mapping from transcripts to tokens, and I'll ensure there's functionality to handle multi-language scenarios seamlessly, possibly with caching for aligner models. We can implement everything and allow scoring verification!


**Implementing CTC Scoring and Unique IDs**

To ensure robustness, I'll default `ctc_enabled` to False, but recommend enabling it for reliable performance. If a `ctc_model_map` is provided, the pipeline will compute CTC scores only for those cases. The verification gate will then use these scores with relative thresholds. I plan to implement unique segment IDs by hashing relative paths, avoiding filename collisions. I'll ensure that outputs remain linked to their original references for evaluation. This should help avoid issues while maintaining a clear structure, especially with potentially duplicate stems across videos.


**Implementing Unique IDs and Segment Handling**

I’ll maintain `segment_id` as the source stem for compatibility, but I plan to manage potential collisions by introducing a unique hashed relative path as `segment_uid`. This way, I can include both `segment_name` and `segment_relpath` for clarity. To ensure uniqueness, I’ll derive a hash from the relative path. For output file names, I’ll keep characters manageable while replacing separators. Also, I’ll add options for trimming audio silence in the processing pipeline. This should help streamline segment management and enhance our system's stability.


**Integrating Quality Control and Dual Processing**

I’m considering how to implement a compression ratio threshold for detecting potentially repetitive or hallucinated transcripts, aiming for a default of around 2.4. I believe I can integrate Gemini into the pipeline for advanced processing after language identification. Additionally, I plan to create both trimmed and untrimmed audio files, allowing for flexibility when running audio processing tasks. If the trimmed version fails verification, I might rerun the process using the untrimmed version for a better outcome. This should improve robustness while maintaining overall efficiency.


**Enhancing Verification and Pipeline Options**

I'm adding options to the pipeline command, such as `--trim-silence`, `--silence-threshold-db`, and options for integrating Gemini. I want to set thresholds for metrics like `min_avg_logprob_mean` and `max_compression_ratio_mean` in the verification process. Additionally, I’ll implement a method to ensure unique segment IDs and refine how we handle decisions based on language identification results. This includes making adjustments to the run_pipeline function to evaluate uncertainty and potentially utilize Gemini's capabilities in specific scenarios.


**Refining Hallucination Flags and Parameters**

I'm thinking about incorporating hallucination flags instead of acceptance requirements. If any model output shows a high compression ratio or low average log probability, we should mark it as human or at least note the reason without overriding strong checks. I'll also implement a deterministic whisper summary for gating with a primary ASR key. Additionally, I want to establish thresholds for LID requirements and keep the option to run ASR on both trimmed and untrimmed audio. It's all about making the pipeline more robust!


Output:
Exit code: 0
Wall time: 0 seconds
Output:
from __future__ import annotations

import json
from pathlib import Path

import typer

from maya_transcribe.audio import compute_qc, ffprobe, normalize_audio
from maya_transcribe.config import Settings
from maya_transcribe.dataset import find_segments_dir
from maya_transcribe.io import write_jsonl
from maya_transcribe.r2 import R2Client
from maya_transcribe.tar import safe_extract_tar

app = typer.Typer(add_completion=False, no_args_is_help=True)

@app.command()
def download(
    video_id: str = typer.Option(..., help="YouTube video id (used to locate the tar in R2)."),
    out: Path = typer.Option(Path("data"), help="Output directory."),
    r2_key: str | None = typer.Option(None, help="Exact R2 object key (skips search)."),
    bucket: str | None = typer.Option(None, help="R2 bucket override (defaults to env R2_BUCKET)."),
    prefix: str | None = typer.Option(None, help="Optional R2 prefix override."),
    use_db: bool = typer.Option(False, help="Fetch `r2_tar_key` from Supabase Postgres (DATABASE_URL)."),
    dry_run: bool = typer.Option(False, help="Only show matched key(s); do not download."),
) -> None:
    """
    Download the cleaned/segmented tar for a video from R2 and extract it.
    """
    settings = Settings.load()
    r2 = R2Client.from_settings(settings)
    if bucket:
        r2 = R2Client(
            endpoint_url=r2.endpoint_url,
            access_key_id=r2.access_key_id,
            secret_access_key=r2.secret_access_key,
            bucket=bucket,
        )

    out.mkdir(parents=True, exist_ok=True)
    r2_prefix = prefix if prefix is not None else settings.r2_prefix

    matches: list[str]
    if use_db and not r2_key:
        from maya_transcribe.db import fetch_video_meta

        meta = fetch_video_meta(video_id)
        if meta and meta.r2_tar_key:
            r2_key = meta.r2_tar_key

    if r2_key:
        matches = [r2_key]
    else:
        best = r2.find_video_tar_key(video_id=video_id, prefix=r2_prefix)
        if not best:
            typer.echo("No matching objects found in R2.")
            raise typer.Exit(code=2)
        matches = [best]

    if dry_run:
        for key in matches:
            typer.echo(key)
        return

    key = matches[0]
    tar_path = r2.download_to(key=key, out_dir=out / video_id)
    extract_dir = out / video_id
    safe_extract_tar(tar_path, extract_dir=extract_dir)
    segments_dir = find_segments_dir(extract_dir)
    typer.echo(f"Downloaded: {tar_path}")
    typer.echo(f"Extracted to: {extract_dir}")
    if segments_dir:
        typer.echo(f"Segments dir: {segments_dir}")

@app.command()
def preprocess(
    segments_dir: Path = typer.Option(..., exists=True, file_okay=False, help="Directory containing audio segments."),
    out: Path = typer.Option(Path("outputs/preprocessed"), help="Output directory for normalized audio + QC."),
    sample_rate: int = typer.Option(16000, help="Target sample rate."),
    trim_silence: bool = typer.Option(True, help="Trim leading/trailing silence (ffmpeg silenceremove)."),
    silence_threshold_db: float = typer.Option(-40.0, help="Silence threshold in dB for trimming."),
    min_silence_sec: float = typer.Option(0.15, help="Min silence duration for trimming."),
) -> None:
    """
    Normalize audio to 16kHz mono WAV, optionally trim silence, and write QC metrics.
    """
    out.mkdir(parents=True, exist_ok=True)

    audio_files: list[Path] = []
    for ext in ("*.wav", "*.mp3", "*.m4a", "*.flac", "*.ogg", "*.opus"):
        audio_files.extend(sorted(segments_dir.rglob(ext)))
    audio_files = sorted(set(audio_files))

    if not audio_files:
        typer.echo(f"No audio files found under: {segments_dir}")
        raise typer.Exit(code=2)

    rows = []
    norm_dir = out / "audio"
    for src in audio_files:
        segment_id = src.stem
        dst = norm_dir / f"{segment_id}.wav"
        normalize_audio(
            src,
            dst,
            sample_rate=sample_rate,
            mono=True,
            trim_silence=trim_silence,
            silence_threshold_db=silence_threshold_db,
            min_silence_sec=min_silence_sec,
        )
        info = ffprobe(dst)
        qc = compute_qc(dst, sample_rate=sample_rate)
        rows.append(
            {
                "segment_id": segment_id,
                "src_path": str(src),
                "wav_path": str(dst),
                "ffprobe": {
                    "duration_sec": info.duration_sec,
                    "sample_rate": info.sample_rate,
                    "channels": info.channels,
                },
                "qc": {
                    "duration_sec": qc.duration_sec,
                    "rms_dbfs": qc.rms_dbfs,
                    "peak_dbfs": qc.peak_dbfs,
                    "clipping_fraction": qc.clipping_fraction,
                },
            }
        )

    write_jsonl(out / "qc.jsonl", rows)
    typer.echo(f"Wrote: {out / 'qc.jsonl'}")
    typer.echo(f"Normalized audio: {norm_dir}")

@app.command()
def lid(
    audio: Path = typer.Option(..., exists=True, dir_okay=False, help="Audio file path."),
    model_id: str = typer.Option("facebook/mms-lid-256", help="HuggingFace model id."),
    top_k: int = typer.Option(5, help="Return top-K predictions."),
) -> None:
    """
    Spoken language identification (MMS-LID).
    """
    from maya_transcribe.lid import MMSLID

    model = MMSLID(model_id=model_id)
    res = model.predict(audio_path=audio, top_k=top_k)
    for p in res.predictions:
        typer.echo(f"{p.code}\t{p.prob:.4f}")

@app.command()
def asr(
    audio: Path = typer.Option(..., exists=True, dir_okay=False, help="Audio file path."),
    language: str | None = typer.Option(None, help="Language hint (ISO-639-1 or ISO-639-3)."),
    model: str = typer.Option("large-v3", help="Whisper model size or path for faster-whisper."),
    beam_size: int = typer.Option(5, help="Beam size."),
    best_of: int = typer.Option(5, help="Best-of (sampling candidates)."),
    temperature: float = typer.Option(0.0, help="Temperature."),
) -> None:
    """
    Transcribe a single file with faster-whisper.
    """
    from maya_transcribe.asr import FasterWhisperASR

    asr_backend = FasterWhisperASR(model_size_or_path=model)
    res = asr_backend.transcribe(
        audio_path=audio, language=language, beam_size=beam_size, best_of=best_of, temperature=temperature
    )
    typer.echo(res.text)

@app.command()
def gemini(
    audio: Path = typer.Option(..., exists=True, dir_okay=False, help="Audio file path."),
    language: str | None = typer.Option(None, help="Language hint (e.g., hin/tam/eng)."),
    model: str = typer.Option("gemini-2.0-flash", help="Gemini model name."),
    prompt_file: Path | None = typer.Option(Path("Prompt.md"), help="Prompt file (defaults to Prompt.md)."),
    max_output_tokens: int = typer.Option(512, help="Max output tokens."),
) -> None:
    """
    Transcribe a single file with Gemini (audio-native LLM). Output is parsed/validated strictly.
    """
    from maya_transcribe.gemini_asr import GeminiASR, GeminiConfig, load_prompt_template

    prompt = load_prompt_template(prompt_file)
    backend = GeminiASR(config=GeminiConfig(model=model, max_output_tokens=max_output_tokens), prompt_template=prompt)
    res = backend.transcribe(audio_path=audio, language=language)
    typer.echo(res.text)

@app.command()
def run(
    segments_dir: Path = typer.Option(..., exists=True, file_okay=False, help="Directory containing audio segments."),
    out: Path = typer.Option(Path("outputs/run"), help="Output directory."),
    whisper_model: str = typer.Option("large-v3", help="Whisper model size/path for faster-whisper."),
    lid_model_id: str = typer.Option("facebook/mms-lid-256", help="HuggingFace MMS-LID model id."),
    transformers_whisper_model: list[str] = typer.Option(
        [], help="Extra whisper-family HF models to run via transformers (repeatable)."
    ),
    limit: int | None = typer.Option(None, help="Process only the first N segments."),
) -> None:
    """
    End-to-end pipeline: preprocess -> LID -> ASR ensemble -> ROVER -> verify.
    """
    from maya_transcribe.pipeline import run_pipeline

    results_path = run_pipeline(
        segments_dir=segments_dir,
        out_dir=out,
        lid_model_id=lid_model_id,
        whisper_model=whisper_model,
        extra_transformers_whisper_models=transformers_whisper_model,
        limit=limit,
    )
    typer.echo(f"Wrote: {results_path}")
    typer.echo(f"Wrote: {out / 'summary.json'}")

@app.command()
def eval(
    results: Path = typer.Option(..., exists=True, dir_okay=False, help="Pipeline results JSONL (outputs/run/results.jsonl)."),
    refs: Path = typer.Option(..., exists=True, dir_okay=False, help="Reference transcripts JSONL."),
    out: Path | None = typer.Option(None, help="Write summary JSON to this path."),
) -> None:
    """
    Compute WER for `rover.text` against a reference JSONL keyed by `segment_id`.
    """
    from maya_transcribe.eval import evaluate

    summary = evaluate(results_path=results, refs_path=refs, out_path=out)
    typer.echo(json.dumps(summary, ensure_ascii=False, indent=2))

if __name__ == "__main__":
    app()


Summary: auto


**Implementing dotenv and handling segment IDs**

I need to start by adding dotenv loading at the import top, importing from dotenv and calling load_dotenv. Then I’ll update the preprocess command to manage segment ID collisions. It could be easier to create a helper function in a module like `maya_transcribe/ids.py`. While it might seem simpler to implement directly in preprocess and pipeline, I want to avoid code duplication. So, I’ll write `segment_identity` in `ids.py`, which will return a dictionary with unique identifiers. Let’s get that added!


**Implementing UID and Segment ID functions**

I’m starting the implementation by creating a function to make unique IDs using a SHA-1 hash. The `make_segment_id` function will generate a segment ID based on the source file's name and its path relative to a root. I’ll handle the case where the file may not have a clear relative path by defaulting to a string representation.

For the preprocess command, I’ll ensure to use the `seen` dictionary to keep track of segments. I also plan to include the UID in the run pipeline. Let’s get this all implemented!


Input:
*** Begin Patch
*** Add File: maya_transcribe/ids.py
+from __future__ import annotations
+
+import hashlib
+from dataclasses import dataclass
+from pathlib import Path
+
+
+@dataclass(frozen=True)
+class SegmentIdentity:
+    segment_id: str
+    base_id: str
+    relpath: str
+    uid: str
+
+
+def segment_identity(src: Path, root: Path, seen: dict[str, int] | None = None) -> SegmentIdentity:
+    """
+    Produce a stable segment identity and prevent collisions when stems repeat.
+    - `base_id`: `src.stem`
+    - `relpath`: path relative to `root` (or absolute as fallback)
+    - `uid`: sha1(relpath) prefix
+    - `segment_id`: base_id unless it collides; then base_id-uid
+    """
+    base_id = src.stem
+    try:
+        relpath = src.relative_to(root).as_posix()
+    except Exception:
+        relpath = str(src)
+
+    uid = hashlib.sha1(relpath.encode("utf-8")).hexdigest()[:10]
+    segment_id = base_id
+
+    if seen is None:
+        segment_id = f"{base_id}-{uid}"
+    else:
+        n = int(seen.get(base_id, 0))
+        seen[base_id] = n + 1
+        if n > 0:
+            segment_id = f"{base_id}-{uid}"
+
+    return SegmentIdentity(segment_id=segment_id, base_id=base_id, relpath=relpath, uid=uid)
+
*** End Patch


Output: {"output":"Success. Updated the following files:\nA maya_transcribe/ids.py\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: maya_transcribe/cli.py
@@
 from pathlib import Path

 import typer
+from dotenv import load_dotenv

 from maya_transcribe.audio import compute_qc, ffprobe, normalize_audio
 from maya_transcribe.config import Settings
 from maya_transcribe.dataset import find_segments_dir
+from maya_transcribe.ids import segment_identity
 from maya_transcribe.io import write_jsonl
 from maya_transcribe.r2 import R2Client
 from maya_transcribe.tar import safe_extract_tar

+load_dotenv(dotenv_path=Path(".env"), override=False)
+
 app = typer.Typer(add_completion=False, no_args_is_help=True)
@@
 def preprocess(
@@
     if not audio_files:
         typer.echo(f"No audio files found under: {segments_dir}")
         raise typer.Exit(code=2)

     rows = []
     norm_dir = out / "audio"
+    seen: dict[str, int] = {}
     for src in audio_files:
-        segment_id = src.stem
+        ident = segment_identity(src, root=segments_dir, seen=seen)
+        segment_id = ident.segment_id
         dst = norm_dir / f"{segment_id}.wav"
         normalize_audio(
             src,
             dst,
             sample_rate=sample_rate,
@@
         qc = compute_qc(dst, sample_rate=sample_rate)
         rows.append(
             {
                 "segment_id": segment_id,
+                "base_id": ident.base_id,
+                "relpath": ident.relpath,
+                "uid": ident.uid,
                 "src_path": str(src),
                 "wav_path": str(dst),
                 "ffprobe": {
                     "duration_sec": info.duration_sec,
*** End Patch


Output: {"output":"Success. Updated the following files:\nM maya_transcribe/cli.py\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


I'm focusing on applying unique segment identity and modifying some requirements like require_lid_backend. I need to trim options and adjust gemini options as well. The next step is to patch the pipeline.py file. I think it'll be necessary to open the remainder of the pipeline file to make any modifications needed. It's essential to ensure everything aligns properly in the code for it to function as intended.


sed -n '200,520p' maya_transcribe/pipeline.py


Output:
Exit code: 0
Wall time: 0 seconds
Output:
                    "verification": {
                        "decision": verification.decision,
                        "reasons": verification.reasons,
                        "scores": verification.scores,
                    },
                    "timing": {
                        "preprocess_sec": t_pre1 - t_pre0,
                        "lid_sec": t_lid1 - t_lid0,
                        "asr_sec": t_asr1 - t_asr0,
                        "normalize_sec": t_norm1 - t_norm0,
                        "rover_sec": t_rover1 - t_rover0,
                        "verify_sec": t_ver1 - t_ver0,
                        "total_sec": time.perf_counter() - t0,
                    },
                }

                f.write(json.dumps(record, ensure_ascii=False) + "\n")
                decision = verification.decision
                counts[decision] = counts.get(decision, 0) + 1
                timings_total.append(record["timing"]["total_sec"])

            except Exception as e:  # pragma: no cover
                counts["error"] += 1
                f.write(
                    json.dumps(
                        {"segment_id": segment_id, "src_path": str(src), "error": str(e)}, ensure_ascii=False
                    )
                    + "\n"
                )

    summary = {
        "segments_total": len(audio_files),
        "counts": counts,
        "avg_total_sec": (sum(timings_total) / len(timings_total)) if timings_total else None,
        "p50_total_sec": _percentile(timings_total, 0.50),
        "p90_total_sec": _percentile(timings_total, 0.90),
    }
    summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    return results_path

def _percentile(xs: list[float], q: float) -> float | None:
    if not xs:
        return None
    xs_sorted = sorted(xs)
    idx = int(round((len(xs_sorted) - 1) * q))
    idx = max(0, min(len(xs_sorted) - 1, idx))
    return float(xs_sorted[idx])


Input:
*** Begin Patch
*** Update File: maya_transcribe/verify.py
@@
 def verify_segment(
     *,
     qc: dict[str, Any],
     lid: LIDResult | None,
     rover: dict[str, Any],
     asr_summaries: dict[str, Any],
+    primary_asr_key: str | None = None,
     thresholds: dict[str, Any] | None = None,
 ) -> Verification:
@@
     max_disagreement = float(thresholds.get("max_rover_disagreement_rate", 0.03))
     lid_min_prob = float(thresholds.get("lid_min_prob", 0.90))
     lid_min_margin = float(thresholds.get("lid_min_margin", 0.20))
+    require_lid = bool(thresholds.get("require_lid", True))
+
+    # Whisper-style failure heuristics (see OpenAI whisper fallback logic).
+    min_avg_logprob = float(thresholds.get("min_avg_logprob_mean", -1.0))
+    max_compression_ratio = float(thresholds.get("max_compression_ratio_mean", 2.4))

     reasons: list[str] = []
     decision = "accept"
@@
     lidc = lid_confidence(lid)
     if lidc["available"]:
         lidc["confident"] = bool(lidc["top1_prob"] >= lid_min_prob and lidc["margin"] >= lid_min_margin)
         if not lidc["confident"]:
             decision = "human" if decision != "reject" else decision
             reasons.append("lid_uncertain")
     else:
         reasons.append("lid_missing")
+        if require_lid:
+            decision = "human" if decision != "reject" else decision

     disagreement_rate = float(rover.get("disagreement_rate") or 0.0)
     if disagreement_rate > max_disagreement:
         decision = "human" if decision != "reject" else decision
         reasons.append("asr_disagreement")

     # Whisper-style safety: flag if any decoder claims "no speech" with high probability.
     no_speech_probs = []
     for summary in asr_summaries.values():
         p = summary.get("no_speech_prob_mean")
         if p is None:
             continue
         no_speech_probs.append(float(p))
     if no_speech_probs and max(no_speech_probs) > 0.8:
         decision = "reject"
         reasons.append("no_speech")

+    # Whisper-style hallucination heuristics: apply to the primary ASR (or rover skeleton fallback).
+    asr_key_for_heuristics = primary_asr_key or str(rover.get("skeleton_key") or "")
+    heur = asr_summaries.get(asr_key_for_heuristics, {}) if asr_key_for_heuristics else {}
+    avg_lp = heur.get("avg_logprob_mean")
+    comp = heur.get("compression_ratio_mean")
+    if avg_lp is not None and float(avg_lp) < min_avg_logprob:
+        decision = "human" if decision != "reject" else decision
+        reasons.append("low_logprob")
+    if comp is not None and float(comp) > max_compression_ratio:
+        decision = "human" if decision != "reject" else decision
+        reasons.append("high_compression_ratio")
+
     scores = {
         "duration_sec": duration,
         "clipping_fraction": clipping,
         "rover_disagreement_rate": disagreement_rate,
         "lid": lidc,
         "no_speech_prob_max": max(no_speech_probs) if no_speech_probs else None,
+        "heuristics_asr_key": asr_key_for_heuristics or None,
+        "avg_logprob_mean": avg_lp,
+        "compression_ratio_mean": comp,
     }

     return Verification(decision=decision, reasons=reasons, scores=scores)
*** End Patch


Output: {"output":"Success. Updated the following files:\nM maya_transcribe/verify.py\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: maya_transcribe/pipeline.py
@@
 from __future__ import annotations

 import json
 import time
 from dataclasses import asdict
 from pathlib import Path
 from typing import Any

 from rich.progress import track

 from maya_transcribe.asr import ASRResult, FasterWhisperASR, TransformersWhisperASR
 from maya_transcribe.audio import compute_qc, ffprobe, normalize_audio
+from maya_transcribe.ids import segment_identity
 from maya_transcribe.lid import LIDResult, MMSLID
 from maya_transcribe.rover import rover_combine
 from maya_transcribe.textnorm import normalize_text
 from maya_transcribe.verify import verify_segment
@@
 def run_pipeline(
     *,
     segments_dir: Path,
     out_dir: Path,
     lid_model_id: str = "facebook/mms-lid-256",
     whisper_model: str = "large-v3",
     extra_transformers_whisper_models: list[str] | None = None,
+    gemini_model: str | None = None,
+    gemini_prompt_path: Path | None = None,
+    gemini_max_output_tokens: int = 512,
+    gemini_hard_only: bool = True,
+    require_lid_backend: bool = True,
+    trim_silence: bool = True,
+    silence_threshold_db: float = -40.0,
+    min_silence_sec: float = 0.15,
     limit: int | None = None,
     thresholds: dict[str, Any] | None = None,
 ) -> Path:
@@
     if limit is not None:
         audio_files = audio_files[: max(0, int(limit))]

     # Instantiate models once.
     lid_backend: MMSLID | None
     try:
         lid_backend = MMSLID(model_id=lid_model_id)
-    except Exception:
-        lid_backend = None
+    except Exception as e:
+        if require_lid_backend:
+            raise RuntimeError(
+                "Failed to initialize MMS-LID. Install torch/transformers deps (pip install -e '.[torch]') "
+                "or run with require_lid_backend=False."
+            ) from e
+        lid_backend = None

     whisper_backend = FasterWhisperASR(model_size_or_path=whisper_model)

     extra_transformers_whisper_models = extra_transformers_whisper_models or []
     transformers_backends: list[TransformersWhisperASR] = []
     for model_id in extra_transformers_whisper_models:
         transformers_backends.append(TransformersWhisperASR(model_id=model_id))

+    gemini_backend = None
+    if gemini_model:
+        try:
+            from maya_transcribe.gemini_asr import GeminiASR, GeminiConfig, load_prompt_template
+
+            prompt = load_prompt_template(gemini_prompt_path or Path("Prompt.md"))
+            gemini_backend = GeminiASR(
+                config=GeminiConfig(model=gemini_model, max_output_tokens=gemini_max_output_tokens),
+                prompt_template=prompt,
+            )
+        except Exception:
+            gemini_backend = None
+
     results_path = out_dir / "results.jsonl"
     summary_path = out_dir / "summary.json"

     counts = {"accept": 0, "human": 0, "reject": 0, "error": 0}
     timings_total: list[float] = []

+    seen: dict[str, int] = {}
     with results_path.open("w", encoding="utf-8") as f:
         for src in track(audio_files, description="Transcribing"):
-            segment_id = src.stem
+            ident = segment_identity(src, root=segments_dir, seen=seen)
+            segment_id = ident.segment_id
             t0 = time.perf_counter()

             try:
                 # 1) Preprocess
                 t_pre0 = time.perf_counter()
                 wav_path = out_dir / "audio" / f"{segment_id}.wav"
-                normalize_audio(src, wav_path, trim_silence=True)
+                normalize_audio(
+                    src,
+                    wav_path,
+                    trim_silence=trim_silence,
+                    silence_threshold_db=silence_threshold_db,
+                    min_silence_sec=min_silence_sec,
+                )
                 info = ffprobe(wav_path)
                 qc = compute_qc(wav_path)
                 t_pre1 = time.perf_counter()

                 # 2) LID
                 t_lid0 = time.perf_counter()
                 lid: LIDResult | None = None
                 if lid_backend is not None:
                     lid = lid_backend.predict(wav_path, top_k=5)
                 t_lid1 = time.perf_counter()
@@
                 # 3) ASR ensemble
                 t_asr0 = time.perf_counter()
                 asr_results: dict[str, ASRResult] = {}

                 # Whisper: deterministic + a small sampling diversity pass.
                 det = whisper_backend.transcribe(wav_path, language=route_lang, beam_size=5, best_of=5, temperature=0.0)
-                asr_results[f"{det.model}|det"] = det
+                primary_asr_key = f"{det.model}|det"
+                asr_results[primary_asr_key] = det
                 samp = whisper_backend.transcribe(
                     wav_path, language=route_lang, beam_size=5, best_of=10, temperature=0.2
                 )
                 asr_results[f"{samp.model}|samp_t0.2"] = samp

                 for tf_backend in transformers_backends:
                     res = tf_backend.transcribe(wav_path, language=route_lang, num_beams=5, max_new_tokens=256)
                     asr_results[res.model] = res

+                # Gemini: optional extra hypothesis (prefer running only on "hard" segments).
+                if gemini_backend is not None:
+                    run_gemini = True
+                    if gemini_hard_only:
+                        det_sum = _asr_summary(det)
+                        avg_lp = det_sum.get("avg_logprob_mean")
+                        comp = det_sum.get("compression_ratio_mean")
+                        no_speech = det_sum.get("no_speech_prob_mean")
+                        run_gemini = bool(
+                            route_lang is None
+                            or (no_speech is not None and float(no_speech) > 0.5)
+                            or (avg_lp is not None and float(avg_lp) < float(thresholds.get("min_avg_logprob_mean", -1.0)))
+                            or (
+                                comp is not None
+                                and float(comp) > float(thresholds.get("max_compression_ratio_mean", 2.4))
+                            )
+                        )
+
+                    if run_gemini:
+                        try:
+                            g = gemini_backend.transcribe(wav_path, language=route_lang)
+                            asr_results[g.model] = g
+                        except Exception:
+                            pass
+
                 t_asr1 = time.perf_counter()

                 # 4) Normalize hypotheses
                 t_norm0 = time.perf_counter()
                 norm_hyps: dict[str, str] = {}
@@
                 verification = verify_segment(
                     qc=asdict(qc),
                     lid=lid,
                     rover={
                         "text": rover.text,
                         "disagreement_rate": rover.disagreement_rate,
                         "skeleton_key": rover.skeleton_key,
                     },
                     asr_summaries=asr_summaries,
+                    primary_asr_key=primary_asr_key,
                     thresholds=thresholds,
                 )
                 t_ver1 = time.perf_counter()

                 record = {
                     "segment_id": segment_id,
+                    "base_id": ident.base_id,
+                    "relpath": ident.relpath,
+                    "uid": ident.uid,
                     "src_path": str(src),
                     "wav_path": str(wav_path),
                     "ffprobe": asdict(info),
                     "qc": asdict(qc),
                     "lid": (
@@
                     "route": {"language_hint": route_lang},
                     "asr": {
                         k: {
                             "model": v.model,
                             "text": v.text,
                             "language": v.language,
                             "language_probability": v.language_probability,
                             "segments": [asdict(s) for s in v.segments],
                             "meta": v.meta,
                         }
                         for k, v in asr_results.items()
                     },
                     "normalized": norm_hyps,
                     "rover": {
                         "text": rover.text,
                         "skeleton_key": rover.skeleton_key,
                         "disagreement_rate": rover.disagreement_rate,
                         "disagreements": [
                             {"kind": d.kind, "index": d.index, "chosen": d.chosen, "options": d.options}
                             for d in rover.disagreements
                         ],
                     },
                     "verification": {
                         "decision": verification.decision,
                         "reasons": verification.reasons,
                         "scores": verification.scores,
                     },
+                    "config": {
+                        "trim_silence": trim_silence,
+                        "silence_threshold_db": silence_threshold_db,
+                        "min_silence_sec": min_silence_sec,
+                        "gemini_model": gemini_model,
+                        "gemini_hard_only": gemini_hard_only,
+                    },
                     "timing": {
                         "preprocess_sec": t_pre1 - t_pre0,
                         "lid_sec": t_lid1 - t_lid0,
                         "asr_sec": t_asr1 - t_asr0,
                         "normalize_sec": t_norm1 - t_norm0,
                         "rover_sec": t_rover1 - t_rover0,
                         "verify_sec": t_ver1 - t_ver0,
                         "total_sec": time.perf_counter() - t0,
                     },
                 }
*** End Patch


Output: {"output":"Success. Updated the following files:\nM maya_transcribe/pipeline.py\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: maya_transcribe/cli.py
@@
 def run(
     segments_dir: Path = typer.Option(..., exists=True, file_okay=False, help="Directory containing audio segments."),
     out: Path = typer.Option(Path("outputs/run"), help="Output directory."),
     whisper_model: str = typer.Option("large-v3", help="Whisper model size/path for faster-whisper."),
     lid_model_id: str = typer.Option("facebook/mms-lid-256", help="HuggingFace MMS-LID model id."),
+    require_lid_backend: bool = typer.Option(True, help="Fail if MMS-LID backend can't initialize."),
     transformers_whisper_model: list[str] = typer.Option(
         [], help="Extra whisper-family HF models to run via transformers (repeatable)."
     ),
+    gemini_model: str | None = typer.Option(None, help="Optional Gemini model name (adds Gemini as a hypothesis)."),
+    gemini_prompt_file: Path | None = typer.Option(Path("Prompt.md"), help="Prompt file for Gemini."),
+    gemini_hard_only: bool = typer.Option(True, help="Only call Gemini on 'hard' segments."),
+    trim_silence: bool = typer.Option(True, help="Trim leading/trailing silence (ffmpeg silenceremove)."),
+    silence_threshold_db: float = typer.Option(-40.0, help="Silence threshold in dB for trimming."),
+    min_silence_sec: float = typer.Option(0.15, help="Min silence duration for trimming."),
     limit: int | None = typer.Option(None, help="Process only the first N segments."),
 ) -> None:
@@
     results_path = run_pipeline(
         segments_dir=segments_dir,
         out_dir=out,
         lid_model_id=lid_model_id,
+        require_lid_backend=require_lid_backend,
         whisper_model=whisper_model,
         extra_transformers_whisper_models=transformers_whisper_model,
+        gemini_model=gemini_model,
+        gemini_prompt_path=gemini_prompt_file,
+        gemini_hard_only=gemini_hard_only,
+        trim_silence=trim_silence,
+        silence_threshold_db=silence_threshold_db,
+        min_silence_sec=min_silence_sec,
         limit=limit,
     )
*** End Patch


Output: {"output":"Success. Updated the following files:\nM maya_transcribe/cli.py\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


python3 -m compileall -q maya_transcribe


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:


maya-transcribe --help | sed -n '1,120p'


Output:
Exit code: 0
Wall time: 0.2 seconds
Output:

 Usage: maya-transcribe [OPTIONS] COMMAND [ARGS]...

╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --help          Show this message and exit.                                  │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ download     Download the cleaned/segmented tar for a video from R2 and      │
│              extract it.                                                     │
│ preprocess   Normalize audio to 16kHz mono WAV, optionally trim silence, and │
│              write QC metrics.                                               │
│ lid          Spoken language identification (MMS-LID).                       │
│ asr          Transcribe a single file with faster-whisper.                   │
│ gemini       Transcribe a single file with Gemini (audio-native LLM). Output │
│              is parsed/validated strictly.                                   │
│ run          End-to-end pipeline: preprocess -> LID -> ASR ensemble -> ROVER │
│              -> verify.                                                      │
│ eval         Compute WER for `rover.text` against a reference JSONL keyed by │
│              `segment_id`.                                                   │
╰──────────────────────────────────────────────────────────────────────────────╯


**Testing the CLI modifications**

I need to check the new CLI by running the pipeline again to make sure my changes didn’t break anything. I’ll conduct a test run with a limit of 1 using Whisper Tiny and see what the results show. I'm hoping everything works as expected! It feels important to verify that the modifications are solid and everything is functioning properly. Let's see how this goes!


maya-transcribe run --segments-dir data/--0C5YVdu5s/--0C5YVdu5s/segments --out outputs/test_run3 --whisper-model tiny --limit 1


Output:
Exit code: 0
Wall time: 11.2 seconds
Output:
/home/ubuntu/.local/lib/python3.10/site-packages/torch/cuda/__init__.py:63: FutureWarning: The pynvml package is deprecated. Please install nvidia-ml-py instead. If you did not install pynvml directly, please report this to the maintainers of the package that installed pynvml for you.
  import pynvml  # type: ignore[import]
2026-01-13 05:31:15.038452: I tensorflow/core/platform/cpu_feature_guard.cc:210] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.
To enable the following instructions: AVX2 FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.
/home/ubuntu/.local/lib/python3.10/site-packages/google/api_core/_python_version_support.py:266: FutureWarning: You are using a Python version (3.10.12) which Google will stop supporting in new releases of google.api_core once it reaches its end of life (2026-10-04). Please upgrade to the latest Python version, or at least Python 3.11, to continue receiving updates for google.api_core past that date.
  warnings.warn(message, FutureWarning)
Transcribing ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 0:00:01
Wrote: outputs/test_run3/results.jsonl
Wrote: outputs/test_run3/summary.json


python3 - <<'PY'
import json
from pathlib import Path
obj=json.loads(Path('outputs/test_run3/results.jsonl').read_text().splitlines()[0])
print('segment_id', obj['segment_id'])
print('base_id', obj['base_id'])
print('relpath', obj['relpath'])
print('decision', obj['verification']['decision'])
print('reasons', obj['verification']['reasons'])
print('lid top1', obj['lid']['predictions'][0])
print('scores', obj['verification']['scores'])
PY


Output:
Exit code: 0
Wall time: 0 seconds
Output:
segment_id SPEAKER_00_0000_0.03-12.15
base_id SPEAKER_00_0000_0.03-12.15
relpath SPEAKER_00_0000_0.03-12.15.flac
decision accept
reasons []
lid top1 {'code': 'eng', 'prob': 0.999092698097229}
scores {'duration_sec': 1.432625, 'clipping_fraction': 0.0, 'rover_disagreement_rate': 0.0, 'lid': {'available': True, 'top1': 'eng', 'top1_prob': 0.999092698097229, 'margin': 0.9989108355948702, 'confident': True}, 'no_speech_prob_max': 0.1619873046875, 'heuristics_asr_key': 'faster-whisper:tiny|det', 'avg_logprob_mean': -0.5895182490348816, 'compression_ratio_mean': 0.6666666666666666}


maya-transcribe gemini --audio outputs/test_run3/audio/SPEAKER_00_0000_0.03-12.15.wav --language eng --model gemini-2.0-flash --max-output-tokens 64


Output:
Exit code: 1
Wall time: 8.7 seconds
Output:
╭───────────────────── Traceback (most recent call last) ──────────────────────╮
│ /home/ubuntu/maya-transcribe/maya_transcribe/cli.py:202 in gemini            │
│                                                                              │
│   199 │                                                                      │
│   200 │   prompt = load_prompt_template(prompt_file)                         │
│   201 │   backend = GeminiASR(config=GeminiConfig(model=model, max_output_to │
│ ❱ 202 │   res = backend.transcribe(audio_path=audio, language=language)      │
│   203 │   typer.echo(res.text)                                               │
│   204                                                                        │
│   205                                                                        │
│                                                                              │
│ ╭───────────────────────────────── locals ─────────────────────────────────╮ │
│ │             audio = PosixPath('outputs/test_run3/audio/SPEAKER_00_0000_… │ │
│ │           backend = <maya_transcribe.gemini_asr.GeminiASR object at      │ │
│ │                     0x75e7183e6440>                                      │ │
│ │          language = 'eng'                                                │ │
│ │ max_output_tokens = 64                                                   │ │
│ │             model = 'gemini-2.0-flash'                                   │ │
│ │            prompt = 'Instruction: You are a strict, verbatim             │ │
│ │                     transcription engine.\nInput: Audio file '+714       │ │
│ │       prompt_file = PosixPath('Prompt.md')                               │ │
│ ╰──────────────────────────────────────────────────────────────────────────╯ │
│                                                                              │
│ /home/ubuntu/maya-transcribe/maya_transcribe/gemini_asr.py:192 in transcribe │
│                                                                              │
│   189 │   │   │   │   backoff = self.config.initial_backoff_sec * (2**attemp │
│   190 │   │   │   │   time.sleep(backoff)                                    │
│   191 │   │                                                                  │
│ ❱ 192 │   │   raise RuntimeError(f"gemini_transcribe_failed: {type(last_err) │
│   193                                                                        │
│   194                                                                        │
│   195 def safe_normalized_transcript(text: str, lang: str | None) -> str:    │
│                                                                              │
│ ╭───────────────────────────────── locals ─────────────────────────────────╮ │
│ │     attempt = 3                                                          │ │
│ │ audio_bytes = b'RIFFZ\xb3\x00\x00WAVEfmt                                 │ │
│ │               \x10\x00\x00\x00\x01\x00\x01\x00\x80>\x00\x00\x00}\x00\x0… │ │
│ │  audio_path = PosixPath('outputs/test_run3/audio/SPEAKER_00_0000_0.03-1… │ │
│ │     backoff = 4.0                                                        │ │
│ │         cfg = GenerateContentConfig(                                     │ │
│ │               │   http_options=None,                                     │ │
│ │               │   should_return_http_response=None,                      │ │
│ │               │   system_instruction=None,                               │ │
│ │               │   temperature=0.0,                                       │ │
│ │               │   top_p=None,                                            │ │
│ │               │   top_k=None,                                            │ │
│ │               │   candidate_count=None,                                  │ │
│ │               │   max_output_tokens=64,                                  │ │
│ │               │   stop_sequences=None,                                   │ │
│ │               │   response_logprobs=None,                                │ │
│ │               │   logprobs=None,                                         │ │
│ │               │   presence_penalty=None,                                 │ │
│ │               │   frequency_penalty=None,                                │ │
│ │               │   seed=None,                                             │ │
│ │               │   response_mime_type='text/plain',                       │ │
│ │               │   response_schema=None,                                  │ │
│ │               │   response_json_schema=None,                             │ │
│ │               │   routing_config=None,                                   │ │
│ │               │   model_selection_config=None,                           │ │
│ │               │   safety_settings=None,                                  │ │
│ │               │   tools=None,                                            │ │
│ │               │   tool_config=None,                                      │ │
│ │               │   labels=None,                                           │ │
│ │               │   cached_content=None,                                   │ │
│ │               │   response_modalities=None,                              │ │
│ │               │   media_resolution=None,                                 │ │
│ │               │   speech_config=None,                                    │ │
│ │               │   audio_timestamp=None,                                  │ │
│ │               │   automatic_function_calling=None,                       │ │
│ │               │   thinking_config=None,                                  │ │
│ │               │   image_config=None                                      │ │
│ │               )                                                          │ │
│ │    language = 'eng'                                                      │ │
│ │    last_err = TypeError('Part.from_text() takes 1 positional argument    │ │
│ │               but 2 were given')                                         │ │
│ │   mime_type = 'audio/wav'                                                │ │
│ │      prompt = 'Instruction: You are a strict, verbatim transcription     │ │
│ │               engine.\nInput: Audio file '+707                           │ │
│ │        self = <maya_transcribe.gemini_asr.GeminiASR object at            │ │
│ │               0x75e7183e6440>                                            │ │
│ ╰──────────────────────────────────────────────────────────────────────────╯ │
╰──────────────────────────────────────────────────────────────────────────────╯
RuntimeError: gemini_transcribe_failed: TypeError: Part.from_text() takes 1
positional argument but 2 were given


Input:
*** Begin Patch
*** Update File: maya_transcribe/gemini_asr.py
@@
                 response = self._client.models.generate_content(
                     model=self.config.model,
                     contents=[
-                        self._types.Part.from_text(prompt),
+                        self._types.Part.from_text(text=prompt),
                         self._types.Part.from_bytes(data=audio_bytes, mime_type=mime_type),
                     ],
                     config=cfg,
                 )
*** End Patch


Output: {"output":"Success. Updated the following files:\nM maya_transcribe/gemini_asr.py\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Output:
Exit code: 1
Wall time: 12.8 seconds
Total output lines: 168
Output:
╭───────────────────── Traceback (most recent call last) ──────────────────────╮
│ /home/ubuntu/maya-transcribe/maya_transcribe/cli.py:202 in gemini            │
│                                                                              │
│   199 │                                                                      │
│   200 │   prompt = load_prompt_template(prompt_file)                         │
│   201 │   backend = GeminiASR(config=GeminiConfig(model=model, max_output_to │
│ ❱ 202 │   res = backend.transcribe(audio_path=audio, language=language)      │
│   203 │   typer.echo(res.text)                                               │
│   204                                                                        │
│   205                                                                        │
│                                                                              │
│ ╭───────────────────────────────── locals ─────────────────────────────────╮ │
│ │             audio = PosixPath('outputs/test_run3/audio/SPEAKER_00_0000_… │ │
│ │           backend = <maya_transcribe.gemini_asr.GeminiASR object at      │ │
│ │                     0x75d89b10fd00>                                      │ │
│ │          language = 'eng'                                                │ │
│ │ max_output_tokens = 64                                                   │ │
│ │             model = 'gemini-2.0-flash'                                   │ │
│ │            prompt = 'Instruction: You are a strict, verbatim             │ │
│ │                     transcription engine.\nInput: Audio file '+714       │ │
│ │       prompt_file = PosixPath('Prompt.md')                               │ │
│ ╰──────────────────────────────────────────────────────────────────────────╯ │
│                                                                              │
│ /home/ubuntu/maya-transcribe/maya_transcribe/gemini_asr.py:192 in transcribe │
│                                                                              │
│   189 │   │   │   │   backoff = self.config.initial_backoff_sec * (2**attemp │
│   190 │   │   │   │   time.sleep(backoff)                                    │
│   191 │   │                                                                  │
│ ❱ 192 │   │   raise RuntimeError(f"gemini_transcribe_failed: {type(last_err) │
│   193                                                                        │
│   194                                                                        │
│   195 def safe_normalized_transcript(text: str, lang: str | None) -> str:    │
│                                                                              │
│ ╭───────────────────────────────── locals ─────────────────────────────────╮ │
│ │     attempt = 3                                                          │ │
│ │ audio_bytes = b'RIFFZ\xb3\x00\x00WAVEfmt                                 │ │
│ │               \x10\x00\x00\x00\x01\x00\x01\x00\x80>\x00\x00\x00}\x00\x0… │ │
│ │  audio_path = PosixPath('outputs/test_run3/audio/SPEAKER_00_0000_0.03-1… │ │
│ │     backoff = 4.0                                                        │ │
│ │         cfg = GenerateContentConfig(                                     │ │
│ │               │   http_options=None,                                     │ │
│ │               │   should_return_http_response=None,                      │ │
│ │               │   system_instruction=None,                               │ │
│ │               │   temperature=0.0,                                       │ │
│ │               │   top_p=None,                                            │ │
│ │               │   top_k=None,                                            │ │
│ │               │   candidate_count=None,                                  │ │
│ │               │   max_output_tokens=64,                                  │ │
│ │               │   stop_sequences=None,                                   │ │
│ │               │   response_logprobs=None,                                │ │
│ │               │   logprobs=None,                                  …5383 chars truncated…│   citation_metadata=None,                        │ │
│ │               │   │   │   finish_message=None,                           │ │
│ │               │   │   │   token_count=None,                              │ │
│ │               │   │   │   finish_reason=<FinishReason.STOP: 'STOP'>,     │ │
│ │               │   │   │   avg_logprobs=-0.07286198437213898,             │ │
│ │               │   │   │   grounding_metadata=None,                       │ │
│ │               │   │   │   index=None,                                    │ │
│ │               │   │   │   logprobs_result=None,                          │ │
│ │               │   │   │   safety_ratings=None,                           │ │
│ │               │   │   │   url_context_metadata=None                      │ │
│ │               │   │   )                                                  │ │
│ │               │   ],                                                     │ │
│ │               │   create_time=None,                                      │ │
│ │               │   model_version='gemini-2.0-flash',                      │ │
│ │               │   prompt_feedback=None,                                  │ │
│ │               │   response_id='1NhlacTuCrq7_uMPwcKYsAU',                 │ │
│ │               │   usage_metadata=GenerateContentResponseUsageMetadata(   │ │
│ │               │   │   cache_tokens_details=None,                         │ │
│ │               │   │   cached_content_token_count=None,                   │ │
│ │               │   │   candidates_token_count=6,                          │ │
│ │               │   │   candidates_tokens_details=[                        │ │
│ │               │   │   │   ModalityTokenCount(                            │ │
│ │               │   │   │   │   modality=<MediaModality.TEXT: 'TEXT'>,     │ │
│ │               │   │   │   │   token_count=6                              │ │
│ │               │   │   │   )                                              │ │
│ │               │   │   ],                                                 │ │
│ │               │   │   prompt_token_count=237,                            │ │
│ │               │   │   prompt_tokens_details=[                            │ │
│ │               │   │   │   ModalityTokenCount(                            │ │
│ │               │   │   │   │   modality=<MediaModality.AUDIO: 'AUDIO'>,   │ │
│ │               │   │   │   │   token_count=50                             │ │
│ │               │   │   │   ),                                             │ │
│ │               │   │   │   ModalityTokenCount(                            │ │
│ │               │   │   │   │   modality=<MediaModality.TEXT: 'TEXT'>,     │ │
│ │               │   │   │   │   token_count=187                            │ │
│ │               │   │   │   )                                              │ │
│ │               │   │   ],                                                 │ │
│ │               │   │   thoughts_token_count=None,                         │ │
│ │               │   │   tool_use_prompt_token_count=None,                  │ │
│ │               │   │   tool_use_prompt_tokens_details=None,               │ │
│ │               │   │   total_token_count=243,                             │ │
│ │               │   │   traffic_type=None                                  │ │
│ │               │   ),                                                     │ │
│ │               │   automatic_function_calling_history=[],                 │ │
│ │               │   parsed=None                                            │ │
│ │               )                                                          │ │
│ │        self = <maya_transcribe.gemini_asr.GeminiASR object at            │ │
│ │               0x75d89b10fd00>                                            │ │
│ ╰──────────────────────────────────────────────────────────────────────────╯ │
╰──────────────────────────────────────────────────────────────────────────────╯
RuntimeError: gemini_transcribe_failed: ValueError: missing_label


**Improving API response parsing**

I’m noticing that we successfully called the API, but the parsing failed because of a missing label, "Output 1: Transcript." It seems like the model just returned a plain transcript. I’m considering adjusting the parser to be more flexible or updating the prompt to enforce the format. While strict validation helps avoid extraneous text, if the output is just plain text, it might be okay to accept it without the label. I need to weigh these options carefully.


**Revising parsing strategy**

Since we're treating Gemini output as a hypothesis rather than absolute truth, I’m thinking about making our parsing more flexible. If the response is a single line without "Output" labels, we can accept it as a transcript. Our current prompt is strict, but that's okay if the model outputs just a transcript. We also have to check for punctuation. I’ll update the parsing logic to accept plain transcript lines while maintaining strict checks for extra lines.


**Updating parse_strict_output**

I’m looking at how to patch `gemini_asr.py`'s `parse_strict_output`. I have a pseudo-code plan: First, I’ll normalize line endings and strip whitespace. If the text is empty, it raises an error. Next, I’ll split the text into lines and check if the first line is "Output 1: Transcript", ensuring exactly two lines. If not, I should accept it as a single line transcript. I also need to check for any additional "Output" labels or punctuation. Let’s implement that patch!


Input:
*** Begin Patch
*** Update File: maya_transcribe/gemini_asr.py
@@
 def parse_strict_output(text: str) -> str:
     """
     Extract the transcript line from the strict output format.
     """
     text = (text or "").replace("\r\n", "\n").replace("\r", "\n").strip()
     if not text:
         raise ValueError("empty_response")

-    # Permit leading whitespace but require the label.
-    if not text.startswith("Output 1: Transcript"):
-        raise ValueError("missing_label")
-
-    lines = [ln.strip() for ln in text.split("\n")]
-    if not lines or lines[0] != "Output 1: Transcript":
-        raise ValueError("bad_label")
-
-    payload = [ln for ln in lines[1:] if ln]
-    if len(payload) != 1:
-        raise ValueError("unexpected_extra_lines")
-
-    transcript = payload[0].strip()
+    lines = [ln.strip() for ln in text.split("\n") if ln.strip()]
+    if not lines:
+        raise ValueError("empty_response")
+
+    if lines[0] == "Output 1: Transcript":
+        if len(lines) != 2:
+            raise ValueError("unexpected_extra_lines")
+        transcript = lines[1].strip()
+    else:
+        # Some model variants return just the transcript; accept only if it's a single non-empty line.
+        if len(lines) != 1:
+            raise ValueError("unexpected_extra_lines")
+        transcript = lines[0].strip()
+
     if not transcript:
         raise ValueError("empty_transcript")
*** End Patch


Output: {"output":"Success. Updated the following files:\nM maya_transcribe/gemini_asr.py\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Output:
Exit code: 0
Wall time: 2.7 seconds
Output:
<unk> intricate web of


**Implementing error handling**

I’m planning to adjust the code so that if the gemini_model is passed but can't initialize, it raises an error to fail fast. This aligns with a quality-first approach, so I'll implement it in pipeline.py. I’ll set up a conditional check and handle exceptions properly to catch initialization errors. It's okay to fail early if there are environment or library issues. Additionally, I’ll consider whether to log gemini transcription errors instead of ignoring them. Let’s patch this!