insika 0.2.0 → 0.7.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 +325 -17
- data/README.md +52 -11
- data/bin/insika +895 -8
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +229 -163
- data/docs/API.md +56 -0
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +111 -21
- data/docs/CONTEXT.md +132 -19
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +75 -4
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +146 -2
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +116 -0
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +16 -10
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +65 -6
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +17 -12
- data/docs/RELEASING.md +27 -0
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +6 -6
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +79 -2
- data/docs/SKILLS.md +13 -4
- data/docs/SOAK.md +127 -0
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +220 -27
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/_includes/head_custom.html +5 -0
- data/docs/_includes/title.html +13 -0
- data/docs/_sass/color_schemes/insika.scss +32 -0
- data/docs/_sass/custom/custom.scss +199 -0
- data/docs/_sass/custom/setup.scss +26 -0
- data/docs/assets/img/favicon.svg +7 -0
- data/docs/assets/img/insika-mark.svg +7 -0
- data/docs/core-concepts.md +21 -0
- data/docs/domain.md +115 -0
- data/docs/improve.md +20 -0
- data/docs/index.md +9 -6
- data/docs/integrate.md +20 -0
- data/docs/operate.md +13 -6
- data/docs/prompts/ADD-TOOL.md +118 -0
- data/docs/prompts/DIAGNOSE-TURN.md +65 -0
- data/docs/prompts/GO-LIVE.md +138 -0
- data/docs/prompts/RUN-EXAMPLES.md +70 -0
- data/docs/reference.md +19 -0
- data/docs/ship.md +10 -2
- data/docs/start-here.md +18 -0
- data/lib/insika/agent_profile.rb +202 -10
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- 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 +119 -14
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +105 -2
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_artifact.rb +35 -0
- data/lib/insika/commands/delete_concept.rb +34 -0
- data/lib/insika/commands/delete_mcp.rb +6 -2
- data/lib/insika/commands/delete_tenant_data.rb +107 -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/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/restore_concept.rb +34 -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/seed_demo_data.rb +31 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -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 +5 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +57 -23
- 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/cron.rb +189 -0
- data/lib/insika/demo/agent_attrs.rb +43 -0
- data/lib/insika/demo/golden_cases.rb +81 -0
- data/lib/insika/demo/seeder.rb +336 -0
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +889 -11
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +69 -82
- data/lib/insika/dsl/server_boot.rb +92 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +186 -5
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +25 -3
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +29 -6
- data/lib/insika/evals/persona.rb +98 -0
- data/lib/insika/evals/runner.rb +9 -0
- data/lib/insika/evals/simulator.rb +225 -0
- data/lib/insika/evals/transport.rb +83 -1
- data/lib/insika/event_stream.rb +10 -0
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +869 -76
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +198 -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 +17 -2
- 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/knowledge.rb +680 -0
- data/lib/insika/knowledge_store.rb +140 -0
- data/lib/insika/mcp_client.rb +94 -0
- data/lib/insika/mcp_json.rb +74 -0
- data/lib/insika/mcp_live_tool.rb +43 -0
- data/lib/insika/mcp_store.rb +98 -26
- data/lib/insika/mcp_tool_ingestor.rb +30 -8
- data/lib/insika/mcp_tool_registry.rb +100 -0
- data/lib/insika/media.rb +382 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +7 -2
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +22 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +37 -17
- 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 +29 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- 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 +316 -0
- data/lib/insika/router/app.rb +157 -0
- data/lib/insika/router/backend_pool.rb +98 -0
- data/lib/insika/router/hash_ring.rb +55 -0
- data/lib/insika/router/proxy_body.rb +34 -0
- data/lib/insika/router/session_key.rb +54 -0
- data/lib/insika/router.rb +18 -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/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +247 -17
- data/lib/insika/server/rack_app.rb +21 -1
- 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 +16 -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/steer_injector.rb +21 -10
- 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 +1437 -54
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +23 -23
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +308 -10
- data/lib/insika/studio/nav_icons.rb +19 -1
- data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
- data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
- data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
- data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
- data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
- data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
- data/lib/insika/studio/views/_agents_master.erb +44 -0
- data/lib/insika/studio/views/_message.erb +49 -32
- data/lib/insika/studio/views/agent_detail.erb +62 -278
- data/lib/insika/studio/views/agents.erb +71 -54
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- 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 +6 -3
- 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 +106 -66
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +15 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- 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 +233 -124
- data/lib/insika/studio/views/settings.erb +41 -1
- data/lib/insika/studio/views/skills.erb +1 -2
- 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 +24 -10
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/templates/browser-agent/README.md +36 -0
- data/lib/insika/templates/browser-agent/agent.rb +49 -0
- data/lib/insika/templates/daily-digest/README.md +38 -0
- data/lib/insika/templates/daily-digest/agent.rb +77 -0
- data/lib/insika/templates/repo-explorer/README.md +36 -0
- data/lib/insika/templates/repo-explorer/agent.rb +45 -0
- data/lib/insika/templates/research-analyst/README.md +26 -0
- data/lib/insika/templates/research-analyst/agent.rb +58 -0
- data/lib/insika/templates/review-panel/README.md +20 -0
- data/lib/insika/templates/review-panel/agent.rb +50 -0
- data/lib/insika/templates/travel-planner/README.md +35 -0
- data/lib/insika/templates/travel-planner/agent.rb +87 -0
- data/lib/insika/templates.rb +112 -0
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +47 -11
- data/lib/insika/timezone.rb +45 -0
- 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 +89 -0
- data/lib/insika/tools/load_knowledge.rb +74 -0
- data/lib/insika/tools/run_persona_eval.rb +328 -0
- data/lib/insika/tools/save_artifact.rb +95 -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_output.rb +1 -1
- data/lib/insika/turn_state.rb +49 -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 +472 -13
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +149 -3
- metadata +181 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
module Commands
|
|
8
|
+
# — the snapshot restored. `RollbackHarvest(snapshot_ref:)`
|
|
9
|
+
# restores the pre-promotion state deterministically: WriteSkill with the
|
|
10
|
+
# snapshot's pre-promotion content (or DeleteSkill when the skill did not
|
|
11
|
+
# exist before — the snapshot's `existed` flag), SetSkillAgents to the
|
|
12
|
+
# snapshot's pre-promotion `enabled_for`, and the `rolled_back_at` stamp on
|
|
13
|
+
# the SAME log (the log stays the single ledger: a skill promoted, rolled
|
|
14
|
+
# back, re-promoted is three readable rows). The "rehearsed once
|
|
15
|
+
# end-to-end" acceptance is this command's full promote -> rollback cycle.
|
|
16
|
+
class RollbackHarvest
|
|
17
|
+
def initialize(harvest_store:, skill_store:, skill_catalog:, profile_source:,
|
|
18
|
+
event_stream:, write_skill: nil, delete_skill: nil, set_skill_agents: nil)
|
|
19
|
+
@harvest_store = harvest_store
|
|
20
|
+
@profiles = ProfileSource.coerce(profile_source)
|
|
21
|
+
@event_stream = event_stream
|
|
22
|
+
@write_skill = write_skill ||
|
|
23
|
+
Insika::Commands::WriteSkill.new(skill_store: skill_store,
|
|
24
|
+
skill_catalog: skill_catalog,
|
|
25
|
+
event_stream: event_stream)
|
|
26
|
+
@delete_skill = delete_skill ||
|
|
27
|
+
Insika::Commands::DeleteSkill.new(skill_store: skill_store,
|
|
28
|
+
skill_catalog: skill_catalog,
|
|
29
|
+
event_stream: event_stream)
|
|
30
|
+
@set_skill_agents = set_skill_agents ||
|
|
31
|
+
Insika::Commands::SetSkillAgents.new(profile_source: @profiles,
|
|
32
|
+
event_stream: event_stream)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# payload: { snapshot_ref:, operator?, reason? }
|
|
36
|
+
# -> Promotion (the row with rolled_back_at stamped)
|
|
37
|
+
def call(command)
|
|
38
|
+
p = AgentPayload.symbolize(command.payload)
|
|
39
|
+
snapshot_ref = AgentPayload.presence(p[:snapshot_ref])
|
|
40
|
+
raise Insika::ValidationError, "snapshot_ref is required" if snapshot_ref.nil?
|
|
41
|
+
|
|
42
|
+
snap = @harvest_store.find_snapshot(snapshot_ref) ||
|
|
43
|
+
(raise Insika::NotFoundError, "harvest snapshot not found: #{snapshot_ref}")
|
|
44
|
+
# The log row whose snapshot_ref matches — but a promotion that never
|
|
45
|
+
# recorded its row (a crash between the writes and the append) rolls
|
|
46
|
+
# back by snapshot, and the rollback stamp catches up on whatever row
|
|
47
|
+
# exists (D8's honest price, resolved by reference).
|
|
48
|
+
promotion = promotion_for_snapshot(snap)
|
|
49
|
+
operator = (AgentPayload.presence(p[:operator]) || command.meta[:operator] || "operator").to_s
|
|
50
|
+
|
|
51
|
+
if snap.existed
|
|
52
|
+
@write_skill.call(Insika::Command.build(:write_skill,
|
|
53
|
+
{ name: snap.skill, content: snap.content,
|
|
54
|
+
agent: snap.agent }))
|
|
55
|
+
else
|
|
56
|
+
begin
|
|
57
|
+
@delete_skill.call(Insika::Command.build(:delete_skill,
|
|
58
|
+
{ name: snap.skill, agent: snap.agent }))
|
|
59
|
+
rescue Insika::NotFoundError
|
|
60
|
+
# the desired state (gone) already holds — a snapshot taken for a
|
|
61
|
+
# skill that never landed rolls back cleanly
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
@set_skill_agents.call(Insika::Command.build(:set_skill_agents,
|
|
65
|
+
{ name: snap.skill,
|
|
66
|
+
agent_ids: snap.enabled_for }))
|
|
67
|
+
|
|
68
|
+
rolled = promotion ? @harvest_store.append_rollback(promotion_id: promotion.id,
|
|
69
|
+
operator: operator, reason: p[:reason]) : nil
|
|
70
|
+
@event_stream.emit(Insika::Event.new(
|
|
71
|
+
type: :skill_rolled_back,
|
|
72
|
+
data: { snapshot_ref: snapshot_ref, skill: snap.skill,
|
|
73
|
+
agent: snap.agent, operator: operator }.compact,
|
|
74
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
75
|
+
))
|
|
76
|
+
rolled || promotion
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def promotion_for_snapshot(snap)
|
|
82
|
+
@harvest_store.promotions.find { |row| row.snapshot_ref == snap.id }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Commands
|
|
5
|
+
# the ONLY path that writes proposals. Distills ONE session
|
|
6
|
+
# end to end: read the transcript and the memory baseline, ask the model,
|
|
7
|
+
# schema-drop, dedup against the ledger, write proposals, mark the session
|
|
8
|
+
# distilled. Synchronous (it runs on the engine's worker fiber, C5) — it
|
|
9
|
+
# creates no task and no turn (D2: no TaskStore work unit — a crash
|
|
10
|
+
# mid-pass leaves the marker unwritten and the next pass re-scans; a
|
|
11
|
+
# duplicate proposal is filtered by the ledger, never applied).
|
|
12
|
+
class RunDistillation
|
|
13
|
+
DEFAULT_IDLE_HOURS = 6
|
|
14
|
+
DEFAULT_MIN_MESSAGES = 3
|
|
15
|
+
DEFAULT_MAX_PROPOSALS = 10
|
|
16
|
+
|
|
17
|
+
def initialize(profiles:, proposal_store:, session_store:, memory_store:,
|
|
18
|
+
settings_store:, event_stream:, distiller_factory: nil)
|
|
19
|
+
@profiles = profiles
|
|
20
|
+
@proposal_store = proposal_store
|
|
21
|
+
@session_store = session_store
|
|
22
|
+
@memory_store = memory_store
|
|
23
|
+
@settings_store = settings_store
|
|
24
|
+
@event_stream = event_stream
|
|
25
|
+
@distiller_factory = distiller_factory ||
|
|
26
|
+
->(config) { Distill::DistillerFactory.build(config, utility_model: utility_model) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# payload: { session_id: } (agent resolved from the session, D6-bis)
|
|
30
|
+
# -> { distilled: true, proposals: N, dropped: {...}, deduped: N, cost: {...} | nil }
|
|
31
|
+
# | { distilled: false, skipped: "already|untagged|no_agent|disabled|
|
|
32
|
+
# too_fresh|too_short|no_model" }
|
|
33
|
+
def call(command)
|
|
34
|
+
session_id = Coercion.presence(command.payload[:session_id] || command.payload["session_id"])
|
|
35
|
+
raise ValidationError, "session_id is required" if session_id.nil?
|
|
36
|
+
|
|
37
|
+
session = @session_store.find(session_id)
|
|
38
|
+
raise Insika::NotFoundError, "session not found: #{session_id}" if session.nil?
|
|
39
|
+
|
|
40
|
+
return skip("already") if @proposal_store.distilled?(session_id)
|
|
41
|
+
|
|
42
|
+
customer = Coercion.presence(session.vars["customer"])
|
|
43
|
+
return skip("untagged") if customer.nil?
|
|
44
|
+
|
|
45
|
+
agent_id = Coercion.presence(session.vars["agent"])
|
|
46
|
+
return skip("no_agent") if agent_id.nil?
|
|
47
|
+
|
|
48
|
+
profile = @profiles[agent_id]
|
|
49
|
+
return skip("no_agent") if profile.nil?
|
|
50
|
+
|
|
51
|
+
config = Coercion.deep_stringify(profile.distill)
|
|
52
|
+
return skip("disabled") if config.nil? || !Coercion.truthy?(config["enabled"])
|
|
53
|
+
|
|
54
|
+
# D4: the pack's own idle threshold wins over the scan's lower bound —
|
|
55
|
+
# a pack that wants 12 h is never distilled at 6.
|
|
56
|
+
idle_hours = config["idle_hours"].to_i
|
|
57
|
+
idle_hours = DEFAULT_IDLE_HOURS unless idle_hours.positive?
|
|
58
|
+
return skip("too_fresh") unless idle?(session.updated_at, idle_hours)
|
|
59
|
+
|
|
60
|
+
min_messages = config["min_messages"].to_i
|
|
61
|
+
min_messages = DEFAULT_MIN_MESSAGES unless min_messages.positive?
|
|
62
|
+
return skip("too_short") if session.messages.size < min_messages
|
|
63
|
+
|
|
64
|
+
tenant = tenant_of(session_id)
|
|
65
|
+
distiller = @distiller_factory.call(config)
|
|
66
|
+
return skip("no_model") if distiller.nil?
|
|
67
|
+
|
|
68
|
+
baseline = memory_baseline(tenant, customer)
|
|
69
|
+
prompt = build_prompt(config, session, baseline)
|
|
70
|
+
result = distiller.distill(prompt: prompt, message_count: session.messages.size,
|
|
71
|
+
max_proposals: (config["max_proposals"] || DEFAULT_MAX_PROPOSALS).to_i)
|
|
72
|
+
|
|
73
|
+
deduped = 0
|
|
74
|
+
survivors = result[:proposals].reject do |proposal|
|
|
75
|
+
dedup = deduped?(tenant, customer, proposal, baseline)
|
|
76
|
+
deduped += 1 if dedup
|
|
77
|
+
dedup
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
survivors.each do |proposal|
|
|
81
|
+
expected = baseline[proposal["name"]]
|
|
82
|
+
@proposal_store.create(
|
|
83
|
+
tenant: tenant, customer: customer, session_ref: session_id,
|
|
84
|
+
key: proposal["name"], value: proposal["value"],
|
|
85
|
+
confidence: proposal["confidence"], evidence: proposal["turns"],
|
|
86
|
+
expected_revision: expected && expected.updated_at,
|
|
87
|
+
expected_existed: !expected.nil?
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
@proposal_store.mark_distilled(session_id, agent: agent_id,
|
|
92
|
+
proposals: survivors.size,
|
|
93
|
+
dropped: result[:dropped],
|
|
94
|
+
deduped: deduped,
|
|
95
|
+
cost: result[:cost])
|
|
96
|
+
@event_stream.emit(Insika::Event.new(
|
|
97
|
+
type: :distillation_completed,
|
|
98
|
+
# counts and ids only (D7) — a fact value never
|
|
99
|
+
# enters the stream.
|
|
100
|
+
data: { session_ref: session_id, agent: agent_id,
|
|
101
|
+
proposals: survivors.size,
|
|
102
|
+
dropped: result[:dropped],
|
|
103
|
+
deduped: deduped,
|
|
104
|
+
cost: result[:cost] },
|
|
105
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
106
|
+
))
|
|
107
|
+
{ distilled: true, proposals: survivors.size, dropped: result[:dropped],
|
|
108
|
+
deduped: deduped, cost: result[:cost] }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# nil tenant in single-tenant deployments; the "<tenant>:" prefix of the
|
|
114
|
+
# session id otherwise (D1 — the same reading ForgetCustomer uses).
|
|
115
|
+
def tenant_of(session_id)
|
|
116
|
+
tenant, rest = session_id.to_s.split(":", 2)
|
|
117
|
+
rest.nil? ? nil : tenant
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def skip(reason)
|
|
121
|
+
{ distilled: false, skipped: reason }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def idle?(updated_at, idle_hours)
|
|
125
|
+
return false if Coercion.blank?(updated_at)
|
|
126
|
+
|
|
127
|
+
Time.iso8601(updated_at.to_s) <= Time.now.utc - idle_hours * 3600
|
|
128
|
+
rescue ArgumentError
|
|
129
|
+
false
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Per fact key: the existence + updated_at at distill time (D5 — the CAS
|
|
133
|
+
# baseline the approval compares against). Sorted by key for a stable
|
|
134
|
+
# prompt.
|
|
135
|
+
def memory_baseline(tenant, customer)
|
|
136
|
+
@memory_store.facts(tenant: tenant, customer: customer)
|
|
137
|
+
.each_with_object({}) { |f, acc| acc[f.key] = f }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def deduped?(tenant, customer, proposal, baseline)
|
|
141
|
+
key = proposal["name"].to_s
|
|
142
|
+
value = proposal["value"].to_s
|
|
143
|
+
return true if @proposal_store.decided?(tenant: tenant, customer: customer,
|
|
144
|
+
key: key, value: value)
|
|
145
|
+
return true if @proposal_store.open_pending?(tenant: tenant, customer: customer, key: key)
|
|
146
|
+
|
|
147
|
+
applied = baseline[key]
|
|
148
|
+
!applied.nil? && applied.value == value && applied.origin.to_s.start_with?("distilled:")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The prompt gets the transcript slice (masked through the
|
|
152
|
+
# output filter first — the redaction rule), the
|
|
153
|
+
# customer's CURRENT facts (so the model can avoid re-proposing applied
|
|
154
|
+
# facts), and the answer rules (the pack prompt or DEFAULT_PROMPT).
|
|
155
|
+
def build_prompt(config, session, baseline)
|
|
156
|
+
base = Coercion.presence(config["prompt"]) || Distill::DEFAULT_PROMPT
|
|
157
|
+
transcript = render_transcript(session.messages)
|
|
158
|
+
facts = baseline.values.map { |f| "- #{f.key}: #{f.value}" }
|
|
159
|
+
<<~PROMPT
|
|
160
|
+
#{base.rstrip}
|
|
161
|
+
|
|
162
|
+
## The conversation
|
|
163
|
+
|
|
164
|
+
#{transcript}
|
|
165
|
+
|
|
166
|
+
## Facts already in this customer's memory (do not re-propose them)
|
|
167
|
+
|
|
168
|
+
#{facts.empty? ? "(none)" : facts.join("\n")}
|
|
169
|
+
PROMPT
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def render_transcript(messages)
|
|
173
|
+
redacted, = Insika::Safety::Detectors.redact(
|
|
174
|
+
messages.each_with_index.map { |m, i| "[#{i}] #{m['role']}: #{m['content']}" }.join("\n")
|
|
175
|
+
)
|
|
176
|
+
redacted
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def utility_model
|
|
180
|
+
return nil unless @settings_store
|
|
181
|
+
|
|
182
|
+
@settings_store.get["utility_model"]
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# the ONLY path that writes candidates. Mines ONE window end
|
|
8
|
+
# to end: resolve the sessions, read the transcripts + evidence, ask the
|
|
9
|
+
# miner, schema-drop, apply the negative list and the grounding filter,
|
|
10
|
+
# dedup against the ledger, write the run + candidates, stamp the markers.
|
|
11
|
+
#
|
|
12
|
+
# Synchronous (it runs on the engine's worker fiber, C12, or the CLI, C15)
|
|
13
|
+
# — it creates no task and no turn. It writes NOTHING to sessions or
|
|
14
|
+
# skills: D2's fork discipline is this command's contract — a customer
|
|
15
|
+
# turn's prefix is untouched by construction, and the only harvest-side
|
|
16
|
+
# spend is the run's cost (E1).
|
|
17
|
+
#
|
|
18
|
+
# Payload: { agent:, last_sessions?, since?, full?, session_ids?,
|
|
19
|
+
# max_proposals?, exclude_sessions? }
|
|
20
|
+
class RunHarvest
|
|
21
|
+
DEFAULT_LAST_SESSIONS = 200 # the EvidenceCollector's window default
|
|
22
|
+
DEFAULT_MIN_MESSAGES = 3
|
|
23
|
+
DEFAULT_MAX_PROPOSALS = 10
|
|
24
|
+
DEFAULT_IDLE_HOURS = 24
|
|
25
|
+
|
|
26
|
+
def initialize(profiles:, harvest_store:, session_store:, task_store:,
|
|
27
|
+
skill_store: nil, tool_trace_store: nil, settings_store: nil,
|
|
28
|
+
negative_list: nil, miner_factory: nil, event_stream:)
|
|
29
|
+
@profiles = ProfileSource.coerce(profiles)
|
|
30
|
+
@harvest_store = harvest_store
|
|
31
|
+
@session_store = session_store
|
|
32
|
+
@task_store = task_store
|
|
33
|
+
@skill_store = skill_store
|
|
34
|
+
@tool_trace_store = tool_trace_store
|
|
35
|
+
@settings_store = settings_store
|
|
36
|
+
@negative_list = negative_list
|
|
37
|
+
@miner_factory = miner_factory ||
|
|
38
|
+
->(config) { Harvest::MinerFactory.build(config, utility_model: utility_model) }
|
|
39
|
+
@event_stream = event_stream
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# -> { mined: true, run_id:, candidates: N,
|
|
43
|
+
# rejected: { "<rule-id>" => N, "ungrounded" => N, "dedup" => N,
|
|
44
|
+
# "schema" => N, ... }, cost: {...} | nil }
|
|
45
|
+
# | { mined: false, skipped: "disabled|no_model|no_grounding_matcher" }
|
|
46
|
+
def call(command)
|
|
47
|
+
p = AgentPayload.symbolize(command.payload)
|
|
48
|
+
agent = AgentPayload.presence(p[:agent])
|
|
49
|
+
raise Insika::ValidationError, "agent is required" if agent.nil?
|
|
50
|
+
|
|
51
|
+
profile = @profiles[agent] ||
|
|
52
|
+
(raise Insika::NotFoundError, "agent '#{agent}' not configured")
|
|
53
|
+
config = Coercion.deep_stringify(profile.harvest)
|
|
54
|
+
return skip("disabled") if config.nil? || !Coercion.truthy?(config["enabled"])
|
|
55
|
+
|
|
56
|
+
# D4: the OPERATIVE negative list lives on the profile (hot-editable,
|
|
57
|
+
# seeded by `insika harvest:negative import`); the injected list is
|
|
58
|
+
# the deployment's fallback. The engine applies data, never authors it.
|
|
59
|
+
@list = negative_list_for(config)
|
|
60
|
+
|
|
61
|
+
# D3: product claims cannot be verified without a matcher, so NOTHING
|
|
62
|
+
# mines — refused, not warned (D12's "by refusal, not by prompt").
|
|
63
|
+
grounding = Grounding.parse(profile.grounding)
|
|
64
|
+
return skip("no_grounding_matcher") if grounding.nil? || !grounding.matcher.sku?
|
|
65
|
+
|
|
66
|
+
miner = @miner_factory.call(config)
|
|
67
|
+
return skip("no_model") if miner.nil?
|
|
68
|
+
|
|
69
|
+
run = @harvest_store.create_run(agent_id: agent, window: window_record(p),
|
|
70
|
+
budget: budget_cap(config))
|
|
71
|
+
sessions = resolve_sessions(agent, p, config)
|
|
72
|
+
|
|
73
|
+
begin
|
|
74
|
+
# No eligible sessions: a run that says "we looked and it was clean"
|
|
75
|
+
# without paying a bill; markers untouched (re-scan, D10).
|
|
76
|
+
if sessions.empty?
|
|
77
|
+
@harvest_store.complete_run(run.id, candidates: 0)
|
|
78
|
+
return { mined: true, run_id: run.id, candidates: 0,
|
|
79
|
+
rejected: empty_rejected, cost: nil }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
prompt = build_prompt(config, sessions, agent)
|
|
83
|
+
result = miner.mine(prompt: prompt,
|
|
84
|
+
message_counts: sessions.map { |s| s[:messages].size },
|
|
85
|
+
max_proposals: max_proposals(p, config))
|
|
86
|
+
|
|
87
|
+
# The mining budget is a REAL cap (the review fix): a pass that
|
|
88
|
+
# spent more than the pack declared is failed with the numbers and
|
|
89
|
+
# proposes NOTHING — the docs' "the budget cap bounds it" is
|
|
90
|
+
# enforced here, post-hoc for the one model call, pre-hoc for every
|
|
91
|
+
# downstream write and gate.
|
|
92
|
+
if budget_exceeded?(budget_cap(config), result[:cost])
|
|
93
|
+
@harvest_store.fail_run(run.id, error: "mining budget exceeded: " \
|
|
94
|
+
"spent #{result[:cost]['spent']} > " \
|
|
95
|
+
"#{budget_cap(config)['tokens']}")
|
|
96
|
+
emit(:harvest_mined, agent: agent, run_id: run.id, candidates: 0,
|
|
97
|
+
rejected: empty_rejected, cost: result[:cost])
|
|
98
|
+
return { mined: true, run_id: run.id, candidates: 0,
|
|
99
|
+
rejected: empty_rejected, cost: result[:cost] }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
survivors, rejected = filter_skills(result[:skills], sessions, agent, grounding.matcher)
|
|
103
|
+
|
|
104
|
+
survivors.each do |skill|
|
|
105
|
+
@harvest_store.create_candidate(
|
|
106
|
+
run_id: run.id, agent: agent, name: skill["name"],
|
|
107
|
+
description: skill["description"], body: skill_md(skill),
|
|
108
|
+
triggers: skill["triggers"] || [], rationale: skill["rationale"].to_s,
|
|
109
|
+
origin: sessions.map { |s| s[:id] }, evidence_turns: skill["evidence_turns"] || [],
|
|
110
|
+
proposer: miner.model
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
final_rejected = merge_rejected(result[:dropped], rejected)
|
|
115
|
+
@harvest_store.complete_run(run.id, candidates: survivors.size,
|
|
116
|
+
cost: result[:cost],
|
|
117
|
+
rejected: final_rejected)
|
|
118
|
+
# Markers AFTER the pass completes (D10's crash-safe re-scan).
|
|
119
|
+
sessions.each { |s| @harvest_store.mark_mined(s[:id], candidates: survivors.size) }
|
|
120
|
+
|
|
121
|
+
emit(:harvest_mined, agent: agent, run_id: run.id, candidates: survivors.size,
|
|
122
|
+
rejected: final_rejected, cost: result[:cost])
|
|
123
|
+
{ mined: true, run_id: run.id, candidates: survivors.size,
|
|
124
|
+
rejected: final_rejected, cost: result[:cost] }
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
# The run is failed; the markers are NOT written (re-scan, D10);
|
|
127
|
+
# the exception propagates to the caller's fiber or the CLI.
|
|
128
|
+
begin
|
|
129
|
+
@harvest_store.fail_run(run.id, error: e.message)
|
|
130
|
+
rescue StandardError
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
raise
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def skip(reason)
|
|
140
|
+
{ mined: false, skipped: reason }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The pack's harvest.miner.budget as { "tokens" => N } — nil when
|
|
144
|
+
# absent/not positive (no cap, the refinement discipline).
|
|
145
|
+
def budget_cap(config)
|
|
146
|
+
raw = config && config.dig("miner", "budget")
|
|
147
|
+
raw.is_a?(Hash) && raw["tokens"].to_i.positive? ? { "tokens" => raw["tokens"].to_i } : nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def budget_exceeded?(cap, cost)
|
|
151
|
+
cap && cost && cost["spent"].to_i > cap["tokens"].to_i
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The candidate's body is a full SKILL.md by construction: the model
|
|
155
|
+
# writes the procedure, the engine wraps the frontmatter (name /
|
|
156
|
+
# description / triggers — the miner's own fields), so a candidate is
|
|
157
|
+
# servable the moment a human promotes it (WriteSkill validates the
|
|
158
|
+
# frontmatter, and the gate's clone serves it the same way).
|
|
159
|
+
def skill_md(skill)
|
|
160
|
+
fm = { "name" => skill["name"], "description" => skill["description"] }
|
|
161
|
+
fm["triggers"] = Array(skill["triggers"]).join(", ") if Array(skill["triggers"]).any?
|
|
162
|
+
"---\n#{fm.map { |k, v| "#{k}: #{v}" }.join("\n")}\n---\n#{skill['body']}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The profile's own rules win over the injected seed (D4). A malformed
|
|
166
|
+
# profile list parses to nil — the injected list stays the fallback,
|
|
167
|
+
# never a silent empty.
|
|
168
|
+
def negative_list_for(config)
|
|
169
|
+
return @negative_list unless config && config["negative_list"]
|
|
170
|
+
|
|
171
|
+
Harvest::NegativeList.parse(config["negative_list"]) || @negative_list
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def empty_rejected = { "schema" => 0, "unknown_key" => 0, "oversized" => 0,
|
|
175
|
+
"bad_turns" => 0, "duplicate" => 0, "capped" => 0 }
|
|
176
|
+
|
|
177
|
+
def window_record(p)
|
|
178
|
+
return { "session_ids" => Array(p[:session_ids]).map(&:to_s) } if p[:session_ids]
|
|
179
|
+
return { "since" => p[:since].to_s } if AgentPayload.presence(p[:since])
|
|
180
|
+
return { "last_sessions" => Integer(p[:last_sessions]) } if p[:last_sessions]
|
|
181
|
+
|
|
182
|
+
{}
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The EvidenceCollector window discipline, applied to session ids: the
|
|
186
|
+
# agent's turns (by the task's command payload), distinct session ids,
|
|
187
|
+
# newest first, capped at the "last N conversations". Marker suppression
|
|
188
|
+
# unless `full` (a re-mine is explicit). A session's evidence ledger
|
|
189
|
+
# is the grounding filter's input.
|
|
190
|
+
def resolve_sessions(agent, p, config)
|
|
191
|
+
ids = window_session_ids(agent, p, config)
|
|
192
|
+
ids = ids.reject { |sid| @harvest_store.mined?(sid) } unless EnvSchema.truthy?(p[:full])
|
|
193
|
+
ids = ids.first(Harvest::Miner::MAX_SESSIONS)
|
|
194
|
+
|
|
195
|
+
min_messages = (config["min_messages"] || DEFAULT_MIN_MESSAGES).to_i
|
|
196
|
+
idle_hours = (config["idle_hours"] || DEFAULT_IDLE_HOURS).to_i
|
|
197
|
+
ids.filter_map do |sid|
|
|
198
|
+
session = @session_store.find(sid)
|
|
199
|
+
next unless session
|
|
200
|
+
|
|
201
|
+
messages = session.messages.to_a
|
|
202
|
+
next if messages.size < min_messages # a 2-message session mines noise
|
|
203
|
+
# D10: the per-agent maturity bound is re-checked here — the engine
|
|
204
|
+
# scan's default is only the LOWER bound (a pack that wants 12 h is
|
|
205
|
+
# never mined at 6, and a manual CLI run over fresh traffic mines
|
|
206
|
+
# nothing).
|
|
207
|
+
next unless idle?(session.updated_at, idle_hours)
|
|
208
|
+
|
|
209
|
+
evidence = (session.evidence || {})["ids"] || []
|
|
210
|
+
{ id: sid.to_s, messages: messages, evidence: Array(evidence).map(&:to_s) }
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def window_session_ids(agent, p, config)
|
|
215
|
+
explicit = Array(p[:session_ids]).map(&:to_s)
|
|
216
|
+
return explicit unless explicit.empty?
|
|
217
|
+
|
|
218
|
+
since = AgentPayload.presence(p[:since])
|
|
219
|
+
tasks = @task_store.each_id
|
|
220
|
+
.filter_map { |id| @task_store.find(id) }
|
|
221
|
+
.select { |t| task_agent(t) == agent }
|
|
222
|
+
.sort_by { |t| [t.created_at.to_s, t.id] }.reverse
|
|
223
|
+
|
|
224
|
+
tasks = tasks.select { |t| t.created_at.to_s >= since.to_s } if since
|
|
225
|
+
|
|
226
|
+
distinct = []
|
|
227
|
+
seen = {}
|
|
228
|
+
tasks.each do |t|
|
|
229
|
+
sid = presence(t.session_id)
|
|
230
|
+
next if sid.nil? || seen[sid]
|
|
231
|
+
|
|
232
|
+
seen[sid] = true
|
|
233
|
+
distinct << sid
|
|
234
|
+
end
|
|
235
|
+
return distinct if since
|
|
236
|
+
|
|
237
|
+
last = AgentPayload.presence(p[:last_sessions])
|
|
238
|
+
count = last ? Integer(last) : config_window(config)
|
|
239
|
+
distinct.first(count)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# The config's miner.window last_sessions, or the collector default.
|
|
243
|
+
def config_window(config)
|
|
244
|
+
configured = config.dig("miner", "window", "last_sessions")
|
|
245
|
+
configured ? Integer(configured) : DEFAULT_LAST_SESSIONS
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def task_agent(task)
|
|
249
|
+
task.command.is_a?(Hash) ? task.command.dig("payload", "agent").to_s : ""
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def idle?(updated_at, idle_hours)
|
|
253
|
+
return false if Coercion.blank?(updated_at)
|
|
254
|
+
|
|
255
|
+
Time.iso8601(updated_at.to_s) <= Time.now.utc - idle_hours * 3600
|
|
256
|
+
rescue ArgumentError
|
|
257
|
+
false
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def presence(value)
|
|
261
|
+
Insika::Coercion.presence(value)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def max_proposals(p, config)
|
|
265
|
+
raw = p[:max_proposals] || config.dig("miner", "max_proposals")
|
|
266
|
+
raw ? Integer(raw) : DEFAULT_MAX_PROPOSALS
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# The prompt: the transcript slices (masked through the output
|
|
270
|
+
# filter — the redaction rule), the evidence ids per
|
|
271
|
+
# session, the agent's CURRENT skill names (the model should not
|
|
272
|
+
# re-propose them), and the answer rules. The pack prompt replaces
|
|
273
|
+
# DEFAULT_PROMPT whole (the forge's half).
|
|
274
|
+
def build_prompt(config, sessions, agent)
|
|
275
|
+
base = Coercion.presence(config["prompt"]) || Harvest::DEFAULT_PROMPT
|
|
276
|
+
current_skills = current_skill_names(agent)
|
|
277
|
+
blocks = sessions.map do |s|
|
|
278
|
+
evidence = s[:evidence].join(", ")
|
|
279
|
+
lines = ["## Session #{s[:id]} (#{s[:messages].size} messages; " \
|
|
280
|
+
"evidence ids: #{evidence.empty? ? '(none)' : evidence})"]
|
|
281
|
+
lines << render_transcript(s[:messages])
|
|
282
|
+
lines.join("\n")
|
|
283
|
+
end
|
|
284
|
+
<<~PROMPT
|
|
285
|
+
#{base.rstrip}
|
|
286
|
+
|
|
287
|
+
## Conversations to mine
|
|
288
|
+
|
|
289
|
+
#{blocks.join("\n")}
|
|
290
|
+
|
|
291
|
+
## Skills this agent already has (do not re-propose them)
|
|
292
|
+
|
|
293
|
+
#{current_skills.empty? ? "(none)" : current_skills.join(", ")}
|
|
294
|
+
PROMPT
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def current_skill_names(agent)
|
|
298
|
+
return [] unless @skill_store
|
|
299
|
+
|
|
300
|
+
(@skill_store.names | @skill_store.names(agent: agent)).sort
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def render_transcript(messages)
|
|
304
|
+
redacted, = Insika::Safety::Detectors.redact(
|
|
305
|
+
messages.each_with_index.map { |m, i| "[#{i}] #{m['role']}: #{m['content']}" }.join("\n")
|
|
306
|
+
)
|
|
307
|
+
redacted
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# The safety order: negative list (D4) -> grounding (D3) -> dedup. A
|
|
311
|
+
# drop is COUNTED and NEVER written. `rejected` keys: the matched RULE
|
|
312
|
+
# ids for negative hits (each named — E2), "ungrounded"/"dedup" for the
|
|
313
|
+
# other two filters.
|
|
314
|
+
# -> [[raw skill], { key => count }]
|
|
315
|
+
def filter_skills(skills, sessions, agent, matcher)
|
|
316
|
+
rejected = Hash.new(0)
|
|
317
|
+
survivors = skills.select do |skill|
|
|
318
|
+
hits = negative_hits(skill)
|
|
319
|
+
if hits.any?
|
|
320
|
+
hits.each { |r| rejected[r.rule] += 1 }
|
|
321
|
+
false
|
|
322
|
+
elsif !grounded?(skill, sessions, matcher)
|
|
323
|
+
rejected["ungrounded"] += 1
|
|
324
|
+
false
|
|
325
|
+
elsif deduped?(skill, agent)
|
|
326
|
+
rejected["dedup"] += 1
|
|
327
|
+
false
|
|
328
|
+
else
|
|
329
|
+
true
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
[survivors, rejected.to_h]
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# D4: every rejected-by-list candidate is logged with the matching rule
|
|
336
|
+
# id. The BODY is part of the match (the review fix): it is exactly what
|
|
337
|
+
# enters the model's context when the skill loads, so a banned phrase
|
|
338
|
+
# hidden there is the Hermes failure the design cites — the same text
|
|
339
|
+
# the grounding filter already reads (name+description+body). The name
|
|
340
|
+
# uses the stricter substring reading, the prose the word-boundary one.
|
|
341
|
+
def negative_hits(skill)
|
|
342
|
+
return [] unless @list
|
|
343
|
+
|
|
344
|
+
text = [skill["name"], skill["description"], skill["body"]].join(" ")
|
|
345
|
+
hits = @list.matches_name(skill["name"].to_s)
|
|
346
|
+
hits | @list.matches(text)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# D3: every reference must be in the union of the origin sessions'
|
|
350
|
+
# persisted evidence ids. The empty-ledger conservative reading (the
|
|
351
|
+
# enforcer's rule): a candidate whose origin sessions hold NO evidence
|
|
352
|
+
# ids and that carries a reference is dropped too. A skill with no
|
|
353
|
+
# references is not a grounding casualty. -> bool
|
|
354
|
+
def grounded?(skill, sessions, matcher)
|
|
355
|
+
text = [skill["name"], skill["description"], skill["body"]].join(" ")
|
|
356
|
+
refs = matcher.references(text)
|
|
357
|
+
return true if refs.empty?
|
|
358
|
+
|
|
359
|
+
union = sessions.flat_map { |s| s[:evidence] }.uniq
|
|
360
|
+
return false if union.empty?
|
|
361
|
+
|
|
362
|
+
missing = refs.reject { |r| union.include?(r) }
|
|
363
|
+
missing.empty?
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# The dedup ledger: the same name already in the store's SkillStore
|
|
367
|
+
# (shared or agent scope) or an open (agent, name) tuple.
|
|
368
|
+
def deduped?(skill, agent)
|
|
369
|
+
name = skill["name"].to_s
|
|
370
|
+
store_has = @skill_store && (@skill_store.get(name) || @skill_store.get(name, agent: agent))
|
|
371
|
+
store_has || @harvest_store.open_pending?(agent: agent, name: name)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# The miner's schema drops + the filters' — one record; the keys never
|
|
375
|
+
# collide (the filters own rule ids and their own names).
|
|
376
|
+
def merge_rejected(dropped, filters)
|
|
377
|
+
dropped.merge(filters) { |_k, left, right| left + right }
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def utility_model
|
|
381
|
+
return nil unless @settings_store
|
|
382
|
+
|
|
383
|
+
@settings_store.get["utility_model"]
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def emit(type, **data)
|
|
387
|
+
@event_stream.emit(Insika::Event.new(
|
|
388
|
+
type: type, data: data, meta: { at: Time.now.utc.iso8601 }
|
|
389
|
+
))
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|