Changeset 0.14.0 (#67)
This commit is contained in:
159
server/tools/kbsearch.go
Normal file
159
server/tools/kbsearch.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Late Registration ────────────────────────
|
||||
// kb_search cannot use init() because it needs stores + embedder,
|
||||
// which aren't available until after main.go initializes them.
|
||||
// Call RegisterKBSearch() from main.go after stores init.
|
||||
|
||||
// RegisterKBSearch registers the kb_search tool with captured dependencies.
|
||||
func RegisterKBSearch(stores store.Stores, embedder *knowledge.Embedder) {
|
||||
Register(&kbSearchTool{
|
||||
stores: stores,
|
||||
embedder: embedder,
|
||||
})
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// kb_search
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type kbSearchTool struct {
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
|
||||
func (t *kbSearchTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "kb_search",
|
||||
DisplayName: "Knowledge Base",
|
||||
Category: "knowledge",
|
||||
Description: "Search knowledge bases for relevant information. " +
|
||||
"Returns text passages from uploaded documents that match the query. " +
|
||||
"Use this when the user asks questions that might be answered by their documents.",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"query": Prop("string", "Search query — use natural language to describe what you're looking for"),
|
||||
"max_results": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "Maximum results to return (1-20, default 5)",
|
||||
},
|
||||
}, []string{"query"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *kbSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
Query string `json:"query"`
|
||||
MaxResults int `json:"max_results"`
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Resolve user's team IDs for scoped access
|
||||
teamIDs, _ := t.stores.Teams.GetUserTeamIDs(ctx, execCtx.UserID)
|
||||
|
||||
// Get active KB IDs for this channel (respects scope: global, team, personal)
|
||||
kbIDs, err := t.stores.KnowledgeBases.GetActiveKBIDs(ctx, execCtx.ChannelID, execCtx.UserID, teamIDs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to look up active knowledge bases: %w", err)
|
||||
}
|
||||
|
||||
// Also include personal KBs (always available to owner, even if not linked to channel)
|
||||
personalKBs, err := t.stores.KnowledgeBases.ListPersonal(ctx, execCtx.UserID)
|
||||
if err == nil {
|
||||
kbIDSet := make(map[string]bool, len(kbIDs))
|
||||
for _, id := range kbIDs {
|
||||
kbIDSet[id] = true
|
||||
}
|
||||
for _, kb := range personalKBs {
|
||||
if !kbIDSet[kb.ID] && kb.ChunkCount > 0 {
|
||||
kbIDs = append(kbIDs, kb.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(kbIDs) == 0 {
|
||||
result, _ := json.Marshal(map[string]interface{}{
|
||||
"results": []interface{}{},
|
||||
"query": args.Query,
|
||||
"searched_kbs": []string{},
|
||||
"message": "No knowledge bases are active on this channel. Link a knowledge base to the channel or upload documents to a personal knowledge base first.",
|
||||
})
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
// Resolve team ID for embedding role resolution (pick first team)
|
||||
var teamID *string
|
||||
if len(teamIDs) > 0 {
|
||||
teamID = &teamIDs[0]
|
||||
}
|
||||
|
||||
// Embed the query
|
||||
embedResult, err := t.embedder.EmbedChunks(ctx, execCtx.UserID, teamID, []string{args.Query})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to embed search query: %w", err)
|
||||
}
|
||||
if len(embedResult.Vectors) == 0 {
|
||||
return "", fmt.Errorf("embedding produced no vectors")
|
||||
}
|
||||
|
||||
queryVec := embedResult.Vectors[0]
|
||||
|
||||
// Track embedding usage (role = "embedding")
|
||||
chanPtr := &execCtx.ChannelID
|
||||
t.embedder.LogUsage(ctx, execCtx.UserID, chanPtr, embedResult)
|
||||
|
||||
// Similarity search across all active KBs
|
||||
const defaultThreshold = 0.3
|
||||
results, err := t.stores.KnowledgeBases.SimilaritySearch(ctx, kbIDs, queryVec, defaultThreshold, args.MaxResults)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("search failed: %w", err)
|
||||
}
|
||||
|
||||
// Collect searched KB names for attribution
|
||||
searchedKBs := make(map[string]bool)
|
||||
for _, r := range results {
|
||||
searchedKBs[r.KBName] = true
|
||||
}
|
||||
kbNames := make([]string, 0, len(searchedKBs))
|
||||
for name := range searchedKBs {
|
||||
kbNames = append(kbNames, name)
|
||||
}
|
||||
|
||||
// If no results found from search, list all searched KB names
|
||||
if len(kbNames) == 0 {
|
||||
for _, kbID := range kbIDs {
|
||||
kb, err := t.stores.KnowledgeBases.GetByID(ctx, kbID)
|
||||
if err == nil {
|
||||
kbNames = append(kbNames, kb.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("🔍 kb_search: %q → %d results across %d KBs", args.Query, len(results), len(kbIDs))
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"results": results,
|
||||
"query": args.Query,
|
||||
"total": len(results),
|
||||
"searched_kbs": kbNames,
|
||||
})
|
||||
return string(out), nil
|
||||
}
|
||||
@@ -4,29 +4,98 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/lib/pq"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Init ────────────────────────────────────
|
||||
// ── 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.
|
||||
|
||||
func init() {
|
||||
Register(&NoteCreateTool{})
|
||||
Register(&NoteSearchTool{})
|
||||
Register(&NoteUpdateTool{})
|
||||
Register(&NoteListTool{})
|
||||
// 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{})
|
||||
}
|
||||
|
||||
// ── 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 — format as pgvector literal
|
||||
vecStr := vectorToString(result.Vectors[0])
|
||||
_, err = database.DB.Exec(
|
||||
`UPDATE notes SET embedding = $1::vector WHERE id = $2`,
|
||||
vecStr, noteID,
|
||||
)
|
||||
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{}
|
||||
type noteCreateTool struct {
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
|
||||
func (t *NoteCreateTool) Definition() ToolDef {
|
||||
func (t *noteCreateTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "note_create",
|
||||
DisplayName: "Create",
|
||||
@@ -41,7 +110,7 @@ func (t *NoteCreateTool) Definition() ToolDef {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *NoteCreateTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
func (t *noteCreateTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
@@ -80,6 +149,9 @@ func (t *NoteCreateTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
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, id, execCtx.UserID, args.Title, args.Content)
|
||||
|
||||
result, _ := json.Marshal(map[string]interface{}{
|
||||
"id": id,
|
||||
"title": args.Title,
|
||||
@@ -95,25 +167,35 @@ func (t *NoteCreateTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
// note_search
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type NoteSearchTool struct{}
|
||||
type noteSearchTool struct {
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
|
||||
func (t *NoteSearchTool) Definition() ToolDef {
|
||||
func (t *noteSearchTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "note_search",
|
||||
DisplayName: "Search",
|
||||
Category: "notes",
|
||||
Description: "Search the user's notes by keyword. Returns matching notes ranked by relevance with highlighted excerpts. Use this to find previously saved information.",
|
||||
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"),
|
||||
"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) {
|
||||
func (t *noteSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
Query string `json:"query"`
|
||||
Limit int `json:"limit"`
|
||||
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)
|
||||
@@ -126,6 +208,16 @@ func (t *NoteSearchTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
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) {
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT id, title, folder_path, tags, LEFT(content, 500),
|
||||
ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank,
|
||||
@@ -136,26 +228,16 @@ func (t *NoteSearchTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
AND search_vector @@ plainto_tsquery('english', $2)
|
||||
ORDER BY rank DESC
|
||||
LIMIT $3
|
||||
`, execCtx.UserID, args.Query, args.Limit)
|
||||
`, execCtx.UserID, query, limit)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("search failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type result 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"`
|
||||
Rank float64 `json:"rank"`
|
||||
}
|
||||
|
||||
results := make([]result, 0)
|
||||
results := make([]noteSearchResult, 0)
|
||||
for rows.Next() {
|
||||
var r result
|
||||
var r noteSearchResult
|
||||
var dbTags pq.StringArray
|
||||
if err := rows.Scan(&r.ID, &r.Title, &r.Folder, &dbTags, &r.Excerpt, &r.Rank, &r.Headline); err != nil {
|
||||
continue
|
||||
@@ -168,20 +250,108 @@ func (t *NoteSearchTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"query": args.Query,
|
||||
"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 — only notes that have embeddings
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT id, title, folder_path, tags, LEFT(content, 500),
|
||||
1 - (embedding <=> $2::vector) AS similarity
|
||||
FROM notes
|
||||
WHERE user_id = $1
|
||||
AND embedding IS NOT NULL
|
||||
AND 1 - (embedding <=> $2::vector) > 0.3
|
||||
ORDER BY embedding <=> $2::vector
|
||||
LIMIT $3
|
||||
`, 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)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
results := make([]noteSearchResult, 0)
|
||||
for rows.Next() {
|
||||
var r noteSearchResult
|
||||
var dbTags pq.StringArray
|
||||
if err := rows.Scan(&r.ID, &r.Title, &r.Folder, &dbTags, &r.Excerpt, &r.Similarity); err != nil {
|
||||
continue
|
||||
}
|
||||
r.Tags = []string(dbTags)
|
||||
if r.Tags == nil {
|
||||
r.Tags = []string{}
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
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{}
|
||||
type noteUpdateTool struct {
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
|
||||
func (t *NoteUpdateTool) Definition() ToolDef {
|
||||
func (t *noteUpdateTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "note_update",
|
||||
DisplayName: "Update",
|
||||
@@ -197,7 +367,7 @@ func (t *NoteUpdateTool) Definition() ToolDef {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *NoteUpdateTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
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"`
|
||||
@@ -251,14 +421,17 @@ func (t *NoteUpdateTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
queryArgs = append(queryArgs, args.NoteID, execCtx.UserID)
|
||||
query := "UPDATE notes SET " + strings.Join(setClauses, ", ") +
|
||||
fmt.Sprintf(" WHERE id = $%d AND user_id = $%d", argIdx, argIdx+1) +
|
||||
" RETURNING id, title, updated_at::text"
|
||||
" RETURNING id, title, content, updated_at::text"
|
||||
|
||||
var id, title, updatedAt string
|
||||
err := database.DB.QueryRow(query, queryArgs...).Scan(&id, &title, &updatedAt)
|
||||
var id, title, content, updatedAt string
|
||||
err := database.DB.QueryRow(query, queryArgs...).Scan(&id, &title, &content, &updatedAt)
|
||||
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, id, execCtx.UserID, title, content)
|
||||
|
||||
result, _ := json.Marshal(map[string]interface{}{
|
||||
"id": id,
|
||||
"title": title,
|
||||
@@ -272,9 +445,9 @@ func (t *NoteUpdateTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
// note_list
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type NoteListTool struct{}
|
||||
type noteListTool struct{}
|
||||
|
||||
func (t *NoteListTool) Definition() ToolDef {
|
||||
func (t *noteListTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "note_list",
|
||||
DisplayName: "List",
|
||||
@@ -288,7 +461,7 @@ func (t *NoteListTool) Definition() ToolDef {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *NoteListTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
func (t *noteListTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
Folder string `json:"folder"`
|
||||
Tag string `json:"tag"`
|
||||
|
||||
@@ -3,13 +3,23 @@ package tools
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// TestMain registers late-registered tools (notes) with nil deps so
|
||||
// that Definition()-level tests work. Execute() tests need real deps.
|
||||
func TestMain(m *testing.M) {
|
||||
RegisterNoteTools(store.Stores{}, nil)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// ── Registry Tests ──────────────────────────
|
||||
|
||||
func TestRegistryHasTools(t *testing.T) {
|
||||
// init() in notes.go registers 4 tools
|
||||
// TestMain registers note tools via RegisterNoteTools
|
||||
if !HasTools() {
|
||||
t.Fatal("Expected HasTools() == true after init registration")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user