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
228 lines
7.9 KiB
Go
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:]
|
|
}
|