Didactopus/src/didactopus/concept_graph.py

83 lines
3.1 KiB
Python

from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from pathlib import Path
import json
import networkx as nx
REL_PREREQ = "prerequisite"
REL_EQUIVALENT = "equivalent_to"
REL_RELATED = "related_to"
REL_EXTENDS = "extends"
REL_DEPENDS = "depends_on"
@dataclass
class ConceptGraph:
graph: nx.MultiDiGraph = field(default_factory=nx.MultiDiGraph)
def add_concept(self, concept_key: str, metadata: dict[str, Any] | None = None) -> None:
self.graph.add_node(concept_key, **(metadata or {}))
def add_edge(self, source: str, target: str, relation: str) -> None:
self.graph.add_edge(source, target, relation=relation)
def add_prerequisite(self, prereq: str, concept: str) -> None:
self.add_edge(prereq, concept, REL_PREREQ)
def add_cross_link(self, source: str, target: str, relation: str) -> None:
self.add_edge(source, target, relation)
def prerequisite_subgraph(self) -> nx.DiGraph:
g = nx.DiGraph()
for node, data in self.graph.nodes(data=True):
g.add_node(node, **data)
for u, v, data in self.graph.edges(data=True):
if data.get("relation") == REL_PREREQ:
g.add_edge(u, v)
return g
def prerequisite_chain(self, concept: str) -> list[str]:
return list(nx.ancestors(self.prerequisite_subgraph(), concept))
def curriculum_path_to_target(self, mastered: set[str], target: str) -> list[str]:
pg = self.prerequisite_subgraph()
needed = set(nx.ancestors(pg, target)) | {target}
ordered = [n for n in nx.topological_sort(pg) if n in needed]
return [n for n in ordered if n not in mastered]
def ready_concepts(self, mastered: set[str]) -> list[str]:
pg = self.prerequisite_subgraph()
ready = []
for node in pg.nodes:
if node in mastered:
continue
if set(pg.predecessors(node)).issubset(mastered):
ready.append(node)
return ready
def related_concepts(self, concept: str, relation_types: set[str] | None = None) -> list[str]:
relation_types = relation_types or {REL_EQUIVALENT, REL_RELATED, REL_EXTENDS, REL_DEPENDS}
found = []
for _, v, data in self.graph.out_edges(concept, data=True):
if data.get("relation") in relation_types:
found.append(v)
return found
def export_graphviz(self, path: str) -> None:
lines = ["digraph Didactopus {"]
for node in self.graph.nodes:
lines.append(f' "{node}";')
for u, v, data in self.graph.edges(data=True):
lines.append(f' "{u}" -> "{v}" [label="{data.get("relation", "")}"];')
lines.append("}")
Path(path).write_text("\n".join(lines), encoding="utf-8")
def export_cytoscape_json(self, path: str) -> None:
data = {
"nodes": [{"data": {"id": n, **attrs}} for n, attrs in self.graph.nodes(data=True)],
"edges": [{"data": {"source": u, "target": v, **attrs}} for u, v, attrs in self.graph.edges(data=True)],
}
Path(path).write_text(json.dumps(data, indent=2), encoding="utf-8")