learn_bash_from_session_data 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1080 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Quiz Generator for Bash Learning System
4
+
5
+ Generates quizzes from analyzed session commands with 4 question types:
6
+ 1. "What does this do?" - Multiple choice about command behavior
7
+ 2. "Which flag?" - Identify correct flag for a task
8
+ 3. "Build the command" - Arrange components to form command
9
+ 4. "Spot the difference" - Explain difference between similar commands
10
+
11
+ Uses ONLY Python standard library.
12
+ """
13
+
14
+ from dataclasses import dataclass, field
15
+ from enum import Enum
16
+ from typing import Optional
17
+ import random
18
+ import re
19
+ import hashlib
20
+
21
+
22
+ class QuizType(Enum):
23
+ """Types of quiz questions."""
24
+ WHAT_DOES = "what_does_this_do"
25
+ WHICH_FLAG = "which_flag"
26
+ BUILD_COMMAND = "build_the_command"
27
+ SPOT_DIFFERENCE = "spot_the_difference"
28
+
29
+
30
+ @dataclass
31
+ class QuizOption:
32
+ """A single quiz answer option."""
33
+ id: str
34
+ text: str
35
+ is_correct: bool
36
+ explanation: Optional[str] = None
37
+
38
+
39
+ @dataclass
40
+ class QuizQuestion:
41
+ """A complete quiz question with options."""
42
+ id: str
43
+ quiz_type: QuizType
44
+ question_text: str
45
+ options: list[QuizOption]
46
+ correct_option_id: str
47
+ explanation: str
48
+ difficulty: int # 1-5
49
+ command_context: Optional[str] = None
50
+ tags: list[str] = field(default_factory=list)
51
+
52
+ def to_dict(self) -> dict:
53
+ """Convert to dictionary for serialization."""
54
+ return {
55
+ "id": self.id,
56
+ "type": self.quiz_type.value,
57
+ "question": self.question_text,
58
+ "options": [
59
+ {
60
+ "id": opt.id,
61
+ "text": opt.text,
62
+ "is_correct": opt.is_correct,
63
+ "explanation": opt.explanation
64
+ }
65
+ for opt in self.options
66
+ ],
67
+ "correct_answer": self.correct_option_id,
68
+ "explanation": self.explanation,
69
+ "difficulty": self.difficulty,
70
+ "command_context": self.command_context,
71
+ "tags": self.tags
72
+ }
73
+
74
+
75
+ @dataclass
76
+ class Quiz:
77
+ """A complete quiz containing multiple questions."""
78
+ id: str
79
+ title: str
80
+ description: str
81
+ questions: list[QuizQuestion]
82
+ total_points: int = 0
83
+ time_limit_seconds: Optional[int] = None
84
+
85
+ def __post_init__(self):
86
+ if self.total_points == 0:
87
+ self.total_points = len(self.questions)
88
+
89
+ def to_dict(self) -> dict:
90
+ """Convert to dictionary for serialization."""
91
+ return {
92
+ "id": self.id,
93
+ "title": self.title,
94
+ "description": self.description,
95
+ "questions": [q.to_dict() for q in self.questions],
96
+ "total_points": self.total_points,
97
+ "time_limit_seconds": self.time_limit_seconds,
98
+ "question_count": len(self.questions)
99
+ }
100
+
101
+
102
+ # =============================================================================
103
+ # Flag Knowledge Base (embedded for standalone use)
104
+ # =============================================================================
105
+
106
+ FLAG_DATABASE: dict[str, dict[str, str]] = {
107
+ "ls": {
108
+ "-l": "Use long listing format with details",
109
+ "-a": "Show all files including hidden ones",
110
+ "-h": "Human-readable file sizes",
111
+ "-R": "List subdirectories recursively",
112
+ "-t": "Sort by modification time",
113
+ "-S": "Sort by file size",
114
+ "-r": "Reverse sort order",
115
+ "-1": "List one file per line",
116
+ "-d": "List directories themselves, not contents",
117
+ "-i": "Show inode numbers"
118
+ },
119
+ "grep": {
120
+ "-i": "Case-insensitive search",
121
+ "-r": "Search recursively in directories",
122
+ "-n": "Show line numbers",
123
+ "-v": "Invert match (show non-matching lines)",
124
+ "-c": "Count matching lines only",
125
+ "-l": "Show only filenames with matches",
126
+ "-w": "Match whole words only",
127
+ "-E": "Use extended regular expressions",
128
+ "-A": "Show lines after match",
129
+ "-B": "Show lines before match",
130
+ "-C": "Show context lines around match",
131
+ "-o": "Show only the matching part",
132
+ "-q": "Quiet mode, exit status only"
133
+ },
134
+ "find": {
135
+ "-name": "Search by filename pattern",
136
+ "-type": "Filter by file type (f=file, d=directory)",
137
+ "-size": "Filter by file size",
138
+ "-mtime": "Filter by modification time",
139
+ "-exec": "Execute command on found files",
140
+ "-delete": "Delete found files",
141
+ "-maxdepth": "Limit search depth",
142
+ "-mindepth": "Minimum search depth",
143
+ "-perm": "Filter by permissions",
144
+ "-user": "Filter by owner",
145
+ "-group": "Filter by group",
146
+ "-newer": "Find files newer than reference"
147
+ },
148
+ "chmod": {
149
+ "-R": "Change permissions recursively",
150
+ "-v": "Verbose output",
151
+ "-c": "Report only when changes made",
152
+ "-f": "Suppress error messages",
153
+ "--reference": "Use another file's permissions"
154
+ },
155
+ "cp": {
156
+ "-r": "Copy directories recursively",
157
+ "-i": "Prompt before overwriting",
158
+ "-f": "Force overwrite without prompting",
159
+ "-v": "Verbose output",
160
+ "-p": "Preserve file attributes",
161
+ "-a": "Archive mode (preserve all)",
162
+ "-n": "Do not overwrite existing files",
163
+ "-u": "Update only (copy if source is newer)",
164
+ "-l": "Create hard links instead of copying",
165
+ "-s": "Create symbolic links instead"
166
+ },
167
+ "mv": {
168
+ "-i": "Prompt before overwriting",
169
+ "-f": "Force overwrite without prompting",
170
+ "-v": "Verbose output",
171
+ "-n": "Do not overwrite existing files",
172
+ "-u": "Update only (move if source is newer)",
173
+ "-b": "Create backup of existing files"
174
+ },
175
+ "rm": {
176
+ "-r": "Remove directories recursively",
177
+ "-f": "Force removal without prompting",
178
+ "-i": "Prompt before each removal",
179
+ "-v": "Verbose output",
180
+ "-d": "Remove empty directories"
181
+ },
182
+ "mkdir": {
183
+ "-p": "Create parent directories as needed",
184
+ "-v": "Verbose output",
185
+ "-m": "Set permissions mode"
186
+ },
187
+ "cat": {
188
+ "-n": "Number all output lines",
189
+ "-b": "Number non-blank lines only",
190
+ "-s": "Squeeze multiple blank lines",
191
+ "-A": "Show all non-printing characters",
192
+ "-E": "Show $ at end of each line",
193
+ "-T": "Show tabs as ^I"
194
+ },
195
+ "tar": {
196
+ "-c": "Create a new archive",
197
+ "-x": "Extract files from archive",
198
+ "-v": "Verbose output",
199
+ "-f": "Specify archive filename",
200
+ "-z": "Compress with gzip",
201
+ "-j": "Compress with bzip2",
202
+ "-t": "List archive contents",
203
+ "-r": "Append files to archive",
204
+ "-u": "Update files in archive",
205
+ "--exclude": "Exclude files matching pattern"
206
+ },
207
+ "curl": {
208
+ "-o": "Write output to file",
209
+ "-O": "Save with remote filename",
210
+ "-L": "Follow redirects",
211
+ "-s": "Silent mode",
212
+ "-v": "Verbose output",
213
+ "-H": "Add custom header",
214
+ "-X": "Specify HTTP method",
215
+ "-d": "Send POST data",
216
+ "-I": "Fetch headers only",
217
+ "-k": "Allow insecure SSL connections",
218
+ "-u": "Specify username:password",
219
+ "-A": "Set User-Agent string"
220
+ },
221
+ "wget": {
222
+ "-O": "Write to specified file",
223
+ "-q": "Quiet mode",
224
+ "-v": "Verbose output",
225
+ "-c": "Continue partial download",
226
+ "-r": "Recursive download",
227
+ "-np": "Don't ascend to parent directory",
228
+ "-P": "Specify download directory",
229
+ "-N": "Only download newer files"
230
+ },
231
+ "git": {
232
+ "--amend": "Amend the previous commit",
233
+ "-m": "Specify commit message",
234
+ "-a": "Stage all modified files",
235
+ "-b": "Create and checkout new branch",
236
+ "-f": "Force operation",
237
+ "-v": "Verbose output",
238
+ "--hard": "Reset working directory and index",
239
+ "--soft": "Reset only HEAD",
240
+ "-u": "Set upstream tracking branch"
241
+ },
242
+ "ssh": {
243
+ "-p": "Specify port number",
244
+ "-i": "Specify identity file",
245
+ "-v": "Verbose mode",
246
+ "-X": "Enable X11 forwarding",
247
+ "-L": "Local port forwarding",
248
+ "-R": "Remote port forwarding",
249
+ "-N": "No remote command",
250
+ "-f": "Go to background"
251
+ },
252
+ "scp": {
253
+ "-r": "Copy directories recursively",
254
+ "-P": "Specify port number",
255
+ "-i": "Specify identity file",
256
+ "-v": "Verbose mode",
257
+ "-C": "Enable compression",
258
+ "-p": "Preserve file attributes"
259
+ },
260
+ "ps": {
261
+ "-e": "Show all processes",
262
+ "-f": "Full format listing",
263
+ "-u": "Show user-oriented format",
264
+ "-a": "Show processes for all users",
265
+ "-x": "Show processes without controlling terminal",
266
+ "aux": "Common combination for all processes"
267
+ },
268
+ "kill": {
269
+ "-9": "Force kill (SIGKILL)",
270
+ "-15": "Graceful termination (SIGTERM)",
271
+ "-l": "List all signal names",
272
+ "-s": "Specify signal by name"
273
+ },
274
+ "awk": {
275
+ "-F": "Specify field separator",
276
+ "-v": "Set variable",
277
+ "-f": "Read program from file"
278
+ },
279
+ "sed": {
280
+ "-i": "Edit files in place",
281
+ "-e": "Add script command",
282
+ "-n": "Suppress automatic printing",
283
+ "-r": "Use extended regular expressions",
284
+ "-E": "Use extended regular expressions (portable)"
285
+ },
286
+ "sort": {
287
+ "-n": "Sort numerically",
288
+ "-r": "Reverse sort order",
289
+ "-k": "Sort by specific field",
290
+ "-u": "Remove duplicates",
291
+ "-t": "Specify field delimiter",
292
+ "-h": "Human numeric sort"
293
+ },
294
+ "uniq": {
295
+ "-c": "Prefix lines with occurrence count",
296
+ "-d": "Only print duplicate lines",
297
+ "-u": "Only print unique lines",
298
+ "-i": "Ignore case differences"
299
+ },
300
+ "head": {
301
+ "-n": "Specify number of lines",
302
+ "-c": "Specify number of bytes"
303
+ },
304
+ "tail": {
305
+ "-n": "Specify number of lines",
306
+ "-f": "Follow file (watch for appends)",
307
+ "-c": "Specify number of bytes"
308
+ },
309
+ "wc": {
310
+ "-l": "Count lines",
311
+ "-w": "Count words",
312
+ "-c": "Count bytes",
313
+ "-m": "Count characters"
314
+ },
315
+ "diff": {
316
+ "-u": "Unified diff format",
317
+ "-r": "Recursively compare directories",
318
+ "-q": "Only report if files differ",
319
+ "-i": "Ignore case differences",
320
+ "-w": "Ignore whitespace",
321
+ "-B": "Ignore blank lines"
322
+ },
323
+ "du": {
324
+ "-h": "Human-readable sizes",
325
+ "-s": "Display only total for each argument",
326
+ "-a": "Include files, not just directories",
327
+ "-c": "Produce grand total",
328
+ "-d": "Max depth to display"
329
+ },
330
+ "df": {
331
+ "-h": "Human-readable sizes",
332
+ "-T": "Show filesystem type",
333
+ "-i": "Show inode information"
334
+ },
335
+ "chmod": {
336
+ "-R": "Change permissions recursively",
337
+ "-v": "Verbose output"
338
+ },
339
+ "chown": {
340
+ "-R": "Change ownership recursively",
341
+ "-v": "Verbose output",
342
+ "-h": "Affect symbolic links"
343
+ }
344
+ }
345
+
346
+ # Command categories for generating related distractors
347
+ COMMAND_CATEGORIES: dict[str, list[str]] = {
348
+ "file_listing": ["ls", "find", "locate", "tree", "stat"],
349
+ "file_manipulation": ["cp", "mv", "rm", "mkdir", "rmdir", "touch"],
350
+ "text_processing": ["grep", "sed", "awk", "cut", "sort", "uniq", "tr"],
351
+ "text_viewing": ["cat", "less", "more", "head", "tail", "wc"],
352
+ "archiving": ["tar", "zip", "unzip", "gzip", "gunzip", "bzip2"],
353
+ "networking": ["curl", "wget", "ssh", "scp", "rsync", "ping", "netstat"],
354
+ "process_management": ["ps", "kill", "top", "htop", "jobs", "bg", "fg"],
355
+ "version_control": ["git"],
356
+ "permissions": ["chmod", "chown", "chgrp"],
357
+ "disk_usage": ["du", "df", "mount", "umount"],
358
+ "comparison": ["diff", "cmp", "comm"]
359
+ }
360
+
361
+ # Common flag descriptions that can be swapped as distractors
362
+ COMMON_FLAG_DESCRIPTIONS: dict[str, list[str]] = {
363
+ "-v": ["Verbose output", "Version information", "Validate input"],
364
+ "-r": ["Recursive operation", "Reverse order", "Read-only mode"],
365
+ "-f": ["Force operation", "File input", "Format output"],
366
+ "-n": ["Numeric output", "No newline", "Number lines", "Dry run"],
367
+ "-i": ["Interactive mode", "Case-insensitive", "In-place edit"],
368
+ "-a": ["All items", "Append mode", "Archive mode"],
369
+ "-l": ["Long format", "List only", "Line mode"],
370
+ "-h": ["Human-readable", "Help message", "Show hidden"]
371
+ }
372
+
373
+
374
+ def _generate_id(content: str) -> str:
375
+ """Generate a unique ID based on content."""
376
+ return hashlib.md5(content.encode()).hexdigest()[:8]
377
+
378
+
379
+ def _get_command_category(cmd: str) -> Optional[str]:
380
+ """Get the category of a command."""
381
+ for category, commands in COMMAND_CATEGORIES.items():
382
+ if cmd in commands:
383
+ return category
384
+ return None
385
+
386
+
387
+ def _get_related_commands(cmd: str) -> list[str]:
388
+ """Get commands in the same category."""
389
+ category = _get_command_category(cmd)
390
+ if category:
391
+ return [c for c in COMMAND_CATEGORIES[category] if c != cmd]
392
+ return []
393
+
394
+
395
+ def _parse_command(cmd_string: str) -> dict:
396
+ """Parse a command string into components."""
397
+ parts = cmd_string.strip().split()
398
+ if not parts:
399
+ return {"base": "", "flags": [], "args": []}
400
+
401
+ base = parts[0]
402
+ flags = []
403
+ args = []
404
+
405
+ for part in parts[1:]:
406
+ if part.startswith("-"):
407
+ flags.append(part)
408
+ else:
409
+ args.append(part)
410
+
411
+ return {"base": base, "flags": flags, "args": args}
412
+
413
+
414
+ def _get_flag_description(cmd: str, flag: str) -> Optional[str]:
415
+ """Get description for a flag of a command."""
416
+ if cmd in FLAG_DATABASE:
417
+ # Handle flags like -la (combined short flags)
418
+ if flag in FLAG_DATABASE[cmd]:
419
+ return FLAG_DATABASE[cmd][flag]
420
+ # Try individual characters for combined flags
421
+ if len(flag) > 2 and flag.startswith("-") and not flag.startswith("--"):
422
+ for char in flag[1:]:
423
+ single_flag = f"-{char}"
424
+ if single_flag in FLAG_DATABASE[cmd]:
425
+ return FLAG_DATABASE[cmd][single_flag]
426
+ return None
427
+
428
+
429
+ def _generate_distractor_flags(cmd: str, correct_flag: str, count: int = 3) -> list[str]:
430
+ """Generate plausible distractor flags."""
431
+ distractors = []
432
+
433
+ # Get other flags from the same command
434
+ if cmd in FLAG_DATABASE:
435
+ other_flags = [f for f in FLAG_DATABASE[cmd].keys() if f != correct_flag]
436
+ random.shuffle(other_flags)
437
+ distractors.extend(other_flags[:count])
438
+
439
+ # If we need more, get common flags from other commands
440
+ if len(distractors) < count:
441
+ for other_cmd, flags in FLAG_DATABASE.items():
442
+ if other_cmd != cmd:
443
+ for flag in flags:
444
+ if flag not in distractors and flag != correct_flag:
445
+ distractors.append(flag)
446
+ if len(distractors) >= count:
447
+ break
448
+ if len(distractors) >= count:
449
+ break
450
+
451
+ return distractors[:count]
452
+
453
+
454
+ def _generate_distractor_descriptions(correct_desc: str, count: int = 3) -> list[str]:
455
+ """Generate plausible wrong descriptions."""
456
+ distractors = []
457
+
458
+ # Collect all descriptions from FLAG_DATABASE
459
+ all_descriptions = []
460
+ for cmd_flags in FLAG_DATABASE.values():
461
+ all_descriptions.extend(cmd_flags.values())
462
+
463
+ # Remove duplicates and the correct answer
464
+ all_descriptions = list(set(all_descriptions))
465
+ all_descriptions = [d for d in all_descriptions if d.lower() != correct_desc.lower()]
466
+
467
+ random.shuffle(all_descriptions)
468
+ return all_descriptions[:count]
469
+
470
+
471
+ def generate_what_does_quiz(
472
+ command: dict,
473
+ analyzed_data: Optional[dict] = None
474
+ ) -> QuizQuestion:
475
+ """
476
+ Generate a "What does this do?" quiz question.
477
+
478
+ Args:
479
+ command: Dictionary with keys like 'command', 'description', 'complexity', etc.
480
+ analyzed_data: Optional additional analysis data
481
+
482
+ Returns:
483
+ QuizQuestion instance
484
+ """
485
+ cmd_string = command.get("command", "")
486
+ description = command.get("description", "")
487
+ complexity = command.get("complexity", 2)
488
+
489
+ parsed = _parse_command(cmd_string)
490
+ base_cmd = parsed["base"]
491
+
492
+ # Build the correct description
493
+ correct_desc = description
494
+ if not correct_desc:
495
+ # Generate from flags
496
+ flag_descs = []
497
+ for flag in parsed["flags"]:
498
+ fd = _get_flag_description(base_cmd, flag)
499
+ if fd:
500
+ flag_descs.append(fd)
501
+ correct_desc = f"Runs {base_cmd}"
502
+ if flag_descs:
503
+ correct_desc += " with: " + ", ".join(flag_descs)
504
+
505
+ # Generate distractors
506
+ distractor_descriptions = _generate_distractor_descriptions(correct_desc, 3)
507
+
508
+ # Make distractors more plausible by relating to the command
509
+ related_cmds = _get_related_commands(base_cmd)
510
+ if related_cmds and len(distractor_descriptions) < 3:
511
+ for rel_cmd in related_cmds[:3 - len(distractor_descriptions)]:
512
+ distractor_descriptions.append(f"Runs {rel_cmd} to process files")
513
+
514
+ # Ensure we have exactly 3 distractors
515
+ while len(distractor_descriptions) < 3:
516
+ distractor_descriptions.append(f"Performs an unrelated {base_cmd} operation")
517
+
518
+ # Create options (shuffle positions)
519
+ options = []
520
+ correct_id = "a"
521
+
522
+ all_answers = [correct_desc] + distractor_descriptions[:3]
523
+ random.shuffle(all_answers)
524
+
525
+ for i, answer in enumerate(all_answers):
526
+ opt_id = chr(ord('a') + i)
527
+ is_correct = (answer == correct_desc)
528
+ if is_correct:
529
+ correct_id = opt_id
530
+ options.append(QuizOption(
531
+ id=opt_id,
532
+ text=answer,
533
+ is_correct=is_correct,
534
+ explanation=f"{'Correct!' if is_correct else 'Incorrect.'} This command: {correct_desc}"
535
+ ))
536
+
537
+ question_id = _generate_id(f"what_does_{cmd_string}")
538
+
539
+ return QuizQuestion(
540
+ id=question_id,
541
+ quiz_type=QuizType.WHAT_DOES,
542
+ question_text=f"What does this command do?\n\n```bash\n{cmd_string}\n```",
543
+ options=options,
544
+ correct_option_id=correct_id,
545
+ explanation=f"The command `{cmd_string}` {correct_desc.lower()}",
546
+ difficulty=min(complexity, 5),
547
+ command_context=cmd_string,
548
+ tags=[base_cmd, "what_does"]
549
+ )
550
+
551
+
552
+ def generate_which_flag_quiz(
553
+ command: dict,
554
+ analyzed_data: Optional[dict] = None
555
+ ) -> Optional[QuizQuestion]:
556
+ """
557
+ Generate a "Which flag?" quiz question.
558
+
559
+ Args:
560
+ command: Dictionary with command info
561
+ analyzed_data: Optional additional analysis data
562
+
563
+ Returns:
564
+ QuizQuestion instance or None if not enough flag data
565
+ """
566
+ cmd_string = command.get("command", "")
567
+ parsed = _parse_command(cmd_string)
568
+ base_cmd = parsed["base"]
569
+
570
+ if base_cmd not in FLAG_DATABASE or not parsed["flags"]:
571
+ return None
572
+
573
+ # Pick a flag to quiz on
574
+ available_flags = [f for f in parsed["flags"] if f in FLAG_DATABASE.get(base_cmd, {})]
575
+ if not available_flags:
576
+ return None
577
+
578
+ target_flag = random.choice(available_flags)
579
+ flag_desc = FLAG_DATABASE[base_cmd][target_flag]
580
+
581
+ # Generate distractor flags
582
+ distractor_flags = _generate_distractor_flags(base_cmd, target_flag, 3)
583
+
584
+ # Ensure we have exactly 3 distractors
585
+ while len(distractor_flags) < 3:
586
+ fake_flag = f"-{random.choice('abcdefghjkmnopqrstuwxyz')}"
587
+ if fake_flag not in distractor_flags and fake_flag != target_flag:
588
+ distractor_flags.append(fake_flag)
589
+
590
+ # Create options
591
+ all_flags = [target_flag] + distractor_flags[:3]
592
+ random.shuffle(all_flags)
593
+
594
+ options = []
595
+ correct_id = "a"
596
+
597
+ for i, flag in enumerate(all_flags):
598
+ opt_id = chr(ord('a') + i)
599
+ is_correct = (flag == target_flag)
600
+ if is_correct:
601
+ correct_id = opt_id
602
+
603
+ # Get description for option explanation
604
+ flag_explanation = FLAG_DATABASE.get(base_cmd, {}).get(flag, "Unknown flag")
605
+
606
+ options.append(QuizOption(
607
+ id=opt_id,
608
+ text=flag,
609
+ is_correct=is_correct,
610
+ explanation=f"{flag}: {flag_explanation}" if flag in FLAG_DATABASE.get(base_cmd, {}) else f"{flag}: Not a standard flag for {base_cmd}"
611
+ ))
612
+
613
+ question_id = _generate_id(f"which_flag_{base_cmd}_{target_flag}")
614
+
615
+ return QuizQuestion(
616
+ id=question_id,
617
+ quiz_type=QuizType.WHICH_FLAG,
618
+ question_text=f"You want to **{flag_desc.lower()}** when using `{base_cmd}`. Which flag should you use?",
619
+ options=options,
620
+ correct_option_id=correct_id,
621
+ explanation=f"The `{target_flag}` flag in `{base_cmd}` is used to {flag_desc.lower()}",
622
+ difficulty=2,
623
+ command_context=base_cmd,
624
+ tags=[base_cmd, "which_flag"]
625
+ )
626
+
627
+
628
+ def generate_build_command_quiz(
629
+ command: dict,
630
+ analyzed_data: Optional[dict] = None
631
+ ) -> QuizQuestion:
632
+ """
633
+ Generate a "Build the command" quiz question.
634
+
635
+ Args:
636
+ command: Dictionary with command info including intent/description
637
+ analyzed_data: Optional additional analysis data
638
+
639
+ Returns:
640
+ QuizQuestion instance
641
+ """
642
+ cmd_string = command.get("command", "")
643
+ description = command.get("description", "")
644
+ intent = command.get("intent", description)
645
+
646
+ parsed = _parse_command(cmd_string)
647
+ base_cmd = parsed["base"]
648
+
649
+ # Create the correct command structure
650
+ correct_components = [base_cmd] + parsed["flags"] + parsed["args"]
651
+ correct_answer = " ".join(correct_components)
652
+
653
+ # Generate wrong arrangements
654
+ distractors = []
655
+
656
+ # Distractor 1: Wrong order
657
+ if len(correct_components) > 2:
658
+ wrong_order = correct_components.copy()
659
+ random.shuffle(wrong_order)
660
+ if wrong_order != correct_components:
661
+ distractors.append(" ".join(wrong_order))
662
+
663
+ # Distractor 2: Missing flag
664
+ if parsed["flags"]:
665
+ missing_flag = [base_cmd] + parsed["flags"][:-1] + parsed["args"]
666
+ distractors.append(" ".join(missing_flag))
667
+
668
+ # Distractor 3: Wrong flag
669
+ if parsed["flags"] and base_cmd in FLAG_DATABASE:
670
+ wrong_flags = _generate_distractor_flags(base_cmd, parsed["flags"][0], 1)
671
+ if wrong_flags:
672
+ wrong_flag_cmd = [base_cmd] + [wrong_flags[0]] + parsed["flags"][1:] + parsed["args"]
673
+ distractors.append(" ".join(wrong_flag_cmd))
674
+
675
+ # Distractor 4: Related but wrong command
676
+ related = _get_related_commands(base_cmd)
677
+ if related:
678
+ wrong_cmd = [related[0]] + parsed["flags"] + parsed["args"]
679
+ distractors.append(" ".join(wrong_cmd))
680
+
681
+ # Ensure we have exactly 3 distractors
682
+ while len(distractors) < 3:
683
+ # Add a clearly wrong option
684
+ distractors.append(f"{base_cmd} --invalid-option")
685
+
686
+ # Remove duplicates and correct answer from distractors
687
+ distractors = list(set(d for d in distractors if d != correct_answer))[:3]
688
+ while len(distractors) < 3:
689
+ distractors.append(f"{base_cmd} --wrong-flag")
690
+
691
+ # Create options
692
+ all_answers = [correct_answer] + distractors[:3]
693
+ random.shuffle(all_answers)
694
+
695
+ options = []
696
+ correct_id = "a"
697
+
698
+ for i, answer in enumerate(all_answers):
699
+ opt_id = chr(ord('a') + i)
700
+ is_correct = (answer == correct_answer)
701
+ if is_correct:
702
+ correct_id = opt_id
703
+ options.append(QuizOption(
704
+ id=opt_id,
705
+ text=f"`{answer}`",
706
+ is_correct=is_correct,
707
+ explanation="Correct command structure" if is_correct else "Incorrect command structure"
708
+ ))
709
+
710
+ question_id = _generate_id(f"build_{cmd_string}")
711
+
712
+ task_description = intent if intent else f"perform the operation: {description}"
713
+
714
+ return QuizQuestion(
715
+ id=question_id,
716
+ quiz_type=QuizType.BUILD_COMMAND,
717
+ question_text=f"Build the correct command to **{task_description}**.\n\nWhich command is correct?",
718
+ options=options,
719
+ correct_option_id=correct_id,
720
+ explanation=f"The correct command is `{correct_answer}` - this properly accomplishes the task",
721
+ difficulty=3,
722
+ command_context=cmd_string,
723
+ tags=[base_cmd, "build_command"]
724
+ )
725
+
726
+
727
+ def generate_spot_difference_quiz(
728
+ cmd1: dict,
729
+ cmd2: dict
730
+ ) -> Optional[QuizQuestion]:
731
+ """
732
+ Generate a "Spot the difference" quiz question.
733
+
734
+ Args:
735
+ cmd1: First command dictionary
736
+ cmd2: Second command dictionary (should be similar but different)
737
+
738
+ Returns:
739
+ QuizQuestion instance or None if commands aren't suitable
740
+ """
741
+ cmd1_string = cmd1.get("command", "")
742
+ cmd2_string = cmd2.get("command", "")
743
+
744
+ parsed1 = _parse_command(cmd1_string)
745
+ parsed2 = _parse_command(cmd2_string)
746
+
747
+ # Commands should have same base
748
+ if parsed1["base"] != parsed2["base"]:
749
+ return None
750
+
751
+ base_cmd = parsed1["base"]
752
+
753
+ # Find the differences
754
+ flags1 = set(parsed1["flags"])
755
+ flags2 = set(parsed2["flags"])
756
+
757
+ only_in_1 = flags1 - flags2
758
+ only_in_2 = flags2 - flags1
759
+
760
+ if not only_in_1 and not only_in_2:
761
+ # Check if args are different
762
+ if parsed1["args"] == parsed2["args"]:
763
+ return None
764
+
765
+ # Build the correct explanation of difference
766
+ differences = []
767
+ if only_in_1:
768
+ for flag in only_in_1:
769
+ desc = _get_flag_description(base_cmd, flag)
770
+ differences.append(f"Command 1 has `{flag}` ({desc or 'unknown'})")
771
+ if only_in_2:
772
+ for flag in only_in_2:
773
+ desc = _get_flag_description(base_cmd, flag)
774
+ differences.append(f"Command 2 has `{flag}` ({desc or 'unknown'})")
775
+ if parsed1["args"] != parsed2["args"]:
776
+ differences.append(f"Different arguments: '{' '.join(parsed1['args'])}' vs '{' '.join(parsed2['args'])}'")
777
+
778
+ correct_explanation = "; ".join(differences) if differences else "Different arguments or options"
779
+
780
+ # Generate distractor explanations
781
+ distractors = [
782
+ "Both commands do exactly the same thing",
783
+ f"Command 1 runs faster than Command 2",
784
+ f"Command 2 is deprecated, Command 1 is the modern version",
785
+ f"Command 1 modifies files, Command 2 only reads them",
786
+ f"Command 2 requires root permissions, Command 1 doesn't"
787
+ ]
788
+ random.shuffle(distractors)
789
+ distractor_explanations = distractors[:3]
790
+
791
+ # Create options
792
+ all_answers = [correct_explanation] + distractor_explanations
793
+ random.shuffle(all_answers)
794
+
795
+ options = []
796
+ correct_id = "a"
797
+
798
+ for i, answer in enumerate(all_answers):
799
+ opt_id = chr(ord('a') + i)
800
+ is_correct = (answer == correct_explanation)
801
+ if is_correct:
802
+ correct_id = opt_id
803
+ options.append(QuizOption(
804
+ id=opt_id,
805
+ text=answer,
806
+ is_correct=is_correct,
807
+ explanation="Correct analysis" if is_correct else "Incorrect analysis"
808
+ ))
809
+
810
+ question_id = _generate_id(f"spot_diff_{cmd1_string}_{cmd2_string}")
811
+
812
+ return QuizQuestion(
813
+ id=question_id,
814
+ quiz_type=QuizType.SPOT_DIFFERENCE,
815
+ question_text=f"What is the key difference between these two commands?\n\n**Command 1:**\n```bash\n{cmd1_string}\n```\n\n**Command 2:**\n```bash\n{cmd2_string}\n```",
816
+ options=options,
817
+ correct_option_id=correct_id,
818
+ explanation=f"The key difference is: {correct_explanation}",
819
+ difficulty=4,
820
+ command_context=f"{cmd1_string} vs {cmd2_string}",
821
+ tags=[base_cmd, "spot_difference"]
822
+ )
823
+
824
+
825
+ def _create_similar_command_variant(command: dict) -> Optional[dict]:
826
+ """Create a similar but different command for spot-the-difference."""
827
+ cmd_string = command.get("command", "")
828
+ parsed = _parse_command(cmd_string)
829
+ base_cmd = parsed["base"]
830
+
831
+ if base_cmd not in FLAG_DATABASE:
832
+ return None
833
+
834
+ # Strategy: add, remove, or change a flag
835
+ strategies = []
836
+
837
+ # Can add a flag
838
+ available_flags = [f for f in FLAG_DATABASE[base_cmd].keys() if f not in parsed["flags"]]
839
+ if available_flags:
840
+ strategies.append("add")
841
+
842
+ # Can remove a flag
843
+ if parsed["flags"]:
844
+ strategies.append("remove")
845
+
846
+ # Can change a flag
847
+ if parsed["flags"] and available_flags:
848
+ strategies.append("change")
849
+
850
+ if not strategies:
851
+ return None
852
+
853
+ strategy = random.choice(strategies)
854
+ new_flags = parsed["flags"].copy()
855
+
856
+ if strategy == "add" and available_flags:
857
+ new_flags.append(random.choice(available_flags))
858
+ elif strategy == "remove" and new_flags:
859
+ new_flags.pop(random.randint(0, len(new_flags) - 1))
860
+ elif strategy == "change" and new_flags and available_flags:
861
+ idx = random.randint(0, len(new_flags) - 1)
862
+ new_flags[idx] = random.choice(available_flags)
863
+
864
+ new_cmd = " ".join([base_cmd] + new_flags + parsed["args"])
865
+
866
+ return {
867
+ "command": new_cmd,
868
+ "description": f"Variant of {cmd_string}",
869
+ "complexity": command.get("complexity", 2)
870
+ }
871
+
872
+
873
+ def generate_quiz_set(
874
+ analyzed_commands: list[dict],
875
+ count: int = 20
876
+ ) -> list[QuizQuestion]:
877
+ """
878
+ Generate a set of quiz questions from analyzed commands.
879
+
880
+ Args:
881
+ analyzed_commands: List of analyzed command dictionaries
882
+ count: Target number of questions (default 20)
883
+
884
+ Returns:
885
+ List of QuizQuestion instances
886
+ """
887
+ questions: list[QuizQuestion] = []
888
+
889
+ # Filter commands by complexity >= 2
890
+ eligible_commands = [
891
+ cmd for cmd in analyzed_commands
892
+ if cmd.get("complexity", 0) >= 2
893
+ ]
894
+
895
+ if not eligible_commands:
896
+ eligible_commands = analyzed_commands
897
+
898
+ # Weight toward high-frequency commands
899
+ weighted_commands = []
900
+ for cmd in eligible_commands:
901
+ frequency = cmd.get("frequency", 1)
902
+ weight = min(frequency, 5) # Cap weight at 5
903
+ weighted_commands.extend([cmd] * weight)
904
+
905
+ if not weighted_commands:
906
+ weighted_commands = eligible_commands
907
+
908
+ # Calculate target counts for each quiz type
909
+ # Distribution: 40% what_does, 25% which_flag, 20% build, 15% spot_diff
910
+ target_what_does = max(1, int(count * 0.4))
911
+ target_which_flag = max(1, int(count * 0.25))
912
+ target_build = max(1, int(count * 0.2))
913
+ target_spot_diff = max(1, int(count * 0.15))
914
+
915
+ used_commands = set()
916
+
917
+ # Generate "What does this do?" questions
918
+ random.shuffle(weighted_commands)
919
+ for cmd in weighted_commands:
920
+ if len([q for q in questions if q.quiz_type == QuizType.WHAT_DOES]) >= target_what_does:
921
+ break
922
+ cmd_id = cmd.get("command", "")
923
+ if cmd_id not in used_commands:
924
+ q = generate_what_does_quiz(cmd)
925
+ questions.append(q)
926
+ used_commands.add(cmd_id)
927
+
928
+ # Generate "Which flag?" questions
929
+ random.shuffle(weighted_commands)
930
+ for cmd in weighted_commands:
931
+ if len([q for q in questions if q.quiz_type == QuizType.WHICH_FLAG]) >= target_which_flag:
932
+ break
933
+ q = generate_which_flag_quiz(cmd)
934
+ if q:
935
+ questions.append(q)
936
+
937
+ # Generate "Build the command" questions
938
+ random.shuffle(weighted_commands)
939
+ for cmd in weighted_commands:
940
+ if len([q for q in questions if q.quiz_type == QuizType.BUILD_COMMAND]) >= target_build:
941
+ break
942
+ q = generate_build_command_quiz(cmd)
943
+ questions.append(q)
944
+
945
+ # Generate "Spot the difference" questions
946
+ random.shuffle(weighted_commands)
947
+ for cmd in weighted_commands:
948
+ if len([q for q in questions if q.quiz_type == QuizType.SPOT_DIFFERENCE]) >= target_spot_diff:
949
+ break
950
+ variant = _create_similar_command_variant(cmd)
951
+ if variant:
952
+ q = generate_spot_difference_quiz(cmd, variant)
953
+ if q:
954
+ questions.append(q)
955
+
956
+ # Shuffle final questions
957
+ random.shuffle(questions)
958
+
959
+ # Trim to target count
960
+ return questions[:count]
961
+
962
+
963
+ def create_quiz(
964
+ analyzed_commands: list[dict],
965
+ title: str = "Bash Command Quiz",
966
+ description: str = "Test your bash command knowledge",
967
+ question_count: int = 20,
968
+ time_limit: Optional[int] = None
969
+ ) -> Quiz:
970
+ """
971
+ Create a complete quiz from analyzed commands.
972
+
973
+ Args:
974
+ analyzed_commands: List of analyzed command dictionaries
975
+ title: Quiz title
976
+ description: Quiz description
977
+ question_count: Target number of questions
978
+ time_limit: Optional time limit in seconds
979
+
980
+ Returns:
981
+ Quiz instance
982
+ """
983
+ questions = generate_quiz_set(analyzed_commands, question_count)
984
+
985
+ quiz_id = _generate_id(f"{title}_{len(questions)}")
986
+
987
+ return Quiz(
988
+ id=quiz_id,
989
+ title=title,
990
+ description=description,
991
+ questions=questions,
992
+ time_limit_seconds=time_limit
993
+ )
994
+
995
+
996
+ # =============================================================================
997
+ # Main entry point for testing
998
+ # =============================================================================
999
+
1000
+ if __name__ == "__main__":
1001
+ import json
1002
+
1003
+ # Example analyzed commands for testing
1004
+ sample_commands = [
1005
+ {
1006
+ "command": "ls -la /var/log",
1007
+ "description": "List all files including hidden ones in /var/log with detailed info",
1008
+ "complexity": 3,
1009
+ "frequency": 5
1010
+ },
1011
+ {
1012
+ "command": "grep -rn 'error' /var/log",
1013
+ "description": "Search recursively for 'error' with line numbers in /var/log",
1014
+ "complexity": 3,
1015
+ "frequency": 4
1016
+ },
1017
+ {
1018
+ "command": "find . -name '*.py' -type f",
1019
+ "description": "Find all Python files in current directory",
1020
+ "complexity": 4,
1021
+ "frequency": 3
1022
+ },
1023
+ {
1024
+ "command": "tar -czvf backup.tar.gz /home/user",
1025
+ "description": "Create a gzipped tar archive of /home/user",
1026
+ "complexity": 4,
1027
+ "frequency": 2
1028
+ },
1029
+ {
1030
+ "command": "chmod -R 755 /var/www",
1031
+ "description": "Recursively set permissions to 755 on /var/www",
1032
+ "complexity": 3,
1033
+ "frequency": 2
1034
+ },
1035
+ {
1036
+ "command": "curl -sL https://example.com/api",
1037
+ "description": "Silently fetch URL following redirects",
1038
+ "complexity": 3,
1039
+ "frequency": 4
1040
+ },
1041
+ {
1042
+ "command": "ps aux | grep python",
1043
+ "description": "List all processes and filter for python",
1044
+ "complexity": 3,
1045
+ "frequency": 5
1046
+ },
1047
+ {
1048
+ "command": "sed -i 's/old/new/g' file.txt",
1049
+ "description": "Replace all occurrences of 'old' with 'new' in-place",
1050
+ "complexity": 4,
1051
+ "frequency": 2
1052
+ },
1053
+ {
1054
+ "command": "sort -u names.txt",
1055
+ "description": "Sort file and remove duplicate lines",
1056
+ "complexity": 2,
1057
+ "frequency": 3
1058
+ },
1059
+ {
1060
+ "command": "du -sh /home/*",
1061
+ "description": "Show disk usage summary for each home directory",
1062
+ "complexity": 2,
1063
+ "frequency": 4
1064
+ }
1065
+ ]
1066
+
1067
+ # Generate quiz
1068
+ quiz = create_quiz(
1069
+ sample_commands,
1070
+ title="Bash Fundamentals Quiz",
1071
+ description="Test your knowledge of common bash commands",
1072
+ question_count=15
1073
+ )
1074
+
1075
+ # Print quiz as JSON
1076
+ print(json.dumps(quiz.to_dict(), indent=2))
1077
+
1078
+ print(f"\n=== Generated {len(quiz.questions)} questions ===")
1079
+ for i, q in enumerate(quiz.questions, 1):
1080
+ print(f"\n{i}. [{q.quiz_type.value}] {q.question_text[:80]}...")