lex-self-talk 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 +11 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/lex-self-talk.gemspec +29 -0
- data/lib/legion/extensions/self_talk/actors/volume_decay.rb +41 -0
- data/lib/legion/extensions/self_talk/client.rb +26 -0
- data/lib/legion/extensions/self_talk/helpers/constants.rb +59 -0
- data/lib/legion/extensions/self_talk/helpers/dialogue.rb +110 -0
- data/lib/legion/extensions/self_talk/helpers/dialogue_turn.rb +39 -0
- data/lib/legion/extensions/self_talk/helpers/inner_voice.rb +73 -0
- data/lib/legion/extensions/self_talk/helpers/llm_enhancer.rb +131 -0
- data/lib/legion/extensions/self_talk/helpers/self_talk_engine.rb +156 -0
- data/lib/legion/extensions/self_talk/runners/self_talk.rb +168 -0
- data/lib/legion/extensions/self_talk/version.rb +9 -0
- data/lib/legion/extensions/self_talk.rb +18 -0
- data/spec/legion/extensions/self_talk/actors/volume_decay_spec.rb +46 -0
- data/spec/legion/extensions/self_talk/client_spec.rb +26 -0
- data/spec/legion/extensions/self_talk/helpers/constants_spec.rb +110 -0
- data/spec/legion/extensions/self_talk/helpers/dialogue_spec.rb +191 -0
- data/spec/legion/extensions/self_talk/helpers/dialogue_turn_spec.rb +78 -0
- data/spec/legion/extensions/self_talk/helpers/inner_voice_spec.rb +172 -0
- data/spec/legion/extensions/self_talk/helpers/llm_enhancer_spec.rb +206 -0
- data/spec/legion/extensions/self_talk/helpers/self_talk_engine_spec.rb +239 -0
- data/spec/legion/extensions/self_talk/runners/self_talk_llm_spec.rb +169 -0
- data/spec/legion/extensions/self_talk/runners/self_talk_spec.rb +196 -0
- data/spec/spec_helper.rb +20 -0
- metadata +87 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9212822e6ff0f3262f502e49f81f7584b82378f0bfdcdc09824691da640b4f8b
|
|
4
|
+
data.tar.gz: b075b53b46c16feba5a123017c2b7a5e03ef68795b5ea4878ac5d32e7eb83bbf
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1ffeeb6f1f76302085132ee82cf7f00e5405febf15e4666adf67e5a4c66335ce90c565484a4466543de71a4cf80f1c0e5fd6410eb427267b1817a07772c7e0f0
|
|
7
|
+
data.tar.gz: c8263855e81e95c7a6462735d0ab4c3561ad32c280fbd053fdb21d8679df11bc8d428312955d3cfa282cc710c72dccb9ee6ebdbe4cb0358ea4206afc8b9c659a
|
data/Gemfile
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Esity
|
|
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,120 @@
|
|
|
1
|
+
# lex-self-talk
|
|
2
|
+
|
|
3
|
+
Inner dialogue system for brain-modeled agentic AI. Multiple cognitive voices (critic, encourager, analyst, devil's advocate, pragmatist, visionary, caretaker, rebel) engage in structured internal conversations to reason through decisions.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`lex-self-talk` implements Vygotsky's inner speech theory and the Internal Family Systems model as a structured deliberation engine. Each voice has a volume (0..1), a type, and an optional bias direction. Voices contribute turns to dialogues, taking positions (support, oppose, question, clarify) with a strength score. The engine tracks consensus across voices and can conclude, deadlock, or abandon dialogues.
|
|
8
|
+
|
|
9
|
+
## Voice Types
|
|
10
|
+
|
|
11
|
+
| Type | Role |
|
|
12
|
+
|------|------|
|
|
13
|
+
| `critic` | Identifies flaws and risks |
|
|
14
|
+
| `encourager` | Affirms and motivates |
|
|
15
|
+
| `analyst` | Evaluates evidence and logic |
|
|
16
|
+
| `devils_advocate` | Challenges assumptions |
|
|
17
|
+
| `pragmatist` | Focuses on feasibility |
|
|
18
|
+
| `visionary` | Explores possibilities |
|
|
19
|
+
| `caretaker` | Considers impact on others |
|
|
20
|
+
| `rebel` | Questions constraints |
|
|
21
|
+
|
|
22
|
+
## Key Constants
|
|
23
|
+
|
|
24
|
+
| Constant | Value | Description |
|
|
25
|
+
|----------|-------|-------------|
|
|
26
|
+
| `MAX_VOICES` | 10 | Maximum registered voices |
|
|
27
|
+
| `MAX_DIALOGUES` | 200 | Maximum stored dialogues (oldest pruned) |
|
|
28
|
+
| `MAX_TURNS_PER_DIALOGUE` | 50 | Turn limit per dialogue |
|
|
29
|
+
| `DEFAULT_VOLUME` | 0.5 | Starting voice volume |
|
|
30
|
+
| `VOLUME_BOOST` | 0.1 | Default amplify amount |
|
|
31
|
+
| `VOLUME_DECAY` | 0.05 | Default dampen amount |
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
Add to your Gemfile:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
gem 'lex-self-talk'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
require 'legion/extensions/self_talk'
|
|
45
|
+
|
|
46
|
+
client = Legion::Extensions::SelfTalk::Client.new
|
|
47
|
+
|
|
48
|
+
# Register voices
|
|
49
|
+
critic_id = client.register_voice(name: 'Inner Critic', voice_type: :critic)[:voice][:id]
|
|
50
|
+
analyst_id = client.register_voice(name: 'Analyst', voice_type: :analyst)[:voice][:id]
|
|
51
|
+
|
|
52
|
+
# Start a dialogue
|
|
53
|
+
dialogue_id = client.start_dialogue(topic: 'Should we refactor the auth module?')[:dialogue][:id]
|
|
54
|
+
|
|
55
|
+
# Voices contribute turns
|
|
56
|
+
client.add_turn(dialogue_id: dialogue_id, voice_id: critic_id,
|
|
57
|
+
content: 'The current implementation has hidden coupling', position: :oppose, strength: 0.8)
|
|
58
|
+
client.add_turn(dialogue_id: dialogue_id, voice_id: analyst_id,
|
|
59
|
+
content: 'Refactoring now reduces long-term maintenance cost', position: :support, strength: 0.7)
|
|
60
|
+
|
|
61
|
+
# Get a report
|
|
62
|
+
report = client.dialogue_report(dialogue_id: dialogue_id)
|
|
63
|
+
# => { found: true, dialogue: {...}, voice_positions: { "Inner Critic" => 0.8, "Analyst" => 0.7 } }
|
|
64
|
+
|
|
65
|
+
# Conclude the dialogue
|
|
66
|
+
client.conclude_dialogue(dialogue_id: dialogue_id, summary: 'Refactor in next sprint')
|
|
67
|
+
|
|
68
|
+
# Check status
|
|
69
|
+
client.self_talk_status
|
|
70
|
+
# => { voice_count: 2, dialogue_count: 1, active_dialogue_count: 0, ... }
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Dominance Labels
|
|
74
|
+
|
|
75
|
+
| Volume Range | Label |
|
|
76
|
+
|-------------|-------|
|
|
77
|
+
| 0.8 - 1.0 | `:commanding` |
|
|
78
|
+
| 0.6 - 0.8 | `:assertive` |
|
|
79
|
+
| 0.4 - 0.6 | `:balanced` |
|
|
80
|
+
| 0.2 - 0.4 | `:quiet` |
|
|
81
|
+
| 0.0 - 0.2 | `:silent` |
|
|
82
|
+
|
|
83
|
+
## Consensus Labels
|
|
84
|
+
|
|
85
|
+
| Score Range | Label |
|
|
86
|
+
|------------|-------|
|
|
87
|
+
| 0.8 - 1.0 | `:unanimous` |
|
|
88
|
+
| 0.6 - 0.8 | `:agreement` |
|
|
89
|
+
| 0.4 - 0.6 | `:mixed` |
|
|
90
|
+
| 0.2 - 0.4 | `:disagreement` |
|
|
91
|
+
| 0.0 - 0.2 | `:conflict` |
|
|
92
|
+
|
|
93
|
+
## Actors
|
|
94
|
+
|
|
95
|
+
| Actor | Interval | Description |
|
|
96
|
+
|-------|----------|-------------|
|
|
97
|
+
| `VolumeDecay` | Every 300s | Calls `dampen!` on every active voice by `VOLUME_DECAY` (0.05), preventing voices from holding elevated volumes indefinitely. Voices that are never amplified will trend toward silence over time. Muted voices are skipped. |
|
|
98
|
+
|
|
99
|
+
## LLM Enhancement
|
|
100
|
+
|
|
101
|
+
`Helpers::LlmEnhancer` provides optional LLM-powered voice generation and dialogue summarization when `legion-llm` is loaded and `Legion::LLM.started?` returns true. All methods rescue `StandardError` and return `nil` — callers always fall back to mechanical processing.
|
|
102
|
+
|
|
103
|
+
| Method | Description |
|
|
104
|
+
|--------|-------------|
|
|
105
|
+
| `generate_turn(voice_type:, topic:, prior_turns:)` | Generates a realistic in-character 1-3 sentence response for the given voice type, returning a `position` (`:support`, `:oppose`, `:question`, or `:clarify`) and `content` |
|
|
106
|
+
| `summarize_dialogue(topic:, turns:)` | Synthesizes all dialogue turns into a 2-3 sentence conclusion with a `recommendation` (`:support`, `:oppose`, or `:abstain`) and a `summary` |
|
|
107
|
+
|
|
108
|
+
Mechanical fallback: `generate_voice_turn` uses the stub string `"[voice_type perspective on topic]"` with position `:clarify` when LLM is unavailable or returns `nil`. `conclude_dialogue` uses `"Dialogue concluded"` as the summary. The `source:` field in `generate_voice_turn` results is `:llm` or `:mechanical` to indicate which path was taken.
|
|
109
|
+
|
|
110
|
+
## Development
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
bundle install
|
|
114
|
+
bundle exec rspec
|
|
115
|
+
bundle exec rubocop
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/self_talk/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-self-talk'
|
|
7
|
+
spec.version = Legion::Extensions::SelfTalk::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX SelfTalk'
|
|
12
|
+
spec.description = 'Inner dialogue system for brain-modeled agentic AI — structured internal conversation via multiple cognitive voices'
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/lex-self-talk'
|
|
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-self-talk'
|
|
19
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-self-talk'
|
|
20
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-self-talk'
|
|
21
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-self-talk/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-self-talk.gemspec Gemfile LICENSE README.md]
|
|
26
|
+
end
|
|
27
|
+
spec.require_paths = ['lib']
|
|
28
|
+
spec.add_development_dependency 'legion-gaia'
|
|
29
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/actors/every'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module SelfTalk
|
|
8
|
+
module Actor
|
|
9
|
+
class VolumeDecay < Legion::Extensions::Actors::Every
|
|
10
|
+
def runner_class
|
|
11
|
+
Legion::Extensions::SelfTalk::Runners::SelfTalk
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def runner_function
|
|
15
|
+
'decay_voices'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def time
|
|
19
|
+
300
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run_now?
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def use_runner?
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def check_subtask?
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def generate_task?
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/self_talk/helpers/constants'
|
|
4
|
+
require 'legion/extensions/self_talk/helpers/inner_voice'
|
|
5
|
+
require 'legion/extensions/self_talk/helpers/dialogue_turn'
|
|
6
|
+
require 'legion/extensions/self_talk/helpers/dialogue'
|
|
7
|
+
require 'legion/extensions/self_talk/helpers/self_talk_engine'
|
|
8
|
+
require 'legion/extensions/self_talk/runners/self_talk'
|
|
9
|
+
|
|
10
|
+
module Legion
|
|
11
|
+
module Extensions
|
|
12
|
+
module SelfTalk
|
|
13
|
+
class Client
|
|
14
|
+
include Runners::SelfTalk
|
|
15
|
+
|
|
16
|
+
def initialize(**)
|
|
17
|
+
@engine = Helpers::SelfTalkEngine.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader :engine
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module SelfTalk
|
|
6
|
+
module Helpers
|
|
7
|
+
module Constants
|
|
8
|
+
MAX_VOICES = 10
|
|
9
|
+
MAX_DIALOGUES = 200
|
|
10
|
+
MAX_TURNS_PER_DIALOGUE = 50
|
|
11
|
+
DEFAULT_VOLUME = 0.5
|
|
12
|
+
VOLUME_BOOST = 0.1
|
|
13
|
+
VOLUME_DECAY = 0.05
|
|
14
|
+
|
|
15
|
+
VOICE_TYPES = %i[
|
|
16
|
+
critic
|
|
17
|
+
encourager
|
|
18
|
+
analyst
|
|
19
|
+
devils_advocate
|
|
20
|
+
pragmatist
|
|
21
|
+
visionary
|
|
22
|
+
caretaker
|
|
23
|
+
rebel
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
DIALOGUE_STATUSES = %i[open concluded deadlocked abandoned].freeze
|
|
27
|
+
|
|
28
|
+
DOMINANCE_LABELS = {
|
|
29
|
+
(0.8..1.0) => :commanding,
|
|
30
|
+
(0.6...0.8) => :assertive,
|
|
31
|
+
(0.4...0.6) => :balanced,
|
|
32
|
+
(0.2...0.4) => :quiet,
|
|
33
|
+
(0.0...0.2) => :silent
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
CONSENSUS_LABELS = {
|
|
37
|
+
(0.8..1.0) => :unanimous,
|
|
38
|
+
(0.6...0.8) => :agreement,
|
|
39
|
+
(0.4...0.6) => :mixed,
|
|
40
|
+
(0.2...0.4) => :disagreement,
|
|
41
|
+
(0.0...0.2) => :conflict
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
def dominance_label(volume)
|
|
47
|
+
DOMINANCE_LABELS.each { |range, label| return label if range.cover?(volume) }
|
|
48
|
+
:silent
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def consensus_label(score)
|
|
52
|
+
CONSENSUS_LABELS.each { |range, label| return label if range.cover?(score) }
|
|
53
|
+
:conflict
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module SelfTalk
|
|
8
|
+
module Helpers
|
|
9
|
+
class Dialogue
|
|
10
|
+
attr_reader :id, :topic, :turns, :status, :conclusion, :created_at
|
|
11
|
+
|
|
12
|
+
def initialize(topic:)
|
|
13
|
+
@id = SecureRandom.uuid
|
|
14
|
+
@topic = topic
|
|
15
|
+
@turns = []
|
|
16
|
+
@status = :open
|
|
17
|
+
@conclusion = nil
|
|
18
|
+
@created_at = Time.now.utc
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def add_turn!(voice_id:, content:, position: :clarify, strength: 0.5)
|
|
22
|
+
return false if @turns.size >= Constants::MAX_TURNS_PER_DIALOGUE
|
|
23
|
+
return false unless active?
|
|
24
|
+
|
|
25
|
+
turn = DialogueTurn.new(
|
|
26
|
+
dialogue_id: @id,
|
|
27
|
+
voice_id: voice_id,
|
|
28
|
+
content: content,
|
|
29
|
+
position: position,
|
|
30
|
+
strength: strength
|
|
31
|
+
)
|
|
32
|
+
@turns << turn
|
|
33
|
+
turn
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def conclude!(summary)
|
|
37
|
+
return false unless active?
|
|
38
|
+
|
|
39
|
+
@conclusion = summary
|
|
40
|
+
@status = :concluded
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def deadlock!
|
|
45
|
+
return false unless active?
|
|
46
|
+
|
|
47
|
+
@status = :deadlocked
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def abandon!
|
|
52
|
+
return false unless active?
|
|
53
|
+
|
|
54
|
+
@status = :abandoned
|
|
55
|
+
true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def active?
|
|
59
|
+
@status == :open
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def concluded?
|
|
63
|
+
@status == :concluded
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def turn_count
|
|
67
|
+
@turns.size
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def voice_positions
|
|
71
|
+
grouped = @turns.group_by(&:voice_id)
|
|
72
|
+
grouped.transform_values do |voice_turns|
|
|
73
|
+
voice_turns.sum(&:strength) / voice_turns.size.to_f
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def consensus_score
|
|
78
|
+
return 1.0 if @turns.empty?
|
|
79
|
+
|
|
80
|
+
support_strength = @turns.select { |t| t.position == :support }.sum(&:strength)
|
|
81
|
+
oppose_strength = @turns.select { |t| t.position == :oppose }.sum(&:strength)
|
|
82
|
+
total = support_strength + oppose_strength
|
|
83
|
+
return 0.5 if total.zero?
|
|
84
|
+
|
|
85
|
+
stronger = [support_strength, oppose_strength].max
|
|
86
|
+
stronger / total
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def consensus_label
|
|
90
|
+
Constants.consensus_label(consensus_score)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def to_h
|
|
94
|
+
{
|
|
95
|
+
id: @id,
|
|
96
|
+
topic: @topic,
|
|
97
|
+
status: @status,
|
|
98
|
+
conclusion: @conclusion,
|
|
99
|
+
turn_count: turn_count,
|
|
100
|
+
consensus_score: consensus_score.round(10),
|
|
101
|
+
consensus_label: consensus_label,
|
|
102
|
+
created_at: @created_at,
|
|
103
|
+
turns: @turns.map(&:to_h)
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module SelfTalk
|
|
8
|
+
module Helpers
|
|
9
|
+
class DialogueTurn
|
|
10
|
+
POSITIONS = %i[support oppose question clarify].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :id, :dialogue_id, :voice_id, :content, :position, :strength, :created_at
|
|
13
|
+
|
|
14
|
+
def initialize(dialogue_id:, voice_id:, content:, position: :clarify, strength: 0.5)
|
|
15
|
+
@id = SecureRandom.uuid
|
|
16
|
+
@dialogue_id = dialogue_id
|
|
17
|
+
@voice_id = voice_id
|
|
18
|
+
@content = content
|
|
19
|
+
@position = POSITIONS.include?(position) ? position : :clarify
|
|
20
|
+
@strength = strength.clamp(0.0, 1.0)
|
|
21
|
+
@created_at = Time.now.utc
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
id: @id,
|
|
27
|
+
dialogue_id: @dialogue_id,
|
|
28
|
+
voice_id: @voice_id,
|
|
29
|
+
content: @content,
|
|
30
|
+
position: @position,
|
|
31
|
+
strength: @strength.round(10),
|
|
32
|
+
created_at: @created_at
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module SelfTalk
|
|
8
|
+
module Helpers
|
|
9
|
+
class InnerVoice
|
|
10
|
+
attr_reader :id, :name, :voice_type, :bias_direction, :created_at
|
|
11
|
+
attr_accessor :volume, :active
|
|
12
|
+
|
|
13
|
+
def initialize(name:, voice_type:, volume: Constants::DEFAULT_VOLUME, bias_direction: nil, active: true)
|
|
14
|
+
@id = SecureRandom.uuid
|
|
15
|
+
@name = name
|
|
16
|
+
@voice_type = voice_type
|
|
17
|
+
@volume = volume.clamp(0.0, 1.0)
|
|
18
|
+
@bias_direction = bias_direction
|
|
19
|
+
@active = active
|
|
20
|
+
@created_at = Time.now.utc
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def amplify!(amount = Constants::VOLUME_BOOST)
|
|
24
|
+
@volume = (@volume + amount).clamp(0.0, 1.0)
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def dampen!(amount = Constants::VOLUME_DECAY)
|
|
29
|
+
@volume = (@volume - amount).clamp(0.0, 1.0)
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def mute!
|
|
34
|
+
@active = false
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def unmute!
|
|
39
|
+
@active = true
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def dominant?
|
|
44
|
+
@volume >= 0.7
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def quiet?
|
|
48
|
+
@volume <= 0.3
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def volume_label
|
|
52
|
+
Constants.dominance_label(@volume)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def to_h
|
|
56
|
+
{
|
|
57
|
+
id: @id,
|
|
58
|
+
name: @name,
|
|
59
|
+
voice_type: @voice_type,
|
|
60
|
+
volume: @volume.round(10),
|
|
61
|
+
volume_label: volume_label,
|
|
62
|
+
bias_direction: @bias_direction,
|
|
63
|
+
active: @active,
|
|
64
|
+
dominant: dominant?,
|
|
65
|
+
quiet: quiet?,
|
|
66
|
+
created_at: @created_at
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module SelfTalk
|
|
6
|
+
module Helpers
|
|
7
|
+
module LlmEnhancer
|
|
8
|
+
SYSTEM_PROMPT = <<~PROMPT
|
|
9
|
+
You are an internal cognitive voice in an autonomous AI agent's inner dialogue system.
|
|
10
|
+
When asked to speak as a specific voice type, adopt that perspective fully:
|
|
11
|
+
- critic: skeptical, identifies flaws and risks
|
|
12
|
+
- encourager: supportive, finds reasons for optimism
|
|
13
|
+
- analyst: data-driven, logical, weighs evidence
|
|
14
|
+
- devils_advocate: challenges assumptions, plays the contrarian
|
|
15
|
+
- pragmatist: focuses on what's actionable and achievable
|
|
16
|
+
- visionary: thinks big picture, sees future possibilities
|
|
17
|
+
- caretaker: concerned about wellbeing and sustainability
|
|
18
|
+
- rebel: questions authority, pushes for unconventional approaches
|
|
19
|
+
Be concise (1-3 sentences). Stay in character. Take a clear position.
|
|
20
|
+
PROMPT
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def available?
|
|
25
|
+
!!(defined?(Legion::LLM) && Legion::LLM.respond_to?(:started?) && Legion::LLM.started?)
|
|
26
|
+
rescue StandardError
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def generate_turn(voice_type:, topic:, prior_turns:)
|
|
31
|
+
prompt = build_generate_turn_prompt(voice_type: voice_type, topic: topic, prior_turns: prior_turns)
|
|
32
|
+
response = llm_ask(prompt)
|
|
33
|
+
parse_generate_turn_response(response)
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
Legion::Logging.warn "[self_talk:llm] generate_turn failed: #{e.message}"
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def summarize_dialogue(topic:, turns:)
|
|
40
|
+
prompt = build_summarize_dialogue_prompt(topic: topic, turns: turns)
|
|
41
|
+
response = llm_ask(prompt)
|
|
42
|
+
parse_summarize_dialogue_response(response)
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
Legion::Logging.warn "[self_talk:llm] summarize_dialogue failed: #{e.message}"
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# --- Private helpers ---
|
|
49
|
+
|
|
50
|
+
def llm_ask(prompt)
|
|
51
|
+
chat = Legion::LLM.chat
|
|
52
|
+
chat.with_instructions(SYSTEM_PROMPT)
|
|
53
|
+
chat.ask(prompt)
|
|
54
|
+
end
|
|
55
|
+
private_class_method :llm_ask
|
|
56
|
+
|
|
57
|
+
def build_generate_turn_prompt(voice_type:, topic:, prior_turns:)
|
|
58
|
+
prior_lines = prior_turns.map do |t|
|
|
59
|
+
"[#{t[:voice_name] || t[:voice_id]}] (#{t[:position]}): #{t[:content]}"
|
|
60
|
+
end.join("\n")
|
|
61
|
+
|
|
62
|
+
prior_section = prior_lines.empty? ? '(no prior turns)' : prior_lines
|
|
63
|
+
|
|
64
|
+
<<~PROMPT
|
|
65
|
+
Topic: #{topic}
|
|
66
|
+
You are speaking as: #{voice_type}
|
|
67
|
+
|
|
68
|
+
Previous turns in this dialogue:
|
|
69
|
+
#{prior_section}
|
|
70
|
+
|
|
71
|
+
Respond in character. Format EXACTLY as:
|
|
72
|
+
POSITION: support | oppose | question | clarify
|
|
73
|
+
CONTENT: <your 1-3 sentence response>
|
|
74
|
+
PROMPT
|
|
75
|
+
end
|
|
76
|
+
private_class_method :build_generate_turn_prompt
|
|
77
|
+
|
|
78
|
+
def parse_generate_turn_response(response)
|
|
79
|
+
return nil unless response&.content
|
|
80
|
+
|
|
81
|
+
text = response.content
|
|
82
|
+
position_match = text.match(/POSITION:\s*(support|oppose|question|clarify)/i)
|
|
83
|
+
content_match = text.match(/CONTENT:\s*(.+)/im)
|
|
84
|
+
|
|
85
|
+
return nil unless position_match && content_match
|
|
86
|
+
|
|
87
|
+
position = position_match.captures.first.strip.downcase.to_sym
|
|
88
|
+
content = content_match.captures.first.strip
|
|
89
|
+
|
|
90
|
+
{ content: content, position: position }
|
|
91
|
+
end
|
|
92
|
+
private_class_method :parse_generate_turn_response
|
|
93
|
+
|
|
94
|
+
def build_summarize_dialogue_prompt(topic:, turns:)
|
|
95
|
+
turn_lines = turns.map do |t|
|
|
96
|
+
"[#{t[:voice_name] || t[:voice_id]}] (#{t[:position]}): #{t[:content]}"
|
|
97
|
+
end.join("\n")
|
|
98
|
+
|
|
99
|
+
<<~PROMPT
|
|
100
|
+
Topic: #{topic}
|
|
101
|
+
|
|
102
|
+
Dialogue turns:
|
|
103
|
+
#{turn_lines}
|
|
104
|
+
|
|
105
|
+
Synthesize this dialogue into a conclusion. Format EXACTLY as:
|
|
106
|
+
RECOMMENDATION: support | oppose | abstain
|
|
107
|
+
SUMMARY: <2-3 sentence synthesis of the key points and conclusion>
|
|
108
|
+
PROMPT
|
|
109
|
+
end
|
|
110
|
+
private_class_method :build_summarize_dialogue_prompt
|
|
111
|
+
|
|
112
|
+
def parse_summarize_dialogue_response(response)
|
|
113
|
+
return nil unless response&.content
|
|
114
|
+
|
|
115
|
+
text = response.content
|
|
116
|
+
recommendation_match = text.match(/RECOMMENDATION:\s*(support|oppose|abstain)/i)
|
|
117
|
+
summary_match = text.match(/SUMMARY:\s*(.+)/im)
|
|
118
|
+
|
|
119
|
+
return nil unless recommendation_match && summary_match
|
|
120
|
+
|
|
121
|
+
recommendation = recommendation_match.captures.first.strip.downcase.to_sym
|
|
122
|
+
summary = summary_match.captures.first.strip
|
|
123
|
+
|
|
124
|
+
{ summary: summary, recommendation: recommendation }
|
|
125
|
+
end
|
|
126
|
+
private_class_method :parse_summarize_dialogue_response
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|