refactor(core): overhaul state transition architecture, persistent job queue, and API layer
- Implement atomic state transitions: add EntryService.PatchEntry() with EntryDiff computation and EventEntryTransition event emission. - Add persistent SQLite task queue (JobEngine): add jobs table with crash recovery, exponential backoff retries, concurrency control, and idempotency key deduplication. - Decompose monolithic HTTP handlers: split handlers.go into domain handlers (auth, feeds, entries, jobs, settings, rules, sse). - Implement real-time Server-Sent Events (/api/events/stream) for multi-tab/device state synchronization. - Fix SQLite concurrency: apply busy_timeout(10000) and journal_mode(WAL) via DSN query parameters to resolve SQLITE_BUSY (5). - Fix AI settings & prompt loading: unmarshal JSON from ai_settings, apply configured prompt templates, and enforce strict enabled/API key validations. - Fix undo share state: remove omitempty from ShareUUID to serialize null for state resets. - Fix middle list counter: compute true unread/total stats from DB and clean switcher button labels. - Adapt mobile UI for iOS: add viewport-fit=cover in index.html and calibrate safe-area insets for the bottom navigation and article reader toolbar. - Add architectural documentation and worklog in docs/009_worklog.md.
This commit is contained in:
@@ -2,6 +2,9 @@ package rules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
|
||||
@@ -14,11 +17,16 @@ type WorkerDispatcher interface {
|
||||
Dispatch(ctx context.Context, action models.RuleAction, entry models.FeedEntry, depth int)
|
||||
}
|
||||
|
||||
type JobEnqueuer interface {
|
||||
Enqueue(ctx context.Context, jobType string, payload map[string]interface{}, idempotencyKey string) (*models.Job, error)
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
db *db.DB
|
||||
bus *events.EventBus
|
||||
evaluator *Evaluator
|
||||
dispatcher WorkerDispatcher
|
||||
enqueuer JobEnqueuer
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
@@ -32,8 +40,13 @@ func NewEngine(database *db.DB, bus *events.EventBus, dispatcher WorkerDispatche
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) SetEnqueuer(enqueuer JobEnqueuer) {
|
||||
e.enqueuer = enqueuer
|
||||
}
|
||||
|
||||
// Start registers event bus subscriptions to evaluate rules reactively.
|
||||
func (e *Engine) Start(ctx context.Context) {
|
||||
e.bus.Subscribe(events.EventEntryTransition, e.handleTransitionEvent)
|
||||
e.bus.Subscribe(events.EventPostCreated, e.handleEvent)
|
||||
e.bus.Subscribe(events.EventPostUpdated, e.handleEvent)
|
||||
e.bus.Subscribe(events.EventVariantUpdated, e.handleEvent)
|
||||
@@ -41,6 +54,44 @@ func (e *Engine) Start(ctx context.Context) {
|
||||
e.log.Info("Rules engine started, listening for events")
|
||||
}
|
||||
|
||||
func (e *Engine) handleTransitionEvent(ctx context.Context, evt events.Event) error {
|
||||
rules, err := e.db.ListRules()
|
||||
if err != nil {
|
||||
e.log.Error("Failed to fetch rules for transition", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
newEntry := evt.NewEntry
|
||||
if newEntry == nil {
|
||||
newEntry = &evt.Entry
|
||||
}
|
||||
|
||||
for _, r := range rules {
|
||||
if !r.IsActive {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check trigger type compatibility
|
||||
trigger := r.Trigger
|
||||
if trigger != "on_update" && trigger != "on_entry_transition" && trigger != "on_variant_updated" && trigger != "on_post_updated" && trigger != "on_post_created" && trigger != "variant_change" {
|
||||
continue
|
||||
}
|
||||
|
||||
if e.evaluator.EvaluateTransition(r, evt.OldEntry, newEntry, evt.Diff) {
|
||||
e.log.Info("Transition rule matched",
|
||||
zap.String("rule_id", r.ID),
|
||||
zap.String("rule_name", r.Name),
|
||||
zap.String("entry_id", newEntry.ID),
|
||||
zap.Int("actions_count", len(r.Actions)),
|
||||
)
|
||||
|
||||
e.executeRuleActions(ctx, r, *newEntry, evt.Depth)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) handleEvent(ctx context.Context, evt events.Event) error {
|
||||
trigger := string(evt.Type)
|
||||
rules, err := e.db.GetActiveRules(trigger)
|
||||
@@ -53,7 +104,6 @@ func (e *Engine) handleEvent(ctx context.Context, evt events.Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fetch full entry with latest variants if entry ID is present
|
||||
entry := evt.Entry
|
||||
if entry.ID != "" {
|
||||
if full, err := e.db.GetEntry(entry.ID); err == nil && full != nil {
|
||||
@@ -62,7 +112,7 @@ func (e *Engine) handleEvent(ctx context.Context, evt events.Event) error {
|
||||
}
|
||||
|
||||
for _, r := range rules {
|
||||
if e.evaluator.Evaluate(r, entry) {
|
||||
if e.evaluator.EvaluateTransition(r, evt.OldEntry, &entry, evt.Diff) {
|
||||
e.log.Info("Rule matched",
|
||||
zap.String("rule_id", r.ID),
|
||||
zap.String("rule_name", r.Name),
|
||||
@@ -70,13 +120,43 @@ func (e *Engine) handleEvent(ctx context.Context, evt events.Event) error {
|
||||
zap.Int("actions_count", len(r.Actions)),
|
||||
)
|
||||
|
||||
for _, action := range r.Actions {
|
||||
if e.dispatcher != nil {
|
||||
e.dispatcher.Dispatch(ctx, action, entry, evt.Depth+1)
|
||||
}
|
||||
}
|
||||
e.executeRuleActions(ctx, r, entry, evt.Depth)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) executeRuleActions(ctx context.Context, r models.Rule, entry models.FeedEntry, depth int) {
|
||||
for _, action := range r.Actions {
|
||||
// Prefer enqueuing async jobs to the persistent task queue if available
|
||||
if e.enqueuer != nil {
|
||||
payload := make(map[string]interface{})
|
||||
if action.Params != nil {
|
||||
for k, v := range action.Params {
|
||||
payload[k] = v
|
||||
}
|
||||
}
|
||||
payload["entry_id"] = entry.ID
|
||||
payload["feed_id"] = entry.FeedID
|
||||
payload["rule_id"] = r.ID
|
||||
payload["rule_name"] = r.Name
|
||||
|
||||
// Generate deterministic idempotency key
|
||||
rawKey := fmt.Sprintf("%s:%s:%s:%d:%d", r.ID, entry.ID, action.Type, entry.CreatedAt.Unix(), entry.PublishedAt.Unix())
|
||||
h := sha256.Sum256([]byte(rawKey))
|
||||
idempotencyKey := hex.EncodeToString(h[:16])
|
||||
|
||||
_, err := e.enqueuer.Enqueue(ctx, action.Type, payload, idempotencyKey)
|
||||
if err != nil {
|
||||
e.log.Error("Failed to enqueue job for rule action",
|
||||
zap.String("rule_id", r.ID),
|
||||
zap.String("action", action.Type),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
} else if e.dispatcher != nil {
|
||||
e.dispatcher.Dispatch(ctx, action, entry, depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+201
-27
@@ -2,6 +2,7 @@ package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -16,8 +17,16 @@ func NewEvaluator() *Evaluator {
|
||||
return &Evaluator{}
|
||||
}
|
||||
|
||||
// Evaluate checks whether a FeedEntry satisfies a Rule's conditions.
|
||||
// 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
|
||||
}
|
||||
@@ -25,7 +34,7 @@ func (e *Evaluator) Evaluate(rule models.Rule, entry models.FeedEntry) bool {
|
||||
matchMode := strings.ToLower(rule.MatchMode)
|
||||
if matchMode == "" || matchMode == "all" {
|
||||
for _, cond := range rule.Conditions {
|
||||
if !e.evaluateCondition(cond, entry) {
|
||||
if !e.evaluateTransitionCondition(cond, oldEntry, newEntry, diff) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -34,20 +43,109 @@ func (e *Evaluator) Evaluate(rule models.Rule, entry models.FeedEntry) bool {
|
||||
|
||||
// matchMode == "any"
|
||||
for _, cond := range rule.Conditions {
|
||||
if e.evaluateCondition(cond, entry) {
|
||||
if e.evaluateTransitionCondition(cond, oldEntry, newEntry, diff) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *Evaluator) evaluateCondition(cond models.RuleCondition, entry models.FeedEntry) bool {
|
||||
actualValue := e.resolveField(cond.Field, entry)
|
||||
return e.compare(actualValue, cond.Operator, cond.Value)
|
||||
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) resolveField(field string, entry models.FeedEntry) interface{} {
|
||||
switch strings.ToLower(field) {
|
||||
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":
|
||||
@@ -83,16 +181,34 @@ func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface
|
||||
return entry.PublishedAt
|
||||
case "created_at":
|
||||
return entry.CreatedAt
|
||||
case "tags", "metadata.tags":
|
||||
return getTagsSlice(entry)
|
||||
}
|
||||
|
||||
// Variant field resolution: "variants.keywords", "variants.readability_full_text", "variants.tags"
|
||||
if strings.HasPrefix(field, "variants.") {
|
||||
variantKey := strings.TrimPrefix(field, "variants.")
|
||||
// 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[variantKey]; ok {
|
||||
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
|
||||
@@ -101,35 +217,85 @@ func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface
|
||||
func (e *Evaluator) compare(actual interface{}, op string, expected interface{}) bool {
|
||||
opLower := strings.ToLower(strings.TrimSpace(op))
|
||||
|
||||
// Handle exists / not_exists operators
|
||||
if opLower == "exists" {
|
||||
if actual == nil {
|
||||
return false
|
||||
}
|
||||
if s, ok := actual.(string); ok && s == "" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if opLower == "not_exists" {
|
||||
// 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 && s == "" {
|
||||
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 Time/Timestamp comparisons
|
||||
// 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)
|
||||
|
||||
@@ -218,6 +384,16 @@ func (e *Evaluator) compareTime(t time.Time, op string, expected interface{}) bo
|
||||
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 {
|
||||
@@ -279,7 +455,6 @@ func parseTime(val interface{}) (time.Time, error) {
|
||||
func ValidateRuleSafety(rule models.Rule) error {
|
||||
trigger := strings.ToLower(rule.Trigger)
|
||||
|
||||
// If trigger is on_variant_updated / variant_change, check for recursive set_variant on same key
|
||||
if trigger == "on_variant_updated" || trigger == "variant_change" {
|
||||
for _, action := range rule.Actions {
|
||||
if action.Type == "set_variant" {
|
||||
@@ -288,7 +463,6 @@ func ValidateRuleSafety(rule models.Rule) error {
|
||||
targetKey, _ = action.Params["key"].(string)
|
||||
}
|
||||
|
||||
// Check if trigger targets this exact key without safe conditions
|
||||
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)
|
||||
|
||||
@@ -116,3 +116,73 @@ func TestRuleEvaluator(t *testing.T) {
|
||||
t.Error("expected ValidateRuleSafety to detect cyclic loop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionEvaluation(t *testing.T) {
|
||||
eval := NewEvaluator()
|
||||
|
||||
oldEntry := models.FeedEntry{
|
||||
ID: "entry-2",
|
||||
FeedID: "feed-1",
|
||||
FeedTitle: "Tech Blog",
|
||||
Title: "Deep Dive into Go Scheduler",
|
||||
Content: "Short intro...",
|
||||
Variants: map[string]models.EntryVariant{},
|
||||
}
|
||||
|
||||
newEntryWithFullText := models.FeedEntry{
|
||||
ID: "entry-2",
|
||||
FeedID: "feed-1",
|
||||
FeedTitle: "Tech Blog",
|
||||
Title: "Deep Dive into Go Scheduler",
|
||||
Content: "Short intro...",
|
||||
Variants: map[string]models.EntryVariant{
|
||||
"readability_full_text": {
|
||||
EntryID: "entry-2",
|
||||
VariantKey: "readability_full_text",
|
||||
TextValue: "Full article content here with extensive details...",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
diff := &models.EntryDiff{
|
||||
VariantsAdded: []string{"readability_full_text"},
|
||||
}
|
||||
|
||||
// Rule: trigger AI summary when readability_full_text became_present AND ai_summary is empty
|
||||
aiRule := models.Rule{
|
||||
ID: "r-ai",
|
||||
Name: "AI summary on full text added",
|
||||
MatchMode: "all",
|
||||
Conditions: []models.RuleCondition{
|
||||
{Field: "variants.readability_full_text", Operator: "became_present"},
|
||||
{Field: "variants.ai_summary", Operator: "is_empty"},
|
||||
},
|
||||
}
|
||||
|
||||
// Should match when full text was added
|
||||
if !eval.EvaluateTransition(aiRule, &oldEntry, &newEntryWithFullText, diff) {
|
||||
t.Error("expected aiRule to match when readability_full_text became_present")
|
||||
}
|
||||
|
||||
// Should NOT match if tag was added instead of full text
|
||||
diffOnlyTags := &models.EntryDiff{
|
||||
FieldsChanged: []string{"metadata.tags"},
|
||||
}
|
||||
if eval.EvaluateTransition(aiRule, &oldEntry, &oldEntry, diffOnlyTags) {
|
||||
t.Error("expected aiRule to NOT match when only tags changed")
|
||||
}
|
||||
|
||||
// Test word count condition for TTS: word_count >= 5
|
||||
ttsRule := models.Rule{
|
||||
ID: "r-tts",
|
||||
Name: "TTS on long summary",
|
||||
MatchMode: "all",
|
||||
Conditions: []models.RuleCondition{
|
||||
{Field: "variants.readability_full_text.word_count", Operator: ">=", Value: 5},
|
||||
},
|
||||
}
|
||||
|
||||
if !eval.EvaluateTransition(ttsRule, &oldEntry, &newEntryWithFullText, diff) {
|
||||
t.Error("expected ttsRule to match when word_count >= 5")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user