insika 0.2.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +325 -17
- data/README.md +52 -11
- data/bin/insika +895 -8
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +229 -163
- data/docs/API.md +56 -0
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +111 -21
- data/docs/CONTEXT.md +132 -19
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +75 -4
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +146 -2
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +116 -0
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +16 -10
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +65 -6
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +17 -12
- data/docs/RELEASING.md +27 -0
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +6 -6
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +79 -2
- data/docs/SKILLS.md +13 -4
- data/docs/SOAK.md +127 -0
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +220 -27
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/_includes/head_custom.html +5 -0
- data/docs/_includes/title.html +13 -0
- data/docs/_sass/color_schemes/insika.scss +32 -0
- data/docs/_sass/custom/custom.scss +199 -0
- data/docs/_sass/custom/setup.scss +26 -0
- data/docs/assets/img/favicon.svg +7 -0
- data/docs/assets/img/insika-mark.svg +7 -0
- data/docs/core-concepts.md +21 -0
- data/docs/domain.md +115 -0
- data/docs/improve.md +20 -0
- data/docs/index.md +9 -6
- data/docs/integrate.md +20 -0
- data/docs/operate.md +13 -6
- data/docs/prompts/ADD-TOOL.md +118 -0
- data/docs/prompts/DIAGNOSE-TURN.md +65 -0
- data/docs/prompts/GO-LIVE.md +138 -0
- data/docs/prompts/RUN-EXAMPLES.md +70 -0
- data/docs/reference.md +19 -0
- data/docs/ship.md +10 -2
- data/docs/start-here.md +18 -0
- data/lib/insika/agent_profile.rb +202 -10
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- 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 +119 -14
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +105 -2
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_artifact.rb +35 -0
- data/lib/insika/commands/delete_concept.rb +34 -0
- data/lib/insika/commands/delete_mcp.rb +6 -2
- data/lib/insika/commands/delete_tenant_data.rb +107 -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/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/restore_concept.rb +34 -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/seed_demo_data.rb +31 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -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 +5 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +57 -23
- 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/cron.rb +189 -0
- data/lib/insika/demo/agent_attrs.rb +43 -0
- data/lib/insika/demo/golden_cases.rb +81 -0
- data/lib/insika/demo/seeder.rb +336 -0
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +889 -11
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +69 -82
- data/lib/insika/dsl/server_boot.rb +92 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +186 -5
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +25 -3
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +29 -6
- data/lib/insika/evals/persona.rb +98 -0
- data/lib/insika/evals/runner.rb +9 -0
- data/lib/insika/evals/simulator.rb +225 -0
- data/lib/insika/evals/transport.rb +83 -1
- data/lib/insika/event_stream.rb +10 -0
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +869 -76
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +198 -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 +17 -2
- 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/knowledge.rb +680 -0
- data/lib/insika/knowledge_store.rb +140 -0
- data/lib/insika/mcp_client.rb +94 -0
- data/lib/insika/mcp_json.rb +74 -0
- data/lib/insika/mcp_live_tool.rb +43 -0
- data/lib/insika/mcp_store.rb +98 -26
- data/lib/insika/mcp_tool_ingestor.rb +30 -8
- data/lib/insika/mcp_tool_registry.rb +100 -0
- data/lib/insika/media.rb +382 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +7 -2
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +22 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +37 -17
- 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 +29 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- 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 +316 -0
- data/lib/insika/router/app.rb +157 -0
- data/lib/insika/router/backend_pool.rb +98 -0
- data/lib/insika/router/hash_ring.rb +55 -0
- data/lib/insika/router/proxy_body.rb +34 -0
- data/lib/insika/router/session_key.rb +54 -0
- data/lib/insika/router.rb +18 -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/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +247 -17
- data/lib/insika/server/rack_app.rb +21 -1
- 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 +16 -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/steer_injector.rb +21 -10
- 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 +1437 -54
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +23 -23
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +308 -10
- data/lib/insika/studio/nav_icons.rb +19 -1
- data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
- data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
- data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
- data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
- data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
- data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
- data/lib/insika/studio/views/_agents_master.erb +44 -0
- data/lib/insika/studio/views/_message.erb +49 -32
- data/lib/insika/studio/views/agent_detail.erb +62 -278
- data/lib/insika/studio/views/agents.erb +71 -54
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- 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 +6 -3
- 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 +106 -66
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +15 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- 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 +233 -124
- data/lib/insika/studio/views/settings.erb +41 -1
- data/lib/insika/studio/views/skills.erb +1 -2
- 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 +24 -10
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/templates/browser-agent/README.md +36 -0
- data/lib/insika/templates/browser-agent/agent.rb +49 -0
- data/lib/insika/templates/daily-digest/README.md +38 -0
- data/lib/insika/templates/daily-digest/agent.rb +77 -0
- data/lib/insika/templates/repo-explorer/README.md +36 -0
- data/lib/insika/templates/repo-explorer/agent.rb +45 -0
- data/lib/insika/templates/research-analyst/README.md +26 -0
- data/lib/insika/templates/research-analyst/agent.rb +58 -0
- data/lib/insika/templates/review-panel/README.md +20 -0
- data/lib/insika/templates/review-panel/agent.rb +50 -0
- data/lib/insika/templates/travel-planner/README.md +35 -0
- data/lib/insika/templates/travel-planner/agent.rb +87 -0
- data/lib/insika/templates.rb +112 -0
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +47 -11
- data/lib/insika/timezone.rb +45 -0
- 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 +89 -0
- data/lib/insika/tools/load_knowledge.rb +74 -0
- data/lib/insika/tools/run_persona_eval.rb +328 -0
- data/lib/insika/tools/save_artifact.rb +95 -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_output.rb +1 -1
- data/lib/insika/turn_state.rb +49 -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 +472 -13
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +149 -3
- metadata +181 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
|
@@ -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
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# C4 — the incumbent's half of a shadow pair, recorded on the mirror's
|
|
8
|
+
# word. One command, two doors: Shape 1 (the reply rides the mirror call
|
|
9
|
+
# itself) and Shape 2 (the follow-up route, for a consumer that mirrors
|
|
10
|
+
# before answering) both dispatch HERE, so there is one behaviour and one
|
|
11
|
+
# store method behind both.
|
|
12
|
+
#
|
|
13
|
+
# Idempotency is first-write-wins: the customer received ONE reply, and
|
|
14
|
+
# letting a retry overwrite it would silently rewrite evidence. The
|
|
15
|
+
# guarantee lives in ShadowPairStore#record_incumbent (inside its
|
|
16
|
+
# transaction); the find below is only the cheap fast path for the common
|
|
17
|
+
# retry. A reply for a pair that does not exist yet creates it :open — the
|
|
18
|
+
# mirror may legitimately arrive before our turn finishes.
|
|
19
|
+
class RecordShadowReply
|
|
20
|
+
def initialize(shadow_pairs:, event_stream: nil)
|
|
21
|
+
@shadow_pairs = shadow_pairs
|
|
22
|
+
@event_stream = event_stream
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# payload: { channel:, external_id:, event_id:, reply:, at: }
|
|
26
|
+
# -> { pair_id:, status: }
|
|
27
|
+
def call(command)
|
|
28
|
+
payload = command.payload
|
|
29
|
+
channel = Coercion.presence(payload[:channel] || payload["channel"])
|
|
30
|
+
external_id = Coercion.presence(payload[:external_id] || payload["external_id"])
|
|
31
|
+
event_id = Coercion.presence(payload[:event_id] || payload["event_id"])
|
|
32
|
+
reply = payload[:reply] || payload["reply"]
|
|
33
|
+
|
|
34
|
+
raise ValidationError, "channel is required" if channel.nil?
|
|
35
|
+
raise ValidationError, "external_id is required" if external_id.nil?
|
|
36
|
+
raise ValidationError, "event_id is required" if event_id.nil?
|
|
37
|
+
raise ValidationError, "reply is required" if reply.to_s.strip.empty?
|
|
38
|
+
|
|
39
|
+
id = ShadowPairStore.key_for(channel: channel, external_id: external_id,
|
|
40
|
+
event_id: event_id)
|
|
41
|
+
existing = @shadow_pairs.find(id)
|
|
42
|
+
if existing && !existing.incumbent_reply.nil?
|
|
43
|
+
emit(id, "already_recorded")
|
|
44
|
+
return { pair_id: id, status: "already_recorded" }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
pair = @shadow_pairs.record_incumbent(id: id, channel: channel, event_id: event_id,
|
|
48
|
+
external_id: external_id, reply: reply.to_s,
|
|
49
|
+
at: payload[:at] || payload["at"])
|
|
50
|
+
emit(id, pair.status.to_s)
|
|
51
|
+
{ pair_id: id, status: pair.status.to_s }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Metadata only: the pair id and its status, never the reply's text.
|
|
57
|
+
def emit(pair_id, status)
|
|
58
|
+
return unless @event_stream
|
|
59
|
+
|
|
60
|
+
@event_stream.emit(Insika::Event.new(
|
|
61
|
+
type: :shadow_reply_recorded,
|
|
62
|
+
data: { pair_id: pair_id, status: status },
|
|
63
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
64
|
+
))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: LIVE re-discovery of an MCP instance's tools.
|
|
8
|
+
# Delegates to McpToolRegistry#refresh (connect +
|
|
9
|
+
# tools/list + write McpStore#tools_cache) — TOOL EXECUTION never reads
|
|
10
|
+
# that cache; this command only refreshes what `entries`/Studio/doctor
|
|
11
|
+
# display. The CLI verb / API route / Studio button that call it are
|
|
12
|
+
# later work (PR3/PR4) — the command itself is the seam they'll share.
|
|
13
|
+
# -> { instance:, tools: [{name,description,inputSchema}, ...] }.
|
|
14
|
+
class RefreshMcpTools
|
|
15
|
+
def initialize(mcp_registry:, event_stream:)
|
|
16
|
+
@mcp_registry = mcp_registry
|
|
17
|
+
@event_stream = event_stream
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(command)
|
|
21
|
+
p = symbolize(command.payload)
|
|
22
|
+
name = Insika::Coercion.presence(p[:name]) ||
|
|
23
|
+
(raise Insika::ValidationError, "refresh_mcp_tools: 'name' (MCP instance) is required")
|
|
24
|
+
|
|
25
|
+
tools = @mcp_registry.refresh(name)
|
|
26
|
+
emit(name, tools)
|
|
27
|
+
{ instance: name, tools: tools }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Emits only the COUNT + the instance name (never a tool's schema, in
|
|
33
|
+
# case a schema description ever carries something sensitive): 0 leakage.
|
|
34
|
+
def emit(name, tools)
|
|
35
|
+
@event_stream.emit(Insika::Event.new(
|
|
36
|
+
type: :mcp_tools_refreshed,
|
|
37
|
+
data: { instance: name, tools: tools.size },
|
|
38
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
39
|
+
))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def symbolize(payload)
|
|
43
|
+
(payload || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# — the operator's dismissal: a candidate that is NOT yet
|
|
8
|
+
# gated can be rejected by a human directly (a human may always outvote
|
|
9
|
+
# the miner). The thin wrapper over `mark_rejected`.
|
|
10
|
+
class RejectHarvest
|
|
11
|
+
def initialize(harvest_store:, event_stream:)
|
|
12
|
+
@harvest_store = harvest_store
|
|
13
|
+
@event_stream = event_stream
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# payload: { candidate_id:, operator?, note? }
|
|
17
|
+
# -> Candidate (rejected)
|
|
18
|
+
def call(command)
|
|
19
|
+
p = AgentPayload.symbolize(command.payload)
|
|
20
|
+
candidate_id = AgentPayload.presence(p[:candidate_id])
|
|
21
|
+
raise Insika::ValidationError, "candidate_id is required" if candidate_id.nil?
|
|
22
|
+
|
|
23
|
+
candidate = @harvest_store.find_candidate(candidate_id) ||
|
|
24
|
+
(raise Insika::NotFoundError, "harvest candidate not found: #{candidate_id}")
|
|
25
|
+
operator = (AgentPayload.presence(p[:operator]) || command.meta[:operator] || "operator").to_s
|
|
26
|
+
rejected = @harvest_store.mark_rejected(candidate.id, operator: operator, note: p[:note])
|
|
27
|
+
|
|
28
|
+
@event_stream.emit(Insika::Event.new(
|
|
29
|
+
type: :harvest_rejected,
|
|
30
|
+
data: { candidate_id: candidate.id, agent: candidate.agent,
|
|
31
|
+
operator: operator },
|
|
32
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
33
|
+
))
|
|
34
|
+
rejected
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Commands
|
|
5
|
+
# the human's answer : approve (→ the
|
|
6
|
+
# store, CAS, provenance `distilled:<session_ref>`), reject (with an
|
|
7
|
+
# optional reason), dismiss (latches the tuple). Approval never silently
|
|
8
|
+
# overwrites an operator edit (D5/E3): the CAS baseline captured at distill
|
|
9
|
+
# time guards the write, and a lost race flips the proposal to `:stale`
|
|
10
|
+
# carrying the fact's CURRENT value — both values visible on the wiki.
|
|
11
|
+
# Synchronous control command — the Studio dispatches it; no task, no actor.
|
|
12
|
+
class ResolveProposal
|
|
13
|
+
DECISIONS = %w[approved rejected dismissed].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(proposal_store:, memory_store:, event_stream:)
|
|
16
|
+
@proposal_store = proposal_store
|
|
17
|
+
@memory_store = memory_store
|
|
18
|
+
@event_stream = event_stream
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# payload: { proposal_id:, decision: "approved"|"rejected"|"dismissed",
|
|
22
|
+
# operator?, note? }
|
|
23
|
+
# operator from payload || command.meta[:operator] || "operator"
|
|
24
|
+
# -> Proposal (the resolved record; a :stale one carries current_value)
|
|
25
|
+
def call(command)
|
|
26
|
+
proposal_id = Coercion.presence(command.payload[:proposal_id] || command.payload["proposal_id"])
|
|
27
|
+
raise ValidationError, "proposal_id is required" if proposal_id.nil?
|
|
28
|
+
|
|
29
|
+
decision = Coercion.presence(command.payload[:decision] || command.payload["decision"])
|
|
30
|
+
raise ValidationError, "decision is required" unless DECISIONS.include?(decision)
|
|
31
|
+
|
|
32
|
+
proposal = @proposal_store.find(proposal_id)
|
|
33
|
+
raise Insika::NotFoundError, "proposal not found: #{proposal_id}" if proposal.nil?
|
|
34
|
+
|
|
35
|
+
operator = operator_for(command)
|
|
36
|
+
note = Coercion.presence(command.payload[:note] || command.payload["note"])
|
|
37
|
+
|
|
38
|
+
case decision
|
|
39
|
+
when "approved" then approve(command, proposal, operator, note)
|
|
40
|
+
when "rejected"
|
|
41
|
+
resolved = @proposal_store.reject(id: proposal.id, operator: operator, note: note)
|
|
42
|
+
emit(:proposal_rejected, resolved)
|
|
43
|
+
resolved
|
|
44
|
+
else # "dismissed"
|
|
45
|
+
resolved = @proposal_store.dismiss(id: proposal.id, operator: operator, note: note)
|
|
46
|
+
emit(:proposal_dismissed, resolved)
|
|
47
|
+
resolved
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# The whole safety argument (D5/E3). The read-check-write CAS rides the
|
|
54
|
+
# store's transaction — a lost race is a re-present, never a silent
|
|
55
|
+
# overwrite.
|
|
56
|
+
def approve(command, proposal, operator, note)
|
|
57
|
+
fact = @memory_store.get_fact(tenant: proposal.tenant, customer: proposal.customer,
|
|
58
|
+
key: proposal.key)
|
|
59
|
+
if proposal.expected_existed
|
|
60
|
+
if fact && fact.updated_at == proposal.expected_revision
|
|
61
|
+
written = @memory_store.replace_if_revision(
|
|
62
|
+
tenant: proposal.tenant, customer: proposal.customer,
|
|
63
|
+
key: proposal.key, value: proposal.value,
|
|
64
|
+
expected_revision: proposal.expected_revision,
|
|
65
|
+
origin: "distilled:#{proposal.session_ref}")
|
|
66
|
+
return stale(proposal, fact, operator) unless written
|
|
67
|
+
else
|
|
68
|
+
return stale(proposal, fact, operator) # the operator's edit stands
|
|
69
|
+
end
|
|
70
|
+
elsif fact
|
|
71
|
+
return stale(proposal, fact, operator) # the operator created it first
|
|
72
|
+
else
|
|
73
|
+
@memory_store.put_fact(tenant: proposal.tenant, customer: proposal.customer,
|
|
74
|
+
key: proposal.key, value: proposal.value,
|
|
75
|
+
origin: "distilled:#{proposal.session_ref}")
|
|
76
|
+
end
|
|
77
|
+
resolved = @proposal_store.approve(id: proposal.id, operator: operator, note: note)
|
|
78
|
+
emit(:proposal_approved, resolved)
|
|
79
|
+
resolved
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The re-present (E3): the proposal carries the fact's CURRENT value next
|
|
83
|
+
# to the proposed one; both are visible on the wiki page.
|
|
84
|
+
def stale(proposal, fact, operator)
|
|
85
|
+
resolved = @proposal_store.mark_stale(id: proposal.id, current_value: fact&.value,
|
|
86
|
+
operator: operator)
|
|
87
|
+
emit(:proposal_stale, resolved)
|
|
88
|
+
resolved
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# ids and statuses only (D7) — a fact value never enters the stream.
|
|
92
|
+
def emit(type, proposal)
|
|
93
|
+
@event_stream.emit(Insika::Event.new(
|
|
94
|
+
type: type,
|
|
95
|
+
data: { proposal_id: proposal.id, status: proposal.status,
|
|
96
|
+
operator: proposal.operator },
|
|
97
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
98
|
+
))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def operator_for(command)
|
|
102
|
+
Coercion.presence(command.payload[:operator] || command.payload["operator"]) ||
|
|
103
|
+
Coercion.presence(command.meta[:operator]) ||
|
|
104
|
+
"operator"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: restores an old version of a concept as the current
|
|
8
|
+
# content (a new write — linear history, like `RestoreSystemFile`).
|
|
9
|
+
# -> { name, agent, tenant, updated_at }.
|
|
10
|
+
class RestoreConcept
|
|
11
|
+
def initialize(knowledge_store:, event_stream:)
|
|
12
|
+
@knowledge_store = knowledge_store
|
|
13
|
+
@event_stream = event_stream
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(command)
|
|
17
|
+
p = AgentPayload.symbolize(command.payload)
|
|
18
|
+
name = AgentPayload.presence(p[:name])
|
|
19
|
+
agent = AgentPayload.presence(p[:agent])
|
|
20
|
+
tenant = AgentPayload.presence(p[:tenant])
|
|
21
|
+
raise Insika::ValidationError, "name is required" if name.nil?
|
|
22
|
+
raise Insika::ValidationError, "agent is required" if agent.nil?
|
|
23
|
+
raise Insika::ValidationError, "version is required" if p[:version].nil?
|
|
24
|
+
|
|
25
|
+
rec = @knowledge_store.restore(agent, name, p[:version], tenant: tenant)
|
|
26
|
+
@event_stream.emit(Insika::Event.new(
|
|
27
|
+
type: :knowledge_learned, data: { name: name, agent: agent },
|
|
28
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
29
|
+
))
|
|
30
|
+
{ name: name, agent: agent, tenant: tenant, updated_at: rec["updated_at"] }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# a channel opt-out event (mapped by the integration)— or
|
|
8
|
+
# the operator force-revoking a contact whose opt-out never arrived.
|
|
9
|
+
# Revokes the contact cell AND cancels every pending record of that
|
|
10
|
+
# customer in ONE transaction on the shared backend (D2: a half-cancelled
|
|
11
|
+
# opt-out is the spam bug — a crash mid-revoke must leave the old state,
|
|
12
|
+
# never the cell revoked with pending records alive). Blocked/fired
|
|
13
|
+
# records are never touched. Tenant-scoped (WS1).
|
|
14
|
+
class RevokeContact
|
|
15
|
+
def initialize(contact_store:, followup_store:, store:, event_stream:)
|
|
16
|
+
@contact_store = contact_store
|
|
17
|
+
@followup_store = followup_store
|
|
18
|
+
@store = store
|
|
19
|
+
@event_stream = event_stream
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# -> { customer:, tenant:, cancelled: }.
|
|
23
|
+
def call(command)
|
|
24
|
+
customer = Coercion.presence(command.payload[:customer] || command.payload["customer"])
|
|
25
|
+
raise ValidationError, "customer is required" if customer.nil?
|
|
26
|
+
|
|
27
|
+
tenant = command.meta[:tenant]
|
|
28
|
+
# ONE transaction on the shared backend: both stores' writes (each
|
|
29
|
+
# wrapped in their own nested transaction) join the outer one.
|
|
30
|
+
cancelled = @store.transaction do
|
|
31
|
+
@contact_store.set_revoked(tenant: tenant, customer: customer)
|
|
32
|
+
@followup_store.cancel_pending_for(tenant: tenant, customer: customer)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@event_stream.emit(Insika::Event.new(
|
|
36
|
+
type: :contact_revoked,
|
|
37
|
+
data: { customer: customer, tenant: tenant },
|
|
38
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
39
|
+
))
|
|
40
|
+
@event_stream.emit(Insika::Event.new(
|
|
41
|
+
type: :followups_cancelled,
|
|
42
|
+
data: { customer: customer, count: cancelled },
|
|
43
|
+
meta: { tenant: tenant, at: Time.now.utc.iso8601 }
|
|
44
|
+
))
|
|
45
|
+
{ customer: customer, tenant: tenant, cancelled: cancelled }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|