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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +145 -17
- data/README.md +7 -1
- data/bin/insika +211 -8
- data/docs/AGENTS.md +377 -2
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +99 -9
- data/docs/CONTEXT.md +127 -16
- data/docs/DEPLOY.md +5 -2
- data/docs/EVALS.md +19 -0
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +14 -8
- data/docs/OBSERVABILITY.md +54 -0
- data/docs/REFINEMENT.md +3 -3
- data/docs/RELEASING.md +27 -0
- data/docs/RUNNING-LOCAL.md +3 -3
- data/docs/SECURITY.md +61 -0
- data/docs/SKILLS.md +2 -2
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +1 -1
- data/lib/insika/agent_profile.rb +145 -10
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +118 -13
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +84 -1
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_tenant_data.rb +95 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +3 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +30 -2
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +716 -6
- data/lib/insika/dsl/runtime.rb +9 -3
- data/lib/insika/dsl/server_boot.rb +70 -1
- data/lib/insika/dsl.rb +83 -3
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +9 -2
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/pairwise.rb +18 -6
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +687 -70
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +221 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +1 -1
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/media.rb +298 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +6 -1
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +6 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +22 -1
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +144 -4
- data/lib/insika/server/rack_app.rb +16 -0
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +4 -0
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +875 -14
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +22 -22
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +262 -5
- data/lib/insika/studio/nav_icons.rb +6 -1
- data/lib/insika/studio/views/agent_detail.erb +627 -84
- data/lib/insika/studio/views/agents.erb +10 -6
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +4 -1
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +2 -1
- data/lib/insika/studio/views/layout.erb +1 -0
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +56 -1
- data/lib/insika/studio/views/settings.erb +2 -0
- data/lib/insika/studio/views/skills.erb +0 -1
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +0 -1
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +25 -1
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_state.rb +38 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +300 -13
- data/lib/insika.rb +92 -3
- 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
|