@huicai/cli 2026.814.0 → 2026.1019.0

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 (485) hide show
  1. package/LICENSE +391 -32
  2. package/README.md +137 -8
  3. package/bin/huicai.js +146 -73
  4. package/package.json +27 -8
  5. package/skills/ab-testing/trigger-cases.json +30 -0
  6. package/skills/academic-writing/trigger-cases.json +30 -0
  7. package/skills/accounting-ops/trigger-cases.json +30 -0
  8. package/skills/ai-agent/trigger-cases.json +30 -0
  9. package/skills/ai-aiops/trigger-cases.json +30 -0
  10. package/skills/ai-coding/trigger-cases.json +30 -0
  11. package/skills/ai-compliance/trigger-cases.json +30 -0
  12. package/skills/ai-eval/trigger-cases.json +30 -0
  13. package/skills/ai-llm-app/trigger-cases.json +30 -0
  14. package/skills/ai-mlops/trigger-cases.json +30 -0
  15. package/skills/ai-multimodal/trigger-cases.json +30 -0
  16. package/skills/ai-prompt/trigger-cases.json +30 -0
  17. package/skills/ai-rag/trigger-cases.json +30 -0
  18. package/skills/anomaly-detection/trigger-cases.json +30 -0
  19. package/skills/api-design/trigger-cases.json +31 -0
  20. package/skills/api-integration/trigger-cases.json +31 -0
  21. package/skills/arch-design/trigger-cases.json +12 -0
  22. package/skills/arch-diagram/SKILL.md +118 -0
  23. package/skills/arch-diagram/trigger-cases.json +30 -0
  24. package/skills/arch-distributed/trigger-cases.json +30 -0
  25. package/skills/arch-multitenant/trigger-cases.json +30 -0
  26. package/skills/audio-brief/SKILL.md +65 -0
  27. package/skills/audio-brief/trigger-cases.json +30 -0
  28. package/skills/audit-assist/trigger-cases.json +30 -0
  29. package/skills/audit-chain/trigger-cases.json +31 -0
  30. package/skills/banking-credit/trigger-cases.json +30 -0
  31. package/skills/brand-design/trigger-cases.json +30 -0
  32. package/skills/business-plan/trigger-cases.json +30 -0
  33. package/skills/capital-ops/trigger-cases.json +30 -0
  34. package/skills/cicd-pipeline/trigger-cases.json +31 -0
  35. package/skills/code-archaeology/SKILL.md +55 -0
  36. package/skills/code-archaeology/trigger-cases.json +9 -0
  37. package/skills/code-quality/SKILL.md +138 -0
  38. package/skills/code-quality/references/languages.md +107 -0
  39. package/skills/code-quality/scripts/__pycache__/code_battery.cpython-312.pyc +0 -0
  40. package/skills/code-quality/scripts/__pycache__/code_check.cpython-312.pyc +0 -0
  41. package/skills/code-quality/scripts/__pycache__/code_check.cpython-313.pyc +0 -0
  42. package/skills/code-quality/scripts/__pycache__/code_csharp.cpython-312.pyc +0 -0
  43. package/skills/code-quality/scripts/__pycache__/code_csharp.cpython-313.pyc +0 -0
  44. package/skills/code-quality/scripts/__pycache__/code_fix.cpython-312.pyc +0 -0
  45. package/skills/code-quality/scripts/__pycache__/code_fix.cpython-313.pyc +0 -0
  46. package/skills/code-quality/scripts/__pycache__/code_java.cpython-312.pyc +0 -0
  47. package/skills/code-quality/scripts/__pycache__/code_java.cpython-313.pyc +0 -0
  48. package/skills/code-quality/scripts/__pycache__/code_jsts.cpython-312.pyc +0 -0
  49. package/skills/code-quality/scripts/__pycache__/code_jsts.cpython-313.pyc +0 -0
  50. package/skills/code-quality/scripts/__pycache__/code_lens.cpython-312.pyc +0 -0
  51. package/skills/code-quality/scripts/__pycache__/code_lens.cpython-313.pyc +0 -0
  52. package/skills/code-quality/scripts/__pycache__/code_python.cpython-312.pyc +0 -0
  53. package/skills/code-quality/scripts/__pycache__/code_python.cpython-313.pyc +0 -0
  54. package/skills/code-quality/scripts/__pycache__/code_testplan.cpython-312.pyc +0 -0
  55. package/skills/code-quality/scripts/code_battery.py +1072 -0
  56. package/skills/code-quality/scripts/code_check.py +1269 -0
  57. package/skills/code-quality/scripts/code_csharp.py +371 -0
  58. package/skills/code-quality/scripts/code_fix.py +201 -0
  59. package/skills/code-quality/scripts/code_java.py +695 -0
  60. package/skills/code-quality/scripts/code_jsts.py +313 -0
  61. package/skills/code-quality/scripts/code_lens.py +1079 -0
  62. package/skills/code-quality/scripts/code_python.py +536 -0
  63. package/skills/code-quality/scripts/code_testplan.py +239 -0
  64. package/skills/code-quality/trigger-cases.json +45 -0
  65. package/skills/code-review/SKILL.md +24 -0
  66. package/skills/code-review/trigger-cases.json +35 -0
  67. package/skills/commit-and-pr/trigger-cases.json +31 -0
  68. package/skills/content-creation/trigger-cases.json +30 -0
  69. package/skills/corporate-governance/trigger-cases.json +30 -0
  70. package/skills/crawl-chain/trigger-cases.json +31 -0
  71. package/skills/customer-service/trigger-cases.json +30 -0
  72. package/skills/data-analysis/trigger-cases.json +30 -0
  73. package/skills/data-chain/SKILL.md +1 -1
  74. package/skills/data-chain/trigger-cases.json +31 -0
  75. package/skills/data-governance/trigger-cases.json +30 -0
  76. package/skills/data-report-pro/trigger-cases.json +30 -0
  77. package/skills/data-streaming/trigger-cases.json +30 -0
  78. package/skills/data-wrangling/SKILL.md +1 -1
  79. package/skills/data-wrangling/trigger-cases.json +30 -0
  80. package/skills/db-es/trigger-cases.json +30 -0
  81. package/skills/db-migration/trigger-cases.json +30 -0
  82. package/skills/db-mysql/trigger-cases.json +30 -0
  83. package/skills/db-postgres/trigger-cases.json +30 -0
  84. package/skills/db-redis/trigger-cases.json +30 -0
  85. package/skills/debug-root-cause/trigger-cases.json +30 -0
  86. package/skills/dependency-audit/SKILL.md +56 -0
  87. package/skills/dependency-audit/trigger-cases.json +7 -0
  88. package/skills/dependency-upgrade/trigger-cases.json +31 -0
  89. package/skills/dev-cpp/trigger-cases.json +30 -0
  90. package/skills/dev-cross-platform/trigger-cases.json +30 -0
  91. package/skills/dev-csharp/SKILL.md +23 -0
  92. package/skills/dev-csharp/trigger-cases.json +30 -0
  93. package/skills/dev-frontend/SKILL.md +19 -0
  94. package/skills/dev-frontend/trigger-cases.json +30 -0
  95. package/skills/dev-go/trigger-cases.json +30 -0
  96. package/skills/dev-harmony/trigger-cases.json +30 -0
  97. package/skills/dev-java/SKILL.md +20 -0
  98. package/skills/dev-java/trigger-cases.json +30 -0
  99. package/skills/dev-mobile/trigger-cases.json +30 -0
  100. package/skills/dev-node/trigger-cases.json +30 -0
  101. package/skills/dev-php/trigger-cases.json +30 -0
  102. package/skills/dev-python/SKILL.md +21 -0
  103. package/skills/dev-python/trigger-cases.json +30 -0
  104. package/skills/dev-rust/trigger-cases.json +30 -0
  105. package/skills/dev-toolchain/trigger-cases.json +31 -0
  106. package/skills/doc-chain/trigger-cases.json +31 -0
  107. package/skills/doc-compare/trigger-cases.json +30 -0
  108. package/skills/docx-author/SKILL.md +127 -25
  109. package/skills/docx-author/scripts/__pycache__/docx_battery.cpython-312.pyc +0 -0
  110. package/skills/docx-author/scripts/__pycache__/docx_build.cpython-312.pyc +0 -0
  111. package/skills/docx-author/scripts/__pycache__/docx_check.cpython-312.pyc +0 -0
  112. package/skills/docx-author/scripts/__pycache__/docx_layout.cpython-312.pyc +0 -0
  113. package/skills/docx-author/scripts/__pycache__/zh_prose.cpython-312.pyc +0 -0
  114. package/skills/docx-author/scripts/docx_battery.py +586 -0
  115. package/skills/docx-author/scripts/docx_build.py +995 -0
  116. package/skills/docx-author/scripts/docx_check.py +1306 -0
  117. package/skills/docx-author/scripts/docx_doc.py +14 -154
  118. package/skills/docx-author/scripts/docx_layout.py +490 -0
  119. package/skills/docx-author/scripts/zh_prose.py +639 -0
  120. package/skills/docx-author/trigger-cases.json +58 -0
  121. package/skills/echo-brief/SKILL.md +84 -0
  122. package/skills/echo-brief/references/echo-format.md +68 -0
  123. package/skills/echo-brief/trigger-cases.json +40 -0
  124. package/skills/ecommerce-ops/trigger-cases.json +30 -0
  125. package/skills/education-training/trigger-cases.json +30 -0
  126. package/skills/ehs-safety/trigger-cases.json +30 -0
  127. package/skills/email-writing/trigger-cases.json +30 -0
  128. package/skills/eng-ai/trigger-cases.json +30 -0
  129. package/skills/eng-architecture/trigger-cases.json +31 -0
  130. package/skills/eng-automation/trigger-cases.json +31 -0
  131. package/skills/eng-collaboration/trigger-cases.json +30 -0
  132. package/skills/eng-complex-env/trigger-cases.json +30 -0
  133. package/skills/eng-data-platform/trigger-cases.json +31 -0
  134. package/skills/eng-database/trigger-cases.json +31 -0
  135. package/skills/eng-develop/trigger-cases.json +30 -0
  136. package/skills/eng-docs/SKILL.md +24 -0
  137. package/skills/eng-docs/trigger-cases.json +30 -0
  138. package/skills/eng-efficiency/trigger-cases.json +30 -0
  139. package/skills/eng-infra/trigger-cases.json +30 -0
  140. package/skills/eng-integration/trigger-cases.json +31 -0
  141. package/skills/eng-legacy/trigger-cases.json +31 -0
  142. package/skills/eng-observability/trigger-cases.json +31 -0
  143. package/skills/eng-ops/trigger-cases.json +31 -0
  144. package/skills/eng-performance/trigger-cases.json +31 -0
  145. package/skills/eng-planning/trigger-cases.json +31 -0
  146. package/skills/eng-quality/SKILL.md +8 -0
  147. package/skills/eng-quality/trigger-cases.json +31 -0
  148. package/skills/eng-release/trigger-cases.json +31 -0
  149. package/skills/eng-reliability/trigger-cases.json +31 -0
  150. package/skills/eng-requirements/trigger-cases.json +12 -0
  151. package/skills/eng-research/trigger-cases.json +30 -0
  152. package/skills/eng-security/trigger-cases.json +31 -0
  153. package/skills/eng-ux/trigger-cases.json +30 -0
  154. package/skills/engineering-chain/trigger-cases.json +30 -0
  155. package/skills/env-offline/trigger-cases.json +31 -0
  156. package/skills/env-private-deploy/trigger-cases.json +31 -0
  157. package/skills/env-xinchuang/trigger-cases.json +31 -0
  158. package/skills/equipment-tpm/trigger-cases.json +30 -0
  159. package/skills/esg-report/trigger-cases.json +30 -0
  160. package/skills/event-management/trigger-cases.json +30 -0
  161. package/skills/excel-analysis-chain/SKILL.md +17 -4
  162. package/skills/excel-analysis-chain/trigger-cases.json +31 -0
  163. package/skills/explain-codebase/trigger-cases.json +11 -0
  164. package/skills/file-ingest/trigger-cases.json +30 -0
  165. package/skills/finance-analysis/trigger-cases.json +31 -0
  166. package/skills/finance-chain/trigger-cases.json +31 -0
  167. package/skills/financial-modeling/trigger-cases.json +31 -0
  168. package/skills/find-simplifications/SKILL.md +43 -0
  169. package/skills/find-simplifications/trigger-cases.json +23 -0
  170. package/skills/foreign-trade/trigger-cases.json +30 -0
  171. package/skills/funnel-cohort/trigger-cases.json +30 -0
  172. package/skills/gov-document/trigger-cases.json +30 -0
  173. package/skills/gui-change-evidence/SKILL.md +47 -0
  174. package/skills/gui-change-evidence/trigger-cases.json +23 -0
  175. package/skills/hr-chain/trigger-cases.json +31 -0
  176. package/skills/hr-operations/trigger-cases.json +30 -0
  177. package/skills/info-disclosure/trigger-cases.json +31 -0
  178. package/skills/infra-gateway/trigger-cases.json +31 -0
  179. package/skills/infra-k8s/trigger-cases.json +31 -0
  180. package/skills/infra-mq/trigger-cases.json +30 -0
  181. package/skills/insurance-advisory/trigger-cases.json +30 -0
  182. package/skills/internal-audit/trigger-cases.json +30 -0
  183. package/skills/ip-management/trigger-cases.json +31 -0
  184. package/skills/ipo-listing/trigger-cases.json +31 -0
  185. package/skills/it-audit/trigger-cases.json +31 -0
  186. package/skills/itsm-ops/trigger-cases.json +31 -0
  187. package/skills/knowledge-sop/trigger-cases.json +31 -0
  188. package/skills/legal-chain/trigger-cases.json +31 -0
  189. package/skills/legal-contract/trigger-cases.json +30 -0
  190. package/skills/litigation-support/trigger-cases.json +31 -0
  191. package/skills/logistics-warehouse/trigger-cases.json +30 -0
  192. package/skills/long-doc-analysis/trigger-cases.json +31 -0
  193. package/skills/management-accounting/trigger-cases.json +30 -0
  194. package/skills/manufacturing-chain/trigger-cases.json +31 -0
  195. package/skills/manufacturing-quality/trigger-cases.json +30 -0
  196. package/skills/marketing-analytics/trigger-cases.json +31 -0
  197. package/skills/marketing-chain/trigger-cases.json +31 -0
  198. package/skills/meeting-minutes/trigger-cases.json +30 -0
  199. package/skills/metrics-system/trigger-cases.json +31 -0
  200. package/skills/negotiation-prep/trigger-cases.json +30 -0
  201. package/skills/obs-prometheus/trigger-cases.json +31 -0
  202. package/skills/office-automation/trigger-cases.json +31 -0
  203. package/skills/okr-review/trigger-cases.json +30 -0
  204. package/skills/ops-fleet/trigger-cases.json +31 -0
  205. package/skills/ops-linux/trigger-cases.json +31 -0
  206. package/skills/optimization-solver/trigger-cases.json +30 -0
  207. package/skills/patent-disclosure/SKILL.md +105 -0
  208. package/skills/patent-disclosure/references/figures.md +110 -0
  209. package/skills/patent-disclosure/references/outline.md +162 -0
  210. package/skills/patent-disclosure/scripts/patent_docx.py +230 -0
  211. package/skills/patent-disclosure/trigger-cases.json +31 -0
  212. package/skills/patent-drafting/SKILL.md +108 -0
  213. package/skills/patent-drafting/trigger-cases.json +31 -0
  214. package/skills/patent-portfolio/SKILL.md +81 -0
  215. package/skills/patent-portfolio/trigger-cases.json +31 -0
  216. package/skills/pdf-toolkit/trigger-cases.json +31 -0
  217. package/skills/perf-optimize/trigger-cases.json +31 -0
  218. package/skills/pivot-crosstab/trigger-cases.json +30 -0
  219. package/skills/policy-analysis/trigger-cases.json +31 -0
  220. package/skills/portfolio-analysis/trigger-cases.json +31 -0
  221. package/skills/ppt-chain/SKILL.md +14 -6
  222. package/skills/ppt-chain/trigger-cases.json +31 -0
  223. package/skills/pptx-author/SKILL.md +86 -33
  224. package/skills/pptx-author/scripts/__pycache__/pptx_check.cpython-312.pyc +0 -0
  225. package/skills/pptx-author/scripts/__pycache__/pptx_deck.cpython-312.pyc +0 -0
  226. package/skills/pptx-author/scripts/__pycache__/pptx_layout.cpython-312.pyc +0 -0
  227. package/skills/pptx-author/scripts/pptx_battery.py +178 -0
  228. package/skills/pptx-author/scripts/pptx_build.py +119 -0
  229. package/skills/pptx-author/scripts/pptx_check.py +1182 -0
  230. package/skills/pptx-author/scripts/pptx_deck.py +736 -145
  231. package/skills/pptx-author/scripts/pptx_layout.py +384 -0
  232. package/skills/pptx-author/trigger-cases.json +15 -0
  233. package/skills/pr-comms/trigger-cases.json +30 -0
  234. package/skills/procurement-supply/trigger-cases.json +31 -0
  235. package/skills/product-design/trigger-cases.json +31 -0
  236. package/skills/production-planning/trigger-cases.json +31 -0
  237. package/skills/project-management/trigger-cases.json +31 -0
  238. package/skills/project-proposal/trigger-cases.json +31 -0
  239. package/skills/prose-standard/SKILL.md +53 -0
  240. package/skills/prose-standard/trigger-cases.json +23 -0
  241. package/skills/qa-api-automation/trigger-cases.json +31 -0
  242. package/skills/qa-mobile-automation/trigger-cases.json +31 -0
  243. package/skills/qa-ui-automation/trigger-cases.json +31 -0
  244. package/skills/quant-analysis/trigger-cases.json +31 -0
  245. package/skills/rag-triage/SKILL.md +64 -0
  246. package/skills/rag-triage/evals.json +57 -0
  247. package/skills/rag-triage/references/triage-playbook.md +75 -0
  248. package/skills/rag-triage/trigger-cases.json +31 -0
  249. package/skills/real-estate-property/trigger-cases.json +30 -0
  250. package/skills/refactor-safely/SKILL.md +1 -1
  251. package/skills/refactor-safely/trigger-cases.json +30 -0
  252. package/skills/regression-forecast/trigger-cases.json +31 -0
  253. package/skills/release-checklist/trigger-cases.json +31 -0
  254. package/skills/report-craft/SKILL.md +76 -0
  255. package/skills/report-craft/references/feasibility.md +118 -0
  256. package/skills/report-craft/references/review-checklist.md +51 -0
  257. package/skills/report-craft/references/visuals.md +114 -0
  258. package/skills/report-craft/references/writing.md +80 -0
  259. package/skills/report-craft/scripts/__pycache__/chart_render.cpython-312.pyc +0 -0
  260. package/skills/report-craft/scripts/__pycache__/fin_model.cpython-312.pyc +0 -0
  261. package/skills/report-craft/scripts/__pycache__/project_facts.cpython-312.pyc +0 -0
  262. package/skills/report-craft/scripts/chart_render.py +1222 -0
  263. package/skills/report-craft/scripts/fin_model.py +602 -0
  264. package/skills/report-craft/scripts/project_facts.py +580 -0
  265. package/skills/report-craft/scripts/report_battery.py +349 -0
  266. package/skills/report-craft/trigger-cases.json +15 -0
  267. package/skills/retail-store-ops/trigger-cases.json +30 -0
  268. package/skills/risk-compliance/trigger-cases.json +31 -0
  269. package/skills/scope-creep/SKILL.md +45 -0
  270. package/skills/scope-creep/trigger-cases.json +7 -0
  271. package/skills/sec-auth/trigger-cases.json +31 -0
  272. package/skills/sec-compliance/trigger-cases.json +31 -0
  273. package/skills/securities-chain/trigger-cases.json +31 -0
  274. package/skills/securities-research/trigger-cases.json +31 -0
  275. package/skills/security-audit/trigger-cases.json +31 -0
  276. package/skills/slides-html/SKILL.md +25 -10
  277. package/skills/slides-html/trigger-cases.json +11 -0
  278. package/skills/sql-analysis/SKILL.md +110 -0
  279. package/skills/sql-analysis/trigger-cases.json +34 -0
  280. package/skills/sql-tuning/trigger-cases.json +31 -0
  281. package/skills/statistical-testing/trigger-cases.json +31 -0
  282. package/skills/strategy-planning/trigger-cases.json +30 -0
  283. package/skills/supply-chain/trigger-cases.json +31 -0
  284. package/skills/tax-compliance/trigger-cases.json +30 -0
  285. package/skills/tech-doc/SKILL.md +262 -0
  286. package/skills/tech-doc/scripts/__pycache__/archive_battery.cpython-312.pyc +0 -0
  287. package/skills/tech-doc/scripts/__pycache__/archive_build.cpython-312.pyc +0 -0
  288. package/skills/tech-doc/scripts/__pycache__/archive_check.cpython-312.pyc +0 -0
  289. package/skills/tech-doc/scripts/__pycache__/archive_spec.cpython-312.pyc +0 -0
  290. package/skills/tech-doc/scripts/__pycache__/doc_templates.cpython-312.pyc +0 -0
  291. package/skills/tech-doc/scripts/archive_battery.py +860 -0
  292. package/skills/tech-doc/scripts/archive_build.py +599 -0
  293. package/skills/tech-doc/scripts/archive_check.py +1307 -0
  294. package/skills/tech-doc/scripts/archive_spec.py +499 -0
  295. package/skills/tech-doc/scripts/doc_templates.py +624 -0
  296. package/skills/tech-doc/trigger-cases.json +57 -0
  297. package/skills/tech-research/trigger-cases.json +31 -0
  298. package/skills/tender-bidding/trigger-cases.json +31 -0
  299. package/skills/translation-pro/trigger-cases.json +30 -0
  300. package/skills/treasury-mgmt/trigger-cases.json +31 -0
  301. package/skills/trim-cot-leakage/SKILL.md +58 -0
  302. package/skills/trim-cot-leakage/trigger-cases.json +23 -0
  303. package/skills/web-craft/SKILL.md +103 -0
  304. package/skills/web-craft/references/craft-checklist.md +75 -0
  305. package/skills/web-craft/references/design-direction.md +122 -0
  306. package/skills/web-craft/references/h5-mobile.md +100 -0
  307. package/skills/web-craft/references/motion.md +136 -0
  308. package/skills/web-craft/references/recipes.md +74 -0
  309. package/skills/web-craft/templates/wedding-invitation.html +1180 -0
  310. package/skills/web-craft/trigger-cases.json +14 -0
  311. package/skills/web-scraping/trigger-cases.json +34 -0
  312. package/skills/web-search/SKILL.md +11 -10
  313. package/skills/web-search/trigger-cases.json +31 -0
  314. package/skills/weekly-report/SKILL.md +134 -0
  315. package/skills/weekly-report/trigger-cases.json +11 -0
  316. package/skills/write-tests/trigger-cases.json +31 -0
  317. package/skills/xlsx-analysis/SKILL.md +143 -77
  318. package/skills/xlsx-analysis/scripts/__pycache__/data_profile.cpython-312.pyc +0 -0
  319. package/skills/xlsx-analysis/scripts/__pycache__/xlsx_build.cpython-312.pyc +0 -0
  320. package/skills/xlsx-analysis/scripts/__pycache__/xlsx_check.cpython-312.pyc +0 -0
  321. package/skills/xlsx-analysis/scripts/__pycache__/xlsx_layout.cpython-312.pyc +0 -0
  322. package/skills/xlsx-analysis/scripts/__pycache__/xlsx_toolkit.cpython-312.pyc +0 -0
  323. package/skills/xlsx-analysis/scripts/data_profile.py +391 -0
  324. package/skills/xlsx-analysis/scripts/xlsx_battery.py +389 -0
  325. package/skills/xlsx-analysis/scripts/xlsx_build.py +522 -0
  326. package/skills/xlsx-analysis/scripts/xlsx_check.py +924 -0
  327. package/skills/xlsx-analysis/scripts/xlsx_layout.py +235 -0
  328. package/skills/xlsx-analysis/scripts/xlsx_style.py +28 -66
  329. package/skills/xlsx-analysis/scripts/xlsx_toolkit.py +61 -5
  330. package/skills/xlsx-analysis/trigger-cases.json +59 -0
  331. package/squads/hc-defense.md +47 -0
  332. package/squads/hc-rdarchive.md +110 -0
  333. package/squads/hc-redteam.md +50 -0
  334. package/squads/hc-weekly.md +125 -0
  335. package/src/account.js +1 -1
  336. package/src/acp.js +1 -0
  337. package/src/advisor.js +1 -1
  338. package/src/agent.js +1 -1
  339. package/src/api.js +1 -1
  340. package/src/archaeology.js +1 -0
  341. package/src/ask-ui.js +1 -0
  342. package/src/assets-import.js +1 -1
  343. package/src/autofix.js +1 -1
  344. package/src/automation-tools.js +1 -1
  345. package/src/automation.js +1 -1
  346. package/src/autorun.js +1 -1
  347. package/src/banner.js +1 -1
  348. package/src/capability-probe.js +1 -0
  349. package/src/checkpoints.js +1 -1
  350. package/src/code-tools.js +1 -0
  351. package/src/codeindex.js +1 -1
  352. package/src/codemode.js +1 -0
  353. package/src/command-bridge.js +1 -0
  354. package/src/command-ui.js +1 -0
  355. package/src/commands.js +1 -1
  356. package/src/compaction.js +1 -1
  357. package/src/computer.js +1 -1
  358. package/src/config.js +1 -1
  359. package/src/consult.js +1 -1
  360. package/src/context-report.js +1 -1
  361. package/src/context-window.js +1 -0
  362. package/src/context.js +1 -1
  363. package/src/crew.js +1 -0
  364. package/src/crush.js +1 -0
  365. package/src/daemon.js +1 -1
  366. package/src/decisions.js +1 -1
  367. package/src/depaudit.js +1 -0
  368. package/src/device.js +1 -1
  369. package/src/diagram-draw.js +1 -0
  370. package/src/diagram-layouts.js +1 -0
  371. package/src/diagram-raster.js +1 -0
  372. package/src/diagram.js +1 -0
  373. package/src/dictation.js +1 -0
  374. package/src/diffview.js +1 -1
  375. package/src/doctor.js +1 -1
  376. package/src/docx-tools.js +1 -0
  377. package/src/errkind.js +1 -1
  378. package/src/evidence.js +1 -0
  379. package/src/executors.js +1 -1
  380. package/src/expert-catalog.js +1 -1
  381. package/src/experts-store.js +1 -1
  382. package/src/experts.js +1 -1
  383. package/src/external-agents.js +1 -0
  384. package/src/forks.js +1 -1
  385. package/src/fs-observe.js +1 -0
  386. package/src/gitflow.js +1 -1
  387. package/src/goalround.js +1 -0
  388. package/src/hooks.js +1 -1
  389. package/src/input.js +1 -1
  390. package/src/insights.js +1 -1
  391. package/src/jobs.js +1 -0
  392. package/src/kbsync.js +1 -1
  393. package/src/lsp.js +1 -0
  394. package/src/mcp-apps.js +1 -0
  395. package/src/mcp.js +1 -1
  396. package/src/memory.js +1 -1
  397. package/src/notify.js +1 -1
  398. package/src/permissions.js +1 -1
  399. package/src/plan-limits.js +1 -0
  400. package/src/policy.js +1 -0
  401. package/src/pptx-tools.js +1 -0
  402. package/src/presence.js +1 -1
  403. package/src/privatedir.js +1 -0
  404. package/src/pyruntime.js +1 -1
  405. package/src/radar.js +1 -0
  406. package/src/render.js +1 -1
  407. package/src/repl.js +1 -1
  408. package/src/report-tools.js +1 -0
  409. package/src/routing.js +1 -0
  410. package/src/sandbox-probe.js +1 -0
  411. package/src/sandbox-win-launcher.mjs +156 -0
  412. package/src/sandbox.js +1 -0
  413. package/src/scan.js +1 -1
  414. package/src/schedule.js +1 -1
  415. package/src/scopecreep.js +1 -0
  416. package/src/sdk.js +1 -0
  417. package/src/selection.js +1 -0
  418. package/src/session-events.js +1 -0
  419. package/src/session.js +1 -1
  420. package/src/sessionindex.js +1 -0
  421. package/src/sessionlog.js +1 -0
  422. package/src/skill-catalog.js +1 -0
  423. package/src/skill-lint.js +1 -0
  424. package/src/skill-optimize.js +1 -0
  425. package/src/skill-scan.js +1 -0
  426. package/src/skills.js +1 -1
  427. package/src/spill.js +1 -0
  428. package/src/squad.js +1 -1
  429. package/src/subagent.js +1 -1
  430. package/src/surface-state.js +1 -0
  431. package/src/tapd.js +1 -0
  432. package/src/terminal-select.js +1 -0
  433. package/src/terminal.js +1 -0
  434. package/src/test-runner.js +1 -0
  435. package/src/todo.js +1 -1
  436. package/src/toolpipe.js +1 -0
  437. package/src/tools.js +1 -1
  438. package/src/toon.js +1 -0
  439. package/src/transcript.js +1 -1
  440. package/src/tui/app.js +1 -1
  441. package/src/tui/command-menu.js +1 -0
  442. package/src/tui/progress.js +1 -0
  443. package/src/tui/scroll.js +1 -0
  444. package/src/tui/welcome.js +1 -0
  445. package/src/tui/workbench.js +1 -0
  446. package/src/ui-spec.js +1 -0
  447. package/src/uitree.js +1 -0
  448. package/src/uninstall.js +1 -1
  449. package/src/visual.js +1 -1
  450. package/src/voice-form.js +1 -0
  451. package/src/voice.js +1 -0
  452. package/src/watch.js +1 -1
  453. package/src/web-audit.js +1 -0
  454. package/src/web-cdp.js +1 -0
  455. package/src/web-inspect.js +1 -0
  456. package/src/websearch.js +1 -0
  457. package/src/weekly.js +1 -0
  458. package/src/workflow.js +1 -0
  459. package/src/worklog.js +1 -0
  460. package/src/xlsx-tools.js +1 -0
  461. package/ui/command-picker.js +79 -0
  462. package/skills/ab-testing/scripts/__pycache__/ab_test.cpython-312.pyc +0 -0
  463. package/skills/accounting-ops/scripts/__pycache__/acct_tools.cpython-312.pyc +0 -0
  464. package/skills/anomaly-detection/scripts/__pycache__/anomaly.cpython-312.pyc +0 -0
  465. package/skills/audit-assist/scripts/__pycache__/audit_sampling.cpython-312.pyc +0 -0
  466. package/skills/banking-credit/scripts/__pycache__/credit_metrics.cpython-312.pyc +0 -0
  467. package/skills/data-report-pro/scripts/__pycache__/bigdata.cpython-312.pyc +0 -0
  468. package/skills/data-wrangling/scripts/__pycache__/join_tools.cpython-312.pyc +0 -0
  469. package/skills/ecommerce-ops/scripts/__pycache__/gmv_decompose.cpython-312.pyc +0 -0
  470. package/skills/funnel-cohort/scripts/__pycache__/funnel.cpython-312.pyc +0 -0
  471. package/skills/management-accounting/scripts/__pycache__/costing.cpython-312.pyc +0 -0
  472. package/skills/manufacturing-quality/scripts/__pycache__/spc.cpython-312.pyc +0 -0
  473. package/skills/marketing-analytics/scripts/__pycache__/rfm.cpython-312.pyc +0 -0
  474. package/skills/optimization-solver/scripts/__pycache__/solver.cpython-312.pyc +0 -0
  475. package/skills/pivot-crosstab/scripts/__pycache__/pivot_tools.cpython-312.pyc +0 -0
  476. package/skills/portfolio-analysis/scripts/__pycache__/portfolio.cpython-312.pyc +0 -0
  477. package/skills/procurement-supply/scripts/__pycache__/supply.cpython-312.pyc +0 -0
  478. package/skills/production-planning/scripts/__pycache__/mrp_capacity.cpython-312.pyc +0 -0
  479. package/skills/project-management/scripts/__pycache__/cpm.cpython-312.pyc +0 -0
  480. package/skills/quant-analysis/scripts/__pycache__/quant.cpython-312.pyc +0 -0
  481. package/skills/real-estate-property/scripts/__pycache__/property_calc.cpython-312.pyc +0 -0
  482. package/skills/regression-forecast/scripts/__pycache__/regress.cpython-312.pyc +0 -0
  483. package/skills/securities-research/scripts/__pycache__/valuation.cpython-312.pyc +0 -0
  484. package/skills/statistical-testing/scripts/__pycache__/stat_tests.cpython-312.pyc +0 -0
  485. package/skills/treasury-mgmt/scripts/__pycache__/cashflow_forecast.cpython-312.pyc +0 -0
@@ -0,0 +1,1307 @@
1
+ # 研发项目**档案级**体检:把整个项目目录一起看,查单份文档看不见的那一层。
2
+ #
3
+ # 单份文档体检(docx_check)管「这一份写得规不规范」;本模块管四件它管不了的事:
4
+ # ① 齐不齐 —— 必备文档缺哪份、12 期周期性报告缺哪几期、5 个阶段评审报告与签到表是否配对;
5
+ # ② 对不对得上 —— 项目名称/编号/负责人/电话/建设单位/起止时间在几十份文档里是否处处一致;
6
+ # ③ 干不干净 —— 模板的「提示:」块删没删、XX 占位符换没换、有没有粘进别的项目的内容;
7
+ # ④ 填没填串 —— 「项目联系人」栏里填的是电话、「联系电话」栏里填的是人名这类。
8
+ #
9
+ # 这四类在真实归档里出错最多,也最容易在验收与审计现场被当场挑出来。
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import sys
15
+ import warnings
16
+ from collections import Counter, defaultdict
17
+
18
+ warnings.simplefilter('ignore')
19
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
20
+ from openpyxl import load_workbook # noqa: E402
21
+
22
+ import archive_spec as S # noqa: E402
23
+
24
+ SKIP_NAMES = ('.DS_Store', 'Thumbs.db')
25
+ DOC_EXT = ('.docx', '.doc')
26
+ SHEET_EXT = ('.xlsx', '.xls', '.xlsm')
27
+
28
+
29
+ def _issue(level, group, kind, where, msg, detail='', suggest=''):
30
+ return {'level': level, 'group': group, 'kind': kind, 'where': where,
31
+ 'msg': msg, 'detail': detail, 'suggest': suggest}
32
+
33
+
34
+ def _norm(s):
35
+ return re.sub(r'[\s ]', '', str(s or '')).strip()
36
+
37
+
38
+ # ── 读取 ───────────────────────────────────────────────────────────────────
39
+ def read_docx(path):
40
+ """返回 {text, headings[], fields{}, tables[]}。fields 取自各表格的「键|值」对。"""
41
+ from docx import Document
42
+ from docx.oxml.ns import qn # noqa: F401 (保持与 docx_check 同一套解析)
43
+ d = Document(path)
44
+ heads, lines, fields, tables = [], [], {}, []
45
+ # **必须按文档顺序遍历 body**:段落归段落、表格归表格地分开收集,会把「考核指标」这一节
46
+ # 和紧跟它的指标表拆开,于是「这一节里有没有量化指标」永远判成没有——
47
+ # 那正是本模块最值钱的一条勾稽检查,顺序一错它就失效了。
48
+ pm = {x._p: x for x in d.paragraphs}
49
+ tm = {x._tbl: x for x in d.tables}
50
+ for ch in d.element.body.iterchildren():
51
+ tag = ch.tag.split('}')[-1]
52
+ if tag == 'p' and ch in pm:
53
+ para = pm[ch]
54
+ t = para.text.strip()
55
+ if not t:
56
+ continue
57
+ lines.append(t)
58
+ name = str(para.style.name or '')
59
+ if name.startswith('Heading'):
60
+ heads.append((int(name[-1]) if name[-1].isdigit() else 1, t))
61
+ else:
62
+ # 有些文档把封面信息栏写成「项目名称:XXX」这样的居中段落而不是表格。
63
+ # 只认表格就会把这类文档的字段全漏掉——漏掉的后果不是报错,
64
+ # 是「一致性检查永远说没问题」,比报错更坏。
65
+ m = re.match(r'^\s*([^::]{2,12})\s*[::]\s*(\S.{0,60})$', t)
66
+ if m:
67
+ k = S.field_key(m.group(1))
68
+ if k and k not in fields:
69
+ fields[k] = m.group(2).strip()
70
+ elif tag == 'tbl' and ch in tm:
71
+ rows = []
72
+ for row in tm[ch].rows:
73
+ try:
74
+ cells = [c.text.strip() for c in row.cells]
75
+ except Exception:
76
+ continue
77
+ ded = []
78
+ for c in cells:
79
+ if not ded or ded[-1] != c:
80
+ ded.append(c)
81
+ rows.append(ded)
82
+ lines.extend(x for x in ded if x)
83
+ for i in range(0, len(ded) - 1, 2): # 键值对:两格一组,左键右值
84
+ k = S.field_key(ded[i])
85
+ if k and ded[i + 1].strip() and k not in fields:
86
+ fields[k] = ded[i + 1].strip()
87
+ tables.append(rows)
88
+ return {'text': '\n'.join(lines), 'headings': heads, 'fields': fields, 'tables': tables,
89
+ 'figures': len(d.inline_shapes)}
90
+
91
+
92
+ def read_sheet(path):
93
+ import pandas as pd
94
+ try:
95
+ xf = pd.ExcelFile(path)
96
+ except Exception as e:
97
+ return {'text': '', 'headings': [], 'fields': {}, 'tables': [], 'error': str(e)[:80]}
98
+ lines, fields = [], {}
99
+ for s in xf.sheet_names[:6]:
100
+ try:
101
+ df = xf.parse(s, header=None, dtype=object).head(80)
102
+ except Exception:
103
+ continue
104
+ for _i, row in df.iterrows():
105
+ vals = ['' if str(v) == 'nan' else str(v).strip() for v in row.tolist()]
106
+ lines.extend(v for v in vals if v)
107
+ for i in range(len(vals) - 1):
108
+ k = S.field_key(vals[i])
109
+ if k and vals[i + 1] and k not in fields:
110
+ fields[k] = vals[i + 1]
111
+ return {'text': '\n'.join(lines), 'headings': [], 'fields': fields, 'tables': []}
112
+
113
+
114
+ def _read_legacy_doc(path):
115
+ """旧版二进制 .doc(OLE2):python-docx 读不了,借系统自带的转换器取纯文本。
116
+
117
+ 真实归档里 12 份月度验收报告都是这个格式。读不到不等于没事——
118
+ 它们照样参与模板残留、项目名一致性、雷同度这些**纯文本**判据;
119
+ 读不到就只能整份跳过,而报告里以前对此一个字都没说。
120
+
121
+ 表格与标题层次拿不到,所以结构类检查自然不会对它们发言——这是如实的降级,
122
+ 不是猜。转换器一个都没有时,如实标记 unreadable,由 inspect 点名。
123
+ """
124
+ import shutil as _sh
125
+ import subprocess as _sp
126
+ for argv in (['textutil', '-convert', 'txt', '-stdout', path], # macOS 自带
127
+ ['antiword', path]): # 多数 Linux 发行版
128
+ if not _sh.which(argv[0]):
129
+ continue
130
+ try:
131
+ r = _sp.run(argv, capture_output=True, timeout=30)
132
+ except Exception:
133
+ continue
134
+ if r.returncode == 0 and r.stdout:
135
+ txt = r.stdout.decode('utf-8', 'replace').replace('\r\n', '\n')
136
+ # textutil 会把 Word 域代码当正文吐出来(TOC \o "1-2"、HYPERLINK \l _Toc123、
137
+ # PAGEREF)。这些是**每份文档都一样**的样板,留着会虚抬雷同度,
138
+ # 也会混进正文类判据。目录项本身不是内容,一并去掉。
139
+ txt = re.sub(r'(?m)^.*(?:\\o\s|HYPERLINK|PAGEREF|\bTOC\b).*$', '', txt)
140
+ lines = [ln.strip() for ln in txt.split('\n')]
141
+ fields = {}
142
+ for i, ln in enumerate(lines[:-1]): # 「键:值」同行或下一行
143
+ m = re.match(r'^(.{2,10})[::]\s*(.*)$', ln)
144
+ if m:
145
+ k = S.field_key(m.group(1))
146
+ v = m.group(2).strip() or lines[i + 1].strip()
147
+ if k and v and k not in fields:
148
+ fields[k] = v
149
+ return {'text': '\n'.join(l for l in lines if l), 'headings': [],
150
+ 'fields': fields, 'tables': [], 'legacy': True}
151
+ return {'text': '', 'headings': [], 'fields': {}, 'tables': [],
152
+ 'error': 'legacy-doc', 'unreadable': True}
153
+
154
+
155
+ def scan(root, spec=None):
156
+ """扫描项目目录 → [{rel, path, kind, ext, doc}]。"""
157
+ spec = spec or S.SPEC
158
+ out = []
159
+ for dirpath, dirnames, filenames in os.walk(root):
160
+ dirnames[:] = [d for d in dirnames if not d.startswith('.')]
161
+ for f in sorted(filenames):
162
+ if f in SKIP_NAMES or f.startswith('~$') or f.startswith('.'):
163
+ continue
164
+ p = os.path.join(dirpath, f)
165
+ rel = os.path.relpath(p, root)
166
+ ext = os.path.splitext(f)[1].lower()
167
+ rec = {'rel': rel, 'path': p, 'ext': ext, 'kind': S.kind_of(f, spec), 'doc': None}
168
+ try:
169
+ if ext == '.docx':
170
+ rec['doc'] = read_docx(p)
171
+ elif ext in SHEET_EXT:
172
+ rec['doc'] = read_sheet(p)
173
+ elif ext == '.doc':
174
+ rec['doc'] = _read_legacy_doc(p)
175
+ except Exception as e:
176
+ rec['doc'] = {'text': '', 'headings': [], 'fields': {}, 'tables': [],
177
+ 'error': f'{type(e).__name__}: {str(e)[:70]}'}
178
+ out.append(rec)
179
+ return out
180
+
181
+
182
+ # ── ① 完整性 ───────────────────────────────────────────────────────────────
183
+ def check_complete(files, spec):
184
+ out = []
185
+ kinds = Counter(f['kind'] for f in files if f['kind'])
186
+ for it in spec['items']:
187
+ if not it.get('required'):
188
+ continue
189
+ if not kinds.get(it['kind']):
190
+ out.append(_issue('error', 'complete', 'missing-doc', it['kind'],
191
+ f"缺必备文档:{it['dir'] + '/' if it['dir'] else ''}{it['file']}",
192
+ f"({it['stage']}阶段)",
193
+ '按规范补齐;确实不适用的要在档案清册里注明原因'))
194
+ for p in spec['periodic']:
195
+ if not p.get('required', True):
196
+ continue # 选做的周期性文档缺席不算缺陷
197
+ got = []
198
+ for f in files:
199
+ m = re.search(p['match'], os.path.splitext(os.path.basename(f['rel']))[0])
200
+ if m:
201
+ try:
202
+ got.append(int(m.group(1)))
203
+ except (ValueError, IndexError):
204
+ pass
205
+ got = sorted(set(got))
206
+ want = list(range(1, p['count'] + 1))
207
+ miss = [n for n in want if n not in got]
208
+ if not got:
209
+ out.append(_issue('error', 'complete', 'missing-periodic', p['kind'],
210
+ f"{p['kind']}一期都没有(应有 {p['count']} 期,按{p['freq']})",
211
+ '', '周期性文档的份数是硬指标,研发费用加计扣除与高企核查都会点数'))
212
+ elif miss:
213
+ out.append(_issue('error', 'complete', 'missing-periodic', p['kind'],
214
+ f"{p['kind']}缺 {len(miss)} 期:第 {miss} 期(已有 {len(got)}/{p['count']})",
215
+ '', '补齐,或在档案清册里写明为何缺'))
216
+ for r in spec['reviews']:
217
+ rep = [f for f in files if re.search(r['match'], f['rel']) and f['ext'] in SHEET_EXT]
218
+ sig = [f for f in files if re.search(r['match'], f['rel']) and '签到' in f['rel']]
219
+ if not rep and not sig:
220
+ out.append(_issue('error', 'complete', 'missing-review', f"{r['stage']}评审",
221
+ f"缺{r['stage']}阶段评审文件(评审报告 + 会议签到表)", '',
222
+ '五个阶段评审是流程闭环的证据,缺一个阶段就无法证明评审真的开过'))
223
+ elif not rep:
224
+ out.append(_issue('error', 'complete', 'review-unpaired', f"{r['stage']}评审",
225
+ f"{r['stage']}评审有签到表但没有评审报告", '', ''))
226
+ elif not sig:
227
+ out.append(_issue('error', 'complete', 'review-unpaired', f"{r['stage']}评审",
228
+ f"{r['stage']}评审有报告但没有会议签到表", '',
229
+ '签到表是「会真的开过」的唯一物证'))
230
+ unknown = [f['rel'] for f in files if not f['kind'] and f['ext'] in DOC_EXT + SHEET_EXT]
231
+ if unknown:
232
+ out.append(_issue('info', 'complete', 'unclassified', '归档',
233
+ f'{len(unknown)} 份文件不在规范目录内:{unknown[:5]}', '',
234
+ '不是错,但归档清册里要能说清它们是什么'))
235
+ legacy = [f['rel'] for f in files if f['ext'] == '.doc']
236
+ if legacy:
237
+ out.append(_issue('warn', 'complete', 'legacy-doc', '归档',
238
+ f'{len(legacy)} 份仍是旧版 .doc:{legacy[:4]}', '',
239
+ '.doc 无法被工具解析核对,长期归档建议统一另存为 .docx'))
240
+ return out
241
+
242
+
243
+ # ── ② 跨文档一致性 ─────────────────────────────────────────────────────────
244
+ def _canonical(values):
245
+ """一组同字段取值里挑基准写法,返回(基准, 被截断的那些, 前缀族)。
246
+
247
+ **基准取最长的,不取最高频的。** 真实归档里被截断的写法反而可能占多数:
248
+ 深度学习项目的封面「项目名称」有 11 份写「…风控系统研」(少一个「发」)、
249
+ 10 份写「…风控系统的研发」、8 份写「…风控系统研发」——按票数选,
250
+ 基准就成了那个断掉的写法,整改指引会把人从对的改成错的。
251
+
252
+ 这个判定有两个使用者(字段一致性、项目名一致性),必须共用一份:
253
+ 各写各的必然漂移,而漂移的表现是「一边说 A 对、一边说 A 错」这种最难查的矛盾。
254
+ """
255
+ if not values:
256
+ return '', [], []
257
+ fam = [v for v in values if any(v.startswith(o) or o.startswith(v)
258
+ for o in values if o != v)]
259
+ if not fam:
260
+ return values[0], [], []
261
+ main = max(fam, key=len)
262
+ return main, [v for v in fam if v != main], fam
263
+
264
+
265
+ def check_consistency(files, spec):
266
+ out = []
267
+ vals = defaultdict(lambda: defaultdict(list))
268
+ for f in files:
269
+ d = f['doc'] or {}
270
+ for k, v in (d.get('fields') or {}).items():
271
+ v = _norm(v)
272
+ if v and v not in ('/', '-', '—', '无'):
273
+ vals[k][v].append(f['rel'])
274
+ for k in spec['consistency']:
275
+ forms = vals.get(k)
276
+ if not forms or len(forms) < 2:
277
+ continue
278
+ ranked = sorted(forms.items(), key=lambda x: -len(x[1]))
279
+ main, trunc, fam = _canonical([v for v, _w in ranked])
280
+ detail = ';'.join(f'「{v}」×{len(w)}(如 {w[0]})' for v, w in ranked[:4])
281
+ if trunc:
282
+ # 前缀关系有两种,不能都叫「截断」:
283
+ # 「…公司」vs「…公司研发二部」是**粒度**不同(都不算错,但全档要统一);
284
+ # 「…系统研」vs「…系统研发」才是复制时少选了字。按后缀形态区分,措辞要准,
285
+ # 否则给出的整改指引会把人带偏。
286
+ suffix = main[len(min(trunc, key=len)):] if trunc else ''
287
+ org_like = bool(re.search(r'[部处科组]$|中心$|公司$|事业部$', suffix))
288
+ if org_like:
289
+ out.append(_issue('error', 'consistency', 'field-granularity', k,
290
+ f'「{k}」全档写法粒度不一致:{detail}', '',
291
+ f'择一并全档统一(正式文件通常用「{main}」这种最完整的写法)'))
292
+ else:
293
+ out.append(_issue('error', 'consistency', 'field-truncated', k,
294
+ f'「{k}」在全档中不一致,其中有被截断的写法:{detail}', '',
295
+ f'以「{main}」为准全档统一——截断是复制时少选了几个字'))
296
+ else:
297
+ out.append(_issue('error', 'consistency', 'field-mismatch', k,
298
+ f'「{k}」在全档中有 {len(forms)} 种取值:{detail}', '',
299
+ '同一项目的这些字段必须处处相同,验收与审计会逐份比对'))
300
+ # 字段串位:按「字段」聚合报,不按文件——同一个模板复制出去的错会散在几十份文件里,
301
+ # 逐份报会刷屏,聚合成一条「哪个字段在哪几份文件里填串了」才看得懂。
302
+ shape_bad = defaultdict(list)
303
+ for f in files:
304
+ for k, (pat, why) in S.FIELD_SHAPES.items():
305
+ v = _norm((f['doc'] or {}).get('fields', {}).get(k, ''))
306
+ if v and not pat.match(v):
307
+ shape_bad[(k, why, v)].append(f['rel'])
308
+ for (k, why, v), where in shape_bad.items():
309
+ out.append(_issue('error', 'consistency', 'field-shape', k,
310
+ f'「{k}」栏填的是「{v}」,{why}({len(where)} 份文件)',
311
+ '例:' + '、'.join(where[:3]),
312
+ '相邻两栏填串了,核对封面信息表'))
313
+ return out
314
+
315
+
316
+ # ── ③ 模板残留 ─────────────────────────────────────────────────────────────
317
+ def check_leak(files, spec):
318
+ out = []
319
+ for f in files:
320
+ txt = (f['doc'] or {}).get('text') or ''
321
+ if not txt:
322
+ continue
323
+ for kind, pat, level, msg, why in S.LEAK_PATTERNS:
324
+ hits = pat.findall(txt)
325
+ if hits:
326
+ out.append(_issue(level, 'leak', kind, f['rel'],
327
+ f'{msg}({len(hits)} 处)', why,
328
+ '交付前全文搜一遍删干净'))
329
+ # 模板批注残留:模板写给填表人的说明(注:由…填写 / 请选择以下结论 / 下同。)
330
+ tn = re.compile('|'.join(S.TEMPLATE_NOTES))
331
+ for f in files:
332
+ txt = (f['doc'] or {}).get('text') or ''
333
+ hits = tn.findall(txt)
334
+ if hits:
335
+ out.append(_issue('error', 'leak', 'template-note', f['rel'],
336
+ f'模板给填表人的批注未删除({len(hits)} 处)',
337
+ '如「注:由项目单位如实填写实际完成情况,下同。」——'
338
+ '这是模板的填表说明,不是给读者看的注释',
339
+ '交付前全文搜「下同」「请选择」「如实填写」删干净'))
340
+ # 选择项一个都没勾:这道题根本没做(专家审查结论全空是验收件的硬伤)
341
+ for f in files:
342
+ txt = (f['doc'] or {}).get('text') or ''
343
+ # 方框型:整份文档里一个勾都找不到 = 这些选择题一道都没做。
344
+ # 判「一个都没勾」而不是「还剩几个没勾」——多选题选完必然剩下未选项。
345
+ boxes = S.EMPTY_BOX_RE.findall(txt)
346
+ if len(boxes) >= 2 and not S.FILLED_BOX_RE.search(txt):
347
+ out.append(_issue('error', 'leak', 'empty-box', f['rel'],
348
+ f'{len(boxes)} 个选择框一个都没勾',
349
+ '如人员变更申请表的「是否变更 □是 □否」原样交出——'
350
+ '这道题根本没做,不是选了「否」',
351
+ '逐个勾选(■/☑)后再交付'))
352
+ empty = S.EMPTY_CHOICE_RE.findall(txt)
353
+ if len(empty) >= 3 and not S.FILLED_CHOICE_RE.search(txt):
354
+ out.append(_issue('error', 'leak', 'unchecked-choice', f['rel'],
355
+ f'{len(empty)} 处选择项一个都没勾',
356
+ '整份文档没有任何一个括号被填——结论没人做、字没人签',
357
+ '逐条勾选并签字;专家审查结论是验收件的核心,空着等于没验收'))
358
+ # 他项目内容残留:文档正文里出现了别的项目名
359
+ names = Counter()
360
+ for f in files:
361
+ n = _norm((f['doc'] or {}).get('fields', {}).get('项目名称', ''))
362
+ if n:
363
+ names[n] += 1
364
+ if names:
365
+ main = names.most_common(1)[0][0]
366
+ for f in files:
367
+ txt = _norm((f['doc'] or {}).get('text') or '')
368
+ for other, _c in names.most_common():
369
+ if other != main and len(other) > 8 and other in txt and main not in txt:
370
+ out.append(_issue('warn', 'leak', 'other-project', f['rel'],
371
+ f'正文里出现了另一个项目名「{other}」而没有本项目名', '',
372
+ '多半是从别的项目复制来的,逐处替换'))
373
+ break
374
+ return out
375
+
376
+
377
+ # ── ④ 单份结构(章节 / 表单字段 / 签批栏)───────────────────────────────────
378
+ DATE_RE = re.compile(r'(20\d{2})\s*[年\-/.]\s*(\d{1,2})\s*[月\-/.]\s*(\d{1,2})?\s*日?')
379
+
380
+
381
+ def _cover_date(doc):
382
+ """取封面落款日期。
383
+
384
+ **优先取「编制时间/编制日期」字段**,取不到才扫正文前 800 字。
385
+ 直接扫正文会先撞上标题里的期间标签(「研发进度报告(2026年07月)」),
386
+ 那个月份天然等于期号,拿它去校验期号等于自己跟自己比,检查形同虚设。
387
+ """
388
+ fv = (doc.get('fields') or {}).get('编制时间')
389
+ if fv:
390
+ m = DATE_RE.search(str(fv))
391
+ if m:
392
+ y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3) or 1)
393
+ if 1 <= mo <= 12 and 1 <= d <= 31:
394
+ return (y, mo, d)
395
+ m = DATE_RE.search((doc.get('text') or '')[:800])
396
+ if not m:
397
+ return None
398
+ y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3) or 1)
399
+ if not (1 <= mo <= 12 and 1 <= d <= 31):
400
+ return None
401
+ return (y, mo, d)
402
+
403
+
404
+ def check_timeline(files, spec):
405
+ """时间线自洽:期号与落款月份对不对得上、五个阶段评审是不是按顺序开的。
406
+
407
+ 这两条都是硬规则,不需要人判断:第 7 期月报的落款不可能是 8 月,
408
+ 结项评审不可能开在验收评审之前。真实归档里这类错不多,但一旦有,
409
+ 核查时是「材料是补的」的直接证据——比任何文字问题都难解释。
410
+ """
411
+ out = []
412
+ # ① 周期性文档:文件名里的期号 vs 落款月份
413
+ for p in spec['periodic']:
414
+ if p.get('freq') != '月':
415
+ continue
416
+ bad = []
417
+ for f in files:
418
+ if f['kind'] != p['kind'] or f['ext'] != '.docx':
419
+ continue
420
+ m = re.search(p['match'], os.path.splitext(os.path.basename(f['rel']))[0])
421
+ dt = _cover_date(f['doc'] or {})
422
+ if not m or not dt:
423
+ continue
424
+ try:
425
+ n = int(m.group(1))
426
+ except (ValueError, IndexError):
427
+ continue
428
+ if 1 <= n <= 12 and dt[1] != n:
429
+ bad.append((f['rel'], n, dt))
430
+ if bad:
431
+ out.append(_issue('error', 'timeline', 'period-date-mismatch', p['kind'],
432
+ f'{len(bad)} 期{p["kind"]}的落款月份与期号对不上',
433
+ ';'.join(f'{os.path.basename(r)} 落款 {d[0]}-{d[1]:02d}(应为 {n} 月)'
434
+ for r, n, d in bad[:3]),
435
+ '期号与落款月份必须一致,否则是「材料后补」的直接证据'))
436
+ # ② 五个阶段评审必须按顺序
437
+ order, dates = [r['stage'] for r in spec['reviews']], {}
438
+ for f in files:
439
+ for r in spec['reviews']:
440
+ if re.search(r['match'], f['rel']) and f['ext'] == '.docx':
441
+ dt = _cover_date(f['doc'] or {})
442
+ if dt and r['stage'] not in dates:
443
+ dates[r['stage']] = (dt, f['rel'])
444
+ seq = [(st, dates[st]) for st in order if st in dates]
445
+ for (s1, (d1, f1)), (s2, (d2, f2)) in zip(seq, seq[1:]):
446
+ if d2 < d1:
447
+ out.append(_issue('error', 'timeline', 'review-out-of-order', f'{s1}→{s2}评审',
448
+ f'{s2}评审({d2[0]}-{d2[1]:02d}-{d2[2]:02d})早于'
449
+ f'{s1}评审({d1[0]}-{d1[1]:02d}-{d1[2]:02d})',
450
+ f'{os.path.basename(f2)} / {os.path.basename(f1)}',
451
+ '评审阶段有先后:立项→设计→测试用例→验收→结项,顺序倒了说明流程没真走'))
452
+ return out
453
+
454
+
455
+ def check_unfinished(files, spec):
456
+ """验收件里承认「进行中 / 预计完成」——指标没完成却在做验收。
457
+
458
+ 这不是文字游戏:验收结论要么达标要么不达标,「进行中」既不是达标也不是不达标,
459
+ 等于把判断推给了后面的人。真实归档里两个项目都在「实际完成情况」栏写了
460
+ 「进行中,指标为预计完成指标」,而专家审查结论一个都没勾。
461
+ """
462
+ out = []
463
+ for f in files:
464
+ if f['kind'] not in S.ACCEPT_KINDS:
465
+ continue
466
+ hits = []
467
+ for rows in ((f['doc'] or {}).get('tables') or []):
468
+ if not rows:
469
+ continue
470
+ # 先按**表头**认出这是不是「立项书规定 vs 实际完成情况」的对照表,
471
+ # 再看数据行的右栏。左栏是立项书原文,里面出现「预计」是正常的。
472
+ head = _norm(' '.join(str(c) for c in rows[0]))
473
+ if '实际完成' not in head and '规定' not in head:
474
+ continue
475
+ for row in rows[1:]:
476
+ if len(row) < 2:
477
+ continue
478
+ for cell in row[1:]:
479
+ t = str(cell)
480
+ if '实际完成情况' in t:
481
+ continue
482
+ for w in S.UNFINISHED_WORDS:
483
+ if w in t:
484
+ hits.append((w, t.replace('\n', '')[:26]))
485
+ break
486
+ if hits:
487
+ out.append(_issue('error', 'unfinished', 'accepting-unfinished', f['rel'],
488
+ f'{len(hits)} 项指标的「实际完成情况」写着未完成措辞,却在做验收',
489
+ ';'.join(f'「{c}」' for _w, c in hits[:3]),
490
+ '验收结论只有达标/不达标两档:没完成就如实写不达标并说明整改,'
491
+ '不能用「进行中」把判断推给后面的人'))
492
+ return out
493
+
494
+
495
+ def check_figures(files, spec):
496
+ """该有图的文档一张图都没有。技术路线、系统架构、流程这些东西,
497
+ 用文字硬描是读者的负担,也是评审最常见的追问点。"""
498
+ out = []
499
+ for f in files:
500
+ if f['ext'] != '.docx' or f['kind'] not in S.FIGURE_EXPECTED:
501
+ continue
502
+ n = (f['doc'] or {}).get('figures')
503
+ if n is None:
504
+ continue
505
+ # 门槛在**空骨架与写实文档之间的空隙**里,量出来的:
506
+ # 刚生成的骨架 260~551 字,写实的最短 1464 字(BigDataRisk 的技术总结报告),
507
+ # 真实归档 5243 字起。原来定 1500 落进了写实区,那份 1464 字的差 36 字擦边漏过——
508
+ # 门槛的作用是「别对空骨架挑刺」,不是「短就不算文档」。
509
+ if n == 0 and len((f['doc'] or {}).get('text') or '') > 800:
510
+ out.append(_issue('warn', 'structure', 'no-figure', f['rel'],
511
+ f'{f["kind"]}全文没有一张图',
512
+ '技术路线、系统架构、关键流程用文字硬描,评审必追问',
513
+ '用 render_diagram 画出来再插进去'))
514
+ return out
515
+
516
+
517
+ def check_structure(files, spec):
518
+ out = []
519
+ for f in files:
520
+ kind, d = f['kind'], f['doc'] or {}
521
+ if not kind or d.get('error'):
522
+ continue
523
+ secs = spec['sections'].get(kind)
524
+ text = d.get('text') or ''
525
+ if secs:
526
+ hd = d.get('headings') or []
527
+ heads = ' '.join(t for _lv, t in hd)
528
+ # **有标题树就只认标题树**:正文里出现「项目考核指标」可能只是某张表的题注,
529
+ # 拿题注当「这一节写了」,真的缺节反而判成不缺。标题不足三条的(表单式文档)
530
+ # 才退回全文匹配。
531
+ hay = heads if len(hd) >= 3 else (heads + ' ' + text[:20000])
532
+ miss_must, miss_should = [], []
533
+ for title, _subs, level, aliases in secs:
534
+ if any(_loose_in(a, hay) for a in [title] + list(aliases)):
535
+ continue
536
+ (miss_must if level == 'must' else miss_should).append(title)
537
+ if miss_must:
538
+ out.append(_issue('error', 'structure', 'missing-section', f['rel'],
539
+ f'缺必备章节:{"、".join(miss_must)}', f'({kind})',
540
+ '评审是按章节核对的,缺一节就是缺一项'))
541
+ if miss_should:
542
+ out.append(_issue('warn', 'structure', 'missing-optional-section', f['rel'],
543
+ f'模板有但本篇没写:{"、".join(miss_should)}', f'({kind})',
544
+ '真实归档里这几节常被省略;要不要补由项目决定,别默认忽略'))
545
+ fields = spec['form_fields'].get(kind)
546
+ if fields:
547
+ hay = text
548
+ miss = [x for x in fields if not _loose_in(x, hay)]
549
+ if miss:
550
+ out.append(_issue('error', 'structure', 'missing-field', f['rel'],
551
+ f'表单缺 {len(miss)} 个字段:{miss[:6]}', f'({kind})',
552
+ '表单类文档的失分点几乎全在字段缺项'))
553
+ ap = spec['approvals'].get(kind)
554
+ if ap:
555
+ miss = [x for x in ap if not _loose_in(x, text)]
556
+ if miss:
557
+ out.append(_issue('error', 'structure', 'missing-approval', f['rel'],
558
+ f'缺签批栏:{"、".join(miss)}', f'({kind})',
559
+ '少一级签批 = 流程没走完,交上去会被退'))
560
+ else:
561
+ blank = [x for x in ap if _approval_blank(text, x)]
562
+ if blank:
563
+ out.append(_issue('warn', 'structure', 'blank-approval', f['rel'],
564
+ f'签批栏是空的:{"、".join(blank)}', '',
565
+ '归档前要签字盖章,空栏等于没审批'))
566
+ return out
567
+
568
+
569
+ def _loose_in(needle, hay):
570
+ """去掉空白与全角括号差异后判断是否包含(模板里字段名常带空格:「项 目 名 称」)。"""
571
+ return _norm(needle).replace('(', '(').replace(')', ')') in \
572
+ _norm(hay).replace('(', '(').replace(')', ')')
573
+
574
+
575
+ def _approval_blank(text, label):
576
+ """签批栏后面 30 字内还有没有实质内容(人名/意见/日期)。"""
577
+ i = _norm(text).find(_norm(label))
578
+ if i < 0:
579
+ return False
580
+ tail = _norm(text)[i + len(_norm(label)): i + len(_norm(label)) + 30]
581
+ tail = tail.lstrip('::')
582
+ return not re.search(r'[一-鿿\d]', tail.replace('年月日', ''))
583
+
584
+
585
+ # ── 周期性文档雷同度 ───────────────────────────────────────────────────────
586
+ # 为什么要有这条:12 期月报交齐了、份数也对,档案体检一切正常——但把 12 份摊开一读,
587
+ # 正文相似度 98%,等于同一份文档改个日期交了十二次。「逐月记录研发过程」这件事
588
+ # 实际上没有发生。研发费用加计扣除与高企认定的核查员一眼就能看出来,而份数检查看不出来。
589
+ #
590
+ # 阈值是**量出来的**,不是拍的(4-gram Jaccard,相邻两期):
591
+ # 真实那两个项目的 12 期月报 → 中位 0.984 / 0.986(其中一对恰好 1.000,两期完全相同)
592
+ # 三份不同类型的正式文档 → 0.19
593
+ # 刚生成的空骨架 → 0.73(且正文均长 230 字,低于判定门槛,本就不判)
594
+ SHINGLE_K = 4
595
+ SAME_BODY_MIN = 300 # 正文均长不到这个数就是还没填的骨架,不判
596
+ SAME_ERR = 0.90
597
+ SAME_WARN = 0.75
598
+ FIELD_LINE_RE = re.compile(r'^[^::]{2,12}[::]\S{0,40}$')
599
+ # 独立成行的日期是落款,不是散文。各期落款天然不同(1 月末、2 月末…),
600
+ # 把它算进「实质正文」会让两份逐字相同的报告显得不同,冲淡真正要量的东西。
601
+ DATE_LINE_RE = re.compile(r'^\s*20\d{2}\s*[年\-/.]\s*\d{1,2}\s*[月\-/.]?\s*\d{0,2}\s*日?\s*$')
602
+
603
+
604
+ def _body_lines(doc):
605
+ """取「实质正文」:去掉标题、封面键值行与过短的碎行——它们本就该在各期之间相同。"""
606
+ heads = {t for _lv, t in (doc.get('headings') or [])}
607
+ out = []
608
+ for line in (doc.get('text') or '').split('\n'):
609
+ t = line.strip()
610
+ if not t or t in heads or len(t) < 15:
611
+ continue
612
+ if FIELD_LINE_RE.match(t) or DATE_LINE_RE.match(t):
613
+ continue
614
+ out.append(t)
615
+ return ''.join(out)
616
+
617
+
618
+ def _shingles(text, k=SHINGLE_K):
619
+ t = re.sub(r'[\s ]', '', text)
620
+ return {t[i:i + k] for i in range(max(len(t) - k + 1, 0))}
621
+
622
+
623
+ def _jaccard(a, b):
624
+ return len(a & b) / len(a | b) if a and b else 0.0
625
+
626
+
627
+ def check_sameness(files, spec):
628
+ out = []
629
+ by_kind = defaultdict(list)
630
+ kinds = {p['kind'] for p in spec['periodic']}
631
+ for f in files:
632
+ # **只查叙述型文档(.docx),不查表格件**。这是量过之后的取舍,不是偷懒:
633
+ # 真实项目的 4 个季度「实际进度表」相似度同样高达 96–98%,但字符数逐季递增
634
+ # (3912 → 4116 → 4473 → 4775)——那是累计型表格逐季补录实际数据的正常形态。
635
+ # 把它一并判成雷同,就是对正确做法喊狼来了。
636
+ if f['kind'] in kinds and f['ext'] in ('.docx', '.doc') and (f['doc'] or {}).get('text'):
637
+ # **按扩展名分组比**:旧版 .doc 走的是纯文本转换,取不到表格内容,
638
+ # 文本厚度天然比 .docx 薄。混在一起比等于拿两把尺量同一件事,
639
+ # 阈值是在 .docx 上量出来的,套到 .doc 上会误报。
640
+ by_kind[(f['kind'], f['ext'])].append(f)
641
+ for (kind, _ext), fs in by_kind.items():
642
+ if len(fs) < 3:
643
+ continue
644
+ fs = sorted(fs, key=lambda x: x['rel'])
645
+ bodies = [_body_lines(f['doc']) for f in fs]
646
+ avg = sum(len(b) for b in bodies) / len(bodies)
647
+ if avg < SAME_BODY_MIN:
648
+ continue # 还是空骨架,等着人填
649
+ shs = [_shingles(b) for b in bodies]
650
+ pairs = [(_jaccard(shs[i], shs[i + 1]), fs[i]['rel'], fs[i + 1]['rel'])
651
+ for i in range(len(shs) - 1)]
652
+ sims = sorted(x[0] for x in pairs)
653
+ med = sims[len(sims) // 2]
654
+ same = [(a, b) for v, a, b in pairs if v > 0.995]
655
+ if med >= SAME_ERR:
656
+ detail = f'相邻两期相似度中位 {med*100:.1f}%,正文均长 {avg:.0f} 字'
657
+ if same:
658
+ detail += f';其中 {len(same)} 对几乎逐字相同(如 {os.path.basename(same[0][0])} 与 '\
659
+ f'{os.path.basename(same[0][1])})'
660
+ out.append(_issue('error', 'sameness', 'periodic-duplicate', kind,
661
+ f'{len(fs)} 期{kind}正文高度雷同,等于同一份改日期交了 {len(fs)} 次',
662
+ detail,
663
+ '周期性文档的价值在「逐期记录了什么」而不是份数;'
664
+ '每期至少写清本期实际完成、与计划的偏差、下期安排——'
665
+ '核查员会摊开来对着读'))
666
+ elif med >= SAME_WARN:
667
+ out.append(_issue('warn', 'sameness', 'periodic-similar', kind,
668
+ f'{len(fs)} 期{kind}正文相似度偏高(中位 {med*100:.1f}%)',
669
+ f'正文均长 {avg:.0f} 字',
670
+ '固定结构可以复用,但每期的实际进展、偏差与问题应当各不相同'))
671
+ return out
672
+
673
+
674
+ # ── ⑤ 指标勾稽(立项 → 验收)───────────────────────────────────────────────
675
+ # 只认**看得出是目标值**的写法:带比较词的(≥92%、≤300ms、不低于 5 万元),
676
+ # 或裸百分比(准确率 92%)。不带比较词的「5000 条」「1 项」多半出现在测试方法或序号里,
677
+ # 把它们当考核指标会让勾稽结论全是噪声——宁可少认,也不能认错。
678
+ NUM_IND = re.compile(
679
+ r'(?:≥|≤|>|<|⩾|⩽|不低于|不少于|不超过|不高于|达到|大于|小于)\s*\d+(?:\.\d+)?\s*'
680
+ r'(?:%|%|万元|亿元|元|人月|个|项|次|ms|毫秒|秒|分钟|小时|天|TPS|QPS|条|倍|分)?'
681
+ r'|\d+(?:\.\d+)?\s*[%%]')
682
+
683
+
684
+ def check_indicators(files, spec):
685
+ out = []
686
+ src = next((f for f in files if f['kind'] == '立项申请书'), None)
687
+ if not src or not (src['doc'] or {}).get('text'):
688
+ return out
689
+ sect = _section_text(src['doc'], '项目考核指标')
690
+ if not sect:
691
+ return out
692
+ inds = []
693
+ for m in NUM_IND.finditer(sect):
694
+ t = _norm(m.group(0))
695
+ if t not in inds:
696
+ inds.append(t)
697
+ if not inds:
698
+ out.append(_issue('warn', 'indicator', 'no-quantified', '立项申请书',
699
+ '「项目考核指标」一节里没有可量化的指标值', '',
700
+ '指标写不出数就没法验收:每条给指标值 + 测试方法 + 判定依据'))
701
+ return out
702
+ targets = [f for f in files if f['kind'] in ('项目验收报告', '工作总结报告', '技术总结报告')]
703
+ if not targets:
704
+ return out
705
+ hay = _norm(' '.join((f['doc'] or {}).get('text') or '' for f in targets))
706
+ missing = [x for x in inds if x not in hay]
707
+ if missing:
708
+ out.append(_issue('warn', 'indicator', 'unverified', '立项申请书 → 验收/总结',
709
+ f'{len(missing)}/{len(inds)} 条立项考核指标在验收与总结文档里找不到对应实测值:'
710
+ f'{missing[:6]}', '',
711
+ '验收报告要逐条对照立项书指标给出实测值与判定,一条都不能少'))
712
+ else:
713
+ out.append(_issue('info', 'indicator', 'traced', '立项申请书 → 验收/总结',
714
+ f'{len(inds)} 条立项考核指标在验收与总结文档中均能找到对应表述', '', ''))
715
+ return out
716
+
717
+
718
+ def _section_text(doc, title, limit=200000):
719
+ """取某一节的正文:从标题行的下一行起,到**下一个标题行**为止。
720
+
721
+ 边界必须按「整行等于某个标题」判,不能按「标题文字在正文里出现的位置」判——
722
+ 正文经常引用别的节名(真实语料:「基于2.1的**项目立项背景**,项目依据…」),
723
+ 按子串截断会把整节裁成 6 个字,字数、要素这些按节长度的判据就全部静默漏报。
724
+ """
725
+ heads = {t for _lv, t in (doc.get('headings') or [])}
726
+ lines = (doc.get('text') or '').split('\n')
727
+ start = next((i for i, ln in enumerate(lines) if ln.strip() == title), None)
728
+ if start is None: # 标题不独占一行(表格型文档)时退回子串定位
729
+ start = next((i for i, ln in enumerate(lines) if title in ln), None)
730
+ if start is None:
731
+ return ''
732
+ body = []
733
+ for ln in lines[start + 1:]:
734
+ if ln.strip() in heads:
735
+ break
736
+ body.append(ln)
737
+ return '\n'.join(body)[:limit]
738
+
739
+
740
+ # ── 表格类归档件的内容自洽 ─────────────────────────────────────────────────
741
+ SCHED_COLS = ('任务名称', '计划开始', '计划结束', '实际开始', '实际结束', '进度')
742
+
743
+
744
+ def _read_sheet_df(path, must_have='任务名称', scan=6):
745
+ """读带标题行的业务表:第一行常是大标题,真正的表头在第 2~3 行。
746
+ 按「哪一行含关键列名」定位表头,而不是硬编码 header=1——真实表格的标题行数并不统一。"""
747
+ import pandas as pd
748
+ try:
749
+ raw = pd.read_excel(path, header=None, dtype=object)
750
+ except Exception:
751
+ return None
752
+ for i in range(min(scan, len(raw))):
753
+ if any(str(v).strip() == must_have for v in raw.iloc[i].tolist()):
754
+ try:
755
+ return pd.read_excel(path, header=i)
756
+ except Exception:
757
+ return None
758
+ return None
759
+
760
+
761
+ def check_schedule(files, spec):
762
+ """进度表的逻辑自洽。
763
+
764
+ 真实归档里抓到过:3 条任务标着「进度 100%」,实际工时 0、实际开始与结束全空——
765
+ 也就是「说完成了,但没有任何完成的痕迹」。这类矛盾在表里安安静静躺着,
766
+ 份数检查、章节检查都碰不到它,只有按这张表自己的语义去核对才看得见。
767
+ """
768
+ import pandas as pd
769
+ out = []
770
+ # **必须先把计划表找出来再比对**:按文件名排序会把「…实际-第1季度」排在「…计划」前面
771
+ # (Unicode 里「实」<「计」),一趟扫下来比对时计划表还没读到,整条检查静默失效。
772
+ sheets = [f for f in files
773
+ if f['ext'] in SHEET_EXT and str(f['kind'] or '').startswith('项目研发进度表')]
774
+ plan_tasks = None
775
+ for f in sheets:
776
+ if str(f['kind']).endswith('计划'):
777
+ df0 = _read_sheet_df(f['path'])
778
+ if df0 is not None and '任务名称' in df0.columns:
779
+ plan_tasks = [str(x) for x in df0['任务名称'].dropna().tolist()]
780
+ break
781
+ for f in sorted(sheets, key=lambda x: x['rel']):
782
+ df = _read_sheet_df(f['path'])
783
+ if df is None or '任务名称' not in df.columns:
784
+ continue
785
+ tasks = [str(x) for x in df['任务名称'].dropna().tolist()]
786
+ if str(f['kind']).endswith('计划'):
787
+ pass
788
+ elif plan_tasks is not None and tasks and tasks != plan_tasks:
789
+ out.append(_issue('warn', 'schedule', 'task-list-drift', f['rel'],
790
+ f'实际表的任务列表与计划表对不上(计划 {len(plan_tasks)} 条 / '
791
+ f'实际 {len(tasks)} 条)', '',
792
+ '实际表应在计划表基础上填实际值,不该增删任务;'
793
+ '确有增删要走变更流程并留变更单'))
794
+ def col(name):
795
+ return df[name] if name in df.columns else None
796
+ prog, ae, as_, ah = col('进度'), col('实际结束'), col('实际开始'), col('实际工时')
797
+ pe, ps = col('计划结束'), col('计划开始')
798
+ # 进度是 0~1 的比例(真实语料里有 0.03/0.38/0.75 这样的值)。
799
+ # 2.0 这种越界值不是「超额完成」,是填错了——量纲搞混或多敲了一位。
800
+ if prog is not None:
801
+ oor = [v for v in prog.dropna().tolist()
802
+ if isinstance(v, (int, float)) and (v > 1.0001 or v < 0)]
803
+ if oor:
804
+ out.append(_issue('error', 'schedule', 'progress-out-of-range', f['rel'],
805
+ f'{len(oor)} 处进度值越界:{sorted(set(oor))[:4]}',
806
+ '该列是 0~1 的完成比例', '按比例填(0.75 表示 75%),不要填 2'))
807
+ if f['kind'].endswith('计划') and prog is not None:
808
+ nz = [v for v in prog.dropna().tolist()
809
+ if isinstance(v, (int, float)) and v > 0]
810
+ if nz:
811
+ out.append(_issue('warn', 'schedule', 'plan-has-progress', f['rel'],
812
+ f'计划表里填了 {len(nz)} 处进度值', '',
813
+ '计划表只写计划,进度填在实际表里;两张表混着填会让'
814
+ '「计划 vs 实际」的对比失去意义'))
815
+ done = prog.apply(lambda v: isinstance(v, (int, float)) and v >= 1) if prog is not None else None
816
+ if done is not None and ae is not None:
817
+ n = int((done & ae.isna()).sum())
818
+ if n:
819
+ rows = df.index[(done & ae.isna())].tolist()[:5]
820
+ out.append(_issue('error', 'schedule', 'done-without-date', f['rel'],
821
+ f'{n} 条任务标记完成,却没有实际结束日期',
822
+ '表内第 ' + '、'.join(str(int(r) + 2) for r in rows) + ' 行',
823
+ '说完成了就得有完成的痕迹:补实际起止与实际工时,'
824
+ '或把进度改回真实值'))
825
+ if done is not None and ah is not None:
826
+ n = int((done & ah.apply(lambda v: isinstance(v, (int, float)) and v == 0)).sum())
827
+ if n:
828
+ out.append(_issue('warn', 'schedule', 'done-zero-hours', f['rel'],
829
+ f'{n} 条任务标记完成但实际工时为 0', '',
830
+ '工时是研发费用归集的依据,0 工时的完成项在加计扣除核查时要解释'))
831
+ for a, b, kind, msg in ((as_, ae, 'actual-reversed', '实际'),
832
+ (ps, pe, 'plan-reversed', '计划')):
833
+ if a is None or b is None:
834
+ continue
835
+ try:
836
+ bad = (a.notna() & b.notna() & (pd.to_datetime(b, errors='coerce')
837
+ < pd.to_datetime(a, errors='coerce')))
838
+ n = int(bad.sum())
839
+ except Exception:
840
+ continue
841
+ if n:
842
+ out.append(_issue('error', 'schedule', kind, f['rel'],
843
+ f'{n} 条任务的{msg}结束早于{msg}开始', '', '核对日期填反了没有'))
844
+ return out
845
+
846
+
847
+ def _open_wb(path):
848
+ """打开工作簿;.xls 老格式 openpyxl 读不了,退回 pandas 造一个等价的只读视图。"""
849
+ if str(path).lower().endswith(('.xlsx', '.xlsm')):
850
+ try:
851
+ return load_workbook(path, data_only=True)
852
+ except Exception:
853
+ return None
854
+ try:
855
+ import pandas as pd
856
+ xf = pd.ExcelFile(path)
857
+ except Exception:
858
+ return None
859
+
860
+ class _Cell:
861
+ __slots__ = ('value',)
862
+ def __init__(self, v): self.value = v
863
+
864
+ class _WS:
865
+ def __init__(self, title, df):
866
+ self.title, self._df = title, df
867
+ def iter_rows(self, min_row=1):
868
+ for row in self._df.values.tolist()[min_row - 1:]:
869
+ yield [_Cell(None if (isinstance(v, float) and v != v) else v) for v in row]
870
+
871
+ class _WB:
872
+ def __init__(self, xf):
873
+ self.worksheets = []
874
+ self.sheetnames = list(xf.sheet_names)
875
+ for s2 in self.sheetnames:
876
+ try:
877
+ self.worksheets.append(_WS(s2, xf.parse(s2, header=None, dtype=object)))
878
+ except Exception:
879
+ pass
880
+ def __getitem__(self, name):
881
+ return next(w for w in self.worksheets if w.title == name)
882
+
883
+ return _WB(xf)
884
+
885
+
886
+ def check_sheet_content(files, spec):
887
+ """评审报告与预算表:填没填。
888
+
889
+ 判断要**直接读工作簿**,不能在扁平化文本上做——标题里的年份「2026」会被当成金额,
890
+ 表头字样会被当成记录内容,两头都会给出「看着有内容其实是空的」的错误结论。
891
+ """
892
+ out = []
893
+ for f in files:
894
+ if f['ext'] not in SHEET_EXT:
895
+ continue
896
+ kind = str(f['kind'] or '')
897
+ if not (kind.endswith('评审报告') or '预算' in kind):
898
+ continue
899
+ # 只跳过「打不开」,不吞掉别的异常。
900
+ # 早先这里是裸 except:漏了 load_workbook 的 import,NameError 被一并咽掉,
901
+ # 整条检查从未执行过却一声不响——比报错坏得多。
902
+ wb = _open_wb(f['path'])
903
+ if wb is None:
904
+ continue
905
+ if kind.endswith('评审报告'):
906
+ for name in ('评审记录', '评审结论'):
907
+ ws = next((wb[t] for t in wb.sheetnames if name in t), None)
908
+ if ws is None:
909
+ continue
910
+ filled = 0
911
+ for row in ws.iter_rows(min_row=1):
912
+ for c in row:
913
+ v = c.value
914
+ if v is None or str(v).strip() == '':
915
+ continue
916
+ t = str(v).strip()
917
+ if t in ('ID', '问题描述', '提出人', '解决建议', '解决方式', '严重程度',
918
+ '预计解决时间', '修改人', '跟踪人', '跟踪意见、签字',
919
+ '评审内容', '评审结论', '评审记录', name):
920
+ continue
921
+ if re.fullmatch(r'\d{1,3}', t): # 序号不算内容
922
+ continue
923
+ filled += 1
924
+ if filled < 2:
925
+ out.append(_issue('warn', 'schedule', 'empty-review-sheet', f['rel'],
926
+ f'{kind}的「{name}」几乎是空的',
927
+ f'除表头与序号外只有 {filled} 格有内容',
928
+ '会开过就该留下记录:问题、责任人、解决时间;'
929
+ '一条都没有,等于无法证明评审真的发生过'))
930
+ else:
931
+ nz = 0
932
+ for ws in wb.worksheets:
933
+ for row in ws.iter_rows():
934
+ for c in row:
935
+ if isinstance(c.value, (int, float)) and not isinstance(c.value, bool) \
936
+ and c.value != 0:
937
+ nz += 1
938
+ if nz == 0:
939
+ # 建议档而非必修:与「签批栏是空的」「评审记录是空的」同属**待填状态**。
940
+ # 刚生成的骨架、或档案卡没给预算科目时,表就是零——那是等着人填,不是错。
941
+ # 三处用两套标准会让「生成即零必修」这条不变量时灵时不灵。
942
+ out.append(_issue('warn', 'schedule', 'empty-budget', f['rel'],
943
+ '预算表里没有任何非零金额', '',
944
+ '预算表全零等于没有编制预算:把科目金额填进档案卡的「预算科目」再生成,'
945
+ '或直接在表里补数'))
946
+ return out
947
+
948
+
949
+ # ── 汇总与报告 ─────────────────────────────────────────────────────────────
950
+ LEVEL = {'error': '必修', 'warn': '建议', 'info': '提示'}
951
+ GROUP = {'complete': '齐全性', 'consistency': '一致性', 'leak': '模板残留',
952
+ 'structure': '结构', 'indicator': '指标勾稽', 'sameness': '雷同度',
953
+ 'unfinished': '验收结论', 'timeline': '时间线', 'schedule': '表格内容'}
954
+ ORDER = {'error': 0, 'warn': 1, 'info': 2}
955
+
956
+
957
+ # ── 项目名称的全档一致性 ───────────────────────────────────────────────────
958
+ def _typo_like(a, b, maxd=2):
959
+ """只差一两个字 = 错字,不是另一个项目。
960
+ 真实归档里「数字权益**开发**系统」被写了 25 处,正名是「开放系统」——
961
+ 差一个字,肉眼扫过去根本发现不了,全文替换却能一次改干净。
962
+
963
+ 用编辑距离而不是逐位比对:**掉一个字、多一个字同样是错字**,
964
+ 逐位比对遇到长度不等直接判否,这类错字就会被归到「另一个项目」里去,
965
+ 整改指引也跟着说错(该说「改错字」却说「拿错文件了」)。"""
966
+ if a == b or abs(len(a) - len(b)) > maxd or min(len(a), len(b)) < 6:
967
+ return False
968
+ prev = list(range(len(b) + 1))
969
+ for i, x in enumerate(a, 1):
970
+ cur = [i]
971
+ for j, y in enumerate(b, 1):
972
+ cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (x != y)))
973
+ if min(cur) > maxd: # 整行都超了,后面只会更大
974
+ return False
975
+ prev = cur
976
+ return prev[-1] <= maxd
977
+
978
+
979
+ def _official_name(files, spec):
980
+ """本项目的正式名:封面「项目名称」栏里最高频的取值。
981
+ 用封面字段而不是正文最高频串——正文里错字可能反超(12 期进度报告复制同一个错标题)。"""
982
+ votes = Counter()
983
+ for f in files:
984
+ v = _norm((f['doc'] or {}).get('fields', {}).get('项目名称', ''))
985
+ if v and len(v) >= 6:
986
+ votes[v] += 1
987
+ if votes:
988
+ ranked = [v for v, _n in votes.most_common()]
989
+ # 同样不能取最高频:封面被截断的写法可能占多数(见 _canonical)
990
+ return _canonical(ranked)[0]
991
+ body = Counter()
992
+ for f in files:
993
+ for m in S.PROJECT_NAME_RE.findall((f['doc'] or {}).get('text') or ''):
994
+ body[m] += 1
995
+ return body.most_common(1)[0][0] if body else ''
996
+
997
+
998
+ def check_project_name(files, spec):
999
+ """项目名在全档里是不是同一个名字。
1000
+
1001
+ 三种写法要分开对待,混为一谈就没法用:
1002
+ · **简称**(「…系统研发」「…系统」)——真实归档混用 144 次,完全正常,一律放过;
1003
+ · **错字**(开放→开发)——等长差一两字,全档 25 处,必修;
1004
+ · **他项目**(「基于信用模型算法的供应商管理系统」出现在本项目的签到表里)——
1005
+ 拿上一个项目的文件改的,标题忘了改。表单类文件里出现他项目名是必修,
1006
+ 正文叙述里出现只报提示:项目建议书里对比友商、总结报告里写软著名称都是正当的。
1007
+ """
1008
+ out = []
1009
+ official = _official_name(files, spec)
1010
+ if not official:
1011
+ return out
1012
+ key = S.name_key(official)
1013
+ typo = defaultdict(list)
1014
+ alien = defaultdict(list)
1015
+ for f in files:
1016
+ txt = (f['doc'] or {}).get('text') or ''
1017
+ if not txt:
1018
+ continue
1019
+ for name in set(S.PROJECT_NAME_RE.findall(txt)):
1020
+ k = S.name_key(name)
1021
+ # 前缀关系也算简称:正式名常带正则吃不下的尾巴(「(二期)」「V2.0」、
1022
+ # 批次序号),只认全等的话,官方名本身的每一次提及都会被判成「另一个项目」。
1023
+ if k == key or k.startswith(key) or key.startswith(k):
1024
+ continue # 简称,正常
1025
+ if _typo_like(k, key):
1026
+ typo[name].append(f['rel'])
1027
+ else:
1028
+ alien[name].append(f['rel'])
1029
+ for name, where in sorted(typo.items(), key=lambda x: -len(x[1])):
1030
+ diff = '、'.join(f'{y}→{x}' for x, y in zip(S.name_key(name), key) if x != y)
1031
+ out.append(_issue('error', 'naming', 'name-typo', where[0],
1032
+ f'项目名写成了「{name}」({len(where)} 份文件),与正式名差 {diff}',
1033
+ '例:' + '、'.join(where[:3]),
1034
+ f'全文替换成「{official}」——差一个字,肉眼审读发现不了'))
1035
+ for name, where in sorted(alien.items(), key=lambda x: -len(x[1])):
1036
+ forms = [w for w in where if any(t in w for t in S.FORM_KINDS)]
1037
+ if forms:
1038
+ out.append(_issue('error', 'naming', 'name-alien', forms[0],
1039
+ f'表单里写的是另一个项目「{name}」',
1040
+ '涉及:' + '、'.join(forms[:3]) +
1041
+ '——签到表/评审报告/申请书的抬头只该出现本项目名',
1042
+ f'拿上一个项目的文件改的,标题没改:应为「{official}」'))
1043
+ rest = [w for w in where if w not in forms]
1044
+ if rest:
1045
+ out.append(_issue('info', 'naming', 'name-other', rest[0],
1046
+ f'正文提到了另一个项目「{name}」({len(rest)} 份)',
1047
+ '例:' + '、'.join(rest[:3]) +
1048
+ '——可能正当(引用软著名称、对比同类项目),也可能是复制粘贴没改干净',
1049
+ '逐处确认是有意引用还是改漏了'))
1050
+ return out
1051
+
1052
+
1053
+ # ── 模板写死的字数上限 ─────────────────────────────────────────────────────
1054
+ def check_wordlimit(files, spec):
1055
+ """模板写着「(500字内)」,交付件写了 4000 字。
1056
+
1057
+ 这不是文风问题:高企认定与研发费用加计扣除的审查人按模板逐条核,
1058
+ 篇幅要求写在模板正文里就是填表要求。留 1.5 倍宽限——
1059
+ 模板也写「500字左右」,卡死 500 会把 520 字报出来,那是噪声。
1060
+ """
1061
+ out = []
1062
+ for f in files:
1063
+ kind = str(f['kind'] or '')
1064
+ doc = f['doc'] or {}
1065
+ if not doc.get('text'):
1066
+ continue
1067
+ for want_kind, title, limit, note in S.WORD_LIMITS:
1068
+ if want_kind not in kind and want_kind not in f['rel']:
1069
+ continue
1070
+ body = _section_text(doc, title, limit=200000)
1071
+ # _section_text 从标题处切起,标题本身要去掉——
1072
+ # 否则「500 字内」实际是按「500 减去标题长度」在卡,报出来的字数也偏大
1073
+ body = (body or '')[len(title):] if (body or '').startswith(title) else (body or '')
1074
+ n = len(re.sub(r'\s', '', body))
1075
+ if n > limit * S.WORD_LIMIT_SLACK:
1076
+ out.append(_issue('warn', 'wordlimit', 'over-limit', f['rel'],
1077
+ f'「{title}」写了约 {n} 字,模板要求 {limit} 字内',
1078
+ f'超出 {n / limit:.1f} 倍' + (f'(模板附注:{note})' if note else ''),
1079
+ '按模板压缩到要求字数,展开的内容放到对应正文章节去'))
1080
+ return out
1081
+
1082
+
1083
+ # ── 模板点名的必备要素 / 签到表与会人 ─────────────────────────────────────
1084
+ def check_required_elements(files, spec):
1085
+ """模板在节标题后括号里写明「包括立项文件号、合同号、研究起止时间」——
1086
+ 那就是这一节的验收项。两个参考项目都只写到「依据…文件精神」,号一个都没有。"""
1087
+ out = []
1088
+ for f in files:
1089
+ doc = f['doc'] or {}
1090
+ if not doc.get('text'):
1091
+ continue
1092
+ for want_kind, title, needs in S.SECTION_MUST_MENTION:
1093
+ if want_kind not in str(f['kind'] or '') and want_kind not in f['rel']:
1094
+ continue
1095
+ body = _section_text(doc, title)
1096
+ if not body:
1097
+ continue
1098
+ miss = [nm for nm, pat in needs if not pat.search(body)]
1099
+ if miss:
1100
+ out.append(_issue('warn', 'wordlimit', 'missing-element', f['rel'],
1101
+ f'「{title}」里没有{"、".join(miss)}',
1102
+ '模板在这一节标题后写明了必须包含的要素',
1103
+ '补上文号与起止时间;自研项目没有合同号的,写明「自研,无合同」'
1104
+ '也比留空好——审查人分不清是没有还是漏填'))
1105
+ return out
1106
+
1107
+
1108
+ def check_signin(files, spec):
1109
+ """签到表有表头没人名:这个会没有任何与会记录。
1110
+
1111
+ 判建议不判必修——纸质签到原件常在线下,电子件留空是常见做法。
1112
+ 但归档里只剩这份电子件时,它就是唯一的证据。
1113
+ """
1114
+ out = []
1115
+ for f in files:
1116
+ if '签到表' not in f['rel'] or f['ext'] not in DOC_EXT:
1117
+ continue
1118
+ for tb in (f['doc'] or {}).get('tables') or []:
1119
+ hdr = next((i for i, r in enumerate(tb)
1120
+ if any(any(h in str(c) for h in S.SIGNIN_HEADER) for c in r)), None)
1121
+ if hdr is None:
1122
+ continue
1123
+ col = next((j for j, c in enumerate(tb[hdr])
1124
+ if any(h in str(c) for h in S.SIGNIN_HEADER)), 0)
1125
+ names = [r for r in tb[hdr + 1:]
1126
+ if col < len(r) and re.fullmatch(r'[一-鿿·]{2,6}', str(r[col]).strip() or '')]
1127
+ if not names:
1128
+ out.append(_issue('warn', 'structure', 'signin-empty', f['rel'],
1129
+ '签到表有表头但一个与会人都没填',
1130
+ f'「{"/".join(S.SIGNIN_HEADER)}」列下方 '
1131
+ f'{max(len(tb) - hdr - 1, 0)} 行全空',
1132
+ '把实际与会人录进去;纸质原件已签的,扫描件一并归档'))
1133
+ break
1134
+ return out
1135
+
1136
+
1137
+ def check_boilerplate(files, spec):
1138
+ """一段话逐字出现在多种**用途完全不同**的文档里 = 没人按各自的用途写。
1139
+
1140
+ 真实语料里最严重的一段 449 字,同时出现在项目建议书、可行性研究报告、研发任务书、
1141
+ 项目研发实施计划、12 期研发进度报告、工作总结报告、技术总结报告、研发评价总结纪要——
1142
+ 九种类型二十份文件。进度报告的职责是记录本期干了什么,它不该和立项建议书开同一个头。
1143
+
1144
+ 判**建议**不判必修:这是质量信号不是事实错误,而且总有正当的复用
1145
+ (立项三件套共享背景),门槛已按真语料定在 4 种类型以避开那一类。
1146
+ 聚合成一条报,34 段逐段报会把整张报告刷掉。
1147
+ """
1148
+ para2files = defaultdict(set)
1149
+ kind_of = {}
1150
+ for f in files:
1151
+ if f['ext'] not in DOC_EXT:
1152
+ continue
1153
+ kind_of[f['rel']] = f['kind'] or os.path.basename(f['rel'])
1154
+ for ln in ((f['doc'] or {}).get('text') or '').split('\n'):
1155
+ t = re.sub(r'\s', '', ln)
1156
+ if len(t) >= S.BOILER_MIN_CHARS:
1157
+ para2files[t].add(f['rel'])
1158
+ rows = []
1159
+ for text, rels in para2files.items():
1160
+ ks = {kind_of[r] for r in rels}
1161
+ if len(ks) >= S.BOILER_MIN_KINDS:
1162
+ rows.append((len(ks), len(rels), len(text), sorted(ks), text))
1163
+ if not rows:
1164
+ return []
1165
+ rows.sort(reverse=True)
1166
+ total = sum(r[2] for r in rows)
1167
+ worst = rows[0]
1168
+ return [_issue('warn', 'sameness', 'cross-boilerplate', worst[3][0],
1169
+ f'{len(rows)} 段正文逐字出现在 {S.BOILER_MIN_KINDS} 种以上不同用途的文档里'
1170
+ f'(合计 {total} 字)',
1171
+ f'最严重的一段 {worst[2]} 字,横跨 {worst[0]} 种类型 / {worst[1]} 份文件:'
1172
+ f'{"、".join(worst[3][:6])}{"…" if len(worst[3]) > 6 else ""}'
1173
+ f'——「{worst[4][:40]}…」',
1174
+ '每类文档有各自的职责:进度报告写本期实际进展,验收报告写达标情况,'
1175
+ '不该和立项建议书开同一个头。背景介绍可以复用,正文主体应当各写各的')]
1176
+
1177
+
1178
+ def inspect(root, spec_name='hengtong', deep=False):
1179
+ spec = S.load(spec_name)
1180
+ files = scan(root, spec)
1181
+ issues = []
1182
+ issues += check_complete(files, spec)
1183
+ issues += check_consistency(files, spec)
1184
+ issues += check_leak(files, spec)
1185
+ issues += check_structure(files, spec)
1186
+ issues += check_indicators(files, spec)
1187
+ issues += check_sameness(files, spec)
1188
+ issues += check_figures(files, spec)
1189
+ issues += check_unfinished(files, spec)
1190
+ issues += check_timeline(files, spec)
1191
+ issues += check_schedule(files, spec)
1192
+ issues += check_sheet_content(files, spec)
1193
+ issues += check_project_name(files, spec)
1194
+ issues += check_wordlimit(files, spec)
1195
+ issues += check_required_elements(files, spec)
1196
+ issues += check_signin(files, spec)
1197
+ issues += check_boilerplate(files, spec)
1198
+ # 读不进来的文件要点名。**沉默是最坏的降级**:份数检查照样通过,
1199
+ # 用户看到「必修 0」会以为这些文件查过了,其实它们的正文一个字都没进过判据。
1200
+ dead = [f['rel'] for f in files if (f['doc'] or {}).get('unreadable')]
1201
+ if dead:
1202
+ issues.append(_issue('warn', 'structure', 'unreadable', dead[0],
1203
+ f'{len(dead)} 份文件读不进来,正文类检查没覆盖到它们',
1204
+ '、'.join(dead[:4]) + ('…' if len(dead) > 4 else '')
1205
+ + '——旧版二进制 .doc,本机没有 textutil/antiword 可用',
1206
+ '转存为 .docx 后重跑,这些文件才会参与模板残留、'
1207
+ '项目名一致性、雷同度等判据'))
1208
+ if deep:
1209
+ issues += _deep(files)
1210
+ counts = Counter(i['level'] for i in issues)
1211
+ kinds = Counter(f['kind'] for f in files if f['kind'])
1212
+ return {
1213
+ 'root': root, 'spec': spec['name'], 'spec_label': spec['label'],
1214
+ 'stats': {'files': len(files),
1215
+ 'docs': sum(1 for f in files if f['ext'] in DOC_EXT),
1216
+ 'sheets': sum(1 for f in files if f['ext'] in SHEET_EXT),
1217
+ 'classified': sum(kinds.values()), 'kinds': dict(kinds),
1218
+ 'unclassified': [f['rel'] for f in files if not f['kind']]},
1219
+ 'counts': {k: counts.get(k, 0) for k in ('error', 'warn', 'info')},
1220
+ 'issues': issues,
1221
+ }
1222
+
1223
+
1224
+ def _deep(files):
1225
+ """顺带对每份 .docx 跑一遍单文档排版体检(慢,默认关)。"""
1226
+ out = []
1227
+ here = os.path.dirname(os.path.abspath(__file__))
1228
+ cand = os.path.join(os.path.dirname(os.path.dirname(here)), 'docx-author', 'scripts')
1229
+ if cand not in sys.path:
1230
+ sys.path.insert(0, cand)
1231
+ try:
1232
+ import docx_check
1233
+ except Exception as e:
1234
+ return [_issue('info', 'structure', 'deep-unavailable', '深度体检',
1235
+ f'单文档体检不可用:{e}')]
1236
+ for f in files:
1237
+ if f['ext'] != '.docx':
1238
+ continue
1239
+ try:
1240
+ r = docx_check.inspect(f['path'])
1241
+ except Exception:
1242
+ continue
1243
+ for it in r['issues']:
1244
+ if it['level'] == 'error':
1245
+ out.append(_issue('error', 'structure', 'doc-' + it['kind'], f['rel'],
1246
+ it['msg'], it.get('detail', ''), ''))
1247
+ return out
1248
+
1249
+
1250
+ def report(res, limit=6, show_info=True):
1251
+ s, c = res['stats'], res['counts']
1252
+ lines = [f"【档案体检】{os.path.basename(os.path.normpath(res['root']))} · {res['spec_label']}",
1253
+ f" 规模:{s['files']} 个文件({s['docs']} 份文档 / {s['sheets']} 张表),"
1254
+ f"其中 {s['classified']} 份可对应到规范条目",
1255
+ f" 结论:必修 {c['error']} · 建议 {c['warn']} · 提示 {c['info']}"]
1256
+ # 对不上规范条目的文件要点名。只报个数等于把它们无声吞掉:
1257
+ # 对不上通常有两种原因——文件命名不规范(会连带把齐全性判成缺件),
1258
+ # 或者确实是规范之外的附件。两种都得让人自己看一眼,工具不该替他决定。
1259
+ unk = s.get('unclassified') or []
1260
+ if unk:
1261
+ lines.append(f" 未对应到规范条目的 {len(unk)} 份:" + '、'.join(unk[:6])
1262
+ + ('…' if len(unk) > 6 else '')
1263
+ + '(命名不规范会连带误判缺件,规范外的附件则可忽略)')
1264
+ # 按**消息原文**分组,不按 kind:同一个 kind 下「项目名称不一致」与「建设单位不一致」
1265
+ # 是两件事,揉成一条会把第二条的正文吃掉,只剩一个孤零零的 where。
1266
+ grouped = defaultdict(list)
1267
+ for it in res['issues']:
1268
+ grouped[(it['level'], it['group'], it['kind'], it['msg'])].append(it)
1269
+ for key in sorted(grouped, key=lambda k: (ORDER[k[0]], k[1], k[3])):
1270
+ lvl, grp, _kind, msg = key
1271
+ if lvl == 'info' and not show_info:
1272
+ continue
1273
+ items = grouped[key]
1274
+ head = f" [{LEVEL[lvl]}·{GROUP.get(grp, grp)}] {msg}"
1275
+ if len(items) > 1:
1276
+ head += f'({len(items)} 处)'
1277
+ lines.append(head)
1278
+ for it in items[:limit]:
1279
+ w = it['where'] or ''
1280
+ d = f" — {it['detail']}" if it['detail'] else ''
1281
+ if w or d:
1282
+ lines.append(f' · {w}{d}')
1283
+ if len(items) > limit:
1284
+ lines.append(f' · …另有 {len(items)-limit} 处')
1285
+ if items[0]['suggest']:
1286
+ lines.append(f" → {items[0]['suggest']}")
1287
+ if not res['issues']:
1288
+ lines.append(' 未发现问题。')
1289
+ return '\n'.join(lines)
1290
+
1291
+
1292
+ def main(argv):
1293
+ args = [a for a in argv if not a.startswith('--')]
1294
+ if not args:
1295
+ print('用法:python archive_check.py 项目目录 [--deep] [--json] [--spec=名或路径]')
1296
+ return 2
1297
+ spec = 'hengtong'
1298
+ for a in argv:
1299
+ if a.startswith('--spec='):
1300
+ spec = a.split('=', 1)[1]
1301
+ res = inspect(args[0], spec, deep='--deep' in argv)
1302
+ print(json.dumps(res, ensure_ascii=False, default=str) if '--json' in argv else report(res))
1303
+ return 0
1304
+
1305
+
1306
+ if __name__ == '__main__':
1307
+ sys.exit(main(sys.argv[1:]))