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
8 changes: 8 additions & 0 deletions internal/ai/cost_wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ func (w *CostAwareWrapper) SetSkipConfirmation(skip bool) {
w.skipConfirmation = skip
}

// InvalidateCache removes the cached response for the given prompt, so a
// truncated or malformed response (network-successful but unusable) doesn't
// keep getting served back on retry until the TTL expires.
func (w *CostAwareWrapper) InvalidateCache(prompt string) error {
contentHash := w.cache.GenerateHash(w.provider.GetProviderName() + w.provider.GetModelName() + prompt)
return w.cache.Delete(contentHash)
}

// WrapGenerate wraps any generation function with tracking
func (w *CostAwareWrapper) WrapGenerate(
ctx context.Context,
Expand Down
126 changes: 76 additions & 50 deletions internal/ai/gemini/commit_summarizer_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,63 +223,89 @@ func (s *GeminiCommitSummarizer) GenerateSuggestions(ctx context.Context, info m
"prompt_length", len(prompt),
"language", s.config.Language)

resp, usage, err := s.wrapper.WrapGenerate(ctx, "suggest-commits", prompt, s.generateFn)
if err != nil {
log.Error("failed to generate suggestions",
"error", err)
return nil, err
}
var usage *models.TokenUsage
var suggestions []models.CommitSuggestion

const maxAttempts = 2
for attempt := 1; attempt <= maxAttempts; attempt++ {
resp, respUsage, err := s.wrapper.WrapGenerate(ctx, "suggest-commits", prompt, s.generateFn)
if err != nil {
log.Error("failed to generate suggestions",
"error", err)
return nil, err
}

var responseText string
if geminiResp, ok := resp.(*genai.GenerateContentResponse); ok {
log.Debug("formatResponse received GenerateContentResponse",
"candidates_count", len(geminiResp.Candidates))
responseText = formatResponse(geminiResp)
if len(responseText) > 0 {
preview := responseText
if len(responseText) > 100 {
preview = responseText[:100]
var responseText string
if geminiResp, ok := resp.(*genai.GenerateContentResponse); ok {
log.Debug("formatResponse received GenerateContentResponse",
"candidates_count", len(geminiResp.Candidates))
responseText = formatResponse(geminiResp)
if len(responseText) > 0 {
preview := responseText
if len(responseText) > 100 {
preview = responseText[:100]
}
log.Debug("formatResponse result",
"response_length", len(responseText),
"response_preview", preview)
} else {
log.Debug("formatResponse result empty")
}
log.Debug("formatResponse result",
"response_length", len(responseText),
"response_preview", preview)
} else if str, ok := resp.(string); ok {
responseText = str
log.Debug("received string response", "length", len(str))
} else if respMap, ok := resp.(map[string]interface{}); ok {
log.Debug("received map response from cache, extracting text")
responseText = extractTextFromMap(respMap)
log.Debug("extracted text from map", "length", len(responseText))
} else {
log.Debug("formatResponse result empty")
log.Warn("unexpected response type", "type", fmt.Sprintf("%T", resp))
}
} else if str, ok := resp.(string); ok {
responseText = str
log.Debug("received string response", "length", len(str))
} else if respMap, ok := resp.(map[string]interface{}); ok {
log.Debug("received map response from cache, extracting text")
responseText = extractTextFromMap(respMap)
log.Debug("extracted text from map", "length", len(responseText))
} else {
log.Warn("unexpected response type", "type", fmt.Sprintf("%T", resp))
}

if responseText == "" {
return nil, domainErrors.ErrInvalidAIOutput.
WithContext("reason", "empty response from AI").
WithContext("operation", "generate commit suggestions")
}
if responseText == "" {
if attempt < maxAttempts {
log.Warn("empty response from AI, invalidating cache and retrying", "attempt", attempt)
_ = s.wrapper.InvalidateCache(prompt)
continue
}
return nil, domainErrors.ErrInvalidAIOutput.
WithContext("reason", "empty response from AI").
WithContext("operation", "generate commit suggestions")
}

suggestions, err := s.parseSuggestionsJSON(responseText)
if err != nil {
respLen := len(responseText)
preview := responseText
if respLen > 500 {
preview = responseText[:500] + "..."
parsed, err := s.parseSuggestionsJSON(responseText)
if err != nil {
if attempt < maxAttempts {
log.Warn("failed to parse suggestions JSON, invalidating cache and retrying",
"error", err, "attempt", attempt)
_ = s.wrapper.InvalidateCache(prompt)
continue
}
respLen := len(responseText)
preview := responseText
if respLen > 500 {
preview = responseText[:500] + "..."
}
return nil, domainErrors.ErrInvalidAIOutput.
WithContext("reason", "failed to parse JSON").
WithContext("response_length", respLen).
WithContext("preview", preview).
WithError(err)
}
if len(parsed) == 0 {
if attempt < maxAttempts {
log.Warn("AI generated no suggestions, invalidating cache and retrying", "attempt", attempt)
_ = s.wrapper.InvalidateCache(prompt)
continue
}
log.Warn("AI generated no suggestions")
return nil, domainErrors.ErrInvalidAIOutput.
WithContext("reason", "AI generated no suggestions")
}
return nil, domainErrors.ErrInvalidAIOutput.
WithContext("reason", "failed to parse JSON").
WithContext("response_length", respLen).
WithContext("preview", preview).
WithError(err)
}
if len(suggestions) == 0 {
log.Warn("AI generated no suggestions")
return nil, domainErrors.ErrInvalidAIOutput.
WithContext("reason", "AI generated no suggestions")

usage = respUsage
suggestions = parsed
break
}
for i := range suggestions {
suggestions[i].Usage = usage
Expand Down
77 changes: 45 additions & 32 deletions internal/ai/gemini/issue_content_generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,43 +142,56 @@ func (s *GeminiIssueContentGenerator) GenerateIssueContent(ctx context.Context,
log.Debug("calling gemini API for issue content",
"prompt_length", len(prompt))

resp, usage, err := s.wrapper.WrapGenerate(ctx, "generate-issue", prompt, s.generateFn)
if err != nil {
log.Error("failed to generate issue content",
"error", err)
return nil, domainErrors.NewAppError(domainErrors.TypeAI, "error generating issue content", err)
}

var usage *models.TokenUsage
var responseText string
if geminiResp, ok := resp.(*genai.GenerateContentResponse); ok {
log.Debug("formatResponse received GenerateContentResponse",
"candidates_count", len(geminiResp.Candidates))
responseText = formatResponse(geminiResp)
if len(responseText) > 0 {
preview := responseText
if len(responseText) > 100 {
preview = responseText[:100]

const maxAttempts = 2
for attempt := 1; attempt <= maxAttempts; attempt++ {
resp, respUsage, err := s.wrapper.WrapGenerate(ctx, "generate-issue", prompt, s.generateFn)
if err != nil {
log.Error("failed to generate issue content",
"error", err)
return nil, domainErrors.NewAppError(domainErrors.TypeAI, "error generating issue content", err)
}

if geminiResp, ok := resp.(*genai.GenerateContentResponse); ok {
log.Debug("formatResponse received GenerateContentResponse",
"candidates_count", len(geminiResp.Candidates))
responseText = formatResponse(geminiResp)
if len(responseText) > 0 {
preview := responseText
if len(responseText) > 100 {
preview = responseText[:100]
}
log.Debug("formatResponse result",
"response_length", len(responseText),
"response_preview", preview)
} else {
log.Debug("formatResponse result empty")
}
log.Debug("formatResponse result",
"response_length", len(responseText),
"response_preview", preview)
} else if str, ok := resp.(string); ok {
responseText = str
log.Debug("received string response", "length", len(str))
} else if respMap, ok := resp.(map[string]interface{}); ok {
log.Debug("received map response from cache, extracting text")
responseText = extractTextFromMap(respMap)
log.Debug("extracted text from map", "length", len(responseText))
} else {
log.Debug("formatResponse result empty")
log.Warn("unexpected response type", "type", fmt.Sprintf("%T", resp))
}
} else if str, ok := resp.(string); ok {
responseText = str
log.Debug("received string response", "length", len(str))
} else if respMap, ok := resp.(map[string]interface{}); ok {
log.Debug("received map response from cache, extracting text")
responseText = extractTextFromMap(respMap)
log.Debug("extracted text from map", "length", len(responseText))
} else {
log.Warn("unexpected response type", "type", fmt.Sprintf("%T", resp))
}

if responseText == "" {
log.Error("empty response from gemini AI after format")
return nil, domainErrors.NewAppError(domainErrors.TypeAI, "empty response from AI", nil)
if responseText == "" {
if attempt < maxAttempts {
log.Warn("empty response from AI, invalidating cache and retrying", "attempt", attempt)
_ = s.wrapper.InvalidateCache(prompt)
continue
}
log.Error("empty response from gemini AI after format")
return nil, domainErrors.NewAppError(domainErrors.TypeAI, "empty response from AI", nil)
}

usage = respUsage
break
}

log.Debug("gemini response received",
Expand Down
124 changes: 75 additions & 49 deletions internal/ai/gemini/pull_requests_summarizer_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,60 +138,86 @@ func (gps *GeminiPRSummarizer) GeneratePRSummary(ctx context.Context, prContent
log.Debug("calling gemini API for PR summary",
"prompt_length", len(prompt))

resp, usage, err := gps.wrapper.WrapGenerate(ctx, "summarize-pr", prompt, gps.generateFn)
if err != nil {
log.Error("failed to generate PR summary",
"error", err)
return models.PRSummary{}, err
}
var usage *models.TokenUsage
var jsonSummary PRSummaryJSON

var responseText string
if geminiResp, ok := resp.(*genai.GenerateContentResponse); ok {
log.Debug("formatResponse received GenerateContentResponse",
"candidates_count", len(geminiResp.Candidates))
responseText = formatResponse(geminiResp)
} else if str, ok := resp.(string); ok {
responseText = str
log.Debug("received string response", "length", len(str))
} else if respMap, ok := resp.(map[string]interface{}); ok {
log.Debug("received map response from cache, extracting text")
responseText = extractTextFromMap(respMap)
log.Debug("extracted text from map", "length", len(responseText))
} else {
log.Warn("unexpected response type", "type", fmt.Sprintf("%T", resp))
}
const maxAttempts = 2
for attempt := 1; attempt <= maxAttempts; attempt++ {
resp, respUsage, err := gps.wrapper.WrapGenerate(ctx, "summarize-pr", prompt, gps.generateFn)
if err != nil {
log.Error("failed to generate PR summary",
"error", err)
return models.PRSummary{}, err
}

if responseText == "" {
return models.PRSummary{}, domainErrors.ErrInvalidAIOutput.
WithContext("reason", "empty response from AI").
WithContext("operation", "summarize PR")
}
var responseText string
if geminiResp, ok := resp.(*genai.GenerateContentResponse); ok {
log.Debug("formatResponse received GenerateContentResponse",
"candidates_count", len(geminiResp.Candidates))
responseText = formatResponse(geminiResp)
} else if str, ok := resp.(string); ok {
responseText = str
log.Debug("received string response", "length", len(str))
} else if respMap, ok := resp.(map[string]interface{}); ok {
log.Debug("received map response from cache, extracting text")
responseText = extractTextFromMap(respMap)
log.Debug("extracted text from map", "length", len(responseText))
} else {
log.Warn("unexpected response type", "type", fmt.Sprintf("%T", resp))
}

var jsonSummary PRSummaryJSON
if err := json.Unmarshal([]byte(responseText), &jsonSummary); err != nil {
respLen := len(responseText)
preview := responseText
if respLen > 500 {
preview = responseText[:500] + "..."
if responseText == "" {
if attempt < maxAttempts {
log.Warn("empty response from AI, invalidating cache and retrying", "attempt", attempt)
_ = gps.wrapper.InvalidateCache(prompt)
continue
}
return models.PRSummary{}, domainErrors.ErrInvalidAIOutput.
WithContext("reason", "empty response from AI").
WithContext("operation", "summarize PR")
}
return models.PRSummary{}, domainErrors.ErrInvalidAIOutput.
WithContext("reason", "failed to parse JSON").
WithContext("response_length", respLen).
WithContext("preview", preview).
WithError(err)
}
if strings.TrimSpace(jsonSummary.Title) == "" {
respLen := len(responseText)
preview := responseText
if respLen > 500 {
preview = responseText[:500] + "..."

var parsed PRSummaryJSON
if err := json.Unmarshal([]byte(responseText), &parsed); err != nil {
if attempt < maxAttempts {
log.Warn("failed to parse PR summary JSON, invalidating cache and retrying",
"error", err, "attempt", attempt)
_ = gps.wrapper.InvalidateCache(prompt)
continue
}
respLen := len(responseText)
preview := responseText
if respLen > 500 {
preview = responseText[:500] + "..."
}
return models.PRSummary{}, domainErrors.ErrInvalidAIOutput.
WithContext("reason", "failed to parse JSON").
WithContext("response_length", respLen).
WithContext("preview", preview).
WithError(err)
}
if strings.TrimSpace(parsed.Title) == "" {
if attempt < maxAttempts {
log.Warn("AI generated no PR title, invalidating cache and retrying", "attempt", attempt)
_ = gps.wrapper.InvalidateCache(prompt)
continue
}
respLen := len(responseText)
preview := responseText
if respLen > 500 {
preview = responseText[:500] + "..."
}
log.Warn("AI generated no PR title",
"response_length", respLen)
return models.PRSummary{}, domainErrors.ErrInvalidAIOutput.
WithContext("reason", "AI generated no PR title").
WithContext("response_length", respLen).
WithContext("preview", preview)
}
log.Warn("AI generated no PR title",
"response_length", respLen)
return models.PRSummary{}, domainErrors.ErrInvalidAIOutput.
WithContext("reason", "AI generated no PR title").
WithContext("response_length", respLen).
WithContext("preview", preview)

usage = respUsage
jsonSummary = parsed
break
}

log.Info("PR summary generated successfully via gemini",
Expand Down
Loading
Loading