feat(metadata): enhance metadata extraction with reasoning content support

This commit is contained in:
2026-03-27 00:24:16 +02:00
parent 74e67526d1
commit f76d1bbd23
2 changed files with 185 additions and 8 deletions

View File

@@ -92,11 +92,18 @@ type chatMessage struct {
type chatCompletionsResponse struct {
Choices []struct {
Message chatMessage `json:"message"`
Message responseChatMessage `json:"message"`
Text string `json:"text,omitempty"`
} `json:"choices"`
Error *providerError `json:"error,omitempty"`
}
type responseChatMessage struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
}
type providerError struct {
Message string `json:"message"`
Type string `json:"type,omitempty"`
@@ -200,7 +207,7 @@ func (c *Client) extractMetadataWithModel(ctx context.Context, input, model stri
return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s metadata: no choices returned", c.name)
}
rawResponse := resp.Choices[0].Message.Content
rawResponse := extractChoiceText(resp.Choices[0].Message, resp.Choices[0].Text)
if c.logConversations && c.log != nil {
c.log.Info("metadata conversation response",
@@ -247,7 +254,7 @@ func (c *Client) Summarize(ctx context.Context, systemPrompt, userPrompt string)
return "", fmt.Errorf("%s summarize: no choices returned", c.name)
}
return strings.TrimSpace(resp.Choices[0].Message.Content), nil
return extractChoiceText(resp.Choices[0].Message, resp.Choices[0].Text), nil
}
func (c *Client) Name() string {
@@ -335,12 +342,45 @@ func (c *Client) doJSON(ctx context.Context, path string, requestBody any, dest
// extractJSONObject finds the first complete {...} block in s.
// It handles models that prepend prose to a JSON response despite json_object mode.
func extractJSONObject(s string) string {
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start == -1 || end == -1 || end <= start {
return ""
for start := 0; start < len(s); start++ {
if s[start] != '{' {
continue
}
depth := 0
inString := false
escaped := false
for end := start; end < len(s); end++ {
ch := s[end]
if escaped {
escaped = false
continue
}
if ch == '\\' && inString {
escaped = true
continue
}
if ch == '"' {
inString = !inString
continue
}
if inString {
continue
}
switch ch {
case '{':
depth++
case '}':
depth--
if depth == 0 {
candidate := s[start : end+1]
if json.Valid([]byte(candidate)) {
return candidate
}
}
}
}
}
return s[start : end+1]
return ""
}
// stripThinkingBlocks removes <think>...</think> and <thinking>...</thinking>
@@ -379,6 +419,71 @@ func stripCodeFence(value string) string {
return strings.TrimSpace(value)
}
func extractChoiceText(message responseChatMessage, fallbackText string) string {
if text := strings.TrimSpace(extractMessageContent(message.Content)); text != "" {
return text
}
if text := strings.TrimSpace(message.ReasoningContent); text != "" {
return text
}
return strings.TrimSpace(fallbackText)
}
func extractMessageContent(raw json.RawMessage) string {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return ""
}
var contentString string
if err := json.Unmarshal(raw, &contentString); err == nil {
return contentString
}
var contentArray []any
if err := json.Unmarshal(raw, &contentArray); err == nil {
parts := make([]string, 0, len(contentArray))
for _, item := range contentArray {
if text := strings.TrimSpace(extractTextFromAny(item)); text != "" {
parts = append(parts, text)
}
}
return strings.Join(parts, "\n")
}
var contentObject map[string]any
if err := json.Unmarshal(raw, &contentObject); err == nil {
return extractTextFromAny(contentObject)
}
return strings.TrimSpace(string(raw))
}
func extractTextFromAny(value any) string {
switch typed := value.(type) {
case string:
return typed
case []any:
parts := make([]string, 0, len(typed))
for _, item := range typed {
if text := strings.TrimSpace(extractTextFromAny(item)); text != "" {
parts = append(parts, text)
}
}
return strings.Join(parts, "\n")
case map[string]any:
// Common provider shapes for chat content parts.
for _, key := range []string{"text", "output_text", "content", "value"} {
if nested, ok := typed[key]; ok {
if text := strings.TrimSpace(extractTextFromAny(nested)); text != "" {
return text
}
}
}
}
return ""
}
func isRetryableStatus(status int) bool {
switch status {
case http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: