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,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,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,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
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
module Commands
|
|
8
|
+
# — the snapshot restored. `RollbackHarvest(snapshot_ref:)`
|
|
9
|
+
# restores the pre-promotion state deterministically: WriteSkill with the
|
|
10
|
+
# snapshot's pre-promotion content (or DeleteSkill when the skill did not
|
|
11
|
+
# exist before — the snapshot's `existed` flag), SetSkillAgents to the
|
|
12
|
+
# snapshot's pre-promotion `enabled_for`, and the `rolled_back_at` stamp on
|
|
13
|
+
# the SAME log (the log stays the single ledger: a skill promoted, rolled
|
|
14
|
+
# back, re-promoted is three readable rows). The "rehearsed once
|
|
15
|
+
# end-to-end" acceptance is this command's full promote -> rollback cycle.
|
|
16
|
+
class RollbackHarvest
|
|
17
|
+
def initialize(harvest_store:, skill_store:, skill_catalog:, profile_source:,
|
|
18
|
+
event_stream:, write_skill: nil, delete_skill: nil, set_skill_agents: nil)
|
|
19
|
+
@harvest_store = harvest_store
|
|
20
|
+
@profiles = ProfileSource.coerce(profile_source)
|
|
21
|
+
@event_stream = event_stream
|
|
22
|
+
@write_skill = write_skill ||
|
|
23
|
+
Insika::Commands::WriteSkill.new(skill_store: skill_store,
|
|
24
|
+
skill_catalog: skill_catalog,
|
|
25
|
+
event_stream: event_stream)
|
|
26
|
+
@delete_skill = delete_skill ||
|
|
27
|
+
Insika::Commands::DeleteSkill.new(skill_store: skill_store,
|
|
28
|
+
skill_catalog: skill_catalog,
|
|
29
|
+
event_stream: event_stream)
|
|
30
|
+
@set_skill_agents = set_skill_agents ||
|
|
31
|
+
Insika::Commands::SetSkillAgents.new(profile_source: @profiles,
|
|
32
|
+
event_stream: event_stream)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# payload: { snapshot_ref:, operator?, reason? }
|
|
36
|
+
# -> Promotion (the row with rolled_back_at stamped)
|
|
37
|
+
def call(command)
|
|
38
|
+
p = AgentPayload.symbolize(command.payload)
|
|
39
|
+
snapshot_ref = AgentPayload.presence(p[:snapshot_ref])
|
|
40
|
+
raise Insika::ValidationError, "snapshot_ref is required" if snapshot_ref.nil?
|
|
41
|
+
|
|
42
|
+
snap = @harvest_store.find_snapshot(snapshot_ref) ||
|
|
43
|
+
(raise Insika::NotFoundError, "harvest snapshot not found: #{snapshot_ref}")
|
|
44
|
+
# The log row whose snapshot_ref matches — but a promotion that never
|
|
45
|
+
# recorded its row (a crash between the writes and the append) rolls
|
|
46
|
+
# back by snapshot, and the rollback stamp catches up on whatever row
|
|
47
|
+
# exists (D8's honest price, resolved by reference).
|
|
48
|
+
promotion = promotion_for_snapshot(snap)
|
|
49
|
+
operator = (AgentPayload.presence(p[:operator]) || command.meta[:operator] || "operator").to_s
|
|
50
|
+
|
|
51
|
+
if snap.existed
|
|
52
|
+
@write_skill.call(Insika::Command.build(:write_skill,
|
|
53
|
+
{ name: snap.skill, content: snap.content,
|
|
54
|
+
agent: snap.agent }))
|
|
55
|
+
else
|
|
56
|
+
begin
|
|
57
|
+
@delete_skill.call(Insika::Command.build(:delete_skill,
|
|
58
|
+
{ name: snap.skill, agent: snap.agent }))
|
|
59
|
+
rescue Insika::NotFoundError
|
|
60
|
+
# the desired state (gone) already holds — a snapshot taken for a
|
|
61
|
+
# skill that never landed rolls back cleanly
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
@set_skill_agents.call(Insika::Command.build(:set_skill_agents,
|
|
65
|
+
{ name: snap.skill,
|
|
66
|
+
agent_ids: snap.enabled_for }))
|
|
67
|
+
|
|
68
|
+
rolled = promotion ? @harvest_store.append_rollback(promotion_id: promotion.id,
|
|
69
|
+
operator: operator, reason: p[:reason]) : nil
|
|
70
|
+
@event_stream.emit(Insika::Event.new(
|
|
71
|
+
type: :skill_rolled_back,
|
|
72
|
+
data: { snapshot_ref: snapshot_ref, skill: snap.skill,
|
|
73
|
+
agent: snap.agent, operator: operator }.compact,
|
|
74
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
75
|
+
))
|
|
76
|
+
rolled || promotion
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def promotion_for_snapshot(snap)
|
|
82
|
+
@harvest_store.promotions.find { |row| row.snapshot_ref == snap.id }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Commands
|
|
5
|
+
# the ONLY path that writes proposals. Distills ONE session
|
|
6
|
+
# end to end: read the transcript and the memory baseline, ask the model,
|
|
7
|
+
# schema-drop, dedup against the ledger, write proposals, mark the session
|
|
8
|
+
# distilled. Synchronous (it runs on the engine's worker fiber, C5) — it
|
|
9
|
+
# creates no task and no turn (D2: no TaskStore work unit — a crash
|
|
10
|
+
# mid-pass leaves the marker unwritten and the next pass re-scans; a
|
|
11
|
+
# duplicate proposal is filtered by the ledger, never applied).
|
|
12
|
+
class RunDistillation
|
|
13
|
+
DEFAULT_IDLE_HOURS = 6
|
|
14
|
+
DEFAULT_MIN_MESSAGES = 3
|
|
15
|
+
DEFAULT_MAX_PROPOSALS = 10
|
|
16
|
+
|
|
17
|
+
def initialize(profiles:, proposal_store:, session_store:, memory_store:,
|
|
18
|
+
settings_store:, event_stream:, distiller_factory: nil)
|
|
19
|
+
@profiles = profiles
|
|
20
|
+
@proposal_store = proposal_store
|
|
21
|
+
@session_store = session_store
|
|
22
|
+
@memory_store = memory_store
|
|
23
|
+
@settings_store = settings_store
|
|
24
|
+
@event_stream = event_stream
|
|
25
|
+
@distiller_factory = distiller_factory ||
|
|
26
|
+
->(config) { Distill::DistillerFactory.build(config, utility_model: utility_model) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# payload: { session_id: } (agent resolved from the session, D6-bis)
|
|
30
|
+
# -> { distilled: true, proposals: N, dropped: {...}, deduped: N, cost: {...} | nil }
|
|
31
|
+
# | { distilled: false, skipped: "already|untagged|no_agent|disabled|
|
|
32
|
+
# too_fresh|too_short|no_model" }
|
|
33
|
+
def call(command)
|
|
34
|
+
session_id = Coercion.presence(command.payload[:session_id] || command.payload["session_id"])
|
|
35
|
+
raise ValidationError, "session_id is required" if session_id.nil?
|
|
36
|
+
|
|
37
|
+
session = @session_store.find(session_id)
|
|
38
|
+
raise Insika::NotFoundError, "session not found: #{session_id}" if session.nil?
|
|
39
|
+
|
|
40
|
+
return skip("already") if @proposal_store.distilled?(session_id)
|
|
41
|
+
|
|
42
|
+
customer = Coercion.presence(session.vars["customer"])
|
|
43
|
+
return skip("untagged") if customer.nil?
|
|
44
|
+
|
|
45
|
+
agent_id = Coercion.presence(session.vars["agent"])
|
|
46
|
+
return skip("no_agent") if agent_id.nil?
|
|
47
|
+
|
|
48
|
+
profile = @profiles[agent_id]
|
|
49
|
+
return skip("no_agent") if profile.nil?
|
|
50
|
+
|
|
51
|
+
config = Coercion.deep_stringify(profile.distill)
|
|
52
|
+
return skip("disabled") if config.nil? || !Coercion.truthy?(config["enabled"])
|
|
53
|
+
|
|
54
|
+
# D4: the pack's own idle threshold wins over the scan's lower bound —
|
|
55
|
+
# a pack that wants 12 h is never distilled at 6.
|
|
56
|
+
idle_hours = config["idle_hours"].to_i
|
|
57
|
+
idle_hours = DEFAULT_IDLE_HOURS unless idle_hours.positive?
|
|
58
|
+
return skip("too_fresh") unless idle?(session.updated_at, idle_hours)
|
|
59
|
+
|
|
60
|
+
min_messages = config["min_messages"].to_i
|
|
61
|
+
min_messages = DEFAULT_MIN_MESSAGES unless min_messages.positive?
|
|
62
|
+
return skip("too_short") if session.messages.size < min_messages
|
|
63
|
+
|
|
64
|
+
tenant = tenant_of(session_id)
|
|
65
|
+
distiller = @distiller_factory.call(config)
|
|
66
|
+
return skip("no_model") if distiller.nil?
|
|
67
|
+
|
|
68
|
+
baseline = memory_baseline(tenant, customer)
|
|
69
|
+
prompt = build_prompt(config, session, baseline)
|
|
70
|
+
result = distiller.distill(prompt: prompt, message_count: session.messages.size,
|
|
71
|
+
max_proposals: (config["max_proposals"] || DEFAULT_MAX_PROPOSALS).to_i)
|
|
72
|
+
|
|
73
|
+
deduped = 0
|
|
74
|
+
survivors = result[:proposals].reject do |proposal|
|
|
75
|
+
dedup = deduped?(tenant, customer, proposal, baseline)
|
|
76
|
+
deduped += 1 if dedup
|
|
77
|
+
dedup
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
survivors.each do |proposal|
|
|
81
|
+
expected = baseline[proposal["name"]]
|
|
82
|
+
@proposal_store.create(
|
|
83
|
+
tenant: tenant, customer: customer, session_ref: session_id,
|
|
84
|
+
key: proposal["name"], value: proposal["value"],
|
|
85
|
+
confidence: proposal["confidence"], evidence: proposal["turns"],
|
|
86
|
+
expected_revision: expected && expected.updated_at,
|
|
87
|
+
expected_existed: !expected.nil?
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
@proposal_store.mark_distilled(session_id, agent: agent_id,
|
|
92
|
+
proposals: survivors.size,
|
|
93
|
+
dropped: result[:dropped],
|
|
94
|
+
deduped: deduped,
|
|
95
|
+
cost: result[:cost])
|
|
96
|
+
@event_stream.emit(Insika::Event.new(
|
|
97
|
+
type: :distillation_completed,
|
|
98
|
+
# counts and ids only (D7) — a fact value never
|
|
99
|
+
# enters the stream.
|
|
100
|
+
data: { session_ref: session_id, agent: agent_id,
|
|
101
|
+
proposals: survivors.size,
|
|
102
|
+
dropped: result[:dropped],
|
|
103
|
+
deduped: deduped,
|
|
104
|
+
cost: result[:cost] },
|
|
105
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
106
|
+
))
|
|
107
|
+
{ distilled: true, proposals: survivors.size, dropped: result[:dropped],
|
|
108
|
+
deduped: deduped, cost: result[:cost] }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# nil tenant in single-tenant deployments; the "<tenant>:" prefix of the
|
|
114
|
+
# session id otherwise (D1 — the same reading ForgetCustomer uses).
|
|
115
|
+
def tenant_of(session_id)
|
|
116
|
+
tenant, rest = session_id.to_s.split(":", 2)
|
|
117
|
+
rest.nil? ? nil : tenant
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def skip(reason)
|
|
121
|
+
{ distilled: false, skipped: reason }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def idle?(updated_at, idle_hours)
|
|
125
|
+
return false if Coercion.blank?(updated_at)
|
|
126
|
+
|
|
127
|
+
Time.iso8601(updated_at.to_s) <= Time.now.utc - idle_hours * 3600
|
|
128
|
+
rescue ArgumentError
|
|
129
|
+
false
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Per fact key: the existence + updated_at at distill time (D5 — the CAS
|
|
133
|
+
# baseline the approval compares against). Sorted by key for a stable
|
|
134
|
+
# prompt.
|
|
135
|
+
def memory_baseline(tenant, customer)
|
|
136
|
+
@memory_store.facts(tenant: tenant, customer: customer)
|
|
137
|
+
.each_with_object({}) { |f, acc| acc[f.key] = f }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def deduped?(tenant, customer, proposal, baseline)
|
|
141
|
+
key = proposal["name"].to_s
|
|
142
|
+
value = proposal["value"].to_s
|
|
143
|
+
return true if @proposal_store.decided?(tenant: tenant, customer: customer,
|
|
144
|
+
key: key, value: value)
|
|
145
|
+
return true if @proposal_store.open_pending?(tenant: tenant, customer: customer, key: key)
|
|
146
|
+
|
|
147
|
+
applied = baseline[key]
|
|
148
|
+
!applied.nil? && applied.value == value && applied.origin.to_s.start_with?("distilled:")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The prompt gets the transcript slice (masked through the
|
|
152
|
+
# output filter first — the redaction rule), the
|
|
153
|
+
# customer's CURRENT facts (so the model can avoid re-proposing applied
|
|
154
|
+
# facts), and the answer rules (the pack prompt or DEFAULT_PROMPT).
|
|
155
|
+
def build_prompt(config, session, baseline)
|
|
156
|
+
base = Coercion.presence(config["prompt"]) || Distill::DEFAULT_PROMPT
|
|
157
|
+
transcript = render_transcript(session.messages)
|
|
158
|
+
facts = baseline.values.map { |f| "- #{f.key}: #{f.value}" }
|
|
159
|
+
<<~PROMPT
|
|
160
|
+
#{base.rstrip}
|
|
161
|
+
|
|
162
|
+
## The conversation
|
|
163
|
+
|
|
164
|
+
#{transcript}
|
|
165
|
+
|
|
166
|
+
## Facts already in this customer's memory (do not re-propose them)
|
|
167
|
+
|
|
168
|
+
#{facts.empty? ? "(none)" : facts.join("\n")}
|
|
169
|
+
PROMPT
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def render_transcript(messages)
|
|
173
|
+
redacted, = Insika::Safety::Detectors.redact(
|
|
174
|
+
messages.each_with_index.map { |m, i| "[#{i}] #{m['role']}: #{m['content']}" }.join("\n")
|
|
175
|
+
)
|
|
176
|
+
redacted
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def utility_model
|
|
180
|
+
return nil unless @settings_store
|
|
181
|
+
|
|
182
|
+
@settings_store.get["utility_model"]
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|