learn_bash_from_session_data 1.0.5 → 1.0.7

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.
@@ -18,6 +18,45 @@ import random
18
18
  import re
19
19
  import hashlib
20
20
 
21
+ try:
22
+ from scripts.knowledge_base import COMMAND_DB, get_command_info, get_flags_for_command
23
+ except ImportError:
24
+ try:
25
+ from knowledge_base import COMMAND_DB, get_command_info, get_flags_for_command
26
+ except ImportError:
27
+ COMMAND_DB = {}
28
+ def get_command_info(name): return None
29
+ def get_flags_for_command(command): return {}
30
+
31
+
32
+ def _get_flags_for_cmd(cmd: str) -> dict[str, str]:
33
+ """Get merged flags for a command from knowledge_base (primary) and local FLAG_DATABASE (fallback).
34
+
35
+ Knowledge_base.py COMMAND_DB is the authoritative source. FLAG_DATABASE provides
36
+ additional coverage for commands not yet in knowledge_base.
37
+ """
38
+ flags = {}
39
+ # Primary source: knowledge_base COMMAND_DB
40
+ kb_flags = get_flags_for_command(cmd)
41
+ if kb_flags:
42
+ flags.update(kb_flags)
43
+ # Fallback/supplement: local FLAG_DATABASE
44
+ if cmd in FLAG_DATABASE:
45
+ for flag, desc in FLAG_DATABASE[cmd].items():
46
+ if flag not in flags:
47
+ flags[flag] = desc
48
+ return flags
49
+
50
+
51
+ def _get_all_flagged_commands() -> set[str]:
52
+ """Get the set of all commands that have flag data from any source."""
53
+ cmds = set()
54
+ for cmd, info in COMMAND_DB.items():
55
+ if info.get("flags"):
56
+ cmds.add(cmd)
57
+ cmds.update(FLAG_DATABASE.keys())
58
+ return cmds
59
+
21
60
 
22
61
  class QuizType(Enum):
23
62
  """Types of quiz questions."""
@@ -499,6 +538,19 @@ def _describe_single_command(cmd: str) -> str:
499
538
  tokens = cmd.split()
500
539
  base_cmd = tokens[0] if tokens else ''
501
540
 
541
+ # Get args (skip flags) for knowledge_base fallback
542
+ args = [t for t in tokens[1:] if not t.startswith('-')]
543
+
544
+ # Check knowledge_base COMMAND_DB for rich description
545
+ if base_cmd and base_cmd in COMMAND_DB:
546
+ cmd_info = COMMAND_DB[base_cmd]
547
+ kb_desc = cmd_info.get('description', '')
548
+ if kb_desc:
549
+ # Use knowledge base description but make it contextual with args
550
+ if args:
551
+ return f"{kb_desc.lower()} ({' '.join(args[:2])})"
552
+ return kb_desc.lower()
553
+
502
554
  # Common command descriptions with bash focus
503
555
  descriptions = {
504
556
  'cd': lambda args: f"changes directory to {args[0] if args else 'specified path'}",
@@ -637,35 +689,35 @@ def _parse_command(cmd_string: str) -> dict:
637
689
 
638
690
 
639
691
  def _get_flag_description(cmd: str, flag: str) -> Optional[str]:
640
- """Get description for a flag of a command."""
641
- if cmd in FLAG_DATABASE:
642
- # Handle flags like -la (combined short flags)
643
- if flag in FLAG_DATABASE[cmd]:
644
- return FLAG_DATABASE[cmd][flag]
645
- # Try individual characters for combined flags
646
- if len(flag) > 2 and flag.startswith("-") and not flag.startswith("--"):
647
- for char in flag[1:]:
648
- single_flag = f"-{char}"
649
- if single_flag in FLAG_DATABASE[cmd]:
650
- return FLAG_DATABASE[cmd][single_flag]
692
+ """Get description for a flag of a command from merged sources."""
693
+ merged = _get_flags_for_cmd(cmd)
694
+ if flag in merged:
695
+ return merged[flag]
696
+ # Try individual characters for combined flags (e.g., -la -> -l, -a)
697
+ if len(flag) > 2 and flag.startswith("-") and not flag.startswith("--"):
698
+ for char in flag[1:]:
699
+ single_flag = f"-{char}"
700
+ if single_flag in merged:
701
+ return merged[single_flag]
651
702
  return None
652
703
 
653
704
 
654
705
  def _generate_distractor_flags(cmd: str, correct_flag: str, count: int = 3) -> list[str]:
655
- """Generate plausible distractor flags."""
706
+ """Generate plausible distractor flags from merged knowledge sources."""
656
707
  distractors = []
657
708
 
658
- # Get other flags from the same command
659
- if cmd in FLAG_DATABASE:
660
- other_flags = [f for f in FLAG_DATABASE[cmd].keys() if f != correct_flag]
709
+ # Get other flags from the same command (merged sources)
710
+ cmd_flags = _get_flags_for_cmd(cmd)
711
+ if cmd_flags:
712
+ other_flags = [f for f in cmd_flags.keys() if f != correct_flag]
661
713
  random.shuffle(other_flags)
662
714
  distractors.extend(other_flags[:count])
663
715
 
664
716
  # If we need more, get common flags from other commands
665
717
  if len(distractors) < count:
666
- for other_cmd, flags in FLAG_DATABASE.items():
718
+ for other_cmd in _get_all_flagged_commands():
667
719
  if other_cmd != cmd:
668
- for flag in flags:
720
+ for flag in _get_flags_for_cmd(other_cmd):
669
721
  if flag not in distractors and flag != correct_flag:
670
722
  distractors.append(flag)
671
723
  if len(distractors) >= count:
@@ -677,20 +729,44 @@ def _generate_distractor_flags(cmd: str, correct_flag: str, count: int = 3) -> l
677
729
 
678
730
 
679
731
  def _generate_distractor_descriptions(correct_desc: str, count: int = 3) -> list[str]:
680
- """Generate plausible wrong descriptions."""
732
+ """Generate plausible wrong descriptions using command-level descriptions for length parity."""
681
733
  distractors = []
682
734
 
683
- # Collect all descriptions from FLAG_DATABASE
684
- all_descriptions = []
685
- for cmd_flags in FLAG_DATABASE.values():
686
- all_descriptions.extend(cmd_flags.values())
687
-
688
- # Remove duplicates and the correct answer
689
- all_descriptions = list(set(all_descriptions))
690
- all_descriptions = [d for d in all_descriptions if d.lower() != correct_desc.lower()]
691
-
692
- random.shuffle(all_descriptions)
693
- return all_descriptions[:count]
735
+ # First: collect command-level descriptions from COMMAND_DB (similar length to correct answer)
736
+ cmd_descriptions = []
737
+ for cmd_name in COMMAND_DB:
738
+ cmd_info = COMMAND_DB[cmd_name]
739
+ desc = cmd_info.get('description', '')
740
+ if desc and desc.lower() != correct_desc.lower():
741
+ # Truncate very long descriptions to similar length as correct answer
742
+ max_len = max(len(correct_desc) + 40, 80)
743
+ if len(desc) > max_len:
744
+ desc = desc[:max_len].rsplit(' ', 1)[0] + '...'
745
+ cmd_descriptions.append(desc)
746
+
747
+ if cmd_descriptions:
748
+ random.shuffle(cmd_descriptions)
749
+ distractors.extend(cmd_descriptions[:count])
750
+
751
+ # Fallback: use flag descriptions if not enough command descriptions
752
+ if len(distractors) < count:
753
+ all_flag_descs = []
754
+ for cmd in _get_all_flagged_commands():
755
+ all_flag_descs.extend(_get_flags_for_cmd(cmd).values())
756
+ all_flag_descs = list(set(all_flag_descs))
757
+ all_flag_descs = [d for d in all_flag_descs if d.lower() != correct_desc.lower()]
758
+ random.shuffle(all_flag_descs)
759
+ distractors.extend(all_flag_descs[:count - len(distractors)])
760
+
761
+ # Remove duplicates
762
+ seen = set()
763
+ unique = []
764
+ for d in distractors:
765
+ dl = d.lower()
766
+ if dl not in seen:
767
+ seen.add(dl)
768
+ unique.append(d)
769
+ return unique[:count]
694
770
 
695
771
 
696
772
  def generate_what_does_quiz(
@@ -741,9 +817,27 @@ def generate_what_does_quiz(
741
817
  for rel_cmd in related_cmds[:3 - len(distractor_descriptions)]:
742
818
  distractor_descriptions.append(f"Runs {rel_cmd} to process files")
743
819
 
744
- # Ensure we have exactly 3 distractors
820
+ # Ensure we have exactly 3 distractors with plausible alternatives
821
+ fallback_actions = [
822
+ f"List directory contents with detailed file information",
823
+ f"Search recursively through files for matching patterns",
824
+ f"Display or modify file permissions and ownership",
825
+ f"Compress or archive files for storage and transfer",
826
+ f"Monitor system processes and resource usage",
827
+ f"Download files from a remote server or URL",
828
+ f"Edit configuration files in the default text editor",
829
+ f"Install or update packages from the package manager",
830
+ ]
831
+ random.shuffle(fallback_actions)
832
+ fb_idx = 0
745
833
  while len(distractor_descriptions) < 3:
746
- distractor_descriptions.append(f"Performs an unrelated {base_cmd} operation")
834
+ if fb_idx < len(fallback_actions):
835
+ fallback = fallback_actions[fb_idx]
836
+ if fallback.lower() != correct_desc.lower():
837
+ distractor_descriptions.append(fallback)
838
+ fb_idx += 1
839
+ else:
840
+ distractor_descriptions.append(f"Run a system utility to process input data")
747
841
 
748
842
  # Create options (shuffle positions)
749
843
  options = []
@@ -797,16 +891,17 @@ def generate_which_flag_quiz(
797
891
  parsed = _parse_command(cmd_string)
798
892
  base_cmd = parsed["base"]
799
893
 
800
- if base_cmd not in FLAG_DATABASE or not parsed["flags"]:
894
+ cmd_flags = _get_flags_for_cmd(base_cmd)
895
+ if not cmd_flags or not parsed["flags"]:
801
896
  return None
802
897
 
803
898
  # Pick a flag to quiz on
804
- available_flags = [f for f in parsed["flags"] if f in FLAG_DATABASE.get(base_cmd, {})]
899
+ available_flags = [f for f in parsed["flags"] if f in cmd_flags]
805
900
  if not available_flags:
806
901
  return None
807
902
 
808
903
  target_flag = random.choice(available_flags)
809
- flag_desc = FLAG_DATABASE[base_cmd][target_flag]
904
+ flag_desc = cmd_flags[target_flag]
810
905
 
811
906
  # Generate distractor flags
812
907
  distractor_flags = _generate_distractor_flags(base_cmd, target_flag, 3)
@@ -831,13 +926,13 @@ def generate_which_flag_quiz(
831
926
  correct_id = opt_id
832
927
 
833
928
  # Get description for option explanation
834
- flag_explanation = FLAG_DATABASE.get(base_cmd, {}).get(flag, "Unknown flag")
929
+ flag_explanation = cmd_flags.get(flag, "Unknown flag")
835
930
 
836
931
  options.append(QuizOption(
837
932
  id=opt_id,
838
933
  text=flag,
839
934
  is_correct=is_correct,
840
- explanation=f"{flag}: {flag_explanation}" if flag in FLAG_DATABASE.get(base_cmd, {}) else f"{flag}: Not a standard flag for {base_cmd}"
935
+ explanation=f"{flag}: {flag_explanation}" if flag in cmd_flags else f"{flag}: Not a standard flag for {base_cmd}"
841
936
  ))
842
937
 
843
938
  question_id = _generate_id(f"which_flag_{base_cmd}_{target_flag}")
@@ -876,19 +971,20 @@ def generate_build_command_quiz(
876
971
  parsed = _parse_command(cmd_string)
877
972
  base_cmd = parsed["base"]
878
973
 
879
- # Create the correct command structure
880
- correct_components = [base_cmd] + parsed["flags"] + parsed["args"]
881
- correct_answer = " ".join(correct_components)
974
+ # Use the original command string as correct answer (preserves flag-argument ordering)
975
+ correct_answer = cmd_string.strip()
882
976
 
883
- # Generate wrong arrangements
977
+ # Generate wrong arrangements using parsed components
978
+ all_parts = [base_cmd] + parsed["flags"] + parsed["args"]
884
979
  distractors = []
885
980
 
886
- # Distractor 1: Wrong order
887
- if len(correct_components) > 2:
888
- wrong_order = correct_components.copy()
981
+ # Distractor 1: Wrong order of components
982
+ if len(all_parts) > 2:
983
+ wrong_order = all_parts.copy()
889
984
  random.shuffle(wrong_order)
890
- if wrong_order != correct_components:
891
- distractors.append(" ".join(wrong_order))
985
+ wrong_str = " ".join(wrong_order)
986
+ if wrong_str != correct_answer:
987
+ distractors.append(wrong_str)
892
988
 
893
989
  # Distractor 2: Missing flag
894
990
  if parsed["flags"]:
@@ -896,7 +992,7 @@ def generate_build_command_quiz(
896
992
  distractors.append(" ".join(missing_flag))
897
993
 
898
994
  # Distractor 3: Wrong flag
899
- if parsed["flags"] and base_cmd in FLAG_DATABASE:
995
+ if parsed["flags"] and _get_flags_for_cmd(base_cmd):
900
996
  wrong_flags = _generate_distractor_flags(base_cmd, parsed["flags"][0], 1)
901
997
  if wrong_flags:
902
998
  wrong_flag_cmd = [base_cmd] + [wrong_flags[0]] + parsed["flags"][1:] + parsed["args"]
@@ -908,15 +1004,31 @@ def generate_build_command_quiz(
908
1004
  wrong_cmd = [related[0]] + parsed["flags"] + parsed["args"]
909
1005
  distractors.append(" ".join(wrong_cmd))
910
1006
 
911
- # Ensure we have exactly 3 distractors
1007
+ # Ensure we have exactly 3 distractors with plausible alternatives
1008
+ # Use real flags from the knowledge base as fallback distractors
1009
+ all_cmd_flags = list(_get_flags_for_cmd(base_cmd).keys())
1010
+ random.shuffle(all_cmd_flags)
1011
+ fb_flag_idx = 0
912
1012
  while len(distractors) < 3:
913
- # Add a clearly wrong option
914
- distractors.append(f"{base_cmd} --invalid-option")
1013
+ if fb_flag_idx < len(all_cmd_flags):
1014
+ fallback_flag = all_cmd_flags[fb_flag_idx]
1015
+ fallback_cmd = f"{base_cmd} {fallback_flag} {' '.join(parsed['args'])}"
1016
+ if fallback_cmd.strip() != correct_answer:
1017
+ distractors.append(fallback_cmd.strip())
1018
+ fb_flag_idx += 1
1019
+ else:
1020
+ # Use a related command as last resort
1021
+ related = _get_related_commands(base_cmd)
1022
+ if related:
1023
+ rel = related[len(distractors) % len(related)]
1024
+ distractors.append(f"{rel} {' '.join(parsed['flags'])} {' '.join(parsed['args'])}".strip())
1025
+ else:
1026
+ distractors.append(f"{base_cmd} {' '.join(parsed['args'])}".strip())
915
1027
 
916
1028
  # Remove duplicates and correct answer from distractors
917
1029
  distractors = list(set(d for d in distractors if d != correct_answer))[:3]
918
1030
  while len(distractors) < 3:
919
- distractors.append(f"{base_cmd} --wrong-flag")
1031
+ distractors.append(f"{base_cmd} {' '.join(parsed['args'])}".strip())
920
1032
 
921
1033
  # Create options
922
1034
  all_answers = [correct_answer] + distractors[:3]
@@ -1065,14 +1177,15 @@ def _create_similar_command_variant(command: dict) -> Optional[dict]:
1065
1177
  parsed = _parse_command(cmd_string)
1066
1178
  base_cmd = parsed["base"]
1067
1179
 
1068
- if base_cmd not in FLAG_DATABASE:
1180
+ variant_flags = _get_flags_for_cmd(base_cmd)
1181
+ if not variant_flags:
1069
1182
  return None
1070
1183
 
1071
1184
  # Strategy: add, remove, or change a flag
1072
1185
  strategies = []
1073
1186
 
1074
1187
  # Can add a flag
1075
- available_flags = [f for f in FLAG_DATABASE[base_cmd].keys() if f not in parsed["flags"]]
1188
+ available_flags = [f for f in variant_flags.keys() if f not in parsed["flags"]]
1076
1189
  if available_flags:
1077
1190
  strategies.append("add")
1078
1191
 
@@ -1149,7 +1262,13 @@ def generate_quiz_set(
1149
1262
  target_build = max(1, int(count * 0.2))
1150
1263
  target_spot_diff = max(1, int(count * 0.15))
1151
1264
 
1152
- used_commands = set()
1265
+ # Track used commands per quiz type to avoid repeating the same command
1266
+ used_per_type = {
1267
+ QuizType.WHAT_DOES: set(),
1268
+ QuizType.WHICH_FLAG: set(),
1269
+ QuizType.BUILD_COMMAND: set(),
1270
+ QuizType.SPOT_DIFFERENCE: set(),
1271
+ }
1153
1272
 
1154
1273
  # Generate "What does this do?" questions
1155
1274
  random.shuffle(weighted_commands)
@@ -1157,38 +1276,47 @@ def generate_quiz_set(
1157
1276
  if len([q for q in questions if q.quiz_type == QuizType.WHAT_DOES]) >= target_what_does:
1158
1277
  break
1159
1278
  cmd_id = cmd.get("command", "")
1160
- if cmd_id not in used_commands:
1279
+ if cmd_id not in used_per_type[QuizType.WHAT_DOES]:
1161
1280
  q = generate_what_does_quiz(cmd)
1162
1281
  questions.append(q)
1163
- used_commands.add(cmd_id)
1282
+ used_per_type[QuizType.WHAT_DOES].add(cmd_id)
1164
1283
 
1165
1284
  # Generate "Which flag?" questions
1166
1285
  random.shuffle(weighted_commands)
1167
1286
  for cmd in weighted_commands:
1168
1287
  if len([q for q in questions if q.quiz_type == QuizType.WHICH_FLAG]) >= target_which_flag:
1169
1288
  break
1170
- q = generate_which_flag_quiz(cmd)
1171
- if q:
1172
- questions.append(q)
1289
+ cmd_id = cmd.get("command", "")
1290
+ if cmd_id not in used_per_type[QuizType.WHICH_FLAG]:
1291
+ q = generate_which_flag_quiz(cmd)
1292
+ if q:
1293
+ questions.append(q)
1294
+ used_per_type[QuizType.WHICH_FLAG].add(cmd_id)
1173
1295
 
1174
1296
  # Generate "Build the command" questions
1175
1297
  random.shuffle(weighted_commands)
1176
1298
  for cmd in weighted_commands:
1177
1299
  if len([q for q in questions if q.quiz_type == QuizType.BUILD_COMMAND]) >= target_build:
1178
1300
  break
1179
- q = generate_build_command_quiz(cmd)
1180
- questions.append(q)
1301
+ cmd_id = cmd.get("command", "")
1302
+ if cmd_id not in used_per_type[QuizType.BUILD_COMMAND]:
1303
+ q = generate_build_command_quiz(cmd)
1304
+ questions.append(q)
1305
+ used_per_type[QuizType.BUILD_COMMAND].add(cmd_id)
1181
1306
 
1182
1307
  # Generate "Spot the difference" questions
1183
1308
  random.shuffle(weighted_commands)
1184
1309
  for cmd in weighted_commands:
1185
1310
  if len([q for q in questions if q.quiz_type == QuizType.SPOT_DIFFERENCE]) >= target_spot_diff:
1186
1311
  break
1187
- variant = _create_similar_command_variant(cmd)
1188
- if variant:
1189
- q = generate_spot_difference_quiz(cmd, variant)
1190
- if q:
1191
- questions.append(q)
1312
+ cmd_id = cmd.get("command", "")
1313
+ if cmd_id not in used_per_type[QuizType.SPOT_DIFFERENCE]:
1314
+ variant = _create_similar_command_variant(cmd)
1315
+ if variant:
1316
+ q = generate_spot_difference_quiz(cmd, variant)
1317
+ if q:
1318
+ questions.append(q)
1319
+ used_per_type[QuizType.SPOT_DIFFERENCE].add(cmd_id)
1192
1320
 
1193
1321
  # Shuffle final questions
1194
1322
  random.shuffle(questions)