Plumb ctx through classifier and Ollama HTTP path

OllamaClient.Generate/GenerateText/call now take ctx and build the HTTP
request via NewRequestWithContext, so an in-flight LLM call is aborted
when the parent context is cancelled (Ctrl-C). Classifier.Classify and
its tier helpers take ctx too. ProcessFunc gets a ctx parameter so the
poller can forward its cancellable context down to classification.

Explainer.summarize manages its own 60s context since reaction-driven
flow has no parent ctx to inherit.
This commit is contained in:
prosolis
2026-05-22 18:55:43 -07:00
parent c9318d7bb0
commit 69967b25c6
5 changed files with 36 additions and 20 deletions

View File

@@ -1,6 +1,7 @@
package classifier
import (
"context"
"encoding/json"
"fmt"
"log/slog"
@@ -38,7 +39,8 @@ func NewClassifier(ollama *OllamaClient, matrixClient *matrix.Client) *Classifie
}
// Classify runs the appropriate classification pipeline for a feed item.
func (c *Classifier) Classify(item *ingestion.FeedItem) (*ClassifyResult, error) {
// ctx is checked before any LLM call so shutdown aborts in-flight work.
func (c *Classifier) Classify(ctx context.Context, item *ingestion.FeedItem) (*ClassifyResult, error) {
// Direct routing — no LLM call needed
if item.DirectRoute != nil {
slog.Debug("direct route", "guid", item.GUID, "channel", *item.DirectRoute)
@@ -60,16 +62,16 @@ func (c *Classifier) Classify(item *ingestion.FeedItem) (*ClassifyResult, error)
}
if item.Tier == 2 {
return c.classifyTier2(item, recent)
return c.classifyTier2(ctx, item, recent)
}
return c.classifyTier1(item, recent)
return c.classifyTier1(ctx, item, recent)
}
func (c *Classifier) classifyTier1(item *ingestion.FeedItem, recent []storage.RecentHeadline) (*ClassifyResult, error) {
func (c *Classifier) classifyTier1(ctx context.Context, item *ingestion.FeedItem, recent []storage.RecentHeadline) (*ClassifyResult, error) {
prompt := BuildTier1Prompt(item, recent)
raw, err := c.ollama.Generate(prompt)
raw, err := c.ollama.Generate(ctx, prompt)
if err != nil {
c.handleOllamaFailure(err)
return nil, fmt.Errorf("ollama generate: %w", err)
@@ -94,7 +96,7 @@ func (c *Classifier) classifyTier1(item *ingestion.FeedItem, recent []storage.Re
}, nil
}
func (c *Classifier) classifyTier2(item *ingestion.FeedItem, recent []storage.RecentHeadline) (*ClassifyResult, error) {
func (c *Classifier) classifyTier2(ctx context.Context, item *ingestion.FeedItem, recent []storage.RecentHeadline) (*ClassifyResult, error) {
// Run keyword gating first
gatingResult, reason := RunGating(item.Headline, item.Lede)
@@ -115,7 +117,7 @@ func (c *Classifier) classifyTier2(item *ingestion.FeedItem, recent []storage.Re
// Stage 3 (ambiguous) or Stage 1 (definite post needing routing) — send to LLM
prompt := BuildTier2Prompt(item, recent)
raw, err := c.ollama.Generate(prompt)
raw, err := c.ollama.Generate(ctx, prompt)
if err != nil {
c.handleOllamaFailure(err)
return nil, fmt.Errorf("ollama generate: %w", err)

View File

@@ -2,6 +2,7 @@ package classifier
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@@ -49,9 +50,11 @@ func NewOllamaClient(cfg config.OllamaConfig) *OllamaClient {
}
}
// Generate sends a prompt to Ollama via the chat API and returns the raw response text.
func (o *OllamaClient) Generate(prompt string) (string, error) {
return o.call(
// Generate sends a prompt to Ollama via the chat API and returns the raw
// response text. Honors ctx cancellation — callers can abort an in-flight
// call instead of waiting for the configured timeout.
func (o *OllamaClient) Generate(ctx context.Context, prompt string) (string, error) {
return o.call(ctx,
"You are a JSON-only news classification API. Respond with valid JSON only. No preamble, no explanation, no markdown.",
prompt,
"json",
@@ -60,11 +63,11 @@ func (o *OllamaClient) Generate(prompt string) (string, error) {
// GenerateText sends a system + user prompt to Ollama and returns the raw
// text response. Unlike Generate, no JSON mode is forced.
func (o *OllamaClient) GenerateText(systemPrompt, userPrompt string) (string, error) {
return o.call(systemPrompt, userPrompt, "")
func (o *OllamaClient) GenerateText(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
return o.call(ctx, systemPrompt, userPrompt, "")
}
func (o *OllamaClient) call(systemPrompt, userPrompt, format string) (string, error) {
func (o *OllamaClient) call(ctx context.Context, systemPrompt, userPrompt, format string) (string, error) {
reqBody := chatRequest{
Model: o.model,
Messages: []chatMessage{
@@ -83,7 +86,12 @@ func (o *OllamaClient) call(systemPrompt, userPrompt, format string) (string, er
url := o.baseURL + "/api/chat"
slog.Debug("ollama: calling", "url", url, "model", o.model)
resp, err := o.httpClient.Post(url, "application/json", bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("ollama request build: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := o.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("ollama request: %w", err)
}