sagadeck 1.9.0 → 1.11.0

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.
Files changed (56) hide show
  1. package/README.md +9 -2
  2. package/SKILL.md +1 -1
  3. package/bin/sagadeck.js +1 -1
  4. package/docs/ANDAMENTO.md +302 -0
  5. package/docs/REFERENCIA.md +72 -4
  6. package/package.json +4 -4
  7. package/src/ai/context.js +7 -2
  8. package/src/ai/critique.js +8 -1
  9. package/src/ai/deck-ai.js +36 -12
  10. package/src/ai/document-visuals.js +6 -2
  11. package/src/ai/ia-check.js +13 -4
  12. package/src/ai/ia-config.js +4 -2
  13. package/src/ai/llm.js +12 -7
  14. package/src/ai/math-check.js +318 -0
  15. package/src/ai/quality.js +3 -3
  16. package/src/ai/units.js +85 -0
  17. package/src/api-client.js +42 -10
  18. package/src/build.js +1 -0
  19. package/src/code-highlight.js +19 -2
  20. package/src/code-language.js +13 -0
  21. package/src/fs-retry.js +2 -2
  22. package/src/lessons.js +72 -11
  23. package/src/mcp/server.js +1 -1
  24. package/src/package.js +4 -0
  25. package/src/runtime/api-core.js +122 -11
  26. package/src/runtime/api-ui.js +2 -1
  27. package/src/runtime/base.css +3 -0
  28. package/src/runtime/skins/aurora-dev.css +39 -0
  29. package/src/runtime/skins/terminal-pro.css +33 -0
  30. package/src/runtime/solution.js +131 -0
  31. package/src/studio/ai-chat-route.js +69 -7
  32. package/src/studio/conference-routes.js +44 -0
  33. package/src/studio/conference.js +144 -0
  34. package/src/studio/developer-starters.js +46 -0
  35. package/src/studio/generation-report.js +61 -1
  36. package/src/studio/generation-request.js +80 -0
  37. package/src/studio/ia-routes.js +1 -1
  38. package/src/studio/layout-samples.js +1 -1
  39. package/src/studio/library-routes.js +7 -0
  40. package/src/studio/public/about.js +68 -0
  41. package/src/studio/public/ai-settings.js +4 -4
  42. package/src/studio/public/app.js +14 -2
  43. package/src/studio/public/chat.js +11 -3
  44. package/src/studio/public/conference-ui.js +91 -0
  45. package/src/studio/public/developer-authoring.css +12 -0
  46. package/src/studio/public/developer-authoring.js +306 -0
  47. package/src/studio/public/generation-guide.js +42 -0
  48. package/src/studio/public/index.html +21 -0
  49. package/src/studio/public/library.css +36 -0
  50. package/src/studio/public/library.html +4 -1
  51. package/src/studio/public/library.js +118 -28
  52. package/src/studio/public/slide-form.js +17 -17
  53. package/src/studio/public/style.css +25 -0
  54. package/src/studio/public/ui-icons.js +1 -1
  55. package/src/studio/server.js +20 -8
  56. package/src/themes.js +48 -0
@@ -0,0 +1,131 @@
1
+ // Exercício resolvido com contas de verdade (layout solution): o número do passo nasce da fórmula, não do texto.
2
+ // Os dados com símbolo e valor viram variáveis; cada passo com `fn` é calculado na ordem (um usa os anteriores pelo
3
+ // nome) com o compilador de src/runtime/formula.js (sem eval), e `{{nome}}` ou `{{expressão:casas}}` no LaTeX ou no
4
+ // texto vira o valor. O mesmo arquivo roda no motor (prévia, PDF) e na apresentação (interactive: o dado vira
5
+ // controle e os passos recalculam). Precisa de SagaFormula carregado antes.
6
+ (function (root) {
7
+ const ID = /^[A-Za-z][A-Za-z0-9_]*$/;
8
+ // "L_1" → L1 · "\rho" → rho · "v_{0}" → v0 · "h_f" → hf
9
+ function varName(symbol) {
10
+ const n = String(symbol ?? "").replace(/\\(?:mathrm|mathit|text|bar|vec|hat)\b/g, "").replace(/[\\_{}\s]/g, "");
11
+ return ID.test(n) ? n : "";
12
+ }
13
+ // "0,05" → 0.05 · "1.000" → 1000 · "9.81" → 9.81 · "1{,}5" → 1.5 · "2,5e-3" → 0.0025; texto → null
14
+ function parseNum(v) {
15
+ if (typeof v === "number") return Number.isFinite(v) ? v : null;
16
+ let t = String(v ?? "").replace(/\{,\}/g, ",").replace(/\\,|[\s ]/g, "");
17
+ if (/^-?\d{1,3}(\.\d{3})+(,\d+)?$/.test(t)) t = t.replace(/\./g, "");
18
+ t = t.replace(",", ".");
19
+ return /^-?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i.test(t) ? Number(t) : null;
20
+ }
21
+ // casas que a pessoa escreveu ("0,026" → 3): o dado aparece como foi escrito
22
+ const writtenDecimals = (v) => { const m = String(v).replace(/\{,\}/g, ",").match(/[.,](\d+)$/); return m && !/^\d{1,3}(\.\d{3})+$/.test(String(v)) ? m[1].length : 0; };
23
+
24
+ // o modelo: variáveis (dados) e contas (passos e resposta com fn), na ordem
25
+ function model(s) {
26
+ const vars = [];
27
+ for (const g of [].concat(s.givens || s.data || [])) {
28
+ if (!g || typeof g !== "object") continue;
29
+ const name = g.name ? String(g.name) : varName(g.symbol), value = parseNum(g.value);
30
+ if (!ID.test(name) || value == null || vars.some((v) => v.name === name)) continue;
31
+ const min = g.min != null ? Number(g.min) : value > 0 ? value / 10 : value < 0 ? value * 10 : -10;
32
+ const max = g.max != null ? Number(g.max) : value > 0 ? value * 10 : value < 0 ? value / 10 : 10;
33
+ const decimals = g.decimals != null ? Number(g.decimals) : writtenDecimals(g.value);
34
+ // passo do controle nas casas do dado (1000 anda de 1 em 1, 0,026 de 0,001), sem passar de ~20 mil posições
35
+ const fine = 10 ** -decimals, range = Math.abs(max - min) || 1;
36
+ const step = g.step != null ? Number(g.step) : range / fine <= 20000 ? fine : 10 ** Math.floor(Math.log10(range / 1000));
37
+ // mínimo e máximo na grade do passo: o controle só para em múltiplos do mínimo, e 0,01 virava 0,011
38
+ const snap = (x, f) => Number((f(x / step) * step).toPrecision(12));
39
+ vars.push({ name, value, decimals, min: snap(min, Math.floor), max: snap(max, Math.ceil), step, label: String(g.label || g.symbol || name) });
40
+ }
41
+ const calcs = [];
42
+ const add = (o, key, label) => {
43
+ if (!o || typeof o !== "object" || o.fn == null || o.fn === "") return;
44
+ const name = o.name && ID.test(String(o.name)) ? String(o.name) : `_${key}`;
45
+ calcs.push({ key, label, name, fn: String(o.fn), ...(o.decimals != null ? { decimals: Number(o.decimals) } : {}) });
46
+ };
47
+ (s.steps || []).forEach((st, i) => add(st, `p${i}`, `passo ${i + 1}`));
48
+ add(s.answer, "resp", "resposta");
49
+ return { vars, calcs };
50
+ }
51
+
52
+ // calcula tudo na ordem; cada conta devolve { value, error } (erro em português, para o slide e para a IA)
53
+ function evaluate(m, values = {}) {
54
+ const env = Object.create(null);
55
+ for (const v of m.vars) env[v.name] = Number.isFinite(values[v.name]) ? values[v.name] : v.value;
56
+ const known = [...m.vars.map((v) => v.name), ...m.calcs.map((c) => c.name)];
57
+ const out = {};
58
+ for (const c of m.calcs) {
59
+ let value = null, error = "";
60
+ try {
61
+ const f = root.SagaFormula.compile(c.fn, known);
62
+ const missing = f.params.filter((p) => !(p in env));
63
+ if (missing.length) error = `a fórmula usa ${missing.map((p) => `"${p}"`).join(", ")}, que não está nos dados (símbolo e valor) nem nos passos anteriores (name)`;
64
+ else { value = f.eval(env); if (value == null) error = "a conta não deu um número (divisão por zero ou raiz de negativo?)"; }
65
+ } catch (e) { error = `fórmula "${c.fn}": ${e.message}`; }
66
+ if (value != null) env[c.name] = value;
67
+ out[c.key] = { value, error };
68
+ }
69
+ return { env, results: out };
70
+ }
71
+ // valor de {{expressão}}: nome, ou conta com os nomes já calculados
72
+ function evalExpr(expr, env) {
73
+ if (expr in env) return env[expr];
74
+ try { const f = root.SagaFormula.compile(expr, Object.keys(env)); return f.params.every((p) => p in env) ? f.eval(env) : null; } catch { return null; }
75
+ }
76
+
77
+ // número para a tela: casas pedidas, ou 4 algarismos (inteiro a partir de 1000); 10 elevado se for extremo.
78
+ // tex: "1\,430{,}77" · math: "1 430,77" (o mesmo, em texto, para trocar dentro da fórmula) · text: "1.430,77"
79
+ const SUP = { "-": "⁻", 0: "⁰", 1: "¹", 2: "²", 3: "³", 4: "⁴", 5: "⁵", 6: "⁶", 7: "⁷", 8: "⁸", 9: "⁹" };
80
+ function format(v, decimals, mode = "tex") {
81
+ if (v == null || !Number.isFinite(v)) return mode === "tex" ? "\\text{—}" : "—";
82
+ const comma = mode === "tex" ? "{,}" : ",", group = mode === "tex" ? "\\," : mode === "math" ? " " : ".";
83
+ const a = Math.abs(v);
84
+ if (v !== 0 && (a < 1e-3 || a >= 1e7) && !(decimals != null && a >= 1e7)) {
85
+ const [mant, e] = v.toExponential(decimals != null ? Math.min(decimals, 4) : 2).split("e");
86
+ const mm = String(Number(mant)).replace(".", comma);
87
+ return mode === "tex" ? `${mm}\\times10^{${Number(e)}}` : `${mm} × 10${String(Number(e)).split("").map((c) => SUP[c] ?? c).join("")}`;
88
+ }
89
+ const s = decimals != null ? v.toFixed(decimals) : a >= 1000 ? v.toFixed(0) : String(Number(v.toPrecision(4)));
90
+ const [int, frac] = s.replace(/^-0(\.0*)?$/, "0$1").split(".");
91
+ return `${int.replace(/\B(?=(\d{3})+(?!\d))/g, group)}${frac ? `${comma}${frac}` : ""}`;
92
+ }
93
+
94
+ // na apresentação: o dado vira controle; mexeu, recalcula e troca os números marcados (data-solv)
95
+ function mount(doc) {
96
+ doc.querySelectorAll("[data-solution]:not([data-solution-ready])").forEach((el) => {
97
+ el.dataset.solutionReady = "1";
98
+ const m = JSON.parse(el.querySelector(".sol-model").textContent);
99
+ const inputs = [...el.querySelectorAll("[data-solv-in]")];
100
+ // a cópia em MathML (para leitor de tela) não acompanha o número novo: quem lê passa a ser o próprio desenho
101
+ el.querySelectorAll(".katex").forEach((k) => {
102
+ if (!k.querySelector("[data-solv]")) return;
103
+ k.querySelector(".katex-mathml")?.setAttribute("hidden", "");
104
+ k.querySelector(".katex-html")?.removeAttribute("aria-hidden");
105
+ });
106
+ const redraw = () => {
107
+ const values = Object.fromEntries(inputs.map((i) => [i.dataset.solvIn, Number(i.value)]));
108
+ const { env } = evaluate(m, values);
109
+ el.querySelectorAll("[data-solv]").forEach((n) => {
110
+ const mk = m.marks[+n.dataset.solv];
111
+ if (mk) n.textContent = format(evalExpr(mk.expr, env), mk.decimals, n.dataset.solvMode === "text" ? "text" : "math");
112
+ });
113
+ const slide = el.closest(".slide");
114
+ if (slide && root.SagadeckFit?.fitSteps) root.requestAnimationFrame(() => root.SagadeckFit.fitSteps(el));
115
+ };
116
+ const stop = (e) => e.stopPropagation(); // setas e cliques no controle não trocam de slide nem de passo
117
+ inputs.forEach((i) => { ["keydown", "pointerdown", "click"].forEach((ev) => i.addEventListener(ev, stop)); i.addEventListener("input", redraw); });
118
+ const reset = el.querySelector("[data-solv-reset]");
119
+ if (reset) {
120
+ ["keydown", "pointerdown", "click"].forEach((ev) => reset.addEventListener(ev, stop));
121
+ reset.addEventListener("click", () => { inputs.forEach((i) => { i.value = String(m.vars.find((v) => v.name === i.dataset.solvIn)?.value ?? i.value); }); redraw(); });
122
+ }
123
+ });
124
+ }
125
+
126
+ root.SagaSolution = { model, evaluate, evalExpr, format, varName, parseNum, mount };
127
+ if (typeof document !== "undefined") {
128
+ const go = () => mount(document);
129
+ document.readyState === "loading" ? document.addEventListener("DOMContentLoaded", go) : go();
130
+ }
131
+ })(typeof globalThis !== "undefined" ? globalThis : window);
@@ -8,6 +8,7 @@ import { loadSpec } from "../build.js";
8
8
  import { writeRecording } from "../api-client.js";
9
9
  import { editDeck, coverDocumentVisuals, checkNumbers } from "../ai/deck-ai.js";
10
10
  import { reviewExperience } from "../ai/quality.js";
11
+ import { checkMath, hasMath, mathSignature, mathWarning } from "../ai/math-check.js";
11
12
  import { maybeResearch } from "../research/chat-research.js";
12
13
  import { mapDataPrompt, runMapData, mapDataInstruction } from "../ai/map-data.js";
13
14
  import { chatErrorResult } from "./errors.js";
@@ -17,6 +18,8 @@ import { critiqueMaterials, critiqueMarkdown, saveCritique, loadCritique, critiq
17
18
  import { styleAction } from "./style-routes.js";
18
19
  import * as Project from "./project.js";
19
20
  import { carryVisualEdits } from "./visual-keys.js";
21
+ import { conferenceItems, conferencePrompt, recordMath, loadGenReport } from "./conference.js";
22
+ import { saveGenerationRequest } from "./generation-request.js";
20
23
 
21
24
  // Arquivos que a IA pode pedir para ver (ver: […]): imagens DENTRO da pasta da apresentação (os recortes e as
22
25
  // páginas do material ficam em contexto/visuais/). Caminho que sai da pasta, que não existe ou que não é imagem fica
@@ -105,8 +108,23 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
105
108
  res.on('close', onClose);
106
109
  // Capture o arquivo da tarefa: abrir outro deck não redireciona a escrita.
107
110
  const taskWorkspace = { ...owner, spec: structuredClone(owner.spec) };
111
+ const originFile = owner.file, originPreview = owner.preview;
112
+ let materialized = null;
108
113
  const prompt = body.message || "";
109
114
  const spec = body.spec || taskWorkspace.spec;
115
+ // Conversar com uma prévia não cria arquivo. Edição/imagem ou documento binário precisam da cópia pessoal.
116
+ const imageOptionsFor = (deck = spec) => {
117
+ const options = imageOptions(taskWorkspace, withBase(taskWorkspace, deck));
118
+ if (originPreview && !originFile && taskWorkspace.file && !materialized) {
119
+ materialized = { id: taskWorkspace.library.idOf(taskWorkspace.file), title: taskWorkspace.spec.title,
120
+ topic: originPreview.topic, file: taskWorkspace.file };
121
+ if (owner.file === originFile && owner.preview === originPreview) {
122
+ owner.file = taskWorkspace.file;
123
+ owner.preview = null;
124
+ }
125
+ }
126
+ return materialized ? { ...options, baseDir: path.dirname(taskWorkspace.file) } : options;
127
+ };
110
128
  const issues = body.issues || [];
111
129
  // a IA vê (e testa) os slides api no ambiente atual, que pode ser o embutido "ensaio"
112
130
  if (!apiBlocked(req) && (spec.slides || []).some((sl) => sl && sl.layout === "api")) await ensureEnsaio();
@@ -128,8 +146,12 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
128
146
  const target = typeof body.targetSlide === "number" ? body.targetSlide : null;
129
147
  const visuals = await lookAt(withBase(taskWorkspace, spec), target, prompt, emit);
130
148
  // anexos: imagens vão como visão; documentos (id do /api/ai/context) vão como texto
131
- let materials = await prepareDocumentMaterials(takeMaterials(taskWorkspace, (Array.isArray(body.attachments) ? body.attachments : [])
132
- .filter((a) => a && typeof a === "object" && a.type === "doc").map((a) => a.id)), path.dirname(taskWorkspace.file), {signal,onProgress:emit});
149
+ const attached = takeMaterials(taskWorkspace, (Array.isArray(body.attachments) ? body.attachments : [])
150
+ .filter((a) => a && typeof a === "object" && a.type === "doc").map((a) => a.id));
151
+ let materials = attached;
152
+ if (!taskWorkspace.file && taskWorkspace.preview && materials.some(m => m.bytes)) imageOptionsFor();
153
+ const materialDir = taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? path.dirname(taskWorkspace.file) : null;
154
+ if (materialDir) materials = await prepareDocumentMaterials(materials, materialDir, {signal,onProgress:emit});
133
155
  for (const [i, url] of (Array.isArray(body.attachments) ? body.attachments : []).entries()) {
134
156
  if (typeof url === "string" && url.startsWith("data:image/")) visuals.push({ label: `imagem colada pelo usuário ${i + 1}`, dataUrl: url });
135
157
  }
@@ -148,13 +170,15 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
148
170
  materials = chatResearch.materials;
149
171
  if (chatResearch.report?.fontes?.length) linkActions.push(`Pesquisa na web: ${chatResearch.report.fontes.length} fonte(s) (${chatResearch.report.fontes.map((f) => f.site).join(", ")})`);
150
172
  const firstInstruction = chatResearch.instruction ? `${chatResearch.instruction}\n${prompt}` : prompt;
173
+ // o que o painel Conferência ainda tem pendente (o ignorado não vai): a IA sabe o que falta sem reler avisos velhos
174
+ const pending = materialDir ? conferencePrompt(conferenceItems(loadGenReport(materialDir), spec, { dir: materialDir })) : "";
151
175
  const editOpts = (extra = {}) => ({
152
176
  spec: withBase(taskWorkspace, spec), signal,
153
177
  instruction: firstInstruction,
154
178
  targetSlide: target,
155
179
  issues,
156
180
  images: true, // a IA decide (regra no prompt: só quando pedirem ou aceitarem)
157
- imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, spec)),
181
+ imageOptions: () => imageOptionsFor(),
158
182
  history,
159
183
  onProgress: emit,
160
184
  visuals,
@@ -167,6 +191,7 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
167
191
  styles: taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? { list: taskWorkspace.library.listStyles(), current: taskWorkspace.spec?.style?.name || null } : null,
168
192
  // dados para o mapa (bloco mapa:): só com a pasta da apresentação, que é onde os arquivos ficam
169
193
  mapData: P ? mapDataPrompt() : null,
194
+ pending,
170
195
  ...extra,
171
196
  });
172
197
  result = await editDeck(editOpts());
@@ -212,6 +237,7 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
212
237
  result.actions = [...(before.actions || []), ...r.feitos.map((f) => `Mapa: gravei ${f.arquivo} (${f.linhas} ${f.tipo === "contorno" ? "contorno" : "itens"}${f.fonte ? `; fonte: ${f.fonte}` : ""})`), ...r.falhas.map((x) => `Mapa: não deu (${x})`), ...(result.actions || [])];
213
238
  }
214
239
  if (result.geo?.length) result = { ...result, geo: undefined, talk: true, reply: P ? `${result.reply}\n(Já busquei os dados duas vezes; me diga o que ainda falta.)` : "Os dados do mapa ficam na pasta da apresentação: abra-a pela biblioteca e peça de novo." };
240
+ if (!taskWorkspace.file && taskWorkspace.preview && !result.talk && !result.variants) imageOptionsFor(result.spec);
215
241
  // documento anexado NESTA mensagem e a resposta montou/reescreveu a maior parte do deck (criar a apresentação
216
242
  // do paper pelo chat): a mesma conferência de cobertura da geração. Pedido pontual ("use a figura 3 no slide
217
243
  // 5") não dispara: mexe em poucos slides.
@@ -220,15 +246,17 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
220
246
  const rewritten = Array.isArray(result.changed) ? result.changed.length : 0;
221
247
  if (rewritten >= Math.max(5, result.spec.slides.length / 2)) {
222
248
  const covered = await coverDocumentVisuals(result.spec, { materials, briefing: prompt, say: (s) => emit({ phase: "step", text: s }),
223
- edit: (o) => editDeck({ signal, images: true, imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, spec)), onProgress: emit, materials, drawCheck: diagramCheck, ...o }) });
249
+ edit: (o) => editDeck({ signal, images: true, imageOptions: () => imageOptionsFor(), onProgress: emit, materials, drawCheck: diagramCheck, ...o }) });
224
250
  signal.throwIfAborted();
225
251
  if (covered.spec !== result.spec) { result.spec = covered.spec; result.actions = [...(result.actions || []), ...covered.actions]; }
226
252
  if (covered.coverage?.missing.length) result.actions = [...(result.actions || []), `Sem slide (figuras/tabelas do material): ${covered.coverage.missing.join("; ")}`];
227
253
  const checked = await checkNumbers(result.spec, { materials, say: (s) => emit({ phase: "step", text: s }),
228
- edit: (o) => editDeck({ signal, images: true, imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, spec)), onProgress: emit, materials, drawCheck: diagramCheck, ...o }) });
254
+ edit: (o) => editDeck({ signal, images: true, imageOptions: () => imageOptionsFor(), onProgress: emit, materials, drawCheck: diagramCheck, ...o }) });
229
255
  signal.throwIfAborted();
230
256
  if (checked.spec !== result.spec) { result.spec = checked.spec; result.actions = [...(result.actions || []), ...checked.actions]; }
231
257
  if (checked.facts?.unsupported.length) result.actions = [...(result.actions || []), `Números sem base no material: ${checked.facts.unsupported.map((b) => `${b.number} (slide ${b.slide})`).join(", ")}`];
258
+ // criou a apresentação do documento pelo chat: o pedido fica gravado como na geração (.sagadeck/geracao.json)
259
+ if (materialDir) try { saveGenerationRequest(materialDir, { origem: "chat", pedido: { briefing: prompt }, materials: attached, spec: result.spec, research: chatResearch.report }); } catch { /* o registro não derruba a resposta */ }
232
260
  }
233
261
  }
234
262
  if (linkActions.length) result.actions = [...linkActions, ...(result.actions || [])];
@@ -264,12 +292,46 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
264
292
  emit({ phase: "test", text: reports.every((x) => x.ok) ? "Os testes passaram; conferindo…" : "Corrigindo com base no resultado…" });
265
293
  const next = await editDeck({
266
294
  spec: withBase(taskWorkspace, result.spec), signal, instruction, targetSlide: target, images: false,
267
- imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, result.spec)), history: convo, onProgress: emit, apiContext: apiContextFor(req, taskWorkspace),
295
+ imageOptions: () => imageOptionsFor(result.spec), history: convo, onProgress: emit, apiContext: apiContextFor(req, taskWorkspace),
268
296
  drawCheck: diagramCheck, runCommand: commandRunner(req, emit, body, taskWorkspace, signal),
269
297
  });
270
298
  convo.push({ role: "user", text: instruction });
271
299
  result = { ...next, actions: [...result.actions, ...(next.actions || [])], spec: next.spec };
272
300
  }
301
+ // Contas: a IA pediu a conferência (contas: [n] ou true) ou mexeu nos números de um slide com conta. Ela escreve
302
+ // cada resultado como fórmula, o código calcula (src/ai/math-check.js) e o que não bate volta para ela corrigir.
303
+ if (!result.talk && !result.transform && !result.style && !result.variants && result.spec?.slides) {
304
+ const all = result.spec.slides.map((_, i) => i);
305
+ // contas pedidas pelo Studio (Corrigir com IA no painel Conferência): conferidas depois da resposta, sempre
306
+ const fromPanel = [].concat(body.contas || []).map(Number).filter((n) => Number.isInteger(n) && n >= 1 && n <= all.length).map((n) => n - 1);
307
+ const asked = result.contas === "all" ? all : [...new Set([...(result.contas || []), ...fromPanel])];
308
+ const before = new Map((spec.slides || []).map((s) => [s?.uid, s]));
309
+ const touched = (result.changed || []).filter((i) => { const s = result.spec.slides[i]; const old = s?.uid ? before.get(s.uid) : spec.slides?.[i]; return hasMath(s) && mathSignature(s) !== mathSignature(old || {}); });
310
+ const only = [...new Set([...asked, ...touched])];
311
+ if (only.length) {
312
+ emit({ phase: "step", text: "Conferindo as contas…" });
313
+ const counted = await checkMath(result.spec, { materials, only, signal, say: (t) => emit({ phase: "step", text: t }),
314
+ // a conversa vai junto: o que a pessoa pediu com todas as letras ("pode corrigir o material") vale
315
+ edit: (o) => editDeck({ signal, images: false, onProgress: emit, materials, drawCheck: diagramCheck, history: [...history, { role: "user", text: prompt }], ...o, spec: withBase(taskWorkspace, o.spec) }) });
316
+ signal.throwIfAborted();
317
+ if (counted.spec !== result.spec) {
318
+ const fixedChanged = counted.spec.slides.map((s, i) => (JSON.stringify(s) !== JSON.stringify(result.spec.slides[i]) ? i : -1)).filter((i) => i >= 0);
319
+ result = { ...result, spec: counted.spec, changed: [...new Set([...(result.changed || []), ...fixedChanged])].sort((a, b) => a - b) };
320
+ }
321
+ const m = counted.math;
322
+ result.math = m;
323
+ // o painel Conferência passa a mostrar o resultado desta conferência para esses slides
324
+ if (deckDir) try { recordMath(deckDir, only, m); } catch (e) { console.error("[Studio] conferência:", e.message); }
325
+ result.actions = [...(result.actions || []), ...counted.actions,
326
+ m?.failed ? `Conferência das contas falhou: ${m.failed}`
327
+ : m ? `Contas conferidas: ${m.checked + (m.fixed?.length || 0)} ok${m.fixed?.length ? ` (${m.fixed.length} corrigida(s): ${m.fixed.map((d) => `slide ${d.slide}, ${d.afirmado} ${d.unidade}`.trim()).join("; ")})` : ""}${m.unchecked?.length ? `, ${m.unchecked.length} sem como calcular` : ""}` : "Nenhuma conta para conferir nesses slides",
328
+ ...(mathWarning(m) ? [mathWarning(m)] : [])];
329
+ if (asked.length && result.reply === "Vou conferir as contas.") result.reply = m?.divergent?.length ? "Conferi as contas; algumas ainda não batem (veja abaixo)." : m?.fixed?.length ? "Conferi as contas e corrigi o que não batia." : "Conferi as contas: estão certas.";
330
+ if (m?.reply) result.reply = `${result.reply}
331
+
332
+ ${m.reply}`;
333
+ }
334
+ }
273
335
  result.mode = "llm";
274
336
  } catch (e) {
275
337
  if (signal.aborted) return { reply: "Parado a pedido. Nenhuma resposta pendente foi aplicada.", spec, actions: [], talk: true, mode: "cancelled" };
@@ -286,7 +348,7 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
286
348
  persist(taskWorkspace);
287
349
  if (owner.file === taskWorkspace.file) owner.spec = taskWorkspace.spec;
288
350
  return { ...result, spec: taskWorkspace.spec, conflicts: merged.conflicts, kept: merged.kept };
289
- }, signal)); } finally { owner.aiChats.delete(requestId); res.off("close", onClose); }
351
+ }, signal).then(result => materialized ? { ...result, materialized } : result)); } finally { owner.aiChats.delete(requestId); res.off("close", onClose); }
290
352
  return true;
291
353
  }
292
354
  return false;
@@ -0,0 +1,44 @@
1
+ // Rotas do painel Revisar › Conferência (src/studio/conference.js): a lista, a decisão por item e o Conferir de novo.
2
+ import path from "node:path";
3
+ import { storedDocumentMaterials } from "../ai/document-materials.js";
4
+ import { llmAvailable } from "../ai/llm.js";
5
+ import * as Project from "./project.js";
6
+ import { conferenceItems, decideItem, recheckDeck, loadGenReport } from "./conference.js";
7
+
8
+ const send = (res, data, status = 200) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(data)); };
9
+
10
+ // o material da apresentação: o documento guardado na criação ou no chat; sem ele, os arquivos de contexto/
11
+ export async function deckMaterials(file) {
12
+ const dir = path.dirname(file);
13
+ let materials = storedDocumentMaterials(dir);
14
+ if (!materials.length) { try { materials = await Project.contextMaterials(Project.projectOf(file)); } catch { materials = []; } }
15
+ return materials;
16
+ }
17
+
18
+ export async function conferenceRoutes({ req, res, pathname, W, readJSON, isBundledTemplate }) {
19
+ if (!pathname.startsWith("/api/conferencia")) return false;
20
+ const dir = W.file && !isBundledTemplate(W.file) ? path.dirname(W.file) : null;
21
+ const list = (report = loadGenReport(dir)) => ({ items: dir ? conferenceItems(report, W.spec, { dir }) : [], conferidoEm: report?.conferidoEm || null, salvo: !!dir });
22
+ if (pathname === "/api/conferencia" && req.method === "GET") { send(res, list()); return true; }
23
+ if (pathname === "/api/conferencia/item" && req.method === "POST") {
24
+ const body = await readJSON(req);
25
+ try { decideItem(dir, String(body.id || ""), String(body.status || "")); send(res, { ok: true, ...list() }); }
26
+ catch (e) { send(res, { error: e.message }, 400); }
27
+ return true;
28
+ }
29
+ if (pathname === "/api/conferencia/conferir" && req.method === "POST") {
30
+ if (!dir) { send(res, { error: "A apresentação precisa estar salva na biblioteca." }, 400); return true; }
31
+ const notes = [];
32
+ try {
33
+ const materials = await deckMaterials(W.file);
34
+ const ai = await llmAvailable({ force: true });
35
+ // sem IA: números e figuras se conferem do mesmo jeito; as contas precisam do modelo para escrever as fórmulas
36
+ const r = await recheckDeck(dir, W.spec, materials, ai ? {} : { complete: async () => { throw new Error("a IA está desligada"); } });
37
+ notes.push(...r.notes);
38
+ if (r.unchecked) notes.push(`${r.unchecked} conta(s) sem como calcular.`);
39
+ send(res, { ok: true, notes, ...list(r.report) });
40
+ } catch (e) { send(res, { error: e.message }, 500); }
41
+ return true;
42
+ }
43
+ return false;
44
+ }
@@ -0,0 +1,144 @@
1
+ // Painel Revisar › Conferência: junta num lugar o que as checagens automáticas acharam no deck aberto (contas que não
2
+ // batem, contas do material que não fecham, números sem base no material, avisos da revisão visual, figuras do
3
+ // material sem slide, slides que a IA calculou ou incluiu) e o que a pessoa decidiu sobre cada um.
4
+ // Antes os achados ficavam espalhados (relatório da criação, mensagem do chat, selo na miniatura) e a professora
5
+ // entregou a aula com a conta errada: o aviso existia, mas não tinha lugar nem botão.
6
+ // A decisão (ignorado: "está certo assim"; enviado: o pedido de correção já foi ao chat) fica no relatório
7
+ // (.sagadeck/avaliacao-geracao.json, campo `decisoes`): o item some do painel e dos próximos pedidos à IA.
8
+ import { locate, checkMath } from "../ai/math-check.js";
9
+ import { unsupportedNumbers, uncoveredVisuals } from "../ai/document-visuals.js";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import YAML from "yaml";
13
+ import { loadGenReport, writeGenReport, slideMark, slideMarks } from "./generation-report.js";
14
+
15
+ // Como os slides eram quando os avisos da revisão visual nasceram: as marcas do relatório; relatório antigo, sem
16
+ // marcas, usa a versão mais antiga guardada do deck (a primeira gravação depois da criação).
17
+ export function reportBaseline(report, dir) {
18
+ if (report?.slideMarks) return report.slideMarks;
19
+ if (!dir) return null;
20
+ try {
21
+ const vdir = path.join(dir, ".sagadeck", "versoes");
22
+ const first = fs.readdirSync(vdir).filter((f) => /\.ya?ml$/i.test(f)).sort()[0];
23
+ return first ? slideMarks(YAML.parse(fs.readFileSync(path.join(vdir, first), "utf8"))) : null;
24
+ } catch { return null; }
25
+ }
26
+
27
+ export const CONF_KINDS = {
28
+ conta: "Conta que não bate",
29
+ material: "Conta do material que não fecha",
30
+ numero: "Número sem base no material",
31
+ visual: "Aviso da revisão visual",
32
+ figura: "Figura do material sem slide",
33
+ derivado: "Calculado pela IA",
34
+ proprio: "Incluído pela IA",
35
+ };
36
+
37
+ // id estável para o mesmo achado (o painel e o relatório falam dele pelo id)
38
+ const hash = (s) => { let h = 5381; for (const c of String(s)) h = ((h << 5) + h + c.codePointAt(0)) >>> 0; return h.toString(36); };
39
+ const fmt = (x) => (typeof x === "number" ? Number(x.toPrecision(4)).toLocaleString("pt-BR", { maximumFractionDigits: 6 }) : String(x ?? ""));
40
+ const unit = (d) => (d.unidade ? ` ${d.unidade}` : "");
41
+
42
+ // Os itens pendentes: relatório + origem dos slides do deck atual, menos o que a pessoa já decidiu. Achado que o
43
+ // deck já não tem (o número saiu do slide, o slide não existe mais) não aparece: foi resolvido.
44
+ export function conferenceItems(report, spec, { dir = null } = {}) {
45
+ const slides = spec?.slides || [];
46
+ const baseline = report?.quality?.issues?.length ? reportBaseline(report, dir) : null;
47
+ // o slide mudou depois do aviso (a IA ou a pessoa já mexeu nele): o aviso pode estar resolvido
48
+ const changed = (n) => { if (!baseline) return false; const s = slides[n - 1], was = baseline[s?.uid || `#${n}`]; return was != null && was !== slideMark(s); };
49
+ const decided = report?.decisoes || {};
50
+ const out = [];
51
+ const still = (n, number) => slides[n - 1] && locate(slides[n - 1], number).length > 0;
52
+ for (const d of report?.math?.divergent || []) {
53
+ if (!still(d.slide, d.afirmado)) continue;
54
+ out.push({ id: `conta:${d.slide}:${d.afirmado}`, kind: "conta", slide: d.slide, contas: [d.slide],
55
+ text: `${d.oQue ? `${d.oQue}: ` : ""}o slide diz ${d.afirmado}${unit(d)}; refazendo a conta, dá ${fmt(d.calculado)}${unit(d)}.`,
56
+ fix: `Confira a conta do slide ${d.slide}${d.oQue ? ` (${d.oQue})` : ""}: o slide diz ${d.afirmado}${unit(d)}; refazendo a partir dos dados, dá ${fmt(d.calculado)}${unit(d)}${d.fn ? ` [${d.fn} com ${JSON.stringify(d.vars || {})}]` : ""}. Se o slide estiver errado, corrija o resultado em todos os campos onde ele aparece e o que depende dele (passos, resposta, notes, gráficos e textos de outros slides); se o slide estiver certo, diga por quê.` });
57
+ }
58
+ for (const d of report?.math?.material || []) {
59
+ if (!still(d.slide, d.afirmado)) continue;
60
+ out.push({ id: `material:${d.slide}:${d.afirmado}`, kind: "material", slide: d.slide, contas: [d.slide],
61
+ text: `${d.oQue ? `${d.oQue}: ` : ""}o material traz ${d.afirmado}${unit(d)}, mas a conta com os dados dele dá ${fmt(d.calculado)}${unit(d)}. Confira com quem escreveu.`,
62
+ fix: `No slide ${d.slide}, o número ${d.afirmado}${unit(d)} está no material, mas não fecha com os dados dele: refazendo, dá ${fmt(d.calculado)}${unit(d)}. Siga a autoria do deck: em \`autoria: autor\` (ou sem autoria definida), mantenha o número do material e explique a divergência nas notes ("Confira: o material traz X; refazendo a conta, dá Y"); em \`autoria: livre\`, corrija no slide e diga nas notes que o material traz outro valor.` });
63
+ }
64
+ for (const b of report?.facts?.unsupported || []) {
65
+ if (!still(b.slide, b.number)) continue;
66
+ out.push({ id: `numero:${b.slide}:${b.number}`, kind: "numero", slide: b.slide,
67
+ text: `${b.number} aparece no slide, mas não está escrito no material.`,
68
+ fix: `O número ${b.number} do slide ${b.slide} não está no material anexado. Se for valor do material lido errado (de um gráfico, por exemplo), troque pelo que o texto ou a tabela do material traz; se for conta sua a partir do material, deixe só com a conta no slide ou nas notes; se não houver base, tire o número (reescreva a frase sem ele). Não mexa em mais nada.` });
69
+ }
70
+ for (const i of report?.quality?.issues || []) {
71
+ if (!slides[i.slide - 1]) continue;
72
+ const text = String(i.text || "").trim(), stale = changed(i.slide);
73
+ out.push({ id: `visual:${i.slide}:${hash(text)}`, kind: "visual", slide: i.slide, ...(stale ? { stale: true } : {}),
74
+ text: stale ? `O slide mudou depois deste aviso; confira se ainda vale: ${text}` : text,
75
+ fix: `${stale ? "O slide mudou depois deste aviso da revisão visual; olhe o slide e, se o problema ainda existir, corrija" : "Corrija"} no slide ${i.slide} este aviso: ${text}` });
76
+ }
77
+ for (const caption of report?.coverage?.missing || []) {
78
+ out.push({ id: `figura:${hash(caption)}`, kind: "figura", slide: null, text: String(caption),
79
+ fix: `Esta figura/tabela do material não aparece em nenhum slide: ${caption}. Encaixe-a no ponto da narrativa em que o assunto dela é discutido (no slide que já fala dele ou num slide novo logo ao lado), com a figura dominante e 1–3 frases de leitura do que ela mostra. Não mexa no resto do deck.` });
80
+ }
81
+ slides.forEach((s, k) => {
82
+ const n = k + 1, note = s?.provenanceNote ? String(s.provenanceNote) : "";
83
+ if (s?.provenance === "derivado") out.push({ id: `derivado:${s.uid || n}`, kind: "derivado", slide: n, contas: [n],
84
+ text: note || "Feito pela IA a partir dos números do material: confira as contas.",
85
+ fix: `O slide ${n} foi feito pela IA a partir dos números do material${note ? ` (${note})` : ""}. Refaça as contas dele a partir dos dados e corrija o que não bater, em todos os campos onde o número aparece.` });
86
+ else if (s?.provenance === "proprio") out.push({ id: `proprio:${s.uid || n}`, kind: "proprio", slide: n,
87
+ text: note || "Incluído pela IA: não está no material.",
88
+ fix: `O slide ${n} foi incluído pela IA e não está no material${note ? ` (${note})` : ""}. Confira se o que ele afirma está correto e coerente com o material; corrija o que não estiver e deixe claro na provenanceNote de onde vem.` });
89
+ });
90
+ return out.filter((it) => !decided[it.id]).map((it) => ({ ...it, label: CONF_KINDS[it.kind] }));
91
+ }
92
+
93
+ // As pendências que vão junto do pedido ao chat (editDeck: pending). O que foi ignorado não vai: a IA não volta a
94
+ // insistir nisso, nem quando a pessoa pede "corrija os avisos da criação" com a mensagem antiga na conversa.
95
+ export function conferencePrompt(items) {
96
+ // aviso de slide que já mudou não vai: a IA refazia a correção de algo que talvez já tenha corrigido
97
+ const list = (items || []).filter((it) => it.kind !== "derivado" && it.kind !== "proprio" && !it.stale);
98
+ if (!list.length) return "";
99
+ return `PENDÊNCIAS DA CONFERÊNCIA AUTOMÁTICA (painel Revisar › Conferência): o que as checagens acharam e a pessoa ainda não resolveu nem ignorou. Avisos antigos da conversa que não estão nesta lista foram resolvidos ou ignorados pela pessoa: não volte a eles. Mexa nestes só se o pedido tiver a ver.\n${list.slice(0, 40).map((it) => `- ${it.slide ? `slide ${it.slide}` : "deck"} · ${it.label}: ${it.text}`).join("\n")}`;
100
+ }
101
+
102
+ // A pessoa decidiu sobre um item: ignorado ("está certo assim") ou enviado (o pedido de correção foi ao chat)
103
+ export function decideItem(dir, id, status) {
104
+ if (!dir) throw new Error("A apresentação precisa estar salva na biblioteca.");
105
+ if (!["ignorado", "enviado"].includes(status)) throw new Error("status: ignorado ou enviado");
106
+ if (!/^[a-z]+:[^\s]{1,200}$/.test(String(id || ""))) throw new Error("item da conferência inválido");
107
+ const report = loadGenReport(dir) || { createdAt: new Date().toISOString(), avisado: true };
108
+ report.decisoes = { ...(report.decisoes || {}), [id]: { status, em: new Date().toISOString() } };
109
+ writeGenReport(dir, report);
110
+ return report.decisoes[id];
111
+ }
112
+
113
+ // O chat conferiu as contas de alguns slides (pedido da pessoa, do painel ou edição que mexeu nos números): o resultado
114
+ // substitui o que o relatório dizia desses slides. only: índices 0-based.
115
+ export function recordMath(dir, only, math) {
116
+ if (!dir || !math || math.failed || !only?.length) return;
117
+ const report = loadGenReport(dir);
118
+ if (!report && !math.divergent?.length && !math.material?.length) return;
119
+ const nums = new Set(only.map((i) => i + 1));
120
+ const keep = (list) => (list || []).filter((d) => !nums.has(d.slide));
121
+ const prev = report?.math || {};
122
+ writeGenReport(dir, { ...(report || { createdAt: new Date().toISOString(), avisado: true }),
123
+ math: { ...prev, divergent: [...keep(prev.divergent), ...(math.divergent || [])], material: [...keep(prev.material), ...(math.material || [])] } });
124
+ }
125
+
126
+ // Conferir de novo (botão do painel): refaz no deck atual a conferência das contas (só confere, não corrige: a
127
+ // correção é pelo item), a checagem de números e a cobertura das figuras do material. A revisão visual não se refaz
128
+ // aqui (precisa fotografar os slides); os avisos dela ficam até a pessoa corrigir ou ignorar.
129
+ export async function recheckDeck(dir, spec, materials = [], { complete, signal } = {}) {
130
+ const report = loadGenReport(dir) || { createdAt: new Date().toISOString(), avisado: true };
131
+ const notes = [];
132
+ const sources = (materials || []).filter((m) => m && m.kind !== "critica" && m.kind !== "pesquisa");
133
+ if (sources.some((m) => String(m.text || "").trim())) report.facts = { checked: 0, unsupported: unsupportedNumbers(spec, sources) };
134
+ if (sources.some((m) => m.inventory?.items?.length)) report.coverage = { missing: uncoveredVisuals(spec, sources).map((m) => m.caption) };
135
+ const r = await checkMath(spec, { materials: sources, ...(complete ? { complete } : {}), signal });
136
+ if (r.math?.failed) notes.push(`A conferência das contas falhou (${r.math.failed}); o resto foi conferido.`);
137
+ else report.math = r.math ? { checked: r.math.checked, fixed: [], unchecked: r.math.unchecked, divergent: r.math.divergent, material: r.math.material } : { checked: 0, fixed: [], unchecked: [], divergent: [], material: [] };
138
+ if (!sources.length) notes.push("Sem material anexado: a checagem de números e de figuras precisa do documento.");
139
+ report.conferidoEm = new Date().toISOString();
140
+ writeGenReport(dir, report);
141
+ return { report, notes, unchecked: r.math?.unchecked?.length || 0 };
142
+ }
143
+
144
+ export { loadGenReport };
@@ -0,0 +1,46 @@
1
+ // Bases curtas para começar uma apresentação técnica sem esperar pela IA.
2
+ // São modelos de fábrica; cada uso cria uma instância pela biblioteca.
3
+ export function developerStarter(kind, { title } = {}) {
4
+ const code = 'function normalizar(nome) {\n return nome.trim().toLowerCase();\n}\n\nconst usuario = normalizar(" Ada ");\nconsole.log(usuario);';
5
+ const curl = ['curl --request POST "$BASE/echo" \\', ' --header "Content-Type: application/json" \\', ' --data \'{"mensagem":"Olá, API"}\''].join("\n");
6
+ if (!["programacao", "apis"].includes(kind)) throw new Error("Escolha uma base de programação ou APIs com cURL.");
7
+ const programming = kind === "programacao";
8
+ const name = String(title || (programming ? "Meu código, passo a passo" : "Minha primeira API com cURL")).trim();
9
+ return {
10
+ title: name, theme: programming ? "terminal-pro" : "aurora-dev", duration: 6, purpose: "palestra", maxWords: 70,
11
+ ...(!programming ? { env: "ensaio" } : {}),
12
+ slides: [
13
+ { layout: "cover", kicker: "Exemplo para editar", title: name,
14
+ subtitle: programming ? "Entrada, transformação e resultado" : "Entenda o comando e experimente uma chamada",
15
+ figure: { motion: { type: "console", caption: programming ? "normalizar.js · saída de exemplo" : "curl · saída de exemplo", scenes: [{ cmd: programming ? "node normalizar.js" : 'curl --request POST "$BASE/echo"', out: programming ? ["ada"] : ['200 OK', '{ "mensagem": "Olá, API" }'] }] } },
16
+ notes: "Este é um exemplo de fábrica para você editar. Use Inserir › Código / cURL para colar seu conteúdo. A animação da capa mostra uma saída ilustrativa." },
17
+ programming ? {
18
+ layout: "codewalk", title: "Uma transformação em ==três passos==", filename: "normalizar.js", language: "JavaScript", code,
19
+ steps: [
20
+ { title: "Definir a regra", text: "A função recebe um nome. trim remove os espaços nas pontas; toLowerCase converte as letras para minúsculas.", highlight: [1, 2, 3] },
21
+ { title: "Aplicar à entrada", text: "Os espaços e as letras maiúsculas entram no mesmo exemplo.", highlight: [5] },
22
+ { title: "Conferir a saída", text: "O resultado pode ser copiado e usado no restante do programa.", highlight: [6], output: "ada" },
23
+ ], notes: "Troque o código em Formatar. Selecione as linhas e crie uma etapa para explicar o trecho. A saída deste guia é ilustrativa, sem executar o código."
24
+ } : {
25
+ layout: "codewalk", title: "O cURL em ==três partes==", filename: "pedido.sh", language: "Bash", code: curl,
26
+ steps: [
27
+ { title: "Método e destino", text: "POST envia os dados para o endpoint. BASE é o endereço do ambiente.", highlight: [1] },
28
+ { title: "Formato dos dados", text: "Content-Type avisa ao servidor que o corpo é JSON.", highlight: [2] },
29
+ { title: "O corpo enviado", text: "A mensagem é o dado que vamos conferir na resposta.", highlight: [3] },
30
+ ], notes: "Este comando explica a chamada, sem executá-la. O próximo slide chama a API de ensaio do Studio local."
31
+ },
32
+ programming ? {
33
+ layout: "diagram", title: "Veja o ==caminho== dos dados", mermaid: 'flowchart LR\n A["Entrada: espaços e maiúsculas"] --> B["trim + toLowerCase"] --> C["Saída: ada"]',
34
+ notes: "Troque os nós pelo fluxo do seu programa. Use a mesma entrada e a mesma saída em toda a explicação."
35
+ } : {
36
+ layout: "api", title: "Experimente ==ao vivo==", text: "No Studio local, clique em Executar. Troque a mensagem no Corpo e execute novamente.", tab: "curl",
37
+ request: { method: "POST", url: "{{base}}/echo", auth: false, body: { mensagem: "Olá, API" } }, answer: "$.recebido",
38
+ notes: "Ambiente ENSAIO: API de mentira que o Studio local fornece. No servidor multiusuário, chamadas não são executadas. Para usar seu serviço, troque o ambiente e importe seu cURL por Inserir › Código / cURL."
39
+ },
40
+ { layout: "code", title: programming ? "Conferir a ==entrada e saída==" : "Ler a ==resposta==", filename: "resposta.json", language: "JSON",
41
+ code: programming ? '{\n "entrada": " Ada ",\n "saida": "ada"\n}' : '{\n "metodo": "POST",\n "recebido": { "mensagem": "Olá, API" },\n "consulta": {}\n}',
42
+ note: programming ? "Mantenha um exemplo concreto do começo ao fim da explicação." : "O campo recebido contém os mesmos dados enviados no corpo." },
43
+ { layout: "end", title: "Agora, o ==seu exemplo==", subtitle: "Cole seu conteúdo em Inserir › Código / cURL ou peça ao assistente para explicar os trechos.", notes: "Você pode aplicar outro tema em Design. O exemplo é seu para editar; cada uso cria uma apresentação independente." },
44
+ ],
45
+ };
46
+ }
@@ -1,10 +1,70 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { mathWarning } from '../ai/math-check.js';
4
+ import { renameRetry } from '../fs-retry.js';
5
+
6
+ // Relatório da criação com IA (.sagadeck/avaliacao-geracao.json): revisão visual, variedade, números sem base no
7
+ // material, contas e figuras de fora. `avisado` diz se a pessoa já viu os avisos no chat (generationNotice).
8
+ const reportFile = (dir) => path.join(dir, '.sagadeck', 'avaliacao-geracao.json');
9
+
10
+ export function loadGenReport(dir) {
11
+ if (!dir) return null;
12
+ try { return JSON.parse(fs.readFileSync(reportFile(dir), 'utf8')); } catch { return null; }
13
+ }
14
+
15
+ // grava inteiro (tmp + rename): o painel Conferência e o chat mexem no mesmo arquivo
16
+ export function writeGenReport(dir, report) {
17
+ const file = reportFile(dir), tmp = `${file}.tmp-${process.pid}`;
18
+ fs.mkdirSync(path.dirname(file), { recursive: true });
19
+ fs.writeFileSync(tmp, JSON.stringify(report, null, 2), 'utf8');
20
+ renameRetry(tmp, file);
21
+ }
22
+
23
+ // impressão digital de um slide (o que ele mostra, sem uid nem marcas do Studio): o aviso da revisão visual vale para
24
+ // o slide como ele era; mudou, o painel avisa que o aviso pode estar velho
25
+ export function slideMark(slide) {
26
+ const s = JSON.stringify({ ...slide, uid: undefined, auto: undefined, review: undefined });
27
+ let h = 5381; for (const c of s) h = ((h << 5) + h + c.codePointAt(0)) >>> 0;
28
+ return h.toString(36);
29
+ }
30
+ // marcas por slide: pelo uid (sobrevive a inserir e apagar slides) e, sem uid, pela posição
31
+ export const slideMarks = (spec) => Object.fromEntries((spec?.slides || []).map((s, i) => [s?.uid || `#${i + 1}`, slideMark(s)]));
3
32
 
4
33
  export function saveGenReport(dir, generation) {
5
34
  const reportDir = path.join(dir, '.sagadeck');
6
35
  fs.mkdirSync(reportDir, { recursive: true });
7
- fs.writeFileSync(path.join(reportDir, 'avaliacao-geracao.json'), JSON.stringify({
36
+ fs.writeFileSync(reportFile(dir), JSON.stringify({
8
37
  createdAt: new Date().toISOString(), quality: generation.quality, variety: generation.variety,
38
+ ...(generation.spec ? { slideMarks: slideMarks(generation.spec) } : {}),
39
+ ...(generation.facts ? { facts: generation.facts } : {}), ...(generation.math ? { math: generation.math } : {}),
40
+ ...(generation.coverage ? { coverage: generation.coverage } : {}), avisado: false,
9
41
  }, null, 2), 'utf8');
10
42
  }
43
+
44
+ // Os avisos que a conferência automática deixou sem resolver, como texto para a pessoa
45
+ export function generationWarnings(report) {
46
+ const out = [];
47
+ const m = mathWarning(report?.math);
48
+ if (m) out.push(m);
49
+ const facts = report?.facts?.unsupported || [];
50
+ if (facts.length) out.push(`Números sem base no material: ${facts.map((b) => `${b.number} (slide ${b.slide})`).join(', ')}`);
51
+ const missing = report?.coverage?.missing || [];
52
+ if (missing.length) out.push(`Figuras e tabelas do material sem slide: ${missing.join('; ')}`);
53
+ const issues = report?.quality?.issues || [];
54
+ for (const i of issues.slice(0, 12)) out.push(`Slide ${i.slide}: ${String(i.text).slice(0, 240)}`);
55
+ if (issues.length > 12) out.push(`… e mais ${issues.length - 12} aviso(s) da revisão visual.`);
56
+ return out;
57
+ }
58
+
59
+ // Na primeira vez que a apresentação abre depois da criação, os avisos viram uma mensagem do chat (uma vez só):
60
+ // antes eles ficavam só no arquivo, e ninguém via que a revisão tinha reclamado.
61
+ export function generationNotice(dir) {
62
+ if (!dir) return null;
63
+ let report;
64
+ try { report = JSON.parse(fs.readFileSync(reportFile(dir), 'utf8')); } catch { return null; }
65
+ if (report.avisado) return null;
66
+ const warnings = generationWarnings(report);
67
+ try { fs.writeFileSync(reportFile(dir), JSON.stringify({ ...report, avisado: true }, null, 2), 'utf8'); } catch { return null; }
68
+ if (!warnings.length) return null;
69
+ return `A conferência automática da criação desta apresentação deixou estes avisos sem resolver:\n${warnings.map((w) => `- ${w}`).join('\n')}\nPeça "corrija os avisos da criação" para eu tentar de novo, ou ajuste à mão.`;
70
+ }