From 92bafbdaa7af1d01382dae622a56e8fff9c0b13b Mon Sep 17 00:00:00 2001 From: jango-blockchained Date: Sun, 2 Feb 2025 23:29:37 +0100 Subject: [PATCH] Enhance OpenAI Test Script with Advanced Model Selection and Error Handling - Added comprehensive model configuration and selection mechanism - Implemented dynamic model handling for OpenAI and DeepSeek providers - Enhanced error handling and retry logic for API interactions - Expanded system analysis with more detailed XML-based response parsing - Updated configuration loading with improved model selection and validation - Added colored logging and more informative console output - Integrated new test suite for API endpoints --- __tests__/api/index.test.ts | 163 +++++++ jest.config.cjs | 3 +- openai_test.ts | 821 +++++++++++++++++++++++++----------- package.json | 4 +- 4 files changed, 740 insertions(+), 251 deletions(-) create mode 100644 __tests__/api/index.test.ts diff --git a/__tests__/api/index.test.ts b/__tests__/api/index.test.ts new file mode 100644 index 0000000..f6a31e3 --- /dev/null +++ b/__tests__/api/index.test.ts @@ -0,0 +1,163 @@ +import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import express from 'express'; +import request from 'supertest'; +import { config } from 'dotenv'; +import { resolve } from 'path'; +import type { Entity } from '../../src/types/hass'; +import { TokenManager } from '../../src/security/index'; +import { MCP_SCHEMA } from '../../src/mcp/schema'; + +// Load test environment variables +config({ path: resolve(process.cwd(), '.env.test') }); + +// Mock dependencies +jest.mock('../../src/security/index', () => ({ + TokenManager: { + validateToken: jest.fn().mockImplementation((token) => token === 'valid-test-token'), + }, + rateLimiter: (req: any, res: any, next: any) => next(), + securityHeaders: (req: any, res: any, next: any) => next(), + validateRequest: (req: any, res: any, next: any) => next(), + sanitizeInput: (req: any, res: any, next: any) => next(), + errorHandler: (err: any, req: any, res: any, next: any) => { + res.status(500).json({ error: err.message }); + }, +})); + +// Create mock entity +const mockEntity: Entity = { + entity_id: 'light.living_room', + state: 'off', + attributes: {}, + last_changed: new Date().toISOString(), + last_updated: new Date().toISOString(), + context: { + id: '123', + parent_id: null, + user_id: null + } +}; + +// Mock Home Assistant module +jest.mock('../../src/hass/index'); + +// Mock LiteMCP +jest.mock('litemcp', () => ({ + LiteMCP: jest.fn().mockImplementation(() => ({ + name: 'home-assistant', + version: '0.1.0', + tools: [] + })) +})); + +// Create Express app for testing +const app = express(); +app.use(express.json()); + +// Add test routes that mimic our actual routes +app.get('/mcp', (_req, res) => { + res.json(MCP_SCHEMA); +}); + +app.get('/state', (req, res) => { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ') || authHeader.split(' ')[1] !== 'valid-test-token') { + return res.status(401).json({ error: 'Unauthorized' }); + } + res.json([mockEntity]); +}); + +app.post('/command', (req, res) => { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ') || authHeader.split(' ')[1] !== 'valid-test-token') { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const { command, entity_id } = req.body; + if (!command || !entity_id) { + return res.status(400).json({ error: 'Missing required parameters' }); + } + + if (command === 'invalid_command') { + return res.status(400).json({ error: 'Invalid command' }); + } + + res.json({ success: true }); +}); + +describe('API Endpoints', () => { + describe('GET /mcp', () => { + it('should return MCP schema without authentication', async () => { + const response = await request(app) + .get('/mcp') + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toBeDefined(); + expect(response.body).toHaveProperty('name'); + expect(response.body).toHaveProperty('version'); + expect(response.body).toHaveProperty('tools'); + }); + }); + + describe('Protected Endpoints', () => { + describe('GET /state', () => { + it('should return 401 without authentication', async () => { + await request(app) + .get('/state') + .expect(401); + }); + + it('should return state with valid token', async () => { + const response = await request(app) + .get('/state') + .set('Authorization', 'Bearer valid-test-token') + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toBeDefined(); + expect(Array.isArray(response.body)).toBeTruthy(); + expect(response.body[0]).toHaveProperty('entity_id', 'light.living_room'); + expect(response.body[0]).toHaveProperty('state', 'off'); + }); + }); + + describe('POST /command', () => { + it('should return 401 without authentication', async () => { + await request(app) + .post('/command') + .send({ + command: 'turn_on', + entity_id: 'light.living_room' + }) + .expect(401); + }); + + it('should process valid command with authentication', async () => { + const response = await request(app) + .set('Authorization', 'Bearer valid-test-token') + .post('/command') + .send({ + command: 'turn_on', + entity_id: 'light.living_room' + }) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toBeDefined(); + expect(response.body).toHaveProperty('success', true); + }); + + it('should validate command parameters', async () => { + await request(app) + .post('/command') + .set('Authorization', 'Bearer valid-test-token') + .send({ + command: 'invalid_command', + entity_id: 'light.living_room' + }) + .expect(400); + }); + }); + }); +}); \ No newline at end of file diff --git a/jest.config.cjs b/jest.config.cjs index 5b83682..f4bf8aa 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -28,7 +28,8 @@ module.exports = { '**/__tests__/helpers.test.ts', '**/__tests__/schemas/devices.test.ts', '**/__tests__/context/index.test.ts', - '**/__tests__/hass/index.test.ts' + '**/__tests__/hass/index.test.ts', + '**/__tests__/api/index.test.ts' ], globals: { 'ts-jest': { diff --git a/openai_test.ts b/openai_test.ts index c5ee89f..858b24c 100644 --- a/openai_test.ts +++ b/openai_test.ts @@ -3,6 +3,9 @@ import OpenAI from "openai"; import { DOMParser, Element, Document } from '@xmldom/xmldom'; import dotenv from 'dotenv'; import readline from 'readline'; +import chalk from 'chalk'; +import express from 'express'; +import bodyParser from 'body-parser'; // Load environment variables dotenv.config(); @@ -21,10 +24,6 @@ if (!hassToken) { process.exit(1); } -const openai = new OpenAI({ - apiKey: openaiApiKey, -}); - // MCP Server configuration const MCP_SERVER = process.env.MCP_SERVER || 'http://localhost:3000'; @@ -43,8 +42,8 @@ interface ToolsResponse { interface SystemAnalysis { overview: { - state: string; - health: string; + state: string[]; + health: string[]; configurations: string[]; integrations: string[]; issues: string[]; @@ -70,6 +69,22 @@ interface SystemAnalysis { cleanup_tasks: string[]; regular_tasks: string[]; }; + entity_usage: { + most_active: string[]; + rarely_used: string[]; + potential_duplicates: string[]; + }; + automation_analysis: { + inefficient_automations: string[]; + potential_improvements: string[]; + suggested_blueprints: string[]; + condition_optimizations: string[]; + }; + energy_management?: { + high_consumption: string[]; + monitoring_suggestions: string[]; + tariff_optimizations: string[]; + }; } interface McpSchema { @@ -93,36 +108,154 @@ interface ListDevicesResponse { devices?: Record; } +// Add model configuration interface +interface ModelConfig { + name: string; + maxTokens: number; + contextWindow: number; +} + +// Update model listing to filter based on API key availability +const AVAILABLE_MODELS: ModelConfig[] = [ + // OpenAI models always available + { name: 'gpt-4o', maxTokens: 4096, contextWindow: 128000 }, + { name: 'gpt-4-turbo', maxTokens: 4096, contextWindow: 128000 }, + { name: 'gpt-4', maxTokens: 8192, contextWindow: 128000 }, + { name: 'gpt-3.5-turbo', maxTokens: 4096, contextWindow: 16385 }, + { name: 'gpt-3.5-turbo-16k', maxTokens: 16385, contextWindow: 16385 }, + + // Conditionally include DeepSeek models + ...(process.env.DEEPSEEK_API_KEY ? [ + { name: 'deepseek-v3', maxTokens: 4096, contextWindow: 128000 }, + { name: 'deepseek-r1', maxTokens: 4096, contextWindow: 1000000 } + ] : []) +]; + +// Add configuration interface +interface AppConfig { + mcpServer: string; + openaiModel: string; + maxRetries: number; + analysisTimeout: number; + selectedModel: ModelConfig; +} + +// Add colored logging functions +const logger = { + info: (msg: string) => console.log(chalk.blue(`ℹ ${msg}`)), + success: (msg: string) => console.log(chalk.green(`✓ ${msg}`)), + warn: (msg: string) => console.log(chalk.yellow(`⚠ ${msg}`)), + error: (msg: string) => console.log(chalk.red(`✗ ${msg}`)), + debug: (msg: string) => process.env.DEBUG && console.log(chalk.gray(`› ${msg}`)) +}; + +// Update default model selection in loadConfig +function loadConfig(): AppConfig { + // Use environment variable or default to gpt-4o + const defaultModelName = process.env.OPENAI_MODEL || 'gpt-4o'; + let defaultModel = AVAILABLE_MODELS.find(m => m.name === defaultModelName); + + // If the configured model isn't found, use gpt-4o without warning + if (!defaultModel) { + defaultModel = AVAILABLE_MODELS.find(m => m.name === 'gpt-4o') || AVAILABLE_MODELS[0]; + } + + return { + mcpServer: process.env.MCP_SERVER || 'http://localhost:3000', + openaiModel: defaultModel.name, // Use the resolved model name + maxRetries: parseInt(process.env.MAX_RETRIES || '3'), + analysisTimeout: parseInt(process.env.ANALYSIS_TIMEOUT || '30000'), + selectedModel: defaultModel + }; +} + +function getOpenAIClient(): OpenAI { + const config = loadConfig(); + + return new OpenAI({ + apiKey: config.selectedModel.name.startsWith('deepseek') + ? process.env.DEEPSEEK_API_KEY + : openaiApiKey, + baseURL: config.selectedModel.name.startsWith('deepseek') + ? 'https://api.deepseek.com/v1' + : 'https://api.openai.com/v1' + }); +} + /** * Executes a tool on the MCP server */ async function executeMcpTool(toolName: string, parameters: Record = {}): Promise { - try { - const response = await fetch(`${MCP_SERVER}/mcp/execute`, { - method: "POST", - headers: { - 'Authorization': `Bearer ${hassToken}`, - 'Content-Type': "application/json", - 'Accept': 'application/json' - }, - body: JSON.stringify({ - tool: toolName, - parameters - }) - }); + const config = loadConfig(); + let attempt = 0; - if (response.ok) { - return await response.json(); + while (attempt <= config.maxRetries) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), config.analysisTimeout); + + // Update endpoint URL to use the same base path as schema + const endpoint = `${config.mcpServer}/mcp/execute`; + + const response = await fetch(endpoint, { + method: "POST", + headers: { + 'Authorization': `Bearer ${hassToken}`, + 'Content-Type': "application/json", + 'Accept': 'application/json' + }, + body: JSON.stringify({ tool: toolName, parameters }), + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (response.ok) { + const data = await response.json(); + if (!isMcpExecuteResponse(data)) { + throw new Error('Invalid MCP response structure'); + } + return data; + } + + if (response.status === 429) { + const retryAfter = response.headers.get('Retry-After') || '1'; + await new Promise(resolve => setTimeout(resolve, parseInt(retryAfter) * 1000)); + continue; + } + + if (response.status === 404) { + logger.error(`Endpoint not found: ${endpoint}`); + return { success: false, message: 'Endpoint not found' }; + } + + if (response.status >= 500) { + logger.warn(`Server error (${response.status}), retrying...`); + attempt++; + continue; + } + + handleHttpError(response.status); + return { success: false, message: `HTTP error ${response.status}` }; + + } catch (error) { + if (error.name === 'AbortError') { + logger.warn(`Request timed out, retrying (${attempt + 1}/${config.maxRetries})...`); + attempt++; + continue; + } + logger.error(`Error executing tool ${toolName}: ${error.message}`); + return { success: false, message: error.message }; } - console.warn(`Failed to execute tool ${toolName}: ${response.status}`); - if (response.status === 401) { - console.error("Authentication failed. Please check your HASS_TOKEN."); - } - return null; - } catch (error) { - console.warn(`Error executing tool ${toolName}:`, error); - return null; } + return { success: false, message: 'Max retries exceeded' }; +} + +// Add type guard for MCP responses +function isMcpExecuteResponse(obj: any): obj is McpExecuteResponse { + return typeof obj === 'object' && + 'success' in obj && + (obj.success === true || typeof obj.message === 'string'); } /** @@ -130,10 +263,12 @@ async function executeMcpTool(toolName: string, parameters: Record */ async function collectHomeAssistantInfo(): Promise { const info: Record = {}; + const config = loadConfig(); - // First, get the MCP schema which contains available tools - const schemaResponse = await fetch(`${MCP_SERVER}/mcp`, { + // Update schema endpoint to be consistent + const schemaResponse = await fetch(`${config.mcpServer}/mcp`, { headers: { + 'Authorization': `Bearer ${hassToken}`, 'Accept': 'application/json' } }); @@ -171,8 +306,8 @@ function formatAnalysis(analysis: SystemAnalysis): string { return ` === System Overview === -Current State: ${analysis.overview.state} -Health: ${analysis.overview.health} +Current State: ${analysis.overview.state.join(', ')} +Health: ${analysis.overview.health.join(', ')} Notable Configurations: ${formatSection(analysis.overview.configurations)} @@ -228,11 +363,49 @@ ${formatSection(analysis.maintenance.regular_tasks)} `; } +// Update compression function with filtering +function compressHaInfo(haInfo: any, focus?: string): string { + return JSON.stringify(haInfo, (key: string, value: any) => { + // Filter based on device type if focus exists + if (focus && key === 'devices') { + const focusedTypes = getRelevantDeviceTypes(focus); + return Object.fromEntries( + Object.entries(value).filter(([domain]) => + focusedTypes.includes(domain) + ) + ); + } + + // Existing compression logic + if (key === 'attributes') { + return Object.keys(value).length > 0 ? value : undefined; + } + return value; + }, 2); // Added space parameter of 2 for better readability +} + +// Add device type mapping +function getRelevantDeviceTypes(prompt: string): string[] { + const TYPE_MAP: Record = { + light: ['light', 'switch', 'group'], + temperature: ['climate', 'sensor'], + security: ['binary_sensor', 'alarm_control_panel'] + }; + + return Object.entries(TYPE_MAP) + .filter(([keyword]) => prompt.toLowerCase().includes(keyword)) + .flatMap(([, types]) => types); +} + /** * Generates analysis and recommendations using the OpenAI API based on the Home Assistant data */ async function generateAnalysis(haInfo: any): Promise { - // Prepare a summarized version of the data to reduce token count + const openai = getOpenAIClient(); + const config = loadConfig(); + + const compressedInfo = compressHaInfo(haInfo); + const deviceTypes = haInfo.devices ? Object.keys(haInfo.devices) : []; const deviceStates = haInfo.devices ? Object.entries(haInfo.devices).reduce((acc: Record, [domain, devices]) => { acc[domain] = (devices as any[]).length; @@ -256,48 +429,18 @@ async function generateAnalysis(haInfo: any): Promise { }; const prompt = ` -Analyze this Home Assistant device summary and provide a concise analysis in XML format. -Focus on key insights and actionable recommendations. - -Device Summary: -${JSON.stringify(summarizedInfo, null, 2)} - -Provide your analysis in this XML format: +Generate your response EXACTLY in this XML format without any additional text: - - Brief overall state - Brief health assessment - - Key configuration insight - - - Key integration insight - - - Critical issue if any - - - - - Key performance tip - - - Key automation suggestion - - - - - Critical update if needed - - - Key maintenance task - - -`; + + + +HA System Snapshot: ${compressedInfo} +${JSON.stringify(summarizedInfo, null, 2)} +`; try { const completion = await openai.chat.completions.create({ - model: "gpt-4", + model: config.selectedModel.name, messages: [ { role: "system", @@ -305,45 +448,55 @@ Provide your analysis in this XML format: }, { role: "user", content: prompt }, ], - max_tokens: 500, + max_tokens: Math.min(config.selectedModel.maxTokens, 1000), temperature: 0.7, }); const result = completion.choices[0].message?.content || ""; - // Parse XML response into structured data + // Clean the response by removing markdown code blocks + const cleanedResult = result.replace(/```xml/g, '').replace(/```/g, '').trim(); + + // Parse XML response with error handling const parser = new DOMParser(); - const xmlDoc = parser.parseFromString(result, "text/xml"); + let xmlDoc: Document; + try { + xmlDoc = parser.parseFromString(cleanedResult, "text/xml"); - const getItems = (path: string): string[] => { - const items = Array.from(xmlDoc.getElementsByTagName('item')) - .filter(item => { - let parent = item.parentNode; - let pathParts = path.split('>').map(p => p.trim()); - for (let i = pathParts.length - 1; i >= 0; i--) { - if (!parent || parent.nodeName !== pathParts[i]) return false; - parent = parent.parentNode; - } - return true; - }); - return items.map(item => (item as unknown as Element).textContent || ""); - }; - - const getText = (path: string): string => { - const pathParts = path.split('>').map(p => p.trim()); - let currentElement: Document | Element = xmlDoc; - for (const part of pathParts) { - const elements = currentElement.getElementsByTagName(part); - if (elements.length === 0) return ""; - currentElement = elements[0] as Element; + // Validate XML structure + if (xmlDoc.getElementsByTagName('analysis').length === 0) { + throw new Error('Missing root element'); + } + } catch (error) { + logger.error(`XML parsing failed: ${error.message}`); + logger.debug(`Raw AI response:\n${cleanedResult}`); + throw new Error('Failed to parse analysis response'); + } + + // Update the getItems function with fallbacks + const getItems = (path: string): string[] => { + try { + return Array.from(xmlDoc.getElementsByTagName('item')) + .filter(item => { + let parent = item.parentNode; + const pathParts = path.split('>').map(p => p.trim()); + for (let i = pathParts.length - 1; i >= 0; i--) { + if (!parent || parent.nodeName !== pathParts[i]) return false; + parent = parent.parentNode; + } + return true; + }) + .map(item => (item as Element).textContent?.trim() || ""); + } catch (error) { + logger.warn(`Failed to parse ${path}: ${error.message}`); + return []; } - return currentElement.textContent || ""; }; const analysis: SystemAnalysis = { overview: { - state: getText("analysis > overview > state"), - health: getText("analysis > overview > health"), + state: getItems("analysis > overview > state"), + health: getItems("analysis > overview > health"), configurations: getItems("analysis > overview > configurations"), integrations: getItems("analysis > overview > integrations"), issues: getItems("analysis > overview > issues"), @@ -369,6 +522,22 @@ Provide your analysis in this XML format: cleanup_tasks: getItems("analysis > maintenance > cleanup_tasks"), regular_tasks: getItems("analysis > maintenance > regular_tasks"), }, + entity_usage: { + most_active: getItems("analysis > entity_usage > most_active"), + rarely_used: getItems("analysis > entity_usage > rarely_used"), + potential_duplicates: getItems("analysis > entity_usage > potential_duplicates") + }, + automation_analysis: { + inefficient_automations: getItems("analysis > automation_analysis > inefficient_automations"), + potential_improvements: getItems("analysis > automation_analysis > potential_improvements"), + suggested_blueprints: getItems("analysis > automation_analysis > suggested_blueprints"), + condition_optimizations: getItems("analysis > automation_analysis > condition_optimizations") + }, + energy_management: { + high_consumption: getItems("analysis > energy_management > high_consumption"), + monitoring_suggestions: getItems("analysis > energy_management > monitoring_suggestions"), + tariff_optimizations: getItems("analysis > energy_management > tariff_optimizations") + } }; return analysis; @@ -392,187 +561,341 @@ async function getUserInput(question: string): Promise { }); } +// Update chunk size calculation +const MAX_CHARACTERS = 8000; // ~2000 tokens (4 chars/token) + +// Update model handling in retry async function handleCustomPrompt(haInfo: any): Promise { - console.log("\nEnter your custom prompt. Available variables:"); - console.log("- {device_count}: Total number of devices"); - console.log("- {device_types}: List of device types"); - console.log("- {device_states}: Current states of devices"); - console.log("- {device_examples}: Example devices and their states"); - console.log("\nExample: 'Analyze my {device_count} devices and suggest automations for {device_types}'"); + try { + // Add device metadata + const deviceTypes = haInfo.devices ? Object.keys(haInfo.devices) : []; + const deviceStates = haInfo.devices ? Object.entries(haInfo.devices).reduce((acc: Record, [domain, devices]) => { + acc[domain] = (devices as any[]).length; + return acc; + }, {}) : {}; + const totalDevices = deviceTypes.reduce((sum, type) => sum + deviceStates[type], 0); - const customPrompt = await getUserInput("\nEnter your prompt: "); - - // Prepare the data for variable replacement - const deviceTypes = haInfo.devices ? Object.keys(haInfo.devices) : []; - const deviceStates = haInfo.devices ? Object.entries(haInfo.devices).reduce((acc: Record, [domain, devices]) => { - acc[domain] = (devices as any[]).length; - return acc; - }, {}) : {}; - - const totalDevices = deviceTypes.reduce((sum, type) => sum + deviceStates[type], 0); - - // Function to filter relevant devices based on the prompt - const getRelevantDevices = (prompt: string, devices: any) => { - const relevantTypes = deviceTypes.filter(type => - prompt.toLowerCase().includes(type.toLowerCase()) || - type === 'light' && prompt.toLowerCase().includes('lights') || - type === 'switch' && prompt.toLowerCase().includes('switches') - ); - - if (relevantTypes.length === 0) { - // If no specific types mentioned, return a summary of all types - return Object.entries(devices).reduce((acc: any, [domain, deviceList]) => { - acc[domain] = { - count: (deviceList as any[]).length, - example: (deviceList as any[])[0] - }; - return acc; - }, {}); + const userPrompt = await getUserInput("Enter your custom prompt: "); + if (!userPrompt) { + console.log("No prompt provided. Exiting..."); + return; } - return relevantTypes.reduce((acc: any, type) => { - if (devices[type]) { - acc[type] = devices[type]; - } - return acc; - }, {}); - }; + const openai = getOpenAIClient(); + const config = loadConfig(); - const relevantDevices = getRelevantDevices(customPrompt, haInfo.devices); - - // Replace variables in the prompt - let formattedPrompt = ` -Here is the current state of your Home Assistant devices: - -Total Devices: ${totalDevices} -Device Types: ${deviceTypes.join(', ')} - -Relevant Device Information: -${JSON.stringify(relevantDevices, null, 2)} - -User Query: ${customPrompt} - -Please analyze this information and provide a detailed response focusing specifically on what was asked. -If the query is about specific device types, please filter and show only relevant information. -Include specific entity IDs and states in your response when applicable. -`; - - try { const completion = await openai.chat.completions.create({ - model: "gpt-4", + model: config.selectedModel.name, messages: [ { role: "system", - content: `You are an expert Home Assistant analyst with direct access to the current state of a Home Assistant instance. -When analyzing device states: -- Always mention specific entity IDs when discussing devices -- Include current state values and relevant attributes -- If discussing lights, mention brightness levels if available -- For climate devices, include temperature and mode information -- For switches and other binary devices, clearly state if they are on/off -- Group related devices together in your analysis -- Provide specific, actionable insights based on the current states` + content: `You are a Home Assistant expert. Analyze the following Home Assistant information and respond to the user's prompt. + Current system has ${totalDevices} devices across ${deviceTypes.length} types: ${JSON.stringify(deviceStates)}` }, - { role: "user", content: formattedPrompt }, + { role: "user", content: userPrompt }, ], - max_tokens: 1000, + max_tokens: config.selectedModel.maxTokens, temperature: 0.3, }); console.log("\nAnalysis Results:\n"); console.log(completion.choices[0].message?.content || "No response generated"); + } catch (error) { - console.error("Error during OpenAI API call:", error); - if (error instanceof Error && error.message.includes('maximum context length')) { - console.log("\nTrying with more concise data..."); - // Retry with even more summarized data - const summarizedDevices = Object.entries(relevantDevices).reduce((acc: any, [type, devices]) => { - if (Array.isArray(devices)) { - const activeDevices = devices.filter((d: any) => - d.state === 'on' || - d.state === 'home' || - (typeof d.state === 'number' && d.state > 0) - ); + console.error("Error processing custom prompt:", error); - acc[type] = { - total: devices.length, - active: activeDevices.length, - active_devices: activeDevices.map((d: any) => ({ - entity_id: d.entity_id, - state: d.state, - name: d.attributes?.friendly_name || d.entity_id, - ...(d.attributes?.brightness && { brightness: Math.round((d.attributes.brightness / 255) * 100) + '%' }), - ...(d.attributes?.temperature && { temperature: d.attributes.temperature }), - ...(d.attributes?.hvac_mode && { mode: d.attributes.hvac_mode }) - })) - }; - } - return acc; - }, {}); + // Retry with simplified prompt if there's an error + try { + const retryPrompt = "Please provide a simpler analysis of the Home Assistant system."; + const openai = getOpenAIClient(); + const config = loadConfig(); - const retryPrompt = ` -Analyzing Home Assistant devices: -Total Devices: ${totalDevices} -Device Types: ${deviceTypes.join(', ')} + const retryCompletion = await openai.chat.completions.create({ + model: config.selectedModel.name, + messages: [ + { + role: "system", + content: "You are a Home Assistant expert. Provide a simple analysis of the system." + }, + { role: "user", content: retryPrompt }, + ], + max_tokens: config.selectedModel.maxTokens, + temperature: 0.3, + }); -Relevant Device Summary: -${JSON.stringify(summarizedDevices, null, 2)} - -User Query: ${customPrompt} - -Please provide a detailed analysis focusing on active devices. -Include specific device names, states, and any relevant attributes (brightness, temperature, etc.). -Group similar devices together in your response. -`; - - try { - const retryCompletion = await openai.chat.completions.create({ - model: "gpt-4", - messages: [ - { - role: "system", - content: "You are an expert Home Assistant analyst. Provide concise, focused answers about device states and configurations." - }, - { role: "user", content: retryPrompt }, - ], - max_tokens: 1000, - temperature: 0.3, - }); - - console.log("\nAnalysis Results:\n"); - console.log(retryCompletion.choices[0].message?.content || "No response generated"); - } catch (retryError) { - console.error("Error during retry:", retryError); - } + console.log("\nAnalysis Results:\n"); + console.log(retryCompletion.choices[0].message?.content || "No response generated"); + } catch (retryError) { + console.error("Error during retry:", retryError); } } } -async function main() { - console.log("Collecting Home Assistant information..."); - const haInfo = await collectHomeAssistantInfo(); - if (!Object.keys(haInfo).length) { - console.error("Failed to collect any Home Assistant information. Exiting."); - return; +// Add new automation optimization function +async function analyzeAutomations(automations: any[]): Promise { + const openai = getOpenAIClient(); + const config = loadConfig(); + + // Compress automation data by only including essential fields + const compressedAutomations = automations.map(automation => ({ + id: automation.entity_id, + name: automation.attributes?.friendly_name || automation.entity_id, + state: automation.state, + last_triggered: automation.attributes?.last_triggered, + mode: automation.attributes?.mode, + trigger_count: automation.attributes?.trigger?.length || 0, + action_count: automation.attributes?.action?.length || 0 + })); + + const prompt = `Analyze these Home Assistant automations and provide optimization suggestions in XML format: +${JSON.stringify(compressedAutomations, null, 2)} + +Generate your response in this EXACT format: + + + Finding 1 + Finding 2 + + + + Recommendation 1 + Recommendation 2 + + + + Blueprint suggestion 1 + Blueprint suggestion 2 + + + + +Focus on: +1. Identifying patterns and potential improvements +2. Suggesting energy-saving optimizations +3. Recommending error handling improvements +4. Suggesting relevant blueprints`; + + try { + const completion = await openai.chat.completions.create({ + model: config.selectedModel.name, + messages: [ + { + role: "system", + content: "You are a Home Assistant automation expert. Analyze the provided automations and respond with specific, actionable suggestions in the required XML format." + }, + { role: "user", content: prompt } + ], + temperature: 0.2, + max_tokens: Math.min(config.selectedModel.maxTokens, 4000) + }); + + const response = completion.choices[0].message?.content || ""; + + // Ensure the response is valid XML + if (!response.trim().startsWith('')) { + return ` + + + Error: Could not analyze automations + + + Please try again with fewer automations + + + No blueprint suggestions available + +`; + } + + return response; + } catch (error) { + logger.error(`Automation analysis failed: ${error.message}`); + return ` + + + Error: ${error.message} + + + Please try again later + + + No blueprint suggestions available + +`; + } +} + +// Update automation handling +async function handleAutomationOptimization(haInfo: any): Promise { + try { + const result = await executeMcpTool('automation', { action: 'list' }); + if (!result?.success) { + logger.error(`Failed to retrieve automations: ${result?.message || 'Unknown error'}`); + return; + } + + const automations = result.automations || []; + if (automations.length === 0) { + logger.warn("No automations found in the system"); + return; + } + + logger.info(`Analyzing ${automations.length} automations...`); + const optimizationXml = await analyzeAutomations(automations); + + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(optimizationXml, "text/xml"); + + const formatSection = (title: string, items: string[]) => + items.length > 0 ? `${chalk.bold(title)}:\n${items.map(i => ` • ${i}`).join('\n')}` : ''; + + console.log(chalk.bold.underline("\nAutomation Optimization Report")); + console.log(formatSection("Key Findings", getItems(xmlDoc, "analysis > item"))); + console.log(formatSection("\nRecommendations", getItems(xmlDoc, "recommendations > item"))); + console.log(formatSection("\nSuggested Blueprints", getItems(xmlDoc, "blueprints > item"))); + + } catch (error) { + logger.error(`Automation optimization failed: ${error.message}`); + } +} + +// Update model selection prompt count dynamically +async function selectModel(): Promise { + console.log(chalk.bold.underline("\nAvailable Models:")); + AVAILABLE_MODELS.forEach((model, index) => { + console.log( + `${index + 1}. ${chalk.blue(model.name.padEnd(20))} ` + + `Context: ${chalk.yellow(model.contextWindow.toLocaleString().padStart(6))} tokens | ` + + `Max output: ${chalk.green(model.maxTokens.toLocaleString().padStart(5))} tokens` + ); + }); + + const maxOption = AVAILABLE_MODELS.length; + const choice = await getUserInput(`\nSelect model (1-${maxOption}): `); + const selectedIndex = parseInt(choice) - 1; + + if (isNaN(selectedIndex) || selectedIndex < 0 || selectedIndex >= AVAILABLE_MODELS.length) { + console.log(chalk.yellow("Invalid selection, using default model")); + return AVAILABLE_MODELS[0]; } - const mode = await getUserInput( - "\nSelect mode:\n1. Standard Analysis\n2. Custom Prompt\nEnter choice (1 or 2): " - ); + const selectedModel = AVAILABLE_MODELS[selectedIndex]; - if (mode === "2") { - await handleCustomPrompt(haInfo); - } else { - console.log("Generating standard analysis and recommendations..."); + // Validate API keys for specific providers + if (selectedModel.name.startsWith('deepseek')) { + if (!process.env.DEEPSEEK_API_KEY) { + logger.error("DeepSeek models require DEEPSEEK_API_KEY in .env"); + process.exit(1); + } + + // Verify DeepSeek connection try { - const analysis = await generateAnalysis(haInfo); - const formattedAnalysis = formatAnalysis(analysis); - console.log("\nHome Assistant Analysis and Recommendations:\n"); - console.log(formattedAnalysis); + await getOpenAIClient().models.list(); } catch (error) { - console.error("Error generating analysis:", error); + logger.error(`DeepSeek connection failed: ${error.message}`); + process.exit(1); } } + + if (selectedModel.name.startsWith('gpt-4-o') && !process.env.OPENAI_API_KEY) { + logger.error("OpenAI models require OPENAI_API_KEY in .env"); + process.exit(1); + } + + return selectedModel; +} + +// Enhanced main function with progress indicators +async function main() { + let config = loadConfig(); + + // Model selection + config.selectedModel = await selectModel(); + logger.info(`Selected model: ${chalk.blue(config.selectedModel.name)} ` + + `(Context: ${config.selectedModel.contextWindow.toLocaleString()} tokens, ` + + `Output: ${config.selectedModel.maxTokens.toLocaleString()} tokens)`); + + logger.info(`Starting analysis with ${config.selectedModel.name} model...`); + + try { + logger.info("Collecting Home Assistant information..."); + const haInfo = await collectHomeAssistantInfo(); + + if (!Object.keys(haInfo).length) { + logger.error("Failed to collect Home Assistant information"); + return; + } + + logger.success(`Collected data from ${Object.keys(haInfo.devices).length} device types`); + + const mode = await getUserInput( + "\nSelect mode:\n1. Standard Analysis\n2. Custom Prompt\n3. Automation Optimization\nEnter choice (1-3): " + ); + + if (mode === "2") { + await handleCustomPrompt(haInfo); + } else if (mode === "3") { + await handleAutomationOptimization(haInfo); + } else { + logger.info("Generating standard analysis..."); + const analysis = await generateAnalysis(haInfo); + const formattedAnalysis = formatAnalysis(analysis); + console.log("\n" + chalk.bold.underline("Home Assistant Analysis") + "\n"); + console.log(formattedAnalysis); + } + + } catch (error) { + logger.error(`Critical failure: ${error.message}`); + process.exit(1); + } +} + +// Add HTTP error handler +function handleHttpError(status: number): void { + const errors: Record = { + 400: 'Invalid request parameters', + 401: 'Authentication failed - check HASS_TOKEN', + 403: 'Insufficient permissions', + 404: 'Endpoint not found', + 429: 'Too many requests' + }; + + logger.error(errors[status] || `HTTP error ${status}`); +} + +// Add helper function for XML parsing +function getItems(xmlDoc: Document, path: string): string[] { + return Array.from(xmlDoc.getElementsByTagName('item')) + .filter(item => { + let parent = item.parentNode; + const pathParts = path.split('>').reverse(); + for (const part of pathParts) { + if (!parent || parent.nodeName !== part.trim()) return false; + parent = parent.parentNode; + } + return true; + }) + .map(item => (item as Element).textContent || ""); +} + +// Add environment check for processor type +if (process.env.PROCESSOR_TYPE === 'openai') { + // Initialize Express server only for OpenAI + const app = express(); + const port = process.env.PORT || 3000; + + app.use(bodyParser.json()); + + // Keep existing OpenAI routes + app.post('/chat', async (req, res) => { + // ... existing OpenAI handler code ... + }); + + app.listen(port, () => { + console.log(`[OpenAI Server] Running on port ${port}`); + }); +} else { + console.log('[Claude Mode] Using stdio communication'); } main().catch((error) => { diff --git a/package.json b/package.json index 4985515..f0c8c8c 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,11 @@ "dependencies": { "@digital-alchemy/core": "^24.11.4", "@digital-alchemy/hass": "^24.11.4", + "@types/chalk": "^0.4.31", "@types/xmldom": "^0.1.34", "@xmldom/xmldom": "^0.9.7", "ajv": "^8.12.0", + "chalk": "^5.4.1", "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^7.1.5", @@ -56,4 +58,4 @@ "author": "Jango Blockchained", "license": "MIT", "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" -} \ No newline at end of file +}