af1eb27917b973b0d988b7d9f14d8ed58d08a407

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

Message

Add logging and update AGENTS.md

Diff

  1diff --git a/AGENTS.md b/AGENTS.md
  2index 11023772477a0d8d92168657988e7e2e1bfb1ed1..20b6fffba42db0b9dc55437fd343b5c61454b0ae 100644
  3--- a/AGENTS.md
  4+++ b/AGENTS.md
  5@@ -1,17 +1,28 @@
  6-# jp — Japanese learning RPG (TUI)
  7+# jp — Japanese learning RPG (TUI + HTTP server)
  8 
  9 ## Overview
 10 
 11-A terminal game for practicing spoken Japanese. The UI is an adventure log:
 12+A game for practicing spoken Japanese. The play loop is an adventure log:
 13 the player types English actions to explore an LLM-narrated world, speaks
 14 Japanese to people at locations through push-to-talk (each spoken line gets
 15 separate fluency feedback from a judge), and can ask scratch questions with
 16 `?question` for how to say something in Japanese. New conversation partners get
 17 a hidden character sheet generated from their introducing reply so they stay
 18-consistent across visits. The UI renders romaji only; kana and kanji are never
 19-shown.
 20+consistent across visits. Only romaji is ever shown to the player; kana and kanji stay internal.
 21 
 22-The app is an HTTP client for two externally managed model services:
 23+Two entry points share one game core (`internal/game`), the LLM layer
 24+(`internal/llm`), config, and bootstrap:
 25+
 26+- **`cmd/jp`** — a Bubble Tea TUI. Runs the game in-process: it builds its own
 27+  state + orchestrator with local adapters (mic capture via `arecord`, local
 28+  audio playback) and talks straight to the model services.
 29+- **`cmd/jp-server`** — an HTTP server exposing in-memory game sessions for a
 30+  thin external client (e.g. iOS). The client records and plays audio; the
 31+  server does ASR transcription, the game loop, the judge, and TTS synthesis
 32+  (returns the NPC line as WAV bytes; replay is client-side). Turns serialize
 33+  per session; sessions die with the process. All requests need a bearer token.
 34+
 35+Both entry points are HTTP clients for two externally managed model services:
 36 
 37 - **LLM** — llama.cpp router (OpenAI-compatible chat completions) for NPC
 38   dialogue, the judge, compaction, character sheets, and scratch questions.
 39@@ -21,8 +32,8 @@ The app is an HTTP client for two externally managed model services:
 40   transcript stays internal; it is what the LLM sees as the player's lines) and
 41   an OpenAI-compatible speech endpoint that plays the NPC's kana.
 42 
 43-Turn flow (spoken): record → transcribe → judge + NPC reply in parallel → play
 44-kana → record the turn in per-location history. Typed actions skip recording,
 45+Turn flow (spoken): record → transcribe → judge + NPC reply in parallel → synthesize the NPC's kana to audio → record
 46+the turn in per-location history. Typed actions skip recording,
 47 transcription, and the judge; scratch questions only touch the scratch model
 48 and never enter world state. Per-location history is compacted into summaries
 49 when it grows past budget (`internal/game/state.go`,
 50@@ -41,7 +52,7 @@ almost fully allocated to the chat model.
 51 
 52 ### Product invariants
 53 
 54-- Render romaji only; never show kana or kanji in the TUI.
 55+- Render romaji only; never expose kana or kanji to the player — not in the TUI and not across the jp-server wire.
 56 - NPCs behave as normal people, not language teachers. Grading stays separate
 57   from NPC dialogue; judge output never enters the NPC prompt.
 58 - Persona data contains no game, player, NPC, quest, or scenario context.
 59@@ -51,6 +62,9 @@ almost fully allocated to the chat model.
 60 
 61 - Small single-purpose packages under `internal/`; adapters implement the small
 62   interfaces defined at their point of use in `internal/game/orchestrator.go`.
 63+- Speech has two adapter pairs over the same interface: local (mic via
 64+  `arecord` + player) in `internal/adapters` for the TUI, and remote (uploaded
 65+  WAV in, returned WAV out) in `internal/server/speech.go` for jp-server.
 66 - LLM replies are parsed from strict `FIELD|value` contracts, never freeform
 67   JSON.
 68 - Errors wrap with `%w`. Startup failures print one stderr line naming the
 69diff --git a/cmd/jp-server/main.go b/cmd/jp-server/main.go
 70index 50b6bb4242eb521c8e874afd94ab3774cadcc957..e488c5fa7bb4477d2a70441e6011091863f410ee 100644
 71--- a/cmd/jp-server/main.go
 72+++ b/cmd/jp-server/main.go
 73@@ -2,6 +2,7 @@ package main
 74 
 75 import (
 76 	"fmt"
 77+	"log/slog"
 78 	"net/http"
 79 	"os"
 80 
 81@@ -11,6 +12,7 @@ import (
 82 )
 83 
 84 func main() {
 85+	slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil)))
 86 	cfg := config.ParseServerConfig()
 87 	if cfg.Token == "" {
 88 		fatalf("token is required: set --token or JP_SERVER_TOKEN")
 89diff --git a/internal/config/config.go b/internal/config/config.go
 90index df40b5ec2548177bb1614d5bbf9f03d3c8dccb0f..d09e76bc5d4e3ffb4fcf3230097a725556ab25a2 100644
 91--- a/internal/config/config.go
 92+++ b/internal/config/config.go
 93@@ -35,7 +35,7 @@ type Config struct {
 94 type ServerConfig struct {
 95 	Config
 96 
 97-	Listen string `long:"listen" env:"JP_SERVER_LISTEN" default:"127.0.0.1:8081" description:"address to listen on"`
 98+	Listen string `long:"listen" env:"JP_SERVER_LISTEN" default:"0.0.0.0:8081" description:"address to listen on"`
 99 	Token  string `long:"token" env:"JP_SERVER_TOKEN" description:"bearer token required on all requests"`
100 }
101 
102diff --git a/internal/server/handlers.go b/internal/server/handlers.go
103index 08888a61e01927f91e45292904f6d560d66a3c6b..b913a6150db64828f985b6b7eb2e03c7ad65c3f7 100644
104--- a/internal/server/handlers.go
105+++ b/internal/server/handlers.go
106@@ -6,6 +6,7 @@ import (
107 	"encoding/json"
108 	"errors"
109 	"io"
110+	"log/slog"
111 	"net/http"
112 	"strings"
113 
114@@ -144,6 +145,7 @@ func (s *Server) postAction(w http.ResponseWriter, r *http.Request) {
115 		}
116 		count, err := sess.orch.GenerateFlashcards(ctx, action)
117 		if err != nil {
118+			slog.Error("flashcards failed", "session", sess.id, "err", err)
119 			writeError(w, http.StatusBadGateway, err.Error())
120 			return
121 		}
122@@ -193,6 +195,7 @@ func finishTurn(w http.ResponseWriter, sess *session, res game.TurnResult) {
123 		if errors.Is(res.Err, game.ErrEmptyTranscript) {
124 			status = http.StatusBadRequest
125 		}
126+		slog.Error("turn failed", "session", sess.id, "err", res.Err)
127 		writeError(w, status, res.Err.Error())
128 		return
129 	}
130@@ -206,9 +209,11 @@ func finishTurn(w http.ResponseWriter, sess *session, res game.TurnResult) {
131 	}
132 	if res.SpeakErr != nil {
133 		out.SpeakError = res.SpeakErr.Error()
134+		slog.Warn("speak failed", "session", sess.id, "err", res.SpeakErr)
135 	}
136 	if res.JudgeErr != nil {
137 		out.JudgeError = res.JudgeErr.Error()
138+		slog.Warn("judge failed", "session", sess.id, "err", res.JudgeErr)
139 	}
140 	writeJSON(w, http.StatusOK, out)
141 }
142diff --git a/internal/server/server.go b/internal/server/server.go
143index f63e3b05d1d9fa552054cc84394999eb1c16b921..67bc2feb49b077c481dfbaadbddb5bcfba4cb174 100644
144--- a/internal/server/server.go
145+++ b/internal/server/server.go
146@@ -7,6 +7,7 @@ import (
147 	"crypto/hmac"
148 	"crypto/rand"
149 	"encoding/hex"
150+	"log/slog"
151 	"net/http"
152 	"strings"
153 	"sync"
154@@ -45,7 +46,7 @@ func (s *Server) Handler() http.Handler {
155 	mux.HandleFunc("GET /v1/sessions/{id}/log", s.getLog)
156 	mux.HandleFunc("POST /v1/sessions/{id}/actions", s.postAction)
157 	mux.HandleFunc("POST /v1/sessions/{id}/speech", s.postSpeech)
158-	return auth(s.token, mux)
159+	return logging(auth(s.token, mux))
160 }
161 
162 func auth(token []byte, next http.Handler) http.Handler {
163@@ -59,6 +60,52 @@ func auth(token []byte, next http.Handler) http.Handler {
164 	})
165 }
166 
167+// statusRecorder captures the final response status for request logging.
168+type statusRecorder struct {
169+	http.ResponseWriter
170+	status      int
171+	wroteHeader bool
172+}
173+
174+func (r *statusRecorder) WriteHeader(code int) {
175+	if !r.wroteHeader {
176+		r.status = code
177+		r.wroteHeader = true
178+	}
179+	r.ResponseWriter.WriteHeader(code)
180+}
181+
182+// logging logs every request with its final status and duration, and turns a
183+// handler panic into a logged 500 instead of a bare connection reset.
184+func logging(next http.Handler) http.Handler {
185+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
186+		start := time.Now()
187+		rec := &statusRecorder{ResponseWriter: w}
188+		defer func() {
189+			if p := recover(); p != nil {
190+				slog.Error("panic", "method", r.Method, "path", r.URL.Path, "err", p)
191+				if !rec.wroteHeader {
192+					writeError(rec, http.StatusInternalServerError, "internal error")
193+				}
194+			}
195+			status := rec.status
196+			if status == 0 {
197+				status = http.StatusOK
198+			}
199+			reqLog := slog.With("method", r.Method, "path", r.URL.Path, "status", status, "duration", time.Since(start))
200+			switch {
201+			case status >= 500:
202+				reqLog.Error("request")
203+			case status >= 400:
204+				reqLog.Warn("request")
205+			default:
206+				reqLog.Info("request")
207+			}
208+		}()
209+		next.ServeHTTP(rec, r)
210+	})
211+}
212+
213 // session is one in-memory game: a fresh state and orchestrator over the
214 // shared service clients. The mutex serializes that session's turns.
215 type session struct {