UNPKG

signalk-parquet

Version:

SignalK plugin to save marine data directly to Parquet files with regimen-based control

1,258 lines 61.2 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"); // import { initializeStreamingService, shutdownStreamingService } from './index'; // 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; // Helper function to migrate deprecated Claude model names function migrateClaudeModel(model, app) { const currentModel = model || 'claude-sonnet-4-20250514'; // Migration mapping for deprecated models const migrations = { 'claude-3-sonnet-20240229': 'claude-sonnet-4-20250514', 'claude-3-5-sonnet-20241022': 'claude-sonnet-4-20250514', 'claude-3-7-sonnet-20250219': 'claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022': 'claude-sonnet-4-20250514', 'claude-3-haiku-20240307': 'claude-sonnet-4-20250514', }; if (migrations[currentModel]) { const newModel = migrations[currentModel]; app?.debug(`Auto-migrated deprecated Claude model ${currentModel} to ${newModel}`); return newModel; } // Validate that the model is in our supported list const supportedModels = [ 'claude-opus-4-1-20250805', 'claude-opus-4-20250514', 'claude-sonnet-4-20250514' ]; // If model is not in supported list, fall back to default if (!supportedModels.includes(currentModel)) { app?.debug(`Unknown Claude model ${currentModel}, falling back to default`); return 'claude-sonnet-4-20250514'; } return currentModel; } 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 '${sampleFile.path}' LIMIT ${limit}`; const instance = await node_api_1.DuckDBInstance.create(); const connection = await instance.connect(); 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(); 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', }); } }); // Register a new command router.post('/api/commands', (req, res) => { try { const { command, description, keywords } = 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); 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 } = req.body; const result = (0, commands_1.updateCommand)(command, description, keywords); 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', }); } }); // 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, severity: a.severity, description: a.description, confidence: a.confidence })), confidence: h.confidence, dataQuality: h.dataQuality, timestamp: h.timestamp, metadata: h.metadata })) }); } catch (error) { app.error(`Analysis history retrieval failed: ${error.message}`); return res.status(500).json({ success: false, error: 'Failed to retrieve analysis history' }); } }); // Delete analysis from history router.delete('/api/analyze/history/:id', 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 analysisId = req.params.id; const analyzer = getSharedAnalyzer(config, app, getDataDir, state); const result = await analyzer.deleteAnalysis(analysisId); if (result.success) { return res.json({ success: true, message: 'Analysis deleted successfully' }); } else { return res.status(404).json({ success: false, error: result.error }); } } catch (error) { app.error(`Analysis deletion failed: ${error.message}`); return res.status(500).json({ success: false, error: 'Failed to delete analysis' }); } }); // =========================================== // VESSEL CONTEXT API ROUTES // =========================================== // Get vessel context router.get('/api/vessel-context', async (_, res) => { try { const contextManager = new vessel_context_1.VesselContextManager(app, getDataDir()); const context = await contextManager.getVesselContext(); return res.json({ success: true, data: context }); } catch (error) { app.error(`Failed to get vessel context: ${error.message}`); return res.status(500).json({ success: false, error: 'Failed to get vessel context' }); } }); // Update vessel context router.post('/api/vessel-context', async (req, res) => { try { const { vesselInfo, customContext } = req.body; const contextManager = new vessel_context_1.VesselContextManager(app, getDataDir()); const updatedContext = await contextManager.updateVesselContext(vesselInfo, customContext, false // Not auto-extracted since it's from user input ); return res.json({ success: true, data: updatedContext, message: 'Vessel context updated successfully' }); } catch (error) { app.error(`Failed to update vessel context: ${error.message}`); return res.status(500).json({ success: false, error: 'Failed to update vessel context' }); } }); // Refresh vessel context from SignalK router.post('/api/vessel-context/refresh', async (_, res) => { try { const contextManager = new vessel_context_1.VesselContextManager(app, getDataDir()); const refreshedContext = await contextManager.refreshVesselInfo(); return res.json({ success: true, data: refreshedContext, message: 'Vessel context refreshed from SignalK data' }); } catch (error) { app.error(`Failed to refresh vessel context: ${error.message}`); return res.status(500).json({ success: false, error: 'Failed to refresh vessel context from SignalK' }); } }); // Get vessel data paths for UI router.get('/api/vessel-context/data-paths', (_, res) => { try { const dataPaths = vessel_context_1.VesselContextManager.getVesselDataPaths(); return res.json({ success: true, data: dataPaths }); } catch (error) { app.error(`Failed to get vessel data paths: ${error.message}`); return res.status(500).json({ success: false, error: 'Failed to get vessel data paths' }); } }); // Generate Claude context preview router.get('/api/vessel-context/claude-preview', async (_, res) => { try { const contextManager = new vessel_context_1.VesselContextManager(app, getDataDir()); // Ensure context is loaded before generating preview await contextManager.getVesselContext(); const claudeContext = contextManager.generateClaudeContext(); return res.json({ success: true, data: { contextText: claudeContext, length: claudeContext.length } }); } catch (error) { app.error(`Failed to generate Claude context preview: ${error.message}`); return res.status(500).json({ success: false, error: 'Failed to generate Claude context preview' }); } }); // =========================================== // END VESSEL CONTEXT API ROUTES // =========================================== // =========================================== // END CLAUDE AI ANALYSIS API ROUTES // =========================================== // Test endpoint router.get('/api/test', (_, res) => { res.json({ message: 'SignalK Parquet Plugin API is working', timestamp: new Date().toISOString(), config: state.currentConfig ? 'loaded' : 'not loaded', }); }); // Version endpoint router.get('/api/version', (_, res) => { const packagePath = path.join(__dirname, '..', 'package.json'); try { const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); res.json({ name: packageJson.name, version: packageJson.version, description: packageJson.description, }); } catch (error) { res.status(500).json({ error: 'Failed to read version information' }); } }); // Historical streaming test endpoints - DISABLED // router.post('/api/historical/trigger/:path', (req: express.Request, res: express.Response) => { // try { // const path = req.params.path; // if (state.historicalStreamingService) { // state.historicalStreamingService.triggerHistoricalStream(path); // res.json({ // success: true, // message: `Triggered historical stream for path: ${path}`, // timestamp: new Date().toISOString() // }); // } else { // res.status(500).json({ // success: false, // error: 'Historical streaming service not initialized' // }); // } // } catch (error) { // res.status(500).json({ // success: false, // error: error instanceof Error ? error.message : String(error) // }); // } // }); // router.get('/api/historical/subscriptions', (_: express.Request, res: express.Response) => { // try { // if (state.historicalStreamingService) { // const subscriptions = state.historicalStreamingService.getActiveSubscriptions(); // res.json({ // success: true, // subscriptions, // count: subscriptions.length // }); // } else { // res.status(500).json({ // success: false, // error: 'Historical streaming service