insika 0.8.0 → 1.0.1
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 +312 -2
- data/README.md +10 -6
- data/bin/insika +4 -1
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +142 -18
- data/docs/API.md +73 -0
- data/docs/ARCHITECTURE.md +56 -50
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CHANNELS.md +19 -2
- data/docs/CONTEXT.md +96 -27
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +70 -13
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +129 -10
- data/docs/FACTS.md +12 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +14 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +164 -28
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +26 -14
- data/docs/RELEASING.md +9 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +34 -1
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +238 -42
- data/docs/operate.md +3 -0
- data/docs/prompts/ADD-TOOL.md +12 -2
- data/docs/prompts/DIAGNOSE-TURN.md +3 -0
- data/docs/prompts/GO-LIVE.md +3 -1
- data/lib/insika/agent_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +99 -17
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/channels/web/widget.js +33 -0
- data/lib/insika/channels/web.rb +5 -2
- data/lib/insika/chat_builder.rb +124 -28
- data/lib/insika/checkpoint.rb +6 -1
- data/lib/insika/checkpoint_store.rb +59 -7
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/delete_kit.rb +26 -0
- data/lib/insika/commands/gate_harvest.rb +3 -0
- data/lib/insika/commands/review_guardrail_flag.rb +38 -0
- data/lib/insika/commands/run_distillation.rb +5 -8
- data/lib/insika/commands/run_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +128 -0
- data/lib/insika/commands/send_message.rb +9 -3
- data/lib/insika/commands/update_settings.rb +23 -1
- data/lib/insika/commands/write_kit.rb +27 -0
- data/lib/insika/compaction.rb +33 -7
- data/lib/insika/config_store.rb +87 -7
- data/lib/insika/context/builder.rb +29 -9
- data/lib/insika/context/priority.rb +5 -0
- data/lib/insika/context/provider.rb +7 -2
- data/lib/insika/context/providers/briefing.rb +11 -8
- data/lib/insika/context/providers/fence_notice.rb +27 -0
- data/lib/insika/context/providers/knowledge.rb +32 -6
- data/lib/insika/context/providers/memory.rb +61 -8
- data/lib/insika/context/providers/pending_confirmation.rb +52 -0
- data/lib/insika/context/providers/prompt.rb +17 -4
- data/lib/insika/context/providers/session.rb +46 -6
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +142 -4
- data/lib/insika/dsl/runtime.rb +37 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +19 -5
- data/lib/insika/edge_limiter.rb +27 -16
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/errors.rb +1 -0
- data/lib/insika/evals/assertions.rb +259 -18
- data/lib/insika/evals/golden.rb +230 -4
- data/lib/insika/evals/judge.rb +19 -10
- data/lib/insika/evals/moderator_compare.rb +47 -0
- data/lib/insika/evals/report.rb +61 -9
- data/lib/insika/evals/runner.rb +64 -4
- data/lib/insika/evals/simulator.rb +21 -3
- data/lib/insika/evals/transport.rb +317 -15
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +105 -17
- data/lib/insika/executor.rb +698 -164
- data/lib/insika/fence.rb +96 -0
- data/lib/insika/golden_store.rb +3 -0
- data/lib/insika/harvest.rb +26 -14
- data/lib/insika/harvest_engine.rb +1 -0
- data/lib/insika/harvest_store.rb +12 -0
- data/lib/insika/http_client.rb +87 -4
- data/lib/insika/kits.rb +54 -0
- data/lib/insika/knowledge.rb +34 -20
- data/lib/insika/llm_configurator.rb +46 -4
- data/lib/insika/llm_provider_store.rb +1 -3
- data/lib/insika/llm_trace_store.rb +54 -0
- data/lib/insika/mcp_client.rb +27 -63
- data/lib/insika/mcp_live_tool.rb +55 -9
- data/lib/insika/mcp_store.rb +37 -3
- data/lib/insika/mcp_tool_registry.rb +76 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +16 -3
- data/lib/insika/memory_trim.rb +60 -0
- data/lib/insika/middleware.rb +12 -3
- data/lib/insika/model_metrics_store.rb +285 -0
- data/lib/insika/model_registry.rb +110 -0
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +14 -21
- data/lib/insika/model_visible.rb +17 -6
- data/lib/insika/onboarding.rb +1 -0
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +30 -2
- data/lib/insika/pack_importer.rb +54 -9
- data/lib/insika/packaging.rb +14 -5
- data/lib/insika/pending_action_store.rb +45 -18
- data/lib/insika/plugin/loader.rb +4 -2
- data/lib/insika/prefix_fingerprint.rb +32 -27
- data/lib/insika/profile_source.rb +5 -0
- data/lib/insika/provider_error_classifier.rb +18 -3
- data/lib/insika/queue_policy.rb +7 -2
- data/lib/insika/refinement/proposer.rb +42 -17
- data/lib/insika/reliability.rb +12 -3
- data/lib/insika/router/app.rb +14 -5
- data/lib/insika/router/session_key.rb +35 -5
- data/lib/insika/safety/config.rb +20 -3
- data/lib/insika/safety/corpus.rb +10 -3
- data/lib/insika/safety/factory.rb +39 -1
- data/lib/insika/safety/input_guardrail.rb +21 -1
- data/lib/insika/safety/judge_moderator.rb +70 -0
- data/lib/insika/safety/output_validator.rb +29 -5
- data/lib/insika/safety/prompt_echo.rb +89 -0
- data/lib/insika/schema_guard.rb +53 -76
- data/lib/insika/server/app.rb +58 -7
- data/lib/insika/server/rack_app.rb +2 -0
- data/lib/insika/server/responses.rb +50 -5
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +163 -11
- data/lib/insika/settings_store.rb +57 -2
- data/lib/insika/shared_conversations.rb +305 -0
- data/lib/insika/skill_catalog.rb +28 -2
- data/lib/insika/skill_store.rb +6 -0
- data/lib/insika/soak/runner.rb +1 -1
- data/lib/insika/spoken_transcript.rb +31 -0
- data/lib/insika/steer_injector.rb +7 -48
- data/lib/insika/store.rb +29 -1
- data/lib/insika/stores/json_value.rb +37 -0
- data/lib/insika/stores/memory.rb +22 -35
- data/lib/insika/stores/postgres.rb +295 -0
- data/lib/insika/stores/sqlite.rb +257 -43
- data/lib/insika/stores/turn_counter.rb +25 -0
- data/lib/insika/studio/app.rb +1459 -163
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +25 -25
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
- data/lib/insika/studio/forms.rb +189 -12
- data/lib/insika/studio/nav_icons.rb +3 -0
- data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
- data/lib/insika/studio/views/_agent_picker.erb +15 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
- data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
- data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
- data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
- data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
- data/lib/insika/studio/views/_agents_master.erb +8 -12
- data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
- data/lib/insika/studio/views/_cfg_budget.erb +20 -0
- data/lib/insika/studio/views/_cfg_distill.erb +26 -0
- data/lib/insika/studio/views/_cfg_followup.erb +30 -0
- data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
- data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
- data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
- data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
- data/lib/insika/studio/views/_chats_master.erb +66 -0
- data/lib/insika/studio/views/_customers_master.erb +37 -0
- data/lib/insika/studio/views/_empty.erb +15 -0
- data/lib/insika/studio/views/_facts_master.erb +42 -0
- data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
- data/lib/insika/studio/views/_identity.erb +16 -0
- data/lib/insika/studio/views/_kpi.erb +17 -0
- data/lib/insika/studio/views/_message.erb +40 -2
- data/lib/insika/studio/views/_model_chart.erb +82 -0
- data/lib/insika/studio/views/_refinement_master.erb +38 -0
- data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
- data/lib/insika/studio/views/_tool_users.erb +17 -0
- data/lib/insika/studio/views/_why.erb +53 -0
- data/lib/insika/studio/views/agent_detail.erb +17 -10
- data/lib/insika/studio/views/agents.erb +28 -16
- data/lib/insika/studio/views/approvals.erb +16 -11
- data/lib/insika/studio/views/artifact.erb +20 -7
- data/lib/insika/studio/views/artifacts.erb +19 -14
- data/lib/insika/studio/views/chats.erb +22 -29
- data/lib/insika/studio/views/customer.erb +88 -43
- data/lib/insika/studio/views/customers.erb +16 -26
- data/lib/insika/studio/views/evals.erb +49 -31
- data/lib/insika/studio/views/fact_detail.erb +114 -0
- data/lib/insika/studio/views/facts.erb +15 -116
- data/lib/insika/studio/views/followups.erb +63 -52
- data/lib/insika/studio/views/funnel.erb +104 -43
- data/lib/insika/studio/views/harvest.erb +85 -59
- data/lib/insika/studio/views/home.erb +138 -23
- data/lib/insika/studio/views/inbox.erb +36 -0
- data/lib/insika/studio/views/kits.erb +176 -0
- data/lib/insika/studio/views/knowledge.erb +92 -80
- data/lib/insika/studio/views/layout.erb +42 -11
- data/lib/insika/studio/views/live.erb +45 -0
- data/lib/insika/studio/views/mcp.erb +43 -13
- data/lib/insika/studio/views/models.erb +187 -0
- data/lib/insika/studio/views/parity.erb +38 -32
- data/lib/insika/studio/views/playground.erb +13 -9
- data/lib/insika/studio/views/refinement.erb +15 -224
- data/lib/insika/studio/views/refinement_detail.erb +243 -0
- data/lib/insika/studio/views/session.erb +153 -118
- data/lib/insika/studio/views/settings.erb +86 -22
- data/lib/insika/studio/views/skills.erb +53 -19
- data/lib/insika/studio/views/system_files.erb +7 -7
- data/lib/insika/studio/views/task.erb +45 -15
- data/lib/insika/studio/views/tasks.erb +35 -18
- data/lib/insika/studio/views/tools.erb +62 -73
- data/lib/insika/task_actor.rb +4 -1
- data/lib/insika/task_store.rb +67 -1
- data/lib/insika/telemetry/pricing.rb +16 -14
- data/lib/insika/telemetry/recorder.rb +115 -9
- data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
- data/lib/insika/telemetry.rb +12 -2
- data/lib/insika/templates/daily-digest/README.md +13 -0
- data/lib/insika/templates/daily-digest/agent.rb +22 -11
- data/lib/insika/testing/store_contract.rb +65 -0
- data/lib/insika/text_tool_call.rb +66 -0
- data/lib/insika/tick.rb +7 -1
- data/lib/insika/token_store.rb +5 -0
- data/lib/insika/tool_assembly.rb +21 -13
- data/lib/insika/tool_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +82 -13
- data/lib/insika/tool_envelope.rb +266 -29
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +12 -4
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +14 -4
- data/lib/insika/tools/a2a_remote.rb +1 -1
- data/lib/insika/tools/agent_enum.rb +1 -1
- data/lib/insika/tools/confirm_pending.rb +112 -0
- data/lib/insika/tools/data_defined_tool.rb +24 -15
- data/lib/insika/tools/generate_image.rb +2 -2
- data/lib/insika/tools/load_knowledge.rb +7 -3
- data/lib/insika/tools/load_skill.rb +3 -3
- data/lib/insika/tools/present.rb +115 -0
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +9 -6
- data/lib/insika/tools/save_artifact.rb +16 -6
- data/lib/insika/tools/schedule_followup.rb +3 -3
- data/lib/insika/tools/stuck_signal.rb +3 -3
- data/lib/insika/tools/subagent.rb +5 -5
- data/lib/insika/tools/subagents.rb +3 -3
- data/lib/insika/tools/tool_search.rb +11 -7
- data/lib/insika/tools/tts.rb +2 -2
- data/lib/insika/tools/update_briefing.rb +4 -4
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +31 -1
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +88 -16
- data/lib/insika/wiring/graph_chat.rb +4 -0
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +23 -3
- metadata +74 -13
- data/lib/insika/commands/import_mcp_tools.rb +0 -48
- data/lib/insika/mcp_http_client.rb +0 -67
- data/lib/insika/mcp_tool_ingestor.rb +0 -165
data/lib/insika/fence.rb
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Third-party text — a product description, a review, an FAQ body, a fact a
|
|
5
|
+
# customer dictated — reaches the model as bytes, and the model reads every
|
|
6
|
+
# one of them. Those bytes can carry zero-width characters, bidi overrides, a
|
|
7
|
+
# forged "\n\nassistant:" turn marker or a tag shaped like the engine's own
|
|
8
|
+
# transcript markup. This is the ONE sanitizer for that class of input:
|
|
9
|
+
# pure Ruby, stdlib only, no IO, every pattern bounded so a hostile megabyte
|
|
10
|
+
# finishes in linear time on the reactor.
|
|
11
|
+
#
|
|
12
|
+
# It never re-wraps. The engine already renders data inside fixed labels
|
|
13
|
+
# (<memory>, <knowledge>, a tool result) and the FenceNotice provider names
|
|
14
|
+
# them to the model; what this module does is make sure the DATA cannot
|
|
15
|
+
# reproduce those labels or a turn boundary.
|
|
16
|
+
module Fence
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Zero-width, bidi and format controls: the usual carriers of hidden text.
|
|
20
|
+
INVISIBLE = /[\u00AD\u061C\u180E\u200B-\u200F\u2028-\u202E\u2060-\u2064\u2066-\u2069\u206A-\u206F\uFE00-\uFE0F\uFEFF\uFFF9-\uFFFB\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]/
|
|
21
|
+
|
|
22
|
+
# C0/C1 controls except tab, newline and carriage return.
|
|
23
|
+
CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/
|
|
24
|
+
|
|
25
|
+
# Tag names that read as transcript or tool-call markup to a model, plus the
|
|
26
|
+
# labels the engine itself renders around data. A copy of any of them inside
|
|
27
|
+
# DATA is a forgery — `<fact>` inside a fact value would end the fact early.
|
|
28
|
+
TAG_NAMES = %w[
|
|
29
|
+
system human user assistant transcript conversation
|
|
30
|
+
tool_result tool_use function_calls function_results invoke
|
|
31
|
+
memory knowledge fact note concept briefing conversation_summary request_context
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
# Only tag-SHAPED text: bare (`<system>`), closing (`</memory>`), self-closing,
|
|
35
|
+
# optionally namespaced (`<ns:invoke>`), with at most eight `name="value"`
|
|
36
|
+
# attributes. Bare words are not attributes, so `<system requirements>`
|
|
37
|
+
# passes. Quantifiers are bounded and non-adjacent — linear on unclosed input.
|
|
38
|
+
TAG_ATTRS = /(?:[ \t]+[\w:.-]{1,40}[ \t]*=[ \t]*(?:"[^"]{0,200}"|'[^']{0,200}'|[^\s"'>]{1,200})){0,8}/
|
|
39
|
+
TAG = /<[ \t]*\/?[ \t]*(?:[a-z][\w.-]{0,30}:)?(?:#{TAG_NAMES.join('|')})\b#{TAG_ATTRS.source}[ \t]*\/?>/i
|
|
40
|
+
|
|
41
|
+
# Provider special tokens (`<|im_start|>`, `<|endoftext|>`).
|
|
42
|
+
SPECIAL_TOKEN = /<\|[^|<>\r\n]{1,64}\|>/
|
|
43
|
+
|
|
44
|
+
# A forged turn boundary: a blank line (or the very start), a full role word,
|
|
45
|
+
# a colon. A mid-sentence "user:" and a one-letter list marker ("A:") do not
|
|
46
|
+
# match. The colon becomes " -", so the words survive as prose.
|
|
47
|
+
TURN_MARKER = /(\A[ \t]*|(?:\r\n|\r|\n)[ \t]*(?:\r\n|\r|\n)[ \t]*)(human|assistant|system|user)[ \t]*:/i
|
|
48
|
+
|
|
49
|
+
REMOVED = "[removed]"
|
|
50
|
+
TRUNCATED = " …[truncated]"
|
|
51
|
+
# One string leaf after the envelope; `Settings fencing.max_chars` overrides.
|
|
52
|
+
DEFAULT_MAX_CHARS = 12_000
|
|
53
|
+
|
|
54
|
+
# -> String. `max_chars` bounds the result INCLUDING the suffix.
|
|
55
|
+
def sanitize_text(text, max_chars: nil)
|
|
56
|
+
s = Coercion.utf8(text).unicode_normalize(:nfkc)
|
|
57
|
+
s = s.gsub(INVISIBLE, "").gsub(CONTROL, " ")
|
|
58
|
+
# To a fixpoint: a tag nested inside another (`</memory</memory>>`) must
|
|
59
|
+
# not reassemble once the inner one goes. Each round shrinks the string or
|
|
60
|
+
# stops, so the loop is bounded by the number of tags.
|
|
61
|
+
loop do
|
|
62
|
+
stripped = s.gsub(TAG, REMOVED).gsub(SPECIAL_TOKEN, REMOVED)
|
|
63
|
+
break if stripped == s
|
|
64
|
+
|
|
65
|
+
s = stripped
|
|
66
|
+
end
|
|
67
|
+
s = s.gsub(TURN_MARKER) { "#{Regexp.last_match(1)}#{Regexp.last_match(2)} -" }
|
|
68
|
+
cap(s, max_chars)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Walks Hash/Array leaves; String leaves are sanitized, keys and every other
|
|
72
|
+
# type pass through untouched (numbers, booleans, nil are not text).
|
|
73
|
+
def sanitize_value(obj, max_chars: nil)
|
|
74
|
+
case obj
|
|
75
|
+
when String then sanitize_text(obj, max_chars: max_chars)
|
|
76
|
+
when Hash then obj.transform_values { |v| sanitize_value(v, max_chars: max_chars) }
|
|
77
|
+
when Array then obj.map { |v| sanitize_value(v, max_chars: max_chars) }
|
|
78
|
+
else obj
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The per-agent switch, read the same way everywhere (envelope, providers,
|
|
83
|
+
# doctor): an operator's `fencing true`, a form's "1" or a JSON `true`.
|
|
84
|
+
def enabled?(profile)
|
|
85
|
+
profile.respond_to?(:fencing) && Coercion.truthy?(profile.fencing)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def cap(text, max_chars)
|
|
89
|
+
return text if max_chars.nil? || text.length <= max_chars
|
|
90
|
+
return text[0, max_chars] if max_chars <= TRUNCATED.length
|
|
91
|
+
|
|
92
|
+
text[0, max_chars - TRUNCATED.length] + TRUNCATED
|
|
93
|
+
end
|
|
94
|
+
private_class_method :cap
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/insika/golden_store.rb
CHANGED
|
@@ -148,6 +148,9 @@ module Insika
|
|
|
148
148
|
# when present. A case that lost its reference in a round-trip would stop being
|
|
149
149
|
# compared against the incumbent and the report would look identical.
|
|
150
150
|
h["reference"] = golden.reference unless golden.reference.empty?
|
|
151
|
+
# And `state`: a snapshot case that lost its snapshot would replay from an
|
|
152
|
+
# empty conversation and fail for a reason that has nothing to do with the agent.
|
|
153
|
+
h["state"] = golden.state unless golden.state.empty?
|
|
151
154
|
# `tenant` follows the same omit-when-default rule: "platform" is the
|
|
152
155
|
# loader's own default, so leaving it out reproduces the same case; an
|
|
153
156
|
# explicit tenant is a case's isolation boundary and must never round-trip away.
|
data/lib/insika/harvest.rb
CHANGED
|
@@ -89,7 +89,7 @@ module Insika
|
|
|
89
89
|
# first-10 audit reads back.
|
|
90
90
|
|
|
91
91
|
# ask: ->(prompt) { "<raw model text>" } | something answering #content
|
|
92
|
-
# (+ #
|
|
92
|
+
# (+ RubyLLM-compatible #tokens for cost).
|
|
93
93
|
# model: the ref recorded as the candidate's `proposer` ("utility_model"
|
|
94
94
|
# default).
|
|
95
95
|
attr_reader :model
|
|
@@ -110,7 +110,7 @@ module Insika
|
|
|
110
110
|
# max_proposals: cap on surviving raw skills. Drops counted, never fixed.
|
|
111
111
|
def mine(prompt:, message_counts:, max_proposals: 10)
|
|
112
112
|
answer = @ask.call(prompt)
|
|
113
|
-
raw = parse(
|
|
113
|
+
raw = parse(answer)
|
|
114
114
|
skills = []
|
|
115
115
|
dropped = DROP_KEYS.to_h { |k| [k, 0] }
|
|
116
116
|
seen = {}
|
|
@@ -142,20 +142,25 @@ module Insika
|
|
|
142
142
|
# discipline). The cached prefix is INCLUDED in the spent total (E1: the
|
|
143
143
|
# run's cost is the harvest's only side spend).
|
|
144
144
|
def cost_of(answer)
|
|
145
|
-
return nil unless answer.respond_to?(:
|
|
145
|
+
return nil unless answer.respond_to?(:tokens)
|
|
146
146
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
147
|
+
usage = answer.tokens
|
|
148
|
+
input = usage.input.to_i
|
|
149
|
+
output = usage.output.to_i
|
|
150
|
+
cached = usage.cache_read.to_i
|
|
151
|
+
spent = input + output + cached + usage.cache_write.to_i
|
|
151
152
|
spent.positive? ? { "spent" => spent, "cached" => cached } : nil
|
|
152
153
|
end
|
|
153
154
|
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
def parse(
|
|
157
|
-
|
|
158
|
-
|
|
155
|
+
# Native responses own JSON parsing; injected text asks retain their
|
|
156
|
+
# existing fenced-JSON contract. Domain validation still runs below.
|
|
157
|
+
def parse(answer)
|
|
158
|
+
parsed = if answer.respond_to?(:parsed)
|
|
159
|
+
answer.parsed
|
|
160
|
+
else
|
|
161
|
+
JSON.parse(text_of(answer).strip.gsub(/\A```(?:json)?\s*|\s*```\z/, ""))
|
|
162
|
+
end
|
|
163
|
+
parsed = parsed["items"] if parsed.is_a?(Hash)
|
|
159
164
|
raise Unusable, "the miner's answer is not an array" unless parsed.is_a?(Array)
|
|
160
165
|
|
|
161
166
|
parsed
|
|
@@ -214,6 +219,10 @@ module Insika
|
|
|
214
219
|
return nil if ref.nil?
|
|
215
220
|
|
|
216
221
|
provider, model = split_ref(ref)
|
|
222
|
+
# A bare "model" ref falls back to the agent's own provider (stamped
|
|
223
|
+
# by RunHarvest) — RubyLLM refuses a nil provider with
|
|
224
|
+
# assume_model_exists, so a bare ref used to fail every run.
|
|
225
|
+
provider ||= Coercion.presence(config && config["provider"])
|
|
217
226
|
factory = ask_factory || ->(m, p) { ruby_llm_ask(m, p, llm: llm) }
|
|
218
227
|
Miner.new(ask: factory.call(model, provider), model: ref)
|
|
219
228
|
end
|
|
@@ -232,10 +241,13 @@ module Insika
|
|
|
232
241
|
require "ruby_llm"
|
|
233
242
|
llm ||= RubyLLM
|
|
234
243
|
lambda do |prompt|
|
|
244
|
+
schema = { "type" => "object", "properties" => { "items" => SKILL_SCHEMA.json_schema },
|
|
245
|
+
"required" => ["items"], "additionalProperties" => false }
|
|
235
246
|
llm.chat(model: model, provider: provider, assume_model_exists: true)
|
|
236
|
-
.with_temperature(0).
|
|
247
|
+
.with_temperature(0).with_schema("schema" => schema, "strict" => false)
|
|
248
|
+
.ask("#{prompt}\n\nReturn the requested array inside a JSON object with the key \"items\".")
|
|
237
249
|
end
|
|
238
250
|
end
|
|
239
251
|
end
|
|
240
252
|
end
|
|
241
|
-
end
|
|
253
|
+
end
|
|
@@ -125,6 +125,7 @@ module Insika
|
|
|
125
125
|
def due_sessions
|
|
126
126
|
due = []
|
|
127
127
|
@session_store.each_id do |id|
|
|
128
|
+
next if Insika::SessionStore.synthetic?(id) # not customers — and skips reading the record
|
|
128
129
|
session = @session_store.find(id)
|
|
129
130
|
next unless session
|
|
130
131
|
next if @harvest_store.mined?(id)
|
data/lib/insika/harvest_store.rb
CHANGED
|
@@ -196,6 +196,18 @@ module Insika
|
|
|
196
196
|
|
|
197
197
|
# gated -> awaiting_approval. The refusal parks: a conversion REFUSAL
|
|
198
198
|
# keeps the candidate AT gated (the page shows the ruler's hole).
|
|
199
|
+
# A parked candidate (gated on a missing ruler: no judge, no criterion,
|
|
200
|
+
# no origin) goes back to pending so the operator can re-run the gate
|
|
201
|
+
# once the hole is fixed. The last reports stay until the new gate
|
|
202
|
+
# overwrites them. gated -> pending only.
|
|
203
|
+
def reopen(candidate_id)
|
|
204
|
+
update_candidate(candidate_id) do |record|
|
|
205
|
+
guard_candidate_state!(record, "gated")
|
|
206
|
+
record["status"] = "pending"
|
|
207
|
+
record["updated_at"] = timestamp
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
199
211
|
def mark_awaiting(candidate_id)
|
|
200
212
|
update_candidate(candidate_id) do |record|
|
|
201
213
|
guard_candidate_state!(record, "gated")
|
data/lib/insika/http_client.rb
CHANGED
|
@@ -24,8 +24,21 @@ module Insika
|
|
|
24
24
|
|
|
25
25
|
class ResponseTooLarge < Insika::Error; end
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
DNS_TTL = 60 # seconds
|
|
28
|
+
# A connection idle longer than this is reopened instead of reused. Kept well
|
|
29
|
+
# under common server idle timeouts: Net::HTTP retries only idempotent methods,
|
|
30
|
+
# so a POST on a socket the server already closed would fail the tool call.
|
|
31
|
+
KEEP_ALIVE = 5 # seconds
|
|
32
|
+
MAX_IDLE_PER_HOST = 16
|
|
33
|
+
|
|
34
|
+
def initialize(max_bytes: MAX_BYTES, dns_ttl: DNS_TTL, keep_alive: KEEP_ALIVE)
|
|
28
35
|
@max_bytes = max_bytes
|
|
36
|
+
@dns_ttl = dns_ttl
|
|
37
|
+
@keep_alive = keep_alive
|
|
38
|
+
@dns = {}
|
|
39
|
+
@idle = Hash.new { |h, k| h[k] = [] }
|
|
40
|
+
@pid = Process.pid
|
|
41
|
+
@lock = Mutex.new
|
|
29
42
|
end
|
|
30
43
|
|
|
31
44
|
def request(method:, url:, headers: {}, body: nil, timeout: nil)
|
|
@@ -34,9 +47,7 @@ module Insika
|
|
|
34
47
|
headers.each { |k, v| req[k] = v }
|
|
35
48
|
req.body = body if body && !body.to_s.empty?
|
|
36
49
|
|
|
37
|
-
|
|
38
|
-
opts = { use_ssl: uri.scheme == "https", open_timeout: t, read_timeout: t }
|
|
39
|
-
Net::HTTP.start(uri.host, uri.port, opts) do |http|
|
|
50
|
+
with_connection(uri, timeout || DEFAULT_TIMEOUT) do |http|
|
|
40
51
|
result = nil
|
|
41
52
|
http.request(req) do |resp|
|
|
42
53
|
# Accumulate in BINARY: Net::HTTP yields ASCII-8BIT chunks and a
|
|
@@ -55,9 +66,81 @@ module Insika
|
|
|
55
66
|
# The redirect TARGET, so a moved API is reported as "moved to <url>"
|
|
56
67
|
# instead of a bare 3xx with the empty body servers send with it.
|
|
57
68
|
result[:location] = resp["location"].to_s if resp["location"]
|
|
69
|
+
# The server's own processing time (Rack::Runtime's X-Runtime, seconds),
|
|
70
|
+
# so a slow call can be split into server work and everything else.
|
|
71
|
+
runtime = Float(resp["x-runtime"], exception: false)
|
|
72
|
+
result[:server_ms] = (runtime * 1000).round if runtime
|
|
58
73
|
end
|
|
59
74
|
result
|
|
60
75
|
end
|
|
61
76
|
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Reuses an idle keep-alive connection to the same origin (a new TLS connection
|
|
81
|
+
# costs several round trips). One caller holds a connection at a time; one that
|
|
82
|
+
# raised is closed, never returned, since its state is unknown.
|
|
83
|
+
def with_connection(uri, timeout)
|
|
84
|
+
key = [uri.scheme, uri.host, uri.port]
|
|
85
|
+
http = checkout(key) || connect(uri, timeout)
|
|
86
|
+
http.read_timeout = timeout
|
|
87
|
+
result = yield http
|
|
88
|
+
checkin(key, http)
|
|
89
|
+
result
|
|
90
|
+
rescue StandardError
|
|
91
|
+
http&.finish if http&.started?
|
|
92
|
+
raise
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def connect(uri, timeout)
|
|
96
|
+
# `ipaddr` connects to the cached address; TLS (SNI, certificate check) and the
|
|
97
|
+
# Host header still use the hostname.
|
|
98
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
99
|
+
http.ipaddr = address(uri.host)
|
|
100
|
+
http.use_ssl = uri.scheme == "https"
|
|
101
|
+
http.open_timeout = timeout
|
|
102
|
+
http.keep_alive_timeout = @keep_alive
|
|
103
|
+
http.start
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def checkout(key)
|
|
107
|
+
@lock.synchronize do
|
|
108
|
+
reset_after_fork
|
|
109
|
+
@idle[key].pop
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def checkin(key, http)
|
|
114
|
+
kept = @lock.synchronize do
|
|
115
|
+
next false if @idle[key].size >= MAX_IDLE_PER_HOST
|
|
116
|
+
|
|
117
|
+
@idle[key].push(http)
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
http.finish unless kept
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A forked child must not share its parent's sockets.
|
|
124
|
+
def reset_after_fork
|
|
125
|
+
return if @pid == Process.pid
|
|
126
|
+
|
|
127
|
+
@pid = Process.pid
|
|
128
|
+
@idle.clear
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Net::HTTP resolves with getaddrinfo, which blocks the whole reactor under
|
|
132
|
+
# Falcon: a burst of N tool calls queued N lookups and froze every turn of the
|
|
133
|
+
# process meanwhile. Resolving once per host per TTL makes that rare.
|
|
134
|
+
# ponytail: the lookup on a miss still blocks; a fiber-aware resolver removes it
|
|
135
|
+
# if one host per minute ever shows up in a profile.
|
|
136
|
+
def address(host)
|
|
137
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
138
|
+
hit = @lock.synchronize { @dns[host] }
|
|
139
|
+
return hit[0] if hit && hit[1] > now
|
|
140
|
+
|
|
141
|
+
ip = Addrinfo.getaddrinfo(host, nil, nil, :STREAM).first.ip_address
|
|
142
|
+
@lock.synchronize { @dns[host] = [ip, now + @dns_ttl] }
|
|
143
|
+
ip
|
|
144
|
+
end
|
|
62
145
|
end
|
|
63
146
|
end
|
data/lib/insika/kits.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Kits: named bundles of skills, tools and tool groups that agents subscribe to
|
|
5
|
+
# (AgentProfile#kits). Stored in Settings["kits"]; applied at READ time by
|
|
6
|
+
# AgentDefaults::ProfileSource, so editing a kit reaches every subscriber on its
|
|
7
|
+
# next turn and the agent's own record never holds a kit's items.
|
|
8
|
+
#
|
|
9
|
+
# A kit only ADDS to an explicit allowlist. An agent whose allowlist is "all"
|
|
10
|
+
# (nil) already sees everything, so the kit leaves it alone.
|
|
11
|
+
module Kits
|
|
12
|
+
NAME = /\A[a-z0-9][a-z0-9_-]{0,63}\z/
|
|
13
|
+
LISTS = %w[skills tools tool_groups].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# -> the normalized kit { "description", "skills", "tools", "tool_groups" }.
|
|
18
|
+
def validate!(name, kit)
|
|
19
|
+
raise Insika::ValidationError, "kit name must be a lowercase slug" unless name.to_s.match?(NAME)
|
|
20
|
+
raise Insika::ValidationError, "kit must be an object" unless kit.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
kit = kit.transform_keys(&:to_s)
|
|
23
|
+
out = { "description" => kit["description"].to_s.strip }
|
|
24
|
+
LISTS.each do |key|
|
|
25
|
+
value = kit[key]
|
|
26
|
+
raise Insika::ValidationError, "kit #{key} must be a list" unless value.nil? || value.is_a?(Array)
|
|
27
|
+
|
|
28
|
+
out[key] = Array(value).map { |v| v.to_s.strip }.reject(&:empty?).uniq
|
|
29
|
+
end
|
|
30
|
+
out
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# -> the profile with its kits' items unioned in (the same object when there is
|
|
34
|
+
# nothing to add). Unknown kit names are ignored: a deleted kit just stops applying.
|
|
35
|
+
def apply(profile, kits_by_name)
|
|
36
|
+
# A malformed stored kit is skipped, never raised: one bad record must not stop
|
|
37
|
+
# every turn of every subscriber (the same rule as AgentDefaults).
|
|
38
|
+
chosen = Array(profile.kits).filter_map { |name| kits_by_name[name] }.grep(Hash)
|
|
39
|
+
return profile if chosen.empty?
|
|
40
|
+
|
|
41
|
+
pick = ->(key) { chosen.flat_map { |k| Array(k[key]) }.uniq }
|
|
42
|
+
changes = {}
|
|
43
|
+
unless profile.tools_allow.nil? && profile.tools_allow_groups.nil?
|
|
44
|
+
tools = pick.("tools")
|
|
45
|
+
groups = pick.("tool_groups")
|
|
46
|
+
changes[:tools_allow] = Array(profile.tools_allow) | tools if tools.any?
|
|
47
|
+
changes[:tools_allow_groups] = Array(profile.tools_allow_groups) | groups if groups.any?
|
|
48
|
+
end
|
|
49
|
+
skills = pick.("skills")
|
|
50
|
+
changes[:skills] = Array(profile.skills) | skills if !profile.skills.nil? && skills.any?
|
|
51
|
+
changes.empty? ? profile : profile.with(**changes)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/insika/knowledge.rb
CHANGED
|
@@ -244,7 +244,7 @@ module Insika
|
|
|
244
244
|
attr_reader :model, :types
|
|
245
245
|
|
|
246
246
|
# ask: ->(prompt) { "<raw model text>" } | something answering #content
|
|
247
|
-
# (+ #
|
|
247
|
+
# (+ RubyLLM-compatible #tokens for cost).
|
|
248
248
|
# model: the ref recorded on the concept's provenance ("utility_model"
|
|
249
249
|
# default).
|
|
250
250
|
# types: the allowed concept types (the profile's `knowledge.types`,
|
|
@@ -262,7 +262,7 @@ module Insika
|
|
|
262
262
|
# cost: { "spent" => N, "cached" => N } | nil }
|
|
263
263
|
def extract(prompt:, max_concepts: 10)
|
|
264
264
|
answer = @ask.call(prompt)
|
|
265
|
-
raw = parse(
|
|
265
|
+
raw = parse(answer)
|
|
266
266
|
concepts = []
|
|
267
267
|
dropped = DROP_KEYS.to_h { |k| [k, 0] }
|
|
268
268
|
seen = {}
|
|
@@ -291,21 +291,25 @@ module Insika
|
|
|
291
291
|
def text_of(answer) = (answer.respond_to?(:content) ? answer.content : answer).to_s
|
|
292
292
|
|
|
293
293
|
def cost_of(answer)
|
|
294
|
-
return nil unless answer.respond_to?(:
|
|
294
|
+
return nil unless answer.respond_to?(:tokens)
|
|
295
295
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
296
|
+
usage = answer.tokens
|
|
297
|
+
input = usage.input.to_i
|
|
298
|
+
output = usage.output.to_i
|
|
299
|
+
cached = usage.cache_read.to_i
|
|
300
|
+
spent = input + output + cached + usage.cache_write.to_i
|
|
300
301
|
spent.positive? ? { "spent" => spent, "cached" => cached } : nil
|
|
301
302
|
end
|
|
302
303
|
|
|
303
|
-
#
|
|
304
|
-
#
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
304
|
+
# Native responses own JSON parsing; injected text asks retain their
|
|
305
|
+
# existing fenced-JSON contract. Domain validation still runs below.
|
|
306
|
+
def parse(answer)
|
|
307
|
+
parsed = if answer.respond_to?(:parsed)
|
|
308
|
+
answer.parsed
|
|
309
|
+
else
|
|
310
|
+
JSON.parse(text_of(answer).strip.gsub(/\A```(?:json)?\s*|\s*```\z/, ""))
|
|
311
|
+
end
|
|
312
|
+
parsed = parsed["items"] if parsed.is_a?(Hash)
|
|
309
313
|
raise Unusable, "the extractor's answer is not an array" unless parsed.is_a?(Array)
|
|
310
314
|
|
|
311
315
|
parsed
|
|
@@ -368,12 +372,17 @@ module Insika
|
|
|
368
372
|
# Temperature 0: extraction should be deterministic for the same turn.
|
|
369
373
|
# `ruby_llm` required lazily so nothing loads a provider gem until an
|
|
370
374
|
# extractor is actually configured (load_guard stays green).
|
|
371
|
-
def ruby_llm_ask(model, provider, llm: nil)
|
|
375
|
+
def ruby_llm_ask(model, provider, llm: nil, schema: nil)
|
|
372
376
|
require "ruby_llm"
|
|
373
377
|
llm ||= RubyLLM
|
|
374
378
|
lambda do |prompt|
|
|
379
|
+
unless schema
|
|
380
|
+
prompt = "#{prompt}\n\nReturn the requested array inside a JSON object with the key \"items\"."
|
|
381
|
+
end
|
|
382
|
+
response_schema = schema || { "type" => "object", "properties" => { "items" => CONCEPT_SCHEMA.json_schema },
|
|
383
|
+
"required" => ["items"], "additionalProperties" => false }
|
|
375
384
|
llm.chat(model: model, provider: provider, assume_model_exists: true)
|
|
376
|
-
.with_temperature(0).ask(prompt)
|
|
385
|
+
.with_temperature(0).with_schema("schema" => response_schema, "strict" => false).ask(prompt)
|
|
377
386
|
end
|
|
378
387
|
end
|
|
379
388
|
end
|
|
@@ -434,7 +443,7 @@ module Insika
|
|
|
434
443
|
# -> { verdict: :related, merged_body: String } | { verdict: :contradicting }
|
|
435
444
|
def resolve(existing_body:, new_body:)
|
|
436
445
|
answer = @ask.call(prompt_for(existing_body, new_body))
|
|
437
|
-
parsed = parse(
|
|
446
|
+
parsed = parse(answer)
|
|
438
447
|
raise Unusable, "not a JSON object" unless VERDICT_SCHEMA.call(parsed).success?
|
|
439
448
|
|
|
440
449
|
classify(parsed)
|
|
@@ -455,9 +464,12 @@ module Insika
|
|
|
455
464
|
|
|
456
465
|
def text_of(answer) = (answer.respond_to?(:content) ? answer.content : answer).to_s
|
|
457
466
|
|
|
458
|
-
def parse(
|
|
459
|
-
|
|
460
|
-
|
|
467
|
+
def parse(answer)
|
|
468
|
+
parsed = if answer.respond_to?(:parsed)
|
|
469
|
+
answer.parsed
|
|
470
|
+
else
|
|
471
|
+
JSON.parse(text_of(answer).strip.gsub(/\A```(?:json)?\s*|\s*```\z/, ""))
|
|
472
|
+
end
|
|
461
473
|
raise Unusable, "the consolidator's answer is not an object" unless parsed.is_a?(Hash)
|
|
462
474
|
|
|
463
475
|
parsed
|
|
@@ -492,7 +504,9 @@ module Insika
|
|
|
492
504
|
return nil if ref.nil?
|
|
493
505
|
|
|
494
506
|
provider, model = ExtractorFactory.split_ref(ref)
|
|
495
|
-
factory = ask_factory || ->(m, p) {
|
|
507
|
+
factory = ask_factory || ->(m, p) {
|
|
508
|
+
ExtractorFactory.ruby_llm_ask(m, p, llm: llm, schema: Consolidator::VERDICT_SCHEMA.json_schema)
|
|
509
|
+
}
|
|
496
510
|
Consolidator.new(ask: factory.call(model, provider), model: ref)
|
|
497
511
|
end
|
|
498
512
|
end
|
|
@@ -48,6 +48,7 @@ module Insika
|
|
|
48
48
|
next
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
remember_boot(config, api)
|
|
51
52
|
if set_accessor(config, "#{api}_api_key", key)
|
|
52
53
|
base = fetch(rec, :base_url)
|
|
53
54
|
set_accessor(config, "#{api}_api_base", base) if base && !base.to_s.empty?
|
|
@@ -61,16 +62,43 @@ module Insika
|
|
|
61
62
|
{ applied: applied, skipped: skipped }
|
|
62
63
|
end
|
|
63
64
|
|
|
64
|
-
#
|
|
65
|
-
#
|
|
65
|
+
# Re-applies the stored providers when they changed since THIS process last
|
|
66
|
+
# applied them. The Studio's `apply` reaches only the worker that served the
|
|
67
|
+
# edit; every other worker (and every worker after a restart) gets the
|
|
68
|
+
# provider here. Called per chat build, so it mutates the config only when the
|
|
69
|
+
# store changed, which is rare. Inside a turn the read comes from the
|
|
70
|
+
# ConfigStore cache. A provider this method applied and that no longer has a
|
|
71
|
+
# key (deleted, or its key cleared) is unapplied; a key it never applied, such
|
|
72
|
+
# as one from the environment, is never cleared. A store error keeps the
|
|
73
|
+
# current config: the next call retries, and the chat is not failed for it.
|
|
74
|
+
def refresh
|
|
75
|
+
records = @provider_store.all_raw
|
|
76
|
+
digest = records.hash
|
|
77
|
+
return if digest == @refreshed_digest
|
|
78
|
+
|
|
79
|
+
applied = apply(records)[:applied].map(&:to_s)
|
|
80
|
+
(Array(@refreshed_apis) - applied).each { |api| unapply(api) }
|
|
81
|
+
@refreshed_apis = applied
|
|
82
|
+
@refreshed_digest = digest
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
warn "[insika] LLM providers not refreshed, keeping the current ones: #{e.class}: #{e.message}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# UNDOES a provider's config in RubyLLM at runtime (delete without a restart):
|
|
88
|
+
# `<api>_api_key`/`<api>_api_base` go back to what they were before this
|
|
89
|
+
# configurator first set them (nil when nothing was). A provider that RubyLLM
|
|
66
90
|
# doesn't recognize (no accessor) -> unapplied: false (nothing applied, nothing to undo).
|
|
67
91
|
# -> { unapplied: bool }.
|
|
68
92
|
def unapply(api)
|
|
69
93
|
api = api.to_s
|
|
94
|
+
# never applied here (another worker served the edit): nothing of ours to undo
|
|
95
|
+
return { unapplied: false } unless boot_values.key?(api)
|
|
96
|
+
|
|
70
97
|
unapplied = false
|
|
71
98
|
with_config do |config|
|
|
72
|
-
|
|
73
|
-
set_accessor(config, "#{api}
|
|
99
|
+
key, base = boot_values[api]
|
|
100
|
+
unapplied = set_accessor(config, "#{api}_api_key", key)
|
|
101
|
+
set_accessor(config, "#{api}_api_base", base)
|
|
74
102
|
end
|
|
75
103
|
{ unapplied: unapplied }
|
|
76
104
|
end
|
|
@@ -86,6 +114,20 @@ module Insika
|
|
|
86
114
|
end
|
|
87
115
|
end
|
|
88
116
|
|
|
117
|
+
# The key/base a provider had before this configurator first overrode it (the
|
|
118
|
+
# environment's, or nil), so removing the authored one puts those back instead
|
|
119
|
+
# of leaving the provider without credentials.
|
|
120
|
+
def remember_boot(config, api)
|
|
121
|
+
return if boot_values.key?(api.to_s)
|
|
122
|
+
|
|
123
|
+
boot_values[api.to_s] = %w[key base].map do |part|
|
|
124
|
+
getter = "#{api}_api_#{part}"
|
|
125
|
+
config.public_send(getter) if config.respond_to?(getter)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def boot_values = (@boot_values ||= {})
|
|
130
|
+
|
|
89
131
|
def set_accessor(config, name, value)
|
|
90
132
|
setter = "#{name}="
|
|
91
133
|
return false unless config.respond_to?(setter)
|
|
@@ -38,9 +38,7 @@ module Insika
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
# -> [Hash] all with REAL api_key (for the configurator). Never goes to the screen.
|
|
41
|
-
def all_raw
|
|
42
|
-
apis.filter_map { |a| raw(a) }
|
|
43
|
-
end
|
|
41
|
+
def all_raw = @cs.all(SCOPE).compact
|
|
44
42
|
|
|
45
43
|
# Upsert with secret reconciliation. `attrs` (string|symbol keys):
|
|
46
44
|
# api (required), base_url, auth_header, api_key (sentinel-aware), models[]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Content-free native diagnostics. Task deletion owns their lifetime.
|
|
5
|
+
class LLMTraceStore
|
|
6
|
+
SCOPE = "llm_traces"
|
|
7
|
+
MAX_PER_TASK = 200
|
|
8
|
+
TEXT_FIELDS = %w[type operation agent provider model status request_id exception_class at].freeze
|
|
9
|
+
NUMBER_FIELDS = %w[turn duration_ms input_tokens output_tokens cache_read_tokens cache_write_tokens thinking_tokens cost].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(store:)
|
|
12
|
+
@store = store
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Also used at the telemetry boundary; never stringify arbitrary objects.
|
|
16
|
+
def self.sanitize(entry)
|
|
17
|
+
entry.each_with_object({}) do |(key, value), result|
|
|
18
|
+
if TEXT_FIELDS.include?(key) && value.is_a?(String)
|
|
19
|
+
result[key] = Coercion.utf8(value)[0, 256].gsub(/[[:cntrl:]]/, "")
|
|
20
|
+
elsif NUMBER_FIELDS.include?(key) && (value.nil? ||
|
|
21
|
+
((value.is_a?(Integer) || value.is_a?(Float)) && value.finite? && value >= 0 && value <= 1e18))
|
|
22
|
+
result[key] = value
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def record(task_id:, entry:) = record_many(task_id: task_id, entries: [entry])
|
|
28
|
+
|
|
29
|
+
# A turn's events in one transaction: one read and one write of the trace,
|
|
30
|
+
# instead of one transaction per event.
|
|
31
|
+
def record_many(task_id:, entries:)
|
|
32
|
+
return if entries.empty?
|
|
33
|
+
|
|
34
|
+
@store.transaction do
|
|
35
|
+
next unless @store.get(TaskStore::SCOPE, "#{TaskStore::KEY_PREFIX}#{task_id}")
|
|
36
|
+
|
|
37
|
+
trace = for_task(task_id)
|
|
38
|
+
all = trace["entries"] + entries.map { |entry| self.class.sanitize(entry) }
|
|
39
|
+
@store.set(SCOPE, task_id.to_s, {
|
|
40
|
+
"entries" => all.last(MAX_PER_TASK),
|
|
41
|
+
"truncated" => trace["truncated"] || all.size > MAX_PER_TASK
|
|
42
|
+
})
|
|
43
|
+
end
|
|
44
|
+
rescue StandardError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def for_task(task_id)
|
|
49
|
+
@store.get(SCOPE, task_id.to_s) || { "entries" => [], "truncated" => false }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def clear(task_id) = @store.delete(SCOPE, task_id.to_s)
|
|
53
|
+
end
|
|
54
|
+
end
|