feat(styles): learn the personal reply style incrementally

Regeneration merges the on-device learned evidence with dictation and reply
choices since the last generation instead of re-reading only the newest
~2,500 characters. It unlocks after 500 new characters, fades traits that
stop getting support, and fully relearns styles without saved evidence.
This commit is contained in:
2026-10-20 20:05:35 +02:00
parent f71f598ac7
commit eaacc35cf7
11 changed files with 777 additions and 43 deletions
@@ -0,0 +1,103 @@
// PersonalStyleLearningStateStoreTests.swift
// OSGKeyboard · Tests
//
// Verifies the device-local prior only applies to the exact saved generation.
@testable import OSGKeyboardShared
import XCTest
@MainActor
final class PersonalStyleLearningStateStoreTests: XCTestCase {
private var suiteName: String!
private var defaults: UserDefaults!
private var store: PersonalStyleLearningStateStore!
override func setUp() {
super.setUp()
suiteName = "com.osgkeyboard.personal-style-state.\(UUID().uuidString)"
defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
store = PersonalStyleLearningStateStore(defaults: defaults)
}
override func tearDown() {
defaults.removePersistentDomain(forName: suiteName)
super.tearDown()
}
func testSavedPriorMatchesSamePackAndGeneration() {
let pack = Self.pack(id: "user.style", generatedAt: Date(timeIntervalSince1970: 100))
store.save(Self.prior, for: pack)
XCTAssertEqual(store.prior(for: pack), Self.prior)
}
func testPriorSurvivesManualEditThatKeepsMetadata() {
let pack = Self.pack(id: "user.style", generatedAt: Date(timeIntervalSince1970: 100))
store.save(Self.prior, for: pack)
var edited = pack
edited.prompt = "# 角色\n手动修改\n# 风格边界\n自然\n# 示例\n输入 → 输出"
XCTAssertEqual(store.prior(for: edited), Self.prior)
}
func testPriorIsIgnoredForAnotherGenerationOrPack() {
store.save(
Self.prior,
for: Self.pack(id: "user.style", generatedAt: Date(timeIntervalSince1970: 100))
)
XCTAssertNil(
store.prior(
for: Self.pack(id: "user.style", generatedAt: Date(timeIntervalSince1970: 500))
)
)
XCTAssertNil(
store.prior(
for: Self.pack(id: "user.other", generatedAt: Date(timeIntervalSince1970: 100))
)
)
XCTAssertNil(store.prior(for: PolishStylePack(id: "user.style", name: "Legacy", prompt: "p")))
}
func testClearRemovesPrior() {
let pack = Self.pack(id: "user.style", generatedAt: Date(timeIntervalSince1970: 100))
store.save(Self.prior, for: pack)
store.clear()
XCTAssertNil(store.prior(for: pack))
}
private static let prior = PolishStyleLearningPriorEvidence(
asr: .init(
traits: [
.init(name: "简短直接", description: "反复使用短句", confidence: 0.7, supportCount: 3)
],
contradictions: []
),
reply: .init(traits: [], contradictions: []),
learnedThrough: Date(timeIntervalSince1970: 90),
cumulativeASRExampleCount: 5,
cumulativeASREffectiveCharacterCount: 2_500,
cumulativeReplyExampleCount: 0,
cumulativeReplyFinalEditCount: 0
)
private static func pack(id: String, generatedAt: Date) -> PolishStylePack {
PolishStylePack(
id: id,
name: "我的说话风格",
prompt: "# 角色\n自然\n# 风格边界\n自然\n# 示例\n输入 → 输出",
learningMetadata: PolishStylePack.LearningMetadata(
schemaVersion: 4,
evidenceStatus: "sufficient",
confidence: 0.8,
asrExampleCount: 5,
asrEffectiveCharacterCount: 2_500,
replyExampleCount: 0,
replyFinalEditCount: 0,
generatedAt: generatedAt
)
)
}
}
@@ -288,7 +288,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
XCTAssertTrue(client.requests.allSatisfy { $0.options?.maxTokens == 4_096 })
let metadata = try XCTUnwrap(generated.learningMetadata)
XCTAssertEqual(metadata.schemaVersion, 3)
XCTAssertEqual(metadata.schemaVersion, 4)
XCTAssertEqual(metadata.evidenceStatus, "sufficient")
XCTAssertEqual(metadata.confidence, 0.86)
XCTAssertEqual(metadata.asrExampleCount, 1)
@@ -342,6 +342,191 @@ final class PolishStyleLearningServiceTests: XCTestCase {
XCTAssertFalse(client.requests[1].text.contains("newest-marker"))
}
// MARK: - Incremental learning
func testFullLearningReturnsPriorThroughNewestLearnedExample() async throws {
let newest = Date(timeIntervalSince1970: 3_000)
let corpus = PolishStyleLearningCorpus(
examples: [
PolishStyleLearningExample(
prePolishText: String(repeating: "测试语料", count: 625),
finalText: "完整语料",
polishStyleID: nil,
createdAt: newest
)
],
effectiveCharacterCount: 2_500
)
let client = StyleLearningCapturingClient(
responses: [Self.sufficientEvidenceResponse, Self.generatedStyleResponse]
)
let service = PolishStyleLearningService(store: store, client: client)
let result = try await service.learnStyle(from: corpus, outputLanguage: .chinese)
XCTAssertFalse(client.requests[0].text.contains(#""prior":"#))
let prior = try XCTUnwrap(result.prior)
XCTAssertEqual(prior.learnedThrough, newest)
XCTAssertEqual(prior.asr.traits.map(\.name), ["简短直接"])
XCTAssertEqual(prior.reply.traits.map(\.name), ["轻松回复"])
XCTAssertEqual(prior.cumulativeASREffectiveCharacterCount, 2_500)
}
func testIncrementalLearningSendsOnlyNewExamplesWithDecayedPrior() async throws {
let learnedThrough = Date(timeIntervalSince1970: 2_000)
let newest = Date(timeIntervalSince1970: 3_000)
let corpus = PolishStyleLearningCorpus(
examples: [
PolishStyleLearningExample(
prePolishText: String(repeating: "旧", count: 3_000),
finalText: "old-marker",
polishStyleID: nil,
createdAt: Date(timeIntervalSince1970: 1_000)
),
PolishStyleLearningExample(
prePolishText: String(repeating: "新", count: 600),
finalText: "new-marker",
polishStyleID: nil,
createdAt: newest
)
],
effectiveCharacterCount: 3_600
)
let replyExamples = [
PolishStyleReplyLearningExample(
receivedMessage: "old-reply-marker",
ordinaryCandidate: "普通候选",
selection: .ordinary,
createdAt: Date(timeIntervalSince1970: 1_500)
),
PolishStyleReplyLearningExample(
receivedMessage: "new-reply-marker",
ordinaryCandidate: "普通候选",
selection: .ordinary,
finalEdit: "用户改稿",
createdAt: Date(timeIntervalSince1970: 2_500)
)
]
let client = StyleLearningCapturingClient(
responses: [Self.incrementalEvidenceResponse, Self.generatedStyleResponse]
)
let service = PolishStyleLearningService(store: store, client: client)
let result = try await service.learnStyle(
from: corpus,
replyExamples: replyExamples,
outputLanguage: .chinese,
prior: Self.prior(learnedThrough: learnedThrough)
)
let extractor = client.requests[0]
XCTAssertTrue(extractor.prompt.contains("INCREMENTAL MERGE"))
XCTAssertTrue(extractor.text.contains(#""prior":"#))
XCTAssertTrue(extractor.text.contains("new-marker"))
XCTAssertFalse(extractor.text.contains("old-marker"))
XCTAssertTrue(extractor.text.contains("new-reply-marker"))
XCTAssertFalse(extractor.text.contains("old-reply-marker"))
// 0.5 decays to 0.4; the 0.15 trait falls below the floor and is dropped.
XCTAssertTrue(extractor.text.contains(#""confidence":0.4"#))
XCTAssertTrue(extractor.text.contains("早期简短直接"))
XCTAssertFalse(extractor.text.contains("即将淘汰"))
let metadata = try XCTUnwrap(result.pack.learningMetadata)
XCTAssertEqual(metadata.asrEffectiveCharacterCount, 2_500 + 600)
XCTAssertEqual(metadata.asrExampleCount, 10 + 1)
XCTAssertEqual(metadata.replyExampleCount, 4 + 1)
XCTAssertEqual(metadata.replyFinalEditCount, 2 + 1)
let nextPrior = try XCTUnwrap(result.prior)
XCTAssertEqual(nextPrior.learnedThrough, newest)
XCTAssertEqual(nextPrior.cumulativeASREffectiveCharacterCount, 3_100)
}
func testIncrementalLearningRequiresFiveHundredNewCharacters() async {
let corpus = PolishStyleLearningCorpus(
examples: [
PolishStyleLearningExample(
prePolishText: String(repeating: "旧", count: 3_000),
finalText: "旧",
polishStyleID: nil,
createdAt: Date(timeIntervalSince1970: 1_000)
),
PolishStyleLearningExample(
prePolishText: String(repeating: "新", count: 100),
finalText: "新",
polishStyleID: nil,
createdAt: Date(timeIntervalSince1970: 3_000)
)
],
effectiveCharacterCount: 3_100
)
let client = StyleLearningCapturingClient(response: "{}")
let service = PolishStyleLearningService(store: store, client: client)
do {
_ = try await service.learnStyle(
from: corpus,
outputLanguage: .chinese,
prior: Self.prior(learnedThrough: Date(timeIntervalSince1970: 2_000))
)
XCTFail("Expected the incremental gate to count only new characters")
} catch let error as PolishStyleLearningError {
XCTAssertEqual(error, .insufficientCorpus(required: 500, actual: 100))
} catch {
XCTFail("Unexpected error: \(error)")
}
XCTAssertTrue(client.requests.isEmpty)
}
func testIncrementalEvidenceAcceptsPriorProfileSource() throws {
let evidence = try PolishStyleLearningService.parseEvidence(
Self.incrementalEvidenceResponse
)
XCTAssertEqual(evidence.asr.evidence.first?.source, .priorProfile)
XCTAssertEqual(evidence.reply.evidence.first?.source, .priorProfile)
}
func testCompactPriorKeepsStrongestTraitsWithinBudget() {
let traits = (0..<12).map { index in
PolishStyleLearningEvidence.Trait(
name: "trait-\(index)",
// 500 characters each, so eight traits overflow the budget.
description: String(repeating: "描述", count: 250),
confidence: 0.2 + Double(index) * 0.05,
supportCount: index + 1
)
}
let evidence = PolishStyleLearningEvidence(
status: .sufficient,
confidence: 0.8,
asr: .init(traits: traits, evidence: [], contradictions: []),
reply: .init(traits: [], evidence: [], contradictions: [])
)
let prior = PolishStyleLearningService.compactPrior(
from: evidence,
learnedThrough: Date(),
cumulativeASRExampleCount: 1,
cumulativeASREffectiveCharacterCount: 1,
cumulativeReplyExampleCount: 0,
cumulativeReplyFinalEditCount: 0
)
XCTAssertLessThan(
prior.asr.traits.count,
PolishStyleLearningService.maximumPriorTraitsPerDomain
)
XCTAssertLessThanOrEqual(
PolishStyleLearningService.priorCharacterCount(prior),
PolishStyleLearningService.maximumPriorCharacters
)
XCTAssertEqual(prior.asr.traits.first?.name, "trait-11")
XCTAssertEqual(
prior.asr.traits.map(\.confidence),
prior.asr.traits.map(\.confidence).sorted(by: >)
)
}
func testServiceRecomputesReadinessInsteadOfTrustingCallerCount() async {
let corpus = PolishStyleLearningCorpus(
examples: [
@@ -761,6 +946,60 @@ final class PolishStyleLearningServiceTests: XCTestCase {
}
"""##
private static let incrementalEvidenceResponse = ##"""
{
"status":"sufficient",
"confidence":0.8,
"asr":{
"traits":[
{"name":"早期简短直接","description":"早期画像与新样本都显示简短直接","confidence":0.6,"supportCount":5}
],
"evidence":[
{"source":"priorProfile","summary":"早期画像保留简短直接","supportCount":4},
{"source":"asrObservedBefore","summary":"新样本继续使用短句","supportCount":1}
],
"contradictions":[]
},
"reply":{
"traits":[
{"name":"轻松回复","description":"早期画像中的轻松回复偏好","confidence":0.5,"supportCount":2}
],
"evidence":[
{"source":"priorProfile","summary":"早期画像保留轻松回复","supportCount":2}
],
"contradictions":[]
}
}
"""##
private static func prior(learnedThrough: Date) -> PolishStyleLearningPriorEvidence {
PolishStyleLearningPriorEvidence(
asr: .init(
traits: [
.init(
name: "早期简短直接",
description: "早期语料反复出现短句",
confidence: 0.5,
supportCount: 4
),
.init(
name: "即将淘汰",
description: "多轮没有新支持的旧特征",
confidence: 0.15,
supportCount: 1
)
],
contradictions: []
),
reply: .init(traits: [], contradictions: []),
learnedThrough: learnedThrough,
cumulativeASRExampleCount: 10,
cumulativeASREffectiveCharacterCount: 2_500,
cumulativeReplyExampleCount: 4,
cumulativeReplyFinalEditCount: 2
)
}
private static let insufficientEvidenceResponse = ##"""
{
"status":"insufficient",