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/outbox_store.rb
CHANGED
|
@@ -4,11 +4,11 @@ require "securerandom"
|
|
|
4
4
|
require "time"
|
|
5
5
|
|
|
6
6
|
module Insika
|
|
7
|
-
# Durable record of one pending OUTBOUND reply
|
|
7
|
+
# Durable record of one pending OUTBOUND reply. A Shape B
|
|
8
8
|
# channel answers out of band — the turn ends and the reply is POSTed to the
|
|
9
9
|
# platform (or, for a relay, to the consumer's own callback) afterwards — so
|
|
10
10
|
# "the answer exists but the recipient is not on this connection" is exactly
|
|
11
|
-
# the problem
|
|
11
|
+
# the problem already solved for async delegation. This store
|
|
12
12
|
# is DelegationStore's shape with a different recipient, deliberately: a second
|
|
13
13
|
# invention here would be a second thing to get wrong.
|
|
14
14
|
#
|
|
@@ -23,7 +23,7 @@ module Insika
|
|
|
23
23
|
# a third party that refused N times is an operator problem, not
|
|
24
24
|
# something to replay forever.
|
|
25
25
|
#
|
|
26
|
-
# `failed` and `delivering` are a deliberate widening of the
|
|
26
|
+
# `failed` and `delivering` are a deliberate widening of the two-status
|
|
27
27
|
# sketch (`pending -> delivered`): without them a crashed claim and an
|
|
28
28
|
# exhausted retry are indistinguishable from a fresh record, and the sweep
|
|
29
29
|
# would redeliver both.
|
|
@@ -38,7 +38,7 @@ module Insika
|
|
|
38
38
|
|
|
39
39
|
Delivery = Data.define(
|
|
40
40
|
:id, :channel, :to, :task_id, :session_id, :payload,
|
|
41
|
-
:status, :attempts, :last_error, :created_at, :updated_at
|
|
41
|
+
:status, :attempts, :last_error, :index, :created_at, :updated_at
|
|
42
42
|
)
|
|
43
43
|
|
|
44
44
|
def initialize(store:)
|
|
@@ -47,7 +47,9 @@ module Insika
|
|
|
47
47
|
|
|
48
48
|
# -> Delivery (:pending). `payload` is the body the channel will send; it is
|
|
49
49
|
# DATA (string keys, JSON types) and the store never interprets it.
|
|
50
|
-
|
|
50
|
+
# `index` is the balloon's position inside its turn — 0 for a
|
|
51
|
+
# plain `:at_end` delivery, written by a progressive flush.
|
|
52
|
+
def create(channel:, to:, task_id:, session_id:, payload:, index: 0, id: SecureRandom.uuid)
|
|
51
53
|
record = {
|
|
52
54
|
"id" => id.to_s,
|
|
53
55
|
"channel" => channel.to_s,
|
|
@@ -58,6 +60,7 @@ module Insika
|
|
|
58
60
|
"status" => "pending",
|
|
59
61
|
"attempts" => 0,
|
|
60
62
|
"last_error" => nil,
|
|
63
|
+
"index" => index.to_i,
|
|
61
64
|
"created_at" => timestamp,
|
|
62
65
|
"updated_at" => timestamp
|
|
63
66
|
}
|
|
@@ -75,8 +78,12 @@ module Insika
|
|
|
75
78
|
# `delivering`: that one was claimed by a process that then died, and whether
|
|
76
79
|
# its POST landed is unknowable — replaying it is the duplicate the claim
|
|
77
80
|
# exists to prevent.
|
|
81
|
+
#
|
|
82
|
+
# Ordered by [task_id, index] : a crashed progressive turn re-drives
|
|
83
|
+
# balloon 1 only after balloon 0, never the reverse.
|
|
78
84
|
def pending
|
|
79
85
|
scan.select { |d| d.status == :pending }
|
|
86
|
+
.sort_by { |d| [d.task_id.to_s, d.index] }
|
|
80
87
|
end
|
|
81
88
|
|
|
82
89
|
# pending -> delivering, ATOMICALLY — across processes, not just fibers: the
|
|
@@ -127,8 +134,38 @@ module Insika
|
|
|
127
134
|
touch(id, record)
|
|
128
135
|
end
|
|
129
136
|
|
|
137
|
+
# WS8 (LGPD): drops every delivery of these sessions, whatever its status.
|
|
138
|
+
# `payload` is the ANSWER as it was handed to the channel, so a purge that
|
|
139
|
+
# stops at the session record leaves the conversation readable here forever.
|
|
140
|
+
# -> count removed.
|
|
141
|
+
def purge_sessions(session_ids)
|
|
142
|
+
wanted = Array(session_ids).map(&:to_s)
|
|
143
|
+
return 0 if wanted.empty?
|
|
144
|
+
|
|
145
|
+
delete_where { |d| wanted.include?(d.session_id.to_s) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# WS8 retention: deliveries created before the cutoff. TERMINAL ones only —
|
|
149
|
+
# a `pending`/`delivering` record older than the window is still somebody's
|
|
150
|
+
# undelivered answer, and the sweep is not the place to decide it is lost.
|
|
151
|
+
# -> count removed.
|
|
152
|
+
def delete_older_than(time)
|
|
153
|
+
cutoff = time.utc.iso8601
|
|
154
|
+
delete_where do |d|
|
|
155
|
+
%i[delivered failed].include?(d.status) && d.created_at.to_s < cutoff
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
130
159
|
private
|
|
131
160
|
|
|
161
|
+
# The id list is SNAPSHOTTED before the deletes: `scan` enumerates the
|
|
162
|
+
# backend's keys lazily and deleting under it would skip records.
|
|
163
|
+
def delete_where(&match)
|
|
164
|
+
doomed = scan.select(&match)
|
|
165
|
+
doomed.each { |d| @store.delete(SCOPE, key_for(d.id)) }
|
|
166
|
+
doomed.size
|
|
167
|
+
end
|
|
168
|
+
|
|
132
169
|
def scan
|
|
133
170
|
return enum_for(:scan) unless block_given?
|
|
134
171
|
|
|
@@ -157,7 +194,8 @@ module Insika
|
|
|
157
194
|
task_id: record["task_id"], session_id: record["session_id"],
|
|
158
195
|
payload: record["payload"], status: record["status"].to_sym,
|
|
159
196
|
attempts: record["attempts"].to_i, last_error: record["last_error"],
|
|
160
|
-
|
|
197
|
+
index: record["index"].to_i, created_at: record["created_at"],
|
|
198
|
+
updated_at: record["updated_at"]
|
|
161
199
|
)
|
|
162
200
|
end
|
|
163
201
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# WS7: business outcomes over real traffic, recorded per conversation by the
|
|
8
|
+
# OPERATOR or the integration (`POST /v1/outcomes`) — `conversion`,
|
|
9
|
+
# `escalation`, `deflected`, … optionally with a monetary `value`. The engine
|
|
10
|
+
# TRANSPORTS the outcome and never interprets it (the same rule as alerts):
|
|
11
|
+
# what "conversion" means is the operator's. Durable in the KV backend,
|
|
12
|
+
# tenant-scoped (WS1) — a tenant can only ever read/write its own cells (the
|
|
13
|
+
# key carries the tenant, and the read methods narrow on it).
|
|
14
|
+
#
|
|
15
|
+
# The Studio's scorecard is the consumer: the LAST outcome per agent as a
|
|
16
|
+
# pill on the grid, and the per-day series on the agent detail — both fed
|
|
17
|
+
# from the same store, no scheduler.
|
|
18
|
+
class OutcomeStore
|
|
19
|
+
SCOPE = "outcomes"
|
|
20
|
+
|
|
21
|
+
Record = Data.define(:id, :tenant, :agent, :session_id, :outcome, :value, :at)
|
|
22
|
+
|
|
23
|
+
def initialize(store:)
|
|
24
|
+
@store = store
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# -> Record. `at` defaults to now (UTC); `value` defaults to 0.0 (an
|
|
28
|
+
# outcome without a value is a unit outcome).
|
|
29
|
+
def create(tenant:, agent:, session_id: nil, outcome:, value: nil, id: SecureRandom.uuid, at: Time.now.utc)
|
|
30
|
+
time = at.utc
|
|
31
|
+
record = { "id" => id, "tenant" => tenant.to_s, "agent" => agent.to_s,
|
|
32
|
+
"session_id" => session_id.to_s, "outcome" => outcome.to_s,
|
|
33
|
+
"value" => value.to_f, "at" => time.iso8601 }
|
|
34
|
+
@store.set(SCOPE, key(tenant, agent, time, id), record)
|
|
35
|
+
to_record(record)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# All records, NEWEST first. `tenant:` narrows to one tenant's (WS1 — the
|
|
39
|
+
# read path a tenant query uses); `agent:` narrows further. An outcome is a
|
|
40
|
+
# fact, never a secret: the fields are ids, an outcome name and a number.
|
|
41
|
+
# NOTE: `tenant: nil` means NO filter — the record's tenant field is
|
|
42
|
+
# `tenant.to_s` ("" for a single-tenant write), so a caller that wants only
|
|
43
|
+
# the no-tenant records must pass `tenant: ""` explicitly (the FOLD uses
|
|
44
|
+
# `for_pair`, never this nil-means-everything trap).
|
|
45
|
+
def all(tenant: nil, agent: nil)
|
|
46
|
+
records = @store.list(SCOPE).filter_map { |k| to_record(@store.get(SCOPE, k)) }
|
|
47
|
+
records.select! { |r| r.tenant == tenant } if tenant
|
|
48
|
+
records.select! { |r| r.agent == agent } if agent
|
|
49
|
+
records.sort_by(&:at).reverse
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# the fold's per-pair read — one KEY-prefix scan (the key IS
|
|
53
|
+
# tenant:agent:YYYY-MM-DD:id, WS1), optionally skipping the keys older than
|
|
54
|
+
# an ISO date WITHOUT reading them (the fold's cursor day — everything
|
|
55
|
+
# before it is already folded). `tenant:` takes either spelling (nil/""
|
|
56
|
+
# and "platform" all mean the no-tenant key segment). -> [Record].
|
|
57
|
+
def for_pair(tenant:, agent:, since_date: nil)
|
|
58
|
+
prefix = "#{key_tenant(tenant)}:#{agent}:"
|
|
59
|
+
keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) }
|
|
60
|
+
keys.select! { |k| date_segment(k) >= since_date } if since_date
|
|
61
|
+
keys.filter_map { |k| to_record(@store.get(SCOPE, k)) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# the distinct (tenant, agent) pairs present in the store —
|
|
65
|
+
# one key scan, no record reads. `tenant` is nil for the no-tenant segment
|
|
66
|
+
# (the FunnelStore#pairs spelling; every key-based API normalizes it back).
|
|
67
|
+
# -> [{ tenant: String | nil, agent: String }]
|
|
68
|
+
def pairs
|
|
69
|
+
@store.list(SCOPE).map { |k| pair_of(k) }.uniq
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# -> { agent => { outcome:, value:, at:, session_id: } }: the LAST outcome
|
|
73
|
+
# per agent — the Studio's scorecard state card. A map so the agent grid
|
|
74
|
+
# looks up in O(1) per card.
|
|
75
|
+
def latest_per_agent(tenant: nil)
|
|
76
|
+
all(tenant: tenant).each_with_object({}) do |r, acc|
|
|
77
|
+
acc[r.agent] ||= { outcome: r.outcome, value: r.value, at: r.at,
|
|
78
|
+
session_id: r.session_id }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# -> { "YYYY-MM-DD" => { outcome => { count:, value: } } } — per-period
|
|
83
|
+
# series for the Studio. `period: :month` buckets by "YYYY-MM".
|
|
84
|
+
def series(tenant: nil, agent: nil, period: :day)
|
|
85
|
+
all(tenant: tenant, agent: agent).each_with_object({}) do |r, acc|
|
|
86
|
+
bucket = period == :month ? r.at[0, 7] : r.at[0, 10]
|
|
87
|
+
cell = ((acc[bucket] ||= {})[r.outcome] ||= { count: 0, value: 0.0 })
|
|
88
|
+
cell[:count] += 1
|
|
89
|
+
cell[:value] += r.value
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Purges a tenant's records (WS8 phase 2 — delete_tenant_data). The tenant
|
|
94
|
+
# is the FIRST key segment, so the purge is a prefix scan — the key IS the
|
|
95
|
+
# isolation (WS1). -> count removed.
|
|
96
|
+
def purge(tenant:)
|
|
97
|
+
prefix = "#{tenant}:"
|
|
98
|
+
keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) }
|
|
99
|
+
keys.each { |k| @store.delete(SCOPE, k) }
|
|
100
|
+
keys.size
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Purges records older than the cutoff (WS8 retention — the tick's sweep).
|
|
104
|
+
# `at` is ISO8601 UTC, so the comparison is lexicographic. -> count removed.
|
|
105
|
+
def delete_older_than(time)
|
|
106
|
+
cutoff = time.utc.iso8601
|
|
107
|
+
removed = 0
|
|
108
|
+
@store.list(SCOPE).each do |k|
|
|
109
|
+
rec = @store.get(SCOPE, k)
|
|
110
|
+
next unless rec && rec["at"].to_s < cutoff
|
|
111
|
+
|
|
112
|
+
@store.delete(SCOPE, k)
|
|
113
|
+
removed += 1
|
|
114
|
+
end
|
|
115
|
+
removed
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def key_tenant(tenant) = tenant.to_s.empty? ? "platform" : tenant.to_s
|
|
121
|
+
|
|
122
|
+
# The date segment of the key (the 3rd segment, 0-indexed: 2).
|
|
123
|
+
def date_segment(k) = k.split(":").fetch(2, "")
|
|
124
|
+
|
|
125
|
+
# Parses the key's tenant segment back: "platform" -> nil (the
|
|
126
|
+
# FunnelStore#pairs spelling); anything else stays.
|
|
127
|
+
def pair_of(k)
|
|
128
|
+
tenant, agent, = k.split(":")
|
|
129
|
+
{ tenant: tenant == "platform" ? nil : tenant, agent: agent }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def key(tenant, agent, time, id)
|
|
133
|
+
# tenant + agent + UTC date prefix: per-period / per-agent listing is a
|
|
134
|
+
# prefix scan, and the tenant IS the first segment — WS1 isolation is the
|
|
135
|
+
# key itself, like the session namespace.
|
|
136
|
+
"#{(tenant || 'platform')}:#{agent}:#{time.strftime('%Y-%m-%d')}:#{id}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def to_record(rec)
|
|
140
|
+
return nil if rec.nil?
|
|
141
|
+
|
|
142
|
+
Record.new(id: rec["id"], tenant: rec["tenant"], agent: rec["agent"],
|
|
143
|
+
session_id: rec["session_id"], outcome: rec["outcome"],
|
|
144
|
+
value: rec["value"].to_f, at: rec["at"])
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -3,17 +3,16 @@
|
|
|
3
3
|
module Insika
|
|
4
4
|
# DYNAMIC tool registry: composes the CODE registry (base, built at boot,
|
|
5
5
|
# immutable) with the DATA-DEFINED tools from the ToolStore. Drop-in for ToolRegistry —
|
|
6
|
-
# the Executor/ToolCatalog/ToolEnvelope only use entries/resolve/side_effect
|
|
7
|
-
# Step B / D2.
|
|
6
|
+
# the Executor/ToolCatalog/ToolEnvelope only use entries/resolve/side_effect?.,
|
|
8
7
|
#
|
|
9
8
|
# Rules:
|
|
10
9
|
# - COLLISION: the base (code) ALWAYS wins — a data-tool cannot hijack
|
|
11
10
|
# the name of a code tool (security, R3). The authoring Command also
|
|
12
11
|
# refuses to create with a colliding name (code_tool?), but the defense stays here.
|
|
13
12
|
# - HOT: `reload` re-reads the store and swaps the dynamic index atomically — a
|
|
14
|
-
# new/edited data-tool takes effect on the next turn without a restart
|
|
13
|
+
# new/edited data-tool takes effect on the next turn without a restart, mirroring
|
|
15
14
|
# SkillCatalog.reload. An in-flight turn has already captured the index.
|
|
16
|
-
#
|
|
15
|
+
# PARITY: empty ToolStore ⇒ entries/resolve/side_effect? identical to the
|
|
17
16
|
# pure base. The base (config/wiring.rb) does not even use the overlay — zero regression.
|
|
18
17
|
#
|
|
19
18
|
# The data-tools enter as NORMAL Registry::Entry (optional: false) — they obey
|
data/lib/insika/pack.rb
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
|
-
# Provisioning pack
|
|
6
|
+
# Provisioning pack: the PORTABLE form of an agent — a
|
|
7
7
|
# manifest + the prompt files + the skills + the data-tool defs. It's what
|
|
8
8
|
# `docs/prompt-base/06` describes as a workspace, here in a value object
|
|
9
9
|
# consumable by the PackImporter (which emits the authoring Commands). GENERIC
|
|
10
|
-
# per project
|
|
10
|
+
# per project: the engine doesn't know any consumer — the pack is the contract.
|
|
11
11
|
#
|
|
12
12
|
# config: Hash — manifest (AgentProfile.build attrs: id/model/provider/
|
|
13
13
|
# limits/metadata/tools_deferred/…). `id`/`model` required there.
|
|
@@ -16,7 +16,7 @@ module Insika
|
|
|
16
16
|
# skills: { "escalation-to-human" => "<SKILL.md>", ... } — 1 per skill.
|
|
17
17
|
# tools: [ { ToolDefinition hash }, ... ] — the pack's data-tool defs.
|
|
18
18
|
#
|
|
19
|
-
# Two sources: `from_h` (GatewayClient JSON — provisioning API
|
|
19
|
+
# Two sources: `from_h` (GatewayClient JSON — provisioning API) and
|
|
20
20
|
# `from_dir` (a folder on disk per docs/prompt-base/06 — authoring/CLI).
|
|
21
21
|
Pack = Data.define(:config, :files, :skills, :tools) do
|
|
22
22
|
# Raw Hash (string|symbol keys) -> Pack. Tolerates both key conventions
|
data/lib/insika/pack_importer.rb
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Insika
|
|
4
|
-
# Pack importer
|
|
4
|
+
# Pack importer: reads a Pack and emits the ALREADY
|
|
5
5
|
# existing authoring Commands — create_agent/update_agent + write_agent_file +
|
|
6
6
|
# write_skill + write_data_tool — making an agent provisionable at runtime from
|
|
7
|
-
# a standardized pack. It's the piece
|
|
8
|
-
#
|
|
7
|
+
# a standardized pack. It's the piece a consumer's provisioning client
|
|
8
|
+
# triggers (via the provisioning API).
|
|
9
9
|
#
|
|
10
|
-
# GENERIC
|
|
10
|
+
# GENERIC: nothing here mentions a consumer — the pack is the contract. Tool
|
|
11
11
|
# names come from the PACK, so prompts<->tools stay consistent by construction
|
|
12
|
-
#
|
|
12
|
+
# It doesn't write to the store directly: it only dispatches Commands on
|
|
13
13
|
# the bus (the same transport discipline) + READS the ProfileSource to decide
|
|
14
14
|
# create vs update.
|
|
15
15
|
#
|
|
@@ -58,13 +58,15 @@ module Insika
|
|
|
58
58
|
# its OWN pack (per-store isolation) and re-provisioning removes what left.
|
|
59
59
|
# - prompt_files = the pack's .md files (write_agent_file also registers;
|
|
60
60
|
# union is a no-op). Setting here makes the list authoritative (removes the ones that left).
|
|
61
|
-
# - skills = the pack's skills/ dirs (explicit allowlist; []
|
|
62
|
-
#
|
|
63
|
-
# skills from other stores).
|
|
61
|
+
# - skills = the pack's skills/ dirs (explicit allowlist; [] when the pack
|
|
62
|
+
# has none — never nil=all, which would leak skills from other stores).
|
|
64
63
|
# - tools_allow = (config.tools_allow) ∪ (the pack's tool names) — guarantees
|
|
65
|
-
# the agent can call its own data-tools
|
|
64
|
+
# the agent can call its own data-tools. [] when neither exists —
|
|
65
|
+
# never nil=all: the ToolStore is GLOBAL, so a tool-less pack would
|
|
66
|
+
# otherwise see every other store's tools (store A's pack calling
|
|
67
|
+
# store B's set_location is exactly that leak).
|
|
66
68
|
# - tools_allow_groups = groups enabled by FLAG in the pack (see
|
|
67
|
-
# #enabled_groups) — the per-flag schema CUT
|
|
69
|
+
# #enabled_groups) — the per-flag schema CUT: only
|
|
68
70
|
# the tools of the enabled groups (union with tools_allow) go to the model;
|
|
69
71
|
# those of disabled groups are cut BEFORE the turn (resolves the OpenClaw
|
|
70
72
|
# tool-call waste, where the flag only exists in Rails).
|
|
@@ -72,24 +74,24 @@ module Insika
|
|
|
72
74
|
attrs = pack.config.dup
|
|
73
75
|
attrs[:id] = id
|
|
74
76
|
attrs[:prompt_files] = pack.files.keys unless pack.files.empty?
|
|
75
|
-
attrs[:skills] = pack.skills.keys
|
|
77
|
+
attrs[:skills] = pack.skills.keys
|
|
76
78
|
|
|
77
79
|
pack_tools = pack.tools.map { |t| tool_name(t) }
|
|
78
80
|
allow = Array(pack.config[:tools_allow]).map(&:to_s) | pack_tools
|
|
79
|
-
attrs[:tools_allow] = allow
|
|
81
|
+
attrs[:tools_allow] = allow
|
|
80
82
|
|
|
81
83
|
groups = enabled_groups(pack.config)
|
|
82
84
|
attrs[:tools_allow_groups] = groups unless groups.nil?
|
|
83
85
|
attrs
|
|
84
86
|
end
|
|
85
87
|
|
|
86
|
-
# PER-FLAG CUT (
|
|
88
|
+
# PER-FLAG CUT (/, STATIC pilot): derives the agent's
|
|
87
89
|
# per-group allowlist from FLAGS declared in the pack config — DATA, never a
|
|
88
|
-
# core convention
|
|
90
|
+
# core convention. The engine doesn't know "groceries_v2"/"b2b": the pack
|
|
89
91
|
# declares which GROUPS are enabled; the flag->group mapping is the
|
|
90
92
|
# responsibility of provisioning/the pack, not the insika. Two forms (union):
|
|
91
93
|
# - `enabled_groups: ["default", "b2b"]` — explicit list of ON groups.
|
|
92
|
-
# - `flags: { "
|
|
94
|
+
# - `flags: { "wholesale" => true, "outlet" => false }` — the flag key IS the group
|
|
93
95
|
# name; only the truthy ones enter (the false ones CUT the group).
|
|
94
96
|
# Neither declared -> nil (no per-group cut; old behavior).
|
|
95
97
|
# Explicit `[]` (empty enabled_groups, or all flags false) -> no group.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# The single source of the gem payload selection. The
|
|
5
|
+
# gemspec and the domain-boundary audit spec call THE SAME function, so
|
|
6
|
+
# "what ships in the gem" is a fact the suite asserts on, never a prose
|
|
7
|
+
# promise: `git ls-files` (fallback: a glob where there is no .git) filtered
|
|
8
|
+
# to lib/ + docs/ + the four root files, minus the never-ship set.
|
|
9
|
+
#
|
|
10
|
+
# Pure and dependency-free (no git gem, no FileUtils beyond reads) so both
|
|
11
|
+
# the gemspec (built outside the app) and the spec can load it. It does NOT
|
|
12
|
+
# ship anything itself — it answers a list.
|
|
13
|
+
#
|
|
14
|
+
# `domain_content?` is the audit's yardstick: best-effort detection of pt-BR
|
|
15
|
+
# domain content in a payload file. Ruby files are scanned for their string
|
|
16
|
+
# literals, regex literals and heredoc bodies (comments stripped);
|
|
17
|
+
# everything else is scanned as raw text. The token set is the pt-BR
|
|
18
|
+
# vocabulary + the demo persona name (PT_BR_TOKENS). The corpus data files
|
|
19
|
+
# and the audit's own token table live in the spec's named allowlist.
|
|
20
|
+
module Packaging
|
|
21
|
+
# The pt-BR domain vocabulary the audit scans for. `loja`/`shop`/`store`
|
|
22
|
+
# are generic-retail stopwords — the ONE justification the inventory
|
|
23
|
+
# allows (D3); `bia` is the demo persona name (D8).
|
|
24
|
+
#
|
|
25
|
+
# The scan is FOLD-aware: both the scanned text and these words go through
|
|
26
|
+
# `fold` (accents stripped, regex bracket classes like `voc[êe]` collapsed
|
|
27
|
+
# to their canonical chars) before matching. That is what makes the
|
|
28
|
+
# shipped corpus data — whose patterns encode pt-BR as `voc[êe]`,
|
|
29
|
+
# `instru[çc][õo]es` — detectable by the same token table (D1: the
|
|
30
|
+
# boundary holds for the content the inventory names).
|
|
31
|
+
ACCENT_MAP = {
|
|
32
|
+
"á" => "a", "à" => "a", "â" => "a", "ã" => "a", "ä" => "a",
|
|
33
|
+
"é" => "e", "è" => "e", "ê" => "e", "ë" => "e",
|
|
34
|
+
"í" => "i", "ì" => "i", "î" => "i", "ï" => "i",
|
|
35
|
+
"ó" => "o", "ò" => "o", "ô" => "o", "õ" => "o", "ö" => "o",
|
|
36
|
+
"ú" => "u", "ù" => "u", "û" => "u", "ü" => "u",
|
|
37
|
+
"ç" => "c"
|
|
38
|
+
}.freeze
|
|
39
|
+
BRACKET_CLASS = /\[([^\]]*)\]/
|
|
40
|
+
|
|
41
|
+
# -> String: the canonical form the token match runs on. A bracket class
|
|
42
|
+
# folds to the deduped canonical chars of its members — `voc[êe]` folds
|
|
43
|
+
# to "voce", matching the folded token "voce" (você).
|
|
44
|
+
def self.fold(text)
|
|
45
|
+
text.to_s.gsub(BRACKET_CLASS) do
|
|
46
|
+
Regexp.last_match(1).chars.map { |c| ACCENT_MAP.fetch(c, c) }.uniq.join
|
|
47
|
+
end.gsub(/[#{ACCENT_MAP.keys.join}]/, ACCENT_MAP)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
TOKEN_WORDS = ["você", "não", "loja", "pedido", "atendente", "obrigad",
|
|
51
|
+
"conversa", "cliente"].freeze
|
|
52
|
+
PT_BR_VOCABULARY = /\b(?:#{TOKEN_WORDS.map { |w| fold(w) }.join("|")})\b/i
|
|
53
|
+
PERSONA_NAME = /\bbia\b/i
|
|
54
|
+
PT_BR_TOKENS = /(?:#{PT_BR_VOCABULARY.source}|#{PERSONA_NAME.source})/i
|
|
55
|
+
|
|
56
|
+
# The never-ship set: the studio JS toolchain (only assets/dist ships) and
|
|
57
|
+
# the docs' own Jekyll Gemfile/config.
|
|
58
|
+
NEVER_SHIP_PREFIXES = ["node_modules", "lib/insika/studio/test/", "lib/insika/studio/assets/src/"].freeze
|
|
59
|
+
NEVER_SHIP_PATHS = %w[
|
|
60
|
+
docs/Gemfile docs/Gemfile.lock docs/_config.yml
|
|
61
|
+
lib/insika/studio/README.md lib/insika/studio/package.json
|
|
62
|
+
lib/insika/studio/package-lock.json lib/insika/studio/tailwind.config.js
|
|
63
|
+
].freeze
|
|
64
|
+
|
|
65
|
+
module_function
|
|
66
|
+
|
|
67
|
+
# -> [String] repo-relative file paths the gem ships, in the gemspec's
|
|
68
|
+
# order: `git ls-files` (fallback: glob where there is no .git), filtered
|
|
69
|
+
# to lib/ + docs/ + the four root files, minus the never-ship set.
|
|
70
|
+
def payload_files(root = Dir.pwd)
|
|
71
|
+
Dir.chdir(root) do
|
|
72
|
+
tracked = `git ls-files -z 2>/dev/null`.split("\x0")
|
|
73
|
+
if tracked.empty?
|
|
74
|
+
tracked = Dir.glob("{lib,docs}/**/*", File::FNM_DOTMATCH).reject { |f| File.directory?(f) } +
|
|
75
|
+
%w[README.md LICENSE CHANGELOG.md bin/insika]
|
|
76
|
+
end
|
|
77
|
+
tracked.select { |file| payload_path?(file) }.reject { |file| excluded?(file) }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# -> bool: is this repo-relative path part of the payload selection?
|
|
82
|
+
def payload_path?(file)
|
|
83
|
+
file.start_with?("lib/", "docs/") || %w[README.md LICENSE CHANGELOG.md bin/insika].include?(file)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# -> bool: is this file in the never-ship set?
|
|
87
|
+
def excluded?(file)
|
|
88
|
+
NEVER_SHIP_PREFIXES.any? { |p| file.include?(p) } || NEVER_SHIP_PATHS.include?(file)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# -> bool: does a payload file hold pt-BR domain content? Best-effort:
|
|
92
|
+
# - Ruby files: comments stripped, string literals / regex literals /
|
|
93
|
+
# heredoc bodies scanned;
|
|
94
|
+
# - everything else: scanned as-is.
|
|
95
|
+
# The token set is the pt-BR vocabulary + the demo persona name; both the
|
|
96
|
+
# text and the vocabulary are FOLDED before matching (accents stripped,
|
|
97
|
+
# bracket classes collapsed), so the corpus data's `voc[êe]`-style
|
|
98
|
+
# patterns are caught by the same table.
|
|
99
|
+
def domain_content?(path)
|
|
100
|
+
text = path.to_s.end_with?(".rb") ? ruby_text(path) : File.read(path)
|
|
101
|
+
fold(text).match?(PT_BR_TOKENS)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# -> bool: does a payload file mention the demo persona name (`bia`)?
|
|
105
|
+
def persona_content?(path)
|
|
106
|
+
text = path.to_s.end_with?(".rb") ? ruby_text(path) : File.read(path)
|
|
107
|
+
text.match?(PERSONA_NAME)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The Ruby source as the audit reads it: heredoc bodies verbatim, string +
|
|
111
|
+
# regex literals, comments stripped. Best-effort by design (D1).
|
|
112
|
+
def ruby_text(path)
|
|
113
|
+
lines = File.readlines(path)
|
|
114
|
+
out = +""
|
|
115
|
+
heredoc = nil
|
|
116
|
+
lines.each do |line|
|
|
117
|
+
if heredoc
|
|
118
|
+
out << line
|
|
119
|
+
heredoc = nil if line.strip == heredoc
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
if (m = line.match(HEREDOC_OPEN))
|
|
123
|
+
heredoc = m[1] || m[2] || m[3]
|
|
124
|
+
next
|
|
125
|
+
end
|
|
126
|
+
code = strip_comments(line)
|
|
127
|
+
out << code.scan(STRING_LITERAL).join("\n")
|
|
128
|
+
out << code.scan(REGEX_LITERAL).join("\n")
|
|
129
|
+
end
|
|
130
|
+
out
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Cuts a line at its first `#` that starts a COMMENT — i.e. a `#` NOT
|
|
134
|
+
# inside a string/regex literal (an interpolation `#{…}` or a literal `#`
|
|
135
|
+
# must survive; a naive strip would corrupt the literal and hide content).
|
|
136
|
+
def strip_comments(line)
|
|
137
|
+
spans = literal_spans(line)
|
|
138
|
+
cut = nil
|
|
139
|
+
line.chars.each_index do |i|
|
|
140
|
+
next unless line[i] == "#"
|
|
141
|
+
next if spans.any? { |b, e| i >= b && i < e }
|
|
142
|
+
|
|
143
|
+
cut = i
|
|
144
|
+
break
|
|
145
|
+
end
|
|
146
|
+
cut ? line[0...cut] : line
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# -> [[begin, end), …] spans of the complete string/regex literals in a line.
|
|
150
|
+
def literal_spans(line)
|
|
151
|
+
spans = []
|
|
152
|
+
line.scan(STRING_LITERAL) { spans << [Regexp.last_match.begin(0), Regexp.last_match.end(0)] }
|
|
153
|
+
line.scan(REGEX_LITERAL) { spans << [Regexp.last_match.begin(0), Regexp.last_match.end(0)] }
|
|
154
|
+
spans
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
HEREDOC_OPEN = /<<[~-]\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z_]\w*))(?:\s*\.\w+)*\s*\z/
|
|
158
|
+
STRING_LITERAL = /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/
|
|
159
|
+
REGEX_LITERAL = %r{\/(?:[^\/\\\n]|\\.)+\/[a-z]*}
|
|
160
|
+
|
|
161
|
+
private_class_method :payload_path?, :excluded?, :ruby_text, :strip_comments, :literal_spans
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
module Parity
|
|
8
|
+
# C5 — the frozen criterion, parsed from the deployment's criterion file.
|
|
9
|
+
# The prose a human reads and the yaml block the machine applies are the SAME
|
|
10
|
+
# file, so there is exactly one place to edit — and the file's WHOLE bytes are
|
|
11
|
+
# hashed, so an edit to the rationale invalidates the frozen rule too, which is
|
|
12
|
+
# correct: the rationale is what makes the numbers reviewable.
|
|
13
|
+
#
|
|
14
|
+
# Strict by construction: a missing key or an unknown key is refused at load
|
|
15
|
+
# (`ValidationError`), and no key has a default — a number the machine filled in
|
|
16
|
+
# is a number nobody pre-registered.
|
|
17
|
+
class Criterion
|
|
18
|
+
Rule = Data.define(
|
|
19
|
+
:version, :unit, :window_days, :pairs_per_day, :min_decided,
|
|
20
|
+
:min_judge_models, :both_orders,
|
|
21
|
+
:win_or_tie_floor, :estimator,
|
|
22
|
+
:worse_rate_ceiling, :undecided_rate_ceiling, :incomplete_rate_ceiling,
|
|
23
|
+
:per_agent_min_decided, :per_agent_win_or_tie_floor,
|
|
24
|
+
:human_assisted, :silent
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
attr_reader :rule, :path, :sha
|
|
28
|
+
|
|
29
|
+
def initialize(rule:, path:, sha:)
|
|
30
|
+
@rule = rule
|
|
31
|
+
@path = path
|
|
32
|
+
@sha = sha
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Reads the file, extracts the FIRST ```yaml fence, validates STRICTLY.
|
|
36
|
+
# Raises Insika::ConfigError when the file is absent or has no block;
|
|
37
|
+
# Insika::ValidationError on an unknown or missing key.
|
|
38
|
+
def self.load(path)
|
|
39
|
+
bytes = read_bytes(path)
|
|
40
|
+
block = extract_yaml_block(bytes)
|
|
41
|
+
parsed = YAML.safe_load(block, permitted_classes: [], aliases: false)
|
|
42
|
+
rule = build_rule(parsed)
|
|
43
|
+
new(rule: rule, path: path, sha: "sha256:#{Digest::SHA256.hexdigest(bytes)}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_h = @rule.to_h
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def read_bytes(path)
|
|
52
|
+
File.read(path, encoding: "UTF-8")
|
|
53
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::EISDIR => e
|
|
54
|
+
raise Insika::ConfigError, "parity criterion not readable at #{path}: #{e.class}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def extract_yaml_block(bytes)
|
|
58
|
+
match = bytes.match(/```yaml\n(.*?)\n```/m)
|
|
59
|
+
raise Insika::ConfigError, "parity criterion has no ```yaml block (nothing to pre-register)" unless match
|
|
60
|
+
|
|
61
|
+
match[1]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_rule(parsed)
|
|
65
|
+
raise Insika::ValidationError, "criterion yaml block must be a mapping" unless parsed.is_a?(Hash)
|
|
66
|
+
|
|
67
|
+
raw = parsed.transform_keys(&:to_s)
|
|
68
|
+
keys = raw.keys.sort
|
|
69
|
+
missing = Rule.members.map(&:to_s) - keys
|
|
70
|
+
unknown = keys - Rule.members.map(&:to_s)
|
|
71
|
+
raise Insika::ValidationError, "criterion is missing key(s): #{missing.join(', ')}" if missing.any?
|
|
72
|
+
raise Insika::ValidationError, "criterion has unknown key(s): #{unknown.join(', ')}" if unknown.any?
|
|
73
|
+
|
|
74
|
+
Rule.new(**Rule.members.to_h { |m| [m, raw[m.to_s]] })
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|