merge: llm copy + Summarizer adapter (Worker B, agent/llm-summarizer)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user