sagadeck 1.10.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.
package/src/lessons.js CHANGED
@@ -8,6 +8,7 @@ import katex from "katex";
8
8
  import { md, esc } from "./markup.js";
9
9
  import "./runtime/formula.js";
10
10
  import "./runtime/calc.js";
11
+ import "./runtime/solution.js";
11
12
 
12
13
  const F = globalThis.SagaFormula;
13
14
  const tex = (s, display = true) => katex.renderToString(String(s ?? ""), { displayMode: display, throwOnError: false, strict: "ignore", trust: false, maxExpand: 1000, maxSize: 20 });
@@ -29,32 +30,92 @@ const panel = (i, inner, cls = "") => `<section class="lesson-panel dyn-frame ${
29
30
  const PROSE = /\p{L}{3,}/u;
30
31
  const isProse = (s) => !/\\[a-zA-Z]|[\^_{}]/.test(s) && PROSE.test(String(s).replace(/\b(sen|cos|tan|tg|log|ln|exp|max|min|lim|mod)\b/g, ""));
31
32
  const mathOrText = (s) => (isProse(String(s ?? "")) ? `<span class="sol-txt t f-body">${md(String(s))}</span>` : tex(s, false));
32
- function givenTex(g) {
33
+ // % (e # & _ $) são comandos no LaTeX: "60 %" saía como erro vermelho na conta da criança
34
+ const unitTex = (u) => String(u).replace(/(?<!\\)[%#&_$]/g, (c) => `\\${c}`).replace(/°/g, "^{\\circ}").replace(/ /g, "\\,");
35
+ const unitSuffix = (u) => (u ? `\\ \\mathrm{${unitTex(u)}}` : "");
36
+ function givenTex(g, value = null) {
33
37
  if (typeof g === "string") return g;
34
38
  if (g.latex) return g.latex;
35
- const value = typeof g.value === "string" && isProse(g.value) ? `\\text{${g.value.replace(/[{}\\$]/g, "")}}` : commaTex(g.value);
36
- // % (e # & _ $) são comandos no LaTeX: "60 %" saía como erro vermelho na conta da criança
37
- const unit = g.unit ? String(g.unit).replace(/(?<!\\)[%#&_$]/g, (c) => `\\${c}`).replace(/°/g, "^{\\circ}").replace(/ /g, "\\,") : "";
38
- return `${g.symbol ?? ""} = ${value}${unit ? `\\ \\mathrm{${unit}}` : ""}`;
39
+ value ??= typeof g.value === "string" && isProse(g.value) ? `\\text{${g.value.replace(/[{}\\$]/g, "")}}` : commaTex(g.value);
40
+ return `${symbolTex(g.symbol)} = ${value}${unitSuffix(g.unit)}`;
39
41
  }
42
+ // "p_atm" é p com índice "atm": sem chaves o LaTeX subscrevia só o "a" (aparecia "pₐtm" num exercício de aula)
43
+ const symbolTex = (s) => String(s ?? "").replace(/_([A-Za-z0-9]{2,})/g, "_{$1}");
44
+ // nome da conta em LaTeX, quando o passo não traz nem latex nem symbol: D2 → D_{2}, rho → \rho, fator → \mathrm{fator}
45
+ const GREEK = /^(alpha|beta|gamma|delta|epsilon|theta|lambda|mu|nu|xi|pi|rho|sigma|tau|phi|chi|psi|omega|Delta|Omega|Phi|Sigma)(\d*)$/;
46
+ const nameTex = (n) => (n.startsWith("_") ? "" : GREEK.test(n) ? n.replace(GREEK, (_, g, d) => `\\${g}${d ? `_{${d}}` : ""}`) : /^[A-Za-z]\d*$/.test(n) ? n.replace(/^(.)(\d+)$/, "$1_{$2}") : `\\mathrm{${n}}`);
47
+ // {{nome}} ou {{expressão:casas}}: o valor calculado no lugar
48
+ const PH = /\{\{\s*([^{}:]+?)\s*(?::\s*(\d+)\s*)?\}\}/g;
49
+ const S = globalThis.SagaSolution;
50
+ // \htmlData só para a marca do número que a apresentação troca ao vivo
51
+ const texLive = (s, display = true) => katex.renderToString(String(s ?? ""), { displayMode: display, throwOnError: false, strict: "ignore", trust: (c) => c.command === "\\htmlData" && Object.keys(c.attributes || {}).every((k) => k === "data-solv"), maxExpand: 1000, maxSize: 20 });
40
52
  export function solutionHTML(s, ctx, head) {
41
53
  const steps = (s.steps || []).map((x) => (typeof x === "string" ? { latex: x } : x || {}));
42
54
  const givens = [].concat(s.givens || s.data || []);
43
55
  const hasAnswer = !!s.answer;
56
+ const answer = hasAnswer ? (typeof s.answer === "string" ? { latex: s.answer } : s.answer) : null;
57
+ // contas: dados com símbolo e valor viram variáveis; passo (e resposta) com fn é calculado pelo motor
58
+ const m = S.model({ ...s, steps, answer });
59
+ const { env, results } = S.evaluate(m);
60
+ const live = !!s.interactive && m.vars.length > 0;
61
+ const T = live ? texLive : tex;
62
+ const marks = [], markAt = new Map();
63
+ const decOf = (name) => m.vars.find((v) => v.name === name)?.decimals ?? m.calcs.find((c) => c.name === name)?.decimals;
64
+ const mark = (expr, d) => {
65
+ const decimals = d != null && d !== "" ? Number(d) : decOf(expr), key = `${expr}|${decimals ?? ""}`;
66
+ if (!markAt.has(key)) { markAt.set(key, marks.length); marks.push({ expr, ...(decimals != null ? { decimals } : {}) }); }
67
+ return { k: markAt.get(key), decimals, value: S.evalExpr(expr, env) };
68
+ };
69
+ const numTex = (r) => { const t = S.format(r.value, r.decimals, "tex"); return live ? `\\htmlData{solv=${r.k}}{${t}}` : t; };
70
+ const fillTex = (str) => String(str ?? "").replace(PH, (_, e, d) => numTex(mark(e, d)));
71
+ const fillText = (str) => {
72
+ const nums = [];
73
+ const out = md(String(str ?? "").replace(PH, (_, e, d) => {
74
+ const r = mark(e, d), t = esc(S.format(r.value, r.decimals, "text"));
75
+ nums.push(live ? `<span data-solv="${r.k}" data-solv-mode="text">${t}</span>` : t);
76
+ return `\uE000${nums.length - 1}\uE001`;
77
+ }));
78
+ return out.replace(/\uE000(\d+)\uE001/g, (_, i) => nums[i]);
79
+ };
80
+ const errors = m.calcs.filter((c) => results[c.key].error).map((c) => `${c.label}: ${results[c.key].error}`);
81
+ if (errors.length) ctx?.warnings?.push(...errors.map((e) => `exercício resolvido: ${e}`));
82
+ // LaTeX do passo calculado: o valor onde a pessoa pôs {{nome}} (o nome do próprio passo), ou "≈ valor unidade" no fim
83
+ const calcTex = (o, key) => {
84
+ const c = m.calcs.find((x) => x.key === key);
85
+ if (!c) return o.latex ? fillTex(o.latex) : "";
86
+ if ([...String(o.latex ?? "").matchAll(PH)].some((x) => x[1] === c.name)) return fillTex(o.latex);
87
+ const r = mark(c.name, c.decimals);
88
+ const exact = r.value != null && S.parseNum(S.format(r.value, c.decimals, "text")) === r.value;
89
+ const lhs = o.latex ? fillTex(o.latex) : o.symbol ? String(o.symbol) : nameTex(c.name);
90
+ const rel = !lhs || /(=|\\approx|≈)\s*$/.test(lhs) ? "" : exact ? "= " : "\\approx ";
91
+ return `${lhs}${lhs ? " " : ""}${rel}${numTex(r)}${unitSuffix(o.unit)}`;
92
+ };
93
+ const calcError = (key) => { const c = m.calcs.find((x) => x.key === key); return c && results[key].error ? `<div class="sol-error dyn-error f-body">${esc(`${c.label[0].toUpperCase()}${c.label.slice(1)}: ${results[key].error}`)}</div>` : ""; };
94
+ const varOf = (g) => (g && typeof g === "object" ? m.vars.find((v) => v.name === (g.name ? String(g.name) : S.varName(g.symbol))) : null);
95
+ const givenHTML = (g) => {
96
+ if (typeof g === "string") return `<div class="sol-given"><div class="sol-tex">${mathOrText(g)}</div></div>`;
97
+ const v = live && !g.latex ? varOf(g) : null;
98
+ const t = v ? givenTex(g, numTex(mark(v.name, v.decimals))) : g.latex ? fillTex(g.latex) : givenTex(g);
99
+ const slider = v ? `<input type="range" class="sol-slider" data-solv-in="${esc(v.name)}" min="${v.min}" max="${v.max}" step="${v.step}" value="${v.value}" aria-label="${esc(v.label)}">` : "";
100
+ return `<div class="sol-given${slider ? " live" : ""}"><div class="sol-tex">${T(t, false)}</div>${g.label ? `<span class="t f-body">${md(g.label)}</span>` : ""}${slider}</div>`;
101
+ };
44
102
  const n = 1 + steps.length + (hasAnswer ? 1 : 0); // enunciado · cada passo · resposta
45
- const left = `<div class="sol-problem">${s.problem ? `<div class="sol-text t f-body">${md(s.problem)}</div>` : ""}
46
- ${givens.length ? `<div class="sol-givens"><div class="sol-label t f-label">Dados</div>${givens.map((g) => `<div class="sol-given"><div class="sol-tex">${typeof g === "string" ? mathOrText(g) : tex(givenTex(g), false)}</div>${g.label ? `<span class="t f-body">${md(g.label)}</span>` : ""}</div>`).join("")}</div>` : ""}
103
+ const reset = live ? `<button type="button" class="sol-reset" data-solv-reset>Restaurar</button>` : "";
104
+ const left = `<div class="sol-problem">${s.problem ? `<div class="sol-text t f-body">${fillText(s.problem)}</div>` : ""}
105
+ ${givens.length ? `<div class="sol-givens"><div class="sol-label t f-label">Dados${reset}</div>${givens.map(givenHTML).join("")}</div>` : ""}
47
106
  ${s.find ? `<div class="sol-find"><span class="sol-label t f-label">Pede-se</span><div class="sol-tex">${mathOrText(s.find)}</div></div>` : ""}</div>`;
48
- const stepHTML = (st, i, k) => `<article class="sol-step${i === k - 1 ? " now" : ""}"><span class="sol-n t f-label">${String(i + 1).padStart(2, "0")}</span><div class="sol-body">${st.text ? `<div class="sol-why t f-body">${md(st.text)}</div>` : ""}${st.latex ? `<div class="sol-math">${tex(st.latex)}</div>` : ""}${st.note ? `<div class="sol-note t f-body">${md(st.note)}</div>` : ""}</div></article>`;
49
- const answer = hasAnswer ? (typeof s.answer === "string" ? { latex: s.answer } : s.answer) : null;
107
+ const stepTex = steps.map((st, i) => calcTex(st, `p${i}`));
108
+ const stepHTML = (st, i, k) => `<article class="sol-step${i === k - 1 ? " now" : ""}"><span class="sol-n t f-label">${String(i + 1).padStart(2, "0")}</span><div class="sol-body">${st.text ? `<div class="sol-why t f-body">${fillText(st.text)}</div>` : ""}${stepTex[i] ? `<div class="sol-math">${T(stepTex[i])}</div>` : ""}${calcError(`p${i}`)}${st.note ? `<div class="sol-note t f-body">${fillText(st.note)}</div>` : ""}</div></article>`;
109
+ const answerTex = answer ? calcTex(answer, "resp") : "";
50
110
  const frames = Array.from({ length: n }, (_, k) => {
51
111
  const done = Math.min(k, steps.length);
52
112
  const list = steps.slice(0, done).map((st, i) => stepHTML(st, i, done)).join("");
53
- const final = hasAnswer && k === n - 1 ? `<div class="sol-answer"><span class="sol-label t f-label">${esc(answer.label || "Resposta")}</span>${answer.latex ? `<div class="sol-math">${tex(answer.latex)}</div>` : ""}${answer.text ? `<div class="t f-body">${md(answer.text)}</div>` : ""}</div>` : "";
113
+ const final = hasAnswer && k === n - 1 ? `<div class="sol-answer"><span class="sol-label t f-label">${esc(answer.label || "Resposta")}</span>${answerTex ? `<div class="sol-math">${T(answerTex)}</div>` : ""}${calcError("resp")}${answer.text ? `<div class="t f-body">${fillText(answer.text)}</div>` : ""}</div>` : "";
54
114
  const empty = k === 0 ? `<div class="sol-think t f-display">${md(s.prompt || "Por onde você começaria?")}</div>` : "";
55
115
  return panel(k, `<div class="sol-steps">${empty}${list}${final}</div>`);
56
116
  }).join("");
57
- return `<div class="L-solution dyn" data-lesson="solution" data-lesson-count="${n}">${head(s)}<div class="sol-row">${left}<div class="dyn-frames">${frames}</div></div></div>`;
117
+ const payload = live ? `<script type="application/json" class="sol-model">${JSON.stringify({ ...m, marks }).replace(/</g, "\\u003c")}</script>` : "";
118
+ return `<div class="L-solution dyn" data-lesson="solution" data-lesson-count="${n}"${live ? " data-solution" : ""}>${head(s)}<div class="sol-row">${left}<div class="dyn-frames">${frames}</div></div>${payload}</div>`;
58
119
  }
59
120
 
60
121
  // ------------------------------------------------------------------------------------------------ calc
package/src/mcp/server.js CHANGED
@@ -135,7 +135,7 @@ const TOOLS = [
135
135
  inputSchema: {
136
136
  type: "object",
137
137
  properties: {
138
- so: { type: "array", items: { type: "string", enum: ["texto", "visao", "imagem", "pesquisa", "video"] }, description: "Só estes recursos (padrão: todos)" },
138
+ so: { type: "array", items: { type: "string", enum: ["texto", "visao", "imagem", "pesquisa", "video", "conferencia"] }, description: "Só estes recursos (padrão: todos)" },
139
139
  rapido: { type: "boolean", description: "Pula geração de imagem e vídeo (os lentos e cobrados)" },
140
140
  video_real: { type: "boolean", description: "Gera um clipe curto de verdade e confere o MP4 (cobrado; leva minutos)" },
141
141
  },
package/src/package.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // <nome>.yaml a apresentação (sem campos internos da máquina de quem salvou)
6
6
  // imagens/, widgets/… os arquivos, nos mesmos caminhos relativos do YAML
7
7
  // assets/ arquivos que estavam FORA da pasta do deck (o YAML do pacote aponta para cá)
8
+ // .sagadeck/geracao.json o pedido que originou a apresentação com IA, quando existe
8
9
  // FALTANDO.txt o que o deck usa mas não existia ao salvar
9
10
  //
10
11
  // Também abre um .zip comum com um .yaml dentro (versão sem manifesto).
@@ -93,6 +94,9 @@ export async function packDeck(spec, { baseDir, name = "apresentacao", generator
93
94
  if (size <= budget) { root.file(q.rel, fs.readFileSync(q.abs)); files.push(q.rel); budget -= size; }
94
95
  else skipped.push(q.rel);
95
96
  }
97
+ // o pedido que originou a apresentação (src/studio/generation-request.js) viaja junto, para auditar depois
98
+ const request = path.join(dir, ".sagadeck", "geracao.json");
99
+ if (fs.existsSync(request)) { root.file(".sagadeck/geracao.json", fs.readFileSync(request)); files.push(".sagadeck/geracao.json"); }
96
100
  for (const a of assets) {
97
101
  if (a.exists) { root.file(a.rel, fs.readFileSync(a.abs)); files.push(a.rel); }
98
102
  else missing.push(a.ref);
@@ -1303,6 +1303,9 @@ html[data-motion="subtle"] .lesson-panel.active{animation:lesson-soft .6s cubic-
1303
1303
  .sol-label{font-size:20px;color:var(--em);letter-spacing:.08em;white-space:nowrap}
1304
1304
  .sol-givens{display:flex;flex-direction:column;gap:10px}
1305
1305
  .sol-given{display:flex;align-items:baseline;gap:16px;font-size:28px}.sol-given .sol-tex{min-width:7em}.sol-given>span{font-size:22px;color:var(--muted)}
1306
+ .sol-given.live{flex-wrap:wrap;row-gap:4px}.sol-slider{flex:1 0 100%;height:30px;accent-color:var(--hi);cursor:pointer}
1307
+ .sol-reset{font:inherit;font-size:16px;letter-spacing:normal;color:var(--fg);background:transparent;border:1px solid var(--line);border-radius:8px;padding:4px 12px;margin-left:16px;cursor:pointer}.sol-reset:hover{border-color:var(--fg)}
1308
+ .sol-error{font-size:20px;margin-top:4px;padding:8px 14px;border-radius:12px}
1306
1309
  .sol-find{display:flex;align-items:baseline;gap:16px;font-size:30px;padding-top:14px;border-top:1px dashed var(--line)}
1307
1310
  .sol-steps{flex:1;display:flex;flex-direction:column;justify-content:flex-end;gap:10px;min-height:0;overflow:hidden;-webkit-mask:linear-gradient(to bottom,transparent 0,#000 60px);mask:linear-gradient(to bottom,transparent 0,#000 60px)}.sol-steps.sol-fit{-webkit-mask:none;mask:none}
1308
1311
  /* passos anteriores em tamanho menor só quando não cabem todos no normal (com 3 passos curtos, ficavam miúdos à toa) */
@@ -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
@@ -143,8 +146,9 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
143
146
  const target = typeof body.targetSlide === "number" ? body.targetSlide : null;
144
147
  const visuals = await lookAt(withBase(taskWorkspace, spec), target, prompt, emit);
145
148
  // anexos: imagens vão como visão; documentos (id do /api/ai/context) vão como texto
146
- let materials = takeMaterials(taskWorkspace, (Array.isArray(body.attachments) ? body.attachments : [])
149
+ const attached = takeMaterials(taskWorkspace, (Array.isArray(body.attachments) ? body.attachments : [])
147
150
  .filter((a) => a && typeof a === "object" && a.type === "doc").map((a) => a.id));
151
+ let materials = attached;
148
152
  if (!taskWorkspace.file && taskWorkspace.preview && materials.some(m => m.bytes)) imageOptionsFor();
149
153
  const materialDir = taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? path.dirname(taskWorkspace.file) : null;
150
154
  if (materialDir) materials = await prepareDocumentMaterials(materials, materialDir, {signal,onProgress:emit});
@@ -166,6 +170,8 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
166
170
  materials = chatResearch.materials;
167
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(", ")})`);
168
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 })) : "";
169
175
  const editOpts = (extra = {}) => ({
170
176
  spec: withBase(taskWorkspace, spec), signal,
171
177
  instruction: firstInstruction,
@@ -185,6 +191,7 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
185
191
  styles: taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? { list: taskWorkspace.library.listStyles(), current: taskWorkspace.spec?.style?.name || null } : null,
186
192
  // dados para o mapa (bloco mapa:): só com a pasta da apresentação, que é onde os arquivos ficam
187
193
  mapData: P ? mapDataPrompt() : null,
194
+ pending,
188
195
  ...extra,
189
196
  });
190
197
  result = await editDeck(editOpts());
@@ -248,6 +255,8 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
248
255
  signal.throwIfAborted();
249
256
  if (checked.spec !== result.spec) { result.spec = checked.spec; result.actions = [...(result.actions || []), ...checked.actions]; }
250
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 */ }
251
260
  }
252
261
  }
253
262
  if (linkActions.length) result.actions = [...linkActions, ...(result.actions || [])];
@@ -289,6 +298,40 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
289
298
  convo.push({ role: "user", text: instruction });
290
299
  result = { ...next, actions: [...result.actions, ...(next.actions || [])], spec: next.spec };
291
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
+ }
292
335
  result.mode = "llm";
293
336
  } catch (e) {
294
337
  if (signal.aborted) return { reply: "Parado a pedido. Nenhuma resposta pendente foi aplicada.", spec, actions: [], talk: true, mode: "cancelled" };
@@ -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 };