diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml deleted file mode 100644 index 532db7a..0000000 --- a/.gitea/workflows/ci.yml +++ /dev/null @@ -1,129 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -env: - NTFY_URL: http://ntfy.observability.svc.cluster.local:80 - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up uv - run: curl -LsSf https://astral.sh/uv/install.sh | sh && echo "$HOME/.local/bin" >> $GITHUB_PATH - - - name: Set up Python - run: uv python install 3.13 - - - name: Install dependencies - run: uv sync --frozen --extra dev - - - name: Run ruff check - run: uv run ruff check . - - - name: Run ruff format check - run: uv run ruff format --check . - - test: - name: Test - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up uv - run: curl -LsSf https://astral.sh/uv/install.sh | sh && echo "$HOME/.local/bin" >> $GITHUB_PATH - - - name: Set up Python - run: uv python install 3.13 - - - name: Install dependencies - run: uv sync --frozen --extra dev - - - name: Run tests - run: uv run pytest -v - - release: - name: Release - runs-on: ubuntu-latest - needs: [lint, test] - if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push' - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Determine version bump - id: version - run: | - # Get latest tag or default to v0.0.0 - LATEST=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") - VERSION=${LATEST#v} - IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" - - # Check commit message for keywords - MSG="${{ gitea.event.head_commit.message }}" - if echo "$MSG" | grep -qiE "^major:|BREAKING CHANGE"; then - MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 - BUMP="major" - elif echo "$MSG" | grep -qiE "^(minor:|feat:)"; then - MINOR=$((MINOR + 1)); PATCH=0 - BUMP="minor" - else - PATCH=$((PATCH + 1)) - BUMP="patch" - fi - - NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}" - echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "bump=$BUMP" >> $GITHUB_OUTPUT - echo "Bumping $LATEST → $NEW_VERSION ($BUMP)" - - - name: Create and push tag - run: | - git config user.name "gitea-actions[bot]" - git config user.email "actions@git.daviestechlabs.io" - git tag -a ${{ steps.version.outputs.version }} -m "Release ${{ steps.version.outputs.version }}" - git push origin ${{ steps.version.outputs.version }} - - notify: - name: Notify - runs-on: ubuntu-latest - needs: [lint, test, release] - if: always() - steps: - - name: Notify on success - if: needs.lint.result == 'success' && needs.test.result == 'success' - run: | - curl -s \ - -H "Title: ✅ CI Passed: ${{ gitea.repository }}" \ - -H "Priority: default" \ - -H "Tags: white_check_mark,github" \ - -H "Click: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \ - -d "Branch: ${{ gitea.ref_name }} - Commit: ${{ gitea.event.head_commit.message || gitea.sha }} - Release: ${{ needs.release.result == 'success' && 'created' || 'skipped' }}" \ - ${{ env.NTFY_URL }}/gitea-ci - - - name: Notify on failure - if: needs.lint.result == 'failure' || needs.test.result == 'failure' - run: | - curl -s \ - -H "Title: ❌ CI Failed: ${{ gitea.repository }}" \ - -H "Priority: high" \ - -H "Tags: x,github" \ - -H "Click: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \ - -d "Branch: ${{ gitea.ref_name }} - Commit: ${{ gitea.event.head_commit.message || gitea.sha }} - Lint: ${{ needs.lint.result }} - Test: ${{ needs.test.result }}" \ - ${{ env.NTFY_URL }}/gitea-ci diff --git a/.gitea/workflows/update-dependency.yml b/.gitea/workflows/update-dependency.yml new file mode 100644 index 0000000..94a0d2b --- /dev/null +++ b/.gitea/workflows/update-dependency.yml @@ -0,0 +1,59 @@ +name: Update handler-base + +on: + repository_dispatch: + types: [handler-base-release] + +env: + NTFY_URL: http://ntfy.observability.svc.cluster.local:80 + +jobs: + update: + name: Update handler-base dependency + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITEA_TOKEN }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Configure Git + run: | + git config user.name "gitea-actions[bot]" + git config user.email "actions@git.daviestechlabs.io" + + - name: Update handler-base + run: | + VERSION="${{ gitea.event.client_payload.version }}" + echo "Updating handler-base to ${VERSION}" + GONOSUMCHECK=git.daviestechlabs.io GONOSUMDB=git.daviestechlabs.io \ + go get git.daviestechlabs.io/daviestechlabs/handler-base@${VERSION} + go mod tidy + + - name: Commit and push + run: | + VERSION="${{ gitea.event.client_payload.version }}" + if git diff --quiet go.mod go.sum; then + echo "No changes to commit" + exit 0 + fi + git add go.mod go.sum + git commit -m "chore(deps): bump handler-base to ${VERSION}" + git push + + - name: Notify + if: success() + run: | + VERSION="${{ gitea.event.client_payload.version }}" + curl -s \ + -H "Title: 📦 Dep Update: ${{ gitea.repository }}" \ + -H "Priority: default" \ + -H "Tags: package,github" \ + -d "handler-base updated to ${VERSION}" \ + ${{ env.NTFY_URL }}/gitea-ci diff --git a/e2e_test.go b/e2e_test.go index ef10051..98a9a19 100644 --- a/e2e_test.go +++ b/e2e_test.go @@ -1,16 +1,16 @@ package main import ( -"context" -"encoding/json" -"net/http" -"net/http/httptest" -"testing" -"time" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" -"git.daviestechlabs.io/daviestechlabs/handler-base/clients" -"git.daviestechlabs.io/daviestechlabs/handler-base/messages" -"github.com/vmihailenco/msgpack/v5" + "git.daviestechlabs.io/daviestechlabs/handler-base/clients" + "git.daviestechlabs.io/daviestechlabs/handler-base/messages" + "github.com/vmihailenco/msgpack/v5" ) // ──────────────────────────────────────────────────────────────────────────── @@ -19,148 +19,148 @@ import ( // mockBackends starts httptest servers simulating all downstream services. type mockBackends struct { -Embeddings *httptest.Server -Reranker *httptest.Server -LLM *httptest.Server -TTS *httptest.Server + Embeddings *httptest.Server + Reranker *httptest.Server + LLM *httptest.Server + TTS *httptest.Server } func newMockBackends(t *testing.T) *mockBackends { -t.Helper() -m := &mockBackends{} + t.Helper() + m := &mockBackends{} -m.Embeddings = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -_ = json.NewEncoder(w).Encode(map[string]any{ -"data": []map[string]any{ -{"embedding": []float64{0.1, 0.2, 0.3, 0.4}}, -}, -}) -})) -t.Cleanup(m.Embeddings.Close) + m.Embeddings = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + {"embedding": []float64{0.1, 0.2, 0.3, 0.4}}, + }, + }) + })) + t.Cleanup(m.Embeddings.Close) -m.Reranker = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -_ = json.NewEncoder(w).Encode(map[string]any{ -"results": []map[string]any{ -{"index": 0, "relevance_score": 0.95}, -}, -}) -})) -t.Cleanup(m.Reranker.Close) + m.Reranker = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{ + {"index": 0, "relevance_score": 0.95}, + }, + }) + })) + t.Cleanup(m.Reranker.Close) -m.LLM = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -var req map[string]any -_ = json.NewDecoder(r.Body).Decode(&req) -_ = json.NewEncoder(w).Encode(map[string]any{ -"choices": []map[string]any{ -{"message": map[string]any{ -"content": "Paris is the capital of France.", -}}, -}, -}) -})) -t.Cleanup(m.LLM.Close) + m.LLM = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]any + _ = json.NewDecoder(r.Body).Decode(&req) + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + {"message": map[string]any{ + "content": "Paris is the capital of France.", + }}, + }, + }) + })) + t.Cleanup(m.LLM.Close) -m.TTS = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -_, _ = w.Write([]byte{0xDE, 0xAD, 0xBE, 0xEF}) -})) -t.Cleanup(m.TTS.Close) + m.TTS = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte{0xDE, 0xAD, 0xBE, 0xEF}) + })) + t.Cleanup(m.TTS.Close) -return m + return m } func TestChatPipeline_LLMOnly(t *testing.T) { -m := newMockBackends(t) -llm := clients.NewLLMClient(m.LLM.URL, 5*time.Second) + m := newMockBackends(t) + llm := clients.NewLLMClient(m.LLM.URL, 5*time.Second) -// Simulate what main.go does for a non-RAG request. -response, err := llm.Generate(context.Background(), "What is the capital of France?", "", "") -if err != nil { -t.Fatal(err) -} -if response != "Paris is the capital of France." { -t.Errorf("response = %q", response) -} + // Simulate what main.go does for a non-RAG request. + response, err := llm.Generate(context.Background(), "What is the capital of France?", "", "") + if err != nil { + t.Fatal(err) + } + if response != "Paris is the capital of France." { + t.Errorf("response = %q", response) + } } func TestChatPipeline_WithRAG(t *testing.T) { -m := newMockBackends(t) -embeddings := clients.NewEmbeddingsClient(m.Embeddings.URL, 5*time.Second, "bge") -reranker := clients.NewRerankerClient(m.Reranker.URL, 5*time.Second) -llm := clients.NewLLMClient(m.LLM.URL, 5*time.Second) + m := newMockBackends(t) + embeddings := clients.NewEmbeddingsClient(m.Embeddings.URL, 5*time.Second, "bge") + reranker := clients.NewRerankerClient(m.Reranker.URL, 5*time.Second) + llm := clients.NewLLMClient(m.LLM.URL, 5*time.Second) -ctx := context.Background() + ctx := context.Background() -// 1. Embed query -embedding, err := embeddings.EmbedSingle(ctx, "What is the capital of France?") -if err != nil { -t.Fatal(err) -} -if len(embedding) == 0 { -t.Fatal("empty embedding") -} + // 1. Embed query + embedding, err := embeddings.EmbedSingle(ctx, "What is the capital of France?") + if err != nil { + t.Fatal(err) + } + if len(embedding) == 0 { + t.Fatal("empty embedding") + } -// 2. Rerank (with mock documents) -docs := []string{"France is a country in Europe", "Paris is its capital"} -results, err := reranker.Rerank(ctx, "capital of France", docs, 2) -if err != nil { -t.Fatal(err) -} -if len(results) == 0 { -t.Fatal("no rerank results") -} -if results[0].Score == 0 { -t.Error("expected non-zero score") -} + // 2. Rerank (with mock documents) + docs := []string{"France is a country in Europe", "Paris is its capital"} + results, err := reranker.Rerank(ctx, "capital of France", docs, 2) + if err != nil { + t.Fatal(err) + } + if len(results) == 0 { + t.Fatal("no rerank results") + } + if results[0].Score == 0 { + t.Error("expected non-zero score") + } -// 3. Generate with context -contextText := results[0].Document -response, err := llm.Generate(ctx, "capital of France?", contextText, "") -if err != nil { -t.Fatal(err) -} -if response == "" { -t.Error("empty response") -} + // 3. Generate with context + contextText := results[0].Document + response, err := llm.Generate(ctx, "capital of France?", contextText, "") + if err != nil { + t.Fatal(err) + } + if response == "" { + t.Error("empty response") + } } func TestChatPipeline_WithTTS(t *testing.T) { -m := newMockBackends(t) -llm := clients.NewLLMClient(m.LLM.URL, 5*time.Second) -tts := clients.NewTTSClient(m.TTS.URL, 5*time.Second, "en") + m := newMockBackends(t) + llm := clients.NewLLMClient(m.LLM.URL, 5*time.Second) + tts := clients.NewTTSClient(m.TTS.URL, 5*time.Second, "en") -ctx := context.Background() + ctx := context.Background() -response, err := llm.Generate(ctx, "hello", "", "") -if err != nil { -t.Fatal(err) -} + response, err := llm.Generate(ctx, "hello", "", "") + if err != nil { + t.Fatal(err) + } -audio, err := tts.Synthesize(ctx, response, "en", "") -if err != nil { -t.Fatal(err) -} -if len(audio) == 0 { -t.Error("empty audio") -} + audio, err := tts.Synthesize(ctx, response, "en", "") + if err != nil { + t.Fatal(err) + } + if len(audio) == 0 { + t.Error("empty audio") + } } func TestChatPipeline_LLMTimeout(t *testing.T) { -// Simulate slow LLM. -slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -time.Sleep(200 * time.Millisecond) -_ = json.NewEncoder(w).Encode(map[string]any{ -"choices": []map[string]any{ -{"message": map[string]any{"content": "late response"}}, -}, -}) -})) -defer slow.Close() + // Simulate slow LLM. + slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(200 * time.Millisecond) + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + {"message": map[string]any{"content": "late response"}}, + }, + }) + })) + defer slow.Close() -llm := clients.NewLLMClient(slow.URL, 100*time.Millisecond) -_, err := llm.Generate(context.Background(), "hello", "", "") -if err == nil { -t.Error("expected timeout error") -} + llm := clients.NewLLMClient(slow.URL, 100*time.Millisecond) + _, err := llm.Generate(context.Background(), "hello", "", "") + if err == nil { + t.Error("expected timeout error") + } } func TestChatPipeline_TypedDecoding(t *testing.T) { @@ -195,7 +195,7 @@ func TestChatPipeline_TypedDecoding(t *testing.T) { } if req.SystemPrompt != "Be brief." { t.Errorf("SystemPrompt = %q", req.SystemPrompt) -} + } } // ──────────────────────────────────────────────────────────────────────────── @@ -203,45 +203,45 @@ func TestChatPipeline_TypedDecoding(t *testing.T) { // ──────────────────────────────────────────────────────────────────────────── func BenchmarkChatPipeline_LLMOnly(b *testing.B) { -llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"answer"}}]}`)) -})) -defer llmSrv.Close() + llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"answer"}}]}`)) + })) + defer llmSrv.Close() -llm := clients.NewLLMClient(llmSrv.URL, 10*time.Second) -ctx := context.Background() + llm := clients.NewLLMClient(llmSrv.URL, 10*time.Second) + ctx := context.Background() -b.ResetTimer() -for b.Loop() { -_, _ = llm.Generate(ctx, "question", "", "") -} + b.ResetTimer() + for b.Loop() { + _, _ = llm.Generate(ctx, "question", "", "") + } } func BenchmarkChatPipeline_RAGFlow(b *testing.B) { -embedSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -_, _ = w.Write([]byte(`{"data":[{"embedding":[0.1,0.2]}]}`)) -})) -defer embedSrv.Close() + embedSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":[{"embedding":[0.1,0.2]}]}`)) + })) + defer embedSrv.Close() -rerankSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -_, _ = w.Write([]byte(`{"results":[{"index":0,"relevance_score":0.9}]}`)) -})) -defer rerankSrv.Close() + rerankSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"results":[{"index":0,"relevance_score":0.9}]}`)) + })) + defer rerankSrv.Close() -llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"answer"}}]}`)) -})) -defer llmSrv.Close() + llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"answer"}}]}`)) + })) + defer llmSrv.Close() -embed := clients.NewEmbeddingsClient(embedSrv.URL, 10*time.Second, "bge") -rerank := clients.NewRerankerClient(rerankSrv.URL, 10*time.Second) -llm := clients.NewLLMClient(llmSrv.URL, 10*time.Second) -ctx := context.Background() + embed := clients.NewEmbeddingsClient(embedSrv.URL, 10*time.Second, "bge") + rerank := clients.NewRerankerClient(rerankSrv.URL, 10*time.Second) + llm := clients.NewLLMClient(llmSrv.URL, 10*time.Second) + ctx := context.Background() -b.ResetTimer() -for b.Loop() { -_, _ = embed.EmbedSingle(ctx, "question") -_, _ = rerank.Rerank(ctx, "question", []string{"doc1", "doc2"}, 2) -_, _ = llm.Generate(ctx, "question", "context", "") -} + b.ResetTimer() + for b.Loop() { + _, _ = embed.EmbedSingle(ctx, "question") + _, _ = rerank.Rerank(ctx, "question", []string{"doc1", "doc2"}, 2) + _, _ = llm.Generate(ctx, "question", "context", "") + } }