kwan 1.0.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.
- kwan/__init__.py +37 -0
- kwan/_bundle_cli.py +80 -0
- kwan/_paths.py +33 -0
- kwan/_version.py +3 -0
- kwan/api/__init__.py +6 -0
- kwan/api/app.py +403 -0
- kwan/api/runtime.py +162 -0
- kwan/api/v1.py +441 -0
- kwan/assets/kwan-logo-dark.png +0 -0
- kwan/assets/kwan-logo-light.png +0 -0
- kwan/assets/kwan-logo.png +0 -0
- kwan/assets/kwan-mark.png +0 -0
- kwan/bundle.py +533 -0
- kwan/bundles/kwan-bundle.math.json +15 -0
- kwan/cli.py +100 -0
- kwan/context/__init__.py +12 -0
- kwan/context/_cli.py +210 -0
- kwan/context/bank.py +76 -0
- kwan/context/banned_terms.txt +272 -0
- kwan/context/checks.py +677 -0
- kwan/context/coverage.py +102 -0
- kwan/context/drafts.py +300 -0
- kwan/context/ghana_terms.txt +581 -0
- kwan/context/settings.py +369 -0
- kwan/context/sheets.py +889 -0
- kwan/context/translate.py +305 -0
- kwan/curriculum/__init__.py +1 -0
- kwan/curriculum/_cli.py +141 -0
- kwan/curriculum/build.py +245 -0
- kwan/curriculum/corrections.py +113 -0
- kwan/curriculum/document.py +470 -0
- kwan/curriculum/exemplars.py +113 -0
- kwan/curriculum/families.py +162 -0
- kwan/curriculum/families.yaml +58 -0
- kwan/curriculum/fetch.py +152 -0
- kwan/curriculum/ghana_terms_extra.txt +27 -0
- kwan/curriculum/labels.py +108 -0
- kwan/curriculum/model.py +57 -0
- kwan/curriculum/parse.py +194 -0
- kwan/curriculum/pdf.py +263 -0
- kwan/curriculum/qa.py +595 -0
- kwan/curriculum/text.py +115 -0
- kwan/data/__init__.py +1 -0
- kwan/data/loaders/__init__.py +60 -0
- kwan/data/loaders/_common.py +50 -0
- kwan/data/loaders/ammore.py +41 -0
- kwan/data/loaders/assistments.py +119 -0
- kwan/data/loaders/eedi.py +95 -0
- kwan/data/loaders/junyi.py +81 -0
- kwan/data/loaders/xes3g5m.py +68 -0
- kwan/data/registry.py +97 -0
- kwan/data/schema.py +122 -0
- kwan/eval/__init__.py +1 -0
- kwan/eval/_cli.py +183 -0
- kwan/eval/ammore.py +66 -0
- kwan/eval/deep.py +1284 -0
- kwan/eval/eedi.py +69 -0
- kwan/eval/fit.py +763 -0
- kwan/eval/harness.py +338 -0
- kwan/eval/kt.py +254 -0
- kwan/eval/latency.py +140 -0
- kwan/eval/metrics.py +65 -0
- kwan/eval/results.py +113 -0
- kwan/eval/simulate.py +675 -0
- kwan/graph/__init__.py +1 -0
- kwan/graph/_cli.py +127 -0
- kwan/graph/_cli_augment.py +487 -0
- kwan/graph/augment.py +649 -0
- kwan/graph/build.py +455 -0
- kwan/graph/candidates.py +284 -0
- kwan/graph/export.py +315 -0
- kwan/graph/junyi.py +488 -0
- kwan/graph/providers/__init__.py +41 -0
- kwan/graph/providers/fake.py +41 -0
- kwan/graph/providers/llm_json.py +167 -0
- kwan/graph/providers/local_encoder.py +273 -0
- kwan/graph/providers/typesafe_jev.py +109 -0
- kwan/graph/review.py +287 -0
- kwan/graph/schema.py +321 -0
- kwan/graph/validate.py +374 -0
- kwan/ids.py +137 -0
- kwan/kwan.py +346 -0
- kwan/learner/__init__.py +26 -0
- kwan/learner/bkt.py +422 -0
- kwan/learner/elo.py +458 -0
- kwan/learner/forgetting.py +80 -0
- kwan/learner/item_bank.py +379 -0
- kwan/learner/params.py +82 -0
- kwan/learner/scoring.py +261 -0
- kwan/py.typed +0 -0
- kwan/recommend/__init__.py +1 -0
- kwan/recommend/explain.py +114 -0
- kwan/recommend/explore.py +138 -0
- kwan/recommend/recommender.py +298 -0
- kwan/recommend/review.py +77 -0
- kwan/store/__init__.py +42 -0
- kwan/store/base.py +313 -0
- kwan/store/memory.py +200 -0
- kwan/store/postgres.py +310 -0
- kwan/store/sqlite.py +412 -0
- kwan-1.0.0.dist-info/METADATA +477 -0
- kwan-1.0.0.dist-info/RECORD +105 -0
- kwan-1.0.0.dist-info/WHEEL +4 -0
- kwan-1.0.0.dist-info/entry_points.txt +2 -0
- kwan-1.0.0.dist-info/licenses/LICENSE +21 -0
kwan/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Kwan: an open-source learning path engine for Ghanaian primary maths (NaCCA B1 to B6).
|
|
2
|
+
|
|
3
|
+
Kwan keeps track of what a pupil has mastered, picks the next skill to learn, gives the reason and
|
|
4
|
+
sets the practice in everyday Ghanaian life. The core needs only the standard library and networkx.
|
|
5
|
+
|
|
6
|
+
from kwan import Kwan
|
|
7
|
+
|
|
8
|
+
engine = Kwan.from_bundle()
|
|
9
|
+
state = engine.new_state("pupil-0042")
|
|
10
|
+
engine.update(state, "B1.1.1.1.1", correct=True)
|
|
11
|
+
for rec in engine.recommend(state, k=3):
|
|
12
|
+
print(rec.indicator, rec.rationale)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from kwan._version import __version__
|
|
18
|
+
|
|
19
|
+
__all__ = ["Kwan", "LearnerState", "Recommendation", "__version__"]
|
|
20
|
+
|
|
21
|
+
_PUBLIC = {"Kwan": "kwan.kwan", "LearnerState": "kwan.learner.elo", "Recommendation": "kwan.kwan"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def __getattr__(name: str) -> Any:
|
|
25
|
+
"""Load the public classes on first use, so `import kwan` stays cheap."""
|
|
26
|
+
module = _PUBLIC.get(name)
|
|
27
|
+
if module is None:
|
|
28
|
+
raise AttributeError(f"module 'kwan' has no attribute {name!r}")
|
|
29
|
+
import importlib
|
|
30
|
+
|
|
31
|
+
value = getattr(importlib.import_module(module), name)
|
|
32
|
+
globals()[name] = value
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def __dir__() -> list[str]:
|
|
37
|
+
return sorted(set(globals()) | set(_PUBLIC))
|
kwan/_bundle_cli.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""`kwan bundle`: write `kwan-bundle.<subject>.json` from the committed data files (PRD 9.4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from kwan.bundle import TEXT_MODES, BundleError, build_bundle, bundle_file_name, dumps_bundle
|
|
11
|
+
|
|
12
|
+
DEFAULT_OUT = "build/bundles"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _bundle(args: argparse.Namespace) -> int:
|
|
16
|
+
from kwan.learner.item_bank import ItemBank
|
|
17
|
+
|
|
18
|
+
params = provenance = None
|
|
19
|
+
if args.params:
|
|
20
|
+
from kwan.bundle import load_params_file
|
|
21
|
+
|
|
22
|
+
params, provenance = load_params_file(args.params)
|
|
23
|
+
item_bank = None
|
|
24
|
+
if args.item_bank:
|
|
25
|
+
import json
|
|
26
|
+
|
|
27
|
+
with open(args.item_bank, encoding="utf-8") as f:
|
|
28
|
+
item_bank = ItemBank.from_dict(json.load(f))
|
|
29
|
+
try:
|
|
30
|
+
doc = build_bundle(
|
|
31
|
+
subject=args.subject,
|
|
32
|
+
text_mode=args.text,
|
|
33
|
+
params=params,
|
|
34
|
+
params_provenance=provenance,
|
|
35
|
+
item_bank=item_bank,
|
|
36
|
+
out=args.out,
|
|
37
|
+
include_drafts=not args.reviewed_only,
|
|
38
|
+
bundle_version=args.bundle_version,
|
|
39
|
+
)
|
|
40
|
+
except (BundleError, FileNotFoundError) as exc:
|
|
41
|
+
print(f"kwan bundle: {exc}", file=sys.stderr)
|
|
42
|
+
return 1
|
|
43
|
+
target = Path(args.out)
|
|
44
|
+
if target.is_dir() or not target.suffix:
|
|
45
|
+
target = target / bundle_file_name(args.subject)
|
|
46
|
+
size = len(dumps_bundle(doc).encode("utf-8"))
|
|
47
|
+
prov = doc["provenance"]
|
|
48
|
+
print(
|
|
49
|
+
f"wrote {target} ({size:,} bytes): {doc['bundle_version']}, text {doc['text_mode']}, "
|
|
50
|
+
f"{prov['graph']['nodes']} skills, {prov['graph']['edges']} links, "
|
|
51
|
+
f"{prov['contexts']['reviewed']} reviewed and {prov['contexts']['drafts']} draft settings, "
|
|
52
|
+
f"params from {prov['params'].get('source', 'defaults')}, commit {doc['source_commit'][:12]}"
|
|
53
|
+
)
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def register(subparsers: Any) -> None:
|
|
58
|
+
"""Add `bundle` to the `kwan` command."""
|
|
59
|
+
p = subparsers.add_parser("bundle", help="write kwan-bundle.<subject>.json from the committed data files")
|
|
60
|
+
p.add_argument("--subject", default="math", help="subject to bundle (default: math)")
|
|
61
|
+
p.add_argument(
|
|
62
|
+
"--text",
|
|
63
|
+
choices=TEXT_MODES,
|
|
64
|
+
default="labels",
|
|
65
|
+
help="labels: Kwan's short labels (what the project ships); full: NaCCA text, only with recorded permission",
|
|
66
|
+
)
|
|
67
|
+
p.add_argument(
|
|
68
|
+
"--out", default=DEFAULT_OUT, metavar="PATH", help=f"directory or file to write (default: {DEFAULT_OUT})"
|
|
69
|
+
)
|
|
70
|
+
p.add_argument(
|
|
71
|
+
"--params",
|
|
72
|
+
metavar="FILE",
|
|
73
|
+
help="fitted parameters JSON (default: data/params/<subject>.params.json if present)",
|
|
74
|
+
)
|
|
75
|
+
p.add_argument("--item-bank", metavar="FILE", help="item-difficulty bank JSON to include")
|
|
76
|
+
p.add_argument("--reviewed-only", action="store_true", help="leave unreviewed practice settings out of the bundle")
|
|
77
|
+
p.add_argument(
|
|
78
|
+
"--bundle-version", metavar="TEXT", help="override the bundle version (default: <subject>-<package version>)"
|
|
79
|
+
)
|
|
80
|
+
p.set_defaults(func=_bundle, extra=None)
|
kwan/_paths.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""One place that finds the repository `data/` directory.
|
|
2
|
+
|
|
3
|
+
Order: the `KWAN_DATA_DIR` environment variable, else the `data/` folder next to the nearest
|
|
4
|
+
`pyproject.toml` above this file (a source checkout or an editable install), else `data/` under the
|
|
5
|
+
current working directory (an installed wheel, where the caller brings its own data).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
ENV_VAR = "KWAN_DATA_DIR"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def repo_root() -> Path | None:
|
|
15
|
+
"""The directory holding `pyproject.toml` above this file, or None outside a source checkout."""
|
|
16
|
+
for parent in Path(__file__).resolve().parents:
|
|
17
|
+
if (parent / "pyproject.toml").is_file():
|
|
18
|
+
return parent
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def data_dir() -> Path:
|
|
23
|
+
"""Absolute path of the directory that holds `graph/`, `curriculum/`, `contexts/` and eval_results.json."""
|
|
24
|
+
env = os.environ.get(ENV_VAR)
|
|
25
|
+
if env:
|
|
26
|
+
return Path(env).expanduser().resolve()
|
|
27
|
+
root = repo_root()
|
|
28
|
+
return (root if root is not None else Path.cwd().resolve()) / "data"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def data_path(name: str) -> Path:
|
|
32
|
+
"""Absolute path of one file in the data directory, for example `data_path("eval_results.json")`."""
|
|
33
|
+
return data_dir() / name
|
kwan/_version.py
ADDED
kwan/api/__init__.py
ADDED
kwan/api/app.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Kwan REST API (FastAPI).
|
|
2
|
+
|
|
3
|
+
Run: uvicorn kwan.api.app:app --port 8000
|
|
4
|
+
Interactive docs: http://localhost:8000/docs
|
|
5
|
+
|
|
6
|
+
Two route families live here:
|
|
7
|
+
|
|
8
|
+
- **v1** (`/v1/...`, see `kwan.api.v1`): built on `kwan.Kwan` and `kwan.store`, with API-key auth
|
|
9
|
+
(`KWAN_API_KEYS`), a persistent store (`KWAN_STORE`, SQLite by default), idempotent attempts and
|
|
10
|
+
JSON logs. This is the API to build on.
|
|
11
|
+
- **v0** (`/health`, `/attempt`, `/recommend/{id}`, `/mastery/{id}`, `/learner/{id}`): the Round 1
|
|
12
|
+
routes, kept for one minor release. Learner state is in memory only. Every v0 response carries a
|
|
13
|
+
`Deprecation` header and a `Link` to the v1 route that replaces it.
|
|
14
|
+
|
|
15
|
+
`create_app(runtime)` builds an application around a given runtime (tests pass a memory store and
|
|
16
|
+
their own keys); the module-level `app` reads its settings from the environment on first use.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
import uuid
|
|
24
|
+
from typing import Annotated, Literal
|
|
25
|
+
|
|
26
|
+
from fastapi import FastAPI, HTTPException, Path, Query, Request
|
|
27
|
+
from fastapi.responses import JSONResponse
|
|
28
|
+
from pydantic import BaseModel, ConfigDict, Field, StrictBool
|
|
29
|
+
|
|
30
|
+
from kwan import ids
|
|
31
|
+
from kwan._version import __version__
|
|
32
|
+
from kwan.api import runtime as _runtime
|
|
33
|
+
from kwan.api import v1 as _v1
|
|
34
|
+
from kwan.graph.schema import load_graph
|
|
35
|
+
from kwan.learner.elo import EloLearner, LearnerState
|
|
36
|
+
from kwan.recommend.recommender import Recommender
|
|
37
|
+
|
|
38
|
+
G = load_graph() # the committed v2 graph
|
|
39
|
+
RESOLVE = ids.Resolver(G) # the v0 routes accept bare codes (as Round 1 clients send them) and namespaced ids
|
|
40
|
+
L = EloLearner(G=G)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _bare(node: str) -> str:
|
|
44
|
+
"""The bare code of a node: what the v0 routes return, as they did on the v1 graph."""
|
|
45
|
+
return G.nodes[node].get("code") or ids.code_of(node)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
R = Recommender(G, L)
|
|
49
|
+
STATES: dict[str, LearnerState] = {} # v0 in-memory state
|
|
50
|
+
LOCK = threading.RLock() # sync endpoints run in a thread pool: one writer at a time
|
|
51
|
+
|
|
52
|
+
LEARNER_ID_MAX = 128
|
|
53
|
+
INDICATOR_ID_MAX = 64
|
|
54
|
+
K_MAX = 50
|
|
55
|
+
V0_PATHS = ("/health", "/attempt", "/recommend/", "/mastery/", "/learner/")
|
|
56
|
+
|
|
57
|
+
DESCRIPTION = """
|
|
58
|
+
Adaptive learning path engine for Ghanaian primary maths (NaCCA B1 to B6).
|
|
59
|
+
|
|
60
|
+
Typical flow for a platform such as Syto:
|
|
61
|
+
|
|
62
|
+
1. `POST /v1/attempts` each time a pupil answers a question. The reply carries the probability the
|
|
63
|
+
engine predicted before seeing the answer and the pupil's updated mastery of that skill.
|
|
64
|
+
2. `GET /v1/learners/{learner_id}/recommendations` to get the next best skills, each with a
|
|
65
|
+
plain-language reason and a practice setting from everyday Ghanaian life.
|
|
66
|
+
3. `GET /v1/learners/{learner_id}/mastery` to draw the mastery map for a teacher.
|
|
67
|
+
|
|
68
|
+
**Auth.** Send one of the keys in `KWAN_API_KEYS` in the `X-API-Key` header. When no key is
|
|
69
|
+
configured, every request is allowed (local use only).
|
|
70
|
+
|
|
71
|
+
**New learners.** A `learner_id` needs no registration. A learner who has never posted an attempt is
|
|
72
|
+
treated as a brand-new pupil. Reads never create or change stored state. A learner is stored on the
|
|
73
|
+
first `POST /v1/attempts` and removed by `DELETE /v1/learners/{learner_id}`.
|
|
74
|
+
|
|
75
|
+
**Ids.** Indicator ids are `math:B1.1.1.1.1`; the bare code `B1.1.1.1.1` is accepted everywhere.
|
|
76
|
+
|
|
77
|
+
**Errors.** `{"detail": "...", "code": "..."}`. An unknown indicator or goal gives 404; a request that
|
|
78
|
+
fails validation gives 422 with the offending field in `detail`; a missing key gives 401.
|
|
79
|
+
|
|
80
|
+
**v0 routes** (`/attempt`, `/recommend/{id}`, ...) are deprecated and answer with a `Deprecation`
|
|
81
|
+
header and a `Link` to their v1 replacement. They keep state in memory only.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
# --------------------------------------------------------------------------- v0 models
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Attempt(BaseModel):
|
|
88
|
+
"""One answer event from a pupil (v0)."""
|
|
89
|
+
|
|
90
|
+
model_config = ConfigDict(
|
|
91
|
+
json_schema_extra={
|
|
92
|
+
"examples": [{"learner_id": "a", "indicator": "B1.1.1.1.1", "correct": True, "item_id": None}]
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
learner_id: str = Field(
|
|
97
|
+
min_length=1,
|
|
98
|
+
max_length=LEARNER_ID_MAX,
|
|
99
|
+
pattern=r"\S",
|
|
100
|
+
description="Your identifier for the pupil. Any non-blank string, no personal data.",
|
|
101
|
+
)
|
|
102
|
+
indicator: str = Field(
|
|
103
|
+
min_length=1,
|
|
104
|
+
max_length=INDICATOR_ID_MAX,
|
|
105
|
+
description="NaCCA indicator id of the skill that was practised, e.g. B1.1.1.1.1.",
|
|
106
|
+
)
|
|
107
|
+
correct: StrictBool = Field(description="Whether the answer was right. Must be a JSON boolean.")
|
|
108
|
+
item_id: str | None = Field(
|
|
109
|
+
default=None,
|
|
110
|
+
max_length=128,
|
|
111
|
+
description="Optional id of the question. Used for item difficulty when the engine "
|
|
112
|
+
"has it; otherwise the item is treated as typical for the skill.",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class AttemptResponse(BaseModel):
|
|
117
|
+
predicted_p_correct: float = Field(
|
|
118
|
+
ge=0,
|
|
119
|
+
le=1,
|
|
120
|
+
description="Probability of a correct answer that the engine "
|
|
121
|
+
"predicted before seeing this attempt (4 decimals).",
|
|
122
|
+
)
|
|
123
|
+
new_mastery: float = Field(ge=0, le=1, description="Mastery of the indicator after this attempt (4 decimals).")
|
|
124
|
+
model_config = ConfigDict(json_schema_extra={"examples": [{"predicted_p_correct": 0.3775, "new_mastery": 0.4839}]})
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class HealthResponse(BaseModel):
|
|
128
|
+
ok: bool = Field(description="True when the engine is loaded and able to serve.")
|
|
129
|
+
nodes: int = Field(description="Number of indicators (skills) in the curriculum graph.")
|
|
130
|
+
edges: int = Field(description="Number of prerequisite links in the curriculum graph.")
|
|
131
|
+
model_config = ConfigDict(json_schema_extra={"examples": [{"ok": True, "nodes": 266, "edges": 287}]})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class Recommendation(BaseModel):
|
|
135
|
+
indicator: str = Field(description="NaCCA indicator id of the recommended skill.")
|
|
136
|
+
score: float = Field(description="Ranking score; higher is better. Comparable only within one response.")
|
|
137
|
+
mastery: float = Field(ge=0, le=1, description="The learner's current mastery of this skill (3 decimals).")
|
|
138
|
+
grade: int = Field(ge=1, le=6, description="Class level, 1 to 6 for B1 to B6.")
|
|
139
|
+
strand: str = Field(description="Curriculum strand name.")
|
|
140
|
+
description: str = Field(description="The indicator text from the NaCCA curriculum.")
|
|
141
|
+
rationale: str = Field(description="Plain-language reason, built only from the signals behind the score.")
|
|
142
|
+
ghana_context: str = Field(description="An everyday Ghanaian setting to practise the skill in.")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class RecommendResponse(BaseModel):
|
|
146
|
+
recommendations: list[Recommendation] = Field(
|
|
147
|
+
description="Best first. May hold fewer than k items, or none, when fewer skills are ready for this learner."
|
|
148
|
+
)
|
|
149
|
+
latency_ms: float = Field(
|
|
150
|
+
ge=0, description="Engine compute time for this call in milliseconds, excluding HTTP and JSON overhead."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class ErrorResponse(BaseModel):
|
|
155
|
+
detail: str = Field(description="What went wrong.")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
LearnerId = Annotated[
|
|
159
|
+
str,
|
|
160
|
+
Path(
|
|
161
|
+
min_length=1,
|
|
162
|
+
max_length=LEARNER_ID_MAX,
|
|
163
|
+
pattern=r"\S",
|
|
164
|
+
description="Your identifier for the pupil. Any non-blank string.",
|
|
165
|
+
),
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
NOT_FOUND = {"model": ErrorResponse}
|
|
169
|
+
|
|
170
|
+
# --------------------------------------------------------------------------- v0 state
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def state(lid: str) -> LearnerState:
|
|
174
|
+
"""Stored state for a learner, created on first use. Call with LOCK held."""
|
|
175
|
+
return STATES.setdefault(lid, LearnerState(lid))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def peek(lid: str) -> LearnerState:
|
|
179
|
+
"""State to read from: the stored one, or a throwaway fresh one for a learner never seen. Call with LOCK held."""
|
|
180
|
+
return STATES.get(lid) or LearnerState(lid)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# --------------------------------------------------------------------------- v0 endpoints
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def v0_successor(path: str) -> str | None:
|
|
187
|
+
"""The v1 path that replaces a v0 path, or None when the path is not a v0 route."""
|
|
188
|
+
if path == "/health":
|
|
189
|
+
return "/v1/health"
|
|
190
|
+
if path == "/attempt":
|
|
191
|
+
return "/v1/attempts"
|
|
192
|
+
for prefix, tail in (("/recommend/", "/recommendations"), ("/mastery/", "/mastery"), ("/learner/", "")):
|
|
193
|
+
if path.startswith(prefix) and len(path) > len(prefix):
|
|
194
|
+
return f"/v1/learners/{path[len(prefix) :]}{tail}"
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def register_v0(app: FastAPI) -> None:
|
|
199
|
+
"""Add the Round 1 routes (deprecated, in-memory state) to an application."""
|
|
200
|
+
|
|
201
|
+
@app.get(
|
|
202
|
+
"/health",
|
|
203
|
+
response_model=HealthResponse,
|
|
204
|
+
tags=["v0 (deprecated)"],
|
|
205
|
+
summary="Liveness check and graph size",
|
|
206
|
+
deprecated=True,
|
|
207
|
+
)
|
|
208
|
+
def health():
|
|
209
|
+
return {"ok": True, "nodes": G.number_of_nodes(), "edges": G.number_of_edges()}
|
|
210
|
+
|
|
211
|
+
@app.post(
|
|
212
|
+
"/attempt",
|
|
213
|
+
response_model=AttemptResponse,
|
|
214
|
+
tags=["v0 (deprecated)"],
|
|
215
|
+
summary="Record one answer",
|
|
216
|
+
deprecated=True,
|
|
217
|
+
responses={404: {**NOT_FOUND, "description": "Unknown indicator."}},
|
|
218
|
+
)
|
|
219
|
+
def attempt(a: Attempt):
|
|
220
|
+
"""Update the learner model with one answer and return the new mastery of that skill.
|
|
221
|
+
|
|
222
|
+
The learner is created on first use. Updates for the same learner are applied one at a time,
|
|
223
|
+
so concurrent requests cannot corrupt the state.
|
|
224
|
+
"""
|
|
225
|
+
indicator = RESOLVE.get(a.indicator)
|
|
226
|
+
if indicator is None:
|
|
227
|
+
raise HTTPException(404, f"unknown indicator {a.indicator}")
|
|
228
|
+
with LOCK:
|
|
229
|
+
st = state(a.learner_id)
|
|
230
|
+
p = L.update(st, indicator, a.correct, a.item_id)
|
|
231
|
+
m = L.mastery(st, indicator)
|
|
232
|
+
return {"predicted_p_correct": round(p, 4), "new_mastery": round(m, 4)}
|
|
233
|
+
|
|
234
|
+
@app.get(
|
|
235
|
+
"/recommend/{learner_id}",
|
|
236
|
+
response_model=RecommendResponse,
|
|
237
|
+
tags=["v0 (deprecated)"],
|
|
238
|
+
summary="Next best skills for a learner",
|
|
239
|
+
deprecated=True,
|
|
240
|
+
responses={404: {**NOT_FOUND, "description": "Unknown goal."}},
|
|
241
|
+
)
|
|
242
|
+
def recommend(
|
|
243
|
+
learner_id: LearnerId,
|
|
244
|
+
k: Annotated[int, Query(ge=1, le=K_MAX, description="How many skills to return at most.")] = 3,
|
|
245
|
+
goal: Annotated[
|
|
246
|
+
str | None,
|
|
247
|
+
Query(
|
|
248
|
+
max_length=INDICATOR_ID_MAX,
|
|
249
|
+
description="Indicator id of a target skill. "
|
|
250
|
+
"Skills on the path to it are ranked first. Leave out or send empty for no goal.",
|
|
251
|
+
),
|
|
252
|
+
] = None,
|
|
253
|
+
grade_max: Annotated[
|
|
254
|
+
int | None,
|
|
255
|
+
Query(
|
|
256
|
+
ge=1,
|
|
257
|
+
le=6,
|
|
258
|
+
description="Class cap: nothing above this class level is recommended (4 means nothing above B4).",
|
|
259
|
+
),
|
|
260
|
+
] = None,
|
|
261
|
+
goal_mode: Annotated[
|
|
262
|
+
Literal["strict", "soft"],
|
|
263
|
+
Query(
|
|
264
|
+
description="How the goal is used. strict: every skill "
|
|
265
|
+
"on the path to the goal comes before every other skill. soft: the goal only raises the "
|
|
266
|
+
"score of skills on the path, so other skills can be mixed in."
|
|
267
|
+
),
|
|
268
|
+
] = "strict",
|
|
269
|
+
):
|
|
270
|
+
"""Skills the learner is ready for and has not mastered, best first.
|
|
271
|
+
|
|
272
|
+
A learner who has never posted an attempt gets the starting recommendations for a new pupil.
|
|
273
|
+
This call does not change any stored state.
|
|
274
|
+
"""
|
|
275
|
+
if goal:
|
|
276
|
+
resolved = RESOLVE.get(goal)
|
|
277
|
+
if resolved is None:
|
|
278
|
+
raise HTTPException(404, f"unknown goal {goal}")
|
|
279
|
+
goal = resolved
|
|
280
|
+
else:
|
|
281
|
+
goal = None
|
|
282
|
+
with LOCK:
|
|
283
|
+
out = R.recommend(peek(learner_id), k=k, goal=goal, grade_max=grade_max, goal_mode=goal_mode)
|
|
284
|
+
for rec in out["recommendations"]:
|
|
285
|
+
rec["indicator"] = _bare(rec["indicator"])
|
|
286
|
+
return out
|
|
287
|
+
|
|
288
|
+
@app.get(
|
|
289
|
+
"/mastery/{learner_id}",
|
|
290
|
+
response_model=dict[str, float],
|
|
291
|
+
tags=["v0 (deprecated)"],
|
|
292
|
+
summary="Mastery of every skill for a learner",
|
|
293
|
+
deprecated=True,
|
|
294
|
+
)
|
|
295
|
+
def mastery(learner_id: LearnerId):
|
|
296
|
+
"""Map of every indicator id in the graph to the learner's mastery, 0 to 1 (3 decimals).
|
|
297
|
+
|
|
298
|
+
A learner who has never posted an attempt gets the starting estimates for a new pupil.
|
|
299
|
+
This call does not change any stored state.
|
|
300
|
+
"""
|
|
301
|
+
with LOCK:
|
|
302
|
+
st = peek(learner_id)
|
|
303
|
+
return {_bare(n): round(L.mastery(st, n), 3) for n in G}
|
|
304
|
+
|
|
305
|
+
@app.delete(
|
|
306
|
+
"/learner/{learner_id}",
|
|
307
|
+
status_code=204,
|
|
308
|
+
tags=["v0 (deprecated)"],
|
|
309
|
+
summary="Forget a learner",
|
|
310
|
+
deprecated=True,
|
|
311
|
+
responses={404: {**NOT_FOUND, "description": "No stored state for this learner."}},
|
|
312
|
+
)
|
|
313
|
+
def delete_learner(learner_id: LearnerId):
|
|
314
|
+
"""Remove everything stored for a learner, so the next call starts from a new pupil.
|
|
315
|
+
|
|
316
|
+
Returns 404 when the learner has no stored state, that is, has never posted an attempt
|
|
317
|
+
or was already deleted.
|
|
318
|
+
"""
|
|
319
|
+
with LOCK:
|
|
320
|
+
if STATES.pop(learner_id, None) is None:
|
|
321
|
+
raise HTTPException(404, f"unknown learner {learner_id}")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# --------------------------------------------------------------------------- the application
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class _LazyRuntime:
|
|
328
|
+
"""Builds the runtime from the environment on the first v1 request, not at import time."""
|
|
329
|
+
|
|
330
|
+
def __init__(self, rt: _runtime.Runtime | None) -> None:
|
|
331
|
+
self._rt = rt
|
|
332
|
+
self._lock = threading.Lock()
|
|
333
|
+
|
|
334
|
+
def get(self) -> _runtime.Runtime:
|
|
335
|
+
if self._rt is None:
|
|
336
|
+
with self._lock:
|
|
337
|
+
if self._rt is None:
|
|
338
|
+
self._rt = _runtime.from_env()
|
|
339
|
+
return self._rt
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def create_app(rt: _runtime.Runtime | None = None, *, v0: bool = True) -> FastAPI:
|
|
343
|
+
"""An application serving v1 (and the deprecated v0 routes when `v0` is true).
|
|
344
|
+
|
|
345
|
+
`rt` is the runtime to serve; None reads the environment (`KWAN_API_KEYS`, `KWAN_STORE`,
|
|
346
|
+
`KWAN_BUNDLE`) when the first v1 request arrives."""
|
|
347
|
+
holder = _LazyRuntime(rt)
|
|
348
|
+
app = FastAPI(
|
|
349
|
+
title="Kwan Learning Path Engine",
|
|
350
|
+
version=__version__,
|
|
351
|
+
summary="Next best maths skill for each pupil, with a reason, in milliseconds.",
|
|
352
|
+
description=DESCRIPTION,
|
|
353
|
+
openapi_tags=[*_v1.TAGS, {"name": "v0 (deprecated)", "description": "Round 1 routes, in-memory state."}],
|
|
354
|
+
)
|
|
355
|
+
_v1.install_error_handlers(app)
|
|
356
|
+
app.state.runtime = holder
|
|
357
|
+
|
|
358
|
+
app.include_router(_v1.build_router(holder.get))
|
|
359
|
+
|
|
360
|
+
@app.middleware("http")
|
|
361
|
+
async def _guard_and_log(request: Request, call_next): # type: ignore[no-untyped-def]
|
|
362
|
+
length = request.headers.get("content-length")
|
|
363
|
+
if length and length.isdigit() and int(length) > _v1.MAX_BODY_BYTES:
|
|
364
|
+
return JSONResponse(
|
|
365
|
+
{"detail": f"request body over {_v1.MAX_BODY_BYTES} bytes", "code": "payload_too_large"},
|
|
366
|
+
status_code=413,
|
|
367
|
+
)
|
|
368
|
+
request_id = request.headers.get("X-Request-Id") or uuid.uuid4().hex[:12]
|
|
369
|
+
t0 = time.perf_counter()
|
|
370
|
+
response = await call_next(request)
|
|
371
|
+
path = request.url.path
|
|
372
|
+
successor = v0_successor(path) if v0 else None
|
|
373
|
+
if successor is not None:
|
|
374
|
+
response.headers["Deprecation"] = "true"
|
|
375
|
+
response.headers["Link"] = f'<{successor}>; rel="successor-version"'
|
|
376
|
+
response.headers["X-Request-Id"] = request_id
|
|
377
|
+
if path.startswith("/v1/") or successor is not None:
|
|
378
|
+
_runtime.logger().info(
|
|
379
|
+
"request",
|
|
380
|
+
extra={
|
|
381
|
+
"event": "request",
|
|
382
|
+
"request_id": request_id,
|
|
383
|
+
"method": request.method,
|
|
384
|
+
"path": path,
|
|
385
|
+
"status": response.status_code,
|
|
386
|
+
"ms": round((time.perf_counter() - t0) * 1000, 2),
|
|
387
|
+
"deprecated": successor is not None,
|
|
388
|
+
},
|
|
389
|
+
)
|
|
390
|
+
return response
|
|
391
|
+
|
|
392
|
+
if v0:
|
|
393
|
+
register_v0(app)
|
|
394
|
+
return app
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def runtime_of(app: FastAPI) -> _runtime.Runtime:
|
|
398
|
+
"""The runtime an application created by `create_app` serves (built on first use)."""
|
|
399
|
+
holder: _LazyRuntime = app.state.runtime
|
|
400
|
+
return holder.get()
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
app = create_app()
|