insika 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +199 -5
- data/README.md +8 -2
- data/bin/insika +231 -13
- data/docs/AGENTS.md +505 -6
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +100 -10
- data/docs/CONTEXT.md +147 -19
- data/docs/DEPLOY.md +34 -11
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +20 -1
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +17 -10
- data/docs/OBSERVABILITY.md +65 -2
- data/docs/REFINEMENT.md +9 -9
- data/docs/RELEASING.md +34 -7
- data/docs/RUNNING-LOCAL.md +4 -4
- data/docs/SECURITY.md +85 -11
- data/docs/SKILLS.md +189 -3
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +2 -2
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +228 -26
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +166 -0
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +132 -24
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +80 -6
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +9 -9
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +145 -13
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +6 -4
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/create_agent.rb +2 -2
- data/lib/insika/commands/create_session.rb +1 -1
- data/lib/insika/commands/delete_llm_provider.rb +1 -1
- data/lib/insika/commands/delete_skill.rb +43 -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 +12 -12
- data/lib/insika/commands/import_mcp_tools.rb +1 -1
- data/lib/insika/commands/import_tools.rb +4 -4
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- 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/resolve_refinement.rb +1 -1
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/run_refinement.rb +5 -5
- data/lib/insika/commands/send_message.rb +112 -15
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/set_agent_tools.rb +1 -1
- data/lib/insika/commands/set_skill_agents.rb +60 -19
- data/lib/insika/commands/trigger_workflow.rb +1 -1
- data/lib/insika/commands/update_agent.rb +1 -1
- data/lib/insika/commands/write_data_tool.rb +1 -1
- data/lib/insika/commands/write_golden.rb +1 -1
- data/lib/insika/commands/write_skill.rb +19 -9
- data/lib/insika/config_store.rb +8 -4
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +23 -5
- data/lib/insika/context/fragment.rb +31 -3
- data/lib/insika/context/priority.rb +6 -2
- 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/request.rb +1 -1
- data/lib/insika/context/providers/session.rb +17 -2
- data/lib/insika/context/providers/skill.rb +7 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +128 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +962 -7
- data/lib/insika/dsl/runtime.rb +20 -11
- data/lib/insika/dsl/server_boot.rb +74 -4
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +152 -15
- data/lib/insika/edge_limiter.rb +167 -8
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +22 -12
- data/lib/insika/errors.rb +72 -5
- data/lib/insika/evals/assertions.rb +15 -14
- data/lib/insika/evals/baseline.rb +3 -3
- data/lib/insika/evals/golden.rb +8 -8
- data/lib/insika/evals/judge.rb +7 -7
- data/lib/insika/evals/pairwise.rb +21 -9
- data/lib/insika/evals/report.rb +2 -2
- data/lib/insika/evals/runner.rb +6 -6
- data/lib/insika/evals/transport.rb +2 -2
- data/lib/insika/event_stream.rb +23 -5
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +1092 -160
- 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/frontmatter.rb +1 -1
- 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 +3 -3
- 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/http_client.rb +3 -3
- data/lib/insika/inbound_log.rb +1 -1
- data/lib/insika/llm_configurator.rb +3 -3
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +4 -4
- data/lib/insika/mcp_tool_ingestor.rb +6 -6
- 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 +8 -3
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +8 -3
- data/lib/insika/outbox_store.rb +44 -6
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +3 -4
- data/lib/insika/pack.rb +3 -3
- data/lib/insika/pack_importer.rb +17 -15
- 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/pending_action_store.rb +1 -1
- data/lib/insika/plugin/loader.rb +2 -2
- data/lib/insika/policy/policy.rb +1 -1
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +34 -7
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +6 -3
- data/lib/insika/recovery.rb +47 -6
- data/lib/insika/refinement/candidate.rb +4 -4
- data/lib/insika/refinement/evidence_collector.rb +6 -6
- data/lib/insika/refinement/gate.rb +7 -7
- data/lib/insika/refinement/panel.rb +7 -7
- data/lib/insika/refinement/proposer.rb +10 -10
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +211 -0
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +46 -6
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +34 -115
- data/lib/insika/safety/factory.rb +18 -5
- 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 +20 -5
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +10 -6
- data/lib/insika/safety/output_validator.rb +13 -7
- data/lib/insika/safety/safe_responses.rb +1 -1
- data/lib/insika/sandbox/boundary.rb +2 -2
- data/lib/insika/sandbox.rb +1 -1
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +366 -54
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +31 -7
- data/lib/insika/server/responses.rb +58 -9
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +11 -7
- data/lib/insika/session_store.rb +66 -3
- data/lib/insika/settings_store.rb +15 -5
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +131 -20
- data/lib/insika/skill_store.rb +70 -22
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/steer_injector.rb +1 -1
- data/lib/insika/store.rb +11 -2
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1058 -75
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +27 -26
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +274 -22
- data/lib/insika/studio/nav_icons.rb +7 -2
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +629 -86
- data/lib/insika/studio/views/agents.erb +11 -7
- 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/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +133 -3
- data/lib/insika/studio/views/settings.erb +9 -12
- data/lib/insika/studio/views/skills.erb +66 -12
- 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/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +22 -2
- data/lib/insika/telemetry/pricing.rb +3 -3
- data/lib/insika/telemetry/recorder.rb +1 -1
- data/lib/insika/telemetry.rb +2 -2
- data/lib/insika/testing/store_contract.rb +54 -33
- data/lib/insika/tick.rb +146 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +25 -15
- data/lib/insika/tool_envelope.rb +70 -1
- data/lib/insika/tool_manifest.rb +11 -7
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_store.rb +1 -1
- data/lib/insika/tool_trace_store.rb +1 -1
- data/lib/insika/tools/concurrency.rb +2 -2
- data/lib/insika/tools/data_defined_tool.rb +14 -5
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/load_skill.rb +61 -3
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +4 -4
- data/lib/insika/tools/subagents.rb +1 -1
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +54 -13
- data/lib/insika/turn_timing.rb +24 -4
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +372 -34
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +122 -16
- metadata +95 -2
- data/lib/insika/server/admin_auth.rb +0 -29
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# The circuit breaker's durable state (WS3): one cell per
|
|
7
|
+
# (tenant, provider/model ref) holding the recent failures and when the
|
|
8
|
+
# circuit opened. The read-modify-write rides `@store.transaction` — the same
|
|
9
|
+
# discipline as the dispatch claim (A1): two concurrent failures of the same
|
|
10
|
+
# cell serialize, so the "10 failures in 60s" count is never lost to a race.
|
|
11
|
+
#
|
|
12
|
+
# States, standard semantics:
|
|
13
|
+
# :closed fewer than `after` failures in the `within` window — attempts flow.
|
|
14
|
+
# :open the window count is met AND the cooldown hasn't elapsed — the
|
|
15
|
+
# edge FAIL-FASTS (no provider call) with retry_after = remaining
|
|
16
|
+
# cooldown. Reached at the `after`-th failure, which stamps
|
|
17
|
+
# opened_at.
|
|
18
|
+
# :half_open the cooldown elapsed — the next attempt is the TRIAL: a success
|
|
19
|
+
# closes (record_success clears the cell), a failure reopens
|
|
20
|
+
# (a new failure count / opened_at).
|
|
21
|
+
class CircuitState
|
|
22
|
+
SCOPE = "circuit_state"
|
|
23
|
+
|
|
24
|
+
# Timestamps are pruned to COUNT_LIMIT — a pathological loop cannot grow the
|
|
25
|
+
# cell unboundedly (the window is 60s-wide; 100 reads and writes bounded).
|
|
26
|
+
COUNT_LIMIT = 100
|
|
27
|
+
|
|
28
|
+
Record = Data.define(:failures, :opened_at)
|
|
29
|
+
|
|
30
|
+
def initialize(store:)
|
|
31
|
+
@store = store
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Records ONE failure for (tenant, ref). If this failure makes the window
|
|
35
|
+
# reach `after` and the circuit is not already open, it stamps opened_at
|
|
36
|
+
# (the instant the breaker trips). -> :closed (still closed) | :open (JUST
|
|
37
|
+
# tripped — closed->open, the ONLY transition that alerts) | :reopened (an
|
|
38
|
+
# already-tripped cell re-stamped: a half-open trial failed; the circuit is
|
|
39
|
+
# open again but the node already told the operator it is sick — no NEW
|
|
40
|
+
# alert (WS3)).
|
|
41
|
+
def record_failure(tenant:, ref:, after: 10, within: 60, now: Time.now)
|
|
42
|
+
key = key_for(tenant, ref)
|
|
43
|
+
@store.transaction do
|
|
44
|
+
record = load(key)
|
|
45
|
+
cutoff = (now.to_i - within)
|
|
46
|
+
retained = record.failures.select { |t| t > cutoff }.last(COUNT_LIMIT)
|
|
47
|
+
failures = (retained + [now.to_i]).last(COUNT_LIMIT)
|
|
48
|
+
opened_at = record.opened_at
|
|
49
|
+
tripped = :closed
|
|
50
|
+
if opened_at.nil?
|
|
51
|
+
if failures.size >= after
|
|
52
|
+
opened_at = now.to_i
|
|
53
|
+
tripped = :open
|
|
54
|
+
end
|
|
55
|
+
else
|
|
56
|
+
# the circuit was tripped before; a failure here can only be a HALF-OPEN
|
|
57
|
+
# trial that failed. Re-stamp opened_at so it RE-OPENS — a breaker that
|
|
58
|
+
# never restamps is one-shot: after the first cooldown every later turn
|
|
59
|
+
# is an unlocked half-open trial (WS3).
|
|
60
|
+
opened_at = now.to_i
|
|
61
|
+
tripped = :reopened
|
|
62
|
+
end
|
|
63
|
+
@store.set(SCOPE, key, { "failures" => failures, "opened_at" => opened_at })
|
|
64
|
+
tripped
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A successful attempt CLOSES the circuit: the cell is cleared so the
|
|
69
|
+
# failure window starts fresh (half-open trial success included).
|
|
70
|
+
def record_success(tenant:, ref:)
|
|
71
|
+
@store.delete(SCOPE, key_for(tenant, ref))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# -> :closed | :open | :half_open
|
|
75
|
+
def state(tenant:, ref:, after: 10, within: 60, cooldown: 300, now: Time.now)
|
|
76
|
+
record = load(key_for(tenant, ref))
|
|
77
|
+
opened = record.opened_at.to_i
|
|
78
|
+
# :closed needs BOTH doors shut: the window count is under `after` AND the
|
|
79
|
+
# circuit never tripped. A surviving opened_at tombstone keeps the circuit
|
|
80
|
+
# governed by the cooldown (open, then half-open) even after its original
|
|
81
|
+
# failures age out of the rolling window — only a SUCCESS clears it (WS3:
|
|
82
|
+
# a failed half-open trial must reopen, not silently close).
|
|
83
|
+
return :closed if record.failures.size < after && opened.zero?
|
|
84
|
+
|
|
85
|
+
return :open if opened.zero? || (now.to_i - opened) < cooldown
|
|
86
|
+
|
|
87
|
+
:half_open
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Seconds until the circuit can be retried (the remaining cooldown). nil
|
|
91
|
+
# while closed.
|
|
92
|
+
def retry_after(tenant:, ref:, cooldown: 300, now: Time.now)
|
|
93
|
+
record = load(key_for(tenant, ref))
|
|
94
|
+
return nil if record.failures.empty?
|
|
95
|
+
|
|
96
|
+
opened = record.opened_at.to_i
|
|
97
|
+
return nil if opened.zero?
|
|
98
|
+
|
|
99
|
+
remaining = cooldown - (now.to_i - opened)
|
|
100
|
+
remaining.positive? ? remaining : nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def key_for(tenant, ref) = "#{tenant || 'platform'}:#{ref}"
|
|
106
|
+
|
|
107
|
+
def load(key)
|
|
108
|
+
record = @store.get(SCOPE, key)
|
|
109
|
+
return Record.new([], nil) if record.nil?
|
|
110
|
+
|
|
111
|
+
Record.new(Array(record["failures"]), record["opened_at"])
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/insika/coercion.rb
CHANGED
|
@@ -19,6 +19,14 @@ module Insika
|
|
|
19
19
|
# Inverse of blank? — a usable, non-empty value.
|
|
20
20
|
def present?(value) = !blank?(value)
|
|
21
21
|
|
|
22
|
+
# The operator-facing truthy: what a form checkbox ("1"), a JSON round-trip
|
|
23
|
+
# ("true") or the DSL (true) produce for an opt-in. One list, one reading —
|
|
24
|
+
# AgentProfile#stream_public?, SkillCatalog#blanket? and the Studio all consult
|
|
25
|
+
# it, so a value that opts in on one surface cannot opt out on another.
|
|
26
|
+
TRUTHY = [true, "true", "1", "yes", "on"].freeze
|
|
27
|
+
|
|
28
|
+
def truthy?(value) = TRUTHY.include?(value)
|
|
29
|
+
|
|
22
30
|
# Bytes that come from outside the engine (sockets, pipes, subprocesses)
|
|
23
31
|
# arrive tagged BINARY, or as UTF-8 carrying invalid sequences. Both break
|
|
24
32
|
# `JSON.generate` the moment the text has an accent or an emoji — today a
|
|
@@ -10,10 +10,12 @@ module Insika
|
|
|
10
10
|
module AgentPayload
|
|
11
11
|
# Fields accepted by AgentProfile.build (order irrelevant).
|
|
12
12
|
FIELDS = %i[id model provider base_prompt prompt_files tools_allow tools_deny
|
|
13
|
-
tools_allow_groups skills context_providers workflows_allow policies
|
|
13
|
+
tools_allow_groups skills skills_eager context_providers workflows_allow policies
|
|
14
14
|
prompt_refs limits approvals_required capabilities subagents tools_deferred
|
|
15
|
-
memory
|
|
16
|
-
|
|
15
|
+
memory prompt_caching tool_persistence tool_output_compression budget reliability alerts
|
|
16
|
+
routes stuck_signal outputs briefing_fields grounding funnel followup
|
|
17
|
+
params model_policy guardrails refinement capabilities_declared
|
|
18
|
+
edge_stream metadata distill harvest].freeze
|
|
17
19
|
|
|
18
20
|
module_function
|
|
19
21
|
|
|
@@ -25,7 +27,7 @@ module Insika
|
|
|
25
27
|
out[:provider] = presence(out[:provider])&.to_sym if out.key?(:provider)
|
|
26
28
|
out[:policies] = Array(out[:policies]).map(&:to_sym) if out.key?(:policies)
|
|
27
29
|
out[:limits] = out[:limits].transform_keys(&:to_sym) if out[:limits].is_a?(Hash)
|
|
28
|
-
# generation params
|
|
30
|
+
# generation params consumed by symbol key (temperature/max_tokens/thinking).
|
|
29
31
|
out[:params] = out[:params].transform_keys(&:to_sym) if out[:params].is_a?(Hash)
|
|
30
32
|
out
|
|
31
33
|
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# the operator cancels ONE pending follow-up record (the
|
|
8
|
+
# Studio's Cancel button dispatches this). Synchronous control command (no
|
|
9
|
+
# task, like RecordOutcome); idempotent — an already-cancelled record
|
|
10
|
+
# returns as-is (a repeat of the same call is not an error). Tenant-scoped
|
|
11
|
+
# (WS1): a tenant principal can only cancel its own tenant's records.
|
|
12
|
+
class CancelFollowup
|
|
13
|
+
def initialize(followup_store:, event_stream:)
|
|
14
|
+
@followup_store = followup_store
|
|
15
|
+
@event_stream = event_stream
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# -> Record. Emits :followup_cancelled { id:, cancelled_by: }.
|
|
19
|
+
def call(command)
|
|
20
|
+
id = Coercion.presence(command.payload[:followup_id] || command.payload["followup_id"])
|
|
21
|
+
raise ValidationError, "followup_id is required" if id.nil?
|
|
22
|
+
|
|
23
|
+
record = @followup_store.find(id)
|
|
24
|
+
raise NotFoundError, "follow-up not found: #{id}" if record.nil?
|
|
25
|
+
|
|
26
|
+
# WS1: the principal's tenant is the scope — a tenant token cannot
|
|
27
|
+
# cancel another tenant's record by id.
|
|
28
|
+
tenant = command.meta[:tenant]
|
|
29
|
+
unless record.tenant == (tenant.to_s.empty? ? "platform" : tenant.to_s)
|
|
30
|
+
raise ValidationError, "follow-up #{id} belongs to another tenant"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A non-pending record (the tick fired it between the render and the
|
|
34
|
+
# click) is a domain error the Studio can flash — never a 500.
|
|
35
|
+
cancelled = begin
|
|
36
|
+
@followup_store.cancel(id: id)
|
|
37
|
+
rescue ArgumentError => e
|
|
38
|
+
raise ValidationError, e.message
|
|
39
|
+
end
|
|
40
|
+
@event_stream.emit(Insika::Event.new(
|
|
41
|
+
type: :followup_cancelled,
|
|
42
|
+
data: { id: cancelled.id, cancelled_by: "operator" },
|
|
43
|
+
meta: { tenant: command.meta[:tenant], at: Time.now.utc.iso8601 }
|
|
44
|
+
))
|
|
45
|
+
cancelled
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -17,13 +17,13 @@ module Insika
|
|
|
17
17
|
attrs = AgentPayload.attrs(command.payload)
|
|
18
18
|
id = AgentPayload.presence(attrs[:id])
|
|
19
19
|
raise Insika::ValidationError, "id is required" if id.nil?
|
|
20
|
-
# model is OPTIONAL as of v2
|
|
20
|
+
# model is OPTIONAL as of v2: omitted -> resolves the platform
|
|
21
21
|
# default_model (Settings) at turn start. An agent with neither its own
|
|
22
22
|
# model nor a platform default fails clearly at the first turn.
|
|
23
23
|
raise Insika::ValidationError, "agent '#{id}' already exists" if @profile_source.fetch(id)
|
|
24
24
|
|
|
25
25
|
profile = Insika::AgentProfile.build(**attrs)
|
|
26
|
-
#
|
|
26
|
+
# definition-time cycle + depth check. Only a profile that
|
|
27
27
|
# introduces edges (non-empty subagents) can create a violation — a
|
|
28
28
|
# childless agent is always a safe leaf. Raises SubagentError (a
|
|
29
29
|
# ValidationError) BEFORE persisting, so a bad graph never lands.
|
|
@@ -21,7 +21,7 @@ module Insika
|
|
|
21
21
|
vars = command.payload[:vars] || command.payload["vars"] || {}
|
|
22
22
|
raise Insika::ValidationError, "vars must be a Hash" unless vars.is_a?(Hash)
|
|
23
23
|
|
|
24
|
-
# Per-chat model pin
|
|
24
|
+
# Per-chat model pin: `model`/`provider` on the payload become a
|
|
25
25
|
# reserved, collision-safe slot in vars (never rendered in the prompt — the
|
|
26
26
|
# Request provider skips "__"-prefixed vars). The ModelResolver reads it as
|
|
27
27
|
# the highest-precedence layer (Chat > Agent > platform default).
|
|
@@ -6,7 +6,7 @@ module Insika
|
|
|
6
6
|
module Commands
|
|
7
7
|
# Control command: removes an LLM provider from the
|
|
8
8
|
# LLMProviderStore. Idempotent (`existed: false` if there was none). UNDOES the
|
|
9
|
-
# config in RubyLLM at runtime
|
|
9
|
+
# config in RubyLLM at runtime: when the provider existed, calls
|
|
10
10
|
# `configurator.unapply(api)` clearing the global key/base — without a restart. A provider
|
|
11
11
|
# that RubyLLM does not recognize degrades gracefully (unapply: false, nothing to
|
|
12
12
|
# undo). -> { existed: bool }.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: removes an authored skill from the SkillStore and RELOADS the
|
|
8
|
+
# catalog. -> { name, agent, deleted }.
|
|
9
|
+
#
|
|
10
|
+
# With `agent`, it deletes only that agent's SPECIALIZATION — the shared skill
|
|
11
|
+
# stays exactly where it was, and the agent falls back to it on the next turn.
|
|
12
|
+
# "Stop specializing this" is the inverse of the specialize action, and it must not
|
|
13
|
+
# be expressible as "delete the skill".
|
|
14
|
+
#
|
|
15
|
+
# Deleting a shared skill does NOT touch any allowlist: an agent left naming a
|
|
16
|
+
# skill that no longer exists sees nothing (the catalog resolves nothing), which is
|
|
17
|
+
# the same outcome as never having allowed it.
|
|
18
|
+
class DeleteSkill
|
|
19
|
+
def initialize(skill_store:, skill_catalog:, event_stream:)
|
|
20
|
+
@skill_store = skill_store
|
|
21
|
+
@skill_catalog = skill_catalog
|
|
22
|
+
@event_stream = event_stream
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(command)
|
|
26
|
+
p = AgentPayload.symbolize(command.payload)
|
|
27
|
+
name = AgentPayload.presence(p[:name])
|
|
28
|
+
agent = AgentPayload.presence(p[:agent])
|
|
29
|
+
raise Insika::ValidationError, "name is required" if name.nil?
|
|
30
|
+
|
|
31
|
+
deleted = @skill_store.delete(name, agent: agent)
|
|
32
|
+
raise Insika::NotFoundError, "skill '#{name}' not found#{" for agent '#{agent}'" if agent}" unless deleted
|
|
33
|
+
|
|
34
|
+
@skill_catalog.reload
|
|
35
|
+
@event_stream.emit(Insika::Event.new(
|
|
36
|
+
type: :skill_deleted, data: { name: name, agent: agent }.compact,
|
|
37
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
38
|
+
))
|
|
39
|
+
{ name: name, agent: agent, deleted: true }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
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)
|
|
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
|
+
@event_stream = event_stream
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# -> { tenant:, sessions:, memory_records:, outcomes:, tokens_revoked:,
|
|
47
|
+
# tasks:, checkpoints:, deliveries:, followups:, contacts: }.
|
|
48
|
+
def call(command)
|
|
49
|
+
tenant = Coercion.presence(command.payload[:tenant] || command.payload["tenant"])
|
|
50
|
+
raise ValidationError, "tenant is required" if tenant.nil?
|
|
51
|
+
|
|
52
|
+
# CREDENTIALS FIRST (WS1+WS8): erasing the data while the tenant's
|
|
53
|
+
# tokens still resolve leaves an offboarded tenant authenticating and
|
|
54
|
+
# opening a NEW session — the purge would report success over a live
|
|
55
|
+
# customer. Revoking before the sweep closes the door, so nothing the
|
|
56
|
+
# tenant does mid-purge survives it. nil store = single_tenant mode
|
|
57
|
+
# (no per-tenant credential exists).
|
|
58
|
+
tokens_revoked = @token_store ? @token_store.revoke_all(tenant_id: tenant) : 0
|
|
59
|
+
|
|
60
|
+
sessions = @session_store.each_id.select { |id| id.to_s.start_with?("#{tenant}:") }
|
|
61
|
+
purged = purge_sessions(sessions)
|
|
62
|
+
|
|
63
|
+
memory_records = @memory_store.purge_tenant(tenant)
|
|
64
|
+
outcomes = @outcome_store ? @outcome_store.purge(tenant: tenant) : 0
|
|
65
|
+
funnel = @funnel_store ? @funnel_store.purge(tenant: tenant) : 0
|
|
66
|
+
# the follow-up footprint dies with the tenant — records
|
|
67
|
+
# and contact cells under the same tenant prefix.
|
|
68
|
+
followups = @followup_store ? @followup_store.purge(tenant: tenant) : 0
|
|
69
|
+
contacts = @contact_store ? @contact_store.purge(tenant: tenant) : 0
|
|
70
|
+
# the distilled proposals die with the tenant.
|
|
71
|
+
proposals = @proposal_store ? @proposal_store.purge(tenant: tenant) : 0
|
|
72
|
+
# candidates reference sessions, and sessions carry the
|
|
73
|
+
# tenant prefix — the harvest rows die with the tenant (D11).
|
|
74
|
+
harvest = @harvest_store ? @harvest_store.purge(tenant: tenant) : 0
|
|
75
|
+
|
|
76
|
+
@event_stream.emit(Insika::Event.new(
|
|
77
|
+
type: :tenant_data_deleted,
|
|
78
|
+
data: { tenant: tenant, sessions: sessions,
|
|
79
|
+
memory_records: memory_records,
|
|
80
|
+
outcomes: outcomes,
|
|
81
|
+
funnel: funnel,
|
|
82
|
+
followups: followups,
|
|
83
|
+
contacts: contacts,
|
|
84
|
+
proposals: proposals,
|
|
85
|
+
harvest: harvest,
|
|
86
|
+
tokens_revoked: tokens_revoked }.merge(purged),
|
|
87
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
88
|
+
))
|
|
89
|
+
{ tenant: tenant, sessions: sessions, memory_records: memory_records,
|
|
90
|
+
outcomes: outcomes, funnel: funnel, followups: followups, contacts: contacts,
|
|
91
|
+
proposals: proposals, harvest: harvest, tokens_revoked: tokens_revoked }.merge(purged)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
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
|