feat(polish): add context safeguards, layered prompts, and output validation

Use redacted cursor neighborhood and pause-aware chunks for more natural polish,
validate protected terms with retry/local fallback, and structure bilingual prompts
for consistency and provider prefix caching.
This commit is contained in:
Rocky
2026-07-29 17:45:11 +08:00
parent 2d44423f4c
commit 34be2e8dd1
40 changed files with 1827 additions and 183 deletions
@@ -101,11 +101,18 @@ public struct UtteranceAudioChunk: Sendable, Equatable {
public let index: Int
public let samples: [Float]
public let isLast: Bool
public let trailingPauseSeconds: Double
public init(index: Int, samples: [Float], isLast: Bool) {
public init(
index: Int,
samples: [Float],
isLast: Bool,
trailingPauseSeconds: Double = 0
) {
self.index = index
self.samples = samples
self.isLast = isLast
self.trailingPauseSeconds = trailingPauseSeconds
}
public var durationSeconds: Double {
+55 -2
View File
@@ -12,6 +12,13 @@ public struct LLMRequest: Codable, Sendable {
public let messages: [Message]
public let temperature: Double?
public let maxTokens: Int?
public let topP: Double?
private enum CodingKeys: String, CodingKey {
case model, messages, temperature
case maxTokens = "max_tokens"
case topP = "top_p"
}
public enum Message: Codable, Sendable {
case system(String)
@@ -52,19 +59,65 @@ public struct LLMRequest: Codable, Sendable {
public init(
model: String,
messages: [Message],
temperature: Double? = 0.3,
maxTokens: Int? = nil
temperature: Double? = 0.1,
maxTokens: Int? = nil,
topP: Double? = 0.9
) {
self.model = model
self.messages = messages
self.temperature = temperature
self.maxTokens = maxTokens
self.topP = topP
}
/// Coarse estimate used only for a safe output ceiling.
public static func estimatedTokenCount(for text: String) -> Int {
var cjkCount = 0
var nonCJKCount = 0
for scalar in text.unicodeScalars {
switch scalar.value {
case 0x4E00...0x9FFF, 0x3400...0x4DBF, 0xF900...0xFAFF:
cjkCount += 1
default:
nonCJKCount += 1
}
}
return max(1, cjkCount + Int(ceil(Double(nonCJKCount) / 4.0)))
}
public static func outputTokenLimit(for text: String) -> Int {
min(4_096, max(256, estimatedTokenCount(for: text) * 2))
}
}
public struct LLMResponse: Codable, Sendable {
public let id: String?
public let choices: [Choice]
public let usage: Usage?
public struct Usage: Codable, Sendable {
public let promptTokens: Int?
public let promptCacheHitTokens: Int?
public let promptTokensDetails: PromptTokensDetails?
public struct PromptTokensDetails: Codable, Sendable {
public let cachedTokens: Int?
private enum CodingKeys: String, CodingKey {
case cachedTokens = "cached_tokens"
}
}
private enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens"
case promptCacheHitTokens = "prompt_cache_hit_tokens"
case promptTokensDetails = "prompt_tokens_details"
}
public var cachedTokens: Int? {
promptCacheHitTokens ?? promptTokensDetails?.cachedTokens
}
}
public struct Choice: Codable, Sendable {
public let index: Int
+48 -1
View File
@@ -9,6 +9,34 @@
import Foundation
public struct FieldHints: Sendable, Equatable {
public let keyboardType: String?
public let returnKeyType: String?
public let isEmptyField: Bool
public let isContextAvailable: Bool
public init(
keyboardType: String? = nil,
returnKeyType: String? = nil,
isEmptyField: Bool = false,
isContextAvailable: Bool = false
) {
self.keyboardType = keyboardType
self.returnKeyType = returnKeyType
self.isEmptyField = isEmptyField
self.isContextAvailable = isContextAvailable
}
public init(from context: FlowFieldContext) {
self.init(
keyboardType: context.keyboardType,
returnKeyType: context.returnKeyType,
isEmptyField: context.isEmptyField,
isContextAvailable: context.isContextAvailable
)
}
}
public struct PolishContext: Sendable {
/// Coarse classification of the input field. When `.unknown` the
/// LLM is told to pick a neutral tone on its own.
@@ -24,6 +52,12 @@ public struct PolishContext: Sendable {
/// bias terminology choices.
public let precedingText: String?
/// Optional text immediately after the insertion point.
public let followingText: String?
/// Input-field signals captured by the keyboard extension.
public let fieldHints: FieldHints?
/// Extra dictionary block appended after `PersonalDictionary.promptFragment()`
/// (e.g. builtin `phrases.tsv` terms on macOS local ASR).
public let dictionarySupplement: String?
@@ -32,19 +66,26 @@ public struct PolishContext: Sendable {
/// include in the prompt. The full preceding text is often
/// hundreds of KB in a long note we only need the tail.
public let maxPrecedingChars: Int
public let maxFollowingChars: Int
public init(
appContext: AppContext = .unknown,
intensity: PolishIntensity = .default,
precedingText: String? = nil,
followingText: String? = nil,
fieldHints: FieldHints? = nil,
dictionarySupplement: String? = nil,
maxPrecedingChars: Int = 500
maxPrecedingChars: Int = 600,
maxFollowingChars: Int = 200
) {
self.appContext = appContext
self.intensity = intensity
self.precedingText = precedingText
self.followingText = followingText
self.fieldHints = fieldHints
self.dictionarySupplement = dictionarySupplement
self.maxPrecedingChars = maxPrecedingChars
self.maxFollowingChars = maxFollowingChars
}
/// Truncated view of `precedingText` ready for prompt injection.
@@ -54,4 +95,10 @@ public struct PolishContext: Sendable {
if raw.count <= maxPrecedingChars { return raw }
return String(raw.suffix(maxPrecedingChars))
}
public var followingForPrompt: String? {
guard let raw = followingText, !raw.isEmpty else { return nil }
if raw.count <= maxFollowingChars { return raw }
return String(raw.prefix(maxFollowingChars))
}
}
+14 -34
View File
@@ -56,41 +56,21 @@ public enum PolishIntensity: String, Codable, Sendable, CaseIterable {
/// heavy restructuring (chat/light/dating), heavy still improves clarity
/// but must not override the style pack's length and format rules.
public func promptGuideline(styleID: String?) -> String {
let base: String
switch styleID {
case "builtin.dating":
base = datingGuideline
case "builtin.flex":
base = flexGuideline
case "builtin.corp":
base = corpGuideline
case "builtin.diba":
base = dibaGuideline
case "builtin.xhs":
base = xhsGuideline
default:
base = defaultGuideline
let transformative = styleID.map(PolishStylePackCatalog.isFunPersonality(id:)) ?? false
switch (self, transformative) {
case (.light, false):
return "Light: remove only explicit fillers and stutters. Merge only unmistakable self-corrections. Do not reorder otherwise-clear wording."
case (.medium, false):
return "Medium: remove clear fillers and abandoned restarts, fix high-confidence ASR errors, and reorder only obviously broken syntax."
case (.heavy, false):
return "Heavy: handle implicit restarts and filler phrases more actively. You may reorder clauses for clarity while preserving every fact and the user's voice."
case (.light, true):
return "Light style strength: clean clear fillers and apply a recognizable but restrained version of the active personality."
case (.medium, true):
return "Medium style strength: merge clear restarts and apply the active personality with a visibly stronger full-sentence rewrite."
case (.heavy, true):
return "Heavy style strength: handle implicit restarts actively and use the strongest version of the active personality, while preserving facts and intent."
}
guard self == .heavy,
let styleID,
PolishStylePackCatalog.limitsHeavyRestructuring(id: styleID)
else {
return base
}
if PolishStylePackCatalog.isFunPersonality(id: styleID) {
return base + """
Style override: keep short sendable form — no report paragraphs or numbered lists unless the transcript enumerates items. \
Full voice rewrite is allowed for style effect; stay within about 13 short bubbles, not an essay. This style's Light/Medium/Heavy rules remain authoritative.
"""
}
return base + """
Style override: the active style pack limits heavy restructuring. Do not expand length, add paragraphs for polish only, or introduce numbered lists unless the transcript explicitly enumerates items. Keep the style pack's chat rhythm, tone, and format rules authoritative.
"""
}
private var datingGuideline: String {
@@ -0,0 +1,216 @@
// PolishStylePolicy.swift
// OSGKeyboard · Shared
//
// Runtime-only policy metadata for style packs. The policy is deliberately
// separate from persisted user packs so older synced data keeps decoding.
import Foundation
public enum PolishRewriteMode: String, Sendable {
case practical
case transformative
}
public enum StructurePolicy: String, Sendable {
case never
case onlyExplicit
case encouraged
}
public enum PunctuationStyle: String, Sendable {
case full
case light
case minimal
}
public struct PolishStylePolicy: Sendable, Equatable {
public let mode: PolishRewriteMode
public let lengthRatio: ClosedRange<Double>
public let structure: StructurePolicy
public let punctuation: PunctuationStyle
public init(
mode: PolishRewriteMode,
lengthRatio: ClosedRange<Double>,
structure: StructurePolicy,
punctuation: PunctuationStyle
) {
self.mode = mode
self.lengthRatio = lengthRatio
self.structure = structure
self.punctuation = punctuation
}
}
public enum PolishStylePolicyResolver {
public static func policy(for style: PolishStylePack) -> PolishStylePolicy {
switch style.id {
case "builtin.chat":
return .init(mode: .practical, lengthRatio: 0.85...1.10, structure: .never, punctuation: .light)
case "builtin.structured":
return .init(mode: .practical, lengthRatio: 0.85...1.35, structure: .encouraged, punctuation: .full)
case "builtin.formal":
return .init(mode: .practical, lengthRatio: 0.85...1.25, structure: .onlyExplicit, punctuation: .full)
case "builtin.dating", "builtin.flex", "builtin.corp", "builtin.diba":
return .init(mode: .transformative, lengthRatio: 0.70...1.60, structure: .never, punctuation: .light)
case "builtin.xhs":
return .init(mode: .transformative, lengthRatio: 0.80...1.80, structure: .encouraged, punctuation: .light)
case "builtin.light":
return .init(mode: .practical, lengthRatio: 0.80...1.20, structure: .onlyExplicit, punctuation: .full)
default:
return .init(mode: .transformative, lengthRatio: 0.70...1.60, structure: .onlyExplicit, punctuation: .full)
}
}
public static func styleCard(
for style: PolishStylePack,
useChineseGuidance: Bool
) -> String {
guard style.kind == .builtin else {
return useChineseGuidance
? customChineseCard(prompt: style.prompt)
: customEnglishCard(prompt: style.prompt)
}
return useChineseGuidance
? chineseBuiltinCard(id: style.id)
: englishBuiltinCard(id: style.id)
}
private static func chineseBuiltinCard(id: String) -> String {
switch id {
case "builtin.structured":
return """
# 风格卡:清晰结构
用最小必要改写提高扫读性。多个独立事项可分项,连续叙述不要硬拆列表;不得改变执行顺序。
禁止添加标题、总结、建议或用户没说过的责任结论。
示例:输入「有三件事第一点修登录第二点发版本第三点通知客服」
输出「有三件事:\n1. 修复登录\n2. 发布版本\n3. 通知客服」
"""
case "builtin.formal":
return """
# 风格卡:正式表达
职业、清楚但不僵硬,去掉口头噪声;只在原文明确列举时使用列表。
禁止增加称呼、落款、寒暄、空洞管理术语或「希望能帮到你」类套话。
"""
case "builtin.chat":
return """
# 风格卡:日常聊天
像用户本人发出的即时消息:口语、简短、保留随意感。不要列表、不要分段、不要变正式。
保留有语气作用的「吧、呢、啦、哈哈」;不要增加称呼、笑点、建议或第二句话。
示例:输入「我觉得吧首先这个价格不合适其次时间也太赶了」
输出「我觉得吧,首先这个价格不合适,其次时间也太赶了。」
"""
case "builtin.dating":
return """
# 风格卡:直男癌拯救器(趣味改写)
在意图和事实不变的前提下,让恋爱聊天更自然、好接、有一点态度;允许整句重写。
禁止编造共同经历、关系承诺和对方说过的话;问句仍由用户向对方提出。
"""
case "builtin.flex":
return """
# 风格卡:装逼指南(趣味改写)
改成简短可发送的中英混合戏仿,英文只作少量调味;力度决定装感浓度。
禁止编造品牌、资产、经历,不要写成广告或英文长句。
"""
case "builtin.corp":
return """
# 风格卡:大厂黑话(趣味改写)
改成自然会议口语,可少量使用对齐、同步、owner、闭环等表达。
禁止堆砌黑话、编造责任人、威胁或事实,不要扩成 PPT 小作文。
"""
case "builtin.diba":
return """
# 风格卡:帝吧大神(趣味改写)
在已有反驳意图上增强冷幽默和拆前提力度,保持 1–3 个短句。
禁止新增攻击对象、脏话、群体攻击或用户没有表达的观点。
"""
case "builtin.xhs":
return """
# 风格卡:小红书集美(趣味改写)
改成亲切、有节奏、短段落的笔记正文;原文有多个要点时可结构化。
禁止编造体验、功效、数字、受众和前后对比;不要自动添加话题标签或 emoji。
"""
default:
return """
# 风格卡:轻度清理
只做准确、通顺、可直接发送所需的最小改动。原句清楚时只补标点。
仅在原文明示列举时使用列表;禁止扩写、总结、换人格或加入书面套话。
"""
}
}
private static func englishBuiltinCard(id: String) -> String {
switch id {
case "builtin.structured":
return """
# Style card: Clear Structure
Improve scanability with the smallest necessary rewrite. List genuinely separate items, but keep a continuous narrative as prose and preserve execution order.
Never add headings, summaries, advice, or responsibility claims.
Example: input "three things first fix login second ship the release third notify support"
output "Three things:\n1. Fix login\n2. Ship the release\n3. Notify support"
"""
case "builtin.formal":
return """
# Style card: Formal
Be professional and clear without sounding stiff. Remove speech noise; use lists only for explicit enumeration.
Never invent greetings, sign-offs, pleasantries, management jargon, or generic helper phrases.
"""
case "builtin.chat":
return """
# Style card: Daily Chat
Write a short, casual instant message in the user's own voice. Never turn it into a list, paragraphs, or formal prose.
Preserve meaningful hesitation and tone words. Do not add a greeting, joke, advice, or a second sentence.
"""
case "builtin.dating":
return """
# Style card: Dating Coach (transformative)
While preserving intent and facts, make dating chat natural, engaging, and lightly playful; a full-sentence rewrite is allowed.
Never invent shared history, commitments, or the other person's words. A question must remain the user's question.
"""
case "builtin.flex":
return """
# Style card: Flex Guide (transformative)
Produce a short, sendable parody with sparse Chinese-English code switching when the input is Chinese; intensity controls the flex.
Never invent brands, possessions, or experiences, and do not write ad copy or long English passages.
"""
case "builtin.corp":
return """
# Style card: Corp Speak (transformative)
Use concise spoken workplace language with a small amount of natural corporate shorthand.
Never dump jargon, invent owners or facts, make threats, or expand into a presentation.
"""
case "builtin.diba":
return """
# Style card: DiBa Logic (transformative)
Strengthen an existing rebuttal with cool premise-breaking humor in one to three short sentences.
Never add a target, profanity, group attack, or an opinion the user did not express.
"""
case "builtin.xhs":
return """
# Style card: Xiaohongshu (transformative)
Produce a friendly, rhythmic note body with short paragraphs; structure multiple genuine points when useful.
Never invent experiences, efficacy, numbers, an audience, or before-and-after claims. Do not add hashtags or emojis.
"""
default:
return """
# Style card: Light Clean
Make only the minimum changes needed for accuracy, fluency, and direct use. If the draft is already clear, add punctuation only.
Use a list only for explicit enumeration. Never expand, summarize, change persona, or add formal filler.
"""
}
}
private static func customChineseCard(prompt: String) -> String {
"""
# 用户自定义风格(低于核心事实与安全规则)
\(prompt)
"""
}
private static func customEnglishCard(prompt: String) -> String {
"""
# User custom style (lower priority than core factual and safety rules)
\(prompt)
"""
}
}