CognxSafeTrack commited on
Commit
a4ce760
·
1 Parent(s): 76fdb59

feat: 4 UX/backend fixes — anti-ghost image, time-travel replay, UX message order, contextual AI remediation

Browse files
apps/api/src/services/ai/index.ts CHANGED
@@ -331,7 +331,15 @@ class AIService {
331
  - Tu NE DOIS PLUS JAMAIS demander à l'utilisateur de s'expliquer davantage (ex: "Quel est l'âge exact ?", "Combien gagnes-tu ?").
332
  - S'il manque un détail mais que l'idée est claire, c'est TOI qui apportes le savoir (donne les tranches d'âges classiques du secteur, donne les revenus moyens du pays).
333
  - Tu es là pour ENRICHIR sa vision, pas pour lui faire passer un interrogatoire. Ne pose AUCUNE question bloquante à la fin.
334
- - **CRITÈRE DE VALIDATION (isQualified)** : Dès que l'utilisateur fournit une réponse sérieuse liée à son projet (même courte), mets 'isQualified: true'. Ne sois pas trop sévère. S'il y a un doute, valide et enrichis dans ton feedback.
 
 
 
 
 
 
 
 
335
 
336
  INVITATION DEEP-DIVE OBLIGATOIRE :
337
  ${hasQuestion ? `L'utilisateur ayant posé une question, tu DOIS systématiquement proposer l'option d'approfondissement pour explorer des alternatives stratégiques.` : ''}
 
331
  - Tu NE DOIS PLUS JAMAIS demander à l'utilisateur de s'expliquer davantage (ex: "Quel est l'âge exact ?", "Combien gagnes-tu ?").
332
  - S'il manque un détail mais que l'idée est claire, c'est TOI qui apportes le savoir (donne les tranches d'âges classiques du secteur, donne les revenus moyens du pays).
333
  - Tu es là pour ENRICHIR sa vision, pas pour lui faire passer un interrogatoire. Ne pose AUCUNE question bloquante à la fin.
334
+ ⚠️ COHÉRENCE THÉMATIQUE OBLIGATOIRE (ANTI-HORS-SUJET) :
335
+ Avant de valider, vérifie que la réponse de l'étudiant est liée à la question du Jour ${dayNumber} : "${expectedExercise?.substring(0, 200)}".
336
+ Si la réponse est TOTALEMENT hors-sujet (ex: salutation, spam, ou thème sans rapport), alors:
337
+ - Mets OBLIGATOIREMENT isQualified: false.
338
+ - Explique-lui POLIMENT et PRÉCISÉMENT (en 2-3 phrases max dans la section validation) ce qu'il doit corriger pour réussir cet exercice.
339
+ - Exemple: Si l'exercice demande "Décris ton client principal" et l'étudiant répond "Bonjour bonne journée", c'est hors-sujet → isQualified: false et tu dis "Pour valider cette étape, je dois savoir QUI achète ton produit : son profil, son âge, ses habitudes. Réponds à l'exercice pour continuer."
340
+ - Ne sois sévère QUE sur le hors-sujet flagrant. Une réponse courte mais pertinente reste validée (isQualified: true).
341
+
342
+ **CRITÈRE DE VALIDATION (isQualified)** : Dès que l'utilisateur fournit une réponse sérieuse liée à son projet (même courte), mets 'isQualified: true'. Ne sois pas trop sévère. S'il y a un doute, valide et enrichis dans ton feedback.
343
 
344
  INVITATION DEEP-DIVE OBLIGATOIRE :
345
  ${hasQuestion ? `L'utilisateur ayant posé une question, tu DOIS systématiquement proposer l'option d'approfondissement pour explorer des alternatives stratégiques.` : ''}
apps/api/src/services/whatsapp.ts CHANGED
@@ -197,19 +197,23 @@ export class WhatsAppService {
197
  const action = dayActionMatch[2];
198
 
199
  if (action === 'REPLAY') {
 
200
  const enrollment = await prisma.enrollment.findFirst({
201
  where: { userId: user.id, status: 'ACTIVE' }
202
  });
203
  if (enrollment) {
 
 
 
 
 
 
204
  await whatsappQueue.add('send-content', {
205
  userId: user.id,
206
  trackId: enrollment.trackId,
207
- dayNumber: enrollment.currentDay
208
- });
209
- await scheduleMessage(user.id, user.language === 'WOLOF'
210
- ? "🔁 Dinanu la yëgël waxtu bi ci kanam..."
211
- : "🔁 Je te renvoie la leçon dans quelques secondes..."
212
- );
213
  }
214
  return;
215
  } else if (action === 'EXERCISE') {
 
197
  const action = dayActionMatch[2];
198
 
199
  if (action === 'REPLAY') {
200
+ const replayDay = parseFloat(dayActionMatch[1]); // Use the day from the button (DAY11_REPLAY → 11)
201
  const enrollment = await prisma.enrollment.findFirst({
202
  where: { userId: user.id, status: 'ACTIVE' }
203
  });
204
  if (enrollment) {
205
+ // ✅ UX Fix 3: Confirmation FIRST, content delayed — so message order is guaranteed
206
+ await scheduleMessage(user.id, user.language === 'WOLOF'
207
+ ? `🔁 Dinanu la yëgël lexon Bés ${Math.floor(replayDay)} ci kanam...`
208
+ : `🔁 Je te renvoie la Leçon ${Math.floor(replayDay)} dans quelques secondes...`
209
+ );
210
+ // skipProgressUpdate: true → sendLessonDay won't touch currentDay or exerciseStatus
211
  await whatsappQueue.add('send-content', {
212
  userId: user.id,
213
  trackId: enrollment.trackId,
214
+ dayNumber: replayDay,
215
+ skipProgressUpdate: true
216
+ }, { delay: 2000 });
 
 
 
217
  }
218
  return;
219
  } else if (action === 'EXERCISE') {
apps/whatsapp-worker/src/index.ts CHANGED
@@ -789,7 +789,9 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
789
  });
790
 
791
  if (trackDay) {
792
- await sendLessonDay(userId, trackId, dayNumber);
 
 
793
 
794
  // Update basic enrollment tracking for compatibility
795
  await prisma.enrollment.updateMany({
 
789
  });
790
 
791
  if (trackDay) {
792
+ await sendLessonDay(userId, trackId, dayNumber, {
793
+ skipProgressUpdate: job.data.skipProgressUpdate === true
794
+ });
795
 
796
  // Update basic enrollment tracking for compatibility
797
  await prisma.enrollment.updateMany({
apps/whatsapp-worker/src/pedagogy.ts CHANGED
@@ -22,7 +22,12 @@ function generateProgressBar(current: number, total: number): string {
22
  return `[${bar}] ${percentage}%`;
23
  }
24
 
25
- export async function sendLessonDay(userId: string, trackId: string, dayNumber: number) {
 
 
 
 
 
26
  console.log(`[PEDAGOGY] Preparing Lesson Day ${dayNumber} for User ${userId}`);
27
 
28
  const user = await prisma.user.findUnique({
@@ -344,28 +349,32 @@ export async function sendLessonDay(userId: string, trackId: string, dayNumber:
344
  );
345
  }
346
 
347
- // 🌟 4. Update User Progress to PENDING 🌟
348
- await prisma.userProgress.upsert({
349
- where: { userId_trackId: { userId, trackId } },
350
- update: {
351
- exerciseStatus: 'PENDING',
352
- lastInteraction: new Date()
353
- },
354
- create: {
355
- userId,
356
- trackId,
357
- exerciseStatus: 'PENDING'
358
- }
359
- });
 
 
360
 
361
- // 🌟 5. Correctly update Enrollment.currentDay 🌟
362
- await prisma.enrollment.updateMany({
363
- where: { userId, trackId, status: 'ACTIVE' },
364
- data: {
365
- currentDay: dayNumber,
366
- lastActivityAt: new Date()
367
- }
368
- });
369
 
370
- console.log(`[PEDAGOGY] Lesson Day ${dayNumber} sent. UserProgress set to PENDING.`);
 
 
 
371
  }
 
22
  return `[${bar}] ${percentage}%`;
23
  }
24
 
25
+ export async function sendLessonDay(
26
+ userId: string,
27
+ trackId: string,
28
+ dayNumber: number,
29
+ options?: { skipProgressUpdate?: boolean }
30
+ ) {
31
  console.log(`[PEDAGOGY] Preparing Lesson Day ${dayNumber} for User ${userId}`);
32
 
33
  const user = await prisma.user.findUnique({
 
349
  );
350
  }
351
 
352
+ // 🌟 4 & 5. Update User Progress and Enrollment.currentDay 🌟
353
+ // ⚠️ Skipped when skipProgressUpdate=true (REPLAY of a historical lesson)
354
+ if (!options?.skipProgressUpdate) {
355
+ await prisma.userProgress.upsert({
356
+ where: { userId_trackId: { userId, trackId } },
357
+ update: {
358
+ exerciseStatus: 'PENDING',
359
+ lastInteraction: new Date()
360
+ },
361
+ create: {
362
+ userId,
363
+ trackId,
364
+ exerciseStatus: 'PENDING'
365
+ }
366
+ });
367
 
368
+ await prisma.enrollment.updateMany({
369
+ where: { userId, trackId, status: 'ACTIVE' },
370
+ data: {
371
+ currentDay: dayNumber,
372
+ lastActivityAt: new Date()
373
+ }
374
+ });
 
375
 
376
+ console.log(`[PEDAGOGY] Lesson Day ${dayNumber} sent. UserProgress set to PENDING.`);
377
+ } else {
378
+ console.log(`[PEDAGOGY] Lesson Day ${dayNumber} replayed (read-only). currentDay unchanged.`);
379
+ }
380
  }
apps/whatsapp-worker/src/services/whatsapp-logic.ts CHANGED
@@ -277,8 +277,19 @@ export class WhatsAppLogic {
277
  console.log(`[FLOW-SYNC] User ${user.id} at Day ${activeEnrollment.currentDay}, status: ${userProgress?.exerciseStatus}`);
278
 
279
  const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
 
280
  const isRecentlyCompleted = userProgress?.exerciseStatus === 'COMPLETED' && userProgress.updatedAt > fiveMinutesAgo;
281
- const shouldForceRevalidation = !!imageUrl || isRecentlyCompleted;
 
 
 
 
 
 
 
 
 
 
282
 
283
  if (shouldForceRevalidation && userProgress?.exerciseStatus === 'COMPLETED') {
284
  console.log(`[FLOW-SYNC] 🔄 Re-validation triggered for User ${user.id} (Reason: ${imageUrl ? 'New Image' : 'Recent Correction'})`);
 
277
  console.log(`[FLOW-SYNC] User ${user.id} at Day ${activeEnrollment.currentDay}, status: ${userProgress?.exerciseStatus}`);
278
 
279
  const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
280
+ const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
281
  const isRecentlyCompleted = userProgress?.exerciseStatus === 'COMPLETED' && userProgress.updatedAt > fiveMinutesAgo;
282
+
283
+ // 🚨 Anti-Fantôme: Only revalidate with an image if the exercise is actively awaiting input
284
+ // OR the image arrives shortly after completion (immediate retry). Orphan images (delayed
285
+ // retries from Meta) arriving > 10 min after COMPLETED must NOT reset progression.
286
+ const isImageForActiveExercise = !!imageUrl && (
287
+ userProgress?.exerciseStatus === 'PENDING' ||
288
+ userProgress?.exerciseStatus === 'PENDING_DEEPDIVE' ||
289
+ userProgress?.exerciseStatus === 'PENDING_REMEDIATION' ||
290
+ (userProgress?.exerciseStatus === 'COMPLETED' && userProgress.updatedAt > tenMinutesAgo)
291
+ );
292
+ const shouldForceRevalidation = isImageForActiveExercise || isRecentlyCompleted;
293
 
294
  if (shouldForceRevalidation && userProgress?.exerciseStatus === 'COMPLETED') {
295
  console.log(`[FLOW-SYNC] 🔄 Re-validation triggered for User ${user.id} (Reason: ${imageUrl ? 'New Image' : 'Recent Correction'})`);