Files
httprunner/uixt/ai/utils.go
lilong.129 90ce090e35 fix: remove redundant message cleaning logic in callModelWithLogging
The previous message cleaning logic was flawed:
- cleanedMsg.Content was already set to message.Content
- The condition checked if message.Content == "" then set cleanedMsg.Content = ""
- This was redundant since cleanedMsg.Content would already be empty

The real fix for the API 400 error is in planner.go where we ensure Tool messages
have non-empty content. The utils.go changes were unnecessary.
2025-06-26 13:41:39 +08:00

476 lines
14 KiB
Go

package ai
import (
"context"
"fmt"
"regexp"
"strings"
"time"
"unicode/utf8"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/internal/json"
"github.com/httprunner/httprunner/v5/uixt/option"
"github.com/pkg/errors"
)
// PlanningJSONResponse represents the JSON response structure for planning
type PlanningJSONResponse struct {
Actions []Action `json:"actions"`
Thought string `json:"thought"`
Error string `json:"error"`
}
// parseStructuredResponse parses model response into structured format with error recovery
func parseStructuredResponse(content string, result interface{}) error {
// Clean and validate UTF-8 content first
cleanContent := sanitizeUTF8Content(content)
// Extract JSON content from response
jsonContent := extractJSONFromContent(cleanContent)
if jsonContent == "" {
// If JSON extraction failed, try parsing the content directly as a fallback
jsonContent = cleanContent
}
// Parse JSON response with error recovery
return parseJSONWithFallback(jsonContent, result)
}
// sanitizeUTF8Content cleans invalid UTF-8 characters from content
func sanitizeUTF8Content(content string) string {
if utf8.ValidString(content) {
return content
}
// Convert to bytes and filter out invalid UTF-8 sequences
bytes := []byte(content)
var validBytes []byte
for len(bytes) > 0 {
r, size := utf8.DecodeRune(bytes)
if r != utf8.RuneError {
// Valid rune, keep it
validBytes = append(validBytes, bytes[:size]...)
}
// Skip invalid bytes (including RuneError)
bytes = bytes[size:]
}
return string(validBytes)
}
// extractJSONFromContent extracts JSON content from various formats in the response
// This function handles multiple formats:
// 1. ```json ... ``` markdown code blocks
// 2. ``` ... ``` generic code blocks
// 3. JSON objects embedded in text
// 4. Plain JSON content
func extractJSONFromContent(content string) string {
content = strings.TrimSpace(content)
// Case 1: Content wrapped in ```json ... ```
if strings.Contains(content, "```json") {
start := strings.Index(content, "```json")
if start != -1 {
start += 7 // length of "```json"
end := strings.Index(content[start:], "```")
if end != -1 {
jsonContent := strings.TrimSpace(content[start : start+end])
return jsonContent
}
}
}
// Case 2: Content wrapped in ``` ... ``` (without json specifier)
if strings.HasPrefix(content, "```") && strings.HasSuffix(content, "```") {
lines := strings.Split(content, "\n")
if len(lines) >= 3 {
// Remove first and last lines (the ``` markers)
jsonLines := lines[1 : len(lines)-1]
jsonContent := strings.Join(jsonLines, "\n")
jsonContent = strings.TrimSpace(jsonContent)
// Check if it looks like JSON
if strings.HasPrefix(jsonContent, "{") && strings.HasSuffix(jsonContent, "}") {
return jsonContent
}
}
}
// Case 3: Look for JSON object in the content using rune-based brace counting (most reliable method)
start := strings.Index(content, "{")
if start != -1 {
// Find the matching closing brace using rune-based iteration to handle UTF-8 properly
braceCount := 0
inString := false
escaped := false
// Use byte-based iteration but track string state properly
for i := start; i < len(content); {
r, size := utf8.DecodeRuneInString(content[i:])
if escaped {
escaped = false
i += size
continue
}
if r == '\\' && inString {
escaped = true
i += size
continue
}
if r == '"' {
inString = !inString
i += size
continue
}
if !inString {
if r == '{' {
braceCount++
} else if r == '}' {
braceCount--
if braceCount == 0 {
jsonContent := strings.TrimSpace(content[start : i+size])
return jsonContent
}
}
}
i += size
}
}
// Case 4: Try regex approach for markdown-like formats (fallback)
jsonRegex := regexp.MustCompile(`(?:json)?\s*({[\s\S]*?})\s*`)
matches := jsonRegex.FindStringSubmatch(content)
if len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
// Case 5: If content itself looks like JSON
if strings.HasPrefix(content, "{") && strings.HasSuffix(content, "}") {
return content
}
return ""
}
// parseJSONWithFallback attempts to parse JSON with multiple strategies for any struct type
func parseJSONWithFallback(jsonContent string, result interface{}) error {
// Strategy 1: Direct JSON unmarshaling
if err := json.Unmarshal([]byte(jsonContent), result); err == nil {
// For specific types, ensure required fields have default values even after successful parsing
switch v := result.(type) {
case *QueryResult:
// Ensure QueryResult has meaningful defaults for empty fields
if v.Content == "" && v.Thought == "" {
v.Content = "Empty response content"
v.Thought = "No content extracted from response"
} else if v.Content == "" {
v.Content = "No content extracted"
} else if v.Thought == "" {
v.Thought = "Successfully parsed structured response"
}
case *AssertionResult:
// Ensure AssertionResult has meaningful defaults
if v.Thought == "" {
v.Thought = "Successfully parsed assertion response"
}
}
return nil
}
// Strategy 2: Try cleaning JSON content and parse again
cleanedJSON := cleanJSONContent(jsonContent)
if err := json.Unmarshal([]byte(cleanedJSON), result); err == nil {
// Apply the same default value logic for cleaned JSON
switch v := result.(type) {
case *QueryResult:
if v.Content == "" && v.Thought == "" {
v.Content = "Empty response content"
v.Thought = "No content extracted from response"
} else if v.Content == "" {
v.Content = "No content extracted"
} else if v.Thought == "" {
v.Thought = "Successfully parsed structured response"
}
case *AssertionResult:
if v.Thought == "" {
v.Thought = "Successfully parsed assertion response"
}
}
return nil
}
// Strategy 3: For specific types, try manual extraction or content analysis
switch v := result.(type) {
case *AssertionResult:
if fallbackResult, err := extractAssertionFieldsManually(jsonContent); err == nil {
*v = *fallbackResult
return nil
}
// Final fallback for assertions: content analysis
*v = *analyzeContentForAssertion(jsonContent)
return nil
case *QueryResult:
// For QueryResult, try basic field extraction
if fallbackResult, err := extractQueryFieldsManually(jsonContent); err == nil {
*v = *fallbackResult
return nil
}
// Fallback to treating content as plain text
*v = QueryResult{
Content: jsonContent,
Thought: "Failed to parse as JSON, returning raw content",
}
return nil
case *PlanningJSONResponse:
// For PlanningJSONResponse, try basic field extraction
if fallbackResult, err := extractPlanningFieldsManually(jsonContent); err == nil {
*v = *fallbackResult
return nil
}
// Fallback with empty actions but preserve any recognizable thought content
*v = PlanningJSONResponse{
Actions: []Action{},
Thought: "Failed to parse structured response",
Error: "JSON parsing failed, returning minimal structure",
}
return nil
}
return errors.New("failed to parse JSON with all strategies")
}
// extractAssertionFieldsManually extracts pass and thought fields from text
func extractAssertionFieldsManually(content string) (*AssertionResult, error) {
result := &AssertionResult{}
// Try to extract "pass" field
if strings.Contains(strings.ToLower(content), `"pass":true`) ||
strings.Contains(strings.ToLower(content), `"pass": true`) {
result.Pass = true
} else if strings.Contains(strings.ToLower(content), `"pass":false`) ||
strings.Contains(strings.ToLower(content), `"pass": false`) {
result.Pass = false
} else {
return nil, errors.New("cannot extract pass field")
}
// Try to extract "thought" field
thoughtStart := strings.Index(content, `"thought"`)
if thoughtStart != -1 {
thoughtSection := content[thoughtStart:]
colonIndex := strings.Index(thoughtSection, ":")
if colonIndex != -1 {
afterColon := strings.TrimSpace(thoughtSection[colonIndex+1:])
if strings.HasPrefix(afterColon, `"`) {
// Find the matching closing quote, handling escaped quotes
thoughtContent := extractQuotedString(afterColon)
result.Thought = thoughtContent
}
}
}
return result, nil
}
// extractQuotedString extracts content from a quoted string, handling escaped quotes
func extractQuotedString(s string) string {
if !strings.HasPrefix(s, `"`) {
return ""
}
s = s[1:] // Remove opening quote
var result strings.Builder
escaped := false
for _, r := range s {
if escaped {
result.WriteRune(r)
escaped = false
continue
}
if r == '\\' {
escaped = true
continue
}
if r == '"' {
// Found closing quote
return result.String()
}
result.WriteRune(r)
}
return result.String()
}
// cleanJSONContent removes common JSON formatting issues
func cleanJSONContent(content string) string {
// Remove any non-printable characters
cleaned := strings.Map(func(r rune) rune {
if r >= 32 && r < 127 || r > 127 { // Keep printable ASCII and Unicode
return r
}
return -1 // Remove non-printable characters
}, content)
// Remove any trailing commas before closing braces/brackets
cleaned = strings.ReplaceAll(cleaned, ",}", "}")
cleaned = strings.ReplaceAll(cleaned, ",]", "]")
return cleaned
}
// analyzeContentForAssertion creates a fallback result by analyzing content
func analyzeContentForAssertion(content string) *AssertionResult {
content = strings.ToLower(content)
// Simple heuristic: look for positive/negative indicators
positiveIndicators := []string{"true", "pass", "success", "correct", "valid", "match"}
negativeIndicators := []string{"false", "fail", "error", "incorrect", "invalid", "mismatch"}
positiveCount := 0
negativeCount := 0
for _, indicator := range positiveIndicators {
if strings.Contains(content, indicator) {
positiveCount++
}
}
for _, indicator := range negativeIndicators {
if strings.Contains(content, indicator) {
negativeCount++
}
}
pass := positiveCount > negativeCount
thought := fmt.Sprintf("Fallback analysis of malformed response (positive: %d, negative: %d)",
positiveCount, negativeCount)
return &AssertionResult{
Pass: pass,
Thought: thought,
}
}
// extractQueryFieldsManually extracts content and thought fields for QueryResult
func extractQueryFieldsManually(content string) (*QueryResult, error) {
result := &QueryResult{}
// Try to extract "content" field
if contentStart := strings.Index(content, `"content"`); contentStart != -1 {
contentSection := content[contentStart:]
if colonIndex := strings.Index(contentSection, ":"); colonIndex != -1 {
afterColon := strings.TrimSpace(contentSection[colonIndex+1:])
if strings.HasPrefix(afterColon, `"`) {
result.Content = extractQuotedString(afterColon)
}
}
}
// Try to extract "thought" field
if thoughtStart := strings.Index(content, `"thought"`); thoughtStart != -1 {
thoughtSection := content[thoughtStart:]
if colonIndex := strings.Index(thoughtSection, ":"); colonIndex != -1 {
afterColon := strings.TrimSpace(thoughtSection[colonIndex+1:])
if strings.HasPrefix(afterColon, `"`) {
result.Thought = extractQuotedString(afterColon)
}
}
}
// If we couldn't extract any fields, return error
if result.Content == "" && result.Thought == "" {
return nil, errors.New("cannot extract content or thought fields")
}
// Set defaults for missing fields (ALWAYS set defaults if any field was extracted)
if result.Content == "" {
result.Content = "Extracted partial information"
}
if result.Thought == "" {
result.Thought = "Partial extraction from malformed response"
}
return result, nil
}
// extractPlanningFieldsManually extracts thought and error fields for PlanningJSONResponse
func extractPlanningFieldsManually(content string) (*PlanningJSONResponse, error) {
result := &PlanningJSONResponse{
Actions: []Action{}, // Default to empty actions
}
// Try to extract "thought" field
if thoughtStart := strings.Index(content, `"thought"`); thoughtStart != -1 {
thoughtSection := content[thoughtStart:]
if colonIndex := strings.Index(thoughtSection, ":"); colonIndex != -1 {
afterColon := strings.TrimSpace(thoughtSection[colonIndex+1:])
if strings.HasPrefix(afterColon, `"`) {
result.Thought = extractQuotedString(afterColon)
}
}
}
// Try to extract "error" field
if errorStart := strings.Index(content, `"error"`); errorStart != -1 {
errorSection := content[errorStart:]
if colonIndex := strings.Index(errorSection, ":"); colonIndex != -1 {
afterColon := strings.TrimSpace(errorSection[colonIndex+1:])
if strings.HasPrefix(afterColon, `"`) {
result.Error = extractQuotedString(afterColon)
}
}
}
// If we couldn't extract any meaningful fields, return error
if result.Thought == "" && result.Error == "" {
return nil, errors.New("cannot extract thought or error fields")
}
// Set defaults for missing fields
if result.Thought == "" {
result.Thought = "Partial extraction from malformed response"
}
return result, nil
}
// callModelWithLogging is a common function to call model with logging and timing
// It handles the common pattern of:
// 1. Log request
// 2. Start timing
// 3. Call model.Generate
// 4. Log timing and model info
// 5. Log response
func callModelWithLogging(ctx context.Context, model model.ToolCallingChatModel, history ConversationHistory, modelType option.LLMServiceType, operation string) (*schema.Message, error) {
logRequest(history)
startTime := time.Now()
defer func() {
log.Debug().Float64("elapsed(s)", time.Since(startTime).Seconds()).
Str("model", string(modelType)).
Msgf("call model service for %s", operation)
}()
message, err := model.Generate(ctx, history)
if err != nil {
return nil, err
}
logResponse(message)
return message, nil
}