diff --git a/src-tauri/src/claude.rs b/src-tauri/src/claude.rs index 240e1b8..3928b99 100644 --- a/src-tauri/src/claude.rs +++ b/src-tauri/src/claude.rs @@ -74,6 +74,9 @@ impl Default for ClaudeState { /// Bridge starten pub fn start_bridge(app: &AppHandle) -> Result<(), String> { + // Smart Hints v2: Session-Topic zurücksetzen bei neuer Bridge/Session + knowledge::reset_session_topic(); + // Script-Pfad ermitteln let exe_dir = std::env::current_exe() .map_err(|e| e.to_string())? diff --git a/src-tauri/src/knowledge.rs b/src-tauri/src/knowledge.rs index 7ad8eaf..3461b6e 100644 --- a/src-tauri/src/knowledge.rs +++ b/src-tauri/src/knowledge.rs @@ -1,6 +1,7 @@ // 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}; @@ -68,6 +69,63 @@ impl KbCache { 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; @@ -127,28 +185,49 @@ const STOP_WORDS: &[&str] = &[ /// 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() - // Satzzeichen entfernen .replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_' && c != '.', " ") .split_whitespace() .filter(|w| { w.len() >= 3 && !STOP_WORDS.contains(&w.as_ref()) - // Zahlen alleine sind selten gute Suchbegriffe && !w.chars().all(|c| c.is_numeric()) }) .map(|w| w.to_string()) .collect(); - // Deduplizieren und max 6 Keywords behalten - let mut unique: Vec = Vec::new(); + // 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() >= 6 { + if unique.len() >= 8 { break; } } @@ -212,18 +291,188 @@ fn create_pool() -> Pool { /// 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 2.0: Nutzt jetzt Keyword-Extraktion statt rohe Nachricht als Query +/// 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 { - // Phase 2.0: Keywords aus der Nachricht extrahieren für bessere Suche - let keywords = extract_keywords(query); - let search_query = if keywords.is_empty() { - // Fallback: erste 100 Zeichen der Nachricht - query[..query.len().min(100)].to_string() - } else { - keywords.join(" ") + 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) }; - search_knowledge_by_query(&search_query, limit).await + 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, + 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, 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 @@ -811,3 +1060,23 @@ pub async fn invalidate_kb_cache() -> Result { 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") + )) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 137f6ba..2248375 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -109,6 +109,9 @@ pub fn run() { knowledge::auto_save_error_pattern, // Phase 3: KB-Cache knowledge::invalidate_kb_cache, + // Phase 3.1: Smart Hints v2 — Session-Management + knowledge::reset_kb_session, + knowledge::get_kb_session_status, // Context-Management context::get_sticky_context, context::set_sticky_context,