| package semantic |
|
|
| import ( |
| "context" |
| "fmt" |
| "time" |
| ) |
|
|
| |
| type RecoveryConfig struct { |
| |
| Enabled bool |
|
|
| |
| |
| MaxRetries int |
|
|
| |
| |
| MinConfidence float64 |
|
|
| |
| |
| |
| PreferHighConfidence bool |
| } |
|
|
| |
| func DefaultRecoveryConfig() RecoveryConfig { |
| return RecoveryConfig{ |
| Enabled: true, |
| MaxRetries: 1, |
| MinConfidence: 0.4, |
| PreferHighConfidence: false, |
| } |
| } |
|
|
| |
| |
| type RecoveryResult struct { |
| |
| Recovered bool `json:"recovered"` |
|
|
| |
| OriginalRef string `json:"original_ref"` |
|
|
| |
| NewRef string `json:"new_ref,omitempty"` |
|
|
| |
| Score float64 `json:"score,omitempty"` |
|
|
| |
| Confidence string `json:"confidence,omitempty"` |
|
|
| |
| Strategy string `json:"strategy,omitempty"` |
|
|
| |
| FailureType string `json:"failure_type"` |
|
|
| |
| Attempts int `json:"attempts"` |
|
|
| |
| LatencyMs int64 `json:"latency_ms"` |
|
|
| |
| Error string `json:"error,omitempty"` |
| } |
|
|
| |
| |
| |
| type SnapshotRefresher func(ctx context.Context, tabID string) error |
|
|
| |
| |
| type NodeIDResolver func(tabID, ref string) (int64, bool) |
|
|
| |
| |
| type ActionExecutor func(ctx context.Context, kind string, nodeID int64) (map[string]any, error) |
|
|
| |
| |
| |
| type DescriptorBuilder func(tabID string) []ElementDescriptor |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| type RecoveryEngine struct { |
| Config RecoveryConfig |
| Matcher ElementMatcher |
| IntentCache *IntentCache |
| Refresh SnapshotRefresher |
| ResolveNode NodeIDResolver |
| BuildDescs DescriptorBuilder |
| } |
|
|
| |
| func NewRecoveryEngine( |
| cfg RecoveryConfig, |
| matcher ElementMatcher, |
| cache *IntentCache, |
| refresh SnapshotRefresher, |
| resolve NodeIDResolver, |
| buildDescs DescriptorBuilder, |
| ) *RecoveryEngine { |
| return &RecoveryEngine{ |
| Config: cfg, |
| Matcher: matcher, |
| IntentCache: cache, |
| Refresh: refresh, |
| ResolveNode: resolve, |
| BuildDescs: buildDescs, |
| } |
| } |
|
|
| |
| |
| func (re *RecoveryEngine) ShouldAttempt(err error, ref string) bool { |
| if !re.Config.Enabled || ref == "" { |
| return false |
| } |
| ft := ClassifyFailure(err) |
| return ft.Recoverable() |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| func (re *RecoveryEngine) Attempt( |
| ctx context.Context, |
| tabID string, |
| ref string, |
| kind string, |
| exec ActionExecutor, |
| ) (RecoveryResult, map[string]any, error) { |
| start := time.Now() |
|
|
| ft := ClassifyFailure(fmt.Errorf("recovery trigger")) |
| |
| |
| rr := RecoveryResult{ |
| OriginalRef: ref, |
| FailureType: ft.String(), |
| } |
|
|
| |
| query := re.reconstructQuery(tabID, ref) |
| if query == "" { |
| rr.Error = "no cached intent for ref " + ref |
| rr.LatencyMs = time.Since(start).Milliseconds() |
| return rr, nil, fmt.Errorf("recovery: %s", rr.Error) |
| } |
|
|
| |
| maxRetries := re.Config.MaxRetries |
| if maxRetries <= 0 { |
| maxRetries = 1 |
| } |
|
|
| var lastErr error |
| for attempt := 1; attempt <= maxRetries; attempt++ { |
| rr.Attempts = attempt |
|
|
| |
| if re.Refresh != nil { |
| if err := re.Refresh(ctx, tabID); err != nil { |
| lastErr = fmt.Errorf("refresh snapshot: %w", err) |
| continue |
| } |
| } |
|
|
| |
| descs := re.BuildDescs(tabID) |
| if len(descs) == 0 { |
| lastErr = fmt.Errorf("empty snapshot after refresh") |
| continue |
| } |
|
|
| |
| result, err := re.Matcher.Find(ctx, query, descs, FindOptions{ |
| Threshold: re.Config.MinConfidence, |
| TopK: 1, |
| }) |
| if err != nil { |
| lastErr = fmt.Errorf("matcher: %w", err) |
| continue |
| } |
| if result.BestRef == "" || result.BestScore < re.Config.MinConfidence { |
| lastErr = fmt.Errorf("no match above threshold %.2f (best: %.2f)", |
| re.Config.MinConfidence, result.BestScore) |
| continue |
| } |
|
|
| |
| conf := CalibrateConfidence(result.BestScore) |
| if re.Config.PreferHighConfidence && conf == "low" { |
| lastErr = fmt.Errorf("match confidence too low: %s (%.2f)", |
| conf, result.BestScore) |
| continue |
| } |
|
|
| rr.NewRef = result.BestRef |
| rr.Score = result.BestScore |
| rr.Confidence = conf |
| rr.Strategy = result.Strategy |
|
|
| |
| nodeID, ok := re.ResolveNode(tabID, result.BestRef) |
| if !ok { |
| lastErr = fmt.Errorf("new ref %s not in cache after refresh", result.BestRef) |
| continue |
| } |
|
|
| |
| actionResult, execErr := exec(ctx, kind, nodeID) |
| rr.LatencyMs = time.Since(start).Milliseconds() |
| if execErr != nil { |
| lastErr = execErr |
| continue |
| } |
|
|
| rr.Recovered = true |
| return rr, actionResult, nil |
| } |
|
|
| rr.LatencyMs = time.Since(start).Milliseconds() |
| if lastErr != nil { |
| rr.Error = lastErr.Error() |
| } |
| return rr, nil, lastErr |
| } |
|
|
| |
| |
| |
| func (re *RecoveryEngine) AttemptWithClassification( |
| ctx context.Context, |
| tabID string, |
| ref string, |
| kind string, |
| ft FailureType, |
| exec ActionExecutor, |
| ) (RecoveryResult, map[string]any, error) { |
| start := time.Now() |
|
|
| rr := RecoveryResult{ |
| OriginalRef: ref, |
| FailureType: ft.String(), |
| } |
|
|
| query := re.reconstructQuery(tabID, ref) |
| if query == "" { |
| rr.Error = "no cached intent for ref " + ref |
| rr.LatencyMs = time.Since(start).Milliseconds() |
| return rr, nil, fmt.Errorf("recovery: %s", rr.Error) |
| } |
|
|
| maxRetries := re.Config.MaxRetries |
| if maxRetries <= 0 { |
| maxRetries = 1 |
| } |
|
|
| var lastErr error |
| for attempt := 1; attempt <= maxRetries; attempt++ { |
| rr.Attempts = attempt |
|
|
| if re.Refresh != nil { |
| if err := re.Refresh(ctx, tabID); err != nil { |
| lastErr = fmt.Errorf("refresh snapshot: %w", err) |
| continue |
| } |
| } |
|
|
| descs := re.BuildDescs(tabID) |
| if len(descs) == 0 { |
| lastErr = fmt.Errorf("empty snapshot after refresh") |
| continue |
| } |
|
|
| result, err := re.Matcher.Find(ctx, query, descs, FindOptions{ |
| Threshold: re.Config.MinConfidence, |
| TopK: 1, |
| }) |
| if err != nil { |
| lastErr = fmt.Errorf("matcher: %w", err) |
| continue |
| } |
| if result.BestRef == "" || result.BestScore < re.Config.MinConfidence { |
| lastErr = fmt.Errorf("no match above threshold %.2f (best: %.2f)", |
| re.Config.MinConfidence, result.BestScore) |
| continue |
| } |
|
|
| conf := CalibrateConfidence(result.BestScore) |
| if re.Config.PreferHighConfidence && conf == "low" { |
| lastErr = fmt.Errorf("match confidence too low: %s (%.2f)", |
| conf, result.BestScore) |
| continue |
| } |
|
|
| rr.NewRef = result.BestRef |
| rr.Score = result.BestScore |
| rr.Confidence = conf |
| rr.Strategy = result.Strategy |
|
|
| nodeID, ok := re.ResolveNode(tabID, result.BestRef) |
| if !ok { |
| lastErr = fmt.Errorf("new ref %s not in cache after refresh", result.BestRef) |
| continue |
| } |
|
|
| actionResult, execErr := exec(ctx, kind, nodeID) |
| rr.LatencyMs = time.Since(start).Milliseconds() |
| if execErr != nil { |
| lastErr = execErr |
| continue |
| } |
|
|
| rr.Recovered = true |
| return rr, actionResult, nil |
| } |
|
|
| rr.LatencyMs = time.Since(start).Milliseconds() |
| if lastErr != nil { |
| rr.Error = lastErr.Error() |
| } |
| return rr, nil, lastErr |
| } |
|
|
| |
| |
| |
| func (re *RecoveryEngine) reconstructQuery(tabID, ref string) string { |
| if re.IntentCache == nil { |
| return "" |
| } |
| entry, ok := re.IntentCache.Lookup(tabID, ref) |
| if !ok { |
| return "" |
| } |
| if entry.Query != "" { |
| return entry.Query |
| } |
| return entry.Descriptor.Composite() |
| } |
|
|
| |
| |
| func (re *RecoveryEngine) RecordIntent(tabID, ref string, entry IntentEntry) { |
| if re.IntentCache != nil { |
| re.IntentCache.Store(tabID, ref, entry) |
| } |
| } |
|
|