adaptive-memory-engine 0.1.6__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.
- adaptive_memory_engine-0.1.6.dist-info/METADATA +228 -0
- adaptive_memory_engine-0.1.6.dist-info/RECORD +72 -0
- adaptive_memory_engine-0.1.6.dist-info/WHEEL +4 -0
- adaptive_memory_engine-0.1.6.dist-info/entry_points.txt +3 -0
- adaptive_memory_engine-0.1.6.dist-info/licenses/LICENSE +21 -0
- ame/__init__.py +1 -0
- ame/agent/__init__.py +1 -0
- ame/agent/mcp.py +474 -0
- ame/agent/memory_api.py +141 -0
- ame/agent/results.py +30 -0
- ame/bronze/schema.py +17 -0
- ame/bronze/store.py +38 -0
- ame/cli/__init__.py +1 -0
- ame/cli/main.py +903 -0
- ame/connectors/base.py +30 -0
- ame/connectors/contract.py +199 -0
- ame/connectors/github.py +66 -0
- ame/connectors/google.py +464 -0
- ame/connectors/google_oauth.py +156 -0
- ame/connectors/jira.py +66 -0
- ame/connectors/json_helpers.py +43 -0
- ame/connectors/markdown.py +116 -0
- ame/connectors/notion.py +59 -0
- ame/connectors/oauth_callback.py +102 -0
- ame/connectors/oauth_provider.py +250 -0
- ame/connectors/obsidian.py +19 -0
- ame/connectors/router.py +155 -0
- ame/connectors/slack.py +66 -0
- ame/connectors/slack_oauth.py +417 -0
- ame/connectors/sync_history.py +73 -0
- ame/context_budget.py +106 -0
- ame/core/config.py +77 -0
- ame/core/corpus.py +17 -0
- ame/core/errors.py +18 -0
- ame/core/paths.py +111 -0
- ame/core/state.py +57 -0
- ame/export/obsidian.py +123 -0
- ame/gold/builder.py +300 -0
- ame/gold/ontology.py +80 -0
- ame/gold/resolver.py +91 -0
- ame/gold/schema.py +40 -0
- ame/gold/store.py +45 -0
- ame/hardware/profiler.py +85 -0
- ame/hardware/tier.py +27 -0
- ame/hermes/__init__.py +3 -0
- ame/hermes/memory.py +209 -0
- ame/models/download.py +243 -0
- ame/models/ollama.py +60 -0
- ame/models/registry.py +101 -0
- ame/models/router.py +22 -0
- ame/pipeline.py +155 -0
- ame/query/diff.py +40 -0
- ame/query/engine.py +919 -0
- ame/query/memory_os.py +313 -0
- ame/query/mql.py +84 -0
- ame/query/multihop.py +264 -0
- ame/query/result.py +20 -0
- ame/sdk.py +52 -0
- ame/security.py +145 -0
- ame/silver/extractor.py +414 -0
- ame/silver/llm_extractor.py +181 -0
- ame/silver/prompts.py +56 -0
- ame/silver/rationale.py +140 -0
- ame/silver/schema.py +51 -0
- ame/silver/store.py +59 -0
- ame/storage/custom_kg.py +33 -0
- ame/storage/lightrag_adapter.py +362 -0
- ame/validation/confidence.py +5 -0
- ame/validation/grounding.py +10 -0
- ame/validation/type_gate.py +22 -0
- ame/writeback.py +173 -0
- memory/__init__.py +3 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import importlib.util
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
from urllib.request import urlopen
|
|
10
|
+
|
|
11
|
+
from ame.bronze.schema import BronzeDocument
|
|
12
|
+
from ame.bronze.store import BronzeStore
|
|
13
|
+
from ame.core.config import LightRagConfig, load_config
|
|
14
|
+
from ame.core.errors import LightRagBackendError
|
|
15
|
+
from ame.gold.schema import GoldEdge, GoldNode
|
|
16
|
+
from ame.gold.store import GoldStore
|
|
17
|
+
from ame.models.download import ModelDownloadError, OllamaModelInstaller, is_model_installed
|
|
18
|
+
from ame.query.engine import QueryEngine
|
|
19
|
+
from ame.query.result import QueryResult
|
|
20
|
+
from ame.storage.custom_kg import to_custom_kg
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LightRagBackend(Protocol):
|
|
24
|
+
name: str
|
|
25
|
+
|
|
26
|
+
async def initialize(self) -> None:
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
async def insert_custom_kg(self, custom_kg: dict) -> None:
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
async def query(self, question: str, mode: str) -> QueryResult:
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
def status(self) -> dict:
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FilesystemLightRagBackend:
|
|
40
|
+
name = "filesystem"
|
|
41
|
+
|
|
42
|
+
def __init__(self, corpus_root: Path, config: LightRagConfig | None = None):
|
|
43
|
+
self.corpus_root = corpus_root
|
|
44
|
+
self.config = config or LightRagConfig()
|
|
45
|
+
|
|
46
|
+
async def initialize(self) -> None:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
async def insert_custom_kg(self, custom_kg: dict) -> None:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
async def query(self, question: str, mode: str) -> QueryResult:
|
|
53
|
+
result = QueryEngine(BronzeStore(self.corpus_root), GoldStore(self.corpus_root)).query(question)
|
|
54
|
+
raw = result.raw or {}
|
|
55
|
+
raw["adapter"] = {"backend": self.name, "mode": mode}
|
|
56
|
+
result.raw = raw
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
def status(self) -> dict:
|
|
60
|
+
return {
|
|
61
|
+
"backend": self.name,
|
|
62
|
+
"package_available": light_rag_package_available(),
|
|
63
|
+
**ollama_server_status(self.config.ollama_host),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class UnavailableLightRagBackend:
|
|
68
|
+
name = "lightrag-core"
|
|
69
|
+
|
|
70
|
+
def __init__(self, reason: str, config: LightRagConfig | None = None):
|
|
71
|
+
self.reason = reason
|
|
72
|
+
self.config = config or LightRagConfig()
|
|
73
|
+
|
|
74
|
+
async def initialize(self) -> None:
|
|
75
|
+
raise LightRagBackendError(self.reason)
|
|
76
|
+
|
|
77
|
+
async def insert_custom_kg(self, custom_kg: dict) -> None:
|
|
78
|
+
raise LightRagBackendError(self.reason)
|
|
79
|
+
|
|
80
|
+
async def query(self, question: str, mode: str) -> QueryResult:
|
|
81
|
+
raise LightRagBackendError(self.reason)
|
|
82
|
+
|
|
83
|
+
def status(self) -> dict:
|
|
84
|
+
return {
|
|
85
|
+
"backend": self.name,
|
|
86
|
+
"package_available": light_rag_package_available(),
|
|
87
|
+
"available": False,
|
|
88
|
+
"error": self.reason,
|
|
89
|
+
**ollama_server_status(self.config.ollama_host),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class DeferredCoreLightRagBackend:
|
|
94
|
+
name = "lightrag-core"
|
|
95
|
+
|
|
96
|
+
def __init__(self, corpus_root: Path, config: LightRagConfig):
|
|
97
|
+
self.corpus_root = corpus_root
|
|
98
|
+
self.config = config
|
|
99
|
+
self.backend: CoreLightRagBackend | None = None
|
|
100
|
+
|
|
101
|
+
async def initialize(self) -> None:
|
|
102
|
+
await self._backend().initialize()
|
|
103
|
+
|
|
104
|
+
async def insert_custom_kg(self, custom_kg: dict) -> None:
|
|
105
|
+
await self._backend().insert_custom_kg(custom_kg)
|
|
106
|
+
|
|
107
|
+
async def query(self, question: str, mode: str) -> QueryResult:
|
|
108
|
+
return await self._backend().query(question, mode)
|
|
109
|
+
|
|
110
|
+
def status(self) -> dict:
|
|
111
|
+
if self.backend is not None:
|
|
112
|
+
return self.backend.status()
|
|
113
|
+
package_available = light_rag_package_available()
|
|
114
|
+
status = {
|
|
115
|
+
"backend": self.name,
|
|
116
|
+
"package_available": package_available,
|
|
117
|
+
"initialized": False,
|
|
118
|
+
"available": package_available,
|
|
119
|
+
**ollama_server_status(self.config.ollama_host),
|
|
120
|
+
}
|
|
121
|
+
if not package_available:
|
|
122
|
+
status["error"] = "LightRAG core package is not installed."
|
|
123
|
+
return status
|
|
124
|
+
|
|
125
|
+
def _backend(self) -> CoreLightRagBackend:
|
|
126
|
+
if self.backend is None:
|
|
127
|
+
self.backend = CoreLightRagBackend.from_config(self.corpus_root, self.config)
|
|
128
|
+
return self.backend
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class CoreLightRagBackend:
|
|
132
|
+
name = "lightrag-core"
|
|
133
|
+
|
|
134
|
+
def __init__(self, rag: object, query_param_cls: type | None = None, config: LightRagConfig | None = None):
|
|
135
|
+
self.rag = rag
|
|
136
|
+
self.query_param_cls = query_param_cls
|
|
137
|
+
self.config = config or LightRagConfig()
|
|
138
|
+
self.initialized = False
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def from_config(cls, corpus_root: Path, config: LightRagConfig) -> CoreLightRagBackend:
|
|
142
|
+
if not light_rag_package_available():
|
|
143
|
+
raise LightRagBackendError("LightRAG core package is not installed.")
|
|
144
|
+
try:
|
|
145
|
+
from lightrag import LightRAG, QueryParam
|
|
146
|
+
from lightrag.llm.ollama import ollama_embed, ollama_model_complete
|
|
147
|
+
from lightrag.utils import Tokenizer, wrap_embedding_func_with_attrs
|
|
148
|
+
except ImportError as exc:
|
|
149
|
+
raise LightRagBackendError(f"LightRAG core imports failed: {exc}") from exc
|
|
150
|
+
|
|
151
|
+
@wrap_embedding_func_with_attrs(
|
|
152
|
+
embedding_dim=config.embedding_dim,
|
|
153
|
+
max_token_size=config.max_token_size,
|
|
154
|
+
model_name=config.embedding_model,
|
|
155
|
+
)
|
|
156
|
+
async def embedding_func(texts: list[str]):
|
|
157
|
+
return await ollama_embed.func(texts, embed_model=config.embedding_model, host=config.ollama_host)
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
rag = LightRAG(
|
|
161
|
+
working_dir=str(corpus_root / "store" / "lightrag" / "core"),
|
|
162
|
+
llm_model_func=ollama_model_complete,
|
|
163
|
+
llm_model_name=config.llm_model,
|
|
164
|
+
llm_model_kwargs={"host": config.ollama_host},
|
|
165
|
+
embedding_func=embedding_func,
|
|
166
|
+
tokenizer=Tokenizer("ame-char", CharTokenizer()),
|
|
167
|
+
)
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
raise LightRagBackendError(f"LightRAG core initialization failed: {exc}") from exc
|
|
170
|
+
return cls(rag, QueryParam, config)
|
|
171
|
+
|
|
172
|
+
async def initialize(self) -> None:
|
|
173
|
+
method = getattr(self.rag, "initialize_storages", None)
|
|
174
|
+
if method is not None:
|
|
175
|
+
try:
|
|
176
|
+
await _maybe_await(method())
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
raise LightRagBackendError(f"LightRAG storage initialization failed: {exc}") from exc
|
|
179
|
+
self.initialized = True
|
|
180
|
+
|
|
181
|
+
async def insert_custom_kg(self, custom_kg: dict) -> None:
|
|
182
|
+
method = getattr(self.rag, "ainsert_custom_kg", None) or getattr(self.rag, "insert_custom_kg", None)
|
|
183
|
+
if method is None:
|
|
184
|
+
raise LightRagBackendError("LightRAG core object does not expose insert_custom_kg.")
|
|
185
|
+
try:
|
|
186
|
+
await _maybe_await(method(custom_kg))
|
|
187
|
+
except Exception as exc:
|
|
188
|
+
raise LightRagBackendError(f"LightRAG custom KG insert failed: {exc}") from exc
|
|
189
|
+
|
|
190
|
+
async def query(self, question: str, mode: str) -> QueryResult:
|
|
191
|
+
if not self.initialized:
|
|
192
|
+
await self.initialize()
|
|
193
|
+
param = self.query_param_cls(mode=mode) if self.query_param_cls is not None else None
|
|
194
|
+
method = getattr(self.rag, "aquery", None) or getattr(self.rag, "query", None)
|
|
195
|
+
if method is None:
|
|
196
|
+
raise LightRagBackendError("LightRAG core object does not expose query.")
|
|
197
|
+
try:
|
|
198
|
+
raw_answer = await _maybe_await(method(question, param=param))
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
raise LightRagBackendError(f"LightRAG query failed: {exc}") from exc
|
|
201
|
+
if raw_answer is None:
|
|
202
|
+
raise LightRagBackendError("LightRAG query returned no answer.")
|
|
203
|
+
answer = raw_answer if isinstance(raw_answer, str) else json.dumps(raw_answer, ensure_ascii=False)
|
|
204
|
+
return QueryResult(
|
|
205
|
+
answer=answer,
|
|
206
|
+
matches=[answer],
|
|
207
|
+
sources=[],
|
|
208
|
+
confidence=None,
|
|
209
|
+
raw={"adapter": {"backend": self.name, "mode": mode}},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def status(self) -> dict:
|
|
213
|
+
return {
|
|
214
|
+
"backend": self.name,
|
|
215
|
+
"package_available": light_rag_package_available(),
|
|
216
|
+
"initialized": self.initialized,
|
|
217
|
+
**ollama_server_status(self.config.ollama_host),
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class LightRagAdapter:
|
|
222
|
+
"""LightRAG boundary with a local filesystem fallback.
|
|
223
|
+
|
|
224
|
+
The MVP always stages a LightRAG-compatible custom_kg payload. Until the
|
|
225
|
+
external LightRAG runtime is wired in, query uses the same Gold/Bronze data
|
|
226
|
+
through a local fallback so the CLI contract is stable.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
def __init__(self, corpus_root: Path, backend: LightRagBackend | None = None, config: LightRagConfig | None = None):
|
|
230
|
+
self.corpus_root = corpus_root
|
|
231
|
+
self.root = corpus_root / "store" / "lightrag"
|
|
232
|
+
self.custom_kg_path = self.root / "custom_kg.json"
|
|
233
|
+
self.state_path = self.root / "adapter_state.json"
|
|
234
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
self.config = config or load_config().lightrag
|
|
236
|
+
self.backend = backend or self._select_backend(self.config)
|
|
237
|
+
|
|
238
|
+
async def initialize(self) -> None:
|
|
239
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
await self.backend.initialize()
|
|
241
|
+
state = {"initialized": True}
|
|
242
|
+
state.update(self.backend.status())
|
|
243
|
+
self._write_state(**state)
|
|
244
|
+
|
|
245
|
+
async def insert_gold(self, nodes: list[GoldNode], edges: list[GoldEdge], chunks: list[BronzeDocument] | None = None) -> Path:
|
|
246
|
+
payload = to_custom_kg(nodes, edges, chunks)
|
|
247
|
+
self.custom_kg_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
248
|
+
state = {
|
|
249
|
+
"initialized": False,
|
|
250
|
+
"custom_kg_path": str(self.custom_kg_path),
|
|
251
|
+
"chunks": len(payload["chunks"]),
|
|
252
|
+
"entities": len(payload["entities"]),
|
|
253
|
+
"relationships": len(payload["relationships"]),
|
|
254
|
+
}
|
|
255
|
+
state.update(self.backend.status())
|
|
256
|
+
self._write_state(**state)
|
|
257
|
+
try:
|
|
258
|
+
await self.backend.initialize()
|
|
259
|
+
await self.backend.insert_custom_kg(payload)
|
|
260
|
+
except LightRagBackendError as exc:
|
|
261
|
+
failed_state = dict(state)
|
|
262
|
+
failed_state.update(self.backend.status())
|
|
263
|
+
failed_state["backend_error"] = str(exc)
|
|
264
|
+
self._write_state(**failed_state)
|
|
265
|
+
raise
|
|
266
|
+
state["initialized"] = True
|
|
267
|
+
state["backend_error"] = None
|
|
268
|
+
state.update(self.backend.status())
|
|
269
|
+
self._write_state(**state)
|
|
270
|
+
return self.custom_kg_path
|
|
271
|
+
|
|
272
|
+
async def query(self, question: str, mode: str = "hybrid") -> QueryResult:
|
|
273
|
+
return await self.backend.query(question, mode)
|
|
274
|
+
|
|
275
|
+
def sync(self, nodes: list[GoldNode], edges: list[GoldEdge], chunks: list[BronzeDocument] | None = None) -> Path:
|
|
276
|
+
return _run_sync(self.insert_gold(nodes, edges, chunks))
|
|
277
|
+
|
|
278
|
+
def status(self) -> dict:
|
|
279
|
+
backend_status = self.backend.status()
|
|
280
|
+
current = {"initialized": False, "custom_kg_path": str(self.custom_kg_path), **backend_status}
|
|
281
|
+
if not self.state_path.exists():
|
|
282
|
+
return current
|
|
283
|
+
persisted = json.loads(self.state_path.read_text(encoding="utf-8"))
|
|
284
|
+
current.update(persisted)
|
|
285
|
+
current.update(backend_status)
|
|
286
|
+
if persisted.get("initialized") is True and backend_status.get("initialized") is False:
|
|
287
|
+
current["initialized"] = True
|
|
288
|
+
return current
|
|
289
|
+
|
|
290
|
+
def _select_backend(self, config: LightRagConfig) -> LightRagBackend:
|
|
291
|
+
if config.backend == "filesystem":
|
|
292
|
+
return FilesystemLightRagBackend(self.corpus_root, config)
|
|
293
|
+
if config.backend == "core":
|
|
294
|
+
return DeferredCoreLightRagBackend(self.corpus_root, config)
|
|
295
|
+
if self._core_backend_available(config):
|
|
296
|
+
return DeferredCoreLightRagBackend(self.corpus_root, config)
|
|
297
|
+
return FilesystemLightRagBackend(self.corpus_root, config)
|
|
298
|
+
|
|
299
|
+
def _core_backend_available(self, config: LightRagConfig) -> bool:
|
|
300
|
+
if not light_rag_package_available():
|
|
301
|
+
return False
|
|
302
|
+
status = ollama_server_status(config.ollama_host)
|
|
303
|
+
if not status.get("ollama_server_available"):
|
|
304
|
+
return False
|
|
305
|
+
try:
|
|
306
|
+
installed = OllamaModelInstaller(host=config.ollama_host).installed_models()
|
|
307
|
+
except ModelDownloadError:
|
|
308
|
+
return False
|
|
309
|
+
return is_model_installed(config.llm_model, installed) and is_model_installed(config.embedding_model, installed)
|
|
310
|
+
|
|
311
|
+
def _write_state(self, **values: object) -> None:
|
|
312
|
+
state = {
|
|
313
|
+
"backend": "filesystem",
|
|
314
|
+
"initialized": False,
|
|
315
|
+
"custom_kg_path": str(self.custom_kg_path),
|
|
316
|
+
}
|
|
317
|
+
if self.state_path.exists():
|
|
318
|
+
state.update(json.loads(self.state_path.read_text(encoding="utf-8")))
|
|
319
|
+
state.update(values)
|
|
320
|
+
self.state_path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def light_rag_package_available() -> bool:
|
|
324
|
+
return importlib.util.find_spec("lightrag") is not None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def ollama_server_status(host: str) -> dict:
|
|
328
|
+
url = f"{host.rstrip('/')}/api/tags"
|
|
329
|
+
try:
|
|
330
|
+
with urlopen(url, timeout=2) as response:
|
|
331
|
+
return {
|
|
332
|
+
"ollama_host": host,
|
|
333
|
+
"ollama_server_available": 200 <= response.status < 300,
|
|
334
|
+
}
|
|
335
|
+
except Exception as exc:
|
|
336
|
+
return {
|
|
337
|
+
"ollama_host": host,
|
|
338
|
+
"ollama_server_available": False,
|
|
339
|
+
"ollama_server_error": str(exc),
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class CharTokenizer:
|
|
344
|
+
def encode(self, content: str) -> list[int]:
|
|
345
|
+
return [ord(char) for char in content]
|
|
346
|
+
|
|
347
|
+
def decode(self, tokens: list[int]) -> str:
|
|
348
|
+
return "".join(chr(token) for token in tokens)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
async def _maybe_await(value):
|
|
352
|
+
if inspect.isawaitable(value):
|
|
353
|
+
return await value
|
|
354
|
+
return value
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _run_sync(coro):
|
|
358
|
+
try:
|
|
359
|
+
asyncio.get_running_loop()
|
|
360
|
+
except RuntimeError:
|
|
361
|
+
return asyncio.run(coro)
|
|
362
|
+
raise LightRagBackendError("LightRagAdapter.sync cannot run inside an active event loop; use insert_gold instead.")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ame.bronze.schema import BronzeDocument
|
|
4
|
+
from ame.silver.schema import SilverEntity
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def is_grounded(entity: SilverEntity, docs: dict[str, BronzeDocument]) -> bool:
|
|
8
|
+
if entity.span is None:
|
|
9
|
+
return True
|
|
10
|
+
return any(entity.span in docs[source_id].content for source_id in entity.source_ids if source_id in docs)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ame.gold.ontology import DEFAULT_RELATION_RULES
|
|
4
|
+
from ame.silver.schema import SilverEntity, SilverRelation
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
RELATION_RULES = DEFAULT_RELATION_RULES
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def relation_type_valid(relation: SilverRelation, entities: list[SilverEntity]) -> bool:
|
|
11
|
+
rule = RELATION_RULES.get(relation.predicate)
|
|
12
|
+
if not rule:
|
|
13
|
+
return False
|
|
14
|
+
by_name: dict[str, set[str]] = {}
|
|
15
|
+
for entity in entities:
|
|
16
|
+
by_name.setdefault(entity.name, set()).add(entity.type)
|
|
17
|
+
subject_types = by_name.get(relation.subject)
|
|
18
|
+
object_types = by_name.get(relation.object)
|
|
19
|
+
if subject_types is None or object_types is None:
|
|
20
|
+
return False
|
|
21
|
+
domains, ranges = rule
|
|
22
|
+
return bool(subject_types & domains) and bool(object_types & ranges)
|
ame/writeback.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ame.bronze.schema import BronzeDocument
|
|
9
|
+
from ame.bronze.store import BronzeStore
|
|
10
|
+
from ame.connectors.markdown import MarkdownConnector
|
|
11
|
+
from ame.core.corpus import require_corpus
|
|
12
|
+
from ame.gold.builder import GoldBuilder
|
|
13
|
+
from ame.gold.store import GoldStore
|
|
14
|
+
from ame.silver.extractor import DeterministicExtractor
|
|
15
|
+
from ame.silver.rationale import RationaleExtractor
|
|
16
|
+
from ame.silver.schema import SilverDecision, SilverEntity, SilverRelation
|
|
17
|
+
from ame.silver.store import SilverStore
|
|
18
|
+
from ame.storage.lightrag_adapter import LightRagAdapter
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MemoryWriter:
|
|
22
|
+
def write_decision(
|
|
23
|
+
self,
|
|
24
|
+
corpus_id: str,
|
|
25
|
+
title: str,
|
|
26
|
+
rationale: str,
|
|
27
|
+
project: str | None = None,
|
|
28
|
+
status: str = "accepted",
|
|
29
|
+
participants: list[str] | None = None,
|
|
30
|
+
source: str = "writeback",
|
|
31
|
+
) -> Path:
|
|
32
|
+
corpus_root = require_corpus(corpus_id)
|
|
33
|
+
path = corpus_root / "writeback" / "decisions" / f"{self._safe_filename(title)}.md"
|
|
34
|
+
participants = participants or []
|
|
35
|
+
date = datetime.now(timezone.utc).date().isoformat()
|
|
36
|
+
frontmatter = ["---", f'title: "{title}"', f"date: {date}", f"source: {source}"]
|
|
37
|
+
if project:
|
|
38
|
+
frontmatter.append(f"project: {project}")
|
|
39
|
+
frontmatter.append("---")
|
|
40
|
+
content = "\n".join(
|
|
41
|
+
[
|
|
42
|
+
*frontmatter,
|
|
43
|
+
"",
|
|
44
|
+
f"# {title}",
|
|
45
|
+
"",
|
|
46
|
+
f"{title} 결정했다.",
|
|
47
|
+
"",
|
|
48
|
+
"## Rationale",
|
|
49
|
+
rationale,
|
|
50
|
+
"",
|
|
51
|
+
"## Participants",
|
|
52
|
+
*[f"- {participant}" for participant in participants],
|
|
53
|
+
"",
|
|
54
|
+
]
|
|
55
|
+
)
|
|
56
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
path.write_text(content, encoding="utf-8")
|
|
58
|
+
decision = SilverDecision(
|
|
59
|
+
id=self._id("decision", corpus_id, title, date),
|
|
60
|
+
corpus_id=corpus_id,
|
|
61
|
+
title=title,
|
|
62
|
+
status=status, # type: ignore[arg-type]
|
|
63
|
+
project=project,
|
|
64
|
+
rationale=rationale,
|
|
65
|
+
decision_date=date,
|
|
66
|
+
participants=participants,
|
|
67
|
+
source_ids=[],
|
|
68
|
+
confidence=0.95,
|
|
69
|
+
)
|
|
70
|
+
self._append_markdown_file(corpus_id, path, explicit_decision=decision)
|
|
71
|
+
return path
|
|
72
|
+
|
|
73
|
+
def write_note(self, corpus_id: str, title: str, content: str, source: str = "writeback") -> Path:
|
|
74
|
+
corpus_root = require_corpus(corpus_id)
|
|
75
|
+
path = corpus_root / "writeback" / "notes" / f"{self._safe_filename(title)}.md"
|
|
76
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
path.write_text(
|
|
78
|
+
"\n".join(["---", f'title: "{title}"', f"source: {source}", "---", "", f"# {title}", "", content, ""]),
|
|
79
|
+
encoding="utf-8",
|
|
80
|
+
)
|
|
81
|
+
self._append_markdown_file(corpus_id, path)
|
|
82
|
+
return path
|
|
83
|
+
|
|
84
|
+
def _append_markdown_file(self, corpus_id: str, path: Path, explicit_decision: SilverDecision | None = None) -> None:
|
|
85
|
+
corpus_root = require_corpus(corpus_id)
|
|
86
|
+
connector = MarkdownConnector()
|
|
87
|
+
ref = connector.scan(path)[0]
|
|
88
|
+
doc = BronzeStore(corpus_root).put(connector.load(corpus_id, ref))
|
|
89
|
+
|
|
90
|
+
entities, relations, decisions = DeterministicExtractor().extract(doc)
|
|
91
|
+
if explicit_decision:
|
|
92
|
+
explicit_decision.source_ids = [doc.id]
|
|
93
|
+
decisions = [decision for decision in decisions if decision.title.casefold() != explicit_decision.title.casefold()]
|
|
94
|
+
decisions.append(explicit_decision)
|
|
95
|
+
entities.append(
|
|
96
|
+
SilverEntity(
|
|
97
|
+
id=f"entity_{explicit_decision.id}",
|
|
98
|
+
corpus_id=corpus_id,
|
|
99
|
+
type="Decision",
|
|
100
|
+
name=explicit_decision.title,
|
|
101
|
+
span=explicit_decision.title if explicit_decision.title in doc.content else None,
|
|
102
|
+
source_ids=[doc.id],
|
|
103
|
+
confidence=explicit_decision.confidence,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
if explicit_decision.project:
|
|
107
|
+
entities.append(
|
|
108
|
+
SilverEntity(
|
|
109
|
+
id=self._id("entity", corpus_id, "Project", explicit_decision.project),
|
|
110
|
+
corpus_id=corpus_id,
|
|
111
|
+
type="Project",
|
|
112
|
+
name=explicit_decision.project,
|
|
113
|
+
span=explicit_decision.project if explicit_decision.project in doc.content else None,
|
|
114
|
+
source_ids=[doc.id],
|
|
115
|
+
confidence=0.95,
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
silver = SilverStore(corpus_root)
|
|
120
|
+
all_entities = self._dedupe_entities(silver.entities() + entities)
|
|
121
|
+
all_relations = self._dedupe_relations(silver.relations() + relations)
|
|
122
|
+
all_decisions = self._dedupe_decisions(silver.decisions() + decisions)
|
|
123
|
+
all_docs = list(BronzeStore(corpus_root).list())
|
|
124
|
+
all_rationales = RationaleExtractor().extract(all_docs, all_decisions)
|
|
125
|
+
silver.replace(all_entities, all_relations, all_decisions, [], all_rationales)
|
|
126
|
+
nodes, edges, timeline = GoldBuilder().build(all_entities, all_relations, all_decisions, all_rationales)
|
|
127
|
+
GoldStore(corpus_root).replace(nodes, edges, timeline)
|
|
128
|
+
LightRagAdapter(corpus_root).sync(nodes, edges, all_docs)
|
|
129
|
+
|
|
130
|
+
def _dedupe_entities(self, entities: list[SilverEntity]) -> list[SilverEntity]:
|
|
131
|
+
by_key: dict[tuple[str, str], SilverEntity] = {}
|
|
132
|
+
for entity in entities:
|
|
133
|
+
key = (entity.type, entity.name.casefold())
|
|
134
|
+
existing = by_key.get(key)
|
|
135
|
+
if existing:
|
|
136
|
+
existing.source_ids = sorted(set(existing.source_ids + entity.source_ids))
|
|
137
|
+
existing.confidence = max(existing.confidence, entity.confidence)
|
|
138
|
+
continue
|
|
139
|
+
by_key[key] = entity
|
|
140
|
+
return list(by_key.values())
|
|
141
|
+
|
|
142
|
+
def _dedupe_relations(self, relations: list[SilverRelation]) -> list[SilverRelation]:
|
|
143
|
+
by_key: dict[tuple[str, str, str], SilverRelation] = {}
|
|
144
|
+
for relation in relations:
|
|
145
|
+
key = (relation.subject.casefold(), relation.predicate, relation.object.casefold())
|
|
146
|
+
existing = by_key.get(key)
|
|
147
|
+
if existing:
|
|
148
|
+
existing.source_ids = sorted(set(existing.source_ids + relation.source_ids))
|
|
149
|
+
existing.confidence = max(existing.confidence, relation.confidence)
|
|
150
|
+
continue
|
|
151
|
+
by_key[key] = relation
|
|
152
|
+
return list(by_key.values())
|
|
153
|
+
|
|
154
|
+
def _dedupe_decisions(self, decisions: list[SilverDecision]) -> list[SilverDecision]:
|
|
155
|
+
by_title: dict[str, SilverDecision] = {}
|
|
156
|
+
for decision in decisions:
|
|
157
|
+
key = decision.title.casefold()
|
|
158
|
+
existing = by_title.get(key)
|
|
159
|
+
if existing:
|
|
160
|
+
existing.source_ids = sorted(set(existing.source_ids + decision.source_ids))
|
|
161
|
+
existing.confidence = max(existing.confidence, decision.confidence)
|
|
162
|
+
if not existing.rationale:
|
|
163
|
+
existing.rationale = decision.rationale
|
|
164
|
+
continue
|
|
165
|
+
by_title[key] = decision
|
|
166
|
+
return list(by_title.values())
|
|
167
|
+
|
|
168
|
+
def _safe_filename(self, value: str) -> str:
|
|
169
|
+
return re.sub(r"[^0-9A-Za-z가-힣._ -]+", "-", value).strip(" .-")[:120] or "memory"
|
|
170
|
+
|
|
171
|
+
def _id(self, prefix: str, *parts: str) -> str:
|
|
172
|
+
digest = hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()[:16]
|
|
173
|
+
return f"{prefix}_{digest}"
|
memory/__init__.py
ADDED