suvradeepp's picture
Publish Tiny Hinglish Turn Detector development preview
35d483e verified
Raw
History Blame Contribute Delete
10.6 kB
"""Canonical record schema and validation for turn-detection datasets.
The upstream smart-turn v3.2 schema uses ``endpoint_bool``, ``midfiller`` and
``endfiller``. In particular, filler values are nullable: ``None`` means that
the example was not annotated for that auxiliary task and must not silently be
converted to ``False``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import math
from typing import Any, Mapping
_ENDPOINT_FIELDS = ("endpoint_bool", "endpoint", "is_endpoint", "label", "target")
_MID_FILLER_FIELDS = ("midfiller", "mid_filler", "is_midfiller")
_END_FILLER_FIELDS = ("endfiller", "end_filler", "is_endfiller")
_SYNTHETIC_FIELDS = ("synthetic", "is_synthetic")
_ID_FIELDS = ("id", "record_id", "example_id", "utterance_id")
_DATASET_FIELDS = ("dataset", "source_dataset", "data_source")
_TRUE_VALUES = {
"1",
"true",
"t",
"yes",
"y",
"end",
"ended",
"endpoint",
"complete",
"completed",
"finished",
"final",
}
_FALSE_VALUES = {
"0",
"false",
"f",
"no",
"n",
"hold",
"continue",
"continuation",
"incomplete",
"not_endpoint",
"non_endpoint",
"nonendpoint",
}
_NULL_VALUES = {"", "na", "n/a", "nan", "none", "null", "unknown", "unlabeled"}
@dataclass(frozen=True)
class ValidationIssue:
"""A machine-readable validation finding."""
severity: str
code: str
message: str
field: str | None = None
def to_dict(self) -> dict[str, str]:
result = {"severity": self.severity, "code": self.code, "message": self.message}
if self.field is not None:
result["field"] = self.field
return result
class RecordValidationError(ValueError):
"""Raised when strict normalization encounters one or more invalid fields."""
def __init__(self, issues: list[ValidationIssue]) -> None:
self.issues = tuple(issues)
summary = "; ".join(f"{item.field or 'record'}: {item.message}" for item in issues)
super().__init__(summary)
@dataclass(frozen=True)
class TurnRecord:
"""Normalized representation of one labeled audio example."""
record_id: str
audio: Any
language: str | None
endpoint: bool | None
midfiller: bool | None
endfiller: bool | None
synthetic: bool | None
dataset: str | None
spoken_text: str | None
source_file: str | None = None
source_row: int | None = None
metadata: Mapping[str, Any] = field(default_factory=dict)
def to_dict(self, *, include_audio: bool = True) -> dict[str, Any]:
"""Convert to a plain mapping, optionally excluding the heavy audio value."""
result: dict[str, Any] = {
"record_id": self.record_id,
"language": self.language,
"endpoint": self.endpoint,
"midfiller": self.midfiller,
"endfiller": self.endfiller,
"synthetic": self.synthetic,
"dataset": self.dataset,
"spoken_text": self.spoken_text,
"source_file": self.source_file,
"source_row": self.source_row,
"metadata": dict(self.metadata),
}
if include_audio:
result["audio"] = self.audio
return result
@dataclass(frozen=True)
class NormalizationResult:
"""A permissively normalized record together with validation findings."""
record: TurnRecord
issues: tuple[ValidationIssue, ...]
@property
def errors(self) -> tuple[ValidationIssue, ...]:
return tuple(item for item in self.issues if item.severity == "error")
@property
def warnings(self) -> tuple[ValidationIssue, ...]:
return tuple(item for item in self.issues if item.severity == "warning")
def normalize_nullable_bool(value: Any, *, field_name: str = "value") -> bool | None:
"""Normalize a nullable boolean without conflating missing and false labels.
Accepted values include booleans, numeric 0/1 and common textual forms. A
null-like value returns ``None``. Other values raise ``ValueError``.
"""
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, float) and math.isnan(value):
return None
if isinstance(value, (int, float)) and value in (0, 1):
return bool(value)
if isinstance(value, str):
normalized = value.strip().casefold().replace("-", "_").replace(" ", "_")
if normalized in _NULL_VALUES:
return None
if normalized in _TRUE_VALUES:
return True
if normalized in _FALSE_VALUES:
return False
raise ValueError(f"{field_name} must be boolean, 0/1, or null; got {value!r}")
def _first_present(record: Mapping[str, Any], names: tuple[str, ...]) -> tuple[str | None, Any]:
for name in names:
if name in record:
return name, record[name]
return None, None
def _optional_text(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _optional_int(value: Any) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def normalize_record_with_issues(record: Mapping[str, Any]) -> NormalizationResult:
"""Normalize a raw row and retain all validation errors and warnings."""
issues: list[ValidationIssue] = []
endpoint_key, endpoint_raw = _first_present(record, _ENDPOINT_FIELDS)
endpoint: bool | None
if endpoint_key is None:
endpoint = None
issues.append(
ValidationIssue("error", "missing_endpoint", "required endpoint label is missing", "endpoint")
)
else:
try:
endpoint = normalize_nullable_bool(endpoint_raw, field_name=endpoint_key)
except ValueError as exc:
endpoint = None
issues.append(ValidationIssue("error", "invalid_endpoint", str(exc), endpoint_key))
if endpoint is None:
issues.append(
ValidationIssue("error", "null_endpoint", "endpoint label cannot be null", endpoint_key)
)
normalized_booleans: dict[str, bool | None] = {}
for canonical, aliases in (
("midfiller", _MID_FILLER_FIELDS),
("endfiller", _END_FILLER_FIELDS),
("synthetic", _SYNTHETIC_FIELDS),
):
key, raw = _first_present(record, aliases)
if key is None:
normalized_booleans[canonical] = None
continue
try:
normalized_booleans[canonical] = normalize_nullable_bool(raw, field_name=key)
except ValueError as exc:
normalized_booleans[canonical] = None
issues.append(ValidationIssue("error", f"invalid_{canonical}", str(exc), key))
id_key, raw_id = _first_present(record, _ID_FIELDS)
source_file = _optional_text(record.get("__source_file", record.get("source_file")))
source_row = _optional_int(record.get("__source_row", record.get("source_row")))
record_id = _optional_text(raw_id)
if record_id is None:
if source_file is not None and source_row is not None:
record_id = f"{source_file}#{source_row}"
else:
record_id = ""
issues.append(ValidationIssue("warning", "missing_id", "record has no explicit id", id_key or "id"))
audio = record.get("audio")
if audio is None:
issues.append(ValidationIssue("error", "missing_audio", "audio value is missing", "audio"))
language = _optional_text(record.get("language", record.get("lang")))
if language is None:
issues.append(ValidationIssue("warning", "missing_language", "language is missing", "language"))
dataset_key, raw_dataset = _first_present(record, _DATASET_FIELDS)
dataset = _optional_text(raw_dataset)
if dataset is None:
issues.append(
ValidationIssue("warning", "missing_dataset", "source dataset is missing", dataset_key or "dataset")
)
spoken_text = _optional_text(record.get("spoken_text", record.get("transcript", record.get("text"))))
consumed = {
"audio",
"language",
"lang",
"spoken_text",
"transcript",
"text",
"__source_file",
"source_file",
"__source_row",
"source_row",
*_ENDPOINT_FIELDS,
*_MID_FILLER_FIELDS,
*_END_FILLER_FIELDS,
*_SYNTHETIC_FIELDS,
*_ID_FIELDS,
*_DATASET_FIELDS,
}
metadata = {key: value for key, value in record.items() if key not in consumed}
normalized = TurnRecord(
record_id=record_id,
audio=audio,
language=language,
endpoint=endpoint,
midfiller=normalized_booleans["midfiller"],
endfiller=normalized_booleans["endfiller"],
synthetic=normalized_booleans["synthetic"],
dataset=dataset,
spoken_text=spoken_text,
source_file=source_file,
source_row=source_row,
metadata=metadata,
)
return NormalizationResult(normalized, tuple(issues))
def normalize_record(record: Mapping[str, Any], *, strict: bool = True) -> TurnRecord:
"""Return a canonical record, raising on invalid fields in strict mode."""
result = normalize_record_with_issues(record)
if strict and result.errors:
raise RecordValidationError(list(result.errors))
return result.record
def validate_record(record: Mapping[str, Any] | TurnRecord) -> tuple[ValidationIssue, ...]:
"""Validate a raw mapping or an already-normalized :class:`TurnRecord`."""
if isinstance(record, Mapping):
return normalize_record_with_issues(record).issues
issues: list[ValidationIssue] = []
if not record.record_id:
issues.append(ValidationIssue("warning", "missing_id", "record has no explicit id", "record_id"))
if record.audio is None:
issues.append(ValidationIssue("error", "missing_audio", "audio value is missing", "audio"))
if not isinstance(record.endpoint, bool):
issues.append(ValidationIssue("error", "invalid_endpoint", "endpoint must be boolean", "endpoint"))
for field_name in ("midfiller", "endfiller", "synthetic"):
value = getattr(record, field_name)
if value is not None and not isinstance(value, bool):
issues.append(
ValidationIssue("error", f"invalid_{field_name}", f"{field_name} must be boolean or null", field_name)
)
return tuple(issues)