Add UI/logical temporal ordering feature
This commit is contained in:
48
README.md
48
README.md
@@ -1,19 +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" - transformed into manageable, actionable groups, with headers that explain their semantic relation, and introduces project management workflow: ranking temporal priority of clusters for future release.
|
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.
|
||||||
|
|
||||||
The backend features an Express server/API that serves "sticky note" data and proxies semantic grouping requests to Large Language Models.
|
## How it works
|
||||||
|
|
||||||
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.
|
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 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
|
## Setup
|
||||||
|
|
||||||
@@ -26,13 +35,16 @@ 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 '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
|
### 3. Install dependencies
|
||||||
|
|
||||||
@@ -48,7 +60,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 +70,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 +86,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 +105,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 +116,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
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
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';
|
||||||
@@ -14,10 +16,22 @@ const scoreLabel = (score: number): string => {
|
|||||||
const Stickies = () => {
|
const Stickies = () => {
|
||||||
const { data: stickies, isLoading, error } = useGetStickies();
|
const { data: stickies, isLoading, error } = useGetStickies();
|
||||||
const { mutate: cluster, data: clusterResponse, 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 clusters = clusterResponse?.clusters;
|
|
||||||
const score = clusterResponse?.score;
|
const score = clusterResponse?.score;
|
||||||
|
|
||||||
const handleCluster = () => {
|
const handleCluster = () => {
|
||||||
@@ -35,19 +49,69 @@ const Stickies = () => {
|
|||||||
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">
|
||||||
{score != null && (
|
{score != null && (
|
||||||
<div className="cohesion-score">
|
<div className="cohesion-score">
|
||||||
Cluster cohesion: <strong>{score.toFixed(2)}</strong> — {scoreLabel(score)}
|
Cluster cohesion: <strong>{score.toFixed(2)}</strong> — {scoreLabel(score)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{clusters.map((group) => (
|
{rankedClusters.map((group, index) => (
|
||||||
<div key={group.label} className="cluster-group">
|
<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>
|
<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
|
||||||
|
|||||||
@@ -24,10 +24,60 @@
|
|||||||
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 {
|
.cohesion-score {
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ 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_CLUSTER_RESPONSE));
|
.mockReturnValueOnce(mockFetchOk(MOCK_CLUSTER_RESPONSE));
|
||||||
@@ -104,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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[];
|
clusters: Cluster[];
|
||||||
score: number;
|
score: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RankedCluster = Cluster & {
|
||||||
|
rank: number;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user