Files
ai-agent/internal/brain/deepask.go
Christoph K. ee7b4cc74f /deepask: Multi-Step Reasoning mit iterativer RAG-Suche
Neuer Discord-Command für tiefe Recherche in 3 Phasen:
1. Initiale Qdrant-Suche mit der Originalfrage
2. LLM generiert Folgefragen, sucht erneut (max 2 Iterationen)
3. Synthese aller gesammelten Chunks zu umfassender Antwort

Nutzbar via /deepask oder @bot deepask.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 19:49:38 +01:00

225 lines
6.4 KiB
Go
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.
// deepask.go Multi-Step Reasoning: iterative RAG-Suche mit Folgefragen
package brain
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
openai "github.com/sashabaranov/go-openai"
"google.golang.org/grpc/metadata"
"my-brain-importer/internal/agents"
"my-brain-importer/internal/config"
)
const maxDeepSteps = 3
// DeepAskQuery führt eine iterative RAG-Suche durch:
// 1. Initiale Suche → LLM generiert Folgefragen
// 2. Vertiefungssuchen mit Folgefragen (max 2 Iterationen)
// 3. Synthese: alle gesammelten Chunks → umfassende Antwort
func DeepAskQuery(question string, history []agents.HistoryMessage) (string, []KnowledgeChunk, error) {
start := time.Now()
ctx := context.Background()
ctx = metadata.AppendToOutgoingContext(ctx, "api-key", config.Cfg.Qdrant.APIKey)
embClient := config.NewEmbeddingClient()
chatClient := config.NewChatClient()
// Deduplizierung über alle Schritte
seen := make(map[string]bool)
var allChunks []KnowledgeChunk
addChunks := func(chunks []KnowledgeChunk) int {
added := 0
for _, c := range chunks {
if !seen[c.Text] {
seen[c.Text] = true
allChunks = append(allChunks, c)
added++
}
}
return added
}
// Phase 1: Initiale Suche
slog.Info("[DeepAsk] Phase 1: Initiale Suche", "frage", question)
initialChunks := searchKnowledge(ctx, embClient, question)
addChunks(initialChunks)
if len(allChunks) == 0 {
return "", nil, nil
}
// Phase 2: Folgefragen generieren und suchen (max 2 Iterationen)
queries := []string{question}
for step := 1; step < maxDeepSteps; step++ {
followUps := generateFollowUpQueries(ctx, chatClient, question, allChunks)
if len(followUps) == 0 {
slog.Info("[DeepAsk] Keine Folgefragen generiert, überspringe", "schritt", step)
break
}
slog.Info("[DeepAsk] Phase 2: Vertiefung",
"schritt", step,
"folgefragen", len(followUps),
"fragen", followUps,
)
newFound := 0
for _, fq := range followUps {
chunks := searchKnowledge(ctx, embClient, fq)
newFound += addChunks(chunks)
queries = append(queries, fq)
}
if newFound == 0 {
slog.Info("[DeepAsk] Keine neuen Chunks gefunden, beende Vertiefung", "schritt", step)
break
}
slog.Info("[DeepAsk] Neue Chunks gefunden", "schritt", step, "neu", newFound, "gesamt", len(allChunks))
}
// Phase 3: Synthese — alle Chunks + Frage → umfassende Antwort
slog.Info("[DeepAsk] Phase 3: Synthese", "chunks", len(allChunks), "schritte", len(queries))
contextText := buildContext(allChunks)
coreMemory := LoadCoreMemory()
systemPrompt := `Du bist ein hilfreicher persönlicher Assistent mit Zugang zu einer umfangreichen Wissensdatenbank.
Dir wurden Informationen aus mehreren Suchdurchläufen bereitgestellt.
WICHTIGE REGELN:
- Nutze ALLE bereitgestellten Informationen für eine umfassende Antwort
- Verbinde Informationen aus verschiedenen Quellen zu einer kohärenten Antwort
- Ergänze mit eigenem Wissen wenn sinnvoll, kennzeichne es mit "Aus meinem Wissen:"
- Antworte auf Deutsch
- Sei gründlich aber strukturiert`
if coreMemory != "" {
systemPrompt += "\n\n## Fakten über den Nutzer:\n" + coreMemory
}
userPrompt := fmt.Sprintf(`Hier sind relevante Informationen aus mehreren Suchdurchläufen in meiner Wissensdatenbank:
%s
Basierend auf ALLEN diesen Informationen, beantworte bitte umfassend folgende Frage:
%s`, contextText, question)
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,
})
stream, err := chatClient.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
Model: config.Cfg.Chat.Model,
Messages: msgs,
Temperature: 0.7,
MaxTokens: 800,
})
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.Info("[DeepAsk] Abgeschlossen",
"dauer", time.Since(start).Round(time.Millisecond),
"chunks_gesamt", len(allChunks),
"suchanfragen", len(queries),
"antwort_zeichen", answer.Len(),
)
return answer.String(), allChunks, nil
}
// generateFollowUpQueries lässt das LLM basierend auf bisherigen Ergebnissen Folgefragen generieren.
// Gibt 0-3 Folgefragen zurück.
func generateFollowUpQueries(ctx context.Context, chatClient *openai.Client, question string, chunks []KnowledgeChunk) []string {
contextText := buildContext(chunks)
prompt := fmt.Sprintf(`Originalfrage: %s
Bisherige Suchergebnisse:
%s
Generiere 1-3 Folgefragen, die helfen würden, die Originalfrage besser zu beantworten.
Jede Folgefrage muss auf einer eigenen Zeile stehen und mit "FOLGEFRAGE:" beginnen.
Wenn die bisherigen Ergebnisse die Frage bereits vollständig beantworten, schreibe: KEINE FOLGEFRAGEN`, question, contextText)
resp, err := chatClient.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
Model: config.Cfg.Chat.Model,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: "Du generierst präzise Suchfragen für eine Wissensdatenbank. Antworte NUR im vorgegebenen Format."},
{Role: openai.ChatMessageRoleUser, Content: prompt},
},
Temperature: 0.3,
MaxTokens: 300,
})
if err != nil {
slog.Warn("[DeepAsk] Folgefragen-Generierung fehlgeschlagen", "fehler", err)
return nil
}
if len(resp.Choices) == 0 {
return nil
}
return parseFollowUpQueries(resp.Choices[0].Message.Content)
}
// parseFollowUpQueries extrahiert Folgefragen aus der LLM-Antwort.
// Erwartet Zeilen im Format "FOLGEFRAGE: <frage>".
func parseFollowUpQueries(response string) []string {
// Reasoning-Modelle: Antwort nach </think>-Tag
if idx := strings.LastIndex(response, "</think>"); idx >= 0 {
response = response[idx+len("</think>"):]
}
if strings.Contains(strings.ToUpper(response), "KEINE FOLGEFRAGEN") {
return nil
}
var queries []string
for _, line := range strings.Split(response, "\n") {
line = strings.TrimSpace(line)
upper := strings.ToUpper(line)
if strings.HasPrefix(upper, "FOLGEFRAGE:") {
q := strings.TrimSpace(line[len("FOLGEFRAGE:"):])
if q != "" {
queries = append(queries, q)
}
}
}
// Max 3 Folgefragen
if len(queries) > 3 {
queries = queries[:3]
}
return queries
}