Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
208 lines
8 KiB
JavaScript
208 lines
8 KiB
JavaScript
/**
|
|
* Voice Transcription & Operators Controller
|
|
* ADR-027: Voice transcription API
|
|
* GET /operators, POST /transcribe
|
|
* GET/PATCH /spaces/:spaceId/transcription
|
|
*/
|
|
|
|
import { Router } from 'express';
|
|
import { authenticate } from '../../../middleware/auth.js';
|
|
import { dbGet, dbAll, dbRun, isPostgres } from '../../../database/connection.js';
|
|
import { apiLogger } from '../../../utils/logger.js';
|
|
import { success, notFound, badRequest, error } from '../../../utils/response.js';
|
|
import { getSecret } from '../../../services/secrets/getSecret.js';
|
|
import { safeParseJSON } from './shared.js';
|
|
|
|
const router = Router();
|
|
|
|
/**
|
|
* GET /operators
|
|
*/
|
|
router.get('/operators', authenticate, async (req, res) => {
|
|
try {
|
|
const { space_id, capability } = req.query;
|
|
|
|
let operatorsTable;
|
|
if (space_id) {
|
|
operatorsTable = await dbGet(`
|
|
SELECT ut.id FROM universal_tables ut JOIN projects p ON ut.project_id = p.id
|
|
WHERE p.space_id = ? AND ut.name LIKE '%Operators%' LIMIT 1
|
|
`, [space_id]);
|
|
}
|
|
if (!operatorsTable) {
|
|
operatorsTable = await dbGet(`SELECT id FROM universal_tables WHERE name LIKE '%Operators%' LIMIT 1`);
|
|
}
|
|
if (!operatorsTable) return success(res, []);
|
|
|
|
const operators = await dbAll(`
|
|
SELECT id, base_id, data, created_at FROM table_rows WHERE table_id = ? ORDER BY created_at ASC
|
|
`, [operatorsTable.id]);
|
|
|
|
let result = operators.map(row => {
|
|
const data = safeParseJSON(row.data, {});
|
|
return {
|
|
id: row.id, base_id: row.base_id, name: data.name || 'Unknown',
|
|
provider: data.provider || 'openai', status: data.status || 'inactive',
|
|
api_url: data.api_url || '', models: data.models || '',
|
|
capabilities: data.capabilities || { chat: true }, description: data.description || ''
|
|
};
|
|
});
|
|
|
|
if (capability) {
|
|
result = result.filter(op => op.capabilities && op.capabilities[capability] === true);
|
|
}
|
|
|
|
return success(res, result);
|
|
} catch (err) {
|
|
apiLogger.error({ err, context: 'GET operators' }, 'Error fetching operators');
|
|
return error(res, 'FETCH_OPERATORS_ERROR', 'Failed to fetch operators', 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /transcribe
|
|
*/
|
|
router.post('/transcribe', authenticate, async (req, res) => {
|
|
try {
|
|
const { audio, format = 'webm', operator_id, space_id, language } = req.body;
|
|
if (!audio) return badRequest(res, 'Missing required field: audio');
|
|
|
|
let operatorData = null;
|
|
let apiKey = null;
|
|
let apiUrl = 'https://api.openai.com/v1';
|
|
|
|
if (operator_id) {
|
|
const operatorRow = await dbGet(`
|
|
SELECT tr.data FROM table_rows tr JOIN universal_tables ut ON tr.table_id = ut.id
|
|
WHERE tr.id = ? AND ut.name LIKE '%Operators%'
|
|
`, [operator_id]);
|
|
if (operatorRow) operatorData = safeParseJSON(operatorRow.data, {});
|
|
}
|
|
|
|
if (!operatorData && space_id) {
|
|
const space = await dbGet(`SELECT settings FROM spaces WHERE id = ?`, [space_id]);
|
|
if (space) {
|
|
const settings = safeParseJSON(space.settings, {});
|
|
if (settings.transcription?.operator_id) {
|
|
const operatorRow = await dbGet(`
|
|
SELECT tr.data FROM table_rows tr JOIN universal_tables ut ON tr.table_id = ut.id
|
|
WHERE tr.id = ? AND ut.name LIKE '%Operators%'
|
|
`, [settings.transcription.operator_id]);
|
|
if (operatorRow) operatorData = safeParseJSON(operatorRow.data, {});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!operatorData) {
|
|
const operatorRow = await dbGet(`
|
|
SELECT tr.data FROM table_rows tr JOIN universal_tables ut ON tr.table_id = ut.id
|
|
WHERE ut.name LIKE '%Operators%'
|
|
AND (
|
|
${isPostgres()
|
|
? "(tr.data::jsonb->'capabilities'->>'transcription')::boolean = true OR tr.data::jsonb->>'provider' = 'openai'"
|
|
: "json_extract(tr.data, '$.capabilities.transcription') = 1 OR json_extract(tr.data, '$.provider') = 'openai'"
|
|
}
|
|
)
|
|
AND ${isPostgres()
|
|
? "tr.data::jsonb->>'status' = 'active'"
|
|
: "json_extract(tr.data, '$.status') = 'active'"
|
|
}
|
|
ORDER BY tr.created_at ASC LIMIT 1
|
|
`);
|
|
if (operatorRow) operatorData = safeParseJSON(operatorRow.data, {});
|
|
}
|
|
|
|
if (operatorData) {
|
|
apiKey = operatorData.api_key;
|
|
apiUrl = operatorData.api_url || 'https://api.openai.com/v1';
|
|
}
|
|
if (!apiKey) apiKey = await getSecret('openai_api_key', 'OPENAI_API_KEY');
|
|
if (!apiKey) return badRequest(res, 'No API key configured for transcription');
|
|
|
|
const audioBuffer = Buffer.from(audio, 'base64');
|
|
const maxSize = 25 * 1024 * 1024;
|
|
if (audioBuffer.length > maxSize) return badRequest(res, 'Audio file too large. Maximum size is 25MB.');
|
|
|
|
const mimeTypes = {
|
|
'webm': 'audio/webm', 'mp3': 'audio/mpeg', 'wav': 'audio/wav',
|
|
'm4a': 'audio/m4a', 'ogg': 'audio/ogg', 'flac': 'audio/flac'
|
|
};
|
|
const mimeType = mimeTypes[format] || 'audio/webm';
|
|
|
|
const FormData = (await import('form-data')).default;
|
|
const formData = new FormData();
|
|
formData.append('file', audioBuffer, { filename: `audio.${format}`, contentType: mimeType });
|
|
formData.append('model', 'whisper-1');
|
|
if (language) formData.append('language', language);
|
|
|
|
const fetch = (await import('node-fetch')).default;
|
|
const whisperUrl = `${apiUrl.replace(/\/$/, '')}/audio/transcriptions`;
|
|
|
|
const response = await fetch(whisperUrl, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${apiKey}`, ...formData.getHeaders() },
|
|
body: formData
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
apiLogger.error({ status: response.status, error: errorText, context: 'Whisper API' }, 'Whisper API error');
|
|
return error(res, 'WHISPER_API_ERROR', `Transcription failed: ${response.statusText}`, response.status);
|
|
}
|
|
|
|
const result = await response.json();
|
|
return success(res, { data: { text: result.text, language: language || 'auto' } });
|
|
} catch (err) {
|
|
apiLogger.error({ err, context: 'Transcribe' }, 'Error transcribing audio');
|
|
return error(res, 'TRANSCRIBE_ERROR', 'Failed to transcribe audio', 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /spaces/:spaceId/transcription
|
|
*/
|
|
router.get('/spaces/:spaceId/transcription', authenticate, async (req, res) => {
|
|
try {
|
|
const { spaceId } = req.params;
|
|
const space = await dbGet(`SELECT settings FROM spaces WHERE id = ?`, [spaceId]);
|
|
if (!space) return notFound(res, 'Space not found');
|
|
|
|
const settings = safeParseJSON(space.settings, {});
|
|
const transcription = settings.transcription || { enabled: false, operator_id: null, language: 'auto' };
|
|
|
|
return success(res, transcription);
|
|
} catch (err) {
|
|
apiLogger.error({ err, context: 'GET transcription settings' }, 'Error fetching settings');
|
|
return error(res, 'FETCH_TRANSCRIPTION_SETTINGS_ERROR', 'Failed to fetch transcription settings', 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PATCH /spaces/:spaceId/transcription
|
|
*/
|
|
router.patch('/spaces/:spaceId/transcription', authenticate, async (req, res) => {
|
|
try {
|
|
const { spaceId } = req.params;
|
|
const { enabled, operator_id, language } = req.body;
|
|
|
|
const space = await dbGet(`SELECT settings FROM spaces WHERE id = ?`, [spaceId]);
|
|
if (!space) return notFound(res, 'Space not found');
|
|
|
|
const settings = safeParseJSON(space.settings, {});
|
|
settings.transcription = {
|
|
enabled: enabled !== undefined ? enabled : settings.transcription?.enabled || false,
|
|
operator_id: operator_id !== undefined ? operator_id : settings.transcription?.operator_id || null,
|
|
language: language !== undefined ? language : settings.transcription?.language || 'auto'
|
|
};
|
|
|
|
await dbRun(`UPDATE spaces SET settings = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
[JSON.stringify(settings), spaceId]);
|
|
|
|
return success(res, { data: settings.transcription });
|
|
} catch (err) {
|
|
apiLogger.error({ err, context: 'PATCH transcription settings' }, 'Error updating settings');
|
|
return error(res, 'UPDATE_TRANSCRIPTION_SETTINGS_ERROR', 'Failed to update transcription settings', 500);
|
|
}
|
|
});
|
|
|
|
export default router;
|