faaizashiq commited on
Commit
862ac30
Β·
verified Β·
1 Parent(s): f14399e

Update backend/agents/puzzle_agent.py

Browse files
Files changed (1) hide show
  1. backend/agents/puzzle_agent.py +332 -275
backend/agents/puzzle_agent.py CHANGED
@@ -19,7 +19,6 @@ THEME_PALETTES = {
19
  "default": {"wall_color": "#8E24AA", "path_color": "#020817", "accent": "#FF477E"},
20
  }
21
 
22
- # Maps required_step keywords β†’ tile IDs
23
  STEP_TILE_MAP = {
24
  "meet_manager": 8,
25
  "meet_scientist": 8,
@@ -33,81 +32,73 @@ STEP_TILE_MAP = {
33
  "verify_pin": 5,
34
  }
35
 
36
- # ─────────────────────────────────────────────────────────────
37
- # DAA LEARNING CONCEPTS β€” mapped to maze structure types
38
- # ─────────────────────────────────────────────────────────────
39
- DAA_CONCEPTS = [
40
- {
41
- "id": "collect_and_reach",
42
- "title": "Conditional Logic: Collect Before Proceed",
43
- "description": "You CANNOT reach the exit until you collect the required items. This teaches: if-else conditions and pre-conditions in algorithms.",
44
- "maze_type": "imperfect", # multiple paths
45
- "min_dim": 7, # internal dim β†’ 15x15 grid
46
- "max_dim": 11, # β†’ 23x23 grid
47
- "force_npc": True,
48
- "force_item": True,
49
- },
50
- {
51
- "id": "shortest_path",
52
- "title": "BFS: Find the Shortest Path",
53
- "description": "Multiple routes exist. Explore carefully β€” some paths are dead ends. The shortest route wins. This teaches: BFS, graph traversal.",
54
- "maze_type": "imperfect", # extra walls removed = multiple routes
55
- "min_dim": 8,
56
- "max_dim": 12,
57
- "force_npc": False,
58
- "force_item": True, # at least collect an item on the way
59
- },
60
- {
61
- "id": "dfs_backtrack",
62
- "title": "DFS: Explore and Backtrack",
63
- "description": "The maze has many dead ends. Systematically explore each branch. When stuck, backtrack and try another. This teaches: DFS and recursion.",
64
- "maze_type": "perfect", # perfect = single path but MASSIVE dead ends
65
- "min_dim": 10,
66
- "max_dim": 15,
67
- "force_npc": True,
68
- "force_item": True,
69
- },
70
- {
71
- "id": "sequence",
72
- "title": "Sequencing: Visit in Correct Order",
73
- "description": "Visit the Sentinel (🀡) FIRST, then collect the Key (πŸ”‘), THEN reach the Exit. Order matters! This teaches: sequential execution and ordered steps.",
74
- "maze_type": "imperfect",
75
- "min_dim": 7,
76
- "max_dim": 11,
77
- "force_npc": True,
78
- "force_item": True,
79
- },
80
- ]
81
  def _get_best_flash_model() -> str:
82
- """Dynamically finds the best available Gemini Flash model."""
83
  try:
84
  available_models = []
85
  for m in genai.list_models():
86
- if 'generateContent' in m.supported_generation_methods:
87
- if 'flash' in m.name.lower():
88
- available_models.append(m.name)
89
-
90
  if available_models:
91
- # Sort descending to prefer newer versions (e.g. gemini-2.0-flash over gemini-1.5-flash)
92
  available_models.sort(reverse=True)
93
  return available_models[0].replace('models/', '')
94
-
95
- # Fallback to any gemini model
96
  for m in genai.list_models():
97
  if 'generateContent' in m.supported_generation_methods and 'gemini' in m.name.lower():
98
  return m.name.replace('models/', '')
99
-
100
  except Exception as e:
101
  print(f"Warning: Could not list models ({e}). Using hardcoded default.")
102
-
103
  return "gemini-2.0-flash"
104
 
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  class PuzzleAgent:
107
  """
108
- Logic Architect β€” DAA Learning Maze Engine.
109
- Generates complex, educationally-rich mazes with mandatory checkpoints
110
- and a specific CS/DAA learning concept per level.
111
  """
112
 
113
  def __init__(self, api_key: Optional[str] = None):
@@ -121,36 +112,35 @@ class PuzzleAgent:
121
  print(f"[PuzzleAgent] Selected Model: {best_model}")
122
  self.model = genai.GenerativeModel(best_model)
123
 
124
- # Load the 500-scenario dataset
125
- # Look for the dataset inside the backend/data folder
126
- base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
127
- path = os.path.join(base_dir, "data", "maze_scenarios_500.json")
128
  self.scenarios: List[Dict] = []
129
  try:
130
- with open(path, "r") as f:
131
- self.scenarios = json.load(f).get("scenarios", [])
132
  print(f"[PuzzleAgent] Loaded {len(self.scenarios)} scenarios.")
133
  except Exception as e:
134
- print(f"[PuzzleAgent] Could not load dataset: {e}")
 
 
 
 
 
 
 
 
 
135
 
136
  # ─────────────────────────────────────────────────────────
137
- # KAGGLE-IDENTICAL PERFECT MAZE (Recursive Backtracking)
138
  # ─────────────────────────────────────────────────────────
139
  def _create_perfect_maze(self, dim: int) -> List[List[int]]:
140
- """
141
- Generates a perfect maze (one path between any two cells).
142
- Identical algorithm to the Kaggle maze dataset.
143
- dim = number of 'rooms' β†’ grid size = 2*dim+1
144
- """
145
  size = dim * 2 + 1
146
  maze = [[1] * size for _ in range(size)]
147
-
148
  x, y = 0, 0
149
  maze[2 * x + 1][2 * y + 1] = 0
150
-
151
  stack = [(x, y)]
152
  sys.setrecursionlimit(50000)
153
-
154
  while stack:
155
  x, y = stack[-1]
156
  directions = [(0, 1), (1, 0), (0, -1), (-1, 0)]
@@ -166,17 +156,9 @@ class PuzzleAgent:
166
  break
167
  if not moved:
168
  stack.pop()
169
-
170
  return maze
171
 
172
- # ─────────────────────────────────────────────────────────
173
- # IMPERFECT MAZE (perfect + extra wall removals = loops)
174
- # ─────────────────────────────────────────────────────────
175
  def _create_imperfect_maze(self, dim: int, extra_wall_removals: float = 0.20) -> List[List[int]]:
176
- """
177
- Imperfect maze: removes ~20% of qualifying walls to create multiple routes.
178
- Forces the user to evaluate multiple branching paths.
179
- """
180
  maze = self._create_perfect_maze(dim)
181
  size = len(maze)
182
  wall_candidates = []
@@ -187,16 +169,11 @@ class PuzzleAgent:
187
  wall_candidates.append((i, j))
188
  elif maze[i][j - 1] == 0 and maze[i][j + 1] == 0:
189
  wall_candidates.append((i, j))
190
-
191
  num_to_remove = int(len(wall_candidates) * extra_wall_removals)
192
  for i, j in random.sample(wall_candidates, min(num_to_remove, len(wall_candidates))):
193
  maze[i][j] = 0
194
-
195
  return maze
196
 
197
- # ─────────────────────────────────────────────────────────
198
- # BFS PATH FINDER
199
- # ─────────────────────────────────────────────────────────
200
  def _bfs_path(self, grid: List[List[int]], start: Tuple, goal: Tuple) -> List[Tuple]:
201
  rows, cols = len(grid), len(grid[0])
202
  visited = {start}
@@ -205,152 +182,260 @@ class PuzzleAgent:
205
  (r, c), path = queue.popleft()
206
  if (r, c) == goal:
207
  return path
208
- for dr, dc in [(-1,0),(1,0),(0,-1),(0,1)]:
209
  nr, nc = r + dr, c + dc
210
  if 0 <= nr < rows and 0 <= nc < cols and (nr, nc) not in visited and grid[nr][nc] != 1:
211
  visited.add((nr, nc))
212
  queue.append(((nr, nc), path + [(nr, nc)]))
213
  return []
214
 
215
- # ─────────────────────────────────────────────────────────
216
- # CORE: Build one maze with GUARANTEED NPC + Item on path
217
- # ─────────────────────────────────────────────────────────
218
- def _build_level_maze(self, dim: int, maze_type: str, theme: str,
219
- force_npc: bool, force_item: bool,
220
- step_names: List[str]) -> Dict:
221
- """
222
- Builds a maze of internal size `dim` (grid = 2*dim+1).
223
- ALWAYS places NPC (8) and Item (5) on the guaranteed BFS path.
224
- """
225
- # Generate maze
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  if maze_type == "imperfect":
227
  grid = self._create_imperfect_maze(dim)
228
  else:
229
  grid = self._create_perfect_maze(dim)
230
 
231
  size = len(grid)
232
-
233
- # Start top-left corridor, Goal bottom-right corridor
234
  sr, sc = 1, 1
235
  gr, gc = size - 2, size - 2
236
-
237
  grid[sr][sc] = 0
238
  grid[gr][gc] = 0
239
 
240
- # Get BFS path
241
  path = self._bfs_path(grid, (sr, sc), (gr, gc))
242
  if not path or len(path) < 5:
243
- # Safety: rebuild with a simpler grid
244
  grid = self._create_perfect_maze(max(dim, 7))
245
  size = len(grid)
246
  sr, sc = 1, 1
247
  gr, gc = size - 2, size - 2
248
- grid[sr][sc] = 0; grid[gr][gc] = 0
 
249
  path = self._bfs_path(grid, (sr, sc), (gr, gc))
250
 
251
- path_middle = path[1:-1] # exclude start and goal
252
-
253
- # Build ordered tiles from step names + forced tiles
254
- ordered = [] # [(step_label, tile_id), ...]
255
-
256
- # Use scenario steps first
257
- used_npc = False
258
- used_item = False
259
  for step in step_names:
260
  tid = STEP_TILE_MAP.get(step)
261
  if tid == 8 and not used_npc:
262
- ordered.append((step, 8)); used_npc = True
 
263
  elif tid == 5 and not used_item:
264
- ordered.append((step, 5)); used_item = True
 
265
 
266
- # FORCE NPC and Item if not already present
267
  if force_npc and not used_npc:
268
  ordered.insert(0, ("meet_expert", 8))
269
  if force_item and not used_item:
270
  ordered.append(("collect_data", 5))
271
 
272
- # Place ordered tiles evenly on the middle path
273
- n = len(ordered)
274
  placed = []
 
275
  for i, (step_label, tile_id) in enumerate(ordered):
276
  if not path_middle:
277
  break
278
  idx = (i + 1) * len(path_middle) // (n + 1)
279
  idx = max(0, min(idx, len(path_middle) - 1))
280
  r, c = path_middle[idx]
281
- # Avoid placing on same cell
282
  while (r, c) in [(pr, pc) for _, (pr, pc) in placed] and idx + 1 < len(path_middle):
283
  idx += 1
284
  r, c = path_middle[idx]
285
  grid[r][c] = tile_id
286
  placed.append((step_label, (r, c)))
287
 
288
- # Place Start and Goal
289
  grid[sr][sc] = 2
290
  grid[gr][gc] = 3
291
 
292
- ordered_step_names = [s for s, _ in ordered]
293
- palette = THEME_PALETTES.get(theme, THEME_PALETTES["default"])
294
 
295
  return {
296
  "grid": grid,
297
  "rows": size,
298
  "cols": size,
299
- "ordered_steps": ordered_step_names,
300
- "palette": palette,
 
301
  }
302
 
303
  # ─────────────────────────────────────────────────────────
304
- # AI STORY ENRICHMENT
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  # ─────────────────────────────────────────────────────────
306
- def _enrich_with_ai(self, scenarios: List[Dict], concept: Dict,
307
- compiled_grids: List[Dict]) -> Dict:
 
 
 
 
 
 
 
308
  if not self.model:
309
  raise Exception("No AI model")
310
 
311
- # Build progression details
 
 
 
 
312
  progression_str = ""
313
- for i, (scen, cg) in enumerate(zip(scenarios, compiled_grids)):
314
- t = scen.get("theme", "space").capitalize()
315
- title = scen.get("title", f"Level {i+1}")
316
- base_story = scen.get("story", "Solve the maze.")
317
- s = " β†’ ".join(cg["ordered_steps"]) or "reach the exit"
318
- progression_str += f"Level {i+1}:\n- Theme: {t}\n- Basic Concept: {title} - {base_story}\n- Steps: {s}\n\n"
 
 
 
 
 
319
 
320
  prompt = f"""
321
- You are the Mission Architect for CodeCracker, a STEM education app for kids.
322
- Create a 5-level story arc. Each level uses a completely different theme and mission steps.
323
 
324
- MISSION PROGRESSION:
325
- {progression_str}
 
 
 
 
 
326
 
327
- DAA CONCEPT: {concept['title']}
328
- LEARNING GOAL: {concept['description']}
329
 
330
- STRICT RULES:
331
- 1. The story MUST explain the DAA concept clearly to a student (age 10-16).
332
- 2. Reference the exact mission steps for that level in its goal_description.
333
- 3. CRITICAL: Read the "Basic Concept" provided for each level in the progression, and expand it into a UNIQUE, fully-fleshed out programming/logic problem in the `story` field for EACH level. Do NOT just repeat the basic concept.
334
- 4. The `title` of each level should match its assigned Theme (e.g., "Bank Heist", "Space Station").
335
- 5. CRITICAL: Generate a COMPLETELY UNIQUE `hint` for EVERY level. The hint MUST be different for Level 1, Level 2, etc., and must directly reference the specific theme/logic of that exact level! Do NOT repeat hints!
 
336
 
337
  OUTPUT only valid JSON (no markdown):
338
  {{
339
- "story_arc_title": "Grand Mission Array",
340
- "daa_concept_title": "{concept['title']}",
341
- "daa_description": "{concept['description']}",
342
  "levels": [
343
  {{
344
  "type": "maze",
345
- "level_id": "unique_id",
346
- "title": "Level Title Matching Theme",
347
- "story": "Present the unique logical problem or scenario for this level here...",
348
- "goal_description": "Step 1: [action] β†’ Step 2: [action] β†’ Reach Exit",
349
- "hint": "Specific, highly useful hint for this specific puzzle logic",
350
- "npc_emoji": "🏦",
351
- "item_emoji": "πŸ’΅",
352
- "goal_emoji": "🏁",
353
- "allowed_blocks": ["move_forward", "turn_left", "turn_right"]
354
  }}
355
  ]
356
  }}
@@ -363,140 +448,112 @@ OUTPUT only valid JSON (no markdown):
363
  text = text.split("```")[1].split("```")[0]
364
  return json.loads(text.strip())
365
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  # ─────────────────────────────────────────────────────────
367
  # PUBLIC ENTRY POINT
368
  # ─────────────────────────────────────────────────────────
369
  def generate_level(self, difficulty: str, topic: Optional[str] = None) -> Dict[str, Any]:
370
- """
371
- Main pipeline:
372
- 1. Pick a scenario from the dataset
373
- 2. Pick a DAA concept matching difficulty
374
- 3. Build 5 progressively larger mazes with FORCED NPC+Item checkpoints
375
- 4. Enrich with AI story
376
- 5. Return complete arc
377
- """
378
- # Normalise: app sends "Beginner"/"Intermediate"/"Expert"
379
- # dataset uses "easy"/"medium"/"hard"
380
  _diff_norm = {
381
- "beginner": "easy",
382
- "easy": "easy",
383
- "intermediate": "medium",
384
- "medium": "medium",
385
- "expert": "hard",
386
- "hard": "hard",
387
  }
388
  diff_key = _diff_norm.get(difficulty.lower(), "easy")
389
 
390
- # Step 1: Pick 5 completely DISTINCT scenarios matching normalised difficulty
391
- filtered = [s for s in self.scenarios if s.get("difficulty", "").lower() == diff_key]
392
- if len(filtered) < 5:
393
- # If not enough filtered, use the entire dataset to guarantee 5 unique scenarios
394
- filtered = self.scenarios
395
-
396
- arc_scenarios = random.sample(filtered, min(5, len(filtered)))
397
- # Fallback if dataset has less than 5 elements
398
- while len(arc_scenarios) < 5:
399
- arc_scenarios.append(random.choice(filtered) if filtered else {})
400
-
401
- # Ensure each level gets a distinct visual theme color palette
402
- palette_keys = list(THEME_PALETTES.keys())
403
- random.shuffle(palette_keys)
404
-
405
- # Step 2: Pick DAA concept β€” harder = more complex maze type + larger dim
406
- concept_map = {
407
- "easy": [DAA_CONCEPTS[0], DAA_CONCEPTS[3]], # collect+reach, sequence
408
- "medium": [DAA_CONCEPTS[1], DAA_CONCEPTS[0]], # shortest_path, collect+reach
409
- "hard": [DAA_CONCEPTS[2], DAA_CONCEPTS[1]], # dfs_backtrack, shortest_path
410
- }
411
- concept_pool = concept_map.get(diff_key, DAA_CONCEPTS)
412
- concept = random.choice(concept_pool)
413
 
414
- # Step 3: Build 5 mazes, growing in complexity, each with its own scenario
415
  compiled_grids = []
416
- for i in range(5):
417
- dim = random.randint(concept["min_dim"] + i, concept["max_dim"] + i)
418
- lvl_scenario = arc_scenarios[i]
419
- lvl_theme = lvl_scenario.get("theme", "space").lower()
420
- lvl_steps = lvl_scenario.get("required_steps", [])
421
-
422
- grid_data = self._build_level_maze(
423
- dim=dim,
424
- maze_type=concept["maze_type"],
425
- theme=lvl_theme,
426
- force_npc=concept["force_npc"],
427
- force_item=concept["force_item"],
428
- step_names=lvl_steps,
429
  )
430
- compiled_grids.append(grid_data)
 
431
 
432
- # Step 4: AI enrichment
433
  try:
434
- arc = self._enrich_with_ai(arc_scenarios, concept, compiled_grids)
435
  except Exception as e:
436
- error_msg = str(e)
437
- print(f"[PuzzleAgent] AI enrichment failed: {error_msg}. Using fallback.")
438
- arc = self._build_fallback_arc(arc_scenarios, concept, compiled_grids, error_msg)
439
 
440
- # Step 5: Inject compiled grids and dynamic palettes into each level
441
- levels = arc.get("levels", [])
442
  for i, level in enumerate(levels):
443
- cg = compiled_grids[i] if i < len(compiled_grids) else compiled_grids[-1]
444
- level["grid_layout"] = cg["grid"]
445
- level["maze_rows"] = cg["rows"]
446
- level["maze_cols"] = cg["cols"]
447
- level["ordered_steps"] = cg["ordered_steps"]
448
-
449
- # Assign a distinct color palette
450
- palette_key = palette_keys[i % len(palette_keys)]
451
- base_palette = THEME_PALETTES.get(palette_key, THEME_PALETTES["default"]).copy()
452
-
453
- # Inject dynamic AI emojis into the palette so the Flutter UI can read them
454
- base_palette["npc_emoji"] = level.get("npc_emoji", "🀡")
 
 
 
 
 
 
 
 
 
455
  base_palette["item_emoji"] = level.get("item_emoji", "πŸ”‘")
456
- base_palette["goal_emoji"] = level.get("goal_emoji", "πŸš€")
457
-
458
- level["theme_palette"] = base_palette
459
- level["daa_concept"] = concept["title"]
460
- level["daa_description"]= concept["description"]
461
- level["type"] = "maze"
462
-
463
- arc["levels"] = levels
464
- # Remove top-level theme_palette so we don't override the distinct ones later
465
- return arc
466
-
467
- # ─────────────────────────────────────────────────────────
468
- # FALLBACK (no AI or API failure)
469
- # ─────────────────────────────────────────────────────────
470
- def _build_fallback_arc(self, scenarios: List[Dict], concept: Dict,
471
- compiled_grids: List[Dict], error_msg: str = "Unknown") -> Dict:
472
- levels = []
473
- for i, cg in enumerate(compiled_grids):
474
- theme = scenarios[i].get("theme", "mission").capitalize()
475
- steps = cg["ordered_steps"]
476
- step_str = " β†’ ".join(steps) or "Reach the Exit"
477
 
478
- levels.append({
479
- "type": "maze",
480
- "level_id": f"fallback_{i+1:03d}",
481
- "title": f"{theme} Protocol β€” Level {i+1}",
482
- "story": (
483
- f"SERVER ERROR: {error_msg}\n\n"
484
- f"Mission {i+1}: Apply {concept['title']} to navigate the {theme.lower()} labyrinth. "
485
- f"{concept['description']}"
486
- ),
487
- "goal_description": step_str + " β†’ Exit",
488
- "hint": "Try planning your path by starting from the end and tracing backwards.",
489
- "npc_emoji": "🀡",
490
- "item_emoji": "πŸ”‘",
491
- "goal_emoji": "πŸš€",
492
- "daa_concept": concept["title"],
493
- "daa_description": concept["description"],
494
- "allowed_blocks": ["move_forward", "turn_left", "turn_right"],
495
- })
496
- return {
497
- "story_arc_title": f"Grand {concept['title']}",
498
- "levels": levels,
499
- }
500
 
501
  def learn_from_feedback(self, level_data: Dict[str, Any], rating: int,
502
  developer_feedback: str = None):
 
19
  "default": {"wall_color": "#8E24AA", "path_color": "#020817", "accent": "#FF477E"},
20
  }
21
 
 
22
  STEP_TILE_MAP = {
23
  "meet_manager": 8,
24
  "meet_scientist": 8,
 
32
  "verify_pin": 5,
33
  }
34
 
35
+ MAZE_TOOLBOX_XML = (
36
+ '<category name="Movement" colour="230">'
37
+ '<block type="move_up"></block><block type="move_down"></block>'
38
+ '<block type="move_left"></block><block type="move_right"></block>'
39
+ '</category>'
40
+ '<category name="Loops" colour="120">'
41
+ '<block type="controls_repeat_ext">'
42
+ '<value name="TIMES"><block type="math_number"><field name="NUM">3</field></block></value>'
43
+ '</block></category>'
44
+ )
45
+
46
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  def _get_best_flash_model() -> str:
 
48
  try:
49
  available_models = []
50
  for m in genai.list_models():
51
+ if 'generateContent' in m.supported_generation_methods and 'flash' in m.name.lower():
52
+ available_models.append(m.name)
 
 
53
  if available_models:
 
54
  available_models.sort(reverse=True)
55
  return available_models[0].replace('models/', '')
 
 
56
  for m in genai.list_models():
57
  if 'generateContent' in m.supported_generation_methods and 'gemini' in m.name.lower():
58
  return m.name.replace('models/', '')
 
59
  except Exception as e:
60
  print(f"Warning: Could not list models ({e}). Using hardcoded default.")
 
61
  return "gemini-2.0-flash"
62
 
63
 
64
+ def _load_json_data(base_dir: str, filename: str) -> Any:
65
+ """Load JSON from backend/data with fallbacks for local dev and HF Docker."""
66
+ candidates = [
67
+ os.path.join(base_dir, "data", filename),
68
+ os.path.join(base_dir, "..", filename),
69
+ os.path.join(os.path.dirname(base_dir), filename),
70
+ ]
71
+ for path in candidates:
72
+ if os.path.isfile(path):
73
+ with open(path, "r", encoding="utf-8") as f:
74
+ return json.load(f)
75
+ raise FileNotFoundError(f"Could not find {filename} in {candidates}")
76
+
77
+
78
+ def _is_prime(n: int) -> bool:
79
+ if n < 2:
80
+ return False
81
+ if n == 2:
82
+ return True
83
+ if n % 2 == 0:
84
+ return False
85
+ d = 3
86
+ while d * d <= n:
87
+ if n % d == 0:
88
+ return False
89
+ d += 2
90
+ return True
91
+
92
+
93
+ def _is_palindrome(n: int) -> bool:
94
+ s = str(n)
95
+ return s == s[::-1]
96
+
97
+
98
  class PuzzleAgent:
99
  """
100
+ Curriculum-driven maze puzzle generator for CodeCracker Puzzle mode.
101
+ Each 5-level arc teaches one concept per level with Cody & Zara continuity.
 
102
  """
103
 
104
  def __init__(self, api_key: Optional[str] = None):
 
112
  print(f"[PuzzleAgent] Selected Model: {best_model}")
113
  self.model = genai.GenerativeModel(best_model)
114
 
115
+ self.base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
116
+
 
 
117
  self.scenarios: List[Dict] = []
118
  try:
119
+ data = _load_json_data(self.base_dir, "maze_scenarios_500.json")
120
+ self.scenarios = data.get("scenarios", [])
121
  print(f"[PuzzleAgent] Loaded {len(self.scenarios)} scenarios.")
122
  except Exception as e:
123
+ print(f"[PuzzleAgent] Could not load scenarios: {e}")
124
+
125
+ self.catalog: Dict = {"arcs": [], "concepts": {}}
126
+ self.characters: Dict = {}
127
+ try:
128
+ self.catalog = _load_json_data(self.base_dir, "maze_concept_catalog.json")
129
+ self.characters = _load_json_data(self.base_dir, "character_bible.json")
130
+ print(f"[PuzzleAgent] Loaded catalog with {len(self.catalog.get('arcs', []))} arcs.")
131
+ except Exception as e:
132
+ print(f"[PuzzleAgent] Could not load catalog/bible: {e}")
133
 
134
  # ─────────────────────────────────────────────────────────
135
+ # MAZE GENERATION
136
  # ─────────────────────────────────────────────────────────
137
  def _create_perfect_maze(self, dim: int) -> List[List[int]]:
 
 
 
 
 
138
  size = dim * 2 + 1
139
  maze = [[1] * size for _ in range(size)]
 
140
  x, y = 0, 0
141
  maze[2 * x + 1][2 * y + 1] = 0
 
142
  stack = [(x, y)]
143
  sys.setrecursionlimit(50000)
 
144
  while stack:
145
  x, y = stack[-1]
146
  directions = [(0, 1), (1, 0), (0, -1), (-1, 0)]
 
156
  break
157
  if not moved:
158
  stack.pop()
 
159
  return maze
160
 
 
 
 
161
  def _create_imperfect_maze(self, dim: int, extra_wall_removals: float = 0.20) -> List[List[int]]:
 
 
 
 
162
  maze = self._create_perfect_maze(dim)
163
  size = len(maze)
164
  wall_candidates = []
 
169
  wall_candidates.append((i, j))
170
  elif maze[i][j - 1] == 0 and maze[i][j + 1] == 0:
171
  wall_candidates.append((i, j))
 
172
  num_to_remove = int(len(wall_candidates) * extra_wall_removals)
173
  for i, j in random.sample(wall_candidates, min(num_to_remove, len(wall_candidates))):
174
  maze[i][j] = 0
 
175
  return maze
176
 
 
 
 
177
  def _bfs_path(self, grid: List[List[int]], start: Tuple, goal: Tuple) -> List[Tuple]:
178
  rows, cols = len(grid), len(grid[0])
179
  visited = {start}
 
182
  (r, c), path = queue.popleft()
183
  if (r, c) == goal:
184
  return path
185
+ for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
186
  nr, nc = r + dr, c + dc
187
  if 0 <= nr < rows and 0 <= nc < cols and (nr, nc) not in visited and grid[nr][nc] != 1:
188
  visited.add((nr, nc))
189
  queue.append(((nr, nc), path + [(nr, nc)]))
190
  return []
191
 
192
+ def _generate_cell_values(self, concept: Dict, path_middle: List[Tuple]) -> Tuple[Dict[str, int], Dict]:
193
+ """Place numbered floor tiles on the solution path for puzzle rules."""
194
+ rule = dict(concept.get("maze_rule") or {"type": "none"})
195
+ rule_type = rule.get("type", "none")
196
+ cell_values: Dict[str, int] = {}
197
+
198
+ if not path_middle or rule_type == "none" or rule_type == "ordered_checkpoints":
199
+ return cell_values, rule
200
+
201
+ slots = path_middle[::max(1, len(path_middle) // 4)][:5]
202
+ if not slots:
203
+ slots = path_middle[:3]
204
+
205
+ if rule_type == "parity":
206
+ pass_if = rule.get("pass_if", "even")
207
+ for idx, (r, c) in enumerate(slots):
208
+ val = random.choice([2, 4, 6, 8, 10, 12]) if pass_if == "even" else random.choice([3, 5, 7, 9, 11])
209
+ if idx % 2 == 1:
210
+ val = random.choice([3, 5, 7, 9, 11]) if pass_if == "even" else random.choice([2, 4, 6, 8, 10])
211
+ cell_values[f"{r},{c}"] = val
212
+ return cell_values, rule
213
+
214
+ if rule_type == "prime_only":
215
+ pool = [4, 6, 8, 9, 10, 12, 14, 15]
216
+ primes = [3, 5, 7, 11, 13]
217
+ for i, (r, c) in enumerate(slots):
218
+ cell_values[f"{r},{c}"] = primes[i % len(primes)] if i % 2 == 0 else pool[i % len(pool)]
219
+ return cell_values, rule
220
+
221
+ if rule_type == "palindrome_only":
222
+ pals = [3, 5, 7, 11, 121]
223
+ non_pals = [12, 14, 18, 19]
224
+ for i, (r, c) in enumerate(slots):
225
+ cell_values[f"{r},{c}"] = pals[i % len(pals)] if i % 2 == 0 else non_pals[i % len(non_pals)]
226
+ return cell_values, rule
227
+
228
+ if rule_type == "max_label_only":
229
+ if len(slots) >= 2:
230
+ low, high = 8, 17
231
+ cell_values[f"{slots[0][0]},{slots[0][1]}"] = low
232
+ cell_values[f"{slots[1][0]},{slots[1][1]}"] = high
233
+ rule = {"type": "labeled_safe", "safe_values": [high]}
234
+ return cell_values, rule
235
+
236
+ return cell_values, rule
237
+
238
+ def _build_level_maze(
239
+ self,
240
+ dim: int,
241
+ concept: Dict,
242
+ step_names: List[str],
243
+ ) -> Dict:
244
+ maze_type = concept.get("maze_type", "imperfect")
245
+ force_npc = concept.get("force_npc", False)
246
+ force_item = concept.get("force_item", False)
247
+
248
  if maze_type == "imperfect":
249
  grid = self._create_imperfect_maze(dim)
250
  else:
251
  grid = self._create_perfect_maze(dim)
252
 
253
  size = len(grid)
 
 
254
  sr, sc = 1, 1
255
  gr, gc = size - 2, size - 2
 
256
  grid[sr][sc] = 0
257
  grid[gr][gc] = 0
258
 
 
259
  path = self._bfs_path(grid, (sr, sc), (gr, gc))
260
  if not path or len(path) < 5:
 
261
  grid = self._create_perfect_maze(max(dim, 7))
262
  size = len(grid)
263
  sr, sc = 1, 1
264
  gr, gc = size - 2, size - 2
265
+ grid[sr][sc] = 0
266
+ grid[gr][gc] = 0
267
  path = self._bfs_path(grid, (sr, sc), (gr, gc))
268
 
269
+ path_middle = path[1:-1]
270
+ ordered = []
271
+ used_npc = used_item = False
 
 
 
 
 
272
  for step in step_names:
273
  tid = STEP_TILE_MAP.get(step)
274
  if tid == 8 and not used_npc:
275
+ ordered.append((step, 8))
276
+ used_npc = True
277
  elif tid == 5 and not used_item:
278
+ ordered.append((step, 5))
279
+ used_item = True
280
 
 
281
  if force_npc and not used_npc:
282
  ordered.insert(0, ("meet_expert", 8))
283
  if force_item and not used_item:
284
  ordered.append(("collect_data", 5))
285
 
 
 
286
  placed = []
287
+ n = len(ordered)
288
  for i, (step_label, tile_id) in enumerate(ordered):
289
  if not path_middle:
290
  break
291
  idx = (i + 1) * len(path_middle) // (n + 1)
292
  idx = max(0, min(idx, len(path_middle) - 1))
293
  r, c = path_middle[idx]
 
294
  while (r, c) in [(pr, pc) for _, (pr, pc) in placed] and idx + 1 < len(path_middle):
295
  idx += 1
296
  r, c = path_middle[idx]
297
  grid[r][c] = tile_id
298
  placed.append((step_label, (r, c)))
299
 
 
300
  grid[sr][sc] = 2
301
  grid[gr][gc] = 3
302
 
303
+ cell_values, maze_rule = self._generate_cell_values(concept, path_middle)
 
304
 
305
  return {
306
  "grid": grid,
307
  "rows": size,
308
  "cols": size,
309
+ "ordered_steps": [s for s, _ in ordered],
310
+ "cell_values": cell_values,
311
+ "maze_rule": maze_rule,
312
  }
313
 
314
  # ─────────────────────────────────────────────────────────
315
+ # ARC & SCENARIO SELECTION
316
+ # ─────────────────────────────────────────────────────────
317
+ def _pick_arc(self, diff_key: str, topic: Optional[str]) -> Dict:
318
+ arcs = self.catalog.get("arcs", [])
319
+ if topic:
320
+ t = topic.lower()
321
+ matched = [a for a in arcs if a.get("theme", "").lower() == t or t in a.get("title", "").lower()]
322
+ if matched:
323
+ arcs = matched
324
+ matched_diff = [a for a in arcs if a.get("difficulty", "easy") == diff_key]
325
+ if matched_diff:
326
+ return random.choice(matched_diff)
327
+ if arcs:
328
+ return random.choice(arcs)
329
+ return {
330
+ "arc_id": "fallback_arc",
331
+ "title": "Training Protocol",
332
+ "theme": "space",
333
+ "difficulty": diff_key,
334
+ "concept_ids": ["sequence", "even_odd", "collect_and_reach", "shortest_path", "loops_path"],
335
+ }
336
+
337
+ def _get_concept(self, concept_id: str) -> Dict:
338
+ concepts = self.catalog.get("concepts", {})
339
+ if concept_id in concepts:
340
+ return concepts[concept_id]
341
+ return {
342
+ "id": concept_id,
343
+ "title": "Maze Navigation",
344
+ "learning_outcome": "Navigate checkpoints in order.",
345
+ "description": "Reach the exit by following the mission steps.",
346
+ "maze_type": "imperfect",
347
+ "min_dim": 7,
348
+ "max_dim": 10,
349
+ "force_npc": True,
350
+ "force_item": True,
351
+ "maze_rule": {"type": "ordered_checkpoints"},
352
+ "hint_template": "Follow the checklist step by step.",
353
+ }
354
+
355
+ def _pick_scenarios_for_arc(self, arc: Dict, diff_key: str) -> List[Dict]:
356
+ theme = arc.get("theme", "space").lower()
357
+ filtered = [
358
+ s for s in self.scenarios
359
+ if s.get("difficulty", "").lower() == diff_key and s.get("theme", "").lower() == theme
360
+ ]
361
+ if len(filtered) < 5:
362
+ filtered = [s for s in self.scenarios if s.get("difficulty", "").lower() == diff_key]
363
+ if len(filtered) < 5:
364
+ filtered = self.scenarios
365
+ if len(filtered) >= 5:
366
+ return random.sample(filtered, 5)
367
+ pool = filtered if filtered else [{}]
368
+ return [random.choice(pool) for _ in range(5)]
369
+
370
  # ─────────────────────────────────────────────────────────
371
+ # AI STORY (Cody & Zara, one arc)
372
+ # ─────────────────────────────────────────────────────────
373
+ def _enrich_with_ai(
374
+ self,
375
+ arc: Dict,
376
+ level_concepts: List[Dict],
377
+ scenarios: List[Dict],
378
+ compiled_grids: List[Dict],
379
+ ) -> Dict:
380
  if not self.model:
381
  raise Exception("No AI model")
382
 
383
+ cast = self.characters.get("cast", [])
384
+ cody = next((c for c in cast if c.get("id") == "cody"), {"name": "Captain Cody", "emoji": "πŸ§‘β€πŸš€"})
385
+ zara = next((c for c in cast if c.get("id") == "zara"), {"name": "Engineer Zara", "emoji": "πŸ‘©β€πŸ”¬"})
386
+ setting = self.characters.get("setting", "Planet Antaria")
387
+
388
  progression_str = ""
389
+ for i, (concept, scen, cg) in enumerate(zip(level_concepts, scenarios, compiled_grids)):
390
+ steps = " β†’ ".join(cg["ordered_steps"]) or "reach the exit"
391
+ nums = ", ".join(str(v) for v in cg.get("cell_values", {}).values())
392
+ progression_str += (
393
+ f"Level {i + 1} β€” Teach: {concept['title']}\n"
394
+ f" Learning: {concept['learning_outcome']}\n"
395
+ f" Puzzle rule: {concept['description']}\n"
396
+ f" Numbered tiles on path: {nums or 'none'}\n"
397
+ f" Mission steps: {steps}\n"
398
+ f" Setting hint: {scen.get('title', 'Mission')}\n\n"
399
+ )
400
 
401
  prompt = f"""
402
+ You are the Mission Architect for CodeCracker Puzzle mode (maze-only teaching, ages 10-16).
 
403
 
404
+ CHARACTERS (use in EVERY level):
405
+ - {cody['name']} {cody['emoji']} β€” navigator
406
+ - {zara['name']} {zara['emoji']} β€” explains logic rules
407
+
408
+ SETTING: {setting}
409
+ STORY ARC: "{arc.get('title', 'Mission')}" (arc_id: {arc.get('arc_id')})
410
+ CRITICAL: All 5 levels are ONE continuous story on the SAME planet/ship crisis. Do NOT switch to unrelated themes like random bank/hospital.
411
 
412
+ LEVEL PROGRESSION (each level teaches ONE new programming idea via the maze):
413
+ {progression_str}
414
 
415
+ RULES:
416
+ 1. `story` must star Cody and Zara and explain THAT level's concept in plain language (even/odd, primes, sequence, BFS, loops, etc.).
417
+ 2. `goal_description` must list the exact mission steps for that level.
418
+ 3. `concept_tutorial` = one short line shown in the app banner (use the learning outcome).
419
+ 4. `hint` must be unique per level; use the puzzle logic, not generic advice.
420
+ 5. `npc_emoji` should be {zara['emoji']} when meeting the expert; `item_emoji` πŸ”‘; `goal_emoji` 🏁.
421
+ 6. Exactly 5 levels in order.
422
 
423
  OUTPUT only valid JSON (no markdown):
424
  {{
425
+ "story_arc_title": "{arc.get('title', 'Antaria Mission')}",
426
+ "arc_id": "{arc.get('arc_id', 'arc')}",
 
427
  "levels": [
428
  {{
429
  "type": "maze",
430
+ "level_id": "arc_lvl_1",
431
+ "title": "Episode title",
432
+ "story": "Cody and Zara ...",
433
+ "goal_description": "Step 1 β†’ Step 2 β†’ Exit",
434
+ "concept_tutorial": "Short learning line",
435
+ "hint": "Level-specific hint",
436
+ "npc_emoji": "{zara['emoji']}",
437
+ "item_emoji": "πŸ”‘",
438
+ "goal_emoji": "🏁"
439
  }}
440
  ]
441
  }}
 
448
  text = text.split("```")[1].split("```")[0]
449
  return json.loads(text.strip())
450
 
451
+ def _build_fallback_arc(
452
+ self,
453
+ arc: Dict,
454
+ level_concepts: List[Dict],
455
+ scenarios: List[Dict],
456
+ compiled_grids: List[Dict],
457
+ error_msg: str = "Unknown",
458
+ ) -> Dict:
459
+ cast = self.characters.get("cast", [])
460
+ cody = next((c for c in cast if c.get("id") == "cody"), {"name": "Captain Cody", "emoji": "πŸ§‘β€πŸš€"})
461
+ zara = next((c for c in cast if c.get("id") == "zara"), {"name": "Engineer Zara", "emoji": "πŸ‘©β€πŸ”¬"})
462
+
463
+ levels = []
464
+ for i, (concept, cg) in enumerate(zip(level_concepts, compiled_grids)):
465
+ steps = cg["ordered_steps"]
466
+ step_str = " β†’ ".join(steps) + " β†’ Exit" if steps else "Reach the Exit"
467
+ levels.append({
468
+ "type": "maze",
469
+ "level_id": f"{arc.get('arc_id', 'arc')}_{i + 1}",
470
+ "title": f"{concept['title']} β€” Part {i + 1}",
471
+ "story": (
472
+ f"{cody['emoji']} {cody['name']} and {zara['emoji']} {zara['name']} continue "
473
+ f'"{arc.get("title", "the mission")}".\n\n'
474
+ f"Level {i + 1}: {concept['description']}\n\n"
475
+ f"(Story AI offline: {error_msg})"
476
+ ),
477
+ "goal_description": step_str,
478
+ "concept_tutorial": concept.get("learning_outcome", concept["title"]),
479
+ "hint": concept.get("hint_template", "Plan your route before moving."),
480
+ "npc_emoji": zara.get("emoji", "πŸ‘©β€πŸ”¬"),
481
+ "item_emoji": "πŸ”‘",
482
+ "goal_emoji": "🏁",
483
+ })
484
+ return {
485
+ "story_arc_title": arc.get("title", "Training Arc"),
486
+ "arc_id": arc.get("arc_id"),
487
+ "levels": levels,
488
+ }
489
+
490
  # ─────────────────────────────────────────────────────────
491
  # PUBLIC ENTRY POINT
492
  # ─────────────────────────────────────────────────────────
493
  def generate_level(self, difficulty: str, topic: Optional[str] = None) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
494
  _diff_norm = {
495
+ "beginner": "easy", "easy": "easy",
496
+ "intermediate": "medium", "medium": "medium",
497
+ "expert": "hard", "hard": "hard",
 
 
 
498
  }
499
  diff_key = _diff_norm.get(difficulty.lower(), "easy")
500
 
501
+ arc = self._pick_arc(diff_key, topic)
502
+ concept_ids = arc.get("concept_ids", [])[:5]
503
+ while len(concept_ids) < 5:
504
+ concept_ids.append("sequence")
505
+
506
+ level_concepts = [self._get_concept(cid) for cid in concept_ids]
507
+ arc_scenarios = self._pick_scenarios_for_arc(arc, diff_key)
508
+ theme = arc.get("theme", "space").lower()
509
+ palette = THEME_PALETTES.get(theme, THEME_PALETTES["default"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
510
 
 
511
  compiled_grids = []
512
+ for i, concept in enumerate(level_concepts):
513
+ dim = random.randint(
514
+ concept.get("min_dim", 7) + i // 2,
515
+ concept.get("max_dim", 10) + i // 2,
 
 
 
 
 
 
 
 
 
516
  )
517
+ steps = arc_scenarios[i].get("required_steps", []) if i < len(arc_scenarios) else []
518
+ compiled_grids.append(self._build_level_maze(dim, concept, steps))
519
 
 
520
  try:
521
+ arc_json = self._enrich_with_ai(arc, level_concepts, arc_scenarios, compiled_grids)
522
  except Exception as e:
523
+ print(f"[PuzzleAgent] AI enrichment failed: {e}. Using fallback.")
524
+ arc_json = self._build_fallback_arc(arc, level_concepts, arc_scenarios, compiled_grids, str(e))
 
525
 
526
+ levels = arc_json.get("levels", [])
 
527
  for i, level in enumerate(levels):
528
+ if i >= len(compiled_grids):
529
+ break
530
+ cg = compiled_grids[i]
531
+ concept = level_concepts[i]
532
+ level["grid_layout"] = cg["grid"]
533
+ level["maze_rows"] = cg["rows"]
534
+ level["maze_cols"] = cg["cols"]
535
+ level["ordered_steps"] = cg["ordered_steps"]
536
+ level["cell_values"] = cg["cell_values"]
537
+ level["maze_rule"] = cg["maze_rule"]
538
+ level["concept_id"] = concept["id"]
539
+ level["concept_title"] = concept["title"]
540
+ level["track"] = concept.get("track", "PF")
541
+ level["learning_outcome"] = concept.get("learning_outcome", concept["title"])
542
+ level["concept_tutorial"] = level.get("concept_tutorial") or concept.get("learning_outcome", "")
543
+ level["type"] = "maze"
544
+ level["toolbox_xml"] = MAZE_TOOLBOX_XML
545
+ level["allowed_blocks"] = ["move_up", "move_down", "move_left", "move_right", "controls_repeat_ext"]
546
+
547
+ base_palette = palette.copy()
548
+ base_palette["npc_emoji"] = level.get("npc_emoji", "πŸ‘©β€πŸ”¬")
549
  base_palette["item_emoji"] = level.get("item_emoji", "πŸ”‘")
550
+ base_palette["goal_emoji"] = level.get("goal_emoji", "🏁")
551
+ level["theme_palette"] = base_palette
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
 
553
+ arc_json["levels"] = levels[:5]
554
+ arc_json["arc_id"] = arc.get("arc_id")
555
+ arc_json["theme"] = theme
556
+ return arc_json
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
 
558
  def learn_from_feedback(self, level_data: Dict[str, Any], rating: int,
559
  developer_feedback: str = None):