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.
Files changed (28) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +11 -0
  3. data/LICENSE +21 -0
  4. data/README.md +120 -0
  5. data/lex-self-talk.gemspec +29 -0
  6. data/lib/legion/extensions/self_talk/actors/volume_decay.rb +41 -0
  7. data/lib/legion/extensions/self_talk/client.rb +26 -0
  8. data/lib/legion/extensions/self_talk/helpers/constants.rb +59 -0
  9. data/lib/legion/extensions/self_talk/helpers/dialogue.rb +110 -0
  10. data/lib/legion/extensions/self_talk/helpers/dialogue_turn.rb +39 -0
  11. data/lib/legion/extensions/self_talk/helpers/inner_voice.rb +73 -0
  12. data/lib/legion/extensions/self_talk/helpers/llm_enhancer.rb +131 -0
  13. data/lib/legion/extensions/self_talk/helpers/self_talk_engine.rb +156 -0
  14. data/lib/legion/extensions/self_talk/runners/self_talk.rb +168 -0
  15. data/lib/legion/extensions/self_talk/version.rb +9 -0
  16. data/lib/legion/extensions/self_talk.rb +18 -0
  17. data/spec/legion/extensions/self_talk/actors/volume_decay_spec.rb +46 -0
  18. data/spec/legion/extensions/self_talk/client_spec.rb +26 -0
  19. data/spec/legion/extensions/self_talk/helpers/constants_spec.rb +110 -0
  20. data/spec/legion/extensions/self_talk/helpers/dialogue_spec.rb +191 -0
  21. data/spec/legion/extensions/self_talk/helpers/dialogue_turn_spec.rb +78 -0
  22. data/spec/legion/extensions/self_talk/helpers/inner_voice_spec.rb +172 -0
  23. data/spec/legion/extensions/self_talk/helpers/llm_enhancer_spec.rb +206 -0
  24. data/spec/legion/extensions/self_talk/helpers/self_talk_engine_spec.rb +239 -0
  25. data/spec/legion/extensions/self_talk/runners/self_talk_llm_spec.rb +169 -0
  26. data/spec/legion/extensions/self_talk/runners/self_talk_spec.rb +196 -0
  27. data/spec/spec_helper.rb +20 -0
  28. 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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ gem 'rspec', '~> 3.13'
8
+ gem 'rubocop', '~> 1.75', require: false
9
+ gem 'rubocop-rspec', require: false
10
+
11
+ gem 'legion-gaia', path: '../../legion-gaia'
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