diff --git a/internal/adapters/chat/chat.go b/internal/adapters/chat/chat.go new file mode 100644 index 0000000..f37d19f --- /dev/null +++ b/internal/adapters/chat/chat.go @@ -0,0 +1,171 @@ +// Package chat implements the per-video deeper-dive chat (ADR-027): a read-only +// QA over a video's ALREADY-STORED transcript (ADR-021). It is the enforcement +// point for the feature's load-bearing safety property — stored-transcript-only: +// the Service has NO VideoSource and NO caption-fetch dependency, only a +// Completer factory, so it CANNOT reach YouTube or the rate gate by construction. +// The caller supplies the stored transcript text; chat never fetches. +// +// It reuses the same LiteLLM gateway as the summarizer (a chat is a different +// call, not a new integration) and the same transcript-truncation discipline +// (TAPIR_MAX_TRANSCRIPT_CHARS) so a long transcript fits a small-context model. +package chat + +import ( + "context" + "fmt" + "strings" + "unicode/utf8" +) + +// Completer is the minimal LLM chat surface the Service needs. *llm.Client +// satisfies it; tests use a fake. It is the SAME surface the summarizer uses. +type Completer interface { + Complete(ctx context.Context, system, user string) (string, error) +} + +// Turn is one completed exchange in an ephemeral, session-only conversation +// (ADR-027 v1: nothing is persisted). +type Turn struct { + Question string + Answer string +} + +// Request is one chat turn: the chosen model, the stored transcript text, the +// prior turns (for multi-turn context within the session), and the new question. +type Request struct { + Model string + Transcript string + History []Turn + Question string +} + +// Reply is the model's answer plus whether the transcript was bounded to fit the +// model context (so the UI can be honest that an answer about the tail of a long +// video may be incomplete). +type Reply struct { + Answer string + Truncated bool +} + +// Service answers questions against a stored transcript via a switchable set of +// models. models is the ordered, local-first list offered to the user (the cloud +// model is simply absent when disabled — see cmd wiring); maxChars bounds the +// transcript sent to any model (0 = unbounded). newClient builds a Completer for +// a chosen model alias (the same gateway, a different alias). +type Service struct { + newClient func(model string) Completer + models []string + maxChars int +} + +// New constructs a Service. models must be non-empty and already filtered to the +// offerable set (cloud excluded when disabled) and de-duplicated by the caller. +func New(newClient func(model string) Completer, models []string, maxChars int) *Service { + return &Service{newClient: newClient, models: models, maxChars: maxChars} +} + +// Models returns a copy of the offerable model list (local-first order). +func (s *Service) Models() []string { + out := make([]string, len(s.models)) + copy(out, s.models) + return out +} + +// offers reports whether model is in the offerable set — the guard that keeps an +// arbitrary, un-offered alias (e.g. a forged form value) from reaching the gateway. +func (s *Service) offers(model string) bool { + for _, m := range s.models { + if m == model { + return true + } + } + return false +} + +// DefaultModel resolves the model a fresh chat opens with: the summary's own +// model when it is still an offered option (the ADR-027 default — chat continues +// in the model that produced the summary), otherwise the first offered model. +// Returns "" only when no models are configured. +func (s *Service) DefaultModel(summaryModel string) string { + if summaryModel != "" && s.offers(summaryModel) { + return summaryModel + } + if len(s.models) > 0 { + return s.models[0] + } + return "" +} + +// Answer runs one chat turn. The model is forced back to a default if the request +// names an un-offered alias, so chat can never call the gateway with an arbitrary +// model. The transcript is truncated up front (reporting whether it was cut) and +// passed as system context; the running conversation is the user message. +func (s *Service) Answer(ctx context.Context, req Request) (Reply, error) { + if len(s.models) == 0 { + return Reply{}, fmt.Errorf("chat: no models configured") + } + model := req.Model + if !s.offers(model) { + model = s.DefaultModel("") + } + + transcript, truncated := truncate(req.Transcript, s.maxChars) + system := buildSystem(transcript, truncated) + user := buildUser(req.History, req.Question) + + out, err := s.newClient(model).Complete(ctx, system, user) + if err != nil { + return Reply{}, fmt.Errorf("chat: %s: %w", model, err) + } + answer := strings.TrimSpace(out) + if answer == "" { + return Reply{}, fmt.Errorf("chat: %s returned an empty answer", model) + } + return Reply{Answer: answer, Truncated: truncated}, nil +} + +const systemPreamble = `You are Tapir, answering questions about ONE video using ONLY the transcript below. +Ground every answer in the transcript. If the transcript does not contain the answer, say so plainly rather than guessing.` + +const truncatedNote = ` +The transcript below is truncated to fit the model — if a question seems to concern something missing, note it may be beyond the available portion.` + +// buildSystem frames the model as a transcript-grounded QA assistant and embeds +// the (possibly truncated) transcript as context. +func buildSystem(transcript string, truncated bool) string { + var b strings.Builder + b.WriteString(systemPreamble) + if truncated { + b.WriteString(truncatedNote) + } + b.WriteString("\n\nTranscript:\n") + b.WriteString(transcript) + return b.String() +} + +// buildUser renders the running conversation as the user message: prior turns as +// Q/A pairs followed by the new question. Folding history into one message keeps +// the Completer surface (a single system+user call) unchanged — no new llm method. +func buildUser(history []Turn, question string) string { + var b strings.Builder + for _, t := range history { + fmt.Fprintf(&b, "Q: %s\nA: %s\n\n", t.Question, t.Answer) + } + fmt.Fprintf(&b, "Q: %s", question) + return b.String() +} + +// truncate caps content to max bytes on a UTF-8 rune boundary, reporting whether +// it cut. It mirrors the summarizer's truncation discipline (ADR-022) but returns +// the cut flag so the chat UI can be honest about a bounded transcript. A +// non-positive max (or content already within budget) returns content unchanged. +func truncate(content string, max int) (string, bool) { + if max <= 0 || len(content) <= max { + return content, false + } + cut := max + for cut > 0 && !utf8.RuneStart(content[cut]) { + cut-- + } + return content[:cut], true +} diff --git a/internal/adapters/chat/chat_test.go b/internal/adapters/chat/chat_test.go new file mode 100644 index 0000000..f94b553 --- /dev/null +++ b/internal/adapters/chat/chat_test.go @@ -0,0 +1,164 @@ +package chat + +import ( + "context" + "errors" + "strings" + "testing" +) + +// recordingCompleter captures the system+user it was asked with and returns a +// canned answer (or error). It also records which model alias built it. +type recordingCompleter struct { + model string + lastSystem string + lastUser string + answer string + err error + calls *int +} + +func (c *recordingCompleter) Complete(_ context.Context, system, user string) (string, error) { + *c.calls++ + c.lastSystem = system + c.lastUser = user + if c.err != nil { + return "", c.err + } + return c.answer, nil +} + +// factory builds a recordingCompleter per model and records the last one built so +// the test can assert which model alias was actually used for the gateway call. +type factory struct { + answer string + err error + calls int + used *recordingCompleter +} + +func (f *factory) make(model string) Completer { + c := &recordingCompleter{model: model, answer: f.answer, err: f.err, calls: &f.calls} + f.used = c + return c +} + +func TestModelsAreOfferedLocalFirstAndCopied(t *testing.T) { + f := &factory{answer: "ok"} + s := New(f.make, []string{"koala/phi4-mini", "iguana/gemma4-26b"}, 0) + + got := s.Models() + want := []string{"koala/phi4-mini", "iguana/gemma4-26b"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("Models() = %v, want %v", got, want) + } + // Mutating the returned slice must not corrupt the Service's list. + got[0] = "tampered" + if s.Models()[0] != "koala/phi4-mini" { + t.Fatal("Models() leaked its backing slice") + } +} + +func TestDefaultModelIsTheSummarysModelWhenOffered(t *testing.T) { + f := &factory{answer: "ok"} + s := New(f.make, []string{"koala/phi4-mini", "iguana/gemma4-26b", "berget/mistral-small"}, 0) + + if got := s.DefaultModel("iguana/gemma4-26b"); got != "iguana/gemma4-26b" { + t.Fatalf("DefaultModel(summary) = %q, want the summary's model", got) + } + // A summary model no longer offered (e.g. cloud disabled) falls back to first. + if got := s.DefaultModel("berget/old-model"); got != "koala/phi4-mini" { + t.Fatalf("DefaultModel(un-offered) = %q, want the first offered model", got) + } + // No summary model recorded → first offered. + if got := s.DefaultModel(""); got != "koala/phi4-mini" { + t.Fatalf("DefaultModel(\"\") = %q, want the first offered model", got) + } +} + +func TestAnswerGroundsOnTranscriptAndCarriesHistory(t *testing.T) { + f := &factory{answer: " The video is about attention. "} + s := New(f.make, []string{"koala/phi4-mini"}, 0) + + reply, err := s.Answer(context.Background(), Request{ + Model: "koala/phi4-mini", + Transcript: "ATTENTION-TRANSCRIPT-MARKER", + History: []Turn{{Question: "who", Answer: "the host"}}, + Question: "what is it about", + }) + if err != nil { + t.Fatalf("Answer: %v", err) + } + if reply.Answer != "The video is about attention." { + t.Fatalf("answer not trimmed: %q", reply.Answer) + } + if reply.Truncated { + t.Fatal("short transcript must not report truncated") + } + // The transcript rides in the system prompt; the conversation in the user msg. + if !strings.Contains(f.used.lastSystem, "ATTENTION-TRANSCRIPT-MARKER") { + t.Fatal("transcript not grounded into the system prompt") + } + if !strings.Contains(f.used.lastUser, "Q: who") || !strings.Contains(f.used.lastUser, "A: the host") { + t.Fatalf("history not carried into the user message: %q", f.used.lastUser) + } + if !strings.Contains(f.used.lastUser, "what is it about") { + t.Fatal("new question missing from the user message") + } +} + +func TestAnswerTruncatesLongTranscriptAndReportsIt(t *testing.T) { + f := &factory{answer: "answer"} + s := New(f.make, []string{"koala/phi4-mini"}, 10) + + reply, err := s.Answer(context.Background(), Request{ + Model: "koala/phi4-mini", + Transcript: strings.Repeat("x", 500), + Question: "summarize", + }) + if err != nil { + t.Fatalf("Answer: %v", err) + } + if !reply.Truncated { + t.Fatal("a transcript past maxChars must report Truncated") + } + if strings.Count(f.used.lastSystem, "x") != 10 { + t.Fatalf("transcript not bounded to maxChars: got %d x's", strings.Count(f.used.lastSystem, "x")) + } +} + +func TestAnswerForcesAnUnofferedModelBackToDefault(t *testing.T) { + f := &factory{answer: "answer"} + s := New(f.make, []string{"koala/phi4-mini", "iguana/gemma4-26b"}, 0) + + // A forged/un-offered model must never reach the gateway as-is — it is forced + // to the default offered model (the cloud-absent guarantee depends on this). + _, err := s.Answer(context.Background(), Request{ + Model: "berget/secret-cloud-model", + Transcript: "t", + Question: "q", + }) + if err != nil { + t.Fatalf("Answer: %v", err) + } + if f.used.model != "koala/phi4-mini" { + t.Fatalf("un-offered model reached the gateway as %q, want the default", f.used.model) + } +} + +func TestAnswerPropagatesCompleterError(t *testing.T) { + f := &factory{err: errors.New("gateway down")} + s := New(f.make, []string{"koala/phi4-mini"}, 0) + + _, err := s.Answer(context.Background(), Request{Model: "koala/phi4-mini", Transcript: "t", Question: "q"}) + if err == nil { + t.Fatal("expected the gateway error to propagate") + } +} + +func TestAnswerRejectsEmptyModelSet(t *testing.T) { + s := New(func(string) Completer { return nil }, nil, 0) + if _, err := s.Answer(context.Background(), Request{Question: "q"}); err == nil { + t.Fatal("expected an error when no models are configured") + } +}