feat: Smart Hints v2 — Session-aware KB mit Projekt-Erkennung [appimage]
Some checks failed
Build AppImage / build (push) Has been cancelled

- Hints nur jede 3. Nachricht statt jede (weniger Noise)
- Max 3 statt 5 Hints pro Runde
- Bereits gezeigte Eintraege werden nicht wiederholt (30-ID Buffer)
- Session-Keywords akkumulieren fuer besseren Kontext
- Automatische Projekt-Erkennung (8 Projekte) boosted relevante Tags
- Topic-Switch resettet Hints fuer frische Treffer
- Neue Commands: reset_kb_session, get_kb_session_status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-20 23:53:19 +02:00
parent 5eed2a36bb
commit 100ba9d5d4
3 changed files with 289 additions and 14 deletions

View file

@ -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())?

View file

@ -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<Mutex<KbCache>> =
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<String>, // Alle Keywords der Session (gewichtet durch Reihenfolge)
shown_ids: Vec<i64>, // Bereits gezeigte KB-Einträge (nicht wiederholen!)
message_count: u32, // Anzahl Nachrichten seit Session-Start
last_project: Option<String>, // Erkanntes Projekt (z.B. "claude-desktop", "dolibarr")
}
static SESSION_TOPIC: std::sync::LazyLock<Mutex<SessionTopic>> =
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<String> {
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/<Name>/...
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<String> {
let mut unique: Vec<String> = 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<String> = 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<String> = 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<String, String> {
// 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
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 {
keywords.join(" ")
new_keywords.join(" ")
};
search_knowledge_by_query(&search_query, limit).await
(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<String>) -> Result<String, String> {
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<String>, 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 <knowledge-hints> Block formatieren
let mut hints = Vec::new();
hints.push("<knowledge-hints>".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("</knowledge-hints>".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<String, String> {
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<String, String> {
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<String, String> {
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")
))
}

View file

@ -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,