Changeset 0.20.0 (#85)
This commit is contained in:
155
server/mentions/parser.go
Normal file
155
server/mentions/parser.go
Normal 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, " ")
|
||||
}
|
||||
213
server/mentions/parser_test.go
Normal file
213
server/mentions/parser_test.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package mentions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
func roster() []models.ChannelModel {
|
||||
return []models.ChannelModel{
|
||||
{ID: "1", ChannelID: "ch1", ModelID: "anthropic/claude-3-opus", DisplayName: "Claude-3-Opus", IsDefault: true},
|
||||
{ID: "2", ChannelID: "ch1", ModelID: "openai/gpt-4", DisplayName: "GPT-4"},
|
||||
{ID: "3", ChannelID: "ch1", ModelID: "anthropic/claude-3-haiku", DisplayName: "Claude-3-Haiku"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_NoMentions(t *testing.T) {
|
||||
m := Parse("Hello, how are you?", roster())
|
||||
if len(m) != 0 {
|
||||
t.Fatalf("expected 0 mentions, got %d", len(m))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_EmptyRoster(t *testing.T) {
|
||||
m := Parse("@gpt-4 hello", nil)
|
||||
if len(m) != 0 {
|
||||
t.Fatalf("expected 0 mentions with empty roster, got %d", len(m))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_SingleMention(t *testing.T) {
|
||||
m := Parse("@GPT-4 what do you think?", roster())
|
||||
if len(m) != 1 {
|
||||
t.Fatalf("expected 1 mention, got %d", len(m))
|
||||
}
|
||||
if m[0].Name != "GPT-4" {
|
||||
t.Errorf("expected Name='GPT-4', got %q", m[0].Name)
|
||||
}
|
||||
if m[0].Resolved == nil {
|
||||
t.Fatal("expected resolved, got nil")
|
||||
}
|
||||
if m[0].Resolved.ID != "2" {
|
||||
t.Errorf("expected resolved to GPT-4 (ID=2), got ID=%s", m[0].Resolved.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_CaseInsensitive(t *testing.T) {
|
||||
m := Parse("@claude-3-opus please help", roster())
|
||||
if len(m) != 1 || m[0].Resolved == nil {
|
||||
t.Fatal("expected 1 resolved mention")
|
||||
}
|
||||
if m[0].Resolved.ID != "1" {
|
||||
t.Errorf("expected Claude-3-Opus (ID=1), got ID=%s", m[0].Resolved.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_MultipleMentions(t *testing.T) {
|
||||
m := Parse("Hey @Claude-3-Opus and @GPT-4, compare your approaches.", roster())
|
||||
if len(m) != 2 {
|
||||
t.Fatalf("expected 2 mentions, got %d", len(m))
|
||||
}
|
||||
if m[0].Resolved == nil || m[0].Resolved.ID != "1" {
|
||||
t.Errorf("first mention: expected Claude-3-Opus")
|
||||
}
|
||||
if m[1].Resolved == nil || m[1].Resolved.ID != "2" {
|
||||
t.Errorf("second mention: expected GPT-4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_UnresolvedMention(t *testing.T) {
|
||||
m := Parse("@nonexistent-model hello", roster())
|
||||
if len(m) != 1 {
|
||||
t.Fatalf("expected 1 mention, got %d", len(m))
|
||||
}
|
||||
if m[0].Resolved != nil {
|
||||
t.Errorf("expected unresolved mention, got resolved to %s", m[0].Resolved.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_MixedResolvedUnresolved(t *testing.T) {
|
||||
m := Parse("@GPT-4 and @fake-model what?", roster())
|
||||
if len(m) != 2 {
|
||||
t.Fatalf("expected 2 mentions, got %d", len(m))
|
||||
}
|
||||
if m[0].Resolved == nil {
|
||||
t.Error("first mention should resolve")
|
||||
}
|
||||
if m[1].Resolved != nil {
|
||||
t.Error("second mention should not resolve")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_MentionAtStart(t *testing.T) {
|
||||
m := Parse("@GPT-4", roster())
|
||||
if len(m) != 1 || m[0].Resolved == nil {
|
||||
t.Fatal("expected 1 resolved mention at start")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_MentionInMiddle(t *testing.T) {
|
||||
m := Parse("Ask @GPT-4 about this", roster())
|
||||
if len(m) != 1 || m[0].Resolved == nil {
|
||||
t.Fatal("expected 1 resolved mention in middle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_NoSpaceBefore(t *testing.T) {
|
||||
// email@GPT-4 should NOT be parsed as a mention
|
||||
m := Parse("email@GPT-4 test", roster())
|
||||
if len(m) != 0 {
|
||||
t.Fatalf("expected 0 mentions (no space before @), got %d", len(m))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_BareAt(t *testing.T) {
|
||||
m := Parse("@ nothing", roster())
|
||||
if len(m) != 0 {
|
||||
t.Fatalf("expected 0 mentions for bare @, got %d", len(m))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_HyphenSpaceNormalization(t *testing.T) {
|
||||
// Display name "Claude-3-Opus" should match "@claude_3_opus" (underscore normalization)
|
||||
m := Parse("@claude_3_opus help", roster())
|
||||
if len(m) != 1 || m[0].Resolved == nil {
|
||||
t.Fatal("expected underscore-normalized match")
|
||||
}
|
||||
if m[0].Resolved.ID != "1" {
|
||||
t.Errorf("expected Claude-3-Opus, got %s", m[0].Resolved.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_ByteOffsets(t *testing.T) {
|
||||
content := "Hey @GPT-4 ok"
|
||||
m := Parse(content, roster())
|
||||
if len(m) != 1 {
|
||||
t.Fatal("expected 1 mention")
|
||||
}
|
||||
if m[0].Start != 4 || m[0].End != 10 {
|
||||
t.Errorf("expected offsets [4,10), got [%d,%d)", m[0].Start, m[0].End)
|
||||
}
|
||||
if content[m[0].Start:m[0].End] != "@GPT-4" {
|
||||
t.Errorf("offsets don't match: %q", content[m[0].Start:m[0].End])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_TrailingPunctuation(t *testing.T) {
|
||||
// Comma, period, exclamation should be stripped
|
||||
tests := []struct {
|
||||
input string
|
||||
want int // expected resolved count
|
||||
}{
|
||||
{"@GPT-4, what?", 1},
|
||||
{"@GPT-4.", 1},
|
||||
{"@GPT-4!", 1},
|
||||
{"@GPT-4? help", 1},
|
||||
{"(@GPT-4)", 0}, // ( before @ is not whitespace — not a mention
|
||||
{"( @GPT-4)", 1}, // space before @ — valid mention, ) stripped
|
||||
}
|
||||
for _, tt := range tests {
|
||||
m := Parse(tt.input, roster())
|
||||
resolved := 0
|
||||
for _, mm := range m {
|
||||
if mm.Resolved != nil {
|
||||
resolved++
|
||||
}
|
||||
}
|
||||
if resolved != tt.want {
|
||||
t.Errorf("Parse(%q): expected %d resolved, got %d", tt.input, tt.want, resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedModels_Dedup(t *testing.T) {
|
||||
m := Parse("@GPT-4 do this @GPT-4 and that", roster())
|
||||
resolved := ResolvedModels(m)
|
||||
if len(resolved) != 1 {
|
||||
t.Fatalf("expected 1 deduplicated model, got %d", len(resolved))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedModels_NilOnNoResolve(t *testing.T) {
|
||||
m := Parse("@unknown-model test", roster())
|
||||
resolved := ResolvedModels(m)
|
||||
if resolved != nil {
|
||||
t.Fatalf("expected nil for unresolved only, got %d", len(resolved))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedModels_MultipleDistinct(t *testing.T) {
|
||||
m := Parse("@GPT-4 and @Claude-3-Opus compare", roster())
|
||||
resolved := ResolvedModels(m)
|
||||
if len(resolved) != 2 {
|
||||
t.Fatalf("expected 2 distinct models, got %d", len(resolved))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"Claude-3-Opus", "claude 3 opus"},
|
||||
{"GPT-4", "gpt 4"},
|
||||
{"gpt_4", "gpt 4"},
|
||||
{" mixed--Case__Name ", "mixed case name"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := normalize(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalize(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user