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,1678 @@
1
+ #!/usr/bin/env python3
2
+ """AuditResult 校验与 HTML 渲染的契约测试(送达规范 v1.6)。
3
+
4
+ 运行:在技能目录内 `python3 scripts/test_audit_delivery.py`
5
+ 本测试**自洽**:只依赖本技能 `scripts/` 内的脚本、schema 与样例,可随技能一起安装。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import contextlib
12
+ import copy
13
+ import html
14
+ import io
15
+ import json
16
+ import re
17
+ import sys
18
+ import tempfile
19
+ import unittest
20
+ from pathlib import Path
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
23
+
24
+ import audit_delivery as delivery # noqa: E402
25
+
26
+ MODULE_PATH = Path(__file__).resolve().parent / "audit_delivery.py"
27
+ SAMPLE_PATH = Path(__file__).resolve().parent / "examples" / "audit-result.sample.json"
28
+ TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "template" / "audit-report.html"
29
+
30
+ # DeepSeek Harness 的 iFinD 取数入口是宿主的结构化 Tool(不是第三方技能目录)。
31
+ # 旧架构写成"第三方技能";本文件按**形态**判定(kebab-case 名称 + 技能),
32
+ # 不在此保留旧技能名 —— 本测试要随技能单独安装,也不该成为需要豁免的残留。
33
+ DSH_IFIND_TOOL = "crwu_audit_ifind_query"
34
+ SKILL_ENTRY_RE = re.compile(r"[a-z0-9][a-z0-9-]*\s*技能")
35
+
36
+ REGION_ORDER = [
37
+ "project-info",
38
+ "summary",
39
+ "actionable-issues",
40
+ "manual-confirmation-items",
41
+ "external-data-verification",
42
+ "review-comparison",
43
+ "ai-scorecard",
44
+ "audit-basis",
45
+ "scope-and-not-checked",
46
+ "professional-trail",
47
+ "file-trace",
48
+ ]
49
+
50
+
51
+ def load_sample() -> dict:
52
+ with SAMPLE_PATH.open("r", encoding="utf-8") as handle:
53
+ return json.load(handle)
54
+
55
+
56
+ def with_structured_review_comparison() -> dict:
57
+ """构造新版逐条复核事实源;统计应全部能从 reviewItems 重算。"""
58
+ result = load_sample()
59
+ comparison = result["reviewComparison"]
60
+ comparison["reviewFiles"] = [
61
+ {
62
+ "order": 1,
63
+ "level": "一级复核",
64
+ "displayName": "01-一级复核意见.docx",
65
+ "version": "v1",
66
+ },
67
+ {
68
+ "order": 2,
69
+ "level": "二级复核",
70
+ "displayName": "02-二级复核意见.docx",
71
+ "version": "v2",
72
+ },
73
+ ]
74
+ comparison["reviewItems"] = [
75
+ {
76
+ "itemId": "RV-001",
77
+ "title": "报告结论与测算表不一致",
78
+ "module": "数据勾稽",
79
+ "reviewLevel": "一级复核",
80
+ "matchStatus": "exact",
81
+ "hitExplanation": {
82
+ "reviewerScope": "specific",
83
+ "matchedAspects": ["报告结论与测算表不一致,AI 已定位到同一处差异"],
84
+ "unmatchedAspects": [],
85
+ "rationale": "AI 复现了复核的同一判断。",
86
+ },
87
+ "linkedIssueIds": ["ISS-DC-003"],
88
+ "reviewerEvidence": {
89
+ "file": "01-一级复核意见.docx",
90
+ "locator": "第1条",
91
+ "quote": "报告结论与测算表不一致。",
92
+ },
93
+ "inFileResolution": "L-open",
94
+ "inFileEvidence": {
95
+ "file": "报告-复审版.docx",
96
+ "locator": "第12页",
97
+ "quote": "结论仍为1,244.00。",
98
+ },
99
+ "handling": "继续修改",
100
+ },
101
+ {
102
+ "itemId": "RV-002",
103
+ "title": "披露文字已补充",
104
+ "module": "报告披露",
105
+ "reviewLevel": "一级复核",
106
+ "matchStatus": "miss",
107
+ "hitExplanation": {
108
+ "reviewerScope": "specific",
109
+ "matchedAspects": [],
110
+ "unmatchedAspects": ["评估范围说明的补充情况"],
111
+ "rationale": "AI 未就该条提出任何判断。",
112
+ },
113
+ "linkedIssueIds": [],
114
+ "reviewerEvidence": {
115
+ "file": "01-一级复核意见.docx",
116
+ "locator": "第2条",
117
+ "quote": "补充评估范围说明。",
118
+ },
119
+ "inFileResolution": "L-resolved",
120
+ "inFileEvidence": {
121
+ "file": "报告-复审版.docx",
122
+ "locator": "第5页",
123
+ "quote": "已补充完整。",
124
+ },
125
+ "handling": "无需继续处理",
126
+ },
127
+ {
128
+ "itemId": "RV-003",
129
+ "title": "答复称已修复但仍未落实",
130
+ "module": "市场法",
131
+ "reviewLevel": "二级复核",
132
+ "matchStatus": "miss",
133
+ "hitExplanation": {
134
+ "reviewerScope": "specific",
135
+ "matchedAspects": [],
136
+ "unmatchedAspects": ["时间修正依据的补充情况"],
137
+ "rationale": "AI 未就该条提出任何判断。",
138
+ },
139
+ "linkedIssueIds": [],
140
+ "reviewerEvidence": {
141
+ "file": "02-二级复核意见.docx",
142
+ "locator": "第1条",
143
+ "quote": "补充时间修正依据。",
144
+ },
145
+ "inFileResolution": "L-unclosed",
146
+ "inFileEvidence": {
147
+ "file": "评估说明-复审版.docx",
148
+ "locator": "市场法章节",
149
+ "quote": "仍未见时间修正依据。",
150
+ },
151
+ "closureEvidence": {
152
+ "file": "项目答复.docx",
153
+ "locator": "答复1",
154
+ "quote": "已补充。",
155
+ },
156
+ "handling": "优先回客户",
157
+ },
158
+ {
159
+ "itemId": "RV-004",
160
+ "title": "扫描件内容无法核验",
161
+ "module": "市场法",
162
+ "reviewLevel": "二级复核",
163
+ "matchStatus": "partial",
164
+ "hitExplanation": {
165
+ "reviewerScope": "general",
166
+ "matchedAspects": ["扫描件附件的核验必要性(AI 已出 ISS-MKT-007)"],
167
+ "unmatchedAspects": ["附件逐页内容的可读性判定"],
168
+ "rationale": "复核条目笼统,AI 覆盖了核验必要性与部分内容,未逐页核验。",
169
+ },
170
+ "linkedIssueIds": ["ISS-MKT-007"],
171
+ "reviewerEvidence": {
172
+ "file": "02-二级复核意见.docx",
173
+ "locator": "第2条",
174
+ "quote": "核验扫描件附件。",
175
+ },
176
+ "inFileResolution": "L-uncheckable",
177
+ "handling": "补充可读文件",
178
+ },
179
+ ]
180
+ comparison["metrics"] = {
181
+ "total": 4,
182
+ "resolved": 1,
183
+ "uncheckable": 1,
184
+ "evaluable": 2,
185
+ "exactHits": 1,
186
+ "partialHits": 0,
187
+ "misses": 1,
188
+ "hitRate": 50.0,
189
+ "exactRate": 50.0,
190
+ "partialRate": 0.0,
191
+ "missRate": 50.0,
192
+ "strictHitRate": 50.0,
193
+ "coverageRate": 50.0,
194
+ }
195
+ return result
196
+
197
+
198
+ def module_string_constants() -> set:
199
+ tree = ast.parse(MODULE_PATH.read_text(encoding="utf-8"))
200
+ values = set()
201
+ for node in ast.walk(tree):
202
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
203
+ values.add(node.value)
204
+ if TEMPLATE_PATH.is_file():
205
+ values.update(text_nodes(TEMPLATE_PATH.read_text(encoding="utf-8")))
206
+ return values
207
+
208
+
209
+ def strip_css_and_scripts(document: str) -> str:
210
+ document = re.sub(r"<style>.*?</style>", " ", document, flags=re.S)
211
+ document = re.sub(r"<script.*?</script>", " ", document, flags=re.S)
212
+ return document
213
+
214
+
215
+ def text_nodes(document: str):
216
+ body = strip_css_and_scripts(document)
217
+ for raw in re.findall(r">([^<>]+)<", body):
218
+ value = html.unescape(raw).strip()
219
+ if value:
220
+ yield value
221
+
222
+
223
+ class AuditResultValidationTest(unittest.TestCase):
224
+ def test_sample_passes(self):
225
+ self.assertEqual([], delivery.validate(load_sample()))
226
+
227
+ def test_missing_material_evidence_is_rejected(self):
228
+ result = load_sample()
229
+ result["issues"][0]["materialEvidence"] = []
230
+ errors = delivery.validate(result)
231
+ self.assertTrue(any("materialEvidence 至少一条" in error for error in errors), errors)
232
+
233
+ def test_rule_defect_fail_requires_rule_evidence(self):
234
+ result = load_sample()
235
+ result["issues"][0]["ruleEvidence"] = []
236
+ result["auditBasis"]["rules"][0]["usedByIssueIds"] = []
237
+ result["auditBasis"]["rules"][0]["usageCount"] = 0
238
+ errors = delivery.validate(result)
239
+ self.assertTrue(any("必须同时具备规则证据与材料证据" in error for error in errors), errors)
240
+
241
+ def test_counts_must_be_recomputable(self):
242
+ result = load_sample()
243
+ result["summary"]["counts"]["fail"] = 0
244
+ errors = delivery.validate(result)
245
+ self.assertTrue(any("summary.counts.fail" in error for error in errors), errors)
246
+
247
+ def test_phase_order_is_enforced(self):
248
+ result = load_sample()
249
+ result["phaseControl"]["reviewAccessedAt"] = "2026-09-09T09:00:00+08:00"
250
+ errors = delivery.validate(result)
251
+ self.assertTrue(any("reviewAccessedAt 必须晚于 phase1FrozenAt" in error for error in errors), errors)
252
+
253
+ def test_phase2_requires_review_comparison_per_issue(self):
254
+ result = load_sample()
255
+ result["issues"][0]["reviewComparison"] = {"status": "not_performed"}
256
+ errors = delivery.validate(result)
257
+ self.assertTrue(any("必须具有 reviewComparison" in error for error in errors), errors)
258
+
259
+ def test_external_formal_must_not_claim_current_version(self):
260
+ result = load_sample()
261
+ rule = result["auditBasis"]["rules"][0]
262
+ rule["version"] = "现行"
263
+ errors = delivery.validate(result)
264
+ self.assertTrue(any("不得宣称" in error for error in errors), errors)
265
+
266
+ def test_usage_count_and_used_by_are_recomputed(self):
267
+ result = load_sample()
268
+ result["auditBasis"]["rules"][0]["usageCount"] = 7
269
+ errors = delivery.validate(result)
270
+ self.assertTrue(any("usageCount 必须可由明细重算" in error for error in errors), errors)
271
+
272
+ def test_review_bands_must_match_details(self):
273
+ result = load_sample()
274
+ result["reviewComparison"]["bands"]["overlap"] = 0
275
+ errors = delivery.validate(result)
276
+ self.assertTrue(any("bands.overlap" in error for error in errors), errors)
277
+
278
+ def test_kb_relative_path_must_be_relative(self):
279
+ result = load_sample()
280
+ result["auditBasis"]["rules"][0]["kbRelativePath"] = "/Users/example/kb/rule.md"
281
+ errors = delivery.validate(result)
282
+ self.assertTrue(any("kbRelativePath" in error or "绝对路径" in error for error in errors), errors)
283
+
284
+ def test_absolute_path_and_node_id_are_forbidden(self):
285
+ result = load_sample()
286
+ result["issues"][0]["problemDescription"] = "见 /Users/example/报告.docx"
287
+ errors = delivery.validate(result)
288
+ self.assertTrue(any("绝对路径" in error for error in errors), errors)
289
+
290
+ result = load_sample()
291
+ result["auditTask"]["profile"]["routeProfile"] = {"nodeId": "abc123"}
292
+ errors = delivery.validate(result)
293
+ self.assertTrue(any("nodeId" in error for error in errors), errors)
294
+
295
+ def _problem_errors(self, description):
296
+ result = load_sample()
297
+ result["issues"][0]["problemDescription"] = description
298
+ return delivery.validate(result)
299
+
300
+ def test_problem_description_must_be_two_block_plain_language(self):
301
+ """§4.6:首句结论 + 员工话明细两段式;样例即为合规写法。"""
302
+ self.assertEqual([], self._problem_errors(load_sample()["issues"][0]["problemDescription"]))
303
+
304
+ errors = self._problem_errors("可比案例交易日期与基准日存在时间差异,未见修正,也未见不调整的分析与理由。")
305
+ self.assertTrue(any("必须是两段式" in error for error in errors), errors)
306
+
307
+ errors = self._problem_errors(
308
+ "三个案例都没有做交易时间修正。\n"
309
+ "案例成交日与基准日相差越远,价格可比性越弱;现在看不出这个差异有没有影响结论。\n"
310
+ "请打开 市场法测算表.xlsx 的「市场法」表 F18:F22 核对。"
311
+ )
312
+ self.assertEqual([], errors)
313
+
314
+ def test_problem_description_requires_at_least_two_detail_lines(self):
315
+ result = load_sample()
316
+ issue = result["issues"][0]
317
+ issue["problemDescription"] = (
318
+ "三个可比案例均未进行时间修正。\n"
319
+ "请打开 市场法测算表.xlsx 的「市场法」表 F18:F22 核对。"
320
+ )
321
+ errors = delivery.validate(result)
322
+ self.assertTrue(
323
+ any("明细 1 行,少于 2 行下限" in error for error in errors),
324
+ errors,
325
+ )
326
+
327
+ def test_problem_description_limits_headline_and_detail_length(self):
328
+ result = load_sample()
329
+ issue = result["issues"][0]
330
+ location = "请打开 市场法测算表.xlsx 的「市场法」表 F18:F22,对照 评估说明.docx 第 35 页核对。"
331
+ issue["problemDescription"] = (
332
+ "三个可比案例的交易日期与评估基准日之间存在明显的时间差异,但市场法测算表里没有任何时间修正系数,也没有关于不调整理由的说明。\n"
333
+ "案例成交日与基准日相差越远,价格可比性越弱,现在看不出这个差异有没有影响结论。\n"
334
+ "{0}".format(location)
335
+ )
336
+ errors = delivery.validate(result)
337
+ self.assertTrue(any("首句" in error and "超过 60 字上限" in error for error in errors), errors)
338
+
339
+ issue["problemDescription"] = "三个案例都没有做交易时间修正。\n" + "、" * 100 + "。"
340
+ errors = delivery.validate(result)
341
+ self.assertTrue(any("明细第 1 行" in error and "字上限" in error for error in errors), errors)
342
+
343
+ issue["problemDescription"] = "三个案例都没有做交易时间修正。\n" + "\n".join([location] * 5)
344
+ errors = delivery.validate(result)
345
+ self.assertTrue(any("超过 4 行上限" in error for error in errors), errors)
346
+
347
+ over_budget_line = "、" * 90 + "汇总!B12"
348
+ over_budget_headline = "、" * 59 + "。"
349
+ issue["problemDescription"] = over_budget_headline + "\n" + "\n".join([over_budget_line] * 4)
350
+ errors = delivery.validate(result)
351
+ self.assertTrue(any("字上限(规则要求与判定链放 gapAnalysis)" in error for error in errors), errors)
352
+
353
+ def test_problem_description_detail_line_must_end_with_sentence_or_locator(self):
354
+ result = load_sample()
355
+ result["issues"][0]["problemDescription"] = (
356
+ "三个案例都没有做交易时间修正。\n"
357
+ "案例成交日与基准日相差越远价格可比性越弱现在看不出这个差异有没有影响结论\n"
358
+ "请打开 市场法测算表.xlsx 的「市场法」表 F18:F22 核对。"
359
+ )
360
+ errors = delivery.validate(result)
361
+ self.assertTrue(any("明细第 1 行" in error and "可核对落点" in error for error in errors), errors)
362
+
363
+ def test_problem_description_headline_must_not_leak_internal_codes(self):
364
+ result = load_sample()
365
+ result["issues"][0]["problemDescription"] = (
366
+ "见 RULE-DC-004 与 06-规则库/清单-数据校对.md。\n"
367
+ "请打开 市场法测算表.xlsx 的「市场法」表 F18:F22 核对。"
368
+ )
369
+ errors = delivery.validate(result)
370
+ self.assertTrue(any("首句含" in error and "规则编号" in error for error in errors), errors)
371
+ self.assertTrue(any("知识库相对路径" in error for error in errors), errors)
372
+
373
+ def test_numeric_ranges_are_not_mistaken_for_knowledge_base_paths(self):
374
+ """回测修正:`0-50/51-100/101-150` 这类区间值与 `L206-L302` 不是知识库路径。"""
375
+ errors = self._problem_errors(
376
+ "面积修正分级只在底稿,说明未披露。\n"
377
+ "妇女表分级为 0-50/51-100/101-150/151-200→100/99/98/97,与文澜表区间不同。\n"
378
+ "请打开 测算明细表.xlsx 的「标准化处理-妇女」表 W16:X19 与说明 L206-L302 核对。"
379
+ )
380
+ self.assertEqual([], errors)
381
+
382
+ def test_problem_description_detail_must_name_a_material_locator(self):
383
+ errors = self._problem_errors(
384
+ "汇总表数据与报告结论对不上。\n"
385
+ "两个数字相差 10.00,报告正文没有说明差额来源。\n"
386
+ "请项目负责人重新核实并补充说明。"
387
+ )
388
+ self.assertTrue(any("明细未给出可核对的文件与位置" in error for error in errors), errors)
389
+
390
+ def test_problem_description_rejects_absolute_paths(self):
391
+ errors = self._problem_errors(
392
+ "汇总表数据与报告结论对不上。\n"
393
+ "两个数字相差 10.00,报告正文没有说明差额来源。\n"
394
+ "见 /Users/example/报告.docx 与 测算明细表.xlsx 汇总!B12。"
395
+ )
396
+ self.assertTrue(any("绝对路径" in error for error in errors), errors)
397
+
398
+ def test_problem_description_headline_rejects_raw_formula_and_cell_dump(self):
399
+ """回测(2026-302514-LX10034-BG8697):首句塞公式串与区域坐标时员工读不懂。"""
400
+ result = load_sample()
401
+ result["issues"][0]["problemDescription"] = (
402
+ "同一评估对象的年租金存在两个值,04计算底稿!租金评估明细表-妇女 AD11=SUM(AD6:AD10)=472900。\n"
403
+ "03评估明细表 同一 5 个单元合计 483,800 元,相差 10,900 元。\n"
404
+ "请打开 03评估明细表-文澜.xlsx 的「明细表」表 AB6:AB10 核对。"
405
+ )
406
+ errors = delivery.validate(result)
407
+ self.assertTrue(any("首句含公式/单元格坐标/文件定位串" in error for error in errors), errors)
408
+
409
+ def test_real_report_description_styles_are_all_rejected(self):
410
+ """回测回归:真实项目里“员工读不懂”的四种典型写法都必须被拦下。"""
411
+ styles = {
412
+ "坐标串堆叠": (
413
+ "年租金在两张表中不一致:04计算底稿!租金评估明细表-妇女 AD11=472900,"
414
+ "03评估明细表!明细表 AB38=2944800,两者差 10,900 元。"
415
+ ),
416
+ "公式串": (
417
+ "评估明细汇总表 F8=SUM(明细表!AB6:AB10)/10000=48.38,与底稿口径不一致,"
418
+ "报告结论采用 294.48 万元。"
419
+ ),
420
+ "案例目录相对路径": (
421
+ "工作版/03评估明细表-文澜.xlsx 的 AB6:AB10 与工作版/04计算底稿 的 AD6:AD10 不一致,"
422
+ "相差 10,900 元,报告未说明。"
423
+ ),
424
+ }
425
+ for name, description in styles.items():
426
+ with self.subTest(style=name):
427
+ errors = self._problem_errors(description)
428
+ self.assertTrue(
429
+ any("必须是两段式" in error or "首句" in error for error in errors),
430
+ "{0} 未被拦截:{1}".format(name, errors),
431
+ )
432
+
433
+ def test_not_checked_reason_code_enum(self):
434
+ result = load_sample()
435
+ result["scope"]["notCheckedItems"][0]["reasonCode"] = "whatever"
436
+ errors = delivery.validate(result)
437
+ self.assertTrue(any("reasonCode 取值非法" in error for error in errors), errors)
438
+
439
+ def test_knowledge_base_files_require_path_and_exported_at(self):
440
+ """渲染器按 path 显示知识库文件名;误用 kbRelativePath 等别名会只显示时间。"""
441
+ result = load_sample()
442
+ result["auditBasis"]["knowledgeBaseFiles"] = [
443
+ {"kbRelativePath": "06-规则库/某规则", "exportedAt": "2026-09-10T09:00:00+08:00"}
444
+ ]
445
+ errors = delivery.validate(result)
446
+ self.assertTrue(any("knowledgeBaseFiles[0].path" in error for error in errors), errors)
447
+
448
+ result = load_sample()
449
+ result["auditBasis"]["knowledgeBaseFiles"] = [{"path": "06-规则库/某规则"}]
450
+ errors = delivery.validate(result)
451
+ self.assertTrue(any("knowledgeBaseFiles[0].exportedAt" in error for error in errors), errors)
452
+
453
+ result = load_sample()
454
+ result["auditBasis"]["knowledgeBaseFiles"] = [
455
+ {"path": "/Users/example/kb/rule.md", "exportedAt": "2026-09-10T09:00:00+08:00"}
456
+ ]
457
+ errors = delivery.validate(result)
458
+ self.assertTrue(any("knowledgeBaseFiles[0].path" in error for error in errors), errors)
459
+
460
+ result = load_sample()
461
+ result["auditBasis"]["knowledgeBaseFiles"] = ["06-规则库/某规则"]
462
+ errors = delivery.validate(result)
463
+ self.assertTrue(any("knowledgeBaseFiles[0] 必须是对象" in error for error in errors), errors)
464
+
465
+ def test_knowledge_base_file_path_is_rendered(self):
466
+ result = load_sample()
467
+ document = delivery.render(result)
468
+ self.assertIn("06-规则库/M-数据对齐-勾稽与一致性/清单-数据校对.md", document)
469
+ self.assertNotIn('<span class="kb-path"></span>', document)
470
+
471
+ def test_adjudication_row_keys_are_required(self):
472
+ """裁定表按 recordId/itemRef/ruling(result) 渲染;三列齐缺会整表空白。"""
473
+ base = load_sample()
474
+ self.assertTrue(base["professionalTrail"]["adjudications"])
475
+
476
+ result = load_sample()
477
+ result["professionalTrail"]["adjudications"] = [{"applicability": "适用", "executed": True}]
478
+ errors = delivery.validate(result)
479
+ self.assertTrue(any("adjudications[0].recordId" in error for error in errors), errors)
480
+ self.assertTrue(any("adjudications[0].itemRef" in error for error in errors), errors)
481
+ self.assertTrue(any("adjudications[0] 必须给出" in error for error in errors), errors)
482
+
483
+ result = load_sample()
484
+ result["professionalTrail"]["adjudications"] = ["不是对象"]
485
+ errors = delivery.validate(result)
486
+ self.assertTrue(any("adjudications[0] 必须是对象" in error for error in errors), errors)
487
+
488
+ def test_adjudication_row_falls_back_to_legacy_keys(self):
489
+ result = load_sample()
490
+ result["professionalTrail"]["adjudications"] = [
491
+ {"chkId": "CHK-LEGACY", "checkItem": "旧键位条目", "ruling": "不符合:示例"}
492
+ ]
493
+ errors = delivery.validate(result)
494
+ self.assertFalse([e for e in errors if "adjudications" in e], errors)
495
+ document = delivery.render(result)
496
+ self.assertIn("CHK-LEGACY", document)
497
+ self.assertIn("旧键位条目", document)
498
+ self.assertIn("不符合:示例", document)
499
+ self.assertNotIn('<td></td><td></td><td></td>', document)
500
+
501
+ def test_missing_in_file_evidence_is_not_rendered_blank(self):
502
+ """L-uncheckable 无在件位置时应显示受控标签,不得留空白单元格(§10.3)。"""
503
+ result = load_sample()
504
+ item = result["reviewComparison"]["reviewerOnlyItems"][0]
505
+ item["inFileResolution"] = "L-uncheckable"
506
+ item.pop("inFileEvidence", None)
507
+ errors = delivery.validate(result)
508
+ self.assertFalse([e for e in errors if "inFileEvidence" in e], errors)
509
+ document = delivery.render(result)
510
+ self.assertIn(delivery.IN_FILE_ABSENT_LABEL, document)
511
+ self.assertNotIn('<td class="value"></td>', document)
512
+
513
+
514
+ class AuditResultRenderTest(unittest.TestCase):
515
+ def setUp(self):
516
+ self.result = load_sample()
517
+ self.document = delivery.render(self.result)
518
+
519
+ def test_sections_in_required_order(self):
520
+ positions = []
521
+ for region in REGION_ORDER:
522
+ index = self.document.find('id="{0}"'.format(region))
523
+ self.assertNotEqual(-1, index, "缺少区域 {0}".format(region))
524
+ positions.append(index)
525
+ self.assertEqual(sorted(positions), positions, "区域顺序必须符合 §3 交付结构")
526
+
527
+ def test_manual_confirmation_directly_follows_ai_issues_in_body_and_catalog(self):
528
+ document = delivery.render(load_sample())
529
+ body = re.search(r'<main id="audit-report">(.*?)</main>', document, re.S).group(1)
530
+ self.assertRegex(
531
+ body,
532
+ re.compile(
533
+ r'<section id="actionable-issues">.*?</section>\s*'
534
+ r'<section id="manual-confirmation-items">',
535
+ re.S,
536
+ ),
537
+ )
538
+ catalog = re.search(r'<nav class="report-catalog".*?</nav>', document, re.S).group(0)
539
+ self.assertLess(
540
+ catalog.find('href="#actionable-issues"'),
541
+ catalog.find('href="#manual-confirmation-items"'),
542
+ )
543
+ self.assertLess(
544
+ catalog.find('href="#manual-confirmation-items"'),
545
+ catalog.find('href="#external-data-verification"'),
546
+ )
547
+
548
+ def test_renderer_uses_standalone_template_with_sidebar_catalog(self):
549
+ self.assertTrue(TEMPLATE_PATH.is_file(), "HTML 模板必须独立存放在 skill/template/")
550
+ template = TEMPLATE_PATH.read_text(encoding="utf-8")
551
+ self.assertIn("{{report_content}}", template)
552
+ self.assertIn('class="report-catalog"', template)
553
+ self.assertIn('aria-label="报告目录"', template)
554
+ self.assertIn('class="report-shell"', template)
555
+ for region in REGION_ORDER:
556
+ self.assertIn('href="#{0}"'.format(region), template, "目录缺少区域 {0}".format(region))
557
+ self.assertIn('href="#{0}"'.format(region), self.document, "渲染结果缺少目录项 {0}".format(region))
558
+
559
+ def test_sidebar_is_responsive_and_hidden_for_print(self):
560
+ template = TEMPLATE_PATH.read_text(encoding="utf-8")
561
+ self.assertIn("position: sticky", template)
562
+ self.assertIn("@media (max-width: 960px)", template)
563
+ self.assertRegex(template, re.compile(r"@media print\s*\{.*?\.report-catalog\s*\{[^}]*display:\s*none", re.S))
564
+
565
+ def test_self_contained_and_offline_safe(self):
566
+ for token in ("http://", "https://", "<link", "script src", "@import"):
567
+ self.assertNotIn(token, self.document)
568
+ self.assertIn("<style>", self.document)
569
+ self.assertIn('type="application/json"', self.document)
570
+
571
+ def test_a4_print_and_black_white_readability(self):
572
+ self.assertIn("@page { size: A4", self.document)
573
+ self.assertIn("break-inside: avoid", self.document)
574
+ self.assertIn("thead { display: table-header-group; }", self.document)
575
+ for issue in self.result["issues"]:
576
+ label = delivery.SEVERITY_LABEL[issue["severity"]]
577
+ self.assertIn('severity-label">严重程度:{0}</span>'.format(label), self.document)
578
+ for severity in delivery.SEVERITY_CLASS.values():
579
+ self.assertIn(severity, self.document)
580
+
581
+ def test_severity_palette_is_professional_and_not_full_card_tinted(self):
582
+ template = TEMPLATE_PATH.read_text(encoding="utf-8")
583
+ for color in ("#c2413b", "#b86b12", "#287596"):
584
+ self.assertIn(color, template.lower())
585
+ self.assertIn(".issue-card.sev-high { border-left-color: var(--high); }", template)
586
+ self.assertNotIn("linear-gradient(90deg, var(--high-soft)", template)
587
+
588
+ def test_professional_trail_folded_by_default(self):
589
+ self.assertIn('<details id="professional-trail">', self.document)
590
+
591
+ def test_scope_readability_uses_clear_icons_instead_of_booleans(self):
592
+ start = self.document.find('id="scope-and-not-checked"')
593
+ end = self.document.find('</section>', start)
594
+ scope = self.document[start:end]
595
+ self.assertIn('class="readable-status is-readable"', scope)
596
+ self.assertIn('aria-label="可读">✅</span>', scope)
597
+ self.assertIn('class="readable-status is-unreadable"', scope)
598
+ self.assertIn('aria-label="不可读">❌</span>', scope)
599
+ self.assertNotIn(">True<", scope)
600
+ self.assertNotIn(">False<", scope)
601
+
602
+ def test_header_leads_with_ai_audit_summary_and_compact_metrics(self):
603
+ document = delivery.render(with_structured_review_comparison())
604
+ expected_title = "中瑞世联AI审核报告 - PRJ-2026-0001"
605
+ self.assertIn("<title>{0}</title>".format(expected_title), document)
606
+ self.assertIn("<h1>{0}</h1>".format(expected_title), document)
607
+ for label in ("AI 检出问题", "待人工确认", "未检查项", "精确命中率", "实际未落实"):
608
+ self.assertIn(label, document)
609
+
610
+ def test_summary_narrative_is_folded_and_action_digest_uses_json_facts(self):
611
+ result = load_sample()
612
+ document = delivery.render(result)
613
+ summary_start = document.find('id="summary"')
614
+ summary_end = document.find('</section>', summary_start)
615
+ summary = document[summary_start:summary_end]
616
+ narrative = result["summary"]["narrative"]
617
+ self.assertIn('class="summary-action-digest"', summary)
618
+ self.assertIn("优先处理", summary)
619
+ self.assertIn("继续核对", summary)
620
+ self.assertIn("人工确认", summary)
621
+ self.assertIn(result["issues"][0]["title"], summary)
622
+ self.assertIn(result["manualConfirmationItems"][0]["title"], summary)
623
+ self.assertRegex(
624
+ summary,
625
+ re.compile(
626
+ r'<details class="summary-narrative-details"><summary>查看完整 AI 审核说明</summary>.*?'
627
+ + re.escape(narrative),
628
+ re.S,
629
+ ),
630
+ )
631
+
632
+ def test_issue_related_sections_show_visible_sequence_numbers(self):
633
+ document = delivery.render(with_structured_review_comparison())
634
+ for label in (
635
+ "问题 01/02",
636
+ "问题 02/02",
637
+ "确认 01/01",
638
+ "复核 01/04",
639
+ "复核 04/04",
640
+ "未检查 01/01",
641
+ ):
642
+ self.assertIn('<span class="item-index">{0}</span>'.format(label), document)
643
+ self.assertIn('<ol class="summary-action-list">', document)
644
+
645
+ def test_action_kpis_link_to_their_json_backed_sections(self):
646
+ document = delivery.render(load_sample())
647
+ for href, label, value in (
648
+ ("#actionable-issues", "AI 检出问题", 2),
649
+ ("#manual-confirmation-items", "待人工确认", 1),
650
+ ("#not-checked-items", "未检查项", 1),
651
+ ):
652
+ self.assertRegex(
653
+ document,
654
+ re.compile(
655
+ r'<a class="metric-card metric-link[^"]*" href="{0}">.*?'
656
+ r'<span>{1}</span><strong>{2}</strong>'.format(
657
+ re.escape(href), re.escape(label), value
658
+ ),
659
+ re.S,
660
+ ),
661
+ )
662
+
663
+ def test_issue_reasoning_is_folded_behind_explicit_control(self):
664
+ document = delivery.render(with_structured_review_comparison())
665
+ self.assertIn('class="issue-evidence"', document)
666
+ self.assertIn("展开判断依据与规则", document)
667
+ self.assertIn("问题方向:数据勾稽", document)
668
+ first_issue = document[document.find('class="issue-card'):document.find('class="issue-card', document.find('class="issue-card') + 1)]
669
+ self.assertLess(first_issue.find("问题描述"), first_issue.find("展开判断依据与规则"))
670
+
671
+ def test_recommended_edits_are_folded_behind_explicit_counted_control(self):
672
+ document = delivery.render(with_structured_review_comparison())
673
+ self.assertIn('class="issue-edits"', document)
674
+ self.assertIn("展开修改意见(共 1 项)", document)
675
+ first_issue = document[document.find('class="issue-card'):document.find('class="issue-card', document.find('class="issue-card') + 1)]
676
+ self.assertLess(first_issue.find("问题描述"), first_issue.find("展开修改意见(共 1 项)"))
677
+ self.assertLess(first_issue.find("展开修改意见(共 1 项)"), first_issue.find("建议修改"))
678
+
679
+ def test_ai_only_issues_are_highlighted_and_summarized_by_severity(self):
680
+ document = delivery.render(with_structured_review_comparison())
681
+ start = document.find('id="actionable-issues"')
682
+ end = document.find('id="external-data-verification"', start)
683
+ section = document[start:end]
684
+ self.assertIn('class="ai-only-summary"', section)
685
+ self.assertIn("AI 独立检出", section)
686
+ self.assertIn('class="ai-only-total"><strong>1</strong><span>项</span>', section)
687
+ self.assertIn('class="ai-only-severity sev-high"><span>高</span><strong>1</strong>', section)
688
+ self.assertIn('class="ai-only-severity sev-medium"><span>中</span><strong>0</strong>', section)
689
+ self.assertIn('class="ai-only-severity sev-low"><span>低</span><strong>0</strong>', section)
690
+
691
+ first_issue_start = section.find('class="issue-card')
692
+ second_issue_start = section.find('class="issue-card', first_issue_start + 1)
693
+ first_issue = section[first_issue_start:second_issue_start]
694
+ second_issue = section[second_issue_start:]
695
+ self.assertIn('class="ai-only-badge"', first_issue)
696
+ self.assertIn("AI 独立发现", first_issue)
697
+ self.assertIn("未与人工复核意见重叠,请优先核验其准确性", first_issue)
698
+ self.assertNotIn('class="ai-only-badge"', second_issue)
699
+
700
+ def test_ai_only_markers_are_hidden_until_review_is_performed(self):
701
+ result = load_sample()
702
+ result["reviewComparison"]["status"] = "not_performed"
703
+ result["reviewComparison"]["bands"] = {"overlap": 0, "aiOnly": 0, "divergent": 0, "reviewerOnly": 0}
704
+ result["reviewComparison"]["reviewerOnlyItems"] = []
705
+ for issue in result["issues"]:
706
+ issue["reviewComparison"] = {"status": "not_performed"}
707
+ document = delivery.render(result)
708
+ self.assertNotIn('class="ai-only-summary"', document)
709
+ self.assertNotIn('class="ai-only-badge"', document)
710
+
711
+ def test_embedded_json_matches_digests(self):
712
+ match = re.search(
713
+ r'<script id="audit-result" type="application/json">(.*?)</script>', self.document, re.S
714
+ )
715
+ self.assertIsNotNone(match)
716
+ embedded = json.loads(match.group(1).replace("<\\/", "</"))
717
+ file_trace = embedded["fileTrace"]
718
+ self.assertEqual(delivery.RENDERER_VERSION, file_trace["rendererVersion"])
719
+ without_digests = copy.deepcopy(embedded)
720
+ without_digests["fileTrace"]["sourceDigest"] = ""
721
+ without_digests["fileTrace"]["embeddedJsonDigest"] = ""
722
+ self.assertEqual(delivery.sha256_hex(delivery.canonical_json(without_digests)), file_trace["sourceDigest"])
723
+ with_source = copy.deepcopy(embedded)
724
+ with_source["fileTrace"]["embeddedJsonDigest"] = ""
725
+ self.assertEqual(delivery.sha256_hex(delivery.canonical_json(with_source)), file_trace["embeddedJsonDigest"])
726
+ self.assertEqual([], delivery.validate(embedded, rendered=True, expect_renderer=True))
727
+
728
+ def test_render_is_deterministic(self):
729
+ self.assertEqual(self.document, delivery.render(load_sample()))
730
+
731
+ def test_problem_description_renders_headline_and_details_separately(self):
732
+ """§4.6:首句单行突出,明细逐行;不得拼成一整段。"""
733
+ document = delivery.render(load_sample())
734
+ self.assertIn('class="problem-headline"', document)
735
+ self.assertIn('class="problem-details"', document)
736
+ matches = re.findall(
737
+ r'<p class="problem-headline"><span class="problem-description">(.*?)</span></p>'
738
+ r'<ul class="problem-details">(.*?)</ul>',
739
+ document,
740
+ re.S,
741
+ )
742
+ self.assertEqual(2, len(matches))
743
+ by_headline = {headline: details for headline, details in matches}
744
+ self.assertIn("三个可比案例都用基准日之前成交的价格,测算里没有任何时间修正。", by_headline)
745
+ details = by_headline["三个可比案例都用基准日之前成交的价格,测算里没有任何时间修正。"]
746
+ detail_items = re.findall(r"<li>(.*?)</li>", details)
747
+ self.assertEqual(2, len(detail_items))
748
+ for item in detail_items:
749
+ self.assertNotIn("\n", item)
750
+ self.assertIn("请打开 市场法测算表.xlsx 的「市场法」表 F18:F22", detail_items[-1])
751
+ self.assertIn("评估说明.docx 第 35 页", detail_items[-1])
752
+
753
+ def test_issue_location_panel_shows_summary_and_deduplicated_material_locations(self):
754
+ result = load_sample()
755
+ issue = result["issues"][0]
756
+ duplicate = copy.deepcopy(issue["materialEvidence"][0])
757
+ issue["materialEvidence"].append(duplicate)
758
+ document = delivery.render(result)
759
+ issues_start = document.find('id="actionable-issues"')
760
+ title_position = document.find(issue["title"], issues_start)
761
+ card_start = document.rfind('<article class="issue-card', 0, title_position)
762
+ card_end = document.find('<article class="issue-card', title_position)
763
+ if card_end == -1:
764
+ card_end = document.find('</section>', title_position)
765
+ first_card = document[card_start:card_end]
766
+ self.assertIn('class="issue-location-panel"', first_card)
767
+ self.assertIn(issue["locationSummary"], first_card)
768
+ evidence = issue["materialEvidence"][0]
769
+ expected = (
770
+ '<span class="location-file">{0}</span>'
771
+ '<span class="location-arrow" aria-hidden="true">→</span>'
772
+ '<span class="location-locator">{1}</span>'
773
+ ).format(evidence["displayName"], evidence["locator"])
774
+ self.assertEqual(1, first_card.count(expected))
775
+
776
+ def test_issue_location_panel_escapes_file_and_locator(self):
777
+ result = load_sample()
778
+ result["issues"][0]["materialEvidence"][0]["displayName"] = "<b>报告.docx</b>"
779
+ result["issues"][0]["materialEvidence"][0]["locator"] = "<script>bad()</script>"
780
+ document = delivery.render(result)
781
+ issues_start = document.find('id="actionable-issues"')
782
+ title_position = document.find(result["issues"][0]["title"], issues_start)
783
+ card_start = document.rfind('<article class="issue-card', 0, title_position)
784
+ card_end = document.find('</section>', title_position)
785
+ card = document[card_start:card_end]
786
+ self.assertIn('class="issue-location-panel"', card)
787
+ self.assertIn("&lt;b&gt;报告.docx&lt;/b&gt;", card)
788
+ self.assertIn("&lt;script&gt;bad()&lt;/script&gt;", card)
789
+ self.assertNotIn("<script>bad()</script>", card)
790
+
791
+ def test_dynamic_content_is_escaped(self):
792
+ result = load_sample()
793
+ payload = "<script>alert(1)</script>"
794
+ result["issues"][0]["problemDescription"] = payload
795
+ document = delivery.render(result)
796
+ self.assertNotIn(payload, document)
797
+ self.assertIn("&lt;script&gt;alert(1)&lt;/script&gt;", document)
798
+ # 嵌入 JSON 必须安全转义 <,全文只能有本页面自己的一个结束标签
799
+ self.assertEqual(1, document.count("</script>"))
800
+ self.assertIn("\\u003cscript>alert(1)", document)
801
+
802
+ def test_dynamic_content_cannot_trigger_template_placeholder_replacement(self):
803
+ result = load_sample()
804
+ result["issues"][0]["problemDescription"] = "保留原文 {{embedded_json}} 与 {{report_content}}"
805
+ document = delivery.render(result)
806
+ visible_issues = re.findall(r'<span class="problem-description">(.*?)</span>', document, re.S)
807
+ self.assertIn("保留原文 {{embedded_json}} 与 {{report_content}}", visible_issues)
808
+ self.assertEqual(1, document.count('<main id="audit-report">'))
809
+ self.assertEqual(1, document.count('<script id="audit-result" type="application/json">'))
810
+
811
+ def test_empty_lists_show_explicit_message(self):
812
+ result = load_sample()
813
+ result["manualConfirmationItems"] = []
814
+ result["summary"]["counts"]["pendingConfirmation"] = 0
815
+ result["reviewComparison"]["status"] = "not_performed"
816
+ result["reviewComparison"]["bands"] = {"overlap": 0, "aiOnly": 0, "divergent": 0, "reviewerOnly": 0}
817
+ result["reviewComparison"]["reviewerOnlyItems"] = []
818
+ for issue in result["issues"]:
819
+ issue["reviewComparison"] = {"status": "not_performed"}
820
+ result["scope"]["notCheckedItems"] = []
821
+ result["summary"]["counts"]["notChecked"] = 0
822
+ result["professionalTrail"]["adjudications"] = []
823
+ result["professionalTrail"]["checkRecords"] = []
824
+ self.assertEqual([], delivery.validate(result))
825
+ document = delivery.render(result)
826
+ self.assertIn(delivery.EMPTY_TEXT, document)
827
+ self.assertIn("需要人工确认事项", document)
828
+ self.assertIn("未检查项", document)
829
+
830
+ def test_renderer_does_not_author_business_text(self):
831
+ """文本节点只能来自受控标签常量或输入数据,不得拼接出新句子(§12.2 / §10.3)。"""
832
+ allowed = set(module_string_constants())
833
+ for scalar in delivery.iter_all_scalars(self.result):
834
+ allowed.add(scalar)
835
+
836
+ def collect(value):
837
+ if isinstance(value, dict):
838
+ for item in value.values():
839
+ collect(item)
840
+ elif isinstance(value, list):
841
+ allowed.add("、".join(str(item) for item in value))
842
+ for item in value:
843
+ collect(item)
844
+
845
+ collect(self.result)
846
+ allowed.add("中瑞世联AI审核报告 - {0}".format(self.result["auditTask"]["projectId"]))
847
+ allowed.update({"✅", "❌", "→"})
848
+ for issue in self.result.get("issues", []):
849
+ # §4.6:问题描述按两段式分行呈现,文本节点即输入数据的分行切片,非渲染器新句
850
+ headline, details = delivery.split_problem_description(issue.get("problemDescription"))
851
+ allowed.update({headline} if headline else set())
852
+ allowed.update(details)
853
+ for node in text_nodes(self.document):
854
+ if re.fullmatch(r"[0-9a-f]{64}", node):
855
+ continue # 渲染期计算的来源/嵌入摘要,可由输入确定性重算
856
+ if re.fullmatch(r"[0-9]+", node):
857
+ continue # 渲染期按输入确定性重算的计数(如概览分区项数),非业务句子
858
+ if node in ("问题描述",):
859
+ continue # 受控字段标签(§4.6 问题描述区标题),与「规则依据」「材料证据」同类
860
+ if any(re.fullmatch(pattern, node) for pattern in (
861
+ r"(问题方向|严重程度|问题类型|判定):.+",
862
+ r"展开修改意见(共 [0-9]+ 项)",
863
+ r"展开判断依据与规则([0-9]+ 条规则,[0-9]+ 条材料)",
864
+ r"展开全部审核依据(共 [0-9]+ 条规则)",
865
+ r"展开 AI 自查错误记录(共 [0-9]+ 条)",
866
+ r".+复核:[0-9]+ 条意见",
867
+ r"展开.+复核意见(共 [0-9]+ 条)",
868
+ r":[0-9]+ 条,须优先回客户。",
869
+ r"已称修复但实际未落实:[0-9]+ 条,须优先回客户。?",
870
+ r"(问题|确认|复核|未检查) [0-9]+/[0-9]+",
871
+ r"实际未落实 [0-9]+",
872
+ r"命中率:\([0-9]+ \+ [0-9]+\) ÷ [0-9]+ = [0-9]+(?:\.[0-9])?%;精确命中率:[0-9]+(?:\.[0-9])?%。部分命中计为命中、只是层次较低;已验证修改和无法核验项不进入分母。",
873
+ r"命中率:\([0-9]+ \+ [0-9]+\) ÷ [0-9]+。部分命中计为命中、只是层次较低;已验证修改和无法核验项不进入分母;综合值按全部有效明细汇总,不取各维度百分比平均。",
874
+ r"(精确命中|部分命中|未命中):[0-9]+ / [0-9]+([0-9]+(?:\.[0-9])?%)",
875
+ r"展开不计入命中率分母的条目(共 [0-9]+ 条)",
876
+ r"[0-9]+/[0-9]+([0-9]+(?:\.[0-9])?%)",
877
+ r"[0-9]+(?:\.[0-9])?%",
878
+ r"[0-9]+ ÷ [0-9]+",
879
+ r"[0-9]+ ÷ [0-9]+ = [0-9]+(?:\.[0-9])?%",
880
+ )):
881
+ continue # 受控标签与输入计数/枚举的确定性组合,不创作新的业务结论
882
+ self.assertIn(node, allowed, "渲染器生成了非标签/非数据的文本:{0}".format(node))
883
+
884
+
885
+ class ExternalDataVerificationTest(unittest.TestCase):
886
+ """《外部数据核验》区:数据源可用性声明 + 逐项正确/不正确 + 基准日锚定。"""
887
+
888
+ def setUp(self):
889
+ self.result = load_sample()
890
+ self.document = delivery.render(self.result)
891
+
892
+ def test_region_is_rendered_in_required_position(self):
893
+ self.assertIn('id="external-data-verification"', self.document)
894
+ positions = [
895
+ self.document.find('id="{0}"'.format(region)) for region in REGION_ORDER
896
+ ]
897
+ self.assertEqual(sorted(positions), positions, "外部数据核验区顺序必须符合 §3 交付结构")
898
+
899
+ def test_external_data_is_named_as_ai_result_and_follows_ai_issues(self):
900
+ document = delivery.render(with_structured_review_comparison())
901
+ self.assertIn("AI 外部数据核验结果", document)
902
+ self.assertLess(document.find('id="actionable-issues"'), document.find('id="external-data-verification"'))
903
+ self.assertLess(document.find('id="external-data-verification"'), document.find('id="review-comparison"'))
904
+
905
+ def test_unavailable_source_is_declared_in_html(self):
906
+ result = load_sample()
907
+ verification = result["externalDataVerification"]
908
+ verification["applicability"] = {
909
+ "status": "required",
910
+ "reason": "报告引用了需要与公开市场数据核对的外部数据。",
911
+ "basis": "知识库 06-规则库/M-外部数据核验/01-模块-外部数据核验 · 表 A 触发范围",
912
+ }
913
+ verification["sources"][0]["configured"] = False
914
+ verification["sources"][0]["authenticated"] = False
915
+ verification["sources"][0]["note"] = "两条取数路径都不可用;相关条目未经外部数据核验"
916
+ verification["unavailableDeclaration"] = "本次未配置 / 未认证 同花顺 iFinD,相关条目未经外部数据核验。"
917
+ document = delivery.render(result)
918
+ self.assertIn(verification["unavailableDeclaration"], document)
919
+ self.assertIn("未配置", document)
920
+ self.assertIn("未授权", document)
921
+
922
+ def test_source_note_names_the_structured_tool_not_a_skill(self):
923
+ """R3C:可执行样例必须把 DSH 取数入口写成结构化 Tool,不得再写成第三方技能。
924
+
925
+ 样例是**可执行文档**:`sources[].note` 会原样渲染进交付 HTML
926
+ (`_external_data_sources_section`),所以旧路径会随示例被下一个审核复制。
927
+ """
928
+ notes = [item.get("note") or "" for item in self.result["externalDataVerification"]["sources"]]
929
+ self.assertTrue(notes, "样例必须保留数据源可用性声明")
930
+ for note in notes:
931
+ self.assertNotRegex(note, SKILL_ENTRY_RE, "样例的数据源说明不得把取数入口写成技能")
932
+ self.assertTrue(
933
+ any(DSH_IFIND_TOOL in note for note in notes),
934
+ "样例必须把 DeepSeek Harness 的取数入口写成结构化 Tool {0}".format(DSH_IFIND_TOOL),
935
+ )
936
+
937
+ # 渲染后的 HTML 才是员工真正读到的交付件 —— 两边都要锁。
938
+ section = self.document[
939
+ self.document.find('id="external-data-verification"'):
940
+ self.document.find("</section>", self.document.find('id="external-data-verification"'))
941
+ ]
942
+ self.assertNotRegex(section, SKILL_ENTRY_RE, "交付 HTML 的数据源表不得出现技能形态的取数入口")
943
+ self.assertIn(DSH_IFIND_TOOL, self.document, "交付 HTML 必须写明结构化 Tool")
944
+
945
+ def test_each_check_shows_correct_and_incorrect_with_deviation(self):
946
+ self.assertIn("EXTERNAL", self.document.replace("EXT-", "EXTERNAL")) # 编号可见(示意)
947
+ self.assertIn("符合", self.document)
948
+ self.assertIn("不符合", self.document)
949
+ self.assertIn("同花顺 iFinD", self.document)
950
+ self.assertIn("2025-06-30", self.document)
951
+
952
+ def test_overview_separates_external_data_from_issue_totals(self):
953
+ """审核结果概览必须分列呈现:问题按模块分布 + 外部数据核验结果,不混成一句。"""
954
+ start = self.document.find('id="summary"')
955
+ end = self.document.find("</section>", start)
956
+ summary = self.document[start:end]
957
+ self.assertIn("问题按模块分布", summary)
958
+ self.assertIn("市场法", summary, "按模块分布须使用员工可读的问题方向")
959
+ self.assertIn("外部数据核验结果", summary, "外部数据核验须在概览中单独成块")
960
+ self.assertIn("与哪一数据源出入较大", summary, "须给出与哪一源出入较大的分布")
961
+ self.assertNotIn("external-data-verification", summary, "概览只做分区呈现,明细仍在专区内")
962
+
963
+ def test_overview_counts_match_external_data_checks(self):
964
+ checks = self.result["externalDataVerification"]["checks"]
965
+ expected = {}
966
+ for check in checks:
967
+ expected[check["decision"]] = expected.get(check["decision"], 0) + 1
968
+ start = self.document.find('id="summary"')
969
+ end = self.document.find("</section>", start)
970
+ summary = self.document[start:end]
971
+ for decision, count in expected.items():
972
+ self.assertIn(
973
+ "<tr><td>{0}</td><td>{1}</td></tr>".format(decision, count),
974
+ summary,
975
+ "概览分区项数必须与核验条目重算一致:{0}={1}".format(decision, count),
976
+ )
977
+
978
+ def test_overview_deviation_block_labels_every_row(self):
979
+ """「与哪一数据源出入较大」不得出现无标签的 "—" 行,且各行项数须合计等于核验项数。"""
980
+ start = self.document.find('id="summary"')
981
+ end = self.document.find("</section>", start)
982
+ summary = self.document[start:end]
983
+ block = summary[summary.find("与哪一数据源出入较大"):]
984
+ self.assertNotIn("<tr><td>—</td>", block, "无出入/未检查必须单列,不得混进 — 行")
985
+ counts = [int(value) for value in re.findall(r"<td>([0-9]+)</td>", block)]
986
+ self.assertEqual(
987
+ len(self.result["externalDataVerification"]["checks"]),
988
+ sum(counts),
989
+ "出入较大分块的行项数之和必须等于核验项数",
990
+ )
991
+
992
+ def test_unfetched_checks_are_labelled_未取数_in_detail(self):
993
+ """逐项核验表中,判定为"未检查"的条目其"出入较大"列须显式写"未取数",不用 — 混淆。"""
994
+ start = self.document.find('id="external-data-verification"')
995
+ section = self.document[start:self.document.find("</section>", start)]
996
+ self.assertIn("<td>{0}</td>".format(delivery.EXT_DATA_NOT_FETCHED_TEXT), section)
997
+
998
+ def test_missing_verification_renders_explicit_empty_state(self):
999
+ result = load_sample()
1000
+ result.pop("externalDataVerification", None)
1001
+ document = delivery.render(result)
1002
+ self.assertIn('id="external-data-verification"', document)
1003
+ self.assertIn(delivery.EXT_DATA_EMPTY_TEXT, document)
1004
+
1005
+ def test_not_applicable_is_explained_without_source_configuration_warning(self):
1006
+ result = load_sample()
1007
+ verification = result["externalDataVerification"]
1008
+ verification["applicability"] = {
1009
+ "status": "not_applicable",
1010
+ "reason": "本报告仅采用成本法,未采用或参考收益法、市场法,不满足方法线。",
1011
+ "basis": "知识库 06-规则库/M-外部数据核验/01-模块-外部数据核验 · 表 A 触发范围(业务线 × 资产线 × 方法线三条同时成立)",
1012
+ }
1013
+ verification["sources"] = []
1014
+ verification["checks"] = []
1015
+ verification.pop("unavailableDeclaration", None)
1016
+ errors = delivery.validate(result)
1017
+ self.assertEqual([], errors)
1018
+ document = delivery.render(result)
1019
+ section = document[
1020
+ document.find('id="external-data-verification"'):
1021
+ document.find("</section>", document.find('id="external-data-verification"'))
1022
+ ]
1023
+ self.assertIn("本报告暂不涉及外部数据核验", section)
1024
+ self.assertIn(verification["applicability"]["reason"], section)
1025
+ self.assertIn(verification["applicability"]["basis"], section)
1026
+ self.assertNotIn("未配置", section)
1027
+ self.assertNotIn("未认证", section)
1028
+ self.assertNotIn("数据源可用性", section)
1029
+
1030
+ summary = document[document.find('id="summary"'):document.find("</section>", document.find('id="summary"'))]
1031
+ self.assertIn("外部数据核验结果", summary)
1032
+ self.assertIn("不适用", summary)
1033
+
1034
+ def test_applicability_judgment_is_required(self):
1035
+ result = load_sample()
1036
+ result["externalDataVerification"].pop("applicability", None)
1037
+ errors = delivery.validate(result)
1038
+ self.assertIn("externalDataVerification.applicability", "\n".join(errors))
1039
+
1040
+ def test_not_applicable_must_not_carry_sources_checks_or_unavailable_declaration(self):
1041
+ result = load_sample()
1042
+ verification = result["externalDataVerification"]
1043
+ verification["applicability"] = {
1044
+ "status": "not_applicable",
1045
+ "reason": "本报告不满足知识库表 A 的三线触发条件。",
1046
+ "basis": "知识库 06-规则库/M-外部数据核验/01-模块-外部数据核验 · 表 A 触发范围",
1047
+ }
1048
+ verification["unavailableDeclaration"] = "未配置/未认证 同花顺 iFinD,相关条目未经外部数据核验。"
1049
+ errors = delivery.validate(result)
1050
+ joined = "\n".join(errors)
1051
+ self.assertIn("not_applicable", joined)
1052
+ self.assertIn("sources", joined)
1053
+ self.assertIn("checks", joined)
1054
+ self.assertIn("unavailableDeclaration", joined)
1055
+
1056
+ def test_required_fields_are_enforced(self):
1057
+ result = load_sample()
1058
+ verification = result["externalDataVerification"]
1059
+ verification["baseDate"] = ""
1060
+ verification["checks"][1]["decision"] = "大概符合"
1061
+ verification["checks"][0].pop("reportEvidence")
1062
+ errors = delivery.validate(result)
1063
+ joined = "\n".join(errors)
1064
+ self.assertIn("externalDataVerification.baseDate 不得为空", joined)
1065
+ self.assertIn("decision 取值非法", joined)
1066
+ self.assertIn("reportEvidence.locator", joined)
1067
+
1068
+ def test_unavailable_declaration_is_required_when_source_missing(self):
1069
+ result = load_sample()
1070
+ verification = result["externalDataVerification"]
1071
+ verification["sources"][0]["configured"] = False
1072
+ verification["sources"][0]["authenticated"] = False
1073
+ verification.pop("unavailableDeclaration", None)
1074
+ errors = delivery.validate(result)
1075
+ self.assertIn("unavailableDeclaration", "\n".join(errors))
1076
+
1077
+ def test_unavailable_source_cannot_be_used_as_verification_basis(self):
1078
+ result = load_sample()
1079
+ verification = result["externalDataVerification"]
1080
+ verification["sources"][0]["configured"] = False
1081
+ verification["sources"][0]["authenticated"] = False
1082
+ verification["unavailableDeclaration"] = "本次未配置 / 未认证 同花顺 iFinD,相关条目未经外部数据核验。"
1083
+ errors = delivery.validate(result)
1084
+ self.assertIn("不可用", "\n".join(errors))
1085
+
1086
+ def test_source_must_be_declared(self):
1087
+ result = load_sample()
1088
+ result["externalDataVerification"]["checks"][0]["sources"][0]["source"] = "某未声明源"
1089
+ errors = delivery.validate(result)
1090
+ self.assertIn("未在 externalDataVerification.sources 中声明", "\n".join(errors))
1091
+
1092
+
1093
+ class AuditDeliveryCliTest(unittest.TestCase):
1094
+ """编排层调用的三个子命令:validate / digest / render。"""
1095
+
1096
+ def test_digest_matches_canonical_sha256(self):
1097
+ buffer = io.StringIO()
1098
+ with contextlib.redirect_stdout(buffer):
1099
+ code = delivery.main(["digest", str(SAMPLE_PATH)])
1100
+ self.assertEqual(0, code)
1101
+ expected = delivery.sha256_hex(delivery.canonical_json(load_sample()))
1102
+ self.assertEqual(expected, buffer.getvalue().strip())
1103
+
1104
+ def test_validate_cli_exit_codes(self):
1105
+ with contextlib.redirect_stdout(io.StringIO()):
1106
+ self.assertEqual(0, delivery.main(["validate", str(SAMPLE_PATH)]))
1107
+ broken = load_sample()
1108
+ broken["issues"][0]["materialEvidence"] = []
1109
+ with tempfile.TemporaryDirectory() as tmp:
1110
+ path = Path(tmp) / "broken.json"
1111
+ path.write_text(json.dumps(broken, ensure_ascii=False), encoding="utf-8")
1112
+ with contextlib.redirect_stderr(io.StringIO()):
1113
+ self.assertEqual(1, delivery.main(["validate", str(path)]))
1114
+
1115
+ def test_render_cli_refuses_invalid_input(self):
1116
+ broken = load_sample()
1117
+ broken["summary"]["counts"]["fail"] = 99
1118
+ with tempfile.TemporaryDirectory() as tmp:
1119
+ path = Path(tmp) / "broken.json"
1120
+ out = Path(tmp) / "out.html"
1121
+ json_out = Path(tmp) / "out.json"
1122
+ path.write_text(json.dumps(broken, ensure_ascii=False), encoding="utf-8")
1123
+ with contextlib.redirect_stderr(io.StringIO()):
1124
+ self.assertEqual(1, delivery.main([
1125
+ "render", str(path), "--out", str(out), "--json-out", str(json_out)
1126
+ ]))
1127
+ self.assertFalse(out.exists(), "校验失败时不得产出 HTML")
1128
+ self.assertFalse(json_out.exists(), "校验失败时不得产出配套 JSON")
1129
+
1130
+ def test_render_writes_json_matching_html_embedded_result(self):
1131
+ with tempfile.TemporaryDirectory() as temp_dir:
1132
+ html_path = Path(temp_dir) / "审核意见.PRJ-2026-0001.html"
1133
+ json_path = Path(temp_dir) / "审核结果.PRJ-2026-0001.json"
1134
+ with contextlib.redirect_stdout(io.StringIO()):
1135
+ exit_code = delivery.main([
1136
+ "render", str(SAMPLE_PATH),
1137
+ "--out", str(html_path),
1138
+ "--json-out", str(json_path),
1139
+ ])
1140
+ self.assertEqual(0, exit_code)
1141
+ archived = json.loads(json_path.read_text(encoding="utf-8"))
1142
+ document = html_path.read_text(encoding="utf-8")
1143
+ embedded = json.loads(re.search(
1144
+ r'<script id="audit-result" type="application/json">(.*?)</script>',
1145
+ document,
1146
+ re.S,
1147
+ ).group(1))
1148
+ self.assertEqual(archived, embedded)
1149
+ self.assertEqual(delivery.RENDERER_VERSION, archived["fileTrace"]["rendererVersion"])
1150
+
1151
+ def test_invalid_json_gate_writes_neither_output(self):
1152
+ result = load_sample()
1153
+ result["issues"][0]["problemDescription"] = "只有一段,不合规。"
1154
+ with tempfile.TemporaryDirectory() as temp_dir:
1155
+ source = Path(temp_dir) / "invalid.json"
1156
+ source.write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8")
1157
+ html_path = Path(temp_dir) / "result.html"
1158
+ json_path = Path(temp_dir) / "result.json"
1159
+ stderr = io.StringIO()
1160
+ with contextlib.redirect_stderr(stderr):
1161
+ exit_code = delivery.main([
1162
+ "render", str(source),
1163
+ "--out", str(html_path),
1164
+ "--json-out", str(json_path),
1165
+ ])
1166
+ self.assertEqual(1, exit_code)
1167
+ self.assertIn("issues[0].problemDescription", stderr.getvalue())
1168
+ self.assertFalse(html_path.exists())
1169
+ self.assertFalse(json_path.exists())
1170
+
1171
+
1172
+ class ReviewerOnlyInFileResolutionTest(unittest.TestCase):
1173
+ """复核独有项在件核验三态(L-resolved/open/unclosed/uncheckable)的校验契约。
1174
+
1175
+ 以样例为基底,只改 phaseControl.reviewComparison,隔离断言目标字段,
1176
+ 不触发与三态无关的其他校验。
1177
+ """
1178
+
1179
+ def _reviewer_item(self, **over):
1180
+ item = {
1181
+ "itemId": "R-1",
1182
+ "title": "人工复核独有项",
1183
+ "reviewerEvidence": {"file": "复核意见.pdf", "locator": "P3", "quote": "……"},
1184
+ "inFileResolution": "L-open",
1185
+ "inFileEvidence": {"file": "定稿.docx", "locator": "P12"},
1186
+ "handling": "进入隔离补审",
1187
+ }
1188
+ item.update(over)
1189
+ return item
1190
+
1191
+ def _result(self, reviewer_item):
1192
+ result = load_sample()
1193
+ result["reviewComparison"] = {
1194
+ "status": "not_performed",
1195
+ "bands": {"overlap": 0, "aiOnly": 0, "divergent": 0, "reviewerOnly": 1},
1196
+ "reviewerOnlyItems": [reviewer_item],
1197
+ "metrics": {"denominator": "人工复核共 5 项"},
1198
+ }
1199
+ return result
1200
+
1201
+ def _errors(self, item):
1202
+ return delivery.validate(self._result(item))
1203
+
1204
+ def test_missing_inFileResolution_is_rejected(self):
1205
+ it = self._reviewer_item(); del it["inFileResolution"]
1206
+ self.assertTrue(any("inFileResolution" in e for e in self._errors(it)))
1207
+
1208
+ def test_invalid_resolution_value_is_rejected(self):
1209
+ self.assertTrue(any("inFileResolution" in e for e in self._errors(self._reviewer_item(inFileResolution="L-foo"))))
1210
+
1211
+ def test_L_open_requires_inFileEvidence(self):
1212
+ it = self._reviewer_item(); del it["inFileEvidence"]
1213
+ self.assertTrue(any("inFileEvidence.file" in e for e in self._errors(it)))
1214
+
1215
+ def test_L_unclosed_requires_closureEvidence(self):
1216
+ it = self._reviewer_item(inFileResolution="L-unclosed")
1217
+ self.assertTrue(any("closureEvidence" in e for e in self._errors(it)))
1218
+
1219
+ def test_L_uncheckable_allows_missing_inFileEvidence(self):
1220
+ it = self._reviewer_item(inFileResolution="L-uncheckable"); del it["inFileEvidence"]
1221
+ self.assertFalse(any("inFileEvidence" in e for e in self._errors(it)), self._errors(it))
1222
+
1223
+ def test_dual_denominator_is_accepted(self):
1224
+ it = self._reviewer_item()
1225
+ r = self._result(it)
1226
+ r["reviewComparison"]["metrics"] = {
1227
+ "denominator": "含 L-resolved:5 项", "aiHitRate": "60%",
1228
+ "denominatorExclResolved": "仅 L-open:2 项", "aiHitRateExclResolved": "50%",
1229
+ }
1230
+ self.assertFalse(any("denominator" in e for e in delivery.validate(r)), delivery.validate(r))
1231
+
1232
+
1233
+ class StructuredReviewComparisonTest(unittest.TestCase):
1234
+ """新版逐条复核事实源:文件顺序、分级明细与百分比必须可重算。"""
1235
+
1236
+ def test_structured_sample_is_valid(self):
1237
+ self.assertEqual([], delivery.validate(with_structured_review_comparison()))
1238
+
1239
+ def test_metrics_are_recomputed_from_review_items(self):
1240
+ result = with_structured_review_comparison()
1241
+ result["reviewComparison"]["metrics"]["hitRate"] = 1.0
1242
+ errors = delivery.validate(result)
1243
+ self.assertTrue(any("hitRate" in error and "重算" in error for error in errors), errors)
1244
+
1245
+ def test_partial_hits_count_as_hits(self):
1246
+ """部分命中是命中(层次较低),命中率必须含 partial,不得等同于精确命中率。"""
1247
+ result = with_structured_review_comparison()
1248
+ comparison = result["reviewComparison"]
1249
+ comparison["reviewItems"][0]["matchStatus"] = "partial"
1250
+ comparison["reviewItems"][0]["hitExplanation"].update(
1251
+ {"matchedAspects": ["部分覆盖"], "unmatchedAspects": ["未覆盖部分"], "rationale": "只覆盖一部分。"})
1252
+ metrics = delivery._review_metrics(comparison["reviewItems"])
1253
+ self.assertEqual(metrics["exactHits"], 0)
1254
+ self.assertEqual(metrics["partialHits"], 1)
1255
+ self.assertEqual(metrics["hitRate"], 50.0)
1256
+ self.assertEqual(metrics["exactRate"], 0.0)
1257
+ self.assertNotEqual(metrics["hitRate"], metrics["exactRate"])
1258
+
1259
+ def test_header_shows_hit_rate_with_explicit_denominator(self):
1260
+ """首屏必须给出命中率口径与精确数字:分母=员工复核中未修改项,分子=AI 命中项,并按全部复核级次列出条数。"""
1261
+ result = with_structured_review_comparison()
1262
+ document = delivery.render(result)
1263
+ header = document.split('id="actionable-issues"')[0]
1264
+ # 主指标为命中率
1265
+ self.assertIn("<span>命中率</span>", header)
1266
+ # 口径说明与精确数字
1267
+ self.assertIn("命中率口径(精确数字 · 按全部复核级次汇总)", header)
1268
+ self.assertIn("分母=员工复核意见中未修改(未落实)的条目;分子=AI 命中的条目(精确命中+部分命中)。", header)
1269
+ # 分母构成必须按级次列出,避免被误读为只统计某一级复核
1270
+ for level in ("一级复核", "二级复核"):
1271
+ self.assertIn(level, header)
1272
+ metrics = delivery._review_metrics(result["reviewComparison"]["reviewItems"])
1273
+ numerator = metrics["exactHits"] + metrics["partialHits"]
1274
+ self.assertIn("{0} ÷ {1} = {2}".format(
1275
+ numerator, metrics["evaluable"], delivery._format_rate(metrics["hitRate"])), header)
1276
+ self.assertIn("{0} ÷ {1} = {2}".format(
1277
+ metrics["exactHits"], metrics["evaluable"], delivery._format_rate(metrics["exactRate"])), header)
1278
+
1279
+ def test_workpaper_capability_notice_is_always_rendered(self):
1280
+ """交付件必须明确说明暂不支持底稿文件审核,且底稿意见不计入 AI 命中率。"""
1281
+ document = delivery.render(with_structured_review_comparison())
1282
+ self.assertIn(delivery.CAPABILITY_WORKPAPER_NOTICE, document)
1283
+ self.assertIn("本工具当前暂不支持底稿文件审核", document)
1284
+
1285
+ def test_denominator_exclusions_are_listed_with_evidence(self):
1286
+ """分母剔除清单:已修改/已落实与材料缺失项逐条给出剔除依据,使分母可审计。"""
1287
+ document = delivery.render(with_structured_review_comparison())
1288
+ self.assertIn("展开不计入命中率分母的条目(共 2 条)", document)
1289
+ self.assertIn("已修改/已落实 · 不计入分母", document)
1290
+ self.assertIn("材料缺失或不可读 · 不计入分母", document)
1291
+
1292
+ def test_ai_comparison_states_both_branches_for_miss_marked_modified(self):
1293
+ """AI 未命中且复核件标注已修改时,AI 对照必须写清两种分支与分母后果。"""
1294
+ document = delivery.render(with_structured_review_comparison())
1295
+ self.assertIn("AI 未命中|复核件标注该项「已修改」", document)
1296
+ self.assertIn("已按在件核验确认该项确已修改(复核事项在被审件中已不存在)→ 不计入命中率分母", document)
1297
+ self.assertIn("若核验发现并未修改,则须计入分母并计为 AI 漏检", document)
1298
+ # 答复称已改但未落地的未命中项同样计入分母
1299
+ self.assertIn("AI 未命中|答复称已改但被审件未落地:计入命中率分母并计为漏检,优先回客户", document)
1300
+ # 区块图例必须说明该规则
1301
+ self.assertIn("分母判定:AI 未命中的复核意见,须先核验该事项是否已被修改", document)
1302
+
1303
+ def test_ai_comparison_marks_unmodified_miss_as_counted(self):
1304
+ """AI 未命中且核验确认并未修改的,必须写明计入分母并计为漏检。"""
1305
+ result = with_structured_review_comparison()
1306
+ item = result["reviewComparison"]["reviewItems"][2] # RV-003:miss + L-unclosed
1307
+ item["inFileResolution"] = "L-open"
1308
+ item.pop("closureEvidence", None)
1309
+ self.assertEqual([], delivery.validate(result))
1310
+ document = delivery.render(result)
1311
+ self.assertIn("AI 未命中|已在件核验:该项并未修改 → 计入命中率分母,计为 AI 漏检", document)
1312
+
1313
+ def test_miss_with_resolved_requires_in_file_proof(self):
1314
+ """AI 未命中而按 L-resolved 剔除分母时,必须给出在件原文证明已修改,否则应按 L-open 计入漏检。"""
1315
+ result = with_structured_review_comparison()
1316
+ item = result["reviewComparison"]["reviewItems"][1] # RV-002:miss + L-resolved
1317
+ self.assertEqual("miss", item["matchStatus"])
1318
+ self.assertEqual("L-resolved", item["inFileResolution"])
1319
+ item["inFileEvidence"].pop("quote")
1320
+ errors = delivery.validate(result)
1321
+ self.assertTrue(
1322
+ any("必须给出在件原文" in e and "L-resolved" in e for e in errors), errors)
1323
+
1324
+ def test_out_of_scope_items_are_registered_and_rendered(self):
1325
+ """能力边界条目:不计分但必须登记备查、可见可展开,且不得混入命中率明细。"""
1326
+ result = with_structured_review_comparison()
1327
+ comparison = result["reviewComparison"]
1328
+ comparison["outOfScopeItems"] = [{
1329
+ "itemId": "RV-900", "title": "底稿层面意见(不计分)", "module": "工作底稿与程序",
1330
+ "reviewerEvidence": {"file": "01-一级复核意见.docx", "locator": "底稿第1条", "quote": "未见底稿。"},
1331
+ "handling": "交人工底稿审核",
1332
+ "exclusionReason": "AI 当前不具备底稿审核能力,不计入命中率口径。",
1333
+ }]
1334
+ self.assertEqual([], delivery.validate(result))
1335
+ document = delivery.render(result)
1336
+ self.assertIn("展开不计入命中率的登记备查条目(共 1 条)", document)
1337
+ self.assertIn("底稿层面意见(不计分)", document)
1338
+ # 不得混入命中率明细
1339
+ comparison["outOfScopeItems"][0]["itemId"] = comparison["reviewItems"][0]["itemId"]
1340
+ self.assertTrue(any("不得混入命中率明细" in e for e in delivery.validate(result)))
1341
+ # 缺 exclusionReason 必须报错
1342
+ comparison["outOfScopeItems"][0]["itemId"] = "RV-901"
1343
+ del comparison["outOfScopeItems"][0]["exclusionReason"]
1344
+ self.assertTrue(any("exclusionReason" in e for e in delivery.validate(result)))
1345
+
1346
+ def test_hit_explanation_is_required_and_consistent(self):
1347
+ result = with_structured_review_comparison()
1348
+ item = result["reviewComparison"]["reviewItems"][0]
1349
+ del item["hitExplanation"]
1350
+ self.assertTrue(any("hitExplanation" in e for e in delivery.validate(result)))
1351
+ result = with_structured_review_comparison()
1352
+ item = result["reviewComparison"]["reviewItems"][0]
1353
+ item["hitExplanation"]["unmatchedAspects"] = ["不该有"]
1354
+ self.assertTrue(any("unmatchedAspects" in e for e in delivery.validate(result)))
1355
+
1356
+ def test_resolved_and_uncheckable_are_excluded_from_denominator(self):
1357
+ result = with_structured_review_comparison()
1358
+ document = delivery.render(result)
1359
+ self.assertIn("50.0%", document)
1360
+ self.assertIn("命中率:(1 + 0) ÷ 2 = 50.0%", document)
1361
+ self.assertIn("精确命中率:50.0%", document)
1362
+ self.assertIn("已验证修改", document)
1363
+ self.assertIn("无法核验", document)
1364
+
1365
+ def test_review_files_are_listed_in_declared_order(self):
1366
+ document = delivery.render(with_structured_review_comparison())
1367
+ first = document.find("01-一级复核意见.docx")
1368
+ second = document.find("02-二级复核意见.docx")
1369
+ self.assertGreaterEqual(first, 0)
1370
+ self.assertGreater(second, first)
1371
+
1372
+ def test_each_review_level_has_visible_summary_and_explicit_expand_control(self):
1373
+ document = delivery.render(with_structured_review_comparison())
1374
+ self.assertIn("一级复核:2 条意见", document)
1375
+ self.assertIn("二级复核:2 条意见", document)
1376
+ self.assertIn("展开一级复核意见(共 2 条)", document)
1377
+ self.assertIn("展开二级复核意见(共 2 条)", document)
1378
+
1379
+ def test_unclosed_claim_is_prominent_with_both_evidence_sides(self):
1380
+ document = delivery.render(with_structured_review_comparison())
1381
+ self.assertIn("已称修复但实际未落实", document)
1382
+ self.assertIn("项目答复.docx", document)
1383
+ self.assertIn("仍未见时间修正依据", document)
1384
+
1385
+ def test_unclosed_claim_is_promoted_to_summary_and_review_heading(self):
1386
+ document = delivery.render(with_structured_review_comparison())
1387
+ summary_start = document.find('id="summary"')
1388
+ summary_end = document.find('</section>', summary_start)
1389
+ summary = document[summary_start:summary_end]
1390
+ self.assertIn('class="summary-closure-alert"', summary)
1391
+ self.assertIn('href="#review-unclosed-alert"', summary)
1392
+ self.assertIn("已称修复但实际未落实:1 条,须优先回客户", summary)
1393
+
1394
+ review_start = document.find('id="review-comparison"')
1395
+ review = document[review_start:]
1396
+ heading = review.find("人工复核对照")
1397
+ alert = review.find('id="review-unclosed-alert"')
1398
+ capability = review.find('class="formula capability-notice"')
1399
+ self.assertGreater(alert, heading)
1400
+ self.assertLess(alert, capability)
1401
+
1402
+ def test_unclosed_review_item_and_level_summary_are_tagged(self):
1403
+ document = delivery.render(with_structured_review_comparison())
1404
+ first_level_start = document.find("一级复核:2 条意见")
1405
+ second_level_start = document.find("二级复核:2 条意见")
1406
+ first_level = document[first_level_start:second_level_start]
1407
+ second_level = document[second_level_start:document.find('class="review-out-of-scope"', second_level_start)]
1408
+ self.assertNotIn("实际未落实 1", first_level)
1409
+ self.assertIn('<span class="level-alert-tag">实际未落实 1</span>', second_level)
1410
+ self.assertIn(
1411
+ '<span class="closure-tag">闭环异常|称已修复但实际未修改</span>',
1412
+ second_level,
1413
+ )
1414
+
1415
+ def test_objective_scorecard_shows_review_level_module_and_weighted_total(self):
1416
+ document = delivery.render(with_structured_review_comparison())
1417
+ self.assertIn("AI 审核表现评分卡", document)
1418
+ self.assertIn("按复核级次", document)
1419
+ self.assertIn("按问题模块", document)
1420
+ self.assertIn("综合命中率", document)
1421
+ self.assertIn("综合精确命中率", document)
1422
+ self.assertNotIn("综合·AI 单机", document)
1423
+
1424
+
1425
+
1426
+ class AiScorecardTest(unittest.TestCase):
1427
+ """AI 审核评分卡(自评六维 + 复审校正 + 审核错误项)的校验契约。"""
1428
+
1429
+ def _result(self, **over):
1430
+ r = load_sample()
1431
+ if "level" in over:
1432
+ r["aiScorecard"]["level"] = over["level"]
1433
+ return r
1434
+
1435
+ def _dim(self, r, key):
1436
+ return next(d for d in r["aiScorecard"]["dimensions"] if d["key"] == key)
1437
+
1438
+ def test_sample_scorecard_is_valid(self):
1439
+ self.assertEqual([], delivery.validate(load_sample()))
1440
+
1441
+ def test_missing_dimension_is_rejected(self):
1442
+ r = load_sample()
1443
+ r["aiScorecard"]["dimensions"] = [d for d in r["aiScorecard"]["dimensions"]
1444
+ if d["key"] != "judgment_quality"]
1445
+ self.assertTrue(any("恰好覆盖六维" in e for e in delivery.validate(r)))
1446
+
1447
+ def test_score_out_of_range_or_step_is_rejected(self):
1448
+ for bad in (10.5, -0.5, 6.3):
1449
+ r = load_sample()
1450
+ self._dim(r, "self_correction")["score"] = bad
1451
+ self.assertTrue(any("0–10 且 0.5 的整数倍" in e for e in delivery.validate(r)), bad)
1452
+
1453
+ def test_empty_basis_is_rejected(self):
1454
+ r = load_sample()
1455
+ self._dim(r, "coverage_completeness")["basis"] = " "
1456
+ self.assertTrue(any("basis 不得为空" in e for e in delivery.validate(r)))
1457
+
1458
+ def test_ai_only_must_be_recomputable(self):
1459
+ r = load_sample()
1460
+ r["aiScorecard"]["composites"]["aiOnly"] = 9.5
1461
+ self.assertTrue(any("aiOnly 必须可由六维均值" in e for e in delivery.validate(r)))
1462
+
1463
+ def test_correction_from_must_equal_first_score(self):
1464
+ r = load_sample()
1465
+ r["aiScorecard"]["corrections"][0]["from"] = 5.0
1466
+ self.assertTrue(any("只增不覆盖" in e for e in delivery.validate(r)))
1467
+
1468
+ def test_correction_requires_reason(self):
1469
+ r = load_sample()
1470
+ r["aiScorecard"]["corrections"][0]["reason"] = ""
1471
+ self.assertTrue(any("校正必须给理由" in e for e in delivery.validate(r)))
1472
+
1473
+ def test_first_review_with_corrections_is_rejected(self):
1474
+ r = self._result(level="初审")
1475
+ r["aiScorecard"]["corrections"] = []
1476
+ r["aiScorecard"]["composites"].pop("withHumanLoop", None)
1477
+ self.assertEqual([], delivery.validate(r))
1478
+ r["aiScorecard"]["corrections"] = [{"key": "self_correction", "from": 7.0, "to": 7.5, "reason": "x"}]
1479
+ self.assertTrue(any("初审不得事后校正" in e for e in delivery.validate(r)))
1480
+
1481
+ def test_second_review_requires_recomputable_with_human_loop(self):
1482
+ r = load_sample()
1483
+ r["aiScorecard"]["composites"]["withHumanLoop"] = 9.0
1484
+ self.assertTrue(any("withHumanLoop 必须可由复审校正后" in e for e in delivery.validate(r)))
1485
+
1486
+ def test_invalid_error_kind_and_duplicate_id_are_rejected(self):
1487
+ r = load_sample()
1488
+ r["selfAuditErrors"][0]["kind"] = "typo"
1489
+ r["selfAuditErrors"][1]["errorId"] = r["selfAuditErrors"][0]["errorId"]
1490
+ errors = delivery.validate(r)
1491
+ self.assertTrue(any("kind 取值非法" in e for e in errors))
1492
+ self.assertTrue(any("errorId 重复" in e for e in errors))
1493
+
1494
+ def test_closed_false_positive_cannot_still_be_an_active_issue(self):
1495
+ r = load_sample()
1496
+ r["selfAuditErrors"][0] = {
1497
+ "errorId": "SAE-001",
1498
+ "kind": "false_positive",
1499
+ "discoveredAt": "复审",
1500
+ "description": "复审确认该条属于误报并已撤回",
1501
+ "correction": "撤回该条",
1502
+ "status": "closed",
1503
+ "issueId": r["issues"][0]["issueId"],
1504
+ }
1505
+ self.assertTrue(any("已关闭假阳性不得仍保留在 issues" in e for e in delivery.validate(r)))
1506
+
1507
+ def test_scorecard_renders_as_tables(self):
1508
+ html = delivery.render(with_structured_review_comparison())
1509
+ self.assertIn('id="ai-scorecard"', html)
1510
+ for label in ("AI 审核表现评分卡", "综合命中率", "综合精确命中率",
1511
+ "按复核级次", "按问题模块", "展开 AI 自查错误记录"):
1512
+ self.assertIn(label, html)
1513
+
1514
+
1515
+ def _source_doc(start: Path, *parts: str) -> "Path | None":
1516
+ """源仓侧的目录同步文档:从技能目录向上找第一个存在的那份。
1517
+
1518
+ 只有源仓维护环境才有它们;技能以副本安装时找不到 → 调用方显式 skip(不静默通过)。
1519
+ """
1520
+ for candidate in (start, *start.parents):
1521
+ path = candidate.joinpath(*parts)
1522
+ if path.is_file():
1523
+ return path
1524
+ return None
1525
+
1526
+
1527
+ class DeliveryContractDocumentationTest(unittest.TestCase):
1528
+ """送达版本、JSON-first 命令和展示映射必须在安装包内同步。"""
1529
+
1530
+ def test_v16_contract_versions_and_json_first_terms_are_synchronized(self):
1531
+ skill_root = Path(__file__).resolve().parent.parent
1532
+ # 目录侧的同步点是**源仓文档**(技能索引与 CLI 变更记录),安装副本里不存在 → 显式 skip。
1533
+ catalog = _source_doc(skill_root, "docs", "skills.md")
1534
+ changelog_path = _source_doc(skill_root, "docs", "v0.0.1", "CHANGELOG.md")
1535
+ if catalog is None or changelog_path is None:
1536
+ self.skipTest("源仓侧文档缺失(安装副本内):跳过目录同步断言")
1537
+ spec = (skill_root / "references" / "11-html-delivery-spec.md").read_text(encoding="utf-8")
1538
+ scripts_readme = (skill_root / "scripts" / "README.md").read_text(encoding="utf-8")
1539
+ skill = (skill_root / "SKILL.md").read_text(encoding="utf-8")
1540
+ skills_catalog = catalog.read_text(encoding="utf-8")
1541
+ changelog = changelog_path.read_text(encoding="utf-8")
1542
+
1543
+ self.assertEqual("renderer/1.2.6", delivery.RENDERER_VERSION)
1544
+ for content in (spec, scripts_readme, skill, skills_catalog, changelog):
1545
+ self.assertIn("v1.6", content)
1546
+ for content in (spec, scripts_readme, skill):
1547
+ self.assertIn("--json-out", content)
1548
+ self.assertIn("JSON 校验", content)
1549
+ self.assertIn("summary.counts.issuesTotal", spec)
1550
+ self.assertIn("issues[].locationSummary", spec)
1551
+ self.assertIn("issues[].materialEvidence[]", spec)
1552
+ self.assertIn("manualConfirmationItems[]", spec)
1553
+ self.assertIn("scope.notCheckedItems[]", spec)
1554
+ self.assertIn("externalDataVerification.applicability.status", spec)
1555
+ self.assertIn("HTML 内嵌", spec)
1556
+
1557
+ def test_scripts_readme_has_complete_inventory_and_defers_mapping_to_spec(self):
1558
+ """F-017:`scripts/README.md` 只留目录与回指;编排调用映射的唯一事实源仍是规范 §14.1。"""
1559
+ skill_root = Path(__file__).resolve().parent.parent
1560
+ spec = (skill_root / "references" / "11-html-delivery-spec.md").read_text(encoding="utf-8")
1561
+ readme = (skill_root / "scripts" / "README.md").read_text(encoding="utf-8")
1562
+ skill = (skill_root / "SKILL.md").read_text(encoding="utf-8")
1563
+ scripts_root = skill_root / "scripts"
1564
+
1565
+ # 1) 权威正文仍在 §14.1:标题与四行阶段映射都不得被改写或搬迁。
1566
+ self.assertIn("### 14.1 编排层调用映射(脚本接入)", spec, "§14.1 标题必须保留")
1567
+ for row in (
1568
+ "| 阶段一冻结(步骤 11) |",
1569
+ "| 阶段二收口 · 交付校验(步骤 14 ①) |",
1570
+ "| 阶段二收口 · 渲染(步骤 14 ②) |",
1571
+ "| 交付(步骤 14 ③) |",
1572
+ ):
1573
+ self.assertIn(row, spec, f"§14.1 必须保留阶段映射行:{row}")
1574
+
1575
+ # 2) README 只保留回指:可点击相对链接(同时显示完整文件名与 §14.1)+ 唯一事实源声明。
1576
+ link = re.search(
1577
+ r"\[[^\]]*11-html-delivery-spec\.md[^\]]*§14\.1[^\]]*\]"
1578
+ r"\(\.\./references/11-html-delivery-spec\.md[^)]*\)",
1579
+ readme,
1580
+ )
1581
+ self.assertIsNotNone(
1582
+ link, "README 必须以相对链接回指 11-html-delivery-spec.md §14.1",
1583
+ )
1584
+ self.assertIn("唯一事实源", readme, "README 必须声明 §14.1 为编排调用映射的唯一事实源")
1585
+ for banned in (
1586
+ "| 编排阶段 | 命令 | 失败处理 |",
1587
+ "| 阶段一冻结(router 步骤 11) |",
1588
+ "| 阶段二收口(router 步骤 14 ①) |",
1589
+ "| 阶段二收口(router 步骤 14 ②) |",
1590
+ "| 交付(router 步骤 14 ③) |",
1591
+ ):
1592
+ self.assertNotIn(banned, readme, f"README 不得保留第二张编排映射表:{banned}")
1593
+
1594
+ # 3) README 的 Python 目录必须与磁盘精确一致(缺一或多一都失败)。
1595
+ listed = set(
1596
+ re.findall(
1597
+ r"`([A-Za-z0-9_]+\.py)`",
1598
+ "\n".join(line for line in readme.splitlines() if line.startswith("|")),
1599
+ )
1600
+ )
1601
+ on_disk = {path.name for path in scripts_root.glob("*.py")}
1602
+ self.assertEqual(
1603
+ on_disk, listed,
1604
+ f"README 目录与磁盘不一致:缺 {sorted(on_disk - listed)},多 {sorted(listed - on_disk)}",
1605
+ )
1606
+
1607
+ # 4) SKILL.md 必须提供一层入口,并在同一处说明职责与真正的事实源。
1608
+ entry_lines = [
1609
+ line for line in skill.splitlines()
1610
+ if "[scripts/README.md](scripts/README.md)" in line
1611
+ ]
1612
+ self.assertTrue(entry_lines, "SKILL.md 必须链接 [scripts/README.md](scripts/README.md)")
1613
+ entry = "\n".join(entry_lines)
1614
+ self.assertIn("脚本目录", entry, "入口必须写明它是脚本目录")
1615
+ self.assertTrue("用法" in entry and "维护" in entry, "入口必须写明它含用法与维护入口")
1616
+ self.assertIn("11-html-delivery-spec.md", entry, "同一处必须声明交付/编排映射仍以规范为准")
1617
+
1618
+ # 5) F-017-R1:三个新增目录项的**规范归口**必须正确 —— 运行 owner 在 14,不在 11。
1619
+ # §14.1 只定义 audit_delivery 的 digest/validate/render 与文件交付;§13.4 是
1620
+ # AuditResult/renderer 的推荐测试,二者都不是取回复核件或多轴 router 的规范。
1621
+ orchestration = (
1622
+ skill_root / "references" / "14-orchestration-workflow.md"
1623
+ ).read_text(encoding="utf-8")
1624
+ for token in ("排除清单", "复核-人工/", "材料-源/"):
1625
+ self.assertIn(token, orchestration, f"14 必须保留安全边界标识:{token}")
1626
+
1627
+ def _step(text: str, number: int) -> str:
1628
+ """截取 14 中编号为 `number` 的步骤段落(至下一个编号步骤为止)。"""
1629
+ start = text.find(f"\n{number}. **")
1630
+ if start < 0:
1631
+ return ""
1632
+ following = re.search(rf"\n{number + 1}\. \*\*", text[start:])
1633
+ return text[start:start + following.start()] if following else text[start:]
1634
+
1635
+ for label, number in (("步骤 2", 2), ("步骤 12", 12)):
1636
+ self.assertIn(
1637
+ "scripts/fetch_review_records.py", _step(orchestration, number),
1638
+ f"运行时 owner:14 {label} 必须含取回复核件的脚本路径",
1639
+ )
1640
+
1641
+ def _owner_cell(filename: str) -> str:
1642
+ """README 目录表中该文件所在行的『对应规范/口径』单元格(按**首列**精确匹配)。
1643
+
1644
+ 不能按"行内含反引号文件名"匹配:`test_fetch_review_records.py` 行的作用列会
1645
+ 提到 `fetch_review_records.py`,那样会把两行都算进来。
1646
+ """
1647
+ rows = []
1648
+ for line in readme.splitlines():
1649
+ if not line.startswith("|"):
1650
+ continue
1651
+ cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
1652
+ if cells and cells[0] == f"`{filename}`":
1653
+ rows.append(cells)
1654
+ self.assertEqual(1, len(rows), f"README 目录表应有且仅有一行 {filename}")
1655
+ return rows[0][-1]
1656
+
1657
+ fetch_owner = _owner_cell("fetch_review_records.py")
1658
+ self.assertIn("14-orchestration-workflow.md", fetch_owner, "取回脚本必须回指 14")
1659
+ self.assertIn("步骤 2", fetch_owner, "取回脚本必须回指 14 步骤 2")
1660
+ self.assertIn("步骤 12", fetch_owner, "取回脚本必须回指 14 步骤 12")
1661
+ self.assertNotIn("§14.1", fetch_owner, "取回脚本不归交付规范 §14.1")
1662
+ self.assertNotIn("§13.4", fetch_owner, "取回脚本不归交付规范 §13.4")
1663
+
1664
+ fetch_test_owner = _owner_cell("test_fetch_review_records.py")
1665
+ self.assertIn("14-orchestration-workflow.md", fetch_test_owner, "取回脚本测试必须回指 14")
1666
+ self.assertIn("步骤 2", fetch_test_owner, "取回脚本测试必须回指 14 步骤 2")
1667
+ self.assertIn("步骤 12", fetch_test_owner, "取回脚本测试必须回指 14 步骤 12")
1668
+ self.assertNotIn("§13.4", fetch_test_owner, "取回脚本测试不归交付规范 §13.4")
1669
+
1670
+ router_test_owner = _owner_cell("test_audit_multiaxis_router.py")
1671
+ self.assertIn("SKILL.md", router_test_owner, "router 测试必须回指 router 入口")
1672
+ self.assertIn("references/00–14", router_test_owner, "router 测试必须回指 router references 全段")
1673
+ self.assertNotIn("§13.4", router_test_owner, "router 测试不归交付规范 §13.4")
1674
+
1675
+
1676
+
1677
+ if __name__ == "__main__":
1678
+ unittest.main(verbosity=2)