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,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# the tick-driven FIRER of follow-up records: one
|
|
7
|
+
# pass per claim window; each record is its own claim (pending -> fired + task
|
|
8
|
+
# creation + the contact bump in ONE transaction — D5). Gating order (D6):
|
|
9
|
+
#
|
|
10
|
+
# policy valid? -> contact state (never-granted / revoked / unavailable ->
|
|
11
|
+
# block) -> quiet hours? (pending — DEFER) -> dedup guard (block) ->
|
|
12
|
+
# frequency ceiling (block) -> FIRE.
|
|
13
|
+
#
|
|
14
|
+
# Blocking is at fire time, never at schedule time; a `blocked` record is
|
|
15
|
+
# auditable, never silent. The engine never modifies a policy and never
|
|
16
|
+
# invents a reason — the turn it creates is delivered entirely by the existing
|
|
17
|
+
# pipeline (it holds no channel code).
|
|
18
|
+
class FollowupEngine
|
|
19
|
+
SCOPE = "followup_fire"
|
|
20
|
+
KEY = "claim"
|
|
21
|
+
DEFAULT_WINDOW = 300 # seconds; one firing worker per window
|
|
22
|
+
|
|
23
|
+
# The engine's kick text. The agent composes the customer-visible message
|
|
24
|
+
# itself — the engine never writes a word the customer reads. %{reason} and
|
|
25
|
+
# %{now} (UTC) are empty-escaped.
|
|
26
|
+
FIRING_PROMPT = "You are following up on a previous conversation, as " \
|
|
27
|
+
"scheduled and with the customer's consent. The follow-up " \
|
|
28
|
+
"reason: %{reason}. It is now %{now} (UTC). Write ONE short " \
|
|
29
|
+
"follow-up message to the customer now, referencing the " \
|
|
30
|
+
"earlier conversation."
|
|
31
|
+
|
|
32
|
+
def initialize(store:, followup_store:, contact_store:, task_store:,
|
|
33
|
+
profiles:, executor:, window: DEFAULT_WINDOW,
|
|
34
|
+
now: nil, logger: nil, event_stream: nil)
|
|
35
|
+
@store = store
|
|
36
|
+
@followup_store = followup_store
|
|
37
|
+
@contact_store = contact_store
|
|
38
|
+
@task_store = task_store
|
|
39
|
+
@profiles = profiles
|
|
40
|
+
@executor = executor
|
|
41
|
+
@window = window
|
|
42
|
+
@now = now
|
|
43
|
+
@logger = logger
|
|
44
|
+
@event_stream = event_stream
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# -> { claimed: false }
|
|
48
|
+
# | { claimed: true, fired: N, blocked: N, errors: N,
|
|
49
|
+
# blocked_reasons: { "rule" => N }, deferred: N }
|
|
50
|
+
# A StoreError on ONE record aborts THAT record's transaction (rescued,
|
|
51
|
+
# counted, the loop continues) — a broken record must not hold the other
|
|
52
|
+
# stores' follow-ups hostage.
|
|
53
|
+
def run
|
|
54
|
+
now_time = @now || Time.now.utc
|
|
55
|
+
return { claimed: false } unless claim_window(now_time)
|
|
56
|
+
|
|
57
|
+
fired = 0
|
|
58
|
+
blocked = 0
|
|
59
|
+
deferred = 0
|
|
60
|
+
errors = 0
|
|
61
|
+
reasons = Hash.new(0)
|
|
62
|
+
|
|
63
|
+
# D7 at fire time: when more than one PENDING record holds the same
|
|
64
|
+
# (customer, reason), only the OLDEST fires. The verdict is snapshotted
|
|
65
|
+
# at pass start — a record that fires earlier in THIS pass must still
|
|
66
|
+
# dedup the younger ones behind it.
|
|
67
|
+
dedup_snapshot = {}
|
|
68
|
+
due = @followup_store.due(now: now_time)
|
|
69
|
+
due.each do |record|
|
|
70
|
+
pair = [record.tenant, record.agent, record.customer, record.reason]
|
|
71
|
+
dedup_snapshot[pair] ||= @followup_store
|
|
72
|
+
.pending_for(tenant: record.tenant, agent: record.agent,
|
|
73
|
+
customer: record.customer, reason: record.reason)
|
|
74
|
+
&.id
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
due.each do |record|
|
|
78
|
+
begin
|
|
79
|
+
outcome = fire_record(record, now_time,
|
|
80
|
+
older_pending_id: dedup_snapshot[[record.tenant, record.agent,
|
|
81
|
+
record.customer, record.reason]])
|
|
82
|
+
case outcome
|
|
83
|
+
when :fired then fired += 1
|
|
84
|
+
when :deferred then deferred += 1
|
|
85
|
+
when Array
|
|
86
|
+
# a blocked record is auditable, never silent — the failing rule is
|
|
87
|
+
# written down on the record itself (D6/D9).
|
|
88
|
+
@followup_store.block(id: record.id, reason: outcome[1], now: now_time)
|
|
89
|
+
blocked += 1
|
|
90
|
+
reasons[outcome[1].to_s] += 1
|
|
91
|
+
end
|
|
92
|
+
rescue StandardError
|
|
93
|
+
# a broken record must not hold the other records' follow-ups
|
|
94
|
+
# hostage — its own transaction already rolled back.
|
|
95
|
+
errors += 1
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
{ claimed: true, fired: fired, blocked: blocked, errors: errors,
|
|
100
|
+
blocked_reasons: reasons, deferred: deferred }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# -> :fired | :deferred | [:blocked, rule]
|
|
106
|
+
def fire_record(record, now_time, older_pending_id: nil)
|
|
107
|
+
profile = @profiles.fetch(record.agent)
|
|
108
|
+
policy = profile && Insika::FollowupPolicy.parse(profile.respond_to?(:followup) ? profile.followup : nil)
|
|
109
|
+
# D9: a malformed policy (or a missing profile) is a BLOCK, never a crash
|
|
110
|
+
# and never a silent fire.
|
|
111
|
+
return [:blocked, :policy_invalid] if policy.nil?
|
|
112
|
+
|
|
113
|
+
contact = @contact_store.get(tenant: record.tenant, customer: record.customer)
|
|
114
|
+
return [:blocked, :consent] if contact.nil? # never messaged (D2)
|
|
115
|
+
return [:blocked, :revoked] if contact.state == "revoked"
|
|
116
|
+
return [:blocked, :unavailable] if contact.state == "unavailable"
|
|
117
|
+
|
|
118
|
+
# quiet hours keep the record PENDING — that IS the deferral; the next
|
|
119
|
+
# pass retries it (never a terminal block).
|
|
120
|
+
return :deferred if policy.quiet?(now_time)
|
|
121
|
+
|
|
122
|
+
# D7 at fire time: only the OLDEST pending per (customer, reason) fires;
|
|
123
|
+
# a younger pending of the same pair blocks (the snapshot taken at pass
|
|
124
|
+
# start, so a same-pass fire still dedups the younger ones).
|
|
125
|
+
if older_pending_id && older_pending_id != record.id
|
|
126
|
+
return [:blocked, :dedup]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# frequency: fired sends inside the window already at the ceiling -> block
|
|
130
|
+
if (window = policy.frequency_window) && window[:count].positive?
|
|
131
|
+
sent = @followup_store.fired_in_window(tenant: record.tenant, customer: record.customer,
|
|
132
|
+
since: now_time - window[:seconds])
|
|
133
|
+
return [:blocked, :frequency] if sent >= window[:count]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
fire(record, policy, now_time)
|
|
137
|
+
:fired
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The atomic claim (D5): the record and the turn commit together or
|
|
141
|
+
# together fail. Within ONE transaction: create the synthetic task, flip
|
|
142
|
+
# the record to fired (with the task id), bump the contact counter — and
|
|
143
|
+
# mark :unavailable when the bump reaches the policy's silence ceiling.
|
|
144
|
+
def fire(record, policy, now_time)
|
|
145
|
+
message = format(FIRING_PROMPT, reason: record.reason.to_s, now: now_time.utc.iso8601)
|
|
146
|
+
command = { "type" => "scheduled_followup",
|
|
147
|
+
"session_id" => record.session_id,
|
|
148
|
+
"payload" => { "agent" => record.agent, "session_id" => record.session_id,
|
|
149
|
+
"customer" => record.customer, "message" => message,
|
|
150
|
+
"origin" => Insika::MessageOrigin::SCHEDULED },
|
|
151
|
+
"meta" => { "tenant" => record.tenant, "transport" => record.transport } }
|
|
152
|
+
|
|
153
|
+
@store.transaction do
|
|
154
|
+
task = @task_store.create(command: command, session_id: record.session_id)
|
|
155
|
+
@followup_store.transition_fired(id: record.id, task_id: task.id, now: now_time)
|
|
156
|
+
cell = @contact_store.bump_outbound(tenant: record.tenant, customer: record.customer,
|
|
157
|
+
now: now_time)
|
|
158
|
+
if cell.sends_without_reply >= policy.silence_after_sends
|
|
159
|
+
@contact_store.mark_unavailable(tenant: record.tenant, customer: record.customer,
|
|
160
|
+
now: now_time)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# AFTER the commit: spawn. A spawn failure leaves a durable :queued task
|
|
165
|
+
# — the recovery sweep resumes it (a queued task is claimed once).
|
|
166
|
+
task_id = @followup_store.find(record.id).task_id
|
|
167
|
+
task = @task_store.find(task_id)
|
|
168
|
+
profile = @profiles.fetch(record.agent)
|
|
169
|
+
@executor.spawn_in_session(task, profile: profile)
|
|
170
|
+
emit_fired(record, task_id)
|
|
171
|
+
task_id
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# :followup_fired carries ids only — a follow-up record's reason is contact
|
|
175
|
+
# data that rides the record and the Studio, never the event stream.
|
|
176
|
+
def emit_fired(record, task_id)
|
|
177
|
+
return unless @event_stream
|
|
178
|
+
|
|
179
|
+
@event_stream.emit(Insika::Event.new(
|
|
180
|
+
type: :followup_fired,
|
|
181
|
+
data: { id: record.id, agent: record.agent, customer: record.customer,
|
|
182
|
+
task_id: task_id, arm: record.arm },
|
|
183
|
+
meta: { tenant: record.tenant, at: Time.now.utc.iso8601 }
|
|
184
|
+
))
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The claim window (funnel_fold.rb's idiom — read-check-write on one key
|
|
188
|
+
# inside a transaction): the O(n) scan never rides the 60 s tick, and two
|
|
189
|
+
# workers racing serialize on the backend's lock.
|
|
190
|
+
def claim_window(now_time)
|
|
191
|
+
@store.transaction do
|
|
192
|
+
current = @store.get(SCOPE, KEY)
|
|
193
|
+
last = current && begin
|
|
194
|
+
Time.iso8601(current["claimed_at"].to_s)
|
|
195
|
+
rescue ArgumentError
|
|
196
|
+
nil # a corrupted claim is not a claim — take the window
|
|
197
|
+
end
|
|
198
|
+
if last.nil? || (now_time - last) >= @window
|
|
199
|
+
@store.set(SCOPE, KEY, { "claimed_at" => now_time.iso8601 })
|
|
200
|
+
true
|
|
201
|
+
else
|
|
202
|
+
false
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# the parsed follow-up policy of ONE agent — the ONLY shape the
|
|
5
|
+
# engine accepts, shared by the schedule/cancel tools, the FollowupEngine,
|
|
6
|
+
# the doctor and the Studio. Pure value object: it never touches a store
|
|
7
|
+
# (D8 — the policy is pack data on the profile, no platform settings layer).
|
|
8
|
+
#
|
|
9
|
+
# `parse` returns nil on a malformed hash (D9 — the firer BLOCKS, the doctor
|
|
10
|
+
# explains, the tools refuse); `parse!` raises Insika::ValidationError naming
|
|
11
|
+
# the exact defect.
|
|
12
|
+
class FollowupPolicy
|
|
13
|
+
QuietHours = Data.define(:timezone, :start, :end)
|
|
14
|
+
|
|
15
|
+
FREQUENCY_RE = /\A(\d+)\/(\d+)(m|h|d)s?\z/
|
|
16
|
+
HH_MM_RE = /\A\d{2}:\d{2}\z/
|
|
17
|
+
DEFAULT_ARM = "schedule"
|
|
18
|
+
DEFAULT_SILENCE_AFTER_SENDS = 3
|
|
19
|
+
KEYWORD_MAX = 200
|
|
20
|
+
|
|
21
|
+
attr_reader :arm, :quiet_hours, :max_frequency, :cancel_keywords, :silence_after_sends
|
|
22
|
+
|
|
23
|
+
def self.parse(hash)
|
|
24
|
+
new(hash)
|
|
25
|
+
rescue Insika::ValidationError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.parse!(hash)
|
|
30
|
+
new(hash)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(hash)
|
|
34
|
+
raise Insika::ValidationError, "followup: declaration must be a Hash" unless hash.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
h = hash.transform_keys(&:to_s)
|
|
37
|
+
@arm = arm_of(h)
|
|
38
|
+
policy = h["policy"]
|
|
39
|
+
raise Insika::ValidationError, "followup.policy must be a Hash" unless policy.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
policy = policy.transform_keys(&:to_s)
|
|
42
|
+
@quiet_hours = quiet_hours_of(policy)
|
|
43
|
+
@max_frequency = frequency_of(policy)
|
|
44
|
+
@cancel_keywords = keywords_of(policy)
|
|
45
|
+
@silence_after_sends = silence_of(policy)
|
|
46
|
+
freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# "2/24h" -> { count: 2, seconds: 86_400 }. nil when the policy has no
|
|
50
|
+
# ceiling (absent max_frequency — the frequency gate is off). Always valid
|
|
51
|
+
# once parsed.
|
|
52
|
+
# `count` is the sends allowed per window; `seconds` is the WINDOW's
|
|
53
|
+
# duration (the value after the "/"), never count*window.
|
|
54
|
+
def frequency_window
|
|
55
|
+
return nil if @max_frequency.nil?
|
|
56
|
+
|
|
57
|
+
match = FREQUENCY_RE.match(@max_frequency)
|
|
58
|
+
multiplier = { "m" => 60, "h" => 3600, "d" => 86_400 }.fetch(match[3])
|
|
59
|
+
{ count: match[1].to_i, seconds: match[2].to_i * multiplier }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Is the given UTC Time inside quiet hours in the policy's timezone? (nil
|
|
63
|
+
# quiet_hours -> false: no quiet window.)
|
|
64
|
+
#
|
|
65
|
+
# IANA names are resolved through the OS tz database by pointing Ruby's
|
|
66
|
+
# `TZ` at the zone for the computation (Ruby stdlib's `Time#getlocal`
|
|
67
|
+
# only takes an offset, not a zone name). Save/restore keeps the global
|
|
68
|
+
# intact; under the engine's cooperative fiber model — no IO between the
|
|
69
|
+
# save and the restore — the mutation is atomic on the calling fiber.
|
|
70
|
+
def quiet?(time)
|
|
71
|
+
return false unless @quiet_hours
|
|
72
|
+
|
|
73
|
+
local = in_zone(@quiet_hours.timezone, time) { |t| t.getlocal }
|
|
74
|
+
minutes = local.hour * 60 + local.min
|
|
75
|
+
start_min = minutes_of(@quiet_hours.start)
|
|
76
|
+
end_min = minutes_of(@quiet_hours.end)
|
|
77
|
+
if start_min <= end_min
|
|
78
|
+
minutes >= start_min && minutes < end_min
|
|
79
|
+
else
|
|
80
|
+
# an overnight window: 21:30-09:00
|
|
81
|
+
minutes >= start_min || minutes < end_min
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The first cancel keyword matched (case/accent-insensitive substring);
|
|
86
|
+
# nil when none matches.
|
|
87
|
+
def match_keyword(text)
|
|
88
|
+
folded = fold(text)
|
|
89
|
+
@cancel_keywords.each do |kw|
|
|
90
|
+
return kw if folded.include?(fold(kw))
|
|
91
|
+
end
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def to_h
|
|
96
|
+
{ "arm" => @arm,
|
|
97
|
+
"policy" => {
|
|
98
|
+
"quiet_hours" => @quiet_hours && {
|
|
99
|
+
"timezone" => @quiet_hours.timezone, "start" => @quiet_hours.start, "end" => @quiet_hours.end
|
|
100
|
+
},
|
|
101
|
+
"max_frequency" => @max_frequency,
|
|
102
|
+
"cancel_keywords" => @cancel_keywords,
|
|
103
|
+
"silence_after_sends" => @silence_after_sends
|
|
104
|
+
}.compact }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def arm_of(hash)
|
|
110
|
+
arm = hash["arm"]
|
|
111
|
+
return DEFAULT_ARM if arm.nil?
|
|
112
|
+
|
|
113
|
+
arm = arm.to_s
|
|
114
|
+
raise Insika::ValidationError, "followup.arm must be a non-blank String" if arm.strip.empty?
|
|
115
|
+
|
|
116
|
+
arm
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def quiet_hours_of(policy)
|
|
120
|
+
qh = policy["quiet_hours"]
|
|
121
|
+
return nil if qh.nil?
|
|
122
|
+
|
|
123
|
+
raise Insika::ValidationError, "followup.policy.quiet_hours must be a Hash" unless qh.is_a?(Hash)
|
|
124
|
+
|
|
125
|
+
qh = qh.transform_keys(&:to_s)
|
|
126
|
+
timezone = qh["timezone"]
|
|
127
|
+
raise Insika::ValidationError, "followup.policy.quiet_hours.timezone is required" if Coercion.blank?(timezone)
|
|
128
|
+
|
|
129
|
+
start_at = qh["start"]
|
|
130
|
+
end_at = qh["end"]
|
|
131
|
+
unless HH_MM_RE.match?(start_at.to_s) && HH_MM_RE.match?(end_at.to_s)
|
|
132
|
+
raise Insika::ValidationError,
|
|
133
|
+
"followup.policy.quiet_hours.start/end must match /\\A\\d{2}:\\d{2}\\z/ (24h)"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# a bogus IANA zone is a malformed policy — refused HERE, where the
|
|
137
|
+
# doctor can name it. An unknown ENV["TZ"] silently behaves as UTC, so
|
|
138
|
+
# existence is checked against the OS tz database, not by asking Time.
|
|
139
|
+
unless zone_known?(timezone.to_s)
|
|
140
|
+
raise Insika::ValidationError,
|
|
141
|
+
"followup.policy.quiet_hours.timezone is not a valid IANA timezone: #{timezone.inspect}"
|
|
142
|
+
end
|
|
143
|
+
QuietHours.new(timezone: timezone.to_s, start: start_at.to_s, end: end_at.to_s)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def frequency_of(policy)
|
|
147
|
+
f = policy["max_frequency"]
|
|
148
|
+
return nil if f.nil?
|
|
149
|
+
|
|
150
|
+
f = f.to_s
|
|
151
|
+
unless FREQUENCY_RE.match?(f)
|
|
152
|
+
raise Insika::ValidationError,
|
|
153
|
+
"followup.policy.max_frequency must match /\\A\\d+\\/(\\d+)(m|h|d)s?\\z/ (e.g. \"2/24h\"; weeks are not allowed)"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
f
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def keywords_of(policy)
|
|
160
|
+
list = policy["cancel_keywords"]
|
|
161
|
+
return [] if list.nil?
|
|
162
|
+
|
|
163
|
+
raise Insika::ValidationError, "followup.policy.cancel_keywords must be an Array" unless list.is_a?(Array)
|
|
164
|
+
|
|
165
|
+
list.map!(&:to_s)
|
|
166
|
+
list.each do |kw|
|
|
167
|
+
if Coercion.blank?(kw)
|
|
168
|
+
raise Insika::ValidationError, "followup.policy.cancel_keywords: each keyword must be non-blank"
|
|
169
|
+
end
|
|
170
|
+
if kw.length > KEYWORD_MAX
|
|
171
|
+
raise Insika::ValidationError,
|
|
172
|
+
"followup.policy.cancel_keywords: each keyword must be <= #{KEYWORD_MAX} chars"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
list
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def silence_of(policy)
|
|
179
|
+
s = policy["silence_after_sends"]
|
|
180
|
+
return DEFAULT_SILENCE_AFTER_SENDS if s.nil?
|
|
181
|
+
|
|
182
|
+
raise Insika::ValidationError, "followup.policy.silence_after_sends must be an Integer > 0" unless s.is_a?(Integer) && s.positive?
|
|
183
|
+
|
|
184
|
+
s
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# NFD + strip combining marks + downcase: the same fold the doctor's
|
|
188
|
+
# identity matcher uses, so "NÃO" matches "não".
|
|
189
|
+
def fold(text)
|
|
190
|
+
text.to_s.unicode_normalize(:nfd).gsub(/\p{Mn}/, "").downcase
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Yields `time` interpreted in the given IANA zone (via a save/restore of
|
|
194
|
+
# ENV["TZ"] — the stdlib-only route to the OS tz database; see #quiet?).
|
|
195
|
+
def in_zone(zone, time)
|
|
196
|
+
previous = ENV["TZ"]
|
|
197
|
+
ENV["TZ"] = zone
|
|
198
|
+
yield time
|
|
199
|
+
ensure
|
|
200
|
+
ENV["TZ"] = previous
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The candidate tz-data roots (TZDIR first — Ruby's own lookup env). The
|
|
204
|
+
# zone name maps to a FILE under the root ("America/Sao_Paulo" ->
|
|
205
|
+
# "America/Sao_Paulo").
|
|
206
|
+
TZ_ROOTS = ([ENV["TZDIR"]] +
|
|
207
|
+
%w[/usr/share/zoneinfo /usr/share/lib/zoneinfo /etc/zoneinfo])
|
|
208
|
+
.compact.freeze
|
|
209
|
+
|
|
210
|
+
def zone_known?(zone)
|
|
211
|
+
return true if zone == "UTC" || zone == "Etc/UTC"
|
|
212
|
+
|
|
213
|
+
TZ_ROOTS.any? { |root| File.directory?(root) && File.exist?(File.join(root, zone)) }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def minutes_of(hhmm)
|
|
217
|
+
h, m = hhmm.split(":").map(&:to_i)
|
|
218
|
+
h * 60 + m
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|