// triage/triage.go – Speichert und sucht Email-Triage-Entscheidungen in Qdrant (RAG-Lernen) // Eigenes Package um Import-Zyklen zwischen brain und email zu vermeiden. package triage import ( "context" "crypto/sha256" "encoding/hex" "fmt" "log/slog" pb "github.com/qdrant/go-client/qdrant" openai "github.com/sashabaranov/go-openai" "google.golang.org/grpc/metadata" "my-brain-importer/internal/config" ) // TriageResult repräsentiert ein Suchergebnis aus vergangenen Triage-Entscheidungen. type TriageResult struct { Text string Score float32 } // StoreDecision speichert eine Triage-Entscheidung in Qdrant. // Bei gleicher Email (deterministischer ID) wird die Entscheidung überschrieben. func StoreDecision(subject, from string, isImportant bool) error { label := "wichtig" if !isImportant { label = "unwichtig" } text := fmt.Sprintf("Email-Triage | Von: %s | Betreff: %s | Entscheidung: %s", from, subject, label) ctx := context.Background() ctx = metadata.AppendToOutgoingContext(ctx, "api-key", config.Cfg.Qdrant.APIKey) embClient := config.NewEmbeddingClient() embResp, err := embClient.CreateEmbeddings(ctx, openai.EmbeddingRequest{ Input: []string{text}, Model: openai.EmbeddingModel(config.Cfg.Embedding.Model), }) if err != nil { return fmt.Errorf("embedding: %w", err) } conn := config.NewQdrantConn() defer conn.Close() id := triageID(text) wait := true _, err = pb.NewPointsClient(conn).Upsert(ctx, &pb.UpsertPoints{ CollectionName: config.Cfg.Qdrant.Collection, Points: []*pb.PointStruct{{ Id: &pb.PointId{ PointIdOptions: &pb.PointId_Uuid{Uuid: id}, }, Vectors: &pb.Vectors{ VectorsOptions: &pb.Vectors_Vector{ Vector: &pb.Vector{Data: embResp.Data[0].Embedding}, }, }, Payload: map[string]*pb.Value{ "text": {Kind: &pb.Value_StringValue{StringValue: text}}, "source": {Kind: &pb.Value_StringValue{StringValue: "email_triage"}}, "type": {Kind: &pb.Value_StringValue{StringValue: "email_triage"}}, }, }}, Wait: &wait, }) if err != nil { return fmt.Errorf("qdrant upsert: %w", err) } slog.Debug("[Triage] Entscheidung gespeichert", "betreff", subject, "wichtig", isImportant) return nil } // SearchSimilar sucht ähnliche vergangene Triage-Entscheidungen in Qdrant. // Gibt bis zu 3 Ergebnisse zurück (nur type=email_triage, Score ≥ 0.7). func SearchSimilar(query string) []TriageResult { ctx := context.Background() ctx = metadata.AppendToOutgoingContext(ctx, "api-key", config.Cfg.Qdrant.APIKey) embClient := config.NewEmbeddingClient() embResp, err := embClient.CreateEmbeddings(ctx, openai.EmbeddingRequest{ Input: []string{query}, Model: openai.EmbeddingModel(config.Cfg.Embedding.Model), }) if err != nil { slog.Warn("[Triage] Embedding für RAG fehlgeschlagen", "fehler", err) return nil } conn := config.NewQdrantConn() defer conn.Close() threshold := float32(0.7) result, err := pb.NewPointsClient(conn).Search(ctx, &pb.SearchPoints{ CollectionName: config.Cfg.Qdrant.Collection, Vector: embResp.Data[0].Embedding, Limit: 3, WithPayload: &pb.WithPayloadSelector{ SelectorOptions: &pb.WithPayloadSelector_Enable{Enable: true}, }, ScoreThreshold: &threshold, Filter: &pb.Filter{ Must: []*pb.Condition{{ ConditionOneOf: &pb.Condition_Field{ Field: &pb.FieldCondition{ Key: "type", Match: &pb.Match{ MatchValue: &pb.Match_Keyword{Keyword: "email_triage"}, }, }, }, }}, }, }) if err != nil { slog.Warn("[Triage] RAG-Suche fehlgeschlagen", "fehler", err) return nil } var results []TriageResult for _, hit := range result.Result { text := hit.Payload["text"].GetStringValue() if text == "" { continue } results = append(results, TriageResult{Text: text, Score: hit.Score}) } return results } func triageID(text string) string { hash := sha256.Sum256([]byte("email_triage:" + text)) return hex.EncodeToString(hash[:16]) }