Refactor search and answer logic to improve modularity
Extracted article handling and text evaluation functions to a new `extractor` module to enhance separation of concerns. Updated the search logic to incorporate result picking using LLM and adjusted the `Answer` function to return structured responses, ensuring better maintainability and extensibility.
This commit is contained in:
parent
b3df0fc902
commit
20bcaefaa2
@ -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
|
||||
type Response struct {
|
||||
Text string
|
||||
Sources []string
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
109
pkg/answer/extractor.go
Normal file
109
pkg/answer/extractor.go
Normal file
@ -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])
|
||||
}
|
@ -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 == "" {
|
||||
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user