from __future__ import annotations import json import logging import os import unicodedata from pathlib import Path import polars as pl from .schemas import classify_vowel log = logging.getLogger(__name__) REPO_ROOT = Path(__file__).resolve().parent.parent # Prefer the column-pruned, stress-filtered parquet (~5.5MB) when present — # 16× smaller than the raw CSV (88MB), loads ~10× faster, and ships in the # Docker image / git repo directly. Falls back to the original CSV in dev # when only the upstream file is on disk. DEFAULT_PARQUET_PATH = REPO_ROOT / "all_data_18Nov2023.parquet" DEFAULT_CSV_PATH = REPO_ROOT / "all_data_18Nov2023.csv" DEFAULT_PREFIX_OFFSETS_PATH = REPO_ROOT / "data" / "prefix_offsets.json" GIT_LFS_POINTER_PREFIX = "version https://git-lfs.github.com/spec/v1" # Subset of CSV columns we actually use. Saves memory on the 88MB file. USED_COLUMNS: tuple[str, ...] = ( "Speaker", "filename", "vowel", "word", "stress", "previous_sound", "next_sound", "previous_word", "next_word", "time", "f1", "f2", "f3", "f1_normed", "f2_normed", "f3_normed", "original_order", "Moras", "Syllabification", "aole", "kaika", "maikai", "Kinney1956", "articles", "demons", "particles", "directionals", "pronouns", "interrogatives", "funct", "manawa", "mea", "ae", "start", "word_start", "word_end", ) FUNCTION_WORD_COLUMNS: tuple[str, ...] = ( "aole", "kaika", "maikai", "Kinney1956", "articles", "demons", "particles", "directionals", "pronouns", "interrogatives", "funct", "manawa", "mea", "ae", ) INTERVIEW_URL_SUFFIX = ( "e=-------en-20--1--txt-txIN%7CtxTI%7CtxTA%7CtxCO%7CtxTY%7CtxLA%7CtxKE%7C" "txPR%7CtxSG%7CtxTO%7CtxTG%7CtxSM%7CtxTR%7CtxSP%7CtxCT%7CtxET%7CtxHT" "--------------------" ) SPEAKER_EPISODES: dict[str, str] = { "IN": "013", "RM": "014", "JM": "016", "HM": "021", "LV": "032", "SB": "033", "AA": "057", "DK": "063", } class DataStore: """In-memory dataset. Loaded once at app startup.""" df: pl.DataFrame prefix_offsets: dict[str, float] prefix_offsets_loaded: bool def __init__(self, df: pl.DataFrame, prefix_offsets: dict[str, float], loaded: bool) -> None: self.df = df self.prefix_offsets = prefix_offsets self.prefix_offsets_loaded = loaded def _data_path() -> Path: """Resolve the source file. Honors KLH_DATA_PATH override, otherwise prefers parquet → csv in that order.""" override = os.environ.get("KLH_DATA_PATH") or os.environ.get("KLH_CSV_PATH") if override: return Path(override) if DEFAULT_PARQUET_PATH.exists() and not _is_git_lfs_pointer(DEFAULT_PARQUET_PATH): return DEFAULT_PARQUET_PATH if DEFAULT_PARQUET_PATH.exists(): log.warning( "%s is a Git LFS pointer, not parquet data — falling back to CSV", DEFAULT_PARQUET_PATH, ) return DEFAULT_CSV_PATH def _is_git_lfs_pointer(path: Path) -> bool: if not path.is_file() or path.stat().st_size > 1024: return False try: return path.read_text(errors="ignore").startswith(GIT_LFS_POINTER_PREFIX) except OSError: return False def _prefix_offsets_path() -> Path: override = os.environ.get("KLH_PREFIX_OFFSETS_PATH") return Path(override) if override else DEFAULT_PREFIX_OFFSETS_PATH def normalize_word_query(value: str) -> str: """Normalize user/corpus words for lookup without changing display text.""" normalized = unicodedata.normalize("NFC", value.strip()) normalized = normalized.replace("'", "ʻ").replace("`", "ʻ").replace("’", "ʻ") return normalized.casefold() def _valid_function_columns(columns: list[str] | None) -> list[str]: if not columns: return [] allowed = set(FUNCTION_WORD_COLUMNS) return [col for col in columns if col in allowed] def _function_flags(row: dict) -> dict[str, bool]: return {col: bool(row.get(col) or 0) for col in FUNCTION_WORD_COLUMNS} def load_prefix_offsets(path: Path | None = None) -> tuple[dict[str, float], bool]: """Load prefix → seconds map. Returns ({}, False) when file is missing/invalid. Per CLAUDE.md: do not crash if the file is absent — the jump-link feature should silently disable instead. """ target = path or _prefix_offsets_path() if not target.exists(): log.warning("prefix_offsets.json not found at %s — interview jump-link disabled", target) return {}, False try: raw = json.loads(target.read_text()) if not isinstance(raw, dict): log.error("prefix_offsets.json is not a JSON object — disabling jump-link") return {}, False offsets = {str(k): float(v) for k, v in raw.items()} return offsets, True except (json.JSONDecodeError, ValueError, OSError) as e: log.error("Failed to parse prefix_offsets.json: %s — disabling jump-link", e) return {}, False def load_dataframe(path: Path | None = None) -> pl.DataFrame: """Load the formant data into a Polars DataFrame, keeping only used columns. Reads either parquet (preferred — pre-pruned, pre-filtered, 5.5MB) or the raw CSV (88MB, requires column-subset + stress filter at load time). Adds a stable `token_id` derived from (Speaker, filename, word_start). """ target = path or _data_path() if target.suffix == ".parquet" and _is_git_lfs_pointer(target): if DEFAULT_CSV_PATH.exists(): log.warning("%s is a Git LFS pointer — loading %s instead", target, DEFAULT_CSV_PATH) target = DEFAULT_CSV_PATH else: raise FileNotFoundError( f"{target} is a Git LFS pointer and {DEFAULT_CSV_PATH} is missing" ) log.info("Loading data from %s", target) if target.suffix == ".parquet": df = pl.read_parquet(target) missing = [col for col in USED_COLUMNS if col not in df.columns] if missing and path is None and DEFAULT_CSV_PATH.exists(): log.warning( "%s is missing columns needed by current filters (%s) — loading %s instead", target, ", ".join(missing[:6]) + ("..." if len(missing) > 6 else ""), DEFAULT_CSV_PATH, ) target = DEFAULT_CSV_PATH df = pl.read_csv( target, columns=list(USED_COLUMNS), null_values=["NA", ""], ) before = df.height df = df.filter(pl.col("stress") != "0") log.info("Filtered stress==0: dropped %d rows", before - df.height) else: df = pl.read_csv( target, columns=list(USED_COLUMNS), null_values=["NA", ""], ) # CSV path — apply the stress filter that's already baked into parquet. before = df.height df = df.filter(pl.col("stress") != "0") log.info("Filtered stress==0: dropped %d rows", before - df.height) df = df.with_columns( ( pl.col("Speaker").cast(pl.Utf8) + pl.lit("|") + pl.col("filename").cast(pl.Utf8) + pl.lit("|") + pl.col("word_start").cast(pl.Utf8) ).alias("token_id"), ) df = df.with_columns( pl.col("filename").str.split("_").list.get(0).alias("filename_prefix"), pl.col("word").map_elements(normalize_word_query, return_dtype=pl.Utf8).alias( "word_search_key" ), ) df = df.with_columns( ( pl.col("Speaker").cast(pl.Utf8) + pl.lit("|") + pl.col("filename_prefix").cast(pl.Utf8) + pl.lit("|") + pl.col("word_start").cast(pl.Utf8) + pl.lit("|") + pl.col("word").cast(pl.Utf8) ).alias("word_occurrence_id"), ) log.info("Loaded %d rows, %d unique tokens", df.height, df.select("token_id").n_unique()) return df # Audio file location in the KLHData repo. Verified 2026-04-23. # Use raw.githubusercontent.com directly (the destination of the github.com/raw # 302 redirect) — native