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
@@ -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(
language: language
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,保存前可预览和修改。";