insika 0.8.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.
Files changed (276) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +312 -2
  3. data/README.md +10 -6
  4. data/bin/insika +4 -1
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +142 -18
  7. data/docs/API.md +73 -0
  8. data/docs/ARCHITECTURE.md +56 -50
  9. data/docs/ARTIFACTS.md +33 -3
  10. data/docs/BENCHMARK.md +50 -7
  11. data/docs/CHANNELS.md +19 -2
  12. data/docs/CONTEXT.md +96 -27
  13. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  14. data/docs/DEPLOY.md +70 -13
  15. data/docs/EMBEDDING.md +10 -0
  16. data/docs/EVALS.md +129 -10
  17. data/docs/FACTS.md +12 -6
  18. data/docs/HARVEST.md +8 -3
  19. data/docs/KNOWLEDGE.md +14 -0
  20. data/docs/MEDIA.md +6 -2
  21. data/docs/OBSERVABILITY.md +164 -28
  22. data/docs/PERFORMANCE.md +110 -0
  23. data/docs/PLUGINS.md +4 -4
  24. data/docs/POLICY.md +26 -14
  25. data/docs/RELEASING.md +9 -0
  26. data/docs/ROUTER.md +7 -0
  27. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  28. data/docs/SECURITY.md +34 -1
  29. data/docs/SHARED-CONVERSATIONS.md +40 -0
  30. data/docs/SKILLS.md +5 -3
  31. data/docs/STUDIO.md +122 -0
  32. data/docs/TOOLS.md +238 -42
  33. data/docs/operate.md +3 -0
  34. data/docs/prompts/ADD-TOOL.md +12 -2
  35. data/docs/prompts/DIAGNOSE-TURN.md +3 -0
  36. data/docs/prompts/GO-LIVE.md +3 -1
  37. data/lib/insika/agent_defaults.rb +140 -0
  38. data/lib/insika/agent_file_store.rb +12 -0
  39. data/lib/insika/agent_profile.rb +99 -17
  40. data/lib/insika/artifact_signing.rb +11 -0
  41. data/lib/insika/channels/web/widget.js +33 -0
  42. data/lib/insika/channels/web.rb +5 -2
  43. data/lib/insika/chat_builder.rb +124 -28
  44. data/lib/insika/checkpoint.rb +6 -1
  45. data/lib/insika/checkpoint_store.rb +59 -7
  46. data/lib/insika/commands/agent_payload.rb +3 -3
  47. data/lib/insika/commands/delete_kit.rb +26 -0
  48. data/lib/insika/commands/gate_harvest.rb +3 -0
  49. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  50. data/lib/insika/commands/run_distillation.rb +5 -8
  51. data/lib/insika/commands/run_harvest.rb +17 -1
  52. data/lib/insika/commands/seed_session.rb +128 -0
  53. data/lib/insika/commands/send_message.rb +9 -3
  54. data/lib/insika/commands/update_settings.rb +23 -1
  55. data/lib/insika/commands/write_kit.rb +27 -0
  56. data/lib/insika/compaction.rb +33 -7
  57. data/lib/insika/config_store.rb +87 -7
  58. data/lib/insika/context/builder.rb +29 -9
  59. data/lib/insika/context/priority.rb +5 -0
  60. data/lib/insika/context/provider.rb +7 -2
  61. data/lib/insika/context/providers/briefing.rb +11 -8
  62. data/lib/insika/context/providers/fence_notice.rb +27 -0
  63. data/lib/insika/context/providers/knowledge.rb +32 -6
  64. data/lib/insika/context/providers/memory.rb +61 -8
  65. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  66. data/lib/insika/context/providers/prompt.rb +17 -4
  67. data/lib/insika/context/providers/session.rb +46 -6
  68. data/lib/insika/context/reranker.rb +75 -0
  69. data/lib/insika/distill.rb +22 -13
  70. data/lib/insika/doctor.rb +142 -4
  71. data/lib/insika/dsl/runtime.rb +37 -4
  72. data/lib/insika/dsl/server_boot.rb +4 -12
  73. data/lib/insika/dsl.rb +19 -5
  74. data/lib/insika/edge_limiter.rb +27 -16
  75. data/lib/insika/egress_guard.rb +11 -3
  76. data/lib/insika/env_schema.rb +33 -73
  77. data/lib/insika/errors.rb +1 -0
  78. data/lib/insika/evals/assertions.rb +259 -18
  79. data/lib/insika/evals/golden.rb +230 -4
  80. data/lib/insika/evals/judge.rb +19 -10
  81. data/lib/insika/evals/moderator_compare.rb +47 -0
  82. data/lib/insika/evals/report.rb +61 -9
  83. data/lib/insika/evals/runner.rb +64 -4
  84. data/lib/insika/evals/simulator.rb +21 -3
  85. data/lib/insika/evals/transport.rb +317 -15
  86. data/lib/insika/event_stream.rb +63 -7
  87. data/lib/insika/evidence.rb +105 -17
  88. data/lib/insika/executor.rb +698 -164
  89. data/lib/insika/fence.rb +96 -0
  90. data/lib/insika/golden_store.rb +3 -0
  91. data/lib/insika/harvest.rb +26 -14
  92. data/lib/insika/harvest_engine.rb +1 -0
  93. data/lib/insika/harvest_store.rb +12 -0
  94. data/lib/insika/http_client.rb +87 -4
  95. data/lib/insika/kits.rb +54 -0
  96. data/lib/insika/knowledge.rb +34 -20
  97. data/lib/insika/llm_configurator.rb +46 -4
  98. data/lib/insika/llm_provider_store.rb +1 -3
  99. data/lib/insika/llm_trace_store.rb +54 -0
  100. data/lib/insika/mcp_client.rb +27 -63
  101. data/lib/insika/mcp_live_tool.rb +55 -9
  102. data/lib/insika/mcp_store.rb +37 -3
  103. data/lib/insika/mcp_tool_registry.rb +76 -26
  104. data/lib/insika/media.rb +38 -25
  105. data/lib/insika/memory_store.rb +16 -3
  106. data/lib/insika/memory_trim.rb +60 -0
  107. data/lib/insika/middleware.rb +12 -3
  108. data/lib/insika/model_metrics_store.rb +285 -0
  109. data/lib/insika/model_registry.rb +110 -0
  110. data/lib/insika/model_resolver.rb +1 -1
  111. data/lib/insika/model_selection.rb +14 -21
  112. data/lib/insika/model_visible.rb +17 -6
  113. data/lib/insika/onboarding.rb +1 -0
  114. data/lib/insika/outcome_store.rb +1 -1
  115. data/lib/insika/overlay_tool_registry.rb +30 -2
  116. data/lib/insika/pack_importer.rb +54 -9
  117. data/lib/insika/packaging.rb +14 -5
  118. data/lib/insika/pending_action_store.rb +45 -18
  119. data/lib/insika/plugin/loader.rb +4 -2
  120. data/lib/insika/prefix_fingerprint.rb +32 -27
  121. data/lib/insika/profile_source.rb +5 -0
  122. data/lib/insika/provider_error_classifier.rb +18 -3
  123. data/lib/insika/queue_policy.rb +7 -2
  124. data/lib/insika/refinement/proposer.rb +42 -17
  125. data/lib/insika/reliability.rb +12 -3
  126. data/lib/insika/router/app.rb +14 -5
  127. data/lib/insika/router/session_key.rb +35 -5
  128. data/lib/insika/safety/config.rb +20 -3
  129. data/lib/insika/safety/corpus.rb +10 -3
  130. data/lib/insika/safety/factory.rb +39 -1
  131. data/lib/insika/safety/input_guardrail.rb +21 -1
  132. data/lib/insika/safety/judge_moderator.rb +70 -0
  133. data/lib/insika/safety/output_validator.rb +29 -5
  134. data/lib/insika/safety/prompt_echo.rb +89 -0
  135. data/lib/insika/schema_guard.rb +53 -76
  136. data/lib/insika/server/app.rb +58 -7
  137. data/lib/insika/server/rack_app.rb +2 -0
  138. data/lib/insika/server/responses.rb +50 -5
  139. data/lib/insika/session_actor.rb +58 -28
  140. data/lib/insika/session_store.rb +163 -11
  141. data/lib/insika/settings_store.rb +57 -2
  142. data/lib/insika/shared_conversations.rb +305 -0
  143. data/lib/insika/skill_catalog.rb +28 -2
  144. data/lib/insika/skill_store.rb +6 -0
  145. data/lib/insika/soak/runner.rb +1 -1
  146. data/lib/insika/spoken_transcript.rb +31 -0
  147. data/lib/insika/steer_injector.rb +7 -48
  148. data/lib/insika/store.rb +29 -1
  149. data/lib/insika/stores/json_value.rb +37 -0
  150. data/lib/insika/stores/memory.rb +22 -35
  151. data/lib/insika/stores/postgres.rb +295 -0
  152. data/lib/insika/stores/sqlite.rb +257 -43
  153. data/lib/insika/stores/turn_counter.rb +25 -0
  154. data/lib/insika/studio/app.rb +1459 -163
  155. data/lib/insika/studio/assets/dist/application.css +1 -1
  156. data/lib/insika/studio/assets/dist/application.js +25 -25
  157. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  158. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  159. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  160. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  161. data/lib/insika/studio/forms.rb +189 -12
  162. data/lib/insika/studio/nav_icons.rb +3 -0
  163. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  164. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  165. data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
  166. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  167. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  168. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  169. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  170. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  171. data/lib/insika/studio/views/_agents_master.erb +8 -12
  172. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  173. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  174. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  175. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  176. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  177. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  178. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  179. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  180. data/lib/insika/studio/views/_chats_master.erb +66 -0
  181. data/lib/insika/studio/views/_customers_master.erb +37 -0
  182. data/lib/insika/studio/views/_empty.erb +15 -0
  183. data/lib/insika/studio/views/_facts_master.erb +42 -0
  184. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  185. data/lib/insika/studio/views/_identity.erb +16 -0
  186. data/lib/insika/studio/views/_kpi.erb +17 -0
  187. data/lib/insika/studio/views/_message.erb +40 -2
  188. data/lib/insika/studio/views/_model_chart.erb +82 -0
  189. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  190. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
  191. data/lib/insika/studio/views/_tool_users.erb +17 -0
  192. data/lib/insika/studio/views/_why.erb +53 -0
  193. data/lib/insika/studio/views/agent_detail.erb +17 -10
  194. data/lib/insika/studio/views/agents.erb +28 -16
  195. data/lib/insika/studio/views/approvals.erb +16 -11
  196. data/lib/insika/studio/views/artifact.erb +20 -7
  197. data/lib/insika/studio/views/artifacts.erb +19 -14
  198. data/lib/insika/studio/views/chats.erb +22 -29
  199. data/lib/insika/studio/views/customer.erb +88 -43
  200. data/lib/insika/studio/views/customers.erb +16 -26
  201. data/lib/insika/studio/views/evals.erb +49 -31
  202. data/lib/insika/studio/views/fact_detail.erb +114 -0
  203. data/lib/insika/studio/views/facts.erb +15 -116
  204. data/lib/insika/studio/views/followups.erb +63 -52
  205. data/lib/insika/studio/views/funnel.erb +104 -43
  206. data/lib/insika/studio/views/harvest.erb +85 -59
  207. data/lib/insika/studio/views/home.erb +138 -23
  208. data/lib/insika/studio/views/inbox.erb +36 -0
  209. data/lib/insika/studio/views/kits.erb +176 -0
  210. data/lib/insika/studio/views/knowledge.erb +92 -80
  211. data/lib/insika/studio/views/layout.erb +42 -11
  212. data/lib/insika/studio/views/live.erb +45 -0
  213. data/lib/insika/studio/views/mcp.erb +43 -13
  214. data/lib/insika/studio/views/models.erb +187 -0
  215. data/lib/insika/studio/views/parity.erb +38 -32
  216. data/lib/insika/studio/views/playground.erb +13 -9
  217. data/lib/insika/studio/views/refinement.erb +15 -224
  218. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  219. data/lib/insika/studio/views/session.erb +153 -118
  220. data/lib/insika/studio/views/settings.erb +86 -22
  221. data/lib/insika/studio/views/skills.erb +53 -19
  222. data/lib/insika/studio/views/system_files.erb +7 -7
  223. data/lib/insika/studio/views/task.erb +45 -15
  224. data/lib/insika/studio/views/tasks.erb +35 -18
  225. data/lib/insika/studio/views/tools.erb +62 -73
  226. data/lib/insika/task_actor.rb +4 -1
  227. data/lib/insika/task_store.rb +67 -1
  228. data/lib/insika/telemetry/pricing.rb +16 -14
  229. data/lib/insika/telemetry/recorder.rb +115 -9
  230. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  231. data/lib/insika/telemetry.rb +12 -2
  232. data/lib/insika/templates/daily-digest/README.md +13 -0
  233. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  234. data/lib/insika/testing/store_contract.rb +65 -0
  235. data/lib/insika/text_tool_call.rb +66 -0
  236. data/lib/insika/tick.rb +7 -1
  237. data/lib/insika/token_store.rb +5 -0
  238. data/lib/insika/tool_assembly.rb +21 -13
  239. data/lib/insika/tool_batch.rb +1 -1
  240. data/lib/insika/tool_catalog.rb +12 -2
  241. data/lib/insika/tool_definition.rb +82 -13
  242. data/lib/insika/tool_envelope.rb +266 -29
  243. data/lib/insika/tool_manifest.rb +1 -0
  244. data/lib/insika/tool_store.rb +12 -4
  245. data/lib/insika/tool_trace_store.rb +15 -8
  246. data/lib/insika/tool_usage_report.rb +14 -4
  247. data/lib/insika/tools/a2a_remote.rb +1 -1
  248. data/lib/insika/tools/agent_enum.rb +1 -1
  249. data/lib/insika/tools/confirm_pending.rb +112 -0
  250. data/lib/insika/tools/data_defined_tool.rb +24 -15
  251. data/lib/insika/tools/generate_image.rb +2 -2
  252. data/lib/insika/tools/load_knowledge.rb +7 -3
  253. data/lib/insika/tools/load_skill.rb +3 -3
  254. data/lib/insika/tools/present.rb +115 -0
  255. data/lib/insika/tools/remember.rb +7 -2
  256. data/lib/insika/tools/run_persona_eval.rb +9 -6
  257. data/lib/insika/tools/save_artifact.rb +16 -6
  258. data/lib/insika/tools/schedule_followup.rb +3 -3
  259. data/lib/insika/tools/stuck_signal.rb +3 -3
  260. data/lib/insika/tools/subagent.rb +5 -5
  261. data/lib/insika/tools/subagents.rb +3 -3
  262. data/lib/insika/tools/tool_search.rb +11 -7
  263. data/lib/insika/tools/tts.rb +2 -2
  264. data/lib/insika/tools/update_briefing.rb +4 -4
  265. data/lib/insika/turn_output.rb +2 -2
  266. data/lib/insika/turn_state.rb +31 -1
  267. data/lib/insika/turn_timing.rb +51 -14
  268. data/lib/insika/version.rb +1 -1
  269. data/lib/insika/wiring/graph.rb +88 -16
  270. data/lib/insika/wiring/graph_chat.rb +4 -0
  271. data/lib/insika/workflow.rb +25 -64
  272. data/lib/insika.rb +23 -3
  273. metadata +74 -13
  274. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  275. data/lib/insika/mcp_http_client.rb +0 -67
  276. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -56,8 +56,13 @@ module Insika
56
56
  config:, pending_action_store: nil, a2a: nil, provisioner: nil,
57
57
  workflow_registry: nil, onboarding: nil, profiles: nil,
58
58
  channels: nil, logger: nil, token_store: nil, outcome_store: nil,
59
- executor: nil, db_path: nil, tool_registry: nil, mcp_store: nil)
59
+ executor: nil, db_path: nil, tool_registry: nil, mcp_store: nil,
60
+ settings_store: nil)
60
61
  @command_bus = command_bus
62
+ # READ for one gate: `POST /v1/conversations/:id/seed` answers only while
63
+ # the platform setting `evals.seeding` is on. nil = no settings = seeding
64
+ # off (fail-closed — the base wiring has no SettingsStore and cannot seed).
65
+ @settings_store = settings_store
61
66
  @event_stream = event_stream
62
67
  @session_store = session_store
63
68
  @task_store = task_store
@@ -132,6 +137,8 @@ module Insika
132
137
  error_response(422, e)
133
138
  rescue Insika::NotFoundError => e
134
139
  error_response(404, e)
140
+ rescue Insika::ConflictError => e
141
+ error_response(409, e) # the write contradicts existing state (a seed on a used conversation)
135
142
  rescue Async::TimeoutError => e
136
143
  error_response(504, e) # synchronous control request exceeded the ceiling
137
144
  rescue StandardError => e
@@ -186,6 +193,8 @@ module Insika
186
193
  handle_trigger_workflow(req, name)
187
194
  in ["POST", ["v1", "responses"]]
188
195
  handle_responses(req)
196
+ in ["POST", ["v1", "conversations", id, "seed"]]
197
+ handle_seed(req, id)
189
198
  in ["POST", ["v1", "outcomes"]] if @outcome_store
190
199
  handle_record_outcome(req)
191
200
  in ["GET", ["v1", "outcomes"]] if @outcome_store
@@ -418,8 +427,11 @@ module Insika
418
427
  tenant = req_tenant(req)
419
428
  ensure_session(parsed[:user], tenant: tenant)
420
429
  payload = { agent: parsed[:agent], session_id: parsed[:user], message: parsed[:message] }
430
+ payload.merge!(parsed.slice(:shared_conversation, :user_text))
421
431
  payload[:origin] = parsed[:origin] if parsed[:origin] # declared, else absent
432
+ payload[:context] = parsed[:context] if parsed[:context]
422
433
  payload[:customer] = parsed[:customer] if parsed[:customer] # WS8: the memory scope handle
434
+ payload.merge!(parsed.slice(*Insika::SessionStore::CUSTOMER_LABELS.map(&:to_sym)))
423
435
  payload[:parts] = parsed[:parts] if parsed[:parts] # WS9: multimodal content parts
424
436
  payload[:source] = parsed[:source] if parsed[:source] # WS9: voice-marked text
425
437
  payload[:channel] = parsed[:channel] if parsed[:channel] # WS9 (saída): the channel's output capabilities
@@ -427,6 +439,40 @@ module Insika
427
439
  tenant: tenant)
428
440
  end
429
441
 
442
+ # POST /v1/conversations/:id/seed — loads the snapshot an eval case starts from
443
+ # (its `state:` — evidence ids, memory facts/notes, history, briefing fields)
444
+ # into the conversation BEFORE its first turn. Body = the state mapping, plus an
445
+ # optional `customer` (the memory scope the turns will carry) and `agent` (the
446
+ # agent the Studio lists the conversation under). Same Bearer as
447
+ # /v1/responses and the same id namespacing for a tenant, so the seeded session
448
+ # IS the one the turn continues. 200 {session}; 409 when the conversation
449
+ # already has messages (seeding a used one is a test bug, not a merge).
450
+ #
451
+ # Refused (auth error) unless the platform setting `evals.seeding` is on. A seeded
452
+ # conversation is a fabricated precondition written under the tenant token:
453
+ # right on the machine running snapshot evals, wrong in production — so the
454
+ # default is off and the doctor warns while it is on.
455
+ def handle_seed(req, id)
456
+ return auth_error(403, "seeding is off (settings evals.seeding)") unless seeding_enabled?
457
+
458
+ body = parse_body(req)
459
+ tenant = req_tenant(req)
460
+ # the conversation id as the TURN will send it in `user` — the path
461
+ # segment arrives percent-encoded (the eval transport encodes "loja:c1").
462
+ conv = URI.decode_www_form_component(id)
463
+ payload = { id: scoped_session_id(tenant, conv), state: body.except(:customer, :agent) }
464
+ (customer = Insika::Coercion.presence(body[:customer])) && (payload[:customer] = customer)
465
+ (agent = Insika::Coercion.presence(body[:agent])) && (payload[:agent] = agent)
466
+ command = Insika::Command.build(:seed_session, payload, transport: :http, tenant: tenant)
467
+ session = dispatch_with_timeout(command)
468
+ json_response(200, { session: session.to_h })
469
+ end
470
+
471
+ def seeding_enabled?
472
+ settings = @settings_store&.get
473
+ settings.is_a?(Hash) && (settings["evals"] || {})["seeding"] == true
474
+ end
475
+
430
476
  # POST /v1/agents — provisions (upserts) an agent from a standardized
431
477
  # PACK. Same Bearer as /v1/responses (gateway_token,
432
478
  # fail-closed). The consumer (GatewayClient/ProvisionStore) sends the pack as
@@ -546,6 +592,10 @@ module Insika
546
592
  profile = @profiles.fetch(id)
547
593
  raise Insika::NotFoundError, "agent not found: #{id}" if profile.nil?
548
594
 
595
+ # The agent as its turns see it: a kit's tools are reachable for real, so
596
+ # the capability view and the eval swap list must count them.
597
+ profile = Insika::Kits.apply(profile, @settings_store.kits) if @settings_store.respond_to?(:kits)
598
+
549
599
  allow = profile.tools_allow
550
600
  deny = Array(profile.tools_deny).map(&:to_s)
551
601
  payload = {
@@ -567,11 +617,7 @@ module Insika
567
617
  def side_effect_tools(profile)
568
618
  return nil unless @tool_registry
569
619
 
570
- allowed = if profile.tools_allow.nil? then Array(@tool_registry.names)
571
- else Array(profile.tools_allow).map(&:to_s)
572
- end
573
- denied = Array(profile.tools_deny).map(&:to_s)
574
- (allowed - denied).select { |name| @tool_registry.side_effect?(name) }.sort
620
+ Insika::Evals::EvalProfile.side_effect_tools(profile, @tool_registry)
575
621
  end
576
622
 
577
623
  # `/v1` only — `/a2a` is versioned by its own JSON-RPC spec and a channel's
@@ -625,6 +671,7 @@ TENANT_SURFACES = [
625
671
  ["POST", ["v1", "sessions"]],
626
672
  ["POST", ["v1", "messages"]],
627
673
  ["POST", ["v1", "responses"]],
674
+ ["POST", ["v1", "conversations", nil, "seed"]],
628
675
  ["POST", ["v1", "outcomes"]],
629
676
  ["GET", ["v1", "outcomes"]],
630
677
  ["POST", ["v1", "workflows", nil]],
@@ -1009,7 +1056,11 @@ TENANT_SURFACES = [
1009
1056
  payload[:session_id] = scoped_session_id(tenant, payload[:session_id]) if payload[:session_id]
1010
1057
  end
1011
1058
  command = Insika::Command.build(:send_message, payload, transport: transport, tenant: tenant)
1012
- subscription = @event_stream.subscribe
1059
+ # Filtered by session from the start: until `bind` below, an unfiltered
1060
+ # subscription takes EVERY turn's events, and under load the other
1061
+ # sessions' deltas fill its 1000-event cap during the dispatch — the
1062
+ # stream then ends with no terminal event. nil session = unfiltered, as before.
1063
+ subscription = @event_stream.subscribe(session_id: payload[:session_id])
1013
1064
  result =
1014
1065
  begin
1015
1066
  @command_bus.dispatch(command)
@@ -80,6 +80,8 @@ module Insika
80
80
  outcome_store: @graph.outcome_store,
81
81
  # GET/PUT/DELETE /v1/mcp[/:name] — the config surface.
82
82
  mcp_store: @rt.component(:mcp_store),
83
+ # POST /v1/conversations/:id/seed answers only while `evals.seeding` is on.
84
+ settings_store: @rt.component(:settings_store),
83
85
  # a 500's error_ref must be findable in the process log.
84
86
  logger: $stdout
85
87
  )
@@ -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
@@ -95,7 +109,7 @@ module Insika
95
109
  end
96
110
 
97
111
  # Turn Event -> OpenAI Responses SSE frame | nil (event with no
98
- # counterpart: :task_started, :tool_result, :skill_activated, ...).
112
+ # counterpart: :task_started, :skill_activated, ...).
99
113
  # Terminal events emit the final frame + `[DONE]` (close the stream).
100
114
  def frame_for(event)
101
115
  case event.type
@@ -103,9 +117,27 @@ module Insika
103
117
  sse("response.output_text.delta",
104
118
  { type: "response.output_text.delta", delta: event.data[:delta].to_s })
105
119
  when :tool_call
106
- sse("response.output_item.added",
107
- { type: "response.output_item.added",
108
- item: { type: "function_call", name: event.data[:name].to_s } })
120
+ item = { type: "function_call", name: event.data[:name].to_s }
121
+ # The provider's call id, as the OpenAI item carries it: `added` and `done`
122
+ # are TWO frames of ONE call, and a consumer pairs them by this. Absent when
123
+ # the emitter had none.
124
+ (id = event.data[:call_id]) && (item[:call_id] = id.to_s)
125
+ # The call's arguments, as the OpenAI item carries them (a JSON string).
126
+ # Absent when the emitter had none to report.
127
+ (args = event.data[:arguments]) && (item[:arguments] = args.is_a?(String) ? args : JSON.generate(args))
128
+ sse("response.output_item.added", { type: "response.output_item.added", item: item })
129
+ when :tool_result
130
+ # How the call ENDED — ok / error / blocked (+ the gate that held it). Until
131
+ # this frame the stream carried tool NAMES only, so nothing outside the
132
+ # process could tell "the tool ran" from "a gate refused it" or "it errored".
133
+ # The result body itself stays inside: it is the model's input, not the
134
+ # consumer's answer. Same `call_id` as the `added` frame — a consumer that
135
+ # counts calls counts `added`, not both.
136
+ item = { type: "function_call", name: event.data[:name].to_s,
137
+ status: (event.data[:status] || "ok").to_s }
138
+ (id = event.data[:call_id]) && (item[:call_id] = id.to_s)
139
+ (gate = event.data[:gate]) && (item[:gate] = gate.to_s)
140
+ sse("response.output_item.done", { type: "response.output_item.done", item: item })
109
141
  when :task_completed
110
142
  completed(event) + done
111
143
  when :task_failed
@@ -149,6 +181,15 @@ module Insika
149
181
  # Studio + the trace. Explicit (not a fall-through) to keep the closed
150
182
  # catalog honest.
151
183
  nil
184
+ when :ui
185
+ # A presentation tool's selection: what the customer should SEE alongside
186
+ # the answer. Namespaced like `insika.intermediate` — no OpenAI Responses
187
+ # counterpart, unknown to strict clients, safely ignored. `items` are the
188
+ # cards the engine validated and joined (id/url/caption); `dropped` is what
189
+ # the model asked for and could not be shown, with the reason.
190
+ sse("insika.ui", { type: "insika.ui", component: event.data[:component].to_s,
191
+ title: event.data[:title], items: Array(event.data[:items]),
192
+ count: event.data[:count].to_i, dropped: Array(event.data[:dropped]) })
152
193
  when :ttft
153
194
  # the live TTFB signal (WS6, INSIKA_TURN_TIMING opt-in): the provider's
154
195
  # ms-to-first-token, emitted when the first content chunk arrives.
@@ -170,7 +211,11 @@ module Insika
170
211
  # `model` travels alongside usage in the event; in the OpenAI shape it is a sibling of
171
212
  # usage (pure tokens in usage).
172
213
  model = usage[:model] || usage["model"]
173
- response[:usage] = usage.reject { |k, _| k.to_s == "model" }
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
174
219
  response[:model] = model if model
175
220
  end
176
221
  # Opt-in per-turn latency breakdown (INSIKA_TURN_TIMING). Absent
@@ -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
- def initialize(session_id:, executor:, parent: Async::Task.current)
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 turn currently sitting at the door: created and :queued, but not yet
32
- # released to run. `collect` merges into THIS one. nil whenever there is
33
- # nothing mergeable — which is the common case and the safe default.
34
- @pending = nil
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) opens the debounce window for this turn; nil or a
42
- # policy without a window behaves exactly as before — dequeued and run at once.
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 = @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
- def collecting? = !@pending.nil?
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 — the loop blocks forever on
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 debounce window. Sleeps on the LOOP's fiber, never on the
125
- # request's, so the POST is acked immediately and the platform does not retry.
126
- # Returns the task to run (re-read from the store when fragments merged into it,
127
- # since the in-memory Task is a frozen snapshot of an older message).
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
- return task unless policy&.debounce?
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 = @pending[:count]
135
- arrivals = @pending[:arrivals]
160
+ wait_for_quiet(policy, pending) if policy&.debounce?
161
+ merged = pending[:count]
136
162
  ensure
137
- # The window is closed BEFORE the turn runs, under every exit path: a
138
- # `collect` that slipped in here would append to a task about to be read.
139
- @pending = nil
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 = @pending[:version]
187
+ mark = pending[:version]
158
188
  Async::Task.current.sleep(quiet)
159
- break if @pending[:version] == mark # a full slice of silence
189
+ break if pending[:version] == mark # a full slice of silence
160
190
  break if monotonic >= deadline
161
191
  end
162
192
  end
@@ -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
- @store.set(SCOPE, key, record)
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
- def append_messages(id, messages)
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
- @store.set(SCOPE, key_for(id), record)
105
+ write(record)
88
106
  to_session(record)
89
107
  end
90
108
 
@@ -94,21 +112,24 @@ module Insika
94
112
  record = fetch!(id)
95
113
  record["vars"] = record["vars"].merge(deep_stringify(vars))
96
114
  record["updated_at"] = timestamp
97
- @store.set(SCOPE, key_for(id), record)
115
+ write(record)
98
116
  to_session(record)
99
117
  end
100
118
 
101
119
  # appends this turn's evidence (ids + ungrounded delta) to the
102
120
  # session record. RMW like append_messages — the SessionActor serializes
103
121
  # same-session turns; the copy is in the method comment.
104
- def append_evidence(id, ids:, ungrounded:)
122
+ def append_evidence(id, ids:, ungrounded:, cards: [])
105
123
  record = fetch!(id)
106
124
  ev = record["evidence"] ||= { "ids" => [], "ungrounded" => 0 }
107
125
  fresh = (ev["ids"] + Array(ids).map(&:to_s).reject(&:empty?)).uniq.last(EvidenceLedger::MAX_IDS)
108
126
  ev["ids"] = fresh
109
127
  ev["ungrounded"] = ev["ungrounded"].to_i + ungrounded.to_i
128
+ # the cards those ids came with (one per id, newest wins) — what a
129
+ # presentation tool joins on in a LATER turn. Absent until a card arrives.
130
+ ev["cards"] = EvidenceLedger.merge_cards(ev["cards"], cards) unless Array(cards).empty?
110
131
  record["updated_at"] = timestamp
111
- @store.set(SCOPE, key_for(id), record)
132
+ write(record)
112
133
  to_session(record)
113
134
  end
114
135
 
@@ -135,7 +156,7 @@ module Insika
135
156
  value = Coercion.presence(Coercion.utf8(value.to_s))
136
157
  value ? briefing["fields"][field.to_s] = value : briefing["fields"].delete(field.to_s)
137
158
  record["updated_at"] = timestamp
138
- @store.set(SCOPE, key_for(id), record)
159
+ write(record)
139
160
  to_session(record)
140
161
  end
141
162
 
@@ -146,7 +167,7 @@ module Insika
146
167
  briefing = record["briefing"] ||= { "fields" => {}, "next_step" => nil }
147
168
  briefing["next_step"] = Coercion.presence(Coercion.utf8(text.to_s))
148
169
  record["updated_at"] = timestamp
149
- @store.set(SCOPE, key_for(id), record)
170
+ write(record)
150
171
  to_session(record)
151
172
  end
152
173
 
@@ -161,31 +182,130 @@ module Insika
161
182
  # read/mutate/set path suspends, so no other writer can interleave
162
183
  # mid-RMW. The LLM call that produced the summary happened BEFORE this
163
184
  # method; only the plain write lives here.
164
- def set_compaction(id, summary:, upto:, model: nil)
185
+ def set_compaction(id, summary: nil, upto:, model: nil, native: nil)
165
186
  record = fetch!(id)
166
187
  current = record["compaction"]
167
188
  upto = Integer(upto)
168
189
  return to_session(record) if current && upto <= current["upto"].to_i
169
190
 
170
191
  record["compaction"] = {
171
- "summary" => Coercion.utf8(summary.to_s),
192
+ "summary" => native ? nil : Coercion.utf8(summary.to_s),
193
+ "native" => native && Coercion.deep_stringify(native),
172
194
  "upto" => upto,
173
195
  "runs" => (current ? current["runs"].to_i : 0) + 1,
174
196
  "model" => Coercion.presence(model.to_s),
175
197
  "at" => timestamp
176
198
  }.compact
177
199
  record["updated_at"] = timestamp
178
- @store.set(SCOPE, key_for(id), record)
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)
179
229
  to_session(record)
180
230
  end
181
231
 
182
232
  # -> bool (delegates to the backend: false for a nonexistent id)
183
233
  def delete(id)
234
+ @store.delete(STATS_SCOPE, key_for(id))
184
235
  @store.delete(SCOPE, key_for(id))
185
236
  end
186
237
 
187
238
  # -> enumerates ids without the "session:" prefix. Without a block,
188
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
+
189
309
  def each_id
190
310
  return enum_for(:each_id) unless block_given?
191
311
 
@@ -196,6 +316,38 @@ module Insika
196
316
 
197
317
  private
198
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
+
199
351
  def key_for(id)
200
352
  "#{KEY_PREFIX}#{id}"
201
353
  end