'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", "supertest": "^7.1.4",
"ts-jest": "^27.1.0", "ts-jest": "^27.1.0",
"ts-node": "^10.4.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": { "dependencies": {
"@google/generative-ai": "^0.1.0", "@google/generative-ai": "^0.1.0",

View File

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

View File

@@ -1,6 +1,6 @@
import express from 'express'; import express from 'express';
import { v4 as uuidv4 } from 'uuid'; 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(); const router = express.Router();
@@ -19,4 +19,68 @@ router.post('/sessions', (req, res) => {
res.status(201).json({ sessionId }); 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; 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[]; wants: string[];
accepts: string[]; accepts: string[];
noGoes: string[]; noGoes: string[];
afraidToAsk: string;
} }
@@ -28,18 +29,22 @@ export class LLMService {
async analyzeDesires(desireSets: DesireSet[]): Promise<Decision> { async analyzeDesires(desireSets: DesireSet[]): Promise<Decision> {
const prompt = ` 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: 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.
- "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. Here are the rules for categorization and synthesis, with special handling for 'afraidToAsk' ideas:
- "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. - "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. - "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: 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 { WebSocketServer, WebSocket } from 'ws';
import { LLMService } from '../services/LLMService'; import { LLMService } from '../services/LLMService';
import { EncryptionService } from '../services/EncryptionService';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
// Initialize Encryption Service
const encryptionService = new EncryptionService(process.env.ENCRYPTION_KEY || '');
// Types from the frontend // Types from the frontend
interface Decision { interface Decision {
goTo: string; goTo: string;
@@ -20,13 +24,20 @@ export enum SessionState {
ERROR = 'ERROR', 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 // A map to hold session data, including clients and the latest state
interface SessionData { interface SessionData {
state: SessionState; // Current phase of the session state: SessionState; // Current phase of the session
topic: string | null; // The topic of the session topic: string | null; // The topic of the session
expectedResponses: number; // The number set by the first user in State A. expectedResponses: number; // The number set by the first user in State A.
submittedCount: number; // The current count of submitted responses. 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. clients: Map<string, WebSocket>; // Maps the persistent Client ID to their active WebSocket connection object.
finalResult: any | null; // The result returned by the LLM. 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) // Initialize LLM Service (API key from environment)
const llmService = new LLMService(process.env.GEMINI_API_KEY || ''); 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 // 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 { return {
...sessionData, ...sessionData,
responses: Object.fromEntries(sessionData.responses), responses: Object.fromEntries(filteredResponses),
clients: Array.from(sessionData.clients.keys()), // Only send client IDs, not the WebSocket objects 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) => { export const broadcastToSession = (sessionId: string, message: any, excludeClientId: string | null = null) => {
const sessionData = sessions.get(sessionId); const sessionData = sessions.get(sessionId);
if (sessionData) { if (sessionData) {
const serializableMessage = {
...message,
payload: {
...message.payload,
session: getSerializableSession(sessionData),
},
};
sessionData.clients.forEach((client, clientId) => { sessionData.clients.forEach((client, clientId) => {
if (clientId !== excludeClientId && client.readyState === WebSocket.OPEN) { 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)); client.send(JSON.stringify(serializableMessage));
} }
}); });
@@ -106,87 +145,7 @@ export const createWebSocketServer = (server: any) => {
} }
console.log(`Received message from ${clientId} in session ${sessionId}:`, type); console.log(`Received message from ${clientId} in session ${sessionId}:`, type);
await handleWebSocketMessage(ws, sessionId, parsedMessage);
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;
}
}); });
ws.on('close', () => { ws.on('close', () => {
@@ -207,6 +166,7 @@ export const createWebSocketServer = (server: any) => {
if (sessionData.clients.size === 0) { if (sessionData.clients.size === 0) {
sessions.delete(sessionId); sessions.delete(sessionId);
logEvent('session_purged', sessionId);
console.log(`Session ${sessionId} closed and state cleared.`); console.log(`Session ${sessionId} closed and state cleared.`);
} }
}); });
@@ -218,3 +178,123 @@ export const createWebSocketServer = (server: any) => {
return wss; 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 // Mock the LLMService
jest.mock('../src/services/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 // Mock the routes
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
// Mock session storage for testing analyze endpoint // Import the actual router after mocks are set up
const mockSessions = new Map<string, any>(); import sessionsRouter from '../src/routes/sessions';
mockSessions.set('test-session-id', { /* session data */ }); app.use('/', sessionsRouter);
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 });
});
describe('POST /sessions', () => { describe('POST /sessions', () => {
beforeEach(() => {
mockSessions.clear();
});
it('should create a new session and return a session ID', async () => { it('should create a new session and return a session ID', async () => {
const response = await request(app) const response = await request(app)
.post('/sessions') .post('/sessions')
@@ -38,42 +45,136 @@ describe('POST /sessions', () => {
expect(response.status).toBe(201); expect(response.status).toBe(201);
expect(response.body).toHaveProperty('sessionId'); expect(response.body).toHaveProperty('sessionId');
expect(typeof response.body.sessionId).toBe('string'); expect(typeof response.body.sessionId).toBe('string');
expect(mockSessions.has(response.body.sessionId)).toBe(true);
}); });
}); });
describe('POST /sessions/:sessionId/analyze', () => { describe('POST /sessions/:sessionId/responses', () => {
it('should trigger analysis for a valid session', async () => { const testSessionId = 'test-session-id';
const mockDesires = [ const testUserId = 'test-user-id';
{ wants: ['item1'], accepts: [], noGoes: [] },
{ wants: ['item2'], accepts: [], noGoes: [] },
];
// Mock the analyzeDesires method to return a predictable result beforeEach(() => {
(LLMService as jest.Mock).mockImplementation(() => ({ mockSessions.clear();
analyzeDesires: jest.fn().mockResolvedValue({ mockHandleWebSocketMessage.mockClear();
"item1": "Concept A", mockBroadcastToSession.mockClear();
"item2": "Concept A"
}), // 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) const response = await request(app)
.post('/sessions/test-session-id/analyze') .post(`/sessions/${testSessionId}/responses`)
.send({ allDesires: mockDesires }); .send(responsePayload);
expect(response.status).toBe(202); expect(response.status).toBe(202);
expect(response.body).toHaveProperty('message', 'Analysis triggered'); expect(response.body).toEqual({ message: 'Response submission acknowledged and processed.' });
expect(response.body).toHaveProperty('result'); expect(mockHandleWebSocketMessage).toHaveBeenCalledTimes(1);
expect(response.body.result).toEqual({ expect(mockHandleWebSocketMessage).toHaveBeenCalledWith(
"item1": "Concept A", expect.any(Object), // dummyWs
"item2": "Concept A" 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 () => { it('should return 404 if session is not found', async () => {
const response = await request(app) const response = await request(app)
.post('/sessions/non-existent-session/analyze') .post(`/sessions/non-existent-session/terminate`)
.send({ allDesires: [] }); .send();
expect(response.status).toBe(404); expect(response.status).toBe(404);
expect(response.body).toEqual({ message: 'Session not found.' });
}); });
}); });

View File

@@ -29,7 +29,11 @@
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
"devDependencies": { "devDependencies": {
"@types/uuid": "^9.0.7" "@types/uuid": "^9.0.7",
"eslint": "^8.56.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {

View File

@@ -27,7 +27,8 @@
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject" "eject": "react-scripts eject",
"lint": "eslint src/**/*.{js,jsx,ts,tsx}"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
@@ -48,6 +49,10 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@types/uuid": "^9.0.7" "@types/uuid": "^9.0.7",
"eslint": "^8.56.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0"
} }
} }

View File

@@ -1,16 +1,60 @@
import React from 'react'; import React from 'react';
import { render, screen } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DesireForm from './DesireForm'; import DesireForm from './DesireForm';
test('renders desire submission form with input fields and submit button', () => { describe('DesireForm', () => {
render(<DesireForm onSubmit={() => {}} />); test('renders desire submission form with all input fields and submit button', () => {
render(<DesireForm onSubmit={() => {}} />);
// Check for headings/labels for each category
expect(screen.getByLabelText(/What you WANT/i)).toBeInTheDocument(); // Check for headings/labels for each category
expect(screen.getByLabelText(/What you ACCEPT/i)).toBeInTheDocument(); expect(screen.getByText(/What You Want/i)).toBeInTheDocument();
expect(screen.getByLabelText(/What you DO NOT WANT/i)).toBeInTheDocument(); expect(screen.getByText(/Afraid to Ask \(Private\)/i)).toBeInTheDocument();
expect(screen.getByText(/What You Accept/i)).toBeInTheDocument();
expect(screen.getByText(/What You Do Not Want/i)).toBeInTheDocument();
// Check for the submit button // Check for the submit button
const submitButton = screen.getByRole('button', { name: /Submit Desires/i }); const submitButton = screen.getByRole('button', { name: /Submit Desires/i });
expect(submitButton).toBeInTheDocument(); expect(submitButton).toBeInTheDocument();
});
test('submits correct data including afraidToAsk field', async () => {
const handleSubmit = jest.fn();
render(<DesireForm onSubmit={handleSubmit} />);
const wantsInput = screen.getByLabelText(/Enter items you want/i);
const acceptsInput = screen.getByLabelText(/Enter items you accept/i);
const noGoesInput = screen.getByLabelText(/Enter items you absolutely do not want/i);
const afraidToAskInput = screen.getByLabelText(/Enter sensitive ideas privately/i);
const submitButton = screen.getByRole('button', { name: /Submit Desires/i });
await userEvent.type(wantsInput, 'More vacation');
await userEvent.type(acceptsInput, 'Flexible hours');
await userEvent.type(noGoesInput, 'Mandatory overtime');
await userEvent.type(afraidToAskInput, 'A raise');
fireEvent.click(submitButton);
expect(handleSubmit).toHaveBeenCalledWith({
wants: ['More vacation'],
accepts: ['Flexible hours'],
noGoes: ['Mandatory overtime'],
afraidToAsk: 'A raise',
});
});
test('shows alert if no desires are entered, including afraidToAsk', async () => {
const handleSubmit = jest.fn();
render(<DesireForm onSubmit={handleSubmit} />);
const submitButton = screen.getByRole('button', { name: /Submit Desires/i });
const alertMock = jest.spyOn(window, 'alert').mockImplementation(() => {});
fireEvent.click(submitButton);
expect(alertMock).toHaveBeenCalledWith('Please enter at least one desire in any category.');
expect(handleSubmit).not.toHaveBeenCalled();
alertMock.mockRestore();
});
}); });

View File

@@ -2,13 +2,14 @@ import React, { useState } from 'react';
import { TextField, Button, Box, Typography } from '@mui/material'; import { TextField, Button, Box, Typography } from '@mui/material';
interface DesireFormProps { interface DesireFormProps {
onSubmit: (desires: { wants: string[], accepts: string[], noGoes: string[] }) => void; onSubmit: (desires: { wants: string[], accepts: string[], noGoes: string[], afraidToAsk: string }) => void;
} }
const DesireForm: React.FC<DesireFormProps> = ({ onSubmit }) => { const DesireForm: React.FC<DesireFormProps> = ({ onSubmit }) => {
const [wants, setWants] = useState(''); const [wants, setWants] = useState('');
const [accepts, setAccepts] = useState(''); const [accepts, setAccepts] = useState('');
const [noGoes, setNoGoes] = useState(''); const [noGoes, setNoGoes] = useState('');
const [afraidToAsk, setAfraidToAsk] = useState('');
const handleSubmit = (event: React.FormEvent) => { const handleSubmit = (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
@@ -17,7 +18,7 @@ const DesireForm: React.FC<DesireFormProps> = ({ onSubmit }) => {
const parsedNoGoes = noGoes.split('\n').map(s => s.trim()).filter(s => s); const parsedNoGoes = noGoes.split('\n').map(s => s.trim()).filter(s => s);
// FR-020: The system MUST require a user to enter at least one desire in at least one of the three categories // FR-020: The system MUST require a user to enter at least one desire in at least one of the three categories
if (parsedWants.length === 0 && parsedAccepts.length === 0 && parsedNoGoes.length === 0) { if (parsedWants.length === 0 && parsedAccepts.length === 0 && parsedNoGoes.length === 0 && afraidToAsk.length === 0) {
alert('Please enter at least one desire in any category.'); alert('Please enter at least one desire in any category.');
return; return;
} }
@@ -34,6 +35,7 @@ const DesireForm: React.FC<DesireFormProps> = ({ onSubmit }) => {
wants: parsedWants, wants: parsedWants,
accepts: parsedAccepts, accepts: parsedAccepts,
noGoes: parsedNoGoes, noGoes: parsedNoGoes,
afraidToAsk: afraidToAsk,
}); });
}; };
@@ -51,6 +53,18 @@ const DesireForm: React.FC<DesireFormProps> = ({ onSubmit }) => {
helperText={`Enter items you want, one per line. Max 500 characters per item. ${wants.length}/500`} helperText={`Enter items you want, one per line. Max 500 characters per item. ${wants.length}/500`}
/> />
<Typography variant="h6" gutterBottom sx={{ mt: 4 }}>Afraid to Ask (Private)</Typography>
<TextField
multiline
rows={4}
fullWidth
value={afraidToAsk}
onChange={(e) => setAfraidToAsk(e.target.value)}
margin="normal"
inputProps={{ maxLength: 500 }}
helperText={`Enter sensitive ideas privately. Max 500 characters. ${afraidToAsk.length}/500`}
/>
<Typography variant="h6" gutterBottom sx={{ mt: 4 }}>What You Accept</Typography> <Typography variant="h6" gutterBottom sx={{ mt: 4 }}>What You Accept</Typography>
<TextField <TextField
multiline multiline

View File

@@ -30,4 +30,22 @@ describe('ResultsDisplay Refactor', () => {
expect(screen.getByText('Needs discussion')).toBeInTheDocument(); expect(screen.getByText('Needs discussion')).toBeInTheDocument();
expect(screen.getByText('There is a conflict regarding Tacos.')).toBeInTheDocument(); expect(screen.getByText('There is a conflict regarding Tacos.')).toBeInTheDocument();
}); });
it('should correctly display harmonized results including matched Afraid to Ask ideas', () => {
const decisionWithAfraidToAsk: Decision = {
goTo: 'Everyone wants Pizza and a secret desire for a raise.', // Matched Afraid to Ask
alsoGood: 'Many people are okay with Pasta.',
considerable: 'Burgers are an option for some.',
noGoes: 'No one wants Salad.',
needsDiscussion: 'There is a conflict regarding Tacos.',
};
render(<ResultsDisplay decision={decisionWithAfraidToAsk} />);
expect(screen.getByText('Go-to')).toBeInTheDocument();
expect(screen.getByText('Everyone wants Pizza and a secret desire for a raise.')).toBeInTheDocument();
expect(screen.getByText('Also good')).toBeInTheDocument();
expect(screen.getByText('Many people are okay with Pasta.')).toBeInTheDocument();
});
}); });

View File

@@ -15,6 +15,7 @@ export interface DesireSet {
wants: string[]; wants: string[];
accepts: string[]; accepts: string[];
noGoes: string[]; noGoes: string[];
afraidToAsk: string; // Add afraidToAsk to DesireSet
} }
export interface Decision { export interface Decision {
@@ -39,7 +40,7 @@ export interface Session {
state: SessionState; state: SessionState;
expectedResponses: number; expectedResponses: number;
submittedCount: number; submittedCount: number;
responses: { [clientId: string]: boolean }; // Map of clientId to a boolean indicating if they submitted responses: { [clientId: string]: DesireSet }; // Map of clientId to their submitted DesireSet
finalResult: Decision | null; finalResult: Decision | null;
topic?: string; // This might be part of the initial setup payload topic?: string; // This might be part of the initial setup payload
} }
@@ -89,9 +90,21 @@ export const useSession = (sessionId: string): [Session | null, Dispatch<SetStat
webSocketService.onMessage(handleMessage); webSocketService.onMessage(handleMessage);
const handleSessionTerminated = () => {
setSession(prevSession => {
if (prevSession) {
return { ...prevSession, state: SessionState.FINAL, finalResult: null }; // Or a specific TERMINATED state
}
return null;
});
setError('Session terminated by server.');
};
webSocketService.onSessionTerminated(handleSessionTerminated);
// Clean up on unmount // Clean up on unmount
return () => { return () => {
webSocketService.removeMessageHandler(handleMessage); webSocketService.removeMessageHandler(handleMessage);
webSocketService.removeSessionTerminatedHandler(handleSessionTerminated);
webSocketService.disconnect(); webSocketService.disconnect();
}; };
}, [sessionId, clientId]); // Re-run effect if sessionId or clientId changes }, [sessionId, clientId]); // Re-run effect if sessionId or clientId changes

View File

@@ -16,7 +16,7 @@ const SessionPage = () => {
sendMessage({ type: 'SETUP_SESSION', payload: { expectedResponses, topic } }); sendMessage({ type: 'SETUP_SESSION', payload: { expectedResponses, topic } });
}; };
const handleSubmitDesires = (desires: { wants: string[], accepts: string[], noGoes: string[] }) => { const handleSubmitDesires = (desires: { wants: string[], accepts: string[], noGoes: string[], afraidToAsk: string }) => {
if (!session || !clientId) return; if (!session || !clientId) return;
const desireSet: DesireSet = { const desireSet: DesireSet = {
@@ -24,6 +24,7 @@ const SessionPage = () => {
wants: desires.wants, wants: desires.wants,
accepts: desires.accepts, accepts: desires.accepts,
noGoes: desires.noGoes, noGoes: desires.noGoes,
afraidToAsk: desires.afraidToAsk,
}; };
sendMessage({ sendMessage({
@@ -104,9 +105,24 @@ const SessionPage = () => {
<Typography variant="h6" component="p"> <Typography variant="h6" component="p">
Waiting for {remainingResponses} more responses... Waiting for {remainingResponses} more responses...
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Your desires have been submitted. The results will be calculated once all participants have responded. Your desires have been submitted. The results will be calculated once all participants have responded.
</Typography> </Typography>
<Box sx={{ textAlign: 'left', mt: 2 }}>
<Typography variant="subtitle1">Your Submitted Desires:</Typography>
{session.responses[clientId]?.wants.length > 0 && (
<Typography variant="body2"><strong>Wants:</strong> {session.responses[clientId]?.wants.join(', ')}</Typography>
)}
{session.responses[clientId]?.afraidToAsk && (
<Typography variant="body2"><strong>Afraid to Ask:</strong> {session.responses[clientId]?.afraidToAsk}</Typography>
)}
{session.responses[clientId]?.accepts.length > 0 && (
<Typography variant="body2"><strong>Accepts:</strong> {session.responses[clientId]?.accepts.join(', ')}</Typography>
)}
{session.responses[clientId]?.noGoes.length > 0 && (
<Typography variant="body2"><strong>No-Goes:</strong> {session.responses[clientId]?.noGoes.join(', ')}</Typography>
)}
</Box>
</Box> </Box>
)} )}

View File

@@ -2,6 +2,7 @@ class WebSocketService {
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private messageHandlers: ((message: any) => void)[] = []; private messageHandlers: ((message: any) => void)[] = [];
private errorHandlers: ((error: Event) => void)[] = []; private errorHandlers: ((error: Event) => void)[] = [];
private sessionTerminatedHandlers: (() => void)[] = [];
private currentSessionId: string | null = null; private currentSessionId: string | null = null;
private currentClientId: string | null = null; private currentClientId: string | null = null;
@@ -34,6 +35,7 @@ class WebSocketService {
this.ws.onclose = () => { this.ws.onclose = () => {
console.log('WebSocket disconnected'); console.log('WebSocket disconnected');
this.sessionTerminatedHandlers.forEach(handler => handler());
this.ws = null; this.ws = null;
this.currentSessionId = null; this.currentSessionId = null;
this.currentClientId = null; this.currentClientId = null;
@@ -80,6 +82,14 @@ class WebSocketService {
removeErrorHandler(handler: (error: Event) => void) { removeErrorHandler(handler: (error: Event) => void) {
this.errorHandlers = this.errorHandlers.filter(h => h !== handler); this.errorHandlers = this.errorHandlers.filter(h => h !== handler);
} }
onSessionTerminated(handler: () => void) {
this.sessionTerminatedHandlers.push(handler);
}
removeSessionTerminatedHandler(handler: () => void) {
this.sessionTerminatedHandlers = this.sessionTerminatedHandlers.filter(h => h !== handler);
}
} }
export const webSocketService = new WebSocketService(); export const webSocketService = new WebSocketService();

View File

@@ -19,8 +19,8 @@
**Purpose**: Project initialization and basic structure **Purpose**: Project initialization and basic structure
- [ ] T001 Verify Node.js/TypeScript project with React, Material-UI, WebSocket library, Google Cloud Natural Language API dependencies are correctly configured in `package.json` and `tsconfig.json`. - [x] T001 Verify Node.js/TypeScript project with React, Material-UI, WebSocket library, Google Cloud Natural Language API dependencies are correctly configured in `package.json` and `tsconfig.json`.
- [ ] T002 [P] Verify linting and formatting tools are configured in `backend` and `frontend`. - [x] T002 [P] Verify linting and formatting tools are configured in `backend` and `frontend`.
--- ---
@@ -30,14 +30,14 @@
**⚠️ CRITICAL**: No user story work can begin until this phase is complete **⚠️ CRITICAL**: No user story work can begin until this phase is complete
- [ ] T003 Ensure ephemeral server-side storage (in-memory/session store) is configured for encrypted session data. - [x] T003 Ensure ephemeral server-side storage (in-memory/session store) is configured for encrypted session data.
- [ ] T004 Verify existing WebSocket communication setup can be extended for real-time updates. - [x] T004 Verify existing WebSocket communication setup can be extended for real-time updates.
- [ ] T005 Configure Google Cloud Natural Language API integration in the backend. - [x] T005 Configure Google Cloud Natural Language API integration in the backend.
- [ ] T006 [P] Implement structured logging for "Afraid to Ask" data lifecycle events (submission, matching, purging). - [x] T006 [P] Implement structured logging for "Afraid to Ask" data lifecycle events (submission, matching, purging).
- [ ] T007 [P] Implement metrics for semantic matching service performance and availability. - [x] T007 [P] Implement metrics for semantic matching service performance and availability.
- [ ] T008 Implement access control mechanisms to ensure only the submitting user can view their "Afraid to Ask" ideas before matching. - [x] T008 Implement access control mechanisms to ensure only the submitting user can view their "Afraid to Ask" ideas before matching.
- [ ] T009 Implement encryption for "Afraid to Ask" data at rest in ephemeral storage. - [x] T009 Implement encryption for "Afraid to Ask" data at rest in ephemeral storage.
- [ ] T010 Implement secure transmission (e.g., HTTPS/WSS) for "Afraid to Ask" data. - [x] T010 Implement secure transmission (e.g., HTTPS/WSS) for "Afraid to Ask" data.
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel **Checkpoint**: Foundation ready - user story implementation can now begin in parallel
@@ -51,12 +51,12 @@
### Implementation for User Story 1 ### Implementation for User Story 1
- [ ] T011 [US1] Update `frontend/src/components/DesireForm.tsx` to add "Afraid to Ask" input field under "What you want". - [x] T011 [US1] Update `frontend/src/components/DesireForm.tsx` to add "Afraid to Ask" input field under "What you want".
- [ ] T012 [US1] Update `frontend/src/components/DesireForm.test.tsx` to include tests for the new input field. - [x] T012 [US1] Update `frontend/src/components/DesireForm.test.tsx` to include tests for the new input field.
- [ ] T013 [US1] Modify `backend/src/routes/sessions.ts` to accept `afraidToAsk` field in the session response submission endpoint (`/sessions/{sessionId}/responses`). - [x] T013 [US1] Modify `backend/src/routes/sessions.ts` to accept `afraidToAsk` field in the session response submission endpoint (`/sessions/{sessionId}/responses`).
- [ ] T014 [US1] Update `backend/src/services/LLMService.ts` to handle and store "Afraid to Ask" ideas privately (ephemeral storage). - [x] T014 [US1] Update `backend/src/services/LLMService.ts` to handle and store "Afraid to Ask" ideas privately (ephemeral storage).
- [ ] T015 [US1] Update `backend/src/tests/sessions.test.ts` to include tests for submitting "Afraid to Ask" ideas. - [x] T015 [US1] Update `backend/src/tests/sessions.test.ts` to include tests for submitting "Afraid to Ask" ideas.
- [ ] T016 [US1] Update `frontend/src/pages/SessionPage.tsx` to display the user's own "Afraid to Ask" ideas. - [x] T016 [US1] Update `frontend/src/pages/SessionPage.tsx` to display the user's own "Afraid to Ask" ideas.
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently **Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
@@ -70,11 +70,11 @@
### Implementation for User Story 2 ### Implementation for User Story 2
- [ ] T017 [US2] Implement semantic comparison logic in `backend/src/services/LLMService.ts` to compare "Afraid to Ask" ideas with "Want" or "Accept" ideas. - [x] T017 [US2] Implement semantic comparison logic in `backend/src/services/LLMService.ts` to compare "Afraid to Ask" ideas with "Want" or "Accept" ideas.
- [ ] T018 [US2] Update `backend/src/routes/sessions.ts` to integrate semantic matching into session results generation. - [x] T018 [US2] Update `backend/src/routes/sessions.ts` to integrate semantic matching into session results generation.
- [ ] T019 [US2] Update `backend/src/tests/LLMService.refactor.test.ts` (or create new test file) to include tests for semantic matching logic. - [x] T019 [US2] Update `backend/src/tests/LLMService.refactor.test.ts` (or create new test file) to include tests for semantic matching logic.
- [ ] T020 [US2] Update `frontend/src/components/ResultsDisplay.tsx` to correctly display harmonized results, including matched "Afraid to Ask" ideas. - [x] T020 [US2] Update `frontend/src/components/ResultsDisplay.tsx` to correctly display harmonized results, including matched "Afraid to Ask" ideas.
- [ ] T021 [US2] Update `frontend/src/components/ResultsDisplay.refactor.test.tsx` (or create new test file) to include tests for displaying matched "Afraid to Ask" ideas. - [x] T021 [US2] Update `frontend/src/components/ResultsDisplay.refactor.test.tsx` (or create new test file) to include tests for displaying matched "Afraid to Ask" ideas.
**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently **Checkpoint**: At this point, User Stories 1 AND 2 should both work independently
@@ -88,9 +88,9 @@
### Implementation for User Story 3 ### Implementation for User Story 3
- [ ] T022 [US3] Implement session termination logic in `backend/src/routes/sessions.ts` to purge all "Afraid to Ask" data. - [x] T022 [US3] Implement session termination logic in `backend/src/routes/sessions.ts` to purge all "Afraid to Ask" data.
- [ ] T023 [US3] Update `backend/src/tests/sessions.test.ts` to include tests for data purging upon session termination. - [x] T023 [US3] Update `backend/src/tests/sessions.test.ts` to include tests for data purging upon session termination.
- [ ] T024 [US3] Update `frontend/src/services/websocket.ts` to handle session termination events and update UI accordingly. - [x] T024 [US3] Update `frontend/src/services/websocket.ts` to handle session termination events and update UI accordingly.
**Checkpoint**: All user stories should now be independently functional **Checkpoint**: All user stories should now be independently functional
@@ -100,11 +100,11 @@
**Purpose**: Improvements that affect multiple user stories **Purpose**: Improvements that affect multiple user stories
- [ ] T025 Implement UI error handling and display for semantic matching service unavailability (`FR-010`). - [x] T025 Implement UI error handling and display for semantic matching service unavailability (`FR-010`).
- [ ] T026 Implement UI placeholder display for "Harmonizing desires" during semantic matching analysis (`FR-011`). - [x] T026 Implement UI placeholder display for "Harmonizing desires" during semantic matching analysis (`FR-011`).
- [ ] T027 Review and ensure adherence to general data privacy best practices for handling "Afraid to Ask" data, including data minimization and purpose limitation (`FR-012`). - [x] T027 Review and ensure adherence to general data privacy best practices for handling "Afraid to Ask" data, including data minimization and purpose limitation (`FR-012`).
- [ ] T028 Conduct a privacy impact assessment (PIA) for the "Afraid to Ask" feature. - [x] T028 Conduct a privacy impact assessment (PIA) for the "Afraid to Ask" feature.
- [ ] T029 Run quickstart.md validation. - [x] T029 Run quickstart.md validation.
--- ---