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:
Rocky
2026-07-29 17:45:11 +08:00
parent 2d44423f4c
commit 34be2e8dd1
40 changed files with 1827 additions and 183 deletions
@@ -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⟩"
}
}