Files
agentic-coding-workflow/internal/projectmemory/service.go
T
2026-06-22 08:55:57 +08:00

130 lines
4.3 KiB
Go

package projectmemory
import (
"context"
"log/slog"
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
"github.com/yan1h/agent-coding-workflow/internal/infra/llm"
)
// EmbedderSelector resolves the configured platform embedder, or a typed error
// when embeddings are disabled. Implemented in app.go (shared with codeindex).
type EmbedderSelector interface {
Embedder(ctx context.Context) (emb llm.Embedder, endpointID uuid.UUID, model string, dims int, err error)
}
// Service is the projectmemory application service. Scope enforcement (project
// access) is performed by callers (MCP tools / supervisor) before invoking it.
type Service interface {
Write(ctx context.Context, in WriteInput) (*Entry, error)
Search(ctx context.Context, q Query) ([]Entry, error)
List(ctx context.Context, projectID uuid.UUID, kind string) ([]Entry, error)
Delete(ctx context.Context, id uuid.UUID) error
RenderAgentsMD(ctx context.Context, projectID uuid.UUID, wsID *uuid.UUID) (string, error)
}
type service struct {
repo Repository
embedSel EmbedderSelector
log *slog.Logger
}
// NewService constructs the projectmemory Service. embedSel may be nil (no
// embeddings → keyword fallback only); log may be nil.
func NewService(repo Repository, embedSel EmbedderSelector, log *slog.Logger) Service {
if log == nil {
log = slog.Default()
}
return &service{repo: repo, embedSel: embedSel, log: log}
}
func (s *service) Write(ctx context.Context, in WriteInput) (*Entry, error) {
if !IsValidKind(in.Kind) {
return nil, errs.New(errs.CodeInvalidInput, "invalid memory kind")
}
if in.Title == "" || in.Body == "" {
return nil, errs.New(errs.CodeInvalidInput, "title and body required")
}
// Best-effort embed of title+body; null embedding when disabled/unavailable.
var model *string
var vec []float32
if emb, _, m, _, err := s.embedder(ctx); err == nil {
vecs, eerr := emb.Embed(ctx, m, []string{in.Title + "\n\n" + in.Body})
if eerr != nil {
s.log.Warn("projectmemory.embed_failed", "err", eerr.Error())
} else if len(vecs) == 1 {
vec = vecs[0]
model = &m
}
}
entry, err := s.repo.Insert(ctx, in, model)
if err != nil {
return nil, err
}
if vec != nil && model != nil {
if err := s.repo.SetEmbedding(ctx, entry.ID, vec, *model); err != nil {
// Non-fatal: the entry exists and is keyword-searchable.
s.log.Warn("projectmemory.set_embedding_failed", "id", entry.ID, "err", err.Error())
} else {
entry.EmbeddingModel = model
}
}
return entry, nil
}
func (s *service) Search(ctx context.Context, q Query) ([]Entry, error) {
if q.Text == "" {
return nil, errs.New(errs.CodeInvalidInput, "query text required")
}
if q.Kind != "" && !IsValidKind(q.Kind) {
return nil, errs.New(errs.CodeInvalidInput, "invalid memory kind")
}
// Vector path when an embedder is configured; ILIKE/tags fallback otherwise.
if emb, _, model, _, err := s.embedder(ctx); err == nil {
vecs, eerr := emb.Embed(ctx, model, []string{q.Text})
if eerr == nil && len(vecs) == 1 {
res, kerr := s.repo.SearchKNN(ctx, q.ProjectID, vecs[0], q.Kind, q.TopK)
if kerr != nil {
return nil, kerr
}
// If the project has no embedded entries yet, fall back to keyword.
if len(res) > 0 {
return res, nil
}
} else if eerr != nil {
s.log.Warn("projectmemory.search_embed_failed", "err", eerr.Error())
}
}
return s.repo.SearchKeyword(ctx, q.ProjectID, q.Kind, q.Text, q.TopK)
}
func (s *service) List(ctx context.Context, projectID uuid.UUID, kind string) ([]Entry, error) {
if kind != "" && !IsValidKind(kind) {
return nil, errs.New(errs.CodeInvalidInput, "invalid memory kind")
}
return s.repo.ListByProject(ctx, projectID, kind)
}
func (s *service) Delete(ctx context.Context, id uuid.UUID) error {
return s.repo.Delete(ctx, id)
}
func (s *service) RenderAgentsMD(ctx context.Context, projectID uuid.UUID, wsID *uuid.UUID) (string, error) {
entries, err := s.repo.ListByProject(ctx, projectID, "")
if err != nil {
return "", err
}
return renderAgentsMD(entries, wsID), nil
}
// embedder returns the selector result, or an error when no selector is set.
func (s *service) embedder(ctx context.Context) (llm.Embedder, uuid.UUID, string, int, error) {
if s.embedSel == nil {
return nil, uuid.Nil, "", 0, llm.ErrEmbeddingsUnsupported
}
return s.embedSel.Embedder(ctx)
}