@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.
Files changed (61) hide show
  1. package/README.md +476 -0
  2. package/docs/mcp_tools.md +138 -0
  3. package/harness_adapters/openclaw/mcp.managed.unix.template.json +25 -0
  4. package/harness_adapters/openclaw/mcp.managed.windows.template.json +26 -0
  5. package/harness_adapters/openclaw/mcp.template.json +14 -0
  6. package/harness_adapters/openclaw/ppl/SKILL.md +114 -0
  7. package/package.json +30 -0
  8. package/pyproject.toml +26 -0
  9. package/scripts/install_windows.ps1 +92 -0
  10. package/scripts/npm/people-memory.js +276 -0
  11. package/scripts/people_memory_bootstrap.py +247 -0
  12. package/scripts/run_graphiti_live_from_liepin.ps1 +87 -0
  13. package/scripts/run_tests_with_artifacts.ps1 +307 -0
  14. package/src/people_network_memory/__init__.py +6 -0
  15. package/src/people_network_memory/application/__init__.py +16 -0
  16. package/src/people_network_memory/application/normalization.py +1441 -0
  17. package/src/people_network_memory/application/services.py +921 -0
  18. package/src/people_network_memory/cli.py +1212 -0
  19. package/src/people_network_memory/config.py +268 -0
  20. package/src/people_network_memory/domain/__init__.py +55 -0
  21. package/src/people_network_memory/domain/identity.py +77 -0
  22. package/src/people_network_memory/domain/models.py +355 -0
  23. package/src/people_network_memory/fixtures/__init__.py +6 -0
  24. package/src/people_network_memory/fixtures/eval.py +398 -0
  25. package/src/people_network_memory/fixtures/extractor_eval.py +364 -0
  26. package/src/people_network_memory/fixtures/generator.py +290 -0
  27. package/src/people_network_memory/fixtures/report.py +252 -0
  28. package/src/people_network_memory/graphiti_adapter/__init__.py +9 -0
  29. package/src/people_network_memory/graphiti_adapter/episode_formatter.py +70 -0
  30. package/src/people_network_memory/graphiti_adapter/graphiti_store.py +655 -0
  31. package/src/people_network_memory/graphiti_adapter/indexer.py +194 -0
  32. package/src/people_network_memory/graphiti_adapter/ontology.py +68 -0
  33. package/src/people_network_memory/harness_adapters/__init__.py +2 -0
  34. package/src/people_network_memory/harness_adapters/openclaw/__init__.py +9 -0
  35. package/src/people_network_memory/harness_adapters/openclaw/installer.py +577 -0
  36. package/src/people_network_memory/harness_adapters/openclaw/integration_eval.py +508 -0
  37. package/src/people_network_memory/harness_adapters/openclaw/smoke.py +292 -0
  38. package/src/people_network_memory/infrastructure/__init__.py +2 -0
  39. package/src/people_network_memory/infrastructure/archive_backup.py +171 -0
  40. package/src/people_network_memory/infrastructure/diagnostics.py +171 -0
  41. package/src/people_network_memory/infrastructure/embeddings.py +155 -0
  42. package/src/people_network_memory/infrastructure/file_store.py +129 -0
  43. package/src/people_network_memory/infrastructure/graphiti_promotion.py +212 -0
  44. package/src/people_network_memory/infrastructure/id_generator.py +40 -0
  45. package/src/people_network_memory/infrastructure/in_memory_store.py +1008 -0
  46. package/src/people_network_memory/infrastructure/llm_extractor.py +476 -0
  47. package/src/people_network_memory/infrastructure/llm_identity_advisor.py +200 -0
  48. package/src/people_network_memory/infrastructure/llm_judge.py +162 -0
  49. package/src/people_network_memory/infrastructure/redaction.py +21 -0
  50. package/src/people_network_memory/infrastructure/release_check.py +186 -0
  51. package/src/people_network_memory/infrastructure/retrieval_intent.py +98 -0
  52. package/src/people_network_memory/infrastructure/semantic_index.py +262 -0
  53. package/src/people_network_memory/mcp_server/__init__.py +2 -0
  54. package/src/people_network_memory/mcp_server/contracts.py +85 -0
  55. package/src/people_network_memory/mcp_server/runtime.py +133 -0
  56. package/src/people_network_memory/mcp_server/tools.py +588 -0
  57. package/src/people_network_memory/ports/__init__.py +2 -0
  58. package/src/people_network_memory/ports/errors.py +25 -0
  59. package/src/people_network_memory/ports/interfaces.py +103 -0
  60. package/src/people_network_memory/projection/__init__.py +6 -0
  61. 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]