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
- .analyze_for_knowledge(content, messages) ⇒ Object
- .conversation_context(messages) ⇒ Object
- .extract(response, messages, model) ⇒ Object
- .extract_async(response, messages, model) ⇒ Object
- .extract_content(response) ⇒ Object
- .extract_decisions(content) ⇒ Object
- .extract_facts(content) ⇒ Object
- .extract_patterns(content) ⇒ Object
- .install ⇒ Object
- .publish_entry(entry, model) ⇒ Object
- .reset! ⇒ Object
- .should_extract?(response) ⇒ Boolean
- .summary ⇒ Object
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, ) entries = [] entries.concat(extract_decisions(content)) entries.concat(extract_patterns(content)) entries.concat(extract_facts(content)) context = conversation_context() 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() return nil if .nil? || .empty? = .select { |m| m[:role].to_s == 'user' } return nil if .empty? first = .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, , 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, ) 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, , model) return unless should_extract?(response) Thread.new do extract(response, , 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.}") 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 |
.install ⇒ Object
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, , 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.}") 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
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 |
.summary ⇒ Object
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 |