mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-04-17 17:42:19 +00:00
Update notification service
This commit is contained in:
111
AppImage/scripts/ai_providers/__init__.py
Normal file
111
AppImage/scripts/ai_providers/__init__.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""AI Providers for ProxMenux notification enhancement.
|
||||
|
||||
This module provides a pluggable architecture for different AI providers
|
||||
to enhance and translate notification messages.
|
||||
|
||||
Supported providers:
|
||||
- Groq: Fast inference, generous free tier (30 req/min)
|
||||
- OpenAI: Industry standard, widely used
|
||||
- Anthropic: Excellent for text generation, Claude Haiku is fast and affordable
|
||||
- Gemini: Google's model, free tier available, good quality/price ratio
|
||||
- Ollama: 100% local execution, no costs, complete privacy
|
||||
- OpenRouter: Aggregator with access to 100+ models using a single API key
|
||||
"""
|
||||
from .base import AIProvider, AIProviderError
|
||||
from .groq_provider import GroqProvider
|
||||
from .openai_provider import OpenAIProvider
|
||||
from .anthropic_provider import AnthropicProvider
|
||||
from .gemini_provider import GeminiProvider
|
||||
from .ollama_provider import OllamaProvider
|
||||
from .openrouter_provider import OpenRouterProvider
|
||||
|
||||
PROVIDERS = {
|
||||
'groq': GroqProvider,
|
||||
'openai': OpenAIProvider,
|
||||
'anthropic': AnthropicProvider,
|
||||
'gemini': GeminiProvider,
|
||||
'ollama': OllamaProvider,
|
||||
'openrouter': OpenRouterProvider,
|
||||
}
|
||||
|
||||
# Provider metadata for UI display
|
||||
PROVIDER_INFO = {
|
||||
'groq': {
|
||||
'name': 'Groq',
|
||||
'default_model': 'llama-3.3-70b-versatile',
|
||||
'description': 'Fast inference, generous free tier (30 req/min). Ideal to get started.',
|
||||
'requires_api_key': True,
|
||||
},
|
||||
'openai': {
|
||||
'name': 'OpenAI',
|
||||
'default_model': 'gpt-4o-mini',
|
||||
'description': 'Industry standard. Very accurate and widely used.',
|
||||
'requires_api_key': True,
|
||||
},
|
||||
'anthropic': {
|
||||
'name': 'Anthropic (Claude)',
|
||||
'default_model': 'claude-3-haiku-20240307',
|
||||
'description': 'Excellent for writing and translation. Fast and affordable.',
|
||||
'requires_api_key': True,
|
||||
},
|
||||
'gemini': {
|
||||
'name': 'Google Gemini',
|
||||
'default_model': 'gemini-1.5-flash',
|
||||
'description': 'Free tier available, very good quality/price ratio.',
|
||||
'requires_api_key': True,
|
||||
},
|
||||
'ollama': {
|
||||
'name': 'Ollama (Local)',
|
||||
'default_model': 'llama3.2',
|
||||
'description': '100% local execution. No costs, complete privacy, no internet required.',
|
||||
'requires_api_key': False,
|
||||
},
|
||||
'openrouter': {
|
||||
'name': 'OpenRouter',
|
||||
'default_model': 'meta-llama/llama-3.3-70b-instruct',
|
||||
'description': 'Aggregator with access to 100+ models using a single API key. Maximum flexibility.',
|
||||
'requires_api_key': True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_provider(name: str, **kwargs) -> AIProvider:
|
||||
"""Factory function to get provider instance.
|
||||
|
||||
Args:
|
||||
name: Provider name (groq, openai, anthropic, gemini, ollama, openrouter)
|
||||
**kwargs: Provider-specific arguments (api_key, model, base_url)
|
||||
|
||||
Returns:
|
||||
AIProvider instance
|
||||
|
||||
Raises:
|
||||
AIProviderError: If provider name is unknown
|
||||
"""
|
||||
if name not in PROVIDERS:
|
||||
raise AIProviderError(f"Unknown provider: {name}. Available: {list(PROVIDERS.keys())}")
|
||||
return PROVIDERS[name](**kwargs)
|
||||
|
||||
|
||||
def get_provider_info(name: str = None) -> dict:
|
||||
"""Get provider metadata for UI display.
|
||||
|
||||
Args:
|
||||
name: Optional provider name. If None, returns all providers info.
|
||||
|
||||
Returns:
|
||||
Provider info dict or dict of all providers
|
||||
"""
|
||||
if name:
|
||||
return PROVIDER_INFO.get(name, {})
|
||||
return PROVIDER_INFO
|
||||
|
||||
|
||||
__all__ = [
|
||||
'AIProvider',
|
||||
'AIProviderError',
|
||||
'PROVIDERS',
|
||||
'PROVIDER_INFO',
|
||||
'get_provider',
|
||||
'get_provider_info',
|
||||
]
|
||||
65
AppImage/scripts/ai_providers/anthropic_provider.py
Normal file
65
AppImage/scripts/ai_providers/anthropic_provider.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Anthropic (Claude) provider implementation.
|
||||
|
||||
Anthropic's Claude models are excellent for text generation and translation.
|
||||
Claude Haiku is particularly fast and affordable for notification enhancement.
|
||||
"""
|
||||
from typing import Optional
|
||||
from .base import AIProvider, AIProviderError
|
||||
|
||||
|
||||
class AnthropicProvider(AIProvider):
|
||||
"""Anthropic provider using their Messages API."""
|
||||
|
||||
NAME = "anthropic"
|
||||
DEFAULT_MODEL = "claude-3-haiku-20240307"
|
||||
REQUIRES_API_KEY = True
|
||||
API_URL = "https://api.anthropic.com/v1/messages"
|
||||
API_VERSION = "2023-06-01"
|
||||
|
||||
def generate(self, system_prompt: str, user_message: str,
|
||||
max_tokens: int = 200) -> Optional[str]:
|
||||
"""Generate a response using Anthropic's API.
|
||||
|
||||
Note: Anthropic uses a different API format than OpenAI.
|
||||
The system prompt goes in a separate field, not in messages.
|
||||
|
||||
Args:
|
||||
system_prompt: System instructions
|
||||
user_message: User message to process
|
||||
max_tokens: Maximum response length
|
||||
|
||||
Returns:
|
||||
Generated text or None if failed
|
||||
|
||||
Raises:
|
||||
AIProviderError: If API key is missing or request fails
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise AIProviderError("API key required for Anthropic")
|
||||
|
||||
# Anthropic uses a different format - system is a top-level field
|
||||
payload = {
|
||||
'model': self.model,
|
||||
'system': system_prompt,
|
||||
'messages': [
|
||||
{'role': 'user', 'content': user_message},
|
||||
],
|
||||
'max_tokens': max_tokens,
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': self.api_key,
|
||||
'anthropic-version': self.API_VERSION,
|
||||
}
|
||||
|
||||
result = self._make_request(self.API_URL, payload, headers)
|
||||
|
||||
try:
|
||||
# Anthropic returns content as array of content blocks
|
||||
content = result['content']
|
||||
if isinstance(content, list) and len(content) > 0:
|
||||
return content[0].get('text', '').strip()
|
||||
return str(content).strip()
|
||||
except (KeyError, IndexError) as e:
|
||||
raise AIProviderError(f"Unexpected response format: {e}")
|
||||
141
AppImage/scripts/ai_providers/base.py
Normal file
141
AppImage/scripts/ai_providers/base.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Base class for AI providers."""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
class AIProviderError(Exception):
|
||||
"""Exception for AI provider errors."""
|
||||
pass
|
||||
|
||||
|
||||
class AIProvider(ABC):
|
||||
"""Abstract base class for AI providers.
|
||||
|
||||
All provider implementations must inherit from this class and implement
|
||||
the generate() method.
|
||||
"""
|
||||
|
||||
# Provider metadata (override in subclasses)
|
||||
NAME = "base"
|
||||
DEFAULT_MODEL = ""
|
||||
REQUIRES_API_KEY = True
|
||||
|
||||
def __init__(self, api_key: str = "", model: str = "", base_url: str = ""):
|
||||
"""Initialize the AI provider.
|
||||
|
||||
Args:
|
||||
api_key: API key for authentication (not required for local providers)
|
||||
model: Model name to use (defaults to DEFAULT_MODEL if empty)
|
||||
base_url: Base URL for API calls (used by Ollama and custom endpoints)
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.model = model or self.DEFAULT_MODEL
|
||||
self.base_url = base_url
|
||||
|
||||
@abstractmethod
|
||||
def generate(self, system_prompt: str, user_message: str,
|
||||
max_tokens: int = 200) -> Optional[str]:
|
||||
"""Generate a response from the AI model.
|
||||
|
||||
Args:
|
||||
system_prompt: System instructions for the model
|
||||
user_message: User message/query to process
|
||||
max_tokens: Maximum tokens in the response
|
||||
|
||||
Returns:
|
||||
Generated text or None if failed
|
||||
|
||||
Raises:
|
||||
AIProviderError: If there's an error communicating with the provider
|
||||
"""
|
||||
pass
|
||||
|
||||
def test_connection(self) -> Dict[str, Any]:
|
||||
"""Test the connection to the AI provider.
|
||||
|
||||
Sends a simple test message to verify the provider is accessible
|
||||
and the API key is valid.
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- success: bool indicating if connection succeeded
|
||||
- message: Human-readable status message
|
||||
- model: Model name being used
|
||||
"""
|
||||
try:
|
||||
response = self.generate(
|
||||
system_prompt="You are a test assistant. Respond with exactly: CONNECTION_OK",
|
||||
user_message="Test connection",
|
||||
max_tokens=20
|
||||
)
|
||||
if response:
|
||||
# Check if response contains our expected text
|
||||
if "CONNECTION_OK" in response.upper() or "CONNECTION" in response.upper():
|
||||
return {
|
||||
'success': True,
|
||||
'message': 'Connection successful',
|
||||
'model': self.model
|
||||
}
|
||||
# Even if different response, connection worked
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'Connected (response received)',
|
||||
'model': self.model
|
||||
}
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'No response received from provider',
|
||||
'model': self.model
|
||||
}
|
||||
except AIProviderError as e:
|
||||
return {
|
||||
'success': False,
|
||||
'message': str(e),
|
||||
'model': self.model
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'Unexpected error: {str(e)}',
|
||||
'model': self.model
|
||||
}
|
||||
|
||||
def _make_request(self, url: str, payload: dict, headers: dict,
|
||||
timeout: int = 15) -> dict:
|
||||
"""Make HTTP request to AI provider API.
|
||||
|
||||
Args:
|
||||
url: API endpoint URL
|
||||
payload: JSON payload to send
|
||||
headers: HTTP headers
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
Parsed JSON response
|
||||
|
||||
Raises:
|
||||
AIProviderError: If request fails
|
||||
"""
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
data = json.dumps(payload).encode('utf-8')
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method='POST')
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode('utf-8'))
|
||||
except urllib.error.HTTPError as e:
|
||||
error_body = ""
|
||||
try:
|
||||
error_body = e.read().decode('utf-8')
|
||||
except Exception:
|
||||
pass
|
||||
raise AIProviderError(f"HTTP {e.code}: {error_body or e.reason}")
|
||||
except urllib.error.URLError as e:
|
||||
raise AIProviderError(f"Connection error: {e.reason}")
|
||||
except json.JSONDecodeError as e:
|
||||
raise AIProviderError(f"Invalid JSON response: {e}")
|
||||
except Exception as e:
|
||||
raise AIProviderError(f"Request failed: {str(e)}")
|
||||
74
AppImage/scripts/ai_providers/gemini_provider.py
Normal file
74
AppImage/scripts/ai_providers/gemini_provider.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Google Gemini provider implementation.
|
||||
|
||||
Google's Gemini models offer a free tier and excellent quality/price ratio.
|
||||
Gemini 1.5 Flash is particularly fast and cost-effective.
|
||||
"""
|
||||
from typing import Optional
|
||||
from .base import AIProvider, AIProviderError
|
||||
|
||||
|
||||
class GeminiProvider(AIProvider):
|
||||
"""Google Gemini provider using the Generative Language API."""
|
||||
|
||||
NAME = "gemini"
|
||||
DEFAULT_MODEL = "gemini-1.5-flash"
|
||||
REQUIRES_API_KEY = True
|
||||
API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
|
||||
|
||||
def generate(self, system_prompt: str, user_message: str,
|
||||
max_tokens: int = 200) -> Optional[str]:
|
||||
"""Generate a response using Google's Gemini API.
|
||||
|
||||
Note: Gemini uses a different API format. System instructions
|
||||
go in a separate systemInstruction field.
|
||||
|
||||
Args:
|
||||
system_prompt: System instructions
|
||||
user_message: User message to process
|
||||
max_tokens: Maximum response length
|
||||
|
||||
Returns:
|
||||
Generated text or None if failed
|
||||
|
||||
Raises:
|
||||
AIProviderError: If API key is missing or request fails
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise AIProviderError("API key required for Gemini")
|
||||
|
||||
url = f"{self.API_BASE}/{self.model}:generateContent?key={self.api_key}"
|
||||
|
||||
# Gemini uses a specific format with contents array
|
||||
payload = {
|
||||
'systemInstruction': {
|
||||
'parts': [{'text': system_prompt}]
|
||||
},
|
||||
'contents': [
|
||||
{
|
||||
'role': 'user',
|
||||
'parts': [{'text': user_message}]
|
||||
}
|
||||
],
|
||||
'generationConfig': {
|
||||
'maxOutputTokens': max_tokens,
|
||||
'temperature': 0.3,
|
||||
}
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
result = self._make_request(url, payload, headers)
|
||||
|
||||
try:
|
||||
# Gemini returns candidates array with content parts
|
||||
candidates = result.get('candidates', [])
|
||||
if candidates:
|
||||
content = candidates[0].get('content', {})
|
||||
parts = content.get('parts', [])
|
||||
if parts:
|
||||
return parts[0].get('text', '').strip()
|
||||
raise AIProviderError("No content in response")
|
||||
except (KeyError, IndexError) as e:
|
||||
raise AIProviderError(f"Unexpected response format: {e}")
|
||||
56
AppImage/scripts/ai_providers/groq_provider.py
Normal file
56
AppImage/scripts/ai_providers/groq_provider.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Groq AI provider implementation.
|
||||
|
||||
Groq provides fast inference with a generous free tier (30 requests/minute).
|
||||
Uses the OpenAI-compatible API format.
|
||||
"""
|
||||
from typing import Optional
|
||||
from .base import AIProvider, AIProviderError
|
||||
|
||||
|
||||
class GroqProvider(AIProvider):
|
||||
"""Groq AI provider using their OpenAI-compatible API."""
|
||||
|
||||
NAME = "groq"
|
||||
DEFAULT_MODEL = "llama-3.3-70b-versatile"
|
||||
REQUIRES_API_KEY = True
|
||||
API_URL = "https://api.groq.com/openai/v1/chat/completions"
|
||||
|
||||
def generate(self, system_prompt: str, user_message: str,
|
||||
max_tokens: int = 200) -> Optional[str]:
|
||||
"""Generate a response using Groq's API.
|
||||
|
||||
Args:
|
||||
system_prompt: System instructions
|
||||
user_message: User message to process
|
||||
max_tokens: Maximum response length
|
||||
|
||||
Returns:
|
||||
Generated text or None if failed
|
||||
|
||||
Raises:
|
||||
AIProviderError: If API key is missing or request fails
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise AIProviderError("API key required for Groq")
|
||||
|
||||
payload = {
|
||||
'model': self.model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': system_prompt},
|
||||
{'role': 'user', 'content': user_message},
|
||||
],
|
||||
'max_tokens': max_tokens,
|
||||
'temperature': 0.3,
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
}
|
||||
|
||||
result = self._make_request(self.API_URL, payload, headers)
|
||||
|
||||
try:
|
||||
return result['choices'][0]['message']['content'].strip()
|
||||
except (KeyError, IndexError) as e:
|
||||
raise AIProviderError(f"Unexpected response format: {e}")
|
||||
118
AppImage/scripts/ai_providers/ollama_provider.py
Normal file
118
AppImage/scripts/ai_providers/ollama_provider.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Ollama provider implementation.
|
||||
|
||||
Ollama enables 100% local AI execution with no costs and complete privacy.
|
||||
No internet connection required - perfect for sensitive enterprise environments.
|
||||
"""
|
||||
from typing import Optional
|
||||
from .base import AIProvider, AIProviderError
|
||||
|
||||
|
||||
class OllamaProvider(AIProvider):
|
||||
"""Ollama provider for local AI execution."""
|
||||
|
||||
NAME = "ollama"
|
||||
DEFAULT_MODEL = "llama3.2"
|
||||
REQUIRES_API_KEY = False
|
||||
DEFAULT_URL = "http://localhost:11434"
|
||||
|
||||
def __init__(self, api_key: str = "", model: str = "", base_url: str = ""):
|
||||
"""Initialize Ollama provider.
|
||||
|
||||
Args:
|
||||
api_key: Not used for Ollama (local execution)
|
||||
model: Model name (default: llama3.2)
|
||||
base_url: Ollama server URL (default: http://localhost:11434)
|
||||
"""
|
||||
super().__init__(api_key, model, base_url)
|
||||
# Use default URL if not provided
|
||||
if not self.base_url:
|
||||
self.base_url = self.DEFAULT_URL
|
||||
|
||||
def generate(self, system_prompt: str, user_message: str,
|
||||
max_tokens: int = 200) -> Optional[str]:
|
||||
"""Generate a response using local Ollama server.
|
||||
|
||||
Args:
|
||||
system_prompt: System instructions
|
||||
user_message: User message to process
|
||||
max_tokens: Maximum response length (maps to num_predict)
|
||||
|
||||
Returns:
|
||||
Generated text or None if failed
|
||||
|
||||
Raises:
|
||||
AIProviderError: If Ollama server is unreachable
|
||||
"""
|
||||
url = f"{self.base_url.rstrip('/')}/api/chat"
|
||||
|
||||
payload = {
|
||||
'model': self.model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': system_prompt},
|
||||
{'role': 'user', 'content': user_message},
|
||||
],
|
||||
'stream': False,
|
||||
'options': {
|
||||
'num_predict': max_tokens,
|
||||
'temperature': 0.3,
|
||||
}
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
try:
|
||||
result = self._make_request(url, payload, headers, timeout=30)
|
||||
except AIProviderError as e:
|
||||
if "Connection" in str(e) or "refused" in str(e).lower():
|
||||
raise AIProviderError(
|
||||
f"Cannot connect to Ollama at {self.base_url}. "
|
||||
"Make sure Ollama is running (ollama serve)"
|
||||
)
|
||||
raise
|
||||
|
||||
try:
|
||||
message = result.get('message', {})
|
||||
return message.get('content', '').strip()
|
||||
except (KeyError, AttributeError) as e:
|
||||
raise AIProviderError(f"Unexpected response format: {e}")
|
||||
|
||||
def test_connection(self):
|
||||
"""Test connection to Ollama server.
|
||||
|
||||
Also checks if the specified model is available.
|
||||
"""
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
# First check if server is running
|
||||
try:
|
||||
url = f"{self.base_url.rstrip('/')}/api/tags"
|
||||
req = urllib.request.Request(url, method='GET')
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode('utf-8'))
|
||||
models = [m.get('name', '').split(':')[0] for m in data.get('models', [])]
|
||||
|
||||
if self.model not in models and f"{self.model}:latest" not in [m.get('name', '') for m in data.get('models', [])]:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f"Model '{self.model}' not found. Available: {', '.join(models[:5])}...",
|
||||
'model': self.model
|
||||
}
|
||||
except urllib.error.URLError:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f"Cannot connect to Ollama at {self.base_url}. Make sure Ollama is running.",
|
||||
'model': self.model
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f"Error checking Ollama: {str(e)}",
|
||||
'model': self.model
|
||||
}
|
||||
|
||||
# If server is up and model exists, do the actual test
|
||||
return super().test_connection()
|
||||
56
AppImage/scripts/ai_providers/openai_provider.py
Normal file
56
AppImage/scripts/ai_providers/openai_provider.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""OpenAI provider implementation.
|
||||
|
||||
OpenAI is the industry standard for AI APIs. gpt-4o-mini provides
|
||||
excellent quality at a reasonable price point.
|
||||
"""
|
||||
from typing import Optional
|
||||
from .base import AIProvider, AIProviderError
|
||||
|
||||
|
||||
class OpenAIProvider(AIProvider):
|
||||
"""OpenAI provider using their Chat Completions API."""
|
||||
|
||||
NAME = "openai"
|
||||
DEFAULT_MODEL = "gpt-4o-mini"
|
||||
REQUIRES_API_KEY = True
|
||||
API_URL = "https://api.openai.com/v1/chat/completions"
|
||||
|
||||
def generate(self, system_prompt: str, user_message: str,
|
||||
max_tokens: int = 200) -> Optional[str]:
|
||||
"""Generate a response using OpenAI's API.
|
||||
|
||||
Args:
|
||||
system_prompt: System instructions
|
||||
user_message: User message to process
|
||||
max_tokens: Maximum response length
|
||||
|
||||
Returns:
|
||||
Generated text or None if failed
|
||||
|
||||
Raises:
|
||||
AIProviderError: If API key is missing or request fails
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise AIProviderError("API key required for OpenAI")
|
||||
|
||||
payload = {
|
||||
'model': self.model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': system_prompt},
|
||||
{'role': 'user', 'content': user_message},
|
||||
],
|
||||
'max_tokens': max_tokens,
|
||||
'temperature': 0.3,
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
}
|
||||
|
||||
result = self._make_request(self.API_URL, payload, headers)
|
||||
|
||||
try:
|
||||
return result['choices'][0]['message']['content'].strip()
|
||||
except (KeyError, IndexError) as e:
|
||||
raise AIProviderError(f"Unexpected response format: {e}")
|
||||
62
AppImage/scripts/ai_providers/openrouter_provider.py
Normal file
62
AppImage/scripts/ai_providers/openrouter_provider.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""OpenRouter provider implementation.
|
||||
|
||||
OpenRouter is an aggregator that provides access to 100+ AI models
|
||||
using a single API key. Maximum flexibility for choosing models.
|
||||
Uses OpenAI-compatible API format.
|
||||
"""
|
||||
from typing import Optional
|
||||
from .base import AIProvider, AIProviderError
|
||||
|
||||
|
||||
class OpenRouterProvider(AIProvider):
|
||||
"""OpenRouter provider for multi-model access."""
|
||||
|
||||
NAME = "openrouter"
|
||||
DEFAULT_MODEL = "meta-llama/llama-3.3-70b-instruct"
|
||||
REQUIRES_API_KEY = True
|
||||
API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||
|
||||
def generate(self, system_prompt: str, user_message: str,
|
||||
max_tokens: int = 200) -> Optional[str]:
|
||||
"""Generate a response using OpenRouter's API.
|
||||
|
||||
OpenRouter uses OpenAI-compatible format with additional
|
||||
headers for app identification.
|
||||
|
||||
Args:
|
||||
system_prompt: System instructions
|
||||
user_message: User message to process
|
||||
max_tokens: Maximum response length
|
||||
|
||||
Returns:
|
||||
Generated text or None if failed
|
||||
|
||||
Raises:
|
||||
AIProviderError: If API key is missing or request fails
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise AIProviderError("API key required for OpenRouter")
|
||||
|
||||
payload = {
|
||||
'model': self.model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': system_prompt},
|
||||
{'role': 'user', 'content': user_message},
|
||||
],
|
||||
'max_tokens': max_tokens,
|
||||
'temperature': 0.3,
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'HTTP-Referer': 'https://github.com/MacRimi/ProxMenux',
|
||||
'X-Title': 'ProxMenux Monitor',
|
||||
}
|
||||
|
||||
result = self._make_request(self.API_URL, payload, headers)
|
||||
|
||||
try:
|
||||
return result['choices'][0]['message']['content'].strip()
|
||||
except (KeyError, IndexError) as e:
|
||||
raise AIProviderError(f"Unexpected response format: {e}")
|
||||
@@ -100,6 +100,16 @@ cp "$SCRIPT_DIR/oci_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️
|
||||
cp "$SCRIPT_DIR/flask_oci_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_oci_routes.py not found"
|
||||
cp "$SCRIPT_DIR/oci/description_templates.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ description_templates.py not found"
|
||||
|
||||
# Copy AI providers module for notification enhancement
|
||||
echo "📋 Copying AI providers module..."
|
||||
if [ -d "$SCRIPT_DIR/ai_providers" ]; then
|
||||
mkdir -p "$APP_DIR/usr/bin/ai_providers"
|
||||
cp "$SCRIPT_DIR/ai_providers/"*.py "$APP_DIR/usr/bin/ai_providers/"
|
||||
echo "✅ AI providers module copied"
|
||||
else
|
||||
echo "⚠️ ai_providers directory not found"
|
||||
fi
|
||||
|
||||
echo "📋 Adding translation support..."
|
||||
cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF'
|
||||
#!/usr/bin/env python3
|
||||
|
||||
@@ -101,6 +101,83 @@ def test_notification():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@notification_bp.route('/api/notifications/test-ai', methods=['POST'])
|
||||
def test_ai_connection():
|
||||
"""Test AI provider connection and configuration.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"provider": "groq" | "openai" | "anthropic" | "gemini" | "ollama" | "openrouter",
|
||||
"api_key": "...",
|
||||
"model": "..." (optional),
|
||||
"ollama_url": "http://localhost:11434" (optional, for ollama)
|
||||
}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true/false,
|
||||
"message": "Connection successful" or error message,
|
||||
"model": "model used for test"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
provider = data.get('provider', 'groq')
|
||||
api_key = data.get('api_key', '')
|
||||
model = data.get('model', '')
|
||||
ollama_url = data.get('ollama_url', 'http://localhost:11434')
|
||||
|
||||
# Validate required fields
|
||||
if provider != 'ollama' and not api_key:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'API key is required',
|
||||
'model': ''
|
||||
}), 400
|
||||
|
||||
if provider == 'ollama' and not ollama_url:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Ollama URL is required',
|
||||
'model': ''
|
||||
}), 400
|
||||
|
||||
# Import and use the AI providers module
|
||||
import sys
|
||||
import os
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if script_dir not in sys.path:
|
||||
sys.path.insert(0, script_dir)
|
||||
|
||||
from ai_providers import get_provider, AIProviderError
|
||||
|
||||
try:
|
||||
ai_provider = get_provider(
|
||||
provider,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
base_url=ollama_url
|
||||
)
|
||||
|
||||
result = ai_provider.test_connection()
|
||||
return jsonify(result)
|
||||
|
||||
except AIProviderError as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': str(e),
|
||||
'model': model
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'Unexpected error: {str(e)}',
|
||||
'model': ''
|
||||
}), 500
|
||||
|
||||
|
||||
@notification_bp.route('/api/notifications/status', methods=['GET'])
|
||||
def get_notification_status():
|
||||
"""Get notification service status."""
|
||||
|
||||
@@ -90,6 +90,60 @@ def _hostname() -> str:
|
||||
return 'proxmox'
|
||||
|
||||
|
||||
def capture_journal_context(keywords: list, lines: int = 30,
|
||||
since: str = "5 minutes ago") -> str:
|
||||
"""Capture relevant journal lines for AI context enrichment.
|
||||
|
||||
Searches recent journald entries for lines matching any of the
|
||||
provided keywords and returns them for AI analysis.
|
||||
|
||||
Args:
|
||||
keywords: List of terms to filter (e.g., ['sdh', 'ata8', 'I/O error'])
|
||||
lines: Maximum number of lines to return (default: 30)
|
||||
since: Time window for journalctl (default: "5 minutes ago")
|
||||
|
||||
Returns:
|
||||
Filtered journal output as string, or empty string if none found
|
||||
|
||||
Example:
|
||||
context = capture_journal_context(
|
||||
keywords=['sdh', 'ata8', 'exception'],
|
||||
lines=30
|
||||
)
|
||||
"""
|
||||
if not keywords:
|
||||
return ""
|
||||
|
||||
try:
|
||||
# Build grep pattern from keywords
|
||||
pattern = "|".join(re.escape(k) for k in keywords if k)
|
||||
if not pattern:
|
||||
return ""
|
||||
|
||||
# Use journalctl with grep to filter relevant lines
|
||||
cmd = (
|
||||
f"journalctl --since='{since}' --no-pager -n 500 2>/dev/null | "
|
||||
f"grep -iE '{pattern}' | tail -n {lines}"
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
return ""
|
||||
except subprocess.TimeoutExpired:
|
||||
return ""
|
||||
except Exception as e:
|
||||
# Silently fail - journal context is optional
|
||||
return ""
|
||||
|
||||
|
||||
# ─── Journal Watcher (Real-time) ─────────────────────────────────
|
||||
|
||||
class JournalWatcher:
|
||||
@@ -725,11 +779,18 @@ class JournalWatcher:
|
||||
enriched = '\n'.join(parts)
|
||||
dev_display = f'/dev/{resolved}'
|
||||
|
||||
# Capture journal context for AI enrichment
|
||||
journal_ctx = capture_journal_context(
|
||||
keywords=[resolved, ata_port, 'I/O error', 'exception', 'SMART'],
|
||||
lines=30
|
||||
)
|
||||
|
||||
self._emit('disk_io_error', 'CRITICAL', {
|
||||
'device': dev_display,
|
||||
'reason': enriched,
|
||||
'hostname': self._hostname,
|
||||
'smart_status': 'FAILED',
|
||||
'_journal_context': journal_ctx,
|
||||
}, entity='disk', entity_id=resolved)
|
||||
return
|
||||
|
||||
@@ -2239,6 +2300,21 @@ class ProxmoxHookWatcher:
|
||||
if dur_m:
|
||||
data['duration'] = dur_m.group(1).strip()
|
||||
|
||||
# Capture journal context for critical/warning events (helps AI provide better context)
|
||||
if severity in ('CRITICAL', 'WARNING') and event_type not in ('backup_complete', 'update_available'):
|
||||
# Build keywords from available data for journal search
|
||||
keywords = ['error', 'fail', 'warning']
|
||||
if 'smartd' in message.lower() or 'smart' in title.lower():
|
||||
keywords.extend(['smartd', 'SMART', 'ata'])
|
||||
if pve_type == 'system-mail':
|
||||
keywords.append('smartd')
|
||||
if entity_id:
|
||||
keywords.append(entity_id)
|
||||
|
||||
journal_ctx = capture_journal_context(keywords=keywords, lines=20)
|
||||
if journal_ctx:
|
||||
data['_journal_context'] = journal_ctx
|
||||
|
||||
event = NotificationEvent(
|
||||
event_type=event_type,
|
||||
severity=severity,
|
||||
|
||||
@@ -615,17 +615,6 @@ class NotificationManager:
|
||||
# Render message from template (structured output)
|
||||
rendered = render_template(event.event_type, event.data)
|
||||
|
||||
# Optional AI enhancement (on text body only)
|
||||
ai_config = {
|
||||
'enabled': self._config.get('ai_enabled', 'false'),
|
||||
'provider': self._config.get('ai_provider', ''),
|
||||
'api_key': self._config.get('ai_api_key', ''),
|
||||
'model': self._config.get('ai_model', ''),
|
||||
}
|
||||
body = format_with_ai(
|
||||
rendered['title'], rendered['body'], severity, ai_config
|
||||
)
|
||||
|
||||
# Enrich data with structured fields for channels that support them
|
||||
enriched_data = dict(event.data)
|
||||
enriched_data['_rendered_fields'] = rendered.get('fields', [])
|
||||
@@ -633,9 +622,13 @@ class NotificationManager:
|
||||
enriched_data['_event_type'] = event.event_type
|
||||
enriched_data['_group'] = TEMPLATES.get(event.event_type, {}).get('group', 'other')
|
||||
|
||||
# Send through all active channels
|
||||
# Pass journal context if available (for AI enrichment)
|
||||
if '_journal_context' in event.data:
|
||||
enriched_data['_journal_context'] = event.data['_journal_context']
|
||||
|
||||
# Send through all active channels (AI applied per-channel with detail_level)
|
||||
self._dispatch_to_channels(
|
||||
rendered['title'], body, severity,
|
||||
rendered['title'], rendered['body'], severity,
|
||||
event.event_type, enriched_data, event.source
|
||||
)
|
||||
|
||||
@@ -647,6 +640,9 @@ class NotificationManager:
|
||||
- {channel}.events.{group} = "true"/"false" (category toggle, default "true")
|
||||
- {channel}.event.{type} = "true"/"false" (per-event toggle, default from template)
|
||||
No global fallback -- each channel decides independently what it receives.
|
||||
|
||||
AI enhancement is applied per-channel with configurable detail level:
|
||||
- {channel}.ai_detail_level = "brief" | "standard" | "detailed"
|
||||
"""
|
||||
with self._lock:
|
||||
channels = dict(self._channels)
|
||||
@@ -655,6 +651,19 @@ class NotificationManager:
|
||||
event_group = template.get('group', 'other')
|
||||
default_event_enabled = 'true' if template.get('default_enabled', True) else 'false'
|
||||
|
||||
# Build AI config once (shared across channels, detail_level varies)
|
||||
ai_config = {
|
||||
'ai_enabled': self._config.get('ai_enabled', 'false'),
|
||||
'ai_provider': self._config.get('ai_provider', 'groq'),
|
||||
'ai_api_key': self._config.get('ai_api_key', ''),
|
||||
'ai_model': self._config.get('ai_model', ''),
|
||||
'ai_language': self._config.get('ai_language', 'en'),
|
||||
'ai_ollama_url': self._config.get('ai_ollama_url', ''),
|
||||
}
|
||||
|
||||
# Get journal context if available
|
||||
journal_context = data.get('_journal_context', '')
|
||||
|
||||
for ch_name, channel in channels.items():
|
||||
# ── Per-channel category check ──
|
||||
# Default: category enabled (true) unless explicitly disabled.
|
||||
@@ -669,12 +678,33 @@ class NotificationManager:
|
||||
continue # Channel has this specific event disabled
|
||||
|
||||
try:
|
||||
# Per-channel emoji enrichment (opt-in via {channel}.rich_format)
|
||||
ch_title, ch_body = title, body
|
||||
|
||||
# ── Per-channel settings ──
|
||||
detail_level_key = f'{ch_name}.ai_detail_level'
|
||||
detail_level = self._config.get(detail_level_key, 'standard')
|
||||
|
||||
rich_key = f'{ch_name}.rich_format'
|
||||
if self._config.get(rich_key, 'false') == 'true':
|
||||
use_rich_format = self._config.get(rich_key, 'false') == 'true'
|
||||
|
||||
# ── Per-channel AI enhancement ──
|
||||
# Apply AI with channel-specific detail level and emoji setting
|
||||
# If AI is enabled AND rich_format is on, AI will include emojis directly
|
||||
ch_body = format_with_ai(
|
||||
ch_title, ch_body, severity, ai_config,
|
||||
detail_level=detail_level,
|
||||
journal_context=journal_context,
|
||||
use_emojis=use_rich_format
|
||||
)
|
||||
|
||||
# Fallback emoji enrichment only if AI is disabled but rich_format is on
|
||||
# (If AI processed the message with emojis, this is skipped)
|
||||
ai_enabled_str = ai_config.get('ai_enabled', 'false')
|
||||
ai_enabled = ai_enabled_str == 'true' if isinstance(ai_enabled_str, str) else bool(ai_enabled_str)
|
||||
|
||||
if use_rich_format and not ai_enabled:
|
||||
ch_title, ch_body = enrich_with_emojis(
|
||||
event_type, title, body, data
|
||||
event_type, ch_title, ch_body, data
|
||||
)
|
||||
|
||||
result = channel.send(ch_title, ch_body, severity, data)
|
||||
@@ -946,14 +976,15 @@ class NotificationManager:
|
||||
message = rendered['body']
|
||||
severity = severity or rendered['severity']
|
||||
|
||||
# AI enhancement
|
||||
# AI config for enhancement
|
||||
ai_config = {
|
||||
'enabled': self._config.get('ai_enabled', 'false'),
|
||||
'provider': self._config.get('ai_provider', ''),
|
||||
'api_key': self._config.get('ai_api_key', ''),
|
||||
'model': self._config.get('ai_model', ''),
|
||||
'ai_enabled': self._config.get('ai_enabled', 'false'),
|
||||
'ai_provider': self._config.get('ai_provider', 'groq'),
|
||||
'ai_api_key': self._config.get('ai_api_key', ''),
|
||||
'ai_model': self._config.get('ai_model', ''),
|
||||
'ai_language': self._config.get('ai_language', 'en'),
|
||||
'ai_ollama_url': self._config.get('ai_ollama_url', ''),
|
||||
}
|
||||
message = format_with_ai(title, message, severity, ai_config)
|
||||
|
||||
results = {}
|
||||
channels_sent = []
|
||||
@@ -964,11 +995,24 @@ class NotificationManager:
|
||||
|
||||
for ch_name, channel in channels.items():
|
||||
try:
|
||||
result = channel.send(title, message, severity, data)
|
||||
# Apply AI enhancement per channel with its detail level and emoji setting
|
||||
detail_level_key = f'{ch_name}.ai_detail_level'
|
||||
detail_level = self._config.get(detail_level_key, 'standard')
|
||||
|
||||
rich_key = f'{ch_name}.rich_format'
|
||||
use_rich_format = self._config.get(rich_key, 'false') == 'true'
|
||||
|
||||
ch_message = format_with_ai(
|
||||
title, message, severity, ai_config,
|
||||
detail_level=detail_level,
|
||||
use_emojis=use_rich_format
|
||||
)
|
||||
|
||||
result = channel.send(title, ch_message, severity, data)
|
||||
results[ch_name] = result
|
||||
|
||||
self._record_history(
|
||||
event_type, ch_name, title, message, severity,
|
||||
event_type, ch_name, title, ch_message, severity,
|
||||
result.get('success', False),
|
||||
result.get('error', ''),
|
||||
source
|
||||
|
||||
@@ -1215,107 +1215,252 @@ def enrich_with_emojis(event_type: str, title: str, body: str,
|
||||
|
||||
# ─── AI Enhancement (Optional) ───────────────────────────────────
|
||||
|
||||
# Supported languages for AI translation
|
||||
AI_LANGUAGES = {
|
||||
'en': 'English',
|
||||
'es': 'Spanish',
|
||||
'fr': 'French',
|
||||
'de': 'German',
|
||||
'pt': 'Portuguese',
|
||||
'it': 'Italian',
|
||||
'ru': 'Russian',
|
||||
'sv': 'Swedish',
|
||||
'no': 'Norwegian',
|
||||
'ja': 'Japanese',
|
||||
'zh': 'Chinese',
|
||||
'nl': 'Dutch',
|
||||
}
|
||||
|
||||
# Token limits for different detail levels
|
||||
AI_DETAIL_TOKENS = {
|
||||
'brief': 100, # 2-3 lines, essential only
|
||||
'standard': 200, # Concise paragraph with context
|
||||
'detailed': 400, # Complete technical details
|
||||
}
|
||||
|
||||
# System prompt template - informative, no recommendations
|
||||
AI_SYSTEM_PROMPT = """You are a technical assistant for ProxMenux Monitor, a Proxmox server monitoring system.
|
||||
|
||||
Your task is to translate and format system alerts to {language}.
|
||||
|
||||
STRICT RULES:
|
||||
1. Translate the message to the requested language
|
||||
2. Maintain an INFORMATIVE and OBJECTIVE tone
|
||||
3. DO NOT use formal introductions ("Dear...", "Esteemed...")
|
||||
4. DO NOT give recommendations or action suggestions
|
||||
5. DO NOT interpret data subjectively
|
||||
6. Present only FACTS and TECHNICAL DATA
|
||||
7. Respect the requested detail level: {detail_level}
|
||||
{emoji_instructions}
|
||||
|
||||
DETAIL LEVELS:
|
||||
- brief: 2-3 lines maximum, only essential information
|
||||
- standard: Concise paragraph with basic context
|
||||
- detailed: Complete information with all available technical details
|
||||
|
||||
MESSAGE TYPES:
|
||||
- Some messages come from Proxmox VE webhooks with raw system data (backup logs, update lists, SMART errors)
|
||||
- Parse and present this data clearly, extracting key information (VM IDs, sizes, durations, errors)
|
||||
- For backup messages: highlight status (OK/ERROR), VM names, sizes, and duration
|
||||
- For update messages: list package names and counts
|
||||
- For disk/SMART errors: highlight affected device and error type
|
||||
|
||||
If journal log context is provided, use it for more precise event information."""
|
||||
|
||||
# Emoji instructions for rich format channels
|
||||
AI_EMOJI_INSTRUCTIONS = """
|
||||
8. ENRICH with contextual emojis and icons:
|
||||
- Use appropriate emojis at the START of the title/message to indicate severity and type
|
||||
- Severity indicators: Use a colored circle at the start (info=blue, warning=yellow, critical=red)
|
||||
- Add relevant technical emojis: disk, server, network, security, backup, etc.
|
||||
- Keep emojis contextual and professional, not decorative
|
||||
- Examples of appropriate emojis:
|
||||
* Disk/Storage: disk, folder, file
|
||||
* Network: globe, signal, connection
|
||||
* Security: shield, lock, key, warning
|
||||
* System: gear, server, computer
|
||||
* Status: checkmark, cross, warning, info
|
||||
* Backup: save, sync, cloud
|
||||
* Performance: chart, speedometer"""
|
||||
|
||||
# No emoji instructions for email/plain channels
|
||||
AI_NO_EMOJI_INSTRUCTIONS = """
|
||||
8. DO NOT use emojis or special icons - plain text only for email compatibility"""
|
||||
|
||||
|
||||
class AIEnhancer:
|
||||
"""Optional AI message enhancement using external LLM API.
|
||||
"""AI message enhancement using pluggable providers.
|
||||
|
||||
Enriches template-generated messages with context and suggestions.
|
||||
Falls back to original message if AI is unavailable or fails.
|
||||
Supports 6 providers: Groq, OpenAI, Anthropic, Gemini, Ollama, OpenRouter.
|
||||
Translates and formats notifications based on configured language and detail level.
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT = """You are a Proxmox system administrator assistant.
|
||||
You receive a notification message about a server event and must enhance it with:
|
||||
1. A brief explanation of what this means in practical terms
|
||||
2. A suggested action if applicable (1-2 sentences max)
|
||||
|
||||
Keep the response concise (max 3 sentences total). Do not repeat the original message.
|
||||
Respond in the same language as the input message."""
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""Initialize AIEnhancer with configuration.
|
||||
|
||||
Args:
|
||||
config: Dictionary containing:
|
||||
- ai_provider: Provider name (groq, openai, anthropic, gemini, ollama, openrouter)
|
||||
- ai_api_key: API key (not required for ollama)
|
||||
- ai_model: Optional model override
|
||||
- ai_language: Target language code (en, es, fr, etc.)
|
||||
- ai_ollama_url: URL for Ollama server (optional)
|
||||
"""
|
||||
self.config = config
|
||||
self._provider = None
|
||||
self._init_provider()
|
||||
|
||||
def __init__(self, provider: str, api_key: str, model: str = ''):
|
||||
self.provider = provider.lower()
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self._enabled = bool(api_key)
|
||||
def _init_provider(self):
|
||||
"""Initialize the AI provider based on configuration."""
|
||||
try:
|
||||
# Import here to avoid circular imports
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add script directory to path for ai_providers import
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if script_dir not in sys.path:
|
||||
sys.path.insert(0, script_dir)
|
||||
|
||||
from ai_providers import get_provider
|
||||
|
||||
provider_name = self.config.get('ai_provider', 'groq')
|
||||
self._provider = get_provider(
|
||||
provider_name,
|
||||
api_key=self.config.get('ai_api_key', ''),
|
||||
model=self.config.get('ai_model', ''),
|
||||
base_url=self.config.get('ai_ollama_url', ''),
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[AIEnhancer] Failed to initialize provider: {e}")
|
||||
self._provider = None
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
"""Check if AI enhancement is available."""
|
||||
return self._provider is not None
|
||||
|
||||
def enhance(self, title: str, body: str, severity: str) -> Optional[str]:
|
||||
"""Enhance a notification message with AI context.
|
||||
def enhance(self, title: str, body: str, severity: str,
|
||||
detail_level: str = 'standard',
|
||||
journal_context: str = '',
|
||||
use_emojis: bool = False) -> Optional[str]:
|
||||
"""Enhance/translate notification with AI.
|
||||
|
||||
Returns enhanced body text, or None if enhancement fails/disabled.
|
||||
Args:
|
||||
title: Notification title
|
||||
body: Notification body text
|
||||
severity: Severity level (info, warning, critical)
|
||||
detail_level: Level of detail (brief, standard, detailed)
|
||||
journal_context: Optional journal log lines for context
|
||||
use_emojis: Whether to include emojis in the response (for push channels)
|
||||
|
||||
Returns:
|
||||
Enhanced/translated text or None if failed
|
||||
"""
|
||||
if not self._enabled:
|
||||
if not self._provider:
|
||||
return None
|
||||
|
||||
# Get language settings
|
||||
language_code = self.config.get('ai_language', 'en')
|
||||
language_name = AI_LANGUAGES.get(language_code, 'English')
|
||||
|
||||
# Get token limit for detail level
|
||||
max_tokens = AI_DETAIL_TOKENS.get(detail_level, 200)
|
||||
|
||||
# Select emoji instructions based on channel type
|
||||
emoji_instructions = AI_EMOJI_INSTRUCTIONS if use_emojis else AI_NO_EMOJI_INSTRUCTIONS
|
||||
|
||||
# Build system prompt with emoji instructions
|
||||
system_prompt = AI_SYSTEM_PROMPT.format(
|
||||
language=language_name,
|
||||
detail_level=detail_level,
|
||||
emoji_instructions=emoji_instructions
|
||||
)
|
||||
|
||||
# Build user message
|
||||
user_msg = f"Severity: {severity}\nTitle: {title}\nMessage:\n{body}"
|
||||
if journal_context:
|
||||
user_msg += f"\n\nJournal log context:\n{journal_context}"
|
||||
|
||||
try:
|
||||
if self.provider in ('openai', 'groq'):
|
||||
return self._call_openai_compatible(title, body, severity)
|
||||
result = self._provider.generate(system_prompt, user_msg, max_tokens)
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"[AIEnhancer] Enhancement failed: {e}")
|
||||
|
||||
return None
|
||||
return None
|
||||
|
||||
def _call_openai_compatible(self, title: str, body: str, severity: str) -> Optional[str]:
|
||||
"""Call OpenAI-compatible API (works with OpenAI, Groq, local)."""
|
||||
if self.provider == 'groq':
|
||||
url = 'https://api.groq.com/openai/v1/chat/completions'
|
||||
model = self.model or 'llama-3.3-70b-versatile'
|
||||
else: # openai
|
||||
url = 'https://api.openai.com/v1/chat/completions'
|
||||
model = self.model or 'gpt-4o-mini'
|
||||
def test_connection(self) -> Dict[str, Any]:
|
||||
"""Test the AI provider connection.
|
||||
|
||||
user_msg = f"Severity: {severity}\nTitle: {title}\nMessage: {body}"
|
||||
|
||||
payload = json.dumps({
|
||||
'model': model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': self.SYSTEM_PROMPT},
|
||||
{'role': 'user', 'content': user_msg},
|
||||
],
|
||||
'max_tokens': 150,
|
||||
'temperature': 0.3,
|
||||
}).encode('utf-8')
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
}
|
||||
|
||||
req = urllib.request.Request(url, data=payload, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
result = json.loads(resp.read().decode('utf-8'))
|
||||
content = result['choices'][0]['message']['content'].strip()
|
||||
return content if content else None
|
||||
Returns:
|
||||
Dict with success, message, and model info
|
||||
"""
|
||||
if not self._provider:
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'Provider not initialized',
|
||||
'model': ''
|
||||
}
|
||||
return self._provider.test_connection()
|
||||
|
||||
|
||||
def format_with_ai(title: str, body: str, severity: str,
|
||||
ai_config: Dict[str, str]) -> str:
|
||||
"""Format a message with optional AI enhancement.
|
||||
ai_config: Dict[str, Any],
|
||||
detail_level: str = 'standard',
|
||||
journal_context: str = '',
|
||||
use_emojis: bool = False) -> str:
|
||||
"""Format a message with AI enhancement/translation.
|
||||
|
||||
If AI is configured and succeeds, appends AI insight to the body.
|
||||
Otherwise returns the original body unchanged.
|
||||
Replaces the message body with AI-processed version if successful.
|
||||
Falls back to original body if AI is unavailable or fails.
|
||||
|
||||
Args:
|
||||
title: Notification title
|
||||
body: Notification body
|
||||
severity: Severity level
|
||||
ai_config: {'enabled': 'true', 'provider': 'groq', 'api_key': '...', 'model': ''}
|
||||
ai_config: Configuration dictionary with AI settings
|
||||
detail_level: Level of detail (brief, standard, detailed)
|
||||
journal_context: Optional journal log context
|
||||
use_emojis: Whether to include emojis (for push channels like Telegram/Discord)
|
||||
|
||||
Returns:
|
||||
Enhanced body string
|
||||
Enhanced body string or original if AI fails
|
||||
"""
|
||||
if ai_config.get('enabled') != 'true' or not ai_config.get('api_key'):
|
||||
# Check if AI is enabled
|
||||
ai_enabled = ai_config.get('ai_enabled')
|
||||
if isinstance(ai_enabled, str):
|
||||
ai_enabled = ai_enabled.lower() == 'true'
|
||||
|
||||
if not ai_enabled:
|
||||
return body
|
||||
|
||||
enhancer = AIEnhancer(
|
||||
provider=ai_config.get('provider', 'groq'),
|
||||
api_key=ai_config['api_key'],
|
||||
model=ai_config.get('model', ''),
|
||||
# Check for API key (not required for Ollama)
|
||||
provider = ai_config.get('ai_provider', 'groq')
|
||||
if provider != 'ollama' and not ai_config.get('ai_api_key'):
|
||||
return body
|
||||
|
||||
# For Ollama, check URL is configured
|
||||
if provider == 'ollama' and not ai_config.get('ai_ollama_url'):
|
||||
return body
|
||||
|
||||
# Create enhancer and process
|
||||
enhancer = AIEnhancer(ai_config)
|
||||
enhanced = enhancer.enhance(
|
||||
title, body, severity,
|
||||
detail_level=detail_level,
|
||||
journal_context=journal_context,
|
||||
use_emojis=use_emojis
|
||||
)
|
||||
|
||||
insight = enhancer.enhance(title, body, severity)
|
||||
if insight:
|
||||
return f"{body}\n\n---\n{insight}"
|
||||
# Return enhanced text if successful, otherwise original
|
||||
if enhanced:
|
||||
# For detailed level (email), append original message for reference
|
||||
# This ensures full technical data is available even after AI processing
|
||||
if detail_level == 'detailed' and body and len(body) > 50:
|
||||
# Only append if original has substantial content
|
||||
enhanced += "\n\n" + "-" * 40 + "\n"
|
||||
enhanced += "Original message:\n"
|
||||
enhanced += body
|
||||
return enhanced
|
||||
|
||||
return body
|
||||
|
||||
Reference in New Issue
Block a user