Compare commits
3 Commits
query-vali
...
workflow-U
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ba13206c5 | ||
|
|
0c7baeb9d8 | ||
|
|
07b0309adb |
48
README.md
48
README.md
@@ -1,21 +1,28 @@
|
||||
# kongruity app
|
||||
# kongruity
|
||||
|
||||
kongruity clusters large volumes of unstructured action items in development settings by their thematic or topical similarity.
|
||||
kongruity pulls in the unstructured artifacts of the creative-engineering process -- to-dos, action items, agile tickets, Jira comment threads, retrospective notes -- and synthesizes them into semantically coherent, prioritized clusters ready for implementation planning.
|
||||
|
||||
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 kongruity world, these artifacts are "sticky notes." A board full of them looks chaotic. With one click, an LLM analyzes their meaning and groups them into thematic clusters, each with a descriptive header.
|
||||
|
||||
kongruity's React/Vite UI displays a board of your team's seemingly chaotic "sticky notes".
|
||||
An independent embedding-based evaluation scores clustering quality, so the output is data-backed. From there, teams can cimply drag-and-rank clusters by implementation priority, turning a wall of noise into an actionable workflow.
|
||||
|
||||
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.
|
||||
## How it works
|
||||
|
||||
The backend features an Express server/API that serves "sticky note" data and proxies semantic grouping requests to Large Language Models.
|
||||
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.
|
||||
|
||||
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.
|
||||
## 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
|
||||
|
||||
- Node.js (v18 or later recommended)
|
||||
- An LLM Platform API key
|
||||
- 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
|
||||
|
||||
@@ -28,13 +35,16 @@ cd kongruity
|
||||
|
||||
### 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
|
||||
echo 'LLM_API_KEY=<your LLM API key>' > backend/.env
|
||||
cat > backend/.env << 'EOF'
|
||||
ANTHROPIC_API_KEY=<your Anthropic API key>
|
||||
VOYAGEAI_API_KEY=<your Voyage AI API key>
|
||||
EOF
|
||||
```
|
||||
|
||||
Replace `<your LLM API key>` with your actual key.
|
||||
Replace placeholder values with your actual keys.
|
||||
|
||||
### 3. Install dependencies
|
||||
|
||||
@@ -50,7 +60,7 @@ npm install
|
||||
|
||||
## Run the app
|
||||
|
||||
### Start the backend - Production Mode
|
||||
### Start the backend — Production mode
|
||||
|
||||
From the `backend/` directory:
|
||||
|
||||
@@ -60,15 +70,15 @@ npm run start
|
||||
|
||||
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
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Build the frontend - Production mode
|
||||
### Build the frontend — Production mode
|
||||
|
||||
From the `frontend/` directory:
|
||||
|
||||
@@ -76,13 +86,14 @@ From the `frontend/` directory:
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Start the frontend - Development mode
|
||||
### Start the frontend — Development mode
|
||||
|
||||
From the `frontend/` directory:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The Vite dev server starts on **http://localhost:5173** by default. Open that URL in a browser.
|
||||
|
||||
## Running tests
|
||||
@@ -94,7 +105,8 @@ From the `backend/` directory:
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
Backend tests use Supertest for HTTP assertions.
|
||||
|
||||
Backend tests use Vitest with Supertest for HTTP assertions.
|
||||
|
||||
### Frontend tests
|
||||
|
||||
@@ -104,7 +116,7 @@ From the `frontend/` directory:
|
||||
npm test
|
||||
```
|
||||
|
||||
This runs `vitest run` with jsdom. For watch mode, during development:
|
||||
This runs Vitest with jsdom. For watch mode during development:
|
||||
|
||||
```bash
|
||||
npm run test:watch
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { DragEvent } from 'react';
|
||||
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 Button from './button';
|
||||
import '../styles/stickies.css';
|
||||
@@ -14,10 +16,22 @@ const scoreLabel = (score: number): string => {
|
||||
const Stickies = () => {
|
||||
const { data: stickies, isLoading, error } = useGetStickies();
|
||||
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 (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
const clusters = clusterResponse?.clusters;
|
||||
const score = clusterResponse?.score;
|
||||
|
||||
const handleCluster = () => {
|
||||
@@ -35,19 +49,69 @@ const Stickies = () => {
|
||||
const renderStickies = (items: StickyType[]) =>
|
||||
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 (
|
||||
<div className="stickies-container">
|
||||
<Button onClick={handleCluster} isLoading={isPending} label="Group Stickies By Topic" />
|
||||
{clusters ? (
|
||||
{rankedClusters.length > 0 ? (
|
||||
<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>
|
||||
{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">
|
||||
{renderStickies(
|
||||
group?.noteIds
|
||||
|
||||
@@ -24,10 +24,60 @@
|
||||
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 {
|
||||
margin: 0 0 16px 0;
|
||||
margin: 0;
|
||||
font-size: 1.2em;
|
||||
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 {
|
||||
|
||||
@@ -73,7 +73,6 @@ describe('Stickies', () => {
|
||||
});
|
||||
|
||||
it('should show cluster labels after clustering succeeds', async () => {
|
||||
// First call = GET notes, second call = POST cluster
|
||||
fetchMock
|
||||
.mockReturnValueOnce(mockFetchOk(MOCK_STICKIES))
|
||||
.mockReturnValueOnce(mockFetchOk(MOCK_CLUSTER_RESPONSE));
|
||||
@@ -104,4 +103,41 @@ describe('Stickies', () => {
|
||||
expect(await screen.findByText('Login flow feels confusing')).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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
4
frontend/src/types/types.d.ts
vendored
4
frontend/src/types/types.d.ts
vendored
@@ -16,3 +16,7 @@ export type ClusterResponse = {
|
||||
clusters: Cluster[];
|
||||
score: number;
|
||||
};
|
||||
|
||||
export type RankedCluster = Cluster & {
|
||||
rank: number;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user