diff --git a/cmd/agent/cmd.go b/cmd/agent/cmd.go new file mode 100644 index 0000000..f1ae581 --- /dev/null +++ b/cmd/agent/cmd.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + + knowledge2 "gitea.stevedudenhoeffer.com/steve/answer/pkg/agents" + + gollm "gitea.stevedudenhoeffer.com/steve/go-llm" + + "gitea.stevedudenhoeffer.com/steve/answer/pkg/agents/shared" + + "gitea.stevedudenhoeffer.com/steve/go-extractor/sites/duckduckgo" + + "gitea.stevedudenhoeffer.com/steve/answer/pkg/agents/searcher" + + "github.com/joho/godotenv" + "github.com/urfave/cli" +) + +func getKey(key string, env string) string { + if key != "" { + return key + } + + return os.Getenv(env) +} + +func main() { + ctx := context.Background() + // Usage: go run cmd/answer.go question... + // - flags: + // --model=[model string such as openai/gpt-4o, anthropic/claude..., google/gemini-1.5. Default: openai/gpt-4o] + // --search-provider=[search provider string such as google, duckduckgo. Default: google] + // --cache-provider=[cache provider string such as memory, redis, file. Default: memory] + + var app = cli.App{ + Name: "answer", + Usage: "has an llm search the web for you to answer a question", + Version: "0.1", + Description: "", + + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "env-file", + Value: ".env", + Usage: "file to read environment variables from", + }, + + &cli.StringFlag{ + Name: "model", + Value: "openai/gpt-4o-mini", + Usage: "model to use for answering the question, syntax: provider/model such as openai/gpt-4o", + }, + + &cli.StringFlag{ + Name: "llm-key", + Value: "", + Usage: "key for the llm model (if empty, will use env var of PROVIDER_API_KEY, such as OPENAI_API_KEY)", + }, + }, + + Action: func(c *cli.Context) error { + // if there is no question to answer, print usage + if c.NArg() == 0 { + return cli.ShowAppHelp(c) + } + + if c.String("env-file") != "" { + _ = godotenv.Load(c.String("env-file")) + } + + var llm gollm.LLM + + model := c.String("model") + + a := strings.Split(model, "/") + + if len(a) != 2 { + panic("invalid model, expected: provider/model (such as openai/gpt-4o)") + } + + switch a[0] { + case "openai": + llm = gollm.OpenAI(getKey(c.String("llm-key"), "OPENAI_API_KEY")) + + case "anthropic": + llm = gollm.Anthropic(getKey(c.String("llm-key"), "ANTHROPI_API_KEY")) + + case "google": + llm = gollm.Google(getKey(c.String("llm-key"), "GOOGLE_API_KEY")) + + default: + panic("unknown model provider") + } + + m, err := llm.ModelVersion(a[1]) + + if err != nil { + panic(err) + } + question := strings.Join(c.Args(), " ") + + search := searcher.Agent{ + Model: m, + OnGoingToNextPage: func(ctx context.Context) error { + slog.Info("going to next page") + return nil + }, + OnReadingSearchResult: func(ctx context.Context, sr duckduckgo.Result) (any, error) { + slog.Info("reading search result", "url", sr.URL, "title", sr.Title, "description", sr.Description) + return nil, nil + }, + OnFinishedReadingSearchResult: func(ctx context.Context, sr duckduckgo.Result, newKnowledge []string, err error, onReadingResult any) error { + slog.Info("finished reading search result", "err", err, "newKnowledge", newKnowledge) + return nil + }, + OnDone: func(ctx context.Context, knowledge shared.Knowledge) error { + slog.Info("done", "knowledge", knowledge) + return nil + }, + MaxReads: 20, + MaxNextResults: 10, + } + + processor := knowledge2.KnowledgeProcessor{Model: m} + knowledge, err := search.Search(ctx, question, question) + + if err != nil { + panic(err) + } + + slog.Info("knowledge", "knowledge", knowledge) + + sum, err := processor.ProcessKnowledge(ctx, knowledge) + + fmt.Println(sum) + return nil + }, + } + + err := app.Run(os.Args) + + if err != nil { + slog.Error("Error: ", err) + } +} diff --git a/cmd/answer.go b/cmd/answer.go index c64ff58..8ec14ea 100644 --- a/cmd/answer.go +++ b/cmd/answer.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "log/slog" "os" "strings" @@ -162,9 +163,7 @@ func main() { panic(err) } - for i, a := range answers { - slog.Info("answer", "index", i, "answer", a) - } + fmt.Println(fmt.Sprintf("Question: %s\nAnswer: %q", question.Question, answers)) return nil }, diff --git a/go.mod b/go.mod index ad374f7..565116e 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,11 @@ replace github.com/rocketlaunchr/google-search => github.com/chrisjoyce911/googl //replace gitea.stevedudenhoeffer.com/steve/go-llm => ../go-llm require ( - gitea.stevedudenhoeffer.com/steve/go-extractor v0.0.0-20250315044602-7c0e44a22f2c - gitea.stevedudenhoeffer.com/steve/go-llm v0.0.0-20250318034148-e5a046a70bd1 + gitea.stevedudenhoeffer.com/steve/go-extractor v0.0.0-20250318064250-39453288ce2a + gitea.stevedudenhoeffer.com/steve/go-llm v0.0.0-20250318074538-52533238d385 github.com/Edw590/go-wolfram v0.0.0-20241010091529-fb9031908c5d github.com/advancedlogic/GoOse v0.0.0-20231203033844-ae6b36caf275 + github.com/davecgh/go-spew v1.1.1 github.com/joho/godotenv v1.5.1 github.com/playwright-community/playwright-go v0.5001.0 github.com/rocketlaunchr/google-search v1.1.6 @@ -19,10 +20,10 @@ require ( ) require ( - cloud.google.com/go v0.119.0 // indirect + cloud.google.com/go v0.120.0 // indirect cloud.google.com/go/ai v0.10.1 // indirect cloud.google.com/go/auth v0.15.0 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.6.0 // indirect cloud.google.com/go/longrunning v0.6.6 // indirect github.com/PuerkitoBio/goquery v1.10.2 // indirect @@ -62,7 +63,7 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect - github.com/sashabaranov/go-openai v1.38.0 // indirect + github.com/sashabaranov/go-openai v1.38.1 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect github.com/temoto/robotstxt v1.1.2 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect @@ -79,7 +80,7 @@ require ( golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect golang.org/x/time v0.11.0 // indirect - google.golang.org/api v0.226.0 // indirect + google.golang.org/api v0.227.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250313205543-e70fdf4c4cb4 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go new file mode 100644 index 0000000..ac16797 --- /dev/null +++ b/pkg/agent/agent.go @@ -0,0 +1,153 @@ +package agent + +import ( + "context" + "fmt" + "io" + "log/slog" + + "github.com/davecgh/go-spew/spew" + + gollm "gitea.stevedudenhoeffer.com/steve/go-llm" +) + +type Agent struct { + // ToolBox is the toolbox to use for the agent. + ToolBox *gollm.ToolBox + + // Model is the model to use for the agent. + Model gollm.ChatCompletion + + // OnNewFunction is a callback that, if non-nil, will be called when a new function is called by the LLM. + // The "answer" and "no_answer" functions are not included in this callback. + // Return an error to stop the function from being called. + OnNewFunction func(ctx context.Context, funcName string, question string, parameter string) (any, error) + + // OnFunctionFinished is a callback that, if non-nil, will be called when a function has finished executing. The + // function name is passed in, as well as the question, the parameter, all similar to OnNewFunction. The result of + // the function is also passed in, as well as any error that occurred. Finally, the result passed from the + // OnNewFunction that preceded this function is passed in as well. + OnFunctionFinished func(ctx context.Context, funcName string, question string, parameter string, result string, err error, newFunctionResult any) error + + req gollm.Request +} + +func NewAgent(req gollm.Request) *Agent { + return &Agent{req: req} +} + +type Response struct { + Text string + Sources []string +} + +func deferClose(cl io.Closer) { + if cl != nil { + _ = cl.Close() + } +} + +func (a *Agent) AddConversation(in gollm.Input) { + a.req.Conversation = append(a.req.Conversation, in) +} + +func (a *Agent) AddMessage(msg gollm.Message) { + slog.Info("adding message", "message", msg) + a.req.Messages = append(a.req.Messages, msg) +} + +// Execute will execute the current request with the given messages. The messages will be appended to the current +// request, but they will _not_ be saved into the embedded request. However, the embedded request will be +// generated with the on the results from the ChatComplete call. +func (a *Agent) Execute(ctx context.Context, msgs ...gollm.Message) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + req := a.req + + slog.Info("executing", "request", req, "messages", msgs) + for _, c := range req.Conversation { + slog.Info("conversation", "message", c) + } + + req.Messages = append(req.Messages, msgs...) + for _, m := range req.Messages { + slog.Info("messages", "message", m) + } + + req.Toolbox = a.ToolBox + + fmt.Println("req:") + spew.Dump(req) + res, err := a.Model.ChatComplete(ctx, req) + fmt.Println("res:") + spew.Dump(res) + if err != nil { + return err + } + + if len(res.Choices) == 0 { + return nil + } + + choice := res.Choices[0] + var callsOutput = make(chan gollm.ToolCallResponse, len(choice.Calls)) + fnCall := func(call gollm.ToolCall) gollm.ToolCallResponse { + str, err := a.ToolBox.Execute(gollm.NewContext(ctx, a.req, &choice, &call), call) + + if err != nil { + return gollm.ToolCallResponse{ + ID: call.ID, + Error: err, + } + } + + return gollm.ToolCallResponse{ + ID: call.ID, + Result: str, + } + } + + for _, call := range choice.Calls { + go func(call gollm.ToolCall) { + var arg any + var err error + + if a.OnNewFunction != nil { + arg, err = a.OnNewFunction(ctx, call.FunctionCall.Name, choice.Content, call.FunctionCall.Arguments) + if err != nil { + callsOutput <- gollm.ToolCallResponse{ + ID: call.ID, + Error: err, + } + return + } + } + + callRes := fnCall(call) + + if a.OnFunctionFinished != nil { + err = a.OnFunctionFinished(ctx, call.FunctionCall.Name, choice.Content, call.FunctionCall.Arguments, callRes.Result, callRes.Error, arg) + if err != nil { + callsOutput <- gollm.ToolCallResponse{ + ID: call.ID, + Error: err, + } + return + } + } + + callsOutput <- callRes + }(call) + } + + var answers []gollm.ToolCallResponse + for i := 0; i < len(choice.Calls); i++ { + result := <-callsOutput + answers = append(answers, result) + } + + close(callsOutput) + slog.Info("generating new request", "answers", answers, "choice", choice) + a.req = gollm.NewContext(ctx, a.req, &choice, nil).ToNewRequest(answers...) + return nil +} diff --git a/pkg/agents/knowledge_processor.go b/pkg/agents/knowledge_processor.go new file mode 100644 index 0000000..73a082c --- /dev/null +++ b/pkg/agents/knowledge_processor.go @@ -0,0 +1,186 @@ +package agents + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" + + "gitea.stevedudenhoeffer.com/steve/answer/pkg/agents/shared" + + gollm "gitea.stevedudenhoeffer.com/steve/go-llm" +) + +type KnowledgeProcessor struct { + Model gollm.ChatCompletion + ContextualInformation []string +} + +// Process takes a knowledge object and processes it into a response string. +func (a KnowledgeProcessor) Process(ctx context.Context, knowledge shared.Knowledge) (string, error) { + originalQuestions := strings.Join(knowledge.OriginalQuestions, "\n") + infoGained := "" + + // group all the gained knowledge by source + var m = map[string][]string{} + for _, k := range knowledge.Knowledge { + m[k.Source] = append(m[k.Source], k.Info) + } + + // now order them in a list so they can be referenced by index + type source struct { + source string + info []string + } + + var sources []source + for k, v := range m { + sources = append(sources, source{ + source: k, + info: v, + }) + + if len(infoGained) > 0 { + infoGained += "\n" + } + + infoGained += strings.Join(v, "\n") + } + + systemPrompt := `I am trying to answer a question, and I gathered some knowledge in an attempt to do so. Here is what I am trying to answer: +` + originalQuestions + ` + +Here is the knowledge I have gathered from ` + fmt.Sprint(len(sources)) + ` sources: +` + infoGained + + if len(knowledge.RemainingQuestions) > 0 { + systemPrompt += "\n\nI still have some questions that I could not find an answer to:\n" + strings.Join(knowledge.RemainingQuestions, "\n") + } + + systemPrompt += "\n\nUsing the sources, write an answer to the original question. Note any information that wasn't able to be answered." + + req := gollm.Request{ + Messages: []gollm.Message{ + { + Role: gollm.RoleSystem, + Text: systemPrompt, + }, + }, + } + + if len(a.ContextualInformation) > 0 { + req.Messages = append(req.Messages, gollm.Message{ + Role: gollm.RoleSystem, + Text: "Some contextual information you should be aware of: " + strings.Join(a.ContextualInformation, "\n"), + }) + } + + resp, err := a.Model.ChatComplete(ctx, req) + if err != nil { + return "", fmt.Errorf("failed to chat complete: %w", err) + } + + systemPrompt = `I am trying to source an analysis of information I have gathered. +To do this I will provide you with all of the sourced information I have gathered in the format of: +[Source] + - Information + - Information + - Information + +Where Source will be a number from 1 to ` + fmt.Sprint(len(sources)) + ` and Information will be the information gathered from that source. + +You should then read the information provided by the user and tag the information with citations from the sources provided. If a fact is provided by multiple sources, you should tag it with all of the sources that provide that information. + +For instance, if the sourced data were: +[1] + - The diameter of the moon is 3,474.8 km + - The moon's age is 4.53 billion years +[2] + - The moon's age is 4.53 billion years +[3] + - The moon is on average 238,855 miles away from the Earth + +And the user provided the following information: +The moon is 4.5 billion years old, 238,855 miles away from the Earth, and has a diameter of 3,474.8 km. + +You would then tag the information with the sources like so: +The moon is 4.5 billion years old [1,2], 238,855 miles away from the Earth [3], and has a diameter of 3,474.8 km [1].` + + providedIntel := `Here is the information I have gathered: +` + + for i, s := range sources { + providedIntel += "[" + fmt.Sprint(i+1) + "]\n" + for _, info := range s.info { + providedIntel += " - " + info + "\n" + } + } + + summarizedData := `Here is the I need you to source with citations: +` + resp.Choices[0].Content + req = gollm.Request{ + Messages: []gollm.Message{ + { + Role: gollm.RoleSystem, + Text: systemPrompt, + }, + { + Role: gollm.RoleSystem, + Text: providedIntel, + }, + { + Role: gollm.RoleUser, + Text: summarizedData, + }, + }, + } + + resp, err = a.Model.ChatComplete(ctx, req) + if err != nil { + return "", fmt.Errorf("failed to chat complete: %w", err) + } + + // now go through the response and find all citations + // use this by looking for \[[\d+,]+\] + // then use the number to find the source + + re := regexp.MustCompile(`\[([\d,\s]+)]`) + + // find all the citations + citations := re.FindAllString(resp.Choices[0].Content, -1) + + // now we need to find the sources + lookup := map[int][]string{} + for _, c := range citations { + c = strings.Trim(c, "[]") + a := strings.Split(c, ",") + + for _, v := range a { + v = strings.TrimSpace(v) + i, _ := strconv.Atoi(v) + + if i < 1 || i > len(sources) { + continue + } + + lookup[i] = append(lookup[i], sources[i-1].source) + } + } + + res := resp.Choices[0].Content + + if len(lookup) > 0 { + res += "\n\nHere are the sources for the information provided:\n" + + for i := 1; i <= len(sources); i++ { + if _, ok := lookup[i]; !ok { + continue + } + + res += "[" + fmt.Sprint(i) + "] <" + lookup[i][0] + ">\n" + } + } + + return res, nil +} diff --git a/pkg/agents/question_splitter.go b/pkg/agents/question_splitter.go new file mode 100644 index 0000000..5948192 --- /dev/null +++ b/pkg/agents/question_splitter.go @@ -0,0 +1,66 @@ +package agents + +import ( + "context" + "fmt" + "strings" + + gollm "gitea.stevedudenhoeffer.com/steve/go-llm" +) + +type QuestionSplitter struct { + Model gollm.ChatCompletion + ContextualInfo []string +} + +func (q QuestionSplitter) SplitQuestion(ctx context.Context, question string) ([]string, error) { + var res []string + + req := gollm.Request{ + Toolbox: gollm.NewToolBox( + gollm.NewFunction( + "questions", + "split the provided question by the user into sub-questions", + func(ctx *gollm.Context, args struct { + Questions []string `description:"The questions to evaluate"` + }) (string, error) { + res = args.Questions + return "", nil + }), + ), + Messages: []gollm.Message{ + { + Role: gollm.RoleSystem, + Text: `The user is going to ask you a question, if the question would be better answered split into multiple questions, please do so. +Respond using the "questions" function. +If the question is fine as is, respond with the original question passed to the "questions" function.`, + }, + }, + } + + if len(q.ContextualInfo) > 0 { + req.Messages = append(req.Messages, gollm.Message{ + Role: gollm.RoleSystem, + Text: "Some contextual information you should be aware of: " + strings.Join(q.ContextualInfo, "\n"), + }) + } + + req.Messages = append(req.Messages, gollm.Message{ + Role: gollm.RoleUser, + Text: question, + }) + + resp, err := q.Model.ChatComplete(ctx, req) + if err != nil { + return nil, err + } + + if len(resp.Choices) == 0 { + return nil, fmt.Errorf("no choices found") + } + + choice := resp.Choices[0] + + _, _ = req.Toolbox.ExecuteCallbacks(gollm.NewContext(ctx, req, &choice, nil), choice.Calls, nil, nil) + return res, nil +} diff --git a/pkg/agents/reader/agent.go b/pkg/agents/reader/agent.go new file mode 100644 index 0000000..a4bacbe --- /dev/null +++ b/pkg/agents/reader/agent.go @@ -0,0 +1,46 @@ +package reader + +import ( + "context" + "fmt" + "net/url" + + "gitea.stevedudenhoeffer.com/steve/answer/pkg/agents/shared" + + "gitea.stevedudenhoeffer.com/steve/answer/pkg/cache" + + gollm "gitea.stevedudenhoeffer.com/steve/go-llm" +) + +type Agent struct { + // Model is the chat completion model to use + Model gollm.ChatCompletion + + // OnNewFunction is called when a new function is created + OnNewFunction func(ctx context.Context, funcName string, question string, parameter string) (any, error) + + // OnFunctionFinished is called when a function is finished + OnFunctionFinished func(ctx context.Context, funcName string, question string, parameter string, result string, err error, newFunctionResult any) error + + Cache cache.Cache + + ContextualInformation []string +} + +// Read will try to read the source and return the answer if possible. +func (a Agent) Read(ctx context.Context, question string, source *url.URL) (shared.Knowledge, error) { + if a.Cache == nil { + a.Cache = cache.Nop{} + } + + ar, err := extractArticle(ctx, a.Cache, source) + if err != nil { + return shared.Knowledge{}, err + } + + if ar.Body == "" { + return shared.Knowledge{}, fmt.Errorf("could not extract body from page") + } + + return doesTextAnswerQuestion(ctx, question, ar.Body, source.String(), a) +} diff --git a/pkg/agents/reader/extractor.go b/pkg/agents/reader/extractor.go new file mode 100644 index 0000000..c81bcb0 --- /dev/null +++ b/pkg/agents/reader/extractor.go @@ -0,0 +1,142 @@ +package reader + +import ( + "context" + "fmt" + "net/url" + "strings" + + "gitea.stevedudenhoeffer.com/steve/answer/pkg/agents/shared" + + "gitea.stevedudenhoeffer.com/steve/answer/pkg/cache" + "gitea.stevedudenhoeffer.com/steve/answer/pkg/extractor" + gollm "gitea.stevedudenhoeffer.com/steve/go-llm" +) + +type article struct { + URL string + Title string + Body string +} + +func extractArticle(ctx context.Context, c cache.Cache, u *url.URL) (res article, err error) { + defer func() { + e := recover() + + if e != nil { + if e, ok := e.(error); ok { + err = fmt.Errorf("panic: %w", e) + } else { + err = fmt.Errorf("panic: %v", e) + } + } + }() + + extractors := extractor.MultiExtractor( + extractor.CacheExtractor{ + Cache: c, + Tag: "goose", + Extractor: extractor.GooseExtractor{}, + }, + extractor.CacheExtractor{ + Cache: c, + Tag: "playwright", + Extractor: extractor.PlaywrightExtractor{}, + }, + ) + + a, err := extractors.Extract(ctx, u.String()) + + if err != nil { + return article{ + URL: "", + Title: "", + Body: "", + }, err + } + + return article{ + URL: a.URL, + Title: a.Title, + Body: a.Body, + }, nil +} + +type Response struct { + Knowledge []string + Remaining string +} + +type Learn struct { + Info string `description:"The information to learn from the text."` +} + +func doesTextAnswerQuestion(ctx context.Context, question string, text string, source string, a Agent) (shared.Knowledge, error) { + var knowledge shared.Knowledge + fnAnswer := gollm.NewFunction( + "learn", + `Use learn to pass some relevant information to the model. The model will use this information to answer the question. Use it to learn relevant information from the text. Keep these concise and relevant to the question.`, + func(ctx *gollm.Context, args Learn) (string, error) { + knowledge.Knowledge = append(knowledge.Knowledge, shared.TidBit{Info: args.Info, Source: source}) + return "", nil + }) + + fnNoAnswer := gollm.NewFunction( + "finished", + "Indicate that the text does not answer the question.", + func(ctx *gollm.Context, args struct { + Remaining string `description:"After all the knowledge has been learned, this is the parts of the question that are not answered, if any. Leave this blank if the text fully answers the question."` + }) (string, error) { + knowledge.RemainingQuestions = []string{args.Remaining} + return "", nil + }) + + req := gollm.Request{ + Messages: []gollm.Message{ + { + Role: gollm.RoleSystem, + Text: `Evaluate the given text to see if you can answer any information from it relevant to the question that the user asks. +Use the "learn" function to pass relevant information to the model. You can use the "learn" function multiple times to pass multiple pieces of relevant information to the model. +If the text does not answer the question or you are done using "learn" to pass on knowledge then use the "finished" function and indicate the parts of the question that are not answered by anything learned. +You can call "learn" multiple times before calling "finished".`, + }, + { + Role: gollm.RoleSystem, + Text: "The text to evaluate: " + text, + }, + }, + Toolbox: gollm.NewToolBox(fnAnswer, fnNoAnswer), + } + + if len(a.ContextualInformation) > 0 { + req.Messages = append(req.Messages, gollm.Message{ + Role: gollm.RoleSystem, + Text: "Some contextual information you should be aware of: " + strings.Join(a.ContextualInformation, "\n"), + }) + } + + req.Messages = append(req.Messages, gollm.Message{ + Role: gollm.RoleUser, + Text: "My question to learn from the text is: " + question, + }) + + resp, err := a.Model.ChatComplete(ctx, req) + + if err != nil { + return knowledge, err + } + + if len(resp.Choices) == 0 { + return knowledge, nil + } + + choice := resp.Choices[0] + + if len(choice.Calls) == 0 { + return knowledge, nil + } + + _, err = req.Toolbox.ExecuteCallbacks(gollm.NewContext(ctx, req, &choice, nil), choice.Calls, nil, nil) + + return knowledge, err +} diff --git a/pkg/agents/remaining_questions.go b/pkg/agents/remaining_questions.go new file mode 100644 index 0000000..e6b8071 --- /dev/null +++ b/pkg/agents/remaining_questions.go @@ -0,0 +1,101 @@ +package agents + +import ( + "context" + "fmt" + "strings" + + "gitea.stevedudenhoeffer.com/steve/answer/pkg/agents/shared" + + gollm "gitea.stevedudenhoeffer.com/steve/go-llm" +) + +type RemainingQuestions struct { + Model gollm.ChatCompletion + ContextualInformation []string +} + +// Process takes a knowledge object and processes it into a response string. +func (a RemainingQuestions) Process(ctx context.Context, knowledge shared.Knowledge) ([]string, error) { + originalQuestions := strings.Join(knowledge.OriginalQuestions, "\n") + infoGained := "" + + // group all the gained knowledge by source + var m = map[string][]string{} + for _, k := range knowledge.Knowledge { + m[k.Source] = append(m[k.Source], k.Info) + } + + // now order them in a list so they can be referenced by index + type source struct { + source string + info []string + } + + var sources []source + for k, v := range m { + sources = append(sources, source{ + source: k, + info: v, + }) + + if len(infoGained) > 0 { + infoGained += "\n" + } + + infoGained += strings.Join(v, "\n") + } + + systemPrompt := `I am trying to answer a question, and I gathered some knowledge in an attempt to do so. Here is what I am trying to answer: +` + originalQuestions + ` + +Here is the knowledge I have gathered from ` + fmt.Sprint(len(sources)) + ` sources: +` + infoGained + + systemPrompt += "\n\nUsing the information gathered, have all of the questions been answered? If not, what questions remain? Use the function 'remaining_questions' to answer this question with 0 or more remaining questions." + + var res []string + req := gollm.Request{ + Messages: []gollm.Message{ + { + Role: gollm.RoleSystem, + Text: systemPrompt, + }, + }, + Toolbox: gollm.NewToolBox( + gollm.NewFunction( + "remaining_questions", + "Given the information learned above, the following questions remain unanswered", + func(ctx *gollm.Context, args struct { + RemainingQuestions []string `description:"The questions that remain unanswered, if any"` + }) (string, error) { + res = append(res, args.RemainingQuestions...) + return "ok", nil + })), + } + + if len(a.ContextualInformation) > 0 { + req.Messages = append(req.Messages, gollm.Message{ + Role: gollm.RoleSystem, + Text: "Some contextual information you should be aware of: " + strings.Join(a.ContextualInformation, "\n"), + }) + } + + resp, err := a.Model.ChatComplete(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to chat complete: %w", err) + } + + if len(resp.Choices) == 0 { + return nil, fmt.Errorf("no choices returned") + } + + choice := resp.Choices[0] + + if len(choice.Calls) == 0 { + return nil, fmt.Errorf("no calls returned") + } + + _, err = req.Toolbox.ExecuteCallbacks(gollm.NewContext(ctx, req, &choice, nil), choice.Calls, nil, nil) + return res, err +} diff --git a/pkg/agents/search_terms.go b/pkg/agents/search_terms.go new file mode 100644 index 0000000..26983bc --- /dev/null +++ b/pkg/agents/search_terms.go @@ -0,0 +1,65 @@ +package agents + +import ( + "context" + "fmt" + + gollm "gitea.stevedudenhoeffer.com/steve/go-llm" +) + +type SearchTerms struct { + Model gollm.ChatCompletion + Context []string +} + +// SearchTerms will create search terms for the given question. +// alreadySearched is a list of search terms that have already been used, and should not be used again. +func (q SearchTerms) SearchTerms(ctx context.Context, question string, alreadySearched []string) (string, error) { + var res string + + req := gollm.Request{ + Toolbox: gollm.NewToolBox( + gollm.NewFunction( + "search_terms", + "search DuckDuckGo with these search terms for the given question", + func(ctx *gollm.Context, args struct { + SearchTerms string `description:"The search terms to use for the search"` + }) (string, error) { + res = args.SearchTerms + return "", nil + }), + ), + Messages: []gollm.Message{ + { + Role: gollm.RoleSystem, + Text: `You are to generate search terms for a question using DuckDuckGo. The question will be provided by the user.`, + }, + }, + } + + if len(alreadySearched) > 0 { + req.Messages = append(req.Messages, gollm.Message{ + Role: gollm.RoleSystem, + Text: fmt.Sprintf("The following search terms have already been used: %v", alreadySearched), + }) + } + + req.Messages = append(req.Messages, gollm.Message{ + Role: gollm.RoleUser, + Text: fmt.Sprintf("The question is: %s", question), + }) + + resp, err := q.Model.ChatComplete(ctx, req) + if err != nil { + return "", err + } + + if len(resp.Choices) == 0 { + return "", fmt.Errorf("no choices found") + } + + choice := resp.Choices[0] + + _, _ = req.Toolbox.ExecuteCallbacks(gollm.NewContext(ctx, req, &choice, nil), choice.Calls, nil, nil) + return res, nil +} diff --git a/pkg/agents/searcher/agent.go b/pkg/agents/searcher/agent.go new file mode 100644 index 0000000..740cbf9 --- /dev/null +++ b/pkg/agents/searcher/agent.go @@ -0,0 +1,296 @@ +package searcher + +import ( + "context" + "fmt" + "log/slog" + "net/url" + "strings" + + "gitea.stevedudenhoeffer.com/steve/answer/pkg/agents/reader" + "gitea.stevedudenhoeffer.com/steve/answer/pkg/agents/shared" + "gitea.stevedudenhoeffer.com/steve/go-extractor" + "gitea.stevedudenhoeffer.com/steve/go-extractor/sites/duckduckgo" + gollm "gitea.stevedudenhoeffer.com/steve/go-llm" +) + +// kMaxRuns is the maximum number of calls into the LLM this agent will make. +const kMaxRuns = 30 + +type Result struct { + // Answer is the answer to the question that was asked. + Answer string + + // Sources is a list of sources that were used to find the answer. + Sources []string + + // Remaining is the remaining part(s) of the question that was not answered. + Remaining string +} + +type Agent struct { + // Model is the chat completion model to use + Model gollm.ChatCompletion + + // OnGoingToNextPage is called when the agent is going to the next page + OnGoingToNextPage func(ctx context.Context) error + + // OnReadingSearchResult is called when the agent is reading a search result + // url is the URL of the search result that is being read. + // The return value is any data that you want to pass to OnFinishedReadingSearchResult. + OnReadingSearchResult func(ctx context.Context, searchResult duckduckgo.Result) (any, error) + + // OnFinishedReadingSearchResult is called when the agent is finished reading a search result. + // newKnowledge is the knowledge that was gained from reading the search result. + // err is any error that occurred while reading the search result. + // onReadingResult is the result of the OnReadingSearchResult function from the same search result. + OnFinishedReadingSearchResult func(ctx context.Context, searchResult duckduckgo.Result, newKnowledge []string, err error, onReadingResult any) error + + OnDone func(ctx context.Context, knowledge shared.Knowledge) error + + // MaxReads is the maximum number of pages that can be read by the agent. Unlimited if <= 0. + MaxReads int + + // MaxNextResults is the maximum number of times that the next_results function can be called. Unlimited if <= 0. + MaxNextResults int + + ContextualInformation []string +} + +// Search will search duckduckgo for the given question, and then read the results to figure out the answer. +// searchQuery is the query that you want to search for, e.g. "what is the capital of France site:reddit.com" +// question is the question that you are trying to answer when reading the search results. +// If the context contains a "browser" key that is an extractor.Browser, it will use that browser to search, otherwise a +// new one will be created and used for the life of this search and then closed. +func (a Agent) Search(ctx context.Context, searchQuery string, question string) (shared.Knowledge, error) { + var knowledge = shared.Knowledge{ + OriginalQuestions: []string{question}, + RemainingQuestions: []string{question}, + } + var done = false + + browser, ok := ctx.Value("browser").(extractor.Browser) + if !ok { + b, err := extractor.NewPlayWrightBrowser(extractor.PlayWrightBrowserOptions{}) + if err != nil { + return knowledge, err + } + + defer deferClose(browser) + ctx = context.WithValue(ctx, "browser", b) + browser = b + } + + cfg := duckduckgo.Config{ + SafeSearch: duckduckgo.SafeSearchOff, + Region: "us-en", + } + + page, err := cfg.OpenSearch(ctx, browser, searchQuery) + if err != nil { + return knowledge, err + } + + defer deferClose(page) + + var numberOfReads int + var numberOfNextResults int + + var searchResults []duckduckgo.Result + + // filterResults will remove any search results that are in oldSearchResults, or are empty + filterResults := func(in []duckduckgo.Result) []duckduckgo.Result { + var res []duckduckgo.Result + for _, r := range in { + if r.URL == "" { + continue + } + + res = append(res, r) + } + + return res + } + + searchResults = filterResults(page.GetResults()) + + fnNextResults := gollm.NewFunction( + "next_results", + "get the next page of search results", + func(c *gollm.Context, + arg struct { + Ignored string `description:"This is ignored, only included for API requirements."` + }) (string, error) { + if numberOfNextResults >= a.MaxNextResults && a.MaxNextResults > 0 { + return "Max next results reached", nil + } + + numberOfNextResults++ + + searchResults = append(searchResults, filterResults(page.GetResults())...) + + // clamp it to the 30 most recent results + if len(searchResults) > 30 { + // remove the first len(searchResults) - 30 elements + searchResults = searchResults[len(searchResults)-30:] + } + return "Got more search results", nil + }, + ) + + fnReadSearchResult := gollm.NewFunction( + "read", + "go to the next page of search results", + func(c *gollm.Context, arg struct { + Num int `description:"The # of the search result to read."` + }) (string, error) { + if numberOfReads >= a.MaxReads && a.MaxReads > 0 { + return "Max reads reached", nil + } + + numberOfReads++ + + r := reader.Agent{ + Model: a.Model, + ContextualInformation: a.ContextualInformation, + } + + // num is 1 based, we need 0 based + num := arg.Num - 1 + + // now ensure bounds are good + if num < 0 || num >= len(searchResults) { + return "", fmt.Errorf("search result %d is out of bounds", num) + } + + sr := searchResults[num] + + // remove that search result from the list + searchResults = append(searchResults[:num], searchResults[num+1:]...) + + u, err := url.Parse(sr.URL) + if err != nil { + return "", err + } + + var onReadingResult any + if a.OnReadingSearchResult != nil { + onReadingResult, err = a.OnReadingSearchResult(ctx, sr) + if err != nil { + return "", err + } + + } + + response, err := r.Read(c, question, u) + if err != nil { + return "", err + } + + if a.OnFinishedReadingSearchResult != nil { + var newKnowledge []string + + for _, k := range response.Knowledge { + newKnowledge = append(newKnowledge, k.Info) + } + err = a.OnFinishedReadingSearchResult(ctx, sr, newKnowledge, err, onReadingResult) + if err != nil { + return "", err + } + } + + slog.Info("read finished", "url", u, "knowledge gained", response.Knowledge, "remaining", response.RemainingQuestions) + + knowledge.Knowledge = append(knowledge.Knowledge, response.Knowledge...) + knowledge.RemainingQuestions = response.RemainingQuestions + + return "ok", nil + }) + + fnDone := gollm.NewFunction( + "done", + "finish reading search results", + func(c *gollm.Context, arg struct { + Ignored string `description:"This is ignored, only included for API requirements."` + }) (string, error) { + done = true + return "ok", nil + }) + + for i := 0; i < kMaxRuns && !done; i++ { + tools := gollm.NewToolBox(fnDone) + + if numberOfReads < a.MaxReads || a.MaxReads <= 0 { + tools = tools.WithFunction(*fnReadSearchResult) + } + + if numberOfNextResults < a.MaxNextResults || a.MaxNextResults <= 0 { + tools = tools.WithFunction(*fnNextResults) + } + + var req = gollm.Request{ + Toolbox: tools, + } + + req.Messages = append(req.Messages, gollm.Message{ + Role: gollm.RoleSystem, + Text: `You are searching DuckDuckGo for the answer to the question that will be posed by the user. The results will be provided in system messages in the format of: #. "https://url.here" - "Title of Page" - "Description here". For instance: +1. "https://example.com" - "Example Title" - "This is an example description." +2. "https://example2.com" - "Example Title 2" - "This is an example description 2."`, + }) + + req.Messages = append(req.Messages, gollm.Message{ + Role: gollm.RoleSystem, + Text: fmt.Sprintf(`You can read a search result by using the function "read_search_result" with the # of the page to read, +it will attempt to read the page, and then an LLM will read the page and see if it answers the question. The return value will be if there was an answer or not. You only have %d reads left of your original %d. Try to only pick high quality search results to read. +If you need to see more results from DuckDuckGo you can run the function "next_results" to get the next page of results. You only have %d next_results left of your original %d. +You can also use the function "done" to give up on reading the search results and finish executing, indicating you either have nothing left to answer or do not think any of the sources left will answer.`, max(a.MaxReads-numberOfReads, 0), a.MaxReads, max(a.MaxNextResults-numberOfNextResults, 0), a.MaxNextResults), + }) + + if len(a.ContextualInformation) > 0 { + req.Messages = append(req.Messages, gollm.Message{ + Role: gollm.RoleSystem, + Text: "Some contextual information you should be aware of: " + strings.Join(a.ContextualInformation, "\n"), + }) + } + + searches := "" + for i, r := range searchResults { + if i > 0 { + searches += "\n" + } + + searches += fmt.Sprintf("%d. %q - %q - %q", i+1, r.URL, r.Title, r.Description) + } + + req.Messages = append(req.Messages, gollm.Message{ + Role: gollm.RoleSystem, + Text: "Search results are:\n" + searches, + }) + + results, err := a.Model.ChatComplete(ctx, req) + if err != nil { + return knowledge, err + } + + if len(results.Choices) == 0 { + break + } + + choice := results.Choices[0] + + _, err = tools.ExecuteCallbacks(gollm.NewContext(ctx, req, &choice, nil), choice.Calls, nil, nil) + if err != nil { + return knowledge, err + } + } + + if a.OnDone != nil { + err := a.OnDone(ctx, knowledge) + if err != nil { + return knowledge, err + } + } + + return knowledge, nil +} diff --git a/pkg/agents/searcher/search.go b/pkg/agents/searcher/search.go new file mode 100644 index 0000000..16e11d0 --- /dev/null +++ b/pkg/agents/searcher/search.go @@ -0,0 +1,45 @@ +package searcher + +import ( + "fmt" + "io" + + "gitea.stevedudenhoeffer.com/steve/go-extractor" + "gitea.stevedudenhoeffer.com/steve/go-extractor/sites/duckduckgo" + gollm "gitea.stevedudenhoeffer.com/steve/go-llm" +) + +func deferClose(closer io.Closer) { + if closer != nil { + _ = closer.Close() + } +} + +type searchResult struct { + Answer string `json:"answer"` + Sources []string `json:"sources"` +} + +func fnSearch(ctx *gollm.Context, args struct { + Query string `description:"The search query to perform on duckduckgo"` + Question string `description:"The question(s) you are trying to answer when you read the search results. e.g: "` +}) (string, error) { + browser, ok := ctx.Value("browser").(extractor.Browser) + if !ok { + return "", fmt.Errorf("browser not found") + } + + cfg := duckduckgo.Config{ + SafeSearch: duckduckgo.SafeSearchOff, + Region: "us-en", + } + + page, err := cfg.OpenSearch(ctx, browser, args.Query) + defer deferClose(page) + + if err != nil { + return "", fmt.Errorf("failed to search: %w", err) + } + + return "", nil +} diff --git a/pkg/agents/shared/knowledge.go b/pkg/agents/shared/knowledge.go new file mode 100644 index 0000000..fc3873b --- /dev/null +++ b/pkg/agents/shared/knowledge.go @@ -0,0 +1,33 @@ +package shared + +import ( + "strings" +) + +// TidBit is a small piece of information that the AI has learned. +type TidBit struct { + Info string + Source string +} + +type Knowledge struct { + // OriginalQuestions are the questions that was asked first to the AI before any processing was done. + OriginalQuestions []string + + // RemainingQuestions is the questions that are left to find answers for. + RemainingQuestions []string + + // Knowledge are the tidbits of information that the AI has learned. + Knowledge []TidBit +} + +// ToMessage converts the knowledge to a message that can be sent to the LLM. +func (k Knowledge) ToMessage() string { + var learned []string + for _, t := range k.Knowledge { + learned = append(learned, t.Info) + } + return "Original questions asked:\n" + strings.Join(k.OriginalQuestions, "\n") + "\n" + + "Learned information:\n" + strings.Join(learned, "\n") + "\n" + + "Remaining questions:\n" + strings.Join(k.RemainingQuestions, "\n") +} diff --git a/pkg/agents/shared/modeltracker.go b/pkg/agents/shared/modeltracker.go new file mode 100644 index 0000000..69a98dc --- /dev/null +++ b/pkg/agents/shared/modeltracker.go @@ -0,0 +1,42 @@ +package shared + +import ( + "context" + "errors" + "sync/atomic" + + gollm "gitea.stevedudenhoeffer.com/steve/go-llm" +) + +type ModelTracker struct { + parent gollm.ChatCompletion + maximum int64 + calls int64 +} + +var _ gollm.ChatCompletion = &ModelTracker{} + +// NewModelTracker creates a new model tracker that will limit the number of calls to the parent. +// Set to 0 to disable the limit. +func NewModelTracker(parent gollm.ChatCompletion, maximum int64) *ModelTracker { + return &ModelTracker{parent: parent, maximum: maximum} +} + +var ErrModelCapacity = errors.New("maximum model capacity reached") + +func (m *ModelTracker) ChatComplete(ctx context.Context, req gollm.Request) (gollm.Response, error) { + if m.maximum > 0 && atomic.AddInt64(&m.calls, 1) >= m.maximum { + return gollm.Response{}, ErrModelCapacity + } + + return m.parent.ChatComplete(ctx, req) +} + +// ResetCalls resets the number of calls made to the parent. +func (m *ModelTracker) ResetCalls() { + atomic.StoreInt64(&m.calls, 0) +} + +func (m *ModelTracker) GetCalls() int64 { + return atomic.LoadInt64(&m.calls) +} diff --git a/pkg/agents/tools/calculator.go b/pkg/agents/tools/calculator.go new file mode 100644 index 0000000..db4ddba --- /dev/null +++ b/pkg/agents/tools/calculator.go @@ -0,0 +1,27 @@ +package tools + +import ( + "go.starlark.net/lib/math" + "go.starlark.net/starlark" + "go.starlark.net/syntax" + + gollm "gitea.stevedudenhoeffer.com/steve/go-llm" +) + +var Calculator = gollm.NewFunction( + "calculator", + "A starlark calculator", + func(ctx *gollm.Context, args struct { + Expression string `description:"The expression to evaluate using starlark"` + }) (string, error) { + val, err := starlark.EvalOptions(&syntax.FileOptions{}, + &starlark.Thread{Name: "main"}, + "input", + args.Expression, + math.Module.Members) + if err != nil { + return "", err + } + + return val.String(), nil + }) diff --git a/pkg/agents/tools/wolfram.go b/pkg/agents/tools/wolfram.go new file mode 100644 index 0000000..18120a3 --- /dev/null +++ b/pkg/agents/tools/wolfram.go @@ -0,0 +1,34 @@ +package tools + +import ( + "github.com/Edw590/go-wolfram" + + gollm "gitea.stevedudenhoeffer.com/steve/go-llm" +) + +type WolframFunctions struct { + Imperial *gollm.Function + Metric *gollm.Function +} + +func CreateWolframFunctions(appId string) WolframFunctions { + client := &wolfram.Client{AppID: appId} + return WolframFunctions{ + Imperial: gollm.NewFunction( + "wolfram", + "Query the Wolfram Alpha API", + func(ctx *gollm.Context, args struct { + Question string `description:"The question to ask Wolfram|Alpha"` + }) (string, error) { + return client.GetShortAnswerQuery(args.Question, wolfram.Imperial, 10) + }), + Metric: gollm.NewFunction( + "wolfram", + "Query the Wolfram Alpha API", + func(ctx *gollm.Context, args struct { + Question string `description:"The question to ask Wolfram|Alpha"` + }) (string, error) { + return client.GetShortAnswerQuery(args.Question, wolfram.Metric, 10) + }), + } +} diff --git a/pkg/answer/answer.go b/pkg/answer/answer.go index a4eb1a2..68266db 100644 --- a/pkg/answer/answer.go +++ b/pkg/answer/answer.go @@ -4,8 +4,11 @@ import ( "context" "errors" "fmt" + "io" "log/slog" + "gitea.stevedudenhoeffer.com/steve/go-extractor" + "github.com/Edw590/go-wolfram" "go.starlark.net/lib/math" "go.starlark.net/starlark" @@ -97,12 +100,31 @@ type Response struct { Sources []string } +func deferClose(cl io.Closer) { + if cl != nil { + _ = cl.Close() + } +} + func (o Options) Answer(ctx context.Context, q Question) (Response, error) { var answer Response + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + b, err := extractor.NewPlayWrightBrowser(extractor.PlayWrightBrowserOptions{ + DarkMode: true, + }) + defer deferClose(b) + if err != nil { + return answer, err + } + + ctx = context.WithValue(ctx, "browser", b) + fnSearch := gollm.NewFunction( "search", - "Search the web for an answer to a question. You can call this function up to "+fmt.Sprint(o.MaxSearches)+` times. The result will be JSON in the format of {"url": "https://example.com", "answer": "the answer to the question"}. If a previous call to search produced no results, do not re-search with just reworded search terms, try a different approach.`, + "Search the web for an answer to a question. You can call this function up to "+fmt.Sprint(o.MaxSearches)+` times. The result will be JSON in the format of {"urls": ["https://example.com", "https://url2.com/"], "answer": "the answer to the question"}. If a previous call to search produced no results, do not re-search with just reworded search terms, try a different approach.`, func(ctx *gollm.Context, args struct { Query string `description:"search the web with this, such as: 'capital of the united states site:wikipedia.org'"` Question string `description:"when reading the results, what question(s) are you trying to answer?"` @@ -113,7 +135,12 @@ func (o Options) Answer(ctx context.Context, q Question) (Response, error) { if o.MaxSearches > 0 { o.MaxSearches = o.MaxSearches - 1 } - return functionSearch(ctx, q2, args.Query) + res, err := functionSearch2(ctx, q2, args.Query) + if err != nil { + return "", err + } + + return res.String() }) fnAnswer := gollm.NewFunction( @@ -238,10 +265,6 @@ func (o Options) Answer(ctx context.Context, q Question) (Response, error) { return nil, err } - if len(res.Choices) > o.MaxSearches { - res.Choices = res.Choices[:o.MaxSearches] - } - if len(res.Choices) == 0 { return nil, fmt.Errorf("no response choices provided") } diff --git a/pkg/answer/search.go b/pkg/answer/search.go index 31c6068..f1bad56 100644 --- a/pkg/answer/search.go +++ b/pkg/answer/search.go @@ -2,15 +2,30 @@ package answer import ( "encoding/json" + "fmt" "log/slog" "net/url" + "slices" "strings" + "time" + + "gitea.stevedudenhoeffer.com/steve/answer/pkg/agent" + + "gitea.stevedudenhoeffer.com/steve/go-extractor" + + "gitea.stevedudenhoeffer.com/steve/go-extractor/sites/duckduckgo" "gitea.stevedudenhoeffer.com/steve/answer/pkg/search" gollm "gitea.stevedudenhoeffer.com/steve/go-llm" ) +const ( + kMaxLoops = 10 + kMaxReads = 10 + kMaxLoadMore = 3 +) + type searchResults struct { Url string `json:"url"` Answer string `json:"answer"` @@ -177,11 +192,462 @@ func internalSearch(ctx *gollm.Context, q Question, searchTerm string) (searchRe return searchResults{Url: "not-found", Answer: "no searched results answered"}, nil } -func functionSearch(ctx *gollm.Context, q Question, searchTerm string) (string, error) { - res, err := internalSearch(ctx, q, searchTerm) +type searchResults2 struct { + Answer string `json:"answer"` + Urls []string `json:"urls"` +} + +func (r searchResults2) String() (string, error) { + b, err := json.Marshal(r) if err != nil { return "", err } - return res.String() + return string(b), nil +} +func functionSearch2(ctx *gollm.Context, q Question, searchTerm string) (searchResults2, error) { + var res searchResults2 + browser, ok := ctx.Value("browser").(extractor.Browser) + if !ok { + return searchResults2{}, fmt.Errorf("browser not found in context") + } + + cfg := duckduckgo.Config{ + SafeSearch: duckduckgo.SafeSearchOff, + Region: "us-en", + } + + page, err := cfg.OpenSearch(ctx, browser, searchTerm) + defer deferClose(page) + if err != nil { + return searchResults2{}, fmt.Errorf("failed to open search page: %w", err) + } + + var totalNextPage int + var totalRead int + + // oldResults are all the old results from the previous pages, so that when we load more we can filter out + // the old results + var oldResults []duckduckgo.Result + + filterResults := func(results []duckduckgo.Result) []duckduckgo.Result { + var res []duckduckgo.Result + for _, r := range results { + if r.Title == "" || r.Description == "" { + continue + } + + if slices.Contains(oldResults, r) { + continue + } + + res = append(res, r) + } + + return res + } + + a := agent.NewAgent(gollm.Request{ + Messages: []gollm.Message{ + { + Role: gollm.RoleSystem, + Text: `You are trying to answer a question by reading pages from a search engine. +Use 'read' to read a page. You can only read 10 pages total, so try to only pick high quality pages. Results of a read will be in the format of {"url": "https://url.here", "answer": "answer here"}. +Additionally, you can use 'next_page' to load more results. You can only use next_page 3 times total. +You can read multiple pages at once, or read one page and continue to the next page if you need more information. +But if you are confident in your answer, you can use 'answer' to provide the answer. +Or you can use 'give_up' to indicate that you cannot find an answer and give up.`, + }, + { + Role: gollm.RoleSystem, + Text: "The question you are trying to answer is: " + q.Question, + }, + { + Role: gollm.RoleSystem, + Text: "The search terms you used were: " + searchTerm, + }, + { + Role: gollm.RoleSystem, + Text: `The search results will be provided by the user in json format of: {"url": "https://url.here", "title": "Title Of Page", "description": "description here"}`, + }, + }, + }) + + a.Model = q.Model + + var giveup bool + + addMessages := func(results []duckduckgo.Result) { + type searchResults struct { + Url string `json:"url"` + Title string `json:"title"` + Desc string `json:"description"` + } + for _, r := range results { + b, _ := json.Marshal(&searchResults{Url: r.URL, Title: r.Title, Desc: r.Description}) + a.AddMessage(gollm.Message{ + Role: gollm.RoleUser, + Text: string(b), + }) + } + } + + fnRead := gollm.NewFunction( + "read", + `Read a page from the search results. The results will be in the JSON format of: {"url": "https://url.here", "answer": "answer here"}`, + func(ctx *gollm.Context, args struct { + URL string `description:"the url to read"` + }) (string, error) { + slog.Info("read", "url", args.URL) + if totalRead >= kMaxReads { + return "you have read the maximum number of pages", nil + } + + totalRead += 1 + + u, err := url.Parse(args.URL) + if err != nil { + return "", fmt.Errorf("failed to parse url: %w", err) + } + + a, err := extractArticle(ctx, q.Cache, u) + slog.Info("extracted article", "url", a.URL, "title", a.Title, "body", a.Body) + if err != nil { + return "", fmt.Errorf("failed to extract article: %w", err) + } + + if a.Title == "" || a.Body == "" { + return "couldn't read the page", nil + } + + answer, err := doesTextAnswerQuestion(ctx, q, a.Body) + if err != nil { + return "", fmt.Errorf("failed to check if text answers question: %w", err) + } + + var res = searchResults{ + Url: u.String(), + Answer: answer, + } + + return res.String() + }) + + fnNextPage := gollm.NewFunction( + "next_page", + "Load more results from the search engine.", + func(ctx *gollm.Context, args struct { + Ignored string `description:"ignored, just here to make sure the function is called. Fill with anything."` + }) (string, error) { + if totalNextPage >= kMaxLoadMore { + return "you have loaded the maximum number of pages", nil + } + + totalNextPage += 1 + + err := page.LoadMore() + if err != nil { + return "", fmt.Errorf("failed to load more results: %w", err) + } + + time.Sleep(4 * time.Second) + + results := page.GetResults() + + // only add the new results here... + filteredResults := filterResults(results) + oldResults = append(oldResults, filteredResults...) + addMessages(filteredResults) + return "ok", nil + }) + + fnAnswer := gollm.NewFunction( + "answer", + "Provide the answer to the question.", + func(ctx *gollm.Context, args struct { + Answer string `description:"the answer to the question"` + Sources []string `description:"the urls of sources used to find the answer"` + }) (string, error) { + res.Answer = args.Answer + res.Urls = args.Sources + giveup = true + return "ok", nil + }) + + fnGiveUp := gollm.NewFunction( + "give_up", + "Indicate that you cannot find an answer and give up.", + func(ctx *gollm.Context, args struct { + Ignored string `description:"ignored, just here to make sure the function is called. Fill with anything."` + }) (string, error) { + giveup = true + return "ok", nil + }) + + // do initial load of results + results := page.GetResults() + filteredResults := filterResults(results) + oldResults = append(oldResults, filteredResults...) + addMessages(filteredResults) + + var i = 0 + for ; i < kMaxLoops && !giveup; i++ { + // figure out my allowed tools, based on limits + var tools = []*gollm.Function{ + fnAnswer, + fnGiveUp, + } + + if totalRead < kMaxReads { + tools = append(tools, fnRead) + } + + if totalNextPage < kMaxLoadMore { + tools = append(tools, fnNextPage) + } + + a.ToolBox = gollm.NewToolBox(tools...) + + err = a.Execute(ctx, gollm.Message{Role: gollm.RoleSystem, Text: "Now evaluate if the text answers the question, and use a function to either provide the answer or read more pages."}) + if err != nil { + return searchResults2{}, fmt.Errorf("failed to run agent: %w", err) + } + } + + if giveup { + return res, fmt.Errorf("gave up: no relevant results found") + } + + if res.Answer == "" { + return res, fmt.Errorf("no answer found") + } + + return res, nil +} +func functionSearch(ctx *gollm.Context, q Question, searchTerm string) (searchResults2, error) { + var res searchResults2 + browser, ok := ctx.Value("browser").(extractor.Browser) + if !ok { + return searchResults2{}, fmt.Errorf("browser not found in context") + } + + cfg := duckduckgo.Config{ + SafeSearch: duckduckgo.SafeSearchOff, + Region: "us-en", + } + + page, err := cfg.OpenSearch(ctx, browser, searchTerm) + defer deferClose(page) + if err != nil { + return searchResults2{}, fmt.Errorf("failed to open search page: %w", err) + } + + var totalNextPage int + var totalRead int + + // oldResults are all the old results from the previous pages, so that when we load more we can filter out + // the old results + var oldResults []duckduckgo.Result + + filterResults := func(results []duckduckgo.Result) []duckduckgo.Result { + var res []duckduckgo.Result + for _, r := range results { + if r.Title == "" || r.Description == "" { + continue + } + + if slices.Contains(oldResults, r) { + continue + } + + res = append(res, r) + } + + return res + } + + var giveup bool + req := gollm.Request{ + Messages: []gollm.Message{ + { + Role: gollm.RoleSystem, + Text: `You are trying to answer a question by reading pages from a search engine. +Use 'read' to read a page. You can only read 10 pages total, so try to only pick high quality pages. +Additionally, you can use 'next_page' to load more results. You can only use next_page 3 times total. +You can read multiple pages at once, or read one page and continue to the next page if you need more information. +But if you are confident in your answer, you can use 'answer' to provide the answer. +Or you can use 'give_up' to indicate that you cannot find an answer and give up.`, + }, + { + Role: gollm.RoleSystem, + Text: "The question you are trying to answer is: " + q.Question, + }, + { + Role: gollm.RoleSystem, + Text: "The search terms you used were: " + searchTerm, + }, + { + Role: gollm.RoleSystem, + Text: `The search results will be provided by the user in json format of: {"url": "https://url.here", "title": "Title Of Page", "description": "description here"}`, + }, + }, + } + + addMessages := func(results []duckduckgo.Result) { + type searchResults struct { + Url string `json:"url"` + Title string `json:"title"` + Desc string `json:"description"` + } + for _, r := range results { + b, _ := json.Marshal(&searchResults{Url: r.URL, Title: r.Title, Desc: r.Description}) + req.Messages = append(req.Messages, gollm.Message{ + Role: gollm.RoleUser, + Text: string(b), + }) + } + } + + fnRead := gollm.NewFunction( + "read", + `Read a page from the search results. The results will be in the JSON format of: {"url": "https://url.here", "answer": "answer here"}`, + func(ctx *gollm.Context, args struct { + URL string `description:"the url to read"` + }) (string, error) { + if totalRead >= kMaxReads { + return "you have read the maximum number of pages", nil + } + + totalRead += 1 + + u, err := url.Parse(args.URL) + if err != nil { + return "", fmt.Errorf("failed to parse url: %w", err) + } + + a, err := extractArticle(ctx, q.Cache, u) + if err != nil { + return "", fmt.Errorf("failed to extract article: %w", err) + } + + if a.Title == "" || a.Body == "" { + return "couldn't read the page", nil + } + + answer, err := doesTextAnswerQuestion(ctx, q, a.Body) + if err != nil { + return "", fmt.Errorf("failed to check if text answers question: %w", err) + } + + var res = searchResults{ + Url: u.String(), + Answer: answer, + } + + return res.String() + }) + + fnNextPage := gollm.NewFunction( + "next_page", + "Load more results from the search engine.", + func(ctx *gollm.Context, args struct { + Ignored string `description:"ignored, just here to make sure the function is called. Fill with anything."` + }) (string, error) { + if totalNextPage >= kMaxLoadMore { + return "you have loaded the maximum number of pages", nil + } + + totalNextPage += 1 + + err := page.LoadMore() + if err != nil { + return "", fmt.Errorf("failed to load more results: %w", err) + } + + time.Sleep(4 * time.Second) + + results := page.GetResults() + + // only add the new results here... + filteredResults := filterResults(results) + oldResults = append(oldResults, filteredResults...) + addMessages(filteredResults) + return "ok", nil + }) + + fnAnswer := gollm.NewFunction( + "answer", + "Provide the answer to the question.", + func(ctx *gollm.Context, args struct { + Answer string `description:"the answer to the question"` + Sources []string `description:"the urls of sources used to find the answer"` + }) (string, error) { + res.Answer = args.Answer + res.Urls = args.Sources + giveup = true + return "ok", nil + }) + + fnGiveUp := gollm.NewFunction( + "give_up", + "Indicate that you cannot find an answer and give up.", + func(ctx *gollm.Context, args struct { + Ignored string `description:"ignored, just here to make sure the function is called. Fill with anything."` + }) (string, error) { + giveup = true + return "ok", nil + }) + + // do initial load of results + results := page.GetResults() + filteredResults := filterResults(results) + oldResults = append(oldResults, filteredResults...) + addMessages(filteredResults) + + var i = 0 + for ; i < kMaxLoops && !giveup; i++ { + // figure out my allowed tools, based on limits + var tools = []*gollm.Function{ + fnAnswer, + fnGiveUp, + } + + if totalRead < kMaxReads { + tools = append(tools, fnRead) + } + + if totalNextPage < kMaxLoadMore { + tools = append(tools, fnNextPage) + } + + req.Toolbox = gollm.NewToolBox(tools...) + + res, err := q.Model.ChatComplete(ctx, req) + if err != nil { + return searchResults2{}, fmt.Errorf("failed to chat complete: %w", err) + } + + if len(res.Choices) == 0 { + break + } + + if len(res.Choices[0].Calls) == 0 { + break + } + + _, err = req.Toolbox.Execute(ctx, res.Choices[0].Calls[0]) + if err != nil { + return searchResults2{}, fmt.Errorf("failed to execute: %w", err) + } + } + + if giveup { + return res, fmt.Errorf("gave up: no relevant results found") + } + + if res.Answer == "" { + return res, fmt.Errorf("no answer found") + } + + return res, nil }