insika 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +199 -5
- data/README.md +8 -2
- data/bin/insika +231 -13
- data/docs/AGENTS.md +505 -6
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +100 -10
- data/docs/CONTEXT.md +147 -19
- data/docs/DEPLOY.md +34 -11
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +20 -1
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +17 -10
- data/docs/OBSERVABILITY.md +65 -2
- data/docs/REFINEMENT.md +9 -9
- data/docs/RELEASING.md +34 -7
- data/docs/RUNNING-LOCAL.md +4 -4
- data/docs/SECURITY.md +85 -11
- data/docs/SKILLS.md +189 -3
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +2 -2
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +228 -26
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +166 -0
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +132 -24
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +80 -6
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +9 -9
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +145 -13
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +6 -4
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/create_agent.rb +2 -2
- data/lib/insika/commands/create_session.rb +1 -1
- data/lib/insika/commands/delete_llm_provider.rb +1 -1
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_tenant_data.rb +95 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +12 -12
- data/lib/insika/commands/import_mcp_tools.rb +1 -1
- data/lib/insika/commands/import_tools.rb +4 -4
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/resolve_refinement.rb +1 -1
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/run_refinement.rb +5 -5
- data/lib/insika/commands/send_message.rb +112 -15
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/set_agent_tools.rb +1 -1
- data/lib/insika/commands/set_skill_agents.rb +60 -19
- data/lib/insika/commands/trigger_workflow.rb +1 -1
- data/lib/insika/commands/update_agent.rb +1 -1
- data/lib/insika/commands/write_data_tool.rb +1 -1
- data/lib/insika/commands/write_golden.rb +1 -1
- data/lib/insika/commands/write_skill.rb +19 -9
- data/lib/insika/config_store.rb +8 -4
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +23 -5
- data/lib/insika/context/fragment.rb +31 -3
- data/lib/insika/context/priority.rb +6 -2
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +30 -2
- data/lib/insika/context/providers/request.rb +1 -1
- data/lib/insika/context/providers/session.rb +17 -2
- data/lib/insika/context/providers/skill.rb +7 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +128 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +962 -7
- data/lib/insika/dsl/runtime.rb +20 -11
- data/lib/insika/dsl/server_boot.rb +74 -4
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +152 -15
- data/lib/insika/edge_limiter.rb +167 -8
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +22 -12
- data/lib/insika/errors.rb +72 -5
- data/lib/insika/evals/assertions.rb +15 -14
- data/lib/insika/evals/baseline.rb +3 -3
- data/lib/insika/evals/golden.rb +8 -8
- data/lib/insika/evals/judge.rb +7 -7
- data/lib/insika/evals/pairwise.rb +21 -9
- data/lib/insika/evals/report.rb +2 -2
- data/lib/insika/evals/runner.rb +6 -6
- data/lib/insika/evals/transport.rb +2 -2
- data/lib/insika/event_stream.rb +23 -5
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +1092 -160
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +221 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/frontmatter.rb +1 -1
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +3 -3
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/http_client.rb +3 -3
- data/lib/insika/inbound_log.rb +1 -1
- data/lib/insika/llm_configurator.rb +3 -3
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +4 -4
- data/lib/insika/mcp_tool_ingestor.rb +6 -6
- data/lib/insika/media.rb +298 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +8 -3
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +8 -3
- data/lib/insika/outbox_store.rb +44 -6
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +3 -4
- data/lib/insika/pack.rb +3 -3
- data/lib/insika/pack_importer.rb +17 -15
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/pending_action_store.rb +1 -1
- data/lib/insika/plugin/loader.rb +2 -2
- data/lib/insika/policy/policy.rb +1 -1
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +34 -7
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +6 -3
- data/lib/insika/recovery.rb +47 -6
- data/lib/insika/refinement/candidate.rb +4 -4
- data/lib/insika/refinement/evidence_collector.rb +6 -6
- data/lib/insika/refinement/gate.rb +7 -7
- data/lib/insika/refinement/panel.rb +7 -7
- data/lib/insika/refinement/proposer.rb +10 -10
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +211 -0
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +46 -6
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +34 -115
- data/lib/insika/safety/factory.rb +18 -5
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +20 -5
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +10 -6
- data/lib/insika/safety/output_validator.rb +13 -7
- data/lib/insika/safety/safe_responses.rb +1 -1
- data/lib/insika/sandbox/boundary.rb +2 -2
- data/lib/insika/sandbox.rb +1 -1
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +366 -54
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +31 -7
- data/lib/insika/server/responses.rb +58 -9
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +11 -7
- data/lib/insika/session_store.rb +66 -3
- data/lib/insika/settings_store.rb +15 -5
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +131 -20
- data/lib/insika/skill_store.rb +70 -22
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/steer_injector.rb +1 -1
- data/lib/insika/store.rb +11 -2
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1058 -75
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +27 -26
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +274 -22
- data/lib/insika/studio/nav_icons.rb +7 -2
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +629 -86
- data/lib/insika/studio/views/agents.erb +11 -7
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +4 -1
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +2 -1
- data/lib/insika/studio/views/layout.erb +1 -0
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +133 -3
- data/lib/insika/studio/views/settings.erb +9 -12
- data/lib/insika/studio/views/skills.erb +66 -12
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +0 -1
- data/lib/insika/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +22 -2
- data/lib/insika/telemetry/pricing.rb +3 -3
- data/lib/insika/telemetry/recorder.rb +1 -1
- data/lib/insika/telemetry.rb +2 -2
- data/lib/insika/testing/store_contract.rb +54 -33
- data/lib/insika/tick.rb +146 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +25 -15
- data/lib/insika/tool_envelope.rb +70 -1
- data/lib/insika/tool_manifest.rb +11 -7
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_store.rb +1 -1
- data/lib/insika/tool_trace_store.rb +1 -1
- data/lib/insika/tools/concurrency.rb +2 -2
- data/lib/insika/tools/data_defined_tool.rb +14 -5
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/load_skill.rb +61 -3
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +4 -4
- data/lib/insika/tools/subagents.rb +1 -1
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +54 -13
- data/lib/insika/turn_timing.rb +24 -4
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +372 -34
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +122 -16
- metadata +95 -2
- data/lib/insika/server/admin_auth.rb +0 -29
data/lib/insika/doctor.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Insika
|
|
4
|
-
# `insika doctor` (
|
|
4
|
+
# `insika doctor` (— OpenClaw's "strict config + doctor --fix",
|
|
5
5
|
# "the repo's best productization discipline"). A read-only diagnosis of a
|
|
6
6
|
# deployment's configuration that turns the scattered, silent boot warnings into
|
|
7
7
|
# ONE structured report — and, with `--fix`, applies the safe autofixes.
|
|
@@ -57,15 +57,89 @@ module Insika
|
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# one entry of the domain inventory. The engine never GUESSES
|
|
61
|
+
# a store (D3) — every entry is something a deployment explicitly declared,
|
|
62
|
+
# or the built-in corpus still in effect (source "gem-default"). A broken
|
|
63
|
+
# enumerator degrades to one error-marked entry, never raises.
|
|
64
|
+
DomainEntry = Data.define(:kind, :agent, :detail, :source, :how_to_clear) do
|
|
65
|
+
def to_h
|
|
66
|
+
{ "kind" => kind, "agent" => agent, "detail" => detail,
|
|
67
|
+
"source" => source, "how_to_clear" => how_to_clear }.compact
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The domain section of the doctor: the E2 proof surface. Read-only
|
|
72
|
+
# and informational — it never fails the exit code (a pilot reporting its
|
|
73
|
+
# own artifacts exits 0).
|
|
74
|
+
DomainReport = Data.define(:generated_at, :gem_version, :entries) do
|
|
75
|
+
def empty? = entries.empty?
|
|
76
|
+
def count = entries.length
|
|
77
|
+
|
|
78
|
+
def to_h
|
|
79
|
+
{ "generated_at" => generated_at, "gem_version" => gem_version,
|
|
80
|
+
"count" => count, "entries" => entries.map(&:to_h) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The human section (prints AFTER the findings — a section, not a finding).
|
|
84
|
+
def to_s
|
|
85
|
+
header =
|
|
86
|
+
if count.zero?
|
|
87
|
+
"domain: 0 artifacts — a bare install declares no store and ships no corpus in use"
|
|
88
|
+
else
|
|
89
|
+
"domain: #{count} artifact(s) — declared by the deployment or inherited from the gem default"
|
|
90
|
+
end
|
|
91
|
+
lines = entries.map do |e|
|
|
92
|
+
agent = e.agent ? "#{e.agent}: " : ""
|
|
93
|
+
source = e.source ? " (#{e.source})" : ""
|
|
94
|
+
clear = e.how_to_clear ? " — clear: #{e.how_to_clear}" : ""
|
|
95
|
+
" [#{e.kind}] #{agent}#{e.detail}#{source}#{clear}"
|
|
96
|
+
end
|
|
97
|
+
([header] + lines).join("\n")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
60
101
|
def initialize(env: ENV, settings_store: nil, llm_provider_store: nil, tool_store: nil,
|
|
61
|
-
agent_file_store: nil,
|
|
102
|
+
agent_file_store: nil, skill_store: nil, skill_catalog: nil,
|
|
103
|
+
profile_source: nil, backend: nil, extra_env_specs: [],
|
|
104
|
+
shadow_pair_store: nil, soak_envelope_path: nil, context_providers: nil,
|
|
105
|
+
memory_store: nil, agent_ids: nil, funnel_store: nil, outcome_store: nil,
|
|
106
|
+
followup_store: nil, contact_store: nil, proposal_store: nil,
|
|
107
|
+
harvest_store: nil, harvest_criterion: nil)
|
|
62
108
|
@env = env
|
|
63
109
|
@settings_store = settings_store
|
|
64
110
|
@llm_provider_store = llm_provider_store
|
|
65
111
|
@tool_store = tool_store
|
|
66
112
|
@agent_file_store = agent_file_store
|
|
113
|
+
@skill_store = skill_store
|
|
114
|
+
@skill_catalog = skill_catalog
|
|
115
|
+
@profile_source = profile_source
|
|
67
116
|
@backend = backend
|
|
68
117
|
@extra_env_specs = extra_env_specs
|
|
118
|
+
@shadow_pair_store = shadow_pair_store
|
|
119
|
+
@soak_envelope_path = soak_envelope_path
|
|
120
|
+
# [ContextProvider | Class] — classes accepted so the CLI
|
|
121
|
+
# can pass the builtin set without deps; the check only reads .layer /
|
|
122
|
+
# .name. nil = skip.
|
|
123
|
+
@context_providers = context_providers
|
|
124
|
+
# the memory-scopes check — Insika::MemoryStore | nil = skip;
|
|
125
|
+
# agent_ids excuse bare cells named like an agent (the agent-memory tab's).
|
|
126
|
+
@memory_store = memory_store
|
|
127
|
+
@agent_ids = Array(agent_ids).map(&:to_s)
|
|
128
|
+
# the outcome-funnel check — nil collaborators = the check
|
|
129
|
+
# reports declarations only (env-only callers stay cheap).
|
|
130
|
+
@funnel_store = funnel_store
|
|
131
|
+
@outcome_store = outcome_store
|
|
132
|
+
# the follow-up check — nil collaborators = the check
|
|
133
|
+
# reports declarations only.
|
|
134
|
+
@followup_store = followup_store
|
|
135
|
+
@contact_store = contact_store
|
|
136
|
+
# the distillation check — nil collaborator = the check
|
|
137
|
+
# reports declarations only (counts skipped).
|
|
138
|
+
@proposal_store = proposal_store
|
|
139
|
+
# the harvest check — nil collaborators = the check
|
|
140
|
+
# reports declarations only (counts skipped).
|
|
141
|
+
@harvest_store = harvest_store
|
|
142
|
+
@harvest_criterion = harvest_criterion
|
|
69
143
|
end
|
|
70
144
|
|
|
71
145
|
# -> Report. Never raises (a broken check degrades to an :error Finding).
|
|
@@ -80,11 +154,25 @@ module Insika
|
|
|
80
154
|
[before, run]
|
|
81
155
|
end
|
|
82
156
|
|
|
157
|
+
# — the domain inventory: what a deployment declares (personas
|
|
158
|
+
# with a metadata.domain tag, outcome funnels, tool evidence) plus the
|
|
159
|
+
# built-in pt-BR corpora still in effect. Read-only: informational, never
|
|
160
|
+
# a gate — a pilot reporting its own artifacts must exit 0.
|
|
161
|
+
def domain
|
|
162
|
+
entries = %i[personas corpora funnels evidence].flat_map { |section| safe_domain(section) }.compact
|
|
163
|
+
DomainReport.new(generated_at: Time.now.utc.iso8601,
|
|
164
|
+
gem_version: Insika::VERSION,
|
|
165
|
+
entries: entries.freeze)
|
|
166
|
+
end
|
|
167
|
+
|
|
83
168
|
private
|
|
84
169
|
|
|
85
170
|
def checks = %i[check_env check_settings_schema check_default_model check_db check_llm_provider
|
|
86
171
|
check_admin_token check_data_tools check_prompt_files check_relay_channel
|
|
87
|
-
check_web_widget
|
|
172
|
+
check_web_widget check_skill_eager check_skill_drift check_shadow_parity
|
|
173
|
+
check_soak_envelope check_turn_timing check_grounding check_cache_layers
|
|
174
|
+
check_memory_scopes check_funnel_declarations check_followup check_distill
|
|
175
|
+
check_harvest check_guardrail_corpora]
|
|
88
176
|
|
|
89
177
|
def safe(check)
|
|
90
178
|
Array(send(check))
|
|
@@ -93,6 +181,99 @@ module Insika
|
|
|
93
181
|
[Finding.new(check: id, severity: :error, message: "check crashed: #{e.class}: #{e.message}", fix: nil)]
|
|
94
182
|
end
|
|
95
183
|
|
|
184
|
+
# one domain section, wrapped — a broken read degrades to one
|
|
185
|
+
# error-marked entry, never raises (the same discipline as `safe`).
|
|
186
|
+
def safe_domain(section)
|
|
187
|
+
send(:"domain_#{section}")
|
|
188
|
+
rescue StandardError => e
|
|
189
|
+
[DomainEntry.new(kind: "error", agent: nil,
|
|
190
|
+
detail: "#{section}: read failed — #{e.class}: #{e.message}",
|
|
191
|
+
source: nil, how_to_clear: nil)]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# 1/4 personas/packs — `profile.metadata["domain"]` (pack `agent.config.json`
|
|
195
|
+
# passes it through; the DSL sets it via `metadata domain: "…"`).
|
|
196
|
+
def domain_personas
|
|
197
|
+
return [] unless @profile_source
|
|
198
|
+
|
|
199
|
+
@profile_source.all.filter_map do |p|
|
|
200
|
+
m = p.metadata
|
|
201
|
+
tag = m && m["domain"]
|
|
202
|
+
next if !Coercion.present?(tag)
|
|
203
|
+
|
|
204
|
+
DomainEntry.new(kind: "persona", agent: p.id, detail: "domain=#{tag}",
|
|
205
|
+
source: "deployment", how_to_clear: nil)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# 2/4 corpora — the built-in pt-BR corpus still in effect (gem default) and
|
|
210
|
+
# the built-in pt-BR safe replies that are not fully overridden.
|
|
211
|
+
def domain_corpora
|
|
212
|
+
return [] unless @profile_source
|
|
213
|
+
|
|
214
|
+
@profile_source.all.each_with_object([]) do |p, acc|
|
|
215
|
+
config = Insika::Safety::Config.from_profile(p)
|
|
216
|
+
next unless config.enabled?
|
|
217
|
+
|
|
218
|
+
if config.corpus_languages.include?("pt-BR")
|
|
219
|
+
acc << DomainEntry.new(kind: "guardrail-corpus", agent: p.id,
|
|
220
|
+
detail: "languages=#{config.corpus_languages.join(',')}",
|
|
221
|
+
source: "gem-default",
|
|
222
|
+
how_to_clear: "docs/domain.md#guardrails")
|
|
223
|
+
end
|
|
224
|
+
fallback = builtin_response_categories(config)
|
|
225
|
+
next if fallback.empty?
|
|
226
|
+
|
|
227
|
+
acc << DomainEntry.new(kind: "safe-responses", agent: p.id,
|
|
228
|
+
detail: "categories=#{fallback.join(',')}",
|
|
229
|
+
source: "gem-default",
|
|
230
|
+
how_to_clear: "docs/domain.md#guardrails")
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# The safe-reply categories that still resolve to the built-in pt-BR
|
|
235
|
+
# DEFAULTS (a category the agent overrode — or a catch-all `default` that
|
|
236
|
+
# replaces every category — contributes none).
|
|
237
|
+
def builtin_response_categories(config)
|
|
238
|
+
Insika::Safety::SafeResponses::DEFAULTS.keys.select do |cat|
|
|
239
|
+
Insika::Safety::SafeResponses.for(cat, overrides: config.responses) == Insika::Safety::SafeResponses::DEFAULTS[cat]
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# 3/4 funnels — the declaration shape. Absent in the tree (or on a
|
|
244
|
+
# profile) -> absent in the report: a bare install shows no funnel and no
|
|
245
|
+
# stage names at all (the vocabulary note).
|
|
246
|
+
def domain_funnels
|
|
247
|
+
return [] unless @profile_source
|
|
248
|
+
|
|
249
|
+
@profile_source.all.filter_map do |p|
|
|
250
|
+
next unless p.respond_to?(:funnel) && p.funnel.is_a?(Hash) && !p.funnel.empty?
|
|
251
|
+
|
|
252
|
+
stages = Array(p.funnel["stages"]).join(",")
|
|
253
|
+
primary = p.funnel["primary"]
|
|
254
|
+
detail = +"stages=#{stages}"
|
|
255
|
+
detail << ", primary=#{primary}" if Coercion.present?(primary)
|
|
256
|
+
DomainEntry.new(kind: "funnel", agent: p.id, detail: detail,
|
|
257
|
+
source: "deployment", how_to_clear: nil)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# 4/4 evidence — tool manifests carrying the `evidence: <kind>`
|
|
262
|
+
# vocabulary. Kinds are pack vocabulary, never gem constants.
|
|
263
|
+
def domain_evidence
|
|
264
|
+
return [] unless @tool_store
|
|
265
|
+
|
|
266
|
+
@tool_store.all_raw.filter_map do |raw|
|
|
267
|
+
ev = raw["evidence"]
|
|
268
|
+
kind = ev.is_a?(Hash) ? ev["kind"] : ev
|
|
269
|
+
next if !Coercion.present?(kind)
|
|
270
|
+
|
|
271
|
+
DomainEntry.new(kind: "evidence", agent: nil,
|
|
272
|
+
detail: "#{raw["name"]}: #{kind}",
|
|
273
|
+
source: "deployment", how_to_clear: nil)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
96
277
|
# -- checks --------------------------------------------------------
|
|
97
278
|
|
|
98
279
|
def check_env
|
|
@@ -155,7 +336,35 @@ module Insika
|
|
|
155
336
|
message: "ADMIN_TOKEN unset — /studio is fail-closed (login denied) and the gateway has no fallback token", fix: nil)]
|
|
156
337
|
end
|
|
157
338
|
|
|
158
|
-
#
|
|
339
|
+
# the soak envelope (a deployment-side file). Absent is :info — not every
|
|
340
|
+
# deployment soaks, and the tooling is optional. Present-and-broken is
|
|
341
|
+
# :error — an envelope that exists but does not parse is a pre-declaration
|
|
342
|
+
# somebody wrote down that the runner will refuse, and only this check says
|
|
343
|
+
# so before the run is attempted.
|
|
344
|
+
def check_soak_envelope
|
|
345
|
+
return [Finding.new(check: "soak-envelope", severity: :info, fix: nil,
|
|
346
|
+
message: "no soak envelope (#{@soak_envelope_path || 'unset'}) — `insika soak` is available, not required")] if @soak_envelope_path.nil? || !File.file?(@soak_envelope_path)
|
|
347
|
+
|
|
348
|
+
Insika::Soak::Envelope.load(@soak_envelope_path)
|
|
349
|
+
[ok("soak-envelope", "soak envelope parses (#{@soak_envelope_path})")]
|
|
350
|
+
rescue Insika::ConfigError => e
|
|
351
|
+
[Finding.new(check: "soak-envelope", severity: :error, fix: nil,
|
|
352
|
+
message: "soak envelope present but broken: #{e.message}")]
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# the soak's prep_p95 gate needs INSIKA_TURN_TIMING on the
|
|
356
|
+
# target. Off is :info normally, and the message names what a soak would
|
|
357
|
+
# refuse — the preflight failure must never be a surprise found 72 hours in.
|
|
358
|
+
def check_turn_timing
|
|
359
|
+
if Insika::EnvSchema.truthy?(Insika::EnvSchema.read("INSIKA_TURN_TIMING", @env))
|
|
360
|
+
[ok("turn-timing", "INSIKA_TURN_TIMING on — the soak's prep_p95 gate is measurable")]
|
|
361
|
+
else
|
|
362
|
+
[Finding.new(check: "turn-timing", severity: :info, fix: nil,
|
|
363
|
+
message: "INSIKA_TURN_TIMING off — a soak would refuse at preflight (P2: no timing block)")]
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# A half-configured relay is the silent kind of broken: with only
|
|
159
368
|
# the deliver URL set, nothing is mounted and every inbound POST 404s; with only
|
|
160
369
|
# the token, the engine accepts turns it can never answer and the customer waits
|
|
161
370
|
# forever on a reply that is sitting in the outbox. Both halves or neither.
|
|
@@ -176,7 +385,7 @@ module Insika
|
|
|
176
385
|
end
|
|
177
386
|
|
|
178
387
|
# The widget is the one PUBLIC channel, so its misconfigurations are the ones that
|
|
179
|
-
# cost money rather than just failing
|
|
388
|
+
# cost money rather than just failing. Three of them, in the
|
|
180
389
|
# order they bite: half the switch set (mounted nowhere, or mounted addressing
|
|
181
390
|
# nothing), and a mount with no chat rate limit anywhere — which the channel
|
|
182
391
|
# refuses with a 503 rather than opening, but which reads to an operator as "the
|
|
@@ -202,6 +411,247 @@ module Insika
|
|
|
202
411
|
"INSIKA_WIDGET_AGENTS sets limits.chat_rate_limit (a public channel with no ceiling is not served)", fix: nil)]
|
|
203
412
|
end
|
|
204
413
|
|
|
414
|
+
# Eagerness moved from the SKILL.md frontmatter to `profile.skills_eager`, because
|
|
415
|
+
# skills are SHARED and a flag on the skill forced one decision onto every agent
|
|
416
|
+
# holding it. The parser now ignores `eager:` — which is the quiet kind of upgrade:
|
|
417
|
+
# nothing crashes, the body simply stops being in the prompt. This check is the
|
|
418
|
+
# only thing that says so. Its sibling half is the opposite mistake: a name in
|
|
419
|
+
# `skills_eager` that the agent's `skills` allowlist does not contain is
|
|
420
|
+
# intersected away at runtime, so the operator's intent evaporates in silence.
|
|
421
|
+
def check_skill_eager
|
|
422
|
+
findings = stale_eager_frontmatter + unreachable_eager_names
|
|
423
|
+
return findings if findings.any?
|
|
424
|
+
return [] unless @skill_store || @skill_catalog || @profile_source
|
|
425
|
+
|
|
426
|
+
[ok("skill-eager", "skill eagerness: per-agent (profile.skills_eager), no stale frontmatter")]
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def stale_eager_frontmatter
|
|
430
|
+
skill_sources.filter_map do |name, content|
|
|
431
|
+
next unless frontmatter_of(content).key?("eager")
|
|
432
|
+
|
|
433
|
+
Finding.new(check: "skill-eager", severity: :warn, fix: nil,
|
|
434
|
+
message: "skill '#{name}' still declares `eager:` in its frontmatter — the key is IGNORED. " \
|
|
435
|
+
"Eagerness is per-agent now: put the name in that agent's `skills_eager` " \
|
|
436
|
+
"(Studio > Skills, or `skills_eager \"#{name}\"` in the DSL).")
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def unreachable_eager_names
|
|
441
|
+
return [] unless @profile_source
|
|
442
|
+
|
|
443
|
+
@profile_source.all.flat_map do |profile|
|
|
444
|
+
eager = profile.skills_eager
|
|
445
|
+
next [] unless eager.is_a?(Array)
|
|
446
|
+
next [] if profile.skills.nil? # nil = every skill allowed, so nothing is unreachable
|
|
447
|
+
|
|
448
|
+
allowed = Array(profile.skills).map(&:to_s)
|
|
449
|
+
(eager.map(&:to_s) - allowed).map do |name|
|
|
450
|
+
Finding.new(check: "skill-eager", severity: :warn, fix: nil,
|
|
451
|
+
message: "agent '#{profile.id}' marks skill '#{name}' eager but does not allow it — " \
|
|
452
|
+
"the name is a no-op. Add it to the agent's skills, or drop it from skills_eager.")
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# { name => raw SKILL.md } across BOTH sources the runtime reads: the disk roots
|
|
458
|
+
# (seed, via the catalog) and the authored store (wins — same precedence as
|
|
459
|
+
# SkillCatalog). The checks parse frontmatter the catalog deliberately drops
|
|
460
|
+
# (`eager:`), so they need the raw text; for a disk skill that is the file itself,
|
|
461
|
+
# and a store-overlaid skill carries a sentinel path that is not a file.
|
|
462
|
+
def skill_sources
|
|
463
|
+
@skill_sources ||= disk_skill_sources.merge(@skill_store ? @skill_store.all : {})
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def disk_skill_sources
|
|
467
|
+
return {} unless @skill_catalog
|
|
468
|
+
|
|
469
|
+
@skill_catalog.all.each_with_object({}) do |skill, acc|
|
|
470
|
+
acc[skill.name] = File.read(skill.path, encoding: "UTF-8") if File.file?(skill.path.to_s)
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# The frontmatter block only; a `eager:` line in the BODY is prose, not config.
|
|
475
|
+
def frontmatter_of(content)
|
|
476
|
+
match = content.to_s.match(/\A---\s*\n(.*?)\n---\s*\n/m)
|
|
477
|
+
match ? Insika::Frontmatter.parse(match[1]) : {}
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Drift between a pack's PROSE and the catalog. Three ways it happened on the
|
|
481
|
+
# pilot, all silent, all found by reading a customer conversation afterwards.
|
|
482
|
+
#
|
|
483
|
+
# Every check here takes MECHANICAL inputs only — skill names, allowlists, agent
|
|
484
|
+
# identities. A check that has to parse prose ("this paragraph declares a count of
|
|
485
|
+
# six") false-positives on the first real pack and takes the doctor's credibility
|
|
486
|
+
# with it, which costs more than the drift it caught.
|
|
487
|
+
def check_skill_drift
|
|
488
|
+
return [] unless (@skill_store || @skill_catalog) && @profile_source
|
|
489
|
+
|
|
490
|
+
findings = prompt_files_naming_unallowed_skills + shared_skills_naming_a_holder + broken_companions
|
|
491
|
+
return findings if findings.any?
|
|
492
|
+
|
|
493
|
+
[ok("skill-drift", "skill references: prompt files, shared bodies and companions all consistent")]
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# D1 residue. The routing table is GENERATED now (SkillCatalog#format_for_prompt
|
|
497
|
+
# renders each skill with its triggers), so the hand-written companion has no
|
|
498
|
+
# reason to exist — but a pack that still carries one keeps instructing the model
|
|
499
|
+
# about skills the agent cannot load. Skill names are known ids, so this is a grep.
|
|
500
|
+
def prompt_files_naming_unallowed_skills
|
|
501
|
+
return [] unless @agent_file_store
|
|
502
|
+
|
|
503
|
+
catalog = skill_sources.keys
|
|
504
|
+
@profile_source.all.flat_map do |profile|
|
|
505
|
+
next [] if profile.skills.nil? # nil = everything allowed, nothing to be outside of
|
|
506
|
+
|
|
507
|
+
allowed = Array(profile.skills).map(&:to_s)
|
|
508
|
+
orphans = catalog - allowed
|
|
509
|
+
next [] if orphans.empty?
|
|
510
|
+
|
|
511
|
+
@agent_file_store.list(profile.id).flat_map do |file|
|
|
512
|
+
body = @agent_file_store.read(profile.id, file).to_s
|
|
513
|
+
orphans.select { |name| mentions?(body, name) }.map do |name|
|
|
514
|
+
Finding.new(check: "skill-drift", severity: :warn, fix: nil,
|
|
515
|
+
message: "agent '#{profile.id}' file '#{file}' names skill '#{name}', which is NOT in its " \
|
|
516
|
+
"skills allowlist — the model is being told to use something it cannot load. " \
|
|
517
|
+
"Allow the skill, or drop the reference (the skill table is generated).")
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# D2. A skill in more than one allowlist that names one of its OWN holders in its
|
|
524
|
+
# text is specialized text in shared clothing — a pilot deployment served every
|
|
525
|
+
# holder three shared skills that each named one of them. Specialize it per agent
|
|
526
|
+
# (write_skill with `agent:`) instead of leaving one store's policy in a shared body.
|
|
527
|
+
#
|
|
528
|
+
# Merchant vocabulary beyond the holders' identities is deliberately out of scope:
|
|
529
|
+
# there is no mechanical source for it.
|
|
530
|
+
def shared_skills_naming_a_holder
|
|
531
|
+
holders = skill_holders
|
|
532
|
+
specialized = specialized_by
|
|
533
|
+
skill_sources.flat_map do |name, content|
|
|
534
|
+
owners = holders[name].to_a
|
|
535
|
+
next [] if owners.length < 2
|
|
536
|
+
|
|
537
|
+
# Who still READS this shared body: a holder with its own version reads that
|
|
538
|
+
# instead, so it stops being a victim — but its identity in the shared text
|
|
539
|
+
# keeps poisoning whoever is left. Identity comes from ALL holders; only the
|
|
540
|
+
# readers shrink as specializations land, so the finding clears when the last
|
|
541
|
+
# victim stops reading, never merely because the named holder moved out.
|
|
542
|
+
readers = owners - specialized[name].to_a
|
|
543
|
+
owners.flat_map do |owner|
|
|
544
|
+
victims = (readers - [owner]).sort
|
|
545
|
+
next [] if victims.empty?
|
|
546
|
+
|
|
547
|
+
identity_terms(owner).select { |term| mentions?(content, term) }.map do |term|
|
|
548
|
+
Finding.new(check: "skill-drift", severity: :warn, fix: nil,
|
|
549
|
+
message: "shared skill '#{name}' names '#{term}', the identity of '#{owner}' — " \
|
|
550
|
+
"#{victims.join(', ')} read(s) #{owner}'s text as their own. Specialize " \
|
|
551
|
+
"'#{owner}' if it needs that text, and remove it from the shared body.")
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# D3 residue, the half `companions:` cannot prevent: a body that points at another
|
|
558
|
+
# catalog skill without declaring it (so the pair can still break apart), and a
|
|
559
|
+
# declared companion an agent is not allowed to load (so the pair breaks for THAT
|
|
560
|
+
# agent, silently, since the engine will not widen an allowlist on its own).
|
|
561
|
+
def broken_companions
|
|
562
|
+
names = skill_sources.keys
|
|
563
|
+
undeclared = skill_sources.flat_map do |name, content|
|
|
564
|
+
declared = declared_companions(content)
|
|
565
|
+
(names - [name] - declared).select { |other| mentions?(body_of(content), other) }.map do |other|
|
|
566
|
+
Finding.new(check: "skill-drift", severity: :warn, fix: nil,
|
|
567
|
+
message: "skill '#{name}' references skill '#{other}' in its body without declaring it a " \
|
|
568
|
+
"companion — the two can arrive apart, and half a recipe is worse than none. " \
|
|
569
|
+
"Add `companions: [#{other}]` to '#{name}'.")
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
undeclared + companions_outside_allowlists
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Same lenient reading as SkillCatalog#parse_list: a YAML list, or the whole value
|
|
576
|
+
# as one comma-separated String when the tolerant parser had to fall back.
|
|
577
|
+
def declared_companions(content)
|
|
578
|
+
raw = frontmatter_of(content)["companions"]
|
|
579
|
+
Array(raw).flat_map { |c| c.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def companions_outside_allowlists
|
|
583
|
+
declared = skill_sources.each_with_object({}) do |(name, content), acc|
|
|
584
|
+
list = declared_companions(content)
|
|
585
|
+
acc[name] = list unless list.empty?
|
|
586
|
+
end
|
|
587
|
+
return [] if declared.empty?
|
|
588
|
+
|
|
589
|
+
@profile_source.all.flat_map do |profile|
|
|
590
|
+
next [] if profile.skills.nil?
|
|
591
|
+
|
|
592
|
+
allowed = Array(profile.skills).map(&:to_s)
|
|
593
|
+
declared.flat_map do |name, companions|
|
|
594
|
+
next [] unless allowed.include?(name)
|
|
595
|
+
|
|
596
|
+
(companions - allowed).map do |missing|
|
|
597
|
+
Finding.new(check: "skill-drift", severity: :warn, fix: nil,
|
|
598
|
+
message: "agent '#{profile.id}' allows skill '#{name}' but not its companion '#{missing}' — " \
|
|
599
|
+
"the pair cannot travel together for this agent. Allow '#{missing}' too.")
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# { skill name => [agent ids that have their OWN version] }.
|
|
606
|
+
def specialized_by
|
|
607
|
+
return {} unless @skill_store.respond_to?(:agents)
|
|
608
|
+
|
|
609
|
+
@skill_store.agents.each_with_object({}) do |agent, acc|
|
|
610
|
+
@skill_store.names(agent: agent).each { |name| (acc[name] ||= []) << agent }
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# { skill name => Set(agent ids that allow it explicitly) }. An agent with
|
|
615
|
+
# skills=nil allows everything and is not a "holder": it says nothing about which
|
|
616
|
+
# skills were meant to be shared.
|
|
617
|
+
def skill_holders
|
|
618
|
+
@profile_source.all.each_with_object({}) do |profile, acc|
|
|
619
|
+
next if profile.skills.nil?
|
|
620
|
+
|
|
621
|
+
Array(profile.skills).each { |name| (acc[name.to_s] ||= []) << profile.id }
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
# An agent's identity as WORDS: the distinctive tokens of its id plus whatever it
|
|
626
|
+
# calls itself in metadata. Structural tokens (agent/store/bot/…) are dropped and
|
|
627
|
+
# short ones ignored — "store" appears in every retail skill ever written, and one
|
|
628
|
+
# false positive is enough for an operator to stop reading the doctor.
|
|
629
|
+
IDENTITY_STOPWORDS = %w[agent agente store shop loja bot assistant assistente atendimento
|
|
630
|
+
prod staging demo test main default].freeze
|
|
631
|
+
|
|
632
|
+
def identity_terms(agent_id)
|
|
633
|
+
profile = @profile_source.fetch(agent_id)
|
|
634
|
+
meta = profile ? (profile.metadata || {}) : {}
|
|
635
|
+
raw = [agent_id.to_s.split(/[-_.\s]+/), meta["name"], meta["display_name"], meta["store_name"]]
|
|
636
|
+
raw.flatten.compact.map { |t| t.to_s.strip }
|
|
637
|
+
.reject { |t| t.length < 4 || IDENTITY_STOPWORDS.include?(t.downcase) }
|
|
638
|
+
.uniq
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# Whole-word, case- and accent-insensitive — the same reading the trigger matcher
|
|
642
|
+
# uses, for the same reason: a substring hit inside a longer word is a false
|
|
643
|
+
# positive, and one of those is enough to lose the operator.
|
|
644
|
+
def mentions?(text, term)
|
|
645
|
+
needle = fold(term)
|
|
646
|
+
return false if needle.empty?
|
|
647
|
+
|
|
648
|
+
/(?<![[:alnum:]])#{Regexp.escape(needle)}(?![[:alnum:]])/.match?(fold(text))
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def fold(text) = text.to_s.unicode_normalize(:nfd).gsub(/\p{Mn}/, "").downcase
|
|
652
|
+
|
|
653
|
+
def body_of(content) = content.to_s.sub(/\A---\s*\n.*?\n---\s*\n/m, "")
|
|
654
|
+
|
|
205
655
|
def platform_chat_rate_limit
|
|
206
656
|
value = ((@settings_store&.get || {})["edge"] || {})["chat_rate_limit"]
|
|
207
657
|
value.to_i.positive?
|
|
@@ -251,6 +701,436 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
|
|
|
251
701
|
broken
|
|
252
702
|
end
|
|
253
703
|
|
|
704
|
+
# the doctor cannot run a turn, so it cannot prove purity — it
|
|
705
|
+
# CAN verify the declaration. An identity-layer provider that is not one of
|
|
706
|
+
# the engine's three known-safe classes is :warn; one of the engine's
|
|
707
|
+
# known-volatile classes overriding to :identity is :error (a volatile
|
|
708
|
+
# identity block bills a cache write every turn).
|
|
709
|
+
IDENTITY_BUILTINS = %w[
|
|
710
|
+
Insika::Context::Providers::Prompt
|
|
711
|
+
Insika::Context::Providers::Skill
|
|
712
|
+
Insika::Context::Providers::ToolSearch
|
|
713
|
+
].freeze
|
|
714
|
+
VOLATILE_BUILTINS = %w[
|
|
715
|
+
Insika::Context::Providers::Request
|
|
716
|
+
Insika::Context::Providers::Session
|
|
717
|
+
Insika::Context::Providers::Memory
|
|
718
|
+
Insika::Context::Providers::SkillTrigger
|
|
719
|
+
].freeze
|
|
720
|
+
|
|
721
|
+
def check_cache_layers
|
|
722
|
+
return [] unless @context_providers
|
|
723
|
+
|
|
724
|
+
findings = @context_providers.flat_map do |p|
|
|
725
|
+
name = p.is_a?(Class) ? p.name : p.class.name
|
|
726
|
+
layer = declared_layer(p)
|
|
727
|
+
next [] unless layer == :identity
|
|
728
|
+
next [] if builtin_of?(p, IDENTITY_BUILTINS)
|
|
729
|
+
|
|
730
|
+
known_volatile = builtin_of?(p, VOLATILE_BUILTINS)
|
|
731
|
+
[Finding.new(check: "cache-layers", severity: known_volatile ? :error : :warn, fix: nil,
|
|
732
|
+
message: "context provider '#{name}' declares layer :identity but is " \
|
|
733
|
+
"#{known_volatile ? 'engine-known turn-dependent' : 'not engine-verified'} — " \
|
|
734
|
+
"a volatile block above the cache boundary bills a cache WRITE every turn. " \
|
|
735
|
+
"Verify the output is byte-stable across turns (no timestamps, no per-turn data).")]
|
|
736
|
+
end
|
|
737
|
+
return findings if findings.any?
|
|
738
|
+
|
|
739
|
+
[ok("cache-layers", "context layers: #{@context_providers.size} provider(s), identity partition verified")]
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
# The declaration is an INSTANCE method, so a class passed as-is does not
|
|
743
|
+
# respond to .layer. Evaluate it on a bare instance (allocate skips
|
|
744
|
+
# initialize — the declaration must not depend on constructor state; a
|
|
745
|
+
# method that does degrades to :volatile, the conservative side). This is
|
|
746
|
+
# what makes an explicit `def layer = :volatile` read as volatile instead of
|
|
747
|
+
# a warning.
|
|
748
|
+
def declared_layer(provider)
|
|
749
|
+
return provider.layer unless provider.is_a?(Class)
|
|
750
|
+
return :volatile unless provider.instance_methods.include?(:layer)
|
|
751
|
+
|
|
752
|
+
provider.instance_method(:layer).bind_call(provider.allocate)
|
|
753
|
+
rescue StandardError
|
|
754
|
+
:volatile
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
# The declaration check is by CLASS: a subclass of an engine-known provider
|
|
758
|
+
# inherits its data source (a Memory subclass is still turn-dependent no
|
|
759
|
+
# matter what it overrides). Accepts instances OR classes; anonymous
|
|
760
|
+
# classes (Class.new(...)) have no .name, so the ancestor walk is what
|
|
761
|
+
# catches them.
|
|
762
|
+
def builtin_of?(provider, consts)
|
|
763
|
+
klass_of = provider.is_a?(Class) ? provider : provider.class
|
|
764
|
+
|
|
765
|
+
consts.any? do |c|
|
|
766
|
+
klass = Object.const_get(c)
|
|
767
|
+
klass_of == klass || klass_of < klass
|
|
768
|
+
rescue NameError
|
|
769
|
+
false
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
# grounding with a matcher that matches NOTHING (no sku) is
|
|
774
|
+
# harmless but useless — every claim passes and the audit reads zero. A
|
|
775
|
+
# warning, never an error: the pack owns matcher quality; the engine refuses
|
|
776
|
+
# only uncompileable data.
|
|
777
|
+
def check_grounding
|
|
778
|
+
return [] unless @profile_source
|
|
779
|
+
|
|
780
|
+
findings = @profile_source.all.flat_map do |profile|
|
|
781
|
+
grounding = profile.respond_to?(:grounding) ? profile.grounding : nil
|
|
782
|
+
next [] if grounding.nil? || grounding == false
|
|
783
|
+
next [] if Coercion.present?(grounding["matcher"].is_a?(Hash) ? grounding["matcher"]["sku"] : nil)
|
|
784
|
+
|
|
785
|
+
[Finding.new(check: "grounding", severity: :warn, fix: nil,
|
|
786
|
+
message: "agent '#{profile.id}' has grounding enabled but no matcher.sku — " \
|
|
787
|
+
"it matches nothing, so no claim is ever flagged or cut. " \
|
|
788
|
+
"Add the store's SKU regex to grounding.matcher.sku.")]
|
|
789
|
+
end
|
|
790
|
+
findings.empty? ? [ok("grounding", "grounding: no agent with an empty matcher")] : findings
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# the memory-scopes check. Reads the cells via C1's enumeration.
|
|
794
|
+
# Warn-only: the doctor never moves data across cells (D2).
|
|
795
|
+
#
|
|
796
|
+
# What it flags, and what it deliberately does NOT:
|
|
797
|
+
# - "memory:chat:<session id>" cells are the engine's OWN per-session shape
|
|
798
|
+
# — never flagged.
|
|
799
|
+
# - a BARE cell is the DESIGNED single-tenant customer shape ("nil tenant +
|
|
800
|
+
# customer -> memory:<customer>, NEVER _default") — with no tenant to
|
|
801
|
+
# migrate to, warning is a false positive, so the check only fires in a
|
|
802
|
+
# multi_tenant deployment (INSIKA_TENANCY), where every customer must
|
|
803
|
+
# live in a [tenant:]customer cell.
|
|
804
|
+
# - `agent_ids:` excuses the agent-memory tab's cells (a bare cell named
|
|
805
|
+
# like an agent is a profile, not a customer).
|
|
806
|
+
def check_memory_scopes
|
|
807
|
+
return [] unless @memory_store
|
|
808
|
+
|
|
809
|
+
findings = []
|
|
810
|
+
@memory_store.cells.each do |cell|
|
|
811
|
+
next if Insika::MemoryStore.session_cell?(cell) # the engine's per-session cell
|
|
812
|
+
next if cell[:customer].nil? # _default — the shared cell
|
|
813
|
+
next if cell[:tenant] # [tenant:]customer — scoped
|
|
814
|
+
next if single_tenant? # bare = the designed single-tenant customer shape
|
|
815
|
+
next if @agent_ids.include?(cell[:customer].to_s)
|
|
816
|
+
|
|
817
|
+
findings << Finding.new(check: "memory-scopes", severity: :warn, fix: nil,
|
|
818
|
+
message: "memory cell '#{cell[:scope]}' is unscoped — in a multi-tenant " \
|
|
819
|
+
"deployment customer memory must live in a [tenant:]customer cell. " \
|
|
820
|
+
"Migrate it, or confirm it is an agent-memory cell " \
|
|
821
|
+
"(a bare cell named like an agent is excused).")
|
|
822
|
+
end
|
|
823
|
+
return findings if findings.any?
|
|
824
|
+
|
|
825
|
+
[ok("memory-scopes", "memory cells: #{@memory_store.cells.size}, all scoped")]
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
def single_tenant?
|
|
829
|
+
Insika::EnvSchema.read("INSIKA_TENANCY", @env) != "multi_tenant"
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# the outcome-funnel check — declarations on every pilot store,
|
|
833
|
+
# the plan's "no 1.0 target without a remeasure" backstop. Warn/error only:
|
|
834
|
+
# the doctor never rewrites a declaration (the pack is authoritative;
|
|
835
|
+
# `--fix` has nothing to fix here).
|
|
836
|
+
def check_funnel_declarations
|
|
837
|
+
return [] unless @profile_source
|
|
838
|
+
|
|
839
|
+
findings = @profile_source.all.flat_map do |profile|
|
|
840
|
+
next [] if profile.funnel.nil?
|
|
841
|
+
|
|
842
|
+
decl = Insika::FunnelDeclaration.parse(profile.funnel)
|
|
843
|
+
if decl.nil?
|
|
844
|
+
# D8: the fold skips this agent until it is fixed — the doctor is the
|
|
845
|
+
# only report of why.
|
|
846
|
+
[Finding.new(check: "outcome-funnel", severity: :error, fix: nil,
|
|
847
|
+
message: "agent '#{profile.id}': malformed funnel declaration — " \
|
|
848
|
+
"#{funnel_defect(profile.funnel)}. The fold skips it " \
|
|
849
|
+
"until this is fixed.")]
|
|
850
|
+
else
|
|
851
|
+
[ok("outcome-funnel",
|
|
852
|
+
"agent '#{profile.id}': outcome funnel declared — #{decl.stages.length} " \
|
|
853
|
+
"stages, primary '#{decl.primary}', window #{decl.attribution_window}")] +
|
|
854
|
+
funnel_data_findings(profile, decl)
|
|
855
|
+
end
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
# visibility is the feature — a profile with outcomes but no
|
|
859
|
+
# funnel shows the hole. Warn only; the pack owns the vocabulary.
|
|
860
|
+
without = outcomes_without_funnel
|
|
861
|
+
without.each do |agent, count|
|
|
862
|
+
findings << Finding.new(check: "outcome-funnel", severity: :warn, fix: nil,
|
|
863
|
+
message: "agent '#{agent}' records #{count} outcomes and " \
|
|
864
|
+
"declares no funnel: nothing folds — the funnel " \
|
|
865
|
+
"shows the hole. Add a `funnel:` block to the pack.")
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
return findings unless findings.empty?
|
|
869
|
+
|
|
870
|
+
[ok("outcome-funnel", "outcome funnels: every declared funnel valid")]
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
# -> { agent_id => outcome count } — profiles with records but no funnel.
|
|
874
|
+
# ONE scan of the outcome store, grouped by agent (a per-profile `all`
|
|
875
|
+
# would scan it once PER profile — the store is not that big, but the
|
|
876
|
+
# key shape exists precisely so this stays a single pass).
|
|
877
|
+
def outcomes_without_funnel
|
|
878
|
+
return {} unless @outcome_store && @profile_source
|
|
879
|
+
|
|
880
|
+
counts = @outcome_store.all.group_by(&:agent).transform_values(&:size)
|
|
881
|
+
@profile_source.all.each_with_object({}) do |p, acc|
|
|
882
|
+
next unless p.funnel.nil?
|
|
883
|
+
|
|
884
|
+
n = counts[p.id.to_s].to_i
|
|
885
|
+
acc[p.id.to_s] = n if n.positive?
|
|
886
|
+
end
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
# The named defect, for the error message. `parse!` gives the exact field.
|
|
890
|
+
def funnel_defect(hash)
|
|
891
|
+
Insika::FunnelDeclaration.parse!(hash).to_s
|
|
892
|
+
rescue Insika::ValidationError => e
|
|
893
|
+
e.message
|
|
894
|
+
end
|
|
895
|
+
|
|
896
|
+
# Data-age findings, gated on the optional collaborators (nil = skip, env-only
|
|
897
|
+
# callers stay cheap). Tenant-agnostic: folds are per (tenant, agent), so the
|
|
898
|
+
# check reads the store's `pairs` and aggregates over every tenant of the agent
|
|
899
|
+
# (a single-tenant deployment reads the "platform" pair).
|
|
900
|
+
def funnel_data_findings(profile, decl)
|
|
901
|
+
return [] unless @funnel_store
|
|
902
|
+
|
|
903
|
+
pairs = @funnel_store.pairs.select { |p| p[:agent] == profile.id.to_s }
|
|
904
|
+
return [] if pairs.empty?
|
|
905
|
+
|
|
906
|
+
findings = []
|
|
907
|
+
folded_days = 0
|
|
908
|
+
primary_count = 0
|
|
909
|
+
baseline = false
|
|
910
|
+
pairs.each do |pair|
|
|
911
|
+
days = @funnel_store.days(tenant: pair[:tenant], agent: pair[:agent])
|
|
912
|
+
folded_days += days.size
|
|
913
|
+
primary_count += days.values.sum { |c| c[decl.primary].to_i }
|
|
914
|
+
baseline ||= !@funnel_store.baseline(tenant: pair[:tenant], agent: pair[:agent]).nil?
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
if primary_count.zero?
|
|
918
|
+
findings << Finding.new(check: "outcome-funnel", severity: :info, fix: nil,
|
|
919
|
+
message: "agent '#{profile.id}': the primary event " \
|
|
920
|
+
"'#{decl.primary}' was never observed over the " \
|
|
921
|
+
"folded days — check the integration.")
|
|
922
|
+
end
|
|
923
|
+
if folded_days >= 28 && !baseline
|
|
924
|
+
findings << Finding.new(check: "outcome-funnel", severity: :info, fix: nil,
|
|
925
|
+
message: "agent '#{profile.id}': #{folded_days} folded " \
|
|
926
|
+
"days and no baseline frozen — " \
|
|
927
|
+
"read the baseline. Freeze it in the Studio.")
|
|
928
|
+
end
|
|
929
|
+
findings
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
# the follow-up check — declarations validated where they are
|
|
933
|
+
# declared (D9), plus the two data-age reads that answer "whose follow-ups
|
|
934
|
+
# will fire" and "what is blocked right now" without reading the store:
|
|
935
|
+
# · a pending record whose `at` is more than one claim window in the past
|
|
936
|
+
# will NEVER fire — a blocked rule or a broken policy is holding it
|
|
937
|
+
# (warn; the Follow-ups page is the drill);
|
|
938
|
+
# · revoked contact cells per tenant (info — the human opt-out bar).
|
|
939
|
+
def check_followup
|
|
940
|
+
return [] unless @profile_source
|
|
941
|
+
|
|
942
|
+
declared = @profile_source.all.select do |profile|
|
|
943
|
+
profile.respond_to?(:followup) && !profile.followup.nil?
|
|
944
|
+
end
|
|
945
|
+
# a bare install (no follow-up on any profile) reports NOTHING — no
|
|
946
|
+
# follow-up vocabulary leaks into the doctor of a store that never
|
|
947
|
+
# scheduled one.
|
|
948
|
+
return [] if declared.empty?
|
|
949
|
+
|
|
950
|
+
findings = declared.flat_map do |profile|
|
|
951
|
+
followup = profile.followup
|
|
952
|
+
decl = Insika::FollowupPolicy.parse(followup)
|
|
953
|
+
if decl.nil?
|
|
954
|
+
[Finding.new(check: "follow-up", severity: :error, fix: nil,
|
|
955
|
+
message: "agent '#{profile.id}': malformed follow-up declaration — " \
|
|
956
|
+
"#{followup_defect(followup)}. The engine will never fire its " \
|
|
957
|
+
"follow-ups until this is fixed.")]
|
|
958
|
+
else
|
|
959
|
+
[ok("follow-up",
|
|
960
|
+
"agent '#{profile.id}': follow-up declared — arm #{decl.arm}, " \
|
|
961
|
+
"#{decl.quiet_hours ? "quiet hours #{decl.quiet_hours.start}-#{decl.quiet_hours.end} " \
|
|
962
|
+
"<#{decl.quiet_hours.timezone}>" : 'no quiet hours'}, " \
|
|
963
|
+
"#{decl.cancel_keywords.size} keyword(s), " \
|
|
964
|
+
"silence after #{decl.silence_after_sends} send(s)")] +
|
|
965
|
+
followup_data_findings(profile, decl)
|
|
966
|
+
end
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
findings
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
# The named defect, for the error message — `parse!` gives the exact field.
|
|
973
|
+
def followup_defect(hash)
|
|
974
|
+
Insika::FollowupPolicy.parse!(hash).to_s
|
|
975
|
+
rescue Insika::ValidationError => e
|
|
976
|
+
e.message
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
# Data-age findings, gated on the optional collaborators (nil = skip,
|
|
980
|
+
# env-only callers stay cheap).
|
|
981
|
+
def followup_data_findings(profile, decl)
|
|
982
|
+
return [] unless @followup_store && @contact_store
|
|
983
|
+
|
|
984
|
+
findings = []
|
|
985
|
+
# a pending record more than one claim window PAST its `at` (the sign:
|
|
986
|
+
# due(now - window)) is a promise the tick will never honor. A
|
|
987
|
+
# quiet-hours deferral is EXCLUDED — that record still fires next pass;
|
|
988
|
+
# only a record the tick could fire RIGHT NOW and doesn't is stuck.
|
|
989
|
+
now = Time.now.utc
|
|
990
|
+
window = Insika::FollowupEngine::DEFAULT_WINDOW
|
|
991
|
+
deferred_now = decl.quiet?(now)
|
|
992
|
+
stale = @followup_store.due(now: now - window)
|
|
993
|
+
.reject { |r| r.agent != profile.id.to_s }
|
|
994
|
+
.reject { deferred_now }
|
|
995
|
+
unless stale.empty?
|
|
996
|
+
findings << Finding.new(check: "follow-up", severity: :warn, fix: nil,
|
|
997
|
+
message: "agent '#{profile.id}': #{stale.size} pending " \
|
|
998
|
+
"follow-up(s) past their scheduled time by more than " \
|
|
999
|
+
"one claim window — the tick will never fire them. A " \
|
|
1000
|
+
"blocked rule or a broken policy is pending; see the " \
|
|
1001
|
+
"Follow-ups page.")
|
|
1002
|
+
end
|
|
1003
|
+
revoked_for(profile).each do |tenant, count|
|
|
1004
|
+
findings << Finding.new(check: "follow-up", severity: :info, fix: nil,
|
|
1005
|
+
message: "agent '#{profile.id}': #{count} revoked contact cell(s) " \
|
|
1006
|
+
"in tenant #{tenant.inspect} — the human opt-out bar " \
|
|
1007
|
+
"(opt-outs are per tenant/customer, shared across agents).")
|
|
1008
|
+
end
|
|
1009
|
+
findings
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
# { tenant => revoked-cell count } among the cells this agent may message.
|
|
1013
|
+
# The contact cell is per (tenant, customer) — shared across agents (D2's
|
|
1014
|
+
# multi-agent note), so the count is a per-tenant tally of revoked cells.
|
|
1015
|
+
def revoked_for(_profile)
|
|
1016
|
+
return {} unless @contact_store
|
|
1017
|
+
|
|
1018
|
+
@contact_store.cells.each_with_object(Hash.new(0)) do |(key, cell), tally|
|
|
1019
|
+
next unless cell["state"] == "revoked"
|
|
1020
|
+
|
|
1021
|
+
tally[key.split(":", 2).first] += 1
|
|
1022
|
+
end
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
# the distillation check — per profile WITH a `distill`
|
|
1026
|
+
# hash: a declared-and-enabled distiller with NO resolvable model (no
|
|
1027
|
+
# distill.model, no platform utility_model) can never run — the warn is
|
|
1028
|
+
# the "declared but dead" signal (D4 — the engine never guesses a model).
|
|
1029
|
+
# A bare install reports one ok ("off"), unlike follow-up's silence: the
|
|
1030
|
+
# Facts page exists with or without declarations and the doctor names why
|
|
1031
|
+
# it stays empty. nil proposal_store = declarations only (counts skipped).
|
|
1032
|
+
def check_distill
|
|
1033
|
+
return [] unless @profile_source
|
|
1034
|
+
|
|
1035
|
+
declared = @profile_source.all.select do |profile|
|
|
1036
|
+
profile.respond_to?(:distill) && !profile.distill.nil?
|
|
1037
|
+
end
|
|
1038
|
+
return [ok("distill", "distillation off — no agent declares it")] if declared.empty?
|
|
1039
|
+
|
|
1040
|
+
settings = @settings_store ? @settings_store.get : {}
|
|
1041
|
+
# one scan pair per run, not per declared profile (the counts are the
|
|
1042
|
+
# store's, printed identically on each line).
|
|
1043
|
+
counts = @proposal_store ? distill_counts : nil
|
|
1044
|
+
declared.flat_map do |profile|
|
|
1045
|
+
config = profile.distill
|
|
1046
|
+
if Coercion.truthy?(config["enabled"]) &&
|
|
1047
|
+
Coercion.presence(config["model"]).nil? &&
|
|
1048
|
+
Coercion.presence(settings["utility_model"]).nil?
|
|
1049
|
+
[Finding.new(check: "distill", severity: :warn, fix: nil,
|
|
1050
|
+
message: "agent '#{profile.id}': the distiller is declared but has " \
|
|
1051
|
+
"no model slot — distillation will never run (set " \
|
|
1052
|
+
"distill.model or the platform utility_model).")]
|
|
1053
|
+
else
|
|
1054
|
+
suffix = counts ? " — #{counts[:pending]} proposal(s) pending, #{counts[:stale]} stale" : ""
|
|
1055
|
+
[ok("distill", "agent '#{profile.id}': distillation declared#{suffix}")]
|
|
1056
|
+
end
|
|
1057
|
+
end
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
def distill_counts
|
|
1061
|
+
{ pending: @proposal_store.pending(limit: 10_000).size,
|
|
1062
|
+
stale: @proposal_store.stale(limit: 10_000).size }
|
|
1063
|
+
end
|
|
1064
|
+
|
|
1065
|
+
# the harvest check — per profile WITH a harvest hash:
|
|
1066
|
+
# declared-without-model warn (D12), no grounding matcher warn (D3),
|
|
1067
|
+
# malformed negative list error (D4), else ok with the pending counts.
|
|
1068
|
+
# With @harvest_criterion: the loaded criterion line + a warn when the
|
|
1069
|
+
# file at its path no longer loads (the frozen rule moved).
|
|
1070
|
+
def check_harvest
|
|
1071
|
+
return [] unless @profile_source
|
|
1072
|
+
|
|
1073
|
+
declared = @profile_source.all.select do |profile|
|
|
1074
|
+
profile.respond_to?(:harvest) && !profile.harvest.nil?
|
|
1075
|
+
end
|
|
1076
|
+
findings = []
|
|
1077
|
+
if declared.empty?
|
|
1078
|
+
findings << ok("harvest", "harvest off — no agent declares it")
|
|
1079
|
+
else
|
|
1080
|
+
settings = @settings_store ? @settings_store.get : {}
|
|
1081
|
+
declared.each do |profile|
|
|
1082
|
+
config = profile.harvest
|
|
1083
|
+
id = profile.id
|
|
1084
|
+
if Coercion.truthy?(config["enabled"]) &&
|
|
1085
|
+
Coercion.presence(config.dig("miner", "model")).nil? &&
|
|
1086
|
+
Coercion.presence(settings["utility_model"]).nil?
|
|
1087
|
+
findings << Finding.new(check: "harvest", severity: :warn, fix: nil,
|
|
1088
|
+
message: "agent '#{id}': the harvester is declared but has no " \
|
|
1089
|
+
"model slot — mining will never run (set " \
|
|
1090
|
+
"harvest.miner.model or the platform utility_model).")
|
|
1091
|
+
end
|
|
1092
|
+
grounding = begin
|
|
1093
|
+
Insika::Grounding.parse(profile.grounding)
|
|
1094
|
+
rescue Insika::ValidationError
|
|
1095
|
+
nil
|
|
1096
|
+
end
|
|
1097
|
+
if Coercion.truthy?(config["enabled"]) && (grounding.nil? || !grounding.matcher.sku?)
|
|
1098
|
+
findings << Finding.new(check: "harvest", severity: :warn, fix: nil,
|
|
1099
|
+
message: "agent '#{id}': product claims cannot be verified — " \
|
|
1100
|
+
"mining is skipped (set grounding.matcher.sku).")
|
|
1101
|
+
end
|
|
1102
|
+
if config["negative_list"].is_a?(Array) &&
|
|
1103
|
+
Insika::Harvest::NegativeList.parse(config["negative_list"]).nil?
|
|
1104
|
+
findings << Finding.new(check: "harvest", severity: :error, fix: nil,
|
|
1105
|
+
message: "agent '#{id}': the harvest.negative_list is malformed " \
|
|
1106
|
+
"— the whole list is refused (half a list silently " \
|
|
1107
|
+
"admits what the store banned).")
|
|
1108
|
+
end
|
|
1109
|
+
suffix = harvest_counts(id) if @harvest_store
|
|
1110
|
+
findings << ok("harvest", "agent '#{id}': harvest declared#{suffix}")
|
|
1111
|
+
end
|
|
1112
|
+
end
|
|
1113
|
+
if @harvest_criterion
|
|
1114
|
+
findings << ok("harvest-criterion",
|
|
1115
|
+
"criterion #{@harvest_criterion.rule.metric} / #{@harvest_criterion.rule.window} " \
|
|
1116
|
+
"threshold #{@harvest_criterion.rule.threshold} (#{@harvest_criterion.sha})")
|
|
1117
|
+
begin
|
|
1118
|
+
Insika::Harvest::Criterion.load(@harvest_criterion.path)
|
|
1119
|
+
rescue Insika::ConfigError, Insika::ValidationError
|
|
1120
|
+
findings << Finding.new(check: "harvest-criterion", severity: :warn, fix: nil,
|
|
1121
|
+
message: "the criterion file at #{@harvest_criterion.path} no longer " \
|
|
1122
|
+
"loads — the frozen rule moved since boot.")
|
|
1123
|
+
end
|
|
1124
|
+
end
|
|
1125
|
+
findings
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1128
|
+
def harvest_counts(agent_id)
|
|
1129
|
+
awaiting = @harvest_store.candidates(agent_id: agent_id, status: "awaiting_approval").size
|
|
1130
|
+
pending = @harvest_store.candidates(agent_id: agent_id, status: "pending").size
|
|
1131
|
+
" — #{awaiting} awaiting, #{pending} pending"
|
|
1132
|
+
end
|
|
1133
|
+
|
|
254
1134
|
def broken_tool(raw)
|
|
255
1135
|
Insika::ToolDefinition.from_h(raw)
|
|
256
1136
|
nil
|
|
@@ -266,8 +1146,6 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
|
|
|
266
1146
|
Finding.new(check: "data-tools", severity: :error, fix: fix, message: message)
|
|
267
1147
|
end
|
|
268
1148
|
|
|
269
|
-
# A fix ONLY when spelling the legacy bare `array` explicitly is enough to make the
|
|
270
|
-
# definition build — never a guess at a broken definition we don't understand.
|
|
271
1149
|
def array_sugar_fix(raw)
|
|
272
1150
|
params = raw.is_a?(Hash) ? raw["parameters"] : nil
|
|
273
1151
|
return nil unless params.is_a?(Array) && params.any? { |p| p.is_a?(Hash) && p["type"].to_s == "array" }
|
|
@@ -283,6 +1161,83 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
|
|
|
283
1161
|
|
|
284
1162
|
# -- helpers -------------------------------------------------------
|
|
285
1163
|
|
|
1164
|
+
# shadow parity. Shadow holds raw customer conversations, so
|
|
1165
|
+
# every dangerous configuration says so BEFORE the experiment starts — and
|
|
1166
|
+
# the one automated reminder to turn it off. The pairs themselves are only
|
|
1167
|
+
# loaded when shadow is ON (a full scan pays for customer text); the off
|
|
1168
|
+
# path needs just a count, which the store answers from its keys.
|
|
1169
|
+
def check_shadow_parity
|
|
1170
|
+
shadow_on = Insika::EnvSchema.truthy?(@env["INSIKA_RELAY_SHADOW"])
|
|
1171
|
+
path = Insika::EnvSchema.read("INSIKA_PARITY_CRITERION", @env)
|
|
1172
|
+
|
|
1173
|
+
unless shadow_on
|
|
1174
|
+
stored = @shadow_pair_store ? @shadow_pair_store.size : 0
|
|
1175
|
+
return [] if stored.zero?
|
|
1176
|
+
|
|
1177
|
+
return [ok("shadow-parity",
|
|
1178
|
+
"shadow off — #{stored} pair(s) still stored, evidence is not forgotten")]
|
|
1179
|
+
end
|
|
1180
|
+
|
|
1181
|
+
if path.nil?
|
|
1182
|
+
return [Finding.new(check: "shadow-parity", severity: :error, fix: nil,
|
|
1183
|
+
message: "shadow on but INSIKA_PARITY_CRITERION is unset — the frozen criterion is required to record pairs")]
|
|
1184
|
+
end
|
|
1185
|
+
|
|
1186
|
+
pairs = @shadow_pair_store ? @shadow_pair_store.each.to_a : []
|
|
1187
|
+
|
|
1188
|
+
criterion = begin
|
|
1189
|
+
Insika::Parity::Criterion.load(path)
|
|
1190
|
+
rescue Insika::Error => e
|
|
1191
|
+
return [Finding.new(check: "shadow-parity", severity: :error, fix: nil,
|
|
1192
|
+
message: "shadow on but the criterion did not load from #{path}: #{e.message}")]
|
|
1193
|
+
end
|
|
1194
|
+
|
|
1195
|
+
findings = [ok("shadow-parity", "shadow on — criterion frozen (#{criterion.sha}) at #{path}")]
|
|
1196
|
+
if Insika::EnvSchema.present?(@env["INSIKA_RELAY_DELIVER_URL"])
|
|
1197
|
+
findings << Finding.new(check: "shadow-parity", severity: :warn, fix: nil,
|
|
1198
|
+
message: "INSIKA_RELAY_DELIVER_URL is set — the URL is INERT: a shadow relay never delivers")
|
|
1199
|
+
end
|
|
1200
|
+
judges = ((@settings_store&.get || {})["evals"] || {})
|
|
1201
|
+
if Array(judges["judges"]).reject { |j| j["model"].to_s.strip.empty? }.empty?
|
|
1202
|
+
findings << Finding.new(check: "shadow-parity", severity: :warn, fix: nil,
|
|
1203
|
+
message: "no judges in settings['evals'] — pairs will accumulate and nothing can judge them")
|
|
1204
|
+
end
|
|
1205
|
+
window = criterion.rule.window_days
|
|
1206
|
+
oldest = pairs.filter_map { |p| parse_pair_time(p) }.min
|
|
1207
|
+
if oldest && oldest < Time.now.utc - (2 * window * 86_400)
|
|
1208
|
+
findings << Finding.new(check: "shadow-parity", severity: :warn, fix: nil,
|
|
1209
|
+
message: "the oldest pair is older than 2 × window_days — shadow is not a permanent mode; judge and turn it off")
|
|
1210
|
+
end
|
|
1211
|
+
findings
|
|
1212
|
+
end
|
|
1213
|
+
|
|
1214
|
+
def parse_pair_time(pair)
|
|
1215
|
+
Time.iso8601(pair.created_at.to_s)
|
|
1216
|
+
rescue ArgumentError
|
|
1217
|
+
nil
|
|
1218
|
+
end
|
|
1219
|
+
|
|
1220
|
+
# the BOOT gate for a malformed guardrail corpus. A typo'd
|
|
1221
|
+
# language/family or a broken pattern source raises ValidationError inside
|
|
1222
|
+
# Safety::Config on the FIRST TURN — mid-conversation, unrecoverable. This
|
|
1223
|
+
# check makes `insika doctor` the place it surfaces instead: an :error
|
|
1224
|
+
# finding (non-zero exit), so a deployment learns at boot, never mid-turn.
|
|
1225
|
+
def check_guardrail_corpora
|
|
1226
|
+
return [] unless @profile_source
|
|
1227
|
+
|
|
1228
|
+
findings = @profile_source.all.each_with_object([]) do |p, acc|
|
|
1229
|
+
Insika::Safety::Config.from_profile(p) # compiles the corpus — raises on a bad declaration
|
|
1230
|
+
rescue Insika::ValidationError => e
|
|
1231
|
+
acc << Finding.new(check: "guardrail-corpora", severity: :error, fix: nil,
|
|
1232
|
+
message: "agent '#{p.id}': malformed guardrails.corpora — #{e.message}. " \
|
|
1233
|
+
"Every message would fail the guardrail; fix the declaration " \
|
|
1234
|
+
"(docs/domain.md#guardrails).")
|
|
1235
|
+
end
|
|
1236
|
+
return findings unless findings.empty?
|
|
1237
|
+
|
|
1238
|
+
[ok("guardrail-corpora", "guardrail corpora: every declaration compiles")]
|
|
1239
|
+
end
|
|
1240
|
+
|
|
286
1241
|
def ok(check, message) = Finding.new(check: check, severity: :ok, message: message, fix: nil)
|
|
287
1242
|
|
|
288
1243
|
# A model to seed the platform default from: DEEPSEEK_MODEL env, else the first
|