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
JavaScript
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