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.
Files changed (300) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +325 -17
  3. data/README.md +52 -11
  4. data/bin/insika +895 -8
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +229 -163
  7. data/docs/API.md +56 -0
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +111 -21
  12. data/docs/CONTEXT.md +132 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +75 -4
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +146 -2
  17. data/docs/FACTS.md +135 -0
  18. data/docs/HARVEST.md +116 -0
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +16 -10
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +65 -6
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -0
  26. data/docs/REFINEMENT.md +17 -12
  27. data/docs/RELEASING.md +27 -0
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +6 -6
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +79 -2
  32. data/docs/SKILLS.md +13 -4
  33. data/docs/SOAK.md +127 -0
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +220 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +115 -0
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +9 -6
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +202 -10
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/balloon_splitter.rb +102 -0
  62. data/lib/insika/budget_ledger.rb +34 -3
  63. data/lib/insika/cache_series_store.rb +49 -0
  64. data/lib/insika/channel_delivery.rb +119 -14
  65. data/lib/insika/channels/relay.rb +77 -3
  66. data/lib/insika/channels/web.rb +2 -2
  67. data/lib/insika/chat_builder.rb +105 -2
  68. data/lib/insika/checkpoint_store.rb +16 -0
  69. data/lib/insika/commands/agent_payload.rb +3 -3
  70. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  71. data/lib/insika/commands/cancel_followup.rb +49 -0
  72. data/lib/insika/commands/delete_artifact.rb +35 -0
  73. data/lib/insika/commands/delete_concept.rb +34 -0
  74. data/lib/insika/commands/delete_mcp.rb +6 -2
  75. data/lib/insika/commands/delete_tenant_data.rb +107 -0
  76. data/lib/insika/commands/export_customer_memory.rb +48 -0
  77. data/lib/insika/commands/forget_customer.rb +117 -0
  78. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  79. data/lib/insika/commands/gate_harvest.rb +138 -0
  80. data/lib/insika/commands/gate_refinement.rb +1 -1
  81. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  82. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  83. data/lib/insika/commands/memory_put_fact.rb +23 -4
  84. data/lib/insika/commands/promote_harvest.rb +130 -0
  85. data/lib/insika/commands/record_outcome.rb +46 -0
  86. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  87. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  88. data/lib/insika/commands/reject_harvest.rb +38 -0
  89. data/lib/insika/commands/resolve_proposal.rb +108 -0
  90. data/lib/insika/commands/restore_concept.rb +34 -0
  91. data/lib/insika/commands/revoke_contact.rb +49 -0
  92. data/lib/insika/commands/rollback_harvest.rb +86 -0
  93. data/lib/insika/commands/run_distillation.rb +186 -0
  94. data/lib/insika/commands/run_harvest.rb +393 -0
  95. data/lib/insika/commands/seed_demo_data.rb +31 -0
  96. data/lib/insika/commands/send_message.rb +103 -6
  97. data/lib/insika/commands/session_purge.rb +67 -0
  98. data/lib/insika/commands/upsert_mcp.rb +6 -3
  99. data/lib/insika/commands/write_concept.rb +57 -0
  100. data/lib/insika/contact_store.rb +183 -0
  101. data/lib/insika/context/builder.rb +21 -3
  102. data/lib/insika/context/fragment.rb +7 -3
  103. data/lib/insika/context/priority.rb +5 -0
  104. data/lib/insika/context/provider.rb +17 -3
  105. data/lib/insika/context/providers/briefing.rb +96 -0
  106. data/lib/insika/context/providers/knowledge.rb +108 -0
  107. data/lib/insika/context/providers/memory.rb +16 -7
  108. data/lib/insika/context/providers/prompt.rb +57 -23
  109. data/lib/insika/context/providers/skill.rb +2 -0
  110. data/lib/insika/context/providers/tool_search.rb +2 -0
  111. data/lib/insika/context_trace_store.rb +38 -2
  112. data/lib/insika/cron.rb +189 -0
  113. data/lib/insika/demo/agent_attrs.rb +43 -0
  114. data/lib/insika/demo/golden_cases.rb +81 -0
  115. data/lib/insika/demo/seeder.rb +336 -0
  116. data/lib/insika/distill.rb +224 -0
  117. data/lib/insika/distill_engine.rb +169 -0
  118. data/lib/insika/doctor.rb +889 -11
  119. data/lib/insika/dsl/definition.rb +3 -2
  120. data/lib/insika/dsl/runtime.rb +69 -82
  121. data/lib/insika/dsl/server_boot.rb +92 -1
  122. data/lib/insika/dsl/system.rb +10 -2
  123. data/lib/insika/dsl.rb +186 -5
  124. data/lib/insika/edge_limiter.rb +28 -7
  125. data/lib/insika/env_schema.rb +25 -3
  126. data/lib/insika/errors.rb +11 -0
  127. data/lib/insika/evals/assertions.rb +3 -2
  128. data/lib/insika/evals/golden.rb +41 -4
  129. data/lib/insika/evals/judge.rb +47 -2
  130. data/lib/insika/evals/pairwise.rb +29 -6
  131. data/lib/insika/evals/persona.rb +98 -0
  132. data/lib/insika/evals/runner.rb +9 -0
  133. data/lib/insika/evals/simulator.rb +225 -0
  134. data/lib/insika/evals/transport.rb +83 -1
  135. data/lib/insika/event_stream.rb +10 -0
  136. data/lib/insika/evidence.rb +183 -0
  137. data/lib/insika/executor.rb +869 -76
  138. data/lib/insika/followup_engine.rb +207 -0
  139. data/lib/insika/followup_policy.rb +198 -0
  140. data/lib/insika/followup_store.rb +306 -0
  141. data/lib/insika/funnel_declaration.rb +106 -0
  142. data/lib/insika/funnel_fold.rb +179 -0
  143. data/lib/insika/funnel_store.rb +163 -0
  144. data/lib/insika/golden_store.rb +17 -2
  145. data/lib/insika/grounding/matcher.rb +69 -0
  146. data/lib/insika/grounding.rb +44 -0
  147. data/lib/insika/harvest/conversion_gate.rb +159 -0
  148. data/lib/insika/harvest/criterion.rb +98 -0
  149. data/lib/insika/harvest/gate.rb +194 -0
  150. data/lib/insika/harvest/negative_list.rb +199 -0
  151. data/lib/insika/harvest.rb +241 -0
  152. data/lib/insika/harvest_engine.rb +193 -0
  153. data/lib/insika/harvest_store.rb +548 -0
  154. data/lib/insika/knowledge.rb +680 -0
  155. data/lib/insika/knowledge_store.rb +140 -0
  156. data/lib/insika/mcp_client.rb +94 -0
  157. data/lib/insika/mcp_json.rb +74 -0
  158. data/lib/insika/mcp_live_tool.rb +43 -0
  159. data/lib/insika/mcp_store.rb +98 -26
  160. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  161. data/lib/insika/mcp_tool_registry.rb +100 -0
  162. data/lib/insika/media.rb +382 -0
  163. data/lib/insika/memory_audit_store.rb +85 -0
  164. data/lib/insika/memory_store.rb +264 -23
  165. data/lib/insika/message_origin.rb +7 -2
  166. data/lib/insika/middleware.rb +9 -0
  167. data/lib/insika/model_visible.rb +87 -0
  168. data/lib/insika/model_visible_trace_store.rb +66 -0
  169. data/lib/insika/onboarding.rb +22 -1
  170. data/lib/insika/outbox_store.rb +42 -4
  171. data/lib/insika/outcome_store.rb +147 -0
  172. data/lib/insika/overlay_tool_registry.rb +37 -17
  173. data/lib/insika/packaging.rb +163 -0
  174. data/lib/insika/parity/criterion.rb +79 -0
  175. data/lib/insika/parity/verdict.rb +318 -0
  176. data/lib/insika/prefix_fingerprint.rb +58 -0
  177. data/lib/insika/profile_source.rb +29 -1
  178. data/lib/insika/prompt_catalog.rb +10 -0
  179. data/lib/insika/proposal_store.rb +271 -0
  180. data/lib/insika/queue_policy.rb +4 -1
  181. data/lib/insika/refinement/proposer.rb +1 -1
  182. data/lib/insika/reliability.rb +32 -6
  183. data/lib/insika/retention.rb +316 -0
  184. data/lib/insika/router/app.rb +157 -0
  185. data/lib/insika/router/backend_pool.rb +98 -0
  186. data/lib/insika/router/hash_ring.rb +55 -0
  187. data/lib/insika/router/proxy_body.rb +34 -0
  188. data/lib/insika/router/session_key.rb +54 -0
  189. data/lib/insika/router.rb +18 -0
  190. data/lib/insika/routing.rb +101 -0
  191. data/lib/insika/safety/config.rb +44 -4
  192. data/lib/insika/safety/corpus.rb +255 -0
  193. data/lib/insika/safety/detectors.rb +32 -113
  194. data/lib/insika/safety/factory.rb +15 -2
  195. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  196. data/lib/insika/safety/grounding_validator.rb +49 -0
  197. data/lib/insika/safety/input_guardrail.rb +1 -1
  198. data/lib/insika/safety/moderator.rb +1 -1
  199. data/lib/insika/safety/output_filter.rb +9 -5
  200. data/lib/insika/safety/output_validator.rb +11 -5
  201. data/lib/insika/schedule.rb +177 -0
  202. data/lib/insika/schedule_engine.rb +314 -0
  203. data/lib/insika/schedule_store.rb +208 -0
  204. data/lib/insika/schema_guard.rb +35 -0
  205. data/lib/insika/server/app.rb +247 -17
  206. data/lib/insika/server/rack_app.rb +21 -1
  207. data/lib/insika/server/responses.rb +40 -1
  208. data/lib/insika/session_actor.rb +8 -4
  209. data/lib/insika/session_store.rb +65 -2
  210. data/lib/insika/settings_store.rb +10 -0
  211. data/lib/insika/shadow_pair_store.rb +258 -0
  212. data/lib/insika/skill_catalog.rb +16 -0
  213. data/lib/insika/soak/envelope.rb +140 -0
  214. data/lib/insika/soak/report.rb +392 -0
  215. data/lib/insika/soak/runner.rb +554 -0
  216. data/lib/insika/steer_injector.rb +21 -10
  217. data/lib/insika/store.rb +10 -1
  218. data/lib/insika/stores/memory.rb +6 -0
  219. data/lib/insika/stores/sqlite.rb +8 -0
  220. data/lib/insika/studio/app.rb +1437 -54
  221. data/lib/insika/studio/assets/dist/application.css +1 -1
  222. data/lib/insika/studio/assets/dist/application.js +23 -23
  223. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  224. data/lib/insika/studio/forms.rb +308 -10
  225. data/lib/insika/studio/nav_icons.rb +19 -1
  226. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  227. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  228. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  229. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  230. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  231. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  232. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  233. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  234. data/lib/insika/studio/views/_agents_master.erb +44 -0
  235. data/lib/insika/studio/views/_message.erb +49 -32
  236. data/lib/insika/studio/views/agent_detail.erb +62 -278
  237. data/lib/insika/studio/views/agents.erb +71 -54
  238. data/lib/insika/studio/views/approvals.erb +4 -1
  239. data/lib/insika/studio/views/artifact.erb +23 -0
  240. data/lib/insika/studio/views/artifacts.erb +59 -0
  241. data/lib/insika/studio/views/chats.erb +4 -1
  242. data/lib/insika/studio/views/customer.erb +94 -0
  243. data/lib/insika/studio/views/customers.erb +32 -0
  244. data/lib/insika/studio/views/evals.erb +6 -3
  245. data/lib/insika/studio/views/facts.erb +133 -0
  246. data/lib/insika/studio/views/followups.erb +125 -0
  247. data/lib/insika/studio/views/funnel.erb +106 -0
  248. data/lib/insika/studio/views/harvest.erb +234 -0
  249. data/lib/insika/studio/views/home.erb +106 -66
  250. data/lib/insika/studio/views/knowledge.erb +123 -0
  251. data/lib/insika/studio/views/layout.erb +15 -11
  252. data/lib/insika/studio/views/mcp.erb +174 -80
  253. data/lib/insika/studio/views/parity.erb +147 -0
  254. data/lib/insika/studio/views/playground.erb +7 -1
  255. data/lib/insika/studio/views/session.erb +233 -124
  256. data/lib/insika/studio/views/settings.erb +41 -1
  257. data/lib/insika/studio/views/skills.erb +1 -2
  258. data/lib/insika/studio/views/system_files.erb +1 -1
  259. data/lib/insika/studio/views/task.erb +13 -0
  260. data/lib/insika/studio/views/tasks.erb +4 -1
  261. data/lib/insika/studio/views/tools.erb +24 -10
  262. data/lib/insika/task_store.rb +21 -1
  263. data/lib/insika/templates/browser-agent/README.md +36 -0
  264. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  265. data/lib/insika/templates/daily-digest/README.md +38 -0
  266. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  267. data/lib/insika/templates/repo-explorer/README.md +36 -0
  268. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  269. data/lib/insika/templates/research-analyst/README.md +26 -0
  270. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  271. data/lib/insika/templates/review-panel/README.md +20 -0
  272. data/lib/insika/templates/review-panel/agent.rb +50 -0
  273. data/lib/insika/templates/travel-planner/README.md +35 -0
  274. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  275. data/lib/insika/templates.rb +112 -0
  276. data/lib/insika/testing/store_contract.rb +27 -6
  277. data/lib/insika/tick.rb +47 -11
  278. data/lib/insika/timezone.rb +45 -0
  279. data/lib/insika/tool_definition.rb +17 -7
  280. data/lib/insika/tool_envelope.rb +69 -0
  281. data/lib/insika/tool_manifest.rb +5 -1
  282. data/lib/insika/tools/data_defined_tool.rb +10 -0
  283. data/lib/insika/tools/generate_image.rb +89 -0
  284. data/lib/insika/tools/load_knowledge.rb +74 -0
  285. data/lib/insika/tools/run_persona_eval.rb +328 -0
  286. data/lib/insika/tools/save_artifact.rb +95 -0
  287. data/lib/insika/tools/schedule_followup.rb +164 -0
  288. data/lib/insika/tools/tts.rb +47 -0
  289. data/lib/insika/tools/update_briefing.rb +126 -0
  290. data/lib/insika/turn_output.rb +1 -1
  291. data/lib/insika/turn_state.rb +49 -1
  292. data/lib/insika/turn_timing.rb +22 -2
  293. data/lib/insika/version.rb +1 -1
  294. data/lib/insika/vitals.rb +84 -0
  295. data/lib/insika/wiring/graph.rb +472 -13
  296. data/lib/insika/wiring/graph_chat.rb +102 -0
  297. data/lib/insika.rb +149 -3
  298. metadata +181 -5
  299. data/docs/build.md +0 -14
  300. 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
@@ -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
- private_class_method :symbolize, :bool, :presence, :normalize_strictness, :normalize_responses
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