zam-core 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +832 -32
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.js +93 -16
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -552,7 +552,9 @@ CREATE TABLE IF NOT EXISTS tokens (
|
|
|
552
552
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
553
553
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
554
554
|
deprecated_at TEXT,
|
|
555
|
-
question TEXT
|
|
555
|
+
question TEXT,
|
|
556
|
+
provider TEXT,
|
|
557
|
+
topic_id TEXT
|
|
556
558
|
);
|
|
557
559
|
|
|
558
560
|
-- Prerequisite dependency graph: "to learn A, first know B"
|
|
@@ -992,6 +994,14 @@ async function runMigrations(db) {
|
|
|
992
994
|
PRIMARY KEY (token_id, source_id)
|
|
993
995
|
)
|
|
994
996
|
`);
|
|
997
|
+
if (tokenCols.length > 0) {
|
|
998
|
+
if (!tokenCols.some((c) => c.name === "provider")) {
|
|
999
|
+
await db.exec(`ALTER TABLE tokens ADD COLUMN provider TEXT`);
|
|
1000
|
+
}
|
|
1001
|
+
if (!tokenCols.some((c) => c.name === "topic_id")) {
|
|
1002
|
+
await db.exec(`ALTER TABLE tokens ADD COLUMN topic_id TEXT`);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
995
1005
|
}
|
|
996
1006
|
|
|
997
1007
|
// src/kernel/db/snapshot.ts
|
|
@@ -1485,8 +1495,8 @@ async function createToken(db, input8) {
|
|
|
1485
1495
|
throw new Error(`bloom_level must be between 1 and 5, got ${bloom}`);
|
|
1486
1496
|
}
|
|
1487
1497
|
await db.prepare(`
|
|
1488
|
-
INSERT INTO tokens (id, slug, concept, domain, bloom_level, context, symbiosis_mode, source_link, question, created_at, updated_at)
|
|
1489
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1498
|
+
INSERT INTO tokens (id, slug, concept, domain, bloom_level, context, symbiosis_mode, source_link, question, provider, topic_id, created_at, updated_at)
|
|
1499
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1490
1500
|
`).run(
|
|
1491
1501
|
id,
|
|
1492
1502
|
input8.slug,
|
|
@@ -1497,16 +1507,33 @@ async function createToken(db, input8) {
|
|
|
1497
1507
|
input8.symbiosis_mode ?? null,
|
|
1498
1508
|
input8.source_link ?? null,
|
|
1499
1509
|
input8.question ?? null,
|
|
1510
|
+
input8.provider ?? null,
|
|
1511
|
+
input8.topic_id ?? null,
|
|
1500
1512
|
now,
|
|
1501
1513
|
now
|
|
1502
1514
|
);
|
|
1503
1515
|
return await getTokenById(db, id);
|
|
1504
1516
|
}
|
|
1517
|
+
function parseTokenFallback(token) {
|
|
1518
|
+
if (token && !token.provider && token.source_link) {
|
|
1519
|
+
if (token.source_link.includes("lehrplanplus.bayern.de")) {
|
|
1520
|
+
token.provider = "lehrplanplus-bayern";
|
|
1521
|
+
const match = token.source_link.match(/#(.*)$/);
|
|
1522
|
+
if (match) {
|
|
1523
|
+
token.topic_id = match[1];
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1505
1528
|
async function getTokenBySlug(db, slug) {
|
|
1506
|
-
|
|
1529
|
+
const token = await db.prepare("SELECT * FROM tokens WHERE slug = ?").get(slug);
|
|
1530
|
+
parseTokenFallback(token);
|
|
1531
|
+
return token;
|
|
1507
1532
|
}
|
|
1508
1533
|
async function getTokenById(db, id) {
|
|
1509
|
-
|
|
1534
|
+
const token = await db.prepare("SELECT * FROM tokens WHERE id = ?").get(id);
|
|
1535
|
+
parseTokenFallback(token);
|
|
1536
|
+
return token;
|
|
1510
1537
|
}
|
|
1511
1538
|
async function updateToken(db, slug, updates) {
|
|
1512
1539
|
const token = await getTokenBySlug(db, slug);
|
|
@@ -1552,6 +1579,14 @@ async function updateToken(db, slug, updates) {
|
|
|
1552
1579
|
fields.push("question = ?");
|
|
1553
1580
|
values.push(updates.question);
|
|
1554
1581
|
}
|
|
1582
|
+
if (updates.provider !== void 0) {
|
|
1583
|
+
fields.push("provider = ?");
|
|
1584
|
+
values.push(updates.provider);
|
|
1585
|
+
}
|
|
1586
|
+
if (updates.topic_id !== void 0) {
|
|
1587
|
+
fields.push("topic_id = ?");
|
|
1588
|
+
values.push(updates.topic_id);
|
|
1589
|
+
}
|
|
1555
1590
|
if (fields.length === 0) {
|
|
1556
1591
|
throw new Error("updateToken called with no fields to update");
|
|
1557
1592
|
}
|
|
@@ -1677,14 +1712,20 @@ async function findTokens(db, query) {
|
|
|
1677
1712
|
return scored;
|
|
1678
1713
|
}
|
|
1679
1714
|
async function listTokens(db, options) {
|
|
1715
|
+
let tokens;
|
|
1680
1716
|
if (options?.domain) {
|
|
1681
|
-
|
|
1717
|
+
tokens = await db.prepare(
|
|
1682
1718
|
"SELECT * FROM tokens WHERE domain = ? AND deprecated_at IS NULL ORDER BY bloom_level, slug"
|
|
1683
1719
|
).all(options.domain);
|
|
1720
|
+
} else {
|
|
1721
|
+
tokens = await db.prepare(
|
|
1722
|
+
"SELECT * FROM tokens WHERE deprecated_at IS NULL ORDER BY bloom_level, domain, slug"
|
|
1723
|
+
).all();
|
|
1684
1724
|
}
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1725
|
+
for (const token of tokens) {
|
|
1726
|
+
parseTokenFallback(token);
|
|
1727
|
+
}
|
|
1728
|
+
return tokens;
|
|
1688
1729
|
}
|
|
1689
1730
|
function slugify(text) {
|
|
1690
1731
|
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
@@ -1736,6 +1777,8 @@ async function listPersonalCards(db, userId, options) {
|
|
|
1736
1777
|
t.question,
|
|
1737
1778
|
t.created_at AS createdAt,
|
|
1738
1779
|
t.updated_at AS updatedAt,
|
|
1780
|
+
t.provider,
|
|
1781
|
+
t.topic_id AS topicId,
|
|
1739
1782
|
c.id AS cardId,
|
|
1740
1783
|
c.state,
|
|
1741
1784
|
c.due_at AS dueAt,
|
|
@@ -1765,6 +1808,17 @@ async function listPersonalCards(db, userId, options) {
|
|
|
1765
1808
|
}
|
|
1766
1809
|
sql += " ORDER BY t.created_at DESC";
|
|
1767
1810
|
const rows = await db.prepare(sql).all(...values);
|
|
1811
|
+
for (const row of rows) {
|
|
1812
|
+
if (!row.provider && row.sourceLink) {
|
|
1813
|
+
if (row.sourceLink.includes("lehrplanplus.bayern.de")) {
|
|
1814
|
+
row.provider = "lehrplanplus-bayern";
|
|
1815
|
+
const match = row.sourceLink.match(/#(.*)$/);
|
|
1816
|
+
if (match) {
|
|
1817
|
+
row.topicId = match[1];
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1768
1822
|
return rows;
|
|
1769
1823
|
}
|
|
1770
1824
|
async function importCurriculumCards(db, userId, cards) {
|
|
@@ -1811,9 +1865,18 @@ async function importCurriculumCards(db, userId, cards) {
|
|
|
1811
1865
|
context: card.context || "",
|
|
1812
1866
|
symbiosis_mode: symbiosisMode,
|
|
1813
1867
|
source_link: card.source_link || null,
|
|
1814
|
-
question: card.question || null
|
|
1868
|
+
question: card.question || null,
|
|
1869
|
+
provider: card.provider || null,
|
|
1870
|
+
topic_id: card.topic_id || null
|
|
1815
1871
|
});
|
|
1816
1872
|
createdCount++;
|
|
1873
|
+
} else {
|
|
1874
|
+
if (!token.provider && card.provider) {
|
|
1875
|
+
await updateToken(tx, token.slug, {
|
|
1876
|
+
provider: card.provider,
|
|
1877
|
+
topic_id: card.topic_id || null
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1817
1880
|
}
|
|
1818
1881
|
const existingCard = await getCard(tx, token.id, userId);
|
|
1819
1882
|
if (!existingCard) {
|
|
@@ -2035,11 +2098,12 @@ async function confirmSourceImport(db, userId, sourceId, proposals) {
|
|
|
2035
2098
|
let createdCount = 0;
|
|
2036
2099
|
let linkedCount = 0;
|
|
2037
2100
|
await db.transaction(async (tx) => {
|
|
2038
|
-
const source = await tx.prepare("SELECT id FROM sources WHERE id = ?").get(sourceId);
|
|
2039
|
-
if (!source) {
|
|
2040
|
-
throw new Error(`Source not found: ${sourceId}`);
|
|
2041
|
-
}
|
|
2042
2101
|
for (const card of proposals) {
|
|
2102
|
+
const cardSourceId = card.source_id || sourceId;
|
|
2103
|
+
const source = await tx.prepare("SELECT id FROM sources WHERE id = ?").get(cardSourceId);
|
|
2104
|
+
if (!source) {
|
|
2105
|
+
throw new Error(`Source not found: ${cardSourceId}`);
|
|
2106
|
+
}
|
|
2043
2107
|
const baseText = card.question && card.question.trim().length > 0 ? card.question : card.concept;
|
|
2044
2108
|
const cleanDomain = slugify(card.domain || "");
|
|
2045
2109
|
const cleanBase = slugify(baseText);
|
|
@@ -2070,11 +2134,19 @@ async function confirmSourceImport(db, userId, sourceId, proposals) {
|
|
|
2070
2134
|
bloom_level: bloom,
|
|
2071
2135
|
context: card.excerpt || "",
|
|
2072
2136
|
symbiosis_mode: symbiosisMode,
|
|
2073
|
-
question: card.question || null
|
|
2137
|
+
question: card.question || null,
|
|
2138
|
+
provider: card.provider || null,
|
|
2139
|
+
topic_id: card.topic_id || null
|
|
2074
2140
|
});
|
|
2075
2141
|
createdCount++;
|
|
2076
2142
|
} else {
|
|
2077
2143
|
linkedCount++;
|
|
2144
|
+
if (!token.provider && card.provider) {
|
|
2145
|
+
await updateToken(tx, token.slug, {
|
|
2146
|
+
provider: card.provider,
|
|
2147
|
+
topic_id: card.topic_id || null
|
|
2148
|
+
});
|
|
2149
|
+
}
|
|
2078
2150
|
}
|
|
2079
2151
|
const existingCard = await getCard(tx, token.id, userId);
|
|
2080
2152
|
if (!existingCard) {
|
|
@@ -2086,7 +2158,12 @@ async function confirmSourceImport(db, userId, sourceId, proposals) {
|
|
|
2086
2158
|
ON CONFLICT(token_id, source_id) DO UPDATE SET
|
|
2087
2159
|
excerpt = excluded.excerpt,
|
|
2088
2160
|
page_number = excluded.page_number`
|
|
2089
|
-
).run(
|
|
2161
|
+
).run(
|
|
2162
|
+
token.id,
|
|
2163
|
+
cardSourceId,
|
|
2164
|
+
card.excerpt || "",
|
|
2165
|
+
card.page_number || null
|
|
2166
|
+
);
|
|
2090
2167
|
}
|
|
2091
2168
|
});
|
|
2092
2169
|
return { createdCount, linkedCount };
|
|
@@ -6069,6 +6146,14 @@ var BLOOM_VERBS2 = {
|
|
|
6069
6146
|
4: "Analyze",
|
|
6070
6147
|
5: "Synthesize"
|
|
6071
6148
|
};
|
|
6149
|
+
var LlmResponseTruncatedError = class extends Error {
|
|
6150
|
+
constructor(label) {
|
|
6151
|
+
super(
|
|
6152
|
+
`${label}: the model's response was cut off before producing usable output (finish_reason=length). The prompt likely left no room in the model's context window for a reply.`
|
|
6153
|
+
);
|
|
6154
|
+
this.name = "LlmResponseTruncatedError";
|
|
6155
|
+
}
|
|
6156
|
+
};
|
|
6072
6157
|
async function readChatContent(res, label) {
|
|
6073
6158
|
if (!res.ok) {
|
|
6074
6159
|
const errorText = await res.text().catch(() => "");
|
|
@@ -6077,11 +6162,16 @@ async function readChatContent(res, label) {
|
|
|
6077
6162
|
);
|
|
6078
6163
|
}
|
|
6079
6164
|
const data = await res.json();
|
|
6080
|
-
const
|
|
6081
|
-
|
|
6165
|
+
const choice = data.choices?.[0];
|
|
6166
|
+
const content = choice?.message?.content;
|
|
6167
|
+
const trimmed = content?.trim() ?? "";
|
|
6168
|
+
if (choice?.finish_reason === "length") {
|
|
6169
|
+
throw new LlmResponseTruncatedError(label);
|
|
6170
|
+
}
|
|
6171
|
+
if (!trimmed) {
|
|
6082
6172
|
throw new Error("Empty response from LLM");
|
|
6083
6173
|
}
|
|
6084
|
-
return
|
|
6174
|
+
return trimmed;
|
|
6085
6175
|
}
|
|
6086
6176
|
async function generateQuestionViaLLM(db, input8) {
|
|
6087
6177
|
const cfg = await getProviderForRole(db, "recall");
|
|
@@ -6248,15 +6338,42 @@ function parseGeneratedCardArray(responseText, label, limits) {
|
|
|
6248
6338
|
};
|
|
6249
6339
|
});
|
|
6250
6340
|
}
|
|
6251
|
-
|
|
6252
|
-
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
|
|
6341
|
+
var CURRICULUM_CHUNK_TEXT_CHARS = 3e3;
|
|
6342
|
+
function splitCurriculumText(text, maxChars) {
|
|
6343
|
+
if (text.length <= maxChars) return [text];
|
|
6344
|
+
const chunks = [];
|
|
6345
|
+
let start = 0;
|
|
6346
|
+
while (start < text.length) {
|
|
6347
|
+
let end = Math.min(start + maxChars, text.length);
|
|
6348
|
+
if (end < text.length) {
|
|
6349
|
+
const minimumBoundary = start + Math.floor(maxChars * 0.6);
|
|
6350
|
+
const sentenceWindow = text.slice(minimumBoundary, end);
|
|
6351
|
+
let sentenceBoundary = -1;
|
|
6352
|
+
for (const match of sentenceWindow.matchAll(/[.!?;:]\s+/g)) {
|
|
6353
|
+
sentenceBoundary = (match.index ?? 0) + match[0].length;
|
|
6354
|
+
}
|
|
6355
|
+
if (sentenceBoundary > 0) {
|
|
6356
|
+
end = minimumBoundary + sentenceBoundary;
|
|
6357
|
+
} else {
|
|
6358
|
+
const whitespaceBoundary = text.lastIndexOf(" ", end);
|
|
6359
|
+
if (whitespaceBoundary >= minimumBoundary) end = whitespaceBoundary + 1;
|
|
6360
|
+
}
|
|
6361
|
+
}
|
|
6362
|
+
chunks.push(text.slice(start, end));
|
|
6363
|
+
start = end;
|
|
6256
6364
|
}
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6365
|
+
return chunks;
|
|
6366
|
+
}
|
|
6367
|
+
function deduplicateGeneratedCards(cards) {
|
|
6368
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6369
|
+
return cards.filter((card) => {
|
|
6370
|
+
const key = `${card.question.trim().toLocaleLowerCase()}\0${card.concept.trim().toLocaleLowerCase()}`;
|
|
6371
|
+
if (seen.has(key)) return false;
|
|
6372
|
+
seen.add(key);
|
|
6373
|
+
return true;
|
|
6374
|
+
});
|
|
6375
|
+
}
|
|
6376
|
+
async function requestCurriculumCards(endpoint, locale, langName, curriculumText, targetCategory, sourceUrl) {
|
|
6260
6377
|
const systemPrompt = `You are ZAM, a highly precise agentic curriculum parser.
|
|
6261
6378
|
Your task is to analyze curriculum objectives, syllabus requirements, or textbook notes, and extract atomic facts, concepts, or skills as structured learning cards in ${langName}.
|
|
6262
6379
|
|
|
@@ -6285,7 +6402,7 @@ Guidelines:
|
|
|
6285
6402
|
curriculum section.
|
|
6286
6403
|
- Output ONLY a raw valid JSON array of objects. Do NOT wrap the JSON in markdown code blocks, HTML, or include any preamble, headers, or conversational filler.`;
|
|
6287
6404
|
const userPrompt = `Curriculum Text to Parse:
|
|
6288
|
-
${
|
|
6405
|
+
${curriculumText}
|
|
6289
6406
|
|
|
6290
6407
|
Target Category: ${targetCategory}
|
|
6291
6408
|
${sourceUrl ? `Source Reference Link: ${sourceUrl}` : ""}
|
|
@@ -6308,10 +6425,73 @@ JSON Array Output:`;
|
|
|
6308
6425
|
temperature: 0.1,
|
|
6309
6426
|
max_tokens: DEFAULT_LLM_MAX_TOKENS
|
|
6310
6427
|
}),
|
|
6311
|
-
locale
|
|
6428
|
+
locale
|
|
6312
6429
|
}
|
|
6313
6430
|
);
|
|
6314
|
-
|
|
6431
|
+
return readChatContent(res, "LLM curriculum import");
|
|
6432
|
+
}
|
|
6433
|
+
async function importCurriculumViaLLM(db, text, targetCategory, sourceUrl) {
|
|
6434
|
+
if (text.length > MAX_IMPORT_TEXT_CHARS) {
|
|
6435
|
+
throw new Error(
|
|
6436
|
+
`Curriculum text exceeds the ${MAX_IMPORT_TEXT_CHARS.toLocaleString()} character limit`
|
|
6437
|
+
);
|
|
6438
|
+
}
|
|
6439
|
+
const cfg = await getProviderForRole(db, "text");
|
|
6440
|
+
const endpoint = await resolveUsableTextEndpoint(db);
|
|
6441
|
+
const langName = LANGUAGE_NAMES[cfg.locale] || "English";
|
|
6442
|
+
let responseText;
|
|
6443
|
+
try {
|
|
6444
|
+
responseText = await requestCurriculumCards(
|
|
6445
|
+
endpoint,
|
|
6446
|
+
cfg.locale,
|
|
6447
|
+
langName,
|
|
6448
|
+
text,
|
|
6449
|
+
targetCategory,
|
|
6450
|
+
sourceUrl
|
|
6451
|
+
);
|
|
6452
|
+
} catch (err) {
|
|
6453
|
+
if (!(err instanceof LlmResponseTruncatedError) || text.length <= CURRICULUM_CHUNK_TEXT_CHARS) {
|
|
6454
|
+
throw err;
|
|
6455
|
+
}
|
|
6456
|
+
const chunks = splitCurriculumText(text, CURRICULUM_CHUNK_TEXT_CHARS);
|
|
6457
|
+
const cards = [];
|
|
6458
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
6459
|
+
let chunkResponse;
|
|
6460
|
+
try {
|
|
6461
|
+
chunkResponse = await requestCurriculumCards(
|
|
6462
|
+
endpoint,
|
|
6463
|
+
cfg.locale,
|
|
6464
|
+
langName,
|
|
6465
|
+
chunk,
|
|
6466
|
+
targetCategory,
|
|
6467
|
+
sourceUrl
|
|
6468
|
+
);
|
|
6469
|
+
} catch (chunkError) {
|
|
6470
|
+
if (chunkError instanceof LlmResponseTruncatedError) {
|
|
6471
|
+
throw new Error(
|
|
6472
|
+
`Curriculum chunk ${index + 1} of ${chunks.length} is still too large for the configured model's context window. Configure a model with a larger context window, or import a smaller source.`
|
|
6473
|
+
);
|
|
6474
|
+
}
|
|
6475
|
+
throw chunkError;
|
|
6476
|
+
}
|
|
6477
|
+
cards.push(
|
|
6478
|
+
...parseGeneratedCardArray(chunkResponse, "curriculum import", {
|
|
6479
|
+
min: 0,
|
|
6480
|
+
max: 200
|
|
6481
|
+
})
|
|
6482
|
+
);
|
|
6483
|
+
}
|
|
6484
|
+
const deduplicated = deduplicateGeneratedCards(cards);
|
|
6485
|
+
if (deduplicated.length > 200) {
|
|
6486
|
+
throw new Error(
|
|
6487
|
+
`Invalid curriculum import response: expected 0-200 cards, got ${deduplicated.length}`
|
|
6488
|
+
);
|
|
6489
|
+
}
|
|
6490
|
+
return deduplicated.map((card) => ({
|
|
6491
|
+
...card,
|
|
6492
|
+
source_link: sourceUrl || null
|
|
6493
|
+
}));
|
|
6494
|
+
}
|
|
6315
6495
|
return parseGeneratedCardArray(responseText, "curriculum import", {
|
|
6316
6496
|
min: 0,
|
|
6317
6497
|
max: 200
|
|
@@ -7269,6 +7449,412 @@ async function readImageOCR(db, imagePath) {
|
|
|
7269
7449
|
return extractTextFromScanViaLLM(db, imagePath);
|
|
7270
7450
|
}
|
|
7271
7451
|
|
|
7452
|
+
// src/cli/curriculum/breadcrumb.ts
|
|
7453
|
+
var LAST_SELECTION_KEY = "curriculum.lastSelection";
|
|
7454
|
+
async function getLastCurriculumSelection(db) {
|
|
7455
|
+
const raw = await getSetting(db, LAST_SELECTION_KEY);
|
|
7456
|
+
if (!raw) return void 0;
|
|
7457
|
+
try {
|
|
7458
|
+
return JSON.parse(raw);
|
|
7459
|
+
} catch {
|
|
7460
|
+
return void 0;
|
|
7461
|
+
}
|
|
7462
|
+
}
|
|
7463
|
+
async function setLastCurriculumSelection(db, breadcrumb) {
|
|
7464
|
+
await setSetting(db, LAST_SELECTION_KEY, JSON.stringify(breadcrumb));
|
|
7465
|
+
}
|
|
7466
|
+
|
|
7467
|
+
// src/cli/curriculum/providers/bildungsplan-bw/manifest.ts
|
|
7468
|
+
var BILDUNGSPLAN_BW_MANIFEST = {
|
|
7469
|
+
schoolYear: "2026/2027",
|
|
7470
|
+
capturedOn: "2026-07-02",
|
|
7471
|
+
sourceRevision: "Bildungsplan Baden-W\xFCrttemberg Gymnasium 2016",
|
|
7472
|
+
schoolTypes: [
|
|
7473
|
+
{ id: "gymnasium", label: "Gymnasium" },
|
|
7474
|
+
{ id: "realschule", label: "Realschule" }
|
|
7475
|
+
],
|
|
7476
|
+
grades: {
|
|
7477
|
+
gymnasium: ["9", "10", "11", "12"]
|
|
7478
|
+
},
|
|
7479
|
+
subjects: {
|
|
7480
|
+
gymnasium: [
|
|
7481
|
+
{ id: "mathematik", label: "Mathematik" },
|
|
7482
|
+
{ id: "physik", label: "Physik" },
|
|
7483
|
+
{ id: "englisch", label: "Englisch" }
|
|
7484
|
+
]
|
|
7485
|
+
},
|
|
7486
|
+
tracks: {},
|
|
7487
|
+
topics: {
|
|
7488
|
+
"gymnasium|10|mathematik": [
|
|
7489
|
+
{ id: "leitidee-zahl", label: "Leitidee Zahl - Variable - Operation" },
|
|
7490
|
+
{ id: "leitidee-messung", label: "Leitidee Messen" },
|
|
7491
|
+
{ id: "leitidee-raum", label: "Leitidee Raum und Form" },
|
|
7492
|
+
{ id: "leitidee-funktion", label: "Leitidee Funktionaler Zusammenhang" },
|
|
7493
|
+
{ id: "leitidee-daten", label: "Leitidee Daten und Zufall" }
|
|
7494
|
+
]
|
|
7495
|
+
},
|
|
7496
|
+
contentUrls: {
|
|
7497
|
+
"gymnasium|10|mathematik": "http://www.bildungsplaene-bw.de/,Lde/LS/BP2016BW/ALLG/GYM/M/bp/klasse-10"
|
|
7498
|
+
}
|
|
7499
|
+
};
|
|
7500
|
+
|
|
7501
|
+
// src/cli/curriculum/providers/bildungsplan-bw/index.ts
|
|
7502
|
+
var bildungsplanBwProvider = {
|
|
7503
|
+
id: "bildungsplan-bw",
|
|
7504
|
+
country: "DE",
|
|
7505
|
+
countryLabel: "Deutschland",
|
|
7506
|
+
region: "BW",
|
|
7507
|
+
regionLabel: "Baden-W\xFCrttemberg",
|
|
7508
|
+
label: "Bildungsplan (Baden-W\xFCrttemberg)",
|
|
7509
|
+
listSchoolTypes() {
|
|
7510
|
+
return BILDUNGSPLAN_BW_MANIFEST.schoolTypes;
|
|
7511
|
+
},
|
|
7512
|
+
listGrades(schoolType) {
|
|
7513
|
+
return (BILDUNGSPLAN_BW_MANIFEST.grades[schoolType] || []).map((id) => ({
|
|
7514
|
+
id,
|
|
7515
|
+
label: `Klasse ${id}`
|
|
7516
|
+
}));
|
|
7517
|
+
},
|
|
7518
|
+
listSubjects(schoolType, _grade) {
|
|
7519
|
+
return BILDUNGSPLAN_BW_MANIFEST.subjects[schoolType] || [];
|
|
7520
|
+
},
|
|
7521
|
+
listTracks(schoolType, grade, subject) {
|
|
7522
|
+
const key = `${schoolType}|${grade}|${subject}`;
|
|
7523
|
+
return BILDUNGSPLAN_BW_MANIFEST.tracks[key] || [];
|
|
7524
|
+
},
|
|
7525
|
+
listTopics(selection) {
|
|
7526
|
+
const key = selection.track ? `${selection.schoolType}|${selection.grade}|${selection.subject}|${selection.track}` : `${selection.schoolType}|${selection.grade}|${selection.subject}`;
|
|
7527
|
+
const list = BILDUNGSPLAN_BW_MANIFEST.topics[key] || [];
|
|
7528
|
+
return list.map((t2) => ({
|
|
7529
|
+
...t2,
|
|
7530
|
+
sourceRef: key
|
|
7531
|
+
}));
|
|
7532
|
+
},
|
|
7533
|
+
resolveTopic(topic) {
|
|
7534
|
+
const uri = BILDUNGSPLAN_BW_MANIFEST.contentUrls[topic.sourceRef];
|
|
7535
|
+
if (!uri) {
|
|
7536
|
+
throw new Error(
|
|
7537
|
+
`Bildungsplan Baden-W\xFCrttemberg: no resolvable source URL for topic "${topic.id}" (${topic.sourceRef}).`
|
|
7538
|
+
);
|
|
7539
|
+
}
|
|
7540
|
+
return {
|
|
7541
|
+
provider: "bildungsplan-bw",
|
|
7542
|
+
topicId: `${topic.sourceRef}#${topic.id}`,
|
|
7543
|
+
uri
|
|
7544
|
+
};
|
|
7545
|
+
},
|
|
7546
|
+
extractTopics(html, topicIds) {
|
|
7547
|
+
const chunks = html.split('<div class="bp-topic-block" id="bp_topic_');
|
|
7548
|
+
const sections = [];
|
|
7549
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
7550
|
+
const chunk = chunks[i];
|
|
7551
|
+
const quoteIdx = chunk.indexOf('"');
|
|
7552
|
+
if (quoteIdx === -1) continue;
|
|
7553
|
+
const id = chunk.slice(0, quoteIdx);
|
|
7554
|
+
const contentHtml = chunk.slice(quoteIdx + 1);
|
|
7555
|
+
const headerMatch = contentHtml.match(
|
|
7556
|
+
/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i
|
|
7557
|
+
);
|
|
7558
|
+
const headerText = headerMatch ? cleanHtmlText(headerMatch[1]).trim() : "";
|
|
7559
|
+
sections.push({
|
|
7560
|
+
id,
|
|
7561
|
+
headerText,
|
|
7562
|
+
contentHtml
|
|
7563
|
+
});
|
|
7564
|
+
}
|
|
7565
|
+
const results = {};
|
|
7566
|
+
for (const topicId of topicIds) {
|
|
7567
|
+
const hashIdx = topicId.indexOf("#");
|
|
7568
|
+
if (hashIdx === -1) continue;
|
|
7569
|
+
const key = topicId.substring(0, hashIdx);
|
|
7570
|
+
const shortId = topicId.substring(hashIdx + 1);
|
|
7571
|
+
const list = BILDUNGSPLAN_BW_MANIFEST.topics[key] ?? [];
|
|
7572
|
+
const match = list.find((t2) => t2.id === shortId);
|
|
7573
|
+
if (!match) continue;
|
|
7574
|
+
const label = match.label;
|
|
7575
|
+
const normalizedLabel = normalizeForComparison(label);
|
|
7576
|
+
const sectionIndex = sections.findIndex((s) => {
|
|
7577
|
+
const normalizedHeader = normalizeForComparison(s.headerText);
|
|
7578
|
+
return normalizedHeader.includes(normalizedLabel) || s.id === shortId;
|
|
7579
|
+
});
|
|
7580
|
+
if (sectionIndex === -1) {
|
|
7581
|
+
continue;
|
|
7582
|
+
}
|
|
7583
|
+
results[topicId] = cleanHtmlText(sections[sectionIndex].contentHtml);
|
|
7584
|
+
}
|
|
7585
|
+
return results;
|
|
7586
|
+
}
|
|
7587
|
+
};
|
|
7588
|
+
function cleanHtmlText(html) {
|
|
7589
|
+
let text = html.replace(
|
|
7590
|
+
/<(head|script|style|svg)[^>]*>[\s\S]*?<\/\1>/gi,
|
|
7591
|
+
" "
|
|
7592
|
+
);
|
|
7593
|
+
text = text.replace(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/gi, "\n\n$1\n\n");
|
|
7594
|
+
text = text.replace(/<li[^>]*>/gi, "\n- ");
|
|
7595
|
+
text = text.replace(/<p[^>]*>/gi, "\n");
|
|
7596
|
+
text = text.replace(/<br\s*\/?>/gi, "\n");
|
|
7597
|
+
text = text.replace(/<[^>]+>/g, " ");
|
|
7598
|
+
text = text.replace(/ /g, " ").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").replace(/–/g, "\u2013").replace(/—/g, "\u2014").replace(/·/g, "\xB7");
|
|
7599
|
+
return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
|
|
7600
|
+
}
|
|
7601
|
+
function normalizeForComparison(str) {
|
|
7602
|
+
return str.toLowerCase().replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
|
|
7603
|
+
}
|
|
7604
|
+
|
|
7605
|
+
// src/cli/curriculum/providers/lehrplanplus-bayern/manifest.ts
|
|
7606
|
+
var LEHRPLANPLUS_BAYERN_MANIFEST = {
|
|
7607
|
+
schoolYear: "2026/2027",
|
|
7608
|
+
capturedOn: "2026-07-02",
|
|
7609
|
+
sourceRevision: "LehrplanPLUS Realschule \u2013 Oktober 2023",
|
|
7610
|
+
schoolTypes: [
|
|
7611
|
+
{ id: "grundschule", label: "Grundschule" },
|
|
7612
|
+
{ id: "mittelschule", label: "Mittelschule" },
|
|
7613
|
+
{ id: "foerderschule", label: "F\xF6rderschule" },
|
|
7614
|
+
{ id: "realschule", label: "Realschule" },
|
|
7615
|
+
{ id: "gymnasium", label: "Gymnasium" },
|
|
7616
|
+
{ id: "wirtschaftsschule", label: "Wirtschaftsschule" },
|
|
7617
|
+
{ id: "fos", label: "Fachoberschule" },
|
|
7618
|
+
{ id: "bos", label: "Berufsoberschule" }
|
|
7619
|
+
],
|
|
7620
|
+
grades: {
|
|
7621
|
+
realschule: ["5", "6", "7", "8", "9", "10"]
|
|
7622
|
+
},
|
|
7623
|
+
subjects: {
|
|
7624
|
+
realschule: [
|
|
7625
|
+
{
|
|
7626
|
+
id: "bwl-rechnungswesen",
|
|
7627
|
+
label: "Betriebswirtschaftslehre / Rechnungswesen"
|
|
7628
|
+
},
|
|
7629
|
+
{ id: "biologie", label: "Biologie" },
|
|
7630
|
+
{ id: "chemie", label: "Chemie" },
|
|
7631
|
+
{ id: "deutsch", label: "Deutsch" },
|
|
7632
|
+
{ id: "englisch", label: "Englisch" },
|
|
7633
|
+
{ id: "ernaehrung_und_gesundheit", label: "Ern\xE4hrung und Gesundheit" },
|
|
7634
|
+
{ id: "ethik", label: "Ethik" },
|
|
7635
|
+
{
|
|
7636
|
+
id: "evangelische-religionslehre",
|
|
7637
|
+
label: "Evangelische Religionslehre"
|
|
7638
|
+
},
|
|
7639
|
+
{ id: "franzoesisch", label: "Franz\xF6sisch" },
|
|
7640
|
+
{ id: "geographie", label: "Geographie" },
|
|
7641
|
+
{ id: "geschichte", label: "Geschichte" },
|
|
7642
|
+
{ id: "it", label: "Informationstechnologie" },
|
|
7643
|
+
{ id: "iu", label: "Islamischer Unterricht" },
|
|
7644
|
+
{ id: "ir", label: "Israelitische Religionslehre" },
|
|
7645
|
+
{
|
|
7646
|
+
id: "katholische-religionslehre",
|
|
7647
|
+
label: "Katholische Religionslehre"
|
|
7648
|
+
},
|
|
7649
|
+
{ id: "kunst", label: "Kunst" },
|
|
7650
|
+
{ id: "mathematik", label: "Mathematik" },
|
|
7651
|
+
{ id: "musik", label: "Musik" },
|
|
7652
|
+
{ id: "or", label: "Orthodoxe Religionslehre" },
|
|
7653
|
+
{ id: "physik", label: "Physik" },
|
|
7654
|
+
{ id: "pug", label: "Politik und Gesellschaft" },
|
|
7655
|
+
{ id: "soziallehre", label: "Soziallehre" },
|
|
7656
|
+
{ id: "sozialwesen", label: "Sozialwesen" },
|
|
7657
|
+
{ id: "spanisch", label: "Spanisch" },
|
|
7658
|
+
{ id: "sport", label: "Sport" },
|
|
7659
|
+
{ id: "textiles-gestalten", label: "Textiles Gestalten" },
|
|
7660
|
+
{ id: "werken", label: "Werken" },
|
|
7661
|
+
{ id: "wirtschaft-und-recht", label: "Wirtschaft und Recht" }
|
|
7662
|
+
]
|
|
7663
|
+
},
|
|
7664
|
+
tracks: {
|
|
7665
|
+
"realschule|9|mathematik": [
|
|
7666
|
+
{ id: "wpfg1", label: "Mathematik 9 (I)" },
|
|
7667
|
+
{ id: "wpfg2-3", label: "Mathematik 9 (II/III)" }
|
|
7668
|
+
]
|
|
7669
|
+
},
|
|
7670
|
+
topics: {
|
|
7671
|
+
"realschule|9|mathematik|wpfg1": [
|
|
7672
|
+
{ id: "lb1", label: "Reelle Zahlen", hours: 10 },
|
|
7673
|
+
{ id: "lb2", label: "Zentrische Streckung", hours: 17 },
|
|
7674
|
+
{ id: "lb3", label: "Rechtwinklige Dreiecke", hours: 20 },
|
|
7675
|
+
{ id: "lb4", label: "Kreis", hours: 10 },
|
|
7676
|
+
{ id: "lb5", label: "Raumgeometrie", hours: 20 },
|
|
7677
|
+
{ id: "lb6", label: "Systeme linearer Gleichungen", hours: 12 },
|
|
7678
|
+
{
|
|
7679
|
+
id: "lb7",
|
|
7680
|
+
label: "Quadratische Funktionen und quadratische Gleichungen",
|
|
7681
|
+
hours: 42
|
|
7682
|
+
},
|
|
7683
|
+
{ id: "lb8", label: "Daten und Zufall", hours: 9 }
|
|
7684
|
+
],
|
|
7685
|
+
"realschule|9|mathematik|wpfg2-3": [
|
|
7686
|
+
{ id: "lb1", label: "Reelle Zahlen", hours: 7 },
|
|
7687
|
+
{ id: "lb2", label: "Zentrische Streckung", hours: 13 },
|
|
7688
|
+
{ id: "lb3", label: "Rechtwinklige Dreiecke", hours: 20 },
|
|
7689
|
+
{ id: "lb4", label: "Kreis", hours: 10 },
|
|
7690
|
+
{ id: "lb5", label: "Lineare Funktionen", hours: 15 },
|
|
7691
|
+
{ id: "lb6", label: "Systeme linearer Gleichungen", hours: 10 },
|
|
7692
|
+
{ id: "lb7", label: "Daten und Zufall", hours: 9 }
|
|
7693
|
+
],
|
|
7694
|
+
"realschule|9|deutsch": [
|
|
7695
|
+
{ id: "lb1", label: "Sprechen und Zuh\xF6ren" },
|
|
7696
|
+
{ id: "lb2", label: "Lesen \u2013 mit Texten und weiteren Medien umgehen" },
|
|
7697
|
+
{ id: "lb3", label: "Schreiben" },
|
|
7698
|
+
{
|
|
7699
|
+
id: "lb4",
|
|
7700
|
+
label: "Sprachgebrauch und Sprache untersuchen und reflektieren"
|
|
7701
|
+
}
|
|
7702
|
+
],
|
|
7703
|
+
"realschule|9|englisch": [
|
|
7704
|
+
{ id: "lb1", label: "Kommunikative Kompetenzen" },
|
|
7705
|
+
{ id: "lb2", label: "Interkulturelle Kompetenzen" },
|
|
7706
|
+
{ id: "lb3", label: "Text- und Medienkompetenzen" },
|
|
7707
|
+
{ id: "lb4", label: "Methodische Kompetenzen" },
|
|
7708
|
+
{ id: "lb5", label: "Themengebiete" }
|
|
7709
|
+
]
|
|
7710
|
+
},
|
|
7711
|
+
contentUrls: {
|
|
7712
|
+
"realschule|9|mathematik|wpfg1": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/9/fach/mathematik/inhalt/fachlehrplaene?w_schulart=realschule&wt_1=schulart&w_fach=mathematik&wt_2=fach&w_jgs=9&wt_3=jgs&w_auspraegung=wpfg1",
|
|
7713
|
+
"realschule|9|mathematik|wpfg2-3": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/9/fach/mathematik/inhalt/fachlehrplaene?w_schulart=realschule&wt_1=schulart&w_fach=mathematik&wt_2=fach&w_jgs=9&wt_3=jgs&w_auspraegung=wpfg2-3",
|
|
7714
|
+
"realschule|9|deutsch": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/9/fach/deutsch/inhalt/fachlehrplaene",
|
|
7715
|
+
"realschule|9|englisch": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/9/fach/englisch/inhalt/fachlehrplaene"
|
|
7716
|
+
}
|
|
7717
|
+
};
|
|
7718
|
+
|
|
7719
|
+
// src/cli/curriculum/providers/lehrplanplus-bayern/index.ts
|
|
7720
|
+
function levelKey(schoolType, grade, subject, track) {
|
|
7721
|
+
return track ? `${schoolType}|${grade}|${subject}|${track}` : `${schoolType}|${grade}|${subject}`;
|
|
7722
|
+
}
|
|
7723
|
+
var lehrplanplusBayernProvider = {
|
|
7724
|
+
id: "lehrplanplus-bayern",
|
|
7725
|
+
country: "DE",
|
|
7726
|
+
countryLabel: "Deutschland",
|
|
7727
|
+
region: "BY",
|
|
7728
|
+
regionLabel: "Bayern",
|
|
7729
|
+
label: "LehrplanPLUS (Bayern)",
|
|
7730
|
+
listSchoolTypes() {
|
|
7731
|
+
return LEHRPLANPLUS_BAYERN_MANIFEST.schoolTypes;
|
|
7732
|
+
},
|
|
7733
|
+
listGrades(schoolType) {
|
|
7734
|
+
return (LEHRPLANPLUS_BAYERN_MANIFEST.grades[schoolType] ?? []).map((grade) => ({
|
|
7735
|
+
id: grade,
|
|
7736
|
+
label: grade
|
|
7737
|
+
}));
|
|
7738
|
+
},
|
|
7739
|
+
listSubjects(schoolType, _grade) {
|
|
7740
|
+
return LEHRPLANPLUS_BAYERN_MANIFEST.subjects[schoolType] ?? [];
|
|
7741
|
+
},
|
|
7742
|
+
listTracks(schoolType, grade, subject) {
|
|
7743
|
+
return LEHRPLANPLUS_BAYERN_MANIFEST.tracks[levelKey(schoolType, grade, subject)] ?? [];
|
|
7744
|
+
},
|
|
7745
|
+
listTopics(selection) {
|
|
7746
|
+
const { schoolType, grade, subject, track } = selection;
|
|
7747
|
+
if (!schoolType || !grade || !subject) return [];
|
|
7748
|
+
const key = levelKey(schoolType, grade, subject, track);
|
|
7749
|
+
return (LEHRPLANPLUS_BAYERN_MANIFEST.topics[key] ?? []).map((topic) => ({
|
|
7750
|
+
...topic,
|
|
7751
|
+
sourceRef: key
|
|
7752
|
+
}));
|
|
7753
|
+
},
|
|
7754
|
+
resolveTopic(topic) {
|
|
7755
|
+
const uri = LEHRPLANPLUS_BAYERN_MANIFEST.contentUrls[topic.sourceRef];
|
|
7756
|
+
if (!uri) {
|
|
7757
|
+
throw new Error(
|
|
7758
|
+
`LehrplanPLUS Bayern: no resolvable source URL for topic "${topic.id}" (${topic.sourceRef}). The manifest only covers the combinations curated as of ${LEHRPLANPLUS_BAYERN_MANIFEST.capturedOn}.`
|
|
7759
|
+
);
|
|
7760
|
+
}
|
|
7761
|
+
return {
|
|
7762
|
+
provider: "lehrplanplus-bayern",
|
|
7763
|
+
topicId: `${topic.sourceRef}#${topic.id}`,
|
|
7764
|
+
uri
|
|
7765
|
+
};
|
|
7766
|
+
},
|
|
7767
|
+
extractTopics(html, topicIds) {
|
|
7768
|
+
const chunks = html.split('<div id="thema_');
|
|
7769
|
+
const sections = [];
|
|
7770
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
7771
|
+
const chunk = chunks[i];
|
|
7772
|
+
const quoteIdx = chunk.indexOf('"');
|
|
7773
|
+
if (quoteIdx === -1) continue;
|
|
7774
|
+
const id = chunk.slice(0, quoteIdx);
|
|
7775
|
+
const classStart = chunk.indexOf('class="');
|
|
7776
|
+
if (classStart === -1) continue;
|
|
7777
|
+
const classEnd = chunk.indexOf('"', classStart + 7);
|
|
7778
|
+
const classContent = chunk.slice(classStart + 7, classEnd);
|
|
7779
|
+
const lvlMatch = classContent.match(/headline_lvl(\d+)/);
|
|
7780
|
+
if (!lvlMatch) continue;
|
|
7781
|
+
const level = parseInt(lvlMatch[1], 10);
|
|
7782
|
+
const contentHtml = chunk.slice(classEnd + 1);
|
|
7783
|
+
const headerMatch = contentHtml.match(
|
|
7784
|
+
/<a[^>]*class="paragraph_toggle"[^>]*>([\s\S]*?)<\/a>/i
|
|
7785
|
+
);
|
|
7786
|
+
const headerText = headerMatch ? cleanHtmlText2(headerMatch[1]).trim() : "";
|
|
7787
|
+
sections.push({
|
|
7788
|
+
id,
|
|
7789
|
+
level,
|
|
7790
|
+
headerText,
|
|
7791
|
+
contentHtml
|
|
7792
|
+
});
|
|
7793
|
+
}
|
|
7794
|
+
const results = {};
|
|
7795
|
+
for (const topicId of topicIds) {
|
|
7796
|
+
let label = "";
|
|
7797
|
+
for (const key of Object.keys(LEHRPLANPLUS_BAYERN_MANIFEST.topics)) {
|
|
7798
|
+
const list = LEHRPLANPLUS_BAYERN_MANIFEST.topics[key];
|
|
7799
|
+
const match = list.find(
|
|
7800
|
+
(t2) => t2.id === topicId || `${key}#${t2.id}` === topicId
|
|
7801
|
+
);
|
|
7802
|
+
if (match) {
|
|
7803
|
+
label = match.label;
|
|
7804
|
+
break;
|
|
7805
|
+
}
|
|
7806
|
+
}
|
|
7807
|
+
if (!label) {
|
|
7808
|
+
continue;
|
|
7809
|
+
}
|
|
7810
|
+
const normalizedLabel = normalizeForComparison2(label);
|
|
7811
|
+
const lvl1Index = sections.findIndex((s) => {
|
|
7812
|
+
if (s.level !== 1) return false;
|
|
7813
|
+
const normalizedHeader = normalizeForComparison2(s.headerText);
|
|
7814
|
+
return normalizedHeader.includes(normalizedLabel);
|
|
7815
|
+
});
|
|
7816
|
+
if (lvl1Index === -1) {
|
|
7817
|
+
continue;
|
|
7818
|
+
}
|
|
7819
|
+
const collectedSections = [sections[lvl1Index]];
|
|
7820
|
+
for (let i = lvl1Index + 1; i < sections.length; i++) {
|
|
7821
|
+
if (sections[i].level === 1) {
|
|
7822
|
+
break;
|
|
7823
|
+
}
|
|
7824
|
+
collectedSections.push(sections[i]);
|
|
7825
|
+
}
|
|
7826
|
+
const fullHtml = collectedSections.map((s) => s.contentHtml).join("\n");
|
|
7827
|
+
results[topicId] = cleanHtmlText2(fullHtml);
|
|
7828
|
+
}
|
|
7829
|
+
return results;
|
|
7830
|
+
}
|
|
7831
|
+
};
|
|
7832
|
+
function cleanHtmlText2(html) {
|
|
7833
|
+
let text = html.replace(
|
|
7834
|
+
/<(head|script|style|svg)[^>]*>[\s\S]*?<\/\1>/gi,
|
|
7835
|
+
" "
|
|
7836
|
+
);
|
|
7837
|
+
text = text.replace(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/gi, "\n\n$1\n\n");
|
|
7838
|
+
text = text.replace(/<li[^>]*>/gi, "\n- ");
|
|
7839
|
+
text = text.replace(/<p[^>]*>/gi, "\n");
|
|
7840
|
+
text = text.replace(/<br\s*\/?>/gi, "\n");
|
|
7841
|
+
text = text.replace(/<[^>]+>/g, " ");
|
|
7842
|
+
text = text.replace(/ /g, " ").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").replace(/–/g, "\u2013").replace(/—/g, "\u2014").replace(/·/g, "\xB7");
|
|
7843
|
+
return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
|
|
7844
|
+
}
|
|
7845
|
+
function normalizeForComparison2(str) {
|
|
7846
|
+
return str.toLowerCase().replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
|
|
7847
|
+
}
|
|
7848
|
+
|
|
7849
|
+
// src/cli/curriculum/registry.ts
|
|
7850
|
+
var CURRICULUM_PROVIDERS = [
|
|
7851
|
+
lehrplanplusBayernProvider,
|
|
7852
|
+
bildungsplanBwProvider
|
|
7853
|
+
];
|
|
7854
|
+
function getCurriculumProvider(id) {
|
|
7855
|
+
return CURRICULUM_PROVIDERS.find((provider) => provider.id === id);
|
|
7856
|
+
}
|
|
7857
|
+
|
|
7272
7858
|
// src/cli/llm/vision.ts
|
|
7273
7859
|
import { randomBytes } from "crypto";
|
|
7274
7860
|
import { readFileSync as readFileSync10 } from "fs";
|
|
@@ -10555,8 +11141,22 @@ bridgeCommand.command("personal-card-confirm-foundations").description("Save con
|
|
|
10555
11141
|
});
|
|
10556
11142
|
bridgeCommand.command("personal-source-import").description(
|
|
10557
11143
|
"Fetch and clean plain text from local file, web link, or vision scan (JSON)"
|
|
10558
|
-
).requiredOption("--type <file|web|scan>", "Source type").requiredOption("--uri <uri>", "Source path or URL").action(async (opts) => {
|
|
11144
|
+
).requiredOption("--type <file|web|scan>", "Source type").requiredOption("--uri <uri>", "Source path or URL").option("--refresh", "Re-fetch an already cached web source").action(async (opts) => {
|
|
10559
11145
|
await withDb2(async (db) => {
|
|
11146
|
+
if (opts.type === "web" && !opts.refresh) {
|
|
11147
|
+
const cached = await db.prepare(
|
|
11148
|
+
"SELECT id, content FROM sources WHERE uri = ? AND type = 'web'"
|
|
11149
|
+
).get(opts.uri);
|
|
11150
|
+
if (cached?.content) {
|
|
11151
|
+
jsonOut2({
|
|
11152
|
+
success: true,
|
|
11153
|
+
sourceId: cached.id,
|
|
11154
|
+
content: cached.content,
|
|
11155
|
+
cached: true
|
|
11156
|
+
});
|
|
11157
|
+
return;
|
|
11158
|
+
}
|
|
11159
|
+
}
|
|
10560
11160
|
let content = "";
|
|
10561
11161
|
if (opts.type === "file") {
|
|
10562
11162
|
content = await readLocalFile(opts.uri);
|
|
@@ -10579,7 +11179,8 @@ bridgeCommand.command("personal-source-import").description(
|
|
|
10579
11179
|
jsonOut2({
|
|
10580
11180
|
success: true,
|
|
10581
11181
|
sourceId: record.id,
|
|
10582
|
-
content: record.content
|
|
11182
|
+
content: record.content,
|
|
11183
|
+
cached: false
|
|
10583
11184
|
});
|
|
10584
11185
|
});
|
|
10585
11186
|
});
|
|
@@ -10605,6 +11206,205 @@ bridgeCommand.command("personal-source-confirm-import").description(
|
|
|
10605
11206
|
});
|
|
10606
11207
|
});
|
|
10607
11208
|
});
|
|
11209
|
+
bridgeCommand.command("curriculum-list-providers").description("List registered curriculum providers (JSON)").action(() => {
|
|
11210
|
+
jsonOut2({
|
|
11211
|
+
success: true,
|
|
11212
|
+
providers: CURRICULUM_PROVIDERS.map((provider) => ({
|
|
11213
|
+
id: provider.id,
|
|
11214
|
+
country: provider.country,
|
|
11215
|
+
countryLabel: provider.countryLabel,
|
|
11216
|
+
region: provider.region,
|
|
11217
|
+
regionLabel: provider.regionLabel,
|
|
11218
|
+
label: provider.label
|
|
11219
|
+
}))
|
|
11220
|
+
});
|
|
11221
|
+
});
|
|
11222
|
+
var CURRICULUM_LEVELS = [
|
|
11223
|
+
"schoolType",
|
|
11224
|
+
"grade",
|
|
11225
|
+
"subject",
|
|
11226
|
+
"track",
|
|
11227
|
+
"topic"
|
|
11228
|
+
];
|
|
11229
|
+
bridgeCommand.command("curriculum-list-level").description("List taxonomy options for the next import-wizard step (JSON)").requiredOption("--provider <id>", "Curriculum provider id").requiredOption(
|
|
11230
|
+
"--level <level>",
|
|
11231
|
+
`Level to list: ${CURRICULUM_LEVELS.join("|")}`
|
|
11232
|
+
).option("--selection <json>", "JSON selection made so far", "{}").action((opts) => {
|
|
11233
|
+
const provider = getCurriculumProvider(opts.provider);
|
|
11234
|
+
if (!provider) jsonError(`Unknown curriculum provider: ${opts.provider}`);
|
|
11235
|
+
if (!CURRICULUM_LEVELS.includes(opts.level)) {
|
|
11236
|
+
jsonError(
|
|
11237
|
+
`Invalid --level: ${opts.level}. Use one of ${CURRICULUM_LEVELS.join(", ")}.`
|
|
11238
|
+
);
|
|
11239
|
+
}
|
|
11240
|
+
let selection;
|
|
11241
|
+
try {
|
|
11242
|
+
selection = JSON.parse(opts.selection);
|
|
11243
|
+
} catch {
|
|
11244
|
+
jsonError("Invalid --selection JSON");
|
|
11245
|
+
return;
|
|
11246
|
+
}
|
|
11247
|
+
const level = opts.level;
|
|
11248
|
+
if (level === "schoolType") {
|
|
11249
|
+
jsonOut2({ success: true, options: provider.listSchoolTypes() });
|
|
11250
|
+
return;
|
|
11251
|
+
}
|
|
11252
|
+
if (!selection.schoolType) jsonError("selection.schoolType is required");
|
|
11253
|
+
if (level === "grade") {
|
|
11254
|
+
jsonOut2({
|
|
11255
|
+
success: true,
|
|
11256
|
+
options: provider.listGrades(selection.schoolType)
|
|
11257
|
+
});
|
|
11258
|
+
return;
|
|
11259
|
+
}
|
|
11260
|
+
if (!selection.grade) jsonError("selection.grade is required");
|
|
11261
|
+
if (level === "subject") {
|
|
11262
|
+
jsonOut2({
|
|
11263
|
+
success: true,
|
|
11264
|
+
options: provider.listSubjects(selection.schoolType, selection.grade)
|
|
11265
|
+
});
|
|
11266
|
+
return;
|
|
11267
|
+
}
|
|
11268
|
+
if (!selection.subject) jsonError("selection.subject is required");
|
|
11269
|
+
if (level === "track") {
|
|
11270
|
+
jsonOut2({
|
|
11271
|
+
success: true,
|
|
11272
|
+
options: provider.listTracks(
|
|
11273
|
+
selection.schoolType,
|
|
11274
|
+
selection.grade,
|
|
11275
|
+
selection.subject
|
|
11276
|
+
)
|
|
11277
|
+
});
|
|
11278
|
+
return;
|
|
11279
|
+
}
|
|
11280
|
+
jsonOut2({ success: true, options: provider.listTopics(selection) });
|
|
11281
|
+
});
|
|
11282
|
+
bridgeCommand.command("curriculum-resolve-topics").description("Resolve selected curriculum topics to source URLs (JSON)").requiredOption("--provider <id>", "Curriculum provider id").requiredOption("--topics <json>", "JSON array of topic nodes to resolve").action((opts) => {
|
|
11283
|
+
const provider = getCurriculumProvider(opts.provider);
|
|
11284
|
+
if (!provider) jsonError(`Unknown curriculum provider: ${opts.provider}`);
|
|
11285
|
+
let topics;
|
|
11286
|
+
try {
|
|
11287
|
+
topics = JSON.parse(opts.topics);
|
|
11288
|
+
} catch {
|
|
11289
|
+
jsonError("Invalid --topics JSON");
|
|
11290
|
+
return;
|
|
11291
|
+
}
|
|
11292
|
+
const resolved = topics.map((topic) => provider.resolveTopic(topic));
|
|
11293
|
+
jsonOut2({ success: true, resolved });
|
|
11294
|
+
});
|
|
11295
|
+
async function fetchRawHtml(url) {
|
|
11296
|
+
if (!await isSafeUrl(url)) {
|
|
11297
|
+
throw new Error(`Access denied to unsafe target URL: ${url}`);
|
|
11298
|
+
}
|
|
11299
|
+
const controller = new AbortController();
|
|
11300
|
+
const timeoutId = setTimeout(() => controller.abort(), 1e4);
|
|
11301
|
+
try {
|
|
11302
|
+
const res = await fetch(url, {
|
|
11303
|
+
signal: controller.signal,
|
|
11304
|
+
headers: {
|
|
11305
|
+
"User-Agent": "ZAM-Content-Studio/0.6.1"
|
|
11306
|
+
}
|
|
11307
|
+
});
|
|
11308
|
+
if (!res.ok) {
|
|
11309
|
+
throw new Error(`Web server responded with status ${res.status}`);
|
|
11310
|
+
}
|
|
11311
|
+
const contentType = res.headers.get("content-type") || "";
|
|
11312
|
+
if (!contentType.includes("text/html") && !contentType.includes("application/xhtml+xml")) {
|
|
11313
|
+
throw new Error(`Unsupported content type: ${contentType}`);
|
|
11314
|
+
}
|
|
11315
|
+
return await res.text();
|
|
11316
|
+
} catch (err) {
|
|
11317
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
11318
|
+
throw new Error("Connection request timed out after 10 seconds");
|
|
11319
|
+
}
|
|
11320
|
+
throw err;
|
|
11321
|
+
} finally {
|
|
11322
|
+
clearTimeout(timeoutId);
|
|
11323
|
+
}
|
|
11324
|
+
}
|
|
11325
|
+
bridgeCommand.command("curriculum-extract-topics").description(
|
|
11326
|
+
"Fetch and extract specific texts for selected curriculum topics (JSON)"
|
|
11327
|
+
).requiredOption("--provider <id>", "Curriculum provider id").requiredOption("--topics <json>", "JSON array of selected topic nodes").action(async (opts) => {
|
|
11328
|
+
await withDb2(async (db) => {
|
|
11329
|
+
const provider = getCurriculumProvider(opts.provider);
|
|
11330
|
+
if (!provider) jsonError(`Unknown curriculum provider: ${opts.provider}`);
|
|
11331
|
+
let topics;
|
|
11332
|
+
try {
|
|
11333
|
+
topics = JSON.parse(opts.topics);
|
|
11334
|
+
} catch {
|
|
11335
|
+
jsonError("Invalid --topics JSON");
|
|
11336
|
+
return;
|
|
11337
|
+
}
|
|
11338
|
+
const topicsByUri = /* @__PURE__ */ new Map();
|
|
11339
|
+
for (const topic of topics) {
|
|
11340
|
+
const resolved = provider.resolveTopic(topic);
|
|
11341
|
+
const list = topicsByUri.get(resolved.uri) || [];
|
|
11342
|
+
list.push(topic);
|
|
11343
|
+
topicsByUri.set(resolved.uri, list);
|
|
11344
|
+
}
|
|
11345
|
+
const extracted = [];
|
|
11346
|
+
for (const [uri, uriTopics] of topicsByUri.entries()) {
|
|
11347
|
+
const rawHtml = await fetchRawHtml(uri);
|
|
11348
|
+
let extractedTexts = {};
|
|
11349
|
+
if (provider.extractTopics) {
|
|
11350
|
+
const fullTopicIds = uriTopics.map((t2) => `${t2.sourceRef}#${t2.id}`);
|
|
11351
|
+
extractedTexts = provider.extractTopics(rawHtml, fullTopicIds);
|
|
11352
|
+
} else {
|
|
11353
|
+
const cleanText = cleanHtml(rawHtml);
|
|
11354
|
+
for (const t2 of uriTopics) {
|
|
11355
|
+
extractedTexts[`${t2.sourceRef}#${t2.id}`] = cleanText;
|
|
11356
|
+
}
|
|
11357
|
+
}
|
|
11358
|
+
const pageCleanedText = cleanHtml(rawHtml);
|
|
11359
|
+
const sourceId = ulid8();
|
|
11360
|
+
await db.prepare(
|
|
11361
|
+
`INSERT INTO sources (id, type, uri, content)
|
|
11362
|
+
VALUES (?, 'web', ?, ?)
|
|
11363
|
+
ON CONFLICT(uri) DO UPDATE SET
|
|
11364
|
+
type = excluded.type,
|
|
11365
|
+
content = excluded.content`
|
|
11366
|
+
).run(sourceId, uri, pageCleanedText);
|
|
11367
|
+
const record = await db.prepare("SELECT id FROM sources WHERE uri = ?").get(uri);
|
|
11368
|
+
for (const topic of uriTopics) {
|
|
11369
|
+
const resolved = provider.resolveTopic(topic);
|
|
11370
|
+
const text = extractedTexts[resolved.topicId] || "";
|
|
11371
|
+
extracted.push({
|
|
11372
|
+
topicId: resolved.topicId,
|
|
11373
|
+
uri,
|
|
11374
|
+
sourceId: record.id,
|
|
11375
|
+
text
|
|
11376
|
+
});
|
|
11377
|
+
}
|
|
11378
|
+
}
|
|
11379
|
+
jsonOut2({ success: true, extracted });
|
|
11380
|
+
});
|
|
11381
|
+
});
|
|
11382
|
+
bridgeCommand.command("curriculum-get-last-selection").description("Read the learner's last navigated curriculum path (JSON)").action(async () => {
|
|
11383
|
+
await withDb2(async (db) => {
|
|
11384
|
+
const breadcrumb = await getLastCurriculumSelection(db);
|
|
11385
|
+
jsonOut2({ success: true, breadcrumb: breadcrumb ?? null });
|
|
11386
|
+
});
|
|
11387
|
+
});
|
|
11388
|
+
bridgeCommand.command("curriculum-set-last-selection").description("Persist the learner's last navigated curriculum path (JSON)").requiredOption(
|
|
11389
|
+
"--breadcrumb <json>",
|
|
11390
|
+
"JSON breadcrumb: {providerId, schoolType?, grade?, subject?, track?}"
|
|
11391
|
+
).action(async (opts) => {
|
|
11392
|
+
await withDb2(async (db) => {
|
|
11393
|
+
let breadcrumb;
|
|
11394
|
+
try {
|
|
11395
|
+
breadcrumb = JSON.parse(opts.breadcrumb);
|
|
11396
|
+
} catch {
|
|
11397
|
+
jsonError("Invalid --breadcrumb JSON");
|
|
11398
|
+
return;
|
|
11399
|
+
}
|
|
11400
|
+
if (!breadcrumb.providerId) {
|
|
11401
|
+
jsonError("breadcrumb.providerId is required");
|
|
11402
|
+
return;
|
|
11403
|
+
}
|
|
11404
|
+
await setLastCurriculumSelection(db, breadcrumb);
|
|
11405
|
+
jsonOut2({ success: true });
|
|
11406
|
+
});
|
|
11407
|
+
});
|
|
10608
11408
|
bridgeCommand.command("serve").description("Start the persistent JSON-RPC stdin/stdout server").option("--stdin", "Use stdin/stdout for communication").action(async (_opts) => {
|
|
10609
11409
|
isServeMode = true;
|
|
10610
11410
|
const {
|