mirror of
https://github.com/httprunner/httprunner.git
synced 2026-05-11 10:00:23 +08:00
476 lines
14 KiB
Go
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
|
|
}
|