Changeset 0.14.0 (#67)

This commit is contained in:
2026-02-26 15:59:26 +00:00
parent 1a71658b24
commit e2149e249d
38 changed files with 5171 additions and 141 deletions

View File

@@ -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(&noteCreateTool{stores: stores, embedder: embedder})
Register(&noteSearchTool{stores: stores, embedder: embedder})
Register(&noteUpdateTool{stores: stores, embedder: embedder})
Register(&noteListTool{})
}
// ── 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"`