Output:
Exit code: 0
Wall time: 0 seconds
Output:
"""
Simple Transcription Validator
==============================

Three checks:
1. Character validation - catch garbage/alien characters
2. Native CTC alignment - verify native script matches audio
3. Romanized MMS alignment - verify romanized text matches audio

Combined scoring: S = 0.45*N + 0.55*R - 0.10*abs(N-R)
  N = native CTC score, R = romanized MMS score
  Accept if S >= 0.70 and min(N,R) >= 0.40
  Forced review if abs(N-R) > 0.25 (high validator disagreement)

Usage:
    from src.validators.simple_validator import validate_transcription

    result = validate_transcription(
        "audio.flac", "నాకు కొన్ని యాడ్స్",
        romanized_text="naaku konni ads", language="te"
    )
    print(result.status)          # accept / review / retry / reject
    print(result.combined_score)  # 0-1
"""
import re
from dataclasses import dataclass
from typing import List, Dict, Optional, Set

# Unicode ranges for Indic scripts (all 12 supported languages)
SCRIPT_RANGES = {
    "te": {  # Telugu
        "name": "Telugu",
        "ranges": [(0x0C00, 0x0C7F)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "hi": {  # Hindi (Devanagari)
        "name": "Hindi",
        "ranges": [(0x0900, 0x097F)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "mr": {  # Marathi (Devanagari, same block as Hindi)
        "name": "Marathi",
        "ranges": [(0x0900, 0x097F)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "ta": {  # Tamil
        "name": "Tamil",
        "ranges": [(0x0B80, 0x0BFF)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "kn": {  # Kannada
        "name": "Kannada",
        "ranges": [(0x0C80, 0x0CFF)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "ml": {  # Malayalam
        "name": "Malayalam",
        "ranges": [(0x0D00, 0x0D7F)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "bn": {  # Bengali
        "name": "Bengali",
        "ranges": [(0x0980, 0x09FF)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "as": {  # Assamese (same Unicode block as Bengali, slight char differences)
        "name": "Assamese",
        "ranges": [(0x0980, 0x09FF)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "gu": {  # Gujarati
        "name": "Gujarati",
        "ranges": [(0x0A80, 0x0AFF)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "pa": {  # Punjabi (Gurmukhi)
        "name": "Punjabi",
        "ranges": [(0x0A00, 0x0A7F)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "or": {  # Odia
        "name": "Odia",
        "ranges": [(0x0B00, 0x0B7F)],
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
    "en": {  # English (Latin)
        "name": "English",
        "ranges": [(0x0041, 0x005A), (0x0061, 0x007A)],  # A-Z, a-z
        "allow_ascii_punct": True,
        "allow_digits": True,
    },
}

# Common allowed characters across all languages
ALLOWED_COMMON = set(" \t\n.,!?;:'\"()-–—0123456789")

# English characters (for code-mixed text)
ENGLISH_CHARS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")

def get_valid_chars(language: str, allow_english: bool = True) -> Set[int]:
    """Get set of valid Unicode codepoints for a language."""
    valid = set()

    # Add common chars
    for c in ALLOWED_COMMON:
        valid.add(ord(c))

    # Add script-specific chars
    lang_config = SCRIPT_RANGES.get(language, SCRIPT_RANGES["te"])
    for start, end in lang_config["ranges"]:
        for cp in range(start, end + 1):
            valid.add(cp)

    # Allow English for code-mixed
    if allow_english:
        for c in ENGLISH_CHARS:
            valid.add(ord(c))

    return valid

def check_characters(
    text: str,
    language: str = "te",
    allow_english: bool = False  # Changed: native = no English
) -> Dict:
    """
    Check if text contains only valid characters for the language.

    For native transcription: allow_english=False (default)

    Returns:
        {
            "valid": True/False,
            "invalid_chars": [...],
            "script_ratio": 0.8,  # ratio of native script chars
        }
    """
    if not text or not text.strip():
        return {"valid": False, "invalid_chars": [], "script_ratio": 0, "reason": "empty"}

    valid_chars = get_valid_chars(language, allow_english)
    lang_config = SCRIPT_RANGES.get(language, SCRIPT_RANGES["te"])

    invalid_chars = []
    script_count = 0
    total_alpha = 0

    for i, char in enumerate(text):
        cp = ord(char)

        # Check if in native script
        is_native = any(start <= cp <= end for start, end in lang_config["ranges"])
        if is_native:
            script_count += 1
            total_alpha += 1

        # Check if invalid (includes English if not allowed)
        elif cp not in valid_chars:
            invalid_chars.append({
                "char": char,
                "codepoint": cp,
                "position": i
            })
            if char in ENGLISH_CHARS:
                total_alpha += 1

    return {
        "valid": len(invalid_chars) == 0,
        "invalid_chars": invalid_chars[:10],  # First 10 only
        "invalid_count": len(invalid_chars),
        "script_ratio": script_count / total_alpha if total_alpha > 0 else 0,
    }

@dataclass
class ValidationResult:
    """Result of transcription validation with dual scoring."""
    status: str  # "accept", "review", "retry", "reject"

    # Character check
    char_valid: bool = True
    invalid_chars: List[Dict] = None
    script_ratio: float = 0.0

    # Native CTC alignment (language-specific wav2vec2)
    alignment_score: float = 0.0       # kept for backward compat
    native_ctc_score: float = 0.0
    low_confidence_words: List[str] = None
    low_confidence_ratio: float = 0.0

    # Romanized MMS alignment (language-agnostic torchaudio MMS_FA)
    roman_mms_score: float = 0.0

    # Combined weighted score: S = 0.45*N + 0.55*R - 0.10*abs(N-R)
    combined_score: float = 0.0

    # Reasons
    reasons: List[str] = None

    def to_dict(self) -> Dict:
        return {
            "status": self.status,
            "char_valid": self.char_valid,
            "invalid_chars": self.invalid_chars or [],
            "script_ratio": round(self.script_ratio, 3),
            "native_ctc_score": round(self.native_ctc_score, 4),
            "roman_mms_score": round(self.roman_mms_score, 4),
            "combined_score": round(self.combined_score, 4),
            "alignment_score": round(self.alignment_score, 4),
            "low_confidence_words": self.low_confidence_words or [],
            "low_confidence_ratio": round(self.low_confidence_ratio, 3),
            "reasons": self.reasons or []
        }

# Global aligners (lazy loaded)
_ctc_aligner = None
_mms_aligner = None

def _get_ctc_aligner(language: str):
    """Lazy load native CTC aligner."""
    global _ctc_aligner
    if _ctc_aligner is None:
        from .ctc_forced_aligner import CTCForcedAligner
        _ctc_aligner = CTCForcedAligner(language=language)
    return _ctc_aligner

def _get_mms_aligner():
    """Lazy load romanized MMS aligner."""
    global _mms_aligner
    if _mms_aligner is None:
        from .mms_aligner import MMSAligner
        _mms_aligner = MMSAligner()
    return _mms_aligner

def compute_combined_score(
    native_ctc: float,
    roman_mms: float,
) -> tuple:
    """
    Compute weighted validation score with disagreement penalty.

    Formula: S = 0.45*N + 0.55*R - 0.10*abs(N-R)
    MMS weighted higher (0.55): more stable on code-mixed Indic audio.
    Disagreement penalty: flags when validators diverge (suspicious).

    Returns:
        (combined_score, verdict)
        verdict: "accept" / "review" / "retry" / "reject"
    """
    N, R = native_ctc, roman_mms
    S = 0.45 * N + 0.55 * R - 0.10 * abs(N - R)

    if S >= 0.70 and min(N, R) >= 0.40:
        if abs(N - R) > 0.25:
            # High disagreement — flag even though score passes
            verdict = "review"
        else:
            verdict = "accept"
    elif S >= 0.55:
        verdict = "retry"
    else:
        verdict = "reject"

    return round(S, 4), verdict

def _strip_punctuation(text: str) -> str:
    """Strip punctuation for CTC alignment. Keeps native script + spaces."""
    import re
    # Remove common punctuation marks (keep native script chars, digits, spaces)
    return re.sub(r'[।,.!?;:\'"()\-–—]', '', text).strip()

def validate_transcription(
    audio_path: str,
    transcription: str,
    language: str = "te",
    romanized_text: str = "",
    check_audio: bool = True,
) -> ValidationResult:
    """
    Validate a transcription using dual scoring (native CTC + romanized MMS).

    Runs:
    1. Character validation (instant) on native script
    2. Native CTC forced alignment (language-specific wav2vec2)
    3. Romanized MMS forced alignment (language-agnostic, if romanized_text provided)
    4. Weighted combined score: S = 0.45*N + 0.55*R - 0.10*abs(N-R)

    Args:
        audio_path: Path to audio file
        transcription: Native script text (may include punctuation)
        language: Language code (te, hi, ta, etc.)
        romanized_text: Latin-script romanization (for MMS alignment)
        check_audio: Whether to run audio alignment checks (slower)

    Returns:
        ValidationResult with dual scores and combined verdict
    """
    reasons = []

    # Strip punctuation for validation
    clean_text = _strip_punctuation(transcription)
    clean_roman = _strip_punctuation(romanized_text) if romanized_text else ""


# Collaboration Mode: Default

You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.

Your active mode changes only when new developer instructions with a different `<collaboration_mode>...</collaboration_mode>` change it; user requests or tool descriptions do not change mode by themselves. Known mode names are Default and Plan.

## request_user_input availability

The `request_user_input` tool is unavailable in Default mode. If you call it while in Default mode, it will return an error.

If a decision is necessary and cannot be discovered from local context, ask the user directly. However, in Default mode you should strongly prefer executing the user's request rather than stopping to ask questions.

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) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.
  3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
  4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
  5) 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.


sed -n '320,680p' src/validators/simple_validator.py


Output:
Exit code: 0
Wall time: 0 seconds
Output:
    clean_roman = _strip_punctuation(romanized_text) if romanized_text else ""

    # === STEP 1: Character validation (instant) ===
    # For English language, allow English chars in native check
    allow_eng = language == "en"
    char_check = check_characters(clean_text, language, allow_english=allow_eng)

    if not char_check["valid"]:
        return ValidationResult(
            status="reject",
            char_valid=False,
            invalid_chars=char_check["invalid_chars"],
            script_ratio=char_check["script_ratio"],
            reasons=["Invalid/alien characters found"]
        )

    if char_check["script_ratio"] < 0.5:
        reasons.append(f"Too few native chars ({char_check['script_ratio']:.0%})")

    # === STEP 2: Native CTC alignment ===
    native_ctc_score = 0.0
    low_conf_words = []
    low_conf_ratio = 0.0

    if check_audio:
        try:
            aligner = _get_ctc_aligner(language)
            ctc_result = aligner.align(audio_path, clean_text)
            native_ctc_score = ctc_result.alignment_score
            low_conf_words = ctc_result.low_confidence_words
            low_conf_ratio = ctc_result.low_confidence_ratio
        except Exception as e:
            reasons.append(f"CTC alignment failed: {str(e)[:50]}")

    # === STEP 3: Romanized MMS alignment ===
    roman_mms_score = 0.0

    if check_audio and clean_roman:
        try:
            mms = _get_mms_aligner()
            mms_result = mms.align(audio_path, clean_roman)
            roman_mms_score = mms_result.alignment_score
        except Exception as e:
            reasons.append(f"MMS alignment failed: {str(e)[:50]}")

    # === STEP 4: Combined scoring ===
    if check_audio and native_ctc_score > 0 and roman_mms_score > 0:
        # Dual scoring: weighted formula with disagreement penalty
        combined_score, verdict = compute_combined_score(
            native_ctc_score, roman_mms_score
        )
        if verdict == "review" and abs(native_ctc_score - roman_mms_score) > 0.25:
            reasons.append(
                f"High validator disagreement: CTC={native_ctc_score:.2f} vs "
                f"MMS={roman_mms_score:.2f}"
            )
    elif check_audio and native_ctc_score > 0:
        # Fallback: CTC only (no romanized text provided)
        combined_score = native_ctc_score
        if native_ctc_score >= 0.70:
            verdict = "accept"
        elif native_ctc_score >= 0.50:
            verdict = "retry"
        else:
            verdict = "reject"
    else:
        # No audio check or both failed
        combined_score = 0.0
        verdict = "review" if reasons else "accept"

    # Character validation failures override
    if not char_check["valid"]:
        verdict = "reject"

    # Append score-based reasons
    if verdict == "retry":
        reasons.append(
            f"Below threshold: combined={combined_score:.2f} "
            f"(CTC={native_ctc_score:.2f}, MMS={roman_mms_score:.2f})"
        )
    elif verdict == "reject" and combined_score > 0:
        reasons.append(
            f"Poor alignment: combined={combined_score:.2f} "
            f"(CTC={native_ctc_score:.2f}, MMS={roman_mms_score:.2f})"
        )

    return ValidationResult(
        status=verdict,
        char_valid=char_check["valid"],
        invalid_chars=char_check.get("invalid_chars"),
        script_ratio=char_check["script_ratio"],
        alignment_score=native_ctc_score,  # backward compat
        native_ctc_score=native_ctc_score,
        roman_mms_score=roman_mms_score,
        combined_score=combined_score,
        low_confidence_words=low_conf_words,
        low_confidence_ratio=low_conf_ratio,
        reasons=reasons if reasons else None,
    )

def cleanup():
    """Release all aligner resources (CTC + MMS)."""
    global _ctc_aligner, _mms_aligner
    if _ctc_aligner is not None:
        _ctc_aligner.cleanup()
        _ctc_aligner = None
    if _mms_aligner is not None:
        _mms_aligner.cleanup()
        _mms_aligner = None

# === Quick validation (no audio check) ===
def quick_validate(transcription: str, language: str = "te") -> Dict:
    """
    Quick character-only validation for NATIVE transcription.

    Checks:
    - Not empty
    - All characters in target script (no English/garbage)
    - Sufficient native script content

    Args:
        transcription: Native script text to validate
        language: Language code (te, hi, ta, etc.)

    Returns:
        {"valid": True/False, "reason": "..."}
    """
    if not transcription or not transcription.strip():
        return {"valid": False, "reason": "Empty transcription"}

    char_check = check_characters(transcription, language, allow_english=False)

    if not char_check["valid"]:
        invalid = [c['char'] for c in char_check['invalid_chars'][:5]]
        return {
            "valid": False,
            "reason": f"Invalid chars: {invalid}"
        }

    if char_check["script_ratio"] < 0.5:
        return {
            "valid": False,
            "reason": f"Too few native chars ({char_check['script_ratio']:.0%})"
        }

    return {"valid": True, "script_ratio": char_check["script_ratio"]}

if __name__ == "__main__":
    import sys

    if len(sys.argv) < 3:
        print("""
Simple Validator
================

Usage:
    # Full validation (with audio check)
    python simple_validator.py <audio_path> <transcription>

    # Quick validation (character check only)
    python simple_validator.py --quick <transcription>

Examples:
    python simple_validator.py audio.flac "నాకు కొన్ని యాడ్స్ గుర్తుంటాయి"
    python simple_validator.py --quick "నాకు కొన్ని యాడ్స్"
""")
        sys.exit(1)

    if sys.argv[1] == "--quick":
        text = " ".join(sys.argv[2:])
        result = quick_validate(text)
        print(f"Valid: {result['valid']}")
        if not result['valid']:
            print(f"Reason: {result['reason']}")
    else:
        audio_path = sys.argv[1]
        transcription = " ".join(sys.argv[2:])

        print("Validating...")
        result = validate_transcription(audio_path, transcription)
        cleanup()

        print(f"\nStatus: {result.status.upper()}")
        print(f"Character valid: {result.char_valid}")
        print(f"Script ratio: {result.script_ratio:.1%}")
        print(f"Alignment score: {result.alignment_score:.4f}")
        print(f"Low conf words: {result.low_confidence_ratio:.1%}")

        if result.reasons:
            print(f"\nReasons for review:")
            for r in result.reasons:
                print(f"  - {r}")

        if result.low_confidence_words:
            print(f"\nLow confidence words: {result.low_confidence_words[:5]}")


Summary: auto


**Inspecting aligner components**


Validator logic is clear: weighted dual scoring with disagreement penalty and retry bands. I’m now checking how each aligner computes scores (normalization, tokenization, model coverage) because that determines whether a 0.70 threshold is truly meaningful for data curation.


sed -n '1,320p' src/validators/ctc_forced_aligner.py


sed -n '320,680p' src/validators/ctc_forced_aligner.py


sed -n '1,320p' src/validators/mms_aligner.py


Output:
Exit code: 0
Wall time: 0 seconds
Output:
"""
CTC Forced Aligner
==================

Proper forced alignment using torchaudio's CTC forced_align.
This is equivalent to MFA but using neural CTC instead of GMM-HMM.

Provides:
- Per-character alignment scores (log-prob)
- Per-word alignment scores
- Overall alignment quality score
- Identifies low-confidence regions

Usage:
    aligner = CTCForcedAligner(language="te")
    result = aligner.align("audio.flac", "transcription text")

    print(f"Alignment Score: {result.alignment_score}")  # 0-1
    print(f"Low confidence words: {result.low_confidence_words}")
"""
import torch
import torchaudio
import numpy as np
import soundfile as sf
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Tuple
from pathlib import Path
import time

@dataclass
class WordAlignment:
    """Alignment info for a single word."""
    word: str
    start_frame: int
    end_frame: int
    start_time: float
    end_time: float
    log_prob: float  # Raw log probability
    confidence: float  # Normalized 0-1 score
    char_scores: List[float] = field(default_factory=list)

    @property
    def duration(self) -> float:
        return self.end_time - self.start_time

    @property
    def is_low_confidence(self) -> bool:
        return self.confidence < 0.5

@dataclass
class AlignmentResult:
    """Result of forced alignment."""
    audio_path: str
    transcription: str

    # Scores
    alignment_score: float  # 0-1, overall quality
    mean_log_prob: float  # Raw mean log prob

    # Per-word details
    word_alignments: List[WordAlignment] = field(default_factory=list)

    # Quality flags
    low_confidence_words: List[str] = field(default_factory=list)
    low_confidence_ratio: float = 0.0

    # Timing
    audio_duration_sec: float = 0.0
    processing_time_sec: float = 0.0

    def to_dict(self) -> Dict:
        return {
            "audio_path": self.audio_path,
            "transcription": self.transcription,
            "alignment_score": round(self.alignment_score, 4),
            "mean_log_prob": round(self.mean_log_prob, 4),
            "word_count": len(self.word_alignments),
            "low_confidence_words": self.low_confidence_words,
            "low_confidence_ratio": round(self.low_confidence_ratio, 4),
            "word_alignments": [
                {
                    "word": wa.word,
                    "start_time": round(wa.start_time, 3),
                    "end_time": round(wa.end_time, 3),
                    "confidence": round(wa.confidence, 4),
                    "log_prob": round(wa.log_prob, 4)
                }
                for wa in self.word_alignments
            ],
            "audio_duration_sec": round(self.audio_duration_sec, 3),
            "processing_time_sec": round(self.processing_time_sec, 3)
        }

class CTCForcedAligner:
    """
    CTC-based forced alignment using torchaudio.

    This provides MFA-equivalent functionality using neural CTC models.
    """

    # Language-specific Wav2Vec2 models for CTC forced alignment
    # Each model trained on the target language for best phoneme coverage.
    # "default" (XLSR-53) is multilingual fallback for languages without dedicated models.
    MODELS = {
        "te": "anuragshas/wav2vec2-large-xlsr-53-telugu",
        "hi": "theainerd/Wav2Vec2-large-xlsr-hindi",
        "mr": "theainerd/Wav2Vec2-large-xlsr-hindi",       # Marathi: Devanagari, use Hindi model
        "ta": "Harveenchadha/wav2vec2-large-xlsr-53-tamil",
        "kn": "Harveenchadha/wav2vec2-large-xlsr-53-kannada",
        "ml": "gvs/wav2vec2-large-xlsr-malayalam",
        "bn": "arijitx/wav2vec2-large-xlsr-bengali",       # Fixed: was pointing to Hindi
        "as": "arijitx/wav2vec2-large-xlsr-bengali",       # Assamese: closest to Bengali
        "gu": "facebook/wav2vec2-large-xlsr-53",            # Gujarati: use multilingual XLSR
        "pa": "facebook/wav2vec2-large-xlsr-53",            # Punjabi: use multilingual XLSR
        "or": "facebook/wav2vec2-large-xlsr-53",            # Odia: use multilingual XLSR
        "en": "facebook/wav2vec2-large-960h-lv60-self",     # English: dedicated English model
        "default": "facebook/wav2vec2-large-xlsr-53",
    }

    def __init__(
        self,
        language: str = "te",
        device: str = "auto",
        cache_dir: str = "./models/ctc_aligner"
    ):
        self.language = language.lower()[:2]
        self.cache_dir = Path(cache_dir)
        self.cache_dir.mkdir(parents=True, exist_ok=True)

        if device == "auto":
            self.device = "cuda" if torch.cuda.is_available() else "cpu"
        else:
            self.device = device

        self.model = None
        self.processor = None
        self.vocab = None
        self._setup_complete = False

    def setup(self) -> bool:
        """Load model and processor."""
        if self._setup_complete:
            return True

        try:
            from transformers import Wav2Vec2ForCTC, Wav2Vec2Processor

            model_name = self.MODELS.get(self.language, self.MODELS["default"])
            print(f"[CTCAligner] Loading {model_name}...")

            self.processor = Wav2Vec2Processor.from_pretrained(
                model_name, cache_dir=self.cache_dir
            )
            self.model = Wav2Vec2ForCTC.from_pretrained(
                model_name, cache_dir=self.cache_dir
            )

            self.model.to(self.device)
            self.model.eval()

            self.vocab = self.processor.tokenizer.get_vocab()
            self.blank_id = self.processor.tokenizer.pad_token_id

            print(f"[CTCAligner] Ready on {self.device}")
            self._setup_complete = True
            return True

        except Exception as e:
            print(f"[CTCAligner] Setup failed: {e}")
            return False

    def _load_audio(self, audio_path: str) -> Tuple[np.ndarray, int]:
        """Load and resample audio to 16kHz."""
        data, sr = sf.read(audio_path)

        if len(data.shape) > 1:
            data = data.mean(axis=1)

        if sr != 16000:
            waveform = torch.from_numpy(data).float().unsqueeze(0)
            data = torchaudio.functional.resample(waveform, sr, 16000).squeeze().numpy()
            sr = 16000

        return data, sr

    def _get_emissions(self, audio: np.ndarray) -> torch.Tensor:
        """Get CTC log-probability emissions."""
        inputs = self.processor(
            audio, sampling_rate=16000, return_tensors="pt", padding=True
        )

        with torch.no_grad():
            logits = self.model(inputs.input_values.to(self.device)).logits

        emissions = torch.log_softmax(logits.cpu(), dim=-1)
        return emissions

    def _text_to_tokens(self, text: str) -> tuple:
        """Convert text to token IDs. Returns (tensor, oov_ratio).
        OOV chars are dropped but tracked. High OOV = unreliable score."""
        tokens = []
        total_chars = 0
        oov_chars = 0
        for char in text:
            if char == " ":
                if "|" in self.vocab:
                    tokens.append(self.vocab["|"])
            elif char in self.vocab:
                tokens.append(self.vocab[char])
                total_chars += 1
                total_chars += 1
            else:
                oov_chars += 1
                total_chars += 1
        oov_ratio = oov_chars / max(total_chars, 1)
        if oov_ratio > 0.1:
            print(f"[CTCAligner] WARNING: {oov_ratio:.0%} OOV chars "
                  f"({oov_chars}/{total_chars})")
        oov_ratio = oov_chars / max(total_chars, 1)
        return torch.tensor([tokens], dtype=torch.int32), oov_ratio, oov_ratio

    def _compute_word_alignments(
        self,
        text: str,
        alignment: torch.Tensor,
        scores: torch.Tensor,
        frame_duration: float
    ) -> List[WordAlignment]:
        """Extract word-level alignments from frame-level results."""
        words = text.split()

        # Build character-to-word mapping
        char_to_word = []
        for word_idx, word in enumerate(words):
            for _ in word:
                char_to_word.append(word_idx)
            char_to_word.append(-1)  # Space/separator

        # Remove trailing separator
        if char_to_word and char_to_word[-1] == -1:
            char_to_word = char_to_word[:-1]

        # Aggregate scores per word
        word_scores = {i: [] for i in range(len(words))}
        word_frames = {i: [] for i in range(len(words))}

        alignment_np = alignment[0].numpy()
        scores_np = scores[0].numpy()

        char_idx = 0
        for frame_idx, (token_idx, score) in enumerate(zip(alignment_np, scores_np)):
            if token_idx != self.blank_id and char_idx < len(char_to_word):
                word_idx = char_to_word[char_idx]
                if word_idx >= 0:
                    word_scores[word_idx].append(score)
                    word_frames[word_idx].append(frame_idx)
                char_idx += 1

        # Create WordAlignment objects
        alignments = []
        for word_idx, word in enumerate(words):
            word_score_list = word_scores.get(word_idx, [])
            frame_list = word_frames.get(word_idx, [])

            if word_score_list:
                mean_log_prob = np.mean(word_score_list)
                # Convert log prob to 0-1 confidence
                # log_prob of 0 = prob of 1 (perfect)
                # log_prob of -5 = prob of ~0.007 (bad)
                confidence = np.exp(mean_log_prob)
                confidence = min(1.0, max(0.0, confidence))

                start_frame = min(frame_list)
                end_frame = max(frame_list) + 1
            else:
                mean_log_prob = -10.0
                confidence = 0.0
                start_frame = 0
                end_frame = 0

            alignments.append(WordAlignment(
                word=word,
                start_frame=start_frame,
                end_frame=end_frame,
                start_time=start_frame * frame_duration,
                end_time=end_frame * frame_duration,
                log_prob=float(mean_log_prob),
                confidence=float(confidence),
                char_scores=word_score_list
            ))

        return alignments

    def align(
        self,
        audio_path: str,
        transcription: str
    ) -> AlignmentResult:
        """
        Perform forced alignment.

        Args:
            audio_path: Path to audio file
            transcription: Text to align

        Returns:
            AlignmentResult with word-level alignments and scores
        """
        if not self._setup_complete:
            if not self.setup():
                return AlignmentResult(
                    audio_path=audio_path,
                    transcription=transcription,
                    alignment_score=0.0,
                    mean_log_prob=-10.0
                )


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

        start_time = time.time()

        try:
            # Load audio
            audio, sr = self._load_audio(audio_path)
            audio_duration = len(audio) / sr

            # Get emissions
            emissions = self._get_emissions(audio)
            num_frames = emissions.shape[1]
            frame_duration = audio_duration / num_frames

            # Convert text to tokens (returns tuple with OOV ratio)
            targets, oov_ratio = self._text_to_tokens(transcription)

            if targets.shape[1] == 0:
                return AlignmentResult(
                    audio_path=audio_path,
                    transcription=transcription,
                    alignment_score=0.0,
                    mean_log_prob=-10.0,
                    audio_duration_sec=audio_duration,
                    processing_time_sec=time.time() - start_time
                )

            # Run forced alignment
            input_lengths = torch.tensor([num_frames])
            target_lengths = torch.tensor([targets.shape[1]])

            alignment, scores = torchaudio.functional.forced_align(
                emissions,
                targets,
                input_lengths,
                target_lengths,
                blank=self.blank_id
            )

            # Compute word alignments
            word_alignments = self._compute_word_alignments(
                transcription, alignment, scores, frame_duration
            )

            # Overall scores
            mean_log_prob = scores.mean().item()

            # Convert to 0-1 score
            # Typical range: -0.5 (excellent) to -5.0 (poor)
            # Map to 0-1: score = 1 + (log_prob / 5)
            alignment_score = max(0.0, min(1.0, 1.0 + mean_log_prob / 5.0))
            # Penalize when many chars were OOV (alignment unreliable)
            if oov_ratio > 0.1:
                alignment_score *= (1.0 - oov_ratio * 0.5)
            # Penalize score when many chars were OOV (alignment is unreliable)
            if oov_ratio > 0.1:
                alignment_score *= (1.0 - oov_ratio * 0.5)

            # Find low confidence words
            low_conf_words = [wa.word for wa in word_alignments if wa.is_low_confidence]
            low_conf_ratio = len(low_conf_words) / max(len(word_alignments), 1)

            processing_time = time.time() - start_time

            return AlignmentResult(
                audio_path=audio_path,
                transcription=transcription,
                alignment_score=alignment_score,
                mean_log_prob=mean_log_prob,
                word_alignments=word_alignments,
                low_confidence_words=low_conf_words,
                low_confidence_ratio=low_conf_ratio,
                audio_duration_sec=audio_duration,
                processing_time_sec=processing_time
            )

        except Exception as e:
            import traceback
            traceback.print_exc()
            return AlignmentResult(
                audio_path=audio_path,
                transcription=transcription,
                alignment_score=0.0,
                mean_log_prob=-10.0,
                processing_time_sec=time.time() - start_time
            )

    def cleanup(self):
        """Release resources."""
        if self.model is not None:
            del self.model
            self.model = None
        if self.processor is not None:
            del self.processor
            self.processor = None

        if torch.cuda.is_available():
            torch.cuda.empty_cache()

# Convenience function
def forced_align(
    audio_path: str,
    transcription: str,
    language: str = "te"
) -> Dict:
    """Quick forced alignment."""
    aligner = CTCForcedAligner(language=language)
    result = aligner.align(audio_path, transcription)
    aligner.cleanup()
    return result.to_dict()

if __name__ == "__main__":
    import sys

    if len(sys.argv) < 3:
        print("Usage: python ctc_forced_aligner.py <audio_path> <transcription>")
        sys.exit(1)

    audio_path = sys.argv[1]
    transcription = " ".join(sys.argv[2:])

    result = forced_align(audio_path, transcription)

    print(f"\n{'='*60}")
    print(f"FORCED ALIGNMENT RESULT")
    print(f"{'='*60}")
    print(f"Alignment Score: {result['alignment_score']:.4f}")
    print(f"Mean Log Prob: {result['mean_log_prob']:.4f}")
    print(f"Low Confidence Words: {result['low_confidence_words']}")
    print(f"Low Confidence Ratio: {result['low_confidence_ratio']:.2%}")

    print(f"\nWord Alignments:")
    for wa in result['word_alignments']:
        conf_bar = "█" * int(wa['confidence'] * 10)
        print(f"  {wa['word']:<20} {wa['confidence']:.3f} {conf_bar} [{wa['start_time']:.2f}-{wa['end_time']:.2f}s]")


Output:
Exit code: 0
Wall time: 0 seconds
Output:
"""
MMS Forced Aligner (Romanized Text)
====================================

Language-agnostic forced alignment using Meta's MMS_FA model via torchaudio.
Works on romanized/Latin-script text. Complements the native CTC aligner.

MMS_FA is trained on 1000+ languages — handles romanized Indic + English equally well.
Used alongside native CTC to produce dual validation scores.

Usage:
    aligner = MMSAligner()
    result = aligner.align("audio.flac", "naaku konni ads gurtuntaayi")
    print(f"Score: {result.alignment_score}")  # 0-1
"""
import torch
import torchaudio
import soundfile as sf
import numpy as np
import time
from dataclasses import dataclass, field
from typing import List, Dict, Optional

@dataclass
class MMSAlignmentResult:
    """Result of MMS forced alignment on romanized text."""
    audio_path: str
    transcription: str

    alignment_score: float  # 0-1, overall quality
    mean_log_prob: float    # Raw mean log prob

    num_tokens: int = 0
    num_frames: int = 0
    audio_duration_sec: float = 0.0
    processing_time_sec: float = 0.0

    def to_dict(self) -> Dict:
        return {
            "audio_path": self.audio_path,
            "transcription": self.transcription[:100],
            "alignment_score": round(self.alignment_score, 4),
            "mean_log_prob": round(self.mean_log_prob, 4),
            "num_tokens": self.num_tokens,
            "num_frames": self.num_frames,
            "audio_duration_sec": round(self.audio_duration_sec, 3),
            "processing_time_sec": round(self.processing_time_sec, 3),
        }

class MMSAligner:
    """
    Language-agnostic forced alignment using torchaudio MMS_FA.

    Tokenization: space → * (word boundary, idx 28), CTC blank = - (idx 0).
    Score normalization: log_prob range ~[-10, 0] mapped to [0, 1].
    """

    def __init__(self, device: str = "auto"):
        if device == "auto":
            self.device = "cuda" if torch.cuda.is_available() else "cpu"
        else:
            self.device = device

        self.model = None
        self.labels = None
        self.label2idx = None
        self.sample_rate = None
        self._setup_complete = False

        # Token indices (set during setup)
        self.BLANK_IDX = 0   # '-' = CTC blank
        self.STAR_IDX = 28   # '*' = word boundary

    def setup(self) -> bool:
        """Lazy load MMS_FA model."""
        if self._setup_complete:
            return True

        try:
            bundle = torchaudio.pipelines.MMS_FA
            self.model = bundle.get_model().to(self.device)
            self.model.eval()
            self.labels = bundle.get_labels()
            self.sample_rate = bundle.sample_rate  # 16000
            self.label2idx = {l: i for i, l in enumerate(self.labels)}
            self.BLANK_IDX = self.label2idx.get("-", 0)
            self.STAR_IDX = self.label2idx.get("*", 28)

            print(f"[MMSAligner] Ready on {self.device} "
                  f"({len(self.labels)} labels, SR={self.sample_rate})")
            self._setup_complete = True
            return True

        except Exception as e:
            print(f"[MMSAligner] Setup failed: {e}")
            return False

    def _load_audio(self, audio_path: str) -> torch.Tensor:
        """Load and resample audio to MMS sample rate (16kHz)."""
        data, sr = sf.read(audio_path)
        if len(data.shape) > 1:
            data = data.mean(axis=1)
        waveform = torch.tensor(data, dtype=torch.float32).unsqueeze(0)
        if sr != self.sample_rate:
            waveform = torchaudio.functional.resample(
                waveform, sr, self.sample_rate
            )
        return waveform

    def _text_to_tokens(self, text: str) -> List[int]:
        """
        Convert romanized text to MMS token indices.
        Space → * (word boundary). Unknown chars skipped.
        Punctuation stripped.
        """
        tokens = []
        for ch in text.lower().strip():
            if ch == " ":
                # Word boundary - don't duplicate
                if tokens and tokens[-1] != self.STAR_IDX:
                    tokens.append(self.STAR_IDX)
            elif ch in self.label2idx and self.label2idx[ch] != self.BLANK_IDX:
                tokens.append(self.label2idx[ch])
            # Punctuation and unknown chars silently skipped
        # Strip leading/trailing boundaries
        while tokens and tokens[0] == self.STAR_IDX:
            tokens.pop(0)
        while tokens and tokens[-1] == self.STAR_IDX:
            tokens.pop()
        return tokens

    def align(self, audio_path: str, romanized_text: str) -> MMSAlignmentResult:
        """
        Perform forced alignment of romanized text against audio.

        Args:
            audio_path: Path to audio file
            romanized_text: Latin-script text to align

        Returns:
            MMSAlignmentResult with alignment score
        """
        if not self._setup_complete:
            if not self.setup():
                return MMSAlignmentResult(
                    audio_path=audio_path,
                    transcription=romanized_text,
                    alignment_score=0.0,
                    mean_log_prob=-10.0,
                )

        start_time = time.time()

        try:
            waveform = self._load_audio(audio_path)
            audio_dur = waveform.shape[1] / self.sample_rate
            tokens = self._text_to_tokens(romanized_text)

            if not tokens:
                return MMSAlignmentResult(
                    audio_path=audio_path,
                    transcription=romanized_text,
                    alignment_score=0.0,
                    mean_log_prob=-10.0,
                    audio_duration_sec=audio_dur,
                    processing_time_sec=time.time() - start_time,
                )

            with torch.no_grad():
                emission, _ = self.model(waveform.to(self.device))
            emission = emission.cpu()

            token_tensor = torch.tensor([tokens], dtype=torch.int32)

            _, scores = torchaudio.functional.forced_align(
                emission, token_tensor, blank=self.BLANK_IDX
            )

            mean_lp = scores.mean().item()
            # Normalize: log probs range ~[-10, 0]. Map to [0, 1].
            # Same formula as CTC aligner for consistency.
            alignment_score = max(0.0, min(1.0, 1.0 + mean_lp / 5.0))

            return MMSAlignmentResult(
                audio_path=audio_path,
                transcription=romanized_text,
                alignment_score=alignment_score,
                mean_log_prob=mean_lp,
                num_tokens=len(tokens),
                num_frames=emission.shape[1],
                audio_duration_sec=audio_dur,
                processing_time_sec=time.time() - start_time,
            )

        except Exception as e:
            import traceback
            traceback.print_exc()
            return MMSAlignmentResult(
                audio_path=audio_path,
                transcription=romanized_text,
                alignment_score=0.0,
                mean_log_prob=-10.0,
                processing_time_sec=time.time() - start_time,
            )

    def cleanup(self):
        """Release resources."""
        if self.model is not None:
            del self.model
            self.model = None
        self._setup_complete = False
        if torch.cuda.is_available():
            torch.cuda.empty_cache()


**Inspecting transcription module**


sed -n '1,320p' src/backend/gemini_transcriber.py


sed -n '320,720p' src/backend/gemini_transcriber.py


sed -n '1,320p' src/backend/transcription_schema.py


Output:
Exit code: 0
Wall time: 0 seconds
Output:
"""
Gemini transcription module using Google AI Studio endpoints.
Handles audio transcription with structured output and multiple model support.
"""
import time
from pathlib import Path
from typing import List, Optional, Dict, Any
from dataclasses import dataclass

from google import genai
from google.genai import types

from .config import GEMINI_API_KEY, GEMINI_MODELS, get_model_name
from .transcription_schema import (
    TranscriptionOutput,
    TranscriptionResult,
    SpeakerMeta,
    get_transcription_prompt,
    get_user_prompt,
    TRANSCRIPTION_JSON_SCHEMA
)
from .audio_processor import AudioChunk

@dataclass
class TranscriptionConfig:
    """Configuration for a transcription job."""
    model: str = "gemini-3-flash-preview"
    thinking_level: Optional[str] = "low"   # minimal, low, medium, high
    # Gemini 3 docs recommend 1.0, but our testing shows 0.0 produces
    # more deterministic, accurate transcriptions with the strict prompt.
    # No looping observed across 30+ test segments.
    temperature: float = 0.0
    language: str = "Telugu"
    timeout_sec: int = 60

class GeminiTranscriber:
    """
    Handles audio transcription using Gemini models via Google AI Studio.
    """

    def __init__(self, api_key: Optional[str] = None):
        """
        Initialize the transcriber.

        Args:
            api_key: Gemini API key (defaults to env var)
        """
        self.api_key = api_key or GEMINI_API_KEY
        if not self.api_key:
            raise ValueError("GEMINI_API_KEY not set")

        self.client = genai.Client(api_key=self.api_key)

    def _get_mime_type(self, file_path: str) -> str:
        """Determine MIME type from file extension."""
        ext = Path(file_path).suffix.lower()
        mime_types = {
            '.flac': 'audio/flac',
            '.wav': 'audio/wav',
            '.mp3': 'audio/mpeg',
            '.ogg': 'audio/ogg',
            '.m4a': 'audio/mp4',
            '.aac': 'audio/aac'
        }
        return mime_types.get(ext, 'audio/flac')

    def _load_audio_bytes(self, file_path: str) -> bytes:
        """Load audio file as bytes."""
        with open(file_path, 'rb') as f:
            return f.read()

    def _build_config(self, config: TranscriptionConfig) -> types.GenerateContentConfig:
        """Build the generation config for the API call."""
        model_name = get_model_name(config.model)
        model_info = GEMINI_MODELS.get(model_name, {})

        # Build thinking config for Gemini 3 models
        thinking_config = None
        if model_info.get("supports_thinking") and config.thinking_level:
            if model_name.startswith("gemini-3"):
                thinking_config = types.ThinkingConfig(
                    thinking_level=config.thinking_level.upper()
                )

        gen_config = types.GenerateContentConfig(
            temperature=config.temperature,
            response_mime_type="application/json",
            response_json_schema=TRANSCRIPTION_JSON_SCHEMA,
            system_instruction=[
                types.Part.from_text(text=get_transcription_prompt(config.language))
            ]
        )

        if thinking_config:
            gen_config.thinking_config = thinking_config

        return gen_config

    def transcribe_audio(
        self,
        audio_path: str,
        config: TranscriptionConfig
    ) -> Dict[str, Any]:
        """
        Transcribe a single audio file.

        Args:
            audio_path: Path to the audio file
            config: Transcription configuration

        Returns:
            Dictionary with transcription results
        """
        model_name = get_model_name(config.model)

        # Load audio
        audio_bytes = self._load_audio_bytes(audio_path)
        mime_type = self._get_mime_type(audio_path)

        # Build content
        contents = [
            types.Content(
                role="user",
                parts=[
                    types.Part.from_bytes(
                        mime_type=mime_type,
                        data=audio_bytes
                    ),
                    types.Part.from_text(text=get_user_prompt())
                ]
            )
        ]

        # Build config
        gen_config = self._build_config(config)

        # Make API call
        start_time = time.time()
        try:
            response = self.client.models.generate_content(
                model=model_name,
                contents=contents,
                config=gen_config
            )
            processing_time = time.time() - start_time

            # Parse response
            if response.text:
                import json
                result = json.loads(response.text)
                result['_processing_time_sec'] = processing_time
                result['_model'] = model_name
                result['_thinking_level'] = config.thinking_level
                return result
            else:
                return {
                    "error": "Empty response from model",
                    "_processing_time_sec": processing_time,
                    "_model": model_name
                }

        except Exception as e:
            processing_time = time.time() - start_time
            return {
                "error": str(e),
                "_processing_time_sec": processing_time,
                "_model": model_name
            }

    def transcribe_chunk(
        self,
        chunk: AudioChunk,
        config: TranscriptionConfig
    ) -> TranscriptionResult:
        """
        Transcribe an AudioChunk and return structured result.

        Args:
            chunk: AudioChunk to transcribe
            config: Transcription configuration

        Returns:
            TranscriptionResult with full metadata
        """
        raw_result = self.transcribe_audio(chunk.file_path, config)

        error_msg = raw_result.get("error")

        # Handle errors
        if error_msg:
            transcription = TranscriptionOutput(
                transcription=f"[ERROR: {error_msg}]",
                code_switch=f"[ERROR: {error_msg}]",
                romanized=f"[ERROR: {error_msg}]",
                tagged=f"[ERROR: {error_msg}]",
            )
        else:
            # Parse speaker metadata if present
            speaker_data = raw_result.get('speaker')
            speaker = SpeakerMeta(**speaker_data) if isinstance(speaker_data, dict) else None

            transcription = TranscriptionOutput(
                transcription=raw_result.get('transcription', ''),
                code_switch=raw_result.get('code_switch', ''),
                romanized=raw_result.get('romanized', ''),
                tagged=raw_result.get('tagged', ''),
                speaker=speaker,
            )

        return TranscriptionResult(
            segment_id=chunk.original_segment,
            chunk_index=chunk.chunk_index,
            total_chunks=chunk.total_chunks,
            duration_sec=chunk.duration_sec,
            language=config.language,
            transcription=transcription,
            model_used=raw_result.get('_model', config.model),
            thinking_level=raw_result.get('_thinking_level'),
            processing_time_sec=raw_result.get('_processing_time_sec')
        )

    def transcribe_batch(
        self,
        chunks: List[AudioChunk],
        config: TranscriptionConfig,
        max_chunks: Optional[int] = None,
        progress_callback: Optional[callable] = None
    ) -> List[TranscriptionResult]:
        """
        Transcribe multiple audio chunks.

        Args:
            chunks: List of AudioChunks to transcribe
            config: Transcription configuration
            max_chunks: Maximum chunks to process (for testing)
            progress_callback: Optional callback(current, total) for progress updates

        Returns:
            List of TranscriptionResults
        """
        if max_chunks:
            chunks = chunks[:max_chunks]

        results = []
        total = len(chunks)

        print(f"[Transcriber] Processing {total} chunks with {config.model}...")

        for i, chunk in enumerate(chunks):
            if progress_callback:
                progress_callback(i + 1, total)
            else:
                print(f"[Transcriber] Processing {i + 1}/{total}: {chunk.original_segment} "
                      f"(chunk {chunk.chunk_index + 1}/{chunk.total_chunks})")

            result = self.transcribe_chunk(chunk, config)
            results.append(result)

            # Brief pause to avoid rate limiting
            if i < total - 1:
                time.sleep(0.5)

        print(f"[Transcriber] Completed {len(results)} transcriptions")
        return results

def transcribe_segments(
    segments: List[AudioChunk],
    language: str = "Telugu",
    model: str = "gemini-3-flash-preview",
    thinking_level: str = "high",
    max_segments: Optional[int] = None
) -> List[TranscriptionResult]:
    """
    Convenience function to transcribe a list of audio segments.

    Args:
        segments: List of AudioChunk objects
        language: Primary language of the audio
        model: Gemini model to use
        thinking_level: Thinking level for Gemini 3 models
        max_segments: Max segments to process (for testing)

    Returns:
        List of TranscriptionResult objects
    """
    config = TranscriptionConfig(
        model=model,
        thinking_level=thinking_level,
        language=language
    )

    transcriber = GeminiTranscriber()
    return transcriber.transcribe_batch(segments, config, max_chunks=max_segments)

if __name__ == "__main__":
    # Test with a sample audio file
    import sys
    if len(sys.argv) > 1:
        audio_path = sys.argv[1]
        language = sys.argv[2] if len(sys.argv) > 2 else "Telugu"

        config = TranscriptionConfig(
            model="gemini-3-flash-preview",
            thinking_level="low",
            language=language
        )

        transcriber = GeminiTranscriber()
        result = transcriber.transcribe_audio(audio_path, config)

        print("\nTranscription Result:")
        import json
        print(json.dumps(result, indent=2, ensure_ascii=False))


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


Output:
Exit code: 0
Wall time: 0 seconds
Output:
"""
Pydantic schemas for structured transcription output.

Output fields (what Gemini returns per segment):
  1. transcription   - Native script with punctuation (PRIMARY, authoritative)
  2. code_switch     - Mixed script: native + English in Latin (derived from 1)
  3. romanized       - Full Latin transliteration (derived from 1)
  4. tagged          - Code-switch + audio event tags [laugh] etc. (derived from 2)
  5. speaker         - Metadata: emotion, style, pace, accent

Prompt v3 design (evolved from v1 original -> v2 strict -> v3 current):
  - Single master template, dynamic language pack injection for 12 languages
  - "Verbatim transcription system" persona (explicit NOT assistant negation)
  - Acoustic punctuation (prosody-based pauses, not grammar rules)
  - Anti-hallucination: [UNK] for uncertain words, no sentence completion
  - Field derivation ordering: transcription is authoritative, others derive from it
  - Transliterate != Translate distinction (fixed v2 contradiction that caused
    English speech to be translated into Telugu on segment 3 test)
  - Per-language script tips (1-2 practical tips, not grammar rules)
"""
from typing import Optional, Dict
from pydantic import BaseModel, Field

# === Language Pack Configs ===
# Per-language metadata + script-specific rules injected into the master prompt.
# Only rules that prevent ACTUAL transcription errors, not generic grammar.
# v1: 12 languages (11 Indic + English). Add more as needed.

LANGUAGE_CONFIGS: Dict[str, Dict[str, str]] = {
    # 1-2 practical script tips per language. Not grammar rules.
    "Hindi": {
        "bcp47": "hi-IN",
        "script": "Devanagari",
        "script_rules": "Preserve Nukta (ज़ vs ज, फ़ vs फ) when clearly pronounced.",
    },
    "Marathi": {
        "bcp47": "mr-IN",
        "script": "Devanagari",
        "script_rules": "Preserve ळ (retroflex lateral) accurately.",
    },
    "Telugu": {
        "bcp47": "te-IN",
        "script": "Telugu",
        "script_rules": "Don't over-split words. Preserve Sandhi/combined forms as spoken.",
    },
    "Tamil": {
        "bcp47": "ta-IN",
        "script": "Tamil",
        "script_rules": "Distinguish short and long vowels accurately.",
    },
    "Kannada": {
        "bcp47": "kn-IN",
        "script": "Kannada",
        "script_rules": "Preserve agglutinated/combined forms as spoken.",
    },
    "Malayalam": {
        "bcp47": "ml-IN",
        "script": "Malayalam",
        "script_rules": "Don't split agglutinated words. Preserve chillu letters.",
    },
    "Gujarati": {
        "bcp47": "gu-IN",
        "script": "Gujarati",
        "script_rules": "",
    },
    "Punjabi": {
        "bcp47": "pa-IN",
        "script": "Gurmukhi",
        "script_rules": "",
    },
    "Bengali": {
        "bcp47": "bn-IN",
        "script": "Bengali",
        "script_rules": "Preserve Chandrabindu for nasalization where spoken.",
    },
    "Assamese": {
        "bcp47": "as-IN",
        "script": "Assamese",
        # Critical: these chars look like Bengali but are Assamese-specific.
        "script_rules": "Use Assamese-specific ৰ (ra) and ৱ (wa), NOT Bengali equivalents.",
    },
    "Odia": {
        "bcp47": "or-IN",
        "script": "Odia",
        "script_rules": "",
    },
    "English": {
        "bcp47": "en-IN",
        "script": "Latin",
        "script_rules": "Standard English spelling. Don't phonetically approximate accents.",
    },
}

class SpeakerMeta(BaseModel):
    """Speaker metadata for TTS training. Only what matters."""
    emotion: str = Field(
        default="neutral",
        description="neutral, happy, sad, angry, excited, surprised"
    )
    speaking_style: str = Field(
        default="conversational",
        description="conversational, narrative, excited, calm, emphatic, sarcastic, formal"
    )
    pace: str = Field(
        default="normal",
        description="slow, normal, fast"
    )
    accent: str = Field(
        default="",
        description="Regional accent/dialect if detectable, empty string if unknown"
    )

class TranscriptionOutput(BaseModel):
    """Structured output: 4 transcription formats + speaker metadata."""

    transcription: str = Field(
        description="Native script transcription with minimal punctuation. "
                    "English words transliterated phonetically in native script."
    )

    code_switch: str = Field(
        description="Mixed script: native language in native script, "
                    "English words in Latin script. Minimal punctuation."
    )

    romanized: str = Field(
        description="Full Roman/Latin script transliteration as pronounced."
    )

    tagged: str = Field(
        description="Code-switch transcription with audio event tags "
                    "([laugh], [cough], [sigh], [breath], [singing], "
                    "[noise], [music], [applause]) at positions where they occur."
    )

    speaker: Optional[SpeakerMeta] = Field(
        default=None,
        description="Speaker metadata: emotion, style, pace, accent."
    )

class TranscriptionResult(BaseModel):
    """Complete result for a transcribed segment including metadata."""

    segment_id: str = Field(description="Identifier for the audio segment")
    chunk_index: int = Field(default=0, description="Chunk index if segment was split")
    total_chunks: int = Field(default=1, description="Total chunks for this segment")
    duration_sec: float = Field(description="Duration of audio in seconds")
    language: str = Field(description="Primary language of the audio")

    transcription: TranscriptionOutput = Field(description="The transcription outputs")

    model_used: str = Field(description="Gemini model used for transcription")
    thinking_level: Optional[str] = Field(default=None, description="Thinking level used")
    processing_time_sec: Optional[float] = Field(default=None, description="API call time")

    # Validation fields (filled by CTC validator, not Gemini)
    validation_status: Optional[str] = Field(default=None, description="accept/review/reject")
    validation_score: Optional[float] = Field(default=None, description="Alignment score 0-1")

    @property
    def native(self) -> str:
        """Shortcut to primary native transcription."""
        return self.transcription.transcription

# === Prompt v3 ===
# Master template: one prompt for all 12 languages, with dynamic language pack.
# Evolution: v1 (original) -> v2 (strict, fixed segment 3 translation) -> v3 (below)
# v3 changes: translate/transliterate fix, field derivation ordering, [UNK] tokens,
#   prosody-based punctuation, simplified language script tips, no-preamble constraint,
#   "not assistant" negation, additionalProperties in schema.

def get_transcription_prompt(language: str) -> str:
    """
    System instruction for Gemini transcription.

    Uses master template with dynamic language pack injection.
    Key design: "transcription" field is authoritative, all others derive from it.
    """
    # Language pack (fallback to generic if unknown language)
    lang_config = LANGUAGE_CONFIGS.get(language, {})
    script_name = lang_config.get("script", f"{language} native")
    bcp47 = lang_config.get("bcp47", "")
    script_rules = lang_config.get("script_rules", "")

    # Build optional script rules section
    script_section = ""
    if script_rules:
        script_section = (
            f"\nSCRIPT RULES FOR {language.upper()}:\n{script_rules}\n"
        )

    # English has different field logic (no transliteration needed)
    if language.lower() == "english":
        native_field_rule = (
            "Write in standard English spelling."
        )
        code_switch_rule = (
            "Same as transcription. If the speaker uses words from another "
            "language, write them in Latin script as heard."
        )
    else:
        # The transliterate vs translate distinction is critical here.
        # v2 prompt had a contradiction: "don't convert English to {language}"
        # + "write English phonetically in {language} script". The word "convert"
        # was ambiguous and caused segment 3 to be translated instead of transcribed.
        native_field_rule = (
            f"Write ONLY in {script_name} script. No Latin characters in this field.\n"
            f"   When the speaker uses English words, TRANSLITERATE them phonetically "
            f"into {script_name} (sound mapping, NOT meaning translation).\n"
            f"   Example: spoken 'computer' → phonetic form in {script_name}, "
            f"NOT the {language} word for computer."
        )
        code_switch_rule = (
            f"Preserve language switching exactly as spoken.\n"
            f"   {language} words stay in {script_name} script. "
            f"English words stay in Latin script.\n"
            f"   Do NOT translate in either direction."
        )

    lang_label = f"{language} ({bcp47})" if bcp47 else language

    return f"""You are a verbatim speech-to-text transcription system. You are NOT a conversational assistant. Your output must precisely match the audio content.

TARGET: {lang_label}

CRITICAL RULES (violations cause rejection):
1. NEVER TRANSLATE. This is transcription, not translation. If the speaker says English words, those are English. If the speaker says {language} words, those are {language}.
2. VERBATIM FIDELITY: Every repetition, filler, stammer, false start, hesitation - exactly as spoken.
3. NO CORRECTION: Do not fix grammar, pronunciation, dialect, or word choice.
4. NO HALLUCINATION: Never add words or phrases not in the audio. If audio cuts off mid-sentence, STOP where the audio stops. Do not complete anything. Do not add conversational preamble like "Sure, here is..." - output ONLY the JSON.
5. UNCERTAINTY: If a word is unclear, write [UNK]. Use [INAUDIBLE] when speech exists but is unintelligible. Use [NO_SPEECH] when the segment contains no speech at all (silence, noise, music only).
6. BOUNDARY HANDLING: Audio is VAD-cut and may start/end mid-speech. Transcribe everything you can confidently hear, including speech at boundaries. Only omit what is truly inaudible.

PUNCTUATION (prosody-based, not grammar):
- Only: comma, period, ? and !
- Insert from audible pauses/intonation only. No pause = no punctuation.
{script_section}
FIELD DERIVATION:
"transcription" is the PRIMARY output. code_switch and romanized must contain the same words in the same order, differing only in script. "tagged" is code_switch with audio event markers inserted at their positions. Do not re-interpret the audio independently for each field.

OUTPUT FIELDS:

1. transcription (AUTHORITATIVE - native script)
   {native_field_rule}
   Punctuation: period, comma, ? and ! only, based on audible prosodic cues.

2. code_switch (derived from transcription - mixed script)
   {code_switch_rule}

3. romanized (derived from transcription - Latin script)
   Full Roman/Latin transliteration of the entire audio as pronounced.
   Preserve pronunciation faithfully even if spelling looks non-standard.

4. tagged (derived from code_switch - mixed script + event tags)
   Same text as code_switch with audio event tags at positions where they occur.
   ONLY these tags, ONLY if clearly and prominently audible (never hallucinate):
   [laugh] [cough] [sigh] [breath] [singing] [noise] [music] [applause]

5. speaker (metadata from audio prosody)
   emotion: neutral | happy | sad | angry | excited | surprised
   speaking_style: conversational | narrative | excited | calm | emphatic | sarcastic | formal
   pace: slow | normal | fast
   accent: regional dialect/accent if confidently detectable, empty string otherwise. Use defaults if unsure."""

def get_user_prompt() -> str:
    """User prompt to accompany the audio."""
    return ("Transcribe this audio segment following the system instructions. "
            "Return a valid JSON object with all required fields.")

# === JSON Schema for Gemini structured output ===
# Uses response_json_schema (JSON Schema path), NOT response_schema (OpenAPI path).
# additionalProperties: False is supported on the JSON Schema path.
# Enums mechanically lock speaker values at the API level (not just prompt-level).
# Gemini structured output enforces schema shape but NOT semantic correctness -
# downstream CTC validation still required.

TRANSCRIPTION_JSON_SCHEMA = {
    "type": "object",
    "properties": {
        "transcription": {
            "type": "string",
            "description": "Native script transcription with minimal punctuation"
        },
        "code_switch": {
            "type": "string",
            "description": "Mixed script preserving language switching"
        },
        "romanized": {
            "type": "string",
            "description": "Complete Roman script transliteration"
        },
        "tagged": {
            "type": "string",
            "description": "Code-switch with audio event tags"
        },
        "speaker": {
            "type": "object",
            "description": "Speaker metadata",
            "properties": {
                "emotion": {
                    "type": "string",
                    "enum": [
                        "neutral", "happy", "sad",
                        "angry", "excited", "surprised"
                    ]
                },
                "speaking_style": {
                    "type": "string",
                    "enum": [
                        "conversational", "narrative", "excited",
                        "calm", "emphatic", "sarcastic", "formal"
                    ]
                },


**Inspecting analysis results**


Output:
Exit code: 0
Wall time: 0 seconds
Output:
total 332
drwxrwxr-x  2 ubuntu ubuntu   4096 Feb  5 01:05 .
drwxrwxr-x 14 ubuntu ubuntu   4096 Feb 12 23:23 ..
-rw-rw-r--  1 ubuntu ubuntu 242996 Feb  5 00:29 final_analysis.json
-rw-rw-r--  1 ubuntu ubuntu  85393 Feb  5 01:05 scoring_results.json


**Examining scoring results thresholds**


I’ve moved from code structure to actual measured outputs in `analysis_results/` so the score can be evidence-backed. I’ll extract acceptance rates, score distributions, and model deltas next.


jq 'keys' analysis_results/scoring_results.json | sed -n '1,120p'


jq '.' analysis_results/scoring_results.json | sed -n '1,260p'


Output:
Exit code: 0
Wall time: 0 seconds
Output:
[
  "metadata",
  "results",
  "summary"
]


Output:
Exit code: 0
Wall time: 0 seconds
Output:
{
  "metadata": {
    "model": "gemini-3-flash-preview",
    "config": "temp0_low",
    "language": "Telugu",
    "segments_scored": 20,
    "timestamp": "2026-02-05T01:05:40.295126"
  },
  "summary": {
    "mean_alignment_score": 0.91371,
    "mean_confidence": 0.91679
  },
  "results": [
    {
      "segment_id": "SPEAKER_00_0000_0.03-2.61.flac",
      "audio_path": "/tmp/maya3_transcribe/pF_BQpHaIdU/extracted/pF_BQpHaIdU/segments/SPEAKER_00_0000_0.03-2.61.flac",
      "duration_sec": 2.582,
      "transcription": "నాకు కొన్ని యాడ్స్ గుర్తుండేవి లైక్ ఎగ్జాంపుల్ కచ్చా మ్యాంగో బైట్",
      "punctuated": "నాకు కొన్ని యాడ్స్ గుర్తుండేవి, లైక్ ఎగ్జాంపుల్ కచ్చా మ్యాంగో బైట్.",
      "scoring": {
        "audio_path": "/tmp/maya3_transcribe/pF_BQpHaIdU/extracted/pF_BQpHaIdU/segments/SPEAKER_00_0000_0.03-2.61.flac",
        "transcription": "నాకు కొన్ని యాడ్స్ గుర్తుండేవి లైక్ ఎగ్జాంపుల్ కచ్చా మ్యాంగో బైట్",
        "alignment_score": 0.8449,
        "average_confidence": 0.8491,
        "min_confidence": 0.7938,
        "word_count": 9,
        "word_scores": [
          {
            "word": "నాకు",
            "start_time": 0,
            "end_time": 0.161,
            "confidence": 0.8919
          },
          {
            "word": "కొన్ని",
            "start_time": 0.161,
            "end_time": 0.424,
            "confidence": 0.8626
          },
          {
            "word": "యాడ్స్",
            "start_time": 0.424,
            "end_time": 0.686,
            "confidence": 0.8771
          },
          {
            "word": "గుర్తుండేవి",
            "start_time": 0.686,
            "end_time": 1.17,
            "confidence": 0.8005
          },
          {
            "word": "లైక్",
            "start_time": 1.17,
            "end_time": 1.331,
            "confidence": 0.8114
          },
          {
            "word": "ఎగ్జాంపుల్",
            "start_time": 1.331,
            "end_time": 1.775,
            "confidence": 0.8438
          },
          {
            "word": "కచ్చా",
            "start_time": 1.775,
            "end_time": 1.997,
            "confidence": 0.8758
          },
          {
            "word": "మ్యాంగో",
            "start_time": 1.997,
            "end_time": 2.299,
            "confidence": 0.7938
          },
          {
            "word": "బైట్",
            "start_time": 2.299,
            "end_time": 2.461,
            "confidence": 0.8849
          }
        ],
        "audio_duration_sec": 2.582,
        "processing_time_sec": 0.318,
        "method": "ctc",
        "language": "te"
      }
    },
    {
      "segment_id": "SPEAKER_00_0107_1343.96-1350.94.flac",
      "audio_path": "/tmp/maya3_transcribe/pF_BQpHaIdU/extracted/pF_BQpHaIdU/segments/SPEAKER_00_0107_1343.96-1350.94.flac",
      "duration_sec": 6.973,
      "transcription": "ఇప్పుడు మీరు పాపిన్స్ గురించి ఇంకా కిస్మి గురించి చెప్తున్నప్పుడు రెండిటి గురించి రకరకాల స్టోరీస్ ఉన్నాయి అవును నాకు స్కూల్లో ఉన్నప్పుడు ఐ గాట్ ఏ డేర్ ట్రూత్ ఆర్ డేర్ లో",
      "punctuated": "ఇప్పుడు మీరు పాపిన్స్ గురించి ఇంకా కిస్మి గురించి చెప్తున్నప్పుడు, రెండిటి గురించి రకరకాల స్టోరీస్ ఉన్నాయి. అవును, నాకు స్కూల్లో ఉన్నప్పుడు ఐ గాట్ ఏ డేర్, ట్రూత్ ఆర్ డేర్ లో.",
      "scoring": {
        "audio_path": "/tmp/maya3_transcribe/pF_BQpHaIdU/extracted/pF_BQpHaIdU/segments/SPEAKER_00_0107_1343.96-1350.94.flac",
        "transcription": "ఇప్పుడు మీరు పాపిన్స్ గురించి ఇంకా కిస్మి గురించి చెప్తున్నప్పుడు రెండిటి గురించి రకరకాల స్టోరీస్ ఉన్నాయి అవును నాకు స్కూల్లో ఉన్నప్పుడు ఐ గాట్ ఏ డేర్ ట్రూత్ ఆర్ డేర్ లో",
        "alignment_score": 0.8949,
        "average_confidence": 0.8995,
        "min_confidence": 0.7618,
        "word_count": 25,
        "word_scores": [
          {
            "word": "ఇప్పుడు",
            "start_time": 0,
            "end_time": 0.321,
            "confidence": 0.906
          },
          {
            "word": "మీరు",
            "start_time": 0.321,
            "end_time": 0.501,
            "confidence": 0.9452
          },
          {
            "word": "పాపిన్స్",
            "start_time": 0.501,
            "end_time": 0.882,
            "confidence": 0.9045
          },
          {
            "word": "గురించి",
            "start_time": 0.882,
            "end_time": 1.202,
            "confidence": 0.8767
          },
          {
            "word": "ఇంకా",
            "start_time": 1.202,
            "end_time": 1.383,
            "confidence": 0.9318
          },
          {
            "word": "కిస్మి",
            "start_time": 1.383,
            "end_time": 1.663,
            "confidence": 0.812
          },
          {
            "word": "గురించి",
            "start_time": 1.663,
            "end_time": 1.984,
            "confidence": 0.9431
          },
          {
            "word": "చెప్తున్నప్పుడు",
            "start_time": 1.984,
            "end_time": 2.705,
            "confidence": 0.9283
          },
          {
            "word": "రెండిటి",
            "start_time": 2.705,
            "end_time": 3.026,
            "confidence": 0.7987
          },
          {
            "word": "గురించి",
            "start_time": 3.026,
            "end_time": 3.346,
            "confidence": 0.9569
          },
          {
            "word": "రకరకాల",
            "start_time": 3.346,
            "end_time": 3.627,
            "confidence": 0.7868
          },
          {
            "word": "స్టోరీస్",
            "start_time": 3.627,
            "end_time": 4.007,
            "confidence": 0.8224
          },
          {
            "word": "ఉన్నాయి",
            "start_time": 4.007,
            "end_time": 4.328,
            "confidence": 0.8206
          },
          {
            "word": "అవును",
            "start_time": 4.328,
            "end_time": 4.568,
            "confidence": 0.9575
          },
          {
            "word": "నాకు",
            "start_time": 4.568,
            "end_time": 4.749,
            "confidence": 0.9519
          },
          {
            "word": "స్కూల్లో",
            "start_time": 4.749,
            "end_time": 5.129,
            "confidence": 0.8872
          },
          {
            "word": "ఉన్నప్పుడు",
            "start_time": 5.129,
            "end_time": 5.61,
            "confidence": 0.8861
          },
          {
            "word": "ఐ",
            "start_time": 5.61,
            "end_time": 5.65,
            "confidence": 0.9972
          },
          {
            "word": "గాట్",
            "start_time": 5.65,
            "end_time": 5.831,
            "confidence": 0.8811
          },
          {
            "word": "ఏ",
            "start_time": 5.831,
            "end_time": 5.871,
            "confidence": 0.9636
          },
          {
            "word": "డేర్",
            "start_time": 5.871,
            "end_time": 6.051,
            "confidence": 0.9529
          },
          {
            "word": "ట్రూత్",
            "start_time": 6.051,
            "end_time": 6.332,
            "confidence": 0.9992
          },
          {
            "word": "ఆర్",
            "start_time": 6.332,
            "end_time": 6.472,
            "confidence": 0.7618
          },
          {
            "word": "డేర్",
            "start_time": 6.472,
            "end_time": 6.652,
            "confidence": 0.9017
          },
          {
            "word": "లో",
            "start_time": 6.652,
            "end_time": 6.732,
            "confidence": 0.9131
          }
        ],
        "audio_duration_sec": 6.973,
        "processing_time_sec": 0.087,
        "method": "ctc",
        "language": "te"
      }
    },
    {


**Inspecting final analysis data**


jq '.' analysis_results/final_analysis.json | sed -n '1,260p'


Output:
Exit code: 0
Wall time: 0 seconds
Output:
{
  "metadata": {
    "video_id": "pF_BQpHaIdU",
    "language": "Telugu",
    "total_segments": 20,
    "models_tested": [
      "gemini-3-pro-preview",
      "gemini-3-flash-preview",
      "gemini-2.5-pro",
      "gemini-2.5-flash",
      "gemini-2.5-flash-lite"
    ],
    "configs_tested": [
      "temp1_high",
      "temp0_low"
    ],
    "created_at": "2026-02-05T00:29:19.723735",
    "notes": {
      "temp1_high": "Temperature=1.0 with thinking_level=high (Gemini 3) or default (2.5)",
      "temp0_low": "Temperature=0.0 with thinking_level=low (Gemini 3) or default (2.5)",
      "thinking_budget": "For Gemini 3 with temp=0, use thinking_budget=300 to prevent loops"
    }
  },
  "summary": {
    "speed_ranking": [
      {
        "model": "gemini-3-flash-preview",
        "temp1_high_avg_sec": 14.27,
        "temp0_low_avg_sec": 2.8
      },
      {
        "model": "gemini-2.5-flash",
        "temp1_high_avg_sec": 4.48,
        "temp0_low_avg_sec": 3.71
      },
      {
        "model": "gemini-3-pro-preview",
        "temp1_high_avg_sec": 30.17,
        "temp0_low_avg_sec": 6.76
      },
      {
        "model": "gemini-2.5-flash-lite",
        "temp1_high_avg_sec": 1.85,
        "temp0_low_avg_sec": 8.1
      },
      {
        "model": "gemini-2.5-pro",
        "temp1_high_avg_sec": 9.26,
        "temp0_low_avg_sec": 8.36
      }
    ],
    "recommendations": {
      "high_volume_production": "gemini-2.5-flash-lite (fastest, 1.5-2s avg)",
      "balanced_quality_speed": "gemini-2.5-flash (good quality, 3-4s avg)",
      "maximum_quality": "gemini-3-pro-preview (best quality, use thinking_budget=300 with temp=0)",
      "settings_notes": {
        "gemini_3_models": "Use temp=0 with thinking_budget=300 to prevent loops",
        "gemini_2.5_models": "temp=0 works fine, no thinking_budget needed"
      }
    }
  },
  "segments": [
    {
      "segment_id": "SPEAKER_00_0000_0.03-2.61.flac",
      "audio_path": "/tmp/maya3_transcribe/pF_BQpHaIdU/extracted/pF_BQpHaIdU/segments/SPEAKER_00_0000_0.03-2.61.flac",
      "duration_sec": 2.582,
      "models": {
        "gemini-3-pro-preview": {
          "temp1_high": {
            "native": "వస్తాదన్నమాట నాకు కొన్ని యాడ్స్ గుర్తుండేవి లైక్ ఎగ్జాంపుల్ కచ్చ మ్యాంగో బైట్",
            "punctuated": "వస్తాదన్నమాట. నాకు కొన్ని యాడ్స్ గుర్తుండేవి, లైక్ ఎగ్జాంపుల్ కచ్చ మ్యాంగో బైట్.",
            "code_switch": "వస్తాదన్నమాట. నాకు కొన్ని ads గుర్తుండేవి like example kaccha mango bite.",
            "romanized": "vasthadhannamata. naaku konni ads gurthundevi like example kaccha mango bite.",
            "time_sec": 20.036017417907715
          },
          "temp0_low": {
            "native": "నాకు కొన్ని యాడ్స్ గుర్తుండేవి లైక్ ఎగ్జాంపుల్ కచ్చా మ్యాంగో బైట్",
            "punctuated": "నాకు కొన్ని యాడ్స్ గుర్తుండేవి, లైక్ ఎగ్జాంపుల్ కచ్చా మ్యాంగో బైట్.",
            "code_switch": "నాకు కొన్ని ads గుర్తుండేవి, like example Kachcha Mango Bite.",
            "romanized": "naaku konni ads gurthundevi, like example kachcha mango bite.",
            "time_sec": 6.039599418640137
          }
        },
        "gemini-3-flash-preview": {
          "temp1_high": {
            "native": "నాకు కొన్ని యాడ్స్ గుర్తుంటాయి లైక్ ఎగ్జాంపుల్ కచ్చా మ్యాంగో బైట్",
            "punctuated": "నాకు కొన్ని యాడ్స్ గుర్తుంటాయి, లైక్ ఎగ్జాంపుల్ కచ్చా మ్యాంగో బైట్।",
            "code_switch": "నాకు కొన్ని ads గుర్తుంటాయి like example kaccha mango bite",
            "romanized": "naku konni ads gurtuntayi like example kaccha mango bite",
            "time_sec": 14.19179916381836
          },
          "temp0_low": {
            "native": "నాకు కొన్ని యాడ్స్ గుర్తుండేవి లైక్ ఎగ్జాంపుల్ కచ్చా మ్యాంగో బైట్",
            "punctuated": "నాకు కొన్ని యాడ్స్ గుర్తుండేవి, లైక్ ఎగ్జాంపుల్ కచ్చా మ్యాంగో బైట్.",
            "code_switch": "నాకు కొన్ని ads గుర్తుండేవి like example కచ్చా mango bite.",
            "romanized": "naaku konni ads gurtundevi like example kaccha mango bite",
            "time_sec": 4.253249168395996
          }
        },
        "gemini-2.5-pro": {
          "temp1_high": {
            "native": "అన్నమాట నాకు కొన్ని యాడ్స్ గుర్తుంటాయి లైక్ ఫర్ ఎక్సామ్పుల్ కచ్చా బాదం",
            "punctuated": "అన్నమాట, నాకు కొన్ని యాడ్స్ గుర్తుంటాయి, లైక్ ఫర్ ఎక్సామ్పుల్ కచ్చా బాదం।",
            "code_switch": "అన్నమాట, నాకు కొన్ని ads గుర్తుంటాయి, like for example కచ్చా బాదం।",
            "romanized": "annamaata, naaku konni ads gurtuntaayi, like for example kacha badam.",
            "time_sec": 10.94771671295166
          },
          "temp0_low": {
            "native": "నాకు కొన్ని యాడ్స్ గుర్తుంటాయి లైక్ ఎగ్జాంపుల్ కచ్చా బాదం",
            "punctuated": "నాకు కొన్ని యాడ్స్ గుర్తుంటాయి, లైక్ ఎగ్జాంపుల్ కచ్చా బాదం।",
            "code_switch": "నాకు కొన్ని ads గుర్తుంటాయి, like example కచ్చా బాదం।",
            "romanized": "naaku konni ads gurtuntaayi, like example kachcha badam.",
            "time_sec": 5.625661134719849
          }
        },
        "gemini-2.5-flash": {
          "temp1_high": {
            "native": "అన్నమాట నా కొన్ని యాడ్స్ గుర్తొస్తాయి లైక్ ఎగ్జాంపుల్ కచ్చా",
            "punctuated": "అన్నమాట. నా కొన్ని యాడ్స్ గుర్తొస్తాయి లైక్, ఎగ్జాంపుల్ కచ్చా.",
            "code_switch": "అన్నమాట. నా కొన్ని ads గుర్తొస్తాయి like, example కచ్చా.",
            "romanized": "annamaata. naa konni ads gurthostayi like, example kaccha.",
            "time_sec": 7.380277395248413
          },
          "temp0_low": {
            "native": "అన్నమాట నాకు కొన్ని యాడ్స్ గుర్తొస్తాయి లైక్ ఎగ్జాంపుల్ కచ్చా",
            "punctuated": "అన్నమాట. నాకు కొన్ని యాడ్స్ గుర్తొస్తాయి లైక్ ఎగ్జాంపుల్ కచ్చా.",
            "code_switch": "అన్నమాట నాకు కొన్ని ads గుర్తొస్తాయి like example కచ్చా",
            "romanized": "annamaata naaku konni ads gurthosthaayi like example kachchaa",
            "time_sec": 3.615159034729004
          }
        },
        "gemini-2.5-flash-lite": {
          "temp1_high": {
            "native": "అన్నమాట నా కొన్ని యాడ్స్ గుర్తుంటాయి లైక్ ఉదాహరణకు కచ్చా",
            "punctuated": "అన్నమాట। నా కొన్ని యాడ్స్ గుర్తుంటాయి లైక్ ఉదాహరణకు కచ్చా",
            "code_switch": "అన్నమాట నా కొన్ని యాడ్స్ గుర్తుంటాయి లైక్ ఉదాహరణకు కచ్చా",
            "romanized": "annamaata na konni ads gurtuntayi like udaaharanaku kachcha",
            "time_sec": 2.2073280811309814
          },
          "temp0_low": {
            "native": "అన్నమాట నా కొన్ని యాడ్స్ గుర్తుంటాయి లైక్ ఎగ్జాంపుల్ కచ్చాబాదం అన్నమాట",
            "punctuated": "అన్నమాట। నా కొన్ని యాడ్స్ గుర్తుంటాయి లైక్ ఎగ్జాంపుల్ కచ్చాబాదం అన్నమాట",
            "code_switch": "అన్నమాట నా కొన్ని యాడ్స్ గుర్తుంటాయి లైక్ ఎగ్జాంపుల్ కచ్చాబాదం అన్నమాట",
            "romanized": "annamaata na konni ads gurtuntaayi like example kachchabaadam annamaata",
            "time_sec": 1.9971544742584229
          }
        }
      },
      "validation": {
        "indicmfa": {
          "transcription": "అన్నమాట నా కొన్ని యాడ్స్ గుర్తుంటాయి లైక్ ఉదాహరణకు కచ్చా",
          "confidence": 0.8448988199234009,
          "word_count": 8,
          "time_sec": 0.0726473331451416
        },
        "indic_conformer": {
          "transcription": "నా కొన్ని యాడ్స్ గుర్తుండే లైక్ ఎగ్జాంపుల్ కచ్చా మ్యాంగో బయట",
          "confidence": null,
          "time_sec": 4.286128044128418
        }
      }
    },
    {
      "segment_id": "SPEAKER_00_0107_1343.96-1350.94.flac",
      "audio_path": "/tmp/maya3_transcribe/pF_BQpHaIdU/extracted/pF_BQpHaIdU/segments/SPEAKER_00_0107_1343.96-1350.94.flac",
      "duration_sec": 6.973,
      "models": {
        "gemini-3-pro-preview": {
          "temp1_high": {
            "native": "ఇప్పుడు మీరు పాపిన్స్ గురించి అని కిస్మీ గురించి చెప్తున్నప్పుడు రెండిటి గురించే రకరకాల స్టోరీస్ ఉంటాయి అవును నాకు స్కూల్లో ఉన్నప్పుడు ఐ గాట్ అ డేర్ ట్రూత్ ఆర్ డేర్ లో ఏదో",
            "punctuated": "ఇప్పుడు మీరు పాపిన్స్ గురించి అని కిస్మీ గురించి చెప్తున్నప్పుడు, రెండిటి గురించే రకరకాల స్టోరీస్ ఉంటాయి. అవును. నాకు స్కూల్లో ఉన్నప్పుడు, ఐ గాట్ అ డేర్. ట్రూత్ ఆర్ డేర్ లో ఏదో.",
            "code_switch": "ఇప్పుడు మీరు Poppins గురించి అని Kismi గురించి చెప్తున్నప్పుడు, రెండిటి గురించే రకరకాల stories ఉంటాయి. అవును. నాకు schoolలో ఉన్నప్పుడు, I got a dare. Truth or dareలో ఏదో.",
            "romanized": "ippudu meeru poppins gurinchi ani kismi gurinchi chepthunnappudu, renditi gurinche rakarakala stories untayi. avunu. naaku schoollo unnappudu, i got a dare. truth or dare lo edho.",
            "time_sec": 26.40731191635132
          },
          "temp0_low": {
            "native": "ఇప్పుడు మీరు పాపిన్స్ గురించి అని కిస్మి గురించి చెప్తున్నప్పుడు రెండిటి గురించి రకరకాల స్టోరీస్ ఉండేవి అవును నాకు స్కూల్లో ఉన్నప్పుడు ఐ గాట్ ఎ డేర్ ట్రూత్ ఆర్ డేర్ లో",
            "punctuated": "ఇప్పుడు మీరు పాపిన్స్ గురించి అని, కిస్మి గురించి చెప్తున్నప్పుడు, రెండిటి గురించి రకరకాల స్టోరీస్ ఉండేవి. అవును. నాకు స్కూల్లో ఉన్నప్పుడు ఐ గాట్ ఎ డేర్, ట్రూత్ ఆర్ డేర్ లో.",
            "code_switch": "ఇప్పుడు మీరు Poppins గురించి అని Kissmi గురించి చెప్తున్నప్పుడు, రెండిటి గురించి రకరకాల stories ఉండేవి. అవును. నాకు school లో ఉన్నప్పుడు I got a dare, truth or dare లో.",
            "romanized": "ippudu meeru poppins gurinchi ani kissmi gurinchi cheptunnappudu, renditi gurinchi rakarakala stories undevi. avunu. naaku school lo unnappudu i got a dare, truth or dare lo.",
            "time_sec": 6.233404159545898
          }
        },
        "gemini-3-flash-preview": {
          "temp1_high": {
            "native": "ఇప్పుడు మీరు పాపిన్స్ గురించి అండ్ కిస్మిస్ గురించి చెప్తున్నప్పుడు రెండిటి గురించి రకరకాల స్టోరీస్ ఉన్నాయి అవును నాకు స్కూల్ లో ఉన్నప్పుడు ఐ గాట్ ఏ డేర్ ట్రూత్ ఆర్ డేర్ లో",
            "punctuated": "ఇప్పుడు మీరు పాపిన్స్ గురించి అండ్ కిస్మిస్ గురించి చెప్తున్నప్పుడు, రెండిటి గురించి రకరకాల స్టోరీస్ ఉన్నాయి. అవును. నాకు స్కూల్ లో ఉన్నప్పుడు ఐ గాట్ ఏ డేర్, ట్రూత్ ఆర్ డేర్ లో.",
            "code_switch": "ఇప్పుడు మీరు Poppins గురించి and Kismiss గురించి చెప్తున్నప్పుడు, రెండిటి గురించి రకరకాల stories ఉన్నాయి. అవును. నాకు school లో ఉన్నప్పుడు I got a dare, truth or dare లో.",
            "romanized": "ippudu meeru poppins gurinchi and kismiss gurinchi cheptunnappudu renditi gurinchi rakkarakala stories unnayi avunu naaku school lo unnappudu i got a dare truth or dare lo",
            "time_sec": 17.55294895172119
          },
          "temp0_low": {
            "native": "ఇప్పుడు మీరు పాపిన్స్ గురించి ఇంకా కిస్మి గురించి చెప్తున్నప్పుడు రెండిటి గురించి రకరకాల స్టోరీస్ ఉన్నాయి అవును నాకు స్కూల్లో ఉన్నప్పుడు ఐ గాట్ ఏ డేర్ ట్రూత్ ఆర్ డేర్ లో",
            "punctuated": "ఇప్పుడు మీరు పాపిన్స్ గురించి ఇంకా కిస్మి గురించి చెప్తున్నప్పుడు, రెండిటి గురించి రకరకాల స్టోరీస్ ఉన్నాయి. అవును, నాకు స్కూల్లో ఉన్నప్పుడు ఐ గాట్ ఏ డేర్, ట్రూత్ ఆర్ డేర్ లో.",
            "code_switch": "ఇప్పుడు మీరు Poppins గురించి ఇంకా Kismi గురించి చెప్తున్నప్పుడు, రెండిటి గురించి రకరకాల stories ఉన్నాయి. అవును, నాకు school లో ఉన్నప్పుడు I got a dare, Truth or Dare లో.",
            "romanized": "ippudu meeru poppins gurinchi inka kismi gurinchi cheptunnappudu renditi gurinchi rakarakala stories unnaayi avunu naaku school lo unnappudu i got a dare truth or dare lo",
            "time_sec": 2.759925127029419
          }
        },
        "gemini-2.5-pro": {
          "temp1_high": {
            "native": "ఇప్పుడు మీరు పాపిన్స్ గురించి అండ్ కిస్మి గురించి చెప్తున్నప్పుడు రెండిటి గురించి రకరకాల స్టోరీస్ ఉండేవి నాకు స్కూల్ లో ఉన్నప్పుడు ఐ గాట్ ఏ డేర్ ట్రూత్ ఆర్ డేర్",
            "punctuated": "ఇప్పుడు మీరు పాపిన్స్ గురించి అండ్ కిస్మి గురించి చెప్తున్నప్పుడు, రెండిటి గురించి రకరకాల స్టోరీస్ ఉండేవి। నాకు స్కూల్ లో ఉన్నప్పుడు, ఐ గాట్ ఏ డేర్, ట్రూత్ ఆర్ డేర్।",
            "code_switch": "ఇప్పుడు మీరు poppins గురించి and కిస్మి గురించి చెప్తున్నప్పుడు, రెండిటి గురించి రకరకాల stories ఉండేవి। నాకు school లో ఉన్నప్పుడు, I got a dare, truth or dare।",
            "romanized": "ippudu meeru poppins gurinchi and kismi gurinchi cheptunnappudu, renditi gurinchi rakarakaala stories undevi. naaku school lo unnappudu, I got a dare, truth or dare.",
            "time_sec": 8.21396517753601
          },
          "temp0_low": {
            "native": "ఇప్పుడు మీరు పాపిన్స్ గురించి కిస్మి గురించి చెప్తున్నప్పుడు రెండిటి గురించి రకరకాల స్టోరీస్ ఉండేవి అవును నాకు స్కూల్లో ఉన్నప్పుడు ఐ గాట్ ఏ డేర్ ట్రూత్ ఆర్ డేర్",
            "punctuated": "ఇప్పుడు మీరు పాపిన్స్ గురించి, కిస్మి గురించి చెప్తున్నప్పుడు, రెండిటి గురించి రకరకాల స్టోరీస్ ఉండేవి। అవును। నాకు స్కూల్లో ఉన్నప్పుడు ఐ గాట్ ఏ డేర్, ట్రూత్ ఆర్ డేర్।",
            "code_switch": "ఇప్పుడు మీరు Poppins గురించి, Kismee గురించి చెప్తున్నప్పుడు, రెండిటి గురించి రకరకాల stories ఉండేవి। అవును। నాకు schoolలో ఉన్నప్పుడు I got a dare, Truth or Dare.",
            "romanized": "ippudu meeru Poppins gurinchi, Kismee gurinchi chepthunnappudu, renditi gurinchi rakarakaala stories undevi. avunu. naaku schoollo unnappudu I got a dare, Truth or Dare.",
            "time_sec": 8.039685010910034
          }
        },
        "gemini-2.5-flash": {
          "temp1_high": {
            "native": "ఇప్పుడు మీరు పాపెన్స్ గురించి కిస్మి గురించి చెప్తున్నప్పుడు రెండిటి గురించి రకరకాల స్టోరీస్ ఉండే నాకు స్కూల్లో ఉన్నప్పుడు ఐ గాట్ ఏ డేర్ ట్రూత్ ఆర్ డేర్",
            "punctuated": "ఇప్పుడు మీరు పాపెన్స్ గురించి, కిస్మి గురించి చెప్తున్నప్పుడు రెండిటి గురించి రకరకాల స్టోరీస్ ఉండే. నాకు స్కూల్లో ఉన్నప్పుడు ఐ గాట్ ఏ డేర్, ట్రూత్ ఆర్ డేర్.",
            "code_switch": "ఇప్పుడు మీరు పాపెన్స్ గురించి కిస్మి గురించి చెప్తున్నప్పుడు రెండిటి గురించి రకరకాల stories ఉండే. నాకు స్కూల్లో ఉన్నప్పుడు I got a dare, truth or dare.",
            "romanized": "ippuḍu mīru poppens gurinchi kismi gurinchi cheptunnappuḍu renḍiṭi gurinchi rakarākala stories unḍe. nāku school lo unnappuḍu I got a dare, truth or dare.",
            "time_sec": 6.689608097076416
          },
          "temp0_low": {
            "native": "ఇప్పుడు మీరు పాపిన్స్ గురించి కెస్మీ గురించి చెప్తున్నప్పుడు రెండిటి గురించి రకరకాల స్టోరీస్ ఉండేవి అవును నాకు స్కూల్లో ఉన్నప్పుడు ఐ గాట్ ఏ డేర్ ట్రూత్ ఆర్ డేర్",
            "punctuated": "ఇప్పుడు మీరు పాపిన్స్ గురించి, కెస్మీ గురించి చెప్తున్నప్పుడు రెండిటి గురించి రకరకాల స్టోరీస్ ఉండేవి. అవును, నాకు స్కూల్లో ఉన్నప్పుడు ఐ గాట్ ఏ డేర్, ట్రూత్ ఆర్ డేర్.",
            "code_switch": "ఇప్పుడు మీరు Popins గురించి Kesmi గురించి చెప్తున్నప్పుడు రెండిటి గురించి రకరకాల స్టోరీస్ ఉండేవి. అవును, నాకు స్కూల్లో ఉన్నప్పుడు I got a dare, Truth or Dare.",
            "romanized": "Ippudu meeru Popins gurinchi Kesmi gurinchi cheptunnappudu renditi gurinchi rakarakala stories undevi. Avunu, naaku schoollo unnappudu I got a dare, Truth or Dare.",
            "time_sec": 4.72485876083374
          }
        },
        "gemini-2.5-flash-lite": {
          "temp1_high": {
            "native": "ఇప్పుడు మీరు పాపిన్స్ గురించి ఐన్ కీస్మి గురించి చెబుతున్నారు అప్పుడు రెండిటి గురించి రకరకాల స్టోరీస్ ఉండేవి అవును నాకు స్కూల్లో ఉన్నప్పుడు ఐ గాట్ ఏ డేర్ ట్రూత్ ఆర్",
            "punctuated": "ఇప్పుడు మీరు పాపిన్స్ గురించి ఐన్ కీస్మి గురించి చెబుతున్నారు అప్పుడు రెండిటి గురించి రకరకాల స్టోరీస్ ఉండేవి। అవును। నాకు స్కూల్లో ఉన్నప్పుడు ఐ గాట్ ఏ డేర్ ట్రూత్ ఆర్",
            "code_switch": "ఇప్పుడు మీరు పాపిన్స్ గురించి ఐన్ కీస్మి గురించి చెబుతున్నారు అప్పుడు రెండిటి గురించి రకరకాల స్టోరీస్ ఉండేవి అవును నాకు స్కూల్లో ఉన్నప్పుడు I got a dare truth or",
            "romanized": "ippudu meeru paapins gurinchi ain kismis gurinchi chebutunnaru appudu renditi gurinchi rakarakala stories undevi avunu naaku school lo unnappudu I got a dare truth or",
            "time_sec": 1.5881335735321045
          },
          "temp0_low": {
            "native": "ఇప్పుడు మీరు పాపిన్స్ గురించి ఐస్ క్రీమ్ గురించి చెబుతున్నారు అప్పుడు రెండింటి గురించి రకరకాల స్టోరీలు ఉండేవి అవును నాకు స్కూల్లో ఉన్నప్పుడు ఐ గాట్ ఏ డేర్ ట్రూత్ ఆర్",
            "punctuated": "ఇప్పుడు మీరు పాపిన్స్ గురించి ఐస్ క్రీమ్ గురించి చెబుతున్నారు అప్పుడు రెండింటి గురించి రకరకాల స్టోరీలు ఉండేవి అవును నాకు స్కూల్లో ఉన్నప్పుడు ఐ గాట్ ఏ డేర్ ట్రూత్ ఆర్",
            "code_switch": "ఇప్పుడు మీరు పాపిన్స్ గురించి ఐస్ క్రీమ్ గురించి చెబుతున్నారు అప్పుడు రెండింటి గురించి రకరకాల స్టోరీలు ఉండేవి అవును నాకు స్కూల్లో ఉన్నప్పుడు I got a dare truth or",
            "romanized": "ippudu meeru papins gurinchi ice cream gurinchi chebutunnaru appudu rendintini gurinchi rakarakala storielu undevi avunu naaku school lo unnappudu I got a dare truth or",
            "time_sec": 1.7781872749328613
          }
        }
      },
      "validation": {
        "indicmfa": {
          "transcription": "ఇప్పుడు మీరు పాపిన్స్ గురించి ఐన్ కీస్మి గురించి చెబుతున్నారు అప్పుడు రెండిటి గురించి రకరకాల స్టోరీస్ ఉండేవి అవును నాకు స్కూల్లో ఉన్నప్పుడు ఐ గాట్ ఏ డేర్ ట్రూత్ ఆర్",
          "confidence": 0.8948860764503479,
          "word_count": 24,
          "time_sec": 0.13230586051940918
        },
        "indic_conformer": {
          "transcription": "నాకు స్కూల్లో ఉన్నప్పుడు ఐ గా డేర్ ట్రూట్ థౌట్ డేర్ అన్న",
          "confidence": null,
          "time_sec": 0.8548033237457275
        }


Summary: auto


**Inspecting evaluation metrics in golden_set**


I’ve confirmed there are benchmark artifacts from February 5, 2026 with multi-model outputs. I’ll now check the `golden_set` evaluation files to anchor the readiness judgment against ground-truth style comparisons, not alignment scores alone.


rg -n "wer|cer|ground|golden|accuracy|f1|precision|recall|accept|retry|reject|threshold" golden_set -S


Output:
Exit code: 0
Wall time: 0 seconds
Output:
total 16
drwxrwxr-x  3 ubuntu ubuntu 4096 Feb 12 23:25 .
drwxrwxr-x 14 ubuntu ubuntu 4096 Feb 12 23:23 ..
-rw-rw-r--  1 ubuntu ubuntu 3614 Feb 12 23:25 calibrate.py
drwxrwxr-x  3 ubuntu ubuntu 4096 Feb 12 23:25 te


Output:
Exit code: 0
Wall time: 0 seconds
Output:
golden_set/calibrate.py:1:"""Golden Set Calibration: validate reference segments to calibrate thresholds.
golden_set/calibrate.py:2:Dir: golden_set/<lang>/segments/*.flac + reference.json
golden_set/calibrate.py:3:Usage: python golden_set/calibrate.py --language te [--run-transcription]"""
golden_set/calibrate.py:8:def compute_cer(ref, hyp):
golden_set/calibrate.py:42:            cer = compute_cer(nr, gn)
golden_set/calibrate.py:44:            results.append({"file":seg["file"],"cer":round(cer,4),"ctc":val.native_ctc_score,"mms":val.roman_mms_score,"S":val.combined_score,"verdict":val.status})
golden_set/calibrate.py:45:            print(f"  {seg['file']}: CER={cer:.2%} S={val.combined_score:.2f} ({val.status})")


Output:
Exit code: 0
Wall time: 0 seconds
Output:
golden_set/calibrate.py
golden_set/te/reference.json


**Reviewing calibration script**


ls -la golden_set/te/segments | sed -n '1,120p'


Output:
Exit code: 0
Wall time: 0 seconds
Output:
"""Golden Set Calibration: validate reference segments to calibrate thresholds.
Dir: golden_set/<lang>/segments/*.flac + reference.json
Usage: python golden_set/calibrate.py --language te [--run-transcription]"""
import json, os, sys, argparse
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.validators import validate_transcription, cleanup

def compute_cer(ref, hyp):
    r, h = list(ref), list(hyp)
    d = [[0]*(len(h)+1) for _ in range(len(r)+1)]
    for i in range(len(r)+1): d[i][0]=i
    for j in range(len(h)+1): d[0][j]=j
    for i in range(1,len(r)+1):
        for j in range(1,len(h)+1):
            cost = 0 if r[i-1]==h[j-1] else 1
            d[i][j] = min(d[i-1][j]+1, d[i][j-1]+1, d[i-1][j-1]+cost)
    return d[len(r)][len(h)] / max(len(r),1)

def calibrate(lc, run_tx=False):
    base = os.path.dirname(__file__)
    ref_path = os.path.join(base, lc, "reference.json")
    seg_dir = os.path.join(base, lc, "segments")
    if not os.path.exists(ref_path):
        print(f"Create {ref_path} first."); return
    if not os.path.isdir(seg_dir):
        os.makedirs(seg_dir, exist_ok=True); print(f"Add segments to {seg_dir}/"); return
    with open(ref_path, encoding="utf-8") as f: ref = json.load(f)
    lang, segs = ref.get("language","Telugu"), ref.get("segments",[])
    print(f"Calibrating {lang} ({lc}): {len(segs)} segments\n")
    results = []
    for seg in segs:
        ap = os.path.join(seg_dir, seg["file"])
        if not os.path.exists(ap): print(f"  SKIP: {seg['file']}"); continue
        nr, rr = seg.get("native",""), seg.get("romanized","")
        if run_tx:
            from src.backend.gemini_transcriber import GeminiTranscriber, TranscriptionConfig
            tx = GeminiTranscriber()
            cfg = TranscriptionConfig(model="gemini-3-flash-preview",thinking_level="low",temperature=0.0,language=lang)
            raw = tx.transcribe_audio(ap, cfg)
            if raw.get("error"): print(f"  ERR: {raw['error']}"); continue
            gn, gr = raw.get("transcription",""), raw.get("romanized","")
            cer = compute_cer(nr, gn)
            val = validate_transcription(ap, gn, romanized_text=gr, language=lc)
            results.append({"file":seg["file"],"cer":round(cer,4),"ctc":val.native_ctc_score,"mms":val.roman_mms_score,"S":val.combined_score,"verdict":val.status})
            print(f"  {seg['file']}: CER={cer:.2%} S={val.combined_score:.2f} ({val.status})")
        else:
            val = validate_transcription(ap, nr, romanized_text=rr, language=lc)
            results.append({"file":seg["file"],"ctc":val.native_ctc_score,"mms":val.roman_mms_score,"S":val.combined_score,"verdict":val.status})
            print(f"  {seg['file']}: S={val.combined_score:.2f} ({val.status}) CTC={val.native_ctc_score:.2f} MMS={val.roman_mms_score:.2f}")
    cleanup()
    if results:
        sc = [r["S"] for r in results]
        vd = {}
        for r in results: vd[r["verdict"]] = vd.get(r["verdict"],0)+1
        print(f"\nSUMMARY: {len(results)} segs | avg={sum(sc)/len(sc):.3f} min={min(sc):.3f} max={max(sc):.3f}")
        print(f"Verdicts: {vd}")
        out = os.path.join(base, lc, "calibration_results.json")
        with open(out,"w",encoding="utf-8") as f: json.dump({"language":lang,"results":results},f,ensure_ascii=False,indent=2)
        print(f"Saved: {out}")

if __name__=="__main__":
    p = argparse.ArgumentParser()
    p.add_argument("--language","-l",default="te")
    p.add_argument("--run-transcription",action="store_true")
    a = p.parse_args()
    calibrate(a.language, a.run_transcription)


Output:
Exit code: 0
Wall time: 0 seconds
Output:
{"language":"Telugu","language_code":"te","segments":[]}


Output:
Exit code: 0
Wall time: 0 seconds
Output:
total 8
drwxrwxr-x 2 ubuntu ubuntu 4096 Feb 12 23:25 .
drwxrwxr-x 3 ubuntu ubuntu 4096 Feb 12 23:25 ..


**Inspecting recent transcription outputs**


There’s currently no populated golden set yet (`reference.json` is empty), so you don’t have calibrated threshold evidence against human references right now. I’ll extract real acceptance/retry/reject behavior from existing transcription outputs to estimate current practical quality and risk.


rg --files transcriptions | sed -n '1,200p'


rg -n "validation_status|validation_score|model|thinking_level" transcriptions -S | sed -n '1,220p'