Skip to content

Commit 047641c

Browse files
Merge pull request #165 from flocko-motion/flo-dev
AI optimizations
2 parents 28b0704 + 3cbae17 commit 047641c

File tree

9 files changed

+147
-102
lines changed

9 files changed

+147
-102
lines changed

server/functional/strings.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,64 @@ func EnsurePointer(o any) any {
116116
return ptr.Interface()
117117
}
118118

119+
// EndsWithDigits checks if a dash-separated string ends with a segment of exactly n digits.
120+
// Useful for filtering dated/versioned model IDs like "gpt-4-0613" or "mistral-small-2402".
121+
func EndsWithDigits(s string, n int) bool {
122+
parts := SplitDash(s)
123+
if len(parts) < 2 {
124+
return false
125+
}
126+
last := parts[len(parts)-1]
127+
if len(last) != n {
128+
return false
129+
}
130+
for _, ch := range last {
131+
if ch < '0' || ch > '9' {
132+
return false
133+
}
134+
}
135+
return true
136+
}
137+
138+
// EndsWithDatePattern checks if a dash-separated string ends with a YYYY-MM-DD pattern.
139+
// Example: "gpt-4-2024-01-25" returns true.
140+
func EndsWithDatePattern(s string) bool {
141+
parts := SplitDash(s)
142+
if len(parts) < 4 {
143+
return false
144+
}
145+
lastThree := parts[len(parts)-3:]
146+
if len(lastThree[0]) != 4 || len(lastThree[1]) != 2 || len(lastThree[2]) != 2 {
147+
return false
148+
}
149+
for _, part := range lastThree {
150+
for _, ch := range part {
151+
if ch < '0' || ch > '9' {
152+
return false
153+
}
154+
}
155+
}
156+
return true
157+
}
158+
159+
// SplitDash splits a string by dashes. Convenience wrapper for model ID parsing.
160+
func SplitDash(s string) []string {
161+
return splitBy(s, '-')
162+
}
163+
164+
func splitBy(s string, sep byte) []string {
165+
var parts []string
166+
start := 0
167+
for i := 0; i < len(s); i++ {
168+
if s[i] == sep {
169+
parts = append(parts, s[start:i])
170+
start = i + 1
171+
}
172+
}
173+
parts = append(parts, s[start:])
174+
return parts
175+
}
176+
119177
// NormalizeYaml unmarshals YAML into the given struct type and re-marshals it to normalize the format.
120178
// If o is not a pointer, a pointer to a new instance of its type is created automatically.
121179
func NormalizeYaml(in string, o any) string {

server/game/ai/mistral/mistral.go

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -195,28 +195,6 @@ func (p *MistralPlatform) ListModels(ctx context.Context, apiKey string) ([]obj.
195195
return models, nil
196196
}
197197

198-
// endsWithFourDigits checks if a model ID ends with -XXXX pattern (4 digits)
199-
func endsWithFourDigits(modelID string) bool {
200-
parts := strings.Split(modelID, "-")
201-
if len(parts) < 2 {
202-
return false
203-
}
204-
205-
lastPart := parts[len(parts)-1]
206-
207-
// Check if last part is exactly 4 digits
208-
if len(lastPart) == 4 {
209-
for _, ch := range lastPart {
210-
if ch < '0' || ch > '9' {
211-
return false
212-
}
213-
}
214-
return true
215-
}
216-
217-
return false
218-
}
219-
220198
// isRelevantModel checks if a model supports chat completions
221199
func isRelevantModel(modelID string) bool {
222200
// List of known non-chat model prefixes to skip
@@ -229,13 +207,13 @@ func isRelevantModel(modelID string) bool {
229207
}
230208

231209
for _, prefix := range nonChatPrefixes {
232-
if len(modelID) > len(prefix) && modelID[:len(prefix)] == prefix {
210+
if strings.HasPrefix(modelID, prefix) {
233211
return false
234212
}
235213
}
236214

237215
// Skip dated models (ending with -XXXX where X is a digit)
238-
if endsWithFourDigits(modelID) {
216+
if functional.EndsWithDigits(modelID, 4) {
239217
return false
240218
}
241219

server/game/ai/openai/openai.go

Lines changed: 40 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -66,16 +66,23 @@ type ModelSession struct {
6666
ResponseID string `json:"responseId"`
6767
}
6868

69+
// InputMessage represents a single message in the Responses API input array.
70+
// Role can be "developer" (instructions/reminders) or "user" (player actions).
71+
type InputMessage struct {
72+
Role string `json:"role"`
73+
Content string `json:"content"`
74+
}
75+
6976
// ResponsesAPIRequest is the request body for the Responses API
7077
type ResponsesAPIRequest struct {
71-
Model string `json:"model"`
72-
Input string `json:"input"`
73-
Instructions string `json:"instructions,omitempty"`
74-
PreviousResponseID string `json:"previous_response_id,omitempty"`
75-
Store bool `json:"store"`
76-
Stream bool `json:"stream,omitempty"`
77-
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
78-
Text *TextConfig `json:"text,omitempty"`
78+
Model string `json:"model"`
79+
Input []InputMessage `json:"input"`
80+
Instructions string `json:"instructions,omitempty"`
81+
PreviousResponseID string `json:"previous_response_id,omitempty"`
82+
Store bool `json:"store"`
83+
Stream bool `json:"stream,omitempty"`
84+
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
85+
Text *TextConfig `json:"text,omitempty"`
7986
}
8087

8188
type TextConfig struct {
@@ -193,10 +200,10 @@ func (p *OpenAiPlatform) ExecuteAction(ctx context.Context, session *obj.GameSes
193200
// Serialize the player action as JSON input (minimal AI-facing structure)
194201
actionInput := action.ToAiJSON()
195202

196-
// Build the request
203+
// Build the request (Input is set below based on action type)
197204
req := ResponsesAPIRequest{
198205
Model: model,
199-
Input: actionInput,
206+
Input: []InputMessage{{Role: "user", Content: actionInput}},
200207
Store: true,
201208
MaxOutputTokens: 5000,
202209
Text: &TextConfig{
@@ -212,9 +219,17 @@ func (p *OpenAiPlatform) ExecuteAction(ctx context.Context, session *obj.GameSes
212219
// System messages become instructions, otherwise use previous_response_id for continuity
213220
if action.Type == obj.GameSessionMessageTypeSystem {
214221
req.Instructions = action.Message
215-
req.Input = templates.PromptMessageStart
222+
req.Input = []InputMessage{{Role: "developer", Content: templates.PromptMessageStart}}
216223
} else if modelSession.ResponseID != "" {
217224
req.PreviousResponseID = modelSession.ResponseID
225+
// Inject developer reminder with every player action to reinforce brevity
226+
req.Input = []InputMessage{
227+
{Role: "developer", Content: templates.ReminderExecuteAction},
228+
{Role: "user", Content: actionInput},
229+
}
230+
// Set debug prompt showing full input sent to the AI
231+
response.PromptStatusUpdate = functional.Ptr(
232+
"[developer] " + templates.ReminderExecuteAction + "\n\n[user] " + actionInput)
218233
}
219234

220235
responseStream := stream.Get().Lookup(response.ID)
@@ -273,21 +288,13 @@ func (p *OpenAiPlatform) ExecuteAction(ctx context.Context, session *obj.GameSes
273288

274289
response.ResponseRaw = &responseText
275290

276-
// Parse the AI response (uses flat status map) and convert to internal format
277-
log.Debug("parsing OpenAI response", "response_length", len(responseText), "response_text", responseText)
278-
var aiResp obj.GameSessionMessageAi
279-
if err := json.Unmarshal([]byte(responseText), &aiResp); err != nil {
291+
// Parse the AI response and convert to internal format
292+
log.Debug("parsing AI response", "response_length", len(responseText), "response_text", responseText)
293+
if err := status.ParseGameResponse(responseText, session.StatusFields, action.StatusFields, response); err != nil {
280294
log.Error("failed to parse game response", "error", err, "response_text", responseText)
281-
return usage, fmt.Errorf("failed to parse game response: %w", err)
295+
return usage, err
282296
}
283297

284-
// Convert flat status map back to ordered []StatusField using session's field definitions.
285-
// Pass action's current status as fallback in case the AI omits a field.
286-
fieldNames := status.FieldNames(session.StatusFields)
287-
response.Message = aiResp.Message
288-
response.StatusFields = status.MapToFields(aiResp.Status, fieldNames, status.FieldsToMap(action.StatusFields))
289-
response.ImagePrompt = aiResp.ImagePrompt
290-
291298
// Update model session with new response ID
292299
modelSession.ResponseID = apiResponse.ID
293300
response.URLAnalytics = functional.Ptr("https://platform.openai.com/logs/" + apiResponse.ID)
@@ -348,10 +355,13 @@ func (p *OpenAiPlatform) ExpandStory(ctx context.Context, session *obj.GameSessi
348355
}
349356

350357
// Build streaming request - plain text, no JSON schema
358+
// Use developer role for the narration instruction (it's a directive, not player input)
351359
model := p.ResolveModel(session.AiModel)
352360
req := ResponsesAPIRequest{
353-
Model: model,
354-
Input: templates.PromptNarratePlotOutline,
361+
Model: model,
362+
Input: []InputMessage{
363+
{Role: "developer", Content: templates.PromptNarratePlotOutline},
364+
},
355365
Store: true,
356366
Stream: true,
357367
MaxOutputTokens: 5000,
@@ -509,9 +519,9 @@ func callImageGenerationAPI(ctx context.Context, apiKey string, prompt string, s
509519

510520
// Note: style parameter is only supported for dall-e-3, not gpt-image-1
511521
// For gpt-image-1, we include the style in the prompt instead
512-
fullPrompt := prompt
522+
fullPrompt := prompt + templates.ImagePromptSuffix
513523
if style != "" {
514-
fullPrompt = fmt.Sprintf("%s. Style: %s", prompt, style)
524+
fullPrompt = fmt.Sprintf("%s Style: %s", fullPrompt, style)
515525
}
516526

517527
reqBody := map[string]interface{}{
@@ -608,7 +618,7 @@ func (p *OpenAiPlatform) Translate(ctx context.Context, apiKey string, input []s
608618
req := ResponsesAPIRequest{
609619
Model: translateModel,
610620
Instructions: lang.TranslateInstruction,
611-
Input: fmt.Sprintf("Translate this JSON to %s:\n\n%s", lang.GetLanguageName(targetLang), originals),
621+
Input: []InputMessage{{Role: "user", Content: fmt.Sprintf("Translate this JSON to %s:\n\n%s", lang.GetLanguageName(targetLang), originals)}},
612622
Store: false,
613623
Text: &TextConfig{
614624
Format: FormatConfig{
@@ -729,45 +739,7 @@ func isIrrelevantModel(modelID string) bool {
729739

730740
// isDatedModel checks if a model ID ends with a date or version pattern
731741
func isDatedModel(modelID string) bool {
732-
parts := strings.Split(modelID, "-")
733-
if len(parts) < 2 {
734-
return false
735-
}
736-
737-
lastPart := parts[len(parts)-1]
738-
739-
// Check if ends with 4 digits (e.g., -1106, -0914)
740-
if len(lastPart) == 4 {
741-
for _, ch := range lastPart {
742-
if ch < '0' || ch > '9' {
743-
return false
744-
}
745-
}
746-
return true
747-
}
748-
749-
// Check if model ends with -YYYY-MM-DD pattern
750-
if len(parts) < 3 {
751-
return false
752-
}
753-
754-
// Get last 3 parts
755-
lastThree := parts[len(parts)-3:]
756-
757-
// Check if they match YYYY-MM-DD pattern
758-
if len(lastThree[0]) == 4 && len(lastThree[1]) == 2 && len(lastThree[2]) == 2 {
759-
// Verify they're all numeric
760-
for _, part := range lastThree {
761-
for _, ch := range part {
762-
if ch < '0' || ch > '9' {
763-
return false
764-
}
765-
}
766-
}
767-
return true
768-
}
769-
770-
return false
742+
return functional.EndsWithDigits(modelID, 4) || functional.EndsWithDatePattern(modelID)
771743
}
772744

773745
// GenerateTheme generates a visual theme JSON for the game player UI
@@ -785,7 +757,7 @@ func (p *OpenAiPlatform) GenerateTheme(ctx context.Context, session *obj.GameSes
785757
reqBody := ResponsesAPIRequest{
786758
Model: model,
787759
Instructions: systemPrompt,
788-
Input: userPrompt,
760+
Input: []InputMessage{{Role: "user", Content: userPrompt}},
789761
Store: false, // Don't store theme generation in conversation history
790762
}
791763

server/game/game_logic.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,10 @@ func DoSessionAction(ctx context.Context, session *obj.GameSession, action obj.G
415415
}
416416
response.TokenUsage = &usage
417417
// Set prompts on response for transparency (educational debug view)
418-
response.PromptStatusUpdate = functional.Ptr(action.ToAiJSON())
418+
// PromptStatusUpdate is set by the platform's ExecuteAction (platform-specific input format)
419+
if response.PromptStatusUpdate == nil {
420+
response.PromptStatusUpdate = functional.Ptr(action.ToAiJSON())
421+
}
419422
response.PromptResponseSchema = functional.Ptr(string(gameSchemaJSON))
420423
response.PromptExpandStory = functional.Ptr(templates.PromptNarratePlotOutline)
421424
response.PromptImageGeneration = response.ImagePrompt

server/game/status/parse.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package status
2+
3+
import (
4+
"cgl/obj"
5+
"encoding/json"
6+
"fmt"
7+
)
8+
9+
// ParseGameResponse parses raw AI JSON text into the response message fields.
10+
// It unmarshals the AI response, converts the flat status map back to ordered
11+
// []StatusField, and populates response.Message, StatusFields, and ImagePrompt.
12+
// actionStatusFields provides fallback values if the AI omits a field.
13+
func ParseGameResponse(responseText string, sessionStatusFields string, actionStatusFields []obj.StatusField, response *obj.GameSessionMessage) error {
14+
var aiResp obj.GameSessionMessageAi
15+
if err := json.Unmarshal([]byte(responseText), &aiResp); err != nil {
16+
return fmt.Errorf("failed to parse game response: %w", err)
17+
}
18+
19+
fieldNames := FieldNames(sessionStatusFields)
20+
response.Message = aiResp.Message
21+
response.StatusFields = MapToFields(aiResp.Status, fieldNames, FieldsToMap(actionStatusFields))
22+
response.ImagePrompt = aiResp.ImagePrompt
23+
24+
return nil
25+
}

server/game/status/status.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,16 @@ func BuildResponseSchema(statusFieldsJSON string) map[string]interface{} {
5555
// Build status properties with exact field names as keys
5656
statusProperties := make(map[string]interface{}, len(fieldNames))
5757
for _, name := range fieldNames {
58-
statusProperties[name] = map[string]interface{}{"type": "string"}
58+
statusProperties[name] = map[string]interface{}{"type": "string", "maxLength": 30}
5959
}
6060

6161
return map[string]interface{}{
6262
"type": "object",
6363
"properties": map[string]interface{}{
6464
"message": map[string]interface{}{
6565
"type": "string",
66-
"description": "The narrative response to the player's action",
66+
"maxLength": 400,
67+
"description": "Telegram-style. Subject-verb-object. No adjectives. Example: 'You start reciting. Dog interrupts. Courtiers laugh.'",
6768
},
6869
"status": map[string]interface{}{
6970
"type": "object",
@@ -74,7 +75,8 @@ func BuildResponseSchema(statusFieldsJSON string) map[string]interface{} {
7475
},
7576
"imagePrompt": map[string]interface{}{
7677
"type": "string",
77-
"description": "Description for generating an image of the scene",
78+
"maxLength": 150,
79+
"description": "Short visual description of the scene for image generation",
7880
},
7981
},
8082
"required": []string{"message", "status", "imagePrompt"},

server/game/templates/templates.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ const (
1919
PromptMessageStart = "Start the game. Generate the opening scene. Set the status fields to good initial values for the scenario."
2020
// PromptNarratePlotOutline is sent after each JSON response to get prose narration
2121
PromptNarratePlotOutline = "NARRATE the summary into prose. STRICT RULES: 1-3 sentences MAXIMUM. No headers, no markdown, no lists. Do NOT repeat status fields. End on an open note. Be brief and atmospheric."
22+
23+
// ReminderExecuteAction is injected as a developer message with every player action
24+
// to reinforce brevity constraints that the model tends to forget over long conversations.
25+
ReminderExecuteAction = "STRICT OUTPUT RULES: message=telegraph-style (subject-verb-object, no adjectives, max 2 sentences). status=short labels (1-3 words each, e.g. 'Low', 'Newcomer'). imagePrompt=max 6 words, visual only."
26+
27+
// ImagePromptSuffix is appended to every image generation prompt to avoid inconsistent player depictions.
28+
ImagePromptSuffix = ". Do not depict the player character."
2229
)
2330

2431
func ImageStyleOrDefault(style string) string {
@@ -101,9 +108,9 @@ func GetTemplate(game *obj.Game) (string, error) {
101108

102109
actionOutput := obj.GameSessionMessageAi{
103110
Type: obj.GameSessionMessageTypeGame,
104-
Message: "You drink the potion. You feel a little bit dizzy. You feel a little bit stronger.",
111+
Message: "Player drinks potion, feels dizzy then stronger.",
105112
Status: statusMap,
106-
ImagePrompt: functional.Ptr("a castle in the background, green grass, late afternoon"),
113+
ImagePrompt: functional.Ptr("green grass, late afternoon, castle in background"),
107114
}
108115
actionOutputStr, _ := json.Marshal(actionOutput)
109116

0 commit comments

Comments
 (0)