feat(adapters): add Summarizer backed by local-first llm routing

Implement ports.Summarizer in internal/adapters/summarizer. It routes through a
local Primary endpoint first and an optional BYO Fallback, owning the routing
itself (not delegating to llm.Router) so it can record AIProvider, AIModel, and
FallbackUsed on domain.Summary. Prompt asks for JSON {summary, highlights,
takeaways}; the parser tolerates thinking-model fences/reasoning and rejects an
empty summary.

The summarizer is the single egress point for content toward an AI model, so it
enforces the local-first guarantee from ai_routing.feature: with no BYO
configured (nil fallback) there is no external endpoint, so content reaches the
local stack and nowhere else. Tests assert all four scenarios via a fake client.

Model alias is config (TAPIR_SUMMARIZER_MODEL, host/name) — not hardcoded;
docs/homelab-integration.md notes it stays `confirm` and that thinking models
need an explicit max_tokens or they return empty content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 17:04:29 +02:00
co-authored by Claude Opus 4.8
parent 0aeb3aa99e
commit 6da9d61e63
3 changed files with 322 additions and 4 deletions
+10 -4
View File
@@ -18,10 +18,16 @@ it** — endpoints and aliases drift, and this file is a snapshot (2026-06-02),
Resolve the live key from the vault when wiring; `sk-local-123` is no longer valid. `confirm` partially resolved. Resolve the live key from the vault when wiring; `sk-local-123` is no longer valid. `confirm` partially resolved.
- **Model alias format:** `host/name`, e.g. `koala/qwen3-coder-30b`, `koala/phi4-mini`, - **Model alias format:** `host/name`, e.g. `koala/qwen3-coder-30b`, `koala/phi4-mini`,
`iguana/devstral`, `iguana/deepseek-r1-14b`. **Not** the `ollama/` prefix form. `iguana/devstral`, `iguana/deepseek-r1-14b`. **Not** the `ollama/` prefix form.
- **Which alias for summarization:** NOT yet decided. Tapir summarizes transcript text, so a - **Which alias for summarization:** NOT yet decided. `confirm`. Tapir summarizes transcript
capable general/instruct model on koala or iguana is the candidate — pick during the build and text, so a capable general/instruct model on koala or iguana is the candidate — pick during the
record the choice (an ADR if it's load-bearing). Do not assume a coder alias is right for prose build and record the choice (an ADR if it's load-bearing). Do not assume a coder alias is right
summarization. for prose summarization. The summarizer adapter does **not** hardcode an alias: it is config,
env `TAPIR_SUMMARIZER_MODEL` (format `host/name`, e.g. `iguana/deepseek-r1-14b`).
- **Thinking models need an explicit `max_tokens`.** qwen3 / deepseek-r1 spend the budget on
reasoning and return **empty content** if `max_tokens` is too low (or unset). The summarizer's
parser treats an empty summary as an error for exactly this reason. When the alias resolves to a
thinking model, add a generous `max_tokens` to the copied `llm.Client` request (it currently
sends none — change Tapir's copy per ADR-004), or pick a non-thinking instruct model.
This maps directly onto the copied `llm` package: `Client` is the OpenAI-compatible caller, This maps directly onto the copied `llm` package: `Client` is the OpenAI-compatible caller,
`Router.Primary` points at this gateway with a chosen alias, `Router.Fallback` is the user's BYO. `Router.Primary` points at this gateway with a chosen alias, `Router.Fallback` is the user's BYO.
+134
View File
@@ -0,0 +1,134 @@
// 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
}
@@ -0,0 +1,178 @@
// These tests translate docs/use-cases/ai_routing.feature. They drive the
// Summarizer through a FAKE Completer — never the live LiteLLM gateway — and the
// load-bearing assertion is the local-first guarantee: when no BYO provider is
// configured, the transcript content reaches the local stack and nowhere else.
package summarizer
import (
"context"
"errors"
"strings"
"testing"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"gitea.d-ma.be/mathias/tapir/internal/ports"
)
// compile-time check: Summarizer satisfies the port.
var _ ports.Summarizer = (*Summarizer)(nil)
// fakeClient records every prompt it is asked to complete, so a test can prove
// whether content reached it. It returns reply, or err when err != nil.
type fakeClient struct {
reply string
err error
calls int
lastUser string
}
func (f *fakeClient) Complete(_ context.Context, _, user string) (string, error) {
f.calls++
f.lastUser = user
if f.err != nil {
return "", f.err
}
return f.reply, nil
}
const goodReply = `{"summary":"A talk about Go.","highlights":["ports and adapters"],"takeaways":["copy, don't couple"]}`
func testVideo() domain.Video {
return domain.Video{ID: "vid-1", UserID: "user-1", Title: "Clean Architecture in Go", URL: "https://x/y"}
}
func testTranscript() domain.Transcript {
return domain.Transcript{VideoID: "vid-1", UserID: "user-1", Source: domain.SourceCaptions, Content: "secret confidential transcript body"}
}
// Scenario: Local AI produces the summary.
func TestSummarize_LocalSucceeds(t *testing.T) {
local := &fakeClient{reply: goodReply}
byo := &fakeClient{reply: `{"summary":"should not be used"}`}
s := New(
Endpoint{Client: local, Provider: "local", Model: "iguana/deepseek-r1-14b"},
&Endpoint{Client: byo, Provider: "anthropic", Model: "claude"},
)
sum, err := s.Summarize(context.Background(), testVideo(), testTranscript())
if err != nil {
t.Fatalf("Summarize: %v", err)
}
if sum.AIProvider != "local" {
t.Errorf("AIProvider = %q, want local", sum.AIProvider)
}
if sum.AIModel != "iguana/deepseek-r1-14b" {
t.Errorf("AIModel = %q", sum.AIModel)
}
if sum.FallbackUsed {
t.Error("FallbackUsed = true, want false")
}
if byo.calls != 0 {
t.Errorf("BYO called %d times; must not be touched when local succeeds", byo.calls)
}
if sum.Summary == "" || len(sum.Highlights) != 1 || len(sum.Takeaways) != 1 {
t.Errorf("parsed summary wrong: %+v", sum)
}
}
// Scenario: Local AI fails and the user has a BYO provider configured.
func TestSummarize_FallsBackToBYO(t *testing.T) {
local := &fakeClient{err: errors.New("connection refused")}
byo := &fakeClient{reply: goodReply}
s := New(
Endpoint{Client: local, Provider: "local", Model: "iguana/deepseek-r1-14b"},
&Endpoint{Client: byo, Provider: "anthropic", Model: "claude-opus"},
)
sum, err := s.Summarize(context.Background(), testVideo(), testTranscript())
if err != nil {
t.Fatalf("Summarize: %v", err)
}
if sum.AIProvider != "anthropic" {
t.Errorf("AIProvider = %q, want anthropic", sum.AIProvider)
}
if sum.AIModel != "claude-opus" {
t.Errorf("AIModel = %q, want claude-opus", sum.AIModel)
}
if !sum.FallbackUsed {
t.Error("FallbackUsed = false, want true")
}
if local.calls != 1 || byo.calls != 1 {
t.Errorf("calls: local=%d byo=%d, want 1 and 1", local.calls, byo.calls)
}
}
// Scenario: Local AI fails and the user has no BYO provider.
// AND: my content is not sent to any third-party model.
func TestSummarize_LocalFailsNoBYO_NoExternalSend(t *testing.T) {
local := &fakeClient{err: errors.New("connection refused")}
s := New(Endpoint{Client: local, Provider: "local", Model: "iguana/deepseek-r1-14b"}, nil)
_, err := s.Summarize(context.Background(), testVideo(), testTranscript())
if err == nil {
t.Fatal("want error when local fails and no BYO, got nil")
}
// Local was the only place content could go; with nil fallback there is no
// external client to receive it at all. Local saw the content once.
if local.calls != 1 {
t.Errorf("local calls = %d, want 1", local.calls)
}
if !strings.Contains(local.lastUser, "secret confidential transcript body") {
t.Error("transcript content should have reached the local stack")
}
}
// Scenario: A user without BYO never has content sent externally — even across
// repeated summarizations. Asserted structurally: a nil fallback means no
// external endpoint exists, so content cannot leave the local stack.
func TestSummarize_NoBYO_ContentOnlyLocal(t *testing.T) {
local := &fakeClient{reply: goodReply}
s := New(Endpoint{Client: local, Provider: "local", Model: "iguana/deepseek-r1-14b"}, nil)
if s.fallback != nil {
t.Fatal("no BYO configured but fallback endpoint is non-nil")
}
for i := 0; i < 3; i++ {
sum, err := s.Summarize(context.Background(), testVideo(), testTranscript())
if err != nil {
t.Fatalf("Summarize: %v", err)
}
if sum.AIProvider != "local" || sum.FallbackUsed {
t.Errorf("provider=%q fallbackUsed=%v, want local/false", sum.AIProvider, sum.FallbackUsed)
}
}
if local.calls != 3 {
t.Errorf("local calls = %d, want 3", local.calls)
}
}
func TestSummarize_EmptyTranscriptIsError(t *testing.T) {
local := &fakeClient{reply: goodReply}
s := New(Endpoint{Client: local, Provider: "local", Model: "m"}, nil)
none := domain.Transcript{VideoID: "vid-1", Source: domain.SourceNone}
if _, err := s.Summarize(context.Background(), testVideo(), none); err == nil {
t.Fatal("want error for transcript with no text")
}
if local.calls != 0 {
t.Errorf("local called %d times for empty transcript; must not call the model", local.calls)
}
}
// parse tolerates thinking-model wrapping (reasoning + code fences around JSON).
func TestParse_ToleratesFencedThinkingOutput(t *testing.T) {
raw := "<think>let me reason...</think>\n```json\n" + goodReply + "\n```"
p, err := parse(raw)
if err != nil {
t.Fatalf("parse: %v", err)
}
if p.Summary != "A talk about Go." {
t.Errorf("summary = %q", p.Summary)
}
}
func TestParse_EmptySummaryRejected(t *testing.T) {
if _, err := parse(`{"summary":" ","highlights":[]}`); err == nil {
t.Fatal("want error for empty summary (thinking model returned no content)")
}
}