from __future__ import annotations from pathlib import Path from docx import Document from docx.enum.section import WD_SECTION from docx.enum.table import WD_TABLE_ALIGNMENT, WD_CELL_VERTICAL_ALIGNMENT from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Cm, Inches, Pt, RGBColor ROOT = Path(__file__).resolve().parents[2] OUT = ROOT / "docs" / "release" / "康康创意方案文档.docx" PICS = ROOT / "docs" / "release" / "xhs-9grid" BLUE = RGBColor(46, 116, 181) DARK_BLUE = RGBColor(31, 77, 120) INK = RGBColor(31, 31, 31) MUTED = RGBColor(95, 95, 95) LIGHT = "F4F6F9" PALE_BLUE = "E8EEF5" GREEN = RGBColor(58, 112, 76) BRICK = RGBColor(154, 78, 65) def set_run_font(run, size=None, bold=None, color=None, font="PingFang SC"): run.font.name = font run._element.rPr.rFonts.set(qn("w:ascii"), "Calibri") run._element.rPr.rFonts.set(qn("w:hAnsi"), "Calibri") run._element.rPr.rFonts.set(qn("w:eastAsia"), font) if size is not None: run.font.size = Pt(size) if bold is not None: run.bold = bold if color is not None: run.font.color.rgb = color def set_paragraph_font(paragraph, size=11, color=INK, font="PingFang SC"): for run in paragraph.runs: set_run_font(run, size=size, color=color, font=font) def set_cell_shading(cell, fill): tc_pr = cell._tc.get_or_add_tcPr() shd = tc_pr.find(qn("w:shd")) if shd is None: shd = OxmlElement("w:shd") tc_pr.append(shd) shd.set(qn("w:fill"), fill) def set_cell_margins(cell, top=80, start=120, bottom=80, end=120): tc = cell._tc tc_pr = tc.get_or_add_tcPr() tc_mar = tc_pr.first_child_found_in("w:tcMar") if tc_mar is None: tc_mar = OxmlElement("w:tcMar") tc_pr.append(tc_mar) for m, v in [("top", top), ("start", start), ("bottom", bottom), ("end", end)]: node = tc_mar.find(qn(f"w:{m}")) if node is None: node = OxmlElement(f"w:{m}") tc_mar.append(node) node.set(qn("w:w"), str(v)) node.set(qn("w:type"), "dxa") def set_table_geometry(table, widths): table.alignment = WD_TABLE_ALIGNMENT.CENTER table.autofit = False tbl = table._tbl tbl_pr = tbl.tblPr tbl_w = tbl_pr.find(qn("w:tblW")) if tbl_w is None: tbl_w = OxmlElement("w:tblW") tbl_pr.append(tbl_w) tbl_w.set(qn("w:w"), str(sum(widths))) tbl_w.set(qn("w:type"), "dxa") tbl_grid = tbl.tblGrid if tbl_grid is None: tbl_grid = OxmlElement("w:tblGrid") tbl.append(tbl_grid) for child in list(tbl_grid): tbl_grid.remove(child) for width in widths: grid_col = OxmlElement("w:gridCol") grid_col.set(qn("w:w"), str(width)) tbl_grid.append(grid_col) for row in table.rows: for idx, cell in enumerate(row.cells): tc_pr = cell._tc.get_or_add_tcPr() tc_w = tc_pr.find(qn("w:tcW")) if tc_w is None: tc_w = OxmlElement("w:tcW") tc_pr.append(tc_w) tc_w.set(qn("w:w"), str(widths[idx])) tc_w.set(qn("w:type"), "dxa") set_cell_margins(cell) cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER def table_border(table, color="DADCE0", size="6"): tbl_pr = table._tbl.tblPr borders = tbl_pr.first_child_found_in("w:tblBorders") if borders is None: borders = OxmlElement("w:tblBorders") tbl_pr.append(borders) for edge in ["top", "left", "bottom", "right", "insideH", "insideV"]: tag = f"w:{edge}" element = borders.find(qn(tag)) if element is None: element = OxmlElement(tag) borders.append(element) element.set(qn("w:val"), "single") element.set(qn("w:sz"), size) element.set(qn("w:space"), "0") element.set(qn("w:color"), color) def add_para(doc, text="", style=None, size=11, bold=False, color=INK, align=None, after=8): p = doc.add_paragraph(style=style) p.paragraph_format.space_after = Pt(after) p.paragraph_format.line_spacing = 1.25 if align is not None: p.alignment = align run = p.add_run(text) set_run_font(run, size=size, bold=bold, color=color) return p def add_heading(doc, text, level=1): p = doc.add_paragraph(style=f"Heading {level}") p.paragraph_format.keep_with_next = True run = p.add_run(text) if level == 1: set_run_font(run, 16, True, BLUE) p.paragraph_format.space_before = Pt(18) p.paragraph_format.space_after = Pt(10) elif level == 2: set_run_font(run, 13, True, BLUE) p.paragraph_format.space_before = Pt(12) p.paragraph_format.space_after = Pt(6) else: set_run_font(run, 12, True, DARK_BLUE) p.paragraph_format.space_before = Pt(8) p.paragraph_format.space_after = Pt(4) return p def add_bullet(doc, text, level=0): p = doc.add_paragraph(style="List Bullet") p.paragraph_format.left_indent = Inches(0.375) p.paragraph_format.first_line_indent = Inches(-0.194) p.paragraph_format.space_after = Pt(4) p.paragraph_format.line_spacing = 1.208 run = p.add_run(text) set_run_font(run, 10.5, color=INK) return p def add_callout(doc, title, body, fill=LIGHT, title_color=DARK_BLUE): table = doc.add_table(rows=1, cols=1) set_table_geometry(table, [9360]) table_border(table, color="E1E5EA", size="4") cell = table.cell(0, 0) set_cell_shading(cell, fill) cell.text = "" p = cell.paragraphs[0] p.paragraph_format.space_after = Pt(4) r = p.add_run(title) set_run_font(r, 11, True, title_color) p2 = cell.add_paragraph() p2.paragraph_format.space_after = Pt(0) p2.paragraph_format.line_spacing = 1.22 r2 = p2.add_run(body) set_run_font(r2, 10.5, color=INK) doc.add_paragraph().paragraph_format.space_after = Pt(4) return table def add_label_table(doc, rows, widths=(2300, 7060), header=None): table = doc.add_table(rows=0, cols=2) set_table_geometry(table, list(widths)) table_border(table, color="DADCE0", size="5") if header: row = table.add_row() row.cells[0].merge(row.cells[1]) cell = row.cells[0] set_cell_shading(cell, PALE_BLUE) p = cell.paragraphs[0] p.paragraph_format.space_after = Pt(0) r = p.add_run(header) set_run_font(r, 10.5, True, DARK_BLUE) for label, value in rows: row = table.add_row() for cell in row.cells: set_cell_shading(cell, "FFFFFF") p0 = row.cells[0].paragraphs[0] p0.paragraph_format.space_after = Pt(0) r0 = p0.add_run(label) set_run_font(r0, 10, True, DARK_BLUE) p1 = row.cells[1].paragraphs[0] p1.paragraph_format.space_after = Pt(0) p1.paragraph_format.line_spacing = 1.18 r1 = p1.add_run(value) set_run_font(r1, 10, color=INK) doc.add_paragraph().paragraph_format.space_after = Pt(4) return table def add_matrix(doc, headers, rows, widths): table = doc.add_table(rows=1, cols=len(headers)) set_table_geometry(table, widths) table_border(table, color="DADCE0", size="5") for idx, h in enumerate(headers): cell = table.cell(0, idx) set_cell_shading(cell, PALE_BLUE) p = cell.paragraphs[0] p.paragraph_format.space_after = Pt(0) r = p.add_run(h) set_run_font(r, 9.5, True, DARK_BLUE) for row_data in rows: row = table.add_row() for idx, text in enumerate(row_data): cell = row.cells[idx] set_cell_shading(cell, "FFFFFF") p = cell.paragraphs[0] p.paragraph_format.space_after = Pt(0) p.paragraph_format.line_spacing = 1.16 r = p.add_run(text) set_run_font(r, 9.2, color=INK) doc.add_paragraph().paragraph_format.space_after = Pt(4) return table def setup_styles(doc): styles = doc.styles normal = styles["Normal"] normal.font.name = "Calibri" normal._element.rPr.rFonts.set(qn("w:eastAsia"), "PingFang SC") normal.font.size = Pt(11) normal.font.color.rgb = INK normal.paragraph_format.space_after = Pt(8) normal.paragraph_format.line_spacing = 1.333 normal.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY for style_name in ["Heading 1", "Heading 2", "Heading 3", "List Bullet"]: style = styles[style_name] style.font.name = "Calibri" style._element.rPr.rFonts.set(qn("w:eastAsia"), "PingFang SC") def set_header_footer(section): header = section.header.paragraphs[0] header.text = "" header.alignment = WD_ALIGN_PARAGRAPH.RIGHT run = header.add_run("康康 Kangkang 创意方案") set_run_font(run, 9, color=MUTED) footer = section.footer.paragraphs[0] footer.text = "" footer.alignment = WD_ALIGN_PARAGRAPH.CENTER run = footer.add_run("本方案仅描述健康记录与科普式解读能力,不构成医疗诊断或用药建议") set_run_font(run, 8.5, color=MUTED) def add_title_page(doc): add_para(doc, "手机上的 AI 创意方案", size=12, bold=True, color=GREEN, align=WD_ALIGN_PARAGRAPH.CENTER, after=10) title = doc.add_paragraph() title.alignment = WD_ALIGN_PARAGRAPH.CENTER title.paragraph_format.space_after = Pt(6) r = title.add_run("康康:本地优先的个人健康档案") set_run_font(r, 24, True, DARK_BLUE) subtitle = doc.add_paragraph() subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER subtitle.paragraph_format.space_after = Pt(18) r2 = subtitle.add_run("100% 本地推理 · 个人健康影像档案 · 大白话解读 · 结构化 RAG 问答") set_run_font(r2, 12.5, color=MUTED) add_label_table( doc, [ ("项目形态", "iOS 原生 App,SwiftUI + SwiftData。"), ("目标用户", "不愿把体检报告、化验单、症状和用药记录交给云端的普通用户。"), ("核心主张", "健康数据默认留在手机里,本地模型负责整理、解释和检索。"), ("技术主线", "Qwen3.5-2B + MNN + Arm SME2/NEON,MLX Swift 作为模拟器和兜底后端。"), ("边界声明", "不做医疗诊断、剂量推荐、急诊判断、医生预约、账号系统或数据上云。"), ], header="项目概览", ) add_callout( doc, "一句话创意", "把体检报告、化验指标、症状、日记和用药记录收进一个本地健康档案,再让手机端本地大模型用普通人能听懂的语言帮助整理、回顾和就诊前准备。", fill="F7FAF7", title_color=GREEN, ) doc.add_page_break() def add_sources(doc): add_heading(doc, "依据来源", 1) add_bullet(doc, "AGENTS.md:项目定位、技术选型、AI 链路、数据模型、隐私边界和六周 demo 目标。") add_bullet(doc, "康康/AI/InferenceEngine.swift 与 AIRuntime.swift:MNN/MLX 双后端、SME2 探测、actor 串行推理闸门。") add_bullet(doc, "康康/Services/HealthExportService.swift:本地 RAG、意图抽取、结构化检索、就诊摘要生成和失败兜底。") add_bullet(doc, "docs/release/app-store-metadata.md 与小红书项目介绍:用户价值、隐私表达和非医疗器械边界。") def main(): doc = Document() section = doc.sections[0] section.page_width = Inches(8.5) section.page_height = Inches(11) section.top_margin = Inches(1) section.bottom_margin = Inches(1) section.left_margin = Inches(1) section.right_margin = Inches(1) section.header_distance = Inches(0.492) section.footer_distance = Inches(0.492) setup_styles(doc) set_header_footer(section) add_title_page(doc) add_heading(doc, "1. 应用场景", 1) add_para(doc, "康康面向的是普通人的日常健康信息管理,而不是医院内部系统或专业诊疗工具。产品重点放在体检后、复查前、慢性指标长期观察、看医生前准备等高频生活场景。") add_matrix( doc, ["场景", "用户动作", "康康提供的价值"], [ ("体检或化验后", "拍摄报告或从相册导入。", "本地识别关键指标、参考范围和异常状态,形成可检索的报告档案。"), ("长期指标记录", "记录血压、血糖、体重、血脂等指标。", "自动沉淀趋势曲线,用大白话说明最近变化,降低读图门槛。"), ("症状与日记", "随手记身体感受、睡眠、疼痛、用药等。", "把零散记录整理成时间线,必要时由本地 AI 追问补全。"), ("就诊前准备", "输入“把最近一个月整理给医生看”。", "本地检索症状、指标、报告和用药,生成可复制/分享的就诊摘要。"), ("家庭健康管理", "为父母或自己保存报告和复查信息。", "减少纸质报告丢失、相册翻找和口头回忆遗漏。"), ], [1600, 2600, 5160], ) add_heading(doc, "2. 用户痛点", 1) add_bullet(doc, "看不懂:体检报告和化验单充满缩写、箭头、参考范围,普通人很难快速判断哪些值得关注。") add_bullet(doc, "找不到:健康信息散落在纸质报告、相册截图、备忘录和聊天记录里,复查或就医时很难完整回溯。") add_bullet(doc, "不敢传:健康报告包含年龄、医院、检查结果、既往问题等高度敏感信息,上传给云端 AI 会带来隐私顾虑。") add_bullet(doc, "记不全:看医生时常常忘记症状持续时间、近期指标变化、在服药物和过敏史。") add_bullet(doc, "工具割裂:传统健康记录 App 重记录轻解释,AI 聊天工具会解释但不沉淀长期个人档案。") doc.add_page_break() add_heading(doc, "3. 技术方案", 1) add_heading(doc, "3.1 模型选型", 2) add_para(doc, "主模型选择 Qwen3.5-2B,原因是它在移动端内存和速度预算内更适合 demo 落地,同时承担文本解读和视觉识别两类任务,避免拆分多套模型造成下载、存储和加载复杂度上升。") add_label_table( doc, [ ("主路径模型", "Qwen3.5-2B-MNN,约 1.2GB,面向真机端侧推理。"), ("兜底模型", "MLX Swift 对应的 Qwen3.5-2B-4bit,主要用于模拟器、调试和 MNN 不可用时的兜底。"), ("能力覆盖", "报告/药盒等图片识别、健康文本整理、趋势解释、身体档案问答和就诊摘要。"), ("取舍逻辑", "不用更大模型追求极限效果,而是优先保证端侧速度、内存可控、下载可接受和 demo 可复现。"), ], header="模型选型摘要", ) add_heading(doc, "3.2 推理框架", 2) add_para(doc, "推理框架采用双后端策略:真机主路径为阿里开源 MNN,面向挑战赛的 Qwen + MNN + SME2 端侧 CPU 推理;MLX Swift 作为 Apple 官方 Metal GPU 兜底,用于模拟器和对照测试。") add_matrix( doc, ["模块", "方案", "作用"], [ ("MNNBackend", "ObjC++ 桥接 MNN LLM 能力。", "在真机上加载 Qwen3.5-2B-MNN,支撑文本生成与视觉分析。"), ("InferenceEngine", "auto / mnn / mlx 三种偏好。", "真机优先 MNN,MNN 不可用时自动回退 MLX。"), ("AIRuntime", "actor 单例 + 推理闸门。", "串行化模型加载、文本生成和视觉推理,避免并发 OOM。"), ("ModelStore", "Application Support/Models。", "管理模型下载、就绪校验和现场旁路导入。"), ], [1900, 3200, 4260], ) add_heading(doc, "3.3 端侧适配思路", 2) add_bullet(doc, "设备优先级:支持 MNN 的真机默认走 MNN;支持 SME2 的 A19/iPhone17 走 SME2 加速,旧设备回退 NEON;模拟器走 MLX。") add_bullet(doc, "内存控制:AIRuntime 使用 actor 内信号量,保证同一时间只有一个重推理任务占用模型内存;加载 LLM 前卸载 VL,加载 MNN 前卸载 MLX 侧模型。") add_bullet(doc, "体验兜底:模型未就绪时 App 仍可启动,AI 入口提示前往模型管理;意图抽取失败时回退近 30 天全表扫描;识别失败时回退手动录入。") add_bullet(doc, "结构化 RAG:不引入 embedding 模型,先由本地模型抽取意图和关键词,再用 SwiftData 检索指标、报告、症状和日记,最后拼接上下文生成回答。") add_bullet(doc, "隐私保护:报告原图只保存到本地 Vault,SwiftData 存结构化记录;使用 iOS completeFileProtection、Face ID 启动锁和永久删除。") add_heading(doc, "4. 创新点", 1) add_matrix( doc, ["创新点", "说明", "差异化价值"], [ ("本地优先健康 AI", "AI 不依赖云端 API,核心解读和问答在手机内完成。", "解决健康数据上传焦虑,形成隐私可信的 AI 体验。"), ("影像档案 + AI 解读闭环", "拍报告、识别、归档、趋势、问答、摘要串成一个系统。", "不是单点 OCR 或聊天框,而是长期可用的个人健康档案。"), ("MNN + SME2 端侧推理", "将 Qwen 模型通过 MNN 接入 iPhone CPU/SME2 路径。", "展示大模型在移动 CPU 上可复现运行的技术亮点。"), ("轻量结构化 RAG", "利用 SwiftData 已有结构化记录检索,不额外引入 embedding 模型。", "降低端侧存储和计算成本,响应更可控。"), ("明确医疗边界", "只做记录、整理和科普式解释,不做诊断、用药和急诊判断。", "既保留实用价值,也降低合规和误导风险。"), ], [1800, 3900, 3660], ) add_heading(doc, "5. 预期效果", 1) add_callout( doc, "用户侧效果", "用户可以把报告、指标、症状和用药从碎片化记录变成可检索、可回顾、可解释的个人健康档案;在看医生前,用一份摘要减少遗漏和重复描述。", fill="F7FAF7", title_color=GREEN, ) add_callout( doc, "技术侧效果", "验证 Qwen3.5-2B 在 MNN + SME2/NEON 端侧 CPU 路径上的可用性,形成模型下载、引擎选择、性能自检、推理串行、失败兜底和本地数据检索的一体化方案。", fill="F4F6F9", title_color=DARK_BLUE, ) add_callout( doc, "展示侧效果", "比赛或路演时可以通过飞行模式、本地模型管理页、性能自检 tok/s、报告识别前后对比和身体档案摘要,清晰证明“不是云端套壳,而是端侧 AI 创意应用”。", fill="FFF6F2", title_color=BRICK, ) add_heading(doc, "6. 方案完整性", 1) add_para(doc, "康康的完整性体现在产品、技术和安全边界三条线同时闭合:用户能完成从采集到回顾再到就诊摘要的闭环;技术上有模型、运行时、数据、服务层和 UI 的分层;安全上明确不引入云服务、不做账号、不做自研密码学。") add_matrix( doc, ["层级", "已覆盖能力", "设计原则"], [ ("采集层", "拍报告、记录指标、写日记、记症状、药品识别。", "入口轻,失败可手动补录。"), ("数据层", "SwiftData 模型:Indicator、Report、DiaryEntry、Symptom、Asset、ChatTurn、UserProfile 等。", "结构化保存,便于检索和趋势计算。"), ("AI 层", "CaptureService、HealthExportService、TrendInsightService 经 AIRuntime 调用本地模型。", "UI 不直连模型,推理统一排队。"), ("展示层", "首页、记录、趋势、我的、模型管理、身体档案摘要。", "围绕 demo 核心卖点组织信息。"), ("隐私层", "本地 Vault、completeFileProtection、Face ID、永久删除、无账号无云。", "使用系统能力,不自造密码学。"), ], [1500, 5000, 2860], ) add_heading(doc, "7. 风险与边界控制", 1) add_bullet(doc, "AI 输出仅作为健康记录整理与科普式解释,不作为医疗诊断、治疗、用药或剂量建议。") add_bullet(doc, "识别结果必须允许用户核对和编辑,避免把模型错误直接落成事实。") add_bullet(doc, "端侧模型受设备性能、内存和电量影响,需要模型未就绪、加载失败、低性能设备等状态提示。") add_bullet(doc, "健康类内容表达要避免“治疗”“诊断”“疗效”等容易误导的词汇。") # Visual appendix with the generated 9-grid overview if available. overview = PICS / "00-9宫格总览.png" if overview.exists(): doc.add_page_break() add_heading(doc, "附录:展示素材示意", 1) add_para(doc, "以下为小红书 9 宫格展示图总览,可用于说明产品闭环与技术亮点。", size=10.5, color=MUTED) p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER run = p.add_run() run.add_picture(str(overview), width=Inches(5.6)) OUT.parent.mkdir(parents=True, exist_ok=True) doc.save(OUT) print(OUT) if __name__ == "__main__": main()