缺少代码差异信息,无法生成具体的commit message。请提供code differences内容以便分析并生成符合Angular规范的提交信息。
当您提供代码差异后,我将按照以下格式生成: ``` <type>(<scope>): <subject> <body> ``` 其中type会根据更改类型选择(feat、fix、docs、style、refactor等),scope表示影响范围,subject简要描述变更内容,body详细说明修改内容。
This commit is contained in:
@@ -21,6 +21,11 @@ struct ParsedReport: Sendable {
|
||||
var unit: String
|
||||
var range: String
|
||||
var status: IndicatorStatus
|
||||
var sourcePageIndex: Int?
|
||||
var sourceBoxX: Double?
|
||||
var sourceBoxY: Double?
|
||||
var sourceBoxWidth: Double?
|
||||
var sourceBoxHeight: Double?
|
||||
}
|
||||
|
||||
/// 一项都没识别出来 = 视作失败,UI 走手动录入回退。
|
||||
@@ -100,11 +105,16 @@ actor CaptureService {
|
||||
do {
|
||||
raw = try await AIRuntime.shared.analyzeReport(
|
||||
imageURLs: [tmpURL],
|
||||
prompt: VLPrompts.regionExtraction()
|
||||
prompt: VLPrompts.regionExtraction(),
|
||||
// 整张化验单可能含十余项,512 token 会截断 → 解析失败。给足额度。
|
||||
maxTokens: 2048
|
||||
)
|
||||
} catch {
|
||||
throw CaptureError.inferenceFailed("\(error)")
|
||||
}
|
||||
#if DEBUG
|
||||
print("🔎 [recognizeRegion] image bytes=\(imageData.count), VL raw output:\n\(raw)\n--- end VL raw ---")
|
||||
#endif
|
||||
do {
|
||||
return try CaptureService.parseIndicatorsJSON(raw)
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
@@ -114,6 +124,56 @@ actor CaptureService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 「拍照识别」OCR 链路:把 Vision OCR 出的纯文本交给 LLM(Qwen3-1.7B)结构化抽指标。
|
||||
/// 不建 Report、不留图;失败抛 `CaptureError`,UI 回退手动录入(§3.2)。
|
||||
/// 调用方(MainActor)先做 OCR,再把文本传进来——OCR 不需进 actor,也避免 UIImage 跨 actor。
|
||||
func recognizeIndicators(fromOCRText text: String) async throws -> [ParsedReport.ParsedIndicator] {
|
||||
do {
|
||||
try await AIRuntime.shared.prepare() // 载 LLM(会先卸 VL,OOM 闸门已处理)
|
||||
} catch {
|
||||
throw CaptureError.modelNotReady
|
||||
}
|
||||
|
||||
let prompt = VLPrompts.indicatorsFromText(text)
|
||||
var collected = ""
|
||||
do {
|
||||
// 整张化验单十余项,给足 token;LLM 解码与任何 VL 解码由 AIRuntime 闸门串行。
|
||||
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 2048)
|
||||
for try await chunk in stream {
|
||||
collected += chunk.text
|
||||
}
|
||||
} catch {
|
||||
throw CaptureError.inferenceFailed("\(error)")
|
||||
}
|
||||
|
||||
// Qwen3 可能吐 <think>…</think>,先剥掉再抠 JSON。
|
||||
let cleaned = CaptureService.stripThink(collected)
|
||||
#if DEBUG
|
||||
print("🧠 [recognizeIndicators] LLM cleaned output:\n\(cleaned)\n--- end LLM ---")
|
||||
#endif
|
||||
do {
|
||||
return try CaptureService.parseIndicatorsJSON(cleaned)
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
throw CaptureError.parseFailed(msg)
|
||||
} catch {
|
||||
throw CaptureError.parseFailed("\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 剥掉 Qwen3 的 <think>…</think>(配对块 / 未闭合开标签 / 孤立闭标签),再 trim 顶部空白。
|
||||
/// 与 HealthExportService.stripThinkBlocks 同逻辑,但本类是非 MainActor actor,放一份 nonisolated 版避免跨隔离调用。
|
||||
nonisolated static func stripThink(_ raw: String) -> String {
|
||||
var s = raw
|
||||
while let openR = s.range(of: "<think>"),
|
||||
let closeR = s.range(of: "</think>", range: openR.upperBound..<s.endIndex) {
|
||||
s.removeSubrange(openR.lowerBound..<closeR.upperBound)
|
||||
}
|
||||
if let openR = s.range(of: "<think>") { s = String(s[..<openR.lowerBound]) }
|
||||
if let closeR = s.range(of: "</think>") { s = String(s[closeR.upperBound...]) }
|
||||
while let first = s.first, first.isWhitespace { s.removeFirst() }
|
||||
return s
|
||||
}
|
||||
|
||||
/// VL 推理 + JSON 解析的纯阶段。assets 必须已写入 Vault。
|
||||
private func runVL(on assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
|
||||
do {
|
||||
@@ -344,7 +404,36 @@ actor CaptureService {
|
||||
let range = stringValue(d, keys: ["range", "reference", "reference_range", "ref", "参考", "参考值", "参考范围", "正常范围"]) ?? ""
|
||||
let statusRaw = stringValue(d, keys: ["status", "flag", "abnormal", "异常", "提示", "标记"])
|
||||
let status = parseIndicatorStatus(raw: statusRaw, value: value, range: range)
|
||||
return .init(name: name, value: value, unit: unit, range: range, status: status)
|
||||
let evidence = parseEvidenceLocation(d)
|
||||
return .init(
|
||||
name: name,
|
||||
value: value,
|
||||
unit: unit,
|
||||
range: range,
|
||||
status: status,
|
||||
sourcePageIndex: evidence?.pageIndex,
|
||||
sourceBoxX: evidence?.box.x,
|
||||
sourceBoxY: evidence?.box.y,
|
||||
sourceBoxWidth: evidence?.box.width,
|
||||
sourceBoxHeight: evidence?.box.height
|
||||
)
|
||||
}
|
||||
|
||||
private static func parseEvidenceLocation(_ d: [String: Any]) -> (pageIndex: Int, box: (x: Double, y: Double, width: Double, height: Double))? {
|
||||
guard let page = intValue(d, keys: ["source_page", "sourcePage", "page", "页码", "来源页码"]),
|
||||
page >= 1,
|
||||
let box = numberArrayValue(d, keys: ["source_box", "sourceBox", "box", "bbox", "位置", "来源位置"]),
|
||||
box.count == 4 else {
|
||||
return nil
|
||||
}
|
||||
let x = box[0]
|
||||
let y = box[1]
|
||||
let width = box[2]
|
||||
let height = box[3]
|
||||
guard x >= 0, y >= 0, width > 0, height > 0, x + width <= 1, y + height <= 1 else {
|
||||
return nil
|
||||
}
|
||||
return (page - 1, (x, y, width, height))
|
||||
}
|
||||
|
||||
private static func stringValue(_ d: [String: Any], keys: [String]) -> String? {
|
||||
@@ -359,6 +448,44 @@ actor CaptureService {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func intValue(_ d: [String: Any], keys: [String]) -> Int? {
|
||||
for key in keys {
|
||||
if let i = d[key] as? Int {
|
||||
return i
|
||||
}
|
||||
if let n = d[key] as? NSNumber {
|
||||
return n.intValue
|
||||
}
|
||||
if let s = d[key] as? String, let i = Int(s.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func numberArrayValue(_ d: [String: Any], keys: [String]) -> [Double]? {
|
||||
for key in keys {
|
||||
if let arr = d[key] as? [Double] {
|
||||
return arr
|
||||
}
|
||||
if let arr = d[key] as? [NSNumber] {
|
||||
return arr.map(\.doubleValue)
|
||||
}
|
||||
if let arr = d[key] as? [Any] {
|
||||
let values = arr.compactMap { item -> Double? in
|
||||
if let d = item as? Double { return d }
|
||||
if let n = item as? NSNumber { return n.doubleValue }
|
||||
if let s = item as? String { return Double(s.trimmingCharacters(in: .whitespacesAndNewlines)) }
|
||||
return nil
|
||||
}
|
||||
if values.count == arr.count {
|
||||
return values
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func arrayValue(_ d: [String: Any], keys: [String]) -> [[String: Any]] {
|
||||
for key in keys {
|
||||
if let arr = d[key] as? [[String: Any]] {
|
||||
@@ -480,7 +607,12 @@ extension Report {
|
||||
status: p.status,
|
||||
capturedAt: reportDate,
|
||||
report: self,
|
||||
source: .report
|
||||
source: .report,
|
||||
sourcePageIndex: p.sourcePageIndex,
|
||||
sourceBoxX: p.sourceBoxX,
|
||||
sourceBoxY: p.sourceBoxY,
|
||||
sourceBoxWidth: p.sourceBoxWidth,
|
||||
sourceBoxHeight: p.sourceBoxHeight
|
||||
)
|
||||
ctx.insert(i)
|
||||
}
|
||||
|
||||
181
康康/Services/ExportTrendBuilder.swift
Normal file
181
康康/Services/ExportTrendBuilder.swift
Normal file
@@ -0,0 +1,181 @@
|
||||
import Foundation
|
||||
|
||||
/// 导出身体档案「## 指标趋势」段的一条趋势摘要。
|
||||
///
|
||||
/// 设计见 `docs/superpowers/specs/2026-06-07-export-indicator-trends-design.md`:
|
||||
/// 对本次就诊相关、且时间窗内有 ≥2 次记录的指标,给一行确定性摘要
|
||||
/// (首值→末值 + 方向 + 时间跨度 + 次数),**不经 LLM**,与 `ReportCompareService` 同思路,
|
||||
/// 从根上杜绝小模型编造趋势数字(§10#5 失败回退 / §12#6 禁止编造)。
|
||||
struct ExportTrend: Sendable {
|
||||
|
||||
enum Direction: Sendable {
|
||||
case up, down, flat
|
||||
var arrow: String {
|
||||
switch self {
|
||||
case .up: return "↑"
|
||||
case .down: return "↓"
|
||||
case .flat: return "→"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let title: String
|
||||
let unit: String
|
||||
/// "152→138" 或血压双值 "152/96→138/88"。
|
||||
let valueText: String
|
||||
let direction: Direction
|
||||
/// 参考范围文本,如 "90-140";无(单边范围解析不出 / 血压双范围)则 nil。
|
||||
let rangeText: String?
|
||||
/// 首末两次记录之间的天数。
|
||||
let spanDays: Int
|
||||
/// 时间窗内记录次数。
|
||||
let count: Int
|
||||
/// 末值仍异常,或状态跨越了参考范围边界 → 行首加 ⚠️。
|
||||
let flagged: Bool
|
||||
|
||||
/// 一行中文:`⚠️ 收缩压 152→138 mmHg ↓(参考 90-140),近 21 天 4 次`
|
||||
func line() -> String {
|
||||
var s = flagged ? "⚠️ " : ""
|
||||
s += title
|
||||
s += " \(valueText)"
|
||||
if !unit.isEmpty { s += " \(unit)" }
|
||||
s += " \(direction.arrow)"
|
||||
if let r = rangeText, !r.isEmpty { s += "(参考 \(r))" }
|
||||
s += ",近 \(spanDays) 天 \(count) 次"
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
enum ExportTrendBuilder {
|
||||
|
||||
/// 平稳阈值:首末相对变化 < 5% 视为「平稳(→)」。
|
||||
static let flatThreshold = 0.05
|
||||
|
||||
/// 构建趋势摘要列表。
|
||||
/// - Parameters:
|
||||
/// - allInWindow: 时间窗内**全部**指标(裁剪前)—— 用来还原完整时间序列。
|
||||
/// - relevant: 本次就诊**相关**指标集(裁剪后)—— 只对这些 series 出趋势。
|
||||
/// - profile: 用于解析性别相关的参考范围(交给 SeriesBucket)。
|
||||
/// - customMetrics: 自定义监测项,用于解析自定义 series 的名称/范围。
|
||||
/// - Returns: 已按「异常优先,其次最近」排序的趋势行。
|
||||
static func build(allInWindow: [Indicator],
|
||||
relevant: [Indicator],
|
||||
profile: UserProfile? = nil,
|
||||
customMetrics: [CustomMonitorMetric] = []) -> [ExportTrend] {
|
||||
let relevantIDs = Set(relevant.compactMap { bucketID(for: $0) })
|
||||
guard !relevantIDs.isEmpty else { return [] }
|
||||
|
||||
// 复用 Trends 的分组逻辑:同 seriesKey 分组、血压合并、name+unit 回退、minPoints≥2、点按时间升序。
|
||||
let buckets = SeriesBucket.build(from: allInWindow,
|
||||
profile: profile,
|
||||
customMetrics: customMetrics,
|
||||
minPoints: 2)
|
||||
|
||||
let trends = buckets
|
||||
.filter { relevantIDs.contains($0.id) }
|
||||
.compactMap { trend(from: $0) }
|
||||
|
||||
// 异常优先,其次最近。
|
||||
return trends.sorted { lhs, rhs in
|
||||
if lhs.flagged != rhs.flagged { return lhs.flagged }
|
||||
return lhs.spanDays >= rhs.spanDays // 仅作稳定次序,实际新近性已由 buckets 顺序保证
|
||||
}
|
||||
}
|
||||
|
||||
/// 指标 → 其所属 SeriesBucket 的 id(与 `SeriesBucket.build` 的 id 方案一致)。
|
||||
/// nil 表示该指标无法归入任何 series(空名)。
|
||||
static func bucketID(for i: Indicator) -> String? {
|
||||
if let k = i.seriesKey, !k.isEmpty {
|
||||
if k == "bp.systolic" || k == "bp.diastolic" { return "bp" }
|
||||
return k
|
||||
}
|
||||
let nk = SeriesBucket.normalizedKey(name: i.name, unit: i.unit)
|
||||
return nk.isEmpty ? nil : "lab:\(nk)"
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func trend(from bucket: SeriesBucket) -> ExportTrend? {
|
||||
if bucket.id == "bp" { return bpTrend(from: bucket) }
|
||||
|
||||
guard let line = bucket.lines.first,
|
||||
line.points.count >= 2,
|
||||
let first = line.points.first,
|
||||
let last = line.points.last else { return nil }
|
||||
|
||||
return ExportTrend(
|
||||
title: bucket.title,
|
||||
unit: bucket.unit,
|
||||
valueText: "\(num(first.value))→\(num(last.value))",
|
||||
direction: direction(first: first.value, last: last.value),
|
||||
rangeText: rangeText(line.referenceRange),
|
||||
spanDays: spanDays(first.date, last.date),
|
||||
count: line.points.count,
|
||||
flagged: last.status != .normal
|
||||
|| crossedBoundary(first: first.status, last: last.status)
|
||||
)
|
||||
}
|
||||
|
||||
/// 血压:收缩 + 舒张合成一行,方向以收缩压为准;不展示参考(收缩/舒张范围不同,保持简洁)。
|
||||
private static func bpTrend(from bucket: SeriesBucket) -> ExportTrend? {
|
||||
guard let sys = bucket.lines.first(where: { $0.seriesKey == "bp.systolic" }),
|
||||
sys.points.count >= 2,
|
||||
let sFirst = sys.points.first,
|
||||
let sLast = sys.points.last else { return nil }
|
||||
|
||||
let dia = bucket.lines.first { $0.seriesKey == "bp.diastolic" }
|
||||
let dFirst = dia?.points.first
|
||||
let dLast = dia?.points.last
|
||||
|
||||
let valueText: String
|
||||
if let dFirst, let dLast {
|
||||
valueText = "\(num(sFirst.value))/\(num(dFirst.value))→\(num(sLast.value))/\(num(dLast.value))"
|
||||
} else {
|
||||
valueText = "\(num(sFirst.value))→\(num(sLast.value))"
|
||||
}
|
||||
|
||||
let sysFlag = sLast.status != .normal
|
||||
|| crossedBoundary(first: sFirst.status, last: sLast.status)
|
||||
let diaFlag = dLast.map { $0.status != .normal } ?? false
|
||||
|
||||
return ExportTrend(
|
||||
title: bucket.title,
|
||||
unit: bucket.unit,
|
||||
valueText: valueText,
|
||||
direction: direction(first: sFirst.value, last: sLast.value),
|
||||
rangeText: nil,
|
||||
spanDays: spanDays(sFirst.date, sLast.date),
|
||||
count: sys.points.count,
|
||||
flagged: sysFlag || diaFlag
|
||||
)
|
||||
}
|
||||
|
||||
static func direction(first: Double, last: Double) -> ExportTrend.Direction {
|
||||
let delta = last - first
|
||||
let base = abs(first)
|
||||
let rel = base > 0 ? abs(delta) / base : abs(delta)
|
||||
if rel < flatThreshold { return .flat }
|
||||
return delta > 0 ? .up : .down
|
||||
}
|
||||
|
||||
/// 状态是否跨越了参考范围边界(正常↔异常之间发生切换)。
|
||||
static func crossedBoundary(first: IndicatorStatus, last: IndicatorStatus) -> Bool {
|
||||
(first == .normal) != (last == .normal)
|
||||
}
|
||||
|
||||
static func spanDays(_ from: Date, _ to: Date) -> Int {
|
||||
let days = to.timeIntervalSince(from) / 86400
|
||||
return max(1, Int(days.rounded()))
|
||||
}
|
||||
|
||||
static func rangeText(_ r: ClosedRange<Double>?) -> String? {
|
||||
guard let r else { return nil }
|
||||
return "\(num(r.lowerBound))-\(num(r.upperBound))"
|
||||
}
|
||||
|
||||
/// 数值格式化:整数去小数点,其余去掉尾随 0(138.0→"138",6.10→"6.1")。
|
||||
static func num(_ v: Double) -> String {
|
||||
if v.truncatingRemainder(dividingBy: 1) == 0 { return String(Int(v)) }
|
||||
return String(format: "%g", v)
|
||||
}
|
||||
}
|
||||
47
康康/Services/HealthExportDialogue.swift
Normal file
47
康康/Services/HealthExportDialogue.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
struct HealthExportDialogueTurn: Identifiable, Hashable, Sendable {
|
||||
enum Role: String, Sendable {
|
||||
case user
|
||||
case assistant
|
||||
|
||||
var transcriptLabel: String {
|
||||
switch self {
|
||||
case .user: return String(appLoc: "患者")
|
||||
case .assistant: return String(appLoc: "康康")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let id: UUID
|
||||
var role: Role
|
||||
var text: String
|
||||
var createdAt: Date
|
||||
|
||||
init(role: Role, text: String, createdAt: Date = .now, id: UUID = UUID()) {
|
||||
self.id = id
|
||||
self.role = role
|
||||
self.text = text
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
static func user(_ text: String) -> HealthExportDialogueTurn {
|
||||
HealthExportDialogueTurn(role: .user, text: text)
|
||||
}
|
||||
|
||||
static func assistant(_ text: String) -> HealthExportDialogueTurn {
|
||||
HealthExportDialogueTurn(role: .assistant, text: text)
|
||||
}
|
||||
|
||||
static func transcript(from turns: [HealthExportDialogueTurn]) -> String {
|
||||
turns
|
||||
.compactMap { turn -> String? in
|
||||
let cleaned = turn.text
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "\n", with: " ")
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
return "\(turn.role.transcriptLabel): \(cleaned)"
|
||||
}
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,13 @@ struct HealthExportService {
|
||||
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,
|
||||
@@ -170,6 +177,146 @@ struct HealthExportService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 多轮导出页的单轮问答。只回答,不入库。
|
||||
func answer(question: String,
|
||||
conversation: [HealthExportDialogueTurn],
|
||||
in modelContext: ModelContext) -> AsyncThrowingStream<TokenChunk, 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)
|
||||
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(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)
|
||||
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 {
|
||||
@@ -251,6 +398,8 @@ struct HealthExportService {
|
||||
var reports: [Report]
|
||||
var diaries: [DiaryEntry]
|
||||
var profile: UserProfile
|
||||
/// 相关指标的趋势行(确定性计算,不进 LLM)。空 → 不渲染「## 指标趋势」段。
|
||||
var trends: [ExportTrend] = []
|
||||
}
|
||||
|
||||
/// 同步 SwiftData 查询。@MainActor。
|
||||
@@ -265,7 +414,8 @@ struct HealthExportService {
|
||||
predicate: #Predicate { $0.capturedAt >= fromDate && $0.capturedAt <= toDate },
|
||||
sortBy: [SortDescriptor(\.capturedAt, order: .reverse)]
|
||||
)
|
||||
var indicators = (try? ctx.fetch(indDesc)) ?? []
|
||||
let allInWindow = (try? ctx.fetch(indDesc)) ?? []
|
||||
var indicators = allInWindow
|
||||
if !intent.keywords.isEmpty {
|
||||
let filtered = indicators.filter { ind in
|
||||
intent.keywords.contains { kw in
|
||||
@@ -328,6 +478,14 @@ struct HealthExportService {
|
||||
// —— Profile(单例) ——
|
||||
let profile = UserProfileStore.loadOrCreate(in: ctx)
|
||||
|
||||
// —— 趋势(确定性,不进 LLM) ——
|
||||
// 用全量 in-window 还原完整序列;裁剪后的 indicators 决定哪些 series 相关。
|
||||
let trends = ExportTrendBuilder.build(
|
||||
allInWindow: allInWindow,
|
||||
relevant: indicators,
|
||||
profile: profile
|
||||
)
|
||||
|
||||
return Snapshot(
|
||||
fromDate: fromDate,
|
||||
toDate: toDate,
|
||||
@@ -335,8 +493,44 @@ struct HealthExportService {
|
||||
symptoms: symptoms,
|
||||
reports: reports,
|
||||
diaries: diaries,
|
||||
profile: profile,
|
||||
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 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,
|
||||
trends: trends
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Phase 3: serialize data for prompt
|
||||
@@ -480,6 +674,12 @@ struct HealthExportService {
|
||||
"""
|
||||
}
|
||||
|
||||
/// 把趋势行拼成追加到 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 编成稳定字符串。
|
||||
|
||||
188
康康/Services/HealthProfileImportService.swift
Normal file
188
康康/Services/HealthProfileImportService.swift
Normal file
@@ -0,0 +1,188 @@
|
||||
import Foundation
|
||||
import HealthKit
|
||||
|
||||
struct HealthProfileImportDraft: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
var birthYear: Int?
|
||||
var biologicalSexRaw: String?
|
||||
var heightCM: Int?
|
||||
var bloodTypeRaw: String?
|
||||
|
||||
var hasAnyImportableField: Bool {
|
||||
birthYear != nil ||
|
||||
biologicalSexRaw != nil ||
|
||||
heightCM != nil ||
|
||||
bloodTypeRaw != nil
|
||||
}
|
||||
|
||||
func apply(to profile: UserProfile, now: Date = .now) {
|
||||
if let birthYear { profile.birthYear = birthYear }
|
||||
if let biologicalSexRaw { profile.biologicalSexRaw = biologicalSexRaw }
|
||||
if let heightCM { profile.heightCM = heightCM }
|
||||
if let bloodTypeRaw { profile.bloodTypeRaw = bloodTypeRaw }
|
||||
profile.updatedAt = now
|
||||
}
|
||||
}
|
||||
|
||||
struct HealthProfileImportPreview {
|
||||
struct Field: Equatable {
|
||||
let title: String
|
||||
let current: String
|
||||
let imported: String?
|
||||
|
||||
var willUpdate: Bool {
|
||||
guard let imported else { return false }
|
||||
return imported != current
|
||||
}
|
||||
}
|
||||
|
||||
let birthYear: Field
|
||||
let sex: Field
|
||||
let height: Field
|
||||
let bloodType: Field
|
||||
|
||||
var fields: [Field] { [birthYear, sex, height, bloodType] }
|
||||
|
||||
init(draft: HealthProfileImportDraft, current profile: UserProfile) {
|
||||
birthYear = Field(
|
||||
title: String(appLoc: "出生年份"),
|
||||
current: profile.birthYear.map(String.init) ?? String(appLoc: "未设置"),
|
||||
imported: draft.birthYear.map(String.init)
|
||||
)
|
||||
sex = Field(
|
||||
title: String(appLoc: "性别"),
|
||||
current: profile.sex.label,
|
||||
imported: draft.biologicalSexRaw.map { Self.sexLabel(raw: $0) }
|
||||
)
|
||||
height = Field(
|
||||
title: String(appLoc: "身高"),
|
||||
current: profile.heightCM.map { "\($0)cm" } ?? String(appLoc: "未设置"),
|
||||
imported: draft.heightCM.map { "\($0)cm" }
|
||||
)
|
||||
bloodType = Field(
|
||||
title: String(appLoc: "血型"),
|
||||
current: profile.bloodTypeRaw.isEmpty ? String(appLoc: "未设置") : "\(profile.bloodTypeRaw)型",
|
||||
imported: draft.bloodTypeRaw.map { "\($0)型" }
|
||||
)
|
||||
}
|
||||
|
||||
private static func sexLabel(raw: String) -> String {
|
||||
(UserProfile.Sex(rawValue: raw) ?? .undisclosed).label
|
||||
}
|
||||
}
|
||||
|
||||
enum HealthProfileImportError: LocalizedError {
|
||||
case unavailable
|
||||
case noReadableFields
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unavailable:
|
||||
return String(appLoc: "这台设备暂不支持读取 Apple 健康资料。")
|
||||
case .noReadableFields:
|
||||
return String(appLoc: "Apple 健康里没有可导入的生日、性别、身高或血型。")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HealthProfileImportService {
|
||||
static let shared = HealthProfileImportService()
|
||||
|
||||
private let store = HKHealthStore()
|
||||
|
||||
func fetchDraft() async throws -> HealthProfileImportDraft {
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
throw HealthProfileImportError.unavailable
|
||||
}
|
||||
|
||||
let readTypes = readObjectTypes()
|
||||
try await requestReadAuthorization(for: readTypes)
|
||||
|
||||
async let birthYear = readBirthYear()
|
||||
async let sex = readBiologicalSexRaw()
|
||||
async let height = readLatestHeightCM()
|
||||
async let bloodType = readBloodTypeRaw()
|
||||
|
||||
let draft = HealthProfileImportDraft(
|
||||
birthYear: try await birthYear,
|
||||
biologicalSexRaw: try await sex,
|
||||
heightCM: try await height,
|
||||
bloodTypeRaw: try await bloodType
|
||||
)
|
||||
guard draft.hasAnyImportableField else {
|
||||
throw HealthProfileImportError.noReadableFields
|
||||
}
|
||||
return draft
|
||||
}
|
||||
|
||||
private func readObjectTypes() -> Set<HKObjectType> {
|
||||
var types: Set<HKObjectType> = [
|
||||
HKObjectType.characteristicType(forIdentifier: .dateOfBirth)!,
|
||||
HKObjectType.characteristicType(forIdentifier: .biologicalSex)!,
|
||||
HKObjectType.characteristicType(forIdentifier: .bloodType)!,
|
||||
]
|
||||
if let height = HKObjectType.quantityType(forIdentifier: .height) {
|
||||
types.insert(height)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
private func requestReadAuthorization(for readTypes: Set<HKObjectType>) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
store.requestAuthorization(toShare: [], read: readTypes) { _, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func readBirthYear() throws -> Int? {
|
||||
try store.dateOfBirthComponents().year
|
||||
}
|
||||
|
||||
private func readBiologicalSexRaw() throws -> String? {
|
||||
switch try store.biologicalSex().biologicalSex {
|
||||
case .female: return "female"
|
||||
case .male: return "male"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func readBloodTypeRaw() throws -> String? {
|
||||
switch try store.bloodType().bloodType {
|
||||
case .aPositive, .aNegative: return "A"
|
||||
case .bPositive, .bNegative: return "B"
|
||||
case .abPositive, .abNegative: return "AB"
|
||||
case .oPositive, .oNegative: return "O"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func readLatestHeightCM() async throws -> Int? {
|
||||
guard let heightType = HKObjectType.quantityType(forIdentifier: .height) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let query = HKSampleQuery(
|
||||
sampleType: heightType,
|
||||
predicate: nil,
|
||||
limit: 1,
|
||||
sortDescriptors: [sort]
|
||||
) { _, samples, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
let sample = samples?.first as? HKQuantitySample
|
||||
let cm = sample?.quantity.doubleValue(for: .meterUnit(with: .centi))
|
||||
continuation.resume(returning: cm.map { Int($0.rounded()) })
|
||||
}
|
||||
store.execute(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
69
康康/Services/OCRService.swift
Normal file
69
康康/Services/OCRService.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
import Vision
|
||||
import UIKit
|
||||
|
||||
enum OCRError: Error {
|
||||
case noImage
|
||||
}
|
||||
|
||||
/// 端侧文字识别(Apple Vision,100% 本地,无网络)。
|
||||
/// 用于「记录指标 · 拍照识别」:VL 直接读密集小字不稳,改为先 OCR 出文本,再交 LLM 结构化。
|
||||
enum OCRService {
|
||||
|
||||
/// 识别图中文字,按阅读顺序(自上而下、行内自左而右)拼成纯文本。
|
||||
/// 中英文混排;表格行会尽量保持在同一行,便于 LLM 把「指标名 数值 范围 单位」对齐解析。
|
||||
static func recognizeText(in cgImage: CGImage) async throws -> String {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<String, Error>) in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let request = VNRecognizeTextRequest()
|
||||
request.recognitionLevel = .accurate
|
||||
request.usesLanguageCorrection = true
|
||||
// 中文(简/繁)+ 英文;化验单常见中英文与数字混排。
|
||||
request.recognitionLanguages = ["zh-Hans", "zh-Hant", "en-US"]
|
||||
let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up, options: [:])
|
||||
do {
|
||||
try handler.perform([request])
|
||||
let obs = (request.results as? [VNRecognizedTextObservation]) ?? []
|
||||
cont.resume(returning: assemble(obs))
|
||||
} catch {
|
||||
cont.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// UIImage 便捷入口。
|
||||
static func recognizeText(in image: UIImage) async throws -> String {
|
||||
guard let cg = image.cgImage else { throw OCRError.noImage }
|
||||
return try await recognizeText(in: cg)
|
||||
}
|
||||
|
||||
/// 把散落的 observation 还原成阅读顺序文本。
|
||||
/// Vision 坐标系原点在左下、y 向上;按 midY 降序分行(同行 y 接近),行内按 minX 升序。
|
||||
private static func assemble(_ obs: [VNRecognizedTextObservation]) -> String {
|
||||
let items: [(rect: CGRect, text: String)] = obs.compactMap { o in
|
||||
guard let t = o.topCandidates(1).first?.string, !t.isEmpty else { return nil }
|
||||
return (o.boundingBox, t)
|
||||
}
|
||||
guard !items.isEmpty else { return "" }
|
||||
|
||||
let sorted = items.sorted { $0.rect.midY > $1.rect.midY }
|
||||
let yTol: CGFloat = 0.012 // 行高容差(归一化坐标);同一行的 cell midY 差异通常 < 此值
|
||||
var rows: [[(rect: CGRect, text: String)]] = []
|
||||
var rowY: [CGFloat] = [] // 各行内 midY 的运行平均,做锚点比单看首元素更稳(抗轻微行漂移)
|
||||
for item in sorted {
|
||||
if let i = rows.indices.last, abs(rowY[i] - item.rect.midY) < yTol {
|
||||
rows[i].append(item)
|
||||
rowY[i] = (rowY[i] * CGFloat(rows[i].count - 1) + item.rect.midY) / CGFloat(rows[i].count)
|
||||
} else {
|
||||
rows.append([item])
|
||||
rowY.append(item.rect.midY)
|
||||
}
|
||||
}
|
||||
return rows.map { row in
|
||||
row.sorted { $0.rect.minX < $1.rect.minX }
|
||||
.map(\.text)
|
||||
.joined(separator: " ")
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user