differix 0.1.0a1__py3-none-any.whl
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.
- agents/editor.py +455 -0
- agents/researcher.py +405 -0
- agents/reviewer.py +374 -0
- cli/model_cli.py +430 -0
- config.py +557 -0
- differix-0.1.0a1.dist-info/METADATA +123 -0
- differix-0.1.0a1.dist-info/RECORD +62 -0
- differix-0.1.0a1.dist-info/WHEEL +5 -0
- differix-0.1.0a1.dist-info/entry_points.txt +2 -0
- differix-0.1.0a1.dist-info/top_level.txt +6 -0
- main.py +2796 -0
- memory/__init__.py +59 -0
- memory/embeddings/__init__.py +29 -0
- memory/embeddings/factory.py +178 -0
- memory/embeddings/providers.py +259 -0
- memory/manager.py +186 -0
- memory/migrator.py +253 -0
- memory/ranker.py +203 -0
- memory/schemas.py +127 -0
- memory/storage.py +552 -0
- tools/agents_chat.py +152 -0
- tools/asset_extractor.py +545 -0
- tools/asset_manager.py +115 -0
- tools/atomic_changeset.py +264 -0
- tools/backup_manager.py +122 -0
- tools/changelog_engine.py +261 -0
- tools/coherence_checker.py +771 -0
- tools/compiler.py +531 -0
- tools/component_normalizer.py +130 -0
- tools/context_assembler.py +631 -0
- tools/contract_validator.py +478 -0
- tools/cost_predictor.py +141 -0
- tools/drift_guard.py +196 -0
- tools/file_manager.py +119 -0
- tools/handshake.py +351 -0
- tools/lesson_manager.py +570 -0
- tools/lesson_memory_bridge.py +398 -0
- tools/llm_client.py +103 -0
- tools/llm_selector.py +362 -0
- tools/model_ranker.py +535 -0
- tools/palette_extractor.py +1043 -0
- tools/plugin_manager.py +204 -0
- tools/project_context_gatherer.py +404 -0
- tools/project_scanner.py +41 -0
- tools/project_structure.py +244 -0
- tools/reasoning_logger.py +78 -0
- tools/reflection.py +452 -0
- tools/sandbox_utils.py +56 -0
- tools/security/__init__.py +0 -0
- tools/security/behavior_guard.py +83 -0
- tools/security/verification_firewall.py +113 -0
- tools/security_filter.py +213 -0
- tools/session_adr.py +311 -0
- tools/skill_manager.py +129 -0
- tools/state_manager.py +383 -0
- tools/structure_index.py +570 -0
- tools/test_schema_consistency.py +19 -0
- tools/verification_firewall.py +113 -0
- tools/visual_capture.py +65 -0
- tools/visual_diff.py +220 -0
- tools/visual_reviewer.py +79 -0
- tools/web_search.py +371 -0
tools/lesson_manager.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tools/lesson_manager.py
|
|
3
|
+
-----------------------
|
|
4
|
+
File-backed lesson storage (source of truth) + A-Score retrieval bridge.
|
|
5
|
+
|
|
6
|
+
All existing file APIs are preserved unchanged. New APIs expose ranked
|
|
7
|
+
retrieval and the feedback loop via LessonMemoryBridge.
|
|
8
|
+
|
|
9
|
+
Trigger semantics:
|
|
10
|
+
- Retrieval functions default to `record_trigger=True` (backward-compatible).
|
|
11
|
+
- Pure context-builders (handshake) MUST pass `record_trigger=False`.
|
|
12
|
+
- Callers who build an actual LLM prompt SHOULD call `record_lesson_trigger()`
|
|
13
|
+
explicitly, once, with the lesson node IDs that ended up in the prompt.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import glob
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import uuid
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from difflib import SequenceMatcher
|
|
24
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
25
|
+
|
|
26
|
+
import config
|
|
27
|
+
|
|
28
|
+
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
29
|
+
|
|
30
|
+
LESSONS_DIRS: Dict[str, str] = {
|
|
31
|
+
"observations": os.path.join(BASE_DIR, "lessons", "observations"),
|
|
32
|
+
"candidates": os.path.join(BASE_DIR, "lessons", "candidates"),
|
|
33
|
+
"promoted": os.path.join(BASE_DIR, "lessons", "promoted"),
|
|
34
|
+
"deprecated": os.path.join(BASE_DIR, "lessons", "deprecated"),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# File layer
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
def init_lessons_dirs() -> None:
|
|
42
|
+
for path in LESSONS_DIRS.values():
|
|
43
|
+
os.makedirs(path, exist_ok=True)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
init_lessons_dirs()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_duplicate_observation(
|
|
50
|
+
new_root_cause: str, new_details: dict, threshold: float = 0.85
|
|
51
|
+
) -> bool:
|
|
52
|
+
obs_dir = LESSONS_DIRS["observations"]
|
|
53
|
+
if not os.path.exists(obs_dir):
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
new_rule_str = json.dumps(new_details or {}, sort_keys=True).lower()
|
|
57
|
+
new_cause_str = str(new_root_cause).lower()
|
|
58
|
+
|
|
59
|
+
for file_path in glob.glob(os.path.join(obs_dir, "*.json")):
|
|
60
|
+
try:
|
|
61
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
62
|
+
existing = json.load(f)
|
|
63
|
+
existing_cause = str(existing.get("root_cause", "")).lower()
|
|
64
|
+
existing_rule_str = json.dumps(
|
|
65
|
+
existing.get("details", {}), sort_keys=True
|
|
66
|
+
).lower()
|
|
67
|
+
cause_sim = SequenceMatcher(None, new_cause_str, existing_cause).ratio()
|
|
68
|
+
rule_sim = SequenceMatcher(None, new_rule_str, existing_rule_str).ratio()
|
|
69
|
+
if cause_sim > threshold or rule_sim > threshold:
|
|
70
|
+
return True
|
|
71
|
+
except Exception:
|
|
72
|
+
continue
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def create_observation(root_cause: str, details: dict = None) -> Optional[str]:
|
|
77
|
+
if is_duplicate_observation(root_cause, details):
|
|
78
|
+
print(f"[Lesson Manager] Skipped duplicate observation: '{root_cause}'")
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
init_lessons_dirs()
|
|
82
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
83
|
+
unique_id = uuid.uuid4().hex[:6]
|
|
84
|
+
filename = f"observation_{timestamp}_{unique_id}.json"
|
|
85
|
+
filepath = os.path.join(LESSONS_DIRS["observations"], filename)
|
|
86
|
+
|
|
87
|
+
data = {
|
|
88
|
+
"id": unique_id,
|
|
89
|
+
"timestamp": datetime.now().isoformat(),
|
|
90
|
+
"root_cause": root_cause,
|
|
91
|
+
"details": details or {},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
95
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
96
|
+
|
|
97
|
+
_invalidate_bridge(reason=f"observation created: {filename}")
|
|
98
|
+
return filename
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def move_lesson(filename: str, source_category: str, target_category: str) -> bool:
|
|
102
|
+
init_lessons_dirs()
|
|
103
|
+
if source_category not in LESSONS_DIRS or target_category not in LESSONS_DIRS:
|
|
104
|
+
raise ValueError(f"Invalid category: {source_category} or {target_category}")
|
|
105
|
+
|
|
106
|
+
src_path = os.path.join(LESSONS_DIRS[source_category], filename)
|
|
107
|
+
dst_path = os.path.join(LESSONS_DIRS[target_category], filename)
|
|
108
|
+
|
|
109
|
+
if not os.path.exists(src_path):
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
if os.path.exists(dst_path):
|
|
113
|
+
base, ext = os.path.splitext(filename)
|
|
114
|
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
115
|
+
dst_path = os.path.join(
|
|
116
|
+
LESSONS_DIRS[target_category], f"{base}_{stamp}{ext}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
os.rename(src_path, dst_path)
|
|
120
|
+
_invalidate_bridge(reason=f"lesson moved {source_category} → {target_category}")
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def move_to_candidates(filename: str, source_category: str = "observations") -> bool:
|
|
125
|
+
return move_lesson(filename, source_category, "candidates")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def move_to_promoted(filename: str, source_category: str = "candidates") -> bool:
|
|
129
|
+
return move_lesson(filename, source_category, "promoted")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def move_to_deprecated(filename: str, source_category: str = "promoted") -> bool:
|
|
133
|
+
return move_lesson(filename, source_category, "deprecated")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_lessons_from_category(category: str) -> List[dict]:
|
|
137
|
+
init_lessons_dirs()
|
|
138
|
+
if category not in LESSONS_DIRS:
|
|
139
|
+
return []
|
|
140
|
+
lessons = []
|
|
141
|
+
target_dir = LESSONS_DIRS[category]
|
|
142
|
+
for filename in sorted(os.listdir(target_dir)):
|
|
143
|
+
if not filename.endswith(".json"):
|
|
144
|
+
continue
|
|
145
|
+
try:
|
|
146
|
+
with open(os.path.join(target_dir, filename), "r", encoding="utf-8") as f:
|
|
147
|
+
data = json.load(f)
|
|
148
|
+
data["_filename"] = filename
|
|
149
|
+
lessons.append(data)
|
|
150
|
+
except Exception as e:
|
|
151
|
+
print(f"[Warning] Failed to parse lesson file {filename}: {e}")
|
|
152
|
+
return lessons
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def load_promoted_lessons() -> List[dict]:
|
|
156
|
+
return load_lessons_from_category("promoted")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Legacy prompt formatting
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
def format_lessons_category_prompt(category_name: str, header_title: str) -> str:
|
|
163
|
+
lessons = load_lessons_from_category(category_name)
|
|
164
|
+
if not lessons:
|
|
165
|
+
return f"No {category_name} items found."
|
|
166
|
+
|
|
167
|
+
formatted = [f"### {header_title} ###"]
|
|
168
|
+
for idx, item in enumerate(lessons, 1):
|
|
169
|
+
filename = item.get("_filename", "unknown.json")
|
|
170
|
+
cause = item.get("root_cause", "N/A")
|
|
171
|
+
details = item.get("details", {})
|
|
172
|
+
formatted.append(f"{idx}. [{filename}] Root Cause: {cause}")
|
|
173
|
+
if isinstance(details, dict):
|
|
174
|
+
for k, v in details.items():
|
|
175
|
+
formatted.append(f" - **{k}:** {v}")
|
|
176
|
+
elif details:
|
|
177
|
+
formatted.append(f" - {details}")
|
|
178
|
+
return "\n".join(formatted)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def get_promoted_lessons_prompt() -> str:
|
|
182
|
+
return format_lessons_category_prompt(
|
|
183
|
+
"promoted", "SYSTEM LESSONS & ARCHITECTURAL MEMORY (PROMOTED)"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def get_observations_prompt() -> str:
|
|
188
|
+
return format_lessons_category_prompt(
|
|
189
|
+
"observations", "RAW UNPROCESSED OBSERVATIONS"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def get_candidates_prompt() -> str:
|
|
194
|
+
return format_lessons_category_prompt(
|
|
195
|
+
"candidates", "LESSON CANDIDATES (PENDING PROMOTION)"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def get_deprecated_prompt() -> str:
|
|
200
|
+
return format_lessons_category_prompt(
|
|
201
|
+
"deprecated", "DEPRECATED LESSONS (HISTORICAL)"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def get_full_memory_prompt() -> str:
|
|
206
|
+
return (
|
|
207
|
+
f"{get_promoted_lessons_prompt()}\n\n"
|
|
208
|
+
f"{get_candidates_prompt()}\n\n"
|
|
209
|
+
f"{get_observations_prompt()}\n\n"
|
|
210
|
+
f"{get_deprecated_prompt()}"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_lessons_context_for_role(role_name: str) -> str:
|
|
215
|
+
role = (role_name or "").strip().lower()
|
|
216
|
+
if role in ("researcher", "superuser", "architect"):
|
|
217
|
+
return get_full_memory_prompt()
|
|
218
|
+
return get_promoted_lessons_prompt()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# Bridge integration
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
_bridge = None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _invalidate_bridge(reason: str = "") -> None:
|
|
228
|
+
global _bridge
|
|
229
|
+
if _bridge is not None:
|
|
230
|
+
_bridge._needs_sync = True
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _get_bridge():
|
|
234
|
+
global _bridge
|
|
235
|
+
if not getattr(config, "LESSONS_MEMORY_ENABLED", True):
|
|
236
|
+
return None
|
|
237
|
+
if _bridge is None:
|
|
238
|
+
try:
|
|
239
|
+
from tools.lesson_memory_bridge import LessonMemoryBridge
|
|
240
|
+
except ImportError:
|
|
241
|
+
try:
|
|
242
|
+
from lesson_memory_bridge import LessonMemoryBridge
|
|
243
|
+
except ImportError:
|
|
244
|
+
print(
|
|
245
|
+
"[LessonManager] Bridge module not found — falling back to file mode."
|
|
246
|
+
)
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
_bridge = LessonMemoryBridge(
|
|
251
|
+
lessons_root=config.LESSONS_ROOT,
|
|
252
|
+
db_path=config.LESSONS_MEMORY_DB_PATH,
|
|
253
|
+
embedding_provider=None,
|
|
254
|
+
)
|
|
255
|
+
_bridge._needs_sync = True
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
print(f"[LessonManager] Bridge init failed ({exc}) — using file mode.")
|
|
258
|
+
_bridge = None
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
if getattr(_bridge, "_needs_sync", False):
|
|
262
|
+
try:
|
|
263
|
+
report = _bridge.sync_all()
|
|
264
|
+
if report.errors:
|
|
265
|
+
for err in report.errors:
|
|
266
|
+
print(f"[LessonManager] Sync warning: {err}")
|
|
267
|
+
_bridge._needs_sync = False
|
|
268
|
+
except Exception as exc:
|
|
269
|
+
print(f"[LessonManager] Sync failed ({exc}) — using cached index.")
|
|
270
|
+
|
|
271
|
+
return _bridge
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
# Ranked retrieval (with explicit record_trigger control)
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
def get_ranked_lessons_scored(
|
|
278
|
+
query: str,
|
|
279
|
+
limit: int = 5,
|
|
280
|
+
stages: Optional[Sequence[str]] = None,
|
|
281
|
+
scope_type=None,
|
|
282
|
+
scope_value: str = "vegex",
|
|
283
|
+
record_trigger: bool = True,
|
|
284
|
+
):
|
|
285
|
+
"""
|
|
286
|
+
A-Score rank lessons matching `query`, filtered by stage.
|
|
287
|
+
|
|
288
|
+
`record_trigger=True` increments T (trigger_count) and refreshes
|
|
289
|
+
last_used_at for every returned node. Callers that are *previewing*
|
|
290
|
+
context (handshake, debug commands) MUST pass record_trigger=False
|
|
291
|
+
to preserve determinism and avoid inflating T.
|
|
292
|
+
"""
|
|
293
|
+
bridge = _get_bridge()
|
|
294
|
+
if bridge is None:
|
|
295
|
+
return []
|
|
296
|
+
|
|
297
|
+
from memory import MemoryScope
|
|
298
|
+
|
|
299
|
+
if stages is None:
|
|
300
|
+
stages = getattr(
|
|
301
|
+
config, "LESSONS_RETRIEVAL_STAGES_CONSUMER", ("promoted",)
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
return bridge.search(
|
|
306
|
+
query=query,
|
|
307
|
+
limit=limit,
|
|
308
|
+
stages=stages,
|
|
309
|
+
scope_type=scope_type or MemoryScope.PROJECT,
|
|
310
|
+
scope_value=scope_value,
|
|
311
|
+
record_trigger=record_trigger,
|
|
312
|
+
)
|
|
313
|
+
except Exception as exc:
|
|
314
|
+
print(f"[LessonManager] Ranked search failed ({exc}).")
|
|
315
|
+
return []
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def get_ranked_lessons_context(
|
|
319
|
+
query: str,
|
|
320
|
+
limit: int = 5,
|
|
321
|
+
stages: Optional[Sequence[str]] = None,
|
|
322
|
+
scope_value: str = "vegex",
|
|
323
|
+
record_trigger: bool = True,
|
|
324
|
+
) -> str:
|
|
325
|
+
"""
|
|
326
|
+
Same as get_ranked_lessons_scored, formatted for LLM injection.
|
|
327
|
+
|
|
328
|
+
Falls back to the legacy prompt formatter if the bridge is unavailable.
|
|
329
|
+
"""
|
|
330
|
+
scored = get_ranked_lessons_scored(
|
|
331
|
+
query=query,
|
|
332
|
+
limit=limit,
|
|
333
|
+
stages=stages,
|
|
334
|
+
scope_value=scope_value,
|
|
335
|
+
record_trigger=record_trigger,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if not scored:
|
|
339
|
+
if stages and "promoted" in stages and len(stages) == 1:
|
|
340
|
+
return get_promoted_lessons_prompt()
|
|
341
|
+
return get_full_memory_prompt()
|
|
342
|
+
|
|
343
|
+
return format_scored_lessons_context(scored)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def format_scored_lessons_context(
|
|
347
|
+
scored,
|
|
348
|
+
header: str = "RELEVANT LESSONS (A-SCORE RANKED)",
|
|
349
|
+
) -> str:
|
|
350
|
+
"""
|
|
351
|
+
Render a List[ScoredMemoryNode] into the LLM-injection format.
|
|
352
|
+
|
|
353
|
+
Use this when you already have the scored list (e.g. so you can also
|
|
354
|
+
persist node IDs to state) — avoids retrieving lessons twice per cycle.
|
|
355
|
+
"""
|
|
356
|
+
if not scored:
|
|
357
|
+
return ""
|
|
358
|
+
lines: List[str] = [
|
|
359
|
+
f"### {header} ###",
|
|
360
|
+
"Format: IF {trigger} THEN avoid {rule} BECAUSE {root_cause}",
|
|
361
|
+
"",
|
|
362
|
+
]
|
|
363
|
+
for idx, s in enumerate(scored, 1):
|
|
364
|
+
node = s.node
|
|
365
|
+
md = node.metadata or {}
|
|
366
|
+
stage = md.get("stage", "unknown")
|
|
367
|
+
cause = md.get("root_cause", "N/A")
|
|
368
|
+
details = md.get("details", {}) or {}
|
|
369
|
+
rule = details.get("rule") or details.get("fix_instruction") or ""
|
|
370
|
+
trigger = details.get("trigger_condition") or details.get("context") or ""
|
|
371
|
+
|
|
372
|
+
lines.append(
|
|
373
|
+
f"{idx}. [stage={stage} | score={s.score:.4f} | "
|
|
374
|
+
f"sim={s.similarity:.3f} | T={node.trigger_count} "
|
|
375
|
+
f"H={node.helpful_count} V={node.violation_count}]"
|
|
376
|
+
)
|
|
377
|
+
if trigger and rule:
|
|
378
|
+
lines.append(f" IF {trigger}")
|
|
379
|
+
lines.append(f" THEN avoid {rule}")
|
|
380
|
+
lines.append(f" BECAUSE {cause}")
|
|
381
|
+
else:
|
|
382
|
+
lines.append(f" root_cause: {cause}")
|
|
383
|
+
for k, v in details.items():
|
|
384
|
+
lines.append(f" - {k}: {v}")
|
|
385
|
+
lines.append("")
|
|
386
|
+
|
|
387
|
+
return "\n".join(lines)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def get_ranked_lessons_for_role(
|
|
391
|
+
role: str,
|
|
392
|
+
query: str,
|
|
393
|
+
limit: int = 5,
|
|
394
|
+
record_trigger: bool = True,
|
|
395
|
+
) -> tuple:
|
|
396
|
+
"""
|
|
397
|
+
Role-aware ranked retrieval. Returns (scored_list, context_string, node_ids).
|
|
398
|
+
|
|
399
|
+
RBAC (matches get_lessons_context_for_role):
|
|
400
|
+
- researcher / superuser / architect → promoted + candidates + observations
|
|
401
|
+
- anyone else → promoted only
|
|
402
|
+
|
|
403
|
+
Pure context-builders (handshake) MUST pass record_trigger=False.
|
|
404
|
+
"""
|
|
405
|
+
role_key = (role or "").strip().lower()
|
|
406
|
+
if role_key in ("researcher", "superuser", "architect"):
|
|
407
|
+
stages = getattr(
|
|
408
|
+
config,
|
|
409
|
+
"LESSONS_RETRIEVAL_STAGES_RESEARCHER",
|
|
410
|
+
("promoted", "candidates", "observations"),
|
|
411
|
+
)
|
|
412
|
+
else:
|
|
413
|
+
stages = getattr(
|
|
414
|
+
config, "LESSONS_RETRIEVAL_STAGES_CONSUMER", ("promoted",)
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
scored = get_ranked_lessons_scored(
|
|
418
|
+
query=query,
|
|
419
|
+
limit=limit,
|
|
420
|
+
stages=stages,
|
|
421
|
+
record_trigger=record_trigger,
|
|
422
|
+
)
|
|
423
|
+
context = format_scored_lessons_context(scored)
|
|
424
|
+
node_ids = [s.node.node_id for s in scored]
|
|
425
|
+
return scored, context, node_ids
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# ---------------------------------------------------------------------------
|
|
429
|
+
# Feedback loop
|
|
430
|
+
# ---------------------------------------------------------------------------
|
|
431
|
+
def record_lesson_trigger(node_ids: Sequence[str]) -> int:
|
|
432
|
+
"""
|
|
433
|
+
Explicitly increment T (trigger_count) and refresh last_used_at for
|
|
434
|
+
the given lesson node IDs. Call this once per actual LLM injection —
|
|
435
|
+
not from handshake, not from preview commands.
|
|
436
|
+
"""
|
|
437
|
+
if not node_ids:
|
|
438
|
+
return 0
|
|
439
|
+
bridge = _get_bridge()
|
|
440
|
+
if bridge is None:
|
|
441
|
+
return 0
|
|
442
|
+
try:
|
|
443
|
+
return bridge.manager.record_trigger(list(node_ids))
|
|
444
|
+
except Exception as exc:
|
|
445
|
+
print(f"[LessonManager] record_trigger failed: {exc}")
|
|
446
|
+
return 0
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def record_lesson_helpful(node_ids: Sequence[str]) -> int:
|
|
450
|
+
if not node_ids:
|
|
451
|
+
return 0
|
|
452
|
+
bridge = _get_bridge()
|
|
453
|
+
if bridge is None:
|
|
454
|
+
return 0
|
|
455
|
+
try:
|
|
456
|
+
return bridge.record_helpful(list(node_ids))
|
|
457
|
+
except Exception as exc:
|
|
458
|
+
print(f"[LessonManager] record_helpful failed: {exc}")
|
|
459
|
+
return 0
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def record_lesson_violation(node_ids: Sequence[str]) -> int:
|
|
463
|
+
if not node_ids:
|
|
464
|
+
return 0
|
|
465
|
+
bridge = _get_bridge()
|
|
466
|
+
if bridge is None:
|
|
467
|
+
return 0
|
|
468
|
+
try:
|
|
469
|
+
return bridge.record_violation(list(node_ids))
|
|
470
|
+
except Exception as exc:
|
|
471
|
+
print(f"[LessonManager] record_violation failed: {exc}")
|
|
472
|
+
return 0
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def analyze_lesson_outcome(
|
|
476
|
+
build_success: bool,
|
|
477
|
+
build_log: str,
|
|
478
|
+
injected_node_ids: Sequence[str],
|
|
479
|
+
keyword_min_hits: int = 2,
|
|
480
|
+
) -> tuple:
|
|
481
|
+
"""
|
|
482
|
+
Determine which injected lessons deserve H (helpful) vs V (violation).
|
|
483
|
+
|
|
484
|
+
Returns (helpful_ids, violated_ids).
|
|
485
|
+
|
|
486
|
+
Semantics per the VegeX blueprint §3B:
|
|
487
|
+
- H increments for every injected lesson when the build passes.
|
|
488
|
+
- V increments only for injected lessons whose distinctive keywords
|
|
489
|
+
appear in the build error output. This prevents penalising a lesson
|
|
490
|
+
that was injected but whose scope was unrelated to the failure.
|
|
491
|
+
"""
|
|
492
|
+
if not injected_node_ids:
|
|
493
|
+
return [], []
|
|
494
|
+
|
|
495
|
+
if build_success:
|
|
496
|
+
return list(injected_node_ids), []
|
|
497
|
+
|
|
498
|
+
bridge = _get_bridge()
|
|
499
|
+
if bridge is None:
|
|
500
|
+
return [], []
|
|
501
|
+
|
|
502
|
+
log_lower = (build_log or "").lower()
|
|
503
|
+
if not log_lower.strip():
|
|
504
|
+
return [], []
|
|
505
|
+
|
|
506
|
+
import re as _re
|
|
507
|
+
|
|
508
|
+
violated: List[str] = []
|
|
509
|
+
for nid in injected_node_ids:
|
|
510
|
+
try:
|
|
511
|
+
node = bridge.manager.get(nid)
|
|
512
|
+
except Exception:
|
|
513
|
+
continue
|
|
514
|
+
md = node.metadata or {}
|
|
515
|
+
details = md.get("details", {}) or {}
|
|
516
|
+
text = " ".join([
|
|
517
|
+
str(md.get("root_cause", "")),
|
|
518
|
+
str(details.get("rule", "")),
|
|
519
|
+
str(details.get("fix_instruction", "")),
|
|
520
|
+
str(details.get("trigger_condition", "")),
|
|
521
|
+
]).lower()
|
|
522
|
+
|
|
523
|
+
keywords = set(_re.findall(r"[a-z][a-z0-9_]{4,}", text))
|
|
524
|
+
if not keywords:
|
|
525
|
+
continue
|
|
526
|
+
hits = sum(1 for kw in keywords if kw in log_lower)
|
|
527
|
+
if hits >= keyword_min_hits:
|
|
528
|
+
violated.append(nid)
|
|
529
|
+
|
|
530
|
+
return [], violated
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def lesson_memory_stats() -> Dict[str, Any]:
|
|
534
|
+
bridge = _get_bridge()
|
|
535
|
+
if bridge is None:
|
|
536
|
+
return {"enabled": False}
|
|
537
|
+
try:
|
|
538
|
+
st = bridge.stats()
|
|
539
|
+
st["enabled"] = True
|
|
540
|
+
return st
|
|
541
|
+
except Exception as exc:
|
|
542
|
+
return {"enabled": False, "error": str(exc)}
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
__all__ = [
|
|
546
|
+
"LESSONS_DIRS",
|
|
547
|
+
"init_lessons_dirs",
|
|
548
|
+
"create_observation",
|
|
549
|
+
"move_lesson",
|
|
550
|
+
"move_to_candidates",
|
|
551
|
+
"move_to_promoted",
|
|
552
|
+
"move_to_deprecated",
|
|
553
|
+
"load_lessons_from_category",
|
|
554
|
+
"load_promoted_lessons",
|
|
555
|
+
"get_promoted_lessons_prompt",
|
|
556
|
+
"get_candidates_prompt",
|
|
557
|
+
"get_observations_prompt",
|
|
558
|
+
"get_deprecated_prompt",
|
|
559
|
+
"get_full_memory_prompt",
|
|
560
|
+
"get_lessons_context_for_role",
|
|
561
|
+
"get_ranked_lessons_scored",
|
|
562
|
+
"get_ranked_lessons_context",
|
|
563
|
+
"get_ranked_lessons_for_role",
|
|
564
|
+
"format_scored_lessons_context",
|
|
565
|
+
"analyze_lesson_outcome",
|
|
566
|
+
"record_lesson_trigger",
|
|
567
|
+
"record_lesson_helpful",
|
|
568
|
+
"record_lesson_violation",
|
|
569
|
+
"lesson_memory_stats",
|
|
570
|
+
]
|