diff --git a/sdks/go/README.md b/sdks/go/README.md new file mode 100644 index 0000000..5241313 --- /dev/null +++ b/sdks/go/README.md @@ -0,0 +1,20 @@ +# Moss Go SDK + +The Go work now has the same two-layer direction as the other Moss SDKs: + +- `sdks/go/sdk/` contains the public Go SDK +- `sdks/go/bindings/` wraps the native `libmoss` runtime via CGO + +Current status: + +- bindings-backed manage operations for mutations and metadata reads +- local `LoadIndex` / `UnloadIndex` / local `Query` via `libmoss` +- examples and unit tests +- env-gated integration test scaffold + +Important note: + +- all runtime operations require the `libmoss` C SDK plus `-tags libmoss` + +The public SDK module lives under [`sdks/go/sdk/`](./sdk/), and the native +bindings module lives under [`sdks/go/bindings/`](./bindings/). diff --git a/sdks/go/bindings/README.md b/sdks/go/bindings/README.md new file mode 100644 index 0000000..18ebb01 --- /dev/null +++ b/sdks/go/bindings/README.md @@ -0,0 +1,41 @@ +# Moss Go Bindings + +This package wraps the native `libmoss` runtime for Go via CGO. + +It mirrors the role of the other language bindings packages in this repository: + +- native runtime access +- local index loading +- local query execution +- cloud-backed manage operations exposed through the native client + +## Status + +The real bindings implementation is compiled only with the `libmoss` build tag. +Without that tag, this package builds a stub that returns a clear +`ErrBindingsUnavailable` error. + +## Local build workflow + +Download the matching `libmoss` C SDK release archive for your platform from: + +- + +For Linux `x86_64`, extract the archive somewhere local so you have: + +```text +/ +├── include/libmoss.h +└── lib/libmoss.so +``` + +Then build with: + +```bash +export CGO_CFLAGS="-I/include" +export CGO_LDFLAGS="-L/lib" +export LD_LIBRARY_PATH="/lib" +go test -tags libmoss ./... +``` + +The Go SDK module can then be built with the same flags and tag. diff --git a/sdks/go/bindings/errors.go b/sdks/go/bindings/errors.go new file mode 100644 index 0000000..8c0e91a --- /dev/null +++ b/sdks/go/bindings/errors.go @@ -0,0 +1,5 @@ +package mosscore + +import "errors" + +var ErrBindingsUnavailable = errors.New("mosscore: libmoss bindings are unavailable; build with -tags libmoss and configure the libmoss C SDK") diff --git a/sdks/go/bindings/go.mod b/sdks/go/bindings/go.mod new file mode 100644 index 0000000..d34cdcc --- /dev/null +++ b/sdks/go/bindings/go.mod @@ -0,0 +1,3 @@ +module github.com/usemoss/moss/sdks/go/bindings + +go 1.22.2 diff --git a/sdks/go/bindings/libmoss.go b/sdks/go/bindings/libmoss.go new file mode 100644 index 0000000..29e8607 --- /dev/null +++ b/sdks/go/bindings/libmoss.go @@ -0,0 +1,597 @@ +//go:build libmoss + +package mosscore + +/* +#cgo linux LDFLAGS: -lmoss -ldl -lm -lpthread +#cgo darwin LDFLAGS: -lmoss -lc++ +#cgo windows LDFLAGS: -lmoss +#include +#include +*/ +import "C" + +import ( + "fmt" + "runtime" + "sync" + "unsafe" +) + +type ManageClient struct { + ptr *C.MossClient +} + +type IndexManager struct { + ptr *C.MossClient + mu sync.RWMutex + loaded map[string]struct{} +} + +func NewManageClient(projectID, projectKey string) (*ManageClient, error) { + ptr, err := newCClient(projectID, projectKey) + if err != nil { + return nil, err + } + client := &ManageClient{ptr: ptr} + runtime.SetFinalizer(client, func(c *ManageClient) { _ = c.Close() }) + return client, nil +} + +func (c *ManageClient) Close() error { + if c == nil || c.ptr == nil { + return nil + } + C.moss_client_free(c.ptr) + c.ptr = nil + return nil +} + +func (c *ManageClient) CreateIndex(name string, docs []DocumentInfo, modelID string) (MutationResult, error) { + input, err := newCDocumentInput(docs) + if err != nil { + return MutationResult{}, err + } + defer input.free() + + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + + var cModelID *C.char + if modelID != "" { + cModelID = C.CString(modelID) + defer C.free(unsafe.Pointer(cModelID)) + } + + var out *C.MossMutationResult + result := C.moss_client_create_index(c.ptr, cName, input.ptr(), C.uintptr_t(len(docs)), cModelID, &out) + if err := checkResult(result); err != nil { + return MutationResult{}, err + } + defer C.moss_free_mutation_result(out) + + return MutationResult{ + JobID: goString(out.job_id), + IndexName: goString(out.index_name), + DocCount: int(out.doc_count), + }, nil +} + +func (c *ManageClient) AddDocs(name string, docs []DocumentInfo, options *MutationOptions) (MutationResult, error) { + input, err := newCDocumentInput(docs) + if err != nil { + return MutationResult{}, err + } + defer input.free() + + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + + var out *C.MossMutationResult + var cOpts *C.MossMutationOptions + if options != nil && options.Upsert != nil { + cOpts = &C.MossMutationOptions{upsert: C.bool(*options.Upsert)} + } + + result := C.moss_client_add_docs(c.ptr, cName, input.ptr(), C.uintptr_t(len(docs)), cOpts, &out) + if err := checkResult(result); err != nil { + return MutationResult{}, err + } + defer C.moss_free_mutation_result(out) + + return MutationResult{ + JobID: goString(out.job_id), + IndexName: goString(out.index_name), + DocCount: int(out.doc_count), + }, nil +} + +func (c *ManageClient) DeleteDocs(name string, docIDs []string) (MutationResult, error) { + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + + ids := newCStringArray(docIDs) + defer ids.free() + + var out *C.MossMutationResult + result := C.moss_client_delete_docs(c.ptr, cName, ids.ptr(), C.uintptr_t(len(docIDs)), &out) + if err := checkResult(result); err != nil { + return MutationResult{}, err + } + defer C.moss_free_mutation_result(out) + + return MutationResult{ + JobID: goString(out.job_id), + IndexName: goString(out.index_name), + DocCount: int(out.doc_count), + }, nil +} + +func (c *ManageClient) GetJobStatus(jobID string) (JobStatusResponse, error) { + cJobID := C.CString(jobID) + defer C.free(unsafe.Pointer(cJobID)) + + var out *C.MossJobStatusResponse + result := C.moss_client_get_job_status(c.ptr, cJobID, &out) + if err := checkResult(result); err != nil { + return JobStatusResponse{}, err + } + defer C.moss_free_job_status_response(out) + + return JobStatusResponse{ + JobID: goString(out.job_id), + Status: goString(out.status), + Progress: float64(out.progress), + CurrentPhase: goOptionalString(out.current_phase), + Error: goOptionalString(out.error), + CreatedAt: goString(out.created_at), + UpdatedAt: goString(out.updated_at), + CompletedAt: goOptionalString(out.completed_at), + }, nil +} + +func (c *ManageClient) GetIndex(name string) (IndexInfo, error) { + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + + var out *C.MossIndexInfo + result := C.moss_client_get_index(c.ptr, cName, &out) + if err := checkResult(result); err != nil { + return IndexInfo{}, err + } + defer C.moss_free_index_info(out) + + return convertIndexInfo(out), nil +} + +func (c *ManageClient) ListIndexes() ([]IndexInfo, error) { + var out *C.MossIndexInfo + var count C.uintptr_t + result := C.moss_client_list_indexes(c.ptr, &out, &count) + if err := checkResult(result); err != nil { + return nil, err + } + defer C.moss_free_index_info_list(out, count) + + items := unsafe.Slice(out, int(count)) + response := make([]IndexInfo, 0, len(items)) + for i := range items { + response = append(response, convertIndexInfo(&items[i])) + } + return response, nil +} + +func (c *ManageClient) DeleteIndex(name string) (bool, error) { + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + + var deleted C.bool + result := C.moss_client_delete_index(c.ptr, cName, &deleted) + if err := checkResult(result); err != nil { + return false, err + } + return bool(deleted), nil +} + +func (c *ManageClient) GetDocs(name string, docIDs []string) ([]DocumentInfo, error) { + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + + ids := newCStringArray(docIDs) + defer ids.free() + + var out *C.MossDocumentInfo + var count C.uintptr_t + result := C.moss_client_get_docs(c.ptr, cName, ids.ptr(), C.uintptr_t(len(docIDs)), &out, &count) + if err := checkResult(result); err != nil { + return nil, err + } + defer C.moss_free_documents(out, count) + + return convertDocuments(out, count), nil +} + +func NewIndexManager(projectID, projectKey string) (*IndexManager, error) { + ptr, err := newCClient(projectID, projectKey) + if err != nil { + return nil, err + } + manager := &IndexManager{ + ptr: ptr, + loaded: map[string]struct{}{}, + } + runtime.SetFinalizer(manager, func(m *IndexManager) { _ = m.Close() }) + return manager, nil +} + +func (m *IndexManager) Close() error { + if m == nil || m.ptr == nil { + return nil + } + C.moss_client_free(m.ptr) + m.ptr = nil + return nil +} + +func (m *IndexManager) LoadIndex(indexName string, options *LoadIndexOptions) (IndexInfo, error) { + cName := C.CString(indexName) + defer C.free(unsafe.Pointer(cName)) + + var out *C.MossIndexInfo + var cOpts *C.MossLoadIndexOptions + if options != nil { + cOpts = &C.MossLoadIndexOptions{ + auto_refresh: C.bool(options.AutoRefresh), + polling_interval_secs: C.uint64_t(options.PollingIntervalInSeconds), + } + } + + result := C.moss_client_load_index(m.ptr, cName, cOpts, &out) + if err := checkResult(result); err != nil { + return IndexInfo{}, err + } + defer C.moss_free_index_info(out) + + m.mu.Lock() + m.loaded[indexName] = struct{}{} + m.mu.Unlock() + return convertIndexInfo(out), nil +} + +func (m *IndexManager) UnloadIndex(indexName string) error { + cName := C.CString(indexName) + defer C.free(unsafe.Pointer(cName)) + + result := C.moss_client_unload_index(m.ptr, cName) + if err := checkResult(result); err != nil { + return err + } + m.mu.Lock() + delete(m.loaded, indexName) + m.mu.Unlock() + return nil +} + +func (m *IndexManager) HasIndex(indexName string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + _, ok := m.loaded[indexName] + return ok +} + +func (m *IndexManager) Query(indexName, query string, queryEmbedding []float32, topK int, alpha float32, filterJSON *string) (SearchResult, error) { + return m.query(indexName, query, queryEmbedding, topK, alpha, filterJSON) +} + +func (m *IndexManager) QueryText(indexName, query string, topK int, alpha float32, filterJSON *string) (SearchResult, error) { + return m.query(indexName, query, nil, topK, alpha, filterJSON) +} + +func (m *IndexManager) LoadQueryModel(indexName string) error { + return nil +} + +func (m *IndexManager) RefreshIndex(indexName string) (RefreshResult, error) { + cName := C.CString(indexName) + defer C.free(unsafe.Pointer(cName)) + + var out *C.MossRefreshResult + result := C.moss_client_refresh_index(m.ptr, cName, &out) + if err := checkResult(result); err != nil { + return RefreshResult{}, err + } + defer C.moss_free_refresh_result(out) + + return RefreshResult{ + IndexName: goString(out.index_name), + PreviousUpdatedAt: goString(out.previous_updated_at), + NewUpdatedAt: goString(out.new_updated_at), + WasUpdated: bool(out.was_updated), + }, nil +} + +func (m *IndexManager) GetIndexInfo(indexName string) (IndexInfo, error) { + cName := C.CString(indexName) + defer C.free(unsafe.Pointer(cName)) + + var out *C.MossIndexInfo + result := C.moss_client_get_index(m.ptr, cName, &out) + if err := checkResult(result); err != nil { + return IndexInfo{}, err + } + defer C.moss_free_index_info(out) + + return convertIndexInfo(out), nil +} + +func (m *IndexManager) query(indexName, query string, queryEmbedding []float32, topK int, alpha float32, filterJSON *string) (SearchResult, error) { + cName := C.CString(indexName) + defer C.free(unsafe.Pointer(cName)) + cQuery := C.CString(query) + defer C.free(unsafe.Pointer(cQuery)) + + var cFilter *C.char + if filterJSON != nil { + cFilter = C.CString(*filterJSON) + defer C.free(unsafe.Pointer(cFilter)) + } + + var embeddingPtr *C.float + var embeddingMem unsafe.Pointer + if len(queryEmbedding) > 0 { + embeddingMem = C.malloc(C.size_t(len(queryEmbedding)) * C.size_t(unsafe.Sizeof(C.float(0)))) + defer C.free(embeddingMem) + embeddingSlice := unsafe.Slice((*C.float)(embeddingMem), len(queryEmbedding)) + for i, value := range queryEmbedding { + embeddingSlice[i] = C.float(value) + } + embeddingPtr = (*C.float)(embeddingMem) + } + + optsMem := C.malloc(C.size_t(unsafe.Sizeof(C.MossQueryOptions{}))) + defer C.free(optsMem) + opts := (*C.MossQueryOptions)(optsMem) + *opts = C.MossQueryOptions{ + top_k: C.uintptr_t(topK), + alpha: C.float(alpha), + filter_json: cFilter, + embedding: embeddingPtr, + embedding_dim: C.uintptr_t(len(queryEmbedding)), + } + + var out *C.MossSearchResult + result := C.moss_client_query(m.ptr, cName, cQuery, opts, &out) + if err := checkResult(result); err != nil { + return SearchResult{}, err + } + defer C.moss_free_search_result(out) + + docs := make([]QueryResultDocumentInfo, 0, int(out.doc_count)) + items := unsafe.Slice(out.docs, int(out.doc_count)) + for i := range items { + item := items[i] + docs = append(docs, QueryResultDocumentInfo{ + ID: goString(item.id), + Text: goString(item.text), + Metadata: convertMetadata(item.metadata, item.metadata_count), + Score: float64(item.score), + }) + } + + return SearchResult{ + Docs: docs, + Query: goString(out.query), + IndexName: goOptionalString(out.index_name), + TimeTakenMs: int(out.time_taken_ms), + }, nil +} + +func newCClient(projectID, projectKey string) (*C.MossClient, error) { + cProjectID := C.CString(projectID) + defer C.free(unsafe.Pointer(cProjectID)) + cProjectKey := C.CString(projectKey) + defer C.free(unsafe.Pointer(cProjectKey)) + + var out *C.MossClient + result := C.moss_client_new(cProjectID, cProjectKey, &out) + if err := checkResult(result); err != nil { + return nil, err + } + return out, nil +} + +func checkResult(result C.MossResult) error { + if result == C.OK { + return nil + } + + message := "libmoss call failed" + if value := C.moss_last_error(); value != nil { + message = C.GoString(value) + } + + return fmt.Errorf("mosscore: %s (code %d)", message, int32(result)) +} + +func convertIndexInfo(info *C.MossIndexInfo) IndexInfo { + return IndexInfo{ + ID: goString(info.id), + Name: goString(info.name), + Version: goOptionalString(info.version), + Status: goString(info.status), + DocCount: int(info.doc_count), + CreatedAt: goOptionalString(info.created_at), + UpdatedAt: goOptionalString(info.updated_at), + Model: ModelRef{ + ID: goString(info.model.id), + Version: goOptionalString(info.model.version), + }, + } +} + +func convertDocuments(out *C.MossDocumentInfo, count C.uintptr_t) []DocumentInfo { + items := unsafe.Slice(out, int(count)) + response := make([]DocumentInfo, 0, len(items)) + for i := range items { + item := items[i] + var embedding []float32 + if item.embedding != nil && item.embedding_dim > 0 { + embedding = make([]float32, int(item.embedding_dim)) + values := unsafe.Slice(item.embedding, int(item.embedding_dim)) + for j := range values { + embedding[j] = float32(values[j]) + } + } + response = append(response, DocumentInfo{ + ID: goString(item.id), + Text: goString(item.text), + Metadata: convertMetadata(item.metadata, item.metadata_count), + Embedding: embedding, + }) + } + return response +} + +func convertMetadata(entries *C.MossMetadataEntry, count C.uintptr_t) map[string]string { + if entries == nil || count == 0 { + return nil + } + items := unsafe.Slice(entries, int(count)) + response := make(map[string]string, len(items)) + for i := range items { + response[goString(items[i].key)] = goString(items[i].value) + } + return response +} + +func goString(value *C.char) string { + if value == nil { + return "" + } + return C.GoString(value) +} + +func goOptionalString(value *C.char) *string { + if value == nil { + return nil + } + v := C.GoString(value) + return &v +} + +type cDocumentInput struct { + docs *C.MossDocumentInfo + count int + allocations []unsafe.Pointer + strings []*C.char +} + +func newCDocumentInput(docs []DocumentInfo) (*cDocumentInput, error) { + input := &cDocumentInput{ + count: len(docs), + } + if len(docs) == 0 { + return input, nil + } + + docsMem := C.malloc(C.size_t(len(docs)) * C.size_t(unsafe.Sizeof(C.MossDocumentInfo{}))) + input.allocations = append(input.allocations, docsMem) + input.docs = (*C.MossDocumentInfo)(docsMem) + docSlice := unsafe.Slice(input.docs, len(docs)) + + for i, doc := range docs { + cID := C.CString(doc.ID) + cText := C.CString(doc.Text) + input.strings = append(input.strings, cID, cText) + + var metadataPtr *C.MossMetadataEntry + if len(doc.Metadata) > 0 { + metaMem := C.malloc(C.size_t(len(doc.Metadata)) * C.size_t(unsafe.Sizeof(C.MossMetadataEntry{}))) + input.allocations = append(input.allocations, metaMem) + metaSlice := unsafe.Slice((*C.MossMetadataEntry)(metaMem), len(doc.Metadata)) + metaIndex := 0 + for key, value := range doc.Metadata { + cKey := C.CString(key) + cValue := C.CString(value) + input.strings = append(input.strings, cKey, cValue) + metaSlice[metaIndex] = C.MossMetadataEntry{ + key: cKey, + value: cValue, + } + metaIndex++ + } + metadataPtr = (*C.MossMetadataEntry)(metaMem) + } + + var embeddingPtr *C.float + if len(doc.Embedding) > 0 { + embeddingMem := C.malloc(C.size_t(len(doc.Embedding)) * C.size_t(unsafe.Sizeof(C.float(0)))) + input.allocations = append(input.allocations, embeddingMem) + embeddingSlice := unsafe.Slice((*C.float)(embeddingMem), len(doc.Embedding)) + for j, value := range doc.Embedding { + embeddingSlice[j] = C.float(value) + } + embeddingPtr = (*C.float)(embeddingMem) + } + + docSlice[i] = C.MossDocumentInfo{ + id: cID, + text: cText, + metadata: metadataPtr, + metadata_count: C.uintptr_t(len(doc.Metadata)), + embedding: embeddingPtr, + embedding_dim: C.uintptr_t(len(doc.Embedding)), + } + } + + return input, nil +} + +func (i *cDocumentInput) ptr() *C.MossDocumentInfo { + return i.docs +} + +func (i *cDocumentInput) free() { + for _, ptr := range i.allocations { + C.free(ptr) + } + for _, value := range i.strings { + C.free(unsafe.Pointer(value)) + } +} + +type cStringArray struct { + valuesPtr **C.char + count int + strings []*C.char + mem unsafe.Pointer +} + +func newCStringArray(values []string) *cStringArray { + array := &cStringArray{count: len(values)} + if len(values) == 0 { + return array + } + array.mem = C.malloc(C.size_t(len(values)) * C.size_t(unsafe.Sizeof((*C.char)(nil)))) + array.valuesPtr = (**C.char)(array.mem) + items := unsafe.Slice(array.valuesPtr, len(values)) + for i, value := range values { + cValue := C.CString(value) + array.strings = append(array.strings, cValue) + items[i] = cValue + } + return array +} + +func (a *cStringArray) ptr() **C.char { + return a.valuesPtr +} + +func (a *cStringArray) free() { + if a.mem != nil { + C.free(a.mem) + } + for _, value := range a.strings { + C.free(unsafe.Pointer(value)) + } +} diff --git a/sdks/go/bindings/models.go b/sdks/go/bindings/models.go new file mode 100644 index 0000000..3366709 --- /dev/null +++ b/sdks/go/bindings/models.go @@ -0,0 +1,71 @@ +package mosscore + +type DocumentInfo struct { + ID string + Text string + Metadata map[string]string + Embedding []float32 +} + +type MutationOptions struct { + Upsert *bool +} + +type MutationResult struct { + JobID string + IndexName string + DocCount int +} + +type ModelRef struct { + ID string + Version *string +} + +type IndexInfo struct { + ID string + Name string + Version *string + Status string + DocCount int + CreatedAt *string + UpdatedAt *string + Model ModelRef +} + +type JobStatusResponse struct { + JobID string + Status string + Progress float64 + CurrentPhase *string + Error *string + CreatedAt string + UpdatedAt string + CompletedAt *string +} + +type LoadIndexOptions struct { + AutoRefresh bool + PollingIntervalInSeconds uint64 +} + +type QueryResultDocumentInfo struct { + ID string + Text string + Metadata map[string]string + Score float64 +} + +type SearchResult struct { + Docs []QueryResultDocumentInfo + Query string + IndexName *string + TimeTakenMs int +} + +type RefreshResult struct { + IndexName string + PreviousUpdatedAt string + NewUpdatedAt string + WasUpdated bool +} diff --git a/sdks/go/bindings/stub.go b/sdks/go/bindings/stub.go new file mode 100644 index 0000000..dc32277 --- /dev/null +++ b/sdks/go/bindings/stub.go @@ -0,0 +1,83 @@ +//go:build !libmoss + +package mosscore + +type ManageClient struct{} + +func NewManageClient(projectID, projectKey string) (*ManageClient, error) { + return nil, ErrBindingsUnavailable +} + +func (c *ManageClient) Close() error { return nil } + +func (c *ManageClient) CreateIndex(name string, docs []DocumentInfo, modelID string) (MutationResult, error) { + return MutationResult{}, ErrBindingsUnavailable +} + +func (c *ManageClient) AddDocs(name string, docs []DocumentInfo, options *MutationOptions) (MutationResult, error) { + return MutationResult{}, ErrBindingsUnavailable +} + +func (c *ManageClient) DeleteDocs(name string, docIDs []string) (MutationResult, error) { + return MutationResult{}, ErrBindingsUnavailable +} + +func (c *ManageClient) GetJobStatus(jobID string) (JobStatusResponse, error) { + return JobStatusResponse{}, ErrBindingsUnavailable +} + +func (c *ManageClient) GetIndex(name string) (IndexInfo, error) { + return IndexInfo{}, ErrBindingsUnavailable +} + +func (c *ManageClient) ListIndexes() ([]IndexInfo, error) { + return nil, ErrBindingsUnavailable +} + +func (c *ManageClient) DeleteIndex(name string) (bool, error) { + return false, ErrBindingsUnavailable +} + +func (c *ManageClient) GetDocs(name string, docIDs []string) ([]DocumentInfo, error) { + return nil, ErrBindingsUnavailable +} + +type IndexManager struct{} + +func NewIndexManager(projectID, projectKey string) (*IndexManager, error) { + return nil, ErrBindingsUnavailable +} + +func (m *IndexManager) Close() error { return nil } + +func (m *IndexManager) LoadIndex(indexName string, options *LoadIndexOptions) (IndexInfo, error) { + return IndexInfo{}, ErrBindingsUnavailable +} + +func (m *IndexManager) UnloadIndex(indexName string) error { + return ErrBindingsUnavailable +} + +func (m *IndexManager) HasIndex(indexName string) bool { + return false +} + +func (m *IndexManager) Query(indexName, query string, queryEmbedding []float32, topK int, alpha float32, filterJSON *string) (SearchResult, error) { + return SearchResult{}, ErrBindingsUnavailable +} + +func (m *IndexManager) QueryText(indexName, query string, topK int, alpha float32, filterJSON *string) (SearchResult, error) { + return SearchResult{}, ErrBindingsUnavailable +} + +func (m *IndexManager) LoadQueryModel(indexName string) error { + return ErrBindingsUnavailable +} + +func (m *IndexManager) RefreshIndex(indexName string) (RefreshResult, error) { + return RefreshResult{}, ErrBindingsUnavailable +} + +func (m *IndexManager) GetIndexInfo(indexName string) (IndexInfo, error) { + return IndexInfo{}, ErrBindingsUnavailable +} diff --git a/sdks/go/sdk/README.md b/sdks/go/sdk/README.md new file mode 100644 index 0000000..7b3e7b2 --- /dev/null +++ b/sdks/go/sdk/README.md @@ -0,0 +1,167 @@ +# Moss client library for Go + +`moss` provides a typed Go client for Moss semantic search workflows. + +The Go SDK now has two layers: + +- a public SDK in `sdks/go/sdk` +- native `libmoss` bindings in `sdks/go/bindings` + +## Features + +- typed Go client and models +- bindings-backed index creation and document mutation +- bindings-backed index metadata and document reads +- local index loading and query via native bindings +- optional caller-provided embeddings for custom indexes +- env-gated live integration tests + +## Current limitations + +- the SDK requires the `libmoss` C SDK and the `libmoss` build tag for real runtime operations +- `LoadIndexOptions.CachePath` is not exposed by the current `libmoss` C API yet + +## Installation + +From this repository, use the module at: + +```go +github.com/usemoss/moss/sdks/go/sdk/moss +``` + +Download the `libmoss` C SDK release and build with `-tags libmoss`. The +bindings setup is documented in +[`../bindings/README.md`](../bindings/README.md). + +## Quick start + +```go +package main + +import ( + "context" + "fmt" + "log" + + "github.com/usemoss/moss/sdks/go/sdk/moss" +) + +func main() { + ctx := context.Background() + + client := moss.NewClient("your-project-id", "your-project-key") + defer client.Close() + + docs := []moss.DocumentInfo{ + { + ID: "doc-1", + Text: "Refunds are processed within five to seven business days.", + Metadata: map[string]string{ + "topic": "refunds", + }, + }, + { + ID: "doc-2", + Text: "Orders can be tracked from the account dashboard.", + Metadata: map[string]string{ + "topic": "shipping", + }, + }, + } + + result, err := client.CreateIndex(ctx, "support-docs", docs, nil) + if err != nil { + log.Fatal(err) + } + + fmt.Println("created job:", result.JobID) + + if _, err := client.LoadIndex(ctx, "support-docs", &moss.LoadIndexOptions{}); err != nil { + log.Fatal(err) + } + + search, err := client.Query(ctx, "support-docs", "how long do refunds take?", &moss.QueryOptions{ + TopK: 3, + }) + if err != nil { + log.Fatal(err) + } + + for _, doc := range search.Docs { + fmt.Printf("%s %.3f\n", doc.ID, doc.Score) + } +} +``` + +## Custom embeddings + +If your documents already have embeddings, omit `ModelID` and the SDK will +default to `"custom"` automatically: + +```go +docs := []moss.DocumentInfo{ + { + ID: "doc-1", + Text: "Attach a caller-provided embedding.", + Embedding: []float32{1, 0, 0, 0}, + }, + { + ID: "doc-2", + Text: "This index uses custom vectors.", + Embedding: []float32{0, 1, 0, 0}, + }, +} + +_, err := client.CreateIndex(ctx, "custom-embeddings", docs, nil) +if err != nil { + log.Fatal(err) +} + +if _, err := client.LoadIndex(ctx, "custom-embeddings", &moss.LoadIndexOptions{}); err != nil { + log.Fatal(err) +} + +results, err := client.Query(ctx, "custom-embeddings", "", &moss.QueryOptions{ + Embedding: []float32{1, 0, 0, 0}, + TopK: 5, +}) +``` + +All documents must either provide embeddings or omit them entirely in the same +batch. + +## Examples + +Runnable examples live here: + +- [`examples/basic/main.go`](./examples/basic/main.go) +- [`examples/custom-embeddings/main.go`](./examples/custom-embeddings/main.go) + +Run them with native bindings enabled: + +```bash +export CGO_CFLAGS="-I/include" +export CGO_LDFLAGS="-L/lib" +export LD_LIBRARY_PATH="/lib" +go run -tags libmoss ./examples/basic +``` + +## Integration tests + +Live tests are skipped unless both of these are set: + +```bash +export MOSS_TEST_PROJECT_ID=... +export MOSS_TEST_PROJECT_KEY=... +``` + +Then run: + +```bash +cd sdks/go/sdk +go test ./... +CGO_CFLAGS="-I/include" \ +CGO_LDFLAGS="-L/lib" \ +LD_LIBRARY_PATH="/lib" \ +go test -tags libmoss ./... +``` diff --git a/sdks/go/sdk/examples/basic/main.go b/sdks/go/sdk/examples/basic/main.go new file mode 100644 index 0000000..533c2b0 --- /dev/null +++ b/sdks/go/sdk/examples/basic/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/usemoss/moss/sdks/go/sdk/moss" +) + +func main() { + projectID := os.Getenv("MOSS_PROJECT_ID") + projectKey := os.Getenv("MOSS_PROJECT_KEY") + if projectID == "" || projectKey == "" { + log.Fatal("set MOSS_PROJECT_ID and MOSS_PROJECT_KEY") + } + + ctx := context.Background() + client := moss.NewClient(projectID, projectKey) + defer func() { + if err := client.Close(); err != nil { + log.Printf("close warning: %v", err) + } + }() + indexName := fmt.Sprintf("go-basic-%d", time.Now().Unix()) + + docs := []moss.DocumentInfo{ + { + ID: "doc-1", + Text: "Refunds are processed within five to seven business days.", + Metadata: map[string]string{ + "topic": "refunds", + }, + }, + { + ID: "doc-2", + Text: "Orders can be tracked from the account dashboard.", + Metadata: map[string]string{ + "topic": "shipping", + }, + }, + } + + result, err := client.CreateIndex(ctx, indexName, docs, nil) + if err != nil { + log.Fatal(err) + } + fmt.Println("create job:", result.JobID) + + if _, err := client.LoadIndex(ctx, indexName, &moss.LoadIndexOptions{}); err != nil { + log.Fatal(err) + } + + search, err := client.Query(ctx, indexName, "how long do refunds take?", &moss.QueryOptions{ + TopK: 3, + }) + if err != nil { + log.Fatal(err) + } + + fmt.Println("query:", search.Query) + for _, doc := range search.Docs { + fmt.Printf("%s %.3f %s\n", doc.ID, doc.Score, doc.Text) + } + + if err := cleanup(ctx, client, indexName); err != nil { + log.Printf("cleanup warning: %v", err) + } +} + +func cleanup(ctx context.Context, client *moss.Client, indexName string) error { + _, err := client.DeleteIndex(ctx, indexName) + return err +} diff --git a/sdks/go/sdk/examples/custom-embeddings/main.go b/sdks/go/sdk/examples/custom-embeddings/main.go new file mode 100644 index 0000000..e1c7aa5 --- /dev/null +++ b/sdks/go/sdk/examples/custom-embeddings/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/usemoss/moss/sdks/go/sdk/moss" +) + +func main() { + projectID := os.Getenv("MOSS_PROJECT_ID") + projectKey := os.Getenv("MOSS_PROJECT_KEY") + if projectID == "" || projectKey == "" { + log.Fatal("set MOSS_PROJECT_ID and MOSS_PROJECT_KEY") + } + + ctx := context.Background() + client := moss.NewClient(projectID, projectKey) + defer func() { + if err := client.Close(); err != nil { + log.Printf("close warning: %v", err) + } + }() + indexName := fmt.Sprintf("go-custom-%d", time.Now().Unix()) + + docs := []moss.DocumentInfo{ + { + ID: "refunds", + Text: "Refunds are processed within five business days.", + Embedding: []float32{1, 0, 0, 0}, + }, + { + ID: "shipping", + Text: "Track your order from the shipping dashboard.", + Embedding: []float32{0, 1, 0, 0}, + }, + } + + result, err := client.CreateIndex(ctx, indexName, docs, nil) + if err != nil { + log.Fatal(err) + } + fmt.Println("create job:", result.JobID) + + if _, err := client.LoadIndex(ctx, indexName, &moss.LoadIndexOptions{}); err != nil { + log.Fatal(err) + } + + query := []float32{1, 0, 0, 0} + search, err := client.Query(ctx, indexName, "", &moss.QueryOptions{ + Embedding: query, + TopK: 2, + }) + if err != nil { + log.Fatal(err) + } + + for _, doc := range search.Docs { + fmt.Printf("%s %.3f\n", doc.ID, doc.Score) + } + + if err := cleanup(ctx, client, indexName); err != nil { + log.Printf("cleanup warning: %v", err) + } +} + +func cleanup(ctx context.Context, client *moss.Client, indexName string) error { + _, err := client.DeleteIndex(ctx, indexName) + return err +} diff --git a/sdks/go/sdk/go.mod b/sdks/go/sdk/go.mod new file mode 100644 index 0000000..fe443db --- /dev/null +++ b/sdks/go/sdk/go.mod @@ -0,0 +1,7 @@ +module github.com/usemoss/moss/sdks/go/sdk + +go 1.22.2 + +require github.com/usemoss/moss/sdks/go/bindings v0.0.0 + +replace github.com/usemoss/moss/sdks/go/bindings => ../bindings diff --git a/sdks/go/sdk/moss/client.go b/sdks/go/sdk/moss/client.go new file mode 100644 index 0000000..5182ba2 --- /dev/null +++ b/sdks/go/sdk/moss/client.go @@ -0,0 +1,163 @@ +package moss + +import ( + "strings" + "sync" + + mosscore "github.com/usemoss/moss/sdks/go/bindings" +) + +type clientConfig struct { + manageURL string + queryURL string +} + +type manageRuntime interface { + Close() error + CreateIndex(name string, docs []mosscore.DocumentInfo, modelID string) (mosscore.MutationResult, error) + AddDocs(name string, docs []mosscore.DocumentInfo, options *mosscore.MutationOptions) (mosscore.MutationResult, error) + DeleteDocs(name string, docIDs []string) (mosscore.MutationResult, error) + GetJobStatus(jobID string) (mosscore.JobStatusResponse, error) + GetIndex(name string) (mosscore.IndexInfo, error) + ListIndexes() ([]mosscore.IndexInfo, error) + DeleteIndex(name string) (bool, error) + GetDocs(name string, docIDs []string) ([]mosscore.DocumentInfo, error) +} + +type indexRuntime interface { + Close() error + LoadIndex(indexName string, options *mosscore.LoadIndexOptions) (mosscore.IndexInfo, error) + UnloadIndex(indexName string) error + HasIndex(indexName string) bool + Query(indexName, query string, queryEmbedding []float32, topK int, alpha float32, filterJSON *string) (mosscore.SearchResult, error) + QueryText(indexName, query string, topK int, alpha float32, filterJSON *string) (mosscore.SearchResult, error) + LoadQueryModel(indexName string) error + RefreshIndex(indexName string) (mosscore.RefreshResult, error) + GetIndexInfo(indexName string) (mosscore.IndexInfo, error) +} + +// Client is the bindings-backed Moss Go SDK client. +type Client struct { + projectID string + projectKey string + manageURL string + queryURL string + manageMu sync.Mutex + manageClient manageRuntime + indexMu sync.Mutex + indexMgr indexRuntime + manageFactory func(projectID, projectKey string) (manageRuntime, error) + indexFactory func(projectID, projectKey string) (indexRuntime, error) +} + +// NewClient constructs a new Moss client with optional overrides. +func NewClient(projectID, projectKey string, opts ...Option) *Client { + cfg := clientConfig{} + + for _, opt := range opts { + if opt != nil { + opt(&cfg) + } + } + + return &Client{ + projectID: strings.TrimSpace(projectID), + projectKey: strings.TrimSpace(projectKey), + manageURL: strings.TrimSpace(cfg.manageURL), + queryURL: strings.TrimSpace(cfg.queryURL), + manageFactory: func(projectID, projectKey string) (manageRuntime, error) { + return mosscore.NewManageClient(projectID, projectKey) + }, + indexFactory: func(projectID, projectKey string) (indexRuntime, error) { + return mosscore.NewIndexManager(projectID, projectKey) + }, + } +} + +func (c *Client) validateManageRequest(indexName string) error { + if err := validateCredentials(c.projectID, c.projectKey); err != nil { + return err + } + if strings.TrimSpace(indexName) == "" { + return ErrEmptyIndexName + } + return nil +} + +func (c *Client) validateQueryRequest(indexName string) error { + if err := validateCredentials(c.projectID, c.projectKey); err != nil { + return err + } + if strings.TrimSpace(indexName) == "" { + return ErrEmptyIndexName + } + return nil +} + +func validateCredentials(projectID, projectKey string) error { + if strings.TrimSpace(projectID) == "" { + return ErrMissingProjectID + } + if strings.TrimSpace(projectKey) == "" { + return ErrMissingProjectKey + } + return nil +} + +func (c *Client) ensureManageClient() (manageRuntime, error) { + c.manageMu.Lock() + defer c.manageMu.Unlock() + + if c.manageClient != nil { + return c.manageClient, nil + } + + client, err := c.manageFactory(c.projectID, c.projectKey) + if err != nil { + return nil, err + } + c.manageClient = client + return client, nil +} + +func (c *Client) ensureIndexManager() (indexRuntime, error) { + c.indexMu.Lock() + defer c.indexMu.Unlock() + + if c.indexMgr != nil { + return c.indexMgr, nil + } + + manager, err := c.indexFactory(c.projectID, c.projectKey) + if err != nil { + return nil, err + } + c.indexMgr = manager + return manager, nil +} + +// Close releases any lazily initialized native runtime handles owned by the client. +func (c *Client) Close() error { + c.manageMu.Lock() + manage := c.manageClient + c.manageClient = nil + c.manageMu.Unlock() + + c.indexMu.Lock() + index := c.indexMgr + c.indexMgr = nil + c.indexMu.Unlock() + + var firstErr error + if manage != nil { + if err := manage.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + if index != nil { + if err := index.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} diff --git a/sdks/go/sdk/moss/client_test.go b/sdks/go/sdk/moss/client_test.go new file mode 100644 index 0000000..865dc23 --- /dev/null +++ b/sdks/go/sdk/moss/client_test.go @@ -0,0 +1,353 @@ +package moss + +import ( + "context" + "encoding/json" + "errors" + "testing" + + mosscore "github.com/usemoss/moss/sdks/go/bindings" +) + +type fakeManageRuntime struct { + closeCalled bool + createIndexFn func(name string, docs []mosscore.DocumentInfo, modelID string) (mosscore.MutationResult, error) + addDocsFn func(name string, docs []mosscore.DocumentInfo, options *mosscore.MutationOptions) (mosscore.MutationResult, error) + deleteDocsFn func(name string, docIDs []string) (mosscore.MutationResult, error) + getJobStatusFn func(jobID string) (mosscore.JobStatusResponse, error) + getIndexFn func(name string) (mosscore.IndexInfo, error) + listIndexesFn func() ([]mosscore.IndexInfo, error) + deleteIndexFn func(name string) (bool, error) + getDocsFn func(name string, docIDs []string) ([]mosscore.DocumentInfo, error) +} + +func (f *fakeManageRuntime) Close() error { + f.closeCalled = true + return nil +} + +func (f *fakeManageRuntime) CreateIndex(name string, docs []mosscore.DocumentInfo, modelID string) (mosscore.MutationResult, error) { + if f.createIndexFn == nil { + return mosscore.MutationResult{}, nil + } + return f.createIndexFn(name, docs, modelID) +} + +func (f *fakeManageRuntime) AddDocs(name string, docs []mosscore.DocumentInfo, options *mosscore.MutationOptions) (mosscore.MutationResult, error) { + if f.addDocsFn == nil { + return mosscore.MutationResult{}, nil + } + return f.addDocsFn(name, docs, options) +} + +func (f *fakeManageRuntime) DeleteDocs(name string, docIDs []string) (mosscore.MutationResult, error) { + if f.deleteDocsFn == nil { + return mosscore.MutationResult{}, nil + } + return f.deleteDocsFn(name, docIDs) +} + +func (f *fakeManageRuntime) GetJobStatus(jobID string) (mosscore.JobStatusResponse, error) { + if f.getJobStatusFn == nil { + return mosscore.JobStatusResponse{}, nil + } + return f.getJobStatusFn(jobID) +} + +func (f *fakeManageRuntime) GetIndex(name string) (mosscore.IndexInfo, error) { + if f.getIndexFn == nil { + return mosscore.IndexInfo{}, nil + } + return f.getIndexFn(name) +} + +func (f *fakeManageRuntime) ListIndexes() ([]mosscore.IndexInfo, error) { + if f.listIndexesFn == nil { + return nil, nil + } + return f.listIndexesFn() +} + +func (f *fakeManageRuntime) DeleteIndex(name string) (bool, error) { + if f.deleteIndexFn == nil { + return false, nil + } + return f.deleteIndexFn(name) +} + +func (f *fakeManageRuntime) GetDocs(name string, docIDs []string) ([]mosscore.DocumentInfo, error) { + if f.getDocsFn == nil { + return nil, nil + } + return f.getDocsFn(name, docIDs) +} + +type fakeIndexRuntime struct { + closeCalled bool + loaded map[string]bool + loadIndexFn func(indexName string, options *mosscore.LoadIndexOptions) (mosscore.IndexInfo, error) + unloadIndexFn func(indexName string) error + queryFn func(indexName, query string, queryEmbedding []float32, topK int, alpha float32, filterJSON *string) (mosscore.SearchResult, error) + queryTextFn func(indexName, query string, topK int, alpha float32, filterJSON *string) (mosscore.SearchResult, error) + loadQueryModelFn func(indexName string) error + refreshIndexFn func(indexName string) (mosscore.RefreshResult, error) + getIndexInfoFn func(indexName string) (mosscore.IndexInfo, error) +} + +func (f *fakeIndexRuntime) Close() error { + f.closeCalled = true + return nil +} + +func (f *fakeIndexRuntime) LoadIndex(indexName string, options *mosscore.LoadIndexOptions) (mosscore.IndexInfo, error) { + if f.loadIndexFn == nil { + if f.loaded == nil { + f.loaded = map[string]bool{} + } + f.loaded[indexName] = true + return mosscore.IndexInfo{Name: indexName, Model: mosscore.ModelRef{ID: string(ModelMossMiniLM)}}, nil + } + return f.loadIndexFn(indexName, options) +} + +func (f *fakeIndexRuntime) UnloadIndex(indexName string) error { + if f.unloadIndexFn != nil { + return f.unloadIndexFn(indexName) + } + if f.loaded != nil { + delete(f.loaded, indexName) + } + return nil +} + +func (f *fakeIndexRuntime) HasIndex(indexName string) bool { + return f.loaded != nil && f.loaded[indexName] +} + +func (f *fakeIndexRuntime) Query(indexName, query string, queryEmbedding []float32, topK int, alpha float32, filterJSON *string) (mosscore.SearchResult, error) { + if f.queryFn == nil { + return mosscore.SearchResult{}, nil + } + return f.queryFn(indexName, query, queryEmbedding, topK, alpha, filterJSON) +} + +func (f *fakeIndexRuntime) QueryText(indexName, query string, topK int, alpha float32, filterJSON *string) (mosscore.SearchResult, error) { + if f.queryTextFn == nil { + return mosscore.SearchResult{}, nil + } + return f.queryTextFn(indexName, query, topK, alpha, filterJSON) +} + +func (f *fakeIndexRuntime) LoadQueryModel(indexName string) error { + if f.loadQueryModelFn == nil { + return nil + } + return f.loadQueryModelFn(indexName) +} + +func (f *fakeIndexRuntime) RefreshIndex(indexName string) (mosscore.RefreshResult, error) { + if f.refreshIndexFn == nil { + return mosscore.RefreshResult{}, nil + } + return f.refreshIndexFn(indexName) +} + +func (f *fakeIndexRuntime) GetIndexInfo(indexName string) (mosscore.IndexInfo, error) { + if f.getIndexInfoFn == nil { + return mosscore.IndexInfo{}, nil + } + return f.getIndexInfoFn(indexName) +} + +func newTestClient(manage manageRuntime, index indexRuntime) *Client { + client := NewClient("project-id", "project-key") + client.manageClient = manage + client.indexMgr = index + return client +} + +func TestGetIndexUsesBindingsRuntime(t *testing.T) { + client := newTestClient(&fakeManageRuntime{ + getIndexFn: func(name string) (mosscore.IndexInfo, error) { + if name != "support-docs" { + t.Fatalf("unexpected index name: %q", name) + } + return mosscore.IndexInfo{ + ID: "idx-1", + Name: "support-docs", + Status: "Ready", + DocCount: 124, + Model: mosscore.ModelRef{ID: string(ModelMossMiniLM)}, + }, nil + }, + }, nil) + + info, err := client.GetIndex(context.Background(), "support-docs") + if err != nil { + t.Fatalf("GetIndex returned error: %v", err) + } + if info.Name != "support-docs" || info.DocCount != 124 || info.Model.ID != string(ModelMossMiniLM) { + t.Fatalf("unexpected index info: %#v", info) + } +} + +func TestListIndexesUsesBindingsRuntime(t *testing.T) { + client := newTestClient(&fakeManageRuntime{ + listIndexesFn: func() ([]mosscore.IndexInfo, error) { + return []mosscore.IndexInfo{ + {Name: "alpha", Status: "Ready", DocCount: 2, Model: mosscore.ModelRef{ID: string(ModelMossMiniLM)}}, + {Name: "beta", Status: "Building", DocCount: 3, Model: mosscore.ModelRef{ID: string(ModelCustom)}}, + }, nil + }, + }, nil) + + indexes, err := client.ListIndexes(context.Background()) + if err != nil { + t.Fatalf("ListIndexes returned error: %v", err) + } + if len(indexes) != 2 { + t.Fatalf("unexpected index count: %d", len(indexes)) + } + if indexes[0].Name != "alpha" || indexes[1].Model.ID != string(ModelCustom) { + t.Fatalf("unexpected indexes: %#v", indexes) + } +} + +func TestGetDocsPassesDocIDsToBindings(t *testing.T) { + client := newTestClient(&fakeManageRuntime{ + getDocsFn: func(name string, docIDs []string) ([]mosscore.DocumentInfo, error) { + if name != "support-docs" { + t.Fatalf("unexpected index name: %q", name) + } + if len(docIDs) != 1 || docIDs[0] != "doc-1" { + t.Fatalf("unexpected doc IDs: %#v", docIDs) + } + return []mosscore.DocumentInfo{ + {ID: "doc-1", Text: "hello", Metadata: map[string]string{"topic": "refunds"}}, + }, nil + }, + }, nil) + + docs, err := client.GetDocs(context.Background(), "support-docs", &GetDocumentsOptions{ + DocIDs: []string{"doc-1"}, + }) + if err != nil { + t.Fatalf("GetDocs returned error: %v", err) + } + if len(docs) != 1 || docs[0].Metadata["topic"] != "refunds" { + t.Fatalf("unexpected docs: %#v", docs) + } +} + +func TestQueryRequiresLoadedIndex(t *testing.T) { + client := newTestClient(nil, &fakeIndexRuntime{loaded: map[string]bool{}}) + + _, err := client.Query(context.Background(), "support-docs", "refund policy", nil) + if !errors.Is(err, ErrIndexNotLoaded) { + t.Fatalf("expected ErrIndexNotLoaded, got %v", err) + } +} + +func TestQueryUsesLocalBindingsAndSupportsFilters(t *testing.T) { + client := newTestClient(nil, &fakeIndexRuntime{ + loaded: map[string]bool{"support-docs": true}, + queryTextFn: func(indexName, query string, topK int, alpha float32, filterJSON *string) (mosscore.SearchResult, error) { + if indexName != "support-docs" { + t.Fatalf("unexpected index name: %q", indexName) + } + if query != "refund policy" { + t.Fatalf("unexpected query: %q", query) + } + if topK != 7 { + t.Fatalf("unexpected topK: %d", topK) + } + if alpha != 0.6 { + t.Fatalf("unexpected alpha: %f", alpha) + } + if filterJSON == nil { + t.Fatal("expected filter JSON to be passed") + } + + var decoded map[string]any + if err := json.Unmarshal([]byte(*filterJSON), &decoded); err != nil { + t.Fatalf("decode filter: %v", err) + } + if decoded["field"] != "topic" { + t.Fatalf("unexpected filter payload: %#v", decoded) + } + + timeTaken := 17 + return mosscore.SearchResult{ + Docs: []mosscore.QueryResultDocumentInfo{{ID: "doc-1", Text: "Refunds take 5-7 days", Score: 0.91, Metadata: map[string]string{"topic": "refunds"}}}, + Query: query, + IndexName: &indexName, + TimeTakenMs: timeTaken, + }, nil + }, + }) + + alpha := 0.6 + result, err := client.Query(context.Background(), "support-docs", "refund policy", &QueryOptions{ + TopK: 7, + Alpha: &alpha, + Filter: map[string]any{"field": "topic"}, + }) + if err != nil { + t.Fatalf("Query returned error: %v", err) + } + if len(result.Docs) != 1 || result.Docs[0].Score != 0.91 { + t.Fatalf("unexpected query result: %#v", result) + } + if result.TimeTakenMs == nil || *result.TimeTakenMs != 17 { + t.Fatalf("unexpected timeTakenMs: %#v", result.TimeTakenMs) + } +} + +func TestLoadIndexSkipsQueryModelForCustomEmbeddings(t *testing.T) { + loadQueryModelCalled := false + client := newTestClient(nil, &fakeIndexRuntime{ + loadIndexFn: func(indexName string, options *mosscore.LoadIndexOptions) (mosscore.IndexInfo, error) { + return mosscore.IndexInfo{ + Name: indexName, + Model: mosscore.ModelRef{ID: string(ModelCustom)}, + }, nil + }, + loadQueryModelFn: func(indexName string) error { + loadQueryModelCalled = true + return nil + }, + }) + + name, err := client.LoadIndex(context.Background(), "custom-index", &LoadIndexOptions{}) + if err != nil { + t.Fatalf("LoadIndex returned error: %v", err) + } + if name != "custom-index" { + t.Fatalf("unexpected loaded index name: %q", name) + } + if loadQueryModelCalled { + t.Fatal("expected custom embedding index to skip query model loading") + } +} + +func TestLoadIndexRejectsUnsupportedCachePath(t *testing.T) { + client := newTestClient(nil, &fakeIndexRuntime{}) + + _, err := client.LoadIndex(context.Background(), "support-docs", &LoadIndexOptions{CachePath: "/tmp/cache"}) + if !errors.Is(err, ErrUnsupportedCachePath) { + t.Fatalf("expected ErrUnsupportedCachePath, got %v", err) + } +} + +func TestCloseReleasesInitializedBindings(t *testing.T) { + manage := &fakeManageRuntime{} + index := &fakeIndexRuntime{} + client := newTestClient(manage, index) + + if err := client.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + if !manage.closeCalled || !index.closeCalled { + t.Fatalf("expected runtimes to be closed: manage=%v index=%v", manage.closeCalled, index.closeCalled) + } +} diff --git a/sdks/go/sdk/moss/conversion.go b/sdks/go/sdk/moss/conversion.go new file mode 100644 index 0000000..7946437 --- /dev/null +++ b/sdks/go/sdk/moss/conversion.go @@ -0,0 +1,92 @@ +package moss + +import mosscore "github.com/usemoss/moss/sdks/go/bindings" + +func toCoreDocumentInfo(value DocumentInfo) mosscore.DocumentInfo { + return mosscore.DocumentInfo{ + ID: value.ID, + Text: value.Text, + Metadata: value.Metadata, + Embedding: value.Embedding, + } +} + +func toCoreDocumentInfos(values []DocumentInfo) []mosscore.DocumentInfo { + out := make([]mosscore.DocumentInfo, 0, len(values)) + for _, value := range values { + out = append(out, toCoreDocumentInfo(value)) + } + return out +} + +func fromCoreIndexInfo(value mosscore.IndexInfo) IndexInfo { + return IndexInfo{ + ID: value.ID, + Name: value.Name, + Version: value.Version, + Status: IndexStatus(value.Status), + DocCount: value.DocCount, + CreatedAt: value.CreatedAt, + UpdatedAt: value.UpdatedAt, + Model: ModelRef{ + ID: value.Model.ID, + Version: value.Model.Version, + }, + } +} + +func fromCoreDocumentInfo(value mosscore.DocumentInfo) DocumentInfo { + return DocumentInfo{ + ID: value.ID, + Text: value.Text, + Metadata: value.Metadata, + Embedding: value.Embedding, + } +} + +func fromCoreMutationResult(value mosscore.MutationResult) MutationResult { + return MutationResult{ + JobID: value.JobID, + IndexName: value.IndexName, + DocCount: value.DocCount, + } +} + +func fromCoreSearchResult(value mosscore.SearchResult) SearchResult { + docs := make([]QueryResultDocumentInfo, 0, len(value.Docs)) + for _, item := range value.Docs { + docs = append(docs, QueryResultDocumentInfo{ + ID: item.ID, + Text: item.Text, + Metadata: item.Metadata, + Score: item.Score, + }) + } + + timeTaken := value.TimeTakenMs + return SearchResult{ + Docs: docs, + Query: value.Query, + IndexName: value.IndexName, + TimeTakenMs: &timeTaken, + } +} + +func fromCoreJobStatusResponse(value mosscore.JobStatusResponse) JobStatusResponse { + var currentPhase *JobPhase + if value.CurrentPhase != nil { + phase := JobPhase(*value.CurrentPhase) + currentPhase = &phase + } + + return JobStatusResponse{ + JobID: value.JobID, + Status: JobStatus(value.Status), + Progress: value.Progress, + CurrentPhase: currentPhase, + Error: value.Error, + CreatedAt: value.CreatedAt, + UpdatedAt: value.UpdatedAt, + CompletedAt: value.CompletedAt, + } +} diff --git a/sdks/go/sdk/moss/errors.go b/sdks/go/sdk/moss/errors.go new file mode 100644 index 0000000..1638300 --- /dev/null +++ b/sdks/go/sdk/moss/errors.go @@ -0,0 +1,33 @@ +package moss + +import ( + "errors" + "fmt" +) + +var ( + ErrMissingProjectID = errors.New("moss: missing project ID") + ErrMissingProjectKey = errors.New("moss: missing project key") + ErrEmptyIndexName = errors.New("moss: index name must not be empty") + ErrEmptyJobID = errors.New("moss: job ID must not be empty") + ErrEmptyDocuments = errors.New("moss: documents must not be empty") + ErrEmptyDocumentIDs = errors.New("moss: document IDs must not be empty") + ErrIndexNotLoaded = errors.New("moss: index is not loaded locally; call LoadIndex first") + ErrUnsupportedCachePath = errors.New("moss: LoadIndexOptions.CachePath is not supported by the current libmoss bindings") +) + +// HTTPError is retained for compatibility with earlier SDK scaffolding. +type HTTPError struct { + StatusCode int + Body string +} + +func (e *HTTPError) Error() string { + if e == nil { + return "" + } + if e.Body == "" { + return fmt.Sprintf("moss: http request failed with status %d", e.StatusCode) + } + return fmt.Sprintf("moss: http request failed with status %d: %s", e.StatusCode, e.Body) +} diff --git a/sdks/go/sdk/moss/integration_test.go b/sdks/go/sdk/moss/integration_test.go new file mode 100644 index 0000000..0eeb415 --- /dev/null +++ b/sdks/go/sdk/moss/integration_test.go @@ -0,0 +1,136 @@ +package moss + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + "time" + + mosscore "github.com/usemoss/moss/sdks/go/bindings" +) + +func TestCloudLifecycleIntegration(t *testing.T) { + projectID := os.Getenv("MOSS_TEST_PROJECT_ID") + projectKey := os.Getenv("MOSS_TEST_PROJECT_KEY") + if projectID == "" || projectKey == "" { + t.Skip("Skipping cloud integration test: set MOSS_TEST_PROJECT_ID and MOSS_TEST_PROJECT_KEY") + } + + client := NewClient(projectID, projectKey) + t.Cleanup(func() { + _ = client.Close() + }) + ctx := context.Background() + indexName := fmt.Sprintf("go-sdk-int-%d", time.Now().UnixNano()) + + docs := []DocumentInfo{ + { + ID: "doc-1", + Text: "Refunds are processed within five business days.", + Embedding: []float32{1, 0, 0, 0}, + }, + { + ID: "doc-2", + Text: "Orders can be tracked from the dashboard.", + Embedding: []float32{0, 1, 0, 0}, + }, + } + + t.Cleanup(func() { + _, _ = client.DeleteIndex(context.Background(), indexName) + }) + + createResult, err := client.CreateIndex(ctx, indexName, docs, nil) + if err != nil { + if errors.Is(err, mosscore.ErrBindingsUnavailable) { + t.Skip("Skipping Go bindings integration test: libmoss bindings are unavailable in this build") + } + t.Fatalf("CreateIndex failed: %v", err) + } + if createResult.JobID == "" || createResult.IndexName != indexName || createResult.DocCount != 2 { + t.Fatalf("unexpected create result: %#v", createResult) + } + + status, err := client.GetJobStatus(ctx, createResult.JobID) + if err != nil { + t.Fatalf("GetJobStatus failed: %v", err) + } + if status.JobID != createResult.JobID || status.Status != JobStatusCompleted { + t.Fatalf("unexpected job status: %#v", status) + } + + info, err := client.GetIndex(ctx, indexName) + if err != nil { + t.Fatalf("GetIndex failed: %v", err) + } + if info.Name != indexName || info.DocCount != 2 || info.Model.ID != string(ModelCustom) { + t.Fatalf("unexpected index info: %#v", info) + } + + gotDocs, err := client.GetDocs(ctx, indexName, nil) + if err != nil { + t.Fatalf("GetDocs failed: %v", err) + } + if len(gotDocs) != 2 { + t.Fatalf("unexpected doc count: %d", len(gotDocs)) + } + + if _, err := client.LoadIndex(ctx, indexName, &LoadIndexOptions{}); err != nil { + if errors.Is(err, mosscore.ErrBindingsUnavailable) { + t.Skip("Skipping local query integration: libmoss bindings are unavailable in this build") + } + t.Fatalf("LoadIndex failed: %v", err) + } + + search, err := client.Query(ctx, indexName, "", &QueryOptions{ + Embedding: []float32{1, 0, 0, 0}, + TopK: 2, + }) + if err != nil { + t.Fatalf("Query failed: %v", err) + } + if len(search.Docs) == 0 || search.Docs[0].ID != "doc-1" { + t.Fatalf("unexpected query result: %#v", search) + } + + upsert := true + addResult, err := client.AddDocs(ctx, indexName, []DocumentInfo{ + { + ID: "doc-3", + Text: "Customers can update shipping addresses before fulfillment.", + Embedding: []float32{0, 0, 1, 0}, + }, + }, &MutationOptions{Upsert: &upsert}) + if err != nil { + t.Fatalf("AddDocs failed: %v", err) + } + if addResult.DocCount != 1 { + t.Fatalf("unexpected add result: %#v", addResult) + } + + info, err = client.GetIndex(ctx, indexName) + if err != nil { + t.Fatalf("GetIndex after AddDocs failed: %v", err) + } + if info.DocCount != 3 { + t.Fatalf("unexpected doc count after add: %d", info.DocCount) + } + + deleteResult, err := client.DeleteDocs(ctx, indexName, []string{"doc-2"}, nil) + if err != nil { + t.Fatalf("DeleteDocs failed: %v", err) + } + if deleteResult.DocCount != 1 { + t.Fatalf("unexpected delete result: %#v", deleteResult) + } + + info, err = client.GetIndex(ctx, indexName) + if err != nil { + t.Fatalf("GetIndex after DeleteDocs failed: %v", err) + } + if info.DocCount != 2 { + t.Fatalf("unexpected doc count after delete: %d", info.DocCount) + } +} diff --git a/sdks/go/sdk/moss/local.go b/sdks/go/sdk/moss/local.go new file mode 100644 index 0000000..fcbd75a --- /dev/null +++ b/sdks/go/sdk/moss/local.go @@ -0,0 +1,90 @@ +package moss + +import ( + "context" + "strings" + + mosscore "github.com/usemoss/moss/sdks/go/bindings" +) + +// LoadIndex downloads an index into the local native runtime for fast querying. +func (c *Client) LoadIndex(ctx context.Context, indexName string, options *LoadIndexOptions) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + if err := c.validateManageRequest(indexName); err != nil { + return "", err + } + if options != nil && strings.TrimSpace(options.CachePath) != "" { + return "", ErrUnsupportedCachePath + } + + manager, err := c.ensureIndexManager() + if err != nil { + return "", err + } + + var bindingOptions *mosscore.LoadIndexOptions + if options != nil { + bindingOptions = &mosscore.LoadIndexOptions{ + AutoRefresh: options.AutoRefresh, + PollingIntervalInSeconds: options.PollingIntervalInSeconds, + } + } + + info, err := manager.LoadIndex(indexName, bindingOptions) + if err != nil { + return "", err + } + if info.Model.ID != string(ModelCustom) { + if err := manager.LoadQueryModel(indexName); err != nil { + return "", err + } + } + if info.Name != "" { + return info.Name, nil + } + return indexName, nil +} + +// UnloadIndex removes a previously loaded index from the local runtime. +func (c *Client) UnloadIndex(ctx context.Context, indexName string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(indexName) == "" { + return ErrEmptyIndexName + } + + manager, err := c.ensureIndexManager() + if err != nil { + return err + } + return manager.UnloadIndex(indexName) +} + +// RefreshIndex refreshes a locally loaded index from the cloud when newer data is available. +func (c *Client) RefreshIndex(ctx context.Context, indexName string) (RefreshResult, error) { + if err := ctx.Err(); err != nil { + return RefreshResult{}, err + } + if strings.TrimSpace(indexName) == "" { + return RefreshResult{}, ErrEmptyIndexName + } + + manager, err := c.ensureIndexManager() + if err != nil { + return RefreshResult{}, err + } + + result, err := manager.RefreshIndex(indexName) + if err != nil { + return RefreshResult{}, err + } + return RefreshResult{ + IndexName: result.IndexName, + PreviousUpdatedAt: result.PreviousUpdatedAt, + NewUpdatedAt: result.NewUpdatedAt, + WasUpdated: result.WasUpdated, + }, nil +} diff --git a/sdks/go/sdk/moss/models.go b/sdks/go/sdk/moss/models.go new file mode 100644 index 0000000..db9e05e --- /dev/null +++ b/sdks/go/sdk/moss/models.go @@ -0,0 +1,152 @@ +package moss + +// MossModel identifies the embedding model backing an index. +type MossModel string + +const ( + ModelMossMiniLM MossModel = "moss-minilm" + ModelMossMediumLM MossModel = "moss-mediumlm" + ModelCustom MossModel = "custom" +) + +// IndexStatus describes the current lifecycle state of an index. +type IndexStatus string + +const ( + IndexStatusNotStarted IndexStatus = "NotStarted" + IndexStatusBuilding IndexStatus = "Building" + IndexStatusReady IndexStatus = "Ready" + IndexStatusFailed IndexStatus = "Failed" +) + +// JobStatus describes the current lifecycle state of a mutation job. +type JobStatus string + +const ( + JobStatusPendingUpload JobStatus = "pending_upload" + JobStatusUploading JobStatus = "uploading" + JobStatusBuilding JobStatus = "building" + JobStatusCompleted JobStatus = "completed" + JobStatusFailed JobStatus = "failed" +) + +// JobPhase describes the current phase of a mutation job. +type JobPhase string + +const ( + JobPhaseDownloading JobPhase = "downloading" + JobPhaseDeserializing JobPhase = "deserializing" + JobPhaseGeneratingEmbeddings JobPhase = "generating_embeddings" + JobPhaseBuildingIndex JobPhase = "building_index" + JobPhaseUploading JobPhase = "uploading" + JobPhaseCleanup JobPhase = "cleanup" +) + +// ModelRef points at the model used by an index. +type ModelRef struct { + ID string `json:"id"` + Version *string `json:"version,omitempty"` +} + +// IndexInfo describes persisted index metadata. +type IndexInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Version *string `json:"version,omitempty"` + Status IndexStatus `json:"status"` + DocCount int `json:"docCount"` + CreatedAt *string `json:"createdAt,omitempty"` + UpdatedAt *string `json:"updatedAt,omitempty"` + Model ModelRef `json:"model"` +} + +// DocumentInfo is the canonical index document representation. +type DocumentInfo struct { + ID string `json:"id"` + Text string `json:"text"` + Metadata map[string]string `json:"metadata,omitempty"` + Embedding []float32 `json:"embedding,omitempty"` +} + +// QueryResultDocumentInfo is a document returned from a query with a score. +type QueryResultDocumentInfo struct { + ID string `json:"id"` + Text string `json:"text"` + Metadata map[string]string `json:"metadata,omitempty"` + Score float64 `json:"score"` +} + +// SearchResult is the response returned by query operations. +type SearchResult struct { + Docs []QueryResultDocumentInfo `json:"docs"` + Query string `json:"query"` + IndexName *string `json:"indexName,omitempty"` + TimeTakenMs *int `json:"timeTakenMs,omitempty"` +} + +// QueryOptions customizes local query behavior. +type QueryOptions struct { + Embedding []float32 `json:"embedding,omitempty"` + TopK int `json:"topK,omitempty"` + Alpha *float64 `json:"alpha,omitempty"` + Filter map[string]any `json:"filter,omitempty"` +} + +// LoadIndexOptions configures local index loading behavior. +type LoadIndexOptions struct { + AutoRefresh bool `json:"autoRefresh,omitempty"` + PollingIntervalInSeconds uint64 `json:"pollingIntervalInSeconds,omitempty"` + CachePath string `json:"cachePath,omitempty"` +} + +// GetDocumentsOptions optionally narrows document retrieval by ID. +type GetDocumentsOptions struct { + DocIDs []string `json:"docIds,omitempty"` +} + +// CreateIndexOptions customizes index creation behavior. +type CreateIndexOptions struct { + ModelID MossModel `json:"modelId,omitempty"` + OnProgress func(JobProgress) `json:"-"` +} + +// MutationOptions customizes add/update/delete document behavior. +type MutationOptions struct { + Upsert *bool `json:"upsert,omitempty"` + OnProgress func(JobProgress) `json:"-"` +} + +// MutationResult is returned when a mutation job completes. +type MutationResult struct { + JobID string `json:"jobId"` + IndexName string `json:"indexName"` + DocCount int `json:"docCount"` +} + +// JobProgress is emitted while a mutation job is running. +type JobProgress struct { + JobID string `json:"jobId"` + Status JobStatus `json:"status"` + Progress float64 `json:"progress"` + CurrentPhase *JobPhase `json:"currentPhase,omitempty"` +} + +// JobStatusResponse is the persisted status view for a mutation job. +type JobStatusResponse struct { + JobID string `json:"jobId"` + Status JobStatus `json:"status"` + Progress float64 `json:"progress"` + CurrentPhase *JobPhase `json:"currentPhase,omitempty"` + Error *string `json:"error,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + CompletedAt *string `json:"completedAt,omitempty"` +} + +// RefreshResult describes the outcome of a local refresh operation. +type RefreshResult struct { + IndexName string `json:"indexName"` + PreviousUpdatedAt string `json:"previousUpdatedAt"` + NewUpdatedAt string `json:"newUpdatedAt"` + WasUpdated bool `json:"wasUpdated"` +} diff --git a/sdks/go/sdk/moss/mutation.go b/sdks/go/sdk/moss/mutation.go new file mode 100644 index 0000000..632bc2b --- /dev/null +++ b/sdks/go/sdk/moss/mutation.go @@ -0,0 +1,232 @@ +package moss + +import ( + "context" + "fmt" + "strings" + "time" + + mosscore "github.com/usemoss/moss/sdks/go/bindings" +) + +const ( + defaultPollInterval = 2 * time.Second + defaultMutationTimeout = 30 * time.Minute + maxConsecutivePollErrors = 3 +) + +// CreateIndex creates a new index through the native bindings and polls until completion. +func (c *Client) CreateIndex(ctx context.Context, indexName string, docs []DocumentInfo, options *CreateIndexOptions) (MutationResult, error) { + if err := ctx.Err(); err != nil { + return MutationResult{}, err + } + if err := c.validateManageRequest(indexName); err != nil { + return MutationResult{}, err + } + if len(docs) == 0 { + return MutationResult{}, ErrEmptyDocuments + } + + modelID := resolveModelID(docs, options) + if _, err := resolveEmbeddingDimension(docs, modelID); err != nil { + return MutationResult{}, err + } + + manage, err := c.ensureManageClient() + if err != nil { + return MutationResult{}, err + } + + response, err := manage.CreateIndex(indexName, toCoreDocumentInfos(docs), string(modelID)) + if err != nil { + return MutationResult{}, err + } + + var onProgress func(JobProgress) + if options != nil { + onProgress = options.OnProgress + } + + return c.pollJobUntilComplete(ctx, response, onProgress) +} + +// AddDocs appends or upserts documents and polls the async job until completion. +func (c *Client) AddDocs(ctx context.Context, indexName string, docs []DocumentInfo, options *MutationOptions) (MutationResult, error) { + if err := ctx.Err(); err != nil { + return MutationResult{}, err + } + if err := c.validateManageRequest(indexName); err != nil { + return MutationResult{}, err + } + if len(docs) == 0 { + return MutationResult{}, ErrEmptyDocuments + } + + var bindingOptions *mosscore.MutationOptions + var onProgress func(JobProgress) + if options != nil { + bindingOptions = &mosscore.MutationOptions{Upsert: options.Upsert} + onProgress = options.OnProgress + } + + manage, err := c.ensureManageClient() + if err != nil { + return MutationResult{}, err + } + + response, err := manage.AddDocs(indexName, toCoreDocumentInfos(docs), bindingOptions) + if err != nil { + return MutationResult{}, err + } + + return c.pollJobUntilComplete(ctx, response, onProgress) +} + +// DeleteDocs removes documents by ID and polls the async job until completion. +func (c *Client) DeleteDocs(ctx context.Context, indexName string, docIDs []string, options *MutationOptions) (MutationResult, error) { + if err := ctx.Err(); err != nil { + return MutationResult{}, err + } + if err := c.validateManageRequest(indexName); err != nil { + return MutationResult{}, err + } + if len(docIDs) == 0 { + return MutationResult{}, ErrEmptyDocumentIDs + } + + var onProgress func(JobProgress) + if options != nil { + onProgress = options.OnProgress + } + + manage, err := c.ensureManageClient() + if err != nil { + return MutationResult{}, err + } + + response, err := manage.DeleteDocs(indexName, docIDs) + if err != nil { + return MutationResult{}, err + } + + return c.pollJobUntilComplete(ctx, response, onProgress) +} + +// GetJobStatus fetches the current status of an async mutation job. +func (c *Client) GetJobStatus(ctx context.Context, jobID string) (JobStatusResponse, error) { + if err := ctx.Err(); err != nil { + return JobStatusResponse{}, err + } + if err := validateCredentials(c.projectID, c.projectKey); err != nil { + return JobStatusResponse{}, err + } + if strings.TrimSpace(jobID) == "" { + return JobStatusResponse{}, ErrEmptyJobID + } + + manage, err := c.ensureManageClient() + if err != nil { + return JobStatusResponse{}, err + } + + response, err := manage.GetJobStatus(jobID) + if err != nil { + return JobStatusResponse{}, err + } + return fromCoreJobStatusResponse(response), nil +} + +func resolveModelID(docs []DocumentInfo, options *CreateIndexOptions) MossModel { + if options != nil && options.ModelID != "" { + return options.ModelID + } + + for _, doc := range docs { + if len(doc.Embedding) > 0 { + return ModelCustom + } + } + + return ModelMossMiniLM +} + +func resolveEmbeddingDimension(docs []DocumentInfo, modelID MossModel) (int, error) { + withEmbeddings := 0 + for _, doc := range docs { + if len(doc.Embedding) > 0 { + withEmbeddings++ + } + } + + withoutEmbeddings := len(docs) - withEmbeddings + if withEmbeddings > 0 && withoutEmbeddings > 0 { + return 0, fmt.Errorf("moss: all documents must either all have embeddings or none should have embeddings") + } + + if withEmbeddings == 0 { + if modelID == ModelCustom { + return 0, fmt.Errorf("moss: cannot use model %q without pre-computed embeddings", ModelCustom) + } + return 0, nil + } + + dimension := len(docs[0].Embedding) + for _, doc := range docs[1:] { + if len(doc.Embedding) != dimension { + return 0, fmt.Errorf("moss: document %q has mismatched embedding dimension (expected %d, got %d)", doc.ID, dimension, len(doc.Embedding)) + } + } + + return dimension, nil +} + +func (c *Client) pollJobUntilComplete( + ctx context.Context, + result mosscore.MutationResult, + onProgress func(JobProgress), +) (MutationResult, error) { + timeoutCtx, cancel := context.WithTimeout(ctx, defaultMutationTimeout) + defer cancel() + + ticker := time.NewTicker(defaultPollInterval) + defer ticker.Stop() + + consecutiveErrors := 0 + completed := fromCoreMutationResult(result) + + for { + status, err := c.GetJobStatus(timeoutCtx, result.JobID) + if err != nil { + consecutiveErrors++ + if consecutiveErrors >= maxConsecutivePollErrors { + return MutationResult{}, fmt.Errorf("moss: job status polling failed after %d consecutive errors: %w", maxConsecutivePollErrors, err) + } + } else { + consecutiveErrors = 0 + if onProgress != nil { + onProgress(JobProgress{ + JobID: status.JobID, + Status: status.Status, + Progress: status.Progress, + CurrentPhase: status.CurrentPhase, + }) + } + + switch status.Status { + case JobStatusCompleted: + return completed, nil + case JobStatusFailed: + if status.Error != nil && *status.Error != "" { + return MutationResult{}, fmt.Errorf("moss: job failed: %s", *status.Error) + } + return MutationResult{}, fmt.Errorf("moss: job failed") + } + } + + select { + case <-timeoutCtx.Done(): + return MutationResult{}, timeoutCtx.Err() + case <-ticker.C: + } + } +} diff --git a/sdks/go/sdk/moss/mutation_test.go b/sdks/go/sdk/moss/mutation_test.go new file mode 100644 index 0000000..04b9d9b --- /dev/null +++ b/sdks/go/sdk/moss/mutation_test.go @@ -0,0 +1,184 @@ +package moss + +import ( + "context" + "strings" + "testing" + + mosscore "github.com/usemoss/moss/sdks/go/bindings" +) + +func TestCreateIndexUsesBindingsAndPollsJobStatus(t *testing.T) { + polls := 0 + client := newTestClient(&fakeManageRuntime{ + createIndexFn: func(name string, docs []mosscore.DocumentInfo, modelID string) (mosscore.MutationResult, error) { + if name != "support-docs" { + t.Fatalf("unexpected index name: %q", name) + } + if len(docs) != 2 { + t.Fatalf("unexpected doc count: %d", len(docs)) + } + if modelID != string(ModelMossMiniLM) { + t.Fatalf("unexpected model ID: %q", modelID) + } + return mosscore.MutationResult{JobID: "job-create", IndexName: name, DocCount: len(docs)}, nil + }, + getJobStatusFn: func(jobID string) (mosscore.JobStatusResponse, error) { + polls++ + if polls == 1 { + phase := "building_index" + return mosscore.JobStatusResponse{ + JobID: jobID, + Status: string(JobStatusBuilding), + Progress: 42, + CurrentPhase: &phase, + CreatedAt: "2026-05-22T00:00:00Z", + UpdatedAt: "2026-05-22T00:00:01Z", + }, nil + } + return mosscore.JobStatusResponse{ + JobID: jobID, + Status: string(JobStatusCompleted), + Progress: 100, + CreatedAt: "2026-05-22T00:00:00Z", + UpdatedAt: "2026-05-22T00:00:02Z", + CompletedAt: ptr("2026-05-22T00:00:02Z"), + }, nil + }, + }, nil) + + progresses := []JobProgress{} + result, err := client.CreateIndex(context.Background(), "support-docs", []DocumentInfo{ + {ID: "doc-1", Text: "hello"}, + {ID: "doc-2", Text: "world"}, + }, &CreateIndexOptions{ + OnProgress: func(progress JobProgress) { + progresses = append(progresses, progress) + }, + }) + if err != nil { + t.Fatalf("CreateIndex returned error: %v", err) + } + if result.JobID != "job-create" || result.IndexName != "support-docs" || result.DocCount != 2 { + t.Fatalf("unexpected mutation result: %#v", result) + } + if len(progresses) != 2 || progresses[len(progresses)-1].Status != JobStatusCompleted { + t.Fatalf("unexpected progress updates: %#v", progresses) + } +} + +func TestCreateIndexRejectsMixedEmbeddings(t *testing.T) { + client := NewClient("project-id", "project-key") + + _, err := client.CreateIndex(context.Background(), "support-docs", []DocumentInfo{ + {ID: "doc-1", Text: "a", Embedding: []float32{1, 2}}, + {ID: "doc-2", Text: "b"}, + }, nil) + if err == nil || !strings.Contains(err.Error(), "all have embeddings or none") { + t.Fatalf("expected mixed embeddings error, got %v", err) + } +} + +func TestAddDocsUsesBindingsAndConvertsOptions(t *testing.T) { + upsert := true + client := newTestClient(&fakeManageRuntime{ + addDocsFn: func(name string, docs []mosscore.DocumentInfo, options *mosscore.MutationOptions) (mosscore.MutationResult, error) { + if name != "support-docs" { + t.Fatalf("unexpected index name: %q", name) + } + if len(docs) != 1 || docs[0].ID != "doc-3" { + t.Fatalf("unexpected docs: %#v", docs) + } + if options == nil || options.Upsert == nil || !*options.Upsert { + t.Fatalf("expected upsert option to be forwarded, got %#v", options) + } + return mosscore.MutationResult{JobID: "job-add", IndexName: name, DocCount: len(docs)}, nil + }, + getJobStatusFn: func(jobID string) (mosscore.JobStatusResponse, error) { + return mosscore.JobStatusResponse{ + JobID: jobID, + Status: string(JobStatusCompleted), + Progress: 100, + CreatedAt: "2026-05-22T00:00:00Z", + UpdatedAt: "2026-05-22T00:00:01Z", + CompletedAt: ptr("2026-05-22T00:00:01Z"), + }, nil + }, + }, nil) + + result, err := client.AddDocs(context.Background(), "support-docs", []DocumentInfo{ + {ID: "doc-3", Text: "new"}, + }, &MutationOptions{Upsert: &upsert}) + if err != nil { + t.Fatalf("AddDocs returned error: %v", err) + } + if result.JobID != "job-add" || result.DocCount != 1 { + t.Fatalf("unexpected add result: %#v", result) + } +} + +func TestDeleteDocsUsesBindings(t *testing.T) { + client := newTestClient(&fakeManageRuntime{ + deleteDocsFn: func(name string, docIDs []string) (mosscore.MutationResult, error) { + if name != "support-docs" { + t.Fatalf("unexpected index name: %q", name) + } + if len(docIDs) != 2 || docIDs[0] != "doc-1" || docIDs[1] != "doc-2" { + t.Fatalf("unexpected doc IDs: %#v", docIDs) + } + return mosscore.MutationResult{JobID: "job-del", IndexName: name, DocCount: len(docIDs)}, nil + }, + getJobStatusFn: func(jobID string) (mosscore.JobStatusResponse, error) { + return mosscore.JobStatusResponse{ + JobID: jobID, + Status: string(JobStatusCompleted), + Progress: 100, + CreatedAt: "2026-05-22T00:00:00Z", + UpdatedAt: "2026-05-22T00:00:01Z", + CompletedAt: ptr("2026-05-22T00:00:01Z"), + }, nil + }, + }, nil) + + result, err := client.DeleteDocs(context.Background(), "support-docs", []string{"doc-1", "doc-2"}, nil) + if err != nil { + t.Fatalf("DeleteDocs returned error: %v", err) + } + if result.DocCount != 2 { + t.Fatalf("unexpected delete result: %#v", result) + } +} + +func TestGetJobStatusUsesBindingsRuntime(t *testing.T) { + client := newTestClient(&fakeManageRuntime{ + getJobStatusFn: func(jobID string) (mosscore.JobStatusResponse, error) { + if jobID != "job-123" { + t.Fatalf("unexpected job ID: %q", jobID) + } + phase := "uploading" + return mosscore.JobStatusResponse{ + JobID: jobID, + Status: string(JobStatusBuilding), + Progress: 55, + CurrentPhase: &phase, + CreatedAt: "2026-05-22T00:00:00Z", + UpdatedAt: "2026-05-22T00:00:01Z", + }, nil + }, + }, nil) + + status, err := client.GetJobStatus(context.Background(), "job-123") + if err != nil { + t.Fatalf("GetJobStatus returned error: %v", err) + } + if status.JobID != "job-123" || status.Status != JobStatusBuilding || status.Progress != 55 { + t.Fatalf("unexpected job status: %#v", status) + } + if status.CurrentPhase == nil || *status.CurrentPhase != JobPhaseUploading { + t.Fatalf("unexpected current phase: %#v", status.CurrentPhase) + } +} + +func ptr(value string) *string { + return &value +} diff --git a/sdks/go/sdk/moss/options.go b/sdks/go/sdk/moss/options.go new file mode 100644 index 0000000..9523b5e --- /dev/null +++ b/sdks/go/sdk/moss/options.go @@ -0,0 +1,28 @@ +package moss + +import "net/http" + +// Option customizes client construction. +type Option func(*clientConfig) + +// WithManageURL is retained for compatibility with earlier SDK scaffolding. +// The bindings-backed client currently ignores explicit endpoint overrides. +func WithManageURL(url string) Option { + return func(cfg *clientConfig) { + cfg.manageURL = url + } +} + +// WithQueryURL is retained for compatibility with earlier SDK scaffolding. +// The bindings-backed client currently ignores explicit endpoint overrides. +func WithQueryURL(url string) Option { + return func(cfg *clientConfig) { + cfg.queryURL = url + } +} + +// WithHTTPClient is retained for compatibility with earlier SDK scaffolding. +// The bindings-backed client currently ignores custom HTTP transports. +func WithHTTPClient(_ *http.Client) Option { + return func(cfg *clientConfig) {} +} diff --git a/sdks/go/sdk/moss/query.go b/sdks/go/sdk/moss/query.go new file mode 100644 index 0000000..8fb0551 --- /dev/null +++ b/sdks/go/sdk/moss/query.go @@ -0,0 +1,68 @@ +package moss + +import ( + "context" + "encoding/json" +) + +const defaultTopK = 5 + +// Query executes a local query against a previously loaded index. +func (c *Client) Query(ctx context.Context, indexName, query string, options *QueryOptions) (SearchResult, error) { + if err := ctx.Err(); err != nil { + return SearchResult{}, err + } + if err := c.validateQueryRequest(indexName); err != nil { + return SearchResult{}, err + } + + manager, err := c.ensureIndexManager() + if err != nil { + return SearchResult{}, err + } + if !manager.HasIndex(indexName) { + return SearchResult{}, ErrIndexNotLoaded + } + return c.queryLocal(manager, indexName, query, options) +} + +func (c *Client) queryLocal(manager indexRuntime, indexName, query string, options *QueryOptions) (SearchResult, error) { + topK := defaultTopK + alpha := 0.8 + var embedding []float32 + var filterJSON *string + + if options != nil { + if options.TopK > 0 { + topK = options.TopK + } + if options.Alpha != nil { + alpha = *options.Alpha + } + if len(options.Embedding) > 0 { + embedding = options.Embedding + } + if options.Filter != nil { + bytes, err := json.Marshal(options.Filter) + if err != nil { + return SearchResult{}, err + } + value := string(bytes) + filterJSON = &value + } + } + + if len(embedding) > 0 { + result, err := manager.Query(indexName, query, embedding, topK, float32(alpha), filterJSON) + if err != nil { + return SearchResult{}, err + } + return fromCoreSearchResult(result), nil + } + + result, err := manager.QueryText(indexName, query, topK, float32(alpha), filterJSON) + if err != nil { + return SearchResult{}, err + } + return fromCoreSearchResult(result), nil +} diff --git a/sdks/go/sdk/moss/read.go b/sdks/go/sdk/moss/read.go new file mode 100644 index 0000000..440778c --- /dev/null +++ b/sdks/go/sdk/moss/read.go @@ -0,0 +1,94 @@ +package moss + +import "context" + +// GetIndex fetches metadata for a single index. +func (c *Client) GetIndex(ctx context.Context, indexName string) (IndexInfo, error) { + if err := ctx.Err(); err != nil { + return IndexInfo{}, err + } + if err := c.validateManageRequest(indexName); err != nil { + return IndexInfo{}, err + } + manage, err := c.ensureManageClient() + if err != nil { + return IndexInfo{}, err + } + response, err := manage.GetIndex(indexName) + if err != nil { + return IndexInfo{}, err + } + return fromCoreIndexInfo(response), nil +} + +// ListIndexes returns all indexes for the configured project. +func (c *Client) ListIndexes(ctx context.Context) ([]IndexInfo, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := validateCredentials(c.projectID, c.projectKey); err != nil { + return nil, err + } + manage, err := c.ensureManageClient() + if err != nil { + return nil, err + } + response, err := manage.ListIndexes() + if err != nil { + return nil, err + } + + out := make([]IndexInfo, 0, len(response)) + for _, item := range response { + out = append(out, fromCoreIndexInfo(item)) + } + return out, nil +} + +// DeleteIndex removes an index from the configured project. +func (c *Client) DeleteIndex(ctx context.Context, indexName string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if err := c.validateManageRequest(indexName); err != nil { + return false, err + } + manage, err := c.ensureManageClient() + if err != nil { + return false, err + } + ok, err := manage.DeleteIndex(indexName) + if err != nil { + return false, err + } + return ok, nil +} + +// GetDocs retrieves all documents for an index or a selected subset by ID. +func (c *Client) GetDocs(ctx context.Context, indexName string, options *GetDocumentsOptions) ([]DocumentInfo, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := c.validateManageRequest(indexName); err != nil { + return nil, err + } + var docIDs []string + if options != nil { + docIDs = options.DocIDs + } + + manage, err := c.ensureManageClient() + if err != nil { + return nil, err + } + response, err := manage.GetDocs(indexName, docIDs) + if err != nil { + return nil, err + } + + out := make([]DocumentInfo, 0, len(response)) + for _, item := range response { + out = append(out, fromCoreDocumentInfo(item)) + } + return out, nil +}