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:
Steve Dudenhoeffer 2025-03-18 01:34:15 -04:00
parent b3df0fc902
commit 20bcaefaa2
3 changed files with 228 additions and 107 deletions

View File

@ -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
View 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])
}

View File

@ -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 == "" {
} }