Skip to content

Commit 20a9bac

Browse files
tae2089claude
andcommitted
feat: add symbol-level nodes to RAG tree (root→community→file→symbol)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1e0c14f commit 20a9bac

2 files changed

Lines changed: 190 additions & 12 deletions

File tree

internal/ragindex/builder.go

Lines changed: 76 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ func (b *Builder) indexDir() string {
4747
return b.IndexDir
4848
}
4949

50+
// nodeInfo는 Builder 내부에서 노드의 파일 정보를 담는 구조체이다.
51+
type nodeInfo struct {
52+
FilePath string
53+
Name string
54+
QualifiedName string
55+
}
56+
5057
// Build는 DB에서 커뮤니티와 멤버 노드를 읽어 doc-index.json을 생성한다.
5158
// 반환값: (커뮤니티 수, 파일 수, 에러)
5259
func (b *Builder) Build() (int, int, error) {
@@ -59,30 +66,34 @@ func (b *Builder) Build() (int, int, error) {
5966
}
6067
slog.Debug("커뮤니티 로드 완료", "count", len(communities))
6168

62-
// 2. 1-pass: 모든 커뮤니티의 고유 파일 경로 수집
69+
// 2. 1-pass: 모든 커뮤니티의 고유 node ID 수집
6370
allNodeIDs := make([]uint, 0)
6471
for _, comm := range communities {
6572
for _, m := range comm.Members {
6673
allNodeIDs = append(allNodeIDs, m.NodeID)
6774
}
6875
}
6976

70-
// 노드 ID → 파일 경로 매핑
71-
nodeFileMap := make(map[uint]string)
77+
// 노드 ID → nodeInfo 매핑
78+
nodeInfoMap := make(map[uint]nodeInfo)
7279
if len(allNodeIDs) > 0 {
7380
var nodes []model.Node
7481
if err := b.DB.Where("id IN ?", allNodeIDs).Find(&nodes).Error; err != nil {
7582
return 0, 0, fmt.Errorf("load all nodes: %w", err)
7683
}
7784
for _, n := range nodes {
78-
nodeFileMap[n.ID] = n.FilePath
85+
nodeInfoMap[n.ID] = nodeInfo{
86+
FilePath: n.FilePath,
87+
Name: n.Name,
88+
QualifiedName: n.QualifiedName,
89+
}
7990
}
8091
}
8192

8293
// 고유 파일 경로 목록 수집
8394
filePathSet := make(map[string]struct{})
84-
for _, fp := range nodeFileMap {
85-
filePathSet[fp] = struct{}{}
95+
for _, info := range nodeInfoMap {
96+
filePathSet[info.FilePath] = struct{}{}
8697
}
8798
allFilePaths := make([]string, 0, len(filePathSet))
8899
for fp := range filePathSet {
@@ -95,6 +106,12 @@ func (b *Builder) Build() (int, int, error) {
95106
return 0, 0, fmt.Errorf("batchFileSummaries: %w", err)
96107
}
97108

109+
// 4. @intent 태그를 가진 symbol 노드 배치 조회
110+
symbolsByFile, err := b.batchSymbolNodes(allNodeIDs, nodeInfoMap)
111+
if err != nil {
112+
return 0, 0, fmt.Errorf("batchSymbolNodes: %w", err)
113+
}
114+
98115
root := &TreeNode{
99116
ID: "root",
100117
Label: "Root",
@@ -104,7 +121,7 @@ func (b *Builder) Build() (int, int, error) {
104121

105122
uniqueFiles := make(map[string]struct{})
106123

107-
// 4. 2-pass: 커뮤니티별 TreeNode 구성
124+
// 5. 2-pass: 커뮤니티별 TreeNode 구성
108125
for _, comm := range communities {
109126
slog.Debug("커뮤니티 처리 중", "key", comm.Key, "members", len(comm.Members))
110127

@@ -119,8 +136,8 @@ func (b *Builder) Build() (int, int, error) {
119136
// 이 커뮤니티의 고유 파일 경로 수집
120137
commFilePathSet := make(map[string]struct{})
121138
for _, m := range comm.Members {
122-
if fp, ok := nodeFileMap[m.NodeID]; ok {
123-
commFilePathSet[fp] = struct{}{}
139+
if info, ok := nodeInfoMap[m.NodeID]; ok {
140+
commFilePathSet[info.FilePath] = struct{}{}
124141
}
125142
}
126143
slog.Debug("파일 경로 그룹 완료", "community", comm.Key, "files", len(commFilePathSet))
@@ -134,7 +151,7 @@ func (b *Builder) Build() (int, int, error) {
134151
Label: filepath.Base(filePath),
135152
Summary: summary,
136153
DocPath: docPath,
137-
Children: []*TreeNode{},
154+
Children: symbolsByFile[filePath],
138155
}
139156
uniqueFiles[filePath] = struct{}{}
140157
commNode.Children = append(commNode.Children, fileNode)
@@ -144,14 +161,14 @@ func (b *Builder) Build() (int, int, error) {
144161
root.Children = append(root.Children, commNode)
145162
}
146163

147-
// 5. Index 구조체 구성
164+
// 6. Index 구조체 구성
148165
idx := &Index{
149166
Version: 1,
150167
BuiltAt: time.Now().UTC(),
151168
Root: root,
152169
}
153170

154-
// 6. doc-index.json 파일 기록 (원자적 쓰기)
171+
// 7. doc-index.json 파일 기록 (원자적 쓰기)
155172
if err := b.writeIndex(idx); err != nil {
156173
return 0, 0, fmt.Errorf("writeIndex: %w", err)
157174
}
@@ -219,6 +236,53 @@ func (b *Builder) batchFileSummaries(filePaths []string) (map[string]string, err
219236
}
220237

221238

239+
// batchSymbolNodes는 @intent 태그를 가진 노드를 filePath → []*TreeNode 맵으로 반환한다.
240+
// 노드당 첫 번째 @intent 값만 summary로 사용한다.
241+
func (b *Builder) batchSymbolNodes(nodeIDs []uint, infoMap map[uint]nodeInfo) (map[string][]*TreeNode, error) {
242+
result := make(map[string][]*TreeNode)
243+
if len(nodeIDs) == 0 {
244+
return result, nil
245+
}
246+
247+
type intentRow struct {
248+
NodeID uint
249+
QualifiedName string
250+
Name string
251+
FilePath string
252+
Value string
253+
}
254+
255+
var rows []intentRow
256+
if err := b.DB.Table("nodes").
257+
Select("nodes.id as node_id, nodes.qualified_name, nodes.name, nodes.file_path, doc_tags.value").
258+
Joins("JOIN annotations ON annotations.node_id = nodes.id").
259+
Joins("JOIN doc_tags ON doc_tags.annotation_id = annotations.id").
260+
Where("nodes.id IN ? AND doc_tags.kind = ?", nodeIDs, string(model.TagIntent)).
261+
Order("nodes.file_path ASC, doc_tags.ordinal ASC, doc_tags.id ASC").
262+
Scan(&rows).Error; err != nil {
263+
return nil, fmt.Errorf("batch symbol nodes: %w", err)
264+
}
265+
266+
// 첫 번째 @intent 태그만 사용 (node_id 기준 deduplicate)
267+
seen := make(map[uint]struct{})
268+
for _, r := range rows {
269+
if _, ok := seen[r.NodeID]; ok {
270+
continue
271+
}
272+
seen[r.NodeID] = struct{}{}
273+
274+
symNode := &TreeNode{
275+
ID: fmt.Sprintf("symbol:%s", r.QualifiedName),
276+
Label: r.Name,
277+
Summary: r.Value,
278+
Children: []*TreeNode{},
279+
}
280+
result[r.FilePath] = append(result[r.FilePath], symNode)
281+
}
282+
283+
return result, nil
284+
}
285+
222286
// docPath는 파일 경로를 기반으로 docs 디렉토리 내의 문서 경로를 반환한다.
223287
// 전체 상대 경로 구조를 유지하여 basename 충돌을 방지한다.
224288
// 예: "internal/mcp/handlers.go" → "docs/internal/mcp/handlers.go.md"

internal/ragindex/builder_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,120 @@ func TestPruneTree_NilRoot(t *testing.T) {
385385
}
386386
}
387387

388+
// TestBuilder_SymbolNodes: @intent 태그를 가진 노드가 file 하위에 symbol 노드로 나타남을 검증한다.
389+
func TestBuilder_SymbolNodes(t *testing.T) {
390+
db := setupDB(t)
391+
tmpDir := t.TempDir()
392+
393+
// community 생성
394+
comm := model.Community{Key: "auth", Label: "Auth Service", Description: "인증"}
395+
if err := db.Create(&comm).Error; err != nil {
396+
t.Fatalf("create community: %v", err)
397+
}
398+
399+
// file 노드 생성 (community 멤버)
400+
fileNode := model.Node{
401+
QualifiedName: "internal/auth/handler.go",
402+
Kind: model.NodeKindFile,
403+
Name: "handler.go",
404+
FilePath: "internal/auth/handler.go",
405+
StartLine: 1, EndLine: 100,
406+
Language: "go",
407+
}
408+
if err := db.Create(&fileNode).Error; err != nil {
409+
t.Fatalf("create file node: %v", err)
410+
}
411+
db.Create(&model.CommunityMembership{CommunityID: comm.ID, NodeID: fileNode.ID})
412+
413+
// function 노드 (같은 파일, community 멤버, @intent 태그 있음)
414+
funcNode := model.Node{
415+
QualifiedName: "internal/auth/handler.go/HandleLogin",
416+
Kind: model.NodeKindFunction,
417+
Name: "HandleLogin",
418+
FilePath: "internal/auth/handler.go",
419+
StartLine: 10, EndLine: 30,
420+
Language: "go",
421+
}
422+
if err := db.Create(&funcNode).Error; err != nil {
423+
t.Fatalf("create func node: %v", err)
424+
}
425+
db.Create(&model.CommunityMembership{CommunityID: comm.ID, NodeID: funcNode.ID})
426+
427+
// @intent annotation + tag 생성
428+
ann := model.Annotation{NodeID: funcNode.ID, Summary: "로그인 핸들러"}
429+
db.Create(&ann)
430+
db.Create(&model.DocTag{AnnotationID: ann.ID, Kind: model.TagIntent, Value: "로그인 요청을 처리하고 JWT를 반환한다", Ordinal: 0})
431+
432+
b := &ragindex.Builder{
433+
DB: db,
434+
OutDir: filepath.Join(tmpDir, "docs"),
435+
IndexDir: tmpDir,
436+
}
437+
_, _, err := b.Build()
438+
if err != nil {
439+
t.Fatalf("Build: %v", err)
440+
}
441+
442+
idx, err := ragindex.LoadIndex(filepath.Join(tmpDir, "doc-index.json"))
443+
if err != nil {
444+
t.Fatalf("LoadIndex: %v", err)
445+
}
446+
447+
// root → community → file → symbol 계층 확인
448+
if len(idx.Root.Children) == 0 {
449+
t.Fatal("expected community children")
450+
}
451+
commNode := idx.Root.Children[0]
452+
if len(commNode.Children) == 0 {
453+
t.Fatal("expected file children")
454+
}
455+
fileTreeNode := commNode.Children[0]
456+
if len(fileTreeNode.Children) == 0 {
457+
t.Fatal("expected symbol children under file node")
458+
}
459+
sym := fileTreeNode.Children[0]
460+
if sym.ID != "symbol:internal/auth/handler.go/HandleLogin" {
461+
t.Errorf("symbol ID = %q, want %q", sym.ID, "symbol:internal/auth/handler.go/HandleLogin")
462+
}
463+
if sym.Label != "HandleLogin" {
464+
t.Errorf("symbol Label = %q, want %q", sym.Label, "HandleLogin")
465+
}
466+
if sym.Summary != "로그인 요청을 처리하고 JWT를 반환한다" {
467+
t.Errorf("symbol Summary = %q", sym.Summary)
468+
}
469+
if sym.DocPath != "" {
470+
t.Errorf("symbol DocPath should be empty, got %q", sym.DocPath)
471+
}
472+
}
473+
474+
// TestBuilder_NoSymbolsWithoutIntent: @intent 태그 없는 노드는 symbol 노드로 추가되지 않는다.
475+
func TestBuilder_NoSymbolsWithoutIntent(t *testing.T) {
476+
db := setupDB(t)
477+
tmpDir := t.TempDir()
478+
479+
comm := model.Community{Key: "core", Label: "Core"}
480+
db.Create(&comm)
481+
node := model.Node{QualifiedName: "core/utils.go/helper", Kind: model.NodeKindFunction, Name: "helper",
482+
FilePath: "core/utils.go", StartLine: 1, EndLine: 5, Language: "go"}
483+
db.Create(&node)
484+
db.Create(&model.CommunityMembership{CommunityID: comm.ID, NodeID: node.ID})
485+
// annotation 없음 → @intent 없음
486+
487+
b := &ragindex.Builder{DB: db, OutDir: filepath.Join(tmpDir, "docs"), IndexDir: tmpDir}
488+
if _, _, err := b.Build(); err != nil {
489+
t.Fatalf("Build: %v", err)
490+
}
491+
492+
idx, _ := ragindex.LoadIndex(filepath.Join(tmpDir, "doc-index.json"))
493+
if len(idx.Root.Children) == 0 || len(idx.Root.Children[0].Children) == 0 {
494+
t.Fatal("expected file node")
495+
}
496+
fileNode := idx.Root.Children[0].Children[0]
497+
if len(fileNode.Children) != 0 {
498+
t.Errorf("expected 0 symbol children, got %d", len(fileNode.Children))
499+
}
500+
}
501+
388502
// TestFindNode: FindNode가 재귀적으로 트리에서 노드를 찾는지 검증한다.
389503
func TestFindNode(t *testing.T) {
390504
root := &ragindex.TreeNode{

0 commit comments

Comments
 (0)