feat: add streaming cloud ASR, polish routing, and settings card layout

Unify Bailian/Volcengine/OpenAI realtime streaming, ABE polish routing with
fun styles, and a shared card-page Settings hierarchy; bump to 1.1 (build 32).
This commit is contained in:
Rocky
2026-07-28 20:25:17 +08:00
parent 956331a2af
commit d656bac8c3
58 changed files with 4153 additions and 1396 deletions
@@ -0,0 +1,359 @@
// PolishRouter.swift
// OSGKeyboard · Shared
//
// Pre-LLM routing for polish: information-density gate (A), prompt
// hard-brake blocks (B), and style-specific degradation (E). Keeps a
// single LLM round-trip decisions are local and zero-latency.
import Foundation
/// How aggressively the polish prompt may rewrite this utterance.
public enum PolishRoutingMode: String, Sendable, Equatable {
/// Normal style + intensity.
case full
/// Sparse input: force Light and forbid style theater / invented facts.
case conservative
/// Fun style cannot run (e.g. DiBa with no opponent quote) chat cleanup.
case chatFallback
}
/// Result of ABE routing for one polish request.
public struct PolishRouteDecision: Sendable, Equatable {
public let mode: PolishRoutingMode
public let effectiveStyleID: String
public let effectiveIntensity: PolishIntensity
public let reasons: [String]
public init(
mode: PolishRoutingMode,
effectiveStyleID: String,
effectiveIntensity: PolishIntensity,
reasons: [String]
) {
self.mode = mode
self.effectiveStyleID = effectiveStyleID
self.effectiveIntensity = effectiveIntensity
self.reasons = reasons
}
}
public enum PolishRouter {
/// Decide polish mode / intensity / style remapping before prompt assembly.
public static func decide(
text: String,
styleID: String,
intensity: PolishIntensity
) -> PolishRouteDecision {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
var reasons: [String] = []
let sparse = isInformationSparse(trimmed)
// Practical non-chat styles keep full routing; chat still gets
// sparse conservative so it cannot invent interlocutor replies.
if styleID == "builtin.chat" {
if sparse {
reasons.append("A:sparse")
reasons.append("E:chat_no_reply")
return PolishRouteDecision(
mode: .conservative,
effectiveStyleID: styleID,
effectiveIntensity: .light,
reasons: reasons
)
}
return PolishRouteDecision(
mode: .full,
effectiveStyleID: styleID,
effectiveIntensity: intensity,
reasons: ["pass"]
)
}
if styleID == "builtin.light"
|| styleID == "builtin.structured"
|| styleID == "builtin.formal" {
return PolishRouteDecision(
mode: .full,
effectiveStyleID: styleID,
effectiveIntensity: intensity,
reasons: ["practical_full"]
)
}
if sparse {
reasons.append("A:sparse")
}
// E: DiBa without an opponent claim chat cleanup.
if styleID == "builtin.diba", !hasOpponentQuote(trimmed) {
reasons.append("E:diba_no_opponent")
return PolishRouteDecision(
mode: .chatFallback,
effectiveStyleID: "builtin.chat",
effectiveIntensity: .light,
reasons: reasons
)
}
// E: note / flirt / buzzword styles with hollow short input.
if sparse {
switch styleID {
case "builtin.xhs" where !hasConcreteEntity(trimmed):
reasons.append("E:xhs_no_topic")
case "builtin.dating":
reasons.append("E:dating_short_no_flirt")
case "builtin.corp" where !hasConcreteEntity(trimmed),
"builtin.flex" where !hasConcreteEntity(trimmed):
let shortName = styleID.replacingOccurrences(of: "builtin.", with: "")
reasons.append("E:\(shortName)_no_subject")
default:
break
}
return PolishRouteDecision(
mode: .conservative,
effectiveStyleID: styleID,
effectiveIntensity: .light,
reasons: reasons
)
}
return PolishRouteDecision(
mode: .full,
effectiveStyleID: styleID,
effectiveIntensity: intensity,
reasons: reasons.isEmpty ? ["pass"] : reasons
)
}
/// Prompt block injected after intensity / before the global contract.
public static func promptBlock(
mode: PolishRoutingMode,
styleID: String,
useChineseGuidance: Bool
) -> String {
var parts: [String] = []
if PolishStylePackCatalog.isFunPersonality(id: styleID)
|| styleID == "builtin.chat" {
parts.append(sparseHardBrake(useChineseGuidance: useChineseGuidance))
parts.append(antiExampleBlock(useChineseGuidance: useChineseGuidance))
}
if styleID == "builtin.chat" {
parts.append(chatNoReplyBlock(useChineseGuidance: useChineseGuidance))
}
switch styleID {
case "builtin.xhs":
parts.append(xhsDegradeBlock(useChineseGuidance: useChineseGuidance))
case "builtin.dating":
parts.append(datingDegradeBlock(useChineseGuidance: useChineseGuidance))
case "builtin.diba":
parts.append(dibaDegradeBlock(useChineseGuidance: useChineseGuidance))
case "builtin.corp":
parts.append(corpDegradeBlock(useChineseGuidance: useChineseGuidance))
case "builtin.flex":
parts.append(flexDegradeBlock(useChineseGuidance: useChineseGuidance))
default:
break
}
switch mode {
case .conservative:
parts.append(conservativeModeBlock(useChineseGuidance: useChineseGuidance))
case .chatFallback:
parts.append(chatFallbackModeBlock(useChineseGuidance: useChineseGuidance))
case .full:
break
}
return parts
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: "\n\n")
}
// MARK: - Density signals
public static func isInformationSparse(_ text: String) -> Bool {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return true }
// Questions / invites / reply-shaped lines are not "empty" keep full polish.
if hasOpponentQuote(trimmed) || hasCommunicativeSignal(trimmed) {
return false
}
let cjk = cjkCount(trimmed)
if cjk > 0 {
if cjk <= 4 { return true }
if cjk <= 10, !hasConcreteEntity(trimmed) {
return true
}
if cjk <= 12, !hasConcreteEntity(trimmed) {
let stripped = stripHollowTokens(trimmed)
if cjkCount(stripped) <= 4 { return true }
}
return false
}
let words = trimmed.split(whereSeparator: { $0.isWhitespace })
return words.count <= 3 && trimmed.count <= 16
}
public static func hasOpponentQuote(_ text: String) -> Bool {
let markers = ["回他", "回她", "对方", "他说", "她说", "你说的", "你这叫", "大家都"]
return markers.contains { text.contains($0) }
}
public static func hasConcreteEntity(_ text: String) -> Bool {
let entities = [
"面膜", "防晒", "口红", "粉底", "洗发", "咖啡", "火锅", "酒店", "餐厅",
"方案", "接口", "测试", "Key", "老板", "电影", "地铁", "快递", "会议",
"周报", "加班", "机票", "医院", "课程", "健身", "外卖", "微信", "项目",
"发布", "文档", "密码", "充电器", "门卡",
]
return entities.contains { text.contains($0) }
}
public static func hasCommunicativeSignal(_ text: String) -> Bool {
if text.contains("") || text.contains("?") { return true }
let patterns = [
#"吗|么|怎么|什么|哪|谁|为何|为什么|为啥"#,
#"能不能|可不可以|要不要|行不行"#,
#"回他|回她"#,
#"约|见面|吃饭|电影"#,
]
for pattern in patterns {
if text.range(of: pattern, options: .regularExpression) != nil {
return true
}
}
return false
}
// MARK: - Prompt fragments
private static func sparseHardBrake(useChineseGuidance: Bool) -> String {
if useChineseGuidance {
return """
# 信息不足时的硬刹车(优先级高于出味与力度跳变)
若原文信息密度不足(极短、缺对象/主题、只有评价或情绪词、无可改写的事实核):
1. 只做口头禅清理与标点恢复,输出长度贴近原文(±30% 以内)。
2. 禁止钩子开头、分段小作文、评论区互动、亲测细节、暧昧加戏、虚构对手论点或会议流程。
3. 宁可「不够味」也不可「编故事」;此时忽略 Light/Medium/Heavy 的跳变要求。
"""
}
return """
# Sparse-input hard brake (outranks style flavor and intensity jumps)
When the transcript is information-sparse (very short, no topic/object, only evaluation/mood words):
1. Only clean fillers and restore punctuation; keep length within ±30% of the original.
2. Do not invent hooks, essays, CTAs, lived-experience details, flirtation, opponent claims, or meeting workflows.
3. Prefer under-flavored over fabricated; ignore Light/Medium/Heavy jump requirements in this case.
"""
}
private static func antiExampleBlock(useChineseGuidance: Bool) -> String {
if useChineseGuidance {
return """
# 反例(禁止)
- 「香香的」✘→ 编闺蜜安利、喷手腕、同事问香水
- 「踩坑了」✘→ 编博主种草与性价比剧情
- 「还行」✘→ 扩成暧昧句或闭环会议发言
- 「嗯」/「没事」✘→「我在呢」「那就好」(禁止接话续写)
"""
}
return """
# Counterexamples (forbidden)
- "smells nice" ✘→ invent friend recommendations or usage scenes
- "got burned" ✘→ invent influencer / value narratives
- "fine" ✘→ expand into flirtation or meeting jargon
- "mm" / "it's fine" ✘→ invent interlocutor replies
"""
}
private static func chatNoReplyBlock(useChineseGuidance: Bool) -> String {
if useChineseGuidance {
return """
# 日常聊天专属:禁止接话
输入是用户要发出的消息草稿,不是对方发来的消息。
不要以聊天对象身份接话、附和、安慰或反问。
极短确认/状态词:近原样输出,禁止续写第二句。
"""
}
return """
# Daily chat: no interlocutor replies
Input is the user's outbound draft, not a message from someone else.
Do not answer, affirm, comfort, or ask follow-ups as the other party.
Ultra-short confirmations/status words: stay near-verbatim; never add a second invented sentence.
"""
}
private static func xhsDegradeBlock(useChineseGuidance: Bool) -> String {
useChineseGuidance
? "# 小红书专属降级\n无明确主题/产品/对象时:禁止笔记结构、CTA 与「姐妹们/集美们」堆砌;禁止从示例抄入原文没有的细节。"
: "# RED Note degrade\nWithout a clear topic/product/object: no note structure, CTA, or sisterly openers; do not copy example-only details."
}
private static func datingDegradeBlock(useChineseGuidance: Bool) -> String {
useChineseGuidance
? "# 直男癌专属降级\n极短关心/评价/确认:禁止暧昧、挑逗、欲擒故纵;本条优先于「原文很干也要完整发挥」。"
: "# Dating degrade\nUltra-short care/praise/acks: no flirtation or push-pull; this outranks “rewrite dry input fully”."
}
private static func dibaDegradeBlock(useChineseGuidance: Bool) -> String {
useChineseGuidance
? "# 帝吧专属降级\n检测不到对方原话或可拆论点时:禁止拆前提与高级黑模板;只做最短清理。"
: "# DiBa degrade\nWithout an opponent claim: no premise-breaking templates; shortest cleanup only."
}
private static func corpDegradeBlock(useChineseGuidance: Bool) -> String {
useChineseGuidance
? "# 大厂黑话专属降级\n无事项主语时:禁止发明 owner/交界面/闭环指令;最多一个黑话点缀或短清理。"
: "# Corp degrade\nWithout a concrete matter: do not invent owners/interfaces/闭环 directives; at most one buzzword or short cleanup."
}
private static func flexDegradeBlock(useChineseGuidance: Bool) -> String {
useChineseGuidance
? "# 装逼指南专属降级\n无评价对象时:禁止整句英文与虚构品牌;最多一个英文词或短清理。"
: "# Flex degrade\nWithout an evaluation target: no full-English dumps or invented brands; at most one English seasoning word."
}
private static func conservativeModeBlock(useChineseGuidance: Bool) -> String {
useChineseGuidance
? "## 本次模式:保守清理\n输入已判定信息不足。忽略风格出味与力度跳变。只输出贴近原文的短句(±30%),禁止扩写与接话。"
: "## Mode: conservative cleanup\nInput is information-sparse. Ignore style flavor and intensity jumps. Output a near-original short line (±30%); no expansion or interlocutor replies."
}
private static func chatFallbackModeBlock(useChineseGuidance: Bool) -> String {
useChineseGuidance
? "## 本次模式:降级为日常清理\n原趣味风格不适用(例如帝吧无对方原话)。按日常聊天最短清理输出,禁止接话续写。"
: "## Mode: fall back to daily-chat cleanup\nThe fun style does not apply (e.g. DiBa without an opponent quote). Shortest daily-chat cleanup only; no invented replies."
}
// MARK: - Helpers
private static func cjkCount(_ text: String) -> Int {
text.unicodeScalars.filter(isCJKScalar).count
}
private static func isCJKScalar(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.value {
case 0x4E00...0x9FFF, 0x3400...0x4DBF, 0xF900...0xFAFF:
return true
default:
return false
}
}
private static let hollowTokens = [
"怎么说呢", "就是说", "然后那个", "嗯那个", "那个", "一下", "感觉",
"", "", "", "", "", "", "", "这个",
]
private static func stripHollowTokens(_ text: String) -> String {
var result = text
for token in hollowTokens.sorted(by: { $0.count > $1.count }) {
result = result.replacingOccurrences(of: token, with: "")
}
return result.trimmingCharacters(in: .whitespacesAndNewlines)
}
}