diff --git a/pkg/answer/answer.go b/pkg/answer/answer.go index 9d10f71..a4eb1a2 100644 --- a/pkg/answer/answer.go +++ b/pkg/answer/answer.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "log/slog" - "net/url" "github.com/Edw590/go-wolfram" "go.starlark.net/lib/math" @@ -13,7 +12,6 @@ import ( "go.starlark.net/syntax" "gitea.stevedudenhoeffer.com/steve/answer/pkg/cache" - "gitea.stevedudenhoeffer.com/steve/answer/pkg/extractor" "gitea.stevedudenhoeffer.com/steve/answer/pkg/search" gollm "gitea.stevedudenhoeffer.com/steve/go-llm" ) @@ -94,105 +92,13 @@ type article struct { 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 { + Text string + Sources []string } -func doesTextAnswerQuestion(ctx *gollm.Context, q Question, text string) (string, error) { - fnAnswer := gollm.NewFunction( - "answer", - "The answer from the given text that answers the question.", - func(ctx *gollm.Context, args struct { - Answer string `description:"the answer to the question, the answer should come from the text"` - }) (string, error) { - return args.Answer, nil - }) - - fnNoAnswer := gollm.NewFunction( - "no_answer", - "Indicate that the text does not answer the question.", - func(ctx *gollm.Context, args struct { - Ignored string `description:"ignored, just here to make sure the function is called. Fill with anything."` - }) (string, error) { - return "", nil - }) - - req := gollm.Request{ - Messages: []gollm.Message{ - { - Role: gollm.RoleSystem, - Text: "Evaluate the given text to see if it answers the question from the user. The text is as follows:", - }, - { - Role: gollm.RoleSystem, - Text: text, - }, - { - Role: gollm.RoleUser, - Text: q.Question, - }, - }, - Toolbox: gollm.NewToolBox(fnAnswer, fnNoAnswer), - } - - res, err := q.Model.ChatComplete(ctx, req) - - if err != nil { - return "", err - } - - if len(res.Choices) == 0 { - return "", nil - } - - if len(res.Choices[0].Calls) == 0 { - return "", nil - } - - return req.Toolbox.Execute(ctx, res.Choices[0].Calls[0]) -} - -func (o Options) Answer(ctx context.Context, q Question) (string, error) { - var answer string +func (o Options) Answer(ctx context.Context, q Question) (Response, error) { + var answer Response fnSearch := gollm.NewFunction( "search", @@ -217,7 +123,8 @@ func (o Options) Answer(ctx context.Context, q Question) (string, error) { Answer string `json:"answer" description:"the answer to the question"` Sources []string `json:"sources" description:"the sources used to find the answer (e.g.: urls of sites from search)"` }) (string, error) { - answer = args.Answer + answer.Text = args.Answer + answer.Sources = args.Sources return args.Answer, nil }) @@ -260,7 +167,7 @@ func (o Options) Answer(ctx context.Context, q Question) (string, error) { func(ctx *gollm.Context, args struct { Reason string `description:"the reason the system is giving up (e.g.: 'no results found')"` }) (string, error) { - answer = "given up: " + args.Reason + answer.Text = "given up: " + args.Reason return "given up", nil }) @@ -422,14 +329,14 @@ func (o Options) Answer(ctx context.Context, q Question) (string, error) { newReq, err := runAnswer(o, req) if err != nil { - return "", err + return answer, err } if newReq == nil { break } - if answer != "" { + if answer.Text != "" { break } @@ -439,6 +346,6 @@ func (o Options) Answer(ctx context.Context, q Question) (string, error) { return answer, nil } -func Answer(ctx context.Context, q Question) (string, error) { +func Answer(ctx context.Context, q Question) (Response, error) { return DefaultOptions.Answer(ctx, q) } diff --git a/pkg/answer/extractor.go b/pkg/answer/extractor.go new file mode 100644 index 0000000..29c432f --- /dev/null +++ b/pkg/answer/extractor.go @@ -0,0 +1,109 @@ +package answer + +import ( + "context" + "fmt" + "net/url" + + gollm "gitea.stevedudenhoeffer.com/steve/go-llm" + + "gitea.stevedudenhoeffer.com/steve/answer/pkg/cache" + "gitea.stevedudenhoeffer.com/steve/answer/pkg/extractor" +) + +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 +} + +func doesTextAnswerQuestion(ctx *gollm.Context, q Question, text string) (string, error) { + fnAnswer := gollm.NewFunction( + "answer", + "The answer from the given text that answers the question.", + func(ctx *gollm.Context, args struct { + Answer string `description:"the answer to the question, the answer should come from the text"` + }) (string, error) { + return args.Answer, nil + }) + + fnNoAnswer := gollm.NewFunction( + "no_answer", + "Indicate that the text does not answer the question.", + func(ctx *gollm.Context, args struct { + Ignored string `description:"ignored, just here to make sure the function is called. Fill with anything."` + }) (string, error) { + return "", nil + }) + + req := gollm.Request{ + Messages: []gollm.Message{ + { + Role: gollm.RoleSystem, + Text: "Evaluate the given text to see if it answers the question from the user. The text is as follows:", + }, + { + Role: gollm.RoleSystem, + Text: text, + }, + { + Role: gollm.RoleUser, + Text: q.Question, + }, + }, + Toolbox: gollm.NewToolBox(fnAnswer, fnNoAnswer), + } + + res, err := q.Model.ChatComplete(ctx, req) + + if err != nil { + return "", err + } + + if len(res.Choices) == 0 { + return "", nil + } + + if len(res.Choices[0].Calls) == 0 { + return "", nil + } + + return req.Toolbox.Execute(ctx, res.Choices[0].Calls[0]) +} diff --git a/pkg/answer/search.go b/pkg/answer/search.go index ee77f47..31c6068 100644 --- a/pkg/answer/search.go +++ b/pkg/answer/search.go @@ -6,6 +6,8 @@ import ( "net/url" "strings" + "gitea.stevedudenhoeffer.com/steve/answer/pkg/search" + gollm "gitea.stevedudenhoeffer.com/steve/go-llm" ) @@ -23,6 +25,92 @@ func (s searchResults) String() (string, error) { return string(b), nil } +func pickResult(ctx *gollm.Context, results []search.Result, q Question) (*search.Result, error) { + // if there's only one result, return it + if len(results) == 1 { + return &results[0], nil + } + + // if there are no results, return nil + if len(results) == 0 { + return nil, nil + } + + var pick *search.Result + var refused bool + // finally, if there are multiple results then ask the LLM to pick one to read next + fnPick := gollm.NewFunction( + "pick", + "The search result to read next.", + func(ctx *gollm.Context, args struct { + URL string `description:"the url to read next"` + }) (string, error) { + for _, r := range results { + if r.URL == args.URL { + pick = &r + break + } + } + + return "", nil + }) + + fnNoPick := gollm.NewFunction( + "no_pick", + "Indicate that there are no results worth reading.", + func(ctx *gollm.Context, args struct { + Ignored string `description:"ignored, just here to make sure the function is called. Fill with anything."` + }) (string, error) { + refused = true + return "", nil + }) + + req := gollm.Request{ + Messages: []gollm.Message{ + { + Role: gollm.RoleSystem, + Text: `You are being given results from a web search. Please select the result you would like to read next to answer the question. Try to pick the most reputable and relevant result. +The results will be in the JSON format of: {"Url": "https://url.here", "Title": "Title Of Search", "Description": "description here"}`, + }, + { + Role: gollm.RoleSystem, + Text: "The question you are trying to answer is: " + q.Question, + }, + }, + Toolbox: gollm.NewToolBox(fnPick, fnNoPick), + } + + for _, r := range results { + b, _ := json.Marshal(r) + req.Messages = append(req.Messages, gollm.Message{ + Role: gollm.RoleUser, + Text: string(b), + }) + } + + res, err := q.Model.ChatComplete(ctx, req) + + if err != nil { + return nil, err + } + + if len(res.Choices) == 0 { + return nil, nil + } + + if len(res.Choices[0].Calls) == 0 { + return nil, nil + } + + _, _ = req.Toolbox.Execute(ctx, res.Choices[0].Calls[0]) + + if refused { + return nil, nil + } + + return pick, nil +} + func internalSearch(ctx *gollm.Context, q Question, searchTerm string) (searchResults, error) { slog.Info("searching", "search", searchTerm, "question", q) results, err := q.Search.Search(ctx, searchTerm) @@ -34,9 +122,26 @@ func internalSearch(ctx *gollm.Context, q Question, searchTerm string) (searchRe return searchResults{Url: "not-found", Answer: "no search results found"}, nil } - // first pass try to see if any provide the result without needing archive - for _, r := range results { - trimmed := strings.TrimSpace(r.URL) + for len(results) > 0 { + var pick *search.Result + if len(results) == 1 { + pick = &results[0] + results = results[1:] + } else { + var err error + pick, err = pickResult(ctx, results, q) + + slog.Info("picked result", "result", pick, "error", err) + if err != nil { + return searchResults{}, err + } + + if pick == nil { + break + } + } + + trimmed := strings.TrimSpace(pick.URL) if trimmed == "" { }