Strict output 모드에서 reasoning이 명시적으로 요청된 경우 이를 보존할 수 있도록 normalizeCompletionOutput 함수와 관련 핸들러들의 로직을 업데이트한다.
213 lines
6.4 KiB
Go
213 lines
6.4 KiB
Go
package openai
|
|
|
|
import (
|
|
"fmt"
|
|
"html"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
func strictOutputContractInstruction(policy strictOutputPolicy) string {
|
|
if !policy.ContractInstruction {
|
|
return ""
|
|
}
|
|
resultTag := policy.XMLResultTag
|
|
if resultTag == "" {
|
|
resultTag = "result"
|
|
}
|
|
return fmt.Sprintf("Strict output contract: follow the client's XML tool protocol. When you complete the task, output exactly one <%s> block and no text outside that XML block. The <%s> content must be the direct final answer that should be shown to the end user. Do not summarize that you answered, completed, explained, greeted, or performed the task. Do not write phrases like \"I completed\", \"I explained\", \"I greeted\", \"summary\", or \"main points\" unless the user explicitly asks for a summary. Put the actual user-facing response inside <%s>.", policy.XMLCompletionTool, resultTag, resultTag)
|
|
}
|
|
|
|
func prependSystemMessage(messages []chatMessage, content string) []chatMessage {
|
|
out := make([]chatMessage, 0, len(messages)+1)
|
|
out = append(out, chatMessage{Role: "system", Content: content})
|
|
out = append(out, messages...)
|
|
return out
|
|
}
|
|
|
|
func normalizeCompletionOutput(policy strictOutputPolicy, content, reasoning string, preserveReasoning bool) (string, string, bool) {
|
|
var sanitized bool
|
|
content, sanitized = sanitizeKnownChatTemplateSentinels(content)
|
|
reasoning, reasoningSanitized := sanitizeKnownChatTemplateSentinels(reasoning)
|
|
sanitized = sanitized || reasoningSanitized
|
|
if !policy.Strict {
|
|
return content, reasoning, sanitized
|
|
}
|
|
normalized := normalizeStrictAgentContent(content)
|
|
if strings.TrimSpace(normalized) == "" && strings.TrimSpace(reasoning) != "" {
|
|
normalized = normalizeStrictAgentContent(reasoning)
|
|
}
|
|
if strings.TrimSpace(normalized) == "" {
|
|
normalized = "Model returned no assistant content."
|
|
}
|
|
if policy.XMLCompletionTool != "" && !startsWithXMLRoot(normalized) {
|
|
normalized = wrapXMLCompletion(policy, normalized)
|
|
}
|
|
if preserveReasoning {
|
|
return normalized, reasoning, sanitized || normalized != content
|
|
}
|
|
return normalized, "", sanitized || normalized != content || reasoning != ""
|
|
}
|
|
|
|
var knownChatTemplateSentinels = []string{
|
|
"<|mask_end|>",
|
|
}
|
|
|
|
func sanitizeKnownChatTemplateSentinels(s string) (string, bool) {
|
|
cleaned := s
|
|
for _, token := range knownChatTemplateSentinels {
|
|
cleaned = strings.ReplaceAll(cleaned, token, "")
|
|
}
|
|
if cleaned == s {
|
|
return s, false
|
|
}
|
|
return strings.TrimSpace(cleaned), true
|
|
}
|
|
|
|
type streamSentinelFilter struct {
|
|
pending string
|
|
}
|
|
|
|
func (f *streamSentinelFilter) Append(delta string) string {
|
|
if delta == "" {
|
|
return ""
|
|
}
|
|
f.pending += delta
|
|
flushLen := streamSentinelSafeFlushLen(f.pending)
|
|
out := f.pending[:flushLen]
|
|
f.pending = f.pending[flushLen:]
|
|
out, _ = sanitizeKnownChatTemplateSentinels(out)
|
|
return out
|
|
}
|
|
|
|
func (f *streamSentinelFilter) Flush() string {
|
|
out := f.pending
|
|
f.pending = ""
|
|
if isKnownSentinelPrefix(out) {
|
|
return ""
|
|
}
|
|
out, _ = sanitizeKnownChatTemplateSentinels(out)
|
|
return out
|
|
}
|
|
|
|
func streamSentinelSafeFlushLen(s string) int {
|
|
hold := 0
|
|
for _, token := range knownChatTemplateSentinels {
|
|
max := len(token) - 1
|
|
if max > len(s) {
|
|
max = len(s)
|
|
}
|
|
for n := max; n > 0; n-- {
|
|
if strings.HasSuffix(s, token[:n]) {
|
|
if n > hold {
|
|
hold = n
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return len(s) - hold
|
|
}
|
|
|
|
func isKnownSentinelPrefix(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for _, token := range knownChatTemplateSentinels {
|
|
if strings.HasPrefix(token, s) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
var (
|
|
agentThinkingBlockRE = regexp.MustCompile(`(?is)<think(?:ing)?\b[^>]*>.*?</think(?:ing)?\s*>`)
|
|
agentCodeFenceLineRE = regexp.MustCompile(`(?m)^\s*` + "```" + `[A-Za-z0-9_-]*\s*$`)
|
|
agentXMLOpenTagRE = regexp.MustCompile(`(?is)<\s*([A-Za-z_][A-Za-z0-9_.:-]*)\b[^>]*>`)
|
|
agentXMLCloseTagRE = regexp.MustCompile(`(?is)</\s*([A-Za-z_][A-Za-z0-9_.:\s-]*)\s*>`)
|
|
completedUseToolRE = regexp.MustCompile("(?is)completed.{0,300}?use\\s+(?:the\\s+)?`?([A-Za-z_][A-Za-z0-9_.:-]*)`?\\s+tool")
|
|
xmlToolWithResultREPattern = `(?is)<\s*%s\b[^>]*>\s*<\s*([A-Za-z_][A-Za-z0-9_.:-]*)\b`
|
|
)
|
|
|
|
func normalizeStrictAgentContent(content string) string {
|
|
cleaned := strings.TrimSpace(content)
|
|
cleaned = agentThinkingBlockRE.ReplaceAllString(cleaned, "")
|
|
cleaned = agentCodeFenceLineRE.ReplaceAllString(cleaned, "")
|
|
cleaned = strings.TrimSpace(cleaned)
|
|
for strings.HasPrefix(cleaned, "---") {
|
|
cleaned = strings.TrimSpace(strings.TrimPrefix(cleaned, "---"))
|
|
}
|
|
cleaned = strings.TrimSpace(cleaned)
|
|
if block, ok := firstXMLRootBlock(cleaned); ok {
|
|
return block
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
func startsWithXMLRoot(content string) bool {
|
|
trimmed := strings.TrimSpace(content)
|
|
open := agentXMLOpenTagRE.FindStringIndex(trimmed)
|
|
return open != nil && open[0] == 0
|
|
}
|
|
|
|
func inferXMLCompletionContract(prompt string) (string, string) {
|
|
tool := ""
|
|
if match := completedUseToolRE.FindStringSubmatch(prompt); len(match) == 2 {
|
|
tool = match[1]
|
|
}
|
|
if tool == "" {
|
|
return "", ""
|
|
}
|
|
resultTag := inferXMLResultTag(prompt, tool)
|
|
if resultTag == "" {
|
|
resultTag = "result"
|
|
}
|
|
return tool, resultTag
|
|
}
|
|
|
|
func inferXMLResultTag(prompt, tool string) string {
|
|
toolPattern := regexp.QuoteMeta(tool)
|
|
re := regexp.MustCompile(fmt.Sprintf(xmlToolWithResultREPattern, toolPattern))
|
|
if match := re.FindStringSubmatch(prompt); len(match) == 2 {
|
|
return match[1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func wrapXMLCompletion(policy strictOutputPolicy, content string) string {
|
|
resultTag := policy.XMLResultTag
|
|
if resultTag == "" {
|
|
resultTag = "result"
|
|
}
|
|
return "<" + policy.XMLCompletionTool + ">\n<" + resultTag + ">" + html.EscapeString(strings.TrimSpace(content)) + "</" + resultTag + ">\n</" + policy.XMLCompletionTool + ">"
|
|
}
|
|
|
|
func firstXMLRootBlock(content string) (string, bool) {
|
|
open := agentXMLOpenTagRE.FindStringSubmatchIndex(content)
|
|
if open == nil {
|
|
return "", false
|
|
}
|
|
root := content[open[2]:open[3]]
|
|
search := content[open[1]:]
|
|
for _, close := range agentXMLCloseTagRE.FindAllStringSubmatchIndex(search, -1) {
|
|
name := search[close[2]:close[3]]
|
|
if canonicalXMLName(name) != canonicalXMLName(root) {
|
|
continue
|
|
}
|
|
closeStart := open[1] + close[0]
|
|
block := content[open[0]:closeStart] + "</" + root + ">"
|
|
return strings.TrimSpace(block), true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func canonicalXMLName(name string) string {
|
|
var b strings.Builder
|
|
for _, r := range strings.ToLower(name) {
|
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|