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.
Files changed (105) hide show
  1. kwan/__init__.py +37 -0
  2. kwan/_bundle_cli.py +80 -0
  3. kwan/_paths.py +33 -0
  4. kwan/_version.py +3 -0
  5. kwan/api/__init__.py +6 -0
  6. kwan/api/app.py +403 -0
  7. kwan/api/runtime.py +162 -0
  8. kwan/api/v1.py +441 -0
  9. kwan/assets/kwan-logo-dark.png +0 -0
  10. kwan/assets/kwan-logo-light.png +0 -0
  11. kwan/assets/kwan-logo.png +0 -0
  12. kwan/assets/kwan-mark.png +0 -0
  13. kwan/bundle.py +533 -0
  14. kwan/bundles/kwan-bundle.math.json +15 -0
  15. kwan/cli.py +100 -0
  16. kwan/context/__init__.py +12 -0
  17. kwan/context/_cli.py +210 -0
  18. kwan/context/bank.py +76 -0
  19. kwan/context/banned_terms.txt +272 -0
  20. kwan/context/checks.py +677 -0
  21. kwan/context/coverage.py +102 -0
  22. kwan/context/drafts.py +300 -0
  23. kwan/context/ghana_terms.txt +581 -0
  24. kwan/context/settings.py +369 -0
  25. kwan/context/sheets.py +889 -0
  26. kwan/context/translate.py +305 -0
  27. kwan/curriculum/__init__.py +1 -0
  28. kwan/curriculum/_cli.py +141 -0
  29. kwan/curriculum/build.py +245 -0
  30. kwan/curriculum/corrections.py +113 -0
  31. kwan/curriculum/document.py +470 -0
  32. kwan/curriculum/exemplars.py +113 -0
  33. kwan/curriculum/families.py +162 -0
  34. kwan/curriculum/families.yaml +58 -0
  35. kwan/curriculum/fetch.py +152 -0
  36. kwan/curriculum/ghana_terms_extra.txt +27 -0
  37. kwan/curriculum/labels.py +108 -0
  38. kwan/curriculum/model.py +57 -0
  39. kwan/curriculum/parse.py +194 -0
  40. kwan/curriculum/pdf.py +263 -0
  41. kwan/curriculum/qa.py +595 -0
  42. kwan/curriculum/text.py +115 -0
  43. kwan/data/__init__.py +1 -0
  44. kwan/data/loaders/__init__.py +60 -0
  45. kwan/data/loaders/_common.py +50 -0
  46. kwan/data/loaders/ammore.py +41 -0
  47. kwan/data/loaders/assistments.py +119 -0
  48. kwan/data/loaders/eedi.py +95 -0
  49. kwan/data/loaders/junyi.py +81 -0
  50. kwan/data/loaders/xes3g5m.py +68 -0
  51. kwan/data/registry.py +97 -0
  52. kwan/data/schema.py +122 -0
  53. kwan/eval/__init__.py +1 -0
  54. kwan/eval/_cli.py +183 -0
  55. kwan/eval/ammore.py +66 -0
  56. kwan/eval/deep.py +1284 -0
  57. kwan/eval/eedi.py +69 -0
  58. kwan/eval/fit.py +763 -0
  59. kwan/eval/harness.py +338 -0
  60. kwan/eval/kt.py +254 -0
  61. kwan/eval/latency.py +140 -0
  62. kwan/eval/metrics.py +65 -0
  63. kwan/eval/results.py +113 -0
  64. kwan/eval/simulate.py +675 -0
  65. kwan/graph/__init__.py +1 -0
  66. kwan/graph/_cli.py +127 -0
  67. kwan/graph/_cli_augment.py +487 -0
  68. kwan/graph/augment.py +649 -0
  69. kwan/graph/build.py +455 -0
  70. kwan/graph/candidates.py +284 -0
  71. kwan/graph/export.py +315 -0
  72. kwan/graph/junyi.py +488 -0
  73. kwan/graph/providers/__init__.py +41 -0
  74. kwan/graph/providers/fake.py +41 -0
  75. kwan/graph/providers/llm_json.py +167 -0
  76. kwan/graph/providers/local_encoder.py +273 -0
  77. kwan/graph/providers/typesafe_jev.py +109 -0
  78. kwan/graph/review.py +287 -0
  79. kwan/graph/schema.py +321 -0
  80. kwan/graph/validate.py +374 -0
  81. kwan/ids.py +137 -0
  82. kwan/kwan.py +346 -0
  83. kwan/learner/__init__.py +26 -0
  84. kwan/learner/bkt.py +422 -0
  85. kwan/learner/elo.py +458 -0
  86. kwan/learner/forgetting.py +80 -0
  87. kwan/learner/item_bank.py +379 -0
  88. kwan/learner/params.py +82 -0
  89. kwan/learner/scoring.py +261 -0
  90. kwan/py.typed +0 -0
  91. kwan/recommend/__init__.py +1 -0
  92. kwan/recommend/explain.py +114 -0
  93. kwan/recommend/explore.py +138 -0
  94. kwan/recommend/recommender.py +298 -0
  95. kwan/recommend/review.py +77 -0
  96. kwan/store/__init__.py +42 -0
  97. kwan/store/base.py +313 -0
  98. kwan/store/memory.py +200 -0
  99. kwan/store/postgres.py +310 -0
  100. kwan/store/sqlite.py +412 -0
  101. kwan-1.0.0.dist-info/METADATA +477 -0
  102. kwan-1.0.0.dist-info/RECORD +105 -0
  103. kwan-1.0.0.dist-info/WHEEL +4 -0
  104. kwan-1.0.0.dist-info/entry_points.txt +2 -0
  105. 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
@@ -0,0 +1,3 @@
1
+ """Single source of the package version (read by hatchling and by `kwan --version`)."""
2
+
3
+ __version__ = "1.0.0"
kwan/api/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """REST API (FastAPI). Needs the `api` extra. The application object is `kwan.api.app:app`:
2
+
3
+ uvicorn kwan.api.app:app --port 8000
4
+
5
+ Settings come from the environment: `KWAN_API_KEYS`, `KWAN_STORE`, `KWAN_BUNDLE` (see `kwan.api.runtime`).
6
+ """
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()