# W2:AI 基座 + Schema 重建 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在真机上跑通 Qwen3-1.7B(MLX 4bit)首个 token 输出,落地 SwiftData 全量数据模型(含新增 Asset/ChatTurn 与字段),交付 `AIRuntime` actor + `FileVault` 加密目录,本周末通过 ≥ 15 tok/s 验收。 **Architecture:** 严格按 spec §1 分层——UI 永远不直接调 AIRuntime,所有推理走 actor 串行化;模型路径用 `Application Support/Models`,JPEG 用 `Application Support/Vault/` 且打开 `.completeFileProtection`;schema 改动用破坏性迁移(W2-W4 不写 VersionedSchema)。 **Tech Stack:** SwiftUI · SwiftData · MLX Swift (SPM) · Swift Testing 框架(`import Testing`) · iOS 17+ **关联文档:** [Spec v1.0](../specs/2026-05-25-kangji-features-design.md) §1-§5;[CLAUDE.md](../../../CLAUDE.md) §3 §5 §10 红线 --- ## 文件结构(本周变更) ### 新建 | 路径 | 职责 | |---|---| | `康康/AI/AIRuntime.swift` | actor 单例,推理串行化,暴露 prepare / generate / lastDecodeRate | | `康康/AI/ModelStore.swift` | 模型路径管理 + bundle 旁路 | | `康康/AI/LLMSession.swift` | Qwen3-1.7B 加载 + 流式生成 | | `康康/AI/TokenChunk.swift` | 流式数据结构 | | `康康/Persistence/FileVault.swift` | `Application Support/Vault/` 加密目录读写 | | `康康/Debug/DebugAIRunner.swift` | DEBUG-only 测试入口,挂在 MeView 末尾 | | `康康Tests/FileVaultTests.swift` | FileVault 单元测试 | | `康康Tests/ModelStoreTests.swift` | ModelStore 单元测试 | ### 修改 | 路径 | 改什么 | |---|---| | `康康/Models/Models.swift` | 加 Asset / ChatTurn,Indicator 加 report/asset/pinned,Report 加 indicators/assets 关系,DiaryEntry 加 tags | | `康康/App/KangkangApp.swift` | Schema 加入两个新 @Model | | `康康/Features/Me/MeView.swift` | DEBUG 块挂 DebugAIRunner | | `康康.xcodeproj` | SPM 加入 mlx-swift 与 mlx-swift-examples | ### 不动(W2 不碰) `RootView` · `HomeView` · `Quick/*` · `Archive/*` · `RecordSheet` · `Trends/TrendsView` · `DesignSystem/*` --- ## Task 1:Xcode 项目加入 MLX Swift SPM 依赖 **Files:** - Modify: `康康.xcodeproj/project.pbxproj`(通过 Xcode UI 修改,不要手编) - [x] **Step 1:打开 Xcode 项目** ```bash open /Users/xuhuayong/apps/康康/康康.xcodeproj ``` - [x] **Step 2:加入 MLX Swift 依赖** 在 Xcode → File → Add Package Dependencies → 输入 URL: ``` https://github.com/ml-explore/mlx-swift ``` 选 "Up to Next Major" → 添加,勾选这些 product 加到 **康康** target: - `MLX` - `MLXFast` - `MLXNN` - `MLXOptimizers` - `MLXRandom` - [x] **Step 3:加入 mlx-swift-examples(含 LLM 工具)** 继续 Add Package Dependencies,URL: ``` https://github.com/ml-explore/mlx-swift-examples ``` 勾选 `MLXLLM` 和 `MLXLMCommon` 加到 **康康** target。 - [x] **Step 4:确认 Build Settings** Xcode → 康康 target → Build Settings → 搜 "Swift Language Version" → 确认 Swift 5(MLX 不支持 Swift 6 严格并发)。 康康 target → General → Minimum Deployments → iOS 17.0(MLX 要求)。 - [x] **Step 5:Build 验证** Xcode 顶部选模拟器(任何一个 iPhone 15+),按 ⌘B。 Expected:Build Succeeded,无依赖错误。 - [x] **Step 6:提交** ```bash cd /Users/xuhuayong/apps/康康 git add 康康.xcodeproj git commit -m "build: add MLX Swift SPM dependencies" ``` --- ## Task 2:扩展 Models.swift —— Asset 与 ChatTurn **Files:** - Modify: `康康/Models/Models.swift`(全文重写) - [x] **Step 1:把 Models.swift 替换为新内容** 打开 `康康/Models/Models.swift`,**整文件替换**为: ```swift import Foundation import SwiftData enum IndicatorStatus: String, Codable, CaseIterable { case high, low, normal } enum ReportType: String, Codable, CaseIterable { case checkup, lab, imaging, prescription, other var label: String { switch self { case .checkup: return "体检报告" case .lab: return "化验单" case .imaging: return "影像报告" case .prescription: return "处方" case .other: return "其他" } } } @Model final class Indicator { var name: String var value: String var unit: String var range: String var statusRaw: String var note: String? var capturedAt: Date var report: Report? var asset: Asset? var pinned: Bool = false init(name: String, value: String, unit: String, range: String, status: IndicatorStatus, note: String? = nil, capturedAt: Date = .now, report: Report? = nil, asset: Asset? = nil, pinned: Bool = false) { self.name = name self.value = value self.unit = unit self.range = range self.statusRaw = status.rawValue self.note = note self.capturedAt = capturedAt self.report = report self.asset = asset self.pinned = pinned } var status: IndicatorStatus { IndicatorStatus(rawValue: statusRaw) ?? .normal } } @Model final class Report { var title: String var typeRaw: String var reportDate: Date var institution: String? var note: String? var summary: String? var pageCount: Int var createdAt: Date @Relationship(deleteRule: .cascade, inverse: \Indicator.report) var indicators: [Indicator] = [] @Relationship(deleteRule: .cascade) var assets: [Asset] = [] init(title: String, type: ReportType, reportDate: Date, institution: String? = nil, note: String? = nil, summary: String? = nil, pageCount: Int = 1, createdAt: Date = .now) { self.title = title self.typeRaw = type.rawValue self.reportDate = reportDate self.institution = institution self.note = note self.summary = summary self.pageCount = pageCount self.createdAt = createdAt } var type: ReportType { ReportType(rawValue: typeRaw) ?? .other } } @Model final class DiaryEntry { var content: String var createdAt: Date var tags: [String] init(content: String, createdAt: Date = .now, tags: [String] = []) { self.content = content self.createdAt = createdAt self.tags = tags } } @Model final class Asset { var relativePath: String var mimeType: String var bytes: Int var createdAt: Date init(relativePath: String, mimeType: String = "image/jpeg", bytes: Int = 0, createdAt: Date = .now) { self.relativePath = relativePath self.mimeType = mimeType self.bytes = bytes self.createdAt = createdAt } } @Model final class ChatTurn { var question: String var answer: String var referencedIndicatorIDs: [String] var referencedReportIDs: [String] var createdAt: Date var decodeRate: Double init(question: String, answer: String, referencedIndicatorIDs: [String] = [], referencedReportIDs: [String] = [], createdAt: Date = .now, decodeRate: Double = 0) { self.question = question self.answer = answer self.referencedIndicatorIDs = referencedIndicatorIDs self.referencedReportIDs = referencedReportIDs self.createdAt = createdAt self.decodeRate = decodeRate } } ``` - [x] **Step 2:更新 KangkangApp.swift Schema** 打开 `康康/App/KangkangApp.swift`,替换 Schema 数组: ```swift let schema = Schema([ Indicator.self, Report.self, DiaryEntry.self, Asset.self, ChatTurn.self, ]) ``` - [x] **Step 3:删模拟器沙盒(破坏性迁移)** 在 Mac 上: ```bash xcrun simctl shutdown all xcrun simctl erase all ``` (也可以在 Simulator → Device → Erase All Content and Settings) - [x] **Step 4:Build & Run 验证** Xcode ⌘R 运行到模拟器,App 启动不崩 = Schema OK。 Expected:App 启动到 RootView,无 fatalError。 - [x] **Step 5:提交** ```bash git add 康康/Models/Models.swift 康康/App/KangkangApp.swift git commit -m "feat(models): add Asset/ChatTurn, indicator-report relationship, pinned flag" ``` --- ## Task 3:FileVault —— 加密目录读写(TDD) **Files:** - Create: `康康/Persistence/FileVault.swift` - Test: `康康Tests/FileVaultTests.swift` - [x] **Step 1:写失败的测试** 创建 `康康Tests/FileVaultTests.swift`: ```swift import Testing import UIKit @testable import 康康 @MainActor struct FileVaultTests { private func makeIsolatedVault() throws -> FileVault { let temp = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) return try FileVault(rootURL: temp) } @Test func writeAndReadJPEGRoundtrip() throws { let vault = try makeIsolatedVault() let image = UIImage(systemName: "doc")! let saved = try vault.writeJPEG(image, quality: 0.8) #expect(saved.bytes > 0) #expect(saved.relativePath.hasSuffix(".jpg")) let loaded = try vault.loadImage(relativePath: saved.relativePath) #expect(loaded.size != .zero) } @Test func removeMakesFileGone() throws { let vault = try makeIsolatedVault() let saved = try vault.writeJPEG(UIImage(systemName: "doc")!) try vault.remove(relativePath: saved.relativePath) #expect(throws: (any Error).self) { _ = try vault.loadImage(relativePath: saved.relativePath) } } @Test func wipeRemovesAllFiles() throws { let vault = try makeIsolatedVault() let a = try vault.writeJPEG(UIImage(systemName: "doc")!) let b = try vault.writeJPEG(UIImage(systemName: "folder")!) try vault.wipe() #expect(throws: (any Error).self) { _ = try vault.loadImage(relativePath: a.relativePath) } #expect(throws: (any Error).self) { _ = try vault.loadImage(relativePath: b.relativePath) } } } ``` - [x] **Step 2:运行测试,确认 fail** Xcode ⌘U 跑测试(在模拟器上跑)。 Expected:`FileVaultTests` 编译错误 "Cannot find 'FileVault' in scope"。 - [x] **Step 3:写最小 FileVault 实现** 创建 `康康/Persistence/FileVault.swift`: ```swift import Foundation import UIKit enum FileVaultError: Error { case readFailed case writeFailed case removeFailed case decodeFailed } final class FileVault { static let shared: FileVault = { do { let appSupport = try FileManager.default.url( for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true ) let vaultURL = appSupport.appendingPathComponent("Vault", isDirectory: true) return try FileVault(rootURL: vaultURL) } catch { fatalError("FileVault.shared init failed: \(error)") } }() let rootURL: URL init(rootURL: URL) throws { self.rootURL = rootURL try FileManager.default.createDirectory( at: rootURL, withIntermediateDirectories: true, attributes: [.protectionKey: FileProtectionType.complete] ) } struct SavedAsset { let relativePath: String let bytes: Int } func writeJPEG(_ image: UIImage, quality: CGFloat = 0.85) throws -> SavedAsset { guard let data = image.jpegData(compressionQuality: quality) else { throw FileVaultError.writeFailed } let filename = "\(UUID().uuidString).jpg" let url = rootURL.appendingPathComponent(filename) try data.write(to: url, options: [.atomic, .completeFileProtection]) return SavedAsset(relativePath: filename, bytes: data.count) } func loadImage(relativePath: String) throws -> UIImage { let url = rootURL.appendingPathComponent(relativePath) let data = try Data(contentsOf: url) guard let image = UIImage(data: data) else { throw FileVaultError.decodeFailed } return image } func remove(relativePath: String) throws { let url = rootURL.appendingPathComponent(relativePath) try FileManager.default.removeItem(at: url) } func wipe() throws { let fm = FileManager.default let contents = try fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil) for url in contents { try fm.removeItem(at: url) } } } ``` - [x] **Step 4:把 FileVault.swift 加入 康康 target** Xcode 右键 `康康/` 目录 → New Group "Persistence" → 把 FileVault.swift 拖进去,确认 Target Membership 勾选 "康康"。 把 FileVaultTests.swift 拖进 康康Tests target,确认 Target Membership 勾选 "康康Tests"。 - [x] **Step 5:跑测试,确认全 pass** Xcode ⌘U。 Expected:`writeAndReadJPEGRoundtrip` / `removeMakesFileGone` / `wipeRemovesAllFiles` 全绿。 - [x] **Step 6:提交** ```bash git add 康康/Persistence/FileVault.swift 康康Tests/FileVaultTests.swift 康康.xcodeproj git commit -m "feat(persistence): add FileVault with complete file protection" ``` --- ## Task 4:ModelStore —— 模型路径与 bundle 旁路(TDD) **Files:** - Create: `康康/AI/ModelStore.swift` - Test: `康康Tests/ModelStoreTests.swift` - [x] **Step 1:写失败的测试** 创建 `康康Tests/ModelStoreTests.swift`: ```swift import Testing import Foundation @testable import 康康 @MainActor struct ModelStoreTests { private func isolatedStore() throws -> ModelStore { let temp = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) return try ModelStore(rootURL: temp) } @Test func freshStoreReportsBothModelsMissing() throws { let store = try isolatedStore() #expect(store.isReady(.llm) == false) #expect(store.isReady(.vl) == false) } @Test func markReadyAfterFolderCreated() throws { let store = try isolatedStore() let llmFolder = store.localURL(for: .llm) try FileManager.default.createDirectory(at: llmFolder, withIntermediateDirectories: true) // dummy config file let configURL = llmFolder.appendingPathComponent("config.json") try "{}".write(to: configURL, atomically: true, encoding: .utf8) #expect(store.isReady(.llm) == true) #expect(store.isReady(.vl) == false) } @Test func totalBytesSumsExistingFiles() throws { let store = try isolatedStore() let folder = store.localURL(for: .llm) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) let data = Data(repeating: 0, count: 1024) try data.write(to: folder.appendingPathComponent("a.bin")) try data.write(to: folder.appendingPathComponent("b.bin")) #expect(store.totalBytes(for: .llm) == 2048) } } ``` - [x] **Step 2:运行测试,确认 fail** ⌘U → expect `Cannot find 'ModelStore'`. - [x] **Step 3:写 ModelStore 实现** 创建 `康康/AI/ModelStore.swift`: ```swift import Foundation enum ModelKind: String, CaseIterable { case llm = "Qwen3-1.7B-MLX-4bit" case vl = "Qwen2.5-VL-3B-MLX-4bit" var displayName: String { switch self { case .llm: return "Qwen3-1.7B" case .vl: return "Qwen2.5-VL-3B" } } /// 用于判定该模型是否已就绪的最小标志文件 var sentinelFilename: String { "config.json" } } final class ModelStore { static let shared: ModelStore = { do { let appSupport = try FileManager.default.url( for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true ) let root = appSupport.appendingPathComponent("Models", isDirectory: true) return try ModelStore(rootURL: root) } catch { fatalError("ModelStore.shared init failed: \(error)") } }() let rootURL: URL init(rootURL: URL) throws { self.rootURL = rootURL try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) } func localURL(for kind: ModelKind) -> URL { rootURL.appendingPathComponent(kind.rawValue, isDirectory: true) } func isReady(_ kind: ModelKind) -> Bool { let sentinel = localURL(for: kind).appendingPathComponent(kind.sentinelFilename) return FileManager.default.fileExists(atPath: sentinel.path) } func totalBytes(for kind: ModelKind) -> Int { let folder = localURL(for: kind) guard let enumerator = FileManager.default.enumerator( at: folder, includingPropertiesForKeys: [.fileSizeKey] ) else { return 0 } var sum = 0 for case let url as URL in enumerator { if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize { sum += size } } return sum } /// Demo 现场旁路:从 Bundle 拷贝预装模型 /// (W6 才真正使用,本周只占位) func seedFromBundle(_ kind: ModelKind) throws { guard let bundleURL = Bundle.main.url(forResource: kind.rawValue, withExtension: nil) else { return } let target = localURL(for: kind) if FileManager.default.fileExists(atPath: target.path) { try FileManager.default.removeItem(at: target) } try FileManager.default.copyItem(at: bundleURL, to: target) } } ``` - [x] **Step 4:Xcode 中把文件加入 target** 右键 `康康/` → New Group "AI" → 拖入 ModelStore.swift,勾 "康康" target。 ModelStoreTests.swift 拖入 康康Tests target。 - [x] **Step 5:跑测试,全绿** ⌘U。 Expected:3 个测试全 pass。 - [x] **Step 6:提交** ```bash git add 康康/AI/ModelStore.swift 康康Tests/ModelStoreTests.swift 康康.xcodeproj git commit -m "feat(ai): add ModelStore with path management and bundle seed" ``` --- ## Task 5:TokenChunk + AIRuntime actor 骨架 **Files:** - Create: `康康/AI/TokenChunk.swift` - Create: `康康/AI/AIRuntime.swift` 本任务**不接 MLX**,只搭骨架。Task 6 才接真模型。 - [x] **Step 1:创建 TokenChunk.swift** ```swift import Foundation struct TokenChunk: Sendable { let text: String let decodeRate: Double // tokens / second, 估算值 } ``` - [x] **Step 2:创建 AIRuntime.swift 骨架** ```swift import Foundation enum AIRuntimeError: Error, LocalizedError { case notReady case modelLoadFailed(String) case inferenceFailed(String) var errorDescription: String? { switch self { case .notReady: return "AI 模型尚未准备好" case .modelLoadFailed(let m): return "模型加载失败:\(m)" case .inferenceFailed(let m): return "推理失败:\(m)" } } } actor AIRuntime { static let shared = AIRuntime() enum Status: Sendable, Equatable { case notReady case loading case ready case error(String) } private(set) var status: Status = .notReady private(set) var lastDecodeRate: Double = 0 private var llmSession: LLMSession? private init() {} /// 加载模型。首次调用会真正加载,后续幂等。 func prepare() async throws { switch status { case .ready: return case .loading: return // 已经在加载 case .error, .notReady: break } guard ModelStore.shared.isReady(.llm) else { status = .error("LLM 模型未就绪") throw AIRuntimeError.notReady } status = .loading do { let session = try await LLMSession.load( folderURL: ModelStore.shared.localURL(for: .llm) ) self.llmSession = session status = .ready } catch { status = .error("\(error)") throw AIRuntimeError.modelLoadFailed("\(error)") } } /// 流式生成。调用前应先 await prepare()。 /// 注意:返回流是同步创建的,但跨 actor 调用 LLMSession 需要 await。 func generate(prompt: String, maxTokens: Int = 256) -> AsyncThrowingStream { // 在 actor 隔离上下文中捕获快照,Task 内不再访问 self.status / self.llmSession let snapshotStatus = status let snapshotSession = llmSession return AsyncThrowingStream { continuation in Task { [weak self] in guard snapshotStatus == .ready, let session = snapshotSession else { continuation.finish(throwing: AIRuntimeError.notReady) return } do { // session.generate 跨 actor 边界,需要 await let stream = await session.generate(prompt: prompt, maxTokens: maxTokens) for try await chunk in stream { await self?.recordRate(chunk.decodeRate) continuation.yield(chunk) } continuation.finish() } catch { continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)")) } } } } private func recordRate(_ rate: Double) { if rate > 0 { lastDecodeRate = rate } } } ``` - [x] **Step 3:确认 Build 失败原因合理** ⌘B → expect "Cannot find 'LLMSession' in scope"(Task 6 才会建)。 这是预期。我们要让 Task 6 写完后 AIRuntime 直接能工作。 - [x] **Step 4:把文件加入 target** 把 TokenChunk.swift 和 AIRuntime.swift 拖进 AI group,勾 "康康" target。 (此时 Build 还是失败,正常) - [x] **Step 5:暂不提交** 等 Task 6 完成、Build 通过后一起提交。 --- ## Task 6:LLMSession —— 接 MLX 跑 Qwen3-1.7B **Files:** - Create: `康康/AI/LLMSession.swift` **预先准备(开发者手动一次)**: 在终端执行,把 Qwen3-1.7B-MLX-4bit 拉到本地某个目录,稍后通过 Xcode 的"Run with arguments / file copy"或者直接放进模拟器沙盒。本周临时方案:**写一个 DEBUG-only 的 import 入口,把 Mac 上的模型目录通过 share sheet 导进 App**。但更简单: **最简方案**:把模型文件夹放进 `~/Library/Developer/CoreSimulator/Devices//data/Containers/Data/Application//Library/Application Support/Models/Qwen3-1.7B-MLX-4bit/`,App 启动后能直接读到。 具体路径在 App 启动时打印,见 Step 5。 - [x] **Step 1:在终端下载模型(脚本一次性)** ```bash mkdir -p ~/tiji-models && cd ~/tiji-models # 用 huggingface-cli;若没装:pip install -U "huggingface_hub[cli]" huggingface-cli download mlx-community/Qwen3-1.7B-MLX-4bit \ --local-dir Qwen3-1.7B-MLX-4bit ``` Expected:目录里有 `config.json` / `model.safetensors` / `tokenizer.json` 等。 - [x] **Step 2:写 LLMSession 实现** 创建 `康康/AI/LLMSession.swift`: ```swift import Foundation import MLX import MLXLLM import MLXLMCommon actor LLMSession { let container: ModelContainer private init(container: ModelContainer) { self.container = container } static func load(folderURL: URL) async throws -> LLMSession { let configuration = ModelConfiguration(directory: folderURL) let container = try await LLMModelFactory.shared.loadContainer( configuration: configuration ) return LLMSession(container: container) } func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream { AsyncThrowingStream { continuation in Task { do { let parameters = GenerateParameters(temperature: 0.6, topP: 0.9) let start = Date() var produced = 0 try await container.perform { context in let lmInput = try await context.processor.prepare( input: .init(prompt: prompt) ) let stream = try MLXLMCommon.generate( input: lmInput, parameters: parameters, context: context ) for await event in stream { switch event { case .chunk(let text): produced += 1 let elapsed = Date().timeIntervalSince(start) let rate = elapsed > 0 ? Double(produced) / elapsed : 0 continuation.yield(TokenChunk(text: text, decodeRate: rate)) if produced >= maxTokens { break } case .info: continue } } } continuation.finish() } catch { continuation.finish(throwing: error) } } } } } ``` > **注**:`MLXLMCommon` 的具体 API 版本可能在 GenerateParameters/stream 处略有差异。如果 Step 4 编译报错,查看 mlx-swift-examples 仓库 `Libraries/MLXLLM` 的最新示例,以仓库示例为准小幅调整。 - [x] **Step 3:把 LLMSession.swift 加入 康康 target** 拖入 AI group,确认 Target Membership。 - [x] **Step 4:Build,期望成功** ⌘B。 Expected:Build Succeeded。 若 MLX API 签名不匹配,参考 https://github.com/ml-explore/mlx-swift-examples 中 `Libraries/MLXLLM` 的最新 LLM 示例修正。 - [x] **Step 5:在 KangkangApp 启动时打印沙盒路径(临时调试)** 打开 `康康/App/KangkangApp.swift`,在 `WindowGroup { RootView() }` 内加一个 `.onAppear`: ```swift .onAppear { #if DEBUG let appSupport = try? FileManager.default.url( for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false ) print("📁 App Support: \(appSupport?.path ?? "?")") print("📁 把 Qwen3-1.7B-MLX-4bit/ 拷到上面路径 + /Models/ 下面") #endif } ``` ⌘R 启动到模拟器,在 Xcode console 看到路径,例如: ``` 📁 App Support: /Users/.../data/Containers/Data/Application//Library/Application Support ``` - [x] **Step 6:把模型拷到沙盒** ```bash APP_SUPPORT="<上面控制台打印的路径>" mkdir -p "$APP_SUPPORT/Models" cp -R ~/tiji-models/Qwen3-1.7B-MLX-4bit "$APP_SUPPORT/Models/" ``` - [x] **Step 7:提交(本任务 + Task 5 一起)** ```bash git add 康康/AI/ 康康/App/KangkangApp.swift 康康.xcodeproj git commit -m "feat(ai): add AIRuntime actor and LLMSession with MLX Qwen3-1.7B" ``` --- ## Task 7:DebugAIRunner —— DEBUG 测试入口 **Files:** - Create: `康康/Debug/DebugAIRunner.swift` - Modify: `康康/Features/Me/MeView.swift` - [x] **Step 1:创建 DebugAIRunner** `康康/Debug/DebugAIRunner.swift`: ```swift #if DEBUG import SwiftUI struct DebugAIRunner: View { @State private var output: String = "" @State private var status: String = "未开始" @State private var rate: Double = 0 @State private var running = false var body: some View { VStack(alignment: .leading, spacing: 12) { Text("DEBUG · AI 自检") .font(.headline) HStack { Text("状态:\(status)") Spacer() Text(String(format: "%.1f tok/s", rate)) .foregroundStyle(.secondary) .monospaced() } .font(.footnote) Button(running ? "推理中..." : "跑一段 prompt") { Task { await run() } } .buttonStyle(.borderedProminent) .disabled(running) ScrollView { Text(output.isEmpty ? "(暂无输出)" : output) .font(.system(.footnote, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxHeight: 280) .padding(8) .background(Color.black.opacity(0.04)) .clipShape(RoundedRectangle(cornerRadius: 8)) } .padding() .background(Color.yellow.opacity(0.08)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, 16) } @MainActor private func run() async { running = true output = "" status = "加载模型..." do { try await AIRuntime.shared.prepare() status = "推理中..." let prompt = "用中文一句话介绍肝功能里 ALT 这个指标。" for try await chunk in await AIRuntime.shared.generate(prompt: prompt, maxTokens: 200) { output += chunk.text rate = chunk.decodeRate } status = "完成 ✓" } catch { status = "失败:\(error.localizedDescription)" } running = false } } #endif ``` - [x] **Step 2:在 MeView 末尾挂上(仅 DEBUG)** 打开 `康康/Features/Me/MeView.swift`,把现有内容整体替换为: ```swift import SwiftUI struct MeView: View { var body: some View { ScrollView { VStack(spacing: 12) { TjPlaceholder(label: "我的 · 模型管理 / Face ID / 关于\n(W6 实现)") .frame(width: 280, height: 180) #if DEBUG DebugAIRunner() #endif } .padding(.vertical, 24) } .background(Tj.Palette.sand.ignoresSafeArea()) } } #Preview { MeView() } ``` - [x] **Step 3:在 Xcode 中加入文件** 右键 `康康/` → New Group "Debug" → 拖入 DebugAIRunner.swift,勾 "康康" target。 - [x] **Step 4:Build,确认 OK** ⌘B → Expected: Build Succeeded。 - [x] **Step 5:提交** ```bash git add 康康/Debug/ 康康/Features/Me/MeView.swift 康康.xcodeproj git commit -m "chore(debug): add AI self-test runner in MeView (DEBUG only)" ``` --- ## Task 8:模拟器跑通自检(里程碑) **Files:**(无,运行验收) - [ ] **Step 1:在模拟器跑一次自检** ⌘R 启动到 iPhone 15 Pro 模拟器。底部 Tab → "我的" → 看到黄色"DEBUG · AI 自检"卡片。 点击"跑一段 prompt"。 Expected: - 状态变 "加载模型..." → "推理中..." → 输出区开始流式吐字 → "完成 ✓" - 顶部 tok/s 显示数字 > 0 若加载模型卡 `LLM 模型未就绪`:回到 Task 6 Step 6 检查模型是否真的拷到沙盒(注意每次 App reinstall 后 UUID 变化,要重新拷)。 - [ ] **Step 2:记录模拟器速度基线** 在 spec 笔记里记下:模拟器 (M1/M2 Mac) decode 速度 ≈ ?? tok/s。 (模拟器跑 MLX 走 CPU,期望 ≥ 8 tok/s,真机才是 ≥ 15) - [ ] **Step 3:连真机跑一次** iPhone 15 Pro / 16 Pro 用线连 Mac → Xcode 选这台真机 → ⌘R。 **注意**:真机沙盒和模拟器不同,需要把模型用 Xcode → Devices and Simulators → 选 App → Download Container 拿到 Documents,再用 Finder → 拖入。 更简单的真机路径:把模型放进 App Bundle 资源,运行时一次拷到沙盒。本周临时方案——在 Step 1 之前,如果你打算走真机,跟我说一声,我加一个"从 Bundle 旁路 seed"的步骤。 W2 验收最低要求:**模拟器跑通**即可。真机验证延后到 W3 一起做。 - [ ] **Step 4:决断** 回顾 spec §6 R1: - 若模拟器 decode < 5 tok/s 或频繁崩溃 → 周五前换 llama.cpp,W2 plan 重写 - 若 ≥ 8 tok/s 且无 OOM → 进 W3,真机延后 - [ ] **Step 5:提交里程碑标记** ```bash git tag w2-llm-runs git commit --allow-empty -m "milestone: W2 LLM 自检通过 (simulator)" ``` --- ## Task 9:加一组 schema 重建烟测(防回归) **Files:** - Create: `康康Tests/ModelsSchemaTests.swift` - [x] **Step 1:写 schema 烟测** ```swift import Testing import SwiftData import Foundation @testable import 康康 @MainActor struct ModelsSchemaTests { private func makeContainer() throws -> ModelContainer { let schema = Schema([ Indicator.self, Report.self, DiaryEntry.self, Asset.self, ChatTurn.self, ]) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) return try ModelContainer(for: schema, configurations: [config]) } @Test func insertIndicatorWithReportRelationship() throws { let container = try makeContainer() let ctx = ModelContext(container) let report = Report(title: "春检", type: .checkup, reportDate: .now) let indicator = Indicator( name: "ALT", value: "32", unit: "U/L", range: "9-50", status: .normal, report: report ) ctx.insert(report) ctx.insert(indicator) try ctx.save() #expect(report.indicators.count == 1) #expect(indicator.report?.title == "春检") } @Test func cascadeDeleteReportRemovesIndicators() throws { let container = try makeContainer() let ctx = ModelContext(container) let report = Report(title: "春检", type: .checkup, reportDate: .now) let indicator = Indicator( name: "ALT", value: "32", unit: "U/L", range: "9-50", status: .normal, report: report ) ctx.insert(report) ctx.insert(indicator) try ctx.save() ctx.delete(report) try ctx.save() let remaining = try ctx.fetch(FetchDescriptor()) #expect(remaining.isEmpty) } @Test func chatTurnPersistsReferencedIDs() throws { let container = try makeContainer() let ctx = ModelContext(container) let turn = ChatTurn( question: "我的 LDL 怎么样?", answer: "近 3 个月 LDL 偏高 [1]", referencedIndicatorIDs: ["abc"], referencedReportIDs: [], decodeRate: 24.3 ) ctx.insert(turn) try ctx.save() let all = try ctx.fetch(FetchDescriptor()) #expect(all.count == 1) #expect(all.first?.referencedIndicatorIDs == ["abc"]) } } ``` - [x] **Step 2:加入 康康Tests target,跑测试** ⌘U。 Expected:3 个测试全 pass。 若 cascade 删除测试失败 → 检查 `Indicator.report` 反向关系是否声明正确(参考 Task 2)。 - [x] **Step 3:提交** ```bash git add 康康Tests/ModelsSchemaTests.swift 康康.xcodeproj git commit -m "test(models): add schema smoke tests for relationships and cascade" ``` --- ## Task 10:周末 retro 与 W2 收尾 **Files:**(无) - [ ] **Step 1:汇总本周成果** 确认这些都 ✓: - [ ] MLX SPM 接入 - [ ] AIRuntime + LLMSession + ModelStore 三个文件存在并跑通 - [ ] FileVault 单元测试 3 个全绿 - [ ] ModelStore 单元测试 3 个全绿 - [ ] Schema 单元测试 3 个全绿 - [ ] DebugAIRunner 在 MeView 能看到,模拟器点击能流式吐字 - [ ] decode 速度记录在某处(README 或 spec 注释) - [ ] **Step 2:更新 CLAUDE.md §8 文件状态** 把 `AI/` 和 `Persistence/FileVault.swift` 从 ❌ 改为 ✅。 打开 CLAUDE.md,找到 §8,把: ``` ├── AI/ ❌ AIRuntime, LLMSession, VLSession, Prompts/ ``` 改为: ``` ├── AI/ ⚠️ AIRuntime + LLMSession + ModelStore ✅;VLSession + Prompts/ ❌ ``` `Persistence/FileVault.swift` 从 ❌ 改为 ✅。 - [ ] **Step 3:风险仪表盘检查** 按 spec §6 仪表盘: - R1 MLX:本周关键 → **通过 / 需补救 / 触发回退** 选一个 - R4 Migration:🟡 → 实际表现? 把决断写进一个 retro 文件: ```bash mkdir -p docs/superpowers/retros ``` 创建 `docs/superpowers/retros/2026-05-31-w2.md`: ```markdown # W2 Retro · 2026-05-31 ## Status - R1 MLX: [通过 / 需补救 / 触发回退] - R4 Migration: [实际] - 本周里程碑: [是否达成] ## 速度基线 - 模拟器(M1/M2 Mac, iPhone 15 Pro sim) decode: ?? tok/s - 真机 iPhone 15 Pro decode: ?? tok/s (若已测) ## 学到的 - ... ## 下周(W3)前置准备 - [ ] 准备 5-10 张真实化验单照片(W4 VL 回归测用) - [ ] 准备 20 条危险问句(W3 末医疗话术安全测试) ``` - [ ] **Step 4:提交收尾** ```bash git add CLAUDE.md docs/superpowers/retros/2026-05-31-w2.md git commit -m "chore: W2 retro and CLAUDE.md status update" ``` - [ ] **Step 5:打 tag** ```bash git tag w2-done -m "W2 complete: AI foundation, Schema, FileVault" ``` W3 plan 在下周一开始时再写,反映本周实际进度。 --- ## 本周交付清单 | 产物 | 验收 | |---|---| | MLX SPM 依赖 | Build 通过 | | 5 个 @Model + 关系 | 3 个 schema 测试全绿 | | FileVault | 3 个单元测试全绿,文件落盘且不可被普通查看 | | ModelStore | 3 个单元测试全绿 | | AIRuntime + LLMSession | DebugAIRunner 点击后流式输出 | | 速度基线 | 记录在 retro 文档 | **唯一红线触发条件**:模拟器 decode < 5 tok/s 或频繁 OOM → 立即换 llama.cpp,本 plan 全部 revert,重写 W2。