Changeset 0.29.0 (#195)

This commit is contained in:
2026-03-17 16:28:47 +00:00
parent 128cbb8174
commit 5d637d3a90
129 changed files with 9418 additions and 3016 deletions

View File

@@ -7,10 +7,8 @@ import (
"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/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
@@ -26,7 +24,7 @@ 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{})
Register(&noteListTool{stores: stores})
}
// ── Shared helpers ─────────────────────────────
@@ -61,12 +59,8 @@ func embedNote(ctx context.Context, embedder *knowledge.Embedder, stores store.S
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,
)
// 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
@@ -138,27 +132,28 @@ func (t *noteCreateTool) Execute(ctx context.Context, execCtx ExecutionContext,
sourceChannelID = &execCtx.ChannelID
}
var id, createdAt string
err := database.DB.QueryRow(`
INSERT INTO notes (user_id, title, content, folder_path, tags, source_channel_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at::text
`, execCtx.UserID, args.Title, args.Content, folder, pq.Array(tags), sourceChannelID,
).Scan(&id, &createdAt)
note := &models.Note{
UserID: execCtx.UserID,
Title: args.Title,
Content: args.Content,
FolderPath: folder,
Tags: tags,
SourceChannelID: sourceChannelID,
}
if err != nil {
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, id, execCtx.UserID, args.Title, args.Content)
go embedNote(context.Background(), t.embedder, t.stores, note.ID, execCtx.UserID, args.Title, args.Content)
result, _ := json.Marshal(map[string]interface{}{
"id": id,
"id": note.ID,
"title": args.Title,
"folder": folder,
"tags": tags,
"created_at": createdAt,
"created_at": note.CreatedAt.Format("2006-01-02T15:04:05Z"),
"message": fmt.Sprintf("Note '%s' created successfully.", args.Title),
})
return string(result), nil
@@ -220,35 +215,22 @@ func (t *noteSearchTool) Execute(ctx context.Context, execCtx ExecutionContext,
}
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,
ts_headline('english', content, plainto_tsquery('english', $2),
'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline
FROM notes
WHERE user_id = $1
AND search_vector @@ plainto_tsquery('english', $2)
ORDER BY rank DESC
LIMIT $3
`, execCtx.UserID, query, limit)
storeResults, err := t.stores.Notes.SearchKeyword(ctx, execCtx.UserID, query, limit)
if err != nil {
return "", fmt.Errorf("search failed: %w", err)
}
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.Rank, &r.Headline); err != nil {
continue
}
r.Tags = []string(dbTags)
if r.Tags == nil {
r.Tags = []string{}
}
results = append(results, r)
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{}{
@@ -292,36 +274,23 @@ func (t *noteSearchTool) semanticSearch(ctx context.Context, execCtx ExecutionCo
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)
// 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)
}
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)
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{}{
@@ -386,60 +355,57 @@ func (t *noteUpdateTool) Execute(ctx context.Context, execCtx ExecutionContext,
return "", fmt.Errorf("note_id is required")
}
// Build dynamic update
setClauses := []string{}
queryArgs := []interface{}{}
argIdx := 1
// 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 {
setClauses = append(setClauses, fmt.Sprintf("title = $%d", argIdx))
queryArgs = append(queryArgs, *args.Title)
argIdx++
fields["title"] = *args.Title
}
if args.Content != nil {
mode := strings.ToLower(args.Mode)
switch mode {
case "append":
setClauses = append(setClauses, fmt.Sprintf("content = content || $%d", argIdx))
fields["content"] = existing.Content + *args.Content
case "prepend":
setClauses = append(setClauses, fmt.Sprintf("content = $%d || content", argIdx))
fields["content"] = *args.Content + existing.Content
default:
setClauses = append(setClauses, fmt.Sprintf("content = $%d", argIdx))
fields["content"] = *args.Content
}
queryArgs = append(queryArgs, *args.Content)
argIdx++
}
if args.Tags != nil {
setClauses = append(setClauses, fmt.Sprintf("tags = $%d", argIdx))
queryArgs = append(queryArgs, pq.Array(args.Tags))
argIdx++
fields["tags"] = args.Tags
}
if len(setClauses) == 0 {
if len(fields) == 0 {
return "", fmt.Errorf("no fields to update — provide at least title, content, or tags")
}
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, content, updated_at::text"
if err := t.stores.Notes.Update(ctx, args.NoteID, fields); err != nil {
return "", fmt.Errorf("note not found or update failed")
}
var id, title, content, updatedAt string
err := database.DB.QueryRow(query, queryArgs...).Scan(&id, &title, &content, &updatedAt)
// 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, id, execCtx.UserID, title, content)
go embedNote(context.Background(), t.embedder, t.stores, updated.ID, execCtx.UserID, updated.Title, updated.Content)
result, _ := json.Marshal(map[string]interface{}{
"id": id,
"title": title,
"updated_at": updatedAt,
"message": fmt.Sprintf("Note '%s' updated successfully.", title),
"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
}
@@ -448,7 +414,10 @@ func (t *noteUpdateTool) Execute(ctx context.Context, execCtx ExecutionContext,
// note_list
// ═══════════════════════════════════════════
type noteListTool struct{ visitorDeniedBase }
type noteListTool struct {
visitorDeniedBase
stores store.Stores
}
func (t *noteListTool) Definition() ToolDef {
return ToolDef{
@@ -478,31 +447,20 @@ func (t *noteListTool) Execute(ctx context.Context, execCtx ExecutionContext, ar
args.Limit = 20
}
query := `SELECT id, title, folder_path, tags, LEFT(content, 100),
created_at::text, updated_at::text
FROM notes WHERE user_id = $1`
queryArgs := []interface{}{execCtx.UserID}
argIdx := 2
opts := store.NoteListOptions{
ListOptions: store.ListOptions{Limit: args.Limit},
}
if args.Folder != "" {
query += fmt.Sprintf(" AND folder_path = $%d", argIdx)
queryArgs = append(queryArgs, normalizePath(args.Folder))
argIdx++
opts.FolderPath = normalizePath(args.Folder)
}
if args.Tag != "" {
query += fmt.Sprintf(" AND $%d = ANY(tags)", argIdx)
queryArgs = append(queryArgs, args.Tag)
argIdx++
opts.Tag = args.Tag
}
query += fmt.Sprintf(" ORDER BY updated_at DESC LIMIT $%d", argIdx)
queryArgs = append(queryArgs, args.Limit)
rows, err := database.DB.Query(query, queryArgs...)
notes, _, err := t.stores.Notes.ListForUser(ctx, execCtx.UserID, opts)
if err != nil {
return "", fmt.Errorf("failed to list notes: %w", err)
}
defer rows.Close()
type item struct {
ID string `json:"id"`
@@ -514,19 +472,25 @@ func (t *noteListTool) Execute(ctx context.Context, execCtx ExecutionContext, ar
UpdatedAt string `json:"updated_at"`
}
items := make([]item, 0)
for rows.Next() {
var n item
var dbTags pq.StringArray
if err := rows.Scan(&n.ID, &n.Title, &n.Folder, &dbTags, &n.Preview,
&n.CreatedAt, &n.UpdatedAt); err != nil {
continue
items := make([]item, 0, len(notes))
for _, n := range notes {
tags := n.Tags
if tags == nil {
tags = []string{}
}
n.Tags = []string(dbTags)
if n.Tags == nil {
n.Tags = []string{}
preview := n.Content
if len(preview) > 100 {
preview = preview[:100]
}
items = append(items, n)
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{}{