insika 0.2.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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +145 -17
  3. data/README.md +7 -1
  4. data/bin/insika +211 -8
  5. data/docs/AGENTS.md +377 -2
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +99 -9
  8. data/docs/CONTEXT.md +127 -16
  9. data/docs/DEPLOY.md +5 -2
  10. data/docs/EVALS.md +19 -0
  11. data/docs/FACTS.md +135 -0
  12. data/docs/HARVEST.md +117 -0
  13. data/docs/LOADTEST.md +14 -8
  14. data/docs/OBSERVABILITY.md +54 -0
  15. data/docs/REFINEMENT.md +3 -3
  16. data/docs/RELEASING.md +27 -0
  17. data/docs/RUNNING-LOCAL.md +3 -3
  18. data/docs/SECURITY.md +61 -0
  19. data/docs/SKILLS.md +2 -2
  20. data/docs/SOAK.md +127 -0
  21. data/docs/TOOLS.md +70 -2
  22. data/docs/domain.md +115 -0
  23. data/docs/index.md +1 -1
  24. data/lib/insika/agent_profile.rb +145 -10
  25. data/lib/insika/balloon_splitter.rb +102 -0
  26. data/lib/insika/budget_ledger.rb +34 -3
  27. data/lib/insika/cache_series_store.rb +49 -0
  28. data/lib/insika/channel_delivery.rb +118 -13
  29. data/lib/insika/channels/relay.rb +77 -3
  30. data/lib/insika/channels/web.rb +2 -2
  31. data/lib/insika/chat_builder.rb +84 -1
  32. data/lib/insika/checkpoint_store.rb +16 -0
  33. data/lib/insika/commands/agent_payload.rb +3 -3
  34. data/lib/insika/commands/cancel_followup.rb +49 -0
  35. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  36. data/lib/insika/commands/export_customer_memory.rb +48 -0
  37. data/lib/insika/commands/forget_customer.rb +117 -0
  38. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  39. data/lib/insika/commands/gate_harvest.rb +138 -0
  40. data/lib/insika/commands/gate_refinement.rb +1 -1
  41. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  42. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  43. data/lib/insika/commands/memory_put_fact.rb +23 -4
  44. data/lib/insika/commands/promote_harvest.rb +130 -0
  45. data/lib/insika/commands/record_outcome.rb +46 -0
  46. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  47. data/lib/insika/commands/reject_harvest.rb +38 -0
  48. data/lib/insika/commands/resolve_proposal.rb +108 -0
  49. data/lib/insika/commands/revoke_contact.rb +49 -0
  50. data/lib/insika/commands/rollback_harvest.rb +86 -0
  51. data/lib/insika/commands/run_distillation.rb +186 -0
  52. data/lib/insika/commands/run_harvest.rb +393 -0
  53. data/lib/insika/commands/send_message.rb +103 -6
  54. data/lib/insika/commands/session_purge.rb +67 -0
  55. data/lib/insika/contact_store.rb +183 -0
  56. data/lib/insika/context/builder.rb +21 -3
  57. data/lib/insika/context/fragment.rb +7 -3
  58. data/lib/insika/context/priority.rb +3 -0
  59. data/lib/insika/context/provider.rb +17 -3
  60. data/lib/insika/context/providers/briefing.rb +96 -0
  61. data/lib/insika/context/providers/memory.rb +16 -7
  62. data/lib/insika/context/providers/prompt.rb +30 -2
  63. data/lib/insika/context/providers/skill.rb +2 -0
  64. data/lib/insika/context/providers/tool_search.rb +2 -0
  65. data/lib/insika/context_trace_store.rb +38 -2
  66. data/lib/insika/distill.rb +224 -0
  67. data/lib/insika/distill_engine.rb +169 -0
  68. data/lib/insika/doctor.rb +716 -6
  69. data/lib/insika/dsl/runtime.rb +9 -3
  70. data/lib/insika/dsl/server_boot.rb +70 -1
  71. data/lib/insika/dsl.rb +83 -3
  72. data/lib/insika/edge_limiter.rb +28 -7
  73. data/lib/insika/env_schema.rb +9 -2
  74. data/lib/insika/errors.rb +11 -0
  75. data/lib/insika/evals/assertions.rb +3 -2
  76. data/lib/insika/evals/pairwise.rb +18 -6
  77. data/lib/insika/evidence.rb +183 -0
  78. data/lib/insika/executor.rb +687 -70
  79. data/lib/insika/followup_engine.rb +207 -0
  80. data/lib/insika/followup_policy.rb +221 -0
  81. data/lib/insika/followup_store.rb +306 -0
  82. data/lib/insika/funnel_declaration.rb +106 -0
  83. data/lib/insika/funnel_fold.rb +179 -0
  84. data/lib/insika/funnel_store.rb +163 -0
  85. data/lib/insika/golden_store.rb +1 -1
  86. data/lib/insika/grounding/matcher.rb +69 -0
  87. data/lib/insika/grounding.rb +44 -0
  88. data/lib/insika/harvest/conversion_gate.rb +159 -0
  89. data/lib/insika/harvest/criterion.rb +98 -0
  90. data/lib/insika/harvest/gate.rb +194 -0
  91. data/lib/insika/harvest/negative_list.rb +199 -0
  92. data/lib/insika/harvest.rb +241 -0
  93. data/lib/insika/harvest_engine.rb +193 -0
  94. data/lib/insika/harvest_store.rb +548 -0
  95. data/lib/insika/media.rb +298 -0
  96. data/lib/insika/memory_audit_store.rb +85 -0
  97. data/lib/insika/memory_store.rb +264 -23
  98. data/lib/insika/message_origin.rb +6 -1
  99. data/lib/insika/model_visible.rb +87 -0
  100. data/lib/insika/model_visible_trace_store.rb +66 -0
  101. data/lib/insika/onboarding.rb +6 -1
  102. data/lib/insika/outbox_store.rb +42 -4
  103. data/lib/insika/outcome_store.rb +147 -0
  104. data/lib/insika/packaging.rb +163 -0
  105. data/lib/insika/parity/criterion.rb +79 -0
  106. data/lib/insika/parity/verdict.rb +318 -0
  107. data/lib/insika/prefix_fingerprint.rb +58 -0
  108. data/lib/insika/profile_source.rb +22 -1
  109. data/lib/insika/proposal_store.rb +271 -0
  110. data/lib/insika/queue_policy.rb +4 -1
  111. data/lib/insika/refinement/proposer.rb +1 -1
  112. data/lib/insika/reliability.rb +32 -6
  113. data/lib/insika/retention.rb +281 -0
  114. data/lib/insika/routing.rb +101 -0
  115. data/lib/insika/safety/config.rb +44 -4
  116. data/lib/insika/safety/corpus.rb +255 -0
  117. data/lib/insika/safety/detectors.rb +32 -113
  118. data/lib/insika/safety/factory.rb +15 -2
  119. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  120. data/lib/insika/safety/grounding_validator.rb +49 -0
  121. data/lib/insika/safety/input_guardrail.rb +1 -1
  122. data/lib/insika/safety/moderator.rb +1 -1
  123. data/lib/insika/safety/output_filter.rb +9 -5
  124. data/lib/insika/safety/output_validator.rb +11 -5
  125. data/lib/insika/schema_guard.rb +35 -0
  126. data/lib/insika/server/app.rb +144 -4
  127. data/lib/insika/server/rack_app.rb +16 -0
  128. data/lib/insika/server/responses.rb +40 -1
  129. data/lib/insika/session_actor.rb +8 -4
  130. data/lib/insika/session_store.rb +65 -2
  131. data/lib/insika/settings_store.rb +10 -0
  132. data/lib/insika/shadow_pair_store.rb +258 -0
  133. data/lib/insika/skill_catalog.rb +4 -0
  134. data/lib/insika/soak/envelope.rb +140 -0
  135. data/lib/insika/soak/report.rb +392 -0
  136. data/lib/insika/soak/runner.rb +554 -0
  137. data/lib/insika/store.rb +10 -1
  138. data/lib/insika/stores/memory.rb +6 -0
  139. data/lib/insika/stores/sqlite.rb +8 -0
  140. data/lib/insika/studio/app.rb +875 -14
  141. data/lib/insika/studio/assets/dist/application.css +1 -1
  142. data/lib/insika/studio/assets/dist/application.js +22 -22
  143. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  144. data/lib/insika/studio/forms.rb +262 -5
  145. data/lib/insika/studio/nav_icons.rb +6 -1
  146. data/lib/insika/studio/views/agent_detail.erb +627 -84
  147. data/lib/insika/studio/views/agents.erb +10 -6
  148. data/lib/insika/studio/views/approvals.erb +4 -1
  149. data/lib/insika/studio/views/chats.erb +4 -1
  150. data/lib/insika/studio/views/customer.erb +94 -0
  151. data/lib/insika/studio/views/customers.erb +32 -0
  152. data/lib/insika/studio/views/evals.erb +4 -1
  153. data/lib/insika/studio/views/facts.erb +133 -0
  154. data/lib/insika/studio/views/followups.erb +125 -0
  155. data/lib/insika/studio/views/funnel.erb +106 -0
  156. data/lib/insika/studio/views/harvest.erb +234 -0
  157. data/lib/insika/studio/views/home.erb +2 -1
  158. data/lib/insika/studio/views/layout.erb +1 -0
  159. data/lib/insika/studio/views/parity.erb +147 -0
  160. data/lib/insika/studio/views/playground.erb +7 -1
  161. data/lib/insika/studio/views/session.erb +56 -1
  162. data/lib/insika/studio/views/settings.erb +2 -0
  163. data/lib/insika/studio/views/skills.erb +0 -1
  164. data/lib/insika/studio/views/system_files.erb +1 -1
  165. data/lib/insika/studio/views/task.erb +13 -0
  166. data/lib/insika/studio/views/tasks.erb +4 -1
  167. data/lib/insika/studio/views/tools.erb +0 -1
  168. data/lib/insika/task_store.rb +21 -1
  169. data/lib/insika/testing/store_contract.rb +27 -6
  170. data/lib/insika/tick.rb +25 -1
  171. data/lib/insika/tool_definition.rb +17 -7
  172. data/lib/insika/tool_envelope.rb +69 -0
  173. data/lib/insika/tool_manifest.rb +5 -1
  174. data/lib/insika/tools/data_defined_tool.rb +10 -0
  175. data/lib/insika/tools/generate_image.rb +44 -0
  176. data/lib/insika/tools/schedule_followup.rb +164 -0
  177. data/lib/insika/tools/tts.rb +47 -0
  178. data/lib/insika/tools/update_briefing.rb +126 -0
  179. data/lib/insika/turn_state.rb +38 -1
  180. data/lib/insika/turn_timing.rb +22 -2
  181. data/lib/insika/version.rb +1 -1
  182. data/lib/insika/vitals.rb +84 -0
  183. data/lib/insika/wiring/graph.rb +300 -13
  184. data/lib/insika.rb +92 -3
  185. metadata +77 -1
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ # the one place distillation asks a model for anything.
7
+ #
8
+ # The engine's generic prompt (what a durable customer fact is, the shape of
9
+ # the answer, and the "never invent, never guess a scope" rules). A pack
10
+ # `distill.prompt` REPLACES this wholesale (the forge's half) — the engine
11
+ # never writes store vocabulary.
12
+ module Distill
13
+ DEFAULT_PROMPT = <<~PROMPT.freeze
14
+ You are distilling durable facts about ONE customer from a finished
15
+ conversation, for a shop assistant's memory. A fact is something that
16
+ stays true across conversations: a size, a preference, a situation, an
17
+ address. It is NOT the conversation itself, not a summary, and not a
18
+ question.
19
+
20
+ Answer with a single JSON array and NOTHING else. No prose, no fences.
21
+
22
+ Rules:
23
+ - Each element is an object with "name" (the fact's key, short, lowercase,
24
+ underscore-separated), "value" (the fact's content, plain text), an
25
+ optional "confidence" (a number between 0 and 1) and an optional "turns"
26
+ (the message indexes in the transcript that support the fact).
27
+ - Never invent: only facts the conversation actually supports.
28
+ - Never guess a scope: you are not told any customer id or tenant — do not
29
+ include one, the engine stamps it.
30
+ - Fewer, better facts beat filling a quota.
31
+ PROMPT
32
+
33
+ # The SAFE-subset JSON Schema (object/array/string/number/integer + required
34
+ # + items — workflow.rb:77's subset). The validator is permissive on unknown
35
+ # keys, so the Distiller itself rejects a proposal carrying any key OUTSIDE
36
+ # this set (D1 — a model-authored `scope` is a cross-tenant escape) and
37
+ # counts the drop.
38
+ PROPOSAL_SCHEMA = Insika::Workflow::Schema.coerce({
39
+ "type" => "array",
40
+ "items" => {
41
+ "type" => "object",
42
+ "properties" => {
43
+ "name" => { "type" => "string" },
44
+ "value" => { "type" => "string" },
45
+ "confidence" => { "type" => "number" },
46
+ "turns" => { "type" => "array", "items" => { "type" => "integer" } }
47
+ },
48
+ "required" => ["name", "value"]
49
+ }
50
+ })
51
+
52
+ # The per-PROPOSAL half (an item of PROPOSAL_SCHEMA) — the Distiller
53
+ # validates each element against THIS, not the whole-answer schema.
54
+ ITEM_SCHEMA = Insika::Workflow::Schema.coerce(PROPOSAL_SCHEMA.json_schema["items"])
55
+
56
+ # The model's answer, filtered into data the command then dedups and a human
57
+ # then gates. Pure over an injected `ask` — the Refinement::Proposer shape:
58
+ # unit-testable without a provider, and the real ask is a lambda built by
59
+ # DistillerFactory (ruby_llm required lazily, load_guard stays green).
60
+ class Distiller
61
+ # A model that answers with prose instead of JSON produces NOTHING,
62
+ # loudly — empty output must not read as "the traffic is clean".
63
+ class Unusable < Insika::ValidationError; end
64
+
65
+ MAX_NAME_LENGTH = 120
66
+ MAX_VALUE_LENGTH = 500
67
+ MAX_TURNS = 20
68
+ ALLOWED_KEYS = %w[name value confidence turns].freeze
69
+ # The E4 audit counters: schema (shape/type/confidence range), unknown_key
70
+ # (a model-authored key — the D1 escape), oversized (length caps),
71
+ # bad_turns (evidence indexes), duplicate (exact repeats), capped (a
72
+ # survivor over max_proposals).
73
+ DROP_KEYS = %w[schema unknown_key oversized bad_turns duplicate capped].freeze
74
+
75
+ # ask: ->(prompt) { "<raw model text>" } | something answering #content
76
+ # (+ #input_tokens/#output_tokens/#cached_tokens for cost).
77
+ # model: the ref recorded in the events ("utility_model" default).
78
+ attr_reader :model
79
+
80
+ def initialize(ask:, model: "utility_model")
81
+ @ask = ask
82
+ @model = model.to_s
83
+ end
84
+
85
+ # -> { proposals: [{ "name", "value", "confidence"?, "turns"? }],
86
+ # dropped: { "schema" => N, "unknown_key" => N, "oversized" => N,
87
+ # "bad_turns" => N, "duplicate" => N },
88
+ # cost: { "spent" => N, "cached" => N } | nil }
89
+ # prompt: the pack prompt or DEFAULT_PROMPT (the caller resolved it).
90
+ # message_count: the session transcript size — turns are validated
91
+ # against it (an index >= message_count drops the proposal).
92
+ # max_proposals: cap on surviving proposals (the model may return more).
93
+ # Drops are counted, never fixed up.
94
+ def distill(prompt:, message_count:, max_proposals: 10)
95
+ answer = @ask.call(prompt)
96
+ raw = parse(text_of(answer))
97
+ proposals = []
98
+ dropped = DROP_KEYS.to_h { |k| [k, 0] }
99
+ seen = {}
100
+ raw.each do |item|
101
+ verdict, reason = classify(item, message_count)
102
+ case verdict
103
+ when :keep
104
+ tuple = [item["name"].to_s, item["value"].to_s]
105
+ if seen[tuple]
106
+ dropped["duplicate"] += 1
107
+ elsif proposals.size >= max_proposals
108
+ dropped["capped"] += 1
109
+ else
110
+ seen[tuple] = true
111
+ proposals << normalize(item)
112
+ end
113
+ when :drop
114
+ dropped[reason] += 1
115
+ end
116
+ end
117
+ { proposals: proposals, dropped: dropped, cost: cost_of(answer) }
118
+ end
119
+
120
+ private
121
+
122
+ def text_of(answer) = (answer.respond_to?(:content) ? answer.content : answer).to_s
123
+
124
+ # nil when the provider said nothing — never 0 (the Proposer's
125
+ # discipline). The cached prefix is INCLUDED in the spent total.
126
+ def cost_of(answer)
127
+ return nil unless answer.respond_to?(:input_tokens) && answer.respond_to?(:output_tokens)
128
+
129
+ input = answer.input_tokens.to_i
130
+ output = answer.output_tokens.to_i
131
+ cached = answer.respond_to?(:cached_tokens) ? answer.cached_tokens.to_i : 0
132
+ spent = input + output + cached
133
+ spent.positive? ? { "spent" => spent, "cached" => cached } : nil
134
+ end
135
+
136
+ # Fences are stripped, the payload parsed STRICTLY: a model that
137
+ # improvises a schema fails here instead of producing half a proposal.
138
+ def parse(raw)
139
+ body = raw.strip.gsub(/\A```(?:json)?\s*|\s*```\z/, "")
140
+ parsed = JSON.parse(body)
141
+ raise Unusable, "the distiller's answer is not an array" unless parsed.is_a?(Array)
142
+
143
+ parsed
144
+ rescue JSON::ParserError => e
145
+ raise Unusable, "the distiller's answer is not valid JSON: #{e.message}"
146
+ end
147
+
148
+ # -> [:keep, nil] | [:drop, drop_key]. The order is the safety order:
149
+ # unknown keys first (D1 — a model-authored scope is a cross-tenant
150
+ # escape), then the turns bounds, then the schema, then the length and
151
+ # confidence bounds. Turns are checked before the schema so a
152
+ # non-integer / out-of-bounds index counts as bad_turns, never as a
153
+ # generic schema miss.
154
+ def classify(item, message_count)
155
+ # a non-Hash element is a shape violation, never an "unknown key"
156
+ return [:drop, "schema"] unless item.is_a?(Hash)
157
+ return [:drop, "unknown_key"] unless (item.keys.map(&:to_s) - ALLOWED_KEYS).empty?
158
+
159
+ turns = item["turns"]
160
+ if turns
161
+ return [:drop, "bad_turns"] if !turns.is_a?(Array) || turns.size > MAX_TURNS
162
+ return [:drop, "bad_turns"] if turns.any? { |t| !t.is_a?(Integer) || t.negative? || t >= message_count }
163
+ end
164
+
165
+ unless ITEM_SCHEMA.call(item).success?
166
+ return [:drop, "schema"]
167
+ end
168
+
169
+ name = item["name"].to_s
170
+ value = item["value"].to_s
171
+ return [:drop, "oversized"] if name.length > MAX_NAME_LENGTH || value.length > MAX_VALUE_LENGTH
172
+
173
+ confidence = item["confidence"]
174
+ if !confidence.nil? && (!confidence.is_a?(Numeric) || confidence.negative? || confidence > 1)
175
+ return [:drop, "schema"]
176
+ end
177
+
178
+ [:keep, nil]
179
+ end
180
+
181
+ # The SAFE subset only — anything the model smuggled in extra keys
182
+ # already dropped with the proposal.
183
+ def normalize(item)
184
+ item.slice("name", "value", "confidence", "turns")
185
+ end
186
+ end
187
+
188
+ # Resolves WHICH model distills, and builds the ask. Profile -> platform
189
+ # utility_model -> nil (D4 — nil means "feature inert", never a guess).
190
+ # `ask_factory`/`llm` injectable (specs).
191
+ module DistillerFactory
192
+ module_function
193
+
194
+ # config: the agent's `distill` hash. -> Distiller | nil
195
+ def build(config, utility_model: nil, ask_factory: nil, llm: nil)
196
+ ref = Coercion.presence(config && config["model"]) || Coercion.presence(utility_model)
197
+ return nil if ref.nil?
198
+
199
+ provider, model = split_ref(ref)
200
+ factory = ask_factory || ->(m, p) { ruby_llm_ask(m, p, llm: llm) }
201
+ Distiller.new(ask: factory.call(model, provider), model: ref)
202
+ end
203
+
204
+ # "provider/model" -> [provider, model]; "model" -> [nil, model] — the
205
+ # ProposerFactory reading, one syntax for "which model" across features.
206
+ def split_ref(ref)
207
+ prov, name = ref.to_s.split("/", 2)
208
+ name ? [prov, name] : [nil, prov]
209
+ end
210
+
211
+ # Temperature 0: a rejected fact must be re-proposable deterministically.
212
+ # `ruby_llm` is required lazily so nothing loads a provider gem until a
213
+ # distiller is actually configured (load_guard stays green).
214
+ def ruby_llm_ask(model, provider, llm: nil)
215
+ require "ruby_llm"
216
+ llm ||= RubyLLM
217
+ lambda do |prompt|
218
+ llm.chat(model: model, provider: provider, assume_model_exists: true)
219
+ .with_temperature(0).ask(prompt)
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # the trigger — finds due sessions and distills them. It
7
+ # spawns a worker fiber (supervisor child, `Tick#start` shape) whose loop
8
+ # claims a window and runs the distillation for due sessions ON the worker
9
+ # fiber — off the tick's critical path and off every customer turn's path.
10
+ # The scan uses the engine default `idle_hours` (6) as the LOWER BOUND; the
11
+ # per-agent value is re-checked inside RunDistillation (a pack that wants
12
+ # 12 h is never distilled at 6).
13
+ #
14
+ # D2 — no queue: the per-session marker is the claim. A crash mid-pass
15
+ # leaves markers unwritten and the next boot re-scans; the dedup ledger
16
+ # filters duplicates. Two workers racing the same session both run, and the
17
+ # ledger still filters.
18
+ class DistillEngine
19
+ SCOPE = "distill"
20
+ KEY = "claim"
21
+ DEFAULT_WINDOW = 300 # seconds — the O(n) scan never rides the 60 s loop
22
+ DEFAULT_IDLE_HOURS = 6
23
+ DEFAULT_MIN_MESSAGES = 3
24
+
25
+ def initialize(store:, proposal_store:, session_store:, runner:, profiles: nil,
26
+ logger: nil, window: DEFAULT_WINDOW,
27
+ idle_hours: DEFAULT_IDLE_HOURS, sleeper: nil)
28
+ @store = store
29
+ @proposal_store = proposal_store
30
+ @session_store = session_store
31
+ @runner = runner
32
+ @profiles = profiles # profile source; nil/empty = nothing distills (parity)
33
+ @logger = logger
34
+ @window = window.to_i
35
+ @idle_hours = idle_hours.to_i
36
+ @sleeper = sleeper || method(:default_sleep)
37
+ end
38
+
39
+ # The supervisor child (tick.rb:69-83's shape): loop { sleeper(window);
40
+ # run_once rescue log }. Returns false when idle_hours <= 0 (the engine
41
+ # default OFF switch — parity) OR when no profile declares a distillation
42
+ # (a scan with nothing to distill would re-read every session record every
43
+ # window forever — each pass skips at the command, writes no marker, and
44
+ # repeats).
45
+ def start(parent:)
46
+ return false unless distillable?
47
+ return true if @task&.running?
48
+
49
+ @task = parent.async do |t|
50
+ t.annotate("insika-distill")
51
+ loop do
52
+ @sleeper.call(@window)
53
+ run_once
54
+ rescue StandardError => e
55
+ log(:warn, "distill pass failed: #{e.class}: #{e.message}")
56
+ end
57
+ end
58
+ true
59
+ end
60
+
61
+ # One pass. -> { claimed: false } | { claimed: true, distilled: N,
62
+ # skipped: N, errors: N }
63
+ def run_once
64
+ return { claimed: false } unless distillable?
65
+ return { claimed: false } unless claim_window
66
+
67
+ distilled = 0
68
+ skipped = 0
69
+ errors = 0
70
+ due_sessions.each do |session|
71
+ begin
72
+ outcome = @runner.call(Insika::Command.build(:run_distillation,
73
+ { session_id: session.id }))
74
+ if outcome[:distilled]
75
+ distilled += 1
76
+ else
77
+ skipped += 1
78
+ end
79
+ rescue StandardError
80
+ # a broken session must not hold the pass; the marker discipline
81
+ # keeps it re-runnable.
82
+ errors += 1
83
+ end
84
+ end
85
+ { claimed: true, distilled: distilled, skipped: skipped, errors: errors }
86
+ end
87
+
88
+ private
89
+
90
+ # The engine is inert unless the switch is on (idle_hours > 0) AND at
91
+ # least one profile declares an enabled distillation — without one, a pass
92
+ # would re-read every session record every window forever (each due
93
+ # session skips at the command, writes no marker, and repeats).
94
+ def distillable?
95
+ return false if @idle_hours <= 0
96
+ return false if @profiles.nil?
97
+
98
+ profiles_list.any? do |p|
99
+ config = p.respond_to?(:distill) ? p.distill : nil
100
+ config && Coercion.truthy?(config["enabled"])
101
+ end
102
+ end
103
+
104
+ def profiles_list
105
+ return @profiles.values if @profiles.respond_to?(:values)
106
+
107
+ @profiles.respond_to?(:all) ? @profiles.all : []
108
+ end
109
+
110
+ # Sessions whose `vars["customer"]` and `vars["agent"]` are present, older
111
+ # than the engine default idle window, with enough messages, and not yet
112
+ # distilled. Oldest first.
113
+ def due_sessions
114
+ cutoff = Time.now.utc - @idle_hours * 3600
115
+ ids = []
116
+ @session_store.each_id do |id|
117
+ session = @session_store.find(id)
118
+ next unless session
119
+ next if Coercion.blank?(session.vars["customer"])
120
+ next if Coercion.blank?(session.vars["agent"])
121
+ next unless aged?(session.updated_at, cutoff)
122
+ next if session.messages.size < DEFAULT_MIN_MESSAGES
123
+ next if @proposal_store.distilled?(id)
124
+
125
+ ids << session
126
+ end
127
+ ids.sort_by { |s| s.updated_at.to_s }
128
+ end
129
+
130
+ def aged?(updated_at, cutoff)
131
+ return false if Coercion.blank?(updated_at)
132
+
133
+ Time.iso8601(updated_at.to_s) <= cutoff
134
+ rescue ArgumentError
135
+ false
136
+ end
137
+
138
+ # One worker per window across N workers — the tick's claim idiom
139
+ # (one key, a timestamp, a window).
140
+ def claim_window
141
+ now = Time.now.utc
142
+ @store.transaction do
143
+ current = @store.get(SCOPE, KEY)
144
+ last = current && begin
145
+ Time.iso8601(current["claimed_at"].to_s)
146
+ rescue ArgumentError
147
+ nil # a corrupted claim is not a claim — take the window
148
+ end
149
+ if last.nil? || (now - last) >= @window
150
+ @store.set(SCOPE, KEY, { "claimed_at" => now.iso8601 })
151
+ true
152
+ else
153
+ false
154
+ end
155
+ end
156
+ end
157
+
158
+ def default_sleep(seconds)
159
+ task = defined?(Async::Task) ? Async::Task.current? : nil
160
+ task ? task.sleep(seconds) : sleep(seconds)
161
+ end
162
+
163
+ def log(level, message)
164
+ @logger&.public_send(level, "[distill] #{message}")
165
+ rescue StandardError
166
+ nil
167
+ end
168
+ end
169
+ end