sagadeck 1.2.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 (233) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +305 -0
  3. package/SKILL.md +148 -0
  4. package/bin/sagadeck.js +511 -0
  5. package/docs/ANDAMENTO.md +1255 -0
  6. package/docs/EXPERIENCIAS.md +39 -0
  7. package/docs/PLANO-EXPERIENCIAS.md +39 -0
  8. package/docs/PLANO-TESTE-AULAS-TECNICAS.md +44 -0
  9. package/docs/PLANO-TESTE-DESINFORMACAO.md +38 -0
  10. package/docs/REFERENCIA.md +1131 -0
  11. package/package.json +59 -0
  12. package/src/adaptive-layouts.js +18 -0
  13. package/src/ai/art-direction.js +8 -0
  14. package/src/ai/commands.js +130 -0
  15. package/src/ai/context.js +245 -0
  16. package/src/ai/deck-ai.js +1135 -0
  17. package/src/ai/directions.js +74 -0
  18. package/src/ai/document-materials.js +49 -0
  19. package/src/ai/document-visuals.js +215 -0
  20. package/src/ai/evaluation.js +31 -0
  21. package/src/ai/ground.js +55 -0
  22. package/src/ai/llm.js +292 -0
  23. package/src/ai/quality.js +51 -0
  24. package/src/ai/relay.js +45 -0
  25. package/src/ai/staged-generation.js +24 -0
  26. package/src/ai/transform.js +858 -0
  27. package/src/ai/usage.js +17 -0
  28. package/src/ai/variety.js +80 -0
  29. package/src/ai/video-frames.js +24 -0
  30. package/src/ai/video-generation.js +126 -0
  31. package/src/ai/web-operation.js +31 -0
  32. package/src/algo-catalog.js +153 -0
  33. package/src/algo-trace.js +139 -0
  34. package/src/api-client.js +514 -0
  35. package/src/api-collections.js +14 -0
  36. package/src/api-demo.js +222 -0
  37. package/src/aspect.js +40 -0
  38. package/src/build.js +323 -0
  39. package/src/carousel.js +56 -0
  40. package/src/chrome.js +65 -0
  41. package/src/code-highlight.js +68 -0
  42. package/src/code-lab-browser.js +22 -0
  43. package/src/code-lab.js +3 -0
  44. package/src/code-language.js +52 -0
  45. package/src/csv.js +74 -0
  46. package/src/decision-lab.js +20 -0
  47. package/src/deck-file.js +119 -0
  48. package/src/diagram/napkin-examples.js +13 -0
  49. package/src/diagram/napkin.js +539 -0
  50. package/src/docx.js +128 -0
  51. package/src/dynamics/git-sim.js +200 -0
  52. package/src/dynamics/layouts.js +112 -0
  53. package/src/elements.js +554 -0
  54. package/src/experiences.js +124 -0
  55. package/src/exploration.js +31 -0
  56. package/src/export/browser.js +53 -0
  57. package/src/export/estudo.js +67 -0
  58. package/src/export/pptx.js +475 -0
  59. package/src/export/roteiro.js +40 -0
  60. package/src/export/shots.js +182 -0
  61. package/src/figures/charts.js +383 -0
  62. package/src/figures/diagrams.js +117 -0
  63. package/src/figures/icons.js +166 -0
  64. package/src/figures/material-pictos.js +20 -0
  65. package/src/figures/pictos.js +255 -0
  66. package/src/figures/points.js +106 -0
  67. package/src/figures/qr.js +29 -0
  68. package/src/figures/ufmap.js +48 -0
  69. package/src/fiscal/autofix.js +377 -0
  70. package/src/fiscal/normalize.js +115 -0
  71. package/src/graph-lab.js +24 -0
  72. package/src/hologram.js +30 -0
  73. package/src/identity.js +86 -0
  74. package/src/import/crop.js +59 -0
  75. package/src/import/index.js +132 -0
  76. package/src/import/legacy-word.js +42 -0
  77. package/src/import/merge.js +45 -0
  78. package/src/import/office.js +123 -0
  79. package/src/import/omml.js +69 -0
  80. package/src/import/pdf-render.js +56 -0
  81. package/src/import/pptx.js +701 -0
  82. package/src/import/xml.js +42 -0
  83. package/src/infographic.js +401 -0
  84. package/src/layouts.js +723 -0
  85. package/src/lessons.js +171 -0
  86. package/src/library.js +399 -0
  87. package/src/markup.js +116 -0
  88. package/src/master.js +214 -0
  89. package/src/mcp/server.js +371 -0
  90. package/src/meeting.js +42 -0
  91. package/src/motion.js +56 -0
  92. package/src/package.js +116 -0
  93. package/src/playground.js +19 -0
  94. package/src/portal-scene.js +20 -0
  95. package/src/preferences.js +61 -0
  96. package/src/protect.js +51 -0
  97. package/src/purpose.js +24 -0
  98. package/src/pytrace.js +1092 -0
  99. package/src/research/duckduckgo.js +73 -0
  100. package/src/research/research.js +202 -0
  101. package/src/research/svg-image.js +17 -0
  102. package/src/research/visuals.js +52 -0
  103. package/src/runtime/api-collection-ui.js +5 -0
  104. package/src/runtime/api-collections.js +28 -0
  105. package/src/runtime/api-core.js +530 -0
  106. package/src/runtime/api-ui.js +966 -0
  107. package/src/runtime/base.css +1774 -0
  108. package/src/runtime/calc.js +139 -0
  109. package/src/runtime/code-lab.js +371 -0
  110. package/src/runtime/decision-lab.js +49 -0
  111. package/src/runtime/diagram.js +341 -0
  112. package/src/runtime/fit.js +268 -0
  113. package/src/runtime/fonts/caveat.css +2 -0
  114. package/src/runtime/fonts/comic-neue.css +3 -0
  115. package/src/runtime/fonts/fredoka.css +2 -0
  116. package/src/runtime/fonts/index.json +10 -0
  117. package/src/runtime/fonts/inter.css +2 -0
  118. package/src/runtime/fonts/patrick-hand.css +2 -0
  119. package/src/runtime/fonts/plus-jakarta-sans.css +2 -0
  120. package/src/runtime/fonts/press-start-2p.css +2 -0
  121. package/src/runtime/fonts/vt323.css +2 -0
  122. package/src/runtime/formula.js +148 -0
  123. package/src/runtime/graph-lab.js +72 -0
  124. package/src/runtime/meeting.css +7 -0
  125. package/src/runtime/meeting.js +10 -0
  126. package/src/runtime/motion.js +15 -0
  127. package/src/runtime/playground.js +11 -0
  128. package/src/runtime/points.js +19 -0
  129. package/src/runtime/portal-scene.css +6 -0
  130. package/src/runtime/portal-scene.js +22 -0
  131. package/src/runtime/runtime.js +827 -0
  132. package/src/runtime/science.js +89 -0
  133. package/src/runtime/skins/aluminio.css +31 -0
  134. package/src/runtime/skins/arcade.css +25 -0
  135. package/src/runtime/skins/aurora.css +17 -0
  136. package/src/runtime/skins/bauhaus.css +19 -0
  137. package/src/runtime/skins/editorial.css +19 -0
  138. package/src/runtime/skins/jornal.css +17 -0
  139. package/src/runtime/skins/manual-noite.css +14 -0
  140. package/src/runtime/skins/manual.css +14 -0
  141. package/src/runtime/skins/noite.css +21 -0
  142. package/src/runtime/skins/oceano.css +17 -0
  143. package/src/runtime/skins/pop.css +23 -0
  144. package/src/runtime/skins/prata.css +18 -0
  145. package/src/runtime/skins/prisma.css +28 -0
  146. package/src/runtime/skins/rabisco.css +18 -0
  147. package/src/runtime/skins/relevo.css +33 -0
  148. package/src/runtime/skins/sinal.css +16 -0
  149. package/src/runtime/skins/terminal.css +23 -0
  150. package/src/runtime/vendor/KATEX-LICENSE.txt +21 -0
  151. package/src/runtime/vendor/MERMAID-LICENSE.txt +21 -0
  152. package/src/runtime/vendor/PLOTLY-LICENSE.txt +21 -0
  153. package/src/runtime/vendor/katex.css +1 -0
  154. package/src/runtime/vendor/mermaid.min.js +3636 -0
  155. package/src/runtime/vendor/plotly.min.js +3620 -0
  156. package/src/runtime/video-player.js +35 -0
  157. package/src/science.js +162 -0
  158. package/src/studio/ai-response.js +36 -0
  159. package/src/studio/api-routes.js +152 -0
  160. package/src/studio/assets/contexto/leia-me.md +12 -0
  161. package/src/studio/assets/contexto/vendas.csv +7 -0
  162. package/src/studio/assets/imagens/colecao-arquitetura.png +0 -0
  163. package/src/studio/assets/imagens/colecao-cor.png +0 -0
  164. package/src/studio/assets/imagens/colecao-equipe.png +0 -0
  165. package/src/studio/assets/imagens/lavanda-cover.jpg +0 -0
  166. package/src/studio/code-routes.js +32 -0
  167. package/src/studio/command-approval.js +48 -0
  168. package/src/studio/demo-decks.js +98 -0
  169. package/src/studio/direction-routes.js +29 -0
  170. package/src/studio/errors.js +65 -0
  171. package/src/studio/exploration-demo.js +17 -0
  172. package/src/studio/exporting.js +105 -0
  173. package/src/studio/generation-report.js +10 -0
  174. package/src/studio/instance.js +48 -0
  175. package/src/studio/layout-samples.js +152 -0
  176. package/src/studio/project.js +371 -0
  177. package/src/studio/public/api-collections.js +28 -0
  178. package/src/studio/public/app.js +5265 -0
  179. package/src/studio/public/art-preview.js +10 -0
  180. package/src/studio/public/calc-fields.js +11 -0
  181. package/src/studio/public/chat-progress.js +47 -0
  182. package/src/studio/public/developer-fields.js +22 -0
  183. package/src/studio/public/explorer.js +443 -0
  184. package/src/studio/public/history.js +58 -0
  185. package/src/studio/public/index.html +867 -0
  186. package/src/studio/public/inspector.js +270 -0
  187. package/src/studio/public/lab-fields.js +5 -0
  188. package/src/studio/public/library.css +275 -0
  189. package/src/studio/public/library.html +55 -0
  190. package/src/studio/public/library.js +471 -0
  191. package/src/studio/public/merge-decks.js +155 -0
  192. package/src/studio/public/review-ui.js +100 -0
  193. package/src/studio/public/screenshot-editor.js +140 -0
  194. package/src/studio/public/share-ui.js +72 -0
  195. package/src/studio/public/slide-clipboard.js +23 -0
  196. package/src/studio/public/slide-form.js +1263 -0
  197. package/src/studio/public/studio-awareness.js +154 -0
  198. package/src/studio/public/studio-next.css +303 -0
  199. package/src/studio/public/style.css +1262 -0
  200. package/src/studio/public/ui-icons.js +2 -0
  201. package/src/studio/public/viewers.js +372 -0
  202. package/src/studio/public/visual-editor.js +326 -0
  203. package/src/studio/public/workspace.js +18 -0
  204. package/src/studio/public-files.js +1 -0
  205. package/src/studio/request-helpers.js +41 -0
  206. package/src/studio/server.js +1583 -0
  207. package/src/studio/share-routes.js +146 -0
  208. package/src/studio/slide-copy-routes.js +26 -0
  209. package/src/studio/slide-copy.js +30 -0
  210. package/src/studio/snapshot.js +160 -0
  211. package/src/studio/style-routes.js +103 -0
  212. package/src/studio/template-collections.js +97 -0
  213. package/src/studio/versions.js +95 -0
  214. package/src/studio/video-download.js +29 -0
  215. package/src/studio/visual-keys.js +52 -0
  216. package/src/studio/workspaces.js +39 -0
  217. package/src/table.js +93 -0
  218. package/src/templates/scaffold.js +372 -0
  219. package/src/themes.js +604 -0
  220. package/src/trace-view.js +450 -0
  221. package/src/uid.js +19 -0
  222. package/src/video-player.js +40 -0
  223. package/src/visual-edits.js +99 -0
  224. package/src/ws.js +134 -0
  225. package/templates/cenario/Texto no cen/303/241rio.yaml" +171 -0
  226. package/templates/cenario/imagens/cidade.svg +30 -0
  227. package/templates/cenario/imagens/pessoa.svg +15 -0
  228. package/templates/cenario-e-ciencia.yaml +47 -0
  229. package/templates/ensaio-api.yaml +160 -0
  230. package/templates/exemplo-alegre.yaml +75 -0
  231. package/templates/exemplo-keynote.yaml +90 -0
  232. package/templates/exemplo-napkin.yaml +139 -0
  233. package/templates/exemplo.yaml +228 -0
@@ -0,0 +1,858 @@
1
+ // Transformar uma apresentação que já existe (importada de um PowerPoint): tarefa agêntica que o chat inicia quando a
2
+ // pessoa pede para melhorar ou recriar a apresentação inteira (o modelo decide; ver `transform:` no PATCH_FORMAT).
3
+ // melhorar — mesmo estilo do original (a moldura dele vira o mestre), conteúdo mais claro e mais rico; cada slide
4
+ // mudado leva a marca de revisão (review) e o original fica em original/original.yaml para Desfazer
5
+ // recriar — do zero, com tudo o que o sagadeck faz, sem perder nada do original
6
+ // Etapas: VER (o modelo de visão descreve as figuras de cada slide original) → PLANEJAR → ESCREVER em blocos →
7
+ // CONFERIR (os fatos do original por código; o desenho por visão, lado a lado com a foto do original) → MONTAR.
8
+ // O modelo de texto escreve; o de visão vê (llm.js: papel "vision"). Juntar slides progressivos é código, não IA.
9
+ import fs from "node:fs";
10
+ import crypto from "node:crypto";
11
+ import path from "node:path";
12
+ import YAML from "yaml";
13
+ import { chat, llmConfig } from "./llm.js";
14
+ import { systemPrompt, extractYaml, parseYaml, validateSlides, sanitizeCheck, materializeImages, countImagePrompts } from "./deck-ai.js";
15
+ import { groundSpotlights } from "./ground.js";
16
+ import { normalizeSpec } from "../fiscal/normalize.js";
17
+ import { plainOf } from "../import/pptx.js";
18
+ import { styleFromImport, repeatedFrame } from "../master.js";
19
+ import { mergeProgressive } from "../import/merge.js";
20
+ import { ensureUids, newUid } from "../uid.js";
21
+
22
+ const clip = (s, n) => (String(s ?? "").length > n ? String(s).slice(0, n - 1) + "…" : String(s ?? ""));
23
+ const jsonOf = (text) => {
24
+ const m = String(text).match(/```(?:json)?\s*([\s\S]*?)```/) || [null, String(text).slice(String(text).indexOf("{"), String(text).lastIndexOf("}") + 1)];
25
+ return JSON.parse(m[1]);
26
+ };
27
+ // resposta cortada no meio (o modelo de visão gasta parte dos tokens pensando): os objetos completos da lista
28
+ // "slides" valem; o resto se pede de novo. Devolve { slides: [...], partial: true|false }.
29
+ export function jsonLoose(text) {
30
+ try { return { ...jsonOf(text), partial: false }; } catch {}
31
+ const t = String(text), start = t.search(/"slides"\s*:\s*\[/);
32
+ if (start < 0) throw new Error("resposta sem JSON");
33
+ const out = [];
34
+ let i = t.indexOf("[", start) + 1, depth = 0, from = -1, inStr = false;
35
+ for (; i < t.length; i++) {
36
+ const c = t[i];
37
+ if (inStr) { if (c === "\\") i++; else if (c === '"') inStr = false; continue; }
38
+ if (c === '"') inStr = true;
39
+ else if (c === "{") { if (depth++ === 0) from = i; }
40
+ else if (c === "}") { if (--depth === 0 && from >= 0) { try { out.push(JSON.parse(t.slice(from, i + 1))); } catch {} from = -1; } }
41
+ else if (c === "]" && depth === 0) break;
42
+ }
43
+ if (!out.length) throw new Error("resposta cortada antes do primeiro item");
44
+ return { slides: out, partial: true };
45
+ }
46
+
47
+ // ------------------------------------------------------------------------------------------------ fatos (código)
48
+ const deaccent = (s) => String(s).normalize("NFD").replace(/[\u0300-\u036f]/g, "");
49
+ const canon = (v) => (Number.isFinite(v) ? String(Math.round(v * 10000) / 10000) : null);
50
+ // leituras possíveis de um número escrito (0,385 = 0.385; 10.000 = 10000; "0.385" pode ser as duas coisas)
51
+ const numVals = (tok) => {
52
+ const t = tok.replace(/\s/g, "");
53
+ const out = new Set();
54
+ if (/^\d{1,3}(\.\d{3})+(,\d+)?$/.test(t)) out.add(canon(Number(t.replace(/\./g, "").replace(",", ".")))); // milhar pt-BR
55
+ if (/^\d+([.,]\d+)?$/.test(t)) out.add(canon(Number(t.replace(",", "."))));
56
+ if (/^\d{1,3}(,\d{3})+(\.\d+)?$/.test(t)) out.add(canon(Number(t.replace(/,/g, "")))); // milhar em inglês
57
+ return [...out].filter(Boolean);
58
+ };
59
+ const numVal = (tok) => numVals(tok)[0] || null;
60
+ const STOP = new Set(["DE", "DA", "DO", "DAS", "DOS", "E", "EM", "NO", "NA", "OU", "UM", "UMA", "OS", "AS", "A", "O", "SE", "PARA", "COM", "POR", "QUE", "NAO", "SAO"]);
61
+ function textsOfSlide(s) {
62
+ const out = [];
63
+ for (const e of s.elements || []) {
64
+ if (e.deco) continue;
65
+ if (e.textbox) { const t = plainOf(e).trim(); if (t && !/^\d{1,3}$/.test(t)) out.push(t); }
66
+ if (e.tableData) for (const r of e.tableData) out.push(r.join(" | "));
67
+ }
68
+ return out;
69
+ }
70
+ export function factsOf(slide, extra = "") {
71
+ const text = [...textsOfSlide(slide), extra].join("\n");
72
+ const numbers = new Set();
73
+ const alts = new Map();
74
+ for (const tok of text.match(/\d+(?:[.,]\d+)*/g) || []) {
75
+ const vs = numVals(tok), v = vs[0];
76
+ if (v == null || v === "0" || /^\d$/.test(v)) continue;
77
+ numbers.add(v); alts.set(v, vs);
78
+ }
79
+ const terms = new Set();
80
+ // siglas: SAE, IDF, TR (o \b do JS não conhece acento: "MÁXIMA" não pode virar a sigla "XIMA")
81
+ // frase toda em caixa alta (título dentro da figura: "BOCA DE LOBO", "DRENAGEM URBANA") não é fileira de siglas:
82
+ // palavra de 3+ letras (só letras) com outra palavra em caixa alta do lado fica de fora; sigla com número (BL1) vale
83
+ const capsPhrase = new Set();
84
+ for (const m of text.matchAll(/(?<![\p{L}\p{N}])(?:\p{Lu}{2,}[\p{Lu}\p{N}]*)(?:[ \t]+(?:\p{Lu}{2,}[\p{Lu}\p{N}]*|\p{N}+))+(?![\p{L}\p{N}])/gu))
85
+ for (const w of m[0].split(/[ \t]+/)) if (/^\p{Lu}{3,}$/u.test(w)) capsPhrase.add(w);
86
+ for (const m of text.matchAll(/(?<![\p{L}\p{N}])([A-Z][A-Z0-9]{1,6})(?![\p{L}\p{N}])/gu)) if (!STOP.has(deaccent(m[1])) && !capsPhrase.has(m[1])) terms.add(m[1]);
87
+ // nomes próprios no meio da frase; palavra comum com maiúscula (item de lista, cabeçalho) que aparece minúscula no
88
+ // mesmo slide não é nome
89
+ // (o espaço antes é da mesma linha: a 1ª palavra de cada linha do PowerPoint começa com maiúscula e não é nome; e
90
+ // palavra seguida de ":" é rótulo, "Obs:", "Fonte:")
91
+ for (const m of text.matchAll(/(?:[a-zà-ú,;][ \t])([A-ZÁÉÍÓÚÂÊÔÃÕÇ][a-zà-úç]{2,}(?:[ \t]+(?:(?:de|da|do|dos|das|e)[ \t]+)?[A-ZÁÉÍÓÚÂÊÔÃÕÇ][a-zà-úç]{2,})*)(?![\p{L}]*\s*:)/gu)) {
92
+ const low = m[1].toLowerCase();
93
+ if (!m[1].includes(" ") && new RegExp(`(?<![\\p{L}])${low}(?![\\p{L}])`, "u").test(text)) continue;
94
+ terms.add(m[1]);
95
+ }
96
+ return { numbers, terms, alts };
97
+ }
98
+ function textOfProduced(slides) {
99
+ const acc = [];
100
+ const walk = (v, k) => {
101
+ if (["image", "drawing", "style", "font", "color", "review", "original"].includes(k)) return;
102
+ if (typeof v === "string" || typeof v === "number") acc.push(String(v));
103
+ else if (Array.isArray(v)) v.forEach((x) => walk(x));
104
+ else if (v && typeof v === "object") for (const [kk, vv] of Object.entries(v)) walk(vv, kk);
105
+ };
106
+ slides.forEach((s) => walk(s));
107
+ return acc.join("\n");
108
+ }
109
+ export function missingFacts(facts, slides) {
110
+ const text = textOfProduced(slides);
111
+ const nums = new Set((text.match(/\d+(?:[.,]\d+)*/g) || []).flatMap(numVals));
112
+ const low = deaccent(text).toLowerCase();
113
+ return {
114
+ numbers: [...facts.numbers].filter((n) => !(facts.alts?.get(n) || [n]).some((v) => nums.has(v))),
115
+ terms: [...facts.terms].filter((t) => !low.includes(deaccent(t).toLowerCase())),
116
+ };
117
+ }
118
+
119
+ // Erro de digitação do escritor ("Ajute", "probailidade", "Distribuião", "Periodo"): a palavra nova que não está no
120
+ // original mas é uma palavra dele com UMA letra a menos (no meio: "chuva" de "chuvas" é plural, não erro) ou sem o
121
+ // acento. Vocabulário = as palavras do original. Vai para a correção como possível erro; quem decide é a IA.
122
+ const WORD = /[\p{L}]{4,}/gu;
123
+ export function vocabularyOf(slides) {
124
+ const v = new Map(); // forma sem acento, minúscula → a palavra como está no original
125
+ for (const s of slides) for (const t of textsOfSlide(s)) for (const w of t.match(WORD) || []) {
126
+ const k = w.toLowerCase();
127
+ if (!v.has(k)) v.set(k, w);
128
+ }
129
+ return v;
130
+ }
131
+ export function typosOf(slides, vocab) {
132
+ if (!vocab.size) return [];
133
+ const plain = new Map(), dropped = new Map();
134
+ for (const [k, w] of vocab) {
135
+ const d = deaccent(k);
136
+ if (!plain.has(d)) plain.set(d, w);
137
+ // a última letra não (plural); a primeira só em palavra comprida ("arras" de "barras")
138
+ for (let i = k.length >= 6 ? 0 : 1; i < k.length - 1; i++) { const x = k.slice(0, i) + k.slice(i + 1); if (!dropped.has(x)) dropped.set(x, w); }
139
+ }
140
+ const out = new Map();
141
+ for (const w of textOfProduced(slides).replace(/\$[^$\n]*\$|`[^`]*`/g, " ").match(WORD) || []) {
142
+ const k = w.toLowerCase();
143
+ if (vocab.has(k) || out.has(k)) continue;
144
+ // sem acento onde o original tem ("Periodo"); o contrário (o novo com acento) é correção, não erro
145
+ const near = (deaccent(k) === k ? plain.get(k) : null) || dropped.get(k);
146
+ if (near) out.set(k, `"${w}" (no original: "${near}")`);
147
+ }
148
+ return [...out.values()];
149
+ }
150
+
151
+ // ------------------------------------------------------------------------------------------------ regras
152
+ const TOOLBOX = `Ferramentas para cada item do plano (campo "acao"):
153
+ - "manter": o slide original fica como está (só no modo melhorar; use quando ele já está bom ou é uma figura específica que não ganha nada sendo refeita).
154
+ - "juntar": slides que são o MESMO desenho crescendo (a pessoa copiou o slide e foi acrescentando partes para "animar") viram UM slide que se monta por cliques. É feito por código, exato, com o desenho original. Informe em "de" os números, na ordem.
155
+ - "escrever": você reescreve um ou mais slides originais ("de") com os layouts do sagadeck. Diga em "ideia" o layout e o recurso (ex.: "calc com L e Δh → tc de Kirpich"; "chart de colunas com a tabela de contagem"; "spotlight no mapa com o exutório, o divisor e a área azul").
156
+ - "novo": slide que não existia (exercício resolvido, calculadora, pergunta para a turma, gráfico feito a partir de uma tabela, algoritmo passo a passo de um método, resumo). "de" = os slides que dão a base (os dados vêm deles).
157
+ Recursos do sagadeck que costumam fazer diferença numa aula: calc (fórmula com entradas que a turma mexe), science (curvas com controles), chart (toda tabela numérica pode virar gráfico), solution (exercício resolvido passo a passo com os dados do próprio material), algo com program: (um método passo a passo como programa: ordenar e classificar, redistribuir blocos…), spotlight (mapa ou figura com regiões explicadas), compare, timeline, diagram, infographic, question/poll, split com a figura original.`;
158
+ const CONTENT_RULES = `Regras de conteúdo (valem sempre):
159
+ - DIREÇÃO VISUAL: interprete o pedido completo, inclusive nível de acabamento. Quando pede recriação visual ambiciosa, reserve ilustrações protagonistas para os conceitos que se beneficiam delas, com composição editorial, materiais, luz e profundidade coerentes. Um conceito físico genérico pode ganhar um corte isométrico ou maquete 3D ilustrativa com image_prompt, em vez de apenas colorir o xerox. Não force este estilo se não foi pedido.
160
+ - FIDELIDADE VISUAL: figuras específicas (cartografia de um lugar, medições, fronteiras, redes reais) preservam topologia, rótulos e fonte. Nunca invente relevo, coordenadas ou cotas para deixá-las bonitas. Se não houver dados para reconstrução exata, mantenha a evidência e use uma ilustração conceitual separada e identificada como tal. Figuras genéricas podem mudar perspectiva e composição preservando o mecanismo.
161
+ - Nas ilustrações novas, prefira rótulos e explicações editáveis fora da imagem. A aparência 3D não implica dados geográficos reais. Imagens são parte da explicação, não enfeites repetidos em todos os slides.
162
+ - NADA do original pode se perder: números, unidades, fórmulas, nomes, siglas, leis, fontes/créditos das figuras, exemplos, tabelas inteiras, observações. Se não couber no slide, vai para outro slide, para \`consulta\` ou para notes.
163
+ - Figuras: pense como um designer que entende do assunto, figura por figura. REDESENHE bonito com os recursos do sagadeck sempre que der para reconstruir sem perder nada: gráfico cujos dados ou cuja equação estão no material (\`chart\` com os pontos, ou \`science\` com a curva da equação), tabela em imagem legível (\`table\`), esquema, fluxo ou ciclo (\`infographic\`, \`diagram\`), equação (LaTeX). Original feio, borrado ou escaneado que dá para reconstruir: reconstrua. MANTENHA a imagem original (image: …, ou figure: { image: … }, pelo caminho dado) só quando ela é insubstituível: foto real, mapa de um lugar, figura com dado que não dá para ler ou reproduzir com fidelidade; aí ela fica, mesmo feia, com a fonte. NUNCA as duas: redesenhou ou transcreveu (tabela, equação, gráfico), a imagem original não entra, nem como "versão alternativa" ou "imagem original". Confira o caminho: a imagem tem de ser a do assunto do slide.
164
+ - Não invente dado. Se o original parecer ter um erro (fórmula que contradiz o gráfico, número que não fecha), NÃO troque em silêncio: aponte em "alertas" e, no slide, mostre o que o material sustenta com uma nota curta para o professor validar.
165
+ - Tabela que veio como IMAGEM (recorte de livro, print) e que a visão transcreveu inteira e legível: reescreva como \`table\` de verdade (cabeçalho, linhas, a fonte em \`source\`), nas cores do deck; se algum valor ficou ilegível, mantenha a imagem original. Dado tabular espalhado em texto também vira \`table\`.
166
+ - Fórmula vai em LaTeX (\`$…$\` no texto, \`equations\` no \`science\`, \`latex\` no \`solution\`), nunca como imagem: a imagem de equação do original (recorte do OLE) só entra se a visão não conseguiu transcrever; transcrita, não repita a imagem. Uma fórmula importante por slide, grande; a explicação das variáveis em lista ao lado ou embaixo.
167
+ - Figura que é ILUSTRAÇÃO (desenho de uma cena, corte de terreno, perfil de solo, mapa, esquema desenhado; não fluxograma de caixas e setas, não gráfico, não tabela) com imagem ESCANEADA ou PIXELADA, genérica ou específica: REDESENHE como ilustração, com o modelo de imagem, a partir dela (não vire diagrama de caixas: ilustração volta como ilustração, bem feita): no lugar de \`image:\`, \`image_prompt: "Clean up and redraw THIS EXACT figure as if traced over it: same aspect ratio, same framing, every line in the same place; crisp lines, gentle colors (…cores que combinem com o deck…). Copy every text label exactly as written, letter by letter, at the same position; leave out what is unreadable; add no new text."\` e \`image_ref: <o caminho da imagem original dada>\` (com \`fit: contain\`). Descreva no prompt, em inglês, o que a figura mostra (ruas, curvas de nível, rio…) para o modelo não errar. O original fica guardado; a conferência compara os dois.
168
+ - Ilustração nova (uma cena, um objeto, um processo físico, a capa) onde uma imagem explica melhor que ícone ou diagrama: \`image_prompt: "…em inglês…"\` sem \`image_ref\`. Com moderação: as que fazem diferença.
169
+ - \`full\` (imagem de fundo com texto por cima) só para FOTO; gráfico, esquema, mapa e tabela nunca vão em \`full\` (o texto cobre os dados): use \`split\` (texto ao lado) ou \`image\`.
170
+ - Camadas físicas (atmosfera, superfície, solo, lençol, rocha; o perfil de um terreno): \`infographic\` com \`shape: camadas\`, de cima para baixo como na realidade, com os fluxos entre elas em \`flows\`; nunca um \`diagram\` (o fluxograma ordena pelas setas e põe a atmosfera embaixo).
171
+ - Gráfico 3D (\`plot.surface\`) só com a função ou os dados do próprio material; nada de superfície de enfeite (uma onda qualquer chamada de "terreno").
172
+ - Ciclo (algo que volta ao começo: ciclo hidrológico, PDCA, ciclo de vida): \`infographic\` com \`shape: ciclo\` (etapas em volta, setas curvas), nunca \`diagram\` (no fluxograma a volta vira setas cruzando o desenho).
173
+ - Quebra de linha do original no meio da frase (a caixa de texto do PowerPoint quebrava ali: "tendem a diminuir com a duração. Considera-se que as chuvas" / "que potencialmente…"): junte a frase; quebra só entre frases ou itens.
174
+ - Exercício novo: quando o material já tem os dados (a série, a tabela, a bacia do original), prefira usá-los.
175
+ - Figura de um slide: um ícone sozinho só quando não houver nada melhor; prefira um gráfico ou esquema do próprio conceito (declividade: o perfil do rio num \`chart\`).
176
+ - Escreva em português, no tom do material (aula). Confira a ortografia de cada título e texto antes de responder.`;
177
+ // o uso do material (o plano escolhe pelo pedido: "proposito"); vale para quem escreve
178
+ const PURPOSE_RULES = {
179
+ palestra: "- É para APRESENTAR (palestra): letra que se lê do fundo da sala. Não use \`dossier\` (página de consulta, letra pequena) nem encha um slide; o que é para ler depois vai em \`consulta\` (material de estudo) ou em outro slide.",
180
+ consulta: "- É material para ESTUDAR depois (consulta): a explicação fica no slide, em parágrafos curtos, com toda a informação (o aluno lê sozinho); letra legível, nada de \`dossier\` de letra miúda; sem slide só de título, sem \"número de impacto\"; código e contas completos.",
181
+ };
182
+ const MODE_RULES = {
183
+ melhorar: `MODO MELHORAR: o estilo é o do original (a moldura dele — faixa, logos, linha do título, número — já está no mestre do deck; use os layouts do sagadeck normalmente, sem redesenhar a moldura). Na capa, a moldura já traz os logos e o texto institucional do original: a capa nova leva só título, subtítulo, autor e data (não repita logo nem instituição). O título dos slides de conteúdo vai na faixa do título da moldura: uma linha curta, como no original. O aprofundamento que não cabe no slide vai em \`consulta\` (material de estudo). Mantenha a ORDEM do original; inclusões entram perto do assunto. Melhore onde ganha: estrutura, clareza, interação, exercícios, redesenho de figura genérica. Slide que já está bom: "manter". Cada slide que mudar vai ser marcado para o professor validar.`,
184
+ recriar: `MODO RECRIAR: uma apresentação nova, do zero, com o melhor que o sagadeck faz (escolha o tema em "tema"). O tema e a paleta: os que o pedido disser (ou o jeito que ele pedir: "colorido", "sóbrio"); sem nada no pedido, o que você achar melhor para o assunto. Tema com fundo e identidade próprios (relevo, arcade, aluminio, terminal…) já traz as cores dele: paleta só se o pedido falar de cores (a paleta troca o fundo; "grafite" deixa o relevo claro e os destaques em tarja cinza). A ordem pode mudar se a didática ganhar (seções, uma ideia por slide, exercícios no ponto certo). "manter" não vale; use "juntar", "escrever" e "novo". Todo slide original precisa ir para algum item (o conteúdo dele não pode sumir).`,
185
+ };
186
+
187
+ // ------------------------------------------------------------------------------------------------ tarefa
188
+ // A tarefa é persistente: o estado vai para .sagadeck/transform/tarefa-<modo>.json a cada etapa e a cada bloco;
189
+ // pedir de novo (mesmo modo, mesmo original) RETOMA de onde parou. Cancelar (signal) chega até a chamada ao modelo.
190
+ // Limites de chamadas, tokens e tempo param com um resultado parcial aproveitável. Estados: concluido | parcial |
191
+ // revisar (terminou, mas algo precisa do professor).
192
+ export const ANALYSIS_VERSION = 2; // muda quando o pedido ao modelo de visão muda: o cache antigo não vale
193
+ export class TransformStop extends Error { constructor(msg, kind) { super(msg); this.kind = kind; } }
194
+ const sha1 = (buf) => crypto.createHash("sha1").update(buf).digest("hex");
195
+ // calls: as chamadas que escrevem e pensam; looks: as de olhar (conferência visual de cada slide, localização dos
196
+ // destaques), curtas e sem raciocínio, uma por slide a cada rodada de correção
197
+ const DEFAULT_LIMITS = { calls: 300, looks: 2000, tokens: 6_000_000, minutes: 120 };
198
+ // impressão dos slides originais (o que a tarefa transforma): outra importação = outra tarefa
199
+ export function sourceHash(spec) {
200
+ const originals = (spec?.slides || []).filter((s) => s.layout === "canvas" && s.original);
201
+ return sha1(JSON.stringify(originals.map((s) => ({ n: s.original.slide, e: s.elements, notes: s.notes }))));
202
+ }
203
+ // a tarefa guardada (para retomar e para mostrar o andamento): { status, stage, srcHash, feitos, itens, updated } | null
204
+ export function jobStatus(dir, mode) {
205
+ try {
206
+ const j = JSON.parse(fs.readFileSync(path.join(dir, ".sagadeck", "transform", `tarefa-${mode}.json`), "utf8"));
207
+ return { mode, status: j.status, stage: j.stage, srcHash: j.srcHash, feitos: Object.keys(j.results || {}).length, itens: j.plan?.slides?.length || 0, calls: j.calls, updated: j.updated };
208
+ } catch { return null; }
209
+ }
210
+
211
+ export async function transformDeck({ spec, dir, mode = "melhorar", request = "", visualReferences = [], onProgress = () => {}, textModel, visionModel, maxRounds = 2, signal, limits = {}, log = () => {}, resume = true }) {
212
+ if (!["melhorar", "recriar"].includes(mode)) throw new Error(`modo desconhecido: ${mode}`);
213
+ const originals = (spec.slides || []).filter((s) => s.layout === "canvas" && s.original);
214
+ if (!originals.length) throw new Error("Esta apresentação não veio de uma importação (importe o PowerPoint primeiro: Biblioteca › Importar apresentação).");
215
+ const cfg = llmConfig();
216
+ const T = textModel || cfg.textModel, V = visionModel || cfg.visionModel;
217
+ const lim = { ...DEFAULT_LIMITS, ...Object.fromEntries(Object.entries(limits || {}).filter(([, v]) => Number(v) > 0)) };
218
+ const cacheDir = path.join(dir, ".sagadeck", "transform");
219
+ fs.mkdirSync(cacheDir, { recursive: true });
220
+ const srcHash = sourceHash(spec);
221
+ const jobFile = path.join(cacheDir, `tarefa-${mode}.json`);
222
+ let job = null;
223
+ if (resume) { try { const j = JSON.parse(fs.readFileSync(jobFile, "utf8")); if (j.srcHash === srcHash && j.status !== "concluido") job = j; } catch {} }
224
+ const resumed = !!job;
225
+ if (!job) job = { version: 1, mode, request, srcHash, status: "andamento", stage: "ver", plan: null, results: {}, checks: {}, usage: {}, calls: 0, spentMs: 0, created: new Date().toISOString() };
226
+ job.textModel = T; job.visionModel = V; job.request = request || job.request; job.status = "andamento";
227
+ const save = () => { job.updated = new Date().toISOString(); fs.writeFileSync(jobFile, JSON.stringify(job)); };
228
+ // resposta do modelo que não deu para usar: fica guardada (as 40 últimas) para entender o que ele mandou
229
+ const failDir = path.join(cacheDir, "falhas");
230
+ const keepFailure = (name, text, err) => {
231
+ try {
232
+ fs.mkdirSync(failDir, { recursive: true });
233
+ fs.writeFileSync(path.join(failDir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${name}.txt`), `${err?.message || ""}\n\n----- resposta -----\n${text}`);
234
+ const all = fs.readdirSync(failDir).sort();
235
+ for (const f of all.slice(0, Math.max(0, all.length - 40))) fs.rmSync(path.join(failDir, f), { force: true });
236
+ } catch {}
237
+ };
238
+ const t0 = Date.now(), spentBefore = job.spentMs || 0;
239
+ const tokens = () => Object.values(job.usage).reduce((a, u) => a + u.in + u.out, 0);
240
+ const ask = async (messages, { model = T, maxTokens = 16000, temperature = 0.3, think } = {}) => {
241
+ if (signal?.aborted) throw new TransformStop("Parado a pedido.", "cancelado");
242
+ const look = think === false;
243
+ if (!look && job.calls >= lim.calls) throw new TransformStop(`Limite de ${lim.calls} chamadas.`, "limite");
244
+ if (look && (job.looks || 0) >= lim.looks) throw new TransformStop(`Limite de ${lim.looks} conferências.`, "limite");
245
+ if (tokens() >= lim.tokens) throw new TransformStop(`Limite de ${lim.tokens} tokens.`, "limite");
246
+ if (spentBefore + Date.now() - t0 >= lim.minutes * 60000) throw new TransformStop(`Limite de ${lim.minutes} minutos.`, "limite");
247
+ if (look) job.looks = (job.looks || 0) + 1; else job.calls++;
248
+ let r;
249
+ // em streaming (os cabeçalhos chegam logo; chamada longa não cai no limite de 300 s do fetch) e mostrando o texto
250
+ // chegando; erro passageiro (rede, 5xx) tenta de novo uma vez
251
+ let shown = 0;
252
+ const onDelta = (_, all) => { if (all.length - shown > 1500) { shown = all.length; onProgress({ ...lastProgress, chars: all.length }); } };
253
+ for (let attempt = 1; ; attempt++) {
254
+ try { r = await chat(messages, { model, maxTokens, temperature, think, signal, onDelta }); break; }
255
+ catch (e) {
256
+ if (signal?.aborted) throw new TransformStop("Parado a pedido.", "cancelado");
257
+ const transient = !e.status || e.status >= 500 || e.status === 429;
258
+ if (attempt >= 2 || !transient) throw e;
259
+ log({ retry: e.message });
260
+ await new Promise((ok) => setTimeout(ok, 3000));
261
+ }
262
+ }
263
+ const u = job.usage[r.model || model] || (job.usage[r.model || model] = { calls: 0, in: 0, out: 0 });
264
+ u.calls++; u.in += r.usage?.prompt_tokens || 0; u.out += r.usage?.completion_tokens || 0;
265
+ log({ model: r.model || model, usage: r.usage });
266
+ return r;
267
+ };
268
+ let lastProgress = {};
269
+ const progress = (phase, text, extra = {}) => { lastProgress = { phase: `transform-${phase}`, text, ...extra }; onProgress(lastProgress); };
270
+ if (resumed) for (const [k, r] of Object.entries(job.results)) if (r.state === "falhou") delete job.results[k]; // tenta de novo o que não saiu
271
+ if (resumed) progress("retomar", `Retomando a tarefa de onde parou (${Object.keys(job.results).length} item(ns) já prontos).`);
272
+ const orig = new Map(originals.map((s) => [s.original.slide, s]));
273
+ const imgFile = (s) => s.original?.image && path.join(dir, s.original.image);
274
+ const imgCount = new Map();
275
+ for (const s of originals) for (const e of s.elements) if (e.image) imgCount.set(e.image, (imgCount.get(e.image) || 0) + 1);
276
+ const contentImages = (s) => [...new Set(s.elements.filter((e) => e.image && !e.deco && imgCount.get(e.image) <= Math.max(2, originals.length * 0.2)).map((e) => e.image))];
277
+ const report = { faltando: [], problemas: [], pendentes: [], revisar: [] };
278
+ let stop = null;
279
+
280
+ // ---- 1. VER — cache pelo conteúdo da foto do slide (e pela versão da análise); falha não conta como feita
281
+ const figFile = path.join(cacheDir, "figuras.json");
282
+ let figCache = {};
283
+ try { figCache = JSON.parse(fs.readFileSync(figFile, "utf8")); } catch {}
284
+ const figKey = (s) => { const f = imgFile(s); return f && fs.existsSync(f) ? `${sha1(fs.readFileSync(f))}:v${ANALYSIS_VERSION}` : null; };
285
+ const figs = {};
286
+ const candidates = originals.filter((s) => imgFile(s) && fs.existsSync(imgFile(s)) && (contentImages(s).length || s.elements.filter((e) => e.drawing && !e.deco).length >= 3 || s.elements.some((e) => e.table)));
287
+ // o cache de antes da "aparencia" (digital, foto, escaneada, pixelada) é olhado de novo: sem ela, nada é redesenhado
288
+ // ("qualidade: boa/ruim" era subjetivo: a visão achava boa até o mapa de xerox)
289
+ for (const s of candidates) { const k = figKey(s); if (k && figCache[k] && (figCache[k].figuras || []).every((f) => "aparencia" in f && "arquivo" in f)) figs[s.original.slide] = figCache[k]; }
290
+ const needFig = candidates.filter((s) => !figs[s.original.slide]);
291
+ try {
292
+ if (needFig.length) {
293
+ const { imagesAsDataUrls } = await import("../import/crop.js");
294
+ const urls = await imagesAsDataUrls(needFig.map(imgFile), { width: 1024 });
295
+ const queue = [];
296
+ for (let k = 0; k < needFig.length; k += 4) queue.push(needFig.slice(k, k + 4));
297
+ const urlOf = new Map(needFig.map((s, j) => [s, urls[j]]));
298
+ // lotes em paralelo, como a escrita (SAGADECK_TRANSFORM_PARALLEL); o que volta para a fila (resposta cortada) é
299
+ // pego por quem estiver livre
300
+ let qi = 0, halted = null;
301
+ const look = async (batch) => {
302
+ const k = needFig.indexOf(batch[0]);
303
+ progress("ver", `Olhando as figuras do original (slides ${batch.map((s) => s.original.slide).join(", ")})…`, { done: k, total: needFig.length });
304
+ const content = [{ type: "text", text: `Fotos de slides de uma apresentação. Para cada slide, descreva as FIGURAS (não o texto corrido, que eu já tenho): o que é (esquema, mapa, gráfico, foto, tabela em imagem, equação em imagem, desenho), se é GENÉRICA (conceito que qualquer livro desenha igual e pode ser redesenhado sem perder nada) ou ESPECÍFICA (mapa de um lugar, dado de experimento, foto real, gráfico com dados que não estão no texto: tem que ser mantida), a APARÊNCIA da imagem ("digital": desenho limpo feito no computador; "foto": fotografia real; "escaneada": papel escaneado, fotocopiado ou fotografado, com traço grosso, manchas ou preto e branco chapado, de livro ou apostila; "pixelada": baixa resolução, borrada, serrilhada) e transcreva os dados legíveis (números, rótulos, eixos, legendas, fórmulas em LaTeX). TABELA em imagem: transcreva-a INTEIRA, uma linha por linha com as células separadas por " | " (o cabeçalho primeiro), e diga se algum valor ficou ilegível. Diga também se o slide parece continuação do anterior (o mesmo desenho com partes a mais). Cada figura que é uma das imagens dadas leva o "arquivo" dela (o nome que vem antes da imagem); figura desenhada no próprio slide (formas, caixas de texto) leva "arquivo": "". Mapa ou desenho em preto e branco de traço grosso, com letras de máquina ou à mão, de livro antigo, é "escaneada".
305
+ Responda só JSON: {"slides":[{"n":6,"figuras":[{"arquivo":"imagens/original/image4.png","tipo":"esquema","generica":true,"aparencia":"escaneada","o_que":"…","dados":"…"}],"continua_anterior":false}]}` }];
306
+ for (const s of batch) {
307
+ content.push({ type: "text", text: `Slide ${s.original.slide}:` }, { type: "image_url", image_url: { url: urlOf.get(s) } });
308
+ const files = contentImages(s);
309
+ if (files.length === 1) content.push({ type: "text", text: `(a imagem deste slide é o arquivo ${files[0]})` });
310
+ if (files.length > 1) {
311
+ const urls = await imagesAsDataUrls(files.map((f) => path.join(dir, f)), { width: 640 });
312
+ files.forEach((f, j) => { if (urls[j]) content.push({ type: "text", text: `Arquivo ${f} (uma das imagens do slide ${s.original.slide}):` }, { type: "image_url", image_url: { url: urls[j] } }); });
313
+ }
314
+ }
315
+ try {
316
+ const r = await ask([{ role: "user", content }], { model: V, maxTokens: 16000 });
317
+ const got = jsonLoose(r.text);
318
+ for (const it of got.slides || []) {
319
+ const s = orig.get(Number(it.n));
320
+ if (!s) continue;
321
+ figs[s.original.slide] = it;
322
+ const key = figKey(s); if (key) figCache[key] = it;
323
+ }
324
+ // resposta cortada: os que ficaram de fora vão de novo, um por vez
325
+ const left = batch.filter((s) => !figs[s.original.slide]);
326
+ if (left.length && batch.length > 1) left.forEach((s) => queue.push([s]));
327
+ else if (left.length) report.problemas.push(`não consegui ver as figuras do slide ${left[0].original.slide} (resposta incompleta): segue só com o texto`);
328
+ } catch (e) {
329
+ if (e instanceof TransformStop) { halted = halted || e; return; }
330
+ if (batch.length > 1) batch.forEach((s) => queue.push([s]));
331
+ else report.problemas.push(`não consegui ver as figuras do slide ${batch[0].original.slide} (${clip(e.message, 120)}): segue só com o texto`);
332
+ }
333
+ fs.writeFileSync(figFile, JSON.stringify(figCache, null, 1));
334
+ };
335
+ const lookWorker = async () => { while (!halted && qi < queue.length) await look(queue[qi++]); };
336
+ const lanes = Math.max(1, Math.min(16, Number(process.env.SAGADECK_TRANSFORM_PARALLEL) || 8));
337
+ await Promise.all(Array.from({ length: Math.min(lanes, queue.length) }, lookWorker));
338
+ if (halted) throw halted;
339
+ }
340
+ } catch (e) { if (e instanceof TransformStop) stop = e; else throw e; }
341
+
342
+ const brief = (s) => {
343
+ const n = s.original.slide;
344
+ const f = figs[n]?.figuras?.length ? `\n figuras: ${figs[n].figuras.map((x) => `${x.arquivo ? `(arquivo ${x.arquivo}) ` : ""}[${x.generica ? "genérica" : "ESPECÍFICA"}${/escaneada|pixelada/.test(x.aparencia || "") ? `, imagem ${String(x.aparencia).toUpperCase()}` : ""}] ${x.tipo}: ${x.o_que}${x.dados ? ` — dados: ${x.dados}` : ""}`).join(" | ")}` : "";
345
+ const imgs = contentImages(s);
346
+ return `### slide ${n}: ${s.title || ""}\n${textsOfSlide(s).map((t) => ` ${t.replace(/\n/g, "\n ")}`).join("\n")}${f}${imgs.length ? `\n imagens: ${imgs.join(", ")}` : ""}${s.notes ? `\n notas: ${clip(s.notes, 1500)}` : ""}${figs[n]?.continua_anterior ? "\n (parece continuação do slide anterior: o mesmo desenho com partes a mais)" : ""}`;
347
+ };
348
+
349
+ // ---- 2. PLANEJAR (fica salvo: retomar não replaneja)
350
+ const style = mode === "melhorar" ? styleFromImport(spec) : null;
351
+ if (!job.plan && !stop) {
352
+ job.stage = "plano"; save();
353
+ progress("plano", "Planejando o que fazer com cada slide…");
354
+ const planMsg = [
355
+ { role: "system", content: `${systemPrompt({ images: true, maxImages: 6 })}\n\n${TOOLBOX}\n\n${CONTENT_RULES}\n\n${MODE_RULES[mode]}` },
356
+ { role: "user", content: `Pedido do professor: ${request || (mode === "melhorar" ? "melhore a apresentação mantendo o estilo" : "recrie a apresentação do zero")}
357
+
358
+ Apresentação original (${originals.length} slides; proporção ${spec.aspect || "16:9"}):
359
+ ${originals.map(brief).join("\n\n")}
360
+
361
+ Faça o PLANO. Responda só com um bloco \`\`\`json:
362
+ {"tema": "${mode === "recriar" ? "um dos temas do sagadeck" : "(ignorado no modo melhorar)"}", ${mode === "recriar" ? '"paleta": "uma das paletas do sagadeck, se o pedido ou o assunto pedir (senão omita)", ' : ""}"proposito": "palestra (para apresentar; o padrão) ou consulta (para o aluno estudar depois), pelo pedido", "titulo": "título da apresentação", "alertas": ["possível erro no conteúdo, com o slide"], "slides": [{"acao": "manter|juntar|escrever|novo", "de": [3], "ideia": "o que vai ter e qual layout/recurso", "imagens": ["imagens/…"]}]}
363
+ Todos os slides de 1 a ${originals.length} precisam aparecer em algum "de". Mantenha "ideia" curta (1 a 2 frases).` },
364
+ ];
365
+ if (visualReferences.length) planMsg[1].content = [{ type: "text", text: planMsg[1].content + "\nReferências visuais anexadas: inspire-se no acabamento; descreva a direção de arte nas ideias de cada item, sem copiar dados ou geografia." }, ...visualReferences.slice(0, 5).map(v => ({ type: "image_url", image_url: { url: v.dataUrl } }))];
366
+ try {
367
+ for (let attempt = 1; attempt <= 3 && !job.plan; attempt++) {
368
+ const r = await ask(planMsg, { maxTokens: 24000 });
369
+ try {
370
+ const p = jsonOf(r.text);
371
+ if (!Array.isArray(p.slides) || !p.slides.length) throw new Error('o JSON precisa de "slides" com os itens do plano');
372
+ job.plan = p;
373
+ } catch (e) { planMsg.push({ role: "assistant", content: r.text }, { role: "user", content: `Não deu para ler o plano (${e.message}). Responda de novo só com o bloco \`\`\`json, completo.` }); }
374
+ }
375
+ } catch (e) { if (e instanceof TransformStop) stop = e; else throw e; }
376
+ if (job.plan) {
377
+ // cobertura: todo slide original em algum item (o que faltar entra onde estava)
378
+ const covered = new Set(job.plan.slides.flatMap((it) => (it.de || []).map(Number)));
379
+ for (const n of originals.map((s) => s.original.slide).filter((n) => !covered.has(n))) {
380
+ const at = job.plan.slides.findIndex((it) => Math.min(...(it.de || [Infinity])) > n);
381
+ const item = { acao: mode === "melhorar" ? "manter" : "escrever", de: [n], ideia: "(o plano esqueceu este slide: entra aqui para não perder o conteúdo)", auto: true };
382
+ if (at < 0) job.plan.slides.push(item); else job.plan.slides.splice(at, 0, item);
383
+ }
384
+ // recriar é redesenhar: nem "manter" nem "juntar" (juntar os progressivos guarda os desenhos velhos do original)
385
+ if (mode === "recriar") job.plan.slides.forEach((it) => { if (it.acao === "manter" || it.acao === "juntar") it.acao = "escrever"; });
386
+ save();
387
+ }
388
+ }
389
+ if (!job.plan) {
390
+ job.status = "parcial"; job.spentMs = spentBefore + Date.now() - t0; save();
391
+ if (stop) throw stop;
392
+ throw new Error("A IA não conseguiu fazer o plano.");
393
+ }
394
+ const plan = job.plan;
395
+
396
+ // ---- 3. ESCREVER em blocos + 4. CONFERIR (fatos por código, desenho por visão, de novo depois de cada correção)
397
+ const deckBase = mode === "melhorar"
398
+ ? { title: spec.title, aspect: spec.aspect, theme: style.theme, master: style.master, footer: false }
399
+ : { title: plan.titulo || spec.title, aspect: spec.aspect, theme: plan.tema && typeof plan.tema === "string" ? plan.tema : "oceano" };
400
+ // para que é (o que o pedido disse: o plano escolhe) e, no recriar, a paleta; o que não existe fica no padrão
401
+ deckBase.purpose = PURPOSE_RULES[plan.proposito] ? plan.proposito : "palestra";
402
+ try {
403
+ const { THEMES, PALETTES } = await import("../themes.js");
404
+ if (typeof deckBase.theme === "string" && !THEMES[deckBase.theme]) deckBase.theme = "oceano";
405
+ if (mode === "recriar" && typeof plan.paleta === "string" && PALETTES[plan.paleta]) deckBase.palette = plan.paleta;
406
+ } catch {}
407
+ const writeSystem = `${systemPrompt({ images: true, maxImages: 6 })}\n\n${CONTENT_RULES}\n${PURPOSE_RULES[deckBase.purpose]}\n\n${MODE_RULES[mode]}\n\nFormato: responda com UM bloco \`\`\`yaml com \`slides:\` (a lista de slides completos). Cada slide leva \`origem: N\` (o número do ITEM do plano de onde ele saiu) e \`mudou: "uma frase: o que mudou em relação ao original"\`. Um item pode virar mais de um slide. Caminhos de imagem: só os que foram dados. Coloque entre aspas todo texto com ": " ou que comece com marcação.`;
408
+ const srcOf = (it) => (it.de || []).map((n) => orig.get(Number(n))).filter(Boolean);
409
+ const vocab = vocabularyOf(originals);
410
+ // manter e juntar: código (sem IA)
411
+ plan.slides.forEach((it, k) => {
412
+ if (job.results[k]) return;
413
+ const src = srcOf(it);
414
+ if (it.acao === "manter" && src.length) job.results[k] = { slides: src.map((s) => structuredClone(s)), state: "ok" };
415
+ if (it.acao === "juntar" && src.length) {
416
+ const merged = mergeProgressive(src);
417
+ if (mode === "melhorar") merged.review = { status: "alterado", note: `Os slides ${src.map((s) => s.original.slide).join(", ")} viraram um só, que se monta por cliques (o mesmo desenho).`, original: src[0].original.slide };
418
+ job.results[k] = { slides: [merged], state: "ok" };
419
+ }
420
+ });
421
+ job.stage = "escrever"; save();
422
+ // fatos do original: o texto do slide e, para cada figura que NÃO foi mantida no novo, o que a visão leu nela (eixos,
423
+ // rótulos). Figura mantida (a mesma imagem no slide novo) já carrega os números dela: não cobra de novo no texto.
424
+ // Pendência é só o que estava ESCRITO no original (texto, tabela do PowerPoint, notas) e sumiu. O que a visão leu
425
+ // numa figura (eixos, coordenadas de mapa, rótulos) é aproximado e, numa figura redesenhada, nem precisa estar no
426
+ // texto: vira dica para a correção (figureFacts), nunca pendência. A imagem do original também não é cobrada: a IA
427
+ // decide se redesenha ou mantém (a conferência visual compara com a foto do original e aponta o que se perdeu).
428
+ const factsFor = (it) => {
429
+ const facts = { numbers: new Set(), terms: new Set(), alts: new Map() };
430
+ for (const s of srcOf(it)) { const f = factsOf(s); f.numbers.forEach((x) => facts.numbers.add(x)); f.terms.forEach((x) => facts.terms.add(x)); f.alts.forEach((v, key) => facts.alts.set(key, v)); }
431
+ return facts;
432
+ };
433
+ const figureFacts = (it, produced = []) => {
434
+ const facts = { numbers: new Set(), terms: new Set(), alts: new Map() };
435
+ const used = JSON.stringify(produced);
436
+ for (const s of srcOf(it)) {
437
+ const kept = contentImages(s).length && contentImages(s).every((im) => used.includes(im));
438
+ if (kept) continue;
439
+ const f = factsOf({ elements: [] }, figs[s.original.slide]?.figuras?.map((x) => x.dados || "").join(" "));
440
+ f.numbers.forEach((x) => facts.numbers.add(x)); f.terms.forEach((x) => facts.terms.add(x)); f.alts.forEach((v, key) => facts.alts.set(key, v));
441
+ }
442
+ return facts;
443
+ };
444
+ // imagem do original que a visão viu como gráfico, mapa, tabela, esquema (não foto): sai inteira (fit: contain).
445
+ // Cortar perde eixo e legenda, e no layout image a legenda ia por cima do gráfico (com contain, vai embaixo).
446
+ const notPhoto = new Set();
447
+ for (const s of originals) {
448
+ const kinds = (figs[s.original.slide]?.figuras || []).map((f) => String(f.tipo || ""));
449
+ if (kinds.length && kinds.some((t) => !/foto|photo|fotografia/i.test(t))) contentImages(s).forEach((im) => notPhoto.add(im));
450
+ }
451
+ const wholeFigures = (slides) => {
452
+ const walk = (o) => {
453
+ if (!o || typeof o !== "object") return;
454
+ if (typeof o.image === "string" && notPhoto.has(o.image) && (!o.fit || o.fit === "cover")) o.fit = "contain";
455
+ for (const v of Object.values(o)) if (v && typeof v === "object") walk(v);
456
+ };
457
+ slides.forEach(walk);
458
+ return slides;
459
+ };
460
+ const siblingsOf = (k) => plan.slides.map((x, j) => (j !== k && (x.de || []).some((n) => (plan.slides[k].de || []).includes(n)) ? j : -1)).filter((j) => j >= 0);
461
+ const writable = plan.slides.map((it, k) => ({ it, k })).filter(({ it, k }) => (it.acao === "escrever" || it.acao === "novo") && !job.results[k]);
462
+ const BATCH = 5;
463
+ const batches = [];
464
+ for (let b = 0; b < writable.length; b += BATCH) batches.push(writable.slice(b, b + BATCH));
465
+ // blocos em paralelo (cada um é independente: o plano já diz o que vai em cada item); SAGADECK_TRANSFORM_PARALLEL
466
+ // troca quantos de cada vez (1 = um por vez, como antes)
467
+ const parallel = Math.max(1, Math.min(16, Number(process.env.SAGADECK_TRANSFORM_PARALLEL) || 8));
468
+ // image_prompt (e image_ref, o redesenho de uma figura ruim do original) vira arquivo em imagens/ia; o que não sai
469
+ // volta a ser a figura de base (ou um ícone, sem base)
470
+ // o que a visão viu em cada imagem do original (pelos tipos das figuras do slide dela): gráfico, tabela e equação
471
+ // não vão para o modelo de imagem (saíam como figura com número trocado e rótulo em inglês, e a fórmula como foto);
472
+ // são refeitos com chart, table e LaTeX. Só ilustração (esquema, mapa, desenho, corte) é redesenhada.
473
+ const tiposDe = new Map(), figDe = new Map();
474
+ for (const s of originals) {
475
+ const fs_ = figs[s.original.slide]?.figuras || [], files = contentImages(s);
476
+ for (const img of files) tiposDe.set(img, fs_.map((f) => String(f.tipo || "")));
477
+ // a figura de cada arquivo (a visão diz qual é qual; com uma imagem só e uma figura, é ela)
478
+ for (const f of fs_) if (f.arquivo && files.includes(f.arquivo)) figDe.set(f.arquivo, f);
479
+ if (files.length === 1 && fs_.length === 1 && !figDe.has(files[0])) figDe.set(files[0], fs_[0]);
480
+ }
481
+ const tipoDe = (img) => (figDe.has(img) ? [String(figDe.get(img).tipo || "")] : tiposDe.get(img));
482
+ const naoIlustracao = (ref) => { const t = tipoDe(ref); return !!t?.length && t.every((x) => /gr[aá]fico|tabela|equa|f[oó]rmula/i.test(x)); };
483
+ const ilustracaoRuim = (img) => { const f = figDe.get(img); return !!f && /mapa|esquema|desenho|ilustra|corte|perfil/i.test(f.tipo || "") && !/gr[aá]fico|tabela|equa/i.test(f.tipo || "") && /escaneada|pixelada/.test(f.aparencia || ""); };
484
+ const themeName = typeof deckBase.theme === "string" ? deckBase.theme : "clean";
485
+ const keepDataFigures = (node) => {
486
+ if (!node || typeof node !== "object") return;
487
+ if (Array.isArray(node)) return node.forEach(keepDataFigures);
488
+ if (typeof node.image_ref === "string" && node.image_prompt && naoIlustracao(node.image_ref)) {
489
+ report.problemas.push(`redesenho recusado (${node.image_ref} é gráfico, tabela ou equação: refaça com chart, table ou LaTeX)`);
490
+ node.image = node.image_ref; delete node.image_prompt; delete node.image_ref;
491
+ }
492
+ // ilustração de xerox que a escritora deixou como estava: o redesenho é automático (a regra pedia e ela ignorava;
493
+ // o mapa do posto fluviométrico ficou o xerox); a conferência visual compara com o original
494
+ if (typeof node.image === "string" && !node.image_prompt && !node.image_ref && ilustracaoRuim(node.image)) {
495
+ const f = figDe.get(node.image);
496
+ node.image_prompt = `Clean up and redraw THIS EXACT figure as if traced over it: same aspect ratio, same framing, every line in the same place; crisp lines and gentle colors that fit a "${themeName}" presentation. Redraw only the figure itself: if the source is a screenshot or a scanned page (window frame, toolbar, scroll bars, page edges, the book's caption line), leave them out. It shows: ${clip(f.o_que, 300)}. Copy every text label exactly as written, letter by letter, at the same position${f.dados ? ` (${clip(f.dados, 300)})` : ""}; leave out what is unreadable; add no new text.`;
497
+ node.image_ref = node.image;
498
+ if (!node.fit) node.fit = "contain";
499
+ if (!node.alt && f.o_que) node.alt = clip(f.o_que, 160); // senão o texto alternativo vira o pedido ao modelo de imagem
500
+ }
501
+ for (const v of Object.values(node)) keepDataFigures(v);
502
+ };
503
+ // imagem do original que é uma EQUAÇÃO num slide novo: a fórmula vai em LaTeX (a imagem ficava, e às vezes no slide
504
+ // errado: a fórmula de P no lugar do hidrograma). Volta para a correção com o que a visão leu.
505
+ const equationImages = (slides) => {
506
+ const out = [];
507
+ slides.forEach((sl, j) => {
508
+ const walk = (o) => { if (!o || typeof o !== "object") return; if (Array.isArray(o)) return o.forEach(walk);
509
+ if (typeof o.image === "string") { const f = figDe.get(o.image); if (f && /equa|f[oó]rmula/i.test(f.tipo || "")) out.push({ j, img: o.image, f }); }
510
+ for (const v of Object.values(o)) walk(v); };
511
+ walk(sl);
512
+ });
513
+ return out;
514
+ };
515
+ const groundMemo = new Map();
516
+ const drawImages = async (slides) => {
517
+ keepDataFigures(slides);
518
+ if (countImagePrompts({ slides })) {
519
+ if (signal?.aborted) throw new TransformStop("Parado a pedido.", "cancelado");
520
+ progress("escrever", "Desenhando as ilustrações…");
521
+ const r = await materializeImages({ slides }, { baseDir: dir, assetsDir: path.join(dir, "imagens", "ia"), max: 6 });
522
+ job.images = (job.images || 0) + r.done.length;
523
+ for (const f of r.failed) report.problemas.push(`ilustração não saiu (${clip(f.error, 100)}): ${clip(f.prompt, 80)}`);
524
+ }
525
+ // foco guiado: a visão olha a figura e põe cada destaque no lugar (a escritora não vê a figura e chutava)
526
+ await groundSpotlights(slides, { baseDir: dir, memo: groundMemo, onFail: (t, e) => report.problemas.push(`foco guiado "${clip(t.replace(/[=*]/g, ""), 60)}": não localizei os destaques na figura (${clip(e.message, 120)}); as caixas são as da escrita`), ask: async (content) => (await ask([{ role: "user", content }], { model: V, maxTokens: 8000, think: false })).text, onProgress: (t) => progress("conferir", t) });
527
+ };
528
+ const runBatch = async (batch) => {
529
+ if (stop) return;
530
+ try {
531
+ progress("escrever", `Escrevendo ${batch.length === 1 ? "o item" : "os itens"} ${batch.map(({ k }) => k + 1).join(", ")} de ${plan.slides.length}…`, { done: Object.keys(job.results).length, total: plan.slides.length });
532
+ const itemsText = batch.map(({ it, k }) => `## ITEM ${k + 1} — ${it.acao}: ${it.ideia}${it.imagens?.length ? `\nImagens para usar: ${it.imagens.join(", ")}` : ""}\nOriginal:\n${srcOf(it).map(brief).join("\n\n") || "(nenhum)"}`).join("\n\n");
533
+ const before = plan.slides.slice(Math.max(0, batch[0].k - 3), batch[0].k).map((it) => `- ${it.ideia}`).join("\n");
534
+ const messages = [
535
+ { role: "system", content: writeSystem },
536
+ { role: "user", content: `Pedido completo da pessoa (preserve a direção visual em cada bloco): ${job.request}\n\nDeck: título "${deckBase.title}", tema ${typeof deckBase.theme === "string" ? deckBase.theme : "o do original (mestre já aplicado)"}.${before ? `\nLogo antes vêm:\n${before}` : ""}\n\nEscreva os slides destes itens do plano:\n\n${itemsText}` },
537
+ ];
538
+ let produced = null, lastErr = null;
539
+ for (let attempt = 1; attempt <= 3 && !produced; attempt++) {
540
+ const r = await ask(messages);
541
+ try { produced = wholeFigures(parseProduced(r.text, batch, deckBase, dir)); }
542
+ catch (e) { lastErr = e; keepFailure(`escrita-${batch.map(({ k }) => k + 1).join("-")}`, r.text, e); messages.push({ role: "assistant", content: r.text }, { role: "user", content: `Não deu para usar:\n${e.message}\nCorrija e responda de novo com o bloco \`\`\`yaml completo.` }); }
543
+ }
544
+ // o bloco não saiu (a resposta parou no meio e faltaram itens, por exemplo): cada item vai de novo, sozinho,
545
+ // para a fila; só o item que não sai nem sozinho fica como falhou
546
+ if (!produced && batch.length > 1) { batches.push(...batch.map((b) => [b])); return; }
547
+ if (!produced) {
548
+ report.problemas.push(`itens ${batch.map(({ k }) => k + 1).join(", ")}: não saíram (${clip(lastErr?.message, 160)})`);
549
+ for (const { k } of batch) job.results[k] = { slides: null, state: "falhou" };
550
+ save(); return;
551
+ }
552
+ await drawImages(produced);
553
+ // conferir → corrigir → conferir de novo (a última conferência também olha o desenho)
554
+ let issues = [], hints = [];
555
+ for (let round = 0; ; round++) {
556
+ issues = []; hints = [];
557
+ for (const { k } of batch) {
558
+ const typos = typosOf(produced.filter((s) => s.origem === k + 1), vocab);
559
+ // dica, não erro: palavra certa também pode ser a do original com uma letra a menos ("estão" de "gestão")
560
+ if (typos.length) hints.push(`ITEM ${k + 1}: confira a grafia (letra faltando ou sem acento?): ${typos.slice(0, 15).join(", ")}`);
561
+ }
562
+ for (const { j, img, f } of equationImages(produced)) {
563
+ issues.push({ k: (produced[j]?.origem || batch[0].k + 1) - 1, kind: "desenho", text: `slide novo ${j + 1} (item ${produced[j]?.origem}): a imagem ${img} é uma EQUAÇÃO${f.dados ? ` (${clip(f.dados, 200)})` : ""}, não uma figura: escreva a fórmula em LaTeX ($…$ no texto, ou equations/latex) e tire a imagem; se o slide precisava de outra figura, use o arquivo certo` });
564
+ }
565
+ for (const { it, k } of batch) {
566
+ if (!srcOf(it).length || it.acao === "novo") continue;
567
+ const mine = produced.filter((s) => s.origem === k + 1);
568
+ const sib = siblingsOf(k);
569
+ const others = [...sib.flatMap((j) => job.results[j]?.slides || []), ...produced.filter((s) => sib.includes(s.origem - 1))];
570
+ const miss = missingFacts(factsFor(it), [...mine, ...others]);
571
+ if (miss.numbers.length || miss.terms.length) issues.push({ k, kind: "fatos", miss, text: `ITEM ${k + 1}: faltou do original ${[miss.numbers.length ? `números ${miss.numbers.slice(0, 30).join(", ")}` : "", miss.terms.length ? `nomes/siglas ${miss.terms.slice(0, 20).join(", ")}` : ""].filter(Boolean).join("; ")}` });
572
+ const fig = missingFacts(figureFacts(it, [...mine, ...others]), [...mine, ...others]);
573
+ const figNums = fig.numbers.filter((x) => !miss.numbers.includes(x)), figTerms = fig.terms.filter((x) => !miss.terms.includes(x));
574
+ if (figNums.length || figTerms.length) hints.push(`ITEM ${k + 1}: a figura do original mostrava ${[figNums.length ? `os números ${figNums.slice(0, 20).join(", ")}` : "", figTerms.length ? `os nomes ${figTerms.slice(0, 12).join(", ")}` : ""].filter(Boolean).join(" e ")}; se a figura foi redesenhada, confira se o que importa está nela (marcações de eixo não precisam)`);
575
+ }
576
+ try {
577
+ progress("conferir", `Conferindo o desenho dos itens ${batch.map(({ k }) => k + 1).join(", ")}${round ? " (depois da correção)" : ""}…`);
578
+ const seen = await visualCheck({ produced, batch, deckBase, dir, orig, ask, model: V, mode, keepFailure });
579
+ for (const v of seen) issues.push({ ...v, kind: "desenho" });
580
+ if (seen.missed?.length) report.problemas.push(`conferência visual sem resposta nos slides novos ${seen.missed.join(", ")} dos itens ${batch.map(({ k }) => k + 1).join(", ")}`);
581
+ } catch (e) {
582
+ if (e instanceof TransformStop) throw e;
583
+ report.problemas.push(`conferência visual dos itens ${batch.map(({ k }) => k + 1).join(", ")} falhou: ${clip(e.message, 160)}`);
584
+ }
585
+ if (!issues.length || round >= maxRounds) break;
586
+ progress("corrigir", `Corrigindo ${issues.length} ponto(s) nos itens ${[...new Set(issues.map((x) => x.k + 1))].join(", ")}…`);
587
+ const fixMsg = [...messages, { role: "assistant", content: `\`\`\`yaml\n${YAML.stringify({ slides: produced })}\`\`\`` },
588
+ { role: "user", content: `Conferi estes slides contra o original e contra a foto de como ficaram:\n${issues.map((x) => `- ${x.text}`).join("\n")}${hints.length ? `\n\nDicas (não são erro): ${hints.map((x) => `\n- ${x}`).join("")}` : ""}\n\nDevolva o bloco \`\`\`yaml com TODOS os slides destes itens, corrigidos (o que faltou entra no slide, numa tabela ou em notes; problema de desenho: ajuste o layout, divida o slide ou reduza o texto).` }];
589
+ // a correção pode vir só com os itens corrigidos: os que não vieram ficam como estavam. Resposta que não serve
590
+ // (YAML quebrado, campo que não existe, imagem errada) volta com o erro, como na escrita
591
+ let fixed = null, fixErr = null, kept = false;
592
+ for (let attempt = 1; attempt <= 2 && !fixed && !kept; attempt++) {
593
+ try {
594
+ const r = await ask(fixMsg);
595
+ // resposta sem bloco nenhum ("não há o que corrigir": a conferência às vezes aponta o que não é erro):
596
+ // fica como está, os pontos seguem para a revisão da pessoa
597
+ if (!/```/.test(r.text)) { kept = true; break; }
598
+ try { fixed = wholeFigures(parseProduced(r.text, batch, deckBase, dir, { partial: true })); }
599
+ catch (e) { fixErr = e; keepFailure(`correcao-${batch.map(({ k }) => k + 1).join("-")}`, r.text, e); fixMsg.push({ role: "assistant", content: r.text }, { role: "user", content: `Não deu para usar:\n${e.message}\nCorrija e responda de novo com o bloco \`\`\`yaml.` }); }
600
+ } catch (e) { if (e instanceof TransformStop) throw e; fixErr = e; break; }
601
+ }
602
+ if (kept) break;
603
+ if (!fixed) { report.problemas.push(`correção dos itens ${batch.map(({ k }) => k + 1).join(", ")} falhou: ${clip(fixErr?.message, 160)}`); break; }
604
+ const got = new Set(fixed.map((x) => x.origem));
605
+ produced = batch.flatMap(({ k }) => (got.has(k + 1) ? fixed.filter((x) => x.origem === k + 1) : produced.filter((x) => x.origem === k + 1)));
606
+ await drawImages(produced);
607
+ }
608
+ for (const { it, k } of batch) {
609
+ const mine = produced.filter((s) => s.origem === k + 1).map((s) => { const out = { ...s }; const why = out.mudou; delete out.origem; delete out.mudou; out._why = why; return out; });
610
+ const factIssue = issues.find((x) => x.k === k && x.kind === "fatos");
611
+ const drawIssues = issues.filter((x) => x.k === k && x.kind === "desenho").map((x) => x.text);
612
+ job.results[k] = { slides: mine, state: factIssue ? "pendente" : drawIssues.length ? "revisar" : "ok", missing: factIssue?.text || null, drawing: drawIssues };
613
+ }
614
+ save();
615
+ } catch (e) {
616
+ if (e instanceof TransformStop) { stop = stop || e; return; }
617
+ // o bloco não saiu (o modelo falhou mesmo depois de tentar de novo): fica registrado e a tarefa segue;
618
+ // esses itens entram como o original e pedir de novo tenta só eles
619
+ report.problemas.push(`itens ${batch.map(({ k }) => k + 1).join(", ")}: ${clip(e.message, 160)}`);
620
+ save();
621
+ }
622
+ };
623
+ let nextBatch = 0;
624
+ const worker = async () => { while (!stop && nextBatch < batches.length) await runBatch(batches[nextBatch++]); };
625
+ await Promise.all(Array.from({ length: Math.min(parallel, batches.length) }, worker));
626
+
627
+ // pendente por fato que faltou: confere de novo com TODOS os itens prontos. Um item irmão (do mesmo slide original)
628
+ // escrito depois, ou noutro bloco ao mesmo tempo, pode ter levado o fato: aí não é pendência.
629
+ for (const [key, res] of Object.entries(job.results)) {
630
+ const k = Number(key), it = plan.slides[k];
631
+ if (res?.state !== "pendente" || !res.slides || !it) continue;
632
+ const all = [...res.slides, ...siblingsOf(k).flatMap((j) => job.results[j]?.slides || [])];
633
+ const miss = missingFacts(factsFor(it), all);
634
+ if (!miss.numbers.length && !miss.terms.length) { res.state = res.drawing?.length ? "revisar" : "ok"; res.missing = null; }
635
+ }
636
+ save();
637
+
638
+ // ---- 5. MONTAR — o que faltou conserva o original; a proposta fica pendente para o professor decidir
639
+ const slides = [];
640
+ const coverage = [];
641
+ // o original que volta para o deck (pendente, não saiu): no recriar, sem a moldura antiga (faixa, logos: o deco e o
642
+ // que se repete na mesma posição, como no estilo), que destoaria do tema novo; o conteúdo fica inteiro
643
+ const frameOf = mode === "recriar" ? repeatedFrame(originals.length > 1 ? originals.slice(1) : originals).isFrame : null;
644
+ const asOriginal = (s) => { const c = structuredClone(s); if (frameOf) c.elements = (c.elements || []).filter((e) => !frameOf(e)); return c; };
645
+ plan.slides.forEach((it, k) => {
646
+ const res = job.results[k];
647
+ const src = srcOf(it);
648
+ const reviewFor = (why, state) => (mode === "melhorar" || state !== "ok")
649
+ ? (it.acao === "novo" || !src.length ? { status: state === "ok" ? "novo" : state, note: clip(why || it.ideia, 220) } : { status: state === "ok" ? "alterado" : state, note: clip(why || it.ideia, 220), original: Number(it.de[0]) })
650
+ : null;
651
+ if (!res?.slides) {
652
+ // não saiu (falhou, parou no limite, cancelado): o original como era
653
+ const back = src.map((s) => (it.acao === "manter" ? structuredClone(s) : asOriginal(s)));
654
+ if (back.length && it.acao !== "manter") report.pendentes.push(`item ${k + 1} (${it.ideia}): ${res?.state === "falhou" ? "não saiu" : "não chegou a ser escrito"}; ficou o original (slide ${src.map((s) => s.original.slide).join(", ")})`);
655
+ slides.push(...back);
656
+ return;
657
+ }
658
+ if (res.state === "pendente" && src.length && mode === "recriar") {
659
+ // recriar: o original não volta para o deck novo (destoava e duplicava: a capa velha antes da nova); a proposta
660
+ // fica, marcada para conferir o que faltou (o original continua em original/)
661
+ slides.push(...res.slides.map(({ _why, ...s }) => ({ ...s, review: { status: "revisar", note: clip(`Faltou do original (slide ${src.map((x) => x.original.slide).join(", ")}): ${res.missing}. Confira e complete se fizer falta.`, 300), original: Number(it.de[0]) } })));
662
+ report.revisar.push(`item ${k + 1}: ${res.missing}`);
663
+ return;
664
+ }
665
+ if (res.state === "pendente" && src.length) {
666
+ // omissão: o original fica, e a proposta vem logo depois, marcada para decidir
667
+ const back = src.map((s) => ({ ...asOriginal(s), uid: newUid() }));
668
+ slides.push(...back);
669
+ slides.push(...res.slides.map(({ _why, ...s }) => ({ ...s, review: { status: "pendente", note: clip(`Proposta para o slide ${src.map((x) => x.original.slide).join(", ")}: ${res.missing}. O original ficou antes desta; aceitar tira o original, desfazer tira a proposta.`, 300), original: Number(it.de[0]), pair: back.map((b) => b.uid) } })));
670
+ report.pendentes.push(res.missing);
671
+ return;
672
+ }
673
+ const state = res.state === "revisar" ? "revisar" : "ok";
674
+ if (state === "revisar") report.revisar.push(`item ${k + 1}: ${res.drawing.join(" · ")}`);
675
+ slides.push(...res.slides.map(({ _why, ...s }) => {
676
+ const rv = s.review || (it.acao === "manter" && state === "ok" ? null : reviewFor(state === "revisar" ? `${_why || it.ideia}. Conferir: ${res.drawing.join("; ")}` : _why, state));
677
+ return rv ? { ...s, review: rv } : s;
678
+ }));
679
+ });
680
+ const out = { ...deckBase, ...(mode === "melhorar" ? { import: spec.import, style: { name: style.name } } : { recreatedFrom: spec.import?.from || spec.title }), slides };
681
+ ensureUids(out);
682
+ // mapa de cobertura: cada trecho do original e para onde foi
683
+ const itemOfSlide = new Map();
684
+ let cursor = 0;
685
+ // (recalcula a posição de cada item no deck montado)
686
+ plan.slides.forEach((it, k) => { const res = job.results[k]; const n = !res?.slides ? srcOf(it).length : res.state === "pendente" && srcOf(it).length && mode === "melhorar" ? srcOf(it).length + res.slides.length : res.slides.length; for (let j = 0; j < n; j++) itemOfSlide.set(cursor + j, k); cursor += n; });
687
+ for (const s of originals) {
688
+ for (const unit of unitsOf(s)) {
689
+ const where = [];
690
+ out.slides.forEach((ns, i) => { if (unitIn(unit, ns)) where.push(i + 1); });
691
+ const viaItem = [...itemOfSlide].filter(([, k]) => (plan.slides[k].de || []).map(Number).includes(s.original.slide)).map(([i]) => i + 1);
692
+ coverage.push({ original: s.original.slide, tipo: unit.kind, trecho: clip(unit.label, 120), onde: where, ...(where.length ? {} : { reescrito_em: viaItem }) });
693
+ }
694
+ }
695
+ fs.mkdirSync(path.join(dir, "original"), { recursive: true });
696
+ fs.writeFileSync(path.join(dir, "original", `cobertura-${mode}.json`), JSON.stringify(coverage, null, 1));
697
+ fs.writeFileSync(path.join(dir, "original", `cobertura-${mode}.md`), coverageMarkdown(coverage, out));
698
+ const literal = coverage.filter((c) => c.onde.length).length;
699
+ job.spentMs = spentBefore + Date.now() - t0;
700
+ const missingItems = plan.slides.filter((it, k) => !job.results[k]?.slides && (it.acao !== "manter" || !srcOf(it).length)).length;
701
+ const status = stop || missingItems ? "parcial" : report.pendentes.length || report.revisar.length ? "revisar" : "concluido";
702
+ job.status = status; job.stage = "montado"; save();
703
+ return {
704
+ spec: out, plan,
705
+ report: { ...report, status, parou: stop ? stop.message : missingItems ? `${missingItems} item(ns) não saíram` : null, retomada: resumed, mode, textModel: T, visionModel: V, calls: job.calls, usage: job.usage, seconds: Math.round(job.spentMs / 1000), alertas: plan.alertas || [], cobertura: { trechos: coverage.length, localizados: literal, reescritos: coverage.length - literal, arquivo: `original/cobertura-${mode}.md` } },
706
+ };
707
+ }
708
+
709
+ // trechos de um slide original: cada caixa de texto (parágrafos), tabela, fórmula, imagem de conteúdo, anotações
710
+ function unitsOf(s) {
711
+ const out = [];
712
+ for (const e of s.elements || []) {
713
+ if (e.deco) continue;
714
+ if (e.textbox) {
715
+ const t = plainOf(e).trim();
716
+ if (!t || /^\d{1,3}$/.test(t)) continue;
717
+ const latex = e.textbox.paragraphs.flatMap((p) => p.runs || []).filter((r) => r.latex).map((r) => r.latex);
718
+ out.push({ kind: latex.length ? "fórmula" : "texto", label: t.replace(/\s+/g, " "), facts: factsOf({ elements: [e] }) });
719
+ } else if (e.tableData) out.push({ kind: "tabela", label: e.tableData.map((r) => r.join(" | ")).slice(0, 2).join(" / "), facts: factsOf({ elements: [e] }) });
720
+ else if (e.image && !e.fromOriginal) out.push({ kind: "imagem", label: e.image, image: e.image });
721
+ else if (e.image && e.fromOriginal) out.push({ kind: "figura", label: e.image, image: e.image });
722
+ }
723
+ if (s.notes) out.push({ kind: "notas", label: s.notes.replace(/\s+/g, " "), facts: factsOf({ elements: [] }, s.notes) });
724
+ return out;
725
+ }
726
+ function unitIn(unit, slide) {
727
+ if (unit.image) return JSON.stringify(slide).includes(unit.image);
728
+ const f = unit.facts;
729
+ if (!f || (!f.numbers.size && !f.terms.size)) {
730
+ // sem número nem nome: procura as primeiras palavras (as curtas não contam, dos dois lados)
731
+ const words = (t) => deaccent(t).toLowerCase().replace(/[^a-z0-9]+/g, " ").split(" ").filter((w) => w.length > 3);
732
+ const probe = words(unit.label).slice(0, 6).join(" ");
733
+ return probe.length > 12 && ` ${words(textOfProduced([slide])).join(" ")} `.includes(` ${probe} `);
734
+ }
735
+ const miss = missingFacts(f, [slide]);
736
+ return !miss.numbers.length && !miss.terms.length;
737
+ }
738
+ function coverageMarkdown(coverage, deck) {
739
+ const title = (i) => { const s = deck.slides[i - 1]; return s ? (s.title || s.text || s.question || s.layout) : ""; };
740
+ const lines = ["# Onde ficou cada trecho do original", "", "Gerado pela transformação. \"Reescrito em\" = o trecho não aparece igual (foi reescrito), então vale conferir.", ""];
741
+ let last = null;
742
+ for (const c of coverage) {
743
+ if (c.original !== last) { lines.push(`## Slide ${c.original} do original`); last = c.original; }
744
+ const where = c.onde.length ? c.onde.map((i) => `slide ${i} (${clip(title(i), 50)})`).join(", ") : c.reescrito_em?.length ? `reescrito em ${c.reescrito_em.map((i) => `slide ${i}`).join(", ")}` : "NÃO ENCONTRADO";
745
+ lines.push(`- ${c.tipo}: "${c.trecho}" → ${where}`);
746
+ }
747
+ return lines.join("\n") + "\n";
748
+ }
749
+
750
+ // caminho de imagem que não existe, mas há UM arquivo de imagem parecido na pasta (mesmo nome em outra pasta, ou até
751
+ // 2 letras de diferença: "imagens/orignal/x.png"): é ele
752
+ const imageListCache = new Map();
753
+ export function nearImage(rel, dir) {
754
+ let files = imageListCache.get(dir);
755
+ if (!files) {
756
+ files = [];
757
+ const walk = (d, pre) => { let ents = []; try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch {} for (const e of ents) { if (e.name.startsWith(".")) continue; if (e.isDirectory()) walk(path.join(d, e.name), `${pre}${e.name}/`); else if (/\.(png|jpe?g|gif|webp|svg)$/i.test(e.name)) files.push(`${pre}${e.name}`); } };
758
+ walk(dir, ""); imageListCache.set(dir, files);
759
+ }
760
+ const want = String(rel).replace(/\\/g, "/").replace(/^\.\//, "");
761
+ const lev = (a, b) => { if (Math.abs(a.length - b.length) > 2) return 9; const d = Array.from({ length: b.length + 1 }, (_, j) => j); for (let i = 1; i <= a.length; i++) { let prev = d[0]; d[0] = i; for (let j = 1; j <= b.length; j++) { const t = d[j]; d[j] = Math.min(d[j] + 1, d[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1)); prev = t; } } return d[b.length]; };
762
+ const near = files.filter((f) => lev(f, want) <= 2);
763
+ if (near.length === 1) return near[0];
764
+ const same = files.filter((f) => f.split("/").pop() === want.split("/").pop());
765
+ return same.length === 1 ? same[0] : null;
766
+ }
767
+
768
+ // resposta do escritor → slides válidos (renderizam, só imagens que existem, origem de um item do bloco)
769
+ function parseProduced(text, batch, deckBase, dir, { partial = false } = {}) {
770
+ const { yaml } = extractYaml(text);
771
+ let raw = parseYaml(yaml);
772
+ if (Array.isArray(raw)) raw = { slides: raw };
773
+ if (!raw || !Array.isArray(raw.slides) || !raw.slides.length) throw new Error('Esperava `slides:` com a lista de slides.');
774
+ const allowed = new Set(batch.map(({ k }) => k + 1));
775
+ let prev = null;
776
+ const slides = raw.slides.map((s, i) => {
777
+ if (!s || typeof s !== "object") throw new Error(`slide ${i + 1}: não é um objeto`);
778
+ let origem = Number(s.origem ?? (batch.length === 1 ? batch[0].k + 1 : NaN));
779
+ // sem origem (ou origem: extra, nova…): é do item do slide de antes; o primeiro, do primeiro item (a IA escreve
780
+ // na ordem dos itens)
781
+ if (!allowed.has(origem) && !/^\d+$/.test(String(s.origem ?? ""))) origem = prev ?? batch[0].k + 1;
782
+ prev = origem;
783
+ if (!allowed.has(origem)) throw new Error(`slide ${i + 1}: origem ${s.origem} não é um item deste bloco (${[...allowed].join(", ")})`);
784
+ return { ...s, origem };
785
+ });
786
+ const missingItems = [...allowed].filter((n) => !slides.some((s) => s.origem === n));
787
+ if (missingItems.length && !partial) throw new Error(`Faltou escrever ${missingItems.length === 1 ? "o item" : "os itens"} ${missingItems.join(", ")} (cada item precisa de pelo menos um slide com origem: N).`);
788
+ const clean = normalizeSpec({ ...deckBase, slides: slides.map(({ origem, mudou, ...rest }) => rest) });
789
+ // imagens que não existem na pasta do deck: erro (volta para a IA)
790
+ const bad = [];
791
+ const walk = (o) => { if (!o || typeof o !== "object") return; for (const [k, v] of Object.entries(o)) {
792
+ if ((k === "image" || k === "image_ref") && typeof v === "string" && !/^(https?:|data:)/.test(v) && !fs.existsSync(path.join(dir, v))) { const fix = nearImage(v, dir); if (fix) o[k] = fix; else bad.push(v); }
793
+ else if (v && typeof v === "object") walk(v);
794
+ } };
795
+ clean.slides.forEach((s) => walk(s));
796
+ if (bad.length) throw new Error(`Imagens que não existem: ${[...new Set(bad)].join(", ")}. Use só os caminhos dados.`);
797
+ validateSlides({ ...clean, _dir: dir });
798
+ sanitizeCheck(clean, clean.slides.map((_, i) => i));
799
+ return clean.slides.map((s, i) => ({ ...s, origem: slides[i].origem, ...(slides[i].mudou ? { mudou: String(slides[i].mudou) } : {}) }));
800
+ }
801
+
802
+ // o slide novo desenhado ao lado da foto do original: o modelo de visão aponta o que está errado
803
+ export async function visualCheck({ produced, batch, deckBase, dir, orig, ask, model, mode = "melhorar", fiscalToo = true, offset = 0, keepFailure }) {
804
+ const { slideSnapshots } = await import("../studio/snapshot.js");
805
+ const { imagesAsDataUrls } = await import("../import/crop.js");
806
+ const deck = { ...deckBase, _dir: dir, slides: produced.map(({ origem, mudou, ...s }) => s) };
807
+ // primeiro o fiscal do sagadeck (medido no navegador, exato): o que ele acha vai para a correção com o texto do objeto
808
+ const fiscal = [];
809
+ if (fiscalToo) try {
810
+ const { layoutCheck } = await import("../studio/snapshot.js");
811
+ for (const r of await layoutCheck(deck)) {
812
+ const found = [...new Set(r.issues.filter((x) => x.kind !== "fonte-pequena" || x.px < 16).map((x) => `${FISCAL[x.kind] || x.kind}${x.px && x.kind === "fonte-pequena" ? ` (${x.px} px)` : ""}: "${clip(x.text, 50)}"`))].slice(0, 4);
813
+ if (found.length) fiscal.push({ k: (produced[r.slide - 1]?.origem || batch[0].k + 1) - 1, text: `slide novo ${r.slide} (item ${produced[r.slide - 1]?.origem}), medido: ${found.join("; ")}` });
814
+ }
815
+ } catch (e) { if (e instanceof TransformStop) throw e; }
816
+ const shots = [];
817
+ for (let i = 0; i < deck.slides.length; i++) shots.push((await slideSnapshots(deck, i, { mode: "final", width: 960 }))[0]?.dataUrl);
818
+ const origOf = (origem) => (batch.find(({ k }) => k + 1 === origem)?.it.de || []).map((n) => orig.get(Number(n))?.original?.image).filter(Boolean);
819
+ const origFiles = [...new Set(produced.flatMap((x) => origOf(x.origem)))];
820
+ const origUrls = new Map((await imagesAsDataUrls(origFiles.map((f) => path.join(dir, f)), { width: 960 })).map((u, j) => [origFiles[j], u]));
821
+ const prompt = `Confira slides NOVOS de uma aula contra as fotos dos slides ORIGINAIS; aqui vai UM slide novo, com o(s) original(is) de onde ele veio. Olhe como um professor que vai apresentar este slide amanhã e aponte só problemas reais:
822
+ - crítica de desenho: destaque ou caixa do foco guiado fora do lugar na figura; figura que não é a do assunto do slide (a legenda fala de uma coisa e a imagem mostra outra); equação ou tabela como imagem (devia ser LaTeX ou table); gráfico de enfeite sem dado do material; ordem física invertida (o que fica em cima no mundo desenhado embaixo); texto gigante ocupando o slide; título repetido;
823
+ - desenho: texto cortado, sobreposto, fora do slide, ilegível de tão pequeno, área vazia enorme, figura esticada, print de tela com a janela do programa;
824
+ - conteúdo: informação que está no original e sumiu no novo (número, rótulo, parte de uma figura específica), figura errada.
825
+ - texto: erro de digitação ou de ortografia (letra faltando ou trocada: "Méodo", "Refrências").
826
+ O rodapé com o nome da aula abreviado com "…" é do tema; não aponte. ${mode === "recriar" ? "O estilo é NOVO de propósito: a moldura do original (logos, faixas, cores, fontes, número da página) NÃO precisa estar no novo; não aponte isso. " : ""}Responda só JSON: {"problemas":["…"]} (lista vazia se o slide estiver bom)`;
827
+ // um slide por chamada: com o lote inteiro numa chamada só (todas as fotos juntas), o modelo pensava até estourar o
828
+ // limite e não escrevia nada (16 mil tokens de raciocínio, resposta vazia) ou aprovava tudo sem olhar
829
+ const one = async (i) => {
830
+ const x = produced[i];
831
+ const content = [{ type: "text", text: prompt }];
832
+ origOf(x.origem).forEach((f, j) => { const u = origUrls.get(f); if (u) content.push({ type: "text", text: `ORIGINAL ${f.match(/(\d+)\.png$/)?.[1] || j + 1}:` }, { type: "image_url", image_url: { url: u } }); });
833
+ content.push({ type: "text", text: `NOVO ${i + 1 + offset} (do item ${x.origem}):` }, { type: "image_url", image_url: { url: shots[i] } });
834
+ const parse = (text) => { const r = jsonLoose(text); return Array.isArray(r.slides) ? r.slides[0] || { ok: true } : r; };
835
+ let r;
836
+ try { r = await ask([{ role: "user", content }], { model, maxTokens: 8000, temperature: 0.6, think: false }); return parse(r.text); }
837
+ catch (e) {
838
+ if (e instanceof TransformStop) throw e;
839
+ keepFailure?.(`conferencia-${x.origem}`, r?.text || "", e);
840
+ // resposta sem JSON (o modelo às vezes só "pensa" e não escreve): mais uma vez, pedindo só o JSON
841
+ try { r = await ask([{ role: "user", content: [...content, { type: "text", text: "Responda agora SÓ o bloco JSON, sem explicação." }] }], { model, maxTokens: 8000, temperature: 0.6, think: false }); return parse(r.text); }
842
+ catch (e2) { if (e2 instanceof TransformStop) throw e2; return null; }
843
+ }
844
+ };
845
+ const res = new Array(produced.length);
846
+ let next = 0;
847
+ await Promise.all(Array.from({ length: Math.min(4, produced.length) }, async () => { while (next < produced.length) { const i = next++; if (shots[i]) res[i] = await one(i); else res[i] = { ok: true }; } }));
848
+ const missed = res.map((r, i) => (r ? null : i + 1 + offset)).filter(Boolean);
849
+ if (missed.length === produced.length) throw new Error("resposta sem JSON");
850
+ const found = res.map((r, i) => ({ r, i })).filter(({ r }) => r && Array.isArray(r.problemas) && r.problemas.filter(Boolean).length)
851
+ .map(({ r, i }) => ({ k: (produced[i]?.origem || batch[0].k + 1) - 1, text: `slide novo ${i + 1 + offset} (item ${produced[i]?.origem}): ${r.problemas.join("; ")}` }));
852
+ return Object.assign([...fiscal, ...found], { missed });
853
+ }
854
+ // o que cada achado do fiscal quer dizer, para a IA corrigir
855
+ const FISCAL = {
856
+ "estouro-horizontal": "texto mais largo que a caixa", "estouro-vertical": "texto cortado embaixo", "fora-do-slide": "sai do slide",
857
+ "passa-da-margem-inferior": "passa da margem de baixo", sobreposicao: "texto por cima de outro", "baixo-contraste": "pouco contraste com o fundo", "fonte-pequena": "letra miúda",
858
+ };