graphiti-core 0.30.0rc0__tar.gz → 0.30.0rc2__tar.gz

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.

Potentially problematic release.


This version of graphiti-core might be problematic. Click here for more details.

Files changed (192) hide show
  1. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/PKG-INFO +1 -1
  2. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/utils/bulk_utils.py +126 -60
  3. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/utils/maintenance/dedup_helpers.py +6 -1
  4. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/utils/maintenance/node_operations.py +36 -8
  5. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/pyproject.toml +1 -1
  6. graphiti_core-0.30.0rc2/tests/utils/maintenance/test_bulk_utils.py +232 -0
  7. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/utils/maintenance/test_node_operations.py +138 -0
  8. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/uv.lock +1 -1
  9. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/.env.example +0 -0
  10. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  11. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/.github/dependabot.yml +0 -0
  12. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/.github/pull_request_template.md +0 -0
  13. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/.github/secret_scanning.yml +0 -0
  14. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/.github/workflows/ai-moderator.yml +0 -0
  15. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/.github/workflows/cla.yml +0 -0
  16. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/.github/workflows/claude-code-review.yml +0 -0
  17. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/.github/workflows/claude.yml +0 -0
  18. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/.github/workflows/codeql.yml +0 -0
  19. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/.github/workflows/lint.yml +0 -0
  20. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/.github/workflows/mcp-server-docker.yml +0 -0
  21. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/.github/workflows/release-graphiti-core.yml +0 -0
  22. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/.github/workflows/typecheck.yml +0 -0
  23. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/.github/workflows/unit_tests.yml +0 -0
  24. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/.gitignore +0 -0
  25. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/AGENTS.md +0 -0
  26. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/CLAUDE.md +0 -0
  27. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/CODE_OF_CONDUCT.md +0 -0
  28. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/CONTRIBUTING.md +0 -0
  29. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/Dockerfile +0 -0
  30. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/LICENSE +0 -0
  31. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/Makefile +0 -0
  32. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/README.md +0 -0
  33. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/SECURITY.md +0 -0
  34. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/Zep-CLA.md +0 -0
  35. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/conftest.py +0 -0
  36. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/depot.json +0 -0
  37. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/docker-compose.test.yml +0 -0
  38. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/docker-compose.yml +0 -0
  39. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/ellipsis.yaml +0 -0
  40. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/examples/data/manybirds_products.json +0 -0
  41. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/examples/ecommerce/runner.ipynb +0 -0
  42. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/examples/ecommerce/runner.py +0 -0
  43. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/examples/langgraph-agent/agent.ipynb +0 -0
  44. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/examples/langgraph-agent/tinybirds-jess.png +0 -0
  45. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/examples/podcast/podcast_runner.py +0 -0
  46. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/examples/podcast/podcast_transcript.txt +0 -0
  47. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/examples/podcast/transcript_parser.py +0 -0
  48. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/examples/quickstart/README.md +0 -0
  49. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/examples/quickstart/quickstart_falkordb.py +0 -0
  50. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/examples/quickstart/quickstart_neo4j.py +0 -0
  51. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/examples/quickstart/quickstart_neptune.py +0 -0
  52. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/examples/quickstart/requirements.txt +0 -0
  53. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/examples/wizard_of_oz/parser.py +0 -0
  54. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/examples/wizard_of_oz/runner.py +0 -0
  55. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/examples/wizard_of_oz/woo.txt +0 -0
  56. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/__init__.py +0 -0
  57. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/cross_encoder/__init__.py +0 -0
  58. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/cross_encoder/bge_reranker_client.py +0 -0
  59. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/cross_encoder/client.py +0 -0
  60. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/cross_encoder/gemini_reranker_client.py +0 -0
  61. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/cross_encoder/openai_reranker_client.py +0 -0
  62. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/driver/__init__.py +0 -0
  63. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/driver/driver.py +0 -0
  64. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/driver/falkordb_driver.py +0 -0
  65. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/driver/kuzu_driver.py +0 -0
  66. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/driver/neo4j_driver.py +0 -0
  67. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/driver/neptune_driver.py +0 -0
  68. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/edges.py +0 -0
  69. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/embedder/__init__.py +0 -0
  70. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/embedder/azure_openai.py +0 -0
  71. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/embedder/client.py +0 -0
  72. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/embedder/gemini.py +0 -0
  73. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/embedder/openai.py +0 -0
  74. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/embedder/voyage.py +0 -0
  75. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/errors.py +0 -0
  76. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/graph_queries.py +0 -0
  77. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/graphiti.py +0 -0
  78. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/graphiti_types.py +0 -0
  79. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/helpers.py +0 -0
  80. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/llm_client/__init__.py +0 -0
  81. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/llm_client/anthropic_client.py +0 -0
  82. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/llm_client/azure_openai_client.py +0 -0
  83. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/llm_client/client.py +0 -0
  84. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/llm_client/config.py +0 -0
  85. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/llm_client/errors.py +0 -0
  86. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/llm_client/gemini_client.py +0 -0
  87. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/llm_client/groq_client.py +0 -0
  88. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/llm_client/openai_base_client.py +0 -0
  89. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/llm_client/openai_client.py +0 -0
  90. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/llm_client/openai_generic_client.py +0 -0
  91. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/llm_client/utils.py +0 -0
  92. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/migrations/__init__.py +0 -0
  93. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/models/__init__.py +0 -0
  94. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/models/edges/__init__.py +0 -0
  95. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/models/edges/edge_db_queries.py +0 -0
  96. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/models/nodes/__init__.py +0 -0
  97. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/models/nodes/node_db_queries.py +0 -0
  98. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/nodes.py +0 -0
  99. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/prompts/__init__.py +0 -0
  100. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/prompts/dedupe_edges.py +0 -0
  101. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/prompts/dedupe_nodes.py +0 -0
  102. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/prompts/eval.py +0 -0
  103. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/prompts/extract_edge_dates.py +0 -0
  104. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/prompts/extract_edges.py +0 -0
  105. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/prompts/extract_nodes.py +0 -0
  106. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/prompts/invalidate_edges.py +0 -0
  107. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/prompts/lib.py +0 -0
  108. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/prompts/models.py +0 -0
  109. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/prompts/prompt_helpers.py +0 -0
  110. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/prompts/summarize_nodes.py +0 -0
  111. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/py.typed +0 -0
  112. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/search/__init__.py +0 -0
  113. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/search/search.py +0 -0
  114. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/search/search_config.py +0 -0
  115. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/search/search_config_recipes.py +0 -0
  116. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/search/search_filters.py +0 -0
  117. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/search/search_helpers.py +0 -0
  118. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/search/search_utils.py +0 -0
  119. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/telemetry/__init__.py +0 -0
  120. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/telemetry/telemetry.py +0 -0
  121. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/utils/__init__.py +0 -0
  122. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/utils/datetime_utils.py +0 -0
  123. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/utils/maintenance/__init__.py +0 -0
  124. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/utils/maintenance/community_operations.py +0 -0
  125. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/utils/maintenance/edge_operations.py +0 -0
  126. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/utils/maintenance/graph_data_operations.py +0 -0
  127. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/utils/maintenance/temporal_operations.py +0 -0
  128. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/utils/maintenance/utils.py +0 -0
  129. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/graphiti_core/utils/ontology_utils/entity_types_utils.py +0 -0
  130. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/images/arxiv-screenshot.png +0 -0
  131. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/images/graphiti-graph-intro.gif +0 -0
  132. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/images/graphiti-intro-slides-stock-2.gif +0 -0
  133. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/images/simple_graph.svg +0 -0
  134. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/mcp_server/.env.example +0 -0
  135. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/mcp_server/.python-version +0 -0
  136. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/mcp_server/Dockerfile +0 -0
  137. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/mcp_server/README.md +0 -0
  138. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/mcp_server/cursor_rules.md +0 -0
  139. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/mcp_server/docker-compose.yml +0 -0
  140. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/mcp_server/graphiti_mcp_server.py +0 -0
  141. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/mcp_server/mcp_config_sse_example.json +0 -0
  142. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/mcp_server/mcp_config_stdio_example.json +0 -0
  143. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/mcp_server/pyproject.toml +0 -0
  144. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/mcp_server/uv.lock +0 -0
  145. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/poetry.lock +0 -0
  146. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/py.typed +0 -0
  147. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/pytest.ini +0 -0
  148. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/server/.env.example +0 -0
  149. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/server/Makefile +0 -0
  150. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/server/README.md +0 -0
  151. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/server/graph_service/__init__.py +0 -0
  152. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/server/graph_service/config.py +0 -0
  153. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/server/graph_service/dto/__init__.py +0 -0
  154. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/server/graph_service/dto/common.py +0 -0
  155. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/server/graph_service/dto/ingest.py +0 -0
  156. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/server/graph_service/dto/retrieve.py +0 -0
  157. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/server/graph_service/main.py +0 -0
  158. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/server/graph_service/routers/__init__.py +0 -0
  159. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/server/graph_service/routers/ingest.py +0 -0
  160. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/server/graph_service/routers/retrieve.py +0 -0
  161. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/server/graph_service/zep_graphiti.py +0 -0
  162. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/server/pyproject.toml +0 -0
  163. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/server/uv.lock +0 -0
  164. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/signatures/version1/cla.json +0 -0
  165. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/cross_encoder/test_bge_reranker_client.py +0 -0
  166. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/cross_encoder/test_gemini_reranker_client.py +0 -0
  167. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/driver/__init__.py +0 -0
  168. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/driver/test_falkordb_driver.py +0 -0
  169. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/embedder/embedder_fixtures.py +0 -0
  170. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/embedder/test_gemini.py +0 -0
  171. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/embedder/test_openai.py +0 -0
  172. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/embedder/test_voyage.py +0 -0
  173. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/evals/data/longmemeval_data/README.md +0 -0
  174. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/evals/data/longmemeval_data/longmemeval_oracle.json +0 -0
  175. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/evals/eval_cli.py +0 -0
  176. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/evals/eval_e2e_graph_building.py +0 -0
  177. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/evals/pytest.ini +0 -0
  178. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/evals/utils.py +0 -0
  179. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/helpers_test.py +0 -0
  180. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/llm_client/test_anthropic_client.py +0 -0
  181. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/llm_client/test_anthropic_client_int.py +0 -0
  182. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/llm_client/test_client.py +0 -0
  183. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/llm_client/test_errors.py +0 -0
  184. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/llm_client/test_gemini_client.py +0 -0
  185. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/test_edge_int.py +0 -0
  186. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/test_entity_exclusion_int.py +0 -0
  187. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/test_graphiti_int.py +0 -0
  188. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/test_graphiti_mock.py +0 -0
  189. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/test_node_int.py +0 -0
  190. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/utils/maintenance/test_edge_operations.py +0 -0
  191. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/utils/maintenance/test_temporal_operations_int.py +0 -0
  192. {graphiti_core-0.30.0rc0 → graphiti_core-0.30.0rc2}/tests/utils/search/search_utils_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphiti-core
3
- Version: 0.30.0rc0
3
+ Version: 0.30.0rc2
4
4
  Summary: A temporal graph building library
5
5
  Project-URL: Homepage, https://help.getzep.com/graphiti/graphiti/overview
6
6
  Project-URL: Repository, https://github.com/getzep/graphiti
@@ -43,8 +43,14 @@ from graphiti_core.models.nodes.node_db_queries import (
43
43
  get_entity_node_save_bulk_query,
44
44
  get_episode_node_save_bulk_query,
45
45
  )
46
- from graphiti_core.nodes import EntityNode, EpisodeType, EpisodicNode, create_entity_node_embeddings
46
+ from graphiti_core.nodes import EntityNode, EpisodeType, EpisodicNode
47
47
  from graphiti_core.utils.datetime_utils import convert_datetimes_to_strings
48
+ from graphiti_core.utils.maintenance.dedup_helpers import (
49
+ DedupResolutionState,
50
+ _build_candidate_indexes,
51
+ _normalize_string_exact,
52
+ _resolve_with_similarity,
53
+ )
48
54
  from graphiti_core.utils.maintenance.edge_operations import (
49
55
  extract_edges,
50
56
  resolve_extracted_edge,
@@ -63,6 +69,38 @@ logger = logging.getLogger(__name__)
63
69
  CHUNK_SIZE = 10
64
70
 
65
71
 
72
+ def _build_directed_uuid_map(pairs: list[tuple[str, str]]) -> dict[str, str]:
73
+ """Collapse alias -> canonical chains while preserving direction.
74
+
75
+ The incoming pairs represent directed mappings discovered during node dedupe. We use a simple
76
+ union-find with iterative path compression to ensure every source UUID resolves to its ultimate
77
+ canonical target, even if aliases appear lexicographically smaller than the canonical UUID.
78
+ """
79
+
80
+ parent: dict[str, str] = {}
81
+
82
+ def find(uuid: str) -> str:
83
+ """Directed union-find lookup using iterative path compression."""
84
+ parent.setdefault(uuid, uuid)
85
+ root = uuid
86
+ while parent[root] != root:
87
+ root = parent[root]
88
+
89
+ while parent[uuid] != root:
90
+ next_uuid = parent[uuid]
91
+ parent[uuid] = root
92
+ uuid = next_uuid
93
+
94
+ return root
95
+
96
+ for source_uuid, target_uuid in pairs:
97
+ parent.setdefault(source_uuid, source_uuid)
98
+ parent.setdefault(target_uuid, target_uuid)
99
+ parent[find(source_uuid)] = find(target_uuid)
100
+
101
+ return {uuid: find(uuid) for uuid in parent}
102
+
103
+
66
104
  class RawEpisode(BaseModel):
67
105
  name: str
68
106
  uuid: str | None = Field(default=None)
@@ -266,83 +304,111 @@ async def dedupe_nodes_bulk(
266
304
  episode_tuples: list[tuple[EpisodicNode, list[EpisodicNode]]],
267
305
  entity_types: dict[str, type[BaseModel]] | None = None,
268
306
  ) -> tuple[dict[str, list[EntityNode]], dict[str, str]]:
269
- embedder = clients.embedder
270
- min_score = 0.8
271
-
272
- # generate embeddings
273
- await semaphore_gather(
274
- *[create_entity_node_embeddings(embedder, nodes) for nodes in extracted_nodes]
275
- )
276
-
277
- # Find similar results
278
- dedupe_tuples: list[tuple[list[EntityNode], list[EntityNode]]] = []
279
- for i, nodes_i in enumerate(extracted_nodes):
280
- existing_nodes: list[EntityNode] = []
281
- for j, nodes_j in enumerate(extracted_nodes):
282
- if i == j:
283
- continue
284
- existing_nodes += nodes_j
285
-
286
- candidates_i: list[EntityNode] = []
287
- for node in nodes_i:
288
- for existing_node in existing_nodes:
289
- # Approximate BM25 by checking for word overlaps (this is faster than creating many in-memory indices)
290
- # This approach will cast a wider net than BM25, which is ideal for this use case
291
- node_words = set(node.name.lower().split())
292
- existing_node_words = set(existing_node.name.lower().split())
293
- has_overlap = not node_words.isdisjoint(existing_node_words)
294
- if has_overlap:
295
- candidates_i.append(existing_node)
296
- continue
307
+ """Resolve entity duplicates across an in-memory batch using a two-pass strategy.
297
308
 
298
- # Check for semantic similarity even if there is no overlap
299
- similarity = np.dot(
300
- normalize_l2(node.name_embedding or []),
301
- normalize_l2(existing_node.name_embedding or []),
302
- )
303
- if similarity >= min_score:
304
- candidates_i.append(existing_node)
305
-
306
- dedupe_tuples.append((nodes_i, candidates_i))
309
+ 1. Run :func:`resolve_extracted_nodes` for every episode in parallel so each batch item is
310
+ reconciled against the live graph just like the non-batch flow.
311
+ 2. Re-run the deterministic similarity heuristics across the union of resolved nodes to catch
312
+ duplicates that only co-occur inside this batch, emitting a canonical UUID map that callers
313
+ can apply to edges and persistence.
314
+ """
307
315
 
308
- # Determine Node Resolutions
309
- bulk_node_resolutions: list[
310
- tuple[list[EntityNode], dict[str, str], list[tuple[EntityNode, EntityNode]]]
311
- ] = await semaphore_gather(
316
+ first_pass_results = await semaphore_gather(
312
317
  *[
313
318
  resolve_extracted_nodes(
314
319
  clients,
315
- dedupe_tuple[0],
320
+ nodes,
316
321
  episode_tuples[i][0],
317
322
  episode_tuples[i][1],
318
323
  entity_types,
319
- existing_nodes_override=dedupe_tuples[i][1],
320
324
  )
321
- for i, dedupe_tuple in enumerate(dedupe_tuples)
325
+ for i, nodes in enumerate(extracted_nodes)
322
326
  ]
323
327
  )
324
328
 
325
- # Collect all duplicate pairs sorted by uuid
329
+ episode_resolutions: list[tuple[str, list[EntityNode]]] = []
330
+ per_episode_uuid_maps: list[dict[str, str]] = []
326
331
  duplicate_pairs: list[tuple[str, str]] = []
327
- for _, _, duplicates in bulk_node_resolutions:
328
- for duplicate in duplicates:
329
- n, m = duplicate
330
- duplicate_pairs.append((n.uuid, m.uuid))
331
332
 
332
- # Now we compress the duplicate_map, so that 3 -> 2 and 2 -> becomes 3 -> 1 (sorted by uuid)
333
- compressed_map: dict[str, str] = compress_uuid_map(duplicate_pairs)
333
+ for (resolved_nodes, uuid_map, duplicates), (episode, _) in zip(
334
+ first_pass_results, episode_tuples, strict=True
335
+ ):
336
+ episode_resolutions.append((episode.uuid, resolved_nodes))
337
+ per_episode_uuid_maps.append(uuid_map)
338
+ duplicate_pairs.extend((source.uuid, target.uuid) for source, target in duplicates)
339
+
340
+ canonical_nodes: dict[str, EntityNode] = {}
341
+ for _, resolved_nodes in episode_resolutions:
342
+ for node in resolved_nodes:
343
+ # NOTE: this loop is O(n^2) in the number of nodes inside the batch because we rebuild
344
+ # the MinHash index for the accumulated canonical pool each time. The LRU-backed
345
+ # shingle cache keeps the constant factors low for typical batch sizes (≤ CHUNK_SIZE),
346
+ # but if batches grow significantly we should switch to an incremental index or chunked
347
+ # processing.
348
+ if not canonical_nodes:
349
+ canonical_nodes[node.uuid] = node
350
+ continue
334
351
 
335
- node_uuid_map: dict[str, EntityNode] = {
336
- node.uuid: node for nodes in extracted_nodes for node in nodes
337
- }
352
+ existing_candidates = list(canonical_nodes.values())
353
+ normalized = _normalize_string_exact(node.name)
354
+ exact_match = next(
355
+ (
356
+ candidate
357
+ for candidate in existing_candidates
358
+ if _normalize_string_exact(candidate.name) == normalized
359
+ ),
360
+ None,
361
+ )
362
+ if exact_match is not None:
363
+ if exact_match.uuid != node.uuid:
364
+ duplicate_pairs.append((node.uuid, exact_match.uuid))
365
+ continue
366
+
367
+ indexes = _build_candidate_indexes(existing_candidates)
368
+ state = DedupResolutionState(
369
+ resolved_nodes=[None],
370
+ uuid_map={},
371
+ unresolved_indices=[],
372
+ )
373
+ _resolve_with_similarity([node], indexes, state)
374
+
375
+ resolved = state.resolved_nodes[0]
376
+ if resolved is None:
377
+ canonical_nodes[node.uuid] = node
378
+ continue
379
+
380
+ canonical_uuid = resolved.uuid
381
+ canonical_nodes.setdefault(canonical_uuid, resolved)
382
+ if canonical_uuid != node.uuid:
383
+ duplicate_pairs.append((node.uuid, canonical_uuid))
384
+
385
+ union_pairs: list[tuple[str, str]] = []
386
+ for uuid_map in per_episode_uuid_maps:
387
+ union_pairs.extend(uuid_map.items())
388
+ union_pairs.extend(duplicate_pairs)
389
+
390
+ compressed_map: dict[str, str] = _build_directed_uuid_map(union_pairs)
338
391
 
339
392
  nodes_by_episode: dict[str, list[EntityNode]] = {}
340
- for i, nodes in enumerate(extracted_nodes):
341
- episode = episode_tuples[i][0]
393
+ for episode_uuid, resolved_nodes in episode_resolutions:
394
+ deduped_nodes: list[EntityNode] = []
395
+ seen: set[str] = set()
396
+ for node in resolved_nodes:
397
+ canonical_uuid = compressed_map.get(node.uuid, node.uuid)
398
+ if canonical_uuid in seen:
399
+ continue
400
+ seen.add(canonical_uuid)
401
+ canonical_node = canonical_nodes.get(canonical_uuid)
402
+ if canonical_node is None:
403
+ logger.error(
404
+ 'Canonical node %s missing during batch dedupe; falling back to %s',
405
+ canonical_uuid,
406
+ node.uuid,
407
+ )
408
+ canonical_node = node
409
+ deduped_nodes.append(canonical_node)
342
410
 
343
- nodes_by_episode[episode.uuid] = [
344
- node_uuid_map[compressed_map.get(node.uuid, node.uuid)] for node in nodes
345
- ]
411
+ nodes_by_episode[episode_uuid] = deduped_nodes
346
412
 
347
413
  return nodes_by_episode, compressed_map
348
414
 
@@ -20,7 +20,7 @@ import math
20
20
  import re
21
21
  from collections import defaultdict
22
22
  from collections.abc import Iterable
23
- from dataclasses import dataclass
23
+ from dataclasses import dataclass, field
24
24
  from functools import lru_cache
25
25
  from hashlib import blake2b
26
26
  from typing import TYPE_CHECKING
@@ -164,6 +164,7 @@ class DedupResolutionState:
164
164
  resolved_nodes: list[EntityNode | None]
165
165
  uuid_map: dict[str, str]
166
166
  unresolved_indices: list[int]
167
+ duplicate_pairs: list[tuple[EntityNode, EntityNode]] = field(default_factory=list)
167
168
 
168
169
 
169
170
  def _build_candidate_indexes(existing_nodes: list[EntityNode]) -> DedupCandidateIndexes:
@@ -213,6 +214,8 @@ def _resolve_with_similarity(
213
214
  match = existing_matches[0]
214
215
  state.resolved_nodes[idx] = match
215
216
  state.uuid_map[node.uuid] = match.uuid
217
+ if match.uuid != node.uuid:
218
+ state.duplicate_pairs.append((node, match))
216
219
  continue
217
220
  if len(existing_matches) > 1:
218
221
  state.unresolved_indices.append(idx)
@@ -236,6 +239,8 @@ def _resolve_with_similarity(
236
239
  if best_candidate is not None and best_score >= _FUZZY_JACCARD_THRESHOLD:
237
240
  state.resolved_nodes[idx] = best_candidate
238
241
  state.uuid_map[node.uuid] = best_candidate.uuid
242
+ if best_candidate.uuid != node.uuid:
243
+ state.duplicate_pairs.append((node, best_candidate))
239
244
  continue
240
245
 
241
246
  state.unresolved_indices.append(idx)
@@ -241,7 +241,11 @@ async def _resolve_with_llm(
241
241
  previous_episodes: list[EpisodicNode] | None,
242
242
  entity_types: dict[str, type[BaseModel]] | None,
243
243
  ) -> None:
244
- """Escalate unresolved nodes to the dedupe prompt so the LLM can select or reject duplicates."""
244
+ """Escalate unresolved nodes to the dedupe prompt so the LLM can select or reject duplicates.
245
+
246
+ The guardrails below defensively ignore malformed or duplicate LLM responses so the
247
+ ingestion workflow remains deterministic even when the model misbehaves.
248
+ """
245
249
  if not state.unresolved_indices:
246
250
  return
247
251
 
@@ -291,21 +295,46 @@ async def _resolve_with_llm(
291
295
 
292
296
  node_resolutions: list[NodeDuplicate] = NodeResolutions(**llm_response).entity_resolutions
293
297
 
298
+ valid_relative_range = range(len(state.unresolved_indices))
299
+ processed_relative_ids: set[int] = set()
300
+
294
301
  for resolution in node_resolutions:
295
302
  relative_id: int = resolution.id
296
303
  duplicate_idx: int = resolution.duplicate_idx
297
304
 
305
+ if relative_id not in valid_relative_range:
306
+ logger.warning(
307
+ 'Skipping invalid LLM dedupe id %s (unresolved indices: %s)',
308
+ relative_id,
309
+ state.unresolved_indices,
310
+ )
311
+ continue
312
+
313
+ if relative_id in processed_relative_ids:
314
+ logger.warning('Duplicate LLM dedupe id %s received; ignoring.', relative_id)
315
+ continue
316
+ processed_relative_ids.add(relative_id)
317
+
298
318
  original_index = state.unresolved_indices[relative_id]
299
319
  extracted_node = extracted_nodes[original_index]
300
320
 
301
- resolved_node = (
302
- indexes.existing_nodes[duplicate_idx]
303
- if 0 <= duplicate_idx < len(indexes.existing_nodes)
304
- else extracted_node
305
- )
321
+ resolved_node: EntityNode
322
+ if duplicate_idx == -1:
323
+ resolved_node = extracted_node
324
+ elif 0 <= duplicate_idx < len(indexes.existing_nodes):
325
+ resolved_node = indexes.existing_nodes[duplicate_idx]
326
+ else:
327
+ logger.warning(
328
+ 'Invalid duplicate_idx %s for extracted node %s; treating as no duplicate.',
329
+ duplicate_idx,
330
+ extracted_node.uuid,
331
+ )
332
+ resolved_node = extracted_node
306
333
 
307
334
  state.resolved_nodes[original_index] = resolved_node
308
335
  state.uuid_map[extracted_node.uuid] = resolved_node.uuid
336
+ if resolved_node.uuid != extracted_node.uuid:
337
+ state.duplicate_pairs.append((extracted_node, resolved_node))
309
338
 
310
339
 
311
340
  async def resolve_extracted_nodes(
@@ -332,7 +361,6 @@ async def resolve_extracted_nodes(
332
361
  uuid_map={},
333
362
  unresolved_indices=[],
334
363
  )
335
- node_duplicates: list[tuple[EntityNode, EntityNode]] = []
336
364
 
337
365
  _resolve_with_similarity(extracted_nodes, indexes, state)
338
366
 
@@ -359,7 +387,7 @@ async def resolve_extracted_nodes(
359
387
 
360
388
  new_node_duplicates: list[
361
389
  tuple[EntityNode, EntityNode]
362
- ] = await filter_existing_duplicate_of_edges(driver, node_duplicates)
390
+ ] = await filter_existing_duplicate_of_edges(driver, state.duplicate_pairs)
363
391
 
364
392
  return (
365
393
  [node for node in state.resolved_nodes if node is not None],
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "graphiti-core"
3
3
  description = "A temporal graph building library"
4
- version = "0.30.0pre0"
4
+ version = "0.30.0pre2"
5
5
  authors = [
6
6
  { name = "Paul Paliychuk", email = "paul@getzep.com" },
7
7
  { name = "Preston Rasmussen", email = "preston@getzep.com" },
@@ -0,0 +1,232 @@
1
+ from collections import deque
2
+ from unittest.mock import AsyncMock, MagicMock
3
+
4
+ import pytest
5
+
6
+ from graphiti_core.edges import EntityEdge
7
+ from graphiti_core.graphiti_types import GraphitiClients
8
+ from graphiti_core.nodes import EntityNode, EpisodeType, EpisodicNode
9
+ from graphiti_core.utils import bulk_utils
10
+ from graphiti_core.utils.datetime_utils import utc_now
11
+
12
+
13
+ def _make_episode(uuid_suffix: str, group_id: str = 'group') -> EpisodicNode:
14
+ return EpisodicNode(
15
+ name=f'episode-{uuid_suffix}',
16
+ group_id=group_id,
17
+ labels=[],
18
+ source=EpisodeType.message,
19
+ content='content',
20
+ source_description='test',
21
+ created_at=utc_now(),
22
+ valid_at=utc_now(),
23
+ )
24
+
25
+
26
+ def _make_clients() -> GraphitiClients:
27
+ driver = MagicMock()
28
+ embedder = MagicMock()
29
+ cross_encoder = MagicMock()
30
+ llm_client = MagicMock()
31
+
32
+ return GraphitiClients.model_construct( # bypass validation to allow test doubles
33
+ driver=driver,
34
+ embedder=embedder,
35
+ cross_encoder=cross_encoder,
36
+ llm_client=llm_client,
37
+ ensure_ascii=False,
38
+ )
39
+
40
+
41
+ @pytest.mark.asyncio
42
+ async def test_dedupe_nodes_bulk_reuses_canonical_nodes(monkeypatch):
43
+ clients = _make_clients()
44
+
45
+ episode_one = _make_episode('1')
46
+ episode_two = _make_episode('2')
47
+
48
+ extracted_one = EntityNode(name='Alice Smith', group_id='group', labels=['Entity'])
49
+ extracted_two = EntityNode(name='Alice Smith', group_id='group', labels=['Entity'])
50
+
51
+ canonical = extracted_one
52
+
53
+ call_queue = deque()
54
+
55
+ async def fake_resolve(
56
+ clients_arg,
57
+ nodes_arg,
58
+ episode_arg,
59
+ previous_episodes_arg,
60
+ entity_types_arg,
61
+ existing_nodes_override=None,
62
+ ):
63
+ call_queue.append(existing_nodes_override)
64
+
65
+ if nodes_arg == [extracted_one]:
66
+ return [canonical], {canonical.uuid: canonical.uuid}, []
67
+
68
+ assert nodes_arg == [extracted_two]
69
+ assert existing_nodes_override is None
70
+
71
+ return [canonical], {extracted_two.uuid: canonical.uuid}, [(extracted_two, canonical)]
72
+
73
+ monkeypatch.setattr(bulk_utils, 'resolve_extracted_nodes', fake_resolve)
74
+
75
+ nodes_by_episode, compressed_map = await bulk_utils.dedupe_nodes_bulk(
76
+ clients,
77
+ [[extracted_one], [extracted_two]],
78
+ [(episode_one, []), (episode_two, [])],
79
+ )
80
+
81
+ assert len(call_queue) == 2
82
+ assert call_queue[0] is None
83
+ assert call_queue[1] is None
84
+
85
+ assert nodes_by_episode[episode_one.uuid] == [canonical]
86
+ assert nodes_by_episode[episode_two.uuid] == [canonical]
87
+ assert compressed_map.get(extracted_two.uuid) == canonical.uuid
88
+
89
+
90
+ @pytest.mark.asyncio
91
+ async def test_dedupe_nodes_bulk_handles_empty_batch(monkeypatch):
92
+ clients = _make_clients()
93
+
94
+ resolve_mock = AsyncMock()
95
+ monkeypatch.setattr(bulk_utils, 'resolve_extracted_nodes', resolve_mock)
96
+
97
+ nodes_by_episode, compressed_map = await bulk_utils.dedupe_nodes_bulk(
98
+ clients,
99
+ [],
100
+ [],
101
+ )
102
+
103
+ assert nodes_by_episode == {}
104
+ assert compressed_map == {}
105
+ resolve_mock.assert_not_awaited()
106
+
107
+
108
+ @pytest.mark.asyncio
109
+ async def test_dedupe_nodes_bulk_single_episode(monkeypatch):
110
+ clients = _make_clients()
111
+
112
+ episode = _make_episode('solo')
113
+ extracted = EntityNode(name='Solo', group_id='group', labels=['Entity'])
114
+
115
+ resolve_mock = AsyncMock(return_value=([extracted], {extracted.uuid: extracted.uuid}, []))
116
+ monkeypatch.setattr(bulk_utils, 'resolve_extracted_nodes', resolve_mock)
117
+
118
+ nodes_by_episode, compressed_map = await bulk_utils.dedupe_nodes_bulk(
119
+ clients,
120
+ [[extracted]],
121
+ [(episode, [])],
122
+ )
123
+
124
+ assert nodes_by_episode == {episode.uuid: [extracted]}
125
+ assert compressed_map == {extracted.uuid: extracted.uuid}
126
+ resolve_mock.assert_awaited_once()
127
+
128
+
129
+ @pytest.mark.asyncio
130
+ async def test_dedupe_nodes_bulk_uuid_map_respects_direction(monkeypatch):
131
+ clients = _make_clients()
132
+
133
+ episode_one = _make_episode('one')
134
+ episode_two = _make_episode('two')
135
+
136
+ extracted_one = EntityNode(uuid='b-uuid', name='Edge Case', group_id='group', labels=['Entity'])
137
+ extracted_two = EntityNode(uuid='a-uuid', name='Edge Case', group_id='group', labels=['Entity'])
138
+
139
+ canonical = extracted_one
140
+ alias = extracted_two
141
+
142
+ async def fake_resolve(
143
+ clients_arg,
144
+ nodes_arg,
145
+ episode_arg,
146
+ previous_episodes_arg,
147
+ entity_types_arg,
148
+ existing_nodes_override=None,
149
+ ):
150
+ if nodes_arg == [extracted_one]:
151
+ return [canonical], {canonical.uuid: canonical.uuid}, []
152
+ assert nodes_arg == [extracted_two]
153
+ return [canonical], {alias.uuid: canonical.uuid}, [(alias, canonical)]
154
+
155
+ monkeypatch.setattr(bulk_utils, 'resolve_extracted_nodes', fake_resolve)
156
+
157
+ nodes_by_episode, compressed_map = await bulk_utils.dedupe_nodes_bulk(
158
+ clients,
159
+ [[extracted_one], [extracted_two]],
160
+ [(episode_one, []), (episode_two, [])],
161
+ )
162
+
163
+ assert nodes_by_episode[episode_one.uuid] == [canonical]
164
+ assert nodes_by_episode[episode_two.uuid] == [canonical]
165
+ assert compressed_map.get(alias.uuid) == canonical.uuid
166
+
167
+
168
+ @pytest.mark.asyncio
169
+ async def test_dedupe_nodes_bulk_missing_canonical_falls_back(monkeypatch, caplog):
170
+ clients = _make_clients()
171
+
172
+ episode = _make_episode('missing')
173
+ extracted = EntityNode(name='Fallback', group_id='group', labels=['Entity'])
174
+
175
+ resolve_mock = AsyncMock(return_value=([extracted], {extracted.uuid: 'missing-canonical'}, []))
176
+ monkeypatch.setattr(bulk_utils, 'resolve_extracted_nodes', resolve_mock)
177
+
178
+ with caplog.at_level('WARNING'):
179
+ nodes_by_episode, compressed_map = await bulk_utils.dedupe_nodes_bulk(
180
+ clients,
181
+ [[extracted]],
182
+ [(episode, [])],
183
+ )
184
+
185
+ assert nodes_by_episode[episode.uuid] == [extracted]
186
+ assert compressed_map.get(extracted.uuid) == 'missing-canonical'
187
+ assert any('Canonical node missing' in rec.message for rec in caplog.records)
188
+
189
+
190
+ def test_build_directed_uuid_map_empty():
191
+ assert bulk_utils._build_directed_uuid_map([]) == {}
192
+
193
+
194
+ def test_build_directed_uuid_map_chain():
195
+ mapping = bulk_utils._build_directed_uuid_map(
196
+ [
197
+ ('a', 'b'),
198
+ ('b', 'c'),
199
+ ]
200
+ )
201
+
202
+ assert mapping['a'] == 'c'
203
+ assert mapping['b'] == 'c'
204
+ assert mapping['c'] == 'c'
205
+
206
+
207
+ def test_build_directed_uuid_map_preserves_direction():
208
+ mapping = bulk_utils._build_directed_uuid_map(
209
+ [
210
+ ('alias', 'canonical'),
211
+ ]
212
+ )
213
+
214
+ assert mapping['alias'] == 'canonical'
215
+ assert mapping['canonical'] == 'canonical'
216
+
217
+
218
+ def test_resolve_edge_pointers_updates_sources():
219
+ created_at = utc_now()
220
+ edge = EntityEdge(
221
+ name='knows',
222
+ fact='fact',
223
+ group_id='group',
224
+ source_node_uuid='alias',
225
+ target_node_uuid='target',
226
+ created_at=created_at,
227
+ )
228
+
229
+ bulk_utils.resolve_edge_pointers([edge], {'alias': 'canonical'})
230
+
231
+ assert edge.source_node_uuid == 'canonical'
232
+ assert edge.target_node_uuid == 'target'