short_retrieval.go: Check Atoi error even though regex ensures numeric input. gateway.go: Log warning when gateway config JSON is malformed instead of silently using defaults.
215 lines
6.2 KiB
Go
215 lines
6.2 KiB
Go
package seahorse
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ParseLastDuration parses a "last" duration string like "6h", "7d", "2w", "1m".
|
|
// Returns the duration and nil error, or zero and error if invalid.
|
|
func ParseLastDuration(s string) (time.Duration, error) {
|
|
if s == "" {
|
|
return 0, fmt.Errorf("empty duration")
|
|
}
|
|
|
|
re := regexp.MustCompile(`^(\d+)([hdwm])$`)
|
|
matches := re.FindStringSubmatch(s)
|
|
if matches == nil {
|
|
return 0, fmt.Errorf("invalid duration format: %q (use format like 6h, 7d, 2w, 1m)", s)
|
|
}
|
|
|
|
value, err := strconv.Atoi(matches[1])
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid duration value: %q", matches[1])
|
|
}
|
|
unit := matches[2]
|
|
|
|
switch unit {
|
|
case "h":
|
|
return time.Duration(value) * time.Hour, nil
|
|
case "d":
|
|
return time.Duration(value) * 24 * time.Hour, nil
|
|
case "w":
|
|
return time.Duration(value) * 7 * 24 * time.Hour, nil
|
|
case "m":
|
|
return time.Duration(value) * 30 * 24 * time.Hour, nil
|
|
default:
|
|
return 0, fmt.Errorf("unknown unit: %q", unit)
|
|
}
|
|
}
|
|
|
|
// GrepInput controls search across summaries and messages.
|
|
type GrepInput struct {
|
|
Pattern string `json:"pattern"`
|
|
Scope string `json:"scope,omitempty"` // "both" (default), "summary", or "message"
|
|
Role string `json:"role,omitempty"` // "user", "assistant", or "" (all)
|
|
AllConversations bool `json:"allConversations,omitempty"`
|
|
Since *time.Time `json:"since,omitempty"`
|
|
Before *time.Time `json:"before,omitempty"`
|
|
Last string `json:"last,omitempty"` // shortcut: "6h", "7d", "2w", "1m"
|
|
Limit int `json:"limit,omitempty"`
|
|
}
|
|
|
|
// GrepResult contains search results.
|
|
type GrepResult struct {
|
|
Success bool `json:"success"`
|
|
Summaries []GrepSummaryResult `json:"summaries"`
|
|
Messages []GrepMessageResult `json:"messages"`
|
|
TotalSummaries int `json:"totalSummaries"`
|
|
TotalMessages int `json:"totalMessages"`
|
|
Hint string `json:"hint,omitempty"`
|
|
}
|
|
|
|
// GrepSummaryResult is a summary match from grep.
|
|
type GrepSummaryResult struct {
|
|
ID string `json:"id"`
|
|
Content string `json:"content"`
|
|
Depth int `json:"depth"`
|
|
Kind SummaryKind `json:"kind"`
|
|
ConversationID int64 `json:"conversationId"`
|
|
// Rank is the bm25 relevance score (negative value, lower = better match).
|
|
// Examples: -5.0 = excellent match, -2.0 = good match, -0.5 = partial match.
|
|
Rank float64 `json:"rank,omitempty"`
|
|
}
|
|
|
|
// GrepMessageResult is a message match from grep.
|
|
type GrepMessageResult struct {
|
|
ID int64 `json:"id,string"`
|
|
Snippet string `json:"snippet"`
|
|
Role string `json:"role"`
|
|
ConversationID int64 `json:"conversationId"`
|
|
Rank float64 `json:"rank,omitempty"` // Relevance score (more negative = better match)
|
|
}
|
|
|
|
// ExpandMessagesResult contains expanded messages.
|
|
type ExpandMessagesResult struct {
|
|
Messages []Message `json:"messages"`
|
|
TokenCount int `json:"tokenCount"`
|
|
}
|
|
|
|
// Grep searches summaries and messages for matching content.
|
|
func (r *RetrievalEngine) Grep(ctx context.Context, input GrepInput) (*GrepResult, error) {
|
|
if input.Pattern == "" {
|
|
return nil, fmt.Errorf("grep: pattern is required")
|
|
}
|
|
|
|
limit := input.Limit
|
|
if limit == 0 {
|
|
limit = 20
|
|
}
|
|
|
|
// Handle Last parameter: convert to Since
|
|
since := input.Since
|
|
if input.Last != "" {
|
|
dur, err := ParseLastDuration(input.Last)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("grep: invalid last: %w", err)
|
|
}
|
|
t := time.Now().UTC().Add(-dur)
|
|
since = &t
|
|
}
|
|
|
|
// Auto-detect mode: use LIKE if pattern contains %, otherwise full-text
|
|
mode := ""
|
|
if strings.Contains(input.Pattern, "%") {
|
|
mode = "like"
|
|
}
|
|
|
|
searchInput := SearchInput{
|
|
Pattern: input.Pattern,
|
|
Mode: mode,
|
|
Role: input.Role,
|
|
AllConversations: input.AllConversations,
|
|
Since: since,
|
|
Before: input.Before,
|
|
Limit: limit,
|
|
}
|
|
|
|
result := &GrepResult{
|
|
Success: true,
|
|
Summaries: make([]GrepSummaryResult, 0),
|
|
Messages: make([]GrepMessageResult, 0),
|
|
TotalSummaries: 0,
|
|
TotalMessages: 0,
|
|
}
|
|
|
|
// Determine scope
|
|
scope := input.Scope
|
|
if scope == "" {
|
|
scope = "both"
|
|
}
|
|
|
|
// Search summaries if requested
|
|
if scope == "both" || scope == "summary" {
|
|
sumResults, err := r.store.SearchSummaries(ctx, searchInput)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("search summaries: %w", err)
|
|
}
|
|
for _, sr := range sumResults {
|
|
if sr.SummaryID != "" {
|
|
result.Summaries = append(result.Summaries, GrepSummaryResult{
|
|
ID: sr.SummaryID,
|
|
Content: sr.Content,
|
|
Depth: sr.Depth,
|
|
Kind: sr.Kind,
|
|
ConversationID: sr.ConversationID,
|
|
Rank: sr.Rank,
|
|
})
|
|
}
|
|
}
|
|
if len(sumResults) > 0 {
|
|
result.TotalSummaries = sumResults[0].TotalCount
|
|
}
|
|
}
|
|
|
|
// Search messages if requested
|
|
if scope == "both" || scope == "message" {
|
|
msgResults, err := r.store.SearchMessages(ctx, searchInput)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("search messages: %w", err)
|
|
}
|
|
for _, sr := range msgResults {
|
|
if sr.MessageID > 0 {
|
|
result.Messages = append(result.Messages, GrepMessageResult{
|
|
ID: sr.MessageID,
|
|
Snippet: sr.Snippet,
|
|
Role: sr.Role,
|
|
ConversationID: sr.ConversationID,
|
|
Rank: sr.Rank,
|
|
})
|
|
}
|
|
}
|
|
if len(msgResults) > 0 {
|
|
result.TotalMessages = msgResults[0].TotalCount
|
|
}
|
|
}
|
|
|
|
// Add hint if no results
|
|
if len(result.Summaries) == 0 && len(result.Messages) == 0 {
|
|
result.Hint = "No matches. Try: %keyword% for fuzzy search, or all_conversations: true"
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// ExpandMessages retrieves full message content by IDs.
|
|
func (r *RetrievalEngine) ExpandMessages(ctx context.Context, messageIDs []int64) (*ExpandMessagesResult, error) {
|
|
result := &ExpandMessagesResult{
|
|
Messages: make([]Message, 0, len(messageIDs)),
|
|
}
|
|
|
|
for _, msgID := range messageIDs {
|
|
msg, err := r.store.GetMessageByID(ctx, msgID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
result.Messages = append(result.Messages, *msg)
|
|
result.TokenCount += msg.TokenCount
|
|
}
|
|
|
|
return result, nil
|
|
}
|