insika 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -5
  3. data/README.md +8 -2
  4. data/bin/insika +231 -13
  5. data/docs/AGENTS.md +505 -6
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +100 -10
  8. data/docs/CONTEXT.md +147 -19
  9. data/docs/DEPLOY.md +34 -11
  10. data/docs/EMBEDDING.md +11 -7
  11. data/docs/EVALS.md +20 -1
  12. data/docs/FACTS.md +135 -0
  13. data/docs/HARVEST.md +117 -0
  14. data/docs/LOADTEST.md +17 -10
  15. data/docs/OBSERVABILITY.md +65 -2
  16. data/docs/REFINEMENT.md +9 -9
  17. data/docs/RELEASING.md +34 -7
  18. data/docs/RUNNING-LOCAL.md +4 -4
  19. data/docs/SECURITY.md +85 -11
  20. data/docs/SKILLS.md +189 -3
  21. data/docs/SOAK.md +127 -0
  22. data/docs/TOOLS.md +70 -2
  23. data/docs/WHY.md +1 -1
  24. data/docs/WORKFLOWS.md +2 -2
  25. data/docs/domain.md +115 -0
  26. data/docs/index.md +2 -2
  27. data/docs/onboarding/start.md +1 -1
  28. data/lib/insika/agent_profile.rb +228 -26
  29. data/lib/insika/alert_dispatcher.rb +139 -0
  30. data/lib/insika/balloon_splitter.rb +102 -0
  31. data/lib/insika/baseline_store.rb +2 -2
  32. data/lib/insika/budget_ledger.rb +166 -0
  33. data/lib/insika/cache_series_store.rb +49 -0
  34. data/lib/insika/channel_delivery.rb +132 -24
  35. data/lib/insika/channel_registry.rb +1 -1
  36. data/lib/insika/channels/relay.rb +80 -6
  37. data/lib/insika/channels/web/widget.js +2 -2
  38. data/lib/insika/channels/web.rb +9 -9
  39. data/lib/insika/channels/webhook.rb +58 -0
  40. data/lib/insika/chat_builder.rb +145 -13
  41. data/lib/insika/checkpoint_store.rb +16 -0
  42. data/lib/insika/circuit_state.rb +114 -0
  43. data/lib/insika/coercion.rb +8 -0
  44. data/lib/insika/commands/agent_payload.rb +6 -4
  45. data/lib/insika/commands/cancel_followup.rb +49 -0
  46. data/lib/insika/commands/create_agent.rb +2 -2
  47. data/lib/insika/commands/create_session.rb +1 -1
  48. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  49. data/lib/insika/commands/delete_skill.rb +43 -0
  50. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  51. data/lib/insika/commands/export_customer_memory.rb +48 -0
  52. data/lib/insika/commands/forget_customer.rb +117 -0
  53. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  54. data/lib/insika/commands/gate_harvest.rb +138 -0
  55. data/lib/insika/commands/gate_refinement.rb +12 -12
  56. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  57. data/lib/insika/commands/import_tools.rb +4 -4
  58. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  59. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  60. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  61. data/lib/insika/commands/memory_put_fact.rb +23 -4
  62. data/lib/insika/commands/promote_harvest.rb +130 -0
  63. data/lib/insika/commands/record_outcome.rb +46 -0
  64. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  65. data/lib/insika/commands/reject_harvest.rb +38 -0
  66. data/lib/insika/commands/resolve_proposal.rb +108 -0
  67. data/lib/insika/commands/resolve_refinement.rb +1 -1
  68. data/lib/insika/commands/revoke_contact.rb +49 -0
  69. data/lib/insika/commands/revoke_token.rb +39 -0
  70. data/lib/insika/commands/rollback_harvest.rb +86 -0
  71. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  72. data/lib/insika/commands/run_distillation.rb +186 -0
  73. data/lib/insika/commands/run_harvest.rb +393 -0
  74. data/lib/insika/commands/run_refinement.rb +5 -5
  75. data/lib/insika/commands/send_message.rb +112 -15
  76. data/lib/insika/commands/session_purge.rb +67 -0
  77. data/lib/insika/commands/set_agent_tools.rb +1 -1
  78. data/lib/insika/commands/set_skill_agents.rb +60 -19
  79. data/lib/insika/commands/trigger_workflow.rb +1 -1
  80. data/lib/insika/commands/update_agent.rb +1 -1
  81. data/lib/insika/commands/write_data_tool.rb +1 -1
  82. data/lib/insika/commands/write_golden.rb +1 -1
  83. data/lib/insika/commands/write_skill.rb +19 -9
  84. data/lib/insika/config_store.rb +8 -4
  85. data/lib/insika/contact_store.rb +183 -0
  86. data/lib/insika/context/builder.rb +23 -5
  87. data/lib/insika/context/fragment.rb +31 -3
  88. data/lib/insika/context/priority.rb +6 -2
  89. data/lib/insika/context/provider.rb +17 -3
  90. data/lib/insika/context/providers/briefing.rb +96 -0
  91. data/lib/insika/context/providers/memory.rb +16 -7
  92. data/lib/insika/context/providers/prompt.rb +30 -2
  93. data/lib/insika/context/providers/request.rb +1 -1
  94. data/lib/insika/context/providers/session.rb +17 -2
  95. data/lib/insika/context/providers/skill.rb +7 -1
  96. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  97. data/lib/insika/context/providers/tool_search.rb +2 -0
  98. data/lib/insika/context_trace_store.rb +128 -0
  99. data/lib/insika/delegation_store.rb +2 -2
  100. data/lib/insika/distill.rb +224 -0
  101. data/lib/insika/distill_engine.rb +169 -0
  102. data/lib/insika/doctor.rb +962 -7
  103. data/lib/insika/dsl/runtime.rb +20 -11
  104. data/lib/insika/dsl/server_boot.rb +74 -4
  105. data/lib/insika/dsl/system.rb +1 -1
  106. data/lib/insika/dsl.rb +152 -15
  107. data/lib/insika/edge_limiter.rb +167 -8
  108. data/lib/insika/egress_guard.rb +3 -3
  109. data/lib/insika/env_schema.rb +22 -12
  110. data/lib/insika/errors.rb +72 -5
  111. data/lib/insika/evals/assertions.rb +15 -14
  112. data/lib/insika/evals/baseline.rb +3 -3
  113. data/lib/insika/evals/golden.rb +8 -8
  114. data/lib/insika/evals/judge.rb +7 -7
  115. data/lib/insika/evals/pairwise.rb +21 -9
  116. data/lib/insika/evals/report.rb +2 -2
  117. data/lib/insika/evals/runner.rb +6 -6
  118. data/lib/insika/evals/transport.rb +2 -2
  119. data/lib/insika/event_stream.rb +23 -5
  120. data/lib/insika/evidence.rb +183 -0
  121. data/lib/insika/executor.rb +1092 -160
  122. data/lib/insika/followup_engine.rb +207 -0
  123. data/lib/insika/followup_policy.rb +221 -0
  124. data/lib/insika/followup_store.rb +306 -0
  125. data/lib/insika/frontmatter.rb +1 -1
  126. data/lib/insika/funnel_declaration.rb +106 -0
  127. data/lib/insika/funnel_fold.rb +179 -0
  128. data/lib/insika/funnel_store.rb +163 -0
  129. data/lib/insika/golden_store.rb +3 -3
  130. data/lib/insika/grounding/matcher.rb +69 -0
  131. data/lib/insika/grounding.rb +44 -0
  132. data/lib/insika/harvest/conversion_gate.rb +159 -0
  133. data/lib/insika/harvest/criterion.rb +98 -0
  134. data/lib/insika/harvest/gate.rb +194 -0
  135. data/lib/insika/harvest/negative_list.rb +199 -0
  136. data/lib/insika/harvest.rb +241 -0
  137. data/lib/insika/harvest_engine.rb +193 -0
  138. data/lib/insika/harvest_store.rb +548 -0
  139. data/lib/insika/http_client.rb +3 -3
  140. data/lib/insika/inbound_log.rb +1 -1
  141. data/lib/insika/llm_configurator.rb +3 -3
  142. data/lib/insika/loop_detector.rb +143 -0
  143. data/lib/insika/mcp_http_client.rb +4 -4
  144. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  145. data/lib/insika/media.rb +298 -0
  146. data/lib/insika/memory_audit_store.rb +85 -0
  147. data/lib/insika/memory_store.rb +264 -23
  148. data/lib/insika/message_origin.rb +8 -3
  149. data/lib/insika/model_resolver.rb +1 -1
  150. data/lib/insika/model_selection.rb +5 -4
  151. data/lib/insika/model_visible.rb +87 -0
  152. data/lib/insika/model_visible_trace_store.rb +66 -0
  153. data/lib/insika/onboarding.rb +8 -3
  154. data/lib/insika/outbox_store.rb +44 -6
  155. data/lib/insika/outcome_store.rb +147 -0
  156. data/lib/insika/overlay_tool_registry.rb +3 -4
  157. data/lib/insika/pack.rb +3 -3
  158. data/lib/insika/pack_importer.rb +17 -15
  159. data/lib/insika/packaging.rb +163 -0
  160. data/lib/insika/parity/criterion.rb +79 -0
  161. data/lib/insika/parity/verdict.rb +318 -0
  162. data/lib/insika/pending_action_store.rb +1 -1
  163. data/lib/insika/plugin/loader.rb +2 -2
  164. data/lib/insika/policy/policy.rb +1 -1
  165. data/lib/insika/prefix_fingerprint.rb +58 -0
  166. data/lib/insika/profile_source.rb +34 -7
  167. data/lib/insika/proposal_store.rb +271 -0
  168. data/lib/insika/provider_error_classifier.rb +160 -0
  169. data/lib/insika/queue_policy.rb +6 -3
  170. data/lib/insika/recovery.rb +47 -6
  171. data/lib/insika/refinement/candidate.rb +4 -4
  172. data/lib/insika/refinement/evidence_collector.rb +6 -6
  173. data/lib/insika/refinement/gate.rb +7 -7
  174. data/lib/insika/refinement/panel.rb +7 -7
  175. data/lib/insika/refinement/proposer.rb +10 -10
  176. data/lib/insika/refinement_store.rb +12 -12
  177. data/lib/insika/reliability.rb +211 -0
  178. data/lib/insika/retention.rb +281 -0
  179. data/lib/insika/routing.rb +101 -0
  180. data/lib/insika/safety/config.rb +46 -6
  181. data/lib/insika/safety/corpus.rb +255 -0
  182. data/lib/insika/safety/detectors.rb +34 -115
  183. data/lib/insika/safety/factory.rb +18 -5
  184. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  185. data/lib/insika/safety/grounding_validator.rb +49 -0
  186. data/lib/insika/safety/input_guardrail.rb +20 -5
  187. data/lib/insika/safety/moderator.rb +19 -11
  188. data/lib/insika/safety/output_filter.rb +10 -6
  189. data/lib/insika/safety/output_validator.rb +13 -7
  190. data/lib/insika/safety/safe_responses.rb +1 -1
  191. data/lib/insika/sandbox/boundary.rb +2 -2
  192. data/lib/insika/sandbox.rb +1 -1
  193. data/lib/insika/schema_guard.rb +35 -0
  194. data/lib/insika/server/app.rb +366 -54
  195. data/lib/insika/server/boot.rb +4 -4
  196. data/lib/insika/server/rack_app.rb +31 -7
  197. data/lib/insika/server/responses.rb +58 -9
  198. data/lib/insika/server/tenant_auth.rb +61 -0
  199. data/lib/insika/session_actor.rb +11 -7
  200. data/lib/insika/session_store.rb +66 -3
  201. data/lib/insika/settings_store.rb +15 -5
  202. data/lib/insika/shadow_pair_store.rb +258 -0
  203. data/lib/insika/shutdown.rb +4 -4
  204. data/lib/insika/skill_catalog.rb +131 -20
  205. data/lib/insika/skill_store.rb +70 -22
  206. data/lib/insika/soak/envelope.rb +140 -0
  207. data/lib/insika/soak/report.rb +392 -0
  208. data/lib/insika/soak/runner.rb +554 -0
  209. data/lib/insika/steer_injector.rb +1 -1
  210. data/lib/insika/store.rb +11 -2
  211. data/lib/insika/stores/memory.rb +6 -0
  212. data/lib/insika/stores/sqlite.rb +8 -0
  213. data/lib/insika/studio/app.rb +1058 -75
  214. data/lib/insika/studio/assets/dist/application.css +1 -1
  215. data/lib/insika/studio/assets/dist/application.js +27 -26
  216. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  217. data/lib/insika/studio/forms.rb +274 -22
  218. data/lib/insika/studio/nav_icons.rb +7 -2
  219. data/lib/insika/studio/views/_message.erb +2 -2
  220. data/lib/insika/studio/views/agent_detail.erb +629 -86
  221. data/lib/insika/studio/views/agents.erb +11 -7
  222. data/lib/insika/studio/views/approvals.erb +4 -1
  223. data/lib/insika/studio/views/chats.erb +4 -1
  224. data/lib/insika/studio/views/customer.erb +94 -0
  225. data/lib/insika/studio/views/customers.erb +32 -0
  226. data/lib/insika/studio/views/evals.erb +4 -1
  227. data/lib/insika/studio/views/facts.erb +133 -0
  228. data/lib/insika/studio/views/followups.erb +125 -0
  229. data/lib/insika/studio/views/funnel.erb +106 -0
  230. data/lib/insika/studio/views/harvest.erb +234 -0
  231. data/lib/insika/studio/views/home.erb +2 -1
  232. data/lib/insika/studio/views/layout.erb +1 -0
  233. data/lib/insika/studio/views/parity.erb +147 -0
  234. data/lib/insika/studio/views/playground.erb +7 -1
  235. data/lib/insika/studio/views/refinement.erb +4 -4
  236. data/lib/insika/studio/views/session.erb +133 -3
  237. data/lib/insika/studio/views/settings.erb +9 -12
  238. data/lib/insika/studio/views/skills.erb +66 -12
  239. data/lib/insika/studio/views/system_files.erb +1 -1
  240. data/lib/insika/studio/views/task.erb +13 -0
  241. data/lib/insika/studio/views/tasks.erb +4 -1
  242. data/lib/insika/studio/views/tools.erb +0 -1
  243. data/lib/insika/subagent_graph.rb +3 -3
  244. data/lib/insika/task_actor.rb +3 -3
  245. data/lib/insika/task_store.rb +22 -2
  246. data/lib/insika/telemetry/pricing.rb +3 -3
  247. data/lib/insika/telemetry/recorder.rb +1 -1
  248. data/lib/insika/telemetry.rb +2 -2
  249. data/lib/insika/testing/store_contract.rb +54 -33
  250. data/lib/insika/tick.rb +146 -0
  251. data/lib/insika/token_store.rb +168 -0
  252. data/lib/insika/tool_assembly.rb +5 -5
  253. data/lib/insika/tool_definition.rb +25 -15
  254. data/lib/insika/tool_envelope.rb +70 -1
  255. data/lib/insika/tool_manifest.rb +11 -7
  256. data/lib/insika/tool_output_compressor.rb +100 -0
  257. data/lib/insika/tool_store.rb +1 -1
  258. data/lib/insika/tool_trace_store.rb +1 -1
  259. data/lib/insika/tools/concurrency.rb +2 -2
  260. data/lib/insika/tools/data_defined_tool.rb +14 -5
  261. data/lib/insika/tools/generate_image.rb +44 -0
  262. data/lib/insika/tools/load_skill.rb +61 -3
  263. data/lib/insika/tools/schedule_followup.rb +164 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +4 -4
  266. data/lib/insika/tools/subagents.rb +1 -1
  267. data/lib/insika/tools/tts.rb +47 -0
  268. data/lib/insika/tools/update_briefing.rb +126 -0
  269. data/lib/insika/turn_output.rb +2 -2
  270. data/lib/insika/turn_state.rb +54 -13
  271. data/lib/insika/turn_timing.rb +24 -4
  272. data/lib/insika/usage_ledger.rb +1 -1
  273. data/lib/insika/version.rb +1 -1
  274. data/lib/insika/vitals.rb +84 -0
  275. data/lib/insika/wiring/graph.rb +372 -34
  276. data/lib/insika/workflow.rb +1 -1
  277. data/lib/insika/workflow_registry.rb +1 -1
  278. data/lib/insika.rb +122 -16
  279. metadata +95 -2
  280. data/lib/insika/server/admin_auth.rb +0 -29
@@ -4,7 +4,7 @@ require "json"
4
4
 
5
5
  module Insika
6
6
  module Evals
7
- # The LLM-judge (RFC-0008 §3.3, Fase B). Scores a golden's `rubric` against the
7
+ # The LLM-judge. Scores a golden's `rubric` against the
8
8
  # actual assistant reply — the subjective layer on top of the deterministic
9
9
  # asserts. Pure over an injected `ask` callable (prompt -> raw model text), so it's
10
10
  # unit-testable without an LLM; the real ask (RubyLLM on the utility_model, temp 0)
@@ -13,7 +13,7 @@ module Insika
13
13
  # Conservative by construction: an unparseable judge reply scores 0 (fails) rather
14
14
  # than silently passing.
15
15
  #
16
- # A PANEL, not a single voice (RFC-0013 §3.9). `quorum: N` samples ONE model N
16
+ # A PANEL, not a single voice. `quorum: N` samples ONE model N
17
17
  # times, which measures that model's variance and little else — at temperature 0 it
18
18
  # mostly returns the same answer, including the same blind spot. Two DIFFERENT
19
19
  # models disagreeing about a rubric is the signal worth having, so `asks:` takes one
@@ -95,7 +95,7 @@ module Insika
95
95
  end
96
96
  end
97
97
 
98
- # The `policy` is the one thing a rubric cannot carry alone (RFC-0014 §3.3): how
98
+ # The `policy` is the one thing a rubric cannot carry alone: how
99
99
  # much this store wants the agent to ask before acting is a per-store decision,
100
100
  # and a judge that is not TOLD it will guess — half the time wrongly. The
101
101
  # deterministic half is `Assertions.policy_checks`; this is the other half.
@@ -149,11 +149,11 @@ module Insika
149
149
  end
150
150
  end
151
151
 
152
- # Builds the configured judge panel from `settings["evals"]` (RFC-0013 §3.9).
152
+ # Builds the configured judge panel from `settings["evals"]`.
153
153
  #
154
154
  # It lives HERE and not in `evals/run.rb` because the CLI is no longer the only
155
155
  # caller: the refinement gate scores a candidate with the SAME judges the operator
156
- # configured, and §3.7 is explicit that a second copy of the judge would be the
156
+ # configured, and is explicit that a second copy of the judge would be the
157
157
  # worst possible outcome — the gate would then be grading against a rubric nobody
158
158
  # tuned. One builder, two callers.
159
159
  module JudgePanel
@@ -164,7 +164,7 @@ module Insika
164
164
  # -> [Judge, [model names]] | nil when nobody is configured to ask. NIL AND NOT
165
165
  # a no-op judge: a rubric'd case with no judge reads as `judge_pending`, which is
166
166
  # visible, where a judge that always passes would be silent.
167
- # `llm` (RFC-0017 A2): the graph's own RubyLLM context; nil = the global
167
+ # `llm`: the graph's own RubyLLM context; nil = the global
168
168
  # constant. Only the deployment root and the CLI build judges today, and both
169
169
  # are one graph per process — the seam keeps the default honest for the day
170
170
  # an embedded graph gates a candidate on its own credentials.
@@ -187,7 +187,7 @@ module Insika
187
187
  # Sugar for the callers that only want the judge (the gate).
188
188
  def judge(settings, **kw) = build(settings, **kw)&.first
189
189
 
190
- # The SAME configured models, asked a different question (RFC-0014 §3.4). One
190
+ # The SAME configured models, asked a different question. One
191
191
  # builder because "who judges here" is one operator decision: a pairwise panel
192
192
  # configured apart from the rubric panel would let a run be graded by judges
193
193
  # nobody chose. -> [Pairwise, [model names]] | nil when nobody is configured.
@@ -4,7 +4,7 @@ require "json"
4
4
 
5
5
  module Insika
6
6
  module Evals
7
- # PAIRWISE AGAINST THE INCUMBENT (RFC-0014 §3.4) — the number that answers "can we
7
+ # PAIRWISE AGAINST THE INCUMBENT — the number that answers "can we
8
8
  # replace it". An absolute 0.72 says a reply cleared a bar we invented; it says
9
9
  # nothing about whether the system already answering 403,231 chats would have done
10
10
  # better with the same customer.
@@ -29,7 +29,7 @@ module Insika
29
29
  # 3. **A split panel stays split.** Averaging "better" and "worse" into
30
30
  # "comparable" invents agreement that nobody expressed.
31
31
  #
32
- # Cost: 2 provider calls per judge per case (§5). This is why it never runs as
32
+ # Cost: 2 provider calls per judge per case. This is why it never runs as
33
33
  # part of the gate and is opt-in on the CLI.
34
34
  class Pairwise
35
35
  BETTER = "better"
@@ -39,7 +39,7 @@ module Insika
39
39
  UNKNOWN = "unknown"
40
40
 
41
41
  # `vs` names WHO the reference half actually is: `agent` (model against model)
42
- # or `human-assisted` (a person typed part of it, P23a's `origin: operator`).
42
+ # or `human-assisted` (a person typed part of it,'s `origin: operator`).
43
43
  # It rides on the verdict rather than beside it so a report cannot print the
44
44
  # outcome without the label.
45
45
  Verdict = Struct.new(:outcome, :reason, :vs, :judges, :order_dependent, keyword_init: true) do
@@ -60,12 +60,19 @@ module Insika
60
60
  def compare(golden:, turns:)
61
61
  return nil unless golden.reference?
62
62
 
63
- ours = Pairwise.transcript(golden.user_turns, turns)
64
- theirs = Pairwise.reference_transcript(golden.reference_messages)
65
- return nil if ours.strip.empty?
63
+ compare_texts(ours: Pairwise.transcript(golden.user_turns, turns),
64
+ theirs: Pairwise.reference_transcript(golden.reference_messages),
65
+ vs: golden.human_assisted? ? "human-assisted" : "agent")
66
+ end
67
+
68
+ # two transcripts, no golden — the shadow seam. The judge is
69
+ # the SAME object with the SAME prompt, so shadow pairs and golden cases are
70
+ # graded by one rule. An empty side returns nil (never a verdict against an
71
+ # empty string — that was a bug in the golden path until this seam landed).
72
+ def compare_texts(ours:, theirs:, vs: "agent")
73
+ return nil if ours.to_s.strip.empty? || theirs.to_s.strip.empty?
66
74
 
67
- panel = @asks.map { |ask| judge_once(ask, ours, theirs) }
68
- combine(panel, vs: golden.human_assisted? ? "human-assisted" : "agent")
75
+ combine(@asks.map { |ask| judge_once(ask, ours, theirs) }, vs: vs)
69
76
  end
70
77
 
71
78
  # The replayed conversation as the judge reads it: the user turns we sent,
@@ -81,8 +88,13 @@ module Insika
81
88
  # is the conversation as the customer received it, and telling it "a person wrote
82
89
  # this one" is an invitation to grade the author instead. The fact is carried to
83
90
  # the READER as `vs: human-assisted` instead, which is where it changes a decision.
91
+ # A reference with no text at all returns "" — the caller's empty guard then
92
+ # refuses instead of judging against an empty string .
84
93
  def self.reference_transcript(messages)
85
- Array(messages).map do |m|
94
+ msgs = Array(messages)
95
+ return "" if msgs.all? { |m| m["text"].to_s.strip.empty? }
96
+
97
+ msgs.map do |m|
86
98
  speaker = m["role"].to_s == "user" ? "customer" : "assistant"
87
99
  "#{speaker}: #{m['text'].to_s.strip}"
88
100
  end.join("\n")
@@ -5,7 +5,7 @@ require "json"
5
5
  module Insika
6
6
  module Evals
7
7
  # Renders a run's [CaseResult] as a machine-readable JSON blob (for the baseline
8
- # + gating in Fase C) and a human-readable markdown summary.
8
+ # gating in) and a human-readable markdown summary.
9
9
  # Pure over the results — takes a clock value in, never reads it (so callers stay
10
10
  # deterministic/testable).
11
11
  module Report
@@ -90,7 +90,7 @@ module Insika
90
90
  lines = ["# Eval report — #{at}", "",
91
91
  "**#{h['passed']}/#{h['total'] - h['skipped']} passed** · #{h['failed']} failed" \
92
92
  "#{" · #{h['skipped']} skipped" if h['skipped'].positive?}" \
93
- "#{" · #{h['judge_pending']} awaiting judge (Fase B)" if h['judge_pending'].positive?}", ""]
93
+ "#{" · #{h['judge_pending']} awaiting judge" if h['judge_pending'].positive?}", ""]
94
94
  results.each do |r|
95
95
  if r.skipped?
96
96
  # WITH the reason, always: "12 skipped" alone is indistinguishable from a
@@ -17,7 +17,7 @@ module Insika
17
17
  # `tokens` is what the whole case cost, summed over its turns, or nil when no
18
18
  # turn reported usage. `cached` is how much of that was served from the prompt
19
19
  # cache, carried separately because it is the number that explains a total.
20
- # Only the refinement gate reads them (RFC-0013 §3.9 records a run's cost); the
20
+ # Only the refinement gate reads them (records a run's cost); the
21
21
  # report and the exit code are untouched.
22
22
  RunCase = Struct.new(:result, :timings, :tokens, :cached, keyword_init: true)
23
23
 
@@ -26,13 +26,13 @@ module Insika
26
26
  #
27
27
  # capabilities: what the DEPLOYMENT has, per agent — anything answering
28
28
  # `#for(agent_id)` with { "tools" =>, "capabilities" => } or nil. Used to skip a
29
- # case the deployment cannot satisfy (RFC-0014 §3.2), BEFORE spending a turn on
29
+ # case the deployment cannot satisfy, BEFORE spending a turn on
30
30
  # it. nil (or an unknown agent) = no resolution, and then a case with `requires`
31
31
  # RUNS and says so in the report: "could not rule it out" is not a reason to
32
32
  # stop testing something, and a suite that shrinks in silence is the failure
33
33
  # this feature exists to avoid.
34
34
  #
35
- # pairwise: an Evals::Pairwise (optional, RFC-0014 §3.4). Only cases carrying a
35
+ # pairwise: an Evals::Pairwise (optional). Only cases carrying a
36
36
  # `reference:` are compared, and the verdict never touches pass/fail — it is the
37
37
  # answer to "can we replace it", reported beside the suite's own verdict.
38
38
  def initialize(transport:, judge: nil, conv_map: {}, capabilities: nil, pairwise: nil)
@@ -53,8 +53,8 @@ module Insika
53
53
  skip = skip_reason(golden)
54
54
  return RunCase.new(result: Assertions.skip(golden, skip), timings: []) if skip
55
55
 
56
- # A backend that resolves state from a pre-existing conversation (e.g. achei-b2b
57
- # needs a real Chat UUID as X-Chat-Id) supplies it via conv_map; otherwise the
56
+ # A backend that resolves state from a pre-existing conversation (e.g. a
57
+ # consumer needing a real Chat UUID as X-Chat-Id) supplies it via conv_map; otherwise the
58
58
  # synthetic "eval-<id>" keeps the adapter's own multi-turn continuation.
59
59
  conv = @conv_map[golden.id] || "eval-#{golden.id}"
60
60
  turns = []
@@ -76,7 +76,7 @@ module Insika
76
76
  # Subjective layer: only when a judge is configured, the case has a rubric, and
77
77
  # the turn ran cleanly (nothing to judge on an errored turn).
78
78
  result.judge = @judge.score(golden: golden, result: last) if @judge && result.rubric && result.error.nil?
79
- # Against the incumbent (RFC-0014 §3.4). Same rule as the judge: nothing to
79
+ # Against the incumbent. Same rule as the judge: nothing to
80
80
  # compare on a turn that errored — half a conversation would lose the
81
81
  # comparison for a reason that has nothing to do with the agent.
82
82
  result.pairwise = @pairwise.compare(golden: golden, turns: turns) if @pairwise && result.error.nil?
@@ -16,7 +16,7 @@ module Insika
16
16
  #
17
17
  # `usage` is the turn's token counts as the deployment reported them
18
18
  # (`response.completed`), or nil when the provider sent none. It is carried, never
19
- # asserted on: the consumer is RFC-0013's refinement budget, which has to bound the
19
+ # asserted on: the consumer is's refinement budget, which has to bound the
20
20
  # cost of a gate replay and cannot invent the number. nil is preserved as nil
21
21
  # rather than zeroed — "the provider did not say" and "it cost nothing" are
22
22
  # different facts, and a budget that confuses them stops being a budget.
@@ -72,7 +72,7 @@ module Insika
72
72
  end
73
73
  end
74
74
 
75
- # What the deployment HAS, per agent (RFC-0014 §3.2), read over the same gated
75
+ # What the deployment HAS, per agent, read over the same gated
76
76
  # `/v1` the replay uses. The eval stays a client: it asks the engine instead of
77
77
  # keeping its own idea of which tools exist.
78
78
  #
@@ -20,9 +20,11 @@ module Insika
20
20
  # local :error event — the turn never waits on transport.
21
21
  MAX_QUEUED = 1000
22
22
 
23
- def initialize(task_id: nil, session_id: nil, on_close: nil)
23
+ def initialize(task_id: nil, session_id: nil, tenant: nil, types: nil, on_close: nil)
24
24
  @task_id = task_id
25
25
  @session_id = session_id
26
+ @tenant = tenant
27
+ @types = types
26
28
  @on_close = on_close
27
29
  @queue = Async::Queue.new
28
30
  end
@@ -39,9 +41,21 @@ module Insika
39
41
 
40
42
  # Meta filter: nil = matches any value. Events with no task_id in
41
43
  # meta (e.g. :session_created) reach only subscribers with no task filter.
44
+ #
45
+ # A TENANT-scoped subscription is FAIL-CLOSED on the meta's tenant (WS1):
46
+ # an event that does not carry the tenant (control events, ignored turns)
47
+ # matches NO tenant subscription. The tenant only ever sees its own.
48
+ #
49
+ # `types:` (nil = any) keeps a subscriber's queue to the events it answers
50
+ # — an alert consumer must not sit behind a full-traffic stream's 1000-cap
51
+ # (WS6), and a filtered queue is the cheapest way to keep it there.
42
52
  def matches?(event)
43
53
  meta = event.meta || {}
44
- (@task_id.nil? || meta[:task_id] == @task_id) &&
54
+ owned = @tenant.nil? || meta[:tenant] == @tenant
55
+
56
+ owned &&
57
+ (@types.nil? || @types.include?(event.type)) &&
58
+ (@task_id.nil? || meta[:task_id] == @task_id) &&
45
59
  (@session_id.nil? || meta[:session_id] == @session_id)
46
60
  end
47
61
 
@@ -103,9 +117,13 @@ module Insika
103
117
  end
104
118
 
105
119
  # nil/nil = all events. Returns the Subscription (the caller iterates with
106
- # `#each` on its own fiber).
107
- def subscribe(task_id: nil, session_id: nil)
108
- sub = Subscription.new(task_id: task_id, session_id: session_id,
120
+ # `#each` on its own fiber). `tenant:` scopes the stream to one tenant's
121
+ # events (WS1) — fail-closed, see Subscription#matches?. `types:` (nil =
122
+ # any) filters by event type so a subscriber's queue only ever holds what
123
+ # its consumer answers (WS6).
124
+ def subscribe(task_id: nil, session_id: nil, tenant: nil, types: nil)
125
+ sub = Subscription.new(task_id: task_id, session_id: session_id, tenant: tenant,
126
+ types: types,
109
127
  on_close: ->(s) { @subscriptions.delete(s) })
110
128
  @subscriptions << sub
111
129
  sub
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ # — the evidence contract (engine half).
7
+ #
8
+ # A tool DECLARES `evidence` in its manifest; the engine then does BOTH jobs
9
+ # from the same declaration: strips the result down to `{items: [{id, line}]}`
10
+ # for the model (the lean envelope), and appends every `id` to the session
11
+ # evidence ledger. No second flag, no "lean but not evidence" mode — a
12
+ # half-configuration cannot exist, which is what keeps "no claim without a
13
+ # tool ID" a tautology at the envelope instead of a convention.
14
+ #
15
+ # Everything here is pure Ruby, no IO: the ToolEnvelope calls it after the real
16
+ # tool returns. It does NOT write anything — the ledger write is the envelope's,
17
+ # via the state.
18
+ module Evidence
19
+ # The declaration (D1/C2.1). Parses three authorable shapes:
20
+ #
21
+ # { "evidence": "products" } // bare kind
22
+ # { "evidence": { "kind": "products" } } // full form
23
+ # { "evidence": { "kind": "products",
24
+ # "items": "results",
25
+ # "attachments": "cards" } } // non-default paths
26
+ #
27
+ # `kind` is the PILOT PACK's value, never a gem constant (the
28
+ # engine owns nothing about product shape).
29
+ Spec = Data.define(:kind, :items_path, :attachments_path) do
30
+ PATH_RE = /\A[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\z/
31
+
32
+ # String | Hash | nil -> Spec | nil. Raises ValidationError on a blank kind
33
+ # or an empty/ill-formed path. All at ingestion, never at the turn.
34
+ def self.parse(raw)
35
+ return nil if raw.nil? || raw == false
36
+
37
+ h = raw.is_a?(String) ? { "kind" => raw } : Coercion.deep_stringify(raw)
38
+ h = h.is_a?(Hash) ? h : {}
39
+ kind = Coercion.presence(h["kind"])
40
+ raise Insika::ValidationError, "evidence.kind is required" if kind.nil?
41
+
42
+ items = presence_path(h["items"], "items")
43
+ attachments = presence_path(h["attachments"], "attachments")
44
+ new(kind: kind, items_path: items, attachments_path: attachments)
45
+ end
46
+
47
+ def to_h
48
+ { "kind" => kind, "items" => items_path, "attachments" => attachments_path }.compact
49
+ end
50
+
51
+ def self.presence_path(value, default)
52
+ s = Coercion.presence(value)
53
+ s = default if s.nil?
54
+ raise Insika::ValidationError, "evidence.#{default}: not a dotted path" unless PATH_RE.match?(s)
55
+
56
+ s
57
+ end
58
+ private_class_method :presence_path
59
+ end
60
+
61
+ # The lean result the model sees — the ONLY thing that survives the envelope.
62
+ MAX_ITEMS = 16
63
+ # Line truncation keeps the transcript lean by force (E1).
64
+ LINE_MAX = 200
65
+ # Attachments are a channel nicety, never the answer; bounded on purpose.
66
+ MAX_ATTACHMENTS = 16
67
+ URL_MAX = 500
68
+
69
+ # The attachments contract, validated for the outbox (channel side, never the
70
+ # model): [{ "type" => "card"|"image", "url" => String, "caption" => String|nil }].
71
+ # Entries without a String url, or beyond MAX_ATTACHMENTS, are DROPPED — never
72
+ # a turn failure (the card is a channel nicety, not the answer).
73
+ def self.valid_attachments(list)
74
+ Array(list).filter_map do |entry|
75
+ next unless entry.is_a?(Hash)
76
+
77
+ url = (entry["url"] || entry[:url]).to_s
78
+ next if url.empty?
79
+
80
+ caption = Coercion.presence(entry["caption"] || entry[:caption])
81
+ { "type" => (entry["type"] || entry[:type]).to_s,
82
+ "url" => url[0, URL_MAX],
83
+ "caption" => caption }
84
+ end.first(MAX_ATTACHMENTS)
85
+ end
86
+
87
+ # Result shaping, stateless — callable from any tool-call fiber (the parallel
88
+ # tool_concurrency path). No shared state in this class.
89
+ class Processor
90
+ class << self
91
+ # -> the parsed Hash the evidence paths dig into. For `evidence_envelope`
92
+ # the raw body lives under `__insika_body` (D3); a code tool returns the
93
+ # object itself. Raises JSON::ParserError on a non-JSON envelope body —
94
+ # the envelope turns that into `{error:}`, nothing recorded (fail closed:
95
+ # no IDs, no claims to make).
96
+ def raw(spec, result)
97
+ return result unless result.is_a?(Hash) && result.key?("__insika_body")
98
+
99
+ JSON.parse(result["__insika_body"].to_s)
100
+ end
101
+
102
+ # -> [lean, attachments]. Assumes the shape already passed
103
+ # SchemaGuard.violation_output. Items are capped and lines truncated; the
104
+ # model must never see a null where the contract says items.
105
+ def build(spec, raw)
106
+ items = SchemaGuard.dig(raw, spec.items_path) || []
107
+ lean_items = items.first(MAX_ITEMS).map do |item|
108
+ { "id" => (item["id"] || item[:id]).to_s,
109
+ "line" => Coercion.utf8((item["line"] || item[:line]).to_s)[0, LINE_MAX] }
110
+ end
111
+ lean = { "items" => lean_items }
112
+ attachments = Insika::Evidence.valid_attachments(SchemaGuard.dig(raw, spec.attachments_path))
113
+ [lean, attachments]
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+ # The session evidence ledger (C4). A session-scoped SET with an `ungrounded`
120
+ # counter: records every product id that entered the context via an
121
+ # evidence-declared tool this session , plus the ungrounded
122
+ # counter that feeds the daily metric. It is NOT a policy object — it records
123
+ # and answers `ids` / `ungrounded` / `lines`; it never decides.
124
+ class EvidenceLedger
125
+ # Oldest-evicted cap: a session that outlives it needs a real cap or the row
126
+ # grows forever.
127
+ MAX_IDS = 1_000
128
+
129
+ def initialize(store: nil, session_id: nil)
130
+ @store = store
131
+ @session_id = session_id
132
+ @ids = []
133
+ @ungrounded = 0
134
+ end
135
+
136
+ # The in-memory accumulator (the envelope appends, the validator/enforcer
137
+ # read the union). The PERSISTED list is appended on flush (Executor, stage 8)
138
+ # — the envelope never blocks on the store.
139
+ def record(ids)
140
+ @ids.concat(Array(ids).map(&:to_s).reject(&:empty?))
141
+ self
142
+ end
143
+
144
+ # -> the effective set for THIS turn: persisted session evidence (loaded at
145
+ # build) + the turn's new ids, deduped, capped.
146
+ def ids
147
+ (session_ids + @ids).uniq.last(MAX_IDS)
148
+ end
149
+
150
+ attr_reader :ungrounded
151
+
152
+ def ungrounded_count(claim)
153
+ @ungrounded += 1
154
+ claim
155
+ end
156
+
157
+ # -> self, flushed to the session record. Idempotent. A store OR not-found
158
+ # failure is swallowed (evidence is audit — it must never fail a committed
159
+ # turn; a session purged mid-turn by forget_customer/session_purge reads as
160
+ # "nothing to append", never an explosion).
161
+ def flush!
162
+ return self unless @store && @session_id
163
+
164
+ @store.append_evidence(@session_id, ids: @ids, ungrounded: @ungrounded)
165
+ @ids = []
166
+ @ungrounded = 0
167
+ self
168
+ rescue Insika::Error
169
+ self
170
+ end
171
+
172
+ private
173
+
174
+ def session_ids
175
+ return [] unless @store && @session_id
176
+
177
+ session = @store.find(@session_id)
178
+ Array(session&.evidence&.fetch("ids", [])).map(&:to_s)
179
+ rescue Insika::NotFoundError
180
+ []
181
+ end
182
+ end
183
+ end