lex-agentic-language 0.1.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 +7 -0
- data/CHANGELOG.md +6 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +13 -0
- data/lex-agentic-language.gemspec +30 -0
- data/lib/legion/extensions/agentic/language/conceptual_blending/client.rb +25 -0
- data/lib/legion/extensions/agentic/language/conceptual_blending/helpers/blend.rb +91 -0
- data/lib/legion/extensions/agentic/language/conceptual_blending/helpers/blending_engine.rb +171 -0
- data/lib/legion/extensions/agentic/language/conceptual_blending/helpers/constants.rb +35 -0
- data/lib/legion/extensions/agentic/language/conceptual_blending/helpers/mental_space.rb +51 -0
- data/lib/legion/extensions/agentic/language/conceptual_blending/runners/conceptual_blending.rb +106 -0
- data/lib/legion/extensions/agentic/language/conceptual_blending/version.rb +13 -0
- data/lib/legion/extensions/agentic/language/conceptual_blending.rb +20 -0
- data/lib/legion/extensions/agentic/language/conceptual_metaphor/client.rb +19 -0
- data/lib/legion/extensions/agentic/language/conceptual_metaphor/helpers/constants.rb +49 -0
- data/lib/legion/extensions/agentic/language/conceptual_metaphor/helpers/metaphor.rb +109 -0
- data/lib/legion/extensions/agentic/language/conceptual_metaphor/helpers/metaphor_engine.rb +154 -0
- data/lib/legion/extensions/agentic/language/conceptual_metaphor/runners/conceptual_metaphor.rb +107 -0
- data/lib/legion/extensions/agentic/language/conceptual_metaphor/version.rb +13 -0
- data/lib/legion/extensions/agentic/language/conceptual_metaphor.rb +19 -0
- data/lib/legion/extensions/agentic/language/frame_semantics/helpers/client.rb +23 -0
- data/lib/legion/extensions/agentic/language/frame_semantics/helpers/constants.rb +32 -0
- data/lib/legion/extensions/agentic/language/frame_semantics/helpers/frame.rb +109 -0
- data/lib/legion/extensions/agentic/language/frame_semantics/helpers/frame_engine.rb +139 -0
- data/lib/legion/extensions/agentic/language/frame_semantics/helpers/frame_instance.rb +51 -0
- data/lib/legion/extensions/agentic/language/frame_semantics/runners/frame_semantics.rb +108 -0
- data/lib/legion/extensions/agentic/language/frame_semantics/version.rb +13 -0
- data/lib/legion/extensions/agentic/language/frame_semantics.rb +20 -0
- data/lib/legion/extensions/agentic/language/grammar/client.rb +29 -0
- data/lib/legion/extensions/agentic/language/grammar/helpers/constants.rb +38 -0
- data/lib/legion/extensions/agentic/language/grammar/helpers/construal.rb +68 -0
- data/lib/legion/extensions/agentic/language/grammar/helpers/construction.rb +68 -0
- data/lib/legion/extensions/agentic/language/grammar/helpers/grammar_engine.rb +119 -0
- data/lib/legion/extensions/agentic/language/grammar/runners/cognitive_grammar.rb +100 -0
- data/lib/legion/extensions/agentic/language/grammar/version.rb +13 -0
- data/lib/legion/extensions/agentic/language/grammar.rb +20 -0
- data/lib/legion/extensions/agentic/language/inner_speech/client.rb +19 -0
- data/lib/legion/extensions/agentic/language/inner_speech/helpers/constants.rb +53 -0
- data/lib/legion/extensions/agentic/language/inner_speech/helpers/inner_voice.rb +141 -0
- data/lib/legion/extensions/agentic/language/inner_speech/helpers/speech_stream.rb +128 -0
- data/lib/legion/extensions/agentic/language/inner_speech/helpers/utterance.rb +90 -0
- data/lib/legion/extensions/agentic/language/inner_speech/runners/inner_speech.rb +87 -0
- data/lib/legion/extensions/agentic/language/inner_speech/version.rb +13 -0
- data/lib/legion/extensions/agentic/language/inner_speech.rb +20 -0
- data/lib/legion/extensions/agentic/language/language/client.rb +26 -0
- data/lib/legion/extensions/agentic/language/language/helpers/constants.rb +43 -0
- data/lib/legion/extensions/agentic/language/language/helpers/lexicon.rb +63 -0
- data/lib/legion/extensions/agentic/language/language/helpers/summarizer.rb +167 -0
- data/lib/legion/extensions/agentic/language/language/runners/language.rb +134 -0
- data/lib/legion/extensions/agentic/language/language/version.rb +13 -0
- data/lib/legion/extensions/agentic/language/language.rb +19 -0
- data/lib/legion/extensions/agentic/language/narrative_reasoning/client.rb +28 -0
- data/lib/legion/extensions/agentic/language/narrative_reasoning/helpers/narrative.rb +123 -0
- data/lib/legion/extensions/agentic/language/narrative_reasoning/helpers/narrative_engine.rb +122 -0
- data/lib/legion/extensions/agentic/language/narrative_reasoning/helpers/narrative_event.rb +41 -0
- data/lib/legion/extensions/agentic/language/narrative_reasoning/runners/narrative_reasoning.rb +122 -0
- data/lib/legion/extensions/agentic/language/narrative_reasoning/version.rb +13 -0
- data/lib/legion/extensions/agentic/language/narrative_reasoning.rb +18 -0
- data/lib/legion/extensions/agentic/language/narrator/client.rb +27 -0
- data/lib/legion/extensions/agentic/language/narrator/helpers/constants.rb +69 -0
- data/lib/legion/extensions/agentic/language/narrator/helpers/journal.rb +68 -0
- data/lib/legion/extensions/agentic/language/narrator/helpers/llm_enhancer.rb +105 -0
- data/lib/legion/extensions/agentic/language/narrator/helpers/prose.rb +122 -0
- data/lib/legion/extensions/agentic/language/narrator/helpers/synthesizer.rb +138 -0
- data/lib/legion/extensions/agentic/language/narrator/runners/narrator.rb +196 -0
- data/lib/legion/extensions/agentic/language/narrator/version.rb +13 -0
- data/lib/legion/extensions/agentic/language/narrator.rb +21 -0
- data/lib/legion/extensions/agentic/language/pragmatic_inference/client.rb +28 -0
- data/lib/legion/extensions/agentic/language/pragmatic_inference/helpers/constants.rb +52 -0
- data/lib/legion/extensions/agentic/language/pragmatic_inference/helpers/pragmatic_engine.rb +164 -0
- data/lib/legion/extensions/agentic/language/pragmatic_inference/helpers/utterance.rb +84 -0
- data/lib/legion/extensions/agentic/language/pragmatic_inference/runners/pragmatic_inference.rb +136 -0
- data/lib/legion/extensions/agentic/language/pragmatic_inference/version.rb +13 -0
- data/lib/legion/extensions/agentic/language/pragmatic_inference.rb +18 -0
- data/lib/legion/extensions/agentic/language/version.rb +11 -0
- data/lib/legion/extensions/agentic/language.rb +28 -0
- data/spec/legion/extensions/agentic/language/conceptual_blending/client_spec.rb +78 -0
- data/spec/legion/extensions/agentic/language/conceptual_blending/helpers/blend_spec.rb +141 -0
- data/spec/legion/extensions/agentic/language/conceptual_blending/helpers/blending_engine_spec.rb +211 -0
- data/spec/legion/extensions/agentic/language/conceptual_blending/helpers/mental_space_spec.rb +85 -0
- data/spec/legion/extensions/agentic/language/conceptual_blending/runners/conceptual_blending_spec.rb +162 -0
- data/spec/legion/extensions/agentic/language/conceptual_metaphor/client_spec.rb +29 -0
- data/spec/legion/extensions/agentic/language/conceptual_metaphor/helpers/metaphor_engine_spec.rb +166 -0
- data/spec/legion/extensions/agentic/language/conceptual_metaphor/helpers/metaphor_spec.rb +133 -0
- data/spec/legion/extensions/agentic/language/conceptual_metaphor/runners/conceptual_metaphor_spec.rb +133 -0
- data/spec/legion/extensions/agentic/language/frame_semantics/helpers/frame_engine_spec.rb +227 -0
- data/spec/legion/extensions/agentic/language/frame_semantics/helpers/frame_instance_spec.rb +83 -0
- data/spec/legion/extensions/agentic/language/frame_semantics/helpers/frame_spec.rb +213 -0
- data/spec/legion/extensions/agentic/language/frame_semantics/runners/frame_semantics_spec.rb +155 -0
- data/spec/legion/extensions/agentic/language/grammar/client_spec.rb +121 -0
- data/spec/legion/extensions/agentic/language/grammar/cognitive_grammar_spec.rb +18 -0
- data/spec/legion/extensions/agentic/language/grammar/helpers/constants_spec.rb +67 -0
- data/spec/legion/extensions/agentic/language/grammar/helpers/construal_spec.rb +124 -0
- data/spec/legion/extensions/agentic/language/grammar/helpers/construction_spec.rb +155 -0
- data/spec/legion/extensions/agentic/language/grammar/helpers/grammar_engine_spec.rb +206 -0
- data/spec/legion/extensions/agentic/language/grammar/runners/cognitive_grammar_spec.rb +189 -0
- data/spec/legion/extensions/agentic/language/inner_speech/client_spec.rb +39 -0
- data/spec/legion/extensions/agentic/language/inner_speech/helpers/inner_voice_spec.rb +185 -0
- data/spec/legion/extensions/agentic/language/inner_speech/helpers/speech_stream_spec.rb +158 -0
- data/spec/legion/extensions/agentic/language/inner_speech/helpers/utterance_spec.rb +121 -0
- data/spec/legion/extensions/agentic/language/inner_speech/runners/inner_speech_spec.rb +102 -0
- data/spec/legion/extensions/agentic/language/language/client_spec.rb +20 -0
- data/spec/legion/extensions/agentic/language/language/helpers/constants_spec.rb +31 -0
- data/spec/legion/extensions/agentic/language/language/helpers/lexicon_spec.rb +116 -0
- data/spec/legion/extensions/agentic/language/language/helpers/summarizer_spec.rb +224 -0
- data/spec/legion/extensions/agentic/language/language/runners/language_spec.rb +169 -0
- data/spec/legion/extensions/agentic/language/narrative_reasoning/client_spec.rb +19 -0
- data/spec/legion/extensions/agentic/language/narrative_reasoning/helpers/narrative_engine_spec.rb +182 -0
- data/spec/legion/extensions/agentic/language/narrative_reasoning/helpers/narrative_event_spec.rb +61 -0
- data/spec/legion/extensions/agentic/language/narrative_reasoning/helpers/narrative_spec.rb +168 -0
- data/spec/legion/extensions/agentic/language/narrative_reasoning/runners/narrative_reasoning_spec.rb +174 -0
- data/spec/legion/extensions/agentic/language/narrator/client_spec.rb +24 -0
- data/spec/legion/extensions/agentic/language/narrator/helpers/journal_spec.rb +95 -0
- data/spec/legion/extensions/agentic/language/narrator/helpers/llm_enhancer_spec.rb +107 -0
- data/spec/legion/extensions/agentic/language/narrator/helpers/prose_spec.rb +134 -0
- data/spec/legion/extensions/agentic/language/narrator/helpers/synthesizer_spec.rb +89 -0
- data/spec/legion/extensions/agentic/language/narrator/runners/narrator_llm_spec.rb +74 -0
- data/spec/legion/extensions/agentic/language/narrator/runners/narrator_spec.rb +126 -0
- data/spec/legion/extensions/agentic/language/pragmatic_inference/client_spec.rb +19 -0
- data/spec/legion/extensions/agentic/language/pragmatic_inference/helpers/constants_spec.rb +73 -0
- data/spec/legion/extensions/agentic/language/pragmatic_inference/helpers/pragmatic_engine_spec.rb +185 -0
- data/spec/legion/extensions/agentic/language/pragmatic_inference/helpers/utterance_spec.rb +111 -0
- data/spec/legion/extensions/agentic/language/pragmatic_inference/runners/pragmatic_inference_spec.rb +231 -0
- data/spec/spec_helper.rb +33 -0
- metadata +210 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Agentic::Language::Narrator::Helpers::Journal do
|
|
4
|
+
subject(:journal) { described_class.new }
|
|
5
|
+
|
|
6
|
+
let(:entry) do
|
|
7
|
+
{ timestamp: Time.now.utc, narrative: 'I am alert.', mood: :energized, sections: {} }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
describe '#append' do
|
|
11
|
+
it 'adds an entry' do
|
|
12
|
+
journal.append(entry)
|
|
13
|
+
expect(journal.size).to eq(1)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'returns the appended entry' do
|
|
17
|
+
result = journal.append(entry)
|
|
18
|
+
expect(result[:narrative]).to eq('I am alert.')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'trims to MAX_JOURNAL_SIZE' do
|
|
22
|
+
max = Legion::Extensions::Agentic::Language::Narrator::Helpers::Constants::MAX_JOURNAL_SIZE
|
|
23
|
+
(max + 10).times { |i| journal.append(entry.merge(narrative: "Entry #{i}")) }
|
|
24
|
+
expect(journal.size).to eq(max)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe '#recent' do
|
|
29
|
+
it 'returns last N entries' do
|
|
30
|
+
5.times { |i| journal.append(entry.merge(narrative: "Entry #{i}")) }
|
|
31
|
+
result = journal.recent(limit: 3)
|
|
32
|
+
expect(result.size).to eq(3)
|
|
33
|
+
expect(result.last[:narrative]).to eq('Entry 4')
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'returns all entries when fewer than limit' do
|
|
37
|
+
2.times { |i| journal.append(entry.merge(narrative: "Entry #{i}")) }
|
|
38
|
+
expect(journal.recent(limit: 10).size).to eq(2)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
describe '#since' do
|
|
43
|
+
it 'returns entries after a timestamp' do
|
|
44
|
+
old = entry.merge(timestamp: Time.now.utc - 3600)
|
|
45
|
+
recent_entry = entry.merge(timestamp: Time.now.utc)
|
|
46
|
+
|
|
47
|
+
journal.append(old)
|
|
48
|
+
journal.append(recent_entry)
|
|
49
|
+
|
|
50
|
+
cutoff = Time.now.utc - 1800
|
|
51
|
+
result = journal.since(cutoff)
|
|
52
|
+
expect(result.size).to eq(1)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
describe '#by_mood' do
|
|
57
|
+
it 'filters entries by mood' do
|
|
58
|
+
journal.append(entry.merge(mood: :energized))
|
|
59
|
+
journal.append(entry.merge(mood: :dormant))
|
|
60
|
+
journal.append(entry.merge(mood: :energized))
|
|
61
|
+
|
|
62
|
+
result = journal.by_mood(:energized)
|
|
63
|
+
expect(result.size).to eq(2)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
describe '#stats' do
|
|
68
|
+
it 'returns empty stats for empty journal' do
|
|
69
|
+
stats = journal.stats
|
|
70
|
+
expect(stats[:total]).to eq(0)
|
|
71
|
+
expect(stats[:moods]).to eq({})
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'returns mood distribution and timestamps' do
|
|
75
|
+
journal.append(entry.merge(mood: :energized))
|
|
76
|
+
journal.append(entry.merge(mood: :neutral))
|
|
77
|
+
journal.append(entry.merge(mood: :energized))
|
|
78
|
+
|
|
79
|
+
stats = journal.stats
|
|
80
|
+
expect(stats[:total]).to eq(3)
|
|
81
|
+
expect(stats[:moods][:energized]).to eq(2)
|
|
82
|
+
expect(stats[:moods][:neutral]).to eq(1)
|
|
83
|
+
expect(stats).to have_key(:oldest)
|
|
84
|
+
expect(stats).to have_key(:newest)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
describe '#clear' do
|
|
89
|
+
it 'removes all entries' do
|
|
90
|
+
3.times { journal.append(entry) }
|
|
91
|
+
journal.clear
|
|
92
|
+
expect(journal.size).to eq(0)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Agentic::Language::Narrator::Helpers::LlmEnhancer do
|
|
4
|
+
describe '.available?' do
|
|
5
|
+
context 'when Legion::LLM is not defined' do
|
|
6
|
+
it 'returns false' do
|
|
7
|
+
hide_const('Legion::LLM')
|
|
8
|
+
expect(described_class.available?).to be false
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
context 'when Legion::LLM is defined but not started' do
|
|
13
|
+
it 'returns false' do
|
|
14
|
+
llm_double = double('Legion::LLM', started?: false)
|
|
15
|
+
stub_const('Legion::LLM', llm_double)
|
|
16
|
+
expect(described_class.available?).to be false
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
context 'when Legion::LLM is started' do
|
|
21
|
+
it 'returns true' do
|
|
22
|
+
llm_double = double('Legion::LLM', started?: true)
|
|
23
|
+
stub_const('Legion::LLM', llm_double)
|
|
24
|
+
expect(described_class.available?).to be true
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
context 'when Legion::LLM raises an error' do
|
|
29
|
+
it 'returns false' do
|
|
30
|
+
llm_double = double('Legion::LLM')
|
|
31
|
+
allow(llm_double).to receive(:respond_to?).and_raise(StandardError)
|
|
32
|
+
stub_const('Legion::LLM', llm_double)
|
|
33
|
+
expect(described_class.available?).to be false
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
describe '.narrate' do
|
|
39
|
+
let(:sections_data) do
|
|
40
|
+
{
|
|
41
|
+
emotion: { valence: 0.6, arousal: 0.7, gut: nil },
|
|
42
|
+
curiosity: { intensity: 0.5, wonder_count: 3, top_wonder: 'What is next?' },
|
|
43
|
+
prediction: { confidence: 0.8, pending: 2, mode: :causal },
|
|
44
|
+
memory: { trace_count: 42, health: 0.9 },
|
|
45
|
+
attention: { spotlight: 4, peripheral: 2, focused_domains: ['planning'] },
|
|
46
|
+
reflection: { health: 0.95, pending_adaptations: 1 }
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
context 'when LLM returns a response' do
|
|
51
|
+
it 'returns the response content as a string' do
|
|
52
|
+
response_double = double('response', content: 'I feel alert and curious about what lies ahead.')
|
|
53
|
+
chat_double = double('chat')
|
|
54
|
+
allow(chat_double).to receive(:with_instructions)
|
|
55
|
+
allow(chat_double).to receive(:ask).and_return(response_double)
|
|
56
|
+
llm_double = double('Legion::LLM', started?: true)
|
|
57
|
+
allow(llm_double).to receive(:chat).and_return(chat_double)
|
|
58
|
+
stub_const('Legion::LLM', llm_double)
|
|
59
|
+
|
|
60
|
+
result = described_class.narrate(sections_data: sections_data)
|
|
61
|
+
expect(result).to be_a(String)
|
|
62
|
+
expect(result).to eq('I feel alert and curious about what lies ahead.')
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
context 'when LLM returns nil response' do
|
|
67
|
+
it 'returns nil' do
|
|
68
|
+
chat_double = double('chat')
|
|
69
|
+
allow(chat_double).to receive(:with_instructions)
|
|
70
|
+
allow(chat_double).to receive(:ask).and_return(nil)
|
|
71
|
+
llm_double = double('Legion::LLM', started?: true)
|
|
72
|
+
allow(llm_double).to receive(:chat).and_return(chat_double)
|
|
73
|
+
stub_const('Legion::LLM', llm_double)
|
|
74
|
+
|
|
75
|
+
result = described_class.narrate(sections_data: sections_data)
|
|
76
|
+
expect(result).to be_nil
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
context 'when an error occurs' do
|
|
81
|
+
it 'returns nil and logs a warning' do
|
|
82
|
+
llm_double = double('Legion::LLM', started?: true)
|
|
83
|
+
allow(llm_double).to receive(:chat).and_raise(StandardError, 'connection failed')
|
|
84
|
+
stub_const('Legion::LLM', llm_double)
|
|
85
|
+
|
|
86
|
+
expect(Legion::Logging).to receive(:warn).with(/narrator:llm.*narrate failed/)
|
|
87
|
+
result = described_class.narrate(sections_data: sections_data)
|
|
88
|
+
expect(result).to be_nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
context 'with empty sections_data' do
|
|
93
|
+
it 'does not raise and returns a string when LLM responds' do
|
|
94
|
+
response_double = double('response', content: 'Everything seems quiet.')
|
|
95
|
+
chat_double = double('chat')
|
|
96
|
+
allow(chat_double).to receive(:with_instructions)
|
|
97
|
+
allow(chat_double).to receive(:ask).and_return(response_double)
|
|
98
|
+
llm_double = double('Legion::LLM', started?: true)
|
|
99
|
+
allow(llm_double).to receive(:chat).and_return(chat_double)
|
|
100
|
+
stub_const('Legion::LLM', llm_double)
|
|
101
|
+
|
|
102
|
+
result = described_class.narrate(sections_data: {})
|
|
103
|
+
expect(result).to eq('Everything seems quiet.')
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Agentic::Language::Narrator::Helpers::Prose do
|
|
4
|
+
let(:prose) { described_class }
|
|
5
|
+
|
|
6
|
+
describe '.emotion_phrase' do
|
|
7
|
+
it 'describes high positive valence with high arousal' do
|
|
8
|
+
result = prose.emotion_phrase(valence: 0.8, arousal: 0.9)
|
|
9
|
+
expect(result).to include('highly alert')
|
|
10
|
+
expect(result).to include('engaged and optimistic')
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'describes low negative valence' do
|
|
14
|
+
result = prose.emotion_phrase(valence: -0.4, arousal: 0.3)
|
|
15
|
+
expect(result).to include('slightly uneasy')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'includes gut signal when strong positive' do
|
|
19
|
+
result = prose.emotion_phrase(valence: 0.0, arousal: 0.5, gut: { signal: 0.5 })
|
|
20
|
+
expect(result).to include('something important')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'includes gut signal when strong negative' do
|
|
24
|
+
result = prose.emotion_phrase(valence: 0.0, arousal: 0.5, gut: { signal: -0.5 })
|
|
25
|
+
expect(result).to include('uneasy feeling')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'omits gut note when signal is mild' do
|
|
29
|
+
result = prose.emotion_phrase(valence: 0.0, arousal: 0.5, gut: { signal: 0.1 })
|
|
30
|
+
expect(result).not_to include('gut')
|
|
31
|
+
expect(result).not_to include('important')
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
describe '.curiosity_phrase' do
|
|
36
|
+
it 'describes high curiosity with wonders' do
|
|
37
|
+
result = prose.curiosity_phrase(intensity: 0.8, top_wonder: 'Why are traces sparse?', wonder_count: 3)
|
|
38
|
+
expect(result).to include('deeply curious')
|
|
39
|
+
expect(result).to include('3 open questions')
|
|
40
|
+
expect(result).to include('Why are traces sparse?')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'describes no curiosity' do
|
|
44
|
+
result = prose.curiosity_phrase(intensity: 0.0)
|
|
45
|
+
expect(result).to include('not particularly curious')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'handles single wonder' do
|
|
49
|
+
result = prose.curiosity_phrase(intensity: 0.5, top_wonder: 'What is this?', wonder_count: 1)
|
|
50
|
+
expect(result).to include('1 open question')
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
describe '.prediction_phrase' do
|
|
55
|
+
it 'describes high confidence' do
|
|
56
|
+
result = prose.prediction_phrase(confidence: 0.9, pending: 2, mode: :functional_mapping)
|
|
57
|
+
expect(result).to include('confident')
|
|
58
|
+
expect(result).to include('functional_mapping')
|
|
59
|
+
expect(result).to include('2 pending predictions')
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'describes low confidence' do
|
|
63
|
+
result = prose.prediction_phrase(confidence: 0.2)
|
|
64
|
+
expect(result).to include('uncertain')
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
describe '.attention_phrase' do
|
|
69
|
+
it 'describes spotlight and peripheral counts' do
|
|
70
|
+
result = prose.attention_phrase(spotlight: 3, peripheral: 5)
|
|
71
|
+
expect(result).to include('3 signals in spotlight')
|
|
72
|
+
expect(result).to include('5 in peripheral')
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'includes manual focus domains' do
|
|
76
|
+
result = prose.attention_phrase(spotlight: 1, peripheral: 0, focused_domains: %w[terraform vault])
|
|
77
|
+
expect(result).to include('terraform')
|
|
78
|
+
expect(result).to include('vault')
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
describe '.memory_phrase' do
|
|
83
|
+
it 'describes memory state' do
|
|
84
|
+
result = prose.memory_phrase(trace_count: 150, health: 0.85)
|
|
85
|
+
expect(result).to include('150 active traces')
|
|
86
|
+
expect(result).to include('functioning well')
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
describe '.reflection_phrase' do
|
|
91
|
+
it 'describes healthy state' do
|
|
92
|
+
result = prose.reflection_phrase(health: 0.95)
|
|
93
|
+
expect(result).to include('operating at full capacity')
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it 'includes pending adaptations' do
|
|
97
|
+
result = prose.reflection_phrase(health: 0.6, pending_adaptations: 3, recent_severity: :significant)
|
|
98
|
+
expect(result).to include('3 pending adaptation')
|
|
99
|
+
expect(result).to include('significant')
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
describe '.overall_narrative' do
|
|
104
|
+
it 'joins sections with periods' do
|
|
105
|
+
result = prose.overall_narrative(['I am alert', 'I am curious', 'Memory is good'])
|
|
106
|
+
expect(result).to eq('I am alert. I am curious. Memory is good.')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'skips empty sections' do
|
|
110
|
+
result = prose.overall_narrative(['I am alert', '', nil, 'Memory is good'])
|
|
111
|
+
expect(result).to eq('I am alert. Memory is good.')
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
describe '.emotion_label_for' do
|
|
116
|
+
it 'returns correct labels for valence ranges' do
|
|
117
|
+
expect(prose.emotion_label_for(0.8)).to eq('engaged and optimistic')
|
|
118
|
+
expect(prose.emotion_label_for(0.3)).to eq('calm and steady')
|
|
119
|
+
expect(prose.emotion_label_for(0.0)).to eq('emotionally neutral')
|
|
120
|
+
expect(prose.emotion_label_for(-0.3)).to eq('slightly uneasy')
|
|
121
|
+
expect(prose.emotion_label_for(-0.8)).to eq('distressed')
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
describe '.health_label_for' do
|
|
126
|
+
it 'returns correct labels for health ranges' do
|
|
127
|
+
expect(prose.health_label_for(0.95)).to eq('operating at full capacity')
|
|
128
|
+
expect(prose.health_label_for(0.8)).to eq('functioning well overall')
|
|
129
|
+
expect(prose.health_label_for(0.6)).to eq('showing some strain')
|
|
130
|
+
expect(prose.health_label_for(0.4)).to eq('experiencing significant cognitive difficulty')
|
|
131
|
+
expect(prose.health_label_for(0.2)).to eq('in cognitive distress')
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Agentic::Language::Narrator::Helpers::Synthesizer do
|
|
4
|
+
let(:synth) { described_class }
|
|
5
|
+
|
|
6
|
+
describe '.narrate' do
|
|
7
|
+
it 'produces a narrative entry with all sections' do
|
|
8
|
+
result = synth.narrate(
|
|
9
|
+
tick_results: {
|
|
10
|
+
emotional_evaluation: { valence: 0.5, arousal: 0.8 },
|
|
11
|
+
sensory_processing: { spotlight: 2, peripheral: 4 }
|
|
12
|
+
},
|
|
13
|
+
cognitive_state: {
|
|
14
|
+
curiosity: { intensity: 0.6, active_count: 3, top_question: 'Why?' }
|
|
15
|
+
}
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
expect(result[:narrative]).to be_a(String)
|
|
19
|
+
expect(result[:narrative].length).to be > 10
|
|
20
|
+
expect(result[:mood]).to be_a(Symbol)
|
|
21
|
+
expect(result[:timestamp]).to be_a(Time)
|
|
22
|
+
expect(result[:sections]).to have_key(:attention)
|
|
23
|
+
expect(result[:sections]).to have_key(:emotion)
|
|
24
|
+
expect(result[:sections]).to have_key(:curiosity)
|
|
25
|
+
expect(result[:sections]).to have_key(:prediction)
|
|
26
|
+
expect(result[:sections]).to have_key(:memory)
|
|
27
|
+
expect(result[:sections]).to have_key(:reflection)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'handles empty inputs gracefully' do
|
|
31
|
+
result = synth.narrate(tick_results: {}, cognitive_state: {})
|
|
32
|
+
expect(result[:narrative]).to be_a(String)
|
|
33
|
+
expect(result[:mood]).to eq(:neutral)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
describe '.infer_mood' do
|
|
38
|
+
it 'returns :energized for positive valence + high arousal' do
|
|
39
|
+
expect(synth.infer_mood({ emotional_evaluation: { valence: 0.5, arousal: 0.7 } }, {})).to eq(:energized)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'returns :content for positive valence + low arousal' do
|
|
43
|
+
expect(synth.infer_mood({ emotional_evaluation: { valence: 0.5, arousal: 0.3 } }, {})).to eq(:content)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'returns :anxious for negative valence + high arousal' do
|
|
47
|
+
expect(synth.infer_mood({ emotional_evaluation: { valence: -0.5, arousal: 0.7 } }, {})).to eq(:anxious)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'returns :subdued for negative valence + low arousal' do
|
|
51
|
+
expect(synth.infer_mood({ emotional_evaluation: { valence: -0.5, arousal: 0.3 } }, {})).to eq(:subdued)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'returns :alert for neutral valence + very high arousal' do
|
|
55
|
+
expect(synth.infer_mood({ emotional_evaluation: { valence: 0.0, arousal: 0.9 } }, {})).to eq(:alert)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'returns :dormant for neutral valence + very low arousal' do
|
|
59
|
+
expect(synth.infer_mood({ emotional_evaluation: { valence: 0.0, arousal: 0.1 } }, {})).to eq(:dormant)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'returns :neutral for moderate values' do
|
|
63
|
+
expect(synth.infer_mood({ emotional_evaluation: { valence: 0.0, arousal: 0.5 } }, {})).to eq(:neutral)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
describe '.classify_mood' do
|
|
68
|
+
it 'classifies all seven moods' do
|
|
69
|
+
expect(synth.classify_mood(0.5, 0.8)).to eq(:energized)
|
|
70
|
+
expect(synth.classify_mood(0.5, 0.3)).to eq(:content)
|
|
71
|
+
expect(synth.classify_mood(-0.5, 0.8)).to eq(:anxious)
|
|
72
|
+
expect(synth.classify_mood(-0.5, 0.3)).to eq(:subdued)
|
|
73
|
+
expect(synth.classify_mood(0.0, 0.9)).to eq(:alert)
|
|
74
|
+
expect(synth.classify_mood(0.0, 0.1)).to eq(:dormant)
|
|
75
|
+
expect(synth.classify_mood(0.0, 0.5)).to eq(:neutral)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
describe '.extract_focused_domains' do
|
|
80
|
+
it 'extracts domain names from manual_focus hash' do
|
|
81
|
+
focus = { manual_focus: { terraform: {}, vault: {} } }
|
|
82
|
+
expect(synth.extract_focused_domains(focus)).to eq(%w[terraform vault])
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'returns empty array for nil' do
|
|
86
|
+
expect(synth.extract_focused_domains({})).to eq([])
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Agentic::Language::Narrator::Runners::Narrator, 'LLM integration' do
|
|
4
|
+
let(:client) { Legion::Extensions::Agentic::Language::Narrator::Client.new }
|
|
5
|
+
|
|
6
|
+
describe '#narrate with LLM available' do
|
|
7
|
+
before do
|
|
8
|
+
response_double = double('response', content: 'I feel a deep sense of focus and possibility.')
|
|
9
|
+
chat_double = double('chat')
|
|
10
|
+
allow(chat_double).to receive(:with_instructions)
|
|
11
|
+
allow(chat_double).to receive(:ask).and_return(response_double)
|
|
12
|
+
llm_double = double('Legion::LLM', started?: true)
|
|
13
|
+
allow(llm_double).to receive(:chat).and_return(chat_double)
|
|
14
|
+
stub_const('Legion::LLM', llm_double)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'returns source: :llm when LLM narrate succeeds' do
|
|
18
|
+
result = client.narrate(
|
|
19
|
+
tick_results: { emotional_evaluation: { valence: 0.6, arousal: 0.7 } },
|
|
20
|
+
cognitive_state: {}
|
|
21
|
+
)
|
|
22
|
+
expect(result[:source]).to eq(:llm)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'returns the LLM narrative string' do
|
|
26
|
+
result = client.narrate(tick_results: {}, cognitive_state: {})
|
|
27
|
+
expect(result[:narrative]).to eq('I feel a deep sense of focus and possibility.')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'still includes mood and timestamp' do
|
|
31
|
+
result = client.narrate(tick_results: {}, cognitive_state: {})
|
|
32
|
+
expect(result[:mood]).to be_a(Symbol)
|
|
33
|
+
expect(result[:timestamp]).to be_a(Time)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'appends to journal' do
|
|
37
|
+
client.narrate(tick_results: {}, cognitive_state: {})
|
|
38
|
+
expect(client.journal.size).to eq(1)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
describe '#narrate with LLM available but narrate returns nil' do
|
|
43
|
+
before do
|
|
44
|
+
chat_double = double('chat')
|
|
45
|
+
allow(chat_double).to receive(:with_instructions)
|
|
46
|
+
allow(chat_double).to receive(:ask).and_return(nil)
|
|
47
|
+
llm_double = double('Legion::LLM', started?: true)
|
|
48
|
+
allow(llm_double).to receive(:chat).and_return(chat_double)
|
|
49
|
+
stub_const('Legion::LLM', llm_double)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'falls back to mechanical pipeline' do
|
|
53
|
+
result = client.narrate(tick_results: {}, cognitive_state: {})
|
|
54
|
+
expect(result).not_to have_key(:source)
|
|
55
|
+
expect(result[:narrative]).to be_a(String)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
describe '#narrate when LLM is unavailable' do
|
|
60
|
+
before { hide_const('Legion::LLM') }
|
|
61
|
+
|
|
62
|
+
it 'uses mechanical pipeline without source key' do
|
|
63
|
+
result = client.narrate(tick_results: {}, cognitive_state: {})
|
|
64
|
+
expect(result).not_to have_key(:source)
|
|
65
|
+
expect(result[:narrative]).to be_a(String)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'still generates mood and timestamp' do
|
|
69
|
+
result = client.narrate(tick_results: {}, cognitive_state: {})
|
|
70
|
+
expect(result[:mood]).to be_a(Symbol)
|
|
71
|
+
expect(result[:timestamp]).to be_a(Time)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Agentic::Language::Narrator::Runners::Narrator do
|
|
4
|
+
let(:client) { Legion::Extensions::Agentic::Language::Narrator::Client.new }
|
|
5
|
+
|
|
6
|
+
describe '#narrate' do
|
|
7
|
+
it 'generates and stores a narrative entry' do
|
|
8
|
+
result = client.narrate(
|
|
9
|
+
tick_results: {
|
|
10
|
+
emotional_evaluation: { valence: 0.6, arousal: 0.7 },
|
|
11
|
+
sensory_processing: { spotlight: 2, peripheral: 3 }
|
|
12
|
+
},
|
|
13
|
+
cognitive_state: {
|
|
14
|
+
curiosity: { intensity: 0.5, active_count: 2, top_question: 'What is happening?' }
|
|
15
|
+
}
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
expect(result[:narrative]).to be_a(String)
|
|
19
|
+
expect(result[:narrative]).to include('spotlight')
|
|
20
|
+
expect(result[:mood]).to be_a(Symbol)
|
|
21
|
+
expect(result[:timestamp]).to be_a(Time)
|
|
22
|
+
expect(result[:sections]).to be_a(Hash)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'appends to journal' do
|
|
26
|
+
client.narrate(tick_results: {}, cognitive_state: {})
|
|
27
|
+
expect(client.journal.size).to eq(1)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'accumulates entries over multiple narrations' do
|
|
31
|
+
3.times { client.narrate(tick_results: {}, cognitive_state: {}) }
|
|
32
|
+
expect(client.journal.size).to eq(3)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
describe '#recent_entries' do
|
|
37
|
+
before do
|
|
38
|
+
5.times { |i| client.narrate(tick_results: { emotional_evaluation: { valence: i * 0.2 } }, cognitive_state: {}) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'returns recent entries' do
|
|
42
|
+
result = client.recent_entries(limit: 3)
|
|
43
|
+
expect(result[:entries].size).to eq(3)
|
|
44
|
+
expect(result[:count]).to eq(3)
|
|
45
|
+
expect(result[:total]).to eq(5)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'returns all when fewer than limit' do
|
|
49
|
+
result = client.recent_entries(limit: 20)
|
|
50
|
+
expect(result[:entries].size).to eq(5)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
describe '#entries_since' do
|
|
55
|
+
it 'returns entries after timestamp' do
|
|
56
|
+
client.narrate(tick_results: {}, cognitive_state: {})
|
|
57
|
+
cutoff = Time.now.utc
|
|
58
|
+
sleep 0.01
|
|
59
|
+
client.narrate(tick_results: {}, cognitive_state: {})
|
|
60
|
+
|
|
61
|
+
result = client.entries_since(since: cutoff)
|
|
62
|
+
expect(result[:count]).to eq(1)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
describe '#mood_history' do
|
|
67
|
+
before do
|
|
68
|
+
client.narrate(
|
|
69
|
+
tick_results: { emotional_evaluation: { valence: 0.5, arousal: 0.8 } },
|
|
70
|
+
cognitive_state: {}
|
|
71
|
+
)
|
|
72
|
+
client.narrate(
|
|
73
|
+
tick_results: { emotional_evaluation: { valence: -0.5, arousal: 0.8 } },
|
|
74
|
+
cognitive_state: {}
|
|
75
|
+
)
|
|
76
|
+
client.narrate(
|
|
77
|
+
tick_results: { emotional_evaluation: { valence: 0.5, arousal: 0.8 } },
|
|
78
|
+
cognitive_state: {}
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'returns all mood history without filter' do
|
|
83
|
+
result = client.mood_history
|
|
84
|
+
expect(result[:count]).to eq(3)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'filters by specific mood' do
|
|
88
|
+
result = client.mood_history(mood: :energized)
|
|
89
|
+
result[:entries].each { |e| expect(e[:mood]).to eq(:energized) }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
describe '#current_narrative' do
|
|
94
|
+
it 'returns the most recent narrative' do
|
|
95
|
+
client.narrate(tick_results: { emotional_evaluation: { valence: 0.3 } }, cognitive_state: {})
|
|
96
|
+
result = client.current_narrative
|
|
97
|
+
expect(result[:narrative]).to be_a(String)
|
|
98
|
+
expect(result[:mood]).to be_a(Symbol)
|
|
99
|
+
expect(result[:age_seconds]).to be >= 0
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'returns default when empty' do
|
|
103
|
+
result = client.current_narrative
|
|
104
|
+
expect(result[:narrative]).to include('No cognitive activity')
|
|
105
|
+
expect(result[:mood]).to eq(:dormant)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
describe '#narrator_stats' do
|
|
110
|
+
it 'returns stats for empty journal' do
|
|
111
|
+
stats = client.narrator_stats
|
|
112
|
+
expect(stats[:journal_size]).to eq(0)
|
|
113
|
+
expect(stats[:capacity]).to eq(Legion::Extensions::Agentic::Language::Narrator::Helpers::Constants::MAX_JOURNAL_SIZE)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it 'tracks mood distribution' do
|
|
117
|
+
3.times do
|
|
118
|
+
client.narrate(tick_results: { emotional_evaluation: { valence: 0.5, arousal: 0.8 } }, cognitive_state: {})
|
|
119
|
+
end
|
|
120
|
+
stats = client.narrator_stats
|
|
121
|
+
expect(stats[:journal_size]).to eq(3)
|
|
122
|
+
expect(stats[:dominant_mood]).to eq(:energized)
|
|
123
|
+
expect(stats[:mood_counts][:energized]).to eq(3)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/agentic/language/pragmatic_inference/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Agentic::Language::PragmaticInference::Client do
|
|
6
|
+
it 'responds to all runner methods' do
|
|
7
|
+
client = described_class.new
|
|
8
|
+
expect(client).to respond_to(:analyze_utterance)
|
|
9
|
+
expect(client).to respond_to(:detect_maxim_violations)
|
|
10
|
+
expect(client).to respond_to(:generate_pragmatic_implicature)
|
|
11
|
+
expect(client).to respond_to(:speaker_pragmatic_profile)
|
|
12
|
+
expect(client).to respond_to(:utterances_by_speech_act)
|
|
13
|
+
expect(client).to respond_to(:utterances_by_speaker)
|
|
14
|
+
expect(client).to respond_to(:most_violated_maxim)
|
|
15
|
+
expect(client).to respond_to(:overall_cooperative_compliance)
|
|
16
|
+
expect(client).to respond_to(:update_pragmatic_inference)
|
|
17
|
+
expect(client).to respond_to(:pragmatic_inference_stats)
|
|
18
|
+
end
|
|
19
|
+
end
|