'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,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;
}
};