3v324v23 commited on
Commit
0a65ab2
·
1 Parent(s): aa22be3

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 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 pilot, grouped into videos for the lab's native-speech runner."""
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
- match = re.fullmatch(r"minerva:([\w-]{11}):(\d+)", s.get("item") or "")
811
- if (s.get("speaker") or "") != "מינרווה" or not match:
812
  continue
813
- video_id, line = match.group(1), int(match.group(2))
814
- timing = re.fullmatch(rf"minerva:{re.escape(video_id)}:([0-9.]+)-([0-9.]+)", s.get("device") or "")
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": titles.get(video_id, video_id),
820
- "filename": files.get(video_id, ""), "samples": []})
821
- video["samples"].append({"id": s["id"], "item": s["item"], "line": line,
822
- "start": float(timing.group(1)) if timing else None,
823
- "end": float(timing.group(2)) if timing else None,
824
- "ar": s.get("ar") or "", "ipa": s.get("ipa") or "",
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 = list(titles)
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
- #!/usr/bin/env python3
2
- """Import the three-video Minerva pilot into the lab as native, correctly spoken samples.
3
 
4
- The videos have no subtitle stream. PIL/Tesseract are deliberately not dependencies: the reviewed, pointed-Hebrew
5
- captions and the detected time spans are the pilot manifest below. ``app.texts.text_phrases`` remains the single
6
- source of Arabic spelling and IPA, exactly as it is for teacher-authored texts.
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() -> tuple[list[tuple[Video, int, float, float, dict]], list[str]]:
146
- out, guessed = [], []
147
- for video in PILOT:
148
- for number, (start, end, tr) in enumerate(video.lines, 1):
149
- made, new = text_phrases(f"minerva:{video.youtube_id}", [[f"{video.title} {number}", tr]])
150
- phrase = made[0]
151
- phrase["key"] = f"minerva:{video.youtube_id}:{number:02d}"
152
- out.append((video, number, start, end, phrase))
153
- guessed.extend(x for x in new if x not in guessed)
154
- return out, guessed
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("--verify", action="store_true", help="verify the 70 remote rows and audio names; do not score/upload")
 
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, guessed = phrases()
173
- if [len(v.lines) for v in PILOT] != [15, 22, 33] or len(entries) != 70:
174
- raise RuntimeError("the Minerva pilot must stay exactly 15 + 22 + 33 = 70 lines")
175
- files = {v.youtube_id: source_file(args.videos_dir, v.youtube_id) for v in PILOT}
176
- for video, number, start, end, phrase in entries:
177
- if end <= start or not phrase["variants"]:
178
- raise ValueError(f"invalid line {video.youtube_id}:{number}")
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 _, _, _, _, phrase in entries}
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 pilot: {len(expected & existing)}/70 rows, {audio}/70 audio files named")
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 == 70 else 1
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, number, start, end, phrase) in enumerate(entries, 1):
217
- item = phrase["key"]
218
  if item in existing:
219
  skipped += 1
220
- print(f"[{n:02d}/70] skip {item}")
221
  continue
222
- data = wav_segment(files[video.youtube_id], start, end)
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.youtube_id}:{start:.1f}-{end:.1f}", "score": result["score"],
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.youtube_id}-{number:02d}-{uuid.uuid4().hex}.wav"
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:02d}/70] saved {item}: {result['score']}")
238
- print(f"Minerva pilot complete: {saved} saved, {skipped} already present")
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
- assert d["videos"][0]["samples"][0]["start"] == 1.0
 
 
 
 
 
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 PILOT, phrases
2
 
3
 
4
- def test_minerva_pilot_manifest_has_exactly_the_reviewed_three_videos_and_70_lines():
5
- assert [v.youtube_id for v in PILOT] == ["U8aBRd3-wlM", "OuAwxXUZBU4", "V2y6LqnoXTo"]
6
- assert [len(v.lines) for v in PILOT] == [15, 22, 33]
7
- made, _ = phrases()
8
- assert len(made) == 70
9
- assert len({p[4]["key"] for p in made}) == 70
10
- assert all(p[2] < p[3] and p[4]["variants"] for p in made)
 
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"