Skip to content

Commit 62b867d

Browse files
Kavirubcclaude
andauthored
feat(transfer): hybrid rule-based + VDB semantic routing (closes #23) (#90)
* feat(config): add VDBRoutingConfig and Strategy to TransferConfig (issue #23) Adds VDBRoutingConfig struct with confidence_threshold, min_samples_per_repo, max_candidates, and explain_decision fields, plus a Strategy field to TransferConfig. Includes defaults in applyDefaults() and mergeConfigs(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(transfer): add VDBRouter for semantic transfer routing (issue #23) Implements VDBRouter.SuggestTransfer() which embeds the issue, searches the VDB collection, analyses repo distribution of results and returns a VDBMatchResult when one repo exceeds the confidence threshold with enough samples. Includes full unit-test coverage. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(gemini): add ExplainTransfer LLM method and prompt (issue #23) Adds ExplainTransferInput type, LLMClient.ExplainTransfer() method (temperature 0.3), and buildExplainTransferPrompt() that generates a 2-3 sentence explanation of why an issue should be transferred. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(steps): hybrid VDB fallback in transfer_check step (issue #23) TransferCheck now accepts embedder, vectorStore, and llmClient deps from the pipeline registry. After rule-based matching fails, when strategy is "hybrid" or "vdb-only" it calls VDBRouter.SuggestTransfer(). On a confident match it sets TransferTarget + metadata (transfer_method, transfer_confidence, transfer_reasoning). Optionally calls LLMClient.ExplainTransfer when explain_decision=true. Loop prevention and transfer_blocked flag still apply to VDB path. Includes integration tests for rule-priority, rules-only strategy, blocked flag and loop prevention. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ea34a71 commit 62b867d

7 files changed

Lines changed: 817 additions & 47 deletions

File tree

internal/core/config/config.go

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -112,15 +112,26 @@ type TransferRule struct {
112112
Enabled *bool `yaml:"enabled,omitempty"`
113113
}
114114

115+
// VDBRoutingConfig configures VDB-based semantic transfer routing.
116+
type VDBRoutingConfig struct {
117+
Enabled *bool `yaml:"enabled,omitempty"`
118+
ConfidenceThreshold float64 `yaml:"confidence_threshold,omitempty"` // Default: 0.75
119+
MinSamplesPerRepo int `yaml:"min_samples_per_repo,omitempty"` // Default: 20
120+
MaxCandidates int `yaml:"max_candidates,omitempty"` // Default: 3
121+
ExplainDecision bool `yaml:"explain_decision,omitempty"` // Default: true
122+
}
123+
115124
// TransferConfig holds transfer routing settings.
116125
type TransferConfig struct {
117-
Enabled *bool `yaml:"enabled,omitempty"`
118-
Rules []TransferRule `yaml:"rules,omitempty"`
119-
LLMRoutingEnabled *bool `yaml:"llm_routing_enabled,omitempty"`
120-
HighConfidence float64 `yaml:"high_confidence,omitempty"` // Default: 0.9
121-
MediumConfidence float64 `yaml:"medium_confidence,omitempty"` // Default: 0.6
122-
DuplicateConfidenceThreshold float64 `yaml:"duplicate_confidence_threshold,omitempty"` // Default: 0.8
123-
RepoCollection string `yaml:"repo_collection,omitempty"` // Collection for repository documentation
126+
Enabled *bool `yaml:"enabled,omitempty"`
127+
Rules []TransferRule `yaml:"rules,omitempty"`
128+
LLMRoutingEnabled *bool `yaml:"llm_routing_enabled,omitempty"`
129+
HighConfidence float64 `yaml:"high_confidence,omitempty"` // Default: 0.9
130+
MediumConfidence float64 `yaml:"medium_confidence,omitempty"` // Default: 0.6
131+
DuplicateConfidenceThreshold float64 `yaml:"duplicate_confidence_threshold,omitempty"` // Default: 0.8
132+
RepoCollection string `yaml:"repo_collection,omitempty"` // Collection for repository documentation
133+
VDBRouting VDBRoutingConfig `yaml:"vdb_routing,omitempty"`
134+
Strategy string `yaml:"strategy,omitempty"` // "rules-only", "vdb-only", "hybrid" (default: "hybrid" when vdb_routing.enabled)
124135
}
125136

126137
// Load reads a config file from the given path and expands environment variables.
@@ -296,6 +307,19 @@ func (c *Config) applyDefaults() {
296307
if c.Transfer.RepoCollection == "" {
297308
c.Transfer.RepoCollection = "simili_repos"
298309
}
310+
// VDB routing defaults
311+
if c.Transfer.VDBRouting.ConfidenceThreshold == 0 {
312+
c.Transfer.VDBRouting.ConfidenceThreshold = 0.75
313+
}
314+
if c.Transfer.VDBRouting.MinSamplesPerRepo == 0 {
315+
c.Transfer.VDBRouting.MinSamplesPerRepo = 20
316+
}
317+
if c.Transfer.VDBRouting.MaxCandidates == 0 {
318+
c.Transfer.VDBRouting.MaxCandidates = 3
319+
}
320+
if c.Transfer.VDBRouting.Enabled != nil && *c.Transfer.VDBRouting.Enabled && c.Transfer.Strategy == "" {
321+
c.Transfer.Strategy = "hybrid"
322+
}
299323
// Auto-close defaults
300324
if c.AutoClose.GracePeriodHours == 0 {
301325
c.AutoClose.GracePeriodHours = 72
@@ -389,6 +413,24 @@ func mergeConfigs(parent, child *Config) *Config {
389413
if child.Transfer.RepoCollection != "" {
390414
result.Transfer.RepoCollection = child.Transfer.RepoCollection
391415
}
416+
if child.Transfer.Strategy != "" {
417+
result.Transfer.Strategy = child.Transfer.Strategy
418+
}
419+
if child.Transfer.VDBRouting.Enabled != nil {
420+
result.Transfer.VDBRouting.Enabled = child.Transfer.VDBRouting.Enabled
421+
}
422+
if child.Transfer.VDBRouting.ConfidenceThreshold != 0 {
423+
result.Transfer.VDBRouting.ConfidenceThreshold = child.Transfer.VDBRouting.ConfidenceThreshold
424+
}
425+
if child.Transfer.VDBRouting.MinSamplesPerRepo != 0 {
426+
result.Transfer.VDBRouting.MinSamplesPerRepo = child.Transfer.VDBRouting.MinSamplesPerRepo
427+
}
428+
if child.Transfer.VDBRouting.MaxCandidates != 0 {
429+
result.Transfer.VDBRouting.MaxCandidates = child.Transfer.VDBRouting.MaxCandidates
430+
}
431+
if child.Transfer.VDBRouting.ExplainDecision {
432+
result.Transfer.VDBRouting.ExplainDecision = child.Transfer.VDBRouting.ExplainDecision
433+
}
392434

393435
// AutoClose: override if fields are set.
394436
// DryRun is always copied so a child config can explicitly set it to false.

internal/integrations/gemini/llm.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,24 @@ func (l *LLMClient) AssessQuality(ctx context.Context, issue *IssueInput) (*Qual
278278
return &result, nil
279279
}
280280

281+
// ExplainTransferInput holds the data needed to generate a transfer explanation.
282+
type ExplainTransferInput struct {
283+
IssueTitle string
284+
IssueBody string
285+
TargetRepo string
286+
SimilarIssues []SimilarIssueInput // From target repo
287+
}
288+
289+
// ExplainTransfer generates a brief explanation of why an issue should be transferred.
290+
func (l *LLMClient) ExplainTransfer(ctx context.Context, input *ExplainTransferInput) (string, error) {
291+
prompt := buildExplainTransferPrompt(input)
292+
text, err := l.generateText(ctx, prompt, 0.3, false)
293+
if err != nil {
294+
return "", fmt.Errorf("failed to explain transfer: %w", err)
295+
}
296+
return strings.TrimSpace(text), nil
297+
}
298+
281299
// DetectDuplicate analyzes semantic similarity for duplicate detection.
282300
// It retries on transient errors (429/5xx) with exponential backoff.
283301
func (l *LLMClient) DetectDuplicate(ctx context.Context, input *DuplicateCheckInput) (*DuplicateResult, error) {

internal/integrations/gemini/prompts.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,33 @@ Assessment must be one of: "excellent", "good", "needs-improvement", "poor"`,
255255
)
256256
}
257257

258+
// buildExplainTransferPrompt creates a prompt to explain a VDB-driven transfer decision.
259+
func buildExplainTransferPrompt(input *ExplainTransferInput) string {
260+
var similarList strings.Builder
261+
for i, s := range input.SimilarIssues {
262+
fmt.Fprintf(&similarList, "%d. #%d: %s\n", i+1, s.Number, s.Title)
263+
}
264+
265+
return fmt.Sprintf(`You are an AI assistant helping explain a GitHub issue routing decision.
266+
267+
The following issue is being considered for transfer to the repository "%s":
268+
- Title: %s
269+
- Body: %s
270+
271+
Similar issues already in "%s":
272+
%s
273+
274+
In 2-3 sentences, explain why this issue belongs in "%s" based on the similar issues found there.
275+
Be concise and specific.`,
276+
input.TargetRepo,
277+
input.IssueTitle,
278+
truncate(input.IssueBody, 500),
279+
input.TargetRepo,
280+
similarList.String(),
281+
input.TargetRepo,
282+
)
283+
}
284+
258285
// buildDuplicateDetectionPrompt creates a prompt for duplicate detection analysis.
259286
func buildDuplicateDetectionPrompt(input *DuplicateCheckInput) string {
260287
var similarList strings.Builder

internal/steps/transfer_check.go

Lines changed: 152 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,54 @@
11
// Author: Kaviru Hapuarachchi
22
// GitHub: https://github.com/Kavirubc
33
// Created: 2026-02-02
4-
// Last Modified: 2026-02-04
4+
// Last Modified: 2026-02-27
55

66
// Package steps provides the transfer check step.
77
package steps
88

99
import (
10+
"fmt"
1011
"log"
1112

1213
"github.com/similigh/simili-bot/internal/core/pipeline"
14+
"github.com/similigh/simili-bot/internal/integrations/gemini"
15+
"github.com/similigh/simili-bot/internal/integrations/qdrant"
1316
"github.com/similigh/simili-bot/internal/transfer"
1417
)
1518

1619
// TransferCheck evaluates if an issue should be transferred to another repository.
17-
type TransferCheck struct{}
20+
// It first applies rule-based matching; if no rule matches and VDB routing is enabled
21+
// it falls back to semantic VDB search (hybrid strategy).
22+
type TransferCheck struct {
23+
embedder *gemini.Embedder
24+
vectorStore qdrant.VectorStore
25+
llmClient *gemini.LLMClient
26+
}
1827

1928
// NewTransferCheck creates a new transfer check step.
2029
func NewTransferCheck(deps *pipeline.Dependencies) *TransferCheck {
21-
return &TransferCheck{}
30+
return &TransferCheck{
31+
embedder: deps.Embedder,
32+
vectorStore: deps.VectorStore,
33+
llmClient: deps.LLMClient,
34+
}
2235
}
2336

2437
// Name returns the step name.
2538
func (s *TransferCheck) Name() string {
2639
return "transfer_check"
2740
}
2841

29-
// Run checks if the issue should be transferred using transfer rules.
42+
// Run checks if the issue should be transferred using transfer rules and optionally VDB routing.
3043
func (s *TransferCheck) Run(ctx *pipeline.Context) error {
3144
if ctx.Issue.EventType == "pull_request" || ctx.Issue.EventType == "pr_comment" {
3245
log.Printf("[transfer_check] Pull request event detected, skipping transfer rules")
3346
return nil
3447
}
3548

36-
// Skip if transfer is not enabled or no rules configured
37-
if ctx.Config.Transfer.Enabled == nil || !*ctx.Config.Transfer.Enabled || len(ctx.Config.Transfer.Rules) == 0 {
38-
log.Printf("[transfer_check] Transfer not enabled or no rules, skipping")
49+
// Skip if transfer is not enabled
50+
if ctx.Config.Transfer.Enabled == nil || !*ctx.Config.Transfer.Enabled {
51+
log.Printf("[transfer_check] Transfer not enabled, skipping")
3952
return nil
4053
}
4154

@@ -47,49 +60,148 @@ func (s *TransferCheck) Run(ctx *pipeline.Context) error {
4760

4861
blockedTargets, _ := ctx.Metadata["blocked_targets"].([]string)
4962

50-
log.Printf("[transfer_check] Checking transfer rules for issue #%d", ctx.Issue.Number)
51-
52-
// Create the rule matcher
53-
matcher := transfer.NewRuleMatcher(ctx.Config.Transfer.Rules)
63+
strategy := ctx.Config.Transfer.Strategy
64+
vdbCfg := ctx.Config.Transfer.VDBRouting
65+
vdbEnabled := vdbCfg.Enabled != nil && *vdbCfg.Enabled
5466

55-
// Build issue input for matching
56-
input := &transfer.IssueInput{
57-
Title: ctx.Issue.Title,
58-
Body: ctx.Issue.Body,
59-
Labels: ctx.Issue.Labels,
60-
Author: ctx.Issue.Author,
67+
// Determine effective strategy
68+
if strategy == "" {
69+
if vdbEnabled {
70+
strategy = "hybrid"
71+
} else {
72+
strategy = "rules-only"
73+
}
6174
}
6275

63-
// Evaluate rules
64-
result := matcher.Match(input)
65-
if result.Matched {
66-
// Check if target is blocked (loop prevention)
67-
isBlocked := false
68-
for _, blocked := range blockedTargets {
69-
if blocked == result.Target {
70-
isBlocked = true
71-
break
76+
log.Printf("[transfer_check] Strategy=%s vdbEnabled=%v issue=#%d", strategy, vdbEnabled, ctx.Issue.Number)
77+
78+
// --- Rule-based matching ---
79+
ruleMatched := false
80+
if strategy != "vdb-only" && len(ctx.Config.Transfer.Rules) > 0 {
81+
matcher := transfer.NewRuleMatcher(ctx.Config.Transfer.Rules)
82+
input := &transfer.IssueInput{
83+
Title: ctx.Issue.Title,
84+
Body: ctx.Issue.Body,
85+
Labels: ctx.Issue.Labels,
86+
Author: ctx.Issue.Author,
87+
}
88+
result := matcher.Match(input)
89+
if result.Matched {
90+
if isBlockedTarget(result.Target, blockedTargets) {
91+
log.Printf("[transfer_check] Skipping transfer to %s: loop prevention (blocked target)", result.Target)
92+
} else {
93+
log.Printf("[transfer_check] Rule match: rule=%s target=%s", result.Rule.Name, result.Target)
94+
setTransferTarget(ctx, result.Target, "rule", 1.0, result.Rule.Name, "")
95+
ruleMatched = true
7296
}
7397
}
98+
}
7499

75-
if isBlocked {
76-
log.Printf("[transfer_check] Skipping transfer to %s: detected loop (blocked target)", result.Target)
77-
return nil
78-
}
100+
if ruleMatched {
101+
return nil
102+
}
79103

80-
log.Printf("[transfer_check] Issue #%d matched rule '%s', target: %s",
81-
ctx.Issue.Number, result.Rule.Name, result.Target)
104+
// --- VDB fallback ---
105+
if strategy == "rules-only" || !vdbEnabled {
106+
log.Printf("[transfer_check] VDB routing disabled or rules-only strategy, skipping VDB")
107+
return nil
108+
}
109+
110+
if s.embedder == nil || s.vectorStore == nil {
111+
log.Printf("[transfer_check] VDB deps not available, skipping VDB routing")
112+
return nil
113+
}
114+
115+
currentRepo := fmt.Sprintf("%s/%s", ctx.Issue.Org, ctx.Issue.Repo)
116+
collection := ctx.Config.Qdrant.Collection
117+
118+
router := transfer.NewVDBRouter(s.embedder, s.vectorStore, collection, 50)
119+
120+
vdbResult, err := router.SuggestTransfer(
121+
ctx.Ctx,
122+
&transfer.IssueInput{Title: ctx.Issue.Title, Body: ctx.Issue.Body},
123+
currentRepo,
124+
vdbCfg.ConfidenceThreshold,
125+
vdbCfg.MinSamplesPerRepo,
126+
vdbCfg.MaxCandidates,
127+
)
128+
if err != nil {
129+
log.Printf("[transfer_check] VDB routing error: %v", err)
130+
return nil // Non-fatal
131+
}
132+
133+
if vdbResult == nil {
134+
log.Printf("[transfer_check] VDB found no confident transfer candidate")
135+
return nil
136+
}
82137

83-
// Set transfer target
84-
ctx.TransferTarget = result.Target
85-
ctx.Result.TransferTarget = result.Target
138+
if isBlockedTarget(vdbResult.Target, blockedTargets) {
139+
log.Printf("[transfer_check] VDB target %s is blocked (loop prevention)", vdbResult.Target)
140+
return nil
141+
}
86142

87-
// Store metadata for downstream steps
88-
ctx.Metadata["transfer_rule"] = result.Rule.Name
89-
ctx.Metadata["skip_duplicate_detection"] = true
90-
} else {
91-
log.Printf("[transfer_check] Issue #%d did not match any transfer rules", ctx.Issue.Number)
143+
// Optionally generate LLM explanation
144+
reasoning := ""
145+
if vdbCfg.ExplainDecision && s.llmClient != nil {
146+
similar := buildSimilarForExplain(vdbResult.SimilarIssues)
147+
explanation, err := s.llmClient.ExplainTransfer(ctx.Ctx, &gemini.ExplainTransferInput{
148+
IssueTitle: ctx.Issue.Title,
149+
IssueBody: ctx.Issue.Body,
150+
TargetRepo: vdbResult.Target,
151+
SimilarIssues: similar,
152+
})
153+
if err != nil {
154+
log.Printf("[transfer_check] ExplainTransfer error (non-fatal): %v", err)
155+
} else {
156+
reasoning = explanation
157+
}
92158
}
93159

160+
log.Printf("[transfer_check] VDB transfer suggestion: target=%s confidence=%.2f",
161+
vdbResult.Target, vdbResult.Confidence)
162+
163+
setTransferTarget(ctx, vdbResult.Target, "vdb", vdbResult.Confidence, "", reasoning)
94164
return nil
95165
}
166+
167+
// setTransferTarget writes the transfer decision into the pipeline context.
168+
func setTransferTarget(ctx *pipeline.Context, target, method string, confidence float64, ruleName, reasoning string) {
169+
ctx.TransferTarget = target
170+
ctx.Result.TransferTarget = target
171+
ctx.Result.TransferConfidence = confidence
172+
if reasoning != "" {
173+
ctx.Result.TransferReason = reasoning
174+
}
175+
176+
ctx.Metadata["transfer_method"] = method
177+
ctx.Metadata["transfer_confidence"] = confidence
178+
if reasoning != "" {
179+
ctx.Metadata["transfer_reasoning"] = reasoning
180+
}
181+
if ruleName != "" {
182+
ctx.Metadata["transfer_rule"] = ruleName
183+
}
184+
ctx.Metadata["skip_duplicate_detection"] = true
185+
}
186+
187+
// isBlockedTarget checks if a target repo is in the blocked list.
188+
func isBlockedTarget(target string, blocked []string) bool {
189+
for _, b := range blocked {
190+
if b == target {
191+
return true
192+
}
193+
}
194+
return false
195+
}
196+
197+
// buildSimilarForExplain converts VDB result IDs to SimilarIssueInput stubs.
198+
func buildSimilarForExplain(ids []string) []gemini.SimilarIssueInput {
199+
out := make([]gemini.SimilarIssueInput, 0, len(ids))
200+
for i, id := range ids {
201+
out = append(out, gemini.SimilarIssueInput{
202+
Number: i + 1,
203+
Title: id, // Best we have without full payloads
204+
})
205+
}
206+
return out
207+
}

0 commit comments

Comments
 (0)