CognxSafeTrack Claude Sonnet 4.6 commited on
Commit
b438786
·
1 Parent(s): 87afdf1

feat: complete agentic audit roadmap — retry, KB generate, real costs, traceId, rate-limit

Browse files

- ai-sdk: exponential backoff retry (3 attempts, 1s→2s) in callWithFailover; skips 4xx and QuotaExceededError
- api/whatsapp: traceId (crypto.randomUUID) propagated to worker bridge via x-trace-id header
- api/whatsapp: route-level rate limit 500/min on POST /webhook, returns 200 on rejection
- api/organizations: POST /:id/kb/generate — GPT-4o-mini JSON mode generates FAQ pairs, embeds via IndexingService
- api/indexing-service: new service batching OpenAI text-embedding-3-small + pgvector INSERT
- admin/AnalyticsPage: real cost from UsageEvent aggregate + byFeature breakdown table
- admin/KnowledgeBasePage: KB auto-generation panel (description textarea, generate button, FAQ preview)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

apps/admin/src/pages/AnalyticsPage.tsx CHANGED
@@ -135,11 +135,11 @@ export default function AnalyticsPage() {
135
  color="text-amber-600"
136
  bg="bg-amber-50"
137
  />
138
- <StatCard
139
- title={t('dashboard.stats.ai_cost')}
140
- value={`$${usage?.costs?.estimatedUsd?.toFixed(2) || '0.00'}`}
141
- icon={<BrainCircuit className="w-5 h-5" />}
142
- trend="Optimisé"
143
  color="text-purple-600"
144
  bg="bg-purple-50"
145
  />
@@ -226,6 +226,55 @@ export default function AnalyticsPage() {
226
  </div>
227
  </div>
228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  {/* ── Text-to-SQL Search ─────────────────────────────────────────── */}
230
  <div className="bg-white rounded-3xl border border-slate-100 shadow-sm p-8">
231
  <div className="flex items-center gap-3 mb-6">
 
135
  color="text-amber-600"
136
  bg="bg-amber-50"
137
  />
138
+ <StatCard
139
+ title={t('dashboard.stats.ai_cost')}
140
+ value={`$${(usage?.costs?.totalUsd ?? 0).toFixed(4)}`}
141
+ icon={<BrainCircuit className="w-5 h-5" />}
142
+ trend={`${((usage?.costs?.totalTokens ?? 0) / 1000).toFixed(1)}k tokens`}
143
  color="text-purple-600"
144
  bg="bg-purple-50"
145
  />
 
226
  </div>
227
  </div>
228
 
229
+ {/* ── AI Cost Breakdown ─────────────────────────────────────────── */}
230
+ {usage?.costs?.byFeature?.length > 0 && (
231
+ <div className="bg-white rounded-3xl border border-slate-100 shadow-sm p-8">
232
+ <div className="flex items-center gap-3 mb-6">
233
+ <div className="p-2.5 bg-purple-50 rounded-xl">
234
+ <BrainCircuit className="w-5 h-5 text-purple-600" />
235
+ </div>
236
+ <div>
237
+ <h2 className="font-bold text-slate-800">Coût IA par fonctionnalité</h2>
238
+ <p className="text-xs text-slate-400">Données réelles — source : UsageEvent</p>
239
+ </div>
240
+ </div>
241
+ <div className="overflow-x-auto">
242
+ <table className="w-full text-sm">
243
+ <thead>
244
+ <tr className="text-left text-xs text-slate-400 uppercase tracking-wider border-b border-slate-100">
245
+ <th className="pb-3 font-semibold">Fonctionnalité</th>
246
+ <th className="pb-3 font-semibold text-right">Appels</th>
247
+ <th className="pb-3 font-semibold text-right">Tokens in</th>
248
+ <th className="pb-3 font-semibold text-right">Tokens out</th>
249
+ <th className="pb-3 font-semibold text-right">Coût (USD)</th>
250
+ </tr>
251
+ </thead>
252
+ <tbody className="divide-y divide-slate-50">
253
+ {(usage.costs.byFeature as Array<{ feature: string; calls: number; tokensIn: number; tokensOut: number; costUsd: number }>)
254
+ .sort((a, b) => b.costUsd - a.costUsd)
255
+ .map((row) => (
256
+ <tr key={row.feature} className="hover:bg-slate-50 transition">
257
+ <td className="py-3 font-medium text-slate-700">
258
+ <span className="px-2 py-0.5 bg-purple-50 text-purple-700 rounded-full text-[11px] font-mono">{row.feature}</span>
259
+ </td>
260
+ <td className="py-3 text-right text-slate-600">{row.calls.toLocaleString()}</td>
261
+ <td className="py-3 text-right text-slate-500">{row.tokensIn.toLocaleString()}</td>
262
+ <td className="py-3 text-right text-slate-500">{row.tokensOut.toLocaleString()}</td>
263
+ <td className="py-3 text-right font-bold text-slate-800">${row.costUsd.toFixed(4)}</td>
264
+ </tr>
265
+ ))}
266
+ </tbody>
267
+ <tfoot className="border-t-2 border-slate-200">
268
+ <tr>
269
+ <td className="pt-3 font-bold text-slate-800" colSpan={4}>Total</td>
270
+ <td className="pt-3 text-right font-bold text-purple-700">${(usage.costs.totalUsd as number).toFixed(4)}</td>
271
+ </tr>
272
+ </tfoot>
273
+ </table>
274
+ </div>
275
+ </div>
276
+ )}
277
+
278
  {/* ── Text-to-SQL Search ─────────────────────────────────────────── */}
279
  <div className="bg-white rounded-3xl border border-slate-100 shadow-sm p-8">
280
  <div className="flex items-center gap-3 mb-6">
apps/admin/src/pages/KnowledgeBasePage.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useState, useEffect } from 'react';
2
  import { useTranslation } from 'react-i18next';
3
- import { Database, Trash2, RefreshCw, Search, ChevronLeft, ChevronRight, Loader2, FileText } from 'lucide-react';
4
  import { api } from '../lib/api';
5
  import { useAuth } from '../lib/auth';
6
  import { useTenant } from '../lib/tenant';
@@ -34,6 +34,9 @@ export default function KnowledgeBasePage() {
34
  const [search, setSearch] = useState('');
35
  const [deletingId, setDeletingId] = useState<string | null>(null);
36
  const [reindexing, setReindexing] = useState(false);
 
 
 
37
 
38
  const fetchEntries = async (p = page) => {
39
  if (!token || !selectedOrgId) return;
@@ -89,6 +92,28 @@ export default function KnowledgeBasePage() {
89
  }
90
  };
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  const handlePageChange = (newPage: number) => {
93
  setPage(newPage);
94
  fetchEntries(newPage);
@@ -124,6 +149,46 @@ export default function KnowledgeBasePage() {
124
  </button>
125
  </div>
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  <div className="relative mb-4">
128
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
129
  <input
 
1
  import { useState, useEffect } from 'react';
2
  import { useTranslation } from 'react-i18next';
3
+ import { Database, Trash2, RefreshCw, Search, ChevronLeft, ChevronRight, Loader2, FileText, Wand2, CheckCircle2 } from 'lucide-react';
4
  import { api } from '../lib/api';
5
  import { useAuth } from '../lib/auth';
6
  import { useTenant } from '../lib/tenant';
 
34
  const [search, setSearch] = useState('');
35
  const [deletingId, setDeletingId] = useState<string | null>(null);
36
  const [reindexing, setReindexing] = useState(false);
37
+ const [genDescription, setGenDescription] = useState('');
38
+ const [generating, setGenerating] = useState(false);
39
+ const [genResult, setGenResult] = useState<{ faqCount: number; chunksIndexed: number; preview: Array<{ question: string; answer: string }> } | null>(null);
40
 
41
  const fetchEntries = async (p = page) => {
42
  if (!token || !selectedOrgId) return;
 
92
  }
93
  };
94
 
95
+ const handleGenerate = async () => {
96
+ if (!token || !selectedOrgId || !genDescription.trim()) return;
97
+ setGenerating(true);
98
+ setGenResult(null);
99
+ try {
100
+ const res = await api.post(
101
+ `/v1/organizations/${selectedOrgId}/kb/generate`,
102
+ { description: genDescription },
103
+ token
104
+ );
105
+ setGenResult(res);
106
+ toast.success(`${res.faqCount} Q&A générées et indexées`);
107
+ await fetchEntries(1);
108
+ setPage(1);
109
+ } catch (err: any) {
110
+ logError('[KB] Generate failed:', err);
111
+ toast.error(err?.message ?? 'Échec de la génération');
112
+ } finally {
113
+ setGenerating(false);
114
+ }
115
+ };
116
+
117
  const handlePageChange = (newPage: number) => {
118
  setPage(newPage);
119
  fetchEntries(newPage);
 
149
  </button>
150
  </div>
151
 
152
+ {/* KB Auto-Generation Panel */}
153
+ <div className="bg-white rounded-2xl border border-slate-100 p-5 mb-6">
154
+ <div className="flex items-center gap-2 mb-3">
155
+ <Wand2 className="w-4 h-4 text-violet-500" />
156
+ <h2 className="text-sm font-semibold text-slate-700">Générer depuis une description</h2>
157
+ </div>
158
+ <textarea
159
+ value={genDescription}
160
+ onChange={e => setGenDescription(e.target.value)}
161
+ placeholder="Décrivez votre activité, vos produits ou services… L'IA génèrera automatiquement une FAQ et l'indexera dans la base de connaissances."
162
+ rows={3}
163
+ className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-violet-400 resize-none mb-3"
164
+ />
165
+ <button
166
+ onClick={handleGenerate}
167
+ disabled={generating || !genDescription.trim()}
168
+ className="flex items-center gap-2 px-4 py-2 bg-violet-600 text-white rounded-xl text-sm font-medium hover:bg-violet-700 transition disabled:opacity-50"
169
+ >
170
+ {generating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Wand2 className="w-4 h-4" />}
171
+ {generating ? 'Génération en cours…' : 'Générer'}
172
+ </button>
173
+
174
+ {genResult && (
175
+ <div className="mt-4 pt-4 border-t border-slate-100">
176
+ <p className="text-sm font-medium text-slate-700 mb-3">
177
+ <CheckCircle2 className="inline w-4 h-4 text-green-500 mr-1" />
178
+ {genResult.faqCount} Q&R générées · {genResult.chunksIndexed} chunks indexés
179
+ </p>
180
+ <div className="space-y-2">
181
+ {genResult.preview.slice(0, 3).map((qa, i) => (
182
+ <div key={i} className="bg-slate-50 rounded-xl p-3 text-sm">
183
+ <p className="font-medium text-slate-700 mb-1">Q: {qa.question}</p>
184
+ <p className="text-slate-500">R: {qa.answer}</p>
185
+ </div>
186
+ ))}
187
+ </div>
188
+ </div>
189
+ )}
190
+ </div>
191
+
192
  <div className="relative mb-4">
193
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
194
  <input
apps/api/src/routes/organizations.ts CHANGED
@@ -259,6 +259,65 @@ export async function organizationRoutes(fastify: FastifyInstance) {
259
  return { ok: true };
260
  });
261
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  fastify.get<P & Q<{ page?: string; limit?: string }>>('/:id/kb', async (req, reply) => {
263
  const id = await resolveOrgId(req.params.id);
264
  if (!id) return reply.code(404).send({ error: 'Organization not found' });
 
259
  return { ok: true };
260
  });
261
 
262
+ /**
263
+ * POST /v1/organizations/:id/kb/generate
264
+ * Admin describes their business → GPT generates FAQ pairs → indexed in KB automatically.
265
+ */
266
+ fastify.post<P>('/:id/kb/generate', async (req, reply) => {
267
+ const id = await resolveOrgId(req.params.id);
268
+ if (!id) return reply.code(404).send({ error: 'Organization not found' });
269
+
270
+ const schema = z.object({ description: z.string().min(20).max(2000) });
271
+ const body = schema.safeParse(req.body);
272
+ if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
273
+
274
+ const { description } = body.data;
275
+
276
+ // Generate FAQ pairs via GPT-4o-mini
277
+ const OpenAI = (await import('openai')).default;
278
+ const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, timeout: 25_000 });
279
+
280
+ let faqs: Array<{ question: string; answer: string }>;
281
+ try {
282
+ const completion = await openai.chat.completions.create({
283
+ model: 'gpt-4o-mini',
284
+ temperature: 0.3,
285
+ response_format: { type: 'json_object' },
286
+ messages: [
287
+ {
288
+ role: 'system',
289
+ content: 'Tu es un expert en création de bases de connaissance pour chatbots WhatsApp. Génère des paires question/réponse précises et utiles basées sur la description métier fournie. Réponds en JSON avec une clé "faqs" contenant un tableau d\'objets {question, answer}.'
290
+ },
291
+ {
292
+ role: 'user',
293
+ content: `Génère 10 à 15 paires Q&A pour ce business :\n\n${description}\n\nCover : horaires, produits/services, prix, localisation, livraison, contact, politique de retour, FAQ courantes. Réponds en JSON.`
294
+ }
295
+ ]
296
+ });
297
+
298
+ const raw = JSON.parse(completion.choices[0]?.message?.content ?? '{}');
299
+ faqs = Array.isArray(raw.faqs) ? raw.faqs : [];
300
+ if (faqs.length === 0) throw new Error('No FAQs generated');
301
+ } catch (err) {
302
+ logger.error({ err }, '[KB-GENERATE] FAQ generation failed');
303
+ return reply.code(503).send({ error: 'AI service failed to generate FAQ' });
304
+ }
305
+
306
+ // Index each FAQ as a separate KB chunk
307
+ const { IndexingService } = await import('../services/indexing-service');
308
+ const chunks = faqs.map((faq) => `Q: ${faq.question}\nR: ${faq.answer}`);
309
+ const indexed = await IndexingService.indexTextChunks(id, chunks, { source: 'auto-generated-faq' });
310
+
311
+ auditService.log({
312
+ action: 'KB_AUTO_GENERATED',
313
+ actorId: req.user?.id,
314
+ resourceId: id,
315
+ details: { faqCount: faqs.length, chunksIndexed: indexed },
316
+ });
317
+
318
+ return { ok: true, faqCount: faqs.length, chunksIndexed: indexed, preview: faqs.slice(0, 3) };
319
+ });
320
+
321
  fastify.get<P & Q<{ page?: string; limit?: string }>>('/:id/kb', async (req, reply) => {
322
  const id = await resolveOrgId(req.params.id);
323
  if (!id) return reply.code(404).send({ error: 'Organization not found' });
apps/api/src/routes/whatsapp.ts CHANGED
@@ -64,7 +64,16 @@ export async function whatsappRoutes(fastify: FastifyInstance) {
64
  * POST /v1/whatsapp/webhook
65
  * Main entry point for incoming messages and events
66
  */
67
- fastify.post('/webhook', async (request, reply) => {
 
 
 
 
 
 
 
 
 
68
  // Verify X-Hub-Signature-256 — fail-open if WHATSAPP_APP_SECRET is not configured
69
  const APP_SECRET = process.env.WHATSAPP_APP_SECRET;
70
  if (APP_SECRET) {
 
64
  * POST /v1/whatsapp/webhook
65
  * Main entry point for incoming messages and events
66
  */
67
+ fastify.post('/webhook', {
68
+ config: {
69
+ rateLimit: {
70
+ max: 500, // Meta sends bursts for large groups
71
+ timeWindow: '1 minute',
72
+ // On rejection, still return 200 so Meta doesn't disable the webhook
73
+ errorResponseBuilder: () => ({ statusCode: 200, error: 'Too Many Requests' })
74
+ }
75
+ }
76
+ }, async (request, reply) => {
77
  // Verify X-Hub-Signature-256 — fail-open if WHATSAPP_APP_SECRET is not configured
78
  const APP_SECRET = process.env.WHATSAPP_APP_SECRET;
79
  if (APP_SECRET) {
apps/api/src/services/indexing-service.ts ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { prisma } from './prisma';
2
+ import { Prisma } from '@repo/database';
3
+ import { logger } from '../logger';
4
+ import { randomUUID } from 'crypto';
5
+
6
+ export class IndexingService {
7
+ /**
8
+ * Embeds an array of text chunks and inserts them into KnowledgeBaseEntry.
9
+ * Returns the number of chunks indexed.
10
+ */
11
+ static async indexTextChunks(
12
+ organizationId: string,
13
+ chunks: string[],
14
+ metadata?: Record<string, unknown>
15
+ ): Promise<number> {
16
+ if (chunks.length === 0) return 0;
17
+
18
+ const apiKey = process.env.OPENAI_API_KEY;
19
+ if (!apiKey) throw new Error('OPENAI_API_KEY not configured');
20
+
21
+ // Generate embeddings in batches of 100 (OpenAI limit)
22
+ const BATCH = 100;
23
+ let indexed = 0;
24
+
25
+ for (let i = 0; i < chunks.length; i += BATCH) {
26
+ const batch = chunks.slice(i, i + BATCH);
27
+
28
+ const response = await fetch('https://api.openai.com/v1/embeddings', {
29
+ method: 'POST',
30
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
31
+ body: JSON.stringify({ input: batch, model: 'text-embedding-3-small' }),
32
+ });
33
+
34
+ if (!response.ok) {
35
+ const err = await response.text();
36
+ throw new Error(`OpenAI embeddings failed: ${err}`);
37
+ }
38
+
39
+ const data = await response.json() as { data: Array<{ embedding: number[] }> };
40
+ const embeddings = data.data.map((d) => d.embedding);
41
+
42
+ for (let j = 0; j < batch.length; j++) {
43
+ const content = batch[j];
44
+ const embedding = embeddings[j];
45
+ const vecRaw = Prisma.raw(`'[${embedding.join(',')}]'::vector`);
46
+
47
+ await prisma.$executeRaw`
48
+ INSERT INTO "KnowledgeBaseEntry" ("id", "organizationId", "content", "embedding", "metadata", "createdAt")
49
+ VALUES (
50
+ ${randomUUID()},
51
+ ${organizationId},
52
+ ${content},
53
+ ${vecRaw},
54
+ ${JSON.stringify(metadata ?? {})}::jsonb,
55
+ NOW()
56
+ )
57
+ `;
58
+ indexed++;
59
+ }
60
+ }
61
+
62
+ logger.info({ organizationId, indexed }, '[INDEXING-SERVICE] Text chunks indexed');
63
+ return indexed;
64
+ }
65
+ }
packages/ai-sdk/src/index.ts CHANGED
@@ -137,6 +137,31 @@ export class AIService {
137
  return tenantRegistry.getProvidersFor(capability);
138
  }
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  private async callWithFailover<T>(
141
  prompt: string,
142
  schema: z.ZodSchema<T>,
@@ -149,7 +174,9 @@ export class AIService {
149
 
150
  for (const provider of providers) {
151
  try {
152
- const { data, usage } = await provider.instance.generateStructuredData(prompt, schema, temperature, imageUrl);
 
 
153
  logger.info(`${provider.name} used successfully for Org: ${organizationId || 'global'}.`);
154
  return { data, source: provider.name, usage };
155
  } catch (err) {
 
137
  return tenantRegistry.getProvidersFor(capability);
138
  }
139
 
140
+ private static async withRetry<T>(
141
+ fn: () => Promise<T>,
142
+ maxAttempts = 3,
143
+ baseDelayMs = 1000
144
+ ): Promise<T> {
145
+ let lastErr: unknown;
146
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
147
+ try {
148
+ return await fn();
149
+ } catch (err: any) {
150
+ lastErr = err;
151
+ // Don't retry quota errors or client errors — fail immediately to the next provider
152
+ if (err?.name === 'QuotaExceededError' || (err?.status >= 400 && err?.status < 500 && err?.status !== 429)) {
153
+ throw err;
154
+ }
155
+ if (attempt < maxAttempts - 1) {
156
+ const delay = baseDelayMs * Math.pow(2, attempt);
157
+ logger.warn(`[AI_RETRY] Transient error on attempt ${attempt + 1}/${maxAttempts} — retrying in ${delay}ms: ${err?.message}`);
158
+ await new Promise(r => setTimeout(r, delay));
159
+ }
160
+ }
161
+ }
162
+ throw lastErr;
163
+ }
164
+
165
  private async callWithFailover<T>(
166
  prompt: string,
167
  schema: z.ZodSchema<T>,
 
174
 
175
  for (const provider of providers) {
176
  try {
177
+ const { data, usage } = await AIService.withRetry(
178
+ () => provider.instance.generateStructuredData(prompt, schema, temperature, imageUrl)
179
+ );
180
  logger.info(`${provider.name} used successfully for Org: ${organizationId || 'global'}.`);
181
  return { data, source: provider.name, usage };
182
  } catch (err) {