c889a57b6b
Test Suites / Build CI Environment (push) Has been cancelled
Test Suites / Basic Tests (push) Has been cancelled
Test Suites / End-to-End Tests (push) Has been cancelled
Test Suites / CLI Tests (push) Has been cancelled
Test Suites / Slow End-to-End Tests (push) Has been cancelled
Test Suites / Graph Database Tests (push) Has been cancelled
Test Suites / Vector DB Tests (push) Has been cancelled
Test Suites / Temporal Graph Test (push) Has been cancelled
Test Suites / Search Test on Different DBs (push) Has been cancelled
Test Suites / Example Tests (push) Has been cancelled
Test Suites / Notebook Tests (push) Has been cancelled
Test Suites / OS and Python Tests Ubuntu (push) Has been cancelled
Test Suites / OS and Python Tests Extended (push) Has been cancelled
Test Suites / LLM Test Suite (push) Has been cancelled
Test Suites / S3 File Storage Test (push) Has been cancelled
Test Suites / Run Integration Tests (push) Has been cancelled
Test Suites / MCP Tests (push) Has been cancelled
Test Suites / Docker Compose Test (push) Has been cancelled
Test Suites / Docker CI test (push) Has been cancelled
Test Suites / Relational DB Migration Tests (push) Has been cancelled
Test Suites / Distributed Cognee Test (push) Has been cancelled
Test Suites / DB Examples Tests (push) Has been cancelled
Test Suites / Test Completion Status (push) Has been cancelled
Test Suites / Claude Code Review (push) Has been cancelled
Test Suites / basic checks (push) Has been cancelled
build | Build and Push Cognee MCP Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
build | Build and Push Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.11) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.12) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (kuzu, kuzu) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (neo4j, neo4j) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Examples (push) Has been cancelled
Weighted Edges Tests / Code Quality for Weighted Edges (push) Has been cancelled
650 lines
26 KiB
Python
650 lines
26 KiB
Python
"""Translate COGX records into Cognee ingestion inputs.
|
|
|
|
Two targets, selected by the source's import mode:
|
|
|
|
- **data items** (``re-derive``): textual records become :class:`DataItem`s
|
|
with deterministic ``data_id``s, fed through the normal ``add + cognify``
|
|
path so Cognee's own extraction builds the graph.
|
|
- **graph batches** (``preserve``/``hybrid``): entity and fact records become
|
|
native :class:`Entity` DataPoints plus custom edge tuples, stored directly
|
|
via the ``add_data_points`` task with zero LLM calls. Temporal validity from
|
|
the source (``valid_at``/``invalid_at``) is preserved as edge properties.
|
|
"""
|
|
|
|
import dataclasses
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from types import SimpleNamespace
|
|
from typing import Any, AsyncIterable, Dict, Iterable, List, Optional, Set, Tuple
|
|
from uuid import NAMESPACE_OID, UUID, uuid5
|
|
|
|
from cognee.modules.engine.models import Entity, EntityType
|
|
from cognee.modules.migration.cogx import (
|
|
COGXEntity,
|
|
COGXEpisode,
|
|
COGXFact,
|
|
COGXRawNode,
|
|
COGXRecord,
|
|
)
|
|
from cognee.modules.migration.snapshot import rehydrate_node
|
|
from cognee.shared.logging_utils import get_logger
|
|
from cognee.tasks.ingestion.data_item import DataItem
|
|
|
|
logger = get_logger("migration.loader")
|
|
|
|
FACTS_PER_DIGEST = 200
|
|
# Target node count per graph batch: keeps each add_data_points call (gather,
|
|
# dedup, deep copies, relational transaction) bounded on bulk imports.
|
|
BATCH_NODE_TARGET = 2000
|
|
|
|
|
|
@dataclass
|
|
class TranslationResult:
|
|
data_items: List[DataItem] = field(default_factory=list)
|
|
graph_batches: List[Dict[str, Any]] = field(default_factory=list)
|
|
counts: Dict[str, int] = field(default_factory=dict)
|
|
# Facts dropped because a subject/object UUID reference could not be
|
|
# resolved to any exported node (never fabricated as UUID-named entities).
|
|
skipped_facts: int = 0
|
|
# False in preserve mode: data items are stored raw, without cognify.
|
|
cognify_data_items: bool = True
|
|
|
|
|
|
def record_data_id(record: COGXRecord) -> UUID:
|
|
"""Deterministic data id so re-importing the same record is idempotent."""
|
|
return uuid5(NAMESPACE_OID, f"cogx:{record.external_system}:{record.external_id}")
|
|
|
|
|
|
def _iso(value: Optional[datetime]) -> Optional[str]:
|
|
return value.isoformat() if value else None
|
|
|
|
|
|
def _record_external_metadata(record: COGXRecord) -> Dict[str, Any]:
|
|
metadata: Dict[str, Any] = {
|
|
"external_system": record.external_system,
|
|
"external_id": record.external_id,
|
|
}
|
|
scope = record.scope.model_dump(exclude_none=True)
|
|
if scope:
|
|
metadata["scope"] = scope
|
|
if record.created_at:
|
|
metadata["external_created_at"] = _iso(record.created_at)
|
|
if record.updated_at:
|
|
metadata["external_updated_at"] = _iso(record.updated_at)
|
|
if record.metadata:
|
|
metadata.update(record.metadata)
|
|
return metadata
|
|
|
|
|
|
def render_episode(episode: COGXEpisode) -> str:
|
|
"""Render an episode as a timestamped transcript."""
|
|
lines = []
|
|
if episode.title:
|
|
lines.append(f"# {episode.title}")
|
|
turns = sorted(
|
|
episode.turns,
|
|
key=lambda turn: turn.occurred_at.timestamp() if turn.occurred_at else float("-inf"),
|
|
)
|
|
for turn in turns:
|
|
timestamp = f" [{_iso(turn.occurred_at)}]" if turn.occurred_at else ""
|
|
lines.append(f"{turn.role}{timestamp}: {turn.content}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _render_fact_line(fact: COGXFact) -> str:
|
|
line = fact.fact_text or f"{fact.subject_ref} {fact.predicate} {fact.object_ref}"
|
|
qualifiers = []
|
|
if fact.valid_at:
|
|
qualifiers.append(f"valid from {_iso(fact.valid_at)}")
|
|
if fact.invalid_at:
|
|
qualifiers.append(f"invalid since {_iso(fact.invalid_at)}")
|
|
if qualifiers:
|
|
line = f"{line} ({', '.join(qualifiers)})"
|
|
return line
|
|
|
|
|
|
def _data_item_for(record: COGXRecord, content: str, label: Optional[str] = None) -> DataItem:
|
|
return DataItem(
|
|
data=content,
|
|
label=label,
|
|
external_metadata=_record_external_metadata(record),
|
|
data_id=record_data_id(record),
|
|
)
|
|
|
|
|
|
def _looks_like_uuid(value: str) -> bool:
|
|
try:
|
|
UUID(str(value))
|
|
return True
|
|
except (ValueError, AttributeError, TypeError):
|
|
return False
|
|
|
|
|
|
def data_item_from_record(record: COGXRecord) -> Optional[DataItem]:
|
|
"""Translate a content-bearing record into a DataItem; None for graph records."""
|
|
if record.kind == "document":
|
|
return _data_item_for(record, record.content, record.title)
|
|
if record.kind == "episode":
|
|
return _data_item_for(record, render_episode(record), record.title)
|
|
if record.kind == "memory":
|
|
content = record.content
|
|
if record.categories:
|
|
content = f"{content}\nCategories: {', '.join(record.categories)}"
|
|
return _data_item_for(record, content)
|
|
if record.kind == "memory_block":
|
|
return _data_item_for(record, f"{record.label}:\n{record.value}", record.label)
|
|
return None
|
|
|
|
|
|
def _fact_edge_properties(fact: COGXFact) -> Dict[str, Any]:
|
|
properties: Dict[str, Any] = {
|
|
"relationship_name": fact.predicate,
|
|
"source_system": fact.external_system,
|
|
"source_external_id": fact.external_id,
|
|
}
|
|
if fact.fact_text:
|
|
properties["edge_text"] = fact.fact_text
|
|
if fact.valid_at:
|
|
properties["valid_at"] = _iso(fact.valid_at)
|
|
if fact.invalid_at:
|
|
properties["invalid_at"] = _iso(fact.invalid_at)
|
|
if fact.confidence is not None:
|
|
properties["confidence"] = fact.confidence
|
|
return properties
|
|
|
|
|
|
def _register_entity(
|
|
record: COGXEntity,
|
|
*,
|
|
entity_types: Dict[str, EntityType],
|
|
by_node_id: Dict[UUID, Any],
|
|
by_external_id: Dict[str, Any],
|
|
first_external_id: Dict[UUID, str],
|
|
preserve_source_ids: bool = False,
|
|
) -> Any:
|
|
"""Register an entity record.
|
|
|
|
``preserve_source_ids`` (cognee-origin archives): the record's external_id
|
|
IS the source node's UUID — keep it, so the imported graph is an exact
|
|
copy (same-named-but-distinct source entities stay distinct, and edge keys
|
|
survive verbatim). Source UUIDs are themselves deterministic
|
|
``Entity.id_for(<llm identity>)`` values, so re-cognifying the same
|
|
content on the target still converges on the same nodes.
|
|
|
|
Otherwise (cross-provider archives): derive class-namespaced ids exactly
|
|
as cognee's own extraction does (``Entity.id_for`` via identity_fields),
|
|
merging same-named records into one node.
|
|
"""
|
|
|
|
def _entity_type_for(name: str) -> EntityType:
|
|
key = name.lower()
|
|
if key not in entity_types:
|
|
entity_types[key] = EntityType(id=EntityType.id_for(name), name=name, description=name)
|
|
return entity_types[key]
|
|
|
|
if preserve_source_ids and _looks_like_uuid(record.external_id):
|
|
node_id = UUID(record.external_id)
|
|
else:
|
|
node_id = Entity.id_for(record.name)
|
|
description = record.description or record.name
|
|
if record.aliases:
|
|
description = f"{description} Also known as: {', '.join(record.aliases)}."
|
|
existing = by_node_id.get(node_id)
|
|
if existing is not None:
|
|
# Same-named source records merge into one node: combine their
|
|
# descriptions/aliases instead of keeping only the first.
|
|
if description and description not in (getattr(existing, "description", "") or ""):
|
|
merged = getattr(existing, "description", None)
|
|
existing.description = f"{merged}\n{description}" if merged else description
|
|
if getattr(existing, "is_a", None) is None and record.entity_type:
|
|
existing.is_a = _entity_type_for(record.entity_type)
|
|
logger.info(
|
|
"Merged same-named entity %r: external_ids %r and %r",
|
|
record.name,
|
|
first_external_id.get(node_id),
|
|
record.external_id,
|
|
)
|
|
by_external_id[record.external_id] = existing
|
|
return existing
|
|
entity = Entity(
|
|
id=node_id,
|
|
name=record.name,
|
|
description=description,
|
|
is_a=_entity_type_for(record.entity_type) if record.entity_type else None,
|
|
)
|
|
by_node_id[node_id] = entity
|
|
first_external_id[node_id] = record.external_id
|
|
by_external_id[record.external_id] = entity
|
|
return entity
|
|
|
|
|
|
def _build_graph_batches(
|
|
entities: List[COGXEntity],
|
|
facts: List[COGXFact],
|
|
raw_nodes: List[COGXRawNode],
|
|
preserve_source_ids: bool = False,
|
|
) -> Tuple[List[Dict[str, Any]], int]:
|
|
"""Map entity/fact/raw-node records onto bounded graph batches.
|
|
|
|
Entity ids come from ``Entity.id_for(name)`` — the class-namespaced scheme
|
|
cognify uses — so preserved facts merge into the existing graph vocabulary
|
|
instead of forming a disconnected parallel graph. Raw nodes are rehydrated
|
|
back into DataPoint instances (keeping their original ids) so facts
|
|
referencing them stay resolvable. Facts whose subject/object UUID reference
|
|
cannot be resolved are skipped — never fabricated as UUID-named entities.
|
|
|
|
Nodes are split into batches of ~``BATCH_NODE_TARGET``; each fact lands in
|
|
a batch containing one of its endpoints, with the other endpoint included
|
|
as a (deterministic-id, hence idempotent) duplicate where batches split.
|
|
|
|
Returns the batches plus the count of skipped facts.
|
|
"""
|
|
if not entities and not facts and not raw_nodes:
|
|
return [], 0
|
|
|
|
entity_types: Dict[str, EntityType] = {}
|
|
by_external_id: Dict[str, Any] = {}
|
|
by_node_id: Dict[UUID, Any] = {}
|
|
first_external_id: Dict[UUID, str] = {}
|
|
|
|
for record in raw_nodes:
|
|
properties = record.properties or {}
|
|
node = rehydrate_node(properties)
|
|
node = by_node_id.setdefault(node.id, node)
|
|
external_id = properties.get("id")
|
|
if external_id:
|
|
by_external_id[str(external_id)] = node
|
|
|
|
for record in entities:
|
|
_register_entity(
|
|
record,
|
|
entity_types=entity_types,
|
|
by_node_id=by_node_id,
|
|
by_external_id=by_external_id,
|
|
first_external_id=first_external_id,
|
|
preserve_source_ids=preserve_source_ids,
|
|
)
|
|
|
|
batches: List[Dict[str, Any]] = []
|
|
batch_index_of: Dict[UUID, int] = {}
|
|
ordered_nodes = list(entity_types.values()) + list(by_node_id.values())
|
|
for start in range(0, len(ordered_nodes), BATCH_NODE_TARGET):
|
|
chunk = ordered_nodes[start : start + BATCH_NODE_TARGET]
|
|
batches.append({"nodes": list(chunk), "edges": []})
|
|
for node in chunk:
|
|
batch_index_of[node.id] = len(batches) - 1
|
|
|
|
def _resolve_ref(ref: str) -> Optional[Any]:
|
|
node = by_external_id.get(ref)
|
|
if node is not None:
|
|
return node
|
|
node = by_node_id.get(Entity.id_for(ref))
|
|
if node is not None:
|
|
return node
|
|
if _looks_like_uuid(ref):
|
|
# A UUID pointing at a node the archive does not contain: skip the
|
|
# fact rather than fabricate an Entity literally named by a UUID.
|
|
return None
|
|
# Plain-name reference (cross-provider archives): treat it as an
|
|
# entity name and create the entity.
|
|
entity = Entity(id=Entity.id_for(ref), name=ref, description=ref)
|
|
by_node_id[entity.id] = entity
|
|
return entity
|
|
|
|
duplicated_in_batch: Dict[int, Set[UUID]] = {}
|
|
skipped_facts = 0
|
|
# Same resolved-key dedup as the streaming path (first fact wins): distinct
|
|
# refs can resolve to one edge key, and re-MERGEing duplicates crashes
|
|
# Ladybug's rel-update row lookup during fresh bulk imports.
|
|
seen_edge_keys: Set[Tuple[UUID, UUID, str]] = set()
|
|
deduped_edges = 0
|
|
|
|
for fact in facts:
|
|
subject = _resolve_ref(fact.subject_ref)
|
|
target = _resolve_ref(fact.object_ref)
|
|
if subject is None or target is None:
|
|
skipped_facts += 1
|
|
unresolved = [
|
|
ref
|
|
for ref, node in ((fact.subject_ref, subject), (fact.object_ref, target))
|
|
if node is None
|
|
]
|
|
logger.warning(
|
|
"Skipping fact %r (%s): unresolved UUID reference(s) %s",
|
|
fact.external_id,
|
|
fact.predicate,
|
|
", ".join(unresolved),
|
|
)
|
|
continue
|
|
|
|
edge_key = (subject.id, target.id, fact.predicate)
|
|
if edge_key in seen_edge_keys:
|
|
deduped_edges += 1
|
|
continue
|
|
seen_edge_keys.add(edge_key)
|
|
|
|
index = batch_index_of.get(subject.id)
|
|
if index is None:
|
|
index = batch_index_of.get(target.id)
|
|
if index is None:
|
|
if not batches:
|
|
batches.append({"nodes": [], "edges": []})
|
|
index = len(batches) - 1
|
|
for node in (subject, target):
|
|
if node.id not in batch_index_of:
|
|
# Newly created name-stub entity: place it with this fact.
|
|
batches[index]["nodes"].append(node)
|
|
batch_index_of[node.id] = index
|
|
elif batch_index_of[node.id] != index:
|
|
# Endpoint lives in another batch: include a duplicate so the
|
|
# edge's batch is self-contained (deterministic ids merge).
|
|
duplicated = duplicated_in_batch.setdefault(index, set())
|
|
if node.id not in duplicated:
|
|
batches[index]["nodes"].append(node)
|
|
duplicated.add(node.id)
|
|
|
|
batches[index]["edges"].append(
|
|
(subject.id, target.id, fact.predicate, _fact_edge_properties(fact))
|
|
)
|
|
|
|
if deduped_edges:
|
|
logger.info("Deduplicated %d facts that resolved to already-seen edge keys.", deduped_edges)
|
|
batches = [batch for batch in batches if batch["nodes"] or batch["edges"]]
|
|
return batches, skipped_facts
|
|
|
|
|
|
class _RecordTranslator:
|
|
"""Incremental record translator shared by the sync and async entry points."""
|
|
|
|
def __init__(self, mode: str, preserve_source_ids: bool = False):
|
|
self.mode = mode
|
|
self.preserve_source_ids = preserve_source_ids
|
|
self.result = TranslationResult(cognify_data_items=mode != "preserve")
|
|
self.entities: List[COGXEntity] = []
|
|
self.facts: List[COGXFact] = []
|
|
self.raw_nodes: List[COGXRawNode] = []
|
|
|
|
def add(self, record: COGXRecord) -> None:
|
|
result = self.result
|
|
result.counts[record.kind] = result.counts.get(record.kind, 0) + 1
|
|
|
|
data_item = data_item_from_record(record)
|
|
if data_item is not None:
|
|
result.data_items.append(data_item)
|
|
elif record.kind == "entity":
|
|
self.entities.append(record)
|
|
elif record.kind == "fact":
|
|
self.facts.append(record)
|
|
elif record.kind == "raw_node":
|
|
# Graph-fidelity payload: only meaningful for preserve/hybrid.
|
|
self.raw_nodes.append(record)
|
|
|
|
def finish(self) -> TranslationResult:
|
|
result, entities, facts = self.result, self.entities, self.facts
|
|
if self.mode == "re-derive":
|
|
# Render the source's derived knowledge as digest documents so it
|
|
# is not lost, and let cognify re-extract it. Raw nodes carry no
|
|
# standalone text and are intentionally dropped in this mode.
|
|
described = [e for e in entities if e.description]
|
|
if described:
|
|
lines = [f"{e.name}: {e.description}" for e in described]
|
|
digest = COGXEntity(
|
|
external_system=described[0].external_system,
|
|
external_id="entities-digest",
|
|
name="entities-digest",
|
|
)
|
|
result.data_items.append(
|
|
_data_item_for(digest, "\n".join(lines), "Imported entity descriptions")
|
|
)
|
|
for start in range(0, len(facts), FACTS_PER_DIGEST):
|
|
chunk = facts[start : start + FACTS_PER_DIGEST]
|
|
digest = COGXFact(
|
|
external_system=chunk[0].external_system,
|
|
external_id=f"facts-digest-{start // FACTS_PER_DIGEST}",
|
|
subject_ref="-",
|
|
predicate="-",
|
|
object_ref="-",
|
|
)
|
|
result.data_items.append(
|
|
_data_item_for(
|
|
digest,
|
|
"\n".join(_render_fact_line(fact) for fact in chunk),
|
|
"Imported facts",
|
|
)
|
|
)
|
|
else:
|
|
batches, skipped_facts = _build_graph_batches(
|
|
entities, facts, self.raw_nodes, preserve_source_ids=self.preserve_source_ids
|
|
)
|
|
result.graph_batches.extend(batches)
|
|
result.skipped_facts = skipped_facts
|
|
return result
|
|
|
|
|
|
def translate_records(
|
|
records: Iterable[COGXRecord], mode: str, preserve_source_ids: bool = False
|
|
) -> TranslationResult:
|
|
"""Translate COGX records according to the import fidelity mode."""
|
|
translator = _RecordTranslator(mode, preserve_source_ids=preserve_source_ids)
|
|
for record in records:
|
|
translator.add(record)
|
|
return translator.finish()
|
|
|
|
|
|
async def translate_record_stream(
|
|
records: AsyncIterable[COGXRecord], mode: str, preserve_source_ids: bool = False
|
|
) -> TranslationResult:
|
|
"""Translate an async record stream without first materializing it as a list."""
|
|
translator = _RecordTranslator(mode, preserve_source_ids=preserve_source_ids)
|
|
async for record in records:
|
|
translator.add(record)
|
|
return translator.finish()
|
|
|
|
|
|
def wrap_graph_batch(batch: Dict[str, Any], source_system: str, index: int) -> DataItem:
|
|
"""Wrap a graph batch in a DataItem with a deterministic data_id.
|
|
|
|
The pipeline runtime treats DataItems with a stable ``data_id`` as
|
|
first-class data items: no file storage, idempotent re-run bookkeeping.
|
|
"""
|
|
fingerprint = "|".join(
|
|
sorted(str(node.id) for node in batch["nodes"])
|
|
+ sorted(f"{s}-{r}-{t}" for s, t, r, _ in batch["edges"])
|
|
)
|
|
return DataItem(
|
|
data=batch,
|
|
label=f"migration-graph-batch-{index}",
|
|
external_metadata={"external_system": source_system, "kind": "graph_batch"},
|
|
data_id=uuid5(NAMESPACE_OID, f"cogx-graph:{source_system}:{fingerprint}"),
|
|
)
|
|
|
|
|
|
def _provenance_ctx(ctx):
|
|
"""Adapt the pipeline context for add_data_points.
|
|
|
|
add_data_points stamps ledger provenance from ``ctx.data_item.id``, which
|
|
exists on Data ORM records but not on raw ingestion DataItems. Substitute
|
|
the DataItem's deterministic ``data_id`` so provenance still lands.
|
|
|
|
Only ``data_item`` is replaced; every other PipelineContext field is
|
|
carried over via ``dataclasses.replace`` so consumers that read ctx
|
|
attributes (e.g. ``add_data_points`` reads ``ctx.pipeline_run_id``) never
|
|
see a field-stripped context — a hand-copied field list here silently
|
|
drops any parameter later added to PipelineContext.
|
|
"""
|
|
if ctx is None:
|
|
return None
|
|
data_item = getattr(ctx, "data_item", None)
|
|
if data_item is None or hasattr(data_item, "id"):
|
|
return ctx
|
|
data_id = getattr(data_item, "data_id", None)
|
|
return dataclasses.replace(ctx, data_item=SimpleNamespace(id=data_id) if data_id else None)
|
|
|
|
|
|
# Edge batches can run larger than node batches: edges are small tuples and
|
|
# add_data_points' per-node work (model traversal, embedding) does not apply.
|
|
EDGE_BATCH_TARGET = 2 * BATCH_NODE_TARGET
|
|
|
|
|
|
async def stream_graph_from_source(source, stats: Dict[str, int], ctx=None) -> Dict[str, int]:
|
|
"""Two-pass streaming graph import for replayable preserve-mode sources.
|
|
|
|
Pass 1 streams the records once: raw nodes are rehydrated and flushed to
|
|
storage in bounded batches (only an id registry is kept), while entity
|
|
records buffer until the pass ends so same-name merging stays complete,
|
|
then flush in bounded batches too. Pass 2 streams the records again,
|
|
resolving each fact against the slim id registry and flushing edge
|
|
batches. Endpoint nodes are guaranteed to exist in the graph store by the
|
|
time edges arrive, so edges reference node ids without re-shipping nodes.
|
|
|
|
Peak memory is the entity set plus one in-flight batch — instead of every
|
|
rehydrated node and fact in the archive. ``stats`` (graph_nodes,
|
|
graph_edges, skipped_facts) is mutated in place so the caller can report
|
|
progress even when this task runs inside a background pipeline.
|
|
"""
|
|
from cognee.tasks.storage.add_data_points import add_data_points
|
|
|
|
ctx = _provenance_ctx(ctx)
|
|
|
|
# Cognee-origin archives keep the source node UUIDs verbatim (exact graph
|
|
# copy); other systems get class-namespaced ids (see _register_entity).
|
|
preserve_source_ids = source.source_system == "cognee"
|
|
|
|
entity_types: Dict[str, EntityType] = {}
|
|
by_external_id: Dict[str, Any] = {}
|
|
by_node_id: Dict[UUID, Any] = {}
|
|
first_external_id: Dict[UUID, str] = {}
|
|
known_node_ids: Set[UUID] = set()
|
|
external_to_node_id: Dict[str, UUID] = {}
|
|
|
|
async def flush(nodes: List[Any], edges: Optional[List[Tuple]] = None) -> None:
|
|
if not nodes and not edges:
|
|
return
|
|
await add_data_points(list(nodes), custom_edges=list(edges) if edges else None, ctx=ctx)
|
|
stats["graph_nodes"] += len(nodes)
|
|
stats["graph_edges"] += len(edges or [])
|
|
logger.info("Streamed graph batch: %d nodes, %d edges", len(nodes), len(edges or []))
|
|
|
|
# Pass 1: raw nodes stream straight to storage; entities buffer for merging.
|
|
batch: List[Any] = []
|
|
async for record in source.records():
|
|
if record.kind == "raw_node":
|
|
properties = record.properties or {}
|
|
node = rehydrate_node(properties)
|
|
if node.id in known_node_ids:
|
|
continue
|
|
known_node_ids.add(node.id)
|
|
external_id = properties.get("id")
|
|
if external_id:
|
|
external_to_node_id[str(external_id)] = node.id
|
|
batch.append(node)
|
|
if len(batch) >= BATCH_NODE_TARGET:
|
|
await flush(batch)
|
|
batch = []
|
|
elif record.kind == "entity":
|
|
_register_entity(
|
|
record,
|
|
entity_types=entity_types,
|
|
by_node_id=by_node_id,
|
|
by_external_id=by_external_id,
|
|
first_external_id=first_external_id,
|
|
preserve_source_ids=preserve_source_ids,
|
|
)
|
|
await flush(batch)
|
|
|
|
ordered_nodes = list(entity_types.values()) + list(by_node_id.values())
|
|
for start in range(0, len(ordered_nodes), BATCH_NODE_TARGET):
|
|
await flush(ordered_nodes[start : start + BATCH_NODE_TARGET])
|
|
known_node_ids.update(node.id for node in ordered_nodes)
|
|
for external_id, node in by_external_id.items():
|
|
external_to_node_id[external_id] = node.id
|
|
# Release the node objects; only the slim id registry survives into pass 2.
|
|
entity_types, by_node_id, by_external_id, ordered_nodes = {}, {}, {}, []
|
|
|
|
# Pass 2: facts resolve against the slim registry and flush as edge batches.
|
|
stub_batch: List[Any] = []
|
|
edge_batch: List[Tuple] = []
|
|
stubbed: Set[UUID] = set()
|
|
# Distinct fact refs can RESOLVE to the same (source, target, relationship)
|
|
# edge key (entities merge by name), so dedupe at the resolved level —
|
|
# first fact wins. Re-MERGEing a duplicate is not only wasteful: the
|
|
# resulting ON MATCH SET rel update crashes Ladybug's committed-in-memory
|
|
# row lookup (csr_node_group.cpp KU_UNREACHABLE) during fresh bulk imports.
|
|
seen_edge_keys: Set[Tuple[UUID, UUID, str]] = set()
|
|
|
|
def resolve(ref: str) -> Tuple[Optional[UUID], Optional[Any]]:
|
|
"""Resolve a fact ref to a node id; returns (id, new_stub_entity_or_None)."""
|
|
node_id = external_to_node_id.get(ref)
|
|
if node_id is not None:
|
|
return node_id, None
|
|
candidate = Entity.id_for(ref)
|
|
if candidate in known_node_ids or candidate in stubbed:
|
|
return candidate, None
|
|
if _looks_like_uuid(ref):
|
|
return None, None
|
|
# Plain-name reference (cross-provider archives): create the entity.
|
|
return candidate, Entity(id=candidate, name=ref, description=ref)
|
|
|
|
async for record in source.records():
|
|
if record.kind != "fact":
|
|
continue
|
|
subject_id, subject_stub = resolve(record.subject_ref)
|
|
object_id, object_stub = resolve(record.object_ref)
|
|
if subject_id is None or object_id is None:
|
|
stats["skipped_facts"] += 1
|
|
unresolved = [
|
|
ref
|
|
for ref, node_id in (
|
|
(record.subject_ref, subject_id),
|
|
(record.object_ref, object_id),
|
|
)
|
|
if node_id is None
|
|
]
|
|
logger.warning(
|
|
"Skipping fact %r (%s): unresolved UUID reference(s) %s",
|
|
record.external_id,
|
|
record.predicate,
|
|
", ".join(unresolved),
|
|
)
|
|
continue
|
|
edge_key = (subject_id, object_id, record.predicate)
|
|
if edge_key in seen_edge_keys:
|
|
stats["deduped_edges"] += 1
|
|
continue
|
|
seen_edge_keys.add(edge_key)
|
|
for stub in (subject_stub, object_stub):
|
|
if stub is not None and stub.id not in stubbed:
|
|
stub_batch.append(stub)
|
|
stubbed.add(stub.id)
|
|
edge_batch.append((subject_id, object_id, record.predicate, _fact_edge_properties(record)))
|
|
if len(edge_batch) >= EDGE_BATCH_TARGET or len(stub_batch) >= BATCH_NODE_TARGET:
|
|
await flush(stub_batch, edge_batch)
|
|
stub_batch, edge_batch = [], []
|
|
await flush(stub_batch, edge_batch)
|
|
|
|
return stats
|
|
|
|
|
|
async def store_imported_graph(batches, ctx=None):
|
|
"""Pipeline task: persist translated graph batches via add_data_points."""
|
|
from cognee.tasks.storage.add_data_points import add_data_points
|
|
|
|
ctx = _provenance_ctx(ctx)
|
|
if isinstance(batches, (dict, DataItem)):
|
|
batches = [batches]
|
|
|
|
for batch in batches:
|
|
if isinstance(batch, DataItem):
|
|
batch = batch.data
|
|
await add_data_points(
|
|
batch["nodes"],
|
|
custom_edges=batch["edges"] or None,
|
|
ctx=ctx,
|
|
)
|
|
logger.info(
|
|
"Stored imported graph batch: %d nodes, %d edges",
|
|
len(batch["nodes"]),
|
|
len(batch["edges"]),
|
|
)
|
|
return batches
|