diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c490fef --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74075ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules +npm-debug.log* +.env +.env.* +dist +coverage +*.sqlite-journal +*.db-journal diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6c1cbe --- /dev/null +++ b/README.md @@ -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 diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..52410a2 --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts new file mode 100644 index 0000000..393f8a7 --- /dev/null +++ b/apps/api/src/db/index.ts @@ -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 +); +`); diff --git a/apps/api/src/graph/engine.test.ts b/apps/api/src/graph/engine.test.ts new file mode 100644 index 0000000..4c06247 --- /dev/null +++ b/apps/api/src/graph/engine.test.ts @@ -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); + }); +}); diff --git a/apps/api/src/graph/engine.ts b/apps/api/src/graph/engine.ts new file mode 100644 index 0000000..56922c5 --- /dev/null +++ b/apps/api/src/graph/engine.ts @@ -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(); + const bFreq = new Map(); + 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({ 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)); + } +} diff --git a/apps/api/src/graph/loader.ts b/apps/api/src/graph/loader.ts new file mode 100644 index 0000000..0804d07 --- /dev/null +++ b/apps/api/src/graph/loader.ts @@ -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; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..edde313 --- /dev/null +++ b/apps/api/src/index.ts @@ -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}`); +}); diff --git a/apps/api/src/routes/conversations.ts b/apps/api/src/routes/conversations.ts new file mode 100644 index 0000000..5016b47 --- /dev/null +++ b/apps/api/src/routes/conversations.ts @@ -0,0 +1,38 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { sendMessage, startConversation } from '../services/conversationService'; + +const router = Router(); + +router.post('/', (req, res) => { + const schema = z.object({ scenarioId: z.string(), userId: z.string().optional() }); + const parsed = schema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + + const conversation = startConversation(parsed.data.scenarioId, parsed.data.userId); + return res.json(conversation); +}); + +router.post('/:id/message', (req, res) => { + const schema = z.object({ message: z.string().min(1) }); + const parsed = schema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + + try { + const result = sendMessage(req.params.id, parsed.data.message); + return res.json({ + messages: [result.node], + context: result.context, + availableOptions: result.availableOptions.map((option) => option.label), + isComplete: result.isComplete + }); + } catch (error) { + return res.status(404).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/apps/api/src/routes/scenarios.ts b/apps/api/src/routes/scenarios.ts new file mode 100644 index 0000000..a0fb853 --- /dev/null +++ b/apps/api/src/routes/scenarios.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { Router } from 'express'; +import { loadGraph } from '../graph/loader'; + +const router = Router(); +const scenarioDir = path.join(__dirname, '..', 'scenarios'); + +router.get('/', (_req, res) => { + const files = fs.readdirSync(scenarioDir).filter((file) => file.endsWith('.json')); + const scenarios = files.map((file) => { + const graph = loadGraph(file.replace('.json', '')); + return { id: graph.id, name: graph.name, description: graph.description, version: graph.version }; + }); + + return res.json(scenarios); +}); + +router.get('/:id/graph', (req, res) => { + try { + return res.json(loadGraph(req.params.id)); + } catch (error) { + return res.status(404).json({ error: (error as Error).message }); + } +}); + +router.post('/:id/validate', (req, res) => { + try { + const graph = loadGraph(req.params.id); + const nodeIds = new Set(Object.keys(graph.nodes)); + const invalidEdges = graph.edges.filter((edge) => !nodeIds.has(edge.from) || !nodeIds.has(edge.to)); + return res.json({ + isValid: invalidEdges.length === 0, + invalidEdges + }); + } catch (error) { + return res.status(404).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/apps/api/src/scenarios/customer-support-escalation.json b/apps/api/src/scenarios/customer-support-escalation.json new file mode 100644 index 0000000..1213c70 --- /dev/null +++ b/apps/api/src/scenarios/customer-support-escalation.json @@ -0,0 +1,17 @@ +{ + "id": "customer-support-escalation", + "name": "Customer Support Escalation", + "description": "De-escalate an upset customer while preserving trust.", + "version": "1.0.0", + "entryNode": "open", + "nodes": { + "open": { "id": "open", "type": "bot", "content": "I can hear your frustration. Can you share what happened?" }, + "apology": { "id": "apology", "type": "bot", "content": "Thank you. I am sorry this happened, and I can help resolve it." }, + "resolve": { "id": "resolve", "type": "end", "content": "Issue documented and escalation initiated." } + }, + "edges": [ + { "id": "c1", "from": "open", "to": "apology", "trigger": { "type": "intent", "value": ["angry", "frustrated", "bad experience"] } }, + { "id": "c2", "from": "apology", "to": "resolve", "trigger": { "type": "button", "value": "thanks" } } + ], + "variables": [] +} diff --git a/apps/api/src/scenarios/performance-review.json b/apps/api/src/scenarios/performance-review.json new file mode 100644 index 0000000..8791faa --- /dev/null +++ b/apps/api/src/scenarios/performance-review.json @@ -0,0 +1,17 @@ +{ + "id": "performance-review", + "name": "Performance Review Coaching", + "description": "Handle difficult review conversations.", + "version": "1.0.0", + "entryNode": "start", + "nodes": { + "start": { "id": "start", "type": "bot", "content": "Thanks for meeting. Ready to review your goals?" }, + "goals": { "id": "goals", "type": "bot", "content": "Which goal do you want to focus on first?" }, + "end": { "id": "end", "type": "end", "content": "Great planning session today." } + }, + "edges": [ + { "id": "p1", "from": "start", "to": "goals", "trigger": { "type": "exact", "value": "yes" } }, + { "id": "p2", "from": "goals", "to": "end", "trigger": { "type": "regex", "value": "(communication|delivery|leadership)" } } + ], + "variables": [] +} diff --git a/apps/api/src/scenarios/sales-negotiation.json b/apps/api/src/scenarios/sales-negotiation.json new file mode 100644 index 0000000..a202977 --- /dev/null +++ b/apps/api/src/scenarios/sales-negotiation.json @@ -0,0 +1,27 @@ +{ + "id": "sales-negotiation", + "name": "Enterprise Software Negotiation", + "description": "Practice budget and objection handling.", + "version": "1.0.0", + "entryNode": "greeting", + "nodes": { + "greeting": { "id": "greeting", "type": "bot", "content": "Hi! Are you evaluating options for 100 employees?" }, + "budget": { "id": "budget", "type": "bot", "content": "What is your budget for this quarter?" }, + "objection": { "id": "objection", "type": "bot", "content": "I understand price concerns. We can explore a discount." }, + "won": { "id": "won", "type": "end", "content": "Great, I will send over the contract." } + }, + "edges": [ + { "id": "e1", "from": "greeting", "to": "budget", "trigger": { "type": "exact", "value": ["yes", "confirm"] } }, + { + "id": "e2", + "from": "budget", + "to": "objection", + "trigger": { "type": "intent", "value": ["too expensive", "need discount", "lower price"] }, + "actions": [{ "type": "setVariable", "target": "objection_raised", "value": true }] + }, + { "id": "e3", "from": "objection", "to": "won", "trigger": { "type": "button", "value": "accept" } } + ], + "variables": [ + { "name": "objection_raised", "type": "boolean", "default": false } + ] +} diff --git a/apps/api/src/services/conversationService.ts b/apps/api/src/services/conversationService.ts new file mode 100644 index 0000000..cb1bc62 --- /dev/null +++ b/apps/api/src/services/conversationService.ts @@ -0,0 +1,71 @@ +import { nanoid } from 'nanoid'; +import { ConversationContext } from '@conversation-trainer/types'; +import { db } from '../db'; +import { GraphEngine } from '../graph/engine'; +import { loadGraph } from '../graph/loader'; + +export function startConversation(scenarioId: string, userId?: string) { + const graph = loadGraph(scenarioId); + const engine = new GraphEngine(graph); + const conversationId = nanoid(); + const context = engine.getInitialContext(conversationId); + + const stmt = db.prepare(` + INSERT INTO conversations(id, scenario_id, user_id, current_node_id, path_history, context_variables, status) + VALUES (?, ?, ?, ?, ?, ?, 'active') + `); + + stmt.run( + conversationId, + scenarioId, + userId ?? null, + context.currentNodeId, + JSON.stringify(context.history), + JSON.stringify(context.variables) + ); + + return { + conversationId, + currentNode: graph.nodes[graph.entryNode], + context + }; +} + +export function sendMessage(conversationId: string, message: string) { + const row = db.prepare('SELECT * FROM conversations WHERE id = ?').get(conversationId) as any; + if (!row) { + throw new Error('Conversation not found'); + } + + const graph = loadGraph(row.scenario_id); + const engine = new GraphEngine(graph); + + const context: ConversationContext = { + conversationId, + currentNodeId: row.current_node_id, + history: JSON.parse(row.path_history), + variables: JSON.parse(row.context_variables) + }; + + const start = Date.now(); + const result = engine.processInput(context, message); + + db.prepare( + `UPDATE conversations + SET current_node_id = ?, path_history = ?, context_variables = ?, status = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ?` + ).run( + result.context.currentNodeId, + JSON.stringify(result.context.history), + JSON.stringify(result.context.variables), + result.isComplete ? 'completed' : 'active', + conversationId + ); + + db.prepare( + `INSERT INTO interaction_logs(conversation_id, node_id, user_input, selected_edge_id, response_time_ms) + VALUES (?, ?, ?, ?, ?)` + ).run(conversationId, context.currentNodeId, message, result.matchedEdgeId ?? null, Date.now() - start); + + return result; +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..9e96b82 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "paths": { + "@conversation-trainer/types": ["../../packages/types/src"] + } + }, + "include": ["src"] +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..45227d4 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,13 @@ + + + + + + Conversation Trainer + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..0ef80a8 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "@conversation-trainer/web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "tsc -p tsconfig.json && vite build", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@conversation-trainer/types": "0.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "tailwindcss": "^3.4.14", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..e8dee25 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,15 @@ +import { ChatContainer } from './components/ChatContainer'; + +export default function App() { + return ( +
+
+
+

Graph-Based Corporate Conversation Trainer

+

Deterministic, auditable, offline training conversations.

+
+
+ +
+ ); +} diff --git a/apps/web/src/components/ChatContainer.tsx b/apps/web/src/components/ChatContainer.tsx new file mode 100644 index 0000000..9c8c66f --- /dev/null +++ b/apps/web/src/components/ChatContainer.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react'; +import { fetchScenarios } from '../lib/api'; +import { useConversation } from '../hooks/useConversation'; +import { GraphViewer } from './GraphViewer'; + +export function ChatContainer() { + const [input, setInput] = useState(''); + const [scenarios, setScenarios] = useState([]); + const convo = useConversation(); + + useEffect(() => { + fetchScenarios().then(setScenarios); + }, []); + + return ( +
+
+
+ +
+ +
+ {convo.messages.map((message, idx) => ( +
+
+ {message.content} +
+
+ ))} +
+ +
+ setInput(event.target.value)} + className="flex-1 rounded-md border px-3 py-2" + placeholder="Type your response..." + /> + +
+ + {convo.options.length > 0 && ( +
+ {convo.options.map((option) => ( + + ))} +
+ )} +
+ + +
+ ); +} diff --git a/apps/web/src/components/GraphViewer.tsx b/apps/web/src/components/GraphViewer.tsx new file mode 100644 index 0000000..5d6d0e8 --- /dev/null +++ b/apps/web/src/components/GraphViewer.tsx @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react'; +import { getGraph } from '../lib/api'; + +interface Props { + scenarioId?: string; +} + +export function GraphViewer({ scenarioId }: Props) { + const [graph, setGraph] = useState(); + + useEffect(() => { + if (!scenarioId) return; + getGraph(scenarioId).then(setGraph); + }, [scenarioId]); + + if (!scenarioId) return
Select a scenario to inspect graph.
; + if (!graph) return
Loading graph...
; + + return ( +
+

Graph Viewer

+

{graph.name}

+
+ {Object.values(graph.nodes).map((node: any) => ( +
+
{node.id}
+
{node.type}
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/hooks/useConversation.ts b/apps/web/src/hooks/useConversation.ts new file mode 100644 index 0000000..ba9957d --- /dev/null +++ b/apps/web/src/hooks/useConversation.ts @@ -0,0 +1,35 @@ +import { useState } from 'react'; +import { sendConversationMessage, startConversation } from '../lib/api'; +import { useChatStore } from '../stores/chatStore'; + +export function useConversation() { + const [loading, setLoading] = useState(false); + const store = useChatStore(); + + async function begin(scenarioId: string) { + setLoading(true); + const response = await startConversation(scenarioId); + store.reset(); + store.setConversation(response.conversationId, scenarioId); + store.addMessage({ role: 'bot', content: String(response.currentNode.content) }); + setLoading(false); + } + + async function send(message: string) { + if (!store.conversationId) return; + store.addMessage({ role: 'user', content: message }); + setLoading(true); + const response = await sendConversationMessage(store.conversationId, message); + const botMessage = response.messages?.[0]?.content ?? 'No response.'; + store.addMessage({ role: 'bot', content: String(botMessage) }); + store.setOptions(response.availableOptions ?? []); + setLoading(false); + } + + return { + loading, + begin, + send, + ...store + }; +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..b8ba026 --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,37 @@ +import { ConversationNode, ConversationContext } from '@conversation-trainer/types'; + +const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'; + +export interface StartResponse { + conversationId: string; + currentNode: ConversationNode; + context: ConversationContext; +} + +export async function fetchScenarios() { + const response = await fetch(`${API_URL}/api/scenarios`); + return response.json(); +} + +export async function startConversation(scenarioId: string): Promise { + const response = await fetch(`${API_URL}/api/conversations`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scenarioId }) + }); + return response.json(); +} + +export async function sendConversationMessage(conversationId: string, message: string) { + const response = await fetch(`${API_URL}/api/conversations/${conversationId}/message`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message }) + }); + return response.json(); +} + +export async function getGraph(scenarioId: string) { + const response = await fetch(`${API_URL}/api/scenarios/${scenarioId}/graph`); + return response.json(); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..9707d82 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/apps/web/src/stores/chatStore.ts b/apps/web/src/stores/chatStore.ts new file mode 100644 index 0000000..832617f --- /dev/null +++ b/apps/web/src/stores/chatStore.ts @@ -0,0 +1,23 @@ +import { create } from 'zustand'; + +type ChatMessage = { role: 'bot' | 'user'; content: string }; + +interface ChatState { + conversationId?: string; + scenarioId?: string; + messages: ChatMessage[]; + options: string[]; + setConversation: (conversationId: string, scenarioId: string) => void; + addMessage: (message: ChatMessage) => void; + setOptions: (options: string[]) => void; + reset: () => void; +} + +export const useChatStore = create((set) => ({ + messages: [], + options: [], + setConversation: (conversationId, scenarioId) => set({ conversationId, scenarioId }), + addMessage: (message) => set((state) => ({ messages: [...state.messages, message] })), + setOptions: (options) => set({ options }), + reset: () => set({ messages: [], conversationId: undefined, options: [], scenarioId: undefined }) +})); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..5bf7e64 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["vite/client"], + "paths": { + "@conversation-trainer/types": ["../../packages/types/src"] + } + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..efe6335 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173 + } +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..15659c3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: '3.9' +services: + api: + image: node:20 + working_dir: /app + volumes: + - ./:/app + command: sh -c "npm install && npm run dev --workspace @conversation-trainer/api" + ports: + - '4000:4000' + + web: + image: node:20 + working_dir: /app + volumes: + - ./:/app + command: sh -c "npm install && npm run dev --workspace @conversation-trainer/web -- --host 0.0.0.0" + ports: + - '5173:5173' + depends_on: + - api diff --git a/docs/adr/0001-deterministic-graph-engine.md b/docs/adr/0001-deterministic-graph-engine.md new file mode 100644 index 0000000..6e043c5 --- /dev/null +++ b/docs/adr/0001-deterministic-graph-engine.md @@ -0,0 +1,16 @@ +# ADR 0001: Deterministic Graph Engine for Corporate Training + +## Status +Accepted + +## Context +Corporate training teams need repeatable role-play simulations that are auditable and editable without prompt engineering or external AI services. + +## Decision +Use a finite-state graph traversal engine where each node and edge is represented in JSON scenarios. The runtime resolves transitions by deterministic trigger evaluation with explicit priority (exact, regex, intent similarity, then fuzzy matching). + +## Consequences +- Reproducible outcomes. +- Full traversal audit history in SQLite. +- Low latency and offline operation. +- Less open-ended language variation than LLM-based systems. diff --git a/package.json b/package.json new file mode 100644 index 0000000..4b04070 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "conversation-trainer", + "private": true, + "version": "0.1.0", + "workspaces": [ + "apps/*", + "packages/*" + ], + "scripts": { + "dev": "concurrently \"npm run dev --workspace @conversation-trainer/api\" \"npm run dev --workspace @conversation-trainer/web\"", + "build": "npm run build --workspaces", + "test": "npm run test --workspace @conversation-trainer/api", + "lint": "npm run lint --workspaces" + }, + "devDependencies": { + "concurrently": "^8.2.2", + "typescript": "^5.6.3" + } +} diff --git a/packages/graph-schema/package.json b/packages/graph-schema/package.json new file mode 100644 index 0000000..2852bbd --- /dev/null +++ b/packages/graph-schema/package.json @@ -0,0 +1,6 @@ +{ + "name": "@conversation-trainer/graph-schema", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts" +} diff --git a/packages/graph-schema/src/index.ts b/packages/graph-schema/src/index.ts new file mode 100644 index 0000000..9d1444d --- /dev/null +++ b/packages/graph-schema/src/index.ts @@ -0,0 +1,38 @@ +export const conversationGraphSchema = { + type: 'object', + required: ['id', 'name', 'entryNode', 'nodes', 'edges', 'version', 'description'], + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + description: { type: 'string' }, + version: { type: 'string' }, + entryNode: { type: 'string' }, + nodes: { + type: 'object', + additionalProperties: { + type: 'object', + required: ['type', 'content'], + properties: { + type: { + enum: ['bot', 'user-input', 'decision', 'end', 'redirect'] + }, + content: { + oneOf: [{ type: 'string' }, { type: 'array' }] + } + } + } + }, + edges: { + type: 'array', + items: { + type: 'object', + required: ['id', 'from', 'to', 'trigger'], + properties: { + id: { type: 'string' }, + from: { type: 'string' }, + to: { type: 'string' } + } + } + } + } +}; diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 0000000..c60d62c --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,6 @@ +{ + "name": "@conversation-trainer/types", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts" +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts new file mode 100644 index 0000000..d057739 --- /dev/null +++ b/packages/types/src/index.ts @@ -0,0 +1,83 @@ +export interface VariableDefinition { + name: string; + type: 'string' | 'number' | 'boolean'; + default?: string | number | boolean; +} + +export interface ContentBlock { + type: 'text' | 'markdown'; + value: string; +} + +export interface ConversationNode { + id: string; + type: 'bot' | 'user-input' | 'decision' | 'end' | 'redirect'; + content: string | ContentBlock[]; + metadata?: { + emotion?: 'neutral' | 'concerned' | 'happy' | 'frustrated'; + avatar?: string; + delayMs?: number; + audioUrl?: string; + }; + condition?: string; +} + +export interface GraphAction { + type: 'setVariable' | 'increment' | 'log' | 'redirect' | 'callWebhook'; + target?: string; + value?: string | number | boolean; +} + +export interface ConversationEdge { + id: string; + from: string; + to: string; + trigger: { + type: 'intent' | 'exact' | 'regex' | 'button' | 'timeout' | 'condition'; + value: string | string[]; + confidenceThreshold?: number; + }; + response?: string; + actions?: GraphAction[]; + condition?: string; + weight?: number; +} + +export interface ConversationGraph { + id: string; + name: string; + description: string; + version: string; + entryNode: string; + nodes: Record; + edges: ConversationEdge[]; + variables?: VariableDefinition[]; +} + +export interface PathEntry { + nodeId: string; + timestamp: string; + choice?: string; +} + +export interface ConversationContext { + conversationId: string; + currentNodeId: string; + variables: Record; + history: PathEntry[]; +} + +export interface TraversalResult { + node: ConversationNode; + context: ConversationContext; + matchedEdgeId?: string; + message?: string; + availableOptions: EdgeOption[]; + isComplete: boolean; +} + +export interface EdgeOption { + id: string; + label: string; + to: string; +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..d032a4a --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "baseUrl": "." + } +}