initial commit
This commit is contained in:
commit
98fa840f87
0
.gitignore
vendored
Normal file
0
.gitignore
vendored
Normal file
36
cmd/answer.go
Normal file
36
cmd/answer.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/urfave/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Usage: go run cmd/answer.go question...
|
||||||
|
// - flags:
|
||||||
|
// --model=[model string such as openai/gpt-4o, anthropic/claude..., google/gemini-1.5. Default: openai/gpt-4o]
|
||||||
|
// --search-provider=[search provider string such as google, duckduckgo. Default: google]
|
||||||
|
|
||||||
|
var app = cli.App{
|
||||||
|
Name: "answer",
|
||||||
|
Usage: "has an llm search the web for you to answer a question",
|
||||||
|
Version: "0.1",
|
||||||
|
Description: "",
|
||||||
|
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
// if there is no question to answer, print usage
|
||||||
|
if c.NArg() == 0 {
|
||||||
|
return cli.ShowAppHelp(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the question
|
||||||
|
fmt.Println("Head: ", c.Args().First())
|
||||||
|
fmt.Println("Tail: ", c.Args().Tail())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Run()
|
||||||
|
|
||||||
|
}
|
60
go.mod
Normal file
60
go.mod
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
module answer
|
||||||
|
|
||||||
|
go 1.23.2
|
||||||
|
|
||||||
|
replace gitea.stevedudenhoeffer.com/steve/go-llm => ../go-llm
|
||||||
|
|
||||||
|
require (
|
||||||
|
cloud.google.com/go v0.115.0 // indirect
|
||||||
|
cloud.google.com/go/ai v0.8.0 // indirect
|
||||||
|
cloud.google.com/go/auth v0.6.0 // indirect
|
||||||
|
cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
|
||||||
|
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||||
|
cloud.google.com/go/longrunning v0.5.7 // indirect
|
||||||
|
gitea.stevedudenhoeffer.com/steve/go-llm v0.0.0-20241031152103-f603010dee49 // indirect
|
||||||
|
github.com/PuerkitoBio/goquery v1.8.1 // indirect
|
||||||
|
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||||
|
github.com/antchfx/htmlquery v1.3.0 // indirect
|
||||||
|
github.com/antchfx/xmlquery v1.3.15 // indirect
|
||||||
|
github.com/antchfx/xpath v1.2.4 // indirect
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.1 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/gobwas/glob v0.2.3 // indirect
|
||||||
|
github.com/gocolly/colly/v2 v2.1.0 // indirect
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
|
github.com/google/generative-ai-go v0.18.0 // indirect
|
||||||
|
github.com/google/s2a-go v0.1.7 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
|
||||||
|
github.com/googleapis/gax-go/v2 v2.12.5 // indirect
|
||||||
|
github.com/kennygrant/sanitize v1.2.4 // indirect
|
||||||
|
github.com/liushuangls/go-anthropic/v2 v2.8.0 // indirect
|
||||||
|
github.com/rocketlaunchr/google-search v1.1.6 // indirect
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
|
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
|
||||||
|
github.com/sashabaranov/go-openai v1.31.0 // indirect
|
||||||
|
github.com/temoto/robotstxt v1.1.2 // indirect
|
||||||
|
github.com/urfave/cli v1.22.16 // indirect
|
||||||
|
go.opencensus.io v0.24.0 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.26.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.26.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.26.0 // indirect
|
||||||
|
golang.org/x/crypto v0.24.0 // indirect
|
||||||
|
golang.org/x/net v0.26.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.21.0 // indirect
|
||||||
|
golang.org/x/sync v0.7.0 // indirect
|
||||||
|
golang.org/x/sys v0.21.0 // indirect
|
||||||
|
golang.org/x/text v0.16.0 // indirect
|
||||||
|
golang.org/x/time v0.5.0 // indirect
|
||||||
|
google.golang.org/api v0.186.0 // indirect
|
||||||
|
google.golang.org/appengine v1.6.8 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 // indirect
|
||||||
|
google.golang.org/grpc v1.64.1 // indirect
|
||||||
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
|
)
|
411
pkg/answer/answer.go
Normal file
411
pkg/answer/answer.go
Normal file
@ -0,0 +1,411 @@
|
|||||||
|
package answer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"answer/pkg/cache"
|
||||||
|
"answer/pkg/search"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
go_llm "gitea.stevedudenhoeffer.com/steve/go-llm"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 go_llm.ChatCompletion
|
||||||
|
|
||||||
|
Search search.Search
|
||||||
|
|
||||||
|
Cache cache.Cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answers is a list of answers to a question
|
||||||
|
type Answers []string
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// MaxThinks is the maximum number of times to think about a question. A "Think" is different than a search in that
|
||||||
|
// the LLM just breaks the question down into smaller parts and tries to answer them. This is useful for complex
|
||||||
|
// questions that are hard to answer since LLMs are better at answering smaller questions.
|
||||||
|
MaxThinks 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
|
||||||
|
}
|
||||||
|
|
||||||
|
var DefaultOptions = Options{
|
||||||
|
MaxSearches: 5,
|
||||||
|
MaxThinks: 10,
|
||||||
|
MaxTries: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Result string
|
||||||
|
Error error
|
||||||
|
}
|
||||||
|
|
||||||
|
func fanExecuteToolCalls(ctx context.Context, toolBox *go_llm.ToolBox, calls []go_llm.ToolCall) []Result {
|
||||||
|
var results []Result
|
||||||
|
var resultsOutput = make(chan Result, len(calls))
|
||||||
|
|
||||||
|
fnCall := func(call go_llm.ToolCall) Result {
|
||||||
|
str, err := toolBox.Execute(ctx, call)
|
||||||
|
if err != nil {
|
||||||
|
return Result{
|
||||||
|
Error: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result{
|
||||||
|
Result: str,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, call := range calls {
|
||||||
|
go func(call go_llm.ToolCall) {
|
||||||
|
resultsOutput <- fnCall(call)
|
||||||
|
}(call)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(calls); i++ {
|
||||||
|
result := <-resultsOutput
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(resultsOutput)
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
type article struct {
|
||||||
|
URL string
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractArticle(ctx context.Context, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("error creating request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.cl.Do(req)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("error getting response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
err := Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("error closing body", "error", err)
|
||||||
|
}
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return "", fmt.Errorf("bad response: %d: %s", resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error reading body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g := goose.New()
|
||||||
|
article, err := g.ExtractFromRawHTML(string(b), target)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error extracting article: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return article.CleanedText, nil
|
||||||
|
panic("not implemented")
|
||||||
|
return article{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func doesTextAnswerQuestion(ctx context.Context, q Question, text string) (string, error) {
|
||||||
|
fnAnswer := go_llm.NewFunction(
|
||||||
|
"answer",
|
||||||
|
"The answer from the given text that answers the question.",
|
||||||
|
func(ctx context.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 := go_llm.NewFunction(
|
||||||
|
"no_answer",
|
||||||
|
"Indicate that the text does not answer the question.",
|
||||||
|
func(ctx context.Context, args struct{}) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
})
|
||||||
|
|
||||||
|
req := go_llm.Request{
|
||||||
|
Messages: []go_llm.Message{
|
||||||
|
{
|
||||||
|
Role: go_llm.RoleSystem,
|
||||||
|
Text: "Evaluate the given text to see if it answers the question from the user. The text is as follows:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: go_llm.RoleSystem,
|
||||||
|
Text: text,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: go_llm.RoleUser,
|
||||||
|
Text: q.Question,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Toolbox: go_llm.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 functionSearch(ctx context.Context, q Question, searchTerm string) (string, error) {
|
||||||
|
res, err := q.Search.Search(ctx, searchTerm)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(res) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// first pass try to see if any provide the result without needing archive
|
||||||
|
for _, r := range res {
|
||||||
|
trimmed := strings.TrimSpace(r.URL)
|
||||||
|
if trimmed == "" {
|
||||||
|
|
||||||
|
}
|
||||||
|
u, err := url.Parse(trimmed)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err := extractArticle(ctx, u)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.Title != "" && a.Body != "" {
|
||||||
|
answer, err := doesTextAnswerQuestion(ctx, q, a.Body)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("error checking if text answers question", "question", q.Question, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if answer != "" {
|
||||||
|
return answer, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func functionThink(ctx context.Context, q Question) (string, error) {
|
||||||
|
fnAnswer := go_llm.NewFunction(
|
||||||
|
"answer",
|
||||||
|
"Answer the question.",
|
||||||
|
func(ctx context.Context, args struct {
|
||||||
|
Answer string `description:"the answer to the question"`
|
||||||
|
}) (string, error) {
|
||||||
|
return args.Answer, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
var temp float32 = 0.8
|
||||||
|
req := go_llm.Request{
|
||||||
|
Messages: []go_llm.Message{
|
||||||
|
{
|
||||||
|
Role: go_llm.RoleSystem,
|
||||||
|
Text: "Answer the given question as accurately and concisely as possible using the answer function.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: go_llm.RoleUser,
|
||||||
|
Text: q.Question,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Toolbox: go_llm.NewToolBox(fnAnswer),
|
||||||
|
Temperature: &temp,
|
||||||
|
}
|
||||||
|
|
||||||
|
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) (Answers, error) {
|
||||||
|
fnSearch := go_llm.NewFunction(
|
||||||
|
"search",
|
||||||
|
"Search the web for an answer to a question. You can call this function up to "+fmt.Sprint(o.MaxSearches)+" times.",
|
||||||
|
func(ctx context.Context, args struct {
|
||||||
|
SearchQuery string `description:"what to search the web for for this question"`
|
||||||
|
Question string `description:"what question(s) you are trying to answer with this search"`
|
||||||
|
}) (string, error) {
|
||||||
|
q2 := q
|
||||||
|
q2.Question = args.Question
|
||||||
|
|
||||||
|
return functionSearch(ctx, q2, args.SearchQuery)
|
||||||
|
})
|
||||||
|
|
||||||
|
fnThink := go_llm.NewFunction(
|
||||||
|
"think",
|
||||||
|
"Think about a question. This is useful for breaking down complex questions into smaller parts that are easier to answer.",
|
||||||
|
func(ctx context.Context, args struct {
|
||||||
|
Question string `description:"the question to think about"`
|
||||||
|
}) (string, error) {
|
||||||
|
q2 := q
|
||||||
|
q2.Question = args.Question
|
||||||
|
|
||||||
|
return functionThink(ctx, q2)
|
||||||
|
})
|
||||||
|
|
||||||
|
fnAnswer := go_llm.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 context.Context, args struct {
|
||||||
|
Answer string `description:"the answer to the question"`
|
||||||
|
}) (string, error) {
|
||||||
|
return args.Answer, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
var funcs = []*go_llm.Function{fnAnswer}
|
||||||
|
|
||||||
|
if o.MaxSearches > 0 {
|
||||||
|
funcs = append(funcs, fnSearch)
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.MaxThinks > 0 {
|
||||||
|
funcs = append(funcs, fnThink)
|
||||||
|
}
|
||||||
|
|
||||||
|
var temp float32 = 0.8
|
||||||
|
|
||||||
|
req := go_llm.Request{
|
||||||
|
Messages: []go_llm.Message{
|
||||||
|
{
|
||||||
|
Role: go_llm.RoleSystem,
|
||||||
|
Text: "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.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: go_llm.RoleUser,
|
||||||
|
Text: q.Question,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Toolbox: go_llm.NewToolBox(funcs...),
|
||||||
|
Temperature: &temp,
|
||||||
|
}
|
||||||
|
|
||||||
|
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) > o.MaxSearches {
|
||||||
|
res.Choices = res.Choices[:o.MaxSearches]
|
||||||
|
}
|
||||||
|
|
||||||
|
var answers []QuestionAnswer
|
||||||
|
choicesOutput := make(chan QuestionAnswer, len(res.Choices))
|
||||||
|
|
||||||
|
for _, choice := range res.Choices {
|
||||||
|
fnChoice := func(choice go_llm.ResponseChoice) QuestionAnswer {
|
||||||
|
var calls []CallResult
|
||||||
|
var callsOutput = make(chan CallResult, len(choice.Calls))
|
||||||
|
fnCall := func(call go_llm.ToolCall) CallResult {
|
||||||
|
str, err := req.Toolbox.Execute(ctx, call)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return CallResult{
|
||||||
|
Error: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CallResult{
|
||||||
|
Result: str,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, call := range choice.Calls {
|
||||||
|
go func(call go_llm.ToolCall) {
|
||||||
|
callsOutput <- fnCall(call)
|
||||||
|
}(call)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(choice.Calls); i++ {
|
||||||
|
result := <-callsOutput
|
||||||
|
calls = append(calls, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(callsOutput)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func Answer(ctx context.Context, q Question) (Answers, error) {
|
||||||
|
return DefaultOptions.Answer(ctx, q)
|
||||||
|
}
|
15
pkg/cache/cache.go
vendored
Normal file
15
pkg/cache/cache.go
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import "io"
|
||||||
|
|
||||||
|
type Cache interface {
|
||||||
|
Get(key string, writer io.Writer) error
|
||||||
|
GetString(key string) (string, error)
|
||||||
|
GetJSON(key string, value any) error
|
||||||
|
|
||||||
|
Set(key string, value io.Reader) error
|
||||||
|
SetJSON(key string, value any) error
|
||||||
|
SetString(key string, value string) error
|
||||||
|
|
||||||
|
Delete(key string) error
|
||||||
|
}
|
160
pkg/cache/directory.go
vendored
Normal file
160
pkg/cache/directory.go
vendored
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Directory struct {
|
||||||
|
BaseFolder string
|
||||||
|
MaxLife time.Duration
|
||||||
|
|
||||||
|
lock sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Cache = &Directory{}
|
||||||
|
|
||||||
|
func (d *Directory) GetPath(key string) string {
|
||||||
|
return filepath.Join(d.BaseFolder, key+".json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Directory) Cleanup(_ context.Context) error {
|
||||||
|
d.lock.Lock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
d.lock.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// go through the BaseFilder looking for any files that are older than MaxLife
|
||||||
|
return filepath.Walk(d.BaseFolder, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignore directories
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// only files that end in .json
|
||||||
|
if filepath.Ext(path) != ".json" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the openFile is older than MaxLife, delete it
|
||||||
|
if time.Since(info.ModTime()) > d.MaxLife {
|
||||||
|
return os.Remove(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoCleanupRoutine will continually loop and cleanup the directory, until the context is cancelled or an error occurs
|
||||||
|
// returns nil on context cancellation, or an error if one occurs during cleanup
|
||||||
|
func (d *Directory) AutoCleanupRoutine(ctx context.Context) error {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case <-time.After(d.MaxLife):
|
||||||
|
err := d.Cleanup(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Directory) openFile(key string) (*os.File, error) {
|
||||||
|
path := d.GetPath(key)
|
||||||
|
|
||||||
|
return os.Open(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Directory) Set(key string, value io.Reader) error {
|
||||||
|
d.lock.Lock()
|
||||||
|
defer d.lock.Unlock()
|
||||||
|
|
||||||
|
fp, err := d.openFile(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func(fp *os.File) {
|
||||||
|
_ = fp.Close()
|
||||||
|
}(fp)
|
||||||
|
|
||||||
|
_, err = io.Copy(fp, value)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Directory) SetJSON(key string, value any) error {
|
||||||
|
d.lock.Lock()
|
||||||
|
defer d.lock.Unlock()
|
||||||
|
|
||||||
|
fp, err := d.openFile(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func(fp *os.File) {
|
||||||
|
_ = fp.Close()
|
||||||
|
}(fp)
|
||||||
|
|
||||||
|
return json.NewEncoder(fp).Encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Directory) SetString(key, value string) error {
|
||||||
|
return d.Set(key, bytes.NewReader([]byte(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Directory) Get(key string, w io.Writer) error {
|
||||||
|
d.lock.Lock()
|
||||||
|
defer d.lock.Unlock()
|
||||||
|
|
||||||
|
fp, err := d.openFile(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer fp.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(w, fp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Directory) GetJSON(key string, v any) error {
|
||||||
|
d.lock.Lock()
|
||||||
|
defer d.lock.Unlock()
|
||||||
|
|
||||||
|
fp, err := d.openFile(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer fp.Close()
|
||||||
|
|
||||||
|
return json.NewEncoder(fp).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Directory) GetString(key string) (string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := d.Get(key, &buf)
|
||||||
|
return buf.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Directory) Delete(key string) error {
|
||||||
|
d.lock.Lock()
|
||||||
|
defer d.lock.Unlock()
|
||||||
|
|
||||||
|
return os.Remove(d.GetPath(key))
|
||||||
|
}
|
44
pkg/search/google.go
Normal file
44
pkg/search/google.go
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
googlesearch "github.com/rocketlaunchr/google-search"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Google struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Search = Google{}
|
||||||
|
|
||||||
|
func (Google) Search(ctx context.Context, search string) ([]Result, error) {
|
||||||
|
res, err := googlesearch.Search(ctx, search, googlesearch.SearchOptions{
|
||||||
|
CountryCode: "",
|
||||||
|
LanguageCode: "",
|
||||||
|
Limit: 0,
|
||||||
|
Start: 0,
|
||||||
|
UserAgent: "",
|
||||||
|
OverLimit: false,
|
||||||
|
ProxyAddr: "",
|
||||||
|
FollowNextPage: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []Result
|
||||||
|
|
||||||
|
// just in case, sort the res by rank, as the api does not mention it is sorted
|
||||||
|
sort.Slice(res, func(i, j int) bool {
|
||||||
|
return res[i].Rank < res[j].Rank
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, r := range res {
|
||||||
|
results = append(results, Result{
|
||||||
|
Title: r.Title,
|
||||||
|
URL: r.URL,
|
||||||
|
Description: r.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
13
pkg/search/search.go
Normal file
13
pkg/search/search.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Title string
|
||||||
|
URL string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Search interface {
|
||||||
|
Search(ctx context.Context, query string) ([]Result, error)
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user