From b7757ca406e18bd9d8464125a0244cdb3f7dc640 Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Tue, 24 Feb 2026 12:56:18 -0500 Subject: [PATCH] add second layer of result validation --- backend/package-lock.json | 230 ++++++++++++++++++++++- backend/package.json | 3 +- backend/routes/notes.routes.js | 4 +- backend/services/clustering.service.js | 23 ++- backend/services/embedding.service.js | 25 +++ backend/services/validation.service.js | 129 +++++++++++++ backend/tests/Clustering.service.test.js | 30 ++- frontend/src/api/api.ts | 6 +- frontend/src/components/stickies.tsx | 17 +- frontend/src/styles/stickies.css | 11 ++ frontend/src/tests/stickies.test.tsx | 15 +- frontend/src/types/types.d.ts | 5 + 12 files changed, 472 insertions(+), 26 deletions(-) create mode 100644 backend/services/embedding.service.js create mode 100644 backend/services/validation.service.js diff --git a/backend/package-lock.json b/backend/package-lock.json index cf41e38..3874b22 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,7 +11,8 @@ "@anthropic-ai/sdk": "^0.74.0", "cors": "^2.8.5", "dotenv": "^16.4.7", - "express": "^4.21.2" + "express": "^4.21.2", + "voyageai": "^0.1.0" }, "devDependencies": { "nodemon": "^3.1.9", @@ -1013,6 +1014,18 @@ "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": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1067,7 +1080,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/balanced-match": { @@ -1077,6 +1089,26 @@ "dev": true, "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": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1138,6 +1170,30 @@ "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": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1215,7 +1271,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -1314,7 +1369,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -1432,7 +1486,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1511,6 +1564,24 @@ "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": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -1609,7 +1680,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -1622,6 +1692,15 @@ "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": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", @@ -1770,7 +1849,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -1826,6 +1904,26 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", @@ -1894,6 +1992,12 @@ "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": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", @@ -2033,6 +2137,26 @@ "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": { "version": "3.1.11", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", @@ -2222,6 +2346,15 @@ "node": "^10 || ^12 || >=14" } }, + "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": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2281,6 +2414,22 @@ "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": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -2554,6 +2703,15 @@ "dev": true, "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": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", @@ -2759,6 +2917,12 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", @@ -2794,6 +2958,12 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -3011,6 +3181,52 @@ "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": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/backend/package.json b/backend/package.json index acf41c4..819d058 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,7 +13,8 @@ "@anthropic-ai/sdk": "^0.74.0", "cors": "^2.8.5", "dotenv": "^16.4.7", - "express": "^4.21.2" + "express": "^4.21.2", + "voyageai": "^0.1.0" }, "devDependencies": { "nodemon": "^3.1.9", diff --git a/backend/routes/notes.routes.js b/backend/routes/notes.routes.js index 10d2c41..36e72e1 100644 --- a/backend/routes/notes.routes.js +++ b/backend/routes/notes.routes.js @@ -23,8 +23,8 @@ router.get('/', async (req, res) => { router.post('/cluster', async (req, res) => { try { const notes = await loadNotes(); - const clusters = await clusterNotes(notes); - res.json(clusters); + const result = await clusterNotes(notes); + res.json(result); } catch (err) { console.error(`Clustering failed: ${err}`); res.status(500).json({ error: 'Clustering failed' }); diff --git a/backend/services/clustering.service.js b/backend/services/clustering.service.js index d342c53..cdc0af5 100644 --- a/backend/services/clustering.service.js +++ b/backend/services/clustering.service.js @@ -1,4 +1,6 @@ import Anthropic from "@anthropic-ai/sdk"; +import { embedNotes } from "./embedding.service.js"; +import { validateStructure, computeCohesionScore } from "./validation.service.js"; const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, @@ -31,7 +33,7 @@ Here are the notes: ${notesJson}`; }; -export const clusterNotes = async (notes) => { +const requestClusters = async (notes) => { const response = await client.messages.create({ model: "claude-sonnet-4-20250514", max_tokens: 4096, @@ -41,7 +43,7 @@ export const clusterNotes = async (notes) => { }); const textBlock = response?.content?.[0]; - + if (!textBlock || textBlock.type !== 'text' || typeof textBlock.text !== 'string') { 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'); } }; + +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 }; +}; diff --git a/backend/services/embedding.service.js b/backend/services/embedding.service.js new file mode 100644 index 0000000..209faf7 --- /dev/null +++ b/backend/services/embedding.service.js @@ -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>} 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; +}; diff --git a/backend/services/validation.service.js b/backend/services/validation.service.js new file mode 100644 index 0000000..61cc3a3 --- /dev/null +++ b/backend/services/validation.service.js @@ -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} 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; +}; diff --git a/backend/tests/Clustering.service.test.js b/backend/tests/Clustering.service.test.js index 711f6bf..139cd3e 100644 --- a/backend/tests/Clustering.service.test.js +++ b/backend/tests/Clustering.service.test.js @@ -1,7 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { createMock } = vi.hoisted(() => { - return { createMock: vi.fn() }; +const { createMock, mockEmbeddings } = vi.hoisted(() => { + 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', () => { @@ -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'; const MOCK_NOTES = [ @@ -60,14 +68,17 @@ describe('clusterNotes service', () => { 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({ content: [{ type: 'text', text: JSON.stringify(MOCK_CLUSTERS) }], }); 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 () => { @@ -89,4 +100,15 @@ describe('clusterNotes service', () => { 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'); + }); }); diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 7ec3265..e210a00 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -1,5 +1,5 @@ import { useQuery, useMutation } from '@tanstack/react-query'; -import type { Sticky, Cluster } from '../types/types'; +import type { Sticky, ClusterResponse } from '../types/types'; const API_BASE = `${import.meta.env.VITE_API_BASE}/v1/notes`; @@ -11,7 +11,7 @@ const fetchStickies = async (): Promise => { return response.json(); }; -const fetchClusters = async (): Promise => { +const fetchClusters = async (): Promise => { const response = await fetch(`${API_BASE}/cluster`, { method: 'POST', }); @@ -29,7 +29,7 @@ export const useGetStickies = () => { }; export const useClusterStickies = () => { - return useMutation({ + return useMutation({ mutationFn: fetchClusters, }); }; diff --git a/frontend/src/components/stickies.tsx b/frontend/src/components/stickies.tsx index 6311144..fe199c0 100644 --- a/frontend/src/components/stickies.tsx +++ b/frontend/src/components/stickies.tsx @@ -4,12 +4,22 @@ import Sticky from './sticky'; import Button from './button'; 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 { data: stickies, isLoading, error } = useGetStickies(); - const { mutate: cluster, data: clusters, isPending } = useClusterStickies(); + const { mutate: cluster, data: clusterResponse, isPending } = useClusterStickies(); if (isLoading) return
Loading...
; if (error) return
Error: {error.message}
; + const clusters = clusterResponse?.clusters; + const score = clusterResponse?.score; + const handleCluster = () => { cluster(); }; @@ -30,6 +40,11 @@ const Stickies = () => {