Files
iris-context/internal/scoring/scorer.go
T
VERSE 6ffb9f83f2 feat(scoring): transition workflow learning and fix history UX (#39)
- add transition scoring engine to learn sequential workflows per
directory using command skeletons
- prioritize current active git branch over older branches in
suggestions
- restore strict chronological order for history navigation and bypass
AI re-ranking
- add in-memory session history for instant access to just-run commands
- fix PTY prompt not syncing when using arrow keys in the history menu
- fix history tie-breaker to properly prioritize newer commands by
assigning larger IDs
- fix potential mutex deadlock between bufferMu and pty write during
history navigation
- fix workspace git detection to remove depth limits and correctly
identify repositories with detached HEADs
- fix SQLite connection leaks in frecency queries
- fix global state leaks in history tests by snapshotting and restoring
registry states
- modernize string prefix checks using strings.CutPrefix
2026-07-26 19:10:56 +07:00

236 lines
5.0 KiB
Go

package scoring
import (
"math"
"sort"
"strings"
"github.com/versenilvis/iris/spec"
)
type ScoreBreakdown struct {
BasePriority int
ContextBonus int
Frecency int
Transition int
MatchQuality int
}
type ScoredSuggestion struct {
spec.Suggestion
Score float64
Breakdown ScoreBreakdown
}
type ScoreConfig struct {
WeightBasePriority float64
WeightContextBonus float64
WeightFrecency float64
WeightTransition float64
WeightMatchQuality float64
}
var DefaultScoreConfig = ScoreConfig{
WeightBasePriority: 0.30,
WeightContextBonus: 0.25,
WeightFrecency: 0.15,
WeightTransition: 0.10,
WeightMatchQuality: 0.20,
}
func Score(suggestions []spec.Suggestion, signals SignalSet) []ScoredSuggestion {
return ScoreWithConfig(suggestions, signals, DefaultScoreConfig)
}
func ScoreWithConfig(suggestions []spec.Suggestion, signals SignalSet, config ScoreConfig) []ScoredSuggestion {
if len(suggestions) == 0 {
return nil
}
localMap := make(map[string]float64, len(signals.LocalFrecency))
for _, e := range signals.LocalFrecency {
localMap[e.Cmd] = e.RawScore
}
globalMap := make(map[string]float64, len(signals.GlobalFrecency))
for _, e := range signals.GlobalFrecency {
globalMap[e.Cmd] = e.RawScore
}
rawFrec := make([]float64, len(suggestions))
for i, s := range suggestions {
if score, ok := localMap[s.Cmd]; ok {
rawFrec[i] = score
} else if score, ok := globalMap[s.Cmd]; ok {
rawFrec[i] = score * 0.7
} else {
rawFrec[i] = 0
}
}
normFrec := normalizeFrecency(rawFrec)
scored := make([]ScoredSuggestion, len(suggestions))
for i, s := range suggestions {
bp := basePriorityFor(s)
cb := ApplyContextRules(signals.Workspace, s.Cmd)
frec := normFrec[i]
trans := transitionScoreFor(ExtractSkeleton(s.Cmd), signals.TransitionEntries, signals.TransitionIsLocal)
mq := matchQualityScore(s.Cmd, signals.Query)
total := config.WeightBasePriority*float64(bp) +
config.WeightContextBonus*float64(cb) +
config.WeightFrecency*float64(frec) +
config.WeightTransition*float64(trans) +
config.WeightMatchQuality*float64(mq)
scored[i] = ScoredSuggestion{
Suggestion: s,
Score: total,
Breakdown: ScoreBreakdown{
BasePriority: bp,
ContextBonus: cb,
Frecency: frec,
Transition: trans,
MatchQuality: mq,
},
}
}
sort.SliceStable(scored, func(i, j int) bool {
if scored[i].Score != scored[j].Score {
return scored[i].Score > scored[j].Score
}
if scored[i].Breakdown.Transition != scored[j].Breakdown.Transition {
return scored[i].Breakdown.Transition > scored[j].Breakdown.Transition
}
if scored[i].Breakdown.Frecency != scored[j].Breakdown.Frecency {
return scored[i].Breakdown.Frecency > scored[j].Breakdown.Frecency
}
if scored[i].Breakdown.ContextBonus != scored[j].Breakdown.ContextBonus {
return scored[i].Breakdown.ContextBonus > scored[j].Breakdown.ContextBonus
}
return scored[i].Cmd < scored[j].Cmd
})
return scored
}
func transitionScoreFor(cmdSkeleton string, entries []TransitionEntry, isLocal bool) int {
if len(entries) == 0 {
return 0 // cold-start: no data, contributes 0 (must check before accessing entries[0])
}
maxCount := entries[0].Count
if maxCount <= 0 {
return 0
}
for _, e := range entries {
if e.NextSkeleton == cmdSkeleton {
score := (float64(e.Count) / float64(maxCount)) * 100.0
if !isLocal {
score *= 0.7
}
return int(math.Round(score))
}
}
return 0
}
func basePriorityFor(s spec.Suggestion) int {
if s.Priority > 0 {
if s.Priority > 100 {
return 100
}
return s.Priority
}
switch s.Source {
case "spec":
return 60
case "ai":
if s.Confidence > 0 {
if s.Confidence > 100 {
return 100
}
return s.Confidence
}
return 50
case "history":
if s.Confidence > 0 {
if s.Confidence > 100 {
return 100
}
return s.Confidence
}
return 40
default:
return 50
}
}
func matchQualityScore(cmd, query string) int {
cmd = strings.TrimSpace(cmd)
query = strings.TrimSpace(query)
if query == "" {
return 100
}
if cmd == query {
return 100
}
if strings.HasPrefix(cmd, query) {
return 100
}
if strings.HasPrefix(strings.ToLower(cmd), strings.ToLower(query)) {
return 80
}
if strings.Contains(strings.ToLower(cmd), strings.ToLower(query)) {
return 50
}
if isSubsequence(strings.ToLower(query), strings.ToLower(cmd)) {
return 30
}
return 0
}
func isSubsequence(sub, full string) bool {
subRunes := []rune(sub)
fullRunes := []rune(full)
if len(subRunes) == 0 {
return true
}
i := 0
for j := 0; j < len(fullRunes) && i < len(subRunes); j++ {
if subRunes[i] == fullRunes[j] {
i++
}
}
return i == len(subRunes)
}
func normalizeFrecency(raw []float64) []int {
if len(raw) == 0 {
return nil
}
maxRaw := 0.0
for _, r := range raw {
if r > maxRaw {
maxRaw = r
}
}
if maxRaw <= 0 {
res := make([]int, len(raw))
return res
}
res := make([]int, len(raw))
for i, r := range raw {
val := int(math.Round((r / maxRaw) * 100.0))
if val > 100 {
val = 100
} else if val < 0 {
val = 0
}
res[i] = val
}
return res
}