Skip to content

Commit a575cdc

Browse files
cpcloudclaude
andauthored
feat(extract): ping LLM during earlier steps, skip on failure (#701)
## Summary - Fire a concurrent LLM ping alongside OCR extraction so we discover unreachable endpoints before earlier steps finish - When the ping fails, the LLM step immediately shows `na` with the error in rose — no time wasted on text/OCR only to fail at the LLM stage - `maybeStartLLMStep` checks ping state before advancing; if already skipped, the step is never invoked - Rerunning via `r model` clears ping state for a fresh attempt - Skipped steps are navigable, auto-expand to show the error, and offer the rerun hint closes #682 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0b92fcd commit a575cdc

File tree

4 files changed

+387
-24
lines changed

4 files changed

+387
-24
lines changed

internal/app/extraction.go

Lines changed: 103 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const (
4747
stepRunning
4848
stepDone
4949
stepFailed
50+
stepSkipped
5051
)
5152

5253
// extractionStepInfo tracks the state of a single extraction step.
@@ -125,6 +126,10 @@ type extractionLogState struct {
125126
previewRow int // row cursor within active tab
126127
previewCol int // column cursor within active tab
127128

129+
// LLM ping state: ping runs concurrently with earlier steps.
130+
llmPingDone bool // true once ping completed (success or fail)
131+
llmPingErr error // non-nil if LLM was unreachable
132+
128133
// Model picker: inline model selection before rerunning LLM step.
129134
modelPicker *modelCompleter // non-nil when picker is showing
130135
modelFilter string // current filter text for fuzzy matching
@@ -178,7 +183,7 @@ func (ex *extractionLogState) cursorStep() extractionStep {
178183
// steps collapse their log content by default.
179184
func (ex *extractionLogState) stepDefaultExpanded(si extractionStep) bool {
180185
info := ex.Steps[si]
181-
if info.Status == stepRunning || info.Status == stepFailed {
186+
if info.Status == stepRunning || info.Status == stepFailed || info.Status == stepSkipped {
182187
// Ext with tools: collapsed by default while running since
183188
// the parent header shows the combined percentage.
184189
if si == stepExtract && len(ex.acquireTools) > 0 && info.Status == stepRunning {
@@ -198,16 +203,16 @@ func (ex *extractionLogState) stepExpanded(si extractionStep) bool {
198203
return ex.stepDefaultExpanded(si)
199204
}
200205

201-
// advanceCursor moves the cursor to the latest settled (done/failed) step.
202-
// In manual mode (after user presses j/k) this is a no-op.
206+
// advanceCursor moves the cursor to the latest settled (done/failed/skipped)
207+
// step. In manual mode (after user presses j/k) this is a no-op.
203208
func (ex *extractionLogState) advanceCursor() {
204209
if ex.cursorManual {
205210
return
206211
}
207212
active := ex.activeSteps()
208213
for i := len(active) - 1; i >= 0; i-- {
209214
s := ex.Steps[active[i]].Status
210-
if s == stepDone || s == stepFailed {
215+
if s == stepDone || s == stepFailed || s == stepSkipped {
211216
ex.cursor = i
212217
ex.toolCursor = -1
213218
return
@@ -237,6 +242,12 @@ type extractionLLMChunkMsg struct {
237242
Err error
238243
}
239244

245+
// extractionLLMPingMsg delivers the result of a background LLM ping.
246+
type extractionLLMPingMsg struct {
247+
ID uint64
248+
Err error // nil = reachable, non-nil = unreachable
249+
}
250+
240251
// --- Overlay lifecycle ---
241252

242253
// startExtractionOverlay opens the extraction progress overlay and kicks off
@@ -336,6 +347,11 @@ func (m *Model) startExtractionOverlay(
336347
state.Steps[stepExtract].Status = stepRunning
337348
state.Steps[stepExtract].Started = time.Now()
338349
cmd = asyncExtractCmd(ctx, state)
350+
// Ping LLM concurrently so we know before OCR finishes whether
351+
// the LLM endpoint is reachable.
352+
if needsLLM {
353+
return tea.Batch(cmd, m.llmPingCmd(state), state.Spinner.Tick)
354+
}
339355
} else if needsLLM {
340356
state.Steps[stepLLM].Status = stepRunning
341357
state.Steps[stepLLM].Started = time.Now()
@@ -464,6 +480,23 @@ func waitForExtractProgress(id uint64, ch <-chan extract.ExtractProgress) tea.Cm
464480
}, extractionProgressMsg{ID: id, Progress: extract.ExtractProgress{Done: true}})
465481
}
466482

483+
// llmPingCmd fires a background ping to the LLM endpoint. The result is
484+
// delivered via extractionLLMPingMsg so the extraction can skip the LLM
485+
// step early if the server is unreachable.
486+
func (m *Model) llmPingCmd(state *extractionLogState) tea.Cmd {
487+
client := m.extractionLLMClient()
488+
if client == nil {
489+
return nil
490+
}
491+
id := state.ID
492+
return func() tea.Msg {
493+
ctx, cancel := context.WithTimeout(context.Background(), llm.QuickOpTimeout)
494+
defer cancel()
495+
err := client.Ping(ctx)
496+
return extractionLLMPingMsg{ID: id, Err: err}
497+
}
498+
}
499+
467500
// llmExtractCmd starts LLM document analysis with streaming.
468501
func (m *Model) llmExtractCmd(ctx context.Context, ex *extractionLogState) tea.Cmd {
469502
client := m.extractionLLMClient()
@@ -560,14 +593,8 @@ func (m *Model) handleExtractionProgress(msg extractionProgressMsg) tea.Cmd {
560593
ex.HasError = true
561594
ex.advanceCursor()
562595
// Extraction failed but LLM can still run on whatever text exists.
563-
if ex.hasLLM {
564-
client := m.extractionLLMClient()
565-
if client != nil {
566-
ex.Steps[stepLLM].Status = stepRunning
567-
ex.Steps[stepLLM].Started = time.Now()
568-
ex.Steps[stepLLM].Detail = m.extractionModelLabel()
569-
return m.llmExtractCmd(ex.ctx, ex)
570-
}
596+
if cmd := m.maybeStartLLMStep(ex); cmd != nil {
597+
return cmd
571598
}
572599
ex.Done = true
573600
if m.isBgExtraction(ex) {
@@ -624,15 +651,9 @@ func (m *Model) handleExtractionProgress(msg extractionProgressMsg) tea.Cmd {
624651
ex.extractedText = p.Text
625652
}
626653

627-
// Advance to LLM step if configured.
628-
if ex.hasLLM {
629-
client := m.extractionLLMClient()
630-
if client != nil {
631-
ex.Steps[stepLLM].Status = stepRunning
632-
ex.Steps[stepLLM].Started = time.Now()
633-
ex.Steps[stepLLM].Detail = m.extractionModelLabel()
634-
return m.llmExtractCmd(ex.ctx, ex)
635-
}
654+
// Advance to LLM step if configured and reachable.
655+
if cmd := m.maybeStartLLMStep(ex); cmd != nil {
656+
return cmd
636657
}
637658

638659
ex.Done = true
@@ -642,6 +663,55 @@ func (m *Model) handleExtractionProgress(msg extractionProgressMsg) tea.Cmd {
642663
return nil
643664
}
644665

666+
// maybeStartLLMStep attempts to advance to the LLM step. If the concurrent
667+
// ping determined the LLM is unreachable, the step is marked skipped and nil
668+
// is returned. Otherwise it starts the LLM streaming command.
669+
func (m *Model) maybeStartLLMStep(ex *extractionLogState) tea.Cmd {
670+
if !ex.hasLLM {
671+
return nil
672+
}
673+
// Already marked skipped by the ping handler.
674+
if ex.Steps[stepLLM].Status == stepSkipped {
675+
return nil
676+
}
677+
client := m.extractionLLMClient()
678+
if client == nil {
679+
return nil
680+
}
681+
ex.Steps[stepLLM].Status = stepRunning
682+
ex.Steps[stepLLM].Started = time.Now()
683+
ex.Steps[stepLLM].Detail = m.extractionModelLabel()
684+
return m.llmExtractCmd(ex.ctx, ex)
685+
}
686+
687+
// handleExtractionLLMPing processes the background LLM ping result.
688+
func (m *Model) handleExtractionLLMPing(msg extractionLLMPingMsg) tea.Cmd {
689+
ex := m.findExtraction(msg.ID)
690+
if ex == nil {
691+
return nil
692+
}
693+
ex.llmPingDone = true
694+
ex.llmPingErr = msg.Err
695+
696+
if msg.Err != nil {
697+
// Mark LLM as skipped immediately so the strikethrough renders
698+
// in real time, even while earlier steps are still running.
699+
ex.Steps[stepLLM].Status = stepSkipped
700+
ex.Steps[stepLLM].Detail = m.extractionModelLabel()
701+
ex.Steps[stepLLM].Logs = append(ex.Steps[stepLLM].Logs, msg.Err.Error())
702+
703+
// If extraction already finished, the pipeline is done.
704+
if ex.Steps[stepExtract].Status == stepDone || ex.Steps[stepExtract].Status == stepFailed {
705+
ex.Done = true
706+
ex.advanceCursor()
707+
if m.isBgExtraction(ex) {
708+
m.setStatusInfo(fmt.Sprintf("Extracted: %s (LLM skipped)", ex.Filename))
709+
}
710+
}
711+
}
712+
return nil
713+
}
714+
645715
// handleExtractionLLMStarted stores the LLM stream channel and starts reading.
646716
func (m *Model) handleExtractionLLMStarted(msg extractionLLMStartedMsg) tea.Cmd {
647717
ex := m.findExtraction(msg.ID)
@@ -870,8 +940,10 @@ func (m *Model) rerunLLMExtraction() tea.Cmd {
870940
ex.CancelFn = cancel
871941
}
872942

873-
// Reset LLM state.
943+
// Reset LLM state (including any prior ping failure).
874944
ex.llmAccum.Reset()
945+
ex.llmPingDone = false
946+
ex.llmPingErr = nil
875947
ex.operations = nil
876948
ex.closeShadowDB()
877949
ex.previewGroups = nil
@@ -1604,6 +1676,9 @@ func (m *Model) renderExtractionStep(
16041676
case stepFailed:
16051677
icon = m.styles.ExtFail().Render("xx") + " "
16061678
nameStyle = m.styles.ExtFailed()
1679+
case stepSkipped:
1680+
icon = m.styles.ExtPending().Render("na") + " "
1681+
nameStyle = m.styles.ExtPending()
16071682
}
16081683

16091684
hasTools := si == stepExtract && len(ex.acquireTools) > 0
@@ -1670,7 +1745,9 @@ func (m *Model) renderExtractionStep(
16701745
hdr.WriteString(" ")
16711746
hdr.WriteString(hint.Render(fmt.Sprintf("%*s", cols.Elapsed, e)))
16721747
}
1673-
if si == stepLLM && info.Status == stepDone && ex.Done && focused && ex.modelPicker == nil {
1748+
llmTerminal := info.Status == stepDone || info.Status == stepFailed ||
1749+
info.Status == stepSkipped
1750+
if si == stepLLM && llmTerminal && ex.Done && focused && ex.modelPicker == nil {
16741751
hdr.WriteString(" ")
16751752
hdr.WriteString(m.styles.ExtRerun().Render("r model"))
16761753
}
@@ -1783,7 +1860,7 @@ func (m *Model) renderExtractionStep(
17831860
raw := strings.Join(info.Logs, "\n")
17841861

17851862
var rendered string
1786-
if si == stepLLM {
1863+
if si == stepLLM && info.Status != stepSkipped {
17871864
// Pretty-print JSON, then render as a fenced code block via glamour.
17881865
formatted := raw
17891866
var buf bytes.Buffer
@@ -1792,6 +1869,8 @@ func (m *Model) renderExtractionStep(
17921869
}
17931870
md := fmt.Sprintf("```json\n%s\n```", formatted)
17941871
rendered = strings.TrimSpace(ex.renderMarkdown(md, logW))
1872+
} else if info.Status == stepSkipped {
1873+
rendered = m.styles.ExtSkipLog().Render(wordWrap(raw, logW))
17951874
} else {
17961875
rendered = m.styles.HeaderHint().Render(wordWrap(raw, logW))
17971876
}

0 commit comments

Comments
 (0)