mcp-agentic-pipelines 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +93 -0
- package/README.md +258 -0
- package/package.json +70 -0
- package/packages/clinical/package.json +22 -0
- package/packages/clinical/src/index.ts +262 -0
- package/packages/clinical/tsconfig.json +13 -0
- package/packages/core/package.json +21 -0
- package/packages/core/src/config.ts +138 -0
- package/packages/core/src/errors.ts +100 -0
- package/packages/core/src/index.ts +104 -0
- package/packages/core/src/llm-config.ts +213 -0
- package/packages/core/src/logging.ts +66 -0
- package/packages/core/src/python-bridge.ts +384 -0
- package/packages/core/src/rate-limiter.ts +136 -0
- package/packages/core/src/types.ts +203 -0
- package/packages/core/src/validation.ts +101 -0
- package/packages/core/tsconfig.json +10 -0
- package/packages/deeppipe/package.json +21 -0
- package/packages/deeppipe/src/index.ts +424 -0
- package/packages/deeppipe/tsconfig.json +13 -0
- package/packages/piste/package.json +20 -0
- package/packages/piste/src/index.ts +48 -0
- package/packages/piste/tsconfig.json +13 -0
- package/packages/precis/package.json +20 -0
- package/packages/precis/src/index.ts +67 -0
- package/packages/precis/tsconfig.json +13 -0
- package/packages/server/package.json +31 -0
- package/packages/server/src/index.ts +427 -0
- package/packages/server/tsconfig.json +17 -0
- package/setup.mjs +141 -0
- package/test.mjs +337 -0
- package/vendors/clinical-intake/pipeline.mjs +349 -0
- package/vendors/clinical-intake/questions/en.txt +9 -0
- package/vendors/clinical-intake/questions/fr.txt +9 -0
- package/vendors/piste/.env.example +73 -0
- package/vendors/piste/app/core/__init__.py +4 -0
- package/vendors/piste/app/core/config.py +83 -0
- package/vendors/piste/app/core/debuglog.py +16 -0
- package/vendors/piste/app/core/middleware.py +40 -0
- package/vendors/piste/bridge_piste.py +301 -0
- package/vendors/piste/pipeline/__init__.py +4 -0
- package/vendors/piste/pipeline/compiler.py +68 -0
- package/vendors/piste/pipeline/offline/__init__.py +28 -0
- package/vendors/piste/pipeline/offline/verifaid_pipeline.py +247 -0
- package/vendors/piste/pipeline/replay.py +15 -0
- package/vendors/piste/pipeline/replay_engine.py +249 -0
- package/vendors/piste/pipeline/signatures/__init__.py +4 -0
- package/vendors/piste/pipeline/signatures/signatures.py +136 -0
- package/vendors/piste/pipeline/stage1/__init__.py +21 -0
- package/vendors/piste/pipeline/stage1/atomic_decomposer.py +61 -0
- package/vendors/piste/pipeline/stage1/check_worthiness.py +100 -0
- package/vendors/piste/pipeline/stage1/orchestrator.py +175 -0
- package/vendors/piste/pipeline/stage1/test_stage1.py +162 -0
- package/vendors/piste/pipeline/stage2/__init__.py +34 -0
- package/vendors/piste/pipeline/stage2/blind_retriever.py +303 -0
- package/vendors/piste/pipeline/stage2/canonical_mapper.py +124 -0
- package/vendors/piste/pipeline/stage2/credibility_scorer.py +85 -0
- package/vendors/piste/pipeline/stage2/orchestrator.py +311 -0
- package/vendors/piste/pipeline/stage2/query_refiner.py +88 -0
- package/vendors/piste/pipeline/stage2/search_decision.py +69 -0
- package/vendors/piste/pipeline/stage2/test_stage2.py +265 -0
- package/vendors/piste/pipeline/stage3/__init__.py +20 -0
- package/vendors/piste/pipeline/stage3/classifier.py +79 -0
- package/vendors/piste/pipeline/stage3/orchestrator.py +225 -0
- package/vendors/piste/pipeline/stage3/test_stage3.py +101 -0
- package/vendors/piste/pipeline/stage4/__init__.py +33 -0
- package/vendors/piste/pipeline/stage4/criticality_gate.py +177 -0
- package/vendors/piste/pipeline/stage4/orchestrator.py +269 -0
- package/vendors/piste/pipeline/stage4/test_stage4.py +192 -0
- package/vendors/piste/pipeline/stage4/verdict_aggregator.py +157 -0
- package/vendors/piste/requirements.txt +53 -0
- package/vendors/precis/backend/__init__.py +6 -0
- package/vendors/precis/backend/agents/__init__.py +3 -0
- package/vendors/precis/backend/agents/data_synthesis.py +105 -0
- package/vendors/precis/backend/agents/dist_free_synth.py +97 -0
- package/vendors/precis/backend/agents/exact_hash_retriever.py +327 -0
- package/vendors/precis/backend/agents/fusion_ranker.py +64 -0
- package/vendors/precis/backend/agents/guardrail.py +175 -0
- package/vendors/precis/backend/agents/query_expander.py +89 -0
- package/vendors/precis/backend/agents/radial_interpol.py +99 -0
- package/vendors/precis/backend/agents/report_generator.py +92 -0
- package/vendors/precis/backend/agents/semantic_reranker.py +135 -0
- package/vendors/precis/backend/agents/stat_anomaly.py +93 -0
- package/vendors/precis/backend/agents/vector_index.py +123 -0
- package/vendors/precis/backend/agents/veri_score.py +341 -0
- package/vendors/precis/backend/agents/work_order_extractor.py +205 -0
- package/vendors/precis/backend/api/__init__.py +3 -0
- package/vendors/precis/backend/api/routes/__init__.py +3 -0
- package/vendors/precis/backend/config.py +88 -0
- package/vendors/precis/backend/core/__init__.py +13 -0
- package/vendors/precis/backend/core/hashing.py +22 -0
- package/vendors/precis/backend/core/metrics.py +77 -0
- package/vendors/precis/backend/core/multitoken.py +166 -0
- package/vendors/precis/backend/core/pmi.py +54 -0
- package/vendors/precis/backend/core/stemming.py +74 -0
- package/vendors/precis/backend/core/tracing.py +150 -0
- package/vendors/precis/backend/data/__init__.py +3 -0
- package/vendors/precis/backend/data/chunker.py +57 -0
- package/vendors/precis/backend/data/pdf_parser.py +42 -0
- package/vendors/precis/backend/db/__init__.py +3 -0
- package/vendors/precis/backend/db/models.py +173 -0
- package/vendors/precis/backend/db/repository.py +269 -0
- package/vendors/precis/backend/llm/__init__.py +3 -0
- package/vendors/precis/backend/llm/anthropic_provider.py +39 -0
- package/vendors/precis/backend/llm/base.py +147 -0
- package/vendors/precis/backend/llm/deepseek_provider.py +43 -0
- package/vendors/precis/backend/llm/factory.py +60 -0
- package/vendors/precis/backend/llm/google_provider.py +39 -0
- package/vendors/precis/backend/llm/ollama_provider.py +54 -0
- package/vendors/precis/backend/llm/openai_provider.py +50 -0
- package/vendors/precis/backend/main.py +677 -0
- package/vendors/precis/backend/orchestrator/__init__.py +3 -0
- package/vendors/precis/backend/orchestrator/planner.py +81 -0
- package/vendors/precis/backend/orchestrator/router.py +319 -0
- package/vendors/precis/backend/orchestrator/types.py +58 -0
- package/vendors/precis/bridge_precis.py +185 -0
- package/vendors/precis/data/sample_reports/README.md +8 -0
- package/vendors/precis/data/seed_data.py +115 -0
- package/vendors/precis/requirements.txt +19 -0
package/test.mjs
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ╔══════════════════════════════════════════════════════════════╗
|
|
4
|
+
* ║ Unified MCP Server — Complete Test Suite ║
|
|
5
|
+
* ║ Tests ALL 31 tools across 5 repositories ║
|
|
6
|
+
* ║ Run: node test.mjs ║
|
|
7
|
+
* ╚══════════════════════════════════════════════════════════════╝
|
|
8
|
+
*
|
|
9
|
+
* Tests every tool, handles timeouts, shows pass/fail/skip.
|
|
10
|
+
* Python backends auto-start if Python is available.
|
|
11
|
+
* Single file — drop anywhere, run anywhere.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { resolve, dirname } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const ROOT = resolve(__dirname);
|
|
20
|
+
const SERVER = resolve(ROOT, 'packages', 'server', 'src', 'index.ts');
|
|
21
|
+
|
|
22
|
+
// ── Terminal colors ──────────────────────────────────────────────────
|
|
23
|
+
const G = '\x1b[32m', R = '\x1b[31m', Y = '\x1b[33m', C = '\x1b[36m', B = '\x1b[1m', D = '\x1b[90m', X = '\x1b[0m';
|
|
24
|
+
const ok = (s) => G + '✅ ' + s + X;
|
|
25
|
+
const no = (s) => R + '❌ ' + s + X;
|
|
26
|
+
const sk = (s) => Y + '⏭️ ' + s + X;
|
|
27
|
+
const h1 = (s) => '\n' + B + C + s + X;
|
|
28
|
+
const h2 = (s) => B + D + s + X;
|
|
29
|
+
|
|
30
|
+
// ── MCP Test Client ──────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
class MCPClient {
|
|
33
|
+
constructor() {
|
|
34
|
+
this.proc = null;
|
|
35
|
+
this.stdout = '';
|
|
36
|
+
this.stderr = '';
|
|
37
|
+
this.nextId = 1;
|
|
38
|
+
this.pending = new Map();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async start() {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
this.proc = spawn('node', ['--import', 'tsx', SERVER], {
|
|
44
|
+
cwd: ROOT,
|
|
45
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
46
|
+
env: { ...process.env, MCP_LOG_LEVEL: 'error', MCP_RATE_LIMIT_ENABLED: 'false' },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
this.proc.stdout.on('data', (c) => {
|
|
50
|
+
this.stdout += c.toString();
|
|
51
|
+
this._processBuffer();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
this.proc.stderr.on('data', (c) => {
|
|
55
|
+
this.stderr += c.toString();
|
|
56
|
+
// Watch for server ready signal
|
|
57
|
+
if (this.stderr.includes('__MCP_READY__') && !this._readyFired) {
|
|
58
|
+
this._readyFired = true;
|
|
59
|
+
this._doHandshake(resolve, reject);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
this.proc.on('error', reject);
|
|
64
|
+
|
|
65
|
+
// Fallback: if server hasn't signaled ready in 5 min, fail
|
|
66
|
+
setTimeout(() => {
|
|
67
|
+
if (!this._readyFired) {
|
|
68
|
+
reject(new Error('Server did not become ready within 5 minutes'));
|
|
69
|
+
}
|
|
70
|
+
}, 300_000);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async _doHandshake(resolve, reject) {
|
|
75
|
+
try {
|
|
76
|
+
// MCP initialize handshake
|
|
77
|
+
const initResult = await this._sendRaw('initialize', {
|
|
78
|
+
protocolVersion: '2024-11-05',
|
|
79
|
+
capabilities: { tools: {} },
|
|
80
|
+
clientInfo: { name: 'test', version: '1.0.0' },
|
|
81
|
+
}, 10000);
|
|
82
|
+
if (initResult._timedOut) {
|
|
83
|
+
reject(new Error('initialize handshake timed out'));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// Send initialized notification (no id — notification, not request)
|
|
87
|
+
this.proc.stdin.write(JSON.stringify({
|
|
88
|
+
jsonrpc: '2.0',
|
|
89
|
+
method: 'notifications/initialized',
|
|
90
|
+
}) + '\n');
|
|
91
|
+
|
|
92
|
+
// Give server a moment, then resolve
|
|
93
|
+
setTimeout(() => resolve(), 500);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
reject(e);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Send a raw JSON-RPC request and return the response. */
|
|
100
|
+
async _sendRaw(method, params, timeoutMs = 30000) {
|
|
101
|
+
const id = this.nextId++;
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
const timer = setTimeout(() => {
|
|
104
|
+
this.pending.delete(id);
|
|
105
|
+
resolve({ _timedOut: true });
|
|
106
|
+
}, timeoutMs);
|
|
107
|
+
|
|
108
|
+
this.pending.set(id, { resolve: (msg) => { clearTimeout(timer); resolve(msg); } });
|
|
109
|
+
|
|
110
|
+
this.proc.stdin.write(JSON.stringify({
|
|
111
|
+
jsonrpc: '2.0',
|
|
112
|
+
method,
|
|
113
|
+
params,
|
|
114
|
+
id,
|
|
115
|
+
}) + '\n');
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_processBuffer() {
|
|
120
|
+
const lines = this.stdout.split('\n');
|
|
121
|
+
this.stdout = lines.pop() || '';
|
|
122
|
+
for (const line of lines) {
|
|
123
|
+
try {
|
|
124
|
+
const msg = JSON.parse(line.trim());
|
|
125
|
+
if (msg.id && this.pending.has(msg.id)) {
|
|
126
|
+
const { resolve } = this.pending.get(msg.id);
|
|
127
|
+
this.pending.delete(msg.id);
|
|
128
|
+
resolve(msg);
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async call(toolName, args = {}, timeoutMs = 30000) {
|
|
135
|
+
const id = this.nextId++;
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
const timer = setTimeout(() => {
|
|
138
|
+
this.pending.delete(id);
|
|
139
|
+
resolve({ _timedOut: true, toolName });
|
|
140
|
+
}, timeoutMs);
|
|
141
|
+
|
|
142
|
+
this.pending.set(id, { resolve: (msg) => { clearTimeout(timer); resolve(msg); } });
|
|
143
|
+
|
|
144
|
+
this.proc.stdin.write(JSON.stringify({
|
|
145
|
+
jsonrpc: '2.0',
|
|
146
|
+
method: 'tools/call',
|
|
147
|
+
params: { name: toolName, arguments: args },
|
|
148
|
+
id,
|
|
149
|
+
}) + '\n');
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
stop() {
|
|
154
|
+
if (this.proc) { this.proc.kill(); this.proc = null; }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Test definitions ─────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
const TESTS = [
|
|
161
|
+
// ═══ Built-in (always) ═══
|
|
162
|
+
{ name: 'mcp_health', args: {}, timeout: 5000, cat: 'Built-in' },
|
|
163
|
+
{ name: 'mcp_list_providers', args: {}, timeout: 5000, cat: 'Built-in' },
|
|
164
|
+
|
|
165
|
+
// ═══ DeepPipe (always works, no keys) ═══
|
|
166
|
+
{ name: 'deeppipe_stats', args: {}, timeout: 5000, cat: 'DeepPipe' },
|
|
167
|
+
{ name: 'deeppipe_list_documents', args: {}, timeout: 5000, cat: 'DeepPipe' },
|
|
168
|
+
{ name: 'deeppipe_search', args: { query: 'test', limit: 3 }, timeout: 5000, cat: 'DeepPipe' },
|
|
169
|
+
{ name: 'deeppipe_ingest', args: { data: Buffer.from('Test document. Payment terms net 30.').toString('base64'), source: 'test.txt' }, timeout: 10000, cat: 'DeepPipe' },
|
|
170
|
+
{ name: 'deeppipe_get_document', args: { id: 1 }, timeout: 5000, cat: 'DeepPipe' },
|
|
171
|
+
{ name: 'deeppipe_get_text', args: { id: 1 }, timeout: 5000, cat: 'DeepPipe' },
|
|
172
|
+
{ name: 'deeppipe_remove_document', args: { id: 1 }, timeout: 5000, cat: 'DeepPipe' },
|
|
173
|
+
{ name: 'deeppipe_ingest_file', args: { path: 'test.txt' }, timeout: 5000, cat: 'DeepPipe', expectFail: true },
|
|
174
|
+
{ name: 'deeppipe_extractive_answer', args: { question: 'what is this about?' }, timeout: 10000, cat: 'DeepPipe' },
|
|
175
|
+
{ name: 'deeppipe_chat_context', args: { question: 'payment?' }, timeout: 15000, cat: 'DeepPipe (LLM)' },
|
|
176
|
+
|
|
177
|
+
// ═══ Piste (Python bridge or local DSPy) ═══
|
|
178
|
+
{ name: 'piste_fact_check', args: { claim_text: 'The sky is blue because of Rayleigh scattering.', locale: 'en' }, timeout: 90000, cat: 'Piste (Python)' },
|
|
179
|
+
{ name: 'piste_list_verdicts', args: {}, timeout: 5000, cat: 'Piste' },
|
|
180
|
+
{ name: 'piste_replay', args: { run_id: 'test' }, timeout: 5000, cat: 'Piste' },
|
|
181
|
+
{ name: 'piste_get_audit', args: { run_id: 'test' }, timeout: 5000, cat: 'Piste' },
|
|
182
|
+
{ name: 'piste_get_verdict', args: { claim_id: 'test' }, timeout: 5000, cat: 'Piste' },
|
|
183
|
+
{ name: 'piste_submit_feedback', args: { run_id: 'test', rating: 3 }, timeout: 5000, cat: 'Piste' },
|
|
184
|
+
|
|
185
|
+
// ═══ Precis (Python bridge or local backend) ═══
|
|
186
|
+
{ name: 'precis_list_documents', args: {}, timeout: 30000, cat: 'Precis (Python)' },
|
|
187
|
+
{ name: 'precis_debug_stem', args: { q: 'payment terms' }, timeout: 30000, cat: 'Precis (Python)' },
|
|
188
|
+
{ name: 'precis_debug_search', args: { q: 'test' }, timeout: 30000, cat: 'Precis (Python)' },
|
|
189
|
+
{ name: 'precis_query', args: { query: 'test' }, timeout: 60000, cat: 'Precis (Python)' },
|
|
190
|
+
{ name: 'precis_upload_document', args: { data: Buffer.from('test').toString('base64'), filename: 't.txt' }, timeout: 30000, cat: 'Precis' },
|
|
191
|
+
{ name: 'precis_upload_batch', args: { files: [] }, timeout: 5000, cat: 'Precis' },
|
|
192
|
+
{ name: 'precis_extract_work_order', args: { data: Buffer.from('test').toString('base64') }, timeout: 5000, cat: 'Precis' },
|
|
193
|
+
{ name: 'precis_list_work_orders', args: {}, timeout: 5000, cat: 'Precis' },
|
|
194
|
+
|
|
195
|
+
// ═══ Clinical (needs Groq + ElevenLabs keys) ═══
|
|
196
|
+
{ name: 'clinical_start_session', args: { lang: 'en' }, timeout: 15000, cat: 'Clinical' },
|
|
197
|
+
{ name: 'clinical_list_sessions', args: {}, timeout: 5000, cat: 'Clinical' },
|
|
198
|
+
{ name: 'clinical_get_session', args: { session_id: 'x' }, timeout: 5000, cat: 'Clinical' },
|
|
199
|
+
{ name: 'clinical_process_audio', args: { session_id: 'x', audio_data: 'dGVzdA==' }, timeout: 5000, cat: 'Clinical (audio)' },
|
|
200
|
+
{ name: 'clinical_generate_podcast', args: { session_id: 'x' }, timeout: 5000, cat: 'Clinical (audio)' },
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
// ── Result interpreter ───────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
function interpret(toolName, response) {
|
|
206
|
+
if (response._timedOut) return { status: 'fail', reason: 'Timeout (no response from server)' };
|
|
207
|
+
|
|
208
|
+
// MCP JSON-RPC error
|
|
209
|
+
if (response.error) {
|
|
210
|
+
const msg = response.error.message || '';
|
|
211
|
+
if (/not reachable|SERVICE_UNAVAILABLE|not configured|Python not found|spawn python/i.test(msg))
|
|
212
|
+
return { status: 'skip', reason: msg.slice(0, 60) };
|
|
213
|
+
if (/NOT_FOUND|not found/i.test(msg))
|
|
214
|
+
return { status: 'skip', reason: msg.slice(0, 60) };
|
|
215
|
+
if (/RATE_LIMIT/i.test(msg))
|
|
216
|
+
return { status: 'fail', reason: 'Rate limited (retry)' };
|
|
217
|
+
return { status: 'fail', reason: msg.slice(0, 80) };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Check result content
|
|
221
|
+
const content = response.result?.content?.[0]?.text;
|
|
222
|
+
if (!content) return { status: 'fail', reason: 'Empty response' };
|
|
223
|
+
|
|
224
|
+
let data;
|
|
225
|
+
try { data = JSON.parse(content); } catch { data = content; }
|
|
226
|
+
|
|
227
|
+
// Is it an error?
|
|
228
|
+
if (response.result?.isError) {
|
|
229
|
+
const errMsg = typeof data === 'string' ? data : (data?.error?.message || data?.error || data?.note || JSON.stringify(data).slice(0, 50));
|
|
230
|
+
if (/not reachable|not configured|not found|Python|backend|bridge|available with full/i.test(errMsg))
|
|
231
|
+
return { status: 'skip', reason: errMsg.slice(0, 60) };
|
|
232
|
+
return { status: 'fail', reason: errMsg.slice(0, 80) };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { status: 'pass', detail: summarize(toolName, data) };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function summarize(name, data) {
|
|
239
|
+
const d = data || {};
|
|
240
|
+
switch (name) {
|
|
241
|
+
case 'mcp_health': return d.tools?.total + ' tools, ' + d.llm?.defaultProvider;
|
|
242
|
+
case 'mcp_list_providers': return (Array.isArray(d) ? d.length : '?') + ' providers';
|
|
243
|
+
case 'deeppipe_stats': return d.documentCount + ' docs';
|
|
244
|
+
case 'deeppipe_search': return d.totalHits + ' hits';
|
|
245
|
+
case 'deeppipe_ingest': return 'doc #' + d.documentId + ', ' + d.wordCount + ' words';
|
|
246
|
+
case 'deeppipe_list_documents': return (d.documents?.length || 0) + ' docs';
|
|
247
|
+
case 'deeppipe_get_document': return d.document?.source || 'ok';
|
|
248
|
+
case 'deeppipe_get_text': return (d.text?.length || 0) + ' chars';
|
|
249
|
+
case 'deeppipe_chat_context': return (d.sources?.length || 0) + ' sources';
|
|
250
|
+
case 'deeppipe_extractive_answer': return (d.answer || '').slice(0, 30) + '...';
|
|
251
|
+
case 'piste_fact_check': return 'verdict: ' + (d.verdict?.label || d.note || '?');
|
|
252
|
+
case 'clinical_start_session': return 'session: ' + (d.session_id || '?').slice(0, 12);
|
|
253
|
+
case 'clinical_list_sessions': return (d.sessions?.length || 0) + ' sessions';
|
|
254
|
+
case 'precis_debug_stem': return (d.stemmed_tokens?.length || 0) + ' tokens';
|
|
255
|
+
case 'precis_list_documents': return Array.isArray(d) ? d.length + ' docs' : 'ok';
|
|
256
|
+
default: return 'ok';
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Main ─────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
async function main() {
|
|
263
|
+
console.log(h1('╔══════════════════════════════════════════╗'));
|
|
264
|
+
console.log(h1('║ MCP Agentic Pipelines — Test Suite ║'));
|
|
265
|
+
console.log(h1('╚══════════════════════════════════════════╝'));
|
|
266
|
+
console.log(D + 'Server: ' + SERVER + X + '\n');
|
|
267
|
+
|
|
268
|
+
const client = new MCPClient();
|
|
269
|
+
|
|
270
|
+
console.log('Starting server...');
|
|
271
|
+
await client.start();
|
|
272
|
+
console.log('Server ready. Testing ' + TESTS.length + ' tools...\n');
|
|
273
|
+
|
|
274
|
+
const results = [];
|
|
275
|
+
let currentCat = '';
|
|
276
|
+
|
|
277
|
+
for (let i = 0; i < TESTS.length; i++) {
|
|
278
|
+
const { name, args, timeout, cat, expectFail } = TESTS[i];
|
|
279
|
+
|
|
280
|
+
if (cat !== currentCat) {
|
|
281
|
+
currentCat = cat;
|
|
282
|
+
console.log(h2('── ' + cat + ' ──'));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const label = name.padEnd(30);
|
|
286
|
+
const response = await client.call(name, args, timeout);
|
|
287
|
+
const result = interpret(name, response);
|
|
288
|
+
|
|
289
|
+
// If expected to fail and it fails → that's a pass
|
|
290
|
+
if (expectFail && result.status === 'fail') {
|
|
291
|
+
result.status = 'pass';
|
|
292
|
+
result.detail = 'expected failure';
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
switch (result.status) {
|
|
296
|
+
case 'pass': console.log(' ' + ok(label) + D + (result.detail || '') + X); break;
|
|
297
|
+
case 'fail': console.log(' ' + no(label) + D + (result.reason || '') + X); break;
|
|
298
|
+
case 'skip': console.log(' ' + sk(label) + D + (result.reason || '') + X); break;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
results.push({ name, ...result });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
client.stop();
|
|
305
|
+
|
|
306
|
+
// ── Summary ────────────────────────────────────────────────────
|
|
307
|
+
const passed = results.filter(r => r.status === 'pass').length;
|
|
308
|
+
const failed = results.filter(r => r.status === 'fail').length;
|
|
309
|
+
const skipped = results.filter(r => r.status === 'skip').length;
|
|
310
|
+
|
|
311
|
+
console.log(h1('═══════════════════════════════════════════'));
|
|
312
|
+
console.log(' ' + ok(passed + ' passed') + ' ' + no(failed + ' failed') + ' ' + sk(skipped + ' skipped') + ' (' + TESTS.length + ' total)');
|
|
313
|
+
console.log(h1('═══════════════════════════════════════════'));
|
|
314
|
+
|
|
315
|
+
// Show failures detail
|
|
316
|
+
if (failed > 0) {
|
|
317
|
+
console.log(R + '\nFailures:' + X);
|
|
318
|
+
for (const r of results.filter(r => r.status === 'fail')) {
|
|
319
|
+
console.log(' ' + no(r.name) + ': ' + (r.reason || ''));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Show how to fix skipped
|
|
324
|
+
if (skipped > 0) {
|
|
325
|
+
console.log(Y + '\nSkipped tools — enable with:' + X);
|
|
326
|
+
console.log(' • Python: Install from https://python.org');
|
|
327
|
+
console.log(' • Python deps: The MCP server auto-installs all pip packages on startup.');
|
|
328
|
+
console.log(' • Groq: Set GROQ_API_KEY and ELEVENLABS_API_KEY in .env');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
main().catch((e) => {
|
|
335
|
+
console.error(R + 'FATAL: ' + e.message + X);
|
|
336
|
+
process.exit(2);
|
|
337
|
+
});
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clinical Intake — Core Pipeline Module
|
|
3
|
+
* Extracted from server.js for reuse by both Express HTTP server and MCP server.
|
|
4
|
+
*
|
|
5
|
+
* Pure functions for:
|
|
6
|
+
* - Loading clinical questionnaires (EN/FR)
|
|
7
|
+
* - Building system prompts with clinical safety rules
|
|
8
|
+
* - Groq STT (speech-to-text)
|
|
9
|
+
* - Multi-provider LLM chat (OpenAI-compatible)
|
|
10
|
+
* - ElevenLabs TTS (text-to-speech)
|
|
11
|
+
* - In-memory session management
|
|
12
|
+
*
|
|
13
|
+
* Privacy: Anonymous by design. No patient identifiers persisted.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import { fileURLToPath } from 'url';
|
|
19
|
+
import { Groq } from 'groq-sdk';
|
|
20
|
+
import OpenAI from 'openai';
|
|
21
|
+
|
|
22
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
|
|
24
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
25
|
+
// Clinical Questionnaires
|
|
26
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
27
|
+
|
|
28
|
+
const QUESTIONS_DIR = path.resolve(__dirname, 'questions');
|
|
29
|
+
|
|
30
|
+
/** Load clinical questions from questions/{lang}.txt */
|
|
31
|
+
export function loadQuestions(lang) {
|
|
32
|
+
const file = path.join(QUESTIONS_DIR, `${lang}.txt`);
|
|
33
|
+
if (!fs.existsSync(file)) return [];
|
|
34
|
+
return fs.readFileSync(file, 'utf8').split('\n').map(l => l.trim()).filter(l => l.length > 0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const QUESTIONS_FR = loadQuestions('fr');
|
|
38
|
+
export const QUESTIONS_EN = loadQuestions('en');
|
|
39
|
+
|
|
40
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
41
|
+
// System Prompts
|
|
42
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the clinical system prompt for a given language and patient name.
|
|
46
|
+
* Includes ALL clinical questions and safety rules.
|
|
47
|
+
*/
|
|
48
|
+
export function buildSystemPrompt(lang, firstName) {
|
|
49
|
+
const questions = lang === 'fr' ? QUESTIONS_FR : QUESTIONS_EN;
|
|
50
|
+
const name = firstName || (lang === 'fr' ? 'M./Mme' : 'sir/madam');
|
|
51
|
+
const org = lang === 'fr' ? 'RAMQ' : 'Health Canada';
|
|
52
|
+
const numberedQuestions = questions.map((q, i) => `${i + 1}. ${q}`).join('\n');
|
|
53
|
+
|
|
54
|
+
if (lang === 'fr') {
|
|
55
|
+
return [
|
|
56
|
+
`Vous êtes un assistant d'accueil clinique pour ${org}.`,
|
|
57
|
+
`Vous effectuez un entretien de pré-consultation structuré.`,
|
|
58
|
+
`Vous vous adressez au patient « ${name} ». Utilisez son prénom.`,
|
|
59
|
+
`Vous êtes professionnel, empathique et efficace. Vous parlez en français.`,
|
|
60
|
+
'',
|
|
61
|
+
`VOICI LES QUESTIONS QUE VOUS DEVEZ POSER, DANS CET ORDRE EXACT :`,
|
|
62
|
+
numberedQuestions,
|
|
63
|
+
'',
|
|
64
|
+
'RÈGLES :',
|
|
65
|
+
'- Posez UNE question à la fois. Attendez la réponse du patient avant de passer à la suivante.',
|
|
66
|
+
'- Si le patient répond brièvement, passez à la question suivante.',
|
|
67
|
+
'- Si le patient divague, redirigez doucement vers la question posée.',
|
|
68
|
+
'- Si le patient mentionne un symptôme urgent (douleur thoracique, essoufflement, perte de conscience), notez-le comme PRIORITAIRE.',
|
|
69
|
+
'- NE JAMAIS diagnostiquer, prescrire ou donner un avis médical.',
|
|
70
|
+
'- Vouvoyez le patient (utilisez « vous »).',
|
|
71
|
+
'- Parlez calmement et clairement.',
|
|
72
|
+
].join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return [
|
|
76
|
+
`You are a Clinical Intake Assistant for ${org}.`,
|
|
77
|
+
`You conduct a structured pre-consultation interview.`,
|
|
78
|
+
`You are speaking to the patient "${name}". Use their first name.`,
|
|
79
|
+
`You are professional, empathetic, and efficient. You speak in English.`,
|
|
80
|
+
'',
|
|
81
|
+
`HERE ARE THE QUESTIONS YOU MUST ASK, IN THIS EXACT ORDER:`,
|
|
82
|
+
numberedQuestions,
|
|
83
|
+
'',
|
|
84
|
+
'RULES:',
|
|
85
|
+
'- Ask ONE question at a time. Wait for the patient\'s response before moving to the next.',
|
|
86
|
+
'- If the patient answers briefly, move to the next question.',
|
|
87
|
+
'- If the patient rambles, gently redirect to the question asked.',
|
|
88
|
+
'- If the patient mentions an urgent symptom (chest pain, shortness of breath, loss of consciousness), flag it as PRIORITY.',
|
|
89
|
+
'- NEVER diagnose, prescribe, or give medical advice.',
|
|
90
|
+
'- Speak calmly and clearly — the patient may be anxious or in pain.',
|
|
91
|
+
].join('\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build the priming message (first greeting the patient hears).
|
|
96
|
+
*/
|
|
97
|
+
export function getPriming(lang, firstName) {
|
|
98
|
+
const questions = lang === 'fr' ? QUESTIONS_FR : QUESTIONS_EN;
|
|
99
|
+
const name = firstName || '';
|
|
100
|
+
const addr = name ? ` ${name},` : '';
|
|
101
|
+
const firstQ = questions[0] || (lang === 'fr'
|
|
102
|
+
? `Bonjour${addr} qu'est-ce qui vous amène aujourd'hui?`
|
|
103
|
+
: `Hello${addr} what brings you in today?`);
|
|
104
|
+
|
|
105
|
+
if (name && firstQ.includes('je suis')) {
|
|
106
|
+
return firstQ;
|
|
107
|
+
}
|
|
108
|
+
return lang === 'fr'
|
|
109
|
+
? `Bonjour${addr} ${firstQ.charAt(0).toLowerCase() + firstQ.slice(1)}`
|
|
110
|
+
: `Hello${addr} ${firstQ.charAt(0).toLowerCase() + firstQ.slice(1)}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
114
|
+
// Session Management (in-memory, anonymous by design)
|
|
115
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
116
|
+
|
|
117
|
+
const sessions = new Map();
|
|
118
|
+
|
|
119
|
+
export function generateSessionId() {
|
|
120
|
+
const now = new Date();
|
|
121
|
+
const pad = n => String(n).padStart(2, '0');
|
|
122
|
+
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}-${String(now.getMilliseconds()).padStart(3, '0')}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function getSession(id, patientName = '', lang = 'fr') {
|
|
126
|
+
if (!sessions.has(id)) {
|
|
127
|
+
const name = patientName || '';
|
|
128
|
+
const priming = getPriming(lang, patientName);
|
|
129
|
+
sessions.set(id, {
|
|
130
|
+
turns: [
|
|
131
|
+
{ role: 'system', content: buildSystemPrompt(lang, patientName) },
|
|
132
|
+
{ role: 'assistant', content: priming }
|
|
133
|
+
],
|
|
134
|
+
dialog: [],
|
|
135
|
+
primingText: priming,
|
|
136
|
+
patientName: patientName,
|
|
137
|
+
lang: lang,
|
|
138
|
+
title: null,
|
|
139
|
+
createdAt: new Date().toISOString(),
|
|
140
|
+
questionIndex: 0,
|
|
141
|
+
questions: lang === 'fr' ? QUESTIONS_FR : QUESTIONS_EN,
|
|
142
|
+
isComplete: false,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return sessions.get(id);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function listSessions() {
|
|
149
|
+
return Array.from(sessions.entries()).map(([id, s]) => ({
|
|
150
|
+
session_id: id,
|
|
151
|
+
patient_name: s.patientName,
|
|
152
|
+
lang: s.lang,
|
|
153
|
+
turn_count: s.dialog.length,
|
|
154
|
+
is_complete: s.isComplete,
|
|
155
|
+
created_at: s.createdAt,
|
|
156
|
+
})).sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function deleteSession(id) {
|
|
160
|
+
sessions.delete(id);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
164
|
+
// Groq STT (Speech-to-Text)
|
|
165
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
166
|
+
|
|
167
|
+
let _groqClient = null;
|
|
168
|
+
|
|
169
|
+
function getGroqClient(apiKey) {
|
|
170
|
+
if (!_groqClient || process.env.GROQ_API_KEY !== apiKey) {
|
|
171
|
+
_groqClient = new Groq({ apiKey });
|
|
172
|
+
}
|
|
173
|
+
return _groqClient;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Transcribe an audio buffer to text using Groq's Whisper v3.
|
|
178
|
+
* @param {Buffer} audioBuffer - Raw audio bytes (webm, mp3, wav)
|
|
179
|
+
* @param {string} lang - Language hint: 'fr', 'en', or other ISO code
|
|
180
|
+
* @param {string} apiKey - Groq API key
|
|
181
|
+
* @returns {Promise<string>} Transcribed text
|
|
182
|
+
*/
|
|
183
|
+
export async function transcribe(audioBuffer, lang = 'fr', apiKey) {
|
|
184
|
+
const groq = getGroqClient(apiKey || process.env.GROQ_API_KEY);
|
|
185
|
+
const file = await Groq.toFile(audioBuffer, 'audio.webm');
|
|
186
|
+
const params = {
|
|
187
|
+
file, model: 'whisper-large-v3', temperature: 0.0, response_format: 'json',
|
|
188
|
+
};
|
|
189
|
+
if (lang && lang !== 'en') {
|
|
190
|
+
params.language = lang === 'fr' ? 'fr' : lang;
|
|
191
|
+
}
|
|
192
|
+
const resp = await groq.audio.transcriptions.create(params);
|
|
193
|
+
return resp.text?.trim() || '';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
197
|
+
// LLM Chat — Multi-Provider (OpenAI-compatible)
|
|
198
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Create an LLM client for any OpenAI-compatible provider.
|
|
202
|
+
* @param {object} config - { apiKey, baseUrl, model }
|
|
203
|
+
* @returns {OpenAI}
|
|
204
|
+
*/
|
|
205
|
+
export function createLLMClient(config = {}) {
|
|
206
|
+
const apiKey = config.apiKey || process.env.DEEPSEEK_API_KEY || '';
|
|
207
|
+
const baseURL = config.baseUrl || process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com';
|
|
208
|
+
return new OpenAI({ apiKey, baseURL });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Send messages to an LLM for clinical reasoning.
|
|
213
|
+
* @param {Array<{role: string, content: string}>} messages
|
|
214
|
+
* @param {object} config - { apiKey, baseUrl, model }
|
|
215
|
+
* @returns {Promise<string>}
|
|
216
|
+
*/
|
|
217
|
+
export async function clinicalChat(messages, config = {}) {
|
|
218
|
+
const client = createLLMClient(config);
|
|
219
|
+
const model = config.model || process.env.DEEPSEEK_MODEL || 'deepseek-chat';
|
|
220
|
+
const resp = await client.chat.completions.create({
|
|
221
|
+
model,
|
|
222
|
+
messages,
|
|
223
|
+
temperature: 0.6,
|
|
224
|
+
max_tokens: 400,
|
|
225
|
+
presence_penalty: 0.3,
|
|
226
|
+
frequency_penalty: 0.3,
|
|
227
|
+
});
|
|
228
|
+
return resp.choices[0]?.message?.content?.trim() || "I'm sorry, I didn't catch that.";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
232
|
+
// ElevenLabs TTS (Text-to-Speech)
|
|
233
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Synthesize speech from text using ElevenLabs.
|
|
237
|
+
* @param {string} text - Text to speak
|
|
238
|
+
* @param {string} apiKey - ElevenLabs API key
|
|
239
|
+
* @param {string} voiceId - ElevenLabs voice ID
|
|
240
|
+
* @returns {Promise<Buffer>} MP3 audio buffer
|
|
241
|
+
*/
|
|
242
|
+
export async function synthesize(text, apiKey, voiceId) {
|
|
243
|
+
const key = apiKey || process.env.ELEVENLABS_API_KEY;
|
|
244
|
+
const voice = voiceId || process.env.ELEVENLABS_VOICE_ID || '21m00Tcm4TlvDq8ikWAM';
|
|
245
|
+
const url = `https://api.elevenlabs.io/v1/text-to-speech/${voice}/stream`;
|
|
246
|
+
const resp = await fetch(url, {
|
|
247
|
+
method: 'POST',
|
|
248
|
+
headers: { 'Content-Type': 'application/json', 'xi-api-key': key },
|
|
249
|
+
body: JSON.stringify({
|
|
250
|
+
text,
|
|
251
|
+
model_id: 'eleven_flash_v2_5',
|
|
252
|
+
voice_settings: { stability: 0.5, similarity_boost: 0.75 },
|
|
253
|
+
}),
|
|
254
|
+
});
|
|
255
|
+
if (!resp.ok) {
|
|
256
|
+
const err = await resp.text().catch(() => '');
|
|
257
|
+
throw new Error(`ElevenLabs ${resp.status}: ${err.slice(0, 200)}`);
|
|
258
|
+
}
|
|
259
|
+
return Buffer.from(await resp.arrayBuffer());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
263
|
+
// Full Turn Processing
|
|
264
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Process one complete clinical turn: STT → LLM → TTS.
|
|
268
|
+
* This is the core pipeline that the MCP server calls directly.
|
|
269
|
+
*
|
|
270
|
+
* @param {string} sessionId
|
|
271
|
+
* @param {Buffer} audioBuffer - Patient's spoken response
|
|
272
|
+
* @param {object} config - { groqApiKey, llmApiKey, llmBaseUrl, llmModel, elevenlabsApiKey, elevenlabsVoiceId }
|
|
273
|
+
* @returns {Promise<object>} { userText, assistantText, assistantAudioBase64, turnNumber, isComplete }
|
|
274
|
+
*/
|
|
275
|
+
export async function processClinicalTurn(sessionId, audioBuffer, config = {}) {
|
|
276
|
+
const session = getSession(sessionId);
|
|
277
|
+
|
|
278
|
+
// 1. STT — transcribe patient audio
|
|
279
|
+
const userText = await transcribe(audioBuffer, session.lang, config.groqApiKey);
|
|
280
|
+
if (!userText) {
|
|
281
|
+
throw new Error('Could not transcribe audio. Please speak clearly and try again.');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Save user turn
|
|
285
|
+
session.turns.push({ role: 'user', content: userText });
|
|
286
|
+
session.dialog.push({ role: 'user', text: userText });
|
|
287
|
+
|
|
288
|
+
// 2. LLM — clinical reasoning
|
|
289
|
+
const messages = session.turns.map(t => ({ role: t.role, content: t.content }));
|
|
290
|
+
const replyText = await clinicalChat(messages, {
|
|
291
|
+
apiKey: config.llmApiKey,
|
|
292
|
+
baseUrl: config.llmBaseUrl,
|
|
293
|
+
model: config.llmModel,
|
|
294
|
+
});
|
|
295
|
+
session.turns.push({ role: 'assistant', content: replyText });
|
|
296
|
+
|
|
297
|
+
// 3. TTS — synthesize reply
|
|
298
|
+
let audioBase64 = null;
|
|
299
|
+
try {
|
|
300
|
+
const audioBuffer = await synthesize(replyText, config.elevenlabsApiKey, config.elevenlabsVoiceId);
|
|
301
|
+
audioBase64 = audioBuffer.toString('base64');
|
|
302
|
+
} catch (err) {
|
|
303
|
+
console.warn(`[${sessionId}] TTS unavailable: ${err.message}`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
session.dialog.push({ role: 'assistant', text: replyText, audioBase64 });
|
|
307
|
+
session.questionIndex++;
|
|
308
|
+
if (session.questionIndex >= session.questions.length) {
|
|
309
|
+
session.isComplete = true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
user_text: userText,
|
|
314
|
+
assistant_text: replyText,
|
|
315
|
+
assistant_audio_base64: audioBase64,
|
|
316
|
+
turn_number: session.questionIndex,
|
|
317
|
+
is_complete: session.isComplete,
|
|
318
|
+
questions_remaining: session.isComplete ? 0 : session.questions.length - session.questionIndex,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Compile all dialog turns into a single MP3 podcast buffer.
|
|
324
|
+
*/
|
|
325
|
+
export async function buildPodcast(sessionId, config = {}) {
|
|
326
|
+
const session = getSession(sessionId);
|
|
327
|
+
const chunks = [];
|
|
328
|
+
|
|
329
|
+
// Add greeting
|
|
330
|
+
try {
|
|
331
|
+
chunks.push(await synthesize(session.primingText, config.elevenlabsApiKey, config.elevenlabsVoiceId));
|
|
332
|
+
} catch {}
|
|
333
|
+
|
|
334
|
+
// Add all turns
|
|
335
|
+
for (const turn of session.dialog) {
|
|
336
|
+
try {
|
|
337
|
+
if (turn.audioBase64) {
|
|
338
|
+
chunks.push(Buffer.from(turn.audioBase64, 'base64'));
|
|
339
|
+
} else {
|
|
340
|
+
const voiceId = turn.role === 'user'
|
|
341
|
+
? (config.elevenlabsUserVoiceId || config.elevenlabsVoiceId)
|
|
342
|
+
: config.elevenlabsVoiceId;
|
|
343
|
+
chunks.push(await synthesize(turn.text, config.elevenlabsApiKey, voiceId));
|
|
344
|
+
}
|
|
345
|
+
} catch {}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return Buffer.concat(chunks);
|
|
349
|
+
}
|