insika 0.2.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +325 -17
- data/README.md +52 -11
- data/bin/insika +895 -8
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +229 -163
- data/docs/API.md +56 -0
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +111 -21
- data/docs/CONTEXT.md +132 -19
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +75 -4
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +146 -2
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +116 -0
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +16 -10
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +65 -6
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +17 -12
- data/docs/RELEASING.md +27 -0
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +6 -6
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +79 -2
- data/docs/SKILLS.md +13 -4
- data/docs/SOAK.md +127 -0
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +220 -27
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/_includes/head_custom.html +5 -0
- data/docs/_includes/title.html +13 -0
- data/docs/_sass/color_schemes/insika.scss +32 -0
- data/docs/_sass/custom/custom.scss +199 -0
- data/docs/_sass/custom/setup.scss +26 -0
- data/docs/assets/img/favicon.svg +7 -0
- data/docs/assets/img/insika-mark.svg +7 -0
- data/docs/core-concepts.md +21 -0
- data/docs/domain.md +115 -0
- data/docs/improve.md +20 -0
- data/docs/index.md +9 -6
- data/docs/integrate.md +20 -0
- data/docs/operate.md +13 -6
- data/docs/prompts/ADD-TOOL.md +118 -0
- data/docs/prompts/DIAGNOSE-TURN.md +65 -0
- data/docs/prompts/GO-LIVE.md +138 -0
- data/docs/prompts/RUN-EXAMPLES.md +70 -0
- data/docs/reference.md +19 -0
- data/docs/ship.md +10 -2
- data/docs/start-here.md +18 -0
- data/lib/insika/agent_profile.rb +202 -10
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +119 -14
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +105 -2
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_artifact.rb +35 -0
- data/lib/insika/commands/delete_concept.rb +34 -0
- data/lib/insika/commands/delete_mcp.rb +6 -2
- data/lib/insika/commands/delete_tenant_data.rb +107 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/restore_concept.rb +34 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/seed_demo_data.rb +31 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +5 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +57 -23
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- data/lib/insika/cron.rb +189 -0
- data/lib/insika/demo/agent_attrs.rb +43 -0
- data/lib/insika/demo/golden_cases.rb +81 -0
- data/lib/insika/demo/seeder.rb +336 -0
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +889 -11
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +69 -82
- data/lib/insika/dsl/server_boot.rb +92 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +186 -5
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +25 -3
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +29 -6
- data/lib/insika/evals/persona.rb +98 -0
- data/lib/insika/evals/runner.rb +9 -0
- data/lib/insika/evals/simulator.rb +225 -0
- data/lib/insika/evals/transport.rb +83 -1
- data/lib/insika/event_stream.rb +10 -0
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +869 -76
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +198 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +17 -2
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/knowledge.rb +680 -0
- data/lib/insika/knowledge_store.rb +140 -0
- data/lib/insika/mcp_client.rb +94 -0
- data/lib/insika/mcp_json.rb +74 -0
- data/lib/insika/mcp_live_tool.rb +43 -0
- data/lib/insika/mcp_store.rb +98 -26
- data/lib/insika/mcp_tool_ingestor.rb +30 -8
- data/lib/insika/mcp_tool_registry.rb +100 -0
- data/lib/insika/media.rb +382 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +7 -2
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +22 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +37 -17
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +29 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +316 -0
- data/lib/insika/router/app.rb +157 -0
- data/lib/insika/router/backend_pool.rb +98 -0
- data/lib/insika/router/hash_ring.rb +55 -0
- data/lib/insika/router/proxy_body.rb +34 -0
- data/lib/insika/router/session_key.rb +54 -0
- data/lib/insika/router.rb +18 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- data/lib/insika/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +247 -17
- data/lib/insika/server/rack_app.rb +21 -1
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +16 -0
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/steer_injector.rb +21 -10
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1437 -54
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +23 -23
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +308 -10
- data/lib/insika/studio/nav_icons.rb +19 -1
- data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
- data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
- data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
- data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
- data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
- data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
- data/lib/insika/studio/views/_agents_master.erb +44 -0
- data/lib/insika/studio/views/_message.erb +49 -32
- data/lib/insika/studio/views/agent_detail.erb +62 -278
- data/lib/insika/studio/views/agents.erb +71 -54
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +6 -3
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +106 -66
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +15 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +233 -124
- data/lib/insika/studio/views/settings.erb +41 -1
- data/lib/insika/studio/views/skills.erb +1 -2
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +24 -10
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/templates/browser-agent/README.md +36 -0
- data/lib/insika/templates/browser-agent/agent.rb +49 -0
- data/lib/insika/templates/daily-digest/README.md +38 -0
- data/lib/insika/templates/daily-digest/agent.rb +77 -0
- data/lib/insika/templates/repo-explorer/README.md +36 -0
- data/lib/insika/templates/repo-explorer/agent.rb +45 -0
- data/lib/insika/templates/research-analyst/README.md +26 -0
- data/lib/insika/templates/research-analyst/agent.rb +58 -0
- data/lib/insika/templates/review-panel/README.md +20 -0
- data/lib/insika/templates/review-panel/agent.rb +50 -0
- data/lib/insika/templates/travel-planner/README.md +35 -0
- data/lib/insika/templates/travel-planner/agent.rb +87 -0
- data/lib/insika/templates.rb +112 -0
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +47 -11
- data/lib/insika/timezone.rb +45 -0
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +89 -0
- data/lib/insika/tools/load_knowledge.rb +74 -0
- data/lib/insika/tools/run_persona_eval.rb +328 -0
- data/lib/insika/tools/save_artifact.rb +95 -0
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +1 -1
- data/lib/insika/turn_state.rb +49 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +472 -13
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +149 -3
- metadata +181 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# The report destination: one record per run, no versioning, the listing
|
|
8
|
+
# IS the history. A store, not a CMS.
|
|
9
|
+
#
|
|
10
|
+
# The tenant is a BINDING of the row — inherited from the agent that saved
|
|
11
|
+
# the artifact (the tool reads it from the turn context, never from the
|
|
12
|
+
# model), so a purge is a tenant-prefix scan and store A's report can never
|
|
13
|
+
# appear in, or be linked from, store B.
|
|
14
|
+
#
|
|
15
|
+
# Record key: "<tenant>:<agent>:<id>" — per-tenant / per-agent scans are
|
|
16
|
+
# prefixes; the id is the LAST segment, so a find is a suffix match (the
|
|
17
|
+
# followup_store.rb idiom — there is no stable prefix for an id alone).
|
|
18
|
+
# Blank tenant -> the literal "platform" (the outcome_store.rb rule, so the
|
|
19
|
+
# purge prefix scans line up).
|
|
20
|
+
class ArtifactStore
|
|
21
|
+
SCOPE = "artifacts"
|
|
22
|
+
|
|
23
|
+
# The mime allowlist — a page, not an attachment: no binaries, no uploads.
|
|
24
|
+
MIMES = %w[text/html text/markdown image/svg+xml].freeze
|
|
25
|
+
|
|
26
|
+
# The size cap on `content` (INSIKA_ARTIFACT_MAX_BYTES; the audio-message
|
|
27
|
+
# precedent is 1 MB — an artifact is a page, not an attachment).
|
|
28
|
+
DEFAULT_MAX_BYTES = 1_000_000
|
|
29
|
+
TITLE_MAX = 200
|
|
30
|
+
|
|
31
|
+
Record = Data.define(:id, :tenant, :agent, :task_id, :title, :mime,
|
|
32
|
+
:content, :created_at)
|
|
33
|
+
|
|
34
|
+
def initialize(store:)
|
|
35
|
+
@store = store
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# -> Record. Validates the mime allowlist, a non-empty title (<= 200
|
|
39
|
+
# chars) and content within `max_bytes` — ValidationError otherwise (the
|
|
40
|
+
# tool returns it to the model as `{ error: }`).
|
|
41
|
+
def create(tenant:, agent:, task_id:, title:, mime:, content:,
|
|
42
|
+
id: SecureRandom.uuid, now: Time.now.utc, max_bytes: DEFAULT_MAX_BYTES)
|
|
43
|
+
mime = "text/html" if mime.to_s.empty?
|
|
44
|
+
raise Insika::ValidationError, "mime must be one of #{MIMES.join(', ')}, got #{mime.inspect}" unless MIMES.include?(mime.to_s)
|
|
45
|
+
|
|
46
|
+
title = title.to_s
|
|
47
|
+
raise Insika::ValidationError, "title is required (1..#{TITLE_MAX} chars)" if title.strip.empty? || title.length > TITLE_MAX
|
|
48
|
+
|
|
49
|
+
content = content.to_s
|
|
50
|
+
raise Insika::ValidationError, "content is required" if content.empty?
|
|
51
|
+
raise Insika::ValidationError, "content exceeds #{max_bytes} bytes (#{content.bytesize})" if content.bytesize > max_bytes
|
|
52
|
+
|
|
53
|
+
record = { "id" => id.to_s, "tenant" => tenant_id(tenant), "agent" => agent.to_s,
|
|
54
|
+
"task_id" => task_id.to_s, "title" => title, "mime" => mime.to_s,
|
|
55
|
+
"content" => content, "created_at" => now.iso8601 }
|
|
56
|
+
@store.set(SCOPE, key(record), record)
|
|
57
|
+
to_record(record)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# -> Record | nil. Suffix match over the scope's keys (no stable prefix
|
|
61
|
+
# for an id alone — the followup_store.rb idiom).
|
|
62
|
+
def find(id)
|
|
63
|
+
key = @store.list(SCOPE).find { |k| k.end_with?(":#{id}") }
|
|
64
|
+
key && to_record(@store.get(SCOPE, key))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# -> [Record] — one agent's artifacts, newest first (the listing IS the
|
|
68
|
+
# history; the Studio tab's read).
|
|
69
|
+
def for_agent(tenant:, agent:)
|
|
70
|
+
prefix = "#{tenant_id(tenant)}:#{agent}:"
|
|
71
|
+
@store.list(SCOPE).filter_map do |k|
|
|
72
|
+
next unless k.start_with?(prefix)
|
|
73
|
+
|
|
74
|
+
to_record(@store.get(SCOPE, k))
|
|
75
|
+
end.sort_by { |r| [r.created_at.to_s, r.id] }.reverse
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# -> [Record] — EVERY agent's artifacts for the tenant, newest first (same
|
|
79
|
+
# prefix scan as #purge, but reads instead of deletes). Needs the CALLER
|
|
80
|
+
# to know the right tenant string — see #all below for the Studio, which
|
|
81
|
+
# doesn't.
|
|
82
|
+
def for_tenant(tenant:)
|
|
83
|
+
prefix = "#{tenant_id(tenant)}:"
|
|
84
|
+
@store.list(SCOPE).filter_map do |k|
|
|
85
|
+
next unless k.start_with?(prefix)
|
|
86
|
+
|
|
87
|
+
to_record(@store.get(SCOPE, k))
|
|
88
|
+
end.sort_by { |r| [r.created_at.to_s, r.id] }.reverse
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# -> [Record] — every artifact in the store, optionally narrowed to one
|
|
92
|
+
# agent, with NO tenant guess. For the Studio only: it is already
|
|
93
|
+
# operator-only (sees every tenant's agents), and unlike #for_agent/
|
|
94
|
+
# #for_tenant (hot, tenant-scoped runtime paths that a real multi-tenant
|
|
95
|
+
# caller uses because it KNOWS its own tenant) the Studio often does not
|
|
96
|
+
# — `save_artifact` binds tenant from the turn (`agent` when the Playground
|
|
97
|
+
# dispatched it, `"platform"` for a plain single-tenant API turn), so a
|
|
98
|
+
# page guessing one fixed string is wrong for the other half of the time.
|
|
99
|
+
# Filtering by the record's own `agent` field sidesteps the guess entirely.
|
|
100
|
+
def all(agent: nil)
|
|
101
|
+
@store.list(SCOPE).filter_map do |k|
|
|
102
|
+
record = to_record(@store.get(SCOPE, k))
|
|
103
|
+
next if record.nil?
|
|
104
|
+
next if agent && record.agent != agent.to_s
|
|
105
|
+
|
|
106
|
+
record
|
|
107
|
+
end.sort_by { |r| [r.created_at.to_s, r.id] }.reverse
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# -> bool (did it exist?).
|
|
111
|
+
def delete(id)
|
|
112
|
+
key = @store.list(SCOPE).find { |k| k.end_with?(":#{id}") }
|
|
113
|
+
key ? @store.delete(SCOPE, key) : false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# -> count removed. The tenant-erasure reach — one tenant's artifacts die
|
|
117
|
+
# with it, never a neighbour's.
|
|
118
|
+
def purge(tenant:)
|
|
119
|
+
prefix = "#{tenant_id(tenant)}:"
|
|
120
|
+
keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) }
|
|
121
|
+
keys.each { |k| @store.delete(SCOPE, k) }
|
|
122
|
+
keys.size
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# -> count removed. The retention knob's reach (`artifact_ttl_days`) —
|
|
126
|
+
# the guarantee that PII inside a report expires even though no reader
|
|
127
|
+
# can see inside the opaque HTML.
|
|
128
|
+
def delete_older_than(time)
|
|
129
|
+
cutoff = time.utc.iso8601
|
|
130
|
+
removed = 0
|
|
131
|
+
@store.list(SCOPE).each do |k|
|
|
132
|
+
record = @store.get(SCOPE, k)
|
|
133
|
+
next unless record && record["created_at"].to_s < cutoff
|
|
134
|
+
|
|
135
|
+
@store.delete(SCOPE, k)
|
|
136
|
+
removed += 1
|
|
137
|
+
end
|
|
138
|
+
removed
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def key(record)
|
|
144
|
+
"#{record['tenant']}:#{record['agent']}:#{record['id']}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def tenant_id(tenant)
|
|
148
|
+
t = tenant.to_s
|
|
149
|
+
t.empty? ? "platform" : t
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def to_record(rec)
|
|
153
|
+
return nil if rec.nil?
|
|
154
|
+
|
|
155
|
+
Record.new(id: rec["id"], tenant: rec["tenant"], agent: rec["agent"],
|
|
156
|
+
task_id: rec["task_id"], title: rec["title"], mime: rec["mime"],
|
|
157
|
+
content: rec["content"], created_at: rec["created_at"])
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# — pure: confirmed answer text -> the balloons a progressive
|
|
5
|
+
# channel should POST. Paragraphs are the unit (`\n\n` is the seam E1 cares
|
|
6
|
+
# about); the sentence split fires only as a SOFT cap for one paragraph that
|
|
7
|
+
# grew past `SENTENCE_AFTER`. It never splits inside a fenced code block.
|
|
8
|
+
#
|
|
9
|
+
# No events, no outbox, no `:intermediate` — if it is handed loop narration
|
|
10
|
+
# that is a caller bug; the splitter has no way to tell.
|
|
11
|
+
module BalloonSplitter
|
|
12
|
+
# Soft cap after which a single paragraph is split on sentences. WhatsApp's
|
|
13
|
+
# hard cap is ~4096; this is a readability cap, not a platform one.
|
|
14
|
+
SENTENCE_AFTER = 600
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# -> [String], at least one when `text` is non-blank, else [].
|
|
19
|
+
def split(text)
|
|
20
|
+
raw = Insika::Coercion.presence(text)
|
|
21
|
+
return [] if raw.nil?
|
|
22
|
+
|
|
23
|
+
paragraphs = raw.split(PARAGRAPH_BREAK)
|
|
24
|
+
balloons = group_fenced(paragraphs)
|
|
25
|
+
balloons.flat_map { |balloon| split_long(balloon) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A fenced code block (` ``` ` … ` ``` `) spans paragraphs and stays ONE
|
|
29
|
+
# balloon — newlines inside it are code, not a seam. A fence that never
|
|
30
|
+
# closes still ends as one balloon (garbage in, one balloon out).
|
|
31
|
+
def group_fenced(paragraphs)
|
|
32
|
+
balloons = []
|
|
33
|
+
fence = nil
|
|
34
|
+
paragraphs.each do |para|
|
|
35
|
+
piece = para.strip
|
|
36
|
+
next if piece.empty?
|
|
37
|
+
|
|
38
|
+
if fence
|
|
39
|
+
fence << piece
|
|
40
|
+
# a line starting with ``` closes the block, wherever it lands — the
|
|
41
|
+
# close fence of ordinary markdown shares a paragraph with its code.
|
|
42
|
+
if closes_fence?(piece)
|
|
43
|
+
balloons << fence.join("\n\n")
|
|
44
|
+
fence = nil
|
|
45
|
+
end
|
|
46
|
+
elsif piece.lines.first.to_s.start_with?(FENCE)
|
|
47
|
+
fence = [piece]
|
|
48
|
+
# one paragraph may open AND close the block (```ruby\nx = 1\n```) —
|
|
49
|
+
# the closer is a later line of the same piece.
|
|
50
|
+
if closes_fence?(piece, opener: true)
|
|
51
|
+
balloons << fence.join("\n\n")
|
|
52
|
+
fence = nil
|
|
53
|
+
end
|
|
54
|
+
else
|
|
55
|
+
balloons << piece
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
balloons << fence.join("\n\n") if fence
|
|
59
|
+
balloons
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Does this paragraph close an open fence? Any line starting with the fence
|
|
63
|
+
# marker counts. `opener: true` skips the FIRST line — the line that opened
|
|
64
|
+
# the block, which cannot be its own closer.
|
|
65
|
+
def closes_fence?(piece, opener: false)
|
|
66
|
+
lines = piece.lines
|
|
67
|
+
lines[(opener ? 1 : 0)..].any? { |line| line.start_with?(FENCE) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The soft cap: a paragraph longer than SENTENCE_AFTER splits after sentence
|
|
71
|
+
# closers followed by whitespace — which is why `3.9s` and `www.` survive
|
|
72
|
+
# (their period is not followed by whitespace) and a decimal does too. A
|
|
73
|
+
# leftover without a closer stays one balloon.
|
|
74
|
+
#
|
|
75
|
+
# Sentences are then RE-GROUPED into ~SENTENCE_AFTER blocks (
|
|
76
|
+
# the split is a cap, not a mandate). Without the regroup, a 680-char
|
|
77
|
+
# paragraph of short sentences would atomize into one balloon per sentence —
|
|
78
|
+
# a paragraph that was ONE bubble becomes 40 WhatsApp messages for no
|
|
79
|
+
# latency win.
|
|
80
|
+
def split_long(balloon)
|
|
81
|
+
return [balloon] if balloon.length <= SENTENCE_AFTER
|
|
82
|
+
|
|
83
|
+
parts = balloon.split(SENTENCE_BOUNDARY).map(&:strip).reject(&:empty?)
|
|
84
|
+
return [balloon] if parts.empty?
|
|
85
|
+
|
|
86
|
+
parts.each_with_object([+""]) do |sentence, balloons|
|
|
87
|
+
if balloons.last.empty?
|
|
88
|
+
balloons.last << sentence
|
|
89
|
+
elsif balloons.last.length + sentence.length + 1 <= SENTENCE_AFTER
|
|
90
|
+
balloons.last << " #{sentence}"
|
|
91
|
+
else
|
|
92
|
+
balloons << +sentence
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
PARAGRAPH_BREAK = /\n\s*\n+/
|
|
98
|
+
FENCE = "```"
|
|
99
|
+
SENTENCE_BOUNDARY = /(?<=[.!?…])\s+(?=\S)/
|
|
100
|
+
private_constant :PARAGRAPH_BREAK, :FENCE, :SENTENCE_BOUNDARY
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/insika/budget_ledger.rb
CHANGED
|
@@ -18,9 +18,11 @@ module Insika
|
|
|
18
18
|
# BEGIN IMMEDIATE: no lost update. No enforcement here — the middleware is
|
|
19
19
|
# WS2; this file is only correct accounting.
|
|
20
20
|
#
|
|
21
|
-
# Growth is bounded
|
|
22
|
-
# day
|
|
23
|
-
#
|
|
21
|
+
# Growth is bounded on the hot path AND swept: each `add` deletes the (id)'s
|
|
22
|
+
# previous day and previous month cell (an ACTIVE scope holds at most 4 keys),
|
|
23
|
+
# and `prune` — the daily sweep on the tick — drops everything else: the cells
|
|
24
|
+
# of an id that went idle for more than one window and the alert flags, which
|
|
25
|
+
# the hot path never collects.
|
|
24
26
|
class BudgetLedger
|
|
25
27
|
SCOPE = "budget_counters"
|
|
26
28
|
ALERT_SCOPE = "budget_alerts"
|
|
@@ -87,6 +89,25 @@ module Insika
|
|
|
87
89
|
!@store.get(ALERT_SCOPE, alert_key(cell_id(tenant, agent), window, now, level)).nil?
|
|
88
90
|
end
|
|
89
91
|
|
|
92
|
+
# The GC of both scopes: drops every cell whose window is not the CURRENT
|
|
93
|
+
# one. `add`'s two deletes only reach the IMMEDIATELY previous day/month, so
|
|
94
|
+
# a scope that goes idle for two days leaves its counter behind forever, and
|
|
95
|
+
# the alert flags were never collected at all — unbounded row growth the WS8
|
|
96
|
+
# retention sweep does not reach (that one is age-based over CONTENT; these
|
|
97
|
+
# are counters with no timestamp). Every key of both scopes ENDS in its
|
|
98
|
+
# bucket, so one rule sweeps both. -> count of cells removed.
|
|
99
|
+
def prune(now: Time.now)
|
|
100
|
+
day = daily_bucket(now)
|
|
101
|
+
month = month_bucket(now)
|
|
102
|
+
@store.transaction do
|
|
103
|
+
[SCOPE, ALERT_SCOPE].sum do |scope|
|
|
104
|
+
stale = @store.list(scope).select { |k| past?(k.rpartition(":").last.to_i, day, month) }
|
|
105
|
+
stale.each { |k| @store.delete(scope, k) }
|
|
106
|
+
stale.size
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
90
111
|
private
|
|
91
112
|
|
|
92
113
|
# No tenant (single_tenant default) is a LITERAL "platform" cell, never a
|
|
@@ -95,6 +116,16 @@ module Insika
|
|
|
95
116
|
[tenant || "platform", agent].join(":")
|
|
96
117
|
end
|
|
97
118
|
|
|
119
|
+
# Is that bucket a window STRICTLY BEHIND the live one? The two kinds of
|
|
120
|
+
# bucket cannot collide — an epoch-day is a multiple of 86_400 (~1.7e9), a
|
|
121
|
+
# calendar month is year*12+month (~24e3) — so the magnitude tells them
|
|
122
|
+
# apart. STRICTLY behind, never "not the current one": a host whose clock
|
|
123
|
+
# runs minutes ahead writes tomorrow's cell around midnight, and a sweeper
|
|
124
|
+
# that deleted it would hand that tenant a fresh day of budget.
|
|
125
|
+
def past?(bucket, day, month)
|
|
126
|
+
bucket >= DAY ? bucket < day : bucket.positive? && bucket < month
|
|
127
|
+
end
|
|
128
|
+
|
|
98
129
|
def bump(id, bucket, by)
|
|
99
130
|
total = @store.get(SCOPE, key(id, bucket)).to_i + by
|
|
100
131
|
@store.set(SCOPE, key(id, bucket), total)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# — per-AGENT cache-hit series (scope "cache_series"), for the
|
|
5
|
+
# Studio agent-detail plot. Sessions do not stamp their agent, so the
|
|
6
|
+
# per-session context trace cannot answer "cache-hit over time for THIS
|
|
7
|
+
# agent"; this capped list can. Entries are counts and a category name only —
|
|
8
|
+
# PII-free by construction. No retention hook: the cap bounds growth.
|
|
9
|
+
class CacheSeriesStore
|
|
10
|
+
SCOPE = "cache_series"
|
|
11
|
+
MAX_PER_AGENT = 200 # oldest dropped; one entry per turn, so 200 is a
|
|
12
|
+
# rolling window, not a leak path
|
|
13
|
+
|
|
14
|
+
def initialize(store:)
|
|
15
|
+
@store = store
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Appends a sanitized entry for the agent; caps. Rescues everything — the
|
|
19
|
+
# series never breaks the turn.
|
|
20
|
+
def record(agent:, entry:)
|
|
21
|
+
return if agent.to_s.empty?
|
|
22
|
+
|
|
23
|
+
list = (@store.get(SCOPE, agent.to_s) || []) + [sanitize(entry)]
|
|
24
|
+
@store.set(SCOPE, agent.to_s, list.last(MAX_PER_AGENT))
|
|
25
|
+
rescue StandardError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# -> [Hash] the agent's series, chronological. [] if none.
|
|
30
|
+
def for_agent(agent) = @store.get(SCOPE, agent.to_s) || []
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def sanitize(entry)
|
|
35
|
+
e = entry.is_a?(Hash) ? entry : {}
|
|
36
|
+
{
|
|
37
|
+
"at" => (e[:at] || e["at"])&.to_s,
|
|
38
|
+
"turn" => int(e[:turn] || e["turn"]),
|
|
39
|
+
"hit_pct" => int_or_nil(e[:hit_pct] || e["hit_pct"]),
|
|
40
|
+
"cached_tokens" => int(e[:cached_tokens] || e["cached_tokens"]),
|
|
41
|
+
"prompt_tokens" => int(e[:prompt_tokens] || e["prompt_tokens"]),
|
|
42
|
+
"invalidation_reason" => (e[:invalidation_reason] || e["invalidation_reason"])&.to_s
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def int(value) = Integer(value || 0)
|
|
47
|
+
def int_or_nil(value) = value.nil? ? nil : Integer(value)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -17,7 +17,7 @@ module Insika
|
|
|
17
17
|
# party with outages, so "keep trying" is a real requirement and "keep trying
|
|
18
18
|
# forever" is a real outage of ours.
|
|
19
19
|
#
|
|
20
|
-
# It is NOT a job queue: no
|
|
20
|
+
# It is NOT a job queue: no priorities, no fan-out. The moment it
|
|
21
21
|
# grows one, the thing to do is take a real queue, not to finish building this.
|
|
22
22
|
class ChannelDelivery
|
|
23
23
|
MAX_ATTEMPTS = 3
|
|
@@ -26,7 +26,8 @@ module Insika
|
|
|
26
26
|
BACKOFF_SECONDS = [1, 5].freeze
|
|
27
27
|
|
|
28
28
|
def initialize(channels:, outbox:, session_store:, event_stream: nil,
|
|
29
|
-
max_attempts: MAX_ATTEMPTS, backoff: BACKOFF_SECONDS, sleeper: nil
|
|
29
|
+
max_attempts: MAX_ATTEMPTS, backoff: BACKOFF_SECONDS, sleeper: nil,
|
|
30
|
+
shadow_pairs: nil, criterion_sha: nil)
|
|
30
31
|
@channels = channels
|
|
31
32
|
@outbox = outbox
|
|
32
33
|
@session_store = session_store
|
|
@@ -34,29 +35,66 @@ module Insika
|
|
|
34
35
|
@max_attempts = max_attempts
|
|
35
36
|
@backoff = Array(backoff)
|
|
36
37
|
@sleeper = sleeper || method(:default_sleep)
|
|
38
|
+
@shadow_pairs = shadow_pairs
|
|
39
|
+
@criterion_sha = criterion_sha
|
|
37
40
|
end
|
|
38
41
|
|
|
39
|
-
#
|
|
40
|
-
#
|
|
42
|
+
# the pair store and the frozen criterion's sha. Both default to
|
|
43
|
+
# nil (parity — a graph without them behaves exactly as today); the server
|
|
44
|
+
# root sets them at boot, after the criterion file has been loaded and
|
|
45
|
+
# refused-or-accepted (the graph itself reads no env and no file).
|
|
46
|
+
attr_writer :shadow_pairs, :criterion_sha
|
|
47
|
+
|
|
48
|
+
# Confirmed answer -> 0..N pending Deliveries, in order .
|
|
49
|
+
# A progressive channel splits on paragraphs (BalloonSplitter); everything
|
|
50
|
+
# else is the single whole-answer row.
|
|
51
|
+
# -> [] when there is nothing to send (the cheap exits):
|
|
41
52
|
# · the turn did not come in through a channel,
|
|
42
53
|
# · the channel is Shape A (answers on its own stream — no `deliver`),
|
|
43
54
|
# · the answer is empty (a turn that died mid-message published nothing, and
|
|
44
55
|
# half a sentence was never an answer),
|
|
56
|
+
# · the channel is in SHADOW mode: the answer is recorded as a
|
|
57
|
+
# pair and nothing is dispatched — zero outbox writes, ever (E1),
|
|
45
58
|
# · or we do not know who to send it to.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
59
|
+
#
|
|
60
|
+
# `attachments` (evidence cards) ride the outbox payload as an
|
|
61
|
+
# ADDITIVE key on the LAST balloon — a Shape B channel that reads `payload`
|
|
62
|
+
# ignores it (JSON contract, additive); one that renders cards consumes it.
|
|
63
|
+
def record_balloons(task:, channel_id:, content:, progressive:, attachments: nil)
|
|
49
64
|
channel = @channels&.find(channel_id)
|
|
50
|
-
return
|
|
65
|
+
return [] unless channel.respond_to?(:deliver)
|
|
66
|
+
# Shadow records ONE pair for the whole answer — a balloon per paragraph
|
|
67
|
+
# would mint N pairs for one turn.
|
|
68
|
+
if shadow?(channel)
|
|
69
|
+
record_shadow(task, channel_id, content)
|
|
70
|
+
return []
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
return [] if content.to_s.strip.empty?
|
|
51
74
|
|
|
52
75
|
to = recipient(channel, task.session_id)
|
|
53
|
-
return
|
|
76
|
+
return [] if to.nil? || to.empty?
|
|
77
|
+
|
|
78
|
+
parts = progressive ? Insika::BalloonSplitter.split(content) : [content.to_s]
|
|
79
|
+
parts = parts.reject { |p| p.to_s.strip.empty? }
|
|
80
|
+
return [] if parts.empty?
|
|
81
|
+
|
|
82
|
+
multi = parts.size > 1
|
|
83
|
+
parts.each_with_index.map do |part, i|
|
|
84
|
+
last = i == parts.size - 1
|
|
85
|
+
create_pending(task, channel_id, part, to,
|
|
86
|
+
index: multi ? i : nil, final: multi ? last : nil,
|
|
87
|
+
attachments: last ? attachments : nil)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def shadow?(channel) = channel.respond_to?(:shadow?) && channel.shadow?
|
|
54
92
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
)
|
|
93
|
+
# does this channel flush progressively? Duck-typed — a channel
|
|
94
|
+
# that does not answer `progressive?` is `:at_end`.
|
|
95
|
+
def progressive?(channel_id)
|
|
96
|
+
channel = @channels&.find(channel_id)
|
|
97
|
+
channel.respond_to?(:progressive?) && channel.progressive?
|
|
60
98
|
end
|
|
61
99
|
|
|
62
100
|
# Claim + POST + bounded retry. Safe to call twice: the second caller loses the
|
|
@@ -90,6 +128,73 @@ module Insika
|
|
|
90
128
|
|
|
91
129
|
private
|
|
92
130
|
|
|
131
|
+
# One outbox row for a confirmed balloon. `index`/`final` ride the payload
|
|
132
|
+
# only when non-nil — a single-balloon progressive turn is indistinguishable
|
|
133
|
+
# from an `:at_end` one on the wire. `index` also lands on the RECORD, which
|
|
134
|
+
# is what the boot sweep orders by. `attachments` :
|
|
135
|
+
# validated for the outbox — malformed entries dropped, never a turn failure.
|
|
136
|
+
def create_pending(task, channel_id, content, to, index: nil, final: nil, attachments: nil)
|
|
137
|
+
payload = { "session_id" => task.session_id.to_s, "task_id" => task.id.to_s,
|
|
138
|
+
"content" => content.to_s }
|
|
139
|
+
payload["index"] = index if index
|
|
140
|
+
payload["final"] = final unless final.nil?
|
|
141
|
+
atts = Insika::Evidence.valid_attachments(attachments)
|
|
142
|
+
payload["attachments"] = atts unless atts.empty?
|
|
143
|
+
@outbox.create(channel: channel_id, to: to, task_id: task.id, session_id: task.session_id,
|
|
144
|
+
payload: payload, index: index.to_i)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Our half of the shadow pair (C3). One store upsert on the turn's terminal,
|
|
148
|
+
# then nil — `Executor#finalize_channel_delivery` returns without dispatching.
|
|
149
|
+
# The ordering rules: no event_id -> :shadow_unpairable (C1 makes this
|
|
150
|
+
# unreachable through the relay; a plugin channel could still get it wrong);
|
|
151
|
+
# no pair store wired -> the same event (fail-closed, nothing delivered);
|
|
152
|
+
# no recipient -> the same event (a pair keyed on an empty external_id can
|
|
153
|
+
# never meet the mirror's half).
|
|
154
|
+
def record_shadow(task, channel_id, content)
|
|
155
|
+
command = task.respond_to?(:command) ? task.command : nil
|
|
156
|
+
payload = command.is_a?(Hash) ? (command["payload"] || command[:payload] || {}) : {}
|
|
157
|
+
agent = payload["agent"] || payload[:agent]
|
|
158
|
+
message = payload["message"] || payload[:message]
|
|
159
|
+
event_id = Insika::Coercion.presence(payload["event_id"] || payload[:event_id])
|
|
160
|
+
if event_id.nil? || @shadow_pairs.nil?
|
|
161
|
+
emit_shadow(:shadow_unpairable, channel_id, agent, nil, silent: nil)
|
|
162
|
+
return nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
channel = @channels&.find(channel_id)
|
|
166
|
+
external_id = recipient(channel, task.session_id)
|
|
167
|
+
# The same empty-recipient guard the delivery path has: a pair keyed on an
|
|
168
|
+
# empty external_id can never meet the mirror's half (its digest differs),
|
|
169
|
+
# so the pair would sit :open forever. C1 makes this unreachable through
|
|
170
|
+
# the relay; a plugin channel could still get it wrong.
|
|
171
|
+
if Insika::Coercion.presence(external_id).nil?
|
|
172
|
+
emit_shadow(:shadow_unpairable, channel_id, agent, nil, silent: nil)
|
|
173
|
+
return nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
silent = content.to_s.strip.empty?
|
|
177
|
+
id = Insika::ShadowPairStore.key_for(channel: channel_id, external_id: external_id,
|
|
178
|
+
event_id: event_id)
|
|
179
|
+
@shadow_pairs.record_ours(id: id, channel: channel_id, agent: agent,
|
|
180
|
+
session_id: task.session_id, task_id: task.id,
|
|
181
|
+
event_id: event_id, inbound: message.to_s,
|
|
182
|
+
reply: content.to_s, criterion_sha: @criterion_sha)
|
|
183
|
+
emit_shadow(:shadow_recorded, channel_id, agent, id, silent: silent)
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Metadata only: the stream reaches every subscriber and stays free of
|
|
188
|
+
# customer content, per the Studio's own emit_operator_action rule.
|
|
189
|
+
def emit_shadow(type, channel, agent, pair_id, silent:)
|
|
190
|
+
return unless @event_stream
|
|
191
|
+
|
|
192
|
+
data = { channel: channel.to_s, agent: agent, pair_id: pair_id }.compact
|
|
193
|
+
data[:silent] = silent unless silent.nil?
|
|
194
|
+
@event_stream.emit(Insika::Event.new(type: type, data: data,
|
|
195
|
+
meta: { at: Time.now.utc.iso8601 }))
|
|
196
|
+
end
|
|
197
|
+
|
|
93
198
|
def attempt(delivery, channel)
|
|
94
199
|
last_error = nil
|
|
95
200
|
|