File size: 8,004 Bytes
3bf43cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68f1146
3bf43cb
 
 
c1d045b
3bf43cb
 
 
 
c1d045b
 
34aa951
c1d045b
3bf43cb
 
c1d045b
 
 
 
3bf43cb
 
 
 
 
 
 
8c6b0a2
 
3bf43cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68f1146
3bf43cb
68f1146
 
 
34aa951
 
3bf43cb
 
34aa951
3bf43cb
 
 
 
 
 
 
 
 
34aa951
 
3bf43cb
 
 
 
 
 
c1d045b
8c6b0a2
3bf43cb
 
3861ad6
3bf43cb
 
 
 
 
 
162cea3
 
3861ad6
 
 
3bf43cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
"""Prepare a released interview end to end: video parts -> transcript -> brief -> back-test.

Run on a GPU machine (Colab, a lab workstation, or a GPU Space). Partner media
and catalogue records stay under private/ and are never committed.

    python scripts/prepare_interview.py \
        --id RG-50.030.0001 \
        --title "Oral history interview with ..." \
        --media private/RG-50.030.0001/media/*.mp4 \
        --catalogue private/RG-50.030.0001/catalogue.json \
        --donor private/RG-50.030.0001/donor.json \
        --device cuda

Steps
  1. media.prepare      checks parts (e.g. 01.06 ... 06.06), extracts and joins audio
  2. asr.transcribe     faster-whisper large-v3, per-segment language ID, flags
  3. transcript review  writes transcript.txt and STOPS unless --skip-review:
                        correct speaker roles and obvious errors, then rerun with --transcript
  4. pipeline           spatio-textual + period gazetteer + agent + validators
  5. backtest           suggested headings vs the catalogue's own subjects

Outputs go to data/interviews/<id>/ (brief.json, backtest.json). Only commit
them, or deploy them to a public Space, when the museum has confirmed the
interview's release terms allow it.
"""
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))

from curatorbot import transcript as tx  # noqa: E402


def main() -> int:
    p = argparse.ArgumentParser()
    p.add_argument("--id", required=True)
    p.add_argument("--title", default="")
    p.add_argument("--media", nargs="*", default=[], help="Recordings, or folders holding them (searched at any depth)")
    p.add_argument("--transcript", help="Reviewed transcript (.txt Q/A or .json); skips transcription")
    p.add_argument("--catalogue", help="JSON with 'subjects' (and optionally 'title', 'summary')")
    p.add_argument("--donor", help="JSON of donor-reported fields, if any")
    p.add_argument("--note", default="", help="Shown above the brief; a default is chosen from --status")
    p.add_argument("--model", default="large-v3")
    p.add_argument("--device", default="cuda")
    p.add_argument("--prompt", default="Auschwitz, Birkenau, Bergen-Belsen, Theresienstadt, Treblinka, Majdanek, Łódź, Litzmannstadt, Lemberg, Judenrat, Kapo, Appell, UNRRA, HIAS")
    p.add_argument("--skip-review", action="store_true", help="Analyse the raw ASR transcript without stopping for review")
    p.add_argument("--status", choices=["released", "under_review"], default="released",
                   help="released: catalogued interview, back-tested; under_review: new submission (e.g. from Box)")
    p.add_argument("--progress-file", help="Also keep the latest progress here (e.g. in Google Drive, to check from another device)")
    p.add_argument("--source-url", help="Public catalogue page for a released interview")
    a = p.parse_args()

    if not a.note:
        a.note = ("Released interview, already catalogued: CuratorBot's suggestions are compared with the catalogue record."
                  if a.status == "released" else
                  "Submission under review: not yet catalogued. Suggestions are drafts for the reviewing curator.")
    out_dir = ROOT / "data" / "interviews" / a.id
    work = ROOT / "work" / a.id
    out_dir.mkdir(parents=True, exist_ok=True)
    work.mkdir(parents=True, exist_ok=True)
    catalogue = json.loads(Path(a.catalogue).read_text(encoding="utf-8")) if a.catalogue else None
    donor = json.loads(Path(a.donor).read_text(encoding="utf-8")) if a.donor else {}
    media_info = None
    from curatorbot.progress import Progress
    prog = Progress(a.progress_file or work / "progress.json")

    if a.transcript:
        src = Path(a.transcript)
        if src.suffix == ".json":
            d = json.loads(src.read_text(encoding="utf-8"))
            segments, media_info = d["segments"], d.get("media")
        else:
            segments = tx.parse_qa_text(src.read_text(encoding="utf-8"))
            side = work / "transcript.json"
            if side.exists():
                media_info = json.loads(side.read_text(encoding="utf-8")).get("media")
    else:
        if not a.media:
            p.error("Give --media files or a reviewed --transcript.")
        from curatorbot.asr import transcribe
        from curatorbot.intake import find_media
        from curatorbot.media import prepare
        files = [f for m in a.media for f in find_media(m)]
        if not files:
            p.error(f"No recordings found in: {', '.join(a.media)}")
        prog.start("Starting", f"{a.id}: {len(files)} recording(s): " + ", ".join(f.name for f in files))
        prep = prepare(files, workdir=work, progress=prog)
        for issue in prep["issues"]:
            print("WARNING:", issue)
        res = transcribe(prep["audio"], size=a.model, device=a.device, initial_prompt=a.prompt, progress=prog)
        segments = tx.from_asr(res["chunks"])
        media_info = {
            "parts": [{"part": x["part"], "duration": x["duration"], "audio": x.get("audio_codec") or ""} for x in prep["parts"]],
            "duration": prep["duration"], "issues": prep["issues"], "simulated": False,
            "languages": _language_spans(res["chunks"], prep["duration"]),
            "flags": [{"at": tx.fmt_time(f["start"]), "text": f["reason"]} for f in res["flags"]][:50],
        }
        (work / "transcript.json").write_text(json.dumps({"media": media_info, "segments": segments}, ensure_ascii=False, indent=1), encoding="utf-8")
        (work / "transcript.txt").write_text(tx.to_qa_text(segments), encoding="utf-8")
        prog.start("Done", f"transcript {work / 'transcript.txt'}: {len(segments)} turns, {prep['duration']} of audio, "
                           f"{len(res['flags'])} listening flag(s)")
        if not a.skip_review:
            print("Review speaker roles and errors, then rerun with --transcript", work / "transcript.txt")
            return 0

    from curatorbot.pipeline import build_brief
    brief = build_brief(segments, interview_id=a.id, title=a.title or (catalogue or {}).get("title") or a.id,
                        donor=donor, note=a.note, media=media_info, status=a.status,
                        source={"url": a.source_url or (catalogue or {}).get("url", "")}, progress=prog)
    (out_dir / "brief.json").write_text(json.dumps(brief, ensure_ascii=False), encoding="utf-8")
    print("Brief:", out_dir / "brief.json", brief["agent"].get("validation"))
    print("Places:", brief.get("placeCoverage"))
    if catalogue:
        from curatorbot.backtest import score_brief
        bt = score_brief(brief, catalogue)
        (out_dir / "backtest.json").write_text(json.dumps(bt, ensure_ascii=False, indent=2), encoding="utf-8")
        print(f"Back-test: recall {bt['recall_exact']:.0%} exact, {bt['recall_head']:.0%} by main heading; "
              f"{len(bt['missed'])} catalogue headings missed.")
        print(f"Model only: precision@10 {bt['precision_exact']:.0%}; R-precision {bt['model_r_precision']:.0%} "
              f"(top {len(catalogue.get('subjects', []))} of {bt['model_suggested_all']} suggestions).")
        w = bt["with_rules"]
        print(f"With form headings by rule: R-precision {w['r_precision']:.0%} (top {w['r']}), "
              f"all {w['suggested']} suggestions: precision {w['precision_exact']:.0%}, recall {w['recall_exact']:.0%} exact.")
    return 0


def _language_spans(chunks, duration):
    spans = []
    for c in chunks:
        lang = c.get("lang") or "en"
        if spans and spans[-1]["code"] == lang:
            spans[-1]["to"] = tx.fmt_time(c["end"])
        else:
            spans.append({"lang": lang, "code": lang, "from": tx.fmt_time(c["start"]), "to": tx.fmt_time(c["end"])})
    if spans:
        spans[0]["from"], spans[-1]["to"] = "00:00:00", duration
    return spans


if __name__ == "__main__":
    raise SystemExit(main())