Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8db2141d45 | |||
| 3198600031 | |||
| 5c899d1635 | |||
| 81a3470407 |
@@ -31,6 +31,20 @@ jobs:
|
|||||||
- name: Download dependencies
|
- name: Download dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 'lts/*'
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
run: npm install -g pnpm
|
||||||
|
|
||||||
|
- name: Build UI
|
||||||
|
run: |
|
||||||
|
cd ui
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm run build
|
||||||
|
|
||||||
- name: Tidy modules
|
- name: Tidy modules
|
||||||
run: go mod tidy
|
run: go mod tidy
|
||||||
|
|
||||||
|
|||||||
@@ -36,3 +36,4 @@ ui/.svelte-kit/
|
|||||||
internal/app/ui/dist/*
|
internal/app/ui/dist/*
|
||||||
!internal/app/ui/dist/placeholder.txt
|
!internal/app/ui/dist/placeholder.txt
|
||||||
.codex
|
.codex
|
||||||
|
.worktrees/
|
||||||
|
|||||||
@@ -478,7 +478,7 @@ metadata_retry:
|
|||||||
include_archived: false
|
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
|
## Client Setup
|
||||||
|
|
||||||
|
|||||||
+24
-22
@@ -194,28 +194,30 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
|||||||
adminActions := newAdminActions(backfillTool, enrichmentRetryer, logger)
|
adminActions := newAdminActions(backfillTool, enrichmentRetryer, logger)
|
||||||
|
|
||||||
toolSet := mcpserver.ToolSet{
|
toolSet := mcpserver.ToolSet{
|
||||||
Capture: tools.NewCaptureTool(db, embeddings, cfg.Capture, activeProjects, enrichmentRetryer, backfillTool),
|
Capture: tools.NewCaptureTool(db, embeddings, cfg.Capture, activeProjects, enrichmentRetryer, backfillTool),
|
||||||
Search: tools.NewSearchTool(db, embeddings, cfg.Search, activeProjects),
|
Search: tools.NewSearchTool(db, embeddings, cfg.Search, activeProjects),
|
||||||
List: tools.NewListTool(db, cfg.Search, activeProjects),
|
List: tools.NewListTool(db, cfg.Search, activeProjects),
|
||||||
Stats: tools.NewStatsTool(db),
|
Stats: tools.NewStatsTool(db),
|
||||||
Get: tools.NewGetTool(db),
|
Get: tools.NewGetTool(db),
|
||||||
Update: tools.NewUpdateTool(db, embeddings, metadata, cfg.Capture, logger),
|
Update: tools.NewUpdateTool(db, embeddings, metadata, cfg.Capture, logger),
|
||||||
Delete: tools.NewDeleteTool(db),
|
Delete: tools.NewDeleteTool(db),
|
||||||
Archive: tools.NewArchiveTool(db),
|
Archive: tools.NewArchiveTool(db),
|
||||||
Projects: tools.NewProjectsTool(db, activeProjects),
|
DuplicateAudit: tools.NewDuplicateAuditTool(db, cfg.Search, activeProjects),
|
||||||
Version: tools.NewVersionTool(cfg.MCP.ServerName, info),
|
Projects: tools.NewProjectsTool(db, activeProjects),
|
||||||
Learnings: tools.NewLearningsTool(db, activeProjects, cfg.Search),
|
Version: tools.NewVersionTool(cfg.MCP.ServerName, info),
|
||||||
Plans: tools.NewPlansTool(db, activeProjects, cfg.Search),
|
Learnings: tools.NewLearningsTool(db, activeProjects, cfg.Search),
|
||||||
ProjectPersonas: tools.NewProjectPersonasTool(db, activeProjects),
|
Plans: tools.NewPlansTool(db, activeProjects, cfg.Search),
|
||||||
WorldModel: tools.NewWorldModelTool(db, activeProjects),
|
ProjectPersonas: tools.NewProjectPersonasTool(db, activeProjects),
|
||||||
Context: tools.NewContextTool(db, embeddings, cfg.Search, activeProjects),
|
WorldModel: tools.NewWorldModelTool(db, activeProjects),
|
||||||
Recall: tools.NewRecallTool(db, embeddings, cfg.Search, activeProjects),
|
ThoughtLearningLinks: tools.NewThoughtLearningLinksTool(db),
|
||||||
Summarize: tools.NewSummarizeTool(db, embeddings, metadata, cfg.Search, activeProjects),
|
Context: tools.NewContextTool(db, embeddings, cfg.Search, activeProjects),
|
||||||
Links: tools.NewLinksTool(db, embeddings, cfg.Search),
|
Recall: tools.NewRecallTool(db, embeddings, cfg.Search, activeProjects),
|
||||||
Files: filesTool,
|
Summarize: tools.NewSummarizeTool(db, embeddings, metadata, cfg.Search, activeProjects),
|
||||||
Backfill: backfillTool,
|
Links: tools.NewLinksTool(db, embeddings, cfg.Search),
|
||||||
Reparse: tools.NewReparseMetadataTool(db, bgMetadata, cfg.Capture, activeProjects, logger),
|
Files: filesTool,
|
||||||
RetryMetadata: tools.NewRetryEnrichmentTool(enrichmentRetryer),
|
Backfill: backfillTool,
|
||||||
|
Reparse: tools.NewReparseMetadataTool(db, bgMetadata, cfg.Capture, activeProjects, logger),
|
||||||
|
RetryMetadata: tools.NewRetryEnrichmentTool(enrichmentRetryer),
|
||||||
//Maintenance: tools.NewMaintenanceTool(db),
|
//Maintenance: tools.NewMaintenanceTool(db),
|
||||||
Skills: tools.NewSkillsTool(db, activeProjects),
|
Skills: tools.NewSkillsTool(db, activeProjects),
|
||||||
Personas: tools.NewAgentPersonasTool(db),
|
Personas: tools.NewAgentPersonasTool(db),
|
||||||
|
|||||||
@@ -19,33 +19,35 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ToolSet struct {
|
type ToolSet struct {
|
||||||
Version *tools.VersionTool
|
Version *tools.VersionTool
|
||||||
Capture *tools.CaptureTool
|
Capture *tools.CaptureTool
|
||||||
Search *tools.SearchTool
|
Search *tools.SearchTool
|
||||||
List *tools.ListTool
|
List *tools.ListTool
|
||||||
Stats *tools.StatsTool
|
Stats *tools.StatsTool
|
||||||
Get *tools.GetTool
|
Get *tools.GetTool
|
||||||
Update *tools.UpdateTool
|
Update *tools.UpdateTool
|
||||||
Delete *tools.DeleteTool
|
Delete *tools.DeleteTool
|
||||||
Archive *tools.ArchiveTool
|
Archive *tools.ArchiveTool
|
||||||
Projects *tools.ProjectsTool
|
DuplicateAudit *tools.DuplicateAuditTool
|
||||||
Context *tools.ContextTool
|
Projects *tools.ProjectsTool
|
||||||
Recall *tools.RecallTool
|
Context *tools.ContextTool
|
||||||
Summarize *tools.SummarizeTool
|
Recall *tools.RecallTool
|
||||||
Links *tools.LinksTool
|
Summarize *tools.SummarizeTool
|
||||||
Files *tools.FilesTool
|
Links *tools.LinksTool
|
||||||
Backfill *tools.BackfillTool
|
Files *tools.FilesTool
|
||||||
Reparse *tools.ReparseMetadataTool
|
Backfill *tools.BackfillTool
|
||||||
RetryMetadata *tools.RetryEnrichmentTool
|
Reparse *tools.ReparseMetadataTool
|
||||||
|
RetryMetadata *tools.RetryEnrichmentTool
|
||||||
//Maintenance *tools.MaintenanceTool
|
//Maintenance *tools.MaintenanceTool
|
||||||
Skills *tools.SkillsTool
|
Skills *tools.SkillsTool
|
||||||
Personas *tools.AgentPersonasTool
|
Personas *tools.AgentPersonasTool
|
||||||
ChatHistory *tools.ChatHistoryTool
|
ChatHistory *tools.ChatHistoryTool
|
||||||
Describe *tools.DescribeTool
|
Describe *tools.DescribeTool
|
||||||
Learnings *tools.LearningsTool
|
Learnings *tools.LearningsTool
|
||||||
Plans *tools.PlansTool
|
Plans *tools.PlansTool
|
||||||
ProjectPersonas *tools.ProjectPersonasTool
|
ProjectPersonas *tools.ProjectPersonasTool
|
||||||
WorldModel *tools.WorldModelTool
|
WorldModel *tools.WorldModelTool
|
||||||
|
ThoughtLearningLinks *tools.ThoughtLearningLinksTool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers groups the HTTP handlers produced for an MCP server instance.
|
// Handlers groups the HTTP handlers produced for an MCP server instance.
|
||||||
@@ -91,6 +93,7 @@ func NewHandlers(cfg config.MCPConfig, logger *slog.Logger, toolSet ToolSet, onS
|
|||||||
registerProjectTools,
|
registerProjectTools,
|
||||||
registerWorldModelTools,
|
registerWorldModelTools,
|
||||||
registerLearningTools,
|
registerLearningTools,
|
||||||
|
registerThoughtLearningLinkTools,
|
||||||
registerPlanTools,
|
registerPlanTools,
|
||||||
registerFileTools,
|
registerFileTools,
|
||||||
registerMaintenanceTools,
|
registerMaintenanceTools,
|
||||||
@@ -225,6 +228,12 @@ func registerThoughtTools(server *mcp.Server, logger *slog.Logger, toolSet ToolS
|
|||||||
}, toolSet.Archive.Handle); err != nil {
|
}, toolSet.Archive.Handle); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
|
Name: "audit_duplicates",
|
||||||
|
Description: "Dry-run duplicate audit for projects, thoughts, and metadata normalization candidates; performs no writes.",
|
||||||
|
}, toolSet.DuplicateAudit.Handle); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "summarize_thoughts",
|
Name: "summarize_thoughts",
|
||||||
Description: "LLM summary of a filtered set of thoughts.",
|
Description: "LLM summary of a filtered set of thoughts.",
|
||||||
@@ -308,6 +317,34 @@ func registerLearningTools(server *mcp.Server, logger *slog.Logger, toolSet Tool
|
|||||||
return nil
|
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 {
|
func registerPlanTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "create_plan",
|
Name: "create_plan",
|
||||||
@@ -734,6 +771,7 @@ func BuildToolCatalog() []tools.ToolEntry {
|
|||||||
{Name: "update_thought", Description: "Update thought content or merge metadata.", Category: "thoughts"},
|
{Name: "update_thought", Description: "Update thought content or merge metadata.", Category: "thoughts"},
|
||||||
{Name: "delete_thought", Description: "Hard-delete a thought by id.", Category: "thoughts"},
|
{Name: "delete_thought", Description: "Hard-delete a thought by id.", Category: "thoughts"},
|
||||||
{Name: "archive_thought", Description: "Archive a thought so it is hidden from default search and listing.", Category: "thoughts"},
|
{Name: "archive_thought", Description: "Archive a thought so it is hidden from default search and listing.", Category: "thoughts"},
|
||||||
|
{Name: "audit_duplicates", Description: "Dry-run duplicate audit for exact/normalized project names, thought content, and metadata value variants. Reports candidates and recommendations; performs no writes.", Category: "admin"},
|
||||||
{Name: "summarize_thoughts", Description: "Produce an LLM prose summary of a filtered or searched set of thoughts.", Category: "thoughts"},
|
{Name: "summarize_thoughts", Description: "Produce an LLM prose summary of a filtered or searched set of thoughts.", Category: "thoughts"},
|
||||||
{Name: "recall_context", Description: "Recall semantically relevant and recent context for prompt injection. Combines vector similarity with recency. Falls back to full-text search when no embeddings exist.", Category: "thoughts"},
|
{Name: "recall_context", Description: "Recall semantically relevant and recent context for prompt injection. Combines vector similarity with recency. Falls back to full-text search when no embeddings exist.", Category: "thoughts"},
|
||||||
{Name: "link_thoughts", Description: "Create a typed relationship between two thoughts.", Category: "thoughts"},
|
{Name: "link_thoughts", Description: "Create a typed relationship between two thoughts.", Category: "thoughts"},
|
||||||
@@ -755,6 +793,10 @@ func BuildToolCatalog() []tools.ToolEntry {
|
|||||||
{Name: "add_learning", Description: "Create a curated learning record distinct from raw thoughts.", Category: "projects"},
|
{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: "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: "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
|
// plans
|
||||||
{Name: "create_plan", Description: "Create a structured plan with status, priority, owner, due date, and optional project link.", Category: "plans"},
|
{Name: "create_plan", Description: "Create a structured plan with status, priority, owner, due date, and optional project link.", Category: "plans"},
|
||||||
|
|||||||
@@ -189,30 +189,31 @@ func TestStreamableHTTPReturnsStructuredToolErrors(t *testing.T) {
|
|||||||
|
|
||||||
func streamableTestToolSet() ToolSet {
|
func streamableTestToolSet() ToolSet {
|
||||||
return ToolSet{
|
return ToolSet{
|
||||||
Version: tools.NewVersionTool("test", buildinfo.Info{Version: "0.0.1", TagName: "v0.0.1", Commit: "test", BuildDate: "2026-03-31T00:00:00Z"}),
|
Version: tools.NewVersionTool("test", buildinfo.Info{Version: "0.0.1", TagName: "v0.0.1", Commit: "test", BuildDate: "2026-03-31T00:00:00Z"}),
|
||||||
Capture: new(tools.CaptureTool),
|
Capture: new(tools.CaptureTool),
|
||||||
Search: new(tools.SearchTool),
|
Search: new(tools.SearchTool),
|
||||||
List: new(tools.ListTool),
|
List: new(tools.ListTool),
|
||||||
Stats: new(tools.StatsTool),
|
Stats: new(tools.StatsTool),
|
||||||
Get: new(tools.GetTool),
|
Get: new(tools.GetTool),
|
||||||
Update: new(tools.UpdateTool),
|
Update: new(tools.UpdateTool),
|
||||||
Delete: new(tools.DeleteTool),
|
Delete: new(tools.DeleteTool),
|
||||||
Archive: new(tools.ArchiveTool),
|
Archive: new(tools.ArchiveTool),
|
||||||
Projects: new(tools.ProjectsTool),
|
Projects: new(tools.ProjectsTool),
|
||||||
Context: new(tools.ContextTool),
|
Context: new(tools.ContextTool),
|
||||||
Recall: new(tools.RecallTool),
|
Recall: new(tools.RecallTool),
|
||||||
Summarize: new(tools.SummarizeTool),
|
Summarize: new(tools.SummarizeTool),
|
||||||
Links: new(tools.LinksTool),
|
Links: new(tools.LinksTool),
|
||||||
Files: new(tools.FilesTool),
|
Files: new(tools.FilesTool),
|
||||||
Backfill: new(tools.BackfillTool),
|
Backfill: new(tools.BackfillTool),
|
||||||
Reparse: new(tools.ReparseMetadataTool),
|
Reparse: new(tools.ReparseMetadataTool),
|
||||||
RetryMetadata: new(tools.RetryEnrichmentTool),
|
RetryMetadata: new(tools.RetryEnrichmentTool),
|
||||||
Skills: new(tools.SkillsTool),
|
Skills: new(tools.SkillsTool),
|
||||||
ChatHistory: new(tools.ChatHistoryTool),
|
ChatHistory: new(tools.ChatHistoryTool),
|
||||||
Describe: new(tools.DescribeTool),
|
Describe: new(tools.DescribeTool),
|
||||||
Learnings: new(tools.LearningsTool),
|
Learnings: new(tools.LearningsTool),
|
||||||
Plans: new(tools.PlansTool),
|
Plans: new(tools.PlansTool),
|
||||||
ProjectPersonas: new(tools.ProjectPersonasTool),
|
ProjectPersonas: new(tools.ProjectPersonasTool),
|
||||||
WorldModel: new(tools.WorldModelTool),
|
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
|
||||||
|
}
|
||||||
@@ -36,10 +36,9 @@ type ContextItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProjectContextOutput struct {
|
type ProjectContextOutput struct {
|
||||||
Project thoughttypes.Project `json:"project"`
|
Project thoughttypes.Project `json:"project"`
|
||||||
Context string `json:"context"`
|
Context string `json:"context"`
|
||||||
Items []ContextItem `json:"items"`
|
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 {
|
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)
|
query := strings.TrimSpace(in.Query)
|
||||||
if 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 {
|
if err != nil {
|
||||||
return nil, ProjectContextOutput{}, err
|
return nil, ProjectContextOutput{}, err
|
||||||
}
|
}
|
||||||
retrievalMode = mode
|
|
||||||
for _, result := range semantic {
|
for _, result := range semantic {
|
||||||
key := fmt.Sprint(result.ID)
|
key := fmt.Sprint(result.ID)
|
||||||
if _, ok := seen[key]; ok {
|
if _, ok := seen[key]; ok {
|
||||||
@@ -103,9 +100,8 @@ func (t *ContextTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in P
|
|||||||
_ = t.store.TouchProject(ctx, project.NumericID)
|
_ = t.store.TouchProject(ctx, project.NumericID)
|
||||||
|
|
||||||
return nil, ProjectContextOutput{
|
return nil, ProjectContextOutput{
|
||||||
Project: *project,
|
Project: *project,
|
||||||
Context: contextBlock,
|
Context: contextBlock,
|
||||||
Items: items,
|
Items: items,
|
||||||
RetrievalMode: retrievalMode,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/session"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/store"
|
||||||
|
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultDuplicateAuditLimit = 50
|
||||||
|
|
||||||
|
var duplicateWhitespace = regexp.MustCompile(`\s+`)
|
||||||
|
|
||||||
|
type DuplicateAuditTool struct {
|
||||||
|
store *store.DB
|
||||||
|
search config.SearchConfig
|
||||||
|
sessions *session.ActiveProjects
|
||||||
|
}
|
||||||
|
|
||||||
|
type DuplicateAuditInput struct {
|
||||||
|
Project string `json:"project,omitempty" jsonschema:"optional project name or id to scope thought duplicate scanning"`
|
||||||
|
Limit int `json:"limit,omitempty" jsonschema:"maximum candidate groups to return"`
|
||||||
|
ThoughtLimit int `json:"thought_limit,omitempty" jsonschema:"maximum thoughts to scan for duplicate content; defaults to search limit"`
|
||||||
|
IncludeArchived bool `json:"include_archived,omitempty" jsonschema:"include archived thoughts in the scan"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DuplicateAuditOutput struct {
|
||||||
|
Report DuplicateAuditReport `json:"report"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DuplicateAuditReport struct {
|
||||||
|
Summary DuplicateAuditSummary `json:"summary"`
|
||||||
|
ProjectCandidates []DuplicateProjectCandidate `json:"project_candidates"`
|
||||||
|
ThoughtCandidates []DuplicateThoughtCandidate `json:"thought_candidates"`
|
||||||
|
MetadataCandidates []MetadataCleanupCandidate `json:"metadata_candidates"`
|
||||||
|
RecommendedNextStep string `json:"recommended_next_step"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DuplicateAuditSummary struct {
|
||||||
|
DryRun bool `json:"dry_run"`
|
||||||
|
ScannedProjects int `json:"scanned_projects"`
|
||||||
|
ScannedThoughts int `json:"scanned_thoughts"`
|
||||||
|
ProjectCandidateGroups int `json:"project_candidate_groups"`
|
||||||
|
ThoughtCandidateGroups int `json:"thought_candidate_groups"`
|
||||||
|
MetadataCandidateGroups int `json:"metadata_candidate_groups"`
|
||||||
|
Truncated bool `json:"truncated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DuplicateProjectCandidate struct {
|
||||||
|
MatchType string `json:"match_type"`
|
||||||
|
NormalizedValue string `json:"normalized_value"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
RecommendedCanonicalID uuid.UUID `json:"recommended_canonical_id"`
|
||||||
|
Projects []thoughttypes.ProjectSummary `json:"projects"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DuplicateThoughtCandidate struct {
|
||||||
|
MatchType string `json:"match_type"`
|
||||||
|
NormalizedValue string `json:"normalized_value"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
RecommendedCanonicalID uuid.UUID `json:"recommended_canonical_id"`
|
||||||
|
Thoughts []thoughttypes.Thought `json:"thoughts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetadataCleanupCandidate struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
NormalizedValue string `json:"normalized_value"`
|
||||||
|
Values []string `json:"values"`
|
||||||
|
Occurrences int `json:"occurrences"`
|
||||||
|
RecommendedValue string `json:"recommended_value"`
|
||||||
|
RecommendedAction string `json:"recommended_action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDuplicateAuditTool(db *store.DB, search config.SearchConfig, sessions *session.ActiveProjects) *DuplicateAuditTool {
|
||||||
|
return &DuplicateAuditTool{store: db, search: search, sessions: sessions}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DuplicateAuditTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in DuplicateAuditInput) (*mcp.CallToolResult, DuplicateAuditOutput, error) {
|
||||||
|
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, DuplicateAuditOutput{}, err
|
||||||
|
}
|
||||||
|
var projectID *int64
|
||||||
|
if project != nil {
|
||||||
|
projectID = &project.NumericID
|
||||||
|
}
|
||||||
|
|
||||||
|
projects, err := t.store.ListProjects(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, DuplicateAuditOutput{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
thoughtLimit := normalizeLimit(in.ThoughtLimit, t.search)
|
||||||
|
if in.ThoughtLimit <= 0 {
|
||||||
|
thoughtLimit = normalizeLimit(0, t.search)
|
||||||
|
}
|
||||||
|
thoughts, err := t.store.ListThoughts(ctx, thoughttypes.ListFilter{Limit: thoughtLimit, ProjectID: projectID, IncludeArchived: in.IncludeArchived})
|
||||||
|
if err != nil {
|
||||||
|
return nil, DuplicateAuditOutput{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if project != nil {
|
||||||
|
_ = t.store.TouchProject(ctx, project.NumericID)
|
||||||
|
}
|
||||||
|
return nil, DuplicateAuditOutput{Report: buildDuplicateAuditReport(projects, thoughts, in)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDuplicateAuditReport(projects []thoughttypes.ProjectSummary, thoughts []thoughttypes.Thought, in DuplicateAuditInput) DuplicateAuditReport {
|
||||||
|
limit := in.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = defaultDuplicateAuditLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
projectCandidates, projectTruncated := duplicateProjectCandidates(projects, limit)
|
||||||
|
thoughtCandidates, thoughtTruncated := duplicateThoughtCandidates(thoughts, limit)
|
||||||
|
metadataCandidates, metadataTruncated := metadataCleanupCandidates(thoughts, limit)
|
||||||
|
|
||||||
|
return DuplicateAuditReport{
|
||||||
|
Summary: DuplicateAuditSummary{
|
||||||
|
DryRun: true,
|
||||||
|
ScannedProjects: len(projects),
|
||||||
|
ScannedThoughts: len(thoughts),
|
||||||
|
ProjectCandidateGroups: len(projectCandidates),
|
||||||
|
ThoughtCandidateGroups: len(thoughtCandidates),
|
||||||
|
MetadataCandidateGroups: len(metadataCandidates),
|
||||||
|
Truncated: projectTruncated || thoughtTruncated || metadataTruncated,
|
||||||
|
},
|
||||||
|
ProjectCandidates: projectCandidates,
|
||||||
|
ThoughtCandidates: thoughtCandidates,
|
||||||
|
MetadataCandidates: metadataCandidates,
|
||||||
|
RecommendedNextStep: "Review candidates manually, then use explicit archive/update/merge tools; this audit performs no writes.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func duplicateProjectCandidates(projects []thoughttypes.ProjectSummary, limit int) ([]DuplicateProjectCandidate, bool) {
|
||||||
|
groups := map[string][]thoughttypes.ProjectSummary{}
|
||||||
|
for _, project := range projects {
|
||||||
|
key := normalizeDuplicateText(project.Name)
|
||||||
|
if key != "" {
|
||||||
|
groups[key] = append(groups[key], project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys := sortedDuplicateKeys(groups)
|
||||||
|
out := make([]DuplicateProjectCandidate, 0, min(len(keys), limit))
|
||||||
|
for _, key := range keys {
|
||||||
|
items := groups[key]
|
||||||
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
if items[i].ThoughtCount != items[j].ThoughtCount {
|
||||||
|
return items[i].ThoughtCount > items[j].ThoughtCount
|
||||||
|
}
|
||||||
|
return items[i].CreatedAt.Before(items[j].CreatedAt)
|
||||||
|
})
|
||||||
|
matchType := "normalized_name"
|
||||||
|
if allProjectNamesExact(items) {
|
||||||
|
matchType = "exact_name"
|
||||||
|
}
|
||||||
|
out = append(out, DuplicateProjectCandidate{MatchType: matchType, NormalizedValue: key, Confidence: 1.0, RecommendedCanonicalID: items[0].ID, Projects: items})
|
||||||
|
if len(out) == limit {
|
||||||
|
return out, len(keys) > limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func duplicateThoughtCandidates(thoughts []thoughttypes.Thought, limit int) ([]DuplicateThoughtCandidate, bool) {
|
||||||
|
groups := map[string][]thoughttypes.Thought{}
|
||||||
|
for _, thought := range thoughts {
|
||||||
|
key := normalizeDuplicateText(thought.Content)
|
||||||
|
if key != "" {
|
||||||
|
groups[key] = append(groups[key], thought)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys := sortedDuplicateKeys(groups)
|
||||||
|
out := make([]DuplicateThoughtCandidate, 0, min(len(keys), limit))
|
||||||
|
for _, key := range keys {
|
||||||
|
items := groups[key]
|
||||||
|
sort.SliceStable(items, func(i, j int) bool { return items[i].CreatedAt.Before(items[j].CreatedAt) })
|
||||||
|
matchType := "normalized_content"
|
||||||
|
if allThoughtContentExact(items) {
|
||||||
|
matchType = "exact_content"
|
||||||
|
}
|
||||||
|
out = append(out, DuplicateThoughtCandidate{MatchType: matchType, NormalizedValue: key, Confidence: 1.0, RecommendedCanonicalID: items[0].GUID, Thoughts: items})
|
||||||
|
if len(out) == limit {
|
||||||
|
return out, len(keys) > limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataCleanupCandidates(thoughts []thoughttypes.Thought, limit int) ([]MetadataCleanupCandidate, bool) {
|
||||||
|
groups := map[string]map[string]int{}
|
||||||
|
add := func(field, value string) {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
key := normalizeDuplicateText(trimmed)
|
||||||
|
if key == "" || trimmed == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bucketKey := field + "\x00" + key
|
||||||
|
if groups[bucketKey] == nil {
|
||||||
|
groups[bucketKey] = map[string]int{}
|
||||||
|
}
|
||||||
|
groups[bucketKey][trimmed]++
|
||||||
|
}
|
||||||
|
for _, thought := range thoughts {
|
||||||
|
add("type", thought.Metadata.Type)
|
||||||
|
add("source", thought.Metadata.Source)
|
||||||
|
for _, topic := range thought.Metadata.Topics {
|
||||||
|
add("topics", topic)
|
||||||
|
}
|
||||||
|
for _, person := range thought.Metadata.People {
|
||||||
|
add("people", person)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(groups))
|
||||||
|
for key, values := range groups {
|
||||||
|
if len(values) > 1 {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
out := make([]MetadataCleanupCandidate, 0, min(len(keys), limit))
|
||||||
|
for _, key := range keys {
|
||||||
|
parts := strings.SplitN(key, "\x00", 2)
|
||||||
|
values, occurrences, recommended := valuesByCount(groups[key])
|
||||||
|
out = append(out, MetadataCleanupCandidate{
|
||||||
|
Field: parts[0],
|
||||||
|
NormalizedValue: parts[1],
|
||||||
|
Values: values,
|
||||||
|
Occurrences: occurrences,
|
||||||
|
RecommendedValue: recommended,
|
||||||
|
RecommendedAction: "preview bulk metadata remap before applying; no changes made by audit",
|
||||||
|
})
|
||||||
|
if len(out) == limit {
|
||||||
|
return out, len(keys) > limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedDuplicateKeys[T any](groups map[string][]T) []string {
|
||||||
|
keys := make([]string, 0, len(groups))
|
||||||
|
for key, items := range groups {
|
||||||
|
if len(items) > 1 {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func valuesByCount(counts map[string]int) ([]string, int, string) {
|
||||||
|
values := make([]string, 0, len(counts))
|
||||||
|
occurrences := 0
|
||||||
|
for value, count := range counts {
|
||||||
|
values = append(values, value)
|
||||||
|
occurrences += count
|
||||||
|
}
|
||||||
|
sort.Slice(values, func(i, j int) bool {
|
||||||
|
if counts[values[i]] != counts[values[j]] {
|
||||||
|
return counts[values[i]] > counts[values[j]]
|
||||||
|
}
|
||||||
|
return values[i] < values[j]
|
||||||
|
})
|
||||||
|
return values, occurrences, values[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDuplicateText(value string) string {
|
||||||
|
return duplicateWhitespace.ReplaceAllString(strings.ToLower(strings.TrimSpace(value)), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func allProjectNamesExact(projects []thoughttypes.ProjectSummary) bool {
|
||||||
|
if len(projects) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
first := strings.TrimSpace(projects[0].Name)
|
||||||
|
for _, project := range projects[1:] {
|
||||||
|
if strings.TrimSpace(project.Name) != first {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func allThoughtContentExact(thoughts []thoughttypes.Thought) bool {
|
||||||
|
if len(thoughts) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
first := strings.TrimSpace(thoughts[0].Content)
|
||||||
|
for _, thought := range thoughts[1:] {
|
||||||
|
if strings.TrimSpace(thought.Content) != first {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildDuplicateAuditGroupsProjectsAndThoughtsSafely(t *testing.T) {
|
||||||
|
now := time.Date(2026, 4, 12, 20, 5, 0, 0, time.UTC)
|
||||||
|
projects := []thoughttypes.ProjectSummary{
|
||||||
|
{Project: thoughttypes.Project{ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), NumericID: 1, Name: "AMCS", CreatedAt: now}, ThoughtCount: 3},
|
||||||
|
{Project: thoughttypes.Project{ID: uuid.MustParse("22222222-2222-2222-2222-222222222222"), NumericID: 2, Name: "amcs ", CreatedAt: now.Add(time.Hour)}, ThoughtCount: 1},
|
||||||
|
{Project: thoughttypes.Project{ID: uuid.MustParse("33333333-3333-3333-3333-333333333333"), NumericID: 3, Name: "other", CreatedAt: now}, ThoughtCount: 1},
|
||||||
|
}
|
||||||
|
thoughts := []thoughttypes.Thought{
|
||||||
|
{ID: 10, GUID: uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), Content: "Same text", Metadata: thoughttypes.ThoughtMetadata{Type: "Note", Topics: []string{"Go", "go"}, People: []string{"Alice"}}, CreatedAt: now},
|
||||||
|
{ID: 11, GUID: uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Content: " same text ", Metadata: thoughttypes.ThoughtMetadata{Type: "note", Topics: []string{"go"}, People: []string{"alice"}}, CreatedAt: now.Add(time.Hour)},
|
||||||
|
{ID: 12, GUID: uuid.MustParse("cccccccc-cccc-cccc-cccc-cccccccccccc"), Content: "unique", Metadata: thoughttypes.ThoughtMetadata{Type: "Task", Topics: []string{"Tasks"}}, CreatedAt: now},
|
||||||
|
}
|
||||||
|
|
||||||
|
report := buildDuplicateAuditReport(projects, thoughts, DuplicateAuditInput{})
|
||||||
|
|
||||||
|
if len(report.ProjectCandidates) != 1 {
|
||||||
|
t.Fatalf("project candidates = %d, want 1: %#v", len(report.ProjectCandidates), report.ProjectCandidates)
|
||||||
|
}
|
||||||
|
projectCandidate := report.ProjectCandidates[0]
|
||||||
|
if projectCandidate.MatchType != "normalized_name" || projectCandidate.NormalizedValue != "amcs" || len(projectCandidate.Projects) != 2 {
|
||||||
|
t.Fatalf("project candidate mismatch: %#v", projectCandidate)
|
||||||
|
}
|
||||||
|
if projectCandidate.RecommendedCanonicalID != projects[0].ID {
|
||||||
|
t.Fatalf("recommended project = %s, want %s", projectCandidate.RecommendedCanonicalID, projects[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(report.ThoughtCandidates) != 1 {
|
||||||
|
t.Fatalf("thought candidates = %d, want 1: %#v", len(report.ThoughtCandidates), report.ThoughtCandidates)
|
||||||
|
}
|
||||||
|
thoughtCandidate := report.ThoughtCandidates[0]
|
||||||
|
if thoughtCandidate.MatchType != "normalized_content" || thoughtCandidate.NormalizedValue != "same text" || len(thoughtCandidate.Thoughts) != 2 {
|
||||||
|
t.Fatalf("thought candidate mismatch: %#v", thoughtCandidate)
|
||||||
|
}
|
||||||
|
if thoughtCandidate.RecommendedCanonicalID != thoughts[0].GUID {
|
||||||
|
t.Fatalf("recommended thought = %s, want %s", thoughtCandidate.RecommendedCanonicalID, thoughts[0].GUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(report.MetadataCandidates) != 3 {
|
||||||
|
t.Fatalf("metadata candidates = %d, want 3: %#v", len(report.MetadataCandidates), report.MetadataCandidates)
|
||||||
|
}
|
||||||
|
if report.Summary.ProjectCandidateGroups != 1 || report.Summary.ThoughtCandidateGroups != 1 || report.Summary.MetadataCandidateGroups != 3 {
|
||||||
|
t.Fatalf("summary mismatch: %#v", report.Summary)
|
||||||
|
}
|
||||||
|
if report.Summary.DryRun != true {
|
||||||
|
t.Fatalf("dry run = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDuplicateAuditRespectsLimit(t *testing.T) {
|
||||||
|
projects := []thoughttypes.ProjectSummary{
|
||||||
|
{Project: thoughttypes.Project{ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), Name: "Alpha"}},
|
||||||
|
{Project: thoughttypes.Project{ID: uuid.MustParse("22222222-2222-2222-2222-222222222222"), Name: "alpha"}},
|
||||||
|
{Project: thoughttypes.Project{ID: uuid.MustParse("33333333-3333-3333-3333-333333333333"), Name: "Beta"}},
|
||||||
|
{Project: thoughttypes.Project{ID: uuid.MustParse("44444444-4444-4444-4444-444444444444"), Name: "beta"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
report := buildDuplicateAuditReport(projects, nil, DuplicateAuditInput{Limit: 1})
|
||||||
|
|
||||||
|
if len(report.ProjectCandidates) != 1 {
|
||||||
|
t.Fatalf("project candidates = %d, want 1", len(report.ProjectCandidates))
|
||||||
|
}
|
||||||
|
if report.Summary.Truncated != true {
|
||||||
|
t.Fatalf("truncated = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,8 +45,7 @@ type RelatedThought struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RelatedOutput struct {
|
type RelatedOutput struct {
|
||||||
Related []RelatedThought `json:"related"`
|
Related []RelatedThought `json:"related"`
|
||||||
RetrievalMode string `json:"retrieval_mode,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLinksTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig) *LinksTool {
|
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
|
includeSemantic = *in.IncludeSemantic
|
||||||
}
|
}
|
||||||
|
|
||||||
var retrievalMode string
|
|
||||||
if includeSemantic {
|
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 {
|
if err != nil {
|
||||||
return nil, RelatedOutput{}, err
|
return nil, RelatedOutput{}, err
|
||||||
}
|
}
|
||||||
retrievalMode = mode
|
|
||||||
for _, item := range semantic {
|
for _, item := range semantic {
|
||||||
key := fmt.Sprint(item.ID)
|
key := fmt.Sprint(item.ID)
|
||||||
if _, ok := seen[key]; ok {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,8 @@ type RecallInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RecallOutput struct {
|
type RecallOutput struct {
|
||||||
Context string `json:"context"`
|
Context string `json:"context"`
|
||||||
Items []ContextItem `json:"items"`
|
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 {
|
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
|
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 {
|
if err != nil {
|
||||||
return nil, RecallOutput{}, err
|
return nil, RecallOutput{}, err
|
||||||
}
|
}
|
||||||
@@ -101,8 +100,7 @@ func (t *RecallTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
return nil, RecallOutput{
|
return nil, RecallOutput{
|
||||||
Context: formatContextBlock(header, lines),
|
Context: formatContextBlock(header, lines),
|
||||||
Items: items,
|
Items: items,
|
||||||
RetrievalMode: mode,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,16 +11,10 @@ import (
|
|||||||
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
RetrievalModeSemantic = "semantic"
|
|
||||||
RetrievalModeText = "text"
|
|
||||||
)
|
|
||||||
|
|
||||||
// semanticSearch runs vector similarity search if embeddings exist for the
|
// semanticSearch runs vector similarity search if embeddings exist for the
|
||||||
// primary embedding model in the given scope, otherwise falls back to Postgres
|
// primary embedding model in the given scope, otherwise falls back to Postgres
|
||||||
// full-text search. Search always uses the primary model so query vectors
|
// full-text search. Search always uses the primary model so query vectors
|
||||||
// match rows stored under the primary model name.
|
// match rows stored under the primary model name.
|
||||||
// It returns the results and the retrieval mode used ("semantic" or "text").
|
|
||||||
func semanticSearch(
|
func semanticSearch(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
db *store.DB,
|
db *store.DB,
|
||||||
@@ -31,22 +25,20 @@ func semanticSearch(
|
|||||||
threshold float64,
|
threshold float64,
|
||||||
projectID *int64,
|
projectID *int64,
|
||||||
excludeID *uuid.UUID,
|
excludeID *uuid.UUID,
|
||||||
) ([]thoughttypes.SearchResult, string, error) {
|
) ([]thoughttypes.SearchResult, error) {
|
||||||
model := embeddings.PrimaryModel()
|
model := embeddings.PrimaryModel()
|
||||||
hasEmbeddings, err := db.HasEmbeddingsForModel(ctx, model, projectID)
|
hasEmbeddings, err := db.HasEmbeddingsForModel(ctx, model, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasEmbeddings {
|
if hasEmbeddings {
|
||||||
embedding, err := embeddings.EmbedPrimary(ctx, query)
|
embedding, err := embeddings.EmbedPrimary(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
results, err := db.SearchSimilarThoughts(ctx, embedding, model, threshold, limit, projectID, excludeID)
|
return db.SearchSimilarThoughts(ctx, embedding, model, threshold, limit, projectID, excludeID)
|
||||||
return results, RetrievalModeSemantic, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
results, err := db.SearchThoughtsText(ctx, query, limit, projectID, excludeID)
|
return db.SearchThoughtsText(ctx, query, limit, projectID, excludeID)
|
||||||
return results, RetrievalModeText, err
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -28,8 +28,7 @@ type SearchInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SearchOutput struct {
|
type SearchOutput struct {
|
||||||
Results []thoughttypes.SearchResult `json:"results"`
|
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 {
|
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)
|
_ = 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 {
|
if err != nil {
|
||||||
return nil, SearchOutput{}, err
|
return nil, SearchOutput{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, SearchOutput{Results: results, RetrievalMode: mode}, nil
|
return nil, SearchOutput{Results: results}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,8 @@ type SummarizeInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SummarizeOutput struct {
|
type SummarizeOutput struct {
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Count int `json:"count"`
|
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 {
|
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)
|
lines := make([]string, 0, limit)
|
||||||
count := 0
|
count := 0
|
||||||
|
|
||||||
var retrievalMode string
|
|
||||||
if query != "" {
|
if query != "" {
|
||||||
var projectID *int64
|
var projectID *int64
|
||||||
if project != nil {
|
if project != nil {
|
||||||
projectID = &project.NumericID
|
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 {
|
if err != nil {
|
||||||
return nil, SummarizeOutput{}, err
|
return nil, SummarizeOutput{}, err
|
||||||
}
|
}
|
||||||
retrievalMode = mode
|
|
||||||
for i, result := range results {
|
for i, result := range results {
|
||||||
lines = append(lines, thoughtContextLine(i, result.Content, result.Metadata, result.Similarity))
|
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)
|
_ = 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"`
|
Direction string `json:"direction"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
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 {
|
Table embeddings {
|
||||||
id bigserial [pk]
|
id bigserial [pk]
|
||||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||||
|
|||||||
Reference in New Issue
Block a user