package rules import ( "fmt" "reflect" "regexp" "strconv" "strings" "time" "git.pengzhan.dev/haopengzhan/rsspipe/internal/models" ) type Evaluator struct{} func NewEvaluator() *Evaluator { return &Evaluator{} } // Evaluate checks whether a single FeedEntry satisfies a Rule's conditions (legacy / static evaluation). func (e *Evaluator) Evaluate(rule models.Rule, entry models.FeedEntry) bool { return e.EvaluateTransition(rule, nil, &entry, nil) } // EvaluateTransition evaluates a rule against the (OldState, NewState, EntryDiff) transition delta. func (e *Evaluator) EvaluateTransition(rule models.Rule, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool { if newEntry == nil { return false } if len(rule.Conditions) == 0 { return true } matchMode := strings.ToLower(rule.MatchMode) if matchMode == "" || matchMode == "all" { for _, cond := range rule.Conditions { if !e.evaluateTransitionCondition(cond, oldEntry, newEntry, diff) { return false } } return true } // matchMode == "any" for _, cond := range rule.Conditions { if e.evaluateTransitionCondition(cond, oldEntry, newEntry, diff) { return true } } return false } func (e *Evaluator) evaluateTransitionCondition(cond models.RuleCondition, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool { opLower := strings.ToLower(strings.TrimSpace(cond.Operator)) // 1. Transition predicates: became_present, changed, removed switch opLower { case "became_present", "added": return e.checkBecamePresent(cond.Field, oldEntry, newEntry, diff) case "changed", "updated": return e.checkFieldChanged(cond.Field, oldEntry, newEntry, diff) case "removed", "deleted": return e.checkFieldRemoved(cond.Field, oldEntry, newEntry, diff) } // 2. Standard state guards & value comparisons evaluated on NewState actualValue := e.resolveField(cond.Field, newEntry) return e.compare(actualValue, opLower, cond.Value) } func (e *Evaluator) checkBecamePresent(field string, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool { if newEntry == nil { return false } // If diff is available, check direct addition if strings.HasPrefix(field, "variants.") { vKey := strings.TrimPrefix(field, "variants.") if strings.Contains(vKey, ".") { vKey = strings.Split(vKey, ".")[0] } if diff != nil && diff.HasVariantAddition(vKey) { return true } // Fallback comparison between oldEntry and newEntry oldHas := oldEntry != nil && oldEntry.Variants != nil && oldEntry.Variants[vKey].TextValue != "" newHas := newEntry.Variants != nil && newEntry.Variants[vKey].TextValue != "" return !oldHas && newHas } if field == "metadata.tags" || field == "tags" { oldTags := getTagsSlice(oldEntry) newTags := getTagsSlice(newEntry) return len(oldTags) == 0 && len(newTags) > 0 } oldVal := e.resolveField(field, oldEntry) newVal := e.resolveField(field, newEntry) return (oldVal == nil || fmt.Sprintf("%v", oldVal) == "") && (newVal != nil && fmt.Sprintf("%v", newVal) != "") } func (e *Evaluator) checkFieldChanged(field string, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool { if oldEntry == nil || newEntry == nil { return false } if strings.HasPrefix(field, "variants.") { vKey := strings.TrimPrefix(field, "variants.") if strings.Contains(vKey, ".") { vKey = strings.Split(vKey, ".")[0] } if diff != nil && (diff.HasVariantAddition(vKey) || diff.HasVariantChange(vKey)) { return true } } oldVal := e.resolveField(field, oldEntry) newVal := e.resolveField(field, newEntry) return !reflect.DeepEqual(oldVal, newVal) } func (e *Evaluator) checkFieldRemoved(field string, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool { if oldEntry == nil { return false } if strings.HasPrefix(field, "variants.") { vKey := strings.TrimPrefix(field, "variants.") if diff != nil { for _, r := range diff.VariantsRemoved { if r == vKey { return true } } } oldHas := oldEntry.Variants != nil && oldEntry.Variants[vKey].TextValue != "" newHas := newEntry != nil && newEntry.Variants != nil && newEntry.Variants[vKey].TextValue != "" return oldHas && !newHas } oldVal := e.resolveField(field, oldEntry) newVal := e.resolveField(field, newEntry) return (oldVal != nil && fmt.Sprintf("%v", oldVal) != "") && (newVal == nil || fmt.Sprintf("%v", newVal) == "") } func (e *Evaluator) resolveField(field string, entry *models.FeedEntry) interface{} { if entry == nil { return nil } fieldLower := strings.ToLower(strings.TrimSpace(field)) switch fieldLower { case "feed_id": return entry.FeedID case "feed_title": return entry.FeedTitle case "title": return entry.Title case "author": return entry.Author case "url": return entry.URL case "content", "raw_content", "rss_content": var parts []string if entry.Title != "" { parts = append(parts, entry.Title) } if entry.Content != "" { parts = append(parts, entry.Content) } if entry.RawContent != "" && entry.RawContent != entry.Content { parts = append(parts, entry.RawContent) } if entry.Variants != nil { if v, ok := entry.Variants["readability_full_text"]; ok && v.TextValue != "" { parts = append(parts, v.TextValue) } } return strings.Join(parts, "\n") case "is_read": return entry.IsRead case "is_starred": return entry.IsStarred case "published_at": return entry.PublishedAt case "created_at": return entry.CreatedAt case "tags", "metadata.tags": return getTagsSlice(entry) } // Variant resolution: "variants.ai_summary.word_count", "variants.ai_summary", "variants.readability_full_text" if strings.HasPrefix(fieldLower, "variants.") { path := strings.TrimPrefix(fieldLower, "variants.") parts := strings.Split(path, ".") vKey := parts[0] if entry.Variants != nil { if v, ok := entry.Variants[vKey]; ok { if len(parts) > 1 { subProp := parts[1] if subProp == "word_count" { return float64(len(strings.Fields(v.TextValue))) } if subProp == "char_count" || subProp == "length" { return float64(len(v.TextValue)) } if v.Metadata != nil { return v.Metadata[subProp] } } return v.TextValue } } return nil } return nil } func (e *Evaluator) compare(actual interface{}, op string, expected interface{}) bool { opLower := strings.ToLower(strings.TrimSpace(op)) // Handle is_empty / is_present / exists / not_exists if opLower == "is_empty" || opLower == "not_exists" { if actual == nil { return true } if s, ok := actual.(string); ok && strings.TrimSpace(s) == "" { return true } if slice, ok := actual.([]string); ok && len(slice) == 0 { return true } return false } if opLower == "is_present" || opLower == "exists" { if actual == nil { return false } if s, ok := actual.(string); ok && strings.TrimSpace(s) == "" { return false } if slice, ok := actual.([]string); ok && len(slice) == 0 { return false } return true } if actual == nil { return opLower == "neq" || opLower == "!=" || opLower == "not_in" || opLower == "not_contains" } // Handle numeric comparisons (word_count >= 500, etc.) if actualNum, ok := toFloat64(actual); ok == nil { if expNum, okExp := toFloat64(expected); okExp == nil { switch opLower { case ">=": return actualNum >= expNum case ">": return actualNum > expNum case "<=": return actualNum <= expNum case "<": return actualNum < expNum case "eq", "==": return actualNum == expNum case "neq", "!=": return actualNum != expNum } } } // Handle Time comparisons if actualTime, ok := actual.(time.Time); ok { return e.compareTime(actualTime, opLower, expected) } // Handle slice containment (e.g. tags contains "Tech") if actualSlice, ok := actual.([]string); ok { expectedStr := fmt.Sprintf("%v", expected) switch opLower { case "contains", "in": for _, item := range actualSlice { if strings.EqualFold(item, expectedStr) { return true } } return false case "contains_any": tokens := parseStringList(expected) for _, item := range actualSlice { for _, t := range tokens { if strings.EqualFold(item, t) { return true } } } return false } } actualStr := fmt.Sprintf("%v", actual) expectedStr := fmt.Sprintf("%v", expected) switch opLower { case "eq", "==": return strings.EqualFold(actualStr, expectedStr) case "neq", "!=": return !strings.EqualFold(actualStr, expectedStr) case "contains": expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n") return strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedClean)) case "not_contains": expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n") return !strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedClean)) case "starts_with": expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n") return strings.HasPrefix(strings.ToLower(actualStr), strings.ToLower(expectedClean)) case "ends_with": expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n") return strings.HasSuffix(strings.ToLower(actualStr), strings.ToLower(expectedClean)) case "contains_any": tokens := parseStringList(expected) for _, token := range tokens { if strings.Contains(strings.ToLower(actualStr), strings.ToLower(token)) { return true } } return false case "in": tokens := parseStringList(expected) for _, item := range tokens { if strings.EqualFold(actualStr, item) { return true } } return false case "not_in": tokens := parseStringList(expected) for _, item := range tokens { if strings.EqualFold(actualStr, item) { return false } } return true case "matches", "regex": re, err := regexp.Compile(expectedStr) if err != nil { return false } return re.MatchString(actualStr) default: return false } } func (e *Evaluator) compareTime(t time.Time, op string, expected interface{}) bool { now := time.Now() switch op { case "within_last_hours": hours, err := toFloat64(expected) if err != nil { return false } cutoff := now.Add(-time.Duration(hours * float64(time.Hour))) return t.After(cutoff) case "older_than_days": days, err := toFloat64(expected) if err != nil { return false } cutoff := now.Add(-time.Duration(days * 24 * float64(time.Hour))) return t.Before(cutoff) case ">=", "after": targetTime, err := parseTime(expected) if err != nil { return false } return t.After(targetTime) || t.Equal(targetTime) case "<=", "before": targetTime, err := parseTime(expected) if err != nil { return false } return t.Before(targetTime) || t.Equal(targetTime) } return false } func getTagsSlice(entry *models.FeedEntry) []string { if entry == nil || entry.Metadata == nil { return nil } if raw, ok := entry.Metadata["tags"]; ok { return parseStringList(raw) } return nil } func parseStringList(val interface{}) []string { var result []string clean := func(s string) string { s = strings.TrimSpace(s) s = strings.Trim(s, "\"'`") return strings.TrimSpace(s) } switch v := val.(type) { case []interface{}: for _, item := range v { itemStr := clean(fmt.Sprintf("%v", item)) if itemStr != "" { result = append(result, itemStr) } } case []string: for _, item := range v { itemStr := clean(item) if itemStr != "" { result = append(result, itemStr) } } case string: parts := strings.Split(v, ",") for _, part := range parts { trimmed := clean(part) if trimmed != "" { result = append(result, trimmed) } } } return result } func toFloat64(val interface{}) (float64, error) { switch v := val.(type) { case float64: return v, nil case int: return float64(v), nil case int64: return float64(v), nil case string: return strconv.ParseFloat(strings.TrimSpace(v), 64) } return 0, fmt.Errorf("cannot convert %v to float64", val) } func parseTime(val interface{}) (time.Time, error) { if t, ok := val.(time.Time); ok { return t, nil } s := fmt.Sprintf("%v", val) return time.Parse(time.RFC3339, s) } // ValidateRuleSafety checks for potential cyclic/endless loops at rule creation time. func ValidateRuleSafety(rule models.Rule) error { trigger := strings.ToLower(rule.Trigger) if trigger == "on_variant_updated" || trigger == "variant_change" { for _, action := range rule.Actions { if action.Type == "set_variant" { targetKey, _ := action.Params["variant_key"].(string) if targetKey == "" { targetKey, _ = action.Params["key"].(string) } for _, cond := range rule.Conditions { if cond.Field == "variants."+targetKey && (cond.Operator == "exists" || cond.Operator == "eq") { return fmt.Errorf("cyclic rule detected: rule triggers on variant '%s' and unconditionally writes to '%s'", targetKey, targetKey) } } } } } return nil }