Merge pull request #13 from kjannette/workflow-UI

Add UI/logical temporal ordering feature
This commit is contained in:
S Jannette
2026-02-24 13:32:29 -05:00
committed by GitHub
5 changed files with 193 additions and 25 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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);
});
});
}); });

View File

@@ -16,3 +16,7 @@ export type ClusterResponse = {
clusters: Cluster[]; clusters: Cluster[];
score: number; score: number;
}; };
export type RankedCluster = Cluster & {
rank: number;
};