Skip to content

Commit 61aaccc

Browse files
committed
feat(audio): stamp iwac:transcriptionModel, where the model can be named
The audio pipeline's 03 wrote bibo:content and no provenance, so which model produced a deposited-audio transcript was recorded only in the header of the file on disk -- auditable by whoever still has the folder, and nowhere in the archive. AI_youtube_transcription/03 has written the annotation since 2026-08-12; this is the same value on the same property, 315. What differs is that --model cannot be required here. Four models can fill Transcriptions/ and one of them can be cited: gemini-3.7-flash, item 111774. gemini-pro-latest and gemini-flash-lite-latest are rolling aliases, which AI_MODEL_ITEMS deliberately holds no entry for -- a run through one reports its version as the string "Gemini Pro Latest", so an annotation through it asserts a release the run never confirmed. voxtral-mini-2602 has no authority item at all. Requiring --model would leave three of the four unwritable. Silence is not consent to lose the provenance either, though. With neither flag the model is read off the transcripts' own "Generated using:" line, which the transcriber wrote and the operator did not, so an unattended run stamps evidence rather than a memory. A header naming something no annotation can cite stops the run instead, pointing at --no-model-annotation (upload the text, claim nothing) or --model (assert the pinned release an alias resolved to on the day). Either way it is a flag someone passes on purpose. A folder holding two models is refused rather than warned about, which is stricter than the YouTube step. One annotation covers the whole batch, so a mix attributes every transcript to whichever model is chosen, and --yes skips the confirmation panel the warning would have been read on. Transcriptions/ accumulates across 02 and 02b runs, so the mix is not exotic. Every file is counted, not one per identifier: a recording that arrived as several media files is several transcripts, and nothing stops 02 having made one and 02b another. The header parser goes in segments.py, which already owns the on-disk format and wrote the line being read back; it refuses to guess when the separator is missing, because a wrong guess there would let transcript text be read as a provenance claim. 02's model list becomes ALLOWED_MODELS so that the argparse choices and the interactive menu stop being two lists that can drift, and so a test can hold the invariant they encode: a pinned id added there needs an Omeka authority item, or 03 refuses the folder it fills. Also wires the pre-write backup this step never had. It was PATCHing a live archive under "Backup: disabled -- no route back"; it now defaults to AI_audio_summary/backups/, with --backup-dir and --no-backup as the YouTube step has them. Not fixed here: read_and_join_transcriptions() still uploads each file's header into bibo:content, where it is indexed as archive full text and exported to Hugging Face as OCR. Stripping it re-PATCHes every audio transcript already in Omeka, so it is its own change.
1 parent 48c50b8 commit 61aaccc

7 files changed

Lines changed: 629 additions & 27 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ nul
5252
*.log
5353

5454
# Pipeline output directories
55+
AI_audio_summary/backups/
5556
AI_audio_summary/Audio/
5657
AI_audio_summary/temp_converted_audio/
5758
AI_audio_summary/temp_segments/

AI_audio_summary/02_AI_transcribe_audio.py

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,19 @@
5353
# Load environment variables from .env file FIRST
5454
load_dotenv()
5555

56+
#: The models this script offers, in menu order, with the note shown beside each.
57+
#: Two of them are rolling aliases, so ``03`` cannot annotate their output: an
58+
#: annotation naming "Gemini Pro Latest" asserts a release the run never
59+
#: confirmed, which is why ``AI_MODEL_ITEMS`` deliberately holds no entry for
60+
#: either. Any *pinned* id added here needs an Omeka authority item, or ``03``
61+
#: will refuse the folder it fills — ``tests/test_audio_pipeline.py`` guards that.
62+
ALLOWED_MODELS = {
63+
"gemini-pro-latest": "Higher quality, slower",
64+
"gemini-3.7-flash": "Faster, good quality",
65+
"gemini-flash-lite-latest": "Fastest, cheapest, lowest latency",
66+
}
67+
DEFAULT_MODEL = "gemini-pro-latest"
68+
5669
# Default transcription prompt (fallback when no prompt file is selected)
5770
DEFAULT_PROMPT = """
5871
Please transcribe the audio content accurately.
@@ -94,7 +107,7 @@ class AudioTranscriber(TranscriberBase):
94107
def __init__(
95108
self,
96109
api_key=None,
97-
model="gemini-pro-latest",
110+
model=DEFAULT_MODEL,
98111
requests_per_minute: Optional[int] = None,
99112
transcription_prompt: Optional[str] = None,
100113
auto_split: bool = False,
@@ -718,7 +731,7 @@ def parse_args():
718731
)
719732
parser.add_argument(
720733
"--model",
721-
choices=["gemini-pro-latest", "gemini-3.7-flash", "gemini-flash-lite-latest"],
734+
choices=list(ALLOWED_MODELS),
722735
default=None,
723736
help="Model to use for transcription (default: interactive selection)"
724737
)
@@ -759,20 +772,19 @@ def select_model_interactive():
759772
models_table.add_column("#", style="cyan", justify="right")
760773
models_table.add_column("Model", style="green")
761774
models_table.add_column("Description", style="dim")
762-
models_table.add_row("1", "gemini-pro-latest", "Higher quality, slower")
763-
models_table.add_row("2", "gemini-3.7-flash", "Faster, good quality")
764-
models_table.add_row("3", "gemini-flash-lite-latest", "Fastest, cheapest, lowest latency")
775+
keys = list(ALLOWED_MODELS)
776+
for number, model in enumerate(keys, start=1):
777+
models_table.add_row(str(number), model, ALLOWED_MODELS[model])
765778
console.print(models_table)
766779

767780
model_choice = console.input(
768-
"\n[bold]Select a model (1-3) or press Enter for default (gemini-pro-latest):[/] "
781+
f"\n[bold]Select a model (1-{len(keys)}) or press Enter for default "
782+
f"({DEFAULT_MODEL}):[/] "
769783
).strip()
770784

771-
if model_choice == '2':
772-
return 'gemini-3.7-flash'
773-
if model_choice == '3':
774-
return 'gemini-flash-lite-latest'
775-
return 'gemini-pro-latest'
785+
if model_choice.isdigit() and 1 <= int(model_choice) <= len(keys):
786+
return keys[int(model_choice) - 1]
787+
return DEFAULT_MODEL
776788

777789

778790
def choose_split_mode(args, transcriber: AudioTranscriber, ffmpeg_ready: bool) -> bool:

AI_audio_summary/03_omeka_transcription_updater.py

Lines changed: 218 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,22 @@
1212
1313
The script will automatically detect and join segments in numerical order.
1414
15+
Each value written carries an ``iwac:transcriptionModel`` annotation naming the
16+
model that produced it, so a transcript's provenance survives outside the file
17+
header on disk. ``--model`` is deliberately optional: three of the four models
18+
that can fill ``Transcriptions/`` have no Omeka authority item to point at —
19+
``voxtral-mini-2602`` has none yet, and ``gemini-pro-latest`` /
20+
``gemini-flash-lite-latest`` are rolling aliases which deliberately have none,
21+
because a run through one cannot state which release answered it. Requiring
22+
``--model`` would make this step unusable for all three. Silence does not mean
23+
"no provenance" either, though: with neither flag the model is read off the
24+
transcripts' own ``Generated using:`` header, and the run stops when that header
25+
names something no annotation can cite.
26+
1527
Usage:
16-
python 03_omeka_transcription_updater.py
1728
python 03_omeka_transcription_updater.py --dry-run
29+
python 03_omeka_transcription_updater.py --model gemini-3.7-flash
30+
python 03_omeka_transcription_updater.py --no-model-annotation --yes
1831
1932
Requirements:
2033
- Environment variables: OMEKA_BASE_URL, OMEKA_KEY_IDENTITY, OMEKA_KEY_CREDENTIAL
@@ -27,7 +40,7 @@
2740
import logging
2841
from pathlib import Path
2942
from typing import Dict, List, Optional, Tuple
30-
from collections import defaultdict
43+
from collections import Counter, defaultdict
3144

3245
from rich.console import Console
3346
from rich.panel import Panel
@@ -40,18 +53,160 @@
4053
# Script directory for relative paths
4154
SCRIPT_DIR = Path(__file__).parent.resolve()
4255

43-
# Shared Omeka client
56+
# Shared Omeka client, then this pipeline's own directory for the sibling
57+
# format module. The latter is implicit only while this file is the entry point;
58+
# importing it any other way — a test — would fail on `segments`.
4459
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
45-
from common.iwac_config import BIBO_CONTENT_PROPERTY_ID, DCTERMS_IDENTIFIER_PROPERTY_ID
60+
sys.path.insert(0, str(Path(__file__).resolve().parent))
61+
from common.iwac_config import (
62+
AI_MODEL_ITEMS,
63+
BIBO_CONTENT_PROPERTY_ID,
64+
DCTERMS_IDENTIFIER_PROPERTY_ID,
65+
IWAC_TRANSCRIPTION_MODEL_PROPERTY_ID,
66+
model_annotation_value,
67+
select_model_key,
68+
)
4669
from common.omeka_client import OmekaClient
4770
from common.omeka_text_updater import PropertyTarget, TextUpdate, run_text_updates
4871
from common.log_redaction import install_credential_redaction
4972

50-
CONTENT_TARGET = PropertyTarget(
51-
term='bibo:content',
52-
property_id=BIBO_CONTENT_PROPERTY_ID,
53-
property_label='content',
54-
)
73+
from segments import GENERATOR_FIELD, read_header
74+
75+
CONTENT_TERM = 'bibo:content'
76+
TRANSCRIPTION_MODEL_TERM = 'iwac:transcriptionModel'
77+
78+
#: Stands in for a transcription file whose header records no generator — one
79+
#: written before the field existed, or hand-edited.
80+
UNRECORDED_GENERATOR = 'unrecorded'
81+
82+
83+
def content_target(annotation_value: Optional[Dict] = None) -> PropertyTarget:
84+
"""The ``bibo:content`` target, carrying provenance when one was asserted."""
85+
return PropertyTarget(
86+
term=CONTENT_TERM,
87+
property_id=BIBO_CONTENT_PROPERTY_ID,
88+
property_label='content',
89+
annotation_term=TRANSCRIPTION_MODEL_TERM if annotation_value else None,
90+
annotation_value=annotation_value,
91+
)
92+
93+
94+
def annotation_key_for(generator: str) -> Optional[str]:
95+
"""The ``AI_MODEL_ITEMS`` key a ``Generated using:`` header names, if any.
96+
97+
Headers are written as ``"<vendor> <model id>"`` — ``"Google
98+
gemini-3.7-flash"``, ``"Mistral voxtral-mini-2602"`` — and for every model
99+
that has an authority item the id *is* the annotation key. Returns ``None``
100+
for the ones that do not, which is most of what this pipeline can produce.
101+
"""
102+
model_id = generator.split(' ')[-1].strip() if generator else ''
103+
return model_id if model_id in AI_MODEL_ITEMS else None
104+
105+
106+
def count_generators(
107+
groups: Dict[str, List[Tuple[Path, Optional[int]]]],
108+
) -> "Counter[str]":
109+
"""Tally the ``Generated using:`` header across every transcription file.
110+
111+
Every file is read, not one per identifier: a recording that arrived as
112+
several media files is several transcription files, and nothing stops 02
113+
having produced one of them and 02b another.
114+
"""
115+
counts: "Counter[str]" = Counter()
116+
for files in groups.values():
117+
for file_path, _ in files:
118+
generator = read_header(file_path).get(GENERATOR_FIELD, '').strip()
119+
counts[generator or UNRECORDED_GENERATOR] += 1
120+
return counts
121+
122+
123+
def resolve_model_key(
124+
counts: "Counter[str]",
125+
*,
126+
requested: Optional[str],
127+
skip: bool,
128+
assume_yes: bool,
129+
) -> Tuple[Optional[str], bool]:
130+
"""Decide which model this batch's ``iwac:transcriptionModel`` names.
131+
132+
Returns ``(model_key, ok)``. A *model_key* of ``None`` with *ok* true is a
133+
run that writes content and no provenance — all this step can do for a model
134+
with no authority item. *ok* false means the operator has to say something
135+
more explicit before anything is written.
136+
"""
137+
if skip:
138+
console.print(
139+
f"[dim]Writing bibo:content with no {TRANSCRIPTION_MODEL_TERM} "
140+
"annotation (--no-model-annotation).[/]"
141+
)
142+
return None, True
143+
144+
if len(counts) > 1:
145+
# Stricter than AI_youtube_transcription/03, which only warns. One
146+
# annotation is written for the whole batch, so a mixed folder
147+
# attributes every transcript to whichever model is chosen — and --yes
148+
# skips the confirmation panel the warning would have been read on. This
149+
# folder accumulates across 02 and 02b runs, so mixing is not exotic.
150+
console.print("[red]✗[/] Transcripts in this folder came from more than one model:")
151+
for generator, count in counts.most_common():
152+
console.print(f" [dim]{count:>4} × {generator}[/]")
153+
console.print(
154+
"[red] One annotation is written for the whole batch, so uploading them "
155+
"together would attribute all of them to one model.[/]"
156+
)
157+
console.print(
158+
"[dim] Move each model's transcripts into their own folder and run this step "
159+
"once per folder, or pass --no-model-annotation to write no provenance.[/]"
160+
)
161+
return None, False
162+
163+
generator = next(iter(counts))
164+
inferred = annotation_key_for(generator)
165+
166+
if requested:
167+
expected = AI_MODEL_ITEMS[requested]['display_title']
168+
if inferred and inferred != requested:
169+
console.print(
170+
f"[yellow]⚠[/] Transcripts record [cyan]{generator}[/] but the annotation "
171+
f"will name [cyan]{expected}[/]."
172+
)
173+
elif inferred is None:
174+
# The expected shape for a rolling alias, and the reason a mismatch
175+
# is a warning rather than a refusal: only the operator can say
176+
# which release answered "gemini-pro-latest" on the day it ran.
177+
console.print(
178+
f"[dim]Transcripts record {generator}, which no annotation can name; "
179+
f"asserting {expected} as asked.[/]"
180+
)
181+
return requested, True
182+
183+
if inferred is None:
184+
console.print(
185+
f"[red]✗[/] Transcripts record [cyan]{generator}[/], which has no entry in "
186+
"AI_MODEL_ITEMS, so no annotation can name it."
187+
)
188+
console.print(
189+
"[dim] Pass --no-model-annotation to upload the text without provenance, or "
190+
"--model <key> to assert the pinned release behind a rolling alias.[/]"
191+
)
192+
return None, False
193+
194+
if assume_yes:
195+
# Not a guess: the transcriber wrote this header itself, which is better
196+
# evidence than an unattended run has any other way of getting.
197+
console.print(
198+
f"[green]✓[/] Annotating as [cyan]{AI_MODEL_ITEMS[inferred]['display_title']}[/], "
199+
f"read from the transcripts' header ([dim]{generator}[/])."
200+
)
201+
return inferred, True
202+
203+
console.print(f"[dim]Transcripts record {generator}.[/]")
204+
try:
205+
chosen = select_model_key(default=inferred)
206+
except (EOFError, KeyboardInterrupt):
207+
console.print("\n[yellow]No answer on stdin — aborted, nothing written.[/]")
208+
return None, False
209+
return chosen, chosen is not None
55210

56211

57212
def search_item_by_identifier(client: OmekaClient, identifier: str) -> Optional[Dict]:
@@ -221,6 +376,17 @@ def main() -> int:
221376
parser = argparse.ArgumentParser(
222377
description="Update Omeka S items with audio transcriptions (bibo:content)."
223378
)
379+
parser.add_argument(
380+
"--model", choices=list(AI_MODEL_ITEMS),
381+
help="AI model that produced the transcriptions. Read from the "
382+
"transcripts' header when omitted; pass it to assert the pinned "
383+
"release behind a rolling alias such as gemini-pro-latest.",
384+
)
385+
parser.add_argument(
386+
"--no-model-annotation", action="store_true",
387+
help="Upload the text with no iwac:transcriptionModel annotation. The "
388+
"only option for a model with no Omeka authority item — Voxtral today.",
389+
)
224390
parser.add_argument(
225391
"--dry-run", action="store_true",
226392
help="Fetch each item and report what would change, but write nothing.",
@@ -229,8 +395,20 @@ def main() -> int:
229395
"--yes", action="store_true",
230396
help="Skip the interactive confirmation before writing.",
231397
)
398+
parser.add_argument(
399+
"--backup-dir", type=Path, default=None,
400+
help="Where each item's pre-write JSON is dumped before its PATCH "
401+
"(default: <pipeline>/backups). The only route back from a bulk overwrite.",
402+
)
403+
parser.add_argument(
404+
"--no-backup", action="store_true",
405+
help="Do not dump pre-write payloads. Not recommended.",
406+
)
232407
args = parser.parse_args()
233408

409+
if args.model and args.no_model_annotation:
410+
parser.error("--model and --no-model-annotation contradict each other.")
411+
234412
setup_logging(SCRIPT_DIR / 'log')
235413
transcriptions_folder = SCRIPT_DIR / 'Transcriptions'
236414

@@ -260,18 +438,48 @@ def main() -> int:
260438
console.print(files_table)
261439
console.print(f"\n[bold]Total:[/] [cyan]{len(groups)}[/] unique identifier(s)")
262440

441+
# Settled before the identifier lookups: a folder that cannot be
442+
# attributed should stop here, not after a few hundred searches against
443+
# a live archive.
444+
model_key, resolved = resolve_model_key(
445+
count_generators(groups),
446+
requested=args.model,
447+
skip=args.no_model_annotation,
448+
assume_yes=args.yes,
449+
)
450+
if not resolved:
451+
return 1
452+
453+
annotation = None
454+
if model_key:
455+
model = AI_MODEL_ITEMS[model_key]
456+
annotation = model_annotation_value(
457+
client.base_url,
458+
model_key,
459+
IWAC_TRANSCRIPTION_MODEL_PROPERTY_ID,
460+
'AI Model - Transcription',
461+
)
462+
logging.info(
463+
"Annotating with %s -> %s (item %s)",
464+
TRANSCRIPTION_MODEL_TERM, model['display_title'], model['item_id'],
465+
)
466+
263467
updates = resolve_updates(client, processor, groups)
264468
unresolved = sum(1 for u in updates if u.item_id is None)
265469
if unresolved:
266470
console.print(f"[yellow]⚠[/] {unresolved} identifier(s) had no matching Omeka item")
267471

472+
backup_dir = None if args.no_backup else (args.backup_dir or SCRIPT_DIR / 'backups')
473+
268474
stats = run_text_updates(
269-
client, updates, CONTENT_TARGET,
475+
client, updates, content_target(annotation),
270476
console=console,
271477
dry_run=args.dry_run,
272478
require_confirmation=not args.yes,
273479
extra_confirm_lines=[f"Source folder: {transcriptions_folder}"],
274480
description="Updating transcriptions...",
481+
backup_dir=backup_dir,
482+
backup_label="audio_transcriptions",
275483
)
276484
if not stats:
277485
return 1 # operator declined

0 commit comments

Comments
 (0)