83 lines
3.1 KiB
Python
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")
|