mindforge-memory 6.4.1__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.
- MindForge.py +20 -0
- mindforge/__init__.py +111 -0
- mindforge/adapters/__init__.py +18 -0
- mindforge/adapters/claude_adapter.py +84 -0
- mindforge/adapters/crewai_memory.py +76 -0
- mindforge/adapters/generic_api.py +183 -0
- mindforge/adapters/langgraph_store.py +463 -0
- mindforge/adapters/openclaw_adapter.py +91 -0
- mindforge/api/__init__.py +1 -0
- mindforge/api/server.py +1567 -0
- mindforge/cli/__init__.py +1 -0
- mindforge/cli/main.py +13607 -0
- mindforge/core/__init__.py +37 -0
- mindforge/core/embedding.py +847 -0
- mindforge/core/encryption.py +597 -0
- mindforge/core/indexer.py +363 -0
- mindforge/core/mindforge.py +6298 -0
- mindforge/core/paths.py +43 -0
- mindforge/core/query.py +501 -0
- mindforge/core/storage.py +18656 -0
- mindforge/core/types.py +314 -0
- mindforge/core/version.py +56 -0
- mindforge/daemon/__init__.py +0 -0
- mindforge/daemon/server.py +277 -0
- mindforge/mcp/__init__.py +4 -0
- mindforge/mcp/server.py +2559 -0
- mindforge/modules/__init__.py +138 -0
- mindforge/modules/categorizer.py +151 -0
- mindforge/modules/conflict_detector.py +356 -0
- mindforge/modules/connectors.py +331 -0
- mindforge/modules/context_window.py +337 -0
- mindforge/modules/event_bus.py +572 -0
- mindforge/modules/evolution.py +210 -0
- mindforge/modules/experience.py +402 -0
- mindforge/modules/federated.py +722 -0
- mindforge/modules/federated_acl.py +393 -0
- mindforge/modules/follow_up.py +315 -0
- mindforge/modules/hybrid_search.py +565 -0
- mindforge/modules/importer.py +374 -0
- mindforge/modules/integrator.py +196 -0
- mindforge/modules/intent_router.py +359 -0
- mindforge/modules/knowledge_graph.py +395 -0
- mindforge/modules/memory_compressor.py +592 -0
- mindforge/modules/memory_decay.py +474 -0
- mindforge/modules/memory_passport.py +250 -0
- mindforge/modules/multi_agent.py +534 -0
- mindforge/modules/multimodal.py +304 -0
- mindforge/modules/outcome_learning.py +186 -0
- mindforge/modules/personality.py +275 -0
- mindforge/modules/principled_forgetting.py +258 -0
- mindforge/modules/privacy.py +736 -0
- mindforge/modules/recall.py +263 -0
- mindforge/modules/session_focus.py +305 -0
- mindforge/modules/share_conflict.py +521 -0
- mindforge/modules/skill_extractor.py +340 -0
- mindforge/modules/skill_pack.py +205 -0
- mindforge/modules/timeline_viz.py +206 -0
- mindforge/modules/v6.py +931 -0
- mindforge_memory-6.4.1.dist-info/METADATA +281 -0
- mindforge_memory-6.4.1.dist-info/RECORD +64 -0
- mindforge_memory-6.4.1.dist-info/WHEEL +5 -0
- mindforge_memory-6.4.1.dist-info/entry_points.txt +3 -0
- mindforge_memory-6.4.1.dist-info/licenses/LICENSE +21 -0
- mindforge_memory-6.4.1.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MindForge v5.0 记忆演化引擎
|
|
3
|
+
艾宾浩斯遗忘曲线 · 记忆巩固 · 记忆重组
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import List, Dict, Tuple
|
|
10
|
+
|
|
11
|
+
from mindforge.core.types import MemoryLayer, Importance
|
|
12
|
+
from mindforge.core.storage import StorageEngine, MemoryEntry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ForgettingCurve:
|
|
17
|
+
"""艾宾浩斯遗忘曲线参数"""
|
|
18
|
+
initial_strength: float = 1.0
|
|
19
|
+
decay_rate: float = 0.1
|
|
20
|
+
review_boost: float = 0.3
|
|
21
|
+
importance_multiplier: Dict[str, float] = field(default_factory=lambda: {
|
|
22
|
+
"LOW": 0.7,
|
|
23
|
+
"MEDIUM": 1.0,
|
|
24
|
+
"HIGH": 1.5,
|
|
25
|
+
"CRITICAL": 2.0,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
def calculate_strength(self, initial_strength: float,
|
|
29
|
+
elapsed_hours: float,
|
|
30
|
+
importance: Importance = Importance.MEDIUM) -> float:
|
|
31
|
+
multiplier = self.importance_multiplier.get(importance.value, 1.0)
|
|
32
|
+
adjusted_decay = self.decay_rate / multiplier
|
|
33
|
+
strength = initial_strength * math.exp(-adjusted_decay * elapsed_hours)
|
|
34
|
+
return max(0.01, min(1.0, strength))
|
|
35
|
+
|
|
36
|
+
def next_review_time(self, current_strength: float,
|
|
37
|
+
target_strength: float = 0.6,
|
|
38
|
+
importance: Importance = Importance.MEDIUM) -> float:
|
|
39
|
+
if current_strength <= target_strength:
|
|
40
|
+
return 0
|
|
41
|
+
multiplier = self.importance_multiplier.get(importance.value, 1.0)
|
|
42
|
+
adjusted_decay = self.decay_rate / multiplier
|
|
43
|
+
hours = -math.log(target_strength / current_strength) / adjusted_decay
|
|
44
|
+
return max(0, hours)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MemoryEvolution:
|
|
48
|
+
"""记忆演化引擎"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, storage: StorageEngine):
|
|
51
|
+
self.storage = storage
|
|
52
|
+
self.forgetting_curve = ForgettingCurve()
|
|
53
|
+
self._consolidation_threshold = 0.7
|
|
54
|
+
self._permanent_threshold = 0.9
|
|
55
|
+
self._short_term_capacity = 100
|
|
56
|
+
|
|
57
|
+
def update_forgetting_scores(self):
|
|
58
|
+
all_memories = self.storage.list_memories(limit=10000)
|
|
59
|
+
now = time.time()
|
|
60
|
+
bulk_updates = []
|
|
61
|
+
for entry in all_memories:
|
|
62
|
+
elapsed_hours = (now - entry.last_accessed_at) / 3600
|
|
63
|
+
if elapsed_hours < 1:
|
|
64
|
+
continue
|
|
65
|
+
new_strength = self.forgetting_curve.calculate_strength(
|
|
66
|
+
entry.strength, elapsed_hours, entry.importance,
|
|
67
|
+
)
|
|
68
|
+
forgetting_score = 1.0 - new_strength
|
|
69
|
+
new_metadata = {
|
|
70
|
+
**entry.metadata,
|
|
71
|
+
"current_strength": new_strength,
|
|
72
|
+
"forgetting_score": forgetting_score,
|
|
73
|
+
}
|
|
74
|
+
bulk_updates.append({
|
|
75
|
+
"id": entry.id,
|
|
76
|
+
"strength": new_strength,
|
|
77
|
+
"forgetting_score": forgetting_score,
|
|
78
|
+
"metadata": new_metadata,
|
|
79
|
+
})
|
|
80
|
+
if bulk_updates:
|
|
81
|
+
self.storage.bulk_update_memory_fields(
|
|
82
|
+
bulk_updates,
|
|
83
|
+
["strength", "forgetting_score", "metadata"],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def consolidate(self, agent_id: str = "", session_id: str = "") -> Dict:
|
|
87
|
+
short_term = self.storage.list_memories(
|
|
88
|
+
layer=MemoryLayer.SHORT_TERM,
|
|
89
|
+
limit=self._short_term_capacity,
|
|
90
|
+
)
|
|
91
|
+
consolidated = []
|
|
92
|
+
promoted = 0
|
|
93
|
+
demoted = 0
|
|
94
|
+
now = time.time()
|
|
95
|
+
for entry in short_term:
|
|
96
|
+
elapsed_hours = (now - entry.created_at) / 3600
|
|
97
|
+
strength = self.forgetting_curve.calculate_strength(
|
|
98
|
+
entry.strength, elapsed_hours, entry.importance
|
|
99
|
+
)
|
|
100
|
+
access_factor = min(entry.access_count / 5.0, 1.0)
|
|
101
|
+
importance_factor = entry.importance.to_int() / 3.0
|
|
102
|
+
consolidation_score = (strength + access_factor + importance_factor) / 3.0
|
|
103
|
+
if consolidation_score >= self._consolidation_threshold:
|
|
104
|
+
new_count = entry.consolidation_count + 1
|
|
105
|
+
self.storage.update_memory(
|
|
106
|
+
entry_id=entry.id,
|
|
107
|
+
layer=MemoryLayer.LONG_TERM,
|
|
108
|
+
consolidation_count=new_count,
|
|
109
|
+
metadata={
|
|
110
|
+
**entry.metadata,
|
|
111
|
+
"consolidated_at": now,
|
|
112
|
+
"consolidation_score": consolidation_score,
|
|
113
|
+
"consolidation_count": new_count,
|
|
114
|
+
},
|
|
115
|
+
actor=agent_id,
|
|
116
|
+
session_id=session_id,
|
|
117
|
+
)
|
|
118
|
+
entry.consolidation_count = new_count
|
|
119
|
+
promoted += 1
|
|
120
|
+
consolidated.append(entry.id)
|
|
121
|
+
elif strength < 0.2 and entry.importance == Importance.LOW:
|
|
122
|
+
self.storage.update_memory(
|
|
123
|
+
entry_id=entry.id,
|
|
124
|
+
layer=MemoryLayer.SENSORY,
|
|
125
|
+
actor=agent_id,
|
|
126
|
+
session_id=session_id,
|
|
127
|
+
)
|
|
128
|
+
demoted += 1
|
|
129
|
+
return {
|
|
130
|
+
"processed": len(short_term),
|
|
131
|
+
"promoted_to_long_term": promoted,
|
|
132
|
+
"demoted_to_sensory": demoted,
|
|
133
|
+
"consolidated_ids": consolidated,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
def promote_to_permanent(self, memory_id: str,
|
|
137
|
+
agent_id: str = "", session_id: str = "") -> bool:
|
|
138
|
+
entry = self.storage.get_memory(memory_id, agent_id, session_id)
|
|
139
|
+
if not entry:
|
|
140
|
+
return False
|
|
141
|
+
if entry.layer != MemoryLayer.LONG_TERM:
|
|
142
|
+
return False
|
|
143
|
+
if entry.consolidation_count < 3:
|
|
144
|
+
return False
|
|
145
|
+
self.storage.update_memory(
|
|
146
|
+
entry_id=memory_id,
|
|
147
|
+
layer=MemoryLayer.PERMANENT,
|
|
148
|
+
metadata={**entry.metadata, "permanent_at": time.time()},
|
|
149
|
+
actor=agent_id,
|
|
150
|
+
session_id=session_id,
|
|
151
|
+
)
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
def review_schedule(self, limit: int = 20) -> List[Tuple[MemoryEntry, float]]:
|
|
155
|
+
all_memories = self.storage.list_memories(limit=1000)
|
|
156
|
+
now = time.time()
|
|
157
|
+
schedule = []
|
|
158
|
+
for entry in all_memories:
|
|
159
|
+
if entry.layer in (MemoryLayer.SENSORY, MemoryLayer.PERMANENT):
|
|
160
|
+
continue
|
|
161
|
+
elapsed_hours = (now - entry.last_accessed_at) / 3600
|
|
162
|
+
current_strength = self.forgetting_curve.calculate_strength(
|
|
163
|
+
entry.strength, elapsed_hours, entry.importance
|
|
164
|
+
)
|
|
165
|
+
next_review = self.forgetting_curve.next_review_time(
|
|
166
|
+
current_strength, 0.6, entry.importance
|
|
167
|
+
)
|
|
168
|
+
urgency = 1.0 - current_strength
|
|
169
|
+
if urgency > 0.3:
|
|
170
|
+
schedule.append((entry, urgency))
|
|
171
|
+
schedule.sort(key=lambda x: x[1], reverse=True)
|
|
172
|
+
return schedule[:limit]
|
|
173
|
+
|
|
174
|
+
def reactivate(self, memory_id: str, agent_id: str = "", session_id: str = "") -> bool:
|
|
175
|
+
entry = self.storage.get_memory(memory_id, agent_id, session_id)
|
|
176
|
+
if not entry:
|
|
177
|
+
return False
|
|
178
|
+
boost = self.forgetting_curve.review_boost
|
|
179
|
+
new_strength = min(1.0, entry.strength + boost)
|
|
180
|
+
self.storage.update_memory(
|
|
181
|
+
entry_id=memory_id,
|
|
182
|
+
metadata={
|
|
183
|
+
**entry.metadata,
|
|
184
|
+
"strength": new_strength,
|
|
185
|
+
"last_review_at": time.time(),
|
|
186
|
+
"review_count": entry.metadata.get("review_count", 0) + 1,
|
|
187
|
+
},
|
|
188
|
+
actor=agent_id,
|
|
189
|
+
session_id=session_id,
|
|
190
|
+
)
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
def get_evolution_stats(self) -> Dict:
|
|
194
|
+
stats = self.storage.get_stats()
|
|
195
|
+
by_layer = stats.get("by_layer", {})
|
|
196
|
+
return {
|
|
197
|
+
"sensory": by_layer.get("sensory", 0),
|
|
198
|
+
"short_term": by_layer.get("short_term", 0),
|
|
199
|
+
"long_term": by_layer.get("long_term", 0),
|
|
200
|
+
"permanent": by_layer.get("permanent", 0),
|
|
201
|
+
"consolidation_rate": self._calc_consolidation_rate(),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
def _calc_consolidation_rate(self) -> float:
|
|
205
|
+
short_term = self.storage.count_memories(layer=MemoryLayer.SHORT_TERM)
|
|
206
|
+
long_term = self.storage.count_memories(layer=MemoryLayer.LONG_TERM)
|
|
207
|
+
total = short_term + long_term
|
|
208
|
+
if total == 0:
|
|
209
|
+
return 0.0
|
|
210
|
+
return long_term / total
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
MindForge v5.8.0 程序性记忆 / 经验蒸馏引擎(Cases → Skills 自进化)
|
|
4
|
+
====================================
|
|
5
|
+
在 v5.3.9 SkillExtractor(记忆→技能模板抽取)基础上补齐闭环:
|
|
6
|
+
|
|
7
|
+
1. 案例记录:Agent 把一次成功任务存为 ExperienceCase(任务/内容/结果/耗时/来源记忆),
|
|
8
|
+
持久化到 experience_cases 表;
|
|
9
|
+
2. 蒸馏落库:distill() 从案例批量提取技能模板(复用 SkillExtractor),
|
|
10
|
+
UPSERT 到 experience_skills 表;
|
|
11
|
+
3. 匹配复用:match_skills() 按新任务查询已存技能,render_skill() 用参数渲染
|
|
12
|
+
可执行流程(复用 SkillTemplate.render)。
|
|
13
|
+
|
|
14
|
+
设计原则:全部零外部依赖、SQLite 单文件、显式调用(不自动写主流程)。
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import sqlite3
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from typing import Any, Dict, List, Optional
|
|
23
|
+
|
|
24
|
+
from mindforge.modules.skill_extractor import SkillExtractor, SkillTemplate
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# 数据类
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
class ExperienceCase:
|
|
32
|
+
"""一次可复用的任务经验(Case)"""
|
|
33
|
+
|
|
34
|
+
__slots__ = ("id", "task", "content", "result", "outcome", "duration_seconds",
|
|
35
|
+
"tags", "source_memory_ids", "agent_id", "created_at")
|
|
36
|
+
|
|
37
|
+
def __init__(self, id: str = "", task: str = "", content: str = "",
|
|
38
|
+
result: str = "", outcome: str = "success",
|
|
39
|
+
duration_seconds: float = 0.0, tags: Optional[List[str]] = None,
|
|
40
|
+
source_memory_ids: Optional[List[str]] = None,
|
|
41
|
+
agent_id: str = "", created_at: float = 0.0):
|
|
42
|
+
self.id = id or str(uuid.uuid4())
|
|
43
|
+
self.task = task
|
|
44
|
+
self.content = content
|
|
45
|
+
self.result = result
|
|
46
|
+
self.outcome = outcome
|
|
47
|
+
self.duration_seconds = duration_seconds
|
|
48
|
+
self.tags = list(tags or [])
|
|
49
|
+
self.source_memory_ids = list(source_memory_ids or [])
|
|
50
|
+
self.agent_id = agent_id
|
|
51
|
+
self.created_at = created_at or time.time()
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
54
|
+
return {
|
|
55
|
+
"id": self.id, "task": self.task, "content": self.content,
|
|
56
|
+
"result": self.result, "outcome": self.outcome,
|
|
57
|
+
"duration_seconds": round(self.duration_seconds, 3),
|
|
58
|
+
"tags": self.tags[:20], "source_memory_ids": self.source_memory_ids[:50],
|
|
59
|
+
"agent_id": self.agent_id, "created_at": round(self.created_at, 3),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# 持久化存储
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
class SkillStore:
|
|
68
|
+
"""案例 + 技能模板的持久化存储(SQLite 单文件)"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, storage=None):
|
|
71
|
+
self.storage = storage
|
|
72
|
+
self._skills: Dict[str, SkillTemplate] = {}
|
|
73
|
+
if self.storage is not None:
|
|
74
|
+
self._ensure_tables()
|
|
75
|
+
self._load_skills()
|
|
76
|
+
|
|
77
|
+
# -- schema -----------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def _ensure_tables(self) -> None:
|
|
80
|
+
try:
|
|
81
|
+
conn = self.storage._get_conn()
|
|
82
|
+
conn.executescript("""
|
|
83
|
+
CREATE TABLE IF NOT EXISTS experience_cases (
|
|
84
|
+
id TEXT PRIMARY KEY,
|
|
85
|
+
task TEXT NOT NULL,
|
|
86
|
+
content TEXT,
|
|
87
|
+
result TEXT DEFAULT '',
|
|
88
|
+
outcome TEXT DEFAULT 'success',
|
|
89
|
+
duration_seconds REAL DEFAULT 0.0,
|
|
90
|
+
tags TEXT DEFAULT '[]',
|
|
91
|
+
source_memory_ids TEXT DEFAULT '[]',
|
|
92
|
+
agent_id TEXT DEFAULT '',
|
|
93
|
+
created_at REAL
|
|
94
|
+
);
|
|
95
|
+
CREATE TABLE IF NOT EXISTS experience_skills (
|
|
96
|
+
name TEXT PRIMARY KEY,
|
|
97
|
+
description TEXT DEFAULT '',
|
|
98
|
+
triggers TEXT DEFAULT '[]',
|
|
99
|
+
slots TEXT DEFAULT '[]',
|
|
100
|
+
steps TEXT DEFAULT '[]',
|
|
101
|
+
examples TEXT DEFAULT '[]',
|
|
102
|
+
tags TEXT DEFAULT '[]',
|
|
103
|
+
source_memory_ids TEXT DEFAULT '[]',
|
|
104
|
+
confidence REAL DEFAULT 0.0,
|
|
105
|
+
cluster_size INTEGER DEFAULT 0,
|
|
106
|
+
updated_at REAL
|
|
107
|
+
);
|
|
108
|
+
""")
|
|
109
|
+
conn.commit()
|
|
110
|
+
except sqlite3.OperationalError as _err:
|
|
111
|
+
import logging
|
|
112
|
+
logging.getLogger(__name__).warning("创建经验表失败: %s", _err)
|
|
113
|
+
|
|
114
|
+
def _load_skills(self) -> None:
|
|
115
|
+
"""重启后从 experience_skills 表恢复内存态"""
|
|
116
|
+
try:
|
|
117
|
+
conn = self.storage._get_conn()
|
|
118
|
+
for row in conn.execute(
|
|
119
|
+
"SELECT name, description, triggers, slots, steps, examples, tags, "
|
|
120
|
+
"source_memory_ids, confidence, cluster_size, updated_at "
|
|
121
|
+
"FROM experience_skills"
|
|
122
|
+
):
|
|
123
|
+
(name, desc, triggers, slots, steps, examples, tags,
|
|
124
|
+
source_ids, confidence, cluster_size, _updated) = row
|
|
125
|
+
from mindforge.modules.skill_extractor import SkillSlot
|
|
126
|
+
parsed_slots = []
|
|
127
|
+
for s in json.loads(slots) if slots else []:
|
|
128
|
+
try:
|
|
129
|
+
parsed_slots.append(SkillSlot(
|
|
130
|
+
name=s.get("name", ""),
|
|
131
|
+
examples=list(s.get("examples", [])),
|
|
132
|
+
required=bool(s.get("required", False)),
|
|
133
|
+
default=s.get("default"),
|
|
134
|
+
))
|
|
135
|
+
except Exception:
|
|
136
|
+
continue
|
|
137
|
+
self._skills[name] = SkillTemplate(
|
|
138
|
+
name=name, description=desc or "",
|
|
139
|
+
triggers=json.loads(triggers) if triggers else [],
|
|
140
|
+
slots=parsed_slots,
|
|
141
|
+
steps=json.loads(steps) if steps else [],
|
|
142
|
+
examples=json.loads(examples) if examples else [],
|
|
143
|
+
tags=json.loads(tags) if tags else [],
|
|
144
|
+
source_memory_ids=json.loads(source_ids) if source_ids else [],
|
|
145
|
+
confidence=float(confidence or 0.0),
|
|
146
|
+
cluster_size=int(cluster_size or 0),
|
|
147
|
+
)
|
|
148
|
+
except sqlite3.OperationalError as _err:
|
|
149
|
+
import logging
|
|
150
|
+
logging.getLogger(__name__).warning("加载技能失败: %s", _err)
|
|
151
|
+
|
|
152
|
+
# -- cases ------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def record_case(self, task: str, content: str = "", result: str = "",
|
|
155
|
+
outcome: str = "success", duration_seconds: float = 0.0,
|
|
156
|
+
tags: Optional[List[str]] = None,
|
|
157
|
+
source_memory_ids: Optional[List[str]] = None,
|
|
158
|
+
agent_id: str = "") -> ExperienceCase:
|
|
159
|
+
"""记录一次任务经验(成功案例)
|
|
160
|
+
|
|
161
|
+
v6.1.2:文本入参在落库前做类型校验。此前 `content` / `result` / `task` /
|
|
162
|
+
`agent_id` 直传 SQLite 绑定,非字符串(如 REST 传进来一个 dict)会抛
|
|
163
|
+
`sqlite3.ProgrammingError: Error binding parameter ...`——该异常既不是
|
|
164
|
+
ValueError 也不是 TypeError,REST 与 MCP 的 400/友好错误分支都接不住,
|
|
165
|
+
对外表现为 500 崩溃(v5.9.0 给 `get()` 修过同类的「入参类型」问题,
|
|
166
|
+
经验链路一直漏着)。现在统一抛 ValueError,三层接口都能给出可读报错。
|
|
167
|
+
|
|
168
|
+
None 按空串处理(与修复前的 SQLite NULL 相比,读回时形态更一致);
|
|
169
|
+
非字符串一律拒绝,不做 `str()` 静默强转——那会把 Python repr 写进库。
|
|
170
|
+
"""
|
|
171
|
+
if task is None or not isinstance(task, str):
|
|
172
|
+
if task is None:
|
|
173
|
+
raise ValueError("task 不能为空")
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"task 必须是字符串,收到 {type(task).__name__}")
|
|
176
|
+
for field, value in (("content", content), ("result", result),
|
|
177
|
+
("agent_id", agent_id)):
|
|
178
|
+
if value is not None and not isinstance(value, str):
|
|
179
|
+
raise ValueError(
|
|
180
|
+
f"{field} 必须是字符串,收到 {type(value).__name__}"
|
|
181
|
+
"(如需存结构化数据,请先序列化为文本)")
|
|
182
|
+
content = content or ""
|
|
183
|
+
result = result or ""
|
|
184
|
+
agent_id = agent_id or ""
|
|
185
|
+
outcome = str(outcome or "success").lower()
|
|
186
|
+
if outcome not in ("success", "partial", "failure"):
|
|
187
|
+
raise ValueError(f"无效 outcome: {outcome}(可选 success/partial/failure)")
|
|
188
|
+
case = ExperienceCase(
|
|
189
|
+
task=task, content=content, result=result, outcome=outcome,
|
|
190
|
+
duration_seconds=max(0.0, float(duration_seconds or 0.0)),
|
|
191
|
+
tags=tags, source_memory_ids=source_memory_ids, agent_id=agent_id,
|
|
192
|
+
)
|
|
193
|
+
if self.storage is not None:
|
|
194
|
+
conn = self.storage._get_conn()
|
|
195
|
+
conn.execute(
|
|
196
|
+
"INSERT INTO experience_cases "
|
|
197
|
+
"(id, task, content, result, outcome, duration_seconds, tags, "
|
|
198
|
+
" source_memory_ids, agent_id, created_at) "
|
|
199
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
200
|
+
(case.id, case.task, case.content, case.result, case.outcome,
|
|
201
|
+
case.duration_seconds,
|
|
202
|
+
json.dumps(case.tags, ensure_ascii=False),
|
|
203
|
+
json.dumps(case.source_memory_ids, ensure_ascii=False),
|
|
204
|
+
case.agent_id, case.created_at))
|
|
205
|
+
conn.commit()
|
|
206
|
+
return case
|
|
207
|
+
|
|
208
|
+
def list_cases(self, limit: int = 100, outcome: str = "") -> List[Dict[str, Any]]:
|
|
209
|
+
if self.storage is None:
|
|
210
|
+
return []
|
|
211
|
+
limit = max(1, min(10000, int(limit)))
|
|
212
|
+
conn = self.storage._get_conn()
|
|
213
|
+
if outcome:
|
|
214
|
+
rows = conn.execute(
|
|
215
|
+
"SELECT id, task, content, result, outcome, duration_seconds, tags, "
|
|
216
|
+
"source_memory_ids, agent_id, created_at FROM experience_cases "
|
|
217
|
+
"WHERE outcome = ? ORDER BY created_at DESC LIMIT ?",
|
|
218
|
+
(str(outcome), limit)).fetchall()
|
|
219
|
+
else:
|
|
220
|
+
rows = conn.execute(
|
|
221
|
+
"SELECT id, task, content, result, outcome, duration_seconds, tags, "
|
|
222
|
+
"source_memory_ids, agent_id, created_at FROM experience_cases "
|
|
223
|
+
"ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()
|
|
224
|
+
out = []
|
|
225
|
+
for r in rows:
|
|
226
|
+
out.append({
|
|
227
|
+
"id": r[0], "task": r[1], "content": r[2] or "", "result": r[3] or "",
|
|
228
|
+
"outcome": r[4], "duration_seconds": round(r[5] or 0.0, 3),
|
|
229
|
+
"tags": json.loads(r[6]) if r[6] else [],
|
|
230
|
+
"source_memory_ids": json.loads(r[7]) if r[7] else [],
|
|
231
|
+
"agent_id": r[8] or "", "created_at": round(r[9] or 0.0, 3),
|
|
232
|
+
})
|
|
233
|
+
return out
|
|
234
|
+
|
|
235
|
+
def delete_case(self, case_id: str) -> bool:
|
|
236
|
+
if self.storage is None:
|
|
237
|
+
return False
|
|
238
|
+
conn = self.storage._get_conn()
|
|
239
|
+
cur = conn.execute("DELETE FROM experience_cases WHERE id = ?", (case_id,))
|
|
240
|
+
conn.commit()
|
|
241
|
+
return cur.rowcount > 0
|
|
242
|
+
|
|
243
|
+
def delete_skill(self, name: str) -> bool:
|
|
244
|
+
"""删除指定技能(v6.0.0 技能包功能:replace 模式先删后写)"""
|
|
245
|
+
if self.storage is None or not name:
|
|
246
|
+
return False
|
|
247
|
+
conn = self.storage._get_conn()
|
|
248
|
+
cur = conn.execute("DELETE FROM experience_skills WHERE name = ?", (name,))
|
|
249
|
+
conn.commit()
|
|
250
|
+
if cur.rowcount > 0:
|
|
251
|
+
self._skills.pop(name, None)
|
|
252
|
+
return True
|
|
253
|
+
# 大小写不敏感兜底:get_skill 已按小写匹配,这里同步删内存态
|
|
254
|
+
for key in [k for k in self._skills if k.lower() == name.lower()]:
|
|
255
|
+
self._skills.pop(key, None)
|
|
256
|
+
return True
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
# -- skills -----------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def save_skills(self, templates: List[SkillTemplate]) -> int:
|
|
262
|
+
"""UPSERT 技能模板(按 name 合并 source_memory_ids)"""
|
|
263
|
+
saved = 0
|
|
264
|
+
if self.storage is None:
|
|
265
|
+
return 0
|
|
266
|
+
conn = self.storage._get_conn()
|
|
267
|
+
for t in templates:
|
|
268
|
+
existing = self._skills.get(t.name)
|
|
269
|
+
source_ids = list(t.source_memory_ids)
|
|
270
|
+
if existing:
|
|
271
|
+
for sid in existing.source_memory_ids:
|
|
272
|
+
if sid not in source_ids:
|
|
273
|
+
source_ids.append(sid)
|
|
274
|
+
now = time.time()
|
|
275
|
+
conn.execute(
|
|
276
|
+
"INSERT INTO experience_skills (name, description, triggers, slots, "
|
|
277
|
+
" steps, examples, tags, source_memory_ids, confidence, cluster_size, "
|
|
278
|
+
" updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
|
279
|
+
"ON CONFLICT(name) DO UPDATE SET "
|
|
280
|
+
" description=excluded.description, triggers=excluded.triggers, "
|
|
281
|
+
" slots=excluded.slots, steps=excluded.steps, examples=excluded.examples, "
|
|
282
|
+
" tags=excluded.tags, source_memory_ids=excluded.source_memory_ids, "
|
|
283
|
+
" confidence=excluded.confidence, cluster_size=excluded.cluster_size, "
|
|
284
|
+
" updated_at=excluded.updated_at",
|
|
285
|
+
(t.name, t.description,
|
|
286
|
+
json.dumps(t.triggers, ensure_ascii=False),
|
|
287
|
+
json.dumps([s.to_dict() for s in t.slots], ensure_ascii=False),
|
|
288
|
+
json.dumps(t.steps, ensure_ascii=False),
|
|
289
|
+
json.dumps(t.examples, ensure_ascii=False),
|
|
290
|
+
json.dumps(t.tags, ensure_ascii=False),
|
|
291
|
+
json.dumps(source_ids, ensure_ascii=False),
|
|
292
|
+
float(t.confidence), int(t.cluster_size), now))
|
|
293
|
+
self._skills[t.name] = t
|
|
294
|
+
saved += 1
|
|
295
|
+
conn.commit()
|
|
296
|
+
return saved
|
|
297
|
+
|
|
298
|
+
def distill(self, min_cluster_size: int = 2, max_skills: int = 50,
|
|
299
|
+
outcome: str = "success") -> Dict[str, Any]:
|
|
300
|
+
"""从成功案例批量蒸馏技能模板并落库"""
|
|
301
|
+
cases = self.list_cases(limit=10000, outcome=outcome)
|
|
302
|
+
mem_dicts = []
|
|
303
|
+
for c in cases:
|
|
304
|
+
content = c["content"] or c["task"] or ""
|
|
305
|
+
tags = list(c["tags"]) or []
|
|
306
|
+
if c["task"] and c["task"] != content:
|
|
307
|
+
content = c["task"] + ":" + content
|
|
308
|
+
if c["outcome"] == "success":
|
|
309
|
+
tags = list(set(tags) | {"success"})
|
|
310
|
+
mem_dicts.append({
|
|
311
|
+
"id": c["id"], "content": content, "tags": tags,
|
|
312
|
+
"category": "experience",
|
|
313
|
+
})
|
|
314
|
+
extractor = SkillExtractor(min_cluster_size=max(1, int(min_cluster_size)),
|
|
315
|
+
max_skills=max_skills)
|
|
316
|
+
templates = extractor.extract(mem_dicts)
|
|
317
|
+
saved = self.save_skills(templates)
|
|
318
|
+
return {
|
|
319
|
+
"cases_processed": len(mem_dicts),
|
|
320
|
+
"skills_found": len(templates),
|
|
321
|
+
"skills_saved": saved,
|
|
322
|
+
"skills": [t.to_dict() for t in templates[:50]],
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
def list_skills(self, limit: int = 50) -> List[Dict[str, Any]]:
|
|
326
|
+
# v6.1.2 修复:原来是「先按插入序截前 limit,再排序」,于是返回的是
|
|
327
|
+
# 「插入顺序前 N 名内部有序」,而不是「全局 top-N」——limit 小于技能总数
|
|
328
|
+
# 时,置信度更高但入库更晚的技能会被整批漏掉。改为先排序后截断。
|
|
329
|
+
limit = max(1, min(1000, int(limit)))
|
|
330
|
+
out = [t.to_dict() for t in self._skills.values()]
|
|
331
|
+
out.sort(key=lambda s: (-s["confidence"], -s["cluster_size"]))
|
|
332
|
+
return out[:limit]
|
|
333
|
+
|
|
334
|
+
def get_skill(self, name: str) -> Optional[SkillTemplate]:
|
|
335
|
+
for key, t in self._skills.items():
|
|
336
|
+
if key.lower() == name.lower():
|
|
337
|
+
return t
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
def match_skills(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
|
|
341
|
+
"""按新任务查询最匹配的已存技能(触发词/标签/bigram 三重评分)"""
|
|
342
|
+
q = str(query or "").strip().lower()
|
|
343
|
+
if not q:
|
|
344
|
+
return []
|
|
345
|
+
qn = self._ngrams(q)
|
|
346
|
+
scored = []
|
|
347
|
+
for t in self._skills.values():
|
|
348
|
+
score = 0.0
|
|
349
|
+
if any(tg.lower() in q or q in tg.lower() for tg in t.triggers):
|
|
350
|
+
score += 0.6
|
|
351
|
+
if any(tg.lower() in q for tg in t.tags):
|
|
352
|
+
score += 0.3
|
|
353
|
+
if qn:
|
|
354
|
+
hay = t.description + " " + " ".join(t.triggers) + " " + t.name
|
|
355
|
+
sn = self._ngrams(hay)
|
|
356
|
+
score += (len(qn & sn) / max(1, len(qn))) * 0.4
|
|
357
|
+
if score > 0:
|
|
358
|
+
scored.append((score, t))
|
|
359
|
+
scored.sort(key=lambda x: (-x[0], -x[1].confidence))
|
|
360
|
+
return [t.to_dict() for _, t in scored[:max(1, min(50, int(limit)))]]
|
|
361
|
+
|
|
362
|
+
def render_skill(self, name: str, **kwargs) -> Optional[str]:
|
|
363
|
+
t = self.get_skill(name)
|
|
364
|
+
if t is None:
|
|
365
|
+
return None
|
|
366
|
+
return t.render(**kwargs)
|
|
367
|
+
|
|
368
|
+
def stats(self) -> Dict[str, Any]:
|
|
369
|
+
conn = self.storage._get_conn() if self.storage is not None else None
|
|
370
|
+
case_count = 0
|
|
371
|
+
skill_count = len(self._skills)
|
|
372
|
+
if conn is not None:
|
|
373
|
+
try:
|
|
374
|
+
case_count = conn.execute(
|
|
375
|
+
"SELECT COUNT(*) FROM experience_cases").fetchone()[0]
|
|
376
|
+
except sqlite3.OperationalError:
|
|
377
|
+
case_count = 0
|
|
378
|
+
outcome_counts: Dict[str, int] = {}
|
|
379
|
+
if conn is not None:
|
|
380
|
+
try:
|
|
381
|
+
for row in conn.execute(
|
|
382
|
+
"SELECT outcome, COUNT(*) FROM experience_cases GROUP BY outcome"):
|
|
383
|
+
outcome_counts[row[0]] = row[1]
|
|
384
|
+
except sqlite3.OperationalError:
|
|
385
|
+
pass
|
|
386
|
+
return {
|
|
387
|
+
"total_cases": int(case_count),
|
|
388
|
+
"total_skills": int(skill_count),
|
|
389
|
+
"outcome_counts": outcome_counts,
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
# -- helpers ----------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
@staticmethod
|
|
395
|
+
def _ngrams(text: str, n: int = 2) -> set:
|
|
396
|
+
s = re.sub(r"[\W_]+", "", text)
|
|
397
|
+
if len(s) < n:
|
|
398
|
+
return {s} if s else set()
|
|
399
|
+
return {s[i:i + n] for i in range(len(s) - n + 1)}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
__all__ = ["ExperienceCase", "SkillStore"]
|