Phase 4: Ask Petal SSE chat
Conversational follow-up on a suggestion, streamed token-by-token. Backend (interface-only; handlers never touch a concrete LLM client): - internal/llm/chat.go: StreamAskPetal with conversational sampling (max_tokens 512, temp 0.7, rep 1.15, top_p 0.92, stop "\n\n\n"), reusing AskPetalSystemPrompt + TrimHistory. - internal/suggestions/chat.go: POST /api/suggestions/:id/chat. One user-scoped join loads the suggestion + parent content_text; surroundingParagraph extracts the \n\n-bounded paragraph at from_pos (whole-doc fallback when unlocated) and injects it server-side. Streams event: token / event: done SSE frames with JSON-encoded data so token newlines can't break framing; real http.Flusher per chunk. LLM-unreachable -> 502 before SSE headers; unknown suggestion -> 404. Frontend: - streamSuggestionChat: fetch + ReadableStream SSE parser (not EventSource, needs POST), abortable. - AskPetal.tsx: whole conversation in component state (no persistence, cleared on close), Petal's first bubble pre-seeded with the explanation, rose/lavender bubbles, CJK font stack on the bubbles only (Note #17), streaming caret. - SuggestionCard "Ask Petal" pill pins the card open while chatting (hover-close suppressed, click-away closes) and widens it to 340px. Tests: chat_test.go covers streamed-text concat + done event, server-side context injection on the system message, sampling params, 404, and surroundingParagraph. go build/vet/test clean, tsc clean, vite build OK. Live SSE smoke-tested against a fake streaming vLLM: tokens flushed individually through the chi middleware stack, done terminator, 502 on LLM-down, 404 on unknown suggestion. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
126
internal/suggestions/chat.go
Normal file
126
internal/suggestions/chat.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package suggestions
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||
)
|
||||
|
||||
// chatRequest is the body the AskPetal panel posts: the full conversation so
|
||||
// far. The suggestion context is loaded server-side and never trusted from the
|
||||
// client (spec Note #10).
|
||||
type chatRequest struct {
|
||||
Messages []llm.Message `json:"messages"`
|
||||
}
|
||||
|
||||
// chat streams an Ask Petal conversational reply over SSE. It loads the
|
||||
// suggestion and its parent document's surrounding paragraph, injects them as
|
||||
// the tutor system prompt, then relays the model's tokens to the browser as
|
||||
// `data:` events. History persistence lives entirely in the client.
|
||||
func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
|
||||
sugID := chi.URLParam(r, "id")
|
||||
|
||||
var body chatRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
errorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// One query for the suggestion fields and the parent document's plain text,
|
||||
// scoped to the local user so a stray id can't read another user's doc.
|
||||
var (
|
||||
original, replacement, explanation, typ string
|
||||
fromPos int
|
||||
contentText string
|
||||
)
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT s.original, s.replacement, s.explanation, s.type, s.from_pos, d.content_text
|
||||
FROM suggestions s
|
||||
JOIN documents d ON d.id = s.doc_id
|
||||
WHERE s.id = ? AND d.user_id = ?`,
|
||||
sugID, db.LocalUserID,
|
||||
).Scan(&original, &replacement, &explanation, &typ, &fromPos, &contentText)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
errorJSON(w, http.StatusNotFound, "suggestion not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
paragraph := surroundingParagraph(contentText, fromPos)
|
||||
systemPrompt := llm.AskPetalSystemPrompt(original, replacement, typ, explanation, paragraph)
|
||||
|
||||
// SSE requires an unbuffered, flushable writer. chi's middleware writers pass
|
||||
// Flush through; bail with a plain error if somehow they don't.
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
serverError(w, errors.New("streaming unsupported"))
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := llm.StreamAskPetal(r.Context(), h.Client, systemPrompt, body.Messages)
|
||||
if err != nil {
|
||||
// The stream never opened (e.g. LLM unreachable) — a normal JSON error is
|
||||
// still appropriate since we haven't written SSE headers yet.
|
||||
errorJSON(w, http.StatusBadGateway, "chat failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering (e.g. nginx)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
for chunk := range ch {
|
||||
writeSSE(w, "token", map[string]string{"text": chunk})
|
||||
flusher.Flush()
|
||||
}
|
||||
// Signal a clean end so the client can stop reading without waiting on EOF.
|
||||
writeSSE(w, "done", map[string]bool{"done": true})
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// writeSSE emits one named SSE event with a JSON data payload. JSON-encoding the
|
||||
// data keeps token text (which may contain newlines) from breaking SSE framing.
|
||||
func writeSSE(w http.ResponseWriter, event string, data any) {
|
||||
payload, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("event: " + event + "\ndata: "))
|
||||
_, _ = w.Write(payload)
|
||||
_, _ = w.Write([]byte("\n\n"))
|
||||
}
|
||||
|
||||
// surroundingParagraph returns the paragraph of contentText containing the
|
||||
// plain-text offset from. Tiptap flattens blocks with blank-line separators, so
|
||||
// paragraphs are bounded by "\n\n". When the offset is unknown (original wasn't
|
||||
// located, from == -1) it falls back to the latency-capped document so the tutor
|
||||
// still has context to work with.
|
||||
func surroundingParagraph(contentText string, from int) string {
|
||||
if from < 0 || from > len(contentText) {
|
||||
return strings.TrimSpace(llm.TruncateDoc(contentText))
|
||||
}
|
||||
start := strings.LastIndex(contentText[:from], "\n\n")
|
||||
if start < 0 {
|
||||
start = 0
|
||||
} else {
|
||||
start += 2
|
||||
}
|
||||
end := len(contentText)
|
||||
if rel := strings.Index(contentText[from:], "\n\n"); rel >= 0 {
|
||||
end = from + rel
|
||||
}
|
||||
return strings.TrimSpace(contentText[start:end])
|
||||
}
|
||||
148
internal/suggestions/chat_test.go
Normal file
148
internal/suggestions/chat_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package suggestions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||
)
|
||||
|
||||
// streamClient serves a canned checkpoint (so a suggestion can be seeded through
|
||||
// the public /check route) and a fixed token stream for chat. It records the
|
||||
// last streamed request so a test can assert on the injected system context.
|
||||
type streamClient struct {
|
||||
checkpoint string
|
||||
tokens []string
|
||||
lastStream llm.CompletionRequest
|
||||
}
|
||||
|
||||
func (c *streamClient) Complete(_ context.Context, _ llm.CompletionRequest) (string, error) {
|
||||
return c.checkpoint, nil
|
||||
}
|
||||
|
||||
func (c *streamClient) Stream(_ context.Context, req llm.CompletionRequest) (<-chan string, error) {
|
||||
c.lastStream = req
|
||||
ch := make(chan string)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
for _, t := range c.tokens {
|
||||
ch <- t
|
||||
}
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestAskPetalChat(t *testing.T) {
|
||||
client := &streamClient{
|
||||
checkpoint: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"}]}`,
|
||||
tokens: []string{"Because ", "\"I\" ", "takes ", "\"have\"."},
|
||||
}
|
||||
srv, docID := newTestServer(t, client)
|
||||
|
||||
// Seed one suggestion via the checkpoint route, then grab its id.
|
||||
do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||
listed := do(t, srv, http.MethodGet, "/docs/"+docID+"/suggestions", "")
|
||||
var sugs []db.Suggestion
|
||||
if err := json.Unmarshal(listed.Body.Bytes(), &sugs); err != nil {
|
||||
t.Fatalf("decode suggestions: %v", err)
|
||||
}
|
||||
if len(sugs) != 1 {
|
||||
t.Fatalf("seed: want 1 suggestion, got %d", len(sugs))
|
||||
}
|
||||
sugID := sugs[0].ID
|
||||
|
||||
rec := do(t, srv, http.MethodPost, "/suggestions/"+sugID+"/chat",
|
||||
`{"messages":[{"role":"user","content":"why is this wrong?"}]}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("chat: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "text/event-stream" {
|
||||
t.Fatalf("chat: content-type=%q, want text/event-stream", ct)
|
||||
}
|
||||
|
||||
// The streamed tokens should arrive concatenated across data: events, and the
|
||||
// stream should terminate with a done event.
|
||||
body := rec.Body.String()
|
||||
got := collectSSEText(body)
|
||||
if want := "Because \"I\" takes \"have\"."; got != want {
|
||||
t.Fatalf("streamed text = %q, want %q", got, want)
|
||||
}
|
||||
if !strings.Contains(body, "event: done") {
|
||||
t.Fatalf("chat stream missing done event:\n%s", body)
|
||||
}
|
||||
|
||||
// Context injection: the system prompt (first message) must carry the
|
||||
// suggestion's original text and the surrounding paragraph — loaded
|
||||
// server-side, never from the client.
|
||||
msgs := client.lastStream.Messages
|
||||
if len(msgs) < 2 || msgs[0].Role != "system" {
|
||||
t.Fatalf("expected a leading system message, got %+v", msgs)
|
||||
}
|
||||
sys := msgs[0].Content
|
||||
if !strings.Contains(sys, "I has") {
|
||||
t.Fatalf("system prompt missing original text:\n%s", sys)
|
||||
}
|
||||
if !strings.Contains(sys, "I has two apple.") {
|
||||
t.Fatalf("system prompt missing surrounding paragraph:\n%s", sys)
|
||||
}
|
||||
// The client's user message rides after the system turn.
|
||||
if last := msgs[len(msgs)-1]; last.Role != "user" || last.Content != "why is this wrong?" {
|
||||
t.Fatalf("user message not forwarded: %+v", last)
|
||||
}
|
||||
|
||||
// Chat sampling parameters from the spec.
|
||||
if client.lastStream.MaxTokens != 512 || client.lastStream.Temperature != 0.7 {
|
||||
t.Fatalf("unexpected chat params: %+v", client.lastStream)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskPetalChatNotFound(t *testing.T) {
|
||||
client := &streamClient{checkpoint: `{"suggestions":[]}`}
|
||||
srv, _ := newTestServer(t, client)
|
||||
rec := do(t, srv, http.MethodPost, "/suggestions/does-not-exist/chat",
|
||||
`{"messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404 for unknown suggestion, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurroundingParagraph(t *testing.T) {
|
||||
text := "First paragraph here.\n\nThe target sentence lives here.\n\nThird paragraph."
|
||||
from := strings.Index(text, "target")
|
||||
got := surroundingParagraph(text, from)
|
||||
if got != "The target sentence lives here." {
|
||||
t.Fatalf("paragraph = %q", got)
|
||||
}
|
||||
|
||||
// Unknown offset falls back to the (trimmed) document.
|
||||
if got := surroundingParagraph(text, -1); !strings.Contains(got, "First paragraph") {
|
||||
t.Fatalf("fallback paragraph = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// collectSSEText concatenates the JSON text fields from every `event: token`
|
||||
// frame in an SSE body.
|
||||
func collectSSEText(body string) string {
|
||||
var b strings.Builder
|
||||
for _, frame := range strings.Split(body, "\n\n") {
|
||||
if !strings.Contains(frame, "event: token") {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(frame, "\n") {
|
||||
data, ok := strings.CutPrefix(line, "data: ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var payload struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(data), &payload)
|
||||
b.WriteString(payload.Text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -47,6 +47,7 @@ func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/{id}/accept", h.accept)
|
||||
r.Post("/{id}/dismiss", h.dismiss)
|
||||
r.Post("/{id}/chat", h.chat)
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user