contract.go
7781 bytes
1package llm
2
3import (
4 "strconv"
5 "strings"
6)
7
8type ContractErrorKind int
9
10const (
11 MissingField ContractErrorKind = iota
12 DuplicateField
13 EmptyValue
14 InvalidFormat
15 OutOfRange
16 PartialTriple
17)
18
19// ContractError is a recoverable, inspectable failure to parse a model output
20// contract. Callers can branch on Kind and Field instead of panicking.
21type ContractError struct {
22 Kind ContractErrorKind
23 Field string
24}
25
26func (e *ContractError) Error() string {
27 return "llm: bad contract field " + e.Field + ": " + kindString(e.Kind)
28}
29
30func kindString(k ContractErrorKind) string {
31 switch k {
32 case MissingField:
33 return "missing"
34 case DuplicateField:
35 return "duplicate"
36 case EmptyValue:
37 return "empty value"
38 case InvalidFormat:
39 return "invalid format"
40 case OutOfRange:
41 return "out of range"
42 case PartialTriple:
43 return "partial spoken triple"
44 default:
45 return "unknown"
46 }
47}
48
49var (
50 judgeFieldNames = []string{"SCORE", "ROMAJI", "FEEDBACK"}
51 gameFieldNames = []string{"LOCATION", "TALK", "DESC", "ROMAJI", "KANA", "ENGLISH"}
52 sheetFieldNames = []string{"ROLE", "GENDER", "TRAITS", "MOOD", "STYLE", "UNCLEAR", "SAMPLE"}
53 scratchFieldNames = []string{"ROMAJI", "TRANSLATION", "BREAKDOWN"}
54)
55
56// parseFields splits a raw model reply into FIELD|value lines. Surrounding
57// whitespace is tolerated and blank lines are skipped. A trailing pipe on a
58// value is dropped: the model sometimes glues the empty next field's pipe onto
59// the previous line. A line without a pipe, an unknown field name, or a
60// repeated field is a recoverable error.
61func parseFields(raw string, names []string) (map[string]string, error) {
62 valid := make(map[string]bool, len(names))
63 for _, n := range names {
64 valid[n] = true
65 }
66 out := make(map[string]string, len(names))
67 for _, line := range strings.Split(raw, "\n") {
68 trimmed := strings.TrimSpace(line)
69 if trimmed == "" {
70 continue
71 }
72 name, value, ok := strings.Cut(trimmed, "|")
73 if !ok {
74 return nil, &ContractError{Kind: InvalidFormat, Field: trimmed}
75 }
76 name = strings.TrimSpace(name)
77 value = strings.TrimSuffix(strings.TrimSpace(value), "|")
78 if !valid[name] {
79 return nil, &ContractError{Kind: InvalidFormat, Field: name}
80 }
81 if _, dup := out[name]; dup {
82 return nil, &ContractError{Kind: DuplicateField, Field: name}
83 }
84 out[name] = value
85 }
86 return out, nil
87}
88
89func requireFields(vals map[string]string, names []string) error {
90 for _, name := range names {
91 v, ok := vals[name]
92 if !ok {
93 return &ContractError{Kind: MissingField, Field: name}
94 }
95 if v == "" {
96 return &ContractError{Kind: EmptyValue, Field: name}
97 }
98 }
99 return nil
100}
101
102type JudgeResult struct {
103 Score int
104 Romaji string
105 Feedback string
106}
107
108// ParseJudge parses the three-field judge contract. The score must be an integer
109// in 0-10; anything else is a recoverable *ContractError. Feedback must be
110// non-empty below 10 and is discarded at 10.
111func ParseJudge(raw string) (JudgeResult, error) {
112 vals, err := parseFields(raw, judgeFieldNames)
113 if err != nil {
114 return JudgeResult{}, err
115 }
116 if err := requireFields(vals, []string{"SCORE", "ROMAJI"}); err != nil {
117 return JudgeResult{}, err
118 }
119 if _, ok := vals["FEEDBACK"]; !ok {
120 return JudgeResult{}, &ContractError{Kind: MissingField, Field: "FEEDBACK"}
121 }
122 score, perr := strconv.Atoi(vals["SCORE"])
123 if perr != nil {
124 return JudgeResult{}, &ContractError{Kind: InvalidFormat, Field: "SCORE"}
125 }
126 if score < 0 || score > 10 {
127 return JudgeResult{}, &ContractError{Kind: OutOfRange, Field: "SCORE"}
128 }
129 feedback := vals["FEEDBACK"]
130 if score == 10 {
131 feedback = ""
132 } else if feedback == "" {
133 return JudgeResult{}, &ContractError{Kind: EmptyValue, Field: "FEEDBACK"}
134 }
135 return JudgeResult{Score: score, Romaji: vals["ROMAJI"], Feedback: feedback}, nil
136}
137
138type GameReply struct {
139 Location string // stable id of the player's current place, always present
140 Talk string // who you are speaking to (role or appearance), or "none"; always present
141 Desc string // English narration; may be empty
142 Romaji string // NPC speech in romaji; part of the spoken triple
143 Kana string // same sentence in kana; part of the spoken triple
144 English string // natural English translation; part of the spoken triple
145}
146
147// ParseGameReply parses the unified game reply contract. LOCATION and TALK are
148// required and must be non-empty. The spoken triple (ROMAJI, KANA, ENGLISH) is
149// all-or-nothing by presence: either none of the three keys appear or all three
150// do, though their values may be empty strings. DESC is optional and defaults to
151// "". A malformed reply is a recoverable *ContractError.
152func ParseGameReply(raw string) (GameReply, error) {
153 vals, err := parseFields(raw, gameFieldNames)
154 if err != nil {
155 return GameReply{}, err
156 }
157 for _, name := range []string{"LOCATION", "TALK"} {
158 v, ok := vals[name]
159 if !ok {
160 return GameReply{}, &ContractError{Kind: MissingField, Field: name}
161 }
162 if v == "" {
163 return GameReply{}, &ContractError{Kind: EmptyValue, Field: name}
164 }
165 }
166 present := 0
167 for _, name := range []string{"ROMAJI", "KANA", "ENGLISH"} {
168 if _, ok := vals[name]; ok {
169 present++
170 }
171 }
172 if present == 1 || present == 2 {
173 return GameReply{}, &ContractError{Kind: PartialTriple, Field: "spoken"}
174 }
175 return GameReply{
176 Location: vals["LOCATION"],
177 Talk: vals["TALK"],
178 Desc: vals["DESC"],
179 Romaji: vals["ROMAJI"],
180 Kana: vals["KANA"],
181 English: vals["ENGLISH"],
182 }, nil
183}
184
185type ScratchAnswer struct {
186 Romaji string
187 Translation string
188 Breakdown string
189}
190
191// ParseScratch parses the three-field scratch contract. All three fields are
192// required and non-empty; a malformed reply is a recoverable *ContractError.
193func ParseScratch(raw string) (ScratchAnswer, error) {
194 vals, err := parseFields(raw, scratchFieldNames)
195 if err != nil {
196 return ScratchAnswer{}, err
197 }
198 if err := requireFields(vals, scratchFieldNames); err != nil {
199 return ScratchAnswer{}, err
200 }
201 return ScratchAnswer{Romaji: vals["ROMAJI"], Translation: vals["TRANSLATION"], Breakdown: vals["BREAKDOWN"]}, nil
202}
203
204// Sheet is one person's compact character profile, parsed from the sheet
205// contract. All seven fields are required and non-empty.
206type Sheet struct {
207 Role string // role, age range, one identifying appearance phrase
208 Gender string // normalized "male" or "female"
209 Traits string // two to three personality traits
210 Mood string // mood today plus what they want or need right now
211 Style string // communication style: sentence length, formality, energy
212 Unclear string // how they handle unclear input: confirm, press on, impatient
213 Sample string // one short sample line in their voice, romaji
214}
215
216// ParseSheet parses the seven-field character-sheet contract. All seven fields
217// are required and non-empty; GENDER must be male or female. A malformed reply
218// is a recoverable *ContractError.
219func ParseSheet(raw string) (Sheet, error) {
220 vals, err := parseFields(raw, sheetFieldNames)
221 if err != nil {
222 return Sheet{}, err
223 }
224 if err := requireFields(vals, sheetFieldNames); err != nil {
225 return Sheet{}, err
226 }
227 gender := strings.ToLower(strings.TrimSpace(vals["GENDER"]))
228 if gender != "male" && gender != "female" {
229 return Sheet{}, &ContractError{Kind: InvalidFormat, Field: "GENDER"}
230 }
231 return Sheet{
232 Role: vals["ROLE"],
233 Gender: gender,
234 Traits: vals["TRAITS"],
235 Mood: vals["MOOD"],
236 Style: vals["STYLE"],
237 Unclear: vals["UNCLEAR"],
238 Sample: vals["SAMPLE"],
239 }, nil
240}
241
242// Text renders the sheet as one space-joined line of its six fields, in order.
243func (s Sheet) Text() string {
244 return "ROLE|" + s.Role +
245 " GENDER|" + s.Gender +
246 " TRAITS|" + s.Traits +
247 " MOOD|" + s.Mood +
248 " STYLE|" + s.Style +
249 " UNCLEAR|" + s.Unclear +
250 " SAMPLE|" + s.Sample
251}