From 0aeb3aa99eb936af477c6ea5e47738c525421c6e Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 2 Jun 2026 17:01:56 +0200 Subject: [PATCH 1/2] feat(adapters): copy stdlib llm package (Client + Router) per ADR-004 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy hyperguild/ingestion/internal/llm into internal/adapters/llm and own it. Tapir owes that repo nothing at the dependency level — no module dep added. Router gives the local-Primary -> BYO-Fallback path needed for ai_routing.feature. Copied tests rewritten from testify to stdlib testing to keep go.mod dependency-free (repo has zero deps; acceptance tests are stdlib too). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/adapters/llm/client.go | 124 +++++++++++++++++++++++++++ internal/adapters/llm/client_test.go | 103 ++++++++++++++++++++++ internal/adapters/llm/router.go | 29 +++++++ internal/adapters/llm/router_test.go | 78 +++++++++++++++++ 4 files changed, 334 insertions(+) create mode 100644 internal/adapters/llm/client.go create mode 100644 internal/adapters/llm/client_test.go create mode 100644 internal/adapters/llm/router.go create mode 100644 internal/adapters/llm/router_test.go diff --git a/internal/adapters/llm/client.go b/internal/adapters/llm/client.go new file mode 100644 index 0000000..0c84a36 --- /dev/null +++ b/internal/adapters/llm/client.go @@ -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 +} diff --git a/internal/adapters/llm/client_test.go b/internal/adapters/llm/client_test.go new file mode 100644 index 0000000..742bc96 --- /dev/null +++ b/internal/adapters/llm/client_test.go @@ -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) + } +} diff --git a/internal/adapters/llm/router.go b/internal/adapters/llm/router.go new file mode 100644 index 0000000..6d46d43 --- /dev/null +++ b/internal/adapters/llm/router.go @@ -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 +} diff --git a/internal/adapters/llm/router_test.go b/internal/adapters/llm/router_test.go new file mode 100644 index 0000000..5ae31ac --- /dev/null +++ b/internal/adapters/llm/router_test.go @@ -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") + } +} From 6da9d61e63a292f4c3b18d59c1457c67954f91b8 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 2 Jun 2026 17:04:29 +0200 Subject: [PATCH 2/2] feat(adapters): add Summarizer backed by local-first llm routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/homelab-integration.md | 14 +- internal/adapters/summarizer/summarizer.go | 134 +++++++++++++ .../adapters/summarizer/summarizer_test.go | 178 ++++++++++++++++++ 3 files changed, 322 insertions(+), 4 deletions(-) create mode 100644 internal/adapters/summarizer/summarizer.go create mode 100644 internal/adapters/summarizer/summarizer_test.go diff --git a/docs/homelab-integration.md b/docs/homelab-integration.md index a39dc68..d847ccb 100644 --- a/docs/homelab-integration.md +++ b/docs/homelab-integration.md @@ -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. diff --git a/internal/adapters/summarizer/summarizer.go b/internal/adapters/summarizer/summarizer.go new file mode 100644 index 0000000..c387370 --- /dev/null +++ b/internal/adapters/summarizer/summarizer.go @@ -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 +} diff --git a/internal/adapters/summarizer/summarizer_test.go b/internal/adapters/summarizer/summarizer_test.go new file mode 100644 index 0000000..07e54f5 --- /dev/null +++ b/internal/adapters/summarizer/summarizer_test.go @@ -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 := "let me reason...\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)") + } +}