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
@@ -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)")
}
}