Skip to content

Commit 5e90e81

Browse files
authored
Add deterministic casing and spoken list semantics (#41)
## Summary - add typed Style Default, Lowercase Prose, and Strict Lowercase policies - make explicit bullet commands and confident spoken list intent deterministic - share casing and list semantics with the portable iOS core - record ADR 0053 and update canonical behavior docs ## Verification - TDD regression suites for casing, list intent, spoken-edit replay, controller fallback, History reformatting, preferences migration, and iOS document pipeline - full macOS and iOS checks passed before branch extraction - rebased cleanly onto current main
1 parent dc69d08 commit 5e90e81

29 files changed

Lines changed: 906 additions & 111 deletions

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,14 @@ supported recognition errors, and applies the selected Natural, Casual
201201
Message, Formal, Technical, or Verbatim Style. It creates validated paragraph
202202
and list blocks, then preserves or flattens structure for the target. It
203203
also applies exact spoken commands such as **scratch that**, **delete that
204-
sentence**, **new paragraph**, and numbered-list boundaries before formatting;
204+
sentence**, **new paragraph**, **start a bullet list**, **bullet**, **next
205+
item**, and numbered-list boundaries before formatting;
205206
say **literal** immediately before a command phrase to keep the phrase. It
207+
normalizes conservative grocery, shopping, packing, task, explicit-marker,
208+
and sequential-ordinal list cues before formatting. Typed casing policy can
209+
retain the selected Style, lowercase prose while preserving source-signaled
210+
names, or enforce strict lowercase while protecting operational tokens. The
211+
same casing and spoken-list rules run on macOS and iOS. It
206212
validates protected numbers, URLs, email addresses, paths, code-like tokens,
207213
quotations, and dictionary terms. A provider error, invalid output, or
208214
three-second deadline delivers the deterministic Edited transcript once when

Sources/HardwareControllerApp/application_preferences.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ struct PreferredMicrophone: Codable, Equatable, Identifiable, Sendable {
4242

4343
/// Stores versioned application presentation preferences.
4444
struct ApplicationPreferences: Codable, Equatable, Sendable {
45-
static let currentSchemaVersion = 6
45+
static let currentSchemaVersion = 7
4646

4747
var appearance: ApplicationAppearance
4848
var sidebarVisibility: SidebarVisibilityPreference
@@ -306,7 +306,7 @@ struct ApplicationPreferencesStore:
306306
_ preferences: ApplicationPreferences
307307
) throws -> ApplicationPreferences {
308308
switch preferences.schemaVersion {
309-
case 1, 2, 3, 4, 5:
309+
case 1, 2, 3, 4, 5, 6:
310310
var migrated = preferences
311311
migrated.schemaVersion = ApplicationPreferences.currentSchemaVersion
312312
if preferences.schemaVersion == 1 {

Sources/HardwareControllerCore/local_ai_dictation.swift

Lines changed: 36 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -45,42 +45,6 @@ public struct LocalAIModelSelection: Codable, Equatable, Sendable {
4545
}
4646
}
4747

48-
public struct PersonalDictionaryReplacement:
49-
Codable,
50-
Equatable,
51-
Identifiable,
52-
Sendable
53-
{
54-
public let id: UUID
55-
public var spokenForm: String
56-
public var replacement: String
57-
58-
public init(
59-
id: UUID = UUID(),
60-
spokenForm: String,
61-
replacement: String
62-
) {
63-
self.id = id
64-
self.spokenForm = spokenForm
65-
self.replacement = replacement
66-
}
67-
}
68-
69-
public struct PersonalDictionary: Codable, Equatable, Sendable {
70-
public var vocabulary: [String]
71-
public var replacements: [PersonalDictionaryReplacement]
72-
73-
public init(
74-
vocabulary: [String] = [],
75-
replacements: [PersonalDictionaryReplacement] = []
76-
) {
77-
self.vocabulary = vocabulary
78-
self.replacements = replacements
79-
}
80-
81-
public static let empty = PersonalDictionary()
82-
}
83-
8448
public struct LocalAISettings: Codable, Equatable, Sendable {
8549
public static let defaultRecommendedModelName = "qwen3.5:4b"
8650

@@ -91,6 +55,7 @@ public struct LocalAISettings: Codable, Equatable, Sendable {
9155
public var dictionary: PersonalDictionary
9256
public var additionalInstructions: String
9357
public var style: VoiceStyle
58+
public var casingPolicy: VoiceCasingPolicy
9459

9560
public init(
9661
provider: LocalAIProviderKind = .appleOnDevice,
@@ -101,7 +66,8 @@ public struct LocalAISettings: Codable, Equatable, Sendable {
10166
includeNearbyText: Bool = false,
10267
dictionary: PersonalDictionary = .empty,
10368
additionalInstructions: String = "",
104-
style: VoiceStyle = .natural
69+
style: VoiceStyle = .natural,
70+
casingPolicy: VoiceCasingPolicy = .styleDefault
10571
) {
10672
self.provider = provider
10773
self.ollamaModel = ollamaModel
@@ -110,6 +76,7 @@ public struct LocalAISettings: Codable, Equatable, Sendable {
11076
self.dictionary = dictionary
11177
self.additionalInstructions = additionalInstructions
11278
self.style = style
79+
self.casingPolicy = casingPolicy
11380
}
11481

11582
private enum CodingKeys: String, CodingKey {
@@ -120,6 +87,7 @@ public struct LocalAISettings: Codable, Equatable, Sendable {
12087
case dictionary
12188
case additionalInstructions
12289
case style
90+
case casingPolicy
12391
}
12492

12593
public init(from decoder: any Decoder) throws {
@@ -140,6 +108,11 @@ public struct LocalAISettings: Codable, Equatable, Sendable {
140108
forKey: .additionalInstructions
141109
)
142110
style = try container.decodeIfPresent(VoiceStyle.self, forKey: .style) ?? .natural
111+
casingPolicy =
112+
try container.decodeIfPresent(
113+
VoiceCasingPolicy.self,
114+
forKey: .casingPolicy
115+
) ?? .styleDefault
143116
}
144117

145118
public func encode(to encoder: any Encoder) throws {
@@ -151,6 +124,7 @@ public struct LocalAISettings: Codable, Equatable, Sendable {
151124
try container.encode(dictionary, forKey: .dictionary)
152125
try container.encode(additionalInstructions, forKey: .additionalInstructions)
153126
try container.encode(style, forKey: .style)
127+
try container.encode(casingPolicy, forKey: .casingPolicy)
154128
}
155129

156130
public static let `default` = LocalAISettings()
@@ -170,6 +144,23 @@ public enum LocalAISettingsValidationError: Error, Equatable, Sendable {
170144
}
171145

172146
extension LocalAISettings {
147+
public var effectiveCasingPolicy: VoiceCasingPolicy {
148+
guard casingPolicy == .styleDefault else {
149+
return casingPolicy
150+
}
151+
let instruction =
152+
additionalInstructions
153+
.split(whereSeparator: { $0.isWhitespace })
154+
.joined(separator: " ")
155+
.lowercased()
156+
let requestsOnlyLowercase =
157+
instruction.contains("only provide text in lowercase")
158+
|| instruction.contains("only use lowercase")
159+
|| instruction.contains("all lowercase")
160+
|| instruction.contains("lowercase only")
161+
return requestsOnlyLowercase ? .strictLowercase : .styleDefault
162+
}
163+
173164
public func validate() throws {
174165
guard style.revision == VoiceStyle.currentRevision else {
175166
throw LocalAISettingsValidationError.unsupportedStyleRevision(
@@ -257,21 +248,28 @@ public struct LocalAIRefinementRequest: Equatable, Sendable {
257248
public let dictionary: PersonalDictionary
258249
public let additionalInstructions: String
259250
public let style: VoiceStyle
251+
public let casingPolicy: VoiceCasingPolicy
252+
public let listIntent: VoiceListIntent
260253

261254
public init(
262255
sessionID: UUID,
263256
transcript: String,
264257
context: LocalAITargetContext,
265258
dictionary: PersonalDictionary,
266259
additionalInstructions: String,
267-
style: VoiceStyle = .natural
260+
style: VoiceStyle = .natural,
261+
casingPolicy: VoiceCasingPolicy = .styleDefault,
262+
listIntent: VoiceListIntent? = nil
268263
) {
269264
self.sessionID = sessionID
270265
self.transcript = transcript
271266
self.context = context
272267
self.dictionary = dictionary
273268
self.additionalInstructions = additionalInstructions
274269
self.style = style
270+
self.casingPolicy = casingPolicy
271+
self.listIntent =
272+
listIntent ?? VoiceListIntentDetector().detect(in: transcript)
275273
}
276274
}
277275

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import Foundation
2+
3+
public struct PersonalDictionaryReplacement:
4+
Codable,
5+
Equatable,
6+
Identifiable,
7+
Sendable
8+
{
9+
public let id: UUID
10+
public var spokenForm: String
11+
public var replacement: String
12+
13+
public init(
14+
id: UUID = UUID(),
15+
spokenForm: String,
16+
replacement: String
17+
) {
18+
self.id = id
19+
self.spokenForm = spokenForm
20+
self.replacement = replacement
21+
}
22+
}
23+
24+
public struct PersonalDictionary: Codable, Equatable, Sendable {
25+
public var vocabulary: [String]
26+
public var replacements: [PersonalDictionaryReplacement]
27+
28+
public init(
29+
vocabulary: [String] = [],
30+
replacements: [PersonalDictionaryReplacement] = []
31+
) {
32+
self.vocabulary = vocabulary
33+
self.replacements = replacements
34+
}
35+
36+
public static let empty = PersonalDictionary()
37+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import Foundation
2+
3+
public enum VoiceCasingPolicy:
4+
String,
5+
CaseIterable,
6+
Codable,
7+
Equatable,
8+
Sendable
9+
{
10+
case styleDefault
11+
case lowercaseProse
12+
case strictLowercase
13+
}
14+
15+
public struct VoiceCasingTransformer: Sendable {
16+
public init() {}
17+
18+
public func apply(
19+
_ policy: VoiceCasingPolicy,
20+
to text: String,
21+
preserving source: String,
22+
dictionary: PersonalDictionary
23+
) -> String {
24+
guard policy != .styleDefault else {
25+
return text
26+
}
27+
let protectedTokens = intentionalTokens(
28+
in: source,
29+
dictionary: dictionary,
30+
preserveProseCasing: policy == .lowercaseProse
31+
)
32+
let ranges = nonoverlappingRanges(
33+
of: protectedTokens,
34+
in: text
35+
)
36+
var result = ""
37+
var cursor = text.startIndex
38+
for range in ranges {
39+
result += text[cursor..<range.lowerBound].lowercased()
40+
result += text[range]
41+
cursor = range.upperBound
42+
}
43+
result += text[cursor...].lowercased()
44+
return result
45+
}
46+
47+
private func intentionalTokens(
48+
in source: String,
49+
dictionary: PersonalDictionary,
50+
preserveProseCasing: Bool
51+
) -> [String] {
52+
let patterns = [
53+
#"https?://[^\s]+"#,
54+
#"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"#,
55+
#"(?:/[^\s/]+){2,}"#,
56+
#"\b[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+\b"#,
57+
#"\b[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*\b"#,
58+
#"\b[A-Z][a-z]+(?:[A-Z][A-Za-z0-9]*)+\b"#,
59+
#"[\"“][^\"”]+[\"”]"#,
60+
]
61+
var tokens = Set(
62+
patterns.flatMap { matches(pattern: $0, in: source) }
63+
)
64+
tokens.formUnion(dictionary.vocabulary)
65+
tokens.formUnion(dictionary.replacements.map(\.replacement))
66+
if preserveProseCasing {
67+
tokens.formUnion(matches(pattern: #"\b[A-Z]{2,}\b"#, in: source))
68+
tokens.formUnion(sourceSignaledNames(in: source))
69+
}
70+
return tokens.filter { !$0.isEmpty }.sorted {
71+
if $0.count != $1.count {
72+
return $0.count > $1.count
73+
}
74+
return $0 < $1
75+
}
76+
}
77+
78+
private func sourceSignaledNames(in source: String) -> [String] {
79+
guard
80+
let expression = try? NSRegularExpression(
81+
pattern: #"\b[A-Z][a-z]+\b"#
82+
)
83+
else {
84+
return []
85+
}
86+
let range = NSRange(source.startIndex..., in: source)
87+
return expression.matches(in: source, range: range).compactMap { match in
88+
guard let wordRange = Range(match.range, in: source) else {
89+
return nil
90+
}
91+
let prefix = source[..<wordRange.lowerBound]
92+
guard let preceding = prefix.last(where: { !$0.isWhitespace }) else {
93+
return nil
94+
}
95+
guard !".!?".contains(preceding) else {
96+
return nil
97+
}
98+
return String(source[wordRange])
99+
}
100+
}
101+
102+
private func matches(
103+
pattern: String,
104+
in source: String
105+
) -> [String] {
106+
guard let expression = try? NSRegularExpression(pattern: pattern) else {
107+
return []
108+
}
109+
let range = NSRange(source.startIndex..., in: source)
110+
return expression.matches(in: source, range: range).compactMap { match in
111+
Range(match.range, in: source).map { String(source[$0]) }
112+
}
113+
}
114+
115+
private func nonoverlappingRanges(
116+
of tokens: [String],
117+
in text: String
118+
) -> [Range<String.Index>] {
119+
let candidates = tokens.flatMap { token in
120+
ranges(of: token, in: text)
121+
}.sorted {
122+
if $0.lowerBound != $1.lowerBound {
123+
return $0.lowerBound < $1.lowerBound
124+
}
125+
return text.distance(from: $0.lowerBound, to: $0.upperBound)
126+
> text.distance(from: $1.lowerBound, to: $1.upperBound)
127+
}
128+
var selected: [Range<String.Index>] = []
129+
for candidate in candidates
130+
where selected.last?.upperBound ?? text.startIndex <= candidate.lowerBound {
131+
selected.append(candidate)
132+
}
133+
return selected
134+
}
135+
136+
private func ranges(
137+
of token: String,
138+
in text: String
139+
) -> [Range<String.Index>] {
140+
var ranges: [Range<String.Index>] = []
141+
var searchStart = text.startIndex
142+
while searchStart < text.endIndex,
143+
let range = text.range(
144+
of: token,
145+
range: searchStart..<text.endIndex
146+
)
147+
{
148+
ranges.append(range)
149+
searchStart = range.upperBound
150+
}
151+
return ranges
152+
}
153+
}

0 commit comments

Comments
 (0)