dsh-crwu-workbench 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (479) hide show
  1. package/CHANGELOG.md +1516 -0
  2. package/LICENSE +26 -0
  3. package/README.en.md +204 -0
  4. package/README.md +521 -0
  5. package/SECURITY.md +39 -0
  6. package/bin/darwin-arm64/crwu +0 -0
  7. package/bin/darwin-arm64/dws +0 -0
  8. package/bin/darwin-arm64/ossutil +0 -0
  9. package/bin/manifest.json +82 -0
  10. package/bin/win32-x64/crwu.exe +0 -0
  11. package/bin/win32-x64/dws.exe +4 -0
  12. package/bin/win32-x64/ossutil.exe +0 -0
  13. package/common/skills/.synced.json +8 -0
  14. package/common/skills/crwu-dws/SKILL.md +208 -0
  15. package/common/skills/crwu-dws/references/00-/347/233/256/345/275/225/345/277/253/347/205/247schema.md +113 -0
  16. package/common/skills/crwu-dws/references/01-/345/256/241/346/240/270/344/270/213/350/275/275/344/270/216manifest/350/247/204/350/214/203.md +115 -0
  17. package/common/skills/crwu-dws/references/02-/347/274/223/345/255/230/344/270/216/345/205/234/345/272/225/346/237/245/346/211/276/350/247/204/350/214/203.md +119 -0
  18. package/common/skills/crwu-dws/scripts/test_dws_source_contract.py +493 -0
  19. package/common/skills/crwu-h3yun-login/SKILL.md +130 -0
  20. package/common/skills/crwu-h3yun-query/SKILL.md +192 -0
  21. package/config/crwu-workbench.yml +26 -0
  22. package/cordis.patch.yml +46 -0
  23. package/lib/client.js +10346 -0
  24. package/lib/index.js +11584 -0
  25. package/package.json +119 -0
  26. package/scripts/prepare.mjs +59 -0
  27. package/skills/README.md +26 -0
  28. package/skills/crwu/crwu-audit/SKILL.md +152 -0
  29. package/skills/crwu/crwu-audit/references/00-input-and-route-profile.md +232 -0
  30. package/skills/crwu/crwu-audit/references/01-report-id-resolution.md +13 -0
  31. package/skills/crwu/crwu-audit/references/02-scope-classification.md +25 -0
  32. package/skills/crwu/crwu-audit/references/03-asset-classification.md +27 -0
  33. package/skills/crwu/crwu-audit/references/04-business-classification.md +63 -0
  34. package/skills/crwu/crwu-audit/references/05-method-classification.md +66 -0
  35. package/skills/crwu/crwu-audit/references/06-overlay-classification.md +42 -0
  36. package/skills/crwu/crwu-audit/references/07-skill-registry.md +57 -0
  37. package/skills/crwu/crwu-audit/references/08-union-dispatch-rules.md +142 -0
  38. package/skills/crwu/crwu-audit/references/09-review-risk-classification.md +85 -0
  39. package/skills/crwu/crwu-audit/references/10-capability-gap-proposal.md +68 -0
  40. package/skills/crwu/crwu-audit/references/11-html-delivery-spec.md +881 -0
  41. package/skills/crwu/crwu-audit/references/12-leaf-common-contract.md +202 -0
  42. package/skills/crwu/crwu-audit/references/13-dingtalk-result-publish.md +88 -0
  43. package/skills/crwu/crwu-audit/references/14-orchestration-workflow.md +52 -0
  44. package/skills/crwu/crwu-audit/references/99-maintenance.md +62 -0
  45. package/skills/crwu/crwu-audit/scripts/README.md +157 -0
  46. package/skills/crwu/crwu-audit/scripts/audit_delivery.py +2736 -0
  47. package/skills/crwu/crwu-audit/scripts/audit_result.schema.json +1659 -0
  48. package/skills/crwu/crwu-audit/scripts/examples/audit-result.sample.json +739 -0
  49. package/skills/crwu/crwu-audit/scripts/fetch_review_records.py +85 -0
  50. package/skills/crwu/crwu-audit/scripts/media_extract.py +513 -0
  51. package/skills/crwu/crwu-audit/scripts/prepare_materials.py +1252 -0
  52. package/skills/crwu/crwu-audit/scripts/test_audit_delivery.py +1678 -0
  53. package/skills/crwu/crwu-audit/scripts/test_audit_multiaxis_router.py +1339 -0
  54. package/skills/crwu/crwu-audit/scripts/test_fetch_review_records.py +77 -0
  55. package/skills/crwu/crwu-audit/scripts/test_media_extract.py +332 -0
  56. package/skills/crwu/crwu-audit/scripts/test_prepare_materials.py +1081 -0
  57. package/skills/crwu/crwu-audit/template/audit-report.html +435 -0
  58. package/skills/crwu/crwu-audit-asset-asset-group-goodwill/SKILL.md +38 -0
  59. package/skills/crwu/crwu-audit-asset-asset-group-goodwill/references/00-applicability.md +28 -0
  60. package/skills/crwu/crwu-audit-asset-asset-group-goodwill/references/01-kb-assembly.md +31 -0
  61. package/skills/crwu/crwu-audit-asset-asset-group-goodwill/references/02-review-focus.md +21 -0
  62. package/skills/crwu/crwu-audit-asset-asset-group-goodwill/references/03-common-contract.md +202 -0
  63. package/skills/crwu/crwu-audit-asset-debt/SKILL.md +38 -0
  64. package/skills/crwu/crwu-audit-asset-debt/references/00-applicability.md +40 -0
  65. package/skills/crwu/crwu-audit-asset-debt/references/01-kb-assembly.md +41 -0
  66. package/skills/crwu/crwu-audit-asset-debt/references/02-review-focus.md +54 -0
  67. package/skills/crwu/crwu-audit-asset-debt/references/03-common-contract.md +202 -0
  68. package/skills/crwu/crwu-audit-asset-enterprise-value/SKILL.md +38 -0
  69. package/skills/crwu/crwu-audit-asset-enterprise-value/references/00-applicability.md +31 -0
  70. package/skills/crwu/crwu-audit-asset-enterprise-value/references/01-kb-assembly.md +35 -0
  71. package/skills/crwu/crwu-audit-asset-enterprise-value/references/02-review-focus.md +33 -0
  72. package/skills/crwu/crwu-audit-asset-enterprise-value/references/03-common-contract.md +202 -0
  73. package/skills/crwu/crwu-audit-asset-equipment/SKILL.md +38 -0
  74. package/skills/crwu/crwu-audit-asset-equipment/references/00-applicability.md +40 -0
  75. package/skills/crwu/crwu-audit-asset-equipment/references/01-kb-assembly.md +39 -0
  76. package/skills/crwu/crwu-audit-asset-equipment/references/02-review-focus.md +52 -0
  77. package/skills/crwu/crwu-audit-asset-equipment/references/03-common-contract.md +202 -0
  78. package/skills/crwu/crwu-audit-asset-intangible/SKILL.md +38 -0
  79. package/skills/crwu/crwu-audit-asset-intangible/references/00-applicability.md +36 -0
  80. package/skills/crwu/crwu-audit-asset-intangible/references/01-kb-assembly.md +40 -0
  81. package/skills/crwu/crwu-audit-asset-intangible/references/02-review-focus.md +68 -0
  82. package/skills/crwu/crwu-audit-asset-intangible/references/03-common-contract.md +202 -0
  83. package/skills/crwu/crwu-audit-asset-inventory/SKILL.md +38 -0
  84. package/skills/crwu/crwu-audit-asset-inventory/references/00-applicability.md +28 -0
  85. package/skills/crwu/crwu-audit-asset-inventory/references/01-kb-assembly.md +31 -0
  86. package/skills/crwu/crwu-audit-asset-inventory/references/02-review-focus.md +21 -0
  87. package/skills/crwu/crwu-audit-asset-inventory/references/03-common-contract.md +202 -0
  88. package/skills/crwu/crwu-audit-asset-mining-right/SKILL.md +38 -0
  89. package/skills/crwu/crwu-audit-asset-mining-right/references/00-applicability.md +31 -0
  90. package/skills/crwu/crwu-audit-asset-mining-right/references/01-kb-assembly.md +35 -0
  91. package/skills/crwu/crwu-audit-asset-mining-right/references/02-review-focus.md +33 -0
  92. package/skills/crwu/crwu-audit-asset-mining-right/references/03-common-contract.md +202 -0
  93. package/skills/crwu/crwu-audit-asset-other/SKILL.md +38 -0
  94. package/skills/crwu/crwu-audit-asset-other/references/00-applicability.md +28 -0
  95. package/skills/crwu/crwu-audit-asset-other/references/01-kb-assembly.md +31 -0
  96. package/skills/crwu/crwu-audit-asset-other/references/02-review-focus.md +21 -0
  97. package/skills/crwu/crwu-audit-asset-other/references/03-common-contract.md +202 -0
  98. package/skills/crwu/crwu-audit-asset-portfolio/SKILL.md +38 -0
  99. package/skills/crwu/crwu-audit-asset-portfolio/references/00-applicability.md +28 -0
  100. package/skills/crwu/crwu-audit-asset-portfolio/references/01-kb-assembly.md +31 -0
  101. package/skills/crwu/crwu-audit-asset-portfolio/references/02-review-focus.md +21 -0
  102. package/skills/crwu/crwu-audit-asset-portfolio/references/03-common-contract.md +202 -0
  103. package/skills/crwu/crwu-audit-asset-realestate/SKILL.md +38 -0
  104. package/skills/crwu/crwu-audit-asset-realestate/references/00-applicability.md +43 -0
  105. package/skills/crwu/crwu-audit-asset-realestate/references/01-kb-assembly.md +75 -0
  106. package/skills/crwu/crwu-audit-asset-realestate/references/02-review-focus.md +93 -0
  107. package/skills/crwu/crwu-audit-asset-realestate/references/03-common-contract.md +202 -0
  108. package/skills/crwu/crwu-audit-asset-scrap-materials/SKILL.md +38 -0
  109. package/skills/crwu/crwu-audit-asset-scrap-materials/references/00-applicability.md +28 -0
  110. package/skills/crwu/crwu-audit-asset-scrap-materials/references/01-kb-assembly.md +31 -0
  111. package/skills/crwu/crwu-audit-asset-scrap-materials/references/02-review-focus.md +21 -0
  112. package/skills/crwu/crwu-audit-asset-scrap-materials/references/03-common-contract.md +202 -0
  113. package/skills/crwu/crwu-audit-asset-transport-equipment/SKILL.md +38 -0
  114. package/skills/crwu/crwu-audit-asset-transport-equipment/references/00-applicability.md +31 -0
  115. package/skills/crwu/crwu-audit-asset-transport-equipment/references/01-kb-assembly.md +35 -0
  116. package/skills/crwu/crwu-audit-asset-transport-equipment/references/02-review-focus.md +33 -0
  117. package/skills/crwu/crwu-audit-asset-transport-equipment/references/03-common-contract.md +202 -0
  118. package/skills/crwu/crwu-audit-biz-asset-operation/SKILL.md +38 -0
  119. package/skills/crwu/crwu-audit-biz-asset-operation/references/00-applicability.md +30 -0
  120. package/skills/crwu/crwu-audit-biz-asset-operation/references/01-kb-assembly.md +32 -0
  121. package/skills/crwu/crwu-audit-biz-asset-operation/references/02-review-focus.md +42 -0
  122. package/skills/crwu/crwu-audit-biz-asset-operation/references/03-common-contract.md +202 -0
  123. package/skills/crwu/crwu-audit-biz-consulting-review/SKILL.md +38 -0
  124. package/skills/crwu/crwu-audit-biz-consulting-review/references/00-applicability.md +30 -0
  125. package/skills/crwu/crwu-audit-biz-consulting-review/references/01-kb-assembly.md +32 -0
  126. package/skills/crwu/crwu-audit-biz-consulting-review/references/02-review-focus.md +40 -0
  127. package/skills/crwu/crwu-audit-biz-consulting-review/references/03-common-contract.md +202 -0
  128. package/skills/crwu/crwu-audit-biz-financial-reporting/SKILL.md +38 -0
  129. package/skills/crwu/crwu-audit-biz-financial-reporting/references/00-applicability.md +41 -0
  130. package/skills/crwu/crwu-audit-biz-financial-reporting/references/01-kb-assembly.md +40 -0
  131. package/skills/crwu/crwu-audit-biz-financial-reporting/references/02-review-focus.md +64 -0
  132. package/skills/crwu/crwu-audit-biz-financial-reporting/references/03-common-contract.md +202 -0
  133. package/skills/crwu/crwu-audit-biz-financing-debt/SKILL.md +38 -0
  134. package/skills/crwu/crwu-audit-biz-financing-debt/references/00-applicability.md +31 -0
  135. package/skills/crwu/crwu-audit-biz-financing-debt/references/01-kb-assembly.md +34 -0
  136. package/skills/crwu/crwu-audit-biz-financing-debt/references/02-review-focus.md +47 -0
  137. package/skills/crwu/crwu-audit-biz-financing-debt/references/03-common-contract.md +202 -0
  138. package/skills/crwu/crwu-audit-biz-investment-capital/SKILL.md +38 -0
  139. package/skills/crwu/crwu-audit-biz-investment-capital/references/00-applicability.md +31 -0
  140. package/skills/crwu/crwu-audit-biz-investment-capital/references/01-kb-assembly.md +34 -0
  141. package/skills/crwu/crwu-audit-biz-investment-capital/references/02-review-focus.md +47 -0
  142. package/skills/crwu/crwu-audit-biz-investment-capital/references/03-common-contract.md +202 -0
  143. package/skills/crwu/crwu-audit-biz-judicial-liquidation-compensation/SKILL.md +38 -0
  144. package/skills/crwu/crwu-audit-biz-judicial-liquidation-compensation/references/00-applicability.md +30 -0
  145. package/skills/crwu/crwu-audit-biz-judicial-liquidation-compensation/references/01-kb-assembly.md +32 -0
  146. package/skills/crwu/crwu-audit-biz-judicial-liquidation-compensation/references/02-review-focus.md +40 -0
  147. package/skills/crwu/crwu-audit-biz-judicial-liquidation-compensation/references/03-common-contract.md +202 -0
  148. package/skills/crwu/crwu-audit-biz-tax-history/SKILL.md +38 -0
  149. package/skills/crwu/crwu-audit-biz-tax-history/references/00-applicability.md +29 -0
  150. package/skills/crwu/crwu-audit-biz-tax-history/references/01-kb-assembly.md +30 -0
  151. package/skills/crwu/crwu-audit-biz-tax-history/references/02-review-focus.md +33 -0
  152. package/skills/crwu/crwu-audit-biz-tax-history/references/03-common-contract.md +202 -0
  153. package/skills/crwu/crwu-audit-biz-transaction-disposal/SKILL.md +38 -0
  154. package/skills/crwu/crwu-audit-biz-transaction-disposal/references/00-applicability.md +31 -0
  155. package/skills/crwu/crwu-audit-biz-transaction-disposal/references/01-kb-assembly.md +34 -0
  156. package/skills/crwu/crwu-audit-biz-transaction-disposal/references/02-review-focus.md +47 -0
  157. package/skills/crwu/crwu-audit-biz-transaction-disposal/references/03-common-contract.md +202 -0
  158. package/skills/crwu/crwu-audit-datacheck/SKILL.md +59 -0
  159. package/skills/crwu/crwu-audit-datacheck/references/00-KB/350/243/205/351/205/215/350/241/250.md +25 -0
  160. package/skills/crwu/crwu-audit-datacheck/references/01-runtime-and-check-catalog.md +139 -0
  161. package/skills/crwu/crwu-audit-datacheck/scripts/recalc_check.py +521 -0
  162. package/skills/crwu/crwu-audit-datacheck/scripts/test_recalc_check.py +419 -0
  163. package/skills/crwu/crwu-audit-datacheck/scripts/test_skill_self_containment.py +44 -0
  164. package/skills/crwu/crwu-audit-external-data/SKILL.md +73 -0
  165. package/skills/crwu/crwu-audit-external-data/references/00-KB/350/243/205/351/205/215/350/241/250.md +38 -0
  166. package/skills/crwu/crwu-audit-external-data/references/01-connector-access.md +116 -0
  167. package/skills/crwu/crwu-audit-external-data/references/02-verification-workflow.md +78 -0
  168. package/skills/crwu/crwu-audit-external-data/scripts/connector_probe.py +374 -0
  169. package/skills/crwu/crwu-audit-external-data/scripts/test_connector_probe.py +264 -0
  170. package/skills/crwu/crwu-audit-output-filter/SKILL.md +34 -0
  171. package/skills/crwu/crwu-audit-output-filter/references/00-applicability.md +7 -0
  172. package/skills/crwu/crwu-audit-output-filter/references/01-kb-assembly.md +11 -0
  173. package/skills/crwu/crwu-audit-output-filter/references/02-review-focus.md +25 -0
  174. package/skills/crwu/crwu-audit-public-general-standards/SKILL.md +46 -0
  175. package/skills/crwu/crwu-audit-public-general-standards/references/00-applicability.md +41 -0
  176. package/skills/crwu/crwu-audit-public-general-standards/references/01-kb-assembly.md +66 -0
  177. package/skills/crwu/crwu-audit-public-general-standards/references/02-review-focus.md +111 -0
  178. package/skills/crwu/crwu-audit-public-general-standards/references/03-execution-contract.md +89 -0
  179. package/skills/crwu/crwu-dev-audit-optimize/SKILL.md +98 -0
  180. package/skills/crwu/crwu-dev-audit-optimize/references/00-/344/274/230/345/214/226/350/247/204/350/214/203/344/270/216/346/226/207/344/273/266/350/220/275/347/202/271.md +132 -0
  181. package/skills/crwu/crwu-dev-audit-optimize/references/01-/345/217/215/351/246/210/345/256/232/344/275/215/344/270/216/347/224/273/345/203/217/346/265/201/347/250/213.md +108 -0
  182. package/skills/crwu/crwu-dev-audit-optimize/references/02-/346/226/271/346/241/210/346/250/241/346/235/277/344/270/216/347/241/256/350/256/244/351/227/250/347/246/201.md +92 -0
  183. package/skills/crwu/crwu-dev-audit-optimize/references/03-AI/344/272/272/345/267/245/345/267/256/350/267/235/345/210/206/346/236/220/346/265/201/347/250/213.md +95 -0
  184. package/skills/crwu/crwu-dev-audit-optimize/scripts/examples/gap-analysis.sample.json +116 -0
  185. package/skills/crwu/crwu-dev-audit-optimize/scripts/gap_analysis.schema.json +33 -0
  186. package/skills/crwu/crwu-dev-audit-optimize/scripts/gap_analysis_delivery.py +475 -0
  187. package/skills/crwu/crwu-dev-audit-optimize/scripts/test_gap_analysis_delivery.py +855 -0
  188. package/skills/crwu/crwu-dev-audit-optimize/template/NOTICE.echarts.txt +7 -0
  189. package/skills/crwu/crwu-dev-audit-optimize/template/echarts.min.js +45 -0
  190. package/skills/crwu/crwu-dev-audit-optimize/template/gap-analysis-report.html +71 -0
  191. package/skills/crwu/crwu-dev-audit-skill-maintainer/SKILL.md +74 -0
  192. package/skills/crwu/crwu-dev-audit-skill-maintainer/references/00-responsibility-and-modes.md +62 -0
  193. package/skills/crwu/crwu-dev-audit-skill-maintainer/references/01-kb-source-discovery.md +89 -0
  194. package/skills/crwu/crwu-dev-audit-skill-maintainer/references/02-child-skill-contract.md +99 -0
  195. package/skills/crwu/crwu-dev-audit-skill-maintainer/references/03-review-item-execution-contract.md +51 -0
  196. package/skills/crwu/crwu-dev-audit-skill-maintainer/references/04-registry-and-mapping-update.md +73 -0
  197. package/skills/crwu/crwu-dev-audit-skill-maintainer/references/05-validation-and-delivery.md +127 -0
  198. package/skills/crwu/crwu-dev-audit-skill-maintainer/references/06-live-routing-reconciliation.md +136 -0
  199. package/skills/crwu/crwu-dev-audit-skill-maintainer/references/07-kb-skill-map.md +411 -0
  200. package/skills/crwu/crwu-dev-audit-skill-maintainer/scripts/README.md +200 -0
  201. package/skills/crwu/crwu-dev-audit-skill-maintainer/scripts/check_audit_skill_mappings.py +2477 -0
  202. package/skills/crwu/crwu-dev-audit-skill-maintainer/scripts/kb_tool.py +577 -0
  203. package/skills/crwu/crwu-dev-audit-skill-maintainer/scripts/test_audit_skill_maintainer.py +5097 -0
  204. package/skills/dws/LICENSE +178 -0
  205. package/skills/dws/NOTICE +17 -0
  206. package/skills/dws/dingtalk-aisearch/SKILL.md +83 -0
  207. package/skills/dws/dingtalk-aisearch/references/aisearch.md +249 -0
  208. package/skills/dws/dingtalk-aisearch/references/intent-guide.md +15 -0
  209. package/skills/dws/dingtalk-aisearch/references/lite-recipes.md +31 -0
  210. package/skills/dws/dingtalk-aitable/SKILL.md +139 -0
  211. package/skills/dws/dingtalk-aitable/references/06-data-analytics.md +11 -0
  212. package/skills/dws/dingtalk-aitable/references/aitable/aitable-advperm.md +251 -0
  213. package/skills/dws/dingtalk-aitable/references/aitable/aitable-attachment.md +49 -0
  214. package/skills/dws/dingtalk-aitable/references/aitable/aitable-best-practices.md +48 -0
  215. package/skills/dws/dingtalk-aitable/references/aitable/aitable-cell-value.md +346 -0
  216. package/skills/dws/dingtalk-aitable/references/aitable/aitable-dashboard-chart.md +46 -0
  217. package/skills/dws/dingtalk-aitable/references/aitable/aitable-data-analysis-sop.md +131 -0
  218. package/skills/dws/dingtalk-aitable/references/aitable/aitable-datasource.md +272 -0
  219. package/skills/dws/dingtalk-aitable/references/aitable/aitable-error-recovery.md +128 -0
  220. package/skills/dws/dingtalk-aitable/references/aitable/aitable-export-import.md +113 -0
  221. package/skills/dws/dingtalk-aitable/references/aitable/aitable-field-properties.md +238 -0
  222. package/skills/dws/dingtalk-aitable/references/aitable/aitable-field.md +186 -0
  223. package/skills/dws/dingtalk-aitable/references/aitable/aitable-filter-sort.md +135 -0
  224. package/skills/dws/dingtalk-aitable/references/aitable/aitable-form.md +130 -0
  225. package/skills/dws/dingtalk-aitable/references/aitable/aitable-formula-guide.md +224 -0
  226. package/skills/dws/dingtalk-aitable/references/aitable/aitable-primary-doc.md +58 -0
  227. package/skills/dws/dingtalk-aitable/references/aitable/aitable-record-create.md +52 -0
  228. package/skills/dws/dingtalk-aitable/references/aitable/aitable-record-delete.md +20 -0
  229. package/skills/dws/dingtalk-aitable/references/aitable/aitable-record-history.md +97 -0
  230. package/skills/dws/dingtalk-aitable/references/aitable/aitable-record-name-key.md +47 -0
  231. package/skills/dws/dingtalk-aitable/references/aitable/aitable-record-query.md +120 -0
  232. package/skills/dws/dingtalk-aitable/references/aitable/aitable-record-share.md +54 -0
  233. package/skills/dws/dingtalk-aitable/references/aitable/aitable-record-stats.md +59 -0
  234. package/skills/dws/dingtalk-aitable/references/aitable/aitable-record-update.md +66 -0
  235. package/skills/dws/dingtalk-aitable/references/aitable/aitable-record-upsert.md +89 -0
  236. package/skills/dws/dingtalk-aitable/references/aitable/aitable-view-config.md +140 -0
  237. package/skills/dws/dingtalk-aitable/references/aitable/aitable-view-extras.md +195 -0
  238. package/skills/dws/dingtalk-aitable/references/aitable/aitable-view-types.md +95 -0
  239. package/skills/dws/dingtalk-aitable/references/aitable/aitable-workflow.md +384 -0
  240. package/skills/dws/dingtalk-aitable/references/aitable-record-ops.md +80 -0
  241. package/skills/dws/dingtalk-aitable/references/aitable-section.md +30 -0
  242. package/skills/dws/dingtalk-aitable/references/aitable.md +59 -0
  243. package/skills/dws/dingtalk-aitable/references/field-rules.md +17 -0
  244. package/skills/dws/dingtalk-aitable/references/intent-guide.md +17 -0
  245. package/skills/dws/dingtalk-aitable/references/recipes/conventions.md +44 -0
  246. package/skills/dws/dingtalk-aitable/references/url-patterns.md +144 -0
  247. package/skills/dws/dingtalk-aitable/scripts/aitable_export_via_task.py +210 -0
  248. package/skills/dws/dingtalk-aitable/scripts/aitable_import_via_task.py +173 -0
  249. package/skills/dws/dingtalk-aitable/scripts/bulk_add_fields.py +273 -0
  250. package/skills/dws/dingtalk-aitable/scripts/import_records.py +333 -0
  251. package/skills/dws/dingtalk-aitable/scripts/upload_attachment.py +190 -0
  252. package/skills/dws/dingtalk-calendar/SKILL.md +137 -0
  253. package/skills/dws/dingtalk-calendar/references/03-meeting.md +56 -0
  254. package/skills/dws/dingtalk-calendar/references/calendar.md +760 -0
  255. package/skills/dws/dingtalk-calendar/references/intent-guide.md +9 -0
  256. package/skills/dws/dingtalk-calendar/references/lite-recipes.md +25 -0
  257. package/skills/dws/dingtalk-calendar/scripts/calendar_free_slot_finder.py +199 -0
  258. package/skills/dws/dingtalk-calendar/scripts/calendar_schedule_meeting.py +233 -0
  259. package/skills/dws/dingtalk-calendar/scripts/calendar_today_agenda.py +139 -0
  260. package/skills/dws/dingtalk-chat/SKILL.md +127 -0
  261. package/skills/dws/dingtalk-chat/references/01-messaging.md +103 -0
  262. package/skills/dws/dingtalk-chat/references/card/callback.md +10 -0
  263. package/skills/dws/dingtalk-chat/references/card/create.md +27 -0
  264. package/skills/dws/dingtalk-chat/references/card/schema.md +14 -0
  265. package/skills/dws/dingtalk-chat/references/card/update.md +13 -0
  266. package/skills/dws/dingtalk-chat/references/chat/chat-bot.md +187 -0
  267. package/skills/dws/dingtalk-chat/references/chat/chat-conversation.md +165 -0
  268. package/skills/dws/dingtalk-chat/references/chat/group-admin.md +144 -0
  269. package/skills/dws/dingtalk-chat/references/chat/group-discovery.md +121 -0
  270. package/skills/dws/dingtalk-chat/references/chat/message-actions.md +132 -0
  271. package/skills/dws/dingtalk-chat/references/chat/message-media.md +76 -0
  272. package/skills/dws/dingtalk-chat/references/chat/message-query.md +135 -0
  273. package/skills/dws/dingtalk-chat/references/chat/thread.md +46 -0
  274. package/skills/dws/dingtalk-chat/references/chat-emoji-list.md +216 -0
  275. package/skills/dws/dingtalk-chat/references/chat.md +140 -0
  276. package/skills/dws/dingtalk-chat/references/contracts.md +70 -0
  277. package/skills/dws/dingtalk-chat/references/intent-guide.md +56 -0
  278. package/skills/dws/dingtalk-contact/SKILL.md +122 -0
  279. package/skills/dws/dingtalk-contact/references/08-directory.md +51 -0
  280. package/skills/dws/dingtalk-contact/references/contact.md +595 -0
  281. package/skills/dws/dingtalk-contact/references/intent-guide.md +14 -0
  282. package/skills/dws/dingtalk-contact/references/lite-recipes.md +30 -0
  283. package/skills/dws/dingtalk-contact/references/recipes/conventions.md +44 -0
  284. package/skills/dws/dingtalk-contact/scripts/contact_dept_members.py +130 -0
  285. package/skills/dws/dingtalk-doc/SKILL.md +112 -0
  286. package/skills/dws/dingtalk-doc/references/04-document.md +57 -0
  287. package/skills/dws/dingtalk-doc/references/contracts.md +40 -0
  288. package/skills/dws/dingtalk-doc/references/doc/doc-block.md +39 -0
  289. package/skills/dws/dingtalk-doc/references/doc/doc-comment.md +29 -0
  290. package/skills/dws/dingtalk-doc/references/doc/doc-create.md +54 -0
  291. package/skills/dws/dingtalk-doc/references/doc/doc-export.md +30 -0
  292. package/skills/dws/dingtalk-doc/references/doc/doc-import.md +28 -0
  293. package/skills/dws/dingtalk-doc/references/doc/doc-info.md +12 -0
  294. package/skills/dws/dingtalk-doc/references/doc/doc-media.md +46 -0
  295. package/skills/dws/dingtalk-doc/references/doc/doc-read.md +56 -0
  296. package/skills/dws/dingtalk-doc/references/doc/doc-update.md +87 -0
  297. package/skills/dws/dingtalk-doc/references/doc/format/doc-jsonml-cookbook.md +469 -0
  298. package/skills/dws/dingtalk-doc/references/doc/format/doc-jsonml-schema.json +526 -0
  299. package/skills/dws/dingtalk-doc/references/doc/format/doc-jsonml-schema.md +711 -0
  300. package/skills/dws/dingtalk-doc/references/doc/style/doc-create-workflow.md +414 -0
  301. package/skills/dws/dingtalk-doc/references/doc/style/doc-style-guideline.md +404 -0
  302. package/skills/dws/dingtalk-doc/references/doc/style/doc-update-workflow.md +316 -0
  303. package/skills/dws/dingtalk-doc/references/doc.md +82 -0
  304. package/skills/dws/dingtalk-doc/references/intent-guide.md +20 -0
  305. package/skills/dws/dingtalk-drive/SKILL.md +128 -0
  306. package/skills/dws/dingtalk-drive/references/drive.md +126 -0
  307. package/skills/dws/dingtalk-drive/references/folder-sync.md +40 -0
  308. package/skills/dws/dingtalk-drive/references/intent-guide.md +22 -0
  309. package/skills/dws/dingtalk-drive/references/lite-recipes.md +39 -0
  310. package/skills/dws/dingtalk-event/SKILL.md +106 -0
  311. package/skills/dws/dingtalk-event/references/event-im-keys.md +54 -0
  312. package/skills/dws/dingtalk-event/references/event-im-lifecycle.md +35 -0
  313. package/skills/dws/dingtalk-event/references/event-im-operations.md +52 -0
  314. package/skills/dws/dingtalk-event/references/event-im-output.md +39 -0
  315. package/skills/dws/dingtalk-event/references/event-im.md +58 -0
  316. package/skills/dws/dingtalk-event/references/event-oa.md +155 -0
  317. package/skills/dws/dingtalk-event/references/event-todo.md +50 -0
  318. package/skills/dws/dingtalk-event/references/event-voip.md +69 -0
  319. package/skills/dws/dingtalk-mail/SKILL.md +113 -0
  320. package/skills/dws/dingtalk-mail/references/09-mail.md +45 -0
  321. package/skills/dws/dingtalk-mail/references/intent-guide.md +8 -0
  322. package/skills/dws/dingtalk-mail/references/lite-recipes.md +45 -0
  323. package/skills/dws/dingtalk-mail/references/mail.md +1889 -0
  324. package/skills/dws/dingtalk-mail/references/recipes/conventions.md +44 -0
  325. package/skills/dws/dingtalk-mail/scripts/mail_send_with_cc.py +122 -0
  326. package/skills/dws/dingtalk-mail/scripts/mail_unread_summary.py +114 -0
  327. package/skills/dws/dingtalk-minutes/SKILL.md +116 -0
  328. package/skills/dws/dingtalk-minutes/references/07-minutes.md +149 -0
  329. package/skills/dws/dingtalk-minutes/references/10-minutes-speaker-match.md +32 -0
  330. package/skills/dws/dingtalk-minutes/references/11-minutes-speaker-correct.md +40 -0
  331. package/skills/dws/dingtalk-minutes/references/intent-guide.md +79 -0
  332. package/skills/dws/dingtalk-minutes/references/lite-recipes.md +25 -0
  333. package/skills/dws/dingtalk-minutes/references/minutes.md +210 -0
  334. package/skills/dws/dingtalk-minutes/references/recipes/conventions.md +33 -0
  335. package/skills/dws/dingtalk-minutes/scripts/minutes_recent_summary.py +196 -0
  336. package/skills/dws/dingtalk-minutes/scripts/test_minutes_scripts.py +222 -0
  337. package/skills/dws/dingtalk-misc/SKILL.md +55 -0
  338. package/skills/dws/dingtalk-misc/references/05-reporting.md +29 -0
  339. package/skills/dws/dingtalk-misc/references/agoal.md +302 -0
  340. package/skills/dws/dingtalk-misc/references/attendance-intent-guide.md +7 -0
  341. package/skills/dws/dingtalk-misc/references/attendance-report.md +109 -0
  342. package/skills/dws/dingtalk-misc/references/attendance-schedule.md +115 -0
  343. package/skills/dws/dingtalk-misc/references/attendance-vacation.md +129 -0
  344. package/skills/dws/dingtalk-misc/references/attendance.md +149 -0
  345. package/skills/dws/dingtalk-misc/references/dev/app.md +42 -0
  346. package/skills/dws/dingtalk-misc/references/dev/connect.md +108 -0
  347. package/skills/dws/dingtalk-misc/references/dev/credentials.md +26 -0
  348. package/skills/dws/dingtalk-misc/references/dev/dev-index.md +13 -0
  349. package/skills/dws/dingtalk-misc/references/dev/event.md +47 -0
  350. package/skills/dws/dingtalk-misc/references/dev/mcp.md +106 -0
  351. package/skills/dws/dingtalk-misc/references/dev/member.md +19 -0
  352. package/skills/dws/dingtalk-misc/references/dev/permission.md +56 -0
  353. package/skills/dws/dingtalk-misc/references/dev/recipes.md +58 -0
  354. package/skills/dws/dingtalk-misc/references/dev/robot.md +74 -0
  355. package/skills/dws/dingtalk-misc/references/dev/security.md +21 -0
  356. package/skills/dws/dingtalk-misc/references/dev/version.md +99 -0
  357. package/skills/dws/dingtalk-misc/references/dev/webapp.md +22 -0
  358. package/skills/dws/dingtalk-misc/references/devapp.md +148 -0
  359. package/skills/dws/dingtalk-misc/references/devdoc-intent-guide.md +7 -0
  360. package/skills/dws/dingtalk-misc/references/devdoc.md +42 -0
  361. package/skills/dws/dingtalk-misc/references/ding.md +184 -0
  362. package/skills/dws/dingtalk-misc/references/hrbrain.md +67 -0
  363. package/skills/dws/dingtalk-misc/references/live.md +23 -0
  364. package/skills/dws/dingtalk-misc/references/markdown.md +167 -0
  365. package/skills/dws/dingtalk-misc/references/oa/oa-form-components.md +345 -0
  366. package/skills/dws/dingtalk-misc/references/oa/oa-process-nodes.md +374 -0
  367. package/skills/dws/dingtalk-misc/references/oa.md +1014 -0
  368. package/skills/dws/dingtalk-misc/references/openapi-explorer.md +47 -0
  369. package/skills/dws/dingtalk-misc/references/pat.md +52 -0
  370. package/skills/dws/dingtalk-misc/references/profile.md +41 -0
  371. package/skills/dws/dingtalk-misc/references/recruit.md +89 -0
  372. package/skills/dws/dingtalk-misc/references/report-conventions.md +44 -0
  373. package/skills/dws/dingtalk-misc/references/report-intent-guide.md +11 -0
  374. package/skills/dws/dingtalk-misc/references/report-lite-recipes.md +25 -0
  375. package/skills/dws/dingtalk-misc/references/report.md +109 -0
  376. package/skills/dws/dingtalk-misc/references/sheet/sheet-batch-operations.md +104 -0
  377. package/skills/dws/dingtalk-misc/references/sheet/sheet-chart.md +69 -0
  378. package/skills/dws/dingtalk-misc/references/sheet/sheet-comment.md +166 -0
  379. package/skills/dws/dingtalk-misc/references/sheet/sheet-conditional-format.md +59 -0
  380. package/skills/dws/dingtalk-misc/references/sheet/sheet-dimension-operations.md +60 -0
  381. package/skills/dws/dingtalk-misc/references/sheet/sheet-dropdown.md +103 -0
  382. package/skills/dws/dingtalk-misc/references/sheet/sheet-export.md +135 -0
  383. package/skills/dws/dingtalk-misc/references/sheet/sheet-filter-view.md +51 -0
  384. package/skills/dws/dingtalk-misc/references/sheet/sheet-filter.md +181 -0
  385. package/skills/dws/dingtalk-misc/references/sheet/sheet-formula.md +192 -0
  386. package/skills/dws/dingtalk-misc/references/sheet/sheet-import.md +76 -0
  387. package/skills/dws/dingtalk-misc/references/sheet/sheet-media-image.md +263 -0
  388. package/skills/dws/dingtalk-misc/references/sheet/sheet-pivot-table.md +292 -0
  389. package/skills/dws/dingtalk-misc/references/sheet/sheet-range-operations.md +161 -0
  390. package/skills/dws/dingtalk-misc/references/sheet/sheet-read-data.md +60 -0
  391. package/skills/dws/dingtalk-misc/references/sheet/sheet-revision-changeset.md +46 -0
  392. package/skills/dws/dingtalk-misc/references/sheet/sheet-search-replace.md +120 -0
  393. package/skills/dws/dingtalk-misc/references/sheet/sheet-style-format.md +71 -0
  394. package/skills/dws/dingtalk-misc/references/sheet/sheet-version.md +84 -0
  395. package/skills/dws/dingtalk-misc/references/sheet/sheet-workbook.md +59 -0
  396. package/skills/dws/dingtalk-misc/references/sheet/sheet-write-data.md +93 -0
  397. package/skills/dws/dingtalk-misc/references/sheet-conventions.md +44 -0
  398. package/skills/dws/dingtalk-misc/references/sheet-intent-guide.md +12 -0
  399. package/skills/dws/dingtalk-misc/references/sheet.md +166 -0
  400. package/skills/dws/dingtalk-misc/references/skill.md +98 -0
  401. package/skills/dws/dingtalk-misc/references/unsupported-scripts.md +49 -0
  402. package/skills/dws/dingtalk-misc/references/url-patterns.md +144 -0
  403. package/skills/dws/dingtalk-misc/references/whiteboard/compose.md +200 -0
  404. package/skills/dws/dingtalk-misc/references/whiteboard/open-nodes-v1/01-overview.md +57 -0
  405. package/skills/dws/dingtalk-misc/references/whiteboard/open-nodes-v1/02-query.md +288 -0
  406. package/skills/dws/dingtalk-misc/references/whiteboard/open-nodes-v1/03-update.md +249 -0
  407. package/skills/dws/dingtalk-misc/references/whiteboard/open-nodes-v1/04-text-style.md +395 -0
  408. package/skills/dws/dingtalk-misc/references/whiteboard/open-nodes-v1/05-shape-frame-group-connector.md +186 -0
  409. package/skills/dws/dingtalk-misc/references/whiteboard/open-nodes-v1/06-vector-icon-path.md +313 -0
  410. package/skills/dws/dingtalk-misc/references/whiteboard/open-nodes-v1/07-examples-errors-write-support.md +270 -0
  411. package/skills/dws/dingtalk-misc/references/whiteboard/open-nodes-v1/08-catalogs.md +61 -0
  412. package/skills/dws/dingtalk-misc/references/whiteboard/open-nodes-v1.md +54 -0
  413. package/skills/dws/dingtalk-misc/references/whiteboard/replace.md +41 -0
  414. package/skills/dws/dingtalk-misc/references/whiteboard/vector.md +51 -0
  415. package/skills/dws/dingtalk-misc/references/whiteboard.md +115 -0
  416. package/skills/dws/dingtalk-misc/scripts/aiapp_create_and_poll.py +138 -0
  417. package/skills/dws/dingtalk-misc/scripts/attendance_my_record.py +91 -0
  418. package/skills/dws/dingtalk-misc/scripts/attendance_report_checkin.py +440 -0
  419. package/skills/dws/dingtalk-misc/scripts/attendance_report_common.py +1698 -0
  420. package/skills/dws/dingtalk-misc/scripts/attendance_report_daily.py +558 -0
  421. package/skills/dws/dingtalk-misc/scripts/attendance_report_detail.py +809 -0
  422. package/skills/dws/dingtalk-misc/scripts/attendance_report_monthly.py +758 -0
  423. package/skills/dws/dingtalk-misc/scripts/attendance_report_record.py +947 -0
  424. package/skills/dws/dingtalk-misc/scripts/attendance_schedule_export.py +344 -0
  425. package/skills/dws/dingtalk-misc/scripts/attendance_schedule_import.py +498 -0
  426. package/skills/dws/dingtalk-misc/scripts/attendance_team_shift.py +89 -0
  427. package/skills/dws/dingtalk-misc/scripts/attendance_vacation_balance.py +617 -0
  428. package/skills/dws/dingtalk-misc/scripts/finance_daily_cashflow.py +70 -0
  429. package/skills/dws/dingtalk-misc/scripts/finance_expense_flow.py +138 -0
  430. package/skills/dws/dingtalk-misc/scripts/oa_batch_approve.py +158 -0
  431. package/skills/dws/dingtalk-misc/scripts/oa_pending_review.py +132 -0
  432. package/skills/dws/dingtalk-misc/scripts/report_received_today.py +315 -0
  433. package/skills/dws/dingtalk-misc/scripts/yida_custom_page_update.py +401 -0
  434. package/skills/dws/dingtalk-misc/scripts/yida_form_builder.py +526 -0
  435. package/skills/dws/dingtalk-misc/scripts/yida_form_fields.py +315 -0
  436. package/skills/dws/dingtalk-misc/scripts/yida_form_inspector.py +311 -0
  437. package/skills/dws/dingtalk-misc/scripts/yida_form_update.py +185 -0
  438. package/skills/dws/dingtalk-misc/scripts/yida_jsx_pipeline.py +1588 -0
  439. package/skills/dws/dingtalk-misc/scripts/yida_page_compiler.py +1441 -0
  440. package/skills/dws/dingtalk-misc/scripts/yida_page_generate.py +514 -0
  441. package/skills/dws/dingtalk-misc/scripts/yida_page_schema.py +519 -0
  442. package/skills/dws/dingtalk-misc/scripts/yida_page_self_check.py +398 -0
  443. package/skills/dws/dingtalk-misc/scripts/yida_process_flow.py +1478 -0
  444. package/skills/dws/dingtalk-misc/scripts/yida_process_update.py +589 -0
  445. package/skills/dws/dingtalk-misc/scripts/yida_report_builder.py +494 -0
  446. package/skills/dws/dingtalk-misc/scripts/yida_report_charts.py +1434 -0
  447. package/skills/dws/dingtalk-misc/scripts/yida_report_update.py +244 -0
  448. package/skills/dws/dingtalk-misc/scripts/yida_schema_common.py +340 -0
  449. package/skills/dws/dingtalk-shared/SKILL.md +83 -0
  450. package/skills/dws/dingtalk-shared/references/capability-limits.md +31 -0
  451. package/skills/dws/dingtalk-shared/references/channel-login.md +26 -0
  452. package/skills/dws/dingtalk-shared/references/confirmation.md +59 -0
  453. package/skills/dws/dingtalk-shared/references/error-codes.md +155 -0
  454. package/skills/dws/dingtalk-shared/references/global-reference.md +122 -0
  455. package/skills/dws/dingtalk-shared/references/intent-guide.md +537 -0
  456. package/skills/dws/dingtalk-shared/references/recipes/conventions.md +45 -0
  457. package/skills/dws/dingtalk-shared/references/recipes/lite-catalog.md +275 -0
  458. package/skills/dws/dingtalk-shared/references/recipes/meta.md +67 -0
  459. package/skills/dws/dingtalk-shared/references/routing.md +52 -0
  460. package/skills/dws/dingtalk-shared/references/runtime-contract.md +11 -0
  461. package/skills/dws/dingtalk-shared/references/schema-usage.md +88 -0
  462. package/skills/dws/dingtalk-shared/references/url-patterns.md +144 -0
  463. package/skills/dws/dingtalk-shared/references/workflow-routing.md +45 -0
  464. package/skills/dws/dingtalk-todo/SKILL.md +119 -0
  465. package/skills/dws/dingtalk-todo/references/02-task.md +70 -0
  466. package/skills/dws/dingtalk-todo/references/intent-guide.md +15 -0
  467. package/skills/dws/dingtalk-todo/references/lite-recipes.md +68 -0
  468. package/skills/dws/dingtalk-todo/references/recipes/conventions.md +44 -0
  469. package/skills/dws/dingtalk-todo/references/todo.md +517 -0
  470. package/skills/dws/dingtalk-todo/scripts/todo_batch_create.py +354 -0
  471. package/skills/dws/dingtalk-todo/scripts/todo_daily_summary.py +153 -0
  472. package/skills/dws/dingtalk-todo/scripts/todo_overdue_check.py +101 -0
  473. package/skills/dws/dingtalk-wiki/SKILL.md +117 -0
  474. package/skills/dws/dingtalk-wiki/references/intent-guide.md +14 -0
  475. package/skills/dws/dingtalk-wiki/references/lite-recipes.md +44 -0
  476. package/skills/dws/dingtalk-wiki/references/wiki-members.md +32 -0
  477. package/skills/dws/dingtalk-wiki/references/wiki-node-ops.md +46 -0
  478. package/skills/dws/dingtalk-wiki/references/wiki.md +65 -0
  479. package/skills/dws/provenance.json +27 -0
@@ -0,0 +1,2736 @@
1
+ #!/usr/bin/env python3
2
+ """CRWU 审核意见交付工具:AuditResult 校验 + 单文件 HTML 渲染。
3
+
4
+ 实现《CRWU 审核意见 HTML 送达规范 v1.6》
5
+ (正文:本技能 references/11-html-delivery-spec.md):
6
+
7
+ - AuditResult JSON 是唯一事实源;HTML 仅如实呈现,不新增/删除/合并/改写任何结论;
8
+ - 校验覆盖 §9.4 关键校验规则 + §11.1 门禁 + §12.2 维护红线(敏感信息扫描);
9
+ - 渲染输出自包含单文件 HTML(内嵌 CSS、无外链资源、A4 可打印、专业审核轨迹默认折叠)。
10
+
11
+ 用法:
12
+ python3 scripts/audit_delivery.py validate <audit-result.json> [--rendered]
13
+ python3 scripts/audit_delivery.py render <audit-result.json> --out <opinion.html> --json-out <result.json>
14
+ 退出码:0 成功;1 校验失败(错误打印到 stderr)。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import hashlib
21
+ import html
22
+ import json
23
+ import re
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ RENDERER_VERSION = "renderer/1.2.6"
28
+ TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "template" / "audit-report.html"
29
+ SCHEMA_VERSION_PREFIX = "1."
30
+
31
+ SEVERITY_LABEL = {"high": "高", "medium": "中", "low": "低"}
32
+ SEVERITY_CLASS = {"high": "sev-high", "medium": "sev-medium", "low": "sev-low"}
33
+ ISSUE_TYPE_LABEL = {
34
+ "rule_defect": "规则性缺陷",
35
+ "reasonableness_doubt": "合理性存疑",
36
+ "unsupported_pending": "无依据待判",
37
+ }
38
+ DECISION_LABEL = {"fail": "不符合", "pending_confirmation": "需人工确认", "pass": "符合"}
39
+ MODULE_LABEL = {
40
+ "dataCheck": "数据勾稽",
41
+ "marketApproach": "市场法",
42
+ "incomeApproach": "收益法",
43
+ "costApproach": "成本法",
44
+ "assetBasis": "资产基础法",
45
+ "reportDisclosure": "报告披露",
46
+ "generalStandards": "通用准则与程序",
47
+ "externalData": "外部数据核验",
48
+ }
49
+ OVERALL_LABEL = {"fail": "存在需要处理的问题", "pending_confirmation": "有待人工确认事项", "pass": "本次未发现需要处理的问题"}
50
+ AUTHORITY_LABEL = {
51
+ "external_formal": "外部正式依据",
52
+ "internal_auxiliary": "内部辅助规则",
53
+ "experience_reference": "历史风险经验",
54
+ }
55
+ SOURCE_TYPE_LABEL = {
56
+ "law": "法律法规",
57
+ "valuation_standard": "资产评估准则",
58
+ "valuation_method_rule": "评估方法规则",
59
+ "regulatory": "监管规则",
60
+ "association": "行业协会要求",
61
+ "internal_audit": "内部审核规则",
62
+ "crwu_kb": "CRWU 知识库规则",
63
+ "risk_case": "历史风险案例",
64
+ }
65
+ FLOW_LABEL = {"pending_response": "待答复", "answered": "已答复", "reviewed": "已复核"}
66
+ REVIEW_CATEGORY_LABEL = {
67
+ "A": "AI 与人工复核均发现",
68
+ "B": "仅 AI 发现",
69
+ "C": "AI 与复核均涉及但结论或范围不同",
70
+ }
71
+ REVIEWER_ONLY_LABEL = "仅人工复核发现"
72
+ REVIEW_MATCH_VALUES = {"exact", "partial", "miss"}
73
+ # AI 对照说明(受控常量):逐条讲清「AI 命中与否」与「是否计入命中率分母」的因果,
74
+ # 特别是「AI 未命中 + 复核件标注已修改」时必须核验是否确已修改,并给出两种分支的后果。
75
+ AI_COMPARISON_NOTE = {
76
+ ("miss", "L-resolved"):
77
+ "AI 未命中|复核件标注该项「已修改」:已按在件核验确认该项确已修改(复核事项在被审件中已不存在)→ 不计入命中率分母;"
78
+ "若核验发现并未修改,则须计入分母并计为 AI 漏检",
79
+ ("miss", "L-open"):
80
+ "AI 未命中|已在件核验:该项并未修改 → 计入命中率分母,计为 AI 漏检",
81
+ ("miss", "L-unclosed"):
82
+ "AI 未命中|答复称已改但被审件未落地:计入命中率分母并计为漏检,优先回客户",
83
+ ("miss", "L-uncheckable"):
84
+ "AI 未命中|材料缺失或不可读·未能核验:不计入命中率分母,登记为未检查项(未核≠未命中)",
85
+ ("exact", "L-resolved"):
86
+ "AI 精确命中|复核意见在被审件中已落实:不计入命中率分母(作人工工作成果呈现)",
87
+ ("partial", "L-resolved"):
88
+ "AI 部分命中|复核意见在被审件中已落实:不计入命中率分母(作人工工作成果呈现)",
89
+ ("exact", "L-open"):
90
+ "AI 精确命中|被审件未落实:计入命中率分母",
91
+ ("partial", "L-open"):
92
+ "AI 部分命中|被审件未落实:计入命中率分母",
93
+ ("exact", "L-unclosed"):
94
+ "AI 精确命中|答复称已改但被审件未落地:计入命中率分母,优先回客户",
95
+ ("partial", "L-unclosed"):
96
+ "AI 部分命中|答复称已改但被审件未落地:计入命中率分母,优先回客户",
97
+ ("exact", "L-uncheckable"):
98
+ "AI 精确命中|材料缺失或不可读·未能核验:不计入命中率分母,登记为未检查项",
99
+ ("partial", "L-uncheckable"):
100
+ "AI 部分命中|材料缺失或不可读·未能核验:不计入命中率分母,登记为未检查项",
101
+ }
102
+ AI_COMPARISON_NOTE_FALLBACK = "AI 对照状态未完整标注(matchStatus / inFileResolution 缺失)"
103
+
104
+ HIT_LEVEL_LABEL = [
105
+ ("exact", "精确命中"),
106
+ ("partial", "部分命中"),
107
+ ("miss", "未命中"),
108
+ ]
109
+ REVIEW_MATCH_LABEL = {
110
+ "exact": "AI 精确命中",
111
+ "partial": "AI 部分命中",
112
+ "miss": "AI 未命中",
113
+ }
114
+ REASON_LABEL = {
115
+ "missing_material": "缺材料",
116
+ "unreadable": "不可读",
117
+ "unsupported": "能力不支持",
118
+ "out_of_scope": "超出本次范围",
119
+ "not_applicable": "不适用",
120
+ }
121
+ RESULT_LABEL = {"pass": "符合", "difference": "差异", "not_applicable": "不适用", "not_checked": "未检查"}
122
+ CONFIDENCE_LABEL = {"high": "高", "medium": "中", "low": "低"}
123
+ REVIEW_STATUS_LABEL = {"not_performed": "未执行", "performed": "已执行"}
124
+
125
+ EMPTY_TEXT = "本次无此类事项"
126
+ EXT_DATA_EMPTY_TEXT = "本次未提供外部数据核验适用性判断"
127
+ EXT_DATA_NO_CHECK_TEXT = "本次无可核验的外部数据项"
128
+ EXT_DATA_NOT_APPLICABLE_TEXT = "本报告暂不涉及外部数据核验"
129
+ EXT_DATA_APPLICABILITY_LABEL = {"required": "需要外部数据核验", "not_applicable": "不适用"}
130
+ EXT_DATA_DECISIONS = ("符合", "不符合", "请说明", "未检查")
131
+ EXT_DATA_UNAVAILABLE_TEXT = "不可用(未经外部数据核验)"
132
+ EXT_DATA_NOT_FETCHED_TEXT = "未取数"
133
+ EXT_DATA_NO_DEVIATION_TEXT = "无出入(符合)"
134
+ EXT_DATA_UNSPECIFIED_SOURCE_TEXT = "未列明来源"
135
+ SUMMARY_DIGEST_ITEM_LIMIT = 3
136
+
137
+ HEX64 = re.compile(r"^[0-9a-f]{64}$")
138
+ ABSOLUTE_PATH_PATTERNS = [
139
+ (re.compile(r"(?:^|[^A-Za-z0-9])/(?:Users|home|var|tmp|private|Volumes|opt)/"), "绝对路径"),
140
+ (re.compile(r"\b[A-Za-z]:[\\/]"), "Windows 绝对路径"),
141
+ (re.compile(r"file://"), "file:// 地址"),
142
+ ]
143
+ FORBIDDEN_KEY_PATTERN = re.compile(r"(?i)(token|api[_-]?key|cookie|password|passwd|secret|connection[_-]?string|nodeId)")
144
+
145
+ # ---- §4.6 问题描述写法:员工可读性硬校验(首句 + 明细两段式) ----
146
+ PROBLEM_HEADLINE_MAX = 60
147
+ PROBLEM_DETAIL_MAX_LINES = 4
148
+ PROBLEM_DETAIL_MIN_LINES = 2
149
+ PROBLEM_DETAIL_LINE_MAX = 80
150
+ PROBLEM_TEXT_MAX = 420
151
+ PROBLEM_HEADLINE_FORBIDDEN_LABEL = "规则编号/内部代号"
152
+ PROBLEM_SENTENCE_TERMINATORS = "。;!?"
153
+ PROBLEM_LOCATOR_ANCHOR_PATTERN = re.compile(
154
+ r"(?<!\d)(?:\.[A-Za-z]{2,5}(?![A-Za-z0-9])"
155
+ r"|(?<![A-Za-z0-9])[A-Za-z]{1,3}\d{1,4}(?::[A-Za-z]{1,3}\d{1,4})?"
156
+ r"|!\s*\S)"
157
+ r"|第\s*\d+\s*[页条项]"
158
+ r"|「|」"
159
+ )
160
+ PROBLEM_HEADLINE_FORBIDDEN_PATTERNS = [
161
+ (re.compile(r"\b(?:RULE|CHK|KB|ISS)-[A-Za-z0-9][A-Za-z0-9._-]*"), PROBLEM_HEADLINE_FORBIDDEN_LABEL),
162
+ (re.compile(r"kb[_-]?id", re.IGNORECASE), PROBLEM_HEADLINE_FORBIDDEN_LABEL),
163
+ # 知识库相对路径形如 `06-规则库/…`:`/` 后必须是中文路径段,
164
+ # 否则区间值(0-50/51-100)与页码(L206-L302)会被误判(真实项目回测修正)。
165
+ (re.compile(r"\d{2}-[\u4e00-\u9fff][^\s,。;、]{0,}/"), "知识库相对路径"),
166
+ ]
167
+ PROBLEM_DETAIL_FORBIDDEN = PROBLEM_HEADLINE_FORBIDDEN_PATTERNS[:2]
168
+ # 首句是员工唯一的“一句话结论”,不得出现公式、区域坐标、文件!表 定位串或案例目录内相对路径;
169
+ # 现象要用普通话描述,精确坐标放明细行。口径源自真实项目回测(ISS-DC-001 等 20 条坐标串堆叠)。
170
+ PROBLEM_HEADLINE_TECHNICAL_PATTERN = re.compile(
171
+ r"=|\b(?:SUM|AVERAGE|IF|VLOOKUP)\("
172
+ r"|[A-Za-z]{1,3}\d{1,4}:[A-Za-z]{1,3}\d{1,4}"
173
+ r"|[^\s,。;]{2,}\.(?:xlsx|xls|docx|doc|pdf)!"
174
+ r"|(?:工作版|提取|材料-源|raw)/"
175
+ )
176
+ PROBLEM_TEXT_FORBIDDEN_PATTERNS = [
177
+ (re.compile(r"\d{2}-[\u4e00-\u9fff][^\s,。;、]{0,}/"), "知识库相对路径"),
178
+ (re.compile(r"(?i)\bnodeId\b"), "nodeId"),
179
+ (re.compile(r"file://"), "file:// 地址"),
180
+ (re.compile(r"(?:^|[^A-Za-z0-9])/(?:Users|home|var|tmp|private|Volumes|opt)/"), "绝对路径"),
181
+ (re.compile(r"\b[A-Za-z]:[\\/]"), "Windows 绝对路径"),
182
+ ]
183
+
184
+ REQUIRED_TOP_LEVEL = [
185
+ "schemaVersion",
186
+ "auditTask",
187
+ "phaseControl",
188
+ "summary",
189
+ "issues",
190
+ "manualConfirmationItems",
191
+ "auditBasis",
192
+ "reviewComparison",
193
+ "scope",
194
+ "professionalTrail",
195
+ "fileTrace",
196
+ "renderPolicy",
197
+ ]
198
+
199
+ ISSUE_REQUIRED = [
200
+ "issueId",
201
+ "title",
202
+ "module",
203
+ "locationSummary",
204
+ "severity",
205
+ "issueType",
206
+ "decision",
207
+ "problemDescription",
208
+ "handlingRequirement",
209
+ "ruleEvidence",
210
+ "materialEvidence",
211
+ "gapAnalysis",
212
+ "recommendedEdits",
213
+ "flowStatus",
214
+ "confidence",
215
+ "reviewComparison",
216
+ ]
217
+
218
+ RULE_REQUIRED = [
219
+ "ruleId",
220
+ "authorityClass",
221
+ "sourceType",
222
+ "title",
223
+ "version",
224
+ "clause",
225
+ "quote",
226
+ "kbRelativePath",
227
+ "exportedAt",
228
+ "usageCount",
229
+ "purposes",
230
+ "usedByIssueIds",
231
+ ]
232
+
233
+ MATERIAL_EVIDENCE_REQUIRED = ["fileId", "displayName", "locator", "excerptOrValue", "evidenceRole"]
234
+ NOT_CHECKED_REQUIRED = ["itemId", "item", "reasonCode", "reason", "impact", "requiredAction", "relatedFiles"]
235
+ MANUAL_ITEM_REQUIRED = [
236
+ "itemId",
237
+ "title",
238
+ "locationSummary",
239
+ "observation",
240
+ "questionToConfirm",
241
+ "evidence",
242
+ "suggestedAction",
243
+ ]
244
+ CHECK_RECORD_REQUIRED = [
245
+ "recordId",
246
+ "domain",
247
+ "checkItem",
248
+ "targets",
249
+ "evidence",
250
+ "result",
251
+ "linkedIssueIds",
252
+ "performedAt",
253
+ "executor",
254
+ ]
255
+ REVIEWER_ONLY_REQUIRED = ["itemId", "title", "reviewerEvidence", "handling", "inFileResolution"]
256
+ # 能力边界条目:AI 当前不具备该层能力(如底稿审核),不计分母/不计漏检,但必须登记备查并渲染
257
+ OUT_OF_SCOPE_REQUIRED = ["itemId", "title", "reviewerEvidence", "handling", "exclusionReason"]
258
+ # 工具能力声明(受控常量,非业务结论):必须在每份交付件中显式出现
259
+ CAPABILITY_WORKPAPER_NOTICE = (
260
+ "本工具当前暂不支持底稿文件审核:底稿类复核意见不计入 AI 命中率,仅登记备查并交人工底稿审核。"
261
+ )
262
+ # ---- AI 审核评分卡(自评;量化工种差距。维度与算法为技能内受控取值,知识库暂无对应词表) ----
263
+ SCORECARD_DIMENSIONS = [
264
+ ("first_delivery_correctness", "首轮交付正确性"),
265
+ ("self_correction", "自我纠错与定位"),
266
+ ("incremental_value", "增量发现价值"),
267
+ ("coverage_completeness", "覆盖完整性"),
268
+ ("process_discipline", "过程纪律与可追溯"),
269
+ ("judgment_quality", "本轮判断质量"),
270
+ ]
271
+ SCORECARD_DIMENSION_KEYS = [key for key, _label in SCORECARD_DIMENSIONS]
272
+ SCORECARD_LEVELS = ("初审", "复审", "终审")
273
+ SCORECARD_ERROR_KINDS = {"false_positive", "correction", "omission", "wording"}
274
+ SCORECARD_ERROR_KIND_LABEL = {
275
+ "false_positive": "假阳性(原判不成立)",
276
+ "correction": "事实更正(结论成立但表述有误)",
277
+ "omission": "漏检(人工复核已提出而 AI 未命中)",
278
+ "wording": "表述修正",
279
+ }
280
+
281
+
282
+ def _round_half_up(value: float) -> float:
283
+ """按 0.5 取整(四舍五入,非银行家舍入)。"""
284
+ import math
285
+ return math.floor(value * 2 + 0.5) / 2.0
286
+
287
+
288
+ def _mean_score(dimensions: list) -> float:
289
+ scores = [float(d.get("score")) for d in dimensions]
290
+ return _round_half_up(sum(scores) / len(scores))
291
+ IN_FILE_RESOLUTION_VALUES = {"L-resolved", "L-open", "L-unclosed", "L-uncheckable"}
292
+ IN_FILE_RESOLUTION_LABEL = {
293
+ "L-resolved": "复核已提出 · 被审件已落实",
294
+ "L-open": "复核已提出 · 被审件未落实",
295
+ "L-unclosed": "复核答复称已改 · 被审件未落地",
296
+ "L-uncheckable": "材料缺失或不可读 · 未能核验",
297
+ }
298
+ # 受控占位标签:无在件位置时不得留空白单元格(送达规范 §10.3)
299
+ IN_FILE_ABSENT_LABEL = "未做在件核验(材料不可及或超出本次范围)"
300
+
301
+
302
+ def _percentage(numerator: int, denominator: int):
303
+ return round(numerator * 100.0 / denominator, 1) if denominator else None
304
+
305
+
306
+ def _review_metrics(items: list) -> dict:
307
+ """从逐条复核事实重算客观指标;已落实与无法核验不进入命中率分母。
308
+
309
+ 口径(2026-09-16 修订):**部分命中是命中,只是层次较低** ——
310
+ 命中率 = (exact + partial) / evaluable;exact/partial/miss 作为**命中层次**单列。
311
+ 不得用 exact/evaluable 冒充“命中率”(那等于把部分命中当未命中)。
312
+ `strictHitRate` / `coverageRate` 仅为历史 JSON 兼容别名,展示名分别降为
313
+ 「精确命中率」与「命中率」,不得再作为独立主指标。
314
+ """
315
+ resolved = [item for item in items if item.get("inFileResolution") == "L-resolved"]
316
+ uncheckable = [item for item in items if item.get("inFileResolution") == "L-uncheckable"]
317
+ evaluable_items = [
318
+ item for item in items
319
+ if item.get("inFileResolution") not in ("L-resolved", "L-uncheckable")
320
+ ]
321
+ exact = sum(item.get("matchStatus") == "exact" for item in evaluable_items)
322
+ partial = sum(item.get("matchStatus") == "partial" for item in evaluable_items)
323
+ misses = sum(item.get("matchStatus") == "miss" for item in evaluable_items)
324
+ denominator = len(evaluable_items)
325
+ hit_rate = _percentage(exact + partial, denominator)
326
+ exact_rate = _percentage(exact, denominator)
327
+ return {
328
+ "total": len(items),
329
+ "resolved": len(resolved),
330
+ "uncheckable": len(uncheckable),
331
+ "evaluable": denominator,
332
+ "exactHits": exact,
333
+ "partialHits": partial,
334
+ "misses": misses,
335
+ "hitRate": hit_rate,
336
+ "exactRate": exact_rate,
337
+ "partialRate": _percentage(partial, denominator),
338
+ "missRate": _percentage(misses, denominator),
339
+ "strictHitRate": exact_rate,
340
+ "coverageRate": hit_rate,
341
+ }
342
+
343
+
344
+ def _header_hit_rate_caliber(comparison) -> str:
345
+ """首屏命中率口径表(精确数字,按全部复核级次汇总)。
346
+
347
+ 口径:分母 = 员工复核意见中**未修改(未落实)**的条目;分子 = **AI 命中**的条目
348
+ (精确命中 + 部分命中)。已剔除项、未命中项与各复核级次条数一并列出,
349
+ 使读者可直接复核分母构成,不会误以为只统计了某一级复核。
350
+ """
351
+ items = comparison.get("reviewItems") or []
352
+ if not items:
353
+ return ""
354
+ metrics = _review_metrics(items)
355
+ out_of_scope = comparison.get("outOfScopeItems") or []
356
+ levels = []
357
+ for entry in sorted(comparison.get("reviewFiles") or [], key=lambda e: e.get("order", 9999)):
358
+ level = entry.get("level")
359
+ if _is_nonempty_str(level) and level not in levels:
360
+ levels.append(level)
361
+ rows = []
362
+ for level in levels:
363
+ count = sum(1 for i in items if i.get("reviewLevel") == level)
364
+ if not count:
365
+ continue # 该级次条目已整体移入能力边界登记备查(如底稿层),不在此表列 0
366
+ rows.append(("复核意见(按级次)", level, count))
367
+ rows.extend([
368
+ ("复核意见(按级次)", "合计", metrics["total"]),
369
+ ("命中率分母", "员工复核中未修改(未落实)", metrics["evaluable"]),
370
+ ("命中率分子", "AI 命中 · 精确命中", metrics["exactHits"]),
371
+ ("命中率分子", "AI 命中 · 部分命中", metrics["partialHits"]),
372
+ ("AI 未命中", "计入分母并计为漏检", metrics["misses"]),
373
+ ("已剔除 · 不计入分母", "已在件落实(含已修改故 AI 不可能命中)", metrics["resolved"]),
374
+ ("已剔除 · 不计入分母", "材料缺失或不可读(未能核验)", metrics["uncheckable"]),
375
+ ("已剔除 · 不计入分母", "能力边界(底稿类,仅登记备查)", len(out_of_scope)),
376
+ ])
377
+ numerator = metrics["exactHits"] + metrics["partialHits"]
378
+ rows.extend([
379
+ ("结果", "分子 ÷ 分母 = 命中率", "{0} ÷ {1} = {2}".format(
380
+ numerator, metrics["evaluable"], _format_rate(metrics["hitRate"]))),
381
+ ("结果(层次)", "精确命中率(精确命中 ÷ 分母)", "{0} ÷ {1} = {2}".format(
382
+ metrics["exactHits"], metrics["evaluable"], _format_rate(metrics["exactRate"]))),
383
+ ])
384
+ parts = ['<h4>{0}</h4>'.format(_text("命中率口径(精确数字 · 按全部复核级次汇总)")),
385
+ '<p class="formula">{0}</p>'.format(_text(
386
+ "分母=员工复核意见中未修改(未落实)的条目;分子=AI 命中的条目(精确命中+部分命中)。"
387
+ "已在件落实、材料缺失或不可读、超出能力层的条目不进入分母。")),
388
+ '<div class="table-scroll"><table><thead><tr>' + "".join(
389
+ '<th scope="col">{0}</th>'.format(_text(label)) for label in ("口径", "分组", "条数 / 结果")
390
+ ) + "</tr></thead><tbody>"]
391
+ for row in rows:
392
+ parts.append("<tr>" + "".join("<td>{0}</td>".format(_text(cell)) for cell in row) + "</tr>")
393
+ parts.append("</tbody></table></div>")
394
+ return "".join(parts)
395
+
396
+
397
+ def _hit_level_row(metrics: dict) -> str:
398
+ """命中层次:精确/部分/未命中三档条数与占比;部分命中是命中,只标层次。"""
399
+ denominator = metrics.get("evaluable") or 0
400
+ if not denominator:
401
+ return ""
402
+ cells = []
403
+ for key, label in HIT_LEVEL_LABEL:
404
+ count = {"exact": metrics.get("exactHits"), "partial": metrics.get("partialHits"),
405
+ "miss": metrics.get("misses")}[key]
406
+ rate = {"exact": metrics.get("exactRate"), "partial": metrics.get("partialRate"),
407
+ "miss": metrics.get("missRate")}[key]
408
+ cells.append('<span>{0}:{1} / {2}({3})</span>'.format(
409
+ _text(label), _text(count), _text(denominator), _text(_format_rate(rate))))
410
+ return '<p class="formula hit-levels">{0}{1}</p>'.format(_text("命中层次:"), "".join(cells))
411
+
412
+
413
+ def _format_rate(value) -> str:
414
+ return "不适用" if value is None else "{0:.1f}%".format(float(value))
415
+
416
+
417
+ # --------------------------------------------------------------------------- #
418
+ # 基础工具
419
+ # --------------------------------------------------------------------------- #
420
+ def canonical_json(value) -> str:
421
+ """确定性序列化:字段排序、UTF-8、无多余空白(摘要与嵌入均基于它)。"""
422
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
423
+
424
+
425
+ def sha256_hex(text: str) -> str:
426
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
427
+
428
+
429
+ def load_result(path: Path) -> dict:
430
+ with path.open("r", encoding="utf-8") as handle:
431
+ data = json.load(handle)
432
+ if not isinstance(data, dict):
433
+ raise ValueError("AuditResult 顶层必须是 JSON 对象")
434
+ return data
435
+
436
+
437
+ def iter_all_scalars(value):
438
+ if isinstance(value, dict):
439
+ for key, item in value.items():
440
+ yield str(key)
441
+ for scalar in iter_all_scalars(item):
442
+ yield scalar
443
+ elif isinstance(value, list):
444
+ for item in value:
445
+ for scalar in iter_all_scalars(item):
446
+ yield scalar
447
+ elif value is None:
448
+ return
449
+ else:
450
+ yield value if isinstance(value, str) else str(value)
451
+
452
+
453
+ def iter_keys(value):
454
+ if isinstance(value, dict):
455
+ for key, item in value.items():
456
+ yield str(key)
457
+ for nested in iter_keys(item):
458
+ yield nested
459
+ elif isinstance(value, list):
460
+ for item in value:
461
+ for nested in iter_keys(item):
462
+ yield nested
463
+
464
+
465
+ def scan_sensitive(result: dict):
466
+ """§10.1 / §12.2:绝对路径、file://、nodeId、凭据类字段一律不得进入交付物。"""
467
+ errors = []
468
+ for key in iter_keys(result):
469
+ match = FORBIDDEN_KEY_PATTERN.search(key)
470
+ if match:
471
+ errors.append("禁止字段名 {0}(交付物不得含 nodeId 或任何凭据类字段)".format(key))
472
+ for text in iter_all_scalars(result):
473
+ for pattern, label in ABSOLUTE_PATH_PATTERNS:
474
+ if pattern.search(text):
475
+ errors.append("禁止出现{0}:{1}".format(label, text[:80]))
476
+ return errors
477
+
478
+
479
+ # --------------------------------------------------------------------------- #
480
+ # 校验(§9.4 / §11.1)
481
+ # --------------------------------------------------------------------------- #
482
+ def _is_nonempty_str(value) -> bool:
483
+ return isinstance(value, str) and value.strip() != ""
484
+
485
+
486
+ def _check_relative_path(value: str) -> bool:
487
+ if not _is_nonempty_str(value):
488
+ return False
489
+ if value.startswith("/") or value.startswith("~") or value.startswith("file://"):
490
+ return False
491
+ if re.match(r"^[A-Za-z]:", value):
492
+ return False
493
+ return True
494
+
495
+
496
+ def _parse_time(value: str):
497
+ try:
498
+ return __import__("datetime").datetime.fromisoformat(value)
499
+ except ValueError:
500
+ return None
501
+
502
+
503
+ def split_problem_description(value) -> tuple:
504
+ """按 §4.6 把 problemDescription 拆为(首句, [明细行])。
505
+
506
+ renderer 只做分段与呈现,不改写任何业务句子。校验器与渲染器共用同一解析口径。
507
+ """
508
+ if not isinstance(value, str):
509
+ return "", []
510
+ blocks = [block.strip() for block in value.split("\n") if block.strip()]
511
+ if not blocks:
512
+ return "", []
513
+ return blocks[0], blocks[1:]
514
+
515
+
516
+ def _issue_location_anchors(issue: dict) -> list:
517
+ """收集本条问题真实存在的材料落点:材料证据文件名 + 建议修改文件名。"""
518
+ anchors = []
519
+ for bucket in ("materialEvidence", "recommendedEdits"):
520
+ items = issue.get(bucket)
521
+ if not isinstance(items, list):
522
+ continue
523
+ for item in items:
524
+ if isinstance(item, dict) and _is_nonempty_str(item.get("displayName")):
525
+ anchors.append(str(item["displayName"]).strip())
526
+ return anchors
527
+
528
+
529
+ def validate_problem_description(where: str, issue: dict) -> list:
530
+ """§4.6 问题描述写法硬校验:首句 + 明细两段式,员工可直接核对。
531
+
532
+ 返回以 where 开头的错误字符串列表;空列表表示通过。
533
+ """
534
+ errors = []
535
+ raw = issue.get("problemDescription")
536
+ if not _is_nonempty_str(raw):
537
+ # 空值由字段必填校验统一报错,这里不重复报
538
+ return errors
539
+ text = str(raw)
540
+ headline, details = split_problem_description(text)
541
+ if not headline:
542
+ errors.append("{0}.problemDescription 缺少首句(须先一句话说清问题是什么)".format(where))
543
+ return errors
544
+ if len(headline) > PROBLEM_HEADLINE_MAX:
545
+ errors.append(
546
+ "{0}.problemDescription 首句 {1} 字,超过 {2} 字上限(首句只写问题是什么,其余内容移到明细行)".format(
547
+ where, len(headline), PROBLEM_HEADLINE_MAX
548
+ )
549
+ )
550
+ if headline[-1] not in PROBLEM_SENTENCE_TERMINATORS:
551
+ errors.append(
552
+ "{0}.problemDescription 首句未以句读收尾(须以 。;!? 之一结束)".format(where)
553
+ )
554
+ if PROBLEM_HEADLINE_TECHNICAL_PATTERN.search(headline):
555
+ errors.append(
556
+ "{0}.problemDescription 首句含公式/单元格坐标/文件定位串,员工读不懂:"
557
+ "首句用普通话写清现象,精确坐标移到明细行".format(where)
558
+ )
559
+ if len(details) < PROBLEM_DETAIL_MIN_LINES:
560
+ errors.append(
561
+ "{0}.problemDescription 必须是两段式:明细 {1} 行,少于 {2} 行下限".format(
562
+ where, len(details), PROBLEM_DETAIL_MIN_LINES
563
+ )
564
+ )
565
+ if len(details) > PROBLEM_DETAIL_MAX_LINES:
566
+ errors.append(
567
+ "{0}.problemDescription 明细 {1} 行,超过 {2} 行上限".format(
568
+ where, len(details), PROBLEM_DETAIL_MAX_LINES
569
+ )
570
+ )
571
+ for position, detail in enumerate(details, start=2):
572
+ if len(detail) > PROBLEM_DETAIL_LINE_MAX:
573
+ errors.append(
574
+ "{0}.problemDescription 明细第 {1} 行 {2} 字,超过 {3} 字上限(请拆成短句)".format(
575
+ where, position - 1, len(detail), PROBLEM_DETAIL_LINE_MAX
576
+ )
577
+ )
578
+ if detail[-1] in PROBLEM_SENTENCE_TERMINATORS:
579
+ continue
580
+ if PROBLEM_LOCATOR_ANCHOR_PATTERN.search(detail):
581
+ # 以文件名/单元格/页码等落点收尾的明细行本身就是可核对指令,不强行加句号
582
+ continue
583
+ errors.append(
584
+ "{0}.problemDescription 明细第 {1} 行既未以句读收尾(。;!?),"
585
+ "也未以文件、sheet、单元格或页码等可核对落点收尾".format(where, position - 1)
586
+ )
587
+ if len(text) > PROBLEM_TEXT_MAX:
588
+ errors.append(
589
+ "{0}.problemDescription 共 {1} 字,超过 {2} 字上限(规则要求与判定链放 gapAnalysis)".format(
590
+ where, len(text), PROBLEM_TEXT_MAX
591
+ )
592
+ )
593
+ for pattern, label in PROBLEM_HEADLINE_FORBIDDEN_PATTERNS:
594
+ if pattern.search(headline):
595
+ errors.append(
596
+ "{0}.problemDescription 首句含{1},员工看不懂:请改为具体现象,编号与路径放规则依据".format(where, label)
597
+ )
598
+ for pattern, label in PROBLEM_DETAIL_FORBIDDEN:
599
+ if pattern.search(" ".join(details)):
600
+ errors.append(
601
+ "{0}.problemDescription 明细含{1},请移除(规则出处放「展开判断依据与规则」)".format(where, label)
602
+ )
603
+ for pattern, label in PROBLEM_TEXT_FORBIDDEN_PATTERNS:
604
+ if pattern.search(text):
605
+ errors.append("{0}.problemDescription 含{1},禁止出现在员工交付内容中".format(where, label))
606
+
607
+ anchors = _issue_location_anchors(issue)
608
+ detail_text = " ".join(details)
609
+ has_anchor = any(anchor and anchor in detail_text for anchor in anchors) or bool(
610
+ PROBLEM_LOCATOR_ANCHOR_PATTERN.search(detail_text)
611
+ )
612
+ if not has_anchor:
613
+ errors.append(
614
+ "{0}.problemDescription 明细未给出可核对的文件与位置:"
615
+ "请写明本次材料中的具体文件名(如 报告.docx、评估说明.xlsx),以及 sheet 名、"
616
+ "单元格/区域、第 N 页或章节名".format(where)
617
+ )
618
+ return errors
619
+
620
+
621
+ def validate(result: dict, rendered: bool = False, expect_renderer: bool = False):
622
+ errors = []
623
+ for key in REQUIRED_TOP_LEVEL:
624
+ if key not in result:
625
+ errors.append("缺少顶层字段 {0}".format(key))
626
+ if errors:
627
+ return errors
628
+
629
+ schema_version = result.get("schemaVersion")
630
+ if not _is_nonempty_str(schema_version) or not str(schema_version).startswith(SCHEMA_VERSION_PREFIX):
631
+ errors.append("schemaVersion 必须是 {0}x 版本".format(SCHEMA_VERSION_PREFIX))
632
+
633
+ # auditTask
634
+ audit_task = result.get("auditTask") or {}
635
+ for field in ("projectId", "reportVersion", "auditTime", "engineVersion"):
636
+ if not _is_nonempty_str(audit_task.get(field)):
637
+ errors.append("auditTask.{0} 不得为空".format(field))
638
+
639
+ # phaseControl(§11 两阶段门禁)
640
+ phase = result.get("phaseControl") or {}
641
+ frozen_at = phase.get("phase1FrozenAt")
642
+ digest = phase.get("phase1Digest")
643
+ accessed_at = phase.get("reviewAccessedAt")
644
+ phase2_at = phase.get("phase2CompletedAt")
645
+ if not _is_nonempty_str(frozen_at):
646
+ errors.append("phaseControl.phase1FrozenAt 不得为空")
647
+ if not _is_nonempty_str(digest) or not HEX64.match(str(digest)):
648
+ errors.append("phaseControl.phase1Digest 必须是 sha256 十六进制摘要")
649
+ if _is_nonempty_str(accessed_at):
650
+ if not _is_nonempty_str(frozen_at):
651
+ errors.append("存在 reviewAccessedAt 时必须先记录 phase1FrozenAt")
652
+ else:
653
+ frozen_dt, accessed_dt = _parse_time(str(frozen_at)), _parse_time(str(accessed_at))
654
+ if frozen_dt is None or accessed_dt is None:
655
+ errors.append("phaseControl 时间必须为 ISO-8601 字符串")
656
+ elif accessed_dt <= frozen_dt:
657
+ errors.append("reviewAccessedAt 必须晚于 phase1FrozenAt(阶段一必须先冻结)")
658
+ if _is_nonempty_str(phase2_at):
659
+ if not _is_nonempty_str(accessed_at):
660
+ errors.append("phase2CompletedAt 非空时必须记录 reviewAccessedAt")
661
+ else:
662
+ accessed_dt, phase2_dt = _parse_time(str(accessed_at)), _parse_time(str(phase2_at))
663
+ if accessed_dt and phase2_dt and phase2_dt < accessed_dt:
664
+ errors.append("phase2CompletedAt 不得早于 reviewAccessedAt")
665
+
666
+ # issues
667
+ issues = result.get("issues")
668
+ if not isinstance(issues, list):
669
+ errors.append("issues 必须是数组")
670
+ issues = []
671
+ seen_issue_ids = set()
672
+ rules_used_by_issues = {}
673
+ review_categories = {"A": 0, "B": 0, "C": 0}
674
+ severity_counts = {"high": 0, "medium": 0, "low": 0}
675
+ fail_count = 0
676
+
677
+ for index, issue in enumerate(issues):
678
+ where = "issues[{0}]".format(index)
679
+ if not isinstance(issue, dict):
680
+ errors.append("{0} 必须是对象".format(where))
681
+ continue
682
+ for field in ISSUE_REQUIRED:
683
+ if field not in issue:
684
+ errors.append("{0} 缺少字段 {1}".format(where, field))
685
+ issue_id = issue.get("issueId")
686
+ if not _is_nonempty_str(issue_id):
687
+ errors.append("{0}.issueId 不得为空".format(where))
688
+ elif issue_id in seen_issue_ids:
689
+ errors.append("issueId 重复:{0}".format(issue_id))
690
+ else:
691
+ seen_issue_ids.add(issue_id)
692
+ for field, label in (("severity", "severity"), ("issueType", "issueType"), ("decision", "decision")):
693
+ value = issue.get(field)
694
+ table = {"severity": SEVERITY_LABEL, "issueType": ISSUE_TYPE_LABEL, "decision": DECISION_LABEL}[field]
695
+ if value not in table:
696
+ errors.append("{0}.{1} 取值非法:{2}".format(where, label, value))
697
+ for field in ("title", "problemDescription", "handlingRequirement", "locationSummary", "module"):
698
+ if not _is_nonempty_str(issue.get(field)):
699
+ errors.append("{0}.{1} 不得为空".format(where, field))
700
+ errors.extend(validate_problem_description(where, issue))
701
+
702
+ severity = issue.get("severity")
703
+ if severity in severity_counts and issue.get("decision") == "fail":
704
+ severity_counts[severity] += 1
705
+ if issue.get("decision") == "fail":
706
+ fail_count += 1
707
+
708
+ rule_evidence = issue.get("ruleEvidence")
709
+ material_evidence = issue.get("materialEvidence")
710
+ if not isinstance(rule_evidence, list):
711
+ errors.append("{0}.ruleEvidence 必须是数组".format(where))
712
+ rule_evidence = []
713
+ if not isinstance(material_evidence, list):
714
+ errors.append("{0}.materialEvidence 必须是数组".format(where))
715
+ material_evidence = []
716
+ if not material_evidence:
717
+ errors.append("{0}.materialEvidence 至少一条(材料证据缺失不得形成结论)".format(where))
718
+ if issue.get("decision") == "fail" and issue.get("issueType") == "rule_defect" and not rule_evidence:
719
+ errors.append("{0} 规则性缺陷不通过必须同时具备规则证据与材料证据".format(where))
720
+ for position, evidence in enumerate(material_evidence):
721
+ if not isinstance(evidence, dict):
722
+ errors.append("{0}.materialEvidence[{1}] 必须是对象".format(where, position))
723
+ continue
724
+ for field in MATERIAL_EVIDENCE_REQUIRED:
725
+ if not _is_nonempty_str(evidence.get(field)) and field != "evidenceRole":
726
+ errors.append("{0}.materialEvidence[{1}].{2} 不得为空".format(where, position, field))
727
+ if not _is_nonempty_str(evidence.get("evidenceRole")):
728
+ errors.append("{0}.materialEvidence[{1}].evidenceRole 不得为空".format(where, position))
729
+ for position, evidence in enumerate(rule_evidence):
730
+ if not isinstance(evidence, dict):
731
+ errors.append("{0}.ruleEvidence[{1}] 必须是对象".format(where, position))
732
+ continue
733
+ rule_id = evidence.get("ruleId")
734
+ if not _is_nonempty_str(rule_id):
735
+ errors.append("{0}.ruleEvidence[{1}].ruleId 不得为空".format(where, position))
736
+ else:
737
+ rules_used_by_issues.setdefault(str(rule_id), set()).add(str(issue_id))
738
+ if not _is_nonempty_str(evidence.get("quote")):
739
+ errors.append("{0}.ruleEvidence[{1}].quote 不得为空(须为本次真实读取原文)".format(where, position))
740
+
741
+ gap = issue.get("gapAnalysis") or {}
742
+ if not isinstance(gap, dict):
743
+ errors.append("{0}.gapAnalysis 必须是对象".format(where))
744
+ else:
745
+ for field in ("ruleRequirement", "observedCondition", "difference", "finalJudgment"):
746
+ if not _is_nonempty_str(gap.get(field)):
747
+ errors.append("{0}.gapAnalysis.{1} 不得为空".format(where, field))
748
+
749
+ edits = issue.get("recommendedEdits")
750
+ if not isinstance(edits, list) or not edits:
751
+ errors.append("{0}.recommendedEdits 至少一条".format(where))
752
+ else:
753
+ for position, edit in enumerate(edits):
754
+ if not isinstance(edit, dict):
755
+ errors.append("{0}.recommendedEdits[{1}] 必须是对象".format(where, position))
756
+ continue
757
+ for field in ("fileId", "displayName", "locator", "action"):
758
+ if not _is_nonempty_str(edit.get(field)):
759
+ errors.append("{0}.recommendedEdits[{1}].{2} 不得为空".format(where, position, field))
760
+
761
+ if issue.get("flowStatus") not in FLOW_LABEL:
762
+ errors.append("{0}.flowStatus 取值非法:{1}".format(where, issue.get("flowStatus")))
763
+ confidence = issue.get("confidence") or {}
764
+ if not isinstance(confidence, dict) or confidence.get("level") not in CONFIDENCE_LABEL:
765
+ errors.append("{0}.confidence.level 取值非法".format(where))
766
+ elif not _is_nonempty_str(confidence.get("reason")):
767
+ errors.append("{0}.confidence.reason 不得为空".format(where))
768
+
769
+ comparison = issue.get("reviewComparison") or {}
770
+ if not isinstance(comparison, dict) or comparison.get("status") not in REVIEW_STATUS_LABEL:
771
+ errors.append("{0}.reviewComparison.status 取值非法".format(where))
772
+ elif comparison.get("status") == "performed":
773
+ category = comparison.get("category")
774
+ if category not in review_categories:
775
+ errors.append("{0}.reviewComparison.category 必须是 A/B/C".format(where))
776
+ else:
777
+ review_categories[category] += 1
778
+
779
+ # manualConfirmationItems
780
+ manual_items = result.get("manualConfirmationItems")
781
+ if not isinstance(manual_items, list):
782
+ errors.append("manualConfirmationItems 必须是数组")
783
+ manual_items = []
784
+ manual_ids = set()
785
+ for index, item in enumerate(manual_items):
786
+ where = "manualConfirmationItems[{0}]".format(index)
787
+ if not isinstance(item, dict):
788
+ errors.append("{0} 必须是对象".format(where))
789
+ continue
790
+ for field in MANUAL_ITEM_REQUIRED:
791
+ if field not in item:
792
+ errors.append("{0} 缺少字段 {1}".format(where, field))
793
+ item_id = item.get("itemId")
794
+ if not _is_nonempty_str(item_id):
795
+ errors.append("{0}.itemId 不得为空".format(where))
796
+ elif item_id in manual_ids:
797
+ errors.append("manualConfirmationItems.itemId 重复:{0}".format(item_id))
798
+ else:
799
+ manual_ids.add(item_id)
800
+ for field in ("title", "locationSummary", "observation", "questionToConfirm", "suggestedAction"):
801
+ if not _is_nonempty_str(item.get(field)):
802
+ errors.append("{0}.{1} 不得为空".format(where, field))
803
+ if not isinstance(item.get("evidence"), list) or not item.get("evidence"):
804
+ errors.append("{0}.evidence 至少一条".format(where))
805
+
806
+ # auditBasis
807
+ audit_basis = result.get("auditBasis") or {}
808
+ rules = audit_basis.get("rules")
809
+ if not isinstance(rules, list) or not rules:
810
+ errors.append("auditBasis.rules 至少一条(只列本次实际使用规则)")
811
+ rules = []
812
+ seen_rule_ids = set()
813
+ for index, rule in enumerate(rules):
814
+ where = "auditBasis.rules[{0}]".format(index)
815
+ if not isinstance(rule, dict):
816
+ errors.append("{0} 必须是对象".format(where))
817
+ continue
818
+ for field in RULE_REQUIRED:
819
+ if field not in rule:
820
+ errors.append("{0} 缺少字段 {1}".format(where, field))
821
+ rule_id = rule.get("ruleId")
822
+ if not _is_nonempty_str(rule_id):
823
+ errors.append("{0}.ruleId 不得为空".format(where))
824
+ elif rule_id in seen_rule_ids:
825
+ errors.append("ruleId 重复:{0}".format(rule_id))
826
+ else:
827
+ seen_rule_ids.add(rule_id)
828
+ where = "{0}({1})".format(where, rule_id)
829
+ authority = rule.get("authorityClass")
830
+ if authority not in AUTHORITY_LABEL:
831
+ errors.append("{0}.authorityClass 取值非法:{1}".format(where, authority))
832
+ if rule.get("sourceType") not in SOURCE_TYPE_LABEL:
833
+ errors.append("{0}.sourceType 取值非法:{1}".format(where, rule.get("sourceType")))
834
+ if authority == "external_formal":
835
+ for field in ("title", "version", "clause", "quote"):
836
+ if not _is_nonempty_str(rule.get(field)):
837
+ errors.append("{0} 外部正式依据必须给出 {1}".format(where, field))
838
+ if str(rule.get("version")).strip() in {"现行", "现行有效", "有效"}:
839
+ errors.append("{0}.version 不得宣称“现行”(须写本次实际读取版本)".format(where))
840
+ if not _check_relative_path(str(rule.get("kbRelativePath"))):
841
+ errors.append("{0}.kbRelativePath 必须是知识库相对路径".format(where))
842
+ if not _is_nonempty_str(rule.get("exportedAt")):
843
+ errors.append("{0}.exportedAt 不得为空".format(where))
844
+ if not isinstance(rule.get("purposes"), list) or not rule.get("purposes"):
845
+ errors.append("{0}.purposes 至少一项".format(where))
846
+ expected_users = rules_used_by_issues.get(str(rule_id), set())
847
+ declared_users = set(map(str, rule.get("usedByIssueIds") or []))
848
+ if declared_users != expected_users:
849
+ errors.append(
850
+ "{0}.usedByIssueIds 与明细不一致(应为 {1})".format(where, sorted(expected_users) or "空")
851
+ )
852
+ expected_usage = len(expected_users)
853
+ if rule.get("usageCount") != expected_usage:
854
+ errors.append(
855
+ "{0}.usageCount 必须可由明细重算(应为 {1})".format(where, expected_usage)
856
+ )
857
+
858
+ # knowledgeBaseFiles(§5.3 相对路径;渲染器按 path 字段显示文件名)
859
+ kb_files = audit_basis.get("knowledgeBaseFiles")
860
+ if not isinstance(kb_files, list):
861
+ errors.append("auditBasis.knowledgeBaseFiles 必须是数组")
862
+ else:
863
+ for index, item in enumerate(kb_files):
864
+ where = "auditBasis.knowledgeBaseFiles[{0}]".format(index)
865
+ if not isinstance(item, dict):
866
+ errors.append("{0} 必须是对象".format(where))
867
+ continue
868
+ if not _is_nonempty_str(item.get("path")):
869
+ errors.append(
870
+ "{0}.path 不得为空(渲染器按 path 显示知识库文件路径;形如 kbRelativePath 的别名会导致只显示时间)".format(where)
871
+ )
872
+ elif not _check_relative_path(str(item.get("path"))):
873
+ errors.append("{0}.path 必须是知识库相对路径".format(where))
874
+ if not _is_nonempty_str(item.get("exportedAt")):
875
+ errors.append("{0}.exportedAt 不得为空".format(where))
876
+
877
+ # reviewComparison(§6)
878
+ comparison = result.get("reviewComparison") or {}
879
+ status = comparison.get("status")
880
+ if status not in REVIEW_STATUS_LABEL:
881
+ errors.append("reviewComparison.status 取值非法:{0}".format(status))
882
+ reviewer_only = comparison.get("reviewerOnlyItems")
883
+ if not isinstance(reviewer_only, list):
884
+ errors.append("reviewComparison.reviewerOnlyItems 必须是数组")
885
+ reviewer_only = []
886
+ reviewer_ids = set()
887
+ for index, item in enumerate(reviewer_only):
888
+ where = "reviewComparison.reviewerOnlyItems[{0}]".format(index)
889
+ if not isinstance(item, dict):
890
+ errors.append("{0} 必须是对象".format(where))
891
+ continue
892
+ for field in REVIEWER_ONLY_REQUIRED:
893
+ if field not in item:
894
+ errors.append("{0} 缺少字段 {1}".format(where, field))
895
+ item_id = item.get("itemId")
896
+ if not _is_nonempty_str(item_id):
897
+ errors.append("{0}.itemId 不得为空".format(where))
898
+ elif item_id in reviewer_ids:
899
+ errors.append("reviewerOnlyItems.itemId 重复:{0}".format(item_id))
900
+ else:
901
+ reviewer_ids.add(item_id)
902
+ evidence = item.get("reviewerEvidence") or {}
903
+ if not isinstance(evidence, dict):
904
+ errors.append("{0}.reviewerEvidence 必须是对象".format(where))
905
+ else:
906
+ for field in ("file", "locator", "quote"):
907
+ if not _is_nonempty_str(evidence.get(field)):
908
+ errors.append("{0}.reviewerEvidence.{1} 不得为空".format(where, field))
909
+ if not _is_nonempty_str(item.get("handling")):
910
+ errors.append("{0}.handling 不得为空".format(where))
911
+ resolution = item.get("inFileResolution")
912
+ if resolution not in IN_FILE_RESOLUTION_VALUES:
913
+ errors.append("{0}.inFileResolution 必须为 L-resolved/L-open/L-unclosed/L-uncheckable".format(where))
914
+ else:
915
+ if resolution in ("L-resolved", "L-open", "L-unclosed"):
916
+ in_file = item.get("inFileEvidence") or {}
917
+ for field in ("file", "locator"):
918
+ if not _is_nonempty_str(in_file.get(field)):
919
+ errors.append("{0}.inFileResolution={1} 必须给出 inFileEvidence.{2}(被审件在件位置)".format(where, resolution, field))
920
+ if resolution == "L-unclosed":
921
+ closure = item.get("closureEvidence") or {}
922
+ if not (_is_nonempty_str(closure.get("file")) and _is_nonempty_str(closure.get("locator"))):
923
+ errors.append("{0}.inFileResolution=L-unclosed 必须给出 closureEvidence(答复出处)".format(where))
924
+
925
+ # 新版逐条复核事实源:文件顺序、逐条命中和指标全部可重算。
926
+ review_files = comparison.get("reviewFiles")
927
+ review_items = comparison.get("reviewItems")
928
+ if review_files is not None or review_items is not None:
929
+ if not isinstance(review_files, list) or not review_files:
930
+ errors.append("reviewComparison.reviewFiles 必须是非空数组(按实际复核顺序登记)")
931
+ review_files = []
932
+ if not isinstance(review_items, list):
933
+ errors.append("reviewComparison.reviewItems 必须是数组")
934
+ review_items = []
935
+
936
+ file_orders = set()
937
+ file_names = set()
938
+ levels = set()
939
+ for index, entry in enumerate(review_files):
940
+ where = "reviewComparison.reviewFiles[{0}]".format(index)
941
+ if not isinstance(entry, dict):
942
+ errors.append("{0} 必须是对象".format(where))
943
+ continue
944
+ for field in ("order", "level", "displayName", "version"):
945
+ if field == "order":
946
+ if not isinstance(entry.get(field), int) or entry.get(field) < 1:
947
+ errors.append("{0}.order 必须是从 1 开始的整数".format(where))
948
+ elif not _is_nonempty_str(entry.get(field)):
949
+ errors.append("{0}.{1} 不得为空".format(where, field))
950
+ if entry.get("order") in file_orders:
951
+ errors.append("reviewComparison.reviewFiles.order 重复:{0}".format(entry.get("order")))
952
+ file_orders.add(entry.get("order"))
953
+ file_names.add(str(entry.get("displayName")))
954
+ levels.add(str(entry.get("level")))
955
+ if review_files and sorted(file_orders) != list(range(1, len(review_files) + 1)):
956
+ errors.append("reviewComparison.reviewFiles.order 必须连续且可排序")
957
+
958
+ item_ids = set()
959
+ for index, item in enumerate(review_items):
960
+ where = "reviewComparison.reviewItems[{0}]".format(index)
961
+ if not isinstance(item, dict):
962
+ errors.append("{0} 必须是对象".format(where))
963
+ continue
964
+ for field in ("itemId", "title", "module", "reviewLevel", "matchStatus", "reviewerEvidence", "linkedIssueIds", "inFileResolution", "handling"):
965
+ if field not in item:
966
+ errors.append("{0} 缺少字段 {1}".format(where, field))
967
+ item_id = item.get("itemId")
968
+ if not _is_nonempty_str(item_id):
969
+ errors.append("{0}.itemId 不得为空".format(where))
970
+ elif item_id in item_ids:
971
+ errors.append("reviewComparison.reviewItems.itemId 重复:{0}".format(item_id))
972
+ item_ids.add(item_id)
973
+ if item.get("matchStatus") not in REVIEW_MATCH_VALUES:
974
+ errors.append("{0}.matchStatus 必须为 exact/partial/miss".format(where))
975
+ if item.get("reviewLevel") not in levels:
976
+ errors.append("{0}.reviewLevel 未在 reviewFiles 中登记".format(where))
977
+ evidence = item.get("reviewerEvidence") or {}
978
+ for field in ("file", "locator", "quote"):
979
+ if not _is_nonempty_str(evidence.get(field)):
980
+ errors.append("{0}.reviewerEvidence.{1} 不得为空".format(where, field))
981
+ if _is_nonempty_str(evidence.get("file")) and str(evidence.get("file")) not in file_names:
982
+ errors.append("{0}.reviewerEvidence.file 未在 reviewFiles 中登记".format(where))
983
+ explanation = item.get("hitExplanation")
984
+ if not isinstance(explanation, dict):
985
+ errors.append("{0}.hitExplanation 必须是对象(AI 须逐条说明命中了哪些、未命中哪些、判定理由)".format(where))
986
+ else:
987
+ scope = explanation.get("reviewerScope")
988
+ if scope not in ("general", "specific"):
989
+ errors.append("{0}.hitExplanation.reviewerScope 必须为 general(复核条目笼统)/ specific(具体)".format(where))
990
+ matched = explanation.get("matchedAspects")
991
+ unmatched = explanation.get("unmatchedAspects")
992
+ for field, value in (("matchedAspects", matched), ("unmatchedAspects", unmatched)):
993
+ if not isinstance(value, list) or any(not _is_nonempty_str(x) for x in value):
994
+ errors.append("{0}.hitExplanation.{1} 必须是非空字符串数组(可为空数组)".format(where, field))
995
+ if not _is_nonempty_str(explanation.get("rationale")):
996
+ errors.append("{0}.hitExplanation.rationale 不得为空(禁止无理由给判定)".format(where))
997
+ if isinstance(matched, list) and isinstance(unmatched, list):
998
+ match_status = item.get("matchStatus")
999
+ if match_status == "exact" and unmatched:
1000
+ errors.append("{0} matchStatus=exact 时 hitExplanation.unmatchedAspects 必须为空".format(where))
1001
+ if match_status == "partial" and not (matched and unmatched):
1002
+ errors.append("{0} matchStatus=partial 时 hitExplanation 必须同时给出 matchedAspects 与 unmatchedAspects".format(where))
1003
+ if match_status == "miss" and (matched or not unmatched):
1004
+ errors.append("{0} matchStatus=miss 时 hitExplanation 必须无 matchedAspects 且有 unmatchedAspects".format(where))
1005
+ linked = item.get("linkedIssueIds")
1006
+ if not isinstance(linked, list):
1007
+ errors.append("{0}.linkedIssueIds 必须是数组".format(where))
1008
+ elif item.get("matchStatus") in ("exact", "partial") and not linked:
1009
+ errors.append("{0} AI 命中项必须关联至少一个 issueId".format(where))
1010
+ elif any(issue_id not in seen_issue_ids for issue_id in linked):
1011
+ errors.append("{0}.linkedIssueIds 含不存在的 issueId".format(where))
1012
+ resolution = item.get("inFileResolution")
1013
+ if resolution not in IN_FILE_RESOLUTION_VALUES:
1014
+ errors.append("{0}.inFileResolution 取值非法".format(where))
1015
+ elif resolution in ("L-resolved", "L-open", "L-unclosed"):
1016
+ in_file = item.get("inFileEvidence") or {}
1017
+ for field in ("file", "locator"):
1018
+ if not _is_nonempty_str(in_file.get(field)):
1019
+ errors.append("{0}.{1} 必须给出在件核验证据".format(where, field))
1020
+ # AI 未命中而按「已落实」剔除分母时,必须给出在件原文证明该事项确已修改/不存在
1021
+ if resolution == "L-resolved" and item.get("matchStatus") == "miss":
1022
+ if not _is_nonempty_str(in_file.get("quote")):
1023
+ errors.append(
1024
+ "{0}.inFileEvidence.quote 不得为空:AI 未命中且按 L-resolved 剔除分母时,"
1025
+ "必须给出在件原文,证明该意见确已修改/已不存在(无法证明的应按 L-open 计入漏检)".format(where))
1026
+ if resolution == "L-unclosed":
1027
+ closure = item.get("closureEvidence") or {}
1028
+ for field in ("file", "locator"):
1029
+ if not _is_nonempty_str(closure.get(field)):
1030
+ errors.append("{0}.closureEvidence.{1} 不得为空".format(where, field))
1031
+
1032
+ expected_metrics = _review_metrics(review_items)
1033
+ metrics = comparison.get("metrics")
1034
+ if not isinstance(metrics, dict):
1035
+ errors.append("reviewComparison.metrics 必须是对象且可由 reviewItems 重算")
1036
+ else:
1037
+ for key, expected in expected_metrics.items():
1038
+ if metrics.get(key) != expected:
1039
+ errors.append(
1040
+ "reviewComparison.metrics.{0} 必须可由 reviewItems 重算(应为 {1})".format(key, expected)
1041
+ )
1042
+ out_of_scope = comparison.get("outOfScopeItems")
1043
+ if out_of_scope is not None:
1044
+ if not isinstance(out_of_scope, list):
1045
+ errors.append("reviewComparison.outOfScopeItems 必须是数组(无则省略或空数组)")
1046
+ else:
1047
+ scope_ids = set()
1048
+ for index, item in enumerate(out_of_scope):
1049
+ where = "reviewComparison.outOfScopeItems[{0}]".format(index)
1050
+ if not isinstance(item, dict):
1051
+ errors.append("{0} 必须是对象".format(where))
1052
+ continue
1053
+ for field in OUT_OF_SCOPE_REQUIRED:
1054
+ if field not in item:
1055
+ errors.append("{0} 缺少字段 {1}".format(where, field))
1056
+ item_id = item.get("itemId")
1057
+ if not _is_nonempty_str(item_id):
1058
+ errors.append("{0}.itemId 不得为空".format(where))
1059
+ elif item_id in item_ids:
1060
+ errors.append("{0}.itemId 与 reviewItems 重复:{1}(能力边界条目不得混入命中率明细)".format(where, item_id))
1061
+ elif item_id in scope_ids:
1062
+ errors.append("outOfScopeItems.itemId 重复:{0}".format(item_id))
1063
+ else:
1064
+ scope_ids.add(item_id)
1065
+ evidence = item.get("reviewerEvidence") or {}
1066
+ for field in ("file", "locator", "quote"):
1067
+ if not _is_nonempty_str(evidence.get(field)):
1068
+ errors.append("{0}.reviewerEvidence.{1} 不得为空".format(where, field))
1069
+ for field in ("title", "handling", "exclusionReason"):
1070
+ if not _is_nonempty_str(item.get(field)):
1071
+ errors.append("{0}.{1} 不得为空".format(where, field))
1072
+
1073
+ hidden_access = comparison.get("hiddenRegionAccess")
1074
+ if hidden_access is not None:
1075
+ if not isinstance(hidden_access, list):
1076
+ errors.append("reviewComparison.hiddenRegionAccess 必须是数组")
1077
+ else:
1078
+ for idx, entry in enumerate(hidden_access):
1079
+ where = "reviewComparison.hiddenRegionAccess[{0}]".format(idx)
1080
+ if not isinstance(entry, dict):
1081
+ errors.append("{0} 必须是对象".format(where))
1082
+ continue
1083
+ for field in ("authorizedBy", "authorizedAt", "authorization", "scope"):
1084
+ if not _is_nonempty_str(entry.get(field)):
1085
+ errors.append("{0}.{1} 不得为空".format(where, field))
1086
+
1087
+ if status == "performed":
1088
+ expected_bands = {
1089
+ "overlap": review_categories["A"],
1090
+ "aiOnly": review_categories["B"],
1091
+ "divergent": review_categories["C"],
1092
+ "reviewerOnly": len(reviewer_only),
1093
+ }
1094
+ bands = comparison.get("bands")
1095
+ if not isinstance(bands, dict):
1096
+ errors.append("reviewComparison.bands 必须是对象(阶段二已完成)")
1097
+ else:
1098
+ for band, expected in expected_bands.items():
1099
+ if bands.get(band) != expected:
1100
+ errors.append(
1101
+ "reviewComparison.bands.{0} 必须可由明细重算(应为 {1})".format(band, expected)
1102
+ )
1103
+ metrics = comparison.get("metrics") or {}
1104
+ if review_items is None and (not isinstance(metrics, dict) or not _is_nonempty_str(metrics.get("denominator"))):
1105
+ errors.append("reviewComparison.metrics.denominator 不得为空(命中率必须给出分母口径)")
1106
+ for index, issue in enumerate(issues):
1107
+ if isinstance(issue, dict):
1108
+ item = issue.get("reviewComparison") or {}
1109
+ if item.get("status") != "performed":
1110
+ errors.append(
1111
+ "阶段二已完成,issues[{0}] 必须具有 reviewComparison(不得标未执行)".format(index)
1112
+ )
1113
+ elif status == "not_performed":
1114
+ if reviewer_only:
1115
+ errors.append("reviewComparison.status=not_performed 时不得填写复核独有项")
1116
+ for index, issue in enumerate(issues):
1117
+ if isinstance(issue, dict):
1118
+ item = issue.get("reviewComparison") or {}
1119
+ if item.get("status") == "performed":
1120
+ errors.append(
1121
+ "reviewComparison.status=not_performed 时 issues[{0}] 不得标为已执行".format(index)
1122
+ )
1123
+
1124
+ # scope(§7)
1125
+ scope = result.get("scope") or {}
1126
+ if not isinstance(scope.get("inputs"), list) or not scope.get("inputs"):
1127
+ errors.append("scope.inputs 至少一项")
1128
+ if not isinstance(scope.get("checkDomains"), list) or not scope.get("checkDomains"):
1129
+ errors.append("scope.checkDomains 至少一项")
1130
+ not_checked = scope.get("notCheckedItems")
1131
+ if not isinstance(not_checked, list):
1132
+ errors.append("scope.notCheckedItems 必须是数组(无则空数组)")
1133
+ not_checked = []
1134
+ not_checked_ids = set()
1135
+ for index, item in enumerate(not_checked):
1136
+ where = "scope.notCheckedItems[{0}]".format(index)
1137
+ if not isinstance(item, dict):
1138
+ errors.append("{0} 必须是对象".format(where))
1139
+ continue
1140
+ for field in NOT_CHECKED_REQUIRED:
1141
+ if field not in item:
1142
+ errors.append("{0} 缺少字段 {1}".format(where, field))
1143
+ item_id = item.get("itemId")
1144
+ if not _is_nonempty_str(item_id):
1145
+ errors.append("{0}.itemId 不得为空".format(where))
1146
+ elif item_id in not_checked_ids:
1147
+ errors.append("notCheckedItems.itemId 重复:{0}".format(item_id))
1148
+ else:
1149
+ not_checked_ids.add(item_id)
1150
+ if item.get("reasonCode") not in REASON_LABEL:
1151
+ errors.append("{0}.reasonCode 取值非法:{1}".format(where, item.get("reasonCode")))
1152
+ for field in ("item", "reason", "impact", "requiredAction"):
1153
+ if not _is_nonempty_str(item.get(field)):
1154
+ errors.append("{0}.{1} 不得为空".format(where, field))
1155
+ if not isinstance(item.get("relatedFiles"), list):
1156
+ errors.append("{0}.relatedFiles 必须是数组".format(where))
1157
+
1158
+ # professionalTrail(§8)
1159
+ trail = result.get("professionalTrail") or {}
1160
+ if not isinstance(trail.get("applicableRuleSnapshot"), dict):
1161
+ errors.append("professionalTrail.applicableRuleSnapshot 必须是对象")
1162
+ for field in ("adjudications", "checkRecords"):
1163
+ if not isinstance(trail.get(field), list):
1164
+ errors.append("professionalTrail.{0} 必须是数组".format(field))
1165
+ if not isinstance(trail.get("comprehensiveComparison"), dict):
1166
+ errors.append("professionalTrail.comprehensiveComparison 必须是对象")
1167
+ for index, record in enumerate(trail.get("adjudications") or []):
1168
+ where = "professionalTrail.adjudications[{0}]".format(index)
1169
+ if not isinstance(record, dict):
1170
+ errors.append("{0} 必须是对象".format(where))
1171
+ continue
1172
+ if not (_is_nonempty_str(record.get("recordId")) or _is_nonempty_str(record.get("chkId"))):
1173
+ errors.append("{0}.recordId 不得为空(渲染器按 recordId 显示编号,缺失将导致裁定表编号列空白)".format(where))
1174
+ if not (_is_nonempty_str(record.get("itemRef")) or _is_nonempty_str(record.get("item"))
1175
+ or _is_nonempty_str(record.get("checkItem"))):
1176
+ errors.append("{0}.itemRef 不得为空(渲染器按 itemRef 显示条目,缺失将导致裁定表条目列空白)".format(where))
1177
+ if not (_is_nonempty_str(record.get("ruling")) or _is_nonempty_str(record.get("result"))):
1178
+ errors.append("{0} 必须给出 ruling 或 result(缺失将导致裁定列空白)".format(where))
1179
+ check_record_ids = set()
1180
+ for index, record in enumerate(trail.get("checkRecords") or []):
1181
+ where = "professionalTrail.checkRecords[{0}]".format(index)
1182
+ if not isinstance(record, dict):
1183
+ errors.append("{0} 必须是对象".format(where))
1184
+ continue
1185
+ for field in CHECK_RECORD_REQUIRED:
1186
+ if field not in record:
1187
+ errors.append("{0} 缺少字段 {1}".format(where, field))
1188
+ record_id = record.get("recordId")
1189
+ if not _is_nonempty_str(record_id):
1190
+ errors.append("{0}.recordId 不得为空".format(where))
1191
+ elif record_id in check_record_ids:
1192
+ errors.append("checkRecords.recordId 重复:{0}".format(record_id))
1193
+ else:
1194
+ check_record_ids.add(record_id)
1195
+ if record.get("result") not in RESULT_LABEL:
1196
+ errors.append("{0}.result 取值非法:{1}".format(where, record.get("result")))
1197
+ for field in ("checkItem", "performedAt", "executor"):
1198
+ if not _is_nonempty_str(record.get(field)):
1199
+ errors.append("{0}.{1} 不得为空".format(where, field))
1200
+ if not isinstance(record.get("evidence"), list) or not record.get("evidence"):
1201
+ errors.append("{0}.evidence 至少一条(每条“检查过”必须有真实文件证据)".format(where))
1202
+ for field in ("targets", "linkedIssueIds"):
1203
+ if not isinstance(record.get(field), list):
1204
+ errors.append("{0}.{1} 必须是数组".format(where, field))
1205
+
1206
+ # summary(§9.4 可重算)
1207
+ summary = result.get("summary") or {}
1208
+ if summary.get("overallDecision") not in OVERALL_LABEL:
1209
+ errors.append("summary.overallDecision 取值非法:{0}".format(summary.get("overallDecision")))
1210
+ expected_counts = {
1211
+ "issuesTotal": len(issues),
1212
+ "fail": fail_count,
1213
+ "high": severity_counts["high"],
1214
+ "medium": severity_counts["medium"],
1215
+ "low": severity_counts["low"],
1216
+ "pendingConfirmation": len(manual_items),
1217
+ "notChecked": len(not_checked),
1218
+ }
1219
+ counts = summary.get("counts")
1220
+ if not isinstance(counts, dict):
1221
+ errors.append("summary.counts 必须是对象且可由明细重算")
1222
+ else:
1223
+ for key, expected in expected_counts.items():
1224
+ if counts.get(key) != expected:
1225
+ errors.append("summary.counts.{0} 必须可由明细重算(应为 {1})".format(key, expected))
1226
+ if fail_count and summary.get("overallDecision") == "pass":
1227
+ errors.append("存在不通过问题,summary.overallDecision 不得为 pass")
1228
+ if not fail_count and summary.get("overallDecision") == "fail":
1229
+ errors.append("不存在不通过问题,summary.overallDecision 不得为 fail")
1230
+ if not _is_nonempty_str(summary.get("narrative")):
1231
+ errors.append("summary.narrative 不得为空")
1232
+
1233
+ # externalDataVerification(外部数据核验:基准日锚定 + 源可用性声明 + 逐项核验)
1234
+ if result.get("externalDataVerification") is not None:
1235
+ _validate_external_data(result.get("externalDataVerification"), errors)
1236
+
1237
+ # fileTrace(§10 / §12)
1238
+ file_trace = result.get("fileTrace") or {}
1239
+ if not _is_nonempty_str(file_trace.get("generatedAt")):
1240
+ errors.append("fileTrace.generatedAt 不得为空")
1241
+ renderer_version = file_trace.get("rendererVersion")
1242
+ if not _is_nonempty_str(renderer_version):
1243
+ errors.append("fileTrace.rendererVersion 不得为空")
1244
+ for field in ("sourceDigest", "embeddedJsonDigest"):
1245
+ value = file_trace.get(field)
1246
+ if rendered:
1247
+ if not _is_nonempty_str(value) or not HEX64.match(str(value)):
1248
+ errors.append("fileTrace.{0} 必须是渲染后的 sha256 摘要".format(field))
1249
+ elif _is_nonempty_str(value) and not HEX64.match(str(value)):
1250
+ errors.append("fileTrace.{0} 如果不是空值必须是 sha256 摘要".format(field))
1251
+ if expect_renderer and renderer_version != RENDERER_VERSION:
1252
+ errors.append("fileTrace.rendererVersion 与当前 renderer 不一致:{0}".format(renderer_version))
1253
+
1254
+ # renderPolicy
1255
+ policy = result.get("renderPolicy") or {}
1256
+ if not isinstance(policy, dict):
1257
+ errors.append("renderPolicy 必须是对象")
1258
+ elif not isinstance(policy.get("printTrail"), bool):
1259
+ errors.append("renderPolicy.printTrail 必须是布尔值")
1260
+
1261
+ # 敏感信息(§10.1 / §12.2)
1262
+ errors.extend(scan_sensitive(result))
1263
+
1264
+ # ---- AI 审核评分卡(自评)+ 审核错误项 ----
1265
+ scorecard = result.get("aiScorecard")
1266
+ if not isinstance(scorecard, dict):
1267
+ errors.append("aiScorecard 必须是对象")
1268
+ scorecard = {}
1269
+ level = scorecard.get("level")
1270
+ if level not in SCORECARD_LEVELS:
1271
+ errors.append("aiScorecard.level 必须为 初审/复审/终审(与台账 review_level 一致)")
1272
+ dimensions = scorecard.get("dimensions")
1273
+ if not isinstance(dimensions, list):
1274
+ errors.append("aiScorecard.dimensions 必须是数组")
1275
+ dimensions = []
1276
+ seen_keys = []
1277
+ for idx, dim in enumerate(dimensions):
1278
+ where = "aiScorecard.dimensions[{0}]".format(idx)
1279
+ if not isinstance(dim, dict):
1280
+ errors.append("{0} 必须是对象".format(where))
1281
+ continue
1282
+ key = dim.get("key")
1283
+ if key not in SCORECARD_DIMENSION_KEYS:
1284
+ errors.append("{0}.key 取值非法:{1}".format(where, key))
1285
+ else:
1286
+ seen_keys.append(key)
1287
+ score = dim.get("score")
1288
+ if not isinstance(score, (int, float)) or isinstance(score, bool):
1289
+ errors.append("{0}.score 必须是数值".format(where))
1290
+ elif not (0 <= float(score) <= 10) or abs(float(score) * 2 - round(float(score) * 2)) > 1e-9:
1291
+ errors.append("{0}.score 必须为 0–10 且 0.5 的整数倍".format(where))
1292
+ if dim.get("max") != 10:
1293
+ errors.append("{0}.max 必须为 10".format(where))
1294
+ if not _is_nonempty_str(dim.get("basis")):
1295
+ errors.append("{0}.basis 不得为空(禁止无依据给分)".format(where))
1296
+ if sorted(seen_keys) != sorted(SCORECARD_DIMENSION_KEYS):
1297
+ errors.append("aiScorecard.dimensions 必须恰好覆盖六维:{0}".format(SCORECARD_DIMENSION_KEYS))
1298
+
1299
+ composites = scorecard.get("composites")
1300
+ if not isinstance(composites, dict):
1301
+ errors.append("aiScorecard.composites 必须是对象")
1302
+ composites = {}
1303
+ corrections = scorecard.get("corrections") or []
1304
+ if not isinstance(corrections, list):
1305
+ errors.append("aiScorecard.corrections 必须是数组")
1306
+ corrections = []
1307
+ by_key = {d.get("key"): d.get("score") for d in dimensions if isinstance(d, dict)}
1308
+
1309
+ if dimensions:
1310
+ expected_ai_only = _mean_score(dimensions)
1311
+ if composites.get("aiOnly") != expected_ai_only:
1312
+ errors.append("aiScorecard.composites.aiOnly 必须可由六维均值按 0.5 取整复算(应为 {0})".format(expected_ai_only))
1313
+
1314
+ def _final_score(key):
1315
+ score = by_key.get(key)
1316
+ for cor in corrections:
1317
+ if isinstance(cor, dict) and cor.get("key") == key:
1318
+ score = cor.get("to")
1319
+ return score
1320
+
1321
+ for idx, cor in enumerate(corrections):
1322
+ where = "aiScorecard.corrections[{0}]".format(idx)
1323
+ if not isinstance(cor, dict):
1324
+ errors.append("{0} 必须是对象".format(where))
1325
+ continue
1326
+ key = cor.get("key")
1327
+ if key not in SCORECARD_DIMENSION_KEYS:
1328
+ errors.append("{0}.key 取值非法:{1}".format(where, key))
1329
+ continue
1330
+ if cor.get("from") != by_key.get(key):
1331
+ errors.append("{0}.from 必须等于初审该维分数(只增不覆盖:不得改写初审分)".format(where))
1332
+ if not _is_nonempty_str(cor.get("reason")):
1333
+ errors.append("{0}.reason 不得为空(校正必须给理由)".format(where))
1334
+
1335
+ expected_loop = _round_half_up(
1336
+ sum(float(_final_score(k)) for k in SCORECARD_DIMENSION_KEYS) / len(SCORECARD_DIMENSION_KEYS)
1337
+ ) if dimensions else None
1338
+ if level == "初审":
1339
+ if corrections:
1340
+ errors.append("aiScorecard.corrections 仅在复审/终审填写(初审不得事后校正)")
1341
+ if composites.get("withHumanLoop") is not None:
1342
+ errors.append("aiScorecard.composites.withHumanLoop 仅在复审/终审填写")
1343
+ elif level in ("复审", "终审"):
1344
+ if composites.get("withHumanLoop") != expected_loop:
1345
+ errors.append("aiScorecard.composites.withHumanLoop 必须可由复审校正后六维均值按 0.5 取整复算(应为 {0})".format(expected_loop))
1346
+
1347
+ self_errors = result.get("selfAuditErrors")
1348
+ if not isinstance(self_errors, list):
1349
+ errors.append("selfAuditErrors 必须是数组")
1350
+ self_errors = []
1351
+ error_ids = set()
1352
+ for idx, item in enumerate(self_errors):
1353
+ where = "selfAuditErrors[{0}]".format(idx)
1354
+ if not isinstance(item, dict):
1355
+ errors.append("{0} 必须是对象".format(where))
1356
+ continue
1357
+ for field in ("errorId", "kind", "discoveredAt", "description"):
1358
+ if field not in item:
1359
+ errors.append("{0} 缺少字段 {1}".format(where, field))
1360
+ error_id = item.get("errorId")
1361
+ if _is_nonempty_str(error_id):
1362
+ if error_id in error_ids:
1363
+ errors.append("selfAuditErrors.errorId 重复:{0}".format(error_id))
1364
+ error_ids.add(error_id)
1365
+ if item.get("kind") not in SCORECARD_ERROR_KINDS:
1366
+ errors.append("{0}.kind 取值非法:{1}".format(where, item.get("kind")))
1367
+ if item.get("discoveredAt") not in SCORECARD_LEVELS:
1368
+ errors.append("{0}.discoveredAt 必须为 初审/复审/终审".format(where))
1369
+ if not _is_nonempty_str(item.get("description")):
1370
+ errors.append("{0}.description 不得为空".format(where))
1371
+ if item.get("status") is not None and item.get("status") not in ("open", "closed"):
1372
+ errors.append("{0}.status 取值非法:{1}".format(where, item.get("status")))
1373
+ if (
1374
+ item.get("kind") == "false_positive"
1375
+ and item.get("status") == "closed"
1376
+ and item.get("issueId") in seen_issue_ids
1377
+ ):
1378
+ errors.append(
1379
+ "{0} 已关闭假阳性不得仍保留在 issues;请撤回对应问题项或改正错误记录".format(where)
1380
+ )
1381
+
1382
+ return errors
1383
+
1384
+
1385
+ # --------------------------------------------------------------------------- #
1386
+ # 渲染(§10)
1387
+ # --------------------------------------------------------------------------- #
1388
+ def _text(value) -> str:
1389
+ if isinstance(value, (dict, list)):
1390
+ value = canonical_json(value)
1391
+ return html.escape("" if value is None else str(value), quote=True)
1392
+
1393
+
1394
+ def _dt(value: str) -> str:
1395
+ parsed = _parse_time(str(value))
1396
+ return parsed.isoformat() if parsed else str(value)
1397
+
1398
+
1399
+ def _readable_status(value) -> str:
1400
+ if value is True:
1401
+ return '<span class="readable-status is-readable" title="可读" aria-label="可读">✅</span>'
1402
+ return '<span class="readable-status is-unreadable" title="不可读" aria-label="不可读">❌</span>'
1403
+
1404
+
1405
+ def _span(cls: str, value) -> str:
1406
+ return '<span class="{0}">{1}</span>'.format(cls, _text(value))
1407
+
1408
+
1409
+ def _item_index(label: str, index: int, total: int) -> str:
1410
+ """生成分区内稳定序号;至少补齐两位,便于长列表扫描。"""
1411
+ width = max(2, len(str(total)))
1412
+ return _span(
1413
+ "item-index",
1414
+ "{0} {1}/{2}".format(label, str(index).zfill(width), str(total).zfill(width)),
1415
+ )
1416
+
1417
+
1418
+ def _rows(pairs) -> str:
1419
+ return "".join(
1420
+ "<tr><th scope=\"row\">{0}</th><td>{1}</td></tr>".format(_text(label), _text(value))
1421
+ for label, value in pairs
1422
+ )
1423
+
1424
+
1425
+ def _list_block(items, formatter, empty_text=EMPTY_TEXT) -> str:
1426
+ if not items:
1427
+ return '<p class="empty">{0}</p>'.format(_text(empty_text))
1428
+ return "<ul>" + "".join("<li>{0}</li>".format(formatter(item)) for item in items) + "</ul>"
1429
+
1430
+
1431
+ def _rule_map(rules) -> str:
1432
+ grouped = {}
1433
+ for rule in rules:
1434
+ authority = rule.get("authorityClass")
1435
+ source = rule.get("sourceType")
1436
+ grouped.setdefault(authority, {}).setdefault(source, []).append(rule)
1437
+ parts = ['<nav aria-label="审核规则地图" class="rule-map">']
1438
+ for authority in sorted(grouped):
1439
+ parts.append("<section><h4>{0}</h4>".format(_text(AUTHORITY_LABEL.get(authority, authority))))
1440
+ for source in sorted(grouped[authority]):
1441
+ parts.append("<p class=\"source\">{0}</p>".format(_text(SOURCE_TYPE_LABEL.get(source, source))))
1442
+ parts.append("<ul>")
1443
+ for rule in sorted(grouped[authority][source], key=lambda item: str(item.get("ruleId"))):
1444
+ parts.append(
1445
+ "<li>{0}{1}</li>".format(
1446
+ _span("rule-id", rule.get("ruleId")),
1447
+ _span("rule-title", rule.get("title")),
1448
+ )
1449
+ )
1450
+ parts.append("</ul>")
1451
+ parts.append("</section>")
1452
+ parts.append("</nav>")
1453
+ return "".join(parts)
1454
+
1455
+
1456
+ def _is_ai_only_issue(issue) -> bool:
1457
+ comparison = issue.get("reviewComparison") or {}
1458
+ return comparison.get("status") == "performed" and comparison.get("category") == "B"
1459
+
1460
+
1461
+ def _ai_only_summary(issues, comparison) -> str:
1462
+ if comparison.get("status") != "performed":
1463
+ return ""
1464
+ ai_only = [issue for issue in issues if _is_ai_only_issue(issue)]
1465
+ severity_counts = {"high": 0, "medium": 0, "low": 0}
1466
+ for issue in ai_only:
1467
+ severity = issue.get("severity")
1468
+ if severity in severity_counts:
1469
+ severity_counts[severity] += 1
1470
+ parts = ['<aside class="ai-only-summary" aria-label="AI 独立检出统计">']
1471
+ parts.append('<div class="ai-only-summary-head"><span>{0}</span>'.format(_text("AI 独立检出")))
1472
+ parts.append('<span class="ai-only-total"><strong>{0}</strong><span>{1}</span></span></div>'.format(
1473
+ _text(len(ai_only)), _text("项")))
1474
+ parts.append('<div class="ai-only-severity-list">')
1475
+ for severity in ("high", "medium", "low"):
1476
+ parts.append('<span class="ai-only-severity {0}"><span>{1}</span><strong>{2}</strong></span>'.format(
1477
+ SEVERITY_CLASS[severity], _text(SEVERITY_LABEL[severity]), _text(severity_counts[severity])))
1478
+ parts.append("</div></aside>")
1479
+ return "".join(parts)
1480
+
1481
+
1482
+ def _problem_block(value) -> str:
1483
+ """§4.6 问题描述按两段式分行呈现:首句单行突出 + 明细逐行,不拼接、不改写。"""
1484
+ headline, details = split_problem_description(value)
1485
+ if not headline and not details:
1486
+ return '<p class="problem"><span class="problem-label">问题描述</span>{0}</p>'.format(_text(EMPTY_TEXT))
1487
+ parts = ['<div class="problem"><span class="problem-label">问题描述</span>']
1488
+ if headline:
1489
+ parts.append('<p class="problem-headline">{0}</p>'.format(_span("problem-description", headline)))
1490
+ if details:
1491
+ parts.append('<ul class="problem-details">')
1492
+ for detail in details:
1493
+ parts.append("<li>{0}</li>".format(_text(detail)))
1494
+ parts.append("</ul>")
1495
+ parts.append("</div>")
1496
+ return "".join(parts)
1497
+
1498
+
1499
+ def _issue_location_block(issue: dict) -> str:
1500
+ """把 JSON 中的定位摘要与材料证据落点组合成可扫描面板。"""
1501
+ seen = set()
1502
+ locations = []
1503
+ for evidence in issue.get("materialEvidence") or []:
1504
+ file_name = str(evidence.get("displayName") or "").strip()
1505
+ locator = str(evidence.get("locator") or "").strip()
1506
+ key = (file_name, locator)
1507
+ if not file_name or not locator or key in seen:
1508
+ continue
1509
+ seen.add(key)
1510
+ locations.append(key)
1511
+ parts = ['<section class="issue-location-panel" aria-label="问题位置">']
1512
+ parts.append('<p class="issue-location-heading">{0}</p>'.format(
1513
+ _text("问题位置 · 请到以下位置核对")))
1514
+ parts.append('<p class="issue-location-summary">{0}</p>'.format(
1515
+ _text(issue.get("locationSummary"))))
1516
+ if locations:
1517
+ parts.append('<ul class="issue-location-list">')
1518
+ for file_name, locator in locations:
1519
+ parts.append(
1520
+ '<li><span class="location-file">{0}</span>'
1521
+ '<span class="location-arrow" aria-hidden="true">→</span>'
1522
+ '<span class="location-locator">{1}</span></li>'.format(
1523
+ _text(file_name), _text(locator)))
1524
+ parts.append("</ul>")
1525
+ parts.append("</section>")
1526
+ return "".join(parts)
1527
+
1528
+
1529
+ def _issue_card(issue, index: int, total: int) -> str:
1530
+ ai_only = _is_ai_only_issue(issue)
1531
+ card_classes = ["issue-card", SEVERITY_CLASS.get(issue.get("severity"), "")]
1532
+ if ai_only:
1533
+ card_classes.append("ai-only-issue")
1534
+ cards = ['<article class="{0}">'.format(" ".join(filter(None, card_classes)))]
1535
+ cards.append('<header class="issue-head">')
1536
+ cards.append('<div class="issue-title-row">{0}<h3>{1}</h3>'.format(
1537
+ _item_index("问题", index, total), _span("issue-title", issue.get("title"))))
1538
+ if ai_only:
1539
+ cards.append('<span class="ai-only-badge">{0}</span>'.format(_text("AI 独立发现")))
1540
+ cards.append("</div>")
1541
+ if ai_only:
1542
+ cards.append('<p class="ai-only-note">{0}</p>'.format(
1543
+ _text("未与人工复核意见重叠,请优先核验其准确性")))
1544
+ cards.append('<div class="issue-meta">')
1545
+ cards.append('<span class="meta-chip issue-id">{0}</span>'.format(_text(issue.get("issueId"))))
1546
+ cards.append('<span class="meta-chip module-label">{0}:{1}</span>'.format(
1547
+ _text("问题方向"), _text(MODULE_LABEL.get(issue.get("module"), issue.get("module")))))
1548
+ cards.append('<span class="meta-chip severity-label">{0}:{1}</span>'.format(
1549
+ _text("严重程度"), _text(SEVERITY_LABEL.get(issue.get("severity"), issue.get("severity")))))
1550
+ cards.append('<span class="meta-chip type-label">{0}:{1}</span>'.format(
1551
+ _text("问题类型"), _text(ISSUE_TYPE_LABEL.get(issue.get("issueType"), issue.get("issueType")))))
1552
+ cards.append('<span class="meta-chip decision-label">{0}:{1}</span>'.format(
1553
+ _text("判定"), _text(DECISION_LABEL.get(issue.get("decision"), issue.get("decision")))))
1554
+ cards.append('</div>')
1555
+ cards.append("</header>")
1556
+ cards.append(_issue_location_block(issue))
1557
+ cards.append(_problem_block(issue.get("problemDescription")))
1558
+
1559
+ recommended_edits = issue.get("recommendedEdits") or []
1560
+ cards.append('<details class="issue-edits"><summary>{0}</summary>'.format(
1561
+ _text("展开修改意见(共 {0} 项)".format(len(recommended_edits)))))
1562
+ cards.append('<section class="stage stage-edit action-panel"><h4>{0}</h4>'.format(_text("建议修改")))
1563
+ cards.append(_list_block(recommended_edits, _edit_item) if recommended_edits else _list_block([], None))
1564
+ cards.append("</section></details>")
1565
+
1566
+ cards.append('<details class="issue-evidence"><summary>{0}</summary>'.format(
1567
+ _text("展开判断依据与规则({0} 条规则,{1} 条材料)".format(
1568
+ len(issue.get("ruleEvidence") or []), len(issue.get("materialEvidence") or [])))))
1569
+ cards.append('<section class="stage stage-rule"><h4>{0}</h4>'.format(_text("规则依据")))
1570
+ cards.append(_list_block(issue.get("ruleEvidence"), _rule_evidence_item) if issue.get("ruleEvidence") else _list_block([], None))
1571
+ cards.append("</section>")
1572
+
1573
+ cards.append('<section class="stage stage-material"><h4>{0}</h4>'.format(_text("材料证据")))
1574
+ cards.append(_list_block(issue.get("materialEvidence"), _material_evidence_item) if issue.get("materialEvidence") else _list_block([], None))
1575
+ cards.append("</section>")
1576
+
1577
+ gap = issue.get("gapAnalysis") or {}
1578
+ cards.append(
1579
+ '<section class="stage stage-diff"><h4>{0}</h4><table class="kv">{1}</table></section>'.format(
1580
+ _text("差异"),
1581
+ _rows(
1582
+ [
1583
+ ("规则要求", gap.get("ruleRequirement")),
1584
+ ("材料现状", gap.get("observedCondition")),
1585
+ ("差异", gap.get("difference")),
1586
+ ("判断", gap.get("finalJudgment")),
1587
+ ]
1588
+ ),
1589
+ )
1590
+ )
1591
+
1592
+ confidence = issue.get("confidence") or {}
1593
+ cards.append(
1594
+ '<section class="stage stage-conclusion"><h4>{0}</h4><table class="kv">{1}</table></section>'.format(
1595
+ _text("结论"),
1596
+ _rows(
1597
+ [
1598
+ ("处理要求", issue.get("handlingRequirement")),
1599
+ ("流转状态", FLOW_LABEL.get(issue.get("flowStatus"), issue.get("flowStatus"))),
1600
+ ("置信度", CONFIDENCE_LABEL.get(confidence.get("level"), confidence.get("level"))),
1601
+ ("置信度理由", confidence.get("reason")),
1602
+ ]
1603
+ ),
1604
+ )
1605
+ )
1606
+
1607
+ cards.append("</details>")
1608
+
1609
+ comparison = issue.get("reviewComparison") or {}
1610
+ if comparison.get("status") == "performed":
1611
+ cards.append('<p class="issue-review-status"><strong>{0}</strong>{1}</p>'.format(
1612
+ _text("人工复核对照:"),
1613
+ _text(REVIEW_CATEGORY_LABEL.get(comparison.get("category"), comparison.get("category")))))
1614
+ else:
1615
+ cards.append(
1616
+ '<p class="compare-status">{0}{1}</p>'.format(
1617
+ _span("compare-label", "人工复核对照"), _span("compare-value", REVIEW_STATUS_LABEL["not_performed"])
1618
+ )
1619
+ )
1620
+ cards.append("</article>")
1621
+ return "".join(cards)
1622
+
1623
+
1624
+ def _rule_evidence_item(evidence) -> str:
1625
+ return (
1626
+ '<span class="rule-id">{0}</span>'
1627
+ '<span class="authority">{1}</span>'
1628
+ '<span class="clause">{2}</span>'
1629
+ '<span class="quote">{3}</span>'
1630
+ '<span class="kb-path">{4}</span>'
1631
+ '<span class="exported-at">{5}</span>'
1632
+ ).format(
1633
+ _text(evidence.get("ruleId")),
1634
+ _text(AUTHORITY_LABEL.get(evidence.get("authorityClass"), evidence.get("authorityClass"))),
1635
+ _text(evidence.get("clause")),
1636
+ _text(evidence.get("quote")),
1637
+ _text(evidence.get("kbRelativePath")),
1638
+ _text(evidence.get("exportedAt")),
1639
+ )
1640
+
1641
+
1642
+ def _material_evidence_item(evidence) -> str:
1643
+ return (
1644
+ '<span class="file">{0}</span>'
1645
+ '<span class="section">{1}</span>'
1646
+ '<span class="locator">{2}</span>'
1647
+ '<span class="excerpt">{3}</span>'
1648
+ '<span class="role">{4}</span>'
1649
+ ).format(
1650
+ _text(evidence.get("displayName")),
1651
+ _text(evidence.get("section")),
1652
+ _text(evidence.get("locator")),
1653
+ _text(evidence.get("excerptOrValue")),
1654
+ _text(evidence.get("evidenceRole")),
1655
+ )
1656
+
1657
+
1658
+ def _edit_item(edit) -> str:
1659
+ return (
1660
+ '<span class="file">{0}</span><span class="locator">{1}</span><span class="action">{2}</span>'
1661
+ ).format(_text(edit.get("displayName")), _text(edit.get("locator")), _text(edit.get("action")))
1662
+
1663
+
1664
+ def _manual_item(item, index: int, total: int) -> str:
1665
+ return (
1666
+ '<div class="item-heading">{0}<h4>{1}</h4></div><table class="kv">{2}</table>'
1667
+ ).format(
1668
+ _item_index("确认", index, total),
1669
+ _text(item.get("title")),
1670
+ _rows(
1671
+ [
1672
+ ("编号", item.get("itemId")),
1673
+ ("位置", item.get("locationSummary")),
1674
+ ("客观发现", item.get("observation")),
1675
+ ("需确认问题", item.get("questionToConfirm")),
1676
+ ("建议动作", item.get("suggestedAction")),
1677
+ ]
1678
+ ),
1679
+ )
1680
+
1681
+
1682
+
1683
+ def _metric_cells(metrics: dict) -> str:
1684
+ return "".join(
1685
+ "<td>{0}</td>".format(_text(value))
1686
+ for value in (
1687
+ metrics["total"], metrics["resolved"], metrics["uncheckable"], metrics["evaluable"],
1688
+ metrics["exactHits"], metrics["partialHits"], metrics["misses"],
1689
+ _format_rate(metrics["hitRate"]), _format_rate(metrics["exactRate"]),
1690
+ )
1691
+ )
1692
+
1693
+
1694
+ def _performance_table(items: list, group_key: str, title: str) -> str:
1695
+ groups = {}
1696
+ for item in items:
1697
+ groups.setdefault(str(item.get(group_key) or "未分类"), []).append(item)
1698
+ parts = ["<h3>{0}</h3>".format(_text(title)), '<div class="table-scroll"><table class="performance-table">']
1699
+ parts.append("<thead><tr>" + "".join(
1700
+ '<th scope="col">{0}</th>'.format(_text(label))
1701
+ for label in ("维度", "意见", "已验证修改", "无法核验", "可评价", "精确命中", "部分命中", "未命中", "命中率", "精确命中率")
1702
+ ) + "</tr></thead><tbody>")
1703
+ for name, grouped in groups.items():
1704
+ parts.append("<tr><th scope=\"row\">{0}</th>{1}</tr>".format(_text(name), _metric_cells(_review_metrics(grouped))))
1705
+ parts.append("</tbody></table></div>")
1706
+ return "".join(parts)
1707
+
1708
+
1709
+ def _scorecard_section(comparison, self_errors) -> str:
1710
+ """仅以逐条复核事实计算百分比;不再把主观 0–10 自评分作为员工端评分卡。"""
1711
+ items = comparison.get("reviewItems") or []
1712
+ metrics = _review_metrics(items)
1713
+ parts = ['<section id="ai-scorecard">', '<h2>{0}</h2>'.format(_text("AI 审核表现评分卡"))]
1714
+ if not items:
1715
+ parts.append('<p class="empty">{0}</p>'.format(_text("缺少完整逐条复核数据,无法计算客观命中率")))
1716
+ else:
1717
+ parts.append('<div class="score-hero"><div><span>{0}</span><strong>{1}</strong></div><div><span>{2}</span><strong>{3}</strong></div></div>'.format(
1718
+ _text("综合命中率"), _text(_format_rate(metrics["hitRate"])),
1719
+ _text("综合精确命中率"), _text(_format_rate(metrics["exactRate"]))))
1720
+ parts.append('<p class="formula">{0}:({1} + {2}) ÷ {3}。{4}</p>'.format(
1721
+ _text("命中率"), _text(metrics["exactHits"]), _text(metrics["partialHits"]),
1722
+ _text(metrics["evaluable"]),
1723
+ _text("部分命中计为命中、只是层次较低;已验证修改和无法核验项不进入分母;综合值按全部有效明细汇总,不取各维度百分比平均。")))
1724
+ parts.append(_hit_level_row(metrics))
1725
+ parts.append(_performance_table(items, "reviewLevel", "按复核级次"))
1726
+ parts.append(_performance_table(items, "module", "按问题模块"))
1727
+ parts.append('<details class="secondary-details"><summary>{0}</summary>'.format(
1728
+ _text("展开 AI 自查错误记录(共 {0} 条)".format(len(self_errors or [])))))
1729
+ if self_errors:
1730
+ parts.append('<div class="table-scroll"><table><thead><tr>' + "".join(
1731
+ '<th scope="col">{0}</th>'.format(_text(label))
1732
+ for label in ("编号", "错误类型", "发现级次", "说明", "处置")
1733
+ ) + '</tr></thead><tbody>')
1734
+ for item in self_errors:
1735
+ parts.append("<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td><td>{4}</td></tr>".format(
1736
+ _text(item.get("errorId")), _text(SCORECARD_ERROR_KIND_LABEL.get(item.get("kind"), item.get("kind"))),
1737
+ _text(item.get("discoveredAt")), _text(item.get("description")), _text(item.get("correction"))))
1738
+ parts.append("</tbody></table></div>")
1739
+ else:
1740
+ parts.append('<p class="empty">{0}</p>'.format(_text(EMPTY_TEXT)))
1741
+ parts.append("</details></section>")
1742
+ return "".join(parts)
1743
+
1744
+
1745
+ def _reviewer_only_item(item) -> str:
1746
+ evidence = item.get("reviewerEvidence") or {}
1747
+ in_file = item.get("inFileEvidence") or {}
1748
+ closure = item.get("closureEvidence") or {}
1749
+ resolution = item.get("inFileResolution")
1750
+ rows = [
1751
+ ("编号", item.get("itemId")),
1752
+ ("复核文件", evidence.get("file")),
1753
+ ("定位", evidence.get("locator")),
1754
+ ("原文摘录", evidence.get("quote")),
1755
+ ("在件核验", IN_FILE_RESOLUTION_LABEL.get(resolution, resolution)),
1756
+ ("被审件文件", in_file.get("file") or IN_FILE_ABSENT_LABEL),
1757
+ ("被审件定位", in_file.get("locator") or IN_FILE_ABSENT_LABEL),
1758
+ ("处理", item.get("handling")),
1759
+ ]
1760
+ if resolution == "L-unclosed":
1761
+ rows.insert(6, ("答复文件", closure.get("file", "") or IN_FILE_ABSENT_LABEL))
1762
+ rows.insert(7, ("答复定位", closure.get("locator", "") or IN_FILE_ABSENT_LABEL))
1763
+ return (
1764
+ "<h4>{0}</h4><table class=\"kv\">{1}</table>"
1765
+ ).format(_text(item.get("title")), _rows(rows))
1766
+
1767
+
1768
+ def _review_item_card(item, index: int, total: int) -> str:
1769
+ evidence = item.get("reviewerEvidence") or {}
1770
+ in_file = item.get("inFileEvidence") or {}
1771
+ closure = item.get("closureEvidence") or {}
1772
+ resolution = item.get("inFileResolution")
1773
+ explanation = item.get("hitExplanation") or {}
1774
+ scope_label = {"general": "笼统(复核表述较宽)", "specific": "具体(复核表述限定明确)"}.get(
1775
+ explanation.get("reviewerScope"), IN_FILE_ABSENT_LABEL)
1776
+ rows = [
1777
+ ("编号", item.get("itemId")),
1778
+ ("问题方向", item.get("module")),
1779
+ ("AI 对照", AI_COMPARISON_NOTE.get(
1780
+ (item.get("matchStatus"), item.get("inFileResolution")),
1781
+ REVIEW_MATCH_LABEL.get(item.get("matchStatus"), item.get("matchStatus")))),
1782
+ ("复核条目范围", scope_label),
1783
+ ("AI 命中内容", ";".join(explanation.get("matchedAspects") or []) or "(无)"),
1784
+ ("AI 未命中内容", ";".join(explanation.get("unmatchedAspects") or []) or "(无)"),
1785
+ ("命中判定理由", explanation.get("rationale") or IN_FILE_ABSENT_LABEL),
1786
+ ("关联 AI 问题", "、".join(item.get("linkedIssueIds") or []) or "无"),
1787
+ ("复核文件", evidence.get("file")),
1788
+ ("复核定位", evidence.get("locator")),
1789
+ ("复核原文", evidence.get("quote")),
1790
+ ("在件核验", IN_FILE_RESOLUTION_LABEL.get(resolution, resolution)),
1791
+ ("被审件文件", in_file.get("file") or IN_FILE_ABSENT_LABEL),
1792
+ ("被审件定位", in_file.get("locator") or IN_FILE_ABSENT_LABEL),
1793
+ ("被审件证据", in_file.get("quote") or IN_FILE_ABSENT_LABEL),
1794
+ ("处理", item.get("handling")),
1795
+ ]
1796
+ if resolution == "L-unclosed":
1797
+ rows.extend([
1798
+ ("答复文件", closure.get("file")),
1799
+ ("答复定位", closure.get("locator")),
1800
+ ("答复原文", closure.get("quote")),
1801
+ ])
1802
+ alert_class = " review-item-unclosed" if resolution == "L-unclosed" else ""
1803
+ tags = [_item_index("复核", index, total)]
1804
+ if resolution == "L-unclosed":
1805
+ tags.append('<span class="closure-tag">{0}</span>'.format(
1806
+ _text("闭环异常|称已修复但实际未修改")))
1807
+ return (
1808
+ '<article class="review-item{0}"><div class="review-item-heading">{1}<h4>{2}</h4></div>'
1809
+ '<table class="kv">{3}</table></article>'
1810
+ ).format(alert_class, "".join(tags), _text(item.get("title")), _rows(rows))
1811
+
1812
+
1813
+ def _review_comparison_section(comparison) -> str:
1814
+ parts = ['<section id="review-comparison">', '<h2>{0}</h2>'.format(_text("人工复核对照"))]
1815
+ if comparison.get("status") != "performed":
1816
+ parts.append('<p class="empty">{0}</p></section>'.format(_text("本次未执行人工复核对照")))
1817
+ return "".join(parts)
1818
+ files = sorted(comparison.get("reviewFiles") or [], key=lambda item: item.get("order", 9999))
1819
+ items = comparison.get("reviewItems") or []
1820
+ if not items:
1821
+ parts.append('<p class="legacy-note">{0}</p>'.format(_text("当前结果仅含旧版复核汇总,未提供逐条复核文件与意见清单。")))
1822
+ reviewer_items = comparison.get("reviewerOnlyItems") or []
1823
+ parts.append(_list_block(reviewer_items, _reviewer_only_item, empty_text=EMPTY_TEXT))
1824
+ parts.append("</section>")
1825
+ return "".join(parts)
1826
+ metrics = _review_metrics(items)
1827
+ item_positions = {id(item): index for index, item in enumerate(items, start=1)}
1828
+ unclosed = sum(item.get("inFileResolution") == "L-unclosed" for item in items)
1829
+ if unclosed:
1830
+ parts.append('<p id="review-unclosed-alert" class="closure-alert">{0}</p>'.format(
1831
+ _text("已称修复但实际未落实:{0} 条,须优先回客户。".format(unclosed))))
1832
+ parts.append('<p class="formula capability-notice">{0}</p>'.format(_text(CAPABILITY_WORKPAPER_NOTICE)))
1833
+ parts.append('<p class="formula">{0}</p>'.format(
1834
+ _text("分母判定:AI 未命中的复核意见,须先核验该事项是否已被修改——"
1835
+ "确已修改的(复核件标注已修改且在件核验通过)不计入命中率分母;并未修改的计入分母并计为漏检。"
1836
+ "材料缺失或不可读、超出 AI 能力层的条目不进入分母。逐条结论见下表「AI 对照」。")))
1837
+ parts.append('<div class="review-kpis">')
1838
+ for label, value, cls in (
1839
+ ("复核意见", metrics["total"], ""), ("已验证修改", metrics["resolved"], "good"),
1840
+ ("AI 精确命中", metrics["exactHits"], "good"), ("AI 部分命中", metrics["partialHits"], "good"),
1841
+ ("AI 未命中", metrics["misses"], "warning"),
1842
+ ("命中率", _format_rate(metrics["hitRate"]), "primary"),
1843
+ ("精确命中率", _format_rate(metrics["exactRate"]), ""),
1844
+ ):
1845
+ parts.append('<div class="metric-card {0}"><span>{1}</span><strong>{2}</strong></div>'.format(
1846
+ cls, _text(label), _text(value)))
1847
+ parts.append("</div>")
1848
+ parts.append('<p class="formula">{0}:({1} + {2}) ÷ {3} = {4};{5}:{6}。{7}</p>'.format(
1849
+ _text("命中率"), _text(metrics["exactHits"]), _text(metrics["partialHits"]),
1850
+ _text(metrics["evaluable"]), _text(_format_rate(metrics["hitRate"])),
1851
+ _text("精确命中率"), _text(_format_rate(metrics["exactRate"])),
1852
+ _text("部分命中计为命中、只是层次较低;已验证修改和无法核验项不进入分母。")))
1853
+ parts.append(_hit_level_row(metrics))
1854
+ parts.append(_excluded_from_denominator_block(comparison))
1855
+ parts.append('<h3>{0}</h3><ol class="review-file-list">'.format(_text("本次复核文件(按顺序)")))
1856
+ for entry in files:
1857
+ parts.append('<li><span class="order">{0}</span><strong>{1}</strong><span>{2}</span><span>{3}</span></li>'.format(
1858
+ _text(entry.get("order")), _text(entry.get("displayName")), _text(entry.get("level")), _text(entry.get("version"))))
1859
+ parts.append("</ol>")
1860
+ seen_levels = []
1861
+ for entry in files:
1862
+ if entry.get("level") not in seen_levels:
1863
+ seen_levels.append(entry.get("level"))
1864
+ for level in seen_levels:
1865
+ level_items = [item for item in items if item.get("reviewLevel") == level]
1866
+ level_metrics = _review_metrics(level_items)
1867
+ level_unclosed = sum(item.get("inFileResolution") == "L-unclosed" for item in level_items)
1868
+ parts.append('<section class="review-level"><h3>{0}</h3>'.format(
1869
+ _text("{0}:{1} 条意见".format(level, len(level_items)))))
1870
+ level_summary = "".join(
1871
+ '<span><strong>{0}</strong>{1}</span>'.format(_text(label), _text(value))
1872
+ for label, value in (
1873
+ ("已验证修改 ", level_metrics["resolved"]), ("AI 精确命中 ", level_metrics["exactHits"]),
1874
+ ("AI 部分命中 ", level_metrics["partialHits"]), ("AI 未命中 ", level_metrics["misses"]),
1875
+ ("命中率 ", _format_rate(level_metrics["hitRate"])),
1876
+ ("精确命中率 ", _format_rate(level_metrics["exactRate"])),
1877
+ )
1878
+ )
1879
+ if level_unclosed:
1880
+ level_summary += '<span class="level-alert-tag">{0}</span>'.format(
1881
+ _text("实际未落实 {0}".format(level_unclosed)))
1882
+ parts.append('<div class="level-summary">{0}</div>'.format(level_summary))
1883
+ parts.append('<details class="review-details"><summary>{0}</summary>'.format(
1884
+ _text("展开{0}意见(共 {1} 条)".format(level, len(level_items)))))
1885
+ for item in level_items:
1886
+ parts.append(_review_item_card(item, item_positions[id(item)], len(items)))
1887
+ parts.append("</details></section>")
1888
+ parts.append(_out_of_scope_block(comparison))
1889
+ parts.append("</section>")
1890
+ return "".join(parts)
1891
+
1892
+
1893
+ def _excluded_from_denominator_block(comparison) -> str:
1894
+ """分母剔除清单:逐条说明命中率分母为何小于复核条数,并给出在件依据。"""
1895
+ items = comparison.get("reviewItems") or []
1896
+ out_of_scope = comparison.get("outOfScopeItems") or []
1897
+ rows = []
1898
+ for resolution, label in (
1899
+ ("L-resolved", "已修改/已落实 · 不计入分母"),
1900
+ ("L-uncheckable", "材料缺失或不可读 · 不计入分母"),
1901
+ ):
1902
+ for item in items:
1903
+ if item.get("inFileResolution") != resolution:
1904
+ continue
1905
+ evidence = item.get("inFileEvidence") or {}
1906
+ rows.append((label, item.get("itemId"), item.get("title"),
1907
+ evidence.get("file") or IN_FILE_ABSENT_LABEL,
1908
+ evidence.get("locator") or IN_FILE_ABSENT_LABEL,
1909
+ REVIEW_MATCH_LABEL.get(item.get("matchStatus"), item.get("matchStatus"))))
1910
+ for item in out_of_scope:
1911
+ rows.append(("能力边界 · 登记备查", item.get("itemId"), item.get("title"),
1912
+ item.get("exclusionReason"), item.get("handling"), "不计分"))
1913
+ if not rows:
1914
+ return ""
1915
+ parts = ['<details class="review-excluded"><summary>{0}</summary>'.format(
1916
+ _text("展开不计入命中率分母的条目(共 {0} 条)".format(len(rows))))]
1917
+ parts.append('<p class="formula">{0}</p>'.format(
1918
+ _text("命中率分母 = 复核条数 − 已修改/已落实 − 材料缺失或不可读 − 能力边界;下列条目逐条给出剔除依据。")))
1919
+ parts.append('<div class="table-scroll"><table><thead><tr>' + "".join(
1920
+ '<th scope="col">{0}</th>'.format(_text(label))
1921
+ for label in ("剔除依据", "编号", "条目", "在件文件/依据", "在件定位/处理", "AI 对照")
1922
+ ) + "</tr></thead><tbody>")
1923
+ for row in rows:
1924
+ parts.append("<tr>" + "".join("<td>{0}</td>".format(_text(cell)) for cell in row) + "</tr>")
1925
+ parts.append("</tbody></table></div></details>")
1926
+ return "".join(parts)
1927
+
1928
+
1929
+ def _out_of_scope_block(comparison) -> str:
1930
+ """能力边界条目:不计分母、不计漏检,但必须登记备查、可见可展开。"""
1931
+ items = comparison.get("outOfScopeItems") or []
1932
+ if not items:
1933
+ return ""
1934
+ parts = ['<details class="review-out-of-scope"><summary>{0}</summary>'.format(
1935
+ _text("展开不计入命中率的登记备查条目(共 {0} 条)".format(len(items))))]
1936
+ parts.append('<p class="formula">{0}</p>'.format(
1937
+ _text("以下条目属 AI 当前不具备的能力层(如底稿审核),不计入命中率分母、不计漏检、不作评分,仅登记备查并交人工复核。")))
1938
+ for item in items:
1939
+ evidence = item.get("reviewerEvidence") or {}
1940
+ rows = [
1941
+ ("编号", item.get("itemId")),
1942
+ ("问题方向", item.get("module")),
1943
+ ("复核文件", evidence.get("file")),
1944
+ ("复核定位", evidence.get("locator")),
1945
+ ("复核原文", evidence.get("quote")),
1946
+ ("不计分原因", item.get("exclusionReason")),
1947
+ ("处理", item.get("handling")),
1948
+ ]
1949
+ parts.append('<article class="review-item"><h4>{0}</h4><table class="kv">{1}</table></article>'.format(
1950
+ _text(item.get("title")), _rows(rows)))
1951
+ parts.append("</details>")
1952
+ return "".join(parts)
1953
+
1954
+
1955
+ def _not_checked_item(item, index: int, total: int) -> str:
1956
+ return (
1957
+ "<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td><td>{4}</td><td>{5}</td><td>{6}</td></tr>"
1958
+ ).format(
1959
+ _item_index("未检查", index, total),
1960
+ _text(item.get("itemId")),
1961
+ _text(item.get("item")),
1962
+ _text(REASON_LABEL.get(item.get("reasonCode"), item.get("reasonCode"))),
1963
+ _text(item.get("reason")),
1964
+ _text(item.get("impact")),
1965
+ _text(item.get("requiredAction")),
1966
+ )
1967
+
1968
+
1969
+ def _check_record_row(record) -> str:
1970
+ return "<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td></tr>".format(
1971
+ _text(record.get("recordId")),
1972
+ _text(record.get("checkItem")),
1973
+ _text(RESULT_LABEL.get(record.get("result"), record.get("result"))),
1974
+ _text(record.get("executor")),
1975
+ )
1976
+
1977
+
1978
+ def _adjudication_row(record) -> str:
1979
+ ruling = record.get("ruling")
1980
+ if not _is_nonempty_str(ruling):
1981
+ ruling = RESULT_LABEL.get(record.get("result"), record.get("result"))
1982
+ return "<tr><td>{0}</td><td>{1}</td><td>{2}</td></tr>".format(
1983
+ _text(record.get("recordId") or record.get("chkId")),
1984
+ _text(record.get("itemRef") or record.get("item") or record.get("checkItem")),
1985
+ _text(ruling),
1986
+ )
1987
+
1988
+
1989
+ def _validate_external_data(verification, errors):
1990
+ """外部数据核验区校验:基准日锚定、源可用性声明、逐项核验与判定档位。
1991
+
1992
+ 不可用的数据源不得被当作核验依据;数据源不可用时必须在 sources[] 显式声明。
1993
+ """
1994
+ if not isinstance(verification, dict):
1995
+ errors.append("externalDataVerification 必须是对象")
1996
+ return
1997
+ applicability = verification.get("applicability")
1998
+ if not isinstance(applicability, dict):
1999
+ errors.append("externalDataVerification.applicability 必须是对象并先完成适用性判断")
2000
+ applicability = {}
2001
+ status = applicability.get("status")
2002
+ if status not in EXT_DATA_APPLICABILITY_LABEL:
2003
+ errors.append(
2004
+ "externalDataVerification.applicability.status 取值非法:{0}(仅允许 required / not_applicable)".format(
2005
+ status
2006
+ )
2007
+ )
2008
+ for field in ("reason", "basis"):
2009
+ if not _is_nonempty_str(applicability.get(field)):
2010
+ errors.append("externalDataVerification.applicability.{0} 不得为空".format(field))
2011
+ if not _is_nonempty_str(verification.get("baseDate")):
2012
+ errors.append("externalDataVerification.baseDate 不得为空(外部数据核验必须以报告基准日为锚)")
2013
+
2014
+ sources = verification.get("sources")
2015
+ if sources is None:
2016
+ sources = []
2017
+ if not isinstance(sources, list):
2018
+ errors.append("externalDataVerification.sources 必须是数组")
2019
+ sources = []
2020
+ declared = {}
2021
+ for index, item in enumerate(sources):
2022
+ where = "externalDataVerification.sources[{0}]".format(index)
2023
+ if not isinstance(item, dict):
2024
+ errors.append("{0} 必须是对象".format(where))
2025
+ continue
2026
+ for field in ("source", "note"):
2027
+ if not _is_nonempty_str(item.get(field)):
2028
+ errors.append("{0}.{1} 不得为空".format(where, field))
2029
+ for field in ("configured", "authenticated"):
2030
+ if not isinstance(item.get(field), bool):
2031
+ errors.append("{0}.{1} 必须是布尔值".format(where, field))
2032
+ declared[str(item.get("source"))] = item
2033
+
2034
+ checks = verification.get("checks")
2035
+ if checks is None:
2036
+ checks = []
2037
+ if not isinstance(checks, list):
2038
+ errors.append("externalDataVerification.checks 必须是数组")
2039
+ return
2040
+ if status == "not_applicable":
2041
+ if sources:
2042
+ errors.append("externalDataVerification.applicability.status=not_applicable 时 sources 必须为空")
2043
+ if checks:
2044
+ errors.append("externalDataVerification.applicability.status=not_applicable 时 checks 必须为空")
2045
+ if _is_nonempty_str(verification.get("unavailableDeclaration")):
2046
+ errors.append(
2047
+ "externalDataVerification.applicability.status=not_applicable 时不得填写 unavailableDeclaration"
2048
+ )
2049
+ elif status == "required" and not sources and not checks:
2050
+ errors.append("externalDataVerification 至少要有 sources 或 checks 之一,不得空返")
2051
+
2052
+ unavailable = [
2053
+ item
2054
+ for item in sources
2055
+ if isinstance(item, dict) and not (item.get("configured") and item.get("authenticated"))
2056
+ ]
2057
+ if status == "required" and unavailable and not _is_nonempty_str(verification.get("unavailableDeclaration")):
2058
+ errors.append(
2059
+ "externalDataVerification.unavailableDeclaration 在存在未配置 / 未认证数据源时不得为空"
2060
+ "(兜底声明须由数据提供方写入,渲染器不自造句子)"
2061
+ )
2062
+
2063
+ for index, check in enumerate(checks):
2064
+ where = "externalDataVerification.checks[{0}]".format(index)
2065
+ if not isinstance(check, dict):
2066
+ errors.append("{0} 必须是对象".format(where))
2067
+ continue
2068
+ for field in ("checkId", "metric", "reportValue", "decision", "baseDate"):
2069
+ if not _is_nonempty_str(check.get(field)):
2070
+ errors.append("{0}.{1} 不得为空".format(where, field))
2071
+ if check.get("decision") not in EXT_DATA_DECISIONS:
2072
+ errors.append("{0}.decision 取值非法:{1}".format(where, check.get("decision")))
2073
+ if check.get("decision") == "请说明" and not _is_nonempty_str(check.get("note")):
2074
+ errors.append("{0}.note 在判定为“请说明”时不得为空".format(where))
2075
+ evidence = check.get("reportEvidence")
2076
+ if check.get("decision") == "不符合":
2077
+ if not isinstance(evidence, dict) or not _is_nonempty_str(evidence.get("locator")):
2078
+ errors.append("{0}.reportEvidence.locator 在判定为“不符合”时不得为空".format(where))
2079
+ elif evidence is not None and not isinstance(evidence, dict):
2080
+ errors.append("{0}.reportEvidence 必须是对象".format(where))
2081
+
2082
+ items = check.get("sources")
2083
+ if not isinstance(items, list) or not items:
2084
+ errors.append("{0}.sources 必须是非空数组".format(where))
2085
+ continue
2086
+ for sindex, item in enumerate(items):
2087
+ swhere = "{0}.sources[{1}]".format(where, sindex)
2088
+ if not isinstance(item, dict):
2089
+ errors.append("{0} 必须是对象".format(swhere))
2090
+ continue
2091
+ if not _is_nonempty_str(item.get("source")) or not _is_nonempty_str(item.get("value")):
2092
+ errors.append("{0}.source / value 不得为空".format(swhere))
2093
+ name = str(item.get("source"))
2094
+ declaration = declared.get(name)
2095
+ if declared and declaration is None:
2096
+ errors.append("{0}.source 未在 externalDataVerification.sources 中声明:{1}".format(swhere, name))
2097
+ elif declaration is not None and not (
2098
+ declaration.get("configured") and declaration.get("authenticated")
2099
+ ) and item.get("available") is not False:
2100
+ errors.append(
2101
+ "{0}:数据源 {1} 不可用(未配置 / 未认证),必须在条目中标记 available=false".format(
2102
+ swhere, name
2103
+ )
2104
+ )
2105
+
2106
+
2107
+ def _external_data_sources_section(verification):
2108
+ parts = []
2109
+ sources = verification.get("sources") or []
2110
+ if not sources:
2111
+ return ""
2112
+ parts.append("<h4>{0}</h4>".format(_text("数据源可用性")))
2113
+ parts.append(
2114
+ "<table><thead><tr><th>{0}</th><th>{1}</th><th>{2}</th><th>{3}</th></tr></thead><tbody>".format(
2115
+ _text("数据源"), _text("是否配置"), _text("是否已授权"), _text("说明")
2116
+ )
2117
+ )
2118
+ for item in sources:
2119
+ parts.append(
2120
+ "<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td></tr>".format(
2121
+ _text(item.get("source")),
2122
+ _text("已配置" if item.get("configured") else "未配置"),
2123
+ _text("已授权" if item.get("authenticated") else "未授权"),
2124
+ _text(item.get("note")),
2125
+ )
2126
+ )
2127
+ parts.append("</tbody></table>")
2128
+ return "".join(parts)
2129
+
2130
+
2131
+ def _external_data_checks_section(verification):
2132
+ checks = verification.get("checks") or []
2133
+ parts = ["<h4>{0}</h4>".format(_text("逐项核验"))]
2134
+ if not checks:
2135
+ parts.append('<p class="empty">{0}</p>'.format(_text(EXT_DATA_NO_CHECK_TEXT)))
2136
+ return "".join(parts)
2137
+
2138
+ header = ["编号", "数据项", "报告值", "报告位置", "数据源", "外部取值", "取值时点", "口径", "差异说明", "判定", "说明"]
2139
+ parts.append(
2140
+ '<div class="table-scroll"><table><thead><tr>{0}</tr></thead><tbody>'.format(
2141
+ "".join('<th scope="col">{0}</th>'.format(_text(cell)) for cell in header)
2142
+ )
2143
+ )
2144
+ for check in checks:
2145
+ base_date = check.get("baseDate") or verification.get("baseDate")
2146
+ deviation = check.get("deviationAgainst")
2147
+ if not _is_nonempty_str(deviation) or str(deviation).strip() == "—":
2148
+ # 归一化:"未检查"的项一律显示"未取数",不得与"无出入"共用 — 号
2149
+ deviation = EXT_DATA_NOT_FETCHED_TEXT if check.get("decision") == "未检查" else "—"
2150
+ evidence = check.get("reportEvidence") or {}
2151
+ tail = [
2152
+ _text(item) for item in (
2153
+ evidence.get("locator") or "未提供",
2154
+ )
2155
+ ]
2156
+ end_cells = [
2157
+ _text(deviation),
2158
+ _text(check.get("decision")),
2159
+ _text(check.get("note")),
2160
+ ]
2161
+ for item in check.get("sources") or []:
2162
+ if item.get("available") is False:
2163
+ value_cell = _text(EXT_DATA_UNAVAILABLE_TEXT)
2164
+ else:
2165
+ value_cell = _text(item.get("value"))
2166
+ cells = [
2167
+ _text(check.get("checkId")),
2168
+ _text(check.get("metric")),
2169
+ _text(check.get("reportValue")),
2170
+ tail[0],
2171
+ _text(item.get("source")),
2172
+ value_cell,
2173
+ _text(item.get("asOfDate") or base_date or ""),
2174
+ _text(item.get("caliber") or ""),
2175
+ ] + end_cells
2176
+ parts.append("<tr>{0}</tr>".format("".join("<td>{0}</td>".format(cell) for cell in cells)))
2177
+ parts.append("</tbody></table></div>")
2178
+ return "".join(parts)
2179
+
2180
+
2181
+ def _external_data_section(verification) -> str:
2182
+ """《外部数据核验》区:先展示适用性,再按需展示数据源与逐项核验。"""
2183
+ parts = ['<section id="external-data-verification">']
2184
+ parts.append("<h2>{0}</h2>".format(_text("AI 外部数据核验结果")))
2185
+ if not isinstance(verification, dict) or not verification:
2186
+ parts.append('<p class="empty">{0}</p>'.format(_text(EXT_DATA_EMPTY_TEXT)))
2187
+ parts.append("</section>")
2188
+ return "".join(parts)
2189
+
2190
+ applicability = verification.get("applicability") or {}
2191
+ if applicability.get("status") == "not_applicable":
2192
+ parts.append('<div class="external-applicability not-applicable">')
2193
+ parts.append('<h3>{0}</h3>'.format(_text(EXT_DATA_NOT_APPLICABLE_TEXT)))
2194
+ parts.append('<table class="kv">{0}</table>'.format(_rows([
2195
+ ("不适用原因", applicability.get("reason")),
2196
+ ("外部核验范围依据", applicability.get("basis")),
2197
+ ])))
2198
+ parts.append("</div></section>")
2199
+ return "".join(parts)
2200
+
2201
+ unavailable = [
2202
+ item
2203
+ for item in (verification.get("sources") or [])
2204
+ if not (item.get("configured") and item.get("authenticated"))
2205
+ ]
2206
+ declaration = verification.get("unavailableDeclaration")
2207
+ if unavailable and _is_nonempty_str(declaration):
2208
+ parts.append('<p class="banner">{0}</p>'.format(_text(declaration)))
2209
+ parts.append('<table class="kv">{0}</table>'.format(_rows([("基准日", verification.get("baseDate"))])))
2210
+ parts.append(_external_data_sources_section(verification))
2211
+ parts.append(_external_data_checks_section(verification))
2212
+ parts.append("</section>")
2213
+ return "".join(parts)
2214
+
2215
+
2216
+ def _summary_breakdown_section(rendered_result) -> str:
2217
+ """概览分区:问题按模块分布 + 外部数据核验结果(分列呈现,不与问题总数混列)。
2218
+
2219
+ 全部由既有数据确定性推导(issues[].module / externalDataVerification.checks),
2220
+ 不新增冻结字段、不引入人工撰写的句子。
2221
+ """
2222
+ parts = []
2223
+ issues = rendered_result.get("issues") or []
2224
+ if issues:
2225
+ order = []
2226
+ stats = {}
2227
+ for issue in issues:
2228
+ module = str(issue.get("module") or "")
2229
+ if module not in stats:
2230
+ stats[module] = {"total": 0, "high": 0, "medium": 0, "low": 0}
2231
+ order.append(module)
2232
+ stats[module]["total"] += 1
2233
+ severity = issue.get("severity")
2234
+ if severity in ("high", "medium", "low"):
2235
+ stats[module][severity] += 1
2236
+ parts.append("<h4>{0}</h4>".format(_text("问题按模块分布")))
2237
+ parts.append(
2238
+ "<table><thead><tr><th>{0}</th><th>{1}</th><th>{2}</th><th>{3}</th><th>{4}</th></tr></thead><tbody>".format(
2239
+ _text("模块"), _text("问题数"), _text("高"), _text("中"), _text("低")
2240
+ )
2241
+ )
2242
+ for module in sorted(order, key=lambda name: (-stats[name]["total"], name)):
2243
+ row = stats[module]
2244
+ parts.append(
2245
+ "<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td><td>{4}</td></tr>".format(
2246
+ _text(MODULE_LABEL.get(module, module)), _text(row["total"]), _text(row["high"]),
2247
+ _text(row["medium"]), _text(row["low"]),
2248
+ )
2249
+ )
2250
+ parts.append("</tbody></table>")
2251
+
2252
+ verification = rendered_result.get("externalDataVerification") or {}
2253
+ checks = verification.get("checks") or []
2254
+ applicability = verification.get("applicability") or {}
2255
+ if applicability.get("status") == "not_applicable":
2256
+ parts.append("<h4>{0}</h4>".format(_text("外部数据核验结果")))
2257
+ parts.append(
2258
+ '<table><tbody><tr><td>{0}</td><td>{1}</td></tr></tbody></table>'.format(
2259
+ _text("适用性"), _text(EXT_DATA_APPLICABILITY_LABEL["not_applicable"])
2260
+ )
2261
+ )
2262
+ elif isinstance(checks, list) and checks:
2263
+ counts = {}
2264
+ for check in checks:
2265
+ decision = str(check.get("decision") or "")
2266
+ counts[decision] = counts.get(decision, 0) + 1
2267
+ parts.append("<h4>{0}</h4>".format(_text("外部数据核验结果")))
2268
+ parts.append(
2269
+ "<table><thead><tr><th>{0}</th><th>{1}</th></tr></thead><tbody>".format(_text("判定"), _text("项数"))
2270
+ )
2271
+ for decision in EXT_DATA_DECISIONS:
2272
+ if counts.get(decision):
2273
+ parts.append(
2274
+ "<tr><td>{0}</td><td>{1}</td></tr>".format(_text(decision), _text(counts[decision]))
2275
+ )
2276
+ parts.append("</tbody></table>")
2277
+
2278
+ deviating = {}
2279
+ no_deviation = 0
2280
+ not_fetched = 0
2281
+ unspecified = 0
2282
+ for check in checks:
2283
+ against = str(check.get("deviationAgainst") or "").strip()
2284
+ decision = str(check.get("decision") or "")
2285
+ if against and against != "—":
2286
+ deviating[against] = deviating.get(against, 0) + 1
2287
+ elif decision == "未检查":
2288
+ not_fetched += 1
2289
+ elif decision == "符合":
2290
+ no_deviation += 1
2291
+ else:
2292
+ unspecified += 1
2293
+ parts.append("<h4>{0}</h4>".format(_text("与哪一数据源出入较大")))
2294
+ parts.append(
2295
+ "<table><thead><tr><th>{0}</th><th>{1}</th></tr></thead><tbody>".format(_text("数据源"), _text("项数"))
2296
+ )
2297
+ for name in sorted(deviating, key=lambda item: (-deviating[item], item)):
2298
+ parts.append(
2299
+ "<tr><td>{0}</td><td>{1}</td></tr>".format(_text(name), _text(deviating[name]))
2300
+ )
2301
+ if no_deviation:
2302
+ parts.append(
2303
+ "<tr><td>{0}</td><td>{1}</td></tr>".format(_text(EXT_DATA_NO_DEVIATION_TEXT), _text(no_deviation))
2304
+ )
2305
+ if not_fetched:
2306
+ parts.append(
2307
+ "<tr><td>{0}</td><td>{1}</td></tr>".format(_text(EXT_DATA_NOT_FETCHED_TEXT), _text(not_fetched))
2308
+ )
2309
+ if unspecified:
2310
+ parts.append(
2311
+ "<tr><td>{0}</td><td>{1}</td></tr>".format(
2312
+ _text(EXT_DATA_UNSPECIFIED_SOURCE_TEXT), _text(unspecified)
2313
+ )
2314
+ )
2315
+ parts.append("</tbody></table>")
2316
+ return "".join(parts)
2317
+
2318
+
2319
+ def _summary_digest_group(label: str, items: list, css_class: str) -> str:
2320
+ """用 JSON 既有标题与计数生成紧凑摘要,不创作新的业务判断。"""
2321
+ visible = items[:SUMMARY_DIGEST_ITEM_LIMIT]
2322
+ parts = ['<article class="summary-action-group {0}">'.format(css_class)]
2323
+ parts.append('<h3><span>{0}</span><strong>{1}</strong></h3>'.format(_text(label), _text(len(items))))
2324
+ if visible:
2325
+ parts.append('<ol class="summary-action-list">')
2326
+ parts.extend("<li>{0}</li>".format(_text(item.get("title"))) for item in visible)
2327
+ if len(items) > SUMMARY_DIGEST_ITEM_LIMIT:
2328
+ parts.append("<li>{0}</li>".format(
2329
+ _text("另 {0} 项".format(len(items) - SUMMARY_DIGEST_ITEM_LIMIT))))
2330
+ parts.append("</ol>")
2331
+ else:
2332
+ parts.append('<p class="empty">{0}</p>'.format(_text(EMPTY_TEXT)))
2333
+ parts.append("</article>")
2334
+ return "".join(parts)
2335
+
2336
+
2337
+ def _summary_action_digest(issues: list, manual_items: list) -> str:
2338
+ high = [item for item in issues if item.get("severity") == "high"]
2339
+ other = [item for item in issues if item.get("severity") != "high"]
2340
+ return '<div class="summary-action-digest">{0}{1}{2}</div>'.format(
2341
+ _summary_digest_group("优先处理", high, "priority"),
2342
+ _summary_digest_group("继续核对", other, "follow-up"),
2343
+ _summary_digest_group("人工确认", manual_items, "manual"),
2344
+ )
2345
+
2346
+
2347
+ def render(result: dict, print_trail: bool = None) -> str:
2348
+ """确定性渲染:文本节点只来自受控标签或输入数据,不生成新的业务句子。"""
2349
+ rendered_result = json.loads(json.dumps(result, ensure_ascii=False))
2350
+ file_trace = rendered_result.setdefault("fileTrace", {})
2351
+
2352
+ # sourceDigest:渲染输入的规范化摘要(摘要字段自身清零)
2353
+ file_trace["sourceDigest"] = ""
2354
+ file_trace["embeddedJsonDigest"] = ""
2355
+ file_trace["rendererVersion"] = RENDERER_VERSION
2356
+ source_digest = sha256_hex(canonical_json(rendered_result))
2357
+ file_trace["sourceDigest"] = source_digest
2358
+
2359
+ embedded_digest = sha256_hex(canonical_json(rendered_result))
2360
+ file_trace["embeddedJsonDigest"] = embedded_digest
2361
+
2362
+ payload = canonical_json(rendered_result).replace("<", "\\u003c")
2363
+ audit_task = rendered_result.get("auditTask") or {}
2364
+ summary = rendered_result.get("summary") or {}
2365
+ counts = summary.get("counts") or {}
2366
+ issues = rendered_result.get("issues") or []
2367
+ manual_items = rendered_result.get("manualConfirmationItems") or []
2368
+ audit_basis = rendered_result.get("auditBasis") or {}
2369
+ rules = audit_basis.get("rules") or []
2370
+ comparison = rendered_result.get("reviewComparison") or {}
2371
+ scope = rendered_result.get("scope") or {}
2372
+ trail = rendered_result.get("professionalTrail") or {}
2373
+ policy = rendered_result.get("renderPolicy") or {}
2374
+ if print_trail is None:
2375
+ print_trail = bool(policy.get("printTrail"))
2376
+
2377
+ sorted_issues = sorted(
2378
+ issues,
2379
+ key=lambda item: (
2380
+ {"high": 0, "medium": 1, "low": 2}.get(item.get("severity"), 3),
2381
+ str(item.get("module")),
2382
+ str(item.get("issueId")),
2383
+ ),
2384
+ )
2385
+
2386
+ parts = []
2387
+ parts.append('<main id="audit-report">')
2388
+
2389
+ review_items = comparison.get("reviewItems") or []
2390
+ review_metrics = _review_metrics(review_items)
2391
+ unresolved_claims = sum(item.get("inFileResolution") == "L-unclosed" for item in review_items)
2392
+ report_title = "中瑞世联AI审核报告 - {0}".format(audit_task.get("projectId"))
2393
+
2394
+ # 01 AI 审核综合总结 + 紧凑项目信息
2395
+ parts.append('<header id="project-info">')
2396
+ parts.append("<h1>{0}</h1>".format(_text(report_title)))
2397
+ parts.append('<p class="project-line"><strong>{0}</strong><span>{1}</span><span>{2}</span><span>{3}</span></p>'.format(
2398
+ _text(audit_task.get("projectId")), _text(audit_task.get("reportVersion")),
2399
+ _text(((audit_task.get("profile") or {}).get("objectType"))), _text(_dt(audit_task.get("auditTime", "")))))
2400
+ parts.append(
2401
+ '<p class="badge">{0}</p>'.format(
2402
+ _span(
2403
+ "phase-note",
2404
+ "阶段一独立审核已完成并冻结;阶段二复核对照不改变阶段一结论"
2405
+ if comparison.get("status") == "performed"
2406
+ else "阶段一独立审核已完成并冻结;本次未执行阶段二复核对照",
2407
+ )
2408
+ )
2409
+ )
2410
+ parts.append(
2411
+ '<details class="project-details"><summary>{0}</summary><table class="kv">{1}</table></details>'.format(
2412
+ _text("查看完整项目信息"),
2413
+ _rows(
2414
+ [
2415
+ ("报告流水号ID", audit_task.get("projectId")),
2416
+ ("报告版本", audit_task.get("reportVersion")),
2417
+ ("审核时间", _dt(audit_task.get("auditTime", ""))),
2418
+ ("对象类型", ((audit_task.get("profile") or {}).get("objectType"))),
2419
+ ("评估方法", "、".join((audit_task.get("profile") or {}).get("methods") or [])),
2420
+ ("场景", ((audit_task.get("profile") or {}).get("scenario"))),
2421
+ ("环节", ((audit_task.get("profile") or {}).get("stage"))),
2422
+ ("引擎版本", audit_task.get("engineVersion")),
2423
+ ]
2424
+ )
2425
+ )
2426
+ )
2427
+ parts.append("</header>")
2428
+
2429
+ # 02 审核结果概览
2430
+ parts.append('<section id="summary">')
2431
+ parts.append("<h2>{0}</h2>".format(_text("本次 AI 审核结论")))
2432
+ parts.append('<p class="banner">{0}</p>'.format(_text(OVERALL_LABEL.get(summary.get("overallDecision"), summary.get("overallDecision")))))
2433
+ if unresolved_claims:
2434
+ parts.append('<a class="summary-closure-alert" href="#review-unclosed-alert">{0}</a>'.format(
2435
+ _text("已称修复但实际未落实:{0} 条,须优先回客户".format(unresolved_claims))))
2436
+ parts.append('<div class="summary-kpis">')
2437
+ for label, value, cls, href in (
2438
+ ("AI 检出问题", counts.get("issuesTotal"), "danger", "#actionable-issues"),
2439
+ ("待人工确认", counts.get("pendingConfirmation"), "warning", "#manual-confirmation-items"),
2440
+ ("未检查项", counts.get("notChecked"), "neutral", "#not-checked-items"),
2441
+ ("命中率", _format_rate(review_metrics["hitRate"]) if review_items else "数据不足", "primary", None),
2442
+ ("实际未落实", unresolved_claims, "danger", "#review-unclosed-alert" if unresolved_claims else None),
2443
+ ):
2444
+ if href:
2445
+ parts.append(
2446
+ '<a class="metric-card metric-link {0}" href="{1}"><span>{2}</span><strong>{3}</strong></a>'.format(
2447
+ cls, href, _text(label), _text(value)))
2448
+ else:
2449
+ parts.append('<div class="metric-card {0}"><span>{1}</span><strong>{2}</strong></div>'.format(
2450
+ cls, _text(label), _text(value)))
2451
+ parts.append("</div>")
2452
+ parts.append(_summary_action_digest(sorted_issues, manual_items))
2453
+ parts.append(
2454
+ '<details class="summary-narrative-details"><summary>{0}</summary><p>{1}</p></details>'.format(
2455
+ _text("查看完整 AI 审核说明"), _span("narrative", summary.get("narrative"))))
2456
+ parts.append(_header_hit_rate_caliber(rendered_result.get("reviewComparison") or {}))
2457
+ parts.append('<details class="summary-details"><summary>{0}</summary>{1}</details>'.format(
2458
+ _text("展开问题分布与核验概览"), _summary_breakdown_section(rendered_result)))
2459
+ parts.append("</section>")
2460
+
2461
+ # 03 需要处理的问题
2462
+ parts.append('<section id="actionable-issues">')
2463
+ parts.append("<h2>{0}</h2>".format(_text("AI 检出的问题项")))
2464
+ parts.append(_ai_only_summary(sorted_issues, comparison))
2465
+ if sorted_issues:
2466
+ for index, issue in enumerate(sorted_issues, start=1):
2467
+ parts.append(_issue_card(issue, index, len(sorted_issues)))
2468
+ else:
2469
+ parts.append('<p class="empty">{0}</p>'.format(_text(EMPTY_TEXT)))
2470
+ parts.append("</section>")
2471
+
2472
+ # 04 需要人工确认事项
2473
+ parts.append('<section id="manual-confirmation-items">')
2474
+ parts.append("<h2>{0}</h2>".format(_text("需要人工确认事项")))
2475
+ if manual_items:
2476
+ parts.append('<ol class="numbered-card-list">')
2477
+ for index, item in enumerate(manual_items, start=1):
2478
+ parts.append("<li>{0}</li>".format(_manual_item(item, index, len(manual_items))))
2479
+ parts.append("</ol>")
2480
+ else:
2481
+ parts.append('<p class="empty">{0}</p>'.format(_text(EMPTY_TEXT)))
2482
+ parts.append("</section>")
2483
+
2484
+ # 05 AI 外部数据核验
2485
+ parts.append(_external_data_section(rendered_result.get("externalDataVerification")))
2486
+
2487
+ # 06 人工复核对照
2488
+ parts.append(_review_comparison_section(comparison))
2489
+
2490
+ # 07 AI 审核表现评分卡(按明细重算)
2491
+ parts.append(_scorecard_section(comparison, result.get("selfAuditErrors") or []))
2492
+
2493
+ # 08 本次审核依据(默认收起)
2494
+ parts.append('<section id="audit-basis">')
2495
+ parts.append("<h2>{0}</h2>".format(_text("本次审核依据")))
2496
+ parts.append('<details class="audit-basis-details"><summary>{0}</summary>'.format(
2497
+ _text("展开全部审核依据(共 {0} 条规则)".format(len(rules)))))
2498
+ parts.append(_rule_map(rules))
2499
+ parts.append('<div class="table-scroll"><table><thead><tr>' + "".join(
2500
+ '<th scope="col">{0}</th>'.format(_text(label))
2501
+ for label in ("规则编号", "权威层级", "来源分类", "名称", "版本", "条款", "引用次数")
2502
+ ) + '</tr></thead><tbody>')
2503
+ for rule in sorted(rules, key=lambda item: str(item.get("ruleId"))):
2504
+ parts.append("<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td><td>{4}</td><td>{5}</td><td>{6}</td></tr>".format(
2505
+ _text(rule.get("ruleId")), _text(AUTHORITY_LABEL.get(rule.get("authorityClass"), rule.get("authorityClass"))),
2506
+ _text(SOURCE_TYPE_LABEL.get(rule.get("sourceType"), rule.get("sourceType"))), _text(rule.get("title")),
2507
+ _text(rule.get("version")), _text(rule.get("clause")), _text(rule.get("usageCount"))))
2508
+ parts.append("</tbody></table></div>")
2509
+ kb_files = audit_basis.get("knowledgeBaseFiles") or []
2510
+ if kb_files:
2511
+ parts.append("<h3>{0}</h3><ul>".format(_text("本次知识库文件")))
2512
+ parts.extend("<li>{0}{1}</li>".format(
2513
+ _span("kb-path", item.get("path") or item.get("kbRelativePath")),
2514
+ _span("exported-at", item.get("exportedAt"))) for item in kb_files)
2515
+ parts.append("</ul>")
2516
+ parts.append("</details></section>")
2517
+
2518
+ # 07 审核范围与未检查项
2519
+ parts.append('<section id="scope-and-not-checked">')
2520
+ parts.append("<h2>{0}</h2>".format(_text("审核范围与未检查项")))
2521
+ parts.append(
2522
+ '<table><thead><tr><th>{0}</th><th>{1}</th><th>{2}</th></tr></thead><tbody>'.format(
2523
+ _text("文件"), _text("版本"), _text("可读")
2524
+ )
2525
+ )
2526
+ for item in scope.get("inputs") or []:
2527
+ parts.append(
2528
+ "<tr><td>{0}</td><td>{1}</td><td>{2}</td></tr>".format(
2529
+ _text(item.get("displayName")), _text(item.get("version")), _readable_status(item.get("readable"))
2530
+ )
2531
+ )
2532
+ parts.append("</tbody></table>")
2533
+ parts.append("<h4>{0}</h4>".format(_text("检查域")))
2534
+ parts.append("<ul>" + "".join("<li>{0}</li>".format(_text(domain)) for domain in (scope.get("checkDomains") or [])) + "</ul>")
2535
+ parts.append('<div id="not-checked-items">')
2536
+ parts.append("<h4>{0}</h4>".format(_text("未检查项")))
2537
+ not_checked = scope.get("notCheckedItems") or []
2538
+ if not_checked:
2539
+ parts.append(
2540
+ '<table><thead><tr><th>{0}</th><th>{1}</th><th>{2}</th><th>{3}</th><th>{4}</th><th>{5}</th><th>{6}</th></tr></thead><tbody>'.format(
2541
+ _text("序号"), _text("编号"), _text("未检查项"), _text("原因码"), _text("原因"), _text("影响"), _text("需要的动作")
2542
+ )
2543
+ )
2544
+ for index, item in enumerate(not_checked, start=1):
2545
+ parts.append(_not_checked_item(item, index, len(not_checked)))
2546
+ parts.append("</tbody></table>")
2547
+ else:
2548
+ parts.append('<p class="empty">{0}</p>'.format(_text(EMPTY_TEXT)))
2549
+ parts.append("</div>")
2550
+ limitations = scope.get("limitations") or []
2551
+ if limitations:
2552
+ parts.append("<h4>{0}</h4>".format(_text("能力边界")))
2553
+ parts.append("<ul>" + "".join("<li>{0}</li>".format(_text(item)) for item in limitations) + "</ul>")
2554
+ parts.append("</section>")
2555
+
2556
+ # 08 专业审核轨迹(默认折叠)
2557
+ parts.append('<details id="professional-trail"{0}>'.format(" open" if print_trail else ""))
2558
+ parts.append("<summary>{0}</summary>".format(_text("专业审核轨迹")))
2559
+ parts.append('<section id="rule-snapshot"><h4>{0}</h4><table class="kv">{1}</table></section>'.format(
2560
+ _text("适用规则集快照"),
2561
+ _rows([(key, value) for key, value in sorted((trail.get("applicableRuleSnapshot") or {}).items())]),
2562
+ ))
2563
+ parts.append('<section id="adjudications"><h4>{0}</h4>'.format(_text("逐条裁定表")))
2564
+ adjudications = trail.get("adjudications") or []
2565
+ if adjudications:
2566
+ parts.append('<table><thead><tr><th>{0}</th><th>{1}</th><th>{2}</th></tr></thead><tbody>'.format(
2567
+ _text("编号"), _text("条目"), _text("裁定")))
2568
+ for record in adjudications:
2569
+ parts.append(_adjudication_row(record))
2570
+ parts.append("</tbody></table>")
2571
+ else:
2572
+ parts.append('<p class="empty">{0}</p>'.format(_text(EMPTY_TEXT)))
2573
+ parts.append("</section>")
2574
+ parts.append('<section id="check-records"><h4>{0}</h4>'.format(_text("审核记录清单")))
2575
+ check_records = trail.get("checkRecords") or []
2576
+ if check_records:
2577
+ parts.append('<table><thead><tr><th>{0}</th><th>{1}</th><th>{2}</th><th>{3}</th></tr></thead><tbody>'.format(
2578
+ _text("记录编号"), _text("检查项"), _text("结论"), _text("执行模块")))
2579
+ for record in check_records:
2580
+ parts.append(_check_record_row(record))
2581
+ parts.append("</tbody></table>")
2582
+ else:
2583
+ parts.append('<p class="empty">{0}</p>'.format(_text(EMPTY_TEXT)))
2584
+ parts.append("</section>")
2585
+ parts.append('<section id="comprehensive-comparison"><h4>{0}</h4><table class="kv">{1}</table></section>'.format(
2586
+ _text("复核综合对比"),
2587
+ _rows([(key, value) for key, value in sorted((trail.get("comprehensiveComparison") or {}).items())]),
2588
+ ))
2589
+ parts.append("</details>")
2590
+
2591
+ # 09 文件追溯信息
2592
+ file_trace = rendered_result.get("fileTrace") or {}
2593
+ parts.append('<footer id="file-trace">')
2594
+ parts.append("<h2>{0}</h2>".format(_text("文件追溯信息")))
2595
+ parts.append(
2596
+ '<table class="kv">{0}</table>'.format(
2597
+ _rows(
2598
+ [
2599
+ ("生成时间", file_trace.get("generatedAt")),
2600
+ ("渲染器版本", file_trace.get("rendererVersion")),
2601
+ ("数据摘要", file_trace.get("sourceDigest")),
2602
+ ("嵌入 JSON 摘要", file_trace.get("embeddedJsonDigest")),
2603
+ ]
2604
+ )
2605
+ )
2606
+ )
2607
+ parts.append('<p class="footer-meta">{0}</p>'.format(_text("AI 辅助审核结果,供人工三级复核与员工答复流转;合理性质疑与无依据待判事项须人工确认后方可作为结论依据。")))
2608
+ parts.append("</footer>")
2609
+
2610
+ parts.append("</main>")
2611
+ report_content = "\n".join(parts)
2612
+ template = TEMPLATE_PATH.read_text(encoding="utf-8")
2613
+ template_values = {
2614
+ "document_title": _text(report_title),
2615
+ "report_content": report_content,
2616
+ "embedded_json": payload,
2617
+ }
2618
+ placeholders_seen = set()
2619
+
2620
+ def inject_template_value(match):
2621
+ key = match.group(1)
2622
+ placeholders_seen.add(key)
2623
+ return template_values[key]
2624
+
2625
+ document = re.sub(
2626
+ r"\{\{(document_title|report_content|embedded_json)\}\}",
2627
+ inject_template_value,
2628
+ template,
2629
+ )
2630
+ missing = set(template_values) - placeholders_seen
2631
+ if missing:
2632
+ raise ValueError("HTML 模板缺少挂载点:{0}".format("、".join(sorted(missing))))
2633
+ return document
2634
+
2635
+
2636
+ # --------------------------------------------------------------------------- #
2637
+ # CLI
2638
+ # --------------------------------------------------------------------------- #
2639
+ def embedded_result_from_document(document: str) -> dict:
2640
+ match = re.search(
2641
+ r'<script id="audit-result" type="application/json">(.*?)</script>',
2642
+ document,
2643
+ re.S,
2644
+ )
2645
+ if not match:
2646
+ raise ValueError("HTML 缺少内嵌 AuditResult")
2647
+ return json.loads(match.group(1))
2648
+
2649
+
2650
+ def _write_render_pair(html_path: Path, json_path: Path, document: str, rendered: dict) -> None:
2651
+ """全部校验完成后再写临时文件,避免业务校验失败留下半成品。"""
2652
+ if html_path.resolve() == json_path.resolve():
2653
+ raise ValueError("HTML 与配套 JSON 输出路径不得相同")
2654
+ html_path.parent.mkdir(parents=True, exist_ok=True)
2655
+ json_path.parent.mkdir(parents=True, exist_ok=True)
2656
+ html_temp = html_path.with_name(html_path.name + ".tmp")
2657
+ json_temp = json_path.with_name(json_path.name + ".tmp")
2658
+ try:
2659
+ html_temp.write_text(document, encoding="utf-8")
2660
+ json_temp.write_text(canonical_json(rendered) + "\n", encoding="utf-8")
2661
+ json_temp.replace(json_path)
2662
+ html_temp.replace(html_path)
2663
+ finally:
2664
+ for path in (html_temp, json_temp):
2665
+ if path.exists():
2666
+ path.unlink()
2667
+
2668
+
2669
+ def _cmd_validate(args) -> int:
2670
+ result = load_result(Path(args.path))
2671
+ errors = validate(result, rendered=args.rendered, expect_renderer=args.rendered)
2672
+ if errors:
2673
+ for error in errors:
2674
+ print("ERROR: {0}".format(error), file=sys.stderr)
2675
+ print("校验失败:{0} 项".format(len(errors)), file=sys.stderr)
2676
+ return 1
2677
+ print("校验通过:{0}".format(args.path))
2678
+ return 0
2679
+
2680
+
2681
+ def _cmd_digest(args) -> int:
2682
+ """阶段一冻结指纹:对冻结快照做规范化序列化后取 sha256(写入 phaseControl.phase1Digest)。"""
2683
+ result = load_result(Path(args.path))
2684
+ print(sha256_hex(canonical_json(result)))
2685
+ return 0
2686
+
2687
+
2688
+ def _cmd_render(args) -> int:
2689
+ result = load_result(Path(args.path))
2690
+ errors = validate(result, rendered=False)
2691
+ if errors:
2692
+ for error in errors:
2693
+ print("ERROR: {0}".format(error), file=sys.stderr)
2694
+ print("JSON 校验失败,已退回修正,未生成 HTML:{0} 项".format(len(errors)), file=sys.stderr)
2695
+ return 1
2696
+ document = render(result)
2697
+ rendered = embedded_result_from_document(document)
2698
+ post_errors = validate(rendered, rendered=True, expect_renderer=True)
2699
+ if post_errors:
2700
+ for error in post_errors:
2701
+ print("ERROR: {0}".format(error), file=sys.stderr)
2702
+ print("渲染后自检失败,未写出交付文件:{0} 项".format(len(post_errors)), file=sys.stderr)
2703
+ return 1
2704
+ out_path = Path(args.out)
2705
+ json_out_path = Path(args.json_out)
2706
+ _write_render_pair(out_path, json_out_path, document, rendered)
2707
+ print("已渲染:{0}".format(out_path))
2708
+ print("已归档:{0}".format(json_out_path))
2709
+ return 0
2710
+
2711
+
2712
+ def main(argv=None) -> int:
2713
+ parser = argparse.ArgumentParser(description="CRWU AuditResult 校验与单文件 HTML 渲染(送达规范 v1.6)")
2714
+ subparsers = parser.add_subparsers(dest="command", required=True)
2715
+
2716
+ validate_parser = subparsers.add_parser("validate", help="校验 AuditResult JSON")
2717
+ validate_parser.add_argument("path", help="AuditResult JSON 路径")
2718
+ validate_parser.add_argument("--rendered", action="store_true", help="按渲染后状态校验(要求摘要已回填)")
2719
+ validate_parser.set_defaults(func=_cmd_validate)
2720
+
2721
+ digest_parser = subparsers.add_parser("digest", help="计算阶段一冻结指纹(规范化 JSON 的 sha256)")
2722
+ digest_parser.add_argument("path", help="冻结快照 JSON 路径")
2723
+ digest_parser.set_defaults(func=_cmd_digest)
2724
+
2725
+ render_parser = subparsers.add_parser("render", help="渲染自包含单文件 HTML")
2726
+ render_parser.add_argument("path", help="AuditResult JSON 路径")
2727
+ render_parser.add_argument("--out", required=True, help="输出 HTML 路径")
2728
+ render_parser.add_argument("--json-out", required=True, help="输出与 HTML 内嵌对象完全一致的 AuditResult JSON")
2729
+ render_parser.set_defaults(func=_cmd_render)
2730
+
2731
+ args = parser.parse_args(argv)
2732
+ return args.func(args)
2733
+
2734
+
2735
+ if __name__ == "__main__":
2736
+ sys.exit(main())