UNPKG

crew-management-mcp-server

Version:

Crew management server handling crew records, certifications, scheduling, payroll, and vessel assignments with ERP access for data extraction

1,020 lines 69.7 kB
import { logger } from "../utils/logger.js"; import { getDatabase } from "../utils/mongodb.js"; import { getTypesenseClient } from "../utils/typesense.js"; import { getConnectionFromPool, returnConnectionToPool } from "../utils/snowflake.js"; export class ToolHandler { constructor(server) { this.server = server; } async handleCallTool(name, args) { logger.info(`Handling tool call: ${name}`, { args }); try { // Import IMO utilities and response filter const { shouldBypassImoFiltering, validateImoNumber } = await import('../utils/imoUtils.js'); const { filterResponseByCompanyImos } = await import('../utils/responseFilter.js'); const { config } = await import('../utils/config.js'); const companyName = config.companyName || ''; // Define tools that require IMO pre-validation const imoRequiredTools = ["get_seafarer_id", "get_seafarer_details"]; // Layer 1: Pre-validation for IMO-required tools if (imoRequiredTools.includes(name) && !shouldBypassImoFiltering(companyName)) { // Check for IMO parameter in various forms const imoParam = args.imo || args.vessel_imo || args.imoNumber; if (imoParam) { const validation = validateImoNumber(imoParam, companyName); if (!validation.isValid) { logger.warn(`Pre-validation failed for ${name}: ${validation.errorMessage}`); return [{ type: "text", text: validation.errorMessage || "Invalid IMO number for this company" }]; } } } // Layer 2: Execute the tool let response; switch (name) { case "get_vessel_details": response = await this.handleGetVesselDetails(args); break; case "get_seafarer_id": response = await this.handleGetSeafarerId(args); break; case "get_seafarer_details": response = await this.handleGetSeafarerDetails(args); break; case "write_casefile_data": response = await this.handleWriteCasefileData(args); break; case "retrieve_casefile_data": response = await this.handleRetrieveCasefileData(args); break; case "query_crew_database": response = await this.handleQueryCrewDatabase(args); break; default: throw new Error(`Unknown tool: ${name}`); } // Layer 3: Universal post-response filtering const startTime = Date.now(); const filteredResponse = await filterResponseByCompanyImos(response); const filteringTime = Date.now() - startTime; // Monitoring and logging logger.debug(`Tool ${name} completed with response filtering`, { toolName: name, companyName, filteringTimeMs: filteringTime, originalResponseLength: response.length, filteredResponseLength: filteredResponse.length, hasImoFiltering: !shouldBypassImoFiltering(companyName) }); return filteredResponse; } catch (error) { logger.error(`Error handling tool ${name}:`, error); return [{ type: "text", text: `Error: ${error instanceof Error ? error.message : 'Unknown error occurred'}` }]; } } async handleGetVesselDetails(args) { logger.info("Executing get vessel details", { args }); const query = args.query; if (!query) { return [{ type: "text", text: "Error: 'query' parameter is required for vessel details search" }]; } try { logger.info(`Searching for vessel details with vessel name: ${query}`); // Set up search parameters for the fleet-vessel-lookup collection const searchParameters = { q: query, query_by: 'vesselName', per_page: 1, include_fields: 'vesselName,imo,class,flag,shippalmDoc,isV3', prefix: false, num_typos: 2, }; // Execute search with timeout handling const client = getTypesenseClient(); logger.info('Executing Typesense search with parameters:', searchParameters); const raw = await Promise.race([ client.collections('fleet-vessel-lookup').documents().search(searchParameters), new Promise((_, reject) => setTimeout(() => reject(new Error('Search timeout after 8 seconds')), 8000)) ]); const hits = raw.hits || []; if (!hits.length) { logger.info(`No vessels found for query: ${query}`); return [{ type: "text", text: `No vessels found named '${query}'.` }]; } // Process and format results const doc = hits[0].document || {}; const results = { vesselName: doc.vesselName || 'Unknown', imo: doc.imo || 'Unknown', class: doc.class || 'Unknown', flag: doc.flag || 'Unknown', shippalmDoc: doc.shippalmDoc || 'Unknown', isV3: doc.isV3 || false, score: hits[0].text_match || 0 }; logger.info('Vessel search successful:', results); // Return formatted response return [{ type: "text", text: JSON.stringify(results, null, 2) }]; } catch (error) { logger.error(`Error searching for vessel details: ${error}`); // Check if it's a connection/timeout error and provide mock data for development const errorString = error?.toString() || ''; const errorMessage = error instanceof Error ? error.message : ''; if (errorString.includes('ECONNREFUSED') || errorMessage.includes('ECONNREFUSED') || errorMessage.includes('timeout') || errorMessage.includes('ENOTFOUND') || errorString.includes('AggregateError') || error?.constructor?.name === 'AggregateError') { logger.warn(`Typesense connection failed, returning mock data for development. Query: ${query}. Error: ${errorString}`); // Return mock vessel data for development const mockResults = { vesselName: query.toUpperCase(), imo: "1234567", class: "Tanker", flag: "Marshall Islands", shippalmDoc: "active", isV3: true, score: 100, note: "Mock data - Typesense server not available" }; return [{ type: "text", text: JSON.stringify(mockResults, null, 2) }]; } return [{ type: "text", text: `Error querying vessel details: ${error instanceof Error ? error.message : 'Unknown error'}` }]; } } async handleGetSeafarerId(args) { logger.info("Executing get seafarer ID", { args }); let searchQuery = args.query || ""; const queryBy = args.query_by; let filterBy = args.filter_by || ""; // Import IMO utilities const { validateImoNumber, shouldBypassImoFiltering, filterTypesenseHitsByCompanyImos } = await import('../utils/imoUtils.js'); const companyName = (await import('../utils/config.js')).config.companyName || ''; // Pre-validation: Check if specific IMO is requested and validate it if (!shouldBypassImoFiltering(companyName)) { // Extract IMO from filter_by or query parameters - handle various IMO field formats const imoMatch = filterBy.match(/(imo|imo_number|vesselimo|vessel_imo|imonumber)[:\s=]*([0-9]+)/i); const requestedImo = imoMatch ? imoMatch[2] : (args.imo || args.vessel_imo || args.imoNumber); if (requestedImo) { const validation = validateImoNumber(requestedImo, companyName); if (!validation.isValid) { logger.warn(`Pre-validation failed for get_seafarer_id: ${validation.errorMessage}`); return [{ type: "text", text: validation.errorMessage || "Invalid IMO number for this company" }]; } } } // Process filter_by to convert date strings to timestamps let finalFilterBy = ""; if (filterBy !== "") { const filterByArray = filterBy.split(" && "); const filterByList = []; for (const f of filterByArray) { // Crude check for YYYY-MM-DD at end if (f.slice(-10).split('-').length === 3 && f.slice(-10).length === 10) { const prefix = f.slice(0, -10); const dateStr = f.slice(-10); try { const timestamp = Math.floor(new Date(dateStr).getTime() / 1000); filterByList.push(`${prefix}${timestamp}`); } catch (error) { filterByList.push(f); // fallback if date parsing fails } } else { filterByList.push(f); } } finalFilterBy = filterByList.join(" && "); } if (searchQuery === "") { searchQuery = "*"; } try { const client = getTypesenseClient(); const collection = "crew_details"; let query; // Build query based on conditions - include additional fields for IMO filtering const includeFields = "CREW_CODE,ONBOARD_SAILING_STATUS,IMO_NUMBER"; if (filterBy !== "" && (searchQuery !== "*" && searchQuery !== "")) { query = { q: searchQuery, query_by: queryBy, filter_by: finalFilterBy, include_fields: includeFields, per_page: 250 }; } else if (filterBy !== "" && (searchQuery === "*" || searchQuery === "")) { query = { q: "*", filter_by: finalFilterBy, include_fields: includeFields, per_page: 250 }; } else { query = { q: searchQuery, query_by: queryBy, include_fields: includeFields, per_page: 250 }; } const results = await client.collections(collection).documents().search(query); const hits = results.hits || []; const originalHitCount = hits.length; // Apply IMO filtering to remove unauthorized onboard crew let authorizedHits = hits; let imoFilteringStats = null; if (!shouldBypassImoFiltering(companyName)) { const filteringResult = filterTypesenseHitsByCompanyImos(hits); authorizedHits = filteringResult.filteredHits; imoFilteringStats = filteringResult.filteringStats; // Log filtering statistics if (filteringResult.filteringStats.filteredHits > 0) { logger.info(`IMO filtering applied to get_seafarer_id results`, { totalHits: filteringResult.filteringStats.totalHits, authorizedHits: authorizedHits.length, filteredOut: filteringResult.filteringStats.filteredHits, unauthorizedImos: filteringResult.filteringStats.unauthorizedImos.map(i => `${i.field}:${i.value}`), processingTimeMs: filteringResult.filteringStats.processingTimeMs }); } } // Additional filtering for onboard crew on unauthorized vessels const filteredHits = []; let additionalFiltered = 0; const filteredOutImos = new Set(); for (const hit of authorizedHits) { const document = hit.document || {}; // Apply crew-specific filtering logic const onboardStatus = document.ONBOARD_SAILING_STATUS; const imoNumber = document.IMO_NUMBER; if (onboardStatus === 'Onboard' && !shouldBypassImoFiltering(companyName)) { // If crew is onboard but IMO is null, filter out if (!imoNumber) { logger.debug(`Filtering out crew with null IMO: ${document.CREW_CODE}`); additionalFiltered++; continue; } // IMO validation already handled by filterTypesenseHitsByCompanyImos // but adding this as a safety net const { isValidImoForCompany } = await import('../utils/imoUtils.js'); if (!isValidImoForCompany(imoNumber)) { logger.debug(`Filtering out onboard crew on unauthorized vessel: IMO ${imoNumber}`); additionalFiltered++; filteredOutImos.add(imoNumber); continue; } } filteredHits.push(document.CREW_CODE); } if (additionalFiltered > 0) { logger.info(`Additional crew filtering applied: ${additionalFiltered} records filtered`); } // Check if we need to provide helpful error message when no results found if (filteredHits.length === 0 && originalHitCount > 0) { // Original search found crew, but all were filtered out due to IMO restrictions const { getCompanyImoNumbers } = await import('../utils/imoUtils.js'); const availableImos = getCompanyImoNumbers(); const imoList = availableImos.slice(0, 10).join(', '); const moreCount = availableImos.length > 10 ? ` and ${availableImos.length - 10} more` : ''; const unauthorizedImosList = Array.from(filteredOutImos); let errorMessage = ''; if (unauthorizedImosList.length > 0) { const vesselName = searchQuery; errorMessage = `No authorized crew found for vessel "${vesselName}". The vessel's IMO number${unauthorizedImosList.length > 1 ? 's' : ''} (${unauthorizedImosList.join(', ')}) ${unauthorizedImosList.length > 1 ? 'are' : 'is'} not associated with ${companyName}. Available IMO numbers: ${imoList}${moreCount}`; } else { // Crew were filtered out for other reasons (null IMO, etc.) errorMessage = `No authorized crew found. All crew members found were filtered out due to missing vessel information. Available IMO numbers for ${companyName}: ${imoList}${moreCount}`; } return [{ type: "text", text: errorMessage }]; } const formattedResults = { found: filteredHits.length, out_of: results.out_of || 0, page: results.page || 1, hits: filteredHits }; return [{ type: "text", text: JSON.stringify(formattedResults, null, 2) }]; } catch (error) { logger.error(`Error retrieving seafarer id: ${error}`); return [{ type: "text", text: `Error retrieving seafarer id: ${error instanceof Error ? error.message : 'Unknown error'}` }]; } } async handleGetSeafarerDetails(args) { logger.info("Executing get seafarer details", { args }); // Handle both crew_id and seafarer_id parameters for flexibility let crewId = args.crew_id || args.seafarer_id; let requiredFields = args.required_fields || "CREW_CODE, SEAFARER_NAME, CURRENT_RANK_NAME, VESSEL_NAME, IMO_NUMBER, SIGN_ON_DATE, CONTRACT_END_DATE, NATIONALITY_NAME, ONBOARD_SAILING_STATUS"; if (!crewId) { throw new Error("Crew id is required (use either crew_id or seafarer_id parameter)"); } // Convert single string to array for consistent processing if (typeof crewId === 'string') { crewId = [crewId]; } // Validate it's an array if (!Array.isArray(crewId)) { throw new Error("Crew id must be a string or array of strings"); } // Import IMO utilities for validation const { validateImoNumber, shouldBypassImoFiltering } = await import('../utils/imoUtils.js'); const companyName = (await import('../utils/config.js')).config.companyName || ''; // Pre-validation: Check if specific IMO is requested in any parameter and validate it if (!shouldBypassImoFiltering(companyName)) { // Check for IMO in various parameter formats - handle various IMO field formats const directImo = args.imo || args.vessel_imo || args.imoNumber; // Also check if IMO might be embedded in required_fields parameter let embeddedImo = null; if (requiredFields) { const imoMatch = requiredFields.match(/(imo|imo_number|vesselimo|vessel_imo|imonumber)[:\s=]*([0-9]+)/i); if (imoMatch) { embeddedImo = imoMatch[2]; } } const requestedImo = directImo || embeddedImo; if (requestedImo) { const validation = validateImoNumber(requestedImo, companyName); if (!validation.isValid) { logger.warn(`Pre-validation failed for get_seafarer_details: ${validation.errorMessage}`); return [{ type: "text", text: validation.errorMessage || "Invalid IMO number for this company" }]; } } } // Add mandatory fields if not present (including IMO fields for filtering) if (!requiredFields.includes("CREW_CODE")) { requiredFields = `CREW_CODE, ${requiredFields}`; } if (!requiredFields.includes("SIGN_ON_DATE")) { requiredFields = `SIGN_ON_DATE, ${requiredFields}`; } if (!requiredFields.includes("ONBOARD_SAILING_STATUS")) { requiredFields = `ONBOARD_SAILING_STATUS, ${requiredFields}`; } if (!requiredFields.includes("IMO_NUMBER")) { requiredFields = `IMO_NUMBER, ${requiredFields}`; } try { if (Array.isArray(crewId)) { const allResults = []; for (const id of crewId) { try { const client = await getConnectionFromPool(); const query = ` WITH base AS ( SELECT ${requiredFields} FROM revised_base_view WHERE CREW_CODE = '${id.replace(/'/g, "''")}' ) SELECT * FROM base ORDER BY SIGN_ON_DATE DESC LIMIT 1; `; // Log the exact query being executed logger.info(`Executing Snowflake query for crew ${id}:`, { query: query.replace(/\s+/g, ' ').trim(), requiredFields: requiredFields }); const result = await client.execute(query); // Log the raw result from Snowflake logger.info(`Snowflake raw result for crew ${id}:`, { columns: result.columns, rowCount: result.rows.length, firstRow: result.rows[0] || null, rowType: result.rows.length > 0 ? typeof result.rows[0] : 'no data' }); // Rows are already objects from Snowflake SDK, no conversion needed let results = result.rows; await returnConnectionToPool(client); // Apply IMO filtering to results and track filtering reason const { shouldBypassImoFiltering, getCompanyImoNumbers } = await import('../utils/imoUtils.js'); const companyName = (await import('../utils/config.js')).config.companyName || ''; let filteringReason = null; if (!shouldBypassImoFiltering(companyName) && results.length > 0) { const originalCount = results.length; // Apply manual filtering with detailed tracking const filteredResults = []; for (const record of results) { const onboardStatus = record.ONBOARD_SAILING_STATUS; const imoNumber = record.IMO_NUMBER; if (onboardStatus === 'Onboard') { if (!imoNumber) { filteringReason = { type: 'missing_imo', crewCode: id, onboardStatus }; logger.debug(`Filtering out crew ${id} - onboard with null IMO`); continue; } const { isValidImoForCompany } = await import('../utils/imoUtils.js'); if (!isValidImoForCompany(imoNumber)) { filteringReason = { type: 'unauthorized_imo', crewCode: id, onboardStatus }; logger.debug(`Filtering out crew ${id} - onboard on unauthorized vessel IMO ${imoNumber}`); continue; } } // Record passed filtering filteredResults.push(record); } results = filteredResults; if (originalCount !== results.length) { logger.info(`Filtered crew details for ${id}`, { originalCount, filteredCount: results.length, removedCount: originalCount - results.length, reason: filteringReason?.type }); } } const formattedResult = { crew_id: id, results: results }; // Add filtering explanation if crew was filtered out if (filteringReason && results.length === 0) { const availableImos = getCompanyImoNumbers(); const imoList = availableImos.slice(0, 10).join(', '); const moreCount = availableImos.length > 10 ? ` and ${availableImos.length - 10} more` : ''; if (filteringReason.type === 'unauthorized_imo') { formattedResult.filtering_message = `Crew ${filteringReason.crewCode} is onboard a vessel which is not part of ${companyName}. Available IMO numbers: ${imoList}${moreCount}`; } else if (filteringReason.type === 'missing_imo') { formattedResult.filtering_message = `Crew ${filteringReason.crewCode} is onboard a vessel with missing IMO information. Available IMO numbers for ${companyName}: ${imoList}${moreCount}`; } } allResults.push(formattedResult); } catch (error) { logger.error(`Error retrieving seafarer details for ${id}:`, error); // Add error information to results instead of silently skipping const errorResult = { crew_id: id, results: [], error: error instanceof Error ? error.message : String(error), error_type: "query_execution_error" }; allResults.push(errorResult); // Continue processing other crew IDs continue; } } // Add summary message if any crew were filtered out const filteredCrewMessages = allResults .filter(result => result.filtering_message) .map(result => result.filtering_message); const formattedResults = { all_results: allResults }; // Add summary if filtering occurred if (filteredCrewMessages.length > 0) { formattedResults.filtering_summary = { filtered_crew_count: filteredCrewMessages.length, total_requested: crewId.length, messages: filteredCrewMessages }; } return [{ type: "text", text: JSON.stringify(formattedResults, (key, value) => { // Handle date serialization if (value instanceof Date) { return value.toISOString(); } return value; }, 2) }]; } // Handle single crew ID case (though schema expects array) return [{ type: "text", text: "Error: crew_id should be an array of strings" }]; } catch (error) { logger.error(`Error retrieving seafarer details:`, error); return [{ type: "text", text: `Error retrieving seafarer details: ${error instanceof Error ? error.message : String(error)}` }]; } } async handleWriteCasefileData(args) { logger.info("Executing write casefile data", { args }); const operation = args.operation; if (operation === "write_casefile") { return await this.createCasefile(args); } else if (operation === "write_page") { return await this.updateCasefile(args); } else { throw new Error(`Unsupported operation for write_casefile_data: '${operation}'`); } } async createCasefile(args) { logger.info("Creating new casefile", { args }); const casefileName = args.casefileName; const casefileSummary = args.casefileSummary; const currentStatus = args.currentStatus; const originalImportance = args.importance || 0; const category = args.category || "crew"; const role = args.role; const imo = args.imo; let vesselName = null; let vesselId = null; // Get vessel details if IMO is provided if (imo) { try { // This would normally call get_vessel_name function // For now, we'll use a placeholder vesselName = `Vessel_${imo}`; vesselId = `vessel_id_${imo}`; } catch (error) { logger.warn(`Could not retrieve vessel details for IMO ${imo}:`, error); } } try { const db = await getDatabase(); const collection = db.collection("casefiles"); const data = { vesselId: vesselId, imo: imo, vesselName: vesselName, casefile: casefileName, currentStatus: currentStatus, summary: casefileSummary, originalImportance: originalImportance, importance: originalImportance, category: category, role: role, followUp: "", createdAt: new Date(), updatedAt: new Date(), index: [], pages: [] }; logger.info("Inserting casefile data:", data); const result = await collection.insertOne(data); logger.info("Insert result:", result); const casefileId = result.insertedId.toString(); const casefileUrl = this.generateCasefileWeblink(casefileId); // Update document with the generated link await collection.updateOne({ _id: result.insertedId }, { $set: { link: casefileUrl } }); // Push to Typesense for search indexing try { const typesenseData = { ...data, id: casefileId, vesselId: vesselId ? vesselId.toString() : null }; // Remove MongoDB-specific fields delete typesenseData.index; delete typesenseData.pages; logger.info("Pushing to Typesense:", typesenseData); await this.pushToTypesense(typesenseData, "create"); } catch (error) { logger.error("Error pushing to Typesense:", error); } return [{ type: "text", text: `Casefile created with casefile url: ${casefileUrl}` }]; } catch (error) { logger.error("Error creating casefile:", error); return [{ type: "text", text: `Error creating casefile: ${error instanceof Error ? error.message : 'Unknown error'}` }]; } } async updateCasefile(args) { logger.info("Updating casefile", { args }); const casefileUrl = args.casefile_url; const casefileSummary = args.casefileSummary; const importance = args.importance; const planStatus = "unprocessed"; let tags = args.tags || []; const topic = args.topic; let summary = args.summary || ""; const mailId = args.mailId; const currentStatus = args.currentStatus; const casefileName = args.casefileName; const facts = args.facts; let links = args.links || []; const detailedReport = args.detailed_report || ""; if (!casefileUrl) { throw new Error("Casefile URL is required"); } // Process links const processedLinks = links.map(link => ({ link })); if (detailedReport) { processedLinks.unshift({ link: this.markdownToHtmlLink(detailedReport) }); } // Normalize tags: string to list if needed if (typeof tags === 'string') { tags = [tags]; } // Add facts to summary if provided if (facts) { summary = summary + " <br> " + facts; } try { let casefileId; // Validate and extract casefile ID if (this.isValidObjectId(casefileUrl)) { casefileId = casefileUrl; } else { casefileId = await this.linkToId(casefileUrl); if (!this.isValidObjectId(casefileId)) { throw new Error("Valid Casefile ID is required"); } } const db = await getDatabase(); const collection = db.collection("casefiles"); // Build aggregation pipeline for complex update const updatePipeline = []; // Stage 1: Conditional base field updates const setStage = { updatedAt: new Date() }; if (casefileName !== undefined) setStage.casefile = casefileName; if (currentStatus !== undefined) setStage.currentStatus = currentStatus; if (casefileSummary !== undefined) setStage.summary = casefileSummary; if (importance !== undefined) setStage.importance = importance; if (planStatus !== undefined) setStage.plan_status = planStatus; if (Object.keys(setStage).length > 1) { // More than just updatedAt updatePipeline.push({ $set: setStage }); } // Stage 2: Ensure arrays exist and compute new pagenum updatePipeline.push({ $set: { pages: { $ifNull: ["$pages", []] }, index: { $ifNull: ["$index", []] }, _nextPageNum: { $add: [ { $max: [ { $ifNull: [{ $max: "$pages.pagenum" }, 0] }, { $ifNull: [{ $max: "$index.pagenum" }, 0] } ] }, 1 ] } } }); // Stage 3: Update tags as a unique set if (tags.length > 0) { updatePipeline.push({ $set: { tags: { $setUnion: [ { $ifNull: ["$tags", []] }, tags ] } } }); } // Stage 4: Append to pages and index arrays updatePipeline.push({ $set: { pages: { $concatArrays: [ "$pages", [{ pagenum: "$_nextPageNum", summary: summary, createdAt: new Date(), subject: topic, flag: topic, type: "QA_Agent", link: processedLinks, plan_status: planStatus }] ] }, index: { $concatArrays: [ "$index", [{ pagenum: "$_nextPageNum", type: "QA_Agent", createdAt: new Date(), topic: topic, plan_status: planStatus }] ] } } }); // Stage 5: Cleanup temporary field updatePipeline.push({ $unset: "_nextPageNum" }); // Execute update const result = await collection.updateOne({ _id: this.toObjectId(casefileId) }, updatePipeline); // Update Typesense try { const mongoResult = await collection.findOne({ _id: this.toObjectId(casefileId) }); if (mongoResult) { const updateFields = { id: casefileId, summary: mongoResult.summary, originalImportance: mongoResult.originalImportance, importance: mongoResult.importance || 0, plan_status: mongoResult.plan_status, tag: mongoResult.tag, createdAt: mongoResult.createdAt, updatedAt: mongoResult.updatedAt, casefile: mongoResult.casefile, currentStatus: mongoResult.currentStatus, vesselId: mongoResult.vesselId ? mongoResult.vesselId.toString() : null, imo: mongoResult.imo, vesselName: mongoResult.vesselName, category: mongoResult.category, conversationTopic: mongoResult.conversationTopic, role: mongoResult.role, followUp: mongoResult.followUp || "", pages: (mongoResult.pages || []).slice(-2), index: (mongoResult.index || []).slice(-2) }; logger.info("Updating Typesense:", updateFields); await this.pushToTypesense(updateFields, "upsert"); } } catch (error) { logger.error("Error updating Typesense:", error); } return [{ type: "text", text: `Casefile updated with casefile url: ${casefileUrl}` }]; } catch (error) { logger.error("Error updating casefile:", error); return [{ type: "text", text: `Error updating casefile: ${error instanceof Error ? error.message : 'Unknown error'}` }]; } } async handleQueryCrewDatabase(args) { logger.info("Executing direct crew database query", { selectFields: args.select_fields, whereConditions: args.where_conditions, orderBy: args.order_by, limitResults: args.limit_results }); // Enhanced Parameter validation and normalization const validateAndNormalizeParams = (args) => { let selectFields = args.select_fields; // Handle array input if (Array.isArray(selectFields)) { selectFields = selectFields.join(', '); } // Validate it's now a string if (typeof selectFields !== 'string') { throw new Error('select_fields must be a string or array of strings'); } return { selectFields: selectFields.trim(), whereConditions: args.where_conditions?.trim() || '', groupBy: args.group_by?.trim() || '', having: args.having?.trim() || '', orderBy: args.order_by?.trim() || '', distinct: args.distinct || false, limitResults: args.limit_results || 50000, getLatestRecords: args.get_latest_records || false // NEW: Key parameter }; }; // Enhanced Query Type Detection with get_latest_records support let QueryType; (function (QueryType) { QueryType["PURE_AGGREGATE"] = "PURE_AGGREGATE"; QueryType["GROUPED_AGGREGATE"] = "GROUPED_AGGREGATE"; QueryType["RECORD_RETRIEVAL"] = "RECORD_RETRIEVAL"; QueryType["DEDUPLICATION"] = "DEDUPLICATION"; // Latest records needed })(QueryType || (QueryType = {})); const detectQueryType = (params) => { const { selectFields, groupBy, getLatestRecords } = params; const upperFields = selectFields.toUpperCase(); // Check for aggregate functions const hasAggregates = /COUNT\s*\(|SUM\s*\(|AVG\s*\(|MAX\s*\(|MIN\s*\(/i.test(upperFields); // Priority 1: Deduplication when get_latest_records is explicitly requested if (getLatestRecords) { return QueryType.DEDUPLICATION; } // Priority 2: Aggregate patterns if (hasAggregates && !groupBy) { return QueryType.PURE_AGGREGATE; } else if (hasAggregates && groupBy) { return QueryType.GROUPED_AGGREGATE; } else { return QueryType.RECORD_RETRIEVAL; } }; // Handler functions for different query types const handlePureAggregate = async (params) => { logger.info('Handling PURE_AGGREGATE query', { selectFields: params.selectFields }); // Build query with no MONTHS manipulation let sqlQuery = `SELECT ${params.selectFields} FROM revised_base_view`; if (params.whereConditions) { sqlQuery += ` WHERE ${params.whereConditions}`; } // No automatic ORDER BY for aggregate queries if (params.orderBy) { sqlQuery += ` ORDER BY ${params.orderBy}`; } sqlQuery += ` LIMIT ${Math.min(params.limitResults, 100000)}`; return await executeQuery(sqlQuery, params); }; const handleGroupedAggregate = async (params) => { logger.info('Handling GROUPED_AGGREGATE query', { selectFields: params.selectFields, groupBy: params.groupBy }); // Build query with no MONTHS manipulation let sqlQuery = `SELECT ${params.selectFields} FROM revised_base_view`; if (params.whereConditions) { sqlQuery += ` WHERE ${params.whereConditions}`; } if (params.groupBy) { sqlQuery += ` GROUP BY ${params.groupBy}`; } if (params.having && params.groupBy) { sqlQuery += ` HAVING ${params.having}`; } // Custom ORDER BY only if (params.orderBy) { sqlQuery += ` ORDER BY ${params.orderBy}`; } sqlQuery += ` LIMIT ${Math.min(params.limitResults, 100000)}`; return await executeQuery(sqlQuery, params); }; const handleRecordRetrieval = async (params) => { logger.info('Handling RECORD_RETRIEVAL query', { selectFields: params.selectFields }); // Add MONTHS field if not present and not using get_latest_records let fieldsToSelect = params.selectFields; if (!params.getLatestRecords && !fieldsToSelect.toLowerCase().includes('months')) { fieldsToSelect = `${fieldsToSelect}, MONTHS`; } const selectClause = params.distinct ? 'SELECT DISTINCT' : 'SELECT'; let sqlQuery = `${selectClause} ${fieldsToSelect} FROM revised_base_view`; if (params.whereConditions) { sqlQuery += ` WHERE ${params.whereConditions}`; } // Add ORDER BY MONTHS DESC first, then custom ORDER BY if (!params.getLatestRecords) { if (params.orderBy) { sqlQuery += ` ORDER BY MONTHS DESC, ${params.orderBy}`; } else { sqlQuery += ` ORDER BY MONTHS DESC`; } } else if (params.orderBy) { sqlQuery += ` ORDER BY ${params.orderBy}`; } sqlQuery += ` LIMIT ${Math.min(params.limitResults, 100000)}`; return await executeQuery(sqlQuery, params); }; const handleDeduplication = async (params) => { logger.info('Handling DEDUPLICATION query with window functions', { selectFields: params.selectFields }); // Use window function to get latest record per crew member const selectClause = params.distinct ? 'SELECT DISTINCT' : 'SELECT'; let sqlQuery = `WITH latest_records AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY CREW_CODE ORDER BY MONTHS DESC) as rn FROM revised_base_view ) ${selectClause} ${params.selectFields} FROM latest_records WHERE rn = 1`; if (params.whereConditions) { sqlQuery += ` AND ${params.whereConditions}`; } if (params.groupBy) { sqlQuery += ` GROUP BY ${params.groupBy}`; } if (params.having && params.groupBy) { sqlQuery += ` HAVING ${params.having}`; } // Custom ORDER BY only (no MONTHS needed since we have latest records) if (params.orderBy) { sqlQuery += ` ORDER BY ${params.orderBy}`; } sqlQuery += ` LIMIT ${Math.min(params.limitResults, 100000)}`; return await executeQuery(sqlQuery, params); }; // Helper method to execute query and return formatted response const executeQuery = async (sqlQuery, params) => { logger.info("Constructed SQL query", { query: sqlQuery.substring(0, 200) + (sqlQuery.length > 200 ? '...' : ''), limitResults: params.limitResults }); // Import CSV utilities const { convertToCsv } = await import('../utils/csvUtils.js'); // Import IMO utilities for security filtering const { validateImoNumber, shouldBypassImoFiltering } = await import('../utils/imoUtils.js'); const companyName = (await import('../utils/config.js')).config.companyName || ''; // Pre-validation: Check if specific IMO is requested in the WHERE conditions if (!shouldBypassImoFiltering(companyName) && params.whereConditions) { // Extract IMO numbers from WHERE conditions const imoMatches = params.whereConditions.match(/imo_number\s*[=]\s*['"]?(\d+)['"]?/gi); if (imoMatches) { for (const match of imoMatches) { const imoMatch = match.match(/(\d+)/); if (imoMatch) { const requestedImo = imoMatch[1]; const validation = validateImoNumber(requestedImo, companyName); if (!validation.isValid) { logger.warn(`Pre-validation failed for query_crew_database: ${validation.errorMessage}`); return [{ type: "text", text: validation.errorMessage || "Invalid IMO number for this company" }]; } } } } } try { // Execute the query using Snowflake connection pool const client = await getConnectionFromPool(); logger.info('Executing Snowflake query', { query: sqlQuery.substring(0, 200) + (sqlQuery.length > 200 ? '...' : ''), limitResults: params.limitResults, companyName }); const startTime = Date.now(); const result = await client.execute(sqlQuery); const executionTime = Date.now() - startTime; logger.info('Snowflake query executed successfully', { executionTimeMs: executionTime, columnCount: result.columns.length, rowCount: result.rows.length, queryLength: sqlQuery.length }); await returnConnectionToPool(client); // Apply IMO filtering to results if needed let filteredRows = result.rows; let filteringStats = null; if (!shouldBypassImoFiltering(companyName)) { const { filterQueryResultsByCompanyImos } = await import('../utils/imoUtils.js'); const filteringResult = filterQueryResultsByCompanyImos(result.columns, result.rows); filteredRows = filteringResult.filteredRows; filteringStats = fi