Files
ds2api/internal/adapter/openai/history_split.go

281 lines
7.7 KiB
Go

package openai
import (
"context"
"errors"
"fmt"
"strings"
"ds2api/internal/auth"
"ds2api/internal/deepseek"
"ds2api/internal/util"
)
const (
historySplitFilename = "HISTORY.txt"
historySplitContentType = "text/plain; charset=utf-8"
historySplitPurpose = "assistants"
)
func (h *Handler) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, stdReq util.StandardRequest) (util.StandardRequest, error) {
if h == nil || h.DS == nil || h.Store == nil || a == nil {
return stdReq, nil
}
if !h.Store.HistorySplitEnabled() {
return stdReq, nil
}
promptMessages, historyMessages := splitOpenAIHistoryMessages(stdReq.Messages, h.Store.HistorySplitTriggerAfterTurns())
if len(historyMessages) == 0 {
return stdReq, nil
}
reasoningContent := extractHistorySplitReasoningContent(historyMessages)
historyText := buildOpenAIHistoryTranscript(historyMessages)
if strings.TrimSpace(historyText) == "" {
return stdReq, errors.New("history split produced empty transcript")
}
result, err := h.DS.UploadFile(ctx, a, deepseek.UploadFileRequest{
Filename: historySplitFilename,
ContentType: historySplitContentType,
Purpose: historySplitPurpose,
Data: []byte(historyText),
}, 3)
if err != nil {
return stdReq, fmt.Errorf("upload history file: %w", err)
}
fileID := strings.TrimSpace(result.ID)
if fileID == "" {
return stdReq, errors.New("upload history file returned empty file id")
}
stdReq.Messages = promptMessages
stdReq.RefFileIDs = prependUniqueRefFileID(stdReq.RefFileIDs, fileID)
stdReq.FinalPrompt, stdReq.ToolNames = buildHistorySplitPrompt(promptMessages, reasoningContent, stdReq.ToolsRaw, stdReq.ToolChoice, stdReq.Thinking)
return stdReq, nil
}
func buildHistorySplitPrompt(messages []any, reasoningContent string, toolsRaw any, toolPolicy util.ToolChoicePolicy, thinkingEnabled bool) (string, []string) {
if len(messages) == 0 && strings.TrimSpace(reasoningContent) == "" {
return "", nil
}
instruction := historySplitPromptInstruction()
withInstruction := make([]any, 0, len(messages)+1)
withInstruction = append(withInstruction, map[string]any{
"role": "system",
"content": instruction,
})
withInstruction = append(withInstruction, injectHistorySplitReasoningMessage(messages, reasoningContent)...)
return buildOpenAIFinalPromptWithPolicy(withInstruction, toolsRaw, "", toolPolicy, thinkingEnabled)
}
func historySplitPromptInstruction() string {
return "An attached HISTORY.txt file contains prior conversation history and tool progress. Read it first, then answer the latest user request using that history as context."
}
func splitOpenAIHistoryMessages(messages []any, triggerAfterTurns int) ([]any, []any) {
if triggerAfterTurns <= 0 {
triggerAfterTurns = 1
}
lastUserIndex := -1
userTurns := 0
for i, raw := range messages {
msg, ok := raw.(map[string]any)
if !ok {
continue
}
role := strings.ToLower(strings.TrimSpace(asString(msg["role"])))
if role != "user" {
continue
}
userTurns++
lastUserIndex = i
}
if userTurns <= triggerAfterTurns || lastUserIndex < 0 {
return messages, nil
}
promptMessages := make([]any, 0, len(messages)-lastUserIndex)
historyMessages := make([]any, 0, lastUserIndex)
for i, raw := range messages {
msg, ok := raw.(map[string]any)
if !ok {
if i >= lastUserIndex {
promptMessages = append(promptMessages, raw)
} else {
historyMessages = append(historyMessages, raw)
}
continue
}
role := strings.ToLower(strings.TrimSpace(asString(msg["role"])))
switch role {
case "system", "developer":
promptMessages = append(promptMessages, raw)
default:
if i >= lastUserIndex {
promptMessages = append(promptMessages, raw)
} else {
historyMessages = append(historyMessages, raw)
}
}
}
if len(promptMessages) == 0 {
return messages, nil
}
return promptMessages, historyMessages
}
func buildOpenAIHistoryTranscript(messages []any) string {
var b strings.Builder
b.WriteString("# HISTORY.txt\n")
b.WriteString("Prior conversation history and tool progress.\n\n")
entry := 0
for _, raw := range messages {
msg, ok := raw.(map[string]any)
if !ok {
continue
}
role := strings.ToLower(strings.TrimSpace(asString(msg["role"])))
content := buildOpenAIHistoryEntry(role, msg)
if strings.TrimSpace(content) == "" {
continue
}
entry++
fmt.Fprintf(&b, "=== %d. %s ===\n%s\n\n", entry, strings.ToUpper(roleLabelForHistory(role)), content)
}
return strings.TrimSpace(b.String()) + "\n"
}
func buildOpenAIHistoryEntry(role string, msg map[string]any) string {
switch role {
case "assistant":
return strings.TrimSpace(buildAssistantHistoryContent(msg))
case "tool", "function":
return strings.TrimSpace(buildToolHistoryContent(msg))
case "user":
return strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"]))
default:
return strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"]))
}
}
func buildAssistantHistoryContent(msg map[string]any) string {
return strings.TrimSpace(buildAssistantContentForPrompt(msg))
}
func buildToolHistoryContent(msg map[string]any) string {
content := strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"]))
parts := make([]string, 0, 2)
if name := strings.TrimSpace(asString(msg["name"])); name != "" {
parts = append(parts, "name="+name)
}
if callID := strings.TrimSpace(asString(msg["tool_call_id"])); callID != "" {
parts = append(parts, "tool_call_id="+callID)
}
header := ""
if len(parts) > 0 {
header = "[" + strings.Join(parts, " ") + "]"
}
switch {
case header != "" && content != "":
return header + "\n" + content
case header != "":
return header
default:
return content
}
}
func extractHistorySplitReasoningContent(messages []any) string {
for i := len(messages) - 1; i >= 0; i-- {
msg, ok := messages[i].(map[string]any)
if !ok {
continue
}
role := strings.ToLower(strings.TrimSpace(asString(msg["role"])))
if role != "assistant" {
continue
}
reasoning := strings.TrimSpace(normalizeOpenAIReasoningContentForPrompt(msg["reasoning_content"]))
if reasoning == "" {
reasoning = strings.TrimSpace(extractOpenAIReasoningContentFromMessage(msg["content"]))
}
if reasoning != "" {
return reasoning
}
}
return ""
}
func injectHistorySplitReasoningMessage(messages []any, reasoningContent string) []any {
reasoningContent = strings.TrimSpace(reasoningContent)
if reasoningContent == "" {
return messages
}
reasoningMsg := map[string]any{
"role": "assistant",
"content": "",
"reasoning_content": reasoningContent,
}
lastUserIndex := lastOpenAIUserMessageIndex(messages)
if lastUserIndex < 0 {
out := make([]any, 0, len(messages)+1)
out = append(out, reasoningMsg)
out = append(out, messages...)
return out
}
out := make([]any, 0, len(messages)+1)
for i, raw := range messages {
if i == lastUserIndex {
out = append(out, reasoningMsg)
}
out = append(out, raw)
}
return out
}
func lastOpenAIUserMessageIndex(messages []any) int {
last := -1
for i, raw := range messages {
msg, ok := raw.(map[string]any)
if !ok {
continue
}
if strings.ToLower(strings.TrimSpace(asString(msg["role"]))) == "user" {
last = i
}
}
return last
}
func roleLabelForHistory(role string) string {
role = strings.ToLower(strings.TrimSpace(role))
switch role {
case "function":
return "tool"
case "":
return "unknown"
default:
return role
}
}
func prependUniqueRefFileID(existing []string, fileID string) []string {
fileID = strings.TrimSpace(fileID)
if fileID == "" {
return existing
}
out := make([]string, 0, len(existing)+1)
out = append(out, fileID)
for _, id := range existing {
trimmed := strings.TrimSpace(id)
if trimmed == "" || strings.EqualFold(trimmed, fileID) {
continue
}
out = append(out, trimmed)
}
return out
}