Update notification service

This commit is contained in:
MacRimi
2026-03-21 18:53:35 +01:00
parent 5e32857729
commit 1dbb59bc3f
5 changed files with 326 additions and 86 deletions

View File

@@ -9,13 +9,13 @@ import { Label } from "./ui/label"
import { Badge } from "./ui/badge" import { Badge } from "./ui/badge"
import { Button } from "./ui/button" import { Button } from "./ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
import { fetchApi } from "../lib/api-config" import { fetchApi } from "../lib/api-config"
import { import {
Bell, BellOff, Send, CheckCircle2, XCircle, Loader2, Bell, BellOff, Send, CheckCircle2, XCircle, Loader2,
AlertTriangle, Info, Settings2, Zap, Eye, EyeOff, AlertTriangle, Info, Settings2, Zap, Eye, EyeOff,
Trash2, ChevronDown, ChevronUp, ChevronRight, TestTube2, Mail, Webhook, Trash2, ChevronDown, ChevronUp, ChevronRight, TestTube2, Mail, Webhook,
Copy, Server, Shield, ExternalLink, RefreshCw Copy, Server, Shield, ExternalLink, RefreshCw, Download, Upload
} from "lucide-react" } from "lucide-react"
interface ChannelConfig { interface ChannelConfig {
@@ -63,6 +63,8 @@ interface NotificationConfig {
ai_language: string ai_language: string
ai_ollama_url: string ai_ollama_url: string
ai_openai_base_url: string ai_openai_base_url: string
ai_prompt_mode: string // 'default' or 'custom'
ai_custom_prompt: string // User's custom prompt
channel_ai_detail: Record<string, string> channel_ai_detail: Record<string, string>
hostname: string hostname: string
webhook_secret: string webhook_secret: string
@@ -180,6 +182,23 @@ const AI_DETAIL_LEVELS = [
{ value: "detailed", label: "Detailed", desc: "Complete technical details" }, { value: "detailed", label: "Detailed", desc: "Complete technical details" },
] ]
// Example custom prompt for users to adapt
const EXAMPLE_CUSTOM_PROMPT = `You are a notification formatter for ProxMenux Monitor.
Your task is to translate and format server notifications.
RULES:
1. Translate to the user's language
2. Use plain text only (no markdown)
3. Be concise and factual
4. Do not add recommendations
OUTPUT FORMAT:
[TITLE]
your title here
[BODY]
your message here`
const DEFAULT_CONFIG: NotificationConfig = { const DEFAULT_CONFIG: NotificationConfig = {
enabled: false, enabled: false,
channels: { channels: {
@@ -222,6 +241,8 @@ const DEFAULT_CONFIG: NotificationConfig = {
ai_language: "en", ai_language: "en",
ai_ollama_url: "http://localhost:11434", ai_ollama_url: "http://localhost:11434",
ai_openai_base_url: "", ai_openai_base_url: "",
ai_prompt_mode: "default",
ai_custom_prompt: "",
channel_ai_detail: { channel_ai_detail: {
telegram: "brief", telegram: "brief",
gotify: "brief", gotify: "brief",
@@ -259,6 +280,7 @@ export function NotificationSettings() {
const [aiTestResult, setAiTestResult] = useState<{ success: boolean; message: string; model?: string } | null>(null) const [aiTestResult, setAiTestResult] = useState<{ success: boolean; message: string; model?: string } | null>(null)
const [providerModels, setProviderModels] = useState<string[]>([]) const [providerModels, setProviderModels] = useState<string[]>([])
const [loadingProviderModels, setLoadingProviderModels] = useState(false) const [loadingProviderModels, setLoadingProviderModels] = useState(false)
const [showCustomPromptInfo, setShowCustomPromptInfo] = useState(false)
const [webhookSetup, setWebhookSetup] = useState<{ const [webhookSetup, setWebhookSetup] = useState<{
status: "idle" | "running" | "success" | "failed" status: "idle" | "running" | "success" | "failed"
fallback_commands: string[] fallback_commands: string[]
@@ -269,7 +291,7 @@ export function NotificationSettings() {
try { try {
const data = await fetchApi<{ success: boolean; config: NotificationConfig }>("/api/notifications/settings") const data = await fetchApi<{ success: boolean; config: NotificationConfig }>("/api/notifications/settings")
if (data.success && data.config) { if (data.success && data.config) {
// Ensure ai_api_keys and ai_models objects exist (fallback for older configs) // Ensure ai_api_keys, ai_models, and prompt settings exist (fallback for older configs)
const configWithDefaults = { const configWithDefaults = {
...data.config, ...data.config,
ai_api_keys: data.config.ai_api_keys || { ai_api_keys: data.config.ai_api_keys || {
@@ -287,7 +309,9 @@ export function NotificationSettings() {
anthropic: "", anthropic: "",
openai: "", openai: "",
openrouter: "", openrouter: "",
} },
ai_prompt_mode: data.config.ai_prompt_mode || "default",
ai_custom_prompt: data.config.ai_custom_prompt || "",
} }
// If ai_model exists but ai_models doesn't have it, save it // If ai_model exists but ai_models doesn't have it, save it
if (configWithDefaults.ai_model && !configWithDefaults.ai_models[configWithDefaults.ai_provider]) { if (configWithDefaults.ai_model && !configWithDefaults.ai_models[configWithDefaults.ai_provider]) {
@@ -1669,80 +1693,173 @@ export function NotificationSettings() {
</Select> </Select>
</div> </div>
{/* Test Connection button */} {/* Prompt Mode section */}
<button
onClick={handleTestAI}
disabled={
!editMode ||
testingAI ||
!config.ai_model ||
(config.ai_provider !== "ollama" && !config.ai_api_keys?.[config.ai_provider])
}
className="w-full h-9 flex items-center justify-center gap-2 rounded-md text-sm font-medium bg-purple-600 hover:bg-purple-700 text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{testingAI ? (
<><Loader2 className="h-4 w-4 animate-spin" /> Testing...</>
) : (
<><Zap className="h-4 w-4" /> Test Connection</>
)}
</button>
{/* Test result */}
{aiTestResult && (
<div className={`flex items-start gap-2 p-3 rounded-md ${
aiTestResult.success
? "bg-green-500/10 border border-green-500/20"
: "bg-red-500/10 border border-red-500/20"
}`}>
{aiTestResult.success
? <CheckCircle2 className="h-4 w-4 text-green-400 shrink-0 mt-0.5" />
: <XCircle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
}
<p className={`text-xs sm:text-sm leading-relaxed ${
aiTestResult.success ? "text-green-400/90" : "text-red-400/90"
}`}>
{aiTestResult.message}
{aiTestResult.model && ` (${aiTestResult.model})`}
</p>
</div>
)}
{/* Per-channel detail level */}
<div className="space-y-3 pt-3 border-t border-border/50"> <div className="space-y-3 pt-3 border-t border-border/50">
<Label className="text-xs sm:text-sm text-foreground/80">Detail Level per Channel</Label> <div className="space-y-2">
<div className="grid grid-cols-2 gap-3"> <Label className="text-xs sm:text-sm text-foreground/80">Prompt Mode</Label>
{CHANNEL_TYPES.map(ch => ( <Select
<div key={ch} className="flex items-center justify-between gap-2 px-3 py-2 rounded bg-muted/30"> value={config.ai_prompt_mode || "default"}
<span className="text-xs sm:text-sm text-foreground/70 capitalize">{ch}</span> onValueChange={v => {
<Select updateConfig(p => ({ ...p, ai_prompt_mode: v }))
value={config.channel_ai_detail?.[ch] || "standard"} // Show info modal when switching to custom for the first time
onValueChange={v => updateConfig(p => ({ if (v === "custom" && !config.ai_custom_prompt) {
...p, setShowCustomPromptInfo(true)
channel_ai_detail: { ...p.channel_ai_detail, [ch]: v } }
}))} }}
disabled={!editMode} disabled={!editMode}
> >
<SelectTrigger className="h-7 w-[90px] text-xs px-2"> <SelectTrigger className="h-9 text-sm">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{AI_DETAIL_LEVELS.map(l => ( <SelectItem value="default">Default Prompt</SelectItem>
<SelectItem key={l.value} value={l.value} className="text-xs"> <SelectItem value="custom">Custom Prompt</SelectItem>
{l.label} </SelectContent>
</SelectItem> </Select>
))}
</SelectContent>
</Select>
</div>
))}
</div> </div>
{/* Default mode: Detail Level per Channel */}
{(config.ai_prompt_mode || "default") === "default" && (
<div className="space-y-3">
<Label className="text-xs sm:text-sm text-foreground/80">Detail Level per Channel</Label>
<div className="grid grid-cols-2 gap-3">
{CHANNEL_TYPES.map(ch => (
<div key={ch} className="flex items-center justify-between gap-2 px-3 py-2 rounded bg-muted/30">
<span className="text-xs sm:text-sm text-foreground/70 capitalize">{ch}</span>
<Select
value={config.channel_ai_detail?.[ch] || "standard"}
onValueChange={v => updateConfig(p => ({
...p,
channel_ai_detail: { ...p.channel_ai_detail, [ch]: v }
}))}
disabled={!editMode}
>
<SelectTrigger className="h-7 w-[90px] text-xs px-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AI_DETAIL_LEVELS.map(l => (
<SelectItem key={l.value} value={l.value} className="text-xs">
{l.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
<div className="flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
<Info className="h-4 w-4 text-purple-400 shrink-0 mt-0.5" />
<p className="text-xs sm:text-sm text-purple-400/90 leading-relaxed">
AI translates and formats notifications to your selected language. Each channel can have different detail levels.
</p>
</div>
</div>
)}
{/* Custom mode: Editable prompt textarea */}
{config.ai_prompt_mode === "custom" && (
<div className="space-y-3">
<div className="space-y-2">
<Label className="text-xs sm:text-sm text-foreground/80">Custom Prompt</Label>
<textarea
value={config.ai_custom_prompt || ""}
onChange={e => updateConfig(p => ({ ...p, ai_custom_prompt: e.target.value }))}
disabled={!editMode}
placeholder="Enter your custom prompt instructions for the AI..."
className="w-full h-48 px-3 py-2 text-sm rounded-md border border-border bg-background resize-y focus:outline-none focus:ring-2 focus:ring-purple-500/50 disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={!editMode}
onClick={() => {
const blob = new Blob([config.ai_custom_prompt || ""], { type: "text/plain" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "proxmenux_custom_prompt.txt"
a.click()
URL.revokeObjectURL(url)
}}
className="flex items-center gap-1"
>
<Download className="h-4 w-4" />
Export
</Button>
<Button
variant="outline"
size="sm"
disabled={!editMode}
onClick={() => {
const input = document.createElement("input")
input.type = "file"
input.accept = ".txt,.md"
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) {
const text = await file.text()
updateConfig(p => ({ ...p, ai_custom_prompt: text }))
}
}
input.click()
}}
className="flex items-center gap-1"
>
<Upload className="h-4 w-4" />
Import
</Button>
</div>
<div className="flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
<Info className="h-4 w-4 text-purple-400 shrink-0 mt-0.5" />
<p className="text-xs sm:text-sm text-purple-400/90 leading-relaxed">
Define your own prompt rules and format. You control the detail level and style of all notifications. Export to share with others or import prompts from the community.
</p>
</div>
</div>
)}
</div> </div>
<div className="flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20"> {/* Test Connection button - moved to end */}
<Info className="h-4 w-4 text-purple-400 shrink-0 mt-0.5" /> <div className="space-y-3 pt-3 border-t border-border/50">
<p className="text-xs sm:text-sm text-purple-400/90 leading-relaxed"> <button
AI enhancement translates and formats notifications to your selected language. Each channel can have different detail levels. If the AI service is unavailable, standard templates are used as fallback. onClick={handleTestAI}
</p> disabled={
!editMode ||
testingAI ||
!config.ai_model ||
(config.ai_provider !== "ollama" && !config.ai_api_keys?.[config.ai_provider])
}
className="w-full h-9 flex items-center justify-center gap-2 rounded-md text-sm font-medium bg-purple-600 hover:bg-purple-700 text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{testingAI ? (
<><Loader2 className="h-4 w-4 animate-spin" /> Testing...</>
) : (
<><Zap className="h-4 w-4" /> Test Connection</>
)}
</button>
{/* Test result */}
{aiTestResult && (
<div className={`flex items-start gap-2 p-3 rounded-md ${
aiTestResult.success
? "bg-green-500/10 border border-green-500/20"
: "bg-red-500/10 border border-red-500/20"
}`}>
{aiTestResult.success
? <CheckCircle2 className="h-4 w-4 text-green-400 shrink-0 mt-0.5" />
: <XCircle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
}
<p className={`text-xs sm:text-sm leading-relaxed ${
aiTestResult.success ? "text-green-400/90" : "text-red-400/90"
}`}>
{aiTestResult.message}
{aiTestResult.model && ` (${aiTestResult.model})`}
</p>
</div>
)}
</div> </div>
</> </>
)} )}
@@ -1903,6 +2020,77 @@ export function NotificationSettings() {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Custom Prompt Info Modal */}
<Dialog open={showCustomPromptInfo} onOpenChange={setShowCustomPromptInfo}>
<DialogContent className="max-w-[90vw] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg">
<Settings2 className="h-5 w-5 text-purple-400" />
Custom Prompt Mode
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Create your own AI prompt for ProxMenux Monitor notifications
</DialogDescription>
</DialogHeader>
<div className="space-y-4 text-sm">
<div className="space-y-2">
<h4 className="font-medium text-foreground/90">What is a custom prompt?</h4>
<p className="text-muted-foreground text-xs leading-relaxed">
The prompt defines how the AI formats your notifications. With a custom prompt, you control the style, detail level, and format of all messages.
</p>
</div>
<div className="space-y-2">
<h4 className="font-medium text-foreground/90">Important requirements</h4>
<ul className="text-muted-foreground text-xs space-y-1.5">
<li className="flex items-start gap-2">
<span className="text-purple-400 mt-0.5">1.</span>
<span>Your prompt must output in this format:<br/>
<code className="bg-muted px-1.5 py-0.5 rounded text-[11px]">[TITLE]</code> followed by the title, then <code className="bg-muted px-1.5 py-0.5 rounded text-[11px]">[BODY]</code> followed by the message
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-purple-400 mt-0.5">2.</span>
<span>Use plain text only (no markdown) for compatibility with all channels</span>
</li>
<li className="flex items-start gap-2">
<span className="text-purple-400 mt-0.5">3.</span>
<span>The prompt receives raw Proxmox event data as input</span>
</li>
</ul>
</div>
<div className="space-y-2">
<h4 className="font-medium text-foreground/90">Getting started</h4>
<p className="text-muted-foreground text-xs leading-relaxed">
We have added an example prompt to get you started. You can adapt it, export it to share with others, or import prompts from the community.
</p>
</div>
<div className="flex gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={() => {
updateConfig(p => ({ ...p, ai_custom_prompt: EXAMPLE_CUSTOM_PROMPT }))
setShowCustomPromptInfo(false)
}}
className="flex-1"
>
Load Example
</Button>
<Button
size="sm"
onClick={() => setShowCustomPromptInfo(false)}
className="flex-1 bg-purple-600 hover:bg-purple-700 text-white"
>
Got it
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</> </>
) )
} }

View File

@@ -0,0 +1,38 @@
You are a system notification formatter for ProxMenux Monitor, a Proxmox VE monitoring tool.
Your task is to translate and reformat incoming server alert messages into {language}.
═══ ABSOLUTE RULES ═══
1. Translate BOTH title and body to {language}. Every word, label, and unit must be in {language}.
2. NO markdown: no **bold**, no *italic*, no `code`, no headers (#), no bullet lists (- or *)
3. Plain text only — the output is sent to chat apps and email which handle their own formatting
4. Tone: factual, concise, technical. No greetings, no closings, no apologies
5. DO NOT add recommendations, action items, or suggestions ("you should…", "consider…")
6. Present ONLY the facts already in the input — do not invent or assume information
7. OUTPUT ONLY THE FINAL RESULT — never include both original and processed versions.
Do NOT append "Original message:", "Original:", "Source:", or any before/after comparison.
Return ONLY the single, final formatted message in {language}.
8. PLAIN NARRATIVE LINES — if a line in the input is a complete sentence (not a "Label: value"
pair), translate it as-is. Never prepend "Message:", "Note:", or any other label to a sentence.
9. Detail level to apply: {detail_level}
- brief → 2-3 lines, essential data only (status + key metric)
- standard → short paragraph covering who/what/where and the key value
- detailed → full technical breakdown of all available fields
10. Keep the "hostname: " prefix in the title. Translate only the descriptive part.
Example: "pve01: Updates available" → "pve01: Actualizaciones disponibles"
11. EMPTY LIST VALUES — if a list field is empty, "none", or "0":
Always write the translated word for "none" on the line after the label, never leave it blank.
12. DEDUPLICATION — input may contain redundant or repeated information from multiple monitoring sources:
- Identify and merge duplicate facts (same device, same error, same metric mentioned twice)
- Present each unique fact exactly once in a clear, consolidated form
- If the same data appears in different formats, choose the most informative version
{emoji_instructions}
═══ OUTPUT FORMAT ═══
TITLE: <translated title>
BODY:
<translated body>
IMPORTANT:
- Do NOT include the literal words TITLE or BODY anywhere in the translated content

View File

@@ -110,12 +110,13 @@ else
echo "⚠️ ai_providers directory not found" echo "⚠️ ai_providers directory not found"
fi fi
# Copy config files (verified AI models, etc.) # Copy config files (verified AI models, prompts, etc.)
echo "📋 Copying config files..." echo "📋 Copying config files..."
CONFIG_DIR="$APPIMAGE_ROOT/config" CONFIG_DIR="$APPIMAGE_ROOT/config"
if [ -d "$CONFIG_DIR" ]; then if [ -d "$CONFIG_DIR" ]; then
mkdir -p "$APP_DIR/usr/bin/config" mkdir -p "$APP_DIR/usr/bin/config"
cp "$CONFIG_DIR/"*.json "$APP_DIR/usr/bin/config/" 2>/dev/null || true cp "$CONFIG_DIR/"*.json "$APP_DIR/usr/bin/config/" 2>/dev/null || true
cp "$CONFIG_DIR/"*.txt "$APP_DIR/usr/bin/config/" 2>/dev/null || true
echo "✅ Config files copied" echo "✅ Config files copied"
else else
echo "⚠️ config directory not found" echo "⚠️ config directory not found"

View File

@@ -739,6 +739,8 @@ class NotificationManager:
'ai_model': self._config.get('ai_model', ''), 'ai_model': self._config.get('ai_model', ''),
'ai_language': self._config.get('ai_language', 'en'), 'ai_language': self._config.get('ai_language', 'en'),
'ai_ollama_url': self._config.get('ai_ollama_url', ''), 'ai_ollama_url': self._config.get('ai_ollama_url', ''),
'ai_prompt_mode': self._config.get('ai_prompt_mode', 'default'),
'ai_custom_prompt': self._config.get('ai_custom_prompt', ''),
} }
# Get journal context if available # Get journal context if available
@@ -1070,6 +1072,8 @@ class NotificationManager:
'ai_model': self._config.get('ai_model', ''), 'ai_model': self._config.get('ai_model', ''),
'ai_language': self._config.get('ai_language', 'en'), 'ai_language': self._config.get('ai_language', 'en'),
'ai_ollama_url': self._config.get('ai_ollama_url', ''), 'ai_ollama_url': self._config.get('ai_ollama_url', ''),
'ai_prompt_mode': self._config.get('ai_prompt_mode', 'default'),
'ai_custom_prompt': self._config.get('ai_custom_prompt', ''),
} }
results = {} results = {}
@@ -1166,6 +1170,8 @@ class NotificationManager:
'ai_model': self._config.get('ai_model', ''), 'ai_model': self._config.get('ai_model', ''),
'ai_language': self._config.get('ai_language', 'en'), 'ai_language': self._config.get('ai_language', 'en'),
'ai_ollama_url': self._config.get('ai_ollama_url', ''), 'ai_ollama_url': self._config.get('ai_ollama_url', ''),
'ai_prompt_mode': self._config.get('ai_prompt_mode', 'default'),
'ai_custom_prompt': self._config.get('ai_custom_prompt', ''),
} }
ai_enabled = self._config.get('ai_enabled', 'false') ai_enabled = self._config.get('ai_enabled', 'false')
@@ -1559,6 +1565,8 @@ class NotificationManager:
'ai_language': self._config.get('ai_language', 'en'), 'ai_language': self._config.get('ai_language', 'en'),
'ai_ollama_url': self._config.get('ai_ollama_url', 'http://localhost:11434'), 'ai_ollama_url': self._config.get('ai_ollama_url', 'http://localhost:11434'),
'ai_openai_base_url': self._config.get('ai_openai_base_url', ''), 'ai_openai_base_url': self._config.get('ai_openai_base_url', ''),
'ai_prompt_mode': self._config.get('ai_prompt_mode', 'default'),
'ai_custom_prompt': self._config.get('ai_custom_prompt', ''),
'ai_detail_levels': ai_detail_levels, 'ai_detail_levels': ai_detail_levels,
'hostname': self._config.get('hostname', ''), 'hostname': self._config.get('hostname', ''),
'webhook_secret': self._config.get('webhook_secret', ''), 'webhook_secret': self._config.get('webhook_secret', ''),

View File

@@ -1682,18 +1682,23 @@ class AIEnhancer:
language_code = self.config.get('ai_language', 'en') language_code = self.config.get('ai_language', 'en')
language_name = AI_LANGUAGES.get(language_code, 'English') language_name = AI_LANGUAGES.get(language_code, 'English')
# Get token limit for detail level # Check for custom prompt mode
max_tokens = AI_DETAIL_TOKENS.get(detail_level, 200) prompt_mode = self.config.get('ai_prompt_mode', 'default')
custom_prompt = self.config.get('ai_custom_prompt', '')
# Select emoji instructions based on channel type if prompt_mode == 'custom' and custom_prompt.strip():
emoji_instructions = AI_EMOJI_INSTRUCTIONS if use_emojis else AI_NO_EMOJI_INSTRUCTIONS # Custom prompt: user controls everything, use higher token limit
system_prompt = custom_prompt
# Build system prompt with emoji instructions max_tokens = 500 # Allow more tokens for custom prompts
system_prompt = AI_SYSTEM_PROMPT.format( else:
language=language_name, # Default prompt: use detail level and emoji settings
detail_level=detail_level, max_tokens = AI_DETAIL_TOKENS.get(detail_level, 200)
emoji_instructions=emoji_instructions emoji_instructions = AI_EMOJI_INSTRUCTIONS if use_emojis else AI_NO_EMOJI_INSTRUCTIONS
) system_prompt = AI_SYSTEM_PROMPT.format(
language=language_name,
detail_level=detail_level,
emoji_instructions=emoji_instructions
)
# Build user message # Build user message
user_msg = f"Severity: {severity}\nTitle: {title}\nMessage:\n{body}" user_msg = f"Severity: {severity}\nTitle: {title}\nMessage:\n{body}"