feat: scoring and frecency (#37)

Enhance lookup mechanics and priority propagation (`spec/lookup.go`)
Remove the requirement of typing a leading `-` when searching for
options, allowing flag suggestions to surface naturally during input
Accurately forward the `Priority` field from registry specs into the
`Suggestion` struct so the scoring engine evaluates relevance properly

Integrate scoring into the main suggestion pipeline
(`root/suggestions.go`)
Collect context signals using `scoring.CollectSignals(cwd, query,
rootCmd, store)` with the live working directory retrieved from
`spec.GetCWD()`
Pass the entire deduplicated list across spec, history and AI items
through `scoring.Score(deduped, signals)` to compute scores and sort by
descending order before rendering on screen

Record execution history in Frecency Store (`root/wrapper.go`)
Save the last executed command to `lastSubmittedCommand` whenever Enter
is pressed or a suggestion is selected
Trigger `store.Record(...)` asynchronously within the `IRIS_CMD_STOP`
hook using a dedicated goroutine guarded by panic recovery and timeout
limits, preventing blocks on the main shell thread

Optimize workspace identification for AI
(`internal/ai/context_provider.go`)
Use `workspace.DetectCached(cwd)` to instantly fetch ecosystem context
across Git, Node, Go, Rust, Python and Docker alongside active git
branch state
This caching mechanism avoids redundant subprocess execution when
outside git repositories, significantly speeding up prompt context
generation
This commit is contained in:
VERSE
2026-07-13 17:56:29 +07:00
committed by GitHub
parent f54e97a6c0
commit 860b475b53
24 changed files with 1706 additions and 65 deletions
+160
View File
@@ -0,0 +1,160 @@
package scoring
import (
"strings"
"github.com/versenilvis/iris/internal/workspace"
)
type ContextRule interface {
Match(ws workspace.WorkspaceInfo, cmd string) bool
Bonus() int
}
type SimpleContextRule struct {
check func(ws workspace.WorkspaceInfo, cmd string) bool
bonus int
}
func (r *SimpleContextRule) Match(ws workspace.WorkspaceInfo, cmd string) bool {
return r.check(ws, cmd)
}
func (r *SimpleContextRule) Bonus() int {
return r.bonus
}
var DefaultContextRules = []ContextRule{
// Git rules
&SimpleContextRule{
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
return ws.HasGit && (strings.HasPrefix(cmd, "git status") || strings.HasPrefix(cmd, "git diff") ||
strings.HasPrefix(cmd, "git add") || strings.HasPrefix(cmd, "git push") ||
strings.HasPrefix(cmd, "git pull") || strings.HasPrefix(cmd, "git commit") ||
strings.HasPrefix(cmd, "git switch") || strings.HasPrefix(cmd, "git checkout") ||
strings.HasPrefix(cmd, "git branch"))
},
bonus: 40,
},
&SimpleContextRule{
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
return ws.HasGit && (strings.HasPrefix(cmd, "git init") || strings.HasPrefix(cmd, "git clone"))
},
bonus: -50,
},
// Node.js & Bun rules
&SimpleContextRule{
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
return ws.HasNodeProject && (strings.HasPrefix(cmd, "npm run ") || strings.HasPrefix(cmd, "pnpm run ") ||
strings.HasPrefix(cmd, "yarn run ") || strings.HasPrefix(cmd, "bun run ") ||
strings.HasPrefix(cmd, "npm test") || strings.HasPrefix(cmd, "npm start") ||
strings.HasPrefix(cmd, "bun test") || strings.HasPrefix(cmd, "bun start"))
},
bonus: 50,
},
&SimpleContextRule{
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
return ws.HasNodeProject && (strings.HasPrefix(cmd, "npm install") || strings.HasPrefix(cmd, "npm i ") ||
strings.HasPrefix(cmd, "pnpm add") || strings.HasPrefix(cmd, "yarn add") ||
strings.HasPrefix(cmd, "bun install") || strings.HasPrefix(cmd, "bun add"))
},
bonus: 40,
},
// Go rules
&SimpleContextRule{
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
return ws.HasGoProject && (strings.HasPrefix(cmd, "go test") || strings.HasPrefix(cmd, "go run") ||
strings.HasPrefix(cmd, "go build") || strings.HasPrefix(cmd, "go mod tidy") ||
strings.HasPrefix(cmd, "go vet") || strings.HasPrefix(cmd, "go fmt"))
},
bonus: 50,
},
// Rust rules
&SimpleContextRule{
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
return ws.HasRustProject && (strings.HasPrefix(cmd, "cargo test") || strings.HasPrefix(cmd, "cargo run") ||
strings.HasPrefix(cmd, "cargo build") || strings.HasPrefix(cmd, "cargo check") ||
strings.HasPrefix(cmd, "cargo clippy"))
},
bonus: 50,
},
// Python rules
&SimpleContextRule{
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
return ws.HasPythonProject && (strings.HasPrefix(cmd, "pytest") || strings.HasPrefix(cmd, "python main.py") ||
strings.HasPrefix(cmd, "pip install") || strings.HasPrefix(cmd, "poetry run ") ||
strings.HasPrefix(cmd, "uv run "))
},
bonus: 50,
},
// Justfile rules
&SimpleContextRule{
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
return ws.HasJustfile && strings.HasPrefix(cmd, "just ")
},
bonus: 50,
},
// Makefile & C/C++ rules
&SimpleContextRule{
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
return ws.HasMakefile && strings.HasPrefix(cmd, "make")
},
bonus: 50,
},
&SimpleContextRule{
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
return ws.HasMakefile && (strings.HasPrefix(cmd, "gcc ") || strings.HasPrefix(cmd, "g++ ") ||
strings.HasPrefix(cmd, "clang ") || strings.HasPrefix(cmd, "cmake "))
},
bonus: 40,
},
// Docker & K8s rules
&SimpleContextRule{
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
return ws.HasDockerfile && (strings.HasPrefix(cmd, "docker build") || strings.HasPrefix(cmd, "docker compose up") ||
strings.HasPrefix(cmd, "docker compose down") || strings.HasPrefix(cmd, "docker compose logs"))
},
bonus: 40,
},
&SimpleContextRule{
check: func(ws workspace.WorkspaceInfo, cmd string) bool {
return ws.HasK8s && (strings.HasPrefix(cmd, "kubectl get ") || strings.HasPrefix(cmd, "kubectl apply -f ") ||
strings.HasPrefix(cmd, "kubectl logs ") || strings.HasPrefix(cmd, "kubectl describe ") ||
strings.HasPrefix(cmd, "helm upgrade ") || strings.HasPrefix(cmd, "helm install "))
},
bonus: 40,
},
}
func ApplyContextRules(ws workspace.WorkspaceInfo, cmd string) int {
return ApplyCustomContextRules(ws, cmd, DefaultContextRules)
}
func ApplyCustomContextRules(ws workspace.WorkspaceInfo, cmd string, rules []ContextRule) int {
cmd = strings.TrimSpace(cmd)
if cmd == "" {
return 0
}
total := 0
for _, rule := range rules {
if rule.Match(ws, cmd) {
total += rule.Bonus()
}
}
if total > 100 {
return 100
}
if total < -100 {
return -100
}
return total
}
+102
View File
@@ -0,0 +1,102 @@
package scoring
import (
"testing"
"github.com/versenilvis/iris/internal/workspace"
)
func TestApplyContextRules_MultiEcosystem(t *testing.T) {
tests := []struct {
name string
ws workspace.WorkspaceInfo
cmd string
expected int
}{
{
name: "git status inside git repo",
ws: workspace.WorkspaceInfo{HasGit: true},
cmd: "git status -s",
expected: 40,
},
{
name: "git init inside git repo penalized",
ws: workspace.WorkspaceInfo{HasGit: true},
cmd: "git init",
expected: -50,
},
{
name: "bun run dev inside node project",
ws: workspace.WorkspaceInfo{HasNodeProject: true},
cmd: "bun run dev",
expected: 50,
},
{
name: "go test inside go project",
ws: workspace.WorkspaceInfo{HasGoProject: true},
cmd: "go test ./...",
expected: 50,
},
{
name: "cargo check inside rust project",
ws: workspace.WorkspaceInfo{HasRustProject: true},
cmd: "cargo check",
expected: 50,
},
{
name: "pytest inside python project",
ws: workspace.WorkspaceInfo{HasPythonProject: true},
cmd: "pytest -v",
expected: 50,
},
{
name: "just build inside justfile project",
ws: workspace.WorkspaceInfo{HasJustfile: true},
cmd: "just build",
expected: 50,
},
{
name: "kubectl get pods inside k8s workspace",
ws: workspace.WorkspaceInfo{HasK8s: true},
cmd: "kubectl get pods",
expected: 40,
},
{
name: "unrelated command gets no bonus",
ws: workspace.WorkspaceInfo{HasGit: true},
cmd: "echo hello",
expected: 0,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := ApplyContextRules(tc.ws, tc.cmd)
if got != tc.expected {
t.Errorf("expected bonus %d, got %d for command %q", tc.expected, got, tc.cmd)
}
})
}
}
func TestApplyContextRules_Clamping(t *testing.T) {
ws := workspace.WorkspaceInfo{HasGit: true, HasNodeProject: true, HasMakefile: true}
// Create rules that sum above 100 and below -100
rules := []ContextRule{
&SimpleContextRule{check: func(w workspace.WorkspaceInfo, c string) bool { return true }, bonus: 80},
&SimpleContextRule{check: func(w workspace.WorkspaceInfo, c string) bool { return true }, bonus: 60},
}
got := ApplyCustomContextRules(ws, "any cmd", rules)
if got != 100 {
t.Errorf("expected clamp to 100, got %d", got)
}
negRules := []ContextRule{
&SimpleContextRule{check: func(w workspace.WorkspaceInfo, c string) bool { return true }, bonus: -80},
&SimpleContextRule{check: func(w workspace.WorkspaceInfo, c string) bool { return true }, bonus: -60},
}
gotNeg := ApplyCustomContextRules(ws, "any cmd", negRules)
if gotNeg != -100 {
t.Errorf("expected clamp to -100, got %d", gotNeg)
}
}
+332
View File
@@ -0,0 +1,332 @@
package scoring
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
type FrecencyEntry struct {
Cmd string
Cwd string
Count int
LastUsed time.Time
RawScore float64
}
type FrecencyStore struct {
db *sql.DB
mu sync.Mutex
}
func NewFrecencyStore(dbPath string) (*FrecencyStore, error) {
if dbPath == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
dbPath = filepath.Join(home, ".local", "share", "iris", "history.db")
}
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("failed to create directory for history.db: %w", err)
}
_ = os.Chmod(dir, 0700)
if f, err := os.OpenFile(dbPath, os.O_CREATE, 0600); err == nil {
_ = f.Close()
}
_ = os.Chmod(dbPath, 0600)
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
}
store := &FrecencyStore{db: db}
if err := store.initSchema(context.Background()); err != nil {
_ = db.Close()
return nil, err
}
_ = os.Chmod(dbPath, 0600)
return store, nil
}
func (f *FrecencyStore) configureSQLite(ctx context.Context) error {
_, err := f.db.ExecContext(ctx, "PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;")
return err
}
func (f *FrecencyStore) initSchema(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
ctxTimeout, cancel := context.WithTimeout(ctx, 2000*time.Millisecond)
defer cancel()
if err := f.configureSQLite(ctxTimeout); err != nil {
return err
}
schema := `
CREATE TABLE IF NOT EXISTS history_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cmd TEXT NOT NULL,
cwd TEXT NOT NULL,
count INTEGER DEFAULT 1,
last_used TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(cmd, cwd)
);
CREATE INDEX IF NOT EXISTS idx_history_cwd_cmd ON history_entries(cwd, cmd);
`
_, err := f.db.ExecContext(ctxTimeout, schema)
return err
}
func (f *FrecencyStore) Record(ctx context.Context, cmd, cwd string) error {
if f == nil {
return nil
}
cmd = strings.TrimSpace(cmd)
if cmd == "" || cwd == "" {
return nil
}
f.mu.Lock()
defer f.mu.Unlock()
if ctx == nil {
ctx = context.Background()
}
ctxTimeout, cancel := context.WithTimeout(ctx, 1000*time.Millisecond)
defer cancel()
query := `
INSERT INTO history_entries (cmd, cwd, count, last_used)
VALUES (?, ?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(cmd, cwd) DO UPDATE SET
count = count + 1,
last_used = CURRENT_TIMESTAMP;
`
_, err := f.db.ExecContext(ctxTimeout, query, cmd, cwd)
return err
}
func (f *FrecencyStore) RawScore(count int, lastUsed time.Time) float64 {
if count <= 0 {
return 0
}
age := max(time.Since(lastUsed), 0)
var weight float64
switch {
case age <= time.Hour:
weight = 100.0
case age <= 24*time.Hour:
weight = 50.0
case age <= 7*24*time.Hour:
weight = 20.0
case age <= 30*24*time.Hour:
weight = 5.0
default:
weight = 1.0
}
return float64(count) * weight
}
func (f *FrecencyStore) QueryLocal(ctx context.Context, cwd, prefix string, limit int) ([]FrecencyEntry, error) {
if f == nil {
return nil, nil
}
if limit <= 0 {
limit = 50
}
f.mu.Lock()
defer f.mu.Unlock()
if ctx == nil {
ctx = context.Background()
}
ctxTimeout, cancel := context.WithTimeout(ctx, 1000*time.Millisecond)
defer cancel()
var rows *sql.Rows
var err error
if prefix != "" {
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cwd = ? AND cmd LIKE ?`, cwd, prefix+"%")
} else {
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cwd = ?`, cwd)
}
if err != nil {
return nil, err
}
defer rows.Close()
var entries []FrecencyEntry
for rows.Next() {
var cmd, rCwd string
var count int
var lastUsedRaw string
if err := rows.Scan(&cmd, &rCwd, &count, &lastUsedRaw); err != nil {
continue
}
t, err := parseTimestamp(lastUsedRaw)
if err != nil {
t = time.Now()
}
entries = append(entries, FrecencyEntry{
Cmd: cmd,
Cwd: rCwd,
Count: count,
LastUsed: t,
RawScore: f.RawScore(count, t),
})
}
if err := rows.Err(); err != nil {
return nil, err
}
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].RawScore > entries[j].RawScore
})
if len(entries) > limit {
entries = entries[:limit]
}
return entries, nil
}
func (f *FrecencyStore) QueryGlobal(ctx context.Context, prefix string, limit int) ([]FrecencyEntry, error) {
if f == nil {
return nil, nil
}
if limit <= 0 {
limit = 50
}
f.mu.Lock()
defer f.mu.Unlock()
if ctx == nil {
ctx = context.Background()
}
ctxTimeout, cancel := context.WithTimeout(ctx, 1000*time.Millisecond)
defer cancel()
var rows *sql.Rows
var err error
if prefix != "" {
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cmd LIKE ?`, prefix+"%")
} else {
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries`)
}
if err != nil {
return nil, err
}
defer rows.Close()
dedupe := make(map[string]*FrecencyEntry)
for rows.Next() {
var cmd, rCwd string
var count int
var lastUsedRaw string
if err := rows.Scan(&cmd, &rCwd, &count, &lastUsedRaw); err != nil {
continue
}
t, err := parseTimestamp(lastUsedRaw)
if err != nil {
t = time.Now()
}
score := f.RawScore(count, t)
if existing, found := dedupe[cmd]; found {
existing.Count += count
existing.RawScore += score
if t.After(existing.LastUsed) {
existing.LastUsed = t
existing.Cwd = rCwd
}
} else {
dedupe[cmd] = &FrecencyEntry{
Cmd: cmd,
Cwd: rCwd,
Count: count,
LastUsed: t,
RawScore: score,
}
}
}
if err := rows.Err(); err != nil {
return nil, err
}
var entries []FrecencyEntry
for _, entry := range dedupe {
entries = append(entries, *entry)
}
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].RawScore > entries[j].RawScore
})
if len(entries) > limit {
entries = entries[:limit]
}
return entries, nil
}
func (f *FrecencyStore) Close() error {
if f == nil {
return nil
}
f.mu.Lock()
defer f.mu.Unlock()
if f.db != nil {
return f.db.Close()
}
return nil
}
func parseTimestamp(s string) (time.Time, error) {
if t, err := time.Parse("2006-01-02 15:04:05", s); err == nil {
return t, nil
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
if t, err := time.Parse("2006-01-02 15:04:05.999999999-07:00", s); err == nil {
return t, nil
}
return time.Parse("2006-01-02", s)
}
var (
globalFrecencyStore *FrecencyStore
globalFrecencyMu sync.Mutex
)
func GetFrecencyStore() (*FrecencyStore, error) {
globalFrecencyMu.Lock()
defer globalFrecencyMu.Unlock()
if globalFrecencyStore != nil {
return globalFrecencyStore, nil
}
store, err := NewFrecencyStore("")
if err != nil {
return nil, err
}
globalFrecencyStore = store
return globalFrecencyStore, nil
}
+162
View File
@@ -0,0 +1,162 @@
package scoring
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
)
func TestFrecencyStore_RecordAndQueryLocal(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "history.db")
store, err := NewFrecencyStore(dbPath)
if err != nil {
t.Fatalf("NewFrecencyStore failed: %v", err)
}
defer store.Close()
cwd := "/home/user/project"
_ = store.Record(context.Background(), "git status", cwd)
_ = store.Record(context.Background(), "git status", cwd)
_ = store.Record(context.Background(), "git status", cwd)
_ = store.Record(context.Background(), "git commit -m 'test'", cwd)
entries, err := store.QueryLocal(context.Background(), cwd, "git", 10)
if err != nil {
t.Fatalf("QueryLocal failed: %v", err)
}
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
if entries[0].Cmd != "git status" || entries[0].Count != 3 {
t.Errorf("expected top entry to be 'git status' with count 3, got %s (count %d)", entries[0].Cmd, entries[0].Count)
}
}
func TestFrecencyStore_RawScoreDistribution(t *testing.T) {
store := &FrecencyStore{}
now := time.Now()
oldHeavyScore := store.RawScore(5000, now.Add(-30*24*time.Hour))
recentLightScore := store.RawScore(5, now.Add(-30*time.Minute))
if oldHeavyScore <= 0 || recentLightScore <= 0 {
t.Errorf("expected positive raw scores, got %f and %f", oldHeavyScore, recentLightScore)
}
if recentLightScore >= oldHeavyScore {
t.Logf("recent light score (%f) vs old heavy score (%f)", recentLightScore, oldHeavyScore)
}
}
func TestFrecencyStore_QueryGlobalDedupe(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "history.db")
store, err := NewFrecencyStore(dbPath)
if err != nil {
t.Fatalf("NewFrecencyStore failed: %v", err)
}
defer store.Close()
_ = store.Record(context.Background(), "make build", "/repo/a")
_ = store.Record(context.Background(), "make build", "/repo/a")
_ = store.Record(context.Background(), "make build", "/repo/b")
entries, err := store.QueryGlobal(context.Background(), "make", 10)
if err != nil {
t.Fatalf("QueryGlobal failed: %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 deduplicated entry, got %d", len(entries))
}
if entries[0].Count != 3 {
t.Errorf("expected combined count 3 across workspaces, got %d", entries[0].Count)
}
}
func TestFrecencyStore_Permissions(t *testing.T) {
tmpRoot := t.TempDir()
dbDir := filepath.Join(tmpRoot, "subdir", "iris")
dbPath := filepath.Join(dbDir, "history.db")
if err := os.MkdirAll(dbDir, 0755); err != nil {
t.Fatalf("failed to make pre-existing dir: %v", err)
}
if err := os.WriteFile(dbPath, []byte{}, 0644); err != nil {
t.Fatalf("failed to write dummy existing db file: %v", err)
}
store, err := NewFrecencyStore(dbPath)
if err != nil {
t.Fatalf("NewFrecencyStore failed: %v", err)
}
defer store.Close()
dirInfo, err := os.Stat(dbDir)
if err != nil {
t.Fatalf("stat dbDir failed: %v", err)
}
if perm := dirInfo.Mode().Perm(); perm != 0700 {
t.Errorf("expected directory permissions 0700, got %04o", perm)
}
fileInfo, err := os.Stat(dbPath)
if err != nil {
t.Fatalf("stat dbPath failed: %v", err)
}
if perm := fileInfo.Mode().Perm(); perm != 0600 {
t.Errorf("expected database file permissions 0600, got %04o", perm)
}
}
func TestFrecencyStore_SQLiteConfigurationAndContext(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "history.db")
store, err := NewFrecencyStore(dbPath)
if err != nil {
t.Fatalf("NewFrecencyStore failed: %v", err)
}
defer store.Close()
var journalMode string
if qErr := store.db.QueryRowContext(context.Background(), "PRAGMA journal_mode;").Scan(&journalMode); qErr != nil {
t.Fatalf("failed to query journal_mode: %v", qErr)
}
if journalMode != "wal" {
t.Errorf("expected journal_mode 'wal', got '%s'", journalMode)
}
var busyTimeout int
if qErr := store.db.QueryRowContext(context.Background(), "PRAGMA busy_timeout;").Scan(&busyTimeout); qErr != nil {
t.Fatalf("failed to query busy_timeout: %v", qErr)
}
if busyTimeout != 5000 {
t.Errorf("expected busy_timeout 5000, got %d", busyTimeout)
}
ctxCanceled, cancel := context.WithCancel(context.Background())
cancel()
err = store.Record(ctxCanceled, "git status", tmpDir)
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled from Record with canceled context, got %v", err)
}
}
func TestFrecencyStore_NilReceiver(t *testing.T) {
var nilStore *FrecencyStore
if err := nilStore.Record(context.Background(), "cmd", "cwd"); err != nil {
t.Errorf("expected nil error on nil store Record, got %v", err)
}
if entries, err := nilStore.QueryLocal(context.Background(), "cwd", "", 10); err != nil || entries != nil {
t.Errorf("expected nil entries and nil error on nil store QueryLocal, got %v, %v", entries, err)
}
if entries, err := nilStore.QueryGlobal(context.Background(), "", 10); err != nil || entries != nil {
t.Errorf("expected nil entries and nil error on nil store QueryGlobal, got %v, %v", entries, err)
}
if err := nilStore.Close(); err != nil {
t.Errorf("expected nil error on nil store Close, got %v", err)
}
}
+206
View File
@@ -0,0 +1,206 @@
package scoring
import (
"math"
"sort"
"strings"
"github.com/versenilvis/iris/spec"
)
type ScoreBreakdown struct {
BasePriority int
ContextBonus int
Frecency int
MatchQuality int
}
type ScoredSuggestion struct {
spec.Suggestion
Score float64
Breakdown ScoreBreakdown
}
type ScoreConfig struct {
WeightBasePriority float64
WeightContextBonus float64
WeightFrecency float64
WeightMatchQuality float64
}
var DefaultScoreConfig = ScoreConfig{
WeightBasePriority: 0.30,
WeightContextBonus: 0.25,
WeightFrecency: 0.25,
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]
mq := matchQualityScore(s.Cmd, signals.Query)
total := config.WeightBasePriority*float64(bp) +
config.WeightContextBonus*float64(cb) +
config.WeightFrecency*float64(frec) +
config.WeightMatchQuality*float64(mq)
scored[i] = ScoredSuggestion{
Suggestion: s,
Score: total,
Breakdown: ScoreBreakdown{
BasePriority: bp,
ContextBonus: cb,
Frecency: frec,
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.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 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
}
+158
View File
@@ -0,0 +1,158 @@
package scoring
import (
"testing"
"time"
"github.com/versenilvis/iris/internal/workspace"
"github.com/versenilvis/iris/spec"
)
func TestScore_GitInitAndStatusInGitRepo(t *testing.T) {
suggestions := []spec.Suggestion{
{Cmd: "git init", Source: "spec"},
{Cmd: "git status -s", Source: "spec"},
}
signals := SignalSet{
Workspace: workspace.WorkspaceInfo{HasGit: true},
Query: "git",
}
scored := Score(suggestions, signals)
if len(scored) != 2 {
t.Fatalf("expected 2 scored suggestions, got %d", len(scored))
}
if scored[0].Cmd != "git status -s" {
t.Errorf("expected 'git status -s' at top when inside git repo, got %s", scored[0].Cmd)
}
if scored[1].Cmd != "git init" {
t.Errorf("expected 'git init' at bottom, got %s", scored[1].Cmd)
}
if scored[1].Breakdown.ContextBonus != -50 {
t.Errorf("expected -50 penalty for git init, got %d", scored[1].Breakdown.ContextBonus)
}
}
func TestScore_NormalizedFrecency(t *testing.T) {
suggestions := []spec.Suggestion{
{Cmd: "ls -la", Source: "history"},
{Cmd: "git push", Source: "history"},
}
now := time.Now()
signals := SignalSet{
LocalFrecency: []FrecencyEntry{
{Cmd: "ls -la", RawScore: 25000.0, LastUsed: now.Add(-30 * 24 * time.Hour)},
{Cmd: "git push", RawScore: 500.0, LastUsed: now},
},
}
scored := Score(suggestions, signals)
var lsBreakdown, pushBreakdown ScoreBreakdown
for _, s := range scored {
switch s.Cmd {
case "ls -la":
lsBreakdown = s.Breakdown
case "git push":
pushBreakdown = s.Breakdown
}
}
if lsBreakdown.Frecency != 100 {
t.Errorf("expected max raw score to normalize to 100, got %d", lsBreakdown.Frecency)
}
if pushBreakdown.Frecency <= 0 || pushBreakdown.Frecency > 100 {
t.Errorf("expected normalized frecency in (0, 100], got %d", pushBreakdown.Frecency)
}
}
func TestScore_PrefixOverFuzzyMatch(t *testing.T) {
suggestions := []spec.Suggestion{
{Cmd: "make build", Source: "spec"}, // fuzzy/contains for 'bl'
{Cmd: "block", Source: "spec"}, // prefix exact for 'bl'
}
signals := SignalSet{Query: "bl"}
scored := Score(suggestions, signals)
if len(scored) != 2 {
t.Fatalf("expected 2 scored suggestions, got %d", len(scored))
}
if scored[0].Cmd != "block" {
t.Errorf("expected prefix match 'block' to outscore fuzzy 'make build', got %s", scored[0].Cmd)
}
}
func TestScore_AISuggestionConfidence(t *testing.T) {
suggestions := []spec.Suggestion{
{Cmd: "npm run custom-script", Source: "ai", Confidence: 85},
{Cmd: "npm help", Source: "history"},
}
signals := SignalSet{
Workspace: workspace.WorkspaceInfo{HasNodeProject: true},
Query: "npm",
}
scored := Score(suggestions, signals)
if len(scored) < 1 {
t.Fatalf("expected scored suggestions")
}
if scored[0].Cmd != "npm run custom-script" {
t.Errorf("expected high-confidence AI suggestion with context bonus at top, got %s", scored[0].Cmd)
}
}
func TestScore_UnsortedHistorySorting(t *testing.T) {
suggestions := []spec.Suggestion{
{Cmd: "cmdA", Source: "history"},
{Cmd: "cmdB", Source: "history"},
{Cmd: "cmdC", Source: "history"},
}
signals := SignalSet{
LocalFrecency: []FrecencyEntry{
{Cmd: "cmdC", RawScore: 100.0},
{Cmd: "cmdA", RawScore: 50.0},
{Cmd: "cmdB", RawScore: 10.0},
},
}
scored := Score(suggestions, signals)
if len(scored) != 3 {
t.Fatalf("expected 3 items, got %d", len(scored))
}
if scored[0].Cmd != "cmdC" || scored[1].Cmd != "cmdA" || scored[2].Cmd != "cmdB" {
t.Errorf("expected cmdC > cmdA > cmdB based on frecency, got %s, %s, %s", scored[0].Cmd, scored[1].Cmd, scored[2].Cmd)
}
}
func TestBasePriorityFor_HistoryWithConfidence(t *testing.T) {
s1 := spec.Suggestion{Source: "history"}
if p := basePriorityFor(s1); p != 40 {
t.Errorf("expected default history priority 40 when confidence unset, got %d", p)
}
s2 := spec.Suggestion{Source: "history", Confidence: 85}
if p := basePriorityFor(s2); p != 85 {
t.Errorf("expected history priority 85 when confidence is 85, got %d", p)
}
s3 := spec.Suggestion{Source: "history", Confidence: 150}
if p := basePriorityFor(s3); p != 100 {
t.Errorf("expected capped history priority 100 when confidence is > 100, got %d", p)
}
}
func TestIsSubsequence_UTF8(t *testing.T) {
_ = isSubsequence("gố", "gõ tiếng việt")
if !isSubsequence("việt", "tiếng việt") {
t.Error("expected 'việt' to be subsequence of 'tiếng việt'")
}
if !isSubsequence("tệt", "tiếng việt") {
t.Error("expected 'tệt' to be subsequence of 'tiếng việt'")
}
if isSubsequence("xyz", "tiếng việt") {
t.Error("expected 'xyz' NOT to be subsequence of 'tiếng việt'")
}
if !isSubsequence("αγ", "αβγδε") {
t.Error("expected multi-byte rune 'αγ' to be subsequence of 'αβγδε'")
}
}
+41
View File
@@ -0,0 +1,41 @@
package scoring
import (
"context"
"strings"
"github.com/versenilvis/iris/internal/workspace"
)
type SignalSet struct {
Workspace workspace.WorkspaceInfo
LocalFrecency []FrecencyEntry
GlobalFrecency []FrecencyEntry
Query string
RootCommand string
Cwd string
}
// CollectSignals gathers environment, workspace, and historical frecency signals for the given query and directory
func CollectSignals(ctx context.Context, cwd, query, rootCmd string, frecency *FrecencyStore) SignalSet {
ws := workspace.DetectCached(cwd)
if ctx == nil {
ctx = context.Background()
}
var local, global []FrecencyEntry
if frecency != nil {
local, _ = frecency.QueryLocal(ctx, cwd, query, 50)
global, _ = frecency.QueryGlobal(ctx, query, 50)
}
return SignalSet{
Workspace: ws,
LocalFrecency: local,
GlobalFrecency: global,
Query: strings.TrimSpace(query),
RootCommand: strings.TrimSpace(rootCmd),
Cwd: cwd,
}
}
+35
View File
@@ -0,0 +1,35 @@
package scoring
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestCollectSignals(t *testing.T) {
tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte("{}"), 0644)
dbPath := filepath.Join(tmpDir, "history.db")
store, err := NewFrecencyStore(dbPath)
if err != nil {
t.Fatalf("NewFrecencyStore failed: %v", err)
}
defer store.Close()
_ = store.Record(context.Background(), "npm run dev", tmpDir)
_ = store.Record(context.Background(), "npm test", "/other/dir")
signals := CollectSignals(context.Background(), tmpDir, "npm", "npm", store)
if !signals.Workspace.HasNodeProject {
t.Error("expected HasNodeProject to be true in collected signals")
}
if len(signals.LocalFrecency) != 1 || signals.LocalFrecency[0].Cmd != "npm run dev" {
t.Errorf("expected local frecency to contain 'npm run dev', got %v", signals.LocalFrecency)
}
if len(signals.GlobalFrecency) != 2 {
t.Errorf("expected global frecency to contain 2 entries, got %d", len(signals.GlobalFrecency))
}
}