standardgraph 1.2.4__tar.gz → 1.4.0__tar.gz
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.
- {standardgraph-1.2.4 → standardgraph-1.4.0}/PKG-INFO +3 -3
- {standardgraph-1.2.4 → standardgraph-1.4.0}/README.md +1 -1
- {standardgraph-1.2.4 → standardgraph-1.4.0}/pyproject.toml +2 -2
- {standardgraph-1.2.4 → standardgraph-1.4.0}/src/common_core/server.py +502 -97
- {standardgraph-1.2.4 → standardgraph-1.4.0}/src/standardgraph.egg-info/PKG-INFO +3 -3
- {standardgraph-1.2.4 → standardgraph-1.4.0}/setup.cfg +0 -0
- {standardgraph-1.2.4 → standardgraph-1.4.0}/src/common_core/__init__.py +0 -0
- {standardgraph-1.2.4 → standardgraph-1.4.0}/src/common_core/config.py +0 -0
- {standardgraph-1.2.4 → standardgraph-1.4.0}/src/standardgraph.egg-info/SOURCES.txt +0 -0
- {standardgraph-1.2.4 → standardgraph-1.4.0}/src/standardgraph.egg-info/dependency_links.txt +0 -0
- {standardgraph-1.2.4 → standardgraph-1.4.0}/src/standardgraph.egg-info/entry_points.txt +0 -0
- {standardgraph-1.2.4 → standardgraph-1.4.0}/src/standardgraph.egg-info/requires.txt +0 -0
- {standardgraph-1.2.4 → standardgraph-1.4.0}/src/standardgraph.egg-info/top_level.txt +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: standardgraph
|
|
3
|
-
Version: 1.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 1.4.0
|
|
4
|
+
Summary: 172,000+ education standards (math, science, ELA, social studies, CS, arts) across 310 curriculum systems, cross-referenced via NLP — accessible as a Claude MCP server
|
|
5
5
|
Author: StandardGraph
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/swoopeagle/standardgraph
|
|
@@ -24,7 +24,7 @@ Requires-Dist: numpy>=1.26.0
|
|
|
24
24
|
|
|
25
25
|
<!-- mcp-name: io.github.swoopeagle/standardgraph -->
|
|
26
26
|
|
|
27
|
-
**
|
|
27
|
+
**154,000+ education standards across 300 curriculum systems, accessible as a Claude MCP server.**
|
|
28
28
|
|
|
29
29
|
Covers seven subjects — Mathematics, Science, ELA, Social Studies, Computer Science, Arts, and World Languages — across the US (CCSS + all 50 states), Canada, Australia, UK, Singapore, Japan, New Zealand, Ireland, Hong Kong, India, Ghana, South Africa, Rwanda, Cambridge International, IB MYP/DP, AP, and more. Standards are cross-referenced to subject hubs (CCSS for math, NGSS for science, etc.) via semantic similarity, with LLM quality scores on the strongest mappings.
|
|
30
30
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- mcp-name: io.github.swoopeagle/standardgraph -->
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**154,000+ education standards across 300 curriculum systems, accessible as a Claude MCP server.**
|
|
6
6
|
|
|
7
7
|
Covers seven subjects — Mathematics, Science, ELA, Social Studies, Computer Science, Arts, and World Languages — across the US (CCSS + all 50 states), Canada, Australia, UK, Singapore, Japan, New Zealand, Ireland, Hong Kong, India, Ghana, South Africa, Rwanda, Cambridge International, IB MYP/DP, AP, and more. Standards are cross-referenced to subject hubs (CCSS for math, NGSS for science, etc.) via semantic similarity, with LLM quality scores on the strongest mappings.
|
|
8
8
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "standardgraph"
|
|
3
|
-
version = "1.
|
|
4
|
-
description = "
|
|
3
|
+
version = "1.4.0"
|
|
4
|
+
description = "172,000+ education standards (math, science, ELA, social studies, CS, arts) across 310 curriculum systems, cross-referenced via NLP — accessible as a Claude MCP server"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
7
7
|
license = "MIT"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"""StandardGraph MCP server — education standards across 7 subjects."""
|
|
2
|
+
import functools
|
|
2
3
|
import json
|
|
3
4
|
import re
|
|
4
5
|
import sqlite3
|
|
@@ -181,6 +182,42 @@ mcp = FastMCP(
|
|
|
181
182
|
)
|
|
182
183
|
|
|
183
184
|
|
|
185
|
+
# ── Static viz route ──────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
def _viz_dir() -> "os.PathLike | None":
|
|
188
|
+
"""Directory holding the pre-built viz HTML.
|
|
189
|
+
|
|
190
|
+
Uses SG_VIZ_DIR when set; otherwise falls back to ``<repo>/docs/viz`` derived
|
|
191
|
+
from DB_PATH (which, in the hosted deployment, lives in ``<repo>/data``).
|
|
192
|
+
"""
|
|
193
|
+
import os
|
|
194
|
+
from pathlib import Path
|
|
195
|
+
|
|
196
|
+
override = os.getenv("SG_VIZ_DIR")
|
|
197
|
+
if override:
|
|
198
|
+
return Path(override)
|
|
199
|
+
candidate = Path(DB_PATH).resolve().parent.parent / "docs" / "viz"
|
|
200
|
+
return candidate if candidate.is_dir() else None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@mcp.custom_route("/concept-map", methods=["GET"])
|
|
204
|
+
async def _concept_map(request): # noqa: ANN001 — Starlette Request
|
|
205
|
+
"""Serve the self-contained interactive concept map (offline single file)."""
|
|
206
|
+
from pathlib import Path
|
|
207
|
+
|
|
208
|
+
from starlette.responses import HTMLResponse, PlainTextResponse
|
|
209
|
+
|
|
210
|
+
viz = _viz_dir()
|
|
211
|
+
if viz is not None:
|
|
212
|
+
f = Path(viz) / "concept_map.html"
|
|
213
|
+
if f.is_file():
|
|
214
|
+
return HTMLResponse(
|
|
215
|
+
f.read_text(encoding="utf-8"),
|
|
216
|
+
headers={"Cache-Control": "public, max-age=300"},
|
|
217
|
+
)
|
|
218
|
+
return PlainTextResponse("Concept map not available on this deployment.", status_code=404)
|
|
219
|
+
|
|
220
|
+
|
|
184
221
|
# ── DB helpers ────────────────────────────────────────────────────────────────
|
|
185
222
|
|
|
186
223
|
def _db() -> sqlite3.Connection:
|
|
@@ -215,6 +252,50 @@ def _grade_key(g: str) -> int:
|
|
|
215
252
|
return 99
|
|
216
253
|
|
|
217
254
|
|
|
255
|
+
# Separator between the two ends of a grade range ('3-6', '3–6', '6 to 8').
|
|
256
|
+
_RANGE_SEP = re.compile(r"\s*(?:-|–|—|\.\.|to|through)\s*", re.I)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _coerce_grade(v) -> str | None:
|
|
260
|
+
"""Map an int/str grade to a canonical GRADE_ORDER code, else None.
|
|
261
|
+
|
|
262
|
+
Accepts ints, numeric strings ('5', '5.0'), grade codes ('K', 'HS',
|
|
263
|
+
case-insensitive) and high-school year numbers (9–12 → 'HS').
|
|
264
|
+
"""
|
|
265
|
+
if v is None:
|
|
266
|
+
return None
|
|
267
|
+
s = str(v).strip().upper()
|
|
268
|
+
s = re.sub(r"^(?:GRADES?|GR)\.?\s*", "", s) # 'Grade 3' / 'Gr. 3' → '3'
|
|
269
|
+
if s in GRADE_ORDER:
|
|
270
|
+
return s
|
|
271
|
+
if s in ("K", "KG", "KINDERGARTEN"):
|
|
272
|
+
return "K"
|
|
273
|
+
if s in ("HS", "HIGH SCHOOL"):
|
|
274
|
+
return "HS"
|
|
275
|
+
try:
|
|
276
|
+
n = int(float(s))
|
|
277
|
+
except (ValueError, TypeError):
|
|
278
|
+
return None
|
|
279
|
+
if str(n) in GRADE_ORDER:
|
|
280
|
+
return str(n)
|
|
281
|
+
return "HS" if n >= 9 else "K" if n < 0 else None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _norm_grade_bounds(grade_start, grade_end) -> tuple[str | None, str | None]:
|
|
285
|
+
"""Coerce loose grade_start/grade_end args into canonical grade-code bounds.
|
|
286
|
+
|
|
287
|
+
Tolerates a range string passed in either slot — LLM callers frequently send
|
|
288
|
+
grade_start='3-6' despite the integer signature, which previously slipped past
|
|
289
|
+
_grade_key as 99 and silently filtered out every grade. Split it into two ends.
|
|
290
|
+
"""
|
|
291
|
+
for val in (grade_start, grade_end):
|
|
292
|
+
if isinstance(val, str) and _RANGE_SEP.search(val.strip()):
|
|
293
|
+
parts = _RANGE_SEP.split(val.strip(), maxsplit=1)
|
|
294
|
+
if len(parts) == 2 and parts[0] and parts[1]:
|
|
295
|
+
return _coerce_grade(parts[0]), _coerce_grade(parts[1])
|
|
296
|
+
return _coerce_grade(grade_start), _coerce_grade(grade_end)
|
|
297
|
+
|
|
298
|
+
|
|
218
299
|
# A single-letter CCSS cluster segment sitting directly before the final numeric
|
|
219
300
|
# ordinal (e.g. the 'A' in '6.RP.A.3'). The DB is inconsistent about retaining it
|
|
220
301
|
# — most IDs keep it ('5.NF.A.2') but some dropped it on ingest ('6.RP.3') — so
|
|
@@ -223,7 +304,48 @@ _CLUSTER_LETTER = re.compile(r"\.[A-Z](?=\.\d)")
|
|
|
223
304
|
|
|
224
305
|
|
|
225
306
|
def _loose_id(sid: str) -> str:
|
|
226
|
-
|
|
307
|
+
# Upper-case so the compare is case-insensitive and lowercase cluster
|
|
308
|
+
# letters ('6.rp.a.3') still match the [A-Z] cluster pattern.
|
|
309
|
+
return _CLUSTER_LETTER.sub("", sid.upper())
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _resolve_id(conn: sqlite3.Connection, sid: str, system: str) -> str | None:
|
|
313
|
+
"""Return the canonical stored ID matching sid, or None.
|
|
314
|
+
|
|
315
|
+
Tries, in order: exact match, case-insensitive exact match, then a
|
|
316
|
+
cluster-letter-tolerant loose match (so '6.RP.A.3' finds a stored
|
|
317
|
+
'6.RP.3' and vice versa, regardless of case). Each tier is retried with
|
|
318
|
+
trailing punctuation stripped ('6.RP.A.3.' pasted from prose) — but the
|
|
319
|
+
raw ID is always tried first, so IDs that legitimately end in punctuation
|
|
320
|
+
(e.g. Alberta 'CA-AB.MATH.K.MAT.5.1.3.a.') still match exactly. Shared by
|
|
321
|
+
lookup_standard and map_standard so both tolerate the same ID drift.
|
|
322
|
+
"""
|
|
323
|
+
candidates = [sid]
|
|
324
|
+
stripped = sid.rstrip(" .,;:")
|
|
325
|
+
if stripped and stripped != sid:
|
|
326
|
+
candidates.append(stripped)
|
|
327
|
+
|
|
328
|
+
for cand in candidates:
|
|
329
|
+
row = conn.execute("SELECT id FROM standards WHERE id = ?", (cand,)).fetchone()
|
|
330
|
+
if row:
|
|
331
|
+
return row[0]
|
|
332
|
+
row = conn.execute(
|
|
333
|
+
"SELECT id FROM standards WHERE id = ? COLLATE NOCASE", (cand,)
|
|
334
|
+
).fetchone()
|
|
335
|
+
if row:
|
|
336
|
+
return row[0]
|
|
337
|
+
|
|
338
|
+
for cand in candidates:
|
|
339
|
+
target = _loose_id(cand)
|
|
340
|
+
match = next(
|
|
341
|
+
(cid for (cid,) in conn.execute(
|
|
342
|
+
"SELECT id FROM standards WHERE system = ?", (system,))
|
|
343
|
+
if _loose_id(cid) == target),
|
|
344
|
+
None,
|
|
345
|
+
)
|
|
346
|
+
if match:
|
|
347
|
+
return match
|
|
348
|
+
return None
|
|
227
349
|
|
|
228
350
|
|
|
229
351
|
# ── Embedding ─────────────────────────────────────────────────────────────────
|
|
@@ -239,10 +361,24 @@ def _embed_query(text: str) -> np.ndarray:
|
|
|
239
361
|
return np.array(resp.json()["embeddings"][0], dtype=np.float32)
|
|
240
362
|
|
|
241
363
|
|
|
364
|
+
# Canonical crosswalk-hub system per subject. A bare search (no system/subject)
|
|
365
|
+
# ranks across all hubs so it is not silently limited to math.
|
|
366
|
+
SUBJECT_HUB = {
|
|
367
|
+
"mathematics": "ccss",
|
|
368
|
+
"science": "ngss",
|
|
369
|
+
"ela": "ccss-ela",
|
|
370
|
+
"social-studies": "c3",
|
|
371
|
+
"cs": "csta",
|
|
372
|
+
}
|
|
373
|
+
HUB_SYSTEMS = list(SUBJECT_HUB.values())
|
|
374
|
+
|
|
375
|
+
|
|
242
376
|
def _cosine_scores(
|
|
243
377
|
query_vec: np.ndarray,
|
|
244
378
|
conn: sqlite3.Connection,
|
|
245
379
|
system: str | None = None,
|
|
380
|
+
systems: list[str] | None = None,
|
|
381
|
+
subject: str | None = None,
|
|
246
382
|
) -> list[tuple[float, str]]:
|
|
247
383
|
if system:
|
|
248
384
|
rows = conn.execute(
|
|
@@ -250,6 +386,19 @@ def _cosine_scores(
|
|
|
250
386
|
"JOIN standards s ON s.id = e.standard_id WHERE s.system = ?",
|
|
251
387
|
(system,),
|
|
252
388
|
).fetchall()
|
|
389
|
+
elif systems:
|
|
390
|
+
ph = ",".join("?" * len(systems))
|
|
391
|
+
rows = conn.execute(
|
|
392
|
+
"SELECT e.standard_id, e.vector, e.dimensions FROM embeddings e "
|
|
393
|
+
f"JOIN standards s ON s.id = e.standard_id WHERE s.system IN ({ph})",
|
|
394
|
+
list(systems),
|
|
395
|
+
).fetchall()
|
|
396
|
+
elif subject:
|
|
397
|
+
rows = conn.execute(
|
|
398
|
+
"SELECT e.standard_id, e.vector, e.dimensions FROM embeddings e "
|
|
399
|
+
"JOIN standards s ON s.id = e.standard_id WHERE s.subject = ?",
|
|
400
|
+
(subject,),
|
|
401
|
+
).fetchall()
|
|
253
402
|
else:
|
|
254
403
|
rows = conn.execute("SELECT standard_id, vector, dimensions FROM embeddings").fetchall()
|
|
255
404
|
if not rows:
|
|
@@ -303,12 +452,14 @@ def _ensure_fts(conn: sqlite3.Connection) -> bool:
|
|
|
303
452
|
def _fts_search(
|
|
304
453
|
query: str,
|
|
305
454
|
conn: sqlite3.Connection,
|
|
306
|
-
system: str,
|
|
455
|
+
system: str | None = None,
|
|
456
|
+
systems: list[str] | None = None,
|
|
457
|
+
subject: str | None = None,
|
|
307
458
|
grade_filter: set[str] | None = None,
|
|
308
459
|
domain_filter: str | None = None,
|
|
309
460
|
limit: int = 5,
|
|
310
461
|
) -> list[dict]:
|
|
311
|
-
"""BM25-ranked FTS5 keyword search,
|
|
462
|
+
"""BM25-ranked FTS5 keyword search, scoped to a system, set of systems, or subject."""
|
|
312
463
|
if not _ensure_fts(conn):
|
|
313
464
|
return []
|
|
314
465
|
fts_q = _fts_query(query)
|
|
@@ -331,13 +482,22 @@ def _fts_search(
|
|
|
331
482
|
|
|
332
483
|
rowids = [r[0] for r in ranked]
|
|
333
484
|
placeholders = ",".join("?" * len(rowids))
|
|
485
|
+
if system:
|
|
486
|
+
scope_sql, scope_args = "AND system = ?", [system]
|
|
487
|
+
elif systems:
|
|
488
|
+
ph = ",".join("?" * len(systems))
|
|
489
|
+
scope_sql, scope_args = f"AND system IN ({ph})", list(systems)
|
|
490
|
+
elif subject:
|
|
491
|
+
scope_sql, scope_args = "AND subject = ?", [subject]
|
|
492
|
+
else:
|
|
493
|
+
scope_sql, scope_args = "", []
|
|
334
494
|
try:
|
|
335
495
|
rows_by_id = {
|
|
336
496
|
r["rowid"]: r
|
|
337
497
|
for r in conn.execute(
|
|
338
498
|
f"SELECT rowid, id, grade, domain, standard_text"
|
|
339
|
-
f" FROM standards WHERE rowid IN ({placeholders})
|
|
340
|
-
rowids +
|
|
499
|
+
f" FROM standards WHERE rowid IN ({placeholders}) {scope_sql}",
|
|
500
|
+
rowids + scope_args,
|
|
341
501
|
).fetchall()
|
|
342
502
|
}
|
|
343
503
|
except Exception:
|
|
@@ -363,6 +523,51 @@ def _fts_search(
|
|
|
363
523
|
return results
|
|
364
524
|
|
|
365
525
|
|
|
526
|
+
# ── Relationship helpers (shared by lookup_standard + get_learning_path) ──────
|
|
527
|
+
|
|
528
|
+
# Minimum confidence_score for an LLM-validated edge to count as a HARD (default)
|
|
529
|
+
# prerequisite. SOFT edges are stored at 0.5 and only surfaced with include_soft.
|
|
530
|
+
_HARD_CONF = 0.9
|
|
531
|
+
_SOFT_CONF = 0.5
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _related(conn, sid: str, relationship: str, prefer_validated: bool):
|
|
535
|
+
"""Return (ids, source) for a standard's prerequisite/successor edges.
|
|
536
|
+
|
|
537
|
+
When prefer_validated is True and any method='llm_validated' edges exist for
|
|
538
|
+
this standard, return those (source='llm_validated'); otherwise fall back to
|
|
539
|
+
the grade-heuristic edges (source='grade_heuristic'). Falling back preserves
|
|
540
|
+
non-empty prerequisite lists for standards the pilot never re-validated.
|
|
541
|
+
"""
|
|
542
|
+
if prefer_validated:
|
|
543
|
+
val = [r[0] for r in conn.execute(
|
|
544
|
+
"SELECT target_id FROM standard_relationships "
|
|
545
|
+
"WHERE source_id=? AND relationship=? AND method='llm_validated' "
|
|
546
|
+
"ORDER BY confidence_score DESC, target_id",
|
|
547
|
+
(sid, relationship)).fetchall()]
|
|
548
|
+
if val:
|
|
549
|
+
return val, "llm_validated"
|
|
550
|
+
allrows = [r[0] for r in conn.execute(
|
|
551
|
+
"SELECT target_id FROM standard_relationships "
|
|
552
|
+
"WHERE source_id=? AND relationship=? ORDER BY target_id",
|
|
553
|
+
(sid, relationship)).fetchall()]
|
|
554
|
+
return allrows, "grade_heuristic"
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _parse_prereq_note(notes: str | None) -> tuple[str | None, str | None]:
|
|
558
|
+
"""Split a validated-edge note into (strength, rationale).
|
|
559
|
+
|
|
560
|
+
Stored form: 'llm_prereq cosine=0.83 hard: <why>' — surfaced so callers can
|
|
561
|
+
show *why* a prerequisite was included (the provenance/trust story).
|
|
562
|
+
"""
|
|
563
|
+
if not notes:
|
|
564
|
+
return None, None
|
|
565
|
+
m = re.search(r"\b(hard|soft):\s*(.*)$", notes, re.S)
|
|
566
|
+
if m:
|
|
567
|
+
return m.group(1), m.group(2).strip() or None
|
|
568
|
+
return None, notes.strip() or None
|
|
569
|
+
|
|
570
|
+
|
|
366
571
|
# ── Tool 1: lookup_standard ───────────────────────────────────────────────────
|
|
367
572
|
|
|
368
573
|
@mcp.tool()
|
|
@@ -370,6 +575,7 @@ def lookup_standard(
|
|
|
370
575
|
standard_id: str,
|
|
371
576
|
system: str = "ccss",
|
|
372
577
|
include_elaborations: bool = False,
|
|
578
|
+
prefer_validated: bool = True,
|
|
373
579
|
) -> str:
|
|
374
580
|
"""Fetch the full text, domain, cluster, prerequisites, and successors for a single standard.
|
|
375
581
|
|
|
@@ -378,6 +584,10 @@ def lookup_standard(
|
|
|
378
584
|
standard_id: full ID like 'CCSS.MATH.6.RP.A.3' or shortform '6.RP.A.3' (for CCSS).
|
|
379
585
|
For other systems use the full ID, e.g. 'TX.MATH.5.3.K' or 'CA_BC.MATH.3.a'.
|
|
380
586
|
system: curriculum system code (default 'ccss'). See server instructions for all codes.
|
|
587
|
+
prefer_validated: when True (default), return the LLM-validated prerequisite/successor
|
|
588
|
+
edges if any exist for this standard (higher precision, may be cross-domain);
|
|
589
|
+
otherwise fall back to the grade-heuristic edges. The response reports which
|
|
590
|
+
source was used via `prerequisites_method`.
|
|
381
591
|
|
|
382
592
|
Returns the standard text, grade, domain, cluster, sub-standards (if any),
|
|
383
593
|
prerequisite standard IDs from the prior grade, and successor IDs for the next grade.
|
|
@@ -385,19 +595,17 @@ def lookup_standard(
|
|
|
385
595
|
sid = _expand_id(standard_id, system)
|
|
386
596
|
conn = _db()
|
|
387
597
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
if match_id:
|
|
400
|
-
row = conn.execute("SELECT * FROM standards WHERE id = ?", (match_id,)).fetchone()
|
|
598
|
+
# Resolve to the canonical stored ID (tolerating cluster-letter/case drift),
|
|
599
|
+
# then use it for every downstream lookup so sub-standards, prerequisites and
|
|
600
|
+
# successors are keyed off the real ID rather than the drifted input.
|
|
601
|
+
canonical = _resolve_id(conn, sid, system)
|
|
602
|
+
row = (
|
|
603
|
+
conn.execute("SELECT * FROM standards WHERE id = ?", (canonical,)).fetchone()
|
|
604
|
+
if canonical
|
|
605
|
+
else None
|
|
606
|
+
)
|
|
607
|
+
if row:
|
|
608
|
+
sid = canonical
|
|
401
609
|
if not row:
|
|
402
610
|
# Suggest nearby IDs
|
|
403
611
|
suggestions = [
|
|
@@ -416,18 +624,23 @@ def lookup_standard(
|
|
|
416
624
|
(sid,),
|
|
417
625
|
).fetchall()
|
|
418
626
|
|
|
419
|
-
prerequisites =
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
627
|
+
prerequisites, prereq_method = _related(conn, sid, "prerequisite", prefer_validated)
|
|
628
|
+
successors, _ = _related(conn, sid, "successor", prefer_validated)
|
|
629
|
+
|
|
630
|
+
# Provenance: when the prerequisites are LLM-validated, surface *why* each was
|
|
631
|
+
# included (rationale + hard/soft strength) so callers can show/trust the edge.
|
|
632
|
+
prereq_rationales = None
|
|
633
|
+
if prereq_method == "llm_validated":
|
|
634
|
+
prereq_rationales = {}
|
|
635
|
+
for r in conn.execute(
|
|
636
|
+
"SELECT target_id, confidence_score, notes FROM standard_relationships "
|
|
637
|
+
"WHERE source_id=? AND relationship='prerequisite' AND method='llm_validated'",
|
|
638
|
+
(sid,)).fetchall():
|
|
639
|
+
strength, why = _parse_prereq_note(r[2])
|
|
640
|
+
prereq_rationales[r[0]] = {
|
|
641
|
+
"strength": strength or ("hard" if (r[1] or 0) >= _HARD_CONF else "soft"),
|
|
642
|
+
"why": why,
|
|
643
|
+
}
|
|
431
644
|
conn.close()
|
|
432
645
|
|
|
433
646
|
return json.dumps({
|
|
@@ -439,6 +652,8 @@ def lookup_standard(
|
|
|
439
652
|
"standard_text": std["standard_text"],
|
|
440
653
|
"sub_standards": [f"{r['id']} — {r['text']}" for r in sub_stds],
|
|
441
654
|
"prerequisites": prerequisites,
|
|
655
|
+
"prerequisites_method": prereq_method,
|
|
656
|
+
"prerequisite_rationales": prereq_rationales,
|
|
442
657
|
"successors": successors,
|
|
443
658
|
"source_url": std["source_url"],
|
|
444
659
|
"elaborations": None,
|
|
@@ -450,7 +665,8 @@ def lookup_standard(
|
|
|
450
665
|
@mcp.tool()
|
|
451
666
|
def search_standards(
|
|
452
667
|
query: str,
|
|
453
|
-
system: str =
|
|
668
|
+
system: str | None = None,
|
|
669
|
+
subject: str | None = None,
|
|
454
670
|
grade: str | None = None,
|
|
455
671
|
domain: str | None = None,
|
|
456
672
|
limit: int = 5,
|
|
@@ -461,8 +677,15 @@ def search_standards(
|
|
|
461
677
|
Works across all subjects: mathematics, science, ELA, social studies, CS, arts, world-languages.
|
|
462
678
|
Examples: "adding fractions with unlike denominators", "photosynthesis", "argumentative writing grade 8".
|
|
463
679
|
|
|
680
|
+
Search scope is resolved in this order:
|
|
681
|
+
• system given → search that one curriculum (e.g. 'ngss', 'sg-moe', 'de' = Delaware).
|
|
682
|
+
• subject given → search that subject's canonical hub ('mathematics'→ccss, 'science'→ngss,
|
|
683
|
+
'ela'→ccss-ela, 'social-studies'→c3, 'cs'→csta; 'arts'/'world-languages' search subject-wide).
|
|
684
|
+
• neither → rank across all five subject hubs, so a bare query is never limited to math.
|
|
685
|
+
|
|
464
686
|
query: plain English description of the concept or skill.
|
|
465
|
-
system:
|
|
687
|
+
system: optional — a single curriculum code. Call multiple times to compare systems.
|
|
688
|
+
subject: optional — 'mathematics', 'science', 'ela', 'social-studies', 'cs', 'arts', 'world-languages'.
|
|
466
689
|
grade: optional filter — single grade '5', range '6-8', or 'HS'. Grade codes: K 1 2 3 4 5 6 7 8 HS.
|
|
467
690
|
domain: optional keyword to restrict by domain name (e.g. 'geometry', 'algebra').
|
|
468
691
|
limit: number of results (default 5, max sensible ~10).
|
|
@@ -470,12 +693,28 @@ def search_standards(
|
|
|
470
693
|
Uses semantic similarity (requires Ollama) with automatic keyword FTS fallback when Ollama is unavailable.
|
|
471
694
|
Returns standards ranked by relevance with scores (0–1).
|
|
472
695
|
"""
|
|
696
|
+
# Resolve scope: explicit system > subject (→ its hub, or subject-wide if hubless) > all hubs.
|
|
697
|
+
scope_system: str | None = None
|
|
698
|
+
scope_systems: list[str] | None = None
|
|
699
|
+
scope_subject: str | None = None
|
|
700
|
+
if system:
|
|
701
|
+
scope_system = system
|
|
702
|
+
elif subject:
|
|
703
|
+
hub = SUBJECT_HUB.get(subject.lower())
|
|
704
|
+
if hub:
|
|
705
|
+
scope_system = hub
|
|
706
|
+
else:
|
|
707
|
+
scope_subject = subject.lower() # arts / world-languages: no hub, search the subject
|
|
708
|
+
else:
|
|
709
|
+
scope_systems = HUB_SYSTEMS
|
|
710
|
+
|
|
473
711
|
try:
|
|
474
712
|
query_vec = _embed_query(query)
|
|
475
713
|
except Exception:
|
|
476
714
|
conn = _db()
|
|
477
715
|
results = _fts_search(
|
|
478
|
-
query, conn,
|
|
716
|
+
query, conn,
|
|
717
|
+
system=scope_system, systems=scope_systems, subject=scope_subject,
|
|
479
718
|
grade_filter=_parse_grade_filter(grade) if grade else None,
|
|
480
719
|
domain_filter=domain,
|
|
481
720
|
limit=limit,
|
|
@@ -487,7 +726,7 @@ def search_standards(
|
|
|
487
726
|
"results": results,
|
|
488
727
|
}, indent=2)
|
|
489
728
|
conn = _db()
|
|
490
|
-
scored = _cosine_scores(query_vec, conn, system=
|
|
729
|
+
scored = _cosine_scores(query_vec, conn, system=scope_system, systems=scope_systems, subject=scope_subject)
|
|
491
730
|
|
|
492
731
|
results = []
|
|
493
732
|
for score, sid in scored:
|
|
@@ -541,8 +780,8 @@ def _parse_grade_filter(grade: str | list) -> set[str]:
|
|
|
541
780
|
def get_progression(
|
|
542
781
|
concept: str,
|
|
543
782
|
system: str = "ccss",
|
|
544
|
-
grade_start: int | None = None,
|
|
545
|
-
grade_end: int | None = None,
|
|
783
|
+
grade_start: int | str | None = None,
|
|
784
|
+
grade_end: int | str | None = None,
|
|
546
785
|
) -> str:
|
|
547
786
|
"""Show how a math concept is introduced and built upon across grade levels.
|
|
548
787
|
|
|
@@ -552,11 +791,13 @@ def get_progression(
|
|
|
552
791
|
concept: plain English name of the math concept (e.g. 'fractions', 'linear equations',
|
|
553
792
|
'place value', 'geometric transformations').
|
|
554
793
|
system: curriculum to trace (default 'ccss'). Try 'cambridge' or 'ib-myp' for comparison.
|
|
555
|
-
grade_start / grade_end: optional
|
|
794
|
+
grade_start / grade_end: optional bounds to narrow the range (e.g. 3 and 8). Accepts
|
|
795
|
+
ints, grade codes ('K', 'HS'), or a single range string like '3-6' / '6 to 8'.
|
|
556
796
|
|
|
557
797
|
Returns the top matching standards per grade, ordered K through HS, showing how the
|
|
558
798
|
concept deepens over time.
|
|
559
799
|
"""
|
|
800
|
+
g_start, g_end = _norm_grade_bounds(grade_start, grade_end)
|
|
560
801
|
try:
|
|
561
802
|
query_vec = _embed_query(concept)
|
|
562
803
|
except Exception:
|
|
@@ -573,8 +814,8 @@ def get_progression(
|
|
|
573
814
|
continue
|
|
574
815
|
by_grade.setdefault(g, []).append({"id": r["id"], "text": r["standard_text"]})
|
|
575
816
|
|
|
576
|
-
gr_start =
|
|
577
|
-
gr_end =
|
|
817
|
+
gr_start = g_start or "K"
|
|
818
|
+
gr_end = g_end or "HS"
|
|
578
819
|
|
|
579
820
|
return json.dumps({
|
|
580
821
|
"concept": concept,
|
|
@@ -603,9 +844,9 @@ def get_progression(
|
|
|
603
844
|
std = dict(row)
|
|
604
845
|
g = std["grade"]
|
|
605
846
|
|
|
606
|
-
if
|
|
847
|
+
if g_start is not None and _grade_key(g) < _grade_key(g_start):
|
|
607
848
|
continue
|
|
608
|
-
if
|
|
849
|
+
if g_end is not None and _grade_key(g) > _grade_key(g_end):
|
|
609
850
|
continue
|
|
610
851
|
|
|
611
852
|
by_grade.setdefault(g, []).append({
|
|
@@ -616,8 +857,8 @@ def get_progression(
|
|
|
616
857
|
|
|
617
858
|
conn.close()
|
|
618
859
|
|
|
619
|
-
gr_start =
|
|
620
|
-
gr_end =
|
|
860
|
+
gr_start = g_start or "K"
|
|
861
|
+
gr_end = g_end or "HS"
|
|
621
862
|
|
|
622
863
|
stages = []
|
|
623
864
|
for g in sorted(by_grade.keys(), key=_grade_key):
|
|
@@ -635,7 +876,165 @@ def get_progression(
|
|
|
635
876
|
}, indent=2)
|
|
636
877
|
|
|
637
878
|
|
|
638
|
-
# ── Tool 4:
|
|
879
|
+
# ── Tool 4: get_learning_path ─────────────────────────────────────────────────
|
|
880
|
+
|
|
881
|
+
@mcp.tool()
|
|
882
|
+
def get_learning_path(
|
|
883
|
+
target: str,
|
|
884
|
+
system: str = "ccss",
|
|
885
|
+
from_standard: str | None = None,
|
|
886
|
+
max_depth: int = 20,
|
|
887
|
+
include_soft: bool = False,
|
|
888
|
+
) -> str:
|
|
889
|
+
"""Build an ordered, grade-increasing sequence of standards to learn to reach a target.
|
|
890
|
+
|
|
891
|
+
Walks the LLM-validated prerequisite graph backward from the target standard and
|
|
892
|
+
returns every prerequisite (transitively) as a topologically ordered study plan —
|
|
893
|
+
the substrate for self-paced acceleration ("what do I need before calculus?").
|
|
894
|
+
Only method='llm_validated' edges are traversed, so the path is far cleaner than the
|
|
895
|
+
raw grade-adjacency graph and includes genuine cross-domain dependencies.
|
|
896
|
+
|
|
897
|
+
target: the standard to work toward — full ID 'CCSS.MATH.HSF.LE.A.1.b' or shortform.
|
|
898
|
+
system: curriculum system code (default 'ccss'; validated edges currently exist for CCSS math).
|
|
899
|
+
from_standard: optional — the standard the learner has already mastered. When given, the
|
|
900
|
+
path is pruned to the sub-sequence between that standard and the target.
|
|
901
|
+
max_depth: max prerequisite levels to walk back (default 20; guards pathological depth).
|
|
902
|
+
include_soft: when False (default) only HARD prerequisites (direct building blocks) are
|
|
903
|
+
followed; when True, SOFT (helpful-background) edges are included too.
|
|
904
|
+
|
|
905
|
+
Returns the resolved target, an ordered `path` (each node with id/grade/domain/text and its
|
|
906
|
+
immediate in-path prerequisites), plus counts. If the target has no validated prerequisites
|
|
907
|
+
the path is just the target with an explanatory note.
|
|
908
|
+
"""
|
|
909
|
+
conn = _db()
|
|
910
|
+
tgt = _resolve_id(conn, _expand_id(target, system), system)
|
|
911
|
+
if not tgt:
|
|
912
|
+
conn.close()
|
|
913
|
+
return json.dumps({"error": "standard_not_found", "queried_id": _expand_id(target, system)})
|
|
914
|
+
|
|
915
|
+
min_conf = _SOFT_CONF if include_soft else _HARD_CONF
|
|
916
|
+
# (learner, prereq) -> {strength, why, confidence} for provenance in the output.
|
|
917
|
+
edge_meta: dict[tuple[str, str], dict] = {}
|
|
918
|
+
|
|
919
|
+
def prereqs_of(node: str) -> list[str]:
|
|
920
|
+
rows = conn.execute(
|
|
921
|
+
"SELECT target_id, confidence_score, notes FROM standard_relationships "
|
|
922
|
+
"WHERE source_id=? AND relationship='prerequisite' "
|
|
923
|
+
"AND method='llm_validated' AND confidence_score >= ?",
|
|
924
|
+
(node, min_conf)).fetchall()
|
|
925
|
+
out = []
|
|
926
|
+
for p, conf, notes in rows:
|
|
927
|
+
strength, why = _parse_prereq_note(notes)
|
|
928
|
+
edge_meta[(node, p)] = {
|
|
929
|
+
"strength": strength or ("hard" if (conf or 0) >= _HARD_CONF else "soft"),
|
|
930
|
+
"why": why,
|
|
931
|
+
"confidence": conf,
|
|
932
|
+
}
|
|
933
|
+
out.append(p)
|
|
934
|
+
return out
|
|
935
|
+
|
|
936
|
+
# Reverse-BFS the prerequisite closure of the target, bounded by max_depth.
|
|
937
|
+
seen = {tgt}
|
|
938
|
+
edges: dict[str, list[str]] = {}
|
|
939
|
+
frontier = [tgt]
|
|
940
|
+
depth = 0
|
|
941
|
+
while frontier and depth < max_depth:
|
|
942
|
+
nxt: list[str] = []
|
|
943
|
+
for n in frontier:
|
|
944
|
+
ps = prereqs_of(n)
|
|
945
|
+
edges[n] = ps
|
|
946
|
+
for p in ps:
|
|
947
|
+
if p not in seen:
|
|
948
|
+
seen.add(p)
|
|
949
|
+
nxt.append(p)
|
|
950
|
+
frontier = nxt
|
|
951
|
+
depth += 1
|
|
952
|
+
|
|
953
|
+
# Optional from_standard pruning: keep only nodes on a chain from_standard → target.
|
|
954
|
+
from_resolved = None
|
|
955
|
+
from_reachable = None
|
|
956
|
+
if from_standard:
|
|
957
|
+
src = _resolve_id(conn, _expand_id(from_standard, system), system)
|
|
958
|
+
from_resolved = src
|
|
959
|
+
from_reachable = bool(src and src in seen)
|
|
960
|
+
if from_reachable:
|
|
961
|
+
# Forward reachability from src via validated successor edges, within the closure.
|
|
962
|
+
fwd = {src}
|
|
963
|
+
fr = [src]
|
|
964
|
+
while fr:
|
|
965
|
+
nx: list[str] = []
|
|
966
|
+
for n in fr:
|
|
967
|
+
for r in conn.execute(
|
|
968
|
+
"SELECT target_id FROM standard_relationships "
|
|
969
|
+
"WHERE source_id=? AND relationship='successor' "
|
|
970
|
+
"AND method='llm_validated' AND confidence_score >= ?",
|
|
971
|
+
(n, min_conf)).fetchall():
|
|
972
|
+
s = r[0]
|
|
973
|
+
if s in seen and s not in fwd:
|
|
974
|
+
fwd.add(s)
|
|
975
|
+
nx.append(s)
|
|
976
|
+
fr = nx
|
|
977
|
+
keep = (fwd & seen) | {tgt}
|
|
978
|
+
seen = {n for n in seen if n in keep}
|
|
979
|
+
|
|
980
|
+
# Materialise node rows and order by grade (every validated edge increases grade,
|
|
981
|
+
# so grade order is a valid topological order; ties broken by id for determinism).
|
|
982
|
+
nodes = []
|
|
983
|
+
for n in seen:
|
|
984
|
+
row = conn.execute(
|
|
985
|
+
"SELECT id, grade, domain, standard_text FROM standards WHERE id=?", (n,)
|
|
986
|
+
).fetchone()
|
|
987
|
+
if row:
|
|
988
|
+
nodes.append(dict(row))
|
|
989
|
+
conn.close()
|
|
990
|
+
nodes.sort(key=lambda d: (_grade_key(d["grade"]), d["id"]))
|
|
991
|
+
|
|
992
|
+
setids = {d["id"] for d in nodes}
|
|
993
|
+
|
|
994
|
+
def _prereq_entries(learner: str):
|
|
995
|
+
entries = []
|
|
996
|
+
for p in edges.get(learner, []):
|
|
997
|
+
if p not in setids:
|
|
998
|
+
continue
|
|
999
|
+
meta = edge_meta.get((learner, p), {})
|
|
1000
|
+
entries.append({
|
|
1001
|
+
"id": p,
|
|
1002
|
+
"strength": meta.get("strength"),
|
|
1003
|
+
"why": meta.get("why"),
|
|
1004
|
+
})
|
|
1005
|
+
return entries
|
|
1006
|
+
|
|
1007
|
+
path = [{
|
|
1008
|
+
"id": d["id"],
|
|
1009
|
+
"grade": d["grade"],
|
|
1010
|
+
"domain": d["domain"],
|
|
1011
|
+
"standard_text": d["standard_text"],
|
|
1012
|
+
"prerequisites_in_path": _prereq_entries(d["id"]),
|
|
1013
|
+
} for d in nodes]
|
|
1014
|
+
|
|
1015
|
+
result = {
|
|
1016
|
+
"target": tgt,
|
|
1017
|
+
"system": system,
|
|
1018
|
+
"edge_strength": "hard+soft" if include_soft else "hard_only",
|
|
1019
|
+
"path_length": len(path),
|
|
1020
|
+
"path": path,
|
|
1021
|
+
}
|
|
1022
|
+
if from_standard:
|
|
1023
|
+
result["from_standard"] = from_resolved or _expand_id(from_standard, system)
|
|
1024
|
+
result["from_standard_reachable"] = from_reachable
|
|
1025
|
+
# Notes, most-specific first.
|
|
1026
|
+
if from_standard and from_reachable and len(path) == 1:
|
|
1027
|
+
# from_standard prunes everything else away → learner is already at/past the target.
|
|
1028
|
+
result["note"] = "from_standard already reaches the target — no intermediate steps needed"
|
|
1029
|
+
elif len(path) == 1 and path[0]["id"] == tgt:
|
|
1030
|
+
result["note"] = ("no validated prerequisites found for this target"
|
|
1031
|
+
+ (" at hard strength — retry with include_soft=True" if not include_soft else ""))
|
|
1032
|
+
elif from_standard and from_resolved and not from_reachable:
|
|
1033
|
+
result["note"] = "from_standard is not a validated prerequisite of the target; returning the full prerequisite path"
|
|
1034
|
+
return json.dumps(result, indent=2)
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
# ── Tool 5: map_standard ──────────────────────────────────────────────────────
|
|
639
1038
|
|
|
640
1039
|
@mcp.tool()
|
|
641
1040
|
def map_standard(
|
|
@@ -670,11 +1069,19 @@ def map_standard(
|
|
|
670
1069
|
sid = _expand_id(standard_id, from_system)
|
|
671
1070
|
conn = _db()
|
|
672
1071
|
|
|
673
|
-
|
|
1072
|
+
# Same cluster-letter/case tolerance as lookup_standard, then pin sid to the
|
|
1073
|
+
# canonical ID so the crosswalk/embedding queries below hit the stored rows.
|
|
1074
|
+
canonical = _resolve_id(conn, sid, from_system)
|
|
1075
|
+
src = (
|
|
1076
|
+
conn.execute("SELECT * FROM standards WHERE id=?", (canonical,)).fetchone()
|
|
1077
|
+
if canonical
|
|
1078
|
+
else None
|
|
1079
|
+
)
|
|
674
1080
|
if not src:
|
|
675
1081
|
conn.close()
|
|
676
1082
|
return json.dumps({"error": "standard_not_found", "queried_id": sid})
|
|
677
1083
|
|
|
1084
|
+
sid = canonical
|
|
678
1085
|
src_dict = dict(src)
|
|
679
1086
|
|
|
680
1087
|
# ── 1. Precomputed crosswalk above threshold ───────────────────────────────
|
|
@@ -881,7 +1288,49 @@ def map_standard(
|
|
|
881
1288
|
return json.dumps(response, indent=2)
|
|
882
1289
|
|
|
883
1290
|
|
|
884
|
-
# ── Tool
|
|
1291
|
+
# ── Tool 6: list_systems ──────────────────────────────────────────────────────
|
|
1292
|
+
|
|
1293
|
+
@functools.lru_cache(maxsize=1)
|
|
1294
|
+
def _systems_snapshot():
|
|
1295
|
+
"""Per-process cache of system-level aggregates + corpus totals.
|
|
1296
|
+
|
|
1297
|
+
These come from full-table scans (GROUP BY over ~157k standards, a JOIN+GROUP
|
|
1298
|
+
over ~103k crosswalks, a COUNT over ~3.2M relationships) that are *static* for a
|
|
1299
|
+
server's lifetime — the DB is read-only while serving and the hosted process
|
|
1300
|
+
restarts on any DB refresh. Caching turns list_systems from ~250ms into ~0.1ms
|
|
1301
|
+
on repeat calls and removes it as a GIL bottleneck under concurrent load.
|
|
1302
|
+
Returns (rows, totals); rows carry a private `subject_set` for the subject filter.
|
|
1303
|
+
"""
|
|
1304
|
+
conn = _db()
|
|
1305
|
+
systems = conn.execute(
|
|
1306
|
+
"SELECT system, GROUP_CONCAT(DISTINCT subject) AS subjects, COUNT(id) AS standards "
|
|
1307
|
+
"FROM standards GROUP BY system ORDER BY system").fetchall()
|
|
1308
|
+
xwalk_counts = dict(conn.execute(
|
|
1309
|
+
"SELECT s.system, COUNT(*) FROM crosswalk_mappings cm "
|
|
1310
|
+
"INNER JOIN standards s ON s.id = cm.source_id GROUP BY s.system").fetchall())
|
|
1311
|
+
totals = {
|
|
1312
|
+
"systems": len(systems),
|
|
1313
|
+
"standards": conn.execute("SELECT COUNT(*) FROM standards").fetchone()[0],
|
|
1314
|
+
"embeddings": conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0],
|
|
1315
|
+
"crosswalk_mappings": conn.execute("SELECT COUNT(*) FROM crosswalk_mappings").fetchone()[0],
|
|
1316
|
+
"relationships": conn.execute("SELECT COUNT(*) FROM standard_relationships").fetchone()[0],
|
|
1317
|
+
}
|
|
1318
|
+
conn.close()
|
|
1319
|
+
|
|
1320
|
+
rows = []
|
|
1321
|
+
for r in systems:
|
|
1322
|
+
m = _meta(r["system"])
|
|
1323
|
+
rows.append({
|
|
1324
|
+
"system": r["system"],
|
|
1325
|
+
"subjects": r["subjects"],
|
|
1326
|
+
"subject_set": set((r["subjects"] or "").split(",")),
|
|
1327
|
+
"standards": r["standards"],
|
|
1328
|
+
"crosswalked": xwalk_counts.get(r["system"], 0),
|
|
1329
|
+
"country": m.get("country"),
|
|
1330
|
+
"region": m.get("region", "") or None,
|
|
1331
|
+
})
|
|
1332
|
+
return rows, totals
|
|
1333
|
+
|
|
885
1334
|
|
|
886
1335
|
@mcp.tool()
|
|
887
1336
|
def list_systems(
|
|
@@ -899,63 +1348,19 @@ def list_systems(
|
|
|
899
1348
|
|
|
900
1349
|
Returns: system code, subjects covered, standard count, crosswalk coverage, country, region.
|
|
901
1350
|
"""
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
# Build subject-filtered system list from the standards table
|
|
905
|
-
if subject:
|
|
906
|
-
subject_systems = {
|
|
907
|
-
r[0] for r in conn.execute(
|
|
908
|
-
"SELECT DISTINCT system FROM standards WHERE subject = ?", (subject,)
|
|
909
|
-
).fetchall()
|
|
910
|
-
}
|
|
911
|
-
else:
|
|
912
|
-
subject_systems = None
|
|
913
|
-
|
|
914
|
-
# Counts split into separate fast queries to avoid expensive 3-way JOIN.
|
|
915
|
-
systems = conn.execute(
|
|
916
|
-
"""SELECT system,
|
|
917
|
-
GROUP_CONCAT(DISTINCT subject) AS subjects,
|
|
918
|
-
COUNT(id) AS standards
|
|
919
|
-
FROM standards
|
|
920
|
-
GROUP BY system
|
|
921
|
-
ORDER BY system"""
|
|
922
|
-
).fetchall()
|
|
923
|
-
xwalk_counts = dict(conn.execute(
|
|
924
|
-
"SELECT s.system, COUNT(*) FROM crosswalk_mappings cm "
|
|
925
|
-
"INNER JOIN standards s ON s.id = cm.source_id GROUP BY s.system"
|
|
926
|
-
).fetchall())
|
|
927
|
-
|
|
928
|
-
total_std = conn.execute("SELECT COUNT(*) FROM standards").fetchone()[0]
|
|
929
|
-
total_emb = conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0]
|
|
930
|
-
total_xwalk = conn.execute("SELECT COUNT(*) FROM crosswalk_mappings").fetchone()[0]
|
|
931
|
-
total_rel = conn.execute("SELECT COUNT(*) FROM standard_relationships").fetchone()[0]
|
|
932
|
-
conn.close()
|
|
1351
|
+
all_rows, totals = _systems_snapshot()
|
|
933
1352
|
|
|
934
1353
|
system_rows = []
|
|
935
|
-
for r in
|
|
936
|
-
if
|
|
1354
|
+
for r in all_rows:
|
|
1355
|
+
if subject and subject not in r["subject_set"]:
|
|
937
1356
|
continue
|
|
938
|
-
|
|
939
|
-
sys_region = m.get("region", "")
|
|
940
|
-
if region and region.lower() not in sys_region.lower():
|
|
1357
|
+
if region and region.lower() not in (r["region"] or "").lower():
|
|
941
1358
|
continue
|
|
942
|
-
system_rows.append({
|
|
943
|
-
|
|
944
|
-
"subjects": r["subjects"],
|
|
945
|
-
"standards": r["standards"],
|
|
946
|
-
"crosswalked": xwalk_counts.get(r["system"], 0),
|
|
947
|
-
"country": m.get("country"),
|
|
948
|
-
"region": sys_region or None,
|
|
949
|
-
})
|
|
1359
|
+
system_rows.append({k: r[k] for k in
|
|
1360
|
+
("system", "subjects", "standards", "crosswalked", "country", "region")})
|
|
950
1361
|
|
|
951
1362
|
return json.dumps({
|
|
952
|
-
"totals":
|
|
953
|
-
"systems": len(systems),
|
|
954
|
-
"standards": total_std,
|
|
955
|
-
"embeddings": total_emb,
|
|
956
|
-
"crosswalk_mappings": total_xwalk,
|
|
957
|
-
"relationships": total_rel,
|
|
958
|
-
},
|
|
1363
|
+
"totals": totals,
|
|
959
1364
|
"filters_applied": {k: v for k, v in {"subject": subject, "region": region}.items() if v},
|
|
960
1365
|
"matched_systems": len(system_rows),
|
|
961
1366
|
"systems": system_rows,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: standardgraph
|
|
3
|
-
Version: 1.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 1.4.0
|
|
4
|
+
Summary: 172,000+ education standards (math, science, ELA, social studies, CS, arts) across 310 curriculum systems, cross-referenced via NLP — accessible as a Claude MCP server
|
|
5
5
|
Author: StandardGraph
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/swoopeagle/standardgraph
|
|
@@ -24,7 +24,7 @@ Requires-Dist: numpy>=1.26.0
|
|
|
24
24
|
|
|
25
25
|
<!-- mcp-name: io.github.swoopeagle/standardgraph -->
|
|
26
26
|
|
|
27
|
-
**
|
|
27
|
+
**154,000+ education standards across 300 curriculum systems, accessible as a Claude MCP server.**
|
|
28
28
|
|
|
29
29
|
Covers seven subjects — Mathematics, Science, ELA, Social Studies, Computer Science, Arts, and World Languages — across the US (CCSS + all 50 states), Canada, Australia, UK, Singapore, Japan, New Zealand, Ireland, Hong Kong, India, Ghana, South Africa, Rwanda, Cambridge International, IB MYP/DP, AP, and more. Standards are cross-referenced to subject hubs (CCSS for math, NGSS for science, etc.) via semantic similarity, with LLM quality scores on the strongest mappings.
|
|
30
30
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|