pms-analysis-reports-mcp-server
Version:
PMS analysis reports server handling maintenance reports, equipment analysis, compliance tracking, and performance metrics with ERP access for data extraction
431 lines • 18 kB
JavaScript
import { DatabaseManager } from "./mongodb.js";
import { getTypesenseClient } from "./typesense.js";
import { getConfig } from "./config.js";
import { logger } from "./logger.js";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { lookup as mimeLookup } from "mime-types";
import path from "path";
import fs from "fs/promises";
// Helper function to generate alpha-numeric ID
export function generateAlphaNumericId(alphaSize = 3, numericSize = 3) {
const letters = 'abcdefghijklmnopqrstuvwxyz';
const digits = '0123456789';
let alphaPart = '';
let numericPart = '';
for (let i = 0; i < alphaSize; i++) {
alphaPart += letters.charAt(Math.floor(Math.random() * letters.length));
}
for (let i = 0; i < numericSize; i++) {
numericPart += digits.charAt(Math.floor(Math.random() * letters.length));
}
return alphaPart + numericPart;
}
// Helper function to generate artifacts
export function getCasefileArtifacts(functionName, results) {
const artifacts = [];
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (!result.url)
continue;
const artifactData = {
id: `msg_browser_${generateAlphaNumericId()}`,
parentTaskId: `task_casefile_${generateAlphaNumericId()}`,
timestamp: Date.now(),
agent: {
id: "agent_siya_browser",
name: "SIYA",
type: "qna"
},
messageType: "action",
action: {
tool: "browser",
operation: "browsing",
params: {
url: result.title,
pageTitle: `Tool response for ${functionName}`,
visual: {
icon: "browser",
color: "#2D8CFF"
},
stream: {
type: "vnc",
streamId: "stream_browser_1",
target: "browser"
}
}
},
content: `Viewed page: ${functionName}`,
artifacts: [{
id: `artifact_webpage_${generateAlphaNumericId()}`,
type: "browser_view",
content: {
url: result.url,
title: functionName,
screenshot: "",
textContent: `Observed output of cmd \`${functionName}\` executed:`,
extractedInfo: {}
},
metadata: {
domainName: "example.com",
visitTimestamp: Date.now(),
category: "web_page"
}
}],
status: "completed"
};
artifacts.push({
type: "text",
text: JSON.stringify(artifactData, null, 2),
title: `Casefile: ${result.title}`,
format: "json"
});
}
return artifacts;
}
// Helper to generate casefile weblink (calls diary API)
export async function generateCasefileWeblink(casefileId) {
const config = getConfig();
const endpoints = [
`${config.API_BASE_URL}/v1.0/diary/casefile-html/${casefileId}`,
`${config.API_BASE_URL}/v1.0/diary/casefilehtml/${casefileId}`
];
const headers = { Authorization: `Bearer ${config.API_TOKEN}` };
for (const url of endpoints) {
try {
const resp = await fetch(url, { headers });
if (resp.status === 200) {
const body = await resp.json();
const data = body.resultData || {};
if (body.status === "OK" && data.url) {
return data.url;
}
}
}
catch (e) {
// Continue to next endpoint if error
continue;
}
}
throw new Error(`Could not generate weblink for casefile ${casefileId}`);
}
// Upload to S3
export async function uploadToS3(fileContent, destinationPath) {
const config = getConfig();
const ACCESS_KEY = config.s3AccessKey;
const SECRET_KEY = config.s3SecretKey;
const REGION_NAME = config.s3Region;
const BUCKET_NAME = config.s3BucketName;
const s3Client = new S3Client({
region: REGION_NAME,
credentials: {
accessKeyId: ACCESS_KEY,
secretAccessKey: SECRET_KEY,
},
});
// Detect content type based on file extension
const fileName = String(destinationPath).split('/').pop() || 'unknown';
const contentType = mimeLookup(fileName) || "application/octet-stream"; // Fallback for unknown types
const params = {
Bucket: BUCKET_NAME,
Key: destinationPath,
Body: fileContent,
ContentType: contentType,
};
try {
await s3Client.send(new PutObjectCommand(params));
logger.info(`Uploaded ${destinationPath} with type ${contentType}`);
// Return the S3 URL
const s3Url = `https://s3.${REGION_NAME}.amazonaws.com/${BUCKET_NAME}/${destinationPath}`;
return s3Url;
}
catch (error) {
logger.error("Error uploading to S3:", error);
throw new Error(`Failed to upload ${destinationPath} to S3: ${error}`);
}
}
// Helper function to upload attachments to S3
export async function uploadAttachments(attachments, casefileId) {
const uploadedUrls = [];
for (const filePath of attachments) {
try {
// Validate file path - check if it's absolute
if (!path.isAbsolute(filePath)) {
throw new Error(`File path must be absolute: ${filePath}. Relative paths are not supported.`);
}
// Check if file exists
try {
await fs.access(filePath);
}
catch (accessError) {
throw new Error(`File not found or not accessible: ${filePath}. Please ensure the file exists and you have read permissions.`);
}
// Read file content
const fileContent = await fs.readFile(filePath);
const fileName = path.basename(String(filePath));
// Create S3 destination path: casefile_docs/casefile_id/filename
const destinationPath = `casefile_docs/${casefileId}/${fileName}`;
// Upload to S3
const s3Url = await uploadToS3(fileContent, destinationPath);
uploadedUrls.push(s3Url);
logger.info(`Successfully uploaded attachment ${fileName} to S3: ${s3Url}`);
}
catch (error) {
logger.error(`Failed to upload attachment ${filePath}: ${error}`);
throw new Error(`Failed to upload attachment ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return uploadedUrls;
}
// Helper function to validate if a string is a valid link
export function isValidLink(link) {
if (typeof link !== 'string' || !link.trim()) {
return false;
}
// Check for common URL patterns
const urlPatterns = [
/^https?:\/\/.+/i, // HTTP/HTTPS URLs
/^ftp:\/\/.+/i, // FTP URLs
/^mailto:.+/i, // Mailto links
/^tel:.+/i, // Telephone links
/^#.+/i, // Anchor links
/^\/.+/i, // Relative paths (starting with /)
/^[a-zA-Z0-9-]+:\/\/.+/i, // Other protocols
/^www\..+/i, // WWW URLs (without protocol)
/^[a-zA-Z0-9][a-zA-Z0-9-]*\.[a-zA-Z]{2,}/i // Domain names
];
// Check if any pattern matches
return urlPatterns.some(pattern => pattern.test(link.trim()));
}
// Helper function to get vessel details
export async function getVesselDetails(query) {
try {
if (!query) {
throw new Error("Query parameter is required for vessel details search");
}
console.log(`[Internal] Searching for vessel details with vessel name: ${query}`);
// Set up search parameters for the fleet-vessel-lookup collection
const search_parameters = {
q: query,
query_by: 'vesselName',
collection: 'fleet-vessel-lookup',
per_page: 1,
include_fields: 'vesselName,imo,class,flag,shippalmDoc,isV3',
prefix: false,
num_typos: 2,
};
// Execute search
const typesenseClient = getTypesenseClient();
const raw = await typesenseClient.collections('fleet-vessel-lookup').documents().search(search_parameters);
const hits = raw.hits || [];
if (!hits || hits.length === 0) {
return null;
}
// Process and format results
const doc = hits[0].document || {};
return {
vesselName: doc.vesselName,
imo: doc.imo,
class: doc.class,
flag: doc.flag,
shippalmDoc: doc.shippalmDoc,
isV3: doc.isV3,
score: hits[0].text_match || 0
};
}
catch (error) {
console.error(`[Internal] Error searching for vessel details: ${error}`);
throw error;
}
}
// Helper to push casefile to Typesense
export async function pushToTypesense(res, action, dbName, mongoUri, collectionName = 'casefiles') {
const id = res.id || res._id?.toString();
const casefileTxt = res.casefile;
const summaryTxt = res.summary;
const embeddingText = `Below casefile ${casefileTxt} with following summary ${summaryTxt} `;
const link = await generateCasefileWeblink(id);
if (!dbName || !mongoUri || !collectionName) {
throw new Error('Database name, MongoDB URI, and collection name are required');
}
// Update the casefile in MongoDB with the link
const databaseManager = new DatabaseManager();
await databaseManager.initializeDatabase(dbName, mongoUri);
const db = databaseManager.getDb();
const collection = db.collection(collectionName);
await collection.updateOne({ _id: id }, { $set: { link } });
await databaseManager.closeDatabase();
// Prepare Typesense data
const createdAt = res.createdAt instanceof Date ? Math.floor(res.createdAt.getTime() / 1000) : (typeof res.createdAt === 'number' ? res.createdAt : Date.now() / 1000);
const updatedAt = res.updatedAt instanceof Date ? Math.floor(res.updatedAt.getTime() / 1000) : (typeof res.updatedAt === 'number' ? res.updatedAt : Date.now() / 1000);
const importance = typeof res.importance === 'number' ? res.importance : 0;
const data = {
id: id,
_id: id,
casefile: res.casefile,
currentStatus: res.currentStatus,
casefileInitiationDate: createdAt,
category: res.category,
conversationTopic: [],
embedding_text: embeddingText,
imo: Number(res.imo),
importance: String(importance), // <-- convert to string for Typesense
importance_score: importance, // <-- keep as number for other uses
lastcasefileUpdateDate: updatedAt,
summary: res.summary,
vesselId: res.vesselId ? String(res.vesselId) : null,
vesselName: res.vesselName ? String(res.vesselName) : null,
link: link,
followUp: res.followUp || "",
pages: JSON.stringify((res.pages || []).slice(-2)),
index: JSON.stringify((res.index || []).slice(-2)),
};
if (res.plan_status) {
data.plan_status = res.plan_status;
}
// for update action, the following fields are not allowed to be updated:
// - embedding_text
// - casefile
if (action === 'update') {
delete data.embedding_text;
delete data.casefile;
}
try {
const typesenseClient = getTypesenseClient();
console.info(`Data pushed to Typesense:`, data);
const result = await typesenseClient.collections("emailCasefile").documents().import([data], { action });
// Log after Typesense push
console.info(`[pushToTypesense] Data pushed to Typesense successfully for id: ${id}`);
console.info(result);
return result;
}
catch (e) {
if (typeof e === "object" && e !== null && "importResults" in e) {
console.error("Typesense import error details:", e.importResults);
}
throw e;
}
}
// Field mapping for common incorrect field names
const fieldMapping = {
// Common incorrect field names that LLMs might use
"vessel_name": "vesselName",
"imo_number": "imo",
"incident_type": "casefile",
"location": "summary", // This should be part of summary
"incident_date": "summary", // This should be part of summary
"status": "currentStatus",
"importance_level": "importance",
"category": "category",
"summary": "summary",
"created_by": "summary", // This should be part of summary
"description": "summary",
"details": "summary",
"content": "summary",
"title": "casefile",
"name": "casefile",
"flag": "flag"
};
// Validation function for create_casefile
export function validateCreateCasefileArgs(args) {
const errors = [];
const mappedArgs = {};
// Map incorrect field names to correct ones
for (const [key, value] of Object.entries(args)) {
const mappedKey = fieldMapping[key] || key;
mappedArgs[mappedKey] = value;
}
// Validate required fields
const requiredFields = ['casefile', 'category', 'currentStatus', 'summary', 'importance', 'imo', 'pages', 'index'];
const missingFields = [];
for (const field of requiredFields) {
if (!mappedArgs[field]) {
missingFields.push(field);
}
}
if (missingFields.length > 0) {
errors.push(`Missing required fields: ${missingFields.join(', ')}`);
}
// Validate field types and values
if (mappedArgs.imo && (typeof mappedArgs.imo !== 'number' || mappedArgs.imo <= 0)) {
errors.push('IMO must be a positive number');
}
if (mappedArgs.vesselName && typeof mappedArgs.vesselName !== 'string') {
errors.push('Vessel name must be a string');
}
if (mappedArgs.importance && (typeof mappedArgs.importance !== 'number' || mappedArgs.importance < 0 || mappedArgs.importance > 100)) {
errors.push('Importance must be a number between 0 and 100');
}
if (mappedArgs.category && !['General', 'LocationAndCargoActivity', 'FleetAlert', 'SmartShipAlert'].includes(mappedArgs.category)) {
errors.push(`Invalid category. Must be one of: General, LocationAndCargoActivity, FleetAlert, SmartShipAlert`);
}
if (mappedArgs.status && !['active', 'resolved'].includes(mappedArgs.status)) {
errors.push(`Invalid status. Must be one of: active, resolved`);
}
// Validate string fields are not empty
if (mappedArgs.casefile && typeof mappedArgs.casefile === 'string' && mappedArgs.casefile.trim() === '') {
errors.push('Casefile title cannot be empty');
}
if (mappedArgs.currentStatus && typeof mappedArgs.currentStatus === 'string' && mappedArgs.currentStatus.trim() === '') {
errors.push('Current status cannot be empty');
}
if (mappedArgs.flag && typeof mappedArgs.flag === 'string' && mappedArgs.flag.trim() === '') {
errors.push('Flag cannot be empty');
}
if (mappedArgs.summary && typeof mappedArgs.summary === 'string' && mappedArgs.summary.trim() === '') {
errors.push('Summary cannot be empty');
}
if (mappedArgs.pages && Array.isArray(mappedArgs.pages) && mappedArgs.pages.length === 0) {
errors.push('Pages cannot be empty');
}
if (mappedArgs.index && Array.isArray(mappedArgs.index) && mappedArgs.index.length === 0) {
errors.push('Index cannot be empty');
}
// Validate pages and index if provided
if (mappedArgs.pages && Array.isArray(mappedArgs.pages)) {
mappedArgs.pages.forEach((page, i) => {
if (!page || typeof page !== 'object') {
errors.push(`pages[${i}] must be an object`);
return;
}
if (!page.type || typeof page.type !== 'string' || page.type.trim() === '') {
errors.push(`pages[${i}].type is required and cannot be empty`);
}
if (!page.topic || typeof page.type !== 'string' || page.topic.trim() === '') {
errors.push(`pages[${i}].topic is required and cannot be empty`);
}
if (!page.summary || typeof page.summary !== 'string' || page.summary.trim() === '') {
errors.push(`pages[${i}].summary is required and cannot be empty`);
}
});
}
if (mappedArgs.index && Array.isArray(mappedArgs.index)) {
mappedArgs.index.forEach((idx, i) => {
if (!idx || typeof idx !== 'object') {
errors.push(`index[${i}] must be an object`);
return;
}
if (!idx.type || typeof idx.type !== 'string' || idx.type.trim() === '') {
errors.push(`index[${i}].type is required and cannot be empty`);
}
if (!idx.topic || typeof idx.topic !== 'string' || idx.topic.trim() === '') {
errors.push(`index[${i}].topic is required and cannot be empty`);
}
});
}
// Ensure pages and index arrays are consistent in length if both provided
if (Array.isArray(mappedArgs.pages) && Array.isArray(mappedArgs.index)) {
if (mappedArgs.pages.length !== mappedArgs.index.length) {
errors.push(`pages and index must have the same number of entries (got pages=${mappedArgs.pages.length}, index=${mappedArgs.index.length})`);
}
}
// Set default values for optional fields
if (!mappedArgs.status) {
mappedArgs.status = 'active';
}
return {
valid: errors.length === 0,
errors,
mappedArgs
};
}
//# sourceMappingURL=casefileUtils.js.map