Files
zzet--gortex/internal/mcp/tools_analyze_clusters.go
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

451 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]...)
}