23 Commits

Author SHA1 Message Date
KS Jannette
ce7bc2cde9 db created, added tests -- tentative 2026-02-24 19:25:42 -05:00
S Jannette
0ee426e1f3 Update README.md 2026-02-24 14:12:30 -05:00
KS Jannette
90a19e4525 edit 2026-02-24 13:51:19 -05:00
S Jannette
8b555bf729 Fix typo in README.md
Corrected a typo in the README regarding 'simply'.
2026-02-24 13:48:33 -05:00
KS Jannette
c8ee194bc5 hot edit readme 2026-02-24 13:47:24 -05:00
S Jannette
f77362e684 Merge pull request #13 from kjannette/workflow-UI
Add UI/logical temporal ordering feature
2026-02-24 13:32:29 -05:00
KS Jannette
9ba13206c5 Add UI/logical temporal ordering feature 2026-02-24 13:32:06 -05:00
KS Jannette
0c7baeb9d8 hotfix 2026-02-24 13:12:47 -05:00
S Jannette
07b0309adb Merge pull request #12 from kjannette/query-validation
Query validation
2026-02-24 13:07:39 -05:00
KS Jannette
7ec074a043 update documentation 2026-02-24 13:06:56 -05:00
KS Jannette
b7757ca406 add second layer of result validation 2026-02-24 12:56:18 -05:00
KS Jannette
d55f58985b more 2026-02-14 08:11:36 -05:00
KS Jannette
61835ae3aa format 2026-02-13 16:43:00 -05:00
KS Jannette
091315c188 m 2026-02-13 16:35:26 -05:00
KS Jannette
089b9e7e9d more 2026-02-13 16:31:39 -05:00
KS Jannette
3e7978c918 cccccLean 2026-02-13 15:41:50 -05:00
KS Jannette
317468b1ed more 2026-02-13 15:34:29 -05:00
KS Jannette
a720673896 clean 2026-02-13 15:27:10 -05:00
KS Jannette
1a85c6e1e0 fin 2026-02-13 15:21:02 -05:00
S Jannette
7f4c322280 Merge pull request #11 from kjannette/frontFact
more
2026-02-13 15:15:08 -05:00
KS Jannette
5208327b5d more 2026-02-13 15:12:18 -05:00
KS Jannette
ac76871d60 hottie 2026-02-13 13:47:43 -05:00
S Jannette
66c86c2625 Merge pull request #10 from kjannette/refact5
clean
2026-02-13 13:45:24 -05:00
27 changed files with 1204 additions and 150 deletions

View File

@@ -1,40 +1,75 @@
# kongruity app # kongruity
kongruity employs Large Language Model ("LLM") semantic grouping functionality to cluster large volumes of "to dos" or issue tags in development (or other) settings, according to thematic or topical similarity. kongruity pulls in the unstructured artifacts of the creative-engineering process -- to-dos, action items, agile tickets, Jira comment threads, Slack threads, retrospective notes -- and synthesizes them into semantically coherent, prioritized clusters ready for implementation planning.
(To learn more about this topic, see, e.g., [Kozlowski A., Boutyline A., Semantic Structure in Large Language Model Embeddings Aug. 2025, arXiv:2508.10003v1:04 Aug 2025](https://arxiv.org/html/2508.10003v1)). In kongruity world, these artifacts are "sticky notes." A board full of them looks chaotic. With a click, an LLM analyzes their semantic meaning and groups them into thematic clusters, each with a descriptive header.
In the world of kongruity, these "to dos" are called "sticky notes." kongruity's React/Vite UI views a board of seemingly chaotic "sticky notes". But with one click, they are transformed into manageable, actionable groups, each with a header that explains the group semantic interrelation. An independent embedding-based evaluation scores clustering quality, so the output is data-backed. From there, teams can simply drag-and-rank clusters by implementation priority, turning noise into an actionable workflow.
The backend is an Express API that serves "sticky note" data and proxies semantic grouping requests to Antrhopic Claude. ## How it works
Developers may feel free to install other LLM SDKs and alter the syntax at backend/services/clustering.service.js to experiment with any LLM model/platform they prefer. 1. **Ingest** — Sticky notes are loaded and displayed on a board.
2. **Cluster** — An LLM reads every note and groups them by semantic similarity (not keywords).
3. **Evaluate** — In parallel, a separate embedding model (Voyage AI) generates vector representations of each note. A silhouette-based cohesion score measures how well-separated and internally consistent clusters are. The score is displayed alongside the results.
4. **Validate** — Structural checks confirm every note is assigned to exactly one cluster, no clusters are empty, and labels are present.
5. **Prioritize** — Clusters appear ranked and are drag-reorderable. Teams set implementation priority by dragging clusters into position.
## Dev implementation note
Developers may swap in other LLM SDKs/APIs and alter prompt syntax in `backend/services/clustering.service.js` to experiment with any model or platform.
## Prerequisites ## Prerequisites
- Node.js (v18 or later recommended) - Node.js (v18 or later recommended)
- An [Anthropic API key](https://console.anthropic.com/) - PostgreSQL (v14 or later recommended)
- An [Anthropic API key](https://console.anthropic.com/) (or other LLM platform, for clustering)
- A [Voyage AI API key](https://dash.voyageai.com/) (for embedding-based evaluation)
## Setup ## Setup
### 1. Unzip the project ### 1. Clone the repository
```bash ```bash
unzip kongruity.zip git clone https://github.com/kjannette/kongruity_
cd kongruity cd kongruity
``` ```
### 2. Create an environment file ### 2. Create an environment file
The backend expects a `.env` file containing an Anthropic API key in the root `backend/` directory. This file is git-ignored and must be created manually: The backend expects a `.env` file in the `backend/` directory. This file is git-ignored and must be created manually:
```bash ```bash
echo 'ANTHROPIC_API_KEY=<your Anthropic API key>' > backend/.env cat > backend/.env << 'EOF'
ANTHROPIC_API_KEY=<your Anthropic API key> (or other LLM platform key)
VOYAGEAI_API_KEY=<your Voyage AI API key>
DATABASE_URL=postgresql://<user>:<password>@localhost:5432/kongruity
EOF
``` ```
Replace `<your Anthropic API key>` with your actual key. Replace placeholder values with your actual keys and database credentials.
### 3. Install dependencies ### 3. Set up the database
Create a PostgreSQL database for the project:
```bash
createdb kongruity
```
Run the migration to create tables:
```bash
cd backend
npm run db:migrate
```
Seed the database with the sample sticky notes:
```bash
npm run db:seed
```
### 4. Install dependencies
```bash ```bash
cd backend cd backend
@@ -48,7 +83,7 @@ npm install
## Run the app ## Run the app
### Start the backend - Production Mode ### Start the backend Production mode
From the `backend/` directory: From the `backend/` directory:
@@ -58,15 +93,15 @@ npm run start
The API server starts on **http://localhost:3001** (configurable via the `PORT` environment variable). The API server starts on **http://localhost:3001** (configurable via the `PORT` environment variable).
### Start the backend - Development mode ### Start the backend Development mode
To start using Nodemon for "hot reloads," if developing your own features: To start using Nodemon for hot reloads while developing:
```bash ```bash
npm run dev npm run dev
``` ```
### Build the frontend - Production mode ### Build the frontend Production mode
From the `frontend/` directory: From the `frontend/` directory:
@@ -74,13 +109,14 @@ From the `frontend/` directory:
npm run build npm run build
``` ```
### Start the frontend - Development mode ### Start the frontend Development mode
From the `frontend/` directory: From the `frontend/` directory:
```bash ```bash
npm run dev npm run dev
``` ```
The Vite dev server starts on **http://localhost:5173** by default. Open that URL in a browser. The Vite dev server starts on **http://localhost:5173** by default. Open that URL in a browser.
## Running tests ## Running tests
@@ -92,7 +128,8 @@ From the `backend/` directory:
```bash ```bash
npm test npm test
``` ```
Backend tests use Supertest for HTTP assertions.
Backend tests use Vitest with Supertest for HTTP assertions.
### Frontend tests ### Frontend tests
@@ -102,7 +139,7 @@ From the `frontend/` directory:
npm test npm test
``` ```
This runs `vitest run` with jsdom. For watch mode, during development: This runs Vitest with jsdom. For watch mode during development:
```bash ```bash
npm run test:watch npm run test:watch

View File

@@ -6,7 +6,6 @@ const app = express();
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.use('/v1/notes', router); app.use('/v1/notes', router);
app.use((req, res) => { app.use((req, res) => {

13
backend/db/index.js Normal file
View File

@@ -0,0 +1,13 @@
import pg from 'pg';
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
});
export const query = (text, params) => pool.query(text, params);
export const getPool = () => pool;
export const close = () => pool.end();
export default pool;

30
backend/db/migrate.js Normal file
View File

@@ -0,0 +1,30 @@
import 'dotenv/config';
import { query, close } from './index.js';
const up = `
CREATE TABLE IF NOT EXISTS notes (
id VARCHAR(64) PRIMARY KEY,
text TEXT NOT NULL,
x INTEGER DEFAULT 0,
y INTEGER DEFAULT 0,
author VARCHAR(128),
color VARCHAR(32) DEFAULT 'yellow',
source_meta JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
`;
const run = async () => {
try {
await query(up);
console.log('Migration complete — notes table ready.');
} catch (err) {
console.error('Migration failed:', err.message);
process.exit(1);
} finally {
await close();
}
};
run();

54
backend/db/notes.dao.js Normal file
View File

@@ -0,0 +1,54 @@
import { query } from './index.js';
export const getAllNotes = async () => {
const { rows } = await query(
'SELECT id, text, x, y, author, color FROM notes ORDER BY id'
);
return rows;
};
export const getNoteById = async (id) => {
const { rows } = await query(
'SELECT id, text, x, y, author, color FROM notes WHERE id = $1',
[id]
);
return rows[0] || null;
};
export const createNote = async (note) => {
const { rows } = await query(
`INSERT INTO notes (id, text, x, y, author, color)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, text, x, y, author, color`,
[note.id, note.text, note.x ?? 0, note.y ?? 0, note.author, note.color ?? 'yellow']
);
return rows[0];
};
export const createNotes = async (notes) => {
const values = [];
const placeholders = [];
notes.forEach((note, i) => {
const offset = i * 6;
placeholders.push(
`($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5}, $${offset + 6})`
);
values.push(
note.id,
note.text,
note.x ?? 0,
note.y ?? 0,
note.author,
note.color ?? 'yellow'
);
});
const { rows } = await query(
`INSERT INTO notes (id, text, x, y, author, color)
VALUES ${placeholders.join(', ')}
RETURNING id, text, x, y, author, color`,
values
);
return rows;
};

40
backend/db/seed.js Normal file
View File

@@ -0,0 +1,40 @@
import 'dotenv/config';
import { readFile } from 'fs/promises';
import { query, close } from './index.js';
const NOTES_PATH = new URL('../data/notes.json', import.meta.url);
const run = async () => {
try {
const raw = await readFile(NOTES_PATH, 'utf-8');
const notes = JSON.parse(raw);
const insertQuery = `
INSERT INTO notes (id, text, x, y, author, color)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO NOTHING
`;
let inserted = 0;
for (const note of notes) {
const result = await query(insertQuery, [
note.id,
note.text,
note.x,
note.y,
note.author,
note.color,
]);
inserted += result.rowCount;
}
console.log(`Seed complete — ${inserted} notes inserted (${notes.length - inserted} already existed).`);
} catch (err) {
console.error('Seed failed:', err.message);
process.exit(1);
} finally {
await close();
}
};
run();

View File

@@ -11,7 +11,9 @@
"@anthropic-ai/sdk": "^0.74.0", "@anthropic-ai/sdk": "^0.74.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.21.2" "express": "^4.21.2",
"pg": "^8.18.0",
"voyageai": "^0.1.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.9", "nodemon": "^3.1.9",
@@ -1013,6 +1015,18 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -1067,7 +1081,6 @@
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/balanced-match": { "node_modules/balanced-match": {
@@ -1077,6 +1090,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -1138,6 +1171,30 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1215,7 +1272,6 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"delayed-stream": "~1.0.0" "delayed-stream": "~1.0.0"
@@ -1314,7 +1370,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.4.0" "node": ">=0.4.0"
@@ -1432,7 +1487,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -1511,6 +1565,24 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/expect-type": { "node_modules/expect-type": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -1609,7 +1681,6 @@
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
@@ -1622,6 +1693,15 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/formdata-node": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-6.0.3.tgz",
"integrity": "sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==",
"license": "MIT",
"engines": {
"node": ">= 18"
}
},
"node_modules/formidable": { "node_modules/formidable": {
"version": "3.5.4", "version": "3.5.4",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
@@ -1770,7 +1850,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"has-symbols": "^1.0.3" "has-symbols": "^1.0.3"
@@ -1826,6 +1905,26 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore-by-default": { "node_modules/ignore-by-default": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
@@ -1894,6 +1993,12 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/js-base64": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.2.tgz",
"integrity": "sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==",
"license": "BSD-3-Clause"
},
"node_modules/json-schema-to-ts": { "node_modules/json-schema-to-ts": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
@@ -2033,6 +2138,26 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/nodemon": { "node_modules/nodemon": {
"version": "3.1.11", "version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
@@ -2173,6 +2298,96 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pg": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.11.0",
"pg-pool": "^3.11.0",
"pg-protocol": "^1.11.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
"integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2222,6 +2437,54 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -2281,6 +2544,22 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -2531,6 +2810,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/stackback": { "node_modules/stackback": {
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -2554,6 +2842,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/superagent": { "node_modules/superagent": {
"version": "10.3.0", "version": "10.3.0",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
@@ -2759,6 +3056,12 @@
"nodetouch": "bin/nodetouch.js" "nodetouch": "bin/nodetouch.js"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-algebra": { "node_modules/ts-algebra": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
@@ -2794,6 +3097,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/url-join": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
"integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==",
"license": "MIT"
},
"node_modules/utils-merge": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -3011,6 +3320,52 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/voyageai": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/voyageai/-/voyageai-0.1.0.tgz",
"integrity": "sha512-QnalSSHRigZqzKJpXLsgp3tKaKkSv2kGO4rcXQ6mr6PKHa0vRirs0jZ72olvFJrGJkbu1hHijcyr6/eI4L5aRw==",
"license": "MIT",
"dependencies": {
"form-data": "^4.0.0",
"formdata-node": "^6.0.3",
"js-base64": "3.7.2",
"node-fetch": "2.7.0",
"qs": "6.11.2",
"readable-stream": "^4.5.2",
"url-join": "4.0.1"
}
},
"node_modules/voyageai/node_modules/qs": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz",
"integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.0.4"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/why-is-node-running": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -3034,6 +3389,15 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
} }
} }
} }

View File

@@ -7,13 +7,17 @@
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "nodemon server.js", "dev": "nodemon server.js",
"test": "vitest run" "test": "vitest run",
"db:migrate": "node db/migrate.js",
"db:seed": "node db/seed.js"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.74.0", "@anthropic-ai/sdk": "^0.74.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.21.2" "express": "^4.21.2",
"pg": "^8.18.0",
"voyageai": "^0.1.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.9", "nodemon": "^3.1.9",

View File

@@ -1,18 +1,12 @@
import { Router } from 'express'; import { Router } from 'express';
import { readFile } from 'fs/promises'; import { getAllNotes } from '../db/notes.dao.js';
import { clusterNotes } from '../services/clustering.service.js'; import { clusterNotes } from '../services/clustering.service.js';
const router = Router(); const router = Router();
const DATA_PATH = '../data/notes.json';
const loadNotes = async () => {
const raw = await readFile(DATA_PATH, 'utf-8');
return JSON.parse(raw);
};
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const notes = await loadNotes(); const notes = await getAllNotes();
res.json(notes); res.json(notes);
} catch (err) { } catch (err) {
console.error(`Error loading notes: ${err}`); console.error(`Error loading notes: ${err}`);
@@ -22,12 +16,12 @@ router.get('/', async (req, res) => {
router.post('/cluster', async (req, res) => { router.post('/cluster', async (req, res) => {
try { try {
const notes = await loadNotes(); const notes = await getAllNotes();
const clusters = await clusterNotes(notes); const result = await clusterNotes(notes);
res.json(clusters); res.json(result);
} catch (err) { } catch (err) {
console.error(`Clustering failed: ${err}`); console.error(`Clustering failed: ${err}`);
res.status(500).json({ error: `Clustering failed: ${err}` }); res.status(500).json({ error: 'Clustering failed' });
} }
}); });

View File

@@ -1,4 +1,6 @@
import Anthropic from "@anthropic-ai/sdk"; import Anthropic from "@anthropic-ai/sdk";
import { embedNotes } from "./embedding.service.js";
import { validateStructure, computeCohesionScore } from "./validation.service.js";
const client = new Anthropic({ const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY, apiKey: process.env.ANTHROPIC_API_KEY,
@@ -31,17 +33,17 @@ Here are the notes:
${notesJson}`; ${notesJson}`;
}; };
export const clusterNotes = async (notes) => { const requestClusters = async (notes) => {
const response = await client.messages.create({ const response = await client.messages.create({
model: "claude-sonnet-4-20250514", model: "claude-sonnet-4-20250514",
max_tokens: 4096, max_tokens: 4096,
messages: [ messages: [
{ role: "user", content: buildPrompt(notes) }, { role: "user", content: buildPrompt(notes) },
], ],
}); });
const textBlock = response?.content?.[0]; const textBlock = response?.content?.[0];
if (!textBlock || textBlock.type !== 'text' || typeof textBlock.text !== 'string') { if (!textBlock || textBlock.type !== 'text' || typeof textBlock.text !== 'string') {
throw new Error('Unexpected response from LLM API: no text content returned'); throw new Error('Unexpected response from LLM API: no text content returned');
} }
@@ -52,3 +54,20 @@ export const clusterNotes = async (notes) => {
throw new Error('LLM API returned non-JSON response'); throw new Error('LLM API returned non-JSON response');
} }
}; };
export const clusterNotes = async (notes) => {
const [clusters, embeddingMap] = await Promise.all([
requestClusters(notes),
embedNotes(notes),
]);
const noteIds = notes.map((n) => n.id);
const { valid, reasons } = validateStructure(clusters, noteIds);
if (!valid) {
throw new Error(`Cluster validation failed: ${reasons.join('; ')}`);
}
const score = computeCohesionScore(clusters, embeddingMap);
return { clusters, score: Math.round(score * 100) / 100 };
};

View File

@@ -0,0 +1,25 @@
import { VoyageAIClient } from "voyageai";
const client = new VoyageAIClient({
apiKey: process.env.VOYAGEAI_API_KEY,
});
/**
* @param {Array<{id: string, text: string}>} notes
* @returns {Promise<Map<string, number[]>>} noteId → embedding vector
*/
export const embedNotes = async (notes) => {
const texts = notes.map((n) => n.text);
const response = await client.embed({
input: texts,
model: "voyage-3-lite",
});
const embeddingMap = new Map();
response.data.forEach((item, i) => {
embeddingMap.set(notes[i].id, item.embedding);
});
return embeddingMap;
};

View File

@@ -0,0 +1,129 @@
/**
* Structural validation: confirms the LLM output is well-formed
* before it reaches the frontend.
*
* @param {Array<{label: string, noteIds: string[]}>} clusters
* @param {string[]} inputNoteIds - the original note IDs that were sent to the LLM
* @returns {{valid: boolean, reasons: string[]}}
*/
export const validateStructure = (clusters, inputNoteIds) => {
const reasons = [];
if (!Array.isArray(clusters) || clusters.length === 0) {
return { valid: false, reasons: ['Response is not a non-empty array'] };
}
const assignedIds = [];
for (const cluster of clusters) {
if (!cluster.label || typeof cluster.label !== 'string') {
reasons.push(`Cluster missing a valid label`);
}
if (!Array.isArray(cluster.noteIds) || cluster.noteIds.length === 0) {
reasons.push(`Cluster "${cluster.label ?? '(unlabeled)'}" has no noteIds`);
}
assignedIds.push(...(cluster.noteIds ?? []));
}
const inputSet = new Set(inputNoteIds);
const assignedSet = new Set(assignedIds);
if (assignedIds.length !== assignedSet.size) {
reasons.push('One or more notes appear in multiple clusters');
}
const missing = inputNoteIds.filter((id) => !assignedSet.has(id));
if (missing.length > 0) {
reasons.push(`Notes missing from clusters: ${missing.join(', ')}`);
}
const extra = assignedIds.filter((id) => !inputSet.has(id));
if (extra.length > 0) {
reasons.push(`Unknown noteIds in clusters: ${[...new Set(extra)].join(', ')}`);
}
if (clusters.length > inputNoteIds.length) {
reasons.push(`More clusters (${clusters.length}) than notes (${inputNoteIds.length})`);
}
return { valid: reasons.length === 0, reasons };
};
const cosineSimilarity = (a, b) => {
let dot = 0;
let magA = 0;
let magB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
magA += a[i] * a[i];
magB += b[i] * b[i];
}
const denom = Math.sqrt(magA) * Math.sqrt(magB);
return denom === 0 ? 0 : dot / denom;
};
/**
* Computes a silhouette-style cohesion score for the clustering.
*
* For each note, measures how much more similar it is to its own cluster
* versus the nearest neighboring cluster. Returns a score in [-1, 1]
* where higher is better.
*
* @param {Array<{label: string, noteIds: string[]}>} clusters
* @param {Map<string, number[]>} embeddingMap - noteId → vector
* @returns {number} average silhouette score
*/
export const computeCohesionScore = (clusters, embeddingMap) => {
if (clusters.length <= 1) return 1.0;
const scores = [];
for (let ci = 0; ci < clusters.length; ci++) {
const clusterIds = clusters[ci].noteIds;
if (clusterIds.length <= 1) {
scores.push(0);
continue;
}
for (const noteId of clusterIds) {
const vec = embeddingMap.get(noteId);
if (!vec) continue;
// a(i): avg distance to other notes in same cluster
let intraSum = 0;
let intraCount = 0;
for (const otherId of clusterIds) {
if (otherId === noteId) continue;
const otherVec = embeddingMap.get(otherId);
if (!otherVec) continue;
intraSum += 1 - cosineSimilarity(vec, otherVec);
intraCount++;
}
const a = intraCount > 0 ? intraSum / intraCount : 0;
// b(i): min avg distance to notes in any other cluster
let b = Infinity;
for (let oi = 0; oi < clusters.length; oi++) {
if (oi === ci) continue;
const otherClusterIds = clusters[oi].noteIds;
let interSum = 0;
let interCount = 0;
for (const otherId of otherClusterIds) {
const otherVec = embeddingMap.get(otherId);
if (!otherVec) continue;
interSum += 1 - cosineSimilarity(vec, otherVec);
interCount++;
}
if (interCount > 0) {
b = Math.min(b, interSum / interCount);
}
}
if (b === Infinity) b = 0;
const max = Math.max(a, b);
scores.push(max === 0 ? 0 : (b - a) / max);
}
}
if (scores.length === 0) return 0;
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
};

View File

@@ -1,7 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
const { createMock } = vi.hoisted(() => { const { createMock, mockEmbeddings } = vi.hoisted(() => {
return { createMock: vi.fn() }; const embeddings = new Map([
['note_001', [1.0, 0.0, 0.0]],
['note_002', [0.0, 1.0, 0.0]],
]);
return { createMock: vi.fn(), mockEmbeddings: embeddings };
}); });
vi.mock('@anthropic-ai/sdk', () => { vi.mock('@anthropic-ai/sdk', () => {
@@ -14,6 +18,10 @@ vi.mock('@anthropic-ai/sdk', () => {
}; };
}); });
vi.mock('../services/embedding.service.js', () => ({
embedNotes: vi.fn().mockResolvedValue(mockEmbeddings),
}));
import { clusterNotes } from '../services/clustering.service.js'; import { clusterNotes } from '../services/clustering.service.js';
const MOCK_NOTES = [ const MOCK_NOTES = [
@@ -60,14 +68,17 @@ describe('clusterNotes service', () => {
expect(prompt).toContain('Export fails'); expect(prompt).toContain('Export fails');
}); });
it('should parse and return the clustered JSON from the API response', async () => { it('should return clusters and a cohesion score', async () => {
createMock.mockResolvedValue({ createMock.mockResolvedValue({
content: [{ type: 'text', text: JSON.stringify(MOCK_CLUSTERS) }], content: [{ type: 'text', text: JSON.stringify(MOCK_CLUSTERS) }],
}); });
const result = await clusterNotes(MOCK_NOTES); const result = await clusterNotes(MOCK_NOTES);
expect(result).toEqual(MOCK_CLUSTERS); expect(result.clusters).toEqual(MOCK_CLUSTERS);
expect(typeof result.score).toBe('number');
expect(result.score).toBeGreaterThanOrEqual(-1);
expect(result.score).toBeLessThanOrEqual(1);
}); });
it('should throw error when the API returns non-JSON', async () => { it('should throw error when the API returns non-JSON', async () => {
@@ -89,4 +100,15 @@ describe('clusterNotes service', () => {
await expect(clusterNotes(MOCK_NOTES)).rejects.toThrow('401 Unauthorized'); await expect(clusterNotes(MOCK_NOTES)).rejects.toThrow('401 Unauthorized');
}); });
it('should throw a validation error when a note is missing from clusters', async () => {
const incompleteClusters = [
{ label: 'Auth Issues', noteIds: ['note_001'] },
];
createMock.mockResolvedValue({
content: [{ type: 'text', text: JSON.stringify(incompleteClusters) }],
});
await expect(clusterNotes(MOCK_NOTES)).rejects.toThrow('Cluster validation failed');
});
}); });

View File

@@ -2,16 +2,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import request from 'supertest'; import request from 'supertest';
import app from '../app.js'; import app from '../app.js';
vi.mock('../db/notes.dao.js', () => ({
getAllNotes: vi.fn(),
}));
vi.mock('../services/clustering.service.js', () => ({ vi.mock('../services/clustering.service.js', () => ({
clusterNotes: vi.fn(), clusterNotes: vi.fn(),
})); }));
vi.mock('fs/promises', () => ({ import { getAllNotes } from '../db/notes.dao.js';
readFile: vi.fn(),
}));
import { clusterNotes } from '../services/clustering.service.js'; import { clusterNotes } from '../services/clustering.service.js';
import { readFile } from 'fs/promises';
const MOCK_NOTES = [ const MOCK_NOTES = [
{ id: 'note_001', text: 'Login flow feels confusing', x: 193, y: 191, author: 'user_5', color: 'yellow' }, { id: 'note_001', text: 'Login flow feels confusing', x: 193, y: 191, author: 'user_5', color: 'yellow' },
@@ -31,7 +31,7 @@ describe('GET /v1/notes', () => {
}); });
it('should return 200 and an array of notes', async () => { it('should return 200 and an array of notes', async () => {
readFile.mockResolvedValue(JSON.stringify(MOCK_NOTES)); getAllNotes.mockResolvedValue(MOCK_NOTES);
const res = await request(app).get('/v1/notes'); const res = await request(app).get('/v1/notes');
@@ -41,7 +41,7 @@ describe('GET /v1/notes', () => {
}); });
it('should return notes with expected properties', async () => { it('should return notes with expected properties', async () => {
readFile.mockResolvedValue(JSON.stringify(MOCK_NOTES)); getAllNotes.mockResolvedValue(MOCK_NOTES);
const res = await request(app).get('/v1/notes'); const res = await request(app).get('/v1/notes');
const note = res.body[0]; const note = res.body[0];
@@ -54,8 +54,8 @@ describe('GET /v1/notes', () => {
expect(note).toHaveProperty('color'); expect(note).toHaveProperty('color');
}); });
it('should return 500 when the data file cannot be read', async () => { it('should return 500 when the database query fails', async () => {
readFile.mockRejectedValue(new Error('ENOENT: file not found')); getAllNotes.mockRejectedValue(new Error('connection refused'));
const res = await request(app).get('/v1/notes'); const res = await request(app).get('/v1/notes');
@@ -63,15 +63,6 @@ describe('GET /v1/notes', () => {
expect(res.body).toHaveProperty('error'); expect(res.body).toHaveProperty('error');
expect(res.body.error).toBe('Failed to load notes'); expect(res.body.error).toBe('Failed to load notes');
}); });
it('should return 500 when data file is invalid JSON', async () => {
readFile.mockResolvedValue('{ this is not valid json }');
const res = await request(app).get('/v1/notes');
expect(res.status).toBe(500);
expect(res.body).toHaveProperty('error');
});
}); });
describe('POST /v1/notes/cluster', () => { describe('POST /v1/notes/cluster', () => {
@@ -81,7 +72,7 @@ describe('POST /v1/notes/cluster', () => {
}); });
it('should return 200 and clustered results', async () => { it('should return 200 and clustered results', async () => {
readFile.mockResolvedValue(JSON.stringify(MOCK_NOTES)); getAllNotes.mockResolvedValue(MOCK_NOTES);
clusterNotes.mockResolvedValue(MOCK_CLUSTERS); clusterNotes.mockResolvedValue(MOCK_CLUSTERS);
const res = await request(app).post('/v1/notes/cluster'); const res = await request(app).post('/v1/notes/cluster');
@@ -91,7 +82,7 @@ describe('POST /v1/notes/cluster', () => {
}); });
it('should return clusters with the expected shape (label, noteIds)', async () => { it('should return clusters with the expected shape (label, noteIds)', async () => {
readFile.mockResolvedValue(JSON.stringify(MOCK_NOTES)); getAllNotes.mockResolvedValue(MOCK_NOTES);
clusterNotes.mockResolvedValue(MOCK_CLUSTERS); clusterNotes.mockResolvedValue(MOCK_CLUSTERS);
const res = await request(app).post('/v1/notes/cluster'); const res = await request(app).post('/v1/notes/cluster');
@@ -104,7 +95,7 @@ describe('POST /v1/notes/cluster', () => {
}); });
it('should pass the loaded notes to clusterNotes', async () => { it('should pass the loaded notes to clusterNotes', async () => {
readFile.mockResolvedValue(JSON.stringify(MOCK_NOTES)); getAllNotes.mockResolvedValue(MOCK_NOTES);
clusterNotes.mockResolvedValue(MOCK_CLUSTERS); clusterNotes.mockResolvedValue(MOCK_CLUSTERS);
await request(app).post('/v1/notes/cluster'); await request(app).post('/v1/notes/cluster');
@@ -114,8 +105,8 @@ describe('POST /v1/notes/cluster', () => {
}); });
it('should return 500 when clusterNotes (API call) fails', async () => { it('should return 500 when clusterNotes (API call) fails', async () => {
readFile.mockResolvedValue(JSON.stringify(MOCK_NOTES)); getAllNotes.mockResolvedValue(MOCK_NOTES);
clusterNotes.mockRejectedValue(new Error('Anthropic API error')); clusterNotes.mockRejectedValue(new Error('LLM API error'));
const res = await request(app).post('/v1/notes/cluster'); const res = await request(app).post('/v1/notes/cluster');
@@ -124,12 +115,12 @@ describe('POST /v1/notes/cluster', () => {
expect(res.body.error).toMatch(/^Clustering failed/); expect(res.body.error).toMatch(/^Clustering failed/);
}); });
it('should return 500 when the data file cannot be read', async () => { it('should return 500 when the database query fails', async () => {
readFile.mockRejectedValue(new Error('ENOENT: file not found')); getAllNotes.mockRejectedValue(new Error('connection refused'));
const res = await request(app).post('/v1/notes/cluster'); const res = await request(app).post('/v1/notes/cluster');
expect(res.status).toBe(500); expect(res.status).toBe(500);
expect(res.body).toHaveProperty('error'); expect(res.body).toHaveProperty('error');
}); });
}); });

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockQuery } = vi.hoisted(() => ({
mockQuery: vi.fn(),
}));
vi.mock('../db/index.js', () => ({
query: mockQuery,
}));
import { getAllNotes, getNoteById, createNote, createNotes } from '../db/notes.dao.js';
const MOCK_ROWS = [
{ id: 'note_001', text: 'Login flow feels confusing', x: 193, y: 191, author: 'user_5', color: 'yellow' },
{ id: 'note_002', text: 'Login flow is broken on mobile', x: 214, y: 281, author: 'user_9', color: 'yellow' },
];
describe('notes.dao', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getAllNotes', () => {
it('should return all notes ordered by id', async () => {
mockQuery.mockResolvedValue({ rows: MOCK_ROWS });
const result = await getAllNotes();
expect(result).toEqual(MOCK_ROWS);
expect(mockQuery).toHaveBeenCalledWith(
'SELECT id, text, x, y, author, color FROM notes ORDER BY id'
);
});
it('should return an empty array when no notes exist', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await getAllNotes();
expect(result).toEqual([]);
});
it('should propagate database errors', async () => {
mockQuery.mockRejectedValue(new Error('connection refused'));
await expect(getAllNotes()).rejects.toThrow('connection refused');
});
});
describe('getNoteById', () => {
it('should return a single note when found', async () => {
mockQuery.mockResolvedValue({ rows: [MOCK_ROWS[0]] });
const result = await getNoteById('note_001');
expect(result).toEqual(MOCK_ROWS[0]);
expect(mockQuery).toHaveBeenCalledWith(
'SELECT id, text, x, y, author, color FROM notes WHERE id = $1',
['note_001']
);
});
it('should return null when note is not found', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await getNoteById('note_999');
expect(result).toBeNull();
});
});
describe('createNote', () => {
it('should insert a note and return it', async () => {
const input = { id: 'note_003', text: 'Export fails', x: 100, y: 200, author: 'user_1', color: 'blue' };
mockQuery.mockResolvedValue({ rows: [input] });
const result = await createNote(input);
expect(result).toEqual(input);
expect(mockQuery).toHaveBeenCalledOnce();
const [sql, params] = mockQuery.mock.calls[0];
expect(sql).toContain('INSERT INTO notes');
expect(params).toEqual(['note_003', 'Export fails', 100, 200, 'user_1', 'blue']);
});
it('should use defaults for missing x, y, and color', async () => {
const input = { id: 'note_004', text: 'Needs fixing', author: 'user_2' };
mockQuery.mockResolvedValue({ rows: [{ ...input, x: 0, y: 0, color: 'yellow' }] });
await createNote(input);
const params = mockQuery.mock.calls[0][1];
expect(params[2]).toBe(0);
expect(params[3]).toBe(0);
expect(params[5]).toBe('yellow');
});
});
describe('createNotes', () => {
it('should batch-insert multiple notes and return them', async () => {
mockQuery.mockResolvedValue({ rows: MOCK_ROWS });
const result = await createNotes(MOCK_ROWS);
expect(result).toEqual(MOCK_ROWS);
expect(mockQuery).toHaveBeenCalledOnce();
const [sql, params] = mockQuery.mock.calls[0];
expect(sql).toContain('INSERT INTO notes');
expect(params).toHaveLength(12);
});
});
});

View File

@@ -0,0 +1,2 @@
VITE_APP_URL=localhost:3000
VITE_API_BASE=http://localhost:3001

2
frontend/.env.production Normal file
View File

@@ -0,0 +1,2 @@
VITE_APP_URL=https://example.com
VITE_API_BASE=https://www.example.com:4000

View File

@@ -1,7 +1,7 @@
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from '@tanstack/react-query';
import type { Sticky, Cluster } from '../types/types'; import type { Sticky, ClusterResponse } from '../types/types';
const API_BASE = 'http://localhost:3001/v1/notes'; const API_BASE = `${import.meta.env.VITE_API_BASE}/v1/notes`;
const fetchStickies = async (): Promise<Sticky[]> => { const fetchStickies = async (): Promise<Sticky[]> => {
const response = await fetch(API_BASE); const response = await fetch(API_BASE);
@@ -11,7 +11,7 @@ const fetchStickies = async (): Promise<Sticky[]> => {
return response.json(); return response.json();
}; };
const fetchClusters = async (): Promise<Cluster[]> => { const fetchClusters = async (): Promise<ClusterResponse> => {
const response = await fetch(`${API_BASE}/cluster`, { const response = await fetch(`${API_BASE}/cluster`, {
method: 'POST', method: 'POST',
}); });
@@ -29,7 +29,7 @@ export const useGetStickies = () => {
}; };
export const useClusterStickies = () => { export const useClusterStickies = () => {
return useMutation<Cluster[]>({ return useMutation<ClusterResponse>({
mutationFn: fetchClusters, mutationFn: fetchClusters,
}); });
}; };

View File

@@ -8,9 +8,9 @@ type ButtonProps = {
const Button = ({ onClick, isLoading, label }: ButtonProps) => { const Button = ({ onClick, isLoading, label }: ButtonProps) => {
return ( return (
<button className="primaryButton" onClick={onClick} disabled={isLoading}> <button className="primaryButton" onClick={onClick} disabled={isLoading}>
{isLoading ? 'Working...' : label} {isLoading ? 'Working...' : label}
</button> </button>
); );
}; };

View File

@@ -1,14 +1,14 @@
const Navbar = () => { const Navbar = () => {
return ( return (
<div className="main-head-box"> <div className="main-head-box">
<div className="main-head-subbox-left"> <div className="main-head-subbox-left">
<h1 className="main-head">kongruity</h1> <h1 className="main-head">kongruity</h1>
</div>
<div className="main-head-subbox-right">
<span className="material-symbols-outlined">recenter</span>
</div>
</div> </div>
) <div className="main-head-subbox-right">
<span className="material-symbols-outlined">recenter</span>
</div>
</div>
)
} }
export default Navbar; export default Navbar;

View File

@@ -1,16 +1,39 @@
import { useState, useEffect, useRef } from 'react';
import type { DragEvent } from 'react';
import { useGetStickies, useClusterStickies } from '../api/api'; import { useGetStickies, useClusterStickies } from '../api/api';
import type { Sticky as StickyType } from '../types/types'; import type { Sticky as StickyType, RankedCluster } from '../types/types';
import Sticky from './sticky'; import Sticky from './sticky';
import Button from './button'; import Button from './button';
import '../styles/stickies.css'; import '../styles/stickies.css';
const scoreLabel = (score: number): string => {
if (score >= 0.7) return 'Strong';
if (score >= 0.4) return 'Moderate';
if (score >= 0.1) return 'Weak';
return 'Poor';
};
const Stickies = () => { const Stickies = () => {
const { data: stickies, isLoading, error } = useGetStickies(); const { data: stickies, isLoading, error } = useGetStickies();
const { mutate: cluster, data: clusters, isPending } = useClusterStickies(); const { mutate: cluster, data: clusterResponse, isPending } = useClusterStickies();
const [rankedClusters, setRankedClusters] = useState<RankedCluster[]>([]);
const dragIndex = useRef<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
useEffect(() => {
if (clusterResponse?.clusters) {
setRankedClusters(
clusterResponse.clusters.map((c, i) => ({ ...c, rank: i + 1 }))
);
}
}, [clusterResponse]);
if (isLoading) return <div>Loading...</div>; if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>; if (error) return <div>Error: {error.message}</div>;
const score = clusterResponse?.score;
const handleCluster = () => { const handleCluster = () => {
cluster(); cluster();
}; };
@@ -24,20 +47,75 @@ const Stickies = () => {
const stickyMap = buildStickyMap(); const stickyMap = buildStickyMap();
const renderStickies = (items: StickyType[]) => const renderStickies = (items: StickyType[]) =>
items.map((sticky) => <Sticky key={sticky.id} sticky={sticky} />); items?.map((sticky) => <Sticky key={sticky.id} sticky={sticky} />);
const handleDragStart = (index: number) => {
dragIndex.current = index;
};
const handleDragOver = (e: DragEvent, index: number) => {
e.preventDefault();
setDragOverIndex(index);
};
const handleDragLeave = () => {
setDragOverIndex(null);
};
const handleDrop = (targetIndex: number) => {
const sourceIndex = dragIndex.current;
if (sourceIndex === null || sourceIndex === targetIndex) {
dragIndex.current = null;
setDragOverIndex(null);
return;
}
const reordered = [...rankedClusters];
const [moved] = reordered.splice(sourceIndex, 1);
reordered.splice(targetIndex, 0, moved);
setRankedClusters(reordered.map((c, i) => ({ ...c, rank: i + 1 })));
dragIndex.current = null;
setDragOverIndex(null);
};
const handleDragEnd = () => {
dragIndex.current = null;
setDragOverIndex(null);
};
return ( return (
<div className="stickies-container"> <div className="stickies-container">
<Button onClick={handleCluster} isLoading={isPending} label="Group Stickies By Topic" /> <Button onClick={handleCluster} isLoading={isPending} label="Group Stickies By Topic" />
{clusters ? ( {rankedClusters.length > 0 ? (
<div className="clusters-container"> <div className="clusters-container">
{clusters.map((group) => ( {score != null && (
<div key={group.label} className="cluster-group"> <div className="cohesion-score">
<h3 className="cluster-label">{group.label}</h3> Cluster cohesion: <strong>{score.toFixed(2)}</strong> {scoreLabel(score)}
</div>
)}
{rankedClusters.map((group, index) => (
<div
key={group.label}
className={`cluster-group cluster-draggable${dragOverIndex === index ? ' cluster-drag-over' : ''}`}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={() => handleDrop(index)}
onDragEnd={handleDragEnd}
>
<div className="cluster-header">
<span className="cluster-rank" aria-label={`Priority ${group.rank}`}>
{group.rank}
</span>
<h3 className="cluster-label">{group.label}</h3>
<span className="cluster-drag-handle" aria-hidden="true"></span>
</div>
<div className="stickies-grid"> <div className="stickies-grid">
{renderStickies( {renderStickies(
group.noteIds group?.noteIds
.map((id) => stickyMap.get(id)) .map((id) => stickyMap?.get(id))
.filter((s): s is StickyType => !!s) .filter((s): s is StickyType => !!s)
)} )}
</div> </div>

View File

@@ -14,5 +14,5 @@ createRoot(document.getElementById('root')!).render(
<App /> <App />
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>
</StrictMode>, </StrictMode>
) )

View File

@@ -3,16 +3,12 @@ import Navbar from '../components/navbar'
import '../styles/home.css' import '../styles/home.css'
const Home = () => { const Home = () => {
return ( return (
<div> <div>
<div> <Navbar />
<Navbar /> <Stickies />
</div> </div>
<div> );
<Stickies />
</div>
</div>
);
}; };
export default Home; export default Home;

View File

@@ -1,41 +1,73 @@
.main-head-box { .main-head-box {
border-radius: 8px; border-radius: 8px;
border: 1px solid #6dd6f4; border: 1px solid #6dd6f4;
background-color: rgb(92, 0 91); background-color: rgb(92, 0, 91);
display: flex; display: flex;
} }
.main-head-subbox-right {
display: flex;
justify-content: flex-end;
margin-right: 34px;
width: 50%;
}
.main-head-subbox-left {
display: flex;
justify-content: flex-start;
margin-left: 34px;
width: 50%;
}
.main-head {
font-size: 5rem;
color: #6dd6f4;
margin: 6px 0px 24px 22px;
font-family: "Sulphur Point", sans-serif;
font-weight: 500;
letter-spacing: 4px;
text-decoration: underline;
}
.material-symbols-outlined {
margin: 13px 74px 0px 0px;
font-size: 82px;
color: #6dd6f4;
font-variation-settings:
'FILL' 0,
'wght' 300,
'GRAD' 0,
'opsz' 24;
}
@media screen and (max-width: 478px) {
.main-head-subbox-right { .main-head-subbox-right {
display: flex; display: none;
justify-content: flex-end;
margin-right: 34px;
width: 50%;
} }
.main-head-subbox-left { .main-head-subbox-left {
display: flex; display: flex;
justify-content: flex-start; justify-content: center;
margin-left: 34px; margin-left: auto;
width: 50%; margin-right: auto;
width: 100%;
} }
.main-head { .main-head {
font-size: 5rem; font-size: 4rem;
color: #6dd6f4; color: #6dd6f4;
margin: 6px 0px 24px 22px;
font-family: "Sulphur Point", sans-serif; font-family: "Sulphur Point", sans-serif;
font-weight: 500; display: flex;
letter-spacing: 4px; justify-content: center;
align-items: center;
margin-left: auto;
margin-right: auto;
font-weight: 400;
letter-spacing: 2px;
text-decoration: underline; text-decoration: underline;
} }
.material-symbols-outlined { .material-symbols-outlined {
margin: 13px 74px 0px 0px; display: none !important;
font-size: 82px; }
color: #6dd6f4; }
font-variation-settings:
'FILL' 0,
'wght' 300,
'GRAD' 0,
'opsz' 24
}

View File

@@ -3,6 +3,7 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 16px; gap: 16px;
justify-content: center; justify-content: center;
margin-top: 18px;
padding: 24px; padding: 24px;
} }
@@ -23,8 +24,69 @@
padding: 16px; padding: 16px;
} }
.cluster-draggable {
cursor: grab;
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.15s ease;
}
.cluster-draggable:active {
cursor: grabbing;
}
.cluster-drag-over {
border-color: #ffb7ce;
box-shadow: 0 0 12px rgba(255, 183, 206, 0.4);
transform: scale(1.01);
}
.cluster-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.cluster-rank {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: #6dd6f4;
color: #1a1a2e;
font-weight: 700;
font-size: 0.95em;
flex-shrink: 0;
}
.cluster-label { .cluster-label {
margin: 0 0 16px 0; margin: 0;
font-size: 1.2em; font-size: 1.2em;
font-weight: 600; font-weight: 600;
flex: 1;
}
.cluster-drag-handle {
font-size: 1.4em;
color: #6dd6f4;
opacity: 0.4;
user-select: none;
transition: opacity 0.2s ease;
flex-shrink: 0;
}
.cluster-draggable:hover .cluster-drag-handle {
opacity: 0.8;
}
.cohesion-score {
text-align: center;
font-size: 0.95em;
color: #e0e0e0;
padding: 8px 16px;
background: rgba(109, 214, 244, 0.1);
border-radius: 6px;
width: fit-content;
margin: 0 auto;
} }

View File

@@ -9,10 +9,13 @@ const MOCK_STICKIES = [
{ id: 'note_002', text: 'Export takes too long', x: 798, y: 211, author: 'user_2', color: 'green' }, { id: 'note_002', text: 'Export takes too long', x: 798, y: 211, author: 'user_2', color: 'green' },
]; ];
const MOCK_CLUSTERS = [ const MOCK_CLUSTER_RESPONSE = {
{ label: 'Auth Issues', noteIds: ['note_001'] }, clusters: [
{ label: 'Export Issues', noteIds: ['note_002'] }, { label: 'Auth Issues', noteIds: ['note_001'] },
]; { label: 'Export Issues', noteIds: ['note_002'] },
],
score: 0.74,
};
let fetchMock: ReturnType<typeof vi.fn>; let fetchMock: ReturnType<typeof vi.fn>;
@@ -70,10 +73,9 @@ describe('Stickies', () => {
}); });
it('should show cluster labels after clustering succeeds', async () => { it('should show cluster labels after clustering succeeds', async () => {
// First call = GET notes, second call = POST cluster
fetchMock fetchMock
.mockReturnValueOnce(mockFetchOk(MOCK_STICKIES)) .mockReturnValueOnce(mockFetchOk(MOCK_STICKIES))
.mockReturnValueOnce(mockFetchOk(MOCK_CLUSTERS)); .mockReturnValueOnce(mockFetchOk(MOCK_CLUSTER_RESPONSE));
const { Wrapper } = createTestWrapper(); const { Wrapper } = createTestWrapper();
render(<Stickies />, { wrapper: Wrapper }); render(<Stickies />, { wrapper: Wrapper });
@@ -90,7 +92,7 @@ describe('Stickies', () => {
it('should still render sticky note text inside clusters', async () => { it('should still render sticky note text inside clusters', async () => {
fetchMock fetchMock
.mockReturnValueOnce(mockFetchOk(MOCK_STICKIES)) .mockReturnValueOnce(mockFetchOk(MOCK_STICKIES))
.mockReturnValueOnce(mockFetchOk(MOCK_CLUSTERS)); .mockReturnValueOnce(mockFetchOk(MOCK_CLUSTER_RESPONSE));
const { Wrapper } = createTestWrapper(); const { Wrapper } = createTestWrapper();
render(<Stickies />, { wrapper: Wrapper }); render(<Stickies />, { wrapper: Wrapper });
@@ -101,4 +103,41 @@ describe('Stickies', () => {
expect(await screen.findByText('Login flow feels confusing')).toBeInTheDocument(); expect(await screen.findByText('Login flow feels confusing')).toBeInTheDocument();
expect(screen.getByText('Export takes too long')).toBeInTheDocument(); expect(screen.getByText('Export takes too long')).toBeInTheDocument();
}); });
it('should display rank badges on clustered groups', async () => {
fetchMock
.mockReturnValueOnce(mockFetchOk(MOCK_STICKIES))
.mockReturnValueOnce(mockFetchOk(MOCK_CLUSTER_RESPONSE));
const { Wrapper } = createTestWrapper();
render(<Stickies />, { wrapper: Wrapper });
const btn = await screen.findByRole('button', { name: 'Group Stickies By Topic' });
await userEvent.click(btn);
await waitFor(() => {
expect(screen.getByLabelText('Priority 1')).toBeInTheDocument();
expect(screen.getByLabelText('Priority 2')).toBeInTheDocument();
});
});
it('should make cluster groups draggable', async () => {
fetchMock
.mockReturnValueOnce(mockFetchOk(MOCK_STICKIES))
.mockReturnValueOnce(mockFetchOk(MOCK_CLUSTER_RESPONSE));
const { Wrapper } = createTestWrapper();
render(<Stickies />, { wrapper: Wrapper });
const btn = await screen.findByRole('button', { name: 'Group Stickies By Topic' });
await userEvent.click(btn);
await waitFor(() => {
const groups = document.querySelectorAll('.cluster-draggable');
groups.forEach((group) => {
expect(group).toHaveAttribute('draggable', 'true');
});
expect(groups.length).toBe(2);
});
});
}); });

View File

@@ -11,3 +11,12 @@ export type Cluster = {
label: string; label: string;
noteIds: string[]; noteIds: string[];
}; };
export type ClusterResponse = {
clusters: Cluster[];
score: number;
};
export type RankedCluster = Cluster & {
rank: number;
};