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
+1
View File
@@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Managed ASR connect crash**: `URLSessionWebSocketTask.sendPing` can invoke its completion twice when the task is cancelled mid-ping, so opening a managed Volcengine session could trap on the second continuation resume (SIGTRAP). The handler now passes through a once-only gate. / **托管语音识别建连崩溃**:`URLSessionWebSocketTask.sendPing` 在 ping 在途时被取消会回调两次,打开托管火山引擎会话时第二次恢复 continuation 会触发 SIGTRAP 断言。回调现经一次性闸门过滤。
- **Auto-reply storms from Universal Clipboard**: copying on a nearby Mac re-announces one copy under several pasteboard changeCounts, and each re-announcement re-armed auto reply/translate and the auto-reply guide — including on every later keyboard open, because re-ingesting the same text kept refreshing its 30-second hint window. A capture whose text is identical to the newest history entry is now treated as the same logical copy (bookkeeping only, no re-analysis), and auto actions additionally dedupe on a content fingerprint within a 30-second window. The same text fires once; copying something else and then re-copying the text still triggers. / **跨端剪贴板引发自动回复风暴**:在附近 Mac 上复制一次会被系统以多个 changeCount 重复宣告,且每次宣告都会重新武装自动回复/翻译与自动回复引导——由于同文本被重复入库并不断刷新 30 秒提示窗口,之后每次打开键盘也会再触发。现在,与最新历史条目文本相同的采集会被视为同一次复制(仅更新记账,不重新分析),自动动作再叠加内容指纹 + 30 秒窗口去重。同一文本只触发一次;复制其他内容后再重新复制该文本仍可触发。
- **Style card captions**: resolve each built-in style’s short description through the in-app language override, so switching App Language updates the caption instead of showing a localization key or the system language. / **风格卡片说明**:内置风格的说明小字改为走应用内语言设置;切换 App 语言后说明会跟着变,而不再显示本地化 key 或跟随系统语言。
- **Personal reply style keeps learning**: regenerating used only the newest ~2,500 characters, so new dictation pushed earlier learning out instead of adding to it. Regeneration is now incremental: it merges what the style already learned (kept on this device only) with dictation and reply choices since the last generation, unlocks after 500 new characters, and the card shows the cumulative character count plus progress since the last generation. Traits that stop getting new support fade out over a few rounds. A style without saved learning (older builds or another device) is relearned in full and replaced. / **个人回复风格持续学习**:此前重新生成只使用最新约 2,500 字,新听写会把早期学习挤掉而非叠加。现在改为增量重新生成:把该风格已学到的特征(仅保存在本机)与上次生成后的新听写和回复选择合并,新增满 500 字即可重新生成;卡片显示累计学习字数与自上次生成后的新增进度。长期没有新证据支持的特征会在几轮后自然淘汰。没有本机学习记录的风格(旧版本或其他设备生成)会全量重新学习并替换。
## [2.2.0] - 2026-09-18
@@ -28,16 +28,24 @@ struct PersonalReplyStyleSection: View {
@State private var generationTask: Task<Void, Never>?
@State private var generationID: UUID?
@State private var editingPack: PolishStylePack?
/// Evidence from the generation under review; persisted only on save.
@State private var pendingLearning: PendingLearning?
@State private var errorAlert: PersonalReplyStyleErrorAlert?
private struct PendingLearning {
let packID: String
let prior: PolishStyleLearningPriorEvidence
}
private let store = AppGroupStore()
private let learningState = PersonalStyleLearningStateStore.shared
private let generator: LearnedStyleGenerator
init(
initialEditingPack: PolishStylePack? = nil,
generator: @escaping LearnedStyleGenerator = { corpus, replyExamples, language in
generator: @escaping LearnedStyleGenerator = { corpus, replyExamples, language, prior in
try await PolishStyleLearningService(store: AppGroupStore())
.generateStyle(
.learnStyle(
from: corpus,
replyExamples: replyExamples,
outputLanguage: language,
@@ -46,7 +54,8 @@ struct PersonalReplyStyleSection: View {
? PolishStyleLearningCorpusBuilder
.testBuildEffectiveCharacterCount
: PolishStyleLearningCorpusBuilder
.requiredEffectiveCharacterCount
.requiredEffectiveCharacterCount,
prior: prior
)
}
) {
@@ -64,7 +73,7 @@ struct PersonalReplyStyleSection: View {
}
}
}
.sheet(item: $editingPack) { pack in
.sheet(item: $editingPack, onDismiss: { pendingLearning = nil }) { pack in
PolishStyleEditorSheet(
pack: pack,
isNew: !catalog.entries.contains(where: { $0.id == pack.id })
@@ -107,8 +116,17 @@ struct PersonalReplyStyleSection: View {
: PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
}
private func isEligible(_ corpus: PolishStyleLearningCorpus) -> Bool {
corpus.effectiveCharacterCount >= minimumCharacterCount
/// Without a prior the full corpus gate applies; with one, only the
/// characters dictated since the last saved generation count.
private func isEligible(
_ corpus: PolishStyleLearningCorpus,
prior: PolishStyleLearningPriorEvidence? = nil
) -> Bool {
guard let prior else {
return corpus.effectiveCharacterCount >= minimumCharacterCount
}
return corpus.examples(after: prior.learnedThrough).effectiveCharacterCount
>= PolishStyleLearningCorpusBuilder.incrementalEffectiveCharacterCount
}
private var learnedPack: PolishStylePack? {
@@ -121,7 +139,8 @@ struct PersonalReplyStyleSection: View {
_ pack: PolishStylePack,
corpus: PolishStyleLearningCorpus
) -> some View {
let canRegenerate = isEligible(corpus) && !isGenerating
let prior = learningState.prior(for: pack)
let canRegenerate = isEligible(corpus, prior: prior) && !isGenerating
let actionShape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
return VStack(alignment: .leading, spacing: Spacing.md) {
@@ -181,6 +200,22 @@ struct PersonalReplyStyleSection: View {
Self.confidencePercentage(metadata.confidence)
)
)
if let prior {
Text(
AppL10n.format(
"personalReplyStyle.newSinceLast",
Int64(
corpus.examples(after: prior.learnedThrough)
.effectiveCharacterCount
),
Int64(
PolishStyleLearningCorpusBuilder
.incrementalEffectiveCharacterCount
)
)
)
.accessibilityIdentifier("personalReplyStyle.newSinceLast")
}
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
@@ -328,24 +363,31 @@ struct PersonalReplyStyleSection: View {
from corpus: PolishStyleLearningCorpus,
replacing existingPack: PolishStylePack? = nil
) {
guard isEligible(corpus), !isGenerating, generationTask == nil else { return }
let prior = existingPack.flatMap(learningState.prior(for:))
guard isEligible(corpus, prior: prior),
!isGenerating,
generationTask == nil else { return }
let id = UUID()
generationID = id
isGenerating = true
generationTask = Task { @MainActor in
defer { finishGeneration(id: id) }
do {
let generated = try await generator(
let result = try await generator(
corpus,
ClipboardReplyFeedbackStore.shared.learningExamples(),
config.uiLanguage
config.uiLanguage,
prior
)
try Task.checkCancellation()
guard generationID == id, editingPack == nil else { return }
let generated = result.pack
// Always let the user inspect and edit the learned prompt before
// it is saved, synced, or enabled.
// it is saved, synced, or enabled. Saving overwrites the existing
// style, including any manual edits to it.
let reviewPack: PolishStylePack
if let existingPack {
editingPack = PolishStylePack(
reviewPack = PolishStylePack(
id: existingPack.id,
name: generated.name,
prompt: generated.prompt,
@@ -356,8 +398,12 @@ struct PersonalReplyStyleSection: View {
updatedAt: Date()
)
} else {
editingPack = generated
reviewPack = generated
}
pendingLearning = result.prior.map {
PendingLearning(packID: reviewPack.id, prior: $0)
}
editingPack = reviewPack
} catch {
guard generationID == id,
!Task.isCancelled,
@@ -395,6 +441,10 @@ struct PersonalReplyStyleSection: View {
store.setPolishStyleCatalog(catalog)
store.setPersonalReplyStyleId(pack.id)
enabledStyleID = store.personalReplyStyleId
if let pendingLearning, pendingLearning.packID == pack.id {
learningState.save(pendingLearning.prior, for: pack)
self.pendingLearning = nil
}
pushSync()
return true
} catch {
+5 -2
View File
@@ -7,11 +7,14 @@
import OSGKeyboardShared
import SwiftUI
/// The prior is nil for a first or full relearn; with one, the generator
/// learns incrementally from examples after `learnedThrough`.
typealias LearnedStyleGenerator = @MainActor @Sendable (
PolishStyleLearningCorpus,
[PolishStyleReplyLearningExample],
AppUILanguage
) async throws -> PolishStylePack
AppUILanguage,
PolishStyleLearningPriorEvidence?
) async throws -> PolishStyleLearningResult
private struct PolishStyleErrorAlert {
let title: String
@@ -81,10 +81,13 @@ struct PolishStylesScreenshotHarness: View {
)
} else if simulatesGeneration {
PersonalReplyStyleHost(
generator: { _, _, language in
generator: { _, _, language, _ in
try await Task.sleep(for: .seconds(1.8))
return ReleaseNotesScreenshotFixture.generatedStyle(
return PolishStyleLearningResult(
pack: ReleaseNotesScreenshotFixture.generatedStyle(
language: language
),
prior: nil
)
}
)
@@ -115,7 +118,7 @@ private struct PolishStylesServiceUITestHarness: View {
ZStack(alignment: .topTrailing) {
if showsStyles {
PersonalReplyStyleHost(
generator: { corpus, replyExamples, language in
generator: { corpus, replyExamples, language, prior in
let client = PolishStylesUITestScriptedLLMClient(
responses: ReleaseNotesScreenshotFixture.serviceResponses(
language: language,
@@ -129,7 +132,7 @@ private struct PolishStylesServiceUITestHarness: View {
store: AppGroupStore(),
client: client
)
.generateStyle(
.learnStyle(
from: corpus,
replyExamples: replyExamples,
outputLanguage: language,
@@ -138,7 +141,8 @@ private struct PolishStylesServiceUITestHarness: View {
? PolishStyleLearningCorpusBuilder
.testBuildEffectiveCharacterCount
: PolishStyleLearningCorpusBuilder
.requiredEffectiveCharacterCount
.requiredEffectiveCharacterCount,
prior: prior
)
}
)
@@ -456,6 +460,7 @@ private enum ReleaseNotesScreenshotFixture {
store.setPolishStyleCatalog(PolishStyleCatalog())
store.setActivePolishStyleId(PolishStylePackCatalog.defaultID)
store.setPersonalReplyStyleId("")
PersonalStyleLearningStateStore.shared.clear()
}
/// Seeds varied per-day dictation totals across the current month so the
+1
View File
@@ -715,6 +715,7 @@
"personalReplyStyle.generatedAt" = "Generated";
"personalReplyStyle.evidenceSummary" = "%lld ASR characters · %lld reply choices · %lld final edits";
"personalReplyStyle.confidence" = "Confidence: %lld%%";
"personalReplyStyle.newSinceLast" = "New since last generation: %lld / %lld characters";
"personalReplyStyle.regenerate" = "Regenerate";
"personalReplyStyle.regenerating" = "Regenerating…";
"personalReplyStyle.privacy" = "Sent to your configured AI only when you generate. Review and edit before saving.";
@@ -715,6 +715,7 @@
"personalReplyStyle.generatedAt" = "生成于";
"personalReplyStyle.evidenceSummary" = "%lld 个 ASR 字符 · %lld 次回复选择 · %lld 次最终改稿";
"personalReplyStyle.confidence" = "置信度:%lld%%";
"personalReplyStyle.newSinceLast" = "自上次生成后新增 %lld / %lld 字";
"personalReplyStyle.regenerate" = "重新生成";
"personalReplyStyle.regenerating" = "重新生成中…";
"personalReplyStyle.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。";
@@ -0,0 +1,96 @@
// PersonalStyleLearningState.swift
// OSGKeyboard · Shared
//
// Device-local memory that lets a personal style be relearned incrementally:
// the compacted evidence of the last saved generation and the corpus it has
// already absorbed. Dictation history keeps only a rolling window, so this is
// the only place older learning survives. It is never synced — a device
// without a matching record simply relearns from scratch.
import Foundation
/// Compacted stage-one evidence carried into the next generation.
public struct PolishStyleLearningPriorEvidence: Codable, Equatable, Sendable {
public struct Domain: Codable, Equatable, Sendable {
public let traits: [PolishStyleLearningEvidence.Trait]
public let contradictions: [PolishStyleLearningEvidence.Contradiction]
}
public let asr: Domain
public let reply: Domain
/// Newest learned example timestamp. Only later examples count as new.
public let learnedThrough: Date
public let cumulativeASRExampleCount: Int
public let cumulativeASREffectiveCharacterCount: Int
public let cumulativeReplyExampleCount: Int
public let cumulativeReplyFinalEditCount: Int
}
/// A reviewed pack plus the evidence to persist once the user saves it.
public struct PolishStyleLearningResult: Sendable {
public let pack: PolishStylePack
/// Nil when the generator produced no reusable evidence (fixtures).
public let prior: PolishStyleLearningPriorEvidence?
public init(pack: PolishStylePack, prior: PolishStyleLearningPriorEvidence?) {
self.pack = pack
self.prior = prior
}
}
@MainActor
public final class PersonalStyleLearningStateStore {
public static let shared = PersonalStyleLearningStateStore()
private struct Record: Codable {
let packID: String
let generatedAt: Date
let prior: PolishStyleLearningPriorEvidence
}
private static let storageKey = "personalStyle.learningState.v1"
/// Cloud-merged metadata may round dates, so match within one second.
private static let generatedAtTolerance: TimeInterval = 1
private let defaults: UserDefaults?
public init(defaults: UserDefaults? = AppGroup.defaultsIfAvailable) {
self.defaults = defaults
}
/// Returns the prior only when it belongs to exactly this saved
/// generation. A pack replaced elsewhere (another device, an older build)
/// has a different `generatedAt` and falls back to full relearning.
public func prior(for pack: PolishStylePack) -> PolishStyleLearningPriorEvidence? {
guard let generatedAt = pack.learningMetadata?.generatedAt,
let record = load(),
record.packID == pack.id,
abs(record.generatedAt.timeIntervalSince(generatedAt))
<= Self.generatedAtTolerance else {
return nil
}
return record.prior
}
/// Only one personal style is learned at a time, so a save replaces any
/// earlier record.
public func save(_ prior: PolishStyleLearningPriorEvidence, for pack: PolishStylePack) {
guard let defaults,
let generatedAt = pack.learningMetadata?.generatedAt,
let data = try? JSONEncoder().encode(
Record(packID: pack.id, generatedAt: generatedAt, prior: prior)
) else {
return
}
defaults.set(data, forKey: Self.storageKey)
}
public func clear() {
defaults?.removeObject(forKey: Self.storageKey)
}
private func load() -> Record? {
guard let data = defaults?.data(forKey: Self.storageKey) else { return nil }
return try? JSONDecoder().decode(Record.self, from: data)
}
}
@@ -91,6 +91,9 @@ public struct PolishStyleLearningEvidence: Codable, Equatable, Sendable {
case replyFinalEdit
case replyCrossContextSelection
case replyAcceptance
/// A trait retained from the previous generation's accumulated
/// evidence, which is no longer present as raw examples.
case priorProfile
}
public struct Trait: Codable, Equatable, Sendable {
@@ -147,6 +150,20 @@ public struct PolishStyleLearningCorpus: Equatable, Sendable {
effectiveCharacterCount
>= PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
}
/// Examples recorded after `date`, i.e. not yet absorbed by a prior
/// generation.
public func examples(after date: Date) -> PolishStyleLearningCorpus {
let newer = examples.filter { $0.createdAt > date }
return PolishStyleLearningCorpus(
examples: newer,
effectiveCharacterCount: newer.reduce(into: 0) { count, example in
count += PolishStyleLearningCorpusBuilder.effectiveCharacterCount(
in: example.prePolishText
)
}
)
}
}
public enum PolishStyleLearningCorpusBuilder {
@@ -160,6 +177,10 @@ public enum PolishStyleLearningCorpusBuilder {
/// still a real gate; the previous "0 / unlimited" bypass is gone.
public static let testBuildEffectiveCharacterCount = 1_250
/// New effective characters required since the last saved generation
/// before an incremental relearn is worth a request.
public static let incrementalEffectiveCharacterCount = 500
/// Upper bound on effective characters included in a single
/// training-corpus export. Even when the user has accumulated
/// significantly more history than the unlock threshold, the
@@ -459,8 +480,16 @@ public actor PolishStyleLearningService {
let examples: [ReplyExamplePayload]
}
private struct PriorInput: Codable {
let learnedThrough: Date
let asr: PolishStyleLearningPriorEvidence.Domain
let reply: PolishStyleLearningPriorEvidence.Domain
}
private struct EvidenceRequestPayload: Codable {
let schemaVersion: Int
/// Omitted from the JSON entirely on a full (non-incremental) run.
let prior: PriorInput?
let asr: ASRInput
let reply: ReplyInput
}
@@ -488,7 +517,16 @@ public actor PolishStyleLearningService {
private static let maximumEvidenceItemsPerDomain = 24
private static let maximumContradictionsPerDomain = 12
private static let maximumEvidenceFieldCharacters = 320
private static let learningSchemaVersion = 3
private static let learningSchemaVersion = 4
/// Each relearn without fresh support shrinks a prior trait; below the
/// floor it is no longer sent, so unsupported traits fade out in a few
/// rounds without per-trait bookkeeping.
static let priorConfidenceDecay = 0.8
static let priorRetentionFloor = 0.15
static let maximumPriorTraitsPerDomain = 8
static let maximumPriorContradictionsPerDomain = 6
/// Keeps the prior well inside the 30,000-character request budget.
static let maximumPriorCharacters = 4_000
private static let generationOptions = LLMGenerationOptions(
temperature: 0.1,
topP: 0.9,
@@ -513,7 +551,33 @@ public actor PolishStyleLearningService {
minimumEffectiveCharacterCount: Int =
PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
) async throws -> PolishStylePack {
let requiredCharacterCount = max(0, minimumEffectiveCharacterCount)
try await learnStyle(
from: corpus,
replyExamples: replyExamples,
outputLanguage: outputLanguage,
minimumEffectiveCharacterCount: minimumEffectiveCharacterCount
).pack
}
/// With a `prior`, learns incrementally: only examples recorded after
/// `prior.learnedThrough` are sent, merged with the decayed prior, and
/// the incremental gate replaces `minimumEffectiveCharacterCount`.
public func learnStyle(
from fullCorpus: PolishStyleLearningCorpus,
replyExamples allReplyExamples: [PolishStyleReplyLearningExample] = [],
outputLanguage: AppUILanguage,
minimumEffectiveCharacterCount: Int =
PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount,
prior: PolishStyleLearningPriorEvidence? = nil
) async throws -> PolishStyleLearningResult {
let corpus = prior.map { fullCorpus.examples(after: $0.learnedThrough) }
?? fullCorpus
let replyExamples = prior.map { prior in
allReplyExamples.filter { $0.createdAt > prior.learnedThrough }
} ?? allReplyExamples
let requiredCharacterCount = prior == nil
? max(0, minimumEffectiveCharacterCount)
: PolishStyleLearningCorpusBuilder.incrementalEffectiveCharacterCount
let verifiedCharacterCount = corpus.examples.reduce(into: 0) { count, example in
count += PolishStyleLearningCorpusBuilder.effectiveCharacterCount(
in: example.prePolishText
@@ -540,7 +604,8 @@ public actor PolishStyleLearningService {
replyExamples: selectedReplyExamples,
activeStyleID: configuration.activePolishStyleId,
catalog: configuration.polishStyleCatalog,
outputLanguage: outputLanguage
outputLanguage: outputLanguage,
prior: prior.map(Self.decayedPrior)
)
let service = PolishingService(
store: configuration,
@@ -554,21 +619,30 @@ public actor PolishStyleLearningService {
requiresBestEffortASRCandidate: !selectedASRExamples.isEmpty,
notifiesManagedCredits: notifiesManagedCredits
)
// Counts are cumulative across incremental generations so the card
// reflects everything this style has learned, not just this round.
let asrExampleCount = (prior?.cumulativeASRExampleCount ?? 0)
+ selectedASRExamples.count
let asrEffectiveCharacterCount = (prior?.cumulativeASREffectiveCharacterCount ?? 0)
+ selectedASRExamples.reduce(into: 0) { count, example in
count += PolishStyleLearningCorpusBuilder.effectiveCharacterCount(
in: example.prePolishText
)
}
let replyExampleCount = (prior?.cumulativeReplyExampleCount ?? 0)
+ selectedReplyExamples.count
let replyFinalEditCount = (prior?.cumulativeReplyFinalEditCount ?? 0)
+ selectedReplyExamples.filter { Self.normalized($0.finalEdit) != nil }.count
let generatedAt = Date()
let metadata = PolishStylePack.LearningMetadata(
schemaVersion: Self.learningSchemaVersion,
evidenceStatus: evidence.status.rawValue,
confidence: evidence.confidence,
asrExampleCount: selectedASRExamples.count,
asrEffectiveCharacterCount: selectedASRExamples.reduce(into: 0) { count, example in
count += PolishStyleLearningCorpusBuilder.effectiveCharacterCount(
in: example.prePolishText
)
},
replyExampleCount: selectedReplyExamples.count,
replyFinalEditCount: selectedReplyExamples.filter {
Self.normalized($0.finalEdit) != nil
}.count,
generatedAt: Date()
asrExampleCount: asrExampleCount,
asrEffectiveCharacterCount: asrEffectiveCharacterCount,
replyExampleCount: replyExampleCount,
replyFinalEditCount: replyFinalEditCount,
generatedAt: generatedAt
)
// Always synthesize from this operation's evidence. Low-confidence
// profiles use their strongest candidate traits; only genuinely empty
@@ -577,13 +651,30 @@ public actor PolishStyleLearningService {
evidence: evidence,
metadata: metadata
)
return try await synthesizeStyle(
let pack = try await synthesizeStyle(
payload: synthesisPayload,
service: service,
learningMetadata: metadata,
outputLanguage: outputLanguage,
notifiesManagedCredits: notifiesManagedCredits
)
let newestLearnedExample = (
selectedASRExamples.map(\.createdAt) + selectedReplyExamples.map(\.createdAt)
).max()
let learnedThrough = [newestLearnedExample, prior?.learnedThrough]
.compactMap { $0 }
.max() ?? generatedAt
return PolishStyleLearningResult(
pack: pack,
prior: Self.compactPrior(
from: evidence,
learnedThrough: learnedThrough,
cumulativeASRExampleCount: asrExampleCount,
cumulativeASREffectiveCharacterCount: asrEffectiveCharacterCount,
cumulativeReplyExampleCount: replyExampleCount,
cumulativeReplyFinalEditCount: replyFinalEditCount
)
)
}
private func extractEvidence(
@@ -687,7 +778,8 @@ public actor PolishStyleLearningService {
replyExamples: [PolishStyleReplyLearningExample],
activeStyleID: String,
catalog: PolishStyleCatalog,
outputLanguage: AppUILanguage
outputLanguage: AppUILanguage,
prior: PolishStyleLearningPriorEvidence? = nil
) throws -> String {
let activeStyle = PolishStylePackCatalog.resolve(
id: activeStyleID,
@@ -706,6 +798,9 @@ public actor PolishStyleLearningService {
)
let payload = EvidenceRequestPayload(
schemaVersion: learningSchemaVersion,
prior: prior.map {
PriorInput(learnedThrough: $0.learnedThrough, asr: $0.asr, reply: $0.reply)
},
asr: ASRInput(
residualBaseline: reference(
for: baselineStyle,
@@ -896,9 +991,33 @@ public actor PolishStyleLearningService {
The ASR domain may be empty only when asr.examples itself is empty.
- Set status to "sufficient" only when total confidence is at least 0.5.
INCREMENTAL MERGE (only when the payload contains prior):
- prior is this user's previously learned profile, already decayed for
age. It summarizes older evidence that is no longer in the payload.
Unlike the contamination style prompts, it is evidence about the user.
asr.examples and reply.examples contain only newer observations.
- Return one merged profile covering prior and the newer examples.
- Keep every prior trait the newer examples do not contradict, at its
given confidence or lower. Never raise a prior trait without new
support.
- When newer examples support a prior trait, keep its name, add the new
support to its supportCount, and you may raise its confidence.
- When newer examples contradict a prior trait, lower its confidence and
add a contradictions entry; drop it only when the contradiction is
clear and repeated. Keep prior contradictions unless resolved.
- Cite retained prior traits with source priorProfile and the prior
supportCount. priorProfile ranks with asrRepeatedBefore and
replyCrossContextSelection: after user edits, before single
observations.
- Keep prior ASR and reply traits in their own domains.
- When over the trait limit, drop the lowest-confidence prior traits.
- status and confidence describe the merged profile. "insufficient"
requires overall and every trait confidence at most 0.35;
"sufficient" requires overall confidence at least 0.5.
Allowed source values:
asrUserEdit, asrRepeatedBefore, asrObservedBefore, replyFinalEdit,
replyCrossContextSelection, replyAcceptance.
replyCrossContextSelection, replyAcceptance, priorProfile.
Return this exact Codable shape:
{
@@ -1096,6 +1215,118 @@ public actor PolishStyleLearningService {
return references
}
// MARK: - Incremental prior
/// Ages the prior before it is sent: every trait loses confidence, and
/// traits that fall below the retention floor are dropped. Decay is local
/// and deterministic rather than delegated to the model.
static func decayedPrior(
_ prior: PolishStyleLearningPriorEvidence
) -> PolishStyleLearningPriorEvidence {
func decayed(
_ domain: PolishStyleLearningPriorEvidence.Domain
) -> PolishStyleLearningPriorEvidence.Domain {
PolishStyleLearningPriorEvidence.Domain(
traits: domain.traits.compactMap { trait in
let confidence = trait.confidence * priorConfidenceDecay
guard confidence >= priorRetentionFloor else { return nil }
return PolishStyleLearningEvidence.Trait(
name: trait.name,
description: trait.description,
confidence: confidence,
supportCount: trait.supportCount
)
},
contradictions: domain.contradictions
)
}
return PolishStyleLearningPriorEvidence(
asr: decayed(prior.asr),
reply: decayed(prior.reply),
learnedThrough: prior.learnedThrough,
cumulativeASRExampleCount: prior.cumulativeASRExampleCount,
cumulativeASREffectiveCharacterCount: prior.cumulativeASREffectiveCharacterCount,
cumulativeReplyExampleCount: prior.cumulativeReplyExampleCount,
cumulativeReplyFinalEditCount: prior.cumulativeReplyFinalEditCount
)
}
/// Reduces validated evidence to what the next relearn needs: the
/// strongest traits and a few contradictions per domain, within
/// `maximumPriorCharacters`. Evidence items are not kept; the next run
/// cites retained traits as `priorProfile`.
static func compactPrior(
from evidence: PolishStyleLearningEvidence,
learnedThrough: Date,
cumulativeASRExampleCount: Int,
cumulativeASREffectiveCharacterCount: Int,
cumulativeReplyExampleCount: Int,
cumulativeReplyFinalEditCount: Int
) -> PolishStyleLearningPriorEvidence {
func strongest(
_ traits: [PolishStyleLearningEvidence.Trait]
) -> [PolishStyleLearningEvidence.Trait] {
Array(
traits
.filter { $0.confidence >= priorRetentionFloor }
.sorted { $0.confidence > $1.confidence }
.prefix(maximumPriorTraitsPerDomain)
)
}
var asrTraits = strongest(evidence.asr.traits)
var replyTraits = strongest(evidence.reply.traits)
var asrContradictions = Array(
evidence.asr.contradictions.prefix(maximumPriorContradictionsPerDomain)
)
var replyContradictions = Array(
evidence.reply.contradictions.prefix(maximumPriorContradictionsPerDomain)
)
func makePrior() -> PolishStyleLearningPriorEvidence {
PolishStyleLearningPriorEvidence(
asr: .init(traits: asrTraits, contradictions: asrContradictions),
reply: .init(traits: replyTraits, contradictions: replyContradictions),
learnedThrough: learnedThrough,
cumulativeASRExampleCount: cumulativeASRExampleCount,
cumulativeASREffectiveCharacterCount: cumulativeASREffectiveCharacterCount,
cumulativeReplyExampleCount: cumulativeReplyExampleCount,
cumulativeReplyFinalEditCount: cumulativeReplyFinalEditCount
)
}
var prior = makePrior()
// Over budget: shed contradictions first, then the weakest trait
// across both domains, until the prior fits.
while priorCharacterCount(prior) > maximumPriorCharacters {
if !replyContradictions.isEmpty {
replyContradictions.removeLast()
} else if !asrContradictions.isEmpty {
asrContradictions.removeLast()
} else if let weakestASR = asrTraits.last,
weakestASR.confidence <= (replyTraits.last?.confidence ?? .infinity) {
asrTraits.removeLast()
} else if !replyTraits.isEmpty {
replyTraits.removeLast()
} else {
break
}
prior = makePrior()
}
return prior
}
static func priorCharacterCount(_ prior: PolishStyleLearningPriorEvidence) -> Int {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
let domains = [prior.asr, prior.reply]
guard let data = try? encoder.encode(domains),
let text = String(data: data, encoding: .utf8) else {
return 0
}
return text.count
}
private static func boundedExample(
_ example: PolishStyleLearningExample
) -> PolishStyleLearningExample {
@@ -1303,7 +1534,8 @@ public actor PolishStyleLearningService {
allowedSources: [
.asrUserEdit,
.asrRepeatedBefore,
.asrObservedBefore
.asrObservedBefore,
.priorProfile
]
),
isValid(
@@ -1311,7 +1543,8 @@ public actor PolishStyleLearningService {
allowedSources: [
.replyFinalEdit,
.replyCrossContextSelection,
.replyAcceptance
.replyAcceptance,
.priorProfile
]
) else {
return false
@@ -1369,7 +1602,8 @@ public actor PolishStyleLearningService {
switch item.source {
case .asrRepeatedBefore, .replyCrossContextSelection:
return item.supportCount >= 2
case .asrUserEdit, .asrObservedBefore, .replyFinalEdit, .replyAcceptance:
case .asrUserEdit, .asrObservedBefore, .replyFinalEdit, .replyAcceptance,
.priorProfile:
return true
}
}
@@ -1388,7 +1622,7 @@ public actor PolishStyleLearningService {
switch source {
case .asrUserEdit, .replyFinalEdit:
return 0
case .asrRepeatedBefore, .replyCrossContextSelection:
case .asrRepeatedBefore, .replyCrossContextSelection, .priorProfile:
return 1
case .asrObservedBefore, .replyAcceptance:
return 2
@@ -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",
+1
View File
@@ -81,6 +81,7 @@
"OSGKeyboardTests/PolishStylePackTests",
"OSGKeyboardTests/PolishStyleCorpusExportStoreTests",
"OSGKeyboardTests/PolishStyleLearningServiceTests",
"OSGKeyboardTests/PersonalStyleLearningStateStoreTests",
"OSGKeyboardTests/PolishStylesL10nTests",
"OSGKeyboardTests/LLMClientTests",
"OSGKeyboardTests/TranscriptLanguageDetectorTests",