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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +312 -2
- data/README.md +10 -6
- data/bin/insika +4 -1
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +142 -18
- data/docs/API.md +73 -0
- data/docs/ARCHITECTURE.md +56 -50
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CHANNELS.md +19 -2
- data/docs/CONTEXT.md +96 -27
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +70 -13
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +129 -10
- data/docs/FACTS.md +12 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +14 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +164 -28
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +26 -14
- data/docs/RELEASING.md +9 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +34 -1
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +238 -42
- data/docs/operate.md +3 -0
- data/docs/prompts/ADD-TOOL.md +12 -2
- data/docs/prompts/DIAGNOSE-TURN.md +3 -0
- data/docs/prompts/GO-LIVE.md +3 -1
- data/lib/insika/agent_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +99 -17
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/channels/web/widget.js +33 -0
- data/lib/insika/channels/web.rb +5 -2
- data/lib/insika/chat_builder.rb +124 -28
- 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_distillation.rb +5 -8
- data/lib/insika/commands/run_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +128 -0
- 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/builder.rb +29 -9
- data/lib/insika/context/priority.rb +5 -0
- data/lib/insika/context/provider.rb +7 -2
- data/lib/insika/context/providers/briefing.rb +11 -8
- data/lib/insika/context/providers/fence_notice.rb +27 -0
- data/lib/insika/context/providers/knowledge.rb +32 -6
- data/lib/insika/context/providers/memory.rb +61 -8
- 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 +46 -6
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +142 -4
- data/lib/insika/dsl/runtime.rb +37 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +19 -5
- data/lib/insika/edge_limiter.rb +27 -16
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/errors.rb +1 -0
- data/lib/insika/evals/assertions.rb +259 -18
- data/lib/insika/evals/golden.rb +230 -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 +64 -4
- data/lib/insika/evals/simulator.rb +21 -3
- data/lib/insika/evals/transport.rb +317 -15
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +105 -17
- data/lib/insika/executor.rb +698 -164
- data/lib/insika/fence.rb +96 -0
- data/lib/insika/golden_store.rb +3 -0
- 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 +37 -3
- data/lib/insika/mcp_tool_registry.rb +76 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +16 -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 +30 -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/prefix_fingerprint.rb +32 -27
- data/lib/insika/profile_source.rb +5 -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 +58 -7
- data/lib/insika/server/rack_app.rb +2 -0
- data/lib/insika/server/responses.rb +50 -5
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +163 -11
- data/lib/insika/settings_store.rb +57 -2
- 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/spoken_transcript.rb +31 -0
- 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 +1459 -163
- 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 +189 -12
- 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 +83 -243
- 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} +38 -11
- 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 +153 -118
- 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 +115 -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_assembly.rb +21 -13
- data/lib/insika/tool_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +82 -13
- data/lib/insika/tool_envelope.rb +266 -29
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +12 -4
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +14 -4
- 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 +24 -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 +115 -0
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +9 -6
- 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 +11 -7
- 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 +31 -1
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +88 -16
- data/lib/insika/wiring/graph_chat.rb +4 -0
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +23 -3
- metadata +74 -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
data/lib/insika/server/app.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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, :
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|