Files
kangkang/康康/Services/HealthExportService.swift
link2026 9d856fcfc4 ```
feat(AI): 集成MNN推理引擎替换MLX作为主AI运行时

- 引入MNN(alibaba) + Arm SME2 + CPU作为主AI运行时,支持A19/iPhone17的
  SME2和A17的NEON加速
- 添加MLX Swift作为兜底GPU推理方案,实现双后端切换机制
- 使用单一Qwen3.5-2B多模态模型(1.2GB),替代原有的LLM+VL分离架构
- 实现InferenceEngine.current引擎选择逻辑,真机默认MNN,模拟器回退MLX
- 更新AIAgent架构,通过MNNLLMBridge(ObjC++) → MNNBackend进行推理
- 修改队列机制防止并发推理导致OOM,使用信号量闸门控制显存占用
- 更新文档中的技术栈说明、模块边界和周次交付计划
```
2026-06-15 09:24:59 +08:00

789 lines
34 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import SwiftData
///
///
/// ( spec §6):
/// prepare extractingIntent retrieving generating completed
///
/// 线:
/// - UI AI(§3.1)
/// - LLM `AIRuntime.shared` actor , CaptureService (§3.1)
/// - JSON 30 + ,(§3.2 / spec §9)
/// - (§10)
@MainActor
struct HealthExportService {
static let shared = HealthExportService()
private init() {}
// MARK: - Public types
enum Phase: String, Sendable {
case extractingIntent
case retrieving
case generating
case completed
var label: String {
switch self {
case .extractingIntent: return String(appLoc: "理解意图")
case .retrieving: return String(appLoc: "检索数据")
case .generating: return String(appLoc: "撰写报告")
case .completed: return String(appLoc: "已完成")
}
}
}
/// RAG UI (§12 3)
struct RetrievalSummary: Sendable, Equatable {
var chips: [String]
var indicatorCount: Int
var reportCount: Int
var symptomCount: Int
var diaryCount: Int
var totalCount: Int { indicatorCount + reportCount + symptomCount + diaryCount }
/// (), cap "+N",
static func groupedChips(_ names: [String], cap: Int = 8) -> [String] {
var order: [String] = []
var counts: [String: Int] = [:]
for n in names {
if counts[n] == nil { order.append(n) }
counts[n, default: 0] += 1
}
var chips = order.map { name -> String in
let c = counts[name] ?? 1
return c > 1 ? "\(name) ×\(c)" : name
}
if chips.count > cap {
let overflow = chips.count - cap
chips = Array(chips.prefix(cap)) + ["+\(overflow)"]
}
return chips
}
@MainActor
static func from(snapshot: Snapshot) -> RetrievalSummary {
var chips = groupedChips(snapshot.indicators.map(\.name), cap: 8)
chips += snapshot.reports.prefix(3).map(\.title)
chips += snapshot.symptoms.prefix(3).map(\.name)
if !snapshot.diaries.isEmpty {
chips.append(String(appLoc: "日记 ×\(snapshot.diaries.count)"))
}
return RetrievalSummary(
chips: chips,
indicatorCount: snapshot.indicators.count,
reportCount: snapshot.reports.count,
symptomCount: snapshot.symptoms.count,
diaryCount: snapshot.diaries.count
)
}
}
enum Event {
case phaseChanged(Phase)
case retrieved(RetrievalSummary)
case token(TokenChunk)
case completed(persistentID: PersistentIdentifier)
// .failed stream throw, Event
}
enum ServiceError: Error, LocalizedError {
case modelNotReady
case generationFailed(String)
case cancelled
var errorDescription: String? {
switch self {
case .modelNotReady: return String(appLoc: "AI 模型尚未准备好,请先到「我的 · 模型管理」下载。")
case .generationFailed(let m): return String(appLoc: "生成失败:\(m)")
case .cancelled: return String(appLoc: "已取消")
}
}
}
// MARK: - Entry point
/// ;UI sheet stream Service
/// MainActor
func export(prompt: String,
in modelContext: ModelContext) -> AsyncThrowingStream<Event, Error> {
AsyncThrowingStream { continuation in
let task = Task { @MainActor in
do {
// ()
do {
try await AIRuntime.shared.prepare()
} catch {
throw ServiceError.modelNotReady
}
// Phase 1:
continuation.yield(.phaseChanged(.extractingIntent))
let intent = await Self.extractIntent(userPrompt: prompt)
try Task.checkCancellation()
// Phase 2:
continuation.yield(.phaseChanged(.retrieving))
let snapshot = Self.retrieve(intent: intent, ctx: modelContext)
continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
try Task.checkCancellation()
// Phase 3:
continuation.yield(.phaseChanged(.generating))
let dataJSON = Self.serializeData(snapshot: snapshot)
var generated = ""
var lastRate: Double = 0
if Self.isEffectivelyEmpty(snapshot) {
// : LLM,,
// (线:)
generated = Self.fallbackReport(label: intent.labelCN, userPrompt: prompt)
continuation.yield(.token(TokenChunk(text: generated, decodeRate: 0)))
} else {
let genPrompt = HealthExportPrompts.reportGeneration(
userPrompt: prompt,
intentLabelCN: intent.labelCN,
dataJSON: dataJSON
)
// <think>...</think>
// Prompt Qwen3 `/no_think`, thinking
// + chunk + diff yield:
// - thinking ,UI generated
// - </think> ,
var rawAccum = ""
let stream = await AIRuntime.shared.generate(
prompt: genPrompt,
maxTokens: 1024
)
for try await chunk in stream {
try Task.checkCancellation()
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
rawAccum += chunk.text
let clean = Self.stripThinkBlocks(rawAccum)
if clean.count > generated.count, clean.hasPrefix(generated) {
let delta = String(clean.dropFirst(generated.count))
generated = clean
continuation.yield(.token(TokenChunk(
text: delta,
decodeRate: chunk.decodeRate
)))
} else if clean != generated {
// :() UI 退,
// generated = clean yield(退)
generated = clean
}
}
}
guard !generated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw ServiceError.generationFailed("模型未输出任何内容")
}
// ( LLM,)
let trendBlock = Self.trendSection(snapshot.trends)
if !trendBlock.isEmpty {
generated += trendBlock
continuation.yield(.token(TokenChunk(text: trendBlock, decodeRate: 0)))
}
// Phase 4:
let export = HealthExport(
prompt: prompt,
content: generated,
referencedIndicatorIDs: snapshot.indicators.map { Self.idString($0.persistentModelID) },
referencedReportIDs: snapshot.reports.map { Self.idString($0.persistentModelID) },
referencedSymptomIDs: snapshot.symptoms.map { Self.idString($0.persistentModelID) },
referencedDiaryIDs: snapshot.diaries.map { Self.idString($0.persistentModelID) },
inferredTimeFromDate: snapshot.fromDate,
inferredTimeToDate: snapshot.toDate,
inferredIntent: intent.intent,
inferredLabelCN: intent.labelCN,
modelTag: ModelKind.llm.rawValue, // LLM tag,( §12#6)
decodeRate: lastRate
)
modelContext.insert(export)
do { try modelContext.save() } catch {
// UI ;(W6 telemetry)
print("[HealthExportService] save failed: \(error)")
}
continuation.yield(.phaseChanged(.completed))
continuation.yield(.completed(persistentID: export.persistentModelID))
continuation.finish()
} catch is CancellationError {
continuation.finish(throwing: ServiceError.cancelled)
} catch let e as ServiceError {
continuation.finish(throwing: e)
} catch {
continuation.finish(throwing: ServiceError.generationFailed("\(error)"))
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
/// ,
/// : .retrieved(), .token ; .phaseChanged / .completed
func answer(question: String,
conversation: [HealthExportDialogueTurn],
in modelContext: ModelContext) -> AsyncThrowingStream<Event, Error> {
AsyncThrowingStream { continuation in
let task = Task { @MainActor in
do {
do {
try await AIRuntime.shared.prepare()
} catch {
throw ServiceError.modelNotReady
}
let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext)
continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
let dataJSON = Self.serializeData(snapshot: snapshot)
let transcript = HealthExportDialogueTurn.transcript(from: conversation)
let prompt = HealthExportPrompts.dialogueAnswer(
latestQuestion: question,
transcript: transcript,
dataJSON: dataJSON
)
var displayed = ""
var rawAccum = ""
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 480)
for try await chunk in stream {
try Task.checkCancellation()
rawAccum += chunk.text
let clean = Self.stripThinkBlocks(rawAccum)
if clean.count > displayed.count, clean.hasPrefix(displayed) {
let delta = String(clean.dropFirst(displayed.count))
displayed = clean
continuation.yield(.token(TokenChunk(text: delta, decodeRate: chunk.decodeRate)))
} else if clean != displayed {
displayed = clean
}
}
guard !displayed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw ServiceError.generationFailed("模型未输出任何内容")
}
continuation.finish()
} catch is CancellationError {
continuation.finish(throwing: ServiceError.cancelled)
} catch let e as ServiceError {
continuation.finish(throwing: e)
} catch {
continuation.finish(throwing: ServiceError.generationFailed("\(error)"))
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
/// HealthExport
func export(conversation: [HealthExportDialogueTurn],
in modelContext: ModelContext) -> AsyncThrowingStream<Event, Error> {
AsyncThrowingStream { continuation in
let task = Task { @MainActor in
do {
do {
try await AIRuntime.shared.prepare()
} catch {
throw ServiceError.modelNotReady
}
continuation.yield(.phaseChanged(.retrieving))
let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext)
continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
let dataJSON = Self.serializeData(snapshot: snapshot)
let transcript = HealthExportDialogueTurn.transcript(from: conversation)
try Task.checkCancellation()
continuation.yield(.phaseChanged(.generating))
let genPrompt = HealthExportPrompts.dialogueReportGeneration(
transcript: transcript,
dataJSON: dataJSON
)
var generated = ""
var rawAccum = ""
var lastRate: Double = 0
let stream = await AIRuntime.shared.generate(prompt: genPrompt, maxTokens: 1200)
for try await chunk in stream {
try Task.checkCancellation()
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
rawAccum += chunk.text
let clean = Self.stripThinkBlocks(rawAccum)
if clean.count > generated.count, clean.hasPrefix(generated) {
let delta = String(clean.dropFirst(generated.count))
generated = clean
continuation.yield(.token(TokenChunk(text: delta, decodeRate: chunk.decodeRate)))
} else if clean != generated {
generated = clean
}
}
guard !generated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw ServiceError.generationFailed("模型未输出任何内容")
}
// ( LLM,)
let trendBlock = Self.trendSection(snapshot.trends)
if !trendBlock.isEmpty {
generated += trendBlock
continuation.yield(.token(TokenChunk(text: trendBlock, decodeRate: 0)))
}
let export = HealthExport(
prompt: transcript,
content: generated,
referencedIndicatorIDs: snapshot.indicators.map { Self.idString($0.persistentModelID) },
referencedReportIDs: [],
referencedSymptomIDs: [],
referencedDiaryIDs: snapshot.diaries.map { Self.idString($0.persistentModelID) },
inferredTimeFromDate: snapshot.fromDate,
inferredTimeToDate: snapshot.toDate,
inferredIntent: "dialogue_export",
inferredLabelCN: "对话整理",
modelTag: ModelKind.llm.rawValue,
decodeRate: lastRate
)
modelContext.insert(export)
do { try modelContext.save() } catch {
print("[HealthExportService] save failed: \(error)")
}
continuation.yield(.phaseChanged(.completed))
continuation.yield(.completed(persistentID: export.persistentModelID))
continuation.finish()
} catch is CancellationError {
continuation.finish(throwing: ServiceError.cancelled)
} catch let e as ServiceError {
continuation.finish(throwing: e)
} catch {
continuation.finish(throwing: ServiceError.generationFailed("\(error)"))
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
// MARK: - Phase 1: intent extraction
struct Intent: Sendable {
var timeRangeDays: Int
var keywords: [String]
var symptomKeywords: [String]
var intent: String
var labelCN: String
/// : 30 +
static let fallback = Intent(
timeRangeDays: 30,
keywords: [],
symptomKeywords: [],
intent: "general_review",
labelCN: "近期健康摘要"
)
}
/// LLM JSON, `Intent.fallback`
///
private static func extractIntent(userPrompt: String) async -> Intent {
let prompt = HealthExportPrompts.intentExtraction(userPrompt: userPrompt)
var collected = ""
do {
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 200)
for try await chunk in stream {
collected += chunk.text
}
} catch {
return .fallback
}
return parseIntent(collected) ?? .fallback
}
/// JSON: `{}`,
/// (internal)
static func parseIntent(_ raw: String) -> Intent? {
let jsonString = CaptureService.extractJSONObject(from: raw)
guard let data = jsonString.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]),
let dict = obj as? [String: Any] else {
return nil
}
let days = clampDays(dict["time_range_days"])
let keywords = stringArray(dict["keywords"])
let symptomKeywords = stringArray(dict["symptom_keywords"])
let intent = (dict["intent"] as? String)?.trimmingCharacters(in: .whitespaces) ?? "general_review"
let labelCN = (dict["intent_label_cn"] as? String)?.trimmingCharacters(in: .whitespaces) ?? "近期健康摘要"
return Intent(
timeRangeDays: days,
keywords: keywords,
symptomKeywords: symptomKeywords,
intent: intent.isEmpty ? "general_review" : intent,
labelCN: labelCN.isEmpty ? "近期健康摘要" : labelCN
)
}
private static func clampDays(_ raw: Any?) -> Int {
if let n = raw as? Int { return max(1, min(365, n)) }
if let n = raw as? Double { return max(1, min(365, Int(n))) }
if let s = raw as? String, let n = Int(s) { return max(1, min(365, n)) }
return 30
}
private static func stringArray(_ raw: Any?) -> [String] {
guard let arr = raw as? [Any] else { return [] }
return arr.compactMap { ($0 as? String)?.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
// MARK: - Phase 2: retrieve
struct Snapshot {
var fromDate: Date
var toDate: Date
var indicators: [Indicator]
var symptoms: [Symptom]
var reports: [Report]
var diaries: [DiaryEntry]
var profile: UserProfile
/// () AI current_meds
var medications: [Medication] = []
/// (, LLM) ##
var trends: [ExportTrend] = []
}
/// SwiftData @MainActor
private static func retrieve(intent: Intent, ctx: ModelContext) -> Snapshot {
let toDate = Date()
let fromDate = Calendar.current.date(
byAdding: .day, value: -intent.timeRangeDays, to: toDate
) ?? toDate.addingTimeInterval(-30 * 86400)
// Indicators( + )
let indDesc = FetchDescriptor<Indicator>(
predicate: #Predicate { $0.capturedAt >= fromDate && $0.capturedAt <= toDate },
sortBy: [SortDescriptor(\.capturedAt, order: .reverse)]
)
let allInWindow = (try? ctx.fetch(indDesc)) ?? []
var indicators = allInWindow
if !intent.keywords.isEmpty {
let filtered = indicators.filter { ind in
intent.keywords.contains { kw in
ind.name.localizedCaseInsensitiveContains(kw)
}
}
// ,()
let abnormal = indicators.filter { $0.status != .normal }
let combined = (filtered + abnormal).reduce(into: [Indicator]()) { acc, x in
if !acc.contains(where: { $0.persistentModelID == x.persistentModelID }) {
acc.append(x)
}
}
indicators = combined.isEmpty ? indicators : combined
}
indicators = Array(indicators.prefix(20))
// Symptoms()
let symptomDesc = FetchDescriptor<Symptom>(
sortBy: [SortDescriptor(\.startedAt, order: .reverse)]
)
let allSymptoms = (try? ctx.fetch(symptomDesc)) ?? []
let symptoms = Array(
allSymptoms.filter { sym in
let overlapsStart = sym.startedAt <= toDate
let overlapsEnd = (sym.endedAt ?? Date.distantFuture) >= fromDate
return overlapsStart && overlapsEnd
}.prefix(10)
)
// Reports()
let reportDesc = FetchDescriptor<Report>(
predicate: #Predicate { $0.reportDate >= fromDate && $0.reportDate <= toDate },
sortBy: [SortDescriptor(\.reportDate, order: .reverse)]
)
let reports = Array(((try? ctx.fetch(reportDesc)) ?? []).prefix(8))
// Diary
// (targeted,);
// (,) 5
// prompt , bug
let diaryDesc = FetchDescriptor<DiaryEntry>(
predicate: #Predicate { $0.createdAt >= fromDate && $0.createdAt <= toDate },
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
let allDiaries = (try? ctx.fetch(diaryDesc)) ?? []
let diaries: [DiaryEntry]
if intent.symptomKeywords.isEmpty {
diaries = Array(allDiaries.prefix(5))
} else {
diaries = Array(
allDiaries.filter { d in
intent.symptomKeywords.contains { kw in
d.content.localizedCaseInsensitiveContains(kw)
}
}.prefix(5)
)
}
// Profile()
let profile = UserProfileStore.loadOrCreate(in: ctx)
// (, AI current_meds)
let medications = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
// (, LLM)
// in-window ; indicators series
let trends = ExportTrendBuilder.build(
allInWindow: allInWindow,
relevant: indicators,
profile: profile
)
return Snapshot(
fromDate: fromDate,
toDate: toDate,
indicators: indicators,
symptoms: symptoms,
reports: reports,
diaries: diaries,
profile: profile,
medications: medications,
trends: trends
)
}
/// 使 + prompt ,
static func retrieveDialogueSnapshot(ctx: ModelContext) -> Snapshot {
let indicatorDesc = FetchDescriptor<Indicator>(
sortBy: [SortDescriptor(\.capturedAt, order: .reverse)]
)
let diaryDesc = FetchDescriptor<DiaryEntry>(
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
let indicators = (try? ctx.fetch(indicatorDesc)) ?? []
let diaries = (try? ctx.fetch(diaryDesc)) ?? []
let profile = UserProfileStore.loadOrCreate(in: ctx)
let medications = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
let dates = indicators.map(\.capturedAt) + diaries.map(\.createdAt)
let fromDate = dates.min() ?? Date()
let toDate = dates.max() ?? Date()
// ,
let trends = ExportTrendBuilder.build(
allInWindow: indicators,
relevant: indicators,
profile: profile
)
return Snapshot(
fromDate: fromDate,
toDate: toDate,
indicators: indicators,
symptoms: [],
reports: [],
diaries: diaries,
profile: profile,
medications: medications,
trends: trends
)
}
// MARK: - Phase 3: serialize data for prompt
/// Snapshot LLM JSON
/// Codable prompt key,
static func serializeData(snapshot: Snapshot) -> String {
let df = DateFormatter()
df.locale = Locale(identifier: "en_US_POSIX")
df.dateFormat = "yyyy-MM-dd"
let profile = snapshot.profile
var root: [String: Any] = [:]
// profile
var profDict: [String: Any] = [:]
if let age = profile.age { profDict["age"] = age }
let sexLabel = profile.sex.label
if profile.sex != .undisclosed { profDict["sex"] = sexLabel }
if let h = profile.heightCM { profDict["height_cm"] = h }
if let w = profile.weightKG {
profDict["weight_kg"] = w.truncatingRemainder(dividingBy: 1) == 0
? Int(w) : Double(round(w * 10) / 10)
}
if !profile.bloodTypeRaw.isEmpty { profDict["blood_type"] = profile.bloodTypeRaw }
if !profile.allergies.isEmpty { profDict["allergies"] = profile.allergies }
if !profile.chronicConditions.isEmpty { profDict["chronic"] = profile.chronicConditions }
if !profile.familyHistory.isEmpty { profDict["family_history"] = profile.familyHistory }
// current_meds (Medication); profile.currentMedications
let medNames = snapshot.medications.map { m in
m.detailLine.isEmpty ? m.name : "\(m.name) \(m.detailLine)"
}
if !medNames.isEmpty { profDict["current_meds"] = medNames }
root["profile"] = profDict
// symptoms
root["symptoms"] = snapshot.symptoms.map { s -> [String: Any] in
var d: [String: Any] = [
"name": s.name,
"started": df.string(from: s.startedAt),
"severity": s.severity,
"ongoing": s.isOngoing
]
if let ended = s.endedAt { d["ended"] = df.string(from: ended) }
if let note = s.note, !note.isEmpty { d["note"] = note }
return d
}
// indicators
root["indicators"] = snapshot.indicators.map { i -> [String: Any] in
[
"name": i.name,
"value": i.value,
"unit": i.unit,
"range": i.range,
"status": i.status.rawValue,
"date": df.string(from: i.capturedAt)
]
}
// reports
root["reports"] = snapshot.reports.map { r -> [String: Any] in
var d: [String: Any] = [
"title": r.title,
"type": r.type.label,
"date": df.string(from: r.reportDate)
]
if let inst = r.institution, !inst.isEmpty { d["institution"] = inst }
if let sum = r.summary, !sum.isEmpty { d["summary"] = sum }
return d
}
// diaries
root["diaries"] = snapshot.diaries.map { d -> [String: Any] in
let excerpt = String(d.content.prefix(80))
return [
"date": df.string(from: d.createdAt),
"excerpt": excerpt
]
}
// LLM
root["time_window"] = [
"from": df.string(from: snapshot.fromDate),
"to": df.string(from: snapshot.toDate)
]
guard let data = try? JSONSerialization.data(
withJSONObject: root,
options: [.prettyPrinted, .sortedKeys]
),
let str = String(data: data, encoding: .utf8) else {
return "{}"
}
return str
}
// MARK: - ()
/// :///, profile
/// LLM,,
static func isEffectivelyEmpty(_ s: Snapshot) -> Bool {
guard s.symptoms.isEmpty, s.indicators.isEmpty, s.reports.isEmpty,
s.diaries.isEmpty, s.medications.isEmpty else {
return false
}
let p = s.profile
return p.age == nil
&& p.sex == .undisclosed
&& p.heightCM == nil
&& p.weightKG == nil
&& p.bloodTypeRaw.isEmpty
&& p.allergies.isEmpty
&& p.chronicConditions.isEmpty
&& p.familyHistory.isEmpty
}
/// :6 ,,
static func fallbackReport(label: String, userPrompt: String) -> String {
let title = label.isEmpty ? "# 就诊摘要" : "# 就诊摘要 — \(label)"
let complaint = userPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
let complaintLine = complaint.isEmpty ? "无记录" : complaint
return """
\(title)
> 本次未检索到可用的健康记录(指标 / 症状 / 报告 / 日记均为空),以下仅据本人原话,未做任何推断。
## 主诉
\(complaintLine)
## 本人背景
无记录
## 近期症状(按时间倒序)
无记录
## 关键指标(异常项优先)
无记录
## 在服药与过敏
无记录
## 本人疑问
无记录
"""
}
/// LLM ## ()
static func trendSection(_ trends: [ExportTrend]) -> String {
guard !trends.isEmpty else { return "" }
return "\n\n## 指标趋势\n" + trends.map { $0.line() }.joined(separator: "\n")
}
// MARK: - Helpers
/// SwiftData persistentModelID
/// W3 ,()
private static func idString(_ id: PersistentIdentifier) -> String {
String(describing: id)
}
// MARK: - <think>
/// ,
/// + + diff yield,:
/// - `<think>...</think>` ( think )
/// - `<think>...`() ,
/// - Qwen3 `</think>` thinking
/// - trim, `## `
static func stripThinkBlocks(_ raw: String) -> String {
var s = raw
// 1. <think>...</think>( think )
while let openR = s.range(of: "<think>"),
let closeR = s.range(of: "</think>", range: openR.upperBound..<s.endIndex) {
s.removeSubrange(openR.lowerBound..<closeR.upperBound)
}
// 2. :,
if let openR = s.range(of: "<think>") {
s = String(s[..<openR.lowerBound])
}
// 3. (Qwen3 ):
if let closeR = s.range(of: "</think>") {
s = String(s[closeR.upperBound...])
}
// 4. trim
while let first = s.first, first.isWhitespace {
s.removeFirst()
}
return s
}
}