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.
Files changed (72) hide show
  1. adaptive_memory_engine-0.1.6.dist-info/METADATA +228 -0
  2. adaptive_memory_engine-0.1.6.dist-info/RECORD +72 -0
  3. adaptive_memory_engine-0.1.6.dist-info/WHEEL +4 -0
  4. adaptive_memory_engine-0.1.6.dist-info/entry_points.txt +3 -0
  5. adaptive_memory_engine-0.1.6.dist-info/licenses/LICENSE +21 -0
  6. ame/__init__.py +1 -0
  7. ame/agent/__init__.py +1 -0
  8. ame/agent/mcp.py +474 -0
  9. ame/agent/memory_api.py +141 -0
  10. ame/agent/results.py +30 -0
  11. ame/bronze/schema.py +17 -0
  12. ame/bronze/store.py +38 -0
  13. ame/cli/__init__.py +1 -0
  14. ame/cli/main.py +903 -0
  15. ame/connectors/base.py +30 -0
  16. ame/connectors/contract.py +199 -0
  17. ame/connectors/github.py +66 -0
  18. ame/connectors/google.py +464 -0
  19. ame/connectors/google_oauth.py +156 -0
  20. ame/connectors/jira.py +66 -0
  21. ame/connectors/json_helpers.py +43 -0
  22. ame/connectors/markdown.py +116 -0
  23. ame/connectors/notion.py +59 -0
  24. ame/connectors/oauth_callback.py +102 -0
  25. ame/connectors/oauth_provider.py +250 -0
  26. ame/connectors/obsidian.py +19 -0
  27. ame/connectors/router.py +155 -0
  28. ame/connectors/slack.py +66 -0
  29. ame/connectors/slack_oauth.py +417 -0
  30. ame/connectors/sync_history.py +73 -0
  31. ame/context_budget.py +106 -0
  32. ame/core/config.py +77 -0
  33. ame/core/corpus.py +17 -0
  34. ame/core/errors.py +18 -0
  35. ame/core/paths.py +111 -0
  36. ame/core/state.py +57 -0
  37. ame/export/obsidian.py +123 -0
  38. ame/gold/builder.py +300 -0
  39. ame/gold/ontology.py +80 -0
  40. ame/gold/resolver.py +91 -0
  41. ame/gold/schema.py +40 -0
  42. ame/gold/store.py +45 -0
  43. ame/hardware/profiler.py +85 -0
  44. ame/hardware/tier.py +27 -0
  45. ame/hermes/__init__.py +3 -0
  46. ame/hermes/memory.py +209 -0
  47. ame/models/download.py +243 -0
  48. ame/models/ollama.py +60 -0
  49. ame/models/registry.py +101 -0
  50. ame/models/router.py +22 -0
  51. ame/pipeline.py +155 -0
  52. ame/query/diff.py +40 -0
  53. ame/query/engine.py +919 -0
  54. ame/query/memory_os.py +313 -0
  55. ame/query/mql.py +84 -0
  56. ame/query/multihop.py +264 -0
  57. ame/query/result.py +20 -0
  58. ame/sdk.py +52 -0
  59. ame/security.py +145 -0
  60. ame/silver/extractor.py +414 -0
  61. ame/silver/llm_extractor.py +181 -0
  62. ame/silver/prompts.py +56 -0
  63. ame/silver/rationale.py +140 -0
  64. ame/silver/schema.py +51 -0
  65. ame/silver/store.py +59 -0
  66. ame/storage/custom_kg.py +33 -0
  67. ame/storage/lightrag_adapter.py +362 -0
  68. ame/validation/confidence.py +5 -0
  69. ame/validation/grounding.py +10 -0
  70. ame/validation/type_gate.py +22 -0
  71. ame/writeback.py +173 -0
  72. 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,5 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def passes_confidence(confidence: float, threshold: float = 0.7) -> bool:
5
+ return confidence >= threshold
@@ -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
@@ -0,0 +1,3 @@
1
+ from ame.sdk import Corpus
2
+
3
+ __all__ = ["Corpus"]