89 lines
3 KiB
Go
89 lines
3 KiB
Go
package main
|
|
|
|
import "strings"
|
|
|
|
// bufferedMsg is one prior room message the bot retained for context.
|
|
type bufferedMsg struct {
|
|
sender string
|
|
body string
|
|
isBot bool
|
|
}
|
|
|
|
// buildContext assembles the xAI message list under the owner's minimisation
|
|
// rule ("trigger + bot replies only", §6/F8):
|
|
//
|
|
// - GROUP rooms: send ONLY the bot's own prior replies (assistant turns) plus
|
|
// the single triggering message (user turn). Other participants' messages and
|
|
// display names never reach xAI — the third-party-consent mitigation.
|
|
// - 1:1 rooms: there are no third parties, so the peer's recent turns are
|
|
// included too for coherence. Still no display names (pseudo "user").
|
|
//
|
|
// `history` is the recent room window EXCLUDING the trigger; `triggerBody` is the
|
|
// message that addressed the bot. Bodies are stripped of reply-fallback quotes so
|
|
// quoted third-party text doesn't leak.
|
|
func buildContext(system string, history []bufferedMsg, isDM bool, triggerBody string, maxEvents, maxTokens int) []xaiMessage {
|
|
msgs := []xaiMessage{{Role: "system", Content: system}}
|
|
|
|
// Keep at most the last maxEvents history items.
|
|
if len(history) > maxEvents {
|
|
history = history[len(history)-maxEvents:]
|
|
}
|
|
for _, h := range history {
|
|
body := stripReplyFallback(h.body)
|
|
if body == "" {
|
|
continue
|
|
}
|
|
if h.isBot {
|
|
msgs = append(msgs, xaiMessage{Role: "assistant", Content: body})
|
|
continue
|
|
}
|
|
if isDM {
|
|
msgs = append(msgs, xaiMessage{Role: "user", Content: body})
|
|
}
|
|
// group + non-bot history → dropped (privacy minimisation)
|
|
}
|
|
|
|
msgs = append(msgs, xaiMessage{Role: "user", Content: stripReplyFallback(triggerBody)})
|
|
return truncateToTokens(msgs, maxTokens)
|
|
}
|
|
|
|
// estimateTokens is a cheap upper-ish heuristic (~4 chars/token + per-message
|
|
// overhead). Used only to bound request size, not for billing (billing reads the
|
|
// API's returned usage).
|
|
func estimateTokens(s string) int {
|
|
return len([]rune(s))/4 + 4
|
|
}
|
|
|
|
// truncateToTokens drops the oldest non-system, non-final messages until the
|
|
// estimate fits maxTokens. The system prompt (index 0) and the final user
|
|
// trigger are always preserved.
|
|
func truncateToTokens(msgs []xaiMessage, maxTokens int) []xaiMessage {
|
|
total := 0
|
|
for _, m := range msgs {
|
|
total += estimateTokens(m.Content)
|
|
}
|
|
// Drop from index 1 upward (after system), never the last (trigger).
|
|
for total > maxTokens && len(msgs) > 2 {
|
|
total -= estimateTokens(msgs[1].Content)
|
|
msgs = append(msgs[:1], msgs[2:]...)
|
|
}
|
|
return msgs
|
|
}
|
|
|
|
// stripReplyFallback removes the Matrix rich-reply fallback: leading lines that
|
|
// start with "> " (the quoted parent) followed by a blank separator line. This
|
|
// keeps quoted third-party text out of xAI and de-noises the prompt.
|
|
func stripReplyFallback(body string) string {
|
|
if !strings.HasPrefix(body, "> ") {
|
|
return strings.TrimSpace(body)
|
|
}
|
|
lines := strings.Split(body, "\n")
|
|
i := 0
|
|
for i < len(lines) && strings.HasPrefix(lines[i], ">") {
|
|
i++
|
|
}
|
|
for i < len(lines) && strings.TrimSpace(lines[i]) == "" {
|
|
i++
|
|
}
|
|
return strings.TrimSpace(strings.Join(lines[i:], "\n"))
|
|
}
|