insika 0.2.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 +145 -17
- data/README.md +7 -1
- data/bin/insika +211 -8
- data/docs/AGENTS.md +377 -2
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +99 -9
- data/docs/CONTEXT.md +127 -16
- data/docs/DEPLOY.md +5 -2
- data/docs/EVALS.md +19 -0
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +14 -8
- data/docs/OBSERVABILITY.md +54 -0
- data/docs/REFINEMENT.md +3 -3
- data/docs/RELEASING.md +27 -0
- data/docs/RUNNING-LOCAL.md +3 -3
- data/docs/SECURITY.md +61 -0
- data/docs/SKILLS.md +2 -2
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +1 -1
- data/lib/insika/agent_profile.rb +145 -10
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +118 -13
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +84 -1
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/cancel_followup.rb +49 -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 +1 -1
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +3 -0
- 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/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +716 -6
- data/lib/insika/dsl/runtime.rb +9 -3
- data/lib/insika/dsl/server_boot.rb +70 -1
- data/lib/insika/dsl.rb +83 -3
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +9 -2
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/pairwise.rb +18 -6
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +687 -70
- 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/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 +1 -1
- 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/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 +6 -1
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +6 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +22 -1
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +144 -4
- data/lib/insika/server/rack_app.rb +16 -0
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +4 -0
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +875 -14
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +22 -22
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +262 -5
- data/lib/insika/studio/nav_icons.rb +6 -1
- data/lib/insika/studio/views/agent_detail.erb +627 -84
- data/lib/insika/studio/views/agents.erb +10 -6
- 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/session.erb +56 -1
- data/lib/insika/studio/views/settings.erb +2 -0
- data/lib/insika/studio/views/skills.erb +0 -1
- 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/task_store.rb +21 -1
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +25 -1
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_state.rb +38 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +300 -13
- data/lib/insika.rb +92 -3
- metadata +77 -1
|
@@ -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
|
|
@@ -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
|