merge: llm copy + Summarizer adapter (Worker B, agent/llm-summarizer)

This commit is contained in:
2026-06-02 17:22:29 +02:00
7 changed files with 656 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.
- **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.
- **Which alias for summarization:** NOT yet decided. Tapir summarizes transcript text, so a
capable general/instruct model on koala or iguana is the candidate — pick during the build and
record the choice (an ADR if it's load-bearing). Do not assume a coder alias is right for prose
summarization.
- **Which alias for summarization:** NOT yet decided. `confirm`. Tapir summarizes transcript
text, so a capable general/instruct model on koala or iguana is the candidate — pick during the
build and record the choice (an ADR if it's load-bearing). Do not assume a coder alias is right
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,
`Router.Primary` points at this gateway with a chosen alias, `Router.Fallback` is the user's BYO.
+124
View File
@@ -0,0 +1,124 @@
// Package llm is a COPY of hyperguild/ingestion/internal/llm (ADR-004). Tapir
// owns this copy; it is not a module dependency on that repo. The package is
// stdlib-only. Client calls an OpenAI-compatible chat completions endpoint;
// Router (router.go) implements the local-Primary -> BYO-Fallback pattern Tapir
// needs for docs/use-cases/ai_routing.feature.
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// Client calls an OpenAI-compatible chat completions endpoint.
type Client struct {
baseURL string
apiKey string
model string
httpClient *http.Client
}
// New constructs a Client.
func New(baseURL, apiKey, model string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
model: model,
httpClient: &http.Client{Timeout: timeout},
}
}
type chatRequest struct {
Model string `json:"model"`
Messages []message `json:"messages"`
Temperature float64 `json:"temperature"`
}
type message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatResponse struct {
Choices []struct {
Message message `json:"message"`
} `json:"choices"`
}
// Complete sends a system + user message and returns the assistant's reply.
// Retries once on HTTP 429 using Retry-After header or 5s backoff.
func (c *Client) Complete(ctx context.Context, system, user string) (string, error) {
body := chatRequest{
Model: c.model,
Messages: []message{
{Role: "system", Content: system},
{Role: "user", Content: user},
},
Temperature: 0.2,
}
b, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
do := func() (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(b))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
return c.httpClient.Do(req)
}
resp, err := do()
if err != nil {
return "", fmt.Errorf("call LLM: %w", err)
}
if resp.StatusCode == http.StatusTooManyRequests {
_ = resp.Body.Close()
wait := 5 * time.Second
if ra := resp.Header.Get("Retry-After"); ra != "" {
if secs, err := strconv.Atoi(ra); err == nil {
wait = time.Duration(secs) * time.Second
}
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(wait):
}
resp, err = do()
if err != nil {
return "", fmt.Errorf("retry LLM call: %w", err)
}
}
defer resp.Body.Close() //nolint:errcheck
out, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("LLM returned %d: %s", resp.StatusCode, out)
}
var cr chatResponse
if err := json.Unmarshal(out, &cr); err != nil {
return "", fmt.Errorf("parse response: %w", err)
}
if len(cr.Choices) == 0 {
return "", fmt.Errorf("LLM returned no choices")
}
return cr.Choices[0].Message.Content, nil
}
+103
View File
@@ -0,0 +1,103 @@
package llm
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// mockServer returns an OpenAI-compatible endpoint that always replies with the
// given assistant content.
func mockServer(t *testing.T, response string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
t.Errorf("path = %q, want /chat/completions", r.URL.Path)
}
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{
{"message": map[string]any{"role": "assistant", "content": response}},
},
})
}))
}
func TestClient_Complete(t *testing.T) {
srv := mockServer(t, "hello world")
defer srv.Close()
c := New(srv.URL, "", "test-model", 10*time.Second)
got, err := c.Complete(context.Background(), "you are helpful", "say hello")
if err != nil {
t.Fatalf("Complete: %v", err)
}
if got != "hello world" {
t.Errorf("got %q, want %q", got, "hello world")
}
}
func TestClient_ReturnsErrorOnNon200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "overloaded", http.StatusServiceUnavailable)
}))
defer srv.Close()
c := New(srv.URL, "", "test-model", 10*time.Second)
if _, err := c.Complete(context.Background(), "sys", "user"); err == nil {
t.Fatal("want error on non-200, got nil")
}
}
func TestClient_SendsAuthHeader(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
_ = json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{{"message": map[string]any{"content": "ok"}}},
})
}))
defer srv.Close()
c := New(srv.URL, "my-key", "test-model", 10*time.Second)
if _, err := c.Complete(context.Background(), "sys", "user"); err != nil {
t.Fatalf("Complete: %v", err)
}
if gotAuth != "Bearer my-key" {
t.Errorf("Authorization = %q, want %q", gotAuth, "Bearer my-key")
}
}
func TestClient_Retries429(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls == 1 {
w.Header().Set("Retry-After", "0")
w.WriteHeader(http.StatusTooManyRequests)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{{"message": map[string]any{"content": "retried"}}},
})
}))
defer srv.Close()
c := New(srv.URL, "", "test-model", 10*time.Second)
got, err := c.Complete(context.Background(), "sys", "user")
if err != nil {
t.Fatalf("Complete: %v", err)
}
if got != "retried" {
t.Errorf("got %q, want %q", got, "retried")
}
if calls != 2 {
t.Errorf("calls = %d, want 2", calls)
}
}
+29
View File
@@ -0,0 +1,29 @@
package llm
import (
"context"
"fmt"
)
// Router calls Primary first; on any error falls back to Fallback.
// Fallback may be nil, in which case primary errors are returned directly.
type Router struct {
Primary *Client
Fallback *Client
}
// Complete routes through Primary then Fallback.
func (r *Router) Complete(ctx context.Context, system, user string) (string, error) {
out, err := r.Primary.Complete(ctx, system, user)
if err == nil {
return out, nil
}
if r.Fallback == nil {
return "", fmt.Errorf("primary llm: %w", err)
}
out, err2 := r.Fallback.Complete(ctx, system, user)
if err2 != nil {
return "", fmt.Errorf("primary llm: %w; fallback llm: %v", err, err2)
}
return out, nil
}
+78
View File
@@ -0,0 +1,78 @@
package llm
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestRouter_PrimarySucceeds(t *testing.T) {
primary := mockServer(t, "from-primary")
defer primary.Close()
fallback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("fallback must not be called when primary succeeds")
}))
defer fallback.Close()
r := &Router{
Primary: New(primary.URL, "", "m", time.Second),
Fallback: New(fallback.URL, "", "m", time.Second),
}
out, err := r.Complete(context.Background(), "sys", "user")
if err != nil {
t.Fatalf("Complete: %v", err)
}
if out != "from-primary" {
t.Errorf("got %q, want %q", out, "from-primary")
}
}
func TestRouter_FallsBackOnPrimaryError(t *testing.T) {
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
defer primary.Close()
fallback := mockServer(t, "from-fallback")
defer fallback.Close()
r := &Router{
Primary: New(primary.URL, "", "m", time.Second),
Fallback: New(fallback.URL, "", "m", time.Second),
}
out, err := r.Complete(context.Background(), "sys", "user")
if err != nil {
t.Fatalf("Complete: %v", err)
}
if out != "from-fallback" {
t.Errorf("got %q, want %q", out, "from-fallback")
}
}
func TestRouter_BothFail(t *testing.T) {
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "err", http.StatusBadGateway)
}))
defer fail.Close()
r := &Router{
Primary: New(fail.URL, "", "m", time.Second),
Fallback: New(fail.URL, "", "m", time.Second),
}
if _, err := r.Complete(context.Background(), "sys", "user"); err == nil {
t.Fatal("want error when both fail, got nil")
}
}
func TestRouter_NilFallback(t *testing.T) {
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "err", http.StatusBadGateway)
}))
defer fail.Close()
r := &Router{Primary: New(fail.URL, "", "m", time.Second)}
if _, err := r.Complete(context.Background(), "sys", "user"); err == nil {
t.Fatal("want error with nil fallback, got nil")
}
}
+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)")
}
}