Squashes the working tree that had accumulated on cloud-agent. Two strands of work, kept in one commit at the maintainer's request. Crash and stability (this session): - Subscribe to MetricKit so crash, hang, CPU, and disk-write diagnostics are recorded on device. Keyboard-extension memory kills are the most likely way a user loses the keyboard mid-sentence and iOS does not classify them as crashes, so they never reach App Store Connect. - Upload those reports to POST /v1/analytics/diagnostics from internal builds only (local Debug and TestFlight), reusing the analytics installation identifier and skipping entirely when analytics are off. App Store builds keep everything on device. - Shed the Chinese engine, English lexicon, and in-flight pipeline work at the 40 MiB and 48 MiB budget thresholds instead of waiting for a system memory warning that jetsam often preempts. - Install audio taps with the input node's live format through one shared adaptive resampler, so an audio-route change mid-recording no longer raises an uncatchable format-mismatch exception. Covers the dictation preview and the Mac recorder, which still used the pattern the Flow capture path had already moved away from. - Harden the librime bridge: never pass a nil string across a nonnull boundary when a user dictionary holds malformed UTF-8, null-check the engine API vector, and distrust a candidate count without its array. - Guard the expanded candidate grid against a list that shrinks while the collection view is still laying out against the previous count. - Resolve localized text without the App Group so the screen shown when that container is missing renders its message instead of crashing on launch, which is the one situation it exists to handle. - Gate CI on the App Group and Keychain wiring that the keyboard extension hard-depends on, with a self-test proving the check fails. Clipboard, reply, and semantics (pre-existing working tree): - Unify the reply center, refresh keyboard AI features, and add native event and reminder exporters. - Expand clipboard semantic routing with authored corpora and a product-blind holdout, plus the scripts that build and validate them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
335 lines
13 KiB
Swift
335 lines
13 KiB
Swift
// PolishStylePack.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Complete writing-personality prompts used by the polish pipeline. Built-in
|
|
// packs ship with the app; only user-created packs are persisted and synced.
|
|
|
|
import Foundation
|
|
|
|
public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
|
|
public enum Kind: String, Codable, Sendable {
|
|
case builtin
|
|
case user
|
|
}
|
|
|
|
public struct LearningMetadata: Codable, Equatable, Sendable {
|
|
public let schemaVersion: Int
|
|
public let evidenceStatus: String
|
|
public let confidence: Double
|
|
public let asrExampleCount: Int
|
|
public let asrEffectiveCharacterCount: Int
|
|
public let replyExampleCount: Int
|
|
public let replyFinalEditCount: Int
|
|
public let generatedAt: Date
|
|
|
|
public init(
|
|
schemaVersion: Int,
|
|
evidenceStatus: String,
|
|
confidence: Double,
|
|
asrExampleCount: Int,
|
|
asrEffectiveCharacterCount: Int,
|
|
replyExampleCount: Int,
|
|
replyFinalEditCount: Int,
|
|
generatedAt: Date
|
|
) {
|
|
self.schemaVersion = schemaVersion
|
|
self.evidenceStatus = evidenceStatus
|
|
self.confidence = confidence
|
|
self.asrExampleCount = asrExampleCount
|
|
self.asrEffectiveCharacterCount = asrEffectiveCharacterCount
|
|
self.replyExampleCount = replyExampleCount
|
|
self.replyFinalEditCount = replyFinalEditCount
|
|
self.generatedAt = generatedAt
|
|
}
|
|
}
|
|
|
|
public let id: String
|
|
public var name: String
|
|
public var prompt: String
|
|
/// When true, polish may keep model-added emoji and the prompt overrides R5.
|
|
/// Defaults off so existing / builtin styles stay emoji-strict.
|
|
public var allowsAddedEmoji: Bool
|
|
/// Optional learning provenance. It is never included in the runtime prompt.
|
|
public var learningMetadata: LearningMetadata?
|
|
public let kind: Kind
|
|
public let createdAt: Date
|
|
public var updatedAt: Date
|
|
|
|
public init(
|
|
id: String = "user.\(UUID().uuidString.lowercased())",
|
|
name: String,
|
|
prompt: String,
|
|
allowsAddedEmoji: Bool = false,
|
|
learningMetadata: LearningMetadata? = nil,
|
|
kind: Kind = .user,
|
|
createdAt: Date = Date(),
|
|
updatedAt: Date? = nil
|
|
) {
|
|
self.id = id
|
|
self.name = name
|
|
self.prompt = prompt
|
|
self.allowsAddedEmoji = allowsAddedEmoji
|
|
self.learningMetadata = learningMetadata
|
|
self.kind = kind
|
|
self.createdAt = createdAt
|
|
self.updatedAt = updatedAt ?? createdAt
|
|
}
|
|
|
|
public func displayName(language: AppUILanguage? = nil) -> String {
|
|
guard kind == .builtin else { return name }
|
|
return SharedL10n.string("polishStyle.\(id.dropFirst("builtin.".count))", language: language)
|
|
}
|
|
|
|
/// Effective emoji policy for polish: explicit toggle, or a custom prompt that
|
|
/// clearly opts in (so paste-only custom styles still keep model-added emoji).
|
|
public var effectiveAllowsAddedEmoji: Bool {
|
|
if allowsAddedEmoji { return true }
|
|
guard kind == .user else { return false }
|
|
return Self.promptDeclaresAddedEmojiOptIn(prompt)
|
|
}
|
|
|
|
/// Heuristic for custom prompts that declare “add mood emoji” themselves.
|
|
public static func promptDeclaresAddedEmojiOptIn(_ prompt: String) -> Bool {
|
|
let markers = [
|
|
"允许新增 emoji",
|
|
"允许新增emoji",
|
|
"按情绪点缀",
|
|
"按原文情绪",
|
|
"Emoji 覆盖",
|
|
"outranks global R5",
|
|
"may add emojis",
|
|
"allow mood emoji",
|
|
"allowsAddedEmoji"
|
|
]
|
|
return markers.contains { prompt.localizedCaseInsensitiveContains($0) }
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id, name, prompt, allowsAddedEmoji, learningMetadata, kind, createdAt, updatedAt
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
id = try container.decode(String.self, forKey: .id)
|
|
name = try container.decode(String.self, forKey: .name)
|
|
prompt = try container.decode(String.self, forKey: .prompt)
|
|
// Older synced packs omit the key — stay emoji-strict.
|
|
allowsAddedEmoji = try container.decodeIfPresent(Bool.self, forKey: .allowsAddedEmoji) ?? false
|
|
// V1 packs have no learning provenance and remain fully decodable.
|
|
learningMetadata = try container.decodeIfPresent(
|
|
LearningMetadata.self,
|
|
forKey: .learningMetadata
|
|
)
|
|
kind = try container.decode(Kind.self, forKey: .kind)
|
|
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
|
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
|
}
|
|
}
|
|
|
|
public enum PolishStyleLimits {
|
|
public static let maximumUserPacks = 8
|
|
public static let maximumPromptCharacters = 6_000
|
|
}
|
|
|
|
public enum PolishStyleValidationError: Error, Equatable, Sendable {
|
|
case emptyName
|
|
case emptyPrompt
|
|
case tooManyUserPacks
|
|
case promptTooLong(maximum: Int)
|
|
case builtinIsImmutable
|
|
}
|
|
|
|
public struct PolishStyleCatalog: Codable, Equatable, Sendable {
|
|
public var entries: [PolishStylePack]
|
|
public var version: Int
|
|
public var lastSyncedAt: Date?
|
|
/// Deletion tombstones prevent an offline device from restoring old packs.
|
|
public var deletedEntryIDs: [String: Date]
|
|
public var clearedAt: Date?
|
|
|
|
public init(
|
|
entries: [PolishStylePack] = [],
|
|
version: Int = 1,
|
|
lastSyncedAt: Date? = nil,
|
|
deletedEntryIDs: [String: Date] = [:],
|
|
clearedAt: Date? = nil
|
|
) {
|
|
self.entries = entries.filter { $0.kind == .user }
|
|
self.version = version
|
|
self.lastSyncedAt = lastSyncedAt
|
|
self.deletedEntryIDs = deletedEntryIDs
|
|
self.clearedAt = clearedAt
|
|
}
|
|
|
|
public static let empty = PolishStyleCatalog()
|
|
|
|
public mutating func upsert(_ pack: PolishStylePack, at date: Date = Date()) throws {
|
|
guard pack.kind == .user else { throw PolishStyleValidationError.builtinIsImmutable }
|
|
let name = pack.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let prompt = pack.prompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !name.isEmpty else { throw PolishStyleValidationError.emptyName }
|
|
guard !prompt.isEmpty else { throw PolishStyleValidationError.emptyPrompt }
|
|
guard prompt.count <= PolishStyleLimits.maximumPromptCharacters else {
|
|
throw PolishStyleValidationError.promptTooLong(maximum: PolishStyleLimits.maximumPromptCharacters)
|
|
}
|
|
|
|
if let index = entries.firstIndex(where: { $0.id == pack.id }) {
|
|
var updated = pack
|
|
updated.name = name
|
|
updated.prompt = prompt
|
|
updated.allowsAddedEmoji = pack.allowsAddedEmoji
|
|
updated.updatedAt = date
|
|
entries[index] = updated
|
|
} else {
|
|
guard entries.count < PolishStyleLimits.maximumUserPacks else {
|
|
throw PolishStyleValidationError.tooManyUserPacks
|
|
}
|
|
var created = pack
|
|
created.name = name
|
|
created.prompt = prompt
|
|
created.allowsAddedEmoji = pack.allowsAddedEmoji
|
|
created.updatedAt = date
|
|
entries.append(created)
|
|
}
|
|
deletedEntryIDs.removeValue(forKey: pack.id)
|
|
version += 1
|
|
}
|
|
|
|
public mutating func recordDeletion(of id: String, at date: Date = Date()) {
|
|
entries.removeAll { $0.id == id }
|
|
deletedEntryIDs[id] = date
|
|
version += 1
|
|
}
|
|
}
|
|
|
|
public enum PolishStylePackCatalog {
|
|
public static let defaultID = "builtin.light"
|
|
public static let newUserPromptTemplate = """
|
|
# 角色
|
|
描述这个风格采用的写作人格、语气和表达习惯。
|
|
|
|
# 风格边界
|
|
描述这种人格最应该做什么,以及绝不能出现什么。
|
|
|
|
# 示例
|
|
提供一条最能代表这个风格的「输入 → 输出」示例。
|
|
"""
|
|
|
|
/// The stable core owns ASR correction, dictionaries, and output safety.
|
|
/// A style pack contributes personality only.
|
|
public static func runtimePersonality(for style: PolishStylePack) -> String {
|
|
// Older synced user packs may still contain the retired placeholder.
|
|
style.prompt
|
|
.replacingOccurrences(of: "{{DICTIONARY}}", with: "")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
/// Built-in packs loaded from `Resources/PolishStyles/` (manifest + per-style JSON).
|
|
public static let builtins: [PolishStylePack] = {
|
|
let loaded = BuiltinPolishStyleLoader.load()
|
|
precondition(!loaded.isEmpty, "Built-in polish styles failed to load from bundle Resources/PolishStyles")
|
|
return loaded
|
|
}()
|
|
|
|
/// Built-in style sections shown in the polish-styles UI.
|
|
public enum BuiltinStyleGroup: String, CaseIterable, Sendable {
|
|
case practical
|
|
case fun
|
|
|
|
public var ids: [String] {
|
|
switch self {
|
|
case .practical:
|
|
return [defaultID, "builtin.structured", "builtin.formal", "builtin.chat"]
|
|
case .fun:
|
|
return ["builtin.dating", "builtin.flex", "builtin.corp", "builtin.diba", "builtin.xhs"]
|
|
}
|
|
}
|
|
|
|
public var packs: [PolishStylePack] {
|
|
ids.compactMap { id in builtins.first { $0.id == id } }
|
|
}
|
|
}
|
|
|
|
public static func resolve(id: String, userCatalog: PolishStyleCatalog) -> PolishStylePack {
|
|
builtins.first(where: { $0.id == id })
|
|
?? userCatalog.entries.first(where: { $0.id == id })
|
|
?? builtins[0]
|
|
}
|
|
|
|
public static func all(userCatalog: PolishStyleCatalog) -> [PolishStylePack] {
|
|
builtins + userCatalog.entries.sorted {
|
|
if $0.updatedAt != $1.updatedAt { return $0.updatedAt > $1.updatedAt }
|
|
return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
/// Distilled personal styles serve the AI-reply pipeline only. Voice polish
|
|
/// must never load one: a learned pack is injected as "outranks generic
|
|
/// cleanup", which would override the core T1/T2 filler and repetition
|
|
/// cleanup and put the speaker's disfluencies back into the transcript.
|
|
public static func isPersonalReplyStyle(_ pack: PolishStylePack) -> Bool {
|
|
pack.learningMetadata != nil
|
|
}
|
|
|
|
/// Voice-polish eligibility. Rejecting learned packs here — rather than in
|
|
/// the UI — means a stale iCloud value or a view bug still cannot reach
|
|
/// `PolishPromptComposer`; `setActivePolishStyleId` falls back to `defaultID`.
|
|
public static func isValidActiveID(_ id: String, userCatalog: PolishStyleCatalog) -> Bool {
|
|
if builtins.contains(where: { $0.id == id }) { return true }
|
|
guard let pack = userCatalog.entries.first(where: { $0.id == id }) else { return false }
|
|
return !isPersonalReplyStyle(pack)
|
|
}
|
|
|
|
/// The mirror gate: only a distilled personal style may drive AI replies.
|
|
/// Hand-written user packs stay on the Styles page (voice input only).
|
|
public static func resolvePersonalReplyStyle(
|
|
id: String,
|
|
userCatalog: PolishStyleCatalog
|
|
) -> PolishStylePack? {
|
|
guard !id.isEmpty,
|
|
let pack = userCatalog.entries.first(where: { $0.id == id }),
|
|
isPersonalReplyStyle(pack) else { return nil }
|
|
return pack
|
|
}
|
|
|
|
/// Newest distilled personal style. Used by the Skills page and by the
|
|
/// one-shot migration that splits voice input from AI replies.
|
|
public static func latestPersonalReplyStyle(
|
|
userCatalog: PolishStyleCatalog
|
|
) -> PolishStylePack? {
|
|
userCatalog.entries
|
|
.filter(isPersonalReplyStyle)
|
|
.max { $0.updatedAt < $1.updatedAt }
|
|
}
|
|
|
|
/// Fun personality packs that fully rewrite voice (dating / flex / corp / diba / xhs).
|
|
public static func isFunPersonality(id: String) -> Bool {
|
|
BuiltinStyleGroup.fun.ids.contains(id)
|
|
}
|
|
|
|
/// Heavy fun styles bypass practical safeguards and use only the shared
|
|
/// transcript formatter before their personality prompt.
|
|
public static func usesFormattingOnlyPipeline(
|
|
id: String,
|
|
intensity: PolishIntensity
|
|
) -> Bool {
|
|
intensity == .heavy && isFunPersonality(id: id)
|
|
}
|
|
|
|
/// SF Symbol shown on polish-style cards (built-in and user packs).
|
|
public static func systemImage(for id: String) -> String {
|
|
switch id {
|
|
case "builtin.structured": return "list.bullet.rectangle"
|
|
case "builtin.formal": return "briefcase"
|
|
case "builtin.dating": return "heart.text.square"
|
|
case "builtin.chat": return "bubble.left.and.bubble.right"
|
|
case "builtin.light": return "wand.and.sparkles"
|
|
case "builtin.flex": return "textformat"
|
|
case "builtin.corp": return "building.2"
|
|
case "builtin.diba": return "quote.bubble"
|
|
case "builtin.xhs": return "star.bubble"
|
|
default: return "text.badge.star"
|
|
}
|
|
}
|
|
}
|