forgedev 1.2.0 → 1.4.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 +57 -10
- package/bin/chainproof.js +126 -0
- package/bin/devforge.js +1 -1
- package/package.json +25 -7
- package/src/chainproof-bridge.js +330 -0
- package/src/ci-mode.js +85 -0
- package/src/claude-configurator.js +171 -78
- package/src/cli.js +30 -7
- package/src/composer.js +242 -214
- package/src/doctor-checks-chainproof.js +106 -0
- package/src/doctor-checks.js +39 -20
- package/src/doctor-prompts.js +9 -9
- package/src/doctor.js +37 -4
- package/src/guided.js +3 -3
- package/src/index.js +31 -10
- package/src/init-mode.js +76 -12
- package/src/menu.js +178 -0
- package/src/prompts.js +5 -12
- package/src/recommender.js +163 -30
- package/src/scanner.js +57 -2
- package/src/uat-generator.js +204 -189
- package/src/update-check.js +9 -4
- package/src/update.js +57 -13
- package/src/utils.js +162 -5
- package/templates/ai/guardrails-py/backend/app/ai/__init__.py +29 -0
- package/templates/ai/guardrails-py/backend/app/ai/audit_log.py +133 -0
- package/templates/ai/guardrails-py/backend/app/ai/client.py.template +323 -0
- package/templates/ai/guardrails-py/backend/app/ai/health.py.template +157 -0
- package/templates/ai/guardrails-py/backend/app/ai/input_guard.py +98 -0
- package/templates/ai/guardrails-ts/src/lib/ai/audit-log.ts.template +164 -0
- package/templates/ai/guardrails-ts/src/lib/ai/client.ts.template +403 -0
- package/templates/ai/guardrails-ts/src/lib/ai/health.ts.template +165 -0
- package/templates/ai/guardrails-ts/src/lib/ai/index.ts.template +17 -0
- package/templates/ai/guardrails-ts/src/lib/ai/input-guard.ts.template +124 -0
- package/templates/auth/nextauth/src/lib/auth.ts.template +12 -7
- package/templates/backend/express/Dockerfile.template +18 -0
- package/templates/backend/express/package.json.template +33 -0
- package/templates/backend/express/src/index.ts.template +34 -0
- package/templates/backend/express/src/routes/health.ts.template +27 -0
- package/templates/backend/express/tsconfig.json +17 -0
- package/templates/backend/fastapi/backend/Dockerfile.template +5 -0
- package/templates/backend/fastapi/backend/app/api/health.py.template +1 -1
- package/templates/backend/fastapi/backend/app/core/config.py.template +1 -1
- package/templates/backend/fastapi/backend/app/core/errors.py +1 -1
- package/templates/backend/fastapi/backend/app/main.py.template +3 -1
- package/templates/backend/fastapi/backend/requirements.txt.template +2 -0
- package/templates/backend/hono/Dockerfile.template +18 -0
- package/templates/backend/hono/package.json.template +31 -0
- package/templates/backend/hono/src/index.ts.template +32 -0
- package/templates/backend/hono/src/routes/health.ts.template +27 -0
- package/templates/backend/hono/tsconfig.json +18 -0
- package/templates/base/.gitignore.template +3 -0
- package/templates/base/docs/uat/UAT_TEMPLATE.md.template +1 -1
- package/templates/chainproof/base/.chainproof/config.json.template +11 -0
- package/templates/chainproof/base/.chainproof/mcp-server.mjs +310 -0
- package/templates/chainproof/base/.mcp.json +9 -0
- package/templates/chainproof/fastapi/.chainproof/middleware.json.template +14 -0
- package/templates/chainproof/nextjs/.chainproof/hooks.json.template +19 -0
- package/templates/chainproof/polyglot/.chainproof/config.json.template +21 -0
- package/templates/claude-code/agents/architect.md +25 -11
- package/templates/claude-code/agents/build-error-resolver.md +19 -5
- package/templates/claude-code/agents/chief-of-staff.md +42 -8
- package/templates/claude-code/agents/code-quality-reviewer.md +14 -0
- package/templates/claude-code/agents/database-reviewer.md +15 -1
- package/templates/claude-code/agents/deep-reviewer.md +191 -0
- package/templates/claude-code/agents/doc-updater.md +19 -5
- package/templates/claude-code/agents/docs-lookup.md +19 -5
- package/templates/claude-code/agents/e2e-runner.md +26 -12
- package/templates/claude-code/agents/enforcement-gate.md +102 -0
- package/templates/claude-code/agents/frontend-builder.md +188 -0
- package/templates/claude-code/agents/harness-optimizer.md +36 -1
- package/templates/claude-code/agents/loop-operator.md +27 -13
- package/templates/claude-code/agents/planner.md +21 -7
- package/templates/claude-code/agents/product-strategist.md +24 -10
- package/templates/claude-code/agents/production-readiness.md +14 -0
- package/templates/claude-code/agents/prompt-auditor.md +115 -0
- package/templates/claude-code/agents/refactor-cleaner.md +22 -8
- package/templates/claude-code/agents/security-reviewer.md +14 -0
- package/templates/claude-code/agents/spec-validator.md +15 -1
- package/templates/claude-code/agents/tdd-guide.md +21 -7
- package/templates/claude-code/agents/uat-validator.md +14 -0
- package/templates/claude-code/claude-md/base.md +14 -7
- package/templates/claude-code/claude-md/fastapi.md +8 -8
- package/templates/claude-code/claude-md/fullstack.md +6 -6
- package/templates/claude-code/claude-md/hono.md +18 -0
- package/templates/claude-code/claude-md/nextjs.md +5 -5
- package/templates/claude-code/claude-md/remix.md +18 -0
- package/templates/claude-code/commands/audit-security.md +14 -0
- package/templates/claude-code/commands/audit-spec.md +14 -0
- package/templates/claude-code/commands/audit-wiring.md +14 -0
- package/templates/claude-code/commands/build-fix.md +28 -0
- package/templates/claude-code/commands/build-ui.md +59 -0
- package/templates/claude-code/commands/code-review.md +53 -31
- package/templates/claude-code/commands/fix-loop.md +211 -0
- package/templates/claude-code/commands/full-audit.md +36 -8
- package/templates/claude-code/commands/generate-prd.md +1 -1
- package/templates/claude-code/commands/generate-sdd.md +74 -0
- package/templates/claude-code/commands/generate-uat.md +107 -35
- package/templates/claude-code/commands/help.md +68 -0
- package/templates/claude-code/commands/live-uat.md +268 -0
- package/templates/claude-code/commands/optimize-claude-md.md +15 -1
- package/templates/claude-code/commands/plan.md +3 -3
- package/templates/claude-code/commands/pre-pr.md +57 -19
- package/templates/claude-code/commands/product-strategist.md +21 -0
- package/templates/claude-code/commands/resume-session.md +10 -10
- package/templates/claude-code/commands/run-uat.md +59 -2
- package/templates/claude-code/commands/save-session.md +10 -10
- package/templates/claude-code/commands/simplify.md +36 -0
- package/templates/claude-code/commands/tdd.md +17 -18
- package/templates/claude-code/commands/verify-all.md +24 -0
- package/templates/claude-code/commands/verify-intent.md +55 -0
- package/templates/claude-code/commands/workflows.md +52 -40
- package/templates/claude-code/hooks/polyglot.json +10 -1
- package/templates/claude-code/hooks/python.json +10 -1
- package/templates/claude-code/hooks/scripts/autofix-polyglot.mjs +2 -2
- package/templates/claude-code/hooks/scripts/autofix-python.mjs +1 -1
- package/templates/claude-code/hooks/scripts/autofix-typescript.mjs +1 -1
- package/templates/claude-code/hooks/scripts/code-hygiene.mjs +293 -0
- package/templates/claude-code/hooks/scripts/pre-commit-gate.mjs +207 -0
- package/templates/claude-code/hooks/typescript.json +10 -1
- package/templates/claude-code/skills/ai-prompts/SKILL.md +119 -41
- package/templates/claude-code/skills/git-workflow/SKILL.md +5 -5
- package/templates/claude-code/skills/nextjs/SKILL.md +1 -1
- package/templates/claude-code/skills/playwright/SKILL.md +5 -5
- package/templates/claude-code/skills/security-api/SKILL.md +1 -1
- package/templates/claude-code/skills/security-web/SKILL.md +1 -1
- package/templates/claude-code/skills/testing-patterns/SKILL.md +9 -9
- package/templates/database/prisma-postgres/{.env.example → .env.example.template} +1 -0
- package/templates/database/sqlalchemy-postgres/{.env.example → .env.example.template} +1 -0
- package/templates/docs-portal/fastapi/backend/app/portal/__pycache__/docs_reader.cpython-314.pyc +0 -0
- package/templates/docs-portal/fastapi/backend/app/portal/docs_reader.py +201 -0
- package/templates/docs-portal/fastapi/backend/app/portal/html_renderer.py +229 -0
- package/templates/docs-portal/fastapi/backend/app/portal/router.py.template +35 -0
- package/templates/docs-portal/nextjs/src/app/portal/[category]/[slug]/page.tsx +81 -0
- package/templates/docs-portal/nextjs/src/app/portal/[category]/page.tsx +65 -0
- package/templates/docs-portal/nextjs/src/app/portal/layout.tsx.template +54 -0
- package/templates/docs-portal/nextjs/src/app/portal/page.tsx +85 -0
- package/templates/docs-portal/nextjs/src/components/portal/markdown-renderer.tsx +101 -0
- package/templates/docs-portal/nextjs/src/components/portal/mobile-portal-nav.tsx +81 -0
- package/templates/docs-portal/nextjs/src/components/portal/portal-nav.tsx +86 -0
- package/templates/docs-portal/nextjs/src/lib/docs.ts +139 -0
- package/templates/frontend/nextjs/package.json.template +3 -1
- package/templates/frontend/react/index.html.template +12 -0
- package/templates/frontend/react/package.json.template +34 -0
- package/templates/frontend/react/src/App.tsx.template +10 -0
- package/templates/frontend/react/src/index.css +1 -0
- package/templates/frontend/react/src/main.tsx +10 -0
- package/templates/frontend/react/tsconfig.json +17 -0
- package/templates/frontend/react/vite.config.ts.template +15 -0
- package/templates/frontend/react/vitest.config.ts +9 -0
- package/templates/frontend/remix/app/root.tsx.template +31 -0
- package/templates/frontend/remix/app/routes/_index.tsx.template +19 -0
- package/templates/frontend/remix/app/routes/api.health.ts.template +10 -0
- package/templates/frontend/remix/app/tailwind.css +1 -0
- package/templates/frontend/remix/package.json.template +39 -0
- package/templates/frontend/remix/tsconfig.json +18 -0
- package/templates/frontend/remix/vite.config.ts.template +7 -0
- package/templates/infra/github-actions/.github/workflows/ci.yml.template +3 -0
- package/templates/infra/k8s/k8s/deployment.yml.template +70 -0
- package/templates/infra/k8s/k8s/hpa.yml.template +24 -0
- package/templates/infra/k8s/k8s/ingress.yml.template +26 -0
- package/templates/infra/k8s/k8s/kustomization.yml.template +13 -0
- package/templates/infra/k8s/k8s/namespace.yml.template +4 -0
- package/templates/infra/k8s/k8s/networkpolicy.yml.template +41 -0
- package/templates/infra/k8s/k8s/secrets.yml.template +10 -0
- package/templates/infra/k8s/k8s/service.yml.template +15 -0
- package/templates/testing/load/k6/README.md.template +48 -0
- package/templates/testing/load/k6/load-test.js.template +57 -0
- package/docs/00-README.md +0 -310
- package/docs/01-universal-prompt-library.md +0 -1049
- package/docs/02-claude-code-mastery-playbook.md +0 -283
- package/docs/03-multi-agent-verification.md +0 -565
- package/docs/04-errata-and-verification-checklist.md +0 -284
- package/docs/05-universal-scaffolder-vision.md +0 -452
- package/docs/06-confidence-assessment-and-repo-prompt.md +0 -407
- package/docs/errata.md +0 -58
- package/docs/multi-agent-verification.md +0 -66
- package/docs/playbook.md +0 -95
- package/docs/prompt-library.md +0 -160
- package/docs/uat/UAT_CHECKLIST.csv +0 -9
- package/docs/uat/UAT_TEMPLATE.md +0 -163
- package/templates/claude-code/commands/done.md +0 -19
- /package/{docs/plans/.gitkeep → templates/docs-portal/fastapi/backend/app/portal/__init__.py} +0 -0
package/src/utils.js
CHANGED
|
@@ -21,11 +21,6 @@ export function ensureDir(dirPath) {
|
|
|
21
21
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export function copyFile(src, dest) {
|
|
25
|
-
ensureDir(path.dirname(dest));
|
|
26
|
-
fs.copyFileSync(src, dest);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
24
|
export function writeFile(dest, content) {
|
|
30
25
|
ensureDir(path.dirname(dest));
|
|
31
26
|
fs.writeFileSync(dest, content, 'utf-8');
|
|
@@ -48,6 +43,12 @@ export function toPascalCase(str) {
|
|
|
48
43
|
.join('');
|
|
49
44
|
}
|
|
50
45
|
|
|
46
|
+
export function copyEnvCmd() {
|
|
47
|
+
return process.platform === 'win32'
|
|
48
|
+
? 'copy .env.example .env'
|
|
49
|
+
: 'cp .env.example .env';
|
|
50
|
+
}
|
|
51
|
+
|
|
51
52
|
export function toSnakeCase(str) {
|
|
52
53
|
return str.replace(/[-\s]+/g, '_').toLowerCase();
|
|
53
54
|
}
|
|
@@ -55,3 +56,159 @@ export function toSnakeCase(str) {
|
|
|
55
56
|
export function toKebabCase(str) {
|
|
56
57
|
return str.replace(/[\s_]+/g, '-').toLowerCase();
|
|
57
58
|
}
|
|
59
|
+
|
|
60
|
+
// ─── Single source of truth for all per-stack metadata ──────────────
|
|
61
|
+
// Adding a new stack? Add one entry here. Nothing else to touch in utils.js.
|
|
62
|
+
|
|
63
|
+
const STACK_METADATA = {
|
|
64
|
+
'nextjs-fullstack': {
|
|
65
|
+
description: 'Next.js full-stack application with TypeScript, Tailwind CSS, Prisma, and PostgreSQL',
|
|
66
|
+
extraIgnores: '',
|
|
67
|
+
port: '3000',
|
|
68
|
+
commands: {
|
|
69
|
+
LINT_COMMAND: 'npx eslint .',
|
|
70
|
+
TYPE_CHECK_COMMAND: 'npx tsc --noEmit',
|
|
71
|
+
TEST_COMMAND: 'npx vitest run',
|
|
72
|
+
BUILD_COMMAND: 'npm run build',
|
|
73
|
+
DEV_COMMAND: 'npm run dev',
|
|
74
|
+
},
|
|
75
|
+
setupCommands: () => `npm install
|
|
76
|
+
${copyEnvCmd()}
|
|
77
|
+
npx prisma db push
|
|
78
|
+
npm run dev`,
|
|
79
|
+
availableScripts: `- \`npm run dev\`: Start development server
|
|
80
|
+
- \`npm run build\`: Production build
|
|
81
|
+
- \`npm run lint\`: Run ESLint
|
|
82
|
+
- \`npx prisma studio\`: Database GUI
|
|
83
|
+
- \`npx vitest\`: Run unit tests
|
|
84
|
+
- \`npx playwright test\`: Run E2E tests`,
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
'fastapi-backend': {
|
|
88
|
+
description: 'FastAPI backend service with SQLAlchemy, PostgreSQL, and Alembic',
|
|
89
|
+
extraIgnores: '\n# Python\n__pycache__/\n*.pyc\nvenv/\n.venv/\n*.egg-info/',
|
|
90
|
+
port: '8000',
|
|
91
|
+
commands: {
|
|
92
|
+
LINT_COMMAND: 'ruff check .',
|
|
93
|
+
TYPE_CHECK_COMMAND: 'pyright',
|
|
94
|
+
TEST_COMMAND: 'pytest',
|
|
95
|
+
BUILD_COMMAND: 'docker build -t app .',
|
|
96
|
+
DEV_COMMAND: 'uvicorn app.main:app --reload',
|
|
97
|
+
},
|
|
98
|
+
setupCommands: () => `cd backend
|
|
99
|
+
python -m venv venv
|
|
100
|
+
source venv/bin/activate
|
|
101
|
+
pip install -r requirements.txt
|
|
102
|
+
${copyEnvCmd()}
|
|
103
|
+
uvicorn app.main:app --reload`,
|
|
104
|
+
availableScripts: `- \`uvicorn app.main:app --reload\`: Start dev server
|
|
105
|
+
- \`pytest\`: Run tests
|
|
106
|
+
- \`ruff check .\`: Run linter
|
|
107
|
+
- \`alembic upgrade head\`: Run migrations`,
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
'polyglot-fullstack': {
|
|
111
|
+
description: 'Full-stack application with Next.js frontend and FastAPI backend',
|
|
112
|
+
extraIgnores: '\n# Python\n__pycache__/\n*.pyc\nvenv/\n.venv/\n*.egg-info/',
|
|
113
|
+
port: '3000',
|
|
114
|
+
commands: {
|
|
115
|
+
LINT_COMMAND: 'cd frontend && npx eslint . && cd ../backend && ruff check .',
|
|
116
|
+
TYPE_CHECK_COMMAND: 'cd frontend && npx tsc --noEmit',
|
|
117
|
+
TEST_COMMAND: 'cd frontend && npx vitest run && cd ../backend && pytest',
|
|
118
|
+
BUILD_COMMAND: 'docker compose build',
|
|
119
|
+
DEV_COMMAND: 'docker compose up',
|
|
120
|
+
},
|
|
121
|
+
setupCommands: () => `docker compose up -d postgres
|
|
122
|
+
# Frontend
|
|
123
|
+
cd frontend && npm install && npm run dev
|
|
124
|
+
# Backend
|
|
125
|
+
cd backend && pip install -r requirements.txt && uvicorn app.main:app --reload`,
|
|
126
|
+
availableScripts: `- \`docker compose up\`: Start all services
|
|
127
|
+
- \`docker compose up -d postgres\`: Start database only
|
|
128
|
+
- Frontend: \`cd frontend && npm run dev\`
|
|
129
|
+
- Backend: \`cd backend && uvicorn app.main:app --reload\``,
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
'react-express': {
|
|
133
|
+
description: 'Full-stack application with React (Vite) frontend and Express backend',
|
|
134
|
+
extraIgnores: '\ndist/',
|
|
135
|
+
port: '3001', // Express backend port (frontend runs on Vite :5173)
|
|
136
|
+
commands: {
|
|
137
|
+
LINT_COMMAND: 'cd frontend && npx eslint . && cd ../backend && npx eslint .',
|
|
138
|
+
TYPE_CHECK_COMMAND: 'cd frontend && npx tsc --noEmit && cd ../backend && npx tsc --noEmit',
|
|
139
|
+
TEST_COMMAND: 'cd frontend && npx vitest run && cd ../backend && npx vitest run',
|
|
140
|
+
BUILD_COMMAND: 'cd frontend && npm run build && cd ../backend && npm run build',
|
|
141
|
+
DEV_COMMAND: 'npx concurrently "cd frontend && npm run dev" "cd backend && npm run dev"',
|
|
142
|
+
},
|
|
143
|
+
setupCommands: () => `# Frontend
|
|
144
|
+
cd frontend && npm install && npm run dev
|
|
145
|
+
# Backend (in a separate terminal)
|
|
146
|
+
cd backend && npm install && ${copyEnvCmd()} && npx prisma db push && npm run dev`,
|
|
147
|
+
availableScripts: `- Frontend: \`cd frontend && npm run dev\` (Vite dev server)
|
|
148
|
+
- Backend: \`cd backend && npm run dev\` (Express with tsx watch)
|
|
149
|
+
- \`cd backend && npm run build\`: Build backend for production
|
|
150
|
+
- \`cd frontend && npm run build\`: Build frontend for production
|
|
151
|
+
- \`cd backend && npx prisma studio\`: Database GUI
|
|
152
|
+
- \`cd frontend && npx vitest\`: Run frontend tests`,
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
'remix-fullstack': {
|
|
156
|
+
description: 'Full-stack Remix application with Vite, Tailwind CSS, and PostgreSQL',
|
|
157
|
+
extraIgnores: '\nbuild/',
|
|
158
|
+
port: '3000',
|
|
159
|
+
commands: {
|
|
160
|
+
LINT_COMMAND: 'npx eslint .',
|
|
161
|
+
TYPE_CHECK_COMMAND: 'npx tsc --noEmit',
|
|
162
|
+
TEST_COMMAND: 'npx vitest run',
|
|
163
|
+
BUILD_COMMAND: 'npm run build',
|
|
164
|
+
DEV_COMMAND: 'npm run dev',
|
|
165
|
+
},
|
|
166
|
+
setupCommands: () => `npm install
|
|
167
|
+
${copyEnvCmd()}
|
|
168
|
+
npx prisma db push
|
|
169
|
+
npm run dev`,
|
|
170
|
+
availableScripts: `- \`npm run dev\`: Start Remix dev server
|
|
171
|
+
- \`npm run build\`: Production build
|
|
172
|
+
- \`npm run start\`: Start production server
|
|
173
|
+
- \`npm run lint\`: Run ESLint
|
|
174
|
+
- \`npx prisma studio\`: Database GUI
|
|
175
|
+
- \`npx vitest\`: Run unit tests`,
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
'hono-api': {
|
|
179
|
+
description: 'Hono API service with TypeScript, Prisma, and PostgreSQL',
|
|
180
|
+
extraIgnores: '\ndist/',
|
|
181
|
+
port: '3000',
|
|
182
|
+
commands: {
|
|
183
|
+
LINT_COMMAND: 'npx eslint .',
|
|
184
|
+
TYPE_CHECK_COMMAND: 'npx tsc --noEmit',
|
|
185
|
+
TEST_COMMAND: 'npx vitest run',
|
|
186
|
+
BUILD_COMMAND: 'npm run build',
|
|
187
|
+
DEV_COMMAND: 'npm run dev',
|
|
188
|
+
},
|
|
189
|
+
setupCommands: () => `npm install
|
|
190
|
+
${copyEnvCmd()}
|
|
191
|
+
npx prisma db push
|
|
192
|
+
npm run dev`,
|
|
193
|
+
availableScripts: `- \`npm run dev\`: Start Hono dev server (tsx watch)
|
|
194
|
+
- \`npm run build\`: Compile TypeScript
|
|
195
|
+
- \`npm run start\`: Start production server
|
|
196
|
+
- \`npm run lint\`: Run ESLint
|
|
197
|
+
- \`npx prisma studio\`: Database GUI
|
|
198
|
+
- \`npx vitest\`: Run unit tests`,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export function getStackMetadata(stackId) {
|
|
203
|
+
const meta = STACK_METADATA[stackId];
|
|
204
|
+
if (!meta) {
|
|
205
|
+
log.warn(`No stack metadata for "${stackId}" — using empty defaults`);
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
return meta;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function getStackCommands(stackId) {
|
|
212
|
+
const meta = STACK_METADATA[stackId];
|
|
213
|
+
return meta ? { ...meta.commands } : {};
|
|
214
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""AI Guardrails — {{PROJECT_NAME_PASCAL}}
|
|
2
|
+
|
|
3
|
+
Central module for all AI safety infrastructure.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
from app.ai import get_ai_client
|
|
7
|
+
ai = get_ai_client()
|
|
8
|
+
result = await ai.generate(prompt="...", schema=MyModel)
|
|
9
|
+
|
|
10
|
+
Compliance: EU AI Act (2024/1689), NIST AI RMF 1.0
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from app.ai.client import AIClient, get_ai_client, AIResponse, AIClientConfig
|
|
14
|
+
from app.ai.input_guard import validate_input, sanitize_input
|
|
15
|
+
from app.ai.audit_log import ai_audit_log, AuditEntry
|
|
16
|
+
from app.ai.health import ai_health_metrics, AIHealthStatus
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AIClient",
|
|
20
|
+
"get_ai_client",
|
|
21
|
+
"AIResponse",
|
|
22
|
+
"AIClientConfig",
|
|
23
|
+
"validate_input",
|
|
24
|
+
"sanitize_input",
|
|
25
|
+
"ai_audit_log",
|
|
26
|
+
"AuditEntry",
|
|
27
|
+
"ai_health_metrics",
|
|
28
|
+
"AIHealthStatus",
|
|
29
|
+
]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""AI Audit Logger — Structured logging of all AI interactions.
|
|
2
|
+
|
|
3
|
+
Compliance: EU AI Act Art. 12 (logging and traceability),
|
|
4
|
+
NIST AI RMF Manage 1.3 (monitoring)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import threading
|
|
10
|
+
import uuid
|
|
11
|
+
from collections import deque
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("ai.audit")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class AuditEntry:
|
|
20
|
+
"""A single AI interaction audit record."""
|
|
21
|
+
|
|
22
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
23
|
+
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
24
|
+
model: str = ""
|
|
25
|
+
purpose: str = "unspecified"
|
|
26
|
+
input_preview: str = ""
|
|
27
|
+
confidence: float = 0.0
|
|
28
|
+
needs_human_review: bool = False
|
|
29
|
+
latency_ms: float = 0.0
|
|
30
|
+
token_usage: dict[str, int] | None = None
|
|
31
|
+
success: bool = True
|
|
32
|
+
error: str | None = None
|
|
33
|
+
human_action: str | None = None # approved | rejected | modified
|
|
34
|
+
human_reviewer_id: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AIAuditLog:
|
|
38
|
+
"""In-memory audit log with structured logging output."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, max_entries: int = 1000):
|
|
41
|
+
self._entries: deque[AuditEntry] = deque(maxlen=max_entries)
|
|
42
|
+
self._lock = threading.Lock()
|
|
43
|
+
|
|
44
|
+
def log(self, entry: AuditEntry) -> None:
|
|
45
|
+
with self._lock:
|
|
46
|
+
self._entries.append(entry)
|
|
47
|
+
|
|
48
|
+
log_data = {
|
|
49
|
+
"type": "ai_interaction",
|
|
50
|
+
"id": entry.id,
|
|
51
|
+
"timestamp": entry.timestamp,
|
|
52
|
+
"model": entry.model,
|
|
53
|
+
"purpose": entry.purpose,
|
|
54
|
+
"confidence": entry.confidence,
|
|
55
|
+
"needs_human_review": entry.needs_human_review,
|
|
56
|
+
"latency_ms": round(entry.latency_ms, 1),
|
|
57
|
+
"success": entry.success,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if entry.error:
|
|
61
|
+
log_data["error"] = entry.error
|
|
62
|
+
logger.warning("[AI_AUDIT] %s", json.dumps(log_data))
|
|
63
|
+
elif entry.confidence < 0.5:
|
|
64
|
+
logger.warning("[AI_AUDIT] %s", json.dumps(log_data))
|
|
65
|
+
else:
|
|
66
|
+
logger.info("[AI_AUDIT] %s", json.dumps(log_data))
|
|
67
|
+
|
|
68
|
+
VALID_ACTIONS = {"approved", "rejected", "modified"}
|
|
69
|
+
|
|
70
|
+
def record_human_review(
|
|
71
|
+
self, audit_id: str, action: str, reviewer_id: str | None = None
|
|
72
|
+
) -> None:
|
|
73
|
+
if action not in self.VALID_ACTIONS:
|
|
74
|
+
raise ValueError(f"Invalid action: {action}. Must be one of {self.VALID_ACTIONS}")
|
|
75
|
+
|
|
76
|
+
with self._lock:
|
|
77
|
+
for entry in self._entries:
|
|
78
|
+
if entry.id == audit_id:
|
|
79
|
+
entry.human_action = action
|
|
80
|
+
entry.human_reviewer_id = reviewer_id
|
|
81
|
+
break
|
|
82
|
+
else:
|
|
83
|
+
logger.warning("[AI_AUDIT] audit_id not found: %s", audit_id)
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
# Log the review event without adding a duplicate to the buffer
|
|
87
|
+
log_data = {
|
|
88
|
+
"type": "ai_interaction_review",
|
|
89
|
+
"id": entry.id,
|
|
90
|
+
"purpose": f"human-review:{entry.purpose}",
|
|
91
|
+
"action": action,
|
|
92
|
+
"reviewer_id": reviewer_id,
|
|
93
|
+
}
|
|
94
|
+
logger.info("[AI_AUDIT] %s", json.dumps(log_data))
|
|
95
|
+
|
|
96
|
+
def get_recent_entries(self, count: int = 50) -> list[AuditEntry]:
|
|
97
|
+
with self._lock:
|
|
98
|
+
return list(self._entries)[-count:]
|
|
99
|
+
|
|
100
|
+
def get_stats(self, window_seconds: int = 3600) -> dict:
|
|
101
|
+
cutoff = datetime.now(timezone.utc) - timedelta(seconds=window_seconds)
|
|
102
|
+
with self._lock:
|
|
103
|
+
recent = [
|
|
104
|
+
e for e in self._entries
|
|
105
|
+
if datetime.fromisoformat(e.timestamp) >= cutoff
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
if not recent:
|
|
109
|
+
return {
|
|
110
|
+
"total_calls": 0,
|
|
111
|
+
"success_rate": 1.0,
|
|
112
|
+
"avg_confidence": 0.0,
|
|
113
|
+
"avg_latency_ms": 0.0,
|
|
114
|
+
"human_review_rate": 0.0,
|
|
115
|
+
"error_rate": 0.0,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
successes = [e for e in recent if e.success]
|
|
119
|
+
reviews = [e for e in recent if e.needs_human_review]
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
"total_calls": len(recent),
|
|
123
|
+
"success_rate": len(successes) / len(recent),
|
|
124
|
+
"avg_confidence": sum(e.confidence for e in recent) / len(recent),
|
|
125
|
+
"avg_latency_ms": sum(e.latency_ms for e in recent) / len(recent),
|
|
126
|
+
"human_review_rate": len(reviews) / len(recent),
|
|
127
|
+
"error_rate": 1 - (len(successes) / len(recent)),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --- Singleton ---
|
|
132
|
+
|
|
133
|
+
ai_audit_log = AIAuditLog()
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""AI Client — Central wrapper for all LLM interactions.
|
|
2
|
+
|
|
3
|
+
Every AI call goes through this client, which provides:
|
|
4
|
+
- Input validation and prompt injection detection
|
|
5
|
+
- Output validation against Pydantic models
|
|
6
|
+
- Confidence scoring with human review routing
|
|
7
|
+
- Structured audit logging (EU AI Act Art. 12)
|
|
8
|
+
- Health metrics collection (NIST AI RMF Manage 3.2)
|
|
9
|
+
|
|
10
|
+
Compliance: EU AI Act (2024/1689), NIST AI RMF 1.0
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, Callable, TypeVar
|
|
18
|
+
|
|
19
|
+
import anthropic
|
|
20
|
+
from pydantic import BaseModel, ValidationError
|
|
21
|
+
|
|
22
|
+
from app.ai.audit_log import ai_audit_log, AuditEntry
|
|
23
|
+
from app.ai.input_guard import validate_input, InputValidationResult
|
|
24
|
+
from app.ai.health import ai_health_metrics
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T", bound=BaseModel)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AIClientConfig:
|
|
31
|
+
"""Configuration for the AI client."""
|
|
32
|
+
|
|
33
|
+
api_key: str = field(default_factory=lambda: os.environ.get("ANTHROPIC_API_KEY", ""))
|
|
34
|
+
model: str = "claude-sonnet-4-20250514"
|
|
35
|
+
confidence_threshold: float = 0.7
|
|
36
|
+
max_input_length: int = 100_000
|
|
37
|
+
detect_injection: bool = True
|
|
38
|
+
audit_log: bool = True
|
|
39
|
+
moderator: Callable[[str], bool] | None = None
|
|
40
|
+
|
|
41
|
+
def __post_init__(self):
|
|
42
|
+
if not self.api_key:
|
|
43
|
+
raise ValueError("ANTHROPIC_API_KEY environment variable is required")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class AIResponse:
|
|
48
|
+
"""Response from an AI call with metadata."""
|
|
49
|
+
|
|
50
|
+
data: Any
|
|
51
|
+
confidence: float
|
|
52
|
+
needs_human_review: bool
|
|
53
|
+
model: str
|
|
54
|
+
latency_ms: float
|
|
55
|
+
token_usage: dict[str, int] | None = None
|
|
56
|
+
ai_generated: bool = True
|
|
57
|
+
audit_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
58
|
+
error: str | None = None
|
|
59
|
+
blocked: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AIClient:
|
|
63
|
+
"""Central AI client with guardrails, validation, and audit logging."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, config: AIClientConfig | None = None):
|
|
66
|
+
self.config = config or AIClientConfig()
|
|
67
|
+
self.client = anthropic.AsyncAnthropic(api_key=self.config.api_key)
|
|
68
|
+
|
|
69
|
+
async def generate(
|
|
70
|
+
self,
|
|
71
|
+
prompt: str,
|
|
72
|
+
schema: type[T],
|
|
73
|
+
system_prompt: str | None = None,
|
|
74
|
+
context: str | None = None,
|
|
75
|
+
model: str | None = None,
|
|
76
|
+
confidence_threshold: float | None = None,
|
|
77
|
+
max_retries: int = 2,
|
|
78
|
+
purpose: str = "unspecified",
|
|
79
|
+
) -> AIResponse:
|
|
80
|
+
"""Generate a structured response validated against a Pydantic model.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
prompt: The user prompt.
|
|
84
|
+
schema: Pydantic model class to validate output against.
|
|
85
|
+
system_prompt: Optional system prompt override.
|
|
86
|
+
context: Additional context appended to the prompt.
|
|
87
|
+
model: Override model for this call.
|
|
88
|
+
confidence_threshold: Override threshold for human review.
|
|
89
|
+
max_retries: Max retries on validation failure.
|
|
90
|
+
purpose: Business purpose tag for audit log.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
AIResponse with validated data, confidence score, and audit trail.
|
|
94
|
+
"""
|
|
95
|
+
start_time = time.monotonic()
|
|
96
|
+
model = model or self.config.model
|
|
97
|
+
threshold = confidence_threshold if confidence_threshold is not None else self.config.confidence_threshold
|
|
98
|
+
|
|
99
|
+
# Step 1: Input validation
|
|
100
|
+
input_validation = self._validate_inputs(prompt, context)
|
|
101
|
+
if input_validation.blocked:
|
|
102
|
+
return self._build_blocked_response(input_validation, start_time, model, purpose)
|
|
103
|
+
|
|
104
|
+
# Step 2: Call model with retries
|
|
105
|
+
last_error: Exception | None = None
|
|
106
|
+
for attempt in range(max_retries + 1):
|
|
107
|
+
try:
|
|
108
|
+
response = await self._call_model(prompt, system_prompt, context, model)
|
|
109
|
+
|
|
110
|
+
# Step 3: Parse and validate output
|
|
111
|
+
try:
|
|
112
|
+
parsed = schema.model_validate_json(response["content"])
|
|
113
|
+
except (ValidationError, ValueError) as e:
|
|
114
|
+
last_error = e
|
|
115
|
+
if attempt < max_retries:
|
|
116
|
+
continue
|
|
117
|
+
break
|
|
118
|
+
|
|
119
|
+
# Step 4: Score confidence
|
|
120
|
+
confidence = self._score_confidence(response)
|
|
121
|
+
needs_review = confidence < threshold
|
|
122
|
+
|
|
123
|
+
# Step 5: Build response
|
|
124
|
+
result = AIResponse(
|
|
125
|
+
data=parsed,
|
|
126
|
+
confidence=confidence,
|
|
127
|
+
needs_human_review=needs_review,
|
|
128
|
+
model=model,
|
|
129
|
+
latency_ms=(time.monotonic() - start_time) * 1000,
|
|
130
|
+
token_usage=response.get("usage"),
|
|
131
|
+
ai_generated=True,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Step 6: Audit log
|
|
135
|
+
if self.config.audit_log:
|
|
136
|
+
self._log_interaction(result, prompt, purpose)
|
|
137
|
+
|
|
138
|
+
# Step 7: Health metrics
|
|
139
|
+
ai_health_metrics.record_call(
|
|
140
|
+
model=model,
|
|
141
|
+
latency_ms=result.latency_ms,
|
|
142
|
+
confidence=confidence,
|
|
143
|
+
success=True,
|
|
144
|
+
token_usage=response.get("usage"),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
except Exception as e:
|
|
150
|
+
last_error = e
|
|
151
|
+
if attempt < max_retries:
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
# All retries exhausted
|
|
155
|
+
latency = (time.monotonic() - start_time) * 1000
|
|
156
|
+
ai_health_metrics.record_call(model=model, latency_ms=latency, confidence=0, success=False)
|
|
157
|
+
|
|
158
|
+
return AIResponse(
|
|
159
|
+
data=None,
|
|
160
|
+
confidence=0,
|
|
161
|
+
needs_human_review=True,
|
|
162
|
+
model=model,
|
|
163
|
+
latency_ms=latency,
|
|
164
|
+
error=str(last_error) if last_error else "AI call failed after retries",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def _validate_inputs(self, prompt: str, context: str | None) -> InputValidationResult:
|
|
168
|
+
full_input = f"{prompt}\n{context}" if context else prompt
|
|
169
|
+
|
|
170
|
+
if len(full_input) > self.config.max_input_length:
|
|
171
|
+
return InputValidationResult(
|
|
172
|
+
blocked=True,
|
|
173
|
+
reason=f"Input exceeds maximum length ({self.config.max_input_length} chars)",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if self.config.detect_injection:
|
|
177
|
+
result = validate_input(full_input)
|
|
178
|
+
if result.blocked:
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
if self.config.moderator and self.config.moderator(full_input):
|
|
182
|
+
return InputValidationResult(blocked=True, reason="Content blocked by moderation policy")
|
|
183
|
+
|
|
184
|
+
return InputValidationResult(blocked=False)
|
|
185
|
+
|
|
186
|
+
async def _call_model(
|
|
187
|
+
self,
|
|
188
|
+
prompt: str,
|
|
189
|
+
system_prompt: str | None,
|
|
190
|
+
context: str | None,
|
|
191
|
+
model: str,
|
|
192
|
+
) -> dict:
|
|
193
|
+
user_content = f"{prompt}\n\nContext:\n{context}" if context else prompt
|
|
194
|
+
|
|
195
|
+
response = await self.client.messages.create(
|
|
196
|
+
model=model,
|
|
197
|
+
max_tokens=4096,
|
|
198
|
+
system=system_prompt or (
|
|
199
|
+
f"You are an AI assistant for {{PROJECT_NAME}}. "
|
|
200
|
+
"Respond with valid JSON matching the requested schema. Be precise and factual."
|
|
201
|
+
),
|
|
202
|
+
messages=[{"role": "user", "content": user_content}],
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
text = ""
|
|
206
|
+
for block in response.content:
|
|
207
|
+
if block.type == "text":
|
|
208
|
+
text = block.text
|
|
209
|
+
break
|
|
210
|
+
|
|
211
|
+
# Extract JSON from response
|
|
212
|
+
content = self._extract_json(text)
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
"content": content,
|
|
216
|
+
"usage": {
|
|
217
|
+
"input_tokens": response.usage.input_tokens,
|
|
218
|
+
"output_tokens": response.usage.output_tokens,
|
|
219
|
+
},
|
|
220
|
+
"stop_reason": response.stop_reason,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
def _extract_json(self, text: str) -> str:
|
|
224
|
+
"""Extract JSON from model response, handling markdown code blocks."""
|
|
225
|
+
import re
|
|
226
|
+
|
|
227
|
+
text = text.strip()
|
|
228
|
+
|
|
229
|
+
# Try direct parse
|
|
230
|
+
if text.startswith("{") or text.startswith("["):
|
|
231
|
+
return text
|
|
232
|
+
|
|
233
|
+
# Extract from code blocks
|
|
234
|
+
match = re.search(r"```(?:json)?\s*\n?([\s\S]*?)\n?```", text)
|
|
235
|
+
if match:
|
|
236
|
+
return match.group(1).strip()
|
|
237
|
+
|
|
238
|
+
return text
|
|
239
|
+
|
|
240
|
+
def _score_confidence(self, response: dict) -> float:
|
|
241
|
+
score = 0.85
|
|
242
|
+
|
|
243
|
+
if response.get("stop_reason") == "max_tokens":
|
|
244
|
+
score -= 0.3
|
|
245
|
+
|
|
246
|
+
usage = response.get("usage", {})
|
|
247
|
+
output_tokens = usage.get("output_tokens", 0)
|
|
248
|
+
|
|
249
|
+
if output_tokens < 10:
|
|
250
|
+
score -= 0.2
|
|
251
|
+
if output_tokens > 3000:
|
|
252
|
+
score -= 0.1
|
|
253
|
+
|
|
254
|
+
return max(0.0, min(1.0, score))
|
|
255
|
+
|
|
256
|
+
def _log_interaction(self, result: AIResponse, prompt: str, purpose: str) -> None:
|
|
257
|
+
entry = AuditEntry(
|
|
258
|
+
id=result.audit_id,
|
|
259
|
+
model=result.model,
|
|
260
|
+
purpose=purpose,
|
|
261
|
+
input_preview=prompt[:100] + ("..." if len(prompt) > 100 else ""),
|
|
262
|
+
confidence=result.confidence,
|
|
263
|
+
needs_human_review=result.needs_human_review,
|
|
264
|
+
latency_ms=result.latency_ms,
|
|
265
|
+
token_usage=result.token_usage,
|
|
266
|
+
success=result.error is None,
|
|
267
|
+
error=result.error,
|
|
268
|
+
)
|
|
269
|
+
ai_audit_log.log(entry)
|
|
270
|
+
|
|
271
|
+
def _build_blocked_response(
|
|
272
|
+
self,
|
|
273
|
+
validation: InputValidationResult,
|
|
274
|
+
start_time: float,
|
|
275
|
+
model: str,
|
|
276
|
+
purpose: str,
|
|
277
|
+
) -> AIResponse:
|
|
278
|
+
result = AIResponse(
|
|
279
|
+
data=None,
|
|
280
|
+
confidence=0,
|
|
281
|
+
needs_human_review=False,
|
|
282
|
+
model=model,
|
|
283
|
+
latency_ms=(time.monotonic() - start_time) * 1000,
|
|
284
|
+
ai_generated=False,
|
|
285
|
+
error=f"Input blocked: {validation.reason}",
|
|
286
|
+
blocked=True,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
if self.config.audit_log:
|
|
290
|
+
ai_audit_log.log(AuditEntry(
|
|
291
|
+
id=result.audit_id,
|
|
292
|
+
model=model,
|
|
293
|
+
purpose=purpose,
|
|
294
|
+
input_preview="[BLOCKED]",
|
|
295
|
+
confidence=0,
|
|
296
|
+
needs_human_review=False,
|
|
297
|
+
latency_ms=result.latency_ms,
|
|
298
|
+
success=False,
|
|
299
|
+
error=validation.reason,
|
|
300
|
+
))
|
|
301
|
+
|
|
302
|
+
return result
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# --- Singleton ---
|
|
306
|
+
|
|
307
|
+
_default_client: AIClient | None = None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def get_ai_client(config: AIClientConfig | None = None) -> AIClient:
|
|
311
|
+
"""Get the singleton AI client. Call with config on first use only."""
|
|
312
|
+
global _default_client
|
|
313
|
+
if _default_client is None:
|
|
314
|
+
_default_client = AIClient(config)
|
|
315
|
+
elif config is not None:
|
|
316
|
+
import warnings
|
|
317
|
+
warnings.warn(
|
|
318
|
+
"AIClient already initialized; ignoring provided config. "
|
|
319
|
+
"Call get_ai_client() without arguments or use AIClient(config) directly.",
|
|
320
|
+
UserWarning,
|
|
321
|
+
stacklevel=2,
|
|
322
|
+
)
|
|
323
|
+
return _default_client
|