Files
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

225 lines
7.2 KiB
Go

package mcp
import (
"context"
"fmt"
"strings"
"time"
mcpgo "github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/query"
)
// searchCorpus selects which slice of the index search_symbols draws
// from: code symbols, documentation prose, or both.
type searchCorpus int
const (
// corpusCode is the default -- only code symbols (functions,
// types, ...), prose-section KindDoc nodes excluded.
corpusCode searchCorpus = iota
// corpusDocs returns only KindDoc prose-section nodes.
corpusDocs
// corpusAll returns code symbols and prose sections together.
corpusAll
// corpusContent returns only content KindDoc nodes — the
// data_class=content chunks from pdf / office / text documents,
// excluding Markdown prose sections.
corpusContent
)
// parseCorpus reads the `corpus` argument. "" / "code" -> corpusCode,
// "docs" / "doc" / "prose" -> corpusDocs, "all" / "both" -> corpusAll.
// An unrecognised value is an error so a typo surfaces clearly rather
// than silently returning the wrong corpus.
func parseCorpus(req mcpgo.CallToolRequest) (searchCorpus, error) {
switch strings.ToLower(strings.TrimSpace(req.GetString("corpus", ""))) {
case "", "code":
return corpusCode, nil
case "docs", "doc", "prose":
return corpusDocs, nil
case "all", "both":
return corpusAll, nil
case "content":
return corpusContent, nil
default:
return corpusCode, fmt.Errorf("invalid corpus: %q (want code, docs, content, or all)",
req.GetString("corpus", ""))
}
}
// includesDocs reports whether the corpus admits Markdown prose-section
// nodes, which the symbol search still serves. Content (pdf / office / text)
// now rides its own content-index channel — see includesContent — so
// corpusContent does NOT pull the prose channel.
func (c searchCorpus) includesDocs() bool {
return c == corpusDocs || c == corpusAll
}
// includesContent reports whether the corpus admits content-index sections
// (data_class=content), served by the dedicated ContentSearcher rather than
// the symbol search. corpusDocs keeps the historical superset semantic (all
// KindDoc), so it pulls the content channel too.
func (c searchCorpus) includesContent() bool {
return c == corpusContent || c == corpusDocs || c == corpusAll
}
// includesCode reports whether the corpus admits code-symbol nodes.
func (c searchCorpus) includesCode() bool { return c == corpusCode || c == corpusAll }
// docChannelFetchMultiple widens the limit of the parallel doc-biased
// fetch relative to the primary fetch. Prose sections are a minority
// of the corpus, so a doc that the query genuinely matches can sit
// well past the primary fetchLimit behind code symbols that share its
// tokens. Over-fetching here, then keeping only the KindDoc hits, lets
// those prose sections enter the candidate pool. Bounded so the extra
// fetch stays cheap on a large index.
const docChannelFetchMultiple = 4
// docChannelMaxLimit caps the absolute size of the doc-channel fetch
// so a large `limit` request can't blow the over-fetch up unboundedly.
const docChannelMaxLimit = 200
// mergeDocChannel runs a parallel, wider-limit fetch biased to surface
// prose-section (KindDoc) nodes and merges the new doc hits into the
// existing candidate slice. It is the real retrieval channel behind
// corpus:"docs" / "all": the primary fetch is code-shaped and a
// relevant doc can rank just past its limit, so the corpus post-filter
// would have nothing to keep. By over-fetching and admitting only the
// KindDoc hits not already present, prose competes on its own terms
// before the corpus filter and the rerank run.
//
// Existing candidates keep their position; new doc hits append in
// their fetch order (the rerank pass settles final ranking). Dedup is
// by node ID. When the wider fetch surfaces no new docs the input is
// returned unchanged.
func (s *Server) mergeDocChannel(ctx context.Context, query string, nodes []*graph.Node, fetchLimit int, scope query.QueryOptions, timings *query.SearchTimings) []*graph.Node {
if strings.TrimSpace(query) == "" {
return nodes
}
docLimit := fetchLimit * docChannelFetchMultiple
if docLimit > docChannelMaxLimit {
docLimit = docChannelMaxLimit
}
if docLimit <= fetchLimit {
docLimit = fetchLimit
}
start := time.Now()
wide := s.engineFor(ctx).SearchSymbolsScoped(query, docLimit, scope)
if timings != nil {
timings.BM25ExpansionMS += time.Since(start).Milliseconds()
}
if len(wide) == 0 {
return nodes
}
seen := make(map[string]struct{}, len(nodes))
for _, n := range nodes {
if n != nil {
seen[n.ID] = struct{}{}
}
}
merged := nodes
for _, n := range wide {
if n == nil || n.Kind != graph.KindDoc {
continue
}
if _, dup := seen[n.ID]; dup {
continue
}
seen[n.ID] = struct{}{}
merged = append(merged, n)
}
return merged
}
// mergeContentChannel is the retrieval channel for content sections. Since
// content (data_class=content) bodies live in the dedicated content index
// (content_fts) and not the symbol search, the primary + doc fetches above
// can never surface them — this queries the ContentSearcher directly,
// scoped to the session's repo, materialises the matched content nodes by
// ID, and merges them into the candidate pool (dedup by node ID). A nil
// content searcher (in-memory store) or empty result returns the input
// unchanged.
func (s *Server) mergeContentChannel(ctx context.Context, query string, nodes []*graph.Node, fetchLimit int) []*graph.Node {
if strings.TrimSpace(query) == "" {
return nodes
}
cs, ok := s.graph.(graph.ContentSearcher)
if !ok {
return nodes
}
limit := fetchLimit * docChannelFetchMultiple
if limit > docChannelMaxLimit {
limit = docChannelMaxLimit
}
if limit <= fetchLimit {
limit = fetchLimit
}
repoPrefix, _ := s.sessionLocality(ctx)
hits, err := cs.SearchContent(query, repoPrefix, limit)
if err != nil || len(hits) == 0 {
return nodes
}
seen := make(map[string]struct{}, len(nodes))
for _, n := range nodes {
if n != nil {
seen[n.ID] = struct{}{}
}
}
merged := nodes
for _, h := range hits {
if _, dup := seen[h.NodeID]; dup {
continue
}
n := s.graph.GetNode(h.NodeID)
if n == nil {
continue
}
seen[h.NodeID] = struct{}{}
merged = append(merged, n)
}
return merged
}
// filterNodesByCorpus drops nodes that fall outside the selected
// corpus. KindDoc nodes are the "docs" corpus; every other kind is
// "code". corpusAll is a no-op.
func filterNodesByCorpus(nodes []*graph.Node, c searchCorpus) []*graph.Node {
if c == corpusAll {
return nodes
}
out := make([]*graph.Node, 0, len(nodes))
for _, n := range nodes {
if n == nil {
continue
}
isDoc := n.Kind == graph.KindDoc
if c == corpusContent {
// Content corpus: only the data_class=content chunks (pdf /
// office / text), not Markdown prose sections.
if isDoc && isContentNode(n) {
out = append(out, n)
}
continue
}
if isDoc && c.includesDocs() {
out = append(out, n)
} else if !isDoc && c.includesCode() {
out = append(out, n)
}
}
return out
}
// isContentNode is the mcp-local alias for graph.IsContentNode — a KindDoc
// node tagged data_class=content by a content extractor (pdf / pptx / xlsx /
// txt).
func isContentNode(n *graph.Node) bool {
return graph.IsContentNode(n)
}