Class: Insika::Commands::RunHarvest

Inherits:
Object
  • Object
show all
Defined in:
lib/insika/commands/run_harvest.rb

Overview

the ONLY path that writes candidates. Mines ONE window end to end: resolve the sessions, read the transcripts + evidence, ask the miner, schema-drop, apply the negative list and the grounding filter, dedup against the ledger, write the run + candidates, stamp the markers.

Synchronous (it runs on the engine's worker fiber, C12, or the CLI, C15) — it creates no task and no turn. It writes NOTHING to sessions or skills: D2's fork discipline is this command's contract — a customer turn's prefix is untouched by construction, and the only harvest-side spend is the run's cost (E1).

Payload: { agent:, last_sessions?, since?, full?, session_ids?, max_proposals?, exclude_sessions? }

Constant Summary collapse

DEFAULT_LAST_SESSIONS =

the EvidenceCollector's window default

200
DEFAULT_MIN_MESSAGES =
3
DEFAULT_MAX_PROPOSALS =
10
DEFAULT_IDLE_HOURS =
24

Instance Method Summary collapse

Constructor Details

#initialize(profiles:, harvest_store:, session_store:, task_store:, skill_store: nil, tool_trace_store: nil, settings_store: nil, negative_list: nil, miner_factory: nil, event_stream:) ⇒ RunHarvest

Returns a new instance of RunHarvest.



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/insika/commands/run_harvest.rb', line 26

def initialize(profiles:, harvest_store:, session_store:, task_store:,
               skill_store: nil, tool_trace_store: nil, settings_store: nil,
               negative_list: nil, miner_factory: nil, event_stream:)
  @profiles = ProfileSource.coerce(profiles)
  @harvest_store = harvest_store
  @session_store = session_store
  @task_store = task_store
  @skill_store = skill_store
  @tool_trace_store = tool_trace_store
  @settings_store = settings_store
  @negative_list = negative_list
  @miner_factory = miner_factory ||
                   ->(config) { Harvest::MinerFactory.build(config, utility_model: utility_model) }
  @event_stream = event_stream
end

Instance Method Details

#call(command) ⇒ Object

-> { mined: true, run_id:, candidates: N, rejected: { "" => N, "ungrounded" => N, "dedup" => N, "schema" => N, ... }, cost: ... | nil } | { mined: false, skipped: "disabled|no_model|no_grounding_matcher" }



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/insika/commands/run_harvest.rb', line 46

def call(command)
  p = AgentPayload.symbolize(command.payload)
  agent = AgentPayload.presence(p[:agent])
  raise Insika::ValidationError, "agent is required" if agent.nil?

  profile = @profiles[agent] ||
            (raise Insika::NotFoundError, "agent '#{agent}' not configured")
  config = Coercion.deep_stringify(profile.harvest)
  return skip("disabled") if config.nil? || !Coercion.truthy?(config["enabled"])

  # D4: the OPERATIVE negative list lives on the profile (hot-editable,
  # seeded by `insika harvest:negative import`); the injected list is
  # the deployment's fallback. The engine applies data, never authors it.
  @list = negative_list_for(config)

  # D3: product claims cannot be verified without a matcher, so NOTHING
  # mines — refused, not warned (D12's "by refusal, not by prompt").
  grounding = Grounding.parse(profile.grounding)
  return skip("no_grounding_matcher") if grounding.nil? || !grounding.matcher.sku?

  miner = @miner_factory.call(config)
  return skip("no_model") if miner.nil?

  run = @harvest_store.create_run(agent_id: agent, window: window_record(p),
                                  budget: budget_cap(config))
  sessions = resolve_sessions(agent, p, config)

  begin
    # No eligible sessions: a run that says "we looked and it was clean"
    # without paying a bill; markers untouched (re-scan, D10).
    if sessions.empty?
      @harvest_store.complete_run(run.id, candidates: 0)
      return { mined: true, run_id: run.id, candidates: 0,
               rejected: empty_rejected, cost: nil }
    end

    prompt = build_prompt(config, sessions, agent)
    result = miner.mine(prompt: prompt,
                        message_counts: sessions.map { |s| s[:messages].size },
                        max_proposals: max_proposals(p, config))

    # The mining budget is a REAL cap (the review fix): a pass that
    # spent more than the pack declared is failed with the numbers and
    # proposes NOTHING — the docs' "the budget cap bounds it" is
    # enforced here, post-hoc for the one model call, pre-hoc for every
    # downstream write and gate.
    if budget_exceeded?(budget_cap(config), result[:cost])
      @harvest_store.fail_run(run.id, error: "mining budget exceeded: " \
                                           "spent #{result[:cost]['spent']} > " \
                                           "#{budget_cap(config)['tokens']}")
      emit(:harvest_mined, agent: agent, run_id: run.id, candidates: 0,
                           rejected: empty_rejected, cost: result[:cost])
      return { mined: true, run_id: run.id, candidates: 0,
               rejected: empty_rejected, cost: result[:cost] }
    end

    survivors, rejected = filter_skills(result[:skills], sessions, agent, grounding.matcher)

    survivors.each do |skill|
      @harvest_store.create_candidate(
        run_id: run.id, agent: agent, name: skill["name"],
        description: skill["description"], body: skill_md(skill),
        triggers: skill["triggers"] || [], rationale: skill["rationale"].to_s,
        origin: sessions.map { |s| s[:id] }, evidence_turns: skill["evidence_turns"] || [],
        proposer: miner.model
      )
    end

    final_rejected = merge_rejected(result[:dropped], rejected)
    @harvest_store.complete_run(run.id, candidates: survivors.size,
                                        cost: result[:cost],
                                        rejected: final_rejected)
    # Markers AFTER the pass completes (D10's crash-safe re-scan).
    sessions.each { |s| @harvest_store.mark_mined(s[:id], candidates: survivors.size) }

    emit(:harvest_mined, agent: agent, run_id: run.id, candidates: survivors.size,
                         rejected: final_rejected, cost: result[:cost])
    { mined: true, run_id: run.id, candidates: survivors.size,
      rejected: final_rejected, cost: result[:cost] }
  rescue StandardError => e
    # The run is failed; the markers are NOT written (re-scan, D10);
    # the exception propagates to the caller's fiber or the CLI.
    begin
      @harvest_store.fail_run(run.id, error: e.message)
    rescue StandardError
      nil
    end
    raise
  end
end