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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 208 |
-
|
| 209 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
|
|
|
|
|
|
| 360 |
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
}
|
| 368 |
-
});
|
| 369 |
|
| 370 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'})`);
|