feat: add streaming cloud ASR, polish routing, and settings card layout
Unify Bailian/Volcengine/OpenAI realtime streaming, ABE polish routing with fun styles, and a shared card-page Settings hierarchy; bump to 1.1 (build 32).
This commit is contained in:
@@ -1,230 +0,0 @@
|
||||
// ChunkedUtterancePipelineTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
import XCTest
|
||||
import os
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
private struct StubChunkASR: ASRService, @unchecked Sendable {
|
||||
let labels: @Sendable ([Float]) -> String
|
||||
|
||||
func transcribe(
|
||||
stream: AsyncStream<AudioBufferSnapshot>,
|
||||
locale: Locale
|
||||
) -> AsyncStream<ASREvent> {
|
||||
AsyncStream { $0.finish() }
|
||||
}
|
||||
|
||||
func cancel() {}
|
||||
|
||||
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
||||
_ = locale
|
||||
return .success(labels(samples))
|
||||
}
|
||||
}
|
||||
|
||||
final class ChunkedUtterancePipelineTests: XCTestCase {
|
||||
|
||||
func testPipelineStitchesQueuedChunks() async {
|
||||
let config = FlowUtteranceChunkConfig(
|
||||
maxChunkDurationSeconds: 0.05,
|
||||
overlapDurationSeconds: 0,
|
||||
pauseExtensionMaxSeconds: 0,
|
||||
pauseRMSThreshold: 0.02,
|
||||
sampleRate: 1_000
|
||||
)
|
||||
let asr = StubChunkASR { samples in
|
||||
samples.isEmpty ? "" : "seg\(samples.count)"
|
||||
}
|
||||
let pipeline = ChunkedUtterancePipeline(
|
||||
asr: asr,
|
||||
locale: Locale(identifier: "zh-Hans"),
|
||||
config: config
|
||||
)
|
||||
|
||||
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
|
||||
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
|
||||
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
|
||||
continuation.finish()
|
||||
|
||||
let partialsLock = OSAllocatedUnfairLock(initialState: [String]())
|
||||
let outcome = await pipeline.transcribe(stream: stream) { partial in
|
||||
partialsLock.withLock { $0.append(partial) }
|
||||
}
|
||||
let partials = partialsLock.withLock { $0 }
|
||||
|
||||
guard case .success(let success) = outcome else {
|
||||
return XCTFail("expected success, got \(outcome)")
|
||||
}
|
||||
XCTAssertTrue(success.text.contains("seg"))
|
||||
XCTAssertFalse(partials.isEmpty)
|
||||
}
|
||||
|
||||
func testPipelineDeliversPartialSuccessWhenOneChunkFails() async {
|
||||
let config = FlowUtteranceChunkConfig(
|
||||
maxChunkDurationSeconds: 0.05,
|
||||
overlapDurationSeconds: 0,
|
||||
pauseExtensionMaxSeconds: 0,
|
||||
pauseRMSThreshold: 0.02,
|
||||
sampleRate: 1_000
|
||||
)
|
||||
let pipeline = ChunkedUtterancePipeline(
|
||||
asr: FailingSecondChunkASR(),
|
||||
locale: Locale(identifier: "zh-Hans"),
|
||||
config: config
|
||||
)
|
||||
|
||||
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
|
||||
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
|
||||
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
|
||||
continuation.finish()
|
||||
|
||||
let outcome = await pipeline.transcribe(stream: stream) { _ in }
|
||||
|
||||
guard case .success(let success) = outcome else {
|
||||
return XCTFail("expected partial success, got \(outcome)")
|
||||
}
|
||||
XCTAssertFalse(success.text.isEmpty)
|
||||
XCTAssertEqual(success.chunkWarnings.count, 1)
|
||||
}
|
||||
|
||||
func testPipelineRetranscribesShortFinalChunkWithPriorOverlap() async {
|
||||
let config = FlowUtteranceChunkConfig(
|
||||
maxChunkDurationSeconds: 0.05,
|
||||
overlapDurationSeconds: 10,
|
||||
pauseExtensionMaxSeconds: 0,
|
||||
pauseRMSThreshold: 0.02,
|
||||
minFinalChunkDurationSeconds: 0.05,
|
||||
sampleRate: 1_000
|
||||
)
|
||||
let asr = ShortFinalMergeStubASR()
|
||||
let pipeline = ChunkedUtterancePipeline(
|
||||
asr: asr,
|
||||
locale: Locale(identifier: "zh-Hans"),
|
||||
config: config
|
||||
)
|
||||
|
||||
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
|
||||
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
|
||||
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 20), sampleRate: 1_000))
|
||||
continuation.finish()
|
||||
|
||||
let outcome = await pipeline.transcribe(stream: stream) { _ in }
|
||||
guard case .success(let success) = outcome else {
|
||||
return XCTFail("expected success, got \(outcome)")
|
||||
}
|
||||
XCTAssertTrue(success.text.contains("merged"))
|
||||
}
|
||||
|
||||
func testPipelineRetriesEmptyFinalChunkWithOverlap() async {
|
||||
let config = FlowUtteranceChunkConfig(
|
||||
maxChunkDurationSeconds: 0.05,
|
||||
overlapDurationSeconds: 10,
|
||||
pauseExtensionMaxSeconds: 0,
|
||||
pauseRMSThreshold: 0.02,
|
||||
minFinalChunkDurationSeconds: 0.05,
|
||||
sampleRate: 1_000
|
||||
)
|
||||
let asr = EmptyFinalRetryStubASR()
|
||||
let pipeline = ChunkedUtterancePipeline(
|
||||
asr: asr,
|
||||
locale: Locale(identifier: "zh-Hans"),
|
||||
config: config
|
||||
)
|
||||
|
||||
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
|
||||
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
|
||||
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
|
||||
continuation.finish()
|
||||
|
||||
let outcome = await pipeline.transcribe(stream: stream) { _ in }
|
||||
guard case .success(let success) = outcome else {
|
||||
return XCTFail("expected success, got \(outcome)")
|
||||
}
|
||||
XCTAssertTrue(success.text.contains("recovered-tail"))
|
||||
}
|
||||
}
|
||||
|
||||
private struct FailingSecondChunkASR: ASRService, @unchecked Sendable {
|
||||
private let callIndex = OSAllocatedUnfairLock(initialState: 0)
|
||||
|
||||
func transcribe(
|
||||
stream: AsyncStream<AudioBufferSnapshot>,
|
||||
locale: Locale
|
||||
) -> AsyncStream<ASREvent> {
|
||||
AsyncStream { $0.finish() }
|
||||
}
|
||||
|
||||
func cancel() {}
|
||||
|
||||
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
||||
_ = locale
|
||||
let current = callIndex.withLock { state in
|
||||
let value = state
|
||||
state += 1
|
||||
return value
|
||||
}
|
||||
if current == 1 {
|
||||
return .failure("simulated chunk error")
|
||||
}
|
||||
return .success("seg\(samples.count)")
|
||||
}
|
||||
}
|
||||
|
||||
private struct ShortFinalMergeStubASR: ASRService, @unchecked Sendable {
|
||||
private let callIndex = OSAllocatedUnfairLock(initialState: 0)
|
||||
|
||||
func transcribe(
|
||||
stream: AsyncStream<AudioBufferSnapshot>,
|
||||
locale: Locale
|
||||
) -> AsyncStream<ASREvent> {
|
||||
AsyncStream { $0.finish() }
|
||||
}
|
||||
|
||||
func cancel() {}
|
||||
|
||||
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
||||
_ = locale
|
||||
let current = callIndex.withLock { state in
|
||||
let value = state
|
||||
state += 1
|
||||
return value
|
||||
}
|
||||
if current == 0 {
|
||||
return .success("head")
|
||||
}
|
||||
if samples.count > 20 {
|
||||
return .success("merged-tail")
|
||||
}
|
||||
return .success("short")
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyFinalRetryStubASR: ASRService, @unchecked Sendable {
|
||||
private let callIndex = OSAllocatedUnfairLock(initialState: 0)
|
||||
|
||||
func transcribe(
|
||||
stream: AsyncStream<AudioBufferSnapshot>,
|
||||
locale: Locale
|
||||
) -> AsyncStream<ASREvent> {
|
||||
AsyncStream { $0.finish() }
|
||||
}
|
||||
|
||||
func cancel() {}
|
||||
|
||||
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
||||
_ = locale
|
||||
let current = callIndex.withLock { state in
|
||||
let value = state
|
||||
state += 1
|
||||
return value
|
||||
}
|
||||
if current == 0 {
|
||||
return .success("head")
|
||||
}
|
||||
if current == 1 {
|
||||
return .success("")
|
||||
}
|
||||
return .success("recovered-tail")
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ final class CloudASRTests: XCTestCase {
|
||||
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "zhipu"), .zhipuHotwords)
|
||||
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "qwen"), .localFallback)
|
||||
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "bailian"), .bailianStreaming)
|
||||
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "openai"), .prompt)
|
||||
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "openai"), .openaiRealtimeStreaming)
|
||||
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "whisper"), .prompt)
|
||||
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "mimo"), .prompt)
|
||||
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "groq"), .prompt)
|
||||
@@ -25,7 +25,7 @@ final class CloudASRTests: XCTestCase {
|
||||
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "bailian"), "fun-asr-realtime")
|
||||
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "zhipu"), "glm-asr-2512")
|
||||
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "mimo"), "mimo-v2.5-asr")
|
||||
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "openai"), "gpt-4o-mini-transcribe")
|
||||
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "openai"), "gpt-realtime-whisper")
|
||||
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "whisper"), "whisper-1")
|
||||
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "groq"), "whisper-large-v3-turbo")
|
||||
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "siliconflow"), "FunAudioLLM/SenseVoiceSmall")
|
||||
@@ -65,6 +65,25 @@ final class CloudASRTests: XCTestCase {
|
||||
XCTAssertFalse(LLMProvider.provider(id: "moonshot").supportsPersonalDictionaryCloudASR)
|
||||
}
|
||||
|
||||
func testTrueStreamingASRProviders() {
|
||||
XCTAssertTrue(CloudASRModelCatalog.supportsTrueStreamingASR(for: "bailian"))
|
||||
XCTAssertTrue(CloudASRModelCatalog.supportsTrueStreamingASR(for: "volcengine"))
|
||||
XCTAssertTrue(CloudASRModelCatalog.supportsTrueStreamingASR(for: "openai"))
|
||||
XCTAssertTrue(LLMProvider.provider(id: "bailian").supportsStreamingCloudASR)
|
||||
XCTAssertTrue(LLMProvider.provider(id: "volcengine").supportsStreamingCloudASR)
|
||||
XCTAssertTrue(LLMProvider.provider(id: "openai").supportsStreamingCloudASR)
|
||||
XCTAssertFalse(CloudASRModelCatalog.supportsTrueStreamingASR(for: "mimo"))
|
||||
XCTAssertFalse(CloudASRModelCatalog.supportsTrueStreamingASR(for: "zhipu"))
|
||||
XCTAssertFalse(CloudASRModelCatalog.supportsTrueStreamingASR(for: "groq"))
|
||||
XCTAssertFalse(CloudASRModelCatalog.supportsTrueStreamingASR(for: "whisper"))
|
||||
}
|
||||
|
||||
func testUpsample16kTo24kPreservesDurationRatio() {
|
||||
let input = [Float](repeating: 0.25, count: 1_600) // 100 ms @ 16 kHz
|
||||
let output = CloudASRStreamingPCM.upsample16kTo24k(input)
|
||||
XCTAssertEqual(output.count, 2_400) // 100 ms @ 24 kHz
|
||||
}
|
||||
|
||||
func testShowsASREndpointField() {
|
||||
XCTAssertTrue(CloudASRModelCatalog.showsASREndpointField(for: "bailian"))
|
||||
XCTAssertTrue(CloudASRModelCatalog.showsASREndpointField(for: "openai"))
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
// FinalChunkRecoveryTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class FinalChunkRecoveryTests: XCTestCase {
|
||||
|
||||
private let config = FlowUtteranceChunkConfig(
|
||||
maxChunkDurationSeconds: 5.0,
|
||||
overlapDurationSeconds: 0.5,
|
||||
pauseExtensionMaxSeconds: 2,
|
||||
pauseRMSThreshold: 0.015,
|
||||
minFinalChunkDurationSeconds: 0.8,
|
||||
sampleRate: 16_000
|
||||
)
|
||||
|
||||
func testPreMergePlanForShortFinalChunk() {
|
||||
let chunk = UtteranceAudioChunk(
|
||||
index: 1,
|
||||
samples: [Float](repeating: 0.1, count: 4_000),
|
||||
isLast: true
|
||||
)
|
||||
let previous = [Float](repeating: 0.2, count: 80_000)
|
||||
|
||||
let plan = FinalChunkRecovery.preMergePlan(
|
||||
chunk: chunk,
|
||||
processedChunks: 2,
|
||||
previousChunkSamples: previous,
|
||||
config: config
|
||||
)
|
||||
|
||||
XCTAssertNotNil(plan)
|
||||
XCTAssertGreaterThan(plan?.samples.count ?? 0, chunk.samples.count)
|
||||
XCTAssertEqual(plan?.stitchIndex, 0)
|
||||
}
|
||||
|
||||
func testEmptyResultRetryPlanUsesOverlapWhenPriorChunkExists() {
|
||||
let chunk = UtteranceAudioChunk(
|
||||
index: 1,
|
||||
samples: [Float](repeating: 0.1, count: 20_000),
|
||||
isLast: true
|
||||
)
|
||||
let previous = [Float](repeating: 0.2, count: 80_000)
|
||||
|
||||
let plan = FinalChunkRecovery.emptyResultRetryPlan(
|
||||
chunk: chunk,
|
||||
previousChunkSamples: previous,
|
||||
config: config,
|
||||
asrText: " "
|
||||
)
|
||||
|
||||
XCTAssertNotNil(plan)
|
||||
XCTAssertGreaterThan(plan?.samples.count ?? 0, chunk.samples.count)
|
||||
}
|
||||
|
||||
func testEmptyResultRetryPlanRetriesSingleChunkSamples() {
|
||||
let chunk = UtteranceAudioChunk(
|
||||
index: 0,
|
||||
samples: [Float](repeating: 0.1, count: 20_000),
|
||||
isLast: true
|
||||
)
|
||||
|
||||
let plan = FinalChunkRecovery.emptyResultRetryPlan(
|
||||
chunk: chunk,
|
||||
previousChunkSamples: [],
|
||||
config: config,
|
||||
asrText: ""
|
||||
)
|
||||
|
||||
XCTAssertEqual(plan?.samples.count, chunk.samples.count)
|
||||
XCTAssertEqual(plan?.stitchIndex, 0)
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,20 @@ final class IntelligentPolishTests: XCTestCase {
|
||||
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "明天见"))
|
||||
}
|
||||
|
||||
func testShouldSkipLLMTier2ForAckClosings() {
|
||||
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "好的我知道了"))
|
||||
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "那就先这样吧"))
|
||||
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "晚点再说"))
|
||||
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "收到谢谢"))
|
||||
}
|
||||
|
||||
func testShouldNotSkipLLMTier2ForQuestionsOrContent() {
|
||||
XCTAssertFalse(TranscriptPostProcessor.shouldSkipLLM(for: "今晚有空吗"))
|
||||
XCTAssertFalse(TranscriptPostProcessor.shouldSkipLLM(for: "这个还行吧"))
|
||||
XCTAssertFalse(TranscriptPostProcessor.shouldSkipLLM(for: "周六一起吃饭"))
|
||||
XCTAssertFalse(TranscriptPostProcessor.shouldSkipLLM(for: "防晒不由夏天"))
|
||||
}
|
||||
|
||||
func testShouldNotSkipLLMWhenStructurePresent() {
|
||||
XCTAssertFalse(TranscriptPostProcessor.shouldSkipLLM(for: "第一点做完第二点再做"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// PolishRouterTests.swift
|
||||
// OSGKeyboard · Tests
|
||||
//
|
||||
// Locks ABE routing: sparse gate (A), prompt hard-brakes (B), and
|
||||
// style-specific degradation (E) without calling a real LLM.
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class PolishRouterTests: XCTestCase {
|
||||
|
||||
func testSparseShortForcesConservativeLightForFunStyles() {
|
||||
let decision = PolishRouter.decide(
|
||||
text: "这个还行吧",
|
||||
styleID: "builtin.xhs",
|
||||
intensity: .heavy
|
||||
)
|
||||
XCTAssertEqual(decision.mode, .conservative)
|
||||
XCTAssertEqual(decision.effectiveIntensity, .light)
|
||||
XCTAssertEqual(decision.effectiveStyleID, "builtin.xhs")
|
||||
XCTAssertTrue(decision.reasons.contains("A:sparse"))
|
||||
}
|
||||
|
||||
func testDibaWithoutOpponentFallsBackToChat() {
|
||||
let decision = PolishRouter.decide(
|
||||
text: "不是这样的",
|
||||
styleID: "builtin.diba",
|
||||
intensity: .heavy
|
||||
)
|
||||
XCTAssertEqual(decision.mode, .chatFallback)
|
||||
XCTAssertEqual(decision.effectiveStyleID, "builtin.chat")
|
||||
XCTAssertEqual(decision.effectiveIntensity, .light)
|
||||
XCTAssertTrue(decision.reasons.contains("E:diba_no_opponent"))
|
||||
}
|
||||
|
||||
func testDibaWithOpponentQuoteStaysFull() {
|
||||
let decision = PolishRouter.decide(
|
||||
text: "回他你这叫为你好那对方不同意你还要强行是吧",
|
||||
styleID: "builtin.diba",
|
||||
intensity: .heavy
|
||||
)
|
||||
XCTAssertEqual(decision.mode, .full)
|
||||
XCTAssertEqual(decision.effectiveStyleID, "builtin.diba")
|
||||
XCTAssertEqual(decision.effectiveIntensity, .heavy)
|
||||
}
|
||||
|
||||
func testDatingSparseForcesConservative() {
|
||||
let decision = PolishRouter.decide(
|
||||
text: "还行吧",
|
||||
styleID: "builtin.dating",
|
||||
intensity: .heavy
|
||||
)
|
||||
XCTAssertEqual(decision.mode, .conservative)
|
||||
XCTAssertEqual(decision.effectiveIntensity, .light)
|
||||
XCTAssertTrue(decision.reasons.contains("E:dating_short_no_flirt"))
|
||||
}
|
||||
|
||||
func testDatingInviteQuestionStaysFull() {
|
||||
let decision = PolishRouter.decide(
|
||||
text: "今晚有空吗",
|
||||
styleID: "builtin.dating",
|
||||
intensity: .heavy
|
||||
)
|
||||
XCTAssertEqual(decision.mode, .full)
|
||||
XCTAssertEqual(decision.effectiveIntensity, .heavy)
|
||||
}
|
||||
|
||||
func testChatSparseForcesConservativeNoReply() {
|
||||
let decision = PolishRouter.decide(
|
||||
text: "没事",
|
||||
styleID: "builtin.chat",
|
||||
intensity: .medium
|
||||
)
|
||||
XCTAssertEqual(decision.mode, .conservative)
|
||||
XCTAssertEqual(decision.effectiveIntensity, .light)
|
||||
XCTAssertTrue(decision.reasons.contains("E:chat_no_reply"))
|
||||
}
|
||||
|
||||
func testFormalKeepsFullEvenWhenShort() {
|
||||
let decision = PolishRouter.decide(
|
||||
text: "收到",
|
||||
styleID: "builtin.formal",
|
||||
intensity: .heavy
|
||||
)
|
||||
XCTAssertEqual(decision.mode, .full)
|
||||
XCTAssertEqual(decision.effectiveIntensity, .heavy)
|
||||
}
|
||||
|
||||
func testContentfulMediumStaysFullForXHS() {
|
||||
let decision = PolishRouter.decide(
|
||||
text: "这款防晒霜我用了不油夏天可以推荐",
|
||||
styleID: "builtin.xhs",
|
||||
intensity: .heavy
|
||||
)
|
||||
XCTAssertEqual(decision.mode, .full)
|
||||
XCTAssertEqual(decision.effectiveIntensity, .heavy)
|
||||
}
|
||||
|
||||
func testPromptBlockIncludesHardBrakeForFunStyles() {
|
||||
let block = PolishRouter.promptBlock(
|
||||
mode: .conservative,
|
||||
styleID: "builtin.xhs",
|
||||
useChineseGuidance: true
|
||||
)
|
||||
XCTAssertTrue(block.contains("信息不足时的硬刹车"))
|
||||
XCTAssertTrue(block.contains("本次模式:保守清理"))
|
||||
XCTAssertTrue(block.contains("小红书专属降级"))
|
||||
}
|
||||
|
||||
func testComposerInjectsRoutingBlock() {
|
||||
let style = PolishStylePackCatalog.resolve(
|
||||
id: "builtin.dating",
|
||||
userCatalog: .empty
|
||||
)
|
||||
let prompt = PolishPromptComposer.compose(
|
||||
text: "还行",
|
||||
style: style,
|
||||
context: PolishContext(intensity: .light),
|
||||
dictionaryBlock: "",
|
||||
globalContract: "GLOBAL",
|
||||
useChineseGuidance: true,
|
||||
routingMode: .conservative
|
||||
)
|
||||
XCTAssertTrue(prompt.contains("信息不足时的硬刹车"))
|
||||
XCTAssertTrue(prompt.contains("直男癌专属降级"))
|
||||
XCTAssertTrue(prompt.contains("本次模式:保守清理"))
|
||||
}
|
||||
|
||||
func testIsInformationSparseDetectsHollowShorts() {
|
||||
XCTAssertTrue(PolishRouter.isInformationSparse("香香的"))
|
||||
XCTAssertTrue(PolishRouter.isInformationSparse("这个还行吧"))
|
||||
XCTAssertFalse(PolishRouter.isInformationSparse(
|
||||
"这款防晒霜我用了不油夏天可以推荐"
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,15 @@ final class PolishStylePackTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testBuiltinPromptsAreCompleteAndWithinRuntimeLimit() {
|
||||
XCTAssertEqual(PolishStylePackCatalog.builtins.count, 5)
|
||||
XCTAssertEqual(PolishStylePackCatalog.builtins.count, 9)
|
||||
XCTAssertEqual(PolishStylePackCatalog.BuiltinStyleGroup.practical.packs.count, 4)
|
||||
XCTAssertEqual(PolishStylePackCatalog.BuiltinStyleGroup.fun.packs.count, 5)
|
||||
|
||||
for style in PolishStylePackCatalog.builtins {
|
||||
XCTAssertFalse(
|
||||
PolishStylePackCatalog.systemImage(for: style.id).isEmpty,
|
||||
style.id
|
||||
)
|
||||
XCTAssertTrue(style.prompt.contains("# 角色"), style.id)
|
||||
XCTAssertTrue(style.prompt.contains("# ASR 纠错与信息保真"), style.id)
|
||||
XCTAssertTrue(style.prompt.contains("# 输出"), style.id)
|
||||
@@ -30,6 +36,28 @@ final class PolishStylePackTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
func testBuiltinStylesMapToSFSymbols() {
|
||||
let expected: [String: String] = [
|
||||
"builtin.light": "wand.and.sparkles",
|
||||
"builtin.structured": "list.bullet.rectangle",
|
||||
"builtin.formal": "briefcase",
|
||||
"builtin.chat": "bubble.left.and.bubble.right",
|
||||
"builtin.dating": "heart.text.square",
|
||||
"builtin.flex": "textformat",
|
||||
"builtin.corp": "building.2",
|
||||
"builtin.diba": "quote.bubble",
|
||||
"builtin.xhs": "star.bubble",
|
||||
]
|
||||
|
||||
for (id, symbol) in expected {
|
||||
XCTAssertEqual(PolishStylePackCatalog.systemImage(for: id), symbol, id)
|
||||
}
|
||||
XCTAssertEqual(
|
||||
PolishStylePackCatalog.systemImage(for: "user.custom"),
|
||||
"text.badge.star"
|
||||
)
|
||||
}
|
||||
|
||||
func testDatingStyleDefinesRelationshipAwareIntensityAndSafety() throws {
|
||||
let style = try XCTUnwrap(
|
||||
PolishStylePackCatalog.builtins.first { $0.id == "builtin.dating" }
|
||||
@@ -37,11 +65,51 @@ final class PolishStylePackTests: XCTestCase {
|
||||
|
||||
XCTAssertTrue(style.prompt.contains("# 本风格的力度解释"))
|
||||
XCTAssertTrue(style.prompt.contains("# 关系许可闸"))
|
||||
XCTAssertTrue(style.prompt.contains("Light(暖而不撩)"))
|
||||
XCTAssertTrue(style.prompt.contains("Medium(温度与趣味)"))
|
||||
XCTAssertTrue(style.prompt.contains("Heavy(主动而明确)"))
|
||||
XCTAssertTrue(style.prompt.contains("不把冷淡解释成欲擒故纵"))
|
||||
XCTAssertTrue(style.prompt.contains("暧昧不能代替明确同意"))
|
||||
XCTAssertTrue(style.prompt.contains("意图守恒,措辞可整句重写"))
|
||||
XCTAssertTrue(style.prompt.contains("口语为主,巧思点缀"))
|
||||
XCTAssertTrue(style.prompt.contains("Light(加戏)"))
|
||||
XCTAssertTrue(style.prompt.contains("Medium(会撩)"))
|
||||
XCTAssertTrue(style.prompt.contains("Heavy(更挑逗)"))
|
||||
XCTAssertTrue(style.prompt.contains("不把冷淡当欲擒故纵"))
|
||||
XCTAssertTrue(style.prompt.contains("挑逗 ≠ 色情"))
|
||||
}
|
||||
|
||||
func testFunStylesDefineVoiceRewriteContracts() throws {
|
||||
let flex = try XCTUnwrap(
|
||||
PolishStylePackCatalog.builtins.first { $0.id == "builtin.flex" }
|
||||
)
|
||||
let corp = try XCTUnwrap(
|
||||
PolishStylePackCatalog.builtins.first { $0.id == "builtin.corp" }
|
||||
)
|
||||
let diba = try XCTUnwrap(
|
||||
PolishStylePackCatalog.builtins.first { $0.id == "builtin.diba" }
|
||||
)
|
||||
let xhs = try XCTUnwrap(
|
||||
PolishStylePackCatalog.builtins.first { $0.id == "builtin.xhs" }
|
||||
)
|
||||
|
||||
XCTAssertTrue(flex.prompt.contains("装逼指南"))
|
||||
XCTAssertTrue(flex.prompt.contains("口语为主,装感点缀"))
|
||||
XCTAssertTrue(corp.prompt.contains("大厂黑话"))
|
||||
XCTAssertTrue(corp.prompt.contains("汇报"))
|
||||
XCTAssertTrue(corp.prompt.contains("甩锅"))
|
||||
XCTAssertTrue(diba.prompt.contains("帝吧大神"))
|
||||
XCTAssertTrue(diba.prompt.contains("主攻回复对方"))
|
||||
XCTAssertTrue(diba.prompt.contains("不脏字"))
|
||||
XCTAssertTrue(xhs.prompt.contains("小红书集美"))
|
||||
XCTAssertTrue(xhs.prompt.contains("笔记正文"))
|
||||
XCTAssertTrue(xhs.prompt.contains("Light(轻安利)"))
|
||||
XCTAssertTrue(xhs.prompt.contains("禁止编造"))
|
||||
|
||||
for id in ["builtin.dating", "builtin.flex", "builtin.corp", "builtin.diba"] {
|
||||
XCTAssertTrue(PolishStylePackCatalog.isFunPersonality(id: id), id)
|
||||
XCTAssertTrue(PolishStylePackCatalog.limitsHeavyRestructuring(id: id), id)
|
||||
XCTAssertFalse(PolishStylePackCatalog.prefersNoteForm(id: id), id)
|
||||
}
|
||||
|
||||
XCTAssertTrue(PolishStylePackCatalog.isFunPersonality(id: "builtin.xhs"))
|
||||
XCTAssertTrue(PolishStylePackCatalog.prefersNoteForm(id: "builtin.xhs"))
|
||||
XCTAssertFalse(PolishStylePackCatalog.limitsHeavyRestructuring(id: "builtin.xhs"))
|
||||
}
|
||||
|
||||
func testCatalogRejectsNinthUserPack() throws {
|
||||
@@ -142,18 +210,69 @@ final class PolishStylePackTests: XCTestCase {
|
||||
let medium = PolishIntensity.medium.promptGuideline(styleID: "builtin.dating")
|
||||
let heavy = PolishIntensity.heavy.promptGuideline(styleID: "builtin.dating")
|
||||
|
||||
XCTAssertTrue(light.contains("Dating Light"))
|
||||
XCTAssertTrue(light.contains("without adding flirtation"))
|
||||
XCTAssertTrue(medium.contains("Dating Medium"))
|
||||
XCTAssertTrue(medium.contains("at most one"))
|
||||
XCTAssertTrue(heavy.contains("Dating Heavy"))
|
||||
XCTAssertTrue(heavy.contains("Increase romantic tension and directness"))
|
||||
XCTAssertTrue(light.contains("Dating Light (加戏)"))
|
||||
XCTAssertTrue(light.contains("spoken WeChat first"))
|
||||
XCTAssertTrue(light.contains("Do not make it flirtatious"))
|
||||
XCTAssertTrue(medium.contains("Dating Medium (会撩)"))
|
||||
XCTAssertTrue(medium.contains("readable flirtation"))
|
||||
XCTAssertTrue(heavy.contains("Dating Heavy (更挑逗)"))
|
||||
XCTAssertTrue(heavy.contains("Bolder teasing"))
|
||||
XCTAssertTrue(heavy.contains("Style override"))
|
||||
}
|
||||
|
||||
func testFunStylesUseFeatureDensityIntensityGuidelines() {
|
||||
let flex = PolishIntensity.medium.promptGuideline(styleID: "builtin.flex")
|
||||
let corp = PolishIntensity.heavy.promptGuideline(styleID: "builtin.corp")
|
||||
let diba = PolishIntensity.light.promptGuideline(styleID: "builtin.diba")
|
||||
let xhsLight = PolishIntensity.light.promptGuideline(styleID: "builtin.xhs")
|
||||
let xhsHeavy = PolishIntensity.heavy.promptGuideline(styleID: "builtin.xhs")
|
||||
|
||||
XCTAssertTrue(flex.contains("Flex Medium"))
|
||||
XCTAssertTrue(flex.contains("pretentious mix"))
|
||||
XCTAssertTrue(corp.contains("Corp Heavy"))
|
||||
XCTAssertTrue(corp.contains("blame-shift"))
|
||||
XCTAssertTrue(corp.contains("Style override"))
|
||||
XCTAssertTrue(diba.contains("DiBa Light"))
|
||||
XCTAssertTrue(diba.contains("No swearing"))
|
||||
XCTAssertTrue(xhsLight.contains("RED Note Light (轻安利)"))
|
||||
XCTAssertTrue(xhsHeavy.contains("RED Note Heavy (爆款感)"))
|
||||
XCTAssertTrue(xhsHeavy.contains("Paragraphs and scannable structure are allowed"))
|
||||
XCTAssertFalse(xhsHeavy.contains("Style override"))
|
||||
}
|
||||
|
||||
func testHeavyIntensityStillAllowsStructuredStyle() {
|
||||
let guideline = PolishIntensity.heavy.promptGuideline(styleID: "builtin.structured")
|
||||
|
||||
XCTAssertFalse(guideline.contains("Style override"))
|
||||
}
|
||||
|
||||
func testPracticalStylesShareTranscriptOnlyBoundary() {
|
||||
for id in ["builtin.light", "builtin.structured", "builtin.formal", "builtin.chat"] {
|
||||
let pack = PolishStylePackCatalog.resolve(id: id, userCatalog: .empty)
|
||||
XCTAssertTrue(
|
||||
pack.prompt.contains("你不是聊天助手"),
|
||||
id
|
||||
)
|
||||
XCTAssertTrue(
|
||||
pack.prompt.contains("只把输入当作需要整理的语音转写内容"),
|
||||
id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testStructuredStyleEncodesActiveItemizationHardRules() {
|
||||
let pack = PolishStylePackCatalog.resolve(id: "builtin.structured", userCatalog: .empty)
|
||||
XCTAssertTrue(pack.prompt.contains("自动结构化(偏积极)"))
|
||||
XCTAssertTrue(pack.prompt.contains("有 3 条及以上事项"))
|
||||
XCTAssertTrue(pack.prompt.contains("必须**编号列项"))
|
||||
XCTAssertTrue(pack.prompt.contains("语义重排"))
|
||||
XCTAssertTrue(pack.prompt.contains("智能分段"))
|
||||
}
|
||||
|
||||
func testChatStyleForbidsInterlocutorRepliesAndActiveLists() {
|
||||
let pack = PolishStylePackCatalog.resolve(id: "builtin.chat", userCatalog: .empty)
|
||||
XCTAssertTrue(pack.prompt.contains("禁止以聊天对象身份接话"))
|
||||
XCTAssertTrue(pack.prompt.contains("不主动「积极分项」"))
|
||||
XCTAssertTrue(pack.prompt.contains("原:嗯"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,4 +40,14 @@ final class UtteranceTranscriptStitcherTests: XCTestCase {
|
||||
stitcher.append(index: 1, text: "第二段合并")
|
||||
XCTAssertEqual(stitcher.composed(), "第一段 第二段合并")
|
||||
}
|
||||
|
||||
/// Documents the preMerge wipe hazard: append ignores empty text, so
|
||||
/// removeLast + empty append leaves nothing. Pipeline must guard this.
|
||||
func testEmptyAppendAfterRemoveLastWipesPriorSegment() {
|
||||
var stitcher = UtteranceTranscriptStitcher()
|
||||
stitcher.append(index: 0, text: "已识别内容")
|
||||
stitcher.removeLastSegment()
|
||||
stitcher.append(index: 0, text: "")
|
||||
XCTAssertEqual(stitcher.composedSafely(), "")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user