// Package summarizer implements ports.Summarizer backed by the copied llm // package's routing (ADR-004, extended by ADR-022). 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: endpoints // are tried in order, locals first, so content only reaches an external model // after every local endpoint has failed — and never at all when no external // endpoint is configured. package summarizer import ( "bytes" "context" "encoding/json" "errors" "fmt" "strings" "time" "unicode/utf8" "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 an ordered chain of endpoints, trying // each in turn until one returns a parseable summary. It owns its routing // (rather than delegating to llm.Router) so it can record which provider answered // and whether a fallback was used — information llm.Router collapses away. The // chain ordering is the local-first guarantee: callers place local endpoints // first and any external endpoint last, so content only reaches an external model // after every local endpoint has failed. type Summarizer struct { endpoints []Endpoint maxInputChars int // transcript truncation budget; 0 = no limit now func() time.Time } // New constructs a Summarizer from a primary endpoint and an optional fallback // (the historical local-Primary -> BYO-Fallback shape, ADR-004). A nil fallback // means a single-endpoint chain: errors are returned, content never leaves it. func New(primary Endpoint, fallback *Endpoint) *Summarizer { eps := []Endpoint{primary} if fallback != nil { eps = append(eps, *fallback) } return &Summarizer{endpoints: eps, now: time.Now} } // NewChain constructs a Summarizer over an ordered endpoint chain (ADR-022). // endpoints are tried in order; the first to return a parseable summary wins, and // FallbackUsed is recorded true for any endpoint past the first. maxInputChars // bounds the transcript text sent to every endpoint (0 = unbounded), so a long // transcript does not overflow a small-context primary model's window. It panics // on an empty chain — a wiring bug, not a runtime condition. func NewChain(endpoints []Endpoint, maxInputChars int) *Summarizer { if len(endpoints) == 0 { panic("summarizer: NewChain requires at least one endpoint") } return &Summarizer{endpoints: endpoints, maxInputChars: maxInputChars, 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. It walks the endpoint chain in order: // the first endpoint whose reply parses into a non-empty summary wins. An // endpoint is considered failed — and the next one tried — when the model call // errors OR when its reply cannot be parsed (a 200 with malformed JSON or a // highlights field the model emitted as a bare string). Truncation is applied // once, up front, so every endpoint sees the same bounded prompt. When the whole // chain fails, the joined error is returned so the engine queues the work for // retry and delivers no summary. 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, s.maxInputChars) var errs []error for i, ep := range s.endpoints { out, err := ep.Client.Complete(ctx, systemPrompt, user) if err != nil { errs = append(errs, fmt.Errorf("%s/%s call: %w", ep.Provider, ep.Model, err)) continue } sum, perr := s.build(v, ep, i > 0, out) if perr != nil { errs = append(errs, fmt.Errorf("%s/%s output: %w", ep.Provider, ep.Model, perr)) continue } return sum, nil } return domain.Summary{}, fmt.Errorf("summarize: all %d endpoint(s) failed: %w", len(s.endpoints), errors.Join(errs...)) } 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: []string(parsed.Highlights), Takeaways: []string(parsed.Takeaways), AIProvider: ep.Provider, AIModel: ep.Model, FallbackUsed: fallbackUsed, CreatedAt: s.now(), }, nil } func buildUserPrompt(v domain.Video, t domain.Transcript, maxInputChars int) 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", truncate(t.Content, maxInputChars)) return b.String() } // truncate caps content to max bytes on a UTF-8 rune boundary, appending a // marker so the model knows the transcript was cut. A non-positive max (or a // content already within budget) returns content unchanged. Bounding the input // keeps a long transcript from overflowing a small-context model's window — the // production failure mode where koala/phi4-mini's 8k context returned HTTP 400 on // a 11.6k-token transcript. func truncate(content string, max int) string { if max <= 0 || len(content) <= max { return content } cut := max for cut > 0 && !utf8.RuneStart(content[cut]) { cut-- } return content[:cut] + "\n…[transcript truncated to fit the model context]" } // flexStrings is a []string that also unmarshals from a single JSON string or a // JSON array of scalars. Small local models (koala/phi4-mini) sometimes emit // "highlights": "one point" instead of an array, or mix in a number; rather than // fail the whole summary on that quirk, coerce to []string. Empty/whitespace // elements are dropped. type flexStrings []string func (f *flexStrings) UnmarshalJSON(b []byte) error { b = bytes.TrimSpace(b) if len(b) == 0 || string(b) == "null" { *f = nil return nil } if b[0] == '[' { var raw []json.RawMessage if err := json.Unmarshal(b, &raw); err != nil { return err } out := make([]string, 0, len(raw)) for _, r := range raw { s, err := rawToString(r) if err != nil { return err } if strings.TrimSpace(s) != "" { out = append(out, s) } } *f = out return nil } s, err := rawToString(b) if err != nil { return err } if strings.TrimSpace(s) == "" { *f = nil } else { *f = flexStrings{s} } return nil } // rawToString renders a JSON scalar as text: a quoted string is unquoted; any // other scalar (number, bool) is kept as its literal source so no content is lost. func rawToString(r json.RawMessage) (string, error) { r = bytes.TrimSpace(r) if len(r) > 0 && r[0] == '"' { var s string if err := json.Unmarshal(r, &s); err != nil { return "", err } return s, nil } return string(r), nil } type parsedSummary struct { Summary string `json:"summary"` Highlights flexStrings `json:"highlights"` Takeaways flexStrings `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 }