feat: 国际化(i18n) en/ja/ko + App 内语言切换
主体:多语言支持(简体中文源 + 英/日/韩)
- 基础设施:Localizable.xcstrings(String Catalog,sourceLanguage=zh-Hans)
+ pbxproj developmentRegion/knownRegions 注册 en/ja/ko
- 全部硬编码 Locale("zh_CN") → Locale.current;中文 dateFormat → Date.FormatStyle(跟随系统)
- UI 中文字面量统一为 String(appLoc:)(显式绑定所选语言 bundle+locale,即时切换)
Text 字面量走环境 \.locale + Bundle 重定向
- 549 个 catalog key 全部 en/ja/ko 翻译完成(0 未翻译)
- App 内语言切换:我的 → 语言(LanguageManager + 即时生效,无需重启)
- 双用预设(症状/监测指标/慢病)本地化:static→computed 避免缓存
注:本提交为 WIP,一并打包了并行进行的功能模块
(HealthExport 健康导出、Security/Face ID 锁、DiaryAssist 日记 AI 辅助)
及 App 图标、CLAUDE.md、docs/scripts。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,9 +7,9 @@ enum AIRuntimeError: Error, LocalizedError {
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notReady: return "AI 模型尚未准备好"
|
||||
case .modelLoadFailed(let m): return "模型加载失败:\(m)"
|
||||
case .inferenceFailed(let m): return "推理失败:\(m)"
|
||||
case .notReady: return String(appLoc: "AI 模型尚未准备好")
|
||||
case .modelLoadFailed(let m): return String(appLoc: "模型加载失败:\(m)")
|
||||
case .inferenceFailed(let m): return String(appLoc: "推理失败:\(m)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ enum DownloadError: Error, LocalizedError {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .badStatus(let code):
|
||||
return "下载失败(HTTP \(code))"
|
||||
return String(appLoc: "下载失败(HTTP \(code))")
|
||||
case .sizeMismatch(let expected, let got):
|
||||
return "文件大小校验失败(预期 \(expected),实际 \(got))"
|
||||
return String(appLoc: "文件大小校验失败(预期 \(expected),实际 \(got))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,18 +30,27 @@ enum ModelManifest {
|
||||
ModelFile(path: "added_tokens.json", bytes: 707),
|
||||
]
|
||||
case .vl:
|
||||
// Qwen3-VL-4B-Instruct-4bit:字节数取自 mlx-community 仓库实际 blob 大小
|
||||
// (HF API blobs=true,2026-05 核对),用于总进度计算与下载后大小校验。
|
||||
// 策略:完整镜像仓库的全部运行文件(仅排除 README.md / .gitattributes),
|
||||
// 与标准 mlx-vlm 加载环境保持一致,避免漏文件导致 VLMModelFactory 加载失败。
|
||||
// 同时带两份 chat_template(.json 旧约定 + .jinja 新约定)与 video 预处理配置,
|
||||
// 以兼容不同版本 swift-transformers / Qwen3VLProcessor 的读取路径。
|
||||
return [
|
||||
ModelFile(path: "config.json", bytes: 1_659),
|
||||
ModelFile(path: "model.safetensors", bytes: 3_073_720_461),
|
||||
ModelFile(path: "model.safetensors.index.json", bytes: 108_307),
|
||||
ModelFile(path: "tokenizer.json", bytes: 11_421_896),
|
||||
ModelFile(path: "tokenizer_config.json", bytes: 7_256),
|
||||
ModelFile(path: "config.json", bytes: 7_137),
|
||||
ModelFile(path: "model.safetensors", bytes: 3_093_767_283),
|
||||
ModelFile(path: "model.safetensors.index.json", bytes: 64_742),
|
||||
ModelFile(path: "tokenizer.json", bytes: 11_422_654),
|
||||
ModelFile(path: "tokenizer_config.json", bytes: 5_445),
|
||||
ModelFile(path: "vocab.json", bytes: 2_776_833),
|
||||
ModelFile(path: "merges.txt", bytes: 1_671_853),
|
||||
ModelFile(path: "special_tokens_map.json", bytes: 613),
|
||||
ModelFile(path: "added_tokens.json", bytes: 605),
|
||||
ModelFile(path: "chat_template.json", bytes: 1_050),
|
||||
ModelFile(path: "preprocessor_config.json", bytes: 350),
|
||||
ModelFile(path: "added_tokens.json", bytes: 707),
|
||||
ModelFile(path: "generation_config.json", bytes: 269),
|
||||
ModelFile(path: "chat_template.json", bytes: 5_502),
|
||||
ModelFile(path: "chat_template.jinja", bytes: 5_292),
|
||||
ModelFile(path: "preprocessor_config.json", bytes: 782),
|
||||
ModelFile(path: "video_preprocessor_config.json", bytes: 817),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import Foundation
|
||||
nonisolated enum ModelKind: String, CaseIterable {
|
||||
/// 与 HuggingFace mlx-community 仓库名一一对应,也是沙盒 Models/ 下的子目录名。
|
||||
case llm = "Qwen3-1.7B-4bit"
|
||||
case vl = "Qwen2.5-VL-3B-Instruct-4bit"
|
||||
case vl = "Qwen3-VL-4B-Instruct-4bit"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .llm: return "Qwen3-1.7B"
|
||||
case .vl: return "Qwen2.5-VL-3B"
|
||||
case .vl: return "Qwen3-VL-4B"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ enum ModelStoreError: Error, LocalizedError {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingConfig:
|
||||
return "所选文件夹缺少 config.json,不是有效的模型目录"
|
||||
return String(appLoc: "所选文件夹缺少 config.json,不是有效的模型目录")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
76
康康/AI/Prompts/DiaryAssistPrompts.swift
Normal file
76
康康/AI/Prompts/DiaryAssistPrompts.swift
Normal file
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
|
||||
/// 「健康记录」写入时,让 LLM 从医生问诊角度提 3-4 个追问。
|
||||
/// 输出严格 JSON,每个 question 带 dim(问诊维度)+ q(展示)+ fill(可一键追加的模板)。
|
||||
///
|
||||
/// 为什么要 `dim`(对齐 2026-05-30 prompt 优化):
|
||||
/// 1.7B 模型对「不要重复」这类否定指令遵循很差,且先验会把每轮问题都拉向同一簇症状。
|
||||
/// 改成「从固定维度清单里挑,每条标注 dim,跨轮排除已覆盖维度」这种正向结构约束后,
|
||||
/// 去重从「字面比对」升级为「按维度结构去重」,轮内扎堆和轮间换皮重复都能压住。
|
||||
enum DiaryAssistPrompts {
|
||||
|
||||
/// 固定问诊维度清单。模型每条问题必须正好归属其中一个;UI 累积已覆盖维度回传下一轮。
|
||||
/// 顺序即展示/示例顺序,改动需同步 few-shot。
|
||||
static let dimensions: [String] = [
|
||||
"起病诱因", "症状性质", "伴随症状", "加重缓解",
|
||||
"持续频率", "既往家族史", "用药过敏", "生活方式",
|
||||
]
|
||||
|
||||
/// - content: 患者当前全文。
|
||||
/// - coveredDimensions: 之前各轮已经问过(或记录里已写明)的维度名,本轮必须避开。
|
||||
/// 第一轮传空数组。
|
||||
static func suggest(content: String, coveredDimensions: [String] = []) -> String {
|
||||
let covered = coveredDimensions.filter { !$0.isEmpty }
|
||||
let coveredLine = covered.isEmpty ? "无" : covered.joined(separator: "、")
|
||||
let excludeRule = covered.isEmpty
|
||||
? ""
|
||||
: "\n- 本轮【严禁】选择这些已覆盖维度:\(covered.joined(separator: "、"));只能从其余维度里挑。"
|
||||
|
||||
return """
|
||||
你是社区医生的小助手。患者写了一段身体状态的健康记录,信息可能不够完整。
|
||||
请从医生问诊角度提出 3-4 个最值得追问的问题,帮患者把这条记录补全。
|
||||
|
||||
【问诊维度清单】每个问题必须正好归属其中一个,并用 dim 标注:
|
||||
1. 起病诱因 —— 何时开始、有无诱因
|
||||
2. 症状性质 —— 部位、性质、程度
|
||||
3. 伴随症状 —— 是否伴随其他不适
|
||||
4. 加重缓解 —— 什么情况下加重或缓解
|
||||
5. 持续频率 —— 持续多久、多频繁、是否反复发作
|
||||
6. 既往家族史 —— 以前是否有类似、家族相关史
|
||||
7. 用药过敏 —— 在服药物、过敏史
|
||||
8. 生活方式 —— 睡眠、饮食、运动习惯、压力
|
||||
|
||||
硬性规则:
|
||||
- 本轮每个问题必须来自【不同】维度,严禁两条落在同一维度(例如不能两条都问"伴随症状")。\(excludeRule)
|
||||
- 只问【最新记录】里还没写明的事。方括号 `[xxx]` 表示该话题已被提出、只是细节待填,【不要】再作为新问题重复它。
|
||||
- 不给诊断、不给用药建议、不写「建议就医」。
|
||||
- q ≤ 20 字,像真人医生在问;fill 是采纳后追加到原文的中文补充句,可含方括号占位符如 [时间] [部位]。
|
||||
- 至少 3 条,最多 4 条。
|
||||
|
||||
只输出严格 JSON,不要解释、不要 markdown 围栏、不要 <think> 标签。结构:
|
||||
{"questions":[{"dim":"<清单里的一个维度名>","q":"<问题>","fill":"<补充句模板>"}]}
|
||||
|
||||
示例 1(第一轮,记录:头痛了一上午):
|
||||
{"questions":[
|
||||
{"dim":"起病诱因","q":"具体什么时候开始的?","fill":"症状从 [时间] 开始,"},
|
||||
{"dim":"症状性质","q":"是哪种性质的头痛?","fill":"部位/性质是 [部位/胀痛/刺痛],"},
|
||||
{"dim":"伴随症状","q":"还伴有其他不适吗?","fill":"还伴有 [症状],"},
|
||||
{"dim":"生活方式","q":"最近睡眠和压力怎么样?","fill":"近期睡眠 [小时]、压力 [情况],"}
|
||||
]}
|
||||
|
||||
示例 2(后续轮,已覆盖维度:起病诱因、症状性质、伴随症状):
|
||||
{"questions":[
|
||||
{"dim":"加重缓解","q":"做什么会加重或缓解?","fill":"[活动/休息] 时会 [加重/缓解],"},
|
||||
{"dim":"持续频率","q":"这种情况反复或持续多久了?","fill":"已持续/反复 [时长/频率],"},
|
||||
{"dim":"既往家族史","q":"以前有过类似情况吗?","fill":"既往类似 [有/无,频率],"}
|
||||
]}
|
||||
|
||||
现在输出 JSON。
|
||||
已覆盖维度(必须避开):\(coveredLine)
|
||||
【最新记录】:
|
||||
\(content)
|
||||
|
||||
Output: /no_think
|
||||
"""
|
||||
}
|
||||
}
|
||||
91
康康/AI/Prompts/HealthExportPrompts.swift
Normal file
91
康康/AI/Prompts/HealthExportPrompts.swift
Normal file
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
|
||||
/// 「导出身体档案」用到的两个 LLM prompt:
|
||||
/// 1. `intentExtraction` —— 抽取时间窗 + 指标/症状关键词,只输出 JSON
|
||||
/// 2. `reportGeneration` —— 拼真实数据后生成给医生看的 Markdown
|
||||
///
|
||||
/// 解析逻辑见 `HealthExportService`(§3.2 失败回退红线:
|
||||
/// 抽不出 JSON → 用 30 天 + 空关键词兜底,流程不中断)。
|
||||
enum HealthExportPrompts {
|
||||
|
||||
// MARK: - 意图抽取
|
||||
|
||||
/// `intentExtraction(userPrompt:)` 把用户原话拼到模板末尾。
|
||||
/// 期望输出形如:
|
||||
/// ```json
|
||||
/// {"time_range_days":30,
|
||||
/// "keywords":["体温","血压"],
|
||||
/// "symptom_keywords":["感冒","咳嗽"],
|
||||
/// "intent":"cold_consult",
|
||||
/// "intent_label_cn":"感冒就诊"}
|
||||
/// ```
|
||||
static func intentExtraction(userPrompt: String) -> String {
|
||||
"""
|
||||
你是健康数据助手。读用户的请求,只输出严格 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||
|
||||
字段说明(全部必填):
|
||||
{
|
||||
"time_range_days": int, // 回溯天数,默认 30,最大 365
|
||||
"keywords": [string], // 指标关键词(中文,如「血压」「血糖」「体温」「肝功」),无则 []
|
||||
"symptom_keywords": [string], // 症状关键词,无则 []
|
||||
"intent": string, // 英文 snake_case 标签,如 "cold_consult"
|
||||
"intent_label_cn": string // 中文短语,会作为报告标题副题,如 "感冒就诊"
|
||||
}
|
||||
|
||||
规则:
|
||||
- 时间未指定 → 30
|
||||
- 「最近一个月」→ 30,「最近三个月」→ 90,「最近半年」→ 180
|
||||
- 关键词要中文,常见健康指标 / 症状词
|
||||
- intent 简短,4-25 字符,小写下划线
|
||||
|
||||
示例 1:
|
||||
User: 我感冒3天了,要把最近一个月的健康情况给医生看
|
||||
Output: {"time_range_days":30,"keywords":["体温","血压","脉搏"],"symptom_keywords":["感冒","咳嗽","咽喉痛","发烧"],"intent":"cold_consult","intent_label_cn":"感冒就诊"}
|
||||
|
||||
示例 2:
|
||||
User: 我最近血糖好像不稳,把上次体检前后的化验单整理一下
|
||||
Output: {"time_range_days":90,"keywords":["血糖","糖化血红蛋白","胰岛素"],"symptom_keywords":[],"intent":"glucose_review","intent_label_cn":"血糖复查"}
|
||||
|
||||
现在请输出 JSON:
|
||||
User: \(userPrompt)
|
||||
Output: /no_think
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - 报告生成
|
||||
|
||||
/// `reportGeneration(userPrompt:intentLabelCN:dataJSON:)` 拼好后流式生成 Markdown。
|
||||
static func reportGeneration(userPrompt: String,
|
||||
intentLabelCN: String,
|
||||
dataJSON: String) -> String {
|
||||
let labelLine = intentLabelCN.isEmpty
|
||||
? "# 就诊摘要"
|
||||
: "# 就诊摘要 — \(intentLabelCN)"
|
||||
return """
|
||||
你正在帮患者撰写一份给社区医生看的就诊摘要。要求:
|
||||
- 严格输出 Markdown,标题用 # / ##,不要 markdown 围栏
|
||||
- 只用「数据」中给出的信息,数据缺失就写「无记录」
|
||||
- 不要给诊断意见、用药建议或「建议就医」之类的话
|
||||
- 引用数值时保留单位 + 参考范围,异常项前加 ⚠️
|
||||
- 全文中文,简洁,医生 30 秒内能扫完
|
||||
- 不要复述「数据」二字,不要输出 JSON
|
||||
|
||||
结构(严格按以下 6 段):
|
||||
\(labelLine)
|
||||
## 主诉
|
||||
## 患者背景
|
||||
## 近期症状(按时间倒序)
|
||||
## 关键指标(异常项优先)
|
||||
## 在服药与过敏
|
||||
## 患者疑问
|
||||
|
||||
数据:
|
||||
\(dataJSON)
|
||||
|
||||
患者原话:\(userPrompt)
|
||||
|
||||
现在请生成 Markdown(直接输出,不要思考过程,不要 <think> 标签):
|
||||
/no_think
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// VL 模型(Qwen2.5-VL)用于体检 / 化验单识别的 prompt 模板。
|
||||
/// VL 模型(Qwen3-VL)用于体检 / 化验单识别的 prompt 模板。
|
||||
/// 输出契约:严格 JSON,无任何解释文字、markdown 围栏或前后缀。
|
||||
/// 解析失败 → CaptureService 回退到手动录入(§3.2 失败回退红线)。
|
||||
enum VLPrompts {
|
||||
@@ -27,9 +27,21 @@ enum VLPrompts {
|
||||
/// ```
|
||||
/// `kind` 字段省略 —— UI 由 indicators 数量决定走 A2(单项)或 B3(多项)。
|
||||
|
||||
static let reportExtraction: String = #"""
|
||||
/// VL 模型不知"今天"是哪天,且 few-shot 示例里写死了日期,
|
||||
/// 必须把当天日期显式注入 prompt,模型在无报告日期时才会用对正确的回退值。
|
||||
static func reportExtraction(today: Date = .now) -> String {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
let todayStr = f.string(from: today)
|
||||
return reportExtractionTemplate.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
|
||||
}
|
||||
|
||||
private static let reportExtractionTemplate: String = #"""
|
||||
你是一个医学体检报告识别助手。请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||
|
||||
今天的日期是 {{TODAY}}。
|
||||
|
||||
JSON schema(严格):
|
||||
{
|
||||
"title": string,
|
||||
@@ -52,7 +64,8 @@ JSON schema(严格):
|
||||
规则:
|
||||
- status 根据 value 与 range 自己判断:value > range 上限 → "high",< 下限 → "low",否则 → "normal"。
|
||||
- range 字段保留原文(如 "< 3.40"、"3.9 - 6.1"、"0 - 5"),不要解析成区间对象。
|
||||
- 无法识别的字段填空字符串(institution / summary)或合理默认值(report_date 用今天)。
|
||||
- 无法识别的字段填空字符串(institution / summary)。
|
||||
- report_date 必须从图片中识别;实在看不清就填上面给出的「今天」({{TODAY}})。下面示例里的日期只是格式参考,不要直接抄。
|
||||
- 不要发明指标。看不清的整行跳过。
|
||||
- 化验单一般 type = "lab",体检套餐 = "checkup"。
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import MLX
|
||||
import MLXVLM
|
||||
import MLXLMCommon
|
||||
|
||||
/// 封装 MLX VL 模型(Qwen2.5-VL)的图像 → 文本推理。
|
||||
/// 封装 MLX VL 模型(Qwen3-VL)的图像 → 文本推理。
|
||||
/// 与 LLMSession 同款 actor 隔离,串行化由上游 AIRuntime 统一保证。
|
||||
actor VLSession {
|
||||
let container: ModelContainer
|
||||
|
||||
Reference in New Issue
Block a user