Skip to content
Merged
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,9 @@ normalizes conservative grocery, shopping, packing, task, explicit-marker,
and sequential-ordinal list cues before formatting. Typed casing policy can
retain the selected Style, lowercase prose while preserving source-signaled
names, or enforce strict lowercase while protecting operational tokens. The
same casing and spoken-list rules run on macOS and iOS. It
formatter returns typed paragraph/list blocks; deterministic normalization
restores protected token spelling and list boundaries when Edited text has
confident cues. The same casing and spoken-list rules run on macOS and iOS. It
validates protected numbers, URLs, email addresses, paths, code-like tokens,
quotations, and dictionary terms. A provider error, invalid output, or
three-second deadline delivers the deterministic Edited transcript once when
Expand Down
6 changes: 3 additions & 3 deletions Sources/HardwareControllerCore/local_ai_dictation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ public struct LocalAIRefinementRequest: Equatable, Sendable {
}

public struct LocalAIRefinementResponse: Equatable, Sendable {
public let text: String
public let output: VoiceFormattingDraft
public let provider: LocalAIProviderKind
public let modelIdentifier: String
public let modelLoadNanoseconds: UInt64?
Expand All @@ -283,15 +283,15 @@ public struct LocalAIRefinementResponse: Equatable, Sendable {
public let tokenGenerationNanoseconds: UInt64?

public init(
text: String,
output: VoiceFormattingDraft,
provider: LocalAIProviderKind,
modelIdentifier: String,
modelLoadNanoseconds: UInt64? = nil,
generationNanoseconds: UInt64? = nil,
generatedTokenCount: Int? = nil,
tokenGenerationNanoseconds: UInt64? = nil
) {
self.text = text
self.output = output
self.provider = provider
self.modelIdentifier = modelIdentifier
self.modelLoadNanoseconds = modelLoadNanoseconds
Expand Down
61 changes: 50 additions & 11 deletions Sources/HardwareControllerCore/voice_casing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,29 @@ public enum VoiceCasingPolicy:
public struct VoiceCasingTransformer: Sendable {
public init() {}

public func apply(
_ policy: VoiceCasingPolicy,
to output: VoiceFormattingDraft,
preserving source: String,
dictionary: PersonalDictionary
) -> VoiceFormattingDraft {
VoiceFormattingDraft(
blocks: output.blocks.map { block in
VoiceFormattingDraftBlock(
kind: block.kind,
items: block.items.map {
apply(
policy,
to: $0,
preserving: source,
dictionary: dictionary
)
}
)
}
)
}

public func apply(
_ policy: VoiceCasingPolicy,
to text: String,
Expand All @@ -36,9 +59,9 @@ public struct VoiceCasingTransformer: Sendable {
var result = ""
var cursor = text.startIndex
for range in ranges {
result += text[cursor..<range.lowerBound].lowercased()
result += text[range]
cursor = range.upperBound
result += text[cursor..<range.range.lowerBound].lowercased()
result += range.replacement
cursor = range.range.upperBound
}
result += text[cursor...].lowercased()
return result
Expand Down Expand Up @@ -115,19 +138,29 @@ public struct VoiceCasingTransformer: Sendable {
private func nonoverlappingRanges(
of tokens: [String],
in text: String
) -> [Range<String.Index>] {
) -> [ProtectedRange] {
let candidates = tokens.flatMap { token in
ranges(of: token, in: text)
ranges(of: token, in: text).map {
ProtectedRange(range: $0, replacement: token)
}
}.sorted {
if $0.lowerBound != $1.lowerBound {
return $0.lowerBound < $1.lowerBound
if $0.range.lowerBound != $1.range.lowerBound {
return $0.range.lowerBound < $1.range.lowerBound
}
return text.distance(from: $0.lowerBound, to: $0.upperBound)
> text.distance(from: $1.lowerBound, to: $1.upperBound)
return text.distance(
from: $0.range.lowerBound,
to: $0.range.upperBound
)
> text.distance(
from: $1.range.lowerBound,
to: $1.range.upperBound
)
}
var selected: [Range<String.Index>] = []
var selected: [ProtectedRange] = []
for candidate in candidates
where selected.last?.upperBound ?? text.startIndex <= candidate.lowerBound {
where selected.last?.range.upperBound ?? text.startIndex
<= candidate.range.lowerBound
{
selected.append(candidate)
}
return selected
Expand All @@ -142,6 +175,7 @@ public struct VoiceCasingTransformer: Sendable {
while searchStart < text.endIndex,
let range = text.range(
of: token,
options: [.caseInsensitive],
range: searchStart..<text.endIndex
)
{
Expand All @@ -150,4 +184,9 @@ public struct VoiceCasingTransformer: Sendable {
}
return ranges
}

private struct ProtectedRange {
let range: Range<String.Index>
let replacement: String
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,62 @@ import Foundation
public struct VoiceFormattedDocumentBuilder: Sendable {
public init() {}

public func build(
output: VoiceFormattingDraft,
rawText: String,
style: VoiceStyle,
provider: LocalAIProviderKind? = nil,
modelIdentifier: String? = nil,
promptRevision: Int? = nil
) throws -> VoiceFormattedDocument {
guard style.revision == VoiceStyle.currentRevision else {
throw VoiceFormattingError.unsupportedStyleRevision(style.revision)
}
guard style.kind != .verbatim, !output.blocks.isEmpty else {
throw VoiceFormattingError.invalidBlock
}
let blocks = try output.blocks.flatMap { block in
guard !block.items.isEmpty,
block.items.allSatisfy(isSafeNonemptyItem)
else {
throw VoiceFormattingError.invalidBlock
}
let items = block.items.map {
$0.trimmingCharacters(in: .whitespacesAndNewlines)
}
if block.kind == .paragraph {
return items.map {
VoiceFormattedBlock(
kind: .paragraph,
items: [$0],
evidenceIndices: [0]
)
}
}
return [
VoiceFormattedBlock(
kind: formattedKind(block.kind),
items: items,
evidenceIndices: [0]
)
]
}
return VoiceFormattedDocument(
rawText: rawText,
style: style,
blocks: blocks,
evidence: [
evidence(
rawText: rawText,
provider: provider,
modelIdentifier: modelIdentifier,
promptRevision: promptRevision
)
],
validationStatus: .validated
)
}

public func build(
formattedText: String,
rawText: String,
Expand All @@ -29,9 +85,8 @@ public struct VoiceFormattedDocumentBuilder: Sendable {
throw VoiceFormattingError.emptyFormattedText
}

let evidence = VoiceFormattingEvidence(
rawUTF8StartOffset: 0,
rawUTF8EndOffset: rawText.utf8.count,
let evidence = evidence(
rawText: rawText,
provider: provider,
modelIdentifier: modelIdentifier,
promptRevision: promptRevision
Expand All @@ -55,6 +110,42 @@ public struct VoiceFormattedDocumentBuilder: Sendable {
)
}

private func isSafeNonemptyItem(_ item: String) -> Bool {
let trimmed = item.trimmingCharacters(in: .whitespacesAndNewlines)
return !trimmed.isEmpty
&& trimmed.unicodeScalars.allSatisfy {
!CharacterSet.controlCharacters.contains($0)
}
}

private func formattedKind(
_ kind: VoiceFormattingDraftBlockKind
) -> VoiceFormattedBlockKind {
switch kind {
case .paragraph:
.paragraph
case .unorderedList:
.unorderedList
case .orderedList:
.orderedList
}
}

private func evidence(
rawText: String,
provider: LocalAIProviderKind?,
modelIdentifier: String?,
promptRevision: Int?
) -> VoiceFormattingEvidence {
VoiceFormattingEvidence(
rawUTF8StartOffset: 0,
rawUTF8EndOffset: rawText.utf8.count,
provider: provider,
modelIdentifier: modelIdentifier,
promptRevision: promptRevision
)
}

private func parse(
_ text: String,
rawText: String
Expand Down
44 changes: 44 additions & 0 deletions Sources/HardwareControllerCore/voice_formatting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,50 @@ public struct VoiceStyle: Codable, Equatable, Hashable, Sendable {
public static let verbatim = VoiceStyle(kind: .verbatim)
}

public enum VoiceFormattingDraftBlockKind:
String,
Codable,
Equatable,
Hashable,
Sendable
{
case paragraph
case unorderedList
case orderedList
}

public struct VoiceFormattingDraftBlock: Codable, Equatable, Sendable {
public let kind: VoiceFormattingDraftBlockKind
public let items: [String]

public init(
kind: VoiceFormattingDraftBlockKind,
items: [String]
) {
self.kind = kind
self.items = items
}
}

public struct VoiceFormattingDraft: Codable, Equatable, Sendable {
public let blocks: [VoiceFormattingDraftBlock]

public init(blocks: [VoiceFormattingDraftBlock]) {
self.blocks = blocks
}

public static func paragraph(_ text: String) -> VoiceFormattingDraft {
VoiceFormattingDraft(
blocks: [
VoiceFormattingDraftBlock(
kind: .paragraph,
items: [text]
)
]
)
}
}

public enum VoiceFormattedBlockKind:
String,
Codable,
Expand Down
Loading