Module: Legion::LLM::Hooks::Reflection

Extended by:
Legion::Logging::Helper
Defined in:
lib/legion/llm/hooks/reflection.rb

Overview

Extracts learnings from conversation completions and publishes them for Apollo knowledge ingestion. Runs as an after_chat hook.

Extracted knowledge types:

  • Decisions: technical choices made during conversation

  • Patterns: recurring code patterns or architectural approaches

  • Facts: concrete facts about systems, APIs, or configurations

Only triggers on substantive responses (>200 chars) to avoid noise.

Constant Summary collapse

MIN_RESPONSE_LENGTH =
200
MAX_EXTRACT_LENGTH =
500
COOLDOWN_SECONDS =

5 minutes between extractions

300

Class Method Summary collapse

Class Method Details

.analyze_for_knowledge(content, messages) ⇒ Object



66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/legion/llm/hooks/reflection.rb', line 66

def analyze_for_knowledge(content, messages)
  entries = []

  entries.concat(extract_decisions(content))
  entries.concat(extract_patterns(content))
  entries.concat(extract_facts(content))

  context = conversation_context(messages)
  entries.each { |e| e[:context] = context }

  entries
end

.conversation_context(messages) ⇒ Object



149
150
151
152
153
154
155
156
157
# File 'lib/legion/llm/hooks/reflection.rb', line 149

def conversation_context(messages)
  return nil if messages.nil? || messages.empty?

  user_messages = messages.select { |m| m[:role].to_s == 'user' }
  return nil if user_messages.empty?

  first = user_messages.first[:content].to_s
  truncate(first, 200)
end

.extract(response, messages, model) ⇒ Object



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/legion/llm/hooks/reflection.rb', line 47

def extract(response, messages, model)
  content = extract_content(response)
  return if content.nil? || content.length < MIN_RESPONSE_LENGTH

  @mutex.synchronize do
    return if @last_extraction && (Time.now - @last_extraction) < COOLDOWN_SECONDS

    @last_extraction = Time.now
  end

  entries = analyze_for_knowledge(content, messages)
  return if entries.empty?

  entries.each { |entry| publish_entry(entry, model) }
  @mutex.synchronize { @extractions.concat(entries) }

  log.info("[llm][reflection] extracted model=#{model} count=#{entries.size}")
end

.extract_async(response, messages, model) ⇒ Object



36
37
38
39
40
41
42
43
44
45
# File 'lib/legion/llm/hooks/reflection.rb', line 36

def extract_async(response, messages, model)
  return unless should_extract?(response)

  Thread.new do
    extract(response, messages, model)
  rescue StandardError => e
    handle_exception(e, level: :debug, operation: 'llm.hooks.reflection.extract_async', model: model)
    log.debug("[llm][reflection] extract_async_failed model=#{model} error=#{e.message}")
  end
end

.extract_content(response) ⇒ Object



193
194
195
196
197
198
199
# File 'lib/legion/llm/hooks/reflection.rb', line 193

def extract_content(response)
  if response.respond_to?(:content)
    response.content.to_s
  elsif response.is_a?(Hash)
    (response[:content] || response[:text]).to_s
  end
end

.extract_decisions(content) ⇒ Object



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/legion/llm/hooks/reflection.rb', line 79

def extract_decisions(content)
  decision_markers = [
    /(?:decided|choosing|chose|going with|opted for|will use|should use)\s+(.{10,200})/i,
    /(?:the (?:best|right|correct) (?:approach|solution|way))\s+(?:is|would be)\s+(.{10,200})/i
  ]

  entries = []
  decision_markers.each do |pattern|
    content.scan(pattern) do |match|
      text = match[0].strip
      text = truncate(text, MAX_EXTRACT_LENGTH)
      entries << {
        type:         :decision,
        content:      text,
        confidence:   0.7,
        source:       'reflection',
        extracted_at: Time.now.iso8601
      }
    end
  end
  entries.first(2)
end

.extract_facts(content) ⇒ Object



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/legion/llm/hooks/reflection.rb', line 125

def extract_facts(content)
  fact_markers = [
    /(?:the (?:default|setting|value|port|path|endpoint)\s+(?:is|for))\s+(.{5,200})/i,
    /(?:requires?|depends? on|needs?)\s+(.{5,200})/i,
    /(?:version|v)\s*(\d+\.\d+[\w.-]*)/i
  ]

  entries = []
  fact_markers.each do |pattern|
    content.scan(pattern) do |match|
      text = match[0].strip
      text = truncate(text, MAX_EXTRACT_LENGTH)
      entries << {
        type:         :fact,
        content:      text,
        confidence:   0.65,
        source:       'reflection',
        extracted_at: Time.now.iso8601
      }
    end
  end
  entries.first(3)
end

.extract_patterns(content) ⇒ Object



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/legion/llm/hooks/reflection.rb', line 102

def extract_patterns(content)
  pattern_markers = [
    /(?:pattern|convention|idiom|approach)(?:\s+(?:is|for|to))?\s*:?\s*(.{10,200})/i,
    /(?:always|never|typically|usually)\s+(.{10,200})/i
  ]

  entries = []
  pattern_markers.each do |pattern|
    content.scan(pattern) do |match|
      text = match[0].strip
      text = truncate(text, MAX_EXTRACT_LENGTH)
      entries << {
        type:         :pattern,
        content:      text,
        confidence:   0.6,
        source:       'reflection',
        extracted_at: Time.now.iso8601
      }
    end
  end
  entries.first(2)
end

.installObject



29
30
31
32
33
34
# File 'lib/legion/llm/hooks/reflection.rb', line 29

def install
  Legion::LLM::Hooks.after_chat do |response:, messages:, model:, **|
    extract_async(response, messages, model)
    nil
  end
end

.publish_entry(entry, model) ⇒ Object



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/legion/llm/hooks/reflection.rb', line 159

def publish_entry(entry, model)
  if apollo_transport?
    Legion::Transport.publish(
      'lex.apollo.ingest',
      Legion::JSON.dump({
                          content:          entry[:content],
                          content_type:     entry[:type].to_s,
                          knowledge_domain: 'reflection',
                          confidence:       entry[:confidence],
                          source_agent:     "llm:#{model}",
                          metadata:         { context: entry[:context], source: 'reflection_hook' }
                        })
    )
    log.info("[llm][reflection] published via=transport model=#{model} type=#{entry[:type]}")
  elsif apollo_direct?
    Legion::Extensions::Apollo::Runners::Ingest.ingest(
      content:          entry[:content],
      content_type:     entry[:type].to_s,
      knowledge_domain: 'reflection',
      confidence:       entry[:confidence],
      source_agent:     "llm:#{model}"
    )
    log.info("[llm][reflection] published via=direct model=#{model} type=#{entry[:type]}")
  end
rescue StandardError => e
  handle_exception(e, level: :debug, operation: 'llm.hooks.reflection.publish_entry', model: model)
  log.error("[llm][reflection] publish_failed model=#{model} type=#{entry[:type]} error=#{e.message}")
end

.reset!Object



212
213
214
215
216
217
# File 'lib/legion/llm/hooks/reflection.rb', line 212

def reset!
  @mutex.synchronize do
    @extractions = []
    @last_extraction = nil
  end
end

.should_extract?(response) ⇒ Boolean

Returns:

  • (Boolean)


188
189
190
191
# File 'lib/legion/llm/hooks/reflection.rb', line 188

def should_extract?(response)
  content = extract_content(response)
  !content.nil? && content.length >= MIN_RESPONSE_LENGTH
end

.summaryObject



201
202
203
204
205
206
207
208
209
210
# File 'lib/legion/llm/hooks/reflection.rb', line 201

def summary
  @mutex.synchronize do
    {
      total_extractions: @extractions.size,
      last_extraction:   @last_extraction&.iso8601,
      by_type:           @extractions.group_by { |e| e[:type] }.transform_values(&:size),
      recent:            @extractions.last(5).map { |e| { type: e[:type], content: truncate(e[:content], 80) } }
    }
  end
end