// Package summarizer implements ports.Summarizer backed by the copied llm // package's local-Primary -> BYO-Fallback routing (ADR-004). It is the only // place content ever leaves the engine toward an AI model, so it is also the // enforcement point for the local-first guarantee in // docs/use-cases/ai_routing.feature: a user with no BYO provider configured has // their content sent to the local stack and nowhere else. package summarizer import ( "context" "encoding/json" "fmt" "strings" "time" "gitea.d-ma.be/mathias/tapir/internal/domain" ) // Completer is the minimal LLM chat surface the Summarizer needs. // *llm.Client (and *llm.Router) satisfy it; tests use a fake. type Completer interface { Complete(ctx context.Context, system, user string) (string, error) } // Endpoint binds a Completer to the provenance recorded on the produced Summary. type Endpoint struct { Client Completer Provider string // domain AIProvider: "local" | "anthropic" | "openai" | ... Model string // resolved alias, e.g. "iguana/deepseek-r1-14b" } // Summarizer routes a transcript through the local endpoint first, then the // optional BYO endpoint. It owns its routing (rather than delegating to // llm.Router) so it can record which provider answered and whether the fallback // was used — information llm.Router collapses away. type Summarizer struct { primary Endpoint fallback *Endpoint // nil => no BYO; primary errors are returned, never sent externally now func() time.Time } // New constructs a Summarizer. fallback may be nil (no BYO provider configured). func New(primary Endpoint, fallback *Endpoint) *Summarizer { return &Summarizer{primary: primary, fallback: fallback, now: time.Now} } const systemPrompt = `You are Tapir, a video-summarization assistant. Given a video title and transcript, produce a concise, faithful summary. Respond with ONLY a JSON object, no prose and no code fences: {"summary": string, "highlights": [string], "takeaways": [string]} - "summary": 2-4 sentences capturing what the video is about. - "highlights": the notable moments or points raised, most important first. - "takeaways": the actionable conclusions a viewer should leave with. Output the JSON object and nothing else.` // Summarize implements ports.Summarizer. func (s *Summarizer) Summarize(ctx context.Context, v domain.Video, t domain.Transcript) (domain.Summary, error) { if !t.HasText() { return domain.Summary{}, fmt.Errorf("summarize: transcript for video %s has no text", v.ID) } user := buildUserPrompt(v, t) // Primary = local stack. Only on its failure is anything sent externally, // and only when a BYO fallback is configured. out, err := s.primary.Client.Complete(ctx, systemPrompt, user) if err == nil { return s.build(v, s.primary, false, out) } if s.fallback == nil { // No BYO: content was sent to the local stack only. Surface the error so // the engine can queue the work for retry; deliver no summary. return domain.Summary{}, fmt.Errorf("summarize: local AI failed and no BYO provider configured: %w", err) } out, ferr := s.fallback.Client.Complete(ctx, systemPrompt, user) if ferr != nil { return domain.Summary{}, fmt.Errorf("summarize: local AI failed: %w; BYO %s failed: %v", err, s.fallback.Provider, ferr) } return s.build(v, *s.fallback, true, out) } func (s *Summarizer) build(v domain.Video, ep Endpoint, fallbackUsed bool, raw string) (domain.Summary, error) { parsed, err := parse(raw) if err != nil { return domain.Summary{}, fmt.Errorf("summarize: parse %s response: %w", ep.Provider, err) } return domain.Summary{ UserID: v.UserID, VideoID: v.ID, Summary: parsed.Summary, Highlights: parsed.Highlights, Takeaways: parsed.Takeaways, AIProvider: ep.Provider, AIModel: ep.Model, FallbackUsed: fallbackUsed, CreatedAt: s.now(), }, nil } func buildUserPrompt(v domain.Video, t domain.Transcript) string { var b strings.Builder fmt.Fprintf(&b, "Title: %s\n", v.Title) if v.URL != "" { fmt.Fprintf(&b, "URL: %s\n", v.URL) } fmt.Fprintf(&b, "\nTranscript:\n%s", t.Content) return b.String() } type parsedSummary struct { Summary string `json:"summary"` Highlights []string `json:"highlights"` Takeaways []string `json:"takeaways"` } // parse extracts the JSON object from a model reply. Thinking models (qwen3, // deepseek-r1) may wrap the JSON in reasoning or code fences, so we take the // outermost {...} span rather than requiring the whole reply to be valid JSON. func parse(raw string) (parsedSummary, error) { start := strings.IndexByte(raw, '{') end := strings.LastIndexByte(raw, '}') if start < 0 || end < start { return parsedSummary{}, fmt.Errorf("no JSON object in response") } var p parsedSummary if err := json.Unmarshal([]byte(raw[start:end+1]), &p); err != nil { return parsedSummary{}, fmt.Errorf("unmarshal: %w", err) } if strings.TrimSpace(p.Summary) == "" { return parsedSummary{}, fmt.Errorf("response has empty summary (thinking models need an explicit max_tokens)") } return p, nil }