da2d333f3a3f2a93438127dae5856e82d45a4f95
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
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 {