// Claude Desktop — Wissensbasis (claude-db) // Direkte MySQL-Anbindung zur zentralen Wissensdatenbank // Phase 2.0: MySQL Pool als Managed State + Themen-Erkennung // Phase 3.1: Smart Hints v2 — Session-Context-Aware KB-Hints use mysql_async::{Pool, prelude::*}; use serde::{Deserialize, Serialize}; use chrono::NaiveDateTime; use std::sync::Arc; use std::sync::Mutex; use std::collections::HashMap; use std::time::{Duration, Instant}; use tauri::{AppHandle, Manager}; // ============ KB-Cache (RAM) ============ // Cached KB-Suchergebnisse im RAM. Spart MySQL-Roundtrip pro Nachricht. // Invalidierung: automatisch nach 60 Sekunden (TTL). struct CacheEntry { result: String, created_at: Instant, } struct KbCache { entries: HashMap, ttl: Duration, } impl KbCache { fn new(ttl_secs: u64) -> Self { Self { entries: HashMap::new(), ttl: Duration::from_secs(ttl_secs), } } fn get(&self, key: &str) -> Option<&str> { if let Some(entry) = self.entries.get(key) { if entry.created_at.elapsed() < self.ttl { return Some(&entry.result); } } None } fn insert(&mut self, key: String, result: String) { // Max 100 Eintraege — aelteste raus wenn voll if self.entries.len() >= 100 { self.evict_oldest(); } self.entries.insert(key, CacheEntry { result, created_at: Instant::now() }); } fn evict_oldest(&mut self) { if let Some(oldest_key) = self.entries.iter() .min_by_key(|(_, v)| v.created_at) .map(|(k, _)| k.clone()) { self.entries.remove(&oldest_key); } } #[allow(dead_code)] fn invalidate_expired(&mut self) { self.entries.retain(|_, v| v.created_at.elapsed() < self.ttl); } } // Globaler Cache — 60 Sekunden TTL static KB_CACHE: std::sync::LazyLock> = std::sync::LazyLock::new(|| Mutex::new(KbCache::new(60))); // ============ Smart Hints v2: Session Topic Tracking ============ // Akkumuliert Keywords über die Session hinweg, verhindert Wiederholungen, // und liefert nur alle 3 Nachrichten frische Hints. #[derive(Default)] struct SessionTopic { keywords: Vec, // Alle Keywords der Session (gewichtet durch Reihenfolge) shown_ids: Vec, // Bereits gezeigte KB-Einträge (nicht wiederholen!) message_count: u32, // Anzahl Nachrichten seit Session-Start last_project: Option, // Erkanntes Projekt (z.B. "claude-desktop", "dolibarr") } static SESSION_TOPIC: std::sync::LazyLock> = std::sync::LazyLock::new(|| Mutex::new(SessionTopic::default())); /// Session-Topic zurücksetzen — aufrufen wenn eine neue Chat-Session beginnt pub fn reset_session_topic() { let mut topic = SESSION_TOPIC.lock().unwrap(); *topic = SessionTopic::default(); println!("🔄 Session-Topic zurückgesetzt (Smart Hints v2)"); } /// Erkennt das aktuelle Projekt aus Nachrichteninhalt oder Dateipfaden fn detect_project(message: &str) -> Option { let lower = message.to_lowercase(); let projects: &[(&str, &[&str])] = &[ ("claude-desktop", &["claudedesktop", "claude desktop", "claude-desktop", "tauri", "svelte", "bridge"]), ("dolibarr", &["dolibarr", "custom module", "awl.", "dolibarr_test", "produktkarte", "stockkonversion", "importzugferd"]), ("videokonverter", &["videokonverter", "vk-", "ffmpeg", "docker.videokonverter"]), ("nixos", &["nixos", "configuration.nix", "nix-shell", "nixos-rebuild"]), ("homeassistant", &["home assistant", "home-assistant", ".storage/", "lovelace"]), ("leckerbuch", &["leckerbuch", "rezept"]), ("elektroplan", &["elektroplan", "eplan"]), ("vde-katalog", &["vde katalog", "vde-katalog", "normen"]), ]; for (name, triggers) in projects { if triggers.iter().any(|t| lower.contains(t)) { return Some(name.to_string()); } } // Dateipfad-basierte Erkennung: /mnt/.../Projekte//... if let Some(idx) = lower.find("projekte/") { let rest = &message[idx + 9..]; if let Some(end) = rest.find('/') { let project_from_path = rest[..end].to_lowercase() .replace(' ', "-"); if project_from_path.len() >= 3 { return Some(project_from_path); } } } None } /// Verbindungskonfiguration — aus ENV-Variablen, Fallback auf Defaults const MYSQL_PORT: u16 = 3306; fn mysql_host() -> String { std::env::var("CLAUDE_MYSQL_HOST").unwrap_or_else(|_| "192.168.155.11".to_string()) } fn mysql_user() -> String { std::env::var("CLAUDE_MYSQL_USER").unwrap_or_else(|_| "claude".to_string()) } fn mysql_pass() -> String { std::env::var("CLAUDE_MYSQL_PASS").unwrap_or_else(|_| "8715".to_string()) } fn mysql_db() -> String { std::env::var("CLAUDE_MYSQL_DB").unwrap_or_else(|_| "claude".to_string()) } /// Managed MySQL Pool — wird einmal beim App-Start erstellt /// Alle Knowledge-Funktionen nutzen diesen Pool statt jedes Mal einen neuen zu erstellen pub type MysqlPoolState = Arc>; /// Erstellt den globalen MySQL Pool (einmal beim App-Start aufrufen) pub fn create_managed_pool() -> MysqlPoolState { let url = format!( "mysql://{}:{}@{}:{}/{}", mysql_user(), mysql_pass(), mysql_host(), MYSQL_PORT, mysql_db() ); match Pool::new(url.as_str()) { pool => { println!("🗄️ MySQL Pool erstellt ({}:{})", mysql_host(), MYSQL_PORT); Arc::new(Some(pool)) } } } /// Pool aus AppHandle holen — Fallback auf neuen Pool wenn State nicht verfügbar /// TODO: Bestehende Commands schrittweise auf get_pool(app) migrieren #[allow(dead_code)] fn get_pool(app: Option<&AppHandle>) -> Pool { if let Some(app) = app { if let Some(pool_state) = app.try_state::() { if let Some(pool) = pool_state.as_ref() { return pool.clone(); } } } // Fallback: neuen Pool erstellen (für Aufrufe ohne AppHandle, z.B. search_knowledge_internal) create_pool() } /// Deutsche Stoppwörter die bei der Themen-Erkennung gefiltert werden const STOP_WORDS: &[&str] = &[ "der", "die", "das", "den", "dem", "des", "ein", "eine", "einer", "einem", "einen", "und", "oder", "aber", "doch", "noch", "auch", "nur", "schon", "mal", "dann", "ist", "sind", "war", "hat", "haben", "wird", "werden", "kann", "können", "soll", "muss", "müssen", "darf", "will", "wollen", "möchte", "würde", "sollte", "ich", "du", "er", "sie", "es", "wir", "ihr", "mein", "dein", "sein", "mit", "für", "auf", "von", "aus", "bei", "nach", "über", "unter", "vor", "wie", "was", "wer", "wo", "wann", "warum", "wieso", "weshalb", "nicht", "kein", "keine", "keinen", "keinem", "this", "that", "the", "and", "for", "with", "from", "into", "bitte", "danke", "okay", "alles", "nächste", "mach", "zeig", "gib", "mir", "dir", "uns", "hier", "dort", "jetzt", "gerade", "einfach", "phase", "feature", "erstelle", "implementiere", "baue", ]; /// Extrahiert relevante Keywords aus einer User-Nachricht /// Filtert Stoppwörter und kurze Wörter raus, gibt die besten Suchbegriffe zurück /// Phase 3.1: Erkennt auch Projektnamen und technische Terme aus Dateipfaden pub fn extract_keywords(message: &str) -> Vec { let mut unique: Vec = Vec::new(); // Projekt-Name aus Pfad extrahieren (z.B. /mnt/.../Projekte/Leckerbuch/... → leckerbuch) if let Some(proj) = detect_project(message) { if !unique.contains(&proj) { unique.push(proj); } } // Technische Terme die als Ganzes erhalten bleiben sollen let lower = message.to_lowercase(); let tech_terms = [ "mysql", "docker", "tauri", "svelte", "rust", "cargo", "nixos", "forgejo", "portainer", "claude-bridge", "wissensbasis", "knowledge", "webhook", "api", "cors", "jwt", "sse", "websocket", ]; for term in &tech_terms { if lower.contains(term) && !unique.contains(&term.to_string()) { unique.push(term.to_string()); } } // Normale Wort-Extraktion let words: Vec = message .to_lowercase() .replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_' && c != '.', " ") .split_whitespace() .filter(|w| { w.len() >= 3 && !STOP_WORDS.contains(&w.as_ref()) && !w.chars().all(|c| c.is_numeric()) }) .map(|w| w.to_string()) .collect(); // Deduplizieren und max 8 Keywords behalten (mehr als vorher wegen Session-Kontext) for w in words { if !unique.contains(&w) { unique.push(w); } if unique.len() >= 8 { break; } } unique } /// Wissenseintrag aus der knowledge-Tabelle #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KnowledgeEntry { pub id: i64, pub category: String, pub title: String, pub content: String, pub tags: Option, pub priority: i32, pub status: String, pub related_ids: Option, pub source: Option, pub created_at: String, pub updated_at: String, } /// Neuer Wissenseintrag (ohne id, timestamps) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewKnowledge { pub category: String, pub title: String, pub content: String, pub tags: Option, pub priority: Option, pub source: Option, } /// Suchergebnis mit Relevanz-Score #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchResult { pub entry: KnowledgeEntry, pub relevance: f64, } /// Verfügbare Kategorien #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CategoryInfo { pub name: String, pub count: i64, } // ============ Hilfsfunktionen ============ /// Erstellt einen MySQL Connection-Pool fn create_pool() -> Pool { let url = format!( "mysql://{}:{}@{}:{}/{}", mysql_user(), mysql_pass(), mysql_host(), MYSQL_PORT, mysql_db() ); Pool::new(url.as_str()) } // ============ Interne Funktionen (kein Tauri-Command) ============ /// KB-Hints für eine Nachricht laden — fehlertolerant, gibt leeren String bei DB-Problemen /// Wird von claude.rs aufgerufen bevor die Nachricht an die Bridge geht /// Phase 3.1 (Smart Hints v2): Session-Context-Aware — akkumuliert Keywords, /// filtert bereits gezeigte Einträge, liefert nur alle 3 Nachrichten Hints. pub async fn search_knowledge_internal(query: &str, limit: i32) -> Result { let new_keywords = extract_keywords(query); let detected_project = detect_project(query); let (session_query, skip_this_round) = { let mut topic = SESSION_TOPIC.lock().unwrap(); // Projekt-Wechsel erkennen → Reset if let Some(ref proj) = detected_project { if topic.last_project.as_ref() != Some(proj) { println!("🔀 Projekt-Wechsel erkannt: {:?} → {} — Reset Hints", topic.last_project, proj); topic.shown_ids.clear(); topic.message_count = 0; topic.last_project = Some(proj.clone()); } } // Keywords zur Session hinzufügen (dedupliziert) for kw in &new_keywords { if !topic.keywords.contains(kw) { topic.keywords.push(kw.clone()); } } // Max 20 Keywords behalten (neueste bevorzugt) if topic.keywords.len() > 20 { let start = topic.keywords.len() - 20; topic.keywords = topic.keywords[start..].to_vec(); } topic.message_count += 1; // Nur alle 3 Nachrichten frische Hints liefern (nicht jedes Mal!) // Erste Nachricht (count=1) bekommt immer Hints let skip = topic.message_count > 1 && topic.message_count % 3 != 1; // Session-Query bauen: letzte 6 Keywords für breiteren Kontext let query = if topic.keywords.len() > 3 { let start = topic.keywords.len().saturating_sub(6); topic.keywords[start..].join(" ") } else if new_keywords.is_empty() { query[..query.len().min(100)].to_string() } else { new_keywords.join(" ") }; (query, skip) }; if skip_this_round { return Ok(String::new()); } // Projekt-Boost: wenn Projekt erkannt, an Query anhängen let final_query = if let Some(ref proj) = detected_project { format!("{} {}", session_query, proj) } else { session_query.clone() }; // Direkt die session-aware Filterung nutzen (max 3 Hints pro Runde) let filtered = search_knowledge_filtered(&final_query, (limit.min(3)) as usize, &detected_project).await?; Ok(filtered) } /// Session-aware Suche mit Filterung bereits gezeigter Einträge async fn search_knowledge_filtered(search_query: &str, limit: usize, project: &Option) -> Result { let pool = create_pool(); let conn_result = tokio::time::timeout( std::time::Duration::from_secs(3), pool.get_conn() ).await; let mut conn = match conn_result { Ok(Ok(c)) => c, Ok(Err(e)) => { println!("⚠️ KB-Hints: DB-Verbindung fehlgeschlagen: {}", e); return Ok(String::new()); } Err(_) => { println!("⚠️ KB-Hints: DB-Timeout (3s)"); return Ok(String::new()); } }; // Mehr laden als nötig für Filterung let fetch_limit = (limit + 10) as i32; let results: Vec<(i64, String, String, String, Option, i32, f64)> = if let Some(ref proj) = project { // Mit Projekt-Boost: Einträge mit passendem Tag werden bevorzugt let proj_pattern = format!("%{}%", proj); conn.exec( r#"SELECT id, category, title, SUBSTRING(content, 1, 300) as content_preview, tags, priority, MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) as relevance FROM knowledge WHERE status = 'active' AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) ORDER BY CASE WHEN tags LIKE ? THEN 1 ELSE 0 END DESC, priority DESC, usage_count DESC, relevance DESC LIMIT ?"#, (search_query, search_query, &proj_pattern, fetch_limit), ).await.map_err(|e| e.to_string())? } else { conn.exec( r#"SELECT id, category, title, SUBSTRING(content, 1, 300) as content_preview, tags, priority, MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) as relevance FROM knowledge WHERE status = 'active' AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) ORDER BY priority DESC, usage_count DESC, relevance DESC LIMIT ?"#, (search_query, search_query, fetch_limit), ).await.map_err(|e| e.to_string())? }; drop(conn); let _ = pool.disconnect().await; if results.is_empty() { return Ok(String::new()); } // Bereits gezeigte IDs filtern let filtered: Vec<_> = { let topic = SESSION_TOPIC.lock().unwrap(); results.into_iter() .filter(|(id, _, _, _, _, _, _)| !topic.shown_ids.contains(id)) .take(limit) .collect() }; if filtered.is_empty() { return Ok(String::new()); } // Gezeigte IDs merken { let mut topic = SESSION_TOPIC.lock().unwrap(); for (id, _, _, _, _, _, _) in &filtered { topic.shown_ids.push(*id); } // Max 30 IDs behalten if topic.shown_ids.len() > 30 { let start = topic.shown_ids.len() - 30; topic.shown_ids = topic.shown_ids[start..].to_vec(); } } // Als Block formatieren let mut hints = Vec::new(); hints.push("".to_string()); hints.push(format!("Relevante KB-Einträge ({} Treffer):", filtered.len())); for (id, category, title, content_preview, tags, _priority, _relevance) in &filtered { hints.push(format!("\n**#{}** [{}] {}", id, category, title)); hints.push(content_preview.clone()); if let Some(t) = tags { if !t.is_empty() { hints.push(format!("Tags: {}", t)); } } } hints.push("".to_string()); let block = hints.join("\n"); println!("🔍 Smart Hints v2 für '{}': {} Treffer, {} Bytes", &search_query[..search_query.len().min(40)], filtered.len(), block.len()); Ok(block) } /// Sucht in der KB mit einem vorbereiteten Query-String /// Zentrale Suchfunktion die von search_knowledge_internal und proactive_session_hints genutzt wird /// Phase 3: Mit RAM-Cache — gleiche Query innerhalb 60s wird instant aus dem Cache bedient pub async fn search_knowledge_by_query(search_query: &str, limit: i32) -> Result { // Cache-Key: query + limit let cache_key = format!("{}:{}", search_query, limit); // Cache-Hit prüfen { let cache = KB_CACHE.lock().unwrap(); if let Some(cached) = cache.get(&cache_key) { println!("⚡ KB-Cache HIT für '{}'", &search_query[..search_query.len().min(40)]); return Ok(cached.to_string()); } } let pool = create_pool(); // Verbindung mit Timeout — DB nicht erreichbar soll nicht blockieren let conn_result = tokio::time::timeout( std::time::Duration::from_secs(3), pool.get_conn() ).await; let mut conn = match conn_result { Ok(Ok(c)) => c, Ok(Err(e)) => { println!("⚠️ KB-Hints: DB-Verbindung fehlgeschlagen: {}", e); return Ok(String::new()); } Err(_) => { println!("⚠️ KB-Hints: DB-Timeout (3s)"); return Ok(String::new()); } }; // Volltext-Suche — nur Titel und Zusammenfassung, kein ganzer Content let results: Vec<(i64, String, String, String, Option, f64)> = conn.exec( r#"SELECT id, category, title, SUBSTRING(content, 1, 300) as content_preview, tags, MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) as relevance FROM knowledge WHERE status = 'active' AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) ORDER BY priority DESC, usage_count DESC, relevance DESC LIMIT ?"#, (search_query, search_query, limit), ).await.map_err(|e| e.to_string())?; drop(conn); let _ = pool.disconnect().await; if results.is_empty() { println!("🔍 KB-Hints für '{}': keine Treffer", &search_query[..search_query.len().min(40)]); // Leere Ergebnisse auch cachen — verhindert wiederholte DB-Abfragen let mut cache = KB_CACHE.lock().unwrap(); cache.insert(cache_key, String::new()); return Ok(String::new()); } // Als Block formatieren let mut hints = Vec::new(); hints.push("".to_string()); hints.push(format!("Relevante KB-Einträge ({} Treffer):", results.len())); for (id, category, title, content_preview, tags, _relevance) in &results { hints.push(format!("\n**#{}** [{}] {}", id, category, title)); hints.push(content_preview.clone()); if let Some(t) = tags { if !t.is_empty() { hints.push(format!("Tags: {}", t)); } } } hints.push("".to_string()); let block = hints.join("\n"); println!("🔍 KB-Hints für '{}': {} Treffer, {} Bytes (cached)", &search_query[..search_query.len().min(40)], results.len(), block.len()); // In Cache speichern { let mut cache = KB_CACHE.lock().unwrap(); cache.insert(cache_key, block.clone()); } Ok(block) } /// Phase 2.0: Proaktive KB-Abfrage bei SessionStart /// Lädt relevante Einträge basierend auf Projekt-Kontext und letzter Aktivität pub async fn proactive_session_hints(project_name: Option<&str>) -> Result { let mut search_terms = Vec::new(); // Projekt-bezogene Begriffe if let Some(name) = project_name { search_terms.push(name.to_string()); // Projekt-spezifische Zusatzbegriffe let lower = name.to_lowercase(); if lower.contains("dolibarr") { search_terms.push("dolibarr".to_string()); } if lower.contains("claude") { search_terms.push("claude desktop tauri".to_string()); } if lower.contains("bericht") { search_terms.push("bericht modul".to_string()); } if lower.contains("kunden") { search_terms.push("kundenkarte schaltplan".to_string()); } } // Allgemeine Begriffe für Session-Start (aktive Fehler, wichtige Patterns) search_terms.push("fehler workaround aktiv".to_string()); let query = search_terms.join(" "); println!("📋 Proaktive KB-Abfrage: '{}'", &query[..query.len().min(60)]); search_knowledge_by_query(&query, 5).await } /// Phase 2.0: Auto-Fehler-Pattern in KB speichern /// Wird aufgerufen wenn ein Fehler 3x aufgetreten ist pub async fn save_error_pattern_to_kb( error_hash: &str, error_message: &str, tool: &str, occurrence_count: i32, ) -> Result { let pool = create_pool(); let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?; // Prüfen ob für diesen Hash schon ein KB-Eintrag existiert let existing: Option = conn.exec_first( r#"SELECT id FROM knowledge WHERE category = 'fehler' AND tags LIKE CONCAT('%', ?, '%') AND status = 'active' LIMIT 1"#, (error_hash,), ).await.map_err(|e| e.to_string())?; if let Some(id) = existing { // Schon vorhanden — nur Occurrence aktualisieren conn.exec_drop( r#"UPDATE knowledge SET content = CONCAT(content, '\n\n---\nWeiteres Auftreten: ', NOW(), ' (', ?, 'x gesamt)'), updated_at = NOW() WHERE id = ?"#, (occurrence_count, id), ).await.map_err(|e| e.to_string())?; drop(conn); let _ = pool.disconnect().await; println!("📝 Fehler-Pattern #{} aktualisiert ({}x)", id, occurrence_count); return Ok(id); } // Neuen KB-Eintrag erstellen let title = format!("Auto-Pattern: {} Fehler in {}", &error_message[..error_message.len().min(60)], tool); let content = format!( "## Automatisch erkanntes Fehler-Pattern\n\n\ **Tool:** {}\n\ **Häufigkeit:** {}x aufgetreten\n\ **Fehlermeldung:**\n```\n{}\n```\n\n\ **Hash:** `{}`\n\n\ > Dieses Pattern wurde automatisch erstellt nachdem der Fehler {}x aufgetreten ist.\n\ > Bitte Lösung/Workaround ergänzen.", tool, occurrence_count, &error_message[..error_message.len().min(500)], error_hash, occurrence_count ); let tags = format!("auto-pattern,fehler,{},{}", tool.to_lowercase(), error_hash); conn.exec_drop( r#"INSERT INTO knowledge (category, title, content, tags, priority, status, source, created_at, updated_at) VALUES ('fehler', ?, ?, ?, 2, 'active', 'auto-pattern', NOW(), NOW())"#, (&title, &content, &tags), ).await.map_err(|e| e.to_string())?; let id: i64 = conn.last_insert_id().ok_or("Keine Insert-ID")? as i64; drop(conn); let _ = pool.disconnect().await; println!("🆕 Neues Fehler-Pattern in KB gespeichert: #{} ({}x {})", id, occurrence_count, tool); Ok(id) } // ============ Tauri Commands ============ /// Wissensbasis durchsuchen (Volltext) #[tauri::command] pub async fn search_knowledge( query: String, category: Option, limit: Option, ) -> Result, String> { let pool = create_pool(); let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?; let limit = limit.unwrap_or(20); // Volltext-Suche mit optionalem Kategorie-Filter let results: Vec = if let Some(cat) = category { conn.exec_map( r#"SELECT id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at, MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) as relevance FROM knowledge WHERE MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) AND category = ? AND status = 'active' ORDER BY priority DESC, usage_count DESC, relevance DESC LIMIT ?"#, (&query, &query, &cat, limit), |(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at, relevance): (i64, String, String, String, Option, i32, String, Option, Option, NaiveDateTime, NaiveDateTime, f64)| { SearchResult { entry: KnowledgeEntry { id, category, title, content, tags, priority, status, related_ids, source, created_at: created_at.format("%Y-%m-%d %H:%M:%S").to_string(), updated_at: updated_at.format("%Y-%m-%d %H:%M:%S").to_string(), }, relevance, } } ).await.map_err(|e| e.to_string())? } else { conn.exec_map( r#"SELECT id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at, MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) as relevance FROM knowledge WHERE MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) AND status = 'active' ORDER BY priority DESC, usage_count DESC, relevance DESC LIMIT ?"#, (&query, &query, limit), |(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at, relevance): (i64, String, String, String, Option, i32, String, Option, Option, NaiveDateTime, NaiveDateTime, f64)| { SearchResult { entry: KnowledgeEntry { id, category, title, content, tags, priority, status, related_ids, source, created_at: created_at.format("%Y-%m-%d %H:%M:%S").to_string(), updated_at: updated_at.format("%Y-%m-%d %H:%M:%S").to_string(), }, relevance, } } ).await.map_err(|e| e.to_string())? }; drop(conn); pool.disconnect().await.map_err(|e| e.to_string())?; println!("🔍 Wissensbasis-Suche '{}': {} Treffer", query, results.len()); Ok(results) } /// Wissenseintrag nach ID laden #[tauri::command] pub async fn get_knowledge(id: i64) -> Result, String> { let pool = create_pool(); let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?; let result: Option = conn.exec_first( r#"SELECT id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at FROM knowledge WHERE id = ?"#, (id,) ).await.map_err(|e| e.to_string())? .map(|(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at): (i64, String, String, String, Option, i32, String, Option, Option, NaiveDateTime, NaiveDateTime)| { KnowledgeEntry { id, category, title, content, tags, priority, status, related_ids, source, created_at: created_at.format("%Y-%m-%d %H:%M:%S").to_string(), updated_at: updated_at.format("%Y-%m-%d %H:%M:%S").to_string(), } }); drop(conn); pool.disconnect().await.map_err(|e| e.to_string())?; Ok(result) } /// Neuen Wissenseintrag speichern ("Das merken") #[tauri::command] pub async fn save_knowledge(entry: NewKnowledge) -> Result { let pool = create_pool(); let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?; let priority = entry.priority.unwrap_or(2); conn.exec_drop( r#"INSERT INTO knowledge (category, title, content, tags, priority, status, source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'active', ?, NOW(), NOW())"#, (&entry.category, &entry.title, &entry.content, &entry.tags, priority, &entry.source) ).await.map_err(|e| e.to_string())?; let id: i64 = conn.last_insert_id().ok_or("Keine Insert-ID")? as i64; drop(conn); pool.disconnect().await.map_err(|e| e.to_string())?; println!("💾 Wissen gespeichert: {} (ID: {})", entry.title, id); Ok(id) } /// Alle Kategorien mit Anzahl der Einträge #[tauri::command] pub async fn get_knowledge_categories() -> Result, String> { let pool = create_pool(); let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?; let categories: Vec = conn.query_map( r#"SELECT category, COUNT(*) as count FROM knowledge WHERE status = 'active' GROUP BY category ORDER BY count DESC"#, |(name, count): (String, i64)| CategoryInfo { name, count } ).await.map_err(|e| e.to_string())?; drop(conn); pool.disconnect().await.map_err(|e| e.to_string())?; Ok(categories) } /// Letzte N Wissenseinträge laden #[tauri::command] pub async fn get_recent_knowledge( limit: Option, category: Option, ) -> Result, String> { let pool = create_pool(); let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?; let limit = limit.unwrap_or(10); let entries: Vec = if let Some(cat) = category { conn.exec_map( r#"SELECT id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at FROM knowledge WHERE status = 'active' AND category = ? ORDER BY priority DESC, updated_at DESC LIMIT ?"#, (&cat, limit), |(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at): (i64, String, String, String, Option, i32, String, Option, Option, NaiveDateTime, NaiveDateTime)| { KnowledgeEntry { id, category, title, content, tags, priority, status, related_ids, source, created_at: created_at.format("%Y-%m-%d %H:%M:%S").to_string(), updated_at: updated_at.format("%Y-%m-%d %H:%M:%S").to_string(), } } ).await.map_err(|e| e.to_string())? } else { conn.exec_map( r#"SELECT id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at FROM knowledge WHERE status = 'active' ORDER BY priority DESC, updated_at DESC LIMIT ?"#, (limit,), |(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at): (i64, String, String, String, Option, i32, String, Option, Option, NaiveDateTime, NaiveDateTime)| { KnowledgeEntry { id, category, title, content, tags, priority, status, related_ids, source, created_at: created_at.format("%Y-%m-%d %H:%M:%S").to_string(), updated_at: updated_at.format("%Y-%m-%d %H:%M:%S").to_string(), } } ).await.map_err(|e| e.to_string())? }; drop(conn); pool.disconnect().await.map_err(|e| e.to_string())?; Ok(entries) } /// Wissens-Hints für ein Tool/Kommando laden /// Sucht relevante Einträge basierend auf Tool-Name und Kommando #[tauri::command] pub async fn get_tool_hints( tool: String, command: Option, context: Option, ) -> Result, String> { let pool = create_pool(); let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?; // Suchbegriffe aus Tool + Command + Context zusammenbauen let mut search_terms = vec![tool.clone()]; // Tool-spezifische Kategorien mappen let category: Option<&str> = match tool.as_str() { "Bash" => { if let Some(ref cmd) = command { // Relevante Begriffe aus Bash-Kommando extrahieren if cmd.contains("npm") || cmd.contains("node") { search_terms.push("npm".to_string()); } if cmd.contains("git") { search_terms.push("git".to_string()); } if cmd.contains("docker") { search_terms.push("docker".to_string()); } if cmd.contains("cargo") { search_terms.push("cargo".to_string()); search_terms.push("rust".to_string()); } if cmd.contains("dolibarr") { search_terms.push("dolibarr".to_string()); } if cmd.contains("mysql") { search_terms.push("mysql".to_string()); search_terms.push("sql".to_string()); } } None // Keine spezifische Kategorie } "Read" | "Write" | "Edit" => { if let Some(ref cmd) = command { // Aus Dateipfad relevante Begriffe extrahieren if cmd.contains("dolibarr") { search_terms.push("dolibarr".to_string()); } if cmd.contains(".php") { search_terms.push("php".to_string()); } if cmd.contains(".rs") { search_terms.push("rust".to_string()); } if cmd.contains(".ts") || cmd.contains(".svelte") { search_terms.push("svelte".to_string()); } } None } _ => None, }; // Optional: Context-Begriffe hinzufügen if let Some(ref ctx) = context { // Wichtige Begriffe aus Context extrahieren (max 3) for word in ctx.split_whitespace().take(10) { if word.len() > 4 && !search_terms.contains(&word.to_lowercase()) { search_terms.push(word.to_lowercase()); } } } // Suchquery bauen let query_string = search_terms.join(" "); // Suche mit Volltext und optionalem Kategorie-Filter let entries: Vec = if let Some(cat) = category { conn.exec_map( r#"SELECT id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at FROM knowledge WHERE status = 'active' AND category = ? AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) ORDER BY priority DESC, updated_at DESC LIMIT 3"#, (&cat, &query_string), |(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at): (i64, String, String, String, Option, i32, String, Option, Option, NaiveDateTime, NaiveDateTime)| { KnowledgeEntry { id, category, title, content, tags, priority, status, related_ids, source, created_at: created_at.format("%Y-%m-%d %H:%M:%S").to_string(), updated_at: updated_at.format("%Y-%m-%d %H:%M:%S").to_string(), } } ).await.map_err(|e| e.to_string())? } else { conn.exec_map( r#"SELECT id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at FROM knowledge WHERE status = 'active' AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) ORDER BY priority DESC, updated_at DESC LIMIT 3"#, (&query_string,), |(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at): (i64, String, String, String, Option, i32, String, Option, Option, NaiveDateTime, NaiveDateTime)| { KnowledgeEntry { id, category, title, content, tags, priority, status, related_ids, source, created_at: created_at.format("%Y-%m-%d %H:%M:%S").to_string(), updated_at: updated_at.format("%Y-%m-%d %H:%M:%S").to_string(), } } ).await.map_err(|e| e.to_string())? }; drop(conn); pool.disconnect().await.map_err(|e| e.to_string())?; if !entries.is_empty() { println!("💡 {} Wissens-Hints geladen für Tool '{}': {:?}", entries.len(), tool, entries.iter().map(|e| &e.title).collect::>() ); } Ok(entries) } /// Wissens-Hints als formatierter Kontext-Block #[tauri::command] pub async fn format_tool_hints( tool: String, command: Option, context: Option, ) -> Result { let entries = get_tool_hints(tool.clone(), command, context).await?; if entries.is_empty() { return Ok(String::new()); } let mut hints = Vec::new(); hints.push("".to_string()); hints.push(format!("Relevante Informationen für {}:", tool)); for entry in entries { hints.push(format!("\n**{}** ({})", entry.title, entry.category)); // Content auf ~300 Zeichen kürzen (sicher an Char-Boundary) let content = if entry.content.len() > 300 { let end = entry.content.char_indices() .take_while(|(i, _)| *i < 300) .last() .map(|(i, c)| i + c.len_utf8()) .unwrap_or(300.min(entry.content.len())); format!("{}...", &entry.content[..end]) } else { entry.content }; hints.push(content); } hints.push("".to_string()); Ok(hints.join("\n")) } /// Verbindung zur Wissensbasis testen #[tauri::command] pub async fn test_knowledge_connection() -> Result { let pool = create_pool(); let mut conn = pool.get_conn().await.map_err(|e| format!("Verbindungsfehler: {}", e))?; let count: i64 = conn.query_first("SELECT COUNT(*) FROM knowledge") .await .map_err(|e| e.to_string())? .unwrap_or(0); drop(conn); pool.disconnect().await.map_err(|e| e.to_string())?; println!("✅ Wissensbasis verbunden: {} Einträge", count); Ok(format!("Verbunden! {} Einträge in der Wissensbasis", count)) } // ============ Phase 2.0: Neue Commands ============ /// Keywords aus einer Nachricht extrahieren (für Frontend-Debug/Anzeige) #[tauri::command] pub async fn extract_message_keywords(message: String) -> Result, String> { Ok(extract_keywords(&message)) } /// Proaktive KB-Hints bei Session-Start laden #[tauri::command] pub async fn get_session_hints(project_name: Option) -> Result { proactive_session_hints(project_name.as_deref()).await } /// Fehler-Pattern automatisch in KB speichern (aufgerufen von Frontend bei 3+ Occurrences) #[tauri::command] pub async fn auto_save_error_pattern( error_hash: String, error_message: String, tool: String, occurrence_count: i32, ) -> Result { save_error_pattern_to_kb(&error_hash, &error_message, &tool, occurrence_count).await } /// Phase 3: KB-Cache invalidieren (alle Eintraege loeschen) /// Aufrufen wenn neue KB-Eintraege gespeichert wurden #[tauri::command] pub async fn invalidate_kb_cache() -> Result { let mut cache = KB_CACHE.lock().unwrap(); let count = cache.entries.len(); cache.entries.clear(); println!("🗑️ KB-Cache invalidiert ({} Eintraege geloescht)", count); Ok(format!("{} Cache-Eintraege geloescht", count)) } /// Phase 3.1: Session-Topic manuell zurücksetzen (z.B. bei neuem Chat im Frontend) #[tauri::command] pub async fn reset_kb_session() -> Result { reset_session_topic(); Ok("Session-Topic zurückgesetzt".to_string()) } /// Phase 3.1: Session-Status abfragen (Debug/Monitoring) #[tauri::command] pub async fn get_kb_session_status() -> Result { let topic = SESSION_TOPIC.lock().unwrap(); Ok(format!( "Keywords: {} | Shown: {} | Messages: {} | Project: {}", topic.keywords.len(), topic.shown_ids.len(), topic.message_count, topic.last_project.as_deref().unwrap_or("none") )) } /// Block B: Usage-Tracking — markiert einen KB-Eintrag als hilfreich/genutzt. /// Wird beim Tool-Erfolg (tool-end mit success=true) aufgerufen fuer alle Hints /// die zur laufenden Tool-Phase geladen wurden. Async, fire-and-forget. #[tauri::command] pub async fn track_knowledge_hit(id: i64) -> Result<(), String> { let pool = create_pool(); let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?; conn.exec_drop( "UPDATE knowledge SET usage_count = usage_count + 1, last_used = NOW() WHERE id = ?", (id,), ).await.map_err(|e| e.to_string())?; drop(conn); let _ = pool.disconnect().await; Ok(()) }