nexo-brain 5.3.20 → 5.3.21
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.
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/auto_update.py +11 -8
- package/src/dashboard/static/favicon 2.svg +32 -0
- package/src/dashboard/static/nexo-logo 2.png +0 -0
- package/src/dashboard/static/nexo-logo 2.svg +40 -0
- package/src/dashboard/static/style 2.css +2458 -0
- package/src/dashboard/templates/adaptive 2.html +118 -0
- package/src/dashboard/templates/artifacts 2.html +133 -0
- package/src/dashboard/templates/backups 2.html +136 -0
- package/src/dashboard/templates/base 2.html +417 -0
- package/src/dashboard/templates/calendar 2.html +591 -0
- package/src/dashboard/templates/chat 2.html +356 -0
- package/src/dashboard/templates/claims 2.html +259 -0
- package/src/dashboard/templates/cortex 2.html +321 -0
- package/src/dashboard/templates/credentials 2.html +128 -0
- package/src/dashboard/templates/crons 2.html +370 -0
- package/src/dashboard/templates/dashboard 2.html +494 -0
- package/src/dashboard/templates/dreams 2.html +252 -0
- package/src/dashboard/templates/email 2.html +160 -0
- package/src/dashboard/templates/evolution 2.html +189 -0
- package/src/dashboard/templates/feed 2.html +249 -0
- package/src/dashboard/templates/followup_health 2.html +170 -0
- package/src/dashboard/templates/graph 2.html +201 -0
- package/src/dashboard/templates/guard 2.html +259 -0
- package/src/dashboard/templates/inbox 2.html +251 -0
- package/src/dashboard/templates/memory 2.html +420 -0
- package/src/dashboard/templates/operations 2.html +608 -0
- package/src/dashboard/templates/plugins 2.html +185 -0
- package/src/dashboard/templates/protocol 2.html +199 -0
- package/src/dashboard/templates/rules 2.html +246 -0
- package/src/dashboard/templates/sentiment 2.html +247 -0
- package/src/dashboard/templates/sessions 2.html +218 -0
- package/src/dashboard/templates/skills 2.html +329 -0
- package/src/dashboard/templates/somatic 2.html +73 -0
- package/src/dashboard/templates/triggers 2.html +133 -0
- package/src/dashboard/templates/trust 2.html +360 -0
- package/src/db/__init__ 2.py +259 -0
- package/src/db/_core 2.py +437 -0
- package/src/db/_credentials 2.py +124 -0
- package/src/db/_episodic 2.py +762 -0
- package/src/db/_evolution 2.py +54 -0
- package/src/db/_fts 2.py +406 -0
- package/src/db/_goal_profiles 2.py +376 -0
- package/src/db/_hot_context 2.py +660 -0
- package/src/db/_outcomes 2.py +800 -0
- package/src/db/_personal_scripts 2.py +582 -0
- package/src/db/_sessions 2.py +330 -0
- package/src/db/_tasks 2.py +91 -0
- package/src/db/_watchers 2.py +173 -0
- package/src/doctor/formatters 2.py +52 -0
- package/src/doctor/models 2.py +69 -0
- package/src/doctor/planes 2.py +87 -0
- package/src/doctor/providers/__init__ 2.py +1 -0
- package/src/doctor/providers/deep 2.py +367 -0
- package/src/evolution_cycle 2.py +519 -0
- package/src/hooks/auto_capture 2.py +208 -0
- package/src/hooks/caffeinate-guard 2.sh +8 -0
- package/src/hooks/capture-session 2.sh +21 -0
- package/src/hooks/capture-tool-logs 2.sh +158 -0
- package/src/hooks/daily-briefing-check 2.sh +33 -0
- package/src/hooks/heartbeat-enforcement 2.py +90 -0
- package/src/hooks/heartbeat-posttool 2.sh +18 -0
- package/src/hooks/inbox-hook 2.sh +76 -0
- package/src/hooks/post-compact 2.sh +152 -0
- package/src/hooks/pre-compact 2.sh +169 -0
- package/src/hooks/protocol-guardrail 2.sh +10 -0
- package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
- package/src/hooks/session-stop 2.sh +52 -0
- package/src/kg_populate 2.py +292 -0
- package/src/maintenance 2.py +53 -0
- package/src/memory_backends 2.py +71 -0
- package/src/migrate_embeddings 2.py +124 -0
- package/src/nexo_sdk 2.py +103 -0
- package/src/observability 2.py +199 -0
- package/src/plugin_loader 2.py +217 -0
- package/src/plugins/__init__ 2.py +0 -0
- package/src/plugins/artifact_registry 2.py +450 -0
- package/src/plugins/backup 2.py +127 -0
- package/src/plugins/claims_tools 2.py +119 -0
- package/src/plugins/cognitive_memory 2.py +609 -0
- package/src/plugins/core_rules 2.py +252 -0
- package/src/plugins/cortex 2.py +1155 -0
- package/src/plugins/entities 2.py +67 -0
- package/src/plugins/episodic_memory 2.py +560 -0
- package/src/plugins/evolution 2.py +167 -0
- package/src/plugins/goal_engine 2.py +142 -0
- package/src/plugins/guard 2.py +862 -0
- package/src/plugins/impact 2.py +29 -0
- package/src/plugins/knowledge_graph_tools 2.py +137 -0
- package/src/plugins/media_memory_tools 2.py +98 -0
- package/src/plugins/memory_export 2.py +196 -0
- package/src/plugins/outcomes 2.py +130 -0
- package/src/plugins/personal_scripts 2.py +117 -0
- package/src/plugins/preferences 2.py +47 -0
- package/src/plugins/protocol 2.py +1449 -0
- package/src/plugins/simple_api 2.py +106 -0
- package/src/plugins/skills 2.py +341 -0
- package/src/plugins/state_watchers 2.py +79 -0
- package/src/plugins/update 2.py +986 -0
- package/src/plugins/user_state_tools 2.py +43 -0
- package/src/plugins/workflow 2.py +588 -0
- package/src/protocol_settings 2.py +59 -0
- package/src/public_contribution 2.py +466 -0
- package/src/public_evolution_queue 2.py +241 -0
- package/src/requirements 2.txt +14 -0
- package/src/retroactive_learnings 2.py +373 -0
- package/src/rules/__init__ 2.py +0 -0
- package/src/rules/core-rules 2.json +331 -0
- package/src/rules/migrate 2.py +207 -0
- package/src/runtime_power 2.py +874 -0
- package/src/script_registry 2.py +1559 -0
- package/src/scripts/check-context 2.py +272 -0
- package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
- package/src/scripts/deep-sleep/collect 2.py +928 -0
- package/src/scripts/deep-sleep/extract 2.py +330 -0
- package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
- package/src/scripts/deep-sleep/synthesize 2.py +312 -0
- package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
- package/src/scripts/nexo-agent-run 2.py +75 -0
- package/src/scripts/nexo-auto-update 2.py +6 -0
- package/src/scripts/nexo-backup 2.sh +25 -0
- package/src/scripts/nexo-brain-activation 2.sh +140 -0
- package/src/scripts/nexo-catchup 2.py +300 -0
- package/src/scripts/nexo-cognitive-decay 2.py +257 -0
- package/src/scripts/nexo-cortex-cycle 2.py +293 -0
- package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
- package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
- package/src/scripts/nexo-dashboard 2.sh +29 -0
- package/src/scripts/nexo-deep-sleep 2.sh +86 -0
- package/src/scripts/nexo-evolution-run 2.py +1664 -0
- package/src/scripts/nexo-followup-hygiene 2.py +139 -0
- package/src/scripts/nexo-hook-record 2.py +42 -0
- package/src/scripts/nexo-immune 2.py +936 -0
- package/src/scripts/nexo-impact-scorer 2.py +117 -0
- package/src/scripts/nexo-inbox-hook 2.sh +74 -0
- package/src/scripts/nexo-install 2.py +6 -0
- package/src/scripts/nexo-learning-housekeep 2.py +401 -0
- package/src/scripts/nexo-learning-validator 2.py +266 -0
- package/src/scripts/nexo-migrate 2.py +260 -0
- package/src/scripts/nexo-outcome-checker 2.py +127 -0
- package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
- package/src/scripts/nexo-pre-commit 2.py +120 -0
- package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
- package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
- package/src/scripts/nexo-reflection 2.py +256 -0
- package/src/scripts/nexo-runtime-preflight 2.py +274 -0
- package/src/scripts/nexo-sleep 2.py +631 -0
- package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
- package/src/scripts/nexo-sync-clients 2.py +16 -0
- package/src/scripts/nexo-synthesis 2.py +475 -0
- package/src/scripts/nexo-tcc-approve 2.sh +79 -0
- package/src/scripts/nexo-update 2.sh +306 -0
- package/src/scripts/nexo-watchdog 2.sh +1207 -0
- package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
- package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
- package/src/server 2.py +1296 -0
- package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
- package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
- package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
- package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
- package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
- package/src/skills/run-release-final-audit/guide 2.md +16 -0
- package/src/skills/run-release-final-audit/script 2.py +259 -0
- package/src/skills/run-release-final-audit/skill 2.json +77 -0
- package/src/skills/run-runtime-doctor/guide 2.md +12 -0
- package/src/skills/run-runtime-doctor/script 2.py +21 -0
- package/src/skills/run-runtime-doctor/skill 2.json +25 -0
- package/src/skills_runtime 2.py +932 -0
- package/src/state_watchers_runtime 2.py +475 -0
- package/src/storage_router 2.py +32 -0
- package/src/system_catalog 2.py +786 -0
- package/src/tools_coordination 2.py +103 -0
- package/src/tools_credentials 2.py +68 -0
- package/src/tools_drive 2.py +487 -0
- package/src/tools_hot_context 2.py +163 -0
- package/src/tools_learnings 2.py +612 -0
- package/src/tools_menu 2.py +229 -0
- package/src/tools_reminders 2.py +88 -0
- package/src/tools_reminders_crud 2.py +363 -0
- package/src/tools_sessions 2.py +1054 -0
- package/src/tools_system_catalog 2.py +19 -0
- package/src/tools_task_history 2.py +57 -0
- package/src/tools_transcripts 2.py +98 -0
- package/src/transcript_utils 2.py +412 -0
- package/src/user_context 2.py +46 -0
- package/src/user_data_portability 2.py +328 -0
- package/src/user_state_model 2.py +170 -0
- package/templates/CLAUDE.md 2.template +108 -0
- package/templates/CODEX.AGENTS.md 2.template +66 -0
- package/templates/launchagents/README 2.md +132 -0
- package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
- package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
- package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
- package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
- package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
- package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
- package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
- package/templates/launchagents/com.nexo.immune 2.plist +41 -0
- package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
- package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
- package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
- package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
- package/templates/nexo_helper 2.py +301 -0
- package/templates/openclaw 2.json +13 -0
- package/templates/plugin-template 2.py +40 -0
- package/templates/script-template 2.py +59 -0
- package/templates/script-template 2.sh +13 -0
- package/templates/skill-script-template 2.py +48 -0
- package/templates/skill-template 2.md +33 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
"""Learnings CRUD tools: add, search, update, delete, list."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from db import (create_learning, update_learning, delete_learning, search_learnings,
|
|
7
|
+
list_learnings, find_similar_learnings, get_db, now_epoch, supersede_learning)
|
|
8
|
+
|
|
9
|
+
NEGATION_PATTERNS = (
|
|
10
|
+
"do not", "don't", "never", "avoid", "skip", "without", "forbid", "forbidden",
|
|
11
|
+
"disable", "disabled", "remove", "ban", "bypass",
|
|
12
|
+
)
|
|
13
|
+
CONTRADICTION_PAIRS = (
|
|
14
|
+
("enable", "disable"),
|
|
15
|
+
("use", "avoid"),
|
|
16
|
+
("add", "remove"),
|
|
17
|
+
("allow", "forbid"),
|
|
18
|
+
("always", "never"),
|
|
19
|
+
("before", "after"),
|
|
20
|
+
("require", "skip"),
|
|
21
|
+
("validate", "skip"),
|
|
22
|
+
("validate", "bypass"),
|
|
23
|
+
("include", "exclude"),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _split_applies_to(applies_to: str) -> list[str]:
|
|
28
|
+
return [item.strip() for item in str(applies_to or "").split(",") if item.strip()]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _normalize_applies_token(value: str) -> str:
|
|
32
|
+
return str(value or "").replace("\\", "/").rstrip("/").lower()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _applies_overlap(left: str, right: str) -> bool:
|
|
36
|
+
left_tokens = {_normalize_applies_token(item) for item in _split_applies_to(left)}
|
|
37
|
+
right_tokens = {_normalize_applies_token(item) for item in _split_applies_to(right)}
|
|
38
|
+
left_tokens.discard("")
|
|
39
|
+
right_tokens.discard("")
|
|
40
|
+
if not left_tokens or not right_tokens:
|
|
41
|
+
return False
|
|
42
|
+
if left_tokens & right_tokens:
|
|
43
|
+
return True
|
|
44
|
+
for left_token in left_tokens:
|
|
45
|
+
for right_token in right_tokens:
|
|
46
|
+
if "/" in left_token or "/" in right_token:
|
|
47
|
+
if left_token.startswith(f"{right_token}/") or right_token.startswith(f"{left_token}/"):
|
|
48
|
+
return True
|
|
49
|
+
if left_token.endswith(f"/{right_token}") or right_token.endswith(f"/{left_token}"):
|
|
50
|
+
return True
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _normalize_text(text: str) -> str:
|
|
55
|
+
return re.sub(r"\s+", " ", str(text or "").strip().lower())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _tokenize(text: str) -> list[str]:
|
|
59
|
+
return re.findall(r"[a-z0-9_-]+", _normalize_text(text))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _contains_negation(text: str) -> bool:
|
|
63
|
+
lowered = _normalize_text(text)
|
|
64
|
+
return any(token in lowered for token in NEGATION_PATTERNS)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _negated_action_verbs(text: str) -> set[str]:
|
|
68
|
+
lowered = _normalize_text(text)
|
|
69
|
+
matches = set()
|
|
70
|
+
for pattern in (
|
|
71
|
+
r"(?:never|avoid|skip|disable|remove|forbid|bypass)\s+([a-z0-9_-]+)",
|
|
72
|
+
r"(?:do not|don't)\s+([a-z0-9_-]+)",
|
|
73
|
+
):
|
|
74
|
+
matches.update(re.findall(pattern, lowered))
|
|
75
|
+
return {match for match in matches if len(match) > 2}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _looks_contradictory(existing_text: str, new_text: str) -> bool:
|
|
79
|
+
existing_norm = _normalize_text(existing_text)
|
|
80
|
+
new_norm = _normalize_text(new_text)
|
|
81
|
+
if not existing_norm or not new_norm:
|
|
82
|
+
return False
|
|
83
|
+
existing_tokens = set(_tokenize(existing_norm))
|
|
84
|
+
new_tokens = set(_tokenize(new_norm))
|
|
85
|
+
if not (existing_tokens & new_tokens):
|
|
86
|
+
return False
|
|
87
|
+
existing_negated_verbs = _negated_action_verbs(existing_norm)
|
|
88
|
+
new_negated_verbs = _negated_action_verbs(new_norm)
|
|
89
|
+
if existing_negated_verbs & new_tokens and not existing_negated_verbs & new_negated_verbs:
|
|
90
|
+
return True
|
|
91
|
+
if new_negated_verbs & existing_tokens and not existing_negated_verbs & new_negated_verbs:
|
|
92
|
+
return True
|
|
93
|
+
if _contains_negation(existing_norm) != _contains_negation(new_norm):
|
|
94
|
+
return True
|
|
95
|
+
for positive, negative in CONTRADICTION_PAIRS:
|
|
96
|
+
existing_has_pair = positive in existing_norm or negative in existing_norm
|
|
97
|
+
new_has_pair = positive in new_norm or negative in new_norm
|
|
98
|
+
if existing_has_pair and new_has_pair:
|
|
99
|
+
if (positive in existing_norm and negative in new_norm) or (negative in existing_norm and positive in new_norm):
|
|
100
|
+
return True
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _find_conflicting_active_learning(conn, *, category: str, title: str, content: str,
|
|
105
|
+
applies_to: str, exclude_id: int | None = None) -> dict | None:
|
|
106
|
+
if not applies_to:
|
|
107
|
+
return None
|
|
108
|
+
params = [category]
|
|
109
|
+
sql = (
|
|
110
|
+
"SELECT id, title, content, applies_to FROM learnings "
|
|
111
|
+
"WHERE category = ? AND status = 'active' AND COALESCE(applies_to, '') != ''"
|
|
112
|
+
)
|
|
113
|
+
if exclude_id is not None:
|
|
114
|
+
sql += " AND id != ?"
|
|
115
|
+
params.append(exclude_id)
|
|
116
|
+
rows = conn.execute(sql, tuple(params)).fetchall()
|
|
117
|
+
incoming_text = f"{title} {content}"
|
|
118
|
+
for row in rows:
|
|
119
|
+
if not _applies_overlap(row["applies_to"], applies_to):
|
|
120
|
+
continue
|
|
121
|
+
if _looks_contradictory(f"{row['title']} {row['content']}", incoming_text):
|
|
122
|
+
return dict(row)
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def find_conflicting_active_learning(*, category: str, title: str, content: str,
|
|
127
|
+
applies_to: str, exclude_id: int | None = None) -> dict | None:
|
|
128
|
+
"""Public wrapper for canonical-rule enforcement on file-scoped learnings."""
|
|
129
|
+
conn = get_db()
|
|
130
|
+
return _find_conflicting_active_learning(
|
|
131
|
+
conn,
|
|
132
|
+
category=category.lower().strip(),
|
|
133
|
+
title=title,
|
|
134
|
+
content=content,
|
|
135
|
+
applies_to=applies_to,
|
|
136
|
+
exclude_id=exclude_id,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _priority_score(priority: str) -> float:
|
|
141
|
+
return {
|
|
142
|
+
"critical": 1.0,
|
|
143
|
+
"high": 0.85,
|
|
144
|
+
"medium": 0.65,
|
|
145
|
+
"low": 0.45,
|
|
146
|
+
}.get(str(priority or "medium").strip().lower(), 0.65)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _parse_timestamp(value) -> float:
|
|
150
|
+
if isinstance(value, (int, float)):
|
|
151
|
+
return float(value)
|
|
152
|
+
text = str(value or "").strip()
|
|
153
|
+
if not text:
|
|
154
|
+
return 0.0
|
|
155
|
+
try:
|
|
156
|
+
return float(text)
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
for fmt in (None, "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
|
|
160
|
+
try:
|
|
161
|
+
if fmt is None:
|
|
162
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
|
|
163
|
+
return datetime.strptime(text, fmt).timestamp()
|
|
164
|
+
except Exception:
|
|
165
|
+
continue
|
|
166
|
+
return 0.0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _recency_score(row: dict) -> float:
|
|
170
|
+
reference = _parse_timestamp(row.get("last_guard_hit_at")) or _parse_timestamp(row.get("updated_at")) or _parse_timestamp(row.get("created_at"))
|
|
171
|
+
if not reference:
|
|
172
|
+
return 0.35
|
|
173
|
+
age_days = max(0.0, (now_epoch() - reference) / 86400.0)
|
|
174
|
+
if age_days <= 7:
|
|
175
|
+
return 1.0
|
|
176
|
+
if age_days <= 30:
|
|
177
|
+
return 0.8
|
|
178
|
+
if age_days <= 90:
|
|
179
|
+
return 0.6
|
|
180
|
+
if age_days <= 180:
|
|
181
|
+
return 0.4
|
|
182
|
+
return 0.25
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _usefulness_score(row: dict) -> float:
|
|
186
|
+
hits = int(row.get("guard_hits") or 0)
|
|
187
|
+
if hits >= 5:
|
|
188
|
+
return 1.0
|
|
189
|
+
if hits >= 3:
|
|
190
|
+
return 0.85
|
|
191
|
+
if hits >= 1:
|
|
192
|
+
return 0.65
|
|
193
|
+
return 0.35 if str(row.get("status") or "active") == "active" else 0.15
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _source_richness_score(row: dict) -> float:
|
|
197
|
+
parts = 0.0
|
|
198
|
+
if str(row.get("reasoning") or "").strip():
|
|
199
|
+
parts += 0.25
|
|
200
|
+
if str(row.get("prevention") or "").strip():
|
|
201
|
+
parts += 0.25
|
|
202
|
+
if str(row.get("applies_to") or "").strip():
|
|
203
|
+
parts += 0.2
|
|
204
|
+
if row.get("review_due_at"):
|
|
205
|
+
parts += 0.15
|
|
206
|
+
if len(str(row.get("content") or "").strip()) >= 80:
|
|
207
|
+
parts += 0.15
|
|
208
|
+
return min(1.0, parts)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _contradiction_pressure_score(conn, row: dict) -> float:
|
|
212
|
+
if str(row.get("status") or "active") != "active":
|
|
213
|
+
return 0.7
|
|
214
|
+
conflicting = _find_conflicting_active_learning(
|
|
215
|
+
conn,
|
|
216
|
+
category=str(row.get("category") or ""),
|
|
217
|
+
title=str(row.get("title") or ""),
|
|
218
|
+
content=str(row.get("content") or ""),
|
|
219
|
+
applies_to=str(row.get("applies_to") or ""),
|
|
220
|
+
exclude_id=int(row.get("id") or 0),
|
|
221
|
+
)
|
|
222
|
+
return 0.0 if conflicting else 1.0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def score_learning_quality(row: dict, conn=None) -> dict:
|
|
226
|
+
"""Compute a 0-100 quality score for a learning using usefulness and conflict pressure."""
|
|
227
|
+
conn = conn or get_db()
|
|
228
|
+
confidence = min(1.0, (_priority_score(row.get("priority")) * 0.45) + (float(row.get("weight") or 0.5) * 0.55))
|
|
229
|
+
usefulness = _usefulness_score(row)
|
|
230
|
+
recency = _recency_score(row)
|
|
231
|
+
contradiction = _contradiction_pressure_score(conn, row)
|
|
232
|
+
source_richness = _source_richness_score(row)
|
|
233
|
+
status = str(row.get("status") or "active")
|
|
234
|
+
status_multiplier = 1.0 if status == "active" else 0.65 if status in {"pending_review", "review"} else 0.45
|
|
235
|
+
overall = (
|
|
236
|
+
confidence * 0.28
|
|
237
|
+
+ usefulness * 0.24
|
|
238
|
+
+ recency * 0.18
|
|
239
|
+
+ contradiction * 0.18
|
|
240
|
+
+ source_richness * 0.12
|
|
241
|
+
) * status_multiplier
|
|
242
|
+
score = max(0, min(100, round(overall * 100)))
|
|
243
|
+
if score >= 80:
|
|
244
|
+
label = "strong"
|
|
245
|
+
elif score >= 60:
|
|
246
|
+
label = "usable"
|
|
247
|
+
elif score >= 40:
|
|
248
|
+
label = "weak"
|
|
249
|
+
else:
|
|
250
|
+
label = "fragile"
|
|
251
|
+
return {
|
|
252
|
+
"score": score,
|
|
253
|
+
"label": label,
|
|
254
|
+
"confidence": round(confidence * 100),
|
|
255
|
+
"usefulness": round(usefulness * 100),
|
|
256
|
+
"recency_relevance": round(recency * 100),
|
|
257
|
+
"contradiction_pressure": round((1.0 - contradiction) * 100),
|
|
258
|
+
"source_richness": round(source_richness * 100),
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def handle_learning_add(category: str, title: str, content: str, reasoning: str = '',
|
|
263
|
+
prevention: str = '', applies_to: str = '', review_days: int = 30,
|
|
264
|
+
priority: str = 'medium', supersedes_id: int = 0) -> str:
|
|
265
|
+
"""Add a new learning entry to the specified category.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
category: Free-form category name (e.g., 'backend', 'frontend', 'devops', 'infrastructure', 'security', 'nexo-ops'). Use consistent names — learnings are grouped and searched by category.
|
|
269
|
+
title: Short title for the learning
|
|
270
|
+
content: Full description of what was learned
|
|
271
|
+
reasoning: WHY this matters — what led to discovering this, what was the context
|
|
272
|
+
prevention: Concrete rule/check that prevents repeating this mistake
|
|
273
|
+
applies_to: Files, systems, or areas this learning applies to
|
|
274
|
+
review_days: Days until this learning should be reviewed again
|
|
275
|
+
priority: critical, high, medium, low (default: medium)
|
|
276
|
+
"""
|
|
277
|
+
if priority not in ('critical', 'high', 'medium', 'low'):
|
|
278
|
+
priority = 'medium'
|
|
279
|
+
category = category.lower().strip()
|
|
280
|
+
if not category:
|
|
281
|
+
return "ERROR: Category cannot be empty."
|
|
282
|
+
# Dedup guard: block exact title duplicates in same category
|
|
283
|
+
conn = get_db()
|
|
284
|
+
existing = conn.execute(
|
|
285
|
+
"SELECT id, title FROM learnings WHERE LOWER(title) = LOWER(?) AND category = ? AND status = 'active'",
|
|
286
|
+
(title.strip(), category)
|
|
287
|
+
).fetchone()
|
|
288
|
+
if existing:
|
|
289
|
+
return f"Learning #{existing['id']} already exists with same title in {category}: {existing['title']}. Use nexo_learning_update to modify it."
|
|
290
|
+
conflicting = _find_conflicting_active_learning(
|
|
291
|
+
conn,
|
|
292
|
+
category=category,
|
|
293
|
+
title=title,
|
|
294
|
+
content=content,
|
|
295
|
+
applies_to=applies_to,
|
|
296
|
+
)
|
|
297
|
+
if conflicting and int(supersedes_id or 0) != int(conflicting["id"]):
|
|
298
|
+
return (
|
|
299
|
+
f"ERROR: Contradictory active learning #{conflicting['id']} already exists for applies_to="
|
|
300
|
+
f"{conflicting.get('applies_to', '')}: {conflicting['title']}. "
|
|
301
|
+
f"Supersede or update the existing canonical rule instead of creating two active file rules."
|
|
302
|
+
)
|
|
303
|
+
result = create_learning(
|
|
304
|
+
category, title, content, reasoning=reasoning, supersedes_id=(int(supersedes_id) if supersedes_id else None)
|
|
305
|
+
)
|
|
306
|
+
if "error" in result:
|
|
307
|
+
return f"ERROR: {result['error']}"
|
|
308
|
+
if prevention or applies_to or review_days > 0 or priority != 'medium':
|
|
309
|
+
initial_weight = {'critical': 0.9, 'high': 0.7, 'medium': 0.5, 'low': 0.3}[priority]
|
|
310
|
+
updated_at = now_epoch()
|
|
311
|
+
review_due_at = now_epoch() + (max(1, int(review_days)) * 86400)
|
|
312
|
+
conn = get_db()
|
|
313
|
+
conn.execute(
|
|
314
|
+
"UPDATE learnings SET prevention = ?, applies_to = ?, status = COALESCE(status, 'active'), "
|
|
315
|
+
"review_due_at = ?, updated_at = ?, priority = ?, weight = ? WHERE id = ?",
|
|
316
|
+
(prevention, applies_to, review_due_at, updated_at,
|
|
317
|
+
priority, initial_weight, result["id"])
|
|
318
|
+
)
|
|
319
|
+
conn.commit()
|
|
320
|
+
result = dict(result)
|
|
321
|
+
result.update({
|
|
322
|
+
"prevention": prevention,
|
|
323
|
+
"applies_to": applies_to,
|
|
324
|
+
"status": result.get("status") or "active",
|
|
325
|
+
"review_due_at": review_due_at,
|
|
326
|
+
"updated_at": updated_at,
|
|
327
|
+
"priority": priority,
|
|
328
|
+
"weight": initial_weight,
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
# Cognitive ingest — embed learning for semantic search
|
|
332
|
+
new_id = result["id"]
|
|
333
|
+
try:
|
|
334
|
+
import cognitive
|
|
335
|
+
cognitive.ingest(f"{title}: {content}", "learning", f"L{new_id}", title, category)
|
|
336
|
+
except Exception:
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
# Similarity check — detect repeated errors
|
|
340
|
+
matches = find_similar_learnings(new_id, title, content, category)
|
|
341
|
+
repetition_msg = ""
|
|
342
|
+
if matches:
|
|
343
|
+
conn = get_db()
|
|
344
|
+
for original_id, similarity in matches:
|
|
345
|
+
conn.execute(
|
|
346
|
+
"INSERT INTO error_repetitions (new_learning_id, original_learning_id, similarity, area) VALUES (?,?,?,?)",
|
|
347
|
+
(new_id, original_id, similarity, category)
|
|
348
|
+
)
|
|
349
|
+
conn.commit()
|
|
350
|
+
repetition_msg = f"\n⚠️ REPETITION WARNING: Similar to {len(matches)} existing learning(s): " + \
|
|
351
|
+
", ".join(f"#{m[0]} ({m[1]:.0%})" for m in matches[:3])
|
|
352
|
+
|
|
353
|
+
# Somatic event logging (append-only in nexo.db, projected to cognitive.db nightly)
|
|
354
|
+
try:
|
|
355
|
+
if applies_to:
|
|
356
|
+
for file_path in [f.strip() for f in applies_to.split(",") if f.strip()]:
|
|
357
|
+
get_db().execute(
|
|
358
|
+
"INSERT INTO somatic_events (target, target_type, event_type, delta, source) VALUES (?, ?, ?, ?, ?)",
|
|
359
|
+
(file_path, "file", "learning_add", 0.15, f"learning:{new_id}")
|
|
360
|
+
)
|
|
361
|
+
# Area + extra file pain ONLY for repeated errors
|
|
362
|
+
if matches:
|
|
363
|
+
get_db().execute(
|
|
364
|
+
"INSERT INTO somatic_events (target, target_type, event_type, delta, source) VALUES (?, ?, ?, ?, ?)",
|
|
365
|
+
(category, "area", "error_repetition", 0.15, f"learning:{new_id}")
|
|
366
|
+
)
|
|
367
|
+
if applies_to:
|
|
368
|
+
for file_path in [f.strip() for f in applies_to.split(",") if f.strip()]:
|
|
369
|
+
get_db().execute(
|
|
370
|
+
"INSERT INTO somatic_events (target, target_type, event_type, delta, source) VALUES (?, ?, ?, ?, ?)",
|
|
371
|
+
(file_path, "file", "error_repetition", 0.25, f"learning:{new_id}")
|
|
372
|
+
)
|
|
373
|
+
get_db().commit()
|
|
374
|
+
except Exception:
|
|
375
|
+
pass # Somatic event logging is best-effort
|
|
376
|
+
|
|
377
|
+
# Knowledge graph incremental population
|
|
378
|
+
try:
|
|
379
|
+
from kg_populate import on_learning_add
|
|
380
|
+
on_learning_add(new_id, category, title, applies_to)
|
|
381
|
+
except Exception:
|
|
382
|
+
pass
|
|
383
|
+
|
|
384
|
+
if supersedes_id:
|
|
385
|
+
superseded = supersede_learning(int(supersedes_id), new_id, f"Superseded by learning #{new_id}.")
|
|
386
|
+
if "error" in superseded:
|
|
387
|
+
return f"ERROR: Learning #{new_id} created but supersede failed: {superseded['error']}"
|
|
388
|
+
|
|
389
|
+
# Post-insert verification: confirm the learning actually persisted
|
|
390
|
+
verify_conn = get_db()
|
|
391
|
+
verified = verify_conn.execute(
|
|
392
|
+
"SELECT id, title, category FROM learnings WHERE id = ? AND status = 'active'",
|
|
393
|
+
(result["id"],)
|
|
394
|
+
).fetchone()
|
|
395
|
+
if not verified:
|
|
396
|
+
return (
|
|
397
|
+
f"⚠ PERSISTENCE FAILURE: Learning #{result['id']} was inserted but NOT found on verification read. "
|
|
398
|
+
f"Retry nexo_learning_add or investigate DB integrity."
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# Fase 2 item 3: when a learning lands with an enforceable prevention
|
|
402
|
+
# rule, scan recent decisions for ones that would have been decided
|
|
403
|
+
# differently and surface them as deterministic followups. Best-effort:
|
|
404
|
+
# a failure here must NEVER block the learning insert that the user
|
|
405
|
+
# already verified above.
|
|
406
|
+
retro_meta_msg = ""
|
|
407
|
+
if prevention:
|
|
408
|
+
try:
|
|
409
|
+
from retroactive_learnings import apply_learning_retroactively
|
|
410
|
+
retro_result = apply_learning_retroactively(
|
|
411
|
+
int(result["id"]),
|
|
412
|
+
lookback_days=14,
|
|
413
|
+
max_matches=5,
|
|
414
|
+
min_score=0.4,
|
|
415
|
+
)
|
|
416
|
+
if retro_result.get("followups_created"):
|
|
417
|
+
retro_meta_msg = (
|
|
418
|
+
f"\n📜 Retroactive scan: {retro_result['followups_created']} "
|
|
419
|
+
f"past decision(s) flagged for review (scanned "
|
|
420
|
+
f"{retro_result.get('scanned', 0)})"
|
|
421
|
+
)
|
|
422
|
+
except Exception:
|
|
423
|
+
pass # Best-effort surfacing only
|
|
424
|
+
|
|
425
|
+
meta = []
|
|
426
|
+
if prevention:
|
|
427
|
+
meta.append("with prevention")
|
|
428
|
+
if applies_to:
|
|
429
|
+
meta.append(f"applies_to={applies_to}")
|
|
430
|
+
if supersedes_id:
|
|
431
|
+
meta.append(f"supersedes={int(supersedes_id)}")
|
|
432
|
+
meta_str = f" ({', '.join(meta)})" if meta else ""
|
|
433
|
+
return f"Learning #{result['id']} added in {category}: {title}{meta_str} ✓verified{repetition_msg}{retro_meta_msg}"
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def handle_learning_search(query: str, category: str = '') -> str:
|
|
437
|
+
"""Search learnings by query string, optionally filtered by category."""
|
|
438
|
+
results = search_learnings(query, category if category else None)
|
|
439
|
+
if not results:
|
|
440
|
+
return f"No results for '{query}'."
|
|
441
|
+
lines = [f"RESULTS ({len(results)}):"]
|
|
442
|
+
conn = get_db()
|
|
443
|
+
for r in results:
|
|
444
|
+
snippet = r["content"][:100] + "..." if len(r["content"]) > 100 else r["content"]
|
|
445
|
+
status = r.get("status", "active")
|
|
446
|
+
review_due = r.get("review_due_at")
|
|
447
|
+
review_note = f" | review_due={review_due:.0f}" if isinstance(review_due, (int, float)) and review_due else ""
|
|
448
|
+
pri = r.get("priority", "medium") or "medium"
|
|
449
|
+
w = r.get("weight", 0.5) or 0.5
|
|
450
|
+
quality = score_learning_quality(r, conn)
|
|
451
|
+
pri_icon = {"critical": "🔴", "high": "🟠", "medium": "🟡", "low": "⚪"}.get(pri, "🟡")
|
|
452
|
+
lines.append(f" #{r['id']} [{r['category']}] [{status}] {pri_icon}{pri} w={w:.2f} q={quality['score']} {r['title']}{review_note}")
|
|
453
|
+
lines.append(f" {snippet}")
|
|
454
|
+
if r.get("prevention"):
|
|
455
|
+
lines.append(f" Prevention: {r['prevention'][:100]}")
|
|
456
|
+
|
|
457
|
+
# v1.2: Passive rehearsal — strengthen matching cognitive memories
|
|
458
|
+
try:
|
|
459
|
+
import cognitive
|
|
460
|
+
for r in results[:5]:
|
|
461
|
+
cognitive.rehearse_by_content(f"{r.get('title', '')} {r.get('content', '')[:200]}")
|
|
462
|
+
except Exception:
|
|
463
|
+
pass
|
|
464
|
+
|
|
465
|
+
return "\n".join(lines)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def handle_learning_update(id: int, title: str = '', content: str = '', category: str = '',
|
|
469
|
+
reasoning: str = '', prevention: str = '', applies_to: str = '',
|
|
470
|
+
status: str = '', review_days: int = 0, priority: str = '',
|
|
471
|
+
supersedes_id: int = 0) -> str:
|
|
472
|
+
"""Update an existing learning, including review metadata and priority."""
|
|
473
|
+
conn = get_db()
|
|
474
|
+
current = conn.execute("SELECT * FROM learnings WHERE id = ?", (id,)).fetchone()
|
|
475
|
+
if not current:
|
|
476
|
+
return f"ERROR: Learning #{id} not found."
|
|
477
|
+
kwargs = {}
|
|
478
|
+
if title:
|
|
479
|
+
kwargs["title"] = title
|
|
480
|
+
if content:
|
|
481
|
+
kwargs["content"] = content
|
|
482
|
+
if category:
|
|
483
|
+
kwargs["category"] = category.lower().strip()
|
|
484
|
+
if reasoning:
|
|
485
|
+
kwargs["reasoning"] = reasoning
|
|
486
|
+
if prevention:
|
|
487
|
+
kwargs["prevention"] = prevention
|
|
488
|
+
if applies_to:
|
|
489
|
+
kwargs["applies_to"] = applies_to
|
|
490
|
+
if status:
|
|
491
|
+
kwargs["status"] = status
|
|
492
|
+
if review_days > 0:
|
|
493
|
+
kwargs["review_days"] = review_days
|
|
494
|
+
if not kwargs:
|
|
495
|
+
return "ERROR: Nothing to update. Provide new fields."
|
|
496
|
+
effective_category = kwargs.get("category", current["category"])
|
|
497
|
+
effective_title = kwargs.get("title", current["title"])
|
|
498
|
+
effective_content = kwargs.get("content", current["content"])
|
|
499
|
+
effective_applies_to = kwargs.get("applies_to", current["applies_to"])
|
|
500
|
+
effective_status = kwargs.get("status", current["status"])
|
|
501
|
+
if effective_status != "superseded":
|
|
502
|
+
conflicting = _find_conflicting_active_learning(
|
|
503
|
+
conn,
|
|
504
|
+
category=effective_category,
|
|
505
|
+
title=effective_title,
|
|
506
|
+
content=effective_content,
|
|
507
|
+
applies_to=effective_applies_to,
|
|
508
|
+
exclude_id=id,
|
|
509
|
+
)
|
|
510
|
+
if conflicting and int(supersedes_id or 0) != int(conflicting["id"]):
|
|
511
|
+
return (
|
|
512
|
+
f"ERROR: Update would conflict with active learning #{conflicting['id']} "
|
|
513
|
+
f"for applies_to={conflicting.get('applies_to', '')}. "
|
|
514
|
+
f"Supersede the old rule or merge into one canonical learning."
|
|
515
|
+
)
|
|
516
|
+
basic_kwargs = {k: v for k, v in kwargs.items() if k in {"title", "content", "category", "reasoning"}}
|
|
517
|
+
result = update_learning(id, **basic_kwargs)
|
|
518
|
+
if "error" in result:
|
|
519
|
+
return f"ERROR: {result['error']}"
|
|
520
|
+
extra_updates = {}
|
|
521
|
+
if prevention:
|
|
522
|
+
extra_updates["prevention"] = prevention
|
|
523
|
+
if applies_to:
|
|
524
|
+
extra_updates["applies_to"] = applies_to
|
|
525
|
+
if status:
|
|
526
|
+
extra_updates["status"] = status
|
|
527
|
+
if priority and priority in ('critical', 'high', 'medium', 'low'):
|
|
528
|
+
extra_updates["priority"] = priority
|
|
529
|
+
extra_updates["weight"] = {'critical': 0.9, 'high': 0.7, 'medium': 0.5, 'low': 0.3}[priority]
|
|
530
|
+
if review_days > 0:
|
|
531
|
+
extra_updates["review_due_at"] = now_epoch() + (max(1, int(review_days)) * 86400)
|
|
532
|
+
if extra_updates:
|
|
533
|
+
extra_updates["updated_at"] = now_epoch()
|
|
534
|
+
set_clause = ", ".join(f"{k} = ?" for k in extra_updates)
|
|
535
|
+
values = list(extra_updates.values()) + [id]
|
|
536
|
+
conn = get_db()
|
|
537
|
+
conn.execute(f"UPDATE learnings SET {set_clause} WHERE id = ?", values)
|
|
538
|
+
conn.commit()
|
|
539
|
+
if supersedes_id:
|
|
540
|
+
superseded = supersede_learning(int(supersedes_id), id, f"Superseded by learning #{id}.")
|
|
541
|
+
if "error" in superseded:
|
|
542
|
+
return f"ERROR: Learning #{id} updated but supersede failed: {superseded['error']}"
|
|
543
|
+
return f"Learning #{id} updated."
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def handle_learning_delete(id: int) -> str:
|
|
547
|
+
"""Delete a learning entry by ID."""
|
|
548
|
+
deleted = delete_learning(id)
|
|
549
|
+
if not deleted:
|
|
550
|
+
return f"ERROR: Learning #{id} not found."
|
|
551
|
+
return f"Learning #{id} deleted."
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def handle_learning_list(category: str = '') -> str:
|
|
555
|
+
"""List all learnings, grouped by category if no filter given."""
|
|
556
|
+
results = list_learnings(category if category else None)
|
|
557
|
+
if not results:
|
|
558
|
+
label = category if category else "ALL"
|
|
559
|
+
return f"LEARNINGS {label} (0): No entries."
|
|
560
|
+
|
|
561
|
+
conn = get_db()
|
|
562
|
+
if category:
|
|
563
|
+
label = category.upper()
|
|
564
|
+
lines = [f"LEARNINGS {label} ({len(results)}):"]
|
|
565
|
+
for r in results:
|
|
566
|
+
pri = r.get("priority", "medium") or "medium"
|
|
567
|
+
w = r.get("weight", 0.5) or 0.5
|
|
568
|
+
quality = score_learning_quality(r, conn)
|
|
569
|
+
pri_icon = {"critical": "🔴", "high": "🟠", "medium": "🟡", "low": "⚪"}.get(pri, "🟡")
|
|
570
|
+
lines.append(f" #{r['id']} [{r.get('status','active')}] {pri_icon}{pri} w={w:.2f} q={quality['score']} {r['title']}")
|
|
571
|
+
else:
|
|
572
|
+
lines = [f"LEARNINGS ALL ({len(results)}):"]
|
|
573
|
+
current_cat = None
|
|
574
|
+
for r in results:
|
|
575
|
+
if r["category"] != current_cat:
|
|
576
|
+
current_cat = r["category"]
|
|
577
|
+
lines.append(f"\n [{current_cat.upper()}]")
|
|
578
|
+
pri = r.get("priority", "medium") or "medium"
|
|
579
|
+
w = r.get("weight", 0.5) or 0.5
|
|
580
|
+
quality = score_learning_quality(r, conn)
|
|
581
|
+
pri_icon = {"critical": "🔴", "high": "🟠", "medium": "🟡", "low": "⚪"}.get(pri, "🟡")
|
|
582
|
+
lines.append(f" #{r['id']} [{r.get('status','active')}] {pri_icon}{pri} w={w:.2f} q={quality['score']} {r['title']}")
|
|
583
|
+
|
|
584
|
+
return "\n".join(lines)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def handle_learning_quality(id: int = 0, category: str = "", status: str = "active", limit: int = 20) -> str:
|
|
588
|
+
"""Inspect memory quality so fragile learnings can be tightened before they mislead guard/retrieval."""
|
|
589
|
+
results = list_learnings(category if category else None)
|
|
590
|
+
if id:
|
|
591
|
+
results = [row for row in results if int(row.get("id") or 0) == int(id)]
|
|
592
|
+
if status:
|
|
593
|
+
results = [row for row in results if str(row.get("status") or "").lower() == str(status).lower()]
|
|
594
|
+
results = results[: max(1, int(limit or 20))]
|
|
595
|
+
if not results:
|
|
596
|
+
return "LEARNING QUALITY (0): No matching learnings."
|
|
597
|
+
|
|
598
|
+
conn = get_db()
|
|
599
|
+
scored = []
|
|
600
|
+
for row in results:
|
|
601
|
+
quality = score_learning_quality(row, conn)
|
|
602
|
+
scored.append((row, quality))
|
|
603
|
+
avg_score = round(sum(item[1]["score"] for item in scored) / len(scored))
|
|
604
|
+
weak = [item for item in scored if item[1]["score"] < 60]
|
|
605
|
+
lines = [f"LEARNING QUALITY ({len(scored)}) avg={avg_score} weak={len(weak)}:"]
|
|
606
|
+
for row, quality in scored:
|
|
607
|
+
lines.append(
|
|
608
|
+
f" #{row['id']} q={quality['score']} [{quality['label']}] {row['title']} "
|
|
609
|
+
f"(conf={quality['confidence']} useful={quality['usefulness']} recency={quality['recency_relevance']} "
|
|
610
|
+
f"pressure={quality['contradiction_pressure']} richness={quality['source_richness']})"
|
|
611
|
+
)
|
|
612
|
+
return "\n".join(lines)
|