| from __future__ import annotations |
|
|
| import argparse |
| import json |
| from pathlib import Path |
| from typing import Any |
|
|
|
|
| CRITERIA = ["pronunciation", "meaning_preserved", "harakat_quality", "pacing", "comfort"] |
| WEIGHTS = { |
| "pronunciation": 0.3, |
| "meaning_preserved": 0.3, |
| "harakat_quality": 0.15, |
| "pacing": 0.1, |
| "comfort": 0.15, |
| } |
|
|
|
|
| def parse_rating(value: str) -> dict[str, Any]: |
| if "=" not in value: |
| raise ValueError("Rating must look like mishkala=5,5,4,5,5") |
| label, scores_text = value.split("=", 1) |
| label = label.strip() |
| if not label: |
| raise ValueError("Preprocessor label cannot be empty.") |
| pieces = [piece.strip() for piece in scores_text.split(",")] |
| if len(pieces) != len(CRITERIA): |
| raise ValueError(f"Expected {len(CRITERIA)} scores for {label}: {', '.join(CRITERIA)}") |
| scores: dict[str, int] = {} |
| for criterion, piece in zip(CRITERIA, pieces): |
| try: |
| score = int(piece) |
| except ValueError as exc: |
| raise ValueError(f"{criterion} for {label} must be a number from 1 to 5") from exc |
| if not 1 <= score <= 5: |
| raise ValueError(f"{criterion} for {label} must be from 1 to 5") |
| scores[criterion] = score |
| weighted = round(sum(scores[key] * WEIGHTS[key] for key in CRITERIA), 2) |
| return { |
| "label": label, |
| "scores": scores, |
| "weightedScore": weighted, |
| "minimumScore": min(scores.values()), |
| "promotionReady": weighted >= 4.0 and scores["meaning_preserved"] >= 4 and min(scores.values()) >= 3, |
| } |
|
|
|
|
| def choose_best(ratings: list[dict[str, Any]]) -> dict[str, Any] | None: |
| if not ratings: |
| return None |
| return max( |
| ratings, |
| key=lambda item: ( |
| float(item.get("weightedScore") or 0), |
| int((item.get("scores") or {}).get("meaning_preserved") or 0), |
| int(item.get("minimumScore") or 0), |
| ), |
| ) |
|
|
|
|
| def score_tts_preprocessor( |
| rating_values: list[str], |
| baseline_label: str = "plain", |
| report_path: Path | None = None, |
| json_path: Path | None = None, |
| ) -> dict[str, Any]: |
| if not rating_values: |
| raise ValueError("At least one --rating is required.") |
| ratings = [parse_rating(value) for value in rating_values] |
| best = choose_best(ratings) |
| baseline = next((item for item in ratings if item["label"] == baseline_label), None) |
| improvement = None |
| if best and baseline: |
| improvement = round(float(best["weightedScore"]) - float(baseline["weightedScore"]), 2) |
| payload = { |
| "ready": bool(best and best.get("promotionReady") and best.get("label") != baseline_label), |
| "best": best, |
| "baseline": baseline, |
| "baselineLabel": baseline_label, |
| "weightedImprovementOverBaseline": improvement, |
| "ratings": ratings, |
| "criteria": CRITERIA, |
| "weights": WEIGHTS, |
| } |
| if report_path: |
| write_report(report_path, payload) |
| payload["reportPath"] = str(report_path) |
| if json_path: |
| json_path.parent.mkdir(parents=True, exist_ok=True) |
| json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") |
| payload["jsonPath"] = str(json_path) |
| return payload |
|
|
|
|
| def markdown_value(value: Any) -> str: |
| if value is None or value == "": |
| return "-" |
| return str(value) |
|
|
|
|
| def write_report(path: Path, payload: dict[str, Any]) -> None: |
| best = payload.get("best") or {} |
| baseline = payload.get("baseline") or {} |
| lines = [ |
| "# Arabic TTS Preprocessor Listening Score", |
| "", |
| "Scores compare the same cleaned Arabic text before and after a pronunciation preprocessor such as Mishkala Tashkeel.", |
| "", |
| f"Best preprocessor: {markdown_value(best.get('label'))}", |
| f"Baseline: {markdown_value(baseline.get('label') or payload.get('baselineLabel'))}", |
| f"Weighted improvement over baseline: {markdown_value(payload.get('weightedImprovementOverBaseline'))}", |
| f"Promotion ready: {markdown_value(payload.get('ready'))}", |
| "", |
| "| Preprocessor | Pronunciation | Meaning preserved | Harakat quality | Pacing | Long-listen comfort | Weighted | Min | Ready |", |
| "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |", |
| ] |
| for item in payload.get("ratings", []): |
| scores = item.get("scores") or {} |
| lines.append( |
| "| " |
| + " | ".join( |
| [ |
| markdown_value(item.get("label")), |
| markdown_value(scores.get("pronunciation")), |
| markdown_value(scores.get("meaning_preserved")), |
| markdown_value(scores.get("harakat_quality")), |
| markdown_value(scores.get("pacing")), |
| markdown_value(scores.get("comfort")), |
| markdown_value(item.get("weightedScore")), |
| markdown_value(item.get("minimumScore")), |
| markdown_value(item.get("promotionReady")), |
| ] |
| ) |
| + " |" |
| ) |
| lines.extend( |
| [ |
| "", |
| "## Promotion Rule", |
| "", |
| "Promote a preprocessor only when it beats the plain sample, its weighted score is at least 4.0, meaning preservation is at least 4, and no criterion is below 3.", |
| "If the diacritized sample sounds more formal but less comfortable for long listening, keep the plain cleaned text.", |
| ] |
| ) |
| path.parent.mkdir(parents=True, exist_ok=True) |
| path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") |
|
|
|
|
| def print_table(payload: dict[str, Any]) -> None: |
| print("preprocessor weighted meaning min ready") |
| print("---------------- -------- ------- --- -----") |
| for item in payload["ratings"]: |
| scores = item.get("scores") or {} |
| print( |
| f"{item.get('label', '-'):<16} " |
| f"{item.get('weightedScore', 0):>8} " |
| f"{scores.get('meaning_preserved', 0):>7} " |
| f"{item.get('minimumScore', 0):>3} " |
| f"{str(item.get('promotionReady')):<5}" |
| ) |
| best = payload.get("best") or {} |
| if best: |
| print() |
| print(f"Best preprocessor: {best.get('label')} weighted={best.get('weightedScore')} ready={payload.get('ready')}") |
|
|
|
|
| def main_cli() -> None: |
| parser = argparse.ArgumentParser(description="Score plain vs diacritized Arabic TTS samples after listening.") |
| parser.add_argument( |
| "--rating", |
| action="append", |
| default=[], |
| help="label=pronunciation,meaning_preserved,harakat_quality,pacing,comfort using 1-5 scores.", |
| ) |
| parser.add_argument("--baseline-label", default="plain", help="Label used for the non-preprocessed sample.") |
| parser.add_argument("--write-report", type=Path, help="Write a Markdown listening score report.") |
| parser.add_argument("--write-json", type=Path, help="Write a JSON score report for model_promotion_gate.py.") |
| parser.add_argument("--json", action="store_true", help="Print JSON instead of a compact table.") |
| args = parser.parse_args() |
|
|
| payload = score_tts_preprocessor( |
| args.rating, |
| baseline_label=args.baseline_label, |
| report_path=args.write_report, |
| json_path=args.write_json, |
| ) |
| if args.json: |
| print(json.dumps(payload, ensure_ascii=False, indent=2)) |
| else: |
| print_table(payload) |
|
|
|
|
| if __name__ == "__main__": |
| main_cli() |
|
|