Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions cmd/semantic/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,11 @@ Flags (find/match):

// snapshotElement is the JSON shape from pinchtab's /snapshot endpoint.
type snapshotElement struct {
Ref string `json:"ref"`
Role string `json:"role"`
Name string `json:"name"`
Value string `json:"value"`
Ref string `json:"ref"`
Role string `json:"role"`
Name string `json:"name"`
Value string `json:"value"`
Interactive bool `json:"interactive"`
}

func loadSnapshot(path string) ([]semantic.ElementDescriptor, error) {
Expand Down Expand Up @@ -94,10 +95,11 @@ func loadSnapshot(path string) ([]semantic.ElementDescriptor, error) {
descs := make([]semantic.ElementDescriptor, len(elements))
for i, e := range elements {
descs[i] = semantic.ElementDescriptor{
Ref: e.Ref,
Role: e.Role,
Name: e.Name,
Value: e.Value,
Ref: e.Ref,
Role: e.Role,
Name: e.Name,
Value: e.Value,
Interactive: e.Interactive,
}
}
return descs, nil
Expand Down
38 changes: 38 additions & 0 deletions cmd/semantic/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package main

import (
"os"
"testing"
)

func TestLoadSnapshot_PropagatesInteractiveFlag(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "snapshot-*.json")
if err != nil {
t.Fatalf("CreateTemp failed: %v", err)
}

json := `[
{"ref":"e1","role":"button","name":"Submit","interactive":true},
{"ref":"e2","role":"text","name":"Submit","interactive":false}
]`
if _, err := f.WriteString(json); err != nil {
t.Fatalf("WriteString failed: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("Close failed: %v", err)
}

descs, err := loadSnapshot(f.Name())
if err != nil {
t.Fatalf("loadSnapshot failed: %v", err)
}
if len(descs) != 2 {
t.Fatalf("expected 2 descriptors, got %d", len(descs))
}
if !descs[0].Interactive {
t.Fatalf("expected first descriptor interactive=true")
}
if descs[1].Interactive {
t.Fatalf("expected second descriptor interactive=false")
}
}
47 changes: 45 additions & 2 deletions internal/engine/lexical.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ const (
phraseExactBonus = 0.15
// phrasePartialBonus rewards partial phrase containment (bigrams/trigrams).
phrasePartialBonus = 0.08
// interactiveActionBoost is applied when action verbs imply intent to interact.
interactiveActionBoost = 0.10
// interactiveBaseBoost lightly favors interactive elements for generic queries.
interactiveBaseBoost = 0.05
)

// LexicalMatcher scores elements using Jaccard similarity with synonym
Expand All @@ -47,7 +51,7 @@ func (m *LexicalMatcher) Find(_ context.Context, query string, elements []types.
var candidates []scored
for _, el := range elements {
composite := el.Composite()
score := LexicalScore(query, composite)
score := lexicalScore(query, composite, el.Interactive)
if score >= opts.Threshold {
candidates = append(candidates, scored{desc: el, score: score})
}
Expand Down Expand Up @@ -122,10 +126,27 @@ var roleKeywords = map[string]bool{
"search": true,
}

var actionVerbs = map[string]bool{
"click": true,
"press": true,
"tap": true,
"type": true,
"enter": true,
"select": true,
"check": true,
"toggle": true,
"submit": true,
"fill": true,
}

// LexicalScore computes Jaccard similarity with synonym expansion,
// context-aware stopwords, role boosting, and prefix matching.
// Returns [0, 1].
func LexicalScore(query, desc string) float64 {
return lexicalScore(query, desc, false)
}

func lexicalScore(query, desc string, interactive bool) float64 {
rawQTokens := tokenize(query)
rawDTokens := tokenize(desc)

Expand Down Expand Up @@ -204,7 +225,10 @@ func LexicalScore(query, desc string) float64 {
// --- 5. Phrase bonus for preserving multi-word intent ---
phraseBoost := phraseBonus(qTokens, dTokens)

score := jaccard + synScore + prefixScore + roleBoost + phraseBoost
// --- 6. Interactive boost for action-oriented queries ---
interactiveScore := interactiveBoost(qTokens, interactive)

score := jaccard + synScore + prefixScore + roleBoost + phraseBoost + interactiveScore
if score > 1.0 {
score = 1.0
}
Expand Down Expand Up @@ -242,6 +266,25 @@ func minInt(a, b int) int {
return b
}

func interactiveBoost(qTokens []string, isInteractive bool) float64 {
if !isInteractive {
return 0
}
if containsActionVerb(qTokens) {
return interactiveActionBoost
}
return interactiveBaseBoost
}

func containsActionVerb(tokens []string) bool {
for _, t := range tokens {
if actionVerbs[t] {
return true
}
}
return false
}

func tokenPrefixScore(qTokens, dTokens []string) float64 {
if len(qTokens) == 0 {
return 0
Expand Down
43 changes: 42 additions & 1 deletion internal/engine/lexical_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,48 @@ func TestLexicalScore_PhraseBonus_NoPhraseMatch(t *testing.T) {
}
}

// LexicalMatcher (types.ElementMatcher interface) tests
func TestInteractiveBoost_ActionVerbDetection(t *testing.T) {
if !containsActionVerb(tokenize("click submit")) {
t.Fatalf("expected action verb detection for action-oriented query")
}
if containsActionVerb(tokenize("account settings")) {
t.Fatalf("did not expect action verb detection for non-action query")
}
}

func TestInteractiveBoost_NonActionUsesMildBoost(t *testing.T) {
action := interactiveBoost(tokenize("click submit"), true)
nonAction := interactiveBoost(tokenize("account settings"), true)
if nonAction <= 0 {
t.Fatalf("expected non-action interactive boost to be positive")
}
if action <= nonAction {
t.Fatalf("expected action boost to be larger than non-action boost, action=%f nonAction=%f", action, nonAction)
}
}

func TestLexicalMatcher_ActionQueryPrefersInteractiveElement(t *testing.T) {
m := NewLexicalMatcher()

elements := []types.ElementDescriptor{
{Ref: "e1", Role: "button", Name: "Submit", Interactive: false},
{Ref: "e2", Role: "button", Name: "Submit", Interactive: true},
}

result, err := m.Find(context.Background(), "click submit", elements, types.FindOptions{
Threshold: 0,
TopK: 2,
})
if err != nil {
t.Fatalf("Find returned error: %v", err)
}
if len(result.Matches) < 2 {
t.Fatalf("expected 2 matches, got %d", len(result.Matches))
}
if result.BestRef != "e2" {
t.Fatalf("expected interactive element to rank first, got %s", result.BestRef)
}
}

// LexicalMatcher (types.ElementMatcher interface) tests

Expand Down
9 changes: 5 additions & 4 deletions internal/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,11 @@ type MatchExplain struct {

// ElementDescriptor describes a single accessibility tree node.
type ElementDescriptor struct {
Ref string
Role string
Name string
Value string
Ref string
Role string
Name string
Value string
Interactive bool
}

// Composite returns a single string combining role, name, and value
Expand Down
Loading