package web import ( "context" "errors" "net/http" "strings" "gitea.d-ma.be/mathias/tapir/internal/adapters/chat" "gitea.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 } a.render(w, r, ChatPage(chatView{ VideoID: row.VideoID, Title: displayTitle(*row), Available: hasText, Models: a.Chat.Models(), Selected: a.Chat.DefaultModel(row.AIModel), })) } // 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.renderChat(w, r, 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 } // renderChat returns the chat panel fragment for an HTMX request, or the full // chat page otherwise (no-JS POST re-renders the whole page). func (a *App) renderChat(w http.ResponseWriter, r *http.Request, v chatView) { if isHTMX(r) { a.render(w, r, chatPanel(v)) return } a.render(w, r, ChatPage(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:] }