feat(tools): implement CRUD operations for thoughts and projects
* Add tools for creating, retrieving, updating, and deleting thoughts. * Implement project management tools for creating and listing projects. * Introduce linking functionality between thoughts. * Add search and recall capabilities for thoughts based on semantic queries. * Implement statistics and summarization tools for thought analysis. * Create database migrations for thoughts, projects, and links. * Add helper functions for UUID parsing and project resolution.
This commit is contained in:
155
internal/metadata/normalize.go
Normal file
155
internal/metadata/normalize.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/config"
|
||||
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultType = "observation"
|
||||
DefaultTopicFallback = "uncategorized"
|
||||
maxTopics = 10
|
||||
)
|
||||
|
||||
var allowedTypes = map[string]struct{}{
|
||||
"observation": {},
|
||||
"task": {},
|
||||
"idea": {},
|
||||
"reference": {},
|
||||
"person_note": {},
|
||||
}
|
||||
|
||||
func Fallback(capture config.CaptureConfig) thoughttypes.ThoughtMetadata {
|
||||
topicFallback := strings.TrimSpace(capture.MetadataDefaults.TopicFallback)
|
||||
if topicFallback == "" {
|
||||
topicFallback = DefaultTopicFallback
|
||||
}
|
||||
|
||||
return thoughttypes.ThoughtMetadata{
|
||||
People: []string{},
|
||||
ActionItems: []string{},
|
||||
DatesMentioned: []string{},
|
||||
Topics: []string{topicFallback},
|
||||
Type: normalizeType(capture.MetadataDefaults.Type),
|
||||
Source: normalizeSource(capture.Source),
|
||||
}
|
||||
}
|
||||
|
||||
func Normalize(in thoughttypes.ThoughtMetadata, capture config.CaptureConfig) thoughttypes.ThoughtMetadata {
|
||||
out := thoughttypes.ThoughtMetadata{
|
||||
People: normalizeList(in.People, 0),
|
||||
ActionItems: normalizeList(in.ActionItems, 0),
|
||||
DatesMentioned: normalizeList(in.DatesMentioned, 0),
|
||||
Topics: normalizeList(in.Topics, maxTopics),
|
||||
Type: normalizeType(in.Type),
|
||||
Source: normalizeSource(in.Source),
|
||||
}
|
||||
|
||||
if len(out.Topics) == 0 {
|
||||
out.Topics = Fallback(capture).Topics
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = Fallback(capture).Type
|
||||
}
|
||||
if out.Source == "" {
|
||||
out.Source = Fallback(capture).Source
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeList(values []string, limit int) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
|
||||
for _, value := range values {
|
||||
trimmed := strings.Join(strings.Fields(strings.TrimSpace(value)), " ")
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.ToLower(trimmed)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, trimmed)
|
||||
|
||||
if limit > 0 && len(result) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeType(value string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if normalized == "" {
|
||||
return DefaultType
|
||||
}
|
||||
if _, ok := allowedTypes[normalized]; ok {
|
||||
return normalized
|
||||
}
|
||||
return DefaultType
|
||||
}
|
||||
|
||||
func normalizeSource(value string) string {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" {
|
||||
return config.DefaultSource
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func Merge(base, patch thoughttypes.ThoughtMetadata, capture config.CaptureConfig) thoughttypes.ThoughtMetadata {
|
||||
merged := base
|
||||
|
||||
if len(patch.People) > 0 {
|
||||
merged.People = append(append([]string{}, merged.People...), patch.People...)
|
||||
}
|
||||
if len(patch.ActionItems) > 0 {
|
||||
merged.ActionItems = append(append([]string{}, merged.ActionItems...), patch.ActionItems...)
|
||||
}
|
||||
if len(patch.DatesMentioned) > 0 {
|
||||
merged.DatesMentioned = append(append([]string{}, merged.DatesMentioned...), patch.DatesMentioned...)
|
||||
}
|
||||
if len(patch.Topics) > 0 {
|
||||
merged.Topics = append(append([]string{}, merged.Topics...), patch.Topics...)
|
||||
}
|
||||
if strings.TrimSpace(patch.Type) != "" {
|
||||
merged.Type = patch.Type
|
||||
}
|
||||
if strings.TrimSpace(patch.Source) != "" {
|
||||
merged.Source = patch.Source
|
||||
}
|
||||
|
||||
return Normalize(merged, capture)
|
||||
}
|
||||
|
||||
func SortedTopCounts(in map[string]int, limit int) []thoughttypes.KeyCount {
|
||||
out := make([]thoughttypes.KeyCount, 0, len(in))
|
||||
for key, count := range in {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, thoughttypes.KeyCount{Key: key, Count: count})
|
||||
}
|
||||
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Count == out[j].Count {
|
||||
return out[i].Key < out[j].Key
|
||||
}
|
||||
return out[i].Count > out[j].Count
|
||||
})
|
||||
|
||||
if limit > 0 && len(out) > limit {
|
||||
return out[:limit]
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
81
internal/metadata/normalize_test.go
Normal file
81
internal/metadata/normalize_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/config"
|
||||
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
|
||||
func testCaptureConfig() config.CaptureConfig {
|
||||
return config.CaptureConfig{
|
||||
Source: "mcp",
|
||||
MetadataDefaults: config.CaptureMetadataDefault{
|
||||
Type: "observation",
|
||||
TopicFallback: "uncategorized",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackUsesConfiguredDefaults(t *testing.T) {
|
||||
got := Fallback(testCaptureConfig())
|
||||
if got.Type != "observation" {
|
||||
t.Fatalf("Fallback type = %q, want observation", got.Type)
|
||||
}
|
||||
if len(got.Topics) != 1 || got.Topics[0] != "uncategorized" {
|
||||
t.Fatalf("Fallback topics = %#v, want [uncategorized]", got.Topics)
|
||||
}
|
||||
if got.Source != "mcp" {
|
||||
t.Fatalf("Fallback source = %q, want mcp", got.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTrimsDedupesAndCapsTopics(t *testing.T) {
|
||||
topics := []string{}
|
||||
for i := 0; i < 12; i++ {
|
||||
topics = append(topics, strings.TrimSpace(" topic "))
|
||||
topics = append(topics, string(rune('a'+i)))
|
||||
}
|
||||
|
||||
got := Normalize(thoughttypes.ThoughtMetadata{
|
||||
People: []string{" Alice ", "alice", "", "Bob"},
|
||||
Topics: topics,
|
||||
Type: "INVALID",
|
||||
}, testCaptureConfig())
|
||||
|
||||
if len(got.People) != 2 {
|
||||
t.Fatalf("People len = %d, want 2", len(got.People))
|
||||
}
|
||||
if got.Type != "observation" {
|
||||
t.Fatalf("Type = %q, want observation", got.Type)
|
||||
}
|
||||
if len(got.Topics) != maxTopics {
|
||||
t.Fatalf("Topics len = %d, want %d", len(got.Topics), maxTopics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeAddsPatchAndNormalizes(t *testing.T) {
|
||||
base := thoughttypes.ThoughtMetadata{
|
||||
People: []string{"Alice"},
|
||||
Topics: []string{"go"},
|
||||
Type: "idea",
|
||||
Source: "mcp",
|
||||
}
|
||||
patch := thoughttypes.ThoughtMetadata{
|
||||
People: []string{" Bob ", "alice"},
|
||||
Topics: []string{"testing"},
|
||||
Type: "task",
|
||||
}
|
||||
|
||||
got := Merge(base, patch, testCaptureConfig())
|
||||
if got.Type != "task" {
|
||||
t.Fatalf("Type = %q, want task", got.Type)
|
||||
}
|
||||
if len(got.People) != 2 {
|
||||
t.Fatalf("People len = %d, want 2", len(got.People))
|
||||
}
|
||||
if len(got.Topics) != 2 {
|
||||
t.Fatalf("Topics len = %d, want 2", len(got.Topics))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user