lex-narrator 0.1.1
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 +7 -0
- data/Gemfile +15 -0
- data/LICENSE +21 -0
- data/README.md +71 -0
- data/lex-narrator.gemspec +29 -0
- data/lib/legion/extensions/narrator/client.rb +23 -0
- data/lib/legion/extensions/narrator/helpers/constants.rb +65 -0
- data/lib/legion/extensions/narrator/helpers/journal.rb +62 -0
- data/lib/legion/extensions/narrator/helpers/llm_enhancer.rb +101 -0
- data/lib/legion/extensions/narrator/helpers/prose.rb +118 -0
- data/lib/legion/extensions/narrator/helpers/synthesizer.rb +134 -0
- data/lib/legion/extensions/narrator/runners/narrator.rb +192 -0
- data/lib/legion/extensions/narrator/version.rb +9 -0
- data/lib/legion/extensions/narrator.rb +18 -0
- data/spec/legion/extensions/narrator/client_spec.rb +24 -0
- data/spec/legion/extensions/narrator/helpers/journal_spec.rb +95 -0
- data/spec/legion/extensions/narrator/helpers/llm_enhancer_spec.rb +107 -0
- data/spec/legion/extensions/narrator/helpers/prose_spec.rb +134 -0
- data/spec/legion/extensions/narrator/helpers/synthesizer_spec.rb +89 -0
- data/spec/legion/extensions/narrator/runners/narrator_llm_spec.rb +74 -0
- data/spec/legion/extensions/narrator/runners/narrator_spec.rb +124 -0
- data/spec/spec_helper.rb +20 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 70acf0397dff2bf9773a893f3e595d40728ef241467ce5e394ce2daa36fe286a
|
|
4
|
+
data.tar.gz: caceda0dc9cb7f4f2aae65a0b3e6843108590d574f4731eef5b8c94ae9d876e5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9b5d3a9b0c07b8d0da95fdb71560204755edf84d737d258f72c7f869e83401c34a0eba3e915043999c768ad674c82e53732be01a437f2dba4626ca5c9b6d7855
|
|
7
|
+
data.tar.gz: 0abe5958816469fbbc12fbb04564136ae49efd684e0ae68de6fc696b5b542b325b9da57937895595c50f3ebf04f9c32014f13a49b15d9c75828d7fd280a82662
|
data/Gemfile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
source 'https://rubygems.org'
|
|
4
|
+
|
|
5
|
+
gemspec
|
|
6
|
+
|
|
7
|
+
group :development, :test do
|
|
8
|
+
gem 'rake', '~> 13.0'
|
|
9
|
+
gem 'rspec', '~> 3.0'
|
|
10
|
+
gem 'rubocop', '~> 1.21'
|
|
11
|
+
gem 'rubocop-rspec', require: false
|
|
12
|
+
gem 'simplecov', require: false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
gem 'legion-gaia', path: '../../legion-gaia'
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LegionIO
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# lex-narrator
|
|
2
|
+
|
|
3
|
+
Real-time cognitive narrative stream for the LegionIO cognitive architecture. Translates the agent's internal cognitive state into human-readable prose each tick.
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
Each tick, reads emotional state, active curiosity wonders, prediction confidence, attention focus, memory health, and reflection status from `tick_results` and `cognitive_state`, then generates a timestamped narrative entry. Entries are appended to a rolling journal (capped at 500). Provides mood classification, journal queries, and statistics.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
client = Legion::Extensions::Narrator::Client.new
|
|
13
|
+
|
|
14
|
+
# Generate narrative from tick results
|
|
15
|
+
entry = client.narrate(
|
|
16
|
+
tick_results: {
|
|
17
|
+
emotional_evaluation: { valence: 0.4, arousal: 0.7 },
|
|
18
|
+
sensory_processing: { spotlight: 3, peripheral: 5 },
|
|
19
|
+
prediction_engine: { confidence: 0.6, mode: :functional_mapping }
|
|
20
|
+
},
|
|
21
|
+
cognitive_state: {
|
|
22
|
+
curiosity: { intensity: 0.8, active_count: 4, top_question: 'Why are infrastructure traces sparse?' }
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
# => { narrative: "3 signals in spotlight focus, 5 in peripheral awareness. I am highly alert
|
|
26
|
+
# and calm and steady. I am deeply curious, with 4 open questions. Most pressing:
|
|
27
|
+
# \"Why are infrastructure traces sparse?\"...", mood: :energized }
|
|
28
|
+
|
|
29
|
+
# Query journal
|
|
30
|
+
client.recent_entries(limit: 5)
|
|
31
|
+
client.entries_since(since: Time.now - 3600)
|
|
32
|
+
client.mood_history(mood: :anxious, limit: 10)
|
|
33
|
+
client.current_narrative
|
|
34
|
+
client.narrator_stats
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Mood Classification
|
|
38
|
+
|
|
39
|
+
| Mood | Condition |
|
|
40
|
+
|------|-----------|
|
|
41
|
+
| `:energized` | valence > 0.3, arousal > 0.5 |
|
|
42
|
+
| `:content` | valence > 0.3, arousal <= 0.5 |
|
|
43
|
+
| `:anxious` | valence < -0.3, arousal > 0.5 |
|
|
44
|
+
| `:subdued` | valence < -0.3, arousal <= 0.5 |
|
|
45
|
+
| `:alert` | neutral valence, arousal > 0.7 |
|
|
46
|
+
| `:dormant` | neutral valence, arousal < 0.2 |
|
|
47
|
+
| `:neutral` | neutral valence, moderate arousal |
|
|
48
|
+
|
|
49
|
+
## LLM Enhancement
|
|
50
|
+
|
|
51
|
+
When `legion-llm` is loaded and started, `Helpers::LlmEnhancer` replaces the mechanical `Prose.*` sentence-concatenation pipeline with a single LLM-generated narrative.
|
|
52
|
+
|
|
53
|
+
**Method**: `LlmEnhancer.narrate(sections_data:)`
|
|
54
|
+
|
|
55
|
+
Accepts a hash of six cognitive domains — `emotion`, `curiosity`, `prediction`, `memory`, `attention`, and `reflection` — assembled by the runner from `tick_results` and `cognitive_state`. Sends the metrics to the LLM with a system prompt instructing it to write a 3-5 sentence first-person internal monologue (present tense, vivid, no raw numbers). Returns the generated narrative string, or `nil` on failure.
|
|
56
|
+
|
|
57
|
+
**Availability gate**: `LlmEnhancer.available?` checks `Legion::LLM.started?`. Returns `false` if `legion-llm` is not loaded, not configured, or raises any error — the check is fully safe to call unconditionally.
|
|
58
|
+
|
|
59
|
+
**Fallback**: When LLM is unavailable or `narrate` returns `nil`, the existing `Helpers::Prose` label-based pipeline runs unchanged. The runner includes `source: :llm` in the result hash only when the LLM path is taken.
|
|
60
|
+
|
|
61
|
+
## Development
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
bundle install
|
|
65
|
+
bundle exec rspec
|
|
66
|
+
bundle exec rubocop
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/narrator/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-narrator'
|
|
7
|
+
spec.version = Legion::Extensions::Narrator::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX Narrator'
|
|
12
|
+
spec.description = 'Cognitive narrative stream for brain-modeled agentic AI'
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/lex-narrator'
|
|
14
|
+
spec.license = 'MIT'
|
|
15
|
+
spec.required_ruby_version = '>= 3.4'
|
|
16
|
+
|
|
17
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
18
|
+
spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-narrator'
|
|
19
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-narrator'
|
|
20
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-narrator'
|
|
21
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-narrator/issues'
|
|
22
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
23
|
+
|
|
24
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
25
|
+
Dir.glob('{lib,spec}/**/*') + %w[lex-narrator.gemspec Gemfile LICENSE README.md]
|
|
26
|
+
end
|
|
27
|
+
spec.require_paths = ['lib']
|
|
28
|
+
spec.add_development_dependency 'legion-gaia'
|
|
29
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/narrator/helpers/constants'
|
|
4
|
+
require 'legion/extensions/narrator/helpers/prose'
|
|
5
|
+
require 'legion/extensions/narrator/helpers/journal'
|
|
6
|
+
require 'legion/extensions/narrator/helpers/synthesizer'
|
|
7
|
+
require 'legion/extensions/narrator/runners/narrator'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module Narrator
|
|
12
|
+
class Client
|
|
13
|
+
include Runners::Narrator
|
|
14
|
+
|
|
15
|
+
attr_reader :journal
|
|
16
|
+
|
|
17
|
+
def initialize(journal: nil, **)
|
|
18
|
+
@journal = journal || Helpers::Journal.new
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Narrator
|
|
6
|
+
module Helpers
|
|
7
|
+
module Constants
|
|
8
|
+
# Maximum entries in the narrative journal
|
|
9
|
+
MAX_JOURNAL_SIZE = 500
|
|
10
|
+
|
|
11
|
+
# Narrative generation modes
|
|
12
|
+
MODES = %i[mechanical enhanced].freeze
|
|
13
|
+
|
|
14
|
+
# Default sections in a narrative entry
|
|
15
|
+
SECTIONS = %i[
|
|
16
|
+
attention emotion curiosity prediction
|
|
17
|
+
memory reflection identity overall
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
# Emotional dimension labels for prose generation
|
|
21
|
+
EMOTION_LABELS = {
|
|
22
|
+
high_positive: 'engaged and optimistic',
|
|
23
|
+
low_positive: 'calm and steady',
|
|
24
|
+
neutral: 'emotionally neutral',
|
|
25
|
+
low_negative: 'slightly uneasy',
|
|
26
|
+
high_negative: 'distressed'
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# Arousal labels
|
|
30
|
+
AROUSAL_LABELS = {
|
|
31
|
+
high: 'highly alert',
|
|
32
|
+
medium: 'moderately attentive',
|
|
33
|
+
low: 'calm and measured',
|
|
34
|
+
dormant: 'in a low-activity state'
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
# Curiosity intensity labels
|
|
38
|
+
CURIOSITY_LABELS = {
|
|
39
|
+
high: 'deeply curious',
|
|
40
|
+
medium: 'moderately curious',
|
|
41
|
+
low: 'mildly interested',
|
|
42
|
+
none: 'not particularly curious about anything'
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
# Prediction confidence labels
|
|
46
|
+
CONFIDENCE_LABELS = {
|
|
47
|
+
high: 'confident in my predictions',
|
|
48
|
+
medium: 'moderately confident',
|
|
49
|
+
low: 'uncertain about outcomes',
|
|
50
|
+
none: 'lacking predictive context'
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
# Cognitive health labels
|
|
54
|
+
HEALTH_LABELS = {
|
|
55
|
+
excellent: 'operating at full capacity',
|
|
56
|
+
good: 'functioning well overall',
|
|
57
|
+
fair: 'showing some strain',
|
|
58
|
+
poor: 'experiencing significant cognitive difficulty',
|
|
59
|
+
critical: 'in cognitive distress'
|
|
60
|
+
}.freeze
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Narrator
|
|
6
|
+
module Helpers
|
|
7
|
+
class Journal
|
|
8
|
+
attr_reader :entries
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@entries = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def append(entry)
|
|
15
|
+
@entries << entry
|
|
16
|
+
trim_to_capacity
|
|
17
|
+
entry
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def recent(limit: 10)
|
|
21
|
+
@entries.last(limit)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def since(timestamp)
|
|
25
|
+
@entries.select { |e| e[:timestamp] >= timestamp }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def by_mood(mood)
|
|
29
|
+
@entries.select { |e| e[:mood] == mood }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def size
|
|
33
|
+
@entries.size
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def clear
|
|
37
|
+
@entries.clear
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def stats
|
|
41
|
+
return { total: 0, moods: {} } if @entries.empty?
|
|
42
|
+
|
|
43
|
+
mood_counts = @entries.each_with_object(Hash.new(0)) { |e, h| h[e[:mood]] += 1 }
|
|
44
|
+
{
|
|
45
|
+
total: @entries.size,
|
|
46
|
+
moods: mood_counts,
|
|
47
|
+
oldest: @entries.first[:timestamp],
|
|
48
|
+
newest: @entries.last[:timestamp],
|
|
49
|
+
capacity: Constants::MAX_JOURNAL_SIZE
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def trim_to_capacity
|
|
56
|
+
@entries.shift(@entries.size - Constants::MAX_JOURNAL_SIZE) if @entries.size > Constants::MAX_JOURNAL_SIZE
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Narrator
|
|
6
|
+
module Helpers
|
|
7
|
+
module LlmEnhancer
|
|
8
|
+
SYSTEM_PROMPT = <<~PROMPT
|
|
9
|
+
You are the internal narrator for an autonomous AI agent built on LegionIO.
|
|
10
|
+
You translate raw cognitive metrics into a flowing first-person internal monologue.
|
|
11
|
+
Write 3-5 sentences that feel like genuine introspection, not a report.
|
|
12
|
+
Vary your sentence structure. Use present tense. Be concise and vivid.
|
|
13
|
+
PROMPT
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def available?
|
|
18
|
+
!!(defined?(Legion::LLM) && Legion::LLM.respond_to?(:started?) && Legion::LLM.started?)
|
|
19
|
+
rescue StandardError
|
|
20
|
+
false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def narrate(sections_data:)
|
|
24
|
+
prompt = build_narrate_prompt(sections_data)
|
|
25
|
+
response = llm_ask(prompt)
|
|
26
|
+
parse_narrate_response(response)
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
Legion::Logging.warn "[narrator:llm] narrate failed: #{e.message}"
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# --- Private helpers ---
|
|
33
|
+
|
|
34
|
+
def llm_ask(prompt)
|
|
35
|
+
chat = Legion::LLM.chat
|
|
36
|
+
chat.with_instructions(SYSTEM_PROMPT)
|
|
37
|
+
chat.ask(prompt)
|
|
38
|
+
end
|
|
39
|
+
private_class_method :llm_ask
|
|
40
|
+
|
|
41
|
+
def build_narrate_prompt(sections_data)
|
|
42
|
+
parts = [
|
|
43
|
+
'Generate a first-person internal monologue based on the following cognitive state:',
|
|
44
|
+
'',
|
|
45
|
+
emotion_section(sections_data[:emotion] || {}),
|
|
46
|
+
curiosity_section(sections_data[:curiosity] || {}),
|
|
47
|
+
prediction_section(sections_data[:prediction] || {}),
|
|
48
|
+
memory_section(sections_data[:memory] || {}),
|
|
49
|
+
attention_section(sections_data[:attention] || {}),
|
|
50
|
+
reflection_section(sections_data[:reflection] || {}),
|
|
51
|
+
'',
|
|
52
|
+
"Write a 3-5 sentence first-person narrative. Do not mention numbers directly \u2014 translate them into felt experience."
|
|
53
|
+
]
|
|
54
|
+
parts.join("\n")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def emotion_section(emo)
|
|
58
|
+
"EMOTION:\n- Valence: #{emo[:valence] || 0.0}\n- Arousal: #{emo[:arousal] || 0.5}\n- Gut signal: #{emo[:gut] || 'none'}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def curiosity_section(cur)
|
|
62
|
+
"CURIOSITY:\n- Intensity: #{cur[:intensity] || 0.0}\n- Active questions: #{cur[:wonder_count] || 0}\n- Top question: #{cur[:top_wonder] || 'none'}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def prediction_section(pred)
|
|
66
|
+
"PREDICTION:\n- Confidence: #{pred[:confidence] || 0.0}\n- Pending: #{pred[:pending] || 0}\n- Mode: #{pred[:mode] || 'unknown'}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def memory_section(mem)
|
|
70
|
+
"MEMORY:\n- Active traces: #{mem[:trace_count] || 0}\n- Health: #{mem[:health] || 1.0}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def attention_section(att)
|
|
74
|
+
domains = Array(att[:focused_domains]).join(', ')
|
|
75
|
+
domains = 'none' if domains.empty?
|
|
76
|
+
"ATTENTION:\n- Spotlight: #{att[:spotlight] || 0}\n- Peripheral: #{att[:peripheral] || 0}\n- Focused: #{domains}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def reflection_section(ref)
|
|
80
|
+
"REFLECTION:\n- Cognitive health: #{ref[:health] || 1.0}\n- Pending adaptations: #{ref[:pending_adaptations] || 0}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def parse_narrate_response(response)
|
|
84
|
+
return nil unless response&.content
|
|
85
|
+
|
|
86
|
+
response.content.strip
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private_class_method :build_narrate_prompt
|
|
90
|
+
private_class_method :emotion_section
|
|
91
|
+
private_class_method :curiosity_section
|
|
92
|
+
private_class_method :prediction_section
|
|
93
|
+
private_class_method :memory_section
|
|
94
|
+
private_class_method :attention_section
|
|
95
|
+
private_class_method :reflection_section
|
|
96
|
+
private_class_method :parse_narrate_response
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Narrator
|
|
6
|
+
module Helpers
|
|
7
|
+
module Prose
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def emotion_phrase(valence: 0.0, arousal: 0.5, gut: nil)
|
|
11
|
+
emotion_label = emotion_label_for(valence)
|
|
12
|
+
arousal_label = arousal_label_for(arousal)
|
|
13
|
+
base = "I am #{arousal_label} and #{emotion_label}"
|
|
14
|
+
gut_note = gut_phrase(gut)
|
|
15
|
+
gut_note ? "#{base}. #{gut_note}" : base
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def curiosity_phrase(intensity: 0.0, top_wonder: nil, wonder_count: 0)
|
|
19
|
+
label = curiosity_label_for(intensity)
|
|
20
|
+
base = "I am #{label}"
|
|
21
|
+
if top_wonder && wonder_count.positive?
|
|
22
|
+
"#{base}, with #{wonder_count} open #{'question' if wonder_count == 1}#{'questions' if wonder_count != 1}. " \
|
|
23
|
+
"Most pressing: \"#{top_wonder}\""
|
|
24
|
+
else
|
|
25
|
+
base
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def prediction_phrase(confidence: 0.0, pending: 0, mode: nil)
|
|
30
|
+
label = confidence_label_for(confidence)
|
|
31
|
+
base = "I am #{label}"
|
|
32
|
+
base += " (#{mode} reasoning)" if mode
|
|
33
|
+
base += ", with #{pending} pending predictions" if pending.positive?
|
|
34
|
+
base
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def attention_phrase(spotlight: 0, peripheral: 0, focused_domains: [])
|
|
38
|
+
parts = ["#{spotlight} signals in spotlight focus, #{peripheral} in peripheral awareness"]
|
|
39
|
+
parts << "manually focused on: #{focused_domains.join(', ')}" if focused_domains.any?
|
|
40
|
+
parts.join('. ')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def memory_phrase(trace_count: 0, health: 1.0)
|
|
44
|
+
health_label = health_label_for(health)
|
|
45
|
+
"Memory system #{health_label} with #{trace_count} active traces"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def reflection_phrase(health: 1.0, pending_adaptations: 0, recent_severity: nil)
|
|
49
|
+
label = health_label_for(health)
|
|
50
|
+
base = "Cognitive health: #{label}"
|
|
51
|
+
base += ". #{pending_adaptations} pending adaptation recommendations" if pending_adaptations.positive?
|
|
52
|
+
base += ". Most recent concern: #{recent_severity}" if recent_severity
|
|
53
|
+
base
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def overall_narrative(sections)
|
|
57
|
+
"#{sections.compact.reject(&:empty?).join('. ')}."
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def gut_phrase(gut)
|
|
61
|
+
return nil unless gut.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
signal = gut[:signal] || gut[:gut_signal]
|
|
64
|
+
return nil unless signal
|
|
65
|
+
|
|
66
|
+
if signal > 0.3
|
|
67
|
+
'My gut says something important is happening'
|
|
68
|
+
elsif signal < -0.3
|
|
69
|
+
'I have an uneasy feeling about the current situation'
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def emotion_label_for(valence)
|
|
74
|
+
if valence > 0.5 then Constants::EMOTION_LABELS[:high_positive]
|
|
75
|
+
elsif valence > 0.1 then Constants::EMOTION_LABELS[:low_positive]
|
|
76
|
+
elsif valence > -0.1 then Constants::EMOTION_LABELS[:neutral]
|
|
77
|
+
elsif valence > -0.5 then Constants::EMOTION_LABELS[:low_negative]
|
|
78
|
+
else Constants::EMOTION_LABELS[:high_negative]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def arousal_label_for(arousal)
|
|
83
|
+
if arousal > 0.7 then Constants::AROUSAL_LABELS[:high]
|
|
84
|
+
elsif arousal > 0.4 then Constants::AROUSAL_LABELS[:medium]
|
|
85
|
+
elsif arousal > 0.1 then Constants::AROUSAL_LABELS[:low]
|
|
86
|
+
else Constants::AROUSAL_LABELS[:dormant]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def curiosity_label_for(intensity)
|
|
91
|
+
if intensity > 0.7 then Constants::CURIOSITY_LABELS[:high]
|
|
92
|
+
elsif intensity > 0.4 then Constants::CURIOSITY_LABELS[:medium]
|
|
93
|
+
elsif intensity > 0.1 then Constants::CURIOSITY_LABELS[:low]
|
|
94
|
+
else Constants::CURIOSITY_LABELS[:none]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def confidence_label_for(confidence)
|
|
99
|
+
if confidence > 0.7 then Constants::CONFIDENCE_LABELS[:high]
|
|
100
|
+
elsif confidence > 0.4 then Constants::CONFIDENCE_LABELS[:medium]
|
|
101
|
+
elsif confidence > 0.1 then Constants::CONFIDENCE_LABELS[:low]
|
|
102
|
+
else Constants::CONFIDENCE_LABELS[:none]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def health_label_for(health)
|
|
107
|
+
if health > 0.9 then Constants::HEALTH_LABELS[:excellent]
|
|
108
|
+
elsif health > 0.7 then Constants::HEALTH_LABELS[:good]
|
|
109
|
+
elsif health > 0.5 then Constants::HEALTH_LABELS[:fair]
|
|
110
|
+
elsif health > 0.3 then Constants::HEALTH_LABELS[:poor]
|
|
111
|
+
else Constants::HEALTH_LABELS[:critical]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Narrator
|
|
6
|
+
module Helpers
|
|
7
|
+
module Synthesizer
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def narrate(tick_results: {}, cognitive_state: {})
|
|
11
|
+
sections = build_sections(tick_results, cognitive_state)
|
|
12
|
+
narrative = Prose.overall_narrative(sections.values)
|
|
13
|
+
mood = infer_mood(tick_results, cognitive_state)
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
timestamp: Time.now.utc,
|
|
17
|
+
narrative: narrative,
|
|
18
|
+
sections: sections,
|
|
19
|
+
mood: mood,
|
|
20
|
+
tick_seq: tick_results[:tick_seq] || cognitive_state[:tick_seq]
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build_sections(tick_results, cognitive_state)
|
|
25
|
+
{
|
|
26
|
+
attention: synthesize_attention(tick_results, cognitive_state),
|
|
27
|
+
emotion: synthesize_emotion(tick_results, cognitive_state),
|
|
28
|
+
curiosity: synthesize_curiosity(tick_results, cognitive_state),
|
|
29
|
+
prediction: synthesize_prediction(tick_results, cognitive_state),
|
|
30
|
+
memory: synthesize_memory(tick_results, cognitive_state),
|
|
31
|
+
reflection: synthesize_reflection(cognitive_state)
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def synthesize_attention(tick_results, cognitive_state)
|
|
36
|
+
attention = tick_results[:sensory_processing] || {}
|
|
37
|
+
focus = cognitive_state[:attention_status] || {}
|
|
38
|
+
|
|
39
|
+
Prose.attention_phrase(
|
|
40
|
+
spotlight: attention[:spotlight] || 0,
|
|
41
|
+
peripheral: attention[:peripheral] || 0,
|
|
42
|
+
focused_domains: extract_focused_domains(focus)
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def synthesize_emotion(tick_results, cognitive_state)
|
|
47
|
+
valence_data = tick_results[:emotional_evaluation] || {}
|
|
48
|
+
gut_data = tick_results[:gut_instinct] || cognitive_state[:gut] || {}
|
|
49
|
+
|
|
50
|
+
Prose.emotion_phrase(
|
|
51
|
+
valence: valence_data[:valence] || cognitive_state.dig(:emotion, :valence) || 0.0,
|
|
52
|
+
arousal: valence_data[:arousal] || cognitive_state.dig(:emotion, :arousal) || 0.5,
|
|
53
|
+
gut: gut_data
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def synthesize_curiosity(tick_results, cognitive_state)
|
|
58
|
+
curiosity_data = cognitive_state[:curiosity] || {}
|
|
59
|
+
wonder_data = tick_results[:working_memory_integration] || {}
|
|
60
|
+
|
|
61
|
+
Prose.curiosity_phrase(
|
|
62
|
+
intensity: curiosity_data[:intensity] || wonder_data[:curiosity_intensity] || 0.0,
|
|
63
|
+
top_wonder: extract_top_wonder(curiosity_data, wonder_data),
|
|
64
|
+
wonder_count: curiosity_data[:active_count] || wonder_data[:active_wonders] || 0
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def synthesize_prediction(tick_results, cognitive_state)
|
|
69
|
+
pred_data = tick_results[:prediction_engine] || {}
|
|
70
|
+
pred_state = cognitive_state[:prediction] || {}
|
|
71
|
+
|
|
72
|
+
Prose.prediction_phrase(
|
|
73
|
+
confidence: pred_data[:confidence] || pred_state[:confidence] || 0.0,
|
|
74
|
+
pending: pred_state[:pending_count] || 0,
|
|
75
|
+
mode: pred_data[:mode] || pred_state[:mode]
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def synthesize_memory(tick_results, cognitive_state)
|
|
80
|
+
memory_data = cognitive_state[:memory] || {}
|
|
81
|
+
consol = tick_results[:memory_consolidation] || {}
|
|
82
|
+
|
|
83
|
+
Prose.memory_phrase(
|
|
84
|
+
trace_count: memory_data[:trace_count] || consol[:remaining] || 0,
|
|
85
|
+
health: memory_data[:health] || 1.0
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def synthesize_reflection(cognitive_state)
|
|
90
|
+
ref_data = cognitive_state[:reflection] || {}
|
|
91
|
+
|
|
92
|
+
Prose.reflection_phrase(
|
|
93
|
+
health: ref_data[:health] || 1.0,
|
|
94
|
+
pending_adaptations: ref_data[:pending_adaptations] || 0,
|
|
95
|
+
recent_severity: ref_data[:recent_severity]
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def infer_mood(tick_results, cognitive_state)
|
|
100
|
+
valence = tick_results.dig(:emotional_evaluation, :valence) ||
|
|
101
|
+
cognitive_state.dig(:emotion, :valence) || 0.0
|
|
102
|
+
arousal = tick_results.dig(:emotional_evaluation, :arousal) ||
|
|
103
|
+
cognitive_state.dig(:emotion, :arousal) || 0.5
|
|
104
|
+
|
|
105
|
+
classify_mood(valence, arousal)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def classify_mood(valence, arousal)
|
|
109
|
+
if valence > 0.3 && arousal > 0.5 then :energized
|
|
110
|
+
elsif valence > 0.3 then :content
|
|
111
|
+
elsif valence < -0.3 && arousal > 0.5 then :anxious
|
|
112
|
+
elsif valence < -0.3 then :subdued
|
|
113
|
+
elsif arousal > 0.7 then :alert
|
|
114
|
+
elsif arousal < 0.2 then :dormant
|
|
115
|
+
else :neutral
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def extract_focused_domains(focus)
|
|
120
|
+
manual = focus[:manual_focus]
|
|
121
|
+
return [] unless manual.is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
manual.keys.map(&:to_s)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def extract_top_wonder(curiosity_data, wonder_data)
|
|
127
|
+
curiosity_data[:top_question] || wonder_data[:top_question] ||
|
|
128
|
+
(wonder_data[:top_wonder].is_a?(Hash) ? wonder_data[:top_wonder][:question] : nil)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|