feat(polish): add question guard, ABE routing, and flow trace
Harden polish so question drafts stay questions, add local density routing with style-specific degrade, expand fun style packs, and add end-to-end FlowTrace logging plus offline guard eval scripts.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
// FlowTrace.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// One greppable trace channel for the whole voice path:
|
||||
//
|
||||
// capture → downsample → utterance gate → chunker → ASR → polish → keyboard
|
||||
//
|
||||
// Every line is `[trace] stage=<area>.<step> key=value …`, so a single
|
||||
// Console.app filter (subsystem `com.osgkeyboard.ios`, message contains
|
||||
// `[trace]`) replays one utterance end to end. The `stage=` tag keeps the
|
||||
// stages sortable, which matters because the pipeline spans two processes
|
||||
// (main app captures and recognises, keyboard extension inserts).
|
||||
//
|
||||
// Transcript payloads are logged in the clear only in DEBUG builds. Release
|
||||
// builds mark them `.private` so recognised speech never lands in a sysdiagnose
|
||||
// the user shares with a third party.
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
public enum FlowTrace {
|
||||
|
||||
// MARK: - Stage channels
|
||||
|
||||
/// Mic capture and audio plumbing (engine, converter, gate, drain).
|
||||
public static func capture(_ step: String, _ detail: String = "") {
|
||||
OSGLog.flow.info("[trace] stage=capture.\(step, privacy: .public) \(detail, privacy: .public)")
|
||||
}
|
||||
|
||||
/// Chunking and transcript stitching between capture and the ASR engine.
|
||||
public static func pipeline(_ step: String, _ detail: String = "") {
|
||||
OSGLog.flow.info("[trace] stage=pipeline.\(step, privacy: .public) \(detail, privacy: .public)")
|
||||
}
|
||||
|
||||
/// Recognition engine boundary (local SpeechAnalyzer or cloud provider).
|
||||
public static func asr(_ step: String, _ detail: String = "") {
|
||||
OSGLog.asr.info("[trace] stage=asr.\(step, privacy: .public) \(detail, privacy: .public)")
|
||||
}
|
||||
|
||||
/// LLM polish / translation stage.
|
||||
public static func polish(_ step: String, _ detail: String = "") {
|
||||
OSGLog.flow.info("[trace] stage=polish.\(step, privacy: .public) \(detail, privacy: .public)")
|
||||
}
|
||||
|
||||
/// Keyboard extension side: result delivery and text insertion.
|
||||
public static func keyboard(_ step: String, _ detail: String = "") {
|
||||
OSGLog.keyboardExt.info("[trace] stage=keyboard.\(step, privacy: .public) \(detail, privacy: .public)")
|
||||
}
|
||||
|
||||
/// Paths that used to fail silently (dropped audio, empty transcripts).
|
||||
/// Logged at `warning` so they stand out without changing the filter.
|
||||
public static func warn(_ step: String, _ detail: String = "") {
|
||||
OSGLog.flow.warning("[trace] stage=\(step, privacy: .public) \(detail, privacy: .public) OUTCOME=SUSPECT")
|
||||
}
|
||||
|
||||
// MARK: - Transcript payloads
|
||||
|
||||
/// Logs recognised / polished text plus its length.
|
||||
///
|
||||
/// `step` names the point in the path (`asr.chunk`, `asr.final`,
|
||||
/// `polish.input`, `polish.output`, `keyboard.insert`), so a diff between
|
||||
/// two adjacent `text.*` lines shows exactly which stage changed the text.
|
||||
public static func transcript(_ step: String, _ text: String, _ detail: String = "") {
|
||||
let length = text.count
|
||||
let empty = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
#if DEBUG
|
||||
OSGLog.asr.info(
|
||||
"[trace] stage=text.\(step, privacy: .public) len=\(length, privacy: .public) empty=\(empty, privacy: .public) \(detail, privacy: .public) text=\(text, privacy: .public)"
|
||||
)
|
||||
#else
|
||||
OSGLog.asr.info(
|
||||
"[trace] stage=text.\(step, privacy: .public) len=\(length, privacy: .public) empty=\(empty, privacy: .public) \(detail, privacy: .public) text=\(text, privacy: .private)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Formatting helpers
|
||||
|
||||
/// Sample count → seconds at the canonical 16 kHz ASR rate.
|
||||
public static func seconds(samples: Int, sampleRate: Int = 16_000) -> String {
|
||||
guard sampleRate > 0 else { return "0.00" }
|
||||
return String(format: "%.2f", Double(samples) / Double(sampleRate))
|
||||
}
|
||||
|
||||
public static func seconds(since start: Date) -> String {
|
||||
String(format: "%.2f", Date().timeIntervalSince(start))
|
||||
}
|
||||
|
||||
/// Root-mean-square of a PCM window — distinguishes "user was silent"
|
||||
/// from "audio never reached the recogniser" when a transcript is empty.
|
||||
public static func rms(_ samples: [Float]) -> String {
|
||||
guard !samples.isEmpty else { return "0.0000" }
|
||||
var sum: Float = 0
|
||||
for sample in samples {
|
||||
sum += sample * sample
|
||||
}
|
||||
return String(format: "%.4f", (sum / Float(samples.count)).squareRoot())
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,20 @@ public enum UtteranceStreamChunker {
|
||||
var chunkIndex = 0
|
||||
|
||||
func emit(upTo splitEnd: Int, isLast: Bool) {
|
||||
guard splitEnd > 0, splitEnd <= buffer.count else { return }
|
||||
guard splitEnd > 0, splitEnd <= buffer.count else {
|
||||
FlowTrace.warn(
|
||||
"pipeline.chunk.emitSkipped",
|
||||
"chunk=\(chunkIndex) splitEnd=\(splitEnd) buffered=\(buffer.count)"
|
||||
)
|
||||
return
|
||||
}
|
||||
let chunkSamples = Array(buffer[..<splitEnd])
|
||||
FlowTrace.pipeline(
|
||||
"chunk.emit",
|
||||
"chunk=\(chunkIndex) samples=\(chunkSamples.count) "
|
||||
+ "seconds=\(FlowTrace.seconds(samples: chunkSamples.count, sampleRate: config.sampleRate)) "
|
||||
+ "rms=\(FlowTrace.rms(chunkSamples)) isLast=\(isLast ? 1 : 0)"
|
||||
)
|
||||
continuation.yield(
|
||||
UtteranceAudioChunk(index: chunkIndex, samples: chunkSamples, isLast: isLast)
|
||||
)
|
||||
@@ -36,9 +48,13 @@ public enum UtteranceStreamChunker {
|
||||
}
|
||||
}
|
||||
|
||||
var receivedSnapshots = 0
|
||||
var receivedSamples = 0
|
||||
for await snap in stream {
|
||||
if Task.isCancelled { break }
|
||||
guard !snap.samples.isEmpty else { continue }
|
||||
receivedSnapshots += 1
|
||||
receivedSamples += snap.samples.count
|
||||
buffer.append(contentsOf: snap.samples)
|
||||
|
||||
while buffer.count >= config.maxChunkSamples(forChunkIndex: chunkIndex) {
|
||||
@@ -51,10 +67,24 @@ public enum UtteranceStreamChunker {
|
||||
}
|
||||
}
|
||||
|
||||
FlowTrace.pipeline(
|
||||
"chunk.streamEnded",
|
||||
"snapshots=\(receivedSnapshots) samples=\(receivedSamples) "
|
||||
+ "seconds=\(FlowTrace.seconds(samples: receivedSamples, sampleRate: config.sampleRate)) "
|
||||
+ "chunksEmitted=\(chunkIndex) buffered=\(buffer.count) "
|
||||
+ "cancelled=\(Task.isCancelled ? 1 : 0)"
|
||||
)
|
||||
|
||||
if !buffer.isEmpty {
|
||||
emit(upTo: buffer.count, isLast: true)
|
||||
} else if chunkIndex == 0 {
|
||||
// Empty utterance — no chunks.
|
||||
// Empty utterance — no chunks. The recogniser is never
|
||||
// invoked, so an empty transcript here means the mic stream
|
||||
// itself was empty, not that recognition failed.
|
||||
FlowTrace.warn(
|
||||
"pipeline.chunk.emptyUtterance",
|
||||
"snapshots=\(receivedSnapshots) samples=0 chunksEmitted=0"
|
||||
)
|
||||
} else {
|
||||
// Stream ended exactly on a chunk boundary; prior emit holds
|
||||
// all tail audio. Marker so FinalChunkRecovery paths run.
|
||||
|
||||
Reference in New Issue
Block a user