feat(ai): unify reply center and refresh keyboard AI features
- Merge invitation, task, blessing, clarification, and empathy actions into a single Reply flow, with three fixed, clearly labeled stance choices whenever user intent must not be guessed. - Refine clipboard semantic routing with bilingual schedule, confirmation, and follow-up models, conservative language thresholds, and explicit-assignment guard for complaint-only text. - Persist Apple account refresh state, harden session recovery, and surface durable account diagnostics across keyboard and app. - Derive personal-style prompts through two-stage corpus evidence and apply real low-confidence ASR tendencies instead of neutral templates. - Localize the new reply center, clipboard semantics, and personal-style surfaces in both English and Simplified Chinese.
This commit is contained in:
@@ -41,6 +41,142 @@ public struct AIClipboardReplyStyleContext: Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// A semantic modifier for the generic Reply action. Scenes change how the
|
||||
/// reply is expressed without creating another user-facing keyboard action.
|
||||
public enum AIClipboardReplyScene: Equatable, Sendable {
|
||||
case invitation
|
||||
case task
|
||||
case blessing
|
||||
case clarification
|
||||
case complaint
|
||||
case negativeQuestion
|
||||
|
||||
public static func resolve(from analysis: ClipboardSemanticAnalysis) -> Self? {
|
||||
if analysis.complaint.isDetected,
|
||||
analysis.complaint.isApprovedForAutomaticRouting {
|
||||
return .complaint
|
||||
}
|
||||
if analysis.invitation.isDetected,
|
||||
analysis.invitation.isApprovedForAutomaticRouting {
|
||||
return .invitation
|
||||
}
|
||||
if analysis.blessing.isDetected,
|
||||
analysis.blessing.isApprovedForAutomaticRouting {
|
||||
return .blessing
|
||||
}
|
||||
if [
|
||||
analysis.confirmationDecision,
|
||||
analysis.followUpReminder,
|
||||
analysis.task
|
||||
].contains(where: {
|
||||
$0.isDetected && $0.isApprovedForAutomaticRouting
|
||||
}) {
|
||||
return .task
|
||||
}
|
||||
if analysis.sentiment == .negative,
|
||||
analysis.question.isDetected,
|
||||
analysis.question.isApprovedForAutomaticRouting {
|
||||
return .negativeQuestion
|
||||
}
|
||||
if [
|
||||
analysis.scheduleNegotiation,
|
||||
analysis.question
|
||||
].contains(where: {
|
||||
$0.isDetected && $0.isApprovedForAutomaticRouting
|
||||
}) {
|
||||
return .clarification
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public var requiresIntentVariants: Bool {
|
||||
switch self {
|
||||
case .invitation, .task, .blessing, .clarification:
|
||||
return true
|
||||
case .complaint, .negativeQuestion:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func instruction(locale: String) -> String {
|
||||
let zh = locale == "zh"
|
||||
switch self {
|
||||
case .invitation:
|
||||
return zh
|
||||
? """
|
||||
<reply_scene type="invitation">
|
||||
场景修饰:对方正在发出邀约。三个候选必须分别表达接受、婉拒和暂不确定;不得替用户编造已有安排、拒绝理由、同行人、时间承诺或地点承诺。接受候选可确认原文已有的时间地点;婉拒候选可以简短感谢但不过度道歉;待定候选只说明需要确认,不虚构何时能答复。
|
||||
</reply_scene>
|
||||
"""
|
||||
: """
|
||||
<reply_scene type="invitation">
|
||||
Scene modifier: the sender is making an invitation. The three variants must respectively accept, decline, and stay tentative. Never invent the user's schedule, reason for declining, companion, or time/place commitment. The accepting variant may confirm source-supported details; the declining variant may briefly thank without over-apologizing; the tentative variant may say the user needs to check without inventing when they will decide.
|
||||
</reply_scene>
|
||||
"""
|
||||
case .task:
|
||||
return zh
|
||||
? """
|
||||
<reply_scene type="task">
|
||||
场景修饰:对方正在提出任务、行动请求、确认事项或跟进提醒。三个候选必须分别表达确认处理、追问关键信息和协商范围或时间;不得虚构已经完成、确定截止时间、负责人、能力或承诺。仅复用原文明确给出的事项和期限。
|
||||
</reply_scene>
|
||||
"""
|
||||
: """
|
||||
<reply_scene type="task">
|
||||
Scene modifier: the sender is assigning a task, requesting action, asking for confirmation, or following up. The three variants must respectively acknowledge, ask for essential clarification, and negotiate scope or timing. Never invent completion, deadlines, ownership, capability, or commitments. Reuse only task and timing details stated in the source.
|
||||
</reply_scene>
|
||||
"""
|
||||
case .blessing:
|
||||
return zh
|
||||
? """
|
||||
<reply_scene type="blessing">
|
||||
场景修饰:对方正在表达节日、生日或人生事件祝福。三个候选必须分别是真诚感谢并回祝、简短温暖回应和轻松活泼回应。先判断用户是否是祝福对象;若群聊在祝福第三方,只能以群成员身份接一句祝福。不得虚构关系、共同经历或承诺。
|
||||
</reply_scene>
|
||||
"""
|
||||
: """
|
||||
<reply_scene type="blessing">
|
||||
Scene modifier: the sender is sharing a holiday, birthday, or life-event wish. The three variants must respectively thank and return the wish, respond briefly and warmly, and respond lightly and playfully. First determine whether the user is the recipient; when a group is wishing someone else, join only as a group member. Invent no relationship, shared history, or commitment.
|
||||
</reply_scene>
|
||||
"""
|
||||
case .clarification:
|
||||
return zh
|
||||
? """
|
||||
<reply_scene type="clarification">
|
||||
场景修饰:当前问题、日程协商或请求缺少作答或执行所需的信息。三个候选必须分别直接回应当前能够确认的部分、追问一个最关键缺口,以及先简短确认理解再追问;每个候选最多两个问题。不得把猜测当成答案,也不要写成表单、审问或客服问卷。
|
||||
</reply_scene>
|
||||
"""
|
||||
: """
|
||||
<reply_scene type="clarification">
|
||||
Scene modifier: the question, schedule negotiation, or request lacks information needed to answer or act. The three variants must respectively respond to what can already be confirmed, ask one key missing detail, and briefly confirm understanding before asking. Use at most two questions per variant. Never present a guess as an answer or sound like a form, interrogation, or support questionnaire.
|
||||
</reply_scene>
|
||||
"""
|
||||
case .complaint:
|
||||
return zh
|
||||
? """
|
||||
<reply_scene type="complaint">
|
||||
场景修饰:对方正在表达明确不满。先用日常口语接住对方的情绪,再直接回应核心问题;仅在原文支持时给出稳妥下一步。避免“深表歉意”“给您带来不便”等客服模板,不推诿、不淡化问题,也不虚构责任、进度或承诺。多回复模式下,所有候选都必须保持这一共情立场;“轻松趣味”只能更口语,不能开玩笑、调侃对方或使用 playful 情绪。
|
||||
</reply_scene>
|
||||
"""
|
||||
: """
|
||||
<reply_scene type="complaint">
|
||||
Scene modifier: the sender is expressing clear frustration. First acknowledge the emotion in everyday language, then respond directly to the core issue and offer a safe next step only when supported by the source. Avoid canned support phrases, deflection, minimizing the problem, and invented responsibility, progress, or promises. In multi-reply mode every variant must keep this empathetic stance; playful may only sound more conversational and must not joke, tease the sender, or use the playful emotion.
|
||||
</reply_scene>
|
||||
"""
|
||||
case .negativeQuestion:
|
||||
return zh
|
||||
? """
|
||||
<reply_scene type="negative_question">
|
||||
场景修饰:对方的问题带有着急、不满或困扰。先简短接住这种感受,再直接回答或说明下一步;不要因为语气负面就默认用户有错,不要无依据道歉、认责或承诺。多回复模式下,所有候选都必须保持克制和体谅;“轻松趣味”不能开玩笑、调侃对方或使用 playful 情绪。
|
||||
</reply_scene>
|
||||
"""
|
||||
: """
|
||||
<reply_scene type="negative_question">
|
||||
Scene modifier: the question carries urgency, frustration, or concern. Briefly acknowledge that feeling, then answer directly or state the next step. A negative tone alone does not prove the user is at fault, so do not apologize, accept blame, or promise anything without source support. In multi-reply mode every variant must stay measured and considerate; playful must not joke, tease the sender, or use the playful emotion.
|
||||
</reply_scene>
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
|
||||
public let id: String
|
||||
public let systemImage: String
|
||||
@@ -133,32 +269,20 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
public static let declineInvitationID = "declineInvitation"
|
||||
public static let acceptTaskID = "acceptTask"
|
||||
public static let clarifyRequestID = "clarifyRequest"
|
||||
/// Legacy ID consolidated into `replyID`.
|
||||
public static let empathyReplyID = "empathyReply"
|
||||
public static let blessingReplyID = "blessingReply"
|
||||
/// Legacy ID consolidated into `clarifyRequestID`.
|
||||
public static let askForDetailsID = "askForDetails"
|
||||
public static let businessReplyID = "businessReply"
|
||||
public static let organizeListID = "organizeList"
|
||||
public static let replyStyleSkillIDs: Set<String> = [
|
||||
replyID,
|
||||
acceptInvitationID,
|
||||
declineInvitationID,
|
||||
acceptTaskID,
|
||||
clarifyRequestID,
|
||||
empathyReplyID,
|
||||
blessingReplyID
|
||||
]
|
||||
public static let replyStyleSkillIDs: Set<String> = [replyID]
|
||||
/// Contextual system actions remain available to semantic ranking but are
|
||||
/// not user-managed entries in the host app's Skills catalog.
|
||||
public static let hiddenFromSkillManagementIDs: Set<String> = [
|
||||
replyID,
|
||||
declineInvitationID,
|
||||
empathyReplyID,
|
||||
blessingReplyID,
|
||||
acceptInvitationID,
|
||||
callPhoneID,
|
||||
createContactID,
|
||||
clarifyRequestID
|
||||
createContactID
|
||||
]
|
||||
public static let extractTodosID = "extractTodos"
|
||||
public static let extractTodosShortcutName = "OSGExtractTodos"
|
||||
@@ -239,60 +363,6 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: acceptInvitationID,
|
||||
systemImage: "checkmark.bubble.fill",
|
||||
titleKey: "keyboard.ai.skill.acceptInvitation",
|
||||
cardTitleKey: "skills.acceptInvitation.name",
|
||||
descriptionKey: "skills.acceptInvitation.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: declineInvitationID,
|
||||
systemImage: "hand.raised.fill",
|
||||
titleKey: "keyboard.ai.skill.declineInvitation",
|
||||
cardTitleKey: "skills.declineInvitation.name",
|
||||
descriptionKey: "skills.declineInvitation.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: acceptTaskID,
|
||||
systemImage: "checkmark.circle.fill",
|
||||
titleKey: "keyboard.ai.skill.acceptTask",
|
||||
cardTitleKey: "skills.acceptTask.name",
|
||||
descriptionKey: "skills.acceptTask.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: clarifyRequestID,
|
||||
systemImage: "questionmark.bubble.fill",
|
||||
titleKey: "keyboard.ai.skill.clarifyRequest",
|
||||
cardTitleKey: "skills.clarifyRequest.name",
|
||||
descriptionKey: "skills.clarifyRequest.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: empathyReplyID,
|
||||
systemImage: "heart.fill",
|
||||
titleKey: "keyboard.ai.skill.empathyReply",
|
||||
cardTitleKey: "skills.empathyReply.name",
|
||||
descriptionKey: "skills.empathyReply.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: blessingReplyID,
|
||||
systemImage: "party.popper.fill",
|
||||
titleKey: "keyboard.ai.skill.blessingReply",
|
||||
cardTitleKey: "skills.blessingReply.name",
|
||||
descriptionKey: "skills.blessingReply.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: organizeListID,
|
||||
systemImage: "list.bullet.rectangle",
|
||||
@@ -349,6 +419,15 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
/// Hidden compatibility objects for stale direct lookups. They are not
|
||||
/// part of `catalog`, defaults, skill management, or keyboard visibility.
|
||||
private static let legacyReplySkills: [String: AIClipboardSkill] = [
|
||||
empathyReplyID: AIClipboardSkill(
|
||||
id: empathyReplyID,
|
||||
systemImage: "heart.fill",
|
||||
titleKey: "keyboard.ai.skill.empathyReply",
|
||||
cardTitleKey: "skills.empathyReply.name",
|
||||
descriptionKey: "skills.empathyReply.description",
|
||||
kind: .transform,
|
||||
isDefault: false
|
||||
),
|
||||
playfulReplyID: AIClipboardSkill(
|
||||
id: playfulReplyID,
|
||||
systemImage: "theatermasks.fill",
|
||||
@@ -374,12 +453,19 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
|
||||
public static func canonicalID(for id: String) -> String {
|
||||
switch id {
|
||||
case replyInSourceLanguageID, playfulReplyID, businessReplyID:
|
||||
case replyInSourceLanguageID,
|
||||
playfulReplyID,
|
||||
businessReplyID,
|
||||
empathyReplyID,
|
||||
acceptInvitationID,
|
||||
declineInvitationID,
|
||||
acceptTaskID,
|
||||
clarifyRequestID,
|
||||
blessingReplyID,
|
||||
askForDetailsID:
|
||||
return replyID
|
||||
case extractConclusionsID:
|
||||
return summarizeID
|
||||
case askForDetailsID:
|
||||
return clarifyRequestID
|
||||
default:
|
||||
return id
|
||||
}
|
||||
@@ -425,7 +511,7 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
).first { $0.id == resolvedID }
|
||||
}
|
||||
|
||||
/// `enabledIDs` is the Skills-tab order. `nil` keeps the default three.
|
||||
/// `enabledIDs` is the Skills-tab order. `nil` keeps current defaults.
|
||||
/// An explicit empty array shows no chips (carousel fallback).
|
||||
public static func visible(
|
||||
enabledIDs: [String]? = nil,
|
||||
@@ -457,6 +543,7 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
locale: String,
|
||||
translationTargetLocaleId: String,
|
||||
replyStyle: AIClipboardReplyStyleContext? = nil,
|
||||
replyScene: AIClipboardReplyScene? = nil,
|
||||
preferredLanguages: [String] = Locale.preferredLanguages,
|
||||
now: Date = Date()
|
||||
) -> String {
|
||||
@@ -479,7 +566,8 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
baseInstruction,
|
||||
skillID: skill.id,
|
||||
locale: locale,
|
||||
style: replyStyle
|
||||
style: replyStyle,
|
||||
scene: canonicalID(for: skill.id) == replyID ? replyScene : nil
|
||||
)
|
||||
}
|
||||
|
||||
@@ -535,30 +623,6 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
locale: locale,
|
||||
preferredLanguages: preferredLanguages
|
||||
)
|
||||
case acceptInvitationID:
|
||||
return zh
|
||||
? "请自然、爽快地接受剪贴板中的邀约,像聊天一样确认必要的时间或地点。不要客套过头,也不要虚构用户的安排。"
|
||||
: "Accept the invitation in a relaxed, natural chat tone and confirm any necessary time or place. Avoid excessive pleasantries and invented plans."
|
||||
case declineInvitationID:
|
||||
return zh
|
||||
? "请用自然、不端着的口吻婉拒剪贴板中的邀约。可以简单表达感谢,但不要过度道歉、长篇解释或虚构理由。"
|
||||
: "Decline the invitation naturally without sounding stiff. A brief thank-you is fine; avoid excessive apology, long explanations, or invented reasons."
|
||||
case acceptTaskID:
|
||||
return zh
|
||||
? "请像聊天一样简短确认收到剪贴板中的任务或行动请求,可自然带上事项和截止时间。不要写成正式回执,也不要虚构承诺。"
|
||||
: "Acknowledge the task or action request in a short chat-style reply, naturally confirming the work and deadline. Do not sound like a formal receipt or invent commitments."
|
||||
case clarifyRequestID:
|
||||
return zh
|
||||
? "请理解剪贴板中的问题、任务或故障描述,找出回答、执行、定位或解决前最缺的关键信息,用自然聊天口吻最多追问两个最必要的问题。问题要简短、不重复,不要像表单、审问或客服问卷。"
|
||||
: "Understand the question, task, or problem in the clipboard, identify the key information missing before answering, acting, diagnosing, or resolving it, and ask at most two essential questions in a natural chat tone. Keep them short and non-repetitive, not like a form, interrogation, or support questionnaire."
|
||||
case empathyReplyID:
|
||||
return zh
|
||||
? "请先用日常口语接住对方的不满,再确认核心问题并给出稳妥下一步。避免“深表歉意”“给您带来不便”等客服模板,不推诿或过度承诺。"
|
||||
: "Respond to the frustration in everyday language, acknowledge the core issue, and give a safe next step. Avoid canned support phrases, deflection, and overpromising."
|
||||
case blessingReplyID:
|
||||
return zh
|
||||
? "请根据剪贴板中的祝福写一段简短、自然、可直接发送的回复。若祝福是发给用户的,先真诚感谢,再自然回祝;若群聊里是在祝福第三方,就以群成员身份接一句祝福,不要假装自己是收件人。保留节日、生日或人生事件,不虚构关系、经历和承诺。"
|
||||
: "Write a short, natural, sendable response to the blessing in the clipboard. If it is addressed to the user, thank the sender sincerely and return an appropriate wish. If a group message blesses someone else, join the wish as a group member without pretending to be the recipient. Preserve the holiday, birthday, or life event, and invent no relationship, history, or commitment."
|
||||
case businessReplyID:
|
||||
return zh
|
||||
? "请写一段专业但不官腔的商务聊天回复,表达直接、自然,保留人名、组织名、时间和承诺边界,可直接发送。不要套用正式邮件开场和结尾。"
|
||||
@@ -671,7 +735,8 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
_ baseInstruction: String,
|
||||
skillID: String,
|
||||
locale: String,
|
||||
style: AIClipboardReplyStyleContext?
|
||||
style: AIClipboardReplyStyleContext?,
|
||||
scene: AIClipboardReplyScene?
|
||||
) -> String {
|
||||
let zh = locale == "zh"
|
||||
let conversationalBaseline: String
|
||||
@@ -692,9 +757,10 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
Voice baseline: sound like an ordinary person chatting naturally with a friend, close friend, or colleague. Match the relationship without putting on a voice, and never sound like a memo, support template, or AI. Prefer short sentences, everyday wording, and natural conversational cues. When the message clearly carries warmth, comfort, frustration, apology, or another emotion, one fitting emoji may be used naturally; never force or stack emojis. Usually write 1–3 sentences with no title, quotation marks, or explanation.
|
||||
"""
|
||||
}
|
||||
let sceneInstruction = scene.map { "\n\($0.instruction(locale: locale))" } ?? ""
|
||||
guard let style,
|
||||
!style.prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return "\(baseInstruction)\n\(conversationalBaseline)"
|
||||
return "\(baseInstruction)\n\(conversationalBaseline)\(sceneInstruction)"
|
||||
}
|
||||
let boundedStyle = String(
|
||||
style.prompt
|
||||
@@ -714,7 +780,7 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
</user_reply_style>
|
||||
Apply only stable wording, rhythm, and expression habits from this style. It must not change the selected skill's intent, facts, safety boundaries, or output language; the selected skill wins on conflict.
|
||||
"""
|
||||
return "\(baseInstruction)\n\(conversationalBaseline)\n\(personalStyle)"
|
||||
return "\(baseInstruction)\n\(conversationalBaseline)\(sceneInstruction)\n\(personalStyle)"
|
||||
}
|
||||
|
||||
/// Clipboard translation always follows the device's primary system language.
|
||||
|
||||
@@ -318,6 +318,8 @@ public struct AIQuestionService: Sendable {
|
||||
switch error {
|
||||
case .cancelled:
|
||||
return .cancelled
|
||||
case .timeout:
|
||||
return .timeout
|
||||
case .transport, .rateLimited:
|
||||
return .network
|
||||
case .invalidURL, .noAPIKey, .decoding:
|
||||
|
||||
@@ -107,6 +107,8 @@ public struct AnthropicMessagesClient: LLMClient {
|
||||
throw LLMError.cancelled
|
||||
} catch let urlError as URLError where urlError.code == .cancelled {
|
||||
throw LLMError.cancelled
|
||||
} catch let urlError as URLError where urlError.code == .timedOut {
|
||||
throw LLMError.timeout
|
||||
} catch {
|
||||
throw LLMError.transport(String(describing: error))
|
||||
}
|
||||
|
||||
@@ -458,6 +458,9 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
// v9 consolidates playful and business reply into Reply. Do not
|
||||
// add Reply here: `sanitized` preserves it only when any reply ID
|
||||
// was enabled, so a user's explicit disabled state stays disabled.
|
||||
// v10 applies the same canonical migration to Empathetic Reply.
|
||||
// v11 folds invitation, task, blessing, and clarification replies
|
||||
// into Reply. `sanitized` again preserves explicit disabled state.
|
||||
let additions = catalog.map(\.id).filter {
|
||||
additionIDs.contains($0) && !decoded.enabledIDs.contains($0)
|
||||
}
|
||||
@@ -483,7 +486,7 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private static let currentAgentSkillDefaultsMigrationVersion = 9
|
||||
private static let currentAgentSkillDefaultsMigrationVersion = 11
|
||||
|
||||
private static func decodeUserSkillCatalog(from defaults: UserDefaults) -> AIUserSkillCatalog {
|
||||
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog) else {
|
||||
|
||||
@@ -12,6 +12,18 @@ public struct ClipboardReplyCandidateSnapshot: Codable, Equatable, Identifiable,
|
||||
case ordinary
|
||||
case formal
|
||||
case playful
|
||||
case invitationAccept
|
||||
case invitationDecline
|
||||
case invitationTentative
|
||||
case taskAcknowledge
|
||||
case taskClarify
|
||||
case taskNegotiate
|
||||
case blessingReturn
|
||||
case blessingWarm
|
||||
case blessingPlayful
|
||||
case clarificationDirect
|
||||
case clarificationQuestion
|
||||
case clarificationConfirm
|
||||
}
|
||||
|
||||
public let id: UUID
|
||||
@@ -121,12 +133,29 @@ public final class ClipboardReplyFeedbackStore {
|
||||
now: Date = Date()
|
||||
) -> [PolishStyleReplyLearningExample] {
|
||||
records(now: now).compactMap { record in
|
||||
guard record.outcome != .awaitingSelection,
|
||||
let ordinary = record.candidates.first(where: {
|
||||
$0.kind == .ordinary
|
||||
}) else {
|
||||
guard record.outcome != .awaitingSelection else {
|
||||
return nil
|
||||
}
|
||||
guard let ordinary = record.candidates.first(where: {
|
||||
$0.kind == .ordinary
|
||||
}) else {
|
||||
// Scene-specific choices express the user's decision, not a
|
||||
// reusable tone preference. Only a later user-authored edit is
|
||||
// valid personal-style evidence.
|
||||
guard record.outcome == .selected,
|
||||
let selected = record.selectedCandidate,
|
||||
let finalText = record.finalText else {
|
||||
return nil
|
||||
}
|
||||
return PolishStyleReplyLearningExample(
|
||||
receivedMessage: record.sourceText,
|
||||
ordinaryCandidate: selected.text,
|
||||
selection: .contextual,
|
||||
finalEdit: finalText,
|
||||
createdAt: record.createdAt,
|
||||
styleID: record.styleID
|
||||
)
|
||||
}
|
||||
let selection: PolishStyleReplySelection
|
||||
if record.outcome == .discarded {
|
||||
selection = .discarded
|
||||
@@ -279,6 +308,19 @@ public final class ClipboardReplyFeedbackStore {
|
||||
return .formal
|
||||
case .playful:
|
||||
return .playful
|
||||
case .invitationAccept,
|
||||
.invitationDecline,
|
||||
.invitationTentative,
|
||||
.taskAcknowledge,
|
||||
.taskClarify,
|
||||
.taskNegotiate,
|
||||
.blessingReturn,
|
||||
.blessingWarm,
|
||||
.blessingPlayful,
|
||||
.clarificationDirect,
|
||||
.clarificationQuestion,
|
||||
.clarificationConfirm:
|
||||
return .contextual
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,25 @@ public struct ClipboardIntentLabel: Equatable, Sendable {
|
||||
public let isApprovedForAutomaticRouting: Bool
|
||||
}
|
||||
|
||||
public enum ClipboardSemanticDomain: String, CaseIterable, Equatable, Sendable {
|
||||
case finance
|
||||
case travel
|
||||
case calendar
|
||||
case communication
|
||||
case media
|
||||
case smartHome
|
||||
case shopping
|
||||
case dining
|
||||
case health
|
||||
case weather
|
||||
case accountService
|
||||
case generalKnowledge
|
||||
|
||||
public var localizationKey: String {
|
||||
"keyboard.semantic.domain.\(rawValue)"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ClipboardVerifierDecision: Equatable, Sendable {
|
||||
public let group: String
|
||||
public let label: String
|
||||
@@ -68,6 +87,11 @@ public struct ClipboardSemanticAnalysis: Equatable, Sendable {
|
||||
public let blessing: ClipboardIntentLabel
|
||||
public let actionVerifier: ClipboardVerifierDecision?
|
||||
public let coordinationVerifier: ClipboardVerifierDecision?
|
||||
public let assistantCommand: ClipboardIntentLabel
|
||||
public let informationQuery: ClipboardIntentLabel
|
||||
public let systemNotification: ClipboardIntentLabel
|
||||
public let domain: ClipboardSemanticDomain?
|
||||
public let domainConfidence: Double?
|
||||
|
||||
public var hasDateOrTime: Bool { !dates.isEmpty }
|
||||
public var hasAddress: Bool { !addresses.isEmpty }
|
||||
@@ -81,6 +105,69 @@ public struct ClipboardSemanticAnalysis: Equatable, Sendable {
|
||||
}
|
||||
public var hasPersonName: Bool { !personNames.isEmpty }
|
||||
public var hasOrganizationName: Bool { !organizationNames.isEmpty }
|
||||
|
||||
public init(
|
||||
language: ClipboardLanguageLabel?,
|
||||
dates: [ClipboardDateLabel],
|
||||
addresses: [ClipboardTextLabel],
|
||||
phoneNumbers: [ClipboardTextLabel],
|
||||
urls: [URL],
|
||||
personNames: [ClipboardTextLabel],
|
||||
organizationNames: [ClipboardTextLabel],
|
||||
sentiment: ClipboardSentimentLabel,
|
||||
sentimentConfidence: Double,
|
||||
task: ClipboardIntentLabel,
|
||||
question: ClipboardIntentLabel,
|
||||
invitation: ClipboardIntentLabel,
|
||||
complaint: ClipboardIntentLabel,
|
||||
replyableMessage: ClipboardIntentLabel,
|
||||
scheduleNegotiation: ClipboardIntentLabel,
|
||||
confirmationDecision: ClipboardIntentLabel,
|
||||
followUpReminder: ClipboardIntentLabel,
|
||||
blessing: ClipboardIntentLabel,
|
||||
actionVerifier: ClipboardVerifierDecision?,
|
||||
coordinationVerifier: ClipboardVerifierDecision?,
|
||||
assistantCommand: ClipboardIntentLabel = .notDetected,
|
||||
informationQuery: ClipboardIntentLabel = .notDetected,
|
||||
systemNotification: ClipboardIntentLabel = .notDetected,
|
||||
domain: ClipboardSemanticDomain? = nil,
|
||||
domainConfidence: Double? = nil
|
||||
) {
|
||||
self.language = language
|
||||
self.dates = dates
|
||||
self.addresses = addresses
|
||||
self.phoneNumbers = phoneNumbers
|
||||
self.urls = urls
|
||||
self.personNames = personNames
|
||||
self.organizationNames = organizationNames
|
||||
self.sentiment = sentiment
|
||||
self.sentimentConfidence = sentimentConfidence
|
||||
self.task = task
|
||||
self.question = question
|
||||
self.invitation = invitation
|
||||
self.complaint = complaint
|
||||
self.replyableMessage = replyableMessage
|
||||
self.scheduleNegotiation = scheduleNegotiation
|
||||
self.confirmationDecision = confirmationDecision
|
||||
self.followUpReminder = followUpReminder
|
||||
self.blessing = blessing
|
||||
self.actionVerifier = actionVerifier
|
||||
self.coordinationVerifier = coordinationVerifier
|
||||
self.assistantCommand = assistantCommand
|
||||
self.informationQuery = informationQuery
|
||||
self.systemNotification = systemNotification
|
||||
self.domain = domain
|
||||
self.domainConfidence = domainConfidence
|
||||
}
|
||||
}
|
||||
|
||||
public extension ClipboardIntentLabel {
|
||||
static let notDetected = ClipboardIntentLabel(
|
||||
confidence: 0,
|
||||
threshold: 1,
|
||||
isDetected: false,
|
||||
isApprovedForAutomaticRouting: false
|
||||
)
|
||||
}
|
||||
|
||||
/// Deterministic HTTP(S) extraction shared by analysis and direct URL skills.
|
||||
@@ -199,6 +286,20 @@ public actor ClipboardSemanticAnalyzer {
|
||||
case confirmationDecision
|
||||
case followUpReminder
|
||||
case blessing
|
||||
case assistantCommand
|
||||
case informationQuery
|
||||
case systemNotification
|
||||
|
||||
var isDisplayOnly: Bool {
|
||||
switch self {
|
||||
case .assistantCommand, .informationQuery, .systemNotification:
|
||||
return true
|
||||
case .task, .question, .invitation, .complaint,
|
||||
.replyableMessage, .scheduleNegotiation,
|
||||
.confirmationDecision, .followUpReminder, .blessing:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let resourceDirectory = "ClipboardSemantics"
|
||||
@@ -286,7 +387,26 @@ public actor ClipboardSemanticAnalyzer {
|
||||
segments: segments,
|
||||
languageIdentifier: languageIdentifier
|
||||
)
|
||||
let blessing = adjustedBlessingLabel(blessingCandidate, text: text)
|
||||
let assistantCommand = intentLabel(
|
||||
.assistantCommand,
|
||||
segments: segments,
|
||||
languageIdentifier: languageIdentifier
|
||||
)
|
||||
let informationQuery = intentLabel(
|
||||
.informationQuery,
|
||||
segments: segments,
|
||||
languageIdentifier: languageIdentifier
|
||||
)
|
||||
let systemNotification = intentLabel(
|
||||
.systemNotification,
|
||||
segments: segments,
|
||||
languageIdentifier: languageIdentifier
|
||||
)
|
||||
let domain = domainLabel(
|
||||
segments: segments,
|
||||
languageIdentifier: languageIdentifier
|
||||
)
|
||||
let blessing = Self.adjustedBlessingLabel(blessingCandidate, text: text)
|
||||
let sentiment = sentimentLabel(segments: segments)
|
||||
let actionVerifier = verifierDecision(
|
||||
id: "action",
|
||||
@@ -343,7 +463,12 @@ public actor ClipboardSemanticAnalyzer {
|
||||
followUpReminder: verifiedCoordination.followUpReminder,
|
||||
blessing: blessing,
|
||||
actionVerifier: actionVerifier,
|
||||
coordinationVerifier: coordinationVerifier
|
||||
coordinationVerifier: coordinationVerifier,
|
||||
assistantCommand: assistantCommand,
|
||||
informationQuery: informationQuery,
|
||||
systemNotification: systemNotification,
|
||||
domain: domain.value,
|
||||
domainConfidence: domain.confidence
|
||||
)
|
||||
}
|
||||
|
||||
@@ -519,64 +644,147 @@ public actor ClipboardSemanticAnalyzer {
|
||||
return !explicitTaskMarkers.contains { normalized.contains($0) }
|
||||
}
|
||||
|
||||
private func adjustedBlessingLabel(
|
||||
static func adjustedBlessingLabel(
|
||||
_ candidate: ClipboardIntentLabel,
|
||||
text: String
|
||||
) -> ClipboardIntentLabel {
|
||||
guard Self.hasExplicitBlessingMarker(in: text) else {
|
||||
if isRejectedBlessingContext(in: text) {
|
||||
return ClipboardIntentLabel(
|
||||
confidence: candidate.confidence,
|
||||
threshold: candidate.threshold,
|
||||
threshold: 1,
|
||||
isDetected: false,
|
||||
isApprovedForAutomaticRouting: candidate.isApprovedForAutomaticRouting
|
||||
)
|
||||
}
|
||||
// Explicit blessing phrases are deterministic routing evidence. The
|
||||
// statistical model remains useful for diagnostics, but cannot route
|
||||
// broad positive language without one of these high-precision markers.
|
||||
|
||||
if hasExplicitBlessingMarker(in: text) {
|
||||
// Explicit blessing phrases are deterministic routing evidence.
|
||||
return ClipboardIntentLabel(
|
||||
confidence: 1,
|
||||
threshold: 1,
|
||||
isDetected: true,
|
||||
isApprovedForAutomaticRouting: true
|
||||
)
|
||||
}
|
||||
|
||||
let modelThreshold = max(candidate.threshold, 0.98)
|
||||
let isModelApproved = candidate.isApprovedForAutomaticRouting
|
||||
&& candidate.confidence >= modelThreshold
|
||||
return ClipboardIntentLabel(
|
||||
confidence: 1,
|
||||
threshold: 1,
|
||||
isDetected: true,
|
||||
isApprovedForAutomaticRouting: true
|
||||
confidence: candidate.confidence,
|
||||
threshold: modelThreshold,
|
||||
isDetected: isModelApproved,
|
||||
isApprovedForAutomaticRouting: candidate.isApprovedForAutomaticRouting
|
||||
)
|
||||
}
|
||||
|
||||
static func hasExplicitBlessingMarker(in text: String) -> Bool {
|
||||
let normalized = text.lowercased()
|
||||
let quotedOrMetaContexts = [
|
||||
"祝福模板", "祝福语模板", "文章引用", "搜索词", "系统正在检查",
|
||||
"文档里收录", "贺卡名单", "收集祝福", "greeting template",
|
||||
"message template", "the article quotes", "search phrase",
|
||||
"system is checking", "document contains", "card list",
|
||||
"quotes the phrase", "如何描述生日快乐", "怎么说生日快乐",
|
||||
"如何写生日祝福", "how would you describe a happy birthday",
|
||||
"how do you say happy birthday", "what does happy birthday mean",
|
||||
"宁愿你", "祝你倒闭", "祝你立马倒闭", "祝你去死", "祝你倒霉",
|
||||
"祝你失败", "祝你完蛋"
|
||||
]
|
||||
guard !quotedOrMetaContexts.contains(where: { normalized.contains($0) }) else {
|
||||
let normalized = normalizedBlessingText(text)
|
||||
guard !isRejectedBlessingContext(in: normalized) else {
|
||||
return false
|
||||
}
|
||||
let markers = [
|
||||
"生日快乐", "新年快乐", "春节快乐", "节日快乐", "圣诞快乐",
|
||||
"中秋快乐", "恭喜", "预祝", "祝你", "祝您", "祝大家", "祝他", "祝她",
|
||||
"愿你", "愿您", "happy birthday", "happy new year",
|
||||
"merry christmas", "happy holidays", "congratulations",
|
||||
"congrats", "best wishes", "good luck", "wishing you",
|
||||
"wish you", "wish him", "wish her", "wish them", "let us wish",
|
||||
"let's wish", "we wish", "may you"
|
||||
"生日快乐", "新年快乐", "春节快乐", "元旦快乐", "元宵节快乐",
|
||||
"端午安康", "端午快乐", "节日快乐", "圣诞快乐", "中秋快乐",
|
||||
"国庆快乐", "新婚快乐", "毕业快乐", "纪念日快乐", "恭喜",
|
||||
"祝贺", "预祝", "祝你", "祝您", "祝大家", "祝各位", "祝我们",
|
||||
"祝他", "祝她", "祝他们", "祝愿", "愿你", "愿您", "愿大家",
|
||||
"愿各位", "愿我们", "愿他", "愿她", "愿他们", "一路顺风",
|
||||
"一路平安", "早日康复", "前程似锦", "万事如意", "心想事成",
|
||||
"平安喜乐", "节哀顺变", "开业大吉", "做个好梦",
|
||||
"happy birthday", "happy new year", "happy anniversary",
|
||||
"happy graduation", "happy wedding", "merry christmas",
|
||||
"happy holidays", "congratulations", "congrats", "best wishes",
|
||||
"good luck", "safe travels", "get well soon", "sweet dreams",
|
||||
"all the best", "wishing you", "wishing him", "wishing her",
|
||||
"wishing them", "wish you", "wish him", "wish her", "wish them",
|
||||
"let us wish", "let's wish", "we wish", "may you", "may your",
|
||||
"hope you have"
|
||||
]
|
||||
return markers.contains { normalized.contains($0) }
|
||||
}
|
||||
|
||||
private func emptyAnalysis() -> ClipboardSemanticAnalysis {
|
||||
let emptyIntent = ClipboardIntentLabel(
|
||||
confidence: 0,
|
||||
threshold: 1,
|
||||
isDetected: false,
|
||||
isApprovedForAutomaticRouting: false
|
||||
static func isRejectedBlessingContext(in text: String) -> Bool {
|
||||
let normalized = normalizedBlessingText(text)
|
||||
let blockedFragments = [
|
||||
"祝福模板", "祝福语模板", "祝福文案", "文章引用", "搜索词",
|
||||
"系统正在检查", "文档里收录", "文档里引用", "海报上印着",
|
||||
"示例文本", "关键词列表", "分析句式", "贺卡名单", "收集祝福",
|
||||
"如何描述生日快乐", "怎么说生日快乐", "如何写生日祝福",
|
||||
"怎么写生日祝福", "帮我写一段祝福", "帮我生成祝福",
|
||||
"greeting template", "message template", "blessing template",
|
||||
"the article quotes", "the document quotes", "search phrase",
|
||||
"system is checking", "document contains", "card list",
|
||||
"quotes the phrase", "sample text", "keyword list",
|
||||
"how would you describe a happy birthday",
|
||||
"how do you say happy birthday", "how to write a birthday wish",
|
||||
"what does happy birthday mean", "write a birthday wish",
|
||||
"宁愿你", "祝你倒闭", "祝你立马倒闭", "祝你去死", "祝你倒霉",
|
||||
"祝你失败", "祝你完蛋", "wish you would die", "wish you bad luck"
|
||||
]
|
||||
if blockedFragments.contains(where: { normalized.contains($0) }) {
|
||||
return true
|
||||
}
|
||||
|
||||
let receivedPatterns = [
|
||||
#"(?:谢谢|感谢|收到|收到了|多谢).{0,20}(?:祝福|祝愿|生日快乐|恭喜)"#,
|
||||
#"(?:thank|thanks).{0,64}(?:wish|wishes|congratulations|birthday message)"#
|
||||
]
|
||||
let reportedOrMetaPatterns = [
|
||||
#"(?:帮我写|帮我生成|搜索|查找).{0,16}(?:祝福|祝福语|祝愿|生日快乐)"#,
|
||||
#"(?:他说|她说|他们说|会议记录|新闻|群公告).{0,20}(?:祝|愿|恭喜)"#,
|
||||
#"(?:he said|she said|they said|meeting notes|the article reports).{0,32}(?:wish|congratulat)"#
|
||||
]
|
||||
if reportedOrMetaPatterns.contains(where: {
|
||||
normalized.range(of: $0, options: .regularExpression) != nil
|
||||
}) {
|
||||
return true
|
||||
}
|
||||
let containsReciprocalWish = [
|
||||
"也祝", "同样祝", ",祝你", ",祝您", "。祝你", "。祝您",
|
||||
". wish you", ". wishing you", "! wish you", "! wishing you",
|
||||
", and wish you", ", wishing you", "same to you"
|
||||
].contains { normalized.contains($0) }
|
||||
if !containsReciprocalWish,
|
||||
receivedPatterns.contains(where: {
|
||||
normalized.range(of: $0, options: .regularExpression) != nil
|
||||
}) {
|
||||
return true
|
||||
}
|
||||
|
||||
let plainGreetings = [
|
||||
"你好", "您好", "早上好", "中午好", "下午好", "晚上好",
|
||||
"晚安", "好久不见", "hello", "good morning", "good afternoon",
|
||||
"good evening", "long time no see"
|
||||
]
|
||||
let trimmed = normalized.trimmingCharacters(
|
||||
in: .whitespacesAndNewlines.union(.punctuationCharacters)
|
||||
)
|
||||
if plainGreetings.contains(trimmed) {
|
||||
return true
|
||||
}
|
||||
|
||||
let celebrationOnly = [
|
||||
"庆祝", "庆功", "庆典", "celebrate", "celebration"
|
||||
].contains { normalized.contains($0) }
|
||||
return celebrationOnly && !containsDirectWishCue(in: normalized)
|
||||
}
|
||||
|
||||
private static func normalizedBlessingText(_ text: String) -> String {
|
||||
text.precomposedStringWithCompatibilityMapping.lowercased()
|
||||
}
|
||||
|
||||
private static func containsDirectWishCue(in normalized: String) -> Bool {
|
||||
[
|
||||
"祝你", "祝您", "祝大家", "祝各位", "祝他", "祝她", "祝他们",
|
||||
"愿你", "愿您", "愿大家", "愿他", "愿她", "恭喜", "祝贺",
|
||||
"wishing you", "wish you", "wish him", "wish her", "wish them",
|
||||
"congratulations", "congrats", "good luck", "best wishes"
|
||||
].contains { normalized.contains($0) }
|
||||
}
|
||||
|
||||
private func emptyAnalysis() -> ClipboardSemanticAnalysis {
|
||||
let emptyIntent = ClipboardIntentLabel.notDetected
|
||||
return ClipboardSemanticAnalysis(
|
||||
language: nil,
|
||||
dates: [],
|
||||
@@ -597,7 +805,12 @@ public actor ClipboardSemanticAnalyzer {
|
||||
followUpReminder: emptyIntent,
|
||||
blessing: emptyIntent,
|
||||
actionVerifier: nil,
|
||||
coordinationVerifier: nil
|
||||
coordinationVerifier: nil,
|
||||
assistantCommand: emptyIntent,
|
||||
informationQuery: emptyIntent,
|
||||
systemNotification: emptyIntent,
|
||||
domain: nil,
|
||||
domainConfidence: nil
|
||||
)
|
||||
}
|
||||
|
||||
@@ -756,15 +969,50 @@ public actor ClipboardSemanticAnalyzer {
|
||||
maximumCount: 2
|
||||
)[positiveLabel] ?? 0
|
||||
}.max() ?? 0
|
||||
// Boundary classifiers launch in display/shadow mode. They may expose a
|
||||
// threshold-crossing result, but can never authorize an existing route.
|
||||
let approved = entry.configuration.acceptedForAutomaticRouting
|
||||
&& !id.isDisplayOnly
|
||||
return ClipboardIntentLabel(
|
||||
confidence: rounded(confidence),
|
||||
threshold: rounded(threshold),
|
||||
isDetected: approved && confidence >= threshold,
|
||||
isDetected: (approved || id.isDisplayOnly) && confidence >= threshold,
|
||||
isApprovedForAutomaticRouting: approved
|
||||
)
|
||||
}
|
||||
|
||||
private func domainLabel(
|
||||
segments: [String],
|
||||
languageIdentifier: String?
|
||||
) -> (value: ClipboardSemanticDomain?, confidence: Double?) {
|
||||
guard let entry = modelEntry(id: "domain") else {
|
||||
return (nil, nil)
|
||||
}
|
||||
let threshold = languageIdentifier.flatMap {
|
||||
entry.configuration.confidenceThresholdsByLanguage?[$0]
|
||||
} ?? entry.configuration.confidenceThreshold ?? 1
|
||||
let winners = segments.compactMap { segment -> (
|
||||
domain: ClipboardSemanticDomain,
|
||||
confidence: Double
|
||||
)? in
|
||||
let ranked = entry.model.predictedLabelHypotheses(
|
||||
for: segment,
|
||||
maximumCount: ClipboardSemanticDomain.allCases.count
|
||||
).sorted { $0.value > $1.value }
|
||||
guard let winner = ranked.first,
|
||||
let domain = ClipboardSemanticDomain(rawValue: winner.key) else {
|
||||
return nil
|
||||
}
|
||||
return (domain, winner.value)
|
||||
}
|
||||
guard let winner = winners.max(by: {
|
||||
$0.confidence < $1.confidence
|
||||
}), winner.confidence >= threshold else {
|
||||
return (nil, nil)
|
||||
}
|
||||
return (winner.domain, rounded(winner.confidence))
|
||||
}
|
||||
|
||||
private func sentimentLabel(
|
||||
segments: [String]
|
||||
) -> (label: ClipboardSentimentLabel, confidence: Double) {
|
||||
@@ -905,7 +1153,7 @@ public actor ClipboardSemanticAnalyzer {
|
||||
guard let url,
|
||||
let data = try? Data(contentsOf: url),
|
||||
let decoded = try? decoder.decode(Manifest.self, from: data),
|
||||
(1...3).contains(decoded.schemaVersion) else {
|
||||
(1...4).contains(decoded.schemaVersion) else {
|
||||
continue
|
||||
}
|
||||
manifest = decoded
|
||||
|
||||
@@ -11,7 +11,6 @@ import Foundation
|
||||
public enum ClipboardSkillSemanticRanker {
|
||||
private static let longTextCharacterThreshold = 360
|
||||
private static let languageConfidenceThreshold = 0.75
|
||||
private static let maximumReplyRecommendations = 2
|
||||
|
||||
public static func ranked(
|
||||
skills: [AIClipboardSkill],
|
||||
@@ -31,8 +30,8 @@ public enum ClipboardSkillSemanticRanker {
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns semantically relevant skills and always keeps the generic Reply
|
||||
/// action available as a safe fallback for accepted clipboard text.
|
||||
/// Returns semantically relevant skills and keeps generic Reply as a safe
|
||||
/// fallback unless a display-only boundary intent suppresses human routing.
|
||||
public static func recommended(
|
||||
skills: [AIClipboardSkill],
|
||||
sourceText: String,
|
||||
@@ -47,7 +46,9 @@ public enum ClipboardSkillSemanticRanker {
|
||||
analysis: analysis,
|
||||
preferredLanguages: preferredLanguages
|
||||
)
|
||||
let genericReply = skills.first { $0.id == AIClipboardSkillCatalog.replyID }
|
||||
let genericReply = suppressesInterpersonalRouting(analysis)
|
||||
? nil
|
||||
: skills.first { $0.id == AIClipboardSkillCatalog.replyID }
|
||||
if genericReply != nil {
|
||||
scores[AIClipboardSkillCatalog.replyID, default: 0] = max(
|
||||
1,
|
||||
@@ -56,7 +57,6 @@ public enum ClipboardSkillSemanticRanker {
|
||||
}
|
||||
let relevant = skills.filter { scores[$0.id, default: 0] > 0 }
|
||||
var selected: [AIClipboardSkill] = []
|
||||
var specializedReplyCount = 0
|
||||
for skill in sorted(relevant, scores: scores) {
|
||||
let mustReserveGenericReply = genericReply != nil
|
||||
&& !selected.contains(where: { $0.id == AIClipboardSkillCatalog.replyID })
|
||||
@@ -67,10 +67,6 @@ public enum ClipboardSkillSemanticRanker {
|
||||
selected.append(skill)
|
||||
continue
|
||||
}
|
||||
if skill.supportsReplyStyle {
|
||||
guard specializedReplyCount < maximumReplyRecommendations else { continue }
|
||||
specializedReplyCount += 1
|
||||
}
|
||||
selected.append(skill)
|
||||
}
|
||||
if let genericReply,
|
||||
@@ -116,70 +112,65 @@ public enum ClipboardSkillSemanticRanker {
|
||||
boost(AIClipboardSkillCatalog.navigateID, 180)
|
||||
}
|
||||
|
||||
if isRoutingEvidence(analysis.invitation) {
|
||||
if analysis.hasDateOrTime {
|
||||
boost(AIClipboardSkillCatalog.extractEventsID, 260)
|
||||
let suppressesInterpersonalRouting = suppressesInterpersonalRouting(analysis)
|
||||
if !suppressesInterpersonalRouting {
|
||||
if isRoutingEvidence(analysis.invitation) {
|
||||
if analysis.hasDateOrTime {
|
||||
boost(AIClipboardSkillCatalog.extractEventsID, 260)
|
||||
}
|
||||
boost(AIClipboardSkillCatalog.replyID, 300)
|
||||
} else if analysis.hasDateOrTime {
|
||||
boost(AIClipboardSkillCatalog.extractEventsID, 110)
|
||||
}
|
||||
boost(AIClipboardSkillCatalog.acceptInvitationID, 240)
|
||||
boost(AIClipboardSkillCatalog.declineInvitationID, 230)
|
||||
boost(AIClipboardSkillCatalog.replyID, 60)
|
||||
} else if analysis.hasDateOrTime {
|
||||
boost(AIClipboardSkillCatalog.extractEventsID, 110)
|
||||
}
|
||||
|
||||
// A threshold-crossing, evaluation-gated model may still rank a
|
||||
// reversible chip; execution always remains explicitly user-initiated.
|
||||
if isRoutingEvidence(analysis.scheduleNegotiation) {
|
||||
boost(AIClipboardSkillCatalog.clarifyRequestID, 300)
|
||||
boost(AIClipboardSkillCatalog.extractEventsID, 200)
|
||||
boost(AIClipboardSkillCatalog.replyID, 250)
|
||||
}
|
||||
if !suppressesInterpersonalRouting {
|
||||
// A threshold-crossing, evaluation-gated model may still rank a
|
||||
// reversible chip; execution always remains explicitly user-initiated.
|
||||
if isRoutingEvidence(analysis.scheduleNegotiation) {
|
||||
boost(AIClipboardSkillCatalog.extractEventsID, 200)
|
||||
boost(AIClipboardSkillCatalog.replyID, 300)
|
||||
}
|
||||
|
||||
if isRoutingEvidence(analysis.confirmationDecision) {
|
||||
boost(AIClipboardSkillCatalog.acceptTaskID, 300)
|
||||
boost(AIClipboardSkillCatalog.replyID, 280)
|
||||
}
|
||||
if isRoutingEvidence(analysis.confirmationDecision) {
|
||||
boost(AIClipboardSkillCatalog.replyID, 300)
|
||||
}
|
||||
|
||||
if isRoutingEvidence(analysis.followUpReminder) {
|
||||
boost(AIClipboardSkillCatalog.extractTodosID, 285)
|
||||
boost(AIClipboardSkillCatalog.acceptTaskID, 250)
|
||||
boost(AIClipboardSkillCatalog.clarifyRequestID, 170)
|
||||
boost(AIClipboardSkillCatalog.replyID, 90)
|
||||
}
|
||||
if isRoutingEvidence(analysis.followUpReminder) {
|
||||
boost(AIClipboardSkillCatalog.extractTodosID, 285)
|
||||
boost(AIClipboardSkillCatalog.replyID, 250)
|
||||
}
|
||||
|
||||
if isRoutingEvidence(analysis.task) {
|
||||
boost(AIClipboardSkillCatalog.extractTodosID, 155)
|
||||
boost(AIClipboardSkillCatalog.acceptTaskID, 140)
|
||||
boost(AIClipboardSkillCatalog.clarifyRequestID, 105)
|
||||
}
|
||||
if isRoutingEvidence(analysis.task) {
|
||||
boost(AIClipboardSkillCatalog.extractTodosID, 155)
|
||||
boost(AIClipboardSkillCatalog.replyID, 140)
|
||||
}
|
||||
|
||||
if isRoutingEvidence(analysis.question) {
|
||||
boost(AIClipboardSkillCatalog.replyID, 145)
|
||||
boost(AIClipboardSkillCatalog.clarifyRequestID, 110)
|
||||
}
|
||||
if isRoutingEvidence(analysis.question) {
|
||||
boost(AIClipboardSkillCatalog.replyID, 145)
|
||||
}
|
||||
|
||||
if isRoutingEvidence(analysis.blessing) {
|
||||
boost(AIClipboardSkillCatalog.blessingReplyID, 300)
|
||||
boost(AIClipboardSkillCatalog.replyID, 95)
|
||||
}
|
||||
if isRoutingEvidence(analysis.blessing) {
|
||||
boost(AIClipboardSkillCatalog.replyID, 300)
|
||||
}
|
||||
|
||||
if isRoutingEvidence(analysis.complaint) {
|
||||
boost(AIClipboardSkillCatalog.empathyReplyID, 105)
|
||||
boost(AIClipboardSkillCatalog.clarifyRequestID, 90)
|
||||
boost(AIClipboardSkillCatalog.replyID, 55)
|
||||
} else if analysis.sentiment == .negative,
|
||||
isRoutingEvidence(analysis.question) {
|
||||
boost(AIClipboardSkillCatalog.empathyReplyID, 85)
|
||||
boost(AIClipboardSkillCatalog.clarifyRequestID, 65)
|
||||
}
|
||||
if isRoutingEvidence(analysis.complaint) {
|
||||
boost(AIClipboardSkillCatalog.replyID, 105)
|
||||
} else if analysis.sentiment == .negative,
|
||||
isRoutingEvidence(analysis.question) {
|
||||
boost(AIClipboardSkillCatalog.replyID, 85)
|
||||
}
|
||||
|
||||
if analysis.hasOrganizationName,
|
||||
isRoutingEvidence(analysis.task)
|
||||
|| isRoutingEvidence(analysis.question)
|
||||
|| isRoutingEvidence(analysis.invitation) {
|
||||
boost(AIClipboardSkillCatalog.replyID, 125)
|
||||
} else if analysis.hasOrganizationName {
|
||||
boost(AIClipboardSkillCatalog.replyID, 70)
|
||||
if analysis.hasOrganizationName,
|
||||
isRoutingEvidence(analysis.task)
|
||||
|| isRoutingEvidence(analysis.question)
|
||||
|| isRoutingEvidence(analysis.invitation) {
|
||||
boost(AIClipboardSkillCatalog.replyID, 125)
|
||||
} else if analysis.hasOrganizationName {
|
||||
boost(AIClipboardSkillCatalog.replyID, 70)
|
||||
}
|
||||
}
|
||||
|
||||
if isListLike(sourceText) {
|
||||
@@ -193,32 +184,80 @@ public enum ClipboardSkillSemanticRanker {
|
||||
boost(AIClipboardSkillCatalog.saveToNotesID, 85)
|
||||
}
|
||||
|
||||
let hasSpecializedReplyIntent = isRoutingEvidence(analysis.task)
|
||||
|| isRoutingEvidence(analysis.question)
|
||||
|| isRoutingEvidence(analysis.invitation)
|
||||
|| isRoutingEvidence(analysis.scheduleNegotiation)
|
||||
|| isRoutingEvidence(analysis.confirmationDecision)
|
||||
|| isRoutingEvidence(analysis.followUpReminder)
|
||||
|| isRoutingEvidence(analysis.blessing)
|
||||
|| isRoutingEvidence(analysis.complaint)
|
||||
if analysis.replyableMessage.isDetected,
|
||||
!hasSpecializedReplyIntent,
|
||||
sourceText.count < longTextCharacterThreshold,
|
||||
!isListLike(sourceText) {
|
||||
boost(AIClipboardSkillCatalog.replyID, 160)
|
||||
if analysis.sentiment != .negative,
|
||||
!isRoutingEvidence(analysis.complaint) {
|
||||
// Reply now exposes ordinary, formal, and playful variants
|
||||
// inside one action rather than ranking separate style skills.
|
||||
boost(AIClipboardSkillCatalog.replyID, 145)
|
||||
if !suppressesInterpersonalRouting {
|
||||
let hasSpecializedReplyIntent = isRoutingEvidence(analysis.task)
|
||||
|| isRoutingEvidence(analysis.question)
|
||||
|| isRoutingEvidence(analysis.invitation)
|
||||
|| isRoutingEvidence(analysis.scheduleNegotiation)
|
||||
|| isRoutingEvidence(analysis.confirmationDecision)
|
||||
|| isRoutingEvidence(analysis.followUpReminder)
|
||||
|| isRoutingEvidence(analysis.blessing)
|
||||
|| isRoutingEvidence(analysis.complaint)
|
||||
if analysis.replyableMessage.isDetected,
|
||||
!hasSpecializedReplyIntent,
|
||||
sourceText.count < longTextCharacterThreshold,
|
||||
!isListLike(sourceText) {
|
||||
boost(AIClipboardSkillCatalog.replyID, 160)
|
||||
if analysis.sentiment != .negative,
|
||||
!isRoutingEvidence(analysis.complaint) {
|
||||
// Reply now exposes ordinary, formal, and playful variants
|
||||
// inside one action rather than ranking separate style skills.
|
||||
boost(AIClipboardSkillCatalog.replyID, 145)
|
||||
}
|
||||
}
|
||||
if analysis.sentiment == .positive {
|
||||
boost(AIClipboardSkillCatalog.replyID, 45)
|
||||
}
|
||||
}
|
||||
if analysis.sentiment == .positive {
|
||||
boost(AIClipboardSkillCatalog.replyID, 45)
|
||||
}
|
||||
applyDomainBoosts(
|
||||
analysis,
|
||||
sourceText: sourceText,
|
||||
suppressesInterpersonalRouting: suppressesInterpersonalRouting,
|
||||
boost: boost
|
||||
)
|
||||
return scores
|
||||
}
|
||||
|
||||
private static func applyDomainBoosts(
|
||||
_ analysis: ClipboardSemanticAnalysis,
|
||||
sourceText: String,
|
||||
suppressesInterpersonalRouting: Bool,
|
||||
boost: (String, Int) -> Void
|
||||
) {
|
||||
guard let domain = analysis.domain,
|
||||
let confidence = analysis.domainConfidence,
|
||||
confidence > 0 else {
|
||||
return
|
||||
}
|
||||
switch domain {
|
||||
case .calendar:
|
||||
if analysis.hasDateOrTime {
|
||||
boost(AIClipboardSkillCatalog.extractEventsID, 20)
|
||||
}
|
||||
case .travel:
|
||||
if analysis.hasAddress {
|
||||
boost(AIClipboardSkillCatalog.navigateID, 20)
|
||||
}
|
||||
case .media, .generalKnowledge:
|
||||
if sourceText.count >= longTextCharacterThreshold {
|
||||
boost(AIClipboardSkillCatalog.summarizeID, 15)
|
||||
}
|
||||
case .communication:
|
||||
if !suppressesInterpersonalRouting,
|
||||
hasInterpersonalRoutingEvidence(analysis) {
|
||||
boost(AIClipboardSkillCatalog.replyID, 15)
|
||||
}
|
||||
case .finance, .accountService:
|
||||
if !suppressesInterpersonalRouting,
|
||||
isRoutingEvidence(analysis.question)
|
||||
|| isRoutingEvidence(analysis.complaint) {
|
||||
boost(AIClipboardSkillCatalog.replyID, 10)
|
||||
}
|
||||
case .smartHome, .shopping, .dining, .health, .weather:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private static func sorted(
|
||||
_ skills: [AIClipboardSkill],
|
||||
scores: [String: Int]
|
||||
@@ -255,6 +294,34 @@ public enum ClipboardSkillSemanticRanker {
|
||||
label.isDetected && label.isApprovedForAutomaticRouting
|
||||
}
|
||||
|
||||
private static func isDisplayEvidence(_ label: ClipboardIntentLabel) -> Bool {
|
||||
label.isDetected
|
||||
&& label.confidence > 0
|
||||
&& label.confidence >= label.threshold
|
||||
}
|
||||
|
||||
private static func suppressesInterpersonalRouting(
|
||||
_ analysis: ClipboardSemanticAnalysis
|
||||
) -> Bool {
|
||||
isDisplayEvidence(analysis.assistantCommand)
|
||||
|| isDisplayEvidence(analysis.informationQuery)
|
||||
|| isDisplayEvidence(analysis.systemNotification)
|
||||
}
|
||||
|
||||
private static func hasInterpersonalRoutingEvidence(
|
||||
_ analysis: ClipboardSemanticAnalysis
|
||||
) -> Bool {
|
||||
isRoutingEvidence(analysis.task)
|
||||
|| isRoutingEvidence(analysis.question)
|
||||
|| isRoutingEvidence(analysis.invitation)
|
||||
|| isRoutingEvidence(analysis.complaint)
|
||||
|| isRoutingEvidence(analysis.replyableMessage)
|
||||
|| isRoutingEvidence(analysis.scheduleNegotiation)
|
||||
|| isRoutingEvidence(analysis.confirmationDecision)
|
||||
|| isRoutingEvidence(analysis.followUpReminder)
|
||||
|| isRoutingEvidence(analysis.blessing)
|
||||
}
|
||||
|
||||
private static func isListLike(_ text: String) -> Bool {
|
||||
let lines = text
|
||||
.split(whereSeparator: \.isNewline)
|
||||
|
||||
@@ -362,8 +362,8 @@ public final class KeyboardState: ObservableObject {
|
||||
public var performAssistantFieldAction: () -> Void = {}
|
||||
/// Sends a tapped idle hint card as the AI question (skip microphone).
|
||||
public var submitAIHint: (AIHintCard) -> Void = { _ in }
|
||||
/// Sends a clipboard skill (reply / summarize / translate / export).
|
||||
public var submitAIClipboardSkill: (AIClipboardSkill) -> Void = { _ in }
|
||||
/// Sends a clipboard skill plus any source-bound Reply scene modifier.
|
||||
public var submitAIClipboardSkill: (AIClipboardSkill, AIClipboardReplyScene?) -> Void = { _, _ in }
|
||||
/// Writes extract-todos titles and opens the host to run the Shortcut.
|
||||
public var runClipboardExportSkill: (String, [String]) -> Void = { _, _ in }
|
||||
public var openSettings: () -> Void = {}
|
||||
|
||||
@@ -12,6 +12,7 @@ public enum LLMError: Error, LocalizedError, Sendable, Equatable {
|
||||
case http(status: Int)
|
||||
case decoding(String)
|
||||
case transport(String)
|
||||
case timeout
|
||||
case cancelled
|
||||
case rateLimited
|
||||
|
||||
@@ -27,6 +28,8 @@ public enum LLMError: Error, LocalizedError, Sendable, Equatable {
|
||||
return SharedL10n.string("error.llm.decoding")
|
||||
case .transport:
|
||||
return SharedL10n.string("error.llm.transport")
|
||||
case .timeout:
|
||||
return SharedL10n.string("error.llm.timeout")
|
||||
case .rateLimited:
|
||||
return SharedL10n.string("error.llm.rateLimited")
|
||||
case .cancelled:
|
||||
@@ -291,6 +294,8 @@ public struct OpenAICompatibleClient: LLMClient {
|
||||
throw LLMError.cancelled
|
||||
} catch let urlError as URLError where urlError.code == .cancelled {
|
||||
throw LLMError.cancelled
|
||||
} catch let urlError as URLError where urlError.code == .timedOut {
|
||||
throw LLMError.timeout
|
||||
} catch {
|
||||
throw LLMError.transport(String(describing: error))
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ public enum PolishStyleReplySelection: String, Codable, Equatable, Sendable {
|
||||
case ordinary
|
||||
case formal
|
||||
case playful
|
||||
/// A scene-specific decision (for example accept/decline) is not a
|
||||
/// reusable voice preference. Only its user-authored final edit may teach.
|
||||
case contextual
|
||||
case discarded
|
||||
}
|
||||
|
||||
@@ -84,6 +87,7 @@ public struct PolishStyleLearningEvidence: Codable, Equatable, Sendable {
|
||||
public enum Source: String, Codable, Hashable, Sendable {
|
||||
case asrUserEdit
|
||||
case asrRepeatedBefore
|
||||
case asrObservedBefore
|
||||
case replyFinalEdit
|
||||
case replyCrossContextSelection
|
||||
case replyAcceptance
|
||||
@@ -259,6 +263,138 @@ public enum PolishStyleLearningError: Error, Equatable, Sendable {
|
||||
case requestTooLarge
|
||||
}
|
||||
|
||||
/// Converts provider and credential failures into safe, actionable messages.
|
||||
/// Raw transport details can contain endpoint data, so they are never shown.
|
||||
public enum PolishStyleLearningFailureMessage {
|
||||
public static func localized(
|
||||
for error: Error,
|
||||
language: AppUILanguage
|
||||
) -> String? {
|
||||
if let polishError = error as? PolishingService.PolishError {
|
||||
switch polishError {
|
||||
case .noTranscript:
|
||||
return SharedL10n.string(
|
||||
"styleLearning.error.emptyRequest",
|
||||
language: language
|
||||
)
|
||||
case .timeout:
|
||||
return SharedL10n.string(
|
||||
"styleLearning.error.timeout",
|
||||
language: language
|
||||
)
|
||||
case .missingAPIKey:
|
||||
return SharedL10n.string(
|
||||
"styleLearning.error.missingAPIKey",
|
||||
language: language
|
||||
)
|
||||
case .keychainLocked:
|
||||
return SharedL10n.string(
|
||||
"styleLearning.error.keychainLocked",
|
||||
language: language
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if let llmError = error as? LLMError {
|
||||
switch llmError {
|
||||
case .invalidURL:
|
||||
return SharedL10n.string("error.llm.invalidURL", language: language)
|
||||
case .noAPIKey:
|
||||
return SharedL10n.string(
|
||||
"styleLearning.error.missingAPIKey",
|
||||
language: language
|
||||
)
|
||||
case .http(let status):
|
||||
return SharedL10n.format(
|
||||
"error.llm.http",
|
||||
language: language,
|
||||
Int64(status)
|
||||
)
|
||||
case .decoding:
|
||||
return SharedL10n.string("error.llm.decoding", language: language)
|
||||
case .transport:
|
||||
return SharedL10n.string("error.llm.transport", language: language)
|
||||
case .timeout:
|
||||
return SharedL10n.string("error.llm.timeout", language: language)
|
||||
case .cancelled:
|
||||
return SharedL10n.string("error.llm.cancelled", language: language)
|
||||
case .rateLimited:
|
||||
return SharedL10n.string("error.llm.rateLimited", language: language)
|
||||
}
|
||||
}
|
||||
|
||||
if let managedError = error as? ManagedGatewayError {
|
||||
switch managedError {
|
||||
case .missingGrant:
|
||||
return SharedL10n.string(
|
||||
"managed.error.grantUnavailable",
|
||||
language: language
|
||||
)
|
||||
case .scopeNotGranted(let scope):
|
||||
return SharedL10n.format(
|
||||
"managed.error.scopeNotGranted",
|
||||
language: language,
|
||||
scope.rawValue
|
||||
)
|
||||
case .invalidGrant:
|
||||
return SharedL10n.string(
|
||||
"managed.error.grantRejected",
|
||||
language: language
|
||||
)
|
||||
case .insufficientCredits:
|
||||
return SharedL10n.string(
|
||||
"managed.error.insufficientCredits",
|
||||
language: language
|
||||
)
|
||||
case .oobeFeatureAlreadyUsed:
|
||||
return SharedL10n.string(
|
||||
"managed.error.oobeFeatureAlreadyUsed",
|
||||
language: language
|
||||
)
|
||||
case .timeout:
|
||||
return SharedL10n.string("managed.error.timeout", language: language)
|
||||
case .providerUnavailable:
|
||||
return SharedL10n.string(
|
||||
"managed.error.providerUnavailable",
|
||||
language: language
|
||||
)
|
||||
case .providerRateLimited:
|
||||
return SharedL10n.string(
|
||||
"managed.error.providerRateLimited",
|
||||
language: language
|
||||
)
|
||||
case .providerTimeout:
|
||||
return SharedL10n.string(
|
||||
"managed.error.providerTimeout",
|
||||
language: language
|
||||
)
|
||||
case .providerFailure:
|
||||
return SharedL10n.string(
|
||||
"managed.error.providerFailure",
|
||||
language: language
|
||||
)
|
||||
case .internalFailure:
|
||||
return SharedL10n.string(
|
||||
"managed.error.internalFailure",
|
||||
language: language
|
||||
)
|
||||
case .server(let code, let status, _):
|
||||
return SharedL10n.format(
|
||||
"managed.error.server",
|
||||
language: language,
|
||||
code,
|
||||
Int64(status)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if error is CancellationError {
|
||||
return SharedL10n.string("error.llm.cancelled", language: language)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public actor PolishStyleLearningService {
|
||||
private struct StyleReference: Codable {
|
||||
let id: String
|
||||
@@ -275,6 +411,7 @@ public actor PolishStyleLearningService {
|
||||
}
|
||||
|
||||
private struct ASRInput: Codable {
|
||||
let residualBaseline: StyleReference
|
||||
let currentStyleContamination: StyleReference
|
||||
let historicalStyleContamination: [StyleReference]
|
||||
let examples: [ASRExamplePayload]
|
||||
@@ -324,7 +461,12 @@ public actor PolishStyleLearningService {
|
||||
private static let maximumEvidenceItemsPerDomain = 24
|
||||
private static let maximumContradictionsPerDomain = 12
|
||||
private static let maximumEvidenceFieldCharacters = 320
|
||||
private static let learningSchemaVersion = 2
|
||||
private static let learningSchemaVersion = 3
|
||||
private static let generationOptions = LLMGenerationOptions(
|
||||
temperature: 0.1,
|
||||
topP: 0.9,
|
||||
maxTokens: 4_096
|
||||
)
|
||||
|
||||
private let store: any ConfigurationStore
|
||||
private let client: LLMClient?
|
||||
@@ -340,42 +482,51 @@ public actor PolishStyleLearningService {
|
||||
public func generateStyle(
|
||||
from corpus: PolishStyleLearningCorpus,
|
||||
replyExamples: [PolishStyleReplyLearningExample] = [],
|
||||
outputLanguage: AppUILanguage
|
||||
outputLanguage: AppUILanguage,
|
||||
minimumEffectiveCharacterCount: Int =
|
||||
PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
|
||||
) async throws -> PolishStylePack {
|
||||
let requiredCharacterCount = max(0, minimumEffectiveCharacterCount)
|
||||
let verifiedCharacterCount = corpus.examples.reduce(into: 0) { count, example in
|
||||
count += PolishStyleLearningCorpusBuilder.effectiveCharacterCount(
|
||||
in: example.prePolishText
|
||||
)
|
||||
}
|
||||
guard verifiedCharacterCount
|
||||
>= PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount else {
|
||||
guard verifiedCharacterCount >= requiredCharacterCount else {
|
||||
throw PolishStyleLearningError.insufficientCorpus(
|
||||
required: PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount,
|
||||
required: requiredCharacterCount,
|
||||
actual: verifiedCharacterCount
|
||||
)
|
||||
}
|
||||
|
||||
// Freeze provider, model, credential channel and contamination controls
|
||||
// so retries and both model stages describe one coherent operation.
|
||||
let configuration = LiveConfigurationStore(
|
||||
snapshot: LiveConfigurationSnapshot(store: store)
|
||||
)
|
||||
let notifiesManagedCredits = client == nil
|
||||
&& configuration.credentialSource == .managed
|
||||
let selectedASRExamples = Self.selectExamples(from: corpus.examples)
|
||||
let selectedReplyExamples = Self.selectReplyExamples(from: replyExamples)
|
||||
let evidencePayload = try Self.makeEvidenceRequestPayload(
|
||||
corpus: corpus,
|
||||
replyExamples: selectedReplyExamples,
|
||||
activeStyleID: store.activePolishStyleId,
|
||||
catalog: store.polishStyleCatalog,
|
||||
activeStyleID: configuration.activePolishStyleId,
|
||||
catalog: configuration.polishStyleCatalog,
|
||||
outputLanguage: outputLanguage
|
||||
)
|
||||
let service = PolishingService(
|
||||
store: store,
|
||||
store: configuration,
|
||||
client: client,
|
||||
timeout: 45
|
||||
timeout: 45,
|
||||
maximumTimeout: 45
|
||||
)
|
||||
let evidenceResponse = try await service.polish(
|
||||
evidencePayload,
|
||||
systemPrompt: Self.evidenceExtractorSystemPrompt(),
|
||||
taskKind: .customSkill
|
||||
let evidence = try await extractEvidence(
|
||||
payload: evidencePayload,
|
||||
service: service,
|
||||
requiresBestEffortASRCandidate: !selectedASRExamples.isEmpty,
|
||||
notifiesManagedCredits: notifiesManagedCredits
|
||||
)
|
||||
notifyManagedCreditsMayHaveChanged()
|
||||
let evidence = try Self.parseEvidence(evidenceResponse)
|
||||
let metadata = PolishStylePack.LearningMetadata(
|
||||
schemaVersion: Self.learningSchemaVersion,
|
||||
evidenceStatus: evidence.status.rawValue,
|
||||
@@ -392,28 +543,99 @@ public actor PolishStyleLearningService {
|
||||
}.count,
|
||||
generatedAt: Date()
|
||||
)
|
||||
// Always synthesize from this operation's evidence. Low-confidence
|
||||
// profiles use their strongest candidate traits; only genuinely empty
|
||||
// profiles may disclose that no personal tendency was observed.
|
||||
let synthesisPayload = try Self.makeSynthesisRequestPayload(
|
||||
evidence: evidence,
|
||||
metadata: metadata
|
||||
)
|
||||
let synthesisResponse = try await service.polish(
|
||||
synthesisPayload,
|
||||
systemPrompt: Self.synthesizerSystemPrompt(
|
||||
outputLanguage: outputLanguage
|
||||
),
|
||||
taskKind: .customSkill
|
||||
)
|
||||
notifyManagedCreditsMayHaveChanged()
|
||||
return try Self.parseGeneratedStyle(
|
||||
synthesisResponse,
|
||||
evidenceStatus: evidence.status,
|
||||
return try await synthesizeStyle(
|
||||
payload: synthesisPayload,
|
||||
service: service,
|
||||
learningMetadata: metadata,
|
||||
outputLanguage: outputLanguage
|
||||
outputLanguage: outputLanguage,
|
||||
notifiesManagedCredits: notifiesManagedCredits
|
||||
)
|
||||
}
|
||||
|
||||
private func notifyManagedCreditsMayHaveChanged() {
|
||||
guard client == nil, store.credentialSource == .managed else { return }
|
||||
private func extractEvidence(
|
||||
payload: String,
|
||||
service: PolishingService,
|
||||
requiresBestEffortASRCandidate: Bool,
|
||||
notifiesManagedCredits: Bool
|
||||
) async throws -> PolishStyleLearningEvidence {
|
||||
let response = try await service.polish(
|
||||
payload,
|
||||
systemPrompt: Self.evidenceExtractorSystemPrompt(),
|
||||
options: Self.generationOptions,
|
||||
taskKind: .customSkill
|
||||
)
|
||||
notifyManagedCreditsMayHaveChanged(ifNeeded: notifiesManagedCredits)
|
||||
do {
|
||||
return try Self.parseEvidence(
|
||||
response,
|
||||
requiresBestEffortASRCandidate: requiresBestEffortASRCandidate
|
||||
)
|
||||
} catch let error as PolishStyleLearningError
|
||||
where error == .invalidResponse {
|
||||
let repairedResponse = try await service.polish(
|
||||
payload,
|
||||
systemPrompt: Self.evidenceRepairSystemPrompt(),
|
||||
options: Self.generationOptions,
|
||||
taskKind: .customSkill
|
||||
)
|
||||
notifyManagedCreditsMayHaveChanged(ifNeeded: notifiesManagedCredits)
|
||||
return try Self.parseEvidence(
|
||||
repairedResponse,
|
||||
requiresBestEffortASRCandidate: requiresBestEffortASRCandidate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func synthesizeStyle(
|
||||
payload: String,
|
||||
service: PolishingService,
|
||||
learningMetadata: PolishStylePack.LearningMetadata,
|
||||
outputLanguage: AppUILanguage,
|
||||
notifiesManagedCredits: Bool
|
||||
) async throws -> PolishStylePack {
|
||||
let response = try await service.polish(
|
||||
payload,
|
||||
systemPrompt: Self.synthesizerSystemPrompt(
|
||||
outputLanguage: outputLanguage
|
||||
),
|
||||
options: Self.generationOptions,
|
||||
taskKind: .customSkill
|
||||
)
|
||||
notifyManagedCreditsMayHaveChanged(ifNeeded: notifiesManagedCredits)
|
||||
do {
|
||||
return try Self.parseGeneratedStyle(
|
||||
response,
|
||||
learningMetadata: learningMetadata,
|
||||
outputLanguage: outputLanguage
|
||||
)
|
||||
} catch let error as PolishStyleLearningError
|
||||
where error == .invalidResponse {
|
||||
let repairedResponse = try await service.polish(
|
||||
payload,
|
||||
systemPrompt: Self.synthesisRepairSystemPrompt(
|
||||
outputLanguage: outputLanguage
|
||||
),
|
||||
options: Self.generationOptions,
|
||||
taskKind: .customSkill
|
||||
)
|
||||
notifyManagedCreditsMayHaveChanged(ifNeeded: notifiesManagedCredits)
|
||||
return try Self.parseGeneratedStyle(
|
||||
repairedResponse,
|
||||
learningMetadata: learningMetadata,
|
||||
outputLanguage: outputLanguage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyManagedCreditsMayHaveChanged(ifNeeded shouldNotify: Bool) {
|
||||
guard shouldNotify else { return }
|
||||
NotificationCenter.default.post(name: .managedCreditsMayHaveChanged, object: nil)
|
||||
}
|
||||
|
||||
@@ -445,6 +667,10 @@ public actor PolishStyleLearningService {
|
||||
userCatalog: catalog
|
||||
)
|
||||
let selectedExamples = selectExamples(from: corpus.examples)
|
||||
let baselineStyle = PolishStylePackCatalog.resolve(
|
||||
id: "builtin.chat",
|
||||
userCatalog: catalog
|
||||
)
|
||||
let references = styleReferences(
|
||||
for: selectedExamples,
|
||||
activeStyle: activeStyle,
|
||||
@@ -454,6 +680,10 @@ public actor PolishStyleLearningService {
|
||||
let payload = EvidenceRequestPayload(
|
||||
schemaVersion: learningSchemaVersion,
|
||||
asr: ASRInput(
|
||||
residualBaseline: reference(
|
||||
for: baselineStyle,
|
||||
outputLanguage: outputLanguage
|
||||
),
|
||||
currentStyleContamination: reference(
|
||||
for: activeStyle,
|
||||
outputLanguage: outputLanguage
|
||||
@@ -487,15 +717,15 @@ public actor PolishStyleLearningService {
|
||||
return try encodeRequest(payload)
|
||||
}
|
||||
|
||||
static func parseEvidence(_ raw: String) throws -> PolishStyleLearningEvidence {
|
||||
guard raw.count <= maximumEvidenceResponseCharacters else {
|
||||
throw PolishStyleLearningError.invalidResponse
|
||||
}
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.first == "{",
|
||||
trimmed.last == "}",
|
||||
let data = trimmed.data(using: .utf8),
|
||||
hasExactEvidenceProtocol(data),
|
||||
static func parseEvidence(
|
||||
_ raw: String,
|
||||
requiresBestEffortASRCandidate: Bool = false
|
||||
) throws -> PolishStyleLearningEvidence {
|
||||
let data = try extractUniqueJSONObject(
|
||||
from: raw,
|
||||
maximumCharacters: maximumEvidenceResponseCharacters
|
||||
)
|
||||
guard hasExactEvidenceProtocol(data),
|
||||
let evidence = try? JSONDecoder().decode(
|
||||
PolishStyleLearningEvidence.self,
|
||||
from: data
|
||||
@@ -503,34 +733,28 @@ public actor PolishStyleLearningService {
|
||||
isValid(evidence) else {
|
||||
throw PolishStyleLearningError.invalidResponse
|
||||
}
|
||||
if requiresBestEffortASRCandidate,
|
||||
evidence.status == .insufficient,
|
||||
evidence.asr.traits.isEmpty {
|
||||
throw PolishStyleLearningError.invalidResponse
|
||||
}
|
||||
return evidence
|
||||
}
|
||||
|
||||
static func parseGeneratedStyle(
|
||||
_ raw: String,
|
||||
evidenceStatus: PolishStyleLearningEvidence.Status = .sufficient,
|
||||
learningMetadata: PolishStylePack.LearningMetadata? = nil,
|
||||
outputLanguage: AppUILanguage
|
||||
) throws -> PolishStylePack {
|
||||
guard raw.count <= maximumSynthesisResponseCharacters else {
|
||||
throw PolishStyleLearningError.invalidResponse
|
||||
}
|
||||
let trimmedResponse = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmedResponse.first == "{",
|
||||
trimmedResponse.last == "}",
|
||||
let data = trimmedResponse.data(using: .utf8),
|
||||
hasExactGeneratedStyleProtocol(data),
|
||||
let data = try extractUniqueJSONObject(
|
||||
from: raw,
|
||||
maximumCharacters: maximumSynthesisResponseCharacters
|
||||
)
|
||||
guard hasExactGeneratedStyleProtocol(data),
|
||||
let generated = try? JSONDecoder().decode(GeneratedStyle.self, from: data) else {
|
||||
throw PolishStyleLearningError.invalidResponse
|
||||
}
|
||||
|
||||
if evidenceStatus == .insufficient {
|
||||
return insufficientEvidencePack(
|
||||
outputLanguage: outputLanguage,
|
||||
learningMetadata: learningMetadata
|
||||
)
|
||||
}
|
||||
|
||||
let prompt = PolishStylePackCatalog.runtimePersonality(
|
||||
for: PolishStylePack(
|
||||
name: "Generated",
|
||||
@@ -579,9 +803,33 @@ public actor PolishStyleLearningService {
|
||||
Markdown, prose, code fences, extra keys, or trailing content.
|
||||
- Keep every string at most 320 characters and every array small.
|
||||
|
||||
PERSONAL RESIDUAL METHOD:
|
||||
- Use asr.residualBaseline (always builtin.chat) only to subtract generic
|
||||
AI cleanup operations. It is not a population norm and must not erase
|
||||
concrete habits observed in the user's raw before text merely because
|
||||
builtin.chat also preserves or permits those habits.
|
||||
- Learn the user's strongest supported residual or, when evidence is
|
||||
sparse, the strongest bounded candidate tendency in raw before text.
|
||||
- Deduplicate exact and near-duplicate examples before counting support.
|
||||
Template variants and repeated copies count as one observation.
|
||||
- Subtract scene, audience/relationship, topic, transient emotion, and
|
||||
ASR recognition artifacts. Also subtract both currentStyleContamination
|
||||
and historicalStyleContamination; those prompts are negative controls,
|
||||
never evidence of identity or preference.
|
||||
- Evaluate residuals separately for information order, epistemic stance,
|
||||
directness, speech acts, rhythm, connective words, register, humor,
|
||||
and Emoji. Do not collapse these dimensions into a vague persona.
|
||||
- Label each described trait as retention or migration. Retention means
|
||||
a native habit to preserve when already present. Migration means a
|
||||
supported relative preference that may be actively transferred.
|
||||
|
||||
EVIDENCE DOMAINS MUST STAY SEPARATE:
|
||||
- asr contains dictation before/after pairs and prior style prompts used
|
||||
only as negative contamination controls.
|
||||
- asr contains dictation before/after pairs and style prompts used only
|
||||
as negative contamination controls. before is the user's native voice.
|
||||
A userEdited=true after is the user's highest-priority final revision.
|
||||
A userEdited=false after is untouched AI output: it can reveal what
|
||||
was retained from before, but cannot support a user preference or
|
||||
migration trait.
|
||||
- reply contains received messages, one or three AI candidates, the
|
||||
selection or explicit discard, and an optional user finalEdit.
|
||||
- receivedMessage and every selected/candidate AI text are NOT the
|
||||
@@ -589,22 +837,40 @@ public actor PolishStyleLearningService {
|
||||
- Reply preferences must never become ASR traits.
|
||||
|
||||
EVIDENCE PRIORITY:
|
||||
- ASR: userEdited=true after > traits repeated across before.
|
||||
- Across both domains, the user's final revision is strongest.
|
||||
- ASR: userEdited=true after > traits repeated across native before.
|
||||
- Reply: finalEdit > the same selection preference repeated across
|
||||
different received-message contexts > one accepted selection.
|
||||
Cross-context selection is relative preference evidence between the
|
||||
offered candidates, not a sample of the user's original voice.
|
||||
A discarded set is negative evidence, never a positive voice sample.
|
||||
A contextual selection records a scene decision, not a tone
|
||||
preference. Always ignore its selected candidate for voice learning;
|
||||
when finalEdit exists, use only that user-authored finalEdit.
|
||||
- asrRepeatedBefore and replyCrossContextSelection require supportCount
|
||||
of at least 2. Order evidence strongest first.
|
||||
- A single accepted AI candidate is weak preference evidence only.
|
||||
|
||||
INSUFFICIENT EVIDENCE:
|
||||
- Include only repeatedly supported traits.
|
||||
- Insufficient means confidence is low, not that personalization must
|
||||
become neutral. When asr.examples is non-empty, always include 1–3
|
||||
concrete candidate retention traits grounded directly in raw before
|
||||
text. Use asrObservedBefore for a single observation and
|
||||
asrRepeatedBefore for a pattern supported by at least two deduplicated
|
||||
observations.
|
||||
- Candidate traits should describe observable form: information order,
|
||||
directness, sentence length and rhythm, connective words, register,
|
||||
speech acts, humor, or Emoji usage. Do not reduce them to generic
|
||||
"preserve meaning", "be clear", or ASR-correction rules.
|
||||
- If support is insufficient or contradictory, set status to
|
||||
"insufficient", confidence no higher than 0.25, and return empty
|
||||
traits, evidence, and contradictions in both domains. Never guess.
|
||||
"insufficient" and confidence no higher than 0.35. Traits may be
|
||||
present only when their own confidence is no higher than 0.35 and
|
||||
they have matching evidence. Empty domains are valid. Never guess.
|
||||
The ASR domain may be empty only when asr.examples itself is empty.
|
||||
- Set status to "sufficient" only when total confidence is at least 0.5.
|
||||
|
||||
Allowed source values:
|
||||
asrUserEdit, asrRepeatedBefore, replyFinalEdit,
|
||||
asrUserEdit, asrRepeatedBefore, asrObservedBefore, replyFinalEdit,
|
||||
replyCrossContextSelection, replyAcceptance.
|
||||
|
||||
Return this exact Codable shape:
|
||||
@@ -625,6 +891,18 @@ public actor PolishStyleLearningService {
|
||||
"""
|
||||
}
|
||||
|
||||
static func evidenceRepairSystemPrompt() -> String {
|
||||
evidenceExtractorSystemPrompt() + """
|
||||
|
||||
|
||||
REPAIR ATTEMPT:
|
||||
- The previous response failed local protocol validation.
|
||||
- Reanalyze the original payload above. Emit only one syntactically
|
||||
valid JSON object matching the exact schema and validation limits.
|
||||
- Do not mention the failed response and do not add a second object.
|
||||
"""
|
||||
}
|
||||
|
||||
static func synthesizerSystemPrompt(outputLanguage: AppUILanguage) -> String {
|
||||
let language = outputLanguage.resolvedLanguageCode().hasPrefix("zh")
|
||||
? "Simplified Chinese"
|
||||
@@ -654,15 +932,30 @@ public actor PolishStyleLearningService {
|
||||
PolishPromptComposer owns those stable contracts. Do not invent a trait
|
||||
absent from the evidence. Represent contradictions as boundaries.
|
||||
|
||||
STATUS AND CONFIDENCE:
|
||||
- Always return a generated prompt, including when evidence.status is
|
||||
"insufficient" or both evidence domains are empty.
|
||||
- Drive the prompt from evidence.status. For insufficient or low-
|
||||
confidence evidence, actively turn every supported candidate trait
|
||||
into a concrete, scoped retention rule and representative example.
|
||||
Low confidence changes the scope and disclosure, not whether the
|
||||
observed personal tendency is applied.
|
||||
- Never replace non-empty candidate traits with a generic neutral prompt,
|
||||
"preserve meaning", "be clear", or ASR-correction boilerplate. The
|
||||
generated prompt must visibly differ according to the supplied traits.
|
||||
- Never invent migration, identity, persona, humor, Emoji habits, or
|
||||
other characteristics to make an insufficient result feel complete.
|
||||
Only when both evidence domains are genuinely empty may the output
|
||||
state that no personal tendency could be observed.
|
||||
- Migration belongs only in AI reply active-transfer mode and requires
|
||||
supported reply evidence. ASR retention never authorizes migration.
|
||||
|
||||
Emoji boundary: never create a generic no-emoji rule for AI reply
|
||||
active-transfer mode. Legal Emoji produced by a playful/fun skill must
|
||||
survive. Set allowsAddedEmoji=true only when reply evidence supports
|
||||
user-added or repeatedly selected Emoji; ASR preserve mode still may not
|
||||
add unsupported Emoji.
|
||||
|
||||
If evidence.status is "insufficient", return a conservative JSON object;
|
||||
its content will be replaced by the app's deterministic no-trait fallback.
|
||||
|
||||
SECURITY AND PROTOCOL:
|
||||
- Return exactly one JSON object with exactly these three keys.
|
||||
- No Markdown fences, surrounding prose, extra keys, or trailing text.
|
||||
@@ -673,6 +966,19 @@ public actor PolishStyleLearningService {
|
||||
"""
|
||||
}
|
||||
|
||||
static func synthesisRepairSystemPrompt(outputLanguage: AppUILanguage) -> String {
|
||||
synthesizerSystemPrompt(outputLanguage: outputLanguage) + """
|
||||
|
||||
|
||||
REPAIR ATTEMPT:
|
||||
- The previous response failed local protocol validation.
|
||||
- Re-synthesize from the original validated evidence payload above.
|
||||
Emit only one valid JSON object with exactly name, prompt, and
|
||||
allowsAddedEmoji. Preserve all required sections and mode labels.
|
||||
- Do not mention the failed response and do not add a second object.
|
||||
"""
|
||||
}
|
||||
|
||||
private static func selectExamples(
|
||||
from examples: [PolishStyleLearningExample]
|
||||
) -> [PolishStyleLearningExample] {
|
||||
@@ -859,6 +1165,70 @@ public actor PolishStyleLearningService {
|
||||
)
|
||||
}
|
||||
|
||||
private static func extractUniqueJSONObject(
|
||||
from raw: String,
|
||||
maximumCharacters: Int
|
||||
) throws -> Data {
|
||||
guard raw.count <= maximumCharacters else {
|
||||
throw PolishStyleLearningError.invalidResponse
|
||||
}
|
||||
|
||||
var objectRanges: [Range<String.Index>] = []
|
||||
var objectStart: String.Index?
|
||||
var depth = 0
|
||||
var isInsideString = false
|
||||
var isEscaped = false
|
||||
var index = raw.startIndex
|
||||
|
||||
while index < raw.endIndex {
|
||||
let character = raw[index]
|
||||
let nextIndex = raw.index(after: index)
|
||||
if objectStart == nil {
|
||||
if character == "{" {
|
||||
objectStart = index
|
||||
depth = 1
|
||||
isInsideString = false
|
||||
isEscaped = false
|
||||
}
|
||||
} else if isInsideString {
|
||||
if isEscaped {
|
||||
isEscaped = false
|
||||
} else if character == "\\" {
|
||||
isEscaped = true
|
||||
} else if character == "\"" {
|
||||
isInsideString = false
|
||||
}
|
||||
} else {
|
||||
switch character {
|
||||
case "\"":
|
||||
isInsideString = true
|
||||
case "{":
|
||||
depth += 1
|
||||
case "}":
|
||||
depth -= 1
|
||||
if depth == 0, let start = objectStart {
|
||||
objectRanges.append(start..<nextIndex)
|
||||
objectStart = nil
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
index = nextIndex
|
||||
}
|
||||
|
||||
guard objectStart == nil,
|
||||
objectRanges.count == 1 else {
|
||||
throw PolishStyleLearningError.invalidResponse
|
||||
}
|
||||
let object = String(raw[objectRanges[0]])
|
||||
guard object.count <= maximumCharacters,
|
||||
let data = object.data(using: .utf8) else {
|
||||
throw PolishStyleLearningError.invalidResponse
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private static func hasExactEvidenceProtocol(_ data: Data) -> Bool {
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data),
|
||||
let root = object as? [String: Any],
|
||||
@@ -903,7 +1273,11 @@ public actor PolishStyleLearningService {
|
||||
(0...1).contains(evidence.confidence),
|
||||
isValid(
|
||||
evidence.asr,
|
||||
allowedSources: [.asrUserEdit, .asrRepeatedBefore]
|
||||
allowedSources: [
|
||||
.asrUserEdit,
|
||||
.asrRepeatedBefore,
|
||||
.asrObservedBefore
|
||||
]
|
||||
),
|
||||
isValid(
|
||||
evidence.reply,
|
||||
@@ -917,11 +1291,12 @@ public actor PolishStyleLearningService {
|
||||
}
|
||||
|
||||
if evidence.status == .insufficient {
|
||||
return evidence.confidence <= 0.25
|
||||
&& isEmpty(evidence.asr)
|
||||
&& isEmpty(evidence.reply)
|
||||
return evidence.confidence <= 0.35
|
||||
&& evidence.asr.traits.allSatisfy { $0.confidence <= 0.35 }
|
||||
&& evidence.reply.traits.allSatisfy { $0.confidence <= 0.35 }
|
||||
}
|
||||
return !evidence.asr.traits.isEmpty || !evidence.reply.traits.isEmpty
|
||||
return evidence.confidence >= 0.5
|
||||
&& (!evidence.asr.traits.isEmpty || !evidence.reply.traits.isEmpty)
|
||||
}
|
||||
|
||||
private static func isValid(
|
||||
@@ -967,7 +1342,7 @@ public actor PolishStyleLearningService {
|
||||
switch item.source {
|
||||
case .asrRepeatedBefore, .replyCrossContextSelection:
|
||||
return item.supportCount >= 2
|
||||
case .asrUserEdit, .replyFinalEdit, .replyAcceptance:
|
||||
case .asrUserEdit, .asrObservedBefore, .replyFinalEdit, .replyAcceptance:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -988,19 +1363,11 @@ public actor PolishStyleLearningService {
|
||||
return 0
|
||||
case .asrRepeatedBefore, .replyCrossContextSelection:
|
||||
return 1
|
||||
case .replyAcceptance:
|
||||
case .asrObservedBefore, .replyAcceptance:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
private static func isEmpty(
|
||||
_ domain: PolishStyleLearningEvidence.Domain
|
||||
) -> Bool {
|
||||
domain.traits.isEmpty
|
||||
&& domain.evidence.isEmpty
|
||||
&& domain.contradictions.isEmpty
|
||||
}
|
||||
|
||||
private static func hasRequiredPromptSections(_ prompt: String) -> Bool {
|
||||
let hasRole = prompt.contains("# 角色")
|
||||
|| prompt.contains("#角色")
|
||||
@@ -1017,46 +1384,6 @@ public actor PolishStyleLearningService {
|
||||
&& lowercased.contains("ai reply active-transfer mode")
|
||||
}
|
||||
|
||||
private static func insufficientEvidencePack(
|
||||
outputLanguage: AppUILanguage,
|
||||
learningMetadata: PolishStylePack.LearningMetadata?
|
||||
) -> PolishStylePack {
|
||||
let isChinese = outputLanguage.resolvedLanguageCode().hasPrefix("zh")
|
||||
let name = isChinese ? "保守保真风格" : "Conservative Preserve Style"
|
||||
let prompt = isChinese
|
||||
? """
|
||||
# 角色
|
||||
在证据不足时不推断个人口吻,只做保守、自然的表达保真。
|
||||
|
||||
# 风格边界
|
||||
ASR preserve mode:保持用户原有语义、言语行为、措辞和直接程度,不引入回复偏好。
|
||||
AI reply active-transfer mode:当前没有足够的个人回复偏好证据,不主动迁移任何风格特征。
|
||||
|
||||
# 示例
|
||||
输入 → 保持原意与原有口吻,不增加未经证据支持的表达习惯。
|
||||
"""
|
||||
: """
|
||||
# Role
|
||||
# 角色
|
||||
With insufficient evidence, infer no personal voice and preserve expression conservatively.
|
||||
|
||||
# Style Boundaries
|
||||
# 风格边界
|
||||
ASR preserve mode: preserve meaning, speech act, wording, and directness without reply preferences.
|
||||
AI reply active-transfer mode: no reply preference has enough evidence, so transfer no inferred trait.
|
||||
|
||||
# Examples
|
||||
# 示例
|
||||
Input → Preserve intent and voice without adding unsupported habits.
|
||||
"""
|
||||
return PolishStylePack(
|
||||
name: name,
|
||||
prompt: prompt,
|
||||
allowsAddedEmoji: false,
|
||||
learningMetadata: learningMetadata
|
||||
)
|
||||
}
|
||||
|
||||
private static func containsInstructionOverride(_ prompt: String) -> Bool {
|
||||
let lowercased = prompt.lowercased()
|
||||
let unsafeMarkers = [
|
||||
|
||||
@@ -63,6 +63,7 @@ public actor PolishingService {
|
||||
let raw: String
|
||||
let mode: PolishMode
|
||||
let systemPrompt: String?
|
||||
let options: LLMGenerationOptions?
|
||||
let providerIdOverride: String?
|
||||
let taskKind: ManagedGatewayTaskKind?
|
||||
let requestPurpose: ManagedGatewayRequestPurpose?
|
||||
@@ -92,6 +93,7 @@ public actor PolishingService {
|
||||
|
||||
private let store: any ConfigurationStore
|
||||
private let timeout: TimeInterval
|
||||
private let maximumTimeout: TimeInterval
|
||||
private let analyticsClient: any AnalyticsClient
|
||||
/// Optional injected client (mostly for testing). When nil we build
|
||||
/// one from `store.makeClient()` per call.
|
||||
@@ -102,15 +104,19 @@ public actor PolishingService {
|
||||
/// shared `LLMClient.requestTimeout`. The safety-net timer adds its
|
||||
/// own slack on top of the length-scaled budget in `polishRemote`, so
|
||||
/// no `+1` is baked in here.
|
||||
/// `maximumTimeout` defaults to the keyboard watchdog-compatible 35 s;
|
||||
/// explicit host workflows may raise it together with their baseline.
|
||||
public init(
|
||||
store: any ConfigurationStore = AppGroupStore(),
|
||||
client: LLMClient? = nil,
|
||||
timeout: TimeInterval? = nil,
|
||||
maximumTimeout: TimeInterval = FlowSessionKeys.maxPolishTimeout,
|
||||
analyticsClient: any AnalyticsClient = NoopAnalyticsClient()
|
||||
) {
|
||||
self.store = store
|
||||
self.injectedClient = client
|
||||
self.timeout = timeout ?? LLMClientFactory.defaultRequestTimeout
|
||||
self.maximumTimeout = maximumTimeout
|
||||
self.analyticsClient = analyticsClient
|
||||
}
|
||||
|
||||
@@ -124,6 +130,7 @@ public actor PolishingService {
|
||||
_ raw: String,
|
||||
mode: PolishMode = .polish,
|
||||
systemPrompt: String? = nil,
|
||||
options: LLMGenerationOptions? = nil,
|
||||
providerIdOverride: String? = nil,
|
||||
taskKind: ManagedGatewayTaskKind? = nil,
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
@@ -135,6 +142,7 @@ public actor PolishingService {
|
||||
raw: raw,
|
||||
mode: mode,
|
||||
systemPrompt: systemPrompt,
|
||||
options: options,
|
||||
providerIdOverride: providerIdOverride,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose,
|
||||
@@ -150,6 +158,7 @@ public actor PolishingService {
|
||||
_ raw: String,
|
||||
mode: PolishMode = .polish,
|
||||
systemPrompt: String? = nil,
|
||||
options: LLMGenerationOptions? = nil,
|
||||
providerIdOverride: String? = nil,
|
||||
taskKind: ManagedGatewayTaskKind? = nil,
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
@@ -161,6 +170,7 @@ public actor PolishingService {
|
||||
raw: raw,
|
||||
mode: mode,
|
||||
systemPrompt: systemPrompt,
|
||||
options: options,
|
||||
providerIdOverride: providerIdOverride,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose,
|
||||
@@ -230,6 +240,7 @@ public actor PolishingService {
|
||||
trimmed,
|
||||
mode: mode,
|
||||
systemPrompt: systemPrompt,
|
||||
options: request.options,
|
||||
providerIdOverride: providerIdOverride,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose,
|
||||
@@ -303,6 +314,8 @@ public actor PolishingService {
|
||||
switch error {
|
||||
case .cancelled:
|
||||
return .cancelled
|
||||
case .timeout:
|
||||
return .timeout
|
||||
case .transport, .rateLimited:
|
||||
return .network
|
||||
case .invalidURL, .noAPIKey, .decoding:
|
||||
@@ -327,6 +340,7 @@ public actor PolishingService {
|
||||
_ trimmed: String,
|
||||
mode: PolishMode,
|
||||
systemPrompt: String? = nil,
|
||||
options: LLMGenerationOptions? = nil,
|
||||
providerIdOverride: String? = nil,
|
||||
taskKind: ManagedGatewayTaskKind? = nil,
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
@@ -397,9 +411,8 @@ public actor PolishingService {
|
||||
id: activeStyle.id,
|
||||
intensity: store.polishIntensity
|
||||
)
|
||||
let firstOptions: LLMGenerationOptions = usesHeavyFunPersonality
|
||||
? .funCreative
|
||||
: .polishDefault
|
||||
let firstOptions = options
|
||||
?? (usesHeavyFunPersonality ? .funCreative : .polishDefault)
|
||||
logPolishConfiguration(
|
||||
prompt: prompt,
|
||||
mode: mode,
|
||||
@@ -471,7 +484,7 @@ public actor PolishingService {
|
||||
options: options
|
||||
)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
} catch HardTimeoutError.timedOut {
|
||||
throw PolishError.timeout
|
||||
}
|
||||
}
|
||||
@@ -581,7 +594,7 @@ public actor PolishingService {
|
||||
/// the *actual* value handed to `LLMClient.polish(timeout:)`, so long
|
||||
/// dictations (which generate long, listified, multi-paragraph output)
|
||||
/// are not cut off mid-generation by a fixed 15 s ceiling. Grows by
|
||||
/// ~10 s per 100 characters, capped at 120 s.
|
||||
/// ~10 s per 100 characters, capped by `maximumTimeout`.
|
||||
///
|
||||
/// Previously this value was computed but only used for the safety-net
|
||||
/// timer while the URLRequest stayed pinned at 15 s — the scaling was
|
||||
@@ -589,14 +602,17 @@ public actor PolishingService {
|
||||
/// (unpolished, unsegmented) ASR text.
|
||||
internal func effectiveTimeout(for text: String) -> TimeInterval {
|
||||
if timeout == LLMClientFactory.defaultRequestTimeout {
|
||||
return FlowSessionKeys.polishTimeout(forCharacterCount: text.count)
|
||||
return min(
|
||||
FlowSessionKeys.polishTimeout(forCharacterCount: text.count),
|
||||
maximumTimeout
|
||||
)
|
||||
}
|
||||
let scaled = timeout + (Double(text.count) / 100.0) * 10.0
|
||||
// The cap participates in the keyboard-watchdog budget — see
|
||||
// `FlowSessionKeys.keyboardResultTimeout`. Raising it here without
|
||||
// going through that constant would silently break the invariant
|
||||
// "keyboard timeout > host worst case".
|
||||
return min(max(scaled, timeout), FlowSessionKeys.maxPolishTimeout)
|
||||
return min(max(scaled, timeout), maximumTimeout)
|
||||
}
|
||||
|
||||
internal static func resolvedProviderId(
|
||||
|
||||
@@ -229,6 +229,10 @@ private final class HardTimeoutRace<T: Sendable>: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum HardTimeoutError: Error, Equatable, Sendable {
|
||||
case timedOut
|
||||
}
|
||||
|
||||
public enum HardTimeout {
|
||||
/// Returns at the deadline even when the losing operation ignores
|
||||
/// cooperative cancellation. The detached loser is still cancelled, but
|
||||
@@ -252,7 +256,7 @@ public enum HardTimeout {
|
||||
try await Task.sleep(
|
||||
nanoseconds: UInt64(max(0, seconds) * 1_000_000_000)
|
||||
)
|
||||
race.resolve(.failure(CancellationError()))
|
||||
race.resolve(.failure(HardTimeoutError.timedOut))
|
||||
} catch {
|
||||
// The operation won and cancelled this timer.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user