Files
OSGKeyboard/OSGKeyboardShared/Models/PolishStylePack.swift
T
rockyandClaude Opus 5 0023741455 feat(app): add crash visibility, memory relief, and clipboard reply work
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>
2026-09-18 17:31:29 -07:00

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"
}
}
}