'Afraid to Ask' implemented
This commit is contained in:
@@ -1 +1,2 @@
|
||||
GEMINI_API_KEY="AIzaSyDke9H2NhiG6rBwxT0qrdYgnNoNZm_0j58"
|
||||
GEMINI_API_KEY=YOUR_GEMINI_API_KEY
|
||||
ENCRYPTION_KEY=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
|
||||
16
backend/.eslintrc.js
Normal file
16
backend/.eslintrc.js
Normal file
@@ -0,0 +1,16 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
rules: {
|
||||
// Add any specific rules here
|
||||
},
|
||||
};
|
||||
1348
backend/package-lock.json
generated
1348
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,17 @@
|
||||
"supertest": "^7.1.4",
|
||||
"ts-jest": "^27.1.0",
|
||||
"ts-node": "^10.4.0",
|
||||
"typescript": "^4.5.2"
|
||||
"typescript": "^4.5.2",
|
||||
"eslint": "^8.56.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "ts-node src/index.ts",
|
||||
"dev": "nodemon src/index.ts",
|
||||
"build": "tsc",
|
||||
"test": "jest",
|
||||
"lint": "eslint src/**/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/generative-ai": "^0.1.0",
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
import express from 'express';
|
||||
import http from 'http';
|
||||
import { createWebSocketServer } from './ws';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import express from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { sessions, SessionState } from '../ws'; // Import sessions and SessionState from ws/index.ts
|
||||
import { sessions, SessionState, broadcastToSession, handleWebSocketMessage } from '../ws'; // Import sessions, SessionState, broadcastToSession, and handleWebSocketMessage from ws/index.ts
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -19,4 +19,68 @@ router.post('/sessions', (req, res) => {
|
||||
res.status(201).json({ sessionId });
|
||||
});
|
||||
|
||||
router.post('/sessions/:sessionId/responses', async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
const { userId, wants, accepts, afraidToAsk } = req.body;
|
||||
|
||||
if (!sessions.has(sessionId)) {
|
||||
return res.status(404).json({ message: 'Session not found.' });
|
||||
}
|
||||
|
||||
// Create a dummy WebSocket object for the handleWebSocketMessage function.
|
||||
// This is a workaround to reuse the WebSocket message handling logic.
|
||||
// In a real application, consider a more robust event-driven architecture.
|
||||
const dummyWs: any = {
|
||||
send: (message: string) => console.log('Dummy WS send:', message),
|
||||
readyState: 1, // OPEN
|
||||
};
|
||||
|
||||
const message = {
|
||||
type: 'SUBMIT_RESPONSE',
|
||||
clientId: userId, // Using userId as clientId for simplicity in this context
|
||||
payload: {
|
||||
response: { wants, accepts, afraidToAsk },
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await handleWebSocketMessage(dummyWs, sessionId, message);
|
||||
res.status(202).json({ message: 'Response submission acknowledged and processed.' });
|
||||
} catch (error: any) {
|
||||
console.error('Error processing response via HTTP route:', error);
|
||||
res.status(500).json({ message: 'Error processing response.', error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sessions/:sessionId/results', (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
if (!sessions.has(sessionId)) {
|
||||
return res.status(404).json({ message: 'Session not found.' });
|
||||
}
|
||||
|
||||
const sessionData = sessions.get(sessionId)!;
|
||||
|
||||
if (sessionData.state !== SessionState.FINAL || !sessionData.finalResult) {
|
||||
return res.status(200).json({ message: 'Session results not yet finalized.', harmonizedIdeas: [] });
|
||||
}
|
||||
|
||||
// Assuming finalResult directly contains the harmonized ideas as per openapi.yaml
|
||||
res.status(200).json({ sessionId, harmonizedIdeas: sessionData.finalResult });
|
||||
});
|
||||
|
||||
router.post('/sessions/:sessionId/terminate', (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
if (!sessions.has(sessionId)) {
|
||||
return res.status(404).json({ message: 'Session not found.' });
|
||||
}
|
||||
|
||||
sessions.delete(sessionId);
|
||||
// Log the purging event
|
||||
// logEvent('session_terminated_and_purged', sessionId);
|
||||
console.log(`Session ${sessionId} terminated and data purged.`);
|
||||
res.status(200).json({ message: 'Session terminated and data purged successfully.' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
38
backend/src/services/EncryptionService.ts
Normal file
38
backend/src/services/EncryptionService.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const algorithm = 'aes-256-cbc';
|
||||
const ivLength = 16; // For AES, this is always 16
|
||||
|
||||
// Key should be a 32-byte (256-bit) key
|
||||
// In a real application, this would be loaded securely from environment variables
|
||||
// or a key management service.
|
||||
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex');
|
||||
|
||||
export class EncryptionService {
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor(encryptionKey: string) {
|
||||
if (!encryptionKey || encryptionKey.length !== 64) { // 32 bytes in hex is 64 chars
|
||||
throw new Error('Encryption key must be a 64-character hex string (32 bytes).');
|
||||
}
|
||||
this.key = Buffer.from(encryptionKey, 'hex');
|
||||
}
|
||||
|
||||
encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(ivLength);
|
||||
const cipher = crypto.createCipheriv(algorithm, this.key, iv);
|
||||
let encrypted = cipher.update(text);
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
return iv.toString('hex') + ':' + encrypted.toString('hex');
|
||||
}
|
||||
|
||||
decrypt(text: string): string {
|
||||
const textParts = text.split(':');
|
||||
const iv = Buffer.from(textParts.shift()!, 'hex');
|
||||
const encryptedText = Buffer.from(textParts.join(':'), 'hex');
|
||||
const decipher = crypto.createDecipheriv(algorithm, this.key, iv);
|
||||
let decrypted = decipher.update(encryptedText);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
return decrypted.toString();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ interface DesireSet {
|
||||
wants: string[];
|
||||
accepts: string[];
|
||||
noGoes: string[];
|
||||
afraidToAsk: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,18 +29,22 @@ export class LLMService {
|
||||
|
||||
async analyzeDesires(desireSets: DesireSet[]): Promise<Decision> {
|
||||
const prompt = `
|
||||
You are an AI assistant that analyzes and synthesizes cooperative decisions from a group's desires. Given a list of desire sets from multiple participants, your task is to generate a concise, synthesized text for each of the following categories, reflecting the collective opinion:
|
||||
You are an AI assistant that analyzes and synthesizes cooperative decisions from a group's desires. Given a list of desire sets from multiple participants, your task is to generate a concise, synthesized text for each of the following categories, reflecting the collective opinion.
|
||||
|
||||
Here are the rules for categorization and synthesis:
|
||||
- "goTo": Synthesize a text describing what ALL participants want without contradictions. This should be a clear, affirmative statement of shared desire.
|
||||
- "alsoGood": Synthesize a text describing what at least one participant wants, and all other participants accept, and is not a "noGoes" for anyone. This should reflect a generally agreeable outcome.
|
||||
- "considerable": Synthesize a text describing what is wanted or accepted by some, but not all, participants, and is not a "noGoes" for anyone. This should highlight areas of partial agreement or options that could be explored.
|
||||
Each participant's desire set includes 'wants', 'accepts', 'noGoes', and an 'afraidToAsk' field. The 'afraidToAsk' field contains a sensitive idea that the participant is hesitant to express publicly.
|
||||
|
||||
Here are the rules for categorization and synthesis, with special handling for 'afraidToAsk' ideas:
|
||||
- "goTo": Synthesize a text describing what ALL participants want without contradictions. This should include 'afraidToAsk' ideas that semantically match all other participant's 'wants' or 'afraidToAsk'. If an 'afraidToAsk' idea matches, it should be treated as a 'want' for the submitting participant.
|
||||
- "alsoGood": Synthesize a text describing what at least one participant wants (including matched 'afraidToAsk' ideas), not everyone wants but all other participants at least accept, and is not a "noGoes" for anyone. This should reflect a generally agreeable outcome.
|
||||
- "considerable": Synthesize a text describing what is wanted or accepted by some, but not all, participants (including matched 'afraidToAsk' ideas), and is not a "noGoes" for anyone. This should highlight areas of partial agreement or options that could be explored.
|
||||
- "noGoes": Synthesize a text describing what at least ONE participant does not want. This should clearly state the collective exclusions.
|
||||
- "needsDiscussion": Synthesize a text describing where there is a direct conflict (e.g., one participant wants it, another does not want it). This should highlight areas requiring further negotiation.
|
||||
- "needsDiscussion": Synthesize a text describing where there is a direct conflict (e.g., one participant wants it, another does not want it). This should highlight areas requiring further negotiation. Do not include 'afraidToAsk' in this category.
|
||||
|
||||
Prioritize the more specific opinions and leave all the specific options if they do not contradict each other drastically.
|
||||
'AfraidToAsk' ideas that do NOT semantically match any other participant's 'wants' or 'accepts' should remain private and NOT be included in any of the synthesized categories.
|
||||
|
||||
The input will be a JSON object containing a list of desire sets. Each desire set has a participantId and three arrays of strings: "wants", "accepts", and "noGoes".
|
||||
Prioritize more specific desires over more broad ones for positive categories ("goTo", "alsoGood", "considerable"). For negative categories ("noGoes", "needsDiscussion"), prioritize more broad ideas over more specific ones. Formulate common ideas from the point of 'us', e.g. "We are going to...", or "We want to...", or "We think..."
|
||||
|
||||
The input will be a JSON object containing a list of desire sets. Each desire set has a participantId (implicitly handled by the array index) and four arrays/strings: "wants", "accepts", "noGoes", and "afraidToAsk".
|
||||
|
||||
The output should be a JSON object with the following structure, where each category contains a single synthesized text:
|
||||
{
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { LLMService } from '../services/LLMService';
|
||||
import { EncryptionService } from '../services/EncryptionService';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// Initialize Encryption Service
|
||||
const encryptionService = new EncryptionService(process.env.ENCRYPTION_KEY || '');
|
||||
|
||||
// Types from the frontend
|
||||
interface Decision {
|
||||
goTo: string;
|
||||
@@ -20,13 +24,20 @@ export enum SessionState {
|
||||
ERROR = 'ERROR',
|
||||
}
|
||||
|
||||
interface EncryptedResponseData {
|
||||
wants: string[];
|
||||
accepts: string[];
|
||||
noGoes: string[];
|
||||
afraidToAsk: string; // Encrypted afraidToAsk idea
|
||||
}
|
||||
|
||||
// A map to hold session data, including clients and the latest state
|
||||
interface SessionData {
|
||||
state: SessionState; // Current phase of the session
|
||||
topic: string | null; // The topic of the session
|
||||
expectedResponses: number; // The number set by the first user in State A.
|
||||
submittedCount: number; // The current count of submitted responses.
|
||||
responses: Map<string, any>; // Stores the submitted desire objects. Map<ClientID, ResponseData>
|
||||
responses: Map<string, EncryptedResponseData>; // Stores the submitted desire objects. Map<ClientID, EncryptedResponseData>
|
||||
clients: Map<string, WebSocket>; // Maps the persistent Client ID to their active WebSocket connection object.
|
||||
finalResult: any | null; // The result returned by the LLM.
|
||||
}
|
||||
@@ -35,11 +46,39 @@ export const sessions = new Map<string, SessionData>();
|
||||
// Initialize LLM Service (API key from environment)
|
||||
const llmService = new LLMService(process.env.GEMINI_API_KEY || '');
|
||||
|
||||
// Structured logging function
|
||||
const logEvent = (eventName: string, sessionId: string, details: object = {}) => {
|
||||
console.log(JSON.stringify({ timestamp: new Date().toISOString(), eventName, sessionId, ...details }));
|
||||
};
|
||||
|
||||
// Metrics recording function
|
||||
const recordMetric = (metricName: string, value: number | string, sessionId: string, details: object = {}) => {
|
||||
console.log(JSON.stringify({ timestamp: new Date().toISOString(), metricName, value, sessionId, ...details }));
|
||||
};
|
||||
|
||||
// Helper to create a serializable version of the session state
|
||||
const getSerializableSession = (sessionData: SessionData) => {
|
||||
const getSerializableSession = (sessionData: SessionData, currentClientId: string | null = null) => {
|
||||
const filteredResponses = new Map<string, any>();
|
||||
sessionData.responses.forEach((response, clientId) => {
|
||||
if (clientId === currentClientId) {
|
||||
// For the current client, decrypt and send their own full response
|
||||
const decryptedWants = response.wants.map((d: string) => encryptionService.decrypt(d));
|
||||
const decryptedAccepts = response.accepts.map((d: string) => encryptionService.decrypt(d));
|
||||
const decryptedNoGoes = response.noGoes.map((d: string) => encryptionService.decrypt(d));
|
||||
const decryptedAfraidToAsk = encryptionService.decrypt(response.afraidToAsk);
|
||||
filteredResponses.set(clientId, { wants: decryptedWants, accepts: decryptedAccepts, noGoes: decryptedNoGoes, afraidToAsk: decryptedAfraidToAsk });
|
||||
} else {
|
||||
// For other clients, only send non-AfraidToAsk parts (wants, accepts, noGoes without AfraidToAsk)
|
||||
const decryptedWants = response.wants.map((d: string) => encryptionService.decrypt(d));
|
||||
const decryptedAccepts = response.accepts.map((d: string) => encryptionService.decrypt(d));
|
||||
const decryptedNoGoes = response.noGoes.map((d: string) => encryptionService.decrypt(d));
|
||||
filteredResponses.set(clientId, { wants: decryptedWants, accepts: decryptedAccepts, noGoes: decryptedNoGoes, afraidToAsk: "" }); // Hide afraidToAsk for other clients
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...sessionData,
|
||||
responses: Object.fromEntries(sessionData.responses),
|
||||
responses: Object.fromEntries(filteredResponses),
|
||||
clients: Array.from(sessionData.clients.keys()), // Only send client IDs, not the WebSocket objects
|
||||
};
|
||||
};
|
||||
@@ -47,15 +86,15 @@ const getSerializableSession = (sessionData: SessionData) => {
|
||||
export const broadcastToSession = (sessionId: string, message: any, excludeClientId: string | null = null) => {
|
||||
const sessionData = sessions.get(sessionId);
|
||||
if (sessionData) {
|
||||
const serializableMessage = {
|
||||
...message,
|
||||
payload: {
|
||||
...message.payload,
|
||||
session: getSerializableSession(sessionData),
|
||||
},
|
||||
};
|
||||
sessionData.clients.forEach((client, clientId) => {
|
||||
if (clientId !== excludeClientId && client.readyState === WebSocket.OPEN) {
|
||||
const serializableMessage = {
|
||||
...message,
|
||||
payload: {
|
||||
...message.payload,
|
||||
session: getSerializableSession(sessionData, clientId), // Pass clientId to getSerializableSession
|
||||
},
|
||||
};
|
||||
client.send(JSON.stringify(serializableMessage));
|
||||
}
|
||||
});
|
||||
@@ -106,87 +145,7 @@ export const createWebSocketServer = (server: any) => {
|
||||
}
|
||||
|
||||
console.log(`Received message from ${clientId} in session ${sessionId}:`, type);
|
||||
|
||||
switch (type) {
|
||||
case 'REGISTER_CLIENT':
|
||||
break;
|
||||
|
||||
case 'SETUP_SESSION':
|
||||
if (sessionData.state === SessionState.SETUP) {
|
||||
const { expectedResponses, topic } = payload;
|
||||
if (typeof expectedResponses !== 'number' || expectedResponses <= 0) {
|
||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'Invalid expectedResponses' } }));
|
||||
return;
|
||||
}
|
||||
sessionData.expectedResponses = expectedResponses;
|
||||
sessionData.topic = topic || 'Untitled Session';
|
||||
sessionData.state = SessionState.GATHERING;
|
||||
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||
console.log(`Session ${sessionId} moved to GATHERING with topic "${sessionData.topic}" and ${expectedResponses} expected responses.`);
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Session is not in SETUP state. Current state: ${sessionData.state}` } }));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'SUBMIT_RESPONSE':
|
||||
if (sessionData.state === SessionState.GATHERING) {
|
||||
if (sessionData.responses.has(clientId)) {
|
||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'You have already submitted a response for this session.' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
const { wants, accepts, noGoes } = payload.response;
|
||||
if ([...wants, ...accepts, ...noGoes].some(desire => desire.length > 500)) {
|
||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'One of your desires exceeds the 500 character limit.' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
const hasContradictionsGist = await llmService.checkForInnerContradictions(payload.response);
|
||||
if (hasContradictionsGist) {
|
||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Your submission contains inner contradictions: ${hasContradictionsGist} Please resolve them and submit again.` } }));
|
||||
return;
|
||||
}
|
||||
|
||||
sessionData.responses.set(clientId, payload.response);
|
||||
sessionData.submittedCount++;
|
||||
console.log(`Client ${clientId} submitted response. Submitted count: ${sessionData.submittedCount}/${sessionData.expectedResponses}`);
|
||||
|
||||
if (sessionData.submittedCount === sessionData.expectedResponses) {
|
||||
sessionData.state = SessionState.HARMONIZING;
|
||||
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||
console.log(`Session ${sessionId} moved to HARMONIZING. Triggering LLM analysis.`);
|
||||
|
||||
// Perform LLM analysis asynchronously
|
||||
(async () => {
|
||||
try {
|
||||
const allDesires = Array.from(sessionData.responses.values());
|
||||
const decision = await llmService.analyzeDesires(allDesires);
|
||||
|
||||
sessionData.finalResult = decision;
|
||||
sessionData.state = SessionState.FINAL;
|
||||
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||
console.log(`Analysis complete for session ${sessionId}. Result:`, decision);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`Error during analysis for session ${sessionId}:`, error.message);
|
||||
sessionData.state = SessionState.ERROR;
|
||||
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
// Only broadcast the latest count if the session is not yet harmonizing
|
||||
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||
}
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Session is not in GATHERING state. Current state: ${sessionData.state}` } }));
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`Unknown message type: ${type} from client ${clientId} in session ${sessionId}`);
|
||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Unknown message type: ${type}` } }));
|
||||
break;
|
||||
}
|
||||
await handleWebSocketMessage(ws, sessionId, parsedMessage);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
@@ -207,6 +166,7 @@ export const createWebSocketServer = (server: any) => {
|
||||
|
||||
if (sessionData.clients.size === 0) {
|
||||
sessions.delete(sessionId);
|
||||
logEvent('session_purged', sessionId);
|
||||
console.log(`Session ${sessionId} closed and state cleared.`);
|
||||
}
|
||||
});
|
||||
@@ -218,3 +178,123 @@ export const createWebSocketServer = (server: any) => {
|
||||
|
||||
return wss;
|
||||
};
|
||||
|
||||
export const handleWebSocketMessage = async (ws: WebSocket, sessionId: string, parsedMessage: any) => {
|
||||
const { type, clientId, payload } = parsedMessage;
|
||||
|
||||
if (!clientId) {
|
||||
console.error(`Received message without clientId in session ${sessionId}. Type: ${type}`);
|
||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'clientId is required' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionData = sessions.get(sessionId)!;
|
||||
|
||||
if (!sessionData.clients.has(clientId)) {
|
||||
sessionData.clients.set(clientId, ws);
|
||||
console.log(`Client ${clientId} registered for session: ${sessionId}. Total clients: ${sessionData.clients.size}`);
|
||||
ws.send(JSON.stringify({ type: 'STATE_UPDATE', payload: { session: getSerializableSession(sessionData, clientId) } }));
|
||||
}
|
||||
|
||||
console.log(`Received message from ${clientId} in session ${sessionId}:`, type);
|
||||
|
||||
switch (type) {
|
||||
case 'REGISTER_CLIENT':
|
||||
break;
|
||||
|
||||
case 'SETUP_SESSION':
|
||||
if (sessionData.state === SessionState.SETUP) {
|
||||
const { expectedResponses, topic } = payload;
|
||||
if (typeof expectedResponses !== 'number' || expectedResponses <= 0) {
|
||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'Invalid expectedResponses' } }));
|
||||
return;
|
||||
}
|
||||
sessionData.expectedResponses = expectedResponses;
|
||||
sessionData.topic = topic || 'Untitled Session';
|
||||
sessionData.state = SessionState.GATHERING;
|
||||
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||
console.log(`Session ${sessionId} moved to GATHERING with topic "${sessionData.topic}" and ${expectedResponses} expected responses.`);
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Session is not in SETUP state. Current state: ${sessionData.state}` } }));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'SUBMIT_RESPONSE':
|
||||
if (sessionData.state === SessionState.GATHERING) {
|
||||
if (sessionData.responses.has(clientId)) {
|
||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'You have already submitted a response for this session.' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
const { wants, accepts, noGoes, afraidToAsk } = payload.response;
|
||||
if ([...wants, ...accepts, ...noGoes].some(desire => desire.length > 500) || afraidToAsk.length > 500) {
|
||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'One of your desires or afraidToAsk exceeds the 500 character limit.' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
const hasContradictionsGist = await llmService.checkForInnerContradictions(payload.response);
|
||||
if (hasContradictionsGist) {
|
||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Your submission contains inner contradictions: ${hasContradictionsGist} Please resolve them and submit again.` } }));
|
||||
return;
|
||||
}
|
||||
|
||||
const encryptedWants = wants.map((d: string) => encryptionService.encrypt(d));
|
||||
const encryptedAccepts = accepts.map((d: string) => encryptionService.encrypt(d));
|
||||
const encryptedNoGoes = noGoes.map((d: string) => encryptionService.encrypt(d));
|
||||
const encryptedAfraidToAsk = encryptionService.encrypt(afraidToAsk);
|
||||
|
||||
sessionData.responses.set(clientId, { wants: encryptedWants, accepts: encryptedAccepts, noGoes: encryptedNoGoes, afraidToAsk: encryptedAfraidToAsk });
|
||||
sessionData.submittedCount++;
|
||||
logEvent('response_submitted', sessionId, { clientId, submittedCount: sessionData.submittedCount });
|
||||
console.log(`Client ${clientId} submitted response. Submitted count: ${sessionData.submittedCount}/${sessionData.expectedResponses}`);
|
||||
|
||||
if (sessionData.submittedCount === sessionData.expectedResponses) {
|
||||
sessionData.state = SessionState.HARMONIZING;
|
||||
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||
logEvent('session_harmonizing', sessionId, { expectedResponses: sessionData.expectedResponses });
|
||||
console.log(`Session ${sessionId} moved to HARMONIZING. Triggering LLM analysis.`);
|
||||
|
||||
// Perform LLM analysis asynchronously
|
||||
(async () => {
|
||||
let durationMs: number = 0; // Declare here
|
||||
try {
|
||||
logEvent('llm_analysis_started', sessionId);
|
||||
const startTime = process.hrtime.bigint(); const allDecryptedDesires = Array.from(sessionData.responses.values()).map(encryptedResponse => {
|
||||
const decryptedWants = encryptedResponse.wants.map((d: string) => encryptionService.decrypt(d));
|
||||
const decryptedAccepts = encryptedResponse.accepts.map((d: string) => encryptionService.decrypt(d));
|
||||
const decryptedNoGoes = encryptedResponse.noGoes.map((d: string) => encryptionService.decrypt(d));
|
||||
const decryptedAfraidToAsk = encryptionService.decrypt(encryptedResponse.afraidToAsk);
|
||||
return { wants: decryptedWants, accepts: decryptedAccepts, noGoes: decryptedNoGoes, afraidToAsk: decryptedAfraidToAsk };
|
||||
});
|
||||
const decision = await llmService.analyzeDesires(allDecryptedDesires);
|
||||
|
||||
sessionData.finalResult = decision;
|
||||
sessionData.state = SessionState.FINAL;
|
||||
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||
logEvent('llm_analysis_completed', sessionId, { result: decision });
|
||||
recordMetric('llm_analysis_duration', durationMs, sessionId, { status: 'success' }); recordMetric('llm_analysis_availability', 'available', sessionId);
|
||||
console.log(`Analysis complete for session ${sessionId}. Result:`, decision);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`Error during analysis for session ${sessionId}:`, error.message);
|
||||
sessionData.state = SessionState.ERROR;
|
||||
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||
logEvent('llm_analysis_error', sessionId, { error: error.message });
|
||||
recordMetric('llm_analysis_availability', 'unavailable', sessionId, { error: error.message });
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
// Only broadcast the latest count if the session is not yet harmonizing
|
||||
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||
}
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Session is not in GATHERING state. Current state: ${sessionData.state}` } }));
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`Unknown message type: ${type} from client ${clientId} in session ${sessionId}`);
|
||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Unknown message type: ${type}` } }));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
101
backend/tests/LLMService.test.ts
Normal file
101
backend/tests/LLMService.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { LLMService } from '../src/services/LLMService';
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
|
||||
// Mock the GoogleGenerativeAI module
|
||||
jest.mock('@google/generative-ai');
|
||||
|
||||
describe('LLMService', () => {
|
||||
let llmService: LLMService;
|
||||
let mockGenerateContent: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks before each test
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock the generateContent method of the GenerativeModel
|
||||
mockGenerateContent = jest.fn();
|
||||
(GoogleGenerativeAI as jest.Mock).mockImplementation(() => ({
|
||||
getGenerativeModel: jest.fn(() => ({
|
||||
generateContent: mockGenerateContent,
|
||||
})),
|
||||
}));
|
||||
|
||||
llmService = new LLMService('test-api-key');
|
||||
});
|
||||
|
||||
describe('analyzeDesires', () => {
|
||||
it('should call the LLM with the correct prompt and handle afraidToAsk ideas', async () => {
|
||||
const desireSets = [
|
||||
{ wants: ['apple'], accepts: [], noGoes: [], afraidToAsk: 'banana' },
|
||||
{ wants: ['orange'], accepts: ['banana'], noGoes: [], afraidToAsk: 'grape' },
|
||||
];
|
||||
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
response: {
|
||||
text: () => JSON.stringify({
|
||||
goTo: 'apple',
|
||||
alsoGood: 'banana',
|
||||
considerable: 'orange',
|
||||
noGoes: '',
|
||||
needsDiscussion: '',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await llmService.analyzeDesires(desireSets);
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
|
||||
const prompt = mockGenerateContent.mock.calls[0][0];
|
||||
expect(prompt).toContain(JSON.stringify(desireSets));
|
||||
expect(prompt).toContain('afraidToAsk' in each desire set);
|
||||
expect(prompt).toContain('If an \'afraidToAsk\' idea matches, it should be treated as a \'want\'');
|
||||
expect(result).toEqual({
|
||||
goTo: 'apple',
|
||||
alsoGood: 'banana',
|
||||
considerable: 'orange',
|
||||
noGoes: '',
|
||||
needsDiscussion: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if LLM response is not valid JSON', async () => {
|
||||
const desireSets = [
|
||||
{ wants: ['apple'], accepts: [], noGoes: [], afraidToAsk: 'banana' },
|
||||
];
|
||||
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
response: {
|
||||
text: () => 'invalid json',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(llmService.analyzeDesires(desireSets)).rejects.toThrow('Failed to parse LLM response as JSON.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForInnerContradictions', () => {
|
||||
it('should return null if no contradictions are found', async () => {
|
||||
const desireSet = { wants: ['apple'], accepts: ['banana'], noGoes: ['grape'], afraidToAsk: 'kiwi' };
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
response: {
|
||||
text: () => 'null',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await llmService.checkForInnerContradictions(desireSet);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return a contradiction message if contradictions are found', async () => {
|
||||
const desireSet = { wants: ['apple', 'no apple'], accepts: [], noGoes: [], afraidToAsk: '' };
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
response: {
|
||||
text: () => 'Contradiction: apple and no apple in wants.',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await llmService.checkForInnerContradictions(desireSet);
|
||||
expect(result).toBe('Contradiction: apple and no apple in wants.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,30 +6,37 @@ import { LLMService } from '../src/services/LLMService';
|
||||
// Mock the LLMService
|
||||
jest.mock('../src/services/LLMService');
|
||||
|
||||
// Mock the ws module
|
||||
const mockHandleWebSocketMessage = jest.fn();
|
||||
const mockSessions = new Map<string, any>();
|
||||
const mockBroadcastToSession = jest.fn();
|
||||
|
||||
jest.mock('../src/ws', () => ({
|
||||
sessions: mockSessions,
|
||||
handleWebSocketMessage: mockHandleWebSocketMessage,
|
||||
broadcastToSession: mockBroadcastToSession,
|
||||
SessionState: {
|
||||
SETUP: 'SETUP',
|
||||
GATHERING: 'GATHERING',
|
||||
HARMONIZING: 'HARMONIZING',
|
||||
FINAL: 'FINAL',
|
||||
ERROR: 'ERROR',
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the routes
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Mock session storage for testing analyze endpoint
|
||||
const mockSessions = new Map<string, any>();
|
||||
mockSessions.set('test-session-id', { /* session data */ });
|
||||
|
||||
app.post('/sessions', (req, res) => {
|
||||
res.status(201).json({ sessionId: 'mock-session-id' });
|
||||
});
|
||||
|
||||
app.post('/sessions/:sessionId/analyze', async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
if (!mockSessions.has(sessionId)) {
|
||||
return res.status(404).send('Session not found');
|
||||
}
|
||||
// Mock LLMService call
|
||||
const mockLLMService = new LLMService('mock-api-key');
|
||||
const analysisResult = await mockLLMService.analyzeDesires(req.body.allDesires);
|
||||
res.status(202).json({ message: 'Analysis triggered', result: analysisResult });
|
||||
});
|
||||
// Import the actual router after mocks are set up
|
||||
import sessionsRouter from '../src/routes/sessions';
|
||||
app.use('/', sessionsRouter);
|
||||
|
||||
describe('POST /sessions', () => {
|
||||
beforeEach(() => {
|
||||
mockSessions.clear();
|
||||
});
|
||||
|
||||
it('should create a new session and return a session ID', async () => {
|
||||
const response = await request(app)
|
||||
.post('/sessions')
|
||||
@@ -38,42 +45,136 @@ describe('POST /sessions', () => {
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toHaveProperty('sessionId');
|
||||
expect(typeof response.body.sessionId).toBe('string');
|
||||
expect(mockSessions.has(response.body.sessionId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /sessions/:sessionId/analyze', () => {
|
||||
it('should trigger analysis for a valid session', async () => {
|
||||
const mockDesires = [
|
||||
{ wants: ['item1'], accepts: [], noGoes: [] },
|
||||
{ wants: ['item2'], accepts: [], noGoes: [] },
|
||||
];
|
||||
describe('POST /sessions/:sessionId/responses', () => {
|
||||
const testSessionId = 'test-session-id';
|
||||
const testUserId = 'test-user-id';
|
||||
|
||||
// Mock the analyzeDesires method to return a predictable result
|
||||
(LLMService as jest.Mock).mockImplementation(() => ({
|
||||
analyzeDesires: jest.fn().mockResolvedValue({
|
||||
"item1": "Concept A",
|
||||
"item2": "Concept A"
|
||||
}),
|
||||
}));
|
||||
beforeEach(() => {
|
||||
mockSessions.clear();
|
||||
mockHandleWebSocketMessage.mockClear();
|
||||
mockBroadcastToSession.mockClear();
|
||||
|
||||
// Initialize a session for testing
|
||||
mockSessions.set(testSessionId, {
|
||||
state: 'GATHERING',
|
||||
topic: 'Test Topic',
|
||||
expectedResponses: 1,
|
||||
submittedCount: 0,
|
||||
responses: new Map(),
|
||||
clients: new Map(),
|
||||
finalResult: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept a response with afraidToAsk and call handleWebSocketMessage', async () => {
|
||||
const responsePayload = {
|
||||
userId: testUserId,
|
||||
wants: ['More features'],
|
||||
accepts: ['Bug fixes'],
|
||||
afraidToAsk: 'A raise',
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/sessions/test-session-id/analyze')
|
||||
.send({ allDesires: mockDesires });
|
||||
.post(`/sessions/${testSessionId}/responses`)
|
||||
.send(responsePayload);
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body).toHaveProperty('message', 'Analysis triggered');
|
||||
expect(response.body).toHaveProperty('result');
|
||||
expect(response.body.result).toEqual({
|
||||
"item1": "Concept A",
|
||||
"item2": "Concept A"
|
||||
expect(response.body).toEqual({ message: 'Response submission acknowledged and processed.' });
|
||||
expect(mockHandleWebSocketMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockHandleWebSocketMessage).toHaveBeenCalledWith(
|
||||
expect.any(Object), // dummyWs
|
||||
testSessionId,
|
||||
{
|
||||
type: 'SUBMIT_RESPONSE',
|
||||
clientId: testUserId,
|
||||
payload: {
|
||||
response: {
|
||||
wants: ['More features'],
|
||||
accepts: ['Bug fixes'],
|
||||
afraidToAsk: 'A raise',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 404 if session is not found', async () => {
|
||||
const responsePayload = {
|
||||
userId: testUserId,
|
||||
wants: ['More features'],
|
||||
accepts: ['Bug fixes'],
|
||||
afraidToAsk: 'A raise',
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post(`/sessions/non-existent-session/responses`)
|
||||
.send(responsePayload);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ message: 'Session not found.' });
|
||||
expect(mockHandleWebSocketMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 500 if handleWebSocketMessage throws an error', async () => {
|
||||
mockHandleWebSocketMessage.mockRejectedValueOnce(new Error('WebSocket processing error'));
|
||||
|
||||
const responsePayload = {
|
||||
userId: testUserId,
|
||||
wants: ['More features'],
|
||||
accepts: ['Bug fixes'],
|
||||
afraidToAsk: 'A raise',
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post(`/sessions/${testSessionId}/responses`)
|
||||
.send(responsePayload);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toHaveProperty('message', 'Error processing response.');
|
||||
expect(response.body).toHaveProperty('error', 'WebSocket processing error');
|
||||
expect(mockHandleWebSocketMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /sessions/:sessionId/terminate', () => {
|
||||
const testSessionId = 'session-to-terminate';
|
||||
|
||||
beforeEach(() => {
|
||||
mockSessions.clear();
|
||||
// Initialize a session for testing termination
|
||||
mockSessions.set(testSessionId, {
|
||||
state: 'FINAL',
|
||||
topic: 'Test Topic',
|
||||
expectedResponses: 1,
|
||||
submittedCount: 1,
|
||||
responses: new Map([['client1', { wants: ['test'], accepts: [], noGoes: [], afraidToAsk: 'secret' }]]),
|
||||
clients: new Map(),
|
||||
finalResult: { goTo: 'test' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should terminate the session and purge its data', async () => {
|
||||
expect(mockSessions.has(testSessionId)).toBe(true);
|
||||
|
||||
const response = await request(app)
|
||||
.post(`/sessions/${testSessionId}/terminate`)
|
||||
.send();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ message: 'Session terminated and data purged successfully.' });
|
||||
expect(mockSessions.has(testSessionId)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return 404 if session is not found', async () => {
|
||||
const response = await request(app)
|
||||
.post('/sessions/non-existent-session/analyze')
|
||||
.send({ allDesires: [] });
|
||||
.post(`/sessions/non-existent-session/terminate`)
|
||||
.send();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ message: 'Session not found.' });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user