test(ai): expand AI flow and clipboard semantic coverage

- Cover reply-variant decision paths, scene-detector abstention, and
  the merged reply center stance catalog.
- Lock in clipboard semantic analyzer thresholds, complaint-only
  suppression, and existing-skill routing.
- Add durable session, refresh, and managed-gateway reliability tests
  for the Apple account path.
- Verify personal-style prompt derivation, low-confidence ASR
  tendencies, and Polish style learning against real corpus evidence.
- Exercise the assistant keyboard and Polish styles UI flows in
  end-to-end UI tests.
This commit is contained in:
Rocky
2026-08-29 11:51:25 +08:00
parent 3c10d73d7f
commit b275b6b0d9
15 changed files with 1348 additions and 125 deletions
+114 -26
View File
@@ -82,7 +82,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
[AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.translateID]
)
XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.summarizeID))
XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.acceptInvitationID))
XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.replyID))
XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.extractEventsID))
}
@@ -113,10 +113,6 @@ final class AIAgentSkillLayoutTests: XCTestCase {
AIClipboardSkillCatalog.callPhoneID,
AIClipboardSkillCatalog.createContactID,
AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.blessingReplyID,
AIClipboardSkillCatalog.organizeListID
]
)
@@ -149,10 +145,6 @@ final class AIAgentSkillLayoutTests: XCTestCase {
AIClipboardSkillCatalog.callPhoneID,
AIClipboardSkillCatalog.createContactID,
AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.blessingReplyID,
AIClipboardSkillCatalog.organizeListID
]
)
@@ -183,10 +175,6 @@ final class AIAgentSkillLayoutTests: XCTestCase {
AIClipboardSkillCatalog.callPhoneID,
AIClipboardSkillCatalog.createContactID,
AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.blessingReplyID,
AIClipboardSkillCatalog.organizeListID
]
)
@@ -218,11 +206,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
[
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.blessingReplyID,
AIClipboardSkillCatalog.organizeListID
]
)
@@ -230,7 +214,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
defaults.integer(
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
),
9
11
)
}
@@ -256,10 +240,6 @@ final class AIAgentSkillLayoutTests: XCTestCase {
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.blessingReplyID,
AIClipboardSkillCatalog.organizeListID
]
)
@@ -298,7 +278,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
defaults.integer(
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
),
9
11
)
}
@@ -324,7 +304,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
defaults.integer(
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
),
9
11
)
XCTAssertEqual(
AppGroupStore(defaults: defaults).agentSkillLayout.enabledIDs,
@@ -332,6 +312,84 @@ final class AIAgentSkillLayoutTests: XCTestCase {
)
}
func testVersionNineLayoutConsolidatesEmpathyReplyAndPersistsMigration() throws {
let defaults = makeDefaults()
let initial = AIAgentSkillLayout(
enabledIDs: [
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.translateID
],
confirmedShortcutIDs: []
)
defaults.set(
try JSONEncoder().encode(initial),
forKey: AppGroupConfiguration.Keys.agentSkillLayout
)
defaults.set(
9,
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
)
let migrated = AppGroupStore(defaults: defaults).agentSkillLayout
XCTAssertEqual(
migrated.enabledIDs,
[AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.translateID]
)
XCTAssertEqual(
defaults.integer(
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
),
11
)
}
func testVersionTenLayoutConsolidatesDecisionRepliesWithoutRestoringDisabledReply() throws {
let enabledDefaults = makeDefaults()
let legacy = AIAgentSkillLayout(
enabledIDs: [
AIClipboardSkillCatalog.acceptInvitationID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.acceptTaskID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.blessingReplyID
],
confirmedShortcutIDs: []
)
enabledDefaults.set(
try JSONEncoder().encode(legacy),
forKey: AppGroupConfiguration.Keys.agentSkillLayout
)
enabledDefaults.set(
10,
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
)
XCTAssertEqual(
AppGroupStore(defaults: enabledDefaults).agentSkillLayout.enabledIDs,
[AIClipboardSkillCatalog.replyID]
)
let disabledDefaults = makeDefaults()
disabledDefaults.set(
try JSONEncoder().encode(
AIAgentSkillLayout(
enabledIDs: [AIClipboardSkillCatalog.translateID],
confirmedShortcutIDs: []
)
),
forKey: AppGroupConfiguration.Keys.agentSkillLayout
)
disabledDefaults.set(
10,
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
)
XCTAssertEqual(
AppGroupStore(defaults: disabledDefaults).agentSkillLayout.enabledIDs,
[AIClipboardSkillCatalog.translateID]
)
}
func testLegacyReplyLookupRemainsAvailableButCanonicalizesForLayout() {
XCTAssertNotNil(
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.playfulReplyID)
@@ -339,6 +397,9 @@ final class AIAgentSkillLayoutTests: XCTestCase {
XCTAssertNotNil(
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.businessReplyID)
)
XCTAssertNotNil(
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.empathyReplyID)
)
XCTAssertEqual(
AIClipboardSkillCatalog.canonicalID(for: AIClipboardSkillCatalog.playfulReplyID),
AIClipboardSkillCatalog.replyID
@@ -347,10 +408,37 @@ final class AIAgentSkillLayoutTests: XCTestCase {
AIClipboardSkillCatalog.canonicalID(for: AIClipboardSkillCatalog.businessReplyID),
AIClipboardSkillCatalog.replyID
)
XCTAssertEqual(
AIClipboardSkillCatalog.canonicalID(for: AIClipboardSkillCatalog.empathyReplyID),
AIClipboardSkillCatalog.replyID
)
for id in [
AIClipboardSkillCatalog.acceptInvitationID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.acceptTaskID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.blessingReplyID,
AIClipboardSkillCatalog.askForDetailsID
] {
XCTAssertEqual(
AIClipboardSkillCatalog.canonicalID(for: id),
AIClipboardSkillCatalog.replyID
)
XCTAssertEqual(
AIClipboardSkillCatalog.skill(id: id)?.id,
AIClipboardSkillCatalog.replyID
)
}
XCTAssertFalse(
AIClipboardSkillCatalog.catalog.contains {
$0.id == AIClipboardSkillCatalog.playfulReplyID
|| $0.id == AIClipboardSkillCatalog.businessReplyID
|| $0.id == AIClipboardSkillCatalog.empathyReplyID
|| $0.id == AIClipboardSkillCatalog.acceptInvitationID
|| $0.id == AIClipboardSkillCatalog.declineInvitationID
|| $0.id == AIClipboardSkillCatalog.acceptTaskID
|| $0.id == AIClipboardSkillCatalog.clarifyRequestID
|| $0.id == AIClipboardSkillCatalog.blessingReplyID
}
)
}
@@ -401,6 +489,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
AIClipboardSkillCatalog.replyInSourceLanguageID,
AIClipboardSkillCatalog.playfulReplyID,
AIClipboardSkillCatalog.businessReplyID,
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.extractConclusionsID,
AIClipboardSkillCatalog.askForDetailsID
@@ -412,8 +501,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
layout.enabledIDs,
[
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.clarifyRequestID
AIClipboardSkillCatalog.summarizeID
]
)
}
@@ -188,26 +188,27 @@ final class AIClipboardSkillTests: XCTestCase {
XCTAssertTrue(prompt.contains("决定、结论和下一步"))
}
func testSemanticReplySkillsHaveDistinctInstructions() {
let ids = [
AIClipboardSkillCatalog.playfulReplyID,
AIClipboardSkillCatalog.acceptInvitationID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.acceptTaskID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.businessReplyID,
AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.organizeListID
func testReplyScenesHaveDistinctInstructions() throws {
let skill = try XCTUnwrap(
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.replyID)
)
let scenes: [AIClipboardReplyScene] = [
.invitation,
.task,
.blessing,
.clarification,
.complaint,
.negativeQuestion
]
let prompts = ids.map {
let prompts = scenes.map {
AIClipboardSkillCatalog.instruction(
skillID: $0,
for: skill,
locale: "zh",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
replyScene: $0
)
}
XCTAssertEqual(Set(prompts).count, ids.count)
XCTAssertEqual(Set(prompts).count, scenes.count)
XCTAssertFalse(prompts.contains { $0.contains("用户选择的操作") })
}
@@ -250,6 +251,54 @@ final class AIClipboardSkillTests: XCTestCase {
XCTAssertTrue(instruction.contains("不能改变当前技能的意图"))
}
func testReplyUsesComplaintSceneModifier() throws {
let skill = try XCTUnwrap(
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.replyID)
)
let instruction = AIClipboardSkillCatalog.instruction(
for: skill,
locale: "zh",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
replyScene: .complaint
)
XCTAssertTrue(instruction.contains(#"<reply_scene type="complaint">"#))
XCTAssertTrue(instruction.contains("接住对方的情绪"))
XCTAssertTrue(instruction.contains("不虚构责任、进度或承诺"))
XCTAssertTrue(instruction.contains("不能开玩笑"))
}
func testReplyUsesLighterModifierForNegativeQuestion() throws {
let skill = try XCTUnwrap(
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.replyID)
)
let instruction = AIClipboardSkillCatalog.instruction(
for: skill,
locale: "zh",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
replyScene: .negativeQuestion
)
XCTAssertTrue(instruction.contains(#"<reply_scene type="negative_question">"#))
XCTAssertTrue(instruction.contains("不要因为语气负面就默认用户有错"))
XCTAssertFalse(instruction.contains(#"<reply_scene type="complaint">"#))
}
func testLegacyDecisionSkillUsesUnifiedReplyScene() throws {
let skill = try XCTUnwrap(
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.acceptInvitationID)
)
let instruction = AIClipboardSkillCatalog.instruction(
for: skill,
locale: "zh",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
replyScene: .complaint
)
XCTAssertEqual(skill.id, AIClipboardSkillCatalog.replyID)
XCTAssertTrue(instruction.contains(#"<reply_scene type="complaint">"#))
}
func testReplyStyleIsNotInjectedIntoNonReplySkill() throws {
let skill = try XCTUnwrap(
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.summarizeID)
+76 -2
View File
@@ -116,11 +116,11 @@ final class AIReplyVariantTests: XCTestCase {
func testReplyKindOwnsLocalPresentationMetadata() {
XCTAssertEqual(
AIReplyVariant.Kind.allCases.map(\.systemImage),
AIReplyVariantSet.generic.kinds.map(\.systemImage),
["bubble.left.fill", "briefcase.fill", "theatermasks.fill"]
)
XCTAssertEqual(
AIReplyVariant.Kind.allCases.map(\.titleKey),
AIReplyVariantSet.generic.kinds.map(\.titleKey),
[
"keyboard.ai.replyVariant.ordinary",
"keyboard.ai.replyVariant.formal",
@@ -129,6 +129,80 @@ final class AIReplyVariantTests: XCTestCase {
)
}
func testInvitationParserRequiresExactSceneRoles() throws {
let valid = """
{"variants":[
{"kind":"invitationTentative","emotion":"neutral","text":""},
{"kind":"invitationAccept","emotion":"warm","text":""},
{"kind":"invitationDecline","emotion":"grateful","text":""}
]}
"""
let variants = try XCTUnwrap(
AIReplyVariantParser.parse(
valid,
variantSet: .invitation
)
)
XCTAssertEqual(variants.map(\.kind), AIReplyVariantSet.invitation.kinds)
XCTAssertNil(AIReplyVariantParser.parse(valid, variantSet: .generic))
XCTAssertNil(
AIReplyVariantParser.parseOrFallback(
"好呀,到时见。",
variantSet: .invitation
)
)
}
func testIntentScenesAlwaysGenerateChoicesWhenPreferenceIsOff() {
for scene in [
AIClipboardReplyScene.invitation,
.task,
.blessing,
.clarification
] {
XCTAssertTrue(
AIReplyVariantSet.shouldGenerate(
multipleRepliesEnabled: false,
scene: scene
)
)
}
XCTAssertFalse(
AIReplyVariantSet.shouldGenerate(
multipleRepliesEnabled: false,
scene: .complaint
)
)
XCTAssertFalse(
AIReplyVariantSet.shouldGenerate(
multipleRepliesEnabled: false,
scene: nil
)
)
}
func testIntentChoicePresentationUsesFixedLocalMeaning() {
XCTAssertEqual(
AIReplyVariantSet.task.kinds.map(\.titleKey),
[
"keyboard.ai.replyVariant.taskAcknowledge",
"keyboard.ai.replyVariant.taskClarify",
"keyboard.ai.replyVariant.taskNegotiate"
]
)
XCTAssertEqual(
AIReplyVariantSet.invitation.kinds.map(\.systemImage),
["checkmark.circle.fill", "hand.raised.fill", "clock.fill"]
)
XCTAssertTrue(AIReplyVariantSet.generic.kinds.allSatisfy(\.usesEmotionIcon))
XCTAssertTrue(
AIReplyVariantSet.invitation.kinds.allSatisfy {
!$0.usesEmotionIcon
}
)
}
func testEmotionMapsOnlyToLocalSFSymbolAllowlist() {
XCTAssertEqual(
AIReplyVariant.Emotion.celebratory.systemImage(fallback: .ordinary),
@@ -223,6 +223,39 @@ final class AISessionStateTests: XCTestCase {
XCTAssertNil(state.selectedReplyVariant)
}
func testSceneReplyVariantsUseLocalRoleOrder() {
var state = AISessionState()
let utteranceID = UUID()
state.enter()
state.beginPreparing(utteranceID: utteranceID)
state.receiveReplyVariants(
[
AIReplyVariant(
kind: .invitationTentative,
emotion: .neutral,
text: "我确认一下。"
),
AIReplyVariant(
kind: .invitationDecline,
emotion: .grateful,
text: "这次先不去了。"
),
AIReplyVariant(
kind: .invitationAccept,
emotion: .warm,
text: "好呀,到时见。"
)
],
utteranceID: utteranceID
)
XCTAssertTrue(state.canSelectReplyVariant)
XCTAssertEqual(
state.replyVariants.map(\.kind),
AIReplyVariantSet.invitation.kinds
)
}
private func makeReplyVariants() -> [AIReplyVariant] {
[
AIReplyVariant(kind: .ordinary, emotion: .warm, text: "普通回复"),
@@ -241,6 +241,37 @@ final class ClipboardReplyFeedbackStoreTests: XCTestCase {
XCTAssertNil(example.playfulCandidate)
}
func testSceneDecisionTeachesStyleOnlyAfterUserFinalEdit() throws {
let candidate = ClipboardReplyCandidateSnapshot(
kind: .invitationDecline,
text: "谢谢邀请,不过这次先不去了。",
emotion: "grateful"
)
let recordID = try XCTUnwrap(
store.begin(
sourceText: "周六一起吃饭吗?",
candidates: [candidate],
styleID: "user.personal"
)
)
store.recordSelection(
recordID: recordID,
candidateID: candidate.id,
answerID: candidate.id
)
XCTAssertTrue(store.learningExamples().isEmpty)
store.recordFinalEdit(
answerID: candidate.id,
text: "这周六不行,下次约~",
revision: 1
)
let example = try XCTUnwrap(store.learningExamples().first)
XCTAssertEqual(example.selection, .contextual)
XCTAssertEqual(example.finalEdit, "这周六不行,下次约~")
}
private func makeCandidates(
suffix: String = ""
) -> [ClipboardReplyCandidateSnapshot] {
@@ -19,6 +19,43 @@ final class ClipboardSemanticAnalyzerTests: XCTestCase {
XCTAssertFalse(analysis.confirmationDecision.isDetected)
XCTAssertFalse(analysis.followUpReminder.isDetected)
XCTAssertFalse(analysis.blessing.isDetected)
XCTAssertFalse(analysis.assistantCommand.isDetected)
XCTAssertFalse(analysis.informationQuery.isDetected)
XCTAssertFalse(analysis.systemNotification.isDetected)
XCTAssertNil(analysis.domain)
XCTAssertNil(analysis.domainConfidence)
}
func testCurrentManifestWithoutExtendedClassifiersFailsClosed() async {
let analysis = await ClipboardSemanticAnalyzer().analyze(
"What is the weather in Shanghai tomorrow?"
)
XCTAssertEqual(analysis.assistantCommand, .notDetected)
XCTAssertEqual(analysis.informationQuery, .notDetected)
XCTAssertEqual(analysis.systemNotification, .notDetected)
XCTAssertNil(analysis.domain)
XCTAssertNil(analysis.domainConfidence)
}
func testPublicDomainTaxonomyContainsTwelveStableLabels() {
XCTAssertEqual(
ClipboardSemanticDomain.allCases.map(\.rawValue),
[
"finance",
"travel",
"calendar",
"communication",
"media",
"smartHome",
"shopping",
"dining",
"health",
"weather",
"accountService",
"generalKnowledge"
]
)
}
func testComplaintTaskPolicySuppressesImplicitFailure() {
@@ -48,6 +85,88 @@ final class ClipboardSemanticAnalyzerTests: XCTestCase {
)
}
func testBlessingRoutingAcceptsBroadExplicitWishes() {
let samples = [
"一路平安,到了记得报个平安。",
"祝愿她早日康复,重新回到喜欢的生活。",
"恭喜顺利毕业,前程似锦!",
"端午安康,愿大家平安喜乐。",
"Safe travels and all the best for the new chapter.",
"Get well soon. We are all thinking of you.",
"Happy anniversary! May your days stay full of love."
]
for text in samples {
let result = ClipboardSemanticAnalyzer.adjustedBlessingLabel(
blessingCandidate(confidence: 0),
text: text
)
XCTAssertTrue(result.isDetected, "Missed broad blessing: \(text)")
XCTAssertTrue(result.isApprovedForAutomaticRouting)
}
}
func testBlessingRoutingRejectsBoundaryContexts() {
let samples = [
"谢谢大家发来的生日祝福。",
"帮我写一段适合春节发微信的祝福语。",
"文档里引用了“祝你生日快乐”这句话。",
"他们说晚点会祝你生日快乐。",
"我们应该找个时间庆祝项目上线。",
"The article quotes the phrase “wishing you good health.”",
"Thanks for all the wonderful birthday wishes.",
"Thanks for the birthday wish you sent yesterday.",
"Find me a message template that says happy birthday."
]
for text in samples {
let result = ClipboardSemanticAnalyzer.adjustedBlessingLabel(
blessingCandidate(confidence: 0.999),
text: text
)
XCTAssertFalse(result.isDetected, "Boundary routed as blessing: \(text)")
XCTAssertTrue(
ClipboardSemanticAnalyzer.isRejectedBlessingContext(in: text),
"Boundary was not rejected: \(text)"
)
}
}
func testBlessingRoutingAllowsReciprocalWish() {
let samples = [
"谢谢你的祝福,也祝你新年快乐、万事如意!",
"Thanks for the kind wishes. Wishing you a wonderful year too!"
]
for text in samples {
let result = ClipboardSemanticAnalyzer.adjustedBlessingLabel(
blessingCandidate(confidence: 0),
text: text
)
XCTAssertTrue(result.isDetected, "Reciprocal wish was rejected: \(text)")
}
}
func testBlessingRoutingUsesStrictModelFallback() {
let implicitWish = "希望接下来的日子都有温暖和惊喜。"
let highConfidence = ClipboardSemanticAnalyzer.adjustedBlessingLabel(
blessingCandidate(confidence: 0.99),
text: implicitWish
)
let lowerConfidence = ClipboardSemanticAnalyzer.adjustedBlessingLabel(
blessingCandidate(confidence: 0.97),
text: implicitWish
)
XCTAssertTrue(highConfidence.isDetected)
XCTAssertEqual(highConfidence.threshold, 0.98)
XCTAssertFalse(lowerConfidence.isDetected)
XCTAssertEqual(lowerConfidence.threshold, 0.98)
}
func testImplicitComplaintDoesNotRouteAsTask() async {
let analysis = await ClipboardSemanticAnalyzer().analyze(
"""
@@ -364,6 +483,15 @@ final class ClipboardSemanticAnalyzerTests: XCTestCase {
label.confidence > 0 && label.confidence >= label.threshold
}
private func blessingCandidate(confidence: Double) -> ClipboardIntentLabel {
ClipboardIntentLabel(
confidence: confidence,
threshold: 0.77,
isDetected: confidence >= 0.77,
isApprovedForAutomaticRouting: true
)
}
private func milliseconds(from duration: Duration) -> Double {
let components = duration.components
return Double(components.seconds) * 1_000
@@ -12,11 +12,10 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
)
XCTAssertEqual(
Array(ranked.prefix(3)),
Array(ranked.prefix(2)),
[
AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.clarifyRequestID
AIClipboardSkillCatalog.replyID
]
)
}
@@ -54,7 +53,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
)
}
func testInvitationWithDatePromotesCalendarAndBothReplyChoices() {
func testInvitationWithDatePromotesUnifiedReplyAndCalendar() {
let ranked = rank(
text: "今晚七点老地方吃饭,你能来吗?",
analysis: analysis(
@@ -64,15 +63,16 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
)
)
XCTAssertEqual(ranked.first, AIClipboardSkillCatalog.extractEventsID)
XCTAssertEqual(ranked.first, AIClipboardSkillCatalog.replyID)
XCTAssertLessThan(
tryIndex(AIClipboardSkillCatalog.acceptInvitationID, in: ranked),
tryIndex(AIClipboardSkillCatalog.summarizeID, in: ranked)
)
XCTAssertLessThan(
tryIndex(AIClipboardSkillCatalog.declineInvitationID, in: ranked),
tryIndex(AIClipboardSkillCatalog.extractEventsID, in: ranked),
tryIndex(AIClipboardSkillCatalog.summarizeID, in: ranked)
)
XCTAssertEqual(AIClipboardReplyScene.resolve(from: analysis(
hasDate: true,
question: detected(),
invitation: detected()
)), .invitation)
}
func testAddressPromotesNavigation() {
@@ -215,10 +215,6 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
tryIndex(AIClipboardSkillCatalog.organizeListID, in: ranked),
tryIndex(AIClipboardSkillCatalog.replyID, in: ranked)
)
XCTAssertLessThan(
tryIndex(AIClipboardSkillCatalog.acceptTaskID, in: ranked),
tryIndex(AIClipboardSkillCatalog.replyID, in: ranked)
)
}
func testUnapprovedComplaintFallsBackToGenericReply() {
@@ -242,6 +238,57 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
}
func testApprovedComplaintMergesEmpathyIntoGenericReply() {
let analysis = analysis(
sentiment: .negative,
complaint: detected()
)
let recommendations = ClipboardSkillSemanticRanker.recommended(
skills: AIClipboardSkillCatalog.catalog,
sourceText: "这个问题已经发生三次了,到底什么时候能解决?",
analysis: analysis,
uiLanguage: .chinese,
limit: 5
).map(\.id)
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.empathyReplyID))
XCTAssertEqual(AIClipboardReplyScene.resolve(from: analysis), .complaint)
}
func testNegativeQuestionUsesLighterReplySceneWithoutEmpathyChip() {
let analysis = analysis(
sentiment: .negative,
question: detected()
)
let recommendations = ClipboardSkillSemanticRanker.recommended(
skills: AIClipboardSkillCatalog.catalog,
sourceText: "为什么到现在还没有发给我?",
analysis: analysis,
uiLanguage: .chinese,
limit: 5
).map(\.id)
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.empathyReplyID))
XCTAssertEqual(AIClipboardReplyScene.resolve(from: analysis), .negativeQuestion)
}
func testUnapprovedNegativeSignalsDoNotCreateReplyScene() {
let unapprovedQuestion = ClipboardIntentLabel(
confidence: 0.82,
threshold: 0.6,
isDetected: true,
isApprovedForAutomaticRouting: false
)
let analysis = analysis(
sentiment: .negative,
question: unapprovedQuestion
)
XCTAssertNil(AIClipboardReplyScene.resolve(from: analysis))
}
func testLongTextPromotesIntegratedSummaryAndNotes() {
let ranked = rank(
text: String(repeating: "这是需要阅读和整理的长文内容。", count: 40),
@@ -320,7 +367,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
}
func testInvitationKeepsSpecificRepliesAndGenericReply() {
func testInvitationUsesOneReplyCenterAndKeepsCalendarAction() {
let recommendations = ClipboardSkillSemanticRanker.recommended(
skills: AIClipboardSkillCatalog.catalog,
sourceText: "今晚七点老地方吃饭,你能来吗?",
@@ -337,15 +384,13 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
XCTAssertEqual(
recommendations,
[
AIClipboardSkillCatalog.extractEventsID,
AIClipboardSkillCatalog.acceptInvitationID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.replyID
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.extractEventsID
]
)
}
func testForeignQuestionKeepsReplyAndOneSpecializedFollowUp() {
func testForeignQuestionKeepsTranslationAndUnifiedReply() {
let recommendations = ClipboardSkillSemanticRanker.recommended(
skills: AIClipboardSkillCatalog.catalog,
sourceText: "Could you send the final proposal by Friday?",
@@ -360,13 +405,12 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
)
let replyCount = recommendations.filter(\.supportsReplyStyle).count
XCTAssertLessThanOrEqual(replyCount, 2)
XCTAssertEqual(replyCount, 1)
XCTAssertEqual(
recommendations.map(\.id),
[
AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.clarifyRequestID
AIClipboardSkillCatalog.replyID
]
)
}
@@ -380,7 +424,6 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
XCTAssertEqual(
recommendations,
[
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.extractEventsID
]
@@ -400,7 +443,6 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
recommendations,
[
AIClipboardSkillCatalog.extractEventsID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.replyID
]
)
@@ -412,13 +454,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
analysis: analysis(confirmationDecision: detected())
)
XCTAssertEqual(
recommendations,
[
AIClipboardSkillCatalog.acceptTaskID,
AIClipboardSkillCatalog.replyID
]
)
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
}
func testFollowUpReminderMapsToExistingSkills() {
@@ -431,14 +467,12 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
recommendations,
[
AIClipboardSkillCatalog.extractTodosID,
AIClipboardSkillCatalog.acceptTaskID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.replyID
]
)
}
func testBlessingMapsToDedicatedReplyAndGenericFallback() {
func testBlessingMapsToUnifiedReplyCenter() {
let recommendations = recommended(
text: "大家一起祝王老师生日快乐、身体健康!",
analysis: analysis(
@@ -447,13 +481,11 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
)
)
XCTAssertEqual(
recommendations,
[
AIClipboardSkillCatalog.blessingReplyID,
AIClipboardSkillCatalog.replyID
]
)
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
XCTAssertEqual(AIClipboardReplyScene.resolve(from: analysis(
replyableMessage: detected(),
blessing: detected()
)), .blessing)
}
func testNewIntentConflictKeepsTopFiveAndGenericReply() {
@@ -472,13 +504,11 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
recommendations,
[
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.acceptTaskID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.extractEventsID,
AIClipboardSkillCatalog.extractTodosID
]
)
XCTAssertEqual(recommendations.count, 5)
XCTAssertEqual(recommendations.count, 3)
}
func testSpecializedNewIntentSuppressesGenericReplyableBoost() {
@@ -494,13 +524,82 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
XCTAssertEqual(
recommendations,
[
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.extractEventsID
]
)
}
func testDisplayOnlyIntentsSuppressLegacyInterpersonalRoutes() {
let samples: [(String, ClipboardSemanticAnalysis)] = [
(
"Turn off the living room lights.",
analysis(
task: detected(),
replyableMessage: detected(),
assistantCommand: displayDetected()
)
),
(
"What is the weather tomorrow?",
analysis(
question: detected(),
replyableMessage: detected(),
informationQuery: displayDetected()
)
),
(
"Your package has shipped.",
analysis(
replyableMessage: detected(),
followUpReminder: detected(),
systemNotification: displayDetected()
)
)
]
for sample in samples {
XCTAssertEqual(
recommended(text: sample.0, analysis: sample.1),
[],
"Display-only intent leaked into a legacy route: \(sample.0)"
)
}
}
func testBelowThresholdDisplayIntentKeepsReplyFallback() {
let weakQuery = ClipboardIntentLabel(
confidence: 0.55,
threshold: 0.8,
isDetected: false,
isApprovedForAutomaticRouting: false
)
XCTAssertEqual(
recommended(
text: "Possibly a query",
analysis: analysis(informationQuery: weakQuery)
),
[AIClipboardSkillCatalog.replyID]
)
}
func testDomainAloneDoesNotReorderOrTriggerReply() {
let baseline = [
AIClipboardSkillCatalog.businessReplyID,
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.summarizeID
]
let ranked = ClipboardSkillSemanticRanker.ranked(
skills: skills(ids: baseline),
sourceText: "Account update",
analysis: analysis(domain: .accountService),
uiLanguage: .chinese
).map(\.id)
XCTAssertEqual(ranked, baseline)
}
func testAnalyzerToRecommendationsForNewIntents() async {
let analyzer = ClipboardSemanticAnalyzer()
let samples: [
@@ -514,7 +613,6 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
"We need to reschedule the review. Is Tuesday or Thursday better?",
\.scheduleNegotiation,
[
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.extractEventsID
]
@@ -523,7 +621,6 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
"I approve the revised proposal; proceed with this version.",
\.confirmationDecision,
[
AIClipboardSkillCatalog.acceptTaskID,
AIClipboardSkillCatalog.replyID
]
),
@@ -532,8 +629,6 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
\.followUpReminder,
[
AIClipboardSkillCatalog.extractTodosID,
AIClipboardSkillCatalog.acceptTaskID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.replyID
]
)
@@ -673,6 +768,15 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
)
}
private func displayDetected() -> ClipboardIntentLabel {
ClipboardIntentLabel(
confidence: 0.95,
threshold: 0.8,
isDetected: true,
isApprovedForAutomaticRouting: false
)
}
private func isThresholdCrossing(_ label: ClipboardIntentLabel) -> Bool {
label.confidence > 0 && label.confidence >= label.threshold
}
@@ -692,7 +796,11 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
scheduleNegotiation: ClipboardIntentLabel? = nil,
confirmationDecision: ClipboardIntentLabel? = nil,
followUpReminder: ClipboardIntentLabel? = nil,
blessing: ClipboardIntentLabel? = nil
blessing: ClipboardIntentLabel? = nil,
assistantCommand: ClipboardIntentLabel? = nil,
informationQuery: ClipboardIntentLabel? = nil,
systemNotification: ClipboardIntentLabel? = nil,
domain: ClipboardSemanticDomain? = nil
) -> ClipboardSemanticAnalysis {
ClipboardSemanticAnalysis(
language: language.map {
@@ -725,7 +833,12 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
followUpReminder: followUpReminder ?? absent(),
blessing: blessing ?? absent(),
actionVerifier: nil,
coordinationVerifier: nil
coordinationVerifier: nil,
assistantCommand: assistantCommand ?? absent(),
informationQuery: informationQuery ?? absent(),
systemNotification: systemNotification ?? absent(),
domain: domain,
domainConfidence: domain == nil ? nil : 0.9
)
}
+20 -1
View File
@@ -177,12 +177,31 @@ final class FlowReliabilityTests: XCTestCase {
}
XCTFail("Expected timeout")
} catch {
XCTAssertTrue(error is CancellationError)
XCTAssertEqual(error as? HardTimeoutError, .timedOut)
}
XCTAssertLessThan(Date().timeIntervalSince(started), 0.2)
}
func testHardTimeoutPreservesCallerCancellation() async {
let task = Task {
try await HardTimeout.run(seconds: 5) {
try await Task.sleep(nanoseconds: 5_000_000_000)
return "late"
}
}
await Task.yield()
task.cancel()
do {
_ = try await task.value
XCTFail("Expected caller cancellation")
} catch {
XCTAssertTrue(error is CancellationError, "Unexpected error: \(error)")
XCTAssertNil(error as? HardTimeoutError)
}
}
func testAudioRoutePolicyRebuildsHFPTransitions() {
XCTAssertTrue(
FlowAudioRouteRecoveryPolicy.shouldRebuild(
+108 -3
View File
@@ -114,6 +114,24 @@ final class IntelligentPolishTests: XCTestCase {
XCTAssertFalse(captured.lastPrompt.contains("趣味风格共享格式化"))
}
func testCallerSuppliedOptionsReachLLMClientUnchanged() async throws {
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
let options = LLMGenerationOptions(
temperature: 0.42,
topP: 0.73,
maxTokens: 777
)
_ = try await service.polish(
"分析这些风格学习样本",
systemPrompt: "Return structured evidence.",
options: options
)
XCTAssertEqual(captured.lastOptions, options)
}
func testPersonalDictionaryUpsertManual() {
var dict = PersonalDictionary.empty
let entry = dict.upsertManual(term: "Kubernetes")
@@ -315,14 +333,71 @@ final class IntelligentPolishTests: XCTestCase {
)
}
func testPolishServiceCapsTimeoutAt120() async throws {
func testPolishServiceDefaultsToKeyboardTimeoutCap() async throws {
store.setEngineMode("local")
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured, timeout: 15)
let service = PolishingService(store: store, client: captured)
let veryLong = String(repeating: "测试", count: 2000)
_ = try await service.polish(veryLong, context: PolishContext())
let passedTimeout = try XCTUnwrap(captured.lastTimeout)
XCTAssertLessThanOrEqual(passedTimeout, 120)
XCTAssertEqual(passedTimeout, FlowSessionKeys.maxPolishTimeout)
}
func testPolishServiceAllowsExplicit45SecondTimeoutCap() async throws {
let captured = CapturingLLMClient()
let service = PolishingService(
store: store,
client: captured,
timeout: 45,
maximumTimeout: 45
)
_ = try await service.polish(
"分析这些风格学习样本",
systemPrompt: "Return structured evidence."
)
XCTAssertEqual(captured.lastTimeout, 45)
}
func testPolishServicePropagatesCallerCancellation() async {
let gate = LLMRequestGate()
let service = PolishingService(
store: store,
client: SuspendingLLMClient(gate: gate)
)
let task = Task {
try await service.polish(
"分析这些风格学习样本",
systemPrompt: "Return structured evidence."
)
}
await gate.waitUntilStarted()
task.cancel()
do {
_ = try await task.value
XCTFail("Expected caller cancellation")
} catch {
XCTAssertTrue(error is CancellationError, "Unexpected error: \(error)")
}
}
func testPolishServicePreservesProviderCancellationError() async {
let service = PolishingService(store: store, client: ThrowingLLMClient())
do {
_ = try await service.polish(
"分析这些风格学习样本",
systemPrompt: "Return structured evidence."
)
XCTFail("Expected provider cancellation")
} catch let error as LLMError {
XCTAssertEqual(error, .cancelled)
} catch {
XCTFail("Expected LLMError.cancelled, got \(error)")
}
}
func testPolishServiceUsesChineseForChineseProviders() async throws {
@@ -779,6 +854,36 @@ private final class ThrowingLLMClient: LLMClient, @unchecked Sendable {
}
}
private actor LLMRequestGate {
private var started = false
private var waiters: [CheckedContinuation<Void, Never>] = []
func markStarted() {
started = true
let pending = waiters
waiters.removeAll()
pending.forEach { $0.resume() }
}
func waitUntilStarted() async {
if started { return }
await withCheckedContinuation { continuation in
waiters.append(continuation)
}
}
}
private struct SuspendingLLMClient: LLMClient {
let gate: LLMRequestGate
let requestTimeout: TimeInterval = 15
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
await gate.markStarted()
try await Task.sleep(nanoseconds: 10_000_000_000)
return text
}
}
private final class FixedResponseLLMClient: LLMClient, @unchecked Sendable {
let requestTimeout: TimeInterval = 15
private let response: String
+79
View File
@@ -350,6 +350,85 @@ final class LLMClientTests: XCTestCase {
}
}
func testAppGroupStoreUsesSelectedCredentialChannelForStyleLearning() {
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(
CredentialSource.managed.rawValue,
forKey: AppGroupConfiguration.Keys.credentialSource
)
let managedStore = AppGroupStore(defaults: defaults)
let managedClient = managedStore.makeClient(taskKind: .customSkill)
XCTAssertEqual(
(managedClient as? ManagedLLMClient)?.capability,
.assistant
)
defaults.set(
CredentialSource.byok.rawValue,
forKey: AppGroupConfiguration.Keys.credentialSource
)
let byokStore = AppGroupStore(defaults: defaults)
XCTAssertFalse(
byokStore.makeClient(taskKind: .customSkill) is ManagedLLMClient
)
}
func testLiveConfigurationStoreSnapshotsAnyConfigurationStore() throws {
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let config = ProviderConfig(defaults: defaults)
config.providerId = "openai"
config.baseURL = "https://snapshot.example/v1"
config.apiKey = "snapshot-key"
config.model = "snapshot-model"
config.credentialSource = .managed
config.polishIntensity = .heavy
config.llmThinkingEnabled = true
let source = AppGroupStore(defaults: defaults)
var catalog = PolishStyleCatalog()
let style = PolishStylePack(
id: "user.snapshot-config",
name: "Snapshot",
prompt: "Keep this style fixed."
)
try catalog.upsert(style)
source.setPolishStyleCatalog(catalog)
source.setActivePolishStyleId(style.id)
let captured = LiveConfigurationStore(store: source as any ConfigurationStore)
config.providerId = "deepseek"
config.baseURL = "https://changed.example/v1"
config.apiKey = "changed-key"
config.model = "changed-model"
config.credentialSource = .byok
config.polishIntensity = .light
config.llmThinkingEnabled = false
source.setActivePolishStyleId(PolishStylePackCatalog.defaultID)
XCTAssertEqual(captured.providerId, "openai")
XCTAssertEqual(captured.baseURL, "https://snapshot.example/v1")
XCTAssertEqual(captured.apiKey, "snapshot-key")
XCTAssertEqual(captured.model, "snapshot-model")
XCTAssertEqual(captured.credentialSource, .managed)
XCTAssertEqual(captured.polishIntensity, .heavy)
XCTAssertTrue(captured.llmThinkingEnabled)
XCTAssertEqual(captured.activePolishStyleId, style.id)
let capturedStyle = try XCTUnwrap(
captured.polishStyleCatalog.entries.first(where: { $0.id == style.id })
)
XCTAssertEqual(capturedStyle.name, style.name)
XCTAssertEqual(capturedStyle.prompt, style.prompt)
}
// MARK: - TEST-2: cloud always polishes (legacy modeId ignored)
/// Cloud engine must invoke the LLM even when a legacy `modeId == "off"`
@@ -138,10 +138,6 @@ final class OfficialSkillCatalogTests: XCTestCase {
AIClipboardSkillCatalog.callPhoneID,
AIClipboardSkillCatalog.createContactID,
AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.blessingReplyID,
AIClipboardSkillCatalog.organizeListID,
"official.rewrite"
]
@@ -215,6 +215,8 @@ final class PolishStyleLearningServiceTests: XCTestCase {
XCTAssertFalse(extractor.text.contains("这个 Prompt 后来已经被编辑"))
XCTAssertTrue(extractor.text.contains(String(source.prefix(100))))
XCTAssertTrue(extractor.text.contains(#""userEdited":true"#))
XCTAssertTrue(extractor.text.contains(#""residualBaseline""#))
XCTAssertTrue(extractor.text.contains(#""id":"builtin.chat""#))
XCTAssertTrue(extractor.text.contains("currentStyleContamination"))
XCTAssertTrue(extractor.text.contains("historicalStyleContamination"))
XCTAssertTrue(extractor.text.contains(#""asr":"#))
@@ -224,19 +226,33 @@ final class PolishStyleLearningServiceTests: XCTestCase {
XCTAssertTrue(extractor.prompt.contains("Evidence Extractor"))
XCTAssertTrue(extractor.prompt.contains("finalEdit >"))
XCTAssertTrue(extractor.prompt.contains("NOT the"))
XCTAssertTrue(extractor.prompt.contains("Deduplicate"))
XCTAssertTrue(extractor.prompt.contains("information order"))
XCTAssertTrue(extractor.prompt.contains("epistemic stance"))
XCTAssertTrue(extractor.prompt.contains("userEdited=false"))
XCTAssertTrue(extractor.prompt.contains("retention or migration"))
XCTAssertTrue(extractor.prompt.contains("must not erase"))
XCTAssertTrue(extractor.prompt.contains("asrObservedBefore"))
XCTAssertTrue(synthesizer.prompt.contains("Style Synthesizer"))
XCTAssertTrue(synthesizer.prompt.contains("ASR preserve mode"))
XCTAssertTrue(synthesizer.prompt.contains("AI reply active-transfer mode"))
XCTAssertTrue(synthesizer.prompt.contains("Legal Emoji"))
XCTAssertTrue(
synthesizer.prompt.contains("actively turn every supported candidate trait")
)
XCTAssertTrue(synthesizer.prompt.contains("Never invent migration"))
XCTAssertTrue(synthesizer.prompt.contains("Never replace non-empty candidate traits"))
XCTAssertTrue(synthesizer.text.contains(#""evidence":"#))
XCTAssertTrue(synthesizer.text.contains(#""learningMetadata":"#))
XCTAssertFalse(synthesizer.text.contains(replyMarker))
XCTAssertFalse(synthesizer.text.contains(selectedCandidateMarker))
XCTAssertFalse(synthesizer.text.contains(String(source.prefix(100))))
XCTAssertTrue(client.requests.allSatisfy { $0.timeout == 45 })
XCTAssertTrue(client.requests.allSatisfy { $0.options?.maxTokens == 4_096 })
let metadata = try XCTUnwrap(generated.learningMetadata)
XCTAssertEqual(metadata.schemaVersion, 2)
XCTAssertEqual(metadata.schemaVersion, 3)
XCTAssertEqual(metadata.evidenceStatus, "sufficient")
XCTAssertEqual(metadata.confidence, 0.86)
XCTAssertEqual(metadata.asrExampleCount, 1)
@@ -320,6 +336,29 @@ final class PolishStyleLearningServiceTests: XCTestCase {
}
}
func testExplicitTestBuildThresholdBypassAllowsEmptyCorpus() async throws {
let client = StyleLearningCapturingClient(
responses: [
Self.insufficientEvidenceResponse,
Self.emptyCorpusGeneratedStyleResponse
]
)
let service = PolishStyleLearningService(store: store, client: client)
let generated = try await service.generateStyle(
from: PolishStyleLearningCorpus(
examples: [],
effectiveCharacterCount: 0
),
outputLanguage: .chinese,
minimumEffectiveCharacterCount: 0
)
XCTAssertEqual(client.requests.count, 2)
XCTAssertEqual(generated.learningMetadata?.asrEffectiveCharacterCount, 0)
XCTAssertEqual(generated.learningMetadata?.evidenceStatus, "insufficient")
}
func testGeneratedStyleRejectsMissingRequiredSections() {
let raw = #"{"name":"Invalid","prompt":"Only one sentence.","allowsAddedEmoji":false}"#
@@ -346,7 +385,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
}
}
func testInsufficientEvidenceCannotCreateInventedTraits() async throws {
func testInsufficientEvidenceStillRunsSynthesizerWithoutHardcodedFallback() async throws {
let source = String(repeating: "保真语料", count: 625)
let corpus = PolishStyleLearningCorpus(
examples: [
@@ -359,11 +398,10 @@ final class PolishStyleLearningServiceTests: XCTestCase {
],
effectiveCharacterCount: 2_500
)
let inventedResponse = ##"{"name":"Invented","prompt":"Invented playful slang and secrets","allowsAddedEmoji":true}"##
let client = StyleLearningCapturingClient(
responses: [
Self.insufficientEvidenceResponse,
inventedResponse
Self.lowConfidenceInsufficientEvidenceResponse,
Self.insufficientGeneratedStyleResponse
]
)
let service = PolishStyleLearningService(store: store, client: client)
@@ -374,13 +412,34 @@ final class PolishStyleLearningServiceTests: XCTestCase {
)
XCTAssertEqual(client.requests.count, 2)
XCTAssertEqual(generated.name, "直接短句风格")
XCTAssertNotEqual(generated.name, "保守保真风格")
XCTAssertEqual(generated.learningMetadata?.evidenceStatus, "insufficient")
XCTAssertEqual(generated.learningMetadata?.confidence, 0.2)
XCTAssertFalse(generated.prompt.contains("Invented"))
XCTAssertFalse(generated.prompt.contains("slang"))
XCTAssertFalse(generated.allowsAddedEmoji)
XCTAssertTrue(generated.prompt.contains("短句"))
XCTAssertTrue(generated.prompt.contains("ASR preserve mode"))
XCTAssertTrue(generated.prompt.contains("AI reply active-transfer mode"))
XCTAssertTrue(client.requests[1].text.contains(#""status":"insufficient""#))
XCTAssertFalse(generated.allowsAddedEmoji)
}
func testNonemptyASRRepairsEmptyInsufficientEvidenceIntoCandidateTraits() async throws {
let client = StyleLearningCapturingClient(
responses: [
Self.insufficientEvidenceResponse,
Self.singleObservationInsufficientEvidenceResponse,
Self.insufficientGeneratedStyleResponse
]
)
let service = PolishStyleLearningService(store: store, client: client)
let generated = try await service.generateStyle(
from: Self.readyCorpus(),
outputLanguage: .chinese
)
XCTAssertEqual(client.requests.count, 3)
XCTAssertTrue(client.requests[1].prompt.contains("REPAIR ATTEMPT"))
XCTAssertTrue(generated.prompt.contains("短句"))
}
func testEvidenceSchemaRejectsFabricationAndProtocolOverrides() {
@@ -415,6 +474,40 @@ final class PolishStyleLearningServiceTests: XCTestCase {
)
}
func testInsufficientEvidenceMayKeepSupportedLowConfidenceObservations() throws {
let evidence = try PolishStyleLearningService.parseEvidence(
Self.lowConfidenceInsufficientEvidenceResponse
)
XCTAssertEqual(evidence.status, .insufficient)
XCTAssertEqual(evidence.asr.traits.first?.confidence, 0.2)
XCTAssertEqual(evidence.asr.evidence.first?.source, .asrRepeatedBefore)
XCTAssertTrue(evidence.reply.traits.isEmpty)
}
func testSingleRawASRObservationIsAcceptedAsLowConfidenceCandidate() throws {
let evidence = try PolishStyleLearningService.parseEvidence(
Self.singleObservationInsufficientEvidenceResponse
)
XCTAssertEqual(evidence.status, .insufficient)
XCTAssertEqual(evidence.asr.traits.first?.supportCount, 1)
XCTAssertEqual(evidence.asr.evidence.first?.source, .asrObservedBefore)
}
func testSufficientEvidenceRequiresMinimumOverallConfidence() {
let lowConfidence = Self.sufficientEvidenceResponse.replacingOccurrences(
of: #""confidence":0.86"#,
with: #""confidence":0.49"#
)
XCTAssertThrowsError(
try PolishStyleLearningService.parseEvidence(lowConfidence)
) { error in
XCTAssertEqual(error as? PolishStyleLearningError, .invalidResponse)
}
}
func testEvidenceSchemaEnforcesSourcePriorityAndSupportCounts() {
let weakCrossContext = Self.sufficientEvidenceResponse.replacingOccurrences(
of: #""source":"replyCrossContextSelection","summary":"","supportCount":2"#,
@@ -438,10 +531,10 @@ final class PolishStyleLearningServiceTests: XCTestCase {
)
}
func testGeneratedStyleRejectsTrailingProtocolContent() {
func testGeneratedStyleRejectsTrailingSecondJSONObject() {
XCTAssertThrowsError(
try PolishStyleLearningService.parseGeneratedStyle(
Self.generatedStyleResponse + "\nnot-json",
Self.generatedStyleResponse + "\n{}",
outputLanguage: .chinese
)
) { error in
@@ -449,6 +542,161 @@ final class PolishStyleLearningServiceTests: XCTestCase {
}
}
func testWrappedAndFencedJSONIsRecoveredWithoutRetry() async throws {
let client = StyleLearningCapturingClient(
responses: [
"\u{FEFF}Evidence follows:\n```json\n\(Self.sufficientEvidenceResponse)\n```\nDone.",
"```json\n\(Self.generatedStyleResponse)\n```"
]
)
let service = PolishStyleLearningService(store: store, client: client)
let generated = try await service.generateStyle(
from: Self.readyCorpus(),
outputLanguage: .chinese
)
XCTAssertEqual(generated.name, "我的说话风格")
XCTAssertEqual(client.requests.count, 2)
XCTAssertFalse(client.requests.contains { $0.prompt.contains("REPAIR ATTEMPT") })
}
func testInvalidEvidenceResponseRetriesOnceWithOriginalPayload() async throws {
let client = StyleLearningCapturingClient(
responses: [
"invalid evidence",
Self.sufficientEvidenceResponse,
Self.generatedStyleResponse
]
)
let service = PolishStyleLearningService(store: store, client: client)
_ = try await service.generateStyle(
from: Self.readyCorpus(),
outputLanguage: .chinese
)
XCTAssertEqual(client.requests.count, 3)
XCTAssertEqual(client.requests[0].text, client.requests[1].text)
XCTAssertTrue(client.requests[1].prompt.contains("REPAIR ATTEMPT"))
XCTAssertFalse(client.requests[2].prompt.contains("REPAIR ATTEMPT"))
}
func testInvalidSynthesisResponseRetriesOnceWithOriginalPayload() async throws {
let client = StyleLearningCapturingClient(
responses: [
Self.sufficientEvidenceResponse,
"invalid style",
Self.generatedStyleResponse
]
)
let service = PolishStyleLearningService(store: store, client: client)
_ = try await service.generateStyle(
from: Self.readyCorpus(),
outputLanguage: .chinese
)
XCTAssertEqual(client.requests.count, 3)
XCTAssertEqual(client.requests[1].text, client.requests[2].text)
XCTAssertTrue(client.requests[2].prompt.contains("REPAIR ATTEMPT"))
}
func testInvalidEvidenceResponseRetriesAtMostOnce() async {
let client = StyleLearningCapturingClient(
responses: ["invalid first response", "invalid repair response"]
)
let service = PolishStyleLearningService(store: store, client: client)
do {
_ = try await service.generateStyle(
from: Self.readyCorpus(),
outputLanguage: .chinese
)
XCTFail("Expected invalid response after one repair attempt")
} catch let error as PolishStyleLearningError {
XCTAssertEqual(error, .invalidResponse)
} catch {
XCTFail("Unexpected error: \(error)")
}
XCTAssertEqual(client.requests.count, 2)
XCTAssertTrue(client.requests[1].prompt.contains("REPAIR ATTEMPT"))
}
func testPromptTooLongSynthesisResponseIsNotRetried() async throws {
let oversizedPrompt = """
#
\(String(repeating: "", count: 6_000))
#
ASR preserve modeAI reply active-transfer mode
#
"""
let responseData = try JSONSerialization.data(withJSONObject: [
"name": "Too Long",
"prompt": oversizedPrompt,
"allowsAddedEmoji": false
])
let response = try XCTUnwrap(String(data: responseData, encoding: .utf8))
let client = StyleLearningCapturingClient(
responses: [Self.sufficientEvidenceResponse, response]
)
let service = PolishStyleLearningService(store: store, client: client)
do {
_ = try await service.generateStyle(
from: Self.readyCorpus(),
outputLanguage: .chinese
)
XCTFail("Expected prompt length rejection")
} catch let error as PolishStyleLearningError {
XCTAssertEqual(error, .promptTooLong(maximum: 6_000))
} catch {
XCTFail("Unexpected error: \(error)")
}
XCTAssertEqual(client.requests.count, 2)
}
func testFailureMessagesExposeSpecificActionableReasons() {
XCTAssertEqual(
PolishStyleLearningFailureMessage.localized(
for: PolishingService.PolishError.missingAPIKey,
language: .english
),
"The current AI service has no API key. Configure it in Settings and try again."
)
XCTAssertEqual(
PolishStyleLearningFailureMessage.localized(
for: LLMError.timeout,
language: .english
),
"The AI request timed out. Please try again."
)
XCTAssertEqual(
PolishStyleLearningFailureMessage.localized(
for: LLMError.http(status: 401),
language: .english
),
"API returned HTTP 401. Try again later or contact the provider."
)
XCTAssertEqual(
PolishStyleLearningFailureMessage.localized(
for: ManagedGatewayError.insufficientCredits,
language: .english
),
"Not enough credits. Open the Account tab in the main app to add credits."
)
XCTAssertEqual(
PolishStyleLearningFailureMessage.localized(
for: ManagedGatewayError.invalidGrant,
language: .chinese
),
"托管服务授权已失效,请打开主 App 重新连接账号。"
)
}
private static let sufficientEvidenceResponse = ##"""
{
"status":"sufficient",
@@ -486,6 +734,40 @@ final class PolishStyleLearningServiceTests: XCTestCase {
}
"""##
private static let lowConfidenceInsufficientEvidenceResponse = ##"""
{
"status":"insufficient",
"confidence":0.2,
"asr":{
"traits":[
{"name":"retention:短句倾向","description":"近似去重后仍观察到短句,但支持有限","confidence":0.2,"supportCount":2}
],
"evidence":[
{"source":"asrRepeatedBefore","summary":"两个不同场景的原声 before 使用短句","supportCount":2}
],
"contradictions":[]
},
"reply":{"traits":[],"evidence":[],"contradictions":[]}
}
"""##
private static let singleObservationInsufficientEvidenceResponse = ##"""
{
"status":"insufficient",
"confidence":0.18,
"asr":{
"traits":[
{"name":"retention:短句候选","description":"一次原始 ASR 观察显示用户倾向直接短句","confidence":0.18,"supportCount":1}
],
"evidence":[
{"source":"asrObservedBefore","summary":"原始 before 使用直接短句,样本仍少","supportCount":1}
],
"contradictions":[]
},
"reply":{"traits":[],"evidence":[],"contradictions":[]}
}
"""##
private static let generatedStyleResponse = ##"""
{
"name":"我的说话风格",
@@ -493,11 +775,44 @@ final class PolishStyleLearningServiceTests: XCTestCase {
"allowsAddedEmoji":true
}
"""##
private static let emptyCorpusGeneratedStyleResponse = ##"""
{
"name":"待补充语料",
"prompt":"# 角色\n当前没有可观察的个人语料,不声明个人表达特征。\n# 风格边界\nASR preserve mode:不推断未观察到的表达习惯。\nAI reply active-transfer mode:不迁移未经观察的回复偏好。\n# 示例\n输入:没有个人语料\n输出:等待用户提供语料。",
"allowsAddedEmoji":false
}
"""##
private static let insufficientGeneratedStyleResponse = ##"""
{
"name":"直接短句风格",
"prompt":"# 角色\n优先使用语料观察到的直接短句,先说结论,不扩写背景。\n# 风格边界\nASR preserve mode:保留短句节奏与直接表达;只在原文确有多个信息点时分句。\nAI reply active-transfer mode:当前没有回复偏好证据,不迁移未经支持的语气,但保持简短直接。\n# 示例\n输入:这个事情我觉得可以之后再确认一下\n输出:这个可以,之后再确认。",
"allowsAddedEmoji":false
}
"""##
private static func readyCorpus() -> PolishStyleLearningCorpus {
let source = String(repeating: "测试语料", count: 625)
return PolishStyleLearningCorpus(
examples: [
PolishStyleLearningExample(
prePolishText: source,
finalText: source + "",
polishStyleID: "builtin.chat",
createdAt: Date()
)
],
effectiveCharacterCount: 2_500
)
}
}
private struct StyleLearningCapturedRequest {
let text: String
let prompt: String
let timeout: TimeInterval?
let options: LLMGenerationOptions?
}
private final class StyleLearningCapturingClient: LLMClient, @unchecked Sendable {
@@ -518,9 +833,42 @@ private final class StyleLearningCapturingClient: LLMClient, @unchecked Sendable
_ text: String,
systemPrompt: String,
timeout: TimeInterval?
) async throws -> String {
try await nextResponse(
text: text,
prompt: systemPrompt,
timeout: timeout,
options: nil
)
}
func polish(
_ text: String,
systemPrompt: String,
timeout: TimeInterval?,
options: LLMGenerationOptions
) async throws -> String {
try await nextResponse(
text: text,
prompt: systemPrompt,
timeout: timeout,
options: options
)
}
private func nextResponse(
text: String,
prompt: String,
timeout: TimeInterval?,
options: LLMGenerationOptions?
) async throws -> String {
requests.append(
StyleLearningCapturedRequest(text: text, prompt: systemPrompt)
StyleLearningCapturedRequest(
text: text,
prompt: prompt,
timeout: timeout,
options: options
)
)
guard !responses.isEmpty else { return "{}" }
let index = min(responseIndex, responses.count - 1)