playable-lessons 0.2.2 → 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/README.md +198 -6
- package/out/cli/index.js +415 -37
- package/out/shared/aiTaskExport.js +161 -0
- package/out/shared/caseStudyExport.js +115 -0
- package/out/shared/flashcardExport.js +161 -0
- package/out/shared/generate.js +490 -11
- package/out/shared/h5pExporter.js +286 -0
- package/out/shared/imageClient.js +227 -0
- package/out/shared/inkParser.js +187 -0
- package/out/shared/planExport.js +110 -0
- package/out/shared/project.js +12 -0
- package/out/shared/prompts.js +433 -1
- package/out/shared/quizExport.js +182 -0
- package/out/shared/storyExport.js +53 -4
- package/out/shared/summaryExport.js +144 -0
- package/out/shared/version.generated.js +7 -0
- package/package.json +7 -4
package/out/shared/generate.js
CHANGED
|
@@ -8,10 +8,24 @@
|
|
|
8
8
|
*/
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
10
|
exports.extractInk = extractInk;
|
|
11
|
+
exports.extractJson = extractJson;
|
|
11
12
|
exports.generateInk = generateInk;
|
|
13
|
+
exports.collectSceneImagePrompts = collectSceneImagePrompts;
|
|
14
|
+
exports.generateSceneImages = generateSceneImages;
|
|
15
|
+
exports.generateFlashcards = generateFlashcards;
|
|
16
|
+
exports.quizLetter = quizLetter;
|
|
17
|
+
exports.generateQuiz = generateQuiz;
|
|
18
|
+
exports.generateSummary = generateSummary;
|
|
19
|
+
exports.generateAiTask = generateAiTask;
|
|
20
|
+
exports.generateCaseStudy = generateCaseStudy;
|
|
21
|
+
exports.generatePlan = generatePlan;
|
|
22
|
+
exports.applyPlan = applyPlan;
|
|
23
|
+
exports.refineText = refineText;
|
|
12
24
|
const aiClient_js_1 = require("./aiClient.js");
|
|
13
25
|
const prompts_js_1 = require("./prompts.js");
|
|
14
26
|
const storyExport_js_1 = require("./storyExport.js");
|
|
27
|
+
const inkParser_js_1 = require("./inkParser.js");
|
|
28
|
+
const imageClient_js_1 = require("./imageClient.js");
|
|
15
29
|
/** Pull Ink source out of an AI response — prefer a ```ink block, then any fence. */
|
|
16
30
|
function extractInk(raw) {
|
|
17
31
|
const inkBlock = raw.match(/```ink\s*([\s\S]*?)```/);
|
|
@@ -27,6 +41,37 @@ function extractInkCorrection(raw) {
|
|
|
27
41
|
const m = raw.match(/```ink\s*([\s\S]*?)```/);
|
|
28
42
|
return m ? m[1].trim() : null;
|
|
29
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Pull a JSON object out of an AI response — prefer a ```json fence, then any
|
|
46
|
+
* fence, then the outermost {...} span. Throws if nothing parses.
|
|
47
|
+
*/
|
|
48
|
+
function extractJson(raw) {
|
|
49
|
+
const fence = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
50
|
+
const candidate = (fence ? fence[1] : raw).trim();
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(candidate);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Fall through and try slicing the outermost object span.
|
|
56
|
+
}
|
|
57
|
+
const first = candidate.indexOf('{');
|
|
58
|
+
const last = candidate.lastIndexOf('}');
|
|
59
|
+
if (first !== -1 && last > first) {
|
|
60
|
+
return JSON.parse(candidate.slice(first, last + 1));
|
|
61
|
+
}
|
|
62
|
+
throw new Error('Could not parse JSON from the AI response');
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Shared core of the single-call JSON generators (flashcards, quiz, summary):
|
|
66
|
+
* resolve the transport from `opts` (defaulting to callAI with `config`), invoke
|
|
67
|
+
* it, and parse the response as JSON. Returns the parsed data and the raw
|
|
68
|
+
* response — the raw is kept on each result for the `json` export + debugging.
|
|
69
|
+
*/
|
|
70
|
+
async function callForJson(messages, config, opts) {
|
|
71
|
+
const call = opts.call ?? ((m) => (0, aiClient_js_1.callAI)(m, config));
|
|
72
|
+
const raw = await call(messages);
|
|
73
|
+
return { data: extractJson(raw), raw };
|
|
74
|
+
}
|
|
30
75
|
async function generateInk(params, config, opts = {}) {
|
|
31
76
|
const log = opts.log ?? (() => { });
|
|
32
77
|
const call = opts.call ?? ((messages) => (0, aiClient_js_1.callAI)(messages, config));
|
|
@@ -36,14 +81,15 @@ async function generateInk(params, config, opts = {}) {
|
|
|
36
81
|
const protagonist = params.protagonistType || 'the reader';
|
|
37
82
|
const tone = params.tone || 'professional';
|
|
38
83
|
const answers = params.answers || '(no specific preferences provided — use sensible defaults)';
|
|
39
|
-
const
|
|
84
|
+
const sp = (0, prompts_js_1.storyPrompts)(params.branchingStyle);
|
|
85
|
+
const inkSystem = sp.system + '\n\n' + sp.inkSyntaxRef;
|
|
40
86
|
// ─── Stage 1: Outline ───
|
|
41
87
|
log('[1/4] Generating story outline…');
|
|
42
88
|
const outlineRaw = await call([
|
|
43
|
-
{ role: 'system', content:
|
|
89
|
+
{ role: 'system', content: sp.system },
|
|
44
90
|
{
|
|
45
91
|
role: 'user',
|
|
46
|
-
content:
|
|
92
|
+
content: sp.outline
|
|
47
93
|
.replace('{{inputMode}}', mode)
|
|
48
94
|
.replace('{{inputText}}', params.inputText)
|
|
49
95
|
.replace('{{storyLength}}', length)
|
|
@@ -54,16 +100,14 @@ async function generateInk(params, config, opts = {}) {
|
|
|
54
100
|
]);
|
|
55
101
|
// ─── Stage 2: Ink generation ───
|
|
56
102
|
log('[2/4] Generating Ink source…');
|
|
103
|
+
const inkPrompt = sp.inkGeneration
|
|
104
|
+
.replace('{{outline}}', outlineRaw)
|
|
105
|
+
.replace('{{inputMode}}', mode)
|
|
106
|
+
.replace('{{inputText}}', params.inputText)
|
|
107
|
+
.replace('{{storyLength}}', length) + (params.sceneImages ? '\n' + prompts_js_1.PROMPTS.sceneImageInstruction : '');
|
|
57
108
|
const inkRaw = await call([
|
|
58
109
|
{ role: 'system', content: inkSystem },
|
|
59
|
-
{
|
|
60
|
-
role: 'user',
|
|
61
|
-
content: prompts_js_1.PROMPTS.inkGeneration
|
|
62
|
-
.replace('{{outline}}', outlineRaw)
|
|
63
|
-
.replace('{{inputMode}}', mode)
|
|
64
|
-
.replace('{{inputText}}', params.inputText)
|
|
65
|
-
.replace('{{storyLength}}', length)
|
|
66
|
-
}
|
|
110
|
+
{ role: 'user', content: inkPrompt }
|
|
67
111
|
]);
|
|
68
112
|
let inkSource = extractInk(inkRaw);
|
|
69
113
|
// ─── Stage 3: Review pass ───
|
|
@@ -110,3 +154,438 @@ async function generateInk(params, config, opts = {}) {
|
|
|
110
154
|
}
|
|
111
155
|
}
|
|
112
156
|
}
|
|
157
|
+
/** Extract every knot's scene-image prompt from Ink source. */
|
|
158
|
+
function collectSceneImagePrompts(inkSource) {
|
|
159
|
+
const parsed = (0, inkParser_js_1.parseInkSource)(inkSource);
|
|
160
|
+
const requests = [];
|
|
161
|
+
for (const knot of parsed.knots) {
|
|
162
|
+
if (knot.imagePrompt)
|
|
163
|
+
requests.push({ knotId: knot.id, prompt: knot.imagePrompt });
|
|
164
|
+
}
|
|
165
|
+
return requests;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Resolve a story's `# IMAGE_PROMPT:` tags to generated images. Returns a map
|
|
169
|
+
* of the raw prompt (as written in the tag) → data URL, which the preview,
|
|
170
|
+
* project store, and HTML export use to render scenes. Identical prompts are
|
|
171
|
+
* generated once; a failed image is logged and skipped rather than failing the
|
|
172
|
+
* whole run (the story still plays, that scene just shows a placeholder).
|
|
173
|
+
*/
|
|
174
|
+
async function generateSceneImages(inkSource, config, opts = {}) {
|
|
175
|
+
const log = opts.log ?? (() => { });
|
|
176
|
+
const style = opts.style ?? 'cartoon';
|
|
177
|
+
const gen = opts.generate ?? ((styledPrompt) => (0, imageClient_js_1.generateImage)(styledPrompt, config));
|
|
178
|
+
const requests = collectSceneImagePrompts(inkSource);
|
|
179
|
+
let unique = [...new Map(requests.map((r) => [r.prompt, r])).values()];
|
|
180
|
+
if (opts.maxImages && opts.maxImages > 0 && unique.length > opts.maxImages) {
|
|
181
|
+
log(`Capping scene images at ${opts.maxImages} of ${unique.length} unique prompts.`);
|
|
182
|
+
unique = unique.slice(0, opts.maxImages);
|
|
183
|
+
}
|
|
184
|
+
const images = {};
|
|
185
|
+
let done = 0;
|
|
186
|
+
for (const req of unique) {
|
|
187
|
+
done++;
|
|
188
|
+
log(`Generating scene image ${done}/${unique.length} (${req.knotId})…`);
|
|
189
|
+
try {
|
|
190
|
+
images[req.prompt] = await gen((0, imageClient_js_1.buildSceneImagePrompt)(req.prompt, style));
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
log(`Scene image failed for ${req.knotId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return images;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Coerce a parsed AI payload into a valid FlashcardResult. Drops any card that
|
|
200
|
+
* lacks a usable front/back (models occasionally emit malformed entries) and
|
|
201
|
+
* drops empty hint/tag strings so exporters can treat them as absent.
|
|
202
|
+
*/
|
|
203
|
+
function normalizeFlashcards(data, fallbackTitle) {
|
|
204
|
+
const obj = (data ?? {});
|
|
205
|
+
const deckTitle = typeof obj.deckTitle === 'string' && obj.deckTitle.trim() ? obj.deckTitle.trim() : fallbackTitle;
|
|
206
|
+
const rawCards = Array.isArray(obj.cards) ? obj.cards : [];
|
|
207
|
+
const cards = [];
|
|
208
|
+
for (const entry of rawCards) {
|
|
209
|
+
const c = entry;
|
|
210
|
+
const front = typeof c?.front === 'string' ? c.front.trim() : '';
|
|
211
|
+
const back = typeof c?.back === 'string' ? c.back.trim() : '';
|
|
212
|
+
if (!front || !back)
|
|
213
|
+
continue;
|
|
214
|
+
const hint = typeof c.hint === 'string' ? c.hint.trim() : '';
|
|
215
|
+
const tag = typeof c.tag === 'string' ? c.tag.trim() : '';
|
|
216
|
+
cards.push({ front, back, ...(hint ? { hint } : {}), ...(tag ? { tag } : {}) });
|
|
217
|
+
}
|
|
218
|
+
return { deckTitle, cards, raw: '' };
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Generate a set of study flashcards from source material. A single AI call
|
|
222
|
+
* that must return strict JSON — no compile/review loop (unlike generateInk).
|
|
223
|
+
*/
|
|
224
|
+
async function generateFlashcards(params, config, opts = {}) {
|
|
225
|
+
const log = opts.log ?? (() => { });
|
|
226
|
+
const cardCount = params.cardCount && params.cardCount > 0 ? params.cardCount : 12;
|
|
227
|
+
const tone = params.tone || 'professional';
|
|
228
|
+
log('[1/1] Generating flashcards…');
|
|
229
|
+
const { data, raw } = await callForJson([
|
|
230
|
+
{ role: 'system', content: prompts_js_1.PROMPTS.flashcardsSystem },
|
|
231
|
+
{
|
|
232
|
+
role: 'user',
|
|
233
|
+
content: prompts_js_1.PROMPTS.flashcards
|
|
234
|
+
.replace('{{inputMode}}', params.inputMode)
|
|
235
|
+
.replace('{{inputText}}', params.inputText)
|
|
236
|
+
.replace('{{cardCount}}', String(cardCount))
|
|
237
|
+
.replace('{{tone}}', tone)
|
|
238
|
+
}
|
|
239
|
+
], config, opts);
|
|
240
|
+
const result = normalizeFlashcards(data, 'Flashcards');
|
|
241
|
+
result.raw = raw;
|
|
242
|
+
if (result.cards.length === 0) {
|
|
243
|
+
throw new Error('The model returned no usable flashcards');
|
|
244
|
+
}
|
|
245
|
+
log(`Generated ${result.cards.length} flashcard(s).`);
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
const QUIZ_LETTERS = 'ABCDEFGHIJ';
|
|
249
|
+
/**
|
|
250
|
+
* Coerce a parsed AI payload into a valid QuizResult. Drops any question whose
|
|
251
|
+
* stem is empty, that has fewer than 2 usable options, or whose correctIndex is
|
|
252
|
+
* out of range. Empty explanations are dropped.
|
|
253
|
+
*/
|
|
254
|
+
function normalizeQuiz(data, fallbackTitle) {
|
|
255
|
+
const obj = (data ?? {});
|
|
256
|
+
const quizTitle = typeof obj.quizTitle === 'string' && obj.quizTitle.trim() ? obj.quizTitle.trim() : fallbackTitle;
|
|
257
|
+
const rawQs = Array.isArray(obj.questions) ? obj.questions : [];
|
|
258
|
+
const questions = [];
|
|
259
|
+
for (const entry of rawQs) {
|
|
260
|
+
const q = entry;
|
|
261
|
+
const stem = typeof q?.stem === 'string' ? q.stem.trim() : '';
|
|
262
|
+
if (!stem)
|
|
263
|
+
continue;
|
|
264
|
+
const options = Array.isArray(q.options)
|
|
265
|
+
? q.options
|
|
266
|
+
.map((o) => (typeof o === 'string' ? o.trim() : ''))
|
|
267
|
+
.filter((s) => s.length > 0)
|
|
268
|
+
: [];
|
|
269
|
+
if (options.length < 2)
|
|
270
|
+
continue;
|
|
271
|
+
const idx = Number(q.correctIndex);
|
|
272
|
+
const correctIndex = Number.isInteger(idx) && idx >= 0 && idx < options.length ? idx : -1;
|
|
273
|
+
if (correctIndex < 0)
|
|
274
|
+
continue;
|
|
275
|
+
const explanation = typeof q.explanation === 'string' ? q.explanation.trim() : '';
|
|
276
|
+
questions.push({ stem, options, correctIndex, ...(explanation ? { explanation } : {}) });
|
|
277
|
+
}
|
|
278
|
+
return { quizTitle, questions, raw: '' };
|
|
279
|
+
}
|
|
280
|
+
/** 1-based letter (A, B, …) for an option index, used by exporters. */
|
|
281
|
+
function quizLetter(index) {
|
|
282
|
+
return QUIZ_LETTERS[index] ?? String(index + 1);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Generate a multiple-choice quiz from source material. A single AI call that
|
|
286
|
+
* must return strict JSON — no compile/review loop (unlike generateInk).
|
|
287
|
+
*/
|
|
288
|
+
async function generateQuiz(params, config, opts = {}) {
|
|
289
|
+
const log = opts.log ?? (() => { });
|
|
290
|
+
const questionCount = params.questionCount && params.questionCount > 0 ? params.questionCount : 10;
|
|
291
|
+
const tone = params.tone || 'professional';
|
|
292
|
+
log('[1/1] Generating quiz…');
|
|
293
|
+
const { data, raw } = await callForJson([
|
|
294
|
+
{ role: 'system', content: prompts_js_1.PROMPTS.quizSystem },
|
|
295
|
+
{
|
|
296
|
+
role: 'user',
|
|
297
|
+
content: prompts_js_1.PROMPTS.quiz
|
|
298
|
+
.replace('{{inputMode}}', params.inputMode)
|
|
299
|
+
.replace('{{inputText}}', params.inputText)
|
|
300
|
+
.replace('{{questionCount}}', String(questionCount))
|
|
301
|
+
.replace('{{tone}}', tone)
|
|
302
|
+
}
|
|
303
|
+
], config, opts);
|
|
304
|
+
const result = normalizeQuiz(data, 'Quiz');
|
|
305
|
+
result.raw = raw;
|
|
306
|
+
if (result.questions.length === 0) {
|
|
307
|
+
throw new Error('The model returned no usable quiz questions');
|
|
308
|
+
}
|
|
309
|
+
log(`Generated ${result.questions.length} quiz question(s).`);
|
|
310
|
+
return result;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Coerce a parsed AI payload into a valid SummaryResult. Drops empty key points
|
|
314
|
+
* and glossary entries missing a term or definition.
|
|
315
|
+
*/
|
|
316
|
+
function normalizeSummary(data, fallbackTitle) {
|
|
317
|
+
const obj = (data ?? {});
|
|
318
|
+
const title = typeof obj.title === 'string' && obj.title.trim() ? obj.title.trim() : fallbackTitle;
|
|
319
|
+
const overview = typeof obj.overview === 'string' ? obj.overview.trim() : '';
|
|
320
|
+
const keyPoints = Array.isArray(obj.keyPoints)
|
|
321
|
+
? obj.keyPoints
|
|
322
|
+
.map((p) => (typeof p === 'string' ? p.trim() : ''))
|
|
323
|
+
.filter((p) => p.length > 0)
|
|
324
|
+
: [];
|
|
325
|
+
const glossary = [];
|
|
326
|
+
const rawEntries = Array.isArray(obj.glossary) ? obj.glossary : [];
|
|
327
|
+
for (const entry of rawEntries) {
|
|
328
|
+
const g = entry;
|
|
329
|
+
const term = typeof g?.term === 'string' ? g.term.trim() : '';
|
|
330
|
+
const definition = typeof g?.definition === 'string' ? g.definition.trim() : '';
|
|
331
|
+
if (term && definition)
|
|
332
|
+
glossary.push({ term, definition });
|
|
333
|
+
}
|
|
334
|
+
return { title, overview, keyPoints, glossary, raw: '' };
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Generate a study summary from source material. A single AI call that must
|
|
338
|
+
* return strict JSON — no compile/review loop (unlike generateInk).
|
|
339
|
+
*/
|
|
340
|
+
async function generateSummary(params, config, opts = {}) {
|
|
341
|
+
const log = opts.log ?? (() => { });
|
|
342
|
+
const keyPointCount = params.keyPointCount && params.keyPointCount > 0 ? params.keyPointCount : 8;
|
|
343
|
+
const tone = params.tone || 'professional';
|
|
344
|
+
log('[1/1] Generating study summary…');
|
|
345
|
+
const { data, raw } = await callForJson([
|
|
346
|
+
{ role: 'system', content: prompts_js_1.PROMPTS.summarySystem },
|
|
347
|
+
{
|
|
348
|
+
role: 'user',
|
|
349
|
+
content: prompts_js_1.PROMPTS.summary
|
|
350
|
+
.replace('{{inputMode}}', params.inputMode)
|
|
351
|
+
.replace('{{inputText}}', params.inputText)
|
|
352
|
+
.replace('{{keyPointCount}}', String(keyPointCount))
|
|
353
|
+
.replace('{{tone}}', tone)
|
|
354
|
+
}
|
|
355
|
+
], config, opts);
|
|
356
|
+
const result = normalizeSummary(data, 'Summary');
|
|
357
|
+
result.raw = raw;
|
|
358
|
+
if (!result.overview && result.keyPoints.length === 0) {
|
|
359
|
+
throw new Error('The model returned no usable summary content');
|
|
360
|
+
}
|
|
361
|
+
log(`Generated ${result.keyPoints.length} key point(s), ${result.glossary.length} glossary term(s).`);
|
|
362
|
+
return result;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Coerce a parsed AI payload into a valid AiTaskResult. Drops tasks missing a
|
|
366
|
+
* scenario/brief/deliverable/whyItWorks, and drops specifics/rubric items that
|
|
367
|
+
* lack both fields. Keeps only tasks that retain at least one load-bearing
|
|
368
|
+
* specific and one rubric criterion.
|
|
369
|
+
*/
|
|
370
|
+
function normalizeAiTask(data, fallbackTitle) {
|
|
371
|
+
const obj = (data ?? {});
|
|
372
|
+
const title = typeof obj.title === 'string' && obj.title.trim() ? obj.title.trim() : fallbackTitle;
|
|
373
|
+
const rawTasks = Array.isArray(obj.tasks) ? obj.tasks : [];
|
|
374
|
+
const tasks = [];
|
|
375
|
+
for (const entry of rawTasks) {
|
|
376
|
+
const t = entry;
|
|
377
|
+
const scenario = typeof t?.scenario === 'string' ? t.scenario.trim() : '';
|
|
378
|
+
const brief = typeof t?.brief === 'string' ? t.brief.trim() : '';
|
|
379
|
+
const deliverable = typeof t?.deliverable === 'string' ? t.deliverable.trim() : '';
|
|
380
|
+
const whyItWorks = typeof t?.whyItWorks === 'string' ? t.whyItWorks.trim() : '';
|
|
381
|
+
if (!scenario || !brief || !deliverable || !whyItWorks)
|
|
382
|
+
continue;
|
|
383
|
+
const specifics = [];
|
|
384
|
+
for (const s of Array.isArray(t.loadBearingSpecifics) ? t.loadBearingSpecifics : []) {
|
|
385
|
+
const sp = s;
|
|
386
|
+
const detail = typeof sp?.detail === 'string' ? sp.detail.trim() : '';
|
|
387
|
+
const why = typeof sp?.whyGenericAnswersMissIt === 'string' ? sp.whyGenericAnswersMissIt.trim() : '';
|
|
388
|
+
if (detail && why)
|
|
389
|
+
specifics.push({ detail, whyGenericAnswersMissIt: why });
|
|
390
|
+
}
|
|
391
|
+
const rubric = [];
|
|
392
|
+
for (const r of Array.isArray(t.rubric) ? t.rubric : []) {
|
|
393
|
+
const rr = r;
|
|
394
|
+
const criterion = typeof rr?.criterion === 'string' ? rr.criterion.trim() : '';
|
|
395
|
+
const description = typeof rr?.description === 'string' ? rr.description.trim() : '';
|
|
396
|
+
if (criterion && description)
|
|
397
|
+
rubric.push({ criterion, description });
|
|
398
|
+
}
|
|
399
|
+
if (specifics.length === 0 || rubric.length === 0)
|
|
400
|
+
continue;
|
|
401
|
+
tasks.push({ scenario, brief, deliverable, loadBearingSpecifics: specifics, rubric, whyItWorks });
|
|
402
|
+
}
|
|
403
|
+
return { title, tasks, raw: '' };
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Generate AI-collaboration tasks from source material. A single AI call that
|
|
407
|
+
* must return strict JSON — no compile/review loop (unlike generateInk).
|
|
408
|
+
*/
|
|
409
|
+
async function generateAiTask(params, config, opts = {}) {
|
|
410
|
+
const log = opts.log ?? (() => { });
|
|
411
|
+
const taskCount = params.taskCount && params.taskCount > 0 ? params.taskCount : 3;
|
|
412
|
+
const tone = params.tone || 'professional';
|
|
413
|
+
log('[1/1] Generating AI-collaboration tasks…');
|
|
414
|
+
const { data, raw } = await callForJson([
|
|
415
|
+
{ role: 'system', content: prompts_js_1.PROMPTS.aiTaskSystem },
|
|
416
|
+
{
|
|
417
|
+
role: 'user',
|
|
418
|
+
content: prompts_js_1.PROMPTS.aiTask
|
|
419
|
+
.replace('{{inputMode}}', params.inputMode)
|
|
420
|
+
.replace('{{inputText}}', params.inputText)
|
|
421
|
+
.replace('{{taskCount}}', String(taskCount))
|
|
422
|
+
.replace('{{tone}}', tone)
|
|
423
|
+
}
|
|
424
|
+
], config, opts);
|
|
425
|
+
const result = normalizeAiTask(data, 'AI-Collaboration Tasks');
|
|
426
|
+
result.raw = raw;
|
|
427
|
+
if (result.tasks.length === 0) {
|
|
428
|
+
throw new Error('The model returned no usable AI-collaboration tasks');
|
|
429
|
+
}
|
|
430
|
+
log(`Generated ${result.tasks.length} AI-collaboration task(s).`);
|
|
431
|
+
return result;
|
|
432
|
+
}
|
|
433
|
+
/** Coerce a parsed payload into a valid CaseStudyResult (trims, drops empties). */
|
|
434
|
+
function normalizeCaseStudy(data, fallbackTitle) {
|
|
435
|
+
const obj = (data ?? {});
|
|
436
|
+
const strArr = (v) => Array.isArray(v)
|
|
437
|
+
? v
|
|
438
|
+
.map((x) => (typeof x === 'string' ? x.trim() : ''))
|
|
439
|
+
.filter((s) => s.length > 0)
|
|
440
|
+
: [];
|
|
441
|
+
const title = typeof obj.title === 'string' && obj.title.trim() ? obj.title.trim() : fallbackTitle;
|
|
442
|
+
const narrative = typeof obj.narrative === 'string' ? obj.narrative.trim() : '';
|
|
443
|
+
const caseStudy = {
|
|
444
|
+
title,
|
|
445
|
+
protagonist: typeof obj.protagonist === 'string' ? obj.protagonist.trim() : '',
|
|
446
|
+
situation: typeof obj.situation === 'string' ? obj.situation.trim() : '',
|
|
447
|
+
keyFacts: strArr(obj.keyFacts),
|
|
448
|
+
conflict: typeof obj.conflict === 'string' ? obj.conflict.trim() : '',
|
|
449
|
+
decisionPoints: strArr(obj.decisionPoints),
|
|
450
|
+
discussionQuestions: strArr(obj.discussionQuestions),
|
|
451
|
+
...(narrative ? { narrative } : {})
|
|
452
|
+
};
|
|
453
|
+
return { title, caseStudy, raw: '' };
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Generate a teaching case study from source material. A single AI call that
|
|
457
|
+
* must return strict JSON — no compile/review loop (unlike generateInk).
|
|
458
|
+
*/
|
|
459
|
+
async function generateCaseStudy(params, config, opts = {}) {
|
|
460
|
+
const log = opts.log ?? (() => { });
|
|
461
|
+
const depth = params.depth === 'idea' || params.depth === 'outline' ? params.depth : 'complete';
|
|
462
|
+
const tone = params.tone || 'professional';
|
|
463
|
+
log(`[1/1] Generating case study (${depth})…`);
|
|
464
|
+
const { data, raw } = await callForJson([
|
|
465
|
+
{ role: 'system', content: prompts_js_1.PROMPTS.caseStudySystem },
|
|
466
|
+
{
|
|
467
|
+
role: 'user',
|
|
468
|
+
content: prompts_js_1.PROMPTS.caseStudy
|
|
469
|
+
.replace('{{inputMode}}', params.inputMode)
|
|
470
|
+
.replace('{{inputText}}', params.inputText)
|
|
471
|
+
.replace('{{depth}}', depth)
|
|
472
|
+
.replace('{{tone}}', tone)
|
|
473
|
+
}
|
|
474
|
+
], config, opts);
|
|
475
|
+
const result = normalizeCaseStudy(data, 'Case Study');
|
|
476
|
+
result.raw = raw;
|
|
477
|
+
const cs = result.caseStudy;
|
|
478
|
+
if (!cs.situation && !cs.conflict && cs.keyFacts.length === 0) {
|
|
479
|
+
throw new Error('The model returned no usable case study content');
|
|
480
|
+
}
|
|
481
|
+
log('Generated case study.');
|
|
482
|
+
return result;
|
|
483
|
+
}
|
|
484
|
+
const PLAN_TARGETS = ['story', 'flashcards', 'quiz', 'summary', 'ai-task', 'case-study'];
|
|
485
|
+
/** Coerce a parsed payload into a valid PlanResult (validates targets, drops junk). */
|
|
486
|
+
function normalizePlan(data, fallbackTitle) {
|
|
487
|
+
const obj = (data ?? {});
|
|
488
|
+
const title = typeof obj.title === 'string' && obj.title.trim() ? obj.title.trim() : fallbackTitle;
|
|
489
|
+
const summary = typeof obj.summary === 'string' ? obj.summary.trim() : '';
|
|
490
|
+
const recommendations = [];
|
|
491
|
+
for (const r of Array.isArray(obj.recommendations) ? obj.recommendations : []) {
|
|
492
|
+
const rr = r;
|
|
493
|
+
const target = typeof rr?.target === 'string' ? rr.target.trim() : '';
|
|
494
|
+
if (!PLAN_TARGETS.includes(target))
|
|
495
|
+
continue;
|
|
496
|
+
const rationale = typeof rr.rationale === 'string' ? rr.rationale.trim() : '';
|
|
497
|
+
if (!rationale)
|
|
498
|
+
continue;
|
|
499
|
+
const rec = { target: target, rationale };
|
|
500
|
+
const depth = typeof rr.depth === 'string' ? rr.depth.trim() : '';
|
|
501
|
+
if (depth === 'idea' || depth === 'outline' || depth === 'complete')
|
|
502
|
+
rec.depth = depth;
|
|
503
|
+
const count = Number(rr.count);
|
|
504
|
+
if (Number.isInteger(count) && count > 0)
|
|
505
|
+
rec.count = count;
|
|
506
|
+
recommendations.push(rec);
|
|
507
|
+
}
|
|
508
|
+
return { title, summary, recommendations, raw: '' };
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Recommend a set of outputs for the source material. A single AI call that
|
|
512
|
+
* must return strict JSON — no compile/review loop (unlike generateInk).
|
|
513
|
+
*/
|
|
514
|
+
async function generatePlan(params, config, opts = {}) {
|
|
515
|
+
const log = opts.log ?? (() => { });
|
|
516
|
+
const tone = params.tone || 'professional';
|
|
517
|
+
log('[1/1] Planning recommendations…');
|
|
518
|
+
const { data, raw } = await callForJson([
|
|
519
|
+
{ role: 'system', content: prompts_js_1.PROMPTS.planSystem },
|
|
520
|
+
{
|
|
521
|
+
role: 'user',
|
|
522
|
+
content: prompts_js_1.PROMPTS.plan
|
|
523
|
+
.replace('{{inputMode}}', params.inputMode)
|
|
524
|
+
.replace('{{inputText}}', params.inputText)
|
|
525
|
+
.replace('{{tone}}', tone)
|
|
526
|
+
}
|
|
527
|
+
], config, opts);
|
|
528
|
+
const result = normalizePlan(data, 'Plan');
|
|
529
|
+
result.raw = raw;
|
|
530
|
+
if (result.recommendations.length === 0) {
|
|
531
|
+
throw new Error('The model returned no usable recommendations');
|
|
532
|
+
}
|
|
533
|
+
log(`Planned ${result.recommendations.length} output(s).`);
|
|
534
|
+
return result;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Generate every recommended output, returning the assembled artifacts. Used by
|
|
538
|
+
* the CLI (`plan --apply`) and the GUI ("generate all → project"). Reuses the
|
|
539
|
+
* per-target generators via the shared transport in `opts`.
|
|
540
|
+
*/
|
|
541
|
+
async function applyPlan(plan, params, config, opts = {}) {
|
|
542
|
+
const log = opts.log ?? (() => { });
|
|
543
|
+
const { inputMode, inputText, tone, branchingStyle, sceneImages } = params;
|
|
544
|
+
const artifacts = {};
|
|
545
|
+
const recs = plan.recommendations;
|
|
546
|
+
recs.forEach((rec, i) => {
|
|
547
|
+
log(`[${i + 1}/${recs.length}] Generating ${rec.target}…`);
|
|
548
|
+
});
|
|
549
|
+
for (const rec of recs) {
|
|
550
|
+
switch (rec.target) {
|
|
551
|
+
case 'story':
|
|
552
|
+
artifacts.story = await generateInk({ inputMode, inputText, storyLength: 'medium', tone, branchingStyle, sceneImages }, config, opts);
|
|
553
|
+
break;
|
|
554
|
+
case 'flashcards':
|
|
555
|
+
artifacts.flashcards = await generateFlashcards({ inputMode, inputText, cardCount: rec.count, tone }, config, opts);
|
|
556
|
+
break;
|
|
557
|
+
case 'quiz':
|
|
558
|
+
artifacts.quiz = await generateQuiz({ inputMode, inputText, questionCount: rec.count, tone }, config, opts);
|
|
559
|
+
break;
|
|
560
|
+
case 'summary':
|
|
561
|
+
artifacts.summary = await generateSummary({ inputMode, inputText, keyPointCount: rec.count, tone }, config, opts);
|
|
562
|
+
break;
|
|
563
|
+
case 'ai-task':
|
|
564
|
+
artifacts.aiTask = await generateAiTask({ inputMode, inputText, taskCount: rec.count, tone }, config, opts);
|
|
565
|
+
break;
|
|
566
|
+
case 'case-study':
|
|
567
|
+
artifacts.caseStudy = await generateCaseStudy({ inputMode, inputText, depth: rec.depth, tone }, config, opts);
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return artifacts;
|
|
572
|
+
}
|
|
573
|
+
// ─── AI refinement (per-item) ────────────────────────────────────────────────
|
|
574
|
+
/**
|
|
575
|
+
* Refine a single piece of text via the LLM. Returns the improved text (not
|
|
576
|
+
* JSON — just cleaned-up prose). Used by the inline "✨ Improve" buttons.
|
|
577
|
+
*/
|
|
578
|
+
async function refineText(text, instruction, config, opts = {}) {
|
|
579
|
+
const call = opts.call ?? ((messages) => (0, aiClient_js_1.callAI)(messages, config));
|
|
580
|
+
const result = await call([
|
|
581
|
+
{
|
|
582
|
+
role: 'system',
|
|
583
|
+
content: 'You improve educational content. Return ONLY the improved text — no preamble, no explanation, no markdown.'
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
role: 'user',
|
|
587
|
+
content: `${instruction}\n\nCurrent text:\n"""\n${text}\n"""\n\nReturn only the improved version.`
|
|
588
|
+
}
|
|
589
|
+
]);
|
|
590
|
+
return result.trim();
|
|
591
|
+
}
|