130 lines
4.0 KiB
Go
130 lines
4.0 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"time"
|
|
|
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
|
)
|
|
|
|
// ── Late Registration ────────────────────────
|
|
|
|
func RegisterConversationSearch(stores store.Stores) {
|
|
Register(&conversationSearchTool{stores: stores})
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// conversation_search
|
|
// ═══════════════════════════════════════════
|
|
|
|
type conversationSearchTool struct {
|
|
BaseTool
|
|
stores store.Stores
|
|
}
|
|
|
|
func (t *conversationSearchTool) Definition() ToolDef {
|
|
return ToolDef{
|
|
Name: "conversation_search",
|
|
DisplayName: "Search Chat",
|
|
Category: "context",
|
|
Description: "Search earlier messages in this conversation by keyword. " +
|
|
"Useful when the conversation is long or has been summarized and " +
|
|
"you need to find specific details from earlier discussion. " +
|
|
"Returns matching message excerpts with timestamps.",
|
|
Parameters: JSONSchema(map[string]interface{}{
|
|
"query": Prop("string", "Search keywords — use terms likely to appear in the messages"),
|
|
"max_results": map[string]interface{}{
|
|
"type": "integer",
|
|
"description": "Maximum results to return (1-20, default 5)",
|
|
},
|
|
"role_filter": PropEnum(
|
|
"Filter by message role (default: all)",
|
|
"all", "user", "assistant",
|
|
),
|
|
}, []string{"query"}),
|
|
}
|
|
}
|
|
|
|
func (t *conversationSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
|
var args struct {
|
|
Query string `json:"query"`
|
|
MaxResults int `json:"max_results"`
|
|
RoleFilter string `json:"role_filter"`
|
|
}
|
|
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
|
return "", fmt.Errorf("invalid arguments: %w", err)
|
|
}
|
|
|
|
if args.Query == "" {
|
|
return "", fmt.Errorf("query is required")
|
|
}
|
|
if args.MaxResults <= 0 || args.MaxResults > 20 {
|
|
args.MaxResults = 5
|
|
}
|
|
|
|
// Build query — full-text search scoped to this channel
|
|
// Uses on-the-fly to_tsvector (no index needed — channel-scoped is bounded)
|
|
roleClause := ""
|
|
queryArgs := []interface{}{execCtx.ChannelID, args.Query, args.MaxResults}
|
|
if args.RoleFilter == "user" || args.RoleFilter == "assistant" {
|
|
roleClause = "AND m.role = $4"
|
|
queryArgs = append(queryArgs, args.RoleFilter)
|
|
}
|
|
|
|
rows, err := database.DB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT m.id, m.role,
|
|
ts_headline('english', m.content, plainto_tsquery('english', $2),
|
|
'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline,
|
|
ts_rank(to_tsvector('english', m.content), plainto_tsquery('english', $2)) AS rank,
|
|
m.created_at
|
|
FROM messages m
|
|
WHERE m.channel_id = $1
|
|
AND m.deleted_at IS NULL
|
|
AND m.role IN ('user', 'assistant')
|
|
AND to_tsvector('english', m.content) @@ plainto_tsquery('english', $2)
|
|
%s
|
|
ORDER BY rank DESC, m.created_at DESC
|
|
LIMIT $3
|
|
`, roleClause), queryArgs...)
|
|
if err != nil {
|
|
return "", fmt.Errorf("search failed: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
type searchResult struct {
|
|
MessageID string `json:"message_id"`
|
|
Role string `json:"role"`
|
|
Excerpt string `json:"excerpt"`
|
|
Rank float64 `json:"rank"`
|
|
Timestamp string `json:"timestamp"`
|
|
}
|
|
|
|
results := make([]searchResult, 0)
|
|
for rows.Next() {
|
|
var r searchResult
|
|
var rank float64
|
|
var createdAt time.Time
|
|
if err := rows.Scan(&r.MessageID, &r.Role, &r.Excerpt, &rank, &createdAt); err != nil {
|
|
continue
|
|
}
|
|
r.Rank = rank
|
|
r.Timestamp = createdAt.Format(time.RFC3339)
|
|
results = append(results, r)
|
|
}
|
|
|
|
log.Printf("🔍 conversation_search: %q → %d results in channel %s",
|
|
args.Query, len(results), execCtx.ChannelID)
|
|
|
|
out, _ := json.Marshal(map[string]interface{}{
|
|
"results": results,
|
|
"query": args.Query,
|
|
"total": len(results),
|
|
"channel_id": execCtx.ChannelID,
|
|
})
|
|
return string(out), nil
|
|
}
|