Parent directory

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}