921 let s = ''; 922 idx = idx + 1; 923 while (idx > 0) { 924 const r = (idx - 1) % 26; 925 s = String.fromCharCode(65 + r) + s; 926 idx = Math.floor((idx - 1) / 26); 927 } 928 return s; 929} 930function normalizeForCompare(s) { 931 // Trim + strip any "?…" query-suffix on URLs, so two equivalent values 932 // compare equal even if one was written with extra params. 933 return String(s ?? '').trim().replace(/\?.*$/, ''); 934} 935 936function safeFilename(s) { 937 return String(s || 'unnamed').replace(/[\/\\?%*:|"<>+]/g, '_').replace(/\s+/g, ' ').trim(); 938} 939 940// ─── Integration-backed transcription + evaluation ────────────────── 941 942// Transcribe one audio part via Deepgram. Mints a signed storage URL and 943// hands it to Deepgram, which fetches the bytes server-side from the public 944// Cloudflare tunnel. Avoids inlining base64 (no 25 MB cap, no memory bloat). 945async function deepgramTranscribe(context, storageFileId, languageHint = 'fr') { 946 const u = await context.call('storage', 'files_get_url', { 947 id: parseInt(storageFileId, 10), 948 ttl_seconds: 1800, 949 }); 950 if (!u?.url) throw new Error('files_get_url returned no url for storage_file_id=' + storageFileId); 951 const res = await context.integration('deepgram', 'deepgram_listen', { 952 url: u.url, 953 language: languageHint, 954 model: 'nova-3', 955 smart_format: true, 956 punctuate: true, 957 paragraphs: true, 958 }); 959 try { context.log('deepgram raw response keys: ' + Object.keys(res || {}).join(',')); } catch {} 960 // context.integration may return either the envelope {success,status,data} 961 // OR the raw response body directly. Handle both. 962 let body = res?.data ?? res ?? {}; 963 // If envelope-shaped and explicitly failed, raise. 964 if (res && typeof res === 'object' && res.success === false) { 965 throw new Error('deepgram_listen failed: status=' + res.status + ' ' + JSON.stringify(res.error || res.data || {}).slice(0, 300)); 966 } 967 const alternative = body?.results?.channels?.[0]?.alternatives?.[0] || {}; 968 const transcript = alternative.transcript; 969 if (typeof transcript === 'string') { 970 return { 971 text: transcript, 972 segments: compactDeepgramSentenceTimings(alternative), 973 duration_seconds: Number(body?.metadata?.duration || 0) || null, 974 }; 975 } 976 if (typeof body?.transcript === 'string') { 977 return { text: body.transcript, segments: [], duration_seconds: null }; 978 } 979 throw new Error('deepgram_listen returned unexpected shape: ' + JSON.stringify(body).slice(0, 200)); 980} 981 982function compactDeepgramSentenceTimings(alternative, offsetSeconds = 0) { 983 const paragraphs = alternative?.paragraphs?.paragraphs || []; 984 return paragraphs 985 .flatMap(paragraph => paragraph?.sentences || []) 986 .map(sentence => ({ 987 start: Number((Number(sentence?.start || 0) + offsetSeconds).toFixed(3)), ··· 1191 `- IBAN audible après annonce de coupure : ${facts.iban_audible_after_announced_pause ? 'oui' : 'non'}`, 1192 ]; 1193 if (isConsoPiloteGrid(facts.target_grille_id)) { 1194 lines.push( 1195 `- Annonce d'enregistrement avant la séquence Conso Pilote : ${facts.session_recording_precedes_target_addon ? 'oui' : 'non'}`, 1196 `- Autorisation explicite des données Conso Pilote : ${facts.session_conso_data_authorization ? 'oui' : 'non'}`, 1197 `- Procédure IBAN Conso Pilote protégée et reprise : ${facts.session_protected_iban_procedure ? 'oui' : 'non'}`, 1198 `- Souscription Conso Pilote finalisée : ${facts.session_conso_subscription_completed ? 'oui' : 'non'}`, 1199 ); 1200 } 1201 return `## Faits vérifiés par le serveur\n\n${lines.join('\n')}\n\nCes faits sont déterministes et priment sur toute estimation. N'invente jamais de timestamp lorsque l'horaire est non vérifiable.`; 1202} 1203 1204// Render the criteres list into the markdown block the skill expects. 1205// Mirrors lib/evaluate_appel.js renderGrille so the prompt stays identical. 1206function renderGrilleText(grille, criteres) { 1207 const lines = []; 1208 const baremeTotal = criteres.reduce((s, c) => s + (Number(c.bareme) || 0), 0); 1209 const scoringMode = normalizeScoringMode(grille); 1210 const baseScore = gridBaseScore(grille); 1211 lines.push('## Grille à appliquer', ''); 1212 lines.push(`- Fournisseur : ${grille?.fournisseur || '(inconnu)'}`); 1213 lines.push(`- Version : ${grille?.local_id || ''}`); 1214 if (scoringMode === 'deduction') { 1215 lines.push(`- Mode de notation : malus déduits d'une note de départ de ${baseScore}`); 1216 lines.push(`- Malus maximum cumulé : ${baremeTotal}`); 1217 } else { 1218 lines.push(`- Bareme total : ${baremeTotal}`); 1219 } 1220 lines.push(`- Nombre de critères : ${criteres.length}`, ''); 1221 if (grille?.local_id === 'lead_telecom_v3') { 1222 lines.push( 1223 '### Consignes spécifiques — Lead Télécom V3', 1224 'Ces consignes priment sur les exemples génériques Free et de souscription. Cette grille évalue la qualification et le rappel pour tout le télécom (leads, box et mobile), quel que soit le fournisseur. Ne pas substituer les critères de souscription Free aux sept critères fournis. Une qualification télécom sans souscription finalisée ou portant sur un autre opérateur reste évaluable avec cette grille.', 1225 'LT002 (consentement RGPD) et LT006 (accord de recontact) sont bloquants : leur absence doit être explicitement signalée dans le bilan et les points à corriger, même si le score est élevé. Ne pas les neutraliser comme Non applicable du seul fait qu’une vente est déjà signée.', 1226 'Sans accord de recontact, écrire explicitement « Lead KO » dans le bilan. Un accord de souscription ne vaut pas accord de rappel.', 1227 'Conserver le calcul additif sur 100 points : un blocage ne transforme pas arbitrairement la note globale en zéro. Le verdict qualité ne modifie pas le statut commercial du lead ou du contrat.', 1228 '', 1229 ); 1230 } 1231 lines.push('### Critères', ''); 1232 const sorted = [...criteres].sort((a, b) => String(a.code).localeCompare(String(b.code))); 1233 for (const c of sorted) { 1234 const crit = c.critique ? ' [CRITIQUE]' : ''; 1235 lines.push(`**${c.code} — ${c.categorie || ''} / ${c.libelle || ''}** (/${c.bareme})${crit}`); 1236 const instruction = c.commentaire_instruction || c.description; 1237 if (instruction) lines.push(`> ${instruction}`); 1238 lines.push(''); 1239 } 1240 return lines.join('\n'); 1241} 1242 1243function normalizeScoringMode(grille) { 1244 return String(grille?.scoring_mode || 'additive').trim().toLowerCase() === 'deduction' 1245 ? 'deduction' 1246 : 'additive'; 1247} 1248 1249function gridBaseScore(grille) { 1250 const value = Number(grille?.base_score); 1251 return Number.isFinite(value) && value > 0 ? value : 100; 1252} 1253 1254function calculateEvaluationScore(evaluations, criteres, grille = null) { 1255 const earned = evaluations.reduce((sum, evaluation) => sum + (Number(evaluation?.score) || 0), 0); 1256 const baremeTotal = criteres.reduce((sum, critere) => sum + (Number(critere?.bareme) || 0), 0); 1257 if (normalizeScoringMode(grille) !== 'deduction') { 1258 return { score_total: earned, score_max: baremeTotal }; 1259 } 1260 const baseScore = gridBaseScore(grille); 1261 const appliedPenalty = Math.max(0, baremeTotal - earned); 1262 return { 1263 score_total: Math.max(0, Math.min(baseScore, baseScore - appliedPenalty)), 1264 score_max: baseScore, 1265 }; 1266} 1267 1268function safeJson(value, fallback = null) { 1269 if (value == null || value === '') return fallback; 1270 if (typeof value !== 'string') return value; 1271 try { return JSON.parse(value); } catch { return fallback; } 1272} 1273 1274function selectEvaluationOffer(ventes, offres) { 1275 const offerById = new Map((offres || []).map((offre) => [String(offre?.local_id || offre?.id || ''), offre])); 1276 const candidates = (ventes || []) 1277 .map((vente, index) => { 1278 const offre = offerById.get(String(vente?.offre_id || '')) || null; 1279 if (!offre?.criteres_grille_id) return null; 1280 const role = String(vente?.vente_role || '').trim().toLowerCase(); 1281 const family = String(offre?.famille || '').trim().toLowerCase(); 1282 const type = String(offre?.type_offre || '').trim().toLowerCase(); 1283 const principal = role === 'principale' || role === 'principal' || role === 'main'; 1284 const energy = type === 'energie' || family === 'energie' || family.startsWith('energie_'); 1285 return { 1286 vente, 1287 offre, 1288 index, 1289 rank: principal && energy ? 0 : energy ? 1 : principal ? 2 : 3, 1290 }; 1291 }) 1292 .filter(Boolean); 1293 candidates.sort((left, right) => left.rank - right.rank || left.index - right.index); 1294 return candidates[0] || null; 1295} 1296 ··· 3276 params: offreIds, 3277 }); 3278 offres = offresQuery.rows || []; 3279 } 3280 log('offres linked to ' + appel.local_id + ': ' + offres.length); 3281 3282 const metadataMismatch = detectVenteMetadataMismatch(ventes, offres); 3283 if (metadataMismatch) { 3284 log('metadata conflict detected before Claude; continuing with transcript evaluation: ' + metadataMismatch.detail); 3285 } 3286 3287 // Resolve the principal energy product deterministically. Add-ons may be 3288 // inserted before the energy sale, so array order is not a business rule. 3289 let grilleId = null, criteres = [], grille = null; 3290 3291 // Path-based fallback heuristic — runs BEFORE the vente→offre chain so 3292 // unambiguous folder signals (e.g. "Enregistrements Free") win over a 3293 // potentially-stale vente link. Add more rules here as new centres need 3294 // them; right now only GRF's Free sub-folder is in scope. 3295 const audioPaths = (appel.audio_files || []).map(f => f.path || '').join(' | '); 3296 const activeConsoPiloteGridId = resolveConsoPiloteGridId(offres); 3297 // Folders that are unambiguously Free (no Mint/OHM sales there). 3298 // Keep this list explicit so we don't accidentally Free-grade a Mint call. 3299 if (/\/(EHR - Enregistrements de ventes ConsoPilote|Alysse - Enregistrements des ventes Conso Pilote)\b/i.test(audioPaths)) { 3300 grilleId = activeConsoPiloteGridId; 3301 log('grille resolved via path heuristic: ConsoPilote folder → ' + activeConsoPiloteGridId); 3302 } else if (/\/Galsen - Mint Énergie - Enregistrement des ventes\b/i.test(audioPaths)) { 3303 grilleId = 'mint_energie_v2'; 3304 log('grille resolved via path heuristic: Elite/Galsen Mint folder → mint_energie_v2'); 3305 } else if (/\/(Enregistrements de Reda|Enregistrements ATM|Enregistrements Protected - Février - Mars)\b/i.test(audioPaths)) { 3306 grilleId = 'lead_telecom_v3'; 3307 log('grille resolved via path heuristic: Reda/ATM/Protected Free folder → lead_telecom_v3'); 3308 } else if (/\/(Enregistrements Free|Enregistrements CaptoPhone|Enregistrements Nabadji|Enregistrements des ventes TINGIS|MGK_Demande d'enregistrements_FREE|Mon Assistant Perso_Demande d'enregistrements_FREE)\b/i.test(audioPaths)) { 3309 grilleId = 'lead_telecom_v3'; 3310 log('grille resolved via path heuristic: Free folder → lead_telecom_v3'); 3311 } 3312 3313 if (!grilleId) { 3314 const selected = selectEvaluationOffer(ventes, offres); 3315 grilleId = selected?.offre?.criteres_grille_id || null; 3316 if (selected) log('grille resolved via principal offer: vente=' + (selected.vente.local_id || selected.vente.id || '') + ' offre=' + selected.offre.local_id); 3317 } 3318 // Common hydration — fires for path-heuristic AND vente-chain grilleId 3319 if (grilleId) { 3320 const gQuery = await context.call('tables', 'tables_query', { 3321 sql: 'SELECT * FROM {grilles} WHERE local_id = ?', 3322 params: [grilleId], 3323 }); 3324 grille = gQuery.rows?.[0] || null; 3325 const cQuery = await context.call('tables', 'tables_query', { 3326 sql: 'SELECT * FROM {criteres} WHERE grille_id = ?', 3327 params: [grilleId], 3328 }); 3329 criteres = cQuery.rows || []; 3330 } 3331 log('grille=' + grilleId + ' criteres=' + criteres.length); 3332 const timingFacts = timingFactsForGrid(anchorTimingFacts, sessionAppels, grilleId, ventes, offres); 3333 3334 // 7. Evaluate via Claude Haiku 4.5 using the inlined `evaluer-appel` skill. 3335 // No grille resolvable → do NOT create an empty rapport (those polluted 3336 // the rapports table with score=null rows; client-visible bug 2026-05-21). 3337 // Mark the appel 'no_grille' so it's queryable + can be reprocessed once 3338 // a grille/offre link exists, but it stays out of the rapports list. 3339 if (!grilleId || !criteres.length) { 3340 log('no grille resolvable — marking no_grille, no rapport created'); 3341 await context.call('tables', 'rows_update', { 3342 table: 'appels', id: appelId, 3343 fields: { 3344 status: 'no_grille', 3345 status_at: new Date().toISOString(), 3346 status_error: 'no grille resolved (offre link missing or offre has no criteres_grille_id)', 3347 ai_status: 'no_grille', 3348 ai_status_at: new Date().toISOString(), 3349 ai_status_error: 'no grille resolved (offre link missing or offre has no criteres_grille_id)', 3350 ai_owner: null, 3351 status_owner: null, 3352 }, 3353 }); 3354 return { 3355 ok: true, skipped: 'no_grille', appel_id: appelId, appel_local_id: appel.local_id, 3356 centre_appel_id: appel.centre_appel_id, audios_processed: audios.length, 3357 transcript_chars: transcript_text.length, total_ms: Date.now() - t0, 3358 }; 3359 } 3360 3361 // TKT-59 : chaque grille distincte parmi les ventes de l'appel reçoit sa 3362 // propre évaluation. La grille principale garde le comportement historique ; 3363 // les groupes additionnels sont évalués après le rapport principal. 3364 const partitioned = partitionVentesByGrille(ventes, offres, grilleId); 3365 const primaryVentes = partitioned.primaryVentes; 3366 let addonGroups = partitioned.addonGroups; 3367 let skipPrimaryEvaluation = false; 3368 let primaryScore = null; 3369 if (forcedGridIds.length) { 3370 const forcedPlan = planForcedGridEvaluation(grilleId, addonGroups, forcedGridIds); 3371 skipPrimaryEvaluation = forcedPlan.skipPrimaryEvaluation; 3372 addonGroups = forcedPlan.addonGroups; 3373 log('forced-grid plan: requested=' + forcedPlan.requestedGridIds.join(',') 3374 + ', matched=' + forcedPlan.matchedGridIds.join(',') 3375 + ', skip_primary=' + skipPrimaryEvaluation); 3376 if (!forcedPlan.matchedGridIds.length) { 3377 return { 3378 ok: false, 3379 skipped: 'forced_grid_not_linked', 3380 appel_id: appelId, 3381 appel_local_id: appel.local_id, 3382 requested_grids: forcedPlan.requestedGridIds, 3383 total_ms: Date.now() - t0, 3384 }; 3385 } 3386 } else if (opts.addon_only) { 3387 const addonPlan = await planAddonOnlyEvaluation(context, { 3388 primaryGrilleId: grilleId, 3389 primaryVentes, 3390 addonGroups, 3391 offres, 3392 appelIds: sessionAppelIds, 3393 }); 3394 skipPrimaryEvaluation = !addonPlan.primaryNeedsEvaluation; 3395 addonGroups = addonPlan.addonGroups; 3396 primaryScore = addonPlan.primaryScore; 3397 log('add-on-only plan: skip_primary=' + skipPrimaryEvaluation 3398 + ', missing=' + addonPlan.missingSaleGridPairs.map((item) => item.vente_id + '@' + item.grille_id).join(',')); 3399 }