add llm adapter interface and implementations

This commit is contained in:
leonardotrapani
2026-01-31 20:42:14 +01:00
parent e3bf09b02f
commit bb8effc69f
7 changed files with 424 additions and 1 deletions
+73
View File
@@ -0,0 +1,73 @@
package llm
import (
"context"
"fmt"
"log"
"time"
"github.com/sashabaranov/go-openai"
)
// GroqAdapter implements Adapter using Groq's OpenAI-compatible API
type GroqAdapter struct {
client *openai.Client
config Config
}
// NewGroqAdapter creates a new Groq LLM adapter
func NewGroqAdapter(cfg Config) *GroqAdapter {
clientConfig := openai.DefaultConfig(cfg.APIKey)
clientConfig.BaseURL = "https://api.groq.com/openai/v1"
return &GroqAdapter{
client: openai.NewClientWithConfig(clientConfig),
config: cfg,
}
}
func (a *GroqAdapter) Process(ctx context.Context, text string) (string, error) {
if text == "" {
return "", nil
}
opts := PostProcessingOptions{
RemoveStutters: a.config.RemoveStutters,
AddPunctuation: a.config.AddPunctuation,
FixGrammar: a.config.FixGrammar,
RemoveFillerWords: a.config.RemoveFillerWords,
}
systemPrompt := BuildSystemPrompt(opts, a.config.Keywords)
userPrompt := BuildUserPrompt(text, a.config.CustomPrompt)
model := a.config.Model
if model == "" {
model = "llama-3.3-70b-versatile"
}
req := openai.ChatCompletionRequest{
Model: model,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
{Role: openai.ChatMessageRoleUser, Content: userPrompt},
},
Temperature: 0.3, // Low temperature for consistent cleanup
}
start := time.Now()
resp, err := a.client.CreateChatCompletion(ctx, req)
duration := time.Since(start)
if err != nil {
log.Printf("groq-llm-adapter: API call failed after %v: %v", duration, err)
return "", fmt.Errorf("groq chat completion: %w", err)
}
if len(resp.Choices) == 0 {
return "", fmt.Errorf("groq chat completion: no response choices")
}
result := resp.Choices[0].Message.Content
log.Printf("groq-llm-adapter: processed in %v: %q -> %q", duration, text, result)
return result, nil
}
+71
View File
@@ -0,0 +1,71 @@
package llm
import (
"context"
"fmt"
"log"
"time"
"github.com/sashabaranov/go-openai"
)
// OpenAIAdapter implements Adapter using OpenAI's chat completions API
type OpenAIAdapter struct {
client *openai.Client
config Config
}
// NewOpenAIAdapter creates a new OpenAI LLM adapter
func NewOpenAIAdapter(cfg Config) *OpenAIAdapter {
return &OpenAIAdapter{
client: openai.NewClient(cfg.APIKey),
config: cfg,
}
}
func (a *OpenAIAdapter) Process(ctx context.Context, text string) (string, error) {
if text == "" {
return "", nil
}
opts := PostProcessingOptions{
RemoveStutters: a.config.RemoveStutters,
AddPunctuation: a.config.AddPunctuation,
FixGrammar: a.config.FixGrammar,
RemoveFillerWords: a.config.RemoveFillerWords,
}
systemPrompt := BuildSystemPrompt(opts, a.config.Keywords)
userPrompt := BuildUserPrompt(text, a.config.CustomPrompt)
model := a.config.Model
if model == "" {
model = "gpt-4o-mini"
}
req := openai.ChatCompletionRequest{
Model: model,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
{Role: openai.ChatMessageRoleUser, Content: userPrompt},
},
Temperature: 0.3, // Low temperature for consistent cleanup
}
start := time.Now()
resp, err := a.client.CreateChatCompletion(ctx, req)
duration := time.Since(start)
if err != nil {
log.Printf("openai-llm-adapter: API call failed after %v: %v", duration, err)
return "", fmt.Errorf("openai chat completion: %w", err)
}
if len(resp.Choices) == 0 {
return "", fmt.Errorf("openai chat completion: no response choices")
}
result := resp.Choices[0].Message.Content
log.Printf("openai-llm-adapter: processed in %v: %q -> %q", duration, text, result)
return result, nil
}
+42
View File
@@ -0,0 +1,42 @@
package llm
import (
"context"
"fmt"
)
// Adapter interface for LLM text processing
type Adapter interface {
Process(ctx context.Context, text string) (string, error)
}
// Config holds LLM adapter configuration
type Config struct {
Provider string
APIKey string
Model string
RemoveStutters bool
AddPunctuation bool
FixGrammar bool
RemoveFillerWords bool
CustomPrompt string
Keywords []string
}
// NewAdapter creates an LLM adapter based on the provider
func NewAdapter(cfg Config) (Adapter, error) {
switch cfg.Provider {
case "openai":
if cfg.APIKey == "" {
return nil, fmt.Errorf("OpenAI API key required")
}
return NewOpenAIAdapter(cfg), nil
case "groq":
if cfg.APIKey == "" {
return nil, fmt.Errorf("Groq API key required")
}
return NewGroqAdapter(cfg), nil
default:
return nil, fmt.Errorf("unsupported LLM provider: %s", cfg.Provider)
}
}
+155
View File
@@ -0,0 +1,155 @@
package llm
import (
"strings"
"testing"
)
func TestBuildSystemPrompt(t *testing.T) {
tests := []struct {
name string
opts PostProcessingOptions
keywords []string
contains []string
}{
{
name: "all options enabled",
opts: PostProcessingOptions{
RemoveStutters: true,
AddPunctuation: true,
FixGrammar: true,
RemoveFillerWords: true,
},
keywords: nil,
contains: []string{
"Remove stutters",
"Add proper punctuation",
"Fix grammar",
"Remove filler words",
},
},
{
name: "only grammar",
opts: PostProcessingOptions{
FixGrammar: true,
},
keywords: nil,
contains: []string{
"Fix grammar",
},
},
{
name: "with keywords",
opts: PostProcessingOptions{
RemoveStutters: true,
},
keywords: []string{"Kubernetes", "TypeScript", "hyprvoice"},
contains: []string{
"Kubernetes",
"TypeScript",
"hyprvoice",
"Context keywords",
},
},
{
name: "no options - should have default",
opts: PostProcessingOptions{},
keywords: nil,
contains: []string{
"Clean up the text",
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := BuildSystemPrompt(tc.opts, tc.keywords)
for _, expected := range tc.contains {
if !strings.Contains(result, expected) {
t.Errorf("expected prompt to contain %q, got: %s", expected, result)
}
}
})
}
}
func TestBuildUserPrompt(t *testing.T) {
tests := []struct {
name string
text string
customPrompt string
expected string
}{
{
name: "no custom prompt",
text: "hello world",
customPrompt: "",
expected: "hello world",
},
{
name: "with custom prompt",
text: "hello world",
customPrompt: "Format as a haiku",
expected: "Format as a haiku\n\nText to process:\nhello world",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := BuildUserPrompt(tc.text, tc.customPrompt)
if result != tc.expected {
t.Errorf("expected %q, got %q", tc.expected, result)
}
})
}
}
func TestNewAdapter(t *testing.T) {
// Test OpenAI adapter creation
openaiCfg := Config{
Provider: "openai",
APIKey: "sk-test-key",
Model: "gpt-4o-mini",
}
adapter, err := NewAdapter(openaiCfg)
if err != nil {
t.Fatalf("failed to create openai adapter: %v", err)
}
if _, ok := adapter.(*OpenAIAdapter); !ok {
t.Error("expected OpenAIAdapter type")
}
// Test Groq adapter creation
groqCfg := Config{
Provider: "groq",
APIKey: "gsk_test-key",
Model: "llama-3.3-70b-versatile",
}
adapter, err = NewAdapter(groqCfg)
if err != nil {
t.Fatalf("failed to create groq adapter: %v", err)
}
if _, ok := adapter.(*GroqAdapter); !ok {
t.Error("expected GroqAdapter type")
}
// Test missing API key
noKeyCfg := Config{
Provider: "openai",
APIKey: "",
}
_, err = NewAdapter(noKeyCfg)
if err == nil {
t.Error("expected error for missing API key")
}
// Test unsupported provider
badCfg := Config{
Provider: "unsupported",
APIKey: "key",
}
_, err = NewAdapter(badCfg)
if err == nil {
t.Error("expected error for unsupported provider")
}
}
+65
View File
@@ -0,0 +1,65 @@
package llm
import (
"fmt"
"strings"
)
// PostProcessingOptions controls which cleanup operations to request
type PostProcessingOptions struct {
RemoveStutters bool
AddPunctuation bool
FixGrammar bool
RemoveFillerWords bool
}
// BuildSystemPrompt generates the system prompt for text cleanup
func BuildSystemPrompt(opts PostProcessingOptions, keywords []string) string {
var tasks []string
if opts.RemoveStutters {
tasks = append(tasks, "Remove stutters and repeated words/phrases")
}
if opts.AddPunctuation {
tasks = append(tasks, "Add proper punctuation")
}
if opts.FixGrammar {
tasks = append(tasks, "Fix grammar errors")
}
if opts.RemoveFillerWords {
tasks = append(tasks, "Remove filler words (um, uh, like, you know, etc.)")
}
// If no tasks, just clean up generally
if len(tasks) == 0 {
tasks = append(tasks, "Clean up the text while preserving meaning")
}
prompt := "You are a text cleanup assistant. Your job is to clean up speech-to-text transcriptions.\n\n"
prompt += "Tasks:\n"
for _, task := range tasks {
prompt += fmt.Sprintf("- %s\n", task)
}
prompt += "\nRules:\n"
prompt += "- Preserve the original meaning and intent\n"
prompt += "- Keep the same language as the input\n"
prompt += "- Do not add any new information\n"
prompt += "- Do not remove meaningful content\n"
prompt += "- Output ONLY the cleaned text, nothing else\n"
prompt += "- If the input is empty or nonsensical, return it as-is\n"
if len(keywords) > 0 {
prompt += fmt.Sprintf("\nContext keywords (use correct spelling for these terms): %s\n", strings.Join(keywords, ", "))
}
return prompt
}
// BuildUserPrompt generates the user prompt with the text to process
func BuildUserPrompt(text string, customPrompt string) string {
if customPrompt != "" {
return fmt.Sprintf("%s\n\nText to process:\n%s", customPrompt, text)
}
return text
}
+17
View File
@@ -47,3 +47,20 @@ Key decisions:
- LLM provider names are "openai" and "groq" (not "groq-transcription") - LLM provider names are "openai" and "groq" (not "groq-transcription")
- PostProcessing defaults to all true only if ALL options are false (zero values) - PostProcessing defaults to all true only if ALL options are false (zero values)
- Keywords at root level (global), used by both transcription and LLM - Keywords at root level (global), used by both transcription and LLM
## Task 3: Create LLM adapter interface and implementations - COMPLETE
Created internal/llm package with:
- `Adapter` interface: `Process(ctx, text) (string, error)`
- `Config` struct mirroring config.LLMAdapterConfig
- `prompt.go` with `BuildSystemPrompt(opts, keywords)` and `BuildUserPrompt(text, customPrompt)`
- `OpenAIAdapter` using go-openai chat completions API
- `GroqAdapter` using Groq's OpenAI-compatible API (baseURL override)
- `NewAdapter(config)` factory function
Key decisions:
- Low temperature (0.3) for consistent text cleanup
- Default models: gpt-4o-mini (OpenAI), llama-3.3-70b-versatile (Groq)
- System prompt builds dynamically based on enabled options
- Keywords included in system prompt for correct spelling hints
- Custom prompt prepended to user prompt if enabled
+1 -1
View File
@@ -66,7 +66,7 @@
"Factory returns correct adapter", "Factory returns correct adapter",
"Typecheck passes" "Typecheck passes"
], ],
"passes": false "passes": true
}, },
{ {
"title": "Integrate LLM phase into pipeline", "title": "Integrate LLM phase into pipeline",