feat: Smart Hints v2 — Session-aware KB mit Projekt-Erkennung [appimage]
Some checks failed
Build AppImage / build (push) Has been cancelled
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:
parent
5eed2a36bb
commit
100ba9d5d4
3 changed files with 289 additions and 14 deletions
|
|
@ -74,6 +74,9 @@ impl Default for ClaudeState {
|
||||||
|
|
||||||
/// Bridge starten
|
/// Bridge starten
|
||||||
pub fn start_bridge(app: &AppHandle) -> Result<(), String> {
|
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
|
// Script-Pfad ermitteln
|
||||||
let exe_dir = std::env::current_exe()
|
let exe_dir = std::env::current_exe()
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Claude Desktop — Wissensbasis (claude-db)
|
// Claude Desktop — Wissensbasis (claude-db)
|
||||||
// Direkte MySQL-Anbindung zur zentralen Wissensdatenbank
|
// Direkte MySQL-Anbindung zur zentralen Wissensdatenbank
|
||||||
// Phase 2.0: MySQL Pool als Managed State + Themen-Erkennung
|
// 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 mysql_async::{Pool, prelude::*};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -68,6 +69,63 @@ impl KbCache {
|
||||||
static KB_CACHE: std::sync::LazyLock<Mutex<KbCache>> =
|
static KB_CACHE: std::sync::LazyLock<Mutex<KbCache>> =
|
||||||
std::sync::LazyLock::new(|| Mutex::new(KbCache::new(60)));
|
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
|
/// Verbindungskonfiguration — aus ENV-Variablen, Fallback auf Defaults
|
||||||
const MYSQL_PORT: u16 = 3306;
|
const MYSQL_PORT: u16 = 3306;
|
||||||
|
|
||||||
|
|
@ -127,28 +185,49 @@ const STOP_WORDS: &[&str] = &[
|
||||||
|
|
||||||
/// Extrahiert relevante Keywords aus einer User-Nachricht
|
/// Extrahiert relevante Keywords aus einer User-Nachricht
|
||||||
/// Filtert Stoppwörter und kurze Wörter raus, gibt die besten Suchbegriffe zurück
|
/// 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> {
|
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
|
let words: Vec<String> = message
|
||||||
.to_lowercase()
|
.to_lowercase()
|
||||||
// Satzzeichen entfernen
|
|
||||||
.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_' && c != '.', " ")
|
.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_' && c != '.', " ")
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
.filter(|w| {
|
.filter(|w| {
|
||||||
w.len() >= 3
|
w.len() >= 3
|
||||||
&& !STOP_WORDS.contains(&w.as_ref())
|
&& !STOP_WORDS.contains(&w.as_ref())
|
||||||
// Zahlen alleine sind selten gute Suchbegriffe
|
|
||||||
&& !w.chars().all(|c| c.is_numeric())
|
&& !w.chars().all(|c| c.is_numeric())
|
||||||
})
|
})
|
||||||
.map(|w| w.to_string())
|
.map(|w| w.to_string())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Deduplizieren und max 6 Keywords behalten
|
// Deduplizieren und max 8 Keywords behalten (mehr als vorher wegen Session-Kontext)
|
||||||
let mut unique: Vec<String> = Vec::new();
|
|
||||||
for w in words {
|
for w in words {
|
||||||
if !unique.contains(&w) {
|
if !unique.contains(&w) {
|
||||||
unique.push(w);
|
unique.push(w);
|
||||||
}
|
}
|
||||||
if unique.len() >= 6 {
|
if unique.len() >= 8 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -212,18 +291,188 @@ fn create_pool() -> Pool {
|
||||||
|
|
||||||
/// KB-Hints für eine Nachricht laden — fehlertolerant, gibt leeren String bei DB-Problemen
|
/// 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
|
/// 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> {
|
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 new_keywords = extract_keywords(query);
|
||||||
let keywords = extract_keywords(query);
|
let detected_project = detect_project(query);
|
||||||
let search_query = if keywords.is_empty() {
|
|
||||||
// Fallback: erste 100 Zeichen der Nachricht
|
let (session_query, skip_this_round) = {
|
||||||
query[..query.len().min(100)].to_string()
|
let mut topic = SESSION_TOPIC.lock().unwrap();
|
||||||
} else {
|
|
||||||
keywords.join(" ")
|
// 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<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
|
/// 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);
|
println!("🗑️ KB-Cache invalidiert ({} Eintraege geloescht)", count);
|
||||||
Ok(format!("{} Cache-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")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,9 @@ pub fn run() {
|
||||||
knowledge::auto_save_error_pattern,
|
knowledge::auto_save_error_pattern,
|
||||||
// Phase 3: KB-Cache
|
// Phase 3: KB-Cache
|
||||||
knowledge::invalidate_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-Management
|
||||||
context::get_sticky_context,
|
context::get_sticky_context,
|
||||||
context::set_sticky_context,
|
context::set_sticky_context,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue