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:
@@ -1,6 +1,7 @@
|
|||||||
package classifier
|
package classifier
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -38,7 +39,8 @@ func NewClassifier(ollama *OllamaClient, matrixClient *matrix.Client) *Classifie
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Classify runs the appropriate classification pipeline for a feed item.
|
// 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
|
// Direct routing — no LLM call needed
|
||||||
if item.DirectRoute != nil {
|
if item.DirectRoute != nil {
|
||||||
slog.Debug("direct route", "guid", item.GUID, "channel", *item.DirectRoute)
|
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 {
|
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)
|
prompt := BuildTier1Prompt(item, recent)
|
||||||
|
|
||||||
raw, err := c.ollama.Generate(prompt)
|
raw, err := c.ollama.Generate(ctx, prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.handleOllamaFailure(err)
|
c.handleOllamaFailure(err)
|
||||||
return nil, fmt.Errorf("ollama generate: %w", err)
|
return nil, fmt.Errorf("ollama generate: %w", err)
|
||||||
@@ -94,7 +96,7 @@ func (c *Classifier) classifyTier1(item *ingestion.FeedItem, recent []storage.Re
|
|||||||
}, nil
|
}, 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
|
// Run keyword gating first
|
||||||
gatingResult, reason := RunGating(item.Headline, item.Lede)
|
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
|
// Stage 3 (ambiguous) or Stage 1 (definite post needing routing) — send to LLM
|
||||||
prompt := BuildTier2Prompt(item, recent)
|
prompt := BuildTier2Prompt(item, recent)
|
||||||
|
|
||||||
raw, err := c.ollama.Generate(prompt)
|
raw, err := c.ollama.Generate(ctx, prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.handleOllamaFailure(err)
|
c.handleOllamaFailure(err)
|
||||||
return nil, fmt.Errorf("ollama generate: %w", err)
|
return nil, fmt.Errorf("ollama generate: %w", err)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package classifier
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"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.
|
// Generate sends a prompt to Ollama via the chat API and returns the raw
|
||||||
func (o *OllamaClient) Generate(prompt string) (string, error) {
|
// response text. Honors ctx cancellation — callers can abort an in-flight
|
||||||
return o.call(
|
// 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.",
|
"You are a JSON-only news classification API. Respond with valid JSON only. No preamble, no explanation, no markdown.",
|
||||||
prompt,
|
prompt,
|
||||||
"json",
|
"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
|
// GenerateText sends a system + user prompt to Ollama and returns the raw
|
||||||
// text response. Unlike Generate, no JSON mode is forced.
|
// text response. Unlike Generate, no JSON mode is forced.
|
||||||
func (o *OllamaClient) GenerateText(systemPrompt, userPrompt string) (string, error) {
|
func (o *OllamaClient) GenerateText(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
|
||||||
return o.call(systemPrompt, userPrompt, "")
|
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{
|
reqBody := chatRequest{
|
||||||
Model: o.model,
|
Model: o.model,
|
||||||
Messages: []chatMessage{
|
Messages: []chatMessage{
|
||||||
@@ -83,7 +86,12 @@ func (o *OllamaClient) call(systemPrompt, userPrompt, format string) (string, er
|
|||||||
url := o.baseURL + "/api/chat"
|
url := o.baseURL + "/api/chat"
|
||||||
slog.Debug("ollama: calling", "url", url, "model", o.model)
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("ollama request: %w", err)
|
return "", fmt.Errorf("ollama request: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package explainer
|
package explainer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -125,8 +126,11 @@ Rules:
|
|||||||
- Stick strictly to facts in the article. Do not speculate.`
|
- Stick strictly to facts in the article. Do not speculate.`
|
||||||
|
|
||||||
func (e *Explainer) summarize(headline, body string) (string, error) {
|
func (e *Explainer) summarize(headline, body string) (string, error) {
|
||||||
|
// Reaction-driven flow has no parent ctx; bound the LLM call ourselves.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
prompt := fmt.Sprintf("Headline: %s\n\nArticle body:\n%s", headline, body)
|
prompt := fmt.Sprintf("Headline: %s\n\nArticle body:\n%s", headline, body)
|
||||||
out, err := e.ollama.GenerateText(summarySystem, prompt)
|
out, err := e.ollama.GenerateText(ctx, summarySystem, prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ProcessFunc is called for each new feed item that needs classification.
|
// ProcessFunc is called for each new feed item that needs classification.
|
||||||
type ProcessFunc func(item *FeedItem)
|
// ctx is the poller's parent context; callbacks should pass it into any
|
||||||
|
// downstream LLM/HTTP work so shutdown aborts in-flight calls.
|
||||||
|
type ProcessFunc func(ctx context.Context, item *FeedItem)
|
||||||
|
|
||||||
// Poller manages per-source RSS polling goroutines.
|
// Poller manages per-source RSS polling goroutines.
|
||||||
type Poller struct {
|
type Poller struct {
|
||||||
@@ -182,7 +184,7 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
p.process(&items[i])
|
p.process(ctx, &items[i])
|
||||||
newCount++
|
newCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +214,7 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
p.process(&FeedItem{
|
p.process(ctx, &FeedItem{
|
||||||
GUID: s.GUID,
|
GUID: s.GUID,
|
||||||
Headline: s.Headline,
|
Headline: s.Headline,
|
||||||
Lede: s.Lede,
|
Lede: s.Lede,
|
||||||
|
|||||||
4
main.go
4
main.go
@@ -95,8 +95,8 @@ func main() {
|
|||||||
slog.Info("post queue started")
|
slog.Info("post queue started")
|
||||||
|
|
||||||
// Build the pipeline callback: classify → enqueue
|
// Build the pipeline callback: classify → enqueue
|
||||||
processItem := func(item *ingestion.FeedItem) {
|
processItem := func(ctx context.Context, item *ingestion.FeedItem) {
|
||||||
result, err := cls.Classify(item)
|
result, err := cls.Classify(ctx, item)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("classification failed, will retry next cycle",
|
slog.Error("classification failed, will retry next cycle",
|
||||||
"guid", item.GUID,
|
"guid", item.GUID,
|
||||||
|
|||||||
Reference in New Issue
Block a user