vojo/apps/ai-bot/context.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"))
}