'Afraid to Ask' implemented

This commit is contained in:
aodulov
2025-10-13 13:14:30 +03:00
parent 09269190c1
commit 5f8541a5f3
20 changed files with 2081 additions and 190 deletions

View File

@@ -1 +1,2 @@
GEMINI_API_KEY="AIzaSyDke9H2NhiG6rBwxT0qrdYgnNoNZm_0j58"
GEMINI_API_KEY=YOUR_GEMINI_API_KEY
ENCRYPTION_KEY=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2

16
backend/.eslintrc.js Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -1,3 +1,6 @@
import dotenv from 'dotenv';
dotenv.config();
import express from 'express';
import http from 'http';
import { createWebSocketServer } from './ws';

View File

@@ -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;

View 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();
}
}

View File

@@ -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:
{

View File

@@ -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;
}
};

View 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.');
});
});
});

View File

@@ -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.' });
});
});