Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@ sql-doctor/
│ ├── migration/
│ │ └── analyzer.go # Migration lock risk & destructive check
│ ├── ai/
│ │ ├── provider.go # AIProvider interface
│ │ ├── provider.go # AIProvider interface & curated models
│ │ ├── base.go # BaseProvider & shared prompt templates
│ │ ├── gemini/ # Google GenAI SDK client
│ │ ├── openai/ # OpenAI & Ollama/local LLM client
│ │ ├── claude/ # Anthropic Claude Messages API client
│ │ └── context/ # Schema context minifier & prompt builder
│ ├── storage/
│ │ └── sqlite.go # Local SQLite state repo (~/.sql-doctor/)
Expand Down
30 changes: 22 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,20 +268,34 @@ sql-doctor format "select id,name from users where status='active' and age>21 or

---

### 10. Optional Gemini AI Assistant
If you want AI explanations or natural-language query generation, add your own Gemini API key:
### 10. Multi-Model AI Assistant (Gemini, OpenAI, Claude, Ollama)
If you want AI explanations or natural-language query generation, SQL Doctor supports **Google Gemini**, **OpenAI (ChatGPT)**, **Anthropic Claude**, and **Ollama / Local LLMs** (OpenAI-compatible):

```bash
# Configure your API key
sql-doctor config set-ai-key <your-api-key>

# Verify configuration
# Open the interactive AI configuration dashboard
sql-doctor config ai

# Ask questions grounded in your schema
# Or switch provider and model directly:
sql-doctor config ai switch openai gpt-4o-mini
sql-doctor config ai switch claude claude-3-5-haiku-20241022
sql-doctor config ai switch gemini gemini-3.8-flash
sql-doctor config ai switch ollama deepseek-r1:8b

# Configure API keys (prompts with masked input if key omitted):
sql-doctor config ai set-key openai
sql-doctor config ai set-key claude
sql-doctor config ai set-key gemini

# Set custom endpoint for Ollama / local models:
sql-doctor config ai set-endpoint http://localhost:11434/v1

# Test connection and latency:
sql-doctor config ai test

# Ask questions grounded in your schema:
sql-doctor ask "Which tables store customer billing records?"

# Generate queries
# Generate queries (with interactive execution prompt):
sql-doctor ask "Write a query to find the top 5 customers by revenue this year"
```

Expand Down
177 changes: 177 additions & 0 deletions internal/ai/base.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package ai

import (
"context"
"fmt"
"strings"

"github.com/sql-doctor/sql-doctor/internal/query/analyzer"
)

// BaseCaller is a function that makes the actual API call to the LLM backend
type BaseCaller func(ctx context.Context, systemPrompt, userPrompt string) (string, error)

// BaseProvider implements the standard AIProvider methods on top of a BaseCaller
type BaseProvider struct {
Name string
ModelName string
Configured bool
Caller BaseCaller
}

func (b *BaseProvider) IsConfigured() bool {
return b.Configured
}

func (b *BaseProvider) ProviderName() string {
return b.Name
}

func (b *BaseProvider) Model() string {
return b.ModelName
}

func (b *BaseProvider) TestConnection(ctx context.Context) error {
if !b.Configured {
return fmt.Errorf("%s is not configured (missing API key or endpoint)", b.Name)
}
res, err := b.Caller(ctx, "You are a test ping agent.", "Reply with the single word 'PONG' only.")
if err != nil {
return err
}
if strings.TrimSpace(res) == "" {
return fmt.Errorf("received empty response from %s", b.Name)
}
return nil
}

func (b *BaseProvider) ExplainQuery(ctx context.Context, sqlQuery string, metrics *analyzer.QueryAnalysisResult) (string, error) {
sys := "You are a Senior Principal Database Performance Engineer. Provide clear, concise, actionable query analysis."
var metricsStr string
if metrics != nil {
metricsStr = fmt.Sprintf("Execution Time: %.2fms\nRows Examined: %d\nRows Returned: %d\nFull Table Scan: %v\nPerformance Score: %d/100",
metrics.ExecutionTimeMs, metrics.RowsExamined, metrics.RowsReturned, metrics.HasFullTableScan, metrics.PerformanceScore)
}

prompt := fmt.Sprintf(`Explain the execution behavior and performance characteristics of this SQL query:

SQL:
%s

Observed Metrics:
%s

Explain in 2-3 concise paragraphs:
1. What the query is doing logically.
2. Why the query is fast or slow based on the observed metrics.
3. Specific actionable steps to improve it.`, sqlQuery, metricsStr)

return b.Caller(ctx, sys, prompt)
}

func (b *BaseProvider) OptimizeQuery(ctx context.Context, sqlQuery string, schemaContext string, metrics *analyzer.QueryAnalysisResult) (string, error) {
sys := "You are an expert SQL Query Optimizer. Always prioritize index selection, sargability, and deterministic query rewrites."
prompt := fmt.Sprintf(`Analyze and provide concrete optimization recommendations for this SQL query:

QUERY:
%s

SCHEMA CONTEXT:
%s

Provide:
1. Suggested optimized SQL rewrite.
2. Any recommended composite or single-column indexes with exact DDL.
3. Rationale explaining why the rewrite is faster.`, sqlQuery, schemaContext)

return b.Caller(ctx, sys, prompt)
}

func (b *BaseProvider) ReviewSchema(ctx context.Context, schemaSummary string) (string, error) {
sys := "You are a Senior Database Architect. Review database schema design, normalization, relationships, and index strategy."
prompt := fmt.Sprintf(`Review this database schema and identify design smells, missing constraints, or performance hazards:

%s

Provide:
1. Architectural strengths and design quality evaluation.
2. High-priority schema risks or normalization smells.
3. Recommended improvements.`, schemaSummary)

return b.Caller(ctx, sys, prompt)
}

func (b *BaseProvider) Ask(ctx context.Context, question string, schemaContext string) (string, error) {
sys := "You are SQL Doctor, an intelligent database diagnostics and engineering assistant. Ground your answer strictly in the provided database schema."
prompt := fmt.Sprintf(`Question: %s

Connected Database Schema:
%s

Answer the user's question clearly and provide relevant SQL snippets or explanations based strictly on the provided schema.`, question, schemaContext)

return b.Caller(ctx, sys, prompt)
}

func (b *BaseProvider) GenerateSQL(ctx context.Context, userGoal string, schemaContext string) (*GeneratedSQL, error) {
sys := "You are an expert SQL developer. Generate valid, high-performance SQL based strictly on the provided schema. Output only the SQL query and brief rationale."
prompt := fmt.Sprintf(`User Goal: %s

Schema:
%s

Generate the optimal SQL query to accomplish the user's goal.
Format your output as:
---SQL---
<The SQL Query Here>
---EXPLANATION---
<Brief explanation of the logic and any assumptions>
`, userGoal, schemaContext)

raw, err := b.Caller(ctx, sys, prompt)
if err != nil {
return nil, err
}

result := &GeneratedSQL{}
if strings.Contains(raw, "---SQL---") {
parts := strings.Split(raw, "---SQL---")
if len(parts) > 1 {
subParts := strings.Split(parts[1], "---EXPLANATION---")
result.SQL = CleanCodeBlock(subParts[0])
if len(subParts) > 1 {
result.Explanation = strings.TrimSpace(subParts[1])
}
}
} else {
result.SQL = CleanCodeBlock(raw)
}

upper := strings.ToUpper(result.SQL)
if strings.Contains(upper, "DELETE") || strings.Contains(upper, "UPDATE") || strings.Contains(upper, "DROP") || strings.Contains(upper, "TRUNCATE") {
result.IsDestructive = true
}

return result, nil
}

func (b *BaseProvider) SummarizeDoctor(ctx context.Context, doctorSummary string) (string, error) {
sys := "You are a Database Reliability Engineer. Provide an executive summary of database health findings."
prompt := fmt.Sprintf(`Summarize these database diagnostic findings into an executive briefing with prioritized action items:

%s`, doctorSummary)

return b.Caller(ctx, sys, prompt)
}

// CleanCodeBlock strips markdown backticks from generated code
func CleanCodeBlock(s string) string {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "```sql") {
s = strings.TrimPrefix(s, "```sql")
} else if strings.HasPrefix(s, "```") {
s = strings.TrimPrefix(s, "```")
}
s = strings.TrimSuffix(s, "```")
return strings.TrimSpace(s)
}
132 changes: 132 additions & 0 deletions internal/ai/claude/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package claude

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/sql-doctor/sql-doctor/internal/ai"
)

// Client implements ai.AIProvider for Anthropic Claude via the Messages API
type Client struct {
ai.BaseProvider
apiKey string
client *http.Client
}

type messagePayload struct {
Role string `json:"role"`
Content string `json:"content"`
}

type claudeRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System string `json:"system,omitempty"`
Messages []messagePayload `json:"messages"`
Temperature float64 `json:"temperature"`
}

type claudeResponse struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}

// New creates an Anthropic Claude provider client
func New(apiKey, modelName string) *Client {
if modelName == "" {
modelName = "claude-3-5-haiku-20241022"
}

c := &Client{
apiKey: apiKey,
client: &http.Client{
Timeout: 60 * time.Second,
},
}

c.BaseProvider = ai.BaseProvider{
Name: "Anthropic Claude",
ModelName: modelName,
Configured: strings.TrimSpace(apiKey) != "",
Caller: c.callClaude,
}

return c
}

func (c *Client) callClaude(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
if !c.IsConfigured() {
return "", fmt.Errorf("Anthropic Claude API key is not configured. Set key using: sql-doctor config ai set-key claude <key> or set ANTHROPIC_API_KEY environment variable")
}

reqBody := claudeRequest{
Model: c.ModelName,
MaxTokens: 4096,
System: systemPrompt,
Messages: []messagePayload{
{Role: "user", Content: userPrompt},
},
Temperature: 0.2,
}

jsonBytes, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("failed to marshal Claude request: %w", err)
}

url := "https://api.anthropic.com/v1/messages"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(jsonBytes))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}

req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", c.apiKey)
req.Header.Set("anthropic-version", "2023-06-01")

resp, err := c.client.Do(req)
if err != nil {
return "", fmt.Errorf("Anthropic Claude connection failed: %w", err)
}
defer resp.Body.Close()

bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}

var clResp claudeResponse
if err := json.Unmarshal(bodyBytes, &clResp); err != nil {
return "", fmt.Errorf("Anthropic Claude returned non-JSON response (HTTP %d): %s", resp.StatusCode, string(bodyBytes))
}

if clResp.Error != nil && clResp.Error.Message != "" {
return "", fmt.Errorf("Anthropic Claude API error: %s", clResp.Error.Message)
}

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("Anthropic Claude HTTP error %d: %s", resp.StatusCode, string(bodyBytes))
}

var sb strings.Builder
for _, block := range clResp.Content {
if block.Type == "text" {
sb.WriteString(block.Text)
}
}

return strings.TrimSpace(sb.String()), nil
}
Loading
Loading