From 726e12a2f5f87236ff2528f61cf22ac6b90b97be Mon Sep 17 00:00:00 2001 From: jackjustus Date: Wed, 12 Aug 2026 14:36:33 -0700 Subject: [PATCH 01/20] cleanup unused items in api def. --- backend/internal/gen/openapi/api.gen.go | 9 +-------- backend/internal/handler/rides.go | 5 +---- cli/internal/gen/client/client.gen.go | 13 +++---------- openapi.yaml | 26 ++++++++----------------- 4 files changed, 13 insertions(+), 40 deletions(-) diff --git a/backend/internal/gen/openapi/api.gen.go b/backend/internal/gen/openapi/api.gen.go index 3196461..835ec96 100644 --- a/backend/internal/gen/openapi/api.gen.go +++ b/backend/internal/gen/openapi/api.gen.go @@ -43,13 +43,6 @@ type Ride struct { RiddenAt time.Time `json:"riddenAt"` } -// RidePage defines model for RidePage. -type RidePage struct { - // NextCursor Pass as cursor to fetch the next page. Absent on the last page. - NextCursor *string `json:"nextCursor,omitempty"` - Rides Rides `json:"rides"` -} - // Rides defines model for Rides. type Rides = []Ride @@ -263,7 +256,7 @@ type ListRidesResponseObject interface { VisitListRidesResponse(w http.ResponseWriter) error } -type ListRides200JSONResponse RidePage +type ListRides200JSONResponse Rides func (response ListRides200JSONResponse) VisitListRidesResponse(w http.ResponseWriter) error { diff --git a/backend/internal/handler/rides.go b/backend/internal/handler/rides.go index 9b352c3..764824e 100644 --- a/backend/internal/handler/rides.go +++ b/backend/internal/handler/rides.go @@ -13,8 +13,5 @@ func (s Server) ListRides(ctx context.Context, req api.ListRidesRequestObject) ( if err != nil { return nil, err } - return api.ListRides200JSONResponse(api.RidePage{ - NextCursor: nil, - Rides: rides.ToAPI(), - }), nil + return api.ListRides200JSONResponse(rides.ToAPI()), nil } diff --git a/cli/internal/gen/client/client.gen.go b/cli/internal/gen/client/client.gen.go index cf1796b..629a73b 100644 --- a/cli/internal/gen/client/client.gen.go +++ b/cli/internal/gen/client/client.gen.go @@ -44,13 +44,6 @@ type Ride struct { RiddenAt time.Time `json:"riddenAt"` } -// RidePage defines model for RidePage. -type RidePage struct { - // NextCursor Pass as cursor to fetch the next page. Absent on the last page. - NextCursor *string `json:"nextCursor,omitempty"` - Rides Rides `json:"rides"` -} - // Rides defines model for Rides. type Rides = []Ride @@ -519,11 +512,11 @@ type ListRidesResponse struct { Body []byte HTTPResponse *http.Response // JSON200 the response for an HTTP 200 `application/json` response - JSON200 *RidePage + JSON200 *Rides } // GetJSON200 returns the response for an HTTP 200 `application/json` response -func (r ListRidesResponse) GetJSON200() *RidePage { +func (r ListRidesResponse) GetJSON200() *Rides { return r.JSON200 } @@ -662,7 +655,7 @@ func ParseListRidesResponse(rsp *http.Response) (*ListRidesResponse, error) { switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest RidePage + var dest Rides if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } diff --git a/openapi.yaml b/openapi.yaml index 42d5e0b..3518929 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -18,12 +18,7 @@ paths: application/json: schema: $ref: "#/components/schemas/Credits" -# "404": -# description: Not Found -# content: -# application/json: -# schema: -# $ref: "#components/schemas/ErrorNotFound" + /rides: get: operationId: listRides @@ -52,7 +47,8 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/RidePage" + $ref: "#/components/schemas/Rides" + /log/{coasterID}: post: operationId: logRide @@ -114,6 +110,10 @@ components: allOf: - $ref: "#/components/schemas/WithId" - $ref: "#/components/schemas/WithName" + Coasters: + type: array + items: + $ref: "#/components/schemas/Coaster" Credit: description: > @@ -157,14 +157,4 @@ components: Rides: type: array items: - $ref: "#/components/schemas/Ride" - RidePage: - type: object - required: - - rides - properties: - rides: - $ref: "#/components/schemas/Rides" - nextCursor: - type: string - description: Pass as cursor to fetch the next page. Absent on the last page. + $ref: "#/components/schemas/Ride" \ No newline at end of file From 441e526431d74fbf91b8c6f372168ed793fedd41 Mon Sep 17 00:00:00 2001 From: jackjustus Date: Wed, 12 Aug 2026 14:41:23 -0700 Subject: [PATCH 02/20] fix cli breakage. --- cli/cmd/coaster/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/cmd/coaster/main.go b/cli/cmd/coaster/main.go index 838938a..3c87eac 100644 --- a/cli/cmd/coaster/main.go +++ b/cli/cmd/coaster/main.go @@ -109,7 +109,7 @@ func listRides(ctx context.Context, cmd *cli.Command) error { } return table([]string{"RIDDEN AT", "COASTER"}, func(w *tabwriter.Writer) { - for _, ride := range resp.JSON200.Rides { + for _, ride := range *resp.JSON200 { _, _ = fmt.Fprintf(w, "%s\t%s\n", ride.RiddenAt.Format(time.RFC3339), ride.Coaster.Name) From 12968315257f1808027e55c0cc4d823a39fbb10f Mon Sep 17 00:00:00 2001 From: jackjustus Date: Wed, 12 Aug 2026 15:56:21 -0700 Subject: [PATCH 03/20] api stub --- backend/internal/gen/openapi/api.gen.go | 81 ++++++++++++ backend/internal/handler/coasters.go | 11 ++ cli/internal/gen/client/client.gen.go | 166 ++++++++++++++++++++++++ openapi.yaml | 22 ++++ 4 files changed, 280 insertions(+) create mode 100644 backend/internal/handler/coasters.go diff --git a/backend/internal/gen/openapi/api.gen.go b/backend/internal/gen/openapi/api.gen.go index 835ec96..2268416 100644 --- a/backend/internal/gen/openapi/api.gen.go +++ b/backend/internal/gen/openapi/api.gen.go @@ -22,6 +22,9 @@ type Coaster struct { Name string `json:"name"` } +// Coasters defines model for Coasters. +type Coasters = []Coaster + // Credit A coaster the rider has ridden at least once, aggregated over every ride of it. The id is the coaster's id. type Credit struct { FirstRiddenAt time.Time `json:"firstRiddenAt"` @@ -59,6 +62,12 @@ type WithName struct { Name string `json:"name"` } +// SearchCoasterParams defines parameters for SearchCoaster. +type SearchCoasterParams struct { + // Q the query which users are searching for, returns a coaster reference + Q string `form:"q" json:"q"` +} + // LogRideParams defines parameters for LogRide. type LogRideParams struct { // Timestamp the time at which the views is being recorded @@ -76,6 +85,9 @@ type ListRidesParams struct { // ServerInterface represents all server handlers. type ServerInterface interface { + // SearchCoaster List coaster & park, based on search + // (GET /coasters) + SearchCoaster(ctx echo.Context, params SearchCoasterParams) error // ListCredits List distinct coasters ridden, one entry per coaster // (GET /credits) ListCredits(ctx echo.Context) error @@ -92,6 +104,24 @@ type ServerInterfaceWrapper struct { Handler ServerInterface } +// SearchCoaster converts echo context to params. +func (w *ServerInterfaceWrapper) SearchCoaster(ctx echo.Context) error { + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params SearchCoasterParams + // ------------- Required query parameter "q" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, true, "q", ctx.QueryParams(), ¶ms.Q, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter q: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.SearchCoaster(ctx, params) + return err +} + // ListCredits converts echo context to params. func (w *ServerInterfaceWrapper) ListCredits(ctx echo.Context) error { var err error @@ -201,7 +231,30 @@ func RegisterHandlersWithOptions(router EchoRouter, si ServerInterface, options router.GET(options.BaseURL+"/credits", wrapper.ListCredits, options.OperationMiddlewares["listCredits"]...) router.GET(options.BaseURL+"/rides", wrapper.ListRides, options.OperationMiddlewares["listRides"]...) router.POST(options.BaseURL+"/log/:coasterID", wrapper.LogRide, options.OperationMiddlewares["logRide"]...) + router.GET(options.BaseURL+"/coasters", wrapper.SearchCoaster, options.OperationMiddlewares["searchCoaster"]...) + +} + +type SearchCoasterRequestObject struct { + Params SearchCoasterParams +} +type SearchCoasterResponseObject interface { + VisitSearchCoasterResponse(w http.ResponseWriter) error +} + +type SearchCoaster200JSONResponse Coasters + +func (response SearchCoaster200JSONResponse) VisitSearchCoasterResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err } type ListCreditsRequestObject struct { @@ -272,6 +325,9 @@ func (response ListRides200JSONResponse) VisitListRidesResponse(w http.ResponseW // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // SearchCoaster List coaster & park, based on search + // (GET /coasters) + SearchCoaster(ctx context.Context, request SearchCoasterRequestObject) (SearchCoasterResponseObject, error) // ListCredits List distinct coasters ridden, one entry per coaster // (GET /credits) ListCredits(ctx context.Context, request ListCreditsRequestObject) (ListCreditsResponseObject, error) @@ -295,6 +351,31 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } +// SearchCoaster operation middleware +func (sh *strictHandler) SearchCoaster(ctx echo.Context, params SearchCoasterParams) error { + var request SearchCoasterRequestObject + + request.Params = params + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.SearchCoaster(ctx.Request().Context(), request.(SearchCoasterRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "SearchCoaster") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(SearchCoasterResponseObject); ok { + return validResponse.VisitSearchCoasterResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // ListCredits operation middleware func (sh *strictHandler) ListCredits(ctx echo.Context) error { var request ListCreditsRequestObject diff --git a/backend/internal/handler/coasters.go b/backend/internal/handler/coasters.go new file mode 100644 index 0000000..3b394b8 --- /dev/null +++ b/backend/internal/handler/coasters.go @@ -0,0 +1,11 @@ +package handler + +import ( + "context" + + api "github.com/jackjustus/credittracker/backend/internal/gen/openapi" +) + +func (s Server) GetCoaster(ctx context.Context, req api.SearchCoasterRequestObject) (api.SearchCoasterResponseObject, error) { + return api.SearchCoaster200JSONResponse{}, nil +} diff --git a/cli/internal/gen/client/client.gen.go b/cli/internal/gen/client/client.gen.go index 629a73b..96d9a88 100644 --- a/cli/internal/gen/client/client.gen.go +++ b/cli/internal/gen/client/client.gen.go @@ -23,6 +23,9 @@ type Coaster struct { Name string `json:"name"` } +// Coasters defines model for Coasters. +type Coasters = []Coaster + // Credit A coaster the rider has ridden at least once, aggregated over every ride of it. The id is the coaster's id. type Credit struct { FirstRiddenAt time.Time `json:"firstRiddenAt"` @@ -60,6 +63,12 @@ type WithName struct { Name string `json:"name"` } +// SearchCoasterParams defines parameters for SearchCoaster. +type SearchCoasterParams struct { + // Q the query which users are searching for, returns a coaster reference + Q string `form:"q" json:"q"` +} + // LogRideParams defines parameters for LogRide. type LogRideParams struct { // Timestamp the time at which the views is being recorded @@ -149,6 +158,11 @@ func WithRequestEditorFn(fn RequestEditorFn) ClientOption { // The interface specification for the client above. type ClientInterface interface { + // SearchCoaster List coaster & park, based on search + // + // Corresponds with GET /coasters (the `SearchCoaster` operationId). + SearchCoaster(ctx context.Context, params *SearchCoasterParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListCredits List distinct coasters ridden, one entry per coaster // // Corresponds with GET /credits (the `ListCredits` operationId). @@ -165,6 +179,21 @@ type ClientInterface interface { ListRides(ctx context.Context, params *ListRidesParams, reqEditors ...RequestEditorFn) (*http.Response, error) } +// SearchCoaster List coaster & park, based on search +// +// Corresponds with GET /coasters (the `SearchCoaster` operationId). +func (c *Client) SearchCoaster(ctx context.Context, params *SearchCoasterParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSearchCoasterRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // ListCredits List distinct coasters ridden, one entry per coaster // // Corresponds with GET /credits (the `ListCredits` operationId). @@ -210,6 +239,56 @@ func (c *Client) ListRides(ctx context.Context, params *ListRidesParams, reqEdit return c.Client.Do(req) } +// NewSearchCoasterRequest constructs an http.Request for the SearchCoaster method +func NewSearchCoasterRequest(server string, params *SearchCoasterParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/coasters") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "q", params.Q, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewListCreditsRequest constructs an http.Request for the ListCredits method func NewListCreditsRequest(server string) (*http.Request, error) { var err error @@ -404,6 +483,13 @@ func WithBaseURL(baseURL string) ClientOption { // ClientWithResponsesInterface is the interface specification for the client with responses above. type ClientWithResponsesInterface interface { + // SearchCoasterWithResponse List coaster & park, based on search + // + // Returns a wrapper object for the known response body format(s). + // + // Corresponds with GET /coasters (the `SearchCoaster` operationId). + SearchCoasterWithResponse(ctx context.Context, params *SearchCoasterParams, reqEditors ...RequestEditorFn) (*SearchCoasterResponse, error) + // ListCreditsWithResponse List distinct coasters ridden, one entry per coaster // // Returns a wrapper object for the known response body format(s). @@ -426,6 +512,47 @@ type ClientWithResponsesInterface interface { ListRidesWithResponse(ctx context.Context, params *ListRidesParams, reqEditors ...RequestEditorFn) (*ListRidesResponse, error) } +type SearchCoasterResponse struct { + Body []byte + HTTPResponse *http.Response + // JSON200 the response for an HTTP 200 `application/json` response + JSON200 *Coasters +} + +// GetJSON200 returns the response for an HTTP 200 `application/json` response +func (r SearchCoasterResponse) GetJSON200() *Coasters { + return r.JSON200 +} + +// GetBody returns the raw response body bytes +func (r SearchCoasterResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r SearchCoasterResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SearchCoasterResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SearchCoasterResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type ListCreditsResponse struct { Body []byte HTTPResponse *http.Response @@ -549,6 +676,19 @@ func (r ListRidesResponse) ContentType() string { return "" } +// SearchCoasterWithResponse List coaster & park, based on search +// +// Returns a wrapper object for the known response body format(s). +// +// Corresponds with GET /coasters (the `SearchCoaster` operationId). +func (c *ClientWithResponses) SearchCoasterWithResponse(ctx context.Context, params *SearchCoasterParams, reqEditors ...RequestEditorFn) (*SearchCoasterResponse, error) { + rsp, err := c.SearchCoaster(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseSearchCoasterResponse(rsp) +} + // ListCreditsWithResponse List distinct coasters ridden, one entry per coaster // // Returns a wrapper object for the known response body format(s). @@ -588,6 +728,32 @@ func (c *ClientWithResponses) ListRidesWithResponse(ctx context.Context, params return ParseListRidesResponse(rsp) } +// ParseSearchCoasterResponse parses an HTTP response from a SearchCoasterWithResponse call +func ParseSearchCoasterResponse(rsp *http.Response) (*SearchCoasterResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SearchCoasterResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Coasters + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} + // ParseListCreditsResponse parses an HTTP response from a ListCreditsWithResponse call func ParseListCreditsResponse(rsp *http.Response) (*ListCreditsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/openapi.yaml b/openapi.yaml index 3518929..58f8f78 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -75,6 +75,28 @@ paths: schema: $ref: "#/components/schemas/Ride" + /coasters: + get: + operationId: searchCoaster + summary: List coaster & park, based on search + parameters: + - name: q + in: query + required: true + description: the query which users are searching for, returns a coaster reference + example: kingda ka + schema: + type: string + minLength: 1 + maxLength: 256 + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/Coasters" + components: schemas: From abfc2a47c91fac287f2840c9b6dc67e62120a9ab Mon Sep 17 00:00:00 2001 From: jackjustus Date: Wed, 12 Aug 2026 16:15:27 -0700 Subject: [PATCH 04/20] fix func name --- backend/internal/handler/coasters.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/handler/coasters.go b/backend/internal/handler/coasters.go index 3b394b8..7f1c6e3 100644 --- a/backend/internal/handler/coasters.go +++ b/backend/internal/handler/coasters.go @@ -6,6 +6,6 @@ import ( api "github.com/jackjustus/credittracker/backend/internal/gen/openapi" ) -func (s Server) GetCoaster(ctx context.Context, req api.SearchCoasterRequestObject) (api.SearchCoasterResponseObject, error) { +func (s Server) SearchCoaster(ctx context.Context, req api.SearchCoasterRequestObject) (api.SearchCoasterResponseObject, error) { return api.SearchCoaster200JSONResponse{}, nil } From af968506b3f55c076288d0291e86c4cbdfe34131 Mon Sep 17 00:00:00 2001 From: jackjustus Date: Thu, 13 Aug 2026 23:00:12 -0700 Subject: [PATCH 05/20] first pass at embedding things & test --- .github/workflows/ci.yml | 1 + Makefile | 6 +- backend/cmd/embedder/main.go | 23 ++++ backend/cmd/embedder/main_test.go | 49 +++++++++ backend/compose.yaml | 10 +- .../20260812232147_add_pgvector.sql | 17 +++ backend/go.mod | 7 +- backend/go.sum | 8 ++ backend/internal/embed/client.go | 100 ++++++++++++++++++ backend/internal/gen/db/coaster.sql.go | 6 +- backend/internal/gen/db/models.go | 21 ++-- backend/internal/gen/db/ride_events.sql.go | 6 +- backend/internal/models/coaster.go | 32 ++++++ backend/internal/models/park.go | 30 ++++++ 14 files changed, 301 insertions(+), 15 deletions(-) create mode 100644 backend/cmd/embedder/main.go create mode 100644 backend/cmd/embedder/main_test.go create mode 100644 backend/db/migrations/20260812232147_add_pgvector.sql create mode 100644 backend/internal/embed/client.go create mode 100644 backend/internal/models/park.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25f4352..e6243d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,7 @@ jobs: cli/go.sum - run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@${{ env.GOLANGCI_VERSION }} - run: make check + - run: make test generated: name: generated code is current diff --git a/Makefile b/Makefile index 05a60fd..9b3186d 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ export GOOSE_DRIVER := postgres export GOOSE_DBSTRING := $(DATABASE_URL) export GOOSE_MIGRATION_DIR := backend/db/migrations -.PHONY: help api rebuild-api watch-api stop-api logs psql install tools generate check lint verify-gen migration +.PHONY: help api rebuild-api watch-api stop-api logs psql install tools generate check test lint verify-gen migration help: ## Show available targets @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk -F':.*?## ' '{printf " \033[36m%-11s\033[0m %s\n", $$1, $$2}' @@ -54,6 +54,10 @@ check: ## Build, vet, and lint both modules cd cli && go build ./... && go vet ./... $(MAKE) lint +test: ## Run the unit tests for both modules + cd backend && go test ./... + cd cli && go test ./... + lint: ## Run golangci-lint on both modules cd backend && golangci-lint run cd cli && golangci-lint run diff --git a/backend/cmd/embedder/main.go b/backend/cmd/embedder/main.go new file mode 100644 index 0000000..09dc956 --- /dev/null +++ b/backend/cmd/embedder/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/jackjustus/credittracker/backend/internal/models" +) + +func main() { + +} + +func CoasterDoc(c models.CoasterWithPark) (string, error) { + var b strings.Builder + + _, err := fmt.Fprintf(&b, "%s is a coaster at %s", c.Coaster().Name(), c.Park().Name()) + if err != nil { + return "", err + } + + return b.String(), nil +} diff --git a/backend/cmd/embedder/main_test.go b/backend/cmd/embedder/main_test.go new file mode 100644 index 0000000..b00351c --- /dev/null +++ b/backend/cmd/embedder/main_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackjustus/credittracker/backend/internal/gen/db" + "github.com/jackjustus/credittracker/backend/internal/models" +) + +func TestCoasterDoc(t *testing.T) { + now := pgtype.Timestamptz{Time: time.Now(), Valid: true} + parkID := uuid.New() + + c := models.NewCoaster(db.Coaster{ + ID: uuid.New(), + ParkID: parkID, + Name: "Kingda Ka", + ManufacturedAt: now, + CreatedAt: now, + UpdatedAt: now, + }) + p := models.NewPark(db.Park{ + ID: parkID, + Name: "Six Flags Great Adventure", + City: "Jackson", + Country: "United States", + CreatedAt: now, + UpdatedAt: now, + }) + cwp, err := models.NewCoasterWithPark(c, p) + if err != nil { + t.Fatalf("NewCoasterWithPark: %v", err) + } + + doc, err := CoasterDoc(*cwp) + if err != nil { + t.Fatalf("CoasterDoc: %v", err) + } + + // Pinned exactly: this string is what gets embedded, so a wording change + // invalidates every vector already stored under the old phrasing. + want := "Kingda Ka is a coaster at Six Flags Great Adventure" + if doc != want { + t.Errorf("CoasterDoc = %q, want %q", doc, want) + } +} diff --git a/backend/compose.yaml b/backend/compose.yaml index 70ae457..387ff5b 100644 --- a/backend/compose.yaml +++ b/backend/compose.yaml @@ -1,6 +1,6 @@ services: db: - image: postgres:18 + image: pgvector/pgvector:pg18-trixie restart: unless-stopped environment: POSTGRES_PASSWORD: testtest @@ -56,6 +56,14 @@ services: path: ./go.sum environment: DATABASE_URL: postgres://postgres:testtest@db:5432/postgres?sslmode=disable + # Ollama runs natively on the host, not in a container: Docker Desktop on + # macOS has no GPU passthrough, so a containerized Ollama would be + # CPU-only. The host daemon must bind 0.0.0.0 (OLLAMA_HOST) to accept + # this; on loopback it only answers processes on the Mac itself. + OLLAMA_URL: http://host.docker.internal:11434 + # Automatic on Docker Desktop; this makes it work on Linux hosts too. + extra_hosts: + - "host.docker.internal:host-gateway" depends_on: migrate: condition: service_completed_successfully diff --git a/backend/db/migrations/20260812232147_add_pgvector.sql b/backend/db/migrations/20260812232147_add_pgvector.sql new file mode 100644 index 0000000..c68c0bc --- /dev/null +++ b/backend/db/migrations/20260812232147_add_pgvector.sql @@ -0,0 +1,17 @@ +-- +goose Up +CREATE EXTENSION IF NOT EXISTS vector; + +ALTER TABLE coasters + ADD COLUMN search_profile text, -- text passed to embedding model + ADD COLUMN profile_embedding vector(768), + ADD COLUMN profile_embedding_model text, -- text literal of embedding model used, ex: nomic-embed-text + ADD COLUMN profile_embedded_at timestamptz; + +-- +goose Down +ALTER TABLE coasters + DROP COLUMN search_profile , + DROP COLUMN profile_embedding , + DROP COLUMN profile_embedding_model , + DROP COLUMN profile_embedded_at ; + +DROP EXTENSION IF EXISTS vector; \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod index 989c0e1..e81cd07 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -8,6 +8,7 @@ require ( github.com/labstack/echo/v4 v4.15.4 github.com/life4/genesis v1.10.3 github.com/oapi-codegen/runtime v1.6.0 + github.com/pgvector/pgvector-go v0.4.1 ) require ( @@ -20,9 +21,9 @@ require ( github.com/mattn/go-isatty v0.0.24 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/crypto v0.54.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.41.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 6c58707..39a16c6 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -30,6 +30,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU= github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/pgvector/pgvector-go v0.4.1 h1:Oaj0mC0Ky8KaTweNHHpLwyFlN6a0nUFoo1vgSFTEhPI= +github.com/pgvector/pgvector-go v0.4.1/go.mod h1:4fSXyjl1TYAIdByAql6JazKWRr2s7J0g4hcRY5cBFCk= 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/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= @@ -44,14 +46,20 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/backend/internal/embed/client.go b/backend/internal/embed/client.go new file mode 100644 index 0000000..f4d6603 --- /dev/null +++ b/backend/internal/embed/client.go @@ -0,0 +1,100 @@ +package embed + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" +) + +// Ollama's default listen address, which is right for anything run on the host +// (`go run ./cmd/embedder`). Containers cannot reach the daemon this way and +// must set OLLAMA_URL to host.docker.internal, as compose.yaml does. + +const defaultURL = "http://localhost:11434" + +const ( + Model = "nomic-embed-text" + Dimensions = 768 +) + +type Client struct { + url string + httpc *http.Client +} + +func NewClient() (*Client, error) { + url := os.Getenv("OLLAMA_URL") + if url == "" { + url = defaultURL + } + return &Client{ + url: url, + httpc: &http.Client{Timeout: 5 * time.Second}, + }, nil +} + +type embedRequest struct { + Model string `json:"model"` + Input []string `json:"input"` +} + +type embedResponse struct { + Embeddings [][]float32 `json:"embeddings"` + Error string `json:"error"` +} + +// Embed takes in a list of strings, and returns vectors for each input string +func (c *Client) Embed(ctx context.Context, docs []string) ([][]float32, error) { + if len(docs) == 0 { + return nil, nil + } + + body, err := json.Marshal(embedRequest{Model: Model, Input: docs}) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url+"/api/embed", bytes.NewReader(body)) + if err != nil { + return nil, err + } + //req.Header.Set("Content-Type", "application/json") + + res, err := c.httpc.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + raw, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var parsed embedResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("ollama returned %s: %s", res.Status, raw) + } + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ollama returned %s: %s", res.Status, parsed.Error) + } + // Callers zip these against the docs they sent, so a short response would + // silently write one coaster's embedding onto another's row. + if len(parsed.Embeddings) != len(docs) { + return nil, fmt.Errorf("asked for %d embeddings, got %d", len(docs), len(parsed.Embeddings)) + } + return parsed.Embeddings, nil +} + +func (c *Client) EmbedOne(ctx context.Context, doc string) ([]float32, error) { + vecs, err := c.Embed(ctx, []string{doc}) + if err != nil { + return nil, err + } + return vecs[0], nil +} diff --git a/backend/internal/gen/db/coaster.sql.go b/backend/internal/gen/db/coaster.sql.go index ac5aac7..bb23dc3 100644 --- a/backend/internal/gen/db/coaster.sql.go +++ b/backend/internal/gen/db/coaster.sql.go @@ -12,7 +12,7 @@ import ( ) const hydrateCoaster = `-- name: HydrateCoaster :one -SELECT coasters.id, coasters.park_id, coasters.name, coasters.manufactured_at, coasters.external_id, coasters.external_source, coasters.created_at, coasters.updated_at +SELECT coasters.id, coasters.park_id, coasters.name, coasters.manufactured_at, coasters.external_id, coasters.external_source, coasters.created_at, coasters.updated_at, coasters.search_profile, coasters.profile_embedding, coasters.profile_embedding_model, coasters.profile_embedded_at FROM coasters WHERE coasters.id = $1 ` @@ -33,6 +33,10 @@ func (q *Queries) HydrateCoaster(ctx context.Context, id uuid.UUID) (HydrateCoas &i.Coaster.ExternalSource, &i.Coaster.CreatedAt, &i.Coaster.UpdatedAt, + &i.Coaster.SearchProfile, + &i.Coaster.ProfileEmbedding, + &i.Coaster.ProfileEmbeddingModel, + &i.Coaster.ProfileEmbeddedAt, ) return i, err } diff --git a/backend/internal/gen/db/models.go b/backend/internal/gen/db/models.go index 88785b9..56496ce 100644 --- a/backend/internal/gen/db/models.go +++ b/backend/internal/gen/db/models.go @@ -7,17 +7,22 @@ package db import ( "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" + "github.com/pgvector/pgvector-go" ) type Coaster struct { - ID uuid.UUID - ParkID uuid.UUID - Name string - ManufacturedAt pgtype.Timestamptz - ExternalID *string - ExternalSource *string - CreatedAt pgtype.Timestamptz - UpdatedAt pgtype.Timestamptz + ID uuid.UUID + ParkID uuid.UUID + Name string + ManufacturedAt pgtype.Timestamptz + ExternalID *string + ExternalSource *string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + SearchProfile *string + ProfileEmbedding *pgvector.Vector + ProfileEmbeddingModel *string + ProfileEmbeddedAt pgtype.Timestamptz } type Park struct { diff --git a/backend/internal/gen/db/ride_events.sql.go b/backend/internal/gen/db/ride_events.sql.go index dcedd67..5123b7a 100644 --- a/backend/internal/gen/db/ride_events.sql.go +++ b/backend/internal/gen/db/ride_events.sql.go @@ -31,7 +31,7 @@ func (q *Queries) CreateRideEvent(ctx context.Context, arg CreateRideEventParams } const getRideEvents = `-- name: GetRideEvents :many -SELECT ride_events.coaster_id, ride_events.created_at, coasters.id, coasters.park_id, coasters.name, coasters.manufactured_at, coasters.external_id, coasters.external_source, coasters.created_at, coasters.updated_at +SELECT ride_events.coaster_id, ride_events.created_at, coasters.id, coasters.park_id, coasters.name, coasters.manufactured_at, coasters.external_id, coasters.external_source, coasters.created_at, coasters.updated_at, coasters.search_profile, coasters.profile_embedding, coasters.profile_embedding_model, coasters.profile_embedded_at FROM ride_events JOIN coasters ON ride_events.coaster_id = coasters.id ORDER BY ride_events.created_at DESC @@ -62,6 +62,10 @@ func (q *Queries) GetRideEvents(ctx context.Context) ([]GetRideEventsRow, error) &i.Coaster.ExternalSource, &i.Coaster.CreatedAt, &i.Coaster.UpdatedAt, + &i.Coaster.SearchProfile, + &i.Coaster.ProfileEmbedding, + &i.Coaster.ProfileEmbeddingModel, + &i.Coaster.ProfileEmbeddedAt, ); err != nil { return nil, err } diff --git a/backend/internal/models/coaster.go b/backend/internal/models/coaster.go index 0fa868c..e6493d6 100644 --- a/backend/internal/models/coaster.go +++ b/backend/internal/models/coaster.go @@ -1,6 +1,7 @@ package models import ( + "errors" "time" "github.com/google/uuid" @@ -15,6 +16,14 @@ type Coaster struct { manufacturedAt time.Time } +func (c *Coaster) ID() uuid.UUID { + return c.id +} + +func (c *Coaster) Name() string { + return c.name +} + func (c *Coaster) ToRide(time time.Time) api.Ride { return api.Ride{ Coaster: api.Coaster{ @@ -33,3 +42,26 @@ func NewCoaster(coaster db.Coaster) *Coaster { manufacturedAt: coaster.ManufacturedAt.Time, } } + +type CoasterWithPark struct { + coaster *Coaster + park *Park +} + +func (c *CoasterWithPark) Coaster() *Coaster { + return c.coaster +} + +func (c *CoasterWithPark) Park() *Park { + return c.park +} + +func NewCoasterWithPark(coaster *Coaster, park *Park) (*CoasterWithPark, error) { + if coaster.parkID != park.id { + return nil, errors.New("cannot create coaster and park without relationship") + } + return &CoasterWithPark{ + coaster: coaster, + park: park, + }, nil +} diff --git a/backend/internal/models/park.go b/backend/internal/models/park.go new file mode 100644 index 0000000..a4a563e --- /dev/null +++ b/backend/internal/models/park.go @@ -0,0 +1,30 @@ +package models + +import ( + "github.com/google/uuid" + "github.com/jackjustus/credittracker/backend/internal/gen/db" +) + +type Park struct { + id uuid.UUID + name string + city string + country string +} + +func (p *Park) ID() uuid.UUID { + return p.id +} + +func (p *Park) Name() string { + return p.name +} + +func NewPark(row db.Park) *Park { + return &Park{ + id: row.ID, + name: row.Name, + city: row.City, + country: row.Country, + } +} From c155c89d5d20415c369c9bbc9837c131a1f16e7a Mon Sep 17 00:00:00 2001 From: jackjustus Date: Thu, 13 Aug 2026 23:13:49 -0700 Subject: [PATCH 06/20] add embedding target queries --- backend/db/queries/coaster.sql | 15 ++++++ backend/internal/gen/db/coaster.sql.go | 66 ++++++++++++++++++++++++++ backend/internal/gen/db/querier.go | 7 +++ 3 files changed, 88 insertions(+) diff --git a/backend/db/queries/coaster.sql b/backend/db/queries/coaster.sql index fc9fd9e..c9d6950 100644 --- a/backend/db/queries/coaster.sql +++ b/backend/db/queries/coaster.sql @@ -2,3 +2,18 @@ SELECT sqlc.embed(coasters) FROM coasters WHERE coasters.id = @id; + +-- Candidates for (re-)embedding.. either never embedded, or embedded under a different +-- model than the parameterized one. +-- +-- Park is embedded so that the doc producer as as much metadata abt the coaster as possible to +-- improve embedding outcomes. +-- +-- name: ListCoastersNeedingEmbedding :many +SELECT sqlc.embed(coasters), sqlc.embed(parks) +FROM coasters +JOIN parks ON parks.id = coasters.park_id +WHERE coasters.profile_embedding IS NULL + OR coasters.profile_embedding_model IS DISTINCT FROM @model::text +ORDER BY coasters.id +LIMIT @row_limit; diff --git a/backend/internal/gen/db/coaster.sql.go b/backend/internal/gen/db/coaster.sql.go index bb23dc3..3b5a2d4 100644 --- a/backend/internal/gen/db/coaster.sql.go +++ b/backend/internal/gen/db/coaster.sql.go @@ -40,3 +40,69 @@ func (q *Queries) HydrateCoaster(ctx context.Context, id uuid.UUID) (HydrateCoas ) return i, err } + +const listCoastersNeedingEmbedding = `-- name: ListCoastersNeedingEmbedding :many +SELECT coasters.id, coasters.park_id, coasters.name, coasters.manufactured_at, coasters.external_id, coasters.external_source, coasters.created_at, coasters.updated_at, coasters.search_profile, coasters.profile_embedding, coasters.profile_embedding_model, coasters.profile_embedded_at, parks.id, parks.name, parks.city, parks.country, parks.external_id, parks.external_source, parks.created_at, parks.updated_at +FROM coasters +JOIN parks ON parks.id = coasters.park_id +WHERE coasters.profile_embedding IS NULL + OR coasters.profile_embedding_model IS DISTINCT FROM $1::text +ORDER BY coasters.id +LIMIT $2 +` + +type ListCoastersNeedingEmbeddingParams struct { + Model string + RowLimit int32 +} + +type ListCoastersNeedingEmbeddingRow struct { + Coaster Coaster + Park Park +} + +// Candidates for (re-)embedding.. either never embedded, or embedded under a different +// model than the parameterized one. +// +// Park is embedded so that the doc producer as as much metadata abt the coaster as possible to +// improve embedding outcomes. +func (q *Queries) ListCoastersNeedingEmbedding(ctx context.Context, arg ListCoastersNeedingEmbeddingParams) ([]ListCoastersNeedingEmbeddingRow, error) { + rows, err := q.db.Query(ctx, listCoastersNeedingEmbedding, arg.Model, arg.RowLimit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListCoastersNeedingEmbeddingRow{} + for rows.Next() { + var i ListCoastersNeedingEmbeddingRow + if err := rows.Scan( + &i.Coaster.ID, + &i.Coaster.ParkID, + &i.Coaster.Name, + &i.Coaster.ManufacturedAt, + &i.Coaster.ExternalID, + &i.Coaster.ExternalSource, + &i.Coaster.CreatedAt, + &i.Coaster.UpdatedAt, + &i.Coaster.SearchProfile, + &i.Coaster.ProfileEmbedding, + &i.Coaster.ProfileEmbeddingModel, + &i.Coaster.ProfileEmbeddedAt, + &i.Park.ID, + &i.Park.Name, + &i.Park.City, + &i.Park.Country, + &i.Park.ExternalID, + &i.Park.ExternalSource, + &i.Park.CreatedAt, + &i.Park.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/backend/internal/gen/db/querier.go b/backend/internal/gen/db/querier.go index 099cd1a..786ad98 100644 --- a/backend/internal/gen/db/querier.go +++ b/backend/internal/gen/db/querier.go @@ -14,6 +14,13 @@ type Querier interface { CreateRideEvent(ctx context.Context, arg CreateRideEventParams) error GetRideEvents(ctx context.Context) ([]GetRideEventsRow, error) HydrateCoaster(ctx context.Context, id uuid.UUID) (HydrateCoasterRow, error) + // Candidates for (re-)embedding.. either never embedded, or embedded under a different + // model than the parameterized one. + // + // Park is embedded so that the doc producer as as much metadata abt the coaster as possible to + // improve embedding outcomes. + // + ListCoastersNeedingEmbedding(ctx context.Context, arg ListCoastersNeedingEmbeddingParams) ([]ListCoastersNeedingEmbeddingRow, error) } var _ Querier = (*Queries)(nil) From a2e0587e363b7722dcbea78b372b131cb2b6860e Mon Sep 17 00:00:00 2001 From: jackjustus Date: Thu, 13 Aug 2026 23:20:45 -0700 Subject: [PATCH 07/20] list embedding targets dao --- backend/internal/data/dao.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/backend/internal/data/dao.go b/backend/internal/data/dao.go index 5bb2b8b..87bc00b 100644 --- a/backend/internal/data/dao.go +++ b/backend/internal/data/dao.go @@ -36,6 +36,26 @@ func (dao *DAO) HydrateCoaster(ctx context.Context, coasterID uuid.UUID) (*model return models.NewCoaster(row.Coaster), nil } +func (dao *DAO) ListEmbeddingTargets(ctx context.Context, model string, limit int32) ([]*models.CoasterWithPark, error) { + rows, err := dao.dbc.ListCoastersNeedingEmbedding(ctx, db.ListCoastersNeedingEmbeddingParams{ + Model: model, + RowLimit: limit, + }) + if err != nil { + return nil, err + } + + coasters := make([]*models.CoasterWithPark, 0, len(rows)) + for _, row := range rows { + cwp, err := models.NewCoasterWithPark(models.NewCoaster(row.Coaster), models.NewPark(row.Park)) + if err != nil { + return nil, err + } + coasters = append(coasters, cwp) + } + return coasters, nil +} + func (dao *DAO) GetCredits(ctx context.Context) (*models.Credits, error) { events, err := dao.dbc.GetRideEvents(ctx) if err != nil { From 9525bc237298de38a34d87e4a40ba6af6d5ca745 Mon Sep 17 00:00:00 2001 From: jackjustus Date: Thu, 13 Aug 2026 23:39:07 -0700 Subject: [PATCH 08/20] write embeddings back to db --- backend/cmd/embedder/main.go | 41 ++++++++++++++++++++++++++ backend/db/queries/coaster.sql | 11 +++++++ backend/internal/data/dao.go | 14 +++++++++ backend/internal/gen/db/coaster.sql.go | 30 +++++++++++++++++++ backend/internal/gen/db/querier.go | 4 +++ 5 files changed, 100 insertions(+) diff --git a/backend/cmd/embedder/main.go b/backend/cmd/embedder/main.go index 09dc956..b1838f2 100644 --- a/backend/cmd/embedder/main.go +++ b/backend/cmd/embedder/main.go @@ -1,14 +1,55 @@ package main import ( + "context" "fmt" + "log" "strings" + "github.com/jackjustus/credittracker/backend/internal/data" + "github.com/jackjustus/credittracker/backend/internal/embed" "github.com/jackjustus/credittracker/backend/internal/models" + "github.com/life4/genesis/slices" ) func main() { + ctx := context.Background() + dao, err := data.NewDAO(ctx) + if err != nil { + log.Fatal(err) + return + } + coasters, err := dao.ListEmbeddingTargets(ctx, embed.Model, 700) + if err != nil { + log.Fatal(err) + return + } + + docs := slices.Map(coasters, func(c *models.CoasterWithPark) string { + doc, _ := CoasterDoc(*c) + return doc + }) + + client, err := embed.NewClient() + if err != nil { + log.Fatal(err) + return + } + + vecs, err := client.Embed(ctx, docs) + if err != nil { + log.Fatal(err) + return + } + + // Embed guarantees one vector per doc, so these indexes stay aligned. + for i, c := range coasters { + if err := dao.SetCoasterEmbedding(ctx, c.Coaster().ID(), docs[i], embed.Model, vecs[i]); err != nil { + log.Fatalf("writing %s: %v", c.Coaster().Name(), err) + } + } + fmt.Printf("embedded %d coasters with %s\n", len(coasters), embed.Model) } func CoasterDoc(c models.CoasterWithPark) (string, error) { diff --git a/backend/db/queries/coaster.sql b/backend/db/queries/coaster.sql index c9d6950..91dd4c3 100644 --- a/backend/db/queries/coaster.sql +++ b/backend/db/queries/coaster.sql @@ -17,3 +17,14 @@ WHERE coasters.profile_embedding IS NULL OR coasters.profile_embedding_model IS DISTINCT FROM @model::text ORDER BY coasters.id LIMIT @row_limit; + +-- Stores the vector alongside the exact text and model that produced it, so a +-- later run can tell whether a row is stale. updated_at is deliberately left +-- alone: the coaster itself did not change, only its derived embedding. +-- name: SetCoasterEmbedding :exec +UPDATE coasters +SET search_profile = @search_profile::text, + profile_embedding = @profile_embedding, + profile_embedding_model = @profile_embedding_model::text, + profile_embedded_at = now() +WHERE id = @id; diff --git a/backend/internal/data/dao.go b/backend/internal/data/dao.go index 87bc00b..ac8a937 100644 --- a/backend/internal/data/dao.go +++ b/backend/internal/data/dao.go @@ -11,6 +11,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/jackjustus/credittracker/backend/internal/gen/db" "github.com/jackjustus/credittracker/backend/internal/models" + "github.com/pgvector/pgvector-go" ) type DAO struct { @@ -56,6 +57,19 @@ func (dao *DAO) ListEmbeddingTargets(ctx context.Context, model string, limit in return coasters, nil } +// SetCoasterEmbedding writes a vector along with the document text and model +// that produced it. The model is a parameter rather than a constant so the +// data layer stays unaware of which embedder produced the vector. +func (dao *DAO) SetCoasterEmbedding(ctx context.Context, coasterID uuid.UUID, doc, model string, vec []float32) error { + embedding := pgvector.NewVector(vec) + return dao.dbc.SetCoasterEmbedding(ctx, db.SetCoasterEmbeddingParams{ + ID: coasterID, + SearchProfile: doc, + ProfileEmbedding: &embedding, + ProfileEmbeddingModel: model, + }) +} + func (dao *DAO) GetCredits(ctx context.Context) (*models.Credits, error) { events, err := dao.dbc.GetRideEvents(ctx) if err != nil { diff --git a/backend/internal/gen/db/coaster.sql.go b/backend/internal/gen/db/coaster.sql.go index 3b5a2d4..2d588d2 100644 --- a/backend/internal/gen/db/coaster.sql.go +++ b/backend/internal/gen/db/coaster.sql.go @@ -9,6 +9,7 @@ import ( "context" "github.com/google/uuid" + "github.com/pgvector/pgvector-go" ) const hydrateCoaster = `-- name: HydrateCoaster :one @@ -106,3 +107,32 @@ func (q *Queries) ListCoastersNeedingEmbedding(ctx context.Context, arg ListCoas } return items, nil } + +const setCoasterEmbedding = `-- name: SetCoasterEmbedding :exec +UPDATE coasters +SET search_profile = $1::text, + profile_embedding = $2, + profile_embedding_model = $3::text, + profile_embedded_at = now() +WHERE id = $4 +` + +type SetCoasterEmbeddingParams struct { + SearchProfile string + ProfileEmbedding *pgvector.Vector + ProfileEmbeddingModel string + ID uuid.UUID +} + +// Stores the vector alongside the exact text and model that produced it, so a +// later run can tell whether a row is stale. updated_at is deliberately left +// alone: the coaster itself did not change, only its derived embedding. +func (q *Queries) SetCoasterEmbedding(ctx context.Context, arg SetCoasterEmbeddingParams) error { + _, err := q.db.Exec(ctx, setCoasterEmbedding, + arg.SearchProfile, + arg.ProfileEmbedding, + arg.ProfileEmbeddingModel, + arg.ID, + ) + return err +} diff --git a/backend/internal/gen/db/querier.go b/backend/internal/gen/db/querier.go index 786ad98..3339129 100644 --- a/backend/internal/gen/db/querier.go +++ b/backend/internal/gen/db/querier.go @@ -21,6 +21,10 @@ type Querier interface { // improve embedding outcomes. // ListCoastersNeedingEmbedding(ctx context.Context, arg ListCoastersNeedingEmbeddingParams) ([]ListCoastersNeedingEmbeddingRow, error) + // Stores the vector alongside the exact text and model that produced it, so a + // later run can tell whether a row is stale. updated_at is deliberately left + // alone: the coaster itself did not change, only its derived embedding. + SetCoasterEmbedding(ctx context.Context, arg SetCoasterEmbeddingParams) error } var _ Querier = (*Queries)(nil) From e25e4a8fc098330cbd18a83ed99cb6e4acc4d58f Mon Sep 17 00:00:00 2001 From: jackjustus Date: Fri, 14 Aug 2026 11:25:38 -0700 Subject: [PATCH 09/20] add query to find embedded coasters --- backend/db/queries/coaster.sql | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/db/queries/coaster.sql b/backend/db/queries/coaster.sql index 91dd4c3..00e2974 100644 --- a/backend/db/queries/coaster.sql +++ b/backend/db/queries/coaster.sql @@ -18,6 +18,15 @@ WHERE coasters.profile_embedding IS NULL ORDER BY coasters.id LIMIT @row_limit; +-- Nearest neighbours to an embedded query vector, closest first. +-- <=> is cosine distance. non-embedded coasters are excluded. +-- name: SearchCoasters :many +SELECT sqlc.embed(coasters) +FROM coasters +WHERE profile_embedding IS NOT NULL +ORDER BY profile_embedding <=> @query_embedding +LIMIT @row_limit; + -- Stores the vector alongside the exact text and model that produced it, so a -- later run can tell whether a row is stale. updated_at is deliberately left -- alone: the coaster itself did not change, only its derived embedding. From 0ff2c8777e42abb80ddcba7fe3b9fcced9c8aa00 Mon Sep 17 00:00:00 2001 From: jackjustus Date: Fri, 14 Aug 2026 11:25:47 -0700 Subject: [PATCH 10/20] coaster helper funcs --- backend/internal/models/coaster.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/backend/internal/models/coaster.go b/backend/internal/models/coaster.go index e6493d6..0c1064b 100644 --- a/backend/internal/models/coaster.go +++ b/backend/internal/models/coaster.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" "github.com/jackjustus/credittracker/backend/internal/gen/db" api "github.com/jackjustus/credittracker/backend/internal/gen/openapi" + "github.com/life4/genesis/slices" ) type Coaster struct { @@ -24,6 +25,13 @@ func (c *Coaster) Name() string { return c.name } +func (c *Coaster) ToAPI() api.Coaster { + return api.Coaster{ + Id: c.id, + Name: c.name, + } +} + func (c *Coaster) ToRide(time time.Time) api.Ride { return api.Ride{ Coaster: api.Coaster{ @@ -43,6 +51,14 @@ func NewCoaster(coaster db.Coaster) *Coaster { } } +type Coasters []*Coaster + +func (cs Coasters) ToAPI() api.Coasters { + return slices.Map(cs, func(c *Coaster) api.Coaster { + return c.ToAPI() + }) +} + type CoasterWithPark struct { coaster *Coaster park *Park From 0cb019c0db9caed9dc89a8be8aa07bb58067a397 Mon Sep 17 00:00:00 2001 From: jackjustus Date: Fri, 14 Aug 2026 11:25:55 -0700 Subject: [PATCH 11/20] dao method --- backend/internal/data/dao.go | 18 ++++++++++++++++++ backend/internal/handler/coasters.go | 15 ++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/backend/internal/data/dao.go b/backend/internal/data/dao.go index ac8a937..5c93409 100644 --- a/backend/internal/data/dao.go +++ b/backend/internal/data/dao.go @@ -11,6 +11,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/jackjustus/credittracker/backend/internal/gen/db" "github.com/jackjustus/credittracker/backend/internal/models" + "github.com/life4/genesis/slices" "github.com/pgvector/pgvector-go" ) @@ -57,6 +58,23 @@ func (dao *DAO) ListEmbeddingTargets(ctx context.Context, model string, limit in return coasters, nil } +// SearchCoasters returns the coasters whose embeddings sit closest to the given query vector. +// The caller supplies an already embedded query. +func (dao *DAO) SearchCoasters(ctx context.Context, query []float32, limit int32) (models.Coasters, error) { + embedding := pgvector.NewVector(query) + rows, err := dao.dbc.SearchCoasters(ctx, db.SearchCoastersParams{ + QueryEmbedding: &embedding, + RowLimit: limit, + }) + if err != nil { + return nil, err + } + + return slices.Map(rows, func(row db.SearchCoastersRow) *models.Coaster { + return models.NewCoaster(row.Coaster) + }), nil +} + // SetCoasterEmbedding writes a vector along with the document text and model // that produced it. The model is a parameter rather than a constant so the // data layer stays unaware of which embedder produced the vector. diff --git a/backend/internal/handler/coasters.go b/backend/internal/handler/coasters.go index 7f1c6e3..7623a40 100644 --- a/backend/internal/handler/coasters.go +++ b/backend/internal/handler/coasters.go @@ -6,6 +6,19 @@ import ( api "github.com/jackjustus/credittracker/backend/internal/gen/openapi" ) +const searchLimit = 10 + +// SearchCoaster embeds the query returns the nearest coasters. func (s Server) SearchCoaster(ctx context.Context, req api.SearchCoasterRequestObject) (api.SearchCoasterResponseObject, error) { - return api.SearchCoaster200JSONResponse{}, nil + query, err := s.embedder.EmbedOne(ctx, req.Params.Q) + if err != nil { + return nil, err + } + + coasters, err := s.dao.SearchCoasters(ctx, query, searchLimit) + if err != nil { + return nil, err + } + + return api.SearchCoaster200JSONResponse(coasters.ToAPI()), nil } From c07b096dc36c6dfecd5a2e0d0e5e21bd3394acdc Mon Sep 17 00:00:00 2001 From: jackjustus Date: Fri, 14 Aug 2026 11:26:39 -0700 Subject: [PATCH 12/20] add embed client to server --- backend/internal/handler/root.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/internal/handler/root.go b/backend/internal/handler/root.go index 9917f81..388abf1 100644 --- a/backend/internal/handler/root.go +++ b/backend/internal/handler/root.go @@ -2,12 +2,14 @@ package handler import ( "github.com/jackjustus/credittracker/backend/internal/data" + "github.com/jackjustus/credittracker/backend/internal/embed" ) type Server struct { - dao *data.DAO + dao *data.DAO + embedder *embed.Client } -func NewServer(dao *data.DAO) Server { - return Server{dao} +func NewServer(dao *data.DAO, embedder *embed.Client) Server { + return Server{dao, embedder} } From 702ef8f583a0bd722c37ca23517376d5a942143a Mon Sep 17 00:00:00 2001 From: jackjustus Date: Fri, 14 Aug 2026 11:26:46 -0700 Subject: [PATCH 13/20] add embed client to server --- backend/cmd/api/main.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 1c583b4..aa8d2f5 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/jackjustus/credittracker/backend/internal/data" + "github.com/jackjustus/credittracker/backend/internal/embed" api "github.com/jackjustus/credittracker/backend/internal/gen/openapi" "github.com/jackjustus/credittracker/backend/internal/handler" "github.com/labstack/echo/v4" @@ -17,7 +18,12 @@ func main() { fmt.Print(err) return } - r := handler.NewServer(dao) + embedder, err := embed.NewClient() + if err != nil { + fmt.Print(err) + return + } + r := handler.NewServer(dao, embedder) e := echo.New() e.HTTPErrorHandler = func(err error, c echo.Context) { c.Logger().Errorf("%s %s: %v", c.Request().Method, c.Path(), err) From 51e659eaaf9ca1e8034b4745ba34a36103a3e73b Mon Sep 17 00:00:00 2001 From: jackjustus Date: Fri, 14 Aug 2026 11:27:08 -0700 Subject: [PATCH 14/20] use log.fatal instead --- backend/cmd/api/main.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index aa8d2f5..944e8f5 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -2,7 +2,7 @@ package main import ( "context" - "fmt" + "log" "github.com/jackjustus/credittracker/backend/internal/data" "github.com/jackjustus/credittracker/backend/internal/embed" @@ -15,12 +15,12 @@ func main() { ctx := context.Background() dao, err := data.NewDAO(ctx) if err != nil { - fmt.Print(err) + log.Fatal(err) return } embedder, err := embed.NewClient() if err != nil { - fmt.Print(err) + log.Fatal(err) return } r := handler.NewServer(dao, embedder) @@ -36,7 +36,7 @@ func main() { )) err = e.Start(":8080") if err != nil { - fmt.Print(err) + log.Fatal(err) return } } From e335f7d6eb8352ac5b7ee3ccf819a10e038b6017 Mon Sep 17 00:00:00 2001 From: jackjustus Date: Fri, 14 Aug 2026 11:27:15 -0700 Subject: [PATCH 15/20] generated files --- backend/internal/gen/db/coaster.sql.go | 54 ++++++++++++++++++++++++++ backend/internal/gen/db/querier.go | 5 +++ 2 files changed, 59 insertions(+) diff --git a/backend/internal/gen/db/coaster.sql.go b/backend/internal/gen/db/coaster.sql.go index 2d588d2..fa21ad0 100644 --- a/backend/internal/gen/db/coaster.sql.go +++ b/backend/internal/gen/db/coaster.sql.go @@ -108,6 +108,60 @@ func (q *Queries) ListCoastersNeedingEmbedding(ctx context.Context, arg ListCoas return items, nil } +const searchCoasters = `-- name: SearchCoasters :many +SELECT coasters.id, coasters.park_id, coasters.name, coasters.manufactured_at, coasters.external_id, coasters.external_source, coasters.created_at, coasters.updated_at, coasters.search_profile, coasters.profile_embedding, coasters.profile_embedding_model, coasters.profile_embedded_at +FROM coasters +WHERE profile_embedding IS NOT NULL +ORDER BY profile_embedding <=> $1 +LIMIT $2 +` + +type SearchCoastersParams struct { + QueryEmbedding *pgvector.Vector + RowLimit int32 +} + +type SearchCoastersRow struct { + Coaster Coaster +} + +// Nearest neighbours to an already-embedded query string, closest first. +// <=> is cosine distance, so an index added later must use vector_cosine_ops +// to be eligible. Rows with no embedding are excluded rather than sorted last: +// NULL would sort to the end anyway, but only after being compared. +func (q *Queries) SearchCoasters(ctx context.Context, arg SearchCoastersParams) ([]SearchCoastersRow, error) { + rows, err := q.db.Query(ctx, searchCoasters, arg.QueryEmbedding, arg.RowLimit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SearchCoastersRow{} + for rows.Next() { + var i SearchCoastersRow + if err := rows.Scan( + &i.Coaster.ID, + &i.Coaster.ParkID, + &i.Coaster.Name, + &i.Coaster.ManufacturedAt, + &i.Coaster.ExternalID, + &i.Coaster.ExternalSource, + &i.Coaster.CreatedAt, + &i.Coaster.UpdatedAt, + &i.Coaster.SearchProfile, + &i.Coaster.ProfileEmbedding, + &i.Coaster.ProfileEmbeddingModel, + &i.Coaster.ProfileEmbeddedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const setCoasterEmbedding = `-- name: SetCoasterEmbedding :exec UPDATE coasters SET search_profile = $1::text, diff --git a/backend/internal/gen/db/querier.go b/backend/internal/gen/db/querier.go index 3339129..b3383eb 100644 --- a/backend/internal/gen/db/querier.go +++ b/backend/internal/gen/db/querier.go @@ -21,6 +21,11 @@ type Querier interface { // improve embedding outcomes. // ListCoastersNeedingEmbedding(ctx context.Context, arg ListCoastersNeedingEmbeddingParams) ([]ListCoastersNeedingEmbeddingRow, error) + // Nearest neighbours to an already-embedded query string, closest first. + // <=> is cosine distance, so an index added later must use vector_cosine_ops + // to be eligible. Rows with no embedding are excluded rather than sorted last: + // NULL would sort to the end anyway, but only after being compared. + SearchCoasters(ctx context.Context, arg SearchCoastersParams) ([]SearchCoastersRow, error) // Stores the vector alongside the exact text and model that produced it, so a // later run can tell whether a row is stale. updated_at is deliberately left // alone: the coaster itself did not change, only its derived embedding. From 6f1ebb83616f22ee7caf939bd5ea48ae4eb891d7 Mon Sep 17 00:00:00 2001 From: jackjustus Date: Fri, 14 Aug 2026 17:17:32 -0700 Subject: [PATCH 16/20] cleanup --- backend/db/queries/coaster.sql | 5 ++--- backend/internal/data/dao.go | 3 +-- backend/internal/embed/client.go | 5 +---- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/backend/db/queries/coaster.sql b/backend/db/queries/coaster.sql index 00e2974..41d6dca 100644 --- a/backend/db/queries/coaster.sql +++ b/backend/db/queries/coaster.sql @@ -28,9 +28,8 @@ ORDER BY profile_embedding <=> @query_embedding LIMIT @row_limit; -- Stores the vector alongside the exact text and model that produced it, so a --- later run can tell whether a row is stale. updated_at is deliberately left --- alone: the coaster itself did not change, only its derived embedding. --- name: SetCoasterEmbedding :exec +-- later run can tell whether a row is stale. +-- rather than changing updated_at, we write to embedded_at. (core coaster metadata isn't changing) UPDATE coasters SET search_profile = @search_profile::text, profile_embedding = @profile_embedding, diff --git a/backend/internal/data/dao.go b/backend/internal/data/dao.go index 937d429..7e3d8ca 100644 --- a/backend/internal/data/dao.go +++ b/backend/internal/data/dao.go @@ -81,8 +81,7 @@ func (dao *DAO) SearchCoasters(ctx context.Context, query []float32, limit int32 } // SetCoasterEmbedding writes a vector along with the document text and model -// that produced it. The model is a parameter rather than a constant so the -// data layer stays unaware of which embedder produced the vector. +// that produced it. func (dao *DAO) SetCoasterEmbedding(ctx context.Context, coasterID uuid.UUID, doc, model string, vec []float32) error { embedding := pgvector.NewVector(vec) return dao.dbc.SetCoasterEmbedding(ctx, db.SetCoasterEmbeddingParams{ diff --git a/backend/internal/embed/client.go b/backend/internal/embed/client.go index f4d6603..f3f6a4c 100644 --- a/backend/internal/embed/client.go +++ b/backend/internal/embed/client.go @@ -14,7 +14,6 @@ import ( // Ollama's default listen address, which is right for anything run on the host // (`go run ./cmd/embedder`). Containers cannot reach the daemon this way and // must set OLLAMA_URL to host.docker.internal, as compose.yaml does. - const defaultURL = "http://localhost:11434" const ( @@ -63,7 +62,6 @@ func (c *Client) Embed(ctx context.Context, docs []string) ([][]float32, error) if err != nil { return nil, err } - //req.Header.Set("Content-Type", "application/json") res, err := c.httpc.Do(req) if err != nil { @@ -83,8 +81,7 @@ func (c *Client) Embed(ctx context.Context, docs []string) ([][]float32, error) if res.StatusCode != http.StatusOK { return nil, fmt.Errorf("ollama returned %s: %s", res.Status, parsed.Error) } - // Callers zip these against the docs they sent, so a short response would - // silently write one coaster's embedding onto another's row. + if len(parsed.Embeddings) != len(docs) { return nil, fmt.Errorf("asked for %d embeddings, got %d", len(docs), len(parsed.Embeddings)) } From c509285714c530346fc65848074c2cc80b264163 Mon Sep 17 00:00:00 2001 From: jackjustus Date: Fri, 14 Aug 2026 17:20:22 -0700 Subject: [PATCH 17/20] fix lint issue --- backend/internal/embed/client.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/internal/embed/client.go b/backend/internal/embed/client.go index f3f6a4c..2ed9b17 100644 --- a/backend/internal/embed/client.go +++ b/backend/internal/embed/client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "os" "time" @@ -67,7 +68,13 @@ func (c *Client) Embed(ctx context.Context, docs []string) ([][]float32, error) if err != nil { return nil, err } - defer res.Body.Close() + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + log.Fatal(err) + return + } + }(res.Body) raw, err := io.ReadAll(res.Body) if err != nil { From ec74d6a0a8ff56de4ce5d2bfc502370b0f2575db Mon Sep 17 00:00:00 2001 From: jackjustus Date: Fri, 14 Aug 2026 17:28:43 -0700 Subject: [PATCH 18/20] update gen code --- backend/internal/gen/db/coaster.sql.go | 10 ++++------ backend/internal/gen/db/querier.go | 10 ++++------ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/backend/internal/gen/db/coaster.sql.go b/backend/internal/gen/db/coaster.sql.go index fa21ad0..8db36fb 100644 --- a/backend/internal/gen/db/coaster.sql.go +++ b/backend/internal/gen/db/coaster.sql.go @@ -125,10 +125,8 @@ type SearchCoastersRow struct { Coaster Coaster } -// Nearest neighbours to an already-embedded query string, closest first. -// <=> is cosine distance, so an index added later must use vector_cosine_ops -// to be eligible. Rows with no embedding are excluded rather than sorted last: -// NULL would sort to the end anyway, but only after being compared. +// Nearest neighbours to an embedded query vector, closest first. +// <=> is cosine distance. non-embedded coasters are excluded. func (q *Queries) SearchCoasters(ctx context.Context, arg SearchCoastersParams) ([]SearchCoastersRow, error) { rows, err := q.db.Query(ctx, searchCoasters, arg.QueryEmbedding, arg.RowLimit) if err != nil { @@ -179,8 +177,8 @@ type SetCoasterEmbeddingParams struct { } // Stores the vector alongside the exact text and model that produced it, so a -// later run can tell whether a row is stale. updated_at is deliberately left -// alone: the coaster itself did not change, only its derived embedding. +// later run can tell whether a row is stale. +// rather than changing updated_at, we write to embedded_at. (core coaster metadata isn't changing) func (q *Queries) SetCoasterEmbedding(ctx context.Context, arg SetCoasterEmbeddingParams) error { _, err := q.db.Exec(ctx, setCoasterEmbedding, arg.SearchProfile, diff --git a/backend/internal/gen/db/querier.go b/backend/internal/gen/db/querier.go index bd60596..37a83e8 100644 --- a/backend/internal/gen/db/querier.go +++ b/backend/internal/gen/db/querier.go @@ -21,14 +21,12 @@ type Querier interface { // improve embedding outcomes. // ListCoastersNeedingEmbedding(ctx context.Context, arg ListCoastersNeedingEmbeddingParams) ([]ListCoastersNeedingEmbeddingRow, error) - // Nearest neighbours to an already-embedded query string, closest first. - // <=> is cosine distance, so an index added later must use vector_cosine_ops - // to be eligible. Rows with no embedding are excluded rather than sorted last: - // NULL would sort to the end anyway, but only after being compared. + // Nearest neighbours to an embedded query vector, closest first. + // <=> is cosine distance. non-embedded coasters are excluded. SearchCoasters(ctx context.Context, arg SearchCoastersParams) ([]SearchCoastersRow, error) // Stores the vector alongside the exact text and model that produced it, so a - // later run can tell whether a row is stale. updated_at is deliberately left - // alone: the coaster itself did not change, only its derived embedding. + // later run can tell whether a row is stale. + // rather than changing updated_at, we write to embedded_at. (core coaster metadata isn't changing) SetCoasterEmbedding(ctx context.Context, arg SetCoasterEmbeddingParams) error } From 260de1cecb43453c010c1182dd86f1ae98b77042 Mon Sep 17 00:00:00 2001 From: jackjustus Date: Fri, 14 Aug 2026 17:29:07 -0700 Subject: [PATCH 19/20] fix sqlc comment --- backend/db/queries/coaster.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/db/queries/coaster.sql b/backend/db/queries/coaster.sql index 41d6dca..0e10e0a 100644 --- a/backend/db/queries/coaster.sql +++ b/backend/db/queries/coaster.sql @@ -30,6 +30,7 @@ LIMIT @row_limit; -- Stores the vector alongside the exact text and model that produced it, so a -- later run can tell whether a row is stale. -- rather than changing updated_at, we write to embedded_at. (core coaster metadata isn't changing) +-- name: SetCoasterEmbedding :exec UPDATE coasters SET search_profile = @search_profile::text, profile_embedding = @profile_embedding, From 17f756beed8f50ecb765652d978edc0e3a43f92e Mon Sep 17 00:00:00 2001 From: jackjustus Date: Fri, 14 Aug 2026 18:41:12 -0700 Subject: [PATCH 20/20] fix merge & add test ci job --- .github/workflows/ci.yml | 13 +++++++++++++ Makefile | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e96f1b1..42d3549 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,19 @@ jobs: # `make build`, not `make check`: linting belongs to the `lint` job. - run: make build + test: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version-file: backend/go.mod + cache-dependency-path: | + backend/go.sum + cli/go.sum + - run: make test + generated: name: generated code is current runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 5b535fb..a975164 100644 --- a/Makefile +++ b/Makefile @@ -54,7 +54,8 @@ build: ## Build and vet both modules cd backend && go build ./... && go vet ./... cd cli && go build ./... && go vet ./... -check: build ## Build, vet, lint, and verify generated code (mirrors CI) +check: build ## Build, vet, test, lint, and verify generated code (mirrors CI) + $(MAKE) test $(MAKE) lint $(MAKE) verify-gen