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")