package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) type scoreAdviceModelResponse struct { AdvisedScore int `json:"advisedScore"` Reason string `json:"reason"` } type geminiGenerateRequest struct { Contents []geminiContent `json:"contents"` GenerationConfig geminiGenerationConfig `json:"generationConfig,omitempty"` SafetySettings []map[string]interface{} `json:"safetySettings,omitempty"` } type geminiContent struct { Role string `json:"role,omitempty"` Parts []geminiPart `json:"parts"` } type geminiPart struct { Text string `json:"text,omitempty"` InlineData *geminiInlineData `json:"inline_data,omitempty"` } type geminiInlineData struct { MimeType string `json:"mime_type"` Data string `json:"data"` } type geminiGenerationConfig struct { Temperature float64 `json:"temperature,omitempty"` ResponseMimeType string `json:"responseMimeType,omitempty"` MaxOutputTokens int `json:"maxOutputTokens,omitempty"` } type geminiGenerateResponse struct { Candidates []struct { Content struct { Parts []struct { Text string `json:"text"` } `json:"parts"` } `json:"content"` } `json:"candidates"` Error *struct { Message string `json:"message"` } `json:"error,omitempty"` } func (a *App) generateScoreAdvice(stage string, playerID int, imageData string, currentScore int) (ScoreAdviceResponse, error) { mimeType, rawBase64, err := parseDataURI(imageData) if err != nil { return ScoreAdviceResponse{}, fmt.Errorf("invalid proof image data: %w", err) } model := strings.TrimSpace(a.cfg.GeminiModel) if model == "" { model = "gemini-2.0-flash" } requestPayload := geminiGenerateRequest{ Contents: []geminiContent{ { Role: "user", Parts: []geminiPart{ {Text: scoreAdvicePrompt(stage, currentScore)}, { InlineData: &geminiInlineData{ MimeType: mimeType, Data: rawBase64, }, }, }, }, }, GenerationConfig: geminiGenerationConfig{ Temperature: 0, ResponseMimeType: "application/json", MaxOutputTokens: 100, }, } body, err := json.Marshal(requestPayload) if err != nil { return ScoreAdviceResponse{}, fmt.Errorf("marshal gemini request: %w", err) } endpoint := fmt.Sprintf( "https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s", url.PathEscape(model), url.QueryEscape(a.cfg.GeminiAPIKey), ) req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return ScoreAdviceResponse{}, fmt.Errorf("create gemini request: %w", err) } req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 25 * time.Second} resp, err := client.Do(req) if err != nil { return ScoreAdviceResponse{}, fmt.Errorf("call gemini api: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2_000_000)) if err != nil { return ScoreAdviceResponse{}, fmt.Errorf("read gemini response: %w", err) } if resp.StatusCode >= 300 { return ScoreAdviceResponse{}, fmt.Errorf("gemini api status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) } var generated geminiGenerateResponse if err := json.Unmarshal(respBody, &generated); err != nil { return ScoreAdviceResponse{}, fmt.Errorf("decode gemini response: %w", err) } if generated.Error != nil && strings.TrimSpace(generated.Error.Message) != "" { return ScoreAdviceResponse{}, fmt.Errorf("gemini api error: %s", generated.Error.Message) } if len(generated.Candidates) == 0 || len(generated.Candidates[0].Content.Parts) == 0 { return ScoreAdviceResponse{}, fmt.Errorf("gemini returned no advice") } rawText := strings.TrimSpace(generated.Candidates[0].Content.Parts[0].Text) jsonText := extractJSONObject(rawText) if jsonText == "" { return ScoreAdviceResponse{}, fmt.Errorf("gemini response was not valid json") } var modelResponse scoreAdviceModelResponse if err := json.Unmarshal([]byte(jsonText), &modelResponse); err != nil { return ScoreAdviceResponse{}, fmt.Errorf("parse gemini advice json: %w", err) } return a.buildAdviceResponse(stage, playerID, modelResponse), nil } func parseDataURI(dataURI string) (string, string, error) { value := strings.TrimSpace(dataURI) if !strings.HasPrefix(value, "data:") { return "", "", fmt.Errorf("expected data uri") } parts := strings.SplitN(value, ",", 2) if len(parts) != 2 { return "", "", fmt.Errorf("invalid data uri format") } header := parts[0] payload := strings.TrimSpace(parts[1]) if payload == "" { return "", "", fmt.Errorf("empty data uri payload") } mimeType := "image/jpeg" if semicolon := strings.Index(header, ";"); semicolon > len("data:") { mimeType = strings.TrimSpace(header[len("data:"):semicolon]) } if mimeType == "" { mimeType = "image/jpeg" } return mimeType, payload, nil } func extractJSONObject(raw string) string { text := strings.TrimSpace(raw) if text == "" { return "" } if strings.HasPrefix(text, "```") { text = strings.TrimPrefix(text, "```json") text = strings.TrimPrefix(text, "```") text = strings.TrimSuffix(strings.TrimSpace(text), "```") text = strings.TrimSpace(text) } start := strings.Index(text, "{") end := strings.LastIndex(text, "}") if start == -1 || end == -1 || end <= start { return "" } return strings.TrimSpace(text[start : end+1]) } func scoreAdvicePrompt(stage string, currentScore int) string { return fmt.Sprintf(`Target scoring assistant. Stage: %s. Current score: %d. Return STRICT JSON only: {"advisedScore":,"reason":""} Do not add markdown or extra fields.`, stage, currentScore) }