feature/go-handler-refactor #1

Merged
billy merged 3 commits from feature/go-handler-refactor into main 2026-02-20 12:33:52 +00:00
12 changed files with 413 additions and 3258 deletions
Showing only changes of commit 2e66cac1e9 - Show all commits

1
.gitignore vendored
View File

@@ -41,3 +41,4 @@ ENV/
.env .env
.env.local .env.local
*.log *.log
voice-assistant

View File

@@ -1,9 +1,23 @@
# Voice Assistant - Using handler-base with audio support # Build stage
ARG BASE_TAG=latest FROM golang:1.25-alpine AS builder
FROM ghcr.io/billy-davies-2/handler-base:${BASE_TAG}
WORKDIR /app WORKDIR /app
COPY voice_assistant.py . RUN apk add --no-cache ca-certificates
CMD ["python", "voice_assistant.py"] COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /voice-assistant .
# Runtime stage
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /voice-assistant /voice-assistant
USER 65534:65534
ENTRYPOINT ["/voice-assistant"]

42
go.mod Normal file
View File

@@ -0,0 +1,42 @@
module git.daviestechlabs.io/daviestechlabs/voice-assistant
go 1.25.1
require (
git.daviestechlabs.io/daviestechlabs/handler-base v0.0.0
github.com/nats-io/nats.go v1.48.0
)
require (
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/nats-io/nkeys v0.4.11 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
replace git.daviestechlabs.io/daviestechlabs/handler-base => ../handler-base

77
go.sum Normal file
View File

@@ -0,0 +1,77 @@
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 h1:NOyNnS19BF2SUDApbOKbDtWZ0IK7b8FJ2uAGdIWOGb0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0/go.mod h1:VL6EgVikRLcJa9ftukrHu/ZkkhFBSo1lzvdBC9CF1ss=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

225
main.go Normal file
View File

@@ -0,0 +1,225 @@
package main
import (
"context"
"encoding/base64"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"time"
"github.com/nats-io/nats.go"
"git.daviestechlabs.io/daviestechlabs/handler-base/clients"
"git.daviestechlabs.io/daviestechlabs/handler-base/config"
"git.daviestechlabs.io/daviestechlabs/handler-base/handler"
)
func main() {
cfg := config.Load()
cfg.ServiceName = "voice-assistant"
cfg.NATSQueueGroup = "voice-assistants"
// Voice-specific settings
ragTopK := getEnvInt("RAG_TOP_K", 10)
ragRerankTopK := getEnvInt("RAG_RERANK_TOP_K", 5)
ragCollection := getEnv("RAG_COLLECTION", "documents")
sttLanguage := getEnv("STT_LANGUAGE", "") // empty = auto-detect
ttsLanguage := getEnv("TTS_LANGUAGE", "en")
includeTranscription := getEnvBool("INCLUDE_TRANSCRIPTION", true)
includeSources := getEnvBool("INCLUDE_SOURCES", false)
// Service clients
timeout := 60 * time.Second
stt := clients.NewSTTClient(cfg.STTURL, timeout)
embeddings := clients.NewEmbeddingsClient(cfg.EmbeddingsURL, timeout, "")
reranker := clients.NewRerankerClient(cfg.RerankerURL, timeout)
llm := clients.NewLLMClient(cfg.LLMURL, timeout)
tts := clients.NewTTSClient(cfg.TTSURL, timeout, ttsLanguage)
milvus := clients.NewMilvusClient(cfg.MilvusHost, cfg.MilvusPort, ragCollection)
h := handler.New("voice.request", cfg)
h.OnMessage(func(ctx context.Context, msg *nats.Msg, data map[string]any) (map[string]any, error) {
requestID := strVal(data, "request_id", "unknown")
audioB64 := strVal(data, "audio", "")
language := strVal(data, "language", sttLanguage)
collection := strVal(data, "collection", ragCollection)
slog.Info("processing voice request", "request_id", requestID)
// 1. Decode audio
audioBytes, err := base64.StdEncoding.DecodeString(audioB64)
if err != nil {
return map[string]any{"request_id": requestID, "error": "Invalid audio encoding"}, nil
}
// 2. Transcribe audio → text
transcription, err := stt.Transcribe(ctx, audioBytes, language)
if err != nil {
slog.Error("STT failed", "error", err)
return map[string]any{"request_id": requestID, "error": "Transcription failed"}, nil
}
query := strings.TrimSpace(transcription.Text)
if query == "" {
slog.Warn("empty transcription", "request_id", requestID)
return map[string]any{"request_id": requestID, "error": "Could not transcribe audio"}, nil
}
slog.Info("transcribed", "text", truncate(query, 50))
// 3. Generate query embedding
embedding, err := embeddings.EmbedSingle(ctx, query)
if err != nil {
slog.Error("embedding failed", "error", err)
return map[string]any{"request_id": requestID, "error": "Embedding failed"}, nil
}
// 4. Search Milvus for context (placeholder — requires Milvus SDK)
_ = milvus
_ = collection
_ = embedding
_ = ragTopK
var documents []map[string]any // Milvus results placeholder
// 5. Rerank documents
if len(documents) > 0 {
texts := make([]string, len(documents))
for i, d := range documents {
if t, ok := d["text"].(string); ok {
texts[i] = t
}
}
reranked, err := reranker.Rerank(ctx, query, texts, ragRerankTopK)
if err != nil {
slog.Error("rerank failed", "error", err)
} else {
documents = make([]map[string]any, len(reranked))
for i, r := range reranked {
documents[i] = map[string]any{"document": r.Document, "score": r.Score}
}
}
}
// 6. Build context
var contextParts []string
for _, d := range documents {
if t, ok := d["document"].(string); ok {
contextParts = append(contextParts, t)
}
}
contextText := strings.Join(contextParts, "\n\n")
// 7. Generate LLM response
responseText, err := llm.Generate(ctx, query, contextText, "")
if err != nil {
slog.Error("LLM generation failed", "error", err)
return map[string]any{"request_id": requestID, "error": "Generation failed"}, nil
}
// 8. Synthesize speech
responseAudioBytes, err := tts.Synthesize(ctx, responseText, ttsLanguage, "")
if err != nil {
slog.Error("TTS failed", "error", err)
return map[string]any{"request_id": requestID, "error": "Speech synthesis failed"}, nil
}
responseAudioB64 := base64.StdEncoding.EncodeToString(responseAudioBytes)
// Build response
result := map[string]any{
"request_id": requestID,
"response": responseText,
"audio": responseAudioB64,
}
if includeTranscription {
result["transcription"] = query
}
if includeSources && len(documents) > 0 {
sources := make([]map[string]any, 0, 3)
for i, d := range documents {
if i >= 3 {
break
}
text := ""
if t, ok := d["document"].(string); ok && len(t) > 200 {
text = t[:200]
} else if t, ok := d["document"].(string); ok {
text = t
}
score := 0.0
if s, ok := d["score"].(float64); ok {
score = s
}
sources = append(sources, map[string]any{"text": text, "score": score})
}
result["sources"] = sources
}
// Publish to response subject
responseSubject := fmt.Sprintf("voice.response.%s", requestID)
_ = h.NATS.Publish(responseSubject, result)
slog.Info("completed voice request", "request_id", requestID)
return result, nil
})
if err := h.Run(); err != nil {
slog.Error("handler failed", "error", err)
os.Exit(1)
}
}
// Helpers
func strVal(m map[string]any, key, fallback string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return fallback
}
func boolVal(m map[string]any, key string, fallback bool) bool {
if v, ok := m[key]; ok {
if b, ok := v.(bool); ok {
return b
}
}
return fallback
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func getEnvInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return fallback
}
func getEnvBool(key string, fallback bool) bool {
if v := os.Getenv(key); v != "" {
return strings.EqualFold(v, "true") || v == "1"
}
return fallback
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}

49
main_test.go Normal file
View File

@@ -0,0 +1,49 @@
package main
import (
"testing"
)
func TestStrVal(t *testing.T) {
m := map[string]any{"key": "value"}
if got := strVal(m, "key", ""); got != "value" {
t.Errorf("strVal(key) = %q", got)
}
if got := strVal(m, "missing", "def"); got != "def" {
t.Errorf("strVal(missing) = %q", got)
}
}
func TestBoolVal(t *testing.T) {
m := map[string]any{"flag": true}
if got := boolVal(m, "flag", false); !got {
t.Error("expected true")
}
if got := boolVal(m, "missing", false); got {
t.Error("expected false fallback")
}
}
func TestGetEnvHelpers(t *testing.T) {
t.Setenv("VA_TEST", "hello")
if got := getEnv("VA_TEST", "x"); got != "hello" {
t.Errorf("getEnv = %q", got)
}
t.Setenv("VA_PORT", "9090")
if got := getEnvInt("VA_PORT", 0); got != 9090 {
t.Errorf("getEnvInt = %d", got)
}
t.Setenv("VA_FLAG", "true")
if got := getEnvBool("VA_FLAG", false); !got {
t.Error("getEnvBool should be true")
}
}
func TestTruncate(t *testing.T) {
if got := truncate("hello world", 5); got != "hello..." {
t.Errorf("truncate = %q", got)
}
if got := truncate("hi", 10); got != "hi" {
t.Errorf("truncate short = %q", got)
}
}

View File

@@ -1,43 +0,0 @@
[project]
name = "voice-assistant"
version = "1.0.0"
description = "Voice assistant pipeline - STT → RAG → LLM → TTS"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
authors = [{ name = "Davies Tech Labs" }]
dependencies = [
"handler-base @ git+https://git.daviestechlabs.io/daviestechlabs/handler-base.git",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"ruff>=0.1.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["."]
only-include = ["voice_assistant.py"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-v --tb=short"
filterwarnings = ["ignore::DeprecationWarning"]

View File

@@ -1 +0,0 @@
# Voice Assistant Tests

View File

@@ -1,147 +0,0 @@
"""
Pytest configuration and fixtures for voice-assistant tests.
"""
import asyncio
import base64
import os
from unittest.mock import MagicMock, patch
import pytest
# Set test environment variables before importing
os.environ.setdefault("NATS_URL", "nats://localhost:4222")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379")
os.environ.setdefault("MILVUS_HOST", "localhost")
os.environ.setdefault("OTEL_ENABLED", "false")
os.environ.setdefault("MLFLOW_ENABLED", "false")
@pytest.fixture(scope="session")
def event_loop():
"""Create event loop for async tests."""
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest.fixture
def sample_audio_b64():
"""Sample base64 encoded audio for testing."""
# 16-bit PCM silence (44 bytes header + 1000 samples)
wav_header = bytes(
[
0x52,
0x49,
0x46,
0x46, # "RIFF"
0x24,
0x08,
0x00,
0x00, # File size
0x57,
0x41,
0x56,
0x45, # "WAVE"
0x66,
0x6D,
0x74,
0x20, # "fmt "
0x10,
0x00,
0x00,
0x00, # Chunk size
0x01,
0x00, # PCM format
0x01,
0x00, # Mono
0x80,
0x3E,
0x00,
0x00, # Sample rate (16000)
0x00,
0x7D,
0x00,
0x00, # Byte rate
0x02,
0x00, # Block align
0x10,
0x00, # Bits per sample
0x64,
0x61,
0x74,
0x61, # "data"
0x00,
0x08,
0x00,
0x00, # Data size
]
)
silence = bytes([0x00] * 2048)
return base64.b64encode(wav_header + silence).decode()
@pytest.fixture
def sample_embedding():
"""Sample embedding vector."""
return [0.1] * 1024
@pytest.fixture
def sample_documents():
"""Sample search results."""
return [
{"text": "Machine learning is a subset of AI.", "score": 0.95},
{"text": "Deep learning uses neural networks.", "score": 0.90},
{"text": "AI enables intelligent automation.", "score": 0.85},
]
@pytest.fixture
def sample_reranked():
"""Sample reranked results."""
return [
{"document": "Machine learning is a subset of AI.", "score": 0.98},
{"document": "Deep learning uses neural networks.", "score": 0.85},
]
@pytest.fixture
def mock_nats_message():
"""Create a mock NATS message."""
msg = MagicMock()
msg.subject = "voice.request"
msg.reply = "voice.response.test-123"
return msg
@pytest.fixture
def mock_voice_request(sample_audio_b64):
"""Sample voice request payload."""
return {
"request_id": "test-request-123",
"audio": sample_audio_b64,
"language": "en",
"collection": "test_collection",
}
@pytest.fixture
def mock_clients():
"""Mock all service clients."""
with (
patch("voice_assistant.STTClient") as stt,
patch("voice_assistant.EmbeddingsClient") as embeddings,
patch("voice_assistant.RerankerClient") as reranker,
patch("voice_assistant.LLMClient") as llm,
patch("voice_assistant.TTSClient") as tts,
patch("voice_assistant.MilvusClient") as milvus,
):
yield {
"stt": stt,
"embeddings": embeddings,
"reranker": reranker,
"llm": llm,
"tts": tts,
"milvus": milvus,
}

View File

@@ -1,198 +0,0 @@
"""
Unit tests for VoiceAssistant handler.
"""
import pytest
from unittest.mock import AsyncMock, patch
# Import after environment is set up in conftest
from voice_assistant import VoiceAssistant, VoiceSettings
class TestVoiceSettings:
"""Tests for VoiceSettings configuration."""
def test_default_settings(self):
"""Test default settings values."""
settings = VoiceSettings()
assert settings.service_name == "voice-assistant"
assert settings.rag_top_k == 10
assert settings.rag_rerank_top_k == 5
assert settings.rag_collection == "documents"
assert settings.stt_language is None # Auto-detect
assert settings.tts_language == "en"
assert settings.include_transcription is True
assert settings.include_sources is False
def test_custom_settings(self, monkeypatch):
"""Test settings from environment."""
monkeypatch.setenv("RAG_TOP_K", "20")
monkeypatch.setenv("RAG_COLLECTION", "custom_collection")
# Note: Would need to re-instantiate settings to pick up env vars
settings = VoiceSettings(rag_top_k=20, rag_collection="custom_collection")
assert settings.rag_top_k == 20
assert settings.rag_collection == "custom_collection"
class TestVoiceAssistant:
"""Tests for VoiceAssistant handler."""
@pytest.fixture
def handler(self):
"""Create handler with mocked clients."""
with (
patch("voice_assistant.STTClient"),
patch("voice_assistant.EmbeddingsClient"),
patch("voice_assistant.RerankerClient"),
patch("voice_assistant.LLMClient"),
patch("voice_assistant.TTSClient"),
patch("voice_assistant.MilvusClient"),
):
handler = VoiceAssistant()
# Setup mock clients
handler.stt = AsyncMock()
handler.embeddings = AsyncMock()
handler.reranker = AsyncMock()
handler.llm = AsyncMock()
handler.tts = AsyncMock()
handler.milvus = AsyncMock()
handler.nats = AsyncMock()
yield handler
def test_init(self, handler):
"""Test handler initialization."""
assert handler.subject == "voice.request"
assert handler.queue_group == "voice-assistants"
assert handler.voice_settings.service_name == "voice-assistant"
@pytest.mark.asyncio
async def test_handle_message_success(
self,
handler,
mock_nats_message,
mock_voice_request,
sample_embedding,
sample_documents,
sample_reranked,
):
"""Test successful voice request handling."""
# Setup mocks
handler.stt.transcribe.return_value = {"text": "What is machine learning?"}
handler.embeddings.embed_single.return_value = sample_embedding
handler.milvus.search_with_texts.return_value = sample_documents
handler.reranker.rerank.return_value = sample_reranked
handler.llm.generate.return_value = "Machine learning is a type of AI."
handler.tts.synthesize.return_value = b"audio_bytes"
# Execute
result = await handler.handle_message(mock_nats_message, mock_voice_request)
# Verify
assert result["request_id"] == "test-request-123"
assert result["response"] == "Machine learning is a type of AI."
assert "audio" in result
assert result["transcription"] == "What is machine learning?"
# Verify pipeline was called
handler.stt.transcribe.assert_called_once()
handler.embeddings.embed_single.assert_called_once()
handler.milvus.search_with_texts.assert_called_once()
handler.reranker.rerank.assert_called_once()
handler.llm.generate.assert_called_once()
handler.tts.synthesize.assert_called_once()
@pytest.mark.asyncio
async def test_handle_message_empty_transcription(
self,
handler,
mock_nats_message,
mock_voice_request,
):
"""Test handling when transcription is empty."""
handler.stt.transcribe.return_value = {"text": ""}
result = await handler.handle_message(mock_nats_message, mock_voice_request)
assert "error" in result
assert result["error"] == "Could not transcribe audio"
# Verify pipeline stopped after transcription
handler.embeddings.embed_single.assert_not_called()
@pytest.mark.asyncio
async def test_handle_message_with_sources(
self,
handler,
mock_nats_message,
mock_voice_request,
sample_embedding,
sample_documents,
sample_reranked,
):
"""Test response includes sources when enabled."""
handler.voice_settings.include_sources = True
# Setup mocks
handler.stt.transcribe.return_value = {"text": "Hello"}
handler.embeddings.embed_single.return_value = sample_embedding
handler.milvus.search_with_texts.return_value = sample_documents
handler.reranker.rerank.return_value = sample_reranked
handler.llm.generate.return_value = "Hi there!"
handler.tts.synthesize.return_value = b"audio"
result = await handler.handle_message(mock_nats_message, mock_voice_request)
assert "sources" in result
assert len(result["sources"]) <= 3
def test_build_context(self, handler):
"""Test context building from documents."""
documents = [
{"document": "First doc content"},
{"document": "Second doc content"},
]
context = handler._build_context(documents)
assert "First doc content" in context
assert "Second doc content" in context
@pytest.mark.asyncio
async def test_setup_initializes_clients(self):
"""Test that setup initializes all clients."""
with (
patch("voice_assistant.STTClient") as stt_cls,
patch("voice_assistant.EmbeddingsClient") as emb_cls,
patch("voice_assistant.RerankerClient") as rer_cls,
patch("voice_assistant.LLMClient") as llm_cls,
patch("voice_assistant.TTSClient") as tts_cls,
patch("voice_assistant.MilvusClient") as mil_cls,
):
mil_cls.return_value.connect = AsyncMock()
handler = VoiceAssistant()
await handler.setup()
stt_cls.assert_called_once()
emb_cls.assert_called_once()
rer_cls.assert_called_once()
llm_cls.assert_called_once()
tts_cls.assert_called_once()
mil_cls.assert_called_once()
@pytest.mark.asyncio
async def test_teardown_closes_clients(self, handler):
"""Test that teardown closes all clients."""
await handler.teardown()
handler.stt.close.assert_called_once()
handler.embeddings.close.assert_called_once()
handler.reranker.close.assert_called_once()
handler.llm.close.assert_called_once()
handler.tts.close.assert_called_once()
handler.milvus.close.assert_called_once()

2638
uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,226 +0,0 @@
#!/usr/bin/env python3
"""
Voice Assistant Service (Refactored)
End-to-end voice assistant pipeline using handler-base:
1. Listen for audio on NATS subject "voice.request"
2. Transcribe with Whisper (STT)
3. Generate embeddings for RAG
4. Retrieve context from Milvus
5. Rerank with BGE reranker
6. Generate response with vLLM
7. Synthesize speech with XTTS
8. Publish result to NATS "voice.response.{request_id}"
"""
import base64
import logging
from typing import Any, Optional
from nats.aio.msg import Msg
from handler_base import Handler, Settings
from handler_base.clients import (
EmbeddingsClient,
RerankerClient,
LLMClient,
TTSClient,
STTClient,
MilvusClient,
)
from handler_base.telemetry import create_span
logger = logging.getLogger("voice-assistant")
class VoiceSettings(Settings):
"""Voice assistant specific settings."""
service_name: str = "voice-assistant"
# RAG settings
rag_top_k: int = 10
rag_rerank_top_k: int = 5
rag_collection: str = "documents"
# Audio settings
stt_language: Optional[str] = None # Auto-detect
tts_language: str = "en"
# Response settings
include_transcription: bool = True
include_sources: bool = False
class VoiceAssistant(Handler):
"""
Voice request handler with full STT -> RAG -> LLM -> TTS pipeline.
Request format (msgpack):
{
"request_id": "uuid",
"audio": "base64 encoded audio",
"language": "optional language code",
"collection": "optional collection name"
}
Response format:
{
"request_id": "uuid",
"transcription": "what the user said",
"response": "generated text response",
"audio": "base64 encoded response audio"
}
"""
def __init__(self):
self.voice_settings = VoiceSettings()
super().__init__(
subject="voice.request",
settings=self.voice_settings,
queue_group="voice-assistants",
)
async def setup(self) -> None:
"""Initialize service clients."""
logger.info("Initializing voice assistant clients...")
self.stt = STTClient(self.voice_settings)
self.embeddings = EmbeddingsClient(self.voice_settings)
self.reranker = RerankerClient(self.voice_settings)
self.llm = LLMClient(self.voice_settings)
self.tts = TTSClient(self.voice_settings)
self.milvus = MilvusClient(self.voice_settings)
await self.milvus.connect(self.voice_settings.rag_collection)
logger.info("Voice assistant clients initialized")
async def teardown(self) -> None:
"""Clean up service clients."""
logger.info("Closing voice assistant clients...")
await self.stt.close()
await self.embeddings.close()
await self.reranker.close()
await self.llm.close()
await self.tts.close()
await self.milvus.close()
logger.info("Voice assistant clients closed")
async def handle_message(self, msg: Msg, data: Any) -> Optional[dict]:
"""Handle incoming voice request."""
request_id = data.get("request_id", "unknown")
audio_b64 = data.get("audio", "")
language = data.get("language", self.voice_settings.stt_language)
collection = data.get("collection", self.voice_settings.rag_collection)
logger.info(f"Processing voice request {request_id}")
with create_span("voice.process") as span:
if span:
span.set_attribute("request.id", request_id)
# 1. Decode audio
audio_bytes = base64.b64decode(audio_b64)
# 2. Transcribe audio to text
transcription = await self._transcribe(audio_bytes, language)
query = transcription.get("text", "")
if not query.strip():
logger.warning(f"Empty transcription for request {request_id}")
return {
"request_id": request_id,
"error": "Could not transcribe audio",
}
logger.info(f"Transcribed: {query[:50]}...")
# 3. Generate query embedding
embedding = await self._get_embedding(query)
# 4. Search Milvus for context
documents = await self._search_context(embedding, collection)
# 5. Rerank documents
reranked = await self._rerank_documents(query, documents)
# 6. Build context
context = self._build_context(reranked)
# 7. Generate LLM response
response_text = await self._generate_response(query, context)
# 8. Synthesize speech
response_audio = await self._synthesize_speech(response_text)
# Build response
result = {
"request_id": request_id,
"response": response_text,
"audio": response_audio,
}
if self.voice_settings.include_transcription:
result["transcription"] = query
if self.voice_settings.include_sources:
result["sources"] = [
{"text": d["document"][:200], "score": d["score"]} for d in reranked[:3]
]
logger.info(f"Completed voice request {request_id}")
# Publish to response subject
response_subject = f"voice.response.{request_id}"
await self.nats.publish(response_subject, result)
return result
async def _transcribe(self, audio: bytes, language: Optional[str]) -> dict:
"""Transcribe audio to text."""
with create_span("voice.stt"):
return await self.stt.transcribe(audio, language=language)
async def _get_embedding(self, text: str) -> list[float]:
"""Generate embedding for query text."""
with create_span("voice.embedding"):
return await self.embeddings.embed_single(text)
async def _search_context(self, embedding: list[float], collection: str) -> list[dict]:
"""Search Milvus for relevant documents."""
with create_span("voice.search"):
return await self.milvus.search_with_texts(
embedding,
limit=self.voice_settings.rag_top_k,
text_field="text",
)
async def _rerank_documents(self, query: str, documents: list[dict]) -> list[dict]:
"""Rerank documents by relevance."""
with create_span("voice.rerank"):
texts = [d.get("text", "") for d in documents]
return await self.reranker.rerank(
query, texts, top_k=self.voice_settings.rag_rerank_top_k
)
def _build_context(self, documents: list[dict]) -> str:
"""Build context string from ranked documents."""
return "\n\n".join(d.get("document", "") for d in documents)
async def _generate_response(self, query: str, context: str) -> str:
"""Generate LLM response."""
with create_span("voice.generate"):
return await self.llm.generate(query, context=context)
async def _synthesize_speech(self, text: str) -> str:
"""Synthesize speech and return base64."""
with create_span("voice.tts"):
audio_bytes = await self.tts.synthesize(text, language=self.voice_settings.tts_language)
return base64.b64encode(audio_bytes).decode()
if __name__ == "__main__":
VoiceAssistant().run()