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,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# WS8 (phase 2): retention — the engine forgets what is old enough to forget.
|
|
7
|
+
# Age-based purge of the CONVERSATION footprint: sessions (and their
|
|
8
|
+
# per-session tool/context traces), terminal tasks (and their checkpoints),
|
|
9
|
+
# customer/tenant memory cells and outcome records. The knob is data, not
|
|
10
|
+
# code: `settings.retention_days` (Integer; nil/0 = OFF — parity, nothing is
|
|
11
|
+
# ever swept by default).
|
|
12
|
+
#
|
|
13
|
+
# Cadence: the Tick calls `run` every pass; an internal DAILY claim (the
|
|
14
|
+
# tick's window idiom — one key, a timestamp, 24 h) makes the actual sweep
|
|
15
|
+
# once a day, so the O(n) scans never ride the 60 s loop. A sweep that
|
|
16
|
+
# crashes propagates to the tick, which logs and keeps ticking.
|
|
17
|
+
class Retention
|
|
18
|
+
SCOPE = "retention"
|
|
19
|
+
KEY = "claim"
|
|
20
|
+
BUDGET_KEY = "budget_claim"
|
|
21
|
+
# the MEMORY TTL's own daily claim. Deliberately NOT the age-based
|
|
22
|
+
# KEY — memory TTLs sweep on their own knob (`memory_ttl_days`), gated by
|
|
23
|
+
# neither retention_days nor the age-based claim (D5).
|
|
24
|
+
MEMORY_TTL_KEY = "memory_ttl_claim"
|
|
25
|
+
WINDOW = 86_400 # one sweep per day, at most
|
|
26
|
+
|
|
27
|
+
TERMINAL = %w[completed failed cancelled].freeze
|
|
28
|
+
|
|
29
|
+
def initialize(session_store:, task_store:, checkpoint_store:,
|
|
30
|
+
memory_store:, outcome_store:, tool_trace_store: nil,
|
|
31
|
+
context_trace_store: nil, outbox_store: nil, shadow_pair_store: nil,
|
|
32
|
+
settings_store: nil, budget_ledger: nil, funnel_store: nil,
|
|
33
|
+
followup_store: nil, contact_store: nil, proposal_store: nil,
|
|
34
|
+
model_visible_trace_store: nil,
|
|
35
|
+
store:, window: WINDOW, now: nil, harvest_store: nil)
|
|
36
|
+
@session_store = session_store
|
|
37
|
+
@task_store = task_store
|
|
38
|
+
@checkpoint_store = checkpoint_store
|
|
39
|
+
@memory_store = memory_store
|
|
40
|
+
@outcome_store = outcome_store
|
|
41
|
+
@tool_trace_store = tool_trace_store
|
|
42
|
+
@context_trace_store = context_trace_store
|
|
43
|
+
@model_visible_trace_store = model_visible_trace_store # ; nil = parity
|
|
44
|
+
@outbox_store = outbox_store
|
|
45
|
+
@shadow_pair_store = shadow_pair_store
|
|
46
|
+
@settings_store = settings_store
|
|
47
|
+
@budget_ledger = budget_ledger # WS2 counter GC; nil = nothing to sweep
|
|
48
|
+
@funnel_store = funnel_store # ; nil = nothing to sweep
|
|
49
|
+
@followup_store = followup_store # ; nil = nothing to sweep
|
|
50
|
+
@contact_store = contact_store # ; nil = nothing to sweep
|
|
51
|
+
@proposal_store = proposal_store # ; nil = nothing to sweep
|
|
52
|
+
@store = store
|
|
53
|
+
@window = window
|
|
54
|
+
@now = now # injectable for specs (a deterministic "today")
|
|
55
|
+
@harvest_store = harvest_store # ; nil = nothing to sweep
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# the sweep reads the memory store's cells/records (specs seed
|
|
59
|
+
# facts through it).
|
|
60
|
+
attr_reader :memory_store
|
|
61
|
+
|
|
62
|
+
# -> { claimed: false } |
|
|
63
|
+
# { claimed: true, sessions:, tasks:, outcomes:, memory:, deliveries: }.
|
|
64
|
+
# Either shape may carry `budget_cells:` — the budget counter GC is NOT
|
|
65
|
+
# gated by retention_days (see #sweep_budget_cells). Either shape may carry
|
|
66
|
+
# `memory_ttl:` — the memory TTL sweep, gated by ITS OWN daily
|
|
67
|
+
# claim and knob, never by retention_days (see #sweep_memory_ttl).
|
|
68
|
+
def run
|
|
69
|
+
budget_cells = sweep_budget_cells
|
|
70
|
+
memory_ttl = sweep_memory_ttl
|
|
71
|
+
days = retention_days
|
|
72
|
+
unless days.to_i.positive? && claim_window
|
|
73
|
+
summary = { claimed: false }
|
|
74
|
+
summary[:budget_cells] = budget_cells if budget_cells
|
|
75
|
+
summary[:memory_ttl] = memory_ttl if memory_ttl
|
|
76
|
+
return summary
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
cutoff = now - (days.to_i * 86_400)
|
|
80
|
+
summary = { claimed: true, sessions: sweep_sessions(cutoff),
|
|
81
|
+
tasks: sweep_tasks(cutoff), outcomes: sweep_outcomes(cutoff),
|
|
82
|
+
memory: @memory_store.prune_older_than(cutoff),
|
|
83
|
+
deliveries: sweep_outbox(cutoff),
|
|
84
|
+
pairs: sweep_shadow_pairs(cutoff) }
|
|
85
|
+
summary[:funnel] = sweep_funnel(cutoff) if @funnel_store
|
|
86
|
+
# the follow-up footprint ages out with the rest — records
|
|
87
|
+
# and contact cells under the SAME retention_days gate (nil collaborator
|
|
88
|
+
# = nothing to sweep, base graph parity).
|
|
89
|
+
summary[:followups] = @followup_store.delete_older_than(cutoff) if @followup_store
|
|
90
|
+
summary[:contacts] = @contact_store.delete_older_than(cutoff) if @contact_store
|
|
91
|
+
# proposals are evidence OF a transcript — when the
|
|
92
|
+
# transcript dies, the proposal's excerpt is gone and the pending fact is
|
|
93
|
+
# stale. Pending AND terminal rows age out together; a proposal is
|
|
94
|
+
# re-derivable (D2), so pruning is never data loss. The session MARKERS
|
|
95
|
+
# are never pruned (the store's rule — the marker is the claim).
|
|
96
|
+
summary[:proposals] = @proposal_store.delete_older_than(cutoff) if @proposal_store
|
|
97
|
+
# candidates (pending AND terminal), log rows and
|
|
98
|
+
# snapshots are DERIVED data of transcripts (D11) — when the transcripts
|
|
99
|
+
# die, the candidates' excerpts are gone; they are re-derivable (D2), so
|
|
100
|
+
# pruning is never data loss. The session markers are never pruned.
|
|
101
|
+
summary[:harvest] = @harvest_store.delete_older_than(cutoff) if @harvest_store
|
|
102
|
+
summary[:budget_cells] = budget_cells if budget_cells
|
|
103
|
+
summary[:memory_ttl] = memory_ttl if memory_ttl
|
|
104
|
+
summary
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# The BudgetLedger's expired cells (WS2). Deliberately OUTSIDE the
|
|
110
|
+
# retention_days gate, on its OWN daily claim: those rows are engine
|
|
111
|
+
# bookkeeping whose window already rolled over, not customer content, so a
|
|
112
|
+
# deployment that keeps its conversations forever (retention OFF — the
|
|
113
|
+
# default) must still not grow budget rows forever. -> count | nil (no
|
|
114
|
+
# ledger, or another worker holds today's claim).
|
|
115
|
+
def sweep_budget_cells
|
|
116
|
+
return nil unless @budget_ledger && claim(BUDGET_KEY)
|
|
117
|
+
|
|
118
|
+
@budget_ledger.prune(now: now)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def retention_days
|
|
122
|
+
return nil unless @settings_store
|
|
123
|
+
|
|
124
|
+
value = @settings_store.get["retention_days"]
|
|
125
|
+
value.to_s.empty? ? nil : Integer(value)
|
|
126
|
+
rescue ArgumentError, TypeError
|
|
127
|
+
nil # a non-numeric value reads as OFF — never a crash at sweep time
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# the memory TTL sweep — TWO independent expiry clocks under
|
|
131
|
+
# ONE daily claim:
|
|
132
|
+
# 1. per-fact expires_at (prune_expired — past dates physically removed);
|
|
133
|
+
# 2. per-cell TTL by `memory_ttl_days` (age by the cell's updated_at).
|
|
134
|
+
# A fact with an explicit expires_at is EXCLUDED from the age-based pass
|
|
135
|
+
# (the explicit override owns that fact's life). Runs on its OWN claim and
|
|
136
|
+
# knob — a deployment with retention_days off still honors memory TTLs.
|
|
137
|
+
# -> Integer (removed) | nil (no knob, or another worker holds the claim).
|
|
138
|
+
def sweep_memory_ttl
|
|
139
|
+
ttl = memory_ttl_setting
|
|
140
|
+
return nil if ttl.nil?
|
|
141
|
+
return nil unless claim(MEMORY_TTL_KEY)
|
|
142
|
+
|
|
143
|
+
removed = @memory_store.prune_expired(now)
|
|
144
|
+
ttl_cutoffs(ttl).each do |scope, cutoff|
|
|
145
|
+
removed += @memory_store.prune_older_than(cutoff, scope: scope)
|
|
146
|
+
end
|
|
147
|
+
removed
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# settings["memory_ttl_days"]: Integer | Hash{ "<tenant>" => days, "*" => days }.
|
|
151
|
+
# nil/empty/blank -> nil (OFF — parity). A non-numeric value -> nil (never
|
|
152
|
+
# a crash at sweep time, the retention_days rescue pattern).
|
|
153
|
+
def memory_ttl_setting
|
|
154
|
+
return nil unless @settings_store
|
|
155
|
+
|
|
156
|
+
raw = @settings_store.get["memory_ttl_days"]
|
|
157
|
+
case raw
|
|
158
|
+
when Integer then raw
|
|
159
|
+
when Hash
|
|
160
|
+
map = raw.each_with_object({}) do |(k, v), acc|
|
|
161
|
+
acc[k.to_s] = Integer(v.to_s)
|
|
162
|
+
rescue ArgumentError, TypeError
|
|
163
|
+
next
|
|
164
|
+
end
|
|
165
|
+
map.empty? ? nil : map
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# -> [[scope, Time]] — one per existing cell with a resolved TTL. The
|
|
170
|
+
# setting is passed in (one settings-store read per sweep — the caller
|
|
171
|
+
# already resolved it).
|
|
172
|
+
def ttl_cutoffs(setting = memory_ttl_setting)
|
|
173
|
+
return [] unless setting
|
|
174
|
+
|
|
175
|
+
@memory_store.cells.filter_map do |cell|
|
|
176
|
+
days = cell_ttl(cell, setting)
|
|
177
|
+
next if days.nil? || days <= 0
|
|
178
|
+
|
|
179
|
+
[cell[:scope], now - days * 86_400]
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Customer cell "memory:acme:c-1" -> map["acme"]; tenant/bare cell
|
|
184
|
+
# "memory:acme" or "memory:c-123" -> map["acme"] / map["c-123"]; fallback
|
|
185
|
+
# map["*"]; an Integer setting -> every cell gets it.
|
|
186
|
+
def cell_ttl(cell, setting)
|
|
187
|
+
return setting if setting.is_a?(Integer)
|
|
188
|
+
|
|
189
|
+
key = cell[:tenant] || cell[:customer]
|
|
190
|
+
v = key && setting[key]
|
|
191
|
+
v ||= setting["*"]
|
|
192
|
+
v
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Sessions untouched past the cutoff, and their per-session traces.
|
|
196
|
+
def sweep_sessions(cutoff)
|
|
197
|
+
removed = 0
|
|
198
|
+
@session_store.each_id.each do |id|
|
|
199
|
+
session = @session_store.find(id)
|
|
200
|
+
next unless session && session.updated_at && session.updated_at < cutoff.iso8601
|
|
201
|
+
|
|
202
|
+
@tool_trace_store&.clear(id)
|
|
203
|
+
@context_trace_store&.clear(id)
|
|
204
|
+
@session_store.delete(id)
|
|
205
|
+
removed += 1
|
|
206
|
+
end
|
|
207
|
+
removed
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# TERMINAL tasks untouched past the cutoff, and their checkpoints. A
|
|
211
|
+
# non-terminal task (queued/running) is never touched here — the Recovery
|
|
212
|
+
# sweep owns those lives. the model-visible traces are
|
|
213
|
+
# transcripts — they die next to their checkpoints.
|
|
214
|
+
def sweep_tasks(cutoff)
|
|
215
|
+
removed = 0
|
|
216
|
+
@task_store.each_id.each do |id|
|
|
217
|
+
task = @task_store.find(id)
|
|
218
|
+
next unless task && TERMINAL.include?(task.status.to_s)
|
|
219
|
+
next unless task.updated_at && task.updated_at < cutoff.iso8601
|
|
220
|
+
|
|
221
|
+
@checkpoint_store.purge(id)
|
|
222
|
+
@model_visible_trace_store&.purge(id)
|
|
223
|
+
@task_store.delete(id)
|
|
224
|
+
removed += 1
|
|
225
|
+
end
|
|
226
|
+
removed
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def sweep_outcomes(cutoff)
|
|
230
|
+
@outcome_store ? @outcome_store.delete_older_than(cutoff) : 0
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# The delivered/failed outbox records past the cutoff. Their `payload` is
|
|
234
|
+
# the answer the customer received — conversation content, so it ages out
|
|
235
|
+
# with the rest of the footprint instead of living in the store forever.
|
|
236
|
+
def sweep_outbox(cutoff)
|
|
237
|
+
@outbox_store ? @outbox_store.delete_older_than(cutoff) : 0
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Shadow pairs past the cutoff, TERMINAL statuses only
|
|
241
|
+
# (judged/incomplete) — an open/complete pair older than the window is
|
|
242
|
+
# still someone's unjudged evidence, exactly like the outbox's rule.
|
|
243
|
+
def sweep_shadow_pairs(cutoff)
|
|
244
|
+
@shadow_pair_store ? @shadow_pair_store.delete_older_than(cutoff) : 0
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# the funnel DAY CELLS die with the outcomes they fold — same
|
|
248
|
+
# retention_days gate, same daily claim. Cursors/baselines live while their
|
|
249
|
+
# agent does. nil collaborator = nothing to sweep (base graph, parity).
|
|
250
|
+
def sweep_funnel(cutoff)
|
|
251
|
+
@funnel_store.delete_older_than(cutoff)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def claim_window = claim(KEY)
|
|
255
|
+
|
|
256
|
+
# The daily claim: one key, a timestamp, a 24 h window — the tick's
|
|
257
|
+
# claim_window idiom (a key per day would be a slow leak; the window is
|
|
258
|
+
# long enough that the store never grows). One key per SWEEP (the age-based
|
|
259
|
+
# one, the budget GC): they gate on different knobs, so a single shared key
|
|
260
|
+
# would let whichever ran first starve the other for a day.
|
|
261
|
+
def claim(key)
|
|
262
|
+
now_time = now
|
|
263
|
+
@store.transaction do
|
|
264
|
+
current = @store.get(SCOPE, key)
|
|
265
|
+
last = current && begin
|
|
266
|
+
Time.iso8601(current["claimed_at"].to_s)
|
|
267
|
+
rescue ArgumentError
|
|
268
|
+
nil
|
|
269
|
+
end
|
|
270
|
+
if last.nil? || (now_time - last) >= @window
|
|
271
|
+
@store.set(SCOPE, key, { "claimed_at" => now_time.iso8601 })
|
|
272
|
+
true
|
|
273
|
+
else
|
|
274
|
+
false
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def now = @now || Time.now.utc
|
|
280
|
+
end
|
|
281
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# WS4: intent routing as DATA. When `AgentProfile#routes` is present, the
|
|
5
|
+
# turn's message is classified into one of the configured routes with a cheap
|
|
6
|
+
# model BEFORE the agent chat is assembled. This class owns the PURE parts —
|
|
7
|
+
# normalizing the route config, generating the classifier prompt from the
|
|
8
|
+
# route descriptions, and parsing the model's answer back into a route — so
|
|
9
|
+
# they are testable without a provider. The ask itself and its usage are the
|
|
10
|
+
# Executor's (a pre-stage call counted in the turn's usage and trace).
|
|
11
|
+
#
|
|
12
|
+
# Config shape (string keys at the persistence boundary — the pack and the
|
|
13
|
+
# Studio store it like any other free-form hash):
|
|
14
|
+
#
|
|
15
|
+
# { "shopping" => "the customer wants to browse products",
|
|
16
|
+
# "order" => { "description" => "asks about an existing order",
|
|
17
|
+
# "delegate" => "order-agent" }, # hand the turn to that agent
|
|
18
|
+
# "human" => { "description" => "the customer is frustrated or asks for a person",
|
|
19
|
+
# "stuck" => true, "message" => "..." }, # end the turn :stuck (WS5)
|
|
20
|
+
# "default" => "shopping", # deterministic fallback
|
|
21
|
+
# "model" => "deepseek-v4-flash" } # the CHEAP classifier
|
|
22
|
+
#
|
|
23
|
+
# `default`/`model`/`provider` are reserved top-level keys; everything else is
|
|
24
|
+
# a route name (single token). A route value is a description String or a Hash
|
|
25
|
+
# with `description` + optional `delegate` (an existing agent the turn is
|
|
26
|
+
# handed to) / `stuck` (the turn ends with the WS5 stuck outcome) / `message`
|
|
27
|
+
# (the consumer-facing lead-in for a stuck route).
|
|
28
|
+
class Routing
|
|
29
|
+
RESERVED = %w[model provider default].freeze
|
|
30
|
+
# A name the classifier can actually answer with: one lowercase token.
|
|
31
|
+
NAME = /\A[a-z0-9][a-z0-9_-]*\z/
|
|
32
|
+
# The turn's usage fields the classifier call can contribute.
|
|
33
|
+
TOKEN_FIELDS = %i[input_tokens output_tokens cached_tokens cache_creation_tokens total_tokens].freeze
|
|
34
|
+
|
|
35
|
+
Entry = Data.define(:name, :description, :delegate, :stuck, :message)
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# -> { entries: [Entry], default: String, model: String, provider: String }
|
|
39
|
+
# | nil (routes absent/empty = routing off). Raises ValidationError on a
|
|
40
|
+
# route name the classifier could never answer with (spaces/symbols) or on
|
|
41
|
+
# a config with zero routes — a routing config that silently routes nothing
|
|
42
|
+
# is a config error, not a quiet off.
|
|
43
|
+
def normalize(routes)
|
|
44
|
+
return nil unless routes.is_a?(Hash) && !routes.empty?
|
|
45
|
+
|
|
46
|
+
bad = routes.keys.map(&:to_s).reject { |k| RESERVED.include?(k) || k.match?(NAME) }
|
|
47
|
+
unless bad.empty?
|
|
48
|
+
raise Insika::ValidationError,
|
|
49
|
+
"route names may not contain spaces or symbols: #{bad.join(', ')}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
entries = routes.each_with_object([]) do |(name, value), acc|
|
|
53
|
+
next if RESERVED.include?(name.to_s)
|
|
54
|
+
|
|
55
|
+
cfg = value.is_a?(Hash) ? stringify(value) : { "description" => value.to_s }
|
|
56
|
+
acc << Entry.new(name: name.to_s, description: cfg["description"].to_s,
|
|
57
|
+
delegate: cfg["delegate"].to_s, stuck: cfg["stuck"] == true,
|
|
58
|
+
message: cfg["message"].to_s)
|
|
59
|
+
end
|
|
60
|
+
raise Insika::ValidationError, "routes must define at least one route" if entries.empty?
|
|
61
|
+
|
|
62
|
+
default = routes["default"].to_s
|
|
63
|
+
default = entries.first.name if default.empty?
|
|
64
|
+
unless entries.any? { |e| e.name == default }
|
|
65
|
+
raise Insika::ValidationError,
|
|
66
|
+
"default route '#{default}' is not a configured route"
|
|
67
|
+
end
|
|
68
|
+
{ entries: entries, default: default,
|
|
69
|
+
model: routes["model"].to_s, provider: routes["provider"].to_s }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The classifier's instructions, auto-generated from the route descriptions
|
|
73
|
+
# (config-over-code: no per-route prompt file).
|
|
74
|
+
def classifier_prompt(meta)
|
|
75
|
+
lines = meta[:entries].map do |e|
|
|
76
|
+
"-#{e.name}: #{e.description.empty? ? e.name : e.description}"
|
|
77
|
+
end
|
|
78
|
+
<<~PROMPT
|
|
79
|
+
Classify the customer's message into exactly one of these intents.
|
|
80
|
+
Reply with ONLY the intent name — nothing else.
|
|
81
|
+
|
|
82
|
+
#{lines.join("\n")}
|
|
83
|
+
PROMPT
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# -> Symbol: the route the model named; the DEFAULT when it named nothing
|
|
87
|
+
# usable (prose, punctuation, an unknown name, empty). Deterministic by
|
|
88
|
+
# construction — a chatty or confused classifier can never invent a route.
|
|
89
|
+
def parse(text, meta)
|
|
90
|
+
token = text.to_s.strip.split(/\s+/).first.to_s.downcase
|
|
91
|
+
.delete_suffix(".").delete_suffix(",")
|
|
92
|
+
names = meta[:entries].map(&:name)
|
|
93
|
+
(names.include?(token) ? token : meta[:default]).to_sym
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.stringify(hash)
|
|
98
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
data/lib/insika/safety/config.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Insika
|
|
4
4
|
module Safety
|
|
5
|
-
# Per-agent guardrail configuration
|
|
5
|
+
# Per-agent guardrail configuration, read from
|
|
6
6
|
# `profile.guardrails`. OPT-IN like `capabilities`: an agent that says nothing
|
|
7
7
|
# gets the CONSERVATIVE default — deterministic detectors ON, LLM moderator OFF.
|
|
8
8
|
#
|
|
@@ -16,9 +16,20 @@ module Insika
|
|
|
16
16
|
# moderator: "provider/model"|nil, # LLM moderator model; nil = deterministic only
|
|
17
17
|
# strictness: "low"|"medium"|"high", # which input categories fire (default medium)
|
|
18
18
|
# responses: { <category> => "<safe reply>", ... } # per-agent override, see below
|
|
19
|
+
# corpora: { "languages" => ["en"], "extra" => { "abuse" => ["/\\bdupa\\b/i"] } }
|
|
19
20
|
# }
|
|
20
21
|
#
|
|
21
|
-
# `
|
|
22
|
+
# `corpora` is the removability knob for the shipped
|
|
23
|
+
# pt-BR corpus: `languages` filters the shipped families (nil = all,
|
|
24
|
+
# [] = none, ["en"] = the EN-only corpus — dropping the pt-BR input
|
|
25
|
+
# heuristics AND the CPF/CNPJ output redaction, a documented consequence),
|
|
26
|
+
# `extra` adds source-string patterns per family. Absent = the full
|
|
27
|
+
# shipped default, byte-for-byte today's behavior (parity). Validation
|
|
28
|
+
# fails loud (Insika::ValidationError naming the value): the doctor's
|
|
29
|
+
# guardrail-corpora check compiles every declaration, so a typo'd language
|
|
30
|
+
# surfaces at boot — `insika doctor` exits non-zero — never mid-turn.
|
|
31
|
+
#
|
|
32
|
+
# `responses` is the CONFIGURATION-OVER-CONVENTION knob. The engine
|
|
22
33
|
# ships neutral built-in refusals (Safety::SafeResponses::DEFAULTS), but this is
|
|
23
34
|
# OSS across arbitrary businesses/languages, so we never hard-bake tone: an agent
|
|
24
35
|
# overrides the safe reply per category (`injection`/`sexual`/`abuse`/`escalate`/
|
|
@@ -37,14 +48,18 @@ module Insika
|
|
|
37
48
|
|
|
38
49
|
DEFAULT_STRICTNESS = :medium
|
|
39
50
|
|
|
40
|
-
attr_reader :input, :output, :moderator, :strictness, :responses
|
|
51
|
+
attr_reader :input, :output, :moderator, :strictness, :responses, :corpora
|
|
41
52
|
|
|
42
|
-
def initialize(input:, output:, moderator:, strictness:, responses: {})
|
|
53
|
+
def initialize(input:, output:, moderator:, strictness:, responses: {}, corpora: nil)
|
|
43
54
|
@input = input
|
|
44
55
|
@output = output
|
|
45
56
|
@moderator = moderator
|
|
46
57
|
@strictness = strictness
|
|
47
58
|
@responses = responses # { "category" => "safe reply" }, agent override map
|
|
59
|
+
@corpora = corpora # { "languages" => [...]?, "extra" => {...}? } | nil (nil = the shipped default)
|
|
60
|
+
# Built ONCE at construction: the compiled corpus the whole turn reads.
|
|
61
|
+
@corpus = Corpus.compile(languages: corpora && corpora["languages"],
|
|
62
|
+
extra: (corpora && corpora["extra"]) || {})
|
|
48
63
|
end
|
|
49
64
|
|
|
50
65
|
# Builds a Config from a profile. A nil/empty `guardrails` -> the
|
|
@@ -58,10 +73,22 @@ module Insika
|
|
|
58
73
|
output: bool(h.fetch(:output, true)),
|
|
59
74
|
moderator: presence(h[:moderator]),
|
|
60
75
|
strictness: normalize_strictness(h[:strictness]),
|
|
61
|
-
responses: normalize_responses(h[:responses])
|
|
76
|
+
responses: normalize_responses(h[:responses]),
|
|
77
|
+
corpora: normalize_corpora(h[:corpora])
|
|
62
78
|
)
|
|
63
79
|
end
|
|
64
80
|
|
|
81
|
+
# The compiled corpus for this agent — never nil: absent
|
|
82
|
+
# corpora -> the full shipped default.
|
|
83
|
+
def corpus = @corpus
|
|
84
|
+
|
|
85
|
+
# The resolved corpus languages (for the doctor's enumeration, C3):
|
|
86
|
+
# nil corpora (or nil languages) = ALL shipped languages.
|
|
87
|
+
def corpus_languages
|
|
88
|
+
langs = @corpora && @corpora["languages"]
|
|
89
|
+
langs.nil? ? Corpus::DEFAULTS.keys : langs
|
|
90
|
+
end
|
|
91
|
+
|
|
65
92
|
# Input categories the deterministic scan should run, per strictness.
|
|
66
93
|
def input_categories = STRICTNESS_CATEGORIES.fetch(@strictness, STRICTNESS_CATEGORIES[DEFAULT_STRICTNESS])
|
|
67
94
|
|
|
@@ -103,7 +130,20 @@ module Insika
|
|
|
103
130
|
end
|
|
104
131
|
end
|
|
105
132
|
|
|
106
|
-
|
|
133
|
+
# `corpora` -> { "languages" => ...?, "extra" => ...? } with STRING keys |
|
|
134
|
+
# nil when absent/empty. Validation of the values happens in
|
|
135
|
+
# Corpus.compile (from_hash fails loud with the value named).
|
|
136
|
+
def self.normalize_corpora(v)
|
|
137
|
+
return nil unless v.is_a?(Hash)
|
|
138
|
+
|
|
139
|
+
h = v.each_with_object({}) { |(k, val), acc| acc[k.to_s] = val }
|
|
140
|
+
return nil if h["languages"].nil? && h["extra"].nil?
|
|
141
|
+
|
|
142
|
+
{ "languages" => h["languages"], "extra" => h["extra"] }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private_class_method :symbolize, :bool, :presence, :normalize_strictness, :normalize_responses,
|
|
146
|
+
:normalize_corpora
|
|
107
147
|
end
|
|
108
148
|
end
|
|
109
149
|
end
|