ci: add handler-base auto-update workflow, remove old Python CI
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Release (push) Has been cancelled
CI / Docker Build & Push (push) Has been cancelled
CI / Notify (push) Has been cancelled

This commit is contained in:
2026-02-20 09:05:58 -05:00
parent 2fa3668e8a
commit 808f41bc90
3 changed files with 214 additions and 284 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -1,16 +1,16 @@
package main package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time" "time"
"git.daviestechlabs.io/daviestechlabs/handler-base/clients" "git.daviestechlabs.io/daviestechlabs/handler-base/clients"
"git.daviestechlabs.io/daviestechlabs/handler-base/messages" "git.daviestechlabs.io/daviestechlabs/handler-base/messages"
"github.com/vmihailenco/msgpack/v5" "github.com/vmihailenco/msgpack/v5"
) )
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
@@ -19,148 +19,148 @@ import (
// mockBackends starts httptest servers simulating all downstream services. // mockBackends starts httptest servers simulating all downstream services.
type mockBackends struct { type mockBackends struct {
Embeddings *httptest.Server Embeddings *httptest.Server
Reranker *httptest.Server Reranker *httptest.Server
LLM *httptest.Server LLM *httptest.Server
TTS *httptest.Server TTS *httptest.Server
} }
func newMockBackends(t *testing.T) *mockBackends { func newMockBackends(t *testing.T) *mockBackends {
t.Helper() t.Helper()
m := &mockBackends{} m := &mockBackends{}
m.Embeddings = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { m.Embeddings = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"data": []map[string]any{ "data": []map[string]any{
{"embedding": []float64{0.1, 0.2, 0.3, 0.4}}, {"embedding": []float64{0.1, 0.2, 0.3, 0.4}},
}, },
}) })
})) }))
t.Cleanup(m.Embeddings.Close) t.Cleanup(m.Embeddings.Close)
m.Reranker = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { m.Reranker = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{ "results": []map[string]any{
{"index": 0, "relevance_score": 0.95}, {"index": 0, "relevance_score": 0.95},
}, },
}) })
})) }))
t.Cleanup(m.Reranker.Close) t.Cleanup(m.Reranker.Close)
m.LLM = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { m.LLM = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req map[string]any var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req) _ = json.NewDecoder(r.Body).Decode(&req)
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{ "choices": []map[string]any{
{"message": map[string]any{ {"message": map[string]any{
"content": "Paris is the capital of France.", "content": "Paris is the capital of France.",
}}, }},
}, },
}) })
})) }))
t.Cleanup(m.LLM.Close) t.Cleanup(m.LLM.Close)
m.TTS = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { m.TTS = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte{0xDE, 0xAD, 0xBE, 0xEF}) _, _ = w.Write([]byte{0xDE, 0xAD, 0xBE, 0xEF})
})) }))
t.Cleanup(m.TTS.Close) t.Cleanup(m.TTS.Close)
return m return m
} }
func TestChatPipeline_LLMOnly(t *testing.T) { func TestChatPipeline_LLMOnly(t *testing.T) {
m := newMockBackends(t) m := newMockBackends(t)
llm := clients.NewLLMClient(m.LLM.URL, 5*time.Second) llm := clients.NewLLMClient(m.LLM.URL, 5*time.Second)
// Simulate what main.go does for a non-RAG request. // Simulate what main.go does for a non-RAG request.
response, err := llm.Generate(context.Background(), "What is the capital of France?", "", "") response, err := llm.Generate(context.Background(), "What is the capital of France?", "", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if response != "Paris is the capital of France." { if response != "Paris is the capital of France." {
t.Errorf("response = %q", response) t.Errorf("response = %q", response)
} }
} }
func TestChatPipeline_WithRAG(t *testing.T) { func TestChatPipeline_WithRAG(t *testing.T) {
m := newMockBackends(t) m := newMockBackends(t)
embeddings := clients.NewEmbeddingsClient(m.Embeddings.URL, 5*time.Second, "bge") embeddings := clients.NewEmbeddingsClient(m.Embeddings.URL, 5*time.Second, "bge")
reranker := clients.NewRerankerClient(m.Reranker.URL, 5*time.Second) reranker := clients.NewRerankerClient(m.Reranker.URL, 5*time.Second)
llm := clients.NewLLMClient(m.LLM.URL, 5*time.Second) llm := clients.NewLLMClient(m.LLM.URL, 5*time.Second)
ctx := context.Background() ctx := context.Background()
// 1. Embed query // 1. Embed query
embedding, err := embeddings.EmbedSingle(ctx, "What is the capital of France?") embedding, err := embeddings.EmbedSingle(ctx, "What is the capital of France?")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(embedding) == 0 { if len(embedding) == 0 {
t.Fatal("empty embedding") t.Fatal("empty embedding")
} }
// 2. Rerank (with mock documents) // 2. Rerank (with mock documents)
docs := []string{"France is a country in Europe", "Paris is its capital"} docs := []string{"France is a country in Europe", "Paris is its capital"}
results, err := reranker.Rerank(ctx, "capital of France", docs, 2) results, err := reranker.Rerank(ctx, "capital of France", docs, 2)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(results) == 0 { if len(results) == 0 {
t.Fatal("no rerank results") t.Fatal("no rerank results")
} }
if results[0].Score == 0 { if results[0].Score == 0 {
t.Error("expected non-zero score") t.Error("expected non-zero score")
} }
// 3. Generate with context // 3. Generate with context
contextText := results[0].Document contextText := results[0].Document
response, err := llm.Generate(ctx, "capital of France?", contextText, "") response, err := llm.Generate(ctx, "capital of France?", contextText, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if response == "" { if response == "" {
t.Error("empty response") t.Error("empty response")
} }
} }
func TestChatPipeline_WithTTS(t *testing.T) { func TestChatPipeline_WithTTS(t *testing.T) {
m := newMockBackends(t) m := newMockBackends(t)
llm := clients.NewLLMClient(m.LLM.URL, 5*time.Second) llm := clients.NewLLMClient(m.LLM.URL, 5*time.Second)
tts := clients.NewTTSClient(m.TTS.URL, 5*time.Second, "en") tts := clients.NewTTSClient(m.TTS.URL, 5*time.Second, "en")
ctx := context.Background() ctx := context.Background()
response, err := llm.Generate(ctx, "hello", "", "") response, err := llm.Generate(ctx, "hello", "", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
audio, err := tts.Synthesize(ctx, response, "en", "") audio, err := tts.Synthesize(ctx, response, "en", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(audio) == 0 { if len(audio) == 0 {
t.Error("empty audio") t.Error("empty audio")
} }
} }
func TestChatPipeline_LLMTimeout(t *testing.T) { func TestChatPipeline_LLMTimeout(t *testing.T) {
// Simulate slow LLM. // Simulate slow LLM.
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond) time.Sleep(200 * time.Millisecond)
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{ "choices": []map[string]any{
{"message": map[string]any{"content": "late response"}}, {"message": map[string]any{"content": "late response"}},
}, },
}) })
})) }))
defer slow.Close() defer slow.Close()
llm := clients.NewLLMClient(slow.URL, 100*time.Millisecond) llm := clients.NewLLMClient(slow.URL, 100*time.Millisecond)
_, err := llm.Generate(context.Background(), "hello", "", "") _, err := llm.Generate(context.Background(), "hello", "", "")
if err == nil { if err == nil {
t.Error("expected timeout error") t.Error("expected timeout error")
} }
} }
func TestChatPipeline_TypedDecoding(t *testing.T) { func TestChatPipeline_TypedDecoding(t *testing.T) {
@@ -195,7 +195,7 @@ func TestChatPipeline_TypedDecoding(t *testing.T) {
} }
if req.SystemPrompt != "Be brief." { if req.SystemPrompt != "Be brief." {
t.Errorf("SystemPrompt = %q", req.SystemPrompt) t.Errorf("SystemPrompt = %q", req.SystemPrompt)
} }
} }
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
@@ -203,45 +203,45 @@ func TestChatPipeline_TypedDecoding(t *testing.T) {
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
func BenchmarkChatPipeline_LLMOnly(b *testing.B) { func BenchmarkChatPipeline_LLMOnly(b *testing.B) {
llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"answer"}}]}`)) _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"answer"}}]}`))
})) }))
defer llmSrv.Close() defer llmSrv.Close()
llm := clients.NewLLMClient(llmSrv.URL, 10*time.Second) llm := clients.NewLLMClient(llmSrv.URL, 10*time.Second)
ctx := context.Background() ctx := context.Background()
b.ResetTimer() b.ResetTimer()
for b.Loop() { for b.Loop() {
_, _ = llm.Generate(ctx, "question", "", "") _, _ = llm.Generate(ctx, "question", "", "")
} }
} }
func BenchmarkChatPipeline_RAGFlow(b *testing.B) { func BenchmarkChatPipeline_RAGFlow(b *testing.B) {
embedSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { embedSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":[{"embedding":[0.1,0.2]}]}`)) _, _ = w.Write([]byte(`{"data":[{"embedding":[0.1,0.2]}]}`))
})) }))
defer embedSrv.Close() defer embedSrv.Close()
rerankSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rerankSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"results":[{"index":0,"relevance_score":0.9}]}`)) _, _ = w.Write([]byte(`{"results":[{"index":0,"relevance_score":0.9}]}`))
})) }))
defer rerankSrv.Close() defer rerankSrv.Close()
llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"answer"}}]}`)) _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"answer"}}]}`))
})) }))
defer llmSrv.Close() defer llmSrv.Close()
embed := clients.NewEmbeddingsClient(embedSrv.URL, 10*time.Second, "bge") embed := clients.NewEmbeddingsClient(embedSrv.URL, 10*time.Second, "bge")
rerank := clients.NewRerankerClient(rerankSrv.URL, 10*time.Second) rerank := clients.NewRerankerClient(rerankSrv.URL, 10*time.Second)
llm := clients.NewLLMClient(llmSrv.URL, 10*time.Second) llm := clients.NewLLMClient(llmSrv.URL, 10*time.Second)
ctx := context.Background() ctx := context.Background()
b.ResetTimer() b.ResetTimer()
for b.Loop() { for b.Loop() {
_, _ = embed.EmbedSingle(ctx, "question") _, _ = embed.EmbedSingle(ctx, "question")
_, _ = rerank.Rerank(ctx, "question", []string{"doc1", "doc2"}, 2) _, _ = rerank.Rerank(ctx, "question", []string{"doc1", "doc2"}, 2)
_, _ = llm.Generate(ctx, "question", "context", "") _, _ = llm.Generate(ctx, "question", "context", "")
} }
} }