Files
kangkang/康康/Services/DiaryAssistService.swift
link2026 558682c8f8 根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:
```
docs(readme): 更新文档说明

- 添加项目使用说明
- 完善配置指南
```

注意:由于未提供具体的代码差异信息,以上为示例格式。请提供实际的代码变更内容以生成准确的commit message。
2026-07-01 10:30:22 +08:00

230 lines
10 KiB
Swift

import Foundation
/// AI : LLM 3-4
///
/// HealthExportService ,(< 400 token),
/// await
///
/// :DiaryQuickSheet
@MainActor
struct DiaryAssistService {
static let shared = DiaryAssistService()
private init() {}
/// fill ,
/// `dim` ( `DiaryAssistPrompts.dimensions`),
/// `adopted` UI ;`round` UI append ,
struct Question: Identifiable, Hashable {
let id: UUID
let q: String
let fill: String
let dim: String
var adopted: Bool
var round: Int
init(id: UUID = UUID(),
q: String,
fill: String,
dim: String = "",
adopted: Bool = false,
round: Int = 0) {
self.id = id
self.q = q
self.fill = fill
self.dim = dim
self.adopted = adopted
self.round = round
}
}
enum AssistError: Error, LocalizedError {
case modelNotReady
case empty
case parseFailed(String)
var errorDescription: String? {
switch self {
case .modelNotReady: return String(appLoc: "AI 模型尚未准备好")
case .empty: return String(appLoc: "AI 没有给出建议,请稍后重试")
case .parseFailed(let m): return String(appLoc: "结果解析失败:\(m)")
}
}
}
/// 3-4
/// - coveredDimensions: ,( question.dim),
/// prompt
/// : AIRuntime actor , Capture / Export GPU
func suggest(content: String,
coveredDimensions: [String] = []) async throws -> (questions: [Question], decodeRate: Double) {
do {
try await AIRuntime.shared.prepare()
} catch {
throw AssistError.modelNotReady
}
let prompt = DiaryAssistPrompts.suggest(content: content, coveredDimensions: coveredDimensions)
// JSON / {"questions":}
// , §10.5退, AI
var lastRate: Double = 0
var parsedButEmpty = false
var lastRaw = ""
for _ in 0..<2 {
try Task.checkCancellation()
var collected = ""
// 512( 400):3-4 dim/q/fill JSON 250-320 token,400
// /
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 512)
for try await chunk in stream {
collected += chunk.text
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
}
lastRaw = collected
if let questions = Self.parseQuestions(from: collected) {
if !questions.isEmpty {
return (Array(questions.prefix(4)), lastRate)
}
parsedButEmpty = true // JSON :, .empty
}
}
// ,,便
#if DEBUG
print("[DiaryAssistService] 解析失败,原始输出 = \(lastRaw)")
#endif
throw parsedButEmpty ? AssistError.empty : AssistError.parseFailed("非 JSON 输出")
}
/// ( §3.2 退):
/// `<think>` JSON `{"questions":[]}`,
/// 退 `[{}]`(MNN ) nil(/)
/// `[]`( nil : .empty .parseFailed)
static func parseQuestions(from raw: String) -> [Question]? {
let stripped = HealthExportService.stripThinkBlocks(raw)
var rawQuestions: [[String: Any]]?
// {"questions":[]}
let objStr = CaptureService.repairJSON(CaptureService.extractJSONObject(from: stripped))
if let data = objStr.data(using: .utf8),
let dict = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let arr = dict["questions"] as? [[String: Any]] {
rawQuestions = arr
}
// 退:, [{},{}]
if rawQuestions == nil {
let arrStr = CaptureService.repairJSON(CaptureService.extractBalancedJSON(from: stripped))
if let data = arrStr.data(using: .utf8),
let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] {
rawQuestions = arr
}
}
// : / maxTokens ,
// {}, "q" 便
// , §3.2 / 线 #5退, AI
if rawQuestions == nil {
let salvaged = salvageQuestionObjects(from: stripped)
if !salvaged.isEmpty { rawQuestions = salvaged }
}
guard let rawQuestions else { return nil }
return rawQuestions.compactMap { d -> Question? in
guard let q = (d["q"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines), !q.isEmpty else {
return nil
}
let fill = (d["fill"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let dim = (d["dim"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return Question(q: q, fill: fill, dim: dim)
}
}
/// : `{}` (),,
/// `"q"` :
/// - `{"questions":[]}` `}` question ,;
/// -
/// wrapper `"q"`( `"questions"`),,
/// q (),
private static func salvageQuestionObjects(from raw: String) -> [[String: Any]] {
var openStack: [String.Index] = []
var collected: [[String: Any]] = []
var seenQ = Set<String>()
var inString = false
var escape = false
var idx = raw.startIndex
while idx < raw.endIndex {
let ch = raw[idx]
if escape { escape = false }
else if ch == "\\" { escape = true }
else if ch == "\"" { inString.toggle() }
else if !inString {
if ch == "{" {
openStack.append(idx)
} else if ch == "}", let open = openStack.popLast() {
let sub = CaptureService.repairJSON(String(raw[open...idx]))
if let data = sub.data(using: .utf8),
let dict = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let q = dict["q"] as? String,
!q.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
seenQ.insert(q).inserted {
collected.append(dict)
}
}
}
idx = raw.index(after: idx)
}
return collected
}
/// 稿稿(spec 2026-06-10-voice-diary)
/// ( / ),退使,
/// suggest AIRuntime actor ,/
func organize(transcript: String) async throws -> (text: String, decodeRate: Double) {
do {
try await AIRuntime.shared.prepare()
} catch {
throw AssistError.modelNotReady
}
let prompt = DiaryAssistPrompts.organize(transcript: transcript)
var collected = ""
var lastRate: Double = 0
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400)
for try await chunk in stream {
collected += chunk.text
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
}
let text = HealthExportService.stripThinkBlocks(collected)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { throw AssistError.empty }
return (text, lastRate)
}
/// 稿(2026-06-28, ConsultationPrompts)
/// organize AIRuntime actor 退(), prompt/
/// maxTokens 700:,
func organizeConsultation(transcript: String) async throws -> (text: String, decodeRate: Double) {
do {
try await AIRuntime.shared.prepare()
} catch {
throw AssistError.modelNotReady
}
let prompt = ConsultationPrompts.organize(transcript: transcript)
var collected = ""
var lastRate: Double = 0
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 700)
for try await chunk in stream {
collected += chunk.text
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
}
let text = HealthExportService.stripThinkBlocks(collected)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { throw AssistError.empty }
return (text, lastRate)
}
}