Changeset 0.20.0 (#85)

This commit is contained in:
2026-03-01 12:40:15 +00:00
parent eb74180611
commit 817062e5fc
57 changed files with 5435 additions and 72 deletions

155
server/mentions/parser.go Normal file
View File

@@ -0,0 +1,155 @@
package mentions
import (
"sort"
"strings"
"unicode"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// Mention represents a parsed @mention token in message content.
type Mention struct {
Raw string // "@claude-3-opus" as written (includes @)
Name string // "claude-3-opus" (normalized, no @)
Start int // byte offset in message content
End int // byte offset end (exclusive)
Resolved *models.ChannelModel // nil if unresolved
}
// Parse extracts @mentions from message content and resolves them
// against the channel's model roster.
//
// Rules:
// - @ followed by one or more non-whitespace characters
// - Greedy: @claude-3-opus-20240229 matches the full token
// - Resolution: case-insensitive match against display_name with
// hyphens/spaces normalized (e.g. "claude 3 opus" matches "Claude-3-Opus")
// - Longest-match-first when display names overlap
// - Unresolved mentions are included with Resolved = nil
// - @ must be at start of content or preceded by whitespace
func Parse(content string, roster []models.ChannelModel) []Mention {
if len(roster) == 0 || !strings.Contains(content, "@") {
return nil
}
// Build lookup: normalized display_name → *ChannelModel
// Sort roster by display_name length descending for longest-match-first
type entry struct {
normalized string
model models.ChannelModel
}
entries := make([]entry, 0, len(roster))
for _, cm := range roster {
if cm.DisplayName == "" {
continue
}
entries = append(entries, entry{
normalized: normalize(cm.DisplayName),
model: cm,
})
}
sort.Slice(entries, func(i, j int) bool {
return len(entries[i].normalized) > len(entries[j].normalized)
})
// Extract @tokens
var mentions []Mention
i := 0
for i < len(content) {
if content[i] != '@' {
i++
continue
}
// @ must be at start or preceded by whitespace
if i > 0 && !unicode.IsSpace(rune(content[i-1])) {
i++
continue
}
// Extract the token after @
start := i
i++ // skip @
tokenStart := i
for i < len(content) && !unicode.IsSpace(rune(content[i])) {
i++
}
if i == tokenStart {
continue // bare @ with no token
}
// Strip trailing punctuation (commas, periods, etc.)
tokenEnd := i
for tokenEnd > tokenStart && isMentionTrailingPunct(content[tokenEnd-1]) {
tokenEnd--
}
if tokenEnd == tokenStart {
continue // all punctuation
}
raw := content[start:tokenEnd]
name := content[tokenStart:tokenEnd]
normalizedName := normalize(name)
// Try to resolve against roster (longest match first)
var resolved *models.ChannelModel
for idx := range entries {
if normalizedName == entries[idx].normalized {
cm := entries[idx].model
resolved = &cm
break
}
}
mentions = append(mentions, Mention{
Raw: raw,
Name: name,
Start: start,
End: tokenEnd,
Resolved: resolved,
})
}
return mentions
}
// ResolvedModels returns deduplicated resolved models from parsed mentions.
// Returns nil if no mentions resolved.
func ResolvedModels(mentions []Mention) []models.ChannelModel {
if len(mentions) == 0 {
return nil
}
seen := make(map[string]bool)
var result []models.ChannelModel
for _, m := range mentions {
if m.Resolved != nil && !seen[m.Resolved.ID] {
seen[m.Resolved.ID] = true
result = append(result, *m.Resolved)
}
}
return result
}
// isMentionTrailingPunct returns true for punctuation that commonly
// follows an @mention but isn't part of the name.
func isMentionTrailingPunct(b byte) bool {
switch b {
case ',', '.', '!', '?', ':', ';', ')', ']', '}':
return true
}
return false
}
// normalize converts a display name to a canonical form for matching:
// lowercase, hyphens → spaces collapsed, trimmed.
func normalize(s string) string {
s = strings.ToLower(s)
s = strings.ReplaceAll(s, "-", " ")
s = strings.ReplaceAll(s, "_", " ")
// Collapse multiple spaces
fields := strings.Fields(s)
return strings.Join(fields, " ")
}