Changeset 0.15.0 (#71)
This commit is contained in:
312
server/compaction/compaction.go
Normal file
312
server/compaction/compaction.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/treepath"
|
||||
)
|
||||
|
||||
// ── Request / Result ────────────────────────
|
||||
|
||||
// CompactRequest specifies what to compact.
|
||||
type CompactRequest struct {
|
||||
ChannelID string
|
||||
UserID string // channel owner
|
||||
TeamID *string // for role resolution
|
||||
Trigger string // "manual" | "auto"
|
||||
}
|
||||
|
||||
// CompactResult describes what compaction produced.
|
||||
type CompactResult struct {
|
||||
SummaryID string
|
||||
SummarizedCount int
|
||||
Model string
|
||||
UsedFallback bool
|
||||
Content string
|
||||
InputTokens int
|
||||
OutputTokens int
|
||||
}
|
||||
|
||||
// ── Errors ──────────────────────────────────
|
||||
|
||||
var (
|
||||
// ErrContextBudget is returned when the conversation to summarize
|
||||
// exceeds the utility model's context window.
|
||||
ErrContextBudget = fmt.Errorf("conversation exceeds utility model context window")
|
||||
)
|
||||
|
||||
// ── Service ─────────────────────────────────
|
||||
|
||||
// Service provides conversation compaction (summarization).
|
||||
// Used by both the HTTP handler (manual) and the background scanner (auto).
|
||||
type Service struct {
|
||||
stores store.Stores
|
||||
resolver *roles.Resolver
|
||||
}
|
||||
|
||||
// NewService creates a compaction service.
|
||||
func NewService(stores store.Stores, resolver *roles.Resolver) *Service {
|
||||
return &Service{stores: stores, resolver: resolver}
|
||||
}
|
||||
|
||||
// Resolver exposes the underlying role resolver (for external checks like
|
||||
// IsConfigured / IsPersonalOverride).
|
||||
func (s *Service) Resolver() *roles.Resolver {
|
||||
return s.resolver
|
||||
}
|
||||
|
||||
// ── Compact ─────────────────────────────────
|
||||
|
||||
// Compact summarizes the conversation in a channel, inserting a summary
|
||||
// tree node. Returns the result or an error.
|
||||
//
|
||||
// The method:
|
||||
// 1. Loads the active path via the message tree
|
||||
// 2. Finds the most recent summary boundary
|
||||
// 3. Collects post-boundary messages
|
||||
// 4. Checks the content fits the utility model's context window
|
||||
// 5. Calls the utility model role to generate a summary
|
||||
// 6. Inserts the summary as a tree node with metadata
|
||||
// 7. Updates the user's cursor
|
||||
// 8. Logs usage
|
||||
func (s *Service) Compact(ctx context.Context, req CompactRequest) (*CompactResult, error) {
|
||||
// ── Load active path ──
|
||||
path, err := treepath.GetActivePath(req.ChannelID, req.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load conversation: %w", err)
|
||||
}
|
||||
if len(path) < 4 {
|
||||
return nil, fmt.Errorf("conversation too short to summarize")
|
||||
}
|
||||
|
||||
// Find existing summary boundary (if any) and only summarize after it
|
||||
startIdx := 0
|
||||
for i := range path {
|
||||
if treepath.IsSummaryMessage(&path[i]) {
|
||||
startIdx = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Build messages to summarize (skip system messages, skip previous summaries)
|
||||
var toSummarize []string
|
||||
messagesInScope := 0
|
||||
var lastMessageID string
|
||||
for _, m := range path[startIdx:] {
|
||||
if m.Role == "system" || treepath.IsSummaryMessage(&m) {
|
||||
continue
|
||||
}
|
||||
toSummarize = append(toSummarize, fmt.Sprintf("%s: %s", m.Role, m.Content))
|
||||
messagesInScope++
|
||||
lastMessageID = m.ID
|
||||
}
|
||||
|
||||
if messagesInScope < 4 {
|
||||
return nil, fmt.Errorf("not enough new messages since last summary")
|
||||
}
|
||||
|
||||
// ── Build the summarization prompt ──
|
||||
conversationText := strings.Join(toSummarize, "\n\n")
|
||||
systemPrompt := `You are a conversation summarizer. Create a concise but comprehensive summary of the following conversation that preserves:
|
||||
- Key decisions and conclusions reached
|
||||
- Important facts, names, numbers, and technical details mentioned
|
||||
- Action items or commitments made
|
||||
- The overall context and topic flow
|
||||
|
||||
Write the summary in a way that would allow the conversation to continue naturally. Use clear, factual language. Do not include meta-commentary about the summarization process.`
|
||||
|
||||
userContent := "Summarize this conversation:\n\n" + conversationText
|
||||
|
||||
// ── Context budget guard rail ──
|
||||
// Estimate whether the prompt fits the utility model's context window.
|
||||
// Prevents sending truncated input to small models (e.g. 32K utility
|
||||
// summarizing a 128K conversation).
|
||||
promptTokens := EstimateTokens(systemPrompt) + 4 + EstimateTokens(userContent) + 4
|
||||
utilityBudget := s.getUtilityContextBudget(ctx, req.UserID, req.TeamID)
|
||||
// Reserve 20% for output (the summary itself)
|
||||
inputCeiling := int(float64(utilityBudget) * 0.80)
|
||||
|
||||
if promptTokens > inputCeiling {
|
||||
log.Printf("⚠ Compaction skipped for channel %s: prompt ~%dK tokens exceeds utility model budget %dK (ceiling %dK)",
|
||||
req.ChannelID, promptTokens/1000, utilityBudget/1000, inputCeiling/1000)
|
||||
return nil, fmt.Errorf("%w: ~%d tokens needed, utility model allows ~%d",
|
||||
ErrContextBudget, promptTokens, inputCeiling)
|
||||
}
|
||||
|
||||
summaryPrompt := []providers.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userContent},
|
||||
}
|
||||
|
||||
// ── Call utility role ──
|
||||
log.Printf("📝 Compacting channel %s for user %s (%d messages, ~%dK tokens, trigger=%s)",
|
||||
req.ChannelID, req.UserID, messagesInScope, promptTokens/1000, req.Trigger)
|
||||
|
||||
result, err := s.resolver.Complete(ctx, roles.RoleUtility, req.UserID, req.TeamID, summaryPrompt)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Compaction failed for channel %s: %v", req.ChannelID, err)
|
||||
return nil, fmt.Errorf("summarization failed: %w", err)
|
||||
}
|
||||
|
||||
// ── Log usage ──
|
||||
s.logUsage(ctx, req.ChannelID, req.UserID, req.Trigger, result)
|
||||
|
||||
// ── Insert summary message as a tree node ──
|
||||
metadata := models.JSONMap{
|
||||
"type": "summary",
|
||||
"summarized_until_id": lastMessageID,
|
||||
"summarized_count": messagesInScope,
|
||||
"utility_model": result.Model,
|
||||
"used_fallback": result.UsedFallback,
|
||||
"trigger": req.Trigger,
|
||||
}
|
||||
|
||||
metaJSON, _ := json.Marshal(metadata)
|
||||
siblingIdx := treepath.NextSiblingIndex(req.ChannelID, &lastMessageID)
|
||||
|
||||
var summaryMsgID string
|
||||
err = database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, parent_id, role, content, model, metadata, sibling_index, participant_type)
|
||||
VALUES ($1, $2, 'assistant', $3, $4, $5, $6, 'system')
|
||||
RETURNING id
|
||||
`, req.ChannelID, lastMessageID, result.Content, result.Model, string(metaJSON), siblingIdx).Scan(&summaryMsgID)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Failed to persist summary for channel %s: %v", req.ChannelID, err)
|
||||
return nil, fmt.Errorf("failed to save summary: %w", err)
|
||||
}
|
||||
|
||||
// Update cursor to point to the summary node
|
||||
if err := treepath.UpdateCursor(req.ChannelID, req.UserID, summaryMsgID); err != nil {
|
||||
log.Printf("⚠ Failed to update cursor after compaction: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Compaction done for channel %s: %s (%d messages → %d chars, trigger=%s)",
|
||||
req.ChannelID, summaryMsgID, messagesInScope, len(result.Content), req.Trigger)
|
||||
|
||||
return &CompactResult{
|
||||
SummaryID: summaryMsgID,
|
||||
SummarizedCount: messagesInScope,
|
||||
Model: result.Model,
|
||||
UsedFallback: result.UsedFallback,
|
||||
Content: result.Content,
|
||||
InputTokens: result.InputTokens,
|
||||
OutputTokens: result.OutputTokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── Context Budget ──────────────────────────
|
||||
|
||||
// getUtilityContextBudget resolves the utility model's context window.
|
||||
// Falls back to DefaultBudget if the model isn't in the catalog.
|
||||
func (s *Service) getUtilityContextBudget(ctx context.Context, userID string, teamID *string) int {
|
||||
cfg, err := s.resolver.GetConfig(ctx, roles.RoleUtility, userID, teamID)
|
||||
if err != nil || cfg == nil || cfg.Primary == nil {
|
||||
return DefaultBudget
|
||||
}
|
||||
|
||||
// Look up the primary model in catalog
|
||||
entry, err := s.stores.Catalog.GetByModelID(ctx, cfg.Primary.ProviderConfigID, cfg.Primary.ModelID)
|
||||
if err == nil && entry.Capabilities.MaxContext > 0 {
|
||||
return entry.Capabilities.MaxContext
|
||||
}
|
||||
|
||||
// Try any-provider lookup (model may be synced under a different config)
|
||||
entry, err = s.stores.Catalog.GetByModelIDAny(ctx, cfg.Primary.ModelID)
|
||||
if err == nil && entry.Capabilities.MaxContext > 0 {
|
||||
return entry.Capabilities.MaxContext
|
||||
}
|
||||
|
||||
return DefaultBudget
|
||||
}
|
||||
|
||||
// ── Rate Limit Check ────────────────────────
|
||||
|
||||
// CheckRateLimit checks the utility rate limit for org-funded calls.
|
||||
// Returns nil if within limits, an error with a descriptive message otherwise.
|
||||
func (s *Service) CheckRateLimit(ctx context.Context, userID string) error {
|
||||
// Load rate limit from global settings (default: 20/hour, 0 = unlimited)
|
||||
limit := 20
|
||||
settings, err := s.stores.GlobalConfig.Get(ctx, "utility_rate_limit")
|
||||
if err == nil {
|
||||
if v, ok := settings["value"]; ok {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
limit = int(n)
|
||||
case int:
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if limit <= 0 {
|
||||
return nil // unlimited
|
||||
}
|
||||
|
||||
since := time.Now().Add(-1 * time.Hour)
|
||||
count, err := s.stores.Usage.CountRecentByRole(ctx, userID, roles.RoleUtility, since)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Rate limit check failed: %v", err)
|
||||
return nil // fail open — don't block on DB errors
|
||||
}
|
||||
|
||||
if count >= limit {
|
||||
return fmt.Errorf("rate limit exceeded: %d utility calls in the last hour (limit: %d)", count, limit)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
// GetUserTeamID returns the user's first team ID (for role resolution).
|
||||
func (s *Service) GetUserTeamID(ctx context.Context, userID string) *string {
|
||||
var teamID string
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT team_id FROM team_members WHERE user_id = $1 LIMIT 1
|
||||
`, userID).Scan(&teamID)
|
||||
if err != nil || teamID == "" {
|
||||
return nil
|
||||
}
|
||||
return &teamID
|
||||
}
|
||||
|
||||
func (s *Service) logUsage(ctx context.Context, channelID, userID, trigger string, result *roles.CompletionResult) {
|
||||
role := roles.RoleUtility
|
||||
entry := &models.UsageEntry{
|
||||
ChannelID: &channelID,
|
||||
UserID: userID,
|
||||
ProviderConfigID: &result.ConfigID,
|
||||
ProviderScope: result.ProviderScope,
|
||||
ModelID: result.Model,
|
||||
Role: &role,
|
||||
PromptTokens: result.InputTokens,
|
||||
CompletionTokens: result.OutputTokens,
|
||||
CacheCreationTokens: result.CacheCreationTokens,
|
||||
CacheReadTokens: result.CacheReadTokens,
|
||||
}
|
||||
|
||||
// Calculate cost from pricing
|
||||
pricing, err := s.stores.Pricing.GetForModel(ctx, result.ConfigID, result.Model)
|
||||
if err == nil && pricing != nil {
|
||||
if pricing.InputPerM != nil {
|
||||
costIn := float64(result.InputTokens) / 1_000_000 * *pricing.InputPerM
|
||||
entry.CostInput = &costIn
|
||||
}
|
||||
if pricing.OutputPerM != nil {
|
||||
costOut := float64(result.OutputTokens) / 1_000_000 * *pricing.OutputPerM
|
||||
entry.CostOutput = &costOut
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.stores.Usage.Log(ctx, entry); err != nil {
|
||||
log.Printf("⚠ Failed to log compaction usage: %v", err)
|
||||
}
|
||||
}
|
||||
401
server/compaction/compaction_test.go
Normal file
401
server/compaction/compaction_test.go
Normal file
@@ -0,0 +1,401 @@
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
)
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
func requireDB(t *testing.T) {
|
||||
t.Helper()
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
}
|
||||
|
||||
func seedChannel(t *testing.T, userID, title string, msgCount, contentSize int) (channelID string, msgIDs []string) {
|
||||
t.Helper()
|
||||
channelID = database.SeedTestChannel(t, userID, title)
|
||||
msgIDs = database.SeedTestMessages(t, channelID, msgCount, contentSize)
|
||||
if len(msgIDs) > 0 {
|
||||
database.SeedTestCursor(t, channelID, userID, msgIDs[len(msgIDs)-1])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// setGlobalSetting writes a global_settings key for scanner config tests.
|
||||
func setGlobalSetting(t *testing.T, key string, value interface{}) {
|
||||
t.Helper()
|
||||
valMap := models.JSONMap{"value": value}
|
||||
valJSON, _ := json.Marshal(valMap)
|
||||
_, err := database.DB.Exec(`
|
||||
INSERT INTO global_settings (key, value) VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2
|
||||
`, key, string(valJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("setGlobalSetting(%s): %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// touchChannelTime backdates a channel's updated_at.
|
||||
func touchChannelTime(t *testing.T, channelID string, age time.Duration) {
|
||||
t.Helper()
|
||||
target := time.Now().Add(-age)
|
||||
_, err := database.DB.Exec(`UPDATE channels SET updated_at = $1 WHERE id = $2`, target, channelID)
|
||||
if err != nil {
|
||||
t.Fatalf("touchChannelTime: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// setChannelSettings sets the channel.settings JSONB.
|
||||
func setChannelSettings(t *testing.T, channelID string, settings models.JSONMap) {
|
||||
t.Helper()
|
||||
sJSON, _ := json.Marshal(settings)
|
||||
_, err := database.DB.Exec(`UPDATE channels SET settings = $1 WHERE id = $2`, string(sJSON), channelID)
|
||||
if err != nil {
|
||||
t.Fatalf("setChannelSettings: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Estimator Tests (no DB — duplicated here for package-level access)
|
||||
// ═══════════════════════════════════════════
|
||||
// Note: estimator_test.go covers the unit tests more thoroughly.
|
||||
// These are here to verify the package compiles with DB tests.
|
||||
|
||||
func TestEstimateTokens_Basic(t *testing.T) {
|
||||
if got := EstimateTokens("hello world!"); got != 3 { // 12 chars → 3
|
||||
t.Errorf("EstimateTokens = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Scanner: Candidate Query
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
func TestScanner_FindCandidates_ReturnsQualifying(t *testing.T) {
|
||||
requireDB(t)
|
||||
stores := postgres.NewStores(database.TestDB)
|
||||
svc := NewService(stores, nil) // no resolver needed for this test
|
||||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||||
|
||||
userID := database.SeedTestUser(t, "scanuser1", "scan1@test.com")
|
||||
|
||||
// Create a channel with enough messages to qualify
|
||||
// 20 messages × 2000 chars = 40K chars (> candidateMinChars=20K)
|
||||
channelID, _ := seedChannel(t, userID, "Big Chat", 20, 2000)
|
||||
|
||||
// Backdate so it passes the activity gap (>2min old) and recency (<7 days)
|
||||
touchChannelTime(t, channelID, 5*time.Minute)
|
||||
|
||||
ctx := context.Background()
|
||||
candidates := sc.findCandidates(ctx)
|
||||
|
||||
if len(candidates) == 0 {
|
||||
t.Fatal("expected at least 1 candidate, got 0")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, c := range candidates {
|
||||
if c.ID == channelID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("channel %s not in candidates (got %d candidates)", channelID, len(candidates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanner_FindCandidates_ExcludesTooFewMessages(t *testing.T) {
|
||||
requireDB(t)
|
||||
stores := postgres.NewStores(database.TestDB)
|
||||
svc := NewService(stores, nil)
|
||||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||||
|
||||
userID := database.SeedTestUser(t, "scanuser2", "scan2@test.com")
|
||||
|
||||
// Only 4 messages — below candidateMinMessages=10
|
||||
channelID, _ := seedChannel(t, userID, "Small Chat", 4, 2000)
|
||||
touchChannelTime(t, channelID, 5*time.Minute)
|
||||
|
||||
ctx := context.Background()
|
||||
candidates := sc.findCandidates(ctx)
|
||||
|
||||
for _, c := range candidates {
|
||||
if c.ID == channelID {
|
||||
t.Fatal("channel with <10 messages should not be a candidate")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanner_FindCandidates_ExcludesTooRecent(t *testing.T) {
|
||||
requireDB(t)
|
||||
stores := postgres.NewStores(database.TestDB)
|
||||
svc := NewService(stores, nil)
|
||||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||||
|
||||
userID := database.SeedTestUser(t, "scanuser3", "scan3@test.com")
|
||||
|
||||
// Enough messages but updated just now (< candidateActivityGap=2min)
|
||||
seedChannel(t, userID, "Fresh Chat", 20, 2000)
|
||||
// Don't backdate — should be excluded
|
||||
|
||||
ctx := context.Background()
|
||||
candidates := sc.findCandidates(ctx)
|
||||
|
||||
for _, c := range candidates {
|
||||
if c.UserID == userID {
|
||||
t.Fatal("channel updated just now should not be a candidate (activity gap)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanner_FindCandidates_ExcludesArchived(t *testing.T) {
|
||||
requireDB(t)
|
||||
stores := postgres.NewStores(database.TestDB)
|
||||
svc := NewService(stores, nil)
|
||||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||||
|
||||
userID := database.SeedTestUser(t, "scanuser4", "scan4@test.com")
|
||||
channelID, _ := seedChannel(t, userID, "Archived Chat", 20, 2000)
|
||||
touchChannelTime(t, channelID, 5*time.Minute)
|
||||
|
||||
// Archive the channel
|
||||
database.DB.Exec(`UPDATE channels SET is_archived = true WHERE id = $1`, channelID)
|
||||
|
||||
ctx := context.Background()
|
||||
candidates := sc.findCandidates(ctx)
|
||||
|
||||
for _, c := range candidates {
|
||||
if c.ID == channelID {
|
||||
t.Fatal("archived channel should not be a candidate")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Scanner: Cooldown + Dedup
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
func TestScanner_Cooldown(t *testing.T) {
|
||||
sc := &Scanner{
|
||||
lastCompacted: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
channelID := "test-cooldown-channel"
|
||||
|
||||
// No cooldown initially
|
||||
sc.mu.Lock()
|
||||
_, inCooldown := sc.lastCompacted[channelID]
|
||||
sc.mu.Unlock()
|
||||
if inCooldown {
|
||||
t.Fatal("should not be in cooldown initially")
|
||||
}
|
||||
|
||||
// Record compaction
|
||||
sc.mu.Lock()
|
||||
sc.lastCompacted[channelID] = time.Now()
|
||||
sc.mu.Unlock()
|
||||
|
||||
// Now it's in cooldown
|
||||
sc.mu.Lock()
|
||||
last, ok := sc.lastCompacted[channelID]
|
||||
sc.mu.Unlock()
|
||||
if !ok {
|
||||
t.Fatal("should be in cooldown after recording")
|
||||
}
|
||||
if time.Since(last) > time.Second {
|
||||
t.Fatal("cooldown timestamp should be recent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanner_InFlightDedup(t *testing.T) {
|
||||
sc := &Scanner{}
|
||||
// sync.Map zero value is ready to use
|
||||
|
||||
channelID := "test-dedup-channel"
|
||||
|
||||
// First load — not in flight
|
||||
_, loaded := sc.inFlight.LoadOrStore(channelID, struct{}{})
|
||||
if loaded {
|
||||
t.Fatal("should not be loaded on first attempt")
|
||||
}
|
||||
|
||||
// Second load — already in flight
|
||||
_, loaded = sc.inFlight.LoadOrStore(channelID, struct{}{})
|
||||
if !loaded {
|
||||
t.Fatal("should be loaded on second attempt (dedup)")
|
||||
}
|
||||
|
||||
// Clean up
|
||||
sc.inFlight.Delete(channelID)
|
||||
_, loaded = sc.inFlight.LoadOrStore(channelID, struct{}{})
|
||||
if loaded {
|
||||
t.Fatal("should not be loaded after delete")
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Scanner: Channel Opt-Out
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
func TestScanner_ShouldCompact_ChannelOptOut(t *testing.T) {
|
||||
requireDB(t)
|
||||
stores := postgres.NewStores(database.TestDB)
|
||||
svc := NewService(stores, nil) // no resolver → IsConfigured returns false
|
||||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||||
|
||||
userID := database.SeedTestUser(t, "optout_user", "optout@test.com")
|
||||
channelID, _ := seedChannel(t, userID, "Opt-Out Chat", 20, 2000)
|
||||
|
||||
// Opt out via channel settings
|
||||
setChannelSettings(t, channelID, models.JSONMap{"auto_compaction": false})
|
||||
|
||||
ch := models.Channel{Settings: models.JSONMap{"auto_compaction": false}}
|
||||
ch.ID = channelID
|
||||
ch.UserID = userID
|
||||
|
||||
ctx := context.Background()
|
||||
if sc.shouldCompact(ctx, &ch) {
|
||||
t.Fatal("shouldCompact should return false for opted-out channel")
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Scanner: Settings Helpers
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
func TestScanner_IsEnabled(t *testing.T) {
|
||||
requireDB(t)
|
||||
stores := postgres.NewStores(database.TestDB)
|
||||
svc := NewService(stores, nil)
|
||||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||||
ctx := context.Background()
|
||||
|
||||
// Default: disabled
|
||||
if sc.isEnabled(ctx) {
|
||||
t.Fatal("should be disabled by default")
|
||||
}
|
||||
|
||||
// Enable
|
||||
setGlobalSetting(t, "auto_compaction_enabled", true)
|
||||
if !sc.isEnabled(ctx) {
|
||||
t.Fatal("should be enabled after setting to true")
|
||||
}
|
||||
|
||||
// Disable again
|
||||
setGlobalSetting(t, "auto_compaction_enabled", false)
|
||||
if sc.isEnabled(ctx) {
|
||||
t.Fatal("should be disabled after setting to false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanner_GetThreshold_Default(t *testing.T) {
|
||||
requireDB(t)
|
||||
stores := postgres.NewStores(database.TestDB)
|
||||
svc := NewService(stores, nil)
|
||||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||||
|
||||
ch := &models.Channel{}
|
||||
got := sc.getThreshold(ch)
|
||||
if got != DefaultThreshold {
|
||||
t.Errorf("default threshold = %f, want %f", got, DefaultThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanner_GetThreshold_GlobalOverride(t *testing.T) {
|
||||
requireDB(t)
|
||||
stores := postgres.NewStores(database.TestDB)
|
||||
svc := NewService(stores, nil)
|
||||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||||
|
||||
setGlobalSetting(t, "auto_compaction_threshold", 0.85)
|
||||
|
||||
ch := &models.Channel{}
|
||||
got := sc.getThreshold(ch)
|
||||
if got != 0.85 {
|
||||
t.Errorf("global threshold = %f, want 0.85", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanner_GetThreshold_ChannelOverride(t *testing.T) {
|
||||
requireDB(t)
|
||||
stores := postgres.NewStores(database.TestDB)
|
||||
svc := NewService(stores, nil)
|
||||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||||
|
||||
setGlobalSetting(t, "auto_compaction_threshold", 0.85)
|
||||
|
||||
ch := &models.Channel{
|
||||
Settings: models.JSONMap{"compaction_threshold": 0.60},
|
||||
}
|
||||
got := sc.getThreshold(ch)
|
||||
if got != 0.60 {
|
||||
t.Errorf("channel threshold = %f, want 0.60", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanner_GetCooldownDuration_Default(t *testing.T) {
|
||||
requireDB(t)
|
||||
stores := postgres.NewStores(database.TestDB)
|
||||
svc := NewService(stores, nil)
|
||||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||||
ctx := context.Background()
|
||||
|
||||
got := sc.getCooldownDuration(ctx)
|
||||
if got != DefaultCooldown {
|
||||
t.Errorf("default cooldown = %s, want %s", got, DefaultCooldown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanner_GetCooldownDuration_Override(t *testing.T) {
|
||||
requireDB(t)
|
||||
stores := postgres.NewStores(database.TestDB)
|
||||
svc := NewService(stores, nil)
|
||||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||||
ctx := context.Background()
|
||||
|
||||
setGlobalSetting(t, "auto_compaction_cooldown_minutes", float64(15))
|
||||
|
||||
got := sc.getCooldownDuration(ctx)
|
||||
want := 15 * time.Minute
|
||||
if got != want {
|
||||
t.Errorf("cooldown = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Context Budget Guard Rail
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
func TestEstimateTokens_GuardRailMath(t *testing.T) {
|
||||
// Simulate a large conversation that exceeds a 32K utility model
|
||||
// 100K chars ≈ 25K tokens of conversation + system prompt overhead
|
||||
largeContent := make([]byte, 100000)
|
||||
contentTokens := EstimateTokens(string(largeContent))
|
||||
systemTokens := EstimateTokens("You are a conversation summarizer...") + 8 // +overhead
|
||||
|
||||
totalPrompt := contentTokens + systemTokens
|
||||
utilityBudget := 32000 // 32K model
|
||||
inputCeiling := int(float64(utilityBudget) * 0.80)
|
||||
|
||||
if totalPrompt <= inputCeiling {
|
||||
t.Errorf("100K chars (%d tokens) should exceed 32K model ceiling (%d tokens)",
|
||||
totalPrompt, inputCeiling)
|
||||
}
|
||||
|
||||
// Smaller conversation should fit
|
||||
smallContent := make([]byte, 50000)
|
||||
smallTokens := EstimateTokens(string(smallContent)) + systemTokens
|
||||
if smallTokens > inputCeiling {
|
||||
t.Errorf("50K chars (%d tokens) should fit in 32K model ceiling (%d tokens)",
|
||||
smallTokens, inputCeiling)
|
||||
}
|
||||
}
|
||||
57
server/compaction/estimator.go
Normal file
57
server/compaction/estimator.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/treepath"
|
||||
)
|
||||
|
||||
// ── Token Estimation ────────────────────────
|
||||
//
|
||||
// Rough heuristic matching the frontend Tokens.estimate() (~4 chars/token).
|
||||
// Good enough for threshold checks. Swap in a real tokenizer later if
|
||||
// over/under-triggering becomes a problem.
|
||||
|
||||
// EstimateTokens returns a rough token count for a string.
|
||||
// Uses the ~4 chars/token heuristic for English (GPT/Claude average).
|
||||
func EstimateTokens(text string) int {
|
||||
return (len(text) + 3) / 4
|
||||
}
|
||||
|
||||
// EstimatePath returns total estimated tokens for a message path.
|
||||
// Adds +4 per message for role/delimiter overhead, matching the frontend.
|
||||
func EstimatePath(path []treepath.PathMessage) int {
|
||||
total := 0
|
||||
for i := range path {
|
||||
total += EstimateTokens(path[i].Content) + 4
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// EstimatePathFromSummary returns estimated tokens for messages after
|
||||
// the most recent summary boundary. Returns 0 and false if a summary
|
||||
// exists but fewer than minMessages follow it.
|
||||
func EstimatePathFromSummary(path []treepath.PathMessage, minMessages int) (tokens int, msgCount int, ok bool) {
|
||||
startIdx := 0
|
||||
for i := range path {
|
||||
if treepath.IsSummaryMessage(&path[i]) {
|
||||
startIdx = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
post := path[startIdx:]
|
||||
|
||||
// Count non-system, non-summary messages
|
||||
count := 0
|
||||
est := 0
|
||||
for i := range post {
|
||||
if post[i].Role == "system" || treepath.IsSummaryMessage(&post[i]) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
est += EstimateTokens(post[i].Content) + 4
|
||||
}
|
||||
|
||||
if count < minMessages {
|
||||
return 0, count, false
|
||||
}
|
||||
return est, count, true
|
||||
}
|
||||
92
server/compaction/estimator_test.go
Normal file
92
server/compaction/estimator_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/treepath"
|
||||
)
|
||||
|
||||
func TestEstimateTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int
|
||||
}{
|
||||
{"", 0},
|
||||
{"a", 1},
|
||||
{"abcd", 1},
|
||||
{"abcde", 2},
|
||||
{"Hello, world!", 4}, // 13 chars → ceil(13/4) = 4
|
||||
{string(make([]byte, 100)), 25},
|
||||
{string(make([]byte, 1000)), 250},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := EstimateTokens(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("EstimateTokens(%d chars) = %d, want %d", len(tt.input), got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimatePath(t *testing.T) {
|
||||
path := []treepath.PathMessage{
|
||||
{Content: string(make([]byte, 40))}, // 10 tokens + 4 overhead = 14
|
||||
{Content: string(make([]byte, 80))}, // 20 tokens + 4 overhead = 24
|
||||
{Content: string(make([]byte, 120))}, // 30 tokens + 4 overhead = 34
|
||||
}
|
||||
got := EstimatePath(path)
|
||||
// (10+4) + (20+4) + (30+4) = 72
|
||||
if got != 72 {
|
||||
t.Errorf("EstimatePath = %d, want 72", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimatePathFromSummary_NoSummary(t *testing.T) {
|
||||
path := make([]treepath.PathMessage, 10)
|
||||
for i := range path {
|
||||
path[i] = treepath.PathMessage{Role: "user", Content: string(make([]byte, 40))}
|
||||
}
|
||||
|
||||
tokens, count, ok := EstimatePathFromSummary(path, 8)
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true for 10 messages >= minMessages=8")
|
||||
}
|
||||
if count != 10 {
|
||||
t.Errorf("count = %d, want 10", count)
|
||||
}
|
||||
// Each: 10 tokens + 4 overhead = 14, × 10 = 140
|
||||
if tokens != 140 {
|
||||
t.Errorf("tokens = %d, want 140", tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimatePathFromSummary_TooFew(t *testing.T) {
|
||||
path := make([]treepath.PathMessage, 3)
|
||||
for i := range path {
|
||||
path[i] = treepath.PathMessage{Role: "user", Content: "hello"}
|
||||
}
|
||||
|
||||
_, _, ok := EstimatePathFromSummary(path, 8)
|
||||
if ok {
|
||||
t.Error("expected ok=false for 3 messages < minMessages=8")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimatePathFromSummary_SkipsSystemMessages(t *testing.T) {
|
||||
path := []treepath.PathMessage{
|
||||
{Role: "system", Content: "You are helpful."},
|
||||
{Role: "user", Content: string(make([]byte, 40))},
|
||||
{Role: "assistant", Content: string(make([]byte, 40))},
|
||||
}
|
||||
|
||||
tokens, count, ok := EstimatePathFromSummary(path, 1)
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true")
|
||||
}
|
||||
// system skipped, 2 non-system messages
|
||||
if count != 2 {
|
||||
t.Errorf("count = %d, want 2", count)
|
||||
}
|
||||
if tokens != 28 { // 2 × (10 + 4) = 28
|
||||
t.Errorf("tokens = %d, want 28", tokens)
|
||||
}
|
||||
}
|
||||
399
server/compaction/scanner.go
Normal file
399
server/compaction/scanner.go
Normal file
@@ -0,0 +1,399 @@
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/treepath"
|
||||
)
|
||||
|
||||
// ── Defaults ────────────────────────────────
|
||||
|
||||
const (
|
||||
DefaultInterval = 5 * time.Minute
|
||||
DefaultConcurrency = 2
|
||||
DefaultThreshold = 0.70
|
||||
DefaultCooldown = 30 * time.Minute
|
||||
DefaultBudget = 128000 // 128K tokens — conservative fallback
|
||||
|
||||
// Candidate query filters
|
||||
candidateMinMessages = 10
|
||||
candidateMinChars = 20000 // ~5K tokens
|
||||
candidateActivityGap = 2 * time.Minute
|
||||
candidateMaxAge = 7 * 24 * time.Hour
|
||||
candidateBatchSize = 50
|
||||
|
||||
// shouldCompact requires this many post-summary messages
|
||||
minPostSummaryMessages = 8
|
||||
)
|
||||
|
||||
// ── Scanner Config ──────────────────────────
|
||||
|
||||
// ScannerConfig holds startup configuration for the scanner.
|
||||
// Settings are re-read from global_settings each tick so changes
|
||||
// take effect without restart.
|
||||
type ScannerConfig struct {
|
||||
Interval time.Duration
|
||||
Concurrency int
|
||||
}
|
||||
|
||||
// ── Scanner ─────────────────────────────────
|
||||
|
||||
// Scanner runs a periodic background loop that finds channels exceeding
|
||||
// their context threshold and auto-compacts them via the utility model role.
|
||||
//
|
||||
// Follows the Ingester pattern: goroutine pool with semaphore, WaitGroup
|
||||
// for graceful shutdown.
|
||||
type Scanner struct {
|
||||
service *Service
|
||||
stores store.Stores
|
||||
|
||||
sem chan struct{}
|
||||
wg sync.WaitGroup
|
||||
stopCh chan struct{}
|
||||
|
||||
// Cooldown: channelID → last compaction time
|
||||
mu sync.Mutex
|
||||
lastCompacted map[string]time.Time
|
||||
|
||||
// In-flight dedup
|
||||
inFlight sync.Map // channelID → struct{}
|
||||
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
// NewScanner creates a compaction scanner. Call Start() to begin scanning.
|
||||
func NewScanner(svc *Service, stores store.Stores, cfg ScannerConfig) *Scanner {
|
||||
interval := cfg.Interval
|
||||
if interval <= 0 {
|
||||
interval = DefaultInterval
|
||||
}
|
||||
concurrency := cfg.Concurrency
|
||||
if concurrency <= 0 {
|
||||
concurrency = DefaultConcurrency
|
||||
}
|
||||
|
||||
return &Scanner{
|
||||
service: svc,
|
||||
stores: stores,
|
||||
sem: make(chan struct{}, concurrency),
|
||||
stopCh: make(chan struct{}),
|
||||
lastCompacted: make(map[string]time.Time),
|
||||
interval: interval,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the scan loop in a background goroutine.
|
||||
func (sc *Scanner) Start() {
|
||||
sc.wg.Add(1)
|
||||
go func() {
|
||||
defer sc.wg.Done()
|
||||
sc.loop()
|
||||
}()
|
||||
log.Printf("🔍 compaction scanner started (interval=%s)", sc.interval)
|
||||
}
|
||||
|
||||
// Stop signals the scanner to stop and waits for in-flight compactions
|
||||
// to drain. Safe to call from main's defer chain.
|
||||
func (sc *Scanner) Stop() {
|
||||
close(sc.stopCh)
|
||||
sc.wg.Wait()
|
||||
log.Printf("🔍 compaction scanner stopped")
|
||||
}
|
||||
|
||||
// ── Main Loop ───────────────────────────────
|
||||
|
||||
func (sc *Scanner) loop() {
|
||||
ticker := time.NewTicker(sc.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sc.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
sc.tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (sc *Scanner) tick() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Re-read kill switch each tick (no restart required)
|
||||
if !sc.isEnabled(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
candidates := sc.findCandidates(ctx)
|
||||
if len(candidates) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🔍 compaction: scanning %d candidates", len(candidates))
|
||||
|
||||
cooldown := sc.getCooldownDuration(ctx)
|
||||
|
||||
for i := range candidates {
|
||||
ch := candidates[i]
|
||||
|
||||
// Skip if in-flight
|
||||
if _, loaded := sc.inFlight.LoadOrStore(ch.ID, struct{}{}); loaded {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if in cooldown
|
||||
sc.mu.Lock()
|
||||
if last, ok := sc.lastCompacted[ch.ID]; ok && time.Since(last) < cooldown {
|
||||
sc.mu.Unlock()
|
||||
sc.inFlight.Delete(ch.ID)
|
||||
remaining := cooldown - time.Since(last)
|
||||
log.Printf("⏭ compaction: channel %s skipped (cooldown, %s remaining)", ch.ID, remaining.Round(time.Second))
|
||||
continue
|
||||
}
|
||||
sc.mu.Unlock()
|
||||
|
||||
// Precise check (loads full path, estimates tokens)
|
||||
if !sc.shouldCompact(ctx, &ch) {
|
||||
sc.inFlight.Delete(ch.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Rate limit check (auto-compaction is always org-funded)
|
||||
if err := sc.service.CheckRateLimit(ctx, ch.UserID); err != nil {
|
||||
sc.inFlight.Delete(ch.ID)
|
||||
log.Printf("⏭ compaction: channel %s skipped (rate limit)", ch.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Dispatch compaction
|
||||
sc.wg.Add(1)
|
||||
go func(ch models.Channel) {
|
||||
defer sc.wg.Done()
|
||||
defer sc.inFlight.Delete(ch.ID)
|
||||
|
||||
// Acquire semaphore
|
||||
sc.sem <- struct{}{}
|
||||
defer func() { <-sc.sem }()
|
||||
|
||||
sc.doCompact(ch)
|
||||
}(ch)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Compact One Channel ─────────────────────
|
||||
|
||||
func (sc *Scanner) doCompact(ch models.Channel) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
teamID := sc.service.GetUserTeamID(ctx, ch.UserID)
|
||||
|
||||
result, err := sc.service.Compact(ctx, CompactRequest{
|
||||
ChannelID: ch.ID,
|
||||
UserID: ch.UserID,
|
||||
TeamID: teamID,
|
||||
Trigger: "auto",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("⚠ compaction: channel %s failed: %v", ch.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Record cooldown
|
||||
sc.mu.Lock()
|
||||
sc.lastCompacted[ch.ID] = time.Now()
|
||||
sc.mu.Unlock()
|
||||
|
||||
// Audit log
|
||||
sc.stores.Audit.Log(ctx, &models.AuditEntry{
|
||||
ActorID: nil, // system action
|
||||
Action: "compaction.auto",
|
||||
ResourceType: "channel",
|
||||
ResourceID: ch.ID,
|
||||
Metadata: models.JSONMap{
|
||||
"summarized_count": result.SummarizedCount,
|
||||
"model": result.Model,
|
||||
"trigger": "auto",
|
||||
"user_id": ch.UserID,
|
||||
},
|
||||
})
|
||||
|
||||
log.Printf("✅ compaction: channel %s done (%d messages → %d chars, model=%s)",
|
||||
ch.ID, result.SummarizedCount, len(result.Content), result.Model)
|
||||
}
|
||||
|
||||
// ── Candidate Query ─────────────────────────
|
||||
|
||||
func (sc *Scanner) findCandidates(ctx context.Context) []models.Channel {
|
||||
activityGap := time.Now().Add(-candidateActivityGap)
|
||||
maxAge := time.Now().Add(-candidateMaxAge)
|
||||
|
||||
rows, err := database.DB.QueryContext(ctx, `
|
||||
SELECT c.id, c.user_id, c.model, c.settings::text,
|
||||
COUNT(m.id) AS msg_count,
|
||||
COALESCE(SUM(LENGTH(m.content)), 0) AS total_chars
|
||||
FROM channels c
|
||||
JOIN messages m ON m.channel_id = c.id AND m.deleted_at IS NULL
|
||||
WHERE c.deleted_at IS NULL
|
||||
AND c.type = 'direct'
|
||||
AND c.is_archived = false
|
||||
AND c.updated_at < $1
|
||||
AND c.updated_at > $2
|
||||
GROUP BY c.id
|
||||
HAVING COUNT(m.id) >= $3
|
||||
AND COALESCE(SUM(LENGTH(m.content)), 0) > $4
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT $5
|
||||
`, activityGap,
|
||||
maxAge,
|
||||
candidateMinMessages,
|
||||
candidateMinChars,
|
||||
candidateBatchSize,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("⚠ compaction: candidate query failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var candidates []models.Channel
|
||||
for rows.Next() {
|
||||
var ch models.Channel
|
||||
var settingsRaw string
|
||||
var msgCount, totalChars int
|
||||
if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Model, &settingsRaw, &msgCount, &totalChars); err != nil {
|
||||
log.Printf("⚠ compaction: candidate scan: %v", err)
|
||||
continue
|
||||
}
|
||||
if settingsRaw != "" {
|
||||
_ = json.Unmarshal([]byte(settingsRaw), &ch.Settings)
|
||||
}
|
||||
candidates = append(candidates, ch)
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
// ── Precise Check ───────────────────────────
|
||||
|
||||
func (sc *Scanner) shouldCompact(ctx context.Context, ch *models.Channel) bool {
|
||||
// 1. Channel-level opt-out
|
||||
if ch.Settings != nil {
|
||||
if v, ok := ch.Settings["auto_compaction"]; ok {
|
||||
if b, ok := v.(bool); ok && !b {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check utility role is configured (global level — personal overrides
|
||||
// aren't checked because auto-compaction is org-funded)
|
||||
if !sc.service.Resolver().IsConfigured(ctx, roles.RoleUtility) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 3. Load active path
|
||||
path, err := treepath.GetActivePath(ch.ID, ch.UserID)
|
||||
if err != nil || len(path) < candidateMinMessages {
|
||||
return false
|
||||
}
|
||||
|
||||
// 4. Estimate tokens from post-summary messages
|
||||
tokens, msgCount, ok := EstimatePathFromSummary(path, minPostSummaryMessages)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// 5. Compare against threshold
|
||||
budget := sc.getContextBudget(ctx, ch)
|
||||
threshold := sc.getThreshold(ch)
|
||||
ratio := float64(tokens) / float64(budget)
|
||||
|
||||
if ratio >= threshold {
|
||||
log.Printf("🔍 compaction: channel %s qualifies (%.0f%% of %dK context, %d messages post-summary)",
|
||||
ch.ID, ratio*100, budget/1000, msgCount)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Settings Helpers ────────────────────────
|
||||
|
||||
func (sc *Scanner) isEnabled(ctx context.Context) bool {
|
||||
val, err := sc.stores.GlobalConfig.Get(ctx, "auto_compaction_enabled")
|
||||
if err != nil || val == nil {
|
||||
return false // default: off
|
||||
}
|
||||
if v, ok := val["value"]; ok {
|
||||
switch b := v.(type) {
|
||||
case bool:
|
||||
return b
|
||||
case string:
|
||||
return b == "true"
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (sc *Scanner) getContextBudget(ctx context.Context, ch *models.Channel) int {
|
||||
// Try channel's model from catalog
|
||||
if ch.Model != "" {
|
||||
entry, err := sc.stores.Catalog.GetByModelIDAny(ctx, ch.Model)
|
||||
if err == nil && entry.Capabilities.MaxContext > 0 {
|
||||
return entry.Capabilities.MaxContext
|
||||
}
|
||||
}
|
||||
return DefaultBudget
|
||||
}
|
||||
|
||||
func (sc *Scanner) getThreshold(ch *models.Channel) float64 {
|
||||
// Channel override
|
||||
if ch.Settings != nil {
|
||||
if v, ok := ch.Settings["compaction_threshold"]; ok {
|
||||
switch f := v.(type) {
|
||||
case float64:
|
||||
if f > 0 && f < 1 {
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Global override
|
||||
ctx := context.Background()
|
||||
val, err := sc.stores.GlobalConfig.Get(ctx, "auto_compaction_threshold")
|
||||
if err == nil && val != nil {
|
||||
if v, ok := val["value"]; ok {
|
||||
if f, ok := v.(float64); ok && f > 0 && f < 1 {
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
return DefaultThreshold
|
||||
}
|
||||
|
||||
func (sc *Scanner) getCooldownDuration(ctx context.Context) time.Duration {
|
||||
val, err := sc.stores.GlobalConfig.Get(ctx, "auto_compaction_cooldown_minutes")
|
||||
if err == nil && val != nil {
|
||||
if v, ok := val["value"]; ok {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
if n > 0 {
|
||||
return time.Duration(n) * time.Minute
|
||||
}
|
||||
case int:
|
||||
if n > 0 {
|
||||
return time.Duration(n) * time.Minute
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return DefaultCooldown
|
||||
}
|
||||
15
server/compaction/testmain_test.go
Normal file
15
server/compaction/testmain_test.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
teardown := database.SetupTestDB()
|
||||
code := m.Run()
|
||||
teardown()
|
||||
os.Exit(code)
|
||||
}
|
||||
Reference in New Issue
Block a user