Compare commits
14 Commits
refact5
...
query-vali
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ec074a043 | ||
|
|
b7757ca406 | ||
|
|
d55f58985b | ||
|
|
61835ae3aa | ||
|
|
091315c188 | ||
|
|
089b9e7e9d | ||
|
|
3e7978c918 | ||
|
|
317468b1ed | ||
|
|
a720673896 | ||
|
|
1a85c6e1e0 | ||
|
|
7f4c322280 | ||
|
|
5208327b5d | ||
|
|
ac76871d60 | ||
|
|
66c86c2625 |
18
README.md
18
README.md
@@ -1,19 +1,21 @@
|
||||
# kongruity app
|
||||
|
||||
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 clusters large volumes of unstructured action items in development settings by their thematic or topical similarity.
|
||||
|
||||
(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, "to dos", action items, agile tickets, Jira comments (appropriately tagged) ... all the myriad artifacts of the creative-engineering process, become thematic "sticky notes."
|
||||
|
||||
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.
|
||||
kongruity's React/Vite UI displays a board of your team's seemingly chaotic "sticky notes".
|
||||
|
||||
The backend is an Express API that serves "sticky note" data and proxies semantic grouping requests to Antrhopic Claude.
|
||||
They are transformed into manageable, actionable groups, with headers that explains each group's semantic relation, and project magament UI options: ranking temporal priority for future relase goals.
|
||||
|
||||
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.
|
||||
The backend features an Express server/API that serves "sticky note" data and proxies semantic grouping requests to Large Language Models.
|
||||
|
||||
Developers may freely swap in other LLM SDKs and/or APIs... and alter prompt syntax at backend/services/clustering.service.js to complement R&D with any LLM model/platform they prefer.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js (v18 or later recommended)
|
||||
- An [Anthropic API key](https://console.anthropic.com/)
|
||||
- An LLM Platform API key
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -29,10 +31,10 @@ cd kongruity
|
||||
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:
|
||||
|
||||
```bash
|
||||
echo 'ANTHROPIC_API_KEY=<your Anthropic API key>' > backend/.env
|
||||
echo 'LLM_API_KEY=<your LLM API key>' > backend/.env
|
||||
```
|
||||
|
||||
Replace `<your Anthropic API key>` with your actual key.
|
||||
Replace `<your LLM API key>` with your actual key.
|
||||
|
||||
### 3. Install dependencies
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ const app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.use('/v1/notes', router);
|
||||
|
||||
app.use((req, res) => {
|
||||
|
||||
230
backend/package-lock.json
generated
230
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFile } from 'fs/promises';
|
||||
import { clusterNotes } from '../services/clustering.service.js';
|
||||
|
||||
const router = Router();
|
||||
const DATA_PATH = '../data/notes.json';
|
||||
const DATA_PATH = new URL('../data/notes.json', import.meta.url);
|
||||
|
||||
const loadNotes = async () => {
|
||||
const raw = await readFile(DATA_PATH, 'utf-8');
|
||||
@@ -23,11 +23,11 @@ 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: ${err}` });
|
||||
res.status(500).json({ error: 'Clustering failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
25
backend/services/embedding.service.js
Normal file
25
backend/services/embedding.service.js
Normal 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;
|
||||
};
|
||||
129
backend/services/validation.service.js
Normal file
129
backend/services/validation.service.js
Normal 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;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('POST /v1/notes/cluster', () => {
|
||||
|
||||
it('should return 500 when clusterNotes (API call) fails', async () => {
|
||||
readFile.mockResolvedValue(JSON.stringify(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');
|
||||
|
||||
|
||||
2
frontend/.env.development
Normal file
2
frontend/.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_APP_URL=localhost:3000
|
||||
VITE_API_BASE=http://localhost:3001
|
||||
2
frontend/.env.production
Normal file
2
frontend/.env.production
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_APP_URL=https://example.com
|
||||
VITE_API_BASE=https://www.example.com:4000
|
||||
@@ -1,7 +1,7 @@
|
||||
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 response = await fetch(API_BASE);
|
||||
@@ -11,7 +11,7 @@ const fetchStickies = async (): Promise<Sticky[]> => {
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const fetchClusters = async (): Promise<Cluster[]> => {
|
||||
const fetchClusters = async (): Promise<ClusterResponse> => {
|
||||
const response = await fetch(`${API_BASE}/cluster`, {
|
||||
method: 'POST',
|
||||
});
|
||||
@@ -29,7 +29,7 @@ export const useGetStickies = () => {
|
||||
};
|
||||
|
||||
export const useClusterStickies = () => {
|
||||
return useMutation<Cluster[]>({
|
||||
return useMutation<ClusterResponse>({
|
||||
mutationFn: fetchClusters,
|
||||
});
|
||||
};
|
||||
@@ -4,13 +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 <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
const clusters = clusterResponse?.clusters;
|
||||
const score = clusterResponse?.score;
|
||||
|
||||
const handleCluster = () => {
|
||||
cluster();
|
||||
};
|
||||
@@ -24,20 +33,25 @@ const Stickies = () => {
|
||||
const stickyMap = buildStickyMap();
|
||||
|
||||
const renderStickies = (items: StickyType[]) =>
|
||||
items.map((sticky) => <Sticky key={sticky.id} sticky={sticky} />);
|
||||
items?.map((sticky) => <Sticky key={sticky.id} sticky={sticky} />);
|
||||
|
||||
return (
|
||||
<div className="stickies-container">
|
||||
<Button onClick={handleCluster} isLoading={isPending} label="Group Stickies By Topic" />
|
||||
{clusters ? (
|
||||
<div className="clusters-container">
|
||||
{score != null && (
|
||||
<div className="cohesion-score">
|
||||
Cluster cohesion: <strong>{score.toFixed(2)}</strong> — {scoreLabel(score)}
|
||||
</div>
|
||||
)}
|
||||
{clusters.map((group) => (
|
||||
<div key={group.label} className="cluster-group">
|
||||
<h3 className="cluster-label">{group.label}</h3>
|
||||
<div className="stickies-grid">
|
||||
{renderStickies(
|
||||
group.noteIds
|
||||
.map((id) => stickyMap.get(id))
|
||||
group?.noteIds
|
||||
.map((id) => stickyMap?.get(id))
|
||||
.filter((s): s is StickyType => !!s)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,5 +14,5 @@ createRoot(document.getElementById('root')!).render(
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
@@ -4,14 +4,10 @@ import '../styles/home.css'
|
||||
|
||||
const Home = () => {
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<Navbar />
|
||||
</div>
|
||||
<div>
|
||||
<Stickies />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.main-head-box {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #6dd6f4;
|
||||
background-color: rgb(92, 0 91);
|
||||
background-color: rgb(92, 0, 91);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -37,5 +37,37 @@
|
||||
'FILL' 0,
|
||||
'wght' 300,
|
||||
'GRAD' 0,
|
||||
'opsz' 24
|
||||
'opsz' 24;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 478px) {
|
||||
.main-head-subbox-right {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-head-subbox-left {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main-head {
|
||||
font-size: 4rem;
|
||||
color: #6dd6f4;
|
||||
font-family: "Sulphur Point", sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
font-weight: 400;
|
||||
letter-spacing: 2px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
margin-top: 18px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@@ -28,3 +29,14 @@
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -9,10 +9,13 @@ const MOCK_STICKIES = [
|
||||
{ id: 'note_002', text: 'Export takes too long', x: 798, y: 211, author: 'user_2', color: 'green' },
|
||||
];
|
||||
|
||||
const MOCK_CLUSTERS = [
|
||||
const MOCK_CLUSTER_RESPONSE = {
|
||||
clusters: [
|
||||
{ label: 'Auth Issues', noteIds: ['note_001'] },
|
||||
{ label: 'Export Issues', noteIds: ['note_002'] },
|
||||
];
|
||||
],
|
||||
score: 0.74,
|
||||
};
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -73,7 +76,7 @@ describe('Stickies', () => {
|
||||
// First call = GET notes, second call = POST cluster
|
||||
fetchMock
|
||||
.mockReturnValueOnce(mockFetchOk(MOCK_STICKIES))
|
||||
.mockReturnValueOnce(mockFetchOk(MOCK_CLUSTERS));
|
||||
.mockReturnValueOnce(mockFetchOk(MOCK_CLUSTER_RESPONSE));
|
||||
|
||||
const { Wrapper } = createTestWrapper();
|
||||
render(<Stickies />, { wrapper: Wrapper });
|
||||
@@ -90,7 +93,7 @@ describe('Stickies', () => {
|
||||
it('should still render sticky note text inside clusters', async () => {
|
||||
fetchMock
|
||||
.mockReturnValueOnce(mockFetchOk(MOCK_STICKIES))
|
||||
.mockReturnValueOnce(mockFetchOk(MOCK_CLUSTERS));
|
||||
.mockReturnValueOnce(mockFetchOk(MOCK_CLUSTER_RESPONSE));
|
||||
|
||||
const { Wrapper } = createTestWrapper();
|
||||
render(<Stickies />, { wrapper: Wrapper });
|
||||
|
||||
@@ -11,3 +11,8 @@ export type Cluster = {
|
||||
label: string;
|
||||
noteIds: string[];
|
||||
};
|
||||
|
||||
export type ClusterResponse = {
|
||||
clusters: Cluster[];
|
||||
score: number;
|
||||
};
|
||||
Reference in New Issue
Block a user