From 96cae73d8ff2600a1803721f2cca486be0e79cdb Mon Sep 17 00:00:00 2001 From: link2026 Date: Mon, 25 May 2026 14:35:37 +0800 Subject: [PATCH] =?UTF-8?q?docs(plan):=20W2=20=E5=AE=9E=E7=8E=B0=E8=AE=A1?= =?UTF-8?q?=E5=88=92=20=E2=80=94=20AI=20=E5=9F=BA=E5=BA=A7=20+=20Schema=20?= =?UTF-8?q?=E9=87=8D=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10 个任务,TDD where possible: - T1 MLX SPM 接入 - T2 Models.swift 扩展(Asset/ChatTurn + 字段 + cascade) - T3 FileVault + 3 个单元测试 - T4 ModelStore + 3 个单元测试 - T5 TokenChunk + AIRuntime actor 骨架 - T6 LLMSession 接 MLX 跑 Qwen3-1.7B - T7 DebugAIRunner 自检入口 - T8 模拟器跑通里程碑(含 R1 红线决断) - T9 Schema 烟测 + 关系测试 - T10 retro + 状态更新 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-25-w2-ai-foundation.md | 1294 +++++++++++++++++ 1 file changed, 1294 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-25-w2-ai-foundation.md diff --git a/docs/superpowers/plans/2026-05-25-w2-ai-foundation.md b/docs/superpowers/plans/2026-05-25-w2-ai-foundation.md new file mode 100644 index 0000000..ab154f9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-w2-ai-foundation.md @@ -0,0 +1,1294 @@ +# 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/TijiApp.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 修改,不要手编) + +- [ ] **Step 1:打开 Xcode 项目** + +```bash +open /Users/xuhuayong/apps/体己/体己.xcodeproj +``` + +- [ ] **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` + +- [ ] **Step 3:加入 mlx-swift-examples(含 LLM 工具)** + +继续 Add Package Dependencies,URL: + +``` +https://github.com/ml-explore/mlx-swift-examples +``` + +勾选 `MLXLLM` 和 `MLXLMCommon` 加到 **体己** target。 + +- [ ] **Step 4:确认 Build Settings** + +Xcode → 体己 target → Build Settings → 搜 "Swift Language Version" → 确认 Swift 5(MLX 不支持 Swift 6 严格并发)。 + +体己 target → General → Minimum Deployments → iOS 17.0(MLX 要求)。 + +- [ ] **Step 5:Build 验证** + +Xcode 顶部选模拟器(任何一个 iPhone 15+),按 ⌘B。 + +Expected:Build Succeeded,无依赖错误。 + +- [ ] **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`(全文重写) + +- [ ] **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 + } +} +``` + +- [ ] **Step 2:更新 TijiApp.swift Schema** + +打开 `体己/App/TijiApp.swift`,替换 Schema 数组: + +```swift +let schema = Schema([ + Indicator.self, + Report.self, + DiaryEntry.self, + Asset.self, + ChatTurn.self, +]) +``` + +- [ ] **Step 3:删模拟器沙盒(破坏性迁移)** + +在 Mac 上: + +```bash +xcrun simctl shutdown all +xcrun simctl erase all +``` + +(也可以在 Simulator → Device → Erase All Content and Settings) + +- [ ] **Step 4:Build & Run 验证** + +Xcode ⌘R 运行到模拟器,App 启动不崩 = Schema OK。 + +Expected:App 启动到 RootView,无 fatalError。 + +- [ ] **Step 5:提交** + +```bash +git add 体己/Models/Models.swift 体己/App/TijiApp.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` + +- [ ] **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) } + } +} +``` + +- [ ] **Step 2:运行测试,确认 fail** + +Xcode ⌘U 跑测试(在模拟器上跑)。 + +Expected:`FileVaultTests` 编译错误 "Cannot find 'FileVault' in scope"。 + +- [ ] **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) + } + } +} +``` + +- [ ] **Step 4:把 FileVault.swift 加入 体己 target** + +Xcode 右键 `体己/` 目录 → New Group "Persistence" → 把 FileVault.swift 拖进去,确认 Target Membership 勾选 "体己"。 + +把 FileVaultTests.swift 拖进 体己Tests target,确认 Target Membership 勾选 "体己Tests"。 + +- [ ] **Step 5:跑测试,确认全 pass** + +Xcode ⌘U。 + +Expected:`writeAndReadJPEGRoundtrip` / `removeMakesFileGone` / `wipeRemovesAllFiles` 全绿。 + +- [ ] **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` + +- [ ] **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) + } +} +``` + +- [ ] **Step 2:运行测试,确认 fail** + +⌘U → expect `Cannot find 'ModelStore'`. + +- [ ] **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) + } +} +``` + +- [ ] **Step 4:Xcode 中把文件加入 target** + +右键 `体己/` → New Group "AI" → 拖入 ModelStore.swift,勾 "体己" target。 +ModelStoreTests.swift 拖入 体己Tests target。 + +- [ ] **Step 5:跑测试,全绿** + +⌘U。 + +Expected:3 个测试全 pass。 + +- [ ] **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 才接真模型。 + +- [ ] **Step 1:创建 TokenChunk.swift** + +```swift +import Foundation + +struct TokenChunk: Sendable { + let text: String + let decodeRate: Double // tokens / second, 估算值 +} +``` + +- [ ] **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 } + } +} +``` + +- [ ] **Step 3:确认 Build 失败原因合理** + +⌘B → expect "Cannot find 'LLMSession' in scope"(Task 6 才会建)。 + +这是预期。我们要让 Task 6 写完后 AIRuntime 直接能工作。 + +- [ ] **Step 4:把文件加入 target** + +把 TokenChunk.swift 和 AIRuntime.swift 拖进 AI group,勾 "体己" target。 + +(此时 Build 还是失败,正常) + +- [ ] **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。 + +- [ ] **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` 等。 + +- [ ] **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` 的最新示例,以仓库示例为准小幅调整。 + +- [ ] **Step 3:把 LLMSession.swift 加入 体己 target** + +拖入 AI group,确认 Target Membership。 + +- [ ] **Step 4:Build,期望成功** + +⌘B。 + +Expected:Build Succeeded。 + +若 MLX API 签名不匹配,参考 https://github.com/ml-explore/mlx-swift-examples 中 `Libraries/MLXLLM` 的最新 LLM 示例修正。 + +- [ ] **Step 5:在 TijiApp 启动时打印沙盒路径(临时调试)** + +打开 `体己/App/TijiApp.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 +``` + +- [ ] **Step 6:把模型拷到沙盒** + +```bash +APP_SUPPORT="<上面控制台打印的路径>" +mkdir -p "$APP_SUPPORT/Models" +cp -R ~/tiji-models/Qwen3-1.7B-MLX-4bit "$APP_SUPPORT/Models/" +``` + +- [ ] **Step 7:提交(本任务 + Task 5 一起)** + +```bash +git add 体己/AI/ 体己/App/TijiApp.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` + +- [ ] **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 +``` + +- [ ] **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() } +``` + +- [ ] **Step 3:在 Xcode 中加入文件** + +右键 `体己/` → New Group "Debug" → 拖入 DebugAIRunner.swift,勾 "体己" target。 + +- [ ] **Step 4:Build,确认 OK** + +⌘B → Expected: Build Succeeded。 + +- [ ] **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` + +- [ ] **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"]) + } +} +``` + +- [ ] **Step 2:加入 体己Tests target,跑测试** + +⌘U。 + +Expected:3 个测试全 pass。 + +若 cascade 删除测试失败 → 检查 `Indicator.report` 反向关系是否声明正确(参考 Task 2)。 + +- [ ] **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。