@reconcrap/people-network-memory 0.1.0
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.
- package/README.md +476 -0
- package/docs/mcp_tools.md +138 -0
- package/harness_adapters/openclaw/mcp.managed.unix.template.json +25 -0
- package/harness_adapters/openclaw/mcp.managed.windows.template.json +26 -0
- package/harness_adapters/openclaw/mcp.template.json +14 -0
- package/harness_adapters/openclaw/ppl/SKILL.md +114 -0
- package/package.json +30 -0
- package/pyproject.toml +26 -0
- package/scripts/install_windows.ps1 +92 -0
- package/scripts/npm/people-memory.js +276 -0
- package/scripts/people_memory_bootstrap.py +247 -0
- package/scripts/run_graphiti_live_from_liepin.ps1 +87 -0
- package/scripts/run_tests_with_artifacts.ps1 +307 -0
- package/src/people_network_memory/__init__.py +6 -0
- package/src/people_network_memory/application/__init__.py +16 -0
- package/src/people_network_memory/application/normalization.py +1441 -0
- package/src/people_network_memory/application/services.py +921 -0
- package/src/people_network_memory/cli.py +1212 -0
- package/src/people_network_memory/config.py +268 -0
- package/src/people_network_memory/domain/__init__.py +55 -0
- package/src/people_network_memory/domain/identity.py +77 -0
- package/src/people_network_memory/domain/models.py +355 -0
- package/src/people_network_memory/fixtures/__init__.py +6 -0
- package/src/people_network_memory/fixtures/eval.py +398 -0
- package/src/people_network_memory/fixtures/extractor_eval.py +364 -0
- package/src/people_network_memory/fixtures/generator.py +290 -0
- package/src/people_network_memory/fixtures/report.py +252 -0
- package/src/people_network_memory/graphiti_adapter/__init__.py +9 -0
- package/src/people_network_memory/graphiti_adapter/episode_formatter.py +70 -0
- package/src/people_network_memory/graphiti_adapter/graphiti_store.py +655 -0
- package/src/people_network_memory/graphiti_adapter/indexer.py +194 -0
- package/src/people_network_memory/graphiti_adapter/ontology.py +68 -0
- package/src/people_network_memory/harness_adapters/__init__.py +2 -0
- package/src/people_network_memory/harness_adapters/openclaw/__init__.py +9 -0
- package/src/people_network_memory/harness_adapters/openclaw/installer.py +577 -0
- package/src/people_network_memory/harness_adapters/openclaw/integration_eval.py +508 -0
- package/src/people_network_memory/harness_adapters/openclaw/smoke.py +292 -0
- package/src/people_network_memory/infrastructure/__init__.py +2 -0
- package/src/people_network_memory/infrastructure/archive_backup.py +171 -0
- package/src/people_network_memory/infrastructure/diagnostics.py +171 -0
- package/src/people_network_memory/infrastructure/embeddings.py +155 -0
- package/src/people_network_memory/infrastructure/file_store.py +129 -0
- package/src/people_network_memory/infrastructure/graphiti_promotion.py +212 -0
- package/src/people_network_memory/infrastructure/id_generator.py +40 -0
- package/src/people_network_memory/infrastructure/in_memory_store.py +1008 -0
- package/src/people_network_memory/infrastructure/llm_extractor.py +476 -0
- package/src/people_network_memory/infrastructure/llm_identity_advisor.py +200 -0
- package/src/people_network_memory/infrastructure/llm_judge.py +162 -0
- package/src/people_network_memory/infrastructure/redaction.py +21 -0
- package/src/people_network_memory/infrastructure/release_check.py +186 -0
- package/src/people_network_memory/infrastructure/retrieval_intent.py +98 -0
- package/src/people_network_memory/infrastructure/semantic_index.py +262 -0
- package/src/people_network_memory/mcp_server/__init__.py +2 -0
- package/src/people_network_memory/mcp_server/contracts.py +85 -0
- package/src/people_network_memory/mcp_server/runtime.py +133 -0
- package/src/people_network_memory/mcp_server/tools.py +588 -0
- package/src/people_network_memory/ports/__init__.py +2 -0
- package/src/people_network_memory/ports/errors.py +25 -0
- package/src/people_network_memory/ports/interfaces.py +103 -0
- package/src/people_network_memory/projection/__init__.py +6 -0
- package/src/people_network_memory/projection/builders.py +46 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
"""Graphiti-backed adapter using embedded Kuzu when configured.
|
|
2
|
+
|
|
3
|
+
Graphiti remains isolated here. The adapter also keeps a JSON projection cache
|
|
4
|
+
for stable person-card output while Graphiti owns semantic episode ingestion and
|
|
5
|
+
graph search.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import asyncio
|
|
12
|
+
import inspect
|
|
13
|
+
import importlib.util
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from concurrent.futures import TimeoutError as FutureTimeoutError
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from people_network_memory.config import PeopleMemoryConfig
|
|
25
|
+
from people_network_memory.domain.models import (
|
|
26
|
+
Evidence,
|
|
27
|
+
IdentityCandidate,
|
|
28
|
+
PersonMemoryRecord,
|
|
29
|
+
PersonRef,
|
|
30
|
+
RecordInteractionResult,
|
|
31
|
+
RetrievalItem,
|
|
32
|
+
SensitivityLabel,
|
|
33
|
+
SocialInteraction,
|
|
34
|
+
)
|
|
35
|
+
from people_network_memory.graphiti_adapter.episode_formatter import format_graphiti_episode
|
|
36
|
+
from people_network_memory.graphiti_adapter.ontology import EDGE_TYPES, ENTITY_TYPES
|
|
37
|
+
from people_network_memory.infrastructure.embeddings import (
|
|
38
|
+
EmbeddingSettings,
|
|
39
|
+
OpenAICompatibleEmbeddingClient,
|
|
40
|
+
)
|
|
41
|
+
from people_network_memory.infrastructure.file_store import JsonPeopleStore
|
|
42
|
+
from people_network_memory.infrastructure.retrieval_intent import (
|
|
43
|
+
is_follow_up_query,
|
|
44
|
+
mentioned_query_target,
|
|
45
|
+
text_answers_mentioned_query,
|
|
46
|
+
)
|
|
47
|
+
from people_network_memory.infrastructure.semantic_index import SemanticProjectionIndex
|
|
48
|
+
from people_network_memory.ports.errors import BackendUnavailableError
|
|
49
|
+
from people_network_memory.ports.errors import SearchError
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
CUSTOM_EXTRACTION_INSTRUCTIONS = """
|
|
53
|
+
Extract personal social-memory entities and relationships conservatively.
|
|
54
|
+
Keep participants separate from people merely mentioned in the note.
|
|
55
|
+
Represent secondhand statements as attributed claims, not direct facts.
|
|
56
|
+
Preserve places, topics, work/school facts, preferences, and follow-up context
|
|
57
|
+
only when supported by the episode body.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GraphitiGraphStore(JsonPeopleStore):
|
|
62
|
+
def __init__(self, graphiti: Any, config: PeopleMemoryConfig) -> None:
|
|
63
|
+
super().__init__(Path(config.data_path).expanduser() / "people-memory.graphiti-cache.json")
|
|
64
|
+
self._graphiti = graphiti
|
|
65
|
+
self._config = config
|
|
66
|
+
self._runner = _AsyncLoopRunner()
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_config(cls, config: PeopleMemoryConfig) -> "GraphitiGraphStore":
|
|
70
|
+
if importlib.util.find_spec("graphiti_core") is None:
|
|
71
|
+
raise BackendUnavailableError(
|
|
72
|
+
"graphiti_core is not installed. Install with `pip install -e .[graphiti]` "
|
|
73
|
+
"after the Graphiti spike requirements are confirmed."
|
|
74
|
+
)
|
|
75
|
+
graphiti = _build_graphiti(config)
|
|
76
|
+
return cls(graphiti=graphiti, config=config)
|
|
77
|
+
|
|
78
|
+
def find_identity_candidates(self, ref: PersonRef) -> list[IdentityCandidate]:
|
|
79
|
+
return super().find_identity_candidates(ref)
|
|
80
|
+
|
|
81
|
+
def save_interaction(
|
|
82
|
+
self, interaction: SocialInteraction, identity_map: dict[str, str | None]
|
|
83
|
+
) -> RecordInteractionResult:
|
|
84
|
+
result = super().save_interaction(interaction, identity_map)
|
|
85
|
+
graphiti_evidence = self.index_interaction_episode(interaction)
|
|
86
|
+
return result.model_copy(update={"evidence": [*result.evidence, graphiti_evidence]})
|
|
87
|
+
|
|
88
|
+
def get_person_memory(self, person_id: str) -> PersonMemoryRecord | None:
|
|
89
|
+
return super().get_person_memory(person_id)
|
|
90
|
+
|
|
91
|
+
def index_interaction_episode(self, interaction: SocialInteraction) -> Evidence:
|
|
92
|
+
try:
|
|
93
|
+
graphiti_result = _with_retries(
|
|
94
|
+
lambda: self._runner.run(
|
|
95
|
+
self._add_episode(interaction),
|
|
96
|
+
timeout_seconds=self._config.graphiti_add_timeout_seconds,
|
|
97
|
+
),
|
|
98
|
+
label="Graphiti add_episode",
|
|
99
|
+
attempts=self._config.graphiti_retry_attempts,
|
|
100
|
+
)
|
|
101
|
+
except Exception as exc: # pragma: no cover - live backend defensive path
|
|
102
|
+
raise BackendUnavailableError(f"Graphiti add_episode failed: {exc}") from exc
|
|
103
|
+
return Evidence(
|
|
104
|
+
evidence_id=getattr(graphiti_result.episode, "uuid", "graphiti_episode"),
|
|
105
|
+
source_text=interaction.source_text,
|
|
106
|
+
recorded_at=interaction.occurred_at or datetime.now(timezone.utc),
|
|
107
|
+
confidence=1.0,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def cache_interaction_projection(self, interaction: SocialInteraction) -> None:
|
|
111
|
+
if any(
|
|
112
|
+
existing.source_text == interaction.source_text
|
|
113
|
+
and existing.occurred_at == interaction.occurred_at
|
|
114
|
+
for existing in self.interactions.values()
|
|
115
|
+
):
|
|
116
|
+
return
|
|
117
|
+
super().save_interaction(interaction, _identity_map_for_cache(self, interaction))
|
|
118
|
+
|
|
119
|
+
def close(self) -> None:
|
|
120
|
+
try:
|
|
121
|
+
close_graphiti = getattr(self._graphiti, "close", None)
|
|
122
|
+
if callable(close_graphiti):
|
|
123
|
+
result = close_graphiti()
|
|
124
|
+
if inspect.isawaitable(result):
|
|
125
|
+
self._runner.run(result, timeout_seconds=10)
|
|
126
|
+
except TimeoutError:
|
|
127
|
+
pass
|
|
128
|
+
finally:
|
|
129
|
+
self._runner.close()
|
|
130
|
+
|
|
131
|
+
def search(
|
|
132
|
+
self,
|
|
133
|
+
query: str,
|
|
134
|
+
*,
|
|
135
|
+
limit: int = 10,
|
|
136
|
+
include_sensitive: bool = False,
|
|
137
|
+
mode: str = "recall",
|
|
138
|
+
) -> list[RetrievalItem]:
|
|
139
|
+
try:
|
|
140
|
+
edges = _with_retries(
|
|
141
|
+
lambda: self._runner.run(
|
|
142
|
+
self._graphiti.search(query, num_results=limit),
|
|
143
|
+
timeout_seconds=self._config.graphiti_search_timeout_seconds,
|
|
144
|
+
),
|
|
145
|
+
label="Graphiti search",
|
|
146
|
+
attempts=self._config.graphiti_retry_attempts,
|
|
147
|
+
)
|
|
148
|
+
except Exception as exc: # pragma: no cover - live backend defensive path
|
|
149
|
+
raise SearchError(f"Graphiti search failed: {exc}") from exc
|
|
150
|
+
items: list[RetrievalItem] = []
|
|
151
|
+
for edge in edges:
|
|
152
|
+
fact = getattr(edge, "fact", "")
|
|
153
|
+
if not include_sensitive and _looks_sensitive(fact):
|
|
154
|
+
continue
|
|
155
|
+
evidence = [
|
|
156
|
+
Evidence(
|
|
157
|
+
evidence_id=episode_id,
|
|
158
|
+
source_text=fact,
|
|
159
|
+
recorded_at=getattr(edge, "reference_time", None)
|
|
160
|
+
or getattr(edge, "created_at", None)
|
|
161
|
+
or datetime.now(timezone.utc),
|
|
162
|
+
confidence=1.0,
|
|
163
|
+
)
|
|
164
|
+
for episode_id in getattr(edge, "episodes", [])[:3]
|
|
165
|
+
]
|
|
166
|
+
if not evidence:
|
|
167
|
+
evidence = [
|
|
168
|
+
Evidence(
|
|
169
|
+
evidence_id=getattr(edge, "uuid", "graphiti_edge"),
|
|
170
|
+
source_text=fact,
|
|
171
|
+
recorded_at=getattr(edge, "created_at", None)
|
|
172
|
+
or datetime.now(timezone.utc),
|
|
173
|
+
)
|
|
174
|
+
]
|
|
175
|
+
items.append(
|
|
176
|
+
RetrievalItem(
|
|
177
|
+
item_id=getattr(edge, "uuid", fact),
|
|
178
|
+
kind="fact",
|
|
179
|
+
title=getattr(edge, "name", "Graphiti fact"),
|
|
180
|
+
matched_text=fact,
|
|
181
|
+
score=1.0,
|
|
182
|
+
why_matched="Matched by Graphiti hybrid graph search.",
|
|
183
|
+
evidence=evidence,
|
|
184
|
+
sensitivity=[SensitivityLabel.SECONDHAND]
|
|
185
|
+
if _looks_secondhand(fact)
|
|
186
|
+
else [],
|
|
187
|
+
is_secondhand=_looks_secondhand(fact),
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
semantic_items = self._semantic_projection_search(
|
|
191
|
+
query,
|
|
192
|
+
limit=max(limit * 4, 20),
|
|
193
|
+
include_sensitive=include_sensitive,
|
|
194
|
+
)
|
|
195
|
+
local_items = super().search(
|
|
196
|
+
query,
|
|
197
|
+
limit=max(limit * 4, 20),
|
|
198
|
+
include_sensitive=include_sensitive,
|
|
199
|
+
mode=mode,
|
|
200
|
+
)
|
|
201
|
+
candidates = _apply_intent_reranking(query, [*semantic_items, *local_items, *items])
|
|
202
|
+
return _merge_retrieval_items(candidates, limit=limit)
|
|
203
|
+
|
|
204
|
+
async def _add_episode(self, interaction: SocialInteraction) -> Any:
|
|
205
|
+
from graphiti_core.nodes import EpisodeType
|
|
206
|
+
|
|
207
|
+
return await self._graphiti.add_episode(
|
|
208
|
+
name=f"social_interaction:{interaction.occurred_at or datetime.now(timezone.utc)}",
|
|
209
|
+
episode_body=format_graphiti_episode(interaction),
|
|
210
|
+
source_description="people-network-memory record_interaction",
|
|
211
|
+
reference_time=interaction.occurred_at or datetime.now(timezone.utc),
|
|
212
|
+
source=EpisodeType.text,
|
|
213
|
+
entity_types=ENTITY_TYPES,
|
|
214
|
+
edge_types=EDGE_TYPES,
|
|
215
|
+
custom_extraction_instructions=CUSTOM_EXTRACTION_INSTRUCTIONS,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def _semantic_projection_search(
|
|
219
|
+
self,
|
|
220
|
+
query: str,
|
|
221
|
+
*,
|
|
222
|
+
limit: int,
|
|
223
|
+
include_sensitive: bool,
|
|
224
|
+
) -> list[RetrievalItem]:
|
|
225
|
+
path = semantic_index_path(self._config)
|
|
226
|
+
if not path.exists():
|
|
227
|
+
return []
|
|
228
|
+
try:
|
|
229
|
+
settings = EmbeddingSettings.from_config(self._config)
|
|
230
|
+
client = OpenAICompatibleEmbeddingClient(settings)
|
|
231
|
+
return SemanticProjectionIndex(path).search(
|
|
232
|
+
query,
|
|
233
|
+
embed_texts=client.embed,
|
|
234
|
+
limit=limit,
|
|
235
|
+
include_sensitive=include_sensitive,
|
|
236
|
+
)
|
|
237
|
+
except Exception:
|
|
238
|
+
return []
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def semantic_index_path(config: PeopleMemoryConfig) -> Path:
|
|
242
|
+
return Path(config.data_path).expanduser() / "people-memory.semantic-cache.json"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _build_graphiti(config: PeopleMemoryConfig) -> Any:
|
|
246
|
+
if not config.llm_provider or not config.llm_model:
|
|
247
|
+
raise BackendUnavailableError(
|
|
248
|
+
"Graphiti requires PEOPLE_MEMORY_LLM_PROVIDER and PEOPLE_MEMORY_LLM_MODEL."
|
|
249
|
+
)
|
|
250
|
+
from graphiti_core import Graphiti
|
|
251
|
+
from graphiti_core.cross_encoder.client import CrossEncoderClient
|
|
252
|
+
from graphiti_core.driver.kuzu_driver import KuzuDriver
|
|
253
|
+
from graphiti_core.embedder.openai import OpenAIEmbedder, OpenAIEmbedderConfig
|
|
254
|
+
from graphiti_core.llm_client.config import LLMConfig
|
|
255
|
+
from graphiti_core.llm_client.openai_generic_client import OpenAIGenericClient
|
|
256
|
+
|
|
257
|
+
embedding = EmbeddingSettings.from_config(config)
|
|
258
|
+
llm_config = LLMConfig(
|
|
259
|
+
api_key=config.llm_api_key or "local",
|
|
260
|
+
model=config.llm_model,
|
|
261
|
+
small_model=config.llm_model,
|
|
262
|
+
base_url=config.llm_base_url,
|
|
263
|
+
)
|
|
264
|
+
llm_client = _build_llm_client(config, llm_config, OpenAIGenericClient)
|
|
265
|
+
embedder = OpenAIEmbedder(
|
|
266
|
+
config=OpenAIEmbedderConfig(
|
|
267
|
+
api_key=embedding.api_key,
|
|
268
|
+
embedding_model=embedding.model,
|
|
269
|
+
embedding_dim=embedding.dimension or 1024,
|
|
270
|
+
base_url=embedding.base_url,
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
cross_encoder = _build_cross_encoder(CrossEncoderClient)
|
|
274
|
+
if config.graph_backend_kind == "kuzu":
|
|
275
|
+
kuzu_path = Path(config.graphiti_kuzu_path).expanduser()
|
|
276
|
+
kuzu_path.parent.mkdir(parents=True, exist_ok=True)
|
|
277
|
+
driver = KuzuDriver(db=str(kuzu_path))
|
|
278
|
+
graphiti = Graphiti(
|
|
279
|
+
graph_driver=driver,
|
|
280
|
+
llm_client=llm_client,
|
|
281
|
+
embedder=embedder,
|
|
282
|
+
cross_encoder=cross_encoder,
|
|
283
|
+
)
|
|
284
|
+
_run_async(_ensure_kuzu_fulltext_indices(driver))
|
|
285
|
+
return graphiti
|
|
286
|
+
raise BackendUnavailableError(
|
|
287
|
+
f"Graphiti backend {config.graph_backend_kind} is not wired in this adapter yet."
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _build_llm_client(
|
|
292
|
+
config: PeopleMemoryConfig, llm_config: Any, openai_generic_client: Any
|
|
293
|
+
) -> Any:
|
|
294
|
+
if config.llm_response_format == "json_schema":
|
|
295
|
+
return openai_generic_client(config=llm_config)
|
|
296
|
+
|
|
297
|
+
class JsonObjectCompatibleClient(openai_generic_client): # type: ignore[misc, valid-type]
|
|
298
|
+
async def _generate_response(
|
|
299
|
+
self,
|
|
300
|
+
messages: list[Any],
|
|
301
|
+
response_model: type[Any] | None = None,
|
|
302
|
+
max_tokens: int = 16384,
|
|
303
|
+
model_size: Any = None,
|
|
304
|
+
) -> dict[str, Any]:
|
|
305
|
+
import openai
|
|
306
|
+
from graphiti_core.llm_client.errors import RateLimitError
|
|
307
|
+
|
|
308
|
+
if response_model is not None and messages:
|
|
309
|
+
serialized_model = json.dumps(response_model.model_json_schema())
|
|
310
|
+
messages[-1].content += (
|
|
311
|
+
"\n\nRespond with exactly one JSON object matching this JSON Schema:\n"
|
|
312
|
+
f"{serialized_model}"
|
|
313
|
+
)
|
|
314
|
+
openai_messages: list[dict[str, str]] = []
|
|
315
|
+
for message in messages:
|
|
316
|
+
message.content = self._clean_input(message.content)
|
|
317
|
+
if message.role in {"user", "system"}:
|
|
318
|
+
openai_messages.append(
|
|
319
|
+
{"role": message.role, "content": message.content}
|
|
320
|
+
)
|
|
321
|
+
try:
|
|
322
|
+
request: dict[str, Any] = {
|
|
323
|
+
"model": self.model,
|
|
324
|
+
"messages": openai_messages,
|
|
325
|
+
"temperature": self.temperature,
|
|
326
|
+
"max_tokens": max_tokens or self.max_tokens,
|
|
327
|
+
}
|
|
328
|
+
if config.llm_response_format == "json_object":
|
|
329
|
+
request["response_format"] = {"type": "json_object"}
|
|
330
|
+
response = await self.client.chat.completions.create(**request)
|
|
331
|
+
result = response.choices[0].message.content or "{}"
|
|
332
|
+
parsed = _parse_json_object(result)
|
|
333
|
+
if response_model is not None and isinstance(parsed, dict):
|
|
334
|
+
valid_fields = set(response_model.model_fields)
|
|
335
|
+
parsed = {
|
|
336
|
+
key: value
|
|
337
|
+
for key, value in parsed.items()
|
|
338
|
+
if key in valid_fields
|
|
339
|
+
}
|
|
340
|
+
return parsed
|
|
341
|
+
except openai.RateLimitError as exc:
|
|
342
|
+
raise RateLimitError from exc
|
|
343
|
+
|
|
344
|
+
return JsonObjectCompatibleClient(config=llm_config)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _build_cross_encoder(cross_encoder_client: Any) -> Any:
|
|
348
|
+
class PassThroughCrossEncoder(cross_encoder_client): # type: ignore[misc, valid-type]
|
|
349
|
+
async def rank(self, query: str, passages: list[str]) -> list[tuple[str, float]]:
|
|
350
|
+
query_terms = {term.lower() for term in query.split() if term.strip()}
|
|
351
|
+
|
|
352
|
+
def score(passage: str) -> float:
|
|
353
|
+
lowered = passage.lower()
|
|
354
|
+
return float(sum(1 for term in query_terms if term in lowered))
|
|
355
|
+
|
|
356
|
+
return sorted(
|
|
357
|
+
((passage, score(passage)) for passage in passages),
|
|
358
|
+
key=lambda item: item[1],
|
|
359
|
+
reverse=True,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
return PassThroughCrossEncoder()
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
async def _ensure_kuzu_fulltext_indices(driver: Any) -> None:
|
|
366
|
+
from graphiti_core.driver.driver import GraphProvider
|
|
367
|
+
from graphiti_core.graph_queries import get_fulltext_indices
|
|
368
|
+
|
|
369
|
+
kuzu_logger = logging.getLogger("graphiti_core.driver.kuzu_driver")
|
|
370
|
+
duplicate_index_filter = _KuzuDuplicateIndexLogFilter()
|
|
371
|
+
kuzu_logger.addFilter(duplicate_index_filter)
|
|
372
|
+
try:
|
|
373
|
+
for query in get_fulltext_indices(GraphProvider.KUZU):
|
|
374
|
+
try:
|
|
375
|
+
await driver.execute_query(query)
|
|
376
|
+
except Exception as exc:
|
|
377
|
+
if not _is_duplicate_kuzu_fulltext_index_exception(exc):
|
|
378
|
+
raise
|
|
379
|
+
finally:
|
|
380
|
+
kuzu_logger.removeFilter(duplicate_index_filter)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class _KuzuDuplicateIndexLogFilter(logging.Filter):
|
|
384
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
385
|
+
return not _is_duplicate_kuzu_fulltext_index_log(record.getMessage())
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _is_duplicate_kuzu_fulltext_index_log(message: str) -> bool:
|
|
389
|
+
lowered = message.lower()
|
|
390
|
+
return (
|
|
391
|
+
"error executing kuzu query" in lowered
|
|
392
|
+
and "create_fts_index" in lowered
|
|
393
|
+
and "already exists" in lowered
|
|
394
|
+
and "index" in lowered
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _is_duplicate_kuzu_fulltext_index_exception(exc: Exception) -> bool:
|
|
399
|
+
lowered = str(exc).lower()
|
|
400
|
+
return "already exists" in lowered and "index" in lowered
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _run_async(awaitable: Any) -> Any:
|
|
404
|
+
try:
|
|
405
|
+
asyncio.get_running_loop()
|
|
406
|
+
except RuntimeError:
|
|
407
|
+
return asyncio.run(awaitable)
|
|
408
|
+
raise RuntimeError("GraphitiGraphStore cannot be called from an active event loop yet.")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _with_retries(operation: Any, *, label: str, attempts: int = 3) -> Any:
|
|
412
|
+
last_exc: Exception | None = None
|
|
413
|
+
for attempt in range(1, attempts + 1):
|
|
414
|
+
try:
|
|
415
|
+
return operation()
|
|
416
|
+
except Exception as exc: # pragma: no cover - retry shape is tested with fakes
|
|
417
|
+
last_exc = exc
|
|
418
|
+
if attempt >= attempts or not _is_retryable_graphiti_error(exc):
|
|
419
|
+
raise
|
|
420
|
+
time.sleep(min(2 ** (attempt - 1), 4))
|
|
421
|
+
raise RuntimeError(f"{label} failed without an exception") from last_exc
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _is_retryable_graphiti_error(exc: Exception) -> bool:
|
|
425
|
+
lowered = str(exc).lower()
|
|
426
|
+
retryable_terms = [
|
|
427
|
+
"500",
|
|
428
|
+
"502",
|
|
429
|
+
"503",
|
|
430
|
+
"504",
|
|
431
|
+
"internalserviceerror",
|
|
432
|
+
"internal server error",
|
|
433
|
+
"timeout",
|
|
434
|
+
"timed out",
|
|
435
|
+
"temporarily unavailable",
|
|
436
|
+
"rate limit",
|
|
437
|
+
"no valid json object found",
|
|
438
|
+
]
|
|
439
|
+
return any(term in lowered for term in retryable_terms)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _parse_json_object(text: str) -> dict[str, Any]:
|
|
443
|
+
stripped = _strip_json_fence(text.strip())
|
|
444
|
+
parsed = _parse_jsonish_object(stripped)
|
|
445
|
+
if not isinstance(parsed, dict):
|
|
446
|
+
raise json.JSONDecodeError("Expected a JSON object", stripped, 0)
|
|
447
|
+
return parsed
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _strip_json_fence(text: str) -> str:
|
|
451
|
+
if not text.startswith("```"):
|
|
452
|
+
return text
|
|
453
|
+
lines = text.splitlines()
|
|
454
|
+
if len(lines) >= 3 and lines[-1].strip() == "```":
|
|
455
|
+
return "\n".join(lines[1:-1]).strip()
|
|
456
|
+
return text
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _first_balanced_json_object(text: str) -> str:
|
|
460
|
+
start = text.find("{")
|
|
461
|
+
if start < 0:
|
|
462
|
+
raise json.JSONDecodeError("No JSON object found", text, 0)
|
|
463
|
+
depth = 0
|
|
464
|
+
in_string = False
|
|
465
|
+
escaped = False
|
|
466
|
+
for index in range(start, len(text)):
|
|
467
|
+
char = text[index]
|
|
468
|
+
if escaped:
|
|
469
|
+
escaped = False
|
|
470
|
+
continue
|
|
471
|
+
if char == "\\" and in_string:
|
|
472
|
+
escaped = True
|
|
473
|
+
continue
|
|
474
|
+
if char == '"':
|
|
475
|
+
in_string = not in_string
|
|
476
|
+
continue
|
|
477
|
+
if in_string:
|
|
478
|
+
continue
|
|
479
|
+
if char == "{":
|
|
480
|
+
depth += 1
|
|
481
|
+
elif char == "}":
|
|
482
|
+
depth -= 1
|
|
483
|
+
if depth == 0:
|
|
484
|
+
return text[start : index + 1]
|
|
485
|
+
raise json.JSONDecodeError("Unterminated JSON object", text, start)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _parse_jsonish_object(text: str) -> Any:
|
|
489
|
+
candidates = [text]
|
|
490
|
+
try:
|
|
491
|
+
balanced = _first_balanced_json_object(text)
|
|
492
|
+
except json.JSONDecodeError:
|
|
493
|
+
balanced = None
|
|
494
|
+
if balanced and balanced != text:
|
|
495
|
+
candidates.append(balanced)
|
|
496
|
+
for candidate in candidates:
|
|
497
|
+
try:
|
|
498
|
+
return json.loads(candidate)
|
|
499
|
+
except json.JSONDecodeError:
|
|
500
|
+
pass
|
|
501
|
+
try:
|
|
502
|
+
return ast.literal_eval(candidate)
|
|
503
|
+
except (ValueError, SyntaxError):
|
|
504
|
+
pass
|
|
505
|
+
try:
|
|
506
|
+
return json.loads(_repair_common_json(candidate))
|
|
507
|
+
except json.JSONDecodeError:
|
|
508
|
+
pass
|
|
509
|
+
raise json.JSONDecodeError("No valid JSON object found", text, 0)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _repair_common_json(text: str) -> str:
|
|
513
|
+
repaired = re.sub(r",\s*([}\]])", r"\1", text)
|
|
514
|
+
repaired = re.sub(r"([{,]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:", r'\1"\2":', repaired)
|
|
515
|
+
repaired = re.sub(r"\bTrue\b", "true", repaired)
|
|
516
|
+
repaired = re.sub(r"\bFalse\b", "false", repaired)
|
|
517
|
+
repaired = re.sub(r"\bNone\b", "null", repaired)
|
|
518
|
+
return repaired
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
class _AsyncLoopRunner:
|
|
522
|
+
def __init__(self) -> None:
|
|
523
|
+
self._loop = asyncio.new_event_loop()
|
|
524
|
+
self._ready = threading.Event()
|
|
525
|
+
self._thread = threading.Thread(target=self._run_loop, daemon=True)
|
|
526
|
+
self._thread.start()
|
|
527
|
+
self._ready.wait(timeout=5)
|
|
528
|
+
|
|
529
|
+
def run(self, awaitable: Any, *, timeout_seconds: float | None = None) -> Any:
|
|
530
|
+
if self._loop.is_closed():
|
|
531
|
+
raise RuntimeError("Graphiti async loop is closed")
|
|
532
|
+
future = asyncio.run_coroutine_threadsafe(awaitable, self._loop)
|
|
533
|
+
try:
|
|
534
|
+
return future.result(timeout=timeout_seconds)
|
|
535
|
+
except FutureTimeoutError as exc:
|
|
536
|
+
future.cancel()
|
|
537
|
+
raise TimeoutError("Graphiti async operation timed out") from exc
|
|
538
|
+
|
|
539
|
+
def close(self) -> None:
|
|
540
|
+
if self._loop.is_closed():
|
|
541
|
+
return
|
|
542
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
543
|
+
self._thread.join(timeout=2)
|
|
544
|
+
self._loop.close()
|
|
545
|
+
|
|
546
|
+
def _run_loop(self) -> None:
|
|
547
|
+
asyncio.set_event_loop(self._loop)
|
|
548
|
+
self._ready.set()
|
|
549
|
+
self._loop.run_forever()
|
|
550
|
+
|
|
551
|
+
def __del__(self) -> None: # pragma: no cover - best-effort cleanup
|
|
552
|
+
try:
|
|
553
|
+
self.close()
|
|
554
|
+
except Exception:
|
|
555
|
+
pass
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _looks_sensitive(text: str) -> bool:
|
|
559
|
+
lowered = text.lower()
|
|
560
|
+
return any(term in lowered for term in ["private", "sensitive", "confidential", "compensation"])
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _looks_secondhand(text: str) -> bool:
|
|
564
|
+
lowered = text.lower()
|
|
565
|
+
return any(term in lowered for term in [" said ", " mentioned ", "told", "提到"])
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _identity_map_for_cache(
|
|
569
|
+
store: GraphitiGraphStore, interaction: SocialInteraction
|
|
570
|
+
) -> dict[str, str | None]:
|
|
571
|
+
identity_map: dict[str, str | None] = {}
|
|
572
|
+
for ref in _iter_interaction_refs(interaction):
|
|
573
|
+
key = _ref_key(ref)
|
|
574
|
+
if key in identity_map:
|
|
575
|
+
continue
|
|
576
|
+
if ref.person_id:
|
|
577
|
+
identity_map[key] = ref.person_id
|
|
578
|
+
continue
|
|
579
|
+
candidates = store.find_identity_candidates(ref)
|
|
580
|
+
identity_map[key] = candidates[0].person_id if candidates and candidates[0].score >= 0.9 else None
|
|
581
|
+
return identity_map
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _iter_interaction_refs(interaction: SocialInteraction) -> list[PersonRef]:
|
|
585
|
+
refs: list[PersonRef] = []
|
|
586
|
+
refs.extend(participant.person for participant in interaction.participants)
|
|
587
|
+
refs.extend(mentioned.person for mentioned in interaction.mentioned_people)
|
|
588
|
+
refs.extend(mentioned.mentioned_by for mentioned in interaction.mentioned_people if mentioned.mentioned_by)
|
|
589
|
+
for claim in interaction.attributed_claims:
|
|
590
|
+
if claim.speaker:
|
|
591
|
+
refs.append(claim.speaker)
|
|
592
|
+
if claim.subject:
|
|
593
|
+
refs.append(claim.subject)
|
|
594
|
+
refs.extend(fact.subject for fact in interaction.direct_facts)
|
|
595
|
+
for follow_up in interaction.follow_ups:
|
|
596
|
+
refs.extend(follow_up.related_people)
|
|
597
|
+
for relationship in interaction.relationships:
|
|
598
|
+
refs.extend([relationship.source, relationship.target])
|
|
599
|
+
return refs
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _ref_key(ref: PersonRef) -> str:
|
|
603
|
+
return ref.person_id or ref.email or ref.phone or ref.label
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _apply_intent_reranking(query: str, items: list[RetrievalItem]) -> list[RetrievalItem]:
|
|
607
|
+
mention_target = mentioned_query_target(query)
|
|
608
|
+
if mention_target:
|
|
609
|
+
mentioned_items: list[RetrievalItem] = []
|
|
610
|
+
for item in items:
|
|
611
|
+
text = f"{item.title} {item.matched_text}"
|
|
612
|
+
if not text_answers_mentioned_query(text, mention_target):
|
|
613
|
+
continue
|
|
614
|
+
if _title_represents_person(item.title, mention_target):
|
|
615
|
+
continue
|
|
616
|
+
mentioned_items.append(item.model_copy(update={"score": item.score + 1.5}))
|
|
617
|
+
if mentioned_items:
|
|
618
|
+
return mentioned_items
|
|
619
|
+
|
|
620
|
+
if is_follow_up_query(query):
|
|
621
|
+
follow_up_items = [
|
|
622
|
+
item
|
|
623
|
+
for item in items
|
|
624
|
+
if item.kind == "follow_up"
|
|
625
|
+
or "follow up" in f"{item.title} {item.matched_text}".lower()
|
|
626
|
+
or "follow-up" in f"{item.title} {item.matched_text}".lower()
|
|
627
|
+
]
|
|
628
|
+
if follow_up_items:
|
|
629
|
+
return [
|
|
630
|
+
item.model_copy(update={"score": item.score + 2.0})
|
|
631
|
+
for item in follow_up_items
|
|
632
|
+
]
|
|
633
|
+
return items
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _title_represents_person(title: str, person_label: str) -> bool:
|
|
637
|
+
lowered_title = title.strip().lower()
|
|
638
|
+
lowered_person = person_label.strip().lower()
|
|
639
|
+
return lowered_title in {
|
|
640
|
+
lowered_person,
|
|
641
|
+
f"interaction with {lowered_person}",
|
|
642
|
+
f"claim involving {lowered_person}",
|
|
643
|
+
f"relationship involving {lowered_person}",
|
|
644
|
+
f"follow-up for {lowered_person}",
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _merge_retrieval_items(items: list[RetrievalItem], *, limit: int) -> list[RetrievalItem]:
|
|
649
|
+
merged: dict[tuple[str, str, str], RetrievalItem] = {}
|
|
650
|
+
for item in items:
|
|
651
|
+
key = (item.kind, item.title, item.matched_text)
|
|
652
|
+
existing = merged.get(key)
|
|
653
|
+
if existing is None or item.score > existing.score:
|
|
654
|
+
merged[key] = item
|
|
655
|
+
return sorted(merged.values(), key=lambda item: item.score, reverse=True)[:limit]
|