feat(learnings): add store and MCP tool layer
This commit is contained in:
174
internal/tools/learnings.go
Normal file
174
internal/tools/learnings.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
)
|
||||
|
||||
type LearningsTool struct {
|
||||
store *store.DB
|
||||
sessions *session.ActiveProjects
|
||||
cfg config.SearchConfig
|
||||
}
|
||||
|
||||
type AddLearningInput struct {
|
||||
Summary string `json:"summary" jsonschema:"short curated learning summary"`
|
||||
Details string `json:"details,omitempty" jsonschema:"optional detailed learning body"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Area string `json:"area,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
Confidence string `json:"confidence,omitempty"`
|
||||
ActionRequired *bool `json:"action_required,omitempty"`
|
||||
SourceType string `json:"source_type,omitempty"`
|
||||
SourceRef string `json:"source_ref,omitempty"`
|
||||
Project string `json:"project,omitempty" jsonschema:"project name or id; falls back to active session project"`
|
||||
RelatedThoughtID *uuid.UUID `json:"related_thought_id,omitempty"`
|
||||
RelatedSkillID *uuid.UUID `json:"related_skill_id,omitempty"`
|
||||
ReviewedBy *string `json:"reviewed_by,omitempty"`
|
||||
DuplicateOfLearningID *uuid.UUID `json:"duplicate_of_learning_id,omitempty"`
|
||||
SupersedesLearningID *uuid.UUID `json:"supersedes_learning_id,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
type AddLearningOutput struct {
|
||||
Learning thoughttypes.Learning `json:"learning"`
|
||||
}
|
||||
|
||||
type GetLearningInput struct {
|
||||
ID uuid.UUID `json:"id" jsonschema:"learning id"`
|
||||
}
|
||||
|
||||
type GetLearningOutput struct {
|
||||
Learning thoughttypes.Learning `json:"learning"`
|
||||
}
|
||||
|
||||
type ListLearningsInput struct {
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Project string `json:"project,omitempty" jsonschema:"project name or id; falls back to active session project"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Area string `json:"area,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
}
|
||||
|
||||
type ListLearningsOutput struct {
|
||||
Learnings []thoughttypes.Learning `json:"learnings"`
|
||||
}
|
||||
|
||||
func NewLearningsTool(db *store.DB, sessions *session.ActiveProjects, cfg config.SearchConfig) *LearningsTool {
|
||||
return &LearningsTool{store: db, sessions: sessions, cfg: cfg}
|
||||
}
|
||||
|
||||
func (t *LearningsTool) Add(ctx context.Context, req *mcp.CallToolRequest, in AddLearningInput) (*mcp.CallToolResult, AddLearningOutput, error) {
|
||||
summary := strings.TrimSpace(in.Summary)
|
||||
if summary == "" {
|
||||
return nil, AddLearningOutput{}, errRequiredField("summary")
|
||||
}
|
||||
|
||||
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
|
||||
if err != nil {
|
||||
return nil, AddLearningOutput{}, err
|
||||
}
|
||||
|
||||
learning := thoughttypes.Learning{
|
||||
Summary: summary,
|
||||
Details: strings.TrimSpace(in.Details),
|
||||
Category: defaultString(strings.TrimSpace(in.Category), "insight"),
|
||||
Area: defaultString(strings.TrimSpace(in.Area), "other"),
|
||||
Status: thoughttypes.LearningStatus(defaultString(strings.TrimSpace(in.Status), string(thoughttypes.LearningStatusPending))),
|
||||
Priority: thoughttypes.LearningPriority(defaultString(strings.TrimSpace(in.Priority), string(thoughttypes.LearningPriorityMedium))),
|
||||
Confidence: thoughttypes.LearningEvidenceLevel(defaultString(strings.TrimSpace(in.Confidence), string(thoughttypes.LearningEvidenceHypothesis))),
|
||||
SourceType: strings.TrimSpace(in.SourceType),
|
||||
SourceRef: strings.TrimSpace(in.SourceRef),
|
||||
RelatedThoughtID: in.RelatedThoughtID,
|
||||
RelatedSkillID: in.RelatedSkillID,
|
||||
ReviewedBy: in.ReviewedBy,
|
||||
DuplicateOfLearningID: in.DuplicateOfLearningID,
|
||||
SupersedesLearningID: in.SupersedesLearningID,
|
||||
Tags: normalizeStringSlice(in.Tags),
|
||||
}
|
||||
if in.ActionRequired != nil {
|
||||
learning.ActionRequired = *in.ActionRequired
|
||||
}
|
||||
if project != nil {
|
||||
learning.ProjectID = &project.ID
|
||||
}
|
||||
|
||||
created, err := t.store.CreateLearning(ctx, learning)
|
||||
if err != nil {
|
||||
return nil, AddLearningOutput{}, err
|
||||
}
|
||||
return nil, AddLearningOutput{Learning: created}, nil
|
||||
}
|
||||
|
||||
func (t *LearningsTool) Get(ctx context.Context, _ *mcp.CallToolRequest, in GetLearningInput) (*mcp.CallToolResult, GetLearningOutput, error) {
|
||||
learning, err := t.store.GetLearning(ctx, in.ID)
|
||||
if err != nil {
|
||||
return nil, GetLearningOutput{}, err
|
||||
}
|
||||
return nil, GetLearningOutput{Learning: learning}, nil
|
||||
}
|
||||
|
||||
func (t *LearningsTool) List(ctx context.Context, req *mcp.CallToolRequest, in ListLearningsInput) (*mcp.CallToolResult, ListLearningsOutput, error) {
|
||||
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
|
||||
if err != nil {
|
||||
return nil, ListLearningsOutput{}, err
|
||||
}
|
||||
|
||||
filter := thoughttypes.LearningFilter{
|
||||
Limit: normalizeLimit(in.Limit, t.cfg),
|
||||
Category: strings.TrimSpace(in.Category),
|
||||
Area: strings.TrimSpace(in.Area),
|
||||
Status: strings.TrimSpace(in.Status),
|
||||
Priority: strings.TrimSpace(in.Priority),
|
||||
Tag: strings.TrimSpace(in.Tag),
|
||||
Query: strings.TrimSpace(in.Query),
|
||||
}
|
||||
if project != nil {
|
||||
filter.ProjectID = &project.ID
|
||||
}
|
||||
|
||||
items, err := t.store.ListLearnings(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, ListLearningsOutput{}, err
|
||||
}
|
||||
return nil, ListLearningsOutput{Learnings: items}, nil
|
||||
}
|
||||
|
||||
func defaultString(value string, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizeStringSlice(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]struct{}{}
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user