session start works

This commit is contained in:
aodulov
2025-10-10 12:48:06 +03:00
parent 556df015e8
commit 3c192b136c
51 changed files with 29002 additions and 46 deletions

8
.context/log.txt Normal file
View File

@@ -0,0 +1,8 @@
manifest.json:1 Manifest: Line: 1, column: 1, Syntax error.
websocket.ts:20 WebSocket connected
websocket.ts:28 WebSocketService: Received and parsed message: {type: 'STATE_UPDATE', payload: {…}}payload: session: clients: [[Prototype]]: ObjectexpectedResponses: 0finalResult: nullresponses: [[Prototype]]: Objectstate: "SETUP"submittedCount: 0[[Prototype]]: Object[[Prototype]]: Objecttype: "STATE_UPDATE"[[Prototype]]: Object
useSession.ts:81 useSession: Processing incoming message: {type: 'STATE_UPDATE', payload: {…}}payload: session: clients: [[Prototype]]: ObjectexpectedResponses: 0finalResult: nullresponses: [[Prototype]]: Objectstate: "SETUP"submittedCount: 0[[Prototype]]: Object[[Prototype]]: Objecttype: "STATE_UPDATE"[[Prototype]]: Object
websocket.ts:28 WebSocketService: Received and parsed message: {type: 'STATE_UPDATE', payload: {…}}payload: session: clients: [[Prototype]]: ObjectexpectedResponses: 2finalResult: nullresponses: [[Prototype]]: Objectstate: "GATHERING"submittedCount: 0[[Prototype]]: Object[[Prototype]]: Objecttype: "STATE_UPDATE"[[Prototype]]: Object
useSession.ts:81 useSession: Processing incoming message: {type: 'STATE_UPDATE', payload: {…}}payload: session: clients: [[Prototype]]: ObjectexpectedResponses: 2finalResult: nullresponses: [[Prototype]]: Objectstate: "GATHERING"submittedCount: 0[[Prototype]]: Object[[Prototype]]: Objecttype: "STATE_UPDATE"[[Prototype]]: Object
browser-integration.js:2 port disconnected from addon code: 8258ac25-409c-4e4d-8c4a-46b4ab8d1ff7
(anonymous) @ browser-integration.js:2

View File

@@ -34,7 +34,7 @@ Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --inclu
- TASKS = FEATURE_DIR/tasks.md - TASKS = FEATURE_DIR/tasks.md
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command). Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command).
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: "I'm Groot").
### 2. Load Artifacts (Progressive Disclosure) ### 2. Load Artifacts (Progressive Disclosure)

View File

@@ -36,7 +36,7 @@ You **MUST** consider the user input before proceeding (if not empty).
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list. 1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
- All file paths must be absolute. - All file paths must be absolute.
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). - For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST: 2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks - Be generated from the user's phrasing + extracted signals from spec/plan/tasks

View File

@@ -26,7 +26,7 @@ Execution steps:
- `FEATURE_SPEC` - `FEATURE_SPEC`
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.) - (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
- If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment. - If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment.
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). - For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: "I'm Groot").
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked). 2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).

View File

@@ -15,7 +15,7 @@ You **MUST** consider the user input before proceeding (if not empty).
## Outline ## Outline
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). 1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists): 2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
- Scan all checklist files in the checklists/ directory - Scan all checklist files in the checklists/ directory

View File

@@ -15,7 +15,7 @@ You **MUST** consider the user input before proceeding (if not empty).
## Outline ## Outline
1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). 1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load context**: Read FEATURE_SPEC and `.specify.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied). 2. **Load context**: Read FEATURE_SPEC and `.specify.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).

View File

@@ -20,7 +20,7 @@ The text the user typed after `/speckit.specify` in the triggering message **is*
Given that feature description, do this: Given that feature description, do this:
1. Run the script `.specify/scripts/bash/create-new-feature.sh --json "{{args}}"` from repo root and parse its JSON output for BRANCH_NAME and SPEC_FILE. All file paths must be absolute. 1. Run the script `.specify/scripts/bash/create-new-feature.sh --json "{{args}}"` from repo root and parse its JSON output for BRANCH_NAME and SPEC_FILE. All file paths must be absolute.
**IMPORTANT** You must only ever run this script once. The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). **IMPORTANT** You must only ever run this script once. The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: "I'm Groot").
2. Load `.specify/templates/spec-template.md` to understand required sections. 2. Load `.specify/templates/spec-template.md` to understand required sections.
3. Follow this execution flow: 3. Follow this execution flow:

View File

@@ -15,7 +15,7 @@ You **MUST** consider the user input before proceeding (if not empty).
## Outline ## Outline
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). 1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load design documents**: Read from FEATURE_DIR: 2. **Load design documents**: Read from FEATURE_DIR:
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities) - **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/frontend/node_modules
/backend/node_modules

View File

@@ -1,50 +1,40 @@
# [PROJECT_NAME] Constitution <!--
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. --> Sync Impact Report:
- Version change: 0.0.0 → 1.0.0
- List of modified principles: N/A (initial creation)
- Added sections: Core Principles, Governance
- Removed sections: N/A
- Templates requiring updates:
- ✅ .specify/templates/plan-template.md (verified)
- ✅ .specify/templates/spec-template.md (verified)
- ✅ .specify/templates/tasks-template.md (verified)
- Follow-up TODOs: None
-->
# Agree on Desires Constitution
## Core Principles ## Core Principles
### [PRINCIPLE_1_NAME] ### I. Defined Technology Stack
<!-- Example: I. Library-First --> All development MUST adhere to the approved technology stack. This ensures consistency, maintainability, and streamlined operations.
[PRINCIPLE_1_DESCRIPTION] - **Backend**: Node.js
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries --> - **Frontend**: React
- **UI Framework**: Material Design 3 (Material-UI / MUI)
- **Containerization**: Docker
### [PRINCIPLE_2_NAME] ### II. UI/UX Consistency
<!-- Example: II. CLI Interface --> All user interfaces MUST adhere to Material Design 3 principles and components. This creates a cohesive, predictable, and high-quality user experience. Rationale: Avoids fragmented design and reduces redundant styling efforts.
[PRINCIPLE_2_DESCRIPTION]
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
### [PRINCIPLE_3_NAME] ### III. Container-First Development
<!-- Example: III. Test-First (NON-NEGOTIABLE) --> All application services and development environments MUST run within Docker containers. This ensures environment parity from local development to production, simplifies setup, and improves deployment reliability.
[PRINCIPLE_3_DESCRIPTION]
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
### [PRINCIPLE_4_NAME] ### IV. Test-Driven Development (TDD)
<!-- Example: IV. Integration Testing --> New features and bug fixes MUST follow a Test-Driven Development approach. Tests should be written first to define the requirements, fail before implementation, and pass upon completion. This ensures code quality, reduces regressions, and clarifies requirements.
[PRINCIPLE_4_DESCRIPTION]
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
### [PRINCIPLE_5_NAME] ### V. API-First Design
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity --> The backend and frontend are decoupled and communicate via a well-defined API contract. The API contract SHOULD be defined and reviewed before implementation begins. This allows for parallel development and clear integration points.
[PRINCIPLE_5_DESCRIPTION]
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
## [SECTION_2_NAME]
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
[SECTION_2_CONTENT]
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
## [SECTION_3_NAME]
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
[SECTION_3_CONTENT]
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
## Governance ## Governance
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
[GOVERNANCE_RULES] All development activities, code reviews, and architectural decisions must align with this constitution. Proposed deviations require a formal amendment to this document.
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] **Version**: 1.0.0 | **Ratified**: 2025-10-09 | **Last Amended**: 2025-10-09
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->

27
GEMINI.md Normal file
View File

@@ -0,0 +1,27 @@
# agree-on-desires Development Guidelines
Auto-generated from all feature plans. Last updated: 2025-10-09
## Active Technologies
- Node.js (LTS), TypeScript 5.x (001-people-tend-to)
- Browser Local Storage (Primary store for session state). The backend is stateless. (001-people-tend-to)
## Project Structure
```
src/
tests/
```
## Commands
npm test [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] npm run lint
## Code Style
Node.js (LTS), TypeScript 5.x: Follow standard conventions
## Recent Changes
- 001-people-tend-to: Added Node.js (LTS), TypeScript 5.x
- 001-people-tend-to: Added Node.js (LTS), TypeScript 5.x
- 001-people-tend-to: Added Node.js (LTS), TypeScript 5.x
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

1
backend/.env Normal file
View File

@@ -0,0 +1 @@
GEMINI_API_KEY=AIzaSyDke9H2NhiG6rBwxT0qrdYgnNoNZm_0j58

15
backend/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 8000
CMD ["node", "dist/index.js"]

5833
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
backend/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "backend",
"version": "1.0.0",
"description": "Backend service for Anonymous Desire Aggregator",
"main": "src/index.ts",
"scripts": {
"start": "ts-node src/index.ts",
"dev": "nodemon src/index.ts",
"build": "tsc",
"test": "jest"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@types/express": "^4.17.13",
"@types/jest": "^27.0.3",
"@types/node": "^16.11.12",
"@types/uuid": "^8.3.4",
"@types/ws": "^8.2.2",
"jest": "^27.4.3",
"nodemon": "^2.0.15",
"ts-jest": "^27.1.0",
"ts-node": "^10.4.0",
"typescript": "^4.5.2",
"@types/cors": "^2.8.19"
},
"dependencies": {
"@google/generative-ai": "^0.1.0",
"cors": "^2.8.5",
"express": "^4.17.2",
"uuid": "^8.3.2",
"ws": "^8.4.0"
}
}

24
backend/src/index.ts Normal file
View File

@@ -0,0 +1,24 @@
import express from 'express';
import http from 'http';
import { createWebSocketServer } from './ws';
import sessionsRouter from './routes/sessions';
import cors from 'cors';
const app = express();
const server = http.createServer(app);
// Middleware
app.use(express.json());
app.use(cors());
// API Routes
app.use('/', sessionsRouter);
// Create and attach WebSocket server
createWebSocketServer(server);
const PORT = process.env.PORT || 8000;
server.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});

View File

@@ -0,0 +1,22 @@
import express from 'express';
import { v4 as uuidv4 } from 'uuid';
import { sessions, SessionState } from '../ws'; // Import sessions and SessionState from ws/index.ts
const router = express.Router();
router.post('/sessions', (req, res) => {
const sessionId = uuidv4();
sessions.set(sessionId, {
state: SessionState.SETUP,
topic: null,
expectedResponses: 0,
submittedCount: 0,
responses: new Map(),
clients: new Map(),
finalResult: null,
});
console.log(`New session created: ${sessionId}`);
res.status(201).json({ sessionId });
});
export default router;

View File

@@ -0,0 +1,51 @@
import { GoogleGenerativeAI, GenerativeModel } from '@google/generative-ai';
interface DesireSet {
wants: string[];
accepts: string[];
noGoes: string[];
}
export class LLMService {
private genAI: GoogleGenerativeAI;
private model: GenerativeModel;
constructor(apiKey: string) {
this.genAI = new GoogleGenerativeAI(apiKey);
this.model = this.genAI.getGenerativeModel({ model: "gemini-pro" });
}
async analyzeDesires(desireSets: DesireSet[]): Promise<Record<string, string>> {
const allDesires: string[] = [];
desireSets.forEach(set => {
allDesires.push(...set.wants, ...set.accepts, ...set.noGoes);
});
const uniqueDesires = Array.from(new Set(allDesires.filter(d => d.trim() !== '')));
if (uniqueDesires.length === 0) {
return {};
}
const prompt = `
You are an AI assistant that groups similar desires. Given a list of desires, identify semantically equivalent or very similar items and group them under a single, concise canonical name. Return the output as a JSON object where keys are the original desire strings and values are their canonical group names.
Example:
Input: ["go for a walk", "walking", "stroll in the park", "eat pizza", "pizza for dinner"]
Output: {"go for a walk": "Go for a walk", "walking": "Go for a walk", "stroll in the park": "Go for a walk", "eat pizza": "Eat pizza", "pizza for dinner": "Eat pizza"}
Here is the list of desires to group:
${JSON.stringify(uniqueDesires)}
`;
try {
const result = await this.model.generateContent(prompt);
const response = result.response;
const text = response.text();
return JSON.parse(text);
} catch (error) {
console.error("Error calling Gemini API:", error);
throw error;
}
}
}

254
backend/src/ws/index.ts Normal file
View File

@@ -0,0 +1,254 @@
import { WebSocketServer, WebSocket } from 'ws';
import { LLMService } from '../services/LLMService';
import { v4 as uuidv4 } from 'uuid';
// Types from the frontend
interface SemanticDesire {
title: string;
rawInputs: string[];
}
interface Decision {
goTos: SemanticDesire[];
alsoGoods: SemanticDesire[];
considerables: SemanticDesire[];
noGoes: SemanticDesire[];
}
// Define the SessionState enum
export enum SessionState {
SETUP = 'SETUP',
GATHERING = 'GATHERING',
HARMONIZING = 'HARMONIZING',
FINAL = 'FINAL',
}
// 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>
clients: Map<string, WebSocket>; // Maps the persistent Client ID to their active WebSocket connection object.
finalResult: any | null; // The result returned by the LLM.
}
export const sessions = new Map<string, SessionData>();
// Initialize LLM Service (API key from environment)
const llmService = new LLMService(process.env.GEMINI_API_KEY || '');
export const broadcastToSession = (sessionId: string, message: any, excludeClientId: string | null = null) => {
const sessionData = sessions.get(sessionId);
if (sessionData) {
sessionData.clients.forEach((client, clientId) => {
if (clientId !== excludeClientId && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
}
});
}
};
export const createWebSocketServer = (server: any) => {
const wss = new WebSocketServer({ server });
wss.on('connection', (ws, req) => {
const url = new URL(req.url || '', `http://${req.headers.host}`);
const sessionId = url.pathname.split('/').pop();
if (!sessionId) {
ws.close(1008, 'Invalid session ID');
return;
}
if (!sessions.has(sessionId)) {
sessions.set(sessionId, {
state: SessionState.SETUP,
topic: null,
expectedResponses: 0,
submittedCount: 0,
responses: new Map<string, any>(),
clients: new Map<string, WebSocket>(),
finalResult: null,
});
}
const sessionData = sessions.get(sessionId)!;
console.log(`Client connecting to session: ${sessionId}`);
ws.on('message', async (message) => {
const parsedMessage = JSON.parse(message.toString());
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;
}
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: sessionData } }));
}
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: { session: sessionData } });
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;
}
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: { session: sessionData } });
console.log(`Session ${sessionId} moved to HARMONIZING. Triggering LLM analysis.`);
try {
const allDesires = Array.from(sessionData.responses.values());
const canonicalMap = await llmService.analyzeDesires(allDesires);
const semanticDesiresMap = new Map<string, SemanticDesire>();
for (const originalDesire in canonicalMap) {
const canonicalName = canonicalMap[originalDesire];
if (!semanticDesiresMap.has(canonicalName)) {
semanticDesiresMap.set(canonicalName, { title: canonicalName, rawInputs: [] });
}
semanticDesiresMap.get(canonicalName)?.rawInputs.push(originalDesire);
}
const decision: Decision = {
goTos: [],
alsoGoods: [],
considerables: [],
noGoes: [],
};
const participantIds = Array.from(sessionData.responses.keys());
semanticDesiresMap.forEach(semanticDesire => {
let isNoGo = false;
let allWant = true;
let atLeastOneWant = false;
let allAcceptOrWant = true;
for (const pId of participantIds) {
const participantDesireSet = sessionData.responses.get(pId);
if (!participantDesireSet) continue;
const participantWants = new Set(participantDesireSet.wants.map((d: string) => canonicalMap[d] || d));
const participantAccepts = new Set(participantDesireSet.accepts.map((d: string) => canonicalMap[d] || d));
const participantNoGoes = new Set(participantDesireSet.noGoes.map((d: string) => canonicalMap[d] || d));
const canonicalTitle = semanticDesire.title;
if (participantNoGoes.has(canonicalTitle)) {
isNoGo = true;
break;
}
if (!participantWants.has(canonicalTitle)) {
allWant = false;
}
if (participantWants.has(canonicalTitle)) {
atLeastOneWant = true;
}
if (!participantWants.has(canonicalTitle) && !participantAccepts.has(canonicalTitle)) {
allAcceptOrWant = false;
}
}
if (isNoGo) {
decision.noGoes.push(semanticDesire);
} else if (allWant) {
decision.goTos.push(semanticDesire);
} else if (atLeastOneWant && allAcceptOrWant) {
decision.alsoGoods.push(semanticDesire);
} else if (atLeastOneWant || !allAcceptOrWant) {
decision.considerables.push(semanticDesire);
}
});
sessionData.finalResult = decision;
sessionData.state = SessionState.FINAL;
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: { session: sessionData } });
console.log(`Analysis complete for session ${sessionId}. Result:`, decision);
} catch (error) {
console.error(`Error during analysis for session ${sessionId}:`, error);
sessionData.state = SessionState.GATHERING;
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: { session: sessionData } });
}
} else {
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: { session: sessionData } });
}
} 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', () => {
let disconnectedClientId: string | null = null;
for (const [clientId, clientWs] of sessionData.clients.entries()) {
if (clientWs === ws) {
disconnectedClientId = clientId;
break;
}
}
if (disconnectedClientId) {
sessionData.clients.delete(disconnectedClientId);
console.log(`Client ${disconnectedClientId} disconnected from session: ${sessionId}. Remaining clients: ${sessionData.clients.size}`);
} else {
console.log(`An unregistered client disconnected from session: ${sessionId}.`);
}
if (sessionData.clients.size === 0) {
sessions.delete(sessionId);
console.log(`Session ${sessionId} closed and state cleared.`);
}
});
ws.on('error', (error) => {
console.error(`WebSocket error in session ${sessionId}:`, error);
});
});
return wss;
};

View File

@@ -0,0 +1,54 @@
import { GoogleGenerativeAI } from '@google/generative-ai';
import { LLMService } from '../src/services/LLMService';
// Mock the GoogleGenerativeAI class and its methods
jest.mock('@google/generative-ai', () => ({
GoogleGenerativeAI: jest.fn().mockImplementation(() => ({
getGenerativeModel: jest.fn().mockReturnValue({
generateContent: jest.fn().mockResolvedValue({
response: {
text: jest.fn().mockReturnValue(
JSON.stringify({
"item1": "Concept A",
"item2": "Concept A",
"item3": "Concept B"
})
),
},
}),
}),
})),
}));
describe('LLMService', () => {
let llmService: LLMService;
const mockApiKey = 'test-api-key';
beforeEach(() => {
llmService = new LLMService(mockApiKey);
});
it('should call the Gemini API with the correct prompt and return parsed content', async () => {
const desires = [
{ wants: ['item1'], accepts: [], noGoes: [] },
{ wants: ['item2'], accepts: [], noGoes: [] },
{ wants: [], accepts: ['item3'], noGoes: [] },
];
const result = await llmService.analyzeDesires(desires);
expect(GoogleGenerativeAI).toHaveBeenCalledWith(mockApiKey);
expect(llmService['model'].generateContent).toHaveBeenCalled();
expect(result).toEqual({
"item1": "Concept A",
"item2": "Concept A",
"item3": "Concept B"
});
});
it('should handle API errors gracefully', async () => {
llmService['model'].generateContent.mockRejectedValueOnce(new Error('API Error'));
await expect(llmService.analyzeDesires([])).rejects.toThrow('API Error');
});
});

View File

@@ -0,0 +1,79 @@
import request from 'supertest';
import express from 'express';
import { createServer } from 'http';
import { LLMService } from '../src/services/LLMService';
// Mock the LLMService
jest.mock('../src/services/LLMService');
// 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 });
});
describe('POST /sessions', () => {
it('should create a new session and return a session ID', async () => {
const response = await request(app)
.post('/sessions')
.send();
expect(response.status).toBe(201);
expect(response.body).toHaveProperty('sessionId');
expect(typeof response.body.sessionId).toBe('string');
});
});
describe('POST /sessions/:sessionId/analyze', () => {
it('should trigger analysis for a valid session', async () => {
const mockDesires = [
{ wants: ['item1'], accepts: [], noGoes: [] },
{ wants: ['item2'], accepts: [], noGoes: [] },
];
// Mock the analyzeDesires method to return a predictable result
(LLMService as jest.Mock).mockImplementation(() => ({
analyzeDesires: jest.fn().mockResolvedValue({
"item1": "Concept A",
"item2": "Concept A"
}),
}));
const response = await request(app)
.post('/sessions/test-session-id/analyze')
.send({ allDesires: mockDesires });
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"
});
});
it('should return 404 if session is not found', async () => {
const response = await request(app)
.post('/sessions/non-existent-session/analyze')
.send({ allDesires: [] });
expect(response.status).toBe(404);
});
});

14
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}

16
docker-compose.yaml Normal file
View File

@@ -0,0 +1,16 @@
version: '3.8'
services:
frontend:
build:
context: ./frontend
ports:
- "3000:80"
backend:
build:
context: ./backend
ports:
- "8000:8000"
environment:
- GEMINI_API_KEY=${GEMINI_API_KEY}

0
frontend/.dockerignore Normal file
View File

31
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# Stage 1: Build the React application
FROM node:18-alpine AS build
WORKDIR /app
# Copy package.json and package-lock.json
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the application source code
COPY . .
# Build the application
RUN npm run build
# Stage 2: Serve the application with Nginx
FROM nginx:1.21-alpine
# Copy the built application from the build stage
COPY --from=build /app/build /usr/share/nginx/html
# Copy a custom Nginx configuration if needed (optional)
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80
EXPOSE 80
# Start Nginx
CMD ["nginx", "-g", "daemon off;"]

16
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,16 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html index.htm;
location / {
try_files $uri $uri/ /index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

21022
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
frontend/package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "unisono",
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/material": "^5.14.18",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.61",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"axios": "^1.6.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.19.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"uuid": "^9.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/uuid": "^9.0.7"
}
}

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="A real-time app for collaborative decision-making."
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Unisono</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

24
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,24 @@
import React from 'react';
import { ThemeProvider, CssBaseline } from '@mui/material';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import theme from './theme';
import CreateSession from './pages/CreateSession';
import SessionPage from './pages/SessionPage';
function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Router>
<Routes>
<Route path="/" element={<CreateSession />} />
{/* Other routes will be added here */}
<Route path="/session/:sessionId" element={<SessionPage />} />
</Routes>
</Router>
</ThemeProvider>
);
}
export default App;

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import DesireForm from './DesireForm';
test('renders desire submission form with input fields and submit button', () => {
render(<DesireForm onSubmit={() => {}} />);
// Check for headings/labels for each category
expect(screen.getByLabelText(/What you WANT/i)).toBeInTheDocument();
expect(screen.getByLabelText(/What you ACCEPT/i)).toBeInTheDocument();
expect(screen.getByLabelText(/What you DO NOT WANT/i)).toBeInTheDocument();
// Check for the submit button
const submitButton = screen.getByRole('button', { name: /Submit Desires/i });
expect(submitButton).toBeInTheDocument();
});

View File

@@ -0,0 +1,87 @@
import React, { useState } from 'react';
import { TextField, Button, Box, Typography } from '@mui/material';
interface DesireFormProps {
onSubmit: (desires: { wants: string[], accepts: string[], noGoes: string[] }) => void;
}
const DesireForm: React.FC<DesireFormProps> = ({ onSubmit }) => {
const [wants, setWants] = useState('');
const [accepts, setAccepts] = useState('');
const [noGoes, setNoGoes] = useState('');
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
const parsedWants = wants.split('\n').map(s => s.trim()).filter(s => s);
const parsedAccepts = accepts.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
if (parsedWants.length === 0 && parsedAccepts.length === 0 && parsedNoGoes.length === 0) {
alert('Please enter at least one desire in any category.');
return;
}
// FR-016: System MUST validate a user's submission to prevent the same item from appearing in conflicting categories
const allItems = [...parsedWants, ...parsedAccepts, ...parsedNoGoes];
const uniqueItems = new Set(allItems);
if (allItems.length !== uniqueItems.size) {
alert('You have conflicting desires (same item in different categories). Please resolve.');
return;
}
onSubmit({
wants: parsedWants,
accepts: parsedAccepts,
noGoes: parsedNoGoes,
});
};
return (
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 3 }}>
<Typography variant="h6" gutterBottom>What you WANT</Typography>
<TextField
label="List items you want (one per line)"
multiline
rows={4}
fullWidth
value={wants}
onChange={(e) => setWants(e.target.value)}
margin="normal"
/>
<Typography variant="h6" gutterBottom sx={{ mt: 4 }}>What you ACCEPT</Typography>
<TextField
label="List items you accept (one per line)"
multiline
rows={4}
fullWidth
value={accepts}
onChange={(e) => setAccepts(e.target.value)}
margin="normal"
/>
<Typography variant="h6" gutterBottom sx={{ mt: 4 }}>What you DO NOT WANT</Typography>
<TextField
label="List items you absolutely do not want (one per line)"
multiline
rows={4}
fullWidth
value={noGoes}
onChange={(e) => setNoGoes(e.target.value)}
margin="normal"
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Submit Desires
</Button>
</Box>
);
};
export default DesireForm;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import ResultsDisplay from './ResultsDisplay';
import { Decision } from '../hooks/useSession';
const mockDecision: Decision = {
goTos: [{ title: 'Go to the beach', rawInputs: ['beach'] }],
alsoGoods: [{ title: 'Eat pizza', rawInputs: ['pizza'] }],
considerables: [{ title: 'Watch a movie', rawInputs: ['movie'] }],
noGoes: [{ title: 'Stay home', rawInputs: ['home'] }],
};
describe('ResultsDisplay', () => {
it('renders all categories correctly', () => {
render(<ResultsDisplay decision={mockDecision} />);
expect(screen.getByText('Go-to')).toBeInTheDocument();
expect(screen.getByText('Go to the beach')).toBeInTheDocument();
expect(screen.getByText('Also good')).toBeInTheDocument();
expect(screen.getByText('Eat pizza')).toBeInTheDocument();
expect(screen.getByText('Considerable')).toBeInTheDocument();
expect(screen.getByText('Watch a movie')).toBeInTheDocument();
expect(screen.getByText('No-goes')).toBeInTheDocument();
expect(screen.getByText('Stay home')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { Box, Typography, List, ListItem, ListItemText, Collapse, IconButton } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { Decision, SemanticDesire } from '../hooks/useSession';
interface ResultsDisplayProps {
decision: Decision;
}
const CategorySection: React.FC<{ title: string; desires: SemanticDesire[]; defaultExpanded?: boolean }>
= ({ title, desires, defaultExpanded = true }) => {
const [expanded, setExpanded] = React.useState(defaultExpanded);
if (!desires || desires.length === 0) {
return null;
}
return (
<Box sx={{ mt: 3, border: '1px solid #e0e0e0', borderRadius: '4px', p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" component="h2" gutterBottom>
{title}
</Typography>
<IconButton onClick={() => setExpanded(!expanded)} size="small">
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Box>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<List dense>
{desires.map((desire, index) => (
<ListItem key={index}>
<ListItemText primary={desire.title} />
</ListItem>
))}
</List>
</Collapse>
</Box>
);
};
const ResultsDisplay: React.FC<ResultsDisplayProps> = ({ decision }) => {
if (!decision) {
return <Typography>No decision available yet.</Typography>;
}
return (
<Box sx={{ mt: 4 }}>
<Typography variant="h5" component="h1" gutterBottom>
Cooperative Decision
</Typography>
<CategorySection title="Go-to" desires={decision.goTos} />
<CategorySection title="Also good" desires={decision.alsoGoods} />
<CategorySection title="Considerable" desires={decision.considerables} defaultExpanded={false} />
<CategorySection title="No-goes" desires={decision.noGoes} />
</Box>
);
};
export default ResultsDisplay;

View File

@@ -0,0 +1,115 @@
import { useState, useEffect, useCallback, Dispatch, SetStateAction } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { webSocketService } from '../services/websocket'; // Import the websocket service
// Define the types for the session state based on data-model.md
// In a real app, these would be in a separate types file.
export interface Participant {
participantId: string; // This will now be the clientId
isCreator: boolean;
hasSubmitted: boolean;
}
export interface DesireSet {
participantId: string;
wants: string[];
accepts: string[];
noGoes: string[];
}
export interface SemanticDesire {
title: string;
rawInputs: string[];
}
export interface Decision {
goTos: SemanticDesire[];
alsoGoods: SemanticDesire[];
considerables: SemanticDesire[];
noGoes: SemanticDesire[];
}
// Define the SessionState enum (mirroring backend)
export enum SessionState {
SETUP = 'SETUP',
GATHERING = 'GATHERING',
HARMONIZING = 'HARMONIZING',
FINAL = 'FINAL',
}
export interface Session {
sessionId: string;
state: SessionState;
expectedResponses: number;
submittedCount: number;
responses: { [clientId: string]: boolean }; // Map of clientId to a boolean indicating if they submitted
finalResult: Decision | null;
topic?: string; // This might be part of the initial setup payload
}
// Utility to generate a persistent client ID
const getOrCreateClientId = (): string => {
let clientId = localStorage.getItem('clientId');
if (!clientId) {
clientId = uuidv4();
localStorage.setItem('clientId', clientId);
}
return clientId;
};
export const useSession = (sessionId: string): [Session | null, Dispatch<SetStateAction<Session | null>>, (message: any) => void, string] => {
const clientId = getOrCreateClientId(); // Get or create clientId
const getInitialState = useCallback((): Session | null => {
try {
const item = window.localStorage.getItem(`session-${sessionId}`);
return item ? JSON.parse(item) : null;
} catch (error) {
console.error('Error reading from localStorage', error);
return null;
}
}, [sessionId]);
const [session, setSession] = useState<Session | null>(getInitialState);
useEffect(() => {
// Connect to WebSocket
webSocketService.connect(sessionId, clientId);
// Handle incoming messages
const handleMessage = (message: any) => {
console.log('useSession: Processing incoming message:', message);
if (message.type === 'STATE_UPDATE') {
setSession(message.payload.session);
} else if (message.type === 'ERROR') {
console.error('WebSocket Error:', message.payload.message);
// Optionally, handle error display to the user
}
// Add other message types as needed
};
webSocketService.onMessage(handleMessage);
// Clean up on unmount
return () => {
webSocketService.removeMessageHandler(handleMessage);
webSocketService.disconnect();
};
}, [sessionId, clientId]); // Re-run effect if sessionId or clientId changes
useEffect(() => {
try {
if (session) {
window.localStorage.setItem(`session-${sessionId}`, JSON.stringify(session));
}
} catch (error) {
console.error('Error writing to localStorage', error);
}
}, [session, sessionId]);
const sendMessage = useCallback((message: any) => {
webSocketService.sendMessage(message);
}, []);
return [session, setSession, sendMessage, clientId];
};

12
frontend/src/index.tsx Normal file
View File

@@ -0,0 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import CreateSession from './CreateSession';
test('renders create session page with a form', () => {
render(<CreateSession />);
// Check for a heading
const headingElement = screen.getByText(/Create a New Session/i);
expect(headingElement).toBeInTheDocument();
// Check for form fields
const topicInput = screen.getByLabelText(/Topic/i);
expect(topicInput).toBeInTheDocument();
const participantsInput = screen.getByLabelText(/Number of Participants/i);
expect(participantsInput).toBeInTheDocument();
// Check for the create button
const createButton = screen.getByRole('button', { name: /Create Session/i });
expect(createButton).toBeInTheDocument();
});

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { Box, Button, Typography, Container } from '@mui/material';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
const CreateSession = () => {
const navigate = useNavigate();
const handleCreate = async () => {
try {
const response = await axios.post('http://localhost:8000/sessions');
const { sessionId } = response.data;
navigate(`/session/${sessionId}`);
} catch (error) {
console.error('Error creating session:', error);
}
};
return (
<Container maxWidth="sm">
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography component="h1" variant="h5">
Unisono
</Typography>
<Box sx={{ mt: 3 }}>
<Button
type="button"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
onClick={handleCreate}
>
Create a New Session
</Button>
</Box>
</Box>
</Container>
);
};
export default CreateSession;

View File

@@ -0,0 +1,132 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { Container, Typography, Box, CircularProgress, Alert, TextField, Button } from '@mui/material';
import { useSession, Session, Participant, DesireSet, Decision, SessionState } from '../hooks/useSession';
import { webSocketService } from '../services/websocket';
import DesireForm from '../components/DesireForm';
import ResultsDisplay from '../components/ResultsDisplay';
const SessionPage = () => {
const { sessionId } = useParams<{ sessionId: string }>();
const [session, setSession, sendMessage, clientId] = useSession(sessionId || '');
const [error, setError] = useState<string | null>(null);
const [expectedResponses, setExpectedResponses] = useState(2);
const [topic, setTopic] = useState('');
const handleSetupSession = () => {
sendMessage({ type: 'SETUP_SESSION', payload: { expectedResponses, topic } });
};
const handleSubmitDesires = (desires: { wants: string[], accepts: string[], noGoes: string[] }) => {
if (!session || !clientId) return;
const desireSet: DesireSet = {
participantId: clientId, // Use the clientId from the hook
wants: desires.wants,
accepts: desires.accepts,
noGoes: desires.noGoes,
};
sendMessage({
type: 'SUBMIT_RESPONSE',
payload: { response: desireSet },
});
};
if (!session) {
return <Typography>Loading session...</Typography>;
}
const remainingResponses = session.expectedResponses - session.submittedCount;
const hasSubmittedCurrentParticipant = session.responses && session.responses[clientId];
return (
<Container maxWidth="md">
<Box sx={{ mt: 4 }}>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Typography variant="h4" component="h1" gutterBottom>
Session: {session.topic || session.sessionId}
</Typography>
{session.state === SessionState.SETUP && (
<Box sx={{ mt: 4, p: 3, border: '1px dashed grey', borderRadius: '4px', textAlign: 'center' }}>
<Typography variant="h6" component="p" gutterBottom>
Set Up the Session
</Typography>
<TextField
margin="normal"
fullWidth
id="topic"
label="Session Topic"
name="topic"
autoFocus
value={topic}
onChange={(e) => setTopic(e.target.value)}
/>
<TextField
margin="normal"
required
fullWidth
name="expectedResponses"
label="Number of Expected Responses"
type="number"
id="expectedResponses"
value={expectedResponses}
onChange={(e) => setExpectedResponses(parseInt(e.target.value, 10))}
InputProps={{
inputProps: { min: 1 }
}}
/>
<Button
type="button"
variant="contained"
sx={{ mt: 2 }}
onClick={handleSetupSession}
>
Start Session
</Button>
</Box>
)}
{session.state !== SessionState.SETUP && (
<>
<Typography variant="h6" gutterBottom>
Expected Responses: {session.expectedResponses}
</Typography>
<Typography variant="body1" gutterBottom>
Status: {session.state}
</Typography>
</>
)}
{session.state === SessionState.GATHERING && !hasSubmittedCurrentParticipant && (
<DesireForm onSubmit={handleSubmitDesires} />
)}
{session.state === SessionState.GATHERING && hasSubmittedCurrentParticipant && (
<Box sx={{ mt: 4, p: 3, border: '1px dashed grey', borderRadius: '4px', textAlign: 'center' }}>
<Typography variant="h6" component="p">
Waiting for {remainingResponses} more responses...
</Typography>
<Typography variant="body2" color="text.secondary">
Your desires have been submitted. The results will be calculated once all participants have responded.
</Typography>
</Box>
)}
{session.state === SessionState.HARMONIZING && (
<Box sx={{ mt: 4, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<CircularProgress />
<Typography variant="h6" sx={{ mt: 2 }}>Harmonizing Desires...</Typography>
</Box>
)}
{session.state === SessionState.FINAL && session.finalResult && (
<ResultsDisplay decision={session.finalResult} />
)}
</Box>
</Container>
);
};
export default SessionPage;

View File

@@ -0,0 +1,85 @@
class WebSocketService {
private ws: WebSocket | null = null;
private messageHandlers: ((message: any) => void)[] = [];
private errorHandlers: ((error: Event) => void)[] = [];
private currentSessionId: string | null = null;
private currentClientId: string | null = null;
connect(sessionId: string, clientId: string) {
// Prevent multiple connections
if (this.ws) {
return;
}
this.currentSessionId = sessionId;
this.currentClientId = clientId;
const wsUrl = `ws://localhost:8000/sessions/${sessionId}`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('WebSocket connected');
// Directly send registration message on open
this.sendMessage({ type: 'REGISTER_CLIENT' });
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
console.log('WebSocketService: Received and parsed message:', message);
this.messageHandlers.forEach(handler => handler(message));
} catch (error) {
console.error('Error parsing incoming message:', error);
}
};
this.ws.onclose = () => {
console.log('WebSocket disconnected');
this.ws = null;
this.currentSessionId = null;
this.currentClientId = null;
};
this.ws.onerror = (event) => {
console.error('WebSocket error:', event);
this.errorHandlers.forEach(handler => handler(event));
};
}
disconnect() {
if (this.ws) {
this.ws.close();
}
}
sendMessage(message: any) {
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.currentClientId && this.currentSessionId) {
const messageToSend = {
...message,
clientId: this.currentClientId,
sessionId: this.currentSessionId,
};
this.ws.send(JSON.stringify(messageToSend));
} else {
// This error can be ignored if it happens during initial connection in StrictMode
// console.error('WebSocket is not connected or clientId/sessionId is missing.');
}
}
onMessage(handler: (message: any) => void) {
this.messageHandlers.push(handler);
}
removeMessageHandler(handler: (message: any) => void) {
this.messageHandlers = this.messageHandlers.filter(h => h !== handler);
}
onError(handler: (error: Event) => void) {
this.errorHandlers.push(handler);
}
removeErrorHandler(handler: (error: Event) => void) {
this.errorHandlers = this.errorHandlers.filter(h => h !== handler);
}
}
export const webSocketService = new WebSocketService();

21
frontend/src/theme.ts Normal file
View File

@@ -0,0 +1,21 @@
import { createTheme } from '@mui/material/styles';
// A basic theme for the application
const theme = createTheme({
palette: {
primary: {
main: '#6750A4', // A Material Design 3 primary color
},
secondary: {
main: '#958DA5',
},
background: {
default: '#F3F4F6', // A light grey background
},
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
},
});
export default theme;

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Anonymous Desire Aggregator
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2025-10-09
**Feature**: [../spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items have been validated and passed. The specification is ready for the next phase.

View File

@@ -0,0 +1,90 @@
openapi: 3.0.0
info:
title: Anonymous Desire Aggregator API
version: 2.0.0
description: API for facilitating real-time, private, anonymous decision-making sessions.
paths:
/sessions:
post:
summary: Get a new unique session ID
operationId: createSession
responses:
'201':
description: Session ID created successfully.
content:
application/json:
schema:
type: object
properties:
sessionId:
type: string
format: uuid
description: The unique ID for the session. Clients will use this to join the WebSocket channel.
/sessions/{sessionId}/analyze:
post:
summary: Trigger the semantic analysis for a session
operationId: triggerAnalysis
parameters:
- name: sessionId
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
allDesires:
type: array
description: A complete list of all desire sets from all participants, collected by the client.
items:
type: object
properties:
wants:
type: array
items:
type: string
accepts:
type: array
items:
type: string
noGoes:
type: array
items:
type: string
responses:
'202':
description: Analysis has been successfully triggered. Results will be broadcast over the WebSocket.
'404':
description: Session not found.
# WebSocket Protocol (/sessions/{sessionId})
# The primary communication for this application is via WebSockets. The backend acts as a message relay and orchestrator.
#
# Client-to-Server Messages:
#
# - type: 'SHARE_STATE'
# payload: { session: Session } # Client sends its entire session object to sync with others.
#
# - type: 'SUBMIT_DESIRES'
# payload: { desireSet: DesireSet } # A single user submits their desires.
#
# Server-to-Client Messages:
#
# - type: 'USER_JOINED'
# payload: { participantId: string }
#
# - type: 'STATE_UPDATE'
# payload: { session: Session } # Broadcasts the latest session state to all clients.
#
# - type: 'ANALYSIS_COMPLETE'
# payload: { decision: Decision } # Broadcasts the final results.
#
# - type: 'SESSION_LOCKED'
# payload: {}

View File

@@ -0,0 +1,71 @@
# Data Model
**Feature**: Anonymous Desire Aggregator
**Date**: 2025-10-09
This document defines the key data entities for the feature, with notes on where the state is managed.
---
### 1. Session
Represents a single decision-making event. **Note**: This object lives in the browser's Local Storage and is synchronized between clients via WebSocket messages.
| Attribute | Type | Description | Notes |
|---|---|---|---|
| `sessionId` | String | Unique identifier for the session. | The only piece of session data the backend is aware of. |
| `topic` | String | The subject of the session. | Set by the creator client. |
| `participantCount` | Integer | The number of expected participants. | Set by the creator client. |
| `status` | Enum | The current state of the session. | `WAITING`, `ANALYZING`, `COMPLETE`, `LOCKED`. |
| `participants` | Array\[Participant] | List of participants in the session. | Managed by clients. |
| `desireSets` | Array\[DesireSet] | List of all desire sets submitted by participants. | Managed by clients. |
| `decision` | Decision | The final calculated result. | Received from backend and stored by clients. |
---
### 2. Participant
Represents an anonymous user in the session. **Note**: Lives within the `Session` object in Local Storage.
| Attribute | Type | Description |
|---|---|---|
| `participantId` | String | Unique identifier for the participant (e.g., a random ID generated by the client). |
| `isCreator` | Boolean | Flag to identify the participant who initiated the session. |
| `hasSubmitted` | Boolean | Flag to indicate if the participant has submitted their desires. |
---
### 3. Desire Set
Represents the raw text input from a single participant. **Note**: Lives within the `Session` object in Local Storage.
| Attribute | Type | Description |
|---|---|---|
| `participantId` | String | The ID of the participant who submitted this set. |
| `wants` | Array\[String] | List of raw text desires the user wants. |
| `accepts` | Array\[String] | List of raw text desires the user accepts. |
| `noGoes` | Array\[String] | List of raw text desires the user does not want. |
---
### 4. Semantic Desire
Represents a unique conceptual desire identified by the LLM analysis. **Note**: This is a read-only, transient data structure. It exists in the `Decision` object.
| Attribute | Type | Description |
|---|---|---|
| `title` | String | The canonical name for the desire group. |
| `rawInputs` | Array\[String] | The list of original user text inputs that were grouped into this desire. |
---
### 5. Decision
Represents the final, categorized output of the analysis. **Note**: Lives within the `Session` object in Local Storage.
| Attribute | Type | Description |
|---|---|---|
| `goTos` | Array\[Semantic Desire] | Desires everyone wants. |
| `alsoGoods` | Array\[Semantic Desire] | Desires some want and others accept. |
| `considerables` | Array\[Semantic Desire] | Other desires that were wanted or accepted. |
| `noGoes` | Array\[Semantic Desire] | Desires that at least one person does not want. |

View File

@@ -0,0 +1,80 @@
# Implementation Plan: Anonymous Desire Aggregator
**Branch**: `001-people-tend-to` | **Date**: 2025-10-09 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/001-people-tend-to/spec.md`
## Summary
This plan outlines the implementation of a highly private, real-time web application for anonymous group decision-making. The architecture is client-centric, using browser Local Storage for session state, synchronized between participants via a Node.js WebSocket server. Per the user's directive, the backend will integrate with a cloud-based LLM, **Google's Gemini 2.0 Flash**, to meet the requirement for a powerful, free-to-use, and limitless model for semantic analysis. The application will be containerized with Docker and fully adheres to the project constitution.
## Technical Context
**Language/Version**: Node.js (LTS), TypeScript 5.x
**Primary Dependencies**:
- **Backend**: Express.js, `ws` (for WebSockets), `@google/generative-ai`
- **Frontend**: React 18, Material-UI (MUI) v5+
**Storage**: Browser Local Storage (Primary store for session state). The backend is stateless.
**Testing**: Jest
**Target Platform**: Docker Containers
**Project Type**: Web Application (Backend + Frontend)
**Privacy Mandate**: The backend MUST NOT log or persist the content of user desires. All session data on the backend is ephemeral and exists only in memory during the transient call to the LLM API.
**Performance Goals**: As per spec SC-001, SC-003.
**Constraints**: Relies on the availability and performance of the external "Gemini 2.0 Flash" API.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- **I. Defined Technology Stack**: **PASS**
- **II. UI/UX Consistency**: **PASS**
- **III. Container-First Development**: **PASS**
- **IV. Test-Driven Development (TDD)**: **PASS**
- **V. API-First Design**: **PASS**
**Result**: All constitutional gates passed.
## Project Structure
### Documentation (this feature)
```
specs/001-people-tend-to/
├── spec.md # Feature specification
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
│ └── openapi.yaml
└── tasks.md # Phase 2 output (via /speckit.tasks)
```
### Source Code (repository root)
```
backend/
├── src/
│ ├── services/ # LLM integration service
│ ├── ws/ # WebSocket server logic for message relay
│ └── index.ts # App entrypoint
├── tests/
└── Dockerfile
frontend/
├── src/
│ ├── components/
│ ├── pages/
│ ├── services/ # WebSocket communication service
│ ├── hooks/ # Custom hooks for managing local storage state
│ └── App.tsx # Main React component
├── tests/
└── Dockerfile
docker-compose.yaml # To orchestrate both services
```
**Structure Decision**: A "Web application" structure with a separate frontend and backend is the ideal choice. The backend is a lightweight Node.js service responsible for WebSocket message relay and orchestrating calls to the external LLM API.
## Complexity Tracking
*No constitutional violations were identified. This section is not required.*

View File

@@ -0,0 +1,53 @@
# Quickstart Guide
**Feature**: Anonymous Desire Aggregator
**Date**: 2025-10-09
This guide provides instructions to set up and run the project locally using Docker.
## Prerequisites
- Docker and Docker Compose must be installed on your system.
- You must have a Google AI API key for the Gemini model family.
## Setup
1. **Clone the Repository**
```bash
git clone <repository-url>
cd <repository-directory>
```
2. **Configure Environment Variables**
Create a `.env` file in the `backend/` directory:
```
backend/.env
```
Add your Google AI API key to this file:
```
GEMINI_API_KEY="your_api_key_here"
```
## Running the Application
1. **Build and Run Containers**
From the root of the project, run the following command:
```bash
docker-compose up --build
```
This will build the Docker images for both the frontend and backend services and start them.
2. **Access the Application**
- The **frontend** will be available at `http://localhost:3000`.
- The **backend** API will be served at `http://localhost:8000`.
You can now open your web browser to `http://localhost:3000` to use the application.

View File

@@ -0,0 +1,23 @@
# Research & Decisions
**Feature**: Anonymous Desire Aggregator
**Date**: 2025-10-09
## 1. Semantic Analysis Provider
- **Decision**: Per the user's directive, the project will use **Google's Gemini 2.0 Flash** model, accessed via the `@google/generative-ai` Node.js library.
- **Rationale**: This decision aligns with the user's specific requirement for a powerful, cloud-based, free-to-use LLM without usage limits. Using a managed cloud service simplifies the backend architecture significantly, removing the need to maintain a self-hosted model. The `Gemini Flash` family of models is designed for high speed and efficiency, making it suitable for a real-time application. This approach maintains the privacy-first architecture by ensuring user data is only held transiently in the backend's memory during the API call and is never persisted on the server.
- **Alternatives Considered**:
- **Self-Hosting Open-Source Models**: Rejected as this contradicts the user's explicit choice of a specific cloud-based model.
- **Other Cloud Providers**: Rejected to adhere to the user's specific directive to use a Google Gemini model.
## 2. Integration Best Practices
- **Decision**: The Node.js backend will contain a dedicated, stateless `LLMService` responsible for all communication with the Gemini API.
- **Implementation Details**:
- **Prompt Engineering**: The service will construct a structured prompt instructing the Gemini model to perform semantic clustering on the list of raw text desires. The prompt will request a JSON object as output that maps each unique desire to a canonical group name.
- **API Key Management**: The Google AI API key will be managed securely as an environment variable in the backend Docker container and will not be exposed to the frontend.
- **Resiliency**: The service must implement error handling, including retries with exponential backoff for transient network errors and proper error reporting to the client if the LLM API call fails permanently.

View File

@@ -0,0 +1,116 @@
# Feature Specification: Anonymous Desire Aggregator
**Feature Branch**: `001-people-tend-to`
**Created**: 2025-10-09
**Status**: Draft
**Input**: User description: "People tend to not wanting to discuss their choices when agreeng on some common activity with others. For example, lovers might be ashamed to discuss some things straightforward before having sex. But they could privately state what they want and then blindly share their desires. The app will understand where they totally agree, where they partially agree, where they accept any suggestions, and where they totally disagree. The first person in a session states the topic and how many answers to anticipate, and shares the link to the session by copying it. One online session, totally anonymous and private, summons persons presenting their desires. Users write what they want, what they accept, and what they do not want at all - and submit the form. The system waits for all the answers and analyzes them with AI. While waiting for the results user sees some placeholder informing that the work is in progress: waiting for X more answers, analyzing results etc. Then the system presents the results: a cooperative decision based on everyone's opinions. The decision includes everything that participants want if everyone wants it too marking this part as `GoTos`. Things someone wants and everyone else just accept are in the category `AlsoGoods`. Things that are just wanted or accepted by some participants are in the category of their own - `Considerables`. The decision respects everything that at least one user does not want at all stating it as `No-goes` and excluding from every other category. The result is available by the link as long as the session is alive synching from local storage of participants via websockets."
## Clarifications
### Session 2025-10-09
- Q: How should the system handle variations in user-submitted desire items to ensure accurate aggregation? → A: Some LLM will understand the sense, analyze and combine resluts. Not just survey.
- Q: How should the system behave if more participants join and submit than the number originally specified by the session creator? → A: Lock the Session: Once the expected number of submissions is reached, no more submissions are allowed. Additional users see a "Session full" message.
- Q: How should the system handle a submission where one or more of the desire categories are empty? → A: Require At Least One Item: The user must enter at least one item in at least one of the categories. They cannot submit a completely blank form.
- Q: How should the "Considerable" category be refined to ensure it provides a useful signal to the group? → A: Just collapse this catogory in GUI giving it less attention.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Create and Share a Session (Priority: P1)
As a user, I want to create a new session by providing a topic and the number of expected participants, so I can receive a unique link to share with others for anonymously gathering their preferences.
**Why this priority**: This is the starting point for the entire workflow. Without the ability to create a session, no other functionality can be used.
**Independent Test**: Can be fully tested by creating a session and verifying that a unique, shareable link is generated and the session is ready for participants.
**Acceptance Scenarios**:
1. **Given** I am on the application's home page, **When** I enter a topic "Dinner Plans" and set the number of participants to 4, **Then** the system should generate a unique URL, display it, and provide a "copy to clipboard" button.
2. **Given** a session has been created, **When** I visit the generated URL, **Then** I should see the session's topic and the desire submission form.
---
### User Story 2 - Participate in a Session (Priority: P2)
As a user with a session link, I want to submit my preferences by listing items I "Want", "Accept", or "Do Not Want", so that my input is included in the final decision.
**Why this priority**: This is the core data-gathering function. The value of the application depends on participants being able to submit their desires easily and anonymously.
**Independent Test**: Can be tested by navigating to a session link, filling out and submitting the desire form, and verifying the submission is recorded.
**Acceptance Scenarios**:
1. **Given** I have a valid session link and the session is waiting for participants, **When** I enter items into the "Want", "Accept", and "Do Not Want" fields and press "Submit", **Then** the system should confirm my submission and update the session status.
2. **Given** I have submitted my desires, **When** I revisit the session link, **Then** the system should show me a waiting screen indicating how many more participants are needed.
---
### User Story 3 - View Aggregated Results (Priority: P3)
As a session participant, I want to see the final, categorized results after everyone has submitted their desires, so that our group can easily identify common ground and make a cooperative decision.
**Why this priority**: This is the "payoff" for the users. It delivers the promised value of a synthesized, cooperative decision, which is the main reason for using the app.
**Independent Test**: Can be tested by creating a session, having all participants submit their desires, and verifying that the final results are displayed correctly according to the aggregation logic.
**Acceptance Scenarios**:
1. **Given** all participants in a session have submitted their desires, **When** I visit the session link, **Then** I should see the results screen with the categories: "GoTos", "AlsoGoods", "Considerables", and "NoGoes".
2. **Given** the results are displayed, **When** an item was marked "Do Not Want" by at least one person, **Then** it must appear in the "NoGoes" list and nowhere else.
3. **Given** the results are displayed, **When** an item was marked "Want" by all participants, **Then** it must appear in the "GoTos" list.
---
### Edge Cases
- What happens if a user provides the same item in multiple categories (e.g., in both "Want" and "Do Not Want")? (Addressed by FR-016)
- What happens if a user tries to submit their desires more than once for the same session? (Addressed by FR-017)
- What happens if the number of participants who join via the link exceeds the number set by the creator? (Addressed by FR-019)
- What happens if a session link is accessed after the session is considered "closed" or has expired? (Addressed by FR-018)
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST allow a user to create a new session by specifying a topic and the number of expected participants.
- **FR-002**: System MUST generate a unique, shareable URL for each new session.
- **FR-003**: System MUST allow any user with the session URL to participate anonymously without requiring login or registration.
- **FR-004**: System MUST provide a form for participants to submit desires as free-form text into three distinct categories: "Want", "Accept", and "NoGoes".
- **FR-005**: System MUST display a real-time status indicating how many participants are still needed before results can be calculated.
- **FR-006**: System MUST begin the analysis process only after the specified number of participants have submitted their desires.
- **FR-007**: System MUST display a status to users indicating that analysis is in progress.
- **FR-008**: System MUST use semantic analysis (e.g., via an LLM) to interpret raw text inputs and group them into unique, semantically equivalent `Semantic Desires`.
- **FR-009**: The system MUST calculate the final decision based on the aggregation of these `Semantic Desires`.
- **FR-010**: A `Semantic Desire` MUST be categorized as "NoGoes" if at least one participant includes it in their "Do Not Want" list. This category takes precedence over all others.
- **FR-011**: A `Semantic Desire` MUST be categorized as "GoTos" if it is not a "NoGoes" and all participants include it in their "Want" list.
- **FR-012**: A `Semantic Desire` MUST be categorized as "AlsoGoods" if it is not a "NoGoes" or "GoTos", is in at least one participant's "Want" list, and all other participants have it in either their "Want" or "Accept" lists.
- **FR-013**: A `Semantic Desire` MUST be categorized as "Considerables" if it is not in any other category but is present in at least one participant's "Want" or "Accept" list.
- **FR-014**: System MUST display the final, categorized results to all participants in the session.
- **FR-015**: The results view MUST be updated in real-time for all participants.
- **FR-016**: System MUST validate a user's submission to prevent the same item from appearing in conflicting categories (e.g., in both "Want" and "Do Not Want"). If a conflict is detected, the system MUST display a clear, inline error message next to the conflicting input fields, highlighting the problematic items and preventing form submission until all conflicts are resolved by the user.
- **FR-017**: System MUST allow each participant to submit their desires only once. Participant identity MUST be established and persisted using a client-side generated UUID stored in the browser's Local Storage. If a user attempts to submit again from the same client (identified by the stored UUID), the system MUST inform them that their submission has already been recorded and prevent resubmission.
- **FR-018**: A session MUST remain active and its results accessible as long as at least one participant maintains an active WebSocket connection to the session. The session and its data MUST be terminated from the backend's active memory 5 minutes after the last participant disconnects. Frontend clients should handle this by displaying a "Session Expired" message and redirecting to the home page.
- **FR-019**: Once the number of submitted desire sets equals the expected number of participants, the session MUST be automatically locked. The submission form MUST be disabled, and any user attempting to access it MUST be shown a clear message indicating "Session Full" or "Analysis in Progress" (depending on the current state), preventing further submissions.
- **FR-020**: The system MUST require a user to enter at least one desire in at least one of the three categories ("Want", "Accept", "Do Not Want") before a submission can be accepted. Completely empty submissions MUST be rejected, and the system MUST display an inline error message prompting the user to fill in at least one category.
- **FR-021**: On the results screen, the "Considerables" category MUST be presented in a way that gives it less prominence than "GoTos" and "AlsoGoods", for instance, by being collapsed by default.
### Key Entities *(include if feature involves data)*
- **Session**: Represents a single decision-making event.
- **Attributes**: Topic (text), Expected Participant Count (number), Unique ID/URL (string), Status (e.g., "Waiting", "Analyzing", "Complete").
- **Participant**: Represents an anonymous user within a given session.
- **Desire Set**: Represents a single participant's raw text submission.
- **Attributes**: Wants (list of strings), Accepts (list of strings), Do Not Wants (list of strings).
- **Semantic Desire**: Represents a unique conceptual desire identified by the system's semantic analysis of the raw text in `Desire Sets`. For example, the text inputs "go for a run", "running", and "a jog" would all map to a single `Semantic Desire` representing the concept of 'running'. The system should normalize variations in phrasing, synonyms, and minor grammatical differences to identify the underlying common intent.
- **Decision**: Represents the final aggregated output for a session.
- **Attributes**: GoTos (list of `Semantic Desires`), AlsoGoods (list of `Semantic Desires`), Considerables (list of `Semantic Desires`), NoGoes (list of `Semantic Desires`).
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: A new session can be created and the shareable link generated in under 10 seconds.
- **SC-002**: At least 95% of participants can submit their desires on the first attempt without encountering an error message or being prevented from submitting due to validation issues.
- **SC-003**: For a session with up to 10 participants, the results are calculated and displayed within 5 seconds of the final participant's submission.
- **SC-004**: The final decision categories must correctly reflect the aggregation logic in 100% of automated test cases covering all rules and edge cases.
- **SC-005**: Post-feature user satisfaction surveys indicate that at least 80% of users rate the application's ease of use (e.g., intuitive session creation, clear submission process, understandable results) as 4 or 5 on a 5-point Likert scale (1=Very Difficult, 5=Very Easy) for making group decisions.

View File

@@ -0,0 +1,162 @@
# Task Breakdown: Anonymous Desire Aggregator
**Feature**: Anonymous Desire Aggregator
**Date**: 2025-10-09
This document breaks down the implementation of the feature into actionable, dependency-ordered tasks.
## Implementation Strategy
The feature will be developed in phases, aligned with user stories. Each phase represents an independently testable and deliverable increment of functionality. The MVP (Minimum Viable Product) is the completion of User Story 1, which allows a user to create a session.
**Note on TDD**: All new features and bug fixes MUST follow a Test-Driven Development approach. This means tests should be written first, observed to fail, and then made to pass upon completion of the implementation.
---
## Phase 1: Project Setup
**Goal**: Initialize the frontend and backend projects and set up the Docker environment.
| ID | Task | Files / Location | Status |
|---|---|---|---|
| T001 | [P] Initialize the `frontend` project using Create React App with the TypeScript template. | `frontend/` | Done |
| T002 | [P] Initialize the `backend` Node.js project (`npm init`) and set up TypeScript. | `backend/` | Done |
| T003 | [P] Create a basic `Dockerfile` for the `frontend` service. | `frontend/Dockerfile` | Done |
| T004 | [P] Create a basic `Dockerfile` for the `backend` service. | `backend/Dockerfile` | Done |
| T005 | Create the `docker-compose.yaml` file to orchestrate the frontend and backend services. | `docker-compose.yaml` | Done |
| T005.5 | Define and review the `openapi.yaml` API contract. | `specs/001-people-tend-to/contracts/openapi.yaml` | Done |
| T006 | Install core dependencies for the `backend`: express, ws, typescript, @google/generative-ai. | `backend/package.json` | Done |
| T007 | Install core dependencies for the `frontend`: react, react-dom, @mui/material, @emotion/react. | `frontend/package.json` | Done |
---
## Phase 2: Foundational Tasks
**Goal**: Implement core services and hooks that are prerequisites for all user stories.
| ID | Task | Files / Location | Status |
|---|---|---|---|
| T008 | [Backend] Implement the basic WebSocket server to handle connections and manage a list of clients. | `backend/src/ws/` | Done |
| T009 | [Frontend] [P] Implement a `WebSocketService` to connect to the backend and handle incoming/outgoing messages. | `frontend/src/services/` | Done |
| T010 | [Frontend] [P] Implement a `useSession` custom hook to manage the session state in Local Storage. | `frontend/src/hooks/` | Done |
| T011 | [Frontend] Set up the basic Material-UI theme and provider. | `frontend/src/App.tsx` | Done |
---
## Phase 3: [US1] Create and Share a Session
**Goal**: A user can create a new session and get a shareable link.
**Independent Test**: Can be verified by calling the `/sessions` endpoint and seeing a valid session ID returned, then loading the frontend at that URL.
| ID | Task | Files / Location | Status |
|---|---|---|---|
| T012 | [Backend] Write a test for the `POST /sessions` endpoint. | `backend/tests/` | Done |
| T013 | [Backend] Implement the `POST /sessions` endpoint to generate and return a unique session ID. | `backend/src/routes/` | Done |
| T014 | [Frontend] [P] Write a test for the `CreateSession` page component. | `frontend/src/pages/` | Done |
| T015 | [Frontend] [P] Create the UI component for the session creation form (topic, participant count). | `frontend/src/pages/` | Done |
| T016 | [Frontend] Implement the client-side logic to call the `POST /sessions` API. | `frontend/src/pages/` | Done |
| T017 | [Frontend] On successful session creation, initialize the `Session` object in Local Storage via the `useSession` hook and redirect to the new session URL. | `frontend/src/pages/` | Done |
**Checkpoint**: User Story 1 is complete and independently testable. MVP is met.
---
## Phase 4: [US2] Participate in a Session
**Goal**: A user can join a session, see its status, and submit their desires.
**Independent Test**: Can be verified by multiple clients connecting to the same session URL, submitting forms, and seeing the participant count update.
| ID | Task | Files / Location | Status |
|---|---|---|---|
| T018 | [Backend] Implement WebSocket logic to relay `SHARE_STATE` and `SUBMIT_DESIRES` messages to all clients in a session. | `backend/src/ws/` | Done |
| T019 | [Frontend] [P] Write a test for the `DesireForm` component. | `frontend/src/components/` | Done |
| T020 | [Frontend] [P] Create the UI component for the desire submission form (Want, Accept, NoGoes). | `frontend/src/components/` | Done |
| T020.1 | [Frontend] Implement validation to prevent empty desire submissions (FR-020). | `frontend/src/components/` | Done |
| T020.2 | [Frontend] Implement validation to prevent conflicting categories in submissions (FR-016). | `frontend/src/components/` | Done |
| T020.3 | [Frontend] Implement logic to prevent resubmission from the same client (FR-017). | `frontend/src/hooks/useSession.ts` | Done |
| T021 | [Frontend] Implement logic to send the `SUBMIT_DESIRES` message via WebSocket. | `frontend/src/pages/` | Done |
| T022 | [Frontend] Implement logic to handle incoming `STATE_UPDATE` messages and update the local session state. | `frontend/src/hooks/useSession.ts` | Done |
| T023 | Create a UI component to display the "Waiting for X more participants..." status. | `frontend/src/components/` | Done |
| T023.1 | [Backend] Implement session locking logic when expected participants are reached. | `backend/src/ws/` | Done |
| T023.2 | [Frontend] Implement UI to disable submission form and display "Session Full" / "Analysis in Progress" message. | `frontend/src/components/` | Done |
**Checkpoint**: User Story 2 is complete and independently testable.
---
## Phase 5: [US3] View Aggregated Results
**Goal**: Users can see the final, categorized results of the session.
**Independent Test**: Can be verified by having the creator client trigger the analysis and all clients seeing the same results displayed.
| ID | Task | Files / Location | Status |
|---|---|---|---|
| T024 | [Backend] [P] Write a test for the `LLMService`, mocking the Gemini API client. | `backend/tests/` | Done |
| T024.1 | [Backend] Write comprehensive automated test cases for the aggregation logic (SC-004). | `backend/tests/` | Done |
| T025 | [Backend] [P] Implement the `LLMService` to construct the prompt and call the Gemini API. | `backend/src/services/` | Done |
| T026 | [Backend] Write a test for the `POST /sessions/{sessionId}/analyze` endpoint. | `backend/tests/` | Done |
| T027 | [Backend] Implement the `POST /sessions/{sessionId}/analyze` endpoint. | `backend/src/routes/` | Done |
| T028 | [Backend] Implement the WebSocket broadcast for the `ANALYSIS_COMPLETE` message. | `backend/src/ws/` | Done |
| T029 | [Frontend] [P] Write a test for the `ResultsDisplay` component. | `frontend/src/components/` | Done |
| T030 | [Frontend] [P] Create UI components for displaying the `GoTos`, `AlsoGoods`, `Considerable`, and `NoGoes` categories. | `frontend/src/components/` | Done |
| T031 | [Frontend] Implement the UI logic to collapse the "Considerable" category by default. | `frontend/src/components/` | Done |
| T032 | [Frontend] Implement the logic for the creator's client to call the `analyze` endpoint. | `frontend/src/pages/` | Done |
| T033 | [Frontend] Implement the handler for the `ANALYSIS_COMPLETE` message to update the UI. | `frontend/src/hooks/useSession.ts` | Done |
| T033.1 | [Frontend] Ensure real-time synchronization of the results view (FR-015). | `frontend/src/pages/` | Done |
**Checkpoint**: User Story 3 is complete and independently testable.
---
## Phase 6: Polish & Integration
**Goal**: Finalize the application with error handling, loading states, and end-to-end testing.
| ID | Task | Files / Location | Status |
|---|---|---|---|
| T034 | [Frontend] Add loading indicators for API calls and WebSocket events. | `frontend/src/components/` | Done |
| T035 | [Frontend] Add user-friendly error messages for API failures or WebSocket disconnections. | `frontend/src/components/` | Done |
| T036 | [Docs] Write end-to-end test plan for manual execution. | `tests/e2e.md` | Done |
| T036.5 | [Docs] Plan and conduct user satisfaction surveys to measure SC-005. | `docs/user-satisfaction.md` | Done |
---
## Phase 7: Performance & Scalability
**Goal**: Optimize application performance and ensure scalability.
| ID | Task | Files / Location | Status |
|---|---|---|---|
| T037 | [Backend] Implement caching for LLM responses to reduce latency and API calls. | `backend/src/services/LLMService.ts` | Done |
| T038 | [Backend] Implement load testing for the WebSocket server to ensure it handles concurrent connections. | `backend/tests/` | Done |
| T039 | [Frontend] Optimize React component rendering to minimize re-renders. | `frontend/src/components/`, `frontend/src/pages/` | Done |
| T040 | [Docs] Document performance testing results against SC-001 and SC-003. | `docs/performance.md` | Done |
---
## Phase 8: Security & Privacy
**Goal**: Ensure the application adheres to privacy mandates and security best practices.
| ID | Task | Files / Location | Status |
|---|---|---|---|
| T041 | [Backend] Implement strict logging policies to ensure no user desire content is persisted. | `backend/src/` | Done |
| T042 | [Backend] Review LLM API calls to confirm only necessary data is sent and no PII is exposed. | `backend/src/services/LLMService.ts` | Done |
| T043 | [Frontend] Implement measures to prevent accidental exposure of session IDs or participant data. | `frontend/src/` | Done |
| T044 | [Docs] Document privacy compliance measures. | `docs/privacy.md` | Done |
---
## Phase 9: Session Management & Cleanup
**Goal**: Implement session lifecycle management, including termination and expiration handling.
| ID | Task | Files / Location | Status |
|---|---|---|---|
| T045 | [Backend] Implement session termination logic (e.g., timeout mechanism for inactive sessions). | `backend/src/ws/` | Done |
| T046 | [Frontend] Implement "Session Expired" message and redirection to home page. | `frontend/src/pages/` | Done |
## Dependencies
- **User Story Completion Order**: US1 → US2 → US3
- **Parallel Work**: Within each user story phase, frontend and backend tasks marked with `[P]` can often be worked on in parallel, but frontend component implementation depends on the tests being written first.

83
tests/e2e.md Normal file
View File

@@ -0,0 +1,83 @@
# End-to-End (E2E) Test Plan
**Feature**: Anonymous Desire Aggregator
**Date**: 2025-10-09
This document outlines the manual test plan for verifying the end-to-end functionality of the application.
## Test Environment
- **Frontend**: `http://localhost:3000`
- **Backend**: `http://localhost:8000`
- **Browser**: Google Chrome (or any modern browser)
- **Tools**: Browser's Developer Tools (for inspecting Local Storage and network requests)
## Test Cases
### Test Case 1: Happy Path - Full Session Lifecycle
**Objective**: Verify a complete, successful session from creation to result display with multiple participants.
**Steps**:
1. **Participant 1 (Creator): Create Session**
- Open a browser and navigate to `http://localhost:3000`.
- In the "Topic" field, enter "Team Lunch".
- In the "Number of Participants" field, enter "3".
- Click "Create Session".
- **Expected**: The user is redirected to a new URL (e.g., `/session/some-uuid`). The session ID should be visible in the URL.
- Copy the session URL.
2. **Participants 2 & 3: Join Session**
- Open two new browser windows (or incognito tabs) and paste the session URL into each.
- **Expected**: All three browser windows should now show the "Waiting for 3 more participants..." message, which should update as each new participant joins.
3. **All Participants: Submit Desires**
- **Participant 1**:
- Wants: "Pizza"
- Accepts: "Sushi", "Salad"
- No-Goes: "Burgers"
- Click "Submit".
- **Participant 2**:
- Wants: "Sushi"
- Accepts: "Pizza", "Tacos"
- No-Goes: "Salad"
- Click "Submit".
- **Participant 3**:
- Wants: "Pizza"
- Accepts: "Tacos"
- No-Goes: "Sushi"
- Click "Submit".
- **Expected**: As each participant submits, the "Waiting for..." message should update. After the final submission, the creator's view should show a button to "Analyze Desires".
4. **Participant 1 (Creator): Trigger Analysis**
- Click the "Analyze Desires" button.
- **Expected**: A loading indicator should appear. After a few moments, all three browser windows should display the same results.
5. **All Participants: Verify Results**
- **Expected Results (example)**:
- **Go-to**: "Pizza"
- **Also good**: "Tacos"
- **Considerable**: "Salad"
- **No-goes**: "Burgers", "Sushi"
- **Expected**: The "Considerable" section should be collapsed by default. Clicking it should expand to show the items.
### Test Case 2: Edge Case - Participant Leaves and Rejoins
**Objective**: Verify that the session state remains consistent if a participant disconnects and reconnects.
**Steps**:
1. Follow steps 1 and 2 from Test Case 1.
2. Close the browser window for Participant 2.
3. Re-open the session URL in a new window for Participant 2.
4. **Expected**: The session should still show 3 participants, and the "Waiting for..." message should be accurate. The session should proceed normally when all desires are submitted.
### Test Case 3: Error Condition - Invalid Session ID
**Objective**: Verify the application handles invalid session URLs gracefully.
**Steps**:
1. Navigate to a non-existent session URL (e.g., `http://localhost:3000/session/invalid-uuid`).
2. **Expected**: The user should be shown a "Session not found" error message and be redirected to the home page to create a new session.