diff --git a/.perm_test_apache b/.perm_test_apache new file mode 100644 index 0000000..e69de29 diff --git a/.perm_test_exec b/.perm_test_exec new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/routes/score_runs.js b/backend/src/routes/score_runs.js index d118c4a..b18785b 100644 --- a/backend/src/routes/score_runs.js +++ b/backend/src/routes/score_runs.js @@ -19,6 +19,11 @@ const { router.use(checkCrudPermissions('score_runs')); +router.post('/run-audit', wrapAsync(async (req, res) => { + const payload = await Score_runsService.runAudit(req.body.data || {}, req.currentUser); + res.status(200).send(payload); +})); + /** * @swagger diff --git a/backend/src/services/score_runs.js b/backend/src/services/score_runs.js index c70e8a2..c49e1fd 100644 --- a/backend/src/services/score_runs.js +++ b/backend/src/services/score_runs.js @@ -1,34 +1,406 @@ const db = require('../db/models'); const Score_runsDBApi = require('../db/api/score_runs'); -const processFile = require("../middlewares/upload"); +const Call_recordingsDBApi = require('../db/api/call_recordings'); +const TranscriptsDBApi = require('../db/api/transcripts'); +const Phrase_matchesDBApi = require('../db/api/phrase_matches'); +const processFile = require('../middlewares/upload'); const ValidationError = require('./notifications/errors/validation'); const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); const stream = require('stream'); +const { Op } = db.Sequelize; +const normalizeText = (value = '') => + value + .toLowerCase() + .replace(/\s+/g, ' ') + .trim(); +const escapeRegExp = (value = '') => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const clamp = (value, min, max) => Math.min(Math.max(value, min), max); + +const estimateDurationSeconds = (text = '') => { + const wordCount = text.trim().split(/\s+/).filter(Boolean).length; + return Math.max(Math.round(wordCount / 2.4), 15); +}; + +const buildSnippet = (text = '', startIndex = 0, matchLength = 0) => { + if (!text) { + return ''; + } + + const snippetStart = Math.max(0, startIndex - 50); + const snippetEnd = Math.min(text.length, startIndex + matchLength + 70); + const prefix = snippetStart > 0 ? '…' : ''; + const suffix = snippetEnd < text.length ? '…' : ''; + + return `${prefix}${text.slice(snippetStart, snippetEnd).trim()}${suffix}`; +}; + +const collectMatches = (sourceText = '', phraseText = '', matchMode = 'contains') => { + if (!sourceText || !phraseText) { + return []; + } + + let regex; + + try { + switch (matchMode) { + case 'exact': + regex = new RegExp(`\\b${escapeRegExp(phraseText)}\\b`, 'gi'); + break; + case 'regex': + regex = new RegExp(phraseText, 'gi'); + break; + case 'fuzzy': + case 'contains': + default: + regex = new RegExp(escapeRegExp(phraseText), 'gi'); + break; + } + } catch (error) { + return []; + } + + const matches = []; + let result = regex.exec(sourceText); + + while (result) { + matches.push({ + index: result.index, + text: result[0], + }); + + if (result[0].length === 0) { + regex.lastIndex += 1; + } + + result = regex.exec(sourceText); + } + + return matches; +}; module.exports = class Score_runsService { static async create(data, currentUser) { const transaction = await db.sequelize.transaction(); try { - await Score_runsDBApi.create( - data, - { - currentUser, - transaction, - }, - ); + await Score_runsDBApi.create(data, { + currentUser, + transaction, + }); await transaction.commit(); } catch (error) { await transaction.rollback(); throw error; } - }; + } + + static async runAudit(data, currentUser) { + const transaction = await db.sequelize.transaction(); + const startedAt = new Date(); + + try { + const transcriptText = typeof data.transcript_text === 'string' ? data.transcript_text.trim() : ''; + const externalCallRef = typeof data.external_call_ref === 'string' ? data.external_call_ref.trim() : ''; + + if (!externalCallRef) { + throw new ValidationError('externalCallRefRequired', 'Call reference is required.'); + } + + if (!transcriptText) { + throw new ValidationError('transcriptRequired', 'Transcript text is required to score a call.'); + } + + if (transcriptText.length < 20) { + throw new ValidationError( + 'transcriptTooShort', + 'Transcript text is too short to score reliably. Please paste a longer call transcript.', + ); + } + + const organizationsId = + currentUser.organizationsId || currentUser.organizationId || currentUser.organization?.id || null; + const agencyId = data.agency || data.agencyId || null; + const agentId = data.agent || data.agentId || null; + const scoringProfileId = data.scoring_profile || data.scoringProfileId || null; + const currentUserHasGlobalAccess = Boolean(currentUser.app_role?.globalAccess); + + let scoringProfile = null; + let scopedPhraseLists = []; + + if (scoringProfileId) { + const scoringProfileWhere = { id: scoringProfileId }; + if (!currentUserHasGlobalAccess && organizationsId) { + scoringProfileWhere.organizationsId = organizationsId; + } + + scoringProfile = await db.scoring_profiles.findOne({ + where: scoringProfileWhere, + transaction, + }); + + if (!scoringProfile) { + throw new ValidationError('scoringProfileNotFound', 'Selected scoring profile was not found.'); + } + + scopedPhraseLists = await scoringProfile.getIncluded_phrase_lists({ transaction }); + } + + const callRecording = await Call_recordingsDBApi.create( + { + external_call_ref: externalCallRef, + direction: data.direction || 'inbound', + from_number: data.from_number || null, + to_number: data.to_number || null, + started_at: startedAt, + ended_at: new Date(startedAt.getTime() + estimateDurationSeconds(transcriptText) * 1000), + duration_seconds: estimateDurationSeconds(transcriptText), + recording_status: 'ready', + customer_reference: data.customer_reference || null, + campaign: data.campaign || null, + notes: data.notes || null, + agency: agencyId, + agent: agentId, + organizations: organizationsId, + }, + { + currentUser, + transaction, + }, + ); + + const transcript = await TranscriptsDBApi.create( + { + call_recording: callRecording.id, + provider: 'other', + status: 'completed', + completed_at: new Date(), + language: data.language || 'en', + confidence: 1, + full_text: transcriptText, + organizations: organizationsId, + }, + { + currentUser, + transaction, + }, + ); + + const phraseListWhere = { is_active: true }; + + if (agencyId) { + phraseListWhere.agencyId = agencyId; + } + + if (!currentUserHasGlobalAccess && organizationsId) { + phraseListWhere.organizationsId = organizationsId; + } + + if (scopedPhraseLists.length > 0) { + phraseListWhere.id = { [Op.in]: scopedPhraseLists.map((item) => item.id) }; + } + + const activePhraseLists = await db.phrase_lists.findAll({ + where: phraseListWhere, + transaction, + }); + + if (!activePhraseLists.length) { + throw new ValidationError( + 'activePhraseListsMissing', + 'No active phrase lists were found for this audit. Activate a phrase list or adjust the selected scoring profile.', + ); + } + + const phraseWhere = { + is_active: true, + phrase_listId: { + [Op.in]: activePhraseLists.map((item) => item.id), + }, + }; + + if (!currentUserHasGlobalAccess && organizationsId) { + phraseWhere.organizationsId = organizationsId; + } + + const activePhrases = await db.phrases.findAll({ + where: phraseWhere, + include: [ + { + model: db.phrase_lists, + as: 'phrase_list', + }, + ], + transaction, + }); + + if (!activePhrases.length) { + throw new ValidationError( + 'activePhrasesMissing', + 'No active phrases were found. Add phrases to your active lists before running a compliance audit.', + ); + } + + let totalGoodWeight = 0; + let totalBadWeight = 0; + let hitGoodWeight = 0; + let hitBadWeight = 0; + let goodPhraseHits = 0; + let badPhraseHits = 0; + let goodDistinctHits = 0; + let badDistinctHits = 0; + let missedGoodPhrases = 0; + + const matchesToPersist = []; + + for (const phrase of activePhrases) { + const listType = phrase.phrase_list?.list_type || 'good'; + const matchMode = phrase.phrase_list?.match_mode || 'contains'; + const phraseText = phrase.phrase_text || ''; + const phraseWeight = Number(phrase.weight || phrase.phrase_list?.default_weight || 1); + const foundMatches = collectMatches(transcriptText, phraseText, matchMode); + + if (listType === 'good') { + totalGoodWeight += phraseWeight; + } else { + totalBadWeight += phraseWeight; + } + + if (!foundMatches.length) { + if (listType === 'good') { + missedGoodPhrases += 1; + } + continue; + } + + const firstMatch = foundMatches[0]; + const occurrenceCount = foundMatches.length; + const weightedHitValue = phraseWeight * occurrenceCount; + + if (listType === 'good') { + hitGoodWeight += weightedHitValue; + goodPhraseHits += occurrenceCount; + goodDistinctHits += 1; + } else { + hitBadWeight += weightedHitValue; + badPhraseHits += occurrenceCount; + badDistinctHits += 1; + } + + matchesToPersist.push({ + phraseId: phrase.id, + phrase_text: phraseText, + list_type: listType, + severity: phrase.severity, + weight: phraseWeight, + occurrence_count: occurrenceCount, + first_start_ms: firstMatch.index, + first_end_ms: firstMatch.index + firstMatch.text.length, + evidence_snippet: buildSnippet(transcriptText, firstMatch.index, firstMatch.text.length), + }); + } + + const goodCoverage = totalGoodWeight > 0 ? (hitGoodWeight / totalGoodWeight) * 100 : 100; + const badPenalty = totalBadWeight > 0 ? Math.min((hitBadWeight / totalBadWeight) * 100, 100) : 0; + const scoreValue = Number(clamp(goodCoverage - badPenalty, 0, 100).toFixed(1)); + const passThreshold = Number(scoringProfile?.pass_threshold || 80); + + let result = 'pass'; + if (scoreValue < passThreshold) { + result = 'fail'; + } else if (badDistinctHits > 0 || missedGoodPhrases > 0) { + result = 'review'; + } + + const riskPhrases = matchesToPersist + .filter((item) => item.list_type === 'bad') + .slice(0, 3) + .map((item) => `“${item.phrase_text}”`); + + const summaryParts = [ + `Scored ${scoreValue}/100 against ${activePhrases.length} active phrases.`, + `Matched ${goodDistinctHits} good phrases and ${badDistinctHits} bad phrases.`, + ]; + + if (missedGoodPhrases > 0) { + summaryParts.push(`Missed ${missedGoodPhrases} expected good phrases.`); + } + + if (riskPhrases.length > 0) { + summaryParts.push(`Key risks: ${riskPhrases.join(', ')}.`); + } + + const finishedAt = new Date(); + + const scoreRun = await Score_runsDBApi.create( + { + agency: agencyId, + call_recording: callRecording.id, + scoring_profile: scoringProfile?.id || null, + transcript: transcript.id, + organizations: organizationsId, + status: 'completed', + started_at: startedAt, + finished_at: finishedAt, + score_value: scoreValue, + result, + good_phrase_hits: goodPhraseHits, + bad_phrase_hits: badPhraseHits, + summary: summaryParts.join(' '), + }, + { + currentUser, + transaction, + }, + ); + + for (const match of matchesToPersist) { + await Phrase_matchesDBApi.create( + { + score_run: scoreRun.id, + phrase: match.phraseId, + organizations: organizationsId, + match_outcome: 'hit', + match_confidence: 1, + occurrence_count: match.occurrence_count, + first_start_ms: match.first_start_ms, + first_end_ms: match.first_end_ms, + evidence_snippet: match.evidence_snippet, + }, + { + currentUser, + transaction, + }, + ); + } + + await transaction.commit(); + + const createdScoreRun = await Score_runsDBApi.findBy({ id: scoreRun.id }); + + return { + scoreRun: createdScoreRun, + matches: matchesToPersist, + stats: { + pass_threshold: passThreshold, + active_phrase_count: activePhrases.length, + active_phrase_list_count: activePhraseLists.length, + good_phrase_coverage: Number(goodCoverage.toFixed(1)), + bad_phrase_penalty: Number(badPenalty.toFixed(1)), + }, + created: { + call_recording_id: callRecording.id, + transcript_id: transcript.id, + }, + }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } static async bulkImport(req, res, sendInvitationEmails = true, host) { const transaction = await db.sequelize.transaction(); @@ -38,24 +410,24 @@ module.exports = class Score_runsService { const bufferStream = new stream.PassThrough(); const results = []; - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); await new Promise((resolve, reject) => { bufferStream .pipe(csv()) - .on('data', (data) => results.push(data)) + .on('data', (row) => results.push(row)) .on('end', async () => { console.log('CSV results', results); resolve(); }) .on('error', (error) => reject(error)); - }) + }); await Score_runsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, }); await transaction.commit(); @@ -68,34 +440,24 @@ module.exports = class Score_runsService { static async update(data, id, currentUser) { const transaction = await db.sequelize.transaction(); try { - let score_runs = await Score_runsDBApi.findBy( - {id}, - {transaction}, - ); + const score_runs = await Score_runsDBApi.findBy({ id }, { transaction }); if (!score_runs) { - throw new ValidationError( - 'score_runsNotFound', - ); + throw new ValidationError('score_runsNotFound'); } - const updatedScore_runs = await Score_runsDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); + const updatedScore_runs = await Score_runsDBApi.update(id, data, { + currentUser, + transaction, + }); await transaction.commit(); return updatedScore_runs; - } catch (error) { await transaction.rollback(); throw error; } - }; + } static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); @@ -117,13 +479,10 @@ module.exports = class Score_runsService { const transaction = await db.sequelize.transaction(); try { - await Score_runsDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); + await Score_runsDBApi.remove(id, { + currentUser, + transaction, + }); await transaction.commit(); } catch (error) { @@ -131,8 +490,4 @@ module.exports = class Score_runsService { throw error; } } - - }; - -