@@ -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+
393422def 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
0 commit comments