feat(polish): add context safeguards, layered prompts, and output validation
Use redacted cursor neighborhood and pause-aware chunks for more natural polish, validate protected terms with retry/local fallback, and structure bilingual prompts for consistency and provider prefix caching.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
// TranscriptLanguageDetector.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Lightweight script detection for choosing the language of LLM guidance.
|
||||
// This intentionally does not attempt full language identification.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum TranscriptLanguageDetector: Sendable {
|
||||
/// Han characters as a share of non-whitespace, non-punctuation characters.
|
||||
public static func cjkRatio(_ text: String) -> Double {
|
||||
var hanCount = 0
|
||||
var meaningfulCount = 0
|
||||
|
||||
for scalar in text.unicodeScalars {
|
||||
if CharacterSet.whitespacesAndNewlines.contains(scalar)
|
||||
|| CharacterSet.punctuationCharacters.contains(scalar)
|
||||
|| CharacterSet.symbols.contains(scalar) {
|
||||
continue
|
||||
}
|
||||
meaningfulCount += 1
|
||||
if isHan(scalar) {
|
||||
hanCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
guard meaningfulCount > 0 else { return 0 }
|
||||
return Double(hanCount) / Double(meaningfulCount)
|
||||
}
|
||||
|
||||
/// Mixed Chinese/English transcripts should still receive Chinese guidance.
|
||||
public static func prefersChineseGuidance(_ text: String) -> Bool {
|
||||
cjkRatio(text) >= 0.15
|
||||
}
|
||||
|
||||
private static func isHan(_ scalar: Unicode.Scalar) -> Bool {
|
||||
switch scalar.value {
|
||||
case 0x4E00...0x9FFF, 0x3400...0x4DBF, 0xF900...0xFAFF:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,11 @@ public enum UtteranceStreamChunker {
|
||||
buffer.reserveCapacity(initialCapacity)
|
||||
var chunkIndex = 0
|
||||
|
||||
func emit(upTo splitEnd: Int, isLast: Bool) {
|
||||
func emit(
|
||||
upTo splitEnd: Int,
|
||||
isLast: Bool,
|
||||
trailingPauseSeconds: Double = 0
|
||||
) {
|
||||
guard splitEnd > 0, splitEnd <= buffer.count else {
|
||||
FlowTrace.warn(
|
||||
"pipeline.chunk.emitSkipped",
|
||||
@@ -37,7 +41,12 @@ public enum UtteranceStreamChunker {
|
||||
+ "rms=\(FlowTrace.rms(chunkSamples)) isLast=\(isLast ? 1 : 0)"
|
||||
)
|
||||
continuation.yield(
|
||||
UtteranceAudioChunk(index: chunkIndex, samples: chunkSamples, isLast: isLast)
|
||||
UtteranceAudioChunk(
|
||||
index: chunkIndex,
|
||||
samples: chunkSamples,
|
||||
isLast: isLast,
|
||||
trailingPauseSeconds: trailingPauseSeconds
|
||||
)
|
||||
)
|
||||
chunkIndex += 1
|
||||
if splitEnd >= buffer.count {
|
||||
@@ -58,12 +67,16 @@ public enum UtteranceStreamChunker {
|
||||
buffer.append(contentsOf: snap.samples)
|
||||
|
||||
while buffer.count >= config.maxChunkSamples(forChunkIndex: chunkIndex) {
|
||||
let split = pauseAwareSplitIndex(
|
||||
let split = pauseAwareSplit(
|
||||
in: buffer,
|
||||
config: config,
|
||||
chunkIndex: chunkIndex
|
||||
)
|
||||
emit(upTo: split, isLast: false)
|
||||
emit(
|
||||
upTo: split.index,
|
||||
isLast: false,
|
||||
trailingPauseSeconds: Double(split.pauseSamples) / Double(config.sampleRate)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,25 +121,45 @@ public enum UtteranceStreamChunker {
|
||||
config: FlowUtteranceChunkConfig,
|
||||
chunkIndex: Int = 1
|
||||
) -> Int {
|
||||
pauseAwareSplit(in: buffer, config: config, chunkIndex: chunkIndex).index
|
||||
}
|
||||
|
||||
static func pauseAwareSplit(
|
||||
in buffer: [Float],
|
||||
config: FlowUtteranceChunkConfig,
|
||||
chunkIndex: Int = 1
|
||||
) -> (index: Int, pauseSamples: Int) {
|
||||
let minSplit = config.maxChunkSamples(forChunkIndex: chunkIndex)
|
||||
guard buffer.count >= minSplit else { return buffer.count }
|
||||
guard buffer.count >= minSplit else { return (buffer.count, 0) }
|
||||
|
||||
let searchEnd = min(buffer.count, minSplit + config.pauseExtensionSamples)
|
||||
if searchEnd <= minSplit {
|
||||
return minSplit
|
||||
return (minSplit, 0)
|
||||
}
|
||||
|
||||
let windowSize = max(config.sampleRate / 50, 160) // ~20 ms
|
||||
var bestPause: Int?
|
||||
let step = max(windowSize / 2, 1)
|
||||
var bestPauseEnd: Int?
|
||||
var bestPauseSamples = 0
|
||||
var currentPauseStart: Int?
|
||||
var idx = minSplit
|
||||
while idx + windowSize <= searchEnd {
|
||||
if rms(of: buffer, start: idx, count: windowSize) < config.pauseRMSThreshold {
|
||||
bestPause = idx + windowSize
|
||||
if currentPauseStart == nil {
|
||||
currentPauseStart = idx
|
||||
}
|
||||
let pauseSamples = idx + windowSize - (currentPauseStart ?? idx)
|
||||
if pauseSamples > bestPauseSamples {
|
||||
bestPauseSamples = pauseSamples
|
||||
bestPauseEnd = idx + windowSize
|
||||
}
|
||||
} else {
|
||||
currentPauseStart = nil
|
||||
}
|
||||
idx += windowSize / 2
|
||||
idx += step
|
||||
}
|
||||
|
||||
return bestPause ?? minSplit
|
||||
return (bestPauseEnd ?? minSplit, bestPauseSamples)
|
||||
}
|
||||
|
||||
static func rms(of samples: [Float], start: Int, count: Int) -> Float {
|
||||
|
||||
@@ -6,17 +6,22 @@
|
||||
import Foundation
|
||||
|
||||
public struct UtteranceTranscriptStitcher: Sendable {
|
||||
private var segments: [(index: Int, text: String)] = []
|
||||
private var segments: [(index: Int, text: String, trailingPauseSeconds: Double)] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
public mutating func append(index: Int, text: String) {
|
||||
public mutating func append(
|
||||
index: Int,
|
||||
text: String,
|
||||
trailingPauseSeconds: Double = 0
|
||||
) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
if let existing = segments.firstIndex(where: { $0.index == index }) {
|
||||
segments[existing].text = trimmed
|
||||
segments[existing].trailingPauseSeconds = trailingPauseSeconds
|
||||
} else {
|
||||
segments.append((index, trimmed))
|
||||
segments.append((index, trimmed, trailingPauseSeconds))
|
||||
segments.sort { $0.index < $1.index }
|
||||
}
|
||||
}
|
||||
@@ -51,6 +56,34 @@ public struct UtteranceTranscriptStitcher: Sendable {
|
||||
return merged
|
||||
}
|
||||
|
||||
/// Final text for LLM processing only. Partial preview continues to use
|
||||
/// `composedSafely()` and therefore never exposes internal markers.
|
||||
public func composedWithPauseMarks(threshold: Double = 0.45) -> String {
|
||||
guard let first = segments.first else { return "" }
|
||||
let safePlain = composedSafely()
|
||||
let mergedPlain = composed()
|
||||
if safePlain != mergedPlain {
|
||||
return naiveWithPauseMarks(threshold: threshold)
|
||||
}
|
||||
|
||||
var plain = first.text
|
||||
var marked = first.text
|
||||
var previous = first
|
||||
for segment in segments.dropFirst() {
|
||||
let nextPlain = Self.mergeWithOverlap(previous: plain, next: segment.text)
|
||||
let suffix = String(nextPlain.dropFirst(min(plain.count, nextPlain.count)))
|
||||
if previous.trailingPauseSeconds >= threshold, !suffix.isEmpty {
|
||||
marked += " \(Self.pauseMarker(previous.trailingPauseSeconds)) "
|
||||
marked += suffix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
} else {
|
||||
marked += suffix
|
||||
}
|
||||
plain = nextPlain
|
||||
previous = segment
|
||||
}
|
||||
return marked
|
||||
}
|
||||
|
||||
/// Merge `next` onto `previous`, dropping duplicated suffix/prefix overlap.
|
||||
public static func mergeWithOverlap(previous: String, next: String) -> String {
|
||||
let trimmedNext = next.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -127,4 +160,19 @@ public struct UtteranceTranscriptStitcher: Sendable {
|
||||
}
|
||||
return next.distance(from: next.startIndex, to: rawIndex)
|
||||
}
|
||||
|
||||
private func naiveWithPauseMarks(threshold: Double) -> String {
|
||||
var pieces: [String] = []
|
||||
for (offset, segment) in segments.enumerated() {
|
||||
pieces.append(segment.text)
|
||||
if segment.trailingPauseSeconds >= threshold, offset < segments.count - 1 {
|
||||
pieces.append(Self.pauseMarker(segment.trailingPauseSeconds))
|
||||
}
|
||||
}
|
||||
return pieces.joined(separator: " ")
|
||||
}
|
||||
|
||||
private static func pauseMarker(_ seconds: Double) -> String {
|
||||
"⟨\(String(format: "%.1f", seconds))s⟩"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user