lex-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/Gemfile +15 -0
- data/LICENSE +21 -0
- data/README.md +74 -0
- data/lex-language.gemspec +29 -0
- data/lib/legion/extensions/language/client.rb +22 -0
- data/lib/legion/extensions/language/helpers/constants.rb +39 -0
- data/lib/legion/extensions/language/helpers/lexicon.rb +59 -0
- data/lib/legion/extensions/language/helpers/summarizer.rb +163 -0
- data/lib/legion/extensions/language/runners/language.rb +130 -0
- data/lib/legion/extensions/language/version.rb +9 -0
- data/lib/legion/extensions/language.rb +16 -0
- data/spec/legion/extensions/language/client_spec.rb +20 -0
- data/spec/legion/extensions/language/helpers/constants_spec.rb +31 -0
- data/spec/legion/extensions/language/helpers/lexicon_spec.rb +116 -0
- data/spec/legion/extensions/language/helpers/summarizer_spec.rb +224 -0
- data/spec/legion/extensions/language/runners/language_spec.rb +169 -0
- data/spec/spec_helper.rb +20 -0
- metadata +77 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 38927cf1b9e51ee052d7d3760a10e7030ba57e08658776dc955669fb7efcbc87
|
|
4
|
+
data.tar.gz: 615a29cdd55c75591edc3b3ce62ee3cb9bacefbed3a2c064ddbcdb5413e75914
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: de1666f187f15312b478e0df40edfdbe916810abfb75bd5c1af8e96c6512910a1ca70ee47dfc1a128e52cccffcc8c9e1ad4ac4024c3fc6c73e7db13816e32312
|
|
7
|
+
data.tar.gz: 14182d7c1c7d1a615bf88cb1394e2a018c18d0f9b2cfa8e176f506c04798a3ac634f74668f0a4391cf133f4b81f51f6a948037a4067cbb7660d82977191603c5
|
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,74 @@
|
|
|
1
|
+
# lex-language
|
|
2
|
+
|
|
3
|
+
Symbolic-to-linguistic grounding for LegionIO agents. Part of the LegionIO cognitive architecture extension ecosystem (LEX).
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
`lex-language` bridges raw memory traces and meaningful knowledge expression. Given a set of memory traces for a domain, it aggregates them by type priority, extracts key facts at configurable depth, assesses knowledge level, and returns a structured summary or prose narrative. A TTL-based lexicon cache avoids reprocessing unchanged trace sets. Also determines whether the agent's knowledge is sufficient to answer specific queries.
|
|
8
|
+
|
|
9
|
+
Key capabilities:
|
|
10
|
+
|
|
11
|
+
- **Domain summarization**: brief (3 facts), standard (7 facts), detailed (all traces above minimum strength)
|
|
12
|
+
- **Knowledge assessment**: rich (>=10 traces), moderate (>=5), sparse (<5)
|
|
13
|
+
- **Wonder resolution**: checks if a query domain has enough traces to answer
|
|
14
|
+
- **Lexicon cache**: 5-minute TTL per domain to avoid repeated processing
|
|
15
|
+
- **Knowledge map**: snapshot of all cached domain summaries
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
Add to your Gemfile:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
gem 'lex-language'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or install directly:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
gem install lex-language
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
require 'legion/extensions/language'
|
|
35
|
+
|
|
36
|
+
# Fetch traces from lex-memory first, then summarize
|
|
37
|
+
client = Legion::Extensions::Language::Client.new
|
|
38
|
+
|
|
39
|
+
summary = client.summarize(domain: :networking, traces: traces, depth: :standard)
|
|
40
|
+
# => { domain: :networking, trace_count: 12, knowledge_level: :rich,
|
|
41
|
+
# key_facts: ['...', '...', ...], strength_stats: { min: 0.3, max: 0.9, avg: 0.65 } }
|
|
42
|
+
|
|
43
|
+
# Assess knowledge in a domain
|
|
44
|
+
knowledge = client.what_do_i_know(domain: :networking, traces: traces)
|
|
45
|
+
|
|
46
|
+
# Check if a wonder can be answered
|
|
47
|
+
result = client.can_answer_wonder?(wonder: { domain: :networking }, traces: traces)
|
|
48
|
+
# => { can_answer: true, trace_count: 12, threshold: 3 }
|
|
49
|
+
|
|
50
|
+
# View all cached knowledge
|
|
51
|
+
map = client.knowledge_map
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Runner Methods
|
|
55
|
+
|
|
56
|
+
| Method | Description |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `summarize` | Summarize domain traces at specified depth (brief/standard/detailed) |
|
|
59
|
+
| `what_do_i_know` | Detailed knowledge assessment for a domain |
|
|
60
|
+
| `can_answer_wonder?` | Check if trace count meets the resolution threshold |
|
|
61
|
+
| `knowledge_map` | All cached domain summaries |
|
|
62
|
+
| `language_stats` | Cached domain count, total traces processed |
|
|
63
|
+
|
|
64
|
+
## Development
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
bundle install
|
|
68
|
+
bundle exec rspec
|
|
69
|
+
bundle exec rubocop
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/language/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-language'
|
|
7
|
+
spec.version = Legion::Extensions::Language::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX Language'
|
|
12
|
+
spec.description = 'Symbolic-to-linguistic grounding layer for brain-modeled agentic AI'
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/lex-language'
|
|
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-language'
|
|
19
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-language'
|
|
20
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-language'
|
|
21
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-language/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-language.gemspec Gemfile LICENSE README.md]
|
|
26
|
+
end
|
|
27
|
+
spec.require_paths = ['lib']
|
|
28
|
+
spec.add_development_dependency 'legion-gaia'
|
|
29
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/language/helpers/constants'
|
|
4
|
+
require 'legion/extensions/language/helpers/summarizer'
|
|
5
|
+
require 'legion/extensions/language/helpers/lexicon'
|
|
6
|
+
require 'legion/extensions/language/runners/language'
|
|
7
|
+
|
|
8
|
+
module Legion
|
|
9
|
+
module Extensions
|
|
10
|
+
module Language
|
|
11
|
+
class Client
|
|
12
|
+
include Runners::Language
|
|
13
|
+
|
|
14
|
+
attr_reader :lexicon
|
|
15
|
+
|
|
16
|
+
def initialize(lexicon: nil, **)
|
|
17
|
+
@lexicon = lexicon || Helpers::Lexicon.new
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Language
|
|
6
|
+
module Helpers
|
|
7
|
+
module Constants
|
|
8
|
+
# Maximum traces to consider in a single summarization
|
|
9
|
+
MAX_TRACES_PER_SUMMARY = 50
|
|
10
|
+
|
|
11
|
+
# Minimum strength threshold for including a trace in a summary
|
|
12
|
+
MIN_SUMMARY_STRENGTH = 0.1
|
|
13
|
+
|
|
14
|
+
# Summary depth levels
|
|
15
|
+
DEPTHS = %i[brief standard detailed].freeze
|
|
16
|
+
|
|
17
|
+
# Trace type priority for summary ordering
|
|
18
|
+
TYPE_PRIORITY = {
|
|
19
|
+
firmware: 0,
|
|
20
|
+
identity: 1,
|
|
21
|
+
procedural: 2,
|
|
22
|
+
semantic: 3,
|
|
23
|
+
trust: 4,
|
|
24
|
+
episodic: 5,
|
|
25
|
+
sensory: 6
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# Knowledge quality thresholds
|
|
29
|
+
KNOWLEDGE_RICH = 10 # traces for "rich knowledge"
|
|
30
|
+
KNOWLEDGE_MODERATE = 5 # traces for "moderate knowledge"
|
|
31
|
+
KNOWLEDGE_SPARSE = 1 # traces for "sparse knowledge"
|
|
32
|
+
|
|
33
|
+
# Wonder resolution: minimum traces to consider a domain "known"
|
|
34
|
+
RESOLUTION_THRESHOLD = 3
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Language
|
|
6
|
+
module Helpers
|
|
7
|
+
class Lexicon
|
|
8
|
+
attr_reader :domain_summaries
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@domain_summaries = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def store_summary(domain, summary)
|
|
15
|
+
domain = domain.to_sym
|
|
16
|
+
@domain_summaries[domain] = summary.merge(cached_at: Time.now.utc)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def get_summary(domain)
|
|
20
|
+
@domain_summaries[domain.to_sym]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def known_domains
|
|
24
|
+
@domain_summaries.keys
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def knowledge_map
|
|
28
|
+
@domain_summaries.transform_values do |summary|
|
|
29
|
+
{
|
|
30
|
+
knowledge_level: summary[:knowledge_level],
|
|
31
|
+
trace_count: summary[:trace_count],
|
|
32
|
+
cached_at: summary[:cached_at]
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def stale?(domain, max_age: 300)
|
|
38
|
+
summary = get_summary(domain)
|
|
39
|
+
return true unless summary
|
|
40
|
+
|
|
41
|
+
(Time.now.utc - summary[:cached_at]) > max_age
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def clear(domain = nil)
|
|
45
|
+
if domain
|
|
46
|
+
@domain_summaries.delete(domain.to_sym)
|
|
47
|
+
else
|
|
48
|
+
@domain_summaries.clear
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def size
|
|
53
|
+
@domain_summaries.size
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Language
|
|
6
|
+
module Helpers
|
|
7
|
+
module Summarizer
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def summarize_domain(traces, domain:, depth: :standard)
|
|
11
|
+
return empty_summary(domain) if traces.empty?
|
|
12
|
+
|
|
13
|
+
grouped = group_by_type(traces)
|
|
14
|
+
knowledge_level = classify_knowledge(traces.size)
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
domain: domain,
|
|
18
|
+
knowledge_level: knowledge_level,
|
|
19
|
+
trace_count: traces.size,
|
|
20
|
+
type_breakdown: type_breakdown(grouped),
|
|
21
|
+
key_facts: extract_key_facts(grouped, depth: depth),
|
|
22
|
+
strength_stats: strength_stats(traces),
|
|
23
|
+
emotional_tone: emotional_tone(traces),
|
|
24
|
+
freshness: freshness_assessment(traces)
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def group_by_type(traces)
|
|
29
|
+
grouped = traces.group_by { |t| t[:trace_type] || :unknown }
|
|
30
|
+
grouped.sort_by { |type, _| Constants::TYPE_PRIORITY[type] || 99 }.to_h
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def type_breakdown(grouped)
|
|
34
|
+
grouped.transform_values(&:size)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def extract_key_facts(grouped, depth: :standard)
|
|
38
|
+
limit = case depth
|
|
39
|
+
when :brief then 3
|
|
40
|
+
when :detailed then 15
|
|
41
|
+
else 7
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
facts = []
|
|
45
|
+
grouped.each do |type, traces|
|
|
46
|
+
sorted = traces.sort_by { |t| -(t[:strength] || 0) }
|
|
47
|
+
count = [sorted.size, limit_per_type(type, limit)].min
|
|
48
|
+
sorted.first(count).each do |trace|
|
|
49
|
+
facts << format_fact(trace, type)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
facts.first(limit)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def format_fact(trace, type)
|
|
57
|
+
content = extract_content_text(trace[:content_payload])
|
|
58
|
+
{
|
|
59
|
+
trace_id: trace[:trace_id],
|
|
60
|
+
type: type,
|
|
61
|
+
content: content,
|
|
62
|
+
strength: (trace[:strength] || 0).round(3),
|
|
63
|
+
confidence: (trace[:confidence] || 0.5).round(3),
|
|
64
|
+
domain_tags: trace[:domain_tags] || []
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def extract_content_text(payload)
|
|
69
|
+
case payload
|
|
70
|
+
when String then payload
|
|
71
|
+
when Hash then payload[:text] || payload[:content] || payload[:summary] || payload.to_s
|
|
72
|
+
when Array then payload.first.to_s
|
|
73
|
+
else payload.to_s
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def classify_knowledge(count)
|
|
78
|
+
if count >= Constants::KNOWLEDGE_RICH then :rich
|
|
79
|
+
elsif count >= Constants::KNOWLEDGE_MODERATE then :moderate
|
|
80
|
+
elsif count >= Constants::KNOWLEDGE_SPARSE then :sparse
|
|
81
|
+
else :none
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def strength_stats(traces)
|
|
86
|
+
strengths = traces.map { |t| t[:strength] || 0 }
|
|
87
|
+
{
|
|
88
|
+
mean: (strengths.sum / strengths.size).round(3),
|
|
89
|
+
max: strengths.max.round(3),
|
|
90
|
+
min: strengths.min.round(3),
|
|
91
|
+
strong: strengths.count { |s| s >= 0.5 },
|
|
92
|
+
weak: strengths.count { |s| s < 0.3 }
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def emotional_tone(traces)
|
|
97
|
+
valences = traces.map { |t| t[:emotional_valence] || 0.0 }
|
|
98
|
+
intensities = traces.map { |t| t[:emotional_intensity] || 0.0 }
|
|
99
|
+
|
|
100
|
+
avg_valence = valences.sum / valences.size
|
|
101
|
+
avg_intensity = intensities.sum / intensities.size
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
avg_valence: avg_valence.round(3),
|
|
105
|
+
avg_intensity: avg_intensity.round(3),
|
|
106
|
+
tone: tone_label(avg_valence, avg_intensity)
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def tone_label(valence, intensity)
|
|
111
|
+
if intensity < 0.2 then :neutral
|
|
112
|
+
elsif valence > 0.3 then :positive
|
|
113
|
+
elsif valence < -0.3 then :negative
|
|
114
|
+
else :mixed
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def freshness_assessment(traces)
|
|
119
|
+
now = Time.now.utc
|
|
120
|
+
ages = traces.map { |t| now - (t[:last_reinforced] || t[:created_at] || now) }
|
|
121
|
+
avg_age = ages.sum / ages.size
|
|
122
|
+
|
|
123
|
+
{
|
|
124
|
+
avg_age_seconds: avg_age.round(1),
|
|
125
|
+
freshest: ages.min.round(1),
|
|
126
|
+
stalest: ages.max.round(1),
|
|
127
|
+
label: freshness_label(avg_age)
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def freshness_label(avg_age)
|
|
132
|
+
if avg_age < 60 then :very_fresh
|
|
133
|
+
elsif avg_age < 3600 then :fresh
|
|
134
|
+
elsif avg_age < 86_400 then :aging
|
|
135
|
+
else :stale
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def empty_summary(domain)
|
|
140
|
+
{
|
|
141
|
+
domain: domain,
|
|
142
|
+
knowledge_level: :none,
|
|
143
|
+
trace_count: 0,
|
|
144
|
+
type_breakdown: {},
|
|
145
|
+
key_facts: [],
|
|
146
|
+
strength_stats: { mean: 0.0, max: 0.0, min: 0.0, strong: 0, weak: 0 },
|
|
147
|
+
emotional_tone: { avg_valence: 0.0, avg_intensity: 0.0, tone: :neutral },
|
|
148
|
+
freshness: { avg_age_seconds: 0.0, freshest: 0.0, stalest: 0.0, label: :stale }
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def limit_per_type(type, total_limit)
|
|
153
|
+
case type
|
|
154
|
+
when :firmware, :identity then [total_limit, 3].min
|
|
155
|
+
when :procedural then [total_limit, 2].min
|
|
156
|
+
else [total_limit, 5].min
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Language
|
|
6
|
+
module Runners
|
|
7
|
+
module Language
|
|
8
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
9
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
10
|
+
|
|
11
|
+
def summarize(domain:, depth: :standard, traces: [], **)
|
|
12
|
+
traces = filter_traces(traces, domain)
|
|
13
|
+
summary = Helpers::Summarizer.summarize_domain(traces, domain: domain.to_sym, depth: depth.to_sym)
|
|
14
|
+
lexicon.store_summary(domain, summary)
|
|
15
|
+
|
|
16
|
+
Legion::Logging.debug "[language] summarize domain=#{domain} traces=#{traces.size} " \
|
|
17
|
+
"knowledge=#{summary[:knowledge_level]}"
|
|
18
|
+
summary
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def what_do_i_know(domain:, depth: :standard, traces: [], **)
|
|
22
|
+
summary = if lexicon.stale?(domain)
|
|
23
|
+
summarize(domain: domain, depth: depth, traces: traces)
|
|
24
|
+
else
|
|
25
|
+
lexicon.get_summary(domain)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
prose = generate_knowledge_prose(summary)
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
domain: domain.to_sym,
|
|
32
|
+
knowledge_level: summary[:knowledge_level],
|
|
33
|
+
prose: prose,
|
|
34
|
+
fact_count: summary[:key_facts]&.size || 0,
|
|
35
|
+
summary: summary
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def can_answer_wonder?(wonder:, traces: [], **)
|
|
40
|
+
domain = wonder.is_a?(Hash) ? wonder[:domain] : :general
|
|
41
|
+
relevant = filter_traces(traces, domain)
|
|
42
|
+
|
|
43
|
+
answerable = relevant.size >= Helpers::Constants::RESOLUTION_THRESHOLD
|
|
44
|
+
confidence = answerable ? compute_answer_confidence(relevant) : 0.0
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
answerable: answerable,
|
|
48
|
+
confidence: confidence.round(3),
|
|
49
|
+
domain: domain,
|
|
50
|
+
trace_count: relevant.size,
|
|
51
|
+
threshold: Helpers::Constants::RESOLUTION_THRESHOLD
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def knowledge_map(**)
|
|
56
|
+
{
|
|
57
|
+
domains: lexicon.knowledge_map,
|
|
58
|
+
known_domains: lexicon.known_domains,
|
|
59
|
+
total_domains: lexicon.size
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def language_stats(**)
|
|
64
|
+
{
|
|
65
|
+
cached_domains: lexicon.size,
|
|
66
|
+
known_domains: lexicon.known_domains,
|
|
67
|
+
knowledge_map: lexicon.knowledge_map
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def lexicon
|
|
74
|
+
@lexicon ||= Helpers::Lexicon.new
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def filter_traces(traces, domain)
|
|
78
|
+
return [] unless traces.is_a?(Array)
|
|
79
|
+
|
|
80
|
+
domain_sym = domain.to_sym
|
|
81
|
+
domain_str = domain.to_s
|
|
82
|
+
|
|
83
|
+
matching = traces.select do |t|
|
|
84
|
+
tags = t[:domain_tags] || []
|
|
85
|
+
tags.any? { |tag| tag.to_s == domain_str || tag.to_sym == domain_sym }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
matching
|
|
89
|
+
.select { |t| (t[:strength] || 0) >= Helpers::Constants::MIN_SUMMARY_STRENGTH }
|
|
90
|
+
.sort_by { |t| -(t[:strength] || 0) }
|
|
91
|
+
.first(Helpers::Constants::MAX_TRACES_PER_SUMMARY)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def generate_knowledge_prose(summary)
|
|
95
|
+
domain = summary[:domain]
|
|
96
|
+
level = summary[:knowledge_level]
|
|
97
|
+
count = summary[:trace_count]
|
|
98
|
+
facts = summary[:key_facts] || []
|
|
99
|
+
|
|
100
|
+
base = "About #{domain}: I have #{level} knowledge (#{count} traces)."
|
|
101
|
+
|
|
102
|
+
if facts.empty?
|
|
103
|
+
"#{base} No specific facts available."
|
|
104
|
+
else
|
|
105
|
+
fact_lines = facts.first(5).map { |f| "- #{truncate(f[:content], 120)}" }
|
|
106
|
+
"#{base}\nKey facts:\n#{fact_lines.join("\n")}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def compute_answer_confidence(traces)
|
|
111
|
+
return 0.0 if traces.empty?
|
|
112
|
+
|
|
113
|
+
strengths = traces.map { |t| t[:strength] || 0.0 }
|
|
114
|
+
confidences = traces.map { |t| t[:confidence] || 0.5 }
|
|
115
|
+
|
|
116
|
+
avg_strength = strengths.sum / strengths.size
|
|
117
|
+
avg_confidence = confidences.sum / confidences.size
|
|
118
|
+
|
|
119
|
+
((avg_strength * 0.5) + (avg_confidence * 0.5)).clamp(0.0, 1.0)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def truncate(text, max_length)
|
|
123
|
+
text = text.to_s
|
|
124
|
+
text.length > max_length ? "#{text[0...max_length]}..." : text
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/language/version'
|
|
4
|
+
require 'legion/extensions/language/helpers/constants'
|
|
5
|
+
require 'legion/extensions/language/helpers/summarizer'
|
|
6
|
+
require 'legion/extensions/language/helpers/lexicon'
|
|
7
|
+
require 'legion/extensions/language/runners/language'
|
|
8
|
+
require 'legion/extensions/language/client'
|
|
9
|
+
|
|
10
|
+
module Legion
|
|
11
|
+
module Extensions
|
|
12
|
+
module Language
|
|
13
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Language::Client do
|
|
4
|
+
describe '#initialize' do
|
|
5
|
+
it 'creates a default lexicon' do
|
|
6
|
+
client = described_class.new
|
|
7
|
+
expect(client.lexicon).to be_a(Legion::Extensions::Language::Helpers::Lexicon)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it 'accepts an injected lexicon' do
|
|
11
|
+
lexicon = Legion::Extensions::Language::Helpers::Lexicon.new
|
|
12
|
+
client = described_class.new(lexicon: lexicon)
|
|
13
|
+
expect(client.lexicon).to equal(lexicon)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'includes Runners::Language' do
|
|
18
|
+
expect(described_class.ancestors).to include(Legion::Extensions::Language::Runners::Language)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Language::Helpers::Constants do
|
|
4
|
+
it 'defines MAX_TRACES_PER_SUMMARY' do
|
|
5
|
+
expect(described_class::MAX_TRACES_PER_SUMMARY).to eq(50)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
it 'defines MIN_SUMMARY_STRENGTH' do
|
|
9
|
+
expect(described_class::MIN_SUMMARY_STRENGTH).to eq(0.1)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'defines DEPTHS as frozen array of symbols' do
|
|
13
|
+
expect(described_class::DEPTHS).to eq(%i[brief standard detailed])
|
|
14
|
+
expect(described_class::DEPTHS).to be_frozen
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'defines TYPE_PRIORITY as frozen hash' do
|
|
18
|
+
expect(described_class::TYPE_PRIORITY).to include(firmware: 0, sensory: 6)
|
|
19
|
+
expect(described_class::TYPE_PRIORITY).to be_frozen
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'defines knowledge thresholds' do
|
|
23
|
+
expect(described_class::KNOWLEDGE_RICH).to eq(10)
|
|
24
|
+
expect(described_class::KNOWLEDGE_MODERATE).to eq(5)
|
|
25
|
+
expect(described_class::KNOWLEDGE_SPARSE).to eq(1)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'defines RESOLUTION_THRESHOLD' do
|
|
29
|
+
expect(described_class::RESOLUTION_THRESHOLD).to eq(3)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Language::Helpers::Lexicon do
|
|
4
|
+
subject(:lexicon) { described_class.new }
|
|
5
|
+
|
|
6
|
+
let(:summary) do
|
|
7
|
+
{
|
|
8
|
+
domain: :networking,
|
|
9
|
+
knowledge_level: :moderate,
|
|
10
|
+
trace_count: 7,
|
|
11
|
+
key_facts: [{ content: 'fact 1' }]
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
describe '#store_summary' do
|
|
16
|
+
it 'stores a summary keyed by domain symbol' do
|
|
17
|
+
lexicon.store_summary(:networking, summary)
|
|
18
|
+
expect(lexicon.get_summary(:networking)).to include(domain: :networking)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'adds a cached_at timestamp' do
|
|
22
|
+
lexicon.store_summary('networking', summary)
|
|
23
|
+
stored = lexicon.get_summary(:networking)
|
|
24
|
+
expect(stored[:cached_at]).to be_a(Time)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'converts string domain to symbol' do
|
|
28
|
+
lexicon.store_summary('security', summary)
|
|
29
|
+
expect(lexicon.get_summary(:security)).not_to be_nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe '#get_summary' do
|
|
34
|
+
it 'returns nil for unknown domain' do
|
|
35
|
+
expect(lexicon.get_summary(:unknown)).to be_nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'returns stored summary' do
|
|
39
|
+
lexicon.store_summary(:networking, summary)
|
|
40
|
+
result = lexicon.get_summary(:networking)
|
|
41
|
+
expect(result[:knowledge_level]).to eq(:moderate)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe '#known_domains' do
|
|
46
|
+
it 'returns empty array initially' do
|
|
47
|
+
expect(lexicon.known_domains).to eq([])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'returns stored domain keys' do
|
|
51
|
+
lexicon.store_summary(:networking, summary)
|
|
52
|
+
lexicon.store_summary(:security, summary)
|
|
53
|
+
expect(lexicon.known_domains).to contain_exactly(:networking, :security)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
describe '#knowledge_map' do
|
|
58
|
+
it 'returns empty hash initially' do
|
|
59
|
+
expect(lexicon.knowledge_map).to eq({})
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'returns condensed view of all summaries' do
|
|
63
|
+
lexicon.store_summary(:networking, summary)
|
|
64
|
+
map = lexicon.knowledge_map
|
|
65
|
+
expect(map[:networking]).to include(knowledge_level: :moderate, trace_count: 7)
|
|
66
|
+
expect(map[:networking]).to have_key(:cached_at)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
describe '#stale?' do
|
|
71
|
+
it 'returns true for unknown domain' do
|
|
72
|
+
expect(lexicon.stale?(:unknown)).to be true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'returns false for recently stored summary' do
|
|
76
|
+
lexicon.store_summary(:networking, summary)
|
|
77
|
+
expect(lexicon.stale?(:networking)).to be false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it 'returns true when max_age exceeded' do
|
|
81
|
+
lexicon.store_summary(:networking, summary)
|
|
82
|
+
stored = lexicon.get_summary(:networking)
|
|
83
|
+
stored[:cached_at] = Time.now.utc - 600
|
|
84
|
+
expect(lexicon.stale?(:networking, max_age: 300)).to be true
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
describe '#clear' do
|
|
89
|
+
before do
|
|
90
|
+
lexicon.store_summary(:networking, summary)
|
|
91
|
+
lexicon.store_summary(:security, summary)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'clears a specific domain' do
|
|
95
|
+
lexicon.clear(:networking)
|
|
96
|
+
expect(lexicon.get_summary(:networking)).to be_nil
|
|
97
|
+
expect(lexicon.get_summary(:security)).not_to be_nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'clears all domains when called without argument' do
|
|
101
|
+
lexicon.clear
|
|
102
|
+
expect(lexicon.size).to eq(0)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
describe '#size' do
|
|
107
|
+
it 'returns 0 initially' do
|
|
108
|
+
expect(lexicon.size).to eq(0)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'reflects stored summary count' do
|
|
112
|
+
lexicon.store_summary(:networking, summary)
|
|
113
|
+
expect(lexicon.size).to eq(1)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Language::Helpers::Summarizer do
|
|
4
|
+
let(:now) { Time.now.utc }
|
|
5
|
+
|
|
6
|
+
let(:base_trace) do
|
|
7
|
+
{
|
|
8
|
+
trace_id: 'trace-1',
|
|
9
|
+
trace_type: :semantic,
|
|
10
|
+
content_payload: 'Networking uses TCP/IP',
|
|
11
|
+
strength: 0.7,
|
|
12
|
+
confidence: 0.8,
|
|
13
|
+
domain_tags: [:networking],
|
|
14
|
+
emotional_valence: 0.2,
|
|
15
|
+
emotional_intensity: 0.3,
|
|
16
|
+
last_reinforced: now,
|
|
17
|
+
created_at: now
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def make_traces(count, overrides = {})
|
|
22
|
+
count.times.map do |i|
|
|
23
|
+
base_trace.merge(trace_id: "trace-#{i}").merge(overrides)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
describe '.summarize_domain' do
|
|
28
|
+
it 'returns empty summary for no traces' do
|
|
29
|
+
result = described_class.summarize_domain([], domain: :networking)
|
|
30
|
+
expect(result[:knowledge_level]).to eq(:none)
|
|
31
|
+
expect(result[:trace_count]).to eq(0)
|
|
32
|
+
expect(result[:key_facts]).to eq([])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'summarizes traces into structured result' do
|
|
36
|
+
traces = make_traces(3)
|
|
37
|
+
result = described_class.summarize_domain(traces, domain: :networking)
|
|
38
|
+
|
|
39
|
+
expect(result[:domain]).to eq(:networking)
|
|
40
|
+
expect(result[:knowledge_level]).to eq(:sparse)
|
|
41
|
+
expect(result[:trace_count]).to eq(3)
|
|
42
|
+
expect(result).to have_key(:type_breakdown)
|
|
43
|
+
expect(result).to have_key(:key_facts)
|
|
44
|
+
expect(result).to have_key(:strength_stats)
|
|
45
|
+
expect(result).to have_key(:emotional_tone)
|
|
46
|
+
expect(result).to have_key(:freshness)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'classifies rich knowledge at 10+ traces' do
|
|
50
|
+
traces = make_traces(12)
|
|
51
|
+
result = described_class.summarize_domain(traces, domain: :networking)
|
|
52
|
+
expect(result[:knowledge_level]).to eq(:rich)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'classifies moderate knowledge at 5-9 traces' do
|
|
56
|
+
traces = make_traces(7)
|
|
57
|
+
result = described_class.summarize_domain(traces, domain: :networking)
|
|
58
|
+
expect(result[:knowledge_level]).to eq(:moderate)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'classifies sparse knowledge at 1-4 traces' do
|
|
62
|
+
traces = make_traces(2)
|
|
63
|
+
result = described_class.summarize_domain(traces, domain: :networking)
|
|
64
|
+
expect(result[:knowledge_level]).to eq(:sparse)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
describe '.group_by_type' do
|
|
69
|
+
it 'groups traces by trace_type' do
|
|
70
|
+
traces = [
|
|
71
|
+
base_trace.merge(trace_type: :firmware),
|
|
72
|
+
base_trace.merge(trace_type: :semantic),
|
|
73
|
+
base_trace.merge(trace_type: :firmware)
|
|
74
|
+
]
|
|
75
|
+
grouped = described_class.group_by_type(traces)
|
|
76
|
+
expect(grouped[:firmware].size).to eq(2)
|
|
77
|
+
expect(grouped[:semantic].size).to eq(1)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it 'orders by TYPE_PRIORITY' do
|
|
81
|
+
traces = [
|
|
82
|
+
base_trace.merge(trace_type: :sensory),
|
|
83
|
+
base_trace.merge(trace_type: :firmware)
|
|
84
|
+
]
|
|
85
|
+
grouped = described_class.group_by_type(traces)
|
|
86
|
+
expect(grouped.keys.first).to eq(:firmware)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
describe '.extract_key_facts' do
|
|
91
|
+
let(:grouped) do
|
|
92
|
+
{ semantic: make_traces(10) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'limits facts by depth :brief' do
|
|
96
|
+
facts = described_class.extract_key_facts(grouped, depth: :brief)
|
|
97
|
+
expect(facts.size).to be <= 3
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'limits facts by depth :standard' do
|
|
101
|
+
facts = described_class.extract_key_facts(grouped, depth: :standard)
|
|
102
|
+
expect(facts.size).to be <= 7
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'limits facts by depth :detailed' do
|
|
106
|
+
facts = described_class.extract_key_facts(grouped, depth: :detailed)
|
|
107
|
+
expect(facts.size).to be <= 15
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it 'returns structured fact hashes' do
|
|
111
|
+
facts = described_class.extract_key_facts(grouped)
|
|
112
|
+
fact = facts.first
|
|
113
|
+
expect(fact).to include(:trace_id, :type, :content, :strength, :confidence, :domain_tags)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
describe '.extract_content_text' do
|
|
118
|
+
it 'handles String payload' do
|
|
119
|
+
expect(described_class.extract_content_text('hello')).to eq('hello')
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'handles Hash with :text key' do
|
|
123
|
+
expect(described_class.extract_content_text({ text: 'hello' })).to eq('hello')
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it 'handles Hash with :content key' do
|
|
127
|
+
expect(described_class.extract_content_text({ content: 'world' })).to eq('world')
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it 'handles Hash with :summary key' do
|
|
131
|
+
expect(described_class.extract_content_text({ summary: 'sum' })).to eq('sum')
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'handles Array payload' do
|
|
135
|
+
expect(described_class.extract_content_text(%w[first second])).to eq('first')
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it 'converts other types to string' do
|
|
139
|
+
expect(described_class.extract_content_text(42)).to eq('42')
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
describe '.strength_stats' do
|
|
144
|
+
it 'computes mean, max, min, strong, weak counts' do
|
|
145
|
+
traces = [
|
|
146
|
+
base_trace.merge(strength: 0.8),
|
|
147
|
+
base_trace.merge(strength: 0.2),
|
|
148
|
+
base_trace.merge(strength: 0.5)
|
|
149
|
+
]
|
|
150
|
+
stats = described_class.strength_stats(traces)
|
|
151
|
+
expect(stats[:mean]).to eq(0.5)
|
|
152
|
+
expect(stats[:max]).to eq(0.8)
|
|
153
|
+
expect(stats[:min]).to eq(0.2)
|
|
154
|
+
expect(stats[:strong]).to eq(2) # 0.8 and 0.5
|
|
155
|
+
expect(stats[:weak]).to eq(1) # 0.2
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
describe '.emotional_tone' do
|
|
160
|
+
it 'computes average valence and intensity' do
|
|
161
|
+
traces = [
|
|
162
|
+
base_trace.merge(emotional_valence: 0.5, emotional_intensity: 0.6),
|
|
163
|
+
base_trace.merge(emotional_valence: 0.3, emotional_intensity: 0.4)
|
|
164
|
+
]
|
|
165
|
+
tone = described_class.emotional_tone(traces)
|
|
166
|
+
expect(tone[:avg_valence]).to eq(0.4)
|
|
167
|
+
expect(tone[:avg_intensity]).to eq(0.5)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it 'classifies neutral tone for low intensity' do
|
|
171
|
+
traces = [base_trace.merge(emotional_valence: 0.5, emotional_intensity: 0.1)]
|
|
172
|
+
tone = described_class.emotional_tone(traces)
|
|
173
|
+
expect(tone[:tone]).to eq(:neutral)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it 'classifies positive tone for high valence' do
|
|
177
|
+
traces = [base_trace.merge(emotional_valence: 0.5, emotional_intensity: 0.5)]
|
|
178
|
+
tone = described_class.emotional_tone(traces)
|
|
179
|
+
expect(tone[:tone]).to eq(:positive)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it 'classifies negative tone for low valence' do
|
|
183
|
+
traces = [base_trace.merge(emotional_valence: -0.5, emotional_intensity: 0.5)]
|
|
184
|
+
tone = described_class.emotional_tone(traces)
|
|
185
|
+
expect(tone[:tone]).to eq(:negative)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it 'classifies mixed tone for moderate valence' do
|
|
189
|
+
traces = [base_trace.merge(emotional_valence: 0.0, emotional_intensity: 0.5)]
|
|
190
|
+
tone = described_class.emotional_tone(traces)
|
|
191
|
+
expect(tone[:tone]).to eq(:mixed)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
describe '.freshness_assessment' do
|
|
196
|
+
it 'classifies very_fresh traces' do
|
|
197
|
+
traces = [base_trace.merge(last_reinforced: Time.now.utc)]
|
|
198
|
+
result = described_class.freshness_assessment(traces)
|
|
199
|
+
expect(result[:label]).to eq(:very_fresh)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it 'classifies stale traces' do
|
|
203
|
+
traces = [base_trace.merge(last_reinforced: Time.now.utc - 100_000)]
|
|
204
|
+
result = described_class.freshness_assessment(traces)
|
|
205
|
+
expect(result[:label]).to eq(:stale)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it 'returns avg_age_seconds, freshest, stalest' do
|
|
209
|
+
result = described_class.freshness_assessment([base_trace])
|
|
210
|
+
expect(result).to have_key(:avg_age_seconds)
|
|
211
|
+
expect(result).to have_key(:freshest)
|
|
212
|
+
expect(result).to have_key(:stalest)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
describe '.empty_summary' do
|
|
217
|
+
it 'returns zeroed-out summary for domain' do
|
|
218
|
+
result = described_class.empty_summary(:networking)
|
|
219
|
+
expect(result[:domain]).to eq(:networking)
|
|
220
|
+
expect(result[:knowledge_level]).to eq(:none)
|
|
221
|
+
expect(result[:trace_count]).to eq(0)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Language::Runners::Language do
|
|
4
|
+
let(:lexicon) { Legion::Extensions::Language::Helpers::Lexicon.new }
|
|
5
|
+
let(:client) { Legion::Extensions::Language::Client.new(lexicon: lexicon) }
|
|
6
|
+
|
|
7
|
+
let(:now) { Time.now.utc }
|
|
8
|
+
let(:base_trace) do
|
|
9
|
+
{
|
|
10
|
+
trace_id: 'trace-1',
|
|
11
|
+
trace_type: :semantic,
|
|
12
|
+
content_payload: 'Networking uses TCP/IP',
|
|
13
|
+
strength: 0.7,
|
|
14
|
+
confidence: 0.8,
|
|
15
|
+
domain_tags: [:networking],
|
|
16
|
+
emotional_valence: 0.2,
|
|
17
|
+
emotional_intensity: 0.3,
|
|
18
|
+
last_reinforced: now,
|
|
19
|
+
created_at: now
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def make_traces(count, domain: :networking)
|
|
24
|
+
count.times.map do |i|
|
|
25
|
+
base_trace.merge(
|
|
26
|
+
trace_id: "trace-#{i}",
|
|
27
|
+
domain_tags: [domain],
|
|
28
|
+
strength: 0.3 + (rand * 0.5)
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe '#summarize' do
|
|
34
|
+
it 'returns a summary hash for the domain' do
|
|
35
|
+
traces = make_traces(5)
|
|
36
|
+
result = client.summarize(domain: :networking, traces: traces)
|
|
37
|
+
expect(result[:domain]).to eq(:networking)
|
|
38
|
+
expect(result[:knowledge_level]).to eq(:moderate)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'stores the summary in the lexicon' do
|
|
42
|
+
traces = make_traces(5)
|
|
43
|
+
client.summarize(domain: :networking, traces: traces)
|
|
44
|
+
expect(lexicon.get_summary(:networking)).not_to be_nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'filters traces by domain' do
|
|
48
|
+
traces = make_traces(5, domain: :networking) + make_traces(3, domain: :security)
|
|
49
|
+
result = client.summarize(domain: :networking, traces: traces)
|
|
50
|
+
expect(result[:trace_count]).to eq(5)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'respects minimum strength threshold' do
|
|
54
|
+
weak_traces = make_traces(3).map { |t| t.merge(strength: 0.05) }
|
|
55
|
+
strong_traces = make_traces(2)
|
|
56
|
+
result = client.summarize(domain: :networking, traces: weak_traces + strong_traces)
|
|
57
|
+
expect(result[:trace_count]).to eq(2)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'accepts string domain and converts to symbol' do
|
|
61
|
+
traces = make_traces(3)
|
|
62
|
+
result = client.summarize(domain: 'networking', traces: traces)
|
|
63
|
+
expect(result[:domain]).to eq(:networking)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
describe '#what_do_i_know' do
|
|
68
|
+
it 'returns structured knowledge with prose' do
|
|
69
|
+
traces = make_traces(5)
|
|
70
|
+
result = client.what_do_i_know(domain: :networking, traces: traces)
|
|
71
|
+
expect(result[:domain]).to eq(:networking)
|
|
72
|
+
expect(result[:knowledge_level]).to be_a(Symbol)
|
|
73
|
+
expect(result[:prose]).to be_a(String)
|
|
74
|
+
expect(result[:fact_count]).to be >= 0
|
|
75
|
+
expect(result).to have_key(:summary)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'uses cached summary when not stale' do
|
|
79
|
+
traces = make_traces(5)
|
|
80
|
+
client.summarize(domain: :networking, traces: traces)
|
|
81
|
+
|
|
82
|
+
result = client.what_do_i_know(domain: :networking, traces: [])
|
|
83
|
+
expect(result[:knowledge_level]).to eq(:moderate)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 're-summarizes when cache is stale' do
|
|
87
|
+
traces = make_traces(5)
|
|
88
|
+
client.summarize(domain: :networking, traces: traces)
|
|
89
|
+
|
|
90
|
+
stored = lexicon.get_summary(:networking)
|
|
91
|
+
stored[:cached_at] = Time.now.utc - 600
|
|
92
|
+
|
|
93
|
+
new_traces = make_traces(12)
|
|
94
|
+
result = client.what_do_i_know(domain: :networking, traces: new_traces)
|
|
95
|
+
expect(result[:knowledge_level]).to eq(:rich)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'includes key facts in prose when available' do
|
|
99
|
+
traces = make_traces(5)
|
|
100
|
+
result = client.what_do_i_know(domain: :networking, traces: traces)
|
|
101
|
+
expect(result[:prose]).to include('Key facts')
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'handles no-facts prose' do
|
|
105
|
+
result = client.what_do_i_know(domain: :empty, traces: [])
|
|
106
|
+
expect(result[:prose]).to include('No specific facts')
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
describe '#can_answer_wonder?' do
|
|
111
|
+
it 'returns answerable when enough traces exist' do
|
|
112
|
+
traces = make_traces(5)
|
|
113
|
+
result = client.can_answer_wonder?(wonder: { domain: :networking }, traces: traces)
|
|
114
|
+
expect(result[:answerable]).to be true
|
|
115
|
+
expect(result[:confidence]).to be > 0.0
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'returns not answerable when traces below threshold' do
|
|
119
|
+
traces = make_traces(2)
|
|
120
|
+
result = client.can_answer_wonder?(wonder: { domain: :networking }, traces: traces)
|
|
121
|
+
expect(result[:answerable]).to be false
|
|
122
|
+
expect(result[:confidence]).to eq(0.0)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'extracts domain from wonder hash' do
|
|
126
|
+
traces = make_traces(5, domain: :security)
|
|
127
|
+
result = client.can_answer_wonder?(wonder: { domain: :security }, traces: traces)
|
|
128
|
+
expect(result[:domain]).to eq(:security)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it 'defaults to :general when wonder is not a hash' do
|
|
132
|
+
result = client.can_answer_wonder?(wonder: 'what is life?', traces: [])
|
|
133
|
+
expect(result[:domain]).to eq(:general)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it 'includes trace_count and threshold' do
|
|
137
|
+
traces = make_traces(4)
|
|
138
|
+
result = client.can_answer_wonder?(wonder: { domain: :networking }, traces: traces)
|
|
139
|
+
expect(result[:trace_count]).to eq(4)
|
|
140
|
+
expect(result[:threshold]).to eq(3)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
describe '#knowledge_map' do
|
|
145
|
+
it 'returns domains, known_domains, and total_domains' do
|
|
146
|
+
traces = make_traces(5)
|
|
147
|
+
client.summarize(domain: :networking, traces: traces)
|
|
148
|
+
result = client.knowledge_map
|
|
149
|
+
expect(result[:known_domains]).to include(:networking)
|
|
150
|
+
expect(result[:total_domains]).to eq(1)
|
|
151
|
+
expect(result[:domains]).to have_key(:networking)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it 'returns empty map with no summaries' do
|
|
155
|
+
result = client.knowledge_map
|
|
156
|
+
expect(result[:total_domains]).to eq(0)
|
|
157
|
+
expect(result[:known_domains]).to eq([])
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
describe '#language_stats' do
|
|
162
|
+
it 'returns cached_domains, known_domains, knowledge_map' do
|
|
163
|
+
result = client.language_stats
|
|
164
|
+
expect(result).to have_key(:cached_domains)
|
|
165
|
+
expect(result).to have_key(:known_domains)
|
|
166
|
+
expect(result).to have_key(:knowledge_map)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Logging
|
|
7
|
+
def self.debug(_msg); end
|
|
8
|
+
def self.info(_msg); end
|
|
9
|
+
def self.warn(_msg); end
|
|
10
|
+
def self.error(_msg); end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
require 'legion/extensions/language'
|
|
15
|
+
|
|
16
|
+
RSpec.configure do |config|
|
|
17
|
+
config.example_status_persistence_file_path = '.rspec_status'
|
|
18
|
+
config.disable_monkey_patching!
|
|
19
|
+
config.expect_with(:rspec) { |c| c.syntax = :expect }
|
|
20
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lex-language
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Esity
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: legion-gaia
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
description: Symbolic-to-linguistic grounding layer for brain-modeled agentic AI
|
|
27
|
+
email:
|
|
28
|
+
- matthewdiverson@gmail.com
|
|
29
|
+
executables: []
|
|
30
|
+
extensions: []
|
|
31
|
+
extra_rdoc_files: []
|
|
32
|
+
files:
|
|
33
|
+
- Gemfile
|
|
34
|
+
- LICENSE
|
|
35
|
+
- README.md
|
|
36
|
+
- lex-language.gemspec
|
|
37
|
+
- lib/legion/extensions/language.rb
|
|
38
|
+
- lib/legion/extensions/language/client.rb
|
|
39
|
+
- lib/legion/extensions/language/helpers/constants.rb
|
|
40
|
+
- lib/legion/extensions/language/helpers/lexicon.rb
|
|
41
|
+
- lib/legion/extensions/language/helpers/summarizer.rb
|
|
42
|
+
- lib/legion/extensions/language/runners/language.rb
|
|
43
|
+
- lib/legion/extensions/language/version.rb
|
|
44
|
+
- spec/legion/extensions/language/client_spec.rb
|
|
45
|
+
- spec/legion/extensions/language/helpers/constants_spec.rb
|
|
46
|
+
- spec/legion/extensions/language/helpers/lexicon_spec.rb
|
|
47
|
+
- spec/legion/extensions/language/helpers/summarizer_spec.rb
|
|
48
|
+
- spec/legion/extensions/language/runners/language_spec.rb
|
|
49
|
+
- spec/spec_helper.rb
|
|
50
|
+
homepage: https://github.com/LegionIO/lex-language
|
|
51
|
+
licenses:
|
|
52
|
+
- MIT
|
|
53
|
+
metadata:
|
|
54
|
+
homepage_uri: https://github.com/LegionIO/lex-language
|
|
55
|
+
source_code_uri: https://github.com/LegionIO/lex-language
|
|
56
|
+
documentation_uri: https://github.com/LegionIO/lex-language
|
|
57
|
+
changelog_uri: https://github.com/LegionIO/lex-language
|
|
58
|
+
bug_tracker_uri: https://github.com/LegionIO/lex-language/issues
|
|
59
|
+
rubygems_mfa_required: 'true'
|
|
60
|
+
rdoc_options: []
|
|
61
|
+
require_paths:
|
|
62
|
+
- lib
|
|
63
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.4'
|
|
68
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '0'
|
|
73
|
+
requirements: []
|
|
74
|
+
rubygems_version: 3.6.9
|
|
75
|
+
specification_version: 4
|
|
76
|
+
summary: LEX Language
|
|
77
|
+
test_files: []
|