Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81a3470407 |
@@ -36,3 +36,4 @@ ui/.svelte-kit/
|
||||
internal/app/ui/dist/*
|
||||
!internal/app/ui/dist/placeholder.txt
|
||||
.codex
|
||||
.worktrees/
|
||||
|
||||
@@ -478,7 +478,7 @@ metadata_retry:
|
||||
include_archived: false
|
||||
```
|
||||
|
||||
**Search fallback**: when no embeddings exist for the active model in scope, `search_thoughts`, `recall_context`, `get_project_context`, `summarize_thoughts`, and `related_thoughts` automatically fall back to Postgres full-text search so results are never silently empty. All five tools include a `retrieval_mode` field in their response (`"semantic"` or `"text"`) so callers can see which path was taken.
|
||||
**Search fallback**: when no embeddings exist for the active model in scope, `search_thoughts`, `recall_context`, `get_project_context`, `summarize_thoughts`, and `related_thoughts` automatically fall back to Postgres full-text search so results are never silently empty.
|
||||
|
||||
## Client Setup
|
||||
|
||||
|
||||
@@ -208,6 +208,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
||||
Plans: tools.NewPlansTool(db, activeProjects, cfg.Search),
|
||||
ProjectPersonas: tools.NewProjectPersonasTool(db, activeProjects),
|
||||
WorldModel: tools.NewWorldModelTool(db, activeProjects),
|
||||
ThoughtLearningLinks: tools.NewThoughtLearningLinksTool(db),
|
||||
Context: tools.NewContextTool(db, embeddings, cfg.Search, activeProjects),
|
||||
Recall: tools.NewRecallTool(db, embeddings, cfg.Search, activeProjects),
|
||||
Summarize: tools.NewSummarizeTool(db, embeddings, metadata, cfg.Search, activeProjects),
|
||||
|
||||
@@ -46,6 +46,7 @@ type ToolSet struct {
|
||||
Plans *tools.PlansTool
|
||||
ProjectPersonas *tools.ProjectPersonasTool
|
||||
WorldModel *tools.WorldModelTool
|
||||
ThoughtLearningLinks *tools.ThoughtLearningLinksTool
|
||||
}
|
||||
|
||||
// Handlers groups the HTTP handlers produced for an MCP server instance.
|
||||
@@ -91,6 +92,7 @@ func NewHandlers(cfg config.MCPConfig, logger *slog.Logger, toolSet ToolSet, onS
|
||||
registerProjectTools,
|
||||
registerWorldModelTools,
|
||||
registerLearningTools,
|
||||
registerThoughtLearningLinkTools,
|
||||
registerPlanTools,
|
||||
registerFileTools,
|
||||
registerMaintenanceTools,
|
||||
@@ -308,6 +310,34 @@ func registerLearningTools(server *mcp.Server, logger *slog.Logger, toolSet Tool
|
||||
return nil
|
||||
}
|
||||
|
||||
func registerThoughtLearningLinkTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "link_thought_learning",
|
||||
Description: "Create or update an explicit association between a raw thought and a curated learning.",
|
||||
}, toolSet.ThoughtLearningLinks.Link); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "unlink_thought_learning",
|
||||
Description: "Remove an explicit association between a thought and a learning.",
|
||||
}, toolSet.ThoughtLearningLinks.Unlink); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "get_thought_learnings",
|
||||
Description: "List curated learnings explicitly linked to a thought.",
|
||||
}, toolSet.ThoughtLearningLinks.GetThoughtLearnings); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "get_learning_thoughts",
|
||||
Description: "List raw thoughts explicitly linked to a learning.",
|
||||
}, toolSet.ThoughtLearningLinks.GetLearningThoughts); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func registerPlanTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "create_plan",
|
||||
@@ -755,6 +785,10 @@ func BuildToolCatalog() []tools.ToolEntry {
|
||||
{Name: "add_learning", Description: "Create a curated learning record distinct from raw thoughts.", Category: "projects"},
|
||||
{Name: "get_learning", Description: "Retrieve a structured learning by id.", Category: "projects"},
|
||||
{Name: "list_learnings", Description: "List structured learnings with optional project, category, area, status, priority, tag, and text filters.", Category: "projects"},
|
||||
{Name: "link_thought_learning", Description: "Create or update a lightweight explicit association between a raw thought and a curated learning.", Category: "projects"},
|
||||
{Name: "unlink_thought_learning", Description: "Remove a lightweight explicit association between a thought and a learning.", Category: "projects"},
|
||||
{Name: "get_thought_learnings", Description: "List curated learnings explicitly associated with a thought.", Category: "projects"},
|
||||
{Name: "get_learning_thoughts", Description: "List raw source thoughts explicitly associated with a learning.", Category: "projects"},
|
||||
|
||||
// plans
|
||||
{Name: "create_plan", Description: "Create a structured plan with status, priority, owner, due date, and optional project link.", Category: "plans"},
|
||||
|
||||
@@ -214,5 +214,6 @@ func streamableTestToolSet() ToolSet {
|
||||
Plans: new(tools.PlansTool),
|
||||
ProjectPersonas: new(tools.ProjectPersonasTool),
|
||||
WorldModel: new(tools.WorldModelTool),
|
||||
ThoughtLearningLinks: new(tools.ThoughtLearningLinksTool),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
|
||||
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
|
||||
// LinkThoughtLearning creates or updates an association between a thought (by GUID) and a learning (by numeric ID).
|
||||
// If the pair already exists, the relation label is updated.
|
||||
func (db *DB) LinkThoughtLearning(ctx context.Context, thoughtGUID uuid.UUID, learningID int64, relation string) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
insert into thought_learning_links (thought_id, learning_id, relation)
|
||||
select t.id, $2, $3
|
||||
from thoughts t
|
||||
where t.guid = $1
|
||||
on conflict (thought_id, learning_id) do update set relation = excluded.relation
|
||||
`, thoughtGUID, learningID, relation)
|
||||
if err != nil {
|
||||
return fmt.Errorf("link thought learning: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnlinkThoughtLearning removes the association between a thought (by GUID) and a learning (by numeric ID).
|
||||
func (db *DB) UnlinkThoughtLearning(ctx context.Context, thoughtGUID uuid.UUID, learningID int64) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
delete from thought_learning_links
|
||||
where thought_id = (select id from thoughts where guid = $1)
|
||||
and learning_id = $2
|
||||
`, thoughtGUID, learningID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unlink thought learning: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLinkedLearnings returns all learnings explicitly associated with a thought (by GUID).
|
||||
func (db *DB) GetLinkedLearnings(ctx context.Context, thoughtGUID uuid.UUID) ([]thoughttypes.LinkedLearning, error) {
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
select l.id, l.guid, l.summary, l.details, l.category, l.area, l.status, l.priority, l.confidence,
|
||||
l.action_required, l.source_type, l.source_ref, l.project_id, l.related_thought_id,
|
||||
l.related_skill_id, l.reviewed_by, l.reviewed_at, l.duplicate_of_learning_id,
|
||||
l.supersedes_learning_id, l.tags::text[], l.created_at, l.updated_at,
|
||||
tll.relation, tll.created_at as linked_at
|
||||
from thought_learning_links tll
|
||||
join learnings l on l.id = tll.learning_id
|
||||
where tll.thought_id = (select id from thoughts where guid = $1)
|
||||
order by tll.created_at desc
|
||||
`, thoughtGUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query linked learnings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]thoughttypes.LinkedLearning, 0)
|
||||
for rows.Next() {
|
||||
var tags []string
|
||||
var linked thoughttypes.LinkedLearning
|
||||
var m generatedmodels.ModelPublicLearnings
|
||||
|
||||
if err := rows.Scan(
|
||||
&m.ID,
|
||||
&m.GUID,
|
||||
&m.Summary,
|
||||
&m.Details,
|
||||
&m.Category,
|
||||
&m.Area,
|
||||
&m.Status,
|
||||
&m.Priority,
|
||||
&m.Confidence,
|
||||
&m.ActionRequired,
|
||||
&m.SourceType,
|
||||
&m.SourceRef,
|
||||
&m.ProjectID,
|
||||
&m.RelatedThoughtID,
|
||||
&m.RelatedSkillID,
|
||||
&m.ReviewedBy,
|
||||
&m.ReviewedAt,
|
||||
&m.DuplicateOfLearningID,
|
||||
&m.SupersedesLearningID,
|
||||
&tags,
|
||||
&m.CreatedAt,
|
||||
&m.UpdatedAt,
|
||||
&linked.Relation,
|
||||
&linked.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan linked learning: %w", err)
|
||||
}
|
||||
linked.Learning = learningFromModel(m, tags)
|
||||
items = append(items, linked)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate linked learnings: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetLinkedThoughtsForLearning returns all thoughts explicitly associated with a learning (by numeric ID).
|
||||
func (db *DB) GetLinkedThoughtsForLearning(ctx context.Context, learningID int64) ([]thoughttypes.LinkedLearningThought, error) {
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
select t.id, t.guid, t.content, t.metadata, t.project_id, t.archived_at, t.created_at, t.updated_at,
|
||||
tll.relation, tll.created_at as linked_at
|
||||
from thought_learning_links tll
|
||||
join thoughts t on t.id = tll.thought_id
|
||||
where tll.learning_id = $1
|
||||
order by tll.created_at desc
|
||||
`, learningID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query linked thoughts for learning: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]thoughttypes.LinkedLearningThought, 0)
|
||||
for rows.Next() {
|
||||
var linked thoughttypes.LinkedLearningThought
|
||||
var m generatedmodels.ModelPublicThoughts
|
||||
|
||||
if err := rows.Scan(
|
||||
&m.ID,
|
||||
&m.GUID,
|
||||
&m.Content,
|
||||
&m.Metadata,
|
||||
&m.ProjectID,
|
||||
&m.ArchivedAt,
|
||||
&m.CreatedAt,
|
||||
&m.UpdatedAt,
|
||||
&linked.Relation,
|
||||
&linked.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan linked thought for learning: %w", err)
|
||||
}
|
||||
thought, err := thoughtFromModel(m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("map linked thought for learning: %w", err)
|
||||
}
|
||||
linked.Thought = thought
|
||||
items = append(items, linked)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate linked thoughts for learning: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -39,7 +39,6 @@ type ProjectContextOutput struct {
|
||||
Project thoughttypes.Project `json:"project"`
|
||||
Context string `json:"context"`
|
||||
Items []ContextItem `json:"items"`
|
||||
RetrievalMode string `json:"retrieval_mode,omitempty"`
|
||||
}
|
||||
|
||||
func NewContextTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *ContextTool {
|
||||
@@ -71,14 +70,12 @@ func (t *ContextTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in P
|
||||
})
|
||||
}
|
||||
|
||||
var retrievalMode string
|
||||
query := strings.TrimSpace(in.Query)
|
||||
if query != "" {
|
||||
semantic, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, &project.NumericID, nil)
|
||||
semantic, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, &project.NumericID, nil)
|
||||
if err != nil {
|
||||
return nil, ProjectContextOutput{}, err
|
||||
}
|
||||
retrievalMode = mode
|
||||
for _, result := range semantic {
|
||||
key := fmt.Sprint(result.ID)
|
||||
if _, ok := seen[key]; ok {
|
||||
@@ -106,6 +103,5 @@ func (t *ContextTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in P
|
||||
Project: *project,
|
||||
Context: contextBlock,
|
||||
Items: items,
|
||||
RetrievalMode: retrievalMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ type RelatedThought struct {
|
||||
|
||||
type RelatedOutput struct {
|
||||
Related []RelatedThought `json:"related"`
|
||||
RetrievalMode string `json:"retrieval_mode,omitempty"`
|
||||
}
|
||||
|
||||
func NewLinksTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig) *LinksTool {
|
||||
@@ -118,13 +117,11 @@ func (t *LinksTool) Related(ctx context.Context, _ *mcp.CallToolRequest, in Rela
|
||||
includeSemantic = *in.IncludeSemantic
|
||||
}
|
||||
|
||||
var retrievalMode string
|
||||
if includeSemantic {
|
||||
semantic, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, thought.Content, t.search.DefaultLimit, t.search.DefaultThreshold, thought.ProjectID, &thought.GUID)
|
||||
semantic, err := semanticSearch(ctx, t.store, t.embeddings, t.search, thought.Content, t.search.DefaultLimit, t.search.DefaultThreshold, thought.ProjectID, &thought.GUID)
|
||||
if err != nil {
|
||||
return nil, RelatedOutput{}, err
|
||||
}
|
||||
retrievalMode = mode
|
||||
for _, item := range semantic {
|
||||
key := fmt.Sprint(item.ID)
|
||||
if _, ok := seen[key]; ok {
|
||||
@@ -141,5 +138,5 @@ func (t *LinksTool) Related(ctx context.Context, _ *mcp.CallToolRequest, in Rela
|
||||
}
|
||||
}
|
||||
|
||||
return nil, RelatedOutput{Related: related, RetrievalMode: retrievalMode}, nil
|
||||
return nil, RelatedOutput{Related: related}, nil
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ type RecallInput struct {
|
||||
type RecallOutput struct {
|
||||
Context string `json:"context"`
|
||||
Items []ContextItem `json:"items"`
|
||||
RetrievalMode string `json:"retrieval_mode,omitempty"`
|
||||
}
|
||||
|
||||
func NewRecallTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *RecallTool {
|
||||
@@ -54,7 +53,7 @@ func (t *RecallTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in Re
|
||||
projectID = &project.NumericID
|
||||
}
|
||||
|
||||
semantic, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil)
|
||||
semantic, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil)
|
||||
if err != nil {
|
||||
return nil, RecallOutput{}, err
|
||||
}
|
||||
@@ -103,6 +102,5 @@ func (t *RecallTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in Re
|
||||
return nil, RecallOutput{
|
||||
Context: formatContextBlock(header, lines),
|
||||
Items: items,
|
||||
RetrievalMode: mode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -11,16 +11,10 @@ import (
|
||||
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
|
||||
const (
|
||||
RetrievalModeSemantic = "semantic"
|
||||
RetrievalModeText = "text"
|
||||
)
|
||||
|
||||
// semanticSearch runs vector similarity search if embeddings exist for the
|
||||
// primary embedding model in the given scope, otherwise falls back to Postgres
|
||||
// full-text search. Search always uses the primary model so query vectors
|
||||
// match rows stored under the primary model name.
|
||||
// It returns the results and the retrieval mode used ("semantic" or "text").
|
||||
func semanticSearch(
|
||||
ctx context.Context,
|
||||
db *store.DB,
|
||||
@@ -31,22 +25,20 @@ func semanticSearch(
|
||||
threshold float64,
|
||||
projectID *int64,
|
||||
excludeID *uuid.UUID,
|
||||
) ([]thoughttypes.SearchResult, string, error) {
|
||||
) ([]thoughttypes.SearchResult, error) {
|
||||
model := embeddings.PrimaryModel()
|
||||
hasEmbeddings, err := db.HasEmbeddingsForModel(ctx, model, projectID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if hasEmbeddings {
|
||||
embedding, err := embeddings.EmbedPrimary(ctx, query)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, err
|
||||
}
|
||||
results, err := db.SearchSimilarThoughts(ctx, embedding, model, threshold, limit, projectID, excludeID)
|
||||
return results, RetrievalModeSemantic, err
|
||||
return db.SearchSimilarThoughts(ctx, embedding, model, threshold, limit, projectID, excludeID)
|
||||
}
|
||||
|
||||
results, err := db.SearchThoughtsText(ctx, query, limit, projectID, excludeID)
|
||||
return results, RetrievalModeText, err
|
||||
return db.SearchThoughtsText(ctx, query, limit, projectID, excludeID)
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
package tools
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRetrievalModeConstants(t *testing.T) {
|
||||
if RetrievalModeSemantic != "semantic" {
|
||||
t.Fatalf("RetrievalModeSemantic = %q, want %q", RetrievalModeSemantic, "semantic")
|
||||
}
|
||||
if RetrievalModeText != "text" {
|
||||
t.Fatalf("RetrievalModeText = %q, want %q", RetrievalModeText, "text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchOutputIncludesRetrievalMode(t *testing.T) {
|
||||
out := SearchOutput{RetrievalMode: RetrievalModeSemantic}
|
||||
if out.RetrievalMode != RetrievalModeSemantic {
|
||||
t.Fatalf("SearchOutput.RetrievalMode = %q, want %q", out.RetrievalMode, RetrievalModeSemantic)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelatedOutputIncludesRetrievalMode(t *testing.T) {
|
||||
out := RelatedOutput{RetrievalMode: RetrievalModeText}
|
||||
if out.RetrievalMode != RetrievalModeText {
|
||||
t.Fatalf("RelatedOutput.RetrievalMode = %q, want %q", out.RetrievalMode, RetrievalModeText)
|
||||
}
|
||||
// retrieval_mode is omitempty — empty string means semantic search was skipped
|
||||
out2 := RelatedOutput{}
|
||||
if out2.RetrievalMode != "" {
|
||||
t.Fatalf("RelatedOutput.RetrievalMode = %q, want empty when include_semantic is false", out2.RetrievalMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeOutputIncludesRetrievalModeOnlyWhenQueryUsed(t *testing.T) {
|
||||
withQuery := SummarizeOutput{Summary: "s", Count: 1, RetrievalMode: RetrievalModeSemantic}
|
||||
if withQuery.RetrievalMode != RetrievalModeSemantic {
|
||||
t.Fatalf("SummarizeOutput.RetrievalMode = %q, want %q", withQuery.RetrievalMode, RetrievalModeSemantic)
|
||||
}
|
||||
withoutQuery := SummarizeOutput{Summary: "s", Count: 1}
|
||||
if withoutQuery.RetrievalMode != "" {
|
||||
t.Fatalf("SummarizeOutput.RetrievalMode = %q, want empty when no query", withoutQuery.RetrievalMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectContextOutputIncludesRetrievalModeOnlyWhenQueryUsed(t *testing.T) {
|
||||
withQuery := ProjectContextOutput{RetrievalMode: RetrievalModeText}
|
||||
if withQuery.RetrievalMode != RetrievalModeText {
|
||||
t.Fatalf("ProjectContextOutput.RetrievalMode = %q, want %q", withQuery.RetrievalMode, RetrievalModeText)
|
||||
}
|
||||
withoutQuery := ProjectContextOutput{}
|
||||
if withoutQuery.RetrievalMode != "" {
|
||||
t.Fatalf("ProjectContextOutput.RetrievalMode = %q, want empty when no query", withoutQuery.RetrievalMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecallOutputIncludesRetrievalMode(t *testing.T) {
|
||||
out := RecallOutput{RetrievalMode: RetrievalModeSemantic}
|
||||
if out.RetrievalMode != RetrievalModeSemantic {
|
||||
t.Fatalf("RecallOutput.RetrievalMode = %q, want %q", out.RetrievalMode, RetrievalModeSemantic)
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ type SearchInput struct {
|
||||
|
||||
type SearchOutput struct {
|
||||
Results []thoughttypes.SearchResult `json:"results"`
|
||||
RetrievalMode string `json:"retrieval_mode,omitempty"`
|
||||
}
|
||||
|
||||
func NewSearchTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *SearchTool {
|
||||
@@ -56,10 +55,10 @@ func (t *SearchTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in Se
|
||||
_ = t.store.TouchProject(ctx, project.NumericID)
|
||||
}
|
||||
|
||||
results, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, threshold, projectID, nil)
|
||||
results, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, threshold, projectID, nil)
|
||||
if err != nil {
|
||||
return nil, SearchOutput{}, err
|
||||
}
|
||||
|
||||
return nil, SearchOutput{Results: results, RetrievalMode: mode}, nil
|
||||
return nil, SearchOutput{Results: results}, nil
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ type SummarizeInput struct {
|
||||
type SummarizeOutput struct {
|
||||
Summary string `json:"summary"`
|
||||
Count int `json:"count"`
|
||||
RetrievalMode string `json:"retrieval_mode,omitempty"`
|
||||
}
|
||||
|
||||
func NewSummarizeTool(db *store.DB, embeddings *ai.EmbeddingRunner, metadata *ai.MetadataRunner, search config.SearchConfig, sessions *session.ActiveProjects) *SummarizeTool {
|
||||
@@ -48,17 +47,15 @@ func (t *SummarizeTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in
|
||||
lines := make([]string, 0, limit)
|
||||
count := 0
|
||||
|
||||
var retrievalMode string
|
||||
if query != "" {
|
||||
var projectID *int64
|
||||
if project != nil {
|
||||
projectID = &project.NumericID
|
||||
}
|
||||
results, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil)
|
||||
results, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil)
|
||||
if err != nil {
|
||||
return nil, SummarizeOutput{}, err
|
||||
}
|
||||
retrievalMode = mode
|
||||
for i, result := range results {
|
||||
lines = append(lines, thoughtContextLine(i, result.Content, result.Metadata, result.Similarity))
|
||||
}
|
||||
@@ -88,5 +85,5 @@ func (t *SummarizeTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in
|
||||
_ = t.store.TouchProject(ctx, project.NumericID)
|
||||
}
|
||||
|
||||
return nil, SummarizeOutput{Summary: summary, Count: count, RetrievalMode: retrievalMode}, nil
|
||||
return nil, SummarizeOutput{Summary: summary, Count: count}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/store"
|
||||
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
|
||||
type ThoughtLearningLinksTool struct {
|
||||
store *store.DB
|
||||
}
|
||||
|
||||
type LinkThoughtLearningInput struct {
|
||||
ThoughtID string `json:"thought_id" jsonschema:"UUID of the thought to link"`
|
||||
LearningID int64 `json:"learning_id" jsonschema:"numeric id of the learning to link"`
|
||||
Relation string `json:"relation,omitempty" jsonschema:"relationship label, e.g. source, derived_from, related; defaults to source"`
|
||||
}
|
||||
|
||||
type LinkThoughtLearningOutput struct {
|
||||
Linked bool `json:"linked"`
|
||||
}
|
||||
|
||||
type UnlinkThoughtLearningInput struct {
|
||||
ThoughtID string `json:"thought_id" jsonschema:"UUID of the thought"`
|
||||
LearningID int64 `json:"learning_id" jsonschema:"numeric id of the learning"`
|
||||
}
|
||||
|
||||
type UnlinkThoughtLearningOutput struct {
|
||||
Unlinked bool `json:"unlinked"`
|
||||
}
|
||||
|
||||
type GetThoughtLearningsInput struct {
|
||||
ThoughtID string `json:"thought_id" jsonschema:"UUID of the thought"`
|
||||
}
|
||||
|
||||
type GetThoughtLearningsOutput struct {
|
||||
Learnings []thoughttypes.LinkedLearning `json:"learnings"`
|
||||
}
|
||||
|
||||
type GetLearningThoughtsInput struct {
|
||||
LearningID int64 `json:"learning_id" jsonschema:"numeric id of the learning"`
|
||||
}
|
||||
|
||||
type GetLearningThoughtsOutput struct {
|
||||
Thoughts []thoughttypes.LinkedLearningThought `json:"thoughts"`
|
||||
}
|
||||
|
||||
func NewThoughtLearningLinksTool(db *store.DB) *ThoughtLearningLinksTool {
|
||||
return &ThoughtLearningLinksTool{store: db}
|
||||
}
|
||||
|
||||
func (t *ThoughtLearningLinksTool) Link(ctx context.Context, _ *mcp.CallToolRequest, in LinkThoughtLearningInput) (*mcp.CallToolResult, LinkThoughtLearningOutput, error) {
|
||||
if err := t.ensureConfigured(); err != nil {
|
||||
return nil, LinkThoughtLearningOutput{}, err
|
||||
}
|
||||
|
||||
thoughtGUID, err := parseUUID(in.ThoughtID)
|
||||
if err != nil {
|
||||
return nil, LinkThoughtLearningOutput{}, err
|
||||
}
|
||||
if in.LearningID <= 0 {
|
||||
return nil, LinkThoughtLearningOutput{}, errRequiredField("learning_id")
|
||||
}
|
||||
relation := strings.TrimSpace(in.Relation)
|
||||
if relation == "" {
|
||||
relation = "source"
|
||||
}
|
||||
|
||||
if _, err := t.store.GetThought(ctx, thoughtGUID); err != nil {
|
||||
return nil, LinkThoughtLearningOutput{}, errEntityNotFound("thought", "thought_id", in.ThoughtID)
|
||||
}
|
||||
if _, err := t.store.GetLearning(ctx, in.LearningID); err != nil {
|
||||
return nil, LinkThoughtLearningOutput{}, errEntityNotFound("learning", "learning_id", strconv.FormatInt(in.LearningID, 10))
|
||||
}
|
||||
|
||||
if err := t.store.LinkThoughtLearning(ctx, thoughtGUID, in.LearningID, relation); err != nil {
|
||||
return nil, LinkThoughtLearningOutput{}, err
|
||||
}
|
||||
return nil, LinkThoughtLearningOutput{Linked: true}, nil
|
||||
}
|
||||
|
||||
func (t *ThoughtLearningLinksTool) Unlink(ctx context.Context, _ *mcp.CallToolRequest, in UnlinkThoughtLearningInput) (*mcp.CallToolResult, UnlinkThoughtLearningOutput, error) {
|
||||
if err := t.ensureConfigured(); err != nil {
|
||||
return nil, UnlinkThoughtLearningOutput{}, err
|
||||
}
|
||||
|
||||
thoughtGUID, err := parseUUID(in.ThoughtID)
|
||||
if err != nil {
|
||||
return nil, UnlinkThoughtLearningOutput{}, err
|
||||
}
|
||||
if in.LearningID <= 0 {
|
||||
return nil, UnlinkThoughtLearningOutput{}, errRequiredField("learning_id")
|
||||
}
|
||||
|
||||
if err := t.store.UnlinkThoughtLearning(ctx, thoughtGUID, in.LearningID); err != nil {
|
||||
return nil, UnlinkThoughtLearningOutput{}, err
|
||||
}
|
||||
return nil, UnlinkThoughtLearningOutput{Unlinked: true}, nil
|
||||
}
|
||||
|
||||
func (t *ThoughtLearningLinksTool) GetThoughtLearnings(ctx context.Context, _ *mcp.CallToolRequest, in GetThoughtLearningsInput) (*mcp.CallToolResult, GetThoughtLearningsOutput, error) {
|
||||
if err := t.ensureConfigured(); err != nil {
|
||||
return nil, GetThoughtLearningsOutput{}, err
|
||||
}
|
||||
|
||||
thoughtGUID, err := parseUUID(in.ThoughtID)
|
||||
if err != nil {
|
||||
return nil, GetThoughtLearningsOutput{}, err
|
||||
}
|
||||
|
||||
if _, err := t.store.GetThought(ctx, thoughtGUID); err != nil {
|
||||
return nil, GetThoughtLearningsOutput{}, errEntityNotFound("thought", "thought_id", in.ThoughtID)
|
||||
}
|
||||
|
||||
learnings, err := t.store.GetLinkedLearnings(ctx, thoughtGUID)
|
||||
if err != nil {
|
||||
return nil, GetThoughtLearningsOutput{}, err
|
||||
}
|
||||
return nil, GetThoughtLearningsOutput{Learnings: learnings}, nil
|
||||
}
|
||||
|
||||
func (t *ThoughtLearningLinksTool) GetLearningThoughts(ctx context.Context, _ *mcp.CallToolRequest, in GetLearningThoughtsInput) (*mcp.CallToolResult, GetLearningThoughtsOutput, error) {
|
||||
if err := t.ensureConfigured(); err != nil {
|
||||
return nil, GetLearningThoughtsOutput{}, err
|
||||
}
|
||||
|
||||
if in.LearningID <= 0 {
|
||||
return nil, GetLearningThoughtsOutput{}, errRequiredField("learning_id")
|
||||
}
|
||||
|
||||
if _, err := t.store.GetLearning(ctx, in.LearningID); err != nil {
|
||||
return nil, GetLearningThoughtsOutput{}, errEntityNotFound("learning", "learning_id", strconv.FormatInt(in.LearningID, 10))
|
||||
}
|
||||
|
||||
thoughts, err := t.store.GetLinkedThoughtsForLearning(ctx, in.LearningID)
|
||||
if err != nil {
|
||||
return nil, GetLearningThoughtsOutput{}, err
|
||||
}
|
||||
return nil, GetLearningThoughtsOutput{Thoughts: thoughts}, nil
|
||||
}
|
||||
|
||||
func (t *ThoughtLearningLinksTool) ensureConfigured() error {
|
||||
if t == nil || t.store == nil {
|
||||
return errInvalidInput("thought learning links tool is not configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -39,3 +39,22 @@ type LinkedThought struct {
|
||||
Direction string `json:"direction"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type ThoughtLearningLink struct {
|
||||
ThoughtID int64 `json:"thought_id"`
|
||||
LearningID int64 `json:"learning_id"`
|
||||
Relation string `json:"relation"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type LinkedLearning struct {
|
||||
Learning Learning `json:"learning"`
|
||||
Relation string `json:"relation"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type LinkedLearningThought struct {
|
||||
Thought Thought `json:"thought"`
|
||||
Relation string `json:"relation"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Many-to-many join table between thoughts and learnings.
|
||||
-- Allows a learning to reference multiple source thoughts and a thought to
|
||||
-- expose multiple related learnings, queryable in both directions.
|
||||
|
||||
CREATE SEQUENCE IF NOT EXISTS public.identity_thought_learning_links_id
|
||||
INCREMENT 1
|
||||
MINVALUE 1
|
||||
MAXVALUE 9223372036854775807
|
||||
START 1
|
||||
CACHE 1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.thought_learning_links (
|
||||
id bigint NOT NULL DEFAULT nextval('public.identity_thought_learning_links_id'),
|
||||
thought_id bigint NOT NULL REFERENCES public.thoughts(id) ON DELETE CASCADE,
|
||||
learning_id bigint NOT NULL REFERENCES public.learnings(id) ON DELETE CASCADE,
|
||||
relation text NOT NULL DEFAULT 'source',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT thought_learning_links_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT thought_learning_links_unique UNIQUE (thought_id, learning_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_thought_learning_links_thought_id
|
||||
ON public.thought_learning_links (thought_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_thought_learning_links_learning_id
|
||||
ON public.thought_learning_links (learning_id);
|
||||
@@ -32,6 +32,20 @@ Table thought_links {
|
||||
}
|
||||
}
|
||||
|
||||
Table thought_learning_links {
|
||||
id bigserial [pk]
|
||||
thought_id bigint [not null, ref: > thoughts.id]
|
||||
learning_id bigint [not null, ref: > learnings.id]
|
||||
relation text [not null, default: `'source'`]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(thought_id, learning_id) [unique]
|
||||
thought_id
|
||||
learning_id
|
||||
}
|
||||
}
|
||||
|
||||
Table embeddings {
|
||||
id bigserial [pk]
|
||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||
|
||||
Reference in New Issue
Block a user