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.
@@ -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 inkSystem = prompts_js_1.PROMPTS.system + '\n\n' + prompts_js_1.PROMPTS.inkSyntaxRef;
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: prompts_js_1.PROMPTS.system },
89
+ { role: 'system', content: sp.system },
44
90
  {
45
91
  role: 'user',
46
- content: prompts_js_1.PROMPTS.outline
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
+ }