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,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
|