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"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"github.com/Edw590/go-wolfram"
|
"github.com/Edw590/go-wolfram"
|
||||||
"go.starlark.net/lib/math"
|
"go.starlark.net/lib/math"
|
||||||
@ -13,7 +12,6 @@ import (
|
|||||||
"go.starlark.net/syntax"
|
"go.starlark.net/syntax"
|
||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/answer/pkg/cache"
|
"gitea.stevedudenhoeffer.com/steve/answer/pkg/cache"
|
||||||
"gitea.stevedudenhoeffer.com/steve/answer/pkg/extractor"
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/answer/pkg/search"
|
"gitea.stevedudenhoeffer.com/steve/answer/pkg/search"
|
||||||
gollm "gitea.stevedudenhoeffer.com/steve/go-llm"
|
gollm "gitea.stevedudenhoeffer.com/steve/go-llm"
|
||||||
)
|
)
|
||||||
@ -94,105 +92,13 @@ type article struct {
|
|||||||
Body string
|
Body string
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractArticle(ctx context.Context, c cache.Cache, u *url.URL) (res article, err error) {
|
type Response struct {
|
||||||
defer func() {
|
Text string
|
||||||
e := recover()
|
Sources []string
|
||||||
|
|
||||||
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) {
|
func (o Options) Answer(ctx context.Context, q Question) (Response, error) {
|
||||||
fnAnswer := gollm.NewFunction(
|
var answer Response
|
||||||
"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
|
|
||||||
|
|
||||||
fnSearch := gollm.NewFunction(
|
fnSearch := gollm.NewFunction(
|
||||||
"search",
|
"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"`
|
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)"`
|
Sources []string `json:"sources" description:"the sources used to find the answer (e.g.: urls of sites from search)"`
|
||||||
}) (string, error) {
|
}) (string, error) {
|
||||||
answer = args.Answer
|
answer.Text = args.Answer
|
||||||
|
answer.Sources = args.Sources
|
||||||
return args.Answer, nil
|
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 {
|
func(ctx *gollm.Context, args struct {
|
||||||
Reason string `description:"the reason the system is giving up (e.g.: 'no results found')"`
|
Reason string `description:"the reason the system is giving up (e.g.: 'no results found')"`
|
||||||
}) (string, error) {
|
}) (string, error) {
|
||||||
answer = "given up: " + args.Reason
|
answer.Text = "given up: " + args.Reason
|
||||||
return "given up", nil
|
return "given up", nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -422,14 +329,14 @@ func (o Options) Answer(ctx context.Context, q Question) (string, error) {
|
|||||||
newReq, err := runAnswer(o, req)
|
newReq, err := runAnswer(o, req)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return answer, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if newReq == nil {
|
if newReq == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if answer != "" {
|
if answer.Text != "" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -439,6 +346,6 @@ func (o Options) Answer(ctx context.Context, q Question) (string, error) {
|
|||||||
return answer, nil
|
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)
|
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"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/answer/pkg/search"
|
||||||
|
|
||||||
gollm "gitea.stevedudenhoeffer.com/steve/go-llm"
|
gollm "gitea.stevedudenhoeffer.com/steve/go-llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -23,6 +25,92 @@ func (s searchResults) String() (string, error) {
|
|||||||
return string(b), nil
|
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) {
|
func internalSearch(ctx *gollm.Context, q Question, searchTerm string) (searchResults, error) {
|
||||||
slog.Info("searching", "search", searchTerm, "question", q)
|
slog.Info("searching", "search", searchTerm, "question", q)
|
||||||
results, err := q.Search.Search(ctx, searchTerm)
|
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
|
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 len(results) > 0 {
|
||||||
for _, r := range results {
|
var pick *search.Result
|
||||||
trimmed := strings.TrimSpace(r.URL)
|
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 == "" {
|
if trimmed == "" {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user