UNPKG

json-object-editor

Version:

JOE the Json Object Editor | Platform Edition

705 lines (628 loc) 33.2 kB
const OpenAI = require("openai"); const MCP = require("../modules/MCP.js"); function IntentOperator() { var self = this; function coloredLog(message) { console.log(JOE.Utils.color('[intent-operator]', 'plugin', false), message); } function getAPIKey() { const setting = JOE.Utils.Settings('OPENAI_API_KEY'); if (!setting) throw new Error("Missing OPENAI_API_KEY setting"); return setting; } function newClient() { return new OpenAI({ apiKey: getAPIKey() }); } /** * Default intents (hard-coded) */ const DEFAULT_INTENTS = [ { name: 'create_object', description: 'User wants to create a new object', handoff: 'handoff_worker', examples: ['add a task', 'create a project', 'new task called clean my room'], operators: ['add', 'create', 'new', 'make'], required_information: ['primary_itemtype','name of item'] }, { name: 'update_object', description: 'Modify existing object', handoff: 'handoff_worker', examples: ['update the status', 'change the name', 'edit task abc123'], operators: ['update', 'edit', 'change', 'modify'], required_information: ['primary_itemtype', 'object_id'] }, { name: 'search', description: 'Retrieve or list objects', handoff: 'handoff_search', examples: ['find all projects', 'show me tasks', 'list users'], operators: ['find', 'search', 'show', 'list', 'get'], required_information: [] }, { name: 'chat', description: 'Conversational/help/explanation', handoff: 'handoff_chat', examples: ['what can you help me with?', 'explain how this works', 'help me'], operators: ['ask', 'help', 'explain', 'what', 'how'], required_information: [] }, { name: 'autofill', description: 'Generate field values', handoff: 'handoff_autofill', examples: ['generate a name', 'fill in the description', 'suggest tags'], operators: ['generate', 'fill', 'suggest', 'autofill'], required_information: ['primary_itemtype'] }, { name: 'analyze', description: 'Analytical reasoning over objects', handoff: 'handoff_worker', examples: ['analyze the data', 'summarize projects', 'count tasks'], operators: ['analyze', 'summarize', 'count', 'aggregate'], required_information: ['primary_itemtype'] } ]; /** * Load and merge intents from setting with defaults */ function loadIntents() { const intents = DEFAULT_INTENTS.slice(); // Copy defaults const intentMap = {}; // Create map of default intents by name intents.forEach(function(intent) { intentMap[intent.name] = intent; }); // Load custom intents from setting try { const customIntents = JOE.Utils.Settings('ai_intents'); if (Array.isArray(customIntents) && customIntents.length > 0) { customIntents.forEach(function(custom) { if (!custom || !custom.name) return; // Skip invalid entries if (intentMap[custom.name]) { // Overwrite existing intent const index = intents.findIndex(function(i) { return i.name === custom.name; }); if (index !== -1) { intents[index] = Object.assign({}, intents[index], custom, { overwrite: true }); } } else { // Add new intent intents.push(Object.assign({}, custom, { overwrite: false })); } }); } } catch (e) { coloredLog('Warning: Failed to load ai_intents setting: ' + e.message); } return intents; } /** * Create an empty intent object structure */ function createEmptyIntent() { return { intent: null, operator: null, operator_span: null, primary_itemtype: null, secondary_itemtypes: [], needs_clarification: true, questions: [], confidence: 0, next_step: null }; } /** * Calculate elapsed time in seconds with 3 decimal places */ function calculateElapsed(startTime) { return parseFloat(((Date.now() - startTime) / 1000).toFixed(3)); } /** * Create a standardized error response */ function createErrorResponse(startTime, errors, additionalFields) { return { intent: createEmptyIntent(), errors: Array.isArray(errors) ? errors : [errors], failedat: 'intent-operator', elapsed: calculateElapsed(startTime), ...additionalFields }; } /** * Build context pack from all loaded schemas and MCP tools */ function buildContextPack() { const summaries = JOE.Schemas.summary || {}; const allowed_itemtypes = Object.keys(summaries).sort(); const itemtype_fields = {}; // Build itemtype_relationships from schema summaries const itemtype_relationships = {}; allowed_itemtypes.forEach(function(itemtype) { const summary = summaries[itemtype]; if (!summary || !Array.isArray(summary.fields)) { itemtype_fields[itemtype] = []; itemtype_relationships[itemtype] = []; return; } // Extract field names, excluding system-managed fields const fieldNames = summary.fields .map(function(f) { return f && f.name; }) .filter(function(name) { return name && name !== '_id' && name !== 'itemtype' && name !== 'joeUpdated' && name !== 'created'; }); itemtype_fields[itemtype] = fieldNames; // Extract relationships from summary.relationships.outbound if (summary.relationships && Array.isArray(summary.relationships.outbound)) { const targetSchemas = summary.relationships.outbound.map(function(rel) { return rel && rel.targetSchema; }).filter(function(schema) { return schema && allowed_itemtypes.indexOf(schema) !== -1; }); itemtype_relationships[itemtype] = targetSchemas; } else { itemtype_relationships[itemtype] = []; } }); // Get MCP tools from minimal toolset (slimmed, safe subset) const mcpToolNames = MCP && MCP.getToolNamesForToolset ? MCP.getToolNamesForToolset('minimal', null) : []; const mcpTools = []; if (mcpToolNames.length > 0 && MCP && MCP.descriptions) { mcpToolNames.forEach(function(toolName) { const description = MCP.descriptions[toolName] || ''; mcpTools.push({ name: toolName, description: description }); }); } return { allowed_itemtypes: allowed_itemtypes, itemtype_fields: itemtype_fields, itemtype_relationships: itemtype_relationships, mcp_tools: mcpTools }; } /** * Infer schemas_to_load from itemtypes and relationships */ function inferSchemasToLoad(primaryItemtype, secondaryItemtypes, contextPack) { const schemas = new Set(); // Add primary and secondary itemtypes if (primaryItemtype) { schemas.add(primaryItemtype); } if (Array.isArray(secondaryItemtypes)) { secondaryItemtypes.forEach(function(it) { if (it) schemas.add(it); }); } // Add all target schemas from relationships if (primaryItemtype && contextPack.itemtype_relationships) { const relationships = contextPack.itemtype_relationships[primaryItemtype] || []; relationships.forEach(function(targetSchema) { if (targetSchema) schemas.add(targetSchema); }); } // Add relationships from secondary itemtypes too if (Array.isArray(secondaryItemtypes) && contextPack.itemtype_relationships) { secondaryItemtypes.forEach(function(secondary) { if (secondary) { const relationships = contextPack.itemtype_relationships[secondary] || []; relationships.forEach(function(targetSchema) { if (targetSchema) schemas.add(targetSchema); }); } }); } return Array.from(schemas).sort(); } /** * Extract JSON from response text (handles markdown code blocks) */ function extractJsonText(text) { if (!text) return null; // Try to find JSON in markdown code blocks const jsonMatch = text.match(/```(?:json)?\s*(\{[\s\S]*\})\s*```/); if (jsonMatch) return jsonMatch[1]; // Try to find JSON object directly const objMatch = text.match(/\{[\s\S]*\}/); if (objMatch) return objMatch[0]; return null; } /** * Validate and normalize intent response */ function validateIntent(rawResponse, contextPack, intents) { const errors = []; const warnings = []; const allowedIntents = intents.map(function(i) { return i.name; }); const allowedItemtypes = contextPack.allowed_itemtypes || []; // Ensure required fields exist const result = { intent: rawResponse.intent || null, operator: rawResponse.operator || null, operator_span: rawResponse.operator_span || null, primary_itemtype: rawResponse.primary_itemtype || null, secondary_itemtypes: Array.isArray(rawResponse.secondary_itemtypes) ? rawResponse.secondary_itemtypes : [], needs_clarification: rawResponse.needs_clarification === true, questions: Array.isArray(rawResponse.questions) ? rawResponse.questions : [], confidence: typeof rawResponse.confidence === 'number' ? Math.max(0, Math.min(1, rawResponse.confidence)) : 0, next_step: rawResponse.next_step || null }; // Extract extracted_raw for validation (will be moved to next_step.data) const extracted_raw = (rawResponse.extracted_raw && typeof rawResponse.extracted_raw === 'object') ? rawResponse.extracted_raw : {}; // Validate intent if (!result.intent) { errors.push('Missing required field: intent'); } else if (allowedIntents.indexOf(result.intent) === -1) { errors.push('Invalid intent: ' + result.intent + ' (must be one of: ' + allowedIntents.join(', ') + ')'); result.intent = null; } // Validate primary_itemtype if (result.primary_itemtype && allowedItemtypes.indexOf(result.primary_itemtype) === -1) { warnings.push('primary_itemtype "' + result.primary_itemtype + '" not in allowed list; setting to null'); result.primary_itemtype = null; } // Filter secondary_itemtypes const validSecondary = result.secondary_itemtypes.filter(function(it) { return allowedItemtypes.indexOf(it) !== -1; }); if (validSecondary.length !== result.secondary_itemtypes.length) { warnings.push('Some secondary_itemtypes were filtered out (not in allowed list)'); } result.secondary_itemtypes = validSecondary; // Check semantic consistency between primary_itemtype and secondary_itemtypes if (result.primary_itemtype && result.secondary_itemtypes.length > 0 && contextPack.itemtype_relationships) { const primaryRelationships = contextPack.itemtype_relationships[result.primary_itemtype] || []; const mismatches = result.secondary_itemtypes.filter(function(secondary) { // Check if primary can reference this secondary itemtype return primaryRelationships.indexOf(secondary) === -1; }); if (mismatches.length > 0) { warnings.push('Semantic mismatch: ' + result.primary_itemtype + ' does not reference ' + mismatches.join(', ') + ' (valid references: ' + (primaryRelationships.length > 0 ? primaryRelationships.join(', ') : 'none') + ')'); // Force clarification when semantic mismatch detected if (!result.needs_clarification) { result.needs_clarification = true; } if (result.questions.length === 0) { result.questions.push('Did you mean a different itemtype? ' + result.primary_itemtype + ' typically relates to ' + (primaryRelationships.length > 0 ? primaryRelationships.join(', ') : 'nothing') + ', but you mentioned ' + mismatches.join(', ') + '.'); } } } // Filter extracted fields by field whitelist let validatedExtracted = {}; if (result.primary_itemtype && contextPack.itemtype_fields[result.primary_itemtype]) { const allowedFields = contextPack.itemtype_fields[result.primary_itemtype]; const rawKeys = Object.keys(extracted_raw); rawKeys.forEach(function(key) { if (allowedFields.indexOf(key) === -1) { warnings.push('Field "' + key + '" in extracted_raw not in whitelist for ' + result.primary_itemtype); } else { validatedExtracted[key] = extracted_raw[key]; } }); } else { validatedExtracted = extracted_raw; } // Set next_step based on intent if missing if (result.intent && !result.next_step) { const intentDef = intents.find(function(i) { return i.name === result.intent; }); if (intentDef && intentDef.handoff) { // Convert handoff string to structured object result.next_step = { handler: intentDef.handoff, prompt: null, data: null }; } } // Validate next_step structure if present if (result.next_step) { if (typeof result.next_step === 'string') { // Legacy string format - convert to structured result.next_step = { handler: result.next_step, prompt: null, data: null }; } else if (typeof result.next_step === 'object' && result.next_step !== null) { // Ensure structured format has required fields if (!result.next_step.handler) { result.next_step.handler = null; } if (result.next_step.prompt === undefined) { result.next_step.prompt = null; } if (result.next_step.data === undefined) { result.next_step.data = null; } } else { result.next_step = null; } } // Move extracted_raw to next_step.data (if next_step exists) if (result.next_step && Object.keys(validatedExtracted).length > 0) { // Merge with existing data if present, otherwise set to extracted fields if (result.next_step.data && typeof result.next_step.data === 'object') { result.next_step.data = Object.assign({}, result.next_step.data, validatedExtracted); } else { result.next_step.data = validatedExtracted; } } return { intent: result, errors: errors, warnings: warnings }; } /** * Validate objects_to_resolve array */ function validateObjectsToResolve(objectsToResolve, contextPack, primaryItemtype) { const errors = []; const warnings = []; const validated = []; if (!Array.isArray(objectsToResolve)) { return { objects: [], errors: ['objects_to_resolve must be an array'], warnings: [] }; } objectsToResolve.forEach(function(obj, index) { if (!obj || typeof obj !== 'object') { warnings.push('objects_to_resolve[' + index + '] is not a valid object'); return; } const itemtype = obj.itemtype; const name = obj.name; const field = obj.field; if (!itemtype || typeof itemtype !== 'string') { errors.push('objects_to_resolve[' + index + '] missing or invalid itemtype'); return; } if (!name || typeof name !== 'string') { errors.push('objects_to_resolve[' + index + '] missing or invalid name'); return; } if (!field || typeof field !== 'string') { errors.push('objects_to_resolve[' + index + '] missing or invalid field'); return; } // Validate itemtype exists if (contextPack.allowed_itemtypes.indexOf(itemtype) === -1) { warnings.push('objects_to_resolve[' + index + '] itemtype "' + itemtype + '" not in allowed list'); } // Validate field exists and is a reference field for primary_itemtype if (primaryItemtype && contextPack.itemtype_fields[primaryItemtype]) { const allowedFields = contextPack.itemtype_fields[primaryItemtype]; if (allowedFields.indexOf(field) === -1) { warnings.push('objects_to_resolve[' + index + '] field "' + field + '" not in allowed fields for ' + primaryItemtype); } } // Build validated object const validatedObj = { itemtype: itemtype, name: name, field: field, operation: obj.operation || 'link', purpose: obj.purpose || 'Resolve ' + itemtype + ' name "' + name + '" to _id for ' + field + ' field' }; validated.push(validatedObj); }); return { objects: validated, errors: errors, warnings: warnings }; } /** * Main handler: classify intent from natural language */ this.default = async function(data, req, res) { const startTime = Date.now(); try { const text = (data && data.text) || ''; if (!text || typeof text !== 'string' || !text.trim()) { return createErrorResponse(startTime, ['Missing or empty "text" parameter']); } // Build context pack const contextPack = buildContextPack(); // Check if context pack is empty if (!contextPack.allowed_itemtypes || contextPack.allowed_itemtypes.length === 0) { return createErrorResponse(startTime, ['No schemas loaded - context pack is empty']); } coloredLog('Context pack: ' + contextPack.allowed_itemtypes.length + ' itemtypes'); // Log prompt size for debugging const contextPackSize = JSON.stringify(contextPack.itemtype_fields).length; coloredLog('Context pack size: ' + contextPackSize + ' characters'); // Load intents (used for both prompt and validation) const intents = loadIntents(); const allowedIntents = intents.map(function(i) { return i.name; }); // Build intent descriptions for prompt (including required_information) const intentDescriptions = intents.map(function(i) { let desc = i.name + ': ' + i.description; if (i.required_information && Array.isArray(i.required_information) && i.required_information.length > 0) { desc += ' (requires: ' + i.required_information.join(', ') + ')'; } return desc; }).join('\n'); // Build MCP tools description for prompt let mcpToolsText = ''; if (contextPack.mcp_tools && contextPack.mcp_tools.length > 0) { mcpToolsText = '\nAvailable MCP tools:\n' + contextPack.mcp_tools.map(function(tool) { return '- ' + tool.name + ': ' + tool.description; }).join('\n') + '\n'; } // Build relationships description for prompt let relationshipsText = ''; if (contextPack.itemtype_relationships) { relationshipsText = '\nItemtype relationships (which itemtypes can reference which):\n'; Object.keys(contextPack.itemtype_relationships).forEach(function(itemtype) { const refs = contextPack.itemtype_relationships[itemtype]; if (refs && refs.length > 0) { relationshipsText += '- ' + itemtype + ' can reference: ' + refs.join(', ') + '\n'; } }); relationshipsText += '\n'; } // Build prompt const prompt = [ 'You are JOE (Json Object Editor) Intent Classifier.', '', 'Task: Analyze the user input and return a structured intent JSON object.', '', 'Allowed intents:', intentDescriptions, '', 'Intent names: ' + allowedIntents.join(', '), '', 'Available itemtypes: ' + contextPack.allowed_itemtypes.join(', '), '', 'For each itemtype, allowed fields:', JSON.stringify(contextPack.itemtype_fields, null, 2), relationshipsText, mcpToolsText, 'Output format (JSON only, no markdown):', '{', ' "intent": "one of the allowed intents",', ' "operator": "normalized verb label (e.g., add, create, edit, find, show)",', ' "operator_span": "literal phrase from input (optional)",', ' "primary_itemtype": "main object type being acted on (or null)",', ' "secondary_itemtypes": ["related object types"],', ' "needs_clarification": false,', ' "questions": [],', ' "confidence": 0.0,', ' "next_step": { "handler": "handler_name", "prompt": "optional prompt text", "data": { "field_name": "extracted value" } },', ' "execution_plan": ["step 1", "step 2", "step 3"],', ' "objects_to_resolve": [', ' { "itemtype": "project", "name": "home", "field": "project", "operation": "link", "purpose": "Resolve project name to _id" }', ' ],', ' "schemas_to_load": ["task", "project", "status"]', '}', '', 'Rules:', '- Choose intent from allowed list only.', '- Choose itemtypes from available list only.', '- CRITICAL: Check semantic consistency using the itemtype_relationships data. If the input mentions a secondary itemtype (e.g., "project"), ensure your primary_itemtype can reference it. If primary_itemtype cannot reference the mentioned secondary_itemtype, set needs_clarification=true and add a clarifying question.', '- CRITICAL: If you detect a typo or uncertain word that might be an itemtype (e.g., "tack" might be "task", "projct" might be "project"), set needs_clarification=true and add a question like "Did you mean \'task\' instead of \'tack\'?"', '- CRITICAL: If there is a semantic mismatch (e.g., primary_itemtype="page" but context mentions "project", when page references "site" not "project"), set needs_clarification=true and add a clarifying question.', '- Only extract fields that exist in the field whitelist for the primary_itemtype.', '- Put extracted field values in next_step.data (as an object with field names as keys).', '- CRITICAL: Identify objects that need resolution: If a field value in next_step.data is a name (not an _id/CUID format), check the itemtype_relationships for the primary_itemtype. If the field name matches a relationship target (e.g., if task can reference "project" and you have field "project": "home"), then "home" is a name that needs fuzzy search to get _id. Add to objects_to_resolve with: itemtype (the targetSchema from relationships), name (the value), field (field name), operation (link/update/reference), and purpose.', '- Example: If next_step.data has { "project": "home" } and primary_itemtype is "task", check itemtype_relationships["task"]. If it includes "project", then add { "itemtype": "project", "name": "home", "field": "project", "operation": "link", "purpose": "Resolve project name \'home\' to _id for linking task" } to objects_to_resolve.', '- Only add to objects_to_resolve if the value looks like a name (not a CUID - CUIDs are long alphanumeric strings like "clx123abc456def789").', '- CRITICAL: For schemas_to_load, include primary_itemtype, secondary_itemtypes, and all targetSchema values from itemtype_relationships. You can also add additional schemas if needed for the operation (e.g., if you need to validate field types).', '- Set needs_clarification=true if ambiguous or missing required info (check required_information for each intent).', '- Set confidence conservatively - if itemtype is uncertain, there are typos, or semantic mismatches, use lower confidence (0.3-0.6) and set needs_clarification=true.', '- next_step should be a structured object with handler (the handoff handler name), prompt (optional descriptive text), and data (object with extracted field values).', '- Always generate execution_plan as an array of strings describing the steps needed.', '- In execution_plan, reference MCP tools consistently using format: "Call MCP tool: toolName" (e.g., "Call MCP tool: getObject to fetch task abc123", "Call MCP tool: saveObject to persist the task").', '- Use real schema names and field names from the context pack.', '- Keep steps at medium detail level (e.g., "Create a task object", "Set name field to clean my room", "Call MCP tool: saveObject to persist").', '- Even if clarification is needed, describe what you think the plan should be.', '', 'User input: ' + text ].join('\n'); // Call OpenAI // Using gpt-4o-mini as it's proven fast and available (gpt-5-nano may not exist yet) const openai = newClient(); const apiStartTime = Date.now(); const response = await openai.responses.create({ //model: 'gpt-4o-mini', model: 'gpt-4.1-nano', instructions: 'You are a JSON-only output assistant. Return only valid JSON, no markdown, no explanations.', input: prompt }); const apiElapsed = ((Date.now() - apiStartTime) / 1000).toFixed(3); coloredLog('OpenAI API call took: ' + apiElapsed + ' seconds'); const rawText = response.output_text || ''; const jsonText = extractJsonText(rawText); if (!jsonText) { return createErrorResponse(startTime, ['Failed to extract JSON from model response'], { raw_response: rawText.substring(0, 500) }); } let rawResponse; try { rawResponse = JSON.parse(jsonText); } catch (e) { return createErrorResponse(startTime, ['Failed to parse JSON: ' + e.message], { raw_response: rawText.substring(0, 500) }); } // Validate and normalize (using intents loaded above) const validated = validateIntent(rawResponse, contextPack, intents); // Extract execution_plan from raw response (not from intent object) const execution_plan = Array.isArray(rawResponse.execution_plan) ? rawResponse.execution_plan : []; // Validate objects_to_resolve const objectsToResolveRaw = Array.isArray(rawResponse.objects_to_resolve) ? rawResponse.objects_to_resolve : []; const objectsToResolveValidated = validateObjectsToResolve(objectsToResolveRaw, contextPack, validated.intent.primary_itemtype); // Infer schemas_to_load and merge with model-provided ones const inferredSchemas = inferSchemasToLoad( validated.intent.primary_itemtype, validated.intent.secondary_itemtypes, contextPack ); const modelSchemas = Array.isArray(rawResponse.schemas_to_load) ? rawResponse.schemas_to_load : []; const allSchemas = new Set([...inferredSchemas, ...modelSchemas]); const schemas_to_load = Array.from(allSchemas).sort(); // Combine errors and warnings const allErrors = [...validated.errors, ...objectsToResolveValidated.errors]; const allWarnings = [...validated.warnings, ...objectsToResolveValidated.warnings]; const result = { raw_request: text, intent: validated.intent, execution_plan: execution_plan, objects_to_resolve: objectsToResolveValidated.objects.length > 0 ? objectsToResolveValidated.objects : undefined, schemas_to_load: schemas_to_load.length > 0 ? schemas_to_load : undefined, errors: allErrors.length > 0 ? allErrors : undefined, warnings: allWarnings.length > 0 ? allWarnings : undefined, validation_log: (allErrors.length > 0 || allWarnings.length > 0) ? { errors: allErrors, warnings: allWarnings } : undefined, elapsed: calculateElapsed(startTime) }; return result; } catch (e) { coloredLog('Error: ' + e.message); return createErrorResponse(startTime, ['Plugin error: ' + e.message]); } }; /** * API endpoint: Get all intents (defaults + custom from setting) */ this.intents = function(data, req, res) { try { const intents = loadIntents(); return { intents: intents, count: intents.length, defaults_count: DEFAULT_INTENTS.length, custom_count: intents.length - DEFAULT_INTENTS.length }; } catch (e) { coloredLog('Error loading intents: ' + e.message); return { errors: ['Failed to load intents: ' + e.message], failedat: 'intent-operator' }; } }; this.async = { default: true }; this.protected = []; return self; } module.exports = new IntentOperator();