Skip to content

Commit ddf7383

Browse files
committed
fix: recover truncated JSON responses and retry LLM generation
Add truncated JSON recovery that extracts release_notes from partial responses when the LLM hits its token limit. Also adds retry logic (up to 2 attempts) for both API failures and parse failures.
1 parent 217fd09 commit ddf7383

2 files changed

Lines changed: 95 additions & 13 deletions

File tree

actions/generate.go

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -240,16 +240,35 @@ func (a *GenerateAction) generateWithLLM(
240240
existingTags,
241241
)
242242

243-
helpers.Log.Info().Msgf("Sending prompt to %s (%s)...", inputs.Provider, inputs.Model)
244-
start := time.Now()
243+
maxRetries := 2
244+
var lastErr error
245245

246-
response, err := a.llmSvc.Generate(inputs.Provider, inputs.Key, inputs.Model, prompt)
247-
if err != nil {
248-
return nil, err
249-
}
246+
for attempt := 1; attempt <= maxRetries; attempt++ {
247+
if attempt > 1 {
248+
helpers.Log.Info().Msgf("Retrying LLM generation (attempt %d/%d)...", attempt, maxRetries)
249+
}
250+
251+
helpers.Log.Info().Msgf("Sending prompt to %s (%s)...", inputs.Provider, inputs.Model)
252+
start := time.Now()
253+
254+
response, err := a.llmSvc.Generate(inputs.Provider, inputs.Key, inputs.Model, prompt)
255+
if err != nil {
256+
lastErr = err
257+
continue
258+
}
259+
260+
duration := time.Since(start).Seconds()
261+
helpers.Log.Info().Msgf("LLM response received in %.2fs (%d characters)", duration, len(response))
262+
263+
result, err := a.promptSvc.ParseResponse(response)
264+
if err != nil {
265+
helpers.Log.Warn().Msgf("Parse failed on attempt %d: %v", attempt, err)
266+
lastErr = err
267+
continue
268+
}
250269

251-
duration := time.Since(start).Seconds()
252-
helpers.Log.Info().Msgf("LLM response received in %.2fs (%d characters)", duration, len(response))
270+
return result, nil
271+
}
253272

254-
return a.promptSvc.ParseResponse(response)
273+
return nil, lastErr
255274
}

services/prompt.go

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,18 +172,81 @@ func (p *PromptService) ParseResponse(text string) (*domain.StructuredResult, er
172172
jsonEnd := strings.LastIndex(text, "}")
173173
if jsonStart >= 0 && jsonEnd > jsonStart {
174174
extracted := text[jsonStart : jsonEnd+1]
175-
if err2 := json.Unmarshal([]byte(extracted), &result); err2 != nil {
176-
return nil, fmt.Errorf("failed to parse LLM response as JSON: %w\nRaw output: %s", err, text[:min(500, len(text))])
175+
if err2 := json.Unmarshal([]byte(extracted), &result); err2 == nil {
176+
helpers.Log.Info().Msg("Structured JSON parsed successfully (extracted from text)")
177+
return &result, nil
177178
}
178-
} else {
179-
return nil, fmt.Errorf("failed to parse LLM response as JSON: %w\nRaw output: %s", err, text[:min(500, len(text))])
180179
}
180+
181+
// Try to recover truncated JSON by extracting release_notes field
182+
if recovered := recoverTruncatedJSON(text); recovered != nil {
183+
helpers.Log.Warn().Msg("LLM response was truncated — recovered partial release notes")
184+
return recovered, nil
185+
}
186+
187+
return nil, fmt.Errorf("failed to parse LLM response as JSON: %w\nRaw output: %s", err, text[:min(500, len(text))])
181188
}
182189

183190
helpers.Log.Info().Msg("Structured JSON parsed successfully")
184191
return &result, nil
185192
}
186193

194+
// recoverTruncatedJSON attempts to extract release_notes from a truncated JSON response.
195+
// This handles cases where the LLM hits its token limit mid-response.
196+
func recoverTruncatedJSON(text string) *domain.StructuredResult {
197+
// Look for "release_notes" field value
198+
re := regexp.MustCompile(`"release_notes"\s*:\s*"`)
199+
loc := re.FindStringIndex(text)
200+
if loc == nil {
201+
return nil
202+
}
203+
204+
// Extract the string value, handling escaped characters
205+
start := loc[1]
206+
var sb strings.Builder
207+
i := start
208+
for i < len(text) {
209+
if text[i] == '\\' && i+1 < len(text) {
210+
switch text[i+1] {
211+
case '"':
212+
sb.WriteByte('"')
213+
case 'n':
214+
sb.WriteByte('\n')
215+
case 't':
216+
sb.WriteByte('\t')
217+
case '\\':
218+
sb.WriteByte('\\')
219+
default:
220+
sb.WriteByte(text[i+1])
221+
}
222+
i += 2
223+
continue
224+
}
225+
if text[i] == '"' {
226+
break
227+
}
228+
sb.WriteByte(text[i])
229+
i++
230+
}
231+
232+
content := strings.TrimSpace(sb.String())
233+
if content == "" {
234+
return nil
235+
}
236+
237+
// Try to extract suggested_version too
238+
version := ""
239+
versionRe := regexp.MustCompile(`"suggested_version"\s*:\s*"([^"]*)"`)
240+
if m := versionRe.FindStringSubmatch(text); len(m) > 1 {
241+
version = m[1]
242+
}
243+
244+
return &domain.StructuredResult{
245+
ReleaseNotes: content,
246+
SuggestedVersion: version,
247+
}
248+
}
249+
187250
func (p *PromptService) GenerateOutputPaths(basePath string) domain.OutputConfig {
188251
if basePath == "" {
189252
return domain.OutputConfig{

0 commit comments

Comments
 (0)