a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
451 lines
14 KiB
Go
451 lines
14 KiB
Go
package mcp
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/mark3labs/mcp-go/mcp"
|
||
"github.com/zzet/gortex/internal/analysis"
|
||
"github.com/zzet/gortex/internal/graph"
|
||
)
|
||
|
||
// handleAnalyzeClusters returns the cached community-detection
|
||
// result as clusters with richer per-cluster stats than the raw
|
||
// `get_communities` surface: density (intra-cluster edges /
|
||
// possible-pairs), file-spread (distinct files touched), language
|
||
// mix, and a hub identifier.
|
||
//
|
||
// "Offline clustering" in the spec refers to k-means / DBSCAN over
|
||
// embeddings. Per-node embeddings aren't a public API of the indexer
|
||
// today, so the analyzer clusters the call graph instead — these ARE
|
||
// offline clusters with strong topology-grounded labels, and they
|
||
// serve the downstream "show me the conceptual areas of this
|
||
// codebase" use case the L12 spec listed.
|
||
//
|
||
// The `algorithm` argument selects the mechanism: leiden (default,
|
||
// the cached modularity communities), louvain (the legacy modularity
|
||
// detector), or spectral (recursive Fiedler-vector bisection — pairs
|
||
// better with similarity edges where modularity's resolution limit
|
||
// blurs boundaries). The wire response echoes the algorithm used.
|
||
func (s *Server) handleAnalyzeClusters(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||
minSize := max(req.GetInt("min_size", 3), 1)
|
||
limit := max(req.GetInt("limit", 50), 1)
|
||
pathPrefix := strings.TrimSpace(req.GetString("path_prefix", ""))
|
||
algorithm := strings.ToLower(strings.TrimSpace(req.GetString("algorithm", "leiden")))
|
||
// resolution (γ) tunes the Leiden modularity granularity. Absent or
|
||
// non-positive falls back to 1.0 (standard modularity). Higher γ →
|
||
// smaller, denser clusters; lower γ → fewer, larger ones.
|
||
resolution := req.GetFloat("resolution", 1.0)
|
||
if resolution <= 0 {
|
||
resolution = 1.0
|
||
}
|
||
|
||
var cr *analysis.CommunityResult
|
||
// incrStats is populated only on the Leiden path; it records
|
||
// whether the partition was recomputed incrementally (only the
|
||
// packages that changed since the last call) or in full.
|
||
var incrStats analysis.IncrementalCommunityStats
|
||
switch algorithm {
|
||
case "", "leiden":
|
||
algorithm = "leiden"
|
||
// The incremental partition cache is keyed to the default
|
||
// resolution. A non-default γ recomputes the partition fully so
|
||
// the cache (γ = 1.0) is never poisoned; γ = 1.0 keeps the fast
|
||
// cached path and stays byte-identical to the pre-resolution
|
||
// output.
|
||
if resolution == 1.0 {
|
||
cr, incrStats = s.incrementalCommunities()
|
||
} else {
|
||
cr = analysis.DetectCommunitiesLeidenWith(s.graph, analysis.LeidenOptions{Resolution: resolution})
|
||
}
|
||
case "louvain":
|
||
cr = analysis.DetectCommunitiesLouvain(s.graph)
|
||
case "spectral":
|
||
cr = analysis.SpectralClusters(s.graph)
|
||
default:
|
||
return mcp.NewToolResultError("analyze clusters: unknown algorithm " + algorithm +
|
||
" (expected: leiden, louvain, spectral)"), nil
|
||
}
|
||
|
||
// Clamp the global partition to the session workspace so a
|
||
// workspace-bound caller never sees clusters whose members live in a
|
||
// sibling workspace (community detection runs over the whole index).
|
||
cr = s.communitiesInSessionScope(ctx, cr)
|
||
|
||
if cr == nil || len(cr.Communities) == 0 {
|
||
empty := map[string]any{
|
||
"clusters": []map[string]any{},
|
||
"total": 0,
|
||
"algorithm": algorithm,
|
||
"note": "no communities detected in the session workspace",
|
||
}
|
||
if blk := s.workspaceScopeBlock(ctx, req, "clusters"); blk != nil {
|
||
empty["scope"] = blk
|
||
}
|
||
return s.respondJSONOrTOON(ctx, req, empty)
|
||
}
|
||
|
||
type clusterRow struct {
|
||
ID string `json:"id"`
|
||
Label string `json:"label"`
|
||
Hub string `json:"hub,omitempty"`
|
||
Size int `json:"size"`
|
||
Files int `json:"files"`
|
||
FileSpread float64 `json:"file_spread"`
|
||
Density float64 `json:"density"`
|
||
Languages map[string]int `json:"languages"`
|
||
TopFiles []string `json:"top_files,omitempty"`
|
||
MemberSample []string `json:"member_sample,omitempty"`
|
||
}
|
||
|
||
// First pass: keep only the clusters that survive size + path-prefix
|
||
// gates, then sort + truncate to the requested limit. The density,
|
||
// language-mix, and top-files work below is bounded by the truncated
|
||
// row count instead of every community in the partition — important
|
||
// on a disk backend where each member touches the graph store.
|
||
type pending struct {
|
||
c *analysis.Community
|
||
row clusterRow
|
||
}
|
||
survivors := make([]pending, 0, len(cr.Communities))
|
||
for i := range cr.Communities {
|
||
c := &cr.Communities[i]
|
||
if c.Size < minSize {
|
||
continue
|
||
}
|
||
if pathPrefix != "" {
|
||
match := false
|
||
for _, f := range c.Files {
|
||
if strings.HasPrefix(f, pathPrefix) {
|
||
match = true
|
||
break
|
||
}
|
||
}
|
||
if !match {
|
||
continue
|
||
}
|
||
}
|
||
row := clusterRow{
|
||
ID: c.ID, Label: c.Label, Hub: c.Hub, Size: c.Size,
|
||
Files: len(c.Files),
|
||
Languages: map[string]int{},
|
||
}
|
||
if c.Size > 0 {
|
||
row.FileSpread = roundScore(float64(len(c.Files)) / float64(c.Size))
|
||
}
|
||
survivors = append(survivors, pending{c: c, row: row})
|
||
}
|
||
sort.Slice(survivors, func(i, j int) bool {
|
||
if survivors[i].c.Size != survivors[j].c.Size {
|
||
return survivors[i].c.Size > survivors[j].c.Size
|
||
}
|
||
return survivors[i].c.ID < survivors[j].c.ID
|
||
})
|
||
truncated := false
|
||
if len(survivors) > limit {
|
||
survivors = survivors[:limit]
|
||
truncated = true
|
||
}
|
||
|
||
// Batch every surviving cluster's member ids and pull their nodes +
|
||
// outgoing edges in two calls — one round-trip each on
|
||
// a disk backend, against the per-member GetNode / GetOutEdges loop the
|
||
// previous shape ran (N members × 2 round-trips). Members from
|
||
// communities that didn't survive the truncate above never reach
|
||
// the store.
|
||
//
|
||
// Per-cluster member cap: communities can hold thousands of nodes
|
||
// each. On a disk backend, fetching tens of thousands of nodes + edges per
|
||
// call is several seconds of cost — the rendered response only
|
||
// uses these to compute density / language mix / top files, all of
|
||
// which converge on a representative sample long before they need
|
||
// every member. With a default 50-cluster limit and ~200 sampled
|
||
// members per cluster, the IN-list stays under 10k IDs and the
|
||
// rendering stays sub-second. The exact `size` field still reflects
|
||
// the true cluster size because it comes from c.Size, not from the
|
||
// sampled set.
|
||
const sampleCap = 200
|
||
sampleMemberIDs := make([]string, 0, len(survivors)*sampleCap)
|
||
sampleSets := make([]map[string]bool, 0, len(survivors))
|
||
for _, p := range survivors {
|
||
members := p.c.Members
|
||
if len(members) > sampleCap {
|
||
members = members[:sampleCap]
|
||
}
|
||
set := make(map[string]bool, len(members))
|
||
for _, m := range members {
|
||
set[m] = true
|
||
}
|
||
sampleSets = append(sampleSets, set)
|
||
sampleMemberIDs = append(sampleMemberIDs, members...)
|
||
}
|
||
memberNodes := s.graph.GetNodesByIDs(sampleMemberIDs)
|
||
memberOutEdges := s.graph.GetOutEdgesByNodeIDs(sampleMemberIDs)
|
||
|
||
rows := make([]clusterRow, 0, len(survivors))
|
||
for i, p := range survivors {
|
||
c := p.c
|
||
row := p.row
|
||
memberSet := sampleSets[i]
|
||
sampleSize := len(memberSet)
|
||
|
||
// Density on the sample, normalised against (sampleSize ·
|
||
// (sampleSize-1)) to keep the ratio meaningful when only part
|
||
// of the cluster was inspected. Intra-sample edges restricted
|
||
// to the call / reference kinds the clusterer cares about.
|
||
intra := 0
|
||
for m := range memberSet {
|
||
for _, e := range memberOutEdges[m] {
|
||
if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeReferences {
|
||
continue
|
||
}
|
||
if memberSet[e.To] {
|
||
intra++
|
||
}
|
||
}
|
||
}
|
||
if sampleSize > 1 {
|
||
possible := sampleSize * (sampleSize - 1)
|
||
row.Density = roundScore(float64(intra) / float64(possible))
|
||
}
|
||
|
||
fileCounts := map[string]int{}
|
||
for m := range memberSet {
|
||
n := memberNodes[m]
|
||
if n == nil {
|
||
continue
|
||
}
|
||
if n.Language != "" {
|
||
row.Languages[n.Language]++
|
||
}
|
||
if n.FilePath != "" {
|
||
fileCounts[n.FilePath]++
|
||
}
|
||
}
|
||
row.TopFiles = topN(fileCounts, 3)
|
||
row.MemberSample = sliceFirstN(c.Members, 5)
|
||
|
||
rows = append(rows, row)
|
||
}
|
||
|
||
resp := map[string]any{
|
||
"clusters": rows,
|
||
"total": len(rows),
|
||
"truncated": truncated,
|
||
"algorithm": algorithm,
|
||
}
|
||
// On the Leiden path, report whether the partition was recomputed
|
||
// incrementally (only the changed packages) or in full, so a
|
||
// caller can see the cache working.
|
||
if algorithm == "leiden" {
|
||
recompute := "incremental"
|
||
if !incrStats.Incremental {
|
||
recompute = "full"
|
||
}
|
||
detection := map[string]any{
|
||
"recompute": recompute,
|
||
"changed_packages": incrStats.ChangedPackages,
|
||
"total_packages": incrStats.TotalPackages,
|
||
"repartitioned_nodes": incrStats.RepartitionedNodes,
|
||
}
|
||
if incrStats.FullRecomputeReason != "" {
|
||
detection["full_recompute_reason"] = incrStats.FullRecomputeReason
|
||
}
|
||
detection["resolution"] = resolution
|
||
resp["detection"] = detection
|
||
}
|
||
if blk := s.workspaceScopeBlock(ctx, req, "clusters"); blk != nil {
|
||
resp["scope"] = blk
|
||
}
|
||
return s.respondJSONOrTOON(ctx, req, resp)
|
||
}
|
||
|
||
// handleAnalyzeConcepts labels each cluster with a human-readable
|
||
// theme via the wired LLM service. Falls back to deterministic
|
||
// path-prefix-based naming when no LLM is available so the tool
|
||
// still produces useful output offline.
|
||
func (s *Server) handleAnalyzeConcepts(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||
minSize := max(req.GetInt("min_size", 3), 1)
|
||
limit := max(req.GetInt("limit", 30), 1)
|
||
maxTokens := max(req.GetInt("max_tokens", 80), 16)
|
||
useLLM := requestBoolDefault(req, "use_llm", s.llmService != nil && s.llmService.Enabled())
|
||
|
||
cr := s.getCommunities()
|
||
// Clamp to the session workspace (community detection is global).
|
||
cr = s.communitiesInSessionScope(ctx, cr)
|
||
if cr == nil || len(cr.Communities) == 0 {
|
||
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
||
"concepts": []map[string]any{},
|
||
"total": 0,
|
||
"source": "communities-empty",
|
||
})
|
||
}
|
||
|
||
type conceptRow struct {
|
||
ClusterID string `json:"cluster_id"`
|
||
Theme string `json:"theme"`
|
||
Files []string `json:"files,omitempty"`
|
||
MemberSize int `json:"member_size"`
|
||
Source string `json:"source"` // "llm" or "heuristic"
|
||
}
|
||
|
||
clusters := append([]analysis.Community(nil), cr.Communities...)
|
||
sort.Slice(clusters, func(i, j int) bool { return clusters[i].Size > clusters[j].Size })
|
||
|
||
out := make([]conceptRow, 0, len(clusters))
|
||
for _, c := range clusters {
|
||
if c.Size < minSize {
|
||
continue
|
||
}
|
||
if len(out) >= limit {
|
||
break
|
||
}
|
||
theme, source := s.labelConcept(ctx, c, useLLM, maxTokens)
|
||
out = append(out, conceptRow{
|
||
ClusterID: c.ID,
|
||
Theme: theme,
|
||
Files: sliceFirstN(c.Files, 3),
|
||
MemberSize: c.Size,
|
||
Source: source,
|
||
})
|
||
}
|
||
|
||
resp := map[string]any{
|
||
"concepts": out,
|
||
"total": len(out),
|
||
}
|
||
if blk := s.workspaceScopeBlock(ctx, req, "concepts"); blk != nil {
|
||
resp["scope"] = blk
|
||
}
|
||
return s.respondJSONOrTOON(ctx, req, resp)
|
||
}
|
||
|
||
// labelConcept returns a theme label for the cluster + the source
|
||
// the label came from ("llm" or "heuristic"). LLM failures fall
|
||
// back to the heuristic — the tool never errors on a single
|
||
// cluster's label.
|
||
func (s *Server) labelConcept(ctx context.Context, c analysis.Community, useLLM bool, maxTokens int) (string, string) {
|
||
if useLLM && s.llmService != nil && s.llmService.Enabled() {
|
||
prompt := buildConceptPrompt(c)
|
||
ans, err := s.llmService.Generate(ctx, prompt, maxTokens)
|
||
if err == nil {
|
||
t := strings.TrimSpace(ans)
|
||
if t != "" {
|
||
return shortenLabel(t), "llm"
|
||
}
|
||
}
|
||
}
|
||
return heuristicConceptLabel(c), "heuristic"
|
||
}
|
||
|
||
// buildConceptPrompt asks the LLM for a 3-6 word theme covering
|
||
// every member file. Anchored to specific evidence so the label
|
||
// stays grounded.
|
||
func buildConceptPrompt(c analysis.Community) string {
|
||
files := sliceFirstN(c.Files, 8)
|
||
hub := c.Hub
|
||
if hub == "" {
|
||
hub = "(none)"
|
||
}
|
||
return fmt.Sprintf(
|
||
"Name this code cluster with a 3-6 word topic label. Hub: %s. Files: %s. "+
|
||
"Respond with the label only, no quotes, no punctuation.",
|
||
hub, strings.Join(files, ", "))
|
||
}
|
||
|
||
// heuristicConceptLabel returns a deterministic label derived from
|
||
// the cluster's hub + common file-path prefix. Used when LLM is
|
||
// disabled or fails.
|
||
func heuristicConceptLabel(c analysis.Community) string {
|
||
if c.Label != "" {
|
||
return c.Label
|
||
}
|
||
prefix := commonFilePrefix(c.Files)
|
||
if prefix == "" && c.Hub != "" {
|
||
return c.Hub
|
||
}
|
||
if prefix != "" && c.Hub != "" {
|
||
return prefix + " · " + c.Hub
|
||
}
|
||
if prefix != "" {
|
||
return prefix
|
||
}
|
||
return "cluster-" + c.ID
|
||
}
|
||
|
||
// commonFilePrefix returns the longest leading directory shared by
|
||
// every file in the list — typically "internal/auth" or similar.
|
||
// Empty when files diverge at the root.
|
||
func commonFilePrefix(files []string) string {
|
||
if len(files) == 0 {
|
||
return ""
|
||
}
|
||
prefix := filepath.Dir(filepath.ToSlash(files[0]))
|
||
for _, f := range files[1:] {
|
||
dir := filepath.Dir(filepath.ToSlash(f))
|
||
for !strings.HasPrefix(dir+"/", prefix+"/") && prefix != "." && prefix != "/" {
|
||
prefix = filepath.Dir(prefix)
|
||
}
|
||
if prefix == "." || prefix == "/" {
|
||
return ""
|
||
}
|
||
}
|
||
if prefix == "." {
|
||
return ""
|
||
}
|
||
return prefix
|
||
}
|
||
|
||
// shortenLabel trims an LLM response to a single line + ≤80 chars
|
||
// so a verbose model doesn't produce a paragraph in the theme field.
|
||
func shortenLabel(s string) string {
|
||
s = strings.SplitN(s, "\n", 2)[0]
|
||
s = strings.Trim(s, `"'.`)
|
||
if len(s) > 80 {
|
||
s = s[:80]
|
||
}
|
||
return s
|
||
}
|
||
|
||
// topN returns the n highest-count keys from the map, sorted by
|
||
// count DESC then key ASC. Used for top_files in cluster rows.
|
||
func topN(counts map[string]int, n int) []string {
|
||
type kv struct {
|
||
k string
|
||
v int
|
||
}
|
||
all := make([]kv, 0, len(counts))
|
||
for k, v := range counts {
|
||
all = append(all, kv{k, v})
|
||
}
|
||
sort.Slice(all, func(i, j int) bool {
|
||
if all[i].v != all[j].v {
|
||
return all[i].v > all[j].v
|
||
}
|
||
return all[i].k < all[j].k
|
||
})
|
||
if len(all) > n {
|
||
all = all[:n]
|
||
}
|
||
out := make([]string, 0, len(all))
|
||
for _, kvp := range all {
|
||
out = append(out, kvp.k)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// sliceFirstN returns the first n elements of s, or s itself if
|
||
// shorter. Returns a nil slice for empty input.
|
||
func sliceFirstN(s []string, n int) []string {
|
||
if len(s) == 0 {
|
||
return nil
|
||
}
|
||
if len(s) <= n {
|
||
return append([]string(nil), s...)
|
||
}
|
||
return append([]string(nil), s[:n]...)
|
||
}
|