add lesson_graph.py
Browse files- lesson_graph.py +243 -0
lesson_graph.py
ADDED
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import networkx as nx
|
2 |
+
import matplotlib.pyplot as plt
|
3 |
+
from io import BytesIO
|
4 |
+
from PIL import Image
|
5 |
+
import matplotlib.patches as mpatches
|
6 |
+
import mplcursors
|
7 |
+
import json
|
8 |
+
from typing import Dict, Tuple, Any, NamedTuple, Optional
|
9 |
+
from enum import Enum
|
10 |
+
|
11 |
+
class NodeType(Enum):
|
12 |
+
USER = "User"
|
13 |
+
SUBJECT = "Subject"
|
14 |
+
GRADE_LEVEL = "Grade Level"
|
15 |
+
LEARNING_OBJECTIVE = "Learning Objective"
|
16 |
+
ACTIVITY = "Activity"
|
17 |
+
ASSESSMENT = "Assessment"
|
18 |
+
RESOURCE = "Resource"
|
19 |
+
SCHOOL_BOARD = "School Board"
|
20 |
+
|
21 |
+
class ResetState(NamedTuple):
|
22 |
+
teacher_name: str
|
23 |
+
subject: str
|
24 |
+
grade_level: str
|
25 |
+
learning_objective: str
|
26 |
+
activity: str
|
27 |
+
assessment: str
|
28 |
+
resource: str
|
29 |
+
school_board: str
|
30 |
+
message: str
|
31 |
+
|
32 |
+
class LessonGraph:
|
33 |
+
INITIAL_STATE: Dict[str, str] = {
|
34 |
+
"teacher_name": "",
|
35 |
+
"subject": "",
|
36 |
+
"grade_level": "",
|
37 |
+
"learning_objective": "",
|
38 |
+
"activity": "",
|
39 |
+
"assessment": "",
|
40 |
+
"resource": "",
|
41 |
+
"school_board": ""
|
42 |
+
}
|
43 |
+
|
44 |
+
REQUIRED_FIELDS = ["teacher_name", "subject", "grade_level"]
|
45 |
+
|
46 |
+
COLOR_MAP: Dict[NodeType, str] = {
|
47 |
+
NodeType.USER: "#FF9999",
|
48 |
+
NodeType.SUBJECT: "#66B2FF",
|
49 |
+
NodeType.GRADE_LEVEL: "#99FF99",
|
50 |
+
NodeType.LEARNING_OBJECTIVE: "#FFCC99",
|
51 |
+
NodeType.ACTIVITY: "#FF99FF",
|
52 |
+
NodeType.ASSESSMENT: "#FFFF99",
|
53 |
+
NodeType.RESOURCE: "#99FFFF",
|
54 |
+
NodeType.SCHOOL_BOARD: "#CCCCCC"
|
55 |
+
}
|
56 |
+
|
57 |
+
def __init__(self):
|
58 |
+
self.graph = nx.DiGraph()
|
59 |
+
self.inputs = self.INITIAL_STATE.copy()
|
60 |
+
|
61 |
+
def validate_required_fields(self):
|
62 |
+
"""
|
63 |
+
Validate that all required fields are filled.
|
64 |
+
Raises a ValueError if any required field is empty.
|
65 |
+
"""
|
66 |
+
missing_fields = [field for field in self.REQUIRED_FIELDS if not self.inputs.get(field)]
|
67 |
+
if missing_fields:
|
68 |
+
raise ValueError(f"The following required fields are missing: {', '.join(missing_fields)}")
|
69 |
+
|
70 |
+
def add_lesson_plan(self, **kwargs) -> Tuple[str, Image.Image]:
|
71 |
+
"""
|
72 |
+
Add nodes and edges to the lesson plan graph for the given inputs.
|
73 |
+
Returns a search string and the graph image.
|
74 |
+
"""
|
75 |
+
self.graph.clear() # Clear previous graph
|
76 |
+
self.inputs.update(kwargs)
|
77 |
+
|
78 |
+
# Validate only required fields
|
79 |
+
self.validate_required_fields()
|
80 |
+
|
81 |
+
# Define node details and add them to the graph
|
82 |
+
nodes = {
|
83 |
+
self.inputs["teacher_name"]: {"type": NodeType.USER, "role": "Teacher"},
|
84 |
+
self.inputs["subject"]: {"type": NodeType.SUBJECT, "description": "Core subject area"},
|
85 |
+
self.inputs["grade_level"]: {"type": NodeType.GRADE_LEVEL, "description": "Target grade for the lesson"},
|
86 |
+
}
|
87 |
+
|
88 |
+
# Add optional nodes if they exist
|
89 |
+
optional_nodes = {
|
90 |
+
"learning_objective": NodeType.LEARNING_OBJECTIVE,
|
91 |
+
"activity": NodeType.ACTIVITY,
|
92 |
+
"assessment": NodeType.ASSESSMENT,
|
93 |
+
"resource": NodeType.RESOURCE,
|
94 |
+
"school_board": NodeType.SCHOOL_BOARD
|
95 |
+
}
|
96 |
+
|
97 |
+
for field, node_type in optional_nodes.items():
|
98 |
+
if self.inputs.get(field):
|
99 |
+
nodes[self.inputs[field]] = {"type": node_type, "description": f"Optional {node_type.value}"}
|
100 |
+
|
101 |
+
# Add nodes to the graph
|
102 |
+
for node, attributes in nodes.items():
|
103 |
+
self.graph.add_node(node, **attributes)
|
104 |
+
|
105 |
+
# Define the relationships between nodes
|
106 |
+
edges = [
|
107 |
+
(self.inputs["teacher_name"], self.inputs["subject"], {"relationship": "TEACHES"}),
|
108 |
+
(self.inputs["subject"], self.inputs["grade_level"], {"relationship": "HAS_GRADE"})
|
109 |
+
]
|
110 |
+
|
111 |
+
# Add optional edges
|
112 |
+
if self.inputs.get("learning_objective"):
|
113 |
+
edges.extend([
|
114 |
+
(self.inputs["subject"], self.inputs["learning_objective"], {"relationship": "COVERS"}),
|
115 |
+
(self.inputs["learning_objective"], self.inputs["school_board"], {"relationship": "ALIGNS_WITH"}) if self.inputs.get("school_board") else None
|
116 |
+
])
|
117 |
+
|
118 |
+
if self.inputs.get("activity") and self.inputs.get("learning_objective"):
|
119 |
+
edges.append((self.inputs["activity"], self.inputs["learning_objective"], {"relationship": "ACHIEVES"}))
|
120 |
+
|
121 |
+
if self.inputs.get("activity") and self.inputs.get("resource"):
|
122 |
+
edges.append((self.inputs["activity"], self.inputs["resource"], {"relationship": "REQUIRES"}))
|
123 |
+
|
124 |
+
if self.inputs.get("learning_objective") and self.inputs.get("assessment"):
|
125 |
+
edges.append((self.inputs["learning_objective"], self.inputs["assessment"], {"relationship": "EVALUATED_BY"}))
|
126 |
+
|
127 |
+
if self.inputs.get("school_board"):
|
128 |
+
edges.append((self.inputs["teacher_name"], self.inputs["school_board"], {"relationship": "BELONGS_TO"}))
|
129 |
+
|
130 |
+
# Remove None entries from edges list
|
131 |
+
edges = [edge for edge in edges if edge is not None]
|
132 |
+
self.graph.add_edges_from(edges)
|
133 |
+
|
134 |
+
# Generate the search string for content discovery
|
135 |
+
search_string = f"{self.inputs['subject']} {self.inputs['grade_level']} {self.inputs.get('learning_objective', '')} {self.inputs.get('activity', '')} {self.inputs.get('resource', '')}".strip()
|
136 |
+
|
137 |
+
# Get the graph image
|
138 |
+
image = self.draw_graph()
|
139 |
+
|
140 |
+
# Return the search string and the graph image
|
141 |
+
return search_string, image
|
142 |
+
|
143 |
+
def draw_graph(self) -> Image.Image:
|
144 |
+
"""
|
145 |
+
Visualize the graph using Matplotlib, handling layout, labels, and interactivity.
|
146 |
+
"""
|
147 |
+
fig, ax = plt.subplots(figsize=(14, 10))
|
148 |
+
pos = nx.spring_layout(self.graph, k=0.9, iterations=50)
|
149 |
+
|
150 |
+
self._draw_nodes(ax, pos)
|
151 |
+
self._draw_edges(ax, pos)
|
152 |
+
self._add_legend(ax)
|
153 |
+
|
154 |
+
plt.title("Your Educational Landscape", fontsize=16)
|
155 |
+
plt.axis('off')
|
156 |
+
plt.tight_layout()
|
157 |
+
|
158 |
+
self._add_interactivity()
|
159 |
+
|
160 |
+
# Save the plot to a BytesIO object
|
161 |
+
buf = BytesIO()
|
162 |
+
plt.savefig(buf, format="png", dpi=300, bbox_inches="tight")
|
163 |
+
buf.seek(0)
|
164 |
+
plt.close(fig)
|
165 |
+
|
166 |
+
return Image.open(buf)
|
167 |
+
|
168 |
+
def _draw_nodes(self, ax, pos):
|
169 |
+
node_colors = [self.COLOR_MAP[self.graph.nodes[node]['type']] for node in self.graph.nodes()]
|
170 |
+
nx.draw_networkx_nodes(self.graph, pos, node_color=node_colors, node_size=3000, alpha=0.8, ax=ax)
|
171 |
+
nx.draw_networkx_labels(self.graph, pos, font_size=10, font_weight="bold", ax=ax)
|
172 |
+
|
173 |
+
def _draw_edges(self, ax, pos):
|
174 |
+
nx.draw_networkx_edges(self.graph, pos, edge_color='gray', arrows=True, arrowsize=20, ax=ax)
|
175 |
+
edge_labels = nx.get_edge_attributes(self.graph, 'relationship')
|
176 |
+
nx.draw_networkx_edge_labels(self.graph, pos, edge_labels=edge_labels, font_size=8, ax=ax)
|
177 |
+
|
178 |
+
def _add_legend(self, ax):
|
179 |
+
legend_elements = [mpatches.Patch(color=color, label=node_type.value) for node_type, color in self.COLOR_MAP.items()]
|
180 |
+
ax.legend(handles=legend_elements, loc='upper left', bbox_to_anchor=(1, 1), title="Node Types")
|
181 |
+
|
182 |
+
def _add_interactivity(self):
|
183 |
+
cursor = mplcursors.cursor(hover=True)
|
184 |
+
@cursor.connect("add")
|
185 |
+
def on_add(sel):
|
186 |
+
node = list(self.graph.nodes())[sel.target.index]
|
187 |
+
node_data = self.graph.nodes[node]
|
188 |
+
sel.annotation.set_text(f"Node: {node}\nType: {node_data['type'].value}\n{node_data.get('description', '')}")
|
189 |
+
|
190 |
+
def reset_state(self) -> ResetState:
|
191 |
+
"""
|
192 |
+
Resets all input states to their default values and clears the graph.
|
193 |
+
Returns a named tuple of the cleared input values and a status message.
|
194 |
+
"""
|
195 |
+
self.inputs = self.INITIAL_STATE.copy()
|
196 |
+
self.graph.clear()
|
197 |
+
return ResetState(**self.inputs, message="Landscape cleared. You can start a new lesson plan.")
|
198 |
+
|
199 |
+
def graph_to_json(self) -> str:
|
200 |
+
"""
|
201 |
+
Converts the current lesson plan graph into a JSON string format and returns the result.
|
202 |
+
"""
|
203 |
+
try:
|
204 |
+
graph_data = {
|
205 |
+
"nodes": [
|
206 |
+
{
|
207 |
+
"id": node,
|
208 |
+
"type": self.graph.nodes[node]["type"].value,
|
209 |
+
"description": self.graph.nodes[node].get("description", "")
|
210 |
+
}
|
211 |
+
for node in self.graph.nodes()
|
212 |
+
],
|
213 |
+
"edges": [
|
214 |
+
{
|
215 |
+
"source": u,
|
216 |
+
"target": v,
|
217 |
+
"relationship": self.graph.edges[u, v]["relationship"]
|
218 |
+
}
|
219 |
+
for u, v in self.graph.edges()
|
220 |
+
]
|
221 |
+
}
|
222 |
+
return json.dumps(graph_data, indent=4)
|
223 |
+
except (KeyError, TypeError) as e:
|
224 |
+
return f"An error occurred while converting the graph to JSON: {str(e)}"
|
225 |
+
|
226 |
+
def process_inputs(self, *args) -> Tuple[str, Optional[Image.Image]]:
|
227 |
+
"""
|
228 |
+
Process input arguments and create a lesson plan.
|
229 |
+
Returns a tuple of search string and graph image, or error message and None.
|
230 |
+
"""
|
231 |
+
try:
|
232 |
+
self.inputs.update(dict(zip(self.INITIAL_STATE.keys(), args)))
|
233 |
+
return self.add_lesson_plan(**self.inputs)
|
234 |
+
except ValueError as e:
|
235 |
+
return str(e), None
|
236 |
+
|
237 |
+
@property
|
238 |
+
def is_empty(self) -> bool:
|
239 |
+
"""Check if all inputs are empty."""
|
240 |
+
return all(value == "" for value in self.inputs.values())
|
241 |
+
|
242 |
+
def __repr__(self) -> str:
|
243 |
+
return f"LessonGraph(inputs={self.inputs}, graph_size={len(self.graph)})"
|