smart_brain 0.1.2 → 0.2.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 +4 -4
- data/CHANGELOG.md +15 -0
- data/MEMPAL_GUIDE.md +1074 -0
- data/README.en.md +173 -173
- data/README.md +467 -173
- data/config/brain.yml +69 -1
- data/conversation_demo.rb +438 -438
- data/db/migrate/002_turn_events_payload.sql +9 -0
- data/db/migrate/003_tiers_and_lifecycle.sql +28 -0
- data/db/migrate/004_kg_edges.sql +30 -0
- data/db/migrate/005_domains_and_memory_scopes.sql +163 -0
- data/docs/coding_todo.md +139 -0
- data/docs/context_package.md +220 -0
- data/docs/evidence_pack.md +190 -0
- data/docs/gap_vs_mempal.md +161 -0
- data/docs/mcp.md +93 -0
- data/docs/memory_types.md +278 -0
- data/docs/multi_scope_memory_refactor_plan.md +483 -0
- data/docs/multi_scope_migration.md +65 -0
- data/docs/policies.md +308 -0
- data/docs/retrieval_plan.md +231 -0
- data/docs/smartbrain_design.md +299 -0
- data/docs/user_guide.md +546 -0
- data/example.rb +91 -91
- data/examples/01_memory_basic.rb +57 -0
- data/examples/02_governance.rb +63 -0
- data/examples/03_postgres_persistence.rb +63 -0
- data/examples/04_ollama_llm.rb +69 -0
- data/examples/05_smart_rag_integration.rb +79 -0
- data/examples/06_multi_scope_memory.rb +50 -0
- data/examples/README.md +49 -0
- data/exe/smart_brain +168 -0
- data/lib/smart_brain/adapters/smart_rag/direct_client.rb +16 -5
- data/lib/smart_brain/adapters/smart_rag/http_client.rb +16 -5
- data/lib/smart_brain/adapters/smart_rag/null_client.rb +7 -2
- data/lib/smart_brain/adapters/smart_rag/scope_filter.rb +60 -0
- data/lib/smart_brain/configuration.rb +57 -0
- data/lib/smart_brain/consolidator/working_summary.rb +80 -12
- data/lib/smart_brain/context_composer/composer.rb +40 -3
- data/lib/smart_brain/contracts/retrieval_plan.rb +10 -0
- data/lib/smart_brain/contracts/scope_context.rb +46 -0
- data/lib/smart_brain/contracts/scope_ref.rb +25 -0
- data/lib/smart_brain/db.rb +109 -0
- data/lib/smart_brain/event_store/in_memory.rb +6 -2
- data/lib/smart_brain/event_store/postgres.rb +199 -0
- data/lib/smart_brain/fusion/merger.rb +31 -2
- data/lib/smart_brain/governance/briefing.rb +146 -0
- data/lib/smart_brain/governance/fact_check.rb +110 -0
- data/lib/smart_brain/governance/knowledge_graph.rb +60 -0
- data/lib/smart_brain/governance/lifecycle.rb +225 -0
- data/lib/smart_brain/governance/tiers.rb +60 -0
- data/lib/smart_brain/memory_extractor/extractor.rb +25 -7
- data/lib/smart_brain/memory_store/in_memory.rb +202 -17
- data/lib/smart_brain/memory_store/postgres.rb +500 -0
- data/lib/smart_brain/model_provider/base.rb +87 -0
- data/lib/smart_brain/model_provider/factory.rb +49 -0
- data/lib/smart_brain/model_provider/ollama.rb +60 -0
- data/lib/smart_brain/model_provider/openai.rb +60 -0
- data/lib/smart_brain/model_provider/stub.rb +26 -0
- data/lib/smart_brain/model_provider.rb +7 -0
- data/lib/smart_brain/observability/tracker.rb +39 -1
- data/lib/smart_brain/retrievers/exact_retriever.rb +6 -0
- data/lib/smart_brain/retrievers/memory_retriever.rb +59 -5
- data/lib/smart_brain/runtime.rb +288 -16
- data/lib/smart_brain/scopes/conflict_resolver.rb +67 -0
- data/lib/smart_brain/scopes/registry.rb +133 -0
- data/lib/smart_brain/scopes/resolver.rb +32 -0
- data/lib/smart_brain/server/http_app.rb +143 -0
- data/lib/smart_brain/server/mcp_server.rb +385 -0
- data/lib/smart_brain/server/service.rb +129 -0
- data/lib/smart_brain/support/levenshtein.rb +35 -0
- data/lib/smart_brain/version.rb +5 -5
- data/lib/smart_brain.rb +80 -35
- metadata +88 -36
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require_relative '../support/levenshtein'
|
|
5
|
+
|
|
6
|
+
module SmartBrain
|
|
7
|
+
module Governance
|
|
8
|
+
# Offline, zero-LLM, zero-network contradiction detector (mempal parity).
|
|
9
|
+
# Scans a piece of text against a session's entities + KG edges and reports
|
|
10
|
+
# three finding types:
|
|
11
|
+
# - SimilarNameConflict: a token within Levenshtein <= N of a known entity
|
|
12
|
+
# - RelationContradiction: text mentions both endpoints of an active edge
|
|
13
|
+
# alongside a negation/contrary cue (may contradict the relation)
|
|
14
|
+
# - StaleFact: text references an edge whose valid_to < now (invalidated)
|
|
15
|
+
#
|
|
16
|
+
# Heuristic by design — surfaces candidates for human/agent review.
|
|
17
|
+
class FactCheck
|
|
18
|
+
TOKEN_RE = /[[:alnum:]_\-\p{Han}]+/.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(memory_store:, config:)
|
|
21
|
+
@memory_store = memory_store
|
|
22
|
+
@config = config
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def check(session_id:, text:, now: Time.now.utc, scope_ids: nil)
|
|
26
|
+
now = Time.parse(now).utc if now.is_a?(String)
|
|
27
|
+
text_l = text.to_s.downcase
|
|
28
|
+
tokens = text.to_s.downcase.scan(TOKEN_RE)
|
|
29
|
+
|
|
30
|
+
findings = []
|
|
31
|
+
findings.concat(similar_name_conflicts(session_id:, scope_ids:, tokens: tokens))
|
|
32
|
+
findings.concat(relation_contradictions(session_id:, scope_ids:, text_l: text_l))
|
|
33
|
+
findings.concat(stale_facts(session_id:, scope_ids:, text_l: text_l, now: now))
|
|
34
|
+
|
|
35
|
+
{ findings: findings, checked_at: Time.now.utc.iso8601,
|
|
36
|
+
counts: { total: findings.size,
|
|
37
|
+
similar_name: findings.count { |f| f[:type] == 'SimilarNameConflict' },
|
|
38
|
+
relation_contradiction: findings.count { |f| f[:type] == 'RelationContradiction' },
|
|
39
|
+
stale_fact: findings.count { |f| f[:type] == 'StaleFact' } } }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
attr_reader :memory_store, :config
|
|
45
|
+
|
|
46
|
+
def similar_name_conflicts(session_id:, scope_ids:, tokens:)
|
|
47
|
+
distance = config.fact_check.fetch(:similar_name_distance, 2) rescue 2
|
|
48
|
+
names = memory_store.entities(session_id: session_id, scope_ids: scope_ids).each_with_object([]) do |e, acc|
|
|
49
|
+
acc << { name: e[:canonical].to_s, entity: e } unless e[:canonical].to_s.empty?
|
|
50
|
+
acc << { name: e[:name].to_s, entity: e } unless e[:name].to_s.empty?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
tokens.product(names).each_with_object([]) do |(token, named), acc|
|
|
54
|
+
next if token.length < 3 || named[:name].length < 3
|
|
55
|
+
next if token == named[:name]
|
|
56
|
+
|
|
57
|
+
d = Support::Levenshtein.distance(token, named[:name])
|
|
58
|
+
next unless d.between?(1, distance)
|
|
59
|
+
|
|
60
|
+
acc << { type: 'SimilarNameConflict', severity: 'warning',
|
|
61
|
+
detail: "'#{token}' is within Levenshtein #{d} of entity '#{named[:name]}'",
|
|
62
|
+
refs: { token: token, entity: named[:name], entity_id: named[:entity][:id], distance: d } }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def relation_contradictions(session_id:, scope_ids:, text_l:)
|
|
67
|
+
cues = negation_cues
|
|
68
|
+
edges = safe_edges(session_id, scope_ids).select { |e| e[:status] == 'active' }
|
|
69
|
+
return [] if edges.empty? || cues.empty?
|
|
70
|
+
|
|
71
|
+
edges.each_with_object([]) do |edge, acc|
|
|
72
|
+
s, o = edge[:subject].downcase, edge[:object].downcase
|
|
73
|
+
next unless text_l.include?(s) && text_l.include?(o)
|
|
74
|
+
next unless cues.any? { |cue| text_l.include?(cue.to_s.downcase) }
|
|
75
|
+
|
|
76
|
+
acc << { type: 'RelationContradiction', severity: 'warning',
|
|
77
|
+
detail: "text may contradict relation '#{edge[:subject]} #{edge[:predicate]} #{edge[:object]}'",
|
|
78
|
+
refs: { edge_id: edge[:id], subject: edge[:subject], predicate: edge[:predicate], object: edge[:object] } }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def stale_facts(session_id:, scope_ids:, text_l:, now:)
|
|
83
|
+
safe_edges(session_id, scope_ids).select { |e| e[:status] == 'invalidated' && e[:valid_to] }
|
|
84
|
+
.each_with_object([]) do |edge, acc|
|
|
85
|
+
valid_to = Time.parse(edge[:valid_to].to_s).utc rescue next
|
|
86
|
+
next if valid_to >= now
|
|
87
|
+
|
|
88
|
+
s, o = edge[:subject].downcase, edge[:object].downcase
|
|
89
|
+
next unless text_l.include?(s) || text_l.include?(o)
|
|
90
|
+
|
|
91
|
+
acc << { type: 'StaleFact', severity: 'info',
|
|
92
|
+
detail: "text references invalidated fact '#{edge[:subject]} #{edge[:predicate]} #{edge[:object]}' (valid_to #{edge[:valid_to]})",
|
|
93
|
+
refs: { edge_id: edge[:id], subject: edge[:subject], object: edge[:object], valid_to: edge[:valid_to] } }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def safe_edges(session_id, scope_ids)
|
|
98
|
+
return [] unless memory_store.respond_to?(:query_edges)
|
|
99
|
+
|
|
100
|
+
memory_store.query_edges(session_id: session_id, scope_ids: scope_ids, include_invalid: true)
|
|
101
|
+
rescue StandardError
|
|
102
|
+
[]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def negation_cues
|
|
106
|
+
config.fact_check.fetch(:negation_cues, []) rescue []
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartBrain
|
|
4
|
+
module Governance
|
|
5
|
+
# Knowledge graph: typed triples (subject-predicate-object) with temporal
|
|
6
|
+
# validity (valid_from/valid_to) over a MemoryStore. Orthogonal to the
|
|
7
|
+
# Stage-1 lifecycle and to memory_items — edges live in kg_edges and are
|
|
8
|
+
# added explicitly (via turn_events[:edges] extraction) or by the agent.
|
|
9
|
+
class KnowledgeGraph
|
|
10
|
+
def initialize(memory_store:, config:)
|
|
11
|
+
@memory_store = memory_store
|
|
12
|
+
@config = config
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def add(session_id:, subject:, predicate:, object:, source_turn_id: nil,
|
|
16
|
+
source_memory_item_id: nil, confidence: nil, valid_from: nil,
|
|
17
|
+
scope_id: nil, scope: nil, source_session_id: nil)
|
|
18
|
+
memory_store.add_edge(
|
|
19
|
+
session_id: session_id,
|
|
20
|
+
scope_id: scope_id,
|
|
21
|
+
scope: scope,
|
|
22
|
+
source_session_id: source_session_id,
|
|
23
|
+
edge: {
|
|
24
|
+
subject: subject, predicate: predicate, object: object,
|
|
25
|
+
source_turn_id: source_turn_id, source_memory_item_id: source_memory_item_id,
|
|
26
|
+
confidence: confidence || default_confidence, valid_from: valid_from
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def query(session_id: nil, scope_ids: nil, subject: nil, predicate: nil, object: nil, include_invalid: false)
|
|
32
|
+
memory_store.query_edges(session_id: session_id, scope_ids: scope_ids, subject: subject,
|
|
33
|
+
predicate: predicate, object: object,
|
|
34
|
+
include_invalid: include_invalid)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def timeline(session_id: nil, scope_ids: nil, subject:)
|
|
38
|
+
memory_store.edges_for_subject(session_id: session_id, scope_ids: scope_ids, subject: subject)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def invalidate(edge_id:, reason: nil)
|
|
42
|
+
memory_store.invalidate_edge(id: edge_id, reason: reason)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def stats(session_id: nil, scope_ids: nil)
|
|
46
|
+
memory_store.edge_stats(session_id: session_id, scope_ids: scope_ids)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
attr_reader :memory_store, :config
|
|
52
|
+
|
|
53
|
+
def default_confidence
|
|
54
|
+
config.kg.fetch(:default_confidence, 0.6)
|
|
55
|
+
rescue StandardError
|
|
56
|
+
0.6
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require_relative 'tiers'
|
|
5
|
+
|
|
6
|
+
module SmartBrain
|
|
7
|
+
module Governance
|
|
8
|
+
# Stage-1 knowledge lifecycle (mempal parity):
|
|
9
|
+
#
|
|
10
|
+
# evidence → distill(candidate) → gate → promote(promoted) → demote/retire
|
|
11
|
+
#
|
|
12
|
+
# Operates over a MemoryStore (independent of backend). distill creates a
|
|
13
|
+
# candidate knowledge item at tier dao_ren|qi; gate is a read-only readiness
|
|
14
|
+
# check; promote/demote are evidence-backed transitions that append to the
|
|
15
|
+
# knowledge_events audit trail. Orthogonal to the conflict `status`.
|
|
16
|
+
class Lifecycle
|
|
17
|
+
DEMOTE_REASONS = %w[contradicted obsolete superseded].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(memory_store:, config:, model_provider: nil)
|
|
20
|
+
@memory_store = memory_store
|
|
21
|
+
@config = config
|
|
22
|
+
@model_provider = model_provider
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def distill(session_id:, statement:, content: nil, tier:, supporting_refs: [], domain: nil, field: nil,
|
|
26
|
+
reviewer: nil, key: nil, scope_id: nil, scope: nil)
|
|
27
|
+
tier = tier.to_s
|
|
28
|
+
raise ArgumentError, "tier must be one of #{Tiers::DISTILLABLE.join('/')}" unless Tiers.distillable?(tier)
|
|
29
|
+
|
|
30
|
+
statement = draft_statement(statement, supporting_refs: supporting_refs, content: content) if statement.nil? || statement.strip.empty?
|
|
31
|
+
raise ArgumentError, 'statement is required (provide one or wire an LLM provider)' if statement.nil? || statement.strip.empty?
|
|
32
|
+
|
|
33
|
+
item = {
|
|
34
|
+
type: 'knowledge',
|
|
35
|
+
key: key || "knowledge:#{tier}:#{slugify(statement)}",
|
|
36
|
+
value_json: {
|
|
37
|
+
statement: statement,
|
|
38
|
+
content: content,
|
|
39
|
+
tier: tier,
|
|
40
|
+
domain: domain,
|
|
41
|
+
field: field,
|
|
42
|
+
supporting_refs: Array(supporting_refs)
|
|
43
|
+
},
|
|
44
|
+
confidence: 0.5,
|
|
45
|
+
status: 'active',
|
|
46
|
+
tier: tier,
|
|
47
|
+
lifecycle_status: 'candidate',
|
|
48
|
+
source_turn_id: nil,
|
|
49
|
+
updated_at: Time.now.utc.iso8601
|
|
50
|
+
}
|
|
51
|
+
record = memory_store.create_item(session_id: session_id, item: item, scope_id: scope_id, scope: scope)
|
|
52
|
+
memory_store.record_event(
|
|
53
|
+
memory_item_id: record[:id], event_type: 'distill',
|
|
54
|
+
from_lifecycle: nil, to_lifecycle: 'candidate',
|
|
55
|
+
reason: 'distilled from evidence', reviewer: reviewer,
|
|
56
|
+
evidence_refs: Array(supporting_refs)
|
|
57
|
+
)
|
|
58
|
+
{ memory_item: record, event: last_event(record[:id]) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Read-only promotion readiness check.
|
|
62
|
+
def gate(memory_item_id:)
|
|
63
|
+
item = memory_store.find_item(id: memory_item_id)
|
|
64
|
+
raise ArgumentError, "memory item not found: #{memory_item_id}" unless item
|
|
65
|
+
|
|
66
|
+
refs = Array(item.dig(:value_json, :supporting_refs) || item.dig(:value_json, 'supporting_refs'))
|
|
67
|
+
min = min_supporting_refs
|
|
68
|
+
reviewer = item.dig(:value_json, :reviewer) || item.dig(:value_json, 'reviewer')
|
|
69
|
+
checks = {
|
|
70
|
+
lifecycle: item[:lifecycle_status],
|
|
71
|
+
supporting_refs: refs.size,
|
|
72
|
+
min_required: min,
|
|
73
|
+
reviewer: reviewer
|
|
74
|
+
}
|
|
75
|
+
ready = item[:lifecycle_status] == 'candidate' && (refs.size >= min || reviewer == 'human')
|
|
76
|
+
{ ready: ready, target_status: 'promoted', checks: checks }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def promote(memory_item_id:, verification_refs:, reason:, reviewer: nil, force: false)
|
|
80
|
+
item = memory_store.find_item(id: memory_item_id)
|
|
81
|
+
raise ArgumentError, "memory item not found: #{memory_item_id}" unless item
|
|
82
|
+
|
|
83
|
+
decision = gate(memory_item_id: memory_item_id)
|
|
84
|
+
if !decision[:ready] && !force
|
|
85
|
+
raise LifecycleGateError, "promotion gate failed: #{decision[:checks].inspect}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
from = item[:lifecycle_status]
|
|
89
|
+
updated = memory_store.set_lifecycle(
|
|
90
|
+
id: memory_item_id, lifecycle_status: 'promoted',
|
|
91
|
+
merge_value: { verification_refs: Array(verification_refs), reviewer: reviewer }
|
|
92
|
+
)
|
|
93
|
+
memory_store.record_event(
|
|
94
|
+
memory_item_id: memory_item_id, event_type: 'promote',
|
|
95
|
+
from_lifecycle: from, to_lifecycle: 'promoted',
|
|
96
|
+
reason: reason, reviewer: reviewer, evidence_refs: Array(verification_refs)
|
|
97
|
+
)
|
|
98
|
+
{ memory_item: updated, event: last_event(memory_item_id) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def demote(memory_item_id:, evidence_refs:, reason:, reason_type:, reviewer: nil)
|
|
102
|
+
raise ArgumentError, "reason_type must be one of #{DEMOTE_REASONS.join('/')}" unless DEMOTE_REASONS.include?(reason_type.to_s)
|
|
103
|
+
|
|
104
|
+
item = memory_store.find_item(id: memory_item_id)
|
|
105
|
+
raise ArgumentError, "memory item not found: #{memory_item_id}" unless item
|
|
106
|
+
|
|
107
|
+
from = item[:lifecycle_status]
|
|
108
|
+
updated = memory_store.set_lifecycle(
|
|
109
|
+
id: memory_item_id, lifecycle_status: 'demoted',
|
|
110
|
+
merge_value: { demotion_reason: reason, reason_type: reason_type }
|
|
111
|
+
)
|
|
112
|
+
memory_store.record_event(
|
|
113
|
+
memory_item_id: memory_item_id, event_type: 'demote',
|
|
114
|
+
from_lifecycle: from, to_lifecycle: 'demoted',
|
|
115
|
+
reason: reason, reason_type: reason_type, reviewer: reviewer, evidence_refs: Array(evidence_refs)
|
|
116
|
+
)
|
|
117
|
+
{ memory_item: updated, event: last_event(memory_item_id) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def promote_to_scope(source_item:, target_scope:, session_id:, verification_refs:, reason:, reviewer: nil)
|
|
121
|
+
refs = Array(verification_refs)
|
|
122
|
+
unless reviewer == 'human' || refs.size >= min_supporting_refs
|
|
123
|
+
raise LifecycleGateError, "scope promotion requires human review or #{min_supporting_refs} verification refs"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
provenance = {
|
|
127
|
+
source_memory_item_id: source_item[:id],
|
|
128
|
+
source_scope: source_item[:scope],
|
|
129
|
+
target_scope: { type: target_scope[:type], id: target_scope[:id] },
|
|
130
|
+
source_session_id: source_item[:source_session_id] || source_item[:session_id],
|
|
131
|
+
verification_refs: refs,
|
|
132
|
+
reviewer: reviewer,
|
|
133
|
+
reason: reason
|
|
134
|
+
}
|
|
135
|
+
item = source_item.slice(:type, :key, :confidence, :tier).merge(
|
|
136
|
+
value_json: (source_item[:value_json] || {}).merge(provenance: provenance),
|
|
137
|
+
status: 'active', lifecycle_status: 'promoted', source_turn_id: source_item[:source_turn_id],
|
|
138
|
+
source_message_id: source_item[:source_message_id],
|
|
139
|
+
source_session_id: source_item[:source_session_id] || source_item[:session_id],
|
|
140
|
+
updated_at: Time.now.utc.iso8601
|
|
141
|
+
)
|
|
142
|
+
target = memory_store.create_item(
|
|
143
|
+
session_id: session_id, item: item, scope_id: target_scope[:scope_id],
|
|
144
|
+
scope: { type: target_scope[:type], id: target_scope[:id] }
|
|
145
|
+
)
|
|
146
|
+
event = memory_store.record_event(
|
|
147
|
+
memory_item_id: target[:id], event_type: 'promote_to_scope',
|
|
148
|
+
from_lifecycle: source_item[:lifecycle_status], to_lifecycle: 'promoted',
|
|
149
|
+
reason: reason, reviewer: reviewer, evidence_refs: refs
|
|
150
|
+
)
|
|
151
|
+
target = memory_store.set_lifecycle(
|
|
152
|
+
id: target[:id], lifecycle_status: 'promoted',
|
|
153
|
+
merge_value: { provenance: provenance.merge(promotion_event_id: event[:id]) }
|
|
154
|
+
)
|
|
155
|
+
{ source_memory_item: source_item, memory_item: target, event: event }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def retract(memory_item_id:, evidence_refs:, reason:, reviewer: nil)
|
|
159
|
+
item = memory_store.find_item(id: memory_item_id)
|
|
160
|
+
raise ArgumentError, "memory item not found: #{memory_item_id}" unless item
|
|
161
|
+
|
|
162
|
+
updated = memory_store.set_status(id: memory_item_id, status: 'retracted', merge_value: { retraction_reason: reason })
|
|
163
|
+
event = memory_store.record_event(
|
|
164
|
+
memory_item_id: memory_item_id, event_type: 'retract',
|
|
165
|
+
from_lifecycle: item[:lifecycle_status], to_lifecycle: item[:lifecycle_status],
|
|
166
|
+
reason: reason, reviewer: reviewer, evidence_refs: Array(evidence_refs)
|
|
167
|
+
)
|
|
168
|
+
{ memory_item: updated, event: event }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def lineage(memory_item_id:)
|
|
172
|
+
lineage = []
|
|
173
|
+
current = memory_store.find_item(id: memory_item_id)
|
|
174
|
+
while current
|
|
175
|
+
lineage << current
|
|
176
|
+
source_id = current.dig(:value_json, :provenance, :source_memory_item_id)
|
|
177
|
+
break unless source_id
|
|
178
|
+
|
|
179
|
+
current = memory_store.find_item(id: source_id)
|
|
180
|
+
end
|
|
181
|
+
lineage
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def events(memory_item_id:)
|
|
185
|
+
memory_store.events_for(memory_item_id: memory_item_id)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
attr_reader :memory_store, :config, :model_provider
|
|
191
|
+
|
|
192
|
+
def min_supporting_refs
|
|
193
|
+
config.lifecycle.fetch(:min_supporting_refs, 2) rescue 2
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def last_event(memory_item_id)
|
|
197
|
+
memory_store.events_for(memory_item_id: memory_item_id).last
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def draft_statement(statement, supporting_refs:, content:)
|
|
201
|
+
return statement unless model_provider&.llm?
|
|
202
|
+
return nil if Array(supporting_refs).empty?
|
|
203
|
+
|
|
204
|
+
refs_text = supporting_refs_for(supporting_refs).map { |i| "- #{i[:key]}: #{i.dig(:value_json, :statement) || i[:value_json]}" }.join("\n")
|
|
205
|
+
result = model_provider.complete(
|
|
206
|
+
prompt: "Distill ONE concise knowledge statement (<= 1 line) from these observations; reply with only the statement.\n#{refs_text}",
|
|
207
|
+
temperature: 0.2, max_tokens: 120
|
|
208
|
+
)
|
|
209
|
+
result[:error] ? nil : result[:text]
|
|
210
|
+
rescue StandardError
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def supporting_refs_for(refs)
|
|
215
|
+
Array(refs).filter_map { |id| memory_store.find_item(id: id) }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def slugify(text)
|
|
219
|
+
text.to_s.downcase.scan(/[[:alnum:]]+/).first(6).join('-')[0, 60]
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
class LifecycleGateError < StandardError; end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartBrain
|
|
4
|
+
module Governance
|
|
5
|
+
# Mind-model tiers (mempal parity): dao_tian 天道 / dao_ren 人道 / shu 术 /
|
|
6
|
+
# qi 器 / evidence 证据. Lower ORDER index = higher assembly priority.
|
|
7
|
+
module Tiers
|
|
8
|
+
ORDER = %w[dao_tian dao_ren shu qi evidence].freeze
|
|
9
|
+
PRIORITY = ORDER.each_with_index.to_h.freeze
|
|
10
|
+
|
|
11
|
+
# Default tier per memory type. dao_tian is never auto-assigned — it is
|
|
12
|
+
# reserved for user-pinned immutable principles.
|
|
13
|
+
DEFAULT_TIER_BY_TYPE = {
|
|
14
|
+
'profile' => 'dao_ren',
|
|
15
|
+
'decisions' => 'dao_ren',
|
|
16
|
+
'patterns' => 'dao_ren',
|
|
17
|
+
'goals' => 'shu',
|
|
18
|
+
'tasks' => 'shu',
|
|
19
|
+
'preferences' => 'qi',
|
|
20
|
+
'cases' => 'qi',
|
|
21
|
+
'entities' => 'evidence',
|
|
22
|
+
'events' => 'evidence'
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Only dao_ren / qi knowledge can enter the candidate stage via distill
|
|
26
|
+
# (mempal rule: dao_tian and shu are not open to candidate state).
|
|
27
|
+
DISTILLABLE = %w[dao_ren qi].freeze
|
|
28
|
+
|
|
29
|
+
# lifecycle statuses that participate in automatic context assembly.
|
|
30
|
+
CONTEXT_LIFECYCLE = %w[raw promoted].freeze
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def tier_for(type)
|
|
35
|
+
DEFAULT_TIER_BY_TYPE.fetch(type.to_s, 'evidence')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def priority(tier)
|
|
39
|
+
PRIORITY.fetch(tier.to_s, PRIORITY.fetch('evidence'))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def distillable?(tier)
|
|
43
|
+
DISTILLABLE.include?(tier.to_s)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def valid?(tier)
|
|
47
|
+
PRIORITY.key?(tier.to_s)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def dao_tian_limit(config)
|
|
51
|
+
(config.tiers.fetch(:dao_tian_limit, 1) rescue 1)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Sort by tier priority asc (dao_tian first); stable on equal priority.
|
|
55
|
+
def sort_by_tier(items)
|
|
56
|
+
items.sort_by.with_index { |i, idx| [priority(i[:tier] || i['tier'] || 'evidence'), idx] }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../governance/tiers'
|
|
4
|
+
|
|
3
5
|
module SmartBrain
|
|
4
6
|
module MemoryExtractor
|
|
5
7
|
class Extractor
|
|
@@ -16,11 +18,14 @@ module SmartBrain
|
|
|
16
18
|
collect(items, explain, events[:decisions], type: 'decisions', confidence: confidence(:user_asserted), source_turn_id: turn[:id])
|
|
17
19
|
collect(items, explain, events[:goals], type: 'goals', confidence: confidence(:user_asserted), source_turn_id: turn[:id])
|
|
18
20
|
collect(items, explain, events[:events], type: 'events', confidence: confidence(:tool_derived), source_turn_id: turn[:id])
|
|
21
|
+
collect(items, explain, events[:profile], type: 'profile', confidence: confidence(:user_asserted), source_turn_id: turn[:id])
|
|
22
|
+
collect(items, explain, events[:cases], type: 'cases', confidence: confidence(:tool_derived), source_turn_id: turn[:id])
|
|
23
|
+
collect(items, explain, events[:patterns], type: 'patterns', confidence: confidence(:inferred), source_turn_id: turn[:id])
|
|
19
24
|
|
|
20
25
|
Array(events[:preferences]).each do |preference|
|
|
21
26
|
key = preference.fetch(:key)
|
|
22
27
|
if preference[:confirmed]
|
|
23
|
-
items << build_item(type: 'preferences', key: key, value_json: preference, source_turn_id: turn[:id], confidence: confidence(:user_asserted))
|
|
28
|
+
items << build_item(type: 'preferences', key: key, value_json: preference, source_turn_id: turn[:id], confidence: confidence(:user_asserted), scope_ref: preference[:scope_ref])
|
|
24
29
|
explain << "write preferences:#{key}"
|
|
25
30
|
else
|
|
26
31
|
explain << "skip preferences:#{key} not confirmed"
|
|
@@ -32,7 +37,7 @@ module SmartBrain
|
|
|
32
37
|
canonical = (entity[:canonical] || entity[:name]).to_s.downcase
|
|
33
38
|
should_write = entity[:remember] || entity_structure_signal?(entity) || entity_frequencies[canonical] >= freq_threshold
|
|
34
39
|
if should_write
|
|
35
|
-
items << build_item(type: 'entities', key: key, value_json: entity, source_turn_id: turn[:id], confidence: confidence(:inferred))
|
|
40
|
+
items << build_item(type: 'entities', key: key, value_json: entity, source_turn_id: turn[:id], confidence: confidence(:inferred), scope_ref: entity[:scope_ref])
|
|
36
41
|
explain << "write entities:#{key}"
|
|
37
42
|
else
|
|
38
43
|
explain << "skip entities:#{key} below threshold"
|
|
@@ -40,14 +45,24 @@ module SmartBrain
|
|
|
40
45
|
end
|
|
41
46
|
|
|
42
47
|
Array(events[:retractions]).each do |retraction|
|
|
43
|
-
items << build_item(type: retraction.fetch(:type), key: retraction.fetch(:key), value_json: retraction, source_turn_id: turn[:id], confidence: confidence(:user_asserted), status: 'retracted')
|
|
48
|
+
items << build_item(type: retraction.fetch(:type), key: retraction.fetch(:key), value_json: retraction, source_turn_id: turn[:id], confidence: confidence(:user_asserted), status: 'retracted', scope_ref: retraction[:scope_ref])
|
|
44
49
|
explain << "retract #{retraction.fetch(:type)}:#{retraction.fetch(:key)}"
|
|
45
50
|
end
|
|
46
51
|
|
|
52
|
+
# KG edges: pass-through (subject/predicate/object). Orthogonal to
|
|
53
|
+
# memory_items — runtime persists them to kg_edges via KnowledgeGraph.
|
|
54
|
+
edges = Array(events[:edges]).map do |edge|
|
|
55
|
+
next unless edge[:subject] && edge[:predicate] && edge[:object]
|
|
56
|
+
|
|
57
|
+
{ subject: edge[:subject], predicate: edge[:predicate],
|
|
58
|
+
object: edge[:object], confidence: edge[:confidence], scope_ref: edge[:scope_ref] }
|
|
59
|
+
end.compact
|
|
60
|
+
|
|
47
61
|
{
|
|
48
62
|
session_id: session_id,
|
|
49
63
|
items: items,
|
|
50
|
-
explain: explain
|
|
64
|
+
explain: explain,
|
|
65
|
+
edges: edges
|
|
51
66
|
}
|
|
52
67
|
end
|
|
53
68
|
|
|
@@ -58,12 +73,12 @@ module SmartBrain
|
|
|
58
73
|
def collect(items, explain, raw_items, type:, confidence:, source_turn_id:)
|
|
59
74
|
Array(raw_items).each do |entry|
|
|
60
75
|
key = entry.fetch(:key)
|
|
61
|
-
items << build_item(type: type, key: key, value_json: entry, source_turn_id: source_turn_id, confidence: confidence)
|
|
76
|
+
items << build_item(type: type, key: key, value_json: entry, source_turn_id: source_turn_id, confidence: confidence, scope_ref: entry[:scope_ref])
|
|
62
77
|
explain << "write #{type}:#{key}"
|
|
63
78
|
end
|
|
64
79
|
end
|
|
65
80
|
|
|
66
|
-
def build_item(type:, key:, value_json:, source_turn_id:, confidence:, status: 'active')
|
|
81
|
+
def build_item(type:, key:, value_json:, source_turn_id:, confidence:, status: 'active', scope_ref: nil)
|
|
67
82
|
{
|
|
68
83
|
type: type,
|
|
69
84
|
key: key,
|
|
@@ -71,7 +86,10 @@ module SmartBrain
|
|
|
71
86
|
source_turn_id: source_turn_id,
|
|
72
87
|
confidence: confidence,
|
|
73
88
|
status: status,
|
|
74
|
-
|
|
89
|
+
tier: Governance::Tiers.tier_for(type),
|
|
90
|
+
lifecycle_status: 'raw',
|
|
91
|
+
updated_at: Time.now.utc.iso8601,
|
|
92
|
+
scope_ref: scope_ref
|
|
75
93
|
}
|
|
76
94
|
end
|
|
77
95
|
|