diff --git a/cmd/ghweb/main.go b/cmd/ghweb/main.go new file mode 100644 index 0000000..5b53fc8 --- /dev/null +++ b/cmd/ghweb/main.go @@ -0,0 +1,103 @@ +package main + +import ( + "crypto/tls" + "encoding/xml" + "flag" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + + "github.com/BorisTyshkevich/github-semantic-search/internal/click" + "github.com/BorisTyshkevich/github-semantic-search/internal/embed" + "github.com/BorisTyshkevich/github-semantic-search/internal/server" +) + +var searchFn = click.Search + +func main() { + var ( + listen = flag.String("listen", ":8080", "http listen address") + connName = flag.String("connection", "", "name of connection from ~/.clickhouse-client/config.xml") + host = flag.String("host", "github.demo.altinity.cloud", "ClickHouse host") + port = flag.Int("port", 9440, "ClickHouse port") + user = flag.String("user", "demo", "ClickHouse user") + pass = flag.String("pass", "demo", "ClickHouse password") + secure = flag.Bool("secure", true, "use TLS") + chTab = flag.String("table", "clickcomments", "ClickHouse table") + debug = flag.Bool("debug", false, "debug mode") + ) + flag.Parse() + + var ( + clickHost string + clickUser string + clickPass string + clickDB string + useSecure bool + ) + if *connName != "" { + var err error + clickHost, clickUser, clickPass, clickDB, useSecure, err = loadConnection(*connName) + if err != nil { + log.Fatalf("load connection: %v", err) + } + } else { + clickHost = fmt.Sprintf("%s:%d", *host, *port) + clickUser = *user + clickPass = *pass + clickDB = "default" + useSecure = *secure + } + tlsCfg := &tls.Config{InsecureSkipVerify: true} + if !useSecure { + tlsCfg = nil + } + + emb := embed.OpenAI{} + handler := server.Handler(emb, click.Options{ + Host: clickHost, + User: clickUser, + Password: clickPass, + DB: clickDB, + Table: *chTab, + TLS: tlsCfg, + }, *debug) + + log.Printf("listening on %s", *listen) + log.Fatal(http.ListenAndServe(*listen, handler)) +} + +type clickhouseConfig struct { + Connections struct { + Connections []struct { + Name string `xml:"name"` + Hostname string `xml:"hostname"` + Port int `xml:"port"` + User string `xml:"user"` + Password string `xml:"password"` + Database string `xml:"database"` + Secure int `xml:"secure"` + } `xml:"connection"` + } `xml:"connections_credentials"` +} + +func loadConnection(name string) (host, user, pass, db string, secure bool, err error) { + path := filepath.Join(os.Getenv("HOME"), ".clickhouse-client", "config.xml") + raw, err := os.ReadFile(path) + if err != nil { + return "", "", "", "", false, err + } + var cfg clickhouseConfig + if err := xml.Unmarshal(raw, &cfg); err != nil { + return "", "", "", "", false, err + } + for _, c := range cfg.Connections.Connections { + if c.Name == name { + return fmt.Sprintf("%s:%d", c.Hostname, c.Port), c.User, c.Password, c.Database, c.Secure == 1, nil + } + } + return "", "", "", "", false, fmt.Errorf("connection %q not found", name) +} diff --git a/internal/embed/embedder.go b/internal/embed/embedder.go new file mode 100644 index 0000000..4ceaff5 --- /dev/null +++ b/internal/embed/embedder.go @@ -0,0 +1,5 @@ +package embed + +type Embedder interface { + Vector(text string, debug bool) ([]float32, error) +} diff --git a/internal/embed/mock.go b/internal/embed/mock.go new file mode 100644 index 0000000..fd96994 --- /dev/null +++ b/internal/embed/mock.go @@ -0,0 +1,11 @@ +package embed + +// Mock is a simple embedder used for tests. +type Mock struct{} + +func (Mock) Vector(text string, debug bool) ([]float32, error) { + // Return small vector for deterministic tests + return []float32{0.1, 0.2, 0.3, 0.4}, nil +} + +var _ Embedder = (*Mock)(nil) diff --git a/internal/embed/openai.go b/internal/embed/openai.go index bcb6efd..2e17766 100644 --- a/internal/embed/openai.go +++ b/internal/embed/openai.go @@ -63,3 +63,11 @@ func Vector(text string, debug bool) ([]float32, error) { return out.Data[0].Embedding, nil } +// OpenAI implements Embedder using the OpenAI API. +type OpenAI struct{} + +func (OpenAI) Vector(text string, debug bool) ([]float32, error) { + return Vector(text, debug) +} + +var _ Embedder = (*OpenAI)(nil) diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..2f5e7b5 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,43 @@ +package server + +import ( + "encoding/json" + "net/http" + + "github.com/BorisTyshkevich/github-semantic-search/internal/click" + "github.com/BorisTyshkevich/github-semantic-search/internal/embed" + "github.com/BorisTyshkevich/github-semantic-search/web" +) + +var content = web.FS + +var SearchFn = click.Search + +func Handler(emb embed.Embedder, opt click.Options, debug bool) http.Handler { + mux := http.NewServeMux() + fs := http.FS(content) + mux.Handle("/", http.FileServer(fs)) + mux.HandleFunc("/api/search", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("query") + if q == "" { + http.Error(w, "missing query", http.StatusBadRequest) + return + } + state := r.URL.Query().Get("state") + labels := r.URL.Query().Get("labels") + + vec, err := emb.Vector(q, debug) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + rows, err := SearchFn(vec, state, labels, opt, debug) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(rows) + }) + return mux +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..2900267 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,37 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/BorisTyshkevich/github-semantic-search/internal/click" + "github.com/BorisTyshkevich/github-semantic-search/internal/embed" +) + +func TestHandler(t *testing.T) { + SearchFn = func(vec []float32, state, labels string, opt click.Options, debug bool) ([]click.Row, error) { + return []click.Row{{Number: 1, Title: "Test", State: "open"}}, nil + } + defer func() { SearchFn = click.Search }() + h := Handler(embed.Mock{}, click.Options{}, false) + ts := httptest.NewServer(h) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/api/search?query=foo") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d", resp.StatusCode) + } + var rows []click.Row + if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].Number != 1 { + t.Fatalf("unexpected response: %#v", rows) + } +} diff --git a/web/fs.go b/web/fs.go new file mode 100644 index 0000000..df9edae --- /dev/null +++ b/web/fs.go @@ -0,0 +1,6 @@ +package web + +import "embed" + +//go:embed * +var FS embed.FS diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..0388fd3 --- /dev/null +++ b/web/index.html @@ -0,0 +1,42 @@ + + + + +GitHub Issue Search + + + +

GitHub Issue Search

+
+ + + + +
+
+ + + diff --git a/webfs.go b/webfs.go new file mode 100644 index 0000000..55cdddf --- /dev/null +++ b/webfs.go @@ -0,0 +1,6 @@ +package webfs + +import "embed" + +//go:embed web/* +var FS embed.FS