insika 0.9.0 → 1.0.1
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 +237 -2
- data/README.md +5 -3
- data/bin/insika +3 -0
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +90 -7
- data/docs/ARCHITECTURE.md +12 -7
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CONTEXT.md +63 -0
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +57 -11
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +33 -4
- data/docs/FACTS.md +8 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +7 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +143 -22
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +22 -13
- data/docs/RELEASING.md +5 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +7 -0
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +111 -9
- data/docs/operate.md +3 -0
- data/lib/insika/agent_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +78 -8
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/chat_builder.rb +79 -25
- data/lib/insika/checkpoint.rb +6 -1
- data/lib/insika/checkpoint_store.rb +59 -7
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/delete_kit.rb +26 -0
- data/lib/insika/commands/gate_harvest.rb +3 -0
- data/lib/insika/commands/review_guardrail_flag.rb +38 -0
- data/lib/insika/commands/run_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +11 -1
- data/lib/insika/commands/send_message.rb +9 -3
- data/lib/insika/commands/update_settings.rb +23 -1
- data/lib/insika/commands/write_kit.rb +27 -0
- data/lib/insika/compaction.rb +33 -7
- data/lib/insika/config_store.rb +87 -7
- data/lib/insika/context/priority.rb +3 -0
- data/lib/insika/context/provider.rb +2 -2
- data/lib/insika/context/providers/knowledge.rb +25 -2
- data/lib/insika/context/providers/memory.rb +53 -4
- data/lib/insika/context/providers/pending_confirmation.rb +52 -0
- data/lib/insika/context/providers/prompt.rb +17 -4
- data/lib/insika/context/providers/session.rb +39 -3
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +34 -4
- data/lib/insika/dsl/runtime.rb +36 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +13 -5
- data/lib/insika/edge_limiter.rb +23 -15
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/evals/assertions.rb +171 -16
- data/lib/insika/evals/golden.rb +141 -4
- data/lib/insika/evals/judge.rb +19 -10
- data/lib/insika/evals/moderator_compare.rb +47 -0
- data/lib/insika/evals/report.rb +61 -9
- data/lib/insika/evals/runner.rb +44 -4
- data/lib/insika/evals/simulator.rb +10 -1
- data/lib/insika/evals/transport.rb +200 -0
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +29 -8
- data/lib/insika/executor.rb +668 -141
- data/lib/insika/harvest.rb +26 -14
- data/lib/insika/harvest_engine.rb +1 -0
- data/lib/insika/harvest_store.rb +12 -0
- data/lib/insika/http_client.rb +87 -4
- data/lib/insika/kits.rb +54 -0
- data/lib/insika/knowledge.rb +34 -20
- data/lib/insika/llm_configurator.rb +46 -4
- data/lib/insika/llm_provider_store.rb +1 -3
- data/lib/insika/llm_trace_store.rb +54 -0
- data/lib/insika/mcp_client.rb +27 -63
- data/lib/insika/mcp_live_tool.rb +55 -9
- data/lib/insika/mcp_store.rb +32 -1
- data/lib/insika/mcp_tool_registry.rb +69 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +4 -3
- data/lib/insika/memory_trim.rb +60 -0
- data/lib/insika/middleware.rb +12 -3
- data/lib/insika/model_metrics_store.rb +285 -0
- data/lib/insika/model_registry.rb +110 -0
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +14 -21
- data/lib/insika/model_visible.rb +17 -6
- data/lib/insika/onboarding.rb +1 -0
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +25 -2
- data/lib/insika/pack_importer.rb +54 -9
- data/lib/insika/packaging.rb +14 -5
- data/lib/insika/pending_action_store.rb +45 -18
- data/lib/insika/plugin/loader.rb +4 -2
- data/lib/insika/profile_source.rb +4 -0
- data/lib/insika/provider_error_classifier.rb +18 -3
- data/lib/insika/queue_policy.rb +7 -2
- data/lib/insika/refinement/proposer.rb +42 -17
- data/lib/insika/reliability.rb +12 -3
- data/lib/insika/router/app.rb +14 -5
- data/lib/insika/router/session_key.rb +35 -5
- data/lib/insika/safety/config.rb +20 -3
- data/lib/insika/safety/corpus.rb +10 -3
- data/lib/insika/safety/factory.rb +39 -1
- data/lib/insika/safety/input_guardrail.rb +21 -1
- data/lib/insika/safety/judge_moderator.rb +70 -0
- data/lib/insika/safety/output_validator.rb +29 -5
- data/lib/insika/safety/prompt_echo.rb +89 -0
- data/lib/insika/schema_guard.rb +53 -76
- data/lib/insika/server/app.rb +17 -8
- data/lib/insika/server/responses.rb +19 -1
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +159 -10
- data/lib/insika/settings_store.rb +47 -1
- data/lib/insika/shared_conversations.rb +305 -0
- data/lib/insika/skill_catalog.rb +28 -2
- data/lib/insika/skill_store.rb +6 -0
- data/lib/insika/soak/runner.rb +1 -1
- data/lib/insika/steer_injector.rb +7 -48
- data/lib/insika/store.rb +29 -1
- data/lib/insika/stores/json_value.rb +37 -0
- data/lib/insika/stores/memory.rb +22 -35
- data/lib/insika/stores/postgres.rb +295 -0
- data/lib/insika/stores/sqlite.rb +257 -43
- data/lib/insika/stores/turn_counter.rb +25 -0
- data/lib/insika/studio/app.rb +1453 -161
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +25 -25
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
- data/lib/insika/studio/forms.rb +171 -9
- data/lib/insika/studio/nav_icons.rb +3 -0
- data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
- data/lib/insika/studio/views/_agent_picker.erb +15 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +78 -242
- data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
- data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
- data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
- data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
- data/lib/insika/studio/views/_agents_master.erb +8 -12
- data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
- data/lib/insika/studio/views/_cfg_budget.erb +20 -0
- data/lib/insika/studio/views/_cfg_distill.erb +26 -0
- data/lib/insika/studio/views/_cfg_followup.erb +30 -0
- data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
- data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
- data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
- data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
- data/lib/insika/studio/views/_chats_master.erb +66 -0
- data/lib/insika/studio/views/_customers_master.erb +37 -0
- data/lib/insika/studio/views/_empty.erb +15 -0
- data/lib/insika/studio/views/_facts_master.erb +42 -0
- data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
- data/lib/insika/studio/views/_identity.erb +16 -0
- data/lib/insika/studio/views/_kpi.erb +17 -0
- data/lib/insika/studio/views/_message.erb +40 -2
- data/lib/insika/studio/views/_model_chart.erb +82 -0
- data/lib/insika/studio/views/_refinement_master.erb +38 -0
- data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +32 -9
- data/lib/insika/studio/views/_tool_users.erb +17 -0
- data/lib/insika/studio/views/_why.erb +53 -0
- data/lib/insika/studio/views/agent_detail.erb +17 -10
- data/lib/insika/studio/views/agents.erb +28 -16
- data/lib/insika/studio/views/approvals.erb +16 -11
- data/lib/insika/studio/views/artifact.erb +20 -7
- data/lib/insika/studio/views/artifacts.erb +19 -14
- data/lib/insika/studio/views/chats.erb +22 -29
- data/lib/insika/studio/views/customer.erb +88 -43
- data/lib/insika/studio/views/customers.erb +16 -26
- data/lib/insika/studio/views/evals.erb +49 -31
- data/lib/insika/studio/views/fact_detail.erb +114 -0
- data/lib/insika/studio/views/facts.erb +15 -116
- data/lib/insika/studio/views/followups.erb +63 -52
- data/lib/insika/studio/views/funnel.erb +104 -43
- data/lib/insika/studio/views/harvest.erb +85 -59
- data/lib/insika/studio/views/home.erb +138 -23
- data/lib/insika/studio/views/inbox.erb +36 -0
- data/lib/insika/studio/views/kits.erb +176 -0
- data/lib/insika/studio/views/knowledge.erb +92 -80
- data/lib/insika/studio/views/layout.erb +42 -11
- data/lib/insika/studio/views/live.erb +45 -0
- data/lib/insika/studio/views/mcp.erb +43 -13
- data/lib/insika/studio/views/models.erb +187 -0
- data/lib/insika/studio/views/parity.erb +38 -32
- data/lib/insika/studio/views/playground.erb +13 -9
- data/lib/insika/studio/views/refinement.erb +15 -224
- data/lib/insika/studio/views/refinement_detail.erb +243 -0
- data/lib/insika/studio/views/session.erb +152 -117
- data/lib/insika/studio/views/settings.erb +86 -22
- data/lib/insika/studio/views/skills.erb +53 -19
- data/lib/insika/studio/views/system_files.erb +7 -7
- data/lib/insika/studio/views/task.erb +45 -15
- data/lib/insika/studio/views/tasks.erb +35 -18
- data/lib/insika/studio/views/tools.erb +62 -73
- data/lib/insika/task_actor.rb +4 -1
- data/lib/insika/task_store.rb +67 -1
- data/lib/insika/telemetry/pricing.rb +16 -14
- data/lib/insika/telemetry/recorder.rb +103 -9
- data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
- data/lib/insika/telemetry.rb +12 -2
- data/lib/insika/templates/daily-digest/README.md +13 -0
- data/lib/insika/templates/daily-digest/agent.rb +22 -11
- data/lib/insika/testing/store_contract.rb +65 -0
- data/lib/insika/text_tool_call.rb +66 -0
- data/lib/insika/tick.rb +7 -1
- data/lib/insika/token_store.rb +5 -0
- data/lib/insika/tool_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +10 -4
- data/lib/insika/tool_envelope.rb +165 -28
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +3 -0
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +2 -2
- data/lib/insika/tools/a2a_remote.rb +1 -1
- data/lib/insika/tools/agent_enum.rb +1 -1
- data/lib/insika/tools/confirm_pending.rb +112 -0
- data/lib/insika/tools/data_defined_tool.rb +23 -15
- data/lib/insika/tools/generate_image.rb +2 -2
- data/lib/insika/tools/load_knowledge.rb +7 -3
- data/lib/insika/tools/load_skill.rb +3 -3
- data/lib/insika/tools/present.rb +1 -8
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +3 -5
- data/lib/insika/tools/save_artifact.rb +16 -6
- data/lib/insika/tools/schedule_followup.rb +3 -3
- data/lib/insika/tools/stuck_signal.rb +3 -3
- data/lib/insika/tools/subagent.rb +5 -5
- data/lib/insika/tools/subagents.rb +3 -3
- data/lib/insika/tools/tool_search.rb +7 -5
- data/lib/insika/tools/tts.rb +2 -2
- data/lib/insika/tools/update_briefing.rb +4 -4
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +18 -0
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +81 -16
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +19 -3
- metadata +69 -13
- data/lib/insika/commands/import_mcp_tools.rb +0 -48
- data/lib/insika/mcp_http_client.rb +0 -67
- data/lib/insika/mcp_tool_ingestor.rb +0 -165
|
@@ -42,9 +42,23 @@ module Insika
|
|
|
42
42
|
message = extract_input(body[:input])
|
|
43
43
|
|
|
44
44
|
out = { agent: agent.strip, user: user, message: message }
|
|
45
|
+
if body.key?(:shared_conversation)
|
|
46
|
+
raise Insika::ValidationError, 'shared_conversation must be an object and user_text a string' unless body[:shared_conversation].is_a?(Hash) && body[:user_text].is_a?(String)
|
|
47
|
+
out[:shared_conversation] = body[:shared_conversation]
|
|
48
|
+
out[:user_text] = body[:user_text]
|
|
49
|
+
end
|
|
45
50
|
(origin = Insika::MessageOrigin.parse!(body[:origin])) && (out[:origin] = origin)
|
|
51
|
+
# the caller's text that rides ahead of `input` to the model; guardrails read `input` alone
|
|
52
|
+
unless body[:context].nil? || body[:context].is_a?(String)
|
|
53
|
+
raise Insika::ValidationError, "context must be a string"
|
|
54
|
+
end
|
|
55
|
+
out[:context] = body[:context] if body[:context]
|
|
46
56
|
# WS8: the optional customer_key — per-customer memory scope + purge handle
|
|
47
57
|
(customer = Insika::Coercion.presence(body[:customer])) && (out[:customer] = customer)
|
|
58
|
+
# the optional labels the Studio shows and searches the conversation by
|
|
59
|
+
Insika::SessionStore::CUSTOMER_LABELS.each do |key|
|
|
60
|
+
out[key.to_sym] = body[key.to_sym] if body[key.to_sym].is_a?(String)
|
|
61
|
+
end
|
|
48
62
|
# WS9: the multimodal OPENAI shape — `input` as an array of content parts
|
|
49
63
|
# ({type: text/image/audio}) is preserved additively alongside the
|
|
50
64
|
# joined text; a string input stays byte-identical to before. The
|
|
@@ -197,7 +211,11 @@ module Insika
|
|
|
197
211
|
# `model` travels alongside usage in the event; in the OpenAI shape it is a sibling of
|
|
198
212
|
# usage (pure tokens in usage).
|
|
199
213
|
model = usage[:model] || usage["model"]
|
|
200
|
-
|
|
214
|
+
projected = usage.transform_keys(&:to_sym).reject { |k, _| k == :model }
|
|
215
|
+
cache = projected[:cached_tokens].to_i + projected[:cache_creation_tokens].to_i
|
|
216
|
+
projected[:input_tokens] += cache if projected[:input_tokens]
|
|
217
|
+
projected[:total_tokens] += cache if projected[:total_tokens]
|
|
218
|
+
response[:usage] = projected
|
|
201
219
|
response[:model] = model if model
|
|
202
220
|
end
|
|
203
221
|
# Opt-in per-turn latency breakdown (INSIKA_TURN_TIMING). Absent
|
data/lib/insika/session_actor.rb
CHANGED
|
@@ -23,28 +23,47 @@ module Insika
|
|
|
23
23
|
# would duplicate the invariant; putting it in the Executor would mix turn
|
|
24
24
|
# execution with queue policy.
|
|
25
25
|
class SessionActor
|
|
26
|
-
|
|
26
|
+
# `on_idle` is called with the actor once its queue drains and the loop ends, so
|
|
27
|
+
# the owner can drop it: an idle session holds no fiber.
|
|
28
|
+
def initialize(session_id:, executor:, parent: Async::Task.current, on_idle: nil)
|
|
27
29
|
@session_id = session_id
|
|
28
30
|
@executor = executor
|
|
31
|
+
@on_idle = on_idle
|
|
29
32
|
@queue = Async::Queue.new
|
|
30
33
|
@running = false
|
|
31
|
-
# The
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
|
|
34
|
+
# The turns sitting at the door: created and :queued, but not yet released to
|
|
35
|
+
# run — whether waiting out a debounce window or simply queued behind the turn
|
|
36
|
+
# in flight. `collect` merges into the LAST one (the turn that has not
|
|
37
|
+
# answered yet; appending to an older one would put a newer fragment in an
|
|
38
|
+
# older message). Keyed by task so a turn released from the FIFO still finds
|
|
39
|
+
# its OWN count — with a single slot, a second turn enqueued behind the first
|
|
40
|
+
# would take the first's fragments with it. Empty is the common case and the
|
|
41
|
+
# safe default: a policy that does not merge never opens one.
|
|
42
|
+
@doors = {}
|
|
35
43
|
@loop = parent.async { |t| t.annotate("session:#{session_id}"); run_loop }
|
|
36
44
|
end
|
|
37
45
|
|
|
38
46
|
# Enqueues a turn (FIFO). Non-blocking: the handler responds with an
|
|
39
47
|
# immediate {task_id:} even if the turn stays :queued behind another. -> task.id.
|
|
40
48
|
#
|
|
41
|
-
# `policy` (a QueuePolicy)
|
|
42
|
-
#
|
|
49
|
+
# `policy` (a QueuePolicy) decides the door: a merging mode (`collect`/`steer`)
|
|
50
|
+
# opens it here, and a `debounce_ms` on top of that also holds the turn once it
|
|
51
|
+
# reaches the front. A nil policy or `followup` behaves exactly as before —
|
|
52
|
+
# nothing to merge into, dequeued and run at once.
|
|
43
53
|
#
|
|
44
54
|
# `timing` is the channel clock a channel turn allocated at 202
|
|
45
55
|
# acceptance and already stamped `:inbound`; it rides the queue so the debounce
|
|
46
56
|
# window and the FIFO wait land INSIDE first_balloon_ms.
|
|
47
57
|
def enqueue(task, profile:, resume_from: nil, policy: nil, timing: nil)
|
|
58
|
+
# The door opens HERE, not when the turn is dequeued. "Created and not yet
|
|
59
|
+
# started" lasts as long as the turn IN FRONT takes, and that whole wait is
|
|
60
|
+
# mergeable: the turn has not spoken, so a fragment appended to it costs
|
|
61
|
+
# nothing and saves an answer. Opening the door only for a debounce window
|
|
62
|
+
# (where it used to live) left every message that arrived while a turn sat in
|
|
63
|
+
# the FIFO with nothing to join — it became a turn of its own behind two
|
|
64
|
+
# others, and a `stream=false` caller then waited for BOTH to run before it
|
|
65
|
+
# heard anything. A window is now what EXTENDS the door, never what creates it.
|
|
66
|
+
@doors[task.id] = open_door(task) if policy&.collect?
|
|
48
67
|
@queue.enqueue([task, profile, resume_from, policy, timing])
|
|
49
68
|
task.id
|
|
50
69
|
end
|
|
@@ -58,7 +77,7 @@ module Insika
|
|
|
58
77
|
# and neither yields between the check and the write below, so the "is it
|
|
59
78
|
# still mergeable" test and the append cannot interleave.
|
|
60
79
|
def collect(text)
|
|
61
|
-
pending = @
|
|
80
|
+
pending = @doors.values.last
|
|
62
81
|
return nil if pending.nil?
|
|
63
82
|
|
|
64
83
|
@executor.task_store.append_message(pending[:task_id], text)
|
|
@@ -88,15 +107,15 @@ module Insika
|
|
|
88
107
|
# request's path.
|
|
89
108
|
attr_reader :current_task
|
|
90
109
|
|
|
91
|
-
# Is there a turn at the door that `collect` could still merge into?
|
|
92
|
-
|
|
110
|
+
# Is there a turn at the door that `collect` could still merge into? True from
|
|
111
|
+
# the moment a merging policy enqueues one until it starts running.
|
|
112
|
+
def collecting? = !@doors.empty?
|
|
93
113
|
|
|
94
114
|
# Is the loop still alive? (the Executor revalidates before reusing from the
|
|
95
115
|
# cache — a dead loop would black-hole queued turns).
|
|
96
116
|
def alive? = !!@loop&.running?
|
|
97
117
|
|
|
98
|
-
# Shuts down the loop (server shutdown / tests
|
|
99
|
-
# dequeue when idle).
|
|
118
|
+
# Shuts down the loop (server shutdown / tests).
|
|
100
119
|
def stop = @loop&.stop
|
|
101
120
|
|
|
102
121
|
private
|
|
@@ -118,45 +137,56 @@ module Insika
|
|
|
118
137
|
@running = false
|
|
119
138
|
@current_task = nil
|
|
120
139
|
end
|
|
140
|
+
# Ends with the last queued turn. A parked fiber per session ever seen grew a
|
|
141
|
+
# worker without bound; the next message starts a fresh actor. No yield sits
|
|
142
|
+
# between this check and the loop's end, so an enqueue cannot slip in.
|
|
143
|
+
break if @queue.empty?
|
|
121
144
|
end
|
|
145
|
+
@on_idle&.call(self)
|
|
146
|
+
nil # the finished task must not hold the actor (see TaskActor#run)
|
|
122
147
|
end
|
|
123
148
|
|
|
124
|
-
# the
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
149
|
+
# Closes the turn's door and reports what it caught. The debounce
|
|
150
|
+
# window (when the policy has one) is the EXTRA wait held here, on the LOOP's
|
|
151
|
+
# fiber and never on the request's, so the POST is acked immediately and the
|
|
152
|
+
# platform does not retry. Returns the task to run — re-read from the store when
|
|
153
|
+
# fragments merged into it, since the in-memory Task is a frozen snapshot of an
|
|
154
|
+
# older message.
|
|
128
155
|
def hold_at_the_door(task, policy)
|
|
129
|
-
|
|
156
|
+
pending = @doors[task.id]
|
|
157
|
+
return task if pending.nil?
|
|
130
158
|
|
|
131
|
-
@pending = { task_id: task.id, count: 1, version: 0, arrivals: [Time.now.utc.iso8601] }
|
|
132
159
|
begin
|
|
133
|
-
wait_for_quiet(policy)
|
|
134
|
-
merged =
|
|
135
|
-
arrivals = @pending[:arrivals]
|
|
160
|
+
wait_for_quiet(policy, pending) if policy&.debounce?
|
|
161
|
+
merged = pending[:count]
|
|
136
162
|
ensure
|
|
137
|
-
# The
|
|
138
|
-
#
|
|
139
|
-
@
|
|
163
|
+
# The door closes BEFORE the turn runs, under every exit path: a `collect`
|
|
164
|
+
# that slipped in here would append to a task about to be read.
|
|
165
|
+
@doors.delete(task.id)
|
|
140
166
|
end
|
|
141
167
|
|
|
142
168
|
return task if merged == 1
|
|
143
169
|
|
|
144
|
-
@executor.emit_coalesced(task, merged: merged, arrivals: arrivals)
|
|
170
|
+
@executor.emit_coalesced(task, merged: merged, arrivals: pending[:arrivals])
|
|
145
171
|
@executor.task_store.find(task.id) || task
|
|
146
172
|
end
|
|
147
173
|
|
|
174
|
+
def open_door(task)
|
|
175
|
+
{ task_id: task.id, count: 1, version: 0, arrivals: [Time.now.utc.iso8601] }
|
|
176
|
+
end
|
|
177
|
+
|
|
148
178
|
# Sleeps in `debounce_ms` slices, restarting whenever a fragment arrives
|
|
149
179
|
# (`version` moved), until either a slice passes in silence or the total
|
|
150
180
|
# deferral reaches `debounce_max_ms` — the ceiling that stops a customer who
|
|
151
181
|
# keeps typing from postponing their own answer forever.
|
|
152
|
-
def wait_for_quiet(policy)
|
|
182
|
+
def wait_for_quiet(policy, pending)
|
|
153
183
|
quiet = policy.debounce_ms / 1000.0
|
|
154
184
|
deadline = monotonic + (policy.debounce_max_ms / 1000.0)
|
|
155
185
|
|
|
156
186
|
loop do
|
|
157
|
-
mark =
|
|
187
|
+
mark = pending[:version]
|
|
158
188
|
Async::Task.current.sleep(quiet)
|
|
159
|
-
break if
|
|
189
|
+
break if pending[:version] == mark # a full slice of silence
|
|
160
190
|
break if monotonic >= deadline
|
|
161
191
|
end
|
|
162
192
|
end
|
data/lib/insika/session_store.rb
CHANGED
|
@@ -20,7 +20,20 @@ module Insika
|
|
|
20
20
|
include Coercion
|
|
21
21
|
|
|
22
22
|
SCOPE = "sessions"
|
|
23
|
+
STATS_SCOPE = "session_stats" # one small record per session, see #write
|
|
24
|
+
# Optional Strings a caller sends to label a conversation in the Studio
|
|
25
|
+
# (shown and searched there; no effect on memory scope).
|
|
26
|
+
CUSTOMER_LABELS = %w[customer_name customer_phone].freeze
|
|
23
27
|
KEY_PREFIX = "session:"
|
|
28
|
+
# Simulator (`sim-<chat>`) and eval (`eval-<id>`) sessions are synthetic
|
|
29
|
+
# traffic: nothing learns from them
|
|
30
|
+
# (knowledge extraction, harvest). A tenant-scoped id ("<tenant>:sim-…")
|
|
31
|
+
# is judged by its last segment.
|
|
32
|
+
SYNTHETIC_PREFIXES = %w[sim- eval-].freeze
|
|
33
|
+
|
|
34
|
+
def self.synthetic?(session_id)
|
|
35
|
+
session_id.to_s.split(":").last.to_s.start_with?(*SYNTHETIC_PREFIXES)
|
|
36
|
+
end
|
|
24
37
|
|
|
25
38
|
Session = Data.define(:id, :messages, :vars, :memory_refs,
|
|
26
39
|
:created_at, :updated_at, :briefing, :evidence, :compaction) do
|
|
@@ -56,7 +69,7 @@ module Insika
|
|
|
56
69
|
"created_at" => now,
|
|
57
70
|
"updated_at" => now
|
|
58
71
|
}
|
|
59
|
-
|
|
72
|
+
write(record)
|
|
60
73
|
to_session(record)
|
|
61
74
|
end
|
|
62
75
|
|
|
@@ -78,13 +91,18 @@ module Insika
|
|
|
78
91
|
# a non-supervised deployment) would interleave read/set and LOSE messages —
|
|
79
92
|
# there is no compare-and-swap here. Route same-session writes through the
|
|
80
93
|
# SessionActor; see session_actor.rb.
|
|
81
|
-
|
|
94
|
+
# `agent:` names the session's agent when it has none yet (a turn with no
|
|
95
|
+
# customer never stamped one), riding this write instead of one of its own.
|
|
96
|
+
def append_messages(id, messages, agent: nil)
|
|
82
97
|
record = fetch!(id)
|
|
83
98
|
incoming = (messages.is_a?(Hash) ? [messages] : Array(messages))
|
|
84
99
|
.map { |msg| stamp(deep_stringify(msg)) }
|
|
85
100
|
record["messages"] += incoming
|
|
101
|
+
if agent && Coercion.presence((record["vars"] ||= {})["agent"]).nil?
|
|
102
|
+
record["vars"]["agent"] = agent.to_s
|
|
103
|
+
end
|
|
86
104
|
record["updated_at"] = timestamp
|
|
87
|
-
|
|
105
|
+
write(record)
|
|
88
106
|
to_session(record)
|
|
89
107
|
end
|
|
90
108
|
|
|
@@ -94,7 +112,7 @@ module Insika
|
|
|
94
112
|
record = fetch!(id)
|
|
95
113
|
record["vars"] = record["vars"].merge(deep_stringify(vars))
|
|
96
114
|
record["updated_at"] = timestamp
|
|
97
|
-
|
|
115
|
+
write(record)
|
|
98
116
|
to_session(record)
|
|
99
117
|
end
|
|
100
118
|
|
|
@@ -111,7 +129,7 @@ module Insika
|
|
|
111
129
|
# presentation tool joins on in a LATER turn. Absent until a card arrives.
|
|
112
130
|
ev["cards"] = EvidenceLedger.merge_cards(ev["cards"], cards) unless Array(cards).empty?
|
|
113
131
|
record["updated_at"] = timestamp
|
|
114
|
-
|
|
132
|
+
write(record)
|
|
115
133
|
to_session(record)
|
|
116
134
|
end
|
|
117
135
|
|
|
@@ -138,7 +156,7 @@ module Insika
|
|
|
138
156
|
value = Coercion.presence(Coercion.utf8(value.to_s))
|
|
139
157
|
value ? briefing["fields"][field.to_s] = value : briefing["fields"].delete(field.to_s)
|
|
140
158
|
record["updated_at"] = timestamp
|
|
141
|
-
|
|
159
|
+
write(record)
|
|
142
160
|
to_session(record)
|
|
143
161
|
end
|
|
144
162
|
|
|
@@ -149,7 +167,7 @@ module Insika
|
|
|
149
167
|
briefing = record["briefing"] ||= { "fields" => {}, "next_step" => nil }
|
|
150
168
|
briefing["next_step"] = Coercion.presence(Coercion.utf8(text.to_s))
|
|
151
169
|
record["updated_at"] = timestamp
|
|
152
|
-
|
|
170
|
+
write(record)
|
|
153
171
|
to_session(record)
|
|
154
172
|
end
|
|
155
173
|
|
|
@@ -164,31 +182,130 @@ module Insika
|
|
|
164
182
|
# read/mutate/set path suspends, so no other writer can interleave
|
|
165
183
|
# mid-RMW. The LLM call that produced the summary happened BEFORE this
|
|
166
184
|
# method; only the plain write lives here.
|
|
167
|
-
def set_compaction(id, summary
|
|
185
|
+
def set_compaction(id, summary: nil, upto:, model: nil, native: nil)
|
|
168
186
|
record = fetch!(id)
|
|
169
187
|
current = record["compaction"]
|
|
170
188
|
upto = Integer(upto)
|
|
171
189
|
return to_session(record) if current && upto <= current["upto"].to_i
|
|
172
190
|
|
|
173
191
|
record["compaction"] = {
|
|
174
|
-
"summary" => Coercion.utf8(summary.to_s),
|
|
192
|
+
"summary" => native ? nil : Coercion.utf8(summary.to_s),
|
|
193
|
+
"native" => native && Coercion.deep_stringify(native),
|
|
175
194
|
"upto" => upto,
|
|
176
195
|
"runs" => (current ? current["runs"].to_i : 0) + 1,
|
|
177
196
|
"model" => Coercion.presence(model.to_s),
|
|
178
197
|
"at" => timestamp
|
|
179
198
|
}.compact
|
|
180
199
|
record["updated_at"] = timestamp
|
|
181
|
-
|
|
200
|
+
write(record)
|
|
201
|
+
to_session(record)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# -> Session. A person's verdict on one guardrail flag:
|
|
205
|
+
# messages[message]["guardrail_flags"][flag]["review"] = { status, at }.
|
|
206
|
+
# status "reported" (a false positive to fix in the corpus) or "resolved";
|
|
207
|
+
# nil clears it. updated_at is left alone — a review is not conversation
|
|
208
|
+
# activity. NotFoundError for an unknown session, message or flag.
|
|
209
|
+
REVIEW_STATUSES = %w[reported resolved].freeze
|
|
210
|
+
|
|
211
|
+
def review_guardrail_flag(id, message:, flag:, status:)
|
|
212
|
+
record = fetch!(id)
|
|
213
|
+
message = Integer(message)
|
|
214
|
+
flag = Integer(flag)
|
|
215
|
+
# a negative index would count from the end: a different flag
|
|
216
|
+
target = Array(record["messages"])[message] unless message.negative?
|
|
217
|
+
flags = target.is_a?(Hash) ? Array(target["guardrail_flags"]) : []
|
|
218
|
+
entry = flags[flag] unless flag.negative?
|
|
219
|
+
raise Insika::NotFoundError, "guardrail flag not found: #{id} #{message}/#{flag}" unless entry.is_a?(Hash)
|
|
220
|
+
|
|
221
|
+
if status.nil?
|
|
222
|
+
entry.delete("review")
|
|
223
|
+
else
|
|
224
|
+
raise Insika::ValidationError, "unknown review status: #{status}" unless REVIEW_STATUSES.include?(status.to_s)
|
|
225
|
+
|
|
226
|
+
entry["review"] = { "status" => status.to_s, "at" => timestamp }
|
|
227
|
+
end
|
|
228
|
+
write(record, amend: true)
|
|
182
229
|
to_session(record)
|
|
183
230
|
end
|
|
184
231
|
|
|
185
232
|
# -> bool (delegates to the backend: false for a nonexistent id)
|
|
186
233
|
def delete(id)
|
|
234
|
+
@store.delete(STATS_SCOPE, key_for(id))
|
|
187
235
|
@store.delete(SCOPE, key_for(id))
|
|
188
236
|
end
|
|
189
237
|
|
|
190
238
|
# -> enumerates ids without the "session:" prefix. Without a block,
|
|
191
239
|
# returns an Enumerator.
|
|
240
|
+
# -> [Session] every record, from ONE bulk read of the backend (see
|
|
241
|
+
# Store#entries) instead of a read per id.
|
|
242
|
+
def all
|
|
243
|
+
@store.entries(SCOPE, KEY_PREFIX).map { |_, record| to_session(record) }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# -> [Session] the `limit` most recently written sessions, newest first, from
|
|
247
|
+
# one read (Store#recent) instead of reading every session to sort them.
|
|
248
|
+
# `offset` skips that many of the newest (the next page).
|
|
249
|
+
def recent(limit, offset: 0)
|
|
250
|
+
# offset only when paging: a backend written for the 3-argument #recent keeps working
|
|
251
|
+
rows = offset.zero? ? @store.recent(SCOPE, KEY_PREFIX, limit) : @store.recent(SCOPE, KEY_PREFIX, limit, offset)
|
|
252
|
+
rows.map { |_, record| to_session(record) }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# -> Integer sessions stored, from the keys alone (no record is read).
|
|
256
|
+
def count = @store.list(SCOPE, KEY_PREFIX).size
|
|
257
|
+
|
|
258
|
+
# What a dashboard needs of a session without its messages: a few bytes per
|
|
259
|
+
# session, written with it, instead of the whole record (messages included).
|
|
260
|
+
# last_role: who spoke last ("user" = the customer is waiting); nil on stats
|
|
261
|
+
# written before it existed, until the session's next write.
|
|
262
|
+
# flagged: messages carrying a guardrail flag nobody resolved (a resolved
|
|
263
|
+
# flag stays on the message as history); reported: flags a person
|
|
264
|
+
# marked as a false positive and nobody resolved yet. Both 0 on stats
|
|
265
|
+
# written before they existed, until the session's next write.
|
|
266
|
+
# reported_at: the newest of those reports.
|
|
267
|
+
Stat = Data.define(:id, :updated_at, :message_count, :vars, :last_role, :flagged, :reported, :reported_at) do
|
|
268
|
+
def initialize(id:, updated_at:, message_count:, vars:, last_role: nil, flagged: 0, reported: 0, reported_at: nil) = super
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# -> [Stat] every session's stats, in no particular order: small enough to read
|
|
272
|
+
# whole, so a reader never depends on the order they were written in.
|
|
273
|
+
def all_stats
|
|
274
|
+
@store.entries(STATS_SCOPE, KEY_PREFIX).map { |key, stat| to_stat(key, stat) }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Bumped when stats gain a field (2: flagged/reported; 3: flagged skips
|
|
278
|
+
# resolved flags), so backfill_stats rewrites the older ones instead of
|
|
279
|
+
# only filling the missing.
|
|
280
|
+
STATS_VERSION = 3
|
|
281
|
+
|
|
282
|
+
# A guardrail flag still marks its conversation until a person resolves it.
|
|
283
|
+
def self.open_flag?(flag) = !(flag.is_a?(Hash) && flag.dig("review", "status") == "resolved")
|
|
284
|
+
|
|
285
|
+
# Writes the stats of every session that has none or older ones (sessions
|
|
286
|
+
# stored before a field existed) and deletes stats whose session is gone.
|
|
287
|
+
# Idempotent and safe with the app running: each batch re-reads inside a
|
|
288
|
+
# transaction, skipping a session that wrote its own stats meanwhile or was
|
|
289
|
+
# deleted. -> stats written.
|
|
290
|
+
def backfill_stats(batch: 500)
|
|
291
|
+
have = @store.entries(STATS_SCOPE, KEY_PREFIX).filter_map { |key, stat| key if current_stat?(stat) }.to_set
|
|
292
|
+
written = 0
|
|
293
|
+
@store.list(SCOPE, KEY_PREFIX).reject { |key| have.include?(key) }.each_slice(batch) do |keys|
|
|
294
|
+
@store.transaction do
|
|
295
|
+
keys.each do |key|
|
|
296
|
+
next if current_stat?(@store.get(STATS_SCOPE, key))
|
|
297
|
+
|
|
298
|
+
record = @store.get(SCOPE, key) or next
|
|
299
|
+
@store.set(STATS_SCOPE, key, stats_of(record))
|
|
300
|
+
written += 1
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
live = @store.list(SCOPE, KEY_PREFIX).to_set
|
|
305
|
+
@store.list(STATS_SCOPE, KEY_PREFIX).each { |key| @store.delete(STATS_SCOPE, key) unless live.include?(key) }
|
|
306
|
+
written
|
|
307
|
+
end
|
|
308
|
+
|
|
192
309
|
def each_id
|
|
193
310
|
return enum_for(:each_id) unless block_given?
|
|
194
311
|
|
|
@@ -199,6 +316,38 @@ module Insika
|
|
|
199
316
|
|
|
200
317
|
private
|
|
201
318
|
|
|
319
|
+
# The one write of a session record: the record and its stats, side by side.
|
|
320
|
+
# amend: an edit that is not activity keeps the session's place in #recent.
|
|
321
|
+
def write(record, amend: false)
|
|
322
|
+
key = key_for(record["id"])
|
|
323
|
+
amend ? @store.amend(SCOPE, key, record) : @store.set(SCOPE, key, record)
|
|
324
|
+
@store.set(STATS_SCOPE, key, stats_of(record))
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def current_stat?(stat) = stat.is_a?(Hash) && stat["v"].to_i >= STATS_VERSION
|
|
328
|
+
|
|
329
|
+
def stats_of(record)
|
|
330
|
+
messages = Array(record["messages"])
|
|
331
|
+
last = messages.last
|
|
332
|
+
vars = record["vars"] || {}
|
|
333
|
+
flags = messages.map { |m| m.is_a?(Hash) ? Array(m["guardrail_flags"]) : [] }
|
|
334
|
+
flagged = flags.count { |fs| fs.any? { self.class.open_flag?(_1) } }
|
|
335
|
+
reported = flags.flatten.select { |f| f.is_a?(Hash) && f.dig("review", "status") == "reported" }
|
|
336
|
+
{ "v" => STATS_VERSION, "updated_at" => record["updated_at"], "message_count" => messages.size,
|
|
337
|
+
"agent" => vars["agent"], "customer" => vars["customer"],
|
|
338
|
+
"last_role" => last.is_a?(Hash) ? last["role"] : nil, "flagged" => (flagged if flagged.positive?),
|
|
339
|
+
"reported" => (reported.size if reported.any?), "reported_at" => reported.filter_map { |f| f.dig("review", "at") }.max,
|
|
340
|
+
**vars.slice(*CUSTOMER_LABELS) }.compact
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def to_stat(key, stat)
|
|
344
|
+
Stat.new(id: key.delete_prefix(KEY_PREFIX), updated_at: stat["updated_at"],
|
|
345
|
+
message_count: stat["message_count"].to_i,
|
|
346
|
+
vars: stat.slice("agent", "customer", *CUSTOMER_LABELS),
|
|
347
|
+
last_role: stat["last_role"], flagged: stat["flagged"].to_i, reported: stat["reported"].to_i,
|
|
348
|
+
reported_at: stat["reported_at"])
|
|
349
|
+
end
|
|
350
|
+
|
|
202
351
|
def key_for(id)
|
|
203
352
|
"#{KEY_PREFIX}#{id}"
|
|
204
353
|
end
|
|
@@ -36,7 +36,7 @@ module Insika
|
|
|
36
36
|
# platform utility_model) into one history fragment. `prompt` replaces
|
|
37
37
|
# the engine default wholesale (the distill convention). enabled: false
|
|
38
38
|
# = parity (nothing runs). Additive keys — reads overlay DEFAULTS.
|
|
39
|
-
"compaction" => { "enabled" => false, "keep_last" => 20,
|
|
39
|
+
"compaction" => { "enabled" => false, "mode" => "summary", "keep_last" => 20,
|
|
40
40
|
"compact_after" => 40, "model" => nil },
|
|
41
41
|
# Fencing (per-agent `fencing` flag): the cap on ONE string leaf of a tool
|
|
42
42
|
# result after sanitizing. Platform-wide — the leaf size is a context-budget
|
|
@@ -103,6 +103,23 @@ module Insika
|
|
|
103
103
|
"agent_token_ceiling" => nil,
|
|
104
104
|
"agent_token_window" => 86_400,
|
|
105
105
|
"limit_response" => nil
|
|
106
|
+
},
|
|
107
|
+
# Burst policy — the platform layer of the QueuePolicy (session vars >
|
|
108
|
+
# agent limits > HERE > QueuePolicy::DEFAULTS). nil = no platform default:
|
|
109
|
+
# the key falls through to QueuePolicy::DEFAULTS (followup, no window,
|
|
110
|
+
# steer_max 5, raw text). Same keys the agent config form writes into
|
|
111
|
+
# profile.limits, so one deploy-wide answer no longer means editing every
|
|
112
|
+
# agent. Additive key — reads overlay DEFAULTS.
|
|
113
|
+
# Platform defaults for agent fields (Insika::AgentDefaults): an agent with
|
|
114
|
+
# no value of its own for one of AgentDefaults::FIELDS runs with this one.
|
|
115
|
+
# Written through #put_agent_defaults. Additive key.
|
|
116
|
+
"agent_defaults" => {},
|
|
117
|
+
"queue" => {
|
|
118
|
+
"queue_mode" => nil,
|
|
119
|
+
"debounce_ms" => nil,
|
|
120
|
+
"debounce_max_ms" => nil,
|
|
121
|
+
"steer_max_messages" => nil,
|
|
122
|
+
"steer_join" => nil
|
|
106
123
|
}
|
|
107
124
|
}.freeze
|
|
108
125
|
|
|
@@ -123,6 +140,35 @@ module Insika
|
|
|
123
140
|
merged
|
|
124
141
|
end
|
|
125
142
|
|
|
143
|
+
# Replaces the platform agent defaults per FIELD (AgentDefaults): a sent field
|
|
144
|
+
# replaces its whole value (unlike #update, which deep-merges and could never
|
|
145
|
+
# drop a key the form removed); nil drops it; fields not sent stay.
|
|
146
|
+
# -> Hash (the resulting agent_defaults).
|
|
147
|
+
def put_agent_defaults(patch)
|
|
148
|
+
data = get
|
|
149
|
+
defaults = (data["agent_defaults"] || {}).merge(stringify(patch || {})).compact
|
|
150
|
+
data["agent_defaults"] = defaults
|
|
151
|
+
@cs.put(SCOPE, KEY, data)
|
|
152
|
+
defaults
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Kits (Insika::Kits), replaced per kit: a sent kit replaces its whole value,
|
|
156
|
+
# nil deletes it, other kits stay. -> Hash (all kits).
|
|
157
|
+
def kits = get["kits"] || {}
|
|
158
|
+
|
|
159
|
+
def put_kit(name, kit)
|
|
160
|
+
data = get
|
|
161
|
+
all = (data["kits"] || {}).dup
|
|
162
|
+
if kit.nil?
|
|
163
|
+
all.delete(name.to_s)
|
|
164
|
+
else
|
|
165
|
+
all[name.to_s] = Insika::Kits.validate!(name, kit)
|
|
166
|
+
end
|
|
167
|
+
data["kits"] = all
|
|
168
|
+
@cs.put(SCOPE, KEY, data)
|
|
169
|
+
all
|
|
170
|
+
end
|
|
171
|
+
|
|
126
172
|
# RAW persisted schema version (bypasses the DEFAULTS overlay, which would always
|
|
127
173
|
# report the current one). nil = no settings persisted yet (fresh deploy — nothing
|
|
128
174
|
# to migrate); an Integer otherwise, 0 for a pre-versioning record.
|