Skip to content

Commit fda2ebf

Browse files
dkackmanclaude
andcommitted
fix(check-script): #965 - a second copy of a line inside its matched words is unscripted_speech, not absorbed into the line
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 6f7fd3d commit fda2ebf

2 files changed

Lines changed: 55 additions & 0 deletions

File tree

‎dw/tasks/script_check.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,35 @@ def align(expected_lines, heard):
390390
return assigned, unassigned
391391

392392

393+
def _release_repeats(expected, heard, assigned, unassigned):
394+
"""Take a second copy of a line out of the words align gave it. Align
395+
keeps a heard word inserted inside a line with that line, so a doubled
396+
line ("Then wear socks. Wear socks.") would read as one line at a lower
397+
similarity - above the mismatch threshold, so no finding. A run of two or
398+
more extra words (one for a one-word line) that is the line, or a stretch
399+
of it, goes back to `unassigned` so unscripted_speech sees it."""
400+
for index, words in enumerate(expected):
401+
own = assigned[index]
402+
if len(own) <= len(words) or not words:
403+
continue
404+
heard_words = [heard[j]["word"] for j in own]
405+
matcher = difflib.SequenceMatcher(None, words, heard_words, autojunk=False)
406+
moved = set()
407+
for op, _i1, _i2, j1, j2 in matcher.get_opcodes():
408+
run = heard_words[j1:j2]
409+
if (
410+
op == "insert"
411+
and len(run) >= min(2, len(words))
412+
and (_contains(words, run) or _contains(run, words))
413+
):
414+
moved.update(range(j1, j2))
415+
if not moved:
416+
continue
417+
unassigned.extend((own[k], (index,)) for k in sorted(moved))
418+
assigned[index] = [j for k, j in enumerate(own) if k not in moved]
419+
unassigned.sort()
420+
421+
393422
def line_similarity(expected, heard):
394423
"""How alike two word lists are, 0..1. Two empty lists are identical."""
395424
if not expected and not heard:
@@ -509,6 +538,7 @@ def check(
509538

510539
expected = [normalize_words(line["text"]) for line in parsed_lines]
511540
assigned, unassigned = align(expected, heard)
541+
_release_repeats(expected, heard, assigned, unassigned)
512542
mismatch = mismatch_rule(similarity_threshold)
513543
by_name = {span["name"]: span for span in spans or ()}
514544

‎tests/test_script_check.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,31 @@ def test_a_line_spoken_twice_in_its_shot_is_a_finding(self):
687687
self.assertEqual(found["at"]["shot"], "A")
688688
self.assertTrue(found["at"]["repeats_line"])
689689

690+
def test_a_doubled_line_inside_a_longer_cut_is_a_finding(self):
691+
# align alone keeps the second copy inside the line (similarity 0.75)
692+
chunks = (
693+
words("hal kettle froze", 0.0)
694+
+ words("then wear socks", 6.3)
695+
+ words("wear socks", 9.1)
696+
+ words("the kettle is whistling hal", 11.0)
697+
)
698+
lines = [
699+
{"text": "Hal, the kettle froze.", "shot": "A"},
700+
{"text": "Then wear socks.", "shot": "B"},
701+
{"text": "The kettle is whistling, Hal.", "shot": "C"},
702+
]
703+
spans = [
704+
{"name": "A", "start": 0.0, "end": 5.0},
705+
{"name": "B", "start": 5.0, "end": 10.3},
706+
{"name": "C", "start": 10.3, "end": 16.0},
707+
]
708+
answer = shot_run(lines, chunks, mono=voiced(16.0), spans=spans)
709+
self.assertEqual(answer["lines"][1]["similarity"], 1.0)
710+
(found,) = rules(answer, "unscripted_speech")
711+
self.assertEqual(found["at"]["shot"], "B")
712+
self.assertTrue(found["at"]["repeats_line"])
713+
self.assertEqual([w["text"] for w in answer["unmatched"]], ["wear", "socks"])
714+
690715
def test_an_ad_lib_in_a_scripted_shot_is_not_a_repeat(self):
691716
chunks = words("alpha bravo", 0.0) + words("zulu yankee", 1.0)
692717
answer = shot_run([{"text": "alpha bravo", "shot": "A"}], chunks)

0 commit comments

Comments
 (0)