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,327 @@
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
+ """
17
+ @fileoverview V2 全量配置写入模块
18
+
19
+ 功能概述:
20
+ - 处理 V2 全量配置的写入逻辑
21
+ - 合并策略:保留现有 manifest 中未覆盖的引用
22
+ - 从目录扫描补充缺失的资源引用
23
+
24
+ 输入示例:
25
+ result = write_v2_full_config(payload, config_path, manifest_path)
26
+
27
+ 输出示例:
28
+ {"message": "V2 全量配置已保存。"}
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import logging
34
+ import os
35
+ from pathlib import Path
36
+
37
+ from fastapi import HTTPException
38
+
39
+ from app.shared.core.io.yaml import read_yaml, write_yaml_atomic
40
+ from app.shared.core.project.manifest.types import ManualDataRefV2, ProjectManifestV2
41
+
42
+ from .base import (
43
+ ConstraintRefV2,
44
+ FullConfigV2Request,
45
+ RegexNodeRefV2,
46
+ SchemaRefV2,
47
+ TransformRefV2,
48
+ _v2_manifest_path,
49
+ )
50
+ from .helpers import _resolve_project_path, project_lock
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+
55
+ def _merge_manifest_references(
56
+ payload: FullConfigV2Request,
57
+ existing_manifest: ProjectManifestV2 | None,
58
+ config_path: str,
59
+ ) -> ProjectManifestV2:
60
+ """合并 manifest 引用。
61
+
62
+ 合并策略:对于客户端**未显式设置**的资源字段,保留现有 manifest 中的同类引用;
63
+ 对于显式设置的字段(即使设为空列表 []),遵从客户端意图(允许清空)。
64
+ 通过 model_fields_set 区分"未提供"与"显式置空"。
65
+
66
+ Args:
67
+ payload: 全量配置请求
68
+ existing_manifest: 现有 manifest(可能为 None)
69
+ config_path: 项目配置根目录
70
+
71
+ Returns:
72
+ 合并后的最终 manifest
73
+ """
74
+ final_manifest = payload.manifest
75
+ # 客户端显式设置的字段集合(区分"未提供"与"显式置空 []")
76
+ set_fields = payload.manifest.model_fields_set
77
+
78
+ def _should_merge(field: str) -> bool:
79
+ """字段未被客户端显式设置,且现有 manifest 有值时才合并。"""
80
+ return field not in set_fields and existing_manifest is not None and bool(getattr(existing_manifest, field))
81
+
82
+ # mypy 无法从 _should_merge 的返回值推断 existing_manifest 非 None,
83
+ # 用 existing 局部变量(仅在 existing_manifest 非 None 时赋值)帮助类型收窄。
84
+ existing: ProjectManifestV2 | None = existing_manifest
85
+
86
+ if _should_merge("schemas") and existing is not None:
87
+ logger.info(f"[put_v2_full_config] 合并现有 schemas: {len(existing.schemas)} 个")
88
+ final_manifest = final_manifest.model_copy(update={"schemas": existing.schemas})
89
+
90
+ if _should_merge("constraints") and existing is not None:
91
+ final_manifest = final_manifest.model_copy(update={"constraints": existing.constraints})
92
+
93
+ if _should_merge("regex_nodes") and existing is not None:
94
+ final_manifest = final_manifest.model_copy(update={"regex_nodes": existing.regex_nodes})
95
+
96
+ if _should_merge("transforms") and existing is not None:
97
+ final_manifest = final_manifest.model_copy(update={"transforms": existing.transforms})
98
+
99
+ if _should_merge("manual_data") and existing is not None:
100
+ final_manifest = final_manifest.model_copy(update={"manual_data": existing.manual_data})
101
+
102
+ if _should_merge("data_sources") and existing is not None:
103
+ final_manifest = final_manifest.model_copy(update={"data_sources": existing.data_sources})
104
+
105
+ # B-fix(settings 回滚防御): settings 同样纳入"未显式设置则保留磁盘值"的合并策略。
106
+ # 此前 payload 的 settings 直接覆盖——前端 builder 硬编码默认值时,用户在设置面板
107
+ # 改过的 CSV 分隔符/超时等会被静默回滚。注意:当前前端总是显式传 settings,
108
+ # 此防御需配合前端 builder 不传或回传真实 settings 才完整生效(协议先正确)。
109
+ if _should_merge("settings") and existing is not None:
110
+ final_manifest = final_manifest.model_copy(update={"settings": existing.settings})
111
+
112
+ # 回归(2026-09-04 扫描): project.description 保全。前端全量保存总是显式构造
113
+ # project: {id, name}(project 键恒在 set_fields,整字段级合并不适用),但
114
+ # description 是磁盘上手写的可选元数据——payload 未显式提供该嵌套字段时从磁盘
115
+ # 透传,防止每次全量保存静默抹除(与 settings 防御同型,按嵌套字段粒度判 fields_set;
116
+ # 显式传 null/新值则遵从客户端意图)。
117
+ if existing is not None and existing.project.description:
118
+ payload_project = final_manifest.project
119
+ if "description" not in payload_project.model_fields_set:
120
+ merged_project = payload_project.model_copy(update={"description": existing.project.description})
121
+ final_manifest = final_manifest.model_copy(update={"project": merged_project})
122
+
123
+ # 回归(2026-09): templates/template_instances 并入统一 fields_set 防线。
124
+ # 此前因 _validate_unique_ids 就地重赋值污染 model_fields_set(列表字段恒被标记),
125
+ # fields_set 判定对列表字段全部失效,这两个字段只能退化为"空列表=未携带"启发式;
126
+ # 现校验器已改用 object.__setattr__ 不再污染 fields_set(纯净性守卫见
127
+ # test_full_config_reliability),本分支与其余列表字段走同一判定,启发式退役。
128
+ # 语义变化:payload 显式传 templates: [] 现在遵从"显式置空=清空意图"(旧启发式
129
+ # 会强制保留磁盘值);与 schemas/constraints 等字段的既定语义对齐。
130
+ if _should_merge("templates") and existing is not None:
131
+ final_manifest = final_manifest.model_copy(update={"templates": existing.templates})
132
+
133
+ if _should_merge("template_instances") and existing is not None:
134
+ final_manifest = final_manifest.model_copy(update={"template_instances": existing.template_instances})
135
+
136
+ # 目录扫描补充:仅对客户端"未显式设置"且当前为空的字段从磁盘发现文件,
137
+ # 显式置空 [] 的字段不会被目录扫描覆盖(尊重用户清空意图)
138
+ def _should_scan(field: str) -> bool:
139
+ return field not in set_fields and not bool(getattr(final_manifest, field))
140
+
141
+ if _should_scan("schemas"):
142
+ schemas_dir = os.path.join(config_path, "schemas")
143
+ if os.path.isdir(schemas_dir):
144
+ schema_refs = []
145
+ for filename in os.listdir(schemas_dir):
146
+ if filename.endswith(".schema.yaml"):
147
+ schema_id = filename[:-12]
148
+ schema_refs.append(SchemaRefV2(id=schema_id, path=f"schemas/{filename}"))
149
+ if schema_refs:
150
+ logger.info(f"[put_v2_full_config] 从 schemas/ 目录扫描到 {len(schema_refs)} 个 schema 文件")
151
+ final_manifest = final_manifest.model_copy(update={"schemas": schema_refs})
152
+
153
+ if _should_scan("constraints"):
154
+ constraints_dir = os.path.join(config_path, "constraints")
155
+ if os.path.isdir(constraints_dir):
156
+ constraint_refs = []
157
+ for filename in os.listdir(constraints_dir):
158
+ if filename.endswith(".constraint.yaml"):
159
+ constraint_id = filename[:-16]
160
+ constraint_refs.append(ConstraintRefV2(id=constraint_id, path=f"constraints/{filename}"))
161
+ if constraint_refs:
162
+ logger.info(
163
+ f"[put_v2_full_config] 从 constraints/ 目录扫描到 {len(constraint_refs)} 个 constraint 文件"
164
+ )
165
+ final_manifest = final_manifest.model_copy(update={"constraints": constraint_refs})
166
+
167
+ if _should_scan("regex_nodes"):
168
+ regex_dirs = [os.path.join(config_path, "regex"), os.path.join(config_path, "regex_nodes")]
169
+ seen_regex_ids = set()
170
+ regex_refs: list[RegexNodeRefV2] = []
171
+ for d in regex_dirs:
172
+ if not os.path.isdir(d):
173
+ continue
174
+ for filename in os.listdir(d):
175
+ if filename.endswith(".regex.yaml"):
176
+ regex_id = filename[: -len(".regex.yaml")]
177
+ if regex_id in seen_regex_ids:
178
+ continue
179
+ rel_dir = os.path.basename(d)
180
+ regex_refs.append(RegexNodeRefV2(id=regex_id, path=f"{rel_dir}/{filename}"))
181
+ seen_regex_ids.add(regex_id)
182
+ if regex_refs:
183
+ logger.info(f"[put_v2_full_config] 从 regex/ 目录扫描到 {len(regex_refs)} 个 regex 文件")
184
+ final_manifest = final_manifest.model_copy(update={"regex_nodes": regex_refs})
185
+
186
+ if _should_scan("transforms"):
187
+ transforms_dir = os.path.join(config_path, "transforms")
188
+ if os.path.isdir(transforms_dir):
189
+ transform_refs = []
190
+ for filename in os.listdir(transforms_dir):
191
+ if filename.endswith(".transform.yaml"):
192
+ transform_id = filename[:-15]
193
+ transform_refs.append(TransformRefV2(id=transform_id, path=f"transforms/{filename}"))
194
+ if transform_refs:
195
+ logger.info(f"[put_v2_full_config] 从 transforms/ 目录扫描到 {len(transform_refs)} 个 transform 文件")
196
+ final_manifest = final_manifest.model_copy(update={"transforms": transform_refs})
197
+
198
+ if _should_scan("manual_data"):
199
+ manual_data_dir = os.path.join(config_path, "manual_data")
200
+ if os.path.isdir(manual_data_dir):
201
+ manual_data_refs = []
202
+ for filename in os.listdir(manual_data_dir):
203
+ if filename.endswith(".manual_data.yaml"):
204
+ manual_data_id = filename[: -len(".manual_data.yaml")]
205
+ manual_data_refs.append(ManualDataRefV2(id=manual_data_id, path=f"manual_data/{filename}"))
206
+ if manual_data_refs:
207
+ logger.info(
208
+ f"[put_v2_full_config] 从 manual_data/ 目录扫描到 {len(manual_data_refs)} 个 manual_data 文件"
209
+ )
210
+ final_manifest = final_manifest.model_copy(update={"manual_data": manual_data_refs})
211
+
212
+ return final_manifest
213
+
214
+
215
+ def _write_resource_files(
216
+ payload: FullConfigV2Request,
217
+ config_path: str,
218
+ ) -> None:
219
+ """写入资源文件。
220
+
221
+ 将 payload 中的 schemas、constraints、regex_nodes 写入对应的文件。
222
+
223
+ Args:
224
+ payload: 全量配置请求
225
+ config_path: 项目配置根目录
226
+ """
227
+ for schema_ref in payload.manifest.schemas:
228
+ schema = payload.schemas.get(schema_ref.id)
229
+ if not schema:
230
+ continue
231
+ try:
232
+ abs_path = _resolve_project_path(config_path, schema_ref.path)
233
+ except ValueError as e:
234
+ logger.error(f"[put_v2_full_config] 非法 Schema 路径: {schema_ref.path}, 错误: {e}")
235
+ continue
236
+ write_yaml_atomic(Path(abs_path), schema.model_dump(exclude_none=True))
237
+
238
+ for constraint_ref in payload.manifest.constraints:
239
+ constraint = payload.constraints.get(constraint_ref.id)
240
+ if not constraint:
241
+ continue
242
+ try:
243
+ abs_path = _resolve_project_path(config_path, constraint_ref.path)
244
+ except ValueError as e:
245
+ logger.error(f"[put_v2_full_config] 非法 Constraint 路径: {constraint_ref.path}, 错误: {e}")
246
+ continue
247
+ write_yaml_atomic(Path(abs_path), constraint.model_dump(exclude_none=True))
248
+
249
+ for regex_ref in payload.manifest.regex_nodes:
250
+ regex_node = payload.regex_nodes.get(regex_ref.id)
251
+ if not regex_node:
252
+ continue
253
+ try:
254
+ abs_path = _resolve_project_path(config_path, regex_ref.path)
255
+ except ValueError as e:
256
+ logger.error(f"[put_v2_full_config] 非法 Regex 路径: {regex_ref.path}, 错误: {e}")
257
+ continue
258
+ write_yaml_atomic(Path(abs_path), regex_node.model_dump(exclude_none=True))
259
+
260
+ for transform_ref in payload.manifest.transforms or []:
261
+ transform = payload.transforms.get(transform_ref.id)
262
+ if not transform:
263
+ continue
264
+ try:
265
+ abs_path = _resolve_project_path(config_path, transform_ref.path)
266
+ except ValueError as e:
267
+ logger.error(f"[put_v2_full_config] 非法 Transform 路径: {transform_ref.path}, 错误: {e}")
268
+ continue
269
+ write_yaml_atomic(Path(abs_path), transform.model_dump(exclude_none=True))
270
+
271
+ for manual_data_ref in payload.manifest.manual_data or []:
272
+ manual_data = payload.manual_data.get(manual_data_ref.id)
273
+ if not manual_data:
274
+ continue
275
+ try:
276
+ abs_path = _resolve_project_path(config_path, manual_data_ref.path)
277
+ except ValueError as e:
278
+ logger.error(f"[put_v2_full_config] 非法 ManualData 路径: {manual_data_ref.path}, 错误: {e}")
279
+ continue
280
+ write_yaml_atomic(Path(abs_path), manual_data.model_dump(exclude_none=True))
281
+
282
+
283
+ def write_v2_full_config(
284
+ payload: FullConfigV2Request,
285
+ config_path: str,
286
+ ) -> dict[str, str]:
287
+ """写入 V2 全量配置。
288
+
289
+ 会覆盖写入 manifest 与其引用的文件。
290
+
291
+ Args:
292
+ payload: 包含 manifest 和所有资源配置的请求
293
+ config_path: 项目配置根目录
294
+
295
+ Returns:
296
+ 操作结果消息字典
297
+ """
298
+ manifest_path = _v2_manifest_path(config_path)
299
+
300
+ # B-reliability: existing manifest 的读取必须在锁内(与写入同一临界区),否则:
301
+ # 1) 并发场景读到他人写入一半的状态; 2) 解析失败时 existing=None 继续写入,
302
+ # data_sources/templates/settings 等无目录扫描兜底的字段会被空基准覆盖丢失。
303
+ # manifest 已存在但损坏时直接拒绝写入(500),不带空基准继续。
304
+ with project_lock(config_path):
305
+ existing_manifest = None
306
+ if os.path.isfile(manifest_path):
307
+ try:
308
+ existing_manifest = ProjectManifestV2.model_validate(read_yaml(Path(manifest_path)))
309
+ logger.info("[put_v2_full_config] 读取现有 manifest 成功")
310
+ except Exception as e:
311
+ logger.error(f"[put_v2_full_config] 读取现有 manifest 失败,拒绝写入以保护现有配置: {e}")
312
+ raise HTTPException(
313
+ status_code=500,
314
+ detail="现有 project.precis.yaml 解析失败,已拒绝本次写入。请先修复或回退 manifest 再保存。",
315
+ )
316
+
317
+ # Step 1: 合并 manifest 引用
318
+ final_manifest = _merge_manifest_references(payload, existing_manifest, config_path)
319
+
320
+ # Step 2: 写入 manifest 与资源文件(在同一锁内保证一致性)
321
+ write_yaml_atomic(Path(manifest_path), final_manifest.model_dump(exclude_none=True))
322
+ logger.info(f"[put_v2_full_config] 写入 manifest 完成,schemas: {len(final_manifest.schemas)} 个")
323
+
324
+ # Step 3: 写入资源文件
325
+ _write_resource_files(payload, config_path)
326
+
327
+ return {"message": "V2 全量配置已保存。"}
@@ -0,0 +1,167 @@
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
+ """
17
+ @fileoverview 项目辅助函数模块
18
+
19
+ 功能概述:
20
+ - 安全加载 YAML 文件的辅助函数
21
+ - 计算 V2 项目清单、视图、工作区配置文件的绝对路径
22
+ - 提供安全的路径解析和项目级文件锁机制
23
+ - 原子写入由 shared.core.io.yaml.write_yaml_atomic 提供
24
+
25
+ 架构设计:
26
+ - 纯 Python 实现,不依赖 FastAPI,便于单元测试
27
+ - 使用 filelock 实现项目级并发写保护
28
+ - 原子写入使用临时文件+重命名策略
29
+
30
+ 输入示例:
31
+ path = _v2_manifest_path("/project/root")
32
+ config = _load_yaml_file(path)
33
+
34
+ 输出示例:
35
+ {"version": 2, "project": {"id": "my-project", "name": "My Project"}}
36
+ """
37
+
38
+ import os
39
+ from collections.abc import Iterator
40
+ from contextlib import contextmanager
41
+
42
+ import yaml
43
+ from filelock import FileLock
44
+
45
+ V2_MANIFEST_FILENAME = "project.precis.yaml"
46
+ V2_VIEW_FILENAME = "project.view.json"
47
+ V2_WORKSPACES_FILENAME = ".precis/workspaces.json"
48
+
49
+
50
+ def _load_yaml_file(path: str) -> dict:
51
+ """
52
+ 安全加载 YAML 文件的辅助函数。
53
+
54
+ 设计意图:
55
+ - 提供统一的错误处理机制,避免调用方处理各种解析异常
56
+ - 返回空字典而不是 None,简化调用方的空值判断
57
+
58
+ 副作用:
59
+ - 如果文件不存在或解析失败,仅记录日志而不抛出异常
60
+
61
+ 参数:
62
+ path: YAML 文件的绝对路径
63
+
64
+ 返回:
65
+ 解析后的字典,失败时返回空字典
66
+ """
67
+ if not os.path.isfile(path):
68
+ return {}
69
+ try:
70
+ with open(path, encoding="utf-8") as f:
71
+ return yaml.safe_load(f) or {}
72
+ except yaml.YAMLError:
73
+ return {}
74
+
75
+
76
+ def _v2_manifest_path(config_path: str) -> str:
77
+ """
78
+ 计算 V2 项目清单文件的绝对路径。
79
+
80
+ 参数:
81
+ config_path: 项目配置根目录
82
+
83
+ 返回:
84
+ manifest 文件的绝对路径
85
+ """
86
+ return os.path.join(config_path, V2_MANIFEST_FILENAME)
87
+
88
+
89
+ def _v2_view_path(config_path: str) -> str:
90
+ """
91
+ 计算 V2 项目视图文件的绝对路径。
92
+
93
+ 参数:
94
+ config_path: 项目配置根目录
95
+
96
+ 返回:
97
+ 视图文件的绝对路径
98
+ """
99
+ return os.path.join(config_path, V2_VIEW_FILENAME)
100
+
101
+
102
+ def _v2_workspaces_path(config_path: str) -> str:
103
+ """
104
+ 计算 V2 工作区配置文件的绝对路径。
105
+
106
+ 参数:
107
+ config_path: 项目配置根目录
108
+
109
+ 返回:
110
+ 工作区配置文件的绝对路径
111
+ """
112
+ return os.path.join(config_path, V2_WORKSPACES_FILENAME)
113
+
114
+
115
+ def _resolve_project_path(base_path: str, relative_path: str) -> str:
116
+ """安全解析项目内的相对路径,防止路径遍历攻击。
117
+
118
+ 验证规则:
119
+ - 解析后的绝对路径必须在 base_path 目录内
120
+ - 拒绝包含 .. 的相对路径组件
121
+ - B-arch2: 用 Path.resolve() 解析符号链接,防止 symlink 逃逸
122
+ (原 os.path.abspath 不解析 symlink,若项目目录含指向外部的 symlink
123
+ 子目录,startswith 检查会通过但实际落在项目外)。
124
+
125
+ 参数:
126
+ base_path: 项目根目录的绝对路径
127
+ relative_path: 用户提供的相对路径
128
+
129
+ 返回:
130
+ 安全的绝对路径(已 resolve,symlink 已展开)
131
+
132
+ 抛出:
133
+ ValueError: 当路径试图跳出项目目录时
134
+ """
135
+ from pathlib import Path
136
+
137
+ base_resolved = Path(base_path).resolve()
138
+ # 拼接后 resolve:同时处理 .. 与 symlink
139
+ target_resolved = Path(base_path, relative_path).resolve()
140
+ target_str = str(target_resolved)
141
+ base_str = str(base_resolved)
142
+ # 包含关系:target == base 或 target 在 base 之下
143
+ if target_str != base_str and not target_str.startswith(base_str + os.sep):
144
+ raise ValueError(f"路径 traversal 被拒绝: {relative_path}")
145
+ return target_str
146
+
147
+
148
+ @contextmanager
149
+ def project_lock(config_path: str, timeout: float = 10.0) -> Iterator[None]:
150
+ """获取项目级文件锁,防止并发写操作损坏 manifest 等关键配置文件。
151
+
152
+ 锁文件存放在 {config_path}/.precis/project.lock,所有写 manifest 的操作共用同一把锁。
153
+
154
+ 参数:
155
+ config_path: 项目配置根目录
156
+ timeout: 获取锁的超时时间(秒),默认 10 秒
157
+
158
+ 使用示例:
159
+ with project_lock(config_path):
160
+ write_yaml_atomic(Path(manifest_path), manifest.model_dump(exclude_none=True))
161
+ """
162
+ lock_dir = os.path.join(config_path, ".precis")
163
+ os.makedirs(lock_dir, exist_ok=True)
164
+ lock_path = os.path.join(lock_dir, "project.lock")
165
+ lock = FileLock(lock_path, timeout=timeout)
166
+ with lock:
167
+ yield