Preuve · code, extrait

backend/process-appel-db-only.mjs

Dépôt jeremy-flexylead/flexylead-crm · commit ccbbaa07870e0f786729003dd6f8862e4bcc8256 · dossier de production synchronisé le 14 septembre 2026 à 08 h 46

Extrait tel qu'à ce commit : lignes 921–987, 1191–1296, 3276–3399, sur 3 973 (numérotation d'origine, 30 lignes de contexte autour de chaque citation) · fichier intégral sur GitHub (dépôt privé, accès requis)

Lignes citées par l'audit : 951–957 1221–1231 1224 1243–1247 1254–1266 3306 3309 3341–3369

297 lignes reproduites · ← retour à l'audit · catalogue des preuves

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  }