463 lines
21 KiB
Python
463 lines
21 KiB
Python
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()
|