缺少代码差异信息,无法生成具体的commit message。请提供code differences内容以便分析并生成符合Angular规范的提交信息。

当您提供代码差异后,我将按照以下格式生成:

```
<type>(<scope>): <subject>

<body>
```

其中type会根据更改类型选择(feat、fix、docs、style、refactor等),scope表示影响范围,subject简要描述变更内容,body详细说明修改内容。
This commit is contained in:
link2026
2026-06-07 14:17:18 +08:00
parent 074d99715d
commit 77a4ee1c37
66 changed files with 2676 additions and 548 deletions

View File

@@ -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)
}

View 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
/// "152138" "152/96138/88"
let valueText: String
let direction: Direction
/// , "90-140";( / ) nil
let rangeText: String?
///
let spanDays: Int
///
let count: Int
/// ,
let flagged: Bool
/// :` 152138 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 退minPoints2
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)
}
}

View 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")
}
}

View File

@@ -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

View 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)
}
}
}

View 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")
}
}