masterytrace-cli 0.1.0__py3-none-any.whl
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.
- masterytrace/__init__.py +68 -0
- masterytrace/adapters/__init__.py +0 -0
- masterytrace/adapters/generic_adapter.py +134 -0
- masterytrace/cli/__init__.py +0 -0
- masterytrace/cli/commands/__init__.py +0 -0
- masterytrace/cli/commands/init.py +57 -0
- masterytrace/cli/commands/record.py +51 -0
- masterytrace/cli/commands/report.py +102 -0
- masterytrace/cli/commands/score.py +63 -0
- masterytrace/cli/format.py +17 -0
- masterytrace/cli/index.py +177 -0
- masterytrace/cli/json_encode.py +52 -0
- masterytrace/cli/types.py +21 -0
- masterytrace/core/__init__.py +0 -0
- masterytrace/core/config.py +62 -0
- masterytrace/core/engine.py +54 -0
- masterytrace/core/event_schema.py +131 -0
- masterytrace/core/scoring_model.py +68 -0
- masterytrace/data/__init__.py +0 -0
- masterytrace/data/sample_events.py +78 -0
- masterytrace/models/__init__.py +0 -0
- masterytrace/models/bkt.py +275 -0
- masterytrace/models/irt.py +285 -0
- masterytrace/py.typed +0 -0
- masterytrace_cli-0.1.0.dist-info/METADATA +362 -0
- masterytrace_cli-0.1.0.dist-info/RECORD +29 -0
- masterytrace_cli-0.1.0.dist-info/WHEEL +4 -0
- masterytrace_cli-0.1.0.dist-info/entry_points.txt +2 -0
- masterytrace_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
masterytrace/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MasteryTrace: Mastery Measurement API and CLI. Fits Bayesian Knowledge
|
|
3
|
+
Tracing (BKT) and 2-parameter logistic Item Response Theory (IRT) models
|
|
4
|
+
to learner response logs and reports per-learner, per-skill mastery
|
|
5
|
+
estimates.
|
|
6
|
+
|
|
7
|
+
Re-exports mirror src/index.ts's re-export surface (core/*, models/*,
|
|
8
|
+
adapters/*) so `from masterytrace import ...` covers the same public API
|
|
9
|
+
as the npm package's `from 'masterytrace-cli'`.
|
|
10
|
+
"""
|
|
11
|
+
from .adapters.generic_adapter import GenericAdapter, generic_adapter, parse_csv
|
|
12
|
+
from .core.config import DEFAULT_CONFIG, MasteryTraceConfig, load_config
|
|
13
|
+
from .core.engine import EngineConfig, EngineResult, ModelSelector, run_scoring
|
|
14
|
+
from .core.event_schema import EventValidationError, ResponseEvent, parse_response_events
|
|
15
|
+
from .core.scoring_model import FittedModel, MasteryLearnerEntry, MasteryReport, MasterySkillEntry, ScoringModel
|
|
16
|
+
from .models.bkt import (
|
|
17
|
+
BKT_DEFAULT_PARAMS,
|
|
18
|
+
BktConfig,
|
|
19
|
+
BktFittedModel,
|
|
20
|
+
BktModel,
|
|
21
|
+
BktParams,
|
|
22
|
+
fit_skill_params_by_grid_search,
|
|
23
|
+
run_forward_recursion,
|
|
24
|
+
)
|
|
25
|
+
from .models.irt import (
|
|
26
|
+
IrtConfig,
|
|
27
|
+
IrtFittedModel,
|
|
28
|
+
IrtItemParams,
|
|
29
|
+
IrtLearnerResult,
|
|
30
|
+
IrtModel,
|
|
31
|
+
probability_correct,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"GenericAdapter",
|
|
38
|
+
"generic_adapter",
|
|
39
|
+
"parse_csv",
|
|
40
|
+
"DEFAULT_CONFIG",
|
|
41
|
+
"MasteryTraceConfig",
|
|
42
|
+
"load_config",
|
|
43
|
+
"EngineConfig",
|
|
44
|
+
"EngineResult",
|
|
45
|
+
"ModelSelector",
|
|
46
|
+
"run_scoring",
|
|
47
|
+
"EventValidationError",
|
|
48
|
+
"ResponseEvent",
|
|
49
|
+
"parse_response_events",
|
|
50
|
+
"FittedModel",
|
|
51
|
+
"MasteryLearnerEntry",
|
|
52
|
+
"MasteryReport",
|
|
53
|
+
"MasterySkillEntry",
|
|
54
|
+
"ScoringModel",
|
|
55
|
+
"BKT_DEFAULT_PARAMS",
|
|
56
|
+
"BktConfig",
|
|
57
|
+
"BktFittedModel",
|
|
58
|
+
"BktModel",
|
|
59
|
+
"BktParams",
|
|
60
|
+
"fit_skill_params_by_grid_search",
|
|
61
|
+
"run_forward_recursion",
|
|
62
|
+
"IrtConfig",
|
|
63
|
+
"IrtFittedModel",
|
|
64
|
+
"IrtItemParams",
|
|
65
|
+
"IrtLearnerResult",
|
|
66
|
+
"IrtModel",
|
|
67
|
+
"probability_correct",
|
|
68
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A source of response events: reads a JSON array of response events, or a
|
|
3
|
+
CSV file with columns `learner_id,skill_id,correct,timestamp`, chosen by
|
|
4
|
+
file extension. Ported from src/adapters/generic-adapter.ts, including its
|
|
5
|
+
size guard and its symlink refusal.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, List, Union
|
|
13
|
+
|
|
14
|
+
from ..core.event_schema import ResponseEvent, parse_response_events
|
|
15
|
+
|
|
16
|
+
# Event logs are small structured records (a handful of fields per
|
|
17
|
+
# response event); there is no legitimate reason for one to approach this
|
|
18
|
+
# size. This guard exists for the case where masterytrace is invoked
|
|
19
|
+
# programmatically (e.g. by another agent/tool) on a file it did not
|
|
20
|
+
# choose itself: without it, an oversized file is read fully into memory
|
|
21
|
+
# and handed to json.loads before any validation runs, which can exhaust
|
|
22
|
+
# memory or hang the process well before the "not valid event data" error
|
|
23
|
+
# a bad file should produce.
|
|
24
|
+
MAX_EVENT_LOG_BYTES = 100 * 1024 * 1024 # 100 MB
|
|
25
|
+
|
|
26
|
+
_CSV_COLUMNS = ("learner_id", "skill_id", "correct", "timestamp")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _assert_readable_regular_file(path: Union[str, Path]) -> None:
|
|
30
|
+
"""
|
|
31
|
+
os.lstat (not os.stat) never follows a symlink, so a symlink at
|
|
32
|
+
`path` is caught here even if its target is a regular file elsewhere
|
|
33
|
+
on disk.
|
|
34
|
+
"""
|
|
35
|
+
stats = os.lstat(path)
|
|
36
|
+
import stat as stat_module
|
|
37
|
+
|
|
38
|
+
if stat_module.S_ISLNK(stats.st_mode):
|
|
39
|
+
raise ValueError(f"Refusing to read '{path}': symlinks are not supported for event log paths.")
|
|
40
|
+
if not stat_module.S_ISREG(stats.st_mode):
|
|
41
|
+
raise ValueError(f"Refusing to read '{path}': not a regular file.")
|
|
42
|
+
if stats.st_size > MAX_EVENT_LOG_BYTES:
|
|
43
|
+
size_mb = stats.st_size / (1024 * 1024)
|
|
44
|
+
limit_mb = MAX_EVENT_LOG_BYTES / (1024 * 1024)
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"Refusing to read '{path}': file is {size_mb:.1f} MB, which exceeds the {limit_mb:.0f} MB event log size limit."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_csv_boolean(raw: str) -> Union[bool, str]:
|
|
51
|
+
"""
|
|
52
|
+
Parses a CSV `correct` cell into a boolean. Only recognizes
|
|
53
|
+
`true`/`false` and `1`/`0` (case-insensitive, trimmed); anything else
|
|
54
|
+
is returned as the original raw string rather than silently guessed.
|
|
55
|
+
That matters: coercing every unrecognized value to False would make a
|
|
56
|
+
typo, an empty cell from a shifted column, or a "yes"/"no" export
|
|
57
|
+
format silently record as an incorrect response instead of surfacing
|
|
58
|
+
as bad data. Returning the raw string instead lets it fail
|
|
59
|
+
ResponseEvent validation's `correct` boolean check with a clear,
|
|
60
|
+
row-numbered validation error, exactly like malformed JSON input does.
|
|
61
|
+
"""
|
|
62
|
+
normalized = raw.strip().lower()
|
|
63
|
+
if normalized in ("true", "1"):
|
|
64
|
+
return True
|
|
65
|
+
if normalized in ("false", "0"):
|
|
66
|
+
return False
|
|
67
|
+
return raw
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_csv(content: str) -> List[Any]:
|
|
71
|
+
"""
|
|
72
|
+
Parses the fixed-column CSV format (`learner_id,skill_id,correct,
|
|
73
|
+
timestamp`) into the raw row shape event-schema expects, so it goes
|
|
74
|
+
through the same validation as JSON input. Deliberately hand-rolled
|
|
75
|
+
rather than pulling in a CSV library: the format has no quoting/
|
|
76
|
+
escaping requirements (skill and learner ids are plain identifiers),
|
|
77
|
+
so a dependency would buy nothing.
|
|
78
|
+
"""
|
|
79
|
+
lines = [line for line in content.splitlines() if line.strip()]
|
|
80
|
+
if not lines:
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
header = [h.strip() for h in lines[0].split(",")]
|
|
84
|
+
column_index = {name: i for i, name in enumerate(header)}
|
|
85
|
+
for required in _CSV_COLUMNS:
|
|
86
|
+
if required not in column_index:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"CSV event log is missing required column '{required}'. Expected header: {','.join(_CSV_COLUMNS)}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
rows: List[Any] = []
|
|
92
|
+
for line in lines[1:]:
|
|
93
|
+
cells = line.split(",")
|
|
94
|
+
|
|
95
|
+
def cell(name: str) -> str:
|
|
96
|
+
index = column_index.get(name)
|
|
97
|
+
if index is None or index >= len(cells):
|
|
98
|
+
return ""
|
|
99
|
+
return cells[index].strip()
|
|
100
|
+
|
|
101
|
+
rows.append(
|
|
102
|
+
{
|
|
103
|
+
"learnerId": cell("learner_id"),
|
|
104
|
+
"skillId": cell("skill_id"),
|
|
105
|
+
"correct": _parse_csv_boolean(cell("correct")),
|
|
106
|
+
"timestamp": cell("timestamp"),
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
return rows
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class GenericAdapter:
|
|
113
|
+
"""
|
|
114
|
+
The only event-log adapter shipped in v0.1: reads a JSON array of
|
|
115
|
+
response events, or a CSV file with columns
|
|
116
|
+
`learner_id,skill_id,correct,timestamp`, chosen by file extension.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
name = "generic"
|
|
120
|
+
|
|
121
|
+
def load(self, path: Union[str, Path]) -> List[ResponseEvent]:
|
|
122
|
+
_assert_readable_regular_file(path)
|
|
123
|
+
content = Path(path).read_text(encoding="utf-8")
|
|
124
|
+
extension = Path(path).suffix.lower()
|
|
125
|
+
|
|
126
|
+
if extension == ".csv":
|
|
127
|
+
raw: Any = parse_csv(content)
|
|
128
|
+
else:
|
|
129
|
+
raw = json.loads(content)
|
|
130
|
+
|
|
131
|
+
return parse_response_events(raw)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
generic_adapter = GenericAdapter()
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scaffolds a bundled sample `events.json` (3 learners x 3 skills, several
|
|
3
|
+
responses each) and a default `masterytrace.config.json` in `cwd`.
|
|
4
|
+
Ported from src/cli/commands/init.ts.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ...core.config import DEFAULT_CONFIG
|
|
13
|
+
from ...data.sample_events import SAMPLE_EVENTS
|
|
14
|
+
from ..format import ok
|
|
15
|
+
from ..json_encode import to_jsonable
|
|
16
|
+
from ..types import CommandResult
|
|
17
|
+
|
|
18
|
+
EVENTS_SAMPLE_FILENAME = "events.json"
|
|
19
|
+
CONFIG_FILENAME = "masterytrace.config.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class InitOptions:
|
|
24
|
+
json: bool
|
|
25
|
+
force: bool
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_init(cwd: str, options: InitOptions) -> CommandResult:
|
|
29
|
+
"""
|
|
30
|
+
Existing files are left untouched unless `--force` is passed.
|
|
31
|
+
"""
|
|
32
|
+
events_path = Path(cwd) / EVENTS_SAMPLE_FILENAME
|
|
33
|
+
config_path = Path(cwd) / CONFIG_FILENAME
|
|
34
|
+
|
|
35
|
+
created = []
|
|
36
|
+
skipped = []
|
|
37
|
+
|
|
38
|
+
if not options.force and events_path.exists():
|
|
39
|
+
skipped.append(EVENTS_SAMPLE_FILENAME)
|
|
40
|
+
else:
|
|
41
|
+
events_path.write_text(json.dumps(to_jsonable(SAMPLE_EVENTS), indent=2) + "\n", encoding="utf-8")
|
|
42
|
+
created.append(EVENTS_SAMPLE_FILENAME)
|
|
43
|
+
|
|
44
|
+
if not options.force and config_path.exists():
|
|
45
|
+
skipped.append(CONFIG_FILENAME)
|
|
46
|
+
else:
|
|
47
|
+
config_path.write_text(json.dumps(DEFAULT_CONFIG, indent=2) + "\n", encoding="utf-8")
|
|
48
|
+
created.append(CONFIG_FILENAME)
|
|
49
|
+
|
|
50
|
+
lines = []
|
|
51
|
+
if created:
|
|
52
|
+
lines.append(f"Created: {', '.join(created)}")
|
|
53
|
+
if skipped:
|
|
54
|
+
lines.append(f"Skipped (already exists, use --force to overwrite): {', '.join(skipped)}")
|
|
55
|
+
lines.append("Next: run 'masterytrace record events.json' to load it, then 'masterytrace score'.")
|
|
56
|
+
|
|
57
|
+
return ok(options.json, {"created": created, "skipped": skipped}, "\n".join(lines) + "\n")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Validates and loads an event log (JSON or CSV, chosen by file extension)
|
|
3
|
+
and stores it to `.masterytrace/events.json`. Storing always *replaces*
|
|
4
|
+
any previously stored log in v0.1 (there is no append/merge mode yet).
|
|
5
|
+
Ported from src/cli/commands/record.ts.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ...adapters.generic_adapter import generic_adapter
|
|
14
|
+
from ...core.event_schema import EventValidationError
|
|
15
|
+
from ..format import fail, ok
|
|
16
|
+
from ..json_encode import to_jsonable
|
|
17
|
+
from ..types import CommandResult
|
|
18
|
+
|
|
19
|
+
STATE_DIR = ".masterytrace"
|
|
20
|
+
EVENTS_STATE_FILENAME = "events.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class RecordOptions:
|
|
25
|
+
json: bool
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_record(cwd: str, event_log_path: str, options: RecordOptions) -> CommandResult:
|
|
29
|
+
try:
|
|
30
|
+
events = generic_adapter.load(event_log_path)
|
|
31
|
+
except EventValidationError as error:
|
|
32
|
+
return fail(
|
|
33
|
+
2,
|
|
34
|
+
options.json,
|
|
35
|
+
{"error": str(error), "issues": error.issues},
|
|
36
|
+
f"Validation error:\n{error}\n",
|
|
37
|
+
)
|
|
38
|
+
except Exception as error: # noqa: BLE001 -- mirrors the TS catch-all for I/O/parse errors
|
|
39
|
+
return fail(1, options.json, {"error": str(error)}, f"Error: {error}\n")
|
|
40
|
+
|
|
41
|
+
state_dir = Path(cwd) / STATE_DIR
|
|
42
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
state_path = state_dir / EVENTS_STATE_FILENAME
|
|
44
|
+
state_path.write_text(json.dumps(to_jsonable(events), indent=2) + "\n", encoding="utf-8")
|
|
45
|
+
|
|
46
|
+
return ok(
|
|
47
|
+
options.json,
|
|
48
|
+
{"eventCount": len(events), "storedAt": str(state_path)},
|
|
49
|
+
f"Stored {len(events)} event(s) to {state_path}\n"
|
|
50
|
+
"(record replaces any previously stored event log; see --help for details.)\n",
|
|
51
|
+
)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reads `.masterytrace/scores.json` (written by `masterytrace score`) and
|
|
3
|
+
prints a per-learner, per-skill mastery table in the requested format.
|
|
4
|
+
Ported from src/cli/commands/report.ts.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List
|
|
12
|
+
|
|
13
|
+
from ..format import fail
|
|
14
|
+
from ..types import CommandResult
|
|
15
|
+
from .record import STATE_DIR
|
|
16
|
+
from .score import SCORES_STATE_FILENAME
|
|
17
|
+
|
|
18
|
+
ReportFormat = str # "table" | "json" | "markdown"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ReportOptions:
|
|
23
|
+
json: bool
|
|
24
|
+
format: ReportFormat
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build_rows(result: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
28
|
+
rows: List[Dict[str, Any]] = []
|
|
29
|
+
for report in result.get("reports", []):
|
|
30
|
+
for learner in report.get("learners", []):
|
|
31
|
+
for skill in learner.get("skills", []):
|
|
32
|
+
rows.append(
|
|
33
|
+
{
|
|
34
|
+
"learner_id": learner["learner_id"],
|
|
35
|
+
"skill_id": skill["skill_id"],
|
|
36
|
+
"model": report["model"],
|
|
37
|
+
"metric": skill["metric"],
|
|
38
|
+
"value": skill["value"],
|
|
39
|
+
"response_count": skill["response_count"],
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
rows.sort(key=lambda r: (r["learner_id"], r["skill_id"], r["model"]))
|
|
43
|
+
return rows
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_TABLE_HEADER = ["learner", "skill", "model", "metric", "value", "responses"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _render_table(rows: List[Dict[str, Any]]) -> str:
|
|
50
|
+
if not rows:
|
|
51
|
+
return "No scores found.\n"
|
|
52
|
+
data = [
|
|
53
|
+
[r["learner_id"], r["skill_id"], r["model"], r["metric"], f"{r['value']:.4f}", str(r["response_count"])]
|
|
54
|
+
for r in rows
|
|
55
|
+
]
|
|
56
|
+
widths = [max(len(_TABLE_HEADER[i]), *(len(row[i]) for row in data)) for i in range(len(_TABLE_HEADER))]
|
|
57
|
+
|
|
58
|
+
def render_row(cells: List[str]) -> str:
|
|
59
|
+
return " ".join(cell.ljust(widths[i]) for i, cell in enumerate(cells))
|
|
60
|
+
|
|
61
|
+
lines = [render_row(_TABLE_HEADER), render_row(["-" * w for w in widths])]
|
|
62
|
+
lines.extend(render_row(row) for row in data)
|
|
63
|
+
return "\n".join(lines) + "\n"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _render_markdown(rows: List[Dict[str, Any]]) -> str:
|
|
67
|
+
if not rows:
|
|
68
|
+
return "No scores found.\n"
|
|
69
|
+
lines = [
|
|
70
|
+
f"| {' | '.join(_TABLE_HEADER)} |",
|
|
71
|
+
f"| {' | '.join('---' for _ in _TABLE_HEADER)} |",
|
|
72
|
+
]
|
|
73
|
+
for r in rows:
|
|
74
|
+
lines.append(
|
|
75
|
+
f"| {r['learner_id']} | {r['skill_id']} | {r['model']} | {r['metric']} | {r['value']:.4f} | {r['response_count']} |"
|
|
76
|
+
)
|
|
77
|
+
return "\n".join(lines) + "\n"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def run_report(cwd: str, options: ReportOptions) -> CommandResult:
|
|
81
|
+
scores_path = Path(cwd) / STATE_DIR / SCORES_STATE_FILENAME
|
|
82
|
+
if not scores_path.exists():
|
|
83
|
+
message = f"No scores found at {scores_path}. Run 'masterytrace score' first."
|
|
84
|
+
return fail(1, options.json, {"error": message}, f"{message}\n")
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
result = json.loads(scores_path.read_text(encoding="utf-8"))
|
|
88
|
+
except Exception as error: # noqa: BLE001 -- mirrors the TS catch-all for parse errors
|
|
89
|
+
return fail(1, options.json, {"error": str(error)}, f"Error reading scores: {error}\n")
|
|
90
|
+
|
|
91
|
+
if options.json:
|
|
92
|
+
return CommandResult(exit_code=0, stdout=f"{json.dumps(result)}\n")
|
|
93
|
+
|
|
94
|
+
rows = _build_rows(result)
|
|
95
|
+
if options.format == "markdown":
|
|
96
|
+
text = _render_markdown(rows)
|
|
97
|
+
elif options.format == "json":
|
|
98
|
+
text = json.dumps(result, indent=2) + "\n"
|
|
99
|
+
else:
|
|
100
|
+
text = _render_table(rows)
|
|
101
|
+
|
|
102
|
+
return CommandResult(exit_code=0, stdout=text)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fits and scores the stored event log (`.masterytrace/events.json`,
|
|
3
|
+
written by `masterytrace record`) with the requested model(s), writing
|
|
4
|
+
the unified report(s) to `.masterytrace/scores.json`. Ported from
|
|
5
|
+
src/cli/commands/score.ts.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ...core.config import bkt_config_from_dict, irt_config_from_dict, load_config
|
|
14
|
+
from ...core.engine import EngineConfig, ModelSelector, run_scoring
|
|
15
|
+
from ...core.event_schema import EventValidationError, parse_response_events
|
|
16
|
+
from ..format import fail, ok
|
|
17
|
+
from ..json_encode import to_jsonable
|
|
18
|
+
from ..types import CommandResult
|
|
19
|
+
from .record import EVENTS_STATE_FILENAME, STATE_DIR
|
|
20
|
+
|
|
21
|
+
SCORES_STATE_FILENAME = "scores.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ScoreOptions:
|
|
26
|
+
json: bool
|
|
27
|
+
model: ModelSelector
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_score(cwd: str, options: ScoreOptions) -> CommandResult:
|
|
31
|
+
events_path = Path(cwd) / STATE_DIR / EVENTS_STATE_FILENAME
|
|
32
|
+
if not events_path.exists():
|
|
33
|
+
message = f"No stored event log found at {events_path}. Run 'masterytrace record <path>' first."
|
|
34
|
+
return fail(1, options.json, {"error": message}, f"{message}\n")
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
raw = json.loads(events_path.read_text(encoding="utf-8"))
|
|
38
|
+
events = parse_response_events(raw)
|
|
39
|
+
except EventValidationError as error:
|
|
40
|
+
return fail(
|
|
41
|
+
2,
|
|
42
|
+
options.json,
|
|
43
|
+
{"error": str(error), "issues": error.issues},
|
|
44
|
+
f"Validation error:\n{error}\n",
|
|
45
|
+
)
|
|
46
|
+
except Exception as error: # noqa: BLE001 -- mirrors the TS catch-all for parse errors
|
|
47
|
+
return fail(1, options.json, {"error": str(error)}, f"Error: {error}\n")
|
|
48
|
+
|
|
49
|
+
raw_config = load_config(cwd)
|
|
50
|
+
config = EngineConfig(
|
|
51
|
+
bkt=bkt_config_from_dict(raw_config.get("bkt")),
|
|
52
|
+
irt=irt_config_from_dict(raw_config.get("irt")),
|
|
53
|
+
)
|
|
54
|
+
result = run_scoring(events, options.model, config)
|
|
55
|
+
|
|
56
|
+
scores_path = Path(cwd) / STATE_DIR / SCORES_STATE_FILENAME
|
|
57
|
+
scores_path.write_text(json.dumps(to_jsonable(result), indent=2) + "\n", encoding="utf-8")
|
|
58
|
+
|
|
59
|
+
return ok(
|
|
60
|
+
options.json,
|
|
61
|
+
{"model": options.model, "eventCount": len(events), "storedAt": str(scores_path)},
|
|
62
|
+
f"Scored {len(events)} event(s) with model(s): {options.model}\nWrote {scores_path}\n",
|
|
63
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Builds successful/failing CommandResults, choosing JSON or human text output. Ported from src/cli/format.ts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .types import CommandResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def ok(as_json: bool, json_payload: Any, text: str) -> CommandResult:
|
|
11
|
+
return CommandResult(exit_code=0, stdout=f"{json.dumps(json_payload)}\n" if as_json else text)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def fail(exit_code: int, as_json: bool, json_payload: Any, text: str) -> CommandResult:
|
|
15
|
+
if as_json:
|
|
16
|
+
return CommandResult(exit_code=exit_code, stdout=f"{json.dumps(json_payload)}\n")
|
|
17
|
+
return CommandResult(exit_code=exit_code, stdout="", stderr=text)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Console entry point: `masterytrace <command> [options]`, installed via the
|
|
4
|
+
`masterytrace` console-script defined in python/pyproject.toml. Ported
|
|
5
|
+
from src/cli/index.ts (which uses `commander`); this port uses the stdlib
|
|
6
|
+
`argparse` to avoid a CLI-framework dependency, matching the flags,
|
|
7
|
+
defaults, subcommands, and exit-code contract of the npm CLI's `--help`
|
|
8
|
+
output.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from typing import List
|
|
16
|
+
|
|
17
|
+
from .. import __version__
|
|
18
|
+
from .commands.init import InitOptions, run_init
|
|
19
|
+
from .commands.record import RecordOptions, run_record
|
|
20
|
+
from .commands.report import ReportOptions, run_report
|
|
21
|
+
from .commands.score import ScoreOptions, run_score
|
|
22
|
+
from .format import fail
|
|
23
|
+
from .types import CommandResult
|
|
24
|
+
|
|
25
|
+
_MODELS = ("bkt", "irt", "both")
|
|
26
|
+
_FORMATS = ("table", "json", "markdown")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _emit(result: CommandResult) -> int:
|
|
30
|
+
if result.stdout:
|
|
31
|
+
sys.stdout.write(result.stdout)
|
|
32
|
+
if result.stderr:
|
|
33
|
+
sys.stderr.write(result.stderr)
|
|
34
|
+
return result.exit_code
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
38
|
+
parser = argparse.ArgumentParser(
|
|
39
|
+
prog="masterytrace",
|
|
40
|
+
description=(
|
|
41
|
+
"Mastery Measurement API/CLI. Fits Bayesian Knowledge Tracing (BKT) and "
|
|
42
|
+
"2-parameter logistic Item Response Theory (IRT) models to learner "
|
|
43
|
+
"response logs and reports per-learner, per-skill mastery estimates."
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument("--version", action="version", version=f"masterytrace-cli {__version__}")
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--json",
|
|
49
|
+
action="store_true",
|
|
50
|
+
default=False,
|
|
51
|
+
help="force machine-readable JSON output on stdout instead of human-formatted text",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
55
|
+
|
|
56
|
+
init_parser = subparsers.add_parser(
|
|
57
|
+
"init",
|
|
58
|
+
help="Scaffold a sample events.json and a default masterytrace.config.json in the current directory",
|
|
59
|
+
)
|
|
60
|
+
init_parser.add_argument(
|
|
61
|
+
"--force",
|
|
62
|
+
action="store_true",
|
|
63
|
+
default=False,
|
|
64
|
+
help="overwrite events.json/masterytrace.config.json if they already exist",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
record_parser = subparsers.add_parser(
|
|
68
|
+
"record",
|
|
69
|
+
help="Validate and load an event log (JSON or CSV) and store it to .masterytrace/events.json",
|
|
70
|
+
)
|
|
71
|
+
record_parser.add_argument(
|
|
72
|
+
"path",
|
|
73
|
+
help="path to a JSON (array of response events) or CSV (learner_id,skill_id,correct,timestamp) event log",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
score_parser = subparsers.add_parser(
|
|
77
|
+
"score",
|
|
78
|
+
help="Fit and score the stored event log and write results to .masterytrace/scores.json",
|
|
79
|
+
)
|
|
80
|
+
score_parser.add_argument(
|
|
81
|
+
"--model", default="both", help="which model(s) to run: bkt, irt, or both"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
report_parser = subparsers.add_parser(
|
|
85
|
+
"report",
|
|
86
|
+
help="Read .masterytrace/scores.json and print a per-learner, per-skill mastery table",
|
|
87
|
+
)
|
|
88
|
+
report_parser.add_argument(
|
|
89
|
+
"--format", default="table", help="output format: table, json, or markdown"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return parser
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def run_cli(argv: List[str]) -> int:
|
|
96
|
+
"""
|
|
97
|
+
`argv` follows the sys.argv convention: argv[0] is the program name,
|
|
98
|
+
the real arguments start at argv[1]. Returns the process exit code
|
|
99
|
+
(0 success, 1 general/usage error, 2 validation error).
|
|
100
|
+
|
|
101
|
+
argparse's `--help`/`--version` actions call `parser.exit()` directly
|
|
102
|
+
(raising SystemExit) rather than returning normally -- that is caught
|
|
103
|
+
here and turned into a normal return, so `run_cli` always returns an
|
|
104
|
+
int and never raises, matching the TS CLI's `program.exitOverride()`
|
|
105
|
+
behavior (commander.helpDisplayed/commander.version both map to exit
|
|
106
|
+
code 0) and letting command handlers be tested as plain functions.
|
|
107
|
+
|
|
108
|
+
`--json` is a *global* flag that must work whether it appears before
|
|
109
|
+
or after the subcommand (`masterytrace --json record x` and
|
|
110
|
+
`masterytrace record x --json` both need to force JSON output, same
|
|
111
|
+
as the npm CLI's commander-based global option). Registering the same
|
|
112
|
+
`--json` action on both the main parser and every subparser does not
|
|
113
|
+
reliably compose in argparse (a subparser's own default can overwrite
|
|
114
|
+
a value already set by the main parser's parsing pass), so instead
|
|
115
|
+
`--json` is detected directly from the raw argument list and stripped
|
|
116
|
+
out before argparse ever sees it.
|
|
117
|
+
"""
|
|
118
|
+
rest = list(argv[1:])
|
|
119
|
+
as_json = "--json" in rest
|
|
120
|
+
while "--json" in rest:
|
|
121
|
+
rest.remove("--json")
|
|
122
|
+
|
|
123
|
+
parser = build_parser()
|
|
124
|
+
try:
|
|
125
|
+
args = parser.parse_args(rest)
|
|
126
|
+
except SystemExit as exc:
|
|
127
|
+
code = exc.code
|
|
128
|
+
return code if isinstance(code, int) else 0
|
|
129
|
+
|
|
130
|
+
if args.command == "init":
|
|
131
|
+
return _emit(run_init(os.getcwd(), InitOptions(json=as_json, force=args.force)))
|
|
132
|
+
|
|
133
|
+
if args.command == "record":
|
|
134
|
+
return _emit(run_record(os.getcwd(), args.path, RecordOptions(json=as_json)))
|
|
135
|
+
|
|
136
|
+
if args.command == "score":
|
|
137
|
+
if args.model not in _MODELS:
|
|
138
|
+
return _emit(
|
|
139
|
+
fail(
|
|
140
|
+
1,
|
|
141
|
+
as_json,
|
|
142
|
+
{"error": f"Invalid --model '{args.model}'. Expected one of: bkt, irt, both."},
|
|
143
|
+
f"Invalid --model '{args.model}'. Expected one of: bkt, irt, both.\n",
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
return _emit(run_score(os.getcwd(), ScoreOptions(json=as_json, model=args.model)))
|
|
147
|
+
|
|
148
|
+
if args.command == "report":
|
|
149
|
+
if args.format not in _FORMATS:
|
|
150
|
+
return _emit(
|
|
151
|
+
fail(
|
|
152
|
+
1,
|
|
153
|
+
as_json,
|
|
154
|
+
{"error": f"Invalid --format '{args.format}'. Expected one of: table, json, markdown."},
|
|
155
|
+
f"Invalid --format '{args.format}'. Expected one of: table, json, markdown.\n",
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
return _emit(run_report(os.getcwd(), ReportOptions(json=as_json, format=args.format)))
|
|
159
|
+
|
|
160
|
+
parser.print_help()
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main() -> None:
|
|
165
|
+
try:
|
|
166
|
+
code = run_cli(sys.argv)
|
|
167
|
+
except SystemExit:
|
|
168
|
+
raise
|
|
169
|
+
except Exception as error: # noqa: BLE001 -- top-level crash guard, mirrors src/cli/index.ts's catch-all
|
|
170
|
+
sys.stderr.write(f"Error: {error}\n")
|
|
171
|
+
sys.exit(1)
|
|
172
|
+
else:
|
|
173
|
+
sys.exit(code)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
main()
|