Skip to content

Commit b3ec703

Browse files
committed
Add typed local AI formatting output
1 parent 72a7ffe commit b3ec703

37 files changed

Lines changed: 1449 additions & 264 deletions

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,9 @@ normalizes conservative grocery, shopping, packing, task, explicit-marker,
208208
and sequential-ordinal list cues before formatting. Typed casing policy can
209209
retain the selected Style, lowercase prose while preserving source-signaled
210210
names, or enforce strict lowercase while protecting operational tokens. The
211-
same casing and spoken-list rules run on macOS and iOS. It
211+
formatter returns typed paragraph/list blocks; deterministic normalization
212+
restores protected token spelling and list boundaries when Edited text has
213+
confident cues. The same casing and spoken-list rules run on macOS and iOS. It
212214
validates protected numbers, URLs, email addresses, paths, code-like tokens,
213215
quotations, and dictionary terms. A provider error, invalid output, or
214216
three-second deadline delivers the deterministic Edited transcript once when

Sources/HardwareControllerCore/local_ai_dictation.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ public struct LocalAIRefinementRequest: Equatable, Sendable {
274274
}
275275

276276
public struct LocalAIRefinementResponse: Equatable, Sendable {
277-
public let text: String
277+
public let output: VoiceFormattingDraft
278278
public let provider: LocalAIProviderKind
279279
public let modelIdentifier: String
280280
public let modelLoadNanoseconds: UInt64?
@@ -283,15 +283,15 @@ public struct LocalAIRefinementResponse: Equatable, Sendable {
283283
public let tokenGenerationNanoseconds: UInt64?
284284

285285
public init(
286-
text: String,
286+
output: VoiceFormattingDraft,
287287
provider: LocalAIProviderKind,
288288
modelIdentifier: String,
289289
modelLoadNanoseconds: UInt64? = nil,
290290
generationNanoseconds: UInt64? = nil,
291291
generatedTokenCount: Int? = nil,
292292
tokenGenerationNanoseconds: UInt64? = nil
293293
) {
294-
self.text = text
294+
self.output = output
295295
self.provider = provider
296296
self.modelIdentifier = modelIdentifier
297297
self.modelLoadNanoseconds = modelLoadNanoseconds

Sources/HardwareControllerCore/voice_casing.swift

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,29 @@ public enum VoiceCasingPolicy:
1515
public struct VoiceCasingTransformer: Sendable {
1616
public init() {}
1717

18+
public func apply(
19+
_ policy: VoiceCasingPolicy,
20+
to output: VoiceFormattingDraft,
21+
preserving source: String,
22+
dictionary: PersonalDictionary
23+
) -> VoiceFormattingDraft {
24+
VoiceFormattingDraft(
25+
blocks: output.blocks.map { block in
26+
VoiceFormattingDraftBlock(
27+
kind: block.kind,
28+
items: block.items.map {
29+
apply(
30+
policy,
31+
to: $0,
32+
preserving: source,
33+
dictionary: dictionary
34+
)
35+
}
36+
)
37+
}
38+
)
39+
}
40+
1841
public func apply(
1942
_ policy: VoiceCasingPolicy,
2043
to text: String,
@@ -36,9 +59,9 @@ public struct VoiceCasingTransformer: Sendable {
3659
var result = ""
3760
var cursor = text.startIndex
3861
for range in ranges {
39-
result += text[cursor..<range.lowerBound].lowercased()
40-
result += text[range]
41-
cursor = range.upperBound
62+
result += text[cursor..<range.range.lowerBound].lowercased()
63+
result += range.replacement
64+
cursor = range.range.upperBound
4265
}
4366
result += text[cursor...].lowercased()
4467
return result
@@ -115,19 +138,29 @@ public struct VoiceCasingTransformer: Sendable {
115138
private func nonoverlappingRanges(
116139
of tokens: [String],
117140
in text: String
118-
) -> [Range<String.Index>] {
141+
) -> [ProtectedRange] {
119142
let candidates = tokens.flatMap { token in
120-
ranges(of: token, in: text)
143+
ranges(of: token, in: text).map {
144+
ProtectedRange(range: $0, replacement: token)
145+
}
121146
}.sorted {
122-
if $0.lowerBound != $1.lowerBound {
123-
return $0.lowerBound < $1.lowerBound
147+
if $0.range.lowerBound != $1.range.lowerBound {
148+
return $0.range.lowerBound < $1.range.lowerBound
124149
}
125-
return text.distance(from: $0.lowerBound, to: $0.upperBound)
126-
> text.distance(from: $1.lowerBound, to: $1.upperBound)
150+
return text.distance(
151+
from: $0.range.lowerBound,
152+
to: $0.range.upperBound
153+
)
154+
> text.distance(
155+
from: $1.range.lowerBound,
156+
to: $1.range.upperBound
157+
)
127158
}
128-
var selected: [Range<String.Index>] = []
159+
var selected: [ProtectedRange] = []
129160
for candidate in candidates
130-
where selected.last?.upperBound ?? text.startIndex <= candidate.lowerBound {
161+
where selected.last?.range.upperBound ?? text.startIndex
162+
<= candidate.range.lowerBound
163+
{
131164
selected.append(candidate)
132165
}
133166
return selected
@@ -142,6 +175,7 @@ public struct VoiceCasingTransformer: Sendable {
142175
while searchStart < text.endIndex,
143176
let range = text.range(
144177
of: token,
178+
options: [.caseInsensitive],
145179
range: searchStart..<text.endIndex
146180
)
147181
{
@@ -150,4 +184,9 @@ public struct VoiceCasingTransformer: Sendable {
150184
}
151185
return ranges
152186
}
187+
188+
private struct ProtectedRange {
189+
let range: Range<String.Index>
190+
let replacement: String
191+
}
153192
}

Sources/HardwareControllerCore/voice_formatted_document_builder.swift

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,62 @@ import Foundation
33
public struct VoiceFormattedDocumentBuilder: Sendable {
44
public init() {}
55

6+
public func build(
7+
output: VoiceFormattingDraft,
8+
rawText: String,
9+
style: VoiceStyle,
10+
provider: LocalAIProviderKind? = nil,
11+
modelIdentifier: String? = nil,
12+
promptRevision: Int? = nil
13+
) throws -> VoiceFormattedDocument {
14+
guard style.revision == VoiceStyle.currentRevision else {
15+
throw VoiceFormattingError.unsupportedStyleRevision(style.revision)
16+
}
17+
guard style.kind != .verbatim, !output.blocks.isEmpty else {
18+
throw VoiceFormattingError.invalidBlock
19+
}
20+
let blocks = try output.blocks.flatMap { block in
21+
guard !block.items.isEmpty,
22+
block.items.allSatisfy(isSafeNonemptyItem)
23+
else {
24+
throw VoiceFormattingError.invalidBlock
25+
}
26+
let items = block.items.map {
27+
$0.trimmingCharacters(in: .whitespacesAndNewlines)
28+
}
29+
if block.kind == .paragraph {
30+
return items.map {
31+
VoiceFormattedBlock(
32+
kind: .paragraph,
33+
items: [$0],
34+
evidenceIndices: [0]
35+
)
36+
}
37+
}
38+
return [
39+
VoiceFormattedBlock(
40+
kind: formattedKind(block.kind),
41+
items: items,
42+
evidenceIndices: [0]
43+
)
44+
]
45+
}
46+
return VoiceFormattedDocument(
47+
rawText: rawText,
48+
style: style,
49+
blocks: blocks,
50+
evidence: [
51+
evidence(
52+
rawText: rawText,
53+
provider: provider,
54+
modelIdentifier: modelIdentifier,
55+
promptRevision: promptRevision
56+
)
57+
],
58+
validationStatus: .validated
59+
)
60+
}
61+
662
public func build(
763
formattedText: String,
864
rawText: String,
@@ -29,9 +85,8 @@ public struct VoiceFormattedDocumentBuilder: Sendable {
2985
throw VoiceFormattingError.emptyFormattedText
3086
}
3187

32-
let evidence = VoiceFormattingEvidence(
33-
rawUTF8StartOffset: 0,
34-
rawUTF8EndOffset: rawText.utf8.count,
88+
let evidence = evidence(
89+
rawText: rawText,
3590
provider: provider,
3691
modelIdentifier: modelIdentifier,
3792
promptRevision: promptRevision
@@ -55,6 +110,42 @@ public struct VoiceFormattedDocumentBuilder: Sendable {
55110
)
56111
}
57112

113+
private func isSafeNonemptyItem(_ item: String) -> Bool {
114+
let trimmed = item.trimmingCharacters(in: .whitespacesAndNewlines)
115+
return !trimmed.isEmpty
116+
&& trimmed.unicodeScalars.allSatisfy {
117+
!CharacterSet.controlCharacters.contains($0)
118+
}
119+
}
120+
121+
private func formattedKind(
122+
_ kind: VoiceFormattingDraftBlockKind
123+
) -> VoiceFormattedBlockKind {
124+
switch kind {
125+
case .paragraph:
126+
.paragraph
127+
case .unorderedList:
128+
.unorderedList
129+
case .orderedList:
130+
.orderedList
131+
}
132+
}
133+
134+
private func evidence(
135+
rawText: String,
136+
provider: LocalAIProviderKind?,
137+
modelIdentifier: String?,
138+
promptRevision: Int?
139+
) -> VoiceFormattingEvidence {
140+
VoiceFormattingEvidence(
141+
rawUTF8StartOffset: 0,
142+
rawUTF8EndOffset: rawText.utf8.count,
143+
provider: provider,
144+
modelIdentifier: modelIdentifier,
145+
promptRevision: promptRevision
146+
)
147+
}
148+
58149
private func parse(
59150
_ text: String,
60151
rawText: String

Sources/HardwareControllerCore/voice_formatting.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,50 @@ public struct VoiceStyle: Codable, Equatable, Hashable, Sendable {
3434
public static let verbatim = VoiceStyle(kind: .verbatim)
3535
}
3636

37+
public enum VoiceFormattingDraftBlockKind:
38+
String,
39+
Codable,
40+
Equatable,
41+
Hashable,
42+
Sendable
43+
{
44+
case paragraph
45+
case unorderedList
46+
case orderedList
47+
}
48+
49+
public struct VoiceFormattingDraftBlock: Codable, Equatable, Sendable {
50+
public let kind: VoiceFormattingDraftBlockKind
51+
public let items: [String]
52+
53+
public init(
54+
kind: VoiceFormattingDraftBlockKind,
55+
items: [String]
56+
) {
57+
self.kind = kind
58+
self.items = items
59+
}
60+
}
61+
62+
public struct VoiceFormattingDraft: Codable, Equatable, Sendable {
63+
public let blocks: [VoiceFormattingDraftBlock]
64+
65+
public init(blocks: [VoiceFormattingDraftBlock]) {
66+
self.blocks = blocks
67+
}
68+
69+
public static func paragraph(_ text: String) -> VoiceFormattingDraft {
70+
VoiceFormattingDraft(
71+
blocks: [
72+
VoiceFormattingDraftBlock(
73+
kind: .paragraph,
74+
items: [text]
75+
)
76+
]
77+
)
78+
}
79+
}
80+
3781
public enum VoiceFormattedBlockKind:
3882
String,
3983
Codable,

0 commit comments

Comments
 (0)