zam-core 0.6.0 → 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 +874 -54
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +22 -1
- package/dist/index.js +124 -36
- 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, "");
|
|
@@ -1722,10 +1763,22 @@ async function listPersonalCards(db, userId, options) {
|
|
|
1722
1763
|
t.bloom_level AS bloomLevel,
|
|
1723
1764
|
t.context,
|
|
1724
1765
|
t.symbiosis_mode AS symbiosisMode,
|
|
1725
|
-
|
|
1766
|
+
COALESCE(
|
|
1767
|
+
t.source_link,
|
|
1768
|
+
(
|
|
1769
|
+
SELECT s.uri
|
|
1770
|
+
FROM token_sources ts
|
|
1771
|
+
INNER JOIN sources s ON s.id = ts.source_id
|
|
1772
|
+
WHERE ts.token_id = t.id
|
|
1773
|
+
ORDER BY s.created_at DESC, s.id DESC
|
|
1774
|
+
LIMIT 1
|
|
1775
|
+
)
|
|
1776
|
+
) AS sourceLink,
|
|
1726
1777
|
t.question,
|
|
1727
1778
|
t.created_at AS createdAt,
|
|
1728
1779
|
t.updated_at AS updatedAt,
|
|
1780
|
+
t.provider,
|
|
1781
|
+
t.topic_id AS topicId,
|
|
1729
1782
|
c.id AS cardId,
|
|
1730
1783
|
c.state,
|
|
1731
1784
|
c.due_at AS dueAt,
|
|
@@ -1755,6 +1808,17 @@ async function listPersonalCards(db, userId, options) {
|
|
|
1755
1808
|
}
|
|
1756
1809
|
sql += " ORDER BY t.created_at DESC";
|
|
1757
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
|
+
}
|
|
1758
1822
|
return rows;
|
|
1759
1823
|
}
|
|
1760
1824
|
async function importCurriculumCards(db, userId, cards) {
|
|
@@ -1801,9 +1865,18 @@ async function importCurriculumCards(db, userId, cards) {
|
|
|
1801
1865
|
context: card.context || "",
|
|
1802
1866
|
symbiosis_mode: symbiosisMode,
|
|
1803
1867
|
source_link: card.source_link || null,
|
|
1804
|
-
question: card.question || null
|
|
1868
|
+
question: card.question || null,
|
|
1869
|
+
provider: card.provider || null,
|
|
1870
|
+
topic_id: card.topic_id || null
|
|
1805
1871
|
});
|
|
1806
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
|
+
}
|
|
1807
1880
|
}
|
|
1808
1881
|
const existingCard = await getCard(tx, token.id, userId);
|
|
1809
1882
|
if (!existingCard) {
|
|
@@ -2025,11 +2098,12 @@ async function confirmSourceImport(db, userId, sourceId, proposals) {
|
|
|
2025
2098
|
let createdCount = 0;
|
|
2026
2099
|
let linkedCount = 0;
|
|
2027
2100
|
await db.transaction(async (tx) => {
|
|
2028
|
-
const source = await tx.prepare("SELECT id FROM sources WHERE id = ?").get(sourceId);
|
|
2029
|
-
if (!source) {
|
|
2030
|
-
throw new Error(`Source not found: ${sourceId}`);
|
|
2031
|
-
}
|
|
2032
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
|
+
}
|
|
2033
2107
|
const baseText = card.question && card.question.trim().length > 0 ? card.question : card.concept;
|
|
2034
2108
|
const cleanDomain = slugify(card.domain || "");
|
|
2035
2109
|
const cleanBase = slugify(baseText);
|
|
@@ -2060,11 +2134,19 @@ async function confirmSourceImport(db, userId, sourceId, proposals) {
|
|
|
2060
2134
|
bloom_level: bloom,
|
|
2061
2135
|
context: card.excerpt || "",
|
|
2062
2136
|
symbiosis_mode: symbiosisMode,
|
|
2063
|
-
question: card.question || null
|
|
2137
|
+
question: card.question || null,
|
|
2138
|
+
provider: card.provider || null,
|
|
2139
|
+
topic_id: card.topic_id || null
|
|
2064
2140
|
});
|
|
2065
2141
|
createdCount++;
|
|
2066
2142
|
} else {
|
|
2067
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
|
+
}
|
|
2068
2150
|
}
|
|
2069
2151
|
const existingCard = await getCard(tx, token.id, userId);
|
|
2070
2152
|
if (!existingCard) {
|
|
@@ -2076,7 +2158,12 @@ async function confirmSourceImport(db, userId, sourceId, proposals) {
|
|
|
2076
2158
|
ON CONFLICT(token_id, source_id) DO UPDATE SET
|
|
2077
2159
|
excerpt = excluded.excerpt,
|
|
2078
2160
|
page_number = excluded.page_number`
|
|
2079
|
-
).run(
|
|
2161
|
+
).run(
|
|
2162
|
+
token.id,
|
|
2163
|
+
cardSourceId,
|
|
2164
|
+
card.excerpt || "",
|
|
2165
|
+
card.page_number || null
|
|
2166
|
+
);
|
|
2080
2167
|
}
|
|
2081
2168
|
});
|
|
2082
2169
|
return { createdCount, linkedCount };
|
|
@@ -5019,12 +5106,7 @@ function installFastFlowLM() {
|
|
|
5019
5106
|
}
|
|
5020
5107
|
}
|
|
5021
5108
|
function installOllama() {
|
|
5022
|
-
|
|
5023
|
-
const isWin = process.platform === "win32";
|
|
5024
|
-
const hasOllama = hasCommand("ollama") || isMac && existsSync9("/Applications/Ollama.app") || isWin && existsSync9(
|
|
5025
|
-
join10(homedir7(), "AppData", "Local", "Programs", "Ollama", "ollama.exe")
|
|
5026
|
-
);
|
|
5027
|
-
if (hasOllama) {
|
|
5109
|
+
if (isOllamaInstalled()) {
|
|
5028
5110
|
return { success: true, message: "Ollama is already installed." };
|
|
5029
5111
|
}
|
|
5030
5112
|
if (process.platform === "darwin") {
|
|
@@ -5079,19 +5161,23 @@ function installOllama() {
|
|
|
5079
5161
|
}
|
|
5080
5162
|
}
|
|
5081
5163
|
}
|
|
5082
|
-
function resolveOllamaCommand() {
|
|
5083
|
-
|
|
5084
|
-
const
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5164
|
+
function resolveOllamaCommand(options = {}) {
|
|
5165
|
+
const platform = options.platform ?? process.platform;
|
|
5166
|
+
const homeDir = options.homeDir ?? homedir7();
|
|
5167
|
+
const commandAvailable = options.commandAvailable ?? hasCommand;
|
|
5168
|
+
const pathExists2 = options.pathExists ?? existsSync9;
|
|
5169
|
+
if (commandAvailable("ollama")) return "ollama";
|
|
5170
|
+
const candidates = platform === "win32" ? [join10(homeDir, "AppData", "Local", "Programs", "Ollama", "ollama.exe")] : platform === "darwin" ? [
|
|
5171
|
+
"/opt/homebrew/bin/ollama",
|
|
5172
|
+
"/usr/local/bin/ollama",
|
|
5173
|
+
"/Applications/Ollama.app/Contents/Resources/ollama"
|
|
5174
|
+
] : ["/usr/local/bin/ollama", "/usr/bin/ollama"];
|
|
5175
|
+
return candidates.find((candidate) => pathExists2(candidate));
|
|
5176
|
+
}
|
|
5177
|
+
function isOllamaInstalled(options = {}) {
|
|
5178
|
+
const platform = options.platform ?? process.platform;
|
|
5179
|
+
const pathExists2 = options.pathExists ?? existsSync9;
|
|
5180
|
+
return resolveOllamaCommand(options) !== void 0 || platform === "darwin" && pathExists2("/Applications/Ollama.app");
|
|
5095
5181
|
}
|
|
5096
5182
|
function prepareLocalModel(runner, model) {
|
|
5097
5183
|
if (runner === "fastflowlm") {
|
|
@@ -6060,6 +6146,14 @@ var BLOOM_VERBS2 = {
|
|
|
6060
6146
|
4: "Analyze",
|
|
6061
6147
|
5: "Synthesize"
|
|
6062
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
|
+
};
|
|
6063
6157
|
async function readChatContent(res, label) {
|
|
6064
6158
|
if (!res.ok) {
|
|
6065
6159
|
const errorText = await res.text().catch(() => "");
|
|
@@ -6068,11 +6162,16 @@ async function readChatContent(res, label) {
|
|
|
6068
6162
|
);
|
|
6069
6163
|
}
|
|
6070
6164
|
const data = await res.json();
|
|
6071
|
-
const
|
|
6072
|
-
|
|
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) {
|
|
6073
6172
|
throw new Error("Empty response from LLM");
|
|
6074
6173
|
}
|
|
6075
|
-
return
|
|
6174
|
+
return trimmed;
|
|
6076
6175
|
}
|
|
6077
6176
|
async function generateQuestionViaLLM(db, input8) {
|
|
6078
6177
|
const cfg = await getProviderForRole(db, "recall");
|
|
@@ -6239,15 +6338,42 @@ function parseGeneratedCardArray(responseText, label, limits) {
|
|
|
6239
6338
|
};
|
|
6240
6339
|
});
|
|
6241
6340
|
}
|
|
6242
|
-
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
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;
|
|
6247
6364
|
}
|
|
6248
|
-
|
|
6249
|
-
|
|
6250
|
-
|
|
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) {
|
|
6251
6377
|
const systemPrompt = `You are ZAM, a highly precise agentic curriculum parser.
|
|
6252
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}.
|
|
6253
6379
|
|
|
@@ -6263,9 +6389,20 @@ Guidelines:
|
|
|
6263
6389
|
- Break complex requirements into multiple separate, atomic cards.
|
|
6264
6390
|
- Keep questions focused on one fact.
|
|
6265
6391
|
- Do not repeat the same concept in multiple cards.
|
|
6392
|
+
- Test the subject matter directly. Never ask what learners "must know", what
|
|
6393
|
+
a curriculum "requires", or merely restate a competency as a question.
|
|
6394
|
+
- Stay within the scope of the supplied curriculum. A reference answer may add
|
|
6395
|
+
the definition, formula, or minimal worked example needed to answer a direct
|
|
6396
|
+
subject-matter question, but it must not introduce named methods, terminology,
|
|
6397
|
+
or adjacent topics that the curriculum passage does not mention.
|
|
6398
|
+
- Keep every reference answer concise, mathematically/factually correct, and
|
|
6399
|
+
focused on the competency being tested.
|
|
6400
|
+
- Set "context" to the exact sentence or bullet that supports that specific
|
|
6401
|
+
card. Never reuse an unrelated passage merely because it comes from the same
|
|
6402
|
+
curriculum section.
|
|
6266
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.`;
|
|
6267
6404
|
const userPrompt = `Curriculum Text to Parse:
|
|
6268
|
-
${
|
|
6405
|
+
${curriculumText}
|
|
6269
6406
|
|
|
6270
6407
|
Target Category: ${targetCategory}
|
|
6271
6408
|
${sourceUrl ? `Source Reference Link: ${sourceUrl}` : ""}
|
|
@@ -6288,10 +6425,73 @@ JSON Array Output:`;
|
|
|
6288
6425
|
temperature: 0.1,
|
|
6289
6426
|
max_tokens: DEFAULT_LLM_MAX_TOKENS
|
|
6290
6427
|
}),
|
|
6291
|
-
locale
|
|
6428
|
+
locale
|
|
6292
6429
|
}
|
|
6293
6430
|
);
|
|
6294
|
-
|
|
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
|
+
}
|
|
6295
6495
|
return parseGeneratedCardArray(responseText, "curriculum import", {
|
|
6296
6496
|
min: 0,
|
|
6297
6497
|
max: 200
|
|
@@ -7186,7 +7386,7 @@ async function readWebLink(url) {
|
|
|
7186
7386
|
redirect: "manual",
|
|
7187
7387
|
signal: controller.signal,
|
|
7188
7388
|
headers: {
|
|
7189
|
-
"User-Agent": "ZAM-Content-Studio/0.6.
|
|
7389
|
+
"User-Agent": "ZAM-Content-Studio/0.6.1"
|
|
7190
7390
|
}
|
|
7191
7391
|
});
|
|
7192
7392
|
if (res.status >= 300 && res.status < 400) {
|
|
@@ -7249,6 +7449,412 @@ async function readImageOCR(db, imagePath) {
|
|
|
7249
7449
|
return extractTextFromScanViaLLM(db, imagePath);
|
|
7250
7450
|
}
|
|
7251
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
|
+
|
|
7252
7858
|
// src/cli/llm/vision.ts
|
|
7253
7859
|
import { randomBytes } from "crypto";
|
|
7254
7860
|
import { readFileSync as readFileSync10 } from "fs";
|
|
@@ -9955,7 +10561,7 @@ bridgeCommand.command("cloud-model-hint").description("Suggest a cloud model for
|
|
|
9955
10561
|
bridgeCommand.command("local-llm-hints").description("Detect installed local LLM servers and suggest defaults (JSON)").action(() => {
|
|
9956
10562
|
const profile = getSystemProfile();
|
|
9957
10563
|
const flmInstalled = hasCommand("flm") || existsSync17("C:\\Program Files\\flm\\flm.exe");
|
|
9958
|
-
const ollamaInstalled =
|
|
10564
|
+
const ollamaInstalled = isOllamaInstalled();
|
|
9959
10565
|
const runners = [
|
|
9960
10566
|
{ id: "flm", label: "FastFlowLM", installed: flmInstalled },
|
|
9961
10567
|
{ id: "ollama", label: "Ollama", installed: ollamaInstalled },
|
|
@@ -10535,8 +11141,22 @@ bridgeCommand.command("personal-card-confirm-foundations").description("Save con
|
|
|
10535
11141
|
});
|
|
10536
11142
|
bridgeCommand.command("personal-source-import").description(
|
|
10537
11143
|
"Fetch and clean plain text from local file, web link, or vision scan (JSON)"
|
|
10538
|
-
).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) => {
|
|
10539
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
|
+
}
|
|
10540
11160
|
let content = "";
|
|
10541
11161
|
if (opts.type === "file") {
|
|
10542
11162
|
content = await readLocalFile(opts.uri);
|
|
@@ -10559,7 +11179,8 @@ bridgeCommand.command("personal-source-import").description(
|
|
|
10559
11179
|
jsonOut2({
|
|
10560
11180
|
success: true,
|
|
10561
11181
|
sourceId: record.id,
|
|
10562
|
-
content: record.content
|
|
11182
|
+
content: record.content,
|
|
11183
|
+
cached: false
|
|
10563
11184
|
});
|
|
10564
11185
|
});
|
|
10565
11186
|
});
|
|
@@ -10585,6 +11206,205 @@ bridgeCommand.command("personal-source-confirm-import").description(
|
|
|
10585
11206
|
});
|
|
10586
11207
|
});
|
|
10587
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
|
+
});
|
|
10588
11408
|
bridgeCommand.command("serve").description("Start the persistent JSON-RPC stdin/stdout server").option("--stdin", "Use stdin/stdout for communication").action(async (_opts) => {
|
|
10589
11409
|
isServeMode = true;
|
|
10590
11410
|
const {
|