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,197 @@
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
+ """@fileoverview 流式事件日志(EventJournal)
17
+
18
+ 持久化流式事件序列,支持断线重连时通过 Last-Event-ID 续传。
19
+
20
+ 存储结构: {journal_dir}/{job_id}.journal,每行一个 JSON 对象(追加写入):
21
+ {"id": 1, "event": "delta", "data": {"text": "你好"}, "ts": 1706000000.123}
22
+
23
+ 设计要点:
24
+ - 文件而非内存: 刷新页面/后端重启后任务状态不丢;多请求共享同一事件源
25
+ - 追加写入 + 文件锁: 保证并发追加时 id 递增且不丢失
26
+ - 启动时扫描已有文件恢复 _next_id: 支持重连续传
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import logging
33
+ import os
34
+ import threading
35
+ import time
36
+ from typing import Any
37
+
38
+ from .types import TERMINAL_EVENTS
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ class EventJournal:
44
+ """@classdesc 流式事件日志
45
+
46
+ 按 job_id 维度持久化事件序列,支持追加、续传读取、终止判定、清理。
47
+ """
48
+
49
+ def __init__(self, job_id: str, journal_dir: str):
50
+ """
51
+ @methoddesc 初始化事件日志
52
+
53
+ 参数:
54
+ job_id: 任务 ID
55
+ journal_dir: 日志目录路径(由调用方决定,通常复用项目本地 .precis 目录)
56
+ """
57
+ self.job_id = job_id
58
+ self.journal_dir = journal_dir
59
+ self.journal_path = os.path.join(journal_dir, f"{job_id}.journal")
60
+ self._lock = threading.Lock()
61
+ self._ensure_dir()
62
+ # 从已有 journal 文件恢复下一个 id(支持服务重启后续传)
63
+ self._next_id = self._recover_next_id()
64
+
65
+ def _ensure_dir(self) -> None:
66
+ """确保日志目录存在。"""
67
+ try:
68
+ os.makedirs(self.journal_dir, exist_ok=True)
69
+ except OSError as e:
70
+ logger.warning(f"创建 journal 目录失败 {self.journal_dir}: {e}")
71
+
72
+ def _recover_next_id(self) -> int:
73
+ """扫描已有 journal 文件,返回下一个可用 id(已有最大 id + 1)。
74
+
75
+ 无文件或为空时返回 1。
76
+ """
77
+ if not os.path.exists(self.journal_path):
78
+ return 1
79
+ max_id = 0
80
+ try:
81
+ with open(self.journal_path, encoding="utf-8") as f:
82
+ for line in f:
83
+ line = line.strip()
84
+ if not line:
85
+ continue
86
+ try:
87
+ entry = json.loads(line)
88
+ eid = entry.get("id", 0)
89
+ if isinstance(eid, int) and eid > max_id:
90
+ max_id = eid
91
+ except json.JSONDecodeError:
92
+ continue
93
+ except OSError as e:
94
+ logger.warning(f"恢复 journal next_id 失败 {self.journal_path}: {e}")
95
+ return max_id + 1
96
+
97
+ def append(self, event: str, data: dict[str, Any], *, force_fsync: bool = False) -> int:
98
+ """
99
+ @methoddesc 追加一个事件,返回分配的递增 id
100
+
101
+ 线程安全(文件锁 + 实例锁)。
102
+
103
+ 性能优化:默认只 flush 不 fsync(避免每个 delta 字符阻塞事件循环)。
104
+ 终止事件(completed/error/cancelled)应传 force_fsync=True 确保持久化。
105
+ force_fsync=True 的非终止事件(如 apply_pending)也建议 fsync。
106
+
107
+ 参数:
108
+ event: 事件类型(见 types.py 常量)
109
+ data: 事件数据(需可 JSON 序列化)
110
+ force_fsync: 是否强制 fsync(终止事件/关键事件设 True)
111
+
112
+ 返回:
113
+ 分配的事件 id(从 1 开始递增)
114
+ """
115
+ entry: dict[str, Any] = {"id": 0, "event": event, "data": data, "ts": time.time()}
116
+ with self._lock:
117
+ entry["id"] = self._next_id
118
+ eid: int = self._next_id
119
+ self._next_id += 1
120
+ line = json.dumps(entry, ensure_ascii=False)
121
+ try:
122
+ # 追加写入,newline 分隔
123
+ with open(self.journal_path, "a", encoding="utf-8") as f:
124
+ f.write(line + "\n")
125
+ f.flush()
126
+ # 仅终止/关键事件 fsync,避免高频 delta 阻塞事件循环(性能优化)
127
+ if force_fsync:
128
+ os.fsync(f.fileno())
129
+ except OSError as e:
130
+ logger.warning(f"追加 journal 事件失败 {self.journal_path}: {e}")
131
+ return eid
132
+
133
+ def _read_all_unlocked(self) -> list[tuple[int, str, dict[str, Any]]]:
134
+ """读取全部事件(不加锁,调用方自行加锁或保证无并发写)。"""
135
+ if not os.path.exists(self.journal_path):
136
+ return []
137
+ events: list[tuple[int, str, dict[str, Any]]] = []
138
+ try:
139
+ with open(self.journal_path, encoding="utf-8") as f:
140
+ for line in f:
141
+ line = line.strip()
142
+ if not line:
143
+ continue
144
+ try:
145
+ entry = json.loads(line)
146
+ events.append((entry["id"], entry["event"], entry["data"]))
147
+ except (json.JSONDecodeError, KeyError):
148
+ continue
149
+ except OSError as e:
150
+ logger.warning(f"读取 journal 失败 {self.journal_path}: {e}")
151
+ return events
152
+
153
+ def read_all(self) -> list[tuple[int, str, dict[str, Any]]]:
154
+ """@methoddesc 读取全部事件,返回 (id, event, data) 元组列表(按 id 升序)。"""
155
+ with self._lock:
156
+ return self._read_all_unlocked()
157
+
158
+ def read_since(self, last_id: int) -> list[tuple[int, str, dict[str, Any]]]:
159
+ """
160
+ @methoddesc 读取 id > last_id 的所有事件(用于断线续传)
161
+
162
+ 参数:
163
+ last_id: 客户端最后收到的事件 id(Last-Event-ID)
164
+
165
+ 返回:
166
+ (id, event, data) 元组列表,仅含 id > last_id 的事件
167
+ """
168
+ with self._lock:
169
+ all_events = self._read_all_unlocked()
170
+ return [(eid, ev, data) for eid, ev, data in all_events if eid > last_id]
171
+
172
+ def is_terminated(self) -> bool:
173
+ """@methoddesc 判断最后一条事件是否为终止事件(completed/error/cancelled)。
174
+
175
+ 返回:
176
+ True 表示任务已结束(用于已完成任务的重连场景,直接回放后关闭)
177
+ """
178
+ with self._lock:
179
+ events = self._read_all_unlocked()
180
+ if not events:
181
+ return False
182
+ last_event = events[-1][1]
183
+ return last_event in TERMINAL_EVENTS
184
+
185
+ def cleanup(self) -> None:
186
+ """@methoddesc 删除 journal 文件(任务完成 24h 后由调用方触发)。"""
187
+ with self._lock:
188
+ try:
189
+ if os.path.exists(self.journal_path):
190
+ os.remove(self.journal_path)
191
+ except OSError as e:
192
+ logger.warning(f"清理 journal 文件失败 {self.journal_path}: {e}")
193
+
194
+ @property
195
+ def path(self) -> str:
196
+ """journal 文件绝对路径(供测试与调试使用)。"""
197
+ return self.journal_path
@@ -0,0 +1,262 @@
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
+ """@fileoverview 流式编排器(StreamingOrchestrator)
17
+
18
+ 把现有的 ChatAgentRunner / ConfigGenerationService / ConfigMigrationService
19
+ 包装成统一的事件流(StreamEvent 序列),落盘到 EventJournal 并推给 SSE 队列。
20
+
21
+ 设计原则(可维护性 + 健壮性):
22
+ - 业务逻辑零改动: 通过回调注入桥接 service 输出,不重写 service
23
+ - emit 是唯一事件出口: 同时落盘 EventJournal(支持续传)+ 推给内存队列(实时 SSE)
24
+ - finally 注销取消信号: 无论成功/取消/失败都清理,防止内存泄漏
25
+ - 终止事件携带完整快照: 即使中途 delta 丢失,前端也能渲染完整结果
26
+ - apply_actions 两阶段确认: 创建 ConfirmController 注册 store,桥接 apply_* 事件
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import logging
33
+ from typing import Any
34
+
35
+ from app.shared.services.ai.agent.chat_tools.apply_actions import ApplyCallbacks
36
+ from app.shared.services.ai.agent.chat_tools.ask_user import AskCallbacks
37
+ from app.shared.services.ai.agent.types import ToolResult
38
+ from app.shared.services.ai.chat_agent_runner import ChatAgentRunner
39
+ from app.shared.services.ai.streaming.pending_interaction_store import (
40
+ ConfirmController,
41
+ InteractionController,
42
+ get_global_pending_interaction_store,
43
+ )
44
+
45
+ from .event_journal import EventJournal
46
+ from .types import (
47
+ EVENT_APPLY_CONFIRMED,
48
+ EVENT_APPLY_PENDING,
49
+ EVENT_APPLY_REJECTED,
50
+ EVENT_CANCELLED,
51
+ EVENT_COMPLETED,
52
+ EVENT_DELTA,
53
+ EVENT_ERROR,
54
+ EVENT_FRONTEND_INSTRUCTION,
55
+ EVENT_STARTED,
56
+ EVENT_TOOL_CALL,
57
+ EVENT_TOOL_RESULT,
58
+ EVENT_TURN_START,
59
+ EVENT_USER_INPUT_REQUESTED,
60
+ EVENT_USER_RESPONDED,
61
+ )
62
+
63
+ logger = logging.getLogger(__name__)
64
+
65
+
66
+ class StreamingOrchestrator:
67
+ """@classdesc 流式编排器
68
+
69
+ 包装底层 service 为事件流。每个实例对应一个 job_id 的完整生命周期。
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ job_id: str,
75
+ journal: EventJournal,
76
+ cancel_event: asyncio.Event,
77
+ event_queue: asyncio.Queue[dict[str, Any]] | None = None,
78
+ ):
79
+ """
80
+ @methoddesc 初始化流式编排器
81
+
82
+ 参数:
83
+ job_id: 任务 ID
84
+ journal: 事件日志(持久化续传)
85
+ cancel_event: 取消信号(由 cancel 端点 set)
86
+ event_queue: 可选的实时事件队列,推送给 SSE 响应。None 时只落盘不推队列。
87
+ """
88
+ self.job_id = job_id
89
+ self.journal = journal
90
+ self.cancel_event = cancel_event
91
+ self.event_queue = event_queue
92
+
93
+ def emit(self, event: str, data: dict[str, Any]) -> int:
94
+ """@methoddesc 追加一个事件到 journal(并推给队列,若有)。
95
+
96
+ 参数:
97
+ event: 事件类型
98
+ data: 事件数据
99
+
100
+ 返回:
101
+ 分配的事件 id
102
+ """
103
+ # 终止事件 + apply_pending 强制 fsync(保证关键状态持久化,delta 等高频事件不 fsync)
104
+ from .types import TERMINAL_EVENTS
105
+
106
+ force_fsync = event in TERMINAL_EVENTS or event == EVENT_APPLY_PENDING
107
+ eid = self.journal.append(event, data, force_fsync=force_fsync)
108
+ if self.event_queue is not None:
109
+ try:
110
+ self.event_queue.put_nowait({"id": eid, "event": event, "data": data})
111
+ except asyncio.QueueFull:
112
+ logger.warning(f"事件队列已满,丢弃实时事件 {event} (job={self.job_id})")
113
+ return eid
114
+
115
+ def _emit_apply(self, event: str, payload: dict[str, Any]) -> None:
116
+ """@methoddesc 桥接 apply 事件回调(丢弃 emit 返回值以匹配 Callable[..., None])。"""
117
+ self.emit(event, payload)
118
+
119
+ async def run_chat(
120
+ self,
121
+ message: str,
122
+ history: list[dict[str, str]] | None,
123
+ provider: Any,
124
+ project_path: str,
125
+ context_nodes: list[dict[str, Any]],
126
+ canvas_nodes: list[dict[str, Any]] | None = None,
127
+ ) -> None:
128
+ """@methoddesc 包装 ChatAgentRunner 为流式事件流
129
+
130
+ 流程: started → (delta/turn_start/tool_call/tool_result 经回调) → completed/cancelled/error
131
+ 新增: apply_pending → await 用户确认 → apply_confirmed/apply_rejected
132
+
133
+ 参数:
134
+ message: 用户消息
135
+ history: 对话历史
136
+ provider: Provider 实例
137
+ project_path: 项目配置目录
138
+ context_nodes: 选中上下文节点
139
+ canvas_nodes: 画布全部业务节点快照(供 read_canvas 工具查询)
140
+ """
141
+ self.emit(EVENT_STARTED, {"job_id": self.job_id, "kind": "chat"})
142
+
143
+ # apply 的确认控制器现在由 ApplyActionsTool 每次 apply 时独立创建(按 apply_id 键控),
144
+ # 不再在 job 级创建单一控制器(避免第 2 个 apply 复用旧决策)。
145
+
146
+ # 创建 apply 事件桥接回调
147
+ apply_callbacks = ApplyCallbacks(
148
+ on_apply_pending=lambda payload: self._emit_apply(EVENT_APPLY_PENDING, payload),
149
+ on_apply_confirmed=lambda payload: self._emit_apply(EVENT_APPLY_CONFIRMED, payload),
150
+ on_apply_rejected=lambda payload: self._emit_apply(EVENT_APPLY_REJECTED, payload),
151
+ on_frontend_instruction=lambda payload: self._emit_apply(EVENT_FRONTEND_INSTRUCTION, payload),
152
+ )
153
+
154
+ # ask_user 事件桥接回调(与 apply_callbacks 平行)
155
+ ask_callbacks = AskCallbacks(
156
+ on_user_input_requested=lambda payload: self._emit_apply(EVENT_USER_INPUT_REQUESTED, payload),
157
+ on_user_responded=lambda payload: self._emit_apply(EVENT_USER_RESPONDED, payload),
158
+ )
159
+
160
+ runner = ChatAgentRunner(
161
+ provider=provider,
162
+ project_path=project_path,
163
+ context_nodes=context_nodes,
164
+ apply_callbacks=apply_callbacks,
165
+ ask_callbacks=ask_callbacks,
166
+ dry_run_enabled=True,
167
+ job_id=self.job_id,
168
+ canvas_nodes=canvas_nodes,
169
+ )
170
+
171
+ # 工具 label 映射(与 ChatAgentRunner._TOOL_LABELS 一致,供 tool_call 事件使用)
172
+ tool_labels = dict(ChatAgentRunner._TOOL_LABELS)
173
+
174
+ # 桥接回调:把 service 输出转为事件
175
+ def on_chunk(text: str) -> None:
176
+ self.emit(EVENT_DELTA, {"text": text})
177
+
178
+ def on_turn(turn: int) -> None:
179
+ self.emit(EVENT_TURN_START, {"turn": turn})
180
+
181
+ def on_tool_call(name: str, call_id: str, turn: int) -> None:
182
+ label = tool_labels.get(name, name)
183
+ self.emit(EVENT_TOOL_CALL, {"tool": name, "call_id": call_id, "turn": turn, "label": label})
184
+
185
+ def on_tool_result(tr: ToolResult) -> None:
186
+ self.emit(
187
+ EVENT_TOOL_RESULT,
188
+ {
189
+ "call_id": tr.call_id,
190
+ "name": tr.name,
191
+ "success": tr.success,
192
+ "label": tool_labels.get(tr.name, tr.name),
193
+ "error": tr.error,
194
+ },
195
+ )
196
+
197
+ def cancelled() -> bool:
198
+ return self.cancel_event.is_set()
199
+
200
+ runner.configure_callbacks(
201
+ on_chunk=on_chunk,
202
+ on_turn=on_turn,
203
+ on_tool_call=on_tool_call,
204
+ on_tool_result=on_tool_result,
205
+ cancelled=cancelled,
206
+ )
207
+
208
+ try:
209
+ result = await runner.run(message, history)
210
+ except Exception as e:
211
+ logger.exception(f"StreamingOrchestrator run_chat 失败 (job={self.job_id})")
212
+ self.emit(EVENT_ERROR, {"message": f"Agent 执行失败: {e}", "code": "RUNNER_ERROR"})
213
+ return
214
+ finally:
215
+ # 兜底清理:拒绝该 job 下所有未决议的 apply 控制器(每次 apply 各自独立,可能有多个)
216
+ # 注意:apply 挂起时 finally 不可达(await 未返回),由 await_decision 超时兜底
217
+ pending_store = get_global_pending_interaction_store()
218
+ for controller in pending_store.pop_by_job_prefix(self.job_id):
219
+ if not controller.is_resolved:
220
+ # store 同时持有 apply(ConfirmController)和 ask(InteractionController),
221
+ # 两类 resolve 签名不同:apply 接 str(confirm/reject),ask 接 dict。
222
+ if isinstance(controller, ConfirmController):
223
+ logger.warning(
224
+ f"run_chat finally 兜底 resolve reject (job={self.job_id}, apply={controller.request_id})"
225
+ )
226
+ await controller.resolve("reject")
227
+ elif isinstance(controller, InteractionController):
228
+ logger.warning(
229
+ f"run_chat finally 兜底 resolve skip (job={self.job_id}, ask={controller.request_id})"
230
+ )
231
+ await controller.resolve({"skipped": True, "reason": "cancelled"})
232
+
233
+ # 终态判定:用 result.cancelled 显式字段(而非 success+iterations 启发式)
234
+ # cancel_event.is_set() 覆盖外部取消信号;result.cancelled 覆盖 executor 内部取消
235
+ if self.cancel_event.is_set() or getattr(result, "cancelled", False):
236
+ # 软取消:已落盘的 apply_actions 改动保留,轨迹如实显示
237
+ self.emit(
238
+ EVENT_CANCELLED,
239
+ {
240
+ "completed_turns": getattr(result, "iterations", 0),
241
+ "partial": True,
242
+ "tool_steps": getattr(result, "tool_steps", []),
243
+ "reply": getattr(result, "reply", ""),
244
+ },
245
+ )
246
+ elif not getattr(result, "success", True):
247
+ # 失败(非取消)
248
+ self.emit(
249
+ EVENT_ERROR,
250
+ {"message": getattr(result, "error", "未知错误"), "code": "AGENT_FAILED"},
251
+ )
252
+ else:
253
+ # 成功:终止事件携带完整快照(容错兜底)
254
+ self.emit(
255
+ EVENT_COMPLETED,
256
+ {
257
+ "reply": getattr(result, "reply", ""),
258
+ "frontend_instructions": getattr(result, "frontend_instructions", []),
259
+ "tool_steps": getattr(result, "tool_steps", []),
260
+ "iterations": getattr(result, "iterations", 0),
261
+ },
262
+ )
@@ -0,0 +1,227 @@
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
+ """@fileoverview 交互门控控制器与挂起交互存储
17
+
18
+ 提供 apply_actions 两阶段确认与 ask_user 交互问答所需的核心组件:
19
+ - ConfirmController: apply 确认门(二元 confirm/reject,向后兼容,apply 代码仍用)
20
+ - InteractionController: 通用交互门(任意 dict response,ask_user 用)
21
+ - PendingInteractionStore Protocol: 存储接口(apply + ask 共用)
22
+ - InMemoryPendingInteractionStore: 进程内 dict 实现(默认)
23
+ - get_global_pending_interaction_store: 全局单例
24
+
25
+ 设计要点:
26
+ - 两类 controller 共存于同一 store,靠 key 前缀区分:
27
+ apply = "{job_id}#apply#{seq}",ask = "{job_id}#ask#{seq}"
28
+ - resolve() 幂等 + 原子(asyncio.Lock 保护 check-then-set)
29
+ - await_decision() / await_response() 带超时,超时自动返回默认值(防死锁)
30
+ - 全局 store 使用 threading.Lock 保护并发安全
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import logging
37
+ import threading
38
+ from typing import Any, Protocol, runtime_checkable
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ # apply 确认超时时间(秒):超时后自动 reject,防止协程永久挂起
43
+ _APPLY_CONFIRM_TIMEOUT = 300.0
44
+
45
+ # ask 交互超时时间(秒):超时后自动返回 {skipped: true, reason: timeout}
46
+ _INTERACTION_TIMEOUT = 300.0
47
+
48
+
49
+ class ConfirmController:
50
+ """@classdesc apply 确认门控制器(向后兼容,apply_actions 专用)
51
+
52
+ 在 apply_actions 协程内创建,调用 await_decision() 挂起协程。
53
+ 确认端点调用 resolve() 唤醒协程。超时未决策则自动返回 reject。
54
+ """
55
+
56
+ def __init__(self, request_id: str, pending_payload: dict[str, Any] | None = None) -> None:
57
+ self.request_id = request_id
58
+ self.pending_payload = pending_payload
59
+ self._gate = asyncio.Event()
60
+ self._decision: str | None = None
61
+ self._resolve_lock = asyncio.Lock()
62
+
63
+ async def await_decision(self) -> str:
64
+ """阻塞等待用户决策,返回 decision 字符串("confirm" 或 "reject")。
65
+
66
+ 带 5 分钟超时:超时自动返回 "reject"。
67
+ """
68
+ try:
69
+ await asyncio.wait_for(self._gate.wait(), timeout=_APPLY_CONFIRM_TIMEOUT)
70
+ except TimeoutError:
71
+ logger.warning(
72
+ "apply 确认超时(%ds),自动 reject: request_id=%s",
73
+ int(_APPLY_CONFIRM_TIMEOUT),
74
+ self.request_id,
75
+ )
76
+ return "reject"
77
+ return self._decision or "reject"
78
+
79
+ async def resolve(self, decision: str) -> None:
80
+ """唤醒协程并写入决策。已决议则忽略(幂等)。"""
81
+ async with self._resolve_lock:
82
+ if self._gate.is_set():
83
+ return
84
+ self._decision = decision
85
+ self._gate.set()
86
+
87
+ @property
88
+ def is_resolved(self) -> bool:
89
+ return self._gate.is_set()
90
+
91
+ @property
92
+ def decision(self) -> str | None:
93
+ return self._decision
94
+
95
+
96
+ class InteractionController:
97
+ """@classdesc 通用交互门控制器(ask_user 专用)
98
+
99
+ 与 ConfirmController 的区别:
100
+ - ConfirmController: 二元 confirm/reject,await_decision() 返回固定字符串
101
+ - InteractionController: 任意 dict response,await_response() 返回 dict
102
+
103
+ 在 ask_user 协程内创建,调用 await_response() 挂起协程。
104
+ /respond 端点调用 resolve(response) 唤醒协程。
105
+ 超时自动返回 {skipped: true, reason: timeout}。
106
+ """
107
+
108
+ def __init__(self, request_id: str, pending_payload: dict[str, Any] | None = None) -> None:
109
+ self.request_id = request_id
110
+ self.pending_payload = pending_payload # question schema(发给前端的提问内容)
111
+ self._gate = asyncio.Event()
112
+ self._response: dict[str, Any] | None = None
113
+ self._resolve_lock = asyncio.Lock()
114
+
115
+ async def await_response(self) -> dict[str, Any]:
116
+ """阻塞等待用户回答,返回 response dict。
117
+
118
+ 带 5 分钟超时:超时返回 {skipped: true, reason: timeout},
119
+ 让 LLM 统一按 skip 处理(无需区分超时和主动跳过)。
120
+ """
121
+ try:
122
+ await asyncio.wait_for(self._gate.wait(), timeout=_INTERACTION_TIMEOUT)
123
+ except TimeoutError:
124
+ logger.warning(
125
+ "ask 等待超时(%ds),自动 skip: request_id=%s",
126
+ int(_INTERACTION_TIMEOUT),
127
+ self.request_id,
128
+ )
129
+ return {"skipped": True, "reason": "timeout"}
130
+ return self._response or {"skipped": True, "reason": "empty"}
131
+
132
+ async def resolve(self, response: dict[str, Any]) -> None:
133
+ """唤醒协程并写入回答。已决议则忽略(幂等)。"""
134
+ async with self._resolve_lock:
135
+ if self._gate.is_set():
136
+ return
137
+ self._response = response
138
+ self._gate.set()
139
+
140
+ @property
141
+ def is_resolved(self) -> bool:
142
+ return self._gate.is_set()
143
+
144
+ @property
145
+ def response(self) -> dict[str, Any] | None:
146
+ return self._response
147
+
148
+
149
+ @runtime_checkable
150
+ class PendingInteractionStore(Protocol):
151
+ """挂起交互存储接口(Protocol,功能 C 可替换为持久化实现)。
152
+
153
+ apply 和 ask controller 共用此接口,靠 key 前缀区分类型。
154
+ @runtime_checkable 让 isinstance 检查可用(用于依赖注入与测试断言)。
155
+ """
156
+
157
+ def put(self, interaction_id: str, controller: ConfirmController | InteractionController) -> None: ...
158
+ def get(self, interaction_id: str) -> ConfirmController | InteractionController | None: ...
159
+ def pop(self, interaction_id: str) -> ConfirmController | InteractionController | None: ...
160
+ def get_all_by_job(self, job_id: str) -> list[ConfirmController | InteractionController]: ...
161
+ def pop_by_job_prefix(self, job_id: str) -> list[ConfirmController | InteractionController]: ...
162
+
163
+
164
+ class InMemoryPendingInteractionStore:
165
+ """进程内 dict 实现(apply + ask 共用)。
166
+
167
+ 键命名(统一加类型前缀):
168
+ - apply: "{job_id}#apply#{seq}"
169
+ - ask: "{job_id}#ask#{seq}"
170
+
171
+ 兼容回退:get/pop 传入 job_id 时,找该 job 下唯一未决议项
172
+ (匹配 f"{job_id}#" 前缀,覆盖 apply 和 ask 两类)。
173
+ """
174
+
175
+ def __init__(self) -> None:
176
+ self._store: dict[str, ConfirmController | InteractionController] = {}
177
+ self._lock = threading.Lock()
178
+
179
+ def put(self, interaction_id: str, controller: ConfirmController | InteractionController) -> None:
180
+ with self._lock:
181
+ self._store[interaction_id] = controller
182
+
183
+ def get(self, interaction_id: str) -> ConfirmController | InteractionController | None:
184
+ with self._lock:
185
+ # 精确匹配 interaction_id(apply_id 或 ask_id)
186
+ if interaction_id in self._store:
187
+ return self._store[interaction_id]
188
+ # 兼容回退:传入的是 job_id,找该 job 下唯一未决议项
189
+ pending = [
190
+ c for aid, c in self._store.items() if aid.startswith(f"{interaction_id}#") and not c.is_resolved
191
+ ]
192
+ return pending[0] if len(pending) == 1 else None
193
+
194
+ def pop(self, interaction_id: str) -> ConfirmController | InteractionController | None:
195
+ with self._lock:
196
+ if interaction_id in self._store:
197
+ return self._store.pop(interaction_id)
198
+ # 兼容回退
199
+ matches = [
200
+ (aid, c) for aid, c in self._store.items() if aid.startswith(f"{interaction_id}#") and not c.is_resolved
201
+ ]
202
+ if len(matches) == 1:
203
+ return self._store.pop(matches[0][0])
204
+ return None
205
+
206
+ def get_all_by_job(self, job_id: str) -> list[ConfirmController | InteractionController]:
207
+ """获取某 job 下所有挂起的 controller(apply + ask,用于 cancel 时批量清理)。"""
208
+ with self._lock:
209
+ prefix = f"{job_id}#"
210
+ return [c for aid, c in self._store.items() if aid.startswith(prefix)]
211
+
212
+ def pop_by_job_prefix(self, job_id: str) -> list[ConfirmController | InteractionController]:
213
+ """弹出某 job 下所有挂起的 controller。"""
214
+ with self._lock:
215
+ prefix = f"{job_id}#"
216
+ matched = [(aid, c) for aid, c in self._store.items() if aid.startswith(prefix)]
217
+ for aid, _ in matched:
218
+ self._store.pop(aid, None)
219
+ return [c for _, c in matched]
220
+
221
+
222
+ _global_store: PendingInteractionStore = InMemoryPendingInteractionStore()
223
+
224
+
225
+ def get_global_pending_interaction_store() -> PendingInteractionStore:
226
+ """获取全局 PendingInteractionStore 单例。"""
227
+ return _global_store