Class: Insika::FollowupEngine
- Inherits:
-
Object
- Object
- Insika::FollowupEngine
- 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
-
#initialize(store:, followup_store:, contact_store:, task_store:, profiles:, executor:, window: DEFAULT_WINDOW, now: nil, logger: nil, event_stream: nil) ⇒ FollowupEngine
constructor
A new instance of FollowupEngine.
-
#run ⇒ Object
-> { 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.
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
#run ⇒ Object
-> { 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 |