Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: ci

on:
push:
pull_request:

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm run lint
- run: npm run test
- run: npm run build
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules
npm-debug.log*
.env
.env.*
dist
coverage
*.sqlite-journal
*.db-journal
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Graph-Based Corporate Conversation Trainer

A full-stack deterministic conversation training system that uses finite-state graph traversal instead of LLMs.

## Features
- Deterministic dialogue traversal (exact, regex, intent similarity, fuzzy fallback)
- SQLite persistence for conversations and interaction logs
- Editable scenario JSON files with three sample scenarios
- React chat interface with quick reply buttons
- Graph inspector panel for real-time scenario node visibility
- Validation endpoint for graph edge integrity

## Architecture
```mermaid
flowchart LR
User --> Web[React + Zustand]
Web --> API[Express API]
API --> Engine[Graph Engine]
API --> DB[(SQLite)]
Engine --> Scenarios[JSON Scenarios]
```

## Monorepo Structure
- `apps/api` Express backend, graph engine, SQLite, scenario files
- `apps/web` React frontend chat + graph viewer
- `packages/types` shared TypeScript interfaces
- `packages/graph-schema` schema module
- `docs/adr` architecture decision records

## Quick Start
```bash
npm install
npm run dev
```
- API: `http://localhost:4000`
- Web: `http://localhost:5173`

## API
- `POST /api/conversations` start conversation
- `POST /api/conversations/:id/message` send user input
- `GET /api/scenarios` list scenarios
- `GET /api/scenarios/:id/graph` get scenario graph
- `POST /api/scenarios/:id/validate` validate graph references

## Performance Benchmark
Run 1000 traversals quickly with a script or test harness against `GraphEngine.processInput`; this architecture avoids external API latency by staying local and deterministic.

## License Suggestion
MIT
26 changes: 26 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "@conversation-trainer/api",
"version": "0.1.0",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"lint": "tsc --noEmit"
},
"dependencies": {
"@conversation-trainer/types": "0.1.0",
"better-sqlite3": "^11.7.0",
"cors": "^2.8.5",
"express": "^4.21.1",
"nanoid": "^5.0.8",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^22.8.1",
"tsx": "^4.19.1",
"vitest": "^2.1.4"
}
}
29 changes: 29 additions & 0 deletions apps/api/src/db/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import path from 'node:path';
import Database from 'better-sqlite3';

const dbPath = path.join(__dirname, '..', '..', 'data', 'conversation-trainer.db');
export const db = new Database(dbPath);

db.exec(`
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
scenario_id TEXT NOT NULL,
user_id TEXT,
current_node_id TEXT,
path_history TEXT,
context_variables TEXT,
status TEXT CHECK(status IN ('active', 'completed', 'abandoned')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS interaction_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT,
node_id TEXT,
user_input TEXT,
selected_edge_id TEXT,
response_time_ms INTEGER,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
44 changes: 44 additions & 0 deletions apps/api/src/graph/engine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { GraphEngine } from './engine';
import { ConversationGraph } from '@conversation-trainer/types';

const graph: ConversationGraph = {
id: 'test',
name: 'Test',
description: 'Test graph',
version: '1.0.0',
entryNode: 'start',
nodes: {
start: { id: 'start', type: 'bot', content: 'start' },
next: { id: 'next', type: 'bot', content: 'next' },
end: { id: 'end', type: 'end', content: 'done' }
},
edges: [
{ id: 'a', from: 'start', to: 'next', trigger: { type: 'exact', value: 'yes' } },
{
id: 'b',
from: 'next',
to: 'end',
trigger: { type: 'intent', value: ['too expensive', 'need discount'] },
actions: [{ type: 'setVariable', target: 'discount', value: true }]
}
],
variables: [{ name: 'discount', type: 'boolean', default: false }]
};

describe('GraphEngine', () => {
it('should follow exact match edge', () => {
const engine = new GraphEngine(graph);
const context = engine.getInitialContext('conversation-1');
const result = engine.processInput(context, 'yes');
expect(result.context.currentNodeId).toBe('next');
});

it('should handle variable action updates', () => {
const engine = new GraphEngine(graph);
const context = engine.getInitialContext('conversation-1');
const mid = engine.processInput(context, 'yes');
const end = engine.processInput(mid.context, 'need discount');
expect(end.context.variables.discount).toBe(true);
});
});
167 changes: 167 additions & 0 deletions apps/api/src/graph/engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import {
ConversationContext,
ConversationEdge,
ConversationGraph,
EdgeOption,
TraversalResult
} from '@conversation-trainer/types';

const tokenize = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ')
.split(/\s+/)
.filter(Boolean);

const cosineSimilarity = (a: string[], b: string[]) => {
const aFreq = new Map<string, number>();
const bFreq = new Map<string, number>();
a.forEach((token) => aFreq.set(token, (aFreq.get(token) ?? 0) + 1));
b.forEach((token) => bFreq.set(token, (bFreq.get(token) ?? 0) + 1));

const keys = new Set([...aFreq.keys(), ...bFreq.keys()]);
let dot = 0;
let aMag = 0;
let bMag = 0;

keys.forEach((key) => {
const aValue = aFreq.get(key) ?? 0;
const bValue = bFreq.get(key) ?? 0;
dot += aValue * bValue;
aMag += aValue * aValue;
bMag += bValue * bValue;
});

if (!aMag || !bMag) return 0;
return dot / (Math.sqrt(aMag) * Math.sqrt(bMag));
};

const levenshteinDistance = (a: string, b: string) => {
const dp = Array.from({ length: a.length + 1 }, () => Array.from<number>({ length: b.length + 1 }).fill(0));
for (let i = 0; i <= a.length; i++) dp[i][0] = i;
for (let j = 0; j <= b.length; j++) dp[0][j] = j;

for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
}
}
return dp[a.length][b.length];
};

export class GraphEngine {
constructor(private readonly graph: ConversationGraph) {}

getInitialContext(conversationId: string): ConversationContext {
const defaultVariables = Object.fromEntries(
(this.graph.variables ?? []).map((v) => [v.name, v.default ?? null])
);

return {
conversationId,
currentNodeId: this.graph.entryNode,
variables: defaultVariables,
history: [{ nodeId: this.graph.entryNode, timestamp: new Date().toISOString() }]
};
}

processInput(context: ConversationContext, input: string): TraversalResult {
const edges = this.graph.edges.filter((edge) => edge.from === context.currentNodeId);
const matched = this.matchEdge(edges, input);

if (!matched) {
const currentNode = this.graph.nodes[context.currentNodeId];
return {
node: currentNode,
context,
message: "I didn't understand that. Try one of the available options.",
availableOptions: this.getAvailableOptions(context.currentNodeId),
isComplete: currentNode.type === 'end'
};
}

const nextContext = this.executeActions(context, matched);
nextContext.currentNodeId = matched.to;
nextContext.history.push({ nodeId: matched.to, timestamp: new Date().toISOString(), choice: input });

const node = this.graph.nodes[matched.to];
return {
node,
context: nextContext,
matchedEdgeId: matched.id,
message: matched.response,
availableOptions: this.getAvailableOptions(matched.to),
isComplete: node.type === 'end'
};
}

getAvailableOptions(nodeId: string): EdgeOption[] {
return this.graph.edges
.filter((edge) => edge.from === nodeId)
.map((edge) => ({ id: edge.id, label: String(edge.trigger.value), to: edge.to }));
}

private executeActions(context: ConversationContext, edge: ConversationEdge): ConversationContext {
const copy: ConversationContext = {
...context,
variables: { ...context.variables },
history: [...context.history]
};

(edge.actions ?? []).forEach((action) => {
if (!action.target) return;
if (action.type === 'setVariable') {
copy.variables[action.target] = action.value ?? null;
}
if (action.type === 'increment') {
copy.variables[action.target] = Number(copy.variables[action.target] ?? 0) + 1;
}
});

return copy;
}

private matchEdge(edges: ConversationEdge[], input: string): ConversationEdge | undefined {
const normalized = input.trim().toLowerCase();
const exact = edges.find((edge) => {
if (edge.trigger.type !== 'exact' && edge.trigger.type !== 'button') return false;
const values = Array.isArray(edge.trigger.value) ? edge.trigger.value : [edge.trigger.value];
return values.some((v) => String(v).toLowerCase() === normalized);
});
if (exact) return exact;

const regex = edges.find((edge) => {
if (edge.trigger.type !== 'regex') return false;
return new RegExp(String(edge.trigger.value), 'i').test(input);
});
if (regex) return regex;

const intents = edges
.filter((edge) => edge.trigger.type === 'intent')
.map((edge) => {
const values = Array.isArray(edge.trigger.value) ? edge.trigger.value : [edge.trigger.value];
const scores = values.map((value) => this.matchIntent(input, String(value)));
return { edge, score: Math.max(...scores) };
})
.sort((a, b) => b.score - a.score);

if (intents[0] && intents[0].score >= 0.5) return intents[0].edge;

const fuzzy = edges.find((edge) => {
const values = Array.isArray(edge.trigger.value) ? edge.trigger.value : [edge.trigger.value];
return values.some((value) => {
const candidate = String(value).toLowerCase();
const distance = levenshteinDistance(candidate, normalized);
const ratio = 1 - distance / Math.max(candidate.length, normalized.length, 1);
return ratio >= 0.8;
});
});

return fuzzy;
}

private matchIntent(input: string, pattern: string): number {
return cosineSimilarity(tokenize(input), tokenize(pattern));
}
}
19 changes: 19 additions & 0 deletions apps/api/src/graph/loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import fs from 'node:fs';
import path from 'node:path';
import { ConversationGraph } from '@conversation-trainer/types';

export function loadGraph(scenarioId: string): ConversationGraph {
const scenarioPath = path.join(__dirname, '..', 'scenarios', `${scenarioId}.json`);
if (!fs.existsSync(scenarioPath)) {
throw new Error(`Scenario '${scenarioId}' not found.`);
}

const raw = fs.readFileSync(scenarioPath, 'utf-8');
const graph = JSON.parse(raw) as ConversationGraph;

if (!graph.nodes[graph.entryNode]) {
throw new Error(`Invalid graph: entry node '${graph.entryNode}' is missing.`);
}

return graph;
}
19 changes: 19 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import express from 'express';
import cors from 'cors';
import conversations from './routes/conversations';
import scenarios from './routes/scenarios';
import './db';

const app = express();
const port = process.env.PORT || 4000;

app.use(cors());
app.use(express.json());

app.get('/health', (_req, res) => res.json({ ok: true }));
app.use('/api/conversations', conversations);
app.use('/api/scenarios', scenarios);

app.listen(port, () => {
console.log(`API listening on port ${port}`);
});
Loading
Loading