survey-mcp-server
Version:
684 lines (683 loc) • 27.1 kB
JavaScript
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)}`);
}
}