Skip to content

Commit 4ea799f

Browse files
Madhuvodactions-user
authored andcommitted
feat: complexity router : adds complexity_tier CEL routing (maximhq#3711)
## Summary Briefly explain the purpose of this PR and the problem it solves. ## Changes - What was changed and why - Any notable design decisions or trade-offs ## Type of change - [ ] Bug fix - [ ] Feature - [ ] Refactor - [ ] Documentation - [ ] Chore/CI ## Affected areas - [ ] Core (Go) - [ ] Transports (HTTP) - [ ] Providers/Integrations - [ ] Plugins - [ ] UI (React) - [ ] Docs ## How to test Describe the steps to validate this change. Include commands and expected outcomes. ```sh # Core/Transports go version go test ./... # UI cd ui pnpm i || npm i pnpm test || npm test pnpm build || npm run build ``` If adding new configs or environment variables, document them here. ## Screenshots/Recordings If UI changes, add before/after screenshots or short clips. ## Breaking changes - [ ] Yes - [ ] No If yes, describe impact and migration instructions. ## Related issues Link related issues and discussions. Example: Closes maximhq#123 ## Security considerations Note any security implications (auth, secrets, PII, sandboxing, etc.). ## Checklist - [ ] I read `docs/contributing/README.md` and followed the guidelines - [ ] I added/updated tests where appropriate - [ ] I updated documentation where needed - [ ] I verified builds succeed (Go and UI) - [ ] I verified the CI pipeline passes locally if applicable
1 parent 817590f commit 4ea799f

14 files changed

Lines changed: 2755 additions & 13 deletions
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package complexity
2+
3+
import "math"
4+
5+
// ComplexityAnalyzer computes complexity scores from normalized text input.
6+
// It is stateless and safe for concurrent use.
7+
type ComplexityAnalyzer struct {
8+
matcher *compiledKeywordMatcher
9+
}
10+
11+
// NewComplexityAnalyzer creates a stateless analyzer with built-in defaults.
12+
func NewComplexityAnalyzer() *ComplexityAnalyzer {
13+
return &ComplexityAnalyzer{
14+
matcher: newCompiledKeywordMatcher(),
15+
}
16+
}
17+
18+
// Analyze computes complexity scores from the normalized input.
19+
func (a *ComplexityAnalyzer) Analyze(input ComplexityInput) *ComplexityResult {
20+
// Select scan mask based on whether conversation history is present.
21+
lastScanMask := lastTextBaseScanMask
22+
if len(input.PriorUserTexts) > 0 {
23+
lastScanMask = lastTextFullScanMask
24+
}
25+
26+
// Extract lexical signals from last user message and system prompt.
27+
lastSignals := a.matcher.analyzeText(input.LastUserText, lastScanMask)
28+
systemSignals := a.matcher.analyzeText(input.SystemText, systemTextScanMask)
29+
30+
// Score primary message signals.
31+
userCodeScore := scoreCount(lastSignals.codeCount, 3)
32+
reasoningScore := scoreCount(lastSignals.reasoningCount, 2)
33+
userTechnicalScore := scoreCount(lastSignals.technicalCount, 3)
34+
userSimpleScore := scoreCount(lastSignals.simpleCount, 2)
35+
outputScore := scoreOutputComplexity(lastSignals)
36+
tokenScore := scoreTokenCount(lastSignals.wordCount)
37+
38+
// System prompt provides soft lexical context for code/technical/simple signals,
39+
// but never drives reasoning override, token count, or output complexity.
40+
systemCodeScore := scoreCount(systemSignals.codeCount, 3)
41+
systemTechnicalScore := scoreCount(systemSignals.technicalCount, 3)
42+
systemSimpleScore := scoreCount(systemSignals.simpleCount, 2)
43+
44+
codeScore := clamp(userCodeScore+(systemCodeScore*systemPromptAssistFactor), 0.0, 1.0)
45+
technicalScore := clamp(userTechnicalScore+(systemTechnicalScore*systemPromptAssistFactor), 0.0, 1.0)
46+
simpleScore := clamp(userSimpleScore+(systemSimpleScore*systemPromptAssistFactor), 0.0, 1.0)
47+
48+
// Conditional simple dampener: only apply full dampener on short, low-signal asks.
49+
wordCount := lastSignals.wordCount
50+
effectiveSimpleWeight := simpleWeight
51+
signalCount := 0
52+
if userCodeScore >= 0.3 {
53+
signalCount++
54+
}
55+
if userTechnicalScore >= 0.3 {
56+
signalCount++
57+
}
58+
if reasoningScore >= 0.3 {
59+
signalCount++
60+
}
61+
if lastSignals.simpleCount > 0 && (wordCount >= 30 || signalCount >= 2) {
62+
effectiveSimpleWeight = 0.01
63+
}
64+
65+
codeContribution := codeScore * codeWeight
66+
reasoningContribution := reasoningScore * reasoningWeight
67+
technicalContribution := technicalScore * technicalWeight
68+
simplePenalty := -(simpleScore * effectiveSimpleWeight)
69+
tokenContribution := tokenScore * tokenCountWeight
70+
71+
// Weighted sum for last message (output complexity applied separately as a score floor).
72+
lastMsgScore := codeContribution +
73+
reasoningContribution +
74+
technicalContribution +
75+
simplePenalty +
76+
tokenContribution
77+
lastMsgScore = clamp(lastMsgScore, 0.0, 1.0)
78+
79+
// Conversation context blending (prior user turns only).
80+
var blended float64
81+
var convScore float64
82+
if len(input.PriorUserTexts) > 0 {
83+
convScore = a.scoreConversationContext(input.PriorUserTexts)
84+
lastWeight := defaultLastMessageBlendWeight
85+
contextWeight := defaultConversationBlendWeight
86+
if isReferentialFollowup(lastSignals, lastMsgScore, convScore, wordCount) {
87+
lastWeight = referentialLastMessageBlendWeight
88+
contextWeight = referentialConversationBlendWeight
89+
}
90+
91+
weightedBlend := (lastMsgScore * lastWeight) + (convScore * contextWeight)
92+
blended = math.Max(lastMsgScore, weightedBlend)
93+
} else {
94+
blended = lastMsgScore
95+
}
96+
97+
// Output complexity as a score floor: strong output signals set a minimum score.
98+
outputFloorMinScore := 0.0
99+
if outputScore > 0.5 {
100+
outputFloorMinScore = outputScore * 0.5
101+
if blended < outputFloorMinScore {
102+
blended = outputFloorMinScore
103+
}
104+
}
105+
106+
finalScore := clamp(blended, 0.0, 1.0)
107+
108+
// Tier classification with reasoning override.
109+
strongCount := lastSignals.strongReasoningCount
110+
tier := a.classifyTier(finalScore)
111+
if strongCount >= 2 {
112+
tier = TierReasoning
113+
} else if strongCount >= 1 && (userCodeScore > 0.5 || userTechnicalScore > 0.5) {
114+
tier = TierReasoning
115+
}
116+
117+
return &ComplexityResult{
118+
Score: finalScore,
119+
Tier: tier,
120+
WordCount: wordCount,
121+
}
122+
}
123+
124+
func (a *ComplexityAnalyzer) scoreConversationContext(priorUserTexts []string) float64 {
125+
if len(priorUserTexts) == 0 {
126+
return 0.0
127+
}
128+
129+
texts := priorUserTexts
130+
if len(texts) > 10 {
131+
texts = texts[len(texts)-10:]
132+
}
133+
134+
var weightedTotal float64
135+
var totalWeight float64
136+
lastIdx := len(texts) - 1
137+
for idx, text := range texts {
138+
signals := a.matcher.analyzeText(text, contextTextScanMask)
139+
code := scoreCount(signals.codeCount, 3)
140+
tech := scoreCount(signals.technicalCount, 3)
141+
reasoning := scoreCount(signals.reasoningCount, 2)
142+
msgScore := (code*codeWeight + tech*technicalWeight + reasoning*reasoningWeight) /
143+
(codeWeight + technicalWeight + reasoningWeight)
144+
weight := 1.0
145+
if lastIdx > 0 {
146+
weight = 1.0 + (2.0 * float64(idx) / float64(lastIdx))
147+
}
148+
weightedTotal += msgScore * weight
149+
totalWeight += weight
150+
}
151+
152+
if totalWeight == 0 {
153+
return 0.0
154+
}
155+
156+
return math.Min(1.0, weightedTotal/totalWeight)
157+
}
158+
159+
func isReferentialFollowup(signals textSignalCounts, lastMsgScore, convScore float64, wordCount int) bool {
160+
if wordCount == 0 || wordCount > referentialMaxWordCount {
161+
return false
162+
}
163+
if lastMsgScore >= referentialMaxStandaloneScore || convScore < referentialMinContextScore {
164+
return false
165+
}
166+
if signals.taskShiftCount > 0 {
167+
return false
168+
}
169+
if signals.referentialPhraseCount > 0 {
170+
return true
171+
}
172+
173+
hasReference := signals.referentialReferenceCount > 0
174+
hasAction := signals.referentialActionCount > 0
175+
return hasReference && hasAction
176+
}
177+
178+
func (a *ComplexityAnalyzer) classifyTier(score float64) string {
179+
switch {
180+
case score < simpleMediumBoundary:
181+
return TierSimple
182+
case score < mediumComplexBoundary:
183+
return TierMedium
184+
case score < complexReasoningBoundary:
185+
return TierComplex
186+
default:
187+
return TierReasoning
188+
}
189+
}

0 commit comments

Comments
 (0)