Class: Insika::FollowupEngine

Inherits:
Object
  • Object
show all
Defined in:
lib/insika/followup_engine.rb

Overview

the tick-driven FIRER of follow-up records: one pass per claim window; each record is its own claim (pending -> fired + task creation + the contact bump in ONE transaction — D5). Gating order (D6):

policy valid? -> contact state (never-granted / revoked / unavailable ->
block) -> quiet hours? (pending — DEFER) -> dedup guard (block) ->
frequency ceiling (block) -> FIRE.

Blocking is at fire time, never at schedule time; a blocked record is auditable, never silent. The engine never modifies a policy and never invents a reason — the turn it creates is delivered entirely by the existing pipeline (it holds no channel code).

Constant Summary collapse

SCOPE =
"followup_fire"
KEY =
"claim"
DEFAULT_WINDOW =

seconds; one firing worker per window

300
FIRING_PROMPT =

The engine's kick text. The agent composes the customer-visible message itself — the engine never writes a word the customer reads. %reason and %now (UTC) are empty-escaped.

"You are following up on a previous conversation, as " \
"scheduled and with the customer's consent. The follow-up " \
"reason: %{reason}. It is now %{now} (UTC). Write ONE short " \
"follow-up message to the customer now, referencing the " \
"earlier conversation."

Instance Method Summary collapse

Constructor Details

#initialize(store:, followup_store:, contact_store:, task_store:, profiles:, executor:, window: DEFAULT_WINDOW, now: nil, logger: nil, event_stream: nil) ⇒ FollowupEngine

Returns a new instance of FollowupEngine.



32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/insika/followup_engine.rb', line 32

def initialize(store:, followup_store:, contact_store:, task_store:,
               profiles:, executor:, window: DEFAULT_WINDOW,
               now: nil, logger: nil, event_stream: nil)
  @store = store
  @followup_store = followup_store
  @contact_store = contact_store
  @task_store = task_store
  @profiles = profiles
  @executor = executor
  @window = window
  @now = now
  @logger = logger
  @event_stream = event_stream
end

Instance Method Details

#runObject

-> { claimed: false } | { claimed: true, fired: N, blocked: N, errors: N, blocked_reasons: { "rule" => N }, deferred: N } A StoreError on ONE record aborts THAT record's transaction (rescued, counted, the loop continues) — a broken record must not hold the other stores' follow-ups hostage.



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/insika/followup_engine.rb', line 53

def run
  now_time = @now || Time.now.utc
  return { claimed: false } unless claim_window(now_time)

  fired = 0
  blocked = 0
  deferred = 0
  errors = 0
  reasons = Hash.new(0)

  # D7 at fire time: when more than one PENDING record holds the same
  # (customer, reason), only the OLDEST fires. The verdict is snapshotted
  # at pass start — a record that fires earlier in THIS pass must still
  # dedup the younger ones behind it.
  dedup_snapshot = {}
  due = @followup_store.due(now: now_time)
  due.each do |record|
    pair = [record.tenant, record.agent, record.customer, record.reason]
    dedup_snapshot[pair] ||= @followup_store
                              .pending_for(tenant: record.tenant, agent: record.agent,
                                           customer: record.customer, reason: record.reason)
                              &.id
  end

  due.each do |record|
    begin
      outcome = fire_record(record, now_time,
                            older_pending_id: dedup_snapshot[[record.tenant, record.agent,
                                                               record.customer, record.reason]])
      case outcome
      when :fired then fired += 1
      when :deferred then deferred += 1
      when Array
        # a blocked record is auditable, never silent — the failing rule is
        # written down on the record itself (D6/D9).
        @followup_store.block(id: record.id, reason: outcome[1], now: now_time)
        blocked += 1
        reasons[outcome[1].to_s] += 1
      end
    rescue StandardError
      # a broken record must not hold the other records' follow-ups
      # hostage — its own transaction already rolled back.
      errors += 1
    end
  end

  { claimed: true, fired: fired, blocked: blocked, errors: errors,
    blocked_reasons: reasons, deferred: deferred }
end