Class: Insika::FollowupPolicy

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

Overview

the parsed follow-up policy of ONE agent — the ONLY shape the engine accepts, shared by the schedule/cancel tools, the FollowupEngine, the doctor and the Studio. Pure value object: it never touches a store (D8 — the policy is pack data on the profile, no platform settings layer).

parse returns nil on a malformed hash (D9 — the firer BLOCKS, the doctor explains, the tools refuse); parse! raises Insika::ValidationError naming the exact defect.

Defined Under Namespace

Classes: QuietHours

Constant Summary collapse

FREQUENCY_RE =
/\A(\d+)\/(\d+)(m|h|d)s?\z/
HH_MM_RE =
/\A\d{2}:\d{2}\z/
DEFAULT_ARM =
"schedule"
DEFAULT_SILENCE_AFTER_SENDS =
3
KEYWORD_MAX =
200

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(hash) ⇒ FollowupPolicy

Returns a new instance of FollowupPolicy.



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

def initialize(hash)
  raise Insika::ValidationError, "followup: declaration must be a Hash" unless hash.is_a?(Hash)

  h = hash.transform_keys(&:to_s)
  @arm = arm_of(h)
  policy = h["policy"]
  raise Insika::ValidationError, "followup.policy must be a Hash" unless policy.is_a?(Hash)

  policy = policy.transform_keys(&:to_s)
  @quiet_hours = quiet_hours_of(policy)
  @max_frequency = frequency_of(policy)
  @cancel_keywords = keywords_of(policy)
  @silence_after_sends = silence_of(policy)
  freeze
end

Instance Attribute Details

#armObject (readonly)

Returns the value of attribute arm.



21
22
23
# File 'lib/insika/followup_policy.rb', line 21

def arm
  @arm
end

#cancel_keywordsObject (readonly)

Returns the value of attribute cancel_keywords.



21
22
23
# File 'lib/insika/followup_policy.rb', line 21

def cancel_keywords
  @cancel_keywords
end

#max_frequencyObject (readonly)

Returns the value of attribute max_frequency.



21
22
23
# File 'lib/insika/followup_policy.rb', line 21

def max_frequency
  @max_frequency
end

#quiet_hoursObject (readonly)

Returns the value of attribute quiet_hours.



21
22
23
# File 'lib/insika/followup_policy.rb', line 21

def quiet_hours
  @quiet_hours
end

#silence_after_sendsObject (readonly)

Returns the value of attribute silence_after_sends.



21
22
23
# File 'lib/insika/followup_policy.rb', line 21

def silence_after_sends
  @silence_after_sends
end

Class Method Details

.parse(hash) ⇒ Object



23
24
25
26
27
# File 'lib/insika/followup_policy.rb', line 23

def self.parse(hash)
  new(hash)
rescue Insika::ValidationError
  nil
end

.parse!(hash) ⇒ Object



29
30
31
# File 'lib/insika/followup_policy.rb', line 29

def self.parse!(hash)
  new(hash)
end

Instance Method Details

#frequency_windowObject

"2/24h" -> { count: 2, seconds: 86_400 }. nil when the policy has no ceiling (absent max_frequency — the frequency gate is off). Always valid once parsed. count is the sends allowed per window; seconds is the WINDOW's duration (the value after the "/"), never count*window.



54
55
56
57
58
59
60
# File 'lib/insika/followup_policy.rb', line 54

def frequency_window
  return nil if @max_frequency.nil?

  match = FREQUENCY_RE.match(@max_frequency)
  multiplier = { "m" => 60, "h" => 3600, "d" => 86_400 }.fetch(match[3])
  { count: match[1].to_i, seconds: match[2].to_i * multiplier }
end

#match_keyword(text) ⇒ Object

The first cancel keyword matched (case/accent-insensitive substring); nil when none matches.



87
88
89
90
91
92
93
# File 'lib/insika/followup_policy.rb', line 87

def match_keyword(text)
  folded = fold(text)
  @cancel_keywords.each do |kw|
    return kw if folded.include?(fold(kw))
  end
  nil
end

#quiet?(time) ⇒ Boolean

Is the given UTC Time inside quiet hours in the policy's timezone? (nil quiet_hours -> false: no quiet window.)

IANA names are resolved through the OS tz database by pointing Ruby's TZ at the zone for the computation (Ruby stdlib's Time#getlocal only takes an offset, not a zone name). Save/restore keeps the global intact; under the engine's cooperative fiber model — no IO between the save and the restore — the mutation is atomic on the calling fiber.

Returns:

  • (Boolean)


70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/insika/followup_policy.rb', line 70

def quiet?(time)
  return false unless @quiet_hours

  local = in_zone(@quiet_hours.timezone, time) { |t| t.getlocal }
  minutes = local.hour * 60 + local.min
  start_min = minutes_of(@quiet_hours.start)
  end_min = minutes_of(@quiet_hours.end)
  if start_min <= end_min
    minutes >= start_min && minutes < end_min
  else
    # an overnight window: 21:30-09:00
    minutes >= start_min || minutes < end_min
  end
end

#to_hObject



95
96
97
98
99
100
101
102
103
104
105
# File 'lib/insika/followup_policy.rb', line 95

def to_h
  { "arm" => @arm,
    "policy" => {
      "quiet_hours" => @quiet_hours && {
        "timezone" => @quiet_hours.timezone, "start" => @quiet_hours.start, "end" => @quiet_hours.end
      },
      "max_frequency" => @max_frequency,
      "cancel_keywords" => @cancel_keywords,
      "silence_after_sends" => @silence_after_sends
    }.compact }
end