Files
kangkang/docs/release/build_creative_proposal_doc.py
link2026 e179a369f6 根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:
```
chore(config): 更新项目配置文件

- 调整开发环境配置参数
- 优化构建流程设置
- 更新依赖包版本管理
```
2026-07-01 08:03:35 +08:00

463 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 原生 AppSwiftUI + SwiftData。"),
("目标用户", "不愿把体检报告、化验单、症状和用药记录交给云端的普通用户。"),
("核心主张", "健康数据默认留在手机里,本地模型负责整理、解释和检索。"),
("技术主线", "Qwen3.5-2B + MNN + Arm SME2/NEONMLX 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.swiftMNN/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 三种偏好。", "真机优先 MNNMNN 不可用时自动回退 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, "隐私保护:报告原图只保存到本地 VaultSwiftData 存结构化记录;使用 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()