package logseq
import (
"context"
"encoding/json"
"fmt"
"log"
"log/slog"
"strings"
"unicode"
"unicode/utf8"
"github.com/firebase/genkit/go/ai"
"github.com/firebase/genkit/go/genkit"
"mimi/internal/bot/llm/agent"
"mimi/internal/provider/logseq"
)
const (
retrievePrompt = "logseq-retrieve"
evalPrompt = "logseq-eval"
)
type LogseqAgent struct {
g *genkit.Genkit
graph logseq.RegexGraph
retrievePrompt ai.Prompt
evalPrompt ai.Prompt
}
func New(g *genkit.Genkit, graph logseq.RegexGraph) LogseqAgent {
retrieve := genkit.LookupPrompt(g, retrievePrompt)
if retrieve == nil {
log.Fatalf("no prompt named '%s' found", retrievePrompt)
}
eval := genkit.LookupPrompt(g, evalPrompt)
if eval == nil {
log.Fatalf("no prompt named '%s' found", evalPrompt)
}
return LogseqAgent{
g: g,
graph: graph,
retrievePrompt: retrieve,
evalPrompt: eval,
}
}
func (a LogseqAgent) GetInfo() agent.Info {
return agent.Info{
Name: "logseq",
Description: `Knows all about cyber valley's / cvland / cyberia history.
Capable of answering to questions about flora and fauna, main goals and mindsets.
Gets info from Logseq database and know how to find any stuff in it.`,
}
}
func sanitizeTitle(title string) string {
if !utf8.ValidString(title) {
title = strings.ToValidUTF8(title, "")
}
title = strings.Map(func(r rune) rune {
if unicode.IsControl(r) && r != '\t' && r != '\n' && r != '\r' {
return -1 }
return r
}, title)
title = strings.ReplaceAll(title, "\t", " ")
title = strings.ReplaceAll(title, "\n", " ")
title = strings.ReplaceAll(title, "\r", " ")
title = strings.Join(strings.Fields(title), " ")
if _, err := json.Marshal(title); err != nil {
slog.Warn("Title failed JSON serialization, using fallback", "original", title, "error", err)
return "untitled"
}
const maxTitleLength = 500
if len(title) > maxTitleLength {
slog.Warn("Truncating overly long title", "original_length", len(title))
title = title[:maxTitleLength] + "..."
}
return title
}
func validateTitles(titles []string) []string {
validated := make([]string, 0, len(titles))
seen := make(map[string]bool)
for _, title := range titles {
sanitized := sanitizeTitle(title)
if sanitized == "" {
slog.Warn("Skipping empty title after sanitization")
continue
}
if seen[sanitized] {
continue
}
seen[sanitized] = true
validated = append(validated, sanitized)
}
slog.Info("Validated titles", "original_count", len(titles), "validated_count", len(validated))
return validated
}
func (a LogseqAgent) Run(ctx context.Context, query string, msgs ...*ai.Message) (agent.Response, error) {
var result agent.Response
titles := a.graph.GetAllTitles()
slog.Info("retrieved titles from graph", "count", len(titles))
titles = validateTitles(titles)
if len(titles) == 0 {
return result, fmt.Errorf("no valid titles found in knowledge graph after sanitization")
}
titleDocs := make([]*ai.Document, len(titles))
for i, t := range titles {
titleDocs[i] = ai.DocumentFromText(t, map[string]any{})
}
var relevantPages map[string][]string
var resp *ai.ModelResponse
var err error
const maxRetries = 3
for attempt := 1; attempt <= maxRetries; attempt++ {
resp, err = a.retrievePrompt.Execute(
ctx,
ai.WithDocs(titleDocs...),
ai.WithMessages(msgs...),
ai.WithInput(map[string]any{"query": query}),
)
if err != nil {
slog.Error("LLM request failed", "attempt", attempt, "error", err)
if attempt == maxRetries {
return result, fmt.Errorf("LLM request failed after %d attempts with %w", maxRetries, err)
}
continue
}
slog.Info("LLM retrieve response", "attempt", attempt, "text", resp.Text())
if err := resp.Output(&relevantPages); err != nil {
slog.Error("Failed to parse LLM output", "attempt", attempt, "response", resp.Text(), "error", err)
if attempt == maxRetries {
slog.Warn("Using fallback: returning empty titles list after parse failures")
relevantPages = map[string][]string{"titles": {}}
break
}
errorMsg := fmt.Sprintf("Previous response had parsing error: %v. Please ensure you return valid JSON with format: {\"titles\": [\"title1\", \"title2\"]} or {\"titles\": []} for no matches.", err)
msgs = append(msgs, ai.NewTextMessage(ai.RoleModel, resp.Text()))
msgs = append(msgs, ai.NewTextMessage(ai.RoleUser, errorMsg))
continue
}
break
}
slog.Info("relevant pages", "titles", relevantPages["titles"])
var docs []*ai.Document
selectedTitles := relevantPages["titles"]
if len(selectedTitles) > 0 {
selectedTitles = validateTitles(selectedTitles)
}
if len(selectedTitles) == 0 {
slog.Warn("No relevant pages found, using fallback to 'cyber valley' and 'cyberia'")
fallbackTitles := []string{"cyber valley", "cyberia", "cvland"}
for _, fb := range fallbackTitles {
for _, title := range titles {
if strings.EqualFold(title, fb) {
selectedTitles = append(selectedTitles, title)
break
}
}
}
if len(selectedTitles) == 0 && len(titles) > 0 {
slog.Warn("Fallback titles not found, using first 3 pages from graph")
maxFallback := 3
if len(titles) < maxFallback {
maxFallback = len(titles)
}
selectedTitles = titles[:maxFallback]
}
}
addedPages := make(map[string]bool)
for _, title := range selectedTitles {
page, found := a.graph.GetPageByTitle(title)
if !found {
slog.Warn("Selected title not found in graph", "title", title)
continue
}
content, err := page.Read()
if err != nil {
slog.Warn("Failed to read page content", "title", title, "error", err)
continue
}
pageTitleLower := strings.ToLower(page.Title())
if !addedPages[pageTitleLower] {
docs = append(docs, ai.DocumentFromText(content, map[string]any{"title": page.Title()}))
addedPages[pageTitleLower] = true
slog.Info("Added main page to documents", "title", page.Title())
}
relatedPages := a.graph.GetRelatedPages(title, 2) slog.Info("Found related pages", "for_page", title, "count", len(relatedPages))
for _, relPage := range relatedPages {
relTitleLower := strings.ToLower(relPage.Title())
if addedPages[relTitleLower] {
continue
}
relContent, err := relPage.Read()
if err != nil {
slog.Warn("Failed to read related page content", "title", relPage.Title(), "error", err)
continue
}
docs = append(docs, ai.DocumentFromText(relContent, map[string]any{"title": relPage.Title()}))
addedPages[relTitleLower] = true
}
}
if len(docs) == 0 {
return result, fmt.Errorf("failed to fetch any relevant pages")
}
slog.Info("relevant documents", "length", len(docs))
resp, err = a.evalPrompt.Execute(
ctx,
ai.WithDocs(docs...),
ai.WithInput(map[string]any{"query": query}),
)
if err != nil {
return result, fmt.Errorf("failed to evaluate final step with %w", err)
}
result = agent.NewResponse(agent.DataText{Text: resp.Text()}, resp)
return result, nil
}