Files
ai-agent/internal/brain/ask.go
Christoph K. 905981cd1e zwischenstand
2026-03-20 23:24:56 +01:00

254 lines
7.1 KiB
Go
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ask.go Sucht relevante Chunks in Qdrant und beantwortet Fragen mit einem LLM
package brain
import (
"context"
"fmt"
"log"
"log/slog"
"strings"
"time"
pb "github.com/qdrant/go-client/qdrant"
openai "github.com/sashabaranov/go-openai"
"google.golang.org/grpc/metadata"
"my-brain-importer/internal/agents"
"my-brain-importer/internal/config"
)
// KnowledgeChunk repräsentiert ein Suchergebnis aus Qdrant.
type KnowledgeChunk struct {
Text string
Score float32
Source string
}
// AskQuery sucht relevante Chunks und generiert eine LLM-Antwort.
// Gibt die Antwort als String und die verwendeten Quellen zurück.
// history enthält vorherige Gesprächsnachrichten (optional, nil für stateless).
func AskQuery(question string, history []agents.HistoryMessage) (string, []KnowledgeChunk, error) {
ctx := context.Background()
ctx = metadata.AppendToOutgoingContext(ctx, "api-key", config.Cfg.Qdrant.APIKey)
embClient := config.NewEmbeddingClient()
chatClient := config.NewChatClient()
chunks := searchKnowledge(ctx, embClient, question)
if len(chunks) == 0 {
return "", nil, nil
}
contextText := buildContext(chunks)
coreMemory := LoadCoreMemory()
systemPromptBase := `Du bist ein hilfreicher persönlicher Assistent.
Beantworte Fragen primär anhand der bereitgestellten Informationen aus der Wissensdatenbank.
Ergänze fehlende Details mit deinem eigenen Wissen, kennzeichne dies aber klar mit "Aus meinem Wissen:".
WICHTIGE REGELN:
- Nutze die bereitgestellten Informationen als Hauptquelle
- Ergänze mit eigenem Wissen wenn sinnvoll, kennzeichne es deutlich
- Antworte auf Deutsch
- Sei präzise und direkt`
systemPrompt := systemPromptBase
if coreMemory != "" {
systemPrompt = systemPromptBase + "\n\n## Fakten über den Nutzer:\n" + coreMemory
}
userPrompt := fmt.Sprintf(`Hier sind die relevanten Informationen aus meiner Wissensdatenbank:
%s
Basierend auf diesen Informationen, beantworte bitte folgende Frage:
%s`, contextText, question)
slog.Debug("[LLM] AskQuery Prompt",
"model", config.Cfg.Chat.Model,
"history_len", len(history),
"system", systemPrompt,
"user", userPrompt,
)
msgs := []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
}
for _, h := range history {
msgs = append(msgs, openai.ChatCompletionMessage{
Role: h.Role,
Content: h.Content,
})
}
msgs = append(msgs, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleUser,
Content: userPrompt,
})
start := time.Now()
stream, err := chatClient.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
Model: config.Cfg.Chat.Model,
Messages: msgs,
Temperature: 0.7,
MaxTokens: 500,
})
if err != nil {
return "", nil, fmt.Errorf("LLM Fehler: %w", err)
}
defer stream.Close()
var answer strings.Builder
for {
response, err := stream.Recv()
if err != nil {
break
}
if len(response.Choices) > 0 {
answer.WriteString(response.Choices[0].Delta.Content)
}
}
slog.Debug("[LLM] AskQuery Antwort",
"dauer", time.Since(start).Round(time.Millisecond),
"zeichen", answer.Len(),
"antwort", answer.String(),
)
return answer.String(), chunks, nil
}
// Ask sucht relevante Chunks und gibt Antwort + Quellen auf stdout aus.
func Ask(question string) {
fmt.Printf("🤔 Frage: \"%s\"\n\n", question)
fmt.Println("🔍 Durchsuche lokale Wissensdatenbank...")
answer, chunks, err := AskQuery(question, nil)
if err != nil {
log.Fatalf("❌ %v", err)
}
if len(chunks) == 0 {
fmt.Println("\n❌ Keine relevanten Informationen in der Datenbank gefunden.")
fmt.Println(" Füge mehr Daten mit './bin/ingest' hinzu.")
return
}
fmt.Printf("✅ %d relevante Informationen gefunden\n\n", len(chunks))
fmt.Println("🧠 Generiere Antwort mit lokalem Modell...")
fmt.Println(strings.Repeat("═", 80))
fmt.Print("\n💬 Antwort:\n\n")
fmt.Print(answer)
fmt.Print("\n\n")
fmt.Println(strings.Repeat("═", 80))
fmt.Print("\n📚 Verwendete Quellen:\n")
for i, chunk := range chunks {
preview := chunk.Text
if len(preview) > 80 {
preview = preview[:80] + "..."
}
fmt.Printf(" [%d] %.1f%% - %s\n %s\n", i+1, chunk.Score*100, chunk.Source, preview)
}
}
func searchKnowledge(ctx context.Context, embClient *openai.Client, query string) []KnowledgeChunk {
embResp, err := embClient.CreateEmbeddings(ctx, openai.EmbeddingRequest{
Input: []string{query},
Model: openai.EmbeddingModel(config.Cfg.Embedding.Model),
})
if err != nil {
log.Printf("❌ Embedding Fehler: %v", err)
return nil
}
conn := config.NewQdrantConn()
defer conn.Close()
searchResult, err := pb.NewPointsClient(conn).Search(ctx, &pb.SearchPoints{
CollectionName: config.Cfg.Qdrant.Collection,
Vector: embResp.Data[0].Embedding,
Limit: config.Cfg.TopK,
WithPayload: &pb.WithPayloadSelector{
SelectorOptions: &pb.WithPayloadSelector_Enable{Enable: true},
},
ScoreThreshold: floatPtr(config.Cfg.ScoreThreshold),
})
if err != nil {
log.Printf("❌ Suche fehlgeschlagen: %v", err)
return nil
}
var chunks []KnowledgeChunk
seen := make(map[string]bool)
for _, hit := range searchResult.Result {
text := hit.Payload["text"].GetStringValue()
if text == "" || seen[text] {
continue
}
seen[text] = true
chunks = append(chunks, KnowledgeChunk{
Text: text,
Score: hit.Score,
Source: hit.Payload["source"].GetStringValue(),
})
}
return chunks
}
func buildContext(chunks []KnowledgeChunk) string {
var b strings.Builder
for i, chunk := range chunks {
fmt.Fprintf(&b, "--- Information %d (Relevanz: %.1f%%) ---\n", i+1, chunk.Score*100)
b.WriteString(chunk.Text)
b.WriteString("\n\n")
}
return b.String()
}
// ChatDirect stellt eine Frage direkt an das LLM ohne Datenbankkontext.
func ChatDirect(question string) (string, error) {
ctx := context.Background()
chatClient := config.NewChatClient()
systemPrompt := `Du bist ein hilfreicher persönlicher Assistent. Antworte auf Deutsch, präzise und direkt.`
slog.Debug("[LLM] ChatDirect Prompt",
"model", config.Cfg.Chat.Model,
"user", question,
)
start := time.Now()
stream, err := chatClient.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
Model: config.Cfg.Chat.Model,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
{Role: openai.ChatMessageRoleUser, Content: question},
},
Temperature: 0.7,
MaxTokens: 500,
})
if err != nil {
return "", fmt.Errorf("LLM Fehler: %w", err)
}
defer stream.Close()
var answer strings.Builder
for {
response, err := stream.Recv()
if err != nil {
break
}
if len(response.Choices) > 0 {
answer.WriteString(response.Choices[0].Delta.Content)
}
}
slog.Debug("[LLM] ChatDirect Antwort",
"dauer", time.Since(start).Round(time.Millisecond),
"zeichen", answer.Len(),
"antwort", answer.String(),
)
return answer.String(), nil
}
func floatPtr(f float32) *float32 { return &f }