UNPKG

signalk-parquet

Version:

SignalK plugin and webapp that archives SK data to Parquet files with a regimen control system, advanced querying, Claude integrated AI analysis, spatial capabilities, and REST API.

1,276 lines 112 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.registerApiRoutes = registerApiRoutes; const fs = __importStar(require("fs-extra")); const path = __importStar(require("path")); const express_1 = __importDefault(require("express")); const path_discovery_1 = require("./utils/path-discovery"); const node_api_1 = require("@duckdb/node-api"); const commands_1 = require("./commands"); const data_handler_1 = require("./data-handler"); const path_helpers_1 = require("./utils/path-helpers"); const claude_analyzer_1 = require("./claude-analyzer"); const analysis_templates_1 = require("./analysis-templates"); const vessel_context_1 = require("./vessel-context"); const validationJobs = new Map(); let lastValidationViolations = []; const repairJobs = new Map(); const VALIDATION_JOB_TTL_MS = 10 * 60 * 1000; // Retain job metadata for 10 minutes function scheduleValidationJobCleanup(jobId) { setTimeout(() => { const job = validationJobs.get(jobId); if (job && job.status !== 'running') { validationJobs.delete(jobId); } }, VALIDATION_JOB_TTL_MS); } const REPAIR_JOB_TTL_MS = 10 * 60 * 1000; function scheduleRepairJobCleanup(jobId) { setTimeout(() => { const job = repairJobs.get(jobId); if (job && job.status !== 'running') { repairJobs.delete(jobId); } }, REPAIR_JOB_TTL_MS); } // Shared analyzer instance to maintain conversation state across requests let sharedAnalyzer = null; /** * Get or create the shared Claude analyzer instance */ function getSharedAnalyzer(config, app, getDataDir, state) { if (!sharedAnalyzer) { sharedAnalyzer = new claude_analyzer_1.ClaudeAnalyzer({ apiKey: config.claudeIntegration.apiKey, model: migrateClaudeModel(config.claudeIntegration.model, app), maxTokens: config.claudeIntegration.maxTokens || 4000, temperature: config.claudeIntegration.temperature || 0.3 }, app, getDataDir(), state); app.debug('🔧 Created shared Claude analyzer instance'); } return sharedAnalyzer; } // AWS S3 for testing connection // eslint-disable-next-line @typescript-eslint/no-explicit-any let ListObjectsV2Command; const claude_models_1 = require("./claude-models"); // Helper function to migrate deprecated Claude model names function migrateClaudeModel(model, app) { const validatedModel = (0, claude_models_1.getValidClaudeModel)(model); if (model && validatedModel !== model) { app?.debug(`Auto-migrated Claude model ${model} to ${validatedModel}`); } return validatedModel; } // =========================================== // PROCESS MANAGEMENT UTILITIES // =========================================== function startProcess(state, type, totalFiles) { // Check if another process is already running if (state.currentProcess?.isRunning) { return false; // Cannot start, another process is active } // Initialize new process state.currentProcess = { type, isRunning: true, startTime: new Date(), totalFiles, processedFiles: 0, cancelRequested: false, abortController: new AbortController() }; return true; } function updateProcessProgress(state, processedFiles, currentFile) { if (state.currentProcess?.isRunning) { state.currentProcess.processedFiles = processedFiles; state.currentProcess.currentFile = currentFile; } } function finishProcess(state) { if (state.currentProcess) { state.currentProcess.isRunning = false; // Keep the process data for a short time for status queries setTimeout(() => { if (state.currentProcess && !state.currentProcess.isRunning) { state.currentProcess = undefined; } }, 30000); // Clear after 30 seconds } } function cancelProcess(state) { if (state.currentProcess?.isRunning) { state.currentProcess.cancelRequested = true; state.currentProcess.abortController?.abort(); return true; } return false; } function getProcessStatus(state) { if (!state.currentProcess) { return { success: true, isRunning: false }; } const process = state.currentProcess; const progress = process.totalFiles && process.processedFiles !== undefined ? Math.round((process.processedFiles / process.totalFiles) * 100) : undefined; return { success: true, isRunning: process.isRunning, processType: process.type, startTime: process.startTime.toISOString(), totalFiles: process.totalFiles, processedFiles: process.processedFiles, currentFile: process.currentFile, progress }; } function registerApiRoutes(router, state, app) { // Serve static files from public directory const publicPath = path.join(__dirname, '../public'); if (fs.existsSync(publicPath)) { router.use(express_1.default.static(publicPath)); } // Get the current configuration for data directory const getDataDir = () => { // Use the user-configured output directory, fallback to SignalK default return state.currentConfig?.outputDirectory || app.getDataDirPath(); }; // Convert BigInt values to regular numbers for JSON serialization // eslint-disable-next-line @typescript-eslint/no-explicit-any function mapForJSON(rawData) { return rawData.map(row => { const convertedRow = {}; for (const [key, value] of Object.entries(row)) { convertedRow[key] = typeof value === 'bigint' ? Number(value) : value; } return convertedRow; }); } // Get available SignalK paths router.get('/api/paths', (_, res) => { try { const dataDir = getDataDir(); const paths = (0, path_discovery_1.getAvailablePaths)(dataDir, app); return res.json({ success: true, dataDirectory: dataDir, paths: paths, }); } catch (error) { return res.status(500).json({ success: false, error: error.message, }); } }); // Get files for a specific path router.get('/api/files/:path(*)', (req, res) => { try { const dataDir = getDataDir(); const signalkPath = req.params.path; const selfContextPath = app.selfContext .replace(/\./g, '/') .replace(/:/g, '_'); const pathDir = path.join(dataDir, selfContextPath, signalkPath.replace(/\./g, '/')); if (!fs.existsSync(pathDir)) { return res.status(404).json({ success: false, error: `Path not found: ${signalkPath}`, }); } const files = fs .readdirSync(pathDir) .filter((file) => file.endsWith('.parquet')) .map((file) => { const filePath = path.join(pathDir, file); const stat = fs.statSync(filePath); return { name: file, path: filePath, size: stat.size, modified: stat.mtime.toISOString(), }; }) .sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime()); return res.json({ success: true, path: signalkPath, directory: pathDir, files: files, }); } catch (error) { return res.status(500).json({ success: false, error: error.message, }); } }); // Get sample data from a specific file router.get('/api/sample/:path(*)', async (req, res) => { try { const dataDir = getDataDir(); const signalkPath = req.params.path; const limit = parseInt(req.query.limit) || 10; const selfContextPath = app.selfContext .replace(/\./g, '/') .replace(/:/g, '_'); const pathDir = path.join(dataDir, selfContextPath, signalkPath.replace(/\./g, '/')); if (!fs.existsSync(pathDir)) { return res.status(404).json({ success: false, error: `Path not found: ${signalkPath}`, }); } // Get the most recent parquet file const files = fs .readdirSync(pathDir) .filter((file) => file.endsWith('.parquet')) .map((file) => { const filePath = path.join(pathDir, file); const stat = fs.statSync(filePath); return { name: file, path: filePath, modified: stat.mtime }; }) .sort((a, b) => b.modified.getTime() - a.modified.getTime()); if (files.length === 0) { return res.status(404).json({ success: false, error: `No parquet files found for path: ${signalkPath}`, }); } const sampleFile = files[0]; const query = `SELECT * FROM read_parquet('${sampleFile.path}', union_by_name=true) LIMIT ${limit}`; const instance = await node_api_1.DuckDBInstance.create(); const connection = await instance.connect(); // Load spatial extension for geographic queries await connection.runAndReadAll("INSTALL spatial;"); await connection.runAndReadAll("LOAD spatial;"); try { const reader = await connection.runAndReadAll(query); const rawData = reader.getRowObjects(); const data = mapForJSON(rawData); // Get column info const columns = data.length > 0 ? Object.keys(data[0]) : []; return res.json({ success: true, path: signalkPath, file: sampleFile.name, columns: columns, rowCount: data.length, data: data, }); } catch (err) { return res.status(400).json({ success: false, error: err.message, }); } finally { connection.disconnectSync(); } } catch (error) { return res.status(500).json({ success: false, error: error.message, }); } }); // Query parquet data router.post('/api/query', async (req, res) => { try { const { query } = req.body; if (!query) { return res.status(400).json({ success: false, error: 'Query is required', }); } const dataDir = getDataDir(); // Replace placeholder paths in query with actual file paths let processedQuery = query; // Find all quoted paths in the query that might be SignalK paths const pathMatches = query.match(/'([^']+)'/g); if (pathMatches) { pathMatches.forEach(match => { const quotedPath = match.slice(1, -1); // Remove quotes // If it looks like a SignalK path, convert to file path const selfContextPath = (0, path_helpers_1.toContextFilePath)(app.selfContext); if (quotedPath.includes(`/${selfContextPath}/`) || quotedPath.includes('.parquet')) { // It's already a file path, use as is return; } else if (quotedPath.includes('.') && !quotedPath.includes('/')) { // It's a SignalK path, convert to file path const filePath = (0, path_helpers_1.toParquetFilePath)(dataDir, selfContextPath, quotedPath); processedQuery = processedQuery.replace(match, `'${filePath}'`); } }); } const instance = await node_api_1.DuckDBInstance.create(); const connection = await instance.connect(); // Load spatial extension for geographic queries await connection.runAndReadAll("INSTALL spatial;"); await connection.runAndReadAll("LOAD spatial;"); try { const reader = await connection.runAndReadAll(processedQuery); const rawData = reader.getRowObjects(); const data = mapForJSON(rawData); return res.json({ success: true, query: processedQuery, rowCount: data.length, data: data, }); } catch (err) { app.error(`Query error: ${err}`); return res.status(400).json({ success: false, error: err.message, }); } finally { connection.disconnectSync(); } } catch (error) { return res.status(500).json({ success: false, error: error.message, }); } }); // Test S3 connection router.post('/api/test-s3', async (_, res) => { try { if (!state.currentConfig) { return res.status(500).json({ success: false, error: 'Plugin not started or configuration not available', }); } if (!state.currentConfig.s3Upload.enabled) { return res.status(400).json({ success: false, error: 'S3 upload is not enabled in configuration', }); } if (!ListObjectsV2Command || !state.s3Client) { // Try to import S3 SDK dynamically try { const awsS3 = await Promise.resolve().then(() => __importStar(require('@aws-sdk/client-s3'))); ListObjectsV2Command = awsS3.ListObjectsV2Command; } catch (importError) { return res.status(503).json({ success: false, error: 'S3 client not available or not initialized', }); } } // Test S3 connection by listing bucket const listCommand = new ListObjectsV2Command({ Bucket: state.currentConfig.s3Upload.bucket, MaxKeys: 1, }); await state.s3Client.send(listCommand); return res.json({ success: true, message: 'S3 connection successful', bucket: state.currentConfig.s3Upload.bucket, region: state.currentConfig.s3Upload.region || 'us-east-1', keyPrefix: state.currentConfig.s3Upload.keyPrefix || 'none', }); } catch (error) { return res.status(500).json({ success: false, error: error.message || 'S3 connection failed', }); } }); // Web App Path Configuration API Routes (manages separate config file) // Get current path configurations router.get('/api/config/paths', (_, res) => { try { const webAppConfig = (0, commands_1.loadWebAppConfig)(app); return res.json({ success: true, paths: webAppConfig.paths, }); } catch (error) { return res.status(500).json({ success: false, error: error.message, }); } }); // Add new path configuration router.post('/api/config/paths', (req, res) => { try { const newPath = req.body; // Validate required fields if (!newPath.path) { res.status(400).json({ success: false, error: 'Path is required', }); return; } // Load current configuration const webAppConfig = (0, commands_1.loadWebAppConfig)(app); const currentPaths = webAppConfig.paths; const currentCommands = webAppConfig.commands; // Add to current paths currentPaths.push(newPath); // Save to web app configuration (0, commands_1.saveWebAppConfig)(currentPaths, currentCommands, app); // Update subscriptions if (state.currentConfig) { (0, data_handler_1.updateDataSubscriptions)(currentPaths, state, state.currentConfig, app); } res.json({ success: true, message: 'Path configuration added successfully', path: newPath, }); } catch (error) { res.status(500).json({ success: false, error: error.message, }); } }); // Update existing path configuration router.put('/api/config/paths/:index', (req, res) => { try { const index = parseInt(req.params.index); const updatedPath = req.body; // Load current configuration const webAppConfig = (0, commands_1.loadWebAppConfig)(app); const currentPaths = webAppConfig.paths; const currentCommands = webAppConfig.commands; if (index < 0 || index >= currentPaths.length) { res.status(404).json({ success: false, error: 'Path configuration not found', }); return; } // Validate required fields if (!updatedPath.path) { res.status(400).json({ success: false, error: 'Path is required', }); return; } // Update the path configuration currentPaths[index] = updatedPath; // Save to web app configuration (0, commands_1.saveWebAppConfig)(currentPaths, currentCommands, app); // Update subscriptions if (state.currentConfig) { (0, data_handler_1.updateDataSubscriptions)(currentPaths, state, state.currentConfig, app); } res.json({ success: true, message: 'Path configuration updated successfully', path: updatedPath, }); } catch (error) { res.status(500).json({ success: false, error: error.message, }); } }); // Remove path configuration router.delete('/api/config/paths/:index', (req, res) => { try { const index = parseInt(req.params.index); // Load current configuration const webAppConfig = (0, commands_1.loadWebAppConfig)(app); const currentPaths = webAppConfig.paths; const currentCommands = webAppConfig.commands; if (index < 0 || index >= currentPaths.length) { res.status(404).json({ success: false, error: 'Path configuration not found', }); return; } // Get the path being removed for response const removedPath = currentPaths[index]; // Remove from current paths currentPaths.splice(index, 1); // Save to web app configuration (0, commands_1.saveWebAppConfig)(currentPaths, currentCommands, app); // Update subscriptions if (state.currentConfig) { (0, data_handler_1.updateDataSubscriptions)(currentPaths, state, state.currentConfig, app); } res.json({ success: true, message: 'Path configuration removed successfully', removedPath: removedPath, }); } catch (error) { res.status(500).json({ success: false, error: error.message, }); } }); // Command Management API endpoints // Get all registered commands router.get('/api/commands', (_, res) => { try { const commands = (0, commands_1.getCurrentCommands)(); return res.json({ success: true, commands: commands, count: commands.length, }); } catch (error) { app.error(`Error retrieving commands: ${error}`); return res.status(500).json({ success: false, error: 'Failed to retrieve commands', }); } }); // =========================================== // HOME PORT CONFIGURATION ENDPOINTS // =========================================== // Get home port configuration router.get('/api/config/homeport', (_req, res) => { try { if (!state.currentConfig) { return res.status(500).json({ success: false, error: 'Plugin configuration not available', }); } return res.json({ success: true, latitude: state.currentConfig.homePortLatitude || null, longitude: state.currentConfig.homePortLongitude || null, }); } catch (error) { return res.status(500).json({ success: false, error: error.message, }); } }); // Update home port configuration router.put('/api/config/homeport', (req, res) => { try { const { latitude, longitude } = req.body; if (!state.currentConfig) { res.status(500).json({ success: false, error: 'Plugin configuration not available', }); return; } // Validate latitude and longitude if (typeof latitude !== 'number' || typeof longitude !== 'number') { res.status(400).json({ success: false, error: 'Latitude and longitude must be numbers', }); return; } if (latitude < -90 || latitude > 90) { res.status(400).json({ success: false, error: 'Latitude must be between -90 and 90', }); return; } if (longitude < -180 || longitude > 180) { res.status(400).json({ success: false, error: 'Longitude must be between -180 and 180', }); return; } // Update the config state.currentConfig.homePortLatitude = latitude; state.currentConfig.homePortLongitude = longitude; // Save to plugin options app.savePluginOptions(state.currentConfig, (err) => { if (err) { app.error(`Failed to save home port: ${err}`); res.status(500).json({ success: false, error: 'Failed to save home port configuration', }); return; } app.debug(`✅ Home port updated: ${latitude}, ${longitude}`); res.json({ success: true, latitude, longitude, }); }); } catch (error) { res.status(500).json({ success: false, error: error.message, }); } }); // Get current vessel position router.get('/api/position/current', (_req, res) => { try { const position = app.getSelfPath('navigation.position'); if (position && position.value) { return res.json({ success: true, latitude: position.value.latitude, longitude: position.value.longitude, }); } return res.status(404).json({ success: false, error: 'Current position not available', }); } catch (error) { return res.status(500).json({ success: false, error: error.message, }); } }); // =========================================== // PATH TYPE DETECTION ENDPOINT // =========================================== // Get data type information for a SignalK path router.get('/api/paths/:path/type', async (req, res) => { try { const pathParam = req.params.path; const { detectPathType } = await Promise.resolve().then(() => __importStar(require('./utils/type-detector'))); const typeInfo = await detectPathType(pathParam, app); return res.json({ success: true, ...typeInfo, }); } catch (error) { return res.status(500).json({ success: false, error: error.message, }); } }); // =========================================== // COMMAND MANAGEMENT ENDPOINTS // =========================================== // Register a new command router.post('/api/commands', (req, res) => { try { const { command, description, keywords, defaultState, thresholds } = req.body; if (!command || !/^[a-zA-Z0-9_]+$/.test(command) || command.length === 0 || command.length > 50) { return res.status(400).json({ success: false, error: 'Invalid command name. Must be alphanumeric with underscores, 1-50 characters.', }); } const result = (0, commands_1.registerCommand)(command, description, keywords, defaultState, thresholds); if (result.state === 'COMPLETED') { // Update webapp config const webAppConfig = (0, commands_1.loadWebAppConfig)(app); const currentCommands = (0, commands_1.getCurrentCommands)(); (0, commands_1.saveWebAppConfig)(webAppConfig.paths, currentCommands, app); const commandState = (0, commands_1.getCommandState)(); const commandConfig = commandState.registeredCommands.get(command); return res.json({ success: true, message: `Command '${command}' registered successfully`, command: commandConfig, }); } else { return res.status(400).json({ success: false, error: result.message || 'Failed to register command', }); } } catch (error) { app.error(`Error registering command: ${error}`); return res.status(500).json({ success: false, error: 'Internal server error', }); } }); // Execute a command router.put('/api/commands/:command/execute', (req, res) => { try { const { command } = req.params; const { value } = req.body; if (typeof value !== 'boolean') { return res.status(400).json({ success: false, error: 'Command value must be a boolean', }); } const result = (0, commands_1.executeCommand)(command, value); if (result.state === 'COMPLETED') { return res.json({ success: true, command: command, value: value, executed: true, timestamp: result.timestamp, }); } else { return res.status(400).json({ success: false, error: result.message || 'Failed to execute command', }); } } catch (error) { app.error(`Error executing command: ${error}`); return res.status(500).json({ success: false, error: 'Internal server error', }); } }); // Unregister a command router.delete('/api/commands/:command', (req, res) => { try { const { command } = req.params; const result = (0, commands_1.unregisterCommand)(command); if (result.state === 'COMPLETED') { // Update webapp config const webAppConfig = (0, commands_1.loadWebAppConfig)(app); const currentCommands = (0, commands_1.getCurrentCommands)(); (0, commands_1.saveWebAppConfig)(webAppConfig.paths, currentCommands, app); return res.json({ success: true, message: `Command '${command}' unregistered successfully`, }); } else { return res.status(404).json({ success: false, error: result.message || 'Command not found', }); } } catch (error) { app.error(`Error unregistering command: ${error}`); return res.status(500).json({ success: false, error: 'Internal server error', }); } }); // Update command (PUT) router.put('/api/commands/:command', (req, res) => { try { const { command } = req.params; const { description, keywords, defaultState, thresholds } = req.body; const result = (0, commands_1.updateCommand)(command, description, keywords, defaultState, thresholds); if (result.state === 'COMPLETED') { // Update webapp config const webAppConfig = (0, commands_1.loadWebAppConfig)(app); const currentCommands = (0, commands_1.getCurrentCommands)(); (0, commands_1.saveWebAppConfig)(webAppConfig.paths, currentCommands, app); return res.json({ success: true, message: result.message, }); } else { return res.status(result.statusCode || 400).json({ success: false, error: result.message, }); } } catch (error) { app.error(`Error updating command: ${error}`); return res.status(500).json({ success: false, error: 'Internal server error', }); } }); // Manual override endpoint router.put('/api/commands/:command/override', (req, res) => { try { const { command } = req.params; const { override, expiryMinutes } = req.body; const result = (0, commands_1.setManualOverride)(command, override, expiryMinutes); if (result.success) { // Update webapp config const webAppConfig = (0, commands_1.loadWebAppConfig)(app); const currentCommands = (0, commands_1.getCurrentCommands)(); (0, commands_1.saveWebAppConfig)(webAppConfig.paths, currentCommands, app); return res.json({ success: true, message: result.message, }); } else { return res.status(400).json({ success: false, error: result.message, }); } } catch (error) { app.error(`Error setting manual override: ${error}`); return res.status(500).json({ success: false, error: 'Internal server error', }); } }); // Get command history router.get('/api/commands/history', (_, res) => { try { // Return the last 50 history entries const commandHistory = (0, commands_1.getCommandHistory)(); const recentHistory = commandHistory.slice(-50); return res.json({ success: true, data: recentHistory, }); } catch (error) { app.error(`Error retrieving command history: ${error}`); return res.status(500).json({ success: false, error: 'Failed to retrieve command history', }); } }); // Get command status router.get('/api/commands/:command/status', (req, res) => { try { const { command } = req.params; const commandState = (0, commands_1.getCommandState)(); const commandConfig = commandState.registeredCommands.get(command); if (!commandConfig) { return res.status(404).json({ success: false, error: 'Command not found', }); } // Get current value from SignalK const currentValue = app.getSelfPath(`commands.${command}`); return res.json({ success: true, command: { ...commandConfig, active: currentValue === true, }, }); } catch (error) { app.error(`Error retrieving command status: ${error}`); return res.status(500).json({ success: false, error: 'Failed to retrieve command status', }); } }); // Streaming Control API endpoints - DISABLED // // Enable streaming at runtime // router.post('/api/streaming/enable', async (req: TypedRequest, res: TypedResponse) => { // try { // if (state.streamingService) { // return res.json({ // success: true, // message: 'Streaming service is already running', // enabled: true // }); // } // // Check if streaming is enabled in config // if (!state.currentConfig?.enableStreaming) { // return res.status(400).json({ // success: false, // error: 'Streaming is disabled in plugin configuration. Enable it in plugin settings first.', // enabled: false // }); // } // const result = await initializeStreamingService(state, app); // if (result.success) { // return res.json({ // success: true, // message: 'Streaming service enabled successfully', // enabled: true // }); // } else { // return res.status(500).json({ // success: false, // error: result.error || 'Failed to enable streaming service', // enabled: false // }); // } // } catch (error) { // app.error(`Error enabling streaming: ${error}`); // return res.status(500).json({ // success: false, // error: (error as Error).message, // enabled: false // }); // } // }); // // Disable streaming at runtime // router.post('/api/streaming/disable', (req: TypedRequest, res: TypedResponse) => { // try { // if (!state.streamingService) { // return res.json({ // success: true, // message: 'Streaming service is not running', // enabled: false // }); // } // const result = shutdownStreamingService(state, app); // if (result.success) { // return res.json({ // success: true, // message: 'Streaming service disabled successfully', // enabled: false // }); // } else { // return res.status(500).json({ // success: false, // error: result.error || 'Failed to disable streaming service', // enabled: true // }); // } // } catch (error) { // app.error(`Error disabling streaming: ${error}`); // return res.status(500).json({ // success: false, // error: (error as Error).message, // enabled: true // }); // } // }); // // Get current streaming status // router.get('/api/streaming/status', (req: TypedRequest, res: TypedResponse) => { // try { // const isEnabled = !!state.streamingService; // const configEnabled = state.currentConfig?.enableStreaming ?? false; // // Get streaming service statistics if available // let stats = {}; // if (state.streamingService && state.streamingService.getActiveSubscriptions) { // const subscriptions = state.streamingService.getActiveSubscriptions(); // stats = { // activeSubscriptions: subscriptions.length, // subscriptions: subscriptions // }; // } // res.json({ // success: true, // enabled: isEnabled, // configEnabled: configEnabled, // canEnable: configEnabled && !isEnabled, // canDisable: isEnabled, // ...stats // }); // } catch (error) { // app.error(`Error getting streaming status: ${error}`); // res.status(500).json({ // success: false, // error: (error as Error).message // }); // } // }); // Health check router.get('/api/health', (_, res) => { return res.json({ success: true, status: 'healthy', timestamp: new Date().toISOString(), }); }); // =========================================== // CLAUDE AI ANALYSIS API ROUTES // =========================================== // Get available analysis templates router.get('/api/analyze/templates', (_, res) => { try { const templates = analysis_templates_1.TEMPLATE_CATEGORIES.map(category => ({ ...category, templates: category.templates.map(template => ({ id: template.id, name: template.name, description: template.description, category: template.category, icon: template.icon, complexity: template.complexity, estimatedTime: template.estimatedTime, requiredPaths: template.requiredPaths })) })); res.json({ success: true, templates: templates }); } catch (error) { app.error(`Template retrieval failed: ${error.message}`); res.status(500).json({ success: false, error: 'Failed to retrieve analysis templates' }); } }); // Test Claude connection router.post('/api/analyze/test-connection', async (_req, res) => { try { const config = state.currentConfig; if (!config?.claudeIntegration?.enabled || !config.claudeIntegration.apiKey) { return res.status(400).json({ success: false, error: 'Claude integration is not configured or enabled' }); } const analyzer = getSharedAnalyzer(config, app, getDataDir, state); const startTime = Date.now(); const testResult = await analyzer.testConnection(); const responseTime = Date.now() - startTime; if (testResult.success) { return res.json({ success: true, model: migrateClaudeModel(config.claudeIntegration.model, app), responseTime, tokenUsage: 50 // Approximate for test }); } else { return res.status(400).json({ success: false, error: testResult.error || 'Connection test failed' }); } } catch (error) { app.error(`Claude connection test failed: ${error.message}`); return res.status(500).json({ success: false, error: 'Claude connection test failed' }); } }); // Main analysis endpoint router.post('/api/analyze', async (req, res) => { try { const config = state.currentConfig; if (!config?.claudeIntegration?.enabled || !config.claudeIntegration.apiKey) { return res.status(400).json({ success: false, error: 'Claude integration is not configured or enabled' }); } const { dataPath, analysisType, templateId, customPrompt, timeRange, aggregationMethod, resolution, claudeModel, useDatabaseAccess } = req.body; console.log(`🧠 ANALYSIS REQUEST: dataPath=${dataPath}, templateId=${templateId}, analysisType=${analysisType}, aggregationMethod=${aggregationMethod}, model=${claudeModel || 'config-default'}`); console.log(`🔍 CUSTOM PROMPT DEBUG: "${customPrompt}" (type: ${typeof customPrompt}, length: ${customPrompt?.length || 0})`); if (!dataPath) { return res.status(400).json({ success: false, error: 'Data path is required' }); } // Use shared analyzer instance to maintain conversation state const analyzer = getSharedAnalyzer(config, app, getDataDir, state); // Build analysis request let analysisRequest; if (templateId) { // Use template const parsedTimeRange = timeRange ? { start: new Date(timeRange.start), end: new Date(timeRange.end) } : undefined; const templateRequest = analysis_templates_1.AnalysisTemplateManager.createAnalysisRequest(templateId, dataPath, customPrompt, parsedTimeRange); if (!templateRequest) { return res.status(400).json({ success: false, error: `Template not found: ${templateId}` }); } analysisRequest = templateRequest; } else { // Custom analysis analysisRequest = { dataPath, analysisType: analysisType || 'custom', customPrompt: customPrompt || 'Analyze this maritime data and provide insights', timeRange: timeRange ? { start: new Date(timeRange.start), end: new Date(timeRange.end) } : undefined, aggregationMethod, resolution, useDatabaseAccess: useDatabaseAccess || false }; } // Execute analysis app.debug(`Starting Claude analysis: ${analysisRequest.analysisType} for ${dataPath}`); const result = await analyzer.analyzeData(analysisRequest); return res.json({ success: true, data: { id: result.id, analysis: result.analysis, insights: result.insights, recommendations: result.recommendations, anomalies: result.anomalies?.map(a => ({ timestamp: a.timestamp, value: a.value, expectedRange: a.expectedRange, severity: a.severity, description: a.description, confidence: a.confidence })), confidence: result.confidence, dataQuality: result.dataQuality, timestamp: result.timestamp, metadata: result.metadata }, usage: result.usage }); } catch (error) { app.error(`Analysis failed: ${error.message}`); return res.status(500).json({ success: false, error: `Analysis failed: ${error.message}` }); } }); // Follow-up question endpoint router.post('/api/analyze/followup', async (req, res) => { try { const config = state.currentConfig; if (!config?.claudeIntegration?.enabled || !config.claudeIntegration.apiKey) { return res.status(400).json({ success: false, error: 'Claude integration is not configured or enabled' }); } const { conversationId, question } = req.body; if (!conversationId || !question) { return res.status(400).json({ success: false, error: 'Both conversationId and question are required' }); } console.log(`🔄 FOLLOW-UP REQUEST: conversationId=${conversationId}, question=${question.substring(0, 100)}...`); // Use shared analyzer instance to access stored conversations const analyzer = getSharedAnalyzer(config, app, getDataDir, state); // Process follow-up question const followUpRequest = { conversationId, question }; const analysisResult = await analyzer.askFollowUp(followUpRequest); return res.json({ success: true, data: analysisResult, usage: analysisResult.usage }); } catch (error) { app.error(`Follow-up question failed: ${error.message}`); return res.status(500).json({ success: false, error: `Follow-up question failed: ${error.message}` }); } }); // Get analysis history router.get('/api/analyze/history', async (req, res) => { try { const config = state.currentConfig; if (!config?.claudeIntegration?.enabled || !config.claudeIntegration.apiKey) { return res.status(400).json({ success: false, error: 'Claude integration is not configured or enabled' }); } const limit = parseInt(req.query.limit || '20', 10); const analyzer = getSharedAnalyzer(config, app, getDataDir, state); const history = await analyzer.getAnalysisHistory(limit); return res.json({ success: true, data: history.map(h => ({ id: h.id, analysis: h.analysis, insights: h.insights, recommendations: h.recommendations, anomalies: h.anomalies?.map(a => ({ timestamp: a.timestamp, value: a.value, expectedRange: a.expectedRange, sev