symfonia-ai-tools 1.7.1 → 1.9.0

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/README.md CHANGED
@@ -19,21 +19,33 @@ symfonia-ai-tools
19
19
 
20
20
  The configurator guides you through:
21
21
  1. Language selection (Polish / English)
22
- 2. Project type, description, tech stack
23
- 3. Instruction & skill packs (checkboxes)
24
- 4. AI tools to configure (checkboxes)
25
- 5. Skills to install (checkboxes — base + from selected packs)
26
- 6. Paths, commands, CI pipeline, JIRA prefix
27
- 7. MCP servers (checkboxes + tokens)
28
- 8. CLI installation (checkboxes)
22
+ 2. **Install scope** choose what to install
23
+ 3. Project type, description, tech stack *(full setup only)*
24
+ 4. Instruction & skill packs (checkboxes)
25
+ 5. AI tools to configure (checkboxes)
26
+ 6. Skills to install (checkboxes base + from selected packs)
27
+ 7. Paths, commands, CI pipeline, JIRA prefix *(full setup only)*
28
+ 8. MCP servers (checkboxes + tokens)
29
+ 9. CLI installation (checkboxes) *(full setup only)*
29
30
 
30
31
  The entire configurator uses arrow-key navigation — no typing numbers.
31
32
 
32
- Finally, it automatically runs GSD:
33
+ Finally (full setup only), it automatically runs GSD:
33
34
  - **New project** → `/gsd-new-project`
34
35
  - **Existing project** → `/gsd-map-codebase` → `/gsd-new-project`
35
36
 
36
- ### Installation modes (existing project)
37
+ ### Install scopes
38
+
39
+ | Scope | What it installs | Use case |
40
+ |-------|-----------------|----------|
41
+ | **Full setup** | Everything — files, skills, MCP, CLI, GSD bootstrap | First-time setup or full reinstall |
42
+ | **Skills only** | Selected skills → `.ai/skills/` + mirrors | Add new skills to existing project |
43
+ | **MCP only** | MCP server configuration | Add/update MCP servers |
44
+ | **Skills + MCP** | Skills and MCP servers | Add skills and MCP in one go |
45
+
46
+ Slim scopes (skills only, MCP only, skills + MCP) skip project metadata, commands, CI, JIRA, and CLI installation — they only ask for what's needed.
47
+
48
+ ### Installation modes (existing project, full setup)
37
49
 
38
50
  | Mode | Description |
39
51
  |------|-------------|
@@ -73,6 +85,7 @@ Instead of choosing a single stack, you pick any combination of instruction & sk
73
85
  | **Vitest** | 1 instruction | Yes |
74
86
  | **Storybook** | 1 instruction | No |
75
87
  | **Laravel + PHP** | 4 instructions, 7 skills | No |
88
+ | **Python FastAPI** | 4 instructions, 7 skills | No |
76
89
  | **Playwright E2E** | 1 instruction, 3 skills | No |
77
90
  | **Docker** | 1 instruction | No |
78
91
 
@@ -155,6 +168,15 @@ When AI opens/edits a file matching `applyTo`, it automatically loads those inst
155
168
  | `api-resource.instructions.md` | `**/*Resource*.php, **/*Request*.php` | Resources with whenLoaded(), Form Requests, Policies |
156
169
  | `testing.instructions.md` | `**/*Test*.php, **/tests/**` | PHPUnit, data providers, factories, Laravel fakes |
157
170
 
171
+ ### Python FastAPI (4 instructions)
172
+
173
+ | Instruction | applyTo | What it teaches AI |
174
+ |-------------|---------|-------------------|
175
+ | `api-schema.instructions.md` | `**/schemas.py, **/*schema*.py, **/*request*.py, **/*response*.py` | Pydantic v2 schemas, StrEnum status fields, `PaginatedResponse[T]`, frozen request models, `ErrorResponse` |
176
+ | `module.instructions.md` | `**/modules/**/router.py, **/modules/**/dependencies.py, **/modules/**/domain/**, **/modules/**/infrastructure/**` | Module structure, FastAPI router pattern, SQLAlchemy 2.0 ORM models, `lifespan=` setup, DI wiring |
177
+ | `service-repository.instructions.md` | `**/domain/services.py, **/infrastructure/repositories/**, **/domain/entities.py, **/domain/value_objects.py` | CQRS repos, domain entities, EventPublisher pattern, layer isolation rules |
178
+ | `testing.instructions.md` | `**/tests/**, **/*test*.py, **/conftest.py` | pytest-asyncio conftest, `async_sessionmaker`, rollback pattern, `AsyncClient` |
179
+
158
180
  ### Playwright (1 instruction)
159
181
 
160
182
  | Instruction | applyTo | What it teaches AI |
@@ -267,6 +289,18 @@ All skills have the `smf-` prefix (Symfonia). The configurator lets you choose w
267
289
  | `smf-testing-unit` | Unit tests | Isolated tests without DB, PHPUnit mocks, data providers |
268
290
  | `smf-testing-manual` | Manual HTTP tests | JetBrains HTTP Client — `.http` files for interactive API testing |
269
291
 
292
+ ### Python FastAPI skills (7 skills)
293
+
294
+ | Skill | Purpose | When to use |
295
+ |-------|---------|-------------|
296
+ | `smf-new-module` | New module from scratch | router → schemas → ORM model → CQRS repos → service → migration → tests |
297
+ | `smf-new-endpoint` | New endpoint in existing module | request schema → route → service method → response schema → tests |
298
+ | `smf-migration` | Alembic migration | autogenerate → review → upgrade → verify; async env.py setup |
299
+ | `smf-background-task` | Celery background task | task def (sync) → `SyncSessionLocal` → dispatch from router → beat schedule → tests |
300
+ | `smf-testing-integration` | Integration tests (HTTP) | `AsyncClient` → real DB via rollback conftest → router assertions |
301
+ | `smf-testing-unit` | Unit tests (service) | mock repos → service logic → no DB needed |
302
+ | `smf-testing-manual` | Manual HTTP tests | JetBrains HTTP Client `.http` files for interactive API testing |
303
+
270
304
  ### Playwright skills (3 skills)
271
305
 
272
306
  | Skill | Purpose | When to use |
@@ -524,6 +558,7 @@ templates/
524
558
  ├── vitest/ # 1 instr.
525
559
  ├── storybook/ # 1 instr.
526
560
  ├── laravel/ # 4 instr. · 7 skills
561
+ ├── python-fastapi/ # 4 instr. · 7 skills
527
562
  ├── playwright/ # 1 instr. · 3 skills
528
563
  └── docker/ # 1 instr.
529
564
  ```
package/lib/i18n.mjs CHANGED
@@ -32,6 +32,15 @@ const translations = {
32
32
  'q.quit_hint': 'Wpisz',
33
33
  'q.quit_hint2': 'aby przerwac w dowolnym momencie.',
34
34
 
35
+ // questions.mjs — install scope
36
+ 'q.section.scope': 'Zakres instalacji',
37
+ 'q.scope.select': 'Co chcesz zainstalowac?',
38
+ 'q.scope.full': 'Pelny setup',
39
+ 'q.scope.full_hint': '(pliki, skille, MCP, CLI)',
40
+ 'q.scope.skills_only': 'Tylko skille',
41
+ 'q.scope.mcp_only': 'Tylko serwery MCP',
42
+ 'q.scope.skills_mcp': 'Skille + MCP',
43
+
35
44
  // questions.mjs — project
36
45
  'q.section.project': 'Projekt',
37
46
  'q.project_type': 'Typ projektu:',
@@ -143,12 +152,16 @@ const translations = {
143
152
  'i.mode.overwrite': 'nadpisz wszystko',
144
153
  'i.mode.skip': 'tylko nowe pliki',
145
154
  'i.mode.mcp': 'tylko MCP',
155
+ 'i.mode.skills': 'tylko skille',
156
+ 'i.mode.skills_mcp': 'skille + MCP',
146
157
  'i.dir': 'Katalog:',
147
158
  'i.mode_label': 'Tryb:',
148
159
  'i.step.base': 'Szablony bazowe',
149
160
  'i.step.pack': 'Pakiet',
150
161
  'i.step.guidelines': 'Skladanie',
151
162
  'i.step.mirror': 'Mirror',
163
+ 'i.step.skills': 'Kopiowanie skilli',
164
+ 'i.step.skills_mirror': 'Mirror skilli',
152
165
  'i.step.mcp': 'Konfiguracja MCP',
153
166
  'i.step.cli': 'Instalacja CLI',
154
167
 
@@ -170,6 +183,8 @@ const translations = {
170
183
 
171
184
  // installer.mjs — next steps
172
185
  'i.done.mcp': 'Konfiguracja MCP zakonczona!',
186
+ 'i.done.skills': 'Instalacja skilli zakonczona!',
187
+ 'i.done.skills_mcp': 'Instalacja skilli i MCP zakonczona!',
173
188
  'i.done.install': 'Instalacja zakonczona!',
174
189
  'i.done.check_mcp': 'Sprawdz MCP:',
175
190
  'i.done.install_gsd': 'Zainstaluj GSD',
@@ -213,6 +228,15 @@ const translations = {
213
228
  'q.quit_hint': 'Type',
214
229
  'q.quit_hint2': 'to abort at any time.',
215
230
 
231
+ // questions.mjs — install scope
232
+ 'q.section.scope': 'Installation scope',
233
+ 'q.scope.select': 'What do you want to install?',
234
+ 'q.scope.full': 'Full setup',
235
+ 'q.scope.full_hint': '(files, skills, MCP, CLI)',
236
+ 'q.scope.skills_only': 'Skills only',
237
+ 'q.scope.mcp_only': 'MCP servers only',
238
+ 'q.scope.skills_mcp': 'Skills + MCP',
239
+
216
240
  // questions.mjs — project
217
241
  'q.section.project': 'Project',
218
242
  'q.project_type': 'Project type:',
@@ -324,12 +348,16 @@ const translations = {
324
348
  'i.mode.overwrite': 'overwrite all',
325
349
  'i.mode.skip': 'new files only',
326
350
  'i.mode.mcp': 'MCP only',
351
+ 'i.mode.skills': 'skills only',
352
+ 'i.mode.skills_mcp': 'skills + MCP',
327
353
  'i.dir': 'Directory:',
328
354
  'i.mode_label': 'Mode:',
329
355
  'i.step.base': 'Base templates',
330
356
  'i.step.pack': 'Pack',
331
357
  'i.step.guidelines': 'Assembling',
332
358
  'i.step.mirror': 'Mirror',
359
+ 'i.step.skills': 'Copying skills',
360
+ 'i.step.skills_mirror': 'Mirroring skills',
333
361
  'i.step.mcp': 'MCP configuration',
334
362
  'i.step.cli': 'CLI installation',
335
363
 
@@ -351,6 +379,8 @@ const translations = {
351
379
 
352
380
  // installer.mjs — next steps
353
381
  'i.done.mcp': 'MCP configuration complete!',
382
+ 'i.done.skills': 'Skills installation complete!',
383
+ 'i.done.skills_mcp': 'Skills & MCP installation complete!',
354
384
  'i.done.install': 'Installation complete!',
355
385
  'i.done.check_mcp': 'Check MCP:',
356
386
  'i.done.install_gsd': 'Install GSD',
package/lib/installer.mjs CHANGED
@@ -116,6 +116,12 @@ export async function install(packageRoot, answers) {
116
116
  const templatesDir = join(packageRoot, 'templates');
117
117
  const mode = answers.installMode || 'fresh';
118
118
 
119
+ // Handle slim install scopes (skills-only, mcp-only, skills+mcp)
120
+ const installScope = answers.installScope || 'full';
121
+ if (installScope !== 'full') {
122
+ return await installSlim(targetDir, templatesDir, answers, installScope);
123
+ }
124
+
119
125
  const modeLabel = mode === 'fresh' ? boldGreen(t('i.mode.fresh'))
120
126
  : mode === 'overwrite' ? boldYellow(t('i.mode.overwrite'))
121
127
  : mode === 'skip-existing' ? boldCyan(t('i.mode.skip'))
@@ -183,6 +189,100 @@ export async function install(packageRoot, answers) {
183
189
  }
184
190
  }
185
191
 
192
+ // ─── Slim install (skills-only, mcp-only, skills+mcp) ───
193
+
194
+ async function installSlim(targetDir, templatesDir, answers, scope) {
195
+ const needsSkills = scope !== 'mcp-only';
196
+ const needsMcp = scope !== 'skills-only';
197
+ const needsMirror = needsSkills && (answers.toolCopilot || answers.toolClaude);
198
+
199
+ const totalSteps = (needsSkills ? 1 : 0) + (needsMirror ? 1 : 0) + (needsMcp ? 1 : 0);
200
+ let currentStep = 0;
201
+
202
+ const modeLabel = scope === 'skills-only' ? boldCyan(t('i.mode.skills'))
203
+ : scope === 'mcp-only' ? boldCyan(t('i.mode.mcp'))
204
+ : boldCyan(t('i.mode.skills_mcp'));
205
+
206
+ section(t('i.section.install'));
207
+ console.log(info(`${t('i.dir')} ${bold(targetDir)}`));
208
+ console.log(info(`${t('i.mode_label')} ${modeLabel}`));
209
+ console.log('');
210
+
211
+ if (needsSkills) {
212
+ step(++currentStep, totalSteps, t('i.step.skills'));
213
+ await installSkills(templatesDir, targetDir, answers);
214
+
215
+ if (needsMirror) {
216
+ step(++currentStep, totalSteps, t('i.step.skills_mirror'));
217
+ const aiSkillsDir = join(targetDir, '.ai', 'skills');
218
+ if (answers.toolCopilot) {
219
+ await mirrorDir(aiSkillsDir, join(targetDir, '.github', 'skills'), targetDir, 'overwrite');
220
+ }
221
+ if (answers.toolClaude) {
222
+ await mirrorDir(aiSkillsDir, join(targetDir, '.claude', 'skills'), targetDir, 'overwrite');
223
+ }
224
+ }
225
+ }
226
+
227
+ if (needsMcp) {
228
+ step(++currentStep, totalSteps, t('i.step.mcp'));
229
+ await generateMcpConfig(targetDir, answers);
230
+ }
231
+
232
+ console.log('');
233
+ const doneKey = scope === 'skills-only' ? 'i.done.skills'
234
+ : scope === 'mcp-only' ? 'i.done.mcp'
235
+ : 'i.done.skills_mcp';
236
+ box([boldGreen(` ${t(doneKey)}`)], boldGreen);
237
+ console.log('');
238
+
239
+ if (needsMcp && answers.toolClaude) {
240
+ console.log(info(`${t('i.done.check_mcp')} ${cyan('claude mcp list')}`));
241
+ }
242
+ }
243
+
244
+ async function installSkills(templatesDir, targetDir, answers) {
245
+ const selectedSkills = answers.selectedSkills || [];
246
+ if (selectedSkills.length === 0) {
247
+ console.log(` ${dim(t('q.skills.none'))}`);
248
+ return;
249
+ }
250
+
251
+ const aiSkillsDir = join(targetDir, '.ai', 'skills');
252
+ await mkdir(aiSkillsDir, { recursive: true });
253
+
254
+ // Base skills
255
+ const baseSkillsDir = join(templatesDir, 'base', '_ai', 'skills');
256
+ try {
257
+ const entries = await readdir(baseSkillsDir, { withFileTypes: true });
258
+ for (const e of entries) {
259
+ if (e.isDirectory() && selectedSkills.includes(e.name)) {
260
+ const dest = join(aiSkillsDir, e.name);
261
+ await mkdir(dest, { recursive: true });
262
+ await copyTemplateDir(join(baseSkillsDir, e.name), dest, answers, 'overwrite');
263
+ }
264
+ }
265
+ } catch { /* no base skills */ }
266
+
267
+ // Pack skills
268
+ const allPacks = await loadPacks(join(templatesDir, 'packs'));
269
+ for (const packId of (answers.packs || [])) {
270
+ const pack = allPacks[packId];
271
+ if (!pack) continue;
272
+ const packSkillsDir = join(pack._dir, '_ai', 'skills');
273
+ try {
274
+ const entries = await readdir(packSkillsDir, { withFileTypes: true });
275
+ for (const e of entries) {
276
+ if (e.isDirectory() && selectedSkills.includes(e.name)) {
277
+ const dest = join(aiSkillsDir, e.name);
278
+ await mkdir(dest, { recursive: true });
279
+ await copyTemplateDir(join(packSkillsDir, e.name), dest, answers, 'overwrite');
280
+ }
281
+ }
282
+ } catch { /* no pack skills */ }
283
+ }
284
+ }
285
+
186
286
  // ─── Guidelines assembly ───
187
287
 
188
288
  async function assembleGuidelines(templatesDir, targetDir, answers, selectedPacks, allPacks, mode) {
package/lib/questions.mjs CHANGED
@@ -80,6 +80,25 @@ export async function askQuestions(packsDir) {
80
80
  console.log('');
81
81
  console.log(` ${dim(t('q.quit_hint'))} ${yellow('"q"')} ${dim(t('q.quit_hint2'))}`);
82
82
 
83
+ // --- Install scope ---
84
+ section(t('q.section.scope'));
85
+ answers.installScope = await askChoice(t('q.scope.select'), [
86
+ 'full',
87
+ 'skills-only',
88
+ 'mcp-only',
89
+ 'skills+mcp',
90
+ ], [
91
+ `${boldGreen(t('q.scope.full'))} ${dim(t('q.scope.full_hint'))}`,
92
+ `${boldCyan(t('q.scope.skills_only'))}`,
93
+ `${boldCyan(t('q.scope.mcp_only'))}`,
94
+ `${boldCyan(t('q.scope.skills_mcp'))}`,
95
+ ]);
96
+
97
+ // Slim flows — early return
98
+ if (answers.installScope !== 'full') {
99
+ return await askSlimFlow(answers, packsDir);
100
+ }
101
+
83
102
  // --- Project type ---
84
103
  section(t('q.section.project'));
85
104
 
@@ -156,9 +175,10 @@ export async function askQuestions(packsDir) {
156
175
 
157
176
  const hasVue = answers.packs.includes('vue3');
158
177
  const hasLaravel = answers.packs.includes('laravel');
159
- answers.testCommand = await ask(t('q.cmd.test'), hasVue ? 'npm run test' : hasLaravel ? 'php artisan test' : 'npm test');
160
- answers.buildCommand = await ask(t('q.cmd.build'), hasVue ? 'npm run build' : hasLaravel ? 'composer build' : 'npm run build');
161
- answers.lintCommand = await ask(t('q.cmd.lint'), hasVue ? 'npm run lint' : hasLaravel ? 'php artisan pint' : 'npm run lint');
178
+ const hasPythonFastapi = answers.packs.includes('python-fastapi');
179
+ answers.testCommand = await ask(t('q.cmd.test'), hasVue ? 'npm run test' : hasLaravel ? 'php artisan test' : hasPythonFastapi ? 'pytest' : 'npm test');
180
+ answers.buildCommand = await ask(t('q.cmd.build'), hasVue ? 'npm run build' : hasLaravel ? 'composer build' : hasPythonFastapi ? 'docker build .' : 'npm run build');
181
+ answers.lintCommand = await ask(t('q.cmd.lint'), hasVue ? 'npm run lint' : hasLaravel ? 'php artisan pint' : hasPythonFastapi ? 'ruff check . && mypy .' : 'npm run lint');
162
182
 
163
183
  // --- CI ---
164
184
  section(t('q.section.ci'));
@@ -493,3 +513,88 @@ function printSummary(answers, allPacks) {
493
513
  tableRow(t('q.summary.gsd'), cyan(gsdSteps.join(dim(' → '))));
494
514
  }
495
515
  }
516
+
517
+ // ─── Slim flow (skills-only, mcp-only, skills+mcp) ───
518
+
519
+ async function askSlimFlow(answers, packsDir) {
520
+ const scope = answers.installScope;
521
+ const needsSkills = scope !== 'mcp-only';
522
+ const needsMcp = scope !== 'skills-only';
523
+
524
+ // --- Target directory ---
525
+ answers.targetDir = await ask(t('q.target_dir'), '.');
526
+ answers.installMode = 'overwrite';
527
+
528
+ // --- Tool selection ---
529
+ await askToolSelection(answers);
530
+
531
+ // --- Skills flow ---
532
+ let allPacks = {};
533
+ if (needsSkills) {
534
+ allPacks = await loadPacks(packsDir);
535
+ await askPackSelection(answers, allPacks);
536
+ const baseSkillsDir = join(packsDir, '..', 'base', '_ai', 'skills');
537
+ await askSkillSelection(answers, allPacks, baseSkillsDir);
538
+ await askPackPlaceholders(answers, allPacks);
539
+ checkCliDeps(answers.selectedSkills || []);
540
+ } else {
541
+ answers.packs = [];
542
+ answers.selectedSkills = [];
543
+ }
544
+
545
+ // --- MCP flow ---
546
+ if (needsMcp) {
547
+ const requiredMcps = collectRequiredMcps(answers.selectedSkills || []);
548
+ await askMcpServers(answers, requiredMcps);
549
+ } else {
550
+ answers.mcpJira = false;
551
+ answers.mcpBitbucket = false;
552
+ answers.mcpFigma = false;
553
+ answers.mcpGrafana = false;
554
+ answers.mcpContext7 = false;
555
+ }
556
+
557
+ // --- Defaults for unused fields ---
558
+ answers.installClaudeCli = false;
559
+ answers.installGsd = false;
560
+
561
+ // --- Summary ---
562
+ printSlimSummary(answers, allPacks);
563
+
564
+ const proceed = await askYN(`\n ${t('q.summary.continue')}`, true);
565
+ if (!proceed) throw new Error('USER_ABORT');
566
+
567
+ return answers;
568
+ }
569
+
570
+ function printSlimSummary(answers, allPacks) {
571
+ section(t('q.section.summary'));
572
+
573
+ tableRow(t('q.summary.dir'), answers.targetDir);
574
+
575
+ const tools = [];
576
+ if (answers.toolClaude) tools.push('Claude');
577
+ if (answers.toolCopilot) tools.push('Copilot');
578
+ if (answers.toolCursor) tools.push('Cursor');
579
+ if (answers.toolGemini) tools.push('Gemini');
580
+ if (answers.toolJunie) tools.push('Junie');
581
+ tableRow(t('q.summary.tools'), tools.length > 0 ? cyan(tools.join(dim(' · '))) : dim(t('q.summary.none')));
582
+
583
+ if (answers.installScope !== 'mcp-only') {
584
+ const packNames = (answers.packs || []).map(id => allPacks[id]?.name || id);
585
+ tableRow(t('q.summary.packs'), packNames.length > 0 ? cyan(packNames.join(dim(' · '))) : dim(t('q.summary.none')));
586
+
587
+ const skills = answers.selectedSkills || [];
588
+ tableRow(t('q.summary.skills'), skills.length > 0 ? cyan(skills.join(dim(' · '))) : dim(t('q.summary.none')));
589
+ }
590
+
591
+ if (answers.installScope !== 'skills-only') {
592
+ const mcps = [];
593
+ if (answers.mcpJira) mcps.push('Jira');
594
+ if (answers.mcpBitbucket) mcps.push('Bitbucket');
595
+ if (answers.mcpFigma) mcps.push('Figma');
596
+ if (answers.mcpGrafana) mcps.push('Grafana');
597
+ if (answers.mcpContext7) mcps.push('Context7');
598
+ tableRow(t('q.summary.mcp'), mcps.length > 0 ? cyan(mcps.join(dim(' · '))) : dim(t('q.summary.none')));
599
+ }
600
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symfonia-ai-tools",
3
- "version": "1.7.1",
3
+ "version": "1.9.0",
4
4
  "description": "AI tooling setup for your project - Claude Code, GitHub Copilot, Cursor, Gemini, Junie, GSD",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,177 @@
1
+ ---
2
+ applyTo: "**/schemas.py,**/*schema*.py,**/*request*.py,**/*response*.py"
3
+ ---
4
+
5
+ # API Schemas, Request & Response Models — Instructions
6
+
7
+ ## Pydantic v2 Schemas
8
+
9
+ Never return ORM models directly from endpoints. All input/output passes through Pydantic schemas.
10
+
11
+ ```python
12
+ from __future__ import annotations
13
+
14
+ from datetime import datetime
15
+ from uuid import UUID
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field
18
+
19
+
20
+ class FeatureResponse(BaseModel):
21
+ model_config = ConfigDict(from_attributes=True)
22
+
23
+ id: UUID
24
+ name: str
25
+ description: str | None
26
+ status: FeatureStatus # use the StrEnum — not bare `str`
27
+ created_at: datetime
28
+
29
+ # Nested relations — use None default (loaded on demand)
30
+ author: UserResponse | None = None
31
+ category: CategoryResponse | None = None
32
+ ```
33
+
34
+ ### Request Schemas (Input Validation)
35
+
36
+ Request schemas are **read-only once validated** — add `frozen=True` to catch accidental mutation:
37
+
38
+ ```python
39
+ from __future__ import annotations
40
+
41
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
42
+ from uuid import UUID
43
+
44
+
45
+ class StoreFeatureRequest(BaseModel):
46
+ model_config = ConfigDict(frozen=True)
47
+
48
+ name: str = Field(min_length=2, max_length=255)
49
+ description: str | None = Field(default=None, max_length=5000)
50
+ category_id: UUID | None = None
51
+ status: FeatureStatus
52
+ tags: list[str] = Field(default_factory=list)
53
+
54
+ @field_validator("tags")
55
+ @classmethod
56
+ def validate_tags(cls, v: list[str]) -> list[str]:
57
+ if any(len(tag) > 50 for tag in v):
58
+ raise ValueError("Each tag must be at most 50 characters")
59
+ return v
60
+
61
+
62
+ class UpdateFeatureRequest(BaseModel):
63
+ model_config = ConfigDict(frozen=True)
64
+
65
+ name: str | None = Field(default=None, min_length=2, max_length=255)
66
+ description: str | None = None
67
+ status: FeatureStatus | None = None
68
+ ```
69
+
70
+ ### List / Index Request with Filtering and Sorting
71
+
72
+ ```python
73
+ from __future__ import annotations
74
+
75
+ from typing import Literal
76
+ from pydantic import BaseModel, Field
77
+
78
+
79
+ class IndexFeatureRequest(BaseModel):
80
+ page: int = Field(default=1, ge=1)
81
+ limit: int = Field(default=20, ge=1, le=100)
82
+ status: FeatureStatus | None = None
83
+ sort_by: Literal["created_at", "name", "status"] = "created_at"
84
+ sort_dir: Literal["asc", "desc"] = "desc"
85
+ ```
86
+
87
+ ### Paginated Collection Response
88
+
89
+ `PaginatedResponse` and `PaginationMeta` are **shared types** — define them once in `app/core/schemas.py`, never per-module:
90
+
91
+ ```python
92
+ # app/core/schemas.py
93
+ from __future__ import annotations
94
+
95
+ from typing import Generic, TypeVar
96
+ from pydantic import BaseModel
97
+
98
+ T = TypeVar("T")
99
+
100
+
101
+ class PaginationMeta(BaseModel):
102
+ total: int
103
+ per_page: int
104
+ current_page: int
105
+ last_page: int
106
+
107
+
108
+ class PaginatedResponse(BaseModel, Generic[T]):
109
+ data: list[T]
110
+ meta: PaginationMeta
111
+ ```
112
+
113
+ Import from `app.core.schemas` in every module that needs it.
114
+
115
+ ## Error Responses
116
+
117
+ Standardize the error body shape. Define `ErrorResponse` in `app/core/schemas.py`:
118
+
119
+ ```python
120
+ # app/core/schemas.py
121
+ class ErrorResponse(BaseModel):
122
+ code: str # machine-readable code: "FEATURE_NOT_FOUND"
123
+ message: str # human-readable description
124
+ field: str | None = None # which field caused the error (optional)
125
+ ```
126
+
127
+ Raise via `HTTPException` — the exception handler converts it:
128
+
129
+ ```python
130
+ from fastapi import HTTPException, status
131
+
132
+ # In service (expected domain errors):
133
+ raise HTTPException(
134
+ status_code=status.HTTP_404_NOT_FOUND,
135
+ detail={"code": "FEATURE_NOT_FOUND", "message": "Feature not found"},
136
+ )
137
+ ```
138
+
139
+ Register a handler in `app/main.py` to enforce consistent shape:
140
+
141
+ ```python
142
+ from fastapi import Request
143
+ from fastapi.responses import JSONResponse
144
+
145
+ @app.exception_handler(HTTPException)
146
+ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
147
+ if isinstance(exc.detail, dict):
148
+ body = exc.detail
149
+ else:
150
+ body = {"code": "HTTP_ERROR", "message": str(exc.detail)}
151
+ return JSONResponse(status_code=exc.status_code, content=body)
152
+ ```
153
+
154
+ ## Enums
155
+
156
+ ```python
157
+ from enum import StrEnum
158
+
159
+
160
+ class FeatureStatus(StrEnum):
161
+ DRAFT = "draft"
162
+ ACTIVE = "active"
163
+ ARCHIVED = "archived"
164
+ ```
165
+
166
+ Use `StrEnum` (Python 3.11+) — auto-serializes to string in JSON.
167
+
168
+ ## Rules
169
+
170
+ - `from_attributes=True` on every response schema that reads from ORM
171
+ - Never expose internal IDs as integers — use UUID
172
+ - Use `Field(...)` for constraints, not just annotations
173
+ - `StrEnum` for all enums — clean JSON serialization, use in both request and response schemas
174
+ - Nested relations are always `| None = None` — populate only when loaded
175
+ - `frozen=True` on all request/input schemas — prevents accidental mutation after validation
176
+ - `PaginatedResponse[T]` and `PaginationMeta` live in `app/core/schemas.py` — import, never redefine
177
+ - `ErrorResponse` body has `code` (machine) and `message` (human) — register a global exception handler