项目文件夹

文件
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 行
14 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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]...)
}