agentic-composite-memory 0.1.0__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.
- agentic_composite_memory/__init__.py +2 -0
- agentic_composite_memory/py.typed +0 -0
- agentic_composite_memory-0.1.0.dist-info/METADATA +167 -0
- agentic_composite_memory-0.1.0.dist-info/RECORD +31 -0
- agentic_composite_memory-0.1.0.dist-info/WHEEL +4 -0
- agentic_composite_memory-0.1.0.dist-info/entry_points.txt +2 -0
- agentic_composite_memory-0.1.0.dist-info/licenses/LICENSE +17 -0
- agentic_memory_sdk/__init__.py +163 -0
- agentic_memory_sdk/adapters/__init__.py +33 -0
- agentic_memory_sdk/adapters/in_memory.py +439 -0
- agentic_memory_sdk/adapters/neo4j_cypher.py +65 -0
- agentic_memory_sdk/adapters/neptune_cypher.py +309 -0
- agentic_memory_sdk/adapters/opensearch_seed.py +322 -0
- agentic_memory_sdk/adapters/postgres_pgvector.py +484 -0
- agentic_memory_sdk/adapters/postgres_unified.py +511 -0
- agentic_memory_sdk/adapters/redis_playbook.py +157 -0
- agentic_memory_sdk/adapters/redis_unified.py +490 -0
- agentic_memory_sdk/adk_integration.py +250 -0
- agentic_memory_sdk/auth.py +380 -0
- agentic_memory_sdk/cli.py +137 -0
- agentic_memory_sdk/governance.py +713 -0
- agentic_memory_sdk/llm_extractors.py +616 -0
- agentic_memory_sdk/models.py +443 -0
- agentic_memory_sdk/ontology_loader.py +189 -0
- agentic_memory_sdk/ports.py +159 -0
- agentic_memory_sdk/py.typed +0 -0
- agentic_memory_sdk/server/__init__.py +4 -0
- agentic_memory_sdk/server/api.py +328 -0
- agentic_memory_sdk/server/main.py +384 -0
- agentic_memory_sdk/server/mcp_server.py +198 -0
- agentic_memory_sdk/service.py +1214 -0
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from collections import defaultdict, deque
|
|
6
|
+
from typing import Tuple
|
|
7
|
+
|
|
8
|
+
from .models import (
|
|
9
|
+
AppMemoryManifest,
|
|
10
|
+
CompiledTBox,
|
|
11
|
+
DomainPackSpec,
|
|
12
|
+
EpisodicRecord,
|
|
13
|
+
ExtractedEntity,
|
|
14
|
+
ExtractedFact,
|
|
15
|
+
ExtractionPayload,
|
|
16
|
+
GovernanceTier,
|
|
17
|
+
GraphFactReadResult,
|
|
18
|
+
MemoryScope,
|
|
19
|
+
OntologyAuditLogEntry,
|
|
20
|
+
PlatformTenantContext,
|
|
21
|
+
RelationRule,
|
|
22
|
+
UpperConceptType,
|
|
23
|
+
_default_layer0_relations,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PIIScrubber:
|
|
28
|
+
"""Deterministic pre-embedding and pre-extraction DLP sanitizer."""
|
|
29
|
+
|
|
30
|
+
_PATTERNS = [
|
|
31
|
+
(re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), "[REDACTED_SSN]"),
|
|
32
|
+
(re.compile(r"\b(?:\d[ -]*?){13,16}\b"), "[REDACTED_PAN]"),
|
|
33
|
+
(
|
|
34
|
+
re.compile(
|
|
35
|
+
r"(?i)\bBearer\s+[A-Za-z0-9_\-\.=]{12,}"
|
|
36
|
+
),
|
|
37
|
+
"[REDACTED_SECRET]",
|
|
38
|
+
),
|
|
39
|
+
(
|
|
40
|
+
re.compile(
|
|
41
|
+
r"(?i)(?:api[_-]?key|secret|token|password|Bearer)\s*[:=]\s*['\"]?[A-Za-z0-9_\-\.]{12,}['\"]?"
|
|
42
|
+
),
|
|
43
|
+
"[REDACTED_SECRET]",
|
|
44
|
+
),
|
|
45
|
+
(
|
|
46
|
+
re.compile(r"\bsk-[A-Za-z0-9]{16,}\b"),
|
|
47
|
+
"[REDACTED_API_KEY]",
|
|
48
|
+
),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def scrub(cls, text: str) -> str:
|
|
53
|
+
scrubbed = text
|
|
54
|
+
for pattern, replacement in cls._PATTERNS:
|
|
55
|
+
scrubbed = pattern.sub(replacement, scrubbed)
|
|
56
|
+
return scrubbed
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class EntityResolutionGuard:
|
|
60
|
+
"""
|
|
61
|
+
Prevents false-positive vector entity merges between distinct numbered, directional,
|
|
62
|
+
or environment-scoped identifiers (e.g., `us-east-1` vs `us-east-2`, `aurora-01` vs `aurora-02`,
|
|
63
|
+
`prod-db` vs `staging-db`) even when dense embedding cosine similarity is >= 0.91.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
_DIRECTION_OR_ENV_TOKENS = {
|
|
67
|
+
"east",
|
|
68
|
+
"west",
|
|
69
|
+
"north",
|
|
70
|
+
"south",
|
|
71
|
+
"central",
|
|
72
|
+
"prod",
|
|
73
|
+
"production",
|
|
74
|
+
"stage",
|
|
75
|
+
"staging",
|
|
76
|
+
"dev",
|
|
77
|
+
"qa",
|
|
78
|
+
"primary",
|
|
79
|
+
"replica",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def normalize_name(cls, name: str) -> str:
|
|
84
|
+
return re.sub(r"[\s_\-]+", "-", name.strip().lower())
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def extract_distinguishing_tokens(cls, name: str) -> tuple[set[str], set[str]]:
|
|
88
|
+
norm = cls.normalize_name(name)
|
|
89
|
+
# Extract normalized numeric tokens (e.g. '01' -> '1', '2' -> '2')
|
|
90
|
+
num_tokens = {str(int(m)) for m in re.findall(r"\d+", norm)}
|
|
91
|
+
words = set(norm.split("-"))
|
|
92
|
+
env_tokens = words.intersection(cls._DIRECTION_OR_ENV_TOKENS)
|
|
93
|
+
return num_tokens, env_tokens
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def can_merge(
|
|
97
|
+
cls,
|
|
98
|
+
existing_name: str,
|
|
99
|
+
candidate_name: str,
|
|
100
|
+
cosine_sim: float,
|
|
101
|
+
threshold: float = 0.91,
|
|
102
|
+
) -> bool:
|
|
103
|
+
if cls.normalize_name(existing_name) == cls.normalize_name(candidate_name):
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
# Check for conflicting numbers or directional/environment qualifiers
|
|
107
|
+
nums_a, envs_a = cls.extract_distinguishing_tokens(existing_name)
|
|
108
|
+
nums_b, envs_b = cls.extract_distinguishing_tokens(candidate_name)
|
|
109
|
+
|
|
110
|
+
if (nums_a or nums_b) and nums_a != nums_b:
|
|
111
|
+
return False
|
|
112
|
+
if (envs_a or envs_b) and envs_a != envs_b:
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
return cosine_sim >= threshold
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class SmartMemoryClassifier:
|
|
119
|
+
"""
|
|
120
|
+
Distinguishes memory types automatically at ingestion time:
|
|
121
|
+
1. Ephemeral Chitchat / Noise (`is_ephemeral_turn`) -> Skips graph extraction and flags for TTL cleanup.
|
|
122
|
+
2. Cross-App User Preferences (`is_user_scoped_entity` / `is_user_scoped_fact`) -> Routes to
|
|
123
|
+
`user:{tenant}:{user_id}` so preferences follow the user across all apps without leaking to other users.
|
|
124
|
+
3. App-Private vs Domain-Shared vs Tenant-Global -> Routed by `TBoxRuntimeValidator`.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
_EPHEMERAL_EXACT = {
|
|
128
|
+
"ok",
|
|
129
|
+
"okay",
|
|
130
|
+
"k",
|
|
131
|
+
"thanks",
|
|
132
|
+
"thank you",
|
|
133
|
+
"thx",
|
|
134
|
+
"got it",
|
|
135
|
+
"sounds good",
|
|
136
|
+
"cool",
|
|
137
|
+
"bye",
|
|
138
|
+
"goodbye",
|
|
139
|
+
"hello",
|
|
140
|
+
"hi",
|
|
141
|
+
"hey",
|
|
142
|
+
"ack",
|
|
143
|
+
"noted",
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
_EPHEMERAL_WORDS = {
|
|
147
|
+
"ok",
|
|
148
|
+
"okay",
|
|
149
|
+
"k",
|
|
150
|
+
"thanks",
|
|
151
|
+
"thank",
|
|
152
|
+
"you",
|
|
153
|
+
"thx",
|
|
154
|
+
"got",
|
|
155
|
+
"it",
|
|
156
|
+
"sounds",
|
|
157
|
+
"good",
|
|
158
|
+
"great",
|
|
159
|
+
"cool",
|
|
160
|
+
"bye",
|
|
161
|
+
"goodbye",
|
|
162
|
+
"hello",
|
|
163
|
+
"hi",
|
|
164
|
+
"hey",
|
|
165
|
+
"ack",
|
|
166
|
+
"noted",
|
|
167
|
+
"sure",
|
|
168
|
+
"yes",
|
|
169
|
+
"yep",
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
_USER_PREFERENCE_PREDICATES = {
|
|
173
|
+
"PREFERS",
|
|
174
|
+
"HAS_PREFERENCE",
|
|
175
|
+
"HAS_TIMEZONE",
|
|
176
|
+
"COMMUNICATION_STYLE",
|
|
177
|
+
"SPEAKS_LANGUAGE",
|
|
178
|
+
"WORKS_IN_ROLE",
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
_CONFIRMATION_WORDS = {
|
|
182
|
+
"yes",
|
|
183
|
+
"yep",
|
|
184
|
+
"no",
|
|
185
|
+
"nope",
|
|
186
|
+
"sure",
|
|
187
|
+
"ok",
|
|
188
|
+
"okay",
|
|
189
|
+
"correct",
|
|
190
|
+
"confirmed",
|
|
191
|
+
"approved",
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def is_ephemeral_turn(
|
|
196
|
+
cls, text: str, preceding_text: str | None = None
|
|
197
|
+
) -> bool:
|
|
198
|
+
"""
|
|
199
|
+
Context-aware fast gate:
|
|
200
|
+
- Never drops short confirmations/negations ('yes', 'no', 'ok') when following a preceding
|
|
201
|
+
turn/question (`preceding_text`), so the LLM extractor sees the full Q&A context!
|
|
202
|
+
- Only flags unambiguous standalone terminal closings ('thanks!', 'bye') on the fast path,
|
|
203
|
+
while deferring nuanced or multi-sentence chitchat classification to the LLM (`ExtractionPayload.is_ephemeral_chitchat`).
|
|
204
|
+
"""
|
|
205
|
+
cleaned = re.sub(r"[.!?,:;\"']+", "", text.strip().lower())
|
|
206
|
+
if not cleaned:
|
|
207
|
+
return True
|
|
208
|
+
words = cleaned.split()
|
|
209
|
+
# If there is preceding context (e.g. an agent question), do NOT drop confirmations like "yes" or "ok"!
|
|
210
|
+
if preceding_text and any(w in cls._CONFIRMATION_WORDS for w in words):
|
|
211
|
+
return False
|
|
212
|
+
if cleaned in cls._EPHEMERAL_EXACT:
|
|
213
|
+
return True
|
|
214
|
+
return len(words) <= 4 and all(w in cls._EPHEMERAL_WORDS for w in words)
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
def is_user_scoped_entity(cls, ent: ExtractedEntity, tbox: CompiledTBox) -> bool:
|
|
218
|
+
if ent.scope == MemoryScope.USER_GLOBAL:
|
|
219
|
+
return True
|
|
220
|
+
return tbox.is_subclass_of(
|
|
221
|
+
ent.entity_class, UpperConceptType.PREFERENCE.value
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
@classmethod
|
|
225
|
+
def is_user_scoped_fact(
|
|
226
|
+
cls, fact: ExtractedFact, user_preference_entities: set[str]
|
|
227
|
+
) -> bool:
|
|
228
|
+
if fact.scope == MemoryScope.USER_GLOBAL:
|
|
229
|
+
return True
|
|
230
|
+
if fact.predicate in cls._USER_PREFERENCE_PREDICATES:
|
|
231
|
+
return True
|
|
232
|
+
return fact.target_entity in user_preference_entities
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class TBoxRuntimeValidator:
|
|
236
|
+
"""
|
|
237
|
+
Closed-world SHACL-equivalent runtime validator implementing Multi-Scope & Two-Bucket Routing.
|
|
238
|
+
- Scope 0 (User-Global `user:{tenant}:{user_id}`): User preferences & persona attributes
|
|
239
|
+
shared across all apps for that specific user, isolated from other users.
|
|
240
|
+
- Bucket 1 (Governed `domain:*` or `app:*`): High-confidence entities and facts conforming
|
|
241
|
+
to TBox classes, RDFS subClassOf hierarchies, disjointness rules, and domain/range constraints.
|
|
242
|
+
- Bucket 2 (Emergent Overflow `app:{tenant}:{app}`): Schema violations or low-confidence facts
|
|
243
|
+
are downgraded to `p:CandidateConcept` / `RELATED_TO` inside the app's private namespace.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def validate_and_partition(
|
|
248
|
+
ctx: PlatformTenantContext,
|
|
249
|
+
payload: ExtractionPayload,
|
|
250
|
+
) -> Tuple[list[Tuple[ExtractedEntity, str]], list[Tuple[ExtractedFact, str, bool]]]:
|
|
251
|
+
tbox: CompiledTBox = ctx.tbox
|
|
252
|
+
routed_entities: list[Tuple[ExtractedEntity, str]] = []
|
|
253
|
+
routed_facts: list[Tuple[ExtractedFact, str, bool]] = []
|
|
254
|
+
|
|
255
|
+
# Track resolved classes per canonical entity name (and detect disjointness collisions)
|
|
256
|
+
entity_class_map: dict[str, str] = {}
|
|
257
|
+
user_scoped_entities: set[str] = set()
|
|
258
|
+
disjoint_pairs: set[tuple[str, str]] = set()
|
|
259
|
+
for c1, c2 in tbox.disjoint_classes:
|
|
260
|
+
disjoint_pairs.add((c1, c2))
|
|
261
|
+
disjoint_pairs.add((c2, c1))
|
|
262
|
+
|
|
263
|
+
for ent in payload.entities:
|
|
264
|
+
ent_copy = ent.model_copy()
|
|
265
|
+
if ent_copy.scope == MemoryScope.EPHEMERAL:
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
is_valid_class = ent_copy.entity_class in tbox.classes
|
|
269
|
+
meets_confidence = ent_copy.confidence >= tbox.min_domain_confidence
|
|
270
|
+
|
|
271
|
+
# Disjointness check if entity was seen earlier in the same batch with a disjoint class
|
|
272
|
+
prior_cls = entity_class_map.get(ent_copy.canonical_name)
|
|
273
|
+
disjoint_violation = (
|
|
274
|
+
prior_cls is not None and (prior_cls, ent_copy.entity_class) in disjoint_pairs
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if SmartMemoryClassifier.is_user_scoped_entity(ent_copy, tbox):
|
|
278
|
+
if not is_valid_class:
|
|
279
|
+
ent_copy.entity_class = UpperConceptType.PREFERENCE.value
|
|
280
|
+
target_ns = ctx.user_private_namespace
|
|
281
|
+
if tbox.is_subclass_of(
|
|
282
|
+
ent_copy.entity_class, UpperConceptType.PREFERENCE.value
|
|
283
|
+
):
|
|
284
|
+
user_scoped_entities.add(ent_copy.canonical_name)
|
|
285
|
+
elif ent_copy.scope == MemoryScope.APP_PRIVATE:
|
|
286
|
+
if not is_valid_class:
|
|
287
|
+
ent_copy.entity_class = UpperConceptType.CANDIDATE.value
|
|
288
|
+
target_ns = ctx.app_private_namespace
|
|
289
|
+
elif is_valid_class and meets_confidence and not disjoint_violation:
|
|
290
|
+
target_ns = ctx.write_namespace
|
|
291
|
+
elif tbox.allow_emergent_overflow:
|
|
292
|
+
orig_cls = ent_copy.entity_class
|
|
293
|
+
ent_copy.entity_class = UpperConceptType.CANDIDATE.value
|
|
294
|
+
if orig_cls and orig_cls != UpperConceptType.CANDIDATE.value and "[ProposedClass=" not in ent_copy.summary:
|
|
295
|
+
ent_copy.summary = f"[ProposedClass={orig_cls}] {ent_copy.summary}"
|
|
296
|
+
target_ns = ctx.app_private_namespace
|
|
297
|
+
else:
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
entity_class_map[ent_copy.canonical_name] = ent_copy.entity_class
|
|
301
|
+
routed_entities.append((ent_copy, target_ns))
|
|
302
|
+
|
|
303
|
+
for fact in payload.facts:
|
|
304
|
+
fact_copy = fact.model_copy()
|
|
305
|
+
if fact_copy.scope == MemoryScope.EPHEMERAL:
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
if SmartMemoryClassifier.is_user_scoped_fact(fact_copy, user_scoped_entities):
|
|
309
|
+
rel_rule = tbox.relations.get(fact_copy.predicate)
|
|
310
|
+
is_func = rel_rule.is_functional if rel_rule else False
|
|
311
|
+
routed_facts.append((fact_copy, ctx.user_private_namespace, is_func))
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
if fact_copy.scope == MemoryScope.APP_PRIVATE:
|
|
315
|
+
rel_rule = tbox.relations.get(fact_copy.predicate)
|
|
316
|
+
is_func = rel_rule.is_functional if rel_rule else False
|
|
317
|
+
routed_facts.append((fact_copy, ctx.app_private_namespace, is_func))
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
rel_rule = tbox.relations.get(fact_copy.predicate)
|
|
321
|
+
src_cls = entity_class_map.get(fact_copy.source_entity, fact_copy.source_class)
|
|
322
|
+
tgt_cls = entity_class_map.get(fact_copy.target_entity, fact_copy.target_class)
|
|
323
|
+
|
|
324
|
+
domain_ok = (
|
|
325
|
+
rel_rule is not None
|
|
326
|
+
and tbox.is_subclass_of(src_cls, rel_rule.domain_class)
|
|
327
|
+
and tbox.is_subclass_of(tgt_cls, rel_rule.range_class)
|
|
328
|
+
)
|
|
329
|
+
confidence_ok = fact_copy.confidence >= tbox.min_domain_confidence
|
|
330
|
+
|
|
331
|
+
if domain_ok and confidence_ok and rel_rule is not None:
|
|
332
|
+
routed_facts.append((fact_copy, ctx.write_namespace, rel_rule.is_functional))
|
|
333
|
+
elif tbox.allow_emergent_overflow:
|
|
334
|
+
# Quarantine to app-private namespace; overflow facts never invalidate functional edges
|
|
335
|
+
orig_pred = fact_copy.predicate
|
|
336
|
+
fact_copy.predicate = "RELATED_TO"
|
|
337
|
+
if orig_pred and orig_pred != "RELATED_TO" and "[ProposedPredicate=" not in fact_copy.fact_summary:
|
|
338
|
+
fact_copy.fact_summary = (
|
|
339
|
+
f"[ProposedPredicate={orig_pred}|{fact_copy.source_class}->{fact_copy.target_class}] "
|
|
340
|
+
f"{fact_copy.fact_summary}"
|
|
341
|
+
)
|
|
342
|
+
routed_facts.append((fact_copy, ctx.app_private_namespace, False))
|
|
343
|
+
|
|
344
|
+
return routed_entities, routed_facts
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class PlatformOntologyRegistrar:
|
|
348
|
+
"""
|
|
349
|
+
Compiles developer-friendly AppMemoryManifest declarations + Federated Domain Packs
|
|
350
|
+
into a CompiledTBox and registers the ADK app in the platform RegistryStoreAdapter.
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
def __init__(self, registry_adapter):
|
|
354
|
+
self.registry = registry_adapter
|
|
355
|
+
self._domain_packs: dict[str, DomainPackSpec] = {}
|
|
356
|
+
self.audit_log: list[OntologyAuditLogEntry] = []
|
|
357
|
+
|
|
358
|
+
@staticmethod
|
|
359
|
+
def infer_upper_concept_parent(class_name: str) -> str:
|
|
360
|
+
"""
|
|
361
|
+
Semantic heuristic + Upper Ontology aligner: maps any unrooted domain class
|
|
362
|
+
to the appropriate Layer 0 `UpperConceptType` (`p:Actor`, `p:Object`, `p:Event`, `p:UserPreference`, `p:Concept`).
|
|
363
|
+
"""
|
|
364
|
+
low = class_name.lower()
|
|
365
|
+
if any(k in low for k in ("user", "actor", "agent", "customer", "person", "team", "owner", "vendor", "patient", "investigator")):
|
|
366
|
+
return UpperConceptType.ACTOR.value
|
|
367
|
+
if any(k in low for k in ("event", "incident", "alert", "outage", "trial", "deployment", "transaction", "dispute", "call", "session")):
|
|
368
|
+
return UpperConceptType.EVENT.value
|
|
369
|
+
if any(k in low for k in ("pref", "setting", "style", "persona")):
|
|
370
|
+
return UpperConceptType.PREFERENCE.value
|
|
371
|
+
if any(k in low for k in ("service", "cluster", "database", "db", "tool", "asset", "system", "region", "device", "drug", "compound", "server", "api", "app")):
|
|
372
|
+
return UpperConceptType.OBJECT.value
|
|
373
|
+
return UpperConceptType.CONCEPT.value
|
|
374
|
+
|
|
375
|
+
@staticmethod
|
|
376
|
+
def infer_relation_flags(predicate: str) -> tuple[bool, bool]:
|
|
377
|
+
"""
|
|
378
|
+
Infers `(is_functional, is_transitive)` when a service proposes an under-specified relation rule.
|
|
379
|
+
"""
|
|
380
|
+
up = re.sub(r"[\s\-]+", "_", predicate.strip().upper())
|
|
381
|
+
is_func = any(
|
|
382
|
+
tok in up
|
|
383
|
+
for tok in (
|
|
384
|
+
"PRIMARY",
|
|
385
|
+
"CURRENT",
|
|
386
|
+
"ACTIVE_TIER",
|
|
387
|
+
"SUBSCRIBED_TO_TIER",
|
|
388
|
+
"HAS_STATUS",
|
|
389
|
+
"ASSIGNED_TO",
|
|
390
|
+
"OWNED_BY",
|
|
391
|
+
"MANAGED_BY",
|
|
392
|
+
"DEPLOYED_IN_PRIMARY",
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
is_trans = any(
|
|
396
|
+
tok in up
|
|
397
|
+
for tok in (
|
|
398
|
+
"DEPENDS_ON",
|
|
399
|
+
"PART_OF",
|
|
400
|
+
"SUBCLASS_OF",
|
|
401
|
+
"UPSTREAM_OF",
|
|
402
|
+
"DOWNSTREAM_OF",
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
return is_func, is_trans
|
|
406
|
+
|
|
407
|
+
def review_and_refine_domain_pack(
|
|
408
|
+
self,
|
|
409
|
+
pack: DomainPackSpec,
|
|
410
|
+
tenant_id: str = "platform",
|
|
411
|
+
auto_register: bool = True,
|
|
412
|
+
) -> tuple[DomainPackSpec, OntologyAuditLogEntry]:
|
|
413
|
+
"""
|
|
414
|
+
Behind-the-scenes Intelligent Ontology Steward:
|
|
415
|
+
Reviews a proposed `DomainPackSpec` (submitted by an app team or LLM),
|
|
416
|
+
automatically fixes unrooted classes, normalizes predicate names to UPPER_SNAKE_CASE,
|
|
417
|
+
infers missing `is_functional` (bi-temporal conflict invalidation) and `is_transitive` flags,
|
|
418
|
+
repairs broken domain/range class references, and records an immutable `OntologyAuditLogEntry`.
|
|
419
|
+
"""
|
|
420
|
+
refined = pack.model_copy(deep=True)
|
|
421
|
+
modifications: list[str] = []
|
|
422
|
+
upper_set = {c.value for c in UpperConceptType} | {"owl:Thing"}
|
|
423
|
+
|
|
424
|
+
# 1. Ensure all classes are rooted in Layer 0 UpperConceptType
|
|
425
|
+
for cls_name, parent_cls in list(refined.classes.items()):
|
|
426
|
+
if not parent_cls or parent_cls in ("Thing", "owl:Thing", "None", ""):
|
|
427
|
+
inferred_parent = self.infer_upper_concept_parent(cls_name)
|
|
428
|
+
refined.classes[cls_name] = inferred_parent
|
|
429
|
+
modifications.append(
|
|
430
|
+
f"Rooted unanchored class '{cls_name}' under Layer 0 '{inferred_parent}'"
|
|
431
|
+
)
|
|
432
|
+
elif parent_cls not in upper_set and parent_cls not in refined.classes:
|
|
433
|
+
inferred_parent = self.infer_upper_concept_parent(parent_cls)
|
|
434
|
+
refined.classes[parent_cls] = inferred_parent
|
|
435
|
+
modifications.append(
|
|
436
|
+
f"Added missing intermediate parent class '{parent_cls}' -> '{inferred_parent}'"
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
# 2. Review and normalize relations
|
|
440
|
+
new_relations: dict[str, RelationRule] = {}
|
|
441
|
+
for raw_key, rel in refined.relations.items():
|
|
442
|
+
norm_pred = re.sub(r"[\s\-]+", "_", (rel.predicate or raw_key).strip().upper())
|
|
443
|
+
rel_copy = rel.model_copy(deep=True)
|
|
444
|
+
if rel_copy.predicate != norm_pred:
|
|
445
|
+
modifications.append(
|
|
446
|
+
f"Normalized predicate '{rel_copy.predicate}' -> '{norm_pred}'"
|
|
447
|
+
)
|
|
448
|
+
rel_copy.predicate = norm_pred
|
|
449
|
+
|
|
450
|
+
# Ensure domain_class and range_class exist in classes or Layer 0
|
|
451
|
+
for attr in ("domain_class", "range_class"):
|
|
452
|
+
cls_val = getattr(rel_copy, attr)
|
|
453
|
+
if cls_val not in upper_set and cls_val not in refined.classes:
|
|
454
|
+
inferred = self.infer_upper_concept_parent(cls_val)
|
|
455
|
+
refined.classes[cls_val] = inferred
|
|
456
|
+
modifications.append(
|
|
457
|
+
f"Auto-registered missing {attr} '{cls_val}' -> '{inferred}' referenced by '{norm_pred}'"
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
inferred_func, inferred_trans = self.infer_relation_flags(norm_pred)
|
|
461
|
+
if inferred_func and not rel_copy.is_functional:
|
|
462
|
+
rel_copy.is_functional = True
|
|
463
|
+
modifications.append(
|
|
464
|
+
f"Enabled bi-temporal functional invalidation (is_functional=True) on singular-state predicate '{norm_pred}'"
|
|
465
|
+
)
|
|
466
|
+
if inferred_trans and not rel_copy.is_transitive:
|
|
467
|
+
rel_copy.is_transitive = True
|
|
468
|
+
modifications.append(
|
|
469
|
+
f"Enabled multi-hop transitive closure (is_transitive=True) on predicate '{norm_pred}'"
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
new_relations[norm_pred] = rel_copy
|
|
473
|
+
|
|
474
|
+
refined.relations = new_relations
|
|
475
|
+
|
|
476
|
+
# Bump patch version if steward modified the proposed schema
|
|
477
|
+
prev_ver = pack.version
|
|
478
|
+
if modifications:
|
|
479
|
+
parts = (pack.version.split(".") + ["0", "0"])[:3]
|
|
480
|
+
try:
|
|
481
|
+
parts[2] = str(int(parts[2]) + 1)
|
|
482
|
+
refined.version = ".".join(parts)
|
|
483
|
+
except ValueError:
|
|
484
|
+
refined.version = f"{pack.version}-reviewed"
|
|
485
|
+
|
|
486
|
+
audit_entry = OntologyAuditLogEntry(
|
|
487
|
+
tenant_id=tenant_id,
|
|
488
|
+
target_id=refined.pack_id,
|
|
489
|
+
action="LLM_REVIEW_AND_REFINE",
|
|
490
|
+
previous_version=prev_ver,
|
|
491
|
+
new_version=refined.version,
|
|
492
|
+
added_or_modified_classes=dict(refined.classes),
|
|
493
|
+
added_or_modified_relations={
|
|
494
|
+
k: v.model_dump() for k, v in refined.relations.items()
|
|
495
|
+
},
|
|
496
|
+
modifications_summary=modifications or ["Verified clean: no schema modifications required"],
|
|
497
|
+
triggered_by="ontology_intelligence_steward",
|
|
498
|
+
)
|
|
499
|
+
self.audit_log.append(audit_entry)
|
|
500
|
+
|
|
501
|
+
if auto_register:
|
|
502
|
+
self.register_domain_pack(refined, record_audit=False)
|
|
503
|
+
|
|
504
|
+
return refined, audit_entry
|
|
505
|
+
|
|
506
|
+
def register_domain_pack(
|
|
507
|
+
self, pack: DomainPackSpec, record_audit: bool = True
|
|
508
|
+
) -> None:
|
|
509
|
+
"""Registers a Layer 1 Federated Domain Pack (e.g. 'payments@v2.1') in the platform catalog."""
|
|
510
|
+
self._domain_packs[pack.pack_id] = pack
|
|
511
|
+
self._domain_packs[pack.domain_name] = pack
|
|
512
|
+
if record_audit:
|
|
513
|
+
self.audit_log.append(
|
|
514
|
+
OntologyAuditLogEntry(
|
|
515
|
+
tenant_id="platform",
|
|
516
|
+
target_id=pack.pack_id,
|
|
517
|
+
action="REGISTER_DOMAIN_PACK",
|
|
518
|
+
previous_version="0.0.0",
|
|
519
|
+
new_version=pack.version,
|
|
520
|
+
added_or_modified_classes=dict(pack.classes),
|
|
521
|
+
added_or_modified_relations={
|
|
522
|
+
k: v.model_dump() for k, v in pack.relations.items()
|
|
523
|
+
},
|
|
524
|
+
modifications_summary=[f"Registered domain pack '{pack.pack_id}'"],
|
|
525
|
+
triggered_by="domain_steward",
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
def compile_manifest(self, manifest: AppMemoryManifest) -> tuple[list[str], str, CompiledTBox]:
|
|
530
|
+
"""Computes read/write namespaces and merges Layer 0 + Layer 1 + Layer 2 TBox rules."""
|
|
531
|
+
app_ns = f"app:{manifest.tenant_id}:{manifest.app_name}"
|
|
532
|
+
global_ns = f"global:{manifest.tenant_id}:core"
|
|
533
|
+
domain_namespaces = [
|
|
534
|
+
f"domain:{manifest.tenant_id}:{pack.split('@')[0]}"
|
|
535
|
+
for pack in manifest.subscribed_domain_packs
|
|
536
|
+
]
|
|
537
|
+
|
|
538
|
+
read_namespaces = [app_ns, global_ns] + domain_namespaces
|
|
539
|
+
# Deduplicate while preserving order
|
|
540
|
+
read_namespaces = list(dict.fromkeys(read_namespaces))
|
|
541
|
+
|
|
542
|
+
if (
|
|
543
|
+
manifest.governance_tier == GovernanceTier.TIER2_DOMAIN_SHARED
|
|
544
|
+
and domain_namespaces
|
|
545
|
+
):
|
|
546
|
+
write_namespace = domain_namespaces[0]
|
|
547
|
+
else:
|
|
548
|
+
write_namespace = app_ns
|
|
549
|
+
|
|
550
|
+
# Layer 0 Base Classes & Universal Relations
|
|
551
|
+
classes: dict[str, str] = {c.value: "owl:Thing" for c in UpperConceptType}
|
|
552
|
+
disjoint_classes: list[tuple[str, str]] = []
|
|
553
|
+
relations: dict[str, RelationRule] = _default_layer0_relations()
|
|
554
|
+
|
|
555
|
+
# Layer 1 Subscribed Domain Packs
|
|
556
|
+
for pack_key in manifest.subscribed_domain_packs:
|
|
557
|
+
pack = self._domain_packs.get(pack_key) or self._domain_packs.get(
|
|
558
|
+
pack_key.split("@")[0]
|
|
559
|
+
)
|
|
560
|
+
if pack:
|
|
561
|
+
classes.update(pack.classes)
|
|
562
|
+
disjoint_classes.extend(pack.disjoint_classes)
|
|
563
|
+
relations.update(pack.relations)
|
|
564
|
+
|
|
565
|
+
# Layer 2 App-Local Custom Extensions
|
|
566
|
+
for cls_name, parent_cls in manifest.custom_classes.items():
|
|
567
|
+
parent_str = (
|
|
568
|
+
parent_cls.value if isinstance(parent_cls, UpperConceptType) else str(parent_cls)
|
|
569
|
+
)
|
|
570
|
+
classes[cls_name] = parent_str
|
|
571
|
+
|
|
572
|
+
for rel in manifest.custom_relations:
|
|
573
|
+
relations[rel.predicate] = rel
|
|
574
|
+
|
|
575
|
+
tbox = CompiledTBox(
|
|
576
|
+
governance_tier=manifest.governance_tier,
|
|
577
|
+
extraction_mode=manifest.extraction_mode,
|
|
578
|
+
allow_emergent_overflow=manifest.allow_emergent_overflow,
|
|
579
|
+
min_domain_confidence=manifest.min_domain_confidence,
|
|
580
|
+
classes=classes,
|
|
581
|
+
disjoint_classes=disjoint_classes,
|
|
582
|
+
relations=relations,
|
|
583
|
+
)
|
|
584
|
+
return read_namespaces, write_namespace, tbox
|
|
585
|
+
|
|
586
|
+
async def register_adk_app(self, manifest: AppMemoryManifest) -> CompiledTBox:
|
|
587
|
+
"""Compiles and persists an ADK app's governance policy and TBox in the RegistryStoreAdapter."""
|
|
588
|
+
read_namespaces, write_namespace, tbox = self.compile_manifest(manifest)
|
|
589
|
+
await self.registry.register_app(
|
|
590
|
+
tenant_id=manifest.tenant_id,
|
|
591
|
+
app_name=manifest.app_name,
|
|
592
|
+
read_namespaces=read_namespaces,
|
|
593
|
+
write_namespace=write_namespace,
|
|
594
|
+
tbox=tbox,
|
|
595
|
+
)
|
|
596
|
+
return tbox
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
class AsyncCircuitBreaker:
|
|
600
|
+
"""Protects the ADK read path from Graph DB outages or latency spikes."""
|
|
601
|
+
|
|
602
|
+
def __init__(self, failure_threshold: int = 3, recovery_timeout_sec: float = 30.0):
|
|
603
|
+
self.failure_threshold = failure_threshold
|
|
604
|
+
self.recovery_timeout_sec = recovery_timeout_sec
|
|
605
|
+
self.failures = 0
|
|
606
|
+
self.last_failure_time = 0.0
|
|
607
|
+
self.state = "CLOSED" # CLOSED, OPEN, HALF_OPEN
|
|
608
|
+
|
|
609
|
+
def allow_request(self) -> bool:
|
|
610
|
+
if self.state == "CLOSED":
|
|
611
|
+
return True
|
|
612
|
+
if self.state == "OPEN":
|
|
613
|
+
if (time.monotonic() - self.last_failure_time) >= self.recovery_timeout_sec:
|
|
614
|
+
self.state = "HALF_OPEN"
|
|
615
|
+
return True
|
|
616
|
+
return False
|
|
617
|
+
return True
|
|
618
|
+
|
|
619
|
+
def record_success(self) -> None:
|
|
620
|
+
self.failures = 0
|
|
621
|
+
self.state = "CLOSED"
|
|
622
|
+
|
|
623
|
+
def record_failure(self) -> None:
|
|
624
|
+
self.failures += 1
|
|
625
|
+
self.last_failure_time = time.monotonic()
|
|
626
|
+
if self.failures >= self.failure_threshold:
|
|
627
|
+
self.state = "OPEN"
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
class TenantRateLimiter:
|
|
631
|
+
"""Per-app sliding window rate limiter preventing runaway agent loops from starving the platform."""
|
|
632
|
+
|
|
633
|
+
def __init__(self, window_sec: float = 60.0):
|
|
634
|
+
self.window_sec = window_sec
|
|
635
|
+
self._hits: dict[tuple[str, str], deque[float]] = defaultdict(deque)
|
|
636
|
+
|
|
637
|
+
def check_and_record(self, tenant_id: str, app_name: str, max_rpm: int = 600) -> bool:
|
|
638
|
+
now = time.monotonic()
|
|
639
|
+
key = (tenant_id, app_name)
|
|
640
|
+
q = self._hits[key]
|
|
641
|
+
while q and (now - q[0]) > self.window_sec:
|
|
642
|
+
q.popleft()
|
|
643
|
+
if len(q) >= max_rpm:
|
|
644
|
+
return False
|
|
645
|
+
q.append(now)
|
|
646
|
+
return True
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
class TokenBudgetSerializer:
|
|
650
|
+
"""
|
|
651
|
+
Packs graph facts (ranked by HippoRAG-inspired associative salience = confidence * reinforcement boost,
|
|
652
|
+
with Letta-inspired pinned user persona facts) and episodic chunks into a bounded token budget (~4 chars/token).
|
|
653
|
+
"""
|
|
654
|
+
|
|
655
|
+
@staticmethod
|
|
656
|
+
def compute_salience(fact: GraphFactReadResult) -> float:
|
|
657
|
+
import math
|
|
658
|
+
|
|
659
|
+
is_user_persona = fact.namespace.startswith("user:")
|
|
660
|
+
persona_boost = 0.25 if is_user_persona else 0.0
|
|
661
|
+
reinforcement_boost = 1.0 + 0.15 * math.log1p(max(0, fact.reinforcement_count - 1))
|
|
662
|
+
return (fact.confidence * reinforcement_boost) + persona_boost
|
|
663
|
+
|
|
664
|
+
@staticmethod
|
|
665
|
+
def serialize(
|
|
666
|
+
facts: list[GraphFactReadResult],
|
|
667
|
+
episodes: list[EpisodicRecord],
|
|
668
|
+
max_tokens: int = 1500,
|
|
669
|
+
degraded_mode: bool = False,
|
|
670
|
+
) -> Tuple[str, list[EpisodicRecord]]:
|
|
671
|
+
max_chars = max_tokens * 4
|
|
672
|
+
used_chars = 0
|
|
673
|
+
fact_lines: list[str] = []
|
|
674
|
+
|
|
675
|
+
if degraded_mode:
|
|
676
|
+
header = "[GRAPH_DEGRADED_FALLBACK_MODE: Serving Verified Seed Summaries]\n"
|
|
677
|
+
else:
|
|
678
|
+
header = "[ACTIVE_ONTOLOGY_GRAPH_FACTS]\n"
|
|
679
|
+
used_chars += len(header)
|
|
680
|
+
|
|
681
|
+
sorted_facts = sorted(
|
|
682
|
+
facts, key=TokenBudgetSerializer.compute_salience, reverse=True
|
|
683
|
+
)
|
|
684
|
+
for f in sorted_facts:
|
|
685
|
+
reinf_tag = (
|
|
686
|
+
f" [verified={f.reinforcement_count}x]"
|
|
687
|
+
if f.reinforcement_count > 1
|
|
688
|
+
else ""
|
|
689
|
+
)
|
|
690
|
+
line = (
|
|
691
|
+
f"- [{f.namespace}] ({f.source_name}) -[{f.predicate}]-> ({f.target_name}): "
|
|
692
|
+
f"{f.fact_summary} [valid_from={f.t_valid}]{reinf_tag}\n"
|
|
693
|
+
)
|
|
694
|
+
if used_chars + len(line) > int(max_chars * 0.65):
|
|
695
|
+
break
|
|
696
|
+
fact_lines.append(line)
|
|
697
|
+
used_chars += len(line)
|
|
698
|
+
|
|
699
|
+
graph_block = (header + "".join(fact_lines)) if fact_lines else ""
|
|
700
|
+
|
|
701
|
+
kept_episodes: list[EpisodicRecord] = []
|
|
702
|
+
for ep in episodes:
|
|
703
|
+
if used_chars + len(ep.content_text) > max_chars:
|
|
704
|
+
remaining = max_chars - used_chars
|
|
705
|
+
if remaining > 120:
|
|
706
|
+
ep_copy = ep.model_copy()
|
|
707
|
+
ep_copy.content_text = ep.content_text[:remaining] + "...[truncated]"
|
|
708
|
+
kept_episodes.append(ep_copy)
|
|
709
|
+
break
|
|
710
|
+
kept_episodes.append(ep)
|
|
711
|
+
used_chars += len(ep.content_text)
|
|
712
|
+
|
|
713
|
+
return graph_block, kept_episodes
|