CognxSafeTrack commited on
Commit ·
2d900bc
1
Parent(s): a8486f0
feat: Implement fuzzy matching for commands and input guardrails in whatsapp.ts
Browse files
apps/api/src/services/whatsapp.ts
CHANGED
|
@@ -20,6 +20,39 @@ export class WhatsAppService {
|
|
| 20 |
return 'UNKNOWN';
|
| 21 |
}
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
static async handleIncomingMessage(phone: string, text: string, audioUrl?: string) {
|
| 24 |
const traceId = audioUrl ? `[STT-FLOW-${phone.slice(-4)}]` : `[TXT-FLOW-${phone.slice(-4)}]`;
|
| 25 |
const normalizedText = this.normalizeCommand(text);
|
|
@@ -29,7 +62,7 @@ export class WhatsAppService {
|
|
| 29 |
let user = await prisma.user.findUnique({ where: { phone } });
|
| 30 |
|
| 31 |
if (!user) {
|
| 32 |
-
const isInscription = normalizedText
|
| 33 |
|
| 34 |
if (isInscription) {
|
| 35 |
console.log(`${traceId} New user registration triggered for ${phone}`);
|
|
@@ -69,7 +102,7 @@ export class WhatsAppService {
|
|
| 69 |
}
|
| 70 |
|
| 71 |
// 1.5. Testing / Cheat Codes (Only for registered users)
|
| 72 |
-
if (normalizedText
|
| 73 |
await prisma.enrollment.deleteMany({ where: { userId: user.id } });
|
| 74 |
await prisma.userProgress.deleteMany({ where: { userId: user.id } });
|
| 75 |
await prisma.response.deleteMany({ where: { userId: user.id } });
|
|
@@ -116,7 +149,7 @@ export class WhatsAppService {
|
|
| 116 |
return;
|
| 117 |
}
|
| 118 |
|
| 119 |
-
if (normalizedText
|
| 120 |
// Reply immediately so the webhook doesn't time out
|
| 121 |
console.log(`[SEED] Triggered by user ${user.id}`);
|
| 122 |
try {
|
|
@@ -307,8 +340,8 @@ export class WhatsAppService {
|
|
| 307 |
|
| 308 |
if (activeEnrollment) {
|
| 309 |
const intent = this.detectIntent(text);
|
| 310 |
-
const isSuite = normalizedText
|
| 311 |
-
const isApprofondir =
|
| 312 |
|
| 313 |
// Handle SUITE Priority
|
| 314 |
if (isSuite) {
|
|
@@ -464,7 +497,26 @@ export class WhatsAppService {
|
|
| 464 |
where: { trackId: activeEnrollment.trackId, dayNumber: activeEnrollment.currentDay }
|
| 465 |
});
|
| 466 |
|
| 467 |
-
if (trackDayFallback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
// Fetch previous responses to provide context to the AI Coach
|
| 469 |
const previousResponsesData = await prisma.response.findMany({
|
| 470 |
where: { userId: user.id, enrollmentId: activeEnrollment.id },
|
|
|
|
| 20 |
return 'UNKNOWN';
|
| 21 |
}
|
| 22 |
|
| 23 |
+
private static levenshteinDistance(a: string, b: string): number {
|
| 24 |
+
const matrix: number[][] = [];
|
| 25 |
+
for (let i = 0; i <= b.length; i++) matrix[i] = [i];
|
| 26 |
+
for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
|
| 27 |
+
|
| 28 |
+
for (let i = 1; i <= b.length; i++) {
|
| 29 |
+
for (let j = 1; j <= a.length; j++) {
|
| 30 |
+
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
| 31 |
+
matrix[i][j] = matrix[i - 1][j - 1];
|
| 32 |
+
} else {
|
| 33 |
+
matrix[i][j] = Math.min(
|
| 34 |
+
matrix[i - 1][j - 1] + 1, // substitution
|
| 35 |
+
matrix[i][j - 1] + 1, // insertion
|
| 36 |
+
matrix[i - 1][j] + 1 // deletion
|
| 37 |
+
);
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
return matrix[b.length][a.length];
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
private static isFuzzyMatch(text: string, target: string, threshold = 0.8): boolean {
|
| 45 |
+
const normalized = text.trim().toUpperCase();
|
| 46 |
+
const tar = target.toUpperCase();
|
| 47 |
+
if (normalized === tar) return true;
|
| 48 |
+
if (normalized.includes(tar) || tar.includes(normalized)) return true;
|
| 49 |
+
|
| 50 |
+
const distance = this.levenshteinDistance(normalized, tar);
|
| 51 |
+
const maxLength = Math.max(normalized.length, tar.length);
|
| 52 |
+
const similarity = 1 - distance / maxLength;
|
| 53 |
+
return similarity >= threshold;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
static async handleIncomingMessage(phone: string, text: string, audioUrl?: string) {
|
| 57 |
const traceId = audioUrl ? `[STT-FLOW-${phone.slice(-4)}]` : `[TXT-FLOW-${phone.slice(-4)}]`;
|
| 58 |
const normalizedText = this.normalizeCommand(text);
|
|
|
|
| 62 |
let user = await prisma.user.findUnique({ where: { phone } });
|
| 63 |
|
| 64 |
if (!user) {
|
| 65 |
+
const isInscription = this.isFuzzyMatch(normalizedText, 'INSCRIPTION') || normalizedText.includes('INSCRI');
|
| 66 |
|
| 67 |
if (isInscription) {
|
| 68 |
console.log(`${traceId} New user registration triggered for ${phone}`);
|
|
|
|
| 102 |
}
|
| 103 |
|
| 104 |
// 1.5. Testing / Cheat Codes (Only for registered users)
|
| 105 |
+
if (this.isFuzzyMatch(normalizedText, 'INSCRIPTION')) {
|
| 106 |
await prisma.enrollment.deleteMany({ where: { userId: user.id } });
|
| 107 |
await prisma.userProgress.deleteMany({ where: { userId: user.id } });
|
| 108 |
await prisma.response.deleteMany({ where: { userId: user.id } });
|
|
|
|
| 149 |
return;
|
| 150 |
}
|
| 151 |
|
| 152 |
+
if (this.isFuzzyMatch(normalizedText, 'SEED')) {
|
| 153 |
// Reply immediately so the webhook doesn't time out
|
| 154 |
console.log(`[SEED] Triggered by user ${user.id}`);
|
| 155 |
try {
|
|
|
|
| 340 |
|
| 341 |
if (activeEnrollment) {
|
| 342 |
const intent = this.detectIntent(text);
|
| 343 |
+
const isSuite = this.isFuzzyMatch(normalizedText, 'SUITE') || normalizedText === '2';
|
| 344 |
+
const isApprofondir = this.isFuzzyMatch(normalizedText, 'APPROFONDIR') || normalizedText === '1';
|
| 345 |
|
| 346 |
// Handle SUITE Priority
|
| 347 |
if (isSuite) {
|
|
|
|
| 497 |
where: { trackId: activeEnrollment.trackId, dayNumber: activeEnrollment.currentDay }
|
| 498 |
});
|
| 499 |
|
| 500 |
+
if (trackDayFallback) {
|
| 501 |
+
// 🚨 Guardrail: Contenu Vide / Gibberish 🚨
|
| 502 |
+
const wordCount = text.trim().split(/\s+/).length;
|
| 503 |
+
if (wordCount < 3 || text.length < 5) {
|
| 504 |
+
console.log(`${traceId} Guardrail: Input too short or potential gibberish: "${text}"`);
|
| 505 |
+
await scheduleMessage(user.id, user.language === 'WOLOF'
|
| 506 |
+
? "Ma déggul li nga wax mbir mi... Mën nga ko gën a firi ci ab kàddu gatt (3-4 kàddu) ?"
|
| 507 |
+
: "Je n'ai pas bien compris ton activité. Peux-tu me réexpliquer en quelques mots ce que tu vends et à qui ?");
|
| 508 |
+
return;
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
// 🚨 Guardrail: Enrollment Priority 🚨
|
| 512 |
+
if (!user.activity || !user.language) {
|
| 513 |
+
console.log(`${traceId} Blocking AI feedback: Enrollment incomplete for User ${user.id}`);
|
| 514 |
+
await scheduleMessage(user.id, user.language === 'WOLOF'
|
| 515 |
+
? "Baax na, waaye laaj bi des na... Bindal 'INSCRIPTION' ngir tàmbali."
|
| 516 |
+
: "C'est noté, mais il faut d'abord terminer ton inscription. Envoie 'INSCRIPTION' pour commencer.");
|
| 517 |
+
return;
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
// Fetch previous responses to provide context to the AI Coach
|
| 521 |
const previousResponsesData = await prisma.response.findMany({
|
| 522 |
where: { userId: user.id, enrollmentId: activeEnrollment.id },
|