session start works
This commit is contained in:
8
.context/log.txt
Normal file
8
.context/log.txt
Normal 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
|
||||
@@ -34,7 +34,7 @@ Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --inclu
|
||||
- TASKS = FEATURE_DIR/tasks.md
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
- 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:
|
||||
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
|
||||
|
||||
@@ -26,7 +26,7 @@ Execution steps:
|
||||
- `FEATURE_SPEC`
|
||||
- (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.
|
||||
- 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).
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## 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):
|
||||
- Scan all checklist files in the checklists/ directory
|
||||
|
||||
@@ -15,7 +15,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## 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).
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ The text the user typed after `/speckit.specify` in the triggering message **is*
|
||||
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.
|
||||
**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.
|
||||
|
||||
3. Follow this execution flow:
|
||||
|
||||
@@ -15,7 +15,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## 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:
|
||||
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
|
||||
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/frontend/node_modules
|
||||
/backend/node_modules
|
||||
@@ -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
|
||||
|
||||
### [PRINCIPLE_1_NAME]
|
||||
<!-- Example: I. Library-First -->
|
||||
[PRINCIPLE_1_DESCRIPTION]
|
||||
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
|
||||
### I. Defined Technology Stack
|
||||
All development MUST adhere to the approved technology stack. This ensures consistency, maintainability, and streamlined operations.
|
||||
- **Backend**: Node.js
|
||||
- **Frontend**: React
|
||||
- **UI Framework**: Material Design 3 (Material-UI / MUI)
|
||||
- **Containerization**: Docker
|
||||
|
||||
### [PRINCIPLE_2_NAME]
|
||||
<!-- Example: II. CLI Interface -->
|
||||
[PRINCIPLE_2_DESCRIPTION]
|
||||
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
|
||||
### II. UI/UX Consistency
|
||||
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_3_NAME]
|
||||
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
|
||||
[PRINCIPLE_3_DESCRIPTION]
|
||||
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
|
||||
### III. Container-First Development
|
||||
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_4_NAME]
|
||||
<!-- Example: IV. Integration Testing -->
|
||||
[PRINCIPLE_4_DESCRIPTION]
|
||||
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
|
||||
### IV. Test-Driven Development (TDD)
|
||||
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_5_NAME]
|
||||
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
|
||||
[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. -->
|
||||
### V. API-First Design
|
||||
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.
|
||||
|
||||
## Governance
|
||||
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
|
||||
|
||||
[GOVERNANCE_RULES]
|
||||
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
|
||||
All development activities, code reviews, and architectural decisions must align with this constitution. Proposed deviations require a formal amendment to this document.
|
||||
|
||||
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
|
||||
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
|
||||
**Version**: 1.0.0 | **Ratified**: 2025-10-09 | **Last Amended**: 2025-10-09
|
||||
|
||||
27
GEMINI.md
Normal file
27
GEMINI.md
Normal 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
1
backend/.env
Normal file
@@ -0,0 +1 @@
|
||||
GEMINI_API_KEY=AIzaSyDke9H2NhiG6rBwxT0qrdYgnNoNZm_0j58
|
||||
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal 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
5833
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
backend/package.json
Normal file
35
backend/package.json
Normal 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
24
backend/src/index.ts
Normal 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}`);
|
||||
});
|
||||
22
backend/src/routes/sessions.ts
Normal file
22
backend/src/routes/sessions.ts
Normal 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;
|
||||
51
backend/src/services/LLMService.ts
Normal file
51
backend/src/services/LLMService.ts
Normal 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
254
backend/src/ws/index.ts
Normal 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;
|
||||
};
|
||||
54
backend/tests/llmService.test.ts
Normal file
54
backend/tests/llmService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
79
backend/tests/sessions.test.ts
Normal file
79
backend/tests/sessions.test.ts
Normal 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
14
backend/tsconfig.json
Normal 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
16
docker-compose.yaml
Normal 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
0
frontend/.dockerignore
Normal file
31
frontend/Dockerfile
Normal file
31
frontend/Dockerfile
Normal 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
16
frontend/nginx.conf
Normal 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
21022
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
frontend/package.json
Normal file
52
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
20
frontend/public/index.html
Normal file
20
frontend/public/index.html
Normal 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
24
frontend/src/App.tsx
Normal 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;
|
||||
16
frontend/src/components/DesireForm.test.tsx
Normal file
16
frontend/src/components/DesireForm.test.tsx
Normal 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();
|
||||
});
|
||||
87
frontend/src/components/DesireForm.tsx
Normal file
87
frontend/src/components/DesireForm.tsx
Normal 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;
|
||||
29
frontend/src/components/ResultsDisplay.test.tsx
Normal file
29
frontend/src/components/ResultsDisplay.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
61
frontend/src/components/ResultsDisplay.tsx
Normal file
61
frontend/src/components/ResultsDisplay.tsx
Normal 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;
|
||||
115
frontend/src/hooks/useSession.ts
Normal file
115
frontend/src/hooks/useSession.ts
Normal 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
12
frontend/src/index.tsx
Normal 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>
|
||||
);
|
||||
22
frontend/src/pages/CreateSession.test.tsx
Normal file
22
frontend/src/pages/CreateSession.test.tsx
Normal 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();
|
||||
});
|
||||
48
frontend/src/pages/CreateSession.tsx
Normal file
48
frontend/src/pages/CreateSession.tsx
Normal 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;
|
||||
132
frontend/src/pages/SessionPage.tsx
Normal file
132
frontend/src/pages/SessionPage.tsx
Normal 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;
|
||||
85
frontend/src/services/websocket.ts
Normal file
85
frontend/src/services/websocket.ts
Normal 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
21
frontend/src/theme.ts
Normal 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
26
frontend/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
34
specs/001-people-tend-to/checklists/requirements.md
Normal file
34
specs/001-people-tend-to/checklists/requirements.md
Normal 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.
|
||||
90
specs/001-people-tend-to/contracts/openapi.yaml
Normal file
90
specs/001-people-tend-to/contracts/openapi.yaml
Normal 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: {}
|
||||
71
specs/001-people-tend-to/data-model.md
Normal file
71
specs/001-people-tend-to/data-model.md
Normal 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. |
|
||||
80
specs/001-people-tend-to/plan.md
Normal file
80
specs/001-people-tend-to/plan.md
Normal 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.*
|
||||
53
specs/001-people-tend-to/quickstart.md
Normal file
53
specs/001-people-tend-to/quickstart.md
Normal 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.
|
||||
23
specs/001-people-tend-to/research.md
Normal file
23
specs/001-people-tend-to/research.md
Normal 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.
|
||||
116
specs/001-people-tend-to/spec.md
Normal file
116
specs/001-people-tend-to/spec.md
Normal 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.
|
||||
162
specs/001-people-tend-to/tasks.md
Normal file
162
specs/001-people-tend-to/tasks.md
Normal 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
83
tests/e2e.md
Normal 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.
|
||||
Reference in New Issue
Block a user