feat(adapters): add Summarizer backed by local-first llm routing
Implement ports.Summarizer in internal/adapters/summarizer. It routes through a
local Primary endpoint first and an optional BYO Fallback, owning the routing
itself (not delegating to llm.Router) so it can record AIProvider, AIModel, and
FallbackUsed on domain.Summary. Prompt asks for JSON {summary, highlights,
takeaways}; the parser tolerates thinking-model fences/reasoning and rejects an
empty summary.
The summarizer is the single egress point for content toward an AI model, so it
enforces the local-first guarantee from ai_routing.feature: with no BYO
configured (nil fallback) there is no external endpoint, so content reaches the
local stack and nowhere else. Tests assert all four scenarios via a fake client.
Model alias is config (TAPIR_SUMMARIZER_MODEL, host/name) — not hardcoded;
docs/homelab-integration.md notes it stays `confirm` and that thinking models
need an explicit max_tokens or they return empty content.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user