feat: add SstData with DictionaryFile and word knowledge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(name: "SstCore"),
|
.target(name: "SstCore"),
|
||||||
|
.target(name: "SstData", dependencies: ["SstCore"]),
|
||||||
.target(
|
.target(
|
||||||
name: "SstWhisper",
|
name: "SstWhisper",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
@@ -19,6 +20,7 @@ let package = Package(
|
|||||||
.executableTarget(name: "Sst", dependencies: ["SstCore", "SstWhisper"]),
|
.executableTarget(name: "Sst", dependencies: ["SstCore", "SstWhisper"]),
|
||||||
.executableTarget(name: "SstProbe", dependencies: ["SstCore", "SstWhisper"]),
|
.executableTarget(name: "SstProbe", dependencies: ["SstCore", "SstWhisper"]),
|
||||||
.testTarget(name: "SstCoreTests", dependencies: ["SstCore"]),
|
.testTarget(name: "SstCoreTests", dependencies: ["SstCore"]),
|
||||||
|
.testTarget(name: "SstDataTests", dependencies: ["SstData", "SstCore"]),
|
||||||
.testTarget(name: "SstWhisperTests", dependencies: ["SstWhisper"]),
|
.testTarget(name: "SstWhisperTests", dependencies: ["SstWhisper"]),
|
||||||
],
|
],
|
||||||
swiftLanguageModes: [.v5]
|
swiftLanguageModes: [.v5]
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct DictionaryEntry: Identifiable, Equatable, Sendable {
|
||||||
|
public enum Kind: Equatable, Sendable {
|
||||||
|
case term(String)
|
||||||
|
case correction(heard: String, wrote: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
public let id: UUID
|
||||||
|
public var kind: Kind
|
||||||
|
|
||||||
|
public init(id: UUID = UUID(), kind: Kind) {
|
||||||
|
self.id = id
|
||||||
|
self.kind = kind
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Equality ignores `id`: two entries with the same content are the same entry.
|
||||||
|
public static func == (lhs: DictionaryEntry, rhs: DictionaryEntry) -> Bool { lhs.kind == rhs.kind }
|
||||||
|
|
||||||
|
/// The text the correction pass listens for (a term listens for itself).
|
||||||
|
public var source: String {
|
||||||
|
switch kind {
|
||||||
|
case .term(let term): term
|
||||||
|
case .correction(let heard, _): heard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text that ends up in the transcript.
|
||||||
|
public var wrote: String {
|
||||||
|
switch kind {
|
||||||
|
case .term(let term): term
|
||||||
|
case .correction(_, let wrote): wrote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DictionaryLine: Equatable, Sendable {
|
||||||
|
case entry(DictionaryEntry)
|
||||||
|
case comment(String)
|
||||||
|
case blank
|
||||||
|
case invalid(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dictionary as a list of lines, so comments, blank lines and order survive an edit.
|
||||||
|
public struct DictionaryFile: Equatable, Sendable {
|
||||||
|
public var lines: [DictionaryLine]
|
||||||
|
|
||||||
|
public init(lines: [DictionaryLine] = []) {
|
||||||
|
self.lines = lines
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(parsing text: String) {
|
||||||
|
var raw = text.replacingOccurrences(of: "\r\n", with: "\n")
|
||||||
|
.split(separator: "\n", omittingEmptySubsequences: false)
|
||||||
|
.map(String.init)
|
||||||
|
if raw.last == "" { raw.removeLast() }
|
||||||
|
lines = raw.map(Self.parseLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseLine(_ line: String) -> DictionaryLine {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.isEmpty { return .blank }
|
||||||
|
if trimmed.hasPrefix("#") { return .comment(line) }
|
||||||
|
if let arrow = trimmed.range(of: "=>") {
|
||||||
|
let heard = trimmed[..<arrow.lowerBound].trimmingCharacters(in: .whitespaces)
|
||||||
|
let wrote = trimmed[arrow.upperBound...].trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !heard.isEmpty, !wrote.isEmpty else { return .invalid(line) }
|
||||||
|
return .entry(DictionaryEntry(kind: .correction(heard: heard, wrote: wrote)))
|
||||||
|
}
|
||||||
|
return .entry(DictionaryEntry(kind: .term(trimmed)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func serialized() -> String {
|
||||||
|
let out = lines.map { line -> String in
|
||||||
|
switch line {
|
||||||
|
case .entry(let entry):
|
||||||
|
switch entry.kind {
|
||||||
|
case .term(let term): return term
|
||||||
|
case .correction(let heard, let wrote): return "\(heard) => \(wrote)"
|
||||||
|
}
|
||||||
|
case .comment(let text), .invalid(let text): return text
|
||||||
|
case .blank: return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.isEmpty ? "" : out.joined(separator: "\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
public var entries: [DictionaryEntry] {
|
||||||
|
lines.compactMap { line in
|
||||||
|
if case .entry(let entry) = line { return entry }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public mutating func append(_ kind: DictionaryEntry.Kind) -> UUID {
|
||||||
|
let entry = DictionaryEntry(kind: kind)
|
||||||
|
lines.append(.entry(entry))
|
||||||
|
return entry.id
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func update(id: UUID, kind: DictionaryEntry.Kind) {
|
||||||
|
guard let index = lines.firstIndex(where: { if case .entry(let e) = $0 { return e.id == id } else { return false } }) else { return }
|
||||||
|
lines[index] = .entry(DictionaryEntry(id: id, kind: kind))
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func remove(id: UUID) {
|
||||||
|
lines.removeAll { if case .entry(let e) = $0 { return e.id == id } else { return false } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import NaturalLanguage
|
||||||
|
|
||||||
|
public protocol WordKnowledge {
|
||||||
|
/// A common word in Dutch or English (case-insensitive). Rare words, names and product names are not.
|
||||||
|
func isWord(_ word: String) -> Bool
|
||||||
|
/// A very frequent function word ("in", "de", "the", "of").
|
||||||
|
func isFunctionWord(_ word: String) -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splitting and normalising the left-hand side of an entry, shared by the engine and the warnings.
|
||||||
|
enum EntryKey {
|
||||||
|
static func parts(_ source: String) -> [String] {
|
||||||
|
source.split(whereSeparator: { $0.isWhitespace || "-‐‑–".contains($0) }).map(String.init)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spacing, hyphens and case do not matter: "Cloud-Code", "cloud code" and "cloudcode" are the same source.
|
||||||
|
static func key(_ source: String) -> String {
|
||||||
|
parts(source).joined().lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most frequent Dutch and English function words.
|
||||||
|
enum CommonWords {
|
||||||
|
static let english: Set<String> = [
|
||||||
|
"a", "about", "after", "all", "also", "an", "and", "any", "are", "as", "at", "be", "because", "been", "but",
|
||||||
|
"by", "can", "could", "did", "do", "does", "for", "from", "had", "has", "have", "he", "her", "here", "him",
|
||||||
|
"his", "how", "i", "if", "in", "into", "is", "it", "its", "just", "me", "more", "most", "my", "no", "not",
|
||||||
|
"now", "of", "on", "one", "only", "or", "other", "our", "out", "over", "she", "so", "some", "such", "than",
|
||||||
|
"that", "the", "their", "them", "then", "there", "these", "they", "this", "those", "to", "up", "us", "very",
|
||||||
|
"was", "we", "were", "what", "when", "which", "who", "will", "with", "would", "you", "your",
|
||||||
|
]
|
||||||
|
static let dutch: Set<String> = [
|
||||||
|
"aan", "al", "als", "ben", "bij", "dan", "dat", "de", "den", "der", "deze", "die", "dit", "doen", "door",
|
||||||
|
"dus", "een", "en", "er", "ga", "had", "heb", "heeft", "hem", "het", "hier", "hij", "hoe", "ik", "in", "is",
|
||||||
|
"ja", "je", "jij", "kan", "kon", "maar", "me", "mij", "mijn", "moet", "na", "naar", "niet", "nog", "nu",
|
||||||
|
"of", "om", "ons", "onze", "ook", "op", "over", "te", "ten", "ter", "toch", "toen", "tot", "u", "uit", "van",
|
||||||
|
"veel", "voor", "was", "wat", "we", "wel", "werd", "wij", "wil", "worden", "wordt", "zal", "ze", "zei",
|
||||||
|
"zich", "zij", "zijn", "zo", "zou",
|
||||||
|
]
|
||||||
|
static let all: Set<String> = english.union(dutch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// English: the on-device word list of `NaturalLanguage` (common words only, so "cloud" and "reef" are in, "anthropic" and "vercel" are not).
|
||||||
|
/// Dutch has no such list, so the spell checker decides. Function words come from the built-in list.
|
||||||
|
public final class SystemWordKnowledge: WordKnowledge, @unchecked Sendable {
|
||||||
|
private let englishWords = NLEmbedding.wordEmbedding(for: .english)
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func isWord(_ word: String) -> Bool {
|
||||||
|
let lowered = word.lowercased()
|
||||||
|
guard !lowered.isEmpty else { return false }
|
||||||
|
if let englishWords {
|
||||||
|
if englishWords.contains(lowered) { return true }
|
||||||
|
} else if spellChecks(lowered, language: "en") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return spellChecks(lowered, language: "nl")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func isFunctionWord(_ word: String) -> Bool {
|
||||||
|
CommonWords.all.contains(word.lowercased())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func spellChecks(_ word: String, language: String) -> Bool {
|
||||||
|
NSSpellChecker.shared.checkSpelling(
|
||||||
|
of: word, startingAt: 0, language: language, wrap: false,
|
||||||
|
inSpellDocumentWithTag: 0, wordCount: nil).location == NSNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import SstData
|
||||||
|
|
||||||
|
struct DictionaryFileTests {
|
||||||
|
let sample = "# Opmerking\nAnthropic\n\ncloud code => Claude Code\n"
|
||||||
|
|
||||||
|
@Test func parsesTermsPairsCommentsAndBlanks() {
|
||||||
|
let file = DictionaryFile(parsing: sample)
|
||||||
|
#expect(file.lines.count == 4)
|
||||||
|
#expect(file.entries == [term("Anthropic"), pair("cloud code", "Claude Code")])
|
||||||
|
#expect(file.lines[0] == .comment("# Opmerking"))
|
||||||
|
#expect(file.lines[2] == .blank)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func roundTripKeepsCommentsBlankLinesAndOrder() {
|
||||||
|
#expect(DictionaryFile(parsing: sample).serialized() == sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func normalisesSpacingAroundTheArrow() {
|
||||||
|
#expect(DictionaryFile(parsing: "a=>b\n").serialized() == "a => b\n")
|
||||||
|
#expect(DictionaryFile(parsing: " cloud code => Claude Code \n").entries == [pair("cloud code", "Claude Code")])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unreadableLinesAreKeptAsInvalid() {
|
||||||
|
let file = DictionaryFile(parsing: "oops =>\n=> nothing\nok\n")
|
||||||
|
#expect(file.lines[0] == .invalid("oops =>"))
|
||||||
|
#expect(file.lines[1] == .invalid("=> nothing"))
|
||||||
|
#expect(file.serialized() == "oops =>\n=> nothing\nok\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func handlesWindowsLineEndingsAndMissingFinalNewline() {
|
||||||
|
let file = DictionaryFile(parsing: "a\r\nb => c")
|
||||||
|
#expect(file.entries == [term("a"), pair("b", "c")])
|
||||||
|
#expect(file.serialized() == "a\nb => c\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptyTextIsAValidEmptyFile() {
|
||||||
|
#expect(DictionaryFile(parsing: "").entries.isEmpty)
|
||||||
|
#expect(DictionaryFile(parsing: "").serialized() == "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func appendUpdateAndRemove() {
|
||||||
|
var file = DictionaryFile(parsing: "# c\n")
|
||||||
|
let id = file.append(.term("Vercel"))
|
||||||
|
#expect(file.entries == [term("Vercel")])
|
||||||
|
file.update(id: id, kind: .correction(heard: "versel", wrote: "Vercel"))
|
||||||
|
#expect(file.entries == [pair("versel", "Vercel")])
|
||||||
|
file.remove(id: id)
|
||||||
|
#expect(file.entries.isEmpty)
|
||||||
|
#expect(file.serialized() == "# c\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import Foundation
|
||||||
|
@testable import SstData
|
||||||
|
|
||||||
|
/// Deterministic stand-in for the macOS spell checker.
|
||||||
|
struct FakeKnowledge: WordKnowledge {
|
||||||
|
var words: Set<String> = ["cloud", "code", "some", "thing", "something", "reef", "slack", "in", "de", "the", "of"]
|
||||||
|
var functionWords: Set<String> = ["in", "de", "the", "of"]
|
||||||
|
func isWord(_ word: String) -> Bool { words.contains(word.lowercased()) }
|
||||||
|
func isFunctionWord(_ word: String) -> Bool { functionWords.contains(word.lowercased()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func term(_ text: String) -> DictionaryEntry { DictionaryEntry(kind: .term(text)) }
|
||||||
|
func pair(_ heard: String, _ wrote: String) -> DictionaryEntry { DictionaryEntry(kind: .correction(heard: heard, wrote: wrote)) }
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import SstData
|
||||||
|
|
||||||
|
struct SystemWordKnowledgeTests {
|
||||||
|
let knowledge = SystemWordKnowledge()
|
||||||
|
|
||||||
|
@Test func recognisesEnglishAndDutchWords() {
|
||||||
|
#expect(knowledge.isWord("cloud"))
|
||||||
|
#expect(knowledge.isWord("Cloud"))
|
||||||
|
#expect(knowledge.isWord("vandaag"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func doesNotTreatProductNamesAsWords() {
|
||||||
|
#expect(!knowledge.isWord("vercel"))
|
||||||
|
#expect(!knowledge.isWord("supabase"))
|
||||||
|
#expect(!knowledge.isWord("anthropic"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func functionWordsComeFromTheBuiltInList() {
|
||||||
|
#expect(knowledge.isFunctionWord("de"))
|
||||||
|
#expect(knowledge.isFunctionWord("The"))
|
||||||
|
#expect(!knowledge.isFunctionWord("vercel"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user