Skip to content

Commit 72d3d88

Browse files
feat(retrieval): add DB docs fallback service
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 6656827 commit 72d3d88

5 files changed

Lines changed: 1015 additions & 0 deletions

File tree

internal/retrieval/helpers.go

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
// @index retrieval 패키지의 순수 DB 후보 처리 헬퍼 함수 모음.
2+
package retrieval
3+
4+
import (
5+
"path/filepath"
6+
"slices"
7+
"strings"
8+
9+
"github.com/tae2089/code-context-graph/internal/model"
10+
"github.com/tae2089/code-context-graph/internal/ragindex"
11+
)
12+
13+
var retrievableNodeKinds = []model.NodeKind{
14+
model.NodeKindFile,
15+
model.NodeKindFunction,
16+
model.NodeKindClass,
17+
model.NodeKindType,
18+
model.NodeKindTest,
19+
}
20+
21+
// @intent identify graph node kinds that can resolve to a generated file-level documentation result.
22+
func IsRetrievableNodeKind(kind model.NodeKind) bool {
23+
return slices.Contains(retrievableNodeKinds, kind)
24+
}
25+
26+
// DBFileGroup은 파일 경로별로 묶인 DB 후보 노드 그룹이다.
27+
// @intent retrieve_docs DB 폴백이 파일 단위 결과 세분성을 유지하도록 후보를 그룹화한다.
28+
type DBFileGroup struct {
29+
FilePath string
30+
Nodes []model.Node
31+
}
32+
33+
// DBCandidateLimit은 그룹화 후 파일 단위 결과 수가 충분하도록 FTS 후보 수를 결정한다.
34+
// @intent retrieve_docs는 파일당 하나의 결과를 반환하므로 후보 수는 최종 파일 한도를 초과해야 하되 무한정 커지면 안 된다.
35+
// @domainRule 후보 수는 최소 dbCandidateFloor, 최대 dbCandidateCap으로 제한한다.
36+
func DBCandidateLimit(limit int) int {
37+
return min(max(limit*10, dbCandidateFloor), dbCandidateCap)
38+
}
39+
40+
// MatchedTerms는 쿼리 문자열을 소문자 토큰으로 분리하고 중복을 제거한다.
41+
// @intent DB 폴백 응답 증거를 위해 검색 쿼리를 결정론적 소문자 용어로 토큰화한다.
42+
func MatchedTerms(query string) []string {
43+
seen := map[string]struct{}{}
44+
terms := make([]string, 0)
45+
for term := range strings.FieldsSeq(strings.ToLower(query)) {
46+
term = strings.Trim(term, `"'()[]{}.,:;!?`)
47+
if term == "" {
48+
continue
49+
}
50+
if _, ok := seen[term]; ok {
51+
continue
52+
}
53+
seen[term] = struct{}{}
54+
terms = append(terms, term)
55+
}
56+
return terms
57+
}
58+
59+
// TextContainsAnyTerm은 텍스트가 주어진 용어 중 하나라도 포함하는지 대소문자 무시로 확인한다.
60+
// @intent DB 폴백 매칭 필드 진단을 위한 단순 대소문자 무시 용어 포함 검사를 수행한다.
61+
func TextContainsAnyTerm(text string, terms []string) bool {
62+
if text == "" || len(terms) == 0 {
63+
return false
64+
}
65+
lower := strings.ToLower(text)
66+
for _, term := range terms {
67+
if term != "" && strings.Contains(lower, term) {
68+
return true
69+
}
70+
}
71+
return false
72+
}
73+
74+
// NodeMatchesTerms는 노드 또는 어노테이션 텍스트가 검색 용어 중 하나라도 일치하는지 확인한다.
75+
// @intent 저장된 노드 또는 어노테이션 텍스트가 검색 용어와 일치하는지 감지한다.
76+
func NodeMatchesTerms(node model.Node, terms []string) bool {
77+
if TextContainsAnyTerm(node.Name, terms) || TextContainsAnyTerm(node.QualifiedName, terms) || TextContainsAnyTerm(node.FilePath, terms) {
78+
return true
79+
}
80+
ann := node.Annotation
81+
if ann == nil {
82+
return false
83+
}
84+
if TextContainsAnyTerm(ann.Summary, terms) || TextContainsAnyTerm(ann.Context, terms) || TextContainsAnyTerm(ann.RawText, terms) {
85+
return true
86+
}
87+
for _, tag := range ann.Tags {
88+
if TextContainsAnyTerm(tag.Name, terms) || TextContainsAnyTerm(tag.Value, terms) {
89+
return true
90+
}
91+
}
92+
return false
93+
}
94+
95+
// GroupCandidatesByFile은 관련성 순서의 심볼/파일 후보를 안정적인 파일 그룹으로 축소한다.
96+
// @intent 관련성 순서의 심볼/파일 후보를 안정적인 파일 그룹으로 축소하면서 각 파일의 노드 증거를 유지한다.
97+
func GroupCandidatesByFile(nodes []model.Node, limit int) ([]DBFileGroup, []uint) {
98+
groups := make([]DBFileGroup, 0, min(limit, len(nodes)))
99+
groupByPath := make(map[string]int)
100+
nodeIDs := make([]uint, 0, len(nodes))
101+
seenNodeIDs := make(map[uint]struct{}, len(nodes))
102+
for _, node := range nodes {
103+
if !IsRetrievableNodeKind(node.Kind) {
104+
continue
105+
}
106+
filePath := strings.TrimSpace(node.FilePath)
107+
if filePath == "" {
108+
continue
109+
}
110+
idx, ok := groupByPath[filePath]
111+
if !ok {
112+
if len(groups) >= limit {
113+
continue
114+
}
115+
idx = len(groups)
116+
groupByPath[filePath] = idx
117+
groups = append(groups, DBFileGroup{FilePath: filePath})
118+
}
119+
groups[idx].Nodes = append(groups[idx].Nodes, node)
120+
if _, seen := seenNodeIDs[node.ID]; !seen {
121+
seenNodeIDs[node.ID] = struct{}{}
122+
nodeIDs = append(nodeIDs, node.ID)
123+
}
124+
}
125+
return groups, nodeIDs
126+
}
127+
128+
// BuildDBResult는 그룹화된 DB 검색 히트와 구조화된 어노테이션으로부터 파일 수준 retrieve_docs 후보를 도출한다.
129+
// @intent 그룹화된 DB 검색 히트와 구조화된 어노테이션으로부터 파일 수준 retrieve_docs 후보를 도출한다.
130+
func BuildDBResult(group DBFileGroup, annotations map[uint]*model.Annotation, terms []string, index int) ragindex.RetrieveResult {
131+
fields := MatchedFields(group.Nodes, annotations, terms)
132+
if len(fields) == 0 {
133+
fields = []string{"search"}
134+
}
135+
summary := DBSummary(group.Nodes, annotations)
136+
return ragindex.RetrieveResult{
137+
ID: "file:" + group.FilePath,
138+
Label: filepath.Base(group.FilePath),
139+
Kind: "file",
140+
Summary: summary,
141+
DocPath: filepath.ToSlash(filepath.Join("docs", filepath.FromSlash(group.FilePath)+".md")),
142+
Path: DBPath(group.FilePath),
143+
Score: 1000 - index,
144+
MatchedTerms: terms,
145+
MatchedFields: fields,
146+
}
147+
}
148+
149+
// MatchedFields는 DB 폴백 파일 히트를 설명하는 노드 메타데이터 또는 어노테이션 버킷을 보고한다.
150+
// @intent DB 폴백 매칭 필드 진단을 위해 어떤 노드 메타데이터 또는 어노테이션 버킷이 파일 히트를 설명하는지 보고한다.
151+
func MatchedFields(nodes []model.Node, annotations map[uint]*model.Annotation, terms []string) []string {
152+
seen := map[string]struct{}{}
153+
fields := make([]string, 0)
154+
add := func(field string) {
155+
if field == "" {
156+
return
157+
}
158+
if _, ok := seen[field]; ok {
159+
return
160+
}
161+
seen[field] = struct{}{}
162+
fields = append(fields, field)
163+
}
164+
for _, node := range nodes {
165+
if TextContainsAnyTerm(node.Name, terms) {
166+
add("label")
167+
}
168+
if TextContainsAnyTerm(node.QualifiedName, terms) {
169+
add("qualified_name")
170+
}
171+
if TextContainsAnyTerm(node.FilePath, terms) {
172+
add("path")
173+
}
174+
ann := annotations[node.ID]
175+
if ann == nil {
176+
continue
177+
}
178+
if TextContainsAnyTerm(ann.Summary, terms) || TextContainsAnyTerm(ann.Context, terms) || TextContainsAnyTerm(ann.RawText, terms) {
179+
add("annotation")
180+
}
181+
for _, tag := range ann.Tags {
182+
if TextContainsAnyTerm(tag.Value, terms) || TextContainsAnyTerm(tag.Name, terms) {
183+
add(string(tag.Kind))
184+
}
185+
}
186+
}
187+
return fields
188+
}
189+
190+
// DBSummary는 그룹화된 DB 폴백 노드에서 첫 번째 사용 가능한 어노테이션 요약/태그 값을 선택한다.
191+
// @intent 첫 번째 사용 가능한 어노테이션 요약 또는 태그 값을 간결한 파일 수준 DB 폴백 요약으로 선택한다.
192+
func DBSummary(nodes []model.Node, annotations map[uint]*model.Annotation) string {
193+
for _, node := range nodes {
194+
ann := annotations[node.ID]
195+
if ann == nil {
196+
continue
197+
}
198+
if summary := strings.TrimSpace(ann.Summary); summary != "" {
199+
return summary
200+
}
201+
for _, tag := range ann.Tags {
202+
if value := strings.TrimSpace(tag.Value); value != "" {
203+
return value
204+
}
205+
}
206+
}
207+
return ""
208+
}
209+
210+
// DBPath는 정규화된 파일 경로 세그먼트로부터 DB 폴백 결과의 비어있지 않은 경로 조각을 생성한다.
211+
// @intent 정규화된 파일 경로 세그먼트로부터 DB 폴백 결과의 비어있지 않은 경로 조각을 생성한다.
212+
func DBPath(filePath string) []string {
213+
parts := strings.Split(filepath.ToSlash(filePath), "/")
214+
path := make([]string, 0, len(parts)+1)
215+
path = append(path, "docs")
216+
for _, part := range parts {
217+
if part != "" {
218+
path = append(path, part)
219+
}
220+
}
221+
return path
222+
}
223+
224+
// DBMatches는 그룹화된 DB 폴백 노드에 대한 간결한 검색 스타일 증거 항목을 생성한다.
225+
// @intent 그룹화된 DB 폴백 노드에 대한 간결한 검색 스타일 증거 항목을 생성한다.
226+
func DBMatches(nodes []model.Node, annotations map[uint]*model.Annotation) []ragindex.SearchResult {
227+
if len(nodes) == 0 {
228+
return nil
229+
}
230+
matches := make([]ragindex.SearchResult, 0, len(nodes))
231+
seen := make(map[uint]struct{}, len(nodes))
232+
for _, node := range nodes {
233+
if _, ok := seen[node.ID]; ok {
234+
continue
235+
}
236+
seen[node.ID] = struct{}{}
237+
summary := strings.TrimSpace(node.Name)
238+
ann := annotations[node.ID]
239+
if ann == nil {
240+
ann = node.Annotation
241+
}
242+
if ann != nil {
243+
if s := strings.TrimSpace(ann.Summary); s != "" {
244+
summary = s
245+
}
246+
}
247+
matches = append(matches, ragindex.SearchResult{
248+
ID: node.QualifiedName,
249+
Label: node.Name,
250+
Kind: string(node.Kind),
251+
Summary: summary,
252+
DocPath: filepath.ToSlash(filepath.Join("docs", filepath.FromSlash(node.FilePath)+".md")),
253+
Path: DBPath(node.FilePath),
254+
})
255+
}
256+
return matches
257+
}

0 commit comments

Comments
 (0)