7d076ecae8887de8e98433327f94d8375c208672

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Add jp-server HTTP entry point and session API

Diff

This diff is truncated to protect this page.

  1diff --git a/cmd/jp-server/main.go b/cmd/jp-server/main.go
  2new file mode 100644
  3index 0000000000000000000000000000000000000000..50b6bb4242eb521c8e874afd94ab3774cadcc957
  4--- /dev/null
  5+++ b/cmd/jp-server/main.go
  6@@ -0,0 +1,34 @@
  7+package main
  8+
  9+import (
 10+	"fmt"
 11+	"net/http"
 12+	"os"
 13+
 14+	"japanese/internal/bootstrap"
 15+	"japanese/internal/config"
 16+	"japanese/internal/server"
 17+)
 18+
 19+func main() {
 20+	cfg := config.ParseServerConfig()
 21+	if cfg.Token == "" {
 22+		fatalf("token is required: set --token or JP_SERVER_TOKEN")
 23+	}
 24+
 25+	svc, err := bootstrap.New(&cfg.Config)
 26+	if err != nil {
 27+		fatalf("%v", err)
 28+	}
 29+
 30+	srv := server.New(svc, cfg.Token)
 31+	fmt.Printf("jp-server: listening on %s\n", cfg.Listen)
 32+	if err := http.ListenAndServe(cfg.Listen, srv.Handler()); err != nil {
 33+		fatalf("%v", err)
 34+	}
 35+}
 36+
 37+func fatalf(format string, args ...any) {
 38+	fmt.Fprintf(os.Stderr, "jp-server: "+format+"\n", args...)
 39+	os.Exit(1)
 40+}
 41diff --git a/internal/adapters/flashcards.go b/internal/adapters/flashcards.go
 42index e82a85d22a66835e2350881f3acbfac0e66ee46a..5a44d55ec92f01290fa54c50e827f8a121408b8c 100644
 43--- a/internal/adapters/flashcards.go
 44+++ b/internal/adapters/flashcards.go
 45@@ -5,6 +5,7 @@ import (
 46 	"encoding/csv"
 47 	"fmt"
 48 	"os"
 49+	"sync"
 50 
 51 	"japanese/internal/llm"
 52 )
 53@@ -41,9 +42,16 @@ func (m *FlashcardModel) Generate(ctx context.Context, instructions string) (int
 54 	return len(rows), nil
 55 }
 56 
 57+// flashcardsMu serializes deck appends so concurrent sessions cannot
 58+// double-write the header or interleave rows.
 59+var flashcardsMu sync.Mutex
 60+
 61 // appendFlashcards appends valid rows to the deck file, creating it with the
 62 // header when it does not exist yet. Existing rows are never rewritten.
 63 func appendFlashcards(path string, rows []llm.FlashcardRow) error {
 64+	flashcardsMu.Lock()
 65+	defer flashcardsMu.Unlock()
 66+
 67 	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
 68 	if err != nil {
 69 		return fmt.Errorf("open flashcards: %w", err)
 70diff --git a/internal/config/config.go b/internal/config/config.go
 71index fffa9fc641428616f776a4047684c17461091c59..df40b5ec2548177bb1614d5bbf9f03d3c8dccb0f 100644
 72--- a/internal/config/config.go
 73+++ b/internal/config/config.go
 74@@ -30,18 +30,37 @@ type Config struct {
 75 	Scenario string `long:"scenario" env:"JP_SCENARIO_PATH" default:"assets/scenarios/small_city.md" description:"path to the scenario brief file"`
 76 }
 77 
 78+// ServerConfig is the jp-server option group: the shared Config plus the HTTP
 79+// listen address and bearer token.
 80+type ServerConfig struct {
 81+	Config
 82+
 83+	Listen string `long:"listen" env:"JP_SERVER_LISTEN" default:"127.0.0.1:8081" description:"address to listen on"`
 84+	Token  string `long:"token" env:"JP_SERVER_TOKEN" description:"bearer token required on all requests"`
 85+}
 86+
 87 func ParseConfig() *Config {
 88 	var cfg Config
 89-	parser := flags.NewParser(&cfg, flags.HelpFlag)
 90+	parse("jp", &cfg)
 91+	return &cfg
 92+}
 93+
 94+// ParseServerConfig parses the jp-server command line into a ServerConfig.
 95+func ParseServerConfig() *ServerConfig {
 96+	var cfg ServerConfig
 97+	parse("jp-server", &cfg)
 98+	return &cfg
 99+}
100+
101+func parse(prog string, target any) {
102+	parser := flags.NewParser(target, flags.HelpFlag)
103 	if _, err := parser.ParseArgs(os.Args[1:]); err != nil {
104 		if e, ok := err.(*flags.Error); ok && e.Type == flags.ErrHelp {
105 			parser.WriteHelp(os.Stdout)
106 			os.Exit(0)
107 		}
108-		fmt.Fprintf(os.Stderr, "jp: %v\n", err)
109+		fmt.Fprintf(os.Stderr, prog+": %v\n", err)
110 		parser.WriteHelp(os.Stderr)
111 		os.Exit(1)
112 	}
113-
114-	return &cfg
115 }
116diff --git a/internal/server/handlers.go b/internal/server/handlers.go
117new file mode 100644
118index 0000000000000000000000000000000000000000..08888a61e01927f91e45292904f6d560d66a3c6b
119--- /dev/null
120+++ b/internal/server/handlers.go
121@@ -0,0 +1,214 @@
122+package server
123+
124+import (
125+	"context"
126+	"encoding/base64"
127+	"encoding/json"
128+	"errors"
129+	"io"
130+	"net/http"
131+	"strings"
132+
133+	"japanese/internal/game"
134+)
135+
136+type judgeWire struct {
137+	Score    int    `json:"score"`
138+	Romaji   string `json:"romaji,omitempty"`
139+	Feedback string `json:"feedback,omitempty"`
140+}
141+
142+type askWire struct {
143+	Romaji      string `json:"romaji,omitempty"`
144+	Translation string `json:"translation,omitempty"`
145+	Breakdown   string `json:"breakdown,omitempty"`
146+}
147+
148+// logEntry is one display entry on the wire. Kana never crosses the protocol.
149+type logEntry struct {
150+	Action    string     `json:"action,omitempty"`
151+	Desc      string     `json:"desc,omitempty"`
152+	Romaji    string     `json:"romaji,omitempty"`
153+	English   string     `json:"english,omitempty"`
154+	HasSpeech bool       `json:"hasSpeech"`
155+	Judge     *judgeWire `json:"judge,omitempty"`
156+	Ask       *askWire   `json:"ask,omitempty"`
157+}
158+
159+func toLogEntry(e game.DisplayEntry) logEntry {
160+	out := logEntry{
161+		Action:    e.Action,
162+		Desc:      e.Desc,
163+		Romaji:    e.Romaji,
164+		English:   e.English,
165+		HasSpeech: e.HasSpeech,
166+	}
167+	if e.HasJudge {
168+		out.Judge = &judgeWire{Score: e.Score, Romaji: e.PlayerRomaji, Feedback: e.Feedback}
169+	}
170+	if e.HasAsk {
171+		out.Ask = &askWire{Romaji: e.AskRomaji, Translation: e.AskTranslation, Breakdown: e.AskBreakdown}
172+	}
173+	return out
174+}
175+
176+type turnResponse struct {
177+	logEntry
178+	Location   string `json:"location,omitempty"`
179+	Talk       string `json:"talk,omitempty"`
180+	Audio      string `json:"audio,omitempty"`
181+	SpeakError string `json:"speakError,omitempty"`
182+	JudgeError string `json:"judgeError,omitempty"`
183+}
184+
185+type flashcardResponse struct {
186+	Added int `json:"added"`
187+}
188+
189+type errorWire struct {
190+	Error string `json:"error"`
191+}
192+
193+func writeJSON(w http.ResponseWriter, status int, v any) {
194+	w.Header().Set("Content-Type", "application/json")
195+	w.WriteHeader(status)
196+	_ = json.NewEncoder(w).Encode(v)
197+}
198+
199+func writeError(w http.ResponseWriter, status int, msg string) {
200+	writeJSON(w, status, errorWire{Error: msg})
201+}
202+
203+func (s *Server) createSession(w http.ResponseWriter, r *http.Request) {
204+	sess := s.newSession()
205+	s.mu.Lock()
206+	s.sessions[sess.id] = sess
207+	s.mu.Unlock()
208+	writeJSON(w, http.StatusOK, map[string]string{"id": sess.id})
209+}
210+
211+func (s *Server) getLog(w http.ResponseWriter, r *http.Request) {
212+	sess := s.session(r.PathValue("id"))
213+	if sess == nil {
214+		writeError(w, http.StatusNotFound, "unknown session")
215+		return
216+	}
217+
218+	sess.mu.Lock()
219+	entries := sess.state.Display()
220+	sess.mu.Unlock()
221diff --git a/internal/server/server.go b/internal/server/server.go
222new file mode 100644
223index 0000000000000000000000000000000000000000..f63e3b05d1d9fa552054cc84394999eb1c16b921
224--- /dev/null
225+++ b/internal/server/server.go
226@@ -0,0 +1,103 @@
227+// Package server exposes jp game sessions over HTTP for a thin client.
228+// Sessions are in-memory and die with the process; turns serialize per
229+// session. Kana never crosses the wire.
230+package server
231+
232+import (
233+	"crypto/hmac"
234+	"crypto/rand"
235+	"encoding/hex"
236+	"net/http"
237+	"strings"
238+	"sync"
239+	"time"
240+
241+	"japanese/internal/adapters"
242+	"japanese/internal/bootstrap"
243+	"japanese/internal/game"
244+	"japanese/internal/stt"
245+	"japanese/internal/tts"
246+)
247+
248+const (
249+	turnTimeout = 3 * time.Minute
250+	maxUpload   = int64(2 << 20)
251+)
252+
253+// Server serves jp game sessions over HTTP.
254+type Server struct {
255+	svc   *bootstrap.Services
256+	token []byte
257+
258+	mu       sync.Mutex
259+	sessions map[string]*session
260+}
261+
262+// New builds a server over the shared service clients.
263+func New(svc *bootstrap.Services, token string) *Server {
264+	return &Server{svc: svc, token: []byte(token), sessions: make(map[string]*session)}
265+}
266+
267+// Handler returns the HTTP handler with bearer-token auth applied.
268+func (s *Server) Handler() http.Handler {
269+	mux := http.NewServeMux()
270+	mux.HandleFunc("POST /v1/sessions", s.createSession)
271+	mux.HandleFunc("GET /v1/sessions/{id}/log", s.getLog)
272+	mux.HandleFunc("POST /v1/sessions/{id}/actions", s.postAction)
273+	mux.HandleFunc("POST /v1/sessions/{id}/speech", s.postSpeech)
274+	return auth(s.token, mux)
275+}
276+
277+func auth(token []byte, next http.Handler) http.Handler {
278+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
279+		got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
280+		if !hmac.Equal([]byte(got), token) {
281+			writeError(w, http.StatusUnauthorized, "unauthorized")
282+			return
283+		}
284+		next.ServeHTTP(w, r)
285+	})
286+}
287+
288+// session is one in-memory game: a fresh state and orchestrator over the
289+// shared service clients. The mutex serializes that session's turns.
290+type session struct {
291+	id string
292+
293+	mu     sync.Mutex
294+	state  *game.State
295+	orch   *game.Orchestrator
296+	input  *remoteInput
297+	output *bufferedOutput
298+}
299+
300+func (s *Server) newSession() *session {
301+	var idb [16]byte
302+	if _, err := rand.Read(idb[:]); err != nil {
303+		panic(err)
304+	}
305+	id := hex.EncodeToString(idb[:])
306+
307+	state := game.NewState(s.svc.Brief)
308+	speechIn := &remoteInput{asr: stt.NewASRClient(s.svc.Cfg.AudioConfig.AudioBaseURL, s.svc.HTTP)}
309+	speechOut := &bufferedOutput{client: tts.NewClient(s.svc.Cfg.AudioConfig.AudioBaseURL, s.svc.HTTP)}
310+	orch := game.NewOrchestrator(
311+		state,
312+		&adapters.GameModel{Client: s.svc.Game},
313+		&adapters.JudgeModel{Client: s.svc.Judge},
314+		&adapters.Compactor{Client: s.svc.Compaction},
315+		&adapters.SheetModel{Client: s.svc.Sheet},
316+		&adapters.ScratchModel{Client: s.svc.Scratch},
317+		&adapters.FlashcardModel{Client: s.svc.Scratch},
318+		speechIn,
319+		speechOut,
320+	)
321+
322+	return &session{id: id, state: state, orch: orch, input: speechIn, output: speechOut}
323+}
324+
325+func (s *Server) session(id string) *session {
326diff --git a/internal/server/speech.go b/internal/server/speech.go
327new file mode 100644
328index 0000000000000000000000000000000000000000..da6d2b66889dd43d342e7618d64425f069d84c98
329--- /dev/null
330+++ b/internal/server/speech.go
331@@ -0,0 +1,77 @@
332+package server
333+
334+import (
335+	"context"
336+	"errors"
337+	"sync"
338+
339+	"japanese/internal/stt"
340+	"japanese/internal/tts"
341+)
342+
343+// remoteInput adapts the uploaded WAV of one spoken turn to game.SpeechInput.
344+// Begin is a no-op; End transcribes the bytes stashed with SetWAV.
345+type remoteInput struct {
346+	asr *stt.ASRClient
347+
348+	mu  sync.Mutex
349+	wav []byte
350+}
351+
352+func (r *remoteInput) Begin(ctx context.Context) error { return nil }
353+
354+// SetWAV stashes the WAV uploaded for this turn.
355+func (r *remoteInput) SetWAV(wav []byte) {
356+	r.mu.Lock()
357+	r.wav = wav
358+	r.mu.Unlock()
359+}
360+
361+func (r *remoteInput) End(ctx context.Context) (string, error) {
362+	r.mu.Lock()
363+	wav := r.wav
364+	r.wav = nil
365+	r.mu.Unlock()
366+	if len(wav) == 0 {
367+		return "", errors.New("speech: no audio uploaded")
368+	}
369+	raw, err := r.asr.TranscribeBytes(ctx, wav)
370+	if err != nil {
371+		return "", err
372+	}
373+	return raw, nil
374+}
375+
376+// bufferedOutput adapts the TTS client to game.SpeechOutput without playback:
377+// Speak synthesizes into memory and TakeLast drains the last clip.
378+type bufferedOutput struct {
379+	client *tts.Client
380+
381+	mu   sync.Mutex
382+	last []byte
383+}
384+
385+func (b *bufferedOutput) Speak(ctx context.Context, kana string) error {
386+	wav, err := b.client.Speech(ctx, kana)
387+	if err != nil {
388+		return err
389+	}
390+	b.mu.Lock()
391+	cp := make([]byte, len(wav))
392+	copy(cp, wav)
393+	b.last = cp
394+	b.mu.Unlock()
395+	return nil
396+}
397+
398+// TakeLast returns the last synthesized clip and clears it.
399+func (b *bufferedOutput) TakeLast() []byte {
400+	b.mu.Lock()
401+	wav := b.last
402+	b.last = nil
403+	b.mu.Unlock()
404+	return wav
405+}
406+
407+// Replay is a no-op: replay is client-side on iOS.
408+func (b *bufferedOutput) Replay() error { return nil }