Files
tapir/internal/web/chat.go
T
mathiasandClaude Sonnet 4.6 38f222c931
CI / Lint / Test / Vet (push) Successful in 11s
CI / Build & Import (push) Successful in 12s
chore: rename Go module path gitea.d-ma.be → git.d-ma.be
Infra ADR-0004 renamed the Gitea host. Bulk replace across go.mod and
all .go import paths. Build and tests pass unchanged.

Closes #20

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dt6aHEDWRjkK14Voi6HnGh
2026-07-02 14:37:33 +02:00

228 lines
7.9 KiB
Go

package web
import (
"context"
"errors"
"net/http"
"strings"
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
)
// Chatter is the per-video chat backend (ADR-027). *chat.Service satisfies it;
// tests substitute a fake. It carries NO caption-fetch dependency — the chat
// handlers reach it only after reading an already-stored transcript, so an
// enabled chat cannot trigger a fetch, touch the rate gate, or reach YouTube.
type Chatter interface {
// Models returns the offerable models, local-first (cloud absent when disabled).
Models() []string
// DefaultModel resolves the model a fresh chat opens with given the summary's
// model (the ADR-027 default), falling back to the first offered model.
DefaultModel(summaryModel string) string
// Answer runs one chat turn against the supplied stored transcript text.
Answer(ctx context.Context, req chat.Request) (chat.Reply, error)
}
// maxHistoryTurns bounds the ephemeral conversation carried per request, so a long
// back-and-forth cannot grow the prompt without limit (the transcript already
// dominates the budget). Older turns drop off the front.
const maxHistoryTurns = 8
// chatView is everything the chat templates render: the video identity for links
// and titles, the model switcher state, the running (ephemeral) conversation, and
// the honest flags — Available is false when no usable transcript is stored
// (ADR-027: honest "not available", never a fetch), Truncated when the transcript
// was bounded to fit the model, Error for a transient model failure.
type chatView struct {
VideoID string
Title string
Available bool
Models []string
Selected string
History []chat.Turn
Truncated bool
Error string
}
// handleChat renders the chat page for a summarized video (GET). Entry is scoped
// through GetSummaryByVideo, which is RLS/user-scoped: a video that is not the
// requesting user's own resolves to ErrNotFound → 404, so chat is reachable only
// from the user's own summary view (ADR-027 isolation). The transcript is read
// from the SHARED store (ADR-021) — a pure DB read, never a caption fetch; an
// absent/text-less transcript renders the honest "not available" state, no fetch.
func (a *App) handleChat(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
row, ok := a.loadOwnedSummary(w, r, userID)
if !ok {
return
}
_, hasText, ok := a.readTranscript(w, r, *row)
if !ok {
return
}
view := chatView{
VideoID: row.VideoID,
Title: displayTitle(*row),
Available: hasText,
Models: a.Chat.Models(),
Selected: a.Chat.DefaultModel(row.AIModel),
}
// HTMX (the in-place reveal from the summary) gets just the open chat section,
// swapped over the closed dock so the summary above it stays put. A no-JS
// navigation gets the full page: the whole summary plus the open chat.
if isHTMX(r) {
a.render(w, r, chatSection(view))
return
}
a.render(w, r, ChatPage(*row, view))
}
// handleChatMessage answers one question against the stored transcript (POST).
// It reads the transcript from the store (no fetch), runs the chosen model over
// it plus the prior turns, appends the answer, and returns the refreshed chat
// panel (HTMX) or the whole page (no-JS). A model failure is surfaced inline,
// not as a 500 — the conversation and the question are preserved for a retry.
func (a *App) handleChatMessage(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
row, ok := a.loadOwnedSummary(w, r, userID)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
transcript, hasText, ok := a.readTranscript(w, r, *row)
if !ok {
return
}
model := a.resolveModel(r.FormValue("model"), row.AIModel)
history := parseHistory(r.Form["hq"], r.Form["ha"])
question := strings.TrimSpace(r.FormValue("question"))
view := chatView{
VideoID: row.VideoID,
Title: displayTitle(*row),
Available: hasText,
Models: a.Chat.Models(),
Selected: model,
History: history,
}
switch {
case !hasText:
// Honest "not available" — no fetch, no model call (ADR-027).
case question == "":
// A model switch with no text just re-renders — no wasted round-trip.
default:
reply, err := a.Chat.Answer(r.Context(), chat.Request{
Model: model,
Transcript: transcript,
History: history,
Question: question,
})
if err != nil {
a.logger().Error("chat answer", "video", row.VideoID, "model", model, "err", err)
view.Error = "That model couldn't answer just now. Try again, or switch models."
} else {
view.History = appendTurn(history, chat.Turn{Question: question, Answer: reply.Answer})
view.Truncated = reply.Truncated
}
}
a.renderChatTurn(w, r, *row, view)
}
// loadOwnedSummary fetches the summary for the path's video scoped to userID, or
// writes the right response (404 on not-found/not-owned, 500 on error) and reports
// false. It is the single isolation gate for both chat handlers.
func (a *App) loadOwnedSummary(w http.ResponseWriter, r *http.Request, userID string) (*store.SummaryRow, bool) {
videoID := r.PathValue("videoId")
row, err := a.Store.GetSummaryByVideo(r.Context(), userID, videoID)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return nil, false
}
if err != nil {
a.serverError(w, r, "chat get summary", err)
return nil, false
}
return row, true
}
// readTranscript reads the shared stored transcript for a row (ADR-021) and
// reports whether it carries usable text. It is a pure DB read — NO caption fetch,
// the property the whole feature's safety rests on. ok is false only on a store
// error (after a 500 is written); a missing/text-less transcript is (",", false,
// true) — the honest "not available" case, handled by the caller, not an error.
func (a *App) readTranscript(w http.ResponseWriter, r *http.Request, row store.SummaryRow) (content string, hasText, ok bool) {
t, found, err := a.Store.GetTranscript(r.Context(), row.Channel, row.ProviderVideoID)
if err != nil {
a.serverError(w, r, "chat get transcript", err)
return "", false, false
}
if !found || !t.HasText() {
return "", false, true
}
return t.Content, true, true
}
// renderChatTurn returns the chat panel fragment for an HTMX answer (swapped in
// place within the open dock), or the full chat page otherwise — the no-JS POST
// re-renders the whole summary + open chat with the new turn.
func (a *App) renderChatTurn(w http.ResponseWriter, r *http.Request, row store.SummaryRow, v chatView) {
if isHTMX(r) {
a.render(w, r, chatPanel(v))
return
}
a.render(w, r, ChatPage(row, v))
}
// resolveModel keeps the posted model only when it is an offered option; anything
// else (a forged value, or a model dropped because cloud is disabled) falls back
// to the default. The chat.Service enforces the same guard before the gateway;
// this keeps the rendered switcher honest too.
func (a *App) resolveModel(posted, summaryModel string) string {
for _, m := range a.Chat.Models() {
if m == posted {
return posted
}
}
return a.Chat.DefaultModel(summaryModel)
}
// parseHistory zips the parallel hidden hq/ha fields back into ordered turns,
// keeping only the most recent maxHistoryTurns. net/url preserves the submission
// order of repeated fields, so the pairing is stable.
func parseHistory(qs, as []string) []chat.Turn {
n := len(qs)
if len(as) < n {
n = len(as)
}
turns := make([]chat.Turn, 0, n)
for i := 0; i < n; i++ {
turns = append(turns, chat.Turn{Question: qs[i], Answer: as[i]})
}
return capHistory(turns)
}
// appendTurn adds a completed exchange and re-bounds the conversation.
func appendTurn(history []chat.Turn, t chat.Turn) []chat.Turn {
return capHistory(append(history, t))
}
// capHistory keeps the last maxHistoryTurns turns (drops the oldest).
func capHistory(turns []chat.Turn) []chat.Turn {
if len(turns) <= maxHistoryTurns {
return turns
}
return turns[len(turns)-maxHistoryTurns:]
}