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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +145 -17
- data/README.md +7 -1
- data/bin/insika +211 -8
- data/docs/AGENTS.md +377 -2
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +99 -9
- data/docs/CONTEXT.md +127 -16
- data/docs/DEPLOY.md +5 -2
- data/docs/EVALS.md +19 -0
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +14 -8
- data/docs/OBSERVABILITY.md +54 -0
- data/docs/REFINEMENT.md +3 -3
- data/docs/RELEASING.md +27 -0
- data/docs/RUNNING-LOCAL.md +3 -3
- data/docs/SECURITY.md +61 -0
- data/docs/SKILLS.md +2 -2
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +1 -1
- data/lib/insika/agent_profile.rb +145 -10
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +118 -13
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +84 -1
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_tenant_data.rb +95 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +3 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +30 -2
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +716 -6
- data/lib/insika/dsl/runtime.rb +9 -3
- data/lib/insika/dsl/server_boot.rb +70 -1
- data/lib/insika/dsl.rb +83 -3
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +9 -2
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/pairwise.rb +18 -6
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +687 -70
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +221 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +1 -1
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/media.rb +298 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +6 -1
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +6 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +22 -1
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +144 -4
- data/lib/insika/server/rack_app.rb +16 -0
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +4 -0
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +875 -14
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +22 -22
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +262 -5
- data/lib/insika/studio/nav_icons.rb +6 -1
- data/lib/insika/studio/views/agent_detail.erb +627 -84
- data/lib/insika/studio/views/agents.erb +10 -6
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +4 -1
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +2 -1
- data/lib/insika/studio/views/layout.erb +1 -0
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +56 -1
- data/lib/insika/studio/views/settings.erb +2 -0
- data/lib/insika/studio/views/skills.erb +0 -1
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +0 -1
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +25 -1
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_state.rb +38 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +300 -13
- data/lib/insika.rb +92 -3
- 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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|