insika 0.2.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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +145 -17
  3. data/README.md +7 -1
  4. data/bin/insika +211 -8
  5. data/docs/AGENTS.md +377 -2
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +99 -9
  8. data/docs/CONTEXT.md +127 -16
  9. data/docs/DEPLOY.md +5 -2
  10. data/docs/EVALS.md +19 -0
  11. data/docs/FACTS.md +135 -0
  12. data/docs/HARVEST.md +117 -0
  13. data/docs/LOADTEST.md +14 -8
  14. data/docs/OBSERVABILITY.md +54 -0
  15. data/docs/REFINEMENT.md +3 -3
  16. data/docs/RELEASING.md +27 -0
  17. data/docs/RUNNING-LOCAL.md +3 -3
  18. data/docs/SECURITY.md +61 -0
  19. data/docs/SKILLS.md +2 -2
  20. data/docs/SOAK.md +127 -0
  21. data/docs/TOOLS.md +70 -2
  22. data/docs/domain.md +115 -0
  23. data/docs/index.md +1 -1
  24. data/lib/insika/agent_profile.rb +145 -10
  25. data/lib/insika/balloon_splitter.rb +102 -0
  26. data/lib/insika/budget_ledger.rb +34 -3
  27. data/lib/insika/cache_series_store.rb +49 -0
  28. data/lib/insika/channel_delivery.rb +118 -13
  29. data/lib/insika/channels/relay.rb +77 -3
  30. data/lib/insika/channels/web.rb +2 -2
  31. data/lib/insika/chat_builder.rb +84 -1
  32. data/lib/insika/checkpoint_store.rb +16 -0
  33. data/lib/insika/commands/agent_payload.rb +3 -3
  34. data/lib/insika/commands/cancel_followup.rb +49 -0
  35. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  36. data/lib/insika/commands/export_customer_memory.rb +48 -0
  37. data/lib/insika/commands/forget_customer.rb +117 -0
  38. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  39. data/lib/insika/commands/gate_harvest.rb +138 -0
  40. data/lib/insika/commands/gate_refinement.rb +1 -1
  41. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  42. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  43. data/lib/insika/commands/memory_put_fact.rb +23 -4
  44. data/lib/insika/commands/promote_harvest.rb +130 -0
  45. data/lib/insika/commands/record_outcome.rb +46 -0
  46. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  47. data/lib/insika/commands/reject_harvest.rb +38 -0
  48. data/lib/insika/commands/resolve_proposal.rb +108 -0
  49. data/lib/insika/commands/revoke_contact.rb +49 -0
  50. data/lib/insika/commands/rollback_harvest.rb +86 -0
  51. data/lib/insika/commands/run_distillation.rb +186 -0
  52. data/lib/insika/commands/run_harvest.rb +393 -0
  53. data/lib/insika/commands/send_message.rb +103 -6
  54. data/lib/insika/commands/session_purge.rb +67 -0
  55. data/lib/insika/contact_store.rb +183 -0
  56. data/lib/insika/context/builder.rb +21 -3
  57. data/lib/insika/context/fragment.rb +7 -3
  58. data/lib/insika/context/priority.rb +3 -0
  59. data/lib/insika/context/provider.rb +17 -3
  60. data/lib/insika/context/providers/briefing.rb +96 -0
  61. data/lib/insika/context/providers/memory.rb +16 -7
  62. data/lib/insika/context/providers/prompt.rb +30 -2
  63. data/lib/insika/context/providers/skill.rb +2 -0
  64. data/lib/insika/context/providers/tool_search.rb +2 -0
  65. data/lib/insika/context_trace_store.rb +38 -2
  66. data/lib/insika/distill.rb +224 -0
  67. data/lib/insika/distill_engine.rb +169 -0
  68. data/lib/insika/doctor.rb +716 -6
  69. data/lib/insika/dsl/runtime.rb +9 -3
  70. data/lib/insika/dsl/server_boot.rb +70 -1
  71. data/lib/insika/dsl.rb +83 -3
  72. data/lib/insika/edge_limiter.rb +28 -7
  73. data/lib/insika/env_schema.rb +9 -2
  74. data/lib/insika/errors.rb +11 -0
  75. data/lib/insika/evals/assertions.rb +3 -2
  76. data/lib/insika/evals/pairwise.rb +18 -6
  77. data/lib/insika/evidence.rb +183 -0
  78. data/lib/insika/executor.rb +687 -70
  79. data/lib/insika/followup_engine.rb +207 -0
  80. data/lib/insika/followup_policy.rb +221 -0
  81. data/lib/insika/followup_store.rb +306 -0
  82. data/lib/insika/funnel_declaration.rb +106 -0
  83. data/lib/insika/funnel_fold.rb +179 -0
  84. data/lib/insika/funnel_store.rb +163 -0
  85. data/lib/insika/golden_store.rb +1 -1
  86. data/lib/insika/grounding/matcher.rb +69 -0
  87. data/lib/insika/grounding.rb +44 -0
  88. data/lib/insika/harvest/conversion_gate.rb +159 -0
  89. data/lib/insika/harvest/criterion.rb +98 -0
  90. data/lib/insika/harvest/gate.rb +194 -0
  91. data/lib/insika/harvest/negative_list.rb +199 -0
  92. data/lib/insika/harvest.rb +241 -0
  93. data/lib/insika/harvest_engine.rb +193 -0
  94. data/lib/insika/harvest_store.rb +548 -0
  95. data/lib/insika/media.rb +298 -0
  96. data/lib/insika/memory_audit_store.rb +85 -0
  97. data/lib/insika/memory_store.rb +264 -23
  98. data/lib/insika/message_origin.rb +6 -1
  99. data/lib/insika/model_visible.rb +87 -0
  100. data/lib/insika/model_visible_trace_store.rb +66 -0
  101. data/lib/insika/onboarding.rb +6 -1
  102. data/lib/insika/outbox_store.rb +42 -4
  103. data/lib/insika/outcome_store.rb +147 -0
  104. data/lib/insika/packaging.rb +163 -0
  105. data/lib/insika/parity/criterion.rb +79 -0
  106. data/lib/insika/parity/verdict.rb +318 -0
  107. data/lib/insika/prefix_fingerprint.rb +58 -0
  108. data/lib/insika/profile_source.rb +22 -1
  109. data/lib/insika/proposal_store.rb +271 -0
  110. data/lib/insika/queue_policy.rb +4 -1
  111. data/lib/insika/refinement/proposer.rb +1 -1
  112. data/lib/insika/reliability.rb +32 -6
  113. data/lib/insika/retention.rb +281 -0
  114. data/lib/insika/routing.rb +101 -0
  115. data/lib/insika/safety/config.rb +44 -4
  116. data/lib/insika/safety/corpus.rb +255 -0
  117. data/lib/insika/safety/detectors.rb +32 -113
  118. data/lib/insika/safety/factory.rb +15 -2
  119. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  120. data/lib/insika/safety/grounding_validator.rb +49 -0
  121. data/lib/insika/safety/input_guardrail.rb +1 -1
  122. data/lib/insika/safety/moderator.rb +1 -1
  123. data/lib/insika/safety/output_filter.rb +9 -5
  124. data/lib/insika/safety/output_validator.rb +11 -5
  125. data/lib/insika/schema_guard.rb +35 -0
  126. data/lib/insika/server/app.rb +144 -4
  127. data/lib/insika/server/rack_app.rb +16 -0
  128. data/lib/insika/server/responses.rb +40 -1
  129. data/lib/insika/session_actor.rb +8 -4
  130. data/lib/insika/session_store.rb +65 -2
  131. data/lib/insika/settings_store.rb +10 -0
  132. data/lib/insika/shadow_pair_store.rb +258 -0
  133. data/lib/insika/skill_catalog.rb +4 -0
  134. data/lib/insika/soak/envelope.rb +140 -0
  135. data/lib/insika/soak/report.rb +392 -0
  136. data/lib/insika/soak/runner.rb +554 -0
  137. data/lib/insika/store.rb +10 -1
  138. data/lib/insika/stores/memory.rb +6 -0
  139. data/lib/insika/stores/sqlite.rb +8 -0
  140. data/lib/insika/studio/app.rb +875 -14
  141. data/lib/insika/studio/assets/dist/application.css +1 -1
  142. data/lib/insika/studio/assets/dist/application.js +22 -22
  143. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  144. data/lib/insika/studio/forms.rb +262 -5
  145. data/lib/insika/studio/nav_icons.rb +6 -1
  146. data/lib/insika/studio/views/agent_detail.erb +627 -84
  147. data/lib/insika/studio/views/agents.erb +10 -6
  148. data/lib/insika/studio/views/approvals.erb +4 -1
  149. data/lib/insika/studio/views/chats.erb +4 -1
  150. data/lib/insika/studio/views/customer.erb +94 -0
  151. data/lib/insika/studio/views/customers.erb +32 -0
  152. data/lib/insika/studio/views/evals.erb +4 -1
  153. data/lib/insika/studio/views/facts.erb +133 -0
  154. data/lib/insika/studio/views/followups.erb +125 -0
  155. data/lib/insika/studio/views/funnel.erb +106 -0
  156. data/lib/insika/studio/views/harvest.erb +234 -0
  157. data/lib/insika/studio/views/home.erb +2 -1
  158. data/lib/insika/studio/views/layout.erb +1 -0
  159. data/lib/insika/studio/views/parity.erb +147 -0
  160. data/lib/insika/studio/views/playground.erb +7 -1
  161. data/lib/insika/studio/views/session.erb +56 -1
  162. data/lib/insika/studio/views/settings.erb +2 -0
  163. data/lib/insika/studio/views/skills.erb +0 -1
  164. data/lib/insika/studio/views/system_files.erb +1 -1
  165. data/lib/insika/studio/views/task.erb +13 -0
  166. data/lib/insika/studio/views/tasks.erb +4 -1
  167. data/lib/insika/studio/views/tools.erb +0 -1
  168. data/lib/insika/task_store.rb +21 -1
  169. data/lib/insika/testing/store_contract.rb +27 -6
  170. data/lib/insika/tick.rb +25 -1
  171. data/lib/insika/tool_definition.rb +17 -7
  172. data/lib/insika/tool_envelope.rb +69 -0
  173. data/lib/insika/tool_manifest.rb +5 -1
  174. data/lib/insika/tools/data_defined_tool.rb +10 -0
  175. data/lib/insika/tools/generate_image.rb +44 -0
  176. data/lib/insika/tools/schedule_followup.rb +164 -0
  177. data/lib/insika/tools/tts.rb +47 -0
  178. data/lib/insika/tools/update_briefing.rb +126 -0
  179. data/lib/insika/turn_state.rb +38 -1
  180. data/lib/insika/turn_timing.rb +22 -2
  181. data/lib/insika/version.rb +1 -1
  182. data/lib/insika/vitals.rb +84 -0
  183. data/lib/insika/wiring/graph.rb +300 -13
  184. data/lib/insika.rb +92 -3
  185. metadata +77 -1
@@ -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
+ # (the engine has no scheduler and this design does not add one).
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
@@ -7,10 +7,15 @@ module Insika
7
7
  # Control command: forgets (removes) a fact from the
8
8
  # agent's memory. Idempotent: forgetting something that doesn't exist is not an error
9
9
  # (`existed: false`). Scoped by `tenant`. -> { existed: bool }.
10
+ #
11
+ # with an `audit_store:` collaborator, appends a content-free
12
+ # audit line (old_hash only — the deleted value's digest, never the
13
+ # value). Optional: nil = no audit.
10
14
  class MemoryForgetFact
11
- def initialize(memory_store:, event_stream:)
15
+ def initialize(memory_store:, event_stream:, audit_store: nil)
12
16
  @memory_store = memory_store
13
17
  @event_stream = event_stream
18
+ @audit_store = audit_store
14
19
  end
15
20
 
16
21
  def call(command)
@@ -19,14 +24,25 @@ module Insika
19
24
  raise Insika::ValidationError, "key is required" if key.nil?
20
25
 
21
26
  tenant = AgentPayload.presence(p[:tenant]) || command.meta[:tenant]
22
- existed = @memory_store.forget_fact(tenant: tenant, key: key)
27
+ customer = AgentPayload.presence(p[:customer])
28
+ operator = AgentPayload.presence(p[:operator]) || command.meta[:operator] || "operator"
29
+
30
+ scope = @memory_store.cell_for(tenant, customer)
31
+ old = @memory_store.get_fact(tenant: tenant, key: key, customer: customer)
32
+ existed = @memory_store.forget_fact(tenant: tenant, key: key, customer: customer)
33
+ @audit_store&.record(
34
+ cell: scope, action: "forget", actor: operator, key: key,
35
+ tenant: tenant, customer: customer,
36
+ old_hash: old && MemoryAuditStore.digest(old.value)
37
+ )
23
38
  @event_stream.emit(Insika::Event.new(
24
39
  type: :memory_fact_forgotten,
25
- data: { tenant: tenant, key: key, existed: existed },
40
+ data: { tenant: tenant, key: key, customer: customer,
41
+ existed: existed }.compact,
26
42
  meta: { at: Time.now.utc.iso8601 }
27
43
  ))
28
44
  { existed: existed }
29
45
  end
30
46
  end
31
47
  end
32
- end
48
+ end
@@ -9,10 +9,16 @@ module Insika
9
9
  # from WITHIN the turn (the `remember` tool); this Command is the HTTP surface the
10
10
  # Studio uses to edit facts directly. Scoped by `tenant` (nil = _default).
11
11
  # Synchronous; does not create a Task. -> Fact.
12
+ #
13
+ # the OPERATOR surface — every write stamps `origin: "operator"`
14
+ # (the `remember` tool keeps "engine") and, with an `audit_store:`
15
+ # collaborator, appends a content-free audit line (old/new digests). The
16
+ # audit is optional: nil = no audit (specs, minimal graphs).
12
17
  class MemoryPutFact
13
- def initialize(memory_store:, event_stream:)
18
+ def initialize(memory_store:, event_stream:, audit_store: nil)
14
19
  @memory_store = memory_store
15
20
  @event_stream = event_stream
21
+ @audit_store = audit_store
16
22
  end
17
23
 
18
24
  def call(command)
@@ -22,14 +28,27 @@ module Insika
22
28
  raise Insika::ValidationError, "value is required" if p[:value].nil?
23
29
 
24
30
  tenant = AgentPayload.presence(p[:tenant]) || command.meta[:tenant]
25
- fact = @memory_store.put_fact(tenant: tenant, key: key, value: p[:value])
31
+ customer = AgentPayload.presence(p[:customer])
32
+ operator = AgentPayload.presence(p[:operator]) || command.meta[:operator] || "operator"
33
+
34
+ scope = @memory_store.cell_for(tenant, customer)
35
+ old = @memory_store.get_fact(tenant: tenant, key: key, customer: customer)
36
+ fact = @memory_store.put_fact(tenant: tenant, key: key, value: p[:value],
37
+ customer: customer, origin: "operator",
38
+ expires_at: AgentPayload.presence(p[:expires_at]))
39
+ @audit_store&.record(
40
+ cell: scope, action: "put", actor: operator, key: key,
41
+ tenant: tenant, customer: customer,
42
+ old_hash: old && MemoryAuditStore.digest(old.value),
43
+ new_hash: MemoryAuditStore.digest(fact.value)
44
+ )
26
45
  @event_stream.emit(Insika::Event.new(
27
46
  type: :memory_fact_put,
28
- data: { tenant: tenant, key: key },
47
+ data: { tenant: tenant, key: key, customer: customer }.compact,
29
48
  meta: { at: Time.now.utc.iso8601 }
30
49
  ))
31
50
  fact
32
51
  end
33
52
  end
34
53
  end
35
- end
54
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # — the human's answer, the ONLY path that lands a
8
+ # skill. Snapshot FIRST (the pre-promotion state), then the two existing
9
+ # write commands, then the append-only log row (D8). A promotion row's
10
+ # `rolled_back_at` makes the log the single ledger; re-promotion after a
11
+ # rollback is a NEW row with a NEW snapshot. The candidate flips to
12
+ # `promoted` ONLY after the writes land (record-after).
13
+ class PromoteHarvest
14
+ def initialize(harvest_store:, skill_store:, skill_catalog:, profile_source:,
15
+ criterion:, conversion_gate:, event_stream:,
16
+ write_skill: nil, set_skill_agents: nil)
17
+ @harvest_store = harvest_store
18
+ @skill_store = skill_store
19
+ @skill_catalog = skill_catalog
20
+ @profiles = ProfileSource.coerce(profile_source)
21
+ @criterion = criterion
22
+ @conversion_gate = conversion_gate
23
+ @event_stream = event_stream
24
+ @write_skill = write_skill ||
25
+ Insika::Commands::WriteSkill.new(skill_store: skill_store,
26
+ skill_catalog: skill_catalog,
27
+ event_stream: event_stream)
28
+ @set_skill_agents = set_skill_agents ||
29
+ Insika::Commands::SetSkillAgents.new(profile_source: @profiles,
30
+ event_stream: event_stream)
31
+ end
32
+
33
+ # payload: { candidate_id:, operator?, note? }
34
+ # operator from payload || command.meta[:operator] || "operator"
35
+ # -> Candidate (promoted | gated — the conversion re-check moved, D8-bis)
36
+ def call(command)
37
+ p = AgentPayload.symbolize(command.payload)
38
+ candidate_id = AgentPayload.presence(p[:candidate_id])
39
+ raise Insika::ValidationError, "candidate_id is required" if candidate_id.nil?
40
+
41
+ candidate = @harvest_store.find_candidate(candidate_id) ||
42
+ (raise Insika::NotFoundError, "harvest candidate not found: #{candidate_id}")
43
+ operator = (AgentPayload.presence(p[:operator]) || command.meta[:operator] || "operator").to_s
44
+
45
+ # D8-bis — the conversion ruler is RE-READ at the moment the skill
46
+ # lands (cheap: a fold read). A dip below the frozen baseline since
47
+ # gating PARKS the candidate at gated with the current numbers — the
48
+ # operator re-decides alertly. The eval gate is NOT re-run (expensive,
49
+ # and the skill body is not anchored to instruction files — nothing
50
+ # drifted to re-validate). A base graph without a conversion gate
51
+ # refuses with the named reason — never passes, never crashes.
52
+ current = if @conversion_gate
53
+ @conversion_gate.call(tenant: command.meta[:tenant], agent: candidate.agent)
54
+ else
55
+ Insika::Harvest::ConversionGate::Result.new(
56
+ passed: false, reason: :no_conversion_gate_wired, metric: nil, window: nil,
57
+ current: nil, baseline: nil, threshold: nil, snapshot_ref: nil
58
+ )
59
+ end
60
+ return @harvest_store.recheck_conversion(candidate.id, conversion_gate: current.to_h) unless current.passed
61
+
62
+ # The criterion must STILL be the frozen one (D8-bis): a criterion that
63
+ # changed between boot and promotion is a criterion nobody froze.
64
+ unless @criterion && @criterion.sha == candidate.criterion_sha
65
+ raise Insika::ValidationError,
66
+ "the harvest criterion changed since boot (#{candidate.criterion_sha.inspect} -> " \
67
+ "#{@criterion&.sha.inspect}) — re-gate the candidate against the new frozen file"
68
+ end
69
+
70
+ # Snapshot first, then the writes, then the log row, then the flip.
71
+ existed = @skill_store.get(candidate.name, agent: candidate.agent)
72
+ # D8 + the review fix: the snapshot records EVERY agent the skill was
73
+ # enabled for BEFORE this promotion (SetSkillAgents' vocabulary). A
74
+ # homonymous skill allowed by B must stay allowed when A promotes —
75
+ # and the rollback restores exactly this set, B included.
76
+ enabled_for = agents_with_skill(candidate.name)
77
+ snapshot = @harvest_store.create_snapshot(
78
+ agent: candidate.agent, skill: candidate.name, content: existed,
79
+ existed: !existed.nil?,
80
+ enabled_for: enabled_for
81
+ )
82
+
83
+ @write_skill.call(Insika::Command.build(:write_skill,
84
+ { name: candidate.name, content: candidate.body,
85
+ agent: candidate.agent }))
86
+ # ENABLE for the candidate's agent WITHOUT disabling anyone else who
87
+ # already had it: the wanted set is the pre-promotion holders plus the
88
+ # candidate.
89
+ @set_skill_agents.call(Insika::Command.build(:set_skill_agents,
90
+ { name: candidate.name,
91
+ agent_ids: enabled_for | [candidate.agent] }))
92
+
93
+ promotion = @harvest_store.append_promotion(
94
+ id: SecureRandom.uuid, agent: candidate.agent, skill: candidate.name,
95
+ origin: candidate.origin, eval_ref: "cand:#{candidate.id}",
96
+ conversion_ref: candidate.conversion_gate && candidate.conversion_gate["snapshot_ref"],
97
+ approver: operator, snapshot_ref: snapshot.id,
98
+ criterion_sha: candidate.criterion_sha
99
+ )
100
+ @harvest_store.mark_promoted(candidate.id, promotion_ref: promotion.id)
101
+
102
+ emit(:skill_promoted, agent: candidate.agent, skill: candidate.name,
103
+ candidate_id: candidate.id, snapshot_ref: snapshot.id,
104
+ promotion_ref: promotion.id, approver: operator)
105
+ @harvest_store.find_candidate(candidate.id)
106
+ end
107
+
108
+ private
109
+
110
+ def profile_of(agent_id)
111
+ @profiles.fetch(agent_id) ||
112
+ (raise Insika::NotFoundError, "agent '#{agent_id}' not configured")
113
+ end
114
+
115
+ # Every agent whose allowlist currently names the skill — the pre-
116
+ # promotion holder set (an agent with skills=nil has ALL, so it holds
117
+ # every name and is not "removable"; SetSkillAgents skips it).
118
+ def agents_with_skill(name)
119
+ @profiles.all.select { |p| Array(p.skills).include?(name) }.map(&:id)
120
+ end
121
+
122
+ def emit(type, **data)
123
+ # ids and refs only — the skill body never enters the stream (D7).
124
+ @event_stream.emit(Insika::Event.new(
125
+ type: type, data: data, meta: { at: Time.now.utc.iso8601 }
126
+ ))
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Commands
7
+ # Control command (WS7): the operator or the integration records a business
8
+ # OUTCOME for a conversation — `{ outcome: "conversion", value: R$ }` — via
9
+ # `POST /v1/outcomes`. The engine transports it and never interprets it; the
10
+ # Studio's scorecard card aggregates the store. The record is tenant-stamped
11
+ # (WS1): a tenant principal's outcomes land under its own tenant, invisible
12
+ # to every other tenant and to nothing the tenant can read.
13
+ class RecordOutcome
14
+ def initialize(outcome_store:, event_stream:)
15
+ @outcome_store = outcome_store
16
+ @event_stream = event_stream
17
+ end
18
+
19
+ def call(command)
20
+ payload = command.payload
21
+ agent = Coercion.presence(payload[:agent] || payload["agent"])
22
+ outcome = Coercion.presence(payload[:outcome] || payload["outcome"])
23
+ raise ValidationError, "agent is required" if agent.nil?
24
+ raise ValidationError, "outcome is required" if outcome.nil?
25
+
26
+ value = payload[:value] || payload["value"]
27
+ unless value.nil? || value.is_a?(Numeric)
28
+ raise ValidationError, "value must be a number"
29
+ end
30
+
31
+ session_id = payload[:session_id] || payload["session_id"]
32
+ record = @outcome_store.create(
33
+ tenant: command.meta[:tenant], agent: agent, session_id: session_id,
34
+ outcome: outcome, value: value
35
+ )
36
+ @event_stream.emit(Insika::Event.new(
37
+ type: :outcome_recorded,
38
+ data: { agent: agent, outcome: outcome, value: record.value,
39
+ session_id: record.session_id },
40
+ meta: { at: Time.now.utc.iso8601 }
41
+ ))
42
+ record
43
+ end
44
+ end
45
+ end
46
+ end