40329-vm/backend/src/routes/interviewWorkflow.js
2026-06-25 16:46:27 +00:00

266 lines
9.6 KiB
JavaScript

const express = require('express');
const db = require('../db/models');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
const INTERVIEW_QUESTIONS = [
'Do you have any professional development training that you recommend that would be of interest or value to your colleagues?',
'Describe training that is most effective for your learning and why.',
'What is a reasonable amount of time that you could dedicate to training?',
'What makes learning memorable for you? How can training be designed to help stick for you?',
'How do you learn about training opportunities? Is that method effective? What could make it more effective?',
];
const SECTION_BY_QUESTION = [
'Participant priorities and interests',
'Preferred learning methods',
'Time available for training',
'What makes training memorable and effective',
'How the participant hears about training opportunities',
];
const FINDING_TYPES = [
'priority_interest',
'preferred_method',
'time_constraint',
'implication',
'channel_preference',
];
const normalizeAnswers = (answers) => {
if (!Array.isArray(answers)) return [];
return INTERVIEW_QUESTIONS.map((question, index) => {
const answer = answers[index] || {};
return {
question,
answer: String(answer.answer || '').trim(),
};
});
};
const sentenceFrom = (text) => {
const clean = String(text || '').replace(/\s+/g, ' ').trim();
if (!clean) return 'No specific response captured.';
return clean.length > 220 ? `${clean.slice(0, 217)}...` : clean;
};
const extractTags = (answers) => {
const text = answers.map((item) => item.answer).join(' ').toLowerCase();
const tagRules = [
['hands-on learning', ['hands-on', 'practice', 'workshop', 'exercise', 'interactive']],
['short sessions', ['short', 'bite', 'micro', 'hour', '30', 'minutes']],
['peer learning', ['peer', 'colleague', 'mentor', 'community', 'team']],
['role-specific training', ['role', 'job', 'specific', 'relevant', 'real-world']],
['communication channels', ['email', 'newsletter', 'calendar', 'intranet', 'slack', 'teams']],
['time constraints', ['busy', 'time', 'schedule', 'workload', 'capacity']],
['follow-up support', ['follow-up', 'refresh', 'reinforce', 'coaching', 'support']],
];
const tags = tagRules
.filter(([, terms]) => terms.some((term) => text.includes(term)))
.map(([tag]) => tag);
return tags.length ? tags : ['training needs assessment'];
};
const buildSummary = (answers, mode) => {
const lines = SECTION_BY_QUESTION.map((section, index) => `- ${section}: ${sentenceFrom(answers[index]?.answer)}`);
const gaps = answers
.map((item) => item.answer)
.join(' ')
.toLowerCase()
.match(/gap|need|hard|difficult|barrier|challenge|limited|lack/g);
lines.push(`- Notable training gaps, barriers, or unmet needs: ${gaps ? 'Potential unmet needs or barriers were mentioned and should be reviewed in context.' : 'No explicit gap or barrier language was captured in this first-pass summary.'}`);
lines.push(`- Recommended implications for future training design: Prioritize practical, easy-to-access training formats that reflect the participant's stated preferences and constraints.`);
lines.push(`- Interview mode: ${mode === 'voice' ? 'Voice or dictated responses' : mode === 'typed' ? 'Typed responses' : 'Mixed mode'}.`);
return lines.join('\n');
};
const organizationWhere = (currentUser) => {
if (currentUser.app_role?.globalAccess) return {};
const organizationId = currentUser.organization?.id || currentUser.organizationId;
return organizationId ? { organizationId } : {};
};
router.get('/records', wrapAsync(async (req, res) => {
const limit = Math.min(Number(req.query.limit) || 20, 50);
const rows = await db.interviews.findAll({
where: organizationWhere(req.currentUser),
include: [
{ model: db.participants, as: 'participant' },
{ model: db.users, as: 'facilitator' },
],
order: [['createdAt', 'DESC']],
limit,
});
res.status(200).send({ rows });
}));
router.get('/analysis', wrapAsync(async (req, res) => {
const interviewWhere = organizationWhere(req.currentUser);
const interviews = await db.interviews.findAll({
where: interviewWhere,
include: [
{ model: db.participants, as: 'participant' },
{ model: db.interview_findings, as: 'interview_findings_interview' },
],
order: [['createdAt', 'DESC']],
limit: 100,
});
const findingCounts = {};
const tagCounts = {};
interviews.forEach((interview) => {
(interview.interview_findings_interview || []).forEach((finding) => {
const type = finding.finding_type || 'uncategorized';
findingCounts[type] = (findingCounts[type] || 0) + 1;
});
try {
const parsed = JSON.parse(interview.inference_notes || '{}');
(parsed.tags || []).forEach((tag) => {
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
});
} catch (error) {
console.error('Failed to parse interview inference notes', error);
}
});
const themes = Object.entries(tagCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([theme, count]) => ({ theme, count }));
res.status(200).send({
interviewCount: interviews.length,
findingCounts,
themes,
latestSummaries: interviews.slice(0, 5).map((interview) => ({
id: interview.id,
participant: interview.participant?.display_name || 'Unnamed participant',
final_summary: interview.final_summary,
createdAt: interview.createdAt,
})),
});
}));
router.get('/records/:id', wrapAsync(async (req, res) => {
const interview = await db.interviews.findOne({
where: { id: req.params.id, ...organizationWhere(req.currentUser) },
include: [
{ model: db.participants, as: 'participant' },
{ model: db.users, as: 'facilitator' },
{ model: db.interview_responses, as: 'interview_responses_interview' },
{ model: db.interview_findings, as: 'interview_findings_interview' },
],
});
if (!interview) {
return res.status(404).send('Interview record not found');
}
return res.status(200).send(interview);
}));
router.post('/records', wrapAsync(async (req, res) => {
const mode = req.body.mode;
const participant = req.body.participant || {};
const answers = normalizeAnswers(req.body.answers);
if (!['voice', 'typed', 'mixed'].includes(mode)) {
return res.status(400).send('Choose voice, typed, or mixed interview mode.');
}
const missingAnswer = answers.find((item) => !item.answer);
if (missingAnswer) {
return res.status(400).send('Please capture a response for every core interview question before saving.');
}
const organizationId = req.currentUser.organization?.id || req.currentUser.organizationId || null;
const transaction = await db.sequelize.transaction();
try {
const createdParticipant = await db.participants.create({
display_name: String(participant.display_name || '').trim() || `Participant ${new Date().toISOString().slice(0, 10)}`,
department: String(participant.department || '').trim() || null,
job_title: String(participant.job_title || '').trim() || null,
notes: String(participant.notes || '').trim() || null,
consent_to_store: true,
organizationId,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
}, { transaction });
const tags = extractTags(answers);
const finalSummary = buildSummary(answers, mode);
const inferenceNotes = JSON.stringify({
objective: 'Understand current practices, training gaps, and priority skill areas across workforce interviews.',
tags,
generated_by: 'rule_based_mvp_summary',
});
const interview = await db.interviews.create({
started_at: new Date(),
completed_at: new Date(),
mode,
status: 'completed',
context_notes: String(req.body.context_notes || '').trim() || 'Fixed five-question training needs assessment interview.',
final_summary: finalSummary,
inference_notes: inferenceNotes,
organizationId,
participantId: createdParticipant.id,
facilitatorId: req.currentUser.id,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
}, { transaction });
await Promise.all(answers.map((item, index) => db.interview_responses.create({
turn_number: index + 1,
response_format: mode === 'voice' ? 'spoken_style' : 'typed',
response_text: `Question: ${item.question}\n\nResponse: ${item.answer}`,
is_clarification: false,
interviewId: interview.id,
organizationsId: organizationId,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
}, { transaction })));
await Promise.all(answers.map((item, index) => db.interview_findings.create({
finding_type: FINDING_TYPES[index] || 'implication',
sentiment: 'neutral',
confidence: 0.7,
evidence_excerpt: sentenceFrom(item.answer),
notes: `${SECTION_BY_QUESTION[index]}: ${sentenceFrom(item.answer)}`,
interviewId: interview.id,
organizationsId: organizationId,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
}, { transaction })));
await transaction.commit();
const savedInterview = await db.interviews.findByPk(interview.id, {
include: [
{ model: db.participants, as: 'participant' },
{ model: db.interview_responses, as: 'interview_responses_interview' },
{ model: db.interview_findings, as: 'interview_findings_interview' },
],
});
return res.status(201).send(savedInterview);
} catch (error) {
await transaction.rollback();
throw error;
}
}));
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;