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:
		| @@ -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 == "" { | ||||||
|  |  | ||||||
| 		} | 		} | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user