The first friendly-pilot live run produced zero summaries: koala/phi4-mini hit three silent failure modes — 8k context overflow on long transcripts (HTTP 400), intermittent malformed JSON (highlights as a bare string), and no fallback wired at all (summarizer.New(primary, nil)). Keep phi4-mini as the fast primary and add resilience around it: - Ordered endpoint chain (summarizer.NewChain): phi4-mini → koala/phi4-14b (local) → berget/mistral-small (worst-case external). All reached through the one LiteLLM gateway by alias. - A parse failure now advances the chain like a transport error — the old Primary→Fallback shape returned the parse error without trying anyone else. - Tolerant parse: highlights/takeaways coerce string→[]string, absorbing the common small-model quirk without spending a fallback round-trip. - Transcript truncation (TAPIR_MAX_TRANSCRIPT_CHARS=18000) prevents the overflow rather than recovering from it; validated to fit phi4-mini's 8k window. - Bounded completion budget (TAPIR_SUMMARY_MAX_TOKENS=1500) — the old 8192 budget itself contributed to the overflow. Local-first guarantee preserved by ordering: external endpoint is tried only after every local one fails. TAPIR_CLOUD_FALLBACK_MODEL="" disables it entirely for client/NDA deployments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
239 lines
8.6 KiB
Go
239 lines
8.6 KiB
Go
// 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
|
|
}
|