Skip to content

Commit 350c443

Browse files
authored
Merge pull request #1 from Manick94/codex/build-graph-based-corporate-training-app
Scaffold deterministic graph-based corporate conversation trainer
2 parents 6b32c78 + 7adfd0f commit 350c443

35 files changed

Lines changed: 1101 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: ci
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
build:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
- uses: actions/setup-node@v4
13+
with:
14+
node-version: 20
15+
- run: npm install
16+
- run: npm run lint
17+
- run: npm run test
18+
- run: npm run build

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
node_modules
2+
npm-debug.log*
3+
.env
4+
.env.*
5+
dist
6+
coverage
7+
*.sqlite-journal
8+
*.db-journal

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Graph-Based Corporate Conversation Trainer
2+
3+
A full-stack deterministic conversation training system that uses finite-state graph traversal instead of LLMs.
4+
5+
## Features
6+
- Deterministic dialogue traversal (exact, regex, intent similarity, fuzzy fallback)
7+
- SQLite persistence for conversations and interaction logs
8+
- Editable scenario JSON files with three sample scenarios
9+
- React chat interface with quick reply buttons
10+
- Graph inspector panel for real-time scenario node visibility
11+
- Validation endpoint for graph edge integrity
12+
13+
## Architecture
14+
```mermaid
15+
flowchart LR
16+
User --> Web[React + Zustand]
17+
Web --> API[Express API]
18+
API --> Engine[Graph Engine]
19+
API --> DB[(SQLite)]
20+
Engine --> Scenarios[JSON Scenarios]
21+
```
22+
23+
## Monorepo Structure
24+
- `apps/api` Express backend, graph engine, SQLite, scenario files
25+
- `apps/web` React frontend chat + graph viewer
26+
- `packages/types` shared TypeScript interfaces
27+
- `packages/graph-schema` schema module
28+
- `docs/adr` architecture decision records
29+
30+
## Quick Start
31+
```bash
32+
npm install
33+
npm run dev
34+
```
35+
- API: `http://localhost:4000`
36+
- Web: `http://localhost:5173`
37+
38+
## API
39+
- `POST /api/conversations` start conversation
40+
- `POST /api/conversations/:id/message` send user input
41+
- `GET /api/scenarios` list scenarios
42+
- `GET /api/scenarios/:id/graph` get scenario graph
43+
- `POST /api/scenarios/:id/validate` validate graph references
44+
45+
## Performance Benchmark
46+
Run 1000 traversals quickly with a script or test harness against `GraphEngine.processInput`; this architecture avoids external API latency by staying local and deterministic.
47+
48+
## License Suggestion
49+
MIT

apps/api/package.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@conversation-trainer/api",
3+
"version": "0.1.0",
4+
"main": "dist/index.js",
5+
"scripts": {
6+
"dev": "tsx watch src/index.ts",
7+
"build": "tsc -p tsconfig.json",
8+
"test": "vitest run",
9+
"lint": "tsc --noEmit"
10+
},
11+
"dependencies": {
12+
"@conversation-trainer/types": "0.1.0",
13+
"better-sqlite3": "^11.7.0",
14+
"cors": "^2.8.5",
15+
"express": "^4.21.1",
16+
"nanoid": "^5.0.8",
17+
"zod": "^3.23.8"
18+
},
19+
"devDependencies": {
20+
"@types/cors": "^2.8.17",
21+
"@types/express": "^4.17.21",
22+
"@types/node": "^22.8.1",
23+
"tsx": "^4.19.1",
24+
"vitest": "^2.1.4"
25+
}
26+
}

apps/api/src/db/index.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import path from 'node:path';
2+
import Database from 'better-sqlite3';
3+
4+
const dbPath = path.join(__dirname, '..', '..', 'data', 'conversation-trainer.db');
5+
export const db = new Database(dbPath);
6+
7+
db.exec(`
8+
CREATE TABLE IF NOT EXISTS conversations (
9+
id TEXT PRIMARY KEY,
10+
scenario_id TEXT NOT NULL,
11+
user_id TEXT,
12+
current_node_id TEXT,
13+
path_history TEXT,
14+
context_variables TEXT,
15+
status TEXT CHECK(status IN ('active', 'completed', 'abandoned')),
16+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
17+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
18+
);
19+
20+
CREATE TABLE IF NOT EXISTS interaction_logs (
21+
id INTEGER PRIMARY KEY AUTOINCREMENT,
22+
conversation_id TEXT,
23+
node_id TEXT,
24+
user_input TEXT,
25+
selected_edge_id TEXT,
26+
response_time_ms INTEGER,
27+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
28+
);
29+
`);

apps/api/src/graph/engine.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { GraphEngine } from './engine';
3+
import { ConversationGraph } from '@conversation-trainer/types';
4+
5+
const graph: ConversationGraph = {
6+
id: 'test',
7+
name: 'Test',
8+
description: 'Test graph',
9+
version: '1.0.0',
10+
entryNode: 'start',
11+
nodes: {
12+
start: { id: 'start', type: 'bot', content: 'start' },
13+
next: { id: 'next', type: 'bot', content: 'next' },
14+
end: { id: 'end', type: 'end', content: 'done' }
15+
},
16+
edges: [
17+
{ id: 'a', from: 'start', to: 'next', trigger: { type: 'exact', value: 'yes' } },
18+
{
19+
id: 'b',
20+
from: 'next',
21+
to: 'end',
22+
trigger: { type: 'intent', value: ['too expensive', 'need discount'] },
23+
actions: [{ type: 'setVariable', target: 'discount', value: true }]
24+
}
25+
],
26+
variables: [{ name: 'discount', type: 'boolean', default: false }]
27+
};
28+
29+
describe('GraphEngine', () => {
30+
it('should follow exact match edge', () => {
31+
const engine = new GraphEngine(graph);
32+
const context = engine.getInitialContext('conversation-1');
33+
const result = engine.processInput(context, 'yes');
34+
expect(result.context.currentNodeId).toBe('next');
35+
});
36+
37+
it('should handle variable action updates', () => {
38+
const engine = new GraphEngine(graph);
39+
const context = engine.getInitialContext('conversation-1');
40+
const mid = engine.processInput(context, 'yes');
41+
const end = engine.processInput(mid.context, 'need discount');
42+
expect(end.context.variables.discount).toBe(true);
43+
});
44+
});

apps/api/src/graph/engine.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import {
2+
ConversationContext,
3+
ConversationEdge,
4+
ConversationGraph,
5+
EdgeOption,
6+
TraversalResult
7+
} from '@conversation-trainer/types';
8+
9+
const tokenize = (value: string) =>
10+
value
11+
.toLowerCase()
12+
.replace(/[^a-z0-9\s]/g, ' ')
13+
.split(/\s+/)
14+
.filter(Boolean);
15+
16+
const cosineSimilarity = (a: string[], b: string[]) => {
17+
const aFreq = new Map<string, number>();
18+
const bFreq = new Map<string, number>();
19+
a.forEach((token) => aFreq.set(token, (aFreq.get(token) ?? 0) + 1));
20+
b.forEach((token) => bFreq.set(token, (bFreq.get(token) ?? 0) + 1));
21+
22+
const keys = new Set([...aFreq.keys(), ...bFreq.keys()]);
23+
let dot = 0;
24+
let aMag = 0;
25+
let bMag = 0;
26+
27+
keys.forEach((key) => {
28+
const aValue = aFreq.get(key) ?? 0;
29+
const bValue = bFreq.get(key) ?? 0;
30+
dot += aValue * bValue;
31+
aMag += aValue * aValue;
32+
bMag += bValue * bValue;
33+
});
34+
35+
if (!aMag || !bMag) return 0;
36+
return dot / (Math.sqrt(aMag) * Math.sqrt(bMag));
37+
};
38+
39+
const levenshteinDistance = (a: string, b: string) => {
40+
const dp = Array.from({ length: a.length + 1 }, () => Array.from<number>({ length: b.length + 1 }).fill(0));
41+
for (let i = 0; i <= a.length; i++) dp[i][0] = i;
42+
for (let j = 0; j <= b.length; j++) dp[0][j] = j;
43+
44+
for (let i = 1; i <= a.length; i++) {
45+
for (let j = 1; j <= b.length; j++) {
46+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
47+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
48+
}
49+
}
50+
return dp[a.length][b.length];
51+
};
52+
53+
export class GraphEngine {
54+
constructor(private readonly graph: ConversationGraph) {}
55+
56+
getInitialContext(conversationId: string): ConversationContext {
57+
const defaultVariables = Object.fromEntries(
58+
(this.graph.variables ?? []).map((v) => [v.name, v.default ?? null])
59+
);
60+
61+
return {
62+
conversationId,
63+
currentNodeId: this.graph.entryNode,
64+
variables: defaultVariables,
65+
history: [{ nodeId: this.graph.entryNode, timestamp: new Date().toISOString() }]
66+
};
67+
}
68+
69+
processInput(context: ConversationContext, input: string): TraversalResult {
70+
const edges = this.graph.edges.filter((edge) => edge.from === context.currentNodeId);
71+
const matched = this.matchEdge(edges, input);
72+
73+
if (!matched) {
74+
const currentNode = this.graph.nodes[context.currentNodeId];
75+
return {
76+
node: currentNode,
77+
context,
78+
message: "I didn't understand that. Try one of the available options.",
79+
availableOptions: this.getAvailableOptions(context.currentNodeId),
80+
isComplete: currentNode.type === 'end'
81+
};
82+
}
83+
84+
const nextContext = this.executeActions(context, matched);
85+
nextContext.currentNodeId = matched.to;
86+
nextContext.history.push({ nodeId: matched.to, timestamp: new Date().toISOString(), choice: input });
87+
88+
const node = this.graph.nodes[matched.to];
89+
return {
90+
node,
91+
context: nextContext,
92+
matchedEdgeId: matched.id,
93+
message: matched.response,
94+
availableOptions: this.getAvailableOptions(matched.to),
95+
isComplete: node.type === 'end'
96+
};
97+
}
98+
99+
getAvailableOptions(nodeId: string): EdgeOption[] {
100+
return this.graph.edges
101+
.filter((edge) => edge.from === nodeId)
102+
.map((edge) => ({ id: edge.id, label: String(edge.trigger.value), to: edge.to }));
103+
}
104+
105+
private executeActions(context: ConversationContext, edge: ConversationEdge): ConversationContext {
106+
const copy: ConversationContext = {
107+
...context,
108+
variables: { ...context.variables },
109+
history: [...context.history]
110+
};
111+
112+
(edge.actions ?? []).forEach((action) => {
113+
if (!action.target) return;
114+
if (action.type === 'setVariable') {
115+
copy.variables[action.target] = action.value ?? null;
116+
}
117+
if (action.type === 'increment') {
118+
copy.variables[action.target] = Number(copy.variables[action.target] ?? 0) + 1;
119+
}
120+
});
121+
122+
return copy;
123+
}
124+
125+
private matchEdge(edges: ConversationEdge[], input: string): ConversationEdge | undefined {
126+
const normalized = input.trim().toLowerCase();
127+
const exact = edges.find((edge) => {
128+
if (edge.trigger.type !== 'exact' && edge.trigger.type !== 'button') return false;
129+
const values = Array.isArray(edge.trigger.value) ? edge.trigger.value : [edge.trigger.value];
130+
return values.some((v) => String(v).toLowerCase() === normalized);
131+
});
132+
if (exact) return exact;
133+
134+
const regex = edges.find((edge) => {
135+
if (edge.trigger.type !== 'regex') return false;
136+
return new RegExp(String(edge.trigger.value), 'i').test(input);
137+
});
138+
if (regex) return regex;
139+
140+
const intents = edges
141+
.filter((edge) => edge.trigger.type === 'intent')
142+
.map((edge) => {
143+
const values = Array.isArray(edge.trigger.value) ? edge.trigger.value : [edge.trigger.value];
144+
const scores = values.map((value) => this.matchIntent(input, String(value)));
145+
return { edge, score: Math.max(...scores) };
146+
})
147+
.sort((a, b) => b.score - a.score);
148+
149+
if (intents[0] && intents[0].score >= 0.5) return intents[0].edge;
150+
151+
const fuzzy = edges.find((edge) => {
152+
const values = Array.isArray(edge.trigger.value) ? edge.trigger.value : [edge.trigger.value];
153+
return values.some((value) => {
154+
const candidate = String(value).toLowerCase();
155+
const distance = levenshteinDistance(candidate, normalized);
156+
const ratio = 1 - distance / Math.max(candidate.length, normalized.length, 1);
157+
return ratio >= 0.8;
158+
});
159+
});
160+
161+
return fuzzy;
162+
}
163+
164+
private matchIntent(input: string, pattern: string): number {
165+
return cosineSimilarity(tokenize(input), tokenize(pattern));
166+
}
167+
}

apps/api/src/graph/loader.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { ConversationGraph } from '@conversation-trainer/types';
4+
5+
export function loadGraph(scenarioId: string): ConversationGraph {
6+
const scenarioPath = path.join(__dirname, '..', 'scenarios', `${scenarioId}.json`);
7+
if (!fs.existsSync(scenarioPath)) {
8+
throw new Error(`Scenario '${scenarioId}' not found.`);
9+
}
10+
11+
const raw = fs.readFileSync(scenarioPath, 'utf-8');
12+
const graph = JSON.parse(raw) as ConversationGraph;
13+
14+
if (!graph.nodes[graph.entryNode]) {
15+
throw new Error(`Invalid graph: entry node '${graph.entryNode}' is missing.`);
16+
}
17+
18+
return graph;
19+
}

apps/api/src/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import express from 'express';
2+
import cors from 'cors';
3+
import conversations from './routes/conversations';
4+
import scenarios from './routes/scenarios';
5+
import './db';
6+
7+
const app = express();
8+
const port = process.env.PORT || 4000;
9+
10+
app.use(cors());
11+
app.use(express.json());
12+
13+
app.get('/health', (_req, res) => res.json({ ok: true }));
14+
app.use('/api/conversations', conversations);
15+
app.use('/api/scenarios', scenarios);
16+
17+
app.listen(port, () => {
18+
console.log(`API listening on port ${port}`);
19+
});

0 commit comments

Comments
 (0)