precis-cli 0.1.3__py3-none-any.whl

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 (442) hide show
  1. app/__init__.py +16 -0
  2. app/api/__init__.py +32 -0
  3. app/api/dependencies.py +185 -0
  4. app/api/main.py +301 -0
  5. app/api/middleware/__init__.py +34 -0
  6. app/api/middleware/exception_handler.py +126 -0
  7. app/api/middleware/request_logging.py +97 -0
  8. app/api/middleware/token_auth.py +215 -0
  9. app/api/models/__init__.py +151 -0
  10. app/api/models/connection_rules.py +98 -0
  11. app/api/models/files.py +99 -0
  12. app/api/models/full_validation.py +554 -0
  13. app/api/models/project.py +117 -0
  14. app/api/models/projects.py +80 -0
  15. app/api/models/schema.py +121 -0
  16. app/api/models/v2_responses.py +46 -0
  17. app/api/models/validation.py +303 -0
  18. app/api/models/workspace.py +118 -0
  19. app/api/routers/__init__.py +56 -0
  20. app/api/routers/ai/__init__.py +36 -0
  21. app/api/routers/ai/chat.py +243 -0
  22. app/api/routers/ai/generate.py +156 -0
  23. app/api/routers/ai/hardware.py +199 -0
  24. app/api/routers/ai/jobs.py +544 -0
  25. app/api/routers/ai/migrate.py +471 -0
  26. app/api/routers/ai/models.py +352 -0
  27. app/api/routers/ai/ollama.py +108 -0
  28. app/api/routers/ai/providers.py +491 -0
  29. app/api/routers/ai/router.py +44 -0
  30. app/api/routers/ai/stream.py +281 -0
  31. app/api/routers/ai/utils.py +182 -0
  32. app/api/routers/core/__init__.py +34 -0
  33. app/api/routers/core/connection_rules.py +188 -0
  34. app/api/routers/core/data_sources.py +374 -0
  35. app/api/routers/core/regex.py +333 -0
  36. app/api/routers/core/reporting.py +263 -0
  37. app/api/routers/files/__init__.py +24 -0
  38. app/api/routers/files/ops.py +179 -0
  39. app/api/routers/files/transfer.py +101 -0
  40. app/api/routers/preview/__init__.py +35 -0
  41. app/api/routers/preview/content_mode.py +264 -0
  42. app/api/routers/preview/header_row.py +154 -0
  43. app/api/routers/preview/models.py +81 -0
  44. app/api/routers/preview/path_mode.py +440 -0
  45. app/api/routers/preview/router.py +30 -0
  46. app/api/routers/project/__init__.py +61 -0
  47. app/api/routers/project/base.py +111 -0
  48. app/api/routers/project/constraint.py +345 -0
  49. app/api/routers/project/full_config.py +411 -0
  50. app/api/routers/project/full_config_writer.py +327 -0
  51. app/api/routers/project/helpers.py +167 -0
  52. app/api/routers/project/inspection_fix.py +330 -0
  53. app/api/routers/project/manifest.py +605 -0
  54. app/api/routers/project/models.py +177 -0
  55. app/api/routers/project/pattern.py +268 -0
  56. app/api/routers/project/regex.py +358 -0
  57. app/api/routers/project/scanner.py +157 -0
  58. app/api/routers/project/schema.py +567 -0
  59. app/api/routers/project/schema_helpers.py +149 -0
  60. app/api/routers/project/settings.py +399 -0
  61. app/api/routers/project/template.py +325 -0
  62. app/api/routers/project/validation.py +227 -0
  63. app/api/routers/project/view.py +177 -0
  64. app/api/routers/project/workspaces.py +167 -0
  65. app/api/routers/projects/__init__.py +25 -0
  66. app/api/routers/projects/create.py +121 -0
  67. app/api/routers/projects/open.py +103 -0
  68. app/api/routers/projects/scan.py +100 -0
  69. app/api/routers/validation/__init__.py +39 -0
  70. app/api/routers/validation/common.py +263 -0
  71. app/api/routers/validation/content_mode.py +401 -0
  72. app/api/routers/validation/history.py +235 -0
  73. app/api/routers/validation/inline_mode.py +196 -0
  74. app/api/routers/validation/path_mode.py +314 -0
  75. app/api/routers/validation/router.py +51 -0
  76. app/api/services/full_validation_response_builder.py +412 -0
  77. app/api/services/io_error_messages.py +41 -0
  78. app/api/services/preview_service.py +248 -0
  79. app/cli/__init__.py +18 -0
  80. app/cli/__main__.py +30 -0
  81. app/cli/shared_services/__init__.py +42 -0
  82. app/cli/shared_services/config_ops.py +581 -0
  83. app/cli/shared_services/generation_ops.py +160 -0
  84. app/cli/shared_services/project_ops.py +288 -0
  85. app/cli/shell/__init__.py +48 -0
  86. app/cli/shell/commands/__init__.py +63 -0
  87. app/cli/shell/commands/ai/__init__.py +260 -0
  88. app/cli/shell/commands/ai/chat.py +331 -0
  89. app/cli/shell/commands/ai/delete.py +165 -0
  90. app/cli/shell/commands/ai/diff.py +70 -0
  91. app/cli/shell/commands/ai/display.py +428 -0
  92. app/cli/shell/commands/ai/executor.py +225 -0
  93. app/cli/shell/commands/ai/executor_utils.py +271 -0
  94. app/cli/shell/commands/ai/generate.py +285 -0
  95. app/cli/shell/commands/ai/interaction.py +262 -0
  96. app/cli/shell/commands/ai/migrate.py +281 -0
  97. app/cli/shell/commands/ai/resolver.py +224 -0
  98. app/cli/shell/commands/ai/status.py +101 -0
  99. app/cli/shell/commands/ai/switch.py +158 -0
  100. app/cli/shell/commands/ai/utils.py +52 -0
  101. app/cli/shell/commands/base.py +343 -0
  102. app/cli/shell/commands/config/__init__.py +131 -0
  103. app/cli/shell/commands/config/base.py +80 -0
  104. app/cli/shell/commands/config/check.py +246 -0
  105. app/cli/shell/commands/config/edit.py +148 -0
  106. app/cli/shell/commands/config/get.py +111 -0
  107. app/cli/shell/commands/config/init.py +123 -0
  108. app/cli/shell/commands/config/inspect.py +164 -0
  109. app/cli/shell/commands/config/list.py +104 -0
  110. app/cli/shell/commands/config/set.py +119 -0
  111. app/cli/shell/commands/config/show.py +150 -0
  112. app/cli/shell/commands/exit.py +67 -0
  113. app/cli/shell/commands/help.py +104 -0
  114. app/cli/shell/commands/infer_schema.py +156 -0
  115. app/cli/shell/commands/open.py +201 -0
  116. app/cli/shell/commands/project.py +215 -0
  117. app/cli/shell/commands/provider.py +619 -0
  118. app/cli/shell/commands/system.py +170 -0
  119. app/cli/shell/commands/validate.py +455 -0
  120. app/cli/shell/completer.py +231 -0
  121. app/cli/shell/config_storage.py +191 -0
  122. app/cli/shell/exceptions.py +90 -0
  123. app/cli/shell/formatter.py +371 -0
  124. app/cli/shell/interactive_menu.py +348 -0
  125. app/cli/shell/main.py +286 -0
  126. app/cli/shell/parser.py +266 -0
  127. app/cli/start.py +180 -0
  128. app/cli_main.py +52 -0
  129. app/mcp_server.py +311 -0
  130. app/shared/core/__init__.py +18 -0
  131. app/shared/core/app_version.py +59 -0
  132. app/shared/core/config/__init__.py +119 -0
  133. app/shared/core/config/server.py +193 -0
  134. app/shared/core/data_source/__init__.py +106 -0
  135. app/shared/core/data_source/loader.py +348 -0
  136. app/shared/core/data_source/loaders/__init__.py +213 -0
  137. app/shared/core/data_source/loaders/base.py +215 -0
  138. app/shared/core/data_source/loaders/converter.py +337 -0
  139. app/shared/core/data_source/loaders/csv_loader.py +252 -0
  140. app/shared/core/data_source/loaders/excel_loader.py +666 -0
  141. app/shared/core/data_source/loaders/extractor.py +268 -0
  142. app/shared/core/data_source/loaders/json_loader.py +354 -0
  143. app/shared/core/data_source/loaders/registry.py +88 -0
  144. app/shared/core/data_source/loaders/sql_loader.py +313 -0
  145. app/shared/core/data_source/loaders/strategies/__init__.py +100 -0
  146. app/shared/core/data_source/loaders/strategies/array_parser.py +277 -0
  147. app/shared/core/data_source/loaders/strategies/lines_parser.py +355 -0
  148. app/shared/core/data_source/loaders/strategies/object_parser.py +278 -0
  149. app/shared/core/data_source/schema_info.py +50 -0
  150. app/shared/core/data_source/specs/__init__.py +128 -0
  151. app/shared/core/data_source/specs/base.py +258 -0
  152. app/shared/core/data_source/specs/csv_source.py +137 -0
  153. app/shared/core/data_source/specs/excel_source.py +141 -0
  154. app/shared/core/data_source/specs/file_base.py +252 -0
  155. app/shared/core/data_source/specs/json_source.py +226 -0
  156. app/shared/core/data_source/specs/sql_source.py +161 -0
  157. app/shared/core/encoding.py +36 -0
  158. app/shared/core/io/__init__.py +24 -0
  159. app/shared/core/io/yaml.py +379 -0
  160. app/shared/core/manifest_schema/__init__.py +52 -0
  161. app/shared/core/manifest_schema/types.py +99 -0
  162. app/shared/core/manifest_schema/version.py +175 -0
  163. app/shared/core/patterns/__init__.py +24 -0
  164. app/shared/core/patterns/loader.py +134 -0
  165. app/shared/core/patterns/writer.py +244 -0
  166. app/shared/core/project/__init__.py +47 -0
  167. app/shared/core/project/constraint/builders/__init__.py +50 -0
  168. app/shared/core/project/constraint/builders/base.py +100 -0
  169. app/shared/core/project/constraint/builders/composite.py +77 -0
  170. app/shared/core/project/constraint/builders/conditional.py +67 -0
  171. app/shared/core/project/constraint/builders/foreign_key.py +53 -0
  172. app/shared/core/project/constraint/builders/registry.py +64 -0
  173. app/shared/core/project/constraint/builders/scripted.py +51 -0
  174. app/shared/core/project/constraint/builders/single_column.py +86 -0
  175. app/shared/core/project/constraint/builders/unique.py +53 -0
  176. app/shared/core/project/constraint/factory.py +170 -0
  177. app/shared/core/project/constraint/reader.py +214 -0
  178. app/shared/core/project/constraint/registry.py +233 -0
  179. app/shared/core/project/constraint/types/__init__.py +63 -0
  180. app/shared/core/project/constraint/types/constraint_file.py +261 -0
  181. app/shared/core/project/constraint/types/refs.py +460 -0
  182. app/shared/core/project/constraint/types.py +28 -0
  183. app/shared/core/project/constraint/writer.py +181 -0
  184. app/shared/core/project/loader/__init__.py +56 -0
  185. app/shared/core/project/loader/loader.py +30 -0
  186. app/shared/core/project/loader/loader_parts/config_inspector.py +137 -0
  187. app/shared/core/project/loader/loader_parts/embedded_constraints.py +224 -0
  188. app/shared/core/project/loader/loader_parts/file_loaders.py +58 -0
  189. app/shared/core/project/loader/loader_parts/inspection_ids.py +85 -0
  190. app/shared/core/project/loader/loader_parts/inspector_helpers.py +312 -0
  191. app/shared/core/project/loader/loader_parts/inspector_id_checks.py +274 -0
  192. app/shared/core/project/loader/loader_parts/inspector_reference_checks.py +690 -0
  193. app/shared/core/project/loader/loader_parts/inspector_uniqueness_checks.py +286 -0
  194. app/shared/core/project/loader/loader_parts/loading_error_messages.py +298 -0
  195. app/shared/core/project/loader/loader_parts/main.py +432 -0
  196. app/shared/core/project/loader/loader_parts/path_validation.py +117 -0
  197. app/shared/core/project/loader/loader_parts/runtime.py +125 -0
  198. app/shared/core/project/loader/types.py +246 -0
  199. app/shared/core/project/manifest/coverage.py +391 -0
  200. app/shared/core/project/manifest/reader.py +260 -0
  201. app/shared/core/project/manifest/types.py +91 -0
  202. app/shared/core/project/manifest/types_parts/__init__.py +60 -0
  203. app/shared/core/project/manifest/types_parts/constants.py +40 -0
  204. app/shared/core/project/manifest/types_parts/data_source.py +68 -0
  205. app/shared/core/project/manifest/types_parts/info.py +59 -0
  206. app/shared/core/project/manifest/types_parts/manifest.py +294 -0
  207. app/shared/core/project/manifest/types_parts/refs.py +153 -0
  208. app/shared/core/project/manifest/types_parts/settings.py +92 -0
  209. app/shared/core/project/manifest/types_parts/settings_file_processing.py +64 -0
  210. app/shared/core/project/manifest/types_parts/settings_script_security.py +78 -0
  211. app/shared/core/project/manifest/types_parts/settings_validation.py +83 -0
  212. app/shared/core/project/manifest/types_parts/template.py +66 -0
  213. app/shared/core/project/manifest/writer.py +262 -0
  214. app/shared/core/project/manual_data/__init__.py +27 -0
  215. app/shared/core/project/manual_data/types.py +75 -0
  216. app/shared/core/project/regex/reader.py +197 -0
  217. app/shared/core/project/regex/types.py +405 -0
  218. app/shared/core/project/regex/writer.py +123 -0
  219. app/shared/core/project/schema/reader.py +170 -0
  220. app/shared/core/project/schema/types.py +47 -0
  221. app/shared/core/project/schema/types_parts/__init__.py +36 -0
  222. app/shared/core/project/schema/types_parts/column.py +174 -0
  223. app/shared/core/project/schema/types_parts/column_utils.py +72 -0
  224. app/shared/core/project/schema/types_parts/constraint.py +165 -0
  225. app/shared/core/project/schema/types_parts/schema_id.py +66 -0
  226. app/shared/core/project/schema/types_parts/source.py +255 -0
  227. app/shared/core/project/schema/types_parts/source_options.py +347 -0
  228. app/shared/core/project/schema/types_parts/table.py +230 -0
  229. app/shared/core/project/schema/writer.py +139 -0
  230. app/shared/core/project/schema_ref_check.py +95 -0
  231. app/shared/core/project/template/__init__.py +27 -0
  232. app/shared/core/project/template/expander.py +263 -0
  233. app/shared/core/project/template/reader.py +120 -0
  234. app/shared/core/project/template/types.py +114 -0
  235. app/shared/core/project/transform/reader.py +76 -0
  236. app/shared/core/project/transform/types.py +116 -0
  237. app/shared/core/project/transform/writer.py +84 -0
  238. app/shared/core/pydantic_messages.py +59 -0
  239. app/shared/core/reporter/__init__.py +46 -0
  240. app/shared/core/reporter/reporter.py +220 -0
  241. app/shared/core/reporter/reporters/__init__.py +65 -0
  242. app/shared/core/reporter/reporters/base.py +188 -0
  243. app/shared/core/reporter/reporters/dingtalk_app_reporter.py +274 -0
  244. app/shared/core/reporter/reporters/email_reporter.py +271 -0
  245. app/shared/core/reporter/reporters/feishu_app_reporter.py +467 -0
  246. app/shared/core/reporter/reporters/local_file_reporter.py +208 -0
  247. app/shared/core/reporter/reporters/wecom_app_reporter.py +268 -0
  248. app/shared/core/utils/__init__.py +18 -0
  249. app/shared/core/utils/path_utils.py +60 -0
  250. app/shared/core/utils/regex_extract.py +113 -0
  251. app/shared/domain/__init__.py +114 -0
  252. app/shared/domain/constraints/__init__.py +76 -0
  253. app/shared/domain/constraints/allowed_values.py +211 -0
  254. app/shared/domain/constraints/base.py +141 -0
  255. app/shared/domain/constraints/charset.py +337 -0
  256. app/shared/domain/constraints/composite.py +174 -0
  257. app/shared/domain/constraints/condition_registry.py +130 -0
  258. app/shared/domain/constraints/conditional.py +629 -0
  259. app/shared/domain/constraints/date_logic.py +731 -0
  260. app/shared/domain/constraints/foreign_key.py +261 -0
  261. app/shared/domain/constraints/key_normalization.py +56 -0
  262. app/shared/domain/constraints/not_null.py +185 -0
  263. app/shared/domain/constraints/range.py +360 -0
  264. app/shared/domain/constraints/regex.py +218 -0
  265. app/shared/domain/constraints/scripted.py +276 -0
  266. app/shared/domain/constraints/unique.py +233 -0
  267. app/shared/domain/data_engine.py +384 -0
  268. app/shared/domain/data_types.py +113 -0
  269. app/shared/domain/data_types_parts/__init__.py +67 -0
  270. app/shared/domain/data_types_parts/base.py +204 -0
  271. app/shared/domain/data_types_parts/composite.py +250 -0
  272. app/shared/domain/data_types_parts/expression.py +202 -0
  273. app/shared/domain/data_types_parts/extracted.py +106 -0
  274. app/shared/domain/data_types_parts/json_types.py +234 -0
  275. app/shared/domain/data_types_parts/scalars.py +719 -0
  276. app/shared/domain/data_types_parts/sequence.py +129 -0
  277. app/shared/domain/dataset_schema.py +48 -0
  278. app/shared/domain/eval_sandbox.py +63 -0
  279. app/shared/domain/expression_system.py +366 -0
  280. app/shared/domain/regex_flags.py +53 -0
  281. app/shared/domain/schema/builder.py +223 -0
  282. app/shared/domain/schema/models.py +339 -0
  283. app/shared/domain/transforms/__init__.py +28 -0
  284. app/shared/domain/transforms/aggregate.py +141 -0
  285. app/shared/domain/transforms/base.py +171 -0
  286. app/shared/domain/transforms/cast_type.py +120 -0
  287. app/shared/domain/transforms/concat.py +103 -0
  288. app/shared/domain/transforms/conditional_assign.py +114 -0
  289. app/shared/domain/transforms/date_format.py +81 -0
  290. app/shared/domain/transforms/digits.py +77 -0
  291. app/shared/domain/transforms/drop_duplicates.py +94 -0
  292. app/shared/domain/transforms/fill_na.py +94 -0
  293. app/shared/domain/transforms/filter_rows.py +97 -0
  294. app/shared/domain/transforms/lookup.py +78 -0
  295. app/shared/domain/transforms/lower_case.py +70 -0
  296. app/shared/domain/transforms/map_value.py +90 -0
  297. app/shared/domain/transforms/math_expr.py +112 -0
  298. app/shared/domain/transforms/modulo.py +82 -0
  299. app/shared/domain/transforms/regex_extract.py +103 -0
  300. app/shared/domain/transforms/registry.py +95 -0
  301. app/shared/domain/transforms/replace.py +88 -0
  302. app/shared/domain/transforms/sort_rows.py +94 -0
  303. app/shared/domain/transforms/string_split.py +84 -0
  304. app/shared/domain/transforms/strip.py +73 -0
  305. app/shared/domain/transforms/substring.py +92 -0
  306. app/shared/domain/transforms/upper_case.py +70 -0
  307. app/shared/domain/transforms/weighted_sum.py +104 -0
  308. app/shared/domain/validation_constraints.py +69 -0
  309. app/shared/services/__init__.py +62 -0
  310. app/shared/services/ai/__init__.py +48 -0
  311. app/shared/services/ai/agent/__init__.py +40 -0
  312. app/shared/services/ai/agent/chat_tools/__init__.py +47 -0
  313. app/shared/services/ai/agent/chat_tools/apply_actions.py +592 -0
  314. app/shared/services/ai/agent/chat_tools/ask_user.py +237 -0
  315. app/shared/services/ai/agent/chat_tools/read_canvas.py +140 -0
  316. app/shared/services/ai/agent/chat_tools/read_project.py +160 -0
  317. app/shared/services/ai/agent/chat_tools/read_table.py +301 -0
  318. app/shared/services/ai/agent/chat_tools/schemas.py +105 -0
  319. app/shared/services/ai/agent/chat_tools/validate_table.py +131 -0
  320. app/shared/services/ai/agent/executor.py +554 -0
  321. app/shared/services/ai/agent/memory.py +256 -0
  322. app/shared/services/ai/agent/planner.py +282 -0
  323. app/shared/services/ai/agent/tool_registry.py +296 -0
  324. app/shared/services/ai/agent/tools/__init__.py +32 -0
  325. app/shared/services/ai/agent/tools/config_generate.py +133 -0
  326. app/shared/services/ai/agent/tools/config_refine.py +109 -0
  327. app/shared/services/ai/agent/tools/config_validate.py +403 -0
  328. app/shared/services/ai/agent/tools/merge_results.py +136 -0
  329. app/shared/services/ai/agent/tools/plan_chunks.py +79 -0
  330. app/shared/services/ai/agent/tools/script_parse.py +359 -0
  331. app/shared/services/ai/agent/types.py +138 -0
  332. app/shared/services/ai/chat_agent_runner.py +596 -0
  333. app/shared/services/ai/chat_orchestrator.py +725 -0
  334. app/shared/services/ai/failure_messages.py +65 -0
  335. app/shared/services/ai/job_storage.py +274 -0
  336. app/shared/services/ai/migrate_service.py +550 -0
  337. app/shared/services/ai/streaming/__init__.py +65 -0
  338. app/shared/services/ai/streaming/event_journal.py +197 -0
  339. app/shared/services/ai/streaming/orchestrator.py +262 -0
  340. app/shared/services/ai/streaming/pending_interaction_store.py +227 -0
  341. app/shared/services/ai/streaming/sse_response.py +148 -0
  342. app/shared/services/ai/streaming/types.py +73 -0
  343. app/shared/services/ai/types.py +213 -0
  344. app/shared/services/ai/utils.py +322 -0
  345. app/shared/services/diff/config_diff.py +320 -0
  346. app/shared/services/hardware.py +237 -0
  347. app/shared/services/llm/__init__.py +62 -0
  348. app/shared/services/llm/actions/__init__.py +42 -0
  349. app/shared/services/llm/actions/_canvas_validator.py +147 -0
  350. app/shared/services/llm/actions/_constraint_validator.py +401 -0
  351. app/shared/services/llm/actions/_regex_validator.py +85 -0
  352. app/shared/services/llm/actions/_schema_validator.py +82 -0
  353. app/shared/services/llm/actions/_settings_validator.py +76 -0
  354. app/shared/services/llm/actions/_transform_validator.py +78 -0
  355. app/shared/services/llm/actions/action_handlers.py +400 -0
  356. app/shared/services/llm/actions/action_parser.py +63 -0
  357. app/shared/services/llm/actions/action_processor.py +468 -0
  358. app/shared/services/llm/actions/action_validator.py +311 -0
  359. app/shared/services/llm/actions/diff_compute.py +195 -0
  360. app/shared/services/llm/actions/regex_handlers.py +284 -0
  361. app/shared/services/llm/actions/registry.py +336 -0
  362. app/shared/services/llm/actions/schema_handlers.py +335 -0
  363. app/shared/services/llm/actions/settings_handlers.py +180 -0
  364. app/shared/services/llm/actions/specs.py +259 -0
  365. app/shared/services/llm/actions/transform_handlers.py +279 -0
  366. app/shared/services/llm/actions/validation_types.py +147 -0
  367. app/shared/services/llm/cache/__init__.py +18 -0
  368. app/shared/services/llm/cache/response_cache.py +80 -0
  369. app/shared/services/llm/chat/__init__.py +35 -0
  370. app/shared/services/llm/chat/chat_system_prompt.py +561 -0
  371. app/shared/services/llm/chat/response_parser.py +296 -0
  372. app/shared/services/llm/config/__init__.py +45 -0
  373. app/shared/services/llm/config/crypto.py +135 -0
  374. app/shared/services/llm/config/loader.py +258 -0
  375. app/shared/services/llm/config/models.py +202 -0
  376. app/shared/services/llm/config/presets.py +128 -0
  377. app/shared/services/llm/config_generator.py +163 -0
  378. app/shared/services/llm/constraints/__init__.py +41 -0
  379. app/shared/services/llm/constraints/constraint_builder.py +177 -0
  380. app/shared/services/llm/constraints/constraint_deletion.py +84 -0
  381. app/shared/services/llm/constraints/constraint_id.py +174 -0
  382. app/shared/services/llm/constraints/frontend_instructions.py +333 -0
  383. app/shared/services/llm/constraints/inline_batch.py +279 -0
  384. app/shared/services/llm/discovery/__init__.py +35 -0
  385. app/shared/services/llm/discovery/scanner.py +181 -0
  386. app/shared/services/llm/generation/__init__.py +42 -0
  387. app/shared/services/llm/generation/agent_wiring.py +156 -0
  388. app/shared/services/llm/generation/config_builder.py +386 -0
  389. app/shared/services/llm/generation/errors.py +36 -0
  390. app/shared/services/llm/generation/existing_config.py +99 -0
  391. app/shared/services/llm/generation/profiler.py +279 -0
  392. app/shared/services/llm/generation/prompt_builder.py +199 -0
  393. app/shared/services/llm/generation/response_parser.py +144 -0
  394. app/shared/services/llm/generation/service.py +622 -0
  395. app/shared/services/llm/models.py +104 -0
  396. app/shared/services/llm/providers/__init__.py +48 -0
  397. app/shared/services/llm/providers/base.py +310 -0
  398. app/shared/services/llm/providers/cached_provider.py +90 -0
  399. app/shared/services/llm/providers/ollama.py +498 -0
  400. app/shared/services/llm/providers/openai.py +334 -0
  401. app/shared/services/llm/providers/registry.py +108 -0
  402. app/shared/services/llm/schema_resolver.py +141 -0
  403. app/shared/services/llm/suggestion_utils.py +219 -0
  404. app/shared/services/llm/validate_executor.py +163 -0
  405. app/shared/services/llm/yaml_io.py +406 -0
  406. app/shared/services/preview/__init__.py +18 -0
  407. app/shared/services/preview/loader.py +145 -0
  408. app/shared/services/preview/path_validation.py +138 -0
  409. app/shared/services/project_loader.py +57 -0
  410. app/shared/services/schema_inference.py +260 -0
  411. app/shared/services/schema_runtime_builder.py +120 -0
  412. app/shared/services/validation/__init__.py +52 -0
  413. app/shared/services/validation/chunked_loader.py +509 -0
  414. app/shared/services/validation/dag/__init__.py +35 -0
  415. app/shared/services/validation/dag/builder.py +141 -0
  416. app/shared/services/validation/dag/executor.py +225 -0
  417. app/shared/services/validation/dag/sorter.py +77 -0
  418. app/shared/services/validation/data_loader.py +231 -0
  419. app/shared/services/validation/engine.py +446 -0
  420. app/shared/services/validation/executor.py +1030 -0
  421. app/shared/services/validation/extractors.py +266 -0
  422. app/shared/services/validation/history.py +209 -0
  423. app/shared/services/validation/json_payload.py +136 -0
  424. app/shared/services/validation/loader.py +152 -0
  425. app/shared/services/validation/memory_monitor.py +179 -0
  426. app/shared/services/validation/postprocess.py +208 -0
  427. app/shared/services/validation/progress.py +64 -0
  428. app/shared/services/validation/report_export.py +222 -0
  429. app/shared/services/validation/resolver.py +180 -0
  430. app/shared/services/validation/service.py +418 -0
  431. app/shared/services/validation/types.py +238 -0
  432. app/shared/services/validation/validators/__init__.py +36 -0
  433. app/shared/services/validation/validators/adapter.py +182 -0
  434. app/shared/services/validation/validators/base.py +332 -0
  435. app/shared/services/validation/validators/composite.py +233 -0
  436. app/shared/services/validation/validators/date_logic.py +276 -0
  437. app/start_server.py +133 -0
  438. precis_cli-0.1.3.dist-info/METADATA +180 -0
  439. precis_cli-0.1.3.dist-info/RECORD +442 -0
  440. precis_cli-0.1.3.dist-info/WHEEL +5 -0
  441. precis_cli-0.1.3.dist-info/entry_points.txt +4 -0
  442. precis_cli-0.1.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1030 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ #
3
+ # Copyright 2026 Precis Team
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ # backend/app/shared/services/validation/executor.py
17
+ """
18
+ @fileoverview 全量校验执行器
19
+
20
+ 功能概述:
21
+ - 提供 CLI 和前端共享的核心校验编排逻辑
22
+ - 编排加载→格式解析→约束校验→结果后处理全流程
23
+ - 支持超时控制、设置覆盖、表过滤等执行选项
24
+ - 提供 ValidationOptions 配置类和 create_executor 工厂函数
25
+
26
+ 架构设计:
27
+ - 组合模式: ValidationExecutor 组合使用 DataSourceResolver + DataLoader
28
+ - 职责分离: 自身仅负责流程编排,路径解析和数据加载委托给子模块
29
+ - 调用方: CLI 的 DataValidator 和前端 API 的 validate_v2_full
30
+
31
+ 输入示例:
32
+ executor = create_executor("project.precis.yaml")
33
+ options = ValidationOptions(timeout_seconds=30, strict_mode=False)
34
+ result = executor.execute(data_directory="./data", options=options)
35
+
36
+ 输出示例:
37
+ result = {
38
+ "success": True,
39
+ "errors": [...],
40
+ "parsed_datasets": {...}
41
+ }
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import logging
47
+ import os
48
+ import time
49
+ from collections.abc import Callable
50
+ from typing import Any
51
+
52
+ import pandas as pd
53
+
54
+ from app.shared.core.project.loader import LoadedProject, load_project
55
+ from app.shared.core.project.schema.types import TableSchemaFile
56
+ from app.shared.domain.dataset_schema import DataSetSchema
57
+
58
+ from .chunked_loader import ChunkedDataLoader
59
+ from .data_loader import DataLoader
60
+ from .engine import execute_dag_if_needed, validate_constraints, validate_full_dataset
61
+ from .memory_monitor import DEFAULT_CHUNK_THRESHOLD_MB, MemoryMonitor
62
+ from .postprocess import (
63
+ attach_source_info,
64
+ build_id_to_name_map,
65
+ build_table_source_map,
66
+ map_table_id,
67
+ postprocess_result,
68
+ )
69
+ from .progress import ProgressEvent
70
+ from .resolver import DataSourceResolver
71
+
72
+ logger = logging.getLogger(__name__)
73
+
74
+ # 进度回调类型:接收 ProgressEvent,无返回值
75
+ ProgressCallback = Callable[[ProgressEvent], None]
76
+
77
+
78
+ class ValidationOptions:
79
+ """
80
+ @classdesc 校验执行选项
81
+
82
+ 用于配置校验执行的可选参数,支持前端覆盖配置。
83
+ 包含超时控制、错误处理策略、严格模式、表过滤等设置。
84
+
85
+ 属性:
86
+ timeout_seconds: 超时时间(秒),默认 30 秒
87
+ error_handling: 错误处理策略,"continue" 表示遇到错误继续执行
88
+ strict_mode: 严格模式,True 时任何错误都视为校验失败
89
+ allow_unsafe_eval: 是否允许不安全脚本执行,None 表示使用项目配置
90
+ table_filter: 表过滤列表,只校验指定表相关的约束
91
+ """
92
+
93
+ def __init__(
94
+ self,
95
+ timeout_seconds: int = 30,
96
+ error_handling: str = "continue",
97
+ strict_mode: bool = False,
98
+ allow_unsafe_eval: bool | None = None,
99
+ table_filter: str | list[str] | None = None,
100
+ chunk_threshold_mb: float = DEFAULT_CHUNK_THRESHOLD_MB,
101
+ chunk_rows: int = 100_000,
102
+ ):
103
+ self.timeout_seconds = timeout_seconds
104
+ self.error_handling = error_handling
105
+ self.strict_mode = strict_mode
106
+ self.allow_unsafe_eval = allow_unsafe_eval
107
+ self.table_filter = table_filter
108
+ self.chunk_threshold_mb = chunk_threshold_mb
109
+ self.chunk_rows = chunk_rows
110
+
111
+
112
+ class ValidationExecutor:
113
+ """
114
+ @classdesc 全量校验执行器
115
+
116
+ 负责:
117
+ - 加载项目配置和 Schema 定义
118
+ - 委托 DataSourceResolver 解析数据源路径
119
+ - 委托 DataLoader 批量加载数据文件
120
+ - 编排格式解析和约束校验
121
+ - 结果后处理(ID→Name 映射)
122
+ - 超时控制
123
+ """
124
+
125
+ def __init__(
126
+ self,
127
+ manifest_path: str,
128
+ settings_override: Any = None,
129
+ allow_unsafe_eval: bool | None = None,
130
+ ):
131
+ # 校验清单文件存在性
132
+ # 【防御性编程】在初始化阶段即检查文件是否存在,避免后续操作失败
133
+ if not os.path.exists(manifest_path):
134
+ raise FileNotFoundError(f"项目配置文件未找到: {manifest_path}")
135
+
136
+ # 解析项目根目录并加载项目配置
137
+ # 【数据流】manifest_path → project_root → load_project → LoadedProject
138
+ # 通过依赖注入把 build_dataset_schema 传入 core 的 load_project,
139
+ # 避免 core 层反向依赖 services(见 loader_parts/main.py 的 schema_builder 参数)。
140
+ self.project_root = os.path.dirname(manifest_path)
141
+ from app.shared.services.project_loader import build_dataset_schema
142
+
143
+ self.loaded_project: LoadedProject = load_project(manifest_path, schema_builder=build_dataset_schema)
144
+ self.dataset_schema: DataSetSchema = self.loaded_project.dataset_schema
145
+ self.settings = self.loaded_project.manifest.settings
146
+ self.manifest = self.loaded_project.manifest
147
+
148
+ # P0-3: 为每个运行时约束附加其来源约束文件路径(相对 manifest 目录)。
149
+ # 独立约束文件为其 YAML 路径,schema 内嵌约束为 schema 文件路径;
150
+ # 模板展开产物与无来源约束保持无该属性(校验错误中 constraint_file 为 null)。
151
+ # getattr 防御:load_project 被 mock 的测试边界可能不带该新字段
152
+ _source_map = getattr(self.loaded_project, "constraint_source_files", None) or {}
153
+ for _constraint in getattr(self.dataset_schema, "constraints", None) or []:
154
+ _cid = getattr(_constraint, "constraint_id", None)
155
+ if _cid is not None and _cid in _source_map:
156
+ _constraint.constraint_file = _source_map[_cid] # type: ignore[attr-defined]
157
+
158
+ # 执行器级别的脚本执行权限覆盖,优先级高于项目配置
159
+ self.allow_unsafe_eval = allow_unsafe_eval
160
+
161
+ # 应用前端或 CLI 传入的设置覆盖值
162
+ # 【配置优先级】settings_override > 项目配置 > 默认值
163
+ if settings_override:
164
+ self._apply_settings_override(settings_override)
165
+
166
+ # 构建表 ID 到 Schema 文件的映射,便于快速查找
167
+ self._schema_by_id: dict[str, TableSchemaFile] = dict(self.loaded_project.schema_files)
168
+
169
+ # 初始化数据源解析器,负责将相对路径解析为绝对路径
170
+ self._resolver = DataSourceResolver(
171
+ project_root=self.project_root,
172
+ manifest=self.manifest,
173
+ schema_by_id=self._schema_by_id,
174
+ )
175
+ # 初始化数据加载器,负责批量加载数据文件
176
+ self._data_loader = DataLoader(
177
+ resolver=self._resolver,
178
+ dataset_schema=self.dataset_schema,
179
+ schema_by_id=self._schema_by_id,
180
+ settings=self.settings,
181
+ )
182
+ # 初始化内存监控器
183
+ self._memory_monitor = MemoryMonitor()
184
+ # 初始化分块数据加载器(惰性创建,仅在需要时使用)
185
+ self._chunked_loader: ChunkedDataLoader | None = None
186
+
187
+ def _get_chunked_loader(self, options: ValidationOptions) -> ChunkedDataLoader:
188
+ """获取或创建分块数据加载器。"""
189
+ if self._chunked_loader is None:
190
+ self._memory_monitor.chunk_threshold_mb = options.chunk_threshold_mb
191
+ self._memory_monitor.chunk_rows = options.chunk_rows
192
+ self._chunked_loader = ChunkedDataLoader(
193
+ resolver=self._resolver,
194
+ dataset_schema=self.dataset_schema,
195
+ schema_by_id=self._schema_by_id,
196
+ settings=self.settings,
197
+ memory_monitor=self._memory_monitor,
198
+ )
199
+ return self._chunked_loader
200
+
201
+ def _apply_settings_override(self, override: Any) -> None:
202
+ """
203
+ @methoddesc 应用设置覆盖
204
+
205
+ 将前端或 CLI 传入的设置覆盖值应用到项目配置中。
206
+ 支持 Pydantic 模型和字典两种格式的覆盖值。
207
+
208
+ 参数:
209
+ override: 设置覆盖值,可以是 Pydantic 模型或字典
210
+
211
+ 异常:
212
+ TypeError: override 类型不支持(非 dict 非 Pydantic 模型)
213
+ ValueError: 覆盖值包含未知字段
214
+ """
215
+ if override is None:
216
+ return
217
+
218
+ # 统一转换为字典格式
219
+ if hasattr(override, "model_dump"):
220
+ override_dict = override.model_dump(exclude_none=True)
221
+ elif isinstance(override, dict):
222
+ override_dict = override
223
+ else:
224
+ raise TypeError("settings_override 必须为 dict 或 Pydantic 模型")
225
+
226
+ # 校验根级别字段是否合法
227
+ allowed_root = {"validation", "file_processing", "script_security"}
228
+ unknown_root = set(override_dict.keys()) - allowed_root
229
+ if unknown_root:
230
+ raise ValueError(f"settings_override 包含未知字段: {sorted(unknown_root)}")
231
+
232
+ # 逐组应用覆盖值
233
+ for group_name in ("validation", "file_processing", "script_security"):
234
+ group_override = override_dict.get(group_name)
235
+ if not group_override:
236
+ continue
237
+ if not isinstance(group_override, dict):
238
+ raise ValueError(f"settings_override.{group_name} 必须为对象")
239
+ target = getattr(self.settings, group_name, None)
240
+ if target is None:
241
+ continue
242
+ for key, value in group_override.items():
243
+ if not hasattr(target, key):
244
+ raise ValueError(f"settings_override.{group_name} 包含未知字段: {key}")
245
+ setattr(target, key, value)
246
+
247
+ def _resolve_allow_unsafe_eval(self, options: ValidationOptions) -> bool:
248
+ """
249
+ @methoddesc 解析本次校验是否允许不安全脚本执行
250
+
251
+ 优先级(高到低):
252
+ 1. 请求级选项 options.allow_unsafe_eval
253
+ 2. 执行器实例级 self.allow_unsafe_eval
254
+ 3. 项目配置:settings.script_security.allow_eval 且非沙箱模式
255
+
256
+ 参数:
257
+ options: 当前校验执行选项
258
+
259
+ 返回:
260
+ True 表示允许执行使用 eval 的脚本化约束
261
+ """
262
+ if options.allow_unsafe_eval is not None:
263
+ return options.allow_unsafe_eval
264
+ if self.allow_unsafe_eval is not None:
265
+ return self.allow_unsafe_eval
266
+ script_security = getattr(self.settings, "script_security", None)
267
+ if script_security is None:
268
+ return False
269
+ allow_eval = getattr(script_security, "allow_eval", False)
270
+ sandbox_mode = getattr(script_security, "sandbox_mode", True)
271
+ return bool(allow_eval and not sandbox_mode)
272
+
273
+ def _build_table_source_map(self) -> dict[str, dict[str, str | None]]:
274
+ """构建表 ID 到数据源信息的映射(委托 postprocess.build_table_source_map)。"""
275
+ return build_table_source_map(self._schema_by_id)
276
+
277
+ @staticmethod
278
+ def _attach_source_info(item: dict[str, Any], table_source_map: dict[str, dict[str, str | None]]) -> None:
279
+ """将数据源信息附加到条目字典(委托 postprocess.attach_source_info)。"""
280
+ attach_source_info(item, table_source_map)
281
+
282
+ @staticmethod
283
+ def _build_id_to_name_map(dataset_schema: DataSetSchema) -> dict[str, str]:
284
+ """构建表 ID 到显示名称的映射(委托 postprocess.build_id_to_name_map)。"""
285
+ return build_id_to_name_map(dataset_schema)
286
+
287
+ def _postprocess_result(self, result: dict[str, Any]) -> None:
288
+ """结果统一后处理(委托 postprocess.postprocess_result)。"""
289
+ postprocess_result(result, self.dataset_schema, self._schema_by_id)
290
+
291
+ def _finalize_result(self, result: dict[str, Any], started: float) -> None:
292
+ result["duration_ms"] = int((time.monotonic() - started) * 1000)
293
+ result["memory_info"] = self._memory_monitor.get_progress_info()
294
+
295
+ @staticmethod
296
+ def _emit_progress(
297
+ callback: ProgressCallback | None,
298
+ started: float,
299
+ stage: str,
300
+ table: str | None = None,
301
+ chunk_index: int = 0,
302
+ chunk_total: int = 0,
303
+ rows_done: int = 0,
304
+ rows_total: int = 0,
305
+ errors_so_far: int = 0,
306
+ ) -> None:
307
+ """安全地触发进度回调。
308
+
309
+ callback 为 None 时直接返回(零开销);callback 抛异常时仅记日志、不抛出,
310
+ 确保进度通道的故障不影响校验主流程。所有字段口径与 ProgressEvent 文档一致。
311
+
312
+ 参数:
313
+ callback: 调用方传入的进度回调,可为 None。
314
+ started: 校验开始时间(time.monotonic() 返回值),用于计算 elapsed_ms。
315
+ stage/table/chunk_index/...: 见 ProgressEvent 字段说明。
316
+ """
317
+ if callback is None:
318
+ return
319
+ try:
320
+ callback(
321
+ ProgressEvent(
322
+ stage=stage,
323
+ table=table,
324
+ chunk_index=chunk_index,
325
+ chunk_total=chunk_total,
326
+ rows_done=rows_done,
327
+ rows_total=rows_total,
328
+ errors_so_far=errors_so_far,
329
+ elapsed_ms=int((time.monotonic() - started) * 1000),
330
+ )
331
+ )
332
+ except Exception: # noqa: BLE001 — 进度通道异常不得影响校验
333
+ logger.exception("progress_callback 抛出异常,已忽略")
334
+
335
+ @staticmethod
336
+ def _map_table_id(item: dict[str, Any], id_to_name: dict[str, str]) -> None:
337
+ """将条目中的表 ID 替换为表显示名称(委托 postprocess.map_table_id)。"""
338
+ map_table_id(item, id_to_name)
339
+
340
+ def execute(
341
+ self,
342
+ data_directory: str,
343
+ options: ValidationOptions | None = None,
344
+ progress_callback: ProgressCallback | None = None,
345
+ ) -> dict[str, Any]:
346
+ """
347
+ @methoddesc 执行完整校验流程
348
+
349
+ 编排数据加载、格式解析、约束校验和结果后处理的完整流程。
350
+ 采用分阶段执行策略,每个阶段后检查超时条件。
351
+ 当检测到大文件时自动切换到分块处理模式。
352
+
353
+ 执行流程:
354
+ Step 1: 检测文件大小,决定使用标准模式还是分块模式
355
+ Step 2: 加载数据源(标准 DataLoader 或 ChunkedDataLoader)
356
+ Step 3: 检查加载阶段超时
357
+ Step 4: 检查数据加载成功
358
+ Step 5: 确定脚本安全执行策略
359
+ Step 6: 执行格式解析和约束校验
360
+ Step 7: 结果后处理(ID→Name 映射 + 数据源信息附加)
361
+ Step 8: 检查校验阶段超时
362
+
363
+ 参数:
364
+ data_directory: 数据文件所在目录
365
+ options: 校验执行选项,包含超时、过滤等配置
366
+ progress_callback: 可选的进度回调,校验过程中在阶段切换/分块边界
367
+ 触发。callback 异常不影响校验(仅记日志)。不传时行为完全不变。
368
+
369
+ 返回:
370
+ 包含以下字段的字典:
371
+ - raw_datasets: 原始数据集
372
+ - parsed_datasets: 解析后的数据集
373
+ - errors: 校验错误列表
374
+ - loading_errors: 数据加载错误列表
375
+ - duration_ms: 执行耗时(毫秒)
376
+ - timeout_occurred: 是否发生超时
377
+ - validation_details: 校验详情
378
+ - chunked_mode: 是否使用了分块模式
379
+ - memory_info: 内存监控信息
380
+ """
381
+ if options is None:
382
+ options = ValidationOptions()
383
+
384
+ # 更新内存监控器配置
385
+ self._memory_monitor.chunk_threshold_mb = options.chunk_threshold_mb
386
+ self._memory_monitor.chunk_rows = options.chunk_rows
387
+
388
+ # 记录开始时间,用于计算总耗时和超时检查
389
+ started = time.monotonic()
390
+ result: dict[str, Any] = {
391
+ "raw_datasets": {},
392
+ "parsed_datasets": {},
393
+ "errors": [],
394
+ "loading_errors": [],
395
+ "duration_ms": 0,
396
+ "timeout_occurred": False,
397
+ "interrupted": False, # C6 遇错即停:是否因 error_handling=stop 提前终止
398
+ "validation_details": {"format_checks": [], "constraint_checks": []},
399
+ "chunked_mode": False,
400
+ "memory_info": {},
401
+ }
402
+
403
+ # Step 1: 检测是否需要分块模式
404
+ use_chunked = self._should_use_chunked_mode(data_directory, options)
405
+ if use_chunked:
406
+ result["chunked_mode"] = True
407
+ logger.info("检测到大文件,启用分块处理模式")
408
+ return self._execute_chunked(data_directory, options, started, result, progress_callback)
409
+
410
+ # Step 2-8: 标准模式流水线(加载→校验→后处理→超时检查)
411
+ return self._execute_standard(data_directory, options, started, result, progress_callback)
412
+
413
+ def _execute_standard(
414
+ self,
415
+ data_directory: str,
416
+ options: ValidationOptions,
417
+ started: float,
418
+ result: dict[str, Any],
419
+ progress_callback: ProgressCallback | None = None,
420
+ ) -> dict[str, Any]:
421
+ """
422
+ @methoddesc 执行标准(非分块)模式校验流水线
423
+
424
+ 承接 execute 的 Step 2-8:数据加载、加载阶段超时与空数据检查、
425
+ 格式解析与约束校验、结果后处理、校验阶段超时检查。
426
+
427
+ 参数:
428
+ data_directory: 数据文件所在目录
429
+ options: 校验执行选项
430
+ started: 开始时间(time.monotonic())
431
+ result: 已初始化的结果字典(chunked_mode=False)
432
+ progress_callback: 可选进度回调
433
+
434
+ 返回:
435
+ 完整的校验结果字典
436
+ """
437
+ # Step 2: 加载数据源(标准模式)
438
+ raw_datasets, loading_errors = self._data_loader.load_data_sources(
439
+ data_directory, table_filter=options.table_filter
440
+ )
441
+ result["raw_datasets"] = raw_datasets
442
+ result["loading_errors"] = loading_errors
443
+
444
+ # 追加项目加载阶段的错误
445
+ if self.loaded_project.loading_errors:
446
+ for err in self.loaded_project.loading_errors:
447
+ result["loading_errors"].append(err.to_dict())
448
+
449
+ result["warnings"] = self.loaded_project.warnings
450
+
451
+ # 进度:数据加载完成(标准模式一锤子加载,rows_total 为各表行数之和)
452
+ self._emit_progress(
453
+ progress_callback,
454
+ started,
455
+ stage="loading",
456
+ table=None,
457
+ rows_done=0,
458
+ rows_total=sum(len(df) for df in raw_datasets.values()),
459
+ errors_so_far=len(result["loading_errors"]),
460
+ )
461
+
462
+ # Step 3: 检查加载阶段是否超时
463
+ if (time.monotonic() - started) > options.timeout_seconds:
464
+ result["errors"].append(
465
+ {"error_type": "Timeout", "message": f"数据加载阶段超时(>{options.timeout_seconds}s)"}
466
+ )
467
+ result["timeout_occurred"] = True
468
+ self._emit_progress(
469
+ progress_callback,
470
+ started,
471
+ stage="done",
472
+ errors_so_far=len(result["errors"]),
473
+ )
474
+ self._finalize_result(result, started)
475
+ return result
476
+
477
+ # Step 4: 检查数据是否加载成功
478
+ if not raw_datasets:
479
+ result["errors"].append(
480
+ {"error_type": "DataLoadingError", "message": "未能从数据目录加载任何数据表,校验中止。"}
481
+ )
482
+ self._emit_progress(
483
+ progress_callback,
484
+ started,
485
+ stage="done",
486
+ errors_so_far=len(result["errors"]),
487
+ )
488
+ self._finalize_result(result, started)
489
+ return result
490
+
491
+ # 标准模式总行数(用于进度事件)
492
+ std_rows_total = sum(len(df) for df in raw_datasets.values())
493
+
494
+ # Step 5: 确定脚本安全执行策略
495
+ allow_unsafe_eval = self._resolve_allow_unsafe_eval(options)
496
+
497
+ logger.debug(f"Starting validate_full_dataset with {len(raw_datasets)} datasets")
498
+
499
+ # 计算校验阶段的超时截止时间
500
+ deadline = started + options.timeout_seconds
501
+
502
+ # 进度:进入校验阶段(标准模式 validate_full_dataset 为一锤子调用,粗粒度)
503
+ self._emit_progress(
504
+ progress_callback,
505
+ started,
506
+ stage="validating",
507
+ table=None,
508
+ rows_done=0,
509
+ rows_total=std_rows_total,
510
+ errors_so_far=len(result["errors"]),
511
+ )
512
+
513
+ # Step 6: 执行格式解析和约束校验
514
+ # C6 遇错即停:error_handling="stop" 时,发现首个错误即停止剩余校验
515
+ stop_on_first_error = options.error_handling == "stop"
516
+ try:
517
+ parsed_datasets, validation_errors, validation_details = validate_full_dataset(
518
+ raw_datasets,
519
+ self.dataset_schema,
520
+ allow_unsafe_eval=allow_unsafe_eval,
521
+ table_filter=options.table_filter,
522
+ transform_files=getattr(self.loaded_project, "transform_files", None) if self.loaded_project else None,
523
+ regex_files=getattr(self.loaded_project, "regex_node_files", None) if self.loaded_project else None,
524
+ deadline=deadline,
525
+ stop_on_first_error=stop_on_first_error,
526
+ )
527
+ except Exception as e:
528
+ logger.exception(f"Error during validate_full_dataset: {e}")
529
+ raise
530
+ result["parsed_datasets"] = parsed_datasets
531
+ result["errors"].extend(validation_errors)
532
+ result["validation_details"] = validation_details
533
+ # C6: 若校验因 stop 提前终止(返回了 ValidationInterrupted 标记),记录中断状态
534
+ if stop_on_first_error and any(e.get("error_type") == "ValidationInterrupted" for e in validation_errors):
535
+ result["interrupted"] = True
536
+
537
+ # 进度:校验执行完成(更新累计错误数)
538
+ self._emit_progress(
539
+ progress_callback,
540
+ started,
541
+ stage="validating",
542
+ table=None,
543
+ rows_done=std_rows_total,
544
+ rows_total=std_rows_total,
545
+ errors_so_far=len(result["errors"]),
546
+ )
547
+
548
+ # Step 7: 结果后处理
549
+ self._postprocess_result(result)
550
+
551
+ # Step 8: 检查校验阶段是否超时
552
+ if (time.monotonic() - started) > options.timeout_seconds:
553
+ result["errors"].append(
554
+ {"error_type": "Timeout", "message": f"数据校验阶段超时(>{options.timeout_seconds}s)"}
555
+ )
556
+ result["timeout_occurred"] = True
557
+
558
+ # 进度:全部完成
559
+ self._emit_progress(
560
+ progress_callback,
561
+ started,
562
+ stage="done",
563
+ table=None,
564
+ rows_done=std_rows_total,
565
+ rows_total=std_rows_total,
566
+ errors_so_far=len(result["errors"]),
567
+ )
568
+
569
+ self._finalize_result(result, started)
570
+ return result
571
+
572
+ def _should_use_chunked_mode(self, data_directory: str, options: ValidationOptions) -> bool:
573
+ """
574
+ @methoddesc 检测数据目录中是否有需要分块处理的大文件
575
+
576
+ 参数:
577
+ data_directory: 数据文件目录
578
+ options: 校验选项
579
+
580
+ 返回:
581
+ True 表示存在需要分块处理的大文件
582
+ """
583
+ search_directory = data_directory
584
+ first_data_source = self._resolver.resolve_first_data_source()
585
+ if first_data_source:
586
+ search_directory = first_data_source
587
+
588
+ if not os.path.isdir(search_directory):
589
+ return False
590
+
591
+ for table_id, schema_file in self._schema_by_id.items():
592
+ source_path, _ = self._resolver.resolve_source_path(data_directory, schema_file)
593
+ if source_path and self._memory_monitor.should_chunk(source_path):
594
+ return True
595
+
596
+ return False
597
+
598
+ def _execute_chunked(
599
+ self,
600
+ data_directory: str,
601
+ options: ValidationOptions,
602
+ started: float,
603
+ result: dict[str, Any],
604
+ progress_callback: ProgressCallback | None = None,
605
+ ) -> dict[str, Any]:
606
+ """
607
+ @methoddesc 执行分块校验流程
608
+
609
+ 使用 ChunkedDataLoader 分块加载数据,逐块执行格式解析和约束校验,
610
+ 最终合并所有分块的结果。分块循环内按块边界触发进度回调(sparkline 主数据源)。
611
+
612
+ 参数:
613
+ data_directory: 数据文件目录
614
+ options: 校验选项
615
+ started: 开始时间
616
+ result: 结果字典(已初始化)
617
+ progress_callback: 可选进度回调;不传时行为完全不变。
618
+
619
+ 返回:
620
+ 完整的校验结果字典
621
+ """
622
+ chunked_loader = self._get_chunked_loader(options)
623
+
624
+ # 分块加载数据源
625
+ try:
626
+ chunked_datasets, chunked_loading_errors = chunked_loader.load_chunked_sources(
627
+ data_directory, table_filter=options.table_filter
628
+ )
629
+ except Exception as e:
630
+ logger.exception(f"分块加载失败: {e}")
631
+ result["errors"].append({"error_type": "ChunkedLoadError", "message": f"分块加载失败: {e}"})
632
+ self._emit_progress(progress_callback, started, stage="done", errors_so_far=len(result["errors"]))
633
+ self._finalize_result(result, started)
634
+ return result
635
+
636
+ # 追加项目加载阶段的错误
637
+ if self.loaded_project.loading_errors:
638
+ for proj_err in self.loaded_project.loading_errors:
639
+ result["loading_errors"].append(proj_err.to_dict())
640
+ # 回归 #5: 追加分块加载阶段单表级别的错误(源找不到/加载异常),否则损坏表静默消失、
641
+ # 报告显示"全部通过"。这与标准模式 data_loader 返回 loading_errors 的语义对齐。
642
+ for chunk_err in chunked_loading_errors:
643
+ result["loading_errors"].append(chunk_err)
644
+ result["warnings"] = self.loaded_project.warnings
645
+
646
+ if not chunked_datasets:
647
+ result["errors"].append(
648
+ {"error_type": "DataLoadingError", "message": "未能从数据目录加载任何数据表,校验中止。"}
649
+ )
650
+ self._emit_progress(progress_callback, started, stage="done", errors_so_far=len(result["errors"]))
651
+ self._finalize_result(result, started)
652
+ return result
653
+
654
+ # 统计总行数和分块数
655
+ total_rows = 0
656
+ total_chunks = 0
657
+ for table_id, chunks in chunked_datasets.items():
658
+ for chunk in chunks:
659
+ total_rows += len(chunk)
660
+ total_chunks += len(chunks)
661
+
662
+ logger.info(f"分块加载完成: {len(chunked_datasets)} 个表, {total_chunks} 个分块, {total_rows} 总行数")
663
+
664
+ # 记录原始数据集信息(用于响应)
665
+ for table_id, chunks in chunked_datasets.items():
666
+ result["raw_datasets"][table_id] = {"chunk_count": len(chunks), "row_count": sum(len(c) for c in chunks)}
667
+
668
+ # 进度:分块加载完成(loading 阶段汇总)
669
+ self._emit_progress(
670
+ progress_callback,
671
+ started,
672
+ stage="loading",
673
+ table=None,
674
+ chunk_total=total_chunks,
675
+ rows_total=total_rows,
676
+ errors_so_far=len(result["loading_errors"]),
677
+ )
678
+
679
+ # 检查加载阶段超时
680
+ if (time.monotonic() - started) > options.timeout_seconds:
681
+ result["errors"].append(
682
+ {"error_type": "Timeout", "message": f"数据加载阶段超时(>{options.timeout_seconds}s)"}
683
+ )
684
+ result["timeout_occurred"] = True
685
+ self._emit_progress(progress_callback, started, stage="done", errors_so_far=len(result["errors"]))
686
+ self._finalize_result(result, started)
687
+ return result
688
+
689
+ # 确定脚本安全策略
690
+ allow_unsafe_eval = self._resolve_allow_unsafe_eval(options)
691
+ deadline = started + options.timeout_seconds
692
+ # C6 遇错即停:error_handling="stop" 时,分块格式校验发现首个错误即停止后续分块
693
+ stop_on_first_error = options.error_handling == "stop"
694
+
695
+ # 逐块执行格式解析(跳过约束),聚合各块结果
696
+ all_parsed_datasets, all_errors, all_validation_details, chunk_interrupted = self._parse_chunks_loop(
697
+ chunked_datasets,
698
+ options=options,
699
+ started=started,
700
+ total_rows=total_rows,
701
+ total_chunks=total_chunks,
702
+ allow_unsafe_eval=allow_unsafe_eval,
703
+ deadline=deadline,
704
+ stop_on_first_error=stop_on_first_error,
705
+ progress_callback=progress_callback,
706
+ )
707
+
708
+ # 合并分块解析结果并对全量数据执行 Transform DAG 与约束校验
709
+ self._apply_chunked_global_validation(
710
+ all_parsed_datasets,
711
+ all_errors,
712
+ all_validation_details,
713
+ result=result,
714
+ options=options,
715
+ allow_unsafe_eval=allow_unsafe_eval,
716
+ deadline=deadline,
717
+ stop_on_first_error=stop_on_first_error,
718
+ chunk_interrupted=chunk_interrupted,
719
+ )
720
+
721
+ # 结果后处理
722
+ self._postprocess_result(result)
723
+
724
+ # 检查总超时
725
+ if (time.monotonic() - started) > options.timeout_seconds:
726
+ result["timeout_occurred"] = True
727
+
728
+ # 进度:分块模式全部完成
729
+ self._emit_progress(
730
+ progress_callback,
731
+ started,
732
+ stage="done",
733
+ table=None,
734
+ chunk_total=total_chunks,
735
+ rows_done=total_rows,
736
+ rows_total=total_rows,
737
+ errors_so_far=len(result["errors"]),
738
+ )
739
+
740
+ self._finalize_result(result, started)
741
+ return result
742
+
743
+ def _parse_chunks_loop(
744
+ self,
745
+ chunked_datasets: dict[str, list[pd.DataFrame]],
746
+ options: ValidationOptions,
747
+ started: float,
748
+ total_rows: int,
749
+ total_chunks: int,
750
+ allow_unsafe_eval: bool,
751
+ deadline: float | None,
752
+ stop_on_first_error: bool,
753
+ progress_callback: ProgressCallback | None = None,
754
+ ) -> tuple[dict[str, list[pd.DataFrame]], list[dict], dict[str, list[dict]], bool]:
755
+ """
756
+ @methoddesc 逐块执行格式解析(跳过约束校验),聚合各块结果
757
+
758
+ 【分块解析】每块只跑格式解析+派生列,跳过 Transform DAG 和约束校验;
759
+ 行变 transform 与跨表/跨块约束都需要全局数据,由
760
+ _apply_chunked_global_validation 在 concat 全量后统一执行。
761
+ 分块循环内按块边界触发进度回调(sparkline 主数据源)。
762
+
763
+ 参数:
764
+ chunked_datasets: 表 ID → 分块 DataFrame 列表
765
+ options: 校验选项
766
+ started: 开始时间(time.monotonic())
767
+ total_rows/total_chunks: 总行数与总分块数(进度事件用)
768
+ allow_unsafe_eval: 脚本执行权限
769
+ deadline: 校验阶段超时截止时间
770
+ stop_on_first_error: C6 遇错即停
771
+ progress_callback: 可选进度回调
772
+
773
+ 返回:
774
+ (按表聚合的解析结果, 聚合错误列表, 聚合校验详情, 是否因 stop 中断)
775
+ """
776
+ all_parsed_datasets: dict[str, list[pd.DataFrame]] = {}
777
+ all_errors: list[dict] = []
778
+ all_validation_details: dict[str, list[dict]] = {
779
+ "format_checks": [],
780
+ "constraint_checks": [],
781
+ }
782
+ # C6: 跟踪是否因 stop 在分块阶段中断(中断后跳过全局约束校验)
783
+ chunk_interrupted = False
784
+ # §1.31: 超时只上报一次并终止整个分块循环(超时后剩余表本就不会再跑,
785
+ # 每表追加一条 Timeout 是对同一事件的重复描述)
786
+ timeout_hit = False
787
+
788
+ # 累计已处理行数(跨表累加,用于进度事件)
789
+ rows_done_so_far = 0
790
+
791
+ for table_id, chunks in chunked_datasets.items():
792
+ if timeout_hit:
793
+ break
794
+ all_parsed_datasets[table_id] = []
795
+
796
+ for chunk_idx, chunk_df in enumerate(chunks):
797
+ # 进度:开始处理当前分块(1-based chunk_index,sparkline 主要数据源)
798
+ self._emit_progress(
799
+ progress_callback,
800
+ started,
801
+ stage="validating",
802
+ table=table_id,
803
+ chunk_index=chunk_idx + 1,
804
+ chunk_total=total_chunks,
805
+ rows_done=rows_done_so_far,
806
+ rows_total=total_rows,
807
+ errors_so_far=len(all_errors),
808
+ )
809
+
810
+ # 超时检查
811
+ if deadline is not None and time.monotonic() > deadline:
812
+ if not timeout_hit:
813
+ logger.warning(f"分块校验超时: 表 {table_id} 分块 {chunk_idx + 1}/{len(chunks)}")
814
+ all_errors.append(
815
+ {
816
+ "error_type": "Timeout",
817
+ "stage": "constraint",
818
+ "check_type": "Timeout",
819
+ "message": "分块校验超时,剩余分块未执行",
820
+ "table": table_id,
821
+ }
822
+ )
823
+ timeout_hit = True
824
+ # §1.31: 中断块行数计入进度——started 进度已发出,补计保持单调,
825
+ # 并补发一条中断点进度事件(前端进度条跳到中断点,不回跳)
826
+ rows_done_so_far += len(chunk_df)
827
+ self._emit_progress(
828
+ progress_callback,
829
+ started,
830
+ stage="validating",
831
+ table=table_id,
832
+ chunk_index=chunk_idx + 1,
833
+ chunk_total=total_chunks,
834
+ rows_done=rows_done_so_far,
835
+ rows_total=total_rows,
836
+ errors_so_far=len(all_errors),
837
+ )
838
+ break
839
+
840
+ try:
841
+ parsed_datasets: dict[str, pd.DataFrame]
842
+ validation_errors: list[dict]
843
+ validation_details: dict[str, list[dict]]
844
+ # 【分块解析】每块只跑格式解析+派生列,跳过 Transform DAG 和约束校验。
845
+ # 行变 transform(FilterRows/DropDuplicates)与跨表/跨块约束都需要全局数据,
846
+ # 在 concat 全量 parsed 后统一执行(execute_dag_if_needed + validate_constraints),
847
+ # 以避免分块模式下的结果错误(行变 transform 块内失效、FK 假阳性、Unique 假阴性)。
848
+ parsed_datasets, validation_errors, validation_details = validate_full_dataset(
849
+ {table_id: chunk_df},
850
+ self.dataset_schema,
851
+ allow_unsafe_eval=allow_unsafe_eval,
852
+ table_filter=options.table_filter,
853
+ # 分块解析阶段不传 transform/regex 文件,跳过 DAG
854
+ transform_files=None,
855
+ regex_files=None,
856
+ deadline=deadline,
857
+ skip_constraints=True,
858
+ )
859
+
860
+ # 收集解析后的数据
861
+ for tid, parsed_df in parsed_datasets.items():
862
+ all_parsed_datasets[tid].append(parsed_df)
863
+
864
+ # 聚合错误(添加分块信息)
865
+ for chunk_err in validation_errors:
866
+ chunk_err["chunk_index"] = chunk_idx
867
+ all_errors.append(chunk_err)
868
+
869
+ # 聚合校验详情
870
+ for check in validation_details.get("format_checks", []):
871
+ all_validation_details["format_checks"].append(check)
872
+ for check in validation_details.get("constraint_checks", []):
873
+ all_validation_details["constraint_checks"].append(check)
874
+
875
+ # C6 遇错即停:分块模式下,当前块格式校验发现错误即停止后续分块(块间中断,
876
+ # 避免半块数据)。块内已跑完,数据完整。
877
+ if stop_on_first_error and validation_errors:
878
+ all_errors.append(
879
+ {
880
+ "error_type": "ValidationInterrupted",
881
+ "stage": "format",
882
+ "check_type": "ValidationInterrupted",
883
+ "table": table_id,
884
+ "message": (
885
+ f"已按项目设置『遇错即停』停止:表 '{table_id}' 分块 {chunk_idx + 1} "
886
+ f"格式校验发现错误,剩余分块及约束校验未执行。"
887
+ ),
888
+ }
889
+ )
890
+ chunk_interrupted = True
891
+ # §1.31: 中断块行数计入进度(该块已完整处理,只是不再继续后续块),
892
+ # 并补发中断点进度事件
893
+ rows_done_so_far += len(chunk_df)
894
+ self._emit_progress(
895
+ progress_callback,
896
+ started,
897
+ stage="validating",
898
+ table=table_id,
899
+ chunk_index=chunk_idx + 1,
900
+ chunk_total=total_chunks,
901
+ rows_done=rows_done_so_far,
902
+ rows_total=total_rows,
903
+ errors_so_far=len(all_errors),
904
+ )
905
+ break
906
+
907
+ except Exception as e:
908
+ logger.exception(f"分块校验异常: 表 {table_id} 分块 {chunk_idx}: {e}")
909
+ all_errors.append(
910
+ {
911
+ "error_type": "ChunkValidationError",
912
+ "stage": "constraint",
913
+ "message": f"分块校验异常: {e}",
914
+ "table": table_id,
915
+ "chunk_index": chunk_idx,
916
+ }
917
+ )
918
+
919
+ # 累计当前分块行数(无论成功失败都已"处理"过)
920
+ rows_done_so_far += len(chunk_df)
921
+
922
+ # 进度:当前分块处理完成(更新 rows_done / errors_so_far)
923
+ self._emit_progress(
924
+ progress_callback,
925
+ started,
926
+ stage="validating",
927
+ table=table_id,
928
+ chunk_index=chunk_idx + 1,
929
+ chunk_total=total_chunks,
930
+ rows_done=rows_done_so_far,
931
+ rows_total=total_rows,
932
+ errors_so_far=len(all_errors),
933
+ )
934
+
935
+ return all_parsed_datasets, all_errors, all_validation_details, chunk_interrupted
936
+
937
+ def _apply_chunked_global_validation(
938
+ self,
939
+ all_parsed_datasets: dict[str, list[pd.DataFrame]],
940
+ all_errors: list[dict],
941
+ all_validation_details: dict[str, list[dict]],
942
+ result: dict[str, Any],
943
+ options: ValidationOptions,
944
+ allow_unsafe_eval: bool,
945
+ deadline: float | None,
946
+ stop_on_first_error: bool,
947
+ chunk_interrupted: bool,
948
+ ) -> None:
949
+ """
950
+ @methoddesc 合并分块解析结果并执行全量 DAG 与约束校验(就地写入 result)
951
+
952
+ 【设计意图】分块仅用于"加载与格式解析省内存";Transform DAG 与约束校验
953
+ 都需要全局数据,因此在所有块解析完成后 concat 为全量 parsed,再统一执行。
954
+ 行变 transform 在分块下逐块执行会导致结果与全量不一致,故 DAG 移到
955
+ concat 后统一执行;必须在约束校验之前,因约束可能依赖 transform 产生的
956
+ 派生列。全量约束校验修复跨表 ForeignKey(不再缺目标表)与跨块 Unique
957
+ (整列去重)的正确性。
958
+
959
+ 参数:
960
+ all_parsed_datasets/all_errors/all_validation_details: _parse_chunks_loop 的聚合输出
961
+ result: 校验结果字典(就地写入 parsed_datasets/errors/validation_details/interrupted)
962
+ options: 校验选项
963
+ allow_unsafe_eval: 脚本执行权限
964
+ deadline: 校验阶段超时截止时间
965
+ stop_on_first_error: C6 遇错即停
966
+ chunk_interrupted: 分块阶段是否已因 stop 中断(中断则跳过约束校验)
967
+ """
968
+ merged_parsed: dict[str, pd.DataFrame] = {}
969
+ for table_id, dfs in all_parsed_datasets.items():
970
+ if dfs:
971
+ merged_parsed[table_id] = pd.concat(dfs, ignore_index=True)
972
+
973
+ merged_parsed, dag_errors = execute_dag_if_needed(
974
+ merged_parsed,
975
+ transform_files=getattr(self.loaded_project, "transform_files", None) if self.loaded_project else None,
976
+ regex_files=getattr(self.loaded_project, "regex_node_files", None) if self.loaded_project else None,
977
+ )
978
+ result["parsed_datasets"] = merged_parsed
979
+ # 回归 #6: 分块路径的 DAG 失败同样需上报(与标准路径 engine 行为一致)。
980
+ # 必须并入 all_errors——下方 result["errors"] = all_errors 整体重绑会覆盖
981
+ # 直接 append 进 result["errors"] 的内容,DAG 错误须在重绑前进入 all_errors。
982
+ for dag_err in dag_errors:
983
+ all_errors.append({"stage": "loading", **dag_err})
984
+
985
+ # C6: 若分块阶段已因 stop 中断,跳过约束校验(已停在前面的格式错误)。
986
+ if merged_parsed and not chunk_interrupted:
987
+ constraint_errors, constraint_details = validate_constraints(
988
+ merged_parsed,
989
+ self.dataset_schema,
990
+ all_errors=[], # 约束阶段独立计错误,避免与分块格式错误混排
991
+ validation_details={
992
+ "format_checks": [], # 占位,下方合并时只取 constraint_checks
993
+ "constraint_checks": [],
994
+ },
995
+ allow_unsafe_eval=allow_unsafe_eval,
996
+ table_filter=options.table_filter,
997
+ deadline=deadline,
998
+ stop_on_first_error=stop_on_first_error,
999
+ )
1000
+ all_errors.extend(constraint_errors)
1001
+ # 用全量约束结果替换分块阶段(分块循环 skip 了约束,constraint_checks 原本为空)
1002
+ all_validation_details["constraint_checks"] = constraint_details.get("constraint_checks", [])
1003
+
1004
+ result["errors"] = all_errors
1005
+ result["validation_details"] = all_validation_details
1006
+ # C6: 分块阶段因 stop 中断,或约束阶段因 stop 中断,均标记 interrupted
1007
+ if chunk_interrupted or (
1008
+ stop_on_first_error and any(e.get("error_type") == "ValidationInterrupted" for e in all_errors)
1009
+ ):
1010
+ result["interrupted"] = True
1011
+
1012
+
1013
+ def create_executor(manifest_path: str, settings_override: dict[str, Any] | None = None) -> ValidationExecutor:
1014
+ """
1015
+ @methoddesc 创建校验执行器工厂函数
1016
+
1017
+ 便捷函数,用于快速创建 ValidationExecutor 实例。
1018
+
1019
+ 参数:
1020
+ manifest_path: 项目清单文件路径
1021
+ settings_override: 可选的设置覆盖值
1022
+
1023
+ 返回:
1024
+ ValidationExecutor 实例
1025
+
1026
+ 示例:
1027
+ >>> executor = create_executor("project.precis.yaml")
1028
+ >>> result = executor.execute("./data")
1029
+ """
1030
+ return ValidationExecutor(manifest_path, settings_override)