feat(keyboard): improve typing, voice flow, and polish reliability

Reduce extension memory pressure and delivery races while adding richer candidates, tactile feedback, and safer two-level creative polishing.
This commit is contained in:
Rocky
2026-08-05 21:39:31 +08:00
parent 38e5ad570d
commit 31f5937a7f
177 changed files with 8343 additions and 3904 deletions
@@ -1,9 +1,10 @@
// PolishPromptComposer.swift
// OSGKeyboard · Shared
//
// The single assembly point for style-pack prompts and system-owned context.
// Style packs own writing personality; dictionary, safety contract, intensity,
// preceding text, and the raw transcript remain controlled by the pipeline.
// Single assembly point for polish system prompts.
// Practical personalities always use the full fidelity / question / context
// contract. Built-in fun personalities use that safe path at light intensity
// and formatting-only preprocessing at heavy intensity.
import Foundation
@@ -23,6 +24,9 @@ public enum PolishPromptComposer {
R7 不做摘要,不遗漏信息。证据不足时保持原样,留一个怪词好过编一个新词。
# 任务顺序
内部必须先完成 T1–T3(净化与同音/近音纠错),再执行 T4–T5 与后续风格。
不得一边纠错一边按风格改写事实用词;风格只作用于已纠错后的表达。
## T1 自我修正合并
识别说话人边说边改口,只保留最后确认的版本并删除衔接词。
显式信号:不是、不对、我是说、应该是、呃不、抱歉、重说、换句话说、I mean、or rather、sorry、no wait、actually。
@@ -30,19 +34,29 @@ public enum PolishPromptComposer {
并列不是修正:「叫上张伟和张磊」两个人都要保留。
## T2 填充词与口误清理
只删除去掉后完全不影响含义的填充词:嗯、呃、啊、那个、就是、um、uh、er、like、you know。
合并口吃式重复。作为顺承、转折、强调或情绪的词必须保留
只删除去掉后完全不影响含义的填充词与口癖:嗯、呃、啊、那个、就是说、怎么说呢、然后然后、对对对、um、uh、er、like、you know。
合并口吃式重复;已被后文推翻的旧表达一并删除
必须保留:表达不确定性的「可能、大概、我觉得」;有情绪和亲疏作用的「吧、呢、啦、哈哈」;真正承担顺序、转折或强调的「然后、但是、其实」。
原则:删除噪声,不删除人的气息。
## T3 ASR 纠错
只修正有充分把握的同音、近音、断句和词典命中。低置信度专有名词保持原样
## T3 同音/近音纠错(先于风格)
主修有上下文支撑的 ASR 同音、近音错误;断句错乱一并理顺
漏字/多字仅在语义已被钉死时顺带修复;不做全文校对,也不为「更书面」换词。
优先级:用户词典命中 > 上下文高置信同音/近音 > 保持原样。
改:改完后句子仍通顺,且原意、对象、立场不变。
不改:专有名词吃不准、多种读法都成立、仅为润色好看而替换。
平衡:有把握的同音错误不要装作没看见;没把握时宁可不改,也不把对的词改错。
## T4 标点与断句
按语义补齐标点。中文使用全角标点,英文使用半角标点;不要输出无标点长段,也不要把每个短语拆成一句。
问句必须保持问句。短聊天句末可以不加句号;问号、逗号和破折号服务真实语气,不为装饰堆叠「!!!」「???」。
标点是节奏与呼吸,不是语法补丁。不主动增加 emoji(见 R5)。
## T5 结构化
## T5 结构化与分段
结构必须服从后面的风格策略。只有内容确实在列点、列步骤或记待办时才结构化。
「首先、然后、最后」用于叙述同一过程时是顺承句,不拆列表。
只有确认处于列举语境时,才可把「第2:00」等序号误识别修回「第二点」。
短文本或单一意图保持一段;长文本只在意思或关系动作真正转折时分段,不为好看硬换行。
# 停顿标记
用户消息可能含 ⟨0.8s⟩ 形式的静音时长。长停顿可提示句段边界;停顿后重复可能是改口。最终输出必须删除所有停顿标记。
@@ -51,8 +65,8 @@ public enum PolishPromptComposer {
输入:嗯那个我们下周一,不是下周二上午十点开评审会,参会的有张伟和李明
输出:我们下周二上午十点开评审会,参会的有张伟和李明。
输入:这个方案预算是三十五万,呃,我确认一下,是三十五万人民币
输出:这个方案预算是三十五万人民币
输入:我们下周在见一面把方案定下来
输出:我们下周再见一面,把方案定下来
输入:首先我们要收集数据然后清洗再做标注最后训练模型
输出:首先我们要收集数据,然后清洗,再做标注,最后训练模型。
@@ -85,6 +99,9 @@ public enum PolishPromptComposer {
R7 Never summarize or omit information. When evidence is weak, leave the wording unchanged rather than guessing.
# Ordered tasks
Internally finish T1T3 (cleanup and homophone / near-homophone repair) before T4T5 and any later style.
Do not restyle factual wording while still correcting it; style applies only after correction.
## T1 Merge self-corrections
Detect a speaker revising themselves; keep only the final confirmed version and remove the correction connector.
Explicit cues: not, no, I mean, rather, should be, sorry, let me restart, no wait, actually.
@@ -92,19 +109,29 @@ public enum PolishPromptComposer {
Coordination is not correction: in "invite Alex and Sam", keep both people.
## T2 Remove fillers and slips
Remove only fillers whose deletion cannot affect meaning: um, uh, er, like, you know, and equivalent Chinese fillers.
Collapse stuttered repetition. Preserve words that carry sequence, contrast, emphasis, hesitation, or emotion.
Remove only fillers whose deletion cannot affect meaning: um, uh, er, like, you know, and Chinese equivalents such as 嗯 / 呃 / 那个 / 就是说 / 怎么说呢.
Collapse stuttered repetition and drop wording overturned by a later correction.
Preserve uncertainty markers, soft particles that carry tone, and real sequential / contrastive connectors.
Principle: remove noise, not the speaker's breath.
## T3 Correct ASR
Fix only high-confidence homophone, near-match, segmentation, and dictionary-backed errors. Preserve uncertain proper nouns.
## T3 Homophone / near-homophone repair (before style)
Primarily fix ASR homophone and near-homophone errors backed by context; also untangle broken segmentation.
Fix missing / extra words only when the intended meaning is already locked by context. Do not proofread the whole draft or swap words merely to sound more formal.
Priority: user-dictionary hits > high-confidence contextual homophones / near-matches > leave unchanged.
Change when the repaired sentence stays natural and keeps the original meaning, referents, and stance.
Do not change uncertain proper nouns, cases where multiple readings remain plausible, or wording changed only for polish aesthetics.
Balance: do not ignore clear homophone errors; when unsure, leave the word alone rather than inventing a wrong fix.
## T4 Punctuate and segment
Add semantic punctuation using the conventions of the dominant language. Avoid both unpunctuated blocks and one sentence per fragment.
Keep questions as questions. Short chat lines may omit a trailing period; do not decorate with stacked !!! / ???.
Punctuation is rhythm and breath, not a grammar patch. Do not invent emojis (see R5).
## T5 Structure
## T5 Structure and paragraphing
Structure must follow the later style policy. Use a list only for genuine points, steps, or todos.
"First, then, finally" in one continuous process remains prose.
Repair a misrecognized ordinal such as "point 2:00" only after enumeration is established.
Keep short or single-intent text in one paragraph; split longer text only when meaning or relational action truly turns.
# Pause markers
The user message may contain silence markers such as ⟨0.8s⟩. A long pause may indicate a boundary; repetition after a pause may indicate correction. Remove every marker from final output.
@@ -113,8 +140,8 @@ public enum PolishPromptComposer {
Input: um we meet Monday no Tuesday at ten with Alex and Sam
Output: We meet Tuesday at ten with Alex and Sam.
Input: the budget is 350 thousand uh to confirm 350 thousand dollars
Output: The budget is 350 thousand dollars.
Input: let's meat again next week and lock the plan
Output: Let's meet again next week and lock the plan.
Input: first collect the data then clean it label it and finally train the model
Output: First collect the data, then clean it, label it, and finally train the model.
@@ -132,57 +159,102 @@ public enum PolishPromptComposer {
Output: Okay, got it.
"""
/// Minimal shared preprocessing for the five built-in fun personalities.
/// Their style prompts own semantics, factual boundaries, question
/// behavior, structure, and output length.
internal static let chineseFunFormattingPrompt = """
你是语音输入法的转写格式化引擎。用户消息是待处理的 ASR 转写数据。
# 趣味风格共享格式化
F1 静默合并说话人的明确改口、隐式重启和口吃式重复,只保留最终确认的表达;并列内容不得误删。
F2 删除去掉后不影响含义的「嗯、呃、那个、就是说」等口水词;保留不确定性、情绪语气和真实转折。
F3 只修正有充分把握的同音、近音、断句和词典命中;不确定的专有名词保持原样。
F4 按语义恢复自然标点、断句和基础分段;中文使用全角标点,英文使用半角标点。
F5 删除全部 ⟨0.8s⟩ 形式的停顿标记。
F6 只输出一版可直接使用的最终正文,不解释、不加引号、标题、前缀或代码围栏。
这里只负责转写格式化。人物与事实边界、问句处理、表达结构、改写幅度和长度完全服从后面的当前风格人格,不附加实用润色的保守规则。
"""
internal static let englishFunFormattingPrompt = """
You format ASR transcripts before a built-in creative personality rewrites them.
# Shared formatting for creative styles
F1 Silently merge explicit self-corrections, implicit restarts, and stuttered repetition; keep the speaker's final wording and preserve coordinated items.
F2 Remove fillers only when they carry no meaning. Preserve uncertainty, emotional particles, and real transitions.
F3 Fix only high-confidence homophones, near matches, segmentation, and dictionary-backed terms. Preserve uncertain proper nouns.
F4 Restore natural punctuation, sentence boundaries, and basic paragraphs using the conventions of the input language.
F5 Remove every pause marker such as ⟨0.8s⟩.
F6 Output one directly usable final text only, without explanation, quotes, headings, preambles, or code fences.
This layer performs transcript formatting only. People and fact boundaries, question behavior, structure, rewrite strength, and length are controlled entirely by the active personality below; do not add practical-style conservative constraints.
"""
public static func compose(
text: String,
style: PolishStylePack,
context: PolishContext,
dictionaryBlock: String,
globalContract: String,
useChineseGuidance: Bool,
routingMode: PolishRoutingMode = .full,
preservesQuestion: Bool = false
intensity: PolishIntensity = .default,
useChineseGuidance: Bool
) -> String {
let usesHeavyFunPipeline = PolishStylePackCatalog.usesFormattingOnlyPipeline(
id: style.id,
intensity: intensity
)
let core = useChineseGuidance ? chineseCorePrompt : englishCorePrompt
let stylePrompt = PolishStylePolicyResolver.styleCard(
let personality = personalitySection(
for: style,
useChineseGuidance: useChineseGuidance
).replacingOccurrences(of: PolishStylePackCatalog.dictionaryPlaceholder, with: "")
let policy = PolishStylePolicyResolver.policy(for: style)
let policyPrompt = policyBlock(policy, useChineseGuidance: useChineseGuidance)
)
let dictionaryPrompt = dictionarySection(
dictionaryBlock,
useChineseGuidance: useChineseGuidance
)
if usesHeavyFunPipeline {
let formatting = useChineseGuidance
? chineseFunFormattingPrompt
: englishFunFormattingPrompt
let outputInstruction = useChineseGuidance
? "用户消息即为待处理的转写文本。只输出当前风格处理后的最终正文。"
: "The user message is the transcript to process. Output only the final text in the active style."
return """
\(formatting)
\(dictionaryPrompt)
\(personality)
\(outputInstruction)
"""
}
let premise = contextPremise(
context.appContext,
useChineseGuidance: useChineseGuidance
)
let intensity = context.intensity.promptGuideline(styleID: style.id)
let routingBlock = PolishRouter.promptBlock(
mode: routingMode,
styleID: style.id,
useChineseGuidance: useChineseGuidance,
preservesQuestion: preservesQuestion
let questionGuard = questionGuardBlock(
for: text,
useChineseGuidance: useChineseGuidance
)
let sanitizedPreceding = context.precedingForPrompt.map(sanitizeEnvelopeContent)
let sanitizedFollowing = context.followingForPrompt.map(sanitizeEnvelopeContent)
let styleBridge = styleOrderBridge(useChineseGuidance: useChineseGuidance)
if useChineseGuidance {
return """
\(core)
\(dictionaryPrompt)
\(stylePrompt)
\(styleBridge)
\(policyPrompt)
\(personality)
\(premise)
## 本次改写力度
\(intensity)
\(routingBlock)
\(questionGuard)
\(runtimeContextBlock(
sanitizedPreceding,
@@ -198,16 +270,13 @@ public enum PolishPromptComposer {
\(dictionaryPrompt)
\(stylePrompt)
\(styleBridge)
\(policyPrompt)
\(personality)
\(premise)
## Rewrite intensity for this request
\(intensity)
\(routingBlock)
\(questionGuard)
\(runtimeContextBlock(
sanitizedPreceding,
@@ -218,6 +287,89 @@ public enum PolishPromptComposer {
"""
}
/// Reminds the model that personality runs after T1T3 correction.
private static func styleOrderBridge(useChineseGuidance: Bool) -> String {
useChineseGuidance
? """
# 风格接入(纠错之后)
以下风格只作用于已完成同音/近音纠错后的表达;不得把未确认的同音词按风格「演」成另一个意思。
"""
: """
# Style handoff (after correction)
Apply the style below only to wording already repaired for homophones / near-homophones; never restyle an unresolved ASR token into a different meaning.
"""
}
private static func questionGuardBlock(
for text: String,
useChineseGuidance: Bool
) -> String {
guard shouldPreserveQuestion(text) else { return "" }
if useChineseGuidance {
return """
# 问句守卫(本次原文是提问)
原文是用户在向别人提问或征求意见。
1. 输出必须仍然是**同一个人提出的同一个问句**,保留问号。
2. 禁止改写成陈述、评价、结论或建议(反例:「你觉得这个包怎么样」✘→「还行,挺顺眼的」)。
3. 风格化只能作用于问法本身,不得替对方作答。
"""
}
return """
# Question guard (this transcript is a question)
The user is asking someone else for their opinion.
1. The output must remain the same question asked by the same person, keeping the question mark.
2. Never turn it into a statement, verdict, or suggestion ("what do you think of this bag" ✘→ "it's fine, looks good").
3. Style may shape how the question is asked, never answer it for the other party.
"""
}
internal static func shouldPreserveQuestion(_ text: String) -> Bool {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
let opponentMarkers = [
"回他", "回她", "对方", "他说", "她说", "你说的", "你这叫", "大家都",
]
guard !opponentMarkers.contains(where: trimmed.contains) else { return false }
if trimmed.contains("") || trimmed.contains("?") { return true }
let patterns = [
#"吗[\s。!!]*$|吗[,]"#,
#"怎么样|如何|哪个|哪家|哪种|什么时候|为什么|为啥"#,
#"能不能|可不可以|要不要|行不行|是不是|有没有|好不好"#,
#"你觉得|你们觉得|大家觉得|你看呢|求推荐|求建议"#,
]
return patterns.contains { trimmed.range(of: $0, options: .regularExpression) != nil }
}
/// Style personality for the live request. Built-ins and custom packs both
/// inject their pack body (minus core-owned ASR / duplicates).
private static func personalitySection(
for style: PolishStylePack,
useChineseGuidance: Bool
) -> String {
let body = PolishStylePackCatalog.runtimePersonality(for: style)
if style.kind == .user {
return useChineseGuidance
? """
# 用户自定义风格(优先于通用清理口吻)
在不改变事实、立场与交际意图的前提下,完整执行下列用户人格;不得稀释成普通通顺清理。
\(body)
"""
: """
# User custom style (outranks generic cleanup tone)
Execute the user's personality below while preserving facts, stance, and intent; do not dilute it into plain cleanup.
\(body)
"""
}
return useChineseGuidance
? """
# 当前风格人格
\(body)
"""
: """
# Active style personality
\(body)
"""
}
/// Neutralize envelope-breaking tags inside user-controlled transcript text.
internal static func sanitizeEnvelopeContent(_ text: String) -> String {
let maxCharacters = 16_000
@@ -228,97 +380,24 @@ public enum PolishPromptComposer {
return String(neutralized.prefix(maxCharacters))
}
private static func policyBlock(
_ policy: PolishStylePolicy,
useChineseGuidance: Bool
) -> String {
if useChineseGuidance {
let mode = policy.mode == .practical
? "实用还原:每处改动都应像用户自己会打出的文字;答不上来就不要改。"
: "趣味改写:允许明显改变表达方式,但不得改变事实、立场、对象和交际意图。"
let structure: String
switch policy.structure {
case .never:
structure = "禁止列表化和为了排版而分段。即使出现「首先/其次」,也保持自然消息。"
case .onlyExplicit:
structure = "仅在原文明示列点、步骤或多项待办时结构化。"
case .encouraged:
structure = "存在多个真正独立事项时鼓励分段或列项;连续叙述仍保持自然段。"
}
let punctuation: String
switch policy.punctuation {
case .full: punctuation = "使用完整标点。"
case .light: punctuation = "使用轻标点;即时短消息句末可省句号。"
case .minimal: punctuation = "只使用理解所需的最少标点。"
}
return """
# 当前风格策略
\(mode)
\(structure)
\(punctuation)
参考长度范围:原文的 \(policy.lengthRatio.lowerBound)\(policy.lengthRatio.upperBound) 倍;不得为凑长度新增或删除信息。
"""
}
let mode = policy.mode == .practical
? "Practical restoration: every change should look like something the user would have typed; if unsure, do not change it."
: "Transformative style: expression may change clearly, but facts, stance, people, and communicative intent must not."
let structure: String
switch policy.structure {
case .never:
structure = "Never create a list or decorative paragraphs. Keep natural message form even with words such as first/second."
case .onlyExplicit:
structure = "Structure only explicit points, steps, or multiple todos."
case .encouraged:
structure = "Use paragraphs or items for genuinely independent points; keep a continuous narrative as prose."
}
let punctuation: String
switch policy.punctuation {
case .full: punctuation = "Use full punctuation."
case .light: punctuation = "Use light punctuation; a short instant message may omit the final period."
case .minimal: punctuation = "Use only punctuation necessary for understanding."
}
return """
# Active style policy
\(mode)
\(structure)
\(punctuation)
Reference length range: \(policy.lengthRatio.lowerBound)\(policy.lengthRatio.upperBound) times the input. Never add or remove information merely to hit the range.
"""
}
private static func injectDictionary(
into prompt: String,
dictionaryBlock: String,
useChineseGuidance: Bool
) -> String {
let trimmed = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
let placeholder = PolishStylePackCatalog.dictionaryPlaceholder
if trimmed.contains(placeholder) {
return trimmed.replacingOccurrences(
of: placeholder,
with: dictionarySection(dictionaryBlock, useChineseGuidance: useChineseGuidance)
)
}
guard !dictionaryBlock.isEmpty else { return trimmed }
return trimmed + "\n\n" + dictionarySection(
dictionaryBlock,
useChineseGuidance: useChineseGuidance
)
}
/// Dictionary is a priority hint; the selected shared core owns correction.
/// Empty input returns no section so correction rules are not duplicated.
private static func dictionarySection(
_ dictionaryBlock: String,
useChineseGuidance: Bool
) -> String {
guard !dictionaryBlock.isEmpty else {
return useChineseGuidance
? "# ASR 纠错\n根据上下文修正明显的同音、近音和断句错误;低置信度专有名词保持原样。"
: "# ASR correction\nFix clear homophone, near-match, and segmentation errors from context; preserve uncertain proper nouns."
}
guard !dictionaryBlock.isEmpty else { return "" }
return useChineseGuidance
? "# 用户词典(必须优先采用这些准确写法)\n\(dictionaryBlock)"
: "# User dictionary (prefer these exact spellings)\n\(dictionaryBlock)"
? """
# 用户词典(必须优先采用这些准确写法)
词典命中优先于同音猜测;未命中时仍按共享纠错规则处理。
\(dictionaryBlock)
"""
: """
# User dictionary (prefer these exact spellings)
Dictionary hits outrank homophone guesses; when nothing matches, use the shared correction rules.
\(dictionaryBlock)
"""
}
private static func contextPremise(