Skip to content

Commit 95f4e20

Browse files
authored
feat: add local transcription fallback when captions are unavailable (#42)
* feat: add local Whisper transcription fallback * feat: use local transcription when captions are unavailable * test: cover local transcription fallback lifecycle * test: verify preparation fallback selection and provenance * docs: document local transcription fallback contract * test: align preparation expectations with local fallback * test: preserve source provenance on reused preparation
1 parent 742f397 commit 95f4e20

6 files changed

Lines changed: 646 additions & 14 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Local transcription fallback
2+
3+
GYTE prefers source-provided captions. Local transcription is a fallback only when inspection found no usable caption.
4+
5+
## Selection order
6+
7+
```text
8+
usable source caption
9+
-> caption acquisition
10+
otherwise
11+
-> private audio acquisition with yt-dlp
12+
-> local Whisper transcription
13+
-> stable private transcript
14+
-> normal normalization/reflow/preparation
15+
```
16+
17+
The fallback does not turn generated transcription into source-provided evidence. Pipeline provenance records `evidence_origin: local-transcription`; caption-backed runs record `evidence_origin: source-caption`.
18+
19+
## Private artifacts
20+
21+
Fallback artifacts stay inside the private workspace:
22+
23+
- `transcription-source.<ext>` — minimum acquired audio used for local transcription;
24+
- `.whisper-output/` — transient/local Whisper output directory;
25+
- `transcription.local.txt` — stable locally generated transcription evidence;
26+
- the normal `transcript.raw.txt`, normalized and analysis artifacts derived afterwards.
27+
28+
These files must not be committed or published automatically.
29+
30+
## Whisper configuration
31+
32+
The default executable is `whisper` from `PATH` and the default model is `base`.
33+
34+
Optional overrides:
35+
36+
```text
37+
GYTE_WHISPER_COMMAND=/path/to/whisper
38+
GYTE_WHISPER_MODEL=tiny|base|small|...
39+
```
40+
41+
The command override may be an executable path or a command name available on `PATH`.
42+
43+
## Restart and retry semantics
44+
45+
- a completed non-empty `transcription.local.txt` plus its private audio is reused unless `--force` is requested;
46+
- if audio acquisition completed but transcription did not, the existing audio is reused on retry;
47+
- partial `yt-dlp` files are not treated as completed evidence;
48+
- `prepare` is not advanced when local transcription fails;
49+
- completed preparation preserves the original `source_mode`, `evidence_origin` and local-transcription details when stable outputs are reused.
50+
51+
`--force` requests a fresh transcription attempt while still allowing already acquired private audio to be reused.
52+
53+
## Failure contract
54+
55+
Local fallback failures are explicit and classified internally as:
56+
57+
- `configuration` — required executable/path is unavailable;
58+
- `download` — private audio acquisition failed or produced no usable audio;
59+
- `transcription` — Whisper execution failed;
60+
- `output` — Whisper completed without a usable UTF-8 transcript.
61+
62+
These failures surface as preparation errors and do not silently advance later pipeline stages.
63+
64+
## Authority boundary
65+
66+
```text
67+
source caption != locally generated transcription
68+
local transcription != reviewed source lesson
69+
local transcription != factual authority
70+
```
71+
72+
Local Whisper output remains derived evidence that may contain recognition errors. It must pass the same later preparation/editorial boundaries as caption-derived material.

src/gyte_study_tools/preparation.py

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
atomic_write_text,
1919
load_state,
2020
)
21+
from gyte_study_tools.transcription import (
22+
LocalTranscriptionError,
23+
LocalTranscriptionResult,
24+
transcribe_locally,
25+
)
2126

2227

2328
RAW_FILENAME = "transcript.raw.txt"
@@ -291,11 +296,21 @@ def build_analysis_markdown(
291296
return "\n".join(lines)
292297

293298

299+
def previous_transcription_record(state_path: Path) -> dict[str, Any]:
300+
state = load_state(state_path)
301+
stages = state.get("stages")
302+
if not isinstance(stages, dict):
303+
return {}
304+
transcribe = stages.get("transcribe")
305+
return transcribe if isinstance(transcribe, dict) else {}
306+
307+
294308
def update_pipeline_state(
295309
state_path: Path,
296310
video_id: str,
297311
source_transcript_path: Path,
298312
source_mode: str,
313+
evidence_origin: str,
299314
raw_path: Path,
300315
normalized_path: Path,
301316
analysis_text_path: Path,
@@ -304,17 +319,23 @@ def update_pipeline_state(
304319
normalized_words: int,
305320
analysis_words: int,
306321
reused: bool,
322+
local_transcription: dict[str, Any] | None = None,
307323
) -> None:
308324
now = datetime.now(timezone.utc).isoformat()
309325
state = load_state(state_path)
310326
stages = state.setdefault("stages", {})
311327

312-
stages["transcribe"] = {
328+
transcribe_stage: dict[str, Any] = {
313329
"status": "complete",
314330
"completed_at": now,
315331
"source_mode": source_mode,
332+
"evidence_origin": evidence_origin,
316333
"source_transcript": source_transcript_path.name,
317334
}
335+
if local_transcription is not None:
336+
transcribe_stage["local_transcription"] = local_transcription
337+
338+
stages["transcribe"] = transcribe_stage
318339
stages["prepare"] = {
319340
"status": "complete",
320341
"completed_at": now,
@@ -387,6 +408,7 @@ def prepare_transcript(
387408
if isinstance(caption, dict)
388409
else None
389410
)
411+
previous_transcription = previous_transcription_record(state_path)
390412

391413
if (
392414
not force
@@ -414,11 +436,27 @@ def prepare_transcript(
414436
f"analysis={analysis_words}."
415437
)
416438

439+
previous_mode = previous_transcription.get("source_mode")
440+
source_mode = (
441+
previous_mode
442+
if isinstance(previous_mode, str) and previous_mode
443+
else "adopted-existing"
444+
)
445+
previous_origin = previous_transcription.get("evidence_origin")
446+
evidence_origin = (
447+
previous_origin
448+
if isinstance(previous_origin, str) and previous_origin
449+
else ("source-caption" if caption is not None else "local-transcription")
450+
)
451+
previous_local = previous_transcription.get("local_transcription")
452+
local_details = previous_local if isinstance(previous_local, dict) else None
453+
417454
update_pipeline_state(
418455
state_path=state_path,
419456
video_id=video_id,
420457
source_transcript_path=source_transcript_path,
421-
source_mode="adopted-existing",
458+
source_mode=source_mode,
459+
evidence_origin=evidence_origin,
422460
raw_path=raw_path,
423461
normalized_path=normalized_path,
424462
analysis_text_path=analysis_text_path,
@@ -427,6 +465,7 @@ def prepare_transcript(
427465
normalized_words=normalized_words,
428466
analysis_words=analysis_words,
429467
reused=True,
468+
local_transcription=local_details,
430469
)
431470

432471
return PreparationResult(
@@ -439,16 +478,31 @@ def prepare_transcript(
439478
raw_words=raw_words,
440479
normalized_words=normalized_words,
441480
analysis_words=analysis_words,
442-
source_mode="adopted-existing",
481+
source_mode=source_mode,
443482
reused=True,
444483
)
445484

446485
source_transcript_path: Path | None = None
447486
source_mode = ""
487+
evidence_origin = ""
488+
local_details: dict[str, Any] | None = None
448489

449490
if not force and is_nonempty_file(raw_path):
450491
source_transcript_path = raw_path
451-
source_mode = "stable-existing"
492+
previous_mode = previous_transcription.get("source_mode")
493+
source_mode = (
494+
previous_mode
495+
if isinstance(previous_mode, str) and previous_mode
496+
else "stable-existing"
497+
)
498+
previous_origin = previous_transcription.get("evidence_origin")
499+
evidence_origin = (
500+
previous_origin
501+
if isinstance(previous_origin, str) and previous_origin
502+
else ("source-caption" if caption is not None else "local-transcription")
503+
)
504+
previous_local = previous_transcription.get("local_transcription")
505+
local_details = previous_local if isinstance(previous_local, dict) else None
452506
elif isinstance(language, str) and language:
453507
source_transcript_path = locate_caption_transcript(
454508
workdir,
@@ -464,15 +518,37 @@ def prepare_transcript(
464518
language,
465519
)
466520
source_mode = "generated-caption"
521+
evidence_origin = "source-caption"
467522
else:
468-
raise PreparationError(
469-
"Nessuna caption utilizzabile e fallback audio "
470-
"non ancora implementato."
523+
try:
524+
local_result: LocalTranscriptionResult = transcribe_locally(
525+
url,
526+
workdir,
527+
force=force,
528+
)
529+
except LocalTranscriptionError as error:
530+
raise PreparationError(
531+
"Fallback di trascrizione locale fallito "
532+
f"({error.kind}): {error}"
533+
) from error
534+
535+
source_transcript_path = local_result.transcript_path
536+
source_mode = (
537+
"reused-local-transcription"
538+
if local_result.reused_transcript
539+
else "generated-local-transcription"
471540
)
541+
evidence_origin = "local-transcription"
542+
local_details = {
543+
"audio": local_result.audio_path.name,
544+
"model": local_result.model,
545+
"reused_audio": local_result.reused_audio,
546+
"reused_transcript": local_result.reused_transcript,
547+
}
472548

473549
if source_transcript_path is None:
474550
raise PreparationError(
475-
"gyte-transcript non ha prodotto un transcript individuabile."
551+
"Nessuna sorgente transcript utilizzabile è stata prodotta."
476552
)
477553

478554
if source_transcript_path != raw_path:
@@ -508,6 +584,7 @@ def prepare_transcript(
508584
video_id=video_id,
509585
source_transcript_path=source_transcript_path,
510586
source_mode=source_mode,
587+
evidence_origin=evidence_origin,
511588
raw_path=raw_path,
512589
normalized_path=normalized_path,
513590
analysis_text_path=analysis_text_path,
@@ -516,6 +593,7 @@ def prepare_transcript(
516593
normalized_words=normalized_words,
517594
analysis_words=analysis_words,
518595
reused=False,
596+
local_transcription=local_details,
519597
)
520598

521599
return PreparationResult(

0 commit comments

Comments
 (0)