521 lines
16 KiB
Go
521 lines
16 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
|
|
"chat-switchboard/knowledge"
|
|
"chat-switchboard/models"
|
|
"chat-switchboard/store"
|
|
)
|
|
|
|
// ── Late Registration ────────────────────────
|
|
// Note tools use late registration (like kb_search) because
|
|
// note_create, note_update, and note_search need the embedder for
|
|
// vector operations. The embedder is optional — if nil, notes still
|
|
// work but semantic search degrades to full-text search.
|
|
|
|
// RegisterNoteTools registers all note tools with captured dependencies.
|
|
// Call from main.go after stores + embedder init.
|
|
func RegisterNoteTools(stores store.Stores, embedder *knowledge.Embedder) {
|
|
Register(¬eCreateTool{stores: stores, embedder: embedder})
|
|
Register(¬eSearchTool{stores: stores, embedder: embedder})
|
|
Register(¬eUpdateTool{stores: stores, embedder: embedder})
|
|
Register(¬eListTool{stores: stores})
|
|
}
|
|
|
|
// ── Shared helpers ─────────────────────────────
|
|
|
|
// embedNote generates a vector for a note's title+content and stores it.
|
|
// Silently no-ops if embedder is nil or not configured.
|
|
func embedNote(ctx context.Context, embedder *knowledge.Embedder, stores store.Stores, noteID, userID, title, content string) {
|
|
if embedder == nil || !embedder.IsConfigured(ctx) {
|
|
return
|
|
}
|
|
|
|
text := title + "\n\n" + content
|
|
if len(text) > 8000 {
|
|
text = text[:8000] // limit to ~2k tokens for embedding
|
|
}
|
|
|
|
// Resolve team for embedding role
|
|
var teamID *string
|
|
if stores.Teams != nil {
|
|
ids, _ := stores.Teams.GetUserTeamIDs(ctx, userID)
|
|
if len(ids) > 0 {
|
|
teamID = &ids[0]
|
|
}
|
|
}
|
|
|
|
result, err := embedder.EmbedChunks(ctx, userID, teamID, []string{text})
|
|
if err != nil {
|
|
log.Printf("⚠ note embed failed for %s: %v", noteID, err)
|
|
return
|
|
}
|
|
if len(result.Vectors) == 0 {
|
|
return
|
|
}
|
|
|
|
// Store the vector via store method
|
|
err = stores.Notes.SetEmbedding(ctx, noteID, vectorToString(result.Vectors[0]))
|
|
if err != nil {
|
|
log.Printf("⚠ note embed store failed for %s: %v", noteID, err)
|
|
return
|
|
}
|
|
|
|
// Track embedding usage
|
|
embedder.LogUsage(ctx, userID, nil, result)
|
|
log.Printf("📝 note %s embedded (%d tokens)", noteID, result.InputTokens)
|
|
}
|
|
|
|
// vectorToString converts a float64 slice to pgvector literal format: [0.1,0.2,...]
|
|
func vectorToString(vec []float64) string {
|
|
parts := make([]string, len(vec))
|
|
for i, v := range vec {
|
|
parts[i] = fmt.Sprintf("%g", v)
|
|
}
|
|
return "[" + strings.Join(parts, ",") + "]"
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// note_create
|
|
// ═══════════════════════════════════════════
|
|
|
|
type noteCreateTool struct {
|
|
visitorDeniedBase
|
|
stores store.Stores
|
|
embedder *knowledge.Embedder
|
|
}
|
|
|
|
func (t *noteCreateTool) Definition() ToolDef {
|
|
return ToolDef{
|
|
Name: "note_create",
|
|
DisplayName: "Create",
|
|
Category: "notes",
|
|
Description: "Create a new note for the user. Use this to save information, insights, summaries, or anything the user wants to remember. Notes are persistent and searchable.",
|
|
Parameters: JSONSchema(map[string]interface{}{
|
|
"title": Prop("string", "Title of the note"),
|
|
"content": Prop("string", "Markdown content of the note"),
|
|
"folder": Prop("string", "Folder path, e.g. '/projects/switchboard/'. Defaults to '/'"),
|
|
"tags": PropArray("Tags for categorization, e.g. ['research', 'golang']"),
|
|
}, []string{"title", "content"}),
|
|
}
|
|
}
|
|
|
|
func (t *noteCreateTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
|
var args struct {
|
|
Title string `json:"title"`
|
|
Content string `json:"content"`
|
|
Folder string `json:"folder"`
|
|
Tags []string `json:"tags"`
|
|
}
|
|
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
|
return "", fmt.Errorf("invalid arguments: %w", err)
|
|
}
|
|
|
|
if args.Title == "" {
|
|
return "", fmt.Errorf("title is required")
|
|
}
|
|
|
|
folder := normalizePath(args.Folder)
|
|
tags := args.Tags
|
|
if tags == nil {
|
|
tags = []string{}
|
|
}
|
|
|
|
// source_channel_id links the note to the conversation that created it
|
|
var sourceChannelID *string
|
|
if execCtx.ChannelID != "" {
|
|
sourceChannelID = &execCtx.ChannelID
|
|
}
|
|
|
|
note := &models.Note{
|
|
UserID: execCtx.UserID,
|
|
Title: args.Title,
|
|
Content: args.Content,
|
|
FolderPath: folder,
|
|
Tags: tags,
|
|
SourceChannelID: sourceChannelID,
|
|
}
|
|
|
|
if err := t.stores.Notes.Create(ctx, note); err != nil {
|
|
return "", fmt.Errorf("failed to create note: %w", err)
|
|
}
|
|
|
|
// Embed async — don't block the tool response
|
|
go embedNote(context.Background(), t.embedder, t.stores, note.ID, execCtx.UserID, args.Title, args.Content)
|
|
|
|
result, _ := json.Marshal(map[string]interface{}{
|
|
"id": note.ID,
|
|
"title": args.Title,
|
|
"folder": folder,
|
|
"tags": tags,
|
|
"created_at": note.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
"message": fmt.Sprintf("Note '%s' created successfully.", args.Title),
|
|
})
|
|
return string(result), nil
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// note_search
|
|
// ═══════════════════════════════════════════
|
|
|
|
type noteSearchTool struct {
|
|
visitorDeniedBase
|
|
stores store.Stores
|
|
embedder *knowledge.Embedder
|
|
}
|
|
|
|
func (t *noteSearchTool) Definition() ToolDef {
|
|
return ToolDef{
|
|
Name: "note_search",
|
|
DisplayName: "Search",
|
|
Category: "notes",
|
|
Description: "Search the user's notes by keyword or semantic meaning. " +
|
|
"Set semantic=true for meaning-based search using AI embeddings, " +
|
|
"or omit for fast keyword matching. Returns matching notes ranked by relevance.",
|
|
Parameters: JSONSchema(map[string]interface{}{
|
|
"query": Prop("string", "Search query — natural language keywords or question"),
|
|
"limit": Prop("integer", "Maximum results to return (default 10, max 50)"),
|
|
"semantic": map[string]interface{}{
|
|
"type": "boolean",
|
|
"description": "Use semantic (vector) search instead of keyword search. Better for meaning-based queries.",
|
|
},
|
|
}, []string{"query"}),
|
|
}
|
|
}
|
|
|
|
func (t *noteSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
|
var args struct {
|
|
Query string `json:"query"`
|
|
Limit int `json:"limit"`
|
|
Semantic bool `json:"semantic"`
|
|
}
|
|
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.Limit <= 0 || args.Limit > 50 {
|
|
args.Limit = 10
|
|
}
|
|
|
|
// Semantic search via vector similarity
|
|
if args.Semantic {
|
|
return t.semanticSearch(ctx, execCtx, args.Query, args.Limit)
|
|
}
|
|
|
|
// Default: full-text keyword search
|
|
return t.keywordSearch(ctx, execCtx, args.Query, args.Limit)
|
|
}
|
|
|
|
func (t *noteSearchTool) keywordSearch(ctx context.Context, execCtx ExecutionContext, query string, limit int) (string, error) {
|
|
storeResults, err := t.stores.Notes.SearchKeyword(ctx, execCtx.UserID, query, limit)
|
|
if err != nil {
|
|
return "", fmt.Errorf("search failed: %w", err)
|
|
}
|
|
|
|
results := make([]noteSearchResult, 0, len(storeResults))
|
|
for _, sr := range storeResults {
|
|
results = append(results, noteSearchResult{
|
|
ID: sr.ID,
|
|
Title: sr.Title,
|
|
Folder: sr.FolderPath,
|
|
Tags: sr.Tags,
|
|
Excerpt: sr.Excerpt,
|
|
Headline: sr.Headline,
|
|
Rank: sr.Rank,
|
|
})
|
|
}
|
|
|
|
out, _ := json.Marshal(map[string]interface{}{
|
|
"query": query,
|
|
"mode": "keyword",
|
|
"count": len(results),
|
|
"results": results,
|
|
})
|
|
return string(out), nil
|
|
}
|
|
|
|
func (t *noteSearchTool) semanticSearch(ctx context.Context, execCtx ExecutionContext, query string, limit int) (string, error) {
|
|
// Check embedder availability — fall back to keyword search if not configured
|
|
if t.embedder == nil || !t.embedder.IsConfigured(ctx) {
|
|
log.Printf("⚠ note semantic search: embedder not configured, falling back to keyword")
|
|
return t.keywordSearch(ctx, execCtx, query, limit)
|
|
}
|
|
|
|
// Resolve team for embedding role
|
|
var teamID *string
|
|
if t.stores.Teams != nil {
|
|
ids, _ := t.stores.Teams.GetUserTeamIDs(ctx, execCtx.UserID)
|
|
if len(ids) > 0 {
|
|
teamID = &ids[0]
|
|
}
|
|
}
|
|
|
|
// Embed the query
|
|
embedResult, err := t.embedder.EmbedChunks(ctx, execCtx.UserID, teamID, []string{query})
|
|
if err != nil {
|
|
log.Printf("⚠ note semantic search embed failed: %v — falling back to keyword", err)
|
|
return t.keywordSearch(ctx, execCtx, query, limit)
|
|
}
|
|
if len(embedResult.Vectors) == 0 {
|
|
return t.keywordSearch(ctx, execCtx, query, limit)
|
|
}
|
|
|
|
// Track embedding usage
|
|
chanPtr := &execCtx.ChannelID
|
|
t.embedder.LogUsage(ctx, execCtx.UserID, chanPtr, embedResult)
|
|
|
|
queryVec := vectorToString(embedResult.Vectors[0])
|
|
|
|
// Vector similarity search via store
|
|
storeResults, err := t.stores.Notes.SearchSemantic(ctx, execCtx.UserID, queryVec, limit)
|
|
if err != nil {
|
|
log.Printf("⚠ note semantic search query failed: %v — falling back to keyword", err)
|
|
return t.keywordSearch(ctx, execCtx, query, limit)
|
|
}
|
|
|
|
results := make([]noteSearchResult, 0, len(storeResults))
|
|
for _, sr := range storeResults {
|
|
results = append(results, noteSearchResult{
|
|
ID: sr.ID,
|
|
Title: sr.Title,
|
|
Folder: sr.FolderPath,
|
|
Tags: sr.Tags,
|
|
Excerpt: sr.Excerpt,
|
|
Similarity: sr.Rank,
|
|
})
|
|
}
|
|
|
|
out, _ := json.Marshal(map[string]interface{}{
|
|
"query": query,
|
|
"mode": "semantic",
|
|
"count": len(results),
|
|
"results": results,
|
|
})
|
|
return string(out), nil
|
|
}
|
|
|
|
type noteSearchResult struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Folder string `json:"folder"`
|
|
Tags []string `json:"tags"`
|
|
Excerpt string `json:"excerpt"`
|
|
Headline string `json:"headline,omitempty"`
|
|
Rank float64 `json:"rank,omitempty"`
|
|
Similarity float64 `json:"similarity,omitempty"`
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// note_update
|
|
// ═══════════════════════════════════════════
|
|
|
|
type noteUpdateTool struct {
|
|
visitorDeniedBase
|
|
stores store.Stores
|
|
embedder *knowledge.Embedder
|
|
}
|
|
|
|
func (t *noteUpdateTool) Definition() ToolDef {
|
|
return ToolDef{
|
|
Name: "note_update",
|
|
DisplayName: "Update",
|
|
Category: "notes",
|
|
Description: "Update an existing note. Can replace content entirely, append to it, or prepend. Use note_search first to find the note ID.",
|
|
Parameters: JSONSchema(map[string]interface{}{
|
|
"note_id": Prop("string", "UUID of the note to update (from note_search results)"),
|
|
"title": Prop("string", "New title (omit to keep current)"),
|
|
"content": Prop("string", "New content or text to append/prepend"),
|
|
"mode": PropEnum("How to apply content", "replace", "append", "prepend"),
|
|
"tags": PropArray("New tags (replaces existing tags)"),
|
|
}, []string{"note_id"}),
|
|
}
|
|
}
|
|
|
|
func (t *noteUpdateTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
|
var args struct {
|
|
NoteID string `json:"note_id"`
|
|
Title *string `json:"title"`
|
|
Content *string `json:"content"`
|
|
Mode string `json:"mode"`
|
|
Tags []string `json:"tags"`
|
|
}
|
|
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
|
return "", fmt.Errorf("invalid arguments: %w", err)
|
|
}
|
|
|
|
if args.NoteID == "" {
|
|
return "", fmt.Errorf("note_id is required")
|
|
}
|
|
|
|
// Verify ownership + get existing content for append/prepend
|
|
existing, err := t.stores.Notes.GetByID(ctx, args.NoteID)
|
|
if err != nil || existing.UserID != execCtx.UserID {
|
|
return "", fmt.Errorf("note not found or update failed")
|
|
}
|
|
|
|
// Build fields map for store.Update
|
|
fields := map[string]interface{}{}
|
|
|
|
if args.Title != nil {
|
|
fields["title"] = *args.Title
|
|
}
|
|
|
|
if args.Content != nil {
|
|
mode := strings.ToLower(args.Mode)
|
|
switch mode {
|
|
case "append":
|
|
fields["content"] = existing.Content + *args.Content
|
|
case "prepend":
|
|
fields["content"] = *args.Content + existing.Content
|
|
default:
|
|
fields["content"] = *args.Content
|
|
}
|
|
}
|
|
|
|
if args.Tags != nil {
|
|
fields["tags"] = args.Tags
|
|
}
|
|
|
|
if len(fields) == 0 {
|
|
return "", fmt.Errorf("no fields to update — provide at least title, content, or tags")
|
|
}
|
|
|
|
if err := t.stores.Notes.Update(ctx, args.NoteID, fields); err != nil {
|
|
return "", fmt.Errorf("note not found or update failed")
|
|
}
|
|
|
|
// Re-fetch to get updated values
|
|
updated, err := t.stores.Notes.GetByID(ctx, args.NoteID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("note not found or update failed")
|
|
}
|
|
|
|
// Re-embed async with updated content
|
|
go embedNote(context.Background(), t.embedder, t.stores, updated.ID, execCtx.UserID, updated.Title, updated.Content)
|
|
|
|
result, _ := json.Marshal(map[string]interface{}{
|
|
"id": updated.ID,
|
|
"title": updated.Title,
|
|
"updated_at": updated.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
|
"message": fmt.Sprintf("Note '%s' updated successfully.", updated.Title),
|
|
})
|
|
return string(result), nil
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// note_list
|
|
// ═══════════════════════════════════════════
|
|
|
|
type noteListTool struct {
|
|
visitorDeniedBase
|
|
stores store.Stores
|
|
}
|
|
|
|
func (t *noteListTool) Definition() ToolDef {
|
|
return ToolDef{
|
|
Name: "note_list",
|
|
DisplayName: "List",
|
|
Category: "notes",
|
|
Description: "List the user's notes, optionally filtered by folder or tag. Shows titles and metadata without full content. Use note_search for keyword searching.",
|
|
Parameters: JSONSchema(map[string]interface{}{
|
|
"folder": Prop("string", "Filter by folder path, e.g. '/projects/' (omit for all)"),
|
|
"tag": Prop("string", "Filter by tag (omit for all)"),
|
|
"limit": Prop("integer", "Maximum results (default 20, max 100)"),
|
|
}, nil),
|
|
}
|
|
}
|
|
|
|
func (t *noteListTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
|
var args struct {
|
|
Folder string `json:"folder"`
|
|
Tag string `json:"tag"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
|
return "", fmt.Errorf("invalid arguments: %w", err)
|
|
}
|
|
|
|
if args.Limit <= 0 || args.Limit > 100 {
|
|
args.Limit = 20
|
|
}
|
|
|
|
opts := store.NoteListOptions{
|
|
ListOptions: store.ListOptions{Limit: args.Limit},
|
|
}
|
|
if args.Folder != "" {
|
|
opts.FolderPath = normalizePath(args.Folder)
|
|
}
|
|
if args.Tag != "" {
|
|
opts.Tag = args.Tag
|
|
}
|
|
|
|
notes, _, err := t.stores.Notes.ListForUser(ctx, execCtx.UserID, opts)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to list notes: %w", err)
|
|
}
|
|
|
|
type item struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Folder string `json:"folder"`
|
|
Tags []string `json:"tags"`
|
|
Preview string `json:"preview"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
items := make([]item, 0, len(notes))
|
|
for _, n := range notes {
|
|
tags := n.Tags
|
|
if tags == nil {
|
|
tags = []string{}
|
|
}
|
|
preview := n.Content
|
|
if len(preview) > 100 {
|
|
preview = preview[:100]
|
|
}
|
|
items = append(items, item{
|
|
ID: n.ID,
|
|
Title: n.Title,
|
|
Folder: n.FolderPath,
|
|
Tags: tags,
|
|
Preview: preview,
|
|
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
|
})
|
|
}
|
|
|
|
out, _ := json.Marshal(map[string]interface{}{
|
|
"count": len(items),
|
|
"notes": items,
|
|
})
|
|
return string(out), nil
|
|
}
|
|
|
|
// ── Path Helper ─────────────────────────────
|
|
|
|
func normalizePath(p string) string {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
return "/"
|
|
}
|
|
if !strings.HasPrefix(p, "/") {
|
|
p = "/" + p
|
|
}
|
|
if !strings.HasSuffix(p, "/") {
|
|
p = p + "/"
|
|
}
|
|
for strings.Contains(p, "//") {
|
|
p = strings.ReplaceAll(p, "//", "/")
|
|
}
|
|
return p
|
|
}
|