Changeset 0.15.1 (#73)
This commit is contained in:
184
server/tools/attachment_recall.go
Normal file
184
server/tools/attachment_recall.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/storage"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Late Registration ────────────────────────
|
||||
// attachment_recall needs stores + objStore, which aren't available at
|
||||
// init time. Called from main.go after storage init.
|
||||
// If objStore is nil (storage not configured), the tool is not registered.
|
||||
|
||||
func RegisterAttachmentRecall(stores store.Stores, objStore storage.ObjectStore) {
|
||||
if objStore == nil {
|
||||
log.Printf("ℹ attachment_recall: storage not configured, tool not registered")
|
||||
return
|
||||
}
|
||||
Register(&attachmentRecallTool{
|
||||
stores: stores,
|
||||
objStore: objStore,
|
||||
})
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// attachment_recall
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
const maxImageBytes = 10 * 1024 * 1024 // 10 MB cap for base64 images
|
||||
|
||||
type attachmentRecallTool struct {
|
||||
stores store.Stores
|
||||
objStore storage.ObjectStore
|
||||
}
|
||||
|
||||
func (t *attachmentRecallTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "attachment_recall",
|
||||
DisplayName: "Attachments",
|
||||
Category: "context",
|
||||
Description: "Re-read file attachments from this conversation. " +
|
||||
"Use action='list' to see available files, then action='read' " +
|
||||
"with an attachment_id to retrieve the file content. " +
|
||||
"Documents return extracted text; images return base64 data.",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"action": PropEnum("Action: 'list' to see files, 'read' to get content", "list", "read"),
|
||||
"attachment_id": Prop("string", "Attachment ID to read (required for action='read')"),
|
||||
}, []string{"action"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *attachmentRecallTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
Action string `json:"action"`
|
||||
AttachmentID string `json:"attachment_id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
switch args.Action {
|
||||
case "list":
|
||||
return t.listAttachments(ctx, execCtx)
|
||||
case "read":
|
||||
if args.AttachmentID == "" {
|
||||
return "", fmt.Errorf("attachment_id is required for action='read'")
|
||||
}
|
||||
return t.readAttachment(ctx, execCtx, args.AttachmentID)
|
||||
default:
|
||||
return "", fmt.Errorf("invalid action: %q (use 'list' or 'read')", args.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// listAttachments returns metadata for all attachments in the channel.
|
||||
func (t *attachmentRecallTool) listAttachments(ctx context.Context, execCtx ExecutionContext) (string, error) {
|
||||
atts, err := t.stores.Attachments.GetByChannel(ctx, execCtx.ChannelID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list attachments: %w", err)
|
||||
}
|
||||
|
||||
type attInfo struct {
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
HasText bool `json:"has_text"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
items := make([]attInfo, 0, len(atts))
|
||||
for _, a := range atts {
|
||||
items = append(items, attInfo{
|
||||
ID: a.ID,
|
||||
Filename: a.Filename,
|
||||
ContentType: a.ContentType,
|
||||
SizeBytes: a.SizeBytes,
|
||||
HasText: a.ExtractedText != nil && *a.ExtractedText != "",
|
||||
CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("📎 attachment_recall: list → %d files in channel %s", len(items), execCtx.ChannelID)
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"attachments": items,
|
||||
"count": len(items),
|
||||
"channel_id": execCtx.ChannelID,
|
||||
})
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// readAttachment returns the content of a specific attachment.
|
||||
func (t *attachmentRecallTool) readAttachment(ctx context.Context, execCtx ExecutionContext, attachmentID string) (string, error) {
|
||||
att, err := t.stores.Attachments.GetByID(ctx, attachmentID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("attachment not found: %s", attachmentID)
|
||||
}
|
||||
|
||||
// Security: verify attachment belongs to this channel
|
||||
if att.ChannelID != execCtx.ChannelID {
|
||||
return "", fmt.Errorf("attachment %s does not belong to this conversation", attachmentID)
|
||||
}
|
||||
|
||||
// Documents: return extracted text
|
||||
if !isImageType(att.ContentType) {
|
||||
if att.ExtractedText != nil && *att.ExtractedText != "" {
|
||||
log.Printf("📎 attachment_recall: read text → %s (%s, %d chars)",
|
||||
att.Filename, att.ID, len(*att.ExtractedText))
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"id": att.ID,
|
||||
"filename": att.Filename,
|
||||
"content_type": att.ContentType,
|
||||
"content": *att.ExtractedText,
|
||||
"content_type_returned": "text",
|
||||
})
|
||||
return string(out), nil
|
||||
}
|
||||
return "", fmt.Errorf("no extracted text available for %s (content type: %s)", att.Filename, att.ContentType)
|
||||
}
|
||||
|
||||
// Images: read from storage and return base64
|
||||
if att.SizeBytes > maxImageBytes {
|
||||
return "", fmt.Errorf("image %s is too large (%d bytes, max %d). Re-upload a smaller version",
|
||||
att.Filename, att.SizeBytes, maxImageBytes)
|
||||
}
|
||||
|
||||
reader, size, _, err := t.objStore.Get(ctx, att.StorageKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read attachment from storage: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// Read and base64-encode
|
||||
data := make([]byte, size)
|
||||
if _, err := io.ReadFull(reader, data); err != nil {
|
||||
return "", fmt.Errorf("failed to read attachment data: %w", err)
|
||||
}
|
||||
|
||||
b64 := base64.StdEncoding.EncodeToString(data)
|
||||
dataURI := fmt.Sprintf("data:%s;base64,%s", att.ContentType, b64)
|
||||
|
||||
log.Printf("📎 attachment_recall: read image → %s (%s, %d bytes)",
|
||||
att.Filename, att.ID, size)
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"id": att.ID,
|
||||
"filename": att.Filename,
|
||||
"content_type": att.ContentType,
|
||||
"content": dataURI,
|
||||
"content_type_returned": "base64_image",
|
||||
})
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func isImageType(ct string) bool {
|
||||
return strings.HasPrefix(ct, "image/")
|
||||
}
|
||||
128
server/tools/conversation_search.go
Normal file
128
server/tools/conversation_search.go
Normal file
@@ -0,0 +1,128 @@
|
||||
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 {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user