insika 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -5
  3. data/README.md +8 -2
  4. data/bin/insika +231 -13
  5. data/docs/AGENTS.md +505 -6
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +100 -10
  8. data/docs/CONTEXT.md +147 -19
  9. data/docs/DEPLOY.md +34 -11
  10. data/docs/EMBEDDING.md +11 -7
  11. data/docs/EVALS.md +20 -1
  12. data/docs/FACTS.md +135 -0
  13. data/docs/HARVEST.md +117 -0
  14. data/docs/LOADTEST.md +17 -10
  15. data/docs/OBSERVABILITY.md +65 -2
  16. data/docs/REFINEMENT.md +9 -9
  17. data/docs/RELEASING.md +34 -7
  18. data/docs/RUNNING-LOCAL.md +4 -4
  19. data/docs/SECURITY.md +85 -11
  20. data/docs/SKILLS.md +189 -3
  21. data/docs/SOAK.md +127 -0
  22. data/docs/TOOLS.md +70 -2
  23. data/docs/WHY.md +1 -1
  24. data/docs/WORKFLOWS.md +2 -2
  25. data/docs/domain.md +115 -0
  26. data/docs/index.md +2 -2
  27. data/docs/onboarding/start.md +1 -1
  28. data/lib/insika/agent_profile.rb +228 -26
  29. data/lib/insika/alert_dispatcher.rb +139 -0
  30. data/lib/insika/balloon_splitter.rb +102 -0
  31. data/lib/insika/baseline_store.rb +2 -2
  32. data/lib/insika/budget_ledger.rb +166 -0
  33. data/lib/insika/cache_series_store.rb +49 -0
  34. data/lib/insika/channel_delivery.rb +132 -24
  35. data/lib/insika/channel_registry.rb +1 -1
  36. data/lib/insika/channels/relay.rb +80 -6
  37. data/lib/insika/channels/web/widget.js +2 -2
  38. data/lib/insika/channels/web.rb +9 -9
  39. data/lib/insika/channels/webhook.rb +58 -0
  40. data/lib/insika/chat_builder.rb +145 -13
  41. data/lib/insika/checkpoint_store.rb +16 -0
  42. data/lib/insika/circuit_state.rb +114 -0
  43. data/lib/insika/coercion.rb +8 -0
  44. data/lib/insika/commands/agent_payload.rb +6 -4
  45. data/lib/insika/commands/cancel_followup.rb +49 -0
  46. data/lib/insika/commands/create_agent.rb +2 -2
  47. data/lib/insika/commands/create_session.rb +1 -1
  48. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  49. data/lib/insika/commands/delete_skill.rb +43 -0
  50. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  51. data/lib/insika/commands/export_customer_memory.rb +48 -0
  52. data/lib/insika/commands/forget_customer.rb +117 -0
  53. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  54. data/lib/insika/commands/gate_harvest.rb +138 -0
  55. data/lib/insika/commands/gate_refinement.rb +12 -12
  56. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  57. data/lib/insika/commands/import_tools.rb +4 -4
  58. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  59. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  60. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  61. data/lib/insika/commands/memory_put_fact.rb +23 -4
  62. data/lib/insika/commands/promote_harvest.rb +130 -0
  63. data/lib/insika/commands/record_outcome.rb +46 -0
  64. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  65. data/lib/insika/commands/reject_harvest.rb +38 -0
  66. data/lib/insika/commands/resolve_proposal.rb +108 -0
  67. data/lib/insika/commands/resolve_refinement.rb +1 -1
  68. data/lib/insika/commands/revoke_contact.rb +49 -0
  69. data/lib/insika/commands/revoke_token.rb +39 -0
  70. data/lib/insika/commands/rollback_harvest.rb +86 -0
  71. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  72. data/lib/insika/commands/run_distillation.rb +186 -0
  73. data/lib/insika/commands/run_harvest.rb +393 -0
  74. data/lib/insika/commands/run_refinement.rb +5 -5
  75. data/lib/insika/commands/send_message.rb +112 -15
  76. data/lib/insika/commands/session_purge.rb +67 -0
  77. data/lib/insika/commands/set_agent_tools.rb +1 -1
  78. data/lib/insika/commands/set_skill_agents.rb +60 -19
  79. data/lib/insika/commands/trigger_workflow.rb +1 -1
  80. data/lib/insika/commands/update_agent.rb +1 -1
  81. data/lib/insika/commands/write_data_tool.rb +1 -1
  82. data/lib/insika/commands/write_golden.rb +1 -1
  83. data/lib/insika/commands/write_skill.rb +19 -9
  84. data/lib/insika/config_store.rb +8 -4
  85. data/lib/insika/contact_store.rb +183 -0
  86. data/lib/insika/context/builder.rb +23 -5
  87. data/lib/insika/context/fragment.rb +31 -3
  88. data/lib/insika/context/priority.rb +6 -2
  89. data/lib/insika/context/provider.rb +17 -3
  90. data/lib/insika/context/providers/briefing.rb +96 -0
  91. data/lib/insika/context/providers/memory.rb +16 -7
  92. data/lib/insika/context/providers/prompt.rb +30 -2
  93. data/lib/insika/context/providers/request.rb +1 -1
  94. data/lib/insika/context/providers/session.rb +17 -2
  95. data/lib/insika/context/providers/skill.rb +7 -1
  96. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  97. data/lib/insika/context/providers/tool_search.rb +2 -0
  98. data/lib/insika/context_trace_store.rb +128 -0
  99. data/lib/insika/delegation_store.rb +2 -2
  100. data/lib/insika/distill.rb +224 -0
  101. data/lib/insika/distill_engine.rb +169 -0
  102. data/lib/insika/doctor.rb +962 -7
  103. data/lib/insika/dsl/runtime.rb +20 -11
  104. data/lib/insika/dsl/server_boot.rb +74 -4
  105. data/lib/insika/dsl/system.rb +1 -1
  106. data/lib/insika/dsl.rb +152 -15
  107. data/lib/insika/edge_limiter.rb +167 -8
  108. data/lib/insika/egress_guard.rb +3 -3
  109. data/lib/insika/env_schema.rb +22 -12
  110. data/lib/insika/errors.rb +72 -5
  111. data/lib/insika/evals/assertions.rb +15 -14
  112. data/lib/insika/evals/baseline.rb +3 -3
  113. data/lib/insika/evals/golden.rb +8 -8
  114. data/lib/insika/evals/judge.rb +7 -7
  115. data/lib/insika/evals/pairwise.rb +21 -9
  116. data/lib/insika/evals/report.rb +2 -2
  117. data/lib/insika/evals/runner.rb +6 -6
  118. data/lib/insika/evals/transport.rb +2 -2
  119. data/lib/insika/event_stream.rb +23 -5
  120. data/lib/insika/evidence.rb +183 -0
  121. data/lib/insika/executor.rb +1092 -160
  122. data/lib/insika/followup_engine.rb +207 -0
  123. data/lib/insika/followup_policy.rb +221 -0
  124. data/lib/insika/followup_store.rb +306 -0
  125. data/lib/insika/frontmatter.rb +1 -1
  126. data/lib/insika/funnel_declaration.rb +106 -0
  127. data/lib/insika/funnel_fold.rb +179 -0
  128. data/lib/insika/funnel_store.rb +163 -0
  129. data/lib/insika/golden_store.rb +3 -3
  130. data/lib/insika/grounding/matcher.rb +69 -0
  131. data/lib/insika/grounding.rb +44 -0
  132. data/lib/insika/harvest/conversion_gate.rb +159 -0
  133. data/lib/insika/harvest/criterion.rb +98 -0
  134. data/lib/insika/harvest/gate.rb +194 -0
  135. data/lib/insika/harvest/negative_list.rb +199 -0
  136. data/lib/insika/harvest.rb +241 -0
  137. data/lib/insika/harvest_engine.rb +193 -0
  138. data/lib/insika/harvest_store.rb +548 -0
  139. data/lib/insika/http_client.rb +3 -3
  140. data/lib/insika/inbound_log.rb +1 -1
  141. data/lib/insika/llm_configurator.rb +3 -3
  142. data/lib/insika/loop_detector.rb +143 -0
  143. data/lib/insika/mcp_http_client.rb +4 -4
  144. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  145. data/lib/insika/media.rb +298 -0
  146. data/lib/insika/memory_audit_store.rb +85 -0
  147. data/lib/insika/memory_store.rb +264 -23
  148. data/lib/insika/message_origin.rb +8 -3
  149. data/lib/insika/model_resolver.rb +1 -1
  150. data/lib/insika/model_selection.rb +5 -4
  151. data/lib/insika/model_visible.rb +87 -0
  152. data/lib/insika/model_visible_trace_store.rb +66 -0
  153. data/lib/insika/onboarding.rb +8 -3
  154. data/lib/insika/outbox_store.rb +44 -6
  155. data/lib/insika/outcome_store.rb +147 -0
  156. data/lib/insika/overlay_tool_registry.rb +3 -4
  157. data/lib/insika/pack.rb +3 -3
  158. data/lib/insika/pack_importer.rb +17 -15
  159. data/lib/insika/packaging.rb +163 -0
  160. data/lib/insika/parity/criterion.rb +79 -0
  161. data/lib/insika/parity/verdict.rb +318 -0
  162. data/lib/insika/pending_action_store.rb +1 -1
  163. data/lib/insika/plugin/loader.rb +2 -2
  164. data/lib/insika/policy/policy.rb +1 -1
  165. data/lib/insika/prefix_fingerprint.rb +58 -0
  166. data/lib/insika/profile_source.rb +34 -7
  167. data/lib/insika/proposal_store.rb +271 -0
  168. data/lib/insika/provider_error_classifier.rb +160 -0
  169. data/lib/insika/queue_policy.rb +6 -3
  170. data/lib/insika/recovery.rb +47 -6
  171. data/lib/insika/refinement/candidate.rb +4 -4
  172. data/lib/insika/refinement/evidence_collector.rb +6 -6
  173. data/lib/insika/refinement/gate.rb +7 -7
  174. data/lib/insika/refinement/panel.rb +7 -7
  175. data/lib/insika/refinement/proposer.rb +10 -10
  176. data/lib/insika/refinement_store.rb +12 -12
  177. data/lib/insika/reliability.rb +211 -0
  178. data/lib/insika/retention.rb +281 -0
  179. data/lib/insika/routing.rb +101 -0
  180. data/lib/insika/safety/config.rb +46 -6
  181. data/lib/insika/safety/corpus.rb +255 -0
  182. data/lib/insika/safety/detectors.rb +34 -115
  183. data/lib/insika/safety/factory.rb +18 -5
  184. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  185. data/lib/insika/safety/grounding_validator.rb +49 -0
  186. data/lib/insika/safety/input_guardrail.rb +20 -5
  187. data/lib/insika/safety/moderator.rb +19 -11
  188. data/lib/insika/safety/output_filter.rb +10 -6
  189. data/lib/insika/safety/output_validator.rb +13 -7
  190. data/lib/insika/safety/safe_responses.rb +1 -1
  191. data/lib/insika/sandbox/boundary.rb +2 -2
  192. data/lib/insika/sandbox.rb +1 -1
  193. data/lib/insika/schema_guard.rb +35 -0
  194. data/lib/insika/server/app.rb +366 -54
  195. data/lib/insika/server/boot.rb +4 -4
  196. data/lib/insika/server/rack_app.rb +31 -7
  197. data/lib/insika/server/responses.rb +58 -9
  198. data/lib/insika/server/tenant_auth.rb +61 -0
  199. data/lib/insika/session_actor.rb +11 -7
  200. data/lib/insika/session_store.rb +66 -3
  201. data/lib/insika/settings_store.rb +15 -5
  202. data/lib/insika/shadow_pair_store.rb +258 -0
  203. data/lib/insika/shutdown.rb +4 -4
  204. data/lib/insika/skill_catalog.rb +131 -20
  205. data/lib/insika/skill_store.rb +70 -22
  206. data/lib/insika/soak/envelope.rb +140 -0
  207. data/lib/insika/soak/report.rb +392 -0
  208. data/lib/insika/soak/runner.rb +554 -0
  209. data/lib/insika/steer_injector.rb +1 -1
  210. data/lib/insika/store.rb +11 -2
  211. data/lib/insika/stores/memory.rb +6 -0
  212. data/lib/insika/stores/sqlite.rb +8 -0
  213. data/lib/insika/studio/app.rb +1058 -75
  214. data/lib/insika/studio/assets/dist/application.css +1 -1
  215. data/lib/insika/studio/assets/dist/application.js +27 -26
  216. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  217. data/lib/insika/studio/forms.rb +274 -22
  218. data/lib/insika/studio/nav_icons.rb +7 -2
  219. data/lib/insika/studio/views/_message.erb +2 -2
  220. data/lib/insika/studio/views/agent_detail.erb +629 -86
  221. data/lib/insika/studio/views/agents.erb +11 -7
  222. data/lib/insika/studio/views/approvals.erb +4 -1
  223. data/lib/insika/studio/views/chats.erb +4 -1
  224. data/lib/insika/studio/views/customer.erb +94 -0
  225. data/lib/insika/studio/views/customers.erb +32 -0
  226. data/lib/insika/studio/views/evals.erb +4 -1
  227. data/lib/insika/studio/views/facts.erb +133 -0
  228. data/lib/insika/studio/views/followups.erb +125 -0
  229. data/lib/insika/studio/views/funnel.erb +106 -0
  230. data/lib/insika/studio/views/harvest.erb +234 -0
  231. data/lib/insika/studio/views/home.erb +2 -1
  232. data/lib/insika/studio/views/layout.erb +1 -0
  233. data/lib/insika/studio/views/parity.erb +147 -0
  234. data/lib/insika/studio/views/playground.erb +7 -1
  235. data/lib/insika/studio/views/refinement.erb +4 -4
  236. data/lib/insika/studio/views/session.erb +133 -3
  237. data/lib/insika/studio/views/settings.erb +9 -12
  238. data/lib/insika/studio/views/skills.erb +66 -12
  239. data/lib/insika/studio/views/system_files.erb +1 -1
  240. data/lib/insika/studio/views/task.erb +13 -0
  241. data/lib/insika/studio/views/tasks.erb +4 -1
  242. data/lib/insika/studio/views/tools.erb +0 -1
  243. data/lib/insika/subagent_graph.rb +3 -3
  244. data/lib/insika/task_actor.rb +3 -3
  245. data/lib/insika/task_store.rb +22 -2
  246. data/lib/insika/telemetry/pricing.rb +3 -3
  247. data/lib/insika/telemetry/recorder.rb +1 -1
  248. data/lib/insika/telemetry.rb +2 -2
  249. data/lib/insika/testing/store_contract.rb +54 -33
  250. data/lib/insika/tick.rb +146 -0
  251. data/lib/insika/token_store.rb +168 -0
  252. data/lib/insika/tool_assembly.rb +5 -5
  253. data/lib/insika/tool_definition.rb +25 -15
  254. data/lib/insika/tool_envelope.rb +70 -1
  255. data/lib/insika/tool_manifest.rb +11 -7
  256. data/lib/insika/tool_output_compressor.rb +100 -0
  257. data/lib/insika/tool_store.rb +1 -1
  258. data/lib/insika/tool_trace_store.rb +1 -1
  259. data/lib/insika/tools/concurrency.rb +2 -2
  260. data/lib/insika/tools/data_defined_tool.rb +14 -5
  261. data/lib/insika/tools/generate_image.rb +44 -0
  262. data/lib/insika/tools/load_skill.rb +61 -3
  263. data/lib/insika/tools/schedule_followup.rb +164 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +4 -4
  266. data/lib/insika/tools/subagents.rb +1 -1
  267. data/lib/insika/tools/tts.rb +47 -0
  268. data/lib/insika/tools/update_briefing.rb +126 -0
  269. data/lib/insika/turn_output.rb +2 -2
  270. data/lib/insika/turn_state.rb +54 -13
  271. data/lib/insika/turn_timing.rb +24 -4
  272. data/lib/insika/usage_ledger.rb +1 -1
  273. data/lib/insika/version.rb +1 -1
  274. data/lib/insika/vitals.rb +84 -0
  275. data/lib/insika/wiring/graph.rb +372 -34
  276. data/lib/insika/workflow.rb +1 -1
  277. data/lib/insika/workflow_registry.rb +1 -1
  278. data/lib/insika.rb +122 -16
  279. metadata +95 -2
  280. data/lib/insika/server/admin_auth.rb +0 -29
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # the tick-driven FIRER of follow-up records: one
7
+ # pass per claim window; each record is its own claim (pending -> fired + task
8
+ # creation + the contact bump in ONE transaction — D5). Gating order (D6):
9
+ #
10
+ # policy valid? -> contact state (never-granted / revoked / unavailable ->
11
+ # block) -> quiet hours? (pending — DEFER) -> dedup guard (block) ->
12
+ # frequency ceiling (block) -> FIRE.
13
+ #
14
+ # Blocking is at fire time, never at schedule time; a `blocked` record is
15
+ # auditable, never silent. The engine never modifies a policy and never
16
+ # invents a reason — the turn it creates is delivered entirely by the existing
17
+ # pipeline (it holds no channel code).
18
+ class FollowupEngine
19
+ SCOPE = "followup_fire"
20
+ KEY = "claim"
21
+ DEFAULT_WINDOW = 300 # seconds; one firing worker per window
22
+
23
+ # The engine's kick text. The agent composes the customer-visible message
24
+ # itself — the engine never writes a word the customer reads. %{reason} and
25
+ # %{now} (UTC) are empty-escaped.
26
+ FIRING_PROMPT = "You are following up on a previous conversation, as " \
27
+ "scheduled and with the customer's consent. The follow-up " \
28
+ "reason: %{reason}. It is now %{now} (UTC). Write ONE short " \
29
+ "follow-up message to the customer now, referencing the " \
30
+ "earlier conversation."
31
+
32
+ def initialize(store:, followup_store:, contact_store:, task_store:,
33
+ profiles:, executor:, window: DEFAULT_WINDOW,
34
+ now: nil, logger: nil, event_stream: nil)
35
+ @store = store
36
+ @followup_store = followup_store
37
+ @contact_store = contact_store
38
+ @task_store = task_store
39
+ @profiles = profiles
40
+ @executor = executor
41
+ @window = window
42
+ @now = now
43
+ @logger = logger
44
+ @event_stream = event_stream
45
+ end
46
+
47
+ # -> { claimed: false }
48
+ # | { claimed: true, fired: N, blocked: N, errors: N,
49
+ # blocked_reasons: { "rule" => N }, deferred: N }
50
+ # A StoreError on ONE record aborts THAT record's transaction (rescued,
51
+ # counted, the loop continues) — a broken record must not hold the other
52
+ # stores' follow-ups hostage.
53
+ def run
54
+ now_time = @now || Time.now.utc
55
+ return { claimed: false } unless claim_window(now_time)
56
+
57
+ fired = 0
58
+ blocked = 0
59
+ deferred = 0
60
+ errors = 0
61
+ reasons = Hash.new(0)
62
+
63
+ # D7 at fire time: when more than one PENDING record holds the same
64
+ # (customer, reason), only the OLDEST fires. The verdict is snapshotted
65
+ # at pass start — a record that fires earlier in THIS pass must still
66
+ # dedup the younger ones behind it.
67
+ dedup_snapshot = {}
68
+ due = @followup_store.due(now: now_time)
69
+ due.each do |record|
70
+ pair = [record.tenant, record.agent, record.customer, record.reason]
71
+ dedup_snapshot[pair] ||= @followup_store
72
+ .pending_for(tenant: record.tenant, agent: record.agent,
73
+ customer: record.customer, reason: record.reason)
74
+ &.id
75
+ end
76
+
77
+ due.each do |record|
78
+ begin
79
+ outcome = fire_record(record, now_time,
80
+ older_pending_id: dedup_snapshot[[record.tenant, record.agent,
81
+ record.customer, record.reason]])
82
+ case outcome
83
+ when :fired then fired += 1
84
+ when :deferred then deferred += 1
85
+ when Array
86
+ # a blocked record is auditable, never silent — the failing rule is
87
+ # written down on the record itself (D6/D9).
88
+ @followup_store.block(id: record.id, reason: outcome[1], now: now_time)
89
+ blocked += 1
90
+ reasons[outcome[1].to_s] += 1
91
+ end
92
+ rescue StandardError
93
+ # a broken record must not hold the other records' follow-ups
94
+ # hostage — its own transaction already rolled back.
95
+ errors += 1
96
+ end
97
+ end
98
+
99
+ { claimed: true, fired: fired, blocked: blocked, errors: errors,
100
+ blocked_reasons: reasons, deferred: deferred }
101
+ end
102
+
103
+ private
104
+
105
+ # -> :fired | :deferred | [:blocked, rule]
106
+ def fire_record(record, now_time, older_pending_id: nil)
107
+ profile = @profiles.fetch(record.agent)
108
+ policy = profile && Insika::FollowupPolicy.parse(profile.respond_to?(:followup) ? profile.followup : nil)
109
+ # D9: a malformed policy (or a missing profile) is a BLOCK, never a crash
110
+ # and never a silent fire.
111
+ return [:blocked, :policy_invalid] if policy.nil?
112
+
113
+ contact = @contact_store.get(tenant: record.tenant, customer: record.customer)
114
+ return [:blocked, :consent] if contact.nil? # never messaged (D2)
115
+ return [:blocked, :revoked] if contact.state == "revoked"
116
+ return [:blocked, :unavailable] if contact.state == "unavailable"
117
+
118
+ # quiet hours keep the record PENDING — that IS the deferral; the next
119
+ # pass retries it (never a terminal block).
120
+ return :deferred if policy.quiet?(now_time)
121
+
122
+ # D7 at fire time: only the OLDEST pending per (customer, reason) fires;
123
+ # a younger pending of the same pair blocks (the snapshot taken at pass
124
+ # start, so a same-pass fire still dedups the younger ones).
125
+ if older_pending_id && older_pending_id != record.id
126
+ return [:blocked, :dedup]
127
+ end
128
+
129
+ # frequency: fired sends inside the window already at the ceiling -> block
130
+ if (window = policy.frequency_window) && window[:count].positive?
131
+ sent = @followup_store.fired_in_window(tenant: record.tenant, customer: record.customer,
132
+ since: now_time - window[:seconds])
133
+ return [:blocked, :frequency] if sent >= window[:count]
134
+ end
135
+
136
+ fire(record, policy, now_time)
137
+ :fired
138
+ end
139
+
140
+ # The atomic claim (D5): the record and the turn commit together or
141
+ # together fail. Within ONE transaction: create the synthetic task, flip
142
+ # the record to fired (with the task id), bump the contact counter — and
143
+ # mark :unavailable when the bump reaches the policy's silence ceiling.
144
+ def fire(record, policy, now_time)
145
+ message = format(FIRING_PROMPT, reason: record.reason.to_s, now: now_time.utc.iso8601)
146
+ command = { "type" => "scheduled_followup",
147
+ "session_id" => record.session_id,
148
+ "payload" => { "agent" => record.agent, "session_id" => record.session_id,
149
+ "customer" => record.customer, "message" => message,
150
+ "origin" => Insika::MessageOrigin::SCHEDULED },
151
+ "meta" => { "tenant" => record.tenant, "transport" => record.transport } }
152
+
153
+ @store.transaction do
154
+ task = @task_store.create(command: command, session_id: record.session_id)
155
+ @followup_store.transition_fired(id: record.id, task_id: task.id, now: now_time)
156
+ cell = @contact_store.bump_outbound(tenant: record.tenant, customer: record.customer,
157
+ now: now_time)
158
+ if cell.sends_without_reply >= policy.silence_after_sends
159
+ @contact_store.mark_unavailable(tenant: record.tenant, customer: record.customer,
160
+ now: now_time)
161
+ end
162
+ end
163
+
164
+ # AFTER the commit: spawn. A spawn failure leaves a durable :queued task
165
+ # — the recovery sweep resumes it (a queued task is claimed once).
166
+ task_id = @followup_store.find(record.id).task_id
167
+ task = @task_store.find(task_id)
168
+ profile = @profiles.fetch(record.agent)
169
+ @executor.spawn_in_session(task, profile: profile)
170
+ emit_fired(record, task_id)
171
+ task_id
172
+ end
173
+
174
+ # :followup_fired carries ids only — a follow-up record's reason is contact
175
+ # data that rides the record and the Studio, never the event stream.
176
+ def emit_fired(record, task_id)
177
+ return unless @event_stream
178
+
179
+ @event_stream.emit(Insika::Event.new(
180
+ type: :followup_fired,
181
+ data: { id: record.id, agent: record.agent, customer: record.customer,
182
+ task_id: task_id, arm: record.arm },
183
+ meta: { tenant: record.tenant, at: Time.now.utc.iso8601 }
184
+ ))
185
+ end
186
+
187
+ # The claim window (funnel_fold.rb's idiom — read-check-write on one key
188
+ # inside a transaction): the O(n) scan never rides the 60 s tick, and two
189
+ # workers racing serialize on the backend's lock.
190
+ def claim_window(now_time)
191
+ @store.transaction do
192
+ current = @store.get(SCOPE, KEY)
193
+ last = current && begin
194
+ Time.iso8601(current["claimed_at"].to_s)
195
+ rescue ArgumentError
196
+ nil # a corrupted claim is not a claim — take the window
197
+ end
198
+ if last.nil? || (now_time - last) >= @window
199
+ @store.set(SCOPE, KEY, { "claimed_at" => now_time.iso8601 })
200
+ true
201
+ else
202
+ false
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # the parsed follow-up policy of ONE agent — the ONLY shape the
5
+ # engine accepts, shared by the schedule/cancel tools, the FollowupEngine,
6
+ # the doctor and the Studio. Pure value object: it never touches a store
7
+ # (D8 — the policy is pack data on the profile, no platform settings layer).
8
+ #
9
+ # `parse` returns nil on a malformed hash (D9 — the firer BLOCKS, the doctor
10
+ # explains, the tools refuse); `parse!` raises Insika::ValidationError naming
11
+ # the exact defect.
12
+ class FollowupPolicy
13
+ QuietHours = Data.define(:timezone, :start, :end)
14
+
15
+ FREQUENCY_RE = /\A(\d+)\/(\d+)(m|h|d)s?\z/
16
+ HH_MM_RE = /\A\d{2}:\d{2}\z/
17
+ DEFAULT_ARM = "schedule"
18
+ DEFAULT_SILENCE_AFTER_SENDS = 3
19
+ KEYWORD_MAX = 200
20
+
21
+ attr_reader :arm, :quiet_hours, :max_frequency, :cancel_keywords, :silence_after_sends
22
+
23
+ def self.parse(hash)
24
+ new(hash)
25
+ rescue Insika::ValidationError
26
+ nil
27
+ end
28
+
29
+ def self.parse!(hash)
30
+ new(hash)
31
+ end
32
+
33
+ def initialize(hash)
34
+ raise Insika::ValidationError, "followup: declaration must be a Hash" unless hash.is_a?(Hash)
35
+
36
+ h = hash.transform_keys(&:to_s)
37
+ @arm = arm_of(h)
38
+ policy = h["policy"]
39
+ raise Insika::ValidationError, "followup.policy must be a Hash" unless policy.is_a?(Hash)
40
+
41
+ policy = policy.transform_keys(&:to_s)
42
+ @quiet_hours = quiet_hours_of(policy)
43
+ @max_frequency = frequency_of(policy)
44
+ @cancel_keywords = keywords_of(policy)
45
+ @silence_after_sends = silence_of(policy)
46
+ freeze
47
+ end
48
+
49
+ # "2/24h" -> { count: 2, seconds: 86_400 }. nil when the policy has no
50
+ # ceiling (absent max_frequency — the frequency gate is off). Always valid
51
+ # once parsed.
52
+ # `count` is the sends allowed per window; `seconds` is the WINDOW's
53
+ # duration (the value after the "/"), never count*window.
54
+ def frequency_window
55
+ return nil if @max_frequency.nil?
56
+
57
+ match = FREQUENCY_RE.match(@max_frequency)
58
+ multiplier = { "m" => 60, "h" => 3600, "d" => 86_400 }.fetch(match[3])
59
+ { count: match[1].to_i, seconds: match[2].to_i * multiplier }
60
+ end
61
+
62
+ # Is the given UTC Time inside quiet hours in the policy's timezone? (nil
63
+ # quiet_hours -> false: no quiet window.)
64
+ #
65
+ # IANA names are resolved through the OS tz database by pointing Ruby's
66
+ # `TZ` at the zone for the computation (Ruby stdlib's `Time#getlocal`
67
+ # only takes an offset, not a zone name). Save/restore keeps the global
68
+ # intact; under the engine's cooperative fiber model — no IO between the
69
+ # save and the restore — the mutation is atomic on the calling fiber.
70
+ def quiet?(time)
71
+ return false unless @quiet_hours
72
+
73
+ local = in_zone(@quiet_hours.timezone, time) { |t| t.getlocal }
74
+ minutes = local.hour * 60 + local.min
75
+ start_min = minutes_of(@quiet_hours.start)
76
+ end_min = minutes_of(@quiet_hours.end)
77
+ if start_min <= end_min
78
+ minutes >= start_min && minutes < end_min
79
+ else
80
+ # an overnight window: 21:30-09:00
81
+ minutes >= start_min || minutes < end_min
82
+ end
83
+ end
84
+
85
+ # The first cancel keyword matched (case/accent-insensitive substring);
86
+ # nil when none matches.
87
+ def match_keyword(text)
88
+ folded = fold(text)
89
+ @cancel_keywords.each do |kw|
90
+ return kw if folded.include?(fold(kw))
91
+ end
92
+ nil
93
+ end
94
+
95
+ def to_h
96
+ { "arm" => @arm,
97
+ "policy" => {
98
+ "quiet_hours" => @quiet_hours && {
99
+ "timezone" => @quiet_hours.timezone, "start" => @quiet_hours.start, "end" => @quiet_hours.end
100
+ },
101
+ "max_frequency" => @max_frequency,
102
+ "cancel_keywords" => @cancel_keywords,
103
+ "silence_after_sends" => @silence_after_sends
104
+ }.compact }
105
+ end
106
+
107
+ private
108
+
109
+ def arm_of(hash)
110
+ arm = hash["arm"]
111
+ return DEFAULT_ARM if arm.nil?
112
+
113
+ arm = arm.to_s
114
+ raise Insika::ValidationError, "followup.arm must be a non-blank String" if arm.strip.empty?
115
+
116
+ arm
117
+ end
118
+
119
+ def quiet_hours_of(policy)
120
+ qh = policy["quiet_hours"]
121
+ return nil if qh.nil?
122
+
123
+ raise Insika::ValidationError, "followup.policy.quiet_hours must be a Hash" unless qh.is_a?(Hash)
124
+
125
+ qh = qh.transform_keys(&:to_s)
126
+ timezone = qh["timezone"]
127
+ raise Insika::ValidationError, "followup.policy.quiet_hours.timezone is required" if Coercion.blank?(timezone)
128
+
129
+ start_at = qh["start"]
130
+ end_at = qh["end"]
131
+ unless HH_MM_RE.match?(start_at.to_s) && HH_MM_RE.match?(end_at.to_s)
132
+ raise Insika::ValidationError,
133
+ "followup.policy.quiet_hours.start/end must match /\\A\\d{2}:\\d{2}\\z/ (24h)"
134
+ end
135
+
136
+ # a bogus IANA zone is a malformed policy — refused HERE, where the
137
+ # doctor can name it. An unknown ENV["TZ"] silently behaves as UTC, so
138
+ # existence is checked against the OS tz database, not by asking Time.
139
+ unless zone_known?(timezone.to_s)
140
+ raise Insika::ValidationError,
141
+ "followup.policy.quiet_hours.timezone is not a valid IANA timezone: #{timezone.inspect}"
142
+ end
143
+ QuietHours.new(timezone: timezone.to_s, start: start_at.to_s, end: end_at.to_s)
144
+ end
145
+
146
+ def frequency_of(policy)
147
+ f = policy["max_frequency"]
148
+ return nil if f.nil?
149
+
150
+ f = f.to_s
151
+ unless FREQUENCY_RE.match?(f)
152
+ raise Insika::ValidationError,
153
+ "followup.policy.max_frequency must match /\\A\\d+\\/(\\d+)(m|h|d)s?\\z/ (e.g. \"2/24h\"; weeks are not allowed)"
154
+ end
155
+
156
+ f
157
+ end
158
+
159
+ def keywords_of(policy)
160
+ list = policy["cancel_keywords"]
161
+ return [] if list.nil?
162
+
163
+ raise Insika::ValidationError, "followup.policy.cancel_keywords must be an Array" unless list.is_a?(Array)
164
+
165
+ list.map!(&:to_s)
166
+ list.each do |kw|
167
+ if Coercion.blank?(kw)
168
+ raise Insika::ValidationError, "followup.policy.cancel_keywords: each keyword must be non-blank"
169
+ end
170
+ if kw.length > KEYWORD_MAX
171
+ raise Insika::ValidationError,
172
+ "followup.policy.cancel_keywords: each keyword must be <= #{KEYWORD_MAX} chars"
173
+ end
174
+ end
175
+ list
176
+ end
177
+
178
+ def silence_of(policy)
179
+ s = policy["silence_after_sends"]
180
+ return DEFAULT_SILENCE_AFTER_SENDS if s.nil?
181
+
182
+ raise Insika::ValidationError, "followup.policy.silence_after_sends must be an Integer > 0" unless s.is_a?(Integer) && s.positive?
183
+
184
+ s
185
+ end
186
+
187
+ # NFD + strip combining marks + downcase: the same fold the doctor's
188
+ # identity matcher uses, so "NÃO" matches "não".
189
+ def fold(text)
190
+ text.to_s.unicode_normalize(:nfd).gsub(/\p{Mn}/, "").downcase
191
+ end
192
+
193
+ # Yields `time` interpreted in the given IANA zone (via a save/restore of
194
+ # ENV["TZ"] — the stdlib-only route to the OS tz database; see #quiet?).
195
+ def in_zone(zone, time)
196
+ previous = ENV["TZ"]
197
+ ENV["TZ"] = zone
198
+ yield time
199
+ ensure
200
+ ENV["TZ"] = previous
201
+ end
202
+
203
+ # The candidate tz-data roots (TZDIR first — Ruby's own lookup env). The
204
+ # zone name maps to a FILE under the root ("America/Sao_Paulo" ->
205
+ # "America/Sao_Paulo").
206
+ TZ_ROOTS = ([ENV["TZDIR"]] +
207
+ %w[/usr/share/zoneinfo /usr/share/lib/zoneinfo /etc/zoneinfo])
208
+ .compact.freeze
209
+
210
+ def zone_known?(zone)
211
+ return true if zone == "UTC" || zone == "Etc/UTC"
212
+
213
+ TZ_ROOTS.any? { |root| File.directory?(root) && File.exist?(File.join(root, zone)) }
214
+ end
215
+
216
+ def minutes_of(hhmm)
217
+ h, m = hhmm.split(":").map(&:to_i)
218
+ h * 60 + m
219
+ end
220
+ end
221
+ end