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/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") + } +} 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)") + } +}