feat(chat): stored-transcript QA service (ADR-027)
A read-only deeper-dive over a video's already-stored transcript (ADR-021). Safe by construction: the Service has no VideoSource and no caption-fetch dependency — only a Completer factory over the existing LiteLLM gateway — so it cannot reach YouTube or the rate gate. Reuses the summarizer's truncation discipline (TAPIR_MAX_TRANSCRIPT_CHARS), reporting the cut so the UI can be honest about a bounded transcript. Model defaults to the summary's model and is switchable among an offered, local-first list; an un-offered alias is forced back to the default so chat can never call the gateway with an arbitrary model. Ephemeral: history is carried per-request, nothing persisted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user