fix: keep chunk details out of speaker headers

Render transcript-wide speaker numbers instead of exposing upload chunks in
reader-facing headers. Reconcile adjacent requests using at least two
unambiguous word-and-time matches in their shared audio, including when
provider speaker IDs change between requests. Reject conflicting mappings
and never treat matching numeric provider IDs as identity evidence.

Assign fresh IDs where continuity cannot be established and report that
uncertainty on stderr. Number only speakers retained after overlap trimming.
Document that overlap matching cannot identify a returning speaker who was
absent from the overlap, and add the reader-facing boundary to AGENTS.md.

Test Plan:
- python3 -B -m unittest -v: all 46 tests passed, including swapped IDs,
  ambiguous evidence, continuity across three requests, and plain headers.
- git diff --cached --check: passed.
- Tests use synthetic speaker labels; live diarization accuracy was not
  verified because provider credentials are unavailable in this environment.
This commit is contained in:
ddidderr committed 2026-09-12 09:30:46 +02:00
1 parent 6e94379a25
commit 5dd1b5811b
7 files changed
+129 -32

No files matched your search

+65 -11
View File
@@ -74,13 +74,8 @@ def token(word):
return "".join(c for c in word["word"].casefold() if c.isalnum())
def stitch(left, right, seam):
"""Switch transcripts at a shared word, away from either audio cut.
Match text AND time to avoid deleting intentional repetitions. Prefer an
anchor with a matching neighbour; accept a single shared word when speech
is sparse. Timestamp ownership is the fallback if hypotheses disagree.
"""
def overlap_matches(left, right, seam):
"""Find matching words at matching times in the shared audio."""
radius = CONTEXT - 0.5
left_near = [(i, word, token(word)) for i, word in enumerate(left)
if abs(midpoint(word) - seam) <= radius]
@@ -92,6 +87,17 @@ def stitch(left, right, seam):
difference = abs(midpoint(a) - midpoint(b))
if a_token and a_token == b_token and difference <= 1.0:
matches[i, j] = (difference, abs((midpoint(a) + midpoint(b)) / 2 - seam))
return matches, bool(left_near or right_near)
def stitch(left, right, seam):
"""Switch transcripts at a shared word, away from either audio cut.
Match text AND time to avoid deleting intentional repetitions. Prefer an
anchor with a matching neighbour; accept a single shared word when speech
is sparse. Timestamp ownership is the fallback if hypotheses disagree.
"""
matches, speech_near = overlap_matches(left, right, seam)
if matches:
def score(pair):
i, j = pair
@@ -108,7 +114,7 @@ def stitch(left, right, seam):
i, j = min(matches, key=score)
return left[:i + 1] + right[j + 1:]
if left_near or right_near:
if speech_near:
print(
f"Warning: no shared words near {seam:.2f}s; joining by timestamps. "
"Review the transcript at this boundary.", file=sys.stderr,
@@ -117,10 +123,38 @@ def stitch(left, right, seam):
[word for word in right if midpoint(word) >= seam])
def speaker_mapping(left, right, seam):
"""Reuse a speaker ID only with unambiguous shared-word evidence.
Require two distinct word pairs, with no competing speaker correspondence
in either direction. Numeric provider IDs alone carry no identity evidence.
"""
matches, _ = overlap_matches(left, right, seam)
left_words, right_words = {}, {}
candidates, reverse = {}, {}
for i, j in matches:
left_words.setdefault(i, set()).add(j)
right_words.setdefault(j, set()).add(i)
old, new = left[i].get("speaker"), right[j].get("speaker")
if old is not None and new is not None:
candidates.setdefault(new, set()).add(old)
reverse.setdefault(old, set()).add(new)
support = {}
for i, j in matches:
old, new = left[i].get("speaker"), right[j].get("speaker")
if (old is not None and new is not None and
len(left_words[i]) == len(right_words[j]) == 1):
support[new, old] = support.get((new, old), 0) + 1
return {new: old for (new, old), count in support.items()
if count >= 2 and len(candidates[new]) == len(reverse[old]) == 1}
def merge(chunks, responses):
if len(chunks) != len(responses) or not chunks:
raise ValueError("Expected one transcription response per chunk")
merged = []
previous = []
next_speaker = 0
for index, (chunk, response) in enumerate(zip(chunks, responses)):
try:
words = normalized_words(response, "Chunk stitching")
@@ -129,12 +163,32 @@ def merge(chunks, responses):
raise ValueError("Word timestamp exceeds the chunk duration")
word["start"] += chunk["start"]
word["end"] += chunk["start"]
if word.get("speaker") is not None:
# Speaker IDs belong to individual requests, not the file.
word["speaker"] = (index, word["speaker"])
mapping = speaker_mapping(previous, words, chunk["seam"])
unmatched = False
for word in words:
speaker = word.get("speaker")
if speaker is not None:
if speaker not in mapping:
mapping[speaker] = next_speaker
next_speaker += 1
unmatched = True
word["speaker"] = mapping[speaker]
if index and unmatched:
print(
f"Warning: speaker continuity is uncertain near {chunk['seam']:.2f}s; "
"unmatched speakers received new transcript IDs.", file=sys.stderr,
)
except ValueError as error:
raise ValueError(f"Chunk {index + 1}: {error}") from error
merged = stitch(merged, words, chunk["seam"]) if index else words
previous = words
# Overlap trimming may discard a speaker's only words. Number only the
# speakers present in the final transcript, in order of first appearance.
visible_speakers = {}
for word in merged:
speaker = word.get("speaker")
if speaker is not None:
word["speaker"] = visible_speakers.setdefault(speaker, len(visible_speakers))
return {"text": " ".join(word["word"] for word in merged), "words": merged}