generate-test-pdfs.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. """生成智保通知识库手动测试 PDF。"""
  2. from __future__ import annotations
  3. from pathlib import Path
  4. from typing import Any
  5. from reportlab.lib import colors
  6. from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT
  7. from reportlab.lib.pagesizes import A4
  8. from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
  9. from reportlab.lib.units import mm
  10. from reportlab.pdfbase import pdfmetrics
  11. from reportlab.pdfbase.ttfonts import TTFont
  12. from reportlab.platypus import (
  13. BaseDocTemplate,
  14. Frame,
  15. KeepTogether,
  16. PageBreak,
  17. PageTemplate,
  18. Paragraph,
  19. Spacer,
  20. Table,
  21. TableStyle,
  22. )
  23. from pypdf import PdfReader
  24. ROOT = Path(__file__).resolve().parents[1]
  25. OUTPUT_DIR = ROOT / "knowledge" / "test-pdfs"
  26. BLUE = colors.HexColor("#146CE5")
  27. NAVY = colors.HexColor("#10213A")
  28. MUTED = colors.HexColor("#65778D")
  29. LIGHT_BLUE = colors.HexColor("#EDF5FF")
  30. LINE = colors.HexColor("#D7E3F0")
  31. GREEN = colors.HexColor("#16836C")
  32. def register_fonts() -> tuple[str, str]:
  33. regular_candidates = (
  34. Path("C:/Windows/Fonts/msyh.ttc"),
  35. Path("C:/Windows/Fonts/simsun.ttc"),
  36. )
  37. bold_candidates = (
  38. Path("C:/Windows/Fonts/msyhbd.ttc"),
  39. Path("C:/Windows/Fonts/simhei.ttf"),
  40. )
  41. regular = next((path for path in regular_candidates if path.exists()), None)
  42. bold = next((path for path in bold_candidates if path.exists()), regular)
  43. if regular is None or bold is None:
  44. raise FileNotFoundError("未找到可用的中文字体")
  45. pdfmetrics.registerFont(TTFont("ZBT-Regular", str(regular)))
  46. pdfmetrics.registerFont(TTFont("ZBT-Bold", str(bold)))
  47. return "ZBT-Regular", "ZBT-Bold"
  48. REGULAR_FONT, BOLD_FONT = register_fonts()
  49. class ZBTDocument(BaseDocTemplate):
  50. def __init__(self, filename: Path, *, title: str, document_code: str) -> None:
  51. super().__init__(
  52. str(filename),
  53. pagesize=A4,
  54. leftMargin=22 * mm,
  55. rightMargin=22 * mm,
  56. topMargin=25 * mm,
  57. bottomMargin=22 * mm,
  58. title=title,
  59. author="智保通",
  60. subject="智保通知识库本地测试资料",
  61. )
  62. self.document_title = title
  63. self.document_code = document_code
  64. frame = Frame(
  65. self.leftMargin,
  66. self.bottomMargin,
  67. self.width,
  68. self.height,
  69. id="content",
  70. )
  71. self.addPageTemplates(
  72. [
  73. PageTemplate(
  74. id="zbt",
  75. frames=[frame],
  76. onPage=self._draw_page,
  77. )
  78. ]
  79. )
  80. def _draw_page(self, canvas: Any, doc: Any) -> None:
  81. width, height = A4
  82. canvas.saveState()
  83. canvas.setFillColor(BLUE)
  84. canvas.rect(0, height - 10 * mm, width, 10 * mm, fill=1, stroke=0)
  85. canvas.setFont(BOLD_FONT, 9)
  86. canvas.setFillColor(colors.white)
  87. canvas.drawString(22 * mm, height - 6.5 * mm, "智保通 ZHIBAOTONG")
  88. canvas.setFont(REGULAR_FONT, 7.5)
  89. canvas.drawRightString(
  90. width - 22 * mm,
  91. height - 6.5 * mm,
  92. self.document_code,
  93. )
  94. canvas.setStrokeColor(LINE)
  95. canvas.line(22 * mm, 15 * mm, width - 22 * mm, 15 * mm)
  96. canvas.setFillColor(MUTED)
  97. canvas.setFont(REGULAR_FONT, 7.5)
  98. canvas.drawString(22 * mm, 10 * mm, "仅用于智保通本地项目测试,不构成真实保险合同或承诺")
  99. canvas.drawRightString(width - 22 * mm, 10 * mm, f"第 {doc.page} 页")
  100. canvas.restoreState()
  101. styles = getSampleStyleSheet()
  102. TITLE = ParagraphStyle(
  103. "ZBTTitle",
  104. parent=styles["Title"],
  105. fontName=BOLD_FONT,
  106. fontSize=25,
  107. leading=34,
  108. textColor=NAVY,
  109. alignment=TA_LEFT,
  110. spaceAfter=8 * mm,
  111. )
  112. SUBTITLE = ParagraphStyle(
  113. "ZBTSubtitle",
  114. parent=styles["Normal"],
  115. fontName=REGULAR_FONT,
  116. fontSize=10,
  117. leading=18,
  118. textColor=MUTED,
  119. spaceAfter=6 * mm,
  120. )
  121. H1 = ParagraphStyle(
  122. "ZBTH1",
  123. parent=styles["Heading1"],
  124. fontName=BOLD_FONT,
  125. fontSize=16,
  126. leading=22,
  127. textColor=NAVY,
  128. borderColor=BLUE,
  129. borderWidth=0,
  130. borderPadding=(0, 0, 0, 8),
  131. spaceBefore=3 * mm,
  132. spaceAfter=2 * mm,
  133. )
  134. H2 = ParagraphStyle(
  135. "ZBTH2",
  136. parent=styles["Heading2"],
  137. fontName=BOLD_FONT,
  138. fontSize=11,
  139. leading=17,
  140. textColor=BLUE,
  141. spaceBefore=3 * mm,
  142. spaceAfter=2 * mm,
  143. )
  144. BODY = ParagraphStyle(
  145. "ZBTBody",
  146. parent=styles["BodyText"],
  147. fontName=REGULAR_FONT,
  148. fontSize=9.5,
  149. leading=18,
  150. textColor=NAVY,
  151. alignment=TA_JUSTIFY,
  152. spaceAfter=2.5 * mm,
  153. )
  154. BULLET = ParagraphStyle(
  155. "ZBTBullet",
  156. parent=BODY,
  157. leftIndent=5 * mm,
  158. firstLineIndent=-3.5 * mm,
  159. bulletIndent=0,
  160. spaceAfter=1.5 * mm,
  161. )
  162. SMALL = ParagraphStyle(
  163. "ZBTSmall",
  164. parent=BODY,
  165. fontSize=8,
  166. leading=14,
  167. textColor=MUTED,
  168. alignment=TA_LEFT,
  169. )
  170. COVER_CODE = ParagraphStyle(
  171. "ZBTCoverCode",
  172. parent=BODY,
  173. fontName=BOLD_FONT,
  174. fontSize=9,
  175. textColor=BLUE,
  176. alignment=TA_CENTER,
  177. )
  178. TABLE_HEAD = ParagraphStyle(
  179. "ZBTTableHead",
  180. parent=BODY,
  181. fontName=BOLD_FONT,
  182. fontSize=8.5,
  183. leading=13,
  184. textColor=colors.white,
  185. alignment=TA_CENTER,
  186. )
  187. TABLE_CELL = ParagraphStyle(
  188. "ZBTTableCell",
  189. parent=BODY,
  190. fontSize=8.2,
  191. leading=13,
  192. alignment=TA_LEFT,
  193. spaceAfter=0,
  194. )
  195. def p(text: str, style: ParagraphStyle = BODY) -> Paragraph:
  196. return Paragraph(text, style)
  197. def bullet(text: str) -> Paragraph:
  198. return Paragraph(f"•  {text}", BULLET)
  199. def data_table(rows: list[list[str]], widths: list[float]) -> Table:
  200. content = [
  201. [p(cell, TABLE_HEAD if index == 0 else TABLE_CELL) for cell in row]
  202. for index, row in enumerate(rows)
  203. ]
  204. table = Table(content, colWidths=widths, repeatRows=1, hAlign="LEFT")
  205. table.setStyle(
  206. TableStyle(
  207. [
  208. ("BACKGROUND", (0, 0), (-1, 0), BLUE),
  209. ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
  210. ("BACKGROUND", (0, 1), (-1, -1), colors.white),
  211. ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F7FAFE")]),
  212. ("GRID", (0, 0), (-1, -1), 0.5, LINE),
  213. ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
  214. ("LEFTPADDING", (0, 0), (-1, -1), 7),
  215. ("RIGHTPADDING", (0, 0), (-1, -1), 7),
  216. ("TOPPADDING", (0, 0), (-1, -1), 7),
  217. ("BOTTOMPADDING", (0, 0), (-1, -1), 7),
  218. ]
  219. )
  220. )
  221. return table
  222. def cover(
  223. title: str,
  224. subtitle: str,
  225. document_code: str,
  226. document_type: str,
  227. version: str,
  228. ) -> list[Any]:
  229. badge = Table(
  230. [[p(document_type, COVER_CODE), p(version, COVER_CODE), p("本地测试资料", COVER_CODE)]],
  231. colWidths=[48 * mm, 48 * mm, 48 * mm],
  232. )
  233. badge.setStyle(
  234. TableStyle(
  235. [
  236. ("BACKGROUND", (0, 0), (-1, -1), LIGHT_BLUE),
  237. ("BOX", (0, 0), (-1, -1), 0.7, colors.HexColor("#B9D3F4")),
  238. ("INNERGRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#C9DCF4")),
  239. ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
  240. ("TOPPADDING", (0, 0), (-1, -1), 9),
  241. ("BOTTOMPADDING", (0, 0), (-1, -1), 9),
  242. ]
  243. )
  244. )
  245. notice = Table(
  246. [[p("资料说明", H2), p("文档内容为智保通本地项目的虚构测试数据,用于验证 PDF 解析、文本切分、向量检索、重排与来源引用。", SMALL)]],
  247. colWidths=[32 * mm, 112 * mm],
  248. )
  249. notice.setStyle(
  250. TableStyle(
  251. [
  252. ("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F5F9FE")),
  253. ("BOX", (0, 0), (-1, -1), 0.7, LINE),
  254. ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
  255. ("LEFTPADDING", (0, 0), (-1, -1), 10),
  256. ("RIGHTPADDING", (0, 0), (-1, -1), 10),
  257. ("TOPPADDING", (0, 0), (-1, -1), 10),
  258. ("BOTTOMPADDING", (0, 0), (-1, -1), 10),
  259. ]
  260. )
  261. )
  262. return [
  263. Spacer(1, 20 * mm),
  264. p("ZBT KNOWLEDGE BASE", COVER_CODE),
  265. Spacer(1, 7 * mm),
  266. p(title, TITLE),
  267. p(subtitle, SUBTITLE),
  268. badge,
  269. Spacer(1, 16 * mm),
  270. notice,
  271. Spacer(1, 8 * mm),
  272. p(f"文档编号:{document_code}", SMALL),
  273. p("生效日期:2026年7月1日  整理日期:2026年7月28日", SMALL),
  274. PageBreak(),
  275. ]
  276. def build_document(
  277. filename: str,
  278. title: str,
  279. document_code: str,
  280. story: list[Any],
  281. ) -> None:
  282. OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
  283. document = ZBTDocument(
  284. OUTPUT_DIR / filename,
  285. title=title,
  286. document_code=document_code,
  287. )
  288. document.build(story)
  289. def build_senior_terms() -> None:
  290. title = "银龄守护医疗险条款摘要"
  291. code = "ZBT-KB-TERMS-001"
  292. story = cover(
  293. title,
  294. "面向50至75周岁人群的住院医疗保障测试条款",
  295. code,
  296. "保险条款",
  297. "V1.0",
  298. )
  299. story += [
  300. p("一、产品基本信息", H1),
  301. data_table(
  302. [
  303. ["项目", "约定内容"],
  304. ["投保年龄", "首次投保年龄为50至75周岁,以投保时的周岁年龄为准。"],
  305. ["保障期间", "一年,自电子保单载明的保障起期零时开始。"],
  306. ["等待期", "疾病住院等待期为30天;意外伤害导致的住院不设等待期。"],
  307. ["适用医院", "中国大陆境内二级及以上公立医院普通部。"],
  308. ["年度保额", "银龄标准计划年度医疗费用保额为100万元。"],
  309. ],
  310. [35 * mm, 109 * mm],
  311. ),
  312. p("二、保险责任", H1),
  313. p("在保险期间内,被保险人因疾病或意外伤害,经符合约定的医院诊断必须住院治疗,对其实际发生且合理必要的医疗费用,按本摘要约定计算保险金。"),
  314. bullet("住院医疗费用包括床位费、药品费、治疗费、检查检验费、手术费和护理费。"),
  315. bullet("年度免赔额为10,000元,同一保险期间内累计计算。"),
  316. bullet("经基本医疗保险结算后,超过免赔额的合规费用按80%比例赔付。"),
  317. bullet("未使用基本医疗保险结算的,超过免赔额部分按60%比例赔付。"),
  318. p("三、责任免除", H1),
  319. p("下列情形产生的费用不在本测试条款保障范围内:投保前已明确诊断的既往症、美容整形、健康体检、康复疗养、违法犯罪行为导致的伤害,以及在非约定医疗机构发生的费用。"),
  320. p("四、示例计算", H1),
  321. p("被保险人使用基本医疗保险结算后,个人承担的合规住院费用为30,000元。扣除10,000元年度免赔额后,可赔付金额为20,000 × 80% = 16,000元。"),
  322. ]
  323. build_document("01-银龄守护医疗险条款摘要.pdf", title, code, story)
  324. def build_claim_service() -> None:
  325. title = "蓉惠医疗险服务申请指南"
  326. code = "ZBT-KB-SERVICE-002"
  327. story = cover(
  328. title,
  329. "医疗费用报销协助的申请入口、材料清单与处理时效",
  330. code,
  331. "服务规则",
  332. "V1.2",
  333. )
  334. story += [
  335. p("一、服务申请入口", H1),
  336. p("用户应登录智保通 H5,在“我的保障”中找到状态为“保障中”的保单,点击“发起保单服务”。“保单服务”页面仅用于查看申请记录、处理状态和运营人员反馈,不提供新的申请入口。"),
  337. p("二、申请时限", H1),
  338. p("建议用户在出院之日起10个自然日内提交服务申请。超过10日仍可提交,但可能因材料补充或信息核验延长处理时间。"),
  339. p("三、基础材料清单", H1),
  340. data_table(
  341. [
  342. ["材料", "要求", "常见问题"],
  343. ["医疗费用票据", "原件照片完整、金额和票据号码清晰", "图片裁切、反光或金额模糊"],
  344. ["住院费用清单", "包含费用项目、数量、单价和合计金额", "只上传首页,缺少明细页"],
  345. ["出院记录", "包含入院诊断、治疗经过和出院诊断", "患者姓名与保单不一致"],
  346. ["诊断证明", "由就诊医院出具并加盖有效印章", "缺少医院印章或诊断日期"],
  347. ["身份与账户信息", "按后续通知补充,不在首次票据识别阶段上传", "银行卡户名与被保险人不一致"],
  348. ],
  349. [32 * mm, 63 * mm, 49 * mm],
  350. ),
  351. p("四、智能识别与人工确认", H1),
  352. p("系统通过阿里云百炼调用 Qwen 多模态模型,识别医疗票据中的票据号码、医疗机构、就诊日期和费用金额。识别结果仅作为候选字段,用户必须对照原始票据逐项核对并确认。AI 识别结果不代表保险公司作出赔付承诺。"),
  353. p("五、处理状态说明", H1),
  354. data_table(
  355. [
  356. ["状态", "含义", "用户下一步"],
  357. ["待处理", "申请已提交,等待运营人员初步核验", "保持联系方式畅通"],
  358. ["已受理", "材料满足初步受理条件", "等待后续服务联系"],
  359. ["未受理", "申请不符合当前服务范围", "查看处理意见或咨询人工服务"],
  360. ],
  361. [31 * mm, 64 * mm, 49 * mm],
  362. ),
  363. p("六、处理时效", H1),
  364. bullet("运营人员应在申请提交后1个工作日内完成初步审核。"),
  365. bullet("状态为“已受理”的申请,工作人员通常在2个工作日内首次联系用户。"),
  366. ]
  367. build_document("02-蓉惠医疗险服务申请指南.pdf", title, code, story)
  368. def build_family_accident() -> None:
  369. title = "家庭意外保障险产品说明"
  370. code = "ZBT-KB-PRODUCT-003"
  371. story = cover(
  372. title,
  373. "适合家庭成员统一配置的意外伤害保障测试资料",
  374. code,
  375. "产品说明",
  376. "V2.0",
  377. )
  378. story += [
  379. p("一、产品定位", H1),
  380. p("家庭意外保障险用于覆盖家庭成员在日常生活、通勤和休闲活动中遭受的意外伤害风险。家庭计划最多可纳入5名家庭成员,成员关系限本人、父母、配偶和子女。"),
  381. p("二、保障计划", H1),
  382. data_table(
  383. [
  384. ["保障项目", "个人计划", "家庭计划"],
  385. ["意外身故及伤残", "每人20万元", "每人20万元"],
  386. ["意外医疗", "每人2万元", "每人2万元"],
  387. ["意外住院津贴", "100元/天,年度最多30天", "100元/天,年度最多30天"],
  388. ["年度保费", "99元/年", "159元/年"],
  389. ["可保障人数", "1人", "最多5人"],
  390. ],
  391. [50 * mm, 47 * mm, 47 * mm],
  392. ),
  393. p("三、意外医疗费用规则", H1),
  394. bullet("每次事故免赔额为100元。"),
  395. bullet("属于当地基本医疗保险范围内的合理费用,扣除免赔额后按90%比例赔付。"),
  396. bullet("同一被保险人年度意外医疗累计赔付不超过20,000元。"),
  397. p("四、投保人与被保险人", H1),
  398. p("投保人应为具有完全民事行为能力的自然人。家庭计划中的被保险人可以是投保人本人、父母、配偶或子女。新增家庭成员时,应准确填写姓名、身份证号码及与投保人的关系。"),
  399. p("五、不保事项", H1),
  400. p("疾病导致的医疗费用、故意自伤、酒后驾驶、无有效驾驶证驾驶、高风险竞技运动以及战争或核辐射导致的损失不属于本产品的意外保障范围。"),
  401. p("六、典型场景", H1),
  402. p("被保险人因骑行摔伤产生合规意外医疗费用2,000元。扣除100元免赔额后,按90%计算,可赔付1,710元。"),
  403. ]
  404. build_document("03-家庭意外保障险产品说明.pdf", title, code, story)
  405. def build_faq() -> None:
  406. title = "智保通投保与保单服务常见问题"
  407. code = "ZBT-KB-FAQ-004"
  408. story = cover(
  409. title,
  410. "覆盖投保、支付、电子保单、服务申请和数据安全的常见问题",
  411. code,
  412. "常见问题",
  413. "V1.0",
  414. )
  415. faq_items = [
  416. ("投保时可以修改联系电话吗?", "可以。投保页面默认带入登录手机号,用户可以修改为实际接收投保及服务通知的手机号。修改联系电话不会改变登录账号,订单仍归属于当前登录用户。"),
  417. ("支付完成后多久生成电子保单?", "本地模拟支付回调成功后,系统立即完成承保并生成电子保单。用户可以在“我的保障”中查看电子保单号、保障期限和保单状态。"),
  418. ("为什么有订单却没有保单?", "待支付、支付失败或已取消的订单不会生成有效保单。只有支付成功并完成承保的订单才会关联电子保单。"),
  419. ("从哪里发起保单服务?", "请在“我的保障”中选择状态为“保障中”的具体保单,点击“发起保单服务”。“保单服务”板块只展示申请记录和处理进度。"),
  420. ("AI 识别出的票据信息可以修改吗?", "可以。票据号码、医疗机构、就诊日期、费用金额和联系电话均应由用户核对,确认无误后再提交。"),
  421. ("申请未受理后应该怎么办?", "用户可以在服务记录中查看运营人员填写的处理意见;如需进一步了解原因,可联系人工服务。"),
  422. ("投保前取消是否收费?", "在订单尚未支付前可以放弃支付,不产生保费扣款。支付后的退保及退款属于后续保全业务,应以对应产品规则和系统处理结果为准。"),
  423. ("个人信息如何保护?", "身份证号码和手机号在后台展示时进行掩码处理,权限和数据范围由后端统一控制。敏感字段不应写入日志或智能体自由文本。"),
  424. ]
  425. story += [p("常见问题索引", H1)]
  426. for index, (question, answer) in enumerate(faq_items, start=1):
  427. story.append(
  428. KeepTogether(
  429. [
  430. p(f"{index:02d} {question}", H2),
  431. p(answer),
  432. Spacer(1, 1.5 * mm),
  433. ]
  434. )
  435. )
  436. build_document("04-智保通投保与保单服务常见问题.pdf", title, code, story)
  437. def main() -> None:
  438. build_senior_terms()
  439. build_claim_service()
  440. build_family_accident()
  441. build_faq()
  442. expected_text = {
  443. "01-银龄守护医疗险条款摘要.pdf": ("等待期为30天", "免赔额为10,000元", "按80%比例赔付"),
  444. "02-蓉惠医疗险服务申请指南.pdf": (
  445. "出院之日起10个自然日",
  446. "阿里云百炼 · Qwen 多模态模型",
  447. "1个工作日内完成初步审核",
  448. ),
  449. "03-家庭意外保障险产品说明.pdf": (
  450. "5名家庭成员",
  451. "每次事故免赔额为100元",
  452. "按90%比例赔付",
  453. ),
  454. "04-智保通投保与保单服务常见问题.pdf": (
  455. "从哪里发起保单服务",
  456. "不会改变登录账号",
  457. "敏感字段不应写入日志",
  458. ),
  459. }
  460. for filename, phrases in expected_text.items():
  461. path = OUTPUT_DIR / filename
  462. reader = PdfReader(path)
  463. extracted = "\n".join(page.extract_text() or "" for page in reader.pages)
  464. normalized = "".join(extracted.split())
  465. missing = [
  466. phrase
  467. for phrase in phrases
  468. if "".join(phrase.split()) not in normalized
  469. ]
  470. if missing:
  471. raise RuntimeError(f"{filename} 文本提取校验失败:{missing}")
  472. print(
  473. f"{path.name}\t{len(reader.pages)} pages\t"
  474. f"{len(extracted)} chars\t{path.stat().st_size} bytes"
  475. )
  476. if __name__ == "__main__":
  477. main()