UNPKG

survey-mcp-server

Version:
684 lines (683 loc) 27.1 kB
import Typesense from "typesense"; import { MongoClient } from "mongodb"; export async function getTypesenseClient() { // Validate required environment variables const host = process.env.TYPESENSE_HOST; const port = process.env.TYPESENSE_PORT; const protocol = process.env.TYPESENSE_PROTOCOL; const apiKey = process.env.TYPESENSE_API_KEY; if (!host || !port || !protocol || !apiKey) { throw new Error('Missing required Typesense environment variables'); } // Initialize Typesense client from scratch const typesenseConfig = { nodes: [ { host, port: Number(port), protocol } ], apiKey, connectionTimeoutSeconds: 10, // retryIntervalSeconds: 1.0, // numRetries: 3, // healthcheckIntervalSeconds: 30, // logLevel: 'debug' as 'debug' }; return new Typesense.Client(typesenseConfig); } export async function getComponentData(componentId, vesselComponentsDbName, vesselComponentsMongoUri, collectionName = 'vesselinfocomponents') { const match = componentId.match(/^(\d+)_(\d+)_(\d+)$/); if (!match) { return `⚠️ Invalid component_id format: ${componentId}`; } if (!vesselComponentsDbName || !vesselComponentsMongoUri || !collectionName) { throw new Error('Database name, MongoDB URI, and collection name are required'); } const [, componentNumber, questionNumber, imo] = match; const componentNo = `${componentNumber}_${questionNumber}_${imo}`; const mongoClient = new MongoClient(vesselComponentsMongoUri); await mongoClient.connect(); try { const db = mongoClient.db(vesselComponentsDbName); const collection = db.collection(collectionName); const doc = await collection.findOne({ componentNo }); if (!doc) { return `⚠️ No component found for ID: ${componentId}`; } if (!doc.data) { return "No data found in the table component"; } if (!doc.data.headers || !Array.isArray(doc.data.headers)) { return "No headers found in the table component"; } if (!doc.data.body || !Array.isArray(doc.data.body)) { return "No body data found in the table component"; } // Extract headers excluding lineitem const headers = doc.data.headers .filter((h) => h && h.name !== "lineitem") .map((h) => h.name); const rows = doc.data.body; // Build markdown table let md = "| " + headers.join(" | ") + " |\n"; md += "| " + headers.map(() => "---").join(" | ") + " |\n"; for (const row of rows) { const formattedRow = row .filter((cell) => cell && !cell.lineitem) // Exclude lineitem and null cells .map((cell) => { if (cell && cell.value && cell.link) { return `[${cell.value}](${cell.link})`; } else if (cell && cell.status && cell.color) { return cell.status; } return cell ? String(cell) : ''; }); md += "| " + formattedRow.join(" | ") + " |\n"; } return md; } catch (error) { // logger.error('Error getting component data:', error); throw new Error(`Error getting component data: ${error.message}`); } finally { await mongoClient.close(); } } export async function addComponentData(answer, imo, vesselComponentsDbName, vesselComponentsMongoUri) { const pattern = /httpsdev\.syia\.ai\/chat\/ag-grid-table\?component=(\d+_\d+)/g; const matches = Array.from(answer.matchAll(pattern)); let result = answer; for (const match of matches) { const component = match[1]; try { const replacement = await getComponentData(`${component}_${imo}`, vesselComponentsDbName, vesselComponentsMongoUri, 'vesselinfocomponents'); result = result.replace(match[0], replacement); } catch (error) { } } return result; } export async function getVesselQnASnapshot(imo, questionNo) { try { const raw_snapshotUrl = process.env.SNAPSHOT_URL; // API endpoint const snapshotUrl = `${raw_snapshotUrl}/${imo}/${questionNo}`; const raw_jwtToken = process.env.JWT_TOKEN; // Authentication token const jwtToken = `Bearer ${raw_jwtToken}`; // Headers for the request const headers = { "Authorization": jwtToken }; const response = await fetch(snapshotUrl, { method: 'GET', headers }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); // Return resultData if it exists, otherwise return the full response if (data && typeof data === 'object' && "resultData" in data) { return data.resultData; } return data; } catch (error) { return null; } } export async function fetchQADetails(imo, qaId, vesselInfoDbName, vesselInfoMongoUri, collectionName = 'vesselinfos') { const mongoClient = new MongoClient(vesselInfoMongoUri); await mongoClient.connect(); try { const db = mongoClient.db(vesselInfoDbName); const collection = db.collection(collectionName); const query = { 'imo': parseInt(imo), 'questionNo': qaId }; const projection = { '_id': 0, 'imo': 1, 'vesselName': 1, 'refreshDate': 1, 'answer': 1 }; const mongoResult = await collection.findOne(query, { projection }); let res = mongoResult ? { imo: mongoResult.imo, vesselName: mongoResult.vesselName, refreshDate: mongoResult.refreshDate, answer: mongoResult.answer } : { imo: parseInt(imo), vesselName: null, refreshDate: null, answer: null }; // Format refresh date if it exists if (res.refreshDate && new Date(res.refreshDate).toString() !== 'Invalid Date') { res.refreshDate = new Date(res.refreshDate).toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric' }); } // Process answer with component data if it exists if (res.answer) { const vesselComponentsDbName = process.env.vesselComponentsDbName || vesselInfoDbName; const vesselComponentsMongoUri = process.env.vesselComponentsMongoUri || vesselInfoMongoUri; res.answer = await addComponentData(res.answer, imo, vesselComponentsDbName, vesselComponentsMongoUri); } // Get vessel QnA snapshot link try { res.Artifactlink = await getVesselQnASnapshot(imo, qaId.toString()); } catch (error) { res.Artifactlink = null; } return res; } catch (error) { // logger.error('Error fetching QA details:', error); throw new Error(`Error fetching QA details: ${error.message}`); } finally { await mongoClient.close(); } } export async function fetchQADetailsAndCreateResponse(imo, questionNo, functionName, linkHeader, vesselInfoDbName, vesselInfoMongoUri, collectionName = 'vesselinfos') { if (!imo) { throw new Error("IMO is required"); } try { // Fetch QA details const result = await fetchQADetails(imo, questionNo, vesselInfoDbName, vesselInfoMongoUri, collectionName); const link = result.Artifactlink; const vesselName = result.vesselName; // Get artifact data const artifactData = await getArtifact(functionName, link); // Create content responses with processed answer const artifactLinkText = link ? `\n\nArtifact Link: ${link}` : ""; const content = { type: "text", text: `${result.answer || "No data available"}${artifactLinkText}` }; const artifact = { type: "text", text: JSON.stringify(artifactData, null, 2) }; return [content, artifact]; } catch (error) { // logger.error(`Error in ${functionName}:`, error); throw new Error(`Error in ${functionName}: ${error.message}`); } } export async function getArtifact(toolName, link) { try { const timestamp = Math.floor(Date.now() / 1000); const artifact = { id: `msg_browser_${Math.random().toString(36).substring(2, 8)}`, parentTaskId: `task_${toolName}_${Math.random().toString(36).substring(2, 8)}`, timestamp, agent: { id: "agent_siya_browser", name: "SIYA", type: "qna" }, messageType: "action", action: { tool: "browser", operation: "browsing", params: { url: link, pageTitle: `Tool response for ${toolName}`, visual: { icon: "browser", color: "#2D8CFF" }, stream: { type: "vnc", streamId: "stream_browser_1", target: "browser" } } }, content: `Viewed page: ${toolName}`, artifacts: [ { id: `artifact_webpage_${Date.now()}_${Math.floor(Math.random() * 1000)}`, type: "browser_view", content: { url: link, title: toolName, screenshot: "", textContent: `Observed output of cmd \`${toolName}\` executed:`, extractedInfo: {} }, metadata: { domainName: "example.com", visitTimestamp: Date.now(), category: "web_page" } } ], status: "completed" }; return artifact; } catch (error) { // logger.error('Error getting artifact:', error); throw new Error(`Error getting artifact: ${error.message}`); } } export async function getVesselImoListFromFleet(fleetImo) { // Use GROUP_DETAILS if available, otherwise fallback to FLEET_DISTRIBUTION const dbName = process.env.GROUP_DETAILS_DB_NAME || process.env.FLEET_DISTRIBUTION_DB_NAME; const mongoUri = process.env.GROUP_DETAILS_MONGO_URI || process.env.FLEET_DISTRIBUTION_MONGO_URI; const collectionName = "common_group_details"; if (!dbName || !mongoUri || !collectionName) { throw new Error('Database name, MongoDB URI, and collection name are required for fleet operations'); } const mongoClient = new MongoClient(mongoUri); await mongoClient.connect(); try { const db = mongoClient.db(dbName); const collection = db.collection(collectionName); const fleetDoc = await collection.findOne({ imo: fleetImo }); if (fleetDoc && fleetDoc.imoList && Array.isArray(fleetDoc.imoList)) { return fleetDoc.imoList; } return []; } catch (error) { throw error; } finally { await mongoClient.close(); } } export function convertUnixDates(document) { const result = { ...document }; const dateFields = [ 'purchaseRequisitionDate', 'purchaseOrderIssuedDate', 'orderReadinessDate', 'date', 'poDate', 'expenseDate', "inspectionTargetDate", "reportDate", "closingDate", "targetDate", "nextDueDate", "extendedDate" ]; let convertedCount = 0; for (const field of dateFields) { const value = result[field]; if (typeof value === "number" && Number.isFinite(value)) { const originalValue = value; result[field] = new Date(value * 1000).toISOString(); convertedCount++; } } return result; } export async function getDataLink(data) { try { const snapshotUrl = process.env.SNAPSHOT_URL || ""; const jwtToken = process.env.JWT_TOKEN || ""; const headers = { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}` }; const payload = { data }; const response = await fetch(snapshotUrl, { method: 'POST', headers, body: JSON.stringify(payload) }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const result = await response.json(); if (result.status === "OK") { return result.resultData; } else { throw new Error('Failed to get data link: Invalid response status'); } } catch (error) { throw new Error(`Error getting data link: ${error.message}`); } } export async function processTypesenseResults(searchResult, toolName, title, linkHeader, artifactTitle, dbName, mongoUri) { try { if (!searchResult || !searchResult.hits || searchResult.hits.length === 0) { return { content: [{ type: "text", text: "No records found for the specified criteria." }] }; } // Process search results into the standard format const hits = searchResult.hits || []; const documents = await Promise.all(hits.map(async (hit, index) => { if (!hit.document) { return {}; } // Create a shallow copy of the document const document = { ...hit.document }; // Remove embedding field to reduce response size if (document.embedding) { delete document.embedding; } // Convert Unix timestamps to readable dates return await convertUnixDates(document); })); // Get data link const dataLink = await getDataLink(documents); // Get vessel name and IMO from hits let vesselName = null; let imo = null; try { vesselName = searchResult.hits[0]?.document?.vesselName; imo = searchResult.hits[0]?.document?.imo; } catch (error) { } // Format results in the standard structure const formattedResults = { found: searchResult.found || 0, out_of: searchResult.out_of || 0, page: searchResult.page || 1, hits: documents, artifactLink: dataLink }; // Get artifact data const artifactData = await getArtifact(toolName, dataLink); // Create content response const content_ = { type: "text", text: JSON.stringify(formattedResults, null, 2), title, format: "json" }; // Create artifact response const artifact = { type: "text", text: JSON.stringify(artifactData, null, 2), title: artifactTitle || title, format: "json" }; return { content: [content_, artifact] }; } catch (error) { return { content: [{ type: "text", text: `Error processing results: ${error.message}`, title: "Error", format: "json" }] }; } } export async function exportDataForImoListGeneric(collectionName, imoList, startDate, endDate, dateField, excludeFields = "", timestampFields = []) { try { const client = await getTypesenseClient(); const collection = client.collections(collectionName); const dateToTs = (dateStr) => { return Math.floor(new Date(dateStr).getTime() / 1000); }; const filterParts = [`imo:[${imoList.join(',')}]`]; if (startDate && dateField) { filterParts.push(`${dateField}:>=${dateToTs(startDate)}`); } if (endDate && dateField) { filterParts.push(`${dateField}:<=${dateToTs(endDate)}`); } const filterBy = filterParts.join(" && "); const query = { filter_by: filterBy, exclude_fields: excludeFields }; const exportResult = await collection.documents().export(query); let exportData; if (typeof exportResult === 'string') { exportData = exportResult; } else if (exportResult && typeof exportResult === 'object' && 'buffer' in exportResult) { exportData = new TextDecoder().decode(exportResult); } else { exportData = String(exportResult); } const documents = exportData .split('\n') .filter(line => line.trim()) .map(line => JSON.parse(line)); // Convert UNIX timestamps to readable date strings for (const doc of documents) { for (const field of timestampFields) { if (field in doc && typeof doc[field] === 'number') { try { doc[field] = new Date(doc[field] * 1000).toISOString().replace('T', ' ').substring(0, 19); } catch (err) { // Leave original value on error } } } } return documents; } catch (error) { // logger.error(`Error exporting data from '${collectionName}' collection:`, error); return []; } } export async function exportSurveysForImoList(imoList) { // For surveys, we don't use date filtering - return all certificate/survey data for the IMO list return exportDataForImoListGeneric('certificate', imoList, undefined, // No startDate filtering undefined, // No endDate filtering undefined, // No dateField - no date filtering at all "embedding", ['issueDate', 'extensionDate', 'expiryDate', 'windowStartDate', 'windowEndDate']); } export function convertToCSV(data) { if (!data.length) return ""; const headers = Object.keys(data[0]); const escapeCSV = (value) => { const str = value != null ? String(value) : ""; const needsEscaping = /[",\n]/.test(str); const escaped = str.replace(/"/g, '""'); // escape double quotes return needsEscaping ? `"${escaped}"` : escaped; }; const rows = data.map(doc => headers.map(header => escapeCSV(doc[header])).join(',')); return [headers.join(','), ...rows].join('\n'); } export async function combined_mongotools_with_grouped_category_mapping(args, allowedCategories = [ "main_engine_performance", "auxiliary_engine_performance", "lubrication_analysis", "systems_equipment", "inventory_supplies", "compliance_reporting" ], categoryMappings = { "main_engine_performance": [67, 68, 69, 70, 76], "auxiliary_engine_performance": [1, 2, 3, 4, 8, 71, 77], "lubrication_analysis": [72, 74], "systems_equipment": [10, 73, 78, 231], "inventory_supplies": [75, 79], "compliance_reporting": [65, 66, 80] }, toolNamePrefix = "combined_mongotools_grouped_category", qaDbName = '', qaMongoUri = '') { // logger.info("combined_mongotools_with_grouped_category_mapping called", args); try { const { imo, category, questionNo } = args; // Early validation with destructuring if (!imo) throw new Error(`Error: Missing required parameter 'imo'. For details refer to survey tool description.`); if (!category) throw new Error(`Error: Missing required parameter 'category'. For details refer to survey tool description.`); // Use provided parameters // Fast validation using Set const allowedCategoriesSet = new Set(allowedCategories); if (!allowedCategoriesSet.has(category)) { return { content: [{ type: "text", text: `Invalid category: ${category}. Allowed values are: ${allowedCategories.join(', ')}` }] }; } const validQuestions = categoryMappings[category]; if (!validQuestions) { return { content: [{ type: "text", text: `No questions found for category: ${category}` }] }; } // Determine questions to fetch const questionsToFetch = questionNo ? (validQuestions.includes(questionNo) ? [questionNo] : null) : validQuestions; if (!questionsToFetch) { return { content: [{ type: "text", text: `Question ${questionNo} not available in category '${category}'. Valid questions for this category are: ${validQuestions.join(', ')}` }] }; } // logger.info(`Fetching grouped category mapping information for category: ${category}, questionNo: ${questionNo || 'all'}, vessel IMO: ${imo}`); // Use provided database connection parameters // Pre-build summary template for efficiency const categoryDisplay = category.replace(/_/g, ' ').toUpperCase(); const questionsList = questionsToFetch.join(', '); let vesselName = ""; const summaryParts = [ '', `**Vessel IMO:** ${imo}`, `**Category:** ${category}`, `**Questions Fetched:** ${questionsList}`, '' ]; const allResponses = []; // Parallel processing for multiple questions (if more than 1) if (questionsToFetch.length > 1) { const promises = questionsToFetch.map(qNo => fetchQADetailsAndCreateResponse(imo, qNo, `${toolNamePrefix}_q${qNo}`, `${category}`, qaDbName, qaMongoUri, 'vesselinfos').catch(error => { // logger.warn(`Failed to fetch data for question ${qNo}:`, error); return null; })); const responses = await Promise.allSettled(promises); console.log("responses", responses); responses.forEach((result, index) => { if (result.status === 'fulfilled' && result.value) { const response = result.value; const qNo = questionsToFetch[index]; // Add artifact if (response.length > 1) { allResponses.push(response[1]); } // Add content to summary (extract markdown from JSON) if (response.length > 0 && response[0].type === 'text') { const text = String(response[0].text); let content = text; try { const parsedContent = JSON.parse(text); content = parsedContent.answer || text; } catch (e) { // Keep original content if not JSON } summaryParts.push(`## Question ${qNo}`, '', content, '', '---', ''); } // Extract vessel name from first successful response if (!vesselName && response.length > 0) { const text = String(response[0].text); try { const parsedContent = JSON.parse(text); vesselName = parsedContent.vesselName || ""; } catch (e) { // Ignore parsing errors } } } }); } else { // Single question - sequential processing const qNo = questionsToFetch[0]; try { const response = await fetchQADetailsAndCreateResponse(imo, qNo, `${toolNamePrefix}_q${qNo}`, `${category}`, qaDbName, qaMongoUri, 'vesselinfos'); // Add artifact if (response.length > 1) { allResponses.push(response[1]); } // Add content to summary (extract markdown from JSON) if (response.length > 0 && response[0].type === 'text') { const text = String(response[0].text); let content = text; try { const parsedContent = JSON.parse(text); content = parsedContent.answer || text; } catch (e) { // Keep original content if not JSON } summaryParts.push(`## Question ${qNo}`, '', content, '', '---', ''); } // Extract vessel name if (response.length > 0) { const text = String(response[0].text); try { const parsedContent = JSON.parse(text); vesselName = parsedContent.vesselName || ""; } catch (e) { // Ignore parsing errors } } } catch (error) { // logger.warn(`Failed to fetch data for question ${qNo}:`, error); } } // Handle no results if (allResponses.length === 0) { return { content: [{ type: "text", text: `No grouped category mapping information found for category: ${category}` }] }; } // Add vessel name to summary if found if (vesselName) { summaryParts[2] = `**Vessel IMO:** ${imo}`; summaryParts.splice(3, 0, `**Vessel Name:** ${vesselName}`); } // Build final response efficiently const summaryText = summaryParts.join('\n'); const finalResult = [{ type: "text", text: summaryText }]; finalResult.push(...allResponses); return { content: finalResult }; } catch (error) { // logger.error(`Error in ${toolNamePrefix}`, { error }); throw new Error(`Error retrieving ${toolNamePrefix} information: ${error instanceof Error ? error.message : String(error)}`); } }