CognxSafeTrack commited on
Commit
f7aefa7
·
1 Parent(s): 81b98f1

feat: implement strict AI feedback branching and contextual lesson menu with history

Browse files
apps/api/src/routes/ai.ts CHANGED
@@ -243,14 +243,25 @@ export async function aiRoutes(fastify: FastifyInstance) {
243
  isDeepDive, iterationCount, imageUrl ?? undefined, isButtonChoice
244
  );
245
 
246
- // 🌟 Standard Feedback UX: 3 lines 🌟
247
- // 1. Encouragement/Validation (VALIDATION)
248
- // 2. Diagnostic (ENRICHED VERSION)
249
- // 3. Action / Help (ACTIONABLE ADVICE)
250
- const formattedFeedback = `✨ *Coach XAMLÉ* :\n\n` +
251
- `🌟 ${feedback.validation}\n\n` +
252
- `🚀 ${feedback.enrichedVersion}\n\n` +
253
- `💡 ${feedback.actionableAdvice}`;
 
 
 
 
 
 
 
 
 
 
 
254
 
255
  return {
256
  success: true,
 
243
  isDeepDive, iterationCount, imageUrl ?? undefined, isButtonChoice
244
  );
245
 
246
+ // 🌟 Standard Feedback UX: 2-stage branching (Lead UX Requirement) 🌟
247
+ let formattedFeedback = `✨ *Coach XAMLÉ* :\n\n`;
248
+
249
+ if (feedback.isQualified === false) {
250
+ // ÉCHEC (Branching 1)
251
+ formattedFeedback += `${feedback.validation}\n\n` +
252
+ (userLanguage === 'WOLOF'
253
+ ? `🚨 Am na yenn mbir yu laaj gëna dëgër. Xoolal li ma la digal te fexe tontu waat ci exercice bi !`
254
+ : `🚨 Certains points sont encore à renforcer. Regarde mes conseils et réessaie de répondre à l'exercice !`);
255
+ } else {
256
+ // SUCCÈS (Branching 2)
257
+ formattedFeedback += `🌟 ${feedback.validation}\n\n` +
258
+ `🚀 ${feedback.enrichedVersion}\n\n` +
259
+ `💡 ${feedback.actionableAdvice}\n\n` +
260
+ (userLanguage === 'WOLOF'
261
+ ? `Soo bëggé gëna xóotal pënd bi ak li ngay dund ci yaw ci terrain bi, bindal 1️⃣ *APPROFONDIR*, soo ko bëggul bindal 2️⃣ *SUITE*.`
262
+ : `Si tu veux affiner ce point avec une donnée de ton propre terrain, tape 1️⃣ *APPROFONDIR*, sinon tape 2️⃣ *SUITE*.`);
263
+ }
264
+
265
 
266
  return {
267
  success: true,
apps/whatsapp-worker/src/handlers/CommandHandler.ts CHANGED
@@ -10,6 +10,7 @@ export class CommandHandler implements MessageHandler {
10
  const { normalizedText } = ctx;
11
 
12
  if (isFuzzyMatch(normalizedText, 'SEED')) return true;
 
13
 
14
  const dayActionMatch = normalizedText.match(/^DAY(\d+)_(EXERCISE|REPLAY|CONTINUE|PROMPT)$/);
15
  if (dayActionMatch) return true;
@@ -18,11 +19,14 @@ export class CommandHandler implements MessageHandler {
18
  }
19
 
20
  async handle(ctx: MessageContext): Promise<boolean> {
21
- const { user, normalizedText, whatsappQueue, traceId } = ctx;
22
  if (!user) return false;
23
 
 
 
24
  // --- 1. SEED ---
25
  if (isFuzzyMatch(normalizedText, 'SEED')) {
 
26
  logger.info({ traceId, userId: user.id }, "Database Seeding requested");
27
  try {
28
  // @ts-ignore
@@ -43,24 +47,74 @@ export class CommandHandler implements MessageHandler {
43
  return true;
44
  }
45
 
46
- // --- 2. DAY ACTIONS (List menus) ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  const dayActionMatch = normalizedText.match(/^DAY(\d+)_(EXERCISE|REPLAY|CONTINUE|PROMPT)$/);
48
  if (dayActionMatch) {
49
  const action = dayActionMatch[2];
50
- const enrollment = await prisma.enrollment.findFirst({ where: { userId: user.id, status: 'ACTIVE' } });
51
 
52
  logger.info({ traceId, userId: user.id, action, day: dayActionMatch[1] }, "Day action triggered");
53
 
54
  if (action === 'REPLAY' && enrollment) {
55
- await whatsappQueue.add('send-content', { userId: user.id, trackId: enrollment.trackId, dayNumber: enrollment.currentDay });
 
 
 
 
 
 
56
  return true;
57
  } else if (action === 'EXERCISE') {
58
- const msg = user.language === 'WOLOF' ? "🎙️ Yónnee sa tontu :" : "🎙️ Envoie ta réponse :";
59
  await whatsappQueue.add('send-message', { userId: user.id, text: msg });
60
  return true;
61
  }
62
  }
63
 
 
64
  return false;
65
  }
66
  }
 
10
  const { normalizedText } = ctx;
11
 
12
  if (isFuzzyMatch(normalizedText, 'SEED')) return true;
13
+ if (isFuzzyMatch(normalizedText, 'MENU_HISTORIQUE')) return true;
14
 
15
  const dayActionMatch = normalizedText.match(/^DAY(\d+)_(EXERCISE|REPLAY|CONTINUE|PROMPT)$/);
16
  if (dayActionMatch) return true;
 
19
  }
20
 
21
  async handle(ctx: MessageContext): Promise<boolean> {
22
+ const { user, normalizedText, whatsappQueue, traceId, activeEnrollment } = ctx;
23
  if (!user) return false;
24
 
25
+ const isWolof = user.language === 'WOLOF';
26
+
27
  // --- 1. SEED ---
28
  if (isFuzzyMatch(normalizedText, 'SEED')) {
29
+ // ... (existing seed logic)
30
  logger.info({ traceId, userId: user.id }, "Database Seeding requested");
31
  try {
32
  // @ts-ignore
 
47
  return true;
48
  }
49
 
50
+ // --- 2. MENU_HISTORIQUE ---
51
+ if (isFuzzyMatch(normalizedText, 'MENU_HISTORIQUE')) {
52
+ if (!activeEnrollment) return false;
53
+
54
+ logger.info({ traceId, userId: user.id }, "User requested LESSON HISTORY");
55
+
56
+ const pastDays = await prisma.trackDay.findMany({
57
+ where: {
58
+ trackId: activeEnrollment.trackId,
59
+ dayNumber: { lt: activeEnrollment.currentDay }
60
+ },
61
+ orderBy: { dayNumber: 'asc' }
62
+ });
63
+
64
+ if (pastDays.length === 0) {
65
+ const msg = isWolof ? "Amul bés yu passé ba pare !" : "Tu n'as pas encore d'anciennes leçons !";
66
+ await whatsappQueue.add('send-message', { userId: user.id, text: msg });
67
+ return true;
68
+ }
69
+
70
+ const rows = pastDays.map(day => ({
71
+ id: `DAY${day.dayNumber}_REPLAY`,
72
+ title: isWolof ? `Bés ${day.dayNumber}` : `Leçon ${day.dayNumber}`,
73
+ description: isWolof ? "Revoir cette leçon" : "Revoir cette leçon"
74
+ }));
75
+
76
+ // WhatsApp list limit is 10 per section. Let's take the last 10 if too many.
77
+ const limitedRows = rows.slice(-10);
78
+
79
+ const { sendInteractiveListMessage } = await import('../whatsapp-cloud');
80
+ await sendInteractiveListMessage(
81
+ user.phone,
82
+ isWolof ? "Sa njàng" : "Ton parcours",
83
+ isWolof ? "Tànnal bés bi nga bëgg tontu waat :" : "Choisis la leçon que tu souhaites revoir :",
84
+ isWolof ? "Seeti" : "Parcourir",
85
+ [{
86
+ title: isWolof ? "Bés yu passé" : "Leçons passées",
87
+ rows: limitedRows
88
+ }]
89
+ );
90
+ return true;
91
+ }
92
+
93
+ // --- 3. DAY ACTIONS (List menus) ---
94
  const dayActionMatch = normalizedText.match(/^DAY(\d+)_(EXERCISE|REPLAY|CONTINUE|PROMPT)$/);
95
  if (dayActionMatch) {
96
  const action = dayActionMatch[2];
97
+ const enrollment = activeEnrollment || await prisma.enrollment.findFirst({ where: { userId: user.id, status: 'ACTIVE' } });
98
 
99
  logger.info({ traceId, userId: user.id, action, day: dayActionMatch[1] }, "Day action triggered");
100
 
101
  if (action === 'REPLAY' && enrollment) {
102
+ // Important: Trigger a full content send for the historical day
103
+ await whatsappQueue.add('send-content', {
104
+ userId: user.id,
105
+ trackId: enrollment.trackId,
106
+ dayNumber: parseFloat(dayActionMatch[1]),
107
+ isHistorical: true // Signal to skip progress update
108
+ });
109
  return true;
110
  } else if (action === 'EXERCISE') {
111
+ const msg = isWolof ? "🎙️ J'écoute ta réponse (vocal wala mbind) :" : "🎙️ J'écoute ta réponse (vocal ou texte) :";
112
  await whatsappQueue.add('send-message', { userId: user.id, text: msg });
113
  return true;
114
  }
115
  }
116
 
117
+
118
  return false;
119
  }
120
  }
apps/whatsapp-worker/src/index.ts CHANGED
@@ -134,27 +134,22 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
134
  if (feedbackRes.ok) {
135
  feedbackData = await feedbackRes.json();
136
 
137
- const callToAction = language === 'WOLOF'
138
- ? "\n\nSu nga bëggee yokk leneen ci li nga xam, bindal 1️⃣ APPROFONDIR, wala nga bind 2️⃣ SUITE."
139
- : "\n\nSi tu veux affiner ce point avec une donnée de ton propre terrain, tape 1️⃣ APPROFONDIR, sinon tape 2️⃣ SUITE.";
140
 
141
- if (feedbackData.validation && feedbackData.enrichedVersion && feedbackData.actionableAdvice) {
142
- feedbackMsg = `🌟 ${feedbackData.validation}\n\n🚀 ${feedbackData.enrichedVersion}\n\n💡 Conseil de Terrain :\n${feedbackData.actionableAdvice}`;
143
- if (!isDeepDive || (isDeepDive && iterationCount < 3 && !feedbackData.isForcedClosure)) {
144
- feedbackMsg += callToAction;
145
- } else if (feedbackData.isForcedClosure) {
146
- feedbackMsg += (language === 'WOLOF' ? "\n\nBindal 2️⃣ SUITE ngir wéy." : "\n\nTape 2️⃣ SUITE pour continuer.");
147
- }
148
- } else if (feedbackData.text) {
149
  feedbackMsg = feedbackData.text;
 
 
 
 
 
 
150
  if (!isDeepDive || (isDeepDive && iterationCount < 3 && !feedbackData.isForcedClosure)) {
151
  feedbackMsg += callToAction;
152
- } else if (feedbackData.isForcedClosure) {
153
- feedbackMsg += (language === 'WOLOF' ? "\n\nBindal 2️⃣ SUITE ngir wéy." : "\n\nTape 2️⃣ SUITE pour continuer.");
154
  }
155
  } else {
156
  feedbackMsg = '✅ Analyse terminée.';
157
  }
 
158
  } else if (feedbackRes.status === 429) {
159
  logger.warn(`[WORKER] 429 Error during generate-feedback`);
160
  const fallbackMsg = language === 'WOLOF'
@@ -811,16 +806,8 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
811
  }
812
  }
813
  else if (job.name === 'send-content') {
814
- const { userId, trackId, dayNumber, testImageUrl } = job.data;
815
 
816
- if (testImageUrl && userId) {
817
- const u = await prisma.user.findUnique({ where: { id: userId } });
818
- if (u?.phone) {
819
- await sendImageMessage(u.phone, testImageUrl, "Branding XAMLÉ 🇸🇳");
820
- logger.info(`[WhatsApp] ✅ Image message sent to ${u.phone}`);
821
- }
822
- return;
823
- }
824
 
825
  const user = await prisma.user.findUnique({ where: { id: userId } });
826
  const trackDay = await prisma.trackDay.findFirst({
 
134
  if (feedbackRes.ok) {
135
  feedbackData = await feedbackRes.json();
136
 
137
+ if (feedbackData.text) {
 
 
138
 
 
 
 
 
 
 
 
 
139
  feedbackMsg = feedbackData.text;
140
+ } else if (feedbackData.validation && feedbackData.enrichedVersion && feedbackData.actionableAdvice) {
141
+ // LEGACY FALLBACK: Construct message if API didn't return .text (Branching already better in .text)
142
+ feedbackMsg = `🌟 ${feedbackData.validation}\n\n🚀 ${feedbackData.enrichedVersion}\n\n💡 Conseil de Terrain :\n${feedbackData.actionableAdvice}`;
143
+ const callToAction = language === 'WOLOF'
144
+ ? "\n\nSoo bëggé gëna xóotal pënd bi ak li ngay dund ci yaw ci terrain bi, bindal 1️⃣ *APPROFONDIR*, soo ko bëggul bindal 2️⃣ *SUITE*."
145
+ : "\n\nSi tu veux affiner ce point avec une donnée de ton propre terrain, tape 1️⃣ *APPROFONDIR*, sinon tape 2️⃣ *SUITE*.";
146
  if (!isDeepDive || (isDeepDive && iterationCount < 3 && !feedbackData.isForcedClosure)) {
147
  feedbackMsg += callToAction;
 
 
148
  }
149
  } else {
150
  feedbackMsg = '✅ Analyse terminée.';
151
  }
152
+
153
  } else if (feedbackRes.status === 429) {
154
  logger.warn(`[WORKER] 429 Error during generate-feedback`);
155
  const fallbackMsg = language === 'WOLOF'
 
806
  }
807
  }
808
  else if (job.name === 'send-content') {
809
+ const { userId, trackId, dayNumber } = job.data;
810
 
 
 
 
 
 
 
 
 
811
 
812
  const user = await prisma.user.findUnique({ where: { id: userId } });
813
  const trackDay = await prisma.trackDay.findFirst({
apps/whatsapp-worker/src/pedagogy.ts CHANGED
@@ -320,7 +320,9 @@ export async function sendLessonDay(
320
 
321
  // 🌟 3. Send Action LIST menu ────────────────────────────────────────────────
322
  // Shown after every lesson so the user knows their options
323
- if (dayNumber === 1) {
 
 
324
  // Direct invitation to respond for Day 1 to reduce friction
325
  await sendTextMessage(
326
  user.phone,
@@ -329,31 +331,49 @@ export async function sendLessonDay(
329
  : "🎙️ À toi de jouer ! Réponds à l'exercice ci-dessus par message vocal ou texte."
330
  );
331
  } else {
332
- await sendInteractiveListMessage(
333
- user.phone,
334
- isWolof ? `Jour ${dayNumber}` : `Leçon ${dayNumber}`,
335
- isWolof
336
- ? "Seetee ci suuf ban jëf ngay def :"
337
- : "Que veux-tu faire maintenant ?",
338
- isWolof ? "Tànnal" : "Choisir",
339
- [{
 
 
 
 
 
 
 
340
  title: isWolof ? "Jëfandikoo" : "Actions",
341
  rows: [
342
- {
343
- id: `DAY${dayNumber}_REPLAY`,
344
- title: isWolof ? `🎧 Refaire Bés ${dayNumber}` : `🎧 Refaire Leçon ${dayNumber}`,
345
- description: isWolof ? "Waxtu bi ci kaw" : "Réécouter la leçon"
346
- },
347
  {
348
  id: `DAY${dayNumber}_EXERCISE`,
349
- title: isWolof ? "🎙️ Yónni tontu" : "🎙️ Répondre",
350
  description: isWolof ? "Dëbb (vocal) walla mbind" : "Message vocal ou texte"
 
 
 
 
 
351
  }
352
  ]
353
- }]
 
 
 
 
 
 
 
 
 
 
354
  );
355
  }
356
 
 
357
  // 🌟 4 & 5. Update User Progress and Enrollment.currentDay 🌟
358
  // ⚠️ Skipped when skipProgressUpdate=true (REPLAY of a historical lesson)
359
  if (!options?.skipProgressUpdate) {
 
320
 
321
  // 🌟 3. Send Action LIST menu ────────────────────────────────────────────────
322
  // Shown after every lesson so the user knows their options
323
+ const isHistorical = options?.skipProgressUpdate === true || dayNumber < currentDay;
324
+
325
+ if (dayNumber === 1 && !isHistorical) {
326
  // Direct invitation to respond for Day 1 to reduce friction
327
  await sendTextMessage(
328
  user.phone,
 
331
  : "🎙️ À toi de jouer ! Réponds à l'exercice ci-dessus par message vocal ou texte."
332
  );
333
  } else {
334
+ const sections = [];
335
+
336
+ if (isHistorical) {
337
+ // MODE REPLAY: Just show the Refaire button
338
+ sections.push({
339
+ title: isWolof ? "Revoir" : "Revue",
340
+ rows: [{
341
+ id: `DAY${dayNumber}_REPLAY`,
342
+ title: isWolof ? `🎧 Refaire Bés ${dayNumber}` : `🎧 Refaire Leçon ${dayNumber}`,
343
+ description: isWolof ? "Waxtu bi ci kaw" : "Réécouter la leçon"
344
+ }]
345
+ });
346
+ } else {
347
+ // MODE NOUVEAU: Let user answer or see history
348
+ sections.push({
349
  title: isWolof ? "Jëfandikoo" : "Actions",
350
  rows: [
 
 
 
 
 
351
  {
352
  id: `DAY${dayNumber}_EXERCISE`,
353
+ title: isWolof ? "📝 Tontul exercice" : "📝 Répondre",
354
  description: isWolof ? "Dëbb (vocal) walla mbind" : "Message vocal ou texte"
355
+ },
356
+ {
357
+ id: `MENU_HISTORIQUE`,
358
+ title: isWolof ? "📚 Li nekk ci ginnaaw" : "📚 Revoir anciennes leçons",
359
+ description: isWolof ? "Seeti lu passé" : "Historique des leçons"
360
  }
361
  ]
362
+ });
363
+ }
364
+
365
+ await sendInteractiveListMessage(
366
+ user.phone,
367
+ isWolof ? `Jour ${dayNumber}` : `Leçon ${dayNumber}`,
368
+ isWolof
369
+ ? "Seetee ci suuf ban jëf ngay def :"
370
+ : "Que veux-tu faire maintenant ?",
371
+ isWolof ? "Tànnal" : "Choisir",
372
+ sections
373
  );
374
  }
375
 
376
+
377
  // 🌟 4 & 5. Update User Progress and Enrollment.currentDay 🌟
378
  // ⚠️ Skipped when skipProgressUpdate=true (REPLAY of a historical lesson)
379
  if (!options?.skipProgressUpdate) {