Changeset 0.15.0 patches (#72)

This commit is contained in:
2026-02-26 23:44:44 +00:00
parent 2d79ff593b
commit e663104575
12 changed files with 76 additions and 45 deletions

View File

@@ -43,14 +43,25 @@ func setGlobalSetting(t *testing.T, key string, value interface{}) {
}
}
// touchChannelTime backdates a channel's updated_at.
func touchChannelTime(t *testing.T, channelID string, age time.Duration) {
// seedChannelBackdated creates a channel with updated_at set to (now - age).
// Uses INSERT with an explicit timestamp so the BEFORE UPDATE trigger on
// channels (which overwrites updated_at = NOW()) never fires.
func seedChannelBackdated(t *testing.T, userID, title string, age time.Duration, msgCount, contentSize int) (channelID string, msgIDs []string) {
t.Helper()
target := time.Now().Add(-age)
_, err := database.DB.Exec(`UPDATE channels SET updated_at = $1 WHERE id = $2`, target, channelID)
err := database.DB.QueryRow(`
INSERT INTO channels (user_id, title, type, updated_at)
VALUES ($1, $2, 'direct', $3)
RETURNING id
`, userID, title, target).Scan(&channelID)
if err != nil {
t.Fatalf("touchChannelTime: %v", err)
t.Fatalf("seedChannelBackdated: %v", err)
}
msgIDs = database.SeedTestMessages(t, channelID, msgCount, contentSize)
if len(msgIDs) > 0 {
database.SeedTestCursor(t, channelID, userID, msgIDs[len(msgIDs)-1])
}
return
}
// setChannelSettings sets the channel.settings JSONB.
@@ -89,10 +100,8 @@ func TestScanner_FindCandidates_ReturnsQualifying(t *testing.T) {
// 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)
// Backdated 5min so it passes activity gap (>2min) and recency (<7 days)
channelID, _ := seedChannelBackdated(t, userID, "Big Chat", 5*time.Minute, 20, 2000)
ctx := context.Background()
candidates := sc.findCandidates(ctx)
@@ -122,8 +131,7 @@ func TestScanner_FindCandidates_ExcludesTooFewMessages(t *testing.T) {
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)
channelID, _ := seedChannelBackdated(t, userID, "Small Chat", 5*time.Minute, 4, 2000)
ctx := context.Background()
candidates := sc.findCandidates(ctx)
@@ -164,8 +172,7 @@ func TestScanner_FindCandidates_ExcludesArchived(t *testing.T) {
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)
channelID, _ := seedChannelBackdated(t, userID, "Archived Chat", 5*time.Minute, 20, 2000)
// Archive the channel
database.DB.Exec(`UPDATE channels SET is_archived = true WHERE id = $1`, channelID)
@@ -377,8 +384,9 @@ func TestScanner_GetCooldownDuration_Override(t *testing.T) {
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)
// 120K chars ≈ 30K tokens of conversation + system prompt overhead
// inputCeiling = 32K * 0.80 = 25.6K → 30K exceeds it
largeContent := make([]byte, 120000)
contentTokens := EstimateTokens(string(largeContent))
systemTokens := EstimateTokens("You are a conversation summarizer...") + 8 // +overhead
@@ -387,7 +395,7 @@ func TestEstimateTokens_GuardRailMath(t *testing.T) {
inputCeiling := int(float64(utilityBudget) * 0.80)
if totalPrompt <= inputCeiling {
t.Errorf("100K chars (%d tokens) should exceed 32K model ceiling (%d tokens)",
t.Errorf("120K chars (%d tokens) should exceed 32K model ceiling (%d tokens)",
totalPrompt, inputCeiling)
}

View File

@@ -237,13 +237,12 @@ func (sc *Scanner) findCandidates(ctx context.Context) []models.Channel {
maxAge := time.Now().Add(-candidateMaxAge)
rows, err := database.DB.QueryContext(ctx, `
SELECT c.id, c.user_id, c.model, c.settings::text,
SELECT c.id, c.user_id, COALESCE(c.model, ''), COALESCE(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'
WHERE c.type = 'direct'
AND c.is_archived = false
AND c.updated_at < $1
AND c.updated_at > $2

View File

@@ -829,18 +829,6 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm
return messages, nil
}
// isSummaryMessage checks if a PathMessage has summary metadata.
func isSummaryMessage(m *PathMessage) bool {
if m.Metadata == nil {
return false
}
var meta map[string]interface{}
if err := json.Unmarshal(*m.Metadata, &meta); err != nil {
return false
}
return meta["type"] == "summary"
}
// ── Message Persistence ─────────────────────
// persistMessage inserts a message into the tree, updates the cursor, and

View File

@@ -33,7 +33,7 @@ func (h *SummarizeHandler) Summarize(c *gin.Context) {
// ── Verify channel ownership ──
var ownerID string
err := database.DB.QueryRow(
`SELECT user_id FROM channels WHERE id = $1 AND deleted_at IS NULL`, channelID,
`SELECT user_id FROM channels WHERE id = $1`, channelID,
).Scan(&ownerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})

View File

@@ -11,6 +11,7 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/compaction"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
@@ -248,8 +249,9 @@ func main() {
protected.POST("/chat/completions", comp.Complete)
protected.GET("/tools", comp.ListTools)
// Summarize & Continue
summarize := handlers.NewSummarizeHandler(stores, roleResolver)
// Summarize & Continue (backed by compaction service)
compactionSvc := compaction.NewService(stores, roleResolver)
summarize := handlers.NewSummarizeHandler(compactionSvc)
protected.POST("/channels/:id/summarize", summarize.Summarize)
// Provider Configs (user-facing — replaces /api-configs)
@@ -400,6 +402,7 @@ func main() {
admin.PUT("/users/:id/role", adm.UpdateUserRole)
admin.PUT("/users/:id/active", adm.ToggleUserActive)
admin.POST("/users/:id/reset-password", adm.ResetPassword)
admin.POST("/users/:id/vault/reset", adm.ResetVault)
admin.DELETE("/users/:id", adm.DeleteUser)
// Global settings

View File

@@ -279,6 +279,9 @@ func (r *Resolver) resolveBinding(ctx context.Context, binding *RoleBinding) (pr
if err != nil {
return nil, providers.ProviderConfig{}, "", fmt.Errorf("decrypt API key: %w", err)
}
} else if len(cfg.APIKeyEnc) > 0 {
// No vault — key stored as raw bytes (unencrypted fallback)
apiKey = string(cfg.APIKeyEnc)
}
// Parse headers