fix(polish): gate test-build style generation at 1,250 chars and cap export at 5,000

Remove the test-mode unlimited personal-style corpus bypass and replace it with a real 1,250-character minimum gate. Production stays at 2,500. Training-corpus export and the diagnostics view now use a 5,000-character upper bound so the user's full private history never leaves the device in a single export.

- Add PolishStyleLearningCorpusBuilder.testBuildEffectiveCharacterCount (1,250) and trainingExtractionMaximumCharacterCount (5,000); trainingWindow(from:maximumCharacterCount:) accepts a custom cap.
- PolishStylesView: drop bypassesStyleLearningCharacterGate in favor of styleLearningMinimumCharacterCount, unify the ready/remaining text, remove the obsolete polishStyles.learn.testBuildReady key.
- PolishStyleCorpusExportStore: pass the 5,000 cap to trainingWindow and expose trainingExtractionMaximumCharacterCount in the export schema.
- FlowDiagnosticsSettingsView: show the export-sized (5,000-cap) corpus so the diagnostics match what the export ZIP actually contains.
- Tests: rewrite the export cap test for 5,000, add below-cap coverage, add trainingWindow(maximumCharacterCount:) unit coverage, and convert the old UI test into a regression test for the 1,250 test-build minimum.
- L10n: drop the testBuildReady string in en + zh-Hans, update diagnostics.corpus.ready to mention 5,000.
- CHANGELOG: bilingual entry under [Unreleased].
This commit is contained in:
Rocky
2026-08-30 12:09:06 +08:00
parent eac44daaf4
commit 3033ca5b33
11 changed files with 175 additions and 35 deletions
+1
View File
@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Monthly usage calendar**: replace the seven-day Home and Mac Dashboard chart with a full current-month calendar whose date-circle opacity reflects each day's dictation characters, placing the Home calendar between its four metrics and Personal Dictionary. / **月度用量日历**:以完整的当月日历替换首页与 Mac 总览的近七天图表,并通过日期圆形背景透明度表示每天的听写字数;首页日历位于四项统计指标与个性词库之间。
- **Home credits and navigation**: move the credit and invitation card from Settings to Home above the usage metrics, explain free signup and referral rewards before sign-in, and open History or Personal Dictionary directly from their metric cards. / **首页积分与导航**:将积分与邀请卡片从设置迁移到首页统计指标上方,登录前明确说明免费注册与邀请奖励,并支持从听写字数和词库指标卡片直接进入历史记录或个性词库。
- **Personal style threshold**: allow generating a learned speaking style after 2,500 effective dictation characters instead of 5,000. / **专属风格门槛**:生成学习型说话风格所需的有效听写字符由 5,000 降至 2,500。
- **Personal style corpus gate**: replace the test-mode unlimited gate with a 1,250-character minimum while keeping the production 2,500-character gate, and cap the training-corpus export at 5,000 effective characters so the user's full private history never leaves the device in one export. / **个人风格语料门槛**:移除测试版本的「无下限」逻辑,改为 1,250 字符最低门槛,正式版保持 2,500 字符;训练语料导出上限调整为 5,000 字符,避免一次性带出全部听写历史。
### Fixed
- **Personal style generation**: derive every reviewed prompt through two-stage corpus evidence, apply concrete low-confidence ASR tendencies instead of replacing them with a neutral template, recover wrapped model JSON once, keep one provider configuration and a 45-second budget across both stages, and distinguish cancellation from timeout. / **专属风格生成**:每个待审阅 Prompt 均通过两阶段语料证据生成,并在证据较少时仍应用原始 ASR 中具体的低置信度表达倾向,而非替换为中性模板;同时支持一次模型 JSON 包装恢复,在两阶段固定同一服务配置与 45 秒预算,并区分主动取消和请求超时。
@@ -15,6 +15,10 @@ struct PolishStyleCorpusExport: Codable, Equatable {
let appBuild: String
let effectiveCharacterCount: Int
let requiredEffectiveCharacterCount: Int
/// Maximum effective characters included in this export. Caps the
/// training-corpus window even when the user has accumulated far
/// more history than the production 2,500-character unlock gate.
let trainingExtractionMaximumCharacterCount: Int
let examples: [Example]
struct Example: Codable, Equatable {
@@ -55,7 +59,10 @@ final class PolishStyleCorpusExportStore {
let eligibleCorpus = PolishStyleLearningCorpusBuilder.build(from: history)
guard !eligibleCorpus.examples.isEmpty else { return nil }
let corpus = PolishStyleLearningCorpusBuilder.trainingWindow(
from: eligibleCorpus.examples
from: eligibleCorpus.examples,
maximumCharacterCount:
PolishStyleLearningCorpusBuilder
.trainingExtractionMaximumCharacterCount
)
let examples = corpus.examples
@@ -77,6 +84,9 @@ final class PolishStyleCorpusExportStore {
effectiveCharacterCount: corpus.effectiveCharacterCount,
requiredEffectiveCharacterCount:
PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount,
trainingExtractionMaximumCharacterCount:
PolishStyleLearningCorpusBuilder
.trainingExtractionMaximumCharacterCount,
examples: examples
)
}
@@ -135,7 +135,10 @@ struct FlowDiagnosticsSettingsView: View {
from: historyStore.snapshot()
)
return PolishStyleLearningCorpusBuilder.trainingWindow(
from: eligibleCorpus.examples
from: eligibleCorpus.examples,
maximumCharacterCount:
PolishStyleLearningCorpusBuilder
.trainingExtractionMaximumCharacterCount
)
}
+19 -11
View File
@@ -55,7 +55,8 @@ struct PolishStylesView: View {
outputLanguage: language,
minimumEffectiveCharacterCount:
AppDistributionChannel.allowsInternalTools
? 0
? PolishStyleLearningCorpusBuilder
.testBuildEffectiveCharacterCount
: PolishStyleLearningCorpusBuilder
.requiredEffectiveCharacterCount
)
@@ -158,17 +159,20 @@ struct PolishStylesView: View {
PolishStyleLearningCorpusBuilder.build(from: history.snapshot())
}
/// Debug and TestFlight builds may exercise the complete generation
/// pipeline before enough personal corpus exists. App Store builds keep
/// the production 2,500-character gate.
private var bypassesStyleLearningCharacterGate: Bool {
/// Minimum effective character count required to unlock personal style
/// generation. Debug + TestFlight builds use a lower 1,250-character
/// gate so internal testers can still exercise the pipeline; the
/// previous 0 / "unlimited" bypass has been removed.
private var styleLearningMinimumCharacterCount: Int {
AppDistributionChannel.allowsInternalTools
? PolishStyleLearningCorpusBuilder.testBuildEffectiveCharacterCount
: PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
}
private func isEligibleForStyleGeneration(
_ corpus: PolishStyleLearningCorpus
) -> Bool {
bypassesStyleLearningCharacterGate || corpus.isReady
corpus.effectiveCharacterCount >= styleLearningMinimumCharacterCount
}
private var learnedStylePack: PolishStylePack? {
@@ -185,7 +189,7 @@ struct PolishStylesView: View {
private var styleLearningCard: some View {
let corpus = styleLearningCorpus
let required = PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
let required = styleLearningMinimumCharacterCount
let reachedLimit = catalog.entries.count >= PolishStyleLimits.maximumUserPacks
let isActionAvailable = isEligibleForStyleGeneration(corpus) && !reachedLimit
let canGenerate = isActionAvailable && !isGeneratingLearnedStyle
@@ -240,13 +244,17 @@ struct PolishStylesView: View {
Spacer()
Text(
bypassesStyleLearningCharacterGate && !corpus.isReady
? AppL10n.string("polishStyles.learn.testBuildReady")
: corpus.isReady
isEligibleForStyleGeneration(corpus)
? AppL10n.string("polishStyles.learn.ready")
: AppL10n.format(
"polishStyles.learn.remaining",
Int64(corpus.remainingCharacterCount)
Int64(
max(
0,
styleLearningMinimumCharacterCount
- corpus.effectiveCharacterCount
)
)
)
)
.font(TypeStyle.caption2)
@@ -134,7 +134,8 @@ private struct PolishStylesServiceUITestHarness: View {
outputLanguage: language,
minimumEffectiveCharacterCount:
AppDistributionChannel.allowsInternalTools
? 0
? PolishStyleLearningCorpusBuilder
.testBuildEffectiveCharacterCount
: PolishStyleLearningCorpusBuilder
.requiredEffectiveCharacterCount
)
+4 -2
View File
@@ -128,6 +128,7 @@
"common.continue" = "Continue";
"common.reset" = "Reset";
"common.cancel" = "Cancel";
"common.dismiss" = "Dismiss";
"common.save" = "Save";
"common.clear" = "Clear";
"common.delete" = "Delete";
@@ -294,7 +295,7 @@
"settings.diagnostics.corpus.section" = "Personal Style Training Corpus";
"settings.diagnostics.corpus.count" = "%d eligible samples · %d effective characters";
"settings.diagnostics.corpus.empty" = "No eligible paired dictations are available.";
"settings.diagnostics.corpus.ready" = "Newest complete samples through the 2,500-character target.";
"settings.diagnostics.corpus.ready" = "Newest complete samples through the 5,000-character export cap.";
"settings.diagnostics.corpus.export" = "Export Training Corpus";
"settings.diagnostics.corpus.privacy" = "Contains dictated text and prior polish prompts. Nothing is uploaded automatically. Share only with trusted devices or services.";
"settings.diagnostics.privacy.section" = "Privacy";
@@ -683,7 +684,6 @@
"polishStyles.learn.progress" = "%lld / %lld characters";
"polishStyles.learn.remaining" = "%lld to go";
"polishStyles.learn.ready" = "Ready";
"polishStyles.learn.testBuildReady" = "Test build: 2,500-character limit disabled";
"polishStyles.learn.action" = "Generate Style";
"polishStyles.learn.generating" = "Generating…";
"polishStyles.learn.generated.description" = "Your personal style learned from reviewed evidence.";
@@ -959,6 +959,8 @@
"account.referral.status.rewarded" = "Rewarded";
"account.referral.status.ineligible" = "Ineligible";
"account.referral.pendingAfterSignIn" = "Your invitation is saved and will be redeemed after sign-in.";
"account.referral.rewardGranted" = "You received %@ credits from the invitation.";
"account.referral.rewardGrantedGeneric" = "Your invitation reward has been added to your balance.";
"account.security.section" = "Account controls";
"account.signOut.action" = "Sign Out";
"account.signOut.confirmTitle" = "Sign out?";
@@ -128,6 +128,7 @@
"common.continue" = "继续";
"common.reset" = "重置";
"common.cancel" = "取消";
"common.dismiss" = "关闭";
"common.save" = "保存";
"common.clear" = "清空";
"common.delete" = "删除";
@@ -294,7 +295,7 @@
"settings.diagnostics.corpus.section" = "个人风格训练语料";
"settings.diagnostics.corpus.count" = "%d 条可用样本 · %d 个有效字符";
"settings.diagnostics.corpus.empty" = "暂无符合条件的成对听写语料。";
"settings.diagnostics.corpus.ready" = "选取最新完整样本,累积至至少 2,500 个有效字符。";
"settings.diagnostics.corpus.ready" = "选取最新完整样本,导出时最多累积 5,000 个有效字符。";
"settings.diagnostics.corpus.export" = "导出训练语料";
"settings.diagnostics.corpus.privacy" = "文件包含口述文本和历史润色提示词,不会自动上传。请仅分享给可信设备或服务。";
"settings.diagnostics.privacy.section" = "隐私";
@@ -682,7 +683,6 @@
"polishStyles.learn.progress" = "%lld / %lld 字";
"polishStyles.learn.remaining" = "还差 %lld 字";
"polishStyles.learn.ready" = "可以生成";
"polishStyles.learn.testBuildReady" = "测试版本:已暂时取消 2500 字限制";
"polishStyles.learn.action" = "生成风格";
"polishStyles.learn.generating" = "生成中…";
"polishStyles.learn.generated.description" = "根据经过确认的证据学习得到的个人表达风格。";
@@ -958,6 +958,8 @@
"account.referral.status.rewarded" = "已奖励";
"account.referral.status.ineligible" = "不符合条件";
"account.referral.pendingAfterSignIn" = "邀请已暂存,登录后会自动兑换。";
"account.referral.rewardGranted" = "恭喜获得 %@ 积分";
"account.referral.rewardGrantedGeneric" = "邀请奖励已发放,积分已到账。";
"account.security.section" = "账号操作";
"account.signOut.action" = "退出登录";
"account.signOut.confirmTitle" = "退出登录?";
@@ -150,8 +150,24 @@ public struct PolishStyleLearningCorpus: Equatable, Sendable {
}
public enum PolishStyleLearningCorpusBuilder {
/// Production minimum effective character count required to unlock
/// personal style generation. App Store builds enforce this gate.
public static let requiredEffectiveCharacterCount = 2_500
/// Test builds (Debug + TestFlight) lower the unlock threshold so
/// internal testers can exercise the complete generation pipeline
/// without dictating a full production corpus. The threshold is
/// still a real gate; the previous "0 / unlimited" bypass is gone.
public static let testBuildEffectiveCharacterCount = 1_250
/// 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
/// exported training set never exceeds this cap so the user's
/// full private dictation history does not leave the device in
/// one shot.
public static let trainingExtractionMaximumCharacterCount = 5_000
public static func build(
from entries: [SpeechHistoryEntry]
) -> PolishStyleLearningCorpus {
@@ -167,12 +183,23 @@ public enum PolishStyleLearningCorpusBuilder {
)
}
/// Selects the newest complete examples until the learning threshold is
/// reached. If less history is available, every eligible example is kept.
/// The returned order is chronological for export and model input.
/// Selects the newest complete examples until `maximumCharacterCount`
/// is reached. If less history is available, every eligible example
/// is kept. The returned order is chronological for export and
/// model input.
///
/// - Parameter maximumCharacterCount: Hard upper bound for the
/// selected window. Defaults to the production unlock threshold
/// (2,500) so live generation still fits the LLM request budget.
/// Callers that build a training-corpus export should pass
/// `trainingExtractionMaximumCharacterCount` (5,000) so the
/// export can carry up to the broader extraction cap when the
/// user has accumulated more history than the live gate.
public static func trainingWindow(
from examples: [PolishStyleLearningExample]
from examples: [PolishStyleLearningExample],
maximumCharacterCount: Int = requiredEffectiveCharacterCount
) -> PolishStyleLearningCorpus {
let limit = max(0, maximumCharacterCount)
let newestFirst = examples.sorted { $0.createdAt > $1.createdAt }
var selected: [PolishStyleLearningExample] = []
var effectiveCharacterCount = 0
@@ -182,7 +209,7 @@ public enum PolishStyleLearningCorpusBuilder {
effectiveCharacterCount += self.effectiveCharacterCount(
in: example.prePolishText
)
if effectiveCharacterCount >= requiredEffectiveCharacterCount {
if effectiveCharacterCount >= limit {
break
}
}
@@ -46,6 +46,7 @@ final class PolishStyleCorpusExportStoreTests: XCTestCase {
XCTAssertEqual(export.appBuild, "94")
XCTAssertEqual(export.effectiveCharacterCount, 4)
XCTAssertEqual(export.requiredEffectiveCharacterCount, 2_500)
XCTAssertEqual(export.trainingExtractionMaximumCharacterCount, 5_000)
XCTAssertEqual(export.examples.count, 1)
XCTAssertEqual(export.examples[0].prePolishText, "你好 世界")
XCTAssertEqual(export.examples[0].finalText, "你好,世界。")
@@ -105,7 +106,50 @@ final class PolishStyleCorpusExportStoreTests: XCTestCase {
XCTAssertEqual(decoded.examples.count, 1)
}
func testExportUsesNewestCompleteExamplesThroughThreshold() throws {
func testExportUsesNewestCompleteExamplesThroughFiveThousandThreshold() throws {
// Total available characters: 7,000 above the export cap.
// Newest first: newest (3,000) + middle (2,000) = 5,000 (cap reached,
// stop). Oldest is dropped from the export window.
let history = SyncedSpeechHistory(
entries: [
SpeechHistoryEntry(
text: "oldest",
prePolishText: String(repeating: "", count: 2_000),
createdAt: Date(timeIntervalSince1970: 1)
),
SpeechHistoryEntry(
text: "middle-complete",
prePolishText: String(repeating: "", count: 2_000),
createdAt: Date(timeIntervalSince1970: 2)
),
SpeechHistoryEntry(
text: "newest-complete",
prePolishText: String(repeating: "", count: 3_000),
createdAt: Date(timeIntervalSince1970: 3)
)
]
)
let export = try XCTUnwrap(
PolishStyleCorpusExportStore(
directoryURL: temporaryDirectory()
).makeExport(from: history)
)
XCTAssertEqual(export.effectiveCharacterCount, 5_000)
XCTAssertEqual(export.requiredEffectiveCharacterCount, 2_500)
XCTAssertEqual(export.trainingExtractionMaximumCharacterCount, 5_000)
XCTAssertEqual(
export.examples.map(\.finalText),
["middle-complete", "newest-complete"]
)
XCTAssertEqual(export.examples[0].prePolishText.count, 2_000)
XCTAssertEqual(export.examples[1].prePolishText.count, 3_000)
}
func testExportKeepsEveryEligibleExampleWhenUnderFiveThousandThreshold() throws {
// 3,600 characters total below the 5,000 cap, so the export keeps
// every eligible example chronologically.
let history = SyncedSpeechHistory(
entries: [
SpeechHistoryEntry(
@@ -132,13 +176,11 @@ final class PolishStyleCorpusExportStoreTests: XCTestCase {
).makeExport(from: history)
)
XCTAssertEqual(export.effectiveCharacterCount, 2_600)
XCTAssertEqual(export.effectiveCharacterCount, 3_600)
XCTAssertEqual(
export.examples.map(\.finalText),
["middle-complete", "newest-complete"]
["oldest", "middle-complete", "newest-complete"]
)
XCTAssertEqual(export.examples[0].prePolishText.count, 1_600)
XCTAssertEqual(export.examples[1].prePolishText.count, 1_000)
}
func testEmptyCorpusRemovesPreviousExport() throws {
@@ -146,6 +146,42 @@ final class PolishStyleLearningServiceTests: XCTestCase {
XCTAssertEqual(window.examples.map(\.finalText), ["older", "newer"])
}
func testTrainingWindowHonorsCustomMaximumCharacterCount() {
// Total available characters: 7,000. With a 5,000-character
// cap (training-corpus export), newest (3,000) + middle (2,000)
// fills the window and oldest is dropped.
let oldest = PolishStyleLearningExample(
prePolishText: String(repeating: "", count: 2_000),
finalText: "oldest",
polishStyleID: nil,
createdAt: Date(timeIntervalSince1970: 1)
)
let middle = PolishStyleLearningExample(
prePolishText: String(repeating: "", count: 2_000),
finalText: "middle",
polishStyleID: nil,
createdAt: Date(timeIntervalSince1970: 2)
)
let newest = PolishStyleLearningExample(
prePolishText: String(repeating: "", count: 3_000),
finalText: "newest",
polishStyleID: nil,
createdAt: Date(timeIntervalSince1970: 3)
)
let window = PolishStyleLearningCorpusBuilder.trainingWindow(
from: [oldest, newest, middle],
maximumCharacterCount: PolishStyleLearningCorpusBuilder
.trainingExtractionMaximumCharacterCount
)
XCTAssertEqual(window.effectiveCharacterCount, 5_000)
XCTAssertEqual(
window.examples.map(\.finalText),
["middle", "newest"]
)
}
func testGenerationRunsExtractorBeforeSynthesizerWithSeparatedPayloads() async throws {
var catalog = PolishStyleCatalog()
let activeStyle = PolishStylePack(
@@ -1,21 +1,29 @@
import XCTest
final class PolishStylesGenerationUITests: XCTestCase {
func testTestBuildCanGenerateWithoutPersonalCorpus() {
func testTestBuildRequiresTwelveFiftyCharacterMinimum() {
// The old "unlimited" test-mode bypass has been removed. Test
// builds now enforce a 1,250-character minimum: an empty corpus
// must keep the generation button disabled, and the visible
// progress text should reflect the remaining distance to the
// active gate.
continueAfterFailure = false
let app = launchServiceHarness(
additionalArgument: "--polish-styles-service-ui-test-no-corpus"
)
XCTAssertTrue(
let generate = app.buttons["polishStyles.learn.generate"]
XCTAssertTrue(generate.waitForExistence(timeout: 5))
XCTAssertFalse(
generate.isEnabled,
"Empty corpus in a test build must not unlock generation; the 1,250-character minimum still applies."
)
XCTAssertFalse(
app.staticTexts[
"Test build: 2,500-character limit disabled"
]
.waitForExistence(timeout: 5)
].exists,
"The old unlimited-bypass label must no longer be shown."
)
let generate = app.buttons["polishStyles.learn.generate"]
XCTAssertTrue(generate.exists)
XCTAssertTrue(generate.isEnabled)
}
func testGeneratedStyleReviewAndSaveFlow() {