Introduce multiple agents, tools, and utilities for processing, extracting, and answering user-provided questions using LLMs and external data. Key features include knowledge processing, question splitting, search term generation, and contextual knowledge handling.
375 lines
10 KiB
Go
375 lines
10 KiB
Go
package answer
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/go-extractor"
|
|
|
|
"github.com/Edw590/go-wolfram"
|
|
"go.starlark.net/lib/math"
|
|
"go.starlark.net/starlark"
|
|
"go.starlark.net/syntax"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/answer/pkg/cache"
|
|
"gitea.stevedudenhoeffer.com/steve/answer/pkg/search"
|
|
gollm "gitea.stevedudenhoeffer.com/steve/go-llm"
|
|
)
|
|
|
|
var ErrMaxTries = errors.New("maximum number of pages tried reached")
|
|
var ErrMaxAnswers = errors.New("maximum number of answers parsed reached")
|
|
var ErrTooManyArguments = errors.New("too many arguments")
|
|
|
|
type Question struct {
|
|
// Question is the question to answer
|
|
Question string
|
|
|
|
Model gollm.ChatCompletion
|
|
|
|
Search search.Search
|
|
|
|
Cache cache.Cache
|
|
}
|
|
|
|
// Answers is a list of answers to a question
|
|
type Answers []string
|
|
|
|
const DefaultPrompt = `You are being asked to answer a question.
|
|
You must respond with a function.
|
|
You can answer it if you know the answer, or if some functions exist you can use those to help you find the answer.
|
|
You can break up the question into multiple steps, and will learn from questions. Such as, if you need to compute the
|
|
exact square root of the powerball jackpot, you could search for the current jackpot and then when search finishes
|
|
you could execute calculate sqrt(that value), and respond with that.
|
|
`
|
|
|
|
type Options struct {
|
|
// MaxSearches is the maximum possible number of searches to execute for this question. If this is set to 5, the function could
|
|
// search up to 5 possible times to find an answer.
|
|
MaxSearches int
|
|
|
|
// MaxTries is the absolute maximum number of pages to try to get an answer from. For instance, if MaxSearches is 5 and
|
|
// 5 pages are tried and no answers are found, the function will return ErrMaxTries.
|
|
MaxTries int
|
|
|
|
// OnNewFunction is a callback that, if non-nil, will be called when a new function is called by the LLM.
|
|
// The "answer" and "no_answer" functions are not included in this callback.
|
|
// Return an error to stop the function from being called.
|
|
OnNewFunction func(ctx context.Context, funcName string, question string, parameter string) (any, error)
|
|
|
|
// OnFunctionFinished is a callback that, if non-nil, will be called when a function has finished executing. The
|
|
// function name is passed in, as well as the question, the parameter, all similar to OnNewFunction. The result of
|
|
// the function is also passed in, as well as any error that occurred. Finally, the result passed from the
|
|
// OnNewFunction that preceded this function is passed in as well.
|
|
OnFunctionFinished func(ctx context.Context, funcName string, question string, parameter string, result string, err error, newFunctionResult any) error
|
|
|
|
// SystemPrompt is the prompt to use when asking the system to answer a question.
|
|
// If this is empty, DefaultPrompt will be used.
|
|
SystemPrompt string
|
|
|
|
// ExtraSystemPrompts is a list of extra prompts to use when asking the system to answer a question. Use these for
|
|
// variety in the prompts, or passing in some useful contextually relevant information.
|
|
// All of these will be used in addition to the SystemPrompt.
|
|
ExtraSystemPrompts []string
|
|
|
|
// WolframAppID is the Wolfram Alpha App ID to use when searching Wolfram Alpha for answers. If not set, the
|
|
// wolfram function will not be available.
|
|
WolframAppID string
|
|
}
|
|
|
|
var DefaultOptions = Options{
|
|
MaxSearches: 10,
|
|
MaxTries: 5,
|
|
}
|
|
|
|
type Result struct {
|
|
Result string
|
|
Error error
|
|
}
|
|
|
|
type article struct {
|
|
URL string
|
|
Title string
|
|
Body string
|
|
}
|
|
|
|
type Response struct {
|
|
Text string
|
|
Sources []string
|
|
}
|
|
|
|
func deferClose(cl io.Closer) {
|
|
if cl != nil {
|
|
_ = cl.Close()
|
|
}
|
|
}
|
|
|
|
func (o Options) Answer(ctx context.Context, q Question) (Response, error) {
|
|
var answer Response
|
|
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
b, err := extractor.NewPlayWrightBrowser(extractor.PlayWrightBrowserOptions{
|
|
DarkMode: true,
|
|
})
|
|
defer deferClose(b)
|
|
if err != nil {
|
|
return answer, err
|
|
}
|
|
|
|
ctx = context.WithValue(ctx, "browser", b)
|
|
|
|
fnSearch := gollm.NewFunction(
|
|
"search",
|
|
"Search the web for an answer to a question. You can call this function up to "+fmt.Sprint(o.MaxSearches)+` times. The result will be JSON in the format of {"urls": ["https://example.com", "https://url2.com/"], "answer": "the answer to the question"}. If a previous call to search produced no results, do not re-search with just reworded search terms, try a different approach.`,
|
|
func(ctx *gollm.Context, args struct {
|
|
Query string `description:"search the web with this, such as: 'capital of the united states site:wikipedia.org'"`
|
|
Question string `description:"when reading the results, what question(s) are you trying to answer?"`
|
|
}) (string, error) {
|
|
q2 := q
|
|
q2.Question = args.Question
|
|
|
|
if o.MaxSearches > 0 {
|
|
o.MaxSearches = o.MaxSearches - 1
|
|
}
|
|
res, err := functionSearch2(ctx, q2, args.Query)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return res.String()
|
|
})
|
|
|
|
fnAnswer := gollm.NewFunction(
|
|
"answer",
|
|
"You definitively answer a question, if you call this it means you know the answer and do not need to search for it or use any other function to find it",
|
|
func(ctx *gollm.Context, args struct {
|
|
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.Text = args.Answer
|
|
answer.Sources = args.Sources
|
|
return args.Answer, nil
|
|
})
|
|
|
|
var fnWolfram *gollm.Function
|
|
|
|
if o.WolframAppID != "" {
|
|
fnWolfram = gollm.NewFunction(
|
|
"wolfram",
|
|
"Search Wolfram Alpha for an answer to a question.",
|
|
func(ctx *gollm.Context, args struct {
|
|
Question string `description:"the question to search for"`
|
|
}) (string, error) {
|
|
cl := wolfram.Client{
|
|
AppID: o.WolframAppID,
|
|
}
|
|
unit := wolfram.Imperial
|
|
|
|
return cl.GetShortAnswerQuery(args.Question, unit, 10)
|
|
})
|
|
}
|
|
|
|
fnCalculate := gollm.NewFunction(
|
|
"calculate",
|
|
"Calculate a mathematical expression using starlark.",
|
|
func(ctx *gollm.Context, args struct {
|
|
Expression string `description:"the mathematical expression to calculate, in starlark format"`
|
|
}) (string, error) {
|
|
fileOpts := syntax.FileOptions{}
|
|
v, err := starlark.EvalOptions(&fileOpts, &starlark.Thread{Name: "main"}, "input", args.Expression, math.Module.Members)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return v.String(), nil
|
|
})
|
|
|
|
fnGiveUp := gollm.NewFunction(
|
|
"give_up",
|
|
"Indicate that the system has given up on finding an answer.",
|
|
func(ctx *gollm.Context, args struct {
|
|
Reason string `description:"the reason the system is giving up (e.g.: 'no results found')"`
|
|
}) (string, error) {
|
|
answer.Text = "given up: " + args.Reason
|
|
return "given up", nil
|
|
})
|
|
|
|
var baseFuncs = []*gollm.Function{fnAnswer, fnCalculate, fnGiveUp}
|
|
|
|
var funcs = baseFuncs
|
|
if fnWolfram != nil {
|
|
funcs = append(funcs, fnWolfram)
|
|
}
|
|
|
|
if o.MaxSearches > 0 {
|
|
funcs = append(funcs, fnSearch)
|
|
}
|
|
|
|
var temp float32 = 0.8
|
|
|
|
var messages []gollm.Message
|
|
|
|
if o.SystemPrompt != "" {
|
|
messages = append(messages, gollm.Message{
|
|
Role: gollm.RoleSystem,
|
|
Text: o.SystemPrompt,
|
|
})
|
|
} else {
|
|
messages = append(messages, gollm.Message{
|
|
Role: gollm.RoleSystem,
|
|
Text: DefaultPrompt,
|
|
})
|
|
}
|
|
|
|
for _, prompt := range o.ExtraSystemPrompts {
|
|
messages = append(messages, gollm.Message{
|
|
Role: gollm.RoleSystem,
|
|
Text: prompt,
|
|
})
|
|
}
|
|
|
|
if q.Question != "" {
|
|
messages = append(messages, gollm.Message{
|
|
Role: gollm.RoleUser,
|
|
Text: q.Question,
|
|
})
|
|
}
|
|
|
|
req := gollm.Request{
|
|
Messages: messages,
|
|
Toolbox: gollm.NewToolBox(funcs...),
|
|
Temperature: &temp,
|
|
}
|
|
|
|
// runAnswer will run the question and try to find the answer. It will return the next request to ask, if needed
|
|
// or any error encountered.
|
|
runAnswer := func(o Options, req gollm.Request) (*gollm.Request, error) {
|
|
|
|
slog.Info("running answer", "question", q.Question, "req", req)
|
|
|
|
for _, c := range req.Conversation {
|
|
slog.Info("conversation", "conversation", c)
|
|
}
|
|
|
|
for _, m := range req.Messages {
|
|
slog.Info("message", "message", m)
|
|
}
|
|
|
|
res, err := q.Model.ChatComplete(ctx, req)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(res.Choices) == 0 {
|
|
return nil, fmt.Errorf("no response choices provided")
|
|
}
|
|
|
|
var answers []gollm.ToolCallResponse
|
|
|
|
choice := res.Choices[0]
|
|
var callsOutput = make(chan gollm.ToolCallResponse, len(choice.Calls))
|
|
fnCall := func(call gollm.ToolCall) gollm.ToolCallResponse {
|
|
str, err := req.Toolbox.Execute(gollm.NewContext(ctx, req, &choice, &call), call)
|
|
|
|
if err != nil {
|
|
return gollm.ToolCallResponse{
|
|
ID: call.ID,
|
|
Error: err,
|
|
}
|
|
}
|
|
|
|
return gollm.ToolCallResponse{
|
|
ID: call.ID,
|
|
Result: str,
|
|
}
|
|
}
|
|
|
|
for _, call := range choice.Calls {
|
|
go func(call gollm.ToolCall) {
|
|
var arg any
|
|
var err error
|
|
if o.OnNewFunction != nil {
|
|
arg, err = o.OnNewFunction(ctx, call.FunctionCall.Name, q.Question, call.FunctionCall.Arguments)
|
|
if err != nil {
|
|
callsOutput <- gollm.ToolCallResponse{
|
|
ID: call.ID,
|
|
Error: err,
|
|
}
|
|
return
|
|
}
|
|
}
|
|
callRes := fnCall(call)
|
|
|
|
if o.OnFunctionFinished != nil {
|
|
err = o.OnFunctionFinished(ctx, call.FunctionCall.Name, q.Question, call.FunctionCall.Arguments, callRes.Result, callRes.Error, arg)
|
|
if err != nil {
|
|
callsOutput <- gollm.ToolCallResponse{
|
|
ID: call.ID,
|
|
Error: err,
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
callsOutput <- callRes
|
|
}(call)
|
|
}
|
|
|
|
for i := 0; i < len(choice.Calls); i++ {
|
|
result := <-callsOutput
|
|
answers = append(answers, result)
|
|
}
|
|
|
|
close(callsOutput)
|
|
|
|
slog.Info("generating new request", "answers", answers, "choice", choice)
|
|
newReq := gollm.NewContext(ctx, req, &choice, nil).ToNewRequest(answers...)
|
|
|
|
return &newReq, nil
|
|
}
|
|
|
|
maxTries := o.MaxTries
|
|
|
|
for i := 0; i < maxTries; i++ {
|
|
// rework this run's functions incase MaxSearches etc. have changed
|
|
var funcs2 = baseFuncs
|
|
|
|
if fnWolfram != nil {
|
|
funcs2 = append(funcs2, fnWolfram)
|
|
}
|
|
|
|
if o.MaxSearches > 0 {
|
|
funcs2 = append(funcs2, fnSearch)
|
|
}
|
|
|
|
req.Toolbox = gollm.NewToolBox(funcs2...)
|
|
|
|
newReq, err := runAnswer(o, req)
|
|
|
|
if err != nil {
|
|
return answer, err
|
|
}
|
|
|
|
if newReq == nil {
|
|
break
|
|
}
|
|
|
|
if answer.Text != "" {
|
|
break
|
|
}
|
|
|
|
req = *newReq
|
|
}
|
|
|
|
return answer, nil
|
|
}
|
|
|
|
func Answer(ctx context.Context, q Question) (Response, error) {
|
|
return DefaultOptions.Answer(ctx, q)
|
|
}
|