insika 0.2.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +325 -17
- data/README.md +52 -11
- data/bin/insika +895 -8
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +229 -163
- data/docs/API.md +56 -0
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +111 -21
- data/docs/CONTEXT.md +132 -19
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +75 -4
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +146 -2
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +116 -0
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +16 -10
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +65 -6
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +17 -12
- data/docs/RELEASING.md +27 -0
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +6 -6
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +79 -2
- data/docs/SKILLS.md +13 -4
- data/docs/SOAK.md +127 -0
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +220 -27
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/_includes/head_custom.html +5 -0
- data/docs/_includes/title.html +13 -0
- data/docs/_sass/color_schemes/insika.scss +32 -0
- data/docs/_sass/custom/custom.scss +199 -0
- data/docs/_sass/custom/setup.scss +26 -0
- data/docs/assets/img/favicon.svg +7 -0
- data/docs/assets/img/insika-mark.svg +7 -0
- data/docs/core-concepts.md +21 -0
- data/docs/domain.md +115 -0
- data/docs/improve.md +20 -0
- data/docs/index.md +9 -6
- data/docs/integrate.md +20 -0
- data/docs/operate.md +13 -6
- data/docs/prompts/ADD-TOOL.md +118 -0
- data/docs/prompts/DIAGNOSE-TURN.md +65 -0
- data/docs/prompts/GO-LIVE.md +138 -0
- data/docs/prompts/RUN-EXAMPLES.md +70 -0
- data/docs/reference.md +19 -0
- data/docs/ship.md +10 -2
- data/docs/start-here.md +18 -0
- data/lib/insika/agent_profile.rb +202 -10
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +119 -14
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +105 -2
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_artifact.rb +35 -0
- data/lib/insika/commands/delete_concept.rb +34 -0
- data/lib/insika/commands/delete_mcp.rb +6 -2
- data/lib/insika/commands/delete_tenant_data.rb +107 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/restore_concept.rb +34 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/seed_demo_data.rb +31 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +5 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +57 -23
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- data/lib/insika/cron.rb +189 -0
- data/lib/insika/demo/agent_attrs.rb +43 -0
- data/lib/insika/demo/golden_cases.rb +81 -0
- data/lib/insika/demo/seeder.rb +336 -0
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +889 -11
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +69 -82
- data/lib/insika/dsl/server_boot.rb +92 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +186 -5
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +25 -3
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +29 -6
- data/lib/insika/evals/persona.rb +98 -0
- data/lib/insika/evals/runner.rb +9 -0
- data/lib/insika/evals/simulator.rb +225 -0
- data/lib/insika/evals/transport.rb +83 -1
- data/lib/insika/event_stream.rb +10 -0
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +869 -76
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +198 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +17 -2
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/knowledge.rb +680 -0
- data/lib/insika/knowledge_store.rb +140 -0
- data/lib/insika/mcp_client.rb +94 -0
- data/lib/insika/mcp_json.rb +74 -0
- data/lib/insika/mcp_live_tool.rb +43 -0
- data/lib/insika/mcp_store.rb +98 -26
- data/lib/insika/mcp_tool_ingestor.rb +30 -8
- data/lib/insika/mcp_tool_registry.rb +100 -0
- data/lib/insika/media.rb +382 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +7 -2
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +22 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +37 -17
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +29 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +316 -0
- data/lib/insika/router/app.rb +157 -0
- data/lib/insika/router/backend_pool.rb +98 -0
- data/lib/insika/router/hash_ring.rb +55 -0
- data/lib/insika/router/proxy_body.rb +34 -0
- data/lib/insika/router/session_key.rb +54 -0
- data/lib/insika/router.rb +18 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- data/lib/insika/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +247 -17
- data/lib/insika/server/rack_app.rb +21 -1
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +16 -0
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/steer_injector.rb +21 -10
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1437 -54
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +23 -23
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +308 -10
- data/lib/insika/studio/nav_icons.rb +19 -1
- data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
- data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
- data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
- data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
- data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
- data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
- data/lib/insika/studio/views/_agents_master.erb +44 -0
- data/lib/insika/studio/views/_message.erb +49 -32
- data/lib/insika/studio/views/agent_detail.erb +62 -278
- data/lib/insika/studio/views/agents.erb +71 -54
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +6 -3
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +106 -66
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +15 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +233 -124
- data/lib/insika/studio/views/settings.erb +41 -1
- data/lib/insika/studio/views/skills.erb +1 -2
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +24 -10
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/templates/browser-agent/README.md +36 -0
- data/lib/insika/templates/browser-agent/agent.rb +49 -0
- data/lib/insika/templates/daily-digest/README.md +38 -0
- data/lib/insika/templates/daily-digest/agent.rb +77 -0
- data/lib/insika/templates/repo-explorer/README.md +36 -0
- data/lib/insika/templates/repo-explorer/agent.rb +45 -0
- data/lib/insika/templates/research-analyst/README.md +26 -0
- data/lib/insika/templates/research-analyst/agent.rb +58 -0
- data/lib/insika/templates/review-panel/README.md +20 -0
- data/lib/insika/templates/review-panel/agent.rb +50 -0
- data/lib/insika/templates/travel-planner/README.md +35 -0
- data/lib/insika/templates/travel-planner/agent.rb +87 -0
- data/lib/insika/templates.rb +112 -0
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +47 -11
- data/lib/insika/timezone.rb +45 -0
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +89 -0
- data/lib/insika/tools/load_knowledge.rb +74 -0
- data/lib/insika/tools/run_persona_eval.rb +328 -0
- data/lib/insika/tools/save_artifact.rb +95 -0
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +1 -1
- data/lib/insika/turn_state.rb +49 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +472 -13
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +149 -3
- metadata +181 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rack/request"
|
|
5
|
+
require "async/http/client"
|
|
6
|
+
require "async/http/endpoint"
|
|
7
|
+
require "protocol/http/body/buffered"
|
|
8
|
+
require_relative "session_key"
|
|
9
|
+
require_relative "proxy_body"
|
|
10
|
+
|
|
11
|
+
module Insika
|
|
12
|
+
module Router
|
|
13
|
+
# The standalone Rack/Async app: session-key extraction →
|
|
14
|
+
# consistent-hash pick → proxy, on the same Async/Falcon stack the engine
|
|
15
|
+
# already runs on. A request whose key has no live owner (nothing found,
|
|
16
|
+
# or the ring itself is empty) round-robins; a request whose chosen
|
|
17
|
+
# backend is unreachable answers the retry envelope — it is NOT
|
|
18
|
+
# retried against a different backend (§3.5): that backend may already
|
|
19
|
+
# hold a durable, at-most-once claim on the task this request names.
|
|
20
|
+
class App
|
|
21
|
+
DEFAULT_BODY_MAX_BYTES = 262_144 # 256 KiB — small JSON control payloads, never uploads (§5)
|
|
22
|
+
RETRY_AFTER_SECONDS = 1
|
|
23
|
+
HOP_BY_HOP = %w[connection keep-alive proxy-connection transfer-encoding upgrade host content-length].freeze
|
|
24
|
+
|
|
25
|
+
# client_factory: (backend_url, timeout) -> an object answering #call(request)
|
|
26
|
+
# -> Protocol::HTTP::Response. Defaults to a real Async::HTTP::Client;
|
|
27
|
+
# a spec injects a fake instead of opening real sockets.
|
|
28
|
+
def initialize(pool:, body_max_bytes: DEFAULT_BODY_MAX_BYTES, backend_timeout: 10, logger: $stdout,
|
|
29
|
+
client_factory: DEFAULT_CLIENT_FACTORY)
|
|
30
|
+
@pool = pool
|
|
31
|
+
@body_max_bytes = body_max_bytes
|
|
32
|
+
@backend_timeout = backend_timeout
|
|
33
|
+
@logger = logger
|
|
34
|
+
@client_factory = client_factory
|
|
35
|
+
@clients = {} # backend address -> memoized client (persistent connections)
|
|
36
|
+
@clients_mutex = Mutex.new
|
|
37
|
+
@rr_index = -1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
DEFAULT_CLIENT_FACTORY = lambda do |backend, timeout|
|
|
41
|
+
Async::HTTP::Client.new(Async::HTTP::Endpoint.parse(backend, timeout: timeout))
|
|
42
|
+
end
|
|
43
|
+
private_constant :DEFAULT_CLIENT_FACTORY
|
|
44
|
+
|
|
45
|
+
def call(env)
|
|
46
|
+
req = Rack::Request.new(env)
|
|
47
|
+
segments = req.path_info.split("/").reject(&:empty?)
|
|
48
|
+
|
|
49
|
+
return health_response if req.request_method == "GET" && segments == ["up"]
|
|
50
|
+
|
|
51
|
+
raw_body = read_body(env)
|
|
52
|
+
backend = pick_backend(req.request_method, segments, raw_body)
|
|
53
|
+
return unavailable_response if backend.nil?
|
|
54
|
+
|
|
55
|
+
proxy(req, backend, raw_body)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def pick_backend(method, segments, raw_body)
|
|
61
|
+
backends = @pool.backends
|
|
62
|
+
return nil if backends.empty?
|
|
63
|
+
|
|
64
|
+
key = session_key(method, segments, raw_body)
|
|
65
|
+
return @pool.ring.backend_for(key) if key
|
|
66
|
+
|
|
67
|
+
@rr_index = (@rr_index + 1) % backends.size
|
|
68
|
+
backends[@rr_index]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def session_key(method, segments, raw_body)
|
|
72
|
+
return nil if raw_body.nil?
|
|
73
|
+
|
|
74
|
+
if raw_body.bytesize > @body_max_bytes
|
|
75
|
+
log("router: body #{raw_body.bytesize}B exceeds body_max_bytes=#{@body_max_bytes} — " \
|
|
76
|
+
"skipping session-key extraction (round-robin), forwarding it whole regardless")
|
|
77
|
+
return nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
SessionKey.extract(method, segments, body: -> { JSON.parse(raw_body) })
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Reads the WHOLE body (it must be forwarded intact) — `body_max_bytes`
|
|
84
|
+
# only bounds how much of it #session_key will try to parse as JSON,
|
|
85
|
+
# never how much reaches the backend (§3.1, §5).
|
|
86
|
+
def read_body(env)
|
|
87
|
+
input = env["rack.input"]
|
|
88
|
+
return nil if input.nil?
|
|
89
|
+
|
|
90
|
+
data = input.read
|
|
91
|
+
input.rewind if input.respond_to?(:rewind)
|
|
92
|
+
data.nil? || data.empty? ? nil : data
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def proxy(req, backend, raw_body)
|
|
96
|
+
client = client_for(backend)
|
|
97
|
+
path = req.script_name.to_s + req.path_info
|
|
98
|
+
path += "?#{req.query_string}" unless req.query_string.to_s.empty?
|
|
99
|
+
body = raw_body ? Protocol::HTTP::Body::Buffered.wrap(raw_body) : nil
|
|
100
|
+
|
|
101
|
+
response = client.call(
|
|
102
|
+
Protocol::HTTP::Request.new(nil, nil, req.request_method, path, nil,
|
|
103
|
+
forward_headers(req), body)
|
|
104
|
+
)
|
|
105
|
+
[response.status, response_headers(response), response.body ? ProxyBody.new(response.body) : []]
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
log("router: backend #{backend} unreachable (#{e.class}: #{e.message})")
|
|
108
|
+
unavailable_response
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def client_for(backend)
|
|
112
|
+
@clients_mutex.synchronize { @clients[backend] ||= @client_factory.call(backend, @backend_timeout) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def forward_headers(req)
|
|
116
|
+
headers = Protocol::HTTP::Headers.new
|
|
117
|
+
req.each_header do |key, value|
|
|
118
|
+
next unless key.start_with?("HTTP_")
|
|
119
|
+
|
|
120
|
+
name = key.sub(/\AHTTP_/, "").tr("_", "-").downcase
|
|
121
|
+
headers.add(name, value) unless HOP_BY_HOP.include?(name)
|
|
122
|
+
end
|
|
123
|
+
headers.add("content-type", req.content_type) if req.content_type
|
|
124
|
+
headers
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def response_headers(response)
|
|
128
|
+
headers = {}
|
|
129
|
+
response.headers&.each do |key, value|
|
|
130
|
+
name = key.to_s
|
|
131
|
+
headers[name] = headers.key?(name) ? Array(headers[name]) + [value] : value
|
|
132
|
+
end
|
|
133
|
+
headers
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def health_response
|
|
137
|
+
json_response(200, { status: "ok", backends: @pool.backends.size })
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def unavailable_response
|
|
141
|
+
json_response(503, { error: { class: "Insika::Router::BackendUnavailable",
|
|
142
|
+
message: "no backend reachable",
|
|
143
|
+
retryable: true, retry_after: RETRY_AFTER_SECONDS } })
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def json_response(status, body)
|
|
147
|
+
[status, { "content-type" => "application/json" }, [JSON.generate(body)]]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def log(message)
|
|
151
|
+
@logger&.puts(message)
|
|
152
|
+
rescue StandardError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "resolv"
|
|
4
|
+
require_relative "hash_ring"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
module Router
|
|
8
|
+
# Backend discovery + the ring it feeds. Two modes:
|
|
9
|
+
#
|
|
10
|
+
# static — a fixed list, resolved once (the Railway shape: N local
|
|
11
|
+
# Falcon workers on known ports inside one container).
|
|
12
|
+
# dns — one hostname re-resolved on an interval (the Kubernetes
|
|
13
|
+
# shape: a headless Service, one A/AAAA record per ready pod).
|
|
14
|
+
#
|
|
15
|
+
# The ring is rebuilt ONLY when the resolved set actually changed — a DNS
|
|
16
|
+
# poll that returns the same pods is a no-op, not a ring rebuild every
|
|
17
|
+
# `dns_interval` seconds.
|
|
18
|
+
class BackendPool
|
|
19
|
+
def initialize(static: nil, dns: nil, dns_port: nil, dns_interval: 15,
|
|
20
|
+
replicas: HashRing::DEFAULT_REPLICAS, resolver: Resolv, logger: $stdout)
|
|
21
|
+
raise ArgumentError, "static or dns is required, not both" if static.nil? == dns.nil?
|
|
22
|
+
raise ArgumentError, "dns_port is required with dns:" if dns && dns_port.nil?
|
|
23
|
+
raise ArgumentError, "static must not be empty" if static && Array(static).empty?
|
|
24
|
+
|
|
25
|
+
@static = Array(static)
|
|
26
|
+
@dns = dns
|
|
27
|
+
@dns_port = dns_port
|
|
28
|
+
@dns_interval = dns_interval
|
|
29
|
+
@replicas = replicas
|
|
30
|
+
@resolver = resolver
|
|
31
|
+
@logger = logger
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
@ring = nil
|
|
34
|
+
refresh!
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def dns? = !@dns.nil?
|
|
38
|
+
|
|
39
|
+
def ring
|
|
40
|
+
@mutex.synchronize { @ring }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def backends
|
|
44
|
+
@mutex.synchronize { @ring&.backends || [] }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Re-resolves (a no-op for static after the first call) and rebuilds
|
|
48
|
+
# the ring iff the resolved set changed. -> bool (did it change?).
|
|
49
|
+
def refresh!
|
|
50
|
+
resolved = @dns ? resolve_dns : @static
|
|
51
|
+
if resolved.empty?
|
|
52
|
+
log("router: 0 backends resolved — keeping the previous ring" \
|
|
53
|
+
"#{" (#{@ring.backends.size} backend(s))" if @ring}")
|
|
54
|
+
return false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
return false if @ring && @ring.backends.sort == resolved.sort
|
|
59
|
+
|
|
60
|
+
@ring = HashRing.new(resolved, replicas: @replicas)
|
|
61
|
+
end
|
|
62
|
+
log("router: ring rebuilt — #{resolved.sort.join(', ')}")
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Starts the periodic re-resolve loop (dns mode only — a no-op for
|
|
67
|
+
# static) inside the caller's Async task, so a spec can drive #refresh!
|
|
68
|
+
# directly without ever starting this loop.
|
|
69
|
+
def start_polling(task)
|
|
70
|
+
return unless dns?
|
|
71
|
+
|
|
72
|
+
task.async do |t|
|
|
73
|
+
loop do
|
|
74
|
+
t.sleep(@dns_interval)
|
|
75
|
+
refresh!
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
log("router: DNS re-resolve failed (#{e.class}: #{e.message}) — keeping the previous ring")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def resolve_dns
|
|
85
|
+
@resolver.getaddresses(@dns).map { |ip| "http://#{ip.include?(':') ? "[#{ip}]" : ip}:#{@dns_port}" }
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
log("router: DNS resolve of #{@dns} failed (#{e.class}: #{e.message})")
|
|
88
|
+
[]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def log(message)
|
|
92
|
+
@logger&.puts(message)
|
|
93
|
+
rescue StandardError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Router
|
|
7
|
+
# Ketama-style consistent hash ring. Each backend gets
|
|
8
|
+
# `replicas` virtual points on a 0..2**32-1 circle (CRC32 of "backend#i");
|
|
9
|
+
# a key's owner is the first point clockwise from CRC32(key). Removing or
|
|
10
|
+
# adding one backend only remaps the ~1/N of the space that belonged to
|
|
11
|
+
# that backend's own points — not the whole ring — which is what keeps a
|
|
12
|
+
# rolling deploy from bouncing every live session to a new owner at once.
|
|
13
|
+
class HashRing
|
|
14
|
+
DEFAULT_REPLICAS = 160
|
|
15
|
+
|
|
16
|
+
def initialize(backends, replicas: DEFAULT_REPLICAS)
|
|
17
|
+
raise ArgumentError, "at least one backend is required" if Array(backends).empty?
|
|
18
|
+
|
|
19
|
+
@replicas = replicas
|
|
20
|
+
@backends = backends.uniq.sort
|
|
21
|
+
@ring = {}
|
|
22
|
+
@backends.each do |backend|
|
|
23
|
+
@replicas.times { |i| @ring[Zlib.crc32("#{backend}\0#{i}")] = backend }
|
|
24
|
+
end
|
|
25
|
+
@points = @ring.keys.sort
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
attr_reader :backends, :points, :ring
|
|
29
|
+
|
|
30
|
+
# -> the backend owning `key` — the first ring point at or after
|
|
31
|
+
# CRC32(key), wrapping around to the first point when `key` hashes past
|
|
32
|
+
# the last one. O(log N) via binary search, not a hash-map rebuild.
|
|
33
|
+
def backend_for(key)
|
|
34
|
+
backend_for_point(Zlib.crc32(key.to_s))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Fraction of a representative KEY SAMPLE whose owner changes between
|
|
38
|
+
# two rings — the measurement acceptance §6.2 asks for, not an
|
|
39
|
+
# assumption. (Ring POINTS themselves are the wrong yardstick: a's/b's/
|
|
40
|
+
# c's/d's own points stay put when "e" is added — only the space of
|
|
41
|
+
# arbitrary keys BETWEEN points shifts.) `sample_size` large enough that
|
|
42
|
+
# the law of large numbers keeps the estimate tight without a spec
|
|
43
|
+
# needing thousands of literal keys of its own.
|
|
44
|
+
def self.remapped_fraction(before, after, sample_size: 20_000)
|
|
45
|
+
moved = (0...sample_size).count { |i| before.backend_for(i) != after.backend_for(i) }
|
|
46
|
+
moved.to_f / sample_size
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def backend_for_point(point)
|
|
50
|
+
idx = @points.bsearch_index { |p| p >= point } || 0
|
|
51
|
+
@ring[@points[idx]]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Router
|
|
5
|
+
# Streams an upstream `Protocol::HTTP::Response` body back to the
|
|
6
|
+
# downstream client. Exposes `#call(stream)`, NOT `#each` — the same
|
|
7
|
+
# discovery `Server::SSEBody` documents: under protocol-rack/protocol-http1
|
|
8
|
+
# (the stack of Async::HTTP::Server AND Falcon), a body that only responds
|
|
9
|
+
# to `#each` is routed to `Body::Enumerable`, whose `read` runs the `#each`
|
|
10
|
+
# in a plain Enumerator Fiber where `Async::Task.current` is unavailable —
|
|
11
|
+
# so a long-running SSE turn proxied through this router would come out
|
|
12
|
+
# empty. `#call` routes it to `Body::Streaming` instead, scheduled via
|
|
13
|
+
# `Fiber.schedule` under the reactor, which is what makes an SSE stream
|
|
14
|
+
# drain through the router with no added buffering beyond the one-time
|
|
15
|
+
# request-body read (§6.5).
|
|
16
|
+
class ProxyBody
|
|
17
|
+
def initialize(upstream_body)
|
|
18
|
+
@upstream_body = upstream_body
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(stream)
|
|
22
|
+
@upstream_body.each { |chunk| stream.write(chunk) }
|
|
23
|
+
rescue StandardError
|
|
24
|
+
# The downstream client disconnected, or the upstream connection
|
|
25
|
+
# dropped mid-stream: no exception escapes (same rule as SSEBody) —
|
|
26
|
+
# the turn itself belongs to the backend, not this connection.
|
|
27
|
+
nil
|
|
28
|
+
ensure
|
|
29
|
+
@upstream_body.close
|
|
30
|
+
stream.close
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Router
|
|
5
|
+
# Session-key extraction, matched to the routes that
|
|
6
|
+
# actually carry production traffic in `Server::App` today. The RFC's own
|
|
7
|
+
# sketch of a path-based `/api/widget/sessions/:token/…` route was written
|
|
8
|
+
# from memory and does not match what `server/app.rb` implements — the
|
|
9
|
+
# widget/relay surface is `POST /channels/:id/messages` (and `/events`),
|
|
10
|
+
# with the session id as `session_id` in the JSON body, not a path
|
|
11
|
+
# segment. `/v1/responses` and `/v1/messages` carry it as `user`. Every
|
|
12
|
+
# other route (health checks, `/studio/*`, onboarding) has no session key
|
|
13
|
+
# and round-robins — none of them depend on a worker's in-memory
|
|
14
|
+
# `SessionActor` (§3.1 point 3).
|
|
15
|
+
module SessionKey
|
|
16
|
+
BODY_FIELD_BY_ROUTE = {
|
|
17
|
+
%w[v1 responses] => "user",
|
|
18
|
+
%w[v1 messages] => "user"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# segments: the request path split on "/" with empty parts removed.
|
|
24
|
+
# body: a zero-arg callable returning the parsed JSON body (a Hash) —
|
|
25
|
+
# called AT MOST ONCE, and only when a route that carries a session key
|
|
26
|
+
# actually matches, so a GET or an unrelated POST never pays for a
|
|
27
|
+
# parse. A malformed body yields no key (the backend's own parser is
|
|
28
|
+
# what answers the client's 400/422), never a router-level error.
|
|
29
|
+
def extract(method, segments, body:)
|
|
30
|
+
return nil unless method == "POST"
|
|
31
|
+
|
|
32
|
+
field =
|
|
33
|
+
if segments.length == 3 && segments[0] == "channels" && %w[messages events].include?(segments[2])
|
|
34
|
+
"session_id"
|
|
35
|
+
else
|
|
36
|
+
BODY_FIELD_BY_ROUTE[segments]
|
|
37
|
+
end
|
|
38
|
+
return nil unless field
|
|
39
|
+
|
|
40
|
+
read_field(body, field)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def read_field(body, field)
|
|
44
|
+
parsed = body.call
|
|
45
|
+
return nil unless parsed.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
value = parsed[field] || parsed[field.to_sym]
|
|
48
|
+
Insika::Coercion.present?(value) ? value.to_s : nil
|
|
49
|
+
rescue StandardError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Session-sticky router. NOT required by `require "insika"` (like
|
|
4
|
+
# server/ and the Studio, it pulls in async-http and is only needed by an
|
|
5
|
+
# operator who actually runs `bin/insika-router`): the engine's serving path
|
|
6
|
+
# (`insika serve` / `DSL::ServerBoot` / `WEB_CONCURRENCY=1`) is completely
|
|
7
|
+
# unaffected by this file's existence — the router is additive infrastructure
|
|
8
|
+
# for whoever chooses to scale past one worker, never a default.
|
|
9
|
+
require_relative "coercion"
|
|
10
|
+
require_relative "router/hash_ring"
|
|
11
|
+
require_relative "router/session_key"
|
|
12
|
+
require_relative "router/backend_pool"
|
|
13
|
+
require_relative "router/app"
|
|
14
|
+
|
|
15
|
+
module Insika
|
|
16
|
+
module Router
|
|
17
|
+
end
|
|
18
|
+
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
|
@@ -16,8 +16,19 @@ 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
|
#
|
|
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
|
+
#
|
|
21
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
|
|
@@ -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
|