@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.
- package/.sdd/skills/curated/api-clean-flask-langgraph/SKILL.md +2751 -0
- package/.sdd/skills/curated/devtrack-api/SKILL.md +137 -0
- package/.sdd/skills/curated/devtrack-api/agents/openai.yaml +4 -0
- package/.sdd/skills/curated/devtrack-api/references/application-presentation.md +381 -0
- package/.sdd/skills/curated/devtrack-api/references/architecture-governance.md +219 -0
- package/.sdd/skills/curated/devtrack-api/references/domain-modeling.md +359 -0
- package/.sdd/skills/curated/devtrack-api/references/implementation-checklist.md +127 -0
- package/.sdd/skills/curated/devtrack-api/references/imports-lint.md +207 -0
- package/.sdd/skills/curated/devtrack-api/references/testing-validation.md +167 -0
- package/.sdd/skills/curated/devtrack-api/references/typeorm-infrastructure.md +334 -0
- package/LICENSE +21 -0
- package/README.md +842 -0
- package/bin/codesdd.js +10 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.js +560 -0
- package/dist/commands/change.d.ts +35 -0
- package/dist/commands/change.js +296 -0
- package/dist/commands/completion.d.ts +72 -0
- package/dist/commands/completion.js +258 -0
- package/dist/commands/config.d.ts +36 -0
- package/dist/commands/config.js +552 -0
- package/dist/commands/feedback.d.ts +9 -0
- package/dist/commands/feedback.js +184 -0
- package/dist/commands/schema.d.ts +6 -0
- package/dist/commands/schema.js +870 -0
- package/dist/commands/sdd/execution.d.ts +3 -0
- package/dist/commands/sdd/execution.js +409 -0
- package/dist/commands/sdd/shared.d.ts +9 -0
- package/dist/commands/sdd/shared.js +84 -0
- package/dist/commands/sdd/skills.d.ts +3 -0
- package/dist/commands/sdd/skills.js +154 -0
- package/dist/commands/sdd.d.ts +3 -0
- package/dist/commands/sdd.js +769 -0
- package/dist/commands/show.d.ts +14 -0
- package/dist/commands/show.js +133 -0
- package/dist/commands/spec.d.ts +15 -0
- package/dist/commands/spec.js +228 -0
- package/dist/commands/validate.d.ts +24 -0
- package/dist/commands/validate.js +295 -0
- package/dist/commands/workflow/index.d.ts +17 -0
- package/dist/commands/workflow/index.js +12 -0
- package/dist/commands/workflow/instructions.d.ts +29 -0
- package/dist/commands/workflow/instructions.js +383 -0
- package/dist/commands/workflow/new-change.d.ts +11 -0
- package/dist/commands/workflow/new-change.js +45 -0
- package/dist/commands/workflow/schemas.d.ts +10 -0
- package/dist/commands/workflow/schemas.js +34 -0
- package/dist/commands/workflow/shared.d.ts +57 -0
- package/dist/commands/workflow/shared.js +117 -0
- package/dist/commands/workflow/status.d.ts +14 -0
- package/dist/commands/workflow/status.js +76 -0
- package/dist/commands/workflow/templates.d.ts +16 -0
- package/dist/commands/workflow/templates.js +68 -0
- package/dist/core/archive.d.ts +16 -0
- package/dist/core/archive.js +487 -0
- package/dist/core/artifact-graph/graph.d.ts +56 -0
- package/dist/core/artifact-graph/graph.js +141 -0
- package/dist/core/artifact-graph/index.d.ts +7 -0
- package/dist/core/artifact-graph/index.js +13 -0
- package/dist/core/artifact-graph/instruction-loader.d.ts +143 -0
- package/dist/core/artifact-graph/instruction-loader.js +215 -0
- package/dist/core/artifact-graph/resolver.d.ts +81 -0
- package/dist/core/artifact-graph/resolver.js +258 -0
- package/dist/core/artifact-graph/schema.d.ts +13 -0
- package/dist/core/artifact-graph/schema.js +108 -0
- package/dist/core/artifact-graph/state.d.ts +12 -0
- package/dist/core/artifact-graph/state.js +54 -0
- package/dist/core/artifact-graph/types.d.ts +45 -0
- package/dist/core/artifact-graph/types.js +43 -0
- package/dist/core/available-tools.d.ts +16 -0
- package/dist/core/available-tools.js +30 -0
- package/dist/core/branding.d.ts +8 -0
- package/dist/core/branding.js +12 -0
- package/dist/core/cli/command-matrix.d.ts +23 -0
- package/dist/core/cli/command-matrix.js +123 -0
- package/dist/core/command-generation/adapters/amazon-q.d.ts +13 -0
- package/dist/core/command-generation/adapters/amazon-q.js +26 -0
- package/dist/core/command-generation/adapters/antigravity.d.ts +13 -0
- package/dist/core/command-generation/adapters/antigravity.js +26 -0
- package/dist/core/command-generation/adapters/auggie.d.ts +13 -0
- package/dist/core/command-generation/adapters/auggie.js +27 -0
- package/dist/core/command-generation/adapters/claude.d.ts +13 -0
- package/dist/core/command-generation/adapters/claude.js +50 -0
- package/dist/core/command-generation/adapters/cline.d.ts +14 -0
- package/dist/core/command-generation/adapters/cline.js +27 -0
- package/dist/core/command-generation/adapters/codebuddy.d.ts +13 -0
- package/dist/core/command-generation/adapters/codebuddy.js +28 -0
- package/dist/core/command-generation/adapters/codex.d.ts +16 -0
- package/dist/core/command-generation/adapters/codex.js +39 -0
- package/dist/core/command-generation/adapters/continue.d.ts +13 -0
- package/dist/core/command-generation/adapters/continue.js +28 -0
- package/dist/core/command-generation/adapters/costrict.d.ts +13 -0
- package/dist/core/command-generation/adapters/costrict.js +27 -0
- package/dist/core/command-generation/adapters/crush.d.ts +13 -0
- package/dist/core/command-generation/adapters/crush.js +30 -0
- package/dist/core/command-generation/adapters/cursor.d.ts +14 -0
- package/dist/core/command-generation/adapters/cursor.js +44 -0
- package/dist/core/command-generation/adapters/factory.d.ts +13 -0
- package/dist/core/command-generation/adapters/factory.js +27 -0
- package/dist/core/command-generation/adapters/gemini.d.ts +13 -0
- package/dist/core/command-generation/adapters/gemini.js +26 -0
- package/dist/core/command-generation/adapters/github-copilot.d.ts +13 -0
- package/dist/core/command-generation/adapters/github-copilot.js +26 -0
- package/dist/core/command-generation/adapters/iflow.d.ts +13 -0
- package/dist/core/command-generation/adapters/iflow.js +29 -0
- package/dist/core/command-generation/adapters/index.d.ts +29 -0
- package/dist/core/command-generation/adapters/index.js +29 -0
- package/dist/core/command-generation/adapters/kilocode.d.ts +14 -0
- package/dist/core/command-generation/adapters/kilocode.js +23 -0
- package/dist/core/command-generation/adapters/kiro.d.ts +13 -0
- package/dist/core/command-generation/adapters/kiro.js +26 -0
- package/dist/core/command-generation/adapters/opencode.d.ts +13 -0
- package/dist/core/command-generation/adapters/opencode.js +29 -0
- package/dist/core/command-generation/adapters/pi.d.ts +14 -0
- package/dist/core/command-generation/adapters/pi.js +41 -0
- package/dist/core/command-generation/adapters/qoder.d.ts +13 -0
- package/dist/core/command-generation/adapters/qoder.js +30 -0
- package/dist/core/command-generation/adapters/qwen.d.ts +13 -0
- package/dist/core/command-generation/adapters/qwen.js +26 -0
- package/dist/core/command-generation/adapters/roocode.d.ts +14 -0
- package/dist/core/command-generation/adapters/roocode.js +27 -0
- package/dist/core/command-generation/adapters/windsurf.d.ts +14 -0
- package/dist/core/command-generation/adapters/windsurf.js +51 -0
- package/dist/core/command-generation/generator.d.ts +21 -0
- package/dist/core/command-generation/generator.js +27 -0
- package/dist/core/command-generation/index.d.ts +22 -0
- package/dist/core/command-generation/index.js +24 -0
- package/dist/core/command-generation/registry.d.ts +36 -0
- package/dist/core/command-generation/registry.js +92 -0
- package/dist/core/command-generation/types.d.ts +56 -0
- package/dist/core/command-generation/types.js +8 -0
- package/dist/core/completions/command-registry.d.ts +7 -0
- package/dist/core/completions/command-registry.js +461 -0
- package/dist/core/completions/completion-provider.d.ts +60 -0
- package/dist/core/completions/completion-provider.js +102 -0
- package/dist/core/completions/factory.d.ts +64 -0
- package/dist/core/completions/factory.js +75 -0
- package/dist/core/completions/generators/bash-generator.d.ts +32 -0
- package/dist/core/completions/generators/bash-generator.js +174 -0
- package/dist/core/completions/generators/fish-generator.d.ts +32 -0
- package/dist/core/completions/generators/fish-generator.js +157 -0
- package/dist/core/completions/generators/powershell-generator.d.ts +33 -0
- package/dist/core/completions/generators/powershell-generator.js +207 -0
- package/dist/core/completions/generators/zsh-generator.d.ts +44 -0
- package/dist/core/completions/generators/zsh-generator.js +250 -0
- package/dist/core/completions/installers/bash-installer.d.ts +87 -0
- package/dist/core/completions/installers/bash-installer.js +318 -0
- package/dist/core/completions/installers/fish-installer.d.ts +43 -0
- package/dist/core/completions/installers/fish-installer.js +143 -0
- package/dist/core/completions/installers/powershell-installer.d.ts +88 -0
- package/dist/core/completions/installers/powershell-installer.js +327 -0
- package/dist/core/completions/installers/zsh-installer.d.ts +125 -0
- package/dist/core/completions/installers/zsh-installer.js +452 -0
- package/dist/core/completions/templates/bash-templates.d.ts +6 -0
- package/dist/core/completions/templates/bash-templates.js +24 -0
- package/dist/core/completions/templates/fish-templates.d.ts +7 -0
- package/dist/core/completions/templates/fish-templates.js +39 -0
- package/dist/core/completions/templates/powershell-templates.d.ts +6 -0
- package/dist/core/completions/templates/powershell-templates.js +25 -0
- package/dist/core/completions/templates/zsh-templates.d.ts +6 -0
- package/dist/core/completions/templates/zsh-templates.js +36 -0
- package/dist/core/completions/types.d.ts +79 -0
- package/dist/core/completions/types.js +2 -0
- package/dist/core/config-prompts.d.ts +9 -0
- package/dist/core/config-prompts.js +34 -0
- package/dist/core/config-schema.d.ts +86 -0
- package/dist/core/config-schema.js +213 -0
- package/dist/core/config.d.ts +17 -0
- package/dist/core/config.js +33 -0
- package/dist/core/converters/json-converter.d.ts +6 -0
- package/dist/core/converters/json-converter.js +51 -0
- package/dist/core/global-config.d.ts +44 -0
- package/dist/core/global-config.js +125 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +3 -0
- package/dist/core/init.d.ts +36 -0
- package/dist/core/init.js +576 -0
- package/dist/core/legacy-cleanup.d.ts +162 -0
- package/dist/core/legacy-cleanup.js +512 -0
- package/dist/core/list.d.ts +9 -0
- package/dist/core/list.js +173 -0
- package/dist/core/migration.d.ts +23 -0
- package/dist/core/migration.js +108 -0
- package/dist/core/parsers/change-parser.d.ts +13 -0
- package/dist/core/parsers/change-parser.js +193 -0
- package/dist/core/parsers/markdown-parser.d.ts +22 -0
- package/dist/core/parsers/markdown-parser.js +187 -0
- package/dist/core/parsers/requirement-blocks.d.ts +37 -0
- package/dist/core/parsers/requirement-blocks.js +201 -0
- package/dist/core/profile-sync-drift.d.ts +38 -0
- package/dist/core/profile-sync-drift.js +201 -0
- package/dist/core/profiles.d.ts +26 -0
- package/dist/core/profiles.js +41 -0
- package/dist/core/project-config.d.ts +64 -0
- package/dist/core/project-config.js +223 -0
- package/dist/core/schemas/base.schema.d.ts +13 -0
- package/dist/core/schemas/base.schema.js +13 -0
- package/dist/core/schemas/change.schema.d.ts +73 -0
- package/dist/core/schemas/change.schema.js +31 -0
- package/dist/core/schemas/index.d.ts +4 -0
- package/dist/core/schemas/index.js +4 -0
- package/dist/core/schemas/spec.schema.d.ts +18 -0
- package/dist/core/schemas/spec.schema.js +15 -0
- package/dist/core/sdd/adr-policy.d.ts +7 -0
- package/dist/core/sdd/adr-policy.js +47 -0
- package/dist/core/sdd/adr.d.ts +4 -0
- package/dist/core/sdd/adr.js +27 -0
- package/dist/core/sdd/bootstrap.d.ts +28 -0
- package/dist/core/sdd/bootstrap.js +353 -0
- package/dist/core/sdd/check.d.ts +51 -0
- package/dist/core/sdd/check.js +831 -0
- package/dist/core/sdd/coordination/coordination-adapters.d.ts +73 -0
- package/dist/core/sdd/coordination/coordination-adapters.js +87 -0
- package/dist/core/sdd/coordination/index.d.ts +2 -0
- package/dist/core/sdd/coordination/index.js +2 -0
- package/dist/core/sdd/dedup.d.ts +23 -0
- package/dist/core/sdd/dedup.js +62 -0
- package/dist/core/sdd/default-bootstrap-files.d.ts +23 -0
- package/dist/core/sdd/default-bootstrap-files.js +385 -0
- package/dist/core/sdd/default-skills.d.ts +16 -0
- package/dist/core/sdd/default-skills.js +427 -0
- package/dist/core/sdd/diagnose.d.ts +25 -0
- package/dist/core/sdd/diagnose.js +1312 -0
- package/dist/core/sdd/docs-sync.d.ts +21 -0
- package/dist/core/sdd/docs-sync.js +231 -0
- package/dist/core/sdd/domain/helpers.d.ts +6 -0
- package/dist/core/sdd/domain/helpers.js +37 -0
- package/dist/core/sdd/domain/lifecycle-guardrails.d.ts +22 -0
- package/dist/core/sdd/domain/lifecycle-guardrails.js +31 -0
- package/dist/core/sdd/domain/lifecycle-hooks.d.ts +16 -0
- package/dist/core/sdd/domain/lifecycle-hooks.js +27 -0
- package/dist/core/sdd/domain/post-active-validation.d.ts +15 -0
- package/dist/core/sdd/domain/post-active-validation.js +71 -0
- package/dist/core/sdd/domain/traceability.d.ts +8 -0
- package/dist/core/sdd/domain/traceability.js +83 -0
- package/dist/core/sdd/domain/transition-engine.d.ts +49 -0
- package/dist/core/sdd/domain/transition-engine.js +120 -0
- package/dist/core/sdd/fingerprint.d.ts +23 -0
- package/dist/core/sdd/fingerprint.js +146 -0
- package/dist/core/sdd/import-openspec.d.ts +31 -0
- package/dist/core/sdd/import-openspec.js +232 -0
- package/dist/core/sdd/init.d.ts +36 -0
- package/dist/core/sdd/init.js +65 -0
- package/dist/core/sdd/json-schema.d.ts +6 -0
- package/dist/core/sdd/json-schema.js +59 -0
- package/dist/core/sdd/legacy-operations.d.ts +286 -0
- package/dist/core/sdd/legacy-operations.js +2175 -0
- package/dist/core/sdd/lenses.d.ts +14 -0
- package/dist/core/sdd/lenses.js +97 -0
- package/dist/core/sdd/merge-catalog.d.ts +9 -0
- package/dist/core/sdd/merge-catalog.js +70 -0
- package/dist/core/sdd/migrate-workspace.d.ts +36 -0
- package/dist/core/sdd/migrate-workspace.js +344 -0
- package/dist/core/sdd/migrate.d.ts +24 -0
- package/dist/core/sdd/migrate.js +385 -0
- package/dist/core/sdd/resolve-project-root.d.ts +15 -0
- package/dist/core/sdd/resolve-project-root.js +46 -0
- package/dist/core/sdd/root-resolver.d.ts +16 -0
- package/dist/core/sdd/root-resolver.js +62 -0
- package/dist/core/sdd/sanitize.d.ts +35 -0
- package/dist/core/sdd/sanitize.js +750 -0
- package/dist/core/sdd/services/approve.service.d.ts +20 -0
- package/dist/core/sdd/services/approve.service.js +82 -0
- package/dist/core/sdd/services/audit.service.d.ts +53 -0
- package/dist/core/sdd/services/audit.service.js +136 -0
- package/dist/core/sdd/services/breakdown.service.d.ts +35 -0
- package/dist/core/sdd/services/breakdown.service.js +185 -0
- package/dist/core/sdd/services/context.service.d.ts +346 -0
- package/dist/core/sdd/services/context.service.js +278 -0
- package/dist/core/sdd/services/debate.service.d.ts +16 -0
- package/dist/core/sdd/services/debate.service.js +73 -0
- package/dist/core/sdd/services/decide.service.d.ts +23 -0
- package/dist/core/sdd/services/decide.service.js +81 -0
- package/dist/core/sdd/services/dedup-apply.service.d.ts +39 -0
- package/dist/core/sdd/services/dedup-apply.service.js +259 -0
- package/dist/core/sdd/services/feature-lint.service.d.ts +29 -0
- package/dist/core/sdd/services/feature-lint.service.js +146 -0
- package/dist/core/sdd/services/finalize.service.d.ts +33 -0
- package/dist/core/sdd/services/finalize.service.js +707 -0
- package/dist/core/sdd/services/frontend-gap.service.d.ts +23 -0
- package/dist/core/sdd/services/frontend-gap.service.js +117 -0
- package/dist/core/sdd/services/frontend-impact.service.d.ts +19 -0
- package/dist/core/sdd/services/frontend-impact.service.js +46 -0
- package/dist/core/sdd/services/ingest-deposito.service.d.ts +32 -0
- package/dist/core/sdd/services/ingest-deposito.service.js +231 -0
- package/dist/core/sdd/services/insight.service.d.ts +21 -0
- package/dist/core/sdd/services/insight.service.js +81 -0
- package/dist/core/sdd/services/legacy-capability.service.d.ts +24 -0
- package/dist/core/sdd/services/legacy-capability.service.js +59 -0
- package/dist/core/sdd/services/mcp-runtime.service.d.ts +42 -0
- package/dist/core/sdd/services/mcp-runtime.service.js +144 -0
- package/dist/core/sdd/services/metrics.service.d.ts +49 -0
- package/dist/core/sdd/services/metrics.service.js +181 -0
- package/dist/core/sdd/services/next.service.d.ts +35 -0
- package/dist/core/sdd/services/next.service.js +54 -0
- package/dist/core/sdd/services/onboard.service.d.ts +9 -0
- package/dist/core/sdd/services/onboard.service.js +165 -0
- package/dist/core/sdd/services/rebuild.service.d.ts +31 -0
- package/dist/core/sdd/services/rebuild.service.js +482 -0
- package/dist/core/sdd/services/scan-naming.service.d.ts +43 -0
- package/dist/core/sdd/services/scan-naming.service.js +246 -0
- package/dist/core/sdd/services/skills-invoke.service.d.ts +24 -0
- package/dist/core/sdd/services/skills-invoke.service.js +63 -0
- package/dist/core/sdd/services/skills-sync.service.d.ts +15 -0
- package/dist/core/sdd/services/skills-sync.service.js +117 -0
- package/dist/core/sdd/services/start.service.d.ts +26 -0
- package/dist/core/sdd/services/start.service.js +237 -0
- package/dist/core/sdd/skills.d.ts +15 -0
- package/dist/core/sdd/skills.js +46 -0
- package/dist/core/sdd/state-lock.d.ts +19 -0
- package/dist/core/sdd/state-lock.js +144 -0
- package/dist/core/sdd/state.d.ts +155 -0
- package/dist/core/sdd/state.js +1000 -0
- package/dist/core/sdd/store/in-memory-adapter.d.ts +12 -0
- package/dist/core/sdd/store/in-memory-adapter.js +27 -0
- package/dist/core/sdd/store/index.d.ts +5 -0
- package/dist/core/sdd/store/index.js +5 -0
- package/dist/core/sdd/store/sdd-stores.d.ts +25 -0
- package/dist/core/sdd/store/sdd-stores.js +59 -0
- package/dist/core/sdd/store/state-store.d.ts +32 -0
- package/dist/core/sdd/store/state-store.js +2 -0
- package/dist/core/sdd/store/yaml-file-adapter.d.ts +12 -0
- package/dist/core/sdd/store/yaml-file-adapter.js +43 -0
- package/dist/core/sdd/structural-health.d.ts +557 -0
- package/dist/core/sdd/structural-health.js +187 -0
- package/dist/core/sdd/transaction.d.ts +14 -0
- package/dist/core/sdd/transaction.js +100 -0
- package/dist/core/sdd/types.d.ts +1570 -0
- package/dist/core/sdd/types.js +617 -0
- package/dist/core/sdd/views.d.ts +3 -0
- package/dist/core/sdd/views.js +560 -0
- package/dist/core/sdd/workspace-schemas.d.ts +620 -0
- package/dist/core/sdd/workspace-schemas.js +254 -0
- package/dist/core/sdd/write-manifest.d.ts +25 -0
- package/dist/core/sdd/write-manifest.js +353 -0
- package/dist/core/shared/index.d.ts +8 -0
- package/dist/core/shared/index.js +8 -0
- package/dist/core/shared/skill-generation.d.ts +49 -0
- package/dist/core/shared/skill-generation.js +106 -0
- package/dist/core/shared/tool-detection.d.ts +71 -0
- package/dist/core/shared/tool-detection.js +158 -0
- package/dist/core/specs-apply.d.ts +73 -0
- package/dist/core/specs-apply.js +385 -0
- package/dist/core/styles/palette.d.ts +7 -0
- package/dist/core/styles/palette.js +8 -0
- package/dist/core/templates/index.d.ts +8 -0
- package/dist/core/templates/index.js +9 -0
- package/dist/core/templates/skill-templates.d.ts +20 -0
- package/dist/core/templates/skill-templates.js +19 -0
- package/dist/core/templates/types.d.ts +19 -0
- package/dist/core/templates/types.js +5 -0
- package/dist/core/templates/workflows/apply-change.d.ts +10 -0
- package/dist/core/templates/workflows/apply-change.js +308 -0
- package/dist/core/templates/workflows/archive-change.d.ts +10 -0
- package/dist/core/templates/workflows/archive-change.js +277 -0
- package/dist/core/templates/workflows/bulk-archive-change.d.ts +10 -0
- package/dist/core/templates/workflows/bulk-archive-change.js +502 -0
- package/dist/core/templates/workflows/continue-change.d.ts +10 -0
- package/dist/core/templates/workflows/continue-change.js +232 -0
- package/dist/core/templates/workflows/explore.d.ts +10 -0
- package/dist/core/templates/workflows/explore.js +475 -0
- package/dist/core/templates/workflows/feedback.d.ts +9 -0
- package/dist/core/templates/workflows/feedback.js +108 -0
- package/dist/core/templates/workflows/ff-change.d.ts +10 -0
- package/dist/core/templates/workflows/ff-change.js +206 -0
- package/dist/core/templates/workflows/new-change.d.ts +10 -0
- package/dist/core/templates/workflows/new-change.js +151 -0
- package/dist/core/templates/workflows/onboard.d.ts +10 -0
- package/dist/core/templates/workflows/onboard.js +573 -0
- package/dist/core/templates/workflows/propose.d.ts +10 -0
- package/dist/core/templates/workflows/propose.js +224 -0
- package/dist/core/templates/workflows/sdd.d.ts +10 -0
- package/dist/core/templates/workflows/sdd.js +107 -0
- package/dist/core/templates/workflows/sync-specs.d.ts +10 -0
- package/dist/core/templates/workflows/sync-specs.js +286 -0
- package/dist/core/templates/workflows/verify-change.d.ts +10 -0
- package/dist/core/templates/workflows/verify-change.js +346 -0
- package/dist/core/update.d.ts +77 -0
- package/dist/core/update.js +538 -0
- package/dist/core/validation/constants.d.ts +34 -0
- package/dist/core/validation/constants.js +40 -0
- package/dist/core/validation/types.d.ts +18 -0
- package/dist/core/validation/types.js +2 -0
- package/dist/core/validation/validator.d.ts +33 -0
- package/dist/core/validation/validator.js +409 -0
- package/dist/core/view.d.ts +8 -0
- package/dist/core/view.js +170 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/prompts/searchable-multi-select.d.ts +28 -0
- package/dist/prompts/searchable-multi-select.js +159 -0
- package/dist/telemetry/config.d.ts +32 -0
- package/dist/telemetry/config.js +68 -0
- package/dist/telemetry/index.d.ts +44 -0
- package/dist/telemetry/index.js +207 -0
- package/dist/ui/ascii-patterns.d.ts +16 -0
- package/dist/ui/ascii-patterns.js +133 -0
- package/dist/ui/welcome-screen.d.ts +10 -0
- package/dist/ui/welcome-screen.js +146 -0
- package/dist/utils/change-metadata.d.ts +51 -0
- package/dist/utils/change-metadata.js +147 -0
- package/dist/utils/change-utils.d.ts +62 -0
- package/dist/utils/change-utils.js +121 -0
- package/dist/utils/command-references.d.ts +18 -0
- package/dist/utils/command-references.js +20 -0
- package/dist/utils/file-system.d.ts +36 -0
- package/dist/utils/file-system.js +281 -0
- package/dist/utils/index.d.ts +6 -0
- package/dist/utils/index.js +9 -0
- package/dist/utils/interactive.d.ts +18 -0
- package/dist/utils/interactive.js +21 -0
- package/dist/utils/item-discovery.d.ts +4 -0
- package/dist/utils/item-discovery.js +73 -0
- package/dist/utils/match.d.ts +3 -0
- package/dist/utils/match.js +22 -0
- package/dist/utils/openspec-compat.d.ts +2 -0
- package/dist/utils/openspec-compat.js +2 -0
- package/dist/utils/shell-detection.d.ts +20 -0
- package/dist/utils/shell-detection.js +41 -0
- package/dist/utils/task-progress.d.ts +8 -0
- package/dist/utils/task-progress.js +36 -0
- package/package.json +111 -0
- package/schemas/sdd/1-spec.schema.json +221 -0
- package/schemas/sdd/2-plan.schema.json +199 -0
- package/schemas/sdd/3-tasks.schema.json +102 -0
- package/schemas/sdd/4-changelog.schema.json +55 -0
- package/schemas/sdd/5-quality.schema.json +427 -0
- package/schemas/sdd/workspace-catalog.schema.json +1012 -0
- package/schemas/spec-driven/schema.yaml +153 -0
- package/schemas/spec-driven/templates/design.md +19 -0
- package/schemas/spec-driven/templates/proposal.md +23 -0
- package/schemas/spec-driven/templates/spec.md +8 -0
- 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
|
+
```
|