Files
topoteretes--cognee/cognee/modules/visualization/preprocessor.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:02:24 +08:00

1400 lines
55 KiB
Python

"""Visualization preprocessor.
Single Python entry point that turns raw ``(nodes_data, edges_data)`` from
the graph adapter into a fully-enriched ``PreprocessedGraph`` ready for the
HTML/JS renderer to consume.
The renderer should *only* read fields produced here — synthesising stage
or bundling information in JavaScript is the source of the current
visualization's mess. By doing it once in Python, every view (Story,
Schema, Context, Retrieval) sees the same enrichment.
"""
import colorsys
import json
import math
import re
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
# ── Constants ────────────────────────────────────────────────────────────────
SCHEMA_GRAPH_NODE_TYPES = {
"DatabaseSchema",
"SchemaTable",
"SchemaRelationship",
"TableType",
}
# Maximum sample instance names attached to each schema type node.
SCHEMA_SAMPLES_PER_TYPE: int = 5
# Maximum semantic entity-type cards in the Schema view's Entity column.
# Entity-type diversity grows with the data (every new EntityType the LLM
# extracts becomes its own card), so beyond this cap the long tail is rolled
# up into a single "Other entities" card — the renderer stacks one card per
# type per rank column, which otherwise made the Entity column endless.
SCHEMA_MAX_ENTITY_TYPES: int = 12
# Display name of the rollup card holding the entity-type long tail.
OTHER_ENTITY_TYPES_LABEL: str = "Other entities"
# Internal graph taxonomy types that must not appear as separate type groups in
# the schema view. EntityType is now surfaced as its own schema type group
# alongside the resolved semantic entity types (Person/Field/...); Entity
# instances still collapse to their semantic type via the is_a edge. This set is
# kept (currently empty) so future genuinely-internal types can be added without
# re-plumbing the guards that reference it.
_INTERNAL_TYPES: frozenset = frozenset()
# Stage assignment by node type — drives the left-to-right Story layout.
# Unknown types fall through to "other".
_STAGE_BY_TYPE: Dict[str, str] = {
"TextDocument": "document",
"DocumentChunk": "chunk",
"TextSummary": "summary",
"GlobalContextSummary": "context",
"Entity": "entity",
"EntityType": "type",
"DatabaseSchema": "schema",
"SchemaTable": "schema",
"SchemaRelationship": "schema",
"TableType": "schema",
"TableRow": "schema",
"ColumnValue": "schema",
}
# Visual ordering of stages along the Story view's left-to-right spine.
STAGE_ORDER: Tuple[str, ...] = (
"document",
"chunk",
"entity",
"type",
"summary",
"context",
"schema",
"other",
)
# Relationship names that connect a structural parent to its children. Edges
# of these types are bundled in the Story view to cut visual noise on dense
# graphs (Alice has 769 edges, mostly contains/is_a).
_STRUCTURAL_RELATIONS: frozenset = frozenset(
{
"contains",
"is_a",
"part_of",
"is_part_of",
"has_relationship",
"made_from",
"summarized_in",
}
)
# Default colors per node type — preserved verbatim from the original
# monolith so existing test tokens continue to match.
_TYPE_COLOR_MAP: Dict[str, str] = {
"TextDocument": "#A550FF",
"DocumentChunk": "#0DFF00",
"Entity": "#6510F4",
"EntityType": "#D5C2FF",
"TextSummary": "#FFB454",
"GlobalContextSummary": "#00C2FF",
# NodeSet container nodes (e.g. the "session_learnings" grouping) were
# missing here and fell through to the gray unknown-type fallback.
"NodeSet": "#94A3B8",
"TableRow": "#A550FF",
"TableType": "#6510F4",
"ColumnValue": "#747470",
"SchemaTable": "#A550FF",
"DatabaseSchema": "#6510F4",
"SchemaRelationship": "#323332",
"default": "#7c3aed",
}
# Ontology-grounded nodes get a distinct fill: the old #D8D8D8 gray was
# indistinguishable from the #DBD8D8 unknown-type fallback, so ontology
# matches visually disappeared into untyped nodes.
_ONTOLOGY_VALID_COLOR = "#FF5CA8"
_UNKNOWN_TYPE_COLOR = "#DBD8D8"
# Node sets produced by the self-improvement bridge (improve()/distillation).
# These get stable, meaningful colors in the "color by node set" overlay
# instead of the deterministic hue-rotation, so they stay recognizable across
# graphs. session_learnings (distilled lessons) is the headline feature.
_DISTILLED_LEARNING_NODE_SET = "session_learnings"
_MEMORY_NODESET_COLORS: Dict[str, str] = {
"session_learnings": "#FFC53D", # distilled lessons (gold)
"user_sessions_from_cache": "#00C2AA", # persisted session Q&A (teal)
"agent_trace_feedbacks": "#FF7A59", # persisted agent trace feedback (coral)
}
def _node_set_names(node_info) -> set:
"""Collect node-set names attached to a node via source_node_set (a comma-joined
string) or belongs_to_set (a list of name strings), ignoring UUID-shaped refs."""
names: set = set()
raw = node_info.get("source_node_set")
if isinstance(raw, str):
names.update(part.strip() for part in raw.split(",") if part.strip())
belongs = node_info.get("belongs_to_set")
if isinstance(belongs, (list, tuple)):
names.update(b for b in belongs if isinstance(b, str))
elif isinstance(belongs, str):
names.add(belongs)
return names
def is_distilled_learning_node(node_info) -> bool:
"""True when a node belongs to the distilled session-learnings node set."""
return _DISTILLED_LEARNING_NODE_SET in _node_set_names(node_info)
# Minimum number of structural edges between the same (source_stage,
# target_stage, relation) for the view to render them as a single bundle
# ribbon instead of individual lines.
DEFAULT_BUNDLE_MIN = 5
# ── Helpers reused by orchestrator and views ─────────────────────────────────
def generate_provenance_colors(values):
"""Generate a deterministic color map for a set of provenance values.
Identical to the original ``_generate_provenance_colors`` — preserved
verbatim so existing string-token tests continue to pass.
"""
color_map = {}
unique = sorted(set(v for v in values if v))
for i, name in enumerate(unique):
hue = (i * 137.5) % 360
r, g, b = colorsys.hls_to_rgb(hue / 360, 0.6, 0.65)
color_map[name] = "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255))
return color_map
# UUID- or content-hash-shaped strings: never useful as display names.
_IDENTIFIER_LIKE_RE = re.compile(
r"^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32,64})$",
re.IGNORECASE,
)
def looks_like_identifier(value) -> bool:
"""True for UUID/hash-shaped strings that would read as noise in the UI."""
return isinstance(value, str) and bool(_IDENTIFIER_LIKE_RE.match(value.strip()))
def derive_node_name(node_info, node_id):
"""Pick a human-readable label for a node, falling back through name/title/text/etc.
Identifier-shaped values (UUIDs, content hashes) are never used as
display names — surfacing them as labels was the single biggest
first-session trust killer in user testing. Nodes with no readable
field get an explicit "Unnamed <Type>" placeholder instead.
"""
name = node_info.get("name")
if name and not looks_like_identifier(name):
return name
for key in ("title", "text", "summary", "description", "content"):
value = node_info.get(key)
if isinstance(value, str) and value.strip() and not looks_like_identifier(value):
normalized = " ".join(value.split())
return normalized[:120]
node_type = node_info.get("type") or "node"
return f"Unnamed {node_type} ({str(node_id)[:8]})"
def node_type_rank(node_type):
"""Fallback ordering when ``topological_rank`` is missing (0 or None).
Order matches the Story view's pipeline column order
(Documents → Chunks → Entities → Types → Summaries → Context) so the
Schema diagram uses the same left-to-right narrative as the Graph tab.
Actor / ownership types occupy negative ranks so they flow in *before*
the document pipeline (Organization → People → Agents → Sessions →
Brain → Documents → … ): agents write sessions, which are recorded into
the brains they belong to.
"""
type_ranks = {
# Actor & ownership layer (left of the document pipeline)
"Tenant": -5,
"User": -4,
"Agent": -3,
"Session": -2,
"Dataset": -1,
# Document → memory pipeline
"TextDocument": 0,
"DocumentChunk": 1,
"Entity": 2,
"EntityType": 3,
"TextSummary": 4,
"GlobalContextSummary": 5,
"DatabaseSchema": 0,
"SchemaTable": 1,
"SchemaRelationship": 2,
"TableType": 1,
"TableRow": 2,
"ColumnValue": 3,
}
return type_ranks.get(node_type, 4)
def _coerce_json_value(value):
if isinstance(value, (dict, list)):
return value
if not isinstance(value, str) or not value.strip():
return None
try:
return json.loads(value)
except json.JSONDecodeError:
return None
def _field_from_column(column):
if isinstance(column, str):
return {"name": column, "type": "column", "required": False}
if not isinstance(column, dict):
return None
name = (
column.get("name") or column.get("column_name") or column.get("field") or column.get("key")
)
if not name:
return None
column_type = (
column.get("type")
or column.get("data_type")
or column.get("python_type")
or column.get("nullable")
or "column"
)
required = bool(column.get("primary_key") or column.get("required"))
if column.get("nullable") is False:
required = True
return {"name": str(name), "type": str(column_type), "required": required}
def extract_schema_fields(node):
fields = []
columns = _coerce_json_value(node.get("columns"))
if isinstance(columns, dict):
for name, column in columns.items():
if isinstance(column, dict):
f = _field_from_column({"name": name, **column})
else:
f = {"name": str(name), "type": str(column), "required": False}
if f:
fields.append(f)
elif isinstance(columns, list):
for column in columns:
f = _field_from_column(column)
if f:
fields.append(f)
if fields:
return fields
fallback_keys = (
"database_type",
"primary_key",
"source_table",
"source_column",
"target_table",
"target_column",
"relationship_type",
"row_count_estimate",
)
for key in fallback_keys:
value = node.get(key)
if value is not None and value != "":
fields.append({"name": key, "type": str(value), "required": False})
return fields
def extract_schema_graph_data(nodes_list, links_list):
"""Build the DLT/structured-schema graph from SchemaTable/SchemaRelationship nodes.
Falls back to ``extract_type_schema_graph_data`` when no schema nodes are present.
"""
schema_nodes = []
schema_node_ids = set()
for node in nodes_list:
if node.get("type") not in SCHEMA_GRAPH_NODE_TYPES:
continue
schema_node_ids.add(node["id"])
schema_nodes.append(
{
"id": node["id"],
"name": node.get("name") or node["id"],
"type": node.get("type"),
"description": node.get("description"),
"fields": extract_schema_fields(node),
"source_table": node.get("source_table"),
"target_table": node.get("target_table"),
"relationship_type": node.get("relationship_type"),
}
)
if schema_nodes:
schema_links = []
seen_links = set()
for link in links_list:
source = str(link["source"])
target = str(link["target"])
if source not in schema_node_ids or target not in schema_node_ids:
continue
label = _link_relation(link)
link_key = (source, target, label)
if link_key in seen_links:
continue
seen_links.add(link_key)
schema_links.append({"source": source, "target": target, "label": label})
return {"nodes": schema_nodes, "links": schema_links}
return extract_type_schema_graph_data(nodes_list, links_list)
def _schema_value_type(value):
if isinstance(value, bool):
return "boolean"
if isinstance(value, int) and not isinstance(value, bool):
return "integer"
if isinstance(value, float):
return "number"
if isinstance(value, list):
return "array"
if isinstance(value, dict):
return "object"
if value is None:
return "nullable"
return "string"
def extract_type_schema_fields(type_nodes):
field_counts: Counter = Counter()
field_types: Dict[str, str] = {}
preferred_fields = (
"source_task",
"source_pipeline",
"source_node_set",
"source_user",
"global_context_bucket_id",
"level",
"is_root",
"topological_rank",
)
excluded_fields = {
"id",
"type",
"name",
"color",
"text",
"summary",
"content",
"description",
"metadata",
"properties",
"source_content_hash",
"belongs_to_set",
"ontology_valid",
"feedback_weight",
"importance_weight",
}
for node in type_nodes:
for key, value in node.items():
if key.startswith("_") or key in excluded_fields or value in (None, ""):
continue
field_counts[key] += 1
field_types.setdefault(key, _schema_value_type(value))
fields: List[Dict[str, Any]] = [
{"name": "count", "type": str(len(type_nodes)), "required": True}
]
ordered_field_names: List[str] = []
for key in preferred_fields:
if key in field_counts:
ordered_field_names.append(key)
for key, _ in field_counts.most_common():
if key not in ordered_field_names:
ordered_field_names.append(key)
for key in ordered_field_names[:5]:
count = field_counts[key]
coverage = int(round(count / max(1, len(type_nodes)) * 100))
fields.append(
{
"name": key,
"type": f"{field_types.get(key, 'any')} {coverage}%",
"required": count == len(type_nodes),
}
)
return fields
def _relationship_label(relation_counts):
total = sum(relation_counts.values())
top_relations = relation_counts.most_common(2)
parts = [f"{name} ({count})" for name, count in top_relations]
if len(relation_counts) > len(top_relations):
parts.append(f"+{len(relation_counts) - len(top_relations)} more")
return ", ".join(parts) if parts else f"{total} edges"
# Relationship name of the Entity -> EntityType edge used to resolve the
# semantic type of extracted entities (mirrors get_schema_inventory).
ENTITY_TYPE_RELATION: str = "is_a"
def _link_relation(link: Dict[str, Any]) -> str:
"""Read a link's relation name across the shapes the preprocessor emits."""
edge_info = link.get("edge_info") or {}
return (
link.get("relationship_type")
or edge_info.get("relationship_name")
or edge_info.get("relationship_type")
or link.get("relation")
or "related"
)
def resolve_semantic_types(
nodes_list: List[Dict[str, Any]], links_list: List[Dict[str, Any]]
) -> Dict[str, str]:
"""Map each node id to its semantic type name.
Non-Entity nodes keep their raw ``type`` property. Entity nodes (``type ==
"Entity"``) resolve to the EntityType ``name`` reached via the ``is_a`` edge,
so semantic types (Person/Tool/Broker) surface instead of the literal
"Entity". Mirrors ``get_schema_inventory._resolve_node_types`` adapted to the
preprocessor's normalized node/link shape.
"""
nodes_by_id = {node["id"]: node for node in nodes_list}
# Collect the EntityType target name for each Entity source via the is_a edge
entity_type_name = {}
for link in links_list:
source = str(link["source"])
target = str(link["target"])
if _link_relation(link) == ENTITY_TYPE_RELATION and target in nodes_by_id:
entity_type_name[source] = nodes_by_id[target].get("name")
node_type = {}
for node in nodes_list:
node_id = node["id"]
raw_type = node.get("type")
if raw_type == "Entity" and entity_type_name.get(node_id):
node_type[node_id] = entity_type_name[node_id]
else:
node_type[node_id] = raw_type or "Node"
return node_type
def extract_type_schema_graph_data(
nodes_list: List[Dict[str, Any]], links_list: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""Fallback schema view: collapse the graph to one node per semantic type."""
node_type_by_id = resolve_semantic_types(nodes_list, links_list)
# Names reached via the is_a edge are semantic *entity* types (Person, Broker,
# …) — rank them in the Entity column rather than letting them fall through to
# the default ("Summaries") rank.
nodes_by_id_lookup = {node["id"]: node for node in nodes_list}
semantic_type_names = set()
for link in links_list:
if _link_relation(link) == ENTITY_TYPE_RELATION:
target_node = nodes_by_id_lookup.get(str(link["target"]))
if target_node and target_node.get("name"):
semantic_type_names.add(target_node["name"])
# Bound the Entity column: keep the most-populated semantic entity types
# as their own cards and remap the long tail onto one rollup type. The
# remap happens on node_type_by_id *before* any downstream aggregation, so
# relationship distributions, pair edges, instance drill-down, and the
# operation layer all treat the rollup as an ordinary type.
rolled_up_types: List[Dict[str, Any]] = []
entity_type_counts = Counter(
type_name for type_name in node_type_by_id.values() if type_name in semantic_type_names
)
if len(entity_type_counts) > SCHEMA_MAX_ENTITY_TYPES:
kept_types = {
name for name, _ in entity_type_counts.most_common(SCHEMA_MAX_ENTITY_TYPES - 1)
}
rolled_types = set(entity_type_counts) - kept_types
rolled_up_types = [
{"name": name, "count": count}
for name, count in entity_type_counts.most_common()
if name in rolled_types
]
for node_id, type_name in node_type_by_id.items():
if type_name in rolled_types:
node_type_by_id[node_id] = OTHER_ENTITY_TYPES_LABEL
semantic_type_names = (semantic_type_names - rolled_types) | {OTHER_ENTITY_TYPES_LABEL}
def _rank_for(type_name):
if type_name in semantic_type_names:
return node_type_rank("Entity")
return node_type_rank(type_name)
nodes_by_type: Dict[str, List[Dict]] = defaultdict(list)
for node in nodes_list:
type_name = node_type_by_id[node["id"]]
if type_name not in _INTERNAL_TYPES:
nodes_by_type[type_name].append(node)
# Aggregate the full per-source-type relationship distribution keyed by
# (relation, target_type). Built once and shared with the per-type "samples"
# records and the lossy pair-edge labels below.
# Track both outgoing AND incoming so types like DocumentChunk whose primary
# connections are incoming (TextDocument→contains→DocumentChunk) are not
# shown as isolated nodes.
relationships_by_type: Dict[str, Counter] = defaultdict(Counter)
for link in links_list:
source_type = node_type_by_id.get(str(link["source"]))
target_type = node_type_by_id.get(str(link["target"]))
if source_type is None or target_type is None:
continue
if source_type in _INTERNAL_TYPES or target_type in _INTERNAL_TYPES:
continue
relation = _link_relation(link)
relationships_by_type[source_type][(relation, target_type)] += 1
relationships_by_type[target_type][(f"\u2190 {relation}", source_type)] += 1
schema_nodes = []
for node_type_name, type_nodes in sorted(nodes_by_type.items()):
# Surface the most-common pipeline / task / user that produced this
# type so the Schema card can show "produced by cognify_pipeline /
# extract_graph_from_data" prominently rather than burying it as
# one of many fields with a "string 100%" coverage label.
pipe_counter: Counter = Counter()
task_counter: Counter = Counter()
user_counter: Counter = Counter()
for tn in type_nodes:
sp = tn.get("source_pipeline")
st = tn.get("source_task")
su = tn.get("source_user")
if sp:
pipe_counter[sp] += 1
if st:
task_counter[st] += 1
if su:
user_counter[su] += 1
top_pipeline = pipe_counter.most_common(1)[0][0] if pipe_counter else None
top_task = task_counter.most_common(1)[0][0] if task_counter else None
top_user = user_counter.most_common(1)[0][0] if user_counter else None
# Rank instances by descending degree, then name, so the sample list is
# deterministic rather than dict-order-dependent. PR3's side panel reads
# these names directly.
ranked = sorted(
type_nodes,
key=lambda tn: (-(tn.get("degree") or 0), tn.get("name") or ""),
)
samples = [tn["name"] for tn in ranked[:SCHEMA_SAMPLES_PER_TYPE]]
# Full per-pair relationship distribution for this source type, sorted by
# descending count then target/relation as stable tiebreakers.
relationships = sorted(
(
{"to_type": target_type, "relation": relation, "count": count}
for (relation, target_type), count in relationships_by_type[node_type_name].items()
),
key=lambda rel: (-rel["count"], rel["to_type"] or "", rel["relation"]),
)
schema_node = {
"id": f"type:{node_type_name}",
"name": node_type_name,
"type": "GraphNodeType",
"rank": _rank_for(node_type_name),
"fields": extract_type_schema_fields(type_nodes),
"source_pipeline": top_pipeline,
"source_task": top_task,
"source_user": top_user,
"instance_count": len(type_nodes),
"samples": samples,
"sample_size": len(samples),
"relationships": relationships,
}
if node_type_name == OTHER_ENTITY_TYPES_LABEL and rolled_up_types:
schema_node["rollup"] = True
schema_node["rolled_up_types"] = rolled_up_types
# Lead the card with the tail size and its largest types so the
# rollup is self-explanatory without inspector drill-down.
top_tail = ", ".join(f"{t['name']} ({t['count']})" for t in rolled_up_types[:3])
schema_node["fields"].insert(
1,
{
"name": "entity types",
"type": f"{len(rolled_up_types)} rolled up: {top_tail}, …",
"required": True,
},
)
schema_nodes.append(schema_node)
relation_counts_by_pair: Dict[Tuple[str, str], Counter] = defaultdict(Counter)
for link in links_list:
source_type = node_type_by_id.get(str(link["source"]))
target_type = node_type_by_id.get(str(link["target"]))
if source_type is None or target_type is None:
continue
if source_type in _INTERNAL_TYPES or target_type in _INTERNAL_TYPES:
continue
relation_counts_by_pair[(source_type, target_type)][_link_relation(link)] += 1
schema_links: List[Dict[str, Any]] = []
for index, ((source_type, target_type), relation_counts) in enumerate(
sorted(
relation_counts_by_pair.items(),
key=lambda item: (-sum(item[1].values()), item[0][0], item[0][1]),
)
):
rel_id = f"rel:{index}:{source_type}:{target_type}"
source_rank = _rank_for(source_type)
target_rank = _rank_for(target_type)
if source_rank == target_rank:
rel_rank = source_rank + 0.5
else:
rel_rank = (source_rank + target_rank) / 2
schema_nodes.append(
{
"id": rel_id,
"name": (
f"{source_type} self-links"
if source_type == target_type
else f"{source_type} to {target_type}"
),
"type": "GraphRelationshipType",
"rank": rel_rank,
"source_type": source_type,
"target_type": target_type,
"relationship_label": _relationship_label(relation_counts),
"edge_count": sum(relation_counts.values()),
"fields": [
{
"name": "edges",
"type": str(sum(relation_counts.values())),
"required": True,
},
{
"name": "top relation",
"type": relation_counts.most_common(1)[0][0],
"required": True,
},
{
"name": "relation types",
"type": str(len(relation_counts)),
"required": True,
},
],
}
)
schema_links.append({"source": f"type:{source_type}", "target": rel_id, "label": "from"})
schema_links.append({"source": rel_id, "target": f"type:{target_type}", "label": "to"})
# Instance-level drill-down data so the inspector can navigate
# Type → instance → neighbours without dropping to the global graph:
# * instances_by_type: every instance name (not just the 5 samples), per type
# * instance_index: a compact per-instance adjacency (outgoing/incoming edges)
# NOTE: for very large graphs this should be scoped/paginated; it is sized to
# the schema graph (one entry per instance) which is fine for typical graphs.
instances_by_type: Dict[str, List[Dict[str, str]]] = defaultdict(list)
instance_index: Dict[str, Dict[str, Any]] = {}
for node in nodes_list:
nid = str(node["id"])
type_name = node_type_by_id[node["id"]]
if type_name in _INTERNAL_TYPES:
continue
display_name = node.get("name") or nid
instances_by_type[type_name].append({"id": nid, "name": display_name})
instance_index[nid] = {
"id": nid,
"name": display_name,
"type": type_name,
"out": [],
"in": [],
}
for type_name in instances_by_type:
instances_by_type[type_name].sort(key=lambda rec: rec["name"])
for link in links_list:
source = str(link["source"])
target = str(link["target"])
if source not in instance_index or target not in instance_index:
continue
relation = _link_relation(link)
instance_index[source]["out"].append({"relation": relation, "id": target})
instance_index[target]["in"].append({"relation": relation, "id": source})
return {
"nodes": schema_nodes,
"links": schema_links,
"instances_by_type": dict(instances_by_type),
"instance_index": instance_index,
}
def build_operation_layer(
schema_graph: Dict[str, Any],
nodes_list: List[Dict[str, Any]],
links_list: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""Attach a transformation impact-layer to ``schema_graph`` in place.
For each catalog operation whose effects touch a schema type present in the
graph, emit an operation node and typed impact links
(produces/enriches/modifies/removes). ``"Entity"`` effects expand to the
semantic entity types actually present (Person/Broker/…). Links are flagged
``observed`` when the live provenance (a type's top ``source_pipeline``)
matches the operation's pipeline. Existing nodes/links are left untouched.
"""
from cognee.modules.visualization.operations_catalog import get_operations_catalog
type_nodes = [n for n in schema_graph.get("nodes", []) if n.get("type") == "GraphNodeType"]
present = {n["name"] for n in type_nodes}
pipeline_by_type = {n["name"]: n.get("source_pipeline") for n in type_nodes}
# Semantic entity types are those reached via the is_a edge (Person/Broker/…).
nodes_by_id = {n["id"]: n for n in nodes_list}
semantic_entity_types = set()
for link in links_list:
if _link_relation(link) == ENTITY_TYPE_RELATION:
target = nodes_by_id.get(str(link["target"]))
if target and target.get("name"):
semantic_entity_types.add(target["name"])
def resolve_targets(effect):
names = set()
target_type = effect.get("target_type")
if target_type == "Entity":
names |= semantic_entity_types & present
if "Entity" in present:
names.add("Entity")
elif target_type and target_type in present:
names.add(target_type)
node_set = effect.get("target_node_set")
if node_set and node_set in present:
names.add(node_set)
return names
operations = []
operation_links = []
for op in get_operations_catalog():
seen = set()
links_for_op = []
for effect in op.get("effects", []):
for type_name in resolve_targets(effect):
key = (effect["effect"], type_name)
if key in seen:
continue
seen.add(key)
observed = op.get("pipeline_name") is not None and pipeline_by_type.get(
type_name
) == op.get("pipeline_name")
links_for_op.append(
{
"source": "op:" + op["name"],
"target": "type:" + type_name,
"effect": effect["effect"],
"property": effect.get("property"),
"observed": bool(observed),
}
)
if not links_for_op:
continue # operation doesn't touch any type present in this graph
operations.append(
{
"id": "op:" + op["name"],
"name": op["label"],
"type": "GraphOperation",
"op_kind": op.get("kind", "pipeline"),
"scope": op.get("scope", "subset"),
"summary": op.get("summary", ""),
}
)
operation_links.extend(links_for_op)
schema_graph["operations"] = operations
schema_graph["operation_links"] = operation_links
return schema_graph
# ── Story-view enrichment ────────────────────────────────────────────────────
def _stage_for_node(node_info):
node_type = node_info.get("type")
return _STAGE_BY_TYPE.get(node_type, "other")
def _visual_rank(node_info, stage):
"""Pick the rank the Story layout reads.
Prefer the runtime-stamped ``topological_rank`` (1-based, set by
``run_tasks_base._stamp_provenance`` as of Phase 1a). Fall back to a
fixed stage order so legacy graphs without stamped ranks still render.
"""
rank = node_info.get("topological_rank")
if isinstance(rank, int) and rank > 0:
return rank
if isinstance(rank, float) and rank > 0:
return int(rank)
return STAGE_ORDER.index(stage) + 1 if stage in STAGE_ORDER else len(STAGE_ORDER)
def _edge_class(relation, edge_info):
"""Classify an edge so the renderer can bundle structural noise and keep
semantic relations visible."""
rel = (relation or "").lower()
edge_relation = (edge_info or {}).get("relationship_name", "") if edge_info else ""
edge_relation = (edge_relation or "").lower()
if rel in _STRUCTURAL_RELATIONS or edge_relation in _STRUCTURAL_RELATIONS:
return "structural"
return "semantic"
def _bundle_key(source_stage, target_stage, edge_class, relation):
return f"{source_stage}|{target_stage}|{edge_class}|{relation or ''}"
def _compact_provenance(node_info):
"""Only emit a provenance dict when at least one field is present, so the
inspector can hide the section cleanly on legacy graphs."""
keys = ("source_task", "source_pipeline", "source_node_set", "source_user")
payload = {k: node_info.get(k) for k in keys if node_info.get(k)}
return payload or None
# ── Memory-map payload ───────────────────────────────────────────────────────
# Gap (ms) between consecutive ``t_created`` values beyond which the timeline
# starts a new run event. 5 minutes cleanly separates pipeline runs while
# merging the sub-second spread within one cognify batch.
MEMORY_TIMELINE_GAP_MS: int = 300_000
# Members per entity group flagged ``important`` even when they did not earn
# a Key-mode ``label_priority`` slot — the Memory view renders these as named
# cards and collapses the rest into a "+K" pill.
MEMORY_GROUP_TOP_MEMBERS: int = 8
def _t_sort_key(t_created) -> Tuple[bool, int]:
"""Sort key tolerant of missing timestamps: untimed values sort last."""
return (t_created is None, t_created if t_created is not None else 0)
def _build_memory_map(nodes: List[Dict[str, Any]], links: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Build the Memory-tab payload (embedded via the ``__MEMORY_DATA__`` token).
The payload carries *structure only*: ids, deterministic ordering,
grouping, an index of structural edges (integer positions into the links
array) and the run timeline. Node/link details are read at render time
from the Story view's already-embedded data, so nothing is duplicated.
Deterministic-position rule: every list is sorted by keys intrinsic to
the data (``chunk_index``, names, ids, ``t_created``), so the layout the
JS derives from it is reproducible and append-stable as the graph grows.
"""
nodes_by_id = {n["id"]: n for n in nodes}
doc_nodes = [n for n in nodes if n["stage"] == "document"]
chunk_nodes = [n for n in nodes if n["stage"] == "chunk"]
entity_nodes = [n for n in nodes if n["stage"] == "entity"]
summary_nodes = [n for n in nodes if n["stage"] == "summary"]
context_nodes = [n for n in nodes if n["stage"] == "context"]
doc_ids = {n["id"] for n in doc_nodes}
chunk_ids = {n["id"] for n in chunk_nodes}
entity_ids = {n["id"] for n in entity_nodes}
summary_ids = {n["id"] for n in summary_nodes}
context_ids = {n["id"] for n in context_nodes}
# ── Structural edge index + relation maps (single pass over links) ─────
edge_index: Dict[str, List[int]] = {
"contains": [],
"made_from": [],
"is_part_of": [],
"summarized_in": [],
"semantic": [],
}
chunk_doc_via_edge: Dict[str, str] = {}
summary_chunks: Dict[str, set] = defaultdict(set)
bucket_children: Dict[str, set] = defaultdict(set)
members_by_type: Dict[str, set] = defaultdict(set)
for position, link in enumerate(links):
source, target = link["source"], link["target"]
rel = (_link_relation(link) or "").lower()
endpoint_stages = {link.get("source_stage"), link.get("target_stage")}
if rel in ("is_part_of", "part_of") or (
rel == "contains" and endpoint_stages == {"document", "chunk"}
):
# Chunk↔document membership: modern graphs use is_part_of
# (chunk→doc); some legacy graphs use contains (doc→chunk).
edge_index["is_part_of"].append(position)
chunk_end = source if source in chunk_ids else target
doc_end = target if target in doc_ids else source
if chunk_end in chunk_ids and doc_end in doc_ids:
chunk_doc_via_edge.setdefault(chunk_end, doc_end)
elif rel == "contains":
edge_index["contains"].append(position)
elif rel == "made_from":
edge_index["made_from"].append(position)
summary_end = source if source in summary_ids else target
chunk_end = target if target in chunk_ids else source
if summary_end in summary_ids and chunk_end in chunk_ids:
summary_chunks[summary_end].add(chunk_end)
elif rel == "summarized_in":
edge_index["summarized_in"].append(position)
if target in context_ids:
bucket_children[target].add(source)
elif rel == ENTITY_TYPE_RELATION:
# Entity → EntityType grouping edge. Entity→Entity is_a edges do
# NOT group (only true EntityType targets count).
if source in entity_ids and nodes_by_id.get(target, {}).get("type") == "EntityType":
members_by_type[target].add(source)
elif link.get("edge_class") == "semantic" and source in entity_ids and target in entity_ids:
edge_index["semantic"].append(position)
# ── Documents with ordered chunk cells ─────────────────────────────────
chunks_by_doc: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
legacy_chunks_by_doc: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
orphan_chunks: List[str] = []
for chunk in chunk_nodes:
raw_doc_id = chunk.get("document_id")
doc_id = str(raw_doc_id) if raw_doc_id is not None else None
if doc_id in doc_ids:
chunks_by_doc[doc_id].append(chunk)
elif chunk["id"] in chunk_doc_via_edge:
legacy_chunks_by_doc[chunk_doc_via_edge[chunk["id"]]].append(chunk)
else:
orphan_chunks.append(chunk["id"])
orphan_chunks.sort()
def _chunk_index_of(chunk):
index = chunk.get("chunk_index")
return index if isinstance(index, int) and not isinstance(index, bool) else None
def _chunk_sort_key(chunk):
index = _chunk_index_of(chunk)
return (index is None, index or 0, *_t_sort_key(chunk.get("t_created")), chunk["id"])
def _chunk_cell(chunk):
return {
"id": chunk["id"],
"chunk_index": _chunk_index_of(chunk),
"t_created": chunk.get("t_created"),
}
documents: List[Dict[str, Any]] = []
for doc in doc_nodes:
primary = sorted(chunks_by_doc.get(doc["id"], []), key=_chunk_sort_key)
# Legacy chunks (attributed only via the is_part_of edge) append
# after the chunk_index-ordered run so existing cells never shift.
legacy = sorted(legacy_chunks_by_doc.get(doc["id"], []), key=_chunk_sort_key)
ordered = primary + legacy
times = [
t
for t in [doc.get("t_created")] + [c.get("t_created") for c in ordered]
if t is not None
]
documents.append(
{
"id": doc["id"],
"name": doc.get("name") or doc["id"],
"t_first": min(times) if times else None,
"chunks": [_chunk_cell(c) for c in ordered],
}
)
documents.sort(key=lambda d: (*_t_sort_key(d["t_first"]), d["name"], d["id"]))
# ── Entity groups (one per EntityType node) ────────────────────────────
entity_groups: List[Dict[str, Any]] = []
grouped_entity_ids: set = set()
type_nodes = sorted(
(n for n in nodes if n.get("type") == "EntityType"),
key=lambda n: (n.get("name") or "", n["id"]),
)
for type_node in type_nodes:
member_nodes = sorted(
(nodes_by_id[mid] for mid in members_by_type.get(type_node["id"], ())),
key=lambda n: (
not n.get("label_priority"),
-(n.get("importance") or 0.0),
n.get("name") or "",
n["id"],
),
)
grouped_entity_ids.update(n["id"] for n in member_nodes)
entity_groups.append(
{
"type_id": type_node["id"],
"type_name": type_node.get("name") or type_node["id"],
"members": [
{
"id": member["id"],
"important": bool(member.get("label_priority"))
or rank < MEMORY_GROUP_TOP_MEMBERS,
}
for rank, member in enumerate(member_nodes)
],
}
)
ungrouped_entities = [
n["id"]
for n in sorted(
(n for n in entity_nodes if n["id"] not in grouped_entity_ids),
key=lambda n: (n.get("name") or "", n["id"]),
)
]
# ── Summaries ──────────────────────────────────────────────────────────
summaries: List[Dict[str, Any]] = []
for summary in sorted(summary_nodes, key=lambda n: (*_t_sort_key(n.get("t_created")), n["id"])):
bucket_id = summary.get("global_context_bucket_id")
summaries.append(
{
"id": summary["id"],
"chunk_ids": sorted(summary_chunks.get(summary["id"], ())),
"bucket_id": str(bucket_id) if bucket_id is not None else None,
}
)
# ── Global context (None → the view renders its empty state) ──────────
context: Optional[Dict[str, Any]] = None
if context_nodes:
root_ids = sorted(n["id"] for n in context_nodes if n.get("is_root"))
buckets = [
{
"id": n["id"],
"level": n.get("level") if isinstance(n.get("level"), int) else None,
"child_ids": sorted(bucket_children.get(n["id"], ())),
}
for n in sorted(
context_nodes,
key=lambda n: (
n.get("level") if isinstance(n.get("level"), int) else -1,
n["id"],
),
)
]
context = {"root_id": root_ids[0] if root_ids else None, "buckets": buckets}
# ── Timeline: gap-cluster t_created into run events ────────────────────
timed = sorted(
(n for n in nodes if n.get("t_created") is not None),
key=lambda n: (n["t_created"], n["id"]),
)
untimed_ids = sorted(n["id"] for n in nodes if n.get("t_created") is None)
clusters: List[List[Dict[str, Any]]] = []
for node in timed:
if clusters and node["t_created"] - clusters[-1][-1]["t_created"] <= MEMORY_TIMELINE_GAP_MS:
clusters[-1].append(node)
else:
clusters.append([node])
timeline: List[Dict[str, Any]] = []
for index, cluster in enumerate(clusters):
node_ids = [n["id"] for n in cluster]
if index == 0 and untimed_ids:
# Nodes without t_created (legacy/defensive) join the first event.
node_ids = untimed_ids + node_ids
pipeline_counts = Counter(
n.get("source_pipeline") for n in cluster if n.get("source_pipeline")
)
if any(n["stage"] == "context" for n in cluster):
label = "global_context_index"
elif pipeline_counts:
label = min(pipeline_counts.items(), key=lambda kv: (-kv[1], kv[0]))[0]
else:
label = "ingestion"
timeline.append(
{
"index": index,
"kind": "run",
"label": label,
"t0": cluster[0]["t_created"],
"t1": cluster[-1]["t_created"],
"node_count": len(node_ids),
"node_ids": node_ids,
}
)
if not timeline and untimed_ids:
# No node carries t_created at all: emit one synthetic event so the
# view always has a "current state" selection on non-empty graphs.
timeline.append(
{
"index": 0,
"kind": "run",
"label": "ingestion",
"t0": 0,
"t1": 0,
"node_count": len(untimed_ids),
"node_ids": untimed_ids,
}
)
return {
"documents": documents,
"orphan_chunks": orphan_chunks,
"entity_groups": entity_groups,
"ungrouped_entities": ungrouped_entities,
"summaries": summaries,
"context": context,
"edges": edge_index,
"timeline": timeline,
}
# ── Public API ───────────────────────────────────────────────────────────────
@dataclass
class PreprocessedGraph:
"""Renderer-facing snapshot of a cognee graph.
Every Python-side derivation the JavaScript renderer needs to make a
correct, readable visualization is computed once here. The renderer
should not synthesize stage/rank/edge_class/etc. on its own.
"""
nodes: List[Dict[str, Any]] = field(default_factory=list)
links: List[Dict[str, Any]] = field(default_factory=list)
color_maps: Dict[str, Dict[str, str]] = field(default_factory=dict)
schema_graph: Dict[str, Any] = field(default_factory=lambda: {"nodes": [], "links": []})
schema_data: Optional[Dict[str, Any]] = None
pipeline_stages: List[str] = field(default_factory=list)
edge_classes: Dict[str, int] = field(default_factory=dict)
bundles: Dict[str, int] = field(default_factory=dict)
provenance_index: Dict[str, Dict[str, Any]] = field(default_factory=dict)
has_meaningful_topological_rank: bool = False
memory_map: Dict[str, Any] = field(default_factory=dict)
def _label_priority_threshold(importances: List[float], percentile: float = 0.75) -> float:
"""Return the importance threshold above which a node earns a Key-mode label."""
if not importances:
return 0.0
finite = sorted(v for v in importances if math.isfinite(v))
if not finite:
return 0.0
rank = max(0, min(len(finite) - 1, int(percentile * (len(finite) - 1))))
return finite[rank]
# Node types whose labels we always show in Key mode regardless of degree —
# documents/types are the natural landmarks of the Story view.
_ALWAYS_LABEL_STAGES = frozenset({"document", "type"})
def preprocess(graph_data, schema_data: Optional[Dict[str, Any]] = None) -> PreprocessedGraph:
"""Turn raw ``(nodes_data, edges_data)`` into a fully-enriched snapshot.
Mirrors the data shape the existing ``cognee_network_visualization``
function builds, plus new renderer-facing fields the Story view needs:
``stage``, ``visual_rank``, ``degree``, ``importance``, ``label_priority``,
``provenance`` on nodes, and ``edge_class``, ``bundle_key`` on links.
The original color maps (task/pipeline/node_set/user) and schema graph
are preserved verbatim so any existing renderer code that reads them
continues to work without modification.
"""
nodes_data, edges_data = graph_data
# ── Nodes pass 1: normalize, color, name, stage ────────────────────────
nodes: List[Dict[str, Any]] = []
node_ids_seen: set = set()
has_meaningful_rank = False
for node_id, node_info in nodes_data:
node_info = dict(node_info) if not isinstance(node_info, dict) else node_info.copy()
sid = str(node_id)
node_info["id"] = sid
node_info["color"] = _TYPE_COLOR_MAP.get(
node_info.get("type", "default"), _UNKNOWN_TYPE_COLOR
)
if node_info.get("ontology_valid") is True:
node_info["color"] = _ONTOLOGY_VALID_COLOR
# Distilled session-learning nodes get a ring overlay in the renderer
# (type fill is preserved) so the self-improvement feature is visible.
node_info["is_memory_learning"] = is_distilled_learning_node(node_info)
raw_name = node_info.get("name")
node_info["name"] = derive_node_name(node_info, node_id)
# Unnamed nodes (UUID/hash names) must never become Key-mode label
# landmarks; pass 3 reads this flag.
node_info["is_unnamed"] = looks_like_identifier(raw_name) or node_info["name"].startswith(
"Unnamed "
)
created_at = node_info.get("created_at")
if isinstance(created_at, int) and not isinstance(created_at, bool):
# Preserve the creation timestamp (epoch ms) for the Memory
# timeline before the raw audit columns are dropped below.
node_info["t_created"] = created_at
node_info.pop("updated_at", None)
node_info.pop("created_at", None)
stage = _stage_for_node(node_info)
node_info["stage"] = stage
node_info["visual_rank"] = _visual_rank(node_info, stage)
node_info["degree"] = 0 # filled in pass 2
node_info["importance"] = 0.0 # filled in pass 2
node_info["label_priority"] = False # filled in pass 3
prov = _compact_provenance(node_info)
if prov is not None:
node_info["provenance"] = prov
rank = node_info.get("topological_rank")
if (isinstance(rank, int) or isinstance(rank, float)) and rank not in (None, 0):
has_meaningful_rank = True
nodes.append(node_info)
node_ids_seen.add(sid)
nodes_by_id = {n["id"]: n for n in nodes}
# ── Links pass: normalize, classify, weight, bundle ────────────────────
links: List[Dict[str, Any]] = []
edge_class_counts: Counter = Counter()
bundle_counts: Counter = Counter()
degree_counter: Counter = Counter()
for edge in edges_data:
# graph adapter may return 3- or 4-tuples; tolerate both
if len(edge) >= 4:
source, target, relation, edge_info = edge[0], edge[1], edge[2], edge[3]
else:
source, target, relation = edge[0], edge[1], edge[2]
edge_info = {}
source = str(source)
target = str(target)
all_weights: Dict[str, float] = {}
primary_weight: Optional[float] = None
if edge_info:
if "weight" in edge_info:
all_weights["default"] = edge_info["weight"]
primary_weight = edge_info["weight"]
if "weights" in edge_info and isinstance(edge_info["weights"], dict):
all_weights.update(edge_info["weights"])
if primary_weight is None and edge_info["weights"]:
primary_weight = next(iter(edge_info["weights"].values()))
for key, value in edge_info.items():
if key.startswith("weight_") and isinstance(value, (int, float)):
all_weights[key[7:]] = value
edge_cls = _edge_class(relation, edge_info)
source_stage = nodes_by_id.get(source, {}).get("stage", "other")
target_stage = nodes_by_id.get(target, {}).get("stage", "other")
bundle_key = _bundle_key(source_stage, target_stage, edge_cls, relation)
edge_class_counts[edge_cls] += 1
bundle_counts[bundle_key] += 1
degree_counter[source] += 1
degree_counter[target] += 1
links.append(
{
"source": source,
"target": target,
"relation": relation,
"weight": primary_weight,
"all_weights": all_weights,
"relationship_type": (edge_info.get("relationship_type") if edge_info else None),
"edge_info": edge_info if edge_info else {},
"edge_class": edge_cls,
"bundle_key": bundle_key,
"source_stage": source_stage,
"target_stage": target_stage,
}
)
# ── Nodes pass 2: degree, importance ───────────────────────────────────
for node in nodes:
deg = degree_counter.get(node["id"], 0)
node["degree"] = deg
# log-scaled, capped — importance is a normalized 0..1 visual weight,
# not a semantic score, so the renderer can size labels/halos cleanly.
node["importance"] = math.log1p(deg) / math.log1p(
max(1, max(degree_counter.values() or [1]))
)
# ── Nodes pass 3: label priority budget ────────────────────────────────
importances = [n["importance"] for n in nodes]
threshold = _label_priority_threshold(importances, percentile=0.75)
for node in nodes:
if node.get("is_unnamed"):
# A placeholder name is never worth a Key-mode label slot.
node["label_priority"] = False
elif node["stage"] in _ALWAYS_LABEL_STAGES:
node["label_priority"] = True
elif node["importance"] >= threshold and threshold > 0:
node["label_priority"] = True
else:
node["label_priority"] = False
# ── Color maps (verbatim shape from the original orchestrator) ─────────
color_maps = {
"task": generate_provenance_colors([n.get("source_task") for n in nodes]),
"pipeline": generate_provenance_colors([n.get("source_pipeline") for n in nodes]),
"node_set": generate_provenance_colors([n.get("source_node_set") for n in nodes]),
"user": generate_provenance_colors([n.get("source_user") for n in nodes]),
}
# Pin stable, meaningful colors for self-improvement node sets, overriding the
# hue-rotation only for sets actually present in this graph.
for set_name, color in _MEMORY_NODESET_COLORS.items():
if set_name in color_maps["node_set"]:
color_maps["node_set"][set_name] = color
schema_graph = extract_schema_graph_data(nodes, links)
build_operation_layer(schema_graph, nodes, links)
# Stages present in the graph, in canonical left-to-right order
present_stages = [s for s in STAGE_ORDER if any(n["stage"] == s for n in nodes)]
provenance_index = {n["id"]: n["provenance"] for n in nodes if n.get("provenance")}
return PreprocessedGraph(
nodes=nodes,
links=links,
color_maps=color_maps,
schema_graph=schema_graph,
schema_data=schema_data,
pipeline_stages=present_stages,
edge_classes=dict(edge_class_counts),
bundles=dict(bundle_counts),
provenance_index=provenance_index,
has_meaningful_topological_rank=has_meaningful_rank,
memory_map=_build_memory_map(nodes, links),
)