da2d333f3a3f2a93438127dae5856e82d45a4f95

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

Message

cmd: implement persona gen authoring helper

Diff

This diff is truncated to protect this page.

  1diff --git a/cmd/jp/main.go b/cmd/jp/main.go
  2index e91d23991a6f9ef3c6edaa1786d655f2ac57a5e5..d158006f50bc8adb70fbdd78256cde434c693551 100644
  3--- a/cmd/jp/main.go
  4+++ b/cmd/jp/main.go
  5@@ -3,6 +3,7 @@ package main
  6 
  7 import (
  8 	"context"
  9+	"encoding/json"
 10 	"flag"
 11 	"fmt"
 12 	"io"
 13@@ -11,6 +12,8 @@ import (
 14 
 15 	"japanese/internal/availability"
 16 	"japanese/internal/config"
 17+	"japanese/internal/llm"
 18+	"japanese/internal/persona"
 19 )
 20 
 21 func main() {
 22@@ -68,7 +71,24 @@ func genPersonaCmd(args []string) {
 23 		fmt.Fprintln(os.Stderr, "jp persona gen: --role is required")
 24 		os.Exit(1)
 25 	}
 26-	fmt.Fprintf(os.Stderr, "jp persona gen %q: not implemented yet\n", *role)
 27+
 28+	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
 29+	defer cancel()
 30+	client := llm.NewClient(cfg.LLMBaseURL, nil)
 31+	draft, err := persona.GenerateDraft(ctx, client, *role)
 32+	if err != nil {
 33+		fmt.Fprintf(os.Stderr, "persona gen: LLM service at %s unavailable: %v\n", cfg.LLMBaseURL, err)
 34+		os.Exit(1)
 35+	}
 36+
 37+	out, merr := json.MarshalIndent(draft, "", "  ")
 38+	if merr != nil {
 39+		fmt.Fprintf(os.Stderr, "persona gen: encode draft: %v\n", merr)
 40+		os.Exit(1)
 41+	}
 42+	fmt.Fprintln(os.Stdout, string(out))
 43+	fmt.Fprintln(os.Stderr, "DRAFT persona. Requires human review before use.")
 44+	fmt.Fprintln(os.Stderr, "Nothing was written to assets/personas/. Review it and save it yourself.")
 45 }
 46 
 47 func usage(w io.Writer) {
 48@@ -76,7 +96,7 @@ func usage(w io.Writer) {
 49 
 50 Usage:
 51   jp run [flags]           Run the game (not implemented yet)
 52-  jp persona gen --role R  Author a persona draft (not implemented yet)
 53+  jp persona gen --role R  Author a persona draft
 54   jp help                  Show this help
 55 
 56 Run "jp run -help" or "jp persona gen -help" for flag details.
 57diff --git a/internal/llm/contract.go b/internal/llm/contract.go
 58index 3320a38b3f7d0c80ffa61704e15321f0342f1719..2602a1deff731826c0ca7a28b9b7a65d721bdb48 100644
 59--- a/internal/llm/contract.go
 60+++ b/internal/llm/contract.go
 61@@ -92,6 +92,13 @@ func requireFields(vals map[string]string, names []string) error {
 62 	return nil
 63 }
 64 
 65+// ParseFields is the shared strict FIELD|value parser for model output
 66+// contracts. It tolerates surrounding whitespace and returns a recoverable
 67+// *ContractError on malformed lines, unknown fields, or duplicate fields.
 68+func ParseFields(raw string, names ...string) (map[string]string, error) {
 69+	return parseFields(raw, names)
 70+}
 71+
 72 type NPCReply struct {
 73 	Romaji  string
 74 	Kana    string
 75diff --git a/internal/persona/draft.go b/internal/persona/draft.go
 76new file mode 100644
 77index 0000000000000000000000000000000000000000..fc9783a52b2189da3bf7967584024c878b8d3295
 78--- /dev/null
 79+++ b/internal/persona/draft.go
 80@@ -0,0 +1,71 @@
 81+package persona
 82+
 83+import (
 84+	"context"
 85+
 86+	"japanese/internal/llm"
 87+)
 88+
 89+// PersonaDraft is an LLM-authored, not-yet-reviewed persona. It carries only
 90+// the fields the model can author; location, situation, and ambiguity are
 91+// filled in by a human during review before it becomes a stored Persona.
 92+type PersonaDraft struct {
 93+	Name        string `json:"name"`
 94+	Personality string `json:"personality"`
 95+	SpeechStyle string `json:"speech_style"`
 96+}
 97+
 98+const draftSystem = `You write one person's profile for use in a realistic everyday conversation.
 99+Return exactly three lines and nothing else, in this order:
100+NAME|<a natural name for the person>
101+PERSONALITY|<two to four personality traits separated by commas>
102+SPEECH_STYLE|<one sentence describing how they speak>`
103+
104+// DraftSystemPrompt returns the game-free system prompt for persona authoring.
105+// It names no game, player, NPC, quest, or scenario.
106+func DraftSystemPrompt() string { return draftSystem }
107+
108+// DraftUserPrompt returns the user prompt that supplies the role to author.
109+func DraftUserPrompt(role string) string {
110+	return "Write the profile of a real person whose role is: " + role + ". They are in an ordinary, real-world situation."
111+}
112+
113+var draftFieldNames = []string{"NAME", "PERSONALITY", "SPEECH_STYLE"}
114+
115+// GenerateDraft asks the configured LLM client for a persona draft for the
116+// given role and parses the strict three-field result. It issues exactly one
117+// chat request; it never starts, probes, or manages any service.
118+func GenerateDraft(ctx context.Context, client *llm.Client, role string) (PersonaDraft, error) {
119+	msgs := []llm.Message{
120+		{Role: llm.RoleSystem, Content: DraftSystemPrompt()},
121+		{Role: llm.RoleUser, Content: DraftUserPrompt(role)},
122+	}
123+	raw, err := client.Generate(ctx, msgs, llm.Qwen3Options())
124+	if err != nil {
125+		return PersonaDraft{}, err
126+	}
127+	return ParseDraft(raw)
128+}
129+
130+// ParseDraft parses the strict three-field persona draft contract. Missing or
131+// empty fields and malformed lines are recoverable *llm.ContractError values.
132+func ParseDraft(raw string) (PersonaDraft, error) {
133+	vals, err := llm.ParseFields(raw, draftFieldNames...)
134+	if err != nil {
135+		return PersonaDraft{}, err
136+	}
137+	for _, name := range draftFieldNames {
138+		v, ok := vals[name]
139+		if !ok {
140+			return PersonaDraft{}, &llm.ContractError{Kind: llm.MissingField, Field: name}
141+		}
142+		if v == "" {
143+			return PersonaDraft{}, &llm.ContractError{Kind: llm.EmptyValue, Field: name}
144+		}
145+	}
146+	return PersonaDraft{
147+		Name:        vals["NAME"],
148+		Personality: vals["PERSONALITY"],
149+		SpeechStyle: vals["SPEECH_STYLE"],
150+	}, nil
151+}
152diff --git a/internal/persona/draft_test.go b/internal/persona/draft_test.go
153new file mode 100644
154index 0000000000000000000000000000000000000000..2c86a608582f64fcb44042939903c02bcb92485d
155--- /dev/null
156+++ b/internal/persona/draft_test.go
157@@ -0,0 +1,120 @@
158+package persona
159+
160+import (
161+	"context"
162+	"encoding/json"
163+	"errors"
164+	"fmt"
165+	"net/http"
166+	"net/http/httptest"
167+	"strings"
168+	"testing"
169+
170+	"japanese/internal/llm"
171+)
172+
173+func TestParseDraftSuccess(t *testing.T) {
174+	raw := "NAME|Kenji\nPERSONALITY|calm, careful\nSPEECH_STYLE|short plain sentences"
175+	got, err := ParseDraft(raw)
176+	if err != nil {
177+		t.Fatalf("ParseDraft: %v", err)
178+	}
179+	if got.Name != "Kenji" || got.Personality != "calm, careful" || got.SpeechStyle != "short plain sentences" {
180+		t.Errorf("unexpected draft: %+v", got)
181+	}
182+}
183+
184+func TestParseDraftFailure(t *testing.T) {
185+	cases := map[string]string{
186+		"missing field":   "NAME|Kenji\nSPEECH_STYLE|x",
187+		"empty field":     "NAME|\nPERSONALITY|p\nSPEECH_STYLE|x",
188+		"malformed line":  "NAME Kenji\nPERSONALITY|p\nSPEECH_STYLE|x",
189+		"duplicate field": "NAME|a\nNAME|b\nPERSONALITY|p\nSPEECH_STYLE|x",
190+	}
191+	for name, raw := range cases {
192+		if _, err := ParseDraft(raw); err == nil {
193+			t.Errorf("%s: expected error", name)
194+		} else {
195+			var ce *llm.ContractError
196+			if !errors.As(err, &ce) {
197+				t.Errorf("%s: expected *llm.ContractError, got %T (%v)", name, err, err)
198+			}
199+		}
200+	}
201+}
202+
203+func TestDraftPromptHasNoGameContext(t *testing.T) {
204+	for _, s := range []string{
205+		DraftSystemPrompt(),
206+		DraftUserPrompt("a ramen shop cook"),
207+		DraftUserPrompt("a train station ticket clerk"),
208+	} {
209+		if gameContextWord.MatchString(s) {
210+			t.Errorf("prompt mentions game context: %q", s)
211+		}
212+	}
213+}
214+
215+func TestGenerateDraftRoundTrip(t *testing.T) {
216+	var gotBody []byte
217+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
218+		buf := make([]byte, 4096)
219+		n, _ := r.Body.Read(buf)
220+		gotBody = buf[:n]
221+		w.Header().Set("Content-Type", "text/event-stream")
222+		fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"NAME|Kenji\\n\"}}]}\n\n")
223+		fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"PERSONALITY|calm, careful\\n\"}}]}\n\n")
224+		fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"SPEECH_STYLE|short plain sentences\"}}]}\n\n")
225+		fmt.Fprint(w, "data: [DONE]\n\n")
226+	}))
227+	defer srv.Close()
228+
229+	client := llm.NewClient(srv.URL, srv.Client())
230+	got, err := GenerateDraft(context.Background(), client, "a ramen shop cook")
231+	if err != nil {
232+		t.Fatalf("GenerateDraft: %v", err)
233+	}
234+	if got.Name != "Kenji" || got.Personality != "calm, careful" || got.SpeechStyle != "short plain sentences" {
235+		t.Errorf("unexpected draft: %+v", got)
236+	}
237+
238+	var req struct {
239+		Model              string         `json:"model"`
240+		ChatTemplateKwargs map[string]any `json:"chat_template_kwargs"`
241+		Messages           []struct {
242+			Role    string `json:"role"`
243+			Content string `json:"content"`
244+		} `json:"messages"`
245+	}
246+	if err := json.Unmarshal(gotBody, &req); err != nil {
247+		t.Fatalf("request not JSON: %v", err)
248+	}
249+	if req.Model != "jp" {
250+		t.Errorf("model = %q, want jp", req.Model)
251+	}
252+	if req.ChatTemplateKwargs["enable_thinking"] != false {
253+		t.Errorf("enable_thinking should be false")
254+	}
255+	var joined strings.Builder
256+	for _, m := range req.Messages {