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
|
||||
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())?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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<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")
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue