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.
Files changed (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +145 -17
  3. data/README.md +7 -1
  4. data/bin/insika +211 -8
  5. data/docs/AGENTS.md +377 -2
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +99 -9
  8. data/docs/CONTEXT.md +127 -16
  9. data/docs/DEPLOY.md +5 -2
  10. data/docs/EVALS.md +19 -0
  11. data/docs/FACTS.md +135 -0
  12. data/docs/HARVEST.md +117 -0
  13. data/docs/LOADTEST.md +14 -8
  14. data/docs/OBSERVABILITY.md +54 -0
  15. data/docs/REFINEMENT.md +3 -3
  16. data/docs/RELEASING.md +27 -0
  17. data/docs/RUNNING-LOCAL.md +3 -3
  18. data/docs/SECURITY.md +61 -0
  19. data/docs/SKILLS.md +2 -2
  20. data/docs/SOAK.md +127 -0
  21. data/docs/TOOLS.md +70 -2
  22. data/docs/domain.md +115 -0
  23. data/docs/index.md +1 -1
  24. data/lib/insika/agent_profile.rb +145 -10
  25. data/lib/insika/balloon_splitter.rb +102 -0
  26. data/lib/insika/budget_ledger.rb +34 -3
  27. data/lib/insika/cache_series_store.rb +49 -0
  28. data/lib/insika/channel_delivery.rb +118 -13
  29. data/lib/insika/channels/relay.rb +77 -3
  30. data/lib/insika/channels/web.rb +2 -2
  31. data/lib/insika/chat_builder.rb +84 -1
  32. data/lib/insika/checkpoint_store.rb +16 -0
  33. data/lib/insika/commands/agent_payload.rb +3 -3
  34. data/lib/insika/commands/cancel_followup.rb +49 -0
  35. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  36. data/lib/insika/commands/export_customer_memory.rb +48 -0
  37. data/lib/insika/commands/forget_customer.rb +117 -0
  38. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  39. data/lib/insika/commands/gate_harvest.rb +138 -0
  40. data/lib/insika/commands/gate_refinement.rb +1 -1
  41. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  42. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  43. data/lib/insika/commands/memory_put_fact.rb +23 -4
  44. data/lib/insika/commands/promote_harvest.rb +130 -0
  45. data/lib/insika/commands/record_outcome.rb +46 -0
  46. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  47. data/lib/insika/commands/reject_harvest.rb +38 -0
  48. data/lib/insika/commands/resolve_proposal.rb +108 -0
  49. data/lib/insika/commands/revoke_contact.rb +49 -0
  50. data/lib/insika/commands/rollback_harvest.rb +86 -0
  51. data/lib/insika/commands/run_distillation.rb +186 -0
  52. data/lib/insika/commands/run_harvest.rb +393 -0
  53. data/lib/insika/commands/send_message.rb +103 -6
  54. data/lib/insika/commands/session_purge.rb +67 -0
  55. data/lib/insika/contact_store.rb +183 -0
  56. data/lib/insika/context/builder.rb +21 -3
  57. data/lib/insika/context/fragment.rb +7 -3
  58. data/lib/insika/context/priority.rb +3 -0
  59. data/lib/insika/context/provider.rb +17 -3
  60. data/lib/insika/context/providers/briefing.rb +96 -0
  61. data/lib/insika/context/providers/memory.rb +16 -7
  62. data/lib/insika/context/providers/prompt.rb +30 -2
  63. data/lib/insika/context/providers/skill.rb +2 -0
  64. data/lib/insika/context/providers/tool_search.rb +2 -0
  65. data/lib/insika/context_trace_store.rb +38 -2
  66. data/lib/insika/distill.rb +224 -0
  67. data/lib/insika/distill_engine.rb +169 -0
  68. data/lib/insika/doctor.rb +716 -6
  69. data/lib/insika/dsl/runtime.rb +9 -3
  70. data/lib/insika/dsl/server_boot.rb +70 -1
  71. data/lib/insika/dsl.rb +83 -3
  72. data/lib/insika/edge_limiter.rb +28 -7
  73. data/lib/insika/env_schema.rb +9 -2
  74. data/lib/insika/errors.rb +11 -0
  75. data/lib/insika/evals/assertions.rb +3 -2
  76. data/lib/insika/evals/pairwise.rb +18 -6
  77. data/lib/insika/evidence.rb +183 -0
  78. data/lib/insika/executor.rb +687 -70
  79. data/lib/insika/followup_engine.rb +207 -0
  80. data/lib/insika/followup_policy.rb +221 -0
  81. data/lib/insika/followup_store.rb +306 -0
  82. data/lib/insika/funnel_declaration.rb +106 -0
  83. data/lib/insika/funnel_fold.rb +179 -0
  84. data/lib/insika/funnel_store.rb +163 -0
  85. data/lib/insika/golden_store.rb +1 -1
  86. data/lib/insika/grounding/matcher.rb +69 -0
  87. data/lib/insika/grounding.rb +44 -0
  88. data/lib/insika/harvest/conversion_gate.rb +159 -0
  89. data/lib/insika/harvest/criterion.rb +98 -0
  90. data/lib/insika/harvest/gate.rb +194 -0
  91. data/lib/insika/harvest/negative_list.rb +199 -0
  92. data/lib/insika/harvest.rb +241 -0
  93. data/lib/insika/harvest_engine.rb +193 -0
  94. data/lib/insika/harvest_store.rb +548 -0
  95. data/lib/insika/media.rb +298 -0
  96. data/lib/insika/memory_audit_store.rb +85 -0
  97. data/lib/insika/memory_store.rb +264 -23
  98. data/lib/insika/message_origin.rb +6 -1
  99. data/lib/insika/model_visible.rb +87 -0
  100. data/lib/insika/model_visible_trace_store.rb +66 -0
  101. data/lib/insika/onboarding.rb +6 -1
  102. data/lib/insika/outbox_store.rb +42 -4
  103. data/lib/insika/outcome_store.rb +147 -0
  104. data/lib/insika/packaging.rb +163 -0
  105. data/lib/insika/parity/criterion.rb +79 -0
  106. data/lib/insika/parity/verdict.rb +318 -0
  107. data/lib/insika/prefix_fingerprint.rb +58 -0
  108. data/lib/insika/profile_source.rb +22 -1
  109. data/lib/insika/proposal_store.rb +271 -0
  110. data/lib/insika/queue_policy.rb +4 -1
  111. data/lib/insika/refinement/proposer.rb +1 -1
  112. data/lib/insika/reliability.rb +32 -6
  113. data/lib/insika/retention.rb +281 -0
  114. data/lib/insika/routing.rb +101 -0
  115. data/lib/insika/safety/config.rb +44 -4
  116. data/lib/insika/safety/corpus.rb +255 -0
  117. data/lib/insika/safety/detectors.rb +32 -113
  118. data/lib/insika/safety/factory.rb +15 -2
  119. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  120. data/lib/insika/safety/grounding_validator.rb +49 -0
  121. data/lib/insika/safety/input_guardrail.rb +1 -1
  122. data/lib/insika/safety/moderator.rb +1 -1
  123. data/lib/insika/safety/output_filter.rb +9 -5
  124. data/lib/insika/safety/output_validator.rb +11 -5
  125. data/lib/insika/schema_guard.rb +35 -0
  126. data/lib/insika/server/app.rb +144 -4
  127. data/lib/insika/server/rack_app.rb +16 -0
  128. data/lib/insika/server/responses.rb +40 -1
  129. data/lib/insika/session_actor.rb +8 -4
  130. data/lib/insika/session_store.rb +65 -2
  131. data/lib/insika/settings_store.rb +10 -0
  132. data/lib/insika/shadow_pair_store.rb +258 -0
  133. data/lib/insika/skill_catalog.rb +4 -0
  134. data/lib/insika/soak/envelope.rb +140 -0
  135. data/lib/insika/soak/report.rb +392 -0
  136. data/lib/insika/soak/runner.rb +554 -0
  137. data/lib/insika/store.rb +10 -1
  138. data/lib/insika/stores/memory.rb +6 -0
  139. data/lib/insika/stores/sqlite.rb +8 -0
  140. data/lib/insika/studio/app.rb +875 -14
  141. data/lib/insika/studio/assets/dist/application.css +1 -1
  142. data/lib/insika/studio/assets/dist/application.js +22 -22
  143. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  144. data/lib/insika/studio/forms.rb +262 -5
  145. data/lib/insika/studio/nav_icons.rb +6 -1
  146. data/lib/insika/studio/views/agent_detail.erb +627 -84
  147. data/lib/insika/studio/views/agents.erb +10 -6
  148. data/lib/insika/studio/views/approvals.erb +4 -1
  149. data/lib/insika/studio/views/chats.erb +4 -1
  150. data/lib/insika/studio/views/customer.erb +94 -0
  151. data/lib/insika/studio/views/customers.erb +32 -0
  152. data/lib/insika/studio/views/evals.erb +4 -1
  153. data/lib/insika/studio/views/facts.erb +133 -0
  154. data/lib/insika/studio/views/followups.erb +125 -0
  155. data/lib/insika/studio/views/funnel.erb +106 -0
  156. data/lib/insika/studio/views/harvest.erb +234 -0
  157. data/lib/insika/studio/views/home.erb +2 -1
  158. data/lib/insika/studio/views/layout.erb +1 -0
  159. data/lib/insika/studio/views/parity.erb +147 -0
  160. data/lib/insika/studio/views/playground.erb +7 -1
  161. data/lib/insika/studio/views/session.erb +56 -1
  162. data/lib/insika/studio/views/settings.erb +2 -0
  163. data/lib/insika/studio/views/skills.erb +0 -1
  164. data/lib/insika/studio/views/system_files.erb +1 -1
  165. data/lib/insika/studio/views/task.erb +13 -0
  166. data/lib/insika/studio/views/tasks.erb +4 -1
  167. data/lib/insika/studio/views/tools.erb +0 -1
  168. data/lib/insika/task_store.rb +21 -1
  169. data/lib/insika/testing/store_contract.rb +27 -6
  170. data/lib/insika/tick.rb +25 -1
  171. data/lib/insika/tool_definition.rb +17 -7
  172. data/lib/insika/tool_envelope.rb +69 -0
  173. data/lib/insika/tool_manifest.rb +5 -1
  174. data/lib/insika/tools/data_defined_tool.rb +10 -0
  175. data/lib/insika/tools/generate_image.rb +44 -0
  176. data/lib/insika/tools/schedule_followup.rb +164 -0
  177. data/lib/insika/tools/tts.rb +47 -0
  178. data/lib/insika/tools/update_briefing.rb +126 -0
  179. data/lib/insika/turn_state.rb +38 -1
  180. data/lib/insika/turn_timing.rb +22 -2
  181. data/lib/insika/version.rb +1 -1
  182. data/lib/insika/vitals.rb +84 -0
  183. data/lib/insika/wiring/graph.rb +300 -13
  184. data/lib/insika.rb +92 -3
  185. metadata +77 -1
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Harvest
5
+ # C7 — the eval half of the double gate (D7). Scores ONE candidate by
6
+ # RUNNING it — the Refinement::Gate mechanism, with the skill's apply:
7
+ #
8
+ # 1. clone the agent into `<agent>-harvest-<run8>`,
9
+ # 2. copy its instruction files, write the candidate skill into the
10
+ # clone's AGENT-scoped SkillStore and enable it on the clone's
11
+ # allowlist (the clone's catalog then serves it — the skill is gated
12
+ # by being *usable*, not by prose),
13
+ # 3. replay the golden set against the clone over the ordinary public
14
+ # surface, compare to the accepted baseline,
15
+ # 4. ANY regression disqualifies,
16
+ # 5. destroy the clone in an `ensure`.
17
+ #
18
+ # Judges are MANDATORY in exactly the three refusal shapes the refined
19
+ # gate already encodes (no baseline / all-red baseline / judged baseline
20
+ # without a judge) — the P18 stamp. A skill that regresses ANY golden case
21
+ # is rejected — this gate is a veto, never a score to argue with.
22
+ class Gate
23
+ # The verdict for one candidate. `passed` is the only field the caller
24
+ # acts on; the rest is what an operator reads.
25
+ Report = Data.define(:candidate_id, :passed, :reason, :cases, :passed_cases,
26
+ :baseline_cases, :regressions, :report, :tokens, :cached) do
27
+ def to_h
28
+ { "candidate_id" => candidate_id, "passed" => passed, "reason" => reason,
29
+ "cases" => cases, "passed_cases" => passed_cases, "baseline_cases" => baseline_cases,
30
+ "regressions" => regressions, "report" => report, "tokens" => tokens,
31
+ "cached" => cached }
32
+ end
33
+ end
34
+
35
+ DEFAULT_TOLERANCE = 0.05
36
+
37
+ # skill_catalog: the graph's catalog (overlays the SkillStore) — the
38
+ # apply writes into the store then RELOADS it, so the clone's next
39
+ # dispatch serves the skill. judge_factory: the settings["evals"] panel
40
+ # (the same one the refinement gate receives).
41
+ def initialize(profiles:, agent_files:, goldens:, baselines:, skill_store:,
42
+ skill_catalog:, transport_factory:, capabilities_factory: nil,
43
+ judge_factory: nil, tolerance: DEFAULT_TOLERANCE)
44
+ @profiles = profiles
45
+ @agent_files = agent_files
46
+ @goldens = goldens
47
+ @baselines = baselines
48
+ @skill_store = skill_store
49
+ @skill_catalog = skill_catalog
50
+ @transport_factory = transport_factory
51
+ @capabilities_factory = capabilities_factory
52
+ @judge_factory = judge_factory
53
+ @tolerance = tolerance
54
+ end
55
+
56
+ # skill: the candidate's { name:, description:, body:, triggers: }.
57
+ # -> Report. Never raises for an ordinary refusal (no cases, no
58
+ # baseline, a replay that blew up).
59
+ def score(agent_id:, skill:, run_id:)
60
+ cases = @goldens.for_agent(agent_id)
61
+ return refusal(skill, "the agent has no golden cases — nothing to gate against") if cases.empty?
62
+
63
+ baseline = @baselines.get(agent_id)
64
+ if baseline.nil?
65
+ return refusal(skill, "no recorded baseline for '#{agent_id}' — " \
66
+ "run `insika evals:baseline import` or record one before gating")
67
+ end
68
+
69
+ if passing_cases(baseline).zero?
70
+ return refusal(skill, "the recorded baseline for '#{agent_id}' has no PASSING case " \
71
+ "(#{baseline_size(baseline)} recorded, all failing) — nothing could " \
72
+ "regress, so every candidate would pass. Fix the agent or the cases, " \
73
+ "then re-record the baseline from a green run")
74
+ end
75
+
76
+ judge = @judge_factory&.call
77
+ if judge.nil? && judged?(baseline)
78
+ return refusal(skill, "the recorded baseline for '#{agent_id}' carries judge scores but no " \
79
+ "judge is configured — a rubric'd case with no verdict counts as a " \
80
+ "PASS, so every candidate would beat it. Configure the judge panel " \
81
+ "(Studio → Settings → Evals, or `settings[\"evals\"][\"judges\"]`) or " \
82
+ "re-record the baseline without one")
83
+ end
84
+
85
+ clone_id = clone_id_for(agent_id, run_id)
86
+ begin
87
+ build_clone(agent_id, clone_id, skill)
88
+ ran = replay(cases, clone_id, judge)
89
+ verdict(skill, ran, baseline, @tolerance)
90
+ rescue StandardError => e
91
+ refusal(skill, "gate failed to run: #{e.class}: #{e.message}")
92
+ ensure
93
+ destroy_clone(clone_id)
94
+ end
95
+ end
96
+
97
+ def clone_id_for(agent_id, run_id) = "#{agent_id}-harvest-#{run_id.to_s.delete('-')[0, 8]}"
98
+
99
+ def passing_cases(baseline)
100
+ (baseline["cases"] || {}).count { |_id, entry| entry.is_a?(Hash) && entry["pass"] }
101
+ end
102
+
103
+ def judged?(baseline)
104
+ (baseline["cases"] || {}).any? { |_id, entry| entry.is_a?(Hash) && !entry["score"].nil? }
105
+ end
106
+
107
+ private
108
+
109
+ def baseline_size(baseline) = (baseline["cases"] || {}).size
110
+
111
+ # Same profile, same tools, same guardrails — only the id and the skill
112
+ # differ. Copying the profile rather than editing the real one is what
113
+ # makes this safe to run against production: the live agent is never
114
+ # touched, not even for a moment.
115
+ def build_clone(agent_id, clone_id, skill)
116
+ profile = @profiles.fetch(agent_id) ||
117
+ (raise Insika::NotFoundError, "agent '#{agent_id}' not configured")
118
+
119
+ @profiles.put(profile.with(id: clone_id))
120
+ current_files(agent_id).each { |name, body| @agent_files.write(clone_id, name, body) }
121
+
122
+ # D7: the skill lands into the clone's AGENT-scoped SkillStore (the
123
+ # store position IS the identity), and the clone's profile `skills`
124
+ # gains the name — so the clone's catalog serves it and the model can
125
+ # load_skill it.
126
+ name = skill["name"].to_s
127
+ @skill_store.write(name, skill["body"].to_s, agent: clone_id)
128
+ enabled = profile.skills.nil? ? nil : (Array(profile.skills).map(&:to_s) | [name])
129
+ @profiles.put(profile.with(id: clone_id, skills: enabled))
130
+ @skill_catalog.reload
131
+ end
132
+
133
+ def current_files(agent_id)
134
+ @agent_files.list(agent_id).each_with_object({}) do |name, acc|
135
+ acc[name] = @agent_files.read(agent_id, name).to_s
136
+ end
137
+ end
138
+
139
+ def replay(cases, clone_id, judge)
140
+ retargeted = cases.map { |g| g.class.new(**g.to_h.merge(agent: clone_id)) }
141
+ runner = Insika::Evals::Runner.new(transport: @transport_factory.call, judge: judge,
142
+ capabilities: @capabilities_factory&.call)
143
+ runner.run(retargeted)
144
+ end
145
+
146
+ def verdict(skill, ran, baseline, tolerance)
147
+ results = ran.map(&:result)
148
+ regressions = Insika::Evals::Baseline.compare(results, baseline, tolerance: tolerance)
149
+ passed = results.count(&:pass?)
150
+ graded = results.reject(&:skipped?).size
151
+ spent = ran.filter_map(&:tokens)
152
+ cached = ran.filter_map(&:cached)
153
+
154
+ Report.new(
155
+ candidate_id: skill["name"], passed: regressions.empty?,
156
+ reason: regressions.empty? ? nil : regression_reason(regressions),
157
+ cases: graded, passed_cases: passed,
158
+ baseline_cases: (baseline["cases"] || {}).size,
159
+ regressions: regressions.map { |r| { "id" => r.id, "kind" => r.kind, "detail" => r.detail } },
160
+ report: Insika::Evals::Report.to_h(results, at: Time.now.utc.iso8601),
161
+ tokens: spent.empty? ? nil : spent.sum,
162
+ cached: cached.empty? ? nil : cached.sum
163
+ )
164
+ end
165
+
166
+ def regression_reason(regressions)
167
+ "#{regressions.size} regression(s): " +
168
+ regressions.first(3).map { |r| "#{r.id} (#{r.kind})" }.join(", ")
169
+ end
170
+
171
+ def refusal(skill, reason)
172
+ Report.new(candidate_id: skill["name"], passed: false, reason: reason,
173
+ cases: 0, passed_cases: 0, baseline_cases: 0, regressions: [], report: nil,
174
+ tokens: nil, cached: nil)
175
+ end
176
+
177
+ # Both halves tolerant of a missing one; runs in an `ensure` after a
178
+ # failure that may have happened before either was created.
179
+ def destroy_clone(clone_id)
180
+ @agent_files.list(clone_id).each { |name| @agent_files.delete(clone_id, name) }
181
+ begin
182
+ Array(@skill_store.names(agent: clone_id)).each do |name|
183
+ @skill_store.delete(name, agent: clone_id)
184
+ end
185
+ rescue StandardError
186
+ nil # a leftover skill record is clutter, never servable without the profile
187
+ end
188
+ @profiles.delete(clone_id)
189
+ rescue StandardError => e
190
+ warn "[harvest] could not delete the gate clone '#{clone_id}': #{e.class}: #{e.message}"
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Harvest
5
+ # C3 — the versioned negative list : things the harvester must
6
+ # never propose. Pure value object — never touches a store, never authors
7
+ # a rule (D4: the engine applies it, the forge authors it). `parse`
8
+ # accepts BOTH the markdown file shape (frontmatter + rule bullets) and
9
+ # the pack-data array shape ({ rule, pattern, note } hashes), so a rule
10
+ # cannot exist in one and not the other (E2's drift guard).
11
+ #
12
+ # A rule is a phrase or a regex. Phrases match case/accent-folded at WORD
13
+ # boundaries (the hygiene — "NÃO DEVOLVEMOS" is the same
14
+ # word as "não devolvemos"); regexes match the raw OR the folded text (the
15
+ # author may write either spelling, `/…/flags` honored). `matches_name` is
16
+ # the stricter SUBSTRING reading the CI spec uses on skill names.
17
+ class NegativeList
18
+ Rule = Data.define(:rule, :pattern, :note, :regexp)
19
+
20
+ # The pt-BR vowel fold — stdlib only, no UnicodeUtils. Both sides of a
21
+ # phrase match are folded through this map.
22
+ FOLD = {
23
+ "á" => "a", "à" => "a", "ã" => "a", "â" => "a",
24
+ "é" => "e", "ê" => "e",
25
+ "í" => "i",
26
+ "ó" => "o", "õ" => "o", "ô" => "o",
27
+ "ú" => "u", "ü" => "u",
28
+ "ç" => "c"
29
+ }.freeze
30
+ FOLD_RE = Regexp.union(FOLD.keys)
31
+
32
+ attr_reader :rules
33
+
34
+ # A file line: "- `rule-id` — <phrase-or-/regex/> — <note>" (the note
35
+ # may be empty; a phrase may be quoted — `"concorrente"` — and the
36
+ # quotes are not part of the pattern, matching the pack-array shape). A
37
+ # malformed line refuses the WHOLE list — a half-parsed list silently
38
+ # admits what the store banned.
39
+
40
+ def self.parse(raw)
41
+ parse!(raw)
42
+ rescue Insika::ValidationError
43
+ nil
44
+ end
45
+
46
+ # -> NegativeList. Raises Insika::ValidationError naming the defect —
47
+ # the E2 seed path (`insika harvest:negative import`) uses THIS one.
48
+ def self.parse!(raw)
49
+ new(rules: parse_rules(raw))
50
+ end
51
+
52
+ def self.parse_rules(raw)
53
+ return [] if raw.nil?
54
+
55
+ case raw
56
+ when String
57
+ file_rules(raw)
58
+ when Array
59
+ array_rules(raw)
60
+ else
61
+ raise Insika::ValidationError, "negative list must be file text or an array of rules"
62
+ end
63
+ end
64
+ private_class_method :parse_rules
65
+
66
+ def self.file_rules(text)
67
+ text.each_line.with_index(1).filter_map do |line, index|
68
+ next if line.strip.empty? || line.strip.start_with?("#")
69
+
70
+ id_match = /\A`([^`]+)`\s+—\s*/.match(line.strip.delete_prefix("- "))
71
+ raise Insika::ValidationError, "negative list line #{index} is malformed — " \
72
+ "expected `- \\`rule-id\\` — <phrase-or-/regex/> — <note>`" if id_match.nil?
73
+
74
+ rest = line.strip.delete_prefix("- ").sub(/\A`[^`]+`\s+—\s*/, "")
75
+ # a trailing bare " —" (an empty note) is presentation, not content
76
+ rest = rest.sub(/\s+—\s*\z/, "") if rest.match?(/\s+—\s*\z/)
77
+ pattern, note = rest.split(" — ", 2)
78
+ build_rule(rule: id_match[1], pattern: strip_quotes(pattern.to_s.strip),
79
+ note: note.to_s.strip, line: index)
80
+ end
81
+ end
82
+ private_class_method :file_rules
83
+
84
+ # The file shape quotes a phrase (`"concorrente"`); the quotes are
85
+ # presentation, not part of the pattern — the pack-array shape has none.
86
+ def self.strip_quotes(pattern)
87
+ return pattern[1...-1] if pattern.start_with?('"') && pattern.end_with?('"') && pattern.length >= 2
88
+
89
+ pattern
90
+ end
91
+ private_class_method :strip_quotes
92
+
93
+ def self.array_rules(entries)
94
+ entries.each_with_index.map do |entry, i|
95
+ unless entry.is_a?(Hash)
96
+ raise Insika::ValidationError, "negative list entry ##{i + 1} is not a { rule, pattern } hash"
97
+ end
98
+
99
+ h = Coercion.deep_stringify(entry)
100
+ rule = Coercion.presence(h["rule"])
101
+ pattern = Coercion.presence(h["pattern"])
102
+ raise Insika::ValidationError, "negative list entry ##{i + 1} is missing a rule id" if rule.nil?
103
+ raise Insika::ValidationError, "negative list rule '#{rule}' is missing a pattern" if pattern.nil?
104
+
105
+ build_rule(rule: rule, pattern: pattern, note: h["note"].to_s.strip, line: i + 1)
106
+ end
107
+ end
108
+ private_class_method :array_rules
109
+
110
+ def self.build_rule(rule:, pattern:, note:, line:)
111
+ Rule.new(rule: rule, pattern: pattern, note: note, regexp: compile_regexp(pattern, rule, line))
112
+ end
113
+ private_class_method :build_rule
114
+
115
+ # "/…/flags" -> Regexp; anything else is a literal phrase (regexp nil).
116
+ def self.compile_regexp(pattern, rule, line)
117
+ return nil unless pattern.start_with?("/")
118
+
119
+ closing = pattern.rindex("/")
120
+ unless closing && closing > 0
121
+ raise Insika::ValidationError, "negative list rule '#{rule}' (line #{line}) has a malformed regex: #{pattern.inspect}"
122
+ end
123
+
124
+ source = pattern[1...closing]
125
+ flags = pattern[(closing + 1)..].to_s
126
+ Regexp.new(source, regexp_flags(flags, rule, line))
127
+ rescue RegexpError => e
128
+ raise Insika::ValidationError,
129
+ "negative list rule '#{rule}' (line #{line}) has a regex that does not compile: #{e.message}"
130
+ end
131
+ private_class_method :compile_regexp
132
+
133
+ def self.regexp_flags(flags, rule, line)
134
+ i = flags.include?("i")
135
+ m = flags.include?("m")
136
+ x = flags.include?("x")
137
+ unknown = flags.delete("i").delete("m").delete("x")
138
+ unless unknown.empty?
139
+ raise Insika::ValidationError,
140
+ "negative list rule '#{rule}' (line #{line}) has unsupported regex flags: #{unknown.inspect}"
141
+ end
142
+
143
+ (i ? Regexp::IGNORECASE : 0) | (m ? Regexp::MULTILINE : 0) | (x ? Regexp::EXTENDED : 0)
144
+ end
145
+ private_class_method :regexp_flags
146
+
147
+ def initialize(rules:)
148
+ @rules = rules
149
+ end
150
+
151
+ # -> [Rule] every rule whose pattern matched the text (word-boundary,
152
+ # case + accent folded for phrases; raw-or-folded for regexes). Empty =
153
+ # clean.
154
+ def matches(text)
155
+ haystack = text.to_s
156
+ folded = fold(haystack)
157
+ @rules.select { |r| rule_match?(r, haystack, folded) }
158
+ end
159
+
160
+ # -> [Rule] every rule whose pattern appears as a SUBSTRING (the stricter
161
+ # reading the CI spec uses on skill NAMES — a name containing a banned
162
+ # token is banned even mid-word).
163
+ def matches_name(name)
164
+ haystack = name.to_s
165
+ folded = fold(haystack)
166
+ @rules.select { |r| name_match?(r, haystack, folded) }
167
+ end
168
+
169
+ # -> Hash { rule => count } — what the run log records.
170
+ def reject_counts(text)
171
+ matches(text).each_with_object({}) { |r, acc| acc[r.rule] = (acc[r.rule] || 0) + 1 }
172
+ end
173
+
174
+ private
175
+
176
+ def rule_match?(rule, raw, folded)
177
+ if rule.regexp
178
+ rule.regexp.match?(raw) || rule.regexp.match?(folded)
179
+ else
180
+ pattern = fold(rule.pattern)
181
+ folded.match?(/(?:\A|\W)#{Regexp.escape(pattern)}(?:\W|\z)/i)
182
+ end
183
+ end
184
+
185
+ def name_match?(rule, raw, folded)
186
+ if rule.regexp
187
+ rule.regexp.match?(raw) || rule.regexp.match?(folded)
188
+ else
189
+ folded.include?(fold(rule.pattern))
190
+ end
191
+ end
192
+
193
+ # Case + accent fold for the pt-BR vowels — stdlib only.
194
+ def fold(text)
195
+ text.to_s.downcase.gsub(FOLD_RE, FOLD)
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ # the one place harvest asks a model for anything.
7
+ #
8
+ # The engine's generic prompt (what a harvestable skill is, the answer
9
+ # shape, the provenance + grounding rules — "only reference IDs you saw in
10
+ # the evidence; never invent a product"). A pack `harvest.prompt` REPLACES
11
+ # this wholesale (the forge's half).
12
+ module Harvest
13
+ DEFAULT_PROMPT = <<~PROMPT.freeze
14
+ You are mining SKILLS from finished customer-service conversations of ONE
15
+ store agent, for a playbook the agent loads on demand. A skill is a
16
+ reusable procedure: WHEN to load it and the exact steps to follow. It is
17
+ NOT a fact about one customer, and NOT a rewrite of the agent's
18
+ instructions.
19
+
20
+ Answer with a single JSON array and NOTHING else. No prose, no fences.
21
+
22
+ Each element is an object with:
23
+ - "name" — the skill's key, short (max 64 chars), lowercase,
24
+ underscore-separated;
25
+ - "description" — one line (max 300 chars): what the skill is for;
26
+ - "body" — the SKILL.md body (max 6000 chars): the procedure itself;
27
+ - "triggers" — optional, up to 10 short words or phrases that should
28
+ surface this skill;
29
+ - "rationale" — optional, one line: what problem the skill solves;
30
+ - "evidence_turns" — optional, the message indexes in the conversations
31
+ that support it.
32
+
33
+ Rules:
34
+ - Reference products by their ID only — an ID you saw in the evidence.
35
+ Never invent a SKU or a product name; the engine rejects anything it
36
+ cannot verify (grounding).
37
+ - Never include a session id, a customer id or a tenant — the engine
38
+ stamps the origin itself.
39
+ - A skill every good agent already does is not a skill worth proposing.
40
+ - Fewer, better skills beat filling a quota.
41
+ PROMPT
42
+
43
+ # The safe-subset JSON Schema (array of objects — Workflow::Schema's
44
+ # subset, the house zero-dep validator). The miner rejects any key OUTSIDE
45
+ # this set and counts the drop: a model-authored `origin`/`agent` would be
46
+ # a provenance lie — the schema refuses it by not having the key, and the
47
+ # engine stamps origin itself.
48
+ SKILL_SCHEMA = Insika::Workflow::Schema.coerce({
49
+ "type" => "array",
50
+ "items" => {
51
+ "type" => "object",
52
+ "properties" => {
53
+ "name" => { "type" => "string" },
54
+ "description" => { "type" => "string" },
55
+ "body" => { "type" => "string" },
56
+ "triggers" => { "type" => "array", "items" => { "type" => "string" } },
57
+ "rationale" => { "type" => "string" },
58
+ "evidence_turns" => { "type" => "array", "items" => { "type" => "integer" } }
59
+ },
60
+ "required" => %w[name description body]
61
+ }
62
+ })
63
+
64
+ # The per-SKILL half (an item of SKILL_SCHEMA) — validated per element.
65
+ ITEM_SCHEMA = Insika::Workflow::Schema.coerce(SKILL_SCHEMA.json_schema["items"])
66
+
67
+ # The model's raw skills, filtered into data the command then filters
68
+ # (negative list, grounding, dedup — C6) and a human then gates.
69
+ # Pure over an injected `ask` — the Refinement::Proposer shape.
70
+ class Miner
71
+ # A model that answers with prose instead of JSON produces NOTHING,
72
+ # loudly — empty output must not read as "the traffic is clean".
73
+ class Unusable < Insika::ValidationError; end
74
+
75
+ MAX_NAME = 64
76
+ MAX_DESCRIPTION = 300
77
+ MAX_BODY = 6000
78
+ MAX_TRIGGERS = 10
79
+ MAX_TRIGGER = 80
80
+ MAX_RATIONALE = 500
81
+ MAX_EVIDENCE_TURNS = 20
82
+ # The window cap the RUN applies (C6): a candidate whose origin sessions
83
+ # are a lie of provenance must never be stamped from a window of 50.
84
+ MAX_SESSIONS = 5
85
+
86
+ ALLOWED_KEYS = %w[name description body triggers rationale evidence_turns].freeze
87
+ DROP_KEYS = %w[schema unknown_key oversized bad_turns duplicate capped].freeze
88
+ # The A/B audit counters: the distinct drops a run records and the
89
+ # first-10 audit reads back.
90
+
91
+ # ask: ->(prompt) { "<raw model text>" } | something answering #content
92
+ # (+ #input_tokens/#output_tokens/#cached_tokens for cost).
93
+ # model: the ref recorded as the candidate's `proposer` ("utility_model"
94
+ # default).
95
+ attr_reader :model
96
+
97
+ def initialize(ask:, model: "utility_model")
98
+ @ask = ask
99
+ @model = model.to_s
100
+ end
101
+
102
+ # -> { skills: [ raw candidate hashes ],
103
+ # dropped: { "schema" => N, "unknown_key" => N, "oversized" => N,
104
+ # "bad_turns" => N, "duplicate" => N, "capped" => N },
105
+ # cost: { "spent" => N, "cached" => N } | nil }
106
+ # prompt: the pack prompt or DEFAULT_PROMPT (the caller resolved it).
107
+ # message_counts: the origin sessions' transcript sizes, in prompt order,
108
+ # so `evidence_turns` indexes are validated against the sessions they
109
+ # name (an index is valid if it fits at least one session).
110
+ # max_proposals: cap on surviving raw skills. Drops counted, never fixed.
111
+ def mine(prompt:, message_counts:, max_proposals: 10)
112
+ answer = @ask.call(prompt)
113
+ raw = parse(text_of(answer))
114
+ skills = []
115
+ dropped = DROP_KEYS.to_h { |k| [k, 0] }
116
+ seen = {}
117
+ raw.each do |item|
118
+ verdict, reason = classify(item, message_counts)
119
+ case verdict
120
+ when :keep
121
+ tuple = [item["name"].to_s, item["description"].to_s, item["body"].to_s]
122
+ if seen[tuple]
123
+ dropped["duplicate"] += 1
124
+ elsif skills.size >= max_proposals
125
+ dropped["capped"] += 1
126
+ else
127
+ seen[tuple] = true
128
+ skills << normalize(item)
129
+ end
130
+ when :drop
131
+ dropped[reason] += 1
132
+ end
133
+ end
134
+ { skills: skills, dropped: dropped, cost: cost_of(answer) }
135
+ end
136
+
137
+ private
138
+
139
+ def text_of(answer) = (answer.respond_to?(:content) ? answer.content : answer).to_s
140
+
141
+ # nil when the provider said nothing — never 0 (the Proposer's
142
+ # discipline). The cached prefix is INCLUDED in the spent total (E1: the
143
+ # run's cost is the harvest's only side spend).
144
+ def cost_of(answer)
145
+ return nil unless answer.respond_to?(:input_tokens) && answer.respond_to?(:output_tokens)
146
+
147
+ input = answer.input_tokens.to_i
148
+ output = answer.output_tokens.to_i
149
+ cached = answer.respond_to?(:cached_tokens) ? answer.cached_tokens.to_i : 0
150
+ spent = input + output + cached
151
+ spent.positive? ? { "spent" => spent, "cached" => cached } : nil
152
+ end
153
+
154
+ # Fences stripped, parsed STRICTLY (the Proposer's discipline): a model
155
+ # that improvises a schema fails here instead of producing half a skill.
156
+ def parse(raw)
157
+ body = raw.strip.gsub(/\A```(?:json)?\s*|\s*```\z/, "")
158
+ parsed = JSON.parse(body)
159
+ raise Unusable, "the miner's answer is not an array" unless parsed.is_a?(Array)
160
+
161
+ parsed
162
+ rescue JSON::ParserError => e
163
+ raise Unusable, "the miner's answer is not valid JSON: #{e.message}"
164
+ end
165
+
166
+ # -> [:keep, nil] | [:drop, drop_key]. The safety order: unknown keys
167
+ # first (a model-authored `origin` is a provenance lie), then the
168
+ # evidence bounds, then the schema, then the length caps.
169
+ def classify(item, message_counts)
170
+ return [:drop, "schema"] unless item.is_a?(Hash)
171
+ return [:drop, "unknown_key"] unless (item.keys.map(&:to_s) - ALLOWED_KEYS).empty?
172
+
173
+ turns = item["evidence_turns"]
174
+ if turns
175
+ return [:drop, "bad_turns"] if !turns.is_a?(Array) || turns.size > MAX_EVIDENCE_TURNS
176
+ span = Array(message_counts).map(&:to_i).max.to_i
177
+ return [:drop, "bad_turns"] if turns.any? { |t| !t.is_a?(Integer) || t.negative? || (span.positive? && t >= span) }
178
+ end
179
+
180
+ unless ITEM_SCHEMA.call(item).success?
181
+ return [:drop, "schema"]
182
+ end
183
+
184
+ return [:drop, "oversized"] if oversized?(item)
185
+
186
+ [:keep, nil]
187
+ end
188
+
189
+ def oversized?(item)
190
+ item["name"].to_s.length > MAX_NAME ||
191
+ item["description"].to_s.length > MAX_DESCRIPTION ||
192
+ item["body"].to_s.length > MAX_BODY ||
193
+ item["rationale"].to_s.length > MAX_RATIONALE ||
194
+ Array(item["triggers"]).size > MAX_TRIGGERS ||
195
+ Array(item["triggers"]).any? { |t| t.to_s.length > MAX_TRIGGER }
196
+ end
197
+
198
+ # The SAFE subset only — anything the model smuggled in has already
199
+ # dropped the skill.
200
+ def normalize(item)
201
+ item.slice(*ALLOWED_KEYS)
202
+ end
203
+ end
204
+
205
+ # Resolves WHICH model mines, and builds the ask. Profile -> platform
206
+ # utility_model -> nil (D12 — nil means "feature inert", never a guess).
207
+ # `ask_factory`/`llm` injectable (specs).
208
+ module MinerFactory
209
+ module_function
210
+
211
+ # config: the agent's `harvest` hash. -> Miner | nil
212
+ def build(config, utility_model: nil, ask_factory: nil, llm: nil)
213
+ ref = Coercion.presence(config && config.dig("miner", "model")) || Coercion.presence(utility_model)
214
+ return nil if ref.nil?
215
+
216
+ provider, model = split_ref(ref)
217
+ factory = ask_factory || ->(m, p) { ruby_llm_ask(m, p, llm: llm) }
218
+ Miner.new(ask: factory.call(model, provider), model: ref)
219
+ end
220
+
221
+ # "provider/model" -> [provider, model]; "model" -> [nil, model] — the
222
+ # ProposerFactory reading, one syntax for "which model" across features.
223
+ def split_ref(ref)
224
+ prov, name = ref.to_s.split("/", 2)
225
+ name ? [prov, name] : [nil, prov]
226
+ end
227
+
228
+ # Temperature 0: a rejected skill must be re-proposable deterministically.
229
+ # `ruby_llm` is required lazily so nothing loads a provider gem until a
230
+ # miner is actually configured (load_guard stays green).
231
+ def ruby_llm_ask(model, provider, llm: nil)
232
+ require "ruby_llm"
233
+ llm ||= RubyLLM
234
+ lambda do |prompt|
235
+ llm.chat(model: model, provider: provider, assume_model_exists: true)
236
+ .with_temperature(0).ask(prompt)
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end