Steve Dudenhoeffer 693ac4e6a7 Add core implementation for AI-powered question answering
Introduce multiple agents, tools, and utilities for processing, extracting, and answering user-provided questions using LLMs and external data. Key features include knowledge processing, question splitting, search term generation, and contextual knowledge handling.
2025-03-21 11:10:48 -04:00

297 lines
9.2 KiB
Go

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
}