Initial commit
This commit is contained in:
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Binaries
|
||||||
|
tell-me
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
82
config/config.go
Normal file
82
config/config.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gopkg.in/ini.v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds the application configuration
|
||||||
|
type Config struct {
|
||||||
|
LLM LLMConfig
|
||||||
|
SearXNG SearXNGConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLMConfig holds LLM API configuration
|
||||||
|
type LLMConfig struct {
|
||||||
|
APIURL string
|
||||||
|
Model string
|
||||||
|
ContextSize int
|
||||||
|
APIKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearXNGConfig holds SearXNG configuration
|
||||||
|
type SearXNGConfig struct {
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads and parses the INI configuration file from ~/.config/tell-me.ini
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
// Get home directory
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build config path
|
||||||
|
configPath := filepath.Join(homeDir, ".config", "tell-me.ini")
|
||||||
|
|
||||||
|
// Check if config file exists
|
||||||
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("config file not found at %s. Please create it from tell-me.ini.example", configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load INI file
|
||||||
|
cfg, err := ini.Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse LLM section
|
||||||
|
llmSection := cfg.Section("llm")
|
||||||
|
llmConfig := LLMConfig{
|
||||||
|
APIURL: llmSection.Key("api_url").String(),
|
||||||
|
Model: llmSection.Key("model").String(),
|
||||||
|
ContextSize: llmSection.Key("context_size").MustInt(16000),
|
||||||
|
APIKey: llmSection.Key("api_key").String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse SearXNG section
|
||||||
|
searxngSection := cfg.Section("searxng")
|
||||||
|
searxngConfig := SearXNGConfig{
|
||||||
|
URL: searxngSection.Key("url").String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if llmConfig.APIURL == "" {
|
||||||
|
return nil, fmt.Errorf("llm.api_url is required in config")
|
||||||
|
}
|
||||||
|
if llmConfig.Model == "" {
|
||||||
|
return nil, fmt.Errorf("llm.model is required in config")
|
||||||
|
}
|
||||||
|
if searxngConfig.URL == "" {
|
||||||
|
return nil, fmt.Errorf("searxng.url is required in config")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Config{
|
||||||
|
LLM: llmConfig,
|
||||||
|
SearXNG: searxngConfig,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
16
go.mod
Normal file
16
go.mod
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
module github.com/tell-me
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/JohannesKaufmann/html-to-markdown v1.6.0
|
||||||
|
github.com/sashabaranov/go-openai v1.41.2
|
||||||
|
gopkg.in/ini.v1 v1.67.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/PuerkitoBio/goquery v1.9.2 // indirect
|
||||||
|
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||||
|
github.com/jlubawy/go-boilerpipe v0.4.0 // indirect
|
||||||
|
golang.org/x/net v0.25.0 // indirect
|
||||||
|
)
|
||||||
90
go.sum
Normal file
90
go.sum
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k=
|
||||||
|
github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
|
||||||
|
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
|
||||||
|
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
||||||
|
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||||
|
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/jlubawy/go-boilerpipe v0.4.0 h1:9OWr5DBO6q+Dq9qv/2+XIIzJ0+okCE/YMZ0Ztn3daJw=
|
||||||
|
github.com/jlubawy/go-boilerpipe v0.4.0/go.mod h1:myVVbfThICMP+2GZM9weT1m+1kvA20pq/t2SG5IO3F8=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=
|
||||||
|
github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
||||||
|
github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
|
||||||
|
github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
|
||||||
|
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||||
|
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
|
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
|
||||||
|
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
|
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||||
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.0.0-20190328230028-74de082e2cca/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
|
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||||
|
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||||
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
|
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
213
llm/client.go
Normal file
213
llm/client.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sashabaranov/go-openai"
|
||||||
|
"github.com/tell-me/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client wraps the OpenAI client for LLM interactions
|
||||||
|
type Client struct {
|
||||||
|
client *openai.Client
|
||||||
|
model string
|
||||||
|
contextSize int
|
||||||
|
searxngURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new LLM client
|
||||||
|
func NewClient(apiURL, apiKey, model string, contextSize int, searxngURL string) *Client {
|
||||||
|
config := openai.DefaultConfig(apiKey)
|
||||||
|
config.BaseURL = apiURL
|
||||||
|
|
||||||
|
client := openai.NewClientWithConfig(config)
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
client: client,
|
||||||
|
model: model,
|
||||||
|
contextSize: contextSize,
|
||||||
|
searxngURL: searxngURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSystemPrompt returns the system prompt that enforces search-first behavior
|
||||||
|
func GetSystemPrompt() string {
|
||||||
|
currentDate := time.Now().Format("2006-01-02")
|
||||||
|
|
||||||
|
return fmt.Sprintf(`You are a helpful AI research assistant with access to web search and article fetching capabilities.
|
||||||
|
|
||||||
|
RESEARCH WORKFLOW - MANDATORY STEPS:
|
||||||
|
1. For questions requiring current information, facts, or knowledge beyond your training data:
|
||||||
|
- Perform MULTIPLE searches (typically 2-3) with DIFFERENT query angles to gather comprehensive information
|
||||||
|
- Vary your search terms to capture different perspectives and sources
|
||||||
|
|
||||||
|
2. After completing ALL searches, analyze the combined results:
|
||||||
|
- Review ALL search results from your multiple searches together
|
||||||
|
- Identify the 3-5 MOST relevant and authoritative URLs across ALL searches
|
||||||
|
- Prioritize: official sources, reputable news sites, technical documentation, expert reviews
|
||||||
|
- Look for sources that complement each other (e.g., official specs + expert analysis + user reviews)
|
||||||
|
|
||||||
|
3. Fetch the selected articles:
|
||||||
|
- Use fetch_articles with the 3-5 best URLs you identified from ALL your searches
|
||||||
|
- Read all fetched content thoroughly before formulating your answer
|
||||||
|
- Synthesize information from multiple sources for a comprehensive response
|
||||||
|
|
||||||
|
HANDLING USER CORRECTIONS - CRITICAL:
|
||||||
|
When a user indicates your answer is incorrect, incomplete, or needs clarification:
|
||||||
|
1. NEVER argue or defend your previous answer
|
||||||
|
2. IMMEDIATELY acknowledge the correction: "Let me search for more accurate information"
|
||||||
|
3. Perform NEW searches with DIFFERENT queries based on the user's feedback
|
||||||
|
4. Fetch NEW sources that address the specific correction or clarification needed
|
||||||
|
5. Provide an updated answer based on the new research
|
||||||
|
6. If the user provides specific information, incorporate it and verify with additional searches
|
||||||
|
|
||||||
|
Remember: The user may have more current or specific knowledge. Your role is to research and verify, not to argue.
|
||||||
|
|
||||||
|
OUTPUT FORMATTING RULES:
|
||||||
|
- NEVER include source URLs or citations in your response
|
||||||
|
- DO NOT use Markdown formatting (no **, ##, -, *, [], etc.)
|
||||||
|
- Write in plain text only - use natural language without any special formatting
|
||||||
|
- For emphasis, use CAPITAL LETTERS instead of bold or italics
|
||||||
|
- For lists, use simple numbered lines (1., 2., 3.) or write as flowing paragraphs
|
||||||
|
- Keep output clean and readable for terminal display
|
||||||
|
|
||||||
|
Available tools:
|
||||||
|
- web_search: Search the internet (can be used multiple times with different queries)
|
||||||
|
- fetch_articles: Fetch and read content from 1-5 URLs at once
|
||||||
|
|
||||||
|
CURRENT DATE: %s`, currentDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTools returns the tool definitions for the LLM
|
||||||
|
func GetTools() []openai.Tool {
|
||||||
|
return []openai.Tool{
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: &openai.FunctionDefinition{
|
||||||
|
Name: "web_search",
|
||||||
|
Description: "Search the internet for information using SearXNG. Use this tool to find current information, facts, news, or any knowledge you need to answer the user's question.",
|
||||||
|
Parameters: json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The search query to find relevant information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["query"]
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: &openai.FunctionDefinition{
|
||||||
|
Name: "fetch_articles",
|
||||||
|
Description: "Fetch and read content from 1-5 articles at once. Provide both titles and URLs from search results. The HTML will be converted to clean text format and combined. Use this after searching to read the most relevant pages together.",
|
||||||
|
Parameters: json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"articles": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The title of the article from search results"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The URL to fetch (must start with http:// or https://)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["title", "url"]
|
||||||
|
},
|
||||||
|
"description": "Array of articles with titles and URLs (1-5 recommended, max 5)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["articles"]
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat sends a message and handles tool calls
|
||||||
|
func (c *Client) Chat(ctx context.Context, messages []openai.ChatCompletionMessage) (string, []openai.ChatCompletionMessage, error) {
|
||||||
|
req := openai.ChatCompletionRequest{
|
||||||
|
Model: c.model,
|
||||||
|
Messages: messages,
|
||||||
|
Tools: GetTools(),
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.client.CreateChatCompletion(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return "", messages, fmt.Errorf("chat completion failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
choice := resp.Choices[0]
|
||||||
|
messages = append(messages, choice.Message)
|
||||||
|
|
||||||
|
// Handle tool calls
|
||||||
|
if len(choice.Message.ToolCalls) > 0 {
|
||||||
|
for _, toolCall := range choice.Message.ToolCalls {
|
||||||
|
var result string
|
||||||
|
|
||||||
|
switch toolCall.Function.Name {
|
||||||
|
case "web_search":
|
||||||
|
var args struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
|
||||||
|
result = fmt.Sprintf("Error parsing arguments: %v", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Searching: %s\n", args.Query)
|
||||||
|
result, err = tools.WebSearch(c.searxngURL, args.Query)
|
||||||
|
if err != nil {
|
||||||
|
result = fmt.Sprintf("Search error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "fetch_articles":
|
||||||
|
var args struct {
|
||||||
|
Articles []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"articles"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
|
||||||
|
result = fmt.Sprintf("Error parsing arguments: %v", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Reading %d articles:\n", len(args.Articles))
|
||||||
|
urls := make([]string, len(args.Articles))
|
||||||
|
for i, article := range args.Articles {
|
||||||
|
fmt.Printf(" - %s\n", article.Title)
|
||||||
|
urls[i] = article.URL
|
||||||
|
}
|
||||||
|
result, err = tools.FetchArticles(urls)
|
||||||
|
if err != nil {
|
||||||
|
result = fmt.Sprintf("Fetch error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
result = fmt.Sprintf("Unknown tool: %s", toolCall.Function.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tool response to messages
|
||||||
|
messages = append(messages, openai.ChatCompletionMessage{
|
||||||
|
Role: openai.ChatMessageRoleTool,
|
||||||
|
Content: result,
|
||||||
|
ToolCallID: toolCall.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make another call with tool results
|
||||||
|
return c.Chat(ctx, messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
return choice.Message.Content, messages, nil
|
||||||
|
}
|
||||||
133
main.go
Normal file
133
main.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/sashabaranov/go-openai"
|
||||||
|
"github.com/tell-me/config"
|
||||||
|
"github.com/tell-me/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load configuration
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error loading configuration: %v\n", err)
|
||||||
|
fmt.Fprintf(os.Stderr, "Please create ~/.config/tell-me.ini from tell-me.ini.example\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create LLM client
|
||||||
|
client := llm.NewClient(
|
||||||
|
cfg.LLM.APIURL,
|
||||||
|
cfg.LLM.APIKey,
|
||||||
|
cfg.LLM.Model,
|
||||||
|
cfg.LLM.ContextSize,
|
||||||
|
cfg.SearXNG.URL,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initialize conversation with system prompt
|
||||||
|
messages := []openai.ChatCompletionMessage{
|
||||||
|
{
|
||||||
|
Role: openai.ChatMessageRoleSystem,
|
||||||
|
Content: llm.GetSystemPrompt(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Check if arguments are provided (non-interactive mode)
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
query := strings.Join(os.Args[1:], " ")
|
||||||
|
processQuery(ctx, client, messages, query)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup signal handling for Ctrl-C
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-sigChan
|
||||||
|
fmt.Println("\n\nGoodbye!")
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Print welcome message
|
||||||
|
fmt.Println("╔════════════════════════════════════════════════════════════════╗")
|
||||||
|
fmt.Println("║ Tell-Me CLI ║")
|
||||||
|
fmt.Println("║ AI-powered search with local LLM support ║")
|
||||||
|
fmt.Println("╚════════════════════════════════════════════════════════════════╝")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("Using model: %s\n", cfg.LLM.Model)
|
||||||
|
fmt.Printf("SearXNG: %s\n", cfg.SearXNG.URL)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Type your questions below. Type 'exit' or 'quit' to exit, or press Ctrl-C.")
|
||||||
|
fmt.Println("────────────────────────────────────────────────────────────────")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Create scanner for user input
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Prompt for user input
|
||||||
|
fmt.Print("❯ ")
|
||||||
|
if !scanner.Scan() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
userInput := strings.TrimSpace(scanner.Text())
|
||||||
|
|
||||||
|
// Check for exit commands
|
||||||
|
if userInput == "exit" || userInput == "quit" {
|
||||||
|
fmt.Println("\nGoodbye!")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip empty input
|
||||||
|
if userInput == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the query
|
||||||
|
messages = processQuery(ctx, client, messages, userInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processQuery handles a single query and returns updated messages
|
||||||
|
func processQuery(ctx context.Context, client *llm.Client, messages []openai.ChatCompletionMessage, userInput string) []openai.ChatCompletionMessage {
|
||||||
|
// Add user message to conversation
|
||||||
|
messages = append(messages, openai.ChatCompletionMessage{
|
||||||
|
Role: openai.ChatMessageRoleUser,
|
||||||
|
Content: userInput,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get response from LLM
|
||||||
|
fmt.Println()
|
||||||
|
response, updatedMessages, err := client.Chat(ctx, messages)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "\nError: %v\n\n", err)
|
||||||
|
// Remove the failed user message
|
||||||
|
return messages[:len(messages)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update messages with the full conversation history
|
||||||
|
messages = updatedMessages
|
||||||
|
|
||||||
|
// Print response with empty line before it
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(response)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
13
tell-me.ini.example
Normal file
13
tell-me.ini.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[llm]
|
||||||
|
# OpenAI-compatible API endpoint (e.g., Ollama, LM Studio)
|
||||||
|
api_url = http://localhost:11434/v1
|
||||||
|
# Model name to use
|
||||||
|
model = llama3.2
|
||||||
|
# Context size for the model
|
||||||
|
context_size = 16000
|
||||||
|
# API key (leave empty if not required)
|
||||||
|
api_key =
|
||||||
|
|
||||||
|
[searxng]
|
||||||
|
# SearXNG instance URL
|
||||||
|
url = http://localhost:8080
|
||||||
172
tools/fetch.go
Normal file
172
tools/fetch.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
md "github.com/JohannesKaufmann/html-to-markdown"
|
||||||
|
"github.com/jlubawy/go-boilerpipe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fetchURL fetches a URL, extracts main content using boilerpipe, and returns clean text
|
||||||
|
func fetchURL(targetURL string) (string, error) {
|
||||||
|
// Validate URL
|
||||||
|
if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") {
|
||||||
|
return "", fmt.Errorf("invalid URL: must start with http:// or https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP client with timeout
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make the request
|
||||||
|
resp, err := client.Get(targetURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to fetch URL: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("request failed with status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the response body
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract article content using boilerpipe
|
||||||
|
doc, err := boilerpipe.ParseDocument(bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
// If boilerpipe fails, fall back to markdown conversion
|
||||||
|
return fallbackToMarkdown(targetURL, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use ArticlePipeline for best results
|
||||||
|
boilerpipe.ArticlePipeline.Process(doc)
|
||||||
|
|
||||||
|
// Get the extracted content
|
||||||
|
content := doc.Content()
|
||||||
|
|
||||||
|
// If content is too short or empty, fall back to markdown
|
||||||
|
if len(strings.TrimSpace(content)) < 100 {
|
||||||
|
return fallbackToMarkdown(targetURL, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build result with title if available
|
||||||
|
var result strings.Builder
|
||||||
|
result.WriteString(fmt.Sprintf("# Content from: %s\n\n", targetURL))
|
||||||
|
|
||||||
|
if doc.Title != "" {
|
||||||
|
result.WriteString(fmt.Sprintf("## %s\n\n", doc.Title))
|
||||||
|
}
|
||||||
|
|
||||||
|
result.WriteString(content)
|
||||||
|
|
||||||
|
return result.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallbackToMarkdown converts HTML to markdown when boilerpipe extraction fails
|
||||||
|
func fallbackToMarkdown(targetURL, htmlContent string) (string, error) {
|
||||||
|
converter := md.NewConverter("", true, nil)
|
||||||
|
markdown, err := converter.ConvertString(htmlContent)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to convert HTML to Markdown: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up the markdown
|
||||||
|
markdown = cleanMarkdown(markdown)
|
||||||
|
|
||||||
|
// Add URL header
|
||||||
|
result := fmt.Sprintf("# Content from: %s\n\n%s", targetURL, markdown)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanMarkdown removes excessive whitespace and limits content length
|
||||||
|
func cleanMarkdown(content string) string {
|
||||||
|
// Remove excessive blank lines (more than 2 consecutive)
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
var cleaned []string
|
||||||
|
blankCount := 0
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
|
||||||
|
if line == "" {
|
||||||
|
blankCount++
|
||||||
|
if blankCount <= 2 {
|
||||||
|
cleaned = append(cleaned, "")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
blankCount = 0
|
||||||
|
cleaned = append(cleaned, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content = strings.Join(cleaned, "\n")
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
|
||||||
|
// Limit content length to approximately 15k tokens (roughly 60k characters)
|
||||||
|
maxChars := 60000
|
||||||
|
if len(content) > maxChars {
|
||||||
|
content = content[:maxChars] + "\n\n[Content truncated due to length...]"
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchArticles fetches multiple URLs concurrently and combines their content
|
||||||
|
func FetchArticles(urls []string) (string, error) {
|
||||||
|
if len(urls) == 0 {
|
||||||
|
return "", fmt.Errorf("no URLs provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit to 5 URLs to avoid overwhelming the system
|
||||||
|
if len(urls) > 5 {
|
||||||
|
urls = urls[:5]
|
||||||
|
}
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
url string
|
||||||
|
content string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch URLs concurrently
|
||||||
|
results := make(chan result, len(urls))
|
||||||
|
for _, url := range urls {
|
||||||
|
go func(u string) {
|
||||||
|
content, err := fetchURL(u)
|
||||||
|
results <- result{url: u, content: content, err: err}
|
||||||
|
}(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect results
|
||||||
|
var combined strings.Builder
|
||||||
|
combined.WriteString("# Combined Content from Multiple Sources\n\n")
|
||||||
|
|
||||||
|
successCount := 0
|
||||||
|
for i := 0; i < len(urls); i++ {
|
||||||
|
res := <-results
|
||||||
|
if res.err != nil {
|
||||||
|
combined.WriteString(fmt.Sprintf("## Failed to fetch: %s\nError: %v\n\n", res.url, res.err))
|
||||||
|
} else {
|
||||||
|
combined.WriteString(res.content)
|
||||||
|
combined.WriteString("\n\n---\n\n")
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if successCount == 0 {
|
||||||
|
return "", fmt.Errorf("failed to fetch any URLs")
|
||||||
|
}
|
||||||
|
|
||||||
|
return combined.String(), nil
|
||||||
|
}
|
||||||
81
tools/search.go
Normal file
81
tools/search.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SearchResult represents a single search result
|
||||||
|
type SearchResult struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchResponse represents the SearXNG API response
|
||||||
|
type SearchResponse struct {
|
||||||
|
Results []SearchResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSearch performs a web search using SearXNG
|
||||||
|
func WebSearch(searxngURL, query string) (string, error) {
|
||||||
|
// Build the search URL
|
||||||
|
searchURL := fmt.Sprintf("%s/search", strings.TrimSuffix(searxngURL, "/"))
|
||||||
|
|
||||||
|
// Create URL with query parameters
|
||||||
|
params := url.Values{}
|
||||||
|
params.Add("q", query)
|
||||||
|
params.Add("format", "json")
|
||||||
|
params.Add("language", "en")
|
||||||
|
|
||||||
|
fullURL := fmt.Sprintf("%s?%s", searchURL, params.Encode())
|
||||||
|
|
||||||
|
// Make the request
|
||||||
|
resp, err := http.Get(fullURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to perform search: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", fmt.Errorf("search request failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the response
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp SearchResponse
|
||||||
|
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse search results: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format results as text
|
||||||
|
if len(searchResp.Results) == 0 {
|
||||||
|
return "No results found.", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var results strings.Builder
|
||||||
|
results.WriteString(fmt.Sprintf("Found %d results:\n\n", len(searchResp.Results)))
|
||||||
|
|
||||||
|
for i, result := range searchResp.Results {
|
||||||
|
if i >= 10 { // Limit to top 10 results
|
||||||
|
break
|
||||||
|
}
|
||||||
|
results.WriteString(fmt.Sprintf("%d. %s\n", i+1, result.Title))
|
||||||
|
results.WriteString(fmt.Sprintf(" URL: %s\n", result.URL))
|
||||||
|
if result.Content != "" {
|
||||||
|
results.WriteString(fmt.Sprintf(" %s\n", result.Content))
|
||||||
|
}
|
||||||
|
results.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.String(), nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user