Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
cab3f0b482 First 2026-03-27 19:42:00 +00:00
4 changed files with 406 additions and 46 deletions

0
.perm_test_apache Normal file
View File

0
.perm_test_exec Normal file
View File

View File

@ -19,6 +19,11 @@ const {
router.use(checkCrudPermissions('score_runs')); 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 * @swagger

View File

@ -1,34 +1,406 @@
const db = require('../db/models'); const db = require('../db/models');
const Score_runsDBApi = require('../db/api/score_runs'); 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 ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser'); const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream'); 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 { module.exports = class Score_runsService {
static async create(data, currentUser) { static async create(data, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await Score_runsDBApi.create( await Score_runsDBApi.create(data, {
data, currentUser,
{ transaction,
currentUser, });
transaction,
},
);
await transaction.commit(); await transaction.commit();
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; 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) { static async bulkImport(req, res, sendInvitationEmails = true, host) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
@ -38,24 +410,24 @@ module.exports = class Score_runsService {
const bufferStream = new stream.PassThrough(); const bufferStream = new stream.PassThrough();
const results = []; 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) => { await new Promise((resolve, reject) => {
bufferStream bufferStream
.pipe(csv()) .pipe(csv())
.on('data', (data) => results.push(data)) .on('data', (row) => results.push(row))
.on('end', async () => { .on('end', async () => {
console.log('CSV results', results); console.log('CSV results', results);
resolve(); resolve();
}) })
.on('error', (error) => reject(error)); .on('error', (error) => reject(error));
}) });
await Score_runsDBApi.bulkImport(results, { await Score_runsDBApi.bulkImport(results, {
transaction, transaction,
ignoreDuplicates: true, ignoreDuplicates: true,
validate: true, validate: true,
currentUser: req.currentUser currentUser: req.currentUser,
}); });
await transaction.commit(); await transaction.commit();
@ -68,34 +440,24 @@ module.exports = class Score_runsService {
static async update(data, id, currentUser) { static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
let score_runs = await Score_runsDBApi.findBy( const score_runs = await Score_runsDBApi.findBy({ id }, { transaction });
{id},
{transaction},
);
if (!score_runs) { if (!score_runs) {
throw new ValidationError( throw new ValidationError('score_runsNotFound');
'score_runsNotFound',
);
} }
const updatedScore_runs = await Score_runsDBApi.update( const updatedScore_runs = await Score_runsDBApi.update(id, data, {
id, currentUser,
data, transaction,
{ });
currentUser,
transaction,
},
);
await transaction.commit(); await transaction.commit();
return updatedScore_runs; return updatedScore_runs;
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
}; }
static async deleteByIds(ids, currentUser) { static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
@ -117,13 +479,10 @@ module.exports = class Score_runsService {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await Score_runsDBApi.remove( await Score_runsDBApi.remove(id, {
id, currentUser,
{ transaction,
currentUser, });
transaction,
},
);
await transaction.commit(); await transaction.commit();
} catch (error) { } catch (error) {
@ -131,8 +490,4 @@ module.exports = class Score_runsService {
throw error; throw error;
} }
} }
}; };