@devtrack-solution/codesdd 1.2.2

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 (433) hide show
  1. package/.sdd/skills/curated/api-clean-flask-langgraph/SKILL.md +2751 -0
  2. package/.sdd/skills/curated/devtrack-api/SKILL.md +137 -0
  3. package/.sdd/skills/curated/devtrack-api/agents/openai.yaml +4 -0
  4. package/.sdd/skills/curated/devtrack-api/references/application-presentation.md +381 -0
  5. package/.sdd/skills/curated/devtrack-api/references/architecture-governance.md +219 -0
  6. package/.sdd/skills/curated/devtrack-api/references/domain-modeling.md +359 -0
  7. package/.sdd/skills/curated/devtrack-api/references/implementation-checklist.md +127 -0
  8. package/.sdd/skills/curated/devtrack-api/references/imports-lint.md +207 -0
  9. package/.sdd/skills/curated/devtrack-api/references/testing-validation.md +167 -0
  10. package/.sdd/skills/curated/devtrack-api/references/typeorm-infrastructure.md +334 -0
  11. package/LICENSE +21 -0
  12. package/README.md +842 -0
  13. package/bin/codesdd.js +10 -0
  14. package/dist/cli/index.d.ts +3 -0
  15. package/dist/cli/index.js +560 -0
  16. package/dist/commands/change.d.ts +35 -0
  17. package/dist/commands/change.js +296 -0
  18. package/dist/commands/completion.d.ts +72 -0
  19. package/dist/commands/completion.js +258 -0
  20. package/dist/commands/config.d.ts +36 -0
  21. package/dist/commands/config.js +552 -0
  22. package/dist/commands/feedback.d.ts +9 -0
  23. package/dist/commands/feedback.js +184 -0
  24. package/dist/commands/schema.d.ts +6 -0
  25. package/dist/commands/schema.js +870 -0
  26. package/dist/commands/sdd/execution.d.ts +3 -0
  27. package/dist/commands/sdd/execution.js +409 -0
  28. package/dist/commands/sdd/shared.d.ts +9 -0
  29. package/dist/commands/sdd/shared.js +84 -0
  30. package/dist/commands/sdd/skills.d.ts +3 -0
  31. package/dist/commands/sdd/skills.js +154 -0
  32. package/dist/commands/sdd.d.ts +3 -0
  33. package/dist/commands/sdd.js +769 -0
  34. package/dist/commands/show.d.ts +14 -0
  35. package/dist/commands/show.js +133 -0
  36. package/dist/commands/spec.d.ts +15 -0
  37. package/dist/commands/spec.js +228 -0
  38. package/dist/commands/validate.d.ts +24 -0
  39. package/dist/commands/validate.js +295 -0
  40. package/dist/commands/workflow/index.d.ts +17 -0
  41. package/dist/commands/workflow/index.js +12 -0
  42. package/dist/commands/workflow/instructions.d.ts +29 -0
  43. package/dist/commands/workflow/instructions.js +383 -0
  44. package/dist/commands/workflow/new-change.d.ts +11 -0
  45. package/dist/commands/workflow/new-change.js +45 -0
  46. package/dist/commands/workflow/schemas.d.ts +10 -0
  47. package/dist/commands/workflow/schemas.js +34 -0
  48. package/dist/commands/workflow/shared.d.ts +57 -0
  49. package/dist/commands/workflow/shared.js +117 -0
  50. package/dist/commands/workflow/status.d.ts +14 -0
  51. package/dist/commands/workflow/status.js +76 -0
  52. package/dist/commands/workflow/templates.d.ts +16 -0
  53. package/dist/commands/workflow/templates.js +68 -0
  54. package/dist/core/archive.d.ts +16 -0
  55. package/dist/core/archive.js +487 -0
  56. package/dist/core/artifact-graph/graph.d.ts +56 -0
  57. package/dist/core/artifact-graph/graph.js +141 -0
  58. package/dist/core/artifact-graph/index.d.ts +7 -0
  59. package/dist/core/artifact-graph/index.js +13 -0
  60. package/dist/core/artifact-graph/instruction-loader.d.ts +143 -0
  61. package/dist/core/artifact-graph/instruction-loader.js +215 -0
  62. package/dist/core/artifact-graph/resolver.d.ts +81 -0
  63. package/dist/core/artifact-graph/resolver.js +258 -0
  64. package/dist/core/artifact-graph/schema.d.ts +13 -0
  65. package/dist/core/artifact-graph/schema.js +108 -0
  66. package/dist/core/artifact-graph/state.d.ts +12 -0
  67. package/dist/core/artifact-graph/state.js +54 -0
  68. package/dist/core/artifact-graph/types.d.ts +45 -0
  69. package/dist/core/artifact-graph/types.js +43 -0
  70. package/dist/core/available-tools.d.ts +16 -0
  71. package/dist/core/available-tools.js +30 -0
  72. package/dist/core/branding.d.ts +8 -0
  73. package/dist/core/branding.js +12 -0
  74. package/dist/core/cli/command-matrix.d.ts +23 -0
  75. package/dist/core/cli/command-matrix.js +123 -0
  76. package/dist/core/command-generation/adapters/amazon-q.d.ts +13 -0
  77. package/dist/core/command-generation/adapters/amazon-q.js +26 -0
  78. package/dist/core/command-generation/adapters/antigravity.d.ts +13 -0
  79. package/dist/core/command-generation/adapters/antigravity.js +26 -0
  80. package/dist/core/command-generation/adapters/auggie.d.ts +13 -0
  81. package/dist/core/command-generation/adapters/auggie.js +27 -0
  82. package/dist/core/command-generation/adapters/claude.d.ts +13 -0
  83. package/dist/core/command-generation/adapters/claude.js +50 -0
  84. package/dist/core/command-generation/adapters/cline.d.ts +14 -0
  85. package/dist/core/command-generation/adapters/cline.js +27 -0
  86. package/dist/core/command-generation/adapters/codebuddy.d.ts +13 -0
  87. package/dist/core/command-generation/adapters/codebuddy.js +28 -0
  88. package/dist/core/command-generation/adapters/codex.d.ts +16 -0
  89. package/dist/core/command-generation/adapters/codex.js +39 -0
  90. package/dist/core/command-generation/adapters/continue.d.ts +13 -0
  91. package/dist/core/command-generation/adapters/continue.js +28 -0
  92. package/dist/core/command-generation/adapters/costrict.d.ts +13 -0
  93. package/dist/core/command-generation/adapters/costrict.js +27 -0
  94. package/dist/core/command-generation/adapters/crush.d.ts +13 -0
  95. package/dist/core/command-generation/adapters/crush.js +30 -0
  96. package/dist/core/command-generation/adapters/cursor.d.ts +14 -0
  97. package/dist/core/command-generation/adapters/cursor.js +44 -0
  98. package/dist/core/command-generation/adapters/factory.d.ts +13 -0
  99. package/dist/core/command-generation/adapters/factory.js +27 -0
  100. package/dist/core/command-generation/adapters/gemini.d.ts +13 -0
  101. package/dist/core/command-generation/adapters/gemini.js +26 -0
  102. package/dist/core/command-generation/adapters/github-copilot.d.ts +13 -0
  103. package/dist/core/command-generation/adapters/github-copilot.js +26 -0
  104. package/dist/core/command-generation/adapters/iflow.d.ts +13 -0
  105. package/dist/core/command-generation/adapters/iflow.js +29 -0
  106. package/dist/core/command-generation/adapters/index.d.ts +29 -0
  107. package/dist/core/command-generation/adapters/index.js +29 -0
  108. package/dist/core/command-generation/adapters/kilocode.d.ts +14 -0
  109. package/dist/core/command-generation/adapters/kilocode.js +23 -0
  110. package/dist/core/command-generation/adapters/kiro.d.ts +13 -0
  111. package/dist/core/command-generation/adapters/kiro.js +26 -0
  112. package/dist/core/command-generation/adapters/opencode.d.ts +13 -0
  113. package/dist/core/command-generation/adapters/opencode.js +29 -0
  114. package/dist/core/command-generation/adapters/pi.d.ts +14 -0
  115. package/dist/core/command-generation/adapters/pi.js +41 -0
  116. package/dist/core/command-generation/adapters/qoder.d.ts +13 -0
  117. package/dist/core/command-generation/adapters/qoder.js +30 -0
  118. package/dist/core/command-generation/adapters/qwen.d.ts +13 -0
  119. package/dist/core/command-generation/adapters/qwen.js +26 -0
  120. package/dist/core/command-generation/adapters/roocode.d.ts +14 -0
  121. package/dist/core/command-generation/adapters/roocode.js +27 -0
  122. package/dist/core/command-generation/adapters/windsurf.d.ts +14 -0
  123. package/dist/core/command-generation/adapters/windsurf.js +51 -0
  124. package/dist/core/command-generation/generator.d.ts +21 -0
  125. package/dist/core/command-generation/generator.js +27 -0
  126. package/dist/core/command-generation/index.d.ts +22 -0
  127. package/dist/core/command-generation/index.js +24 -0
  128. package/dist/core/command-generation/registry.d.ts +36 -0
  129. package/dist/core/command-generation/registry.js +92 -0
  130. package/dist/core/command-generation/types.d.ts +56 -0
  131. package/dist/core/command-generation/types.js +8 -0
  132. package/dist/core/completions/command-registry.d.ts +7 -0
  133. package/dist/core/completions/command-registry.js +461 -0
  134. package/dist/core/completions/completion-provider.d.ts +60 -0
  135. package/dist/core/completions/completion-provider.js +102 -0
  136. package/dist/core/completions/factory.d.ts +64 -0
  137. package/dist/core/completions/factory.js +75 -0
  138. package/dist/core/completions/generators/bash-generator.d.ts +32 -0
  139. package/dist/core/completions/generators/bash-generator.js +174 -0
  140. package/dist/core/completions/generators/fish-generator.d.ts +32 -0
  141. package/dist/core/completions/generators/fish-generator.js +157 -0
  142. package/dist/core/completions/generators/powershell-generator.d.ts +33 -0
  143. package/dist/core/completions/generators/powershell-generator.js +207 -0
  144. package/dist/core/completions/generators/zsh-generator.d.ts +44 -0
  145. package/dist/core/completions/generators/zsh-generator.js +250 -0
  146. package/dist/core/completions/installers/bash-installer.d.ts +87 -0
  147. package/dist/core/completions/installers/bash-installer.js +318 -0
  148. package/dist/core/completions/installers/fish-installer.d.ts +43 -0
  149. package/dist/core/completions/installers/fish-installer.js +143 -0
  150. package/dist/core/completions/installers/powershell-installer.d.ts +88 -0
  151. package/dist/core/completions/installers/powershell-installer.js +327 -0
  152. package/dist/core/completions/installers/zsh-installer.d.ts +125 -0
  153. package/dist/core/completions/installers/zsh-installer.js +452 -0
  154. package/dist/core/completions/templates/bash-templates.d.ts +6 -0
  155. package/dist/core/completions/templates/bash-templates.js +24 -0
  156. package/dist/core/completions/templates/fish-templates.d.ts +7 -0
  157. package/dist/core/completions/templates/fish-templates.js +39 -0
  158. package/dist/core/completions/templates/powershell-templates.d.ts +6 -0
  159. package/dist/core/completions/templates/powershell-templates.js +25 -0
  160. package/dist/core/completions/templates/zsh-templates.d.ts +6 -0
  161. package/dist/core/completions/templates/zsh-templates.js +36 -0
  162. package/dist/core/completions/types.d.ts +79 -0
  163. package/dist/core/completions/types.js +2 -0
  164. package/dist/core/config-prompts.d.ts +9 -0
  165. package/dist/core/config-prompts.js +34 -0
  166. package/dist/core/config-schema.d.ts +86 -0
  167. package/dist/core/config-schema.js +213 -0
  168. package/dist/core/config.d.ts +17 -0
  169. package/dist/core/config.js +33 -0
  170. package/dist/core/converters/json-converter.d.ts +6 -0
  171. package/dist/core/converters/json-converter.js +51 -0
  172. package/dist/core/global-config.d.ts +44 -0
  173. package/dist/core/global-config.js +125 -0
  174. package/dist/core/index.d.ts +2 -0
  175. package/dist/core/index.js +3 -0
  176. package/dist/core/init.d.ts +36 -0
  177. package/dist/core/init.js +576 -0
  178. package/dist/core/legacy-cleanup.d.ts +162 -0
  179. package/dist/core/legacy-cleanup.js +512 -0
  180. package/dist/core/list.d.ts +9 -0
  181. package/dist/core/list.js +173 -0
  182. package/dist/core/migration.d.ts +23 -0
  183. package/dist/core/migration.js +108 -0
  184. package/dist/core/parsers/change-parser.d.ts +13 -0
  185. package/dist/core/parsers/change-parser.js +193 -0
  186. package/dist/core/parsers/markdown-parser.d.ts +22 -0
  187. package/dist/core/parsers/markdown-parser.js +187 -0
  188. package/dist/core/parsers/requirement-blocks.d.ts +37 -0
  189. package/dist/core/parsers/requirement-blocks.js +201 -0
  190. package/dist/core/profile-sync-drift.d.ts +38 -0
  191. package/dist/core/profile-sync-drift.js +201 -0
  192. package/dist/core/profiles.d.ts +26 -0
  193. package/dist/core/profiles.js +41 -0
  194. package/dist/core/project-config.d.ts +64 -0
  195. package/dist/core/project-config.js +223 -0
  196. package/dist/core/schemas/base.schema.d.ts +13 -0
  197. package/dist/core/schemas/base.schema.js +13 -0
  198. package/dist/core/schemas/change.schema.d.ts +73 -0
  199. package/dist/core/schemas/change.schema.js +31 -0
  200. package/dist/core/schemas/index.d.ts +4 -0
  201. package/dist/core/schemas/index.js +4 -0
  202. package/dist/core/schemas/spec.schema.d.ts +18 -0
  203. package/dist/core/schemas/spec.schema.js +15 -0
  204. package/dist/core/sdd/adr-policy.d.ts +7 -0
  205. package/dist/core/sdd/adr-policy.js +47 -0
  206. package/dist/core/sdd/adr.d.ts +4 -0
  207. package/dist/core/sdd/adr.js +27 -0
  208. package/dist/core/sdd/bootstrap.d.ts +28 -0
  209. package/dist/core/sdd/bootstrap.js +353 -0
  210. package/dist/core/sdd/check.d.ts +51 -0
  211. package/dist/core/sdd/check.js +831 -0
  212. package/dist/core/sdd/coordination/coordination-adapters.d.ts +73 -0
  213. package/dist/core/sdd/coordination/coordination-adapters.js +87 -0
  214. package/dist/core/sdd/coordination/index.d.ts +2 -0
  215. package/dist/core/sdd/coordination/index.js +2 -0
  216. package/dist/core/sdd/dedup.d.ts +23 -0
  217. package/dist/core/sdd/dedup.js +62 -0
  218. package/dist/core/sdd/default-bootstrap-files.d.ts +23 -0
  219. package/dist/core/sdd/default-bootstrap-files.js +385 -0
  220. package/dist/core/sdd/default-skills.d.ts +16 -0
  221. package/dist/core/sdd/default-skills.js +427 -0
  222. package/dist/core/sdd/diagnose.d.ts +25 -0
  223. package/dist/core/sdd/diagnose.js +1312 -0
  224. package/dist/core/sdd/docs-sync.d.ts +21 -0
  225. package/dist/core/sdd/docs-sync.js +231 -0
  226. package/dist/core/sdd/domain/helpers.d.ts +6 -0
  227. package/dist/core/sdd/domain/helpers.js +37 -0
  228. package/dist/core/sdd/domain/lifecycle-guardrails.d.ts +22 -0
  229. package/dist/core/sdd/domain/lifecycle-guardrails.js +31 -0
  230. package/dist/core/sdd/domain/lifecycle-hooks.d.ts +16 -0
  231. package/dist/core/sdd/domain/lifecycle-hooks.js +27 -0
  232. package/dist/core/sdd/domain/post-active-validation.d.ts +15 -0
  233. package/dist/core/sdd/domain/post-active-validation.js +71 -0
  234. package/dist/core/sdd/domain/traceability.d.ts +8 -0
  235. package/dist/core/sdd/domain/traceability.js +83 -0
  236. package/dist/core/sdd/domain/transition-engine.d.ts +49 -0
  237. package/dist/core/sdd/domain/transition-engine.js +120 -0
  238. package/dist/core/sdd/fingerprint.d.ts +23 -0
  239. package/dist/core/sdd/fingerprint.js +146 -0
  240. package/dist/core/sdd/import-openspec.d.ts +31 -0
  241. package/dist/core/sdd/import-openspec.js +232 -0
  242. package/dist/core/sdd/init.d.ts +36 -0
  243. package/dist/core/sdd/init.js +65 -0
  244. package/dist/core/sdd/json-schema.d.ts +6 -0
  245. package/dist/core/sdd/json-schema.js +59 -0
  246. package/dist/core/sdd/legacy-operations.d.ts +286 -0
  247. package/dist/core/sdd/legacy-operations.js +2175 -0
  248. package/dist/core/sdd/lenses.d.ts +14 -0
  249. package/dist/core/sdd/lenses.js +97 -0
  250. package/dist/core/sdd/merge-catalog.d.ts +9 -0
  251. package/dist/core/sdd/merge-catalog.js +70 -0
  252. package/dist/core/sdd/migrate-workspace.d.ts +36 -0
  253. package/dist/core/sdd/migrate-workspace.js +344 -0
  254. package/dist/core/sdd/migrate.d.ts +24 -0
  255. package/dist/core/sdd/migrate.js +385 -0
  256. package/dist/core/sdd/resolve-project-root.d.ts +15 -0
  257. package/dist/core/sdd/resolve-project-root.js +46 -0
  258. package/dist/core/sdd/root-resolver.d.ts +16 -0
  259. package/dist/core/sdd/root-resolver.js +62 -0
  260. package/dist/core/sdd/sanitize.d.ts +35 -0
  261. package/dist/core/sdd/sanitize.js +750 -0
  262. package/dist/core/sdd/services/approve.service.d.ts +20 -0
  263. package/dist/core/sdd/services/approve.service.js +82 -0
  264. package/dist/core/sdd/services/audit.service.d.ts +53 -0
  265. package/dist/core/sdd/services/audit.service.js +136 -0
  266. package/dist/core/sdd/services/breakdown.service.d.ts +35 -0
  267. package/dist/core/sdd/services/breakdown.service.js +185 -0
  268. package/dist/core/sdd/services/context.service.d.ts +346 -0
  269. package/dist/core/sdd/services/context.service.js +278 -0
  270. package/dist/core/sdd/services/debate.service.d.ts +16 -0
  271. package/dist/core/sdd/services/debate.service.js +73 -0
  272. package/dist/core/sdd/services/decide.service.d.ts +23 -0
  273. package/dist/core/sdd/services/decide.service.js +81 -0
  274. package/dist/core/sdd/services/dedup-apply.service.d.ts +39 -0
  275. package/dist/core/sdd/services/dedup-apply.service.js +259 -0
  276. package/dist/core/sdd/services/feature-lint.service.d.ts +29 -0
  277. package/dist/core/sdd/services/feature-lint.service.js +146 -0
  278. package/dist/core/sdd/services/finalize.service.d.ts +33 -0
  279. package/dist/core/sdd/services/finalize.service.js +707 -0
  280. package/dist/core/sdd/services/frontend-gap.service.d.ts +23 -0
  281. package/dist/core/sdd/services/frontend-gap.service.js +117 -0
  282. package/dist/core/sdd/services/frontend-impact.service.d.ts +19 -0
  283. package/dist/core/sdd/services/frontend-impact.service.js +46 -0
  284. package/dist/core/sdd/services/ingest-deposito.service.d.ts +32 -0
  285. package/dist/core/sdd/services/ingest-deposito.service.js +231 -0
  286. package/dist/core/sdd/services/insight.service.d.ts +21 -0
  287. package/dist/core/sdd/services/insight.service.js +81 -0
  288. package/dist/core/sdd/services/legacy-capability.service.d.ts +24 -0
  289. package/dist/core/sdd/services/legacy-capability.service.js +59 -0
  290. package/dist/core/sdd/services/mcp-runtime.service.d.ts +42 -0
  291. package/dist/core/sdd/services/mcp-runtime.service.js +144 -0
  292. package/dist/core/sdd/services/metrics.service.d.ts +49 -0
  293. package/dist/core/sdd/services/metrics.service.js +181 -0
  294. package/dist/core/sdd/services/next.service.d.ts +35 -0
  295. package/dist/core/sdd/services/next.service.js +54 -0
  296. package/dist/core/sdd/services/onboard.service.d.ts +9 -0
  297. package/dist/core/sdd/services/onboard.service.js +165 -0
  298. package/dist/core/sdd/services/rebuild.service.d.ts +31 -0
  299. package/dist/core/sdd/services/rebuild.service.js +482 -0
  300. package/dist/core/sdd/services/scan-naming.service.d.ts +43 -0
  301. package/dist/core/sdd/services/scan-naming.service.js +246 -0
  302. package/dist/core/sdd/services/skills-invoke.service.d.ts +24 -0
  303. package/dist/core/sdd/services/skills-invoke.service.js +63 -0
  304. package/dist/core/sdd/services/skills-sync.service.d.ts +15 -0
  305. package/dist/core/sdd/services/skills-sync.service.js +117 -0
  306. package/dist/core/sdd/services/start.service.d.ts +26 -0
  307. package/dist/core/sdd/services/start.service.js +237 -0
  308. package/dist/core/sdd/skills.d.ts +15 -0
  309. package/dist/core/sdd/skills.js +46 -0
  310. package/dist/core/sdd/state-lock.d.ts +19 -0
  311. package/dist/core/sdd/state-lock.js +144 -0
  312. package/dist/core/sdd/state.d.ts +155 -0
  313. package/dist/core/sdd/state.js +1000 -0
  314. package/dist/core/sdd/store/in-memory-adapter.d.ts +12 -0
  315. package/dist/core/sdd/store/in-memory-adapter.js +27 -0
  316. package/dist/core/sdd/store/index.d.ts +5 -0
  317. package/dist/core/sdd/store/index.js +5 -0
  318. package/dist/core/sdd/store/sdd-stores.d.ts +25 -0
  319. package/dist/core/sdd/store/sdd-stores.js +59 -0
  320. package/dist/core/sdd/store/state-store.d.ts +32 -0
  321. package/dist/core/sdd/store/state-store.js +2 -0
  322. package/dist/core/sdd/store/yaml-file-adapter.d.ts +12 -0
  323. package/dist/core/sdd/store/yaml-file-adapter.js +43 -0
  324. package/dist/core/sdd/structural-health.d.ts +557 -0
  325. package/dist/core/sdd/structural-health.js +187 -0
  326. package/dist/core/sdd/transaction.d.ts +14 -0
  327. package/dist/core/sdd/transaction.js +100 -0
  328. package/dist/core/sdd/types.d.ts +1570 -0
  329. package/dist/core/sdd/types.js +617 -0
  330. package/dist/core/sdd/views.d.ts +3 -0
  331. package/dist/core/sdd/views.js +560 -0
  332. package/dist/core/sdd/workspace-schemas.d.ts +620 -0
  333. package/dist/core/sdd/workspace-schemas.js +254 -0
  334. package/dist/core/sdd/write-manifest.d.ts +25 -0
  335. package/dist/core/sdd/write-manifest.js +353 -0
  336. package/dist/core/shared/index.d.ts +8 -0
  337. package/dist/core/shared/index.js +8 -0
  338. package/dist/core/shared/skill-generation.d.ts +49 -0
  339. package/dist/core/shared/skill-generation.js +106 -0
  340. package/dist/core/shared/tool-detection.d.ts +71 -0
  341. package/dist/core/shared/tool-detection.js +158 -0
  342. package/dist/core/specs-apply.d.ts +73 -0
  343. package/dist/core/specs-apply.js +385 -0
  344. package/dist/core/styles/palette.d.ts +7 -0
  345. package/dist/core/styles/palette.js +8 -0
  346. package/dist/core/templates/index.d.ts +8 -0
  347. package/dist/core/templates/index.js +9 -0
  348. package/dist/core/templates/skill-templates.d.ts +20 -0
  349. package/dist/core/templates/skill-templates.js +19 -0
  350. package/dist/core/templates/types.d.ts +19 -0
  351. package/dist/core/templates/types.js +5 -0
  352. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  353. package/dist/core/templates/workflows/apply-change.js +308 -0
  354. package/dist/core/templates/workflows/archive-change.d.ts +10 -0
  355. package/dist/core/templates/workflows/archive-change.js +277 -0
  356. package/dist/core/templates/workflows/bulk-archive-change.d.ts +10 -0
  357. package/dist/core/templates/workflows/bulk-archive-change.js +502 -0
  358. package/dist/core/templates/workflows/continue-change.d.ts +10 -0
  359. package/dist/core/templates/workflows/continue-change.js +232 -0
  360. package/dist/core/templates/workflows/explore.d.ts +10 -0
  361. package/dist/core/templates/workflows/explore.js +475 -0
  362. package/dist/core/templates/workflows/feedback.d.ts +9 -0
  363. package/dist/core/templates/workflows/feedback.js +108 -0
  364. package/dist/core/templates/workflows/ff-change.d.ts +10 -0
  365. package/dist/core/templates/workflows/ff-change.js +206 -0
  366. package/dist/core/templates/workflows/new-change.d.ts +10 -0
  367. package/dist/core/templates/workflows/new-change.js +151 -0
  368. package/dist/core/templates/workflows/onboard.d.ts +10 -0
  369. package/dist/core/templates/workflows/onboard.js +573 -0
  370. package/dist/core/templates/workflows/propose.d.ts +10 -0
  371. package/dist/core/templates/workflows/propose.js +224 -0
  372. package/dist/core/templates/workflows/sdd.d.ts +10 -0
  373. package/dist/core/templates/workflows/sdd.js +107 -0
  374. package/dist/core/templates/workflows/sync-specs.d.ts +10 -0
  375. package/dist/core/templates/workflows/sync-specs.js +286 -0
  376. package/dist/core/templates/workflows/verify-change.d.ts +10 -0
  377. package/dist/core/templates/workflows/verify-change.js +346 -0
  378. package/dist/core/update.d.ts +77 -0
  379. package/dist/core/update.js +538 -0
  380. package/dist/core/validation/constants.d.ts +34 -0
  381. package/dist/core/validation/constants.js +40 -0
  382. package/dist/core/validation/types.d.ts +18 -0
  383. package/dist/core/validation/types.js +2 -0
  384. package/dist/core/validation/validator.d.ts +33 -0
  385. package/dist/core/validation/validator.js +409 -0
  386. package/dist/core/view.d.ts +8 -0
  387. package/dist/core/view.js +170 -0
  388. package/dist/index.d.ts +3 -0
  389. package/dist/index.js +3 -0
  390. package/dist/prompts/searchable-multi-select.d.ts +28 -0
  391. package/dist/prompts/searchable-multi-select.js +159 -0
  392. package/dist/telemetry/config.d.ts +32 -0
  393. package/dist/telemetry/config.js +68 -0
  394. package/dist/telemetry/index.d.ts +44 -0
  395. package/dist/telemetry/index.js +207 -0
  396. package/dist/ui/ascii-patterns.d.ts +16 -0
  397. package/dist/ui/ascii-patterns.js +133 -0
  398. package/dist/ui/welcome-screen.d.ts +10 -0
  399. package/dist/ui/welcome-screen.js +146 -0
  400. package/dist/utils/change-metadata.d.ts +51 -0
  401. package/dist/utils/change-metadata.js +147 -0
  402. package/dist/utils/change-utils.d.ts +62 -0
  403. package/dist/utils/change-utils.js +121 -0
  404. package/dist/utils/command-references.d.ts +18 -0
  405. package/dist/utils/command-references.js +20 -0
  406. package/dist/utils/file-system.d.ts +36 -0
  407. package/dist/utils/file-system.js +281 -0
  408. package/dist/utils/index.d.ts +6 -0
  409. package/dist/utils/index.js +9 -0
  410. package/dist/utils/interactive.d.ts +18 -0
  411. package/dist/utils/interactive.js +21 -0
  412. package/dist/utils/item-discovery.d.ts +4 -0
  413. package/dist/utils/item-discovery.js +73 -0
  414. package/dist/utils/match.d.ts +3 -0
  415. package/dist/utils/match.js +22 -0
  416. package/dist/utils/openspec-compat.d.ts +2 -0
  417. package/dist/utils/openspec-compat.js +2 -0
  418. package/dist/utils/shell-detection.d.ts +20 -0
  419. package/dist/utils/shell-detection.js +41 -0
  420. package/dist/utils/task-progress.d.ts +8 -0
  421. package/dist/utils/task-progress.js +36 -0
  422. package/package.json +111 -0
  423. package/schemas/sdd/1-spec.schema.json +221 -0
  424. package/schemas/sdd/2-plan.schema.json +199 -0
  425. package/schemas/sdd/3-tasks.schema.json +102 -0
  426. package/schemas/sdd/4-changelog.schema.json +55 -0
  427. package/schemas/sdd/5-quality.schema.json +427 -0
  428. package/schemas/sdd/workspace-catalog.schema.json +1012 -0
  429. package/schemas/spec-driven/schema.yaml +153 -0
  430. package/schemas/spec-driven/templates/design.md +19 -0
  431. package/schemas/spec-driven/templates/proposal.md +23 -0
  432. package/schemas/spec-driven/templates/spec.md +8 -0
  433. package/schemas/spec-driven/templates/tasks.md +9 -0
@@ -0,0 +1,2751 @@
1
+ # Skill: API Clean Flask + SQLAlchemy + WebSocket + LangGraph Multiagente
2
+
3
+ ## Objetivo
4
+
5
+ Use esta skill para gerar, revisar, refatorar ou evoluir um projeto Python com Flask seguindo Clean Architecture, com dois canais isolados de entrada:
6
+
7
+ - HTTP REST
8
+ - WebSocket
9
+
10
+ A aplicação possui três domínios de negócio:
11
+
12
+ - Clínicas
13
+ - Serviços
14
+ - Agendamentos
15
+
16
+ E possui um sistema multiagente baseado em LangChain e LangGraph, acionado pela camada `application` e alimentado principalmente por mensagens recebidas via WebSocket.
17
+
18
+ ---
19
+
20
+ ## Stack técnica padrão
21
+
22
+ Use esta stack, salvo decisão explícita em contrário:
23
+
24
+ - Python 3.12+
25
+ - Flask
26
+ - Flask-Sock
27
+ - SQLAlchemy 2.x, usando ORM tipado com `Mapped` e `mapped_column`
28
+ - Alembic para migrations
29
+ - Pydantic v2 para schemas de entrada/saída
30
+ - PyJWT para validação explícita de tokens JWT
31
+ - OpenAPI v3 para contrato HTTP
32
+ - Swagger UI para documentação interativa
33
+ - PostgreSQL como banco relacional recomendado para produção
34
+ - MySQL como banco relacional suportado
35
+ - SQLite apenas para desenvolvimento local leve e testes rápidos quando compatível
36
+ - LangChain para tools e integração com modelos
37
+ - LangGraph para orquestração stateful de agentes
38
+ - Redis obrigatório para cache, pub/sub, fan-out de eventos, sessões WebSocket, locks distribuídos e checkpointer
39
+ - Pytest para testes
40
+ - Ruff, Black e Mypy para qualidade
41
+
42
+ Não use Flask-SQLAlchemy por padrão. Nesta arquitetura, prefira SQLAlchemy puro para manter a persistência em `infrastructure`, sem acoplar o ORM ao objeto Flask.
43
+
44
+ Todo código de persistência deve ser compatível com PostgreSQL e MySQL, salvo quando a skill declarar uma diferença explícita de dialeto.
45
+
46
+ Todo código Python deve ser tipado e orientado a objetos por padrão.
47
+
48
+ ---
49
+
50
+ ## Tipagem Python e orientação a objetos
51
+
52
+ Use Python tipado em todos os arquivos gerados.
53
+
54
+ Regras obrigatórias:
55
+
56
+ - Todo módulo deve usar type hints explícitos em funções, métodos, atributos e retornos públicos.
57
+ - Use `from __future__ import annotations` quando melhorar legibilidade ou evitar imports circulares.
58
+ - Use `dataclass(frozen=True)` para DTOs imutáveis de application quando Pydantic não for necessário.
59
+ - Use Pydantic v2 para schemas de interface e validação de payload externo.
60
+ - Use `Protocol` ou `ABC` para ports.
61
+ - Use classes para use cases, repositories, Unit of Work, policies, services, adapters, controllers e orchestrators.
62
+ - Evite lógica procedural espalhada em funções soltas.
63
+ - Funções soltas são aceitáveis apenas para factories, builders, helpers puros pequenos, route handlers Flask e nodes simples do LangGraph.
64
+ - Não retorne `Any` sem justificativa.
65
+ - Não ignore Mypy sem comentário objetivo.
66
+ - Prefira composição e injeção de dependência explícita em construtores.
67
+ - Evite herança profunda; use herança principalmente para mixins ORM, exceptions e contracts abstratos.
68
+ - Métodos de domínio devem expressar comportamento da entidade, não apenas getters/setters.
69
+
70
+ Exemplo de port tipado:
71
+
72
+ ```python
73
+ from typing import Protocol
74
+
75
+ from src.domain.clinics.entities.clinic import Clinic
76
+
77
+
78
+ class ClinicRepositoryPort(Protocol):
79
+ def get_by_id(self, tenant_id: int, clinic_id: int) -> Clinic | None:
80
+ ...
81
+
82
+ def save(self, clinic: Clinic) -> Clinic:
83
+ ...
84
+ ```
85
+
86
+ Exemplo de use case orientado a objeto:
87
+
88
+ ```python
89
+ from dataclasses import dataclass
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class GetClinicInput:
94
+ tenant_id: int
95
+ clinic_id: int
96
+
97
+
98
+ class GetClinicUseCase:
99
+ def __init__(self, clinic_repository: ClinicRepositoryPort):
100
+ self._clinic_repository = clinic_repository
101
+
102
+ def execute(self, input_data: GetClinicInput) -> ClinicOutput:
103
+ clinic = self._clinic_repository.get_by_id(
104
+ tenant_id=input_data.tenant_id,
105
+ clinic_id=input_data.clinic_id,
106
+ )
107
+ if clinic is None:
108
+ raise NotFoundAppError("Clinic not found")
109
+ return ClinicOutput.from_domain(clinic)
110
+ ```
111
+
112
+ ---
113
+
114
+ ## Regra central da arquitetura
115
+
116
+ Nunca misture estas dimensões:
117
+
118
+ ```txt
119
+ Canais de entrada:
120
+ - HTTP REST
121
+ - WebSocket
122
+
123
+ Domínios de negócio:
124
+ - Clínicas
125
+ - Serviços
126
+ - Agendamentos
127
+
128
+ Sistema de IA:
129
+ - Agentes
130
+ - Tools
131
+ - LangGraph
132
+ - LLM provider
133
+
134
+ SaaS segregation:
135
+ - JWT
136
+ - tenant_id
137
+ - autorização por escopo
138
+ - isolamento por query, índice e constraint
139
+
140
+ Continuidade operacional:
141
+ - exceptions globais
142
+ - erro normalizado
143
+ - fallback sem derrubar conexão/processo
144
+ - Redis cache/pub-sub
145
+
146
+ Documentação de API:
147
+ - OpenAPI v3
148
+ - Swagger UI
149
+ - Bearer JWT authorization
150
+ - persistência local do token na interface Swagger
151
+ ```
152
+
153
+ HTTP, WebSocket e LangGraph são adapters/orquestradores.
154
+
155
+ Os domínios são a regra de negócio.
156
+
157
+ `tenant_id` é o identificador obrigatório de segregação SaaS.
158
+
159
+ JWT autentica o usuário e carrega o `tenant_id` canônico.
160
+
161
+ Redis é infraestrutura compartilhada para cache, publish/subscribe e entrega resiliente de eventos.
162
+
163
+ Swagger UI é adapter de documentação HTTP e deve autenticar via Bearer JWT sem conhecer regras de negócio.
164
+
165
+ O sistema multiagente deve ser agnóstico a modelos e providers de LLM.
166
+
167
+ ---
168
+
169
+ ## Agnosticismo de modelos
170
+
171
+ A skill não deve acoplar o projeto a um provider ou modelo específico.
172
+
173
+ Regras:
174
+
175
+ - Não hardcode `OpenAI`, `Anthropic`, `Gemini`, `Bedrock`, `Azure OpenAI` ou outro provider em `domain` ou `application`.
176
+ - Não hardcode nome de modelo em use case, tool, node ou prompt.
177
+ - Provider, modelo, temperatura, timeout e limites devem vir de configuração.
178
+ - `application/agents` conhece apenas ports, DTOs e contratos.
179
+ - Implementações concretas de LLM ficam em `infrastructure/ai/llm`.
180
+ - LangGraph nodes recebem dependências abstratas ou factories injetadas.
181
+ - Tools devem continuar chamando use cases, independentemente do modelo usado.
182
+ - Prompts devem ser escritos de forma portável, sem instruções específicas de vendor salvo quando uma feature pedir explicitamente.
183
+ - Testes devem usar fake LLM/model stub, não provider real.
184
+
185
+ Crie um port de provider:
186
+
187
+ ```txt
188
+ application/agents/ports/chat_model_port.py
189
+ ```
190
+
191
+ Exemplo:
192
+
193
+ ```python
194
+ from typing import Protocol
195
+
196
+
197
+ class ChatModelPort(Protocol):
198
+ def invoke(self, messages: list[dict[str, str]]) -> str:
199
+ ...
200
+ ```
201
+
202
+ Implementações possíveis:
203
+
204
+ ```txt
205
+ infrastructure/ai/llm/langchain_chat_model_provider.py
206
+ infrastructure/ai/llm/fake_chat_model_provider.py
207
+ infrastructure/ai/llm/provider_factory.py
208
+ ```
209
+
210
+ `provider_factory.py` escolhe o adapter por configuração, por exemplo:
211
+
212
+ ```txt
213
+ LLM_PROVIDER=openai|anthropic|gemini|bedrock|azure|fake
214
+ LLM_MODEL=<model-name>
215
+ ```
216
+
217
+ O valor default para testes deve ser `fake`.
218
+
219
+ ---
220
+
221
+ ## Limites obrigatórios
222
+
223
+ ### Permitido
224
+
225
+ ```txt
226
+ interfaces/http -> application
227
+ interfaces/websocket -> application
228
+ application -> domain
229
+ application -> application ports
230
+ infrastructure -> application ports
231
+ infrastructure/ai -> application/agents ports
232
+ infrastructure/ai/tools -> application use cases
233
+ infrastructure/security -> application/security ports
234
+ infrastructure/cache -> application/shared ports
235
+ infrastructure/events -> application event publisher ports
236
+ ```
237
+
238
+ ### Proibido
239
+
240
+ ```txt
241
+ domain -> flask
242
+ domain -> sqlalchemy
243
+ domain -> langchain
244
+ domain -> langgraph
245
+ domain -> websocket
246
+
247
+ application -> flask
248
+ application -> flask_sock
249
+ application -> sqlalchemy models
250
+ application -> langchain concrete classes
251
+ application -> langgraph concrete classes
252
+ application -> redis concrete client
253
+ application -> jwt concrete library
254
+ application -> provider concreto de LLM
255
+ domain -> provider concreto de LLM
256
+
257
+ interfaces -> repositories diretamente
258
+ interfaces -> SQLAlchemy Session diretamente
259
+ websocket controller -> banco diretamente
260
+ LangGraph node -> banco diretamente
261
+ LangGraph tool -> repository diretamente
262
+ controller -> redis diretamente
263
+ controller -> decodificação manual de JWT
264
+ repository -> query sem tenant_id
265
+ ```
266
+
267
+ ## Python/Flask Clean Architecture boundary contract
268
+
269
+ Use this matrix as a hard contract for generated or reviewed code:
270
+
271
+ | Layer | Owns | Can depend on | Must not depend on |
272
+ | --- | --- | --- | --- |
273
+ | Domain | Entities, value objects, domain services, business invariants | Python standard library, domain modules | Flask, flask_sock, SQLAlchemy, Redis clients, JWT libraries, LangChain, LangGraph, provider SDKs |
274
+ | Application | Use cases, orchestration, DTOs, ports, transaction boundaries | Domain, application DTOs, application ports | Flask request/response, SQLAlchemy models/session, Redis concrete client, JWT concrete verifier, LangChain/LangGraph concrete classes |
275
+ | Interfaces (HTTP/WebSocket) | Transport adapters, schema validation, auth context extraction, response serialization | Application use cases and DTOs, interface schemas | ORM models, raw SQL, direct Redis usage, direct provider calls |
276
+ | Infrastructure | Repositories, persistence models, cache/security/AI adapters, external integrations | Application ports, domain entities for mapping, concrete frameworks/libs | Reverse dependency into interface controllers/routes |
277
+
278
+ Invariants that must hold:
279
+
280
+ - Domain methods receive business data and enforce business rules; they do not parse transport payloads.
281
+ - Application use cases orchestrate domain and ports; they do not import transport or persistence frameworks.
282
+ - Flask controllers and WebSocket handlers only adapt inbound/outbound transport data.
283
+ - Infrastructure adapters implement ports and can use frameworks, but never redefine business rules that belong to domain/application.
284
+
285
+ Leakage example (do not do this):
286
+
287
+ ```python
288
+ from flask import request
289
+ from sqlalchemy.orm import Session
290
+
291
+
292
+ class CreateAppointmentUseCase:
293
+ def __init__(self, db: Session):
294
+ self._db = db
295
+
296
+ def execute(self) -> None:
297
+ tenant_id = int(request.json["tenant_id"])
298
+ self._db.execute("INSERT INTO appointments ...")
299
+ ```
300
+
301
+ Boundary-safe split (preferred):
302
+
303
+ ```python
304
+ from dataclasses import dataclass
305
+ from typing import Protocol
306
+
307
+
308
+ @dataclass(frozen=True)
309
+ class CreateAppointmentInput:
310
+ tenant_id: int
311
+ clinic_id: int
312
+ service_id: int
313
+
314
+
315
+ class AppointmentRepositoryPort(Protocol):
316
+ def create(self, input_data: CreateAppointmentInput) -> int:
317
+ ...
318
+
319
+
320
+ class CreateAppointmentUseCase:
321
+ def __init__(self, repository: AppointmentRepositoryPort):
322
+ self._repository = repository
323
+
324
+ def execute(self, input_data: CreateAppointmentInput) -> int:
325
+ return self._repository.create(input_data)
326
+ ```
327
+
328
+ ---
329
+
330
+ ## OpenSDD decision-support mode (never runtime dependency)
331
+
332
+ Use this mode when the target repository already has a `.sdd/` root and the team is using OpenSDD planning artifacts.
333
+
334
+ The goal is to improve architecture decisions with explicit traceability, not to add OpenSDD runtime dependencies to generated Flask code.
335
+
336
+ ### Mandatory OpenSDD workflow for architecture decisions
337
+
338
+ 1. Run `opensdd sdd onboard system` before broad design work.
339
+ 2. Run `opensdd sdd next` and select active or ready work.
340
+ 3. Run `opensdd sdd context <FEAT-ID>` before drafting architecture changes.
341
+ 4. Keep `.sdd/active/<FEAT-ID>/5-quality.yaml` updated with decision evidence and risks.
342
+ 5. Declare interface impact with `opensdd sdd frontend-impact <FEAT-ID> ...` even when status is `none`.
343
+ 6. Run `opensdd sdd check --render` after updates; run `opensdd sdd diagnose` when structure drift is suspected.
344
+ 7. Consolidate with `opensdd sdd finalize --ref <FEAT-ID>` only after quality evidence is recorded.
345
+
346
+ ### How to use INS/DEB/EPIC/FEAT as decision layers
347
+
348
+ - `INS`: register ambiguity, contradiction, or unknown constraints before implementation.
349
+ - `DEB`: compare options and trade-offs with explicit risks and reversal conditions.
350
+ - `EPIC`: define capability boundaries and non-goals.
351
+ - `FEAT`: execute a bounded slice with concrete acceptance criteria and validation evidence.
352
+
353
+ Do not collapse open questions directly into implementation steps.
354
+
355
+ ### Architecture decision prompts (required)
356
+
357
+ Use these prompts and record answers in FEAT notes/quality evidence:
358
+
359
+ | Decision area | Prompt | Minimum evidence artifact |
360
+ | --- | --- | --- |
361
+ | REST vs WebSocket | Is this interaction request/response, stream, or bidirectional session? | FEAT spec note + acceptance criteria |
362
+ | Use case vs background task | Does business completion require synchronous response, or can it be deferred safely? | FEAT plan + risk note |
363
+ | Pub/Sub vs Streams/outbox | Is at-least-once replay/idempotency required, or is fire-and-forget enough? | FEAT quality risk matrix |
364
+ | Inferred intent vs confirmed action | Can the agent suggest only, or is explicit user confirmation required before side effects? | FEAT quality critical-flow evidence |
365
+ | Model routing strategy | Which model/provider policy is needed by use case class, and where is fallback defined? | FEAT architecture decision note |
366
+ | Checkpointer strategy | What state persistence and resume guarantees are required for orchestration? | FEAT plan + operational risk note |
367
+ | Production exceptions | Which quality/security exceptions are allowed, approved, and time-bounded? | `5-quality.yaml` exception fields |
368
+
369
+ ### Runtime-boundary guardrails
370
+
371
+ - OpenSDD files and commands are planning/governance tools only.
372
+ - Generated Flask code must not import from `.sdd`, parse OpenSDD YAML, or shell out to `opensdd`.
373
+ - Domain and application layers must stay independent of OpenSDD metadata and CLI semantics.
374
+ - If a project does not use OpenSDD, keep the same architecture rules and capture decisions in equivalent project docs.
375
+
376
+ ### Quality-contract usage in this mode
377
+
378
+ For each major architecture decision, update the active FEAT quality artifact with:
379
+
380
+ - requirement being protected;
381
+ - selected option and rejected options;
382
+ - accepted risks and compensating controls;
383
+ - command evidence (`check --render`, tests, build) and timestamped results.
384
+
385
+ This keeps decision history auditable without leaking OpenSDD concerns into runtime code.
386
+
387
+ ---
388
+
389
+ ## Production-readiness guidance (safe and version-aware)
390
+
391
+ Use this section to normalize production hardening without turning the skill into a promise of concrete package APIs that were not validated in the target environment.
392
+
393
+ ### Mandatory baseline versus optional advanced
394
+
395
+ | Area | Mandatory baseline | Optional advanced |
396
+ | --- | --- | --- |
397
+ | Multi-agent orchestration | Clear boundary between intent inference and transactional actions; explicit tool-to-use-case wiring | Multi-agent specialization with orchestrator-level arbitration and escalation policies |
398
+ | Multi-LLM execution | Provider/model selected by configuration; no hardcoded vendor/model names in domain/application | Dynamic routing by intent/cost/latency with explicit fallback policy |
399
+ | Streaming and HITL | Streaming responses stay non-authoritative; mutating actions require explicit confirmation | Partial streaming checkpoints, supervisor queue, or delayed approval workflows |
400
+ | Tenant/security authority | `tenant_id`, `user_id`, roles, and scopes come from validated principal only | Fine-grained policy engine, adaptive controls, and tenant-specific hardening |
401
+ | Redis messaging | Pub/Sub for transient notifications; Streams/outbox for replay-critical events | Exactly-once approximation with dedupe and replay controls per tenant |
402
+ | Resilience | Bounded retries, timeout budget, and deterministic fallback path | Circuit breaker, adaptive retry tiers, and workload-aware throttling |
403
+ | Observability and health | Correlation IDs, structured logs, metrics/traces, and readiness/liveness endpoints | SLO-driven alerting, error budgets, and trace sampling controls |
404
+ | Delivery operations | Reproducible container build and CI gates for lint/type/unit/integration/e2e | Progressive delivery, canary checks, and rollback automation |
405
+
406
+ ### Version-sensitive API policy (validated or conceptual)
407
+
408
+ - Mark integration examples as one of: `VALIDATED` or `CONCEPTUAL`.
409
+ - `VALIDATED` examples are allowed only when tested against the target environment/package versions.
410
+ - `CONCEPTUAL` examples must avoid exact import paths that may drift between package versions.
411
+ - Do not reference private/internal package modules for LangGraph, LangChain, observability, checkpointers, or provider SDKs.
412
+ - Keep provider/model placeholders generic (`provider`, `model`) unless project-level validation proves a concrete choice.
413
+
414
+ Minimal labeling pattern:
415
+
416
+ ```txt
417
+ [VALIDATED] Uses package API confirmed in this repository/environment.
418
+ [CONCEPTUAL] Illustrative flow only; verify imports/signatures before implementation.
419
+ ```
420
+
421
+ ### Redis Pub/Sub versus Streams and outbox
422
+
423
+ - Use Redis Pub/Sub for ephemeral fan-out where message loss is acceptable.
424
+ - Use Redis Streams (or equivalent durable queue) when replay, consumer groups, or delayed recovery is required.
425
+ - Use domain events + outbox for transactional consistency when business side effects must survive process restarts.
426
+ - Keep tenant boundaries explicit in channels, stream keys, and outbox records.
427
+
428
+ ### Resilience, idempotency, and checkpointing
429
+
430
+ - Mutating operations must define idempotency keys and dedupe behavior.
431
+ - Retries must be bounded and classify retryable versus non-retryable failures.
432
+ - Fallback paths must preserve business safety and avoid hidden side effects.
433
+ - Checkpointer strategy must define resume semantics, tenant partitioning, and stale-state cleanup policy.
434
+
435
+ ### Security hardening baseline
436
+
437
+ - Enforce JWT expiry, issuer/audience validation, and key rotation readiness.
438
+ - Define refresh/revocation strategy for long-lived sessions.
439
+ - Apply CORS policy, rate limiting, and security headers at interface boundaries.
440
+ - Never treat request body/query/WebSocket payload/LLM output as identity or tenant authority.
441
+
442
+ ### Typed settings, DI, and operational boundaries
443
+
444
+ - Runtime settings are typed, validated at startup, and environment-specific.
445
+ - Dependency injection composes adapters at startup; domain/application never read environment variables directly.
446
+ - Background workers and async pipelines use the same use-case contracts and tenant security invariants.
447
+ - Docker and CI/CD guidance remain infrastructure concerns, not domain/application code.
448
+
449
+ ---
450
+
451
+ ## JWT e segregação SaaS obrigatória
452
+
453
+ Todo request HTTP e toda conexão ou mensagem WebSocket autenticada deve carregar um JWT válido.
454
+
455
+ O JWT deve conter, no mínimo:
456
+
457
+ ```json
458
+ {
459
+ "sub": "user_123",
460
+ "tenant_id": 42,
461
+ "roles": ["clinic_admin"],
462
+ "scopes": ["appointments:write"],
463
+ "iss": "api-clean-flask",
464
+ "aud": "api-clean-flask-client",
465
+ "iat": 1730000000,
466
+ "exp": 1730003600
467
+ }
468
+ ```
469
+
470
+ Regras:
471
+
472
+ - `tenant_id` do JWT é a fonte canônica de segregação SaaS.
473
+ - Nunca aceite `tenant_id` vindo do body como fonte de autoridade.
474
+ - Payload pode repetir `tenant_id` apenas para validação defensiva; divergência deve gerar `AUTHORIZATION_ERROR`.
475
+ - Toda query de entidade de negócio deve filtrar por `tenant_id`.
476
+ - Todo repository deve exigir `tenant_id` como argumento explícito ou recebê-lo via DTO de application.
477
+ - Todo índice de busca operacional deve começar por `tenant_id` quando a tabela for tenant-scoped.
478
+ - Toda unique constraint de entidade tenant-scoped deve incluir `tenant_id`.
479
+ - Rotas continuam escopadas por `clinic_id`, mas `clinic_id` nunca substitui `tenant_id`.
480
+ - WebSocket deve validar JWT na abertura da conexão ou no primeiro evento de autenticação antes de processar mensagens de negócio.
481
+ - Agent tools devem receber `tenant_id` no contexto e repassá-lo aos use cases.
482
+
483
+ ### Ports de segurança
484
+
485
+ Crie ports em `application/security/ports`:
486
+
487
+ ```txt
488
+ application/security/ports/token_verifier_port.py
489
+ application/security/ports/permission_checker_port.py
490
+ ```
491
+
492
+ `TokenVerifierPort` deve retornar um principal de aplicação, não claims brutas do framework:
493
+
494
+ ```python
495
+ from dataclasses import dataclass
496
+
497
+
498
+ @dataclass(frozen=True)
499
+ class CurrentPrincipal:
500
+ user_id: str
501
+ tenant_id: int
502
+ roles: tuple[str, ...]
503
+ scopes: tuple[str, ...]
504
+ ```
505
+
506
+ Implementação concreta fica em `infrastructure/security/jwt_token_verifier.py`.
507
+
508
+ Controllers HTTP e WebSocket podem ler o principal do adapter de interface, mas só devem passar `tenant_id`, `user_id`, roles/scopes ou DTO equivalente para a camada `application`.
509
+
510
+ ---
511
+
512
+ ## OpenAPI v3 e Swagger UI obrigatórios
513
+
514
+ A API HTTP deve publicar contrato OpenAPI v3 e documentação interativa com Swagger UI.
515
+
516
+ Rotas obrigatórias:
517
+
518
+ ```txt
519
+ GET /openapi.json
520
+ GET /docs
521
+ ```
522
+
523
+ Regras:
524
+
525
+ - O documento deve seguir OpenAPI v3.
526
+ - Todo endpoint REST público deve aparecer no OpenAPI.
527
+ - Endpoints protegidos devem declarar security scheme `bearerAuth`.
528
+ - Swagger UI deve exibir botão `Authorize` para Bearer JWT.
529
+ - Swagger UI deve preservar o token autorizado durante reload da página com `persistAuthorization: true`.
530
+ - Todo serviço/endereço HTTP exposto deve ter exemplos de uso no OpenAPI para facilitar testes diretos pela Swagger UI.
531
+ - Exemplos devem incluir request body, path/query params, headers relevantes e response de sucesso.
532
+ - Endpoints protegidos devem deixar claro no Swagger que exigem Bearer JWT.
533
+ - O token pode ser salvo pelo Swagger UI em storage local do navegador, mas nunca deve ser registrado em log.
534
+ - Em produção, `/docs` pode exigir autenticação, allowlist ou feature flag, conforme política do produto.
535
+ - O contrato OpenAPI não deve importar domain/application; ele pertence a `interfaces/http`.
536
+ - Schemas OpenAPI podem ser derivados dos schemas Pydantic de interface, mas não devem expor modelos ORM.
537
+
538
+ Exemplo mínimo de security scheme:
539
+
540
+ ```json
541
+ {
542
+ "openapi": "3.0.3",
543
+ "components": {
544
+ "securitySchemes": {
545
+ "bearerAuth": {
546
+ "type": "http",
547
+ "scheme": "bearer",
548
+ "bearerFormat": "JWT"
549
+ }
550
+ }
551
+ },
552
+ "security": [
553
+ {
554
+ "bearerAuth": []
555
+ }
556
+ ]
557
+ }
558
+ ```
559
+
560
+ Exemplo conceitual de Swagger UI:
561
+
562
+ ```python
563
+ SWAGGER_UI_CONFIG = {
564
+ "url": "/openapi.json",
565
+ "dom_id": "#swagger-ui",
566
+ "deepLinking": True,
567
+ "persistAuthorization": True,
568
+ "displayRequestDuration": True,
569
+ }
570
+ ```
571
+
572
+ Se gerar HTML manualmente, configure `SwaggerUIBundle` com `persistAuthorization: true`.
573
+
574
+ Exemplo de template de uso por operação:
575
+
576
+ ```json
577
+ {
578
+ "paths": {
579
+ "/api/v1/clinics/{clinic_id}/services": {
580
+ "post": {
581
+ "summary": "Create service",
582
+ "security": [{ "bearerAuth": [] }],
583
+ "parameters": [
584
+ {
585
+ "name": "clinic_id",
586
+ "in": "path",
587
+ "required": true,
588
+ "schema": { "type": "integer" },
589
+ "example": 1
590
+ }
591
+ ],
592
+ "requestBody": {
593
+ "required": true,
594
+ "content": {
595
+ "application/json": {
596
+ "schema": { "$ref": "#/components/schemas/CreateServiceRequest" },
597
+ "examples": {
598
+ "default": {
599
+ "summary": "Create a standard service",
600
+ "value": {
601
+ "name": "Consulta inicial",
602
+ "description": "Primeira consulta da clínica",
603
+ "duration_minutes": 45,
604
+ "price_cents": 15000,
605
+ "currency": "BRL"
606
+ }
607
+ }
608
+ }
609
+ }
610
+ }
611
+ },
612
+ "responses": {
613
+ "201": {
614
+ "description": "Service created",
615
+ "content": {
616
+ "application/json": {
617
+ "examples": {
618
+ "created": {
619
+ "value": {
620
+ "id": 10,
621
+ "clinic_id": 1,
622
+ "name": "Consulta inicial",
623
+ "duration_minutes": 45,
624
+ "price_cents": 15000,
625
+ "currency": "BRL",
626
+ "status": "active"
627
+ }
628
+ }
629
+ }
630
+ }
631
+ }
632
+ }
633
+ }
634
+ }
635
+ }
636
+ }
637
+ }
638
+ ```
639
+
640
+ ---
641
+
642
+ ## Exceptions globais e continuidade de serviço
643
+
644
+ A aplicação deve ter uma hierarquia única de exceptions de aplicação.
645
+
646
+ Crie `src/shared/errors.py` com, no mínimo:
647
+
648
+ ```python
649
+ class AppError(Exception):
650
+ code = "APP_ERROR"
651
+ status_code = 500
652
+ retryable = False
653
+
654
+ def __init__(self, message: str, *, details: list[dict] | None = None):
655
+ super().__init__(message)
656
+ self.message = message
657
+ self.details = details or []
658
+
659
+
660
+ class ValidationAppError(AppError):
661
+ code = "VALIDATION_ERROR"
662
+ status_code = 400
663
+
664
+
665
+ class AuthenticationAppError(AppError):
666
+ code = "AUTHENTICATION_ERROR"
667
+ status_code = 401
668
+
669
+
670
+ class AuthorizationAppError(AppError):
671
+ code = "AUTHORIZATION_ERROR"
672
+ status_code = 403
673
+
674
+
675
+ class ConflictAppError(AppError):
676
+ code = "CONFLICT_ERROR"
677
+ status_code = 409
678
+
679
+
680
+ class ExternalDependencyAppError(AppError):
681
+ code = "EXTERNAL_DEPENDENCY_ERROR"
682
+ status_code = 503
683
+ retryable = True
684
+ ```
685
+
686
+ Regras de continuidade:
687
+
688
+ - REST deve converter toda exception conhecida em resposta JSON normalizada.
689
+ - REST deve capturar exception desconhecida, registrar com `correlation_id`, retornar erro genérico e manter o processo vivo.
690
+ - WebSocket deve capturar erro por mensagem, publicar evento `error.*` e continuar a conexão quando o erro for recuperável.
691
+ - Erro fatal de autenticação WebSocket deve encerrar a conexão com evento de erro normalizado.
692
+ - LangGraph node deve transformar falhas recuperáveis em estado de fallback, não em crash do grafo inteiro.
693
+ - Redis indisponível deve degradar cache/pub-sub de forma controlada quando o fluxo principal puder continuar.
694
+ - Falha de cache nunca pode impedir leitura/escrita transacional principal.
695
+ - Falha de pub/sub deve ser registrada e retornar erro retryable apenas quando a entrega do evento for parte da consistência do caso de uso.
696
+
697
+ ---
698
+
699
+ ## Redis obrigatório para cache e publish/subscribe
700
+
701
+ Redis deve ser acessado apenas por adapters em `infrastructure`.
702
+
703
+ Crie ports:
704
+
705
+ ```txt
706
+ application/shared/ports/cache_port.py
707
+ application/shared/ports/pubsub_port.py
708
+ ```
709
+
710
+ Contrato mínimo de cache:
711
+
712
+ ```python
713
+ from typing import Protocol
714
+
715
+
716
+ class CachePort(Protocol):
717
+ def get_json(self, key: str) -> dict | list | str | int | float | bool | None:
718
+ ...
719
+
720
+ def set_json(self, key: str, value: object, ttl_seconds: int) -> None:
721
+ ...
722
+
723
+ def delete(self, key: str) -> None:
724
+ ...
725
+ ```
726
+
727
+ Contrato mínimo de pub/sub:
728
+
729
+ ```python
730
+ from typing import Protocol
731
+
732
+
733
+ class PubSubPort(Protocol):
734
+ def publish(self, channel: str, payload: dict) -> None:
735
+ ...
736
+
737
+ def subscribe(self, channel: str):
738
+ ...
739
+ ```
740
+
741
+ Regras:
742
+
743
+ - Cache keys devem incluir `tenant_id`.
744
+ - Pub/sub channels devem incluir `tenant_id` quando carregarem evento tenant-scoped.
745
+ - Eventos de agendamento devem ser publicados em canal por tenant e por clínica quando necessário.
746
+ - WebSocket fan-out deve consumir eventos por canal e entregar para conexões registradas do mesmo tenant.
747
+ - Não publique payload bruto sensível.
748
+ - Não use Redis como fonte de verdade para entidades de negócio.
749
+ - Use Redis para cache, locks distribuídos, presença/conexões WebSocket, pub/sub de notificações e checkpointer do LangGraph.
750
+ - Use TTL explícito em todo cache.
751
+ - Falhas Redis devem ser encapsuladas como `ExternalDependencyAppError` quando afetarem o fluxo principal.
752
+
753
+ Exemplos de chaves e canais:
754
+
755
+ ```txt
756
+ cache:tenant:{tenant_id}:clinic:{clinic_id}:services:active
757
+ cache:tenant:{tenant_id}:appointment:{appointment_id}
758
+ pubsub:tenant:{tenant_id}:clinic:{clinic_id}:appointments
759
+ pubsub:tenant:{tenant_id}:ws:notifications
760
+ lock:tenant:{tenant_id}:clinic:{clinic_id}:professional:{professional_id}:slot:{slot_start_at}
761
+ ```
762
+
763
+ ---
764
+
765
+ ## Árvore de diretórios obrigatória
766
+
767
+ ```txt
768
+ api-clean-flask/
769
+ ├── README.md
770
+ ├── pyproject.toml
771
+ ├── .env.example
772
+ ├── Dockerfile
773
+ ├── docker-compose.yml
774
+ ├── alembic.ini
775
+ ├── migrations/
776
+ │ ├── env.py
777
+ │ └── versions/
778
+ ├── tests/
779
+ │ ├── unit/
780
+ │ │ ├── domain/
781
+ │ │ ├── application/
782
+ │ │ ├── security/
783
+ │ │ ├── cache/
784
+ │ │ └── agents/
785
+ │ ├── integration/
786
+ │ │ ├── http/
787
+ │ │ ├── websocket/
788
+ │ │ ├── persistence/
789
+ │ │ ├── redis/
790
+ │ │ └── security/
791
+ │ └── e2e/
792
+ │ ├── appointment_flow_test.py
793
+ │ └── websocket_agent_flow_test.py
794
+
795
+ └── src/
796
+ ├── main.py
797
+ ├── container.py
798
+
799
+ ├── config/
800
+ │ ├── settings.py
801
+ │ ├── logging.py
802
+ │ └── dependency_injection.py
803
+
804
+ ├── interfaces/
805
+ │ ├── http/
806
+ │ │ ├── app_factory.py
807
+ │ │ ├── error_handlers.py
808
+ │ │ ├── openapi/
809
+ │ │ │ ├── openapi_factory.py
810
+ │ │ │ ├── swagger_ui.py
811
+ │ │ │ ├── security_schemes.py
812
+ │ │ │ └── examples/
813
+ │ │ │ ├── clinic_examples.py
814
+ │ │ │ ├── service_examples.py
815
+ │ │ │ ├── appointment_examples.py
816
+ │ │ │ └── agent_examples.py
817
+ │ │ ├── middlewares/
818
+ │ │ │ ├── correlation_middleware.py
819
+ │ │ │ └── auth_middleware.py
820
+ │ │ ├── routes/
821
+ │ │ │ ├── docs_routes.py
822
+ │ │ │ ├── health_routes.py
823
+ │ │ │ ├── clinic_routes.py
824
+ │ │ │ ├── service_routes.py
825
+ │ │ │ ├── appointment_routes.py
826
+ │ │ │ └── agent_routes.py
827
+ │ │ ├── controllers/
828
+ │ │ │ ├── health_controller.py
829
+ │ │ │ ├── clinic_http_controller.py
830
+ │ │ │ ├── service_http_controller.py
831
+ │ │ │ ├── appointment_http_controller.py
832
+ │ │ │ └── agent_http_controller.py
833
+ │ │ └── schemas/
834
+ │ │ ├── clinic_http_schema.py
835
+ │ │ ├── service_http_schema.py
836
+ │ │ ├── appointment_http_schema.py
837
+ │ │ ├── auth_http_schema.py
838
+ │ │ └── agent_http_schema.py
839
+ │ │
840
+ │ └── websocket/
841
+ │ ├── socket_factory.py
842
+ │ ├── websocket_event_dispatcher.py
843
+ │ ├── connection_registry.py
844
+ │ ├── websocket_auth.py
845
+ │ ├── websocket_error_boundary.py
846
+ │ ├── routes/
847
+ │ │ ├── chat_ws_routes.py
848
+ │ │ ├── appointment_ws_routes.py
849
+ │ │ └── notification_ws_routes.py
850
+ │ ├── controllers/
851
+ │ │ ├── chat_ws_controller.py
852
+ │ │ ├── appointment_ws_controller.py
853
+ │ │ └── notification_ws_controller.py
854
+ │ └── schemas/
855
+ │ ├── websocket_envelope_schema.py
856
+ │ ├── chat_ws_schema.py
857
+ │ └── appointment_ws_schema.py
858
+
859
+ ├── application/
860
+ │ ├── security/
861
+ │ │ ├── dto/
862
+ │ │ │ └── current_principal.py
863
+ │ │ ├── ports/
864
+ │ │ │ ├── token_verifier_port.py
865
+ │ │ │ └── permission_checker_port.py
866
+ │ │ └── use_cases/
867
+ │ │ └── verify_access_token_use_case.py
868
+ │ │
869
+ │ ├── shared/
870
+ │ │ ├── dto/
871
+ │ │ │ └── tenant_context.py
872
+ │ │ └── ports/
873
+ │ │ ├── cache_port.py
874
+ │ │ └── pubsub_port.py
875
+ │ │
876
+ │ ├── clinics/
877
+ │ │ ├── dto/
878
+ │ │ │ ├── create_clinic_input.py
879
+ │ │ │ ├── update_clinic_input.py
880
+ │ │ │ └── clinic_output.py
881
+ │ │ ├── ports/
882
+ │ │ │ ├── clinic_repository_port.py
883
+ │ │ │ └── clinic_reader_port.py
884
+ │ │ └── use_cases/
885
+ │ │ ├── create_clinic_use_case.py
886
+ │ │ ├── update_clinic_use_case.py
887
+ │ │ ├── get_clinic_use_case.py
888
+ │ │ ├── activate_clinic_use_case.py
889
+ │ │ └── deactivate_clinic_use_case.py
890
+ │ │
891
+ │ ├── services/
892
+ │ │ ├── dto/
893
+ │ │ │ ├── create_service_input.py
894
+ │ │ │ ├── update_service_input.py
895
+ │ │ │ └── service_output.py
896
+ │ │ ├── ports/
897
+ │ │ │ ├── service_repository_port.py
898
+ │ │ │ └── service_reader_port.py
899
+ │ │ └── use_cases/
900
+ │ │ ├── create_service_use_case.py
901
+ │ │ ├── update_service_use_case.py
902
+ │ │ ├── get_service_use_case.py
903
+ │ │ ├── list_services_by_clinic_use_case.py
904
+ │ │ ├── activate_service_use_case.py
905
+ │ │ └── deactivate_service_use_case.py
906
+ │ │
907
+ │ ├── appointments/
908
+ │ │ ├── dto/
909
+ │ │ │ ├── create_appointment_input.py
910
+ │ │ │ ├── reschedule_appointment_input.py
911
+ │ │ │ ├── appointment_output.py
912
+ │ │ │ └── available_slot_output.py
913
+ │ │ ├── ports/
914
+ │ │ │ ├── appointment_repository_port.py
915
+ │ │ │ ├── appointment_reader_port.py
916
+ │ │ │ ├── schedule_lock_port.py
917
+ │ │ │ └── appointment_event_publisher_port.py
918
+ │ │ └── use_cases/
919
+ │ │ ├── search_available_slots_use_case.py
920
+ │ │ ├── create_appointment_use_case.py
921
+ │ │ ├── confirm_appointment_use_case.py
922
+ │ │ ├── cancel_appointment_use_case.py
923
+ │ │ ├── reschedule_appointment_use_case.py
924
+ │ │ ├── complete_appointment_use_case.py
925
+ │ │ └── register_no_show_use_case.py
926
+ │ │
927
+ │ └── agents/
928
+ │ ├── dto/
929
+ │ │ ├── agent_input.py
930
+ │ │ ├── agent_output.py
931
+ │ │ ├── agent_context.py
932
+ │ │ └── agent_stream_event.py
933
+ │ ├── ports/
934
+ │ │ ├── agent_orchestrator_port.py
935
+ │ │ ├── agent_memory_port.py
936
+ │ │ ├── chat_model_port.py
937
+ │ │ └── agent_event_publisher_port.py
938
+ │ └── use_cases/
939
+ │ ├── process_agent_message_use_case.py
940
+ │ ├── continue_agent_conversation_use_case.py
941
+ │ └── stream_agent_response_use_case.py
942
+
943
+ ├── domain/
944
+ │ ├── clinics/
945
+ │ │ ├── entities/
946
+ │ │ │ └── clinic.py
947
+ │ │ ├── value_objects/
948
+ │ │ │ ├── clinic_id.py
949
+ │ │ │ ├── clinic_document.py
950
+ │ │ │ └── clinic_status.py
951
+ │ │ ├── services/
952
+ │ │ │ └── clinic_policy.py
953
+ │ │ └── exceptions.py
954
+ │ │
955
+ │ ├── services/
956
+ │ │ ├── entities/
957
+ │ │ │ └── service.py
958
+ │ │ ├── value_objects/
959
+ │ │ │ ├── service_id.py
960
+ │ │ │ ├── service_duration.py
961
+ │ │ │ ├── service_price.py
962
+ │ │ │ └── service_status.py
963
+ │ │ ├── services/
964
+ │ │ │ └── service_policy.py
965
+ │ │ └── exceptions.py
966
+ │ │
967
+ │ └── appointments/
968
+ │ ├── entities/
969
+ │ │ ├── appointment.py
970
+ │ │ └── appointment_slot.py
971
+ │ ├── value_objects/
972
+ │ │ ├── appointment_id.py
973
+ │ │ ├── appointment_status.py
974
+ │ │ ├── appointment_period.py
975
+ │ │ └── appointment_reason.py
976
+ │ ├── services/
977
+ │ │ ├── appointment_policy.py
978
+ │ │ └── appointment_conflict_checker.py
979
+ │ └── exceptions.py
980
+
981
+ ├── infrastructure/
982
+ │ ├── persistence/
983
+ │ │ ├── database.py
984
+ │ │ ├── base.py
985
+ │ │ ├── models/
986
+ │ │ │ ├── clinic_model.py
987
+ │ │ │ ├── service_model.py
988
+ │ │ │ ├── appointment_model.py
989
+ │ │ │ └── schedule_slot_lock_model.py
990
+ │ │ ├── repositories/
991
+ │ │ │ ├── sqlalchemy_clinic_repository.py
992
+ │ │ │ ├── sqlalchemy_service_repository.py
993
+ │ │ │ └── sqlalchemy_appointment_repository.py
994
+ │ │ └── unit_of_work/
995
+ │ │ └── sqlalchemy_unit_of_work.py
996
+ │ │
997
+ │ ├── websocket/
998
+ │ │ ├── websocket_connection_manager.py
999
+ │ │ └── websocket_event_publisher.py
1000
+ │ │
1001
+ │ ├── events/
1002
+ │ │ ├── domain_event_bus.py
1003
+ │ │ ├── event_publisher.py
1004
+ │ │ ├── redis_pubsub.py
1005
+ │ │ ├── redis_event_publisher.py
1006
+ │ │ └── event_handlers/
1007
+ │ │ ├── appointment_created_handler.py
1008
+ │ │ ├── appointment_confirmed_handler.py
1009
+ │ │ └── appointment_cancelled_handler.py
1010
+ │ │
1011
+ │ ├── ai/
1012
+ │ │ ├── llm/
1013
+ │ │ │ ├── chat_model_provider.py
1014
+ │ │ │ ├── langchain_chat_model_provider.py
1015
+ │ │ │ ├── fake_chat_model_provider.py
1016
+ │ │ │ └── provider_factory.py
1017
+ │ │ │
1018
+ │ │ └── langgraph/
1019
+ │ │ ├── graph_factory.py
1020
+ │ │ ├── state.py
1021
+ │ │ ├── checkpoints/
1022
+ │ │ │ ├── checkpoint_factory.py
1023
+ │ │ │ └── redis_checkpointer.py
1024
+ │ │ ├── orchestrator/
1025
+ │ │ │ └── langgraph_agent_orchestrator.py
1026
+ │ │ ├── nodes/
1027
+ │ │ │ ├── load_context_node.py
1028
+ │ │ │ ├── guardrail_node.py
1029
+ │ │ │ ├── router_node.py
1030
+ │ │ │ ├── clinic_agent_node.py
1031
+ │ │ │ ├── service_agent_node.py
1032
+ │ │ │ ├── appointment_agent_node.py
1033
+ │ │ │ ├── fallback_agent_node.py
1034
+ │ │ │ ├── response_composer_node.py
1035
+ │ │ │ └── publish_response_node.py
1036
+ │ │ ├── tools/
1037
+ │ │ │ ├── clinic_tools.py
1038
+ │ │ │ ├── service_tools.py
1039
+ │ │ │ └── appointment_tools.py
1040
+ │ │ └── prompts/
1041
+ │ │ ├── router_prompt.py
1042
+ │ │ ├── clinic_agent_prompt.py
1043
+ │ │ ├── service_agent_prompt.py
1044
+ │ │ ├── appointment_agent_prompt.py
1045
+ │ │ └── response_composer_prompt.py
1046
+ │ │
1047
+ │ ├── security/
1048
+ │ │ ├── jwt_token_verifier.py
1049
+ │ │ └── jwt_permission_checker.py
1050
+ │ │
1051
+ │ └── cache/
1052
+ │ ├── redis_client.py
1053
+ │ ├── redis_cache_adapter.py
1054
+ │ ├── redis_session_store.py
1055
+ │ └── redis_lock_adapter.py
1056
+
1057
+ └── shared/
1058
+ ├── errors.py
1059
+ ├── result.py
1060
+ ├── clock.py
1061
+ ├── correlation.py
1062
+ ├── pagination.py
1063
+ ├── id_generator.py
1064
+ └── serialization.py
1065
+ ```
1066
+
1067
+ ---
1068
+
1069
+ ## Convenção de IDs sequenciais
1070
+
1071
+ Este projeto usa IDs sequenciais nas tabelas relacionais.
1072
+
1073
+ Regras:
1074
+
1075
+ 1. Toda tabela de negócio deve ter `id` sequencial como chave primária.
1076
+ 2. Use `BIGINT` como padrão para chaves primárias.
1077
+ 3. Em SQLite de desenvolvimento, use variante `Integer` para preservar autoincremento compatível.
1078
+ 4. Não use UUID como chave primária por padrão.
1079
+ 5. Não gere IDs na aplicação para as entidades relacionais principais.
1080
+ 6. O banco deve gerar o ID.
1081
+ 7. Endpoints com ID sequencial devem ser sempre escopados por clínica, tenant ou permissão.
1082
+ 8. Toda entity ORM de negócio deve ter `tenant_id` obrigatório, não nulo e indexado.
1083
+ 9. `tenant_id` deve vir do JWT validado, nunca do body como fonte de autoridade.
1084
+ 10. Nunca consulte `clinic_id`, `appointment_id`, `service_id`, `professional_id` ou qualquer ID sequencial isolado sem filtrar também por `tenant_id`.
1085
+ 11. Queries por `clinic_id` continuam obrigatórias quando a rota for de clínica, mas `clinic_id` não substitui `tenant_id`.
1086
+
1087
+ Exemplo de rotas preferidas:
1088
+
1089
+ ```txt
1090
+ GET /api/v1/clinics/{clinic_id}/services/{service_id}
1091
+ GET /api/v1/clinics/{clinic_id}/appointments/{appointment_id}
1092
+ PATCH /api/v1/clinics/{clinic_id}/appointments/{appointment_id}/cancel
1093
+ ```
1094
+
1095
+ Evite como padrão:
1096
+
1097
+ ```txt
1098
+ GET /api/v1/appointments/{appointment_id}
1099
+ ```
1100
+
1101
+ A exceção só é aceita se houver validação forte de autorização e escopo.
1102
+
1103
+ ---
1104
+
1105
+ ## Paginação obrigatória em listagens
1106
+
1107
+ Todo endpoint ou use case de listagem deve aceitar paginação por query:
1108
+
1109
+ ```txt
1110
+ ?limit=20&offset=0&search=consulta
1111
+ ```
1112
+
1113
+ Regras:
1114
+
1115
+ - `limit` define o tamanho máximo da página.
1116
+ - `offset` define quantos registros devem ser pulados.
1117
+ - `search` é opcional; quando ausente, vazio ou só whitespace, não aplica filtro textual.
1118
+ - Quando `search` vier preenchido, aplique condição equivalente a `LIKE '%<search>%'` em todos os campos de texto pesquisáveis da listagem.
1119
+ - O filtro textual deve ser construído com bind parameters do SQLAlchemy; nunca interpole string manualmente em SQL.
1120
+ - Escape `%`, `_` e caractere de escape quando o comportamento esperado for busca literal.
1121
+ - Para PostgreSQL, `ilike` pode ser usado para busca case-insensitive.
1122
+ - Para MySQL, valide collation; se a collation já for case-insensitive, `like` pode ser suficiente.
1123
+ - Use `offset`, não `office`.
1124
+ - Defina limite padrão seguro, por exemplo `limit=20`.
1125
+ - Defina limite máximo, por exemplo `limit<=100`.
1126
+ - `count` deve representar o total de registros que satisfazem o filtro, sem aplicar `limit/offset`.
1127
+ - `pages` deve ser calculado como `ceil(count / limit)`.
1128
+ - `data` deve conter apenas os itens da página atual.
1129
+ - Toda query paginada deve manter filtro por `tenant_id`.
1130
+ - Toda query com `search` deve manter filtro por `tenant_id`.
1131
+ - Ordenação padrão deve ser determinística, por exemplo `created_at desc, id desc`.
1132
+
1133
+ Envelope obrigatório de resposta:
1134
+
1135
+ ```json
1136
+ {
1137
+ "pages": 5,
1138
+ "count": 95,
1139
+ "limit": 20,
1140
+ "offset": 0,
1141
+ "data": []
1142
+ }
1143
+ ```
1144
+
1145
+ Exemplo de campos textuais pesquisáveis:
1146
+
1147
+ ```txt
1148
+ clinics: name, document, status
1149
+ services: name, description, currency, status
1150
+ appointments: status, cancellation_reason
1151
+ ```
1152
+
1153
+ Crie helper tipado em `src/shared/pagination.py`:
1154
+
1155
+ ```python
1156
+ from dataclasses import dataclass
1157
+ from math import ceil
1158
+ from typing import Generic, TypeVar
1159
+
1160
+ T = TypeVar("T")
1161
+
1162
+
1163
+ @dataclass(frozen=True)
1164
+ class Page(Generic[T]):
1165
+ pages: int
1166
+ count: int
1167
+ limit: int
1168
+ offset: int
1169
+ data: list[T]
1170
+
1171
+ @classmethod
1172
+ def create(cls, *, count: int, limit: int, offset: int, data: list[T]) -> "Page[T]":
1173
+ pages = ceil(count / limit) if limit > 0 else 0
1174
+ return cls(pages=pages, count=count, limit=limit, offset=offset, data=data)
1175
+ ```
1176
+
1177
+ Regra para repositories:
1178
+
1179
+ ```txt
1180
+ Correto:
1181
+ - find_by_id(tenant_id, clinic_id, service_id)
1182
+ - list_by_clinic(tenant_id, clinic_id)
1183
+ - exists_conflict(tenant_id, clinic_id, professional_id, period)
1184
+
1185
+ Proibido:
1186
+ - find_by_id(service_id)
1187
+ - list_by_clinic(clinic_id) sem tenant_id
1188
+ - exists_conflict(professional_id, period) sem tenant_id e clinic_id
1189
+ ```
1190
+
1191
+ ---
1192
+
1193
+ ## Base ORM obrigatória
1194
+
1195
+ Crie `src/infrastructure/persistence/base.py`:
1196
+
1197
+ ```python
1198
+ from datetime import UTC, datetime
1199
+
1200
+ from sqlalchemy import BigInteger, DateTime, Integer, MetaData, func
1201
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
1202
+ from sqlalchemy.schema import Identity
1203
+
1204
+ NAMING_CONVENTION = {
1205
+ "ix": "ix_%(table_name)s_%(column_0_name)s",
1206
+ "uq": "uq_%(table_name)s_%(column_0_name)s",
1207
+ "ck": "ck_%(table_name)s_%(constraint_name)s",
1208
+ "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
1209
+ "pk": "pk_%(table_name)s",
1210
+ }
1211
+
1212
+ # SQLite exige INTEGER PRIMARY KEY para o comportamento de autoincremento mais previsível.
1213
+ ID_TYPE = BigInteger().with_variant(Integer, "sqlite")
1214
+
1215
+
1216
+ class Base(DeclarativeBase):
1217
+ metadata = MetaData(naming_convention=NAMING_CONVENTION)
1218
+
1219
+
1220
+ class SequentialIdMixin:
1221
+ id: Mapped[int] = mapped_column(
1222
+ ID_TYPE,
1223
+ Identity(always=False),
1224
+ primary_key=True,
1225
+ )
1226
+
1227
+
1228
+ class TenantScopedMixin:
1229
+ tenant_id: Mapped[int] = mapped_column(
1230
+ ID_TYPE,
1231
+ nullable=False,
1232
+ index=True,
1233
+ )
1234
+
1235
+
1236
+ class AuditMixin:
1237
+ created_at: Mapped[datetime] = mapped_column(
1238
+ DateTime(timezone=True),
1239
+ nullable=False,
1240
+ server_default=func.now(),
1241
+ )
1242
+ updated_at: Mapped[datetime] = mapped_column(
1243
+ DateTime(timezone=True),
1244
+ nullable=False,
1245
+ server_default=func.now(),
1246
+ onupdate=func.now(),
1247
+ )
1248
+ deleted_at: Mapped[datetime | None] = mapped_column(
1249
+ DateTime(timezone=True),
1250
+ nullable=True,
1251
+ )
1252
+
1253
+ def mark_deleted(self) -> None:
1254
+ self.deleted_at = datetime.now(UTC)
1255
+ ```
1256
+
1257
+ Regras:
1258
+
1259
+ - Todo modelo ORM de negócio deve herdar `TenantScopedMixin`.
1260
+ - Todo modelo ORM de negócio deve herdar `AuditMixin`.
1261
+ - `created_at` deve ser preenchido automaticamente na criação pelo banco.
1262
+ - `updated_at` deve ser preenchido automaticamente na criação e atualizado automaticamente a cada update.
1263
+ - `deleted_at` deve representar soft delete; não remova fisicamente registros de negócio por padrão.
1264
+ - Soft delete deve alterar `deleted_at` e fazer a operação passar pelo Unit of Work para atualizar `updated_at`.
1265
+ - `tenant_id` deve ser preenchido pela camada `application` a partir do principal JWT validado.
1266
+ - `tenant_id` não deve ser opcional, nullable ou calculado pelo banco.
1267
+ - Índices compostos de leitura devem começar com `tenant_id`.
1268
+ - Constraints únicas tenant-scoped devem incluir `tenant_id`.
1269
+ - Soft delete não remove a obrigação de filtrar por `tenant_id`.
1270
+
1271
+ ---
1272
+
1273
+ ## Database engine e session factory
1274
+
1275
+ Crie `src/infrastructure/persistence/database.py`:
1276
+
1277
+ ```python
1278
+ from collections.abc import Generator
1279
+
1280
+ from sqlalchemy import create_engine
1281
+ from sqlalchemy.engine import Engine
1282
+ from sqlalchemy.orm import Session, sessionmaker
1283
+
1284
+
1285
+ class Database:
1286
+ def __init__(self, database_url: str, echo: bool = False):
1287
+ self.engine: Engine = create_engine(
1288
+ database_url,
1289
+ echo=echo,
1290
+ pool_pre_ping=True,
1291
+ future=True,
1292
+ )
1293
+ self.session_factory = sessionmaker(
1294
+ bind=self.engine,
1295
+ autoflush=False,
1296
+ autocommit=False,
1297
+ expire_on_commit=False,
1298
+ class_=Session,
1299
+ )
1300
+
1301
+ def session(self) -> Generator[Session, None, None]:
1302
+ db_session = self.session_factory()
1303
+ try:
1304
+ yield db_session
1305
+ finally:
1306
+ db_session.close()
1307
+ ```
1308
+
1309
+ Regras:
1310
+
1311
+ - Nunca injete `Session` diretamente em controller.
1312
+ - Nunca armazene `Session` em conexão WebSocket.
1313
+ - Nunca faça `commit()` dentro de repository.
1314
+ - Use Unit of Work para controlar transação.
1315
+ - Para WebSocket, abra uma Unit of Work por mensagem processada, não por conexão.
1316
+
1317
+ ---
1318
+
1319
+ ## Bancos relacionais suportados
1320
+
1321
+ A skill deve gerar código compatível com:
1322
+
1323
+ - PostgreSQL
1324
+ - MySQL
1325
+ - SQLite apenas para desenvolvimento local leve ou testes rápidos
1326
+
1327
+ Regras:
1328
+
1329
+ - `DATABASE_URL` deve aceitar pelo menos:
1330
+ - `postgresql+psycopg://user:pass@host:5432/dbname`
1331
+ - `mysql+pymysql://user:pass@host:3306/dbname`
1332
+ - `sqlite:///local.db`
1333
+ - Models devem evitar tipos e constraints que funcionem apenas em um dialeto, salvo migration manual explícita.
1334
+ - `DateTime(timezone=True)` deve ser normalizado na aplicação quando o dialeto não preservar timezone nativamente.
1335
+ - Tamanho de índices compostos deve considerar limites de MySQL.
1336
+ - Constraints de status podem usar `CheckConstraint`, mas migrations devem validar suporte real no MySQL alvo.
1337
+ - Testes de repository e Unit of Work devem rodar contra SQLite rápido e devem ter perfil de integração para PostgreSQL e MySQL.
1338
+ - Em produção, prefira PostgreSQL quando houver conflito entre recursos avançados de banco.
1339
+ - Diferenças de dialeto devem ficar em migrations ou adapters de infrastructure, nunca em domain/application.
1340
+
1341
+ ---
1342
+
1343
+ ## Modelos ORM mínimos
1344
+
1345
+ ### ClinicModel
1346
+
1347
+ `src/infrastructure/persistence/models/clinic_model.py`:
1348
+
1349
+ ```python
1350
+ from sqlalchemy import CheckConstraint, Index, String, UniqueConstraint
1351
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
1352
+
1353
+ from src.infrastructure.persistence.base import AuditMixin, Base, SequentialIdMixin, TenantScopedMixin
1354
+
1355
+
1356
+ class ClinicModel(Base, SequentialIdMixin, TenantScopedMixin, AuditMixin):
1357
+ __tablename__ = "clinics"
1358
+
1359
+ name: Mapped[str] = mapped_column(String(180), nullable=False)
1360
+ document: Mapped[str | None] = mapped_column(String(32), nullable=True)
1361
+ status: Mapped[str] = mapped_column(String(32), nullable=False, default="active")
1362
+
1363
+ services = relationship(
1364
+ "ServiceModel",
1365
+ back_populates="clinic",
1366
+ lazy="selectin",
1367
+ )
1368
+
1369
+ appointments = relationship(
1370
+ "AppointmentModel",
1371
+ back_populates="clinic",
1372
+ lazy="selectin",
1373
+ )
1374
+
1375
+ __table_args__ = (
1376
+ CheckConstraint(
1377
+ "status in ('active', 'inactive', 'suspended')",
1378
+ name="clinic_status_valid",
1379
+ ),
1380
+ UniqueConstraint("tenant_id", "document", name="uq_clinics_tenant_document"),
1381
+ Index("ix_clinics_tenant_id", "tenant_id"),
1382
+ Index("ix_clinics_tenant_status", "tenant_id", "status"),
1383
+ Index("ix_clinics_tenant_document", "tenant_id", "document"),
1384
+ )
1385
+ ```
1386
+
1387
+ ### ServiceModel
1388
+
1389
+ `src/infrastructure/persistence/models/service_model.py`:
1390
+
1391
+ ```python
1392
+ from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, String, UniqueConstraint
1393
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
1394
+
1395
+ from src.infrastructure.persistence.base import AuditMixin, Base, ID_TYPE, SequentialIdMixin, TenantScopedMixin
1396
+
1397
+
1398
+ class ServiceModel(Base, SequentialIdMixin, TenantScopedMixin, AuditMixin):
1399
+ __tablename__ = "services"
1400
+
1401
+ clinic_id: Mapped[int] = mapped_column(
1402
+ ID_TYPE,
1403
+ ForeignKey("clinics.id", ondelete="RESTRICT"),
1404
+ nullable=False,
1405
+ )
1406
+ name: Mapped[str] = mapped_column(String(180), nullable=False)
1407
+ description: Mapped[str | None] = mapped_column(String(500), nullable=True)
1408
+ duration_minutes: Mapped[int] = mapped_column(Integer, nullable=False)
1409
+ price_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
1410
+ currency: Mapped[str] = mapped_column(String(3), nullable=False, default="BRL")
1411
+ status: Mapped[str] = mapped_column(String(32), nullable=False, default="active")
1412
+
1413
+ clinic = relationship("ClinicModel", back_populates="services", lazy="joined")
1414
+ appointments = relationship("AppointmentModel", back_populates="service", lazy="selectin")
1415
+
1416
+ __table_args__ = (
1417
+ CheckConstraint("duration_minutes > 0", name="service_duration_positive"),
1418
+ CheckConstraint("price_cents >= 0", name="service_price_non_negative"),
1419
+ CheckConstraint(
1420
+ "status in ('active', 'inactive', 'archived')",
1421
+ name="service_status_valid",
1422
+ ),
1423
+ UniqueConstraint("tenant_id", "clinic_id", "name", name="uq_services_tenant_clinic_name"),
1424
+ Index("ix_services_tenant_clinic", "tenant_id", "clinic_id"),
1425
+ Index("ix_services_tenant_clinic_status", "tenant_id", "clinic_id", "status"),
1426
+ )
1427
+ ```
1428
+
1429
+ ### AppointmentModel
1430
+
1431
+ `src/infrastructure/persistence/models/appointment_model.py`:
1432
+
1433
+ ```python
1434
+ from datetime import datetime
1435
+
1436
+ from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String
1437
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
1438
+
1439
+ from src.infrastructure.persistence.base import AuditMixin, Base, ID_TYPE, SequentialIdMixin, TenantScopedMixin
1440
+
1441
+
1442
+ class AppointmentModel(Base, SequentialIdMixin, TenantScopedMixin, AuditMixin):
1443
+ __tablename__ = "appointments"
1444
+
1445
+ clinic_id: Mapped[int] = mapped_column(
1446
+ ID_TYPE,
1447
+ ForeignKey("clinics.id", ondelete="RESTRICT"),
1448
+ nullable=False,
1449
+ )
1450
+ service_id: Mapped[int] = mapped_column(
1451
+ ID_TYPE,
1452
+ ForeignKey("services.id", ondelete="RESTRICT"),
1453
+ nullable=False,
1454
+ )
1455
+ customer_id: Mapped[int] = mapped_column(ID_TYPE, nullable=False)
1456
+ professional_id: Mapped[int] = mapped_column(ID_TYPE, nullable=False)
1457
+
1458
+ start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
1459
+ end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
1460
+ status: Mapped[str] = mapped_column(String(32), nullable=False, default="reserved")
1461
+ cancellation_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
1462
+
1463
+ clinic = relationship("ClinicModel", back_populates="appointments", lazy="joined")
1464
+ service = relationship("ServiceModel", back_populates="appointments", lazy="joined")
1465
+ slot_locks = relationship(
1466
+ "ScheduleSlotLockModel",
1467
+ back_populates="appointment",
1468
+ lazy="selectin",
1469
+ )
1470
+
1471
+ __table_args__ = (
1472
+ CheckConstraint("end_at > start_at", name="appointment_period_valid"),
1473
+ CheckConstraint(
1474
+ "status in ('requested', 'reserved', 'confirmed', 'rescheduled', 'cancelled', 'completed', 'no_show', 'expired')",
1475
+ name="appointment_status_valid",
1476
+ ),
1477
+ Index("ix_appointments_tenant_clinic", "tenant_id", "clinic_id"),
1478
+ Index("ix_appointments_tenant_service", "tenant_id", "service_id"),
1479
+ Index("ix_appointments_tenant_customer", "tenant_id", "customer_id"),
1480
+ Index(
1481
+ "ix_appointments_tenant_professional_period",
1482
+ "tenant_id",
1483
+ "professional_id",
1484
+ "start_at",
1485
+ "end_at",
1486
+ ),
1487
+ Index("ix_appointments_tenant_clinic_status", "tenant_id", "clinic_id", "status"),
1488
+ )
1489
+ ```
1490
+
1491
+ ### ScheduleSlotLockModel
1492
+
1493
+ Use uma tabela de locks por slot discreto para evitar conflito de agendamento de forma portável entre bancos relacionais.
1494
+
1495
+ `src/infrastructure/persistence/models/schedule_slot_lock_model.py`:
1496
+
1497
+ ```python
1498
+ from datetime import datetime
1499
+
1500
+ from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, UniqueConstraint
1501
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
1502
+
1503
+ from src.infrastructure.persistence.base import AuditMixin, Base, ID_TYPE, SequentialIdMixin, TenantScopedMixin
1504
+
1505
+
1506
+ class ScheduleSlotLockModel(Base, SequentialIdMixin, TenantScopedMixin, AuditMixin):
1507
+ __tablename__ = "schedule_slot_locks"
1508
+
1509
+ clinic_id: Mapped[int] = mapped_column(
1510
+ ID_TYPE,
1511
+ ForeignKey("clinics.id", ondelete="RESTRICT"),
1512
+ nullable=False,
1513
+ )
1514
+ professional_id: Mapped[int] = mapped_column(ID_TYPE, nullable=False)
1515
+ appointment_id: Mapped[int | None] = mapped_column(
1516
+ ID_TYPE,
1517
+ ForeignKey("appointments.id", ondelete="CASCADE"),
1518
+ nullable=True,
1519
+ )
1520
+ slot_start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
1521
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
1522
+ status: Mapped[str] = mapped_column(String(32), nullable=False, default="locked")
1523
+
1524
+ appointment = relationship("AppointmentModel", back_populates="slot_locks", lazy="joined")
1525
+
1526
+ __table_args__ = (
1527
+ UniqueConstraint(
1528
+ "tenant_id",
1529
+ "clinic_id",
1530
+ "professional_id",
1531
+ "slot_start_at",
1532
+ name="uq_schedule_slot_locks_tenant_resource_slot",
1533
+ ),
1534
+ CheckConstraint(
1535
+ "status in ('locked', 'confirmed', 'released', 'expired')",
1536
+ name="schedule_slot_lock_status_valid",
1537
+ ),
1538
+ Index(
1539
+ "ix_schedule_slot_locks_tenant_clinic_professional",
1540
+ "tenant_id",
1541
+ "clinic_id",
1542
+ "professional_id",
1543
+ ),
1544
+ Index("ix_schedule_slot_locks_tenant_expires_at", "tenant_id", "expires_at"),
1545
+ Index("ix_schedule_slot_locks_tenant_appointment", "tenant_id", "appointment_id"),
1546
+ )
1547
+ ```
1548
+
1549
+ Regra de negócio para locks:
1550
+
1551
+ - Converta o período do agendamento em slots discretos, por exemplo, de 15 em 15 minutos.
1552
+ - Insira uma linha em `schedule_slot_locks` para cada slot.
1553
+ - Cada lock deve carregar o mesmo `tenant_id` do agendamento.
1554
+ - A constraint única por `tenant_id`, `clinic_id`, `professional_id` e `slot_start_at` impede dois agendamentos para o mesmo profissional no mesmo slot dentro do tenant.
1555
+ - A operação deve ocorrer em transação.
1556
+ - Se qualquer insert falhar por violação da constraint única, reverta a transação e retorne conflito de agenda.
1557
+ - Se Redis lock distribuído for usado antes da transação, ele é otimização de concorrência, não substitui a constraint relacional.
1558
+
1559
+ ---
1560
+
1561
+ ## Unit of Work obrigatório
1562
+
1563
+ `src/infrastructure/persistence/unit_of_work/sqlalchemy_unit_of_work.py`:
1564
+
1565
+ ```python
1566
+ from types import TracebackType
1567
+ from typing import Self
1568
+
1569
+ from sqlalchemy.orm import Session, sessionmaker
1570
+
1571
+ from src.infrastructure.persistence.repositories.sqlalchemy_appointment_repository import (
1572
+ SqlAlchemyAppointmentRepository,
1573
+ )
1574
+ from src.infrastructure.persistence.repositories.sqlalchemy_clinic_repository import (
1575
+ SqlAlchemyClinicRepository,
1576
+ )
1577
+ from src.infrastructure.persistence.repositories.sqlalchemy_service_repository import (
1578
+ SqlAlchemyServiceRepository,
1579
+ )
1580
+
1581
+
1582
+ class SqlAlchemyUnitOfWork:
1583
+ def __init__(self, session_factory: sessionmaker[Session]):
1584
+ self._session_factory = session_factory
1585
+ self.session: Session | None = None
1586
+
1587
+ def __enter__(self) -> Self:
1588
+ self.session = self._session_factory()
1589
+ self.clinics = SqlAlchemyClinicRepository(self.session)
1590
+ self.services = SqlAlchemyServiceRepository(self.session)
1591
+ self.appointments = SqlAlchemyAppointmentRepository(self.session)
1592
+ return self
1593
+
1594
+ def __exit__(
1595
+ self,
1596
+ exc_type: type[BaseException] | None,
1597
+ exc: BaseException | None,
1598
+ tb: TracebackType | None,
1599
+ ) -> None:
1600
+ if self.session is None:
1601
+ return
1602
+
1603
+ if exc_type is not None:
1604
+ self.rollback()
1605
+
1606
+ self.session.close()
1607
+
1608
+ def commit(self) -> None:
1609
+ if self.session is None:
1610
+ raise RuntimeError("UnitOfWork session was not initialized")
1611
+ self.session.commit()
1612
+
1613
+ def rollback(self) -> None:
1614
+ if self.session is None:
1615
+ raise RuntimeError("UnitOfWork session was not initialized")
1616
+ self.session.rollback()
1617
+ ```
1618
+
1619
+ Regras:
1620
+
1621
+ - Use case abre Unit of Work.
1622
+ - Use case decide quando commitar.
1623
+ - Repository nunca commita.
1624
+ - Repository pode usar `flush()` quando precisa obter `id` antes do commit.
1625
+ - Erro de banco deve ser convertido em erro de aplicação na camada de infrastructure ou application.
1626
+
1627
+ ---
1628
+
1629
+ ## App Factory Flask
1630
+
1631
+ `src/interfaces/http/app_factory.py`:
1632
+
1633
+ ```python
1634
+ from flask import Flask
1635
+ from flask_sock import Sock
1636
+
1637
+ from src.config.settings import Settings
1638
+ from src.container import build_container
1639
+ from src.interfaces.http.error_handlers import register_error_handlers
1640
+ from src.interfaces.http.routes.agent_routes import agent_bp
1641
+ from src.interfaces.http.routes.appointment_routes import appointment_bp
1642
+ from src.interfaces.http.routes.clinic_routes import clinic_bp
1643
+ from src.interfaces.http.routes.health_routes import health_bp
1644
+ from src.interfaces.http.routes.service_routes import service_bp
1645
+ from src.interfaces.websocket.routes.chat_ws_routes import register_chat_ws_routes
1646
+
1647
+
1648
+ def create_app(settings: Settings | None = None) -> Flask:
1649
+ app = Flask(__name__)
1650
+ settings = settings or Settings.from_env()
1651
+ container = build_container(settings)
1652
+
1653
+ app.config["SETTINGS"] = settings
1654
+ app.extensions["container"] = container
1655
+
1656
+ app.register_blueprint(health_bp, url_prefix="/health")
1657
+ app.register_blueprint(clinic_bp, url_prefix="/api/v1/clinics")
1658
+ app.register_blueprint(service_bp, url_prefix="/api/v1/clinics")
1659
+ app.register_blueprint(appointment_bp, url_prefix="/api/v1/clinics")
1660
+ app.register_blueprint(agent_bp, url_prefix="/api/v1/agents")
1661
+
1662
+ register_error_handlers(app)
1663
+
1664
+ sock = Sock()
1665
+ sock.init_app(app)
1666
+ register_chat_ws_routes(sock, app)
1667
+
1668
+ return app
1669
+ ```
1670
+
1671
+ `src/main.py`:
1672
+
1673
+ ```python
1674
+ from src.interfaces.http.app_factory import create_app
1675
+
1676
+ app = create_app()
1677
+ ```
1678
+
1679
+ ---
1680
+
1681
+ ## Rotas REST
1682
+
1683
+ As rotas devem ser finas.
1684
+
1685
+ Exemplo: `src/interfaces/http/routes/appointment_routes.py`:
1686
+
1687
+ ```python
1688
+ from flask import Blueprint, current_app, request
1689
+
1690
+ from src.interfaces.http.controllers.appointment_http_controller import (
1691
+ AppointmentHttpController,
1692
+ )
1693
+
1694
+ appointment_bp = Blueprint("appointments", __name__)
1695
+
1696
+
1697
+ @appointment_bp.post("/<int:clinic_id>/appointments")
1698
+ def create_appointment(clinic_id: int):
1699
+ container = current_app.extensions["container"]
1700
+ controller: AppointmentHttpController = container.appointment_http_controller()
1701
+ return controller.create(clinic_id=clinic_id, payload=request.get_json(silent=False))
1702
+
1703
+
1704
+ @appointment_bp.patch("/<int:clinic_id>/appointments/<int:appointment_id>/cancel")
1705
+ def cancel_appointment(clinic_id: int, appointment_id: int):
1706
+ container = current_app.extensions["container"]
1707
+ controller: AppointmentHttpController = container.appointment_http_controller()
1708
+ return controller.cancel(
1709
+ clinic_id=clinic_id,
1710
+ appointment_id=appointment_id,
1711
+ payload=request.get_json(silent=True) or {},
1712
+ )
1713
+ ```
1714
+
1715
+ Controller REST:
1716
+
1717
+ ```python
1718
+ from flask import jsonify
1719
+
1720
+ from src.application.appointments.dto.create_appointment_input import (
1721
+ CreateAppointmentInput,
1722
+ )
1723
+ from src.interfaces.http.schemas.appointment_http_schema import (
1724
+ CreateAppointmentRequest,
1725
+ )
1726
+
1727
+
1728
+ class AppointmentHttpController:
1729
+ def __init__(self, create_appointment_use_case, cancel_appointment_use_case):
1730
+ self._create_appointment_use_case = create_appointment_use_case
1731
+ self._cancel_appointment_use_case = cancel_appointment_use_case
1732
+
1733
+ def create(self, clinic_id: int, payload: dict):
1734
+ request_data = CreateAppointmentRequest.model_validate(payload)
1735
+
1736
+ result = self._create_appointment_use_case.execute(
1737
+ CreateAppointmentInput(
1738
+ clinic_id=clinic_id,
1739
+ service_id=request_data.service_id,
1740
+ customer_id=request_data.customer_id,
1741
+ professional_id=request_data.professional_id,
1742
+ start_at=request_data.start_at,
1743
+ )
1744
+ )
1745
+
1746
+ return jsonify(result.model_dump(mode="json")), 201
1747
+
1748
+ def cancel(self, clinic_id: int, appointment_id: int, payload: dict):
1749
+ result = self._cancel_appointment_use_case.execute(
1750
+ clinic_id=clinic_id,
1751
+ appointment_id=appointment_id,
1752
+ reason=payload.get("reason"),
1753
+ )
1754
+ return jsonify(result.model_dump(mode="json")), 200
1755
+ ```
1756
+
1757
+ ---
1758
+
1759
+ ## WebSocket
1760
+
1761
+ O WebSocket deve receber eventos, validar envelope e chamar use case.
1762
+
1763
+ Não deve acessar banco.
1764
+
1765
+ Não deve chamar LangGraph diretamente.
1766
+
1767
+ ### Envelope padrão
1768
+
1769
+ ```json
1770
+ {
1771
+ "type": "chat.message",
1772
+ "correlation_id": "corr_123",
1773
+ "payload": {
1774
+ "clinic_id": 1,
1775
+ "user_id": 10,
1776
+ "message": "Quero marcar uma consulta amanhã de manhã"
1777
+ }
1778
+ }
1779
+ ```
1780
+
1781
+ ### Schema
1782
+
1783
+ `src/interfaces/websocket/schemas/websocket_envelope_schema.py`:
1784
+
1785
+ ```python
1786
+ from typing import Any
1787
+
1788
+ from pydantic import BaseModel, Field
1789
+
1790
+
1791
+ class WebSocketEnvelopeSchema(BaseModel):
1792
+ type: str = Field(min_length=1)
1793
+ correlation_id: str = Field(min_length=1)
1794
+ payload: dict[str, Any]
1795
+ ```
1796
+
1797
+ ### Controller
1798
+
1799
+ `src/interfaces/websocket/controllers/chat_ws_controller.py`:
1800
+
1801
+ ```python
1802
+ from src.application.agents.dto.agent_input import AgentInput
1803
+ from src.interfaces.websocket.schemas.websocket_envelope_schema import (
1804
+ WebSocketEnvelopeSchema,
1805
+ )
1806
+
1807
+
1808
+ class ChatWebSocketController:
1809
+ def __init__(self, connection_registry, process_agent_message_use_case):
1810
+ self._connection_registry = connection_registry
1811
+ self._process_agent_message_use_case = process_agent_message_use_case
1812
+
1813
+ def handle(self, ws) -> None:
1814
+ connection_id = self._connection_registry.register(ws)
1815
+
1816
+ try:
1817
+ while True:
1818
+ raw_message = ws.receive()
1819
+
1820
+ if raw_message is None:
1821
+ break
1822
+
1823
+ event = WebSocketEnvelopeSchema.model_validate_json(raw_message)
1824
+
1825
+ if event.type != "chat.message":
1826
+ self._connection_registry.send_json(
1827
+ connection_id,
1828
+ {
1829
+ "type": "error.unsupported_event",
1830
+ "correlation_id": event.correlation_id,
1831
+ "payload": {"message": f"Unsupported event type: {event.type}"},
1832
+ },
1833
+ )
1834
+ continue
1835
+
1836
+ self._process_agent_message_use_case.execute(
1837
+ AgentInput(
1838
+ correlation_id=event.correlation_id,
1839
+ tenant_id=self._connection_registry.get_principal(connection_id).tenant_id,
1840
+ clinic_id=int(event.payload["clinic_id"]),
1841
+ user_id=int(event.payload["user_id"]),
1842
+ channel="websocket",
1843
+ connection_id=connection_id,
1844
+ message=str(event.payload["message"]),
1845
+ )
1846
+ )
1847
+ finally:
1848
+ self._connection_registry.unregister(connection_id)
1849
+ ```
1850
+
1851
+ ### Route
1852
+
1853
+ `src/interfaces/websocket/routes/chat_ws_routes.py`:
1854
+
1855
+ ```python
1856
+ from flask import Flask
1857
+ from flask_sock import Sock
1858
+
1859
+
1860
+ def register_chat_ws_routes(sock: Sock, app: Flask) -> None:
1861
+ @sock.route("/ws/chat")
1862
+ def chat(ws):
1863
+ container = app.extensions["container"]
1864
+ controller = container.chat_ws_controller()
1865
+ controller.handle(ws)
1866
+ ```
1867
+
1868
+ ---
1869
+
1870
+ ## Application Agents
1871
+
1872
+ ### DTO
1873
+
1874
+ `src/application/agents/dto/agent_input.py`:
1875
+
1876
+ ```python
1877
+ from dataclasses import dataclass
1878
+ from typing import Literal
1879
+
1880
+
1881
+ @dataclass(frozen=True)
1882
+ class AgentInput:
1883
+ correlation_id: str
1884
+ tenant_id: int
1885
+ clinic_id: int
1886
+ user_id: int
1887
+ channel: Literal["websocket", "rest"]
1888
+ message: str
1889
+ connection_id: str | None = None
1890
+ ```
1891
+
1892
+ `src/application/agents/dto/agent_output.py`:
1893
+
1894
+ ```python
1895
+ from dataclasses import dataclass
1896
+
1897
+
1898
+ @dataclass(frozen=True)
1899
+ class AgentOutput:
1900
+ correlation_id: str
1901
+ message: str
1902
+ intent: str | None
1903
+ requires_user_input: bool = False
1904
+
1905
+ def to_dict(self) -> dict:
1906
+ return {
1907
+ "correlation_id": self.correlation_id,
1908
+ "message": self.message,
1909
+ "intent": self.intent,
1910
+ "requires_user_input": self.requires_user_input,
1911
+ }
1912
+ ```
1913
+
1914
+ ### Port
1915
+
1916
+ `src/application/agents/ports/agent_orchestrator_port.py`:
1917
+
1918
+ ```python
1919
+ from abc import ABC, abstractmethod
1920
+
1921
+ from src.application.agents.dto.agent_input import AgentInput
1922
+ from src.application.agents.dto.agent_output import AgentOutput
1923
+
1924
+
1925
+ class AgentOrchestratorPort(ABC):
1926
+ @abstractmethod
1927
+ def process(self, input_data: AgentInput) -> AgentOutput:
1928
+ raise NotImplementedError
1929
+ ```
1930
+
1931
+ ### Use case
1932
+
1933
+ `src/application/agents/use_cases/process_agent_message_use_case.py`:
1934
+
1935
+ ```python
1936
+ from src.application.agents.dto.agent_input import AgentInput
1937
+
1938
+
1939
+ class ProcessAgentMessageUseCase:
1940
+ def __init__(self, agent_orchestrator, agent_event_publisher):
1941
+ self._agent_orchestrator = agent_orchestrator
1942
+ self._agent_event_publisher = agent_event_publisher
1943
+
1944
+ def execute(self, input_data: AgentInput):
1945
+ result = self._agent_orchestrator.process(input_data)
1946
+
1947
+ if input_data.channel == "websocket" and input_data.connection_id:
1948
+ self._agent_event_publisher.publish(
1949
+ connection_id=input_data.connection_id,
1950
+ event_type="chat.response",
1951
+ correlation_id=input_data.correlation_id,
1952
+ payload=result.to_dict(),
1953
+ )
1954
+
1955
+ return result
1956
+ ```
1957
+
1958
+ ---
1959
+
1960
+ ## LangGraph
1961
+
1962
+ LangGraph fica em infrastructure.
1963
+
1964
+ A camada application só conhece `AgentOrchestratorPort`.
1965
+
1966
+ ### State
1967
+
1968
+ `src/infrastructure/ai/langgraph/state.py`:
1969
+
1970
+ ```python
1971
+ from typing import Any, Literal, NotRequired, TypedDict
1972
+
1973
+
1974
+ class AgentGraphState(TypedDict):
1975
+ correlation_id: str
1976
+ tenant_id: int
1977
+ clinic_id: int
1978
+ user_id: int
1979
+ channel: Literal["websocket", "rest"]
1980
+ user_message: str
1981
+ connection_id: NotRequired[str | None]
1982
+
1983
+ normalized_intent: NotRequired[str]
1984
+ selected_service_id: NotRequired[int | None]
1985
+ selected_professional_id: NotRequired[int | None]
1986
+ selected_appointment_id: NotRequired[int | None]
1987
+
1988
+ application_context: NotRequired[dict[str, Any]]
1989
+ tool_results: NotRequired[list[dict[str, Any]]]
1990
+ final_response: NotRequired[str]
1991
+ requires_user_input: NotRequired[bool]
1992
+ ```
1993
+
1994
+ ### Orchestrator Adapter
1995
+
1996
+ `src/infrastructure/ai/langgraph/orchestrator/langgraph_agent_orchestrator.py`:
1997
+
1998
+ ```python
1999
+ from src.application.agents.dto.agent_input import AgentInput
2000
+ from src.application.agents.dto.agent_output import AgentOutput
2001
+ from src.application.agents.ports.agent_orchestrator_port import AgentOrchestratorPort
2002
+
2003
+
2004
+ class LangGraphAgentOrchestrator(AgentOrchestratorPort):
2005
+ def __init__(self, graph):
2006
+ self._graph = graph
2007
+
2008
+ def process(self, input_data: AgentInput) -> AgentOutput:
2009
+ state = {
2010
+ "correlation_id": input_data.correlation_id,
2011
+ "tenant_id": input_data.tenant_id,
2012
+ "clinic_id": input_data.clinic_id,
2013
+ "user_id": input_data.user_id,
2014
+ "channel": input_data.channel,
2015
+ "connection_id": input_data.connection_id,
2016
+ "user_message": input_data.message,
2017
+ }
2018
+
2019
+ result = self._graph.invoke(
2020
+ state,
2021
+ config={
2022
+ "configurable": {
2023
+ "thread_id": (
2024
+ f"tenant:{input_data.tenant_id}:"
2025
+ f"clinic:{input_data.clinic_id}:"
2026
+ f"user:{input_data.user_id}"
2027
+ ),
2028
+ }
2029
+ },
2030
+ )
2031
+
2032
+ return AgentOutput(
2033
+ correlation_id=input_data.correlation_id,
2034
+ message=result.get("final_response", "Não consegui processar a solicitação."),
2035
+ intent=result.get("normalized_intent"),
2036
+ requires_user_input=result.get("requires_user_input", False),
2037
+ )
2038
+ ```
2039
+
2040
+ ### Graph Factory
2041
+
2042
+ `src/infrastructure/ai/langgraph/graph_factory.py`:
2043
+
2044
+ ```python
2045
+ from langgraph.graph import END, START, StateGraph
2046
+
2047
+ from src.infrastructure.ai.langgraph.nodes.appointment_agent_node import appointment_agent_node
2048
+ from src.infrastructure.ai.langgraph.nodes.clinic_agent_node import clinic_agent_node
2049
+ from src.infrastructure.ai.langgraph.nodes.fallback_agent_node import fallback_agent_node
2050
+ from src.infrastructure.ai.langgraph.nodes.guardrail_node import guardrail_node
2051
+ from src.infrastructure.ai.langgraph.nodes.load_context_node import load_context_node
2052
+ from src.infrastructure.ai.langgraph.nodes.response_composer_node import response_composer_node
2053
+ from src.infrastructure.ai.langgraph.nodes.router_node import router_node
2054
+ from src.infrastructure.ai.langgraph.nodes.service_agent_node import service_agent_node
2055
+ from src.infrastructure.ai.langgraph.state import AgentGraphState
2056
+
2057
+
2058
+ def route_by_intent(state: AgentGraphState) -> str:
2059
+ intent = state.get("normalized_intent")
2060
+
2061
+ if intent in {"clinic_info", "clinic_status"}:
2062
+ return "clinic_agent"
2063
+
2064
+ if intent in {"service_catalog", "service_detail"}:
2065
+ return "service_agent"
2066
+
2067
+ if intent in {
2068
+ "appointment_create",
2069
+ "appointment_reschedule",
2070
+ "appointment_cancel",
2071
+ "appointment_status",
2072
+ }:
2073
+ return "appointment_agent"
2074
+
2075
+ return "fallback_agent"
2076
+
2077
+
2078
+ def build_agent_graph(checkpointer=None):
2079
+ graph = StateGraph(AgentGraphState)
2080
+
2081
+ graph.add_node("load_context", load_context_node)
2082
+ graph.add_node("guardrail", guardrail_node)
2083
+ graph.add_node("router", router_node)
2084
+ graph.add_node("clinic_agent", clinic_agent_node)
2085
+ graph.add_node("service_agent", service_agent_node)
2086
+ graph.add_node("appointment_agent", appointment_agent_node)
2087
+ graph.add_node("fallback_agent", fallback_agent_node)
2088
+ graph.add_node("response_composer", response_composer_node)
2089
+
2090
+ graph.add_edge(START, "load_context")
2091
+ graph.add_edge("load_context", "guardrail")
2092
+ graph.add_edge("guardrail", "router")
2093
+
2094
+ graph.add_conditional_edges(
2095
+ "router",
2096
+ route_by_intent,
2097
+ {
2098
+ "clinic_agent": "clinic_agent",
2099
+ "service_agent": "service_agent",
2100
+ "appointment_agent": "appointment_agent",
2101
+ "fallback_agent": "fallback_agent",
2102
+ },
2103
+ )
2104
+
2105
+ graph.add_edge("clinic_agent", "response_composer")
2106
+ graph.add_edge("service_agent", "response_composer")
2107
+ graph.add_edge("appointment_agent", "response_composer")
2108
+ graph.add_edge("fallback_agent", "response_composer")
2109
+ graph.add_edge("response_composer", END)
2110
+
2111
+ return graph.compile(checkpointer=checkpointer)
2112
+ ```
2113
+
2114
+ ---
2115
+
2116
+ ## Tools dos agentes
2117
+
2118
+ Tools devem chamar use cases.
2119
+
2120
+ Não chame repository diretamente.
2121
+
2122
+ ### Service tools
2123
+
2124
+ `src/infrastructure/ai/langgraph/tools/service_tools.py`:
2125
+
2126
+ ```python
2127
+ from langchain.tools import tool
2128
+
2129
+
2130
+ def build_service_tools(list_services_by_clinic_use_case):
2131
+ @tool("list_services_by_clinic")
2132
+ def list_services_by_clinic(
2133
+ clinic_id: int,
2134
+ limit: int = 20,
2135
+ offset: int = 0,
2136
+ search: str | None = None,
2137
+ ) -> dict:
2138
+ """List active services available for a clinic."""
2139
+ result = list_services_by_clinic_use_case.execute(
2140
+ clinic_id=clinic_id,
2141
+ limit=limit,
2142
+ offset=offset,
2143
+ search=search,
2144
+ )
2145
+ return {
2146
+ "pages": result.pages,
2147
+ "count": result.count,
2148
+ "limit": result.limit,
2149
+ "offset": result.offset,
2150
+ "data": [item.model_dump(mode="json") for item in result.data],
2151
+ }
2152
+
2153
+ return [list_services_by_clinic]
2154
+ ```
2155
+
2156
+ ### Appointment tools
2157
+
2158
+ `src/infrastructure/ai/langgraph/tools/appointment_tools.py`:
2159
+
2160
+ ```python
2161
+ from langchain.tools import tool
2162
+
2163
+
2164
+ def build_appointment_tools(search_available_slots_use_case, create_appointment_use_case):
2165
+ @tool("search_available_slots")
2166
+ def search_available_slots(
2167
+ clinic_id: int,
2168
+ service_id: int,
2169
+ desired_date: str,
2170
+ professional_id: int | None = None,
2171
+ ) -> list[dict]:
2172
+ """Search available appointment slots for a clinic, service and date."""
2173
+ result = search_available_slots_use_case.execute(
2174
+ clinic_id=clinic_id,
2175
+ service_id=service_id,
2176
+ desired_date=desired_date,
2177
+ professional_id=professional_id,
2178
+ )
2179
+ return [item.model_dump(mode="json") for item in result]
2180
+
2181
+ @tool("create_appointment")
2182
+ def create_appointment(
2183
+ clinic_id: int,
2184
+ service_id: int,
2185
+ customer_id: int,
2186
+ professional_id: int,
2187
+ start_at: str,
2188
+ ) -> dict:
2189
+ """Create an appointment after all required fields are known."""
2190
+ result = create_appointment_use_case.execute_from_agent(
2191
+ clinic_id=clinic_id,
2192
+ service_id=service_id,
2193
+ customer_id=customer_id,
2194
+ professional_id=professional_id,
2195
+ start_at=start_at,
2196
+ )
2197
+ return result.model_dump(mode="json")
2198
+
2199
+ return [search_available_slots, create_appointment]
2200
+ ```
2201
+
2202
+ Regra crítica:
2203
+
2204
+ ```txt
2205
+ O agente pode inferir intenção.
2206
+ O agente pode coletar dados.
2207
+ O agente pode sugerir próximos passos.
2208
+ O agente não confirma disponibilidade por conta própria.
2209
+ O caso de uso de agendamento valida disponibilidade, conflito, regras da clínica e status do serviço.
2210
+ ```
2211
+
2212
+ ---
2213
+
2214
+ ## Fluxo correto do chat via WebSocket
2215
+
2216
+ ```txt
2217
+ Cliente WebSocket
2218
+ -> interfaces/websocket/controllers/chat_ws_controller.py
2219
+ -> application/agents/use_cases/process_agent_message_use_case.py
2220
+ -> application/agents/ports/agent_orchestrator_port.py
2221
+ -> infrastructure/ai/langgraph/orchestrator/langgraph_agent_orchestrator.py
2222
+ -> infrastructure/ai/langgraph/graph_factory.py
2223
+ -> nodes/*
2224
+ -> tools/*
2225
+ -> application/clinics | application/services | application/appointments
2226
+ -> domain/*
2227
+ -> infrastructure/persistence/*
2228
+ -> application/agents/use_cases/process_agent_message_use_case.py
2229
+ -> infrastructure/websocket/websocket_event_publisher.py
2230
+ -> Cliente WebSocket
2231
+ ```
2232
+
2233
+ ---
2234
+
2235
+ ## Convenções de domínio
2236
+
2237
+ ### Domain
2238
+
2239
+ Use entidades puras.
2240
+
2241
+ Não use Pydantic em entidades de domínio.
2242
+
2243
+ Não use SQLAlchemy em entidades de domínio.
2244
+
2245
+ Exemplo:
2246
+
2247
+ ```python
2248
+ from dataclasses import dataclass
2249
+ from datetime import datetime
2250
+
2251
+
2252
+ @dataclass
2253
+ class Appointment:
2254
+ id: int | None
2255
+ clinic_id: int
2256
+ service_id: int
2257
+ customer_id: int
2258
+ professional_id: int
2259
+ start_at: datetime
2260
+ end_at: datetime
2261
+ status: str
2262
+
2263
+ def confirm(self) -> None:
2264
+ if self.status not in {"reserved", "requested"}:
2265
+ raise ValueError("Only reserved or requested appointments can be confirmed")
2266
+ self.status = "confirmed"
2267
+
2268
+ def cancel(self, reason: str | None) -> None:
2269
+ if self.status in {"completed", "cancelled"}:
2270
+ raise ValueError("Appointment cannot be cancelled")
2271
+ self.status = "cancelled"
2272
+ ```
2273
+
2274
+ ### Application
2275
+
2276
+ Use explicit DTOs and ports.
2277
+
2278
+ Application rules:
2279
+
2280
+ - Each use case has one orchestration responsibility and explicit input/output contracts.
2281
+ - Use cases can coordinate multiple ports, but must not know adapter internals.
2282
+ - Transaction control (Unit of Work) lives in application orchestration boundaries.
2283
+ - Tenant and authorization context enters via DTO/principal contracts, never through Flask globals.
2284
+
2285
+ Use-case contract example:
2286
+
2287
+ ```python
2288
+ from dataclasses import dataclass
2289
+ from typing import Protocol
2290
+
2291
+
2292
+ @dataclass(frozen=True)
2293
+ class ListServicesInput:
2294
+ tenant_id: int
2295
+ clinic_id: int
2296
+ limit: int
2297
+ offset: int
2298
+ search: str | None
2299
+
2300
+
2301
+ class ServiceReaderPort(Protocol):
2302
+ def list_by_clinic(self, input_data: ListServicesInput) -> list[dict]:
2303
+ ...
2304
+
2305
+
2306
+ class ListServicesByClinicUseCase:
2307
+ def __init__(self, service_reader: ServiceReaderPort):
2308
+ self._service_reader = service_reader
2309
+
2310
+ def execute(self, input_data: ListServicesInput) -> list[dict]:
2311
+ return self._service_reader.list_by_clinic(input_data)
2312
+ ```
2313
+
2314
+ ### Infrastructure
2315
+
2316
+ Implements application ports with concrete adapters.
2317
+
2318
+ Infrastructure rules:
2319
+
2320
+ - Repositories map ORM models to domain/application contracts.
2321
+ - Security adapters validate JWT and produce application principal objects.
2322
+ - Cache/pubsub adapters hide Redis protocol details from application/domain.
2323
+ - AI adapters implement model/provider orchestration behind ports.
2324
+ - Infrastructure can fail with external dependency errors, but error translation to business meaning stays explicit in application boundaries.
2325
+
2326
+ ---
2327
+
2328
+ ## Regras de mapeamento ORM <-> Domain
2329
+
2330
+ Repository deve mapear explicitamente.
2331
+
2332
+ Não retorne `AppointmentModel` para application.
2333
+
2334
+ Exemplo:
2335
+
2336
+ ```python
2337
+ from src.domain.appointments.entities.appointment import Appointment
2338
+ from src.infrastructure.persistence.models.appointment_model import AppointmentModel
2339
+
2340
+
2341
+ class AppointmentMapper:
2342
+ @staticmethod
2343
+ def to_domain(model: AppointmentModel) -> Appointment:
2344
+ return Appointment(
2345
+ id=model.id,
2346
+ clinic_id=model.clinic_id,
2347
+ service_id=model.service_id,
2348
+ customer_id=model.customer_id,
2349
+ professional_id=model.professional_id,
2350
+ start_at=model.start_at,
2351
+ end_at=model.end_at,
2352
+ status=model.status,
2353
+ )
2354
+
2355
+ @staticmethod
2356
+ def to_model(entity: Appointment) -> AppointmentModel:
2357
+ return AppointmentModel(
2358
+ clinic_id=entity.clinic_id,
2359
+ service_id=entity.service_id,
2360
+ customer_id=entity.customer_id,
2361
+ professional_id=entity.professional_id,
2362
+ start_at=entity.start_at,
2363
+ end_at=entity.end_at,
2364
+ status=entity.status,
2365
+ )
2366
+ ```
2367
+
2368
+ ---
2369
+
2370
+ ## Agendamento e conflito de agenda
2371
+
2372
+ Estratégia padrão:
2373
+
2374
+ 1. Serviço define duração.
2375
+ 2. Caso de uso calcula `end_at`.
2376
+ 3. Caso de uso quebra o período em slots discretos.
2377
+ 4. Caso de uso tenta inserir locks dos slots.
2378
+ 5. Banco garante unicidade por `clinic_id`, `professional_id`, `slot_start_at`.
2379
+ 6. Se houver conflito, a transação falha e o use case retorna erro de agenda indisponível.
2380
+
2381
+ Exemplo conceitual:
2382
+
2383
+ ```txt
2384
+ Consulta de 45 minutos
2385
+ Slot base: 15 minutos
2386
+ Início: 10:00
2387
+ Fim: 10:45
2388
+ Locks:
2389
+ - 10:00
2390
+ - 10:15
2391
+ - 10:30
2392
+ ```
2393
+
2394
+ Nunca confie apenas no LLM para validar conflito.
2395
+
2396
+ ---
2397
+
2398
+ ## Migrations
2399
+
2400
+ Regras:
2401
+
2402
+ - Toda mudança de modelo precisa de migration.
2403
+ - Use autogenerate como ponto de partida, nunca como verdade final.
2404
+ - Revise constraints, indexes, defaults e checks manualmente.
2405
+ - Nunca rode migration gerada automaticamente em produção sem revisão.
2406
+ - Nomeie constraints para facilitar diff e rollback.
2407
+ - Se usar PostgreSQL e quiser validação de intervalo real, adicione constraint específica por migration manual, não por modelo genérico.
2408
+ - Se usar MySQL, revise tamanho de índice, charset/collation, suporte de check constraints e comportamento de timezone.
2409
+ - Quando uma migration tiver caminhos diferentes para PostgreSQL e MySQL, deixe a decisão explícita no arquivo da migration e teste os dois dialetos.
2410
+
2411
+ ---
2412
+
2413
+ ## pyproject.toml base
2414
+
2415
+ ```toml
2416
+ [project]
2417
+ name = "api-clean-flask"
2418
+ version = "0.1.0"
2419
+ description = "Clean Architecture Flask API with REST, WebSocket and LangGraph multi-agent orchestration"
2420
+ requires-python = ">=3.12"
2421
+ dependencies = [
2422
+ "Flask>=3.1,<4",
2423
+ "flask-sock>=0.7,<1",
2424
+ "SQLAlchemy>=2.0,<3",
2425
+ "alembic>=1.13,<2",
2426
+ "pydantic>=2.8,<3",
2427
+ "pydantic-settings>=2.4,<3",
2428
+ "PyJWT>=2.8,<3",
2429
+ "apispec>=6.6,<7",
2430
+ "swagger-ui-bundle>=1.1,<2",
2431
+ "langchain>=1,<2",
2432
+ "langgraph>=1,<2",
2433
+ "redis>=5,<7",
2434
+ "python-dotenv>=1,<2",
2435
+ "gunicorn>=22,<24",
2436
+ ]
2437
+
2438
+ [project.optional-dependencies]
2439
+ dev = [
2440
+ "pytest>=8,<9",
2441
+ "pytest-cov>=5,<7",
2442
+ "ruff>=0.6,<1",
2443
+ "black>=24,<26",
2444
+ "mypy>=1.10,<2",
2445
+ ]
2446
+ postgres = [
2447
+ "psycopg[binary]>=3.2,<4",
2448
+ ]
2449
+ mysql = [
2450
+ "pymysql>=1.1,<2",
2451
+ ]
2452
+ databases = [
2453
+ "psycopg[binary]>=3.2,<4",
2454
+ "pymysql>=1.1,<2",
2455
+ ]
2456
+
2457
+ [tool.ruff]
2458
+ line-length = 100
2459
+ src = ["src", "tests"]
2460
+
2461
+ [tool.black]
2462
+ line-length = 100
2463
+
2464
+ [tool.mypy]
2465
+ python_version = "3.12"
2466
+ strict = true
2467
+ warn_unused_ignores = true
2468
+ warn_return_any = true
2469
+ disallow_untyped_defs = true
2470
+ disallow_incomplete_defs = true
2471
+
2472
+ [tool.pytest.ini_options]
2473
+ testpaths = ["tests"]
2474
+ pythonpath = ["."]
2475
+ ```
2476
+
2477
+ Ajuste versões conforme compatibilidade real do ambiente.
2478
+
2479
+ ---
2480
+
2481
+ ## Tratamento de erros
2482
+
2483
+ ### REST
2484
+
2485
+ Formato:
2486
+
2487
+ ```json
2488
+ {
2489
+ "error": {
2490
+ "code": "VALIDATION_ERROR",
2491
+ "message": "Invalid request payload",
2492
+ "details": []
2493
+ }
2494
+ }
2495
+ ```
2496
+
2497
+ ### WebSocket
2498
+
2499
+ Formato:
2500
+
2501
+ ```json
2502
+ {
2503
+ "type": "error.validation",
2504
+ "correlation_id": "corr_123",
2505
+ "payload": {
2506
+ "code": "VALIDATION_ERROR",
2507
+ "message": "Invalid websocket message",
2508
+ "details": []
2509
+ }
2510
+ }
2511
+ ```
2512
+
2513
+ ### Agente
2514
+
2515
+ Formato:
2516
+
2517
+ ```json
2518
+ {
2519
+ "type": "chat.response",
2520
+ "correlation_id": "corr_123",
2521
+ "payload": {
2522
+ "message": "Preciso saber qual serviço você deseja agendar.",
2523
+ "intent": "appointment_create",
2524
+ "requires_user_input": true
2525
+ }
2526
+ }
2527
+ ```
2528
+
2529
+ ---
2530
+
2531
+ ## Observabilidade
2532
+
2533
+ Todo fluxo deve carregar:
2534
+
2535
+ - `correlation_id`
2536
+ - `clinic_id`
2537
+ - `user_id`, quando disponível
2538
+ - `connection_id`, quando WebSocket
2539
+ - `intent`, quando fluxo de agente
2540
+ - duração da operação
2541
+ - status final
2542
+ - erro normalizado, quando houver
2543
+
2544
+ Não registre dados sensíveis em log.
2545
+
2546
+ Não registre prompt completo sem sanitização.
2547
+
2548
+ Não registre payload bruto do WebSocket em produção.
2549
+
2550
+ ---
2551
+
2552
+ ## Testes obrigatórios
2553
+
2554
+ ### Unitários
2555
+
2556
+ - Entidades de domínio.
2557
+ - Políticas de domínio.
2558
+ - Use cases.
2559
+ - Verificação de JWT com token válido, expirado, inválido e sem `tenant_id`.
2560
+ - Propagação de `tenant_id` nos DTOs de application.
2561
+ - Cálculo de paginação `pages`, `count`, `limit`, `offset` e `data`.
2562
+ - Normalização de `search` opcional em listagens.
2563
+ - Mappers ORM/domain.
2564
+ - Nodes determinísticos do LangGraph.
2565
+ - Tools com use cases mockados.
2566
+ - Provider de LLM fake/stub.
2567
+ - Factory de provider sem acoplar application a vendor.
2568
+ - Normalização de exceptions globais.
2569
+ - Cache key builder com `tenant_id`.
2570
+ - Pub/sub channel builder com `tenant_id`.
2571
+ - OpenAPI factory contendo `bearerAuth`.
2572
+ - Swagger UI config com `persistAuthorization`.
2573
+ - OpenAPI examples/templates para cada operação HTTP exposta.
2574
+
2575
+ ### Integração
2576
+
2577
+ - Repositories com banco de teste.
2578
+ - Unit of Work com commit e rollback.
2579
+ - Repositories filtrando obrigatoriamente por `tenant_id`.
2580
+ - Auditoria ORM criando `created_at`, atualizando `updated_at` e aplicando `deleted_at` em soft delete.
2581
+ - Migrations em SQLite e, no perfil de integração, PostgreSQL e MySQL.
2582
+ - Rotas HTTP.
2583
+ - Endpoints de listagem com query `limit` e `offset`.
2584
+ - Endpoints de listagem com query `search` aplicando `LIKE '%search%'` nos campos textuais.
2585
+ - WebSocket controller com conexão fake.
2586
+ - Middleware JWT HTTP.
2587
+ - Autenticação JWT no WebSocket.
2588
+ - `GET /openapi.json` retornando OpenAPI v3 válido.
2589
+ - `GET /docs` retornando Swagger UI com autorização Bearer JWT.
2590
+ - OpenAPI de cada endpoint HTTP contendo exemplo de uso executável pela Swagger UI.
2591
+ - Redis cache adapter com Redis de teste ou fake compatível.
2592
+ - Redis pub/sub adapter com Redis de teste ou fake compatível.
2593
+ - Agent orchestrator com LLM fake.
2594
+ - LangGraph executando com provider fake configurado.
2595
+
2596
+ ### E2E
2597
+
2598
+ - Login ou injeção controlada de JWT válido em ambiente de teste.
2599
+ - Criar clínica.
2600
+ - Criar serviço.
2601
+ - Listar serviços com paginação e busca textual, validando envelope `{pages, count, limit, offset, data}`.
2602
+ - Consultar disponibilidade.
2603
+ - Criar agendamento.
2604
+ - Confirmar agendamento.
2605
+ - Receber evento WebSocket.
2606
+ - Enviar mensagem via WebSocket e receber resposta do agente.
2607
+ - Abrir Swagger UI, salvar token via Authorize e executar request protegido com Bearer JWT.
2608
+ - Garantir que usuário de outro `tenant_id` não leia clínica, serviço, agendamento, cache nem evento pub/sub do tenant original.
2609
+ - Executar pelo menos um fluxo e2e com PostgreSQL e um fluxo e2e com MySQL no perfil de compatibilidade de banco.
2610
+
2611
+ ---
2612
+
2613
+ ## Quality gates for skill maintenance
2614
+
2615
+ These gates keep the skill reviewable and traceable to EPIC-0016 without relying on brittle whole-file snapshots.
2616
+
2617
+ | Gate ID | What must stay true | Traceability focus | Stable assertion anchors |
2618
+ | --- | --- | --- | --- |
2619
+ | GATE-ARCH-BOUNDARY | Domain/application remain framework-free; adapters stay in interface/infrastructure layers | Clean Architecture boundaries and dependency direction | `## Python/Flask Clean Architecture boundary contract`, `Must not depend on` |
2620
+ | GATE-SEC-AUTHORITY | Tenant/security authority comes from validated principal (JWT/context), never from payload or LLM output | Tenant isolation and security authority source | `tenant_id obrigatório`, `WebSocket payload is never authority` |
2621
+ | GATE-OPENSDD-BOUNDARY | OpenSDD is decision-support only; generated runtime code must not import OpenSDD artifacts | OpenSDD support with runtime boundary safety | `## OpenSDD decision-support mode (never runtime dependency)`, `must not import from `.sdd`` |
2622
+ | GATE-PROD-VERSIONING | Production guidance remains provider/model agnostic and version-sensitive APIs are labeled | Production readiness and version-aware guidance | `## Production-readiness guidance (safe and version-aware)`, `Version-sensitive API policy (validated or conceptual)` |
2623
+ | GATE-VALIDATION-STRATEGY | Validation uses stable required anchors/rules instead of prose snapshots | Maintainable quality gate evidence | `Avoid whole-document snapshots`, `Prefer required headings and key rules` |
2624
+
2625
+ ### Stable content assertion strategy
2626
+
2627
+ - Assert required headings and critical boundary phrases only.
2628
+ - Avoid whole-document snapshots of `SKILL.md`.
2629
+ - Prefer assertions tied to gate anchors that are expected to remain stable across editorial rewrites.
2630
+ - If a heading or gate anchor changes intentionally, update both the skill and the targeted test in the same change.
2631
+
2632
+ Recommended minimum anchors for seeded-skill tests:
2633
+
2634
+ - `## Python/Flask Clean Architecture boundary contract`
2635
+ - `## OpenSDD decision-support mode (never runtime dependency)`
2636
+ - `## Production-readiness guidance (safe and version-aware)`
2637
+ - `Version-sensitive API policy (validated or conceptual)`
2638
+ - `Redis Pub/Sub versus Streams and outbox`
2639
+ - `## Quality gates for skill maintenance`
2640
+
2641
+ ---
2642
+
2643
+ ## Checklist antes de aceitar código gerado
2644
+
2645
+ ```txt
2646
+ [ ] Flask aparece apenas em interfaces/http ou interfaces/websocket.
2647
+ [ ] SQLAlchemy aparece apenas em infrastructure/persistence.
2648
+ [ ] LangChain e LangGraph aparecem apenas em infrastructure/ai.
2649
+ [ ] Nenhum provider/modelo de LLM está hardcoded em domain/application.
2650
+ [ ] Provider e modelo são escolhidos por configuração.
2651
+ [ ] Exemplos version-sensitive de LangGraph/LangChain/observabilidade/checkpointer estão marcados como VALIDATED ou CONCEPTUAL.
2652
+ [ ] Exemplos CONCEPTUAL não dependem de imports internos/privados de bibliotecas externas.
2653
+ [ ] Testes de agentes usam provider fake/stub.
2654
+ [ ] Código Python público está tipado com argumentos e retornos.
2655
+ [ ] Mypy roda em modo estrito ou equivalente acordado.
2656
+ [ ] Use cases, repositories, services, policies, controllers e adapters são classes.
2657
+ [ ] Ports usam `Protocol` ou `ABC`.
2658
+ [ ] Domain não importa frameworks.
2659
+ [ ] Application não importa modelos ORM.
2660
+ [ ] Controller não acessa repository diretamente.
2661
+ [ ] Repository não commita transação.
2662
+ [ ] Use case usa Unit of Work.
2663
+ [ ] IDs principais são sequenciais.
2664
+ [ ] Toda entity ORM de negócio possui `tenant_id` obrigatório.
2665
+ [ ] Toda entity ORM de negócio possui `created_at`, `updated_at` e `deleted_at`.
2666
+ [ ] `updated_at` atualiza automaticamente em update.
2667
+ [ ] Soft delete preenche `deleted_at` sem remover registro por padrão.
2668
+ [ ] Toda query de repository filtra por `tenant_id`.
2669
+ [ ] Toda unique constraint tenant-scoped inclui `tenant_id`.
2670
+ [ ] Rotas com IDs sequenciais validam escopo por `tenant_id` e `clinic_id`.
2671
+ [ ] Endpoints de listagem aceitam `limit` e `offset`.
2672
+ [ ] Endpoints de listagem aceitam `search` opcional.
2673
+ [ ] Respostas de listagem retornam `{pages, count, limit, offset, data}`.
2674
+ [ ] Queries paginadas continuam filtrando por `tenant_id`.
2675
+ [ ] Queries com `search` aplicam `LIKE '%search%'` nos campos textuais permitidos.
2676
+ [ ] Queries com `search` usam bind parameters, sem interpolação manual.
2677
+ [ ] JWT é validado por adapter de security e não manualmente em controller.
2678
+ [ ] WebSocket autentica antes de processar evento de negócio.
2679
+ [ ] WebSocket payload nunca é fonte de autoridade para `tenant_id`, `user_id` ou permissões.
2680
+ [ ] OpenAPI v3 expõe todos os endpoints REST públicos.
2681
+ [ ] OpenAPI declara `bearerAuth` para endpoints protegidos.
2682
+ [ ] Cada serviço HTTP possui exemplos/templates de uso no Swagger.
2683
+ [ ] Swagger UI está disponível e preserva token com `persistAuthorization`.
2684
+ [ ] Agendamento usa lock transacional por slot ou constraint equivalente.
2685
+ [ ] WebSocket abre transação por mensagem, não por conexão.
2686
+ [ ] Ações transacionais iniciadas por agente exigem confirmação explícita quando houver efeito colateral.
2687
+ [ ] Agent tool chama use case, não banco.
2688
+ [ ] ToolExecutionContext carrega `tenant_id` e `user_id` de contexto autenticado, não inferido pelo LLM.
2689
+ [ ] Redis é acessado somente por adapters em infrastructure.
2690
+ [ ] Cache keys e pub/sub channels incluem `tenant_id`.
2691
+ [ ] Eventos que exigem replay/recovery usam Streams/outbox em vez de apenas Pub/Sub efêmero.
2692
+ [ ] Operações mutáveis têm estratégia explícita de idempotência/deduplicação.
2693
+ [ ] Exceptions globais normalizam erro sem derrubar o processo.
2694
+ [ ] Observabilidade inclui logs estruturados, métricas/traces e health checks de readiness/liveness.
2695
+ [ ] Migrations foram revisadas manualmente.
2696
+ [ ] Testes cobrem conflito de agenda.
2697
+ [ ] Testes unitários cobrem JWT, tenant_id, cache/pub-sub e exception mapping.
2698
+ [ ] Testes e2e cobrem REST, WebSocket, Redis e isolamento entre tenants.
2699
+ [ ] Pipeline CI/CD executa ao menos lint/type/unit/integration e, quando aplicável, e2e.
2700
+ [ ] Perfil de compatibilidade cobre PostgreSQL e MySQL.
2701
+ ```
2702
+
2703
+ ---
2704
+
2705
+ ## Como responder quando pedirem implementação
2706
+
2707
+ Ao gerar código para este projeto:
2708
+
2709
+ 1. Mostre primeiro o arquivo alvo.
2710
+ 2. Gere código completo do arquivo.
2711
+ 3. Não gere arquivos gigantes sem necessidade.
2712
+ 4. Quando houver dependência entre arquivos, gere na ordem:
2713
+ - domain
2714
+ - application DTO/ports
2715
+ - application use case
2716
+ - infrastructure model/repository
2717
+ - interface schema/controller/route
2718
+ - tests
2719
+ 5. Preserve os limites da arquitetura.
2720
+ 6. Use IDs inteiros sequenciais.
2721
+ 7. Sempre inclua teste quando a mudança envolver regra de negócio.
2722
+ 8. Em agendamento, sempre trate concorrência.
2723
+ 9. Em agente, sempre separe intenção inferida de decisão transacional.
2724
+
2725
+ ---
2726
+
2727
+ ## Decisão final da skill
2728
+
2729
+ Este projeto deve ser tratado como:
2730
+
2731
+ ```txt
2732
+ Clean Architecture Flask API
2733
+ com REST e WebSocket isolados,
2734
+ Python tipado e orientado a objetos,
2735
+ três domínios puros,
2736
+ SQLAlchemy tipado na infraestrutura,
2737
+ IDs relacionais sequenciais,
2738
+ tenant_id obrigatório em toda entity ORM para segregação SaaS,
2739
+ auditoria ORM obrigatória com created_at, updated_at e deleted_at,
2740
+ JWT como fonte canônica de autenticação, autorização e tenant,
2741
+ OpenAPI v3 e Swagger UI com Bearer JWT persistido na interface,
2742
+ Unit of Work para transações,
2743
+ slot locks para conflito de agenda,
2744
+ PostgreSQL e MySQL suportados por SQLAlchemy/Alembic,
2745
+ Redis obrigatório para cache, publish/subscribe, sessões e locks distribuídos,
2746
+ exceptions globais para continuidade de serviço,
2747
+ testes unitários, integração e e2e cobrindo isolamento multi-tenant,
2748
+ guidance de produção classificada entre baseline obrigatório e capacidades avançadas opcionais,
2749
+ APIs sensíveis a versão tratadas como VALIDATED ou CONCEPTUAL conforme evidência real,
2750
+ e LangGraph/LangChain como adapter de IA agnóstico a modelos e controlado pela application layer.
2751
+ ```