@@ -3,23 +3,33 @@ import type { IntentHeuristics } from './planner-executor-agent';
33import { COMMON_HINTS } from './common-hints' ;
44import { HeuristicHint , type HeuristicHintInput } from './heuristic-hint' ;
55import { TaskCategory } from './task-category' ;
6+ import type { LearnedTargetFingerprint } from './profile-types' ;
67
78export interface ComposableHeuristicsOptions {
89 staticHeuristics ?: IntentHeuristics ;
910 taskCategory ?: TaskCategory | null ;
1011 useCommonHints ?: boolean ;
12+ /** Learned fingerprints from previous successful runs */
13+ learnedFingerprints ?: LearnedTargetFingerprint [ ] ;
1114}
1215
16+ /** Minimum confidence for a learned fingerprint to be used as a hint */
17+ const MIN_FINGERPRINT_CONFIDENCE = 0.3 ;
18+
1319export class ComposableHeuristics implements IntentHeuristics {
1420 private readonly staticHeuristics : IntentHeuristics | null ;
1521 private readonly taskCategory : TaskCategory | null ;
1622 private readonly useCommonHints : boolean ;
1723 private currentHints : HeuristicHint [ ] = [ ] ;
24+ private readonly learnedFingerprints : LearnedTargetFingerprint [ ] ;
1825
1926 constructor ( options : ComposableHeuristicsOptions = { } ) {
2027 this . staticHeuristics = options . staticHeuristics ?? null ;
2128 this . taskCategory = options . taskCategory ?? null ;
2229 this . useCommonHints = options . useCommonHints ?? true ;
30+ this . learnedFingerprints = ( options . learnedFingerprints ?? [ ] ) . filter (
31+ fp => fp . confidence >= MIN_FINGERPRINT_CONFIDENCE
32+ ) ;
2333 }
2434
2535 setStepHints ( hints ?: Array < HeuristicHint | HeuristicHintInput > | null ) : void {
@@ -48,6 +58,7 @@ export class ComposableHeuristics implements IntentHeuristics {
4858 return null ;
4959 }
5060
61+ // 1. Check current step hints (highest priority)
5162 for ( const hint of this . currentHints ) {
5263 if ( hint . matchesIntent ( intent ) ) {
5364 const elementId = this . matchHint ( hint , elements ) ;
@@ -57,6 +68,13 @@ export class ComposableHeuristics implements IntentHeuristics {
5768 }
5869 }
5970
71+ // 2. Check learned fingerprints (dynamic hints from successful past runs)
72+ const learnedMatch = this . matchLearnedFingerprint ( intent , elements ) ;
73+ if ( learnedMatch !== null ) {
74+ return learnedMatch ;
75+ }
76+
77+ // 3. Check common hints (built-in)
6078 if ( this . useCommonHints ) {
6179 const commonHint = this . getCommonHintForIntent ( intent ) ;
6280 if ( commonHint ) {
@@ -67,6 +85,7 @@ export class ComposableHeuristics implements IntentHeuristics {
6785 }
6886 }
6987
88+ // 4. Check static heuristics
7089 if ( this . staticHeuristics ) {
7190 try {
7291 const elementId = this . staticHeuristics . findElementForIntent ( intent , elements , url , goal ) ;
@@ -78,12 +97,14 @@ export class ComposableHeuristics implements IntentHeuristics {
7897 }
7998 }
8099
100+ // 5. Fall back to task category defaults
81101 return this . matchTaskCategoryDefaults ( elements ) ;
82102 }
83103
84104 priorityOrder ( ) : string [ ] {
85105 const patterns = [
86106 ...this . currentHints . map ( hint => hint . intentPattern ) ,
107+ ...this . learnedFingerprints . map ( fp => fp . intent ) ,
87108 ...( this . useCommonHints ? Object . keys ( COMMON_HINTS ) : [ ] ) ,
88109 ] ;
89110
@@ -108,6 +129,105 @@ export class ComposableHeuristics implements IntentHeuristics {
108129 return null ;
109130 }
110131
132+ /**
133+ * Match learned fingerprints against current snapshot elements.
134+ * Fingerprints are sorted by confidence (descending) so the most
135+ * reliable past success is tried first.
136+ */
137+ private matchLearnedFingerprint ( intent : string , elements : SnapshotElement [ ] ) : number | null {
138+ if ( this . learnedFingerprints . length === 0 ) {
139+ return null ;
140+ }
141+
142+ const normalizedIntent = intent . toLowerCase ( ) . replace ( / [ \s - ] + / g, '_' ) ;
143+ const sorted = [ ...this . learnedFingerprints ] . sort ( ( a , b ) => b . confidence - a . confidence ) ;
144+
145+ for ( const fp of sorted ) {
146+ // Match intent: exact or substring match
147+ const fpIntent = fp . intent . toLowerCase ( ) . replace ( / [ \s - ] + / g, '_' ) ;
148+ if (
149+ fpIntent !== normalizedIntent &&
150+ ! normalizedIntent . includes ( fpIntent ) &&
151+ ! fpIntent . includes ( normalizedIntent )
152+ ) {
153+ continue ;
154+ }
155+
156+ for ( const element of elements ) {
157+ if ( this . fingerprintMatchesElement ( fp , element ) ) {
158+ return element . id ;
159+ }
160+ }
161+ }
162+
163+ return null ;
164+ }
165+
166+ /**
167+ * Check if a learned fingerprint matches a snapshot element.
168+ * Uses token overlap scoring with a minimum threshold.
169+ */
170+ private fingerprintMatchesElement (
171+ fp : LearnedTargetFingerprint ,
172+ element : SnapshotElement
173+ ) : boolean {
174+ let score = 0 ;
175+ let maxScore = 0 ;
176+
177+ // Role match (weight: 2)
178+ if ( fp . role ) {
179+ maxScore += 2 ;
180+ if ( ( element . role ?? '' ) . toLowerCase ( ) === fp . role ) {
181+ score += 2 ;
182+ }
183+ }
184+
185+ // Text token overlap (weight: up to 3)
186+ if ( fp . textTokens && fp . textTokens . length > 0 ) {
187+ maxScore += 3 ;
188+ const elementText = [ element . text , element . ariaLabel , element . name ]
189+ . filter ( ( v ) : v is string => typeof v === 'string' )
190+ . join ( ' ' )
191+ . toLowerCase ( ) ;
192+ const elementTokens = elementText . split ( / \s + / ) . filter ( t => t . length > 0 ) ;
193+ const matchingTokens = fp . textTokens . filter ( ft =>
194+ elementTokens . some ( et => et === ft || et . includes ( ft ) )
195+ ) ;
196+ if ( matchingTokens . length > 0 ) {
197+ score += Math . min ( 3 , Math . ceil ( ( matchingTokens . length / fp . textTokens . length ) * 3 ) ) ;
198+ }
199+ }
200+
201+ // ARIA token overlap (weight: up to 2)
202+ if ( fp . ariaTokens && fp . ariaTokens . length > 0 ) {
203+ maxScore += 2 ;
204+ const ariaText = [ element . ariaLabel , element . name ]
205+ . filter ( ( v ) : v is string => typeof v === 'string' )
206+ . join ( ' ' )
207+ . toLowerCase ( ) ;
208+ const ariaTokens = ariaText . split ( / \s + / ) . filter ( t => t . length > 0 ) ;
209+ const matchingTokens = fp . ariaTokens . filter ( at =>
210+ ariaTokens . some ( et => et === at || et . includes ( at ) )
211+ ) ;
212+ if ( matchingTokens . length > 0 ) {
213+ score += Math . min ( 2 , Math . ceil ( ( matchingTokens . length / fp . ariaTokens . length ) * 2 ) ) ;
214+ }
215+ }
216+
217+ // href path pattern match (weight: 2)
218+ if ( fp . hrefPathPattern ) {
219+ maxScore += 2 ;
220+ const href = element . href || '' ;
221+ if ( href . toLowerCase ( ) . includes ( fp . hrefPathPattern ) ) {
222+ score += 2 ;
223+ }
224+ }
225+
226+ // Require at least 50% of available score to consider it a match
227+ if ( maxScore === 0 ) return false ;
228+ return score / maxScore >= 0.5 ;
229+ }
230+
111231 private getCommonHintForIntent ( intent : string ) : HeuristicHint | null {
112232 const normalized = intent . toLowerCase ( ) . replace ( / [ \s - ] + / g, '_' ) ;
113233 if ( normalized in COMMON_HINTS ) {
0 commit comments