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
591 lines
24 KiB
Python
591 lines
24 KiB
Python
"""Memory provenance projection.
|
|
|
|
Projects cognee's *relational* actor/ownership/session metadata
|
|
(Tenant → User → Agent → Dataset/"brain" → Data/file, plus agent read/write
|
|
access and agent-written Sessions) into a single ``(nodes, edges)`` graph in
|
|
the exact shape ``GraphDBInterface.get_graph_data()`` returns — so the schema
|
|
view can render the full ownership & data-flow story.
|
|
|
|
The actor layer is read purely from the relational database (no knowledge-graph
|
|
DB and no LLM required), so it works even when the graph backend is unavailable.
|
|
When ``include_memory=True`` the extracted memory (entities/relationships) is
|
|
folded in from the relational ``nodes``/``edges`` tables and linked back to the
|
|
files it was extracted from.
|
|
|
|
Two entry points:
|
|
* ``build_provenance_graph(...)`` — pure, side-effect-free assembly from
|
|
plain records (unit-testable).
|
|
* ``get_memory_provenance_graph(...)`` — async reader that pulls live data
|
|
from the relational layer and calls the builder.
|
|
"""
|
|
|
|
from typing import Any, Dict, List, NamedTuple, Optional, Tuple, TypedDict, cast
|
|
|
|
from cognee.shared.logging_utils import get_logger
|
|
|
|
logger = get_logger()
|
|
|
|
|
|
class Node(NamedTuple):
|
|
"""A graph node in ``get_graph_data()`` shape: ``(id, properties)``.
|
|
|
|
A ``NamedTuple`` (not a dataclass) so the projection stays interchangeable
|
|
with the raw ``GraphDBInterface.get_graph_data()`` output the renderer and
|
|
preprocessor already consume, while still naming what each position means.
|
|
"""
|
|
|
|
id: str
|
|
properties: Dict[str, Any]
|
|
|
|
|
|
class EdgeData(NamedTuple):
|
|
"""A graph edge in ``get_graph_data()`` shape: ``(source, target, relation, properties)``."""
|
|
|
|
source: str
|
|
target: str
|
|
relation: str
|
|
properties: Dict[str, Any]
|
|
|
|
|
|
# ── Input record shapes (relational projection inputs) ───────────────────────
|
|
# TypedDicts make the expected keys explicit. ``id`` is required on each record;
|
|
# the remaining keys are optional (read via ``.get(...)``), hence ``total=False``.
|
|
|
|
|
|
class _HasId(TypedDict):
|
|
id: str
|
|
|
|
|
|
class TenantRecord(_HasId, total=False):
|
|
name: Optional[str]
|
|
|
|
|
|
class UserRecord(_HasId, total=False):
|
|
name: Optional[str]
|
|
tenant_ids: List[str]
|
|
|
|
|
|
class DatasetRecord(_HasId, total=False):
|
|
name: Optional[str]
|
|
owner_id: Optional[str]
|
|
tenant_id: Optional[str]
|
|
|
|
|
|
class FileRecord(_HasId, total=False):
|
|
name: Optional[str]
|
|
dataset_ids: List[str]
|
|
dataset_name: Optional[str]
|
|
|
|
|
|
class AgentDatasetRef(TypedDict, total=False):
|
|
dataset_id: str
|
|
role: str # "read" | "read_write"
|
|
|
|
|
|
class AgentRecord(_HasId, total=False):
|
|
name: Optional[str]
|
|
user_id: Optional[str]
|
|
session_id: Optional[str]
|
|
datasets: List[AgentDatasetRef]
|
|
|
|
|
|
class SessionRecord(_HasId, total=False):
|
|
name: Optional[str]
|
|
user_id: Optional[str]
|
|
dataset_id: Optional[str]
|
|
agent_id: Optional[str]
|
|
|
|
|
|
class MemoryPayload(TypedDict, total=False):
|
|
nodes: List[Tuple[str, Dict[str, Any]]]
|
|
edges: List[Tuple[str, str, str, Dict[str, Any]]]
|
|
links: List[Dict[str, Any]]
|
|
|
|
|
|
def build_provenance_graph(
|
|
*,
|
|
tenants: Optional[List[TenantRecord]] = None,
|
|
users: Optional[List[UserRecord]] = None,
|
|
datasets: Optional[List[DatasetRecord]] = None,
|
|
files: Optional[List[FileRecord]] = None,
|
|
agents: Optional[List[AgentRecord]] = None,
|
|
sessions: Optional[List[SessionRecord]] = None,
|
|
memory: Optional[MemoryPayload] = None,
|
|
) -> Tuple[List[Node], List[EdgeData]]:
|
|
"""Assemble actor/ownership/session records into a ``(nodes, edges)`` graph.
|
|
|
|
Record shapes (all ids are strings):
|
|
tenants: {"id", "name"}
|
|
users: {"id", "name", "tenant_ids": [..]}
|
|
datasets: {"id", "name", "owner_id", "tenant_id"}
|
|
files: {"id", "name", "dataset_ids": [..], "dataset_name"?}
|
|
agents: {"id", "name", "user_id", "session_id"?,
|
|
"datasets": [{"dataset_id", "role": "read"|"read_write"}]}
|
|
sessions: {"id", "name", "user_id", "dataset_id", "agent_id"?}
|
|
memory: optional {"nodes": [(id, props)], "edges": [(s, t, rel, props)],
|
|
"links": [{"node_id", "data_id", "dataset_id"}]}
|
|
|
|
Node ids are namespaced (``user:<id>`` etc.) so the actor layers never
|
|
collide with each other or with raw memory-node ids.
|
|
"""
|
|
tenants = tenants or []
|
|
users = users or []
|
|
datasets = datasets or []
|
|
files = files or []
|
|
agents = agents or []
|
|
sessions = sessions or []
|
|
|
|
nodes: Dict[str, Node] = {}
|
|
edges: List[EdgeData] = []
|
|
seen_edges = set()
|
|
|
|
def add_node(node_id: str, node_type: str, name: str, **extra) -> None:
|
|
if node_id not in nodes:
|
|
props = {"type": node_type, "name": name}
|
|
props.update({k: v for k, v in extra.items() if v is not None})
|
|
nodes[node_id] = Node(node_id, props)
|
|
|
|
def add_edge(source: str, target: str, relation: str) -> None:
|
|
if source in nodes and target in nodes:
|
|
key = (source, target, relation)
|
|
if key not in seen_edges:
|
|
seen_edges.add(key)
|
|
edges.append(EdgeData(source, target, relation, {}))
|
|
|
|
# ── Actor / ownership nodes ──────────────────────────────────────
|
|
for tenant in tenants:
|
|
add_node(f"tenant:{tenant['id']}", "Tenant", tenant.get("name") or "Tenant")
|
|
for user in users:
|
|
add_node(f"user:{user['id']}", "User", user.get("name") or str(user["id"]))
|
|
for dataset in datasets:
|
|
add_node(f"dataset:{dataset['id']}", "Dataset", dataset.get("name") or "Dataset")
|
|
for file in files:
|
|
add_node(
|
|
f"file:{file['id']}",
|
|
"TextDocument",
|
|
file.get("name") or "file",
|
|
source_node_set=file.get("dataset_name"),
|
|
)
|
|
for agent in agents:
|
|
add_node(f"agent:{agent['id']}", "Agent", agent.get("name") or str(agent["id"]))
|
|
for sess in sessions:
|
|
add_node(f"session:{sess['id']}", "Session", sess.get("name") or str(sess["id"]))
|
|
|
|
# ── Edges ────────────────────────────────────────────────────────
|
|
for user in users:
|
|
for tenant_id in user.get("tenant_ids") or []:
|
|
add_edge(f"tenant:{tenant_id}", f"user:{user['id']}", "has_member")
|
|
|
|
for dataset in datasets:
|
|
owner_id = dataset.get("owner_id")
|
|
if owner_id:
|
|
add_edge(f"user:{owner_id}", f"dataset:{dataset['id']}", "owns")
|
|
|
|
for file in files:
|
|
for dataset_id in file.get("dataset_ids") or []:
|
|
add_edge(f"dataset:{dataset_id}", f"file:{file['id']}", "contains")
|
|
|
|
for agent in agents:
|
|
agent_user_id = agent.get("user_id")
|
|
if agent_user_id:
|
|
add_edge(f"user:{agent_user_id}", f"agent:{agent['id']}", "operates")
|
|
for ref in agent.get("datasets") or []:
|
|
dataset_id = ref.get("dataset_id")
|
|
if not dataset_id:
|
|
continue
|
|
add_edge(f"agent:{agent['id']}", f"dataset:{dataset_id}", "reads")
|
|
if ref.get("role") == "read_write":
|
|
add_edge(f"agent:{agent['id']}", f"dataset:{dataset_id}", "writes")
|
|
agent_session_id = agent.get("session_id")
|
|
if agent_session_id:
|
|
add_edge(f"agent:{agent['id']}", f"session:{agent_session_id}", "wrote")
|
|
|
|
for sess in sessions:
|
|
sess_agent_id = sess.get("agent_id")
|
|
if sess_agent_id:
|
|
add_edge(f"agent:{sess_agent_id}", f"session:{sess['id']}", "wrote")
|
|
sess_dataset_id = sess.get("dataset_id")
|
|
if sess_dataset_id:
|
|
add_edge(f"session:{sess['id']}", f"dataset:{sess_dataset_id}", "recorded_in")
|
|
|
|
# ── Optional memory layer ────────────────────────────────────────
|
|
if memory:
|
|
for node_id, props in memory.get("nodes") or []:
|
|
nid = str(node_id)
|
|
if nid not in nodes:
|
|
nodes[nid] = Node(nid, dict(props))
|
|
for source, target, relation, eprops in memory.get("edges") or []:
|
|
s, t = str(source), str(target)
|
|
if s in nodes and t in nodes:
|
|
key = (s, t, relation)
|
|
if key not in seen_edges:
|
|
seen_edges.add(key)
|
|
edges.append(EdgeData(s, t, relation or "related", dict(eprops or {})))
|
|
for link in memory.get("links") or []:
|
|
file_id = f"file:{link['data_id']}" if link.get("data_id") else None
|
|
node_id = str(link["node_id"]) if link.get("node_id") else None
|
|
if file_id and node_id:
|
|
add_edge(file_id, node_id, "mentions")
|
|
|
|
return list(nodes.values()), edges
|
|
|
|
|
|
# ── Live relational readers ──────────────────────────────────────────────────
|
|
|
|
|
|
async def _read_agents(user_ids: List[str]) -> List[AgentRecord]:
|
|
"""Best-effort enumeration of agent connections (registered + persisted)."""
|
|
from uuid import UUID
|
|
|
|
connections: List[Any] = []
|
|
try:
|
|
from cognee.modules.agents.registry import (
|
|
list_persisted_agent_connections,
|
|
list_registered_agent_connections,
|
|
)
|
|
|
|
try:
|
|
connections += list(list_registered_agent_connections() or [])
|
|
except Exception as error: # pragma: no cover - defensive
|
|
logger.debug(f"registered agent enumeration skipped: {error}")
|
|
try:
|
|
connections += list(
|
|
await list_persisted_agent_connections(
|
|
[UUID(uid) for uid in user_ids], active_only=False
|
|
)
|
|
or []
|
|
)
|
|
except Exception as error: # pragma: no cover - defensive
|
|
logger.debug(f"persisted agent enumeration skipped: {error}")
|
|
except Exception as error: # pragma: no cover - module unavailable
|
|
logger.debug(f"agent registry unavailable: {error}")
|
|
return []
|
|
|
|
agents: List[AgentRecord] = []
|
|
seen = set()
|
|
for conn in connections:
|
|
if conn.id in seen:
|
|
continue
|
|
seen.add(conn.id)
|
|
refs: List[AgentDatasetRef] = [
|
|
{"dataset_id": str(ref.id), "role": ref.role or "read"}
|
|
for ref in (conn.datasets or [])
|
|
if getattr(ref, "id", None)
|
|
]
|
|
agents.append(
|
|
{
|
|
"id": conn.id,
|
|
"name": conn.agent_session_name or conn.id,
|
|
"user_id": str(conn.user_id) if conn.user_id else None,
|
|
"session_id": conn.session_id,
|
|
"datasets": refs,
|
|
}
|
|
)
|
|
return agents
|
|
|
|
|
|
async def _read_sessions(user_ids: List[str], agents: List[AgentRecord]) -> List[SessionRecord]:
|
|
"""Best-effort enumeration of session records."""
|
|
from uuid import UUID
|
|
|
|
agent_by_session = {sid: a["id"] for a in agents if (sid := a.get("session_id"))}
|
|
sessions: List[SessionRecord] = []
|
|
try:
|
|
from cognee.modules.session_lifecycle.metrics import list_session_rows
|
|
|
|
page = await list_session_rows(user_ids=[UUID(uid) for uid in user_ids], limit=10000)
|
|
for row in getattr(page, "sessions", None) or []:
|
|
record = getattr(row, "record", row)
|
|
session_id = record.session_id
|
|
sessions.append(
|
|
{
|
|
"id": session_id,
|
|
"name": session_id,
|
|
"user_id": str(record.user_id) if record.user_id else None,
|
|
"dataset_id": str(record.dataset_id) if record.dataset_id else None,
|
|
"agent_id": agent_by_session.get(session_id),
|
|
}
|
|
)
|
|
except Exception as error: # pragma: no cover - defensive
|
|
logger.debug(f"session enumeration skipped: {error}")
|
|
return sessions
|
|
|
|
|
|
async def _read_memory_relational(
|
|
limit: int = 5000, dataset_ids: Optional[List[str]] = None
|
|
) -> Optional[MemoryPayload]:
|
|
"""Read extracted memory from the relational ``nodes``/``edges`` tables.
|
|
|
|
Avoids the knowledge-graph backend entirely, so it works when that backend
|
|
is unavailable. Returns None when there is no memory to show.
|
|
|
|
When ``dataset_ids`` is provided, only memory nodes belonging to those
|
|
datasets are returned, and edges are kept only when BOTH endpoints are
|
|
in-scope — so a scoped provenance graph never folds in another tenant's
|
|
extracted memory.
|
|
"""
|
|
try:
|
|
from sqlalchemy import select
|
|
|
|
from cognee.infrastructure.databases.relational import get_relational_engine
|
|
from cognee.modules.graph.models.Node import Node as NodeRow
|
|
from cognee.modules.graph.models.Edge import Edge as EdgeRow
|
|
except Exception as error: # pragma: no cover - models unavailable
|
|
logger.debug(f"relational memory models unavailable: {error}")
|
|
return None
|
|
|
|
nodes: List[Tuple[str, Dict[str, Any]]] = []
|
|
edges: List[Tuple[str, str, str, Dict[str, Any]]] = []
|
|
links: List[Dict[str, Any]] = []
|
|
node_ids: set = set()
|
|
try:
|
|
db_engine = get_relational_engine()
|
|
async with db_engine.get_async_session() as session:
|
|
node_stmt = select(NodeRow)
|
|
if dataset_ids is not None:
|
|
# Scope to the in-scope datasets. An empty list yields no rows,
|
|
# which is the correct fail-closed behaviour.
|
|
node_stmt = node_stmt.where(NodeRow.dataset_id.in_(dataset_ids))
|
|
for row in (await session.execute(node_stmt.limit(limit))).scalars().all():
|
|
node_id = str(row.id)
|
|
node_ids.add(node_id)
|
|
nodes.append(
|
|
Node(node_id, {"type": row.type or "Node", "name": row.label or str(row.slug)})
|
|
)
|
|
if row.data_id is not None:
|
|
links.append(
|
|
{
|
|
"node_id": node_id,
|
|
"data_id": str(row.data_id),
|
|
"dataset_id": str(row.dataset_id)
|
|
if row.dataset_id is not None
|
|
else None,
|
|
}
|
|
)
|
|
for row in (await session.execute(select(EdgeRow).limit(limit * 4))).scalars().all():
|
|
src, dst = str(row.source_node_id), str(row.destination_node_id)
|
|
if dataset_ids is not None and (src not in node_ids or dst not in node_ids):
|
|
# Drop edges that reach outside the scoped node set.
|
|
continue
|
|
edges.append(EdgeData(src, dst, row.relationship_name or "related", {}))
|
|
except Exception as error: # pragma: no cover - defensive
|
|
logger.debug(f"relational memory read skipped: {error}")
|
|
return None
|
|
|
|
if not nodes:
|
|
return None
|
|
return {"nodes": nodes, "edges": edges, "links": links}
|
|
|
|
|
|
async def _read_memory_graph_provenance(
|
|
limit: int = 5000, dataset_ids: Optional[List[str]] = None
|
|
) -> Optional[MemoryPayload]:
|
|
"""Read extracted memory from graph provenance on ledger-free graphs."""
|
|
if not dataset_ids:
|
|
return None
|
|
|
|
from cognee.infrastructure.databases.provenance import (
|
|
EdgeIdentity,
|
|
get_data_id_from_source_ref_key,
|
|
get_dataset_id_from_source_ref_key,
|
|
)
|
|
from cognee.infrastructure.databases.provenance.markers import (
|
|
stores_provenance_in_graph,
|
|
)
|
|
from cognee.infrastructure.databases.unified import get_unified_engine
|
|
|
|
unified = await get_unified_engine()
|
|
graph = unified.graph
|
|
if not await stores_provenance_in_graph(graph):
|
|
return None
|
|
|
|
refs_by_node: Dict[str, List[str]] = {}
|
|
refs_by_edge: Dict[Any, List[str]] = {}
|
|
for dataset_id in dataset_ids:
|
|
for node_id, refs in (await graph.find_node_source_refs_by_dataset(dataset_id)).items():
|
|
refs_by_node.setdefault(str(node_id), []).extend(refs)
|
|
for edge, refs in (await graph.find_edge_source_refs_by_dataset(dataset_id)).items():
|
|
refs_by_edge.setdefault(edge, []).extend(refs)
|
|
|
|
graph_nodes, graph_edges = await graph.get_graph_data()
|
|
|
|
node_ids = set(refs_by_node)
|
|
node_ids.update(edge.source_id for edge in refs_by_edge)
|
|
node_ids.update(edge.target_id for edge in refs_by_edge)
|
|
nodes_by_id = {str(node_id): props for node_id, props in graph_nodes}
|
|
|
|
nodes: List[Tuple[str, Dict[str, Any]]] = []
|
|
for node_id in sorted(node_ids)[:limit]:
|
|
props = nodes_by_id.get(node_id)
|
|
if props is not None:
|
|
nodes.append(Node(node_id, dict(props)))
|
|
|
|
edges: List[Tuple[str, str, str, Dict[str, Any]]] = []
|
|
edge_ref_keys = set(refs_by_edge)
|
|
for source, target, relation, props in graph_edges:
|
|
edge = EdgeIdentity(str(source), str(target), str(relation))
|
|
if edge not in edge_ref_keys:
|
|
continue
|
|
if str(source) not in node_ids or str(target) not in node_ids:
|
|
continue
|
|
edges.append(EdgeData(str(source), str(target), relation or "related", dict(props or {})))
|
|
if len(edges) >= limit * 4:
|
|
break
|
|
|
|
links: List[Dict[str, Any]] = []
|
|
for node_id, refs in refs_by_node.items():
|
|
for source_ref_key in refs:
|
|
links.append(
|
|
{
|
|
"node_id": node_id,
|
|
"data_id": str(get_data_id_from_source_ref_key(source_ref_key)),
|
|
"dataset_id": str(get_dataset_id_from_source_ref_key(source_ref_key)),
|
|
}
|
|
)
|
|
|
|
if not nodes:
|
|
return None
|
|
return {"nodes": nodes, "edges": edges, "links": links}
|
|
|
|
|
|
async def get_memory_provenance_graph(
|
|
include_memory: bool = False,
|
|
scope_tenant_ids: Optional[List[Any]] = None,
|
|
scope_user_ids: Optional[List[Any]] = None,
|
|
) -> Tuple[List[Node], List[EdgeData]]:
|
|
"""Read live relational data and project it into a provenance ``(nodes, edges)``.
|
|
|
|
Args:
|
|
include_memory: when True, fold in the extracted memory from the
|
|
relational ``nodes``/``edges`` tables and link it to source files.
|
|
scope_tenant_ids: when set, restrict the graph to these tenants (and the
|
|
users/datasets/agents/sessions/memory within them). REQUIRED in
|
|
multi-tenant deployments — without a scope this reads EVERY tenant's
|
|
actors, datasets and files, leaking data across tenants.
|
|
scope_user_ids: alternative scope used when there is no tenant context
|
|
(single-user/OSS installs): restrict to these users and what they own.
|
|
When neither scope is given the read is global — the OSS local default,
|
|
where the single user owns everything.
|
|
"""
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import joinedload, selectinload
|
|
|
|
from cognee.infrastructure.databases.relational import get_relational_engine
|
|
from cognee.modules.data.models import Dataset
|
|
from cognee.modules.users.models import Tenant, User
|
|
|
|
scoped = scope_tenant_ids is not None or scope_user_ids is not None
|
|
|
|
tenants: List[Dict[str, Any]] = []
|
|
users: List[Dict[str, Any]] = []
|
|
datasets: List[Dict[str, Any]] = []
|
|
files: Dict[str, Dict[str, Any]] = {}
|
|
|
|
db_engine = get_relational_engine()
|
|
async with db_engine.get_async_session() as session:
|
|
tenant_stmt = select(Tenant)
|
|
if scope_tenant_ids is not None:
|
|
tenant_stmt = tenant_stmt.where(Tenant.id.in_(scope_tenant_ids))
|
|
for tenant in (await session.execute(tenant_stmt)).scalars().all():
|
|
tenants.append({"id": str(tenant.id), "name": tenant.name})
|
|
|
|
user_stmt = select(User).options(selectinload(User.tenants))
|
|
if scope_tenant_ids is not None:
|
|
user_stmt = user_stmt.where(User.tenant_id.in_(scope_tenant_ids))
|
|
elif scope_user_ids is not None:
|
|
user_stmt = user_stmt.where(User.id.in_(scope_user_ids))
|
|
user_rows = (await session.execute(user_stmt)).scalars().all()
|
|
for user in user_rows:
|
|
tenant_ids = [str(t.id) for t in (user.tenants or [])]
|
|
if getattr(user, "tenant_id", None):
|
|
tenant_ids.append(str(user.tenant_id))
|
|
users.append(
|
|
{
|
|
"id": str(user.id),
|
|
"name": getattr(user, "name", None) or f"user:{str(user.id)[:8]}",
|
|
"tenant_ids": sorted(set(tenant_ids)),
|
|
}
|
|
)
|
|
|
|
dataset_stmt = select(Dataset).options(joinedload(Dataset.data))
|
|
if scope_tenant_ids is not None:
|
|
dataset_stmt = dataset_stmt.where(Dataset.tenant_id.in_(scope_tenant_ids))
|
|
elif scope_user_ids is not None:
|
|
dataset_stmt = dataset_stmt.where(Dataset.owner_id.in_(scope_user_ids))
|
|
dataset_rows = (await session.execute(dataset_stmt)).unique().scalars().all()
|
|
for dataset in dataset_rows:
|
|
datasets.append(
|
|
{
|
|
"id": str(dataset.id),
|
|
"name": dataset.name,
|
|
"owner_id": str(dataset.owner_id) if dataset.owner_id is not None else None,
|
|
"tenant_id": str(dataset.tenant_id) if dataset.tenant_id is not None else None,
|
|
}
|
|
)
|
|
for data in dataset.data or []:
|
|
file_id = str(data.id)
|
|
record = files.setdefault(
|
|
file_id,
|
|
{
|
|
"id": file_id,
|
|
"name": data.name,
|
|
"dataset_ids": [],
|
|
"dataset_name": dataset.name,
|
|
},
|
|
)
|
|
record["dataset_ids"].append(str(dataset.id))
|
|
|
|
user_ids = [u["id"] for u in users]
|
|
dataset_ids = [d["id"] for d in datasets]
|
|
agents = await _read_agents(user_ids)
|
|
if scoped:
|
|
# _read_agents also folds in globally-registered (in-process) agent
|
|
# connections; drop any that don't belong to an in-scope user so the
|
|
# scoped graph can't surface another user's agent.
|
|
allowed_user_ids = set(user_ids)
|
|
agents = [a for a in agents if a.get("user_id") in allowed_user_ids]
|
|
sessions = await _read_sessions(user_ids, agents)
|
|
# Scope memory to the in-scope datasets so it never leaks across tenants.
|
|
memory = None
|
|
if include_memory:
|
|
memory = await _read_memory_graph_provenance(dataset_ids=dataset_ids)
|
|
if memory is None:
|
|
memory = await _read_memory_relational(dataset_ids=dataset_ids if scoped else None)
|
|
|
|
return build_provenance_graph(
|
|
tenants=cast(List[TenantRecord], tenants),
|
|
users=cast(List[UserRecord], users),
|
|
datasets=cast(List[DatasetRecord], datasets),
|
|
files=cast(List[FileRecord], list(files.values())),
|
|
agents=agents,
|
|
sessions=sessions,
|
|
memory=memory,
|
|
)
|
|
|
|
|
|
async def visualize_memory_provenance(
|
|
destination_file_path: Optional[str] = None,
|
|
include_memory: bool = False,
|
|
scope_tenant_ids: Optional[List[Any]] = None,
|
|
scope_user_ids: Optional[List[Any]] = None,
|
|
) -> str:
|
|
"""Render the live memory-provenance graph to a self-contained HTML file.
|
|
|
|
``scope_tenant_ids`` / ``scope_user_ids`` restrict the graph to a tenant or
|
|
user (see ``get_memory_provenance_graph``); pass them in multi-tenant
|
|
deployments to avoid leaking other tenants' data.
|
|
"""
|
|
from cognee.modules.visualization.cognee_network_visualization import (
|
|
cognee_network_visualization,
|
|
)
|
|
|
|
graph_data = await get_memory_provenance_graph(
|
|
include_memory=include_memory,
|
|
scope_tenant_ids=scope_tenant_ids,
|
|
scope_user_ids=scope_user_ids,
|
|
)
|
|
html = await cognee_network_visualization(graph_data, destination_file_path)
|
|
if destination_file_path:
|
|
logger.info(f"Memory provenance visualization saved at: {destination_file_path}")
|
|
return html
|