Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 83 additions & 15 deletions Fixtures/cleanup/guard.jsonl

Large diffs are not rendered by default.

74 changes: 46 additions & 28 deletions Packages/LiveTranscribeKit/Sources/Cleanup/SelfRepair.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ import Shared
/// of the earlier sentence kept. One may not answer a question: "Is it tomorrow? No, the day after."
/// keeps its "No". A cue's words are taken out only with the correction they make, and never
/// changed ("make that" is not "made that"). Nor may a correction that opens a later sentence
/// be dropped whole, leaving what it corrects as said;
/// be dropped whole, leaving what it corrects as said. Whichever way, the correction keeps its
/// meaning: the word that says what it says instead stays, and what it takes back is not
/// written again ("the blue room, sorry, the green room" is never "the blue room" or "the blue
/// green room");
/// - inside a correction phrase, up to ``maxRepairWords`` new words or changed words, and the words
/// it corrects, which is how a garbled phrase is read as meant ("tomorrow. No, sorry, the after
/// tomorrow" → "the day after tomorrow");
Expand Down Expand Up @@ -142,34 +145,36 @@ struct SelfRepair: Sendable {
for line in text.split(whereSeparator: \.isNewline) {
var startsSentence = true
let lineStart = words.count
for token in line.split(whereSeparator: \.isWhitespace) {
for part in token.split(whereSeparator: hyphens.contains) {
let trailing = part.reversed().prefix { !$0.isLetter && !$0.isNumber }
let ends = trailing.contains(where: sentenceEnders.contains)
let asks = trailing.contains("?")
let normalized = EditDistance.words(in: EditDistance.normalize(String(part)))
if normalized.isEmpty {
if ends, let last = words.indices.last, last >= lineStart {
words[last].endsSentence = true
words[last].endsQuestion = asks
}
}
for (offset, word) in normalized.enumerated() {
let isLastOfPart = offset == normalized.count - 1
let upper = offset == 0 && (part.first(where: \.isLetter)?.isUppercase ?? false)
let couldBeName = upper && !functionWords.contains(word) && !placeholders.contains(word)
words.append(SaidWord(
word: word,
endsSentence: isLastOfPart && ends,
endsQuestion: isLastOfPart && asks,
isName: couldBeName && !startsSentence,
isCapitalised: upper && !startsSentence,
mayBeName: couldBeName,
startsSentence: startsSentence && offset == 0
))
let parts = line.split(whereSeparator: \.isWhitespace).flatMap { $0.split(whereSeparator: hyphens.contains) }
for (index, part) in parts.enumerated() {
let trailing = part.reversed().prefix { !$0.isLetter && !$0.isNumber }
// An abbreviation's own full stop ("at 3 p.m. today") ends a sentence only before a
// capital.
let abbreviation = isDottedAbbreviation(part) && index + 1 < parts.count && !startsWithUppercase(parts[index + 1])
let ends = !abbreviation && trailing.contains(where: sentenceEnders.contains)
let asks = trailing.contains("?")
let normalized = EditDistance.words(in: EditDistance.normalize(String(part)))
if normalized.isEmpty {
if ends, let last = words.indices.last, last >= lineStart {
words[last].endsSentence = true
words[last].endsQuestion = asks
}
if !normalized.isEmpty { startsSentence = ends }
}
for (offset, word) in normalized.enumerated() {
let isLastOfPart = offset == normalized.count - 1
let upper = offset == 0 && startsWithUppercase(part)
let couldBeName = upper && !functionWords.contains(word) && !placeholders.contains(word)
words.append(SaidWord(
word: word,
endsSentence: isLastOfPart && ends,
endsQuestion: isLastOfPart && asks,
isName: couldBeName && !startsSentence,
isCapitalised: upper && !startsSentence,
mayBeName: couldBeName,
startsSentence: startsSentence && offset == 0
))
}
if !normalized.isEmpty { startsSentence = ends }
}
if let last = words.indices.last, last >= lineStart, !words[last].endsSentence {
words[last].endsSentence = true
Expand All @@ -179,6 +184,18 @@ struct SelfRepair: Sendable {
return words
}

/// Whether `part` is only letters each followed by a full stop ("p.m.", "e.g.", "U.S.").
private static func isDottedAbbreviation(_ part: Substring) -> Bool {
let characters = Array(part)
guard characters.count >= 4, characters.count.isMultiple(of: 2) else { return false }
return stride(from: 0, to: characters.count, by: 2).allSatisfy { characters[$0].isLetter && characters[$0 + 1] == "." }
}

/// Whether the first letter of `part` is a capital.
private static func startsWithUppercase(_ part: Substring) -> Bool {
part.first(where: \.isLetter)?.isUppercase ?? false
}

/// The words of `text`, with the numbers and bullets that start its list items taken off.
static func writtenWords(in text: String) -> [WrittenWord] {
var words: [WrittenWord] = []
Expand All @@ -193,7 +210,7 @@ struct SelfRepair: Sendable {
words.append(WrittenWord(
word: word,
original: String(part),
isCapitalised: offset == 0 && (part.first(where: \.isLetter)?.isUppercase ?? false),
isCapitalised: offset == 0 && startsWithUppercase(part),
startsSentence: startsSentence,
startsListItem: isListItem && firstOfLine
))
Expand Down Expand Up @@ -320,6 +337,7 @@ struct SelfRepair: Sendable {
words.prefix(2).map(\.word) == ["scratch", "that"]
}
func isFunctionWord(_ word: String) -> Bool { functionWords.contains(word) }
var allFunctionWords: Set<String> { functionWords }
var respellingSimilarity: Double { minRespellingSimilarity }
var retractionLimit: Int { maxRetractedWords }
var correctionCues: [[String]] { cues }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ extension SelfRepair {
/// a word of a phrase has been written, the words it corrected may be written too ("three
/// servers, sorry, four" → "four servers"; "next week, sorry, the after next" → "the week
/// after next"), until the word after the phrase is read; never before, which would keep what
/// was taken back and drop only the cue.
/// was taken back and drop only the cue. The words its key word takes back are never written
/// again, and the key word itself is changed only into a word like it
/// (``SelfRepair/keyWord(of:correcting:)``).
struct Alignment {
let repair: SelfRepair
let said: [SaidWord]
Expand All @@ -30,6 +32,12 @@ extension SelfRepair {
/// For each said index, the words corrected by the phrase that covers it, which a repair
/// may not add there as new words.
private let corrected: [Set<String>]
/// For each said index, the corrected words the key word of the phrase that covers it
/// takes back, which a repair may not write there either.
private let taken: [Set<String>]
/// Whether each said word is the key word of a correction phrase, which a repair may not
/// change.
private let key: [Bool]
/// The words of the cues said, whose forms a repair may not add ("make that" → "made that").
private let cueWords: Set<String>

Expand Down Expand Up @@ -57,12 +65,35 @@ extension SelfRepair {
corrected[index].formUnion(words)
}
}
var taken = Array(repeating: Set<String>(), count: said.count + 1)
for (start, words) in corrections.taken {
for index in start...min(start + min(Self.phraseLength(from: start, in: said), Self.phrase), said.count) {
taken[index].formUnion(words)
}
}
var keys = corrections.keys
for (start, word) in said.enumerated() where word.opensPhrase > 0 {
cover(from: start, length: word.opensPhrase, with: Set(word.spare))
for index in start...min(start + word.opensPhrase, said.count) { corrected[index].formUnion(word.spare) }
let phrase = said[start..<min(start + word.opensPhrase, said.count)]
let spareWords = word.spare.map { SaidWord(word: $0, endsSentence: false, endsQuestion: false, isName: false) }[...]
if let keyIndex = repair.keyWord(of: phrase, correcting: spareWords) {
keys.append(start + keyIndex)
let words = repair.takenBack(by: phrase, from: spareWords)
for index in start...min(start + word.opensPhrase, said.count) { taken[index].formUnion(words) }
}
}
// The key word said again in a row is the key word too: either copy may be the one kept.
var key = Array(repeating: false, count: said.count)
for index in keys {
var end = index
while end + 1 < said.count, said[end + 1].word == said[index].word { end += 1 }
for position in index...end { key[position] = true }
}
self.spare = spare
self.corrected = corrected
self.taken = taken
self.key = key
cueWords = Set(said.filter(\.isCue).map(\.word))
}

Expand Down Expand Up @@ -116,12 +147,13 @@ extension SelfRepair {
if state != 0, j < m {
let (left, repairsLeft, begun) = Self.decode(state)
let new = isRepair(written[j]) && !corrected[i].contains(written[j].word)
if begun, spare[i].contains(written[j].word), !placeholders.contains(written[j].word) {
if begun, spare[i].contains(written[j].word), !taken[i].contains(written[j].word),
!placeholders.contains(written[j].word) {
steps.append((0, 1, state))
} else if left > 0, repairsLeft > 0, new {
steps.append((0, 1, Self.encode(left: left, repairs: repairsLeft - 1, begun: begun)))
}
if left > 0, repairsLeft > 0, i < n, isReplaceable(said[i]), new {
if left > 0, repairsLeft > 0, i < n, mayStandIn(written[j].word, at: i), isReplaceable(said[i]), new {
let after = consuming(1, from: i, in: Self.encode(left: left, repairs: repairsLeft - 1, begun: begun), writing: true)
steps.append((1, 1, after))
}
Expand Down Expand Up @@ -255,6 +287,17 @@ extension SelfRepair {
&& !cueWords.contains { $0 == word.word || WordForms.areForms($0, word.word) }
}

/// Whether a repair may put `word` in place of said word `i`: any word, unless that is a
/// correction's key word, which only a word like it may stand in for.
private func mayStandIn(_ word: String, at i: Int) -> Bool {
!key[i] || EditDistance.normalizedSimilarity(said[i].word, word) >= Self.minKeyWordSimilarity
}

/// How alike a correction's key word and a word a repair puts in its place must be: a
/// garbled key word may be read as meant ("busses" → "buses"), never as another word
/// ("busses" → "trains").
static let minKeyWordSimilarity = 0.5

private func isReplaceable(_ word: SaidWord) -> Bool {
!repair.isProtected(word.word, placeholders: placeholders) && !word.isName && !word.isCue
}
Expand Down
Loading
Loading