Refactor Minerva import script to use metadata and improve functionality
Browse files- Removed hardcoded video data and replaced it with dynamic loading from `data/minerva_metadata.json`.
- Updated the `phrases` function to handle metadata and return structured data for each reviewed line.
- Modified command-line arguments to allow importing specific YouTube IDs.
- Enhanced validation checks to ensure the integrity of imported lines.
- Updated tests to reflect changes in the import logic and ensure coverage for new functionality.
- app/lab.py +52 -0
- app/main.py +14 -16
- data/minerva_metadata.json +0 -0
- scripts/import_minerva.py +36 -130
- tests/test_lab.py +10 -1
- tests/test_minerva_import.py +8 -8
app/lab.py
CHANGED
|
@@ -18,6 +18,10 @@ The IPA follows the bank's (phrases.py), and a word the bank has keeps the bank'
|
|
| 18 |
"""
|
| 19 |
from __future__ import annotations
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
from .align import POOLS
|
| 22 |
from .scorer import LETTER_GOOD
|
| 23 |
from .tips import VOWELS
|
|
@@ -96,6 +100,50 @@ ITEMS = [
|
|
| 96 |
_item("vow-miin", "vowels", "iː", "מי", ("مين", "m iː n"), pair="i / iː"),
|
| 97 |
]
|
| 98 |
BY_ID = {item["id"]: item for item in ITEMS}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
|
| 101 |
def phrase(item: dict) -> dict:
|
|
@@ -112,6 +160,10 @@ def sample_phrase(sample: dict) -> dict | None:
|
|
| 112 |
item = BY_ID.get(sample.get("item"))
|
| 113 |
if item is not None:
|
| 114 |
return phrase(item)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
ar, ipa = sample.get("ar"), sample.get("ipa")
|
| 116 |
if not ar or not ipa:
|
| 117 |
return None
|
|
|
|
| 18 |
"""
|
| 19 |
from __future__ import annotations
|
| 20 |
|
| 21 |
+
import json
|
| 22 |
+
from functools import lru_cache
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
from .align import POOLS
|
| 26 |
from .scorer import LETTER_GOOD
|
| 27 |
from .tips import VOWELS
|
|
|
|
| 100 |
_item("vow-miin", "vowels", "iː", "מי", ("مين", "m iː n"), pair="i / iː"),
|
| 101 |
]
|
| 102 |
BY_ID = {item["id"]: item for item in ITEMS}
|
| 103 |
+
MINERVA_METADATA = Path(__file__).resolve().parent.parent / "data" / "minerva_metadata.json"
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@lru_cache(maxsize=1)
|
| 107 |
+
def minerva_metadata() -> tuple[dict, ...]:
|
| 108 |
+
"""The reviewed Minerva captions used by both the lab page and every rescore.
|
| 109 |
+
|
| 110 |
+
Supabase owns the recording and its historical result; this file owns the sentence, timing and expected phones.
|
| 111 |
+
Keeping that boundary explicit means an OCR correction takes effect on the next server start without reimporting
|
| 112 |
+
audio or editing database rows.
|
| 113 |
+
"""
|
| 114 |
+
raw = json.loads(MINERVA_METADATA.read_text(encoding="utf-8"))
|
| 115 |
+
if not isinstance(raw, list):
|
| 116 |
+
raise ValueError("Minerva metadata must be a JSON array")
|
| 117 |
+
videos, seen = [], set()
|
| 118 |
+
for video in raw:
|
| 119 |
+
video_id = str(video.get("youtube_id") or "")
|
| 120 |
+
segments = video.get("segments")
|
| 121 |
+
if not video_id or video_id in seen or not isinstance(segments, list):
|
| 122 |
+
raise ValueError(f"invalid Minerva video metadata: {video_id or '<missing id>'}")
|
| 123 |
+
seen.add(video_id)
|
| 124 |
+
lines = []
|
| 125 |
+
for line, segment in enumerate(segments, 1):
|
| 126 |
+
timing = segment.get("voice_time_seconds") or {}
|
| 127 |
+
required = (segment.get("arabic"), segment.get("hebrew_transliteration"),
|
| 128 |
+
segment.get("hebrew"), segment.get("expected_phonemes"))
|
| 129 |
+
if not all(isinstance(value, str) and value.strip() for value in required):
|
| 130 |
+
raise ValueError(f"incomplete Minerva metadata: {video_id}:{line:02d}")
|
| 131 |
+
try:
|
| 132 |
+
start, end = float(timing["start"]), float(timing["end"])
|
| 133 |
+
except (KeyError, TypeError, ValueError) as exc:
|
| 134 |
+
raise ValueError(f"invalid Minerva timing: {video_id}:{line:02d}") from exc
|
| 135 |
+
if end <= start:
|
| 136 |
+
raise ValueError(f"invalid Minerva timing: {video_id}:{line:02d}")
|
| 137 |
+
lines.append({"item": f"minerva:{video_id}:{line:02d}", "line": line, "start": start, "end": end,
|
| 138 |
+
"ar": required[0], "tr": required[1], "he": required[2], "ipa": required[3]})
|
| 139 |
+
videos.append({"id": video_id, "title": str(video.get("title_hebrew") or video_id),
|
| 140 |
+
"filename": str(video.get("file_name") or ""), "segments": tuple(lines)})
|
| 141 |
+
return tuple(videos)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def minerva_segments() -> dict[str, dict]:
|
| 145 |
+
"""Reviewed Minerva segments by their persisted lab item key."""
|
| 146 |
+
return {segment["item"]: segment for video in minerva_metadata() for segment in video["segments"]}
|
| 147 |
|
| 148 |
|
| 149 |
def phrase(item: dict) -> dict:
|
|
|
|
| 160 |
item = BY_ID.get(sample.get("item"))
|
| 161 |
if item is not None:
|
| 162 |
return phrase(item)
|
| 163 |
+
segment = minerva_segments().get(sample.get("item"))
|
| 164 |
+
if segment is not None:
|
| 165 |
+
return {"cat": "lab", "key": segment["item"], "he": segment["he"],
|
| 166 |
+
"variants": [(segment["ar"], segment["ipa"])]}
|
| 167 |
ar, ipa = sample.get("ar"), sample.get("ipa")
|
| 168 |
if not ar or not ipa:
|
| 169 |
return None
|
app/main.py
CHANGED
|
@@ -796,37 +796,35 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 796 |
|
| 797 |
@app.get("/api/lab/minerva")
|
| 798 |
def lab_minerva(a: SupabaseAuth = Depends(teacher_only)):
|
| 799 |
-
"""The imported Minerva
|
| 800 |
-
titles = {"U8aBRd3-wlM": "במכולת", "OuAwxXUZBU4": "אסיפת הורים", "V2y6LqnoXTo": "הקדרה"}
|
| 801 |
-
files = {"U8aBRd3-wlM": "במכולת * פִי א-דֻכַּאן * في الدّكّان * קטע שיחה בערבית מדוברת [U8aBRd3-wlM].webm",
|
| 802 |
-
"OuAwxXUZBU4": "אסיפת הורים * אִגתִמַאע אַהַאלִי * إجتماع أهالي * קטע שיחה בערבית מדוברת [OuAwxXUZBU4].webm",
|
| 803 |
-
"V2y6LqnoXTo": "הקְדֵרָה הולידה קְדֵרָה * אִ-טֻנגָ'רָה וִלדַת טֻנגָ'רָה * الطّنجرة ولدت طنجرة * סיפור בערבית מדוברת [V2y6LqnoXTo].mkv"}
|
| 804 |
try:
|
| 805 |
samples = a.lab_samples(full=True)
|
| 806 |
except AuthUnavailable as e:
|
| 807 |
raise lab_error(e)
|
|
|
|
|
|
|
| 808 |
videos: dict[str, dict] = {}
|
| 809 |
for s in samples:
|
| 810 |
-
|
| 811 |
-
if (s.get("speaker") or "") != "מינרווה" or
|
| 812 |
continue
|
| 813 |
-
|
| 814 |
-
|
| 815 |
stored = s.get("result") or {}
|
| 816 |
excluded = stored.get("_lab_excluded_words") or {}
|
| 817 |
if not isinstance(excluded, dict):
|
| 818 |
excluded = {}
|
| 819 |
-
video = videos.setdefault(video_id, {"id": video_id, "title":
|
| 820 |
-
"filename":
|
| 821 |
-
video["samples"].append({"id": s["id"], "item": s["item"], "line": line,
|
| 822 |
-
"start":
|
| 823 |
-
"
|
| 824 |
-
"
|
| 825 |
"score": s.get("score"), "heard_ipa": s.get("heard_ipa") or "",
|
| 826 |
"flagged": s.get("flagged") or [],
|
| 827 |
"unrelated": bool(stored.get("unrelated")), "audio": bool(s.get("audio")),
|
| 828 |
"excluded_words": {str(k): str(v) for k, v in excluded.items()}})
|
| 829 |
-
order =
|
| 830 |
out = list(videos.values())
|
| 831 |
out.sort(key=lambda v: order.index(v["id"]) if v["id"] in order else len(order))
|
| 832 |
for video in out:
|
|
|
|
| 796 |
|
| 797 |
@app.get("/api/lab/minerva")
|
| 798 |
def lab_minerva(a: SupabaseAuth = Depends(teacher_only)):
|
| 799 |
+
"""The imported Minerva audio joined with its reviewed JSON sentence metadata."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 800 |
try:
|
| 801 |
samples = a.lab_samples(full=True)
|
| 802 |
except AuthUnavailable as e:
|
| 803 |
raise lab_error(e)
|
| 804 |
+
manifest = lab.minerva_metadata()
|
| 805 |
+
definitions = {segment["item"]: (video, segment) for video in manifest for segment in video["segments"]}
|
| 806 |
videos: dict[str, dict] = {}
|
| 807 |
for s in samples:
|
| 808 |
+
definition = definitions.get(s.get("item"))
|
| 809 |
+
if (s.get("speaker") or "") != "מינרווה" or definition is None:
|
| 810 |
continue
|
| 811 |
+
meta_video, segment = definition
|
| 812 |
+
video_id = meta_video["id"]
|
| 813 |
stored = s.get("result") or {}
|
| 814 |
excluded = stored.get("_lab_excluded_words") or {}
|
| 815 |
if not isinstance(excluded, dict):
|
| 816 |
excluded = {}
|
| 817 |
+
video = videos.setdefault(video_id, {"id": video_id, "title": meta_video["title"],
|
| 818 |
+
"filename": meta_video["filename"], "samples": []})
|
| 819 |
+
video["samples"].append({"id": s["id"], "item": s["item"], "line": segment["line"],
|
| 820 |
+
"start": segment["start"], "end": segment["end"],
|
| 821 |
+
"ar": segment["ar"], "tr": segment["tr"], "he": segment["he"],
|
| 822 |
+
"ipa": segment["ipa"],
|
| 823 |
"score": s.get("score"), "heard_ipa": s.get("heard_ipa") or "",
|
| 824 |
"flagged": s.get("flagged") or [],
|
| 825 |
"unrelated": bool(stored.get("unrelated")), "audio": bool(s.get("audio")),
|
| 826 |
"excluded_words": {str(k): str(v) for k, v in excluded.items()}})
|
| 827 |
+
order = [video["id"] for video in manifest]
|
| 828 |
out = list(videos.values())
|
| 829 |
out.sort(key=lambda v: order.index(v["id"]) if v["id"] in order else len(order))
|
| 830 |
for video in out:
|
data/minerva_metadata.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
scripts/import_minerva.py
CHANGED
|
@@ -1,13 +1,12 @@
|
|
| 1 |
-
|
| 2 |
-
"""Import the three-video Minerva pilot into the lab as native, correctly spoken samples.
|
| 3 |
|
| 4 |
-
The videos have no subtitle stream
|
| 5 |
-
|
| 6 |
-
|
| 7 |
|
| 8 |
Usage:
|
| 9 |
python scripts/import_minerva.py --dry-run
|
| 10 |
-
python scripts/import_minerva.py --email teacher@example.com
|
| 11 |
|
| 12 |
Existing ``minerva:<video-id>:<line>`` rows are skipped, making an interrupted import safe to resume.
|
| 13 |
"""
|
|
@@ -18,7 +17,6 @@ import os
|
|
| 18 |
import subprocess
|
| 19 |
import sys
|
| 20 |
import uuid
|
| 21 |
-
from dataclasses import dataclass
|
| 22 |
from datetime import date
|
| 23 |
from pathlib import Path
|
| 24 |
|
|
@@ -31,101 +29,11 @@ from app.auth import AuthUnavailable, load_auth # noqa: E402
|
|
| 31 |
from app.main import MODEL_ID, build_version # noqa: E402
|
| 32 |
from app.reference import References # noqa: E402
|
| 33 |
from app.scorer import decode_audio, load_scorer # noqa: E402
|
| 34 |
-
from app.texts import text_phrases # noqa: E402
|
| 35 |
from app.tts import VOICES, load_tts # noqa: E402
|
| 36 |
|
| 37 |
SPEAKER = "מינרווה"
|
| 38 |
|
| 39 |
|
| 40 |
-
@dataclass(frozen=True)
|
| 41 |
-
class Video:
|
| 42 |
-
youtube_id: str
|
| 43 |
-
title: str
|
| 44 |
-
lines: tuple[tuple[float, float, str], ...]
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
# Caption changes were measured at 5 fps. The first story line is its spoken title, before the first full caption.
|
| 48 |
-
PILOT = (
|
| 49 |
-
Video("U8aBRd3-wlM", "במכולת", (
|
| 50 |
-
(9.0, 10.8, "פִיה עִנְדַכּ חַלִיב טַארֶה?"),
|
| 51 |
-
(10.8, 12.8, "אַה, גֻ'וַּא פִי ת-תַּלַּאגֶ'ה, עַלַא אִידֶכּ לְ-יַמִין."),
|
| 52 |
-
(12.8, 14.2, "מֻמְתַאז."),
|
| 53 |
-
(14.2, 17.6, "מֻמְכּן תְחֻטְלִי כַּמַאן וַקִיַּת גֻ'בְּנֶה צַפְרַא?"),
|
| 54 |
-
(17.6, 21.8, "אַה טַבְּעַן, בַּס תְוַאנִי אַחְ'לַצ מַעַאה לְ-חִסַאב."),
|
| 55 |
-
(21.8, 24.6, "תְפַדַّ'לִי."),
|
| 56 |
-
(24.6, 27.6, "הַאדַא הֻוֶּ? אַיוַה, קַדֵּיש לְ-חִסַאב?"),
|
| 57 |
-
(27.6, 30.4, "חַ'מְסֶה וְעִשְרִין שֵיקֶל. תְפַדַّל."),
|
| 58 |
-
(30.4, 33.0, "מַעְכִּיש אַצְעַ'ר? מַעִיש צַרַאפֶת מִיֶּה."),
|
| 59 |
-
(33.0, 35.4, "לַחְטַ'ה אַפְחַצ."),
|
| 60 |
-
(35.4, 39.2, "לַא וַאללַה מַעִיש, פִיה בַּס עִשְרִין אִזַא בִּיפִידוּכּ."),
|
| 61 |
-
(39.2, 42.8, "לַא מַעְלֵיש, מֻש מֻהִם, בַּדַבֶּרְהַא."),
|
| 62 |
-
(42.8, 44.6, "תְפַדַّלִי אִלְ-בַּאקִי, מְעַוַّדִין."),
|
| 63 |
-
(44.6, 46.4, "תִסְלַם, יַעְטִיכּ אִלְ-עַאפְיֶה!"),
|
| 64 |
-
(46.4, 48.4, "אַללַה יְעַאפִיכּ, מַע אִלְ-סַלַאמֶה!"),
|
| 65 |
-
)),
|
| 66 |
-
Video("OuAwxXUZBU4", "אסיפת הורים", (
|
| 67 |
-
(8.8, 11.8, "תַמַאם, כִּיפֶכּ? שוּ צַאר מַעֶכּ?"),
|
| 68 |
-
(11.8, 14.2, "בְּסֵידֶר, אִסַּא טִלְעֵת מִן אִלְ-מַדְרַסֶה."),
|
| 69 |
-
(14.2, 17.4, "קַעַדֶת מַע אִלְ-מֻרַבִּיֶה וּבַּאקִי לְ-מֻעַלִּמַאת."),
|
| 70 |
-
(17.4, 21.2, "מִיס נַוַאל בְּתְקוּל אִנּוּ רַבִּיע וַלַד בִּגַ'נֵּן וְעַקְלוֹ כְּבִּיר."),
|
| 71 |
-
(21.2, 24.4, "מֻאַדַّבּ, בְּחִבּ יְסַאעֶד עֵ'ירוֹ, שַאטֶר וּפַתֶח."),
|
| 72 |
-
(24.4, 25.8, "בַּס מִתְל אִלְ-עַאדֶה."),
|
| 73 |
-
(25.8, 29.4, "לַאזֶם יִשְתַעֵ'ל אַכְּתַר עַלַא מַוְדוּע אִלְ-תַרְכִּיז."),
|
| 74 |
-
(29.4, 31.0, "כְּתִיר בִּשַתּ וּבִּסְרַח."),
|
| 75 |
-
(31.0, 35.8, "וְהַאדַא בִּחַלִּיה יַעְמַל אַעְ'לַאט זְעִ'ירֶה פִי דִּרַאסֶה."),
|
| 76 |
-
(35.8, 37.4, "הֵי וּבַּאקִי לְ-מֻעַלִּמַאת מִנְתַבְּהַאת."),
|
| 77 |
-
(37.4, 39.2, "אִנּוּ מֻשְכִּלְתוֹ מֻש עַדַם פַהֶם."),
|
| 78 |
-
(39.2, 41.0, "בִּלְ-עַכֶּס, עַרְבִּיהֶן שַאטֶר כְּתִיר."),
|
| 79 |
-
(41.0, 46.0, "בַּס מֻשְכִּלְתוֹ קִלֶּת תַרְכִּיז."),
|
| 80 |
-
(46.0, 49.4, "אִדַ'א הֵיכּ אִלְ-וַדְע פִש עַלַא שוּ נִקְלַק."),
|
| 81 |
-
(49.4, 52.6, "הֻוֶּ מְתַחַסֵּן כְּתִיר וּמֻש לַאזֶם נְדַקֵּק עַלַא כֻּל אִשִי."),
|
| 82 |
-
(52.6, 56.4, "רַאח יִתְעַ'יַּר מַע אִלְ-וַקְת וִלְ-גִ'יל אִלוֹ חַקּוֹ."),
|
| 83 |
-
(56.4, 59.8, "מַדְבּוּט. אַנַא בַּקוּל לַאזֶם נְגִ'יבְלוֹ הַדִיֶּה זְעִ'ירֶה."),
|
| 84 |
-
(59.8, 61.6, "מִתְל מַא וַעַדְנַאה."),
|
| 85 |
-
(61.6, 64.2, "אַה אַכִּיד, אִלְ-יוֹם מִנְטַלַע כֻּלְנַא מִשְוַאר."),
|
| 86 |
-
(64.2, 65.2, "וּמְנְגִ'יבְלוֹ שַעְ'לֶה."),
|
| 87 |
-
(65.2, 69.2, "חַ'לִּיה יְחִס אִנּוּ אִחְנַא מְקַדְּרִין תַעַבּוֹ וְגִ'דִּיֶּתוֹ."),
|
| 88 |
-
(69.2, 74.2, "טַיֵּב מֻמְתַאז, בְּלַא מַנְחַכִּי, בַּאי."),
|
| 89 |
-
)),
|
| 90 |
-
Video("V2y6LqnoXTo", "הקדרה", (
|
| 91 |
-
# The title is spoken over the intro card; 5.6-6.4 is the silent transition into the first story caption.
|
| 92 |
-
(0.4, 5.6, "אִלְ-טֻנְגַ'רַה וִלְדַת טֻנְגַ'רַה."),
|
| 93 |
-
(6.4, 10.2, "מַרַּה טַלַבּ גַ'מַא מִן גַ'ארוֹ יַעְטִיה טֻנְגַ'רַה כְּבִּירֶה."),
|
| 94 |
-
(10.2, 14.2, "עַשַאן יִקְדַר יִטְבַּח וְיִטְעַמִּי מַעַאזִימוֹ לְ-כֻּתַאר."),
|
| 95 |
-
(14.2, 17.2, "אִלִּי גַ'איִין עִנְדוֹ עַלַא אִלְ-עַ'דַא."),
|
| 96 |
-
(17.2, 21.0, "אִלְ-גַ'אר וַאפַק וְאַעְטַאה טֻנְגַ'רַה לְגַ'מַא."),
|
| 97 |
-
(21.0, 25.0, "בַּעְד אַכַּם יוֹם רַגַ'ע גַ'מַא אִלְ-טֻנְגַ'רַה לְ-כְּבִּירֶה."),
|
| 98 |
-
(25.0, 28.6, "וּבְּקַלְבְּהַא טֻנְגַ'רַה זְעִ'ירוֹנֶה."),
|
| 99 |
-
(28.6, 31.8, "סַאַל אִלְ-גַ'אר עַן הַאי טֻנְגַ'רַה לְ-זְעִ'ירֶה."),
|
| 100 |
-
(31.8, 36.2, "וְגַ'מַא פַסַּרְלוֹ אִנְהַא אִנְוַלַדַת מִן אִלְ-טֻנְגַ'רַה לְ-כְּבִּירֶה."),
|
| 101 |
-
(36.2, 40.4, "פִי לְ-פַתְרַה אִלִּי כַּאנֶת אִלְ-טֻנְגַ'רַה לְ-כְּבִּירֶה עִנְדוֹ."),
|
| 102 |
-
(40.4, 42.2, "קִבֵּל אִלְ-גַ'אר מִן גַ'מַא לְ-עַ'בִּי."),
|
| 103 |
-
(42.2, 45.8, "הַאי לְ-בִּנְת לְ-זְעִ'ירֶה בְּכֻל פַרַח וּסֻרוּר."),
|
| 104 |
-
(45.8, 48.4, "וּפִי קַלְבּוֹ תְחַוַּת עַלַא גַ'מַא."),
|
| 105 |
-
(48.4, 51.2, "אִנּוּ וַאחַד אַהְבַּל מַעְתוֹה."),
|
| 106 |
-
(51.2, 54.2, "בַּעְד אַכַּם יוֹם אַגַ'א גַ'מַא עִנְד גַ'ארוֹ."),
|
| 107 |
-
(54.2, 58.4, "יִטְלֻבּ מִנּוֹ יִמְסִכּוֹ אִלְ-טֻנְגַ'רַה כַּמַאן מַרַּה."),
|
| 108 |
-
(58.4, 61.0, "פְרִח אִלְ-גַ'אר וְקַאל פִי קַלְבּוֹ."),
|
| 109 |
-
(61.0, 64.0, "הַלַק בְּתוּלַד אִלְ-טֻנְגַ'רַה כַּמַאן בִּנְת."),
|
| 110 |
-
(64.0, 68.0, "וּמִין בִּעְרַף, בַּלְכִּי הַאי לְ-מַרַּה וַלַדַת תוֹאַם."),
|
| 111 |
-
(68.0, 72.6, "וְהֵיכּ מַסַכּ אִלְ-גַ'אר טֻנְגַ'רְתוֹ לְגַ'מַא."),
|
| 112 |
-
(72.6, 76.4, "מַרְקַת עִדֶּת אַיַּאם וְגַ'מַא מַא רַגַ'עְש אִלְ-טֻנְגַ'רַה."),
|
| 113 |
-
(76.4, 79.4, "עַשַאן הֵיכּ רַאח אִלְ-גַ'אר לְעִנְד גַ'מַא."),
|
| 114 |
-
(79.4, 81.8, "וְטַלַבּ מִנּוֹ יִרַגְ'עְהַא."),
|
| 115 |
-
(81.8, 85.6, "בַּס גַ'מַא קַאלְלוֹ אִנּוּ אִלְ-טֻנְגַ'רַה מַאתַת."),
|
| 116 |
-
(85.6, 88.0, "אִלְ-גַ'אר תְעַצַּב כְּתִיר וְקַאל."),
|
| 117 |
-
(88.0, 90.6, "כִּיף בְּתִסְמַח לְנַפְסַכּ תִתְחַוַּת עַלַיّ?"),
|
| 118 |
-
(90.6, 93.6, "שוּ? אִלְ-טֻנְגַ'רַה כַּאיְנֶה כַּאִן חַיّ?"),
|
| 119 |
-
(93.6, 95.8, "בְּתְעִיש וּבְּתְמוּת?"),
|
| 120 |
-
(95.8, 97.8, "גַ'מַא רַד עַלֵיה וְקַאל."),
|
| 121 |
-
(97.8, 101.0, "לַא סַמַח אַללַה, אִתְחַוַּת עַלַא שַחְ'צ מִתְלַכּ."),
|
| 122 |
-
(101.0, 104.4, "בַּס אִדַ'א מֻמְכִּן אִלְ-טֻנְגַ'רַה תוּלַד."),
|
| 123 |
-
(104.4, 106.4, "מַעְנַאה מֻמְכִּן אִנְהַא תְמוּת."),
|
| 124 |
-
(106.4, 113.8, "וְהַאדִי נִהַאיַת אִלְ-קִצַּה."),
|
| 125 |
-
)),
|
| 126 |
-
)
|
| 127 |
-
|
| 128 |
-
|
| 129 |
def source_file(folder: Path, youtube_id: str) -> Path:
|
| 130 |
found = sorted(folder.glob(f"*{youtube_id}*"))
|
| 131 |
if len(found) != 1:
|
|
@@ -142,16 +50,16 @@ def wav_segment(video: Path, start: float, end: float) -> bytes:
|
|
| 142 |
return done.stdout
|
| 143 |
|
| 144 |
|
| 145 |
-
def phrases() ->
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
out.append((video,
|
| 153 |
-
|
| 154 |
-
return out
|
| 155 |
|
| 156 |
|
| 157 |
def default_email() -> str:
|
|
@@ -164,21 +72,19 @@ def main() -> int:
|
|
| 164 |
ap.add_argument("--videos-dir", type=Path, default=Path("/home/eyalshw/Videos/minerva"))
|
| 165 |
ap.add_argument("--email", default="", help="teacher email recorded as the importer (or MINERVA_IMPORT_EMAIL)")
|
| 166 |
ap.add_argument("--dry-run", action="store_true", help="validate videos, segments and transcripts; do not score/upload")
|
| 167 |
-
ap.add_argument("--
|
|
|
|
| 168 |
ap.add_argument("--no-baseline", action="store_true", help="score without the native TTS baseline")
|
| 169 |
args = ap.parse_args()
|
| 170 |
load_dotenv()
|
| 171 |
|
| 172 |
-
entries
|
| 173 |
-
if
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
print(f"validated {len(entries)} lines in {len(files)} videos")
|
| 180 |
-
if guessed and args.dry_run:
|
| 181 |
-
print(f"words read by rule ({len(guessed)}): {' '.join(guessed)}")
|
| 182 |
if args.dry_run:
|
| 183 |
return 0
|
| 184 |
|
|
@@ -197,29 +103,29 @@ def main() -> int:
|
|
| 197 |
print(f"lab is unavailable: {e}", file=sys.stderr)
|
| 198 |
return 2
|
| 199 |
if args.verify:
|
| 200 |
-
expected = {phrase["key"] for _, _,
|
| 201 |
audio = sum(bool(s.get("audio")) for s in remote if s.get("item") in expected)
|
| 202 |
missing = sorted(expected - existing)
|
| 203 |
-
extra = sorted(existing - expected)
|
| 204 |
-
print(f"remote Minerva
|
| 205 |
if missing:
|
| 206 |
print(f"missing: {' '.join(missing)}")
|
| 207 |
if extra:
|
| 208 |
print(f"other Minerva rows: {' '.join(extra)}")
|
| 209 |
-
return 0 if not missing and audio ==
|
| 210 |
|
| 211 |
model = os.environ.get("MODEL_ID", MODEL_ID)
|
| 212 |
scorer = load_scorer(model)
|
| 213 |
tts = None if args.no_baseline else load_tts()
|
| 214 |
refs = References(scorer, tts, list(VOICES.values())) if tts else None
|
| 215 |
saved = skipped = 0
|
| 216 |
-
for n, (video,
|
| 217 |
-
item = phrase["key"]
|
| 218 |
if item in existing:
|
| 219 |
skipped += 1
|
| 220 |
-
print(f"[{n
|
| 221 |
continue
|
| 222 |
-
data = wav_segment(files[video
|
| 223 |
wav = decode_audio(data)
|
| 224 |
baseline = refs.get(item, phrase) if refs else []
|
| 225 |
result = scorer.score(wav, phrase, baseline, diagnostics=True)
|
|
@@ -227,15 +133,15 @@ def main() -> int:
|
|
| 227 |
raise RuntimeError(f"{item}: scoring failed: {result['error']}")
|
| 228 |
row = {"email": email, "item": item, "ar": result["variant"]["ar"], "ipa": result["variant"]["ipa"],
|
| 229 |
"speaker": SPEAKER, "label": "correct", "mistake": None,
|
| 230 |
-
"device": f"minerva:{video
|
| 231 |
"heard_ipa": result["heard_ipa"], "flagged": lab.flagged_sounds(result), "result": result,
|
| 232 |
"model": model, "version": build_version()}
|
| 233 |
-
name = f"{date.today():%Y-%m}/minerva-{video
|
| 234 |
if not auth.add_lab_sample(row, data, "audio/wav", name):
|
| 235 |
raise RuntimeError(f"{item}: row saved but audio upload failed; stop before creating more incomplete rows")
|
| 236 |
saved += 1
|
| 237 |
-
print(f"[{n
|
| 238 |
-
print(f"Minerva
|
| 239 |
return 0
|
| 240 |
|
| 241 |
|
|
|
|
| 1 |
+
"""Import the Minerva videos into the lab as native, correctly spoken samples.
|
|
|
|
| 2 |
|
| 3 |
+
The videos have no subtitle stream: their captions are burned in. ``data/minerva_metadata.json`` holds the reviewed
|
| 4 |
+
caption of every line (Arabic, pointed-Hebrew transliteration, translation, expected IPA) with its spoken time span,
|
| 5 |
+
so the importer only cuts, scores and uploads. The lab page and every rescore read the same file.
|
| 6 |
|
| 7 |
Usage:
|
| 8 |
python scripts/import_minerva.py --dry-run
|
| 9 |
+
python scripts/import_minerva.py --email teacher@example.com [--video <youtube-id> ...]
|
| 10 |
|
| 11 |
Existing ``minerva:<video-id>:<line>`` rows are skipped, making an interrupted import safe to resume.
|
| 12 |
"""
|
|
|
|
| 17 |
import subprocess
|
| 18 |
import sys
|
| 19 |
import uuid
|
|
|
|
| 20 |
from datetime import date
|
| 21 |
from pathlib import Path
|
| 22 |
|
|
|
|
| 29 |
from app.main import MODEL_ID, build_version # noqa: E402
|
| 30 |
from app.reference import References # noqa: E402
|
| 31 |
from app.scorer import decode_audio, load_scorer # noqa: E402
|
|
|
|
| 32 |
from app.tts import VOICES, load_tts # noqa: E402
|
| 33 |
|
| 34 |
SPEAKER = "מינרווה"
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
def source_file(folder: Path, youtube_id: str) -> Path:
|
| 38 |
found = sorted(folder.glob(f"*{youtube_id}*"))
|
| 39 |
if len(found) != 1:
|
|
|
|
| 50 |
return done.stdout
|
| 51 |
|
| 52 |
|
| 53 |
+
def phrases(only: set[str] | None = None) -> list[tuple[dict, dict, dict]]:
|
| 54 |
+
"""(video, segment, phrase) for every reviewed line, optionally limited to some YouTube ids."""
|
| 55 |
+
out = []
|
| 56 |
+
for video in lab.minerva_metadata():
|
| 57 |
+
if only and video["id"] not in only:
|
| 58 |
+
continue
|
| 59 |
+
for segment in video["segments"]:
|
| 60 |
+
out.append((video, segment, {"cat": "lab", "key": segment["item"], "he": segment["he"],
|
| 61 |
+
"variants": [(segment["ar"], segment["ipa"])]}))
|
| 62 |
+
return out
|
| 63 |
|
| 64 |
|
| 65 |
def default_email() -> str:
|
|
|
|
| 72 |
ap.add_argument("--videos-dir", type=Path, default=Path("/home/eyalshw/Videos/minerva"))
|
| 73 |
ap.add_argument("--email", default="", help="teacher email recorded as the importer (or MINERVA_IMPORT_EMAIL)")
|
| 74 |
ap.add_argument("--dry-run", action="store_true", help="validate videos, segments and transcripts; do not score/upload")
|
| 75 |
+
ap.add_argument("--video", action="append", default=[], help="import only this YouTube id (repeatable)")
|
| 76 |
+
ap.add_argument("--verify", action="store_true", help="verify the remote rows and audio names; do not score/upload")
|
| 77 |
ap.add_argument("--no-baseline", action="store_true", help="score without the native TTS baseline")
|
| 78 |
args = ap.parse_args()
|
| 79 |
load_dotenv()
|
| 80 |
|
| 81 |
+
entries = phrases(set(args.video) or None)
|
| 82 |
+
if not entries:
|
| 83 |
+
print("no Minerva lines selected", file=sys.stderr)
|
| 84 |
+
return 2
|
| 85 |
+
files = {v["id"]: source_file(args.videos_dir, v["id"]) for v, _, _ in entries}
|
| 86 |
+
total = len(entries)
|
| 87 |
+
print(f"validated {total} lines in {len(files)} videos")
|
|
|
|
|
|
|
|
|
|
| 88 |
if args.dry_run:
|
| 89 |
return 0
|
| 90 |
|
|
|
|
| 103 |
print(f"lab is unavailable: {e}", file=sys.stderr)
|
| 104 |
return 2
|
| 105 |
if args.verify:
|
| 106 |
+
expected = {phrase["key"] for _, _, phrase in entries}
|
| 107 |
audio = sum(bool(s.get("audio")) for s in remote if s.get("item") in expected)
|
| 108 |
missing = sorted(expected - existing)
|
| 109 |
+
extra = sorted(existing - expected) if not args.video else []
|
| 110 |
+
print(f"remote Minerva: {len(expected & existing)}/{total} rows, {audio}/{total} audio files named")
|
| 111 |
if missing:
|
| 112 |
print(f"missing: {' '.join(missing)}")
|
| 113 |
if extra:
|
| 114 |
print(f"other Minerva rows: {' '.join(extra)}")
|
| 115 |
+
return 0 if not missing and audio == total else 1
|
| 116 |
|
| 117 |
model = os.environ.get("MODEL_ID", MODEL_ID)
|
| 118 |
scorer = load_scorer(model)
|
| 119 |
tts = None if args.no_baseline else load_tts()
|
| 120 |
refs = References(scorer, tts, list(VOICES.values())) if tts else None
|
| 121 |
saved = skipped = 0
|
| 122 |
+
for n, (video, segment, phrase) in enumerate(entries, 1):
|
| 123 |
+
item, start, end = phrase["key"], segment["start"], segment["end"]
|
| 124 |
if item in existing:
|
| 125 |
skipped += 1
|
| 126 |
+
print(f"[{n}/{total}] skip {item}")
|
| 127 |
continue
|
| 128 |
+
data = wav_segment(files[video["id"]], start, end)
|
| 129 |
wav = decode_audio(data)
|
| 130 |
baseline = refs.get(item, phrase) if refs else []
|
| 131 |
result = scorer.score(wav, phrase, baseline, diagnostics=True)
|
|
|
|
| 133 |
raise RuntimeError(f"{item}: scoring failed: {result['error']}")
|
| 134 |
row = {"email": email, "item": item, "ar": result["variant"]["ar"], "ipa": result["variant"]["ipa"],
|
| 135 |
"speaker": SPEAKER, "label": "correct", "mistake": None,
|
| 136 |
+
"device": f"minerva:{video['id']}:{start:.1f}-{end:.1f}", "score": result["score"],
|
| 137 |
"heard_ipa": result["heard_ipa"], "flagged": lab.flagged_sounds(result), "result": result,
|
| 138 |
"model": model, "version": build_version()}
|
| 139 |
+
name = f"{date.today():%Y-%m}/minerva-{video['id']}-{segment['line']:02d}-{uuid.uuid4().hex}.wav"
|
| 140 |
if not auth.add_lab_sample(row, data, "audio/wav", name):
|
| 141 |
raise RuntimeError(f"{item}: row saved but audio upload failed; stop before creating more incomplete rows")
|
| 142 |
saved += 1
|
| 143 |
+
print(f"[{n}/{total}] saved {item}: {result['score']}")
|
| 144 |
+
print(f"Minerva import complete: {saved} saved, {skipped} already present")
|
| 145 |
return 0
|
| 146 |
|
| 147 |
|
tests/test_lab.py
CHANGED
|
@@ -238,7 +238,12 @@ def test_minerva_lab_groups_imported_segments_by_video():
|
|
| 238 |
d = c.get("/api/lab/minerva", headers=teacher).json()
|
| 239 |
assert len(d["videos"]) == 1 and d["videos"][0]["id"] == "U8aBRd3-wlM"
|
| 240 |
assert [s["line"] for s in d["videos"][0]["samples"]] == [1, 2]
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
audio = c.get("/api/lab/samples/1/audio", headers=teacher)
|
| 243 |
assert audio.status_code == 200 and audio.headers["content-type"] == "audio/wav"
|
| 244 |
assert c.get("/api/lab/samples/99/audio", headers=teacher).status_code == 404
|
|
@@ -444,6 +449,10 @@ def test_lab_opens_the_trace_of_any_saved_recording():
|
|
| 444 |
def test_imported_lab_line_reconstructs_its_phrase_outside_the_fixed_list():
|
| 445 |
from app.lab import sample_phrase
|
| 446 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
phrase = sample_phrase({"id": 7, "item": "minerva:video:01", "ar": "حال", "ipa": "ħ aː l",
|
| 448 |
"speaker": "מינרווה"})
|
| 449 |
assert phrase == {"cat": "lab", "key": "minerva:video:01", "he": "מינרווה",
|
|
|
|
| 238 |
d = c.get("/api/lab/minerva", headers=teacher).json()
|
| 239 |
assert len(d["videos"]) == 1 and d["videos"][0]["id"] == "U8aBRd3-wlM"
|
| 240 |
assert [s["line"] for s in d["videos"][0]["samples"]] == [1, 2]
|
| 241 |
+
# Sentence data comes from data/minerva_metadata.json, not the deliberately different stored row above.
|
| 242 |
+
first = d["videos"][0]["samples"][0]
|
| 243 |
+
assert first["start"] == 9.0 and first["end"] == 10.8
|
| 244 |
+
assert first["ar"] == "فيه عندك حليب طارة؟"
|
| 245 |
+
assert first["ipa"] == "f iː h | ʕ i n d a k | ħ a l iː b | tˤ aː r e"
|
| 246 |
+
assert first["tr"] == "פִיה עִנְדַכּ חַלִיב טַארֶה?" and first["he"] == "יש לך חלב טרי?"
|
| 247 |
audio = c.get("/api/lab/samples/1/audio", headers=teacher)
|
| 248 |
assert audio.status_code == 200 and audio.headers["content-type"] == "audio/wav"
|
| 249 |
assert c.get("/api/lab/samples/99/audio", headers=teacher).status_code == 404
|
|
|
|
| 449 |
def test_imported_lab_line_reconstructs_its_phrase_outside_the_fixed_list():
|
| 450 |
from app.lab import sample_phrase
|
| 451 |
|
| 452 |
+
reviewed = sample_phrase({"id": 6, "item": "minerva:U8aBRd3-wlM:01", "ar": "wrong", "ipa": "x"})
|
| 453 |
+
assert reviewed["variants"] == [("فيه عندك حليب طارة؟",
|
| 454 |
+
"f iː h | ʕ i n d a k | ħ a l iː b | tˤ aː r e")]
|
| 455 |
+
|
| 456 |
phrase = sample_phrase({"id": 7, "item": "minerva:video:01", "ar": "حال", "ipa": "ħ aː l",
|
| 457 |
"speaker": "מינרווה"})
|
| 458 |
assert phrase == {"cat": "lab", "key": "minerva:video:01", "he": "מינרווה",
|
tests/test_minerva_import.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
-
from scripts.import_minerva import
|
| 2 |
|
| 3 |
|
| 4 |
-
def
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
assert
|
| 9 |
-
|
| 10 |
-
assert
|
|
|
|
| 1 |
+
from scripts.import_minerva import phrases
|
| 2 |
|
| 3 |
|
| 4 |
+
def test_minerva_import_takes_every_reviewed_line_from_the_metadata():
|
| 5 |
+
made = phrases()
|
| 6 |
+
keys = [p[2]["key"] for p in made]
|
| 7 |
+
assert len(keys) == len(set(keys))
|
| 8 |
+
assert all(s["start"] < s["end"] and p["variants"] == [(s["ar"], s["ipa"])] for _, s, p in made)
|
| 9 |
+
pilot = phrases({"U8aBRd3-wlM", "OuAwxXUZBU4", "V2y6LqnoXTo"})
|
| 10 |
+
assert len(pilot) == 70 and pilot[0][2]["key"] == "minerva:U8aBRd3-wlM:01"
|