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.
- package/bin/learn-bash.js +17 -14
- package/package.json +7 -3
- package/scripts/html_generator.py +143 -36
- package/scripts/knowledge_base.py +5624 -1593
- package/scripts/quiz_generator.py +193 -65
- package/bash-learner-output/run-2026-02-05-154214/index.html +0 -3848
- package/bash-learner-output/run-2026-02-05-154214/summary.json +0 -148
- package/bash-learner-output/run-2026-02-05-155427/index.html +0 -3900
- package/bash-learner-output/run-2026-02-05-155427/summary.json +0 -157
- package/bash-learner-output/run-2026-02-05-155949/index.html +0 -4514
- package/bash-learner-output/run-2026-02-05-155949/summary.json +0 -163
|
@@ -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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
660
|
-
|
|
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
|
|
718
|
+
for other_cmd in _get_all_flagged_commands():
|
|
667
719
|
if other_cmd != cmd:
|
|
668
|
-
for flag in
|
|
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
|
-
#
|
|
684
|
-
|
|
685
|
-
for
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
#
|
|
880
|
-
|
|
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(
|
|
888
|
-
wrong_order =
|
|
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
|
-
|
|
891
|
-
|
|
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
|
|
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
|
-
|
|
914
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1171
|
-
if
|
|
1172
|
-
|
|
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
|
-
|
|
1180
|
-
|
|
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
|
-
|
|
1188
|
-
if
|
|
1189
|
-
|
|
1190
|
-
if
|
|
1191
|
-
|
|
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)
|