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.
Files changed (300) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +325 -17
  3. data/README.md +52 -11
  4. data/bin/insika +895 -8
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +229 -163
  7. data/docs/API.md +56 -0
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +111 -21
  12. data/docs/CONTEXT.md +132 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +75 -4
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +146 -2
  17. data/docs/FACTS.md +135 -0
  18. data/docs/HARVEST.md +116 -0
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +16 -10
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +65 -6
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -0
  26. data/docs/REFINEMENT.md +17 -12
  27. data/docs/RELEASING.md +27 -0
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +6 -6
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +79 -2
  32. data/docs/SKILLS.md +13 -4
  33. data/docs/SOAK.md +127 -0
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +220 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +115 -0
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +9 -6
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +202 -10
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/balloon_splitter.rb +102 -0
  62. data/lib/insika/budget_ledger.rb +34 -3
  63. data/lib/insika/cache_series_store.rb +49 -0
  64. data/lib/insika/channel_delivery.rb +119 -14
  65. data/lib/insika/channels/relay.rb +77 -3
  66. data/lib/insika/channels/web.rb +2 -2
  67. data/lib/insika/chat_builder.rb +105 -2
  68. data/lib/insika/checkpoint_store.rb +16 -0
  69. data/lib/insika/commands/agent_payload.rb +3 -3
  70. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  71. data/lib/insika/commands/cancel_followup.rb +49 -0
  72. data/lib/insika/commands/delete_artifact.rb +35 -0
  73. data/lib/insika/commands/delete_concept.rb +34 -0
  74. data/lib/insika/commands/delete_mcp.rb +6 -2
  75. data/lib/insika/commands/delete_tenant_data.rb +107 -0
  76. data/lib/insika/commands/export_customer_memory.rb +48 -0
  77. data/lib/insika/commands/forget_customer.rb +117 -0
  78. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  79. data/lib/insika/commands/gate_harvest.rb +138 -0
  80. data/lib/insika/commands/gate_refinement.rb +1 -1
  81. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  82. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  83. data/lib/insika/commands/memory_put_fact.rb +23 -4
  84. data/lib/insika/commands/promote_harvest.rb +130 -0
  85. data/lib/insika/commands/record_outcome.rb +46 -0
  86. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  87. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  88. data/lib/insika/commands/reject_harvest.rb +38 -0
  89. data/lib/insika/commands/resolve_proposal.rb +108 -0
  90. data/lib/insika/commands/restore_concept.rb +34 -0
  91. data/lib/insika/commands/revoke_contact.rb +49 -0
  92. data/lib/insika/commands/rollback_harvest.rb +86 -0
  93. data/lib/insika/commands/run_distillation.rb +186 -0
  94. data/lib/insika/commands/run_harvest.rb +393 -0
  95. data/lib/insika/commands/seed_demo_data.rb +31 -0
  96. data/lib/insika/commands/send_message.rb +103 -6
  97. data/lib/insika/commands/session_purge.rb +67 -0
  98. data/lib/insika/commands/upsert_mcp.rb +6 -3
  99. data/lib/insika/commands/write_concept.rb +57 -0
  100. data/lib/insika/contact_store.rb +183 -0
  101. data/lib/insika/context/builder.rb +21 -3
  102. data/lib/insika/context/fragment.rb +7 -3
  103. data/lib/insika/context/priority.rb +5 -0
  104. data/lib/insika/context/provider.rb +17 -3
  105. data/lib/insika/context/providers/briefing.rb +96 -0
  106. data/lib/insika/context/providers/knowledge.rb +108 -0
  107. data/lib/insika/context/providers/memory.rb +16 -7
  108. data/lib/insika/context/providers/prompt.rb +57 -23
  109. data/lib/insika/context/providers/skill.rb +2 -0
  110. data/lib/insika/context/providers/tool_search.rb +2 -0
  111. data/lib/insika/context_trace_store.rb +38 -2
  112. data/lib/insika/cron.rb +189 -0
  113. data/lib/insika/demo/agent_attrs.rb +43 -0
  114. data/lib/insika/demo/golden_cases.rb +81 -0
  115. data/lib/insika/demo/seeder.rb +336 -0
  116. data/lib/insika/distill.rb +224 -0
  117. data/lib/insika/distill_engine.rb +169 -0
  118. data/lib/insika/doctor.rb +889 -11
  119. data/lib/insika/dsl/definition.rb +3 -2
  120. data/lib/insika/dsl/runtime.rb +69 -82
  121. data/lib/insika/dsl/server_boot.rb +92 -1
  122. data/lib/insika/dsl/system.rb +10 -2
  123. data/lib/insika/dsl.rb +186 -5
  124. data/lib/insika/edge_limiter.rb +28 -7
  125. data/lib/insika/env_schema.rb +25 -3
  126. data/lib/insika/errors.rb +11 -0
  127. data/lib/insika/evals/assertions.rb +3 -2
  128. data/lib/insika/evals/golden.rb +41 -4
  129. data/lib/insika/evals/judge.rb +47 -2
  130. data/lib/insika/evals/pairwise.rb +29 -6
  131. data/lib/insika/evals/persona.rb +98 -0
  132. data/lib/insika/evals/runner.rb +9 -0
  133. data/lib/insika/evals/simulator.rb +225 -0
  134. data/lib/insika/evals/transport.rb +83 -1
  135. data/lib/insika/event_stream.rb +10 -0
  136. data/lib/insika/evidence.rb +183 -0
  137. data/lib/insika/executor.rb +869 -76
  138. data/lib/insika/followup_engine.rb +207 -0
  139. data/lib/insika/followup_policy.rb +198 -0
  140. data/lib/insika/followup_store.rb +306 -0
  141. data/lib/insika/funnel_declaration.rb +106 -0
  142. data/lib/insika/funnel_fold.rb +179 -0
  143. data/lib/insika/funnel_store.rb +163 -0
  144. data/lib/insika/golden_store.rb +17 -2
  145. data/lib/insika/grounding/matcher.rb +69 -0
  146. data/lib/insika/grounding.rb +44 -0
  147. data/lib/insika/harvest/conversion_gate.rb +159 -0
  148. data/lib/insika/harvest/criterion.rb +98 -0
  149. data/lib/insika/harvest/gate.rb +194 -0
  150. data/lib/insika/harvest/negative_list.rb +199 -0
  151. data/lib/insika/harvest.rb +241 -0
  152. data/lib/insika/harvest_engine.rb +193 -0
  153. data/lib/insika/harvest_store.rb +548 -0
  154. data/lib/insika/knowledge.rb +680 -0
  155. data/lib/insika/knowledge_store.rb +140 -0
  156. data/lib/insika/mcp_client.rb +94 -0
  157. data/lib/insika/mcp_json.rb +74 -0
  158. data/lib/insika/mcp_live_tool.rb +43 -0
  159. data/lib/insika/mcp_store.rb +98 -26
  160. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  161. data/lib/insika/mcp_tool_registry.rb +100 -0
  162. data/lib/insika/media.rb +382 -0
  163. data/lib/insika/memory_audit_store.rb +85 -0
  164. data/lib/insika/memory_store.rb +264 -23
  165. data/lib/insika/message_origin.rb +7 -2
  166. data/lib/insika/middleware.rb +9 -0
  167. data/lib/insika/model_visible.rb +87 -0
  168. data/lib/insika/model_visible_trace_store.rb +66 -0
  169. data/lib/insika/onboarding.rb +22 -1
  170. data/lib/insika/outbox_store.rb +42 -4
  171. data/lib/insika/outcome_store.rb +147 -0
  172. data/lib/insika/overlay_tool_registry.rb +37 -17
  173. data/lib/insika/packaging.rb +163 -0
  174. data/lib/insika/parity/criterion.rb +79 -0
  175. data/lib/insika/parity/verdict.rb +318 -0
  176. data/lib/insika/prefix_fingerprint.rb +58 -0
  177. data/lib/insika/profile_source.rb +29 -1
  178. data/lib/insika/prompt_catalog.rb +10 -0
  179. data/lib/insika/proposal_store.rb +271 -0
  180. data/lib/insika/queue_policy.rb +4 -1
  181. data/lib/insika/refinement/proposer.rb +1 -1
  182. data/lib/insika/reliability.rb +32 -6
  183. data/lib/insika/retention.rb +316 -0
  184. data/lib/insika/router/app.rb +157 -0
  185. data/lib/insika/router/backend_pool.rb +98 -0
  186. data/lib/insika/router/hash_ring.rb +55 -0
  187. data/lib/insika/router/proxy_body.rb +34 -0
  188. data/lib/insika/router/session_key.rb +54 -0
  189. data/lib/insika/router.rb +18 -0
  190. data/lib/insika/routing.rb +101 -0
  191. data/lib/insika/safety/config.rb +44 -4
  192. data/lib/insika/safety/corpus.rb +255 -0
  193. data/lib/insika/safety/detectors.rb +32 -113
  194. data/lib/insika/safety/factory.rb +15 -2
  195. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  196. data/lib/insika/safety/grounding_validator.rb +49 -0
  197. data/lib/insika/safety/input_guardrail.rb +1 -1
  198. data/lib/insika/safety/moderator.rb +1 -1
  199. data/lib/insika/safety/output_filter.rb +9 -5
  200. data/lib/insika/safety/output_validator.rb +11 -5
  201. data/lib/insika/schedule.rb +177 -0
  202. data/lib/insika/schedule_engine.rb +314 -0
  203. data/lib/insika/schedule_store.rb +208 -0
  204. data/lib/insika/schema_guard.rb +35 -0
  205. data/lib/insika/server/app.rb +247 -17
  206. data/lib/insika/server/rack_app.rb +21 -1
  207. data/lib/insika/server/responses.rb +40 -1
  208. data/lib/insika/session_actor.rb +8 -4
  209. data/lib/insika/session_store.rb +65 -2
  210. data/lib/insika/settings_store.rb +10 -0
  211. data/lib/insika/shadow_pair_store.rb +258 -0
  212. data/lib/insika/skill_catalog.rb +16 -0
  213. data/lib/insika/soak/envelope.rb +140 -0
  214. data/lib/insika/soak/report.rb +392 -0
  215. data/lib/insika/soak/runner.rb +554 -0
  216. data/lib/insika/steer_injector.rb +21 -10
  217. data/lib/insika/store.rb +10 -1
  218. data/lib/insika/stores/memory.rb +6 -0
  219. data/lib/insika/stores/sqlite.rb +8 -0
  220. data/lib/insika/studio/app.rb +1437 -54
  221. data/lib/insika/studio/assets/dist/application.css +1 -1
  222. data/lib/insika/studio/assets/dist/application.js +23 -23
  223. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  224. data/lib/insika/studio/forms.rb +308 -10
  225. data/lib/insika/studio/nav_icons.rb +19 -1
  226. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  227. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  228. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  229. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  230. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  231. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  232. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  233. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  234. data/lib/insika/studio/views/_agents_master.erb +44 -0
  235. data/lib/insika/studio/views/_message.erb +49 -32
  236. data/lib/insika/studio/views/agent_detail.erb +62 -278
  237. data/lib/insika/studio/views/agents.erb +71 -54
  238. data/lib/insika/studio/views/approvals.erb +4 -1
  239. data/lib/insika/studio/views/artifact.erb +23 -0
  240. data/lib/insika/studio/views/artifacts.erb +59 -0
  241. data/lib/insika/studio/views/chats.erb +4 -1
  242. data/lib/insika/studio/views/customer.erb +94 -0
  243. data/lib/insika/studio/views/customers.erb +32 -0
  244. data/lib/insika/studio/views/evals.erb +6 -3
  245. data/lib/insika/studio/views/facts.erb +133 -0
  246. data/lib/insika/studio/views/followups.erb +125 -0
  247. data/lib/insika/studio/views/funnel.erb +106 -0
  248. data/lib/insika/studio/views/harvest.erb +234 -0
  249. data/lib/insika/studio/views/home.erb +106 -66
  250. data/lib/insika/studio/views/knowledge.erb +123 -0
  251. data/lib/insika/studio/views/layout.erb +15 -11
  252. data/lib/insika/studio/views/mcp.erb +174 -80
  253. data/lib/insika/studio/views/parity.erb +147 -0
  254. data/lib/insika/studio/views/playground.erb +7 -1
  255. data/lib/insika/studio/views/session.erb +233 -124
  256. data/lib/insika/studio/views/settings.erb +41 -1
  257. data/lib/insika/studio/views/skills.erb +1 -2
  258. data/lib/insika/studio/views/system_files.erb +1 -1
  259. data/lib/insika/studio/views/task.erb +13 -0
  260. data/lib/insika/studio/views/tasks.erb +4 -1
  261. data/lib/insika/studio/views/tools.erb +24 -10
  262. data/lib/insika/task_store.rb +21 -1
  263. data/lib/insika/templates/browser-agent/README.md +36 -0
  264. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  265. data/lib/insika/templates/daily-digest/README.md +38 -0
  266. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  267. data/lib/insika/templates/repo-explorer/README.md +36 -0
  268. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  269. data/lib/insika/templates/research-analyst/README.md +26 -0
  270. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  271. data/lib/insika/templates/review-panel/README.md +20 -0
  272. data/lib/insika/templates/review-panel/agent.rb +50 -0
  273. data/lib/insika/templates/travel-planner/README.md +35 -0
  274. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  275. data/lib/insika/templates.rb +112 -0
  276. data/lib/insika/testing/store_contract.rb +27 -6
  277. data/lib/insika/tick.rb +47 -11
  278. data/lib/insika/timezone.rb +45 -0
  279. data/lib/insika/tool_definition.rb +17 -7
  280. data/lib/insika/tool_envelope.rb +69 -0
  281. data/lib/insika/tool_manifest.rb +5 -1
  282. data/lib/insika/tools/data_defined_tool.rb +10 -0
  283. data/lib/insika/tools/generate_image.rb +89 -0
  284. data/lib/insika/tools/load_knowledge.rb +74 -0
  285. data/lib/insika/tools/run_persona_eval.rb +328 -0
  286. data/lib/insika/tools/save_artifact.rb +95 -0
  287. data/lib/insika/tools/schedule_followup.rb +164 -0
  288. data/lib/insika/tools/tts.rb +47 -0
  289. data/lib/insika/tools/update_briefing.rb +126 -0
  290. data/lib/insika/turn_output.rb +1 -1
  291. data/lib/insika/turn_state.rb +49 -1
  292. data/lib/insika/turn_timing.rb +22 -2
  293. data/lib/insika/version.rb +1 -1
  294. data/lib/insika/vitals.rb +84 -0
  295. data/lib/insika/wiring/graph.rb +472 -13
  296. data/lib/insika/wiring/graph_chat.rb +102 -0
  297. data/lib/insika.rb +149 -3
  298. metadata +181 -5
  299. data/docs/build.md +0 -14
  300. data/docs/understand.md +0 -10
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command (WS8, phase 2 — LGPD): purges everything the engine holds
8
+ # about ONE TENANT — its sessions (the "<tenant>:" namespace) and everything
9
+ # those sessions left behind (traces, tasks, checkpoints, outbox deliveries
10
+ # — see SessionPurge), every memory cell under the tenant (its own + the
11
+ # customer cells), its outcome records (WS7) and its API CREDENTIALS (every
12
+ # active token of the tenant is revoked first — an offboarded tenant must
13
+ # not authenticate). The tenant string IS the
14
+ # isolation boundary, so zeroing it cannot touch another tenant's data.
15
+ # Operator-only BY CONSTRUCTION: the generic command ingress is
16
+ # operator-grade (a tenant principal never reaches it).
17
+ class DeleteTenantData
18
+ include SessionPurge
19
+
20
+ def initialize(memory_store:, session_store:, tool_trace_store: nil,
21
+ context_trace_store: nil, model_visible_trace_store: nil,
22
+ outcome_store: nil, task_store: nil,
23
+ checkpoint_store: nil, outbox_store: nil, shadow_pairs: nil,
24
+ token_store: nil, funnel_store: nil, event_stream:,
25
+ followup_store: nil, contact_store: nil, proposal_store: nil,
26
+ harvest_store: nil, schedule_store: nil, artifact_store: nil)
27
+ @memory_store = memory_store
28
+ @token_store = token_store
29
+ @session_store = session_store
30
+ @tool_trace_store = tool_trace_store
31
+ @context_trace_store = context_trace_store
32
+ @model_visible_trace_store = model_visible_trace_store # ; nil = parity
33
+ @outcome_store = outcome_store
34
+ @task_store = task_store
35
+ @checkpoint_store = checkpoint_store
36
+ @outbox_store = outbox_store
37
+ @shadow_pairs = shadow_pairs
38
+ @funnel_store = funnel_store # ; nil = nothing to sweep
39
+ @followup_store = followup_store # ; nil = nothing to sweep
40
+ @contact_store = contact_store # ; nil = nothing to sweep
41
+ @proposal_store = proposal_store # ; nil = nothing to sweep
42
+ @harvest_store = harvest_store # ; nil = nothing to sweep
43
+ @schedule_store = schedule_store # ; nil = nothing to sweep
44
+ @artifact_store = artifact_store # ; nil = nothing to sweep
45
+ @event_stream = event_stream
46
+ end
47
+
48
+ # -> { tenant:, sessions:, memory_records:, outcomes:, tokens_revoked:,
49
+ # tasks:, checkpoints:, deliveries:, followups:, contacts: }.
50
+ def call(command)
51
+ tenant = Coercion.presence(command.payload[:tenant] || command.payload["tenant"])
52
+ raise ValidationError, "tenant is required" if tenant.nil?
53
+
54
+ # CREDENTIALS FIRST (WS1+WS8): erasing the data while the tenant's
55
+ # tokens still resolve leaves an offboarded tenant authenticating and
56
+ # opening a NEW session — the purge would report success over a live
57
+ # customer. Revoking before the sweep closes the door, so nothing the
58
+ # tenant does mid-purge survives it. nil store = single_tenant mode
59
+ # (no per-tenant credential exists).
60
+ tokens_revoked = @token_store ? @token_store.revoke_all(tenant_id: tenant) : 0
61
+
62
+ sessions = @session_store.each_id.select { |id| id.to_s.start_with?("#{tenant}:") }
63
+ purged = purge_sessions(sessions)
64
+
65
+ memory_records = @memory_store.purge_tenant(tenant)
66
+ outcomes = @outcome_store ? @outcome_store.purge(tenant: tenant) : 0
67
+ funnel = @funnel_store ? @funnel_store.purge(tenant: tenant) : 0
68
+ # the follow-up footprint dies with the tenant — records
69
+ # and contact cells under the same tenant prefix.
70
+ followups = @followup_store ? @followup_store.purge(tenant: tenant) : 0
71
+ contacts = @contact_store ? @contact_store.purge(tenant: tenant) : 0
72
+ # the distilled proposals die with the tenant.
73
+ proposals = @proposal_store ? @proposal_store.purge(tenant: tenant) : 0
74
+ # candidates reference sessions, and sessions carry the
75
+ # tenant prefix — the harvest rows die with the tenant (D11).
76
+ harvest = @harvest_store ? @harvest_store.purge(tenant: tenant) : 0
77
+ # recurring-schedule rows die with the tenant too (their
78
+ # message text is content, never kept behind an offboarded tenant).
79
+ schedules = @schedule_store ? @schedule_store.purge(tenant: tenant) : 0
80
+ # artifacts die with the tenant — a report is content, and the
81
+ # tenant binding is the isolation boundary (never a model-typed id).
82
+ artifacts = @artifact_store ? @artifact_store.purge(tenant: tenant) : 0
83
+
84
+ @event_stream.emit(Insika::Event.new(
85
+ type: :tenant_data_deleted,
86
+ data: { tenant: tenant, sessions: sessions,
87
+ memory_records: memory_records,
88
+ outcomes: outcomes,
89
+ funnel: funnel,
90
+ followups: followups,
91
+ contacts: contacts,
92
+ proposals: proposals,
93
+ harvest: harvest,
94
+ schedules: schedules,
95
+ artifacts: artifacts,
96
+ tokens_revoked: tokens_revoked }.merge(purged),
97
+ meta: { at: Time.now.utc.iso8601 }
98
+ ))
99
+ { tenant: tenant, sessions: sessions, memory_records: memory_records,
100
+ outcomes: outcomes, funnel: funnel, followups: followups, contacts: contacts,
101
+ proposals: proposals, harvest: harvest, schedules: schedules,
102
+ artifacts: artifacts,
103
+ tokens_revoked: tokens_revoked }.merge(purged)
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command the LGPD access right): exports what the
8
+ # engine holds about ONE customer — their memory cell's facts + notes,
9
+ # expired facts excluded. The FULL content is the RETURN VALUE (the Studio
10
+ # turns it into a JSON download); the emitted event carries counts only, so
11
+ # the event stream, the audit store and every log stay content-free (D7).
12
+ # The return value never enters a store, an event or a log.
13
+ class ExportCustomerMemory
14
+ def initialize(memory_store:, event_stream:)
15
+ @memory_store = memory_store
16
+ @event_stream = event_stream
17
+ end
18
+
19
+ # payload: { customer: (REQUIRED), tenant: } — customer from the payload
20
+ # (an operator names the person); tenant from the payload || meta.
21
+ # -> { "customer" =>, "tenant" =>, "exported_at" =>, "facts" => [Fact#to_h],
22
+ # "notes" => [Note#to_h], "counts" => { "facts" =>, "notes" => } }
23
+ def call(command)
24
+ p = AgentPayload.symbolize(command.payload)
25
+ customer = AgentPayload.presence(p[:customer])
26
+ raise Insika::ValidationError, "customer is required" if customer.nil?
27
+
28
+ tenant = AgentPayload.presence(p[:tenant]) || command.meta[:tenant]
29
+ facts = @memory_store.facts(tenant: tenant, customer: customer)
30
+ notes = @memory_store.notes(tenant: tenant, customer: customer)
31
+ exported_at = Time.now.utc.iso8601
32
+
33
+ @event_stream.emit(Insika::Event.new(
34
+ type: :customer_memory_exported,
35
+ data: { customer: customer, tenant: tenant,
36
+ counts: { facts: facts.size, notes: notes.size } },
37
+ meta: { at: exported_at }
38
+ ))
39
+ {
40
+ "customer" => customer, "tenant" => tenant, "exported_at" => exported_at,
41
+ "facts" => facts.map { |f| Coercion.deep_stringify(f.to_h) },
42
+ "notes" => notes.map { |n| Coercion.deep_stringify(n.to_h) },
43
+ "counts" => { "facts" => facts.size, "notes" => notes.size }
44
+ }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command (WS8, phase 2 — LGPD): purges what the engine holds about
8
+ # ONE customer — the customer's memory cell, their sessions (found through
9
+ # the `customer` var the Executor stamps on a tagged conversation) and
10
+ # everything those sessions left behind (traces, tasks, checkpoints, outbox
11
+ # deliveries — see SessionPurge). The scope string IS the isolation
12
+ # boundary, so zeroing it cannot touch another customer's or another
13
+ # tenant's data. Operator-only BY CONSTRUCTION: the generic command ingress
14
+ # is operator-grade (a tenant principal never reaches it).
15
+ #
16
+ # WHICH TENANT is the whole correctness of the purge: the memory cell is
17
+ # "memory:<tenant>:<customer>" and the sessions are the "<tenant>:" ones. It
18
+ # is read from the command meta (an internal caller acting AS a tenant) or
19
+ # from the payload (`{ customer:, tenant: }` — the operator naming it over
20
+ # HTTP, where the principal has no tenant of its own). Absent from both, the
21
+ # purge is deployment-wide: the untagged memory cell plus that customer's
22
+ # sessions in EVERY tenant — right for a single-tenant deployment, and never
23
+ # what a multi-tenant operator means, so they must name the tenant.
24
+ class ForgetCustomer
25
+ include SessionPurge
26
+
27
+ def initialize(memory_store:, session_store:, tool_trace_store: nil,
28
+ context_trace_store: nil, model_visible_trace_store: nil,
29
+ task_store: nil, checkpoint_store: nil,
30
+ outbox_store: nil, shadow_pairs: nil, audit_store: nil, event_stream:,
31
+ followup_store: nil, contact_store: nil, proposal_store: nil)
32
+ @memory_store = memory_store
33
+ @session_store = session_store
34
+ @tool_trace_store = tool_trace_store
35
+ @context_trace_store = context_trace_store
36
+ @model_visible_trace_store = model_visible_trace_store # ; nil = parity
37
+ @task_store = task_store
38
+ @checkpoint_store = checkpoint_store
39
+ @outbox_store = outbox_store
40
+ @shadow_pairs = shadow_pairs
41
+ @audit_store = audit_store
42
+ @event_stream = event_stream
43
+ @followup_store = followup_store # ; nil = nothing to sweep
44
+ @contact_store = contact_store # ; nil = nothing to sweep
45
+ @proposal_store = proposal_store # ; nil = nothing to sweep
46
+ end
47
+
48
+ # -> { customer:, tenant:, memory_records:, sessions: [], tasks:,
49
+ # checkpoints:, deliveries:, followups:, contacts: }.
50
+ def call(command)
51
+ customer = Coercion.presence(command.payload[:customer] || command.payload["customer"])
52
+ raise ValidationError, "customer is required" if customer.nil?
53
+
54
+ tenant = command.meta[:tenant] ||
55
+ Coercion.presence(command.payload[:tenant] || command.payload["tenant"])
56
+ memory_scope = [tenant, customer].compact.join(":")
57
+ memory_records = @memory_store.purge(tenant: memory_scope)
58
+
59
+ # the follow-up footprint dies with the person — the
60
+ # schedule records and the contact cell (LGPD).
61
+ followups = @followup_store&.purge_customer(tenant: tenant, customer: customer) || 0
62
+ contacts = @contact_store&.delete(tenant: tenant, customer: customer) ? 1 : 0
63
+
64
+ # the distilled PROPOSALS die with the person — a proposal
65
+ # is born inside a customer cell (D6), so forget_customer reaches it.
66
+ proposals = @proposal_store&.purge_customer(tenant: tenant, customer: customer) || 0
67
+
68
+ sessions = session_ids_for(customer, tenant)
69
+ purged = purge_sessions(sessions)
70
+
71
+ # the audit records the thing that happened, content-free —
72
+ # a digest-free line with the counts (the deleted VALUES never enter
73
+ # the audit store). Written AFTER the purge, so the line describes a
74
+ # deletion that actually happened. nil audit_store = no-op.
75
+ @audit_store&.record(
76
+ cell: @memory_store.cell_for(tenant, customer),
77
+ action: "purge", actor: operator(command), tenant: tenant, customer: customer,
78
+ note: "memory_records: #{memory_records}, sessions: #{sessions.size}"
79
+ )
80
+
81
+ @event_stream.emit(Insika::Event.new(
82
+ type: :customer_forgotten,
83
+ data: { customer: customer, tenant: tenant,
84
+ memory_records: memory_records,
85
+ sessions: sessions,
86
+ followups: followups,
87
+ contacts: contacts,
88
+ proposals: proposals }.merge(purged),
89
+ meta: { at: Time.now.utc.iso8601 }
90
+ ))
91
+ { customer: customer, tenant: tenant, memory_records: memory_records,
92
+ sessions: sessions, followups: followups, contacts: contacts,
93
+ proposals: proposals }.merge(purged)
94
+ end
95
+
96
+ private
97
+
98
+ def operator(command)
99
+ Coercion.presence(command.payload[:operator] || command.payload["operator"]) || "operator"
100
+ end
101
+
102
+ # The customer's sessions: the ones the Executor stamped with this
103
+ # customer var — plus, in multi_tenant, only ids inside the tenant's
104
+ # "<tenant>:" namespace (single_tenant sessions carry no prefix).
105
+ def session_ids_for(customer, tenant)
106
+ @session_store.each_id.each_with_object([]) do |id, acc|
107
+ session = @session_store.find(id)
108
+ next unless session
109
+ next unless Coercion.presence(session.vars["customer"]).to_s == customer.to_s
110
+ next if tenant && !id.to_s.start_with?("#{tenant}:")
111
+
112
+ acc << id.to_s
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "date"
5
+
6
+ module Insika
7
+ module Commands
8
+ # the operator's FREEZE — turn the folded counts over a
9
+ # declared span into the baseline snapshot (A/B follow-up) and
10
+ # (promotion gate) read. One command, dispatched from the Studio
11
+ # and available to any operator-grade caller. Synchronous control command —
12
+ # no task, no actor, same discipline as RecordOutcome (WS7).
13
+ #
14
+ # The ≥ 4-week rule ("no 1.0 target before a remeasure") is
15
+ # enforced HERE, not by convention: a frozen span shorter than 28 days is a
16
+ # ValidationError. `frozen_at` stamps when; the baseline OVERWRITES (D5) —
17
+ # the freeze event is the audit surface.
18
+ class FreezeFunnelBaseline
19
+ MIN_SPAN_DAYS = 28
20
+
21
+ def initialize(funnel_store:, profiles:, event_stream:)
22
+ @funnel_store = funnel_store
23
+ @profiles = profiles
24
+ @event_stream = event_stream
25
+ end
26
+
27
+ # payload: { agent:, tenant:, from: "YYYY-MM-DD", to: "YYYY-MM-DD", operator: }
28
+ # agent REQUIRED (ValidationError); tenant from payload || meta[:tenant].
29
+ # from/to optional — default to the pair's folded span (first/last day
30
+ # cell); explicit from/to are inclusive bounds.
31
+ # -> the baseline record.
32
+ def call(command)
33
+ payload = command.payload
34
+ agent = Coercion.presence(payload[:agent] || payload["agent"])
35
+ raise ValidationError, "agent is required" if agent.nil?
36
+
37
+ declaration = declaration_for(agent)
38
+ tenant = payload[:tenant] || payload["tenant"] || command.meta[:tenant]
39
+
40
+ from, to = bounds(payload, tenant, agent)
41
+ validate_span!(from, to)
42
+
43
+ stages = @funnel_store.days(tenant: tenant, agent: agent, from: from, to: to)
44
+ .each_with_object(Hash.new(0)) do |(_, counts), acc|
45
+ counts.each { |stage, n| acc[stage] += n }
46
+ end
47
+ primary = declaration.primary
48
+ primary_count = stages[primary].to_i
49
+ first_count = stages[declaration.first_stage].to_i
50
+ conversion = first_count.zero? ? nil : primary_count.to_f / first_count
51
+
52
+ baseline = {
53
+ "from" => from, "to" => to, "stages" => stages,
54
+ "primary" => primary, "primary_count" => primary_count,
55
+ "conversion" => conversion, "window" => declaration.attribution_window,
56
+ "frozen_at" => Time.now.utc.iso8601
57
+ }
58
+ @funnel_store.set_baseline(tenant: tenant, agent: agent, record: baseline)
59
+
60
+ @event_stream.emit(Insika::Event.new(
61
+ type: :funnel_baseline_frozen,
62
+ data: { agent: agent, from: from, to: to,
63
+ primary: primary, primary_count: primary_count,
64
+ conversion: conversion },
65
+ meta: { at: Time.now.utc.iso8601 }
66
+ ))
67
+ baseline
68
+ end
69
+
70
+ private
71
+
72
+ # -> FunnelDeclaration | raise — the freeze needs the parsed declaration,
73
+ # so a malformed/absent one is a hard ValidationError (D8: the FOLD skips
74
+ # malformed declarations, but freezing without one is an operator error).
75
+ def declaration_for(agent)
76
+ profile = @profiles.fetch(agent)
77
+ decl = profile && Insika::FunnelDeclaration.parse(profile.funnel)
78
+ return decl if decl
79
+
80
+ raise ValidationError, "agent '#{agent}' has no valid funnel declaration"
81
+ end
82
+
83
+ # Explicit from/to (inclusive) or the pair's folded span (first/last day
84
+ # cell). -> [String, String].
85
+ def bounds(payload, tenant, agent)
86
+ from = Coercion.presence(payload[:from] || payload["from"])
87
+ to = Coercion.presence(payload[:to] || payload["to"])
88
+ # both given -> no scan at all (a pair with no cells still freezes,
89
+ # over a zero-count span, when the operator names the dates)
90
+ return [from, to] if from && to
91
+
92
+ days = @funnel_store.days(tenant: tenant, agent: agent)
93
+ days_keys = days.keys
94
+ raise ValidationError, "no folded days for agent '#{agent}'" if days_keys.empty?
95
+
96
+ [from || days_keys.first, to || days_keys.last]
97
+ end
98
+
99
+ def validate_span!(from, to)
100
+ raise ValidationError, "from must be before to" if from >= to
101
+
102
+ span = (Date.iso8601(to) - Date.iso8601(from)).to_i
103
+ if span < MIN_SPAN_DAYS
104
+ raise ValidationError,
105
+ "baseline span must cover at least #{MIN_SPAN_DAYS} days " \
106
+ "(the number needs a remeasured baseline)"
107
+ end
108
+ rescue Date::Error
109
+ raise ValidationError, "from/to must be ISO dates (YYYY-MM-DD)"
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # — the double gate on ONE candidate, in order:
8
+ # eval first (expensive — a full golden replay), conversion second (cheap
9
+ # — a fold read). Synchronous control command (the Studio dispatches it;
10
+ # the gate is minutes of real replay, exactly like `gate_refinement`).
11
+ # It records both reports on the candidate and moves it: both passed ->
12
+ # awaiting_approval; either failed -> rejected with the reason (a failed
13
+ # candidate is terminal — the same finding must re-surface with new
14
+ # evidence, no silent retry).
15
+ class GateHarvest
16
+ def initialize(harvest_store:, gate:, conversion_gate:, criterion: nil, event_stream:)
17
+ @harvest_store = harvest_store
18
+ @gate = gate
19
+ @conversion_gate = conversion_gate
20
+ @criterion = criterion
21
+ @event_stream = event_stream
22
+ end
23
+
24
+ # payload: { candidate_id: } (tenant from command.meta — WS1)
25
+ # -> Candidate (awaiting_approval | rejected | gated-parked; both
26
+ # reports attached)
27
+ def call(command)
28
+ p = AgentPayload.symbolize(command.payload)
29
+ candidate_id = AgentPayload.presence(p[:candidate_id])
30
+ raise Insika::ValidationError, "candidate_id is required" if candidate_id.nil?
31
+
32
+ candidate = @harvest_store.find_candidate(candidate_id) ||
33
+ (raise Insika::NotFoundError, "harvest candidate not found: #{candidate_id}")
34
+
35
+ # The base graph wires NO gates (the deployment owns the eval surface):
36
+ # the candidate parks at gated with the named reason — refuse-with-a-
37
+ # named-reason, never pass, never crash.
38
+ if @gate.nil? || @conversion_gate.nil?
39
+ parked = @harvest_store.attach_gate(
40
+ candidate.id,
41
+ eval_gate: { "passed" => false, "reason" => "no_eval_gate_wired" },
42
+ conversion_gate: { "passed" => false, "reason" => "no_conversion_gate_wired" },
43
+ criterion_sha: nil
44
+ )
45
+ return parked
46
+ end
47
+
48
+ # The mining budget is read HERE too (the review fix): once the mining
49
+ # pass spent the pack's cap, the expensive golden replay is refused
50
+ # with the named reason — the Refinement::Budget discipline ("not
51
+ # gated — the budget was spent"), applied before the provider bill.
52
+ run = @harvest_store.find_run(candidate.run_id)
53
+ cap = run && run.budget && run.budget["tokens"].to_i
54
+ spent = run && run.cost && run.cost["spent"].to_i
55
+ if cap&.positive? && spent.to_i >= cap
56
+ parked = @harvest_store.attach_gate(
57
+ candidate.id,
58
+ eval_gate: { "passed" => false, "reason" => "budget_exceeded",
59
+ "spent" => spent, "tokens" => cap },
60
+ conversion_gate: { "passed" => false, "reason" => "budget_exceeded" },
61
+ criterion_sha: nil
62
+ )
63
+ return parked
64
+ end
65
+
66
+ skill = { "name" => candidate.name, "description" => candidate.description,
67
+ "body" => candidate.body, "triggers" => candidate.triggers }
68
+ eval_report = @gate.score(agent_id: candidate.agent, skill: skill,
69
+ run_id: candidate.run_id)
70
+ conversion_report = @conversion_gate.call(tenant: command.meta[:tenant],
71
+ agent: candidate.agent)
72
+ criterion_sha = @criterion ? @criterion.sha : nil
73
+
74
+ # A conversion REFUSAL (missing data — :no_frozen_baseline etc.) is NOT
75
+ # a candidate failure: it parks the candidate at gated with both
76
+ # reports and the named reason — the page shows the ruler's hole.
77
+ # Only a conversion FAILURE (current worse than baseline) rejects.
78
+ if refused?(conversion_report)
79
+ gated = @harvest_store.attach_gate(candidate.id,
80
+ eval_gate: eval_report.to_h,
81
+ conversion_gate: conversion_report.to_h,
82
+ criterion_sha: criterion_sha)
83
+ emit(:harvest_gated, candidate, gated, eval_report, conversion_report)
84
+ return gated
85
+ end
86
+
87
+ passed = eval_report.passed && conversion_report.passed
88
+ @harvest_store.attach_gate(candidate.id,
89
+ eval_gate: eval_report.to_h,
90
+ conversion_gate: conversion_report.to_h,
91
+ criterion_sha: criterion_sha)
92
+ result = if passed
93
+ @harvest_store.mark_awaiting(candidate.id)
94
+ else
95
+ reason = eval_report.passed ? conversion_report.reason : eval_report.reason
96
+ @harvest_store.mark_rejected(candidate.id, operator: "gate", note: reason)
97
+ end
98
+ emit(:harvest_gated, candidate, result, eval_report, conversion_report)
99
+ result
100
+ end
101
+
102
+ private
103
+
104
+ # The D6 refusals: the ruler is MISSING, not moving — the candidate
105
+ # parks. Anything else with passed: false is a real failure.
106
+ def refused?(report)
107
+ !report.passed && REFUSAL_REASONS.include?(report.reason)
108
+ end
109
+
110
+ REFUSAL_REASONS = %i[no_criterion no_funnel no_frozen_baseline metric_mismatch no_fold
111
+ baseline_span_short no_baseline_rate budget_exceeded].freeze
112
+
113
+ def emit(type, candidate, result, eval_report, conversion_report)
114
+ @event_stream.emit(Insika::Event.new(
115
+ type: type,
116
+ # ids and verdicts, never the skill body
117
+ data: { run_id: candidate.run_id, candidate_id: candidate.id,
118
+ agent: candidate.agent,
119
+ eval_passed: eval_report.passed,
120
+ conversion_passed: conversion_report.passed,
121
+ reason: verdict_reason(result, eval_report, conversion_report) },
122
+ meta: { at: Time.now.utc.iso8601 }
123
+ ))
124
+ end
125
+
126
+ # The refusal/failure reason as an id the operator stream can act on —
127
+ # the symbol, not the human note.
128
+ def verdict_reason(result, eval_report, conversion_report)
129
+ return nil if result.status == "awaiting_approval"
130
+
131
+ return eval_report.reason unless eval_report.passed
132
+ return conversion_report.reason if conversion_report.reason
133
+
134
+ result.decision && result.decision["note"]
135
+ end
136
+ end
137
+ end
138
+ end
@@ -12,7 +12,7 @@ module Insika
12
12
  # Synchronous like `run_refinement`, and for the same reason: it is operator-paced
13
13
  # work with a human waiting on the answer. It is NOT cheap — the replay is a real
14
14
  # conversation per golden case — so it is fired deliberately, never on a timer
15
- # (the engine has no scheduler and this RFC does not add one).
15
+ # (a recurring timer may schedule OTHER turns; this gate stays human-paced).
16
16
  #
17
17
  # Payload:
18
18
  # run_id (required) a run in :completed — the evidence the candidate answers
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # C7 — turn complete shadow pairs into judged pairs, using the judges the
8
+ # operator ALREADY configured (settings["evals"], through JudgePanel — the
9
+ # one builder the eval CLI and the refinement gate use too). Never automatic:
10
+ # a Studio button or the CLI dispatches it, and it states its cost
11
+ # (2 provider calls per judge per pair, both orders — position-bias
12
+ # rule) before it runs.
13
+ #
14
+ # Refusals are visible, a fake judge is not: no configured judges, or fewer
15
+ # models than the criterion demands, is a ValidationError — an unjudged
16
+ # window reads :invalid, where a window judged by judges nobody chose reads
17
+ # as evidence.
18
+ class JudgeShadowPairs
19
+ DEFAULT_LIMIT = 50
20
+ DEFAULT_EXPIRE_HOURS = 24
21
+
22
+ def initialize(shadow_pairs:, settings_store:, criterion:, llm: nil, event_stream: nil)
23
+ @shadow_pairs = shadow_pairs
24
+ @settings_store = settings_store
25
+ @criterion = criterion
26
+ @llm = llm
27
+ @event_stream = event_stream
28
+ end
29
+
30
+ # payload: { limit: Integer = 50, agent: String|nil, expire_after_hours: Integer|nil }
31
+ # -> { judged:, skipped:, expired:, failed:, models: [..] }
32
+ def call(command)
33
+ payload = command.payload
34
+ limit = begin
35
+ Integer(payload[:limit] || payload["limit"] || DEFAULT_LIMIT)
36
+ rescue ArgumentError
37
+ raise ValidationError, "limit must be an integer"
38
+ end
39
+ agent = Coercion.presence(payload[:agent] || payload["agent"])
40
+ expire_hours = begin
41
+ Integer(payload[:expire_after_hours] || payload["expire_after_hours"] || DEFAULT_EXPIRE_HOURS)
42
+ rescue ArgumentError
43
+ raise ValidationError, "expire_after_hours must be an integer"
44
+ end
45
+
46
+ # Judges BEFORE the expire: a refusal (no judges, too few models) must
47
+ # leave every pair untouched — expiring first would stamp :incomplete on
48
+ # pairs and that is terminal evidence against a click that judged nothing.
49
+ panel = Insika::Evals::JudgePanel.pairwise(settings, llm: @llm)
50
+ raise ValidationError, "no judges configured in settings['evals'] — refusing to judge without judges" if panel.nil?
51
+
52
+ pairwise, models = panel
53
+ minimum = @criterion.rule.min_judge_models
54
+ distinct = models.uniq
55
+ if distinct.length < minimum
56
+ raise ValidationError, "the criterion requires at least #{minimum} distinct judge models; " \
57
+ "#{distinct.length} configured (#{distinct.join(', ')})"
58
+ end
59
+
60
+ # A mirror that dropped a half stops looking like pending work.
61
+ expired = @shadow_pairs.expire(older_than: Time.now.utc - (expire_hours * 3600))
62
+
63
+ judged = 0
64
+ failed = 0
65
+ @shadow_pairs.unjudged(limit: limit, agent: agent).each do |pair|
66
+ if judge_pair(pair, pairwise, models)
67
+ judged += 1
68
+ else
69
+ failed += 1
70
+ end
71
+ end
72
+ skipped = @shadow_pairs.unjudged(agent: agent).length
73
+
74
+ emit_summary(judged: judged, skipped: skipped, expired: expired, failed: failed, models: models)
75
+ { judged: judged, skipped: skipped, expired: expired, failed: failed, models: models }
76
+ end
77
+
78
+ private
79
+
80
+ def settings
81
+ (@settings_store.get || {})["evals"] || {}
82
+ end
83
+
84
+ # -> true when the pair was judged. A raising judge leaves the pair
85
+ # :complete (retryable on the next press) and never aborts the batch; an
86
+ # empty side is recorded :unknown — visible, never a preference. The rescue
87
+ # is broad on purpose: a provider outage raises the transport's own error
88
+ # class (RubyLLM::Error, an HTTP status as StandardError), NOT an
89
+ # Insika::Error — and one broken provider must not sink the rest of the
90
+ # batch against this method's own contract.
91
+ def judge_pair(pair, pairwise, models)
92
+ verdict = pairwise.compare_texts(
93
+ ours: Insika::Parity.transcript(pair.inbound, pair.insika_reply),
94
+ theirs: Insika::Parity.transcript(pair.inbound, pair.incumbent_reply)
95
+ )
96
+ verdict ||= Insika::Evals::Pairwise::Verdict.new(
97
+ outcome: "unknown", reason: "a side of the pair was empty", vs: "agent",
98
+ judges: [], order_dependent: false
99
+ )
100
+ blob = verdict.to_h.merge("judged_at" => Time.now.utc.iso8601, "models" => models)
101
+ @shadow_pairs.record_verdict(pair.id, verdict: blob)
102
+ @event_stream&.emit(Insika::Event.new(
103
+ type: :shadow_judged,
104
+ data: { pair_id: pair.id, agent: pair.agent, outcome: blob[:outcome] },
105
+ meta: { at: Time.now.utc.iso8601 }
106
+ ))
107
+ true
108
+ rescue StandardError
109
+ false
110
+ end
111
+
112
+ def emit_summary(judged:, skipped:, expired:, failed:, models:)
113
+ return unless @event_stream
114
+
115
+ @event_stream.emit(Insika::Event.new(
116
+ type: :shadow_judge_batch,
117
+ data: { judged: judged, skipped: skipped, expired: expired,
118
+ failed: failed, models: models },
119
+ meta: { at: Time.now.utc.iso8601 }
120
+ ))
121
+ end
122
+ end
123
+ end
124
+ end