11 Commits

Author SHA1 Message Date
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
KS Jannette
56d0c21b79 clean 2026-02-13 13:45:00 -05:00
S Jannette
37e81e773e Merge pull request #9 from kjannette/refactor4
more
2026-02-13 13:42:52 -05:00
KS Jannette
534da11217 more 2026-02-13 13:42:31 -05:00
S Jannette
2212755772 Merge pull request #8 from kjannette/refact3
cleanup
2026-02-13 13:35:29 -05:00
KS Jannette
f2e1390507 cleanup 2026-02-13 13:35:11 -05:00
S Jannette
31496876a2 Merge pull request #7 from kjannette/refact2
env
2026-02-13 13:25:59 -05:00
KS Jannette
050e1ea522 env 2026-02-13 13:25:33 -05:00
S Jannette
1f878ad3d6 Merge pull request #6 from kjannette/refact
more
2026-02-13 13:18:18 -05:00
10 changed files with 47 additions and 39 deletions

2
.gitignore vendored
View File

@@ -21,4 +21,4 @@ lerna-debug.log*
*.sln *.sln
*.sw? *.sw?
backend/.secrets.js backend/.env

View File

@@ -4,11 +4,13 @@ kongruity employs Large Language Model ("LLM") semantic grouping functionality t
(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)). (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 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. In kongruity world, "to dos", action items, agile tickets... the myriad artifacts of the creative/engineering process, are "sticky notes."
The backend is an Express API that serves "sticky note" data and proxies semantic grouping requests to Antrhopic Claude. kongruity's React/Vite UI displays a board of seemingly chaotic "sticky notes". But with one click, they are transformed into manageable, actionable groups, each with a header that explains that group's semantic relation.
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 is an Express API that serves "sticky note" data and proxies semantic grouping requests to Large Language Models.
Developers may freeely swap in other LLM SDKs and/or APIs... and alter prompt syntax at backend/services/clustering.service.js to compliement their experimentation with any LLM model/platform they prefer.
## Prerequisites ## Prerequisites
@@ -24,12 +26,12 @@ unzip kongruity.zip
cd kongruity cd kongruity
``` ```
### 2. Create a secrets file ### 2. Create an environment file
The backend expects a `.secrets.js` 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 containing an Anthropic API key in the root `backend/` directory. This file is git-ignored and must be created manually:
```bash ```bash
echo 'export const anthropicApiKey = "<your Anthropic API key>"' > backend/.secrets.js echo 'ANTHROPIC_API_KEY=<your Anthropic API key>' > backend/.env
``` ```
Replace `<your Anthropic API key>` with your actual key. Replace `<your Anthropic API key>` with your actual key.

View File

@@ -11,6 +11,6 @@ app.use('/v1/notes', router);
app.use((req, res) => { app.use((req, res) => {
res.status(404).json({ error: `Requested path is invalid or does not exist: ${req.method} ${req.originalUrl}` }); res.status(404).json({ error: `Requested path is invalid or does not exist: ${req.method} ${req.originalUrl}` });
}); });
export default app; export default app;

View File

@@ -1,14 +1,9 @@
import { Router } from 'express'; import { Router } from 'express';
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { clusterNotes } from '../services/clustering.service.js'; import { clusterNotes } from '../services/clustering.service.js';
const router = Router(); const router = Router();
const DATA_PATH = new URL('../data/notes.json', import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const DATA_PATH = join(__dirname, '..', 'data', 'notes.json');
const loadNotes = async () => { const loadNotes = async () => {
const raw = await readFile(DATA_PATH, 'utf-8'); const raw = await readFile(DATA_PATH, 'utf-8');
@@ -20,7 +15,7 @@ router.get('/', async (req, res) => {
const notes = await loadNotes(); const notes = await loadNotes();
res.json(notes); res.json(notes);
} catch (err) { } catch (err) {
console.error(`Error loading notes: ${err}`) console.error(`Error loading notes: ${err}`);
res.status(500).json({ error: 'Failed to load notes' }); res.status(500).json({ error: 'Failed to load notes' });
} }
}); });
@@ -31,8 +26,8 @@ router.post('/cluster', async (req, res) => {
const clusters = await clusterNotes(notes); const clusters = await clusterNotes(notes);
res.json(clusters); res.json(clusters);
} 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,3 +1,4 @@
import 'dotenv/config';
import app from './app.js'; import app from './app.js';
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;

View File

@@ -1,8 +1,7 @@
import Anthropic from "@anthropic-ai/sdk"; import Anthropic from "@anthropic-ai/sdk";
import { anthropicApiKey } from "../.secrets.js";
const client = new Anthropic({ const client = new Anthropic({
apiKey: anthropicApiKey, apiKey: process.env.ANTHROPIC_API_KEY,
}); });
const buildPrompt = (notes) => { const buildPrompt = (notes) => {
@@ -41,6 +40,15 @@ export const clusterNotes = async (notes) => {
], ],
}); });
const raw = response.content[0].text; const textBlock = response?.content?.[0];
return JSON.parse(raw);
if (!textBlock || textBlock.type !== 'text' || typeof textBlock.text !== 'string') {
throw new Error('Unexpected response from LLM API: no text content returned');
}
try {
return JSON.parse(textBlock.text);
} catch {
throw new Error('LLM API returned non-JSON response');
}
}; };

View File

@@ -14,10 +14,6 @@ vi.mock('@anthropic-ai/sdk', () => {
}; };
}); });
vi.mock('../.secrets.js', () => ({
anthropicApiKey: 'test-key-not-real',
}));
import { clusterNotes } from '../services/clustering.service.js'; import { clusterNotes } from '../services/clustering.service.js';
const MOCK_NOTES = [ const MOCK_NOTES = [
@@ -39,7 +35,7 @@ describe('clusterNotes service', () => {
it('should call Anthropic messages.create with the correct model', async () => { it('should call Anthropic messages.create with the correct model', async () => {
createMock.mockResolvedValue({ createMock.mockResolvedValue({
content: [{ text: JSON.stringify(MOCK_CLUSTERS) }], content: [{ type: 'text', text: JSON.stringify(MOCK_CLUSTERS) }],
}); });
await clusterNotes(MOCK_NOTES); await clusterNotes(MOCK_NOTES);
@@ -52,7 +48,7 @@ describe('clusterNotes service', () => {
it('should include all note texts in prompt sent to the LLM API', async () => { it('should include all note texts in prompt sent to the LLM API', async () => {
createMock.mockResolvedValue({ createMock.mockResolvedValue({
content: [{ text: JSON.stringify(MOCK_CLUSTERS) }], content: [{ type: 'text', text: JSON.stringify(MOCK_CLUSTERS) }],
}); });
await clusterNotes(MOCK_NOTES); await clusterNotes(MOCK_NOTES);
@@ -66,7 +62,7 @@ describe('clusterNotes service', () => {
it('should parse and return the clustered JSON from the API response', async () => { it('should parse and return the clustered JSON from the API response', async () => {
createMock.mockResolvedValue({ createMock.mockResolvedValue({
content: [{ 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);
@@ -76,10 +72,16 @@ describe('clusterNotes service', () => {
it('should throw error when the API returns non-JSON', async () => { it('should throw error when the API returns non-JSON', async () => {
createMock.mockResolvedValue({ createMock.mockResolvedValue({
content: [{ text: 'An unknown error occured when generting structured response.' }], content: [{ type: 'text', text: 'An unknown error occured when generting structured response.' }],
}); });
await expect(clusterNotes(MOCK_NOTES)).rejects.toThrow(); await expect(clusterNotes(MOCK_NOTES)).rejects.toThrow('non-JSON response');
});
it('should throw error when the API response has no text content', async () => {
createMock.mockResolvedValue({ content: [] });
await expect(clusterNotes(MOCK_NOTES)).rejects.toThrow('no text content returned');
}); });
it('should throw error when Anthropic API authentication fails', async () => { it('should throw error when Anthropic API authentication fails', async () => {

View File

@@ -24,7 +24,7 @@ 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} />);
return ( return (
<div className="stickies-container"> <div className="stickies-container">
@@ -36,8 +36,8 @@ const Stickies = () => {
<h3 className="cluster-label">{group.label}</h3> <h3 className="cluster-label">{group.label}</h3>
<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

@@ -5,12 +5,12 @@ import '../styles/home.css'
const Home = () => { const Home = () => {
return ( return (
<div> <div>
<div>
<Navbar /> <Navbar />
</div>
<div>
<Stickies /> <Stickies />
</div>
</div> </div>
); );
}; };

View File

@@ -1,7 +1,7 @@
.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;
} }