sagadeck 1.7.1 → 1.9.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/README.md +55 -4
- package/SKILL.md +10 -3
- package/bin/sagadeck.js +23 -0
- package/docs/ANDAMENTO.md +483 -1
- package/docs/PLANO-MELHORIAS.md +180 -0
- package/docs/REFERENCIA.md +184 -105
- package/package.json +34 -2
- package/src/ai/commands.js +2 -2
- package/src/ai/deck-ai.js +14 -5
- package/src/ai/ia-check.js +127 -0
- package/src/ai/ia-config.js +2 -2
- package/src/ai/llm.js +5 -1
- package/src/ai/map-analysis-run.js +119 -0
- package/src/ai/map-data.js +221 -0
- package/src/ai/quality.js +1 -1
- package/src/ai/video-generation.js +70 -25
- package/src/build.js +51 -15
- package/src/elements.js +39 -1
- package/src/export/browser.js +23 -1
- package/src/export/pptx.js +9 -3
- package/src/export/shots.js +10 -3
- package/src/fiscal/normalize.js +19 -0
- package/src/fs-retry.js +37 -2
- package/src/import/office.js +5 -4
- package/src/layouts.js +114 -99
- package/src/lessons.js +1 -1
- package/src/library.js +7 -7
- package/src/map-analysis.js +109 -0
- package/src/map-config.js +89 -0
- package/src/map-routing.js +82 -0
- package/src/map-services.js +60 -0
- package/src/map.js +274 -0
- package/src/mcp/server.js +17 -0
- package/src/motion.js +20 -6
- package/src/purpose.js +1 -1
- package/src/runtime/base.css +105 -329
- package/src/runtime/fit.js +3 -2
- package/src/runtime/fonts/amatic-sc.css +3 -0
- package/src/runtime/fonts/bebas-neue.css +2 -0
- package/src/runtime/fonts/cabin-sketch.css +2 -0
- package/src/runtime/fonts/fraunces.css +3 -0
- package/src/runtime/fonts/gelasio.css +3 -0
- package/src/runtime/fonts/ibm-plex-mono.css +4 -0
- package/src/runtime/fonts/index.json +10 -1
- package/src/runtime/fonts/itim.css +2 -0
- package/src/runtime/fonts/montserrat.css +2 -0
- package/src/runtime/fonts/space-grotesk.css +2 -0
- package/src/runtime/map-background.js +91 -0
- package/src/runtime/map.js +266 -0
- package/src/runtime/runtime.js +81 -100
- package/src/runtime/skins/caderno.css +33 -0
- package/src/runtime/skins/cartaz.css +27 -0
- package/src/runtime/skins/infografico.css +29 -0
- package/src/runtime/skins/lousa.css +22 -0
- package/src/runtime/skins/musgo.css +42 -0
- package/src/runtime/vendor/LEAFLET-LICENSE.txt +26 -0
- package/src/runtime/vendor/MAPLIBRE-LEAFLET-LICENSE.txt +15 -0
- package/src/runtime/vendor/MAPLIBRE-LICENSE.txt +116 -0
- package/src/runtime/vendor/geo-contornos.json +1 -0
- package/src/runtime/vendor/leaflet.css +661 -0
- package/src/runtime/vendor/leaflet.js +6 -0
- package/src/runtime/vendor/maplibre-gl.css +1 -0
- package/src/runtime/vendor/maplibre-gl.js +825 -0
- package/src/runtime/vendor/maplibre-leaflet.js +234 -0
- package/src/runtime/vendor/maplibre-worker.js +10 -0
- package/src/studio/ai-chat-route.js +20 -0
- package/src/studio/assets/imagens/tour-mapa-botao.jpg +0 -0
- package/src/studio/assets/imagens/tour-mapa-editor.jpg +0 -0
- package/src/studio/command-approval.js +2 -3
- package/src/studio/demo-decks.js +6 -2
- package/src/studio/export-jobs.js +86 -0
- package/src/studio/exporting.js +36 -18
- package/src/studio/feature-demos.js +144 -0
- package/src/studio/geo-routes.js +138 -0
- package/src/studio/ia-routes.js +9 -0
- package/src/studio/layout-samples.js +78 -16
- package/src/studio/library-routes.js +0 -9
- package/src/studio/project.js +2 -1
- package/src/studio/public/ai-settings.js +6 -3
- package/src/studio/public/app.js +46 -52
- package/src/studio/public/explorer.js +23 -8
- package/src/studio/public/export-status.js +154 -0
- package/src/studio/public/index.html +17 -10
- package/src/studio/public/library.css +12 -3
- package/src/studio/public/library.html +3 -0
- package/src/studio/public/library.js +39 -12
- package/src/studio/public/map-data.js +158 -0
- package/src/studio/public/map-editor.js +676 -0
- package/src/studio/public/map-fields.js +21 -0
- package/src/studio/public/map-settings.js +86 -0
- package/src/studio/public/slide-form.js +19 -16
- package/src/studio/public/style.css +58 -0
- package/src/studio/public/ui-icons.js +1 -1
- package/src/studio/public/viewers.js +2 -1
- package/src/studio/public/visual-editor.js +1 -1
- package/src/studio/server.js +34 -9
- package/src/studio/snapshot.js +8 -4
- package/src/studio/template-collections.js +1 -1
- package/src/studio/tour.js +158 -0
- package/src/themes.js +154 -0
- package/templates/cenario-e-ciencia.yaml +1 -21
- package/templates/ensaio-api.yaml +3 -3
- package/templates/cenario/Texto no cen/303/241rio.yaml" +0 -171
- package/templates/cenario/imagens/cidade.svg +0 -30
- package/templates/cenario/imagens/pessoa.svg +0 -15
package/src/ai/deck-ai.js
CHANGED
|
@@ -69,7 +69,7 @@ Siga ESTRITAMENTE a referência abaixo: use só layouts, elementos, campos e fig
|
|
|
69
69
|
- ANTI-FEIJÃO-COM-ARROZ: varie os layouts de verdade; capa (cover) no início e encerramento (end) no fim quando fizer sentido. Regras duras: nunca 2 slides seguidos com o mesmo layout (salvo série intencional); em 6+ slides use 5+ layouts diferentes com 1 slide de impacto (headline, number, statement, full, image, question); no máximo 45% do deck em listas/cartões (cards, list, steps, stats, bento, agenda). Conteúdo manda no layout, não o contrário (ver o mapa em "Layouts válidos").
|
|
70
70
|
- Slide denso (documentação, referência, números): feche com 1 takeaway em ==destaque== e use o elemento \`aviso\` (tipos: \`importante\`, \`atencao\`, \`dica\`, \`perigo\`) para o que não pode passar batido; grife ==palavras-chave== no texto corrido em vez de encher de negrito.
|
|
71
71
|
- Escreva no idioma do pedido do usuário.
|
|
72
|
-
- Layouts válidos (\`layout:\`): ${Object.keys(LAYOUTS).join(", ")}. Conteúdo → layout (use o que comunica, não o mais fácil): processo/fluxo/arquitetura/UML: layout \`diagram\` (Mermaid); itens em volta de uma ideia: layout \`infographic\`; gráfico: \`chart\` (fórmula/curva/3D: \`science\`); datas: \`calendar\` (grade) ou \`timeline\` (marcos); página densa estilo revista: \`poster\`; jornada+problema+solução+painel: \`onepage\`; status semanal: \`status\`; definição: \`definition\`; citação: \`quote\`; número gigante: \`number\`; KPIs: \`stats\`; A×B: \`compare\`; 2×2: \`matrix\`; pergunta à plateia: \`question\`/\`poll\`; código: \`code\`, guiado: \`codewalk\`; screenshot com foco: \`spotlight\`; API ao vivo: \`api\`; conta/calculadora: \`solution\`/\`calc\`; algoritmo animado: \`algo\`; redes/rotas: \`graphlab\`; código executável pelo aluno: \`codelab\`; experimento: \`playground\`; decisão: \`decisionlab\`; duelo/terminais/turnos: \`duel\`/\`terminals\`/\`turns\`; carrossel: \`carousel\`; funil/pirâmide/agenda: \`funnel\`/\`pyramid\`/\`agenda\`; grade/consulta: \`bento\`/\`mosaic\`/\`ribbon\`/\`dossier\`;
|
|
72
|
+
- Layouts válidos (\`layout:\`): ${Object.keys(LAYOUTS).join(", ")}. Conteúdo → layout (use o que comunica, não o mais fácil): processo/fluxo/arquitetura/UML: layout \`diagram\` (Mermaid); itens em volta de uma ideia: layout \`infographic\`; gráfico: \`chart\` (fórmula/curva/3D: \`science\`); datas: \`calendar\` (grade) ou \`timeline\` (marcos); página densa estilo revista: \`poster\`; jornada+problema+solução+painel: \`onepage\`; status semanal: \`status\`; definição: \`definition\`; citação: \`quote\`; número gigante: \`number\`; KPIs: \`stats\`; A×B: \`compare\`; 2×2: \`matrix\`; pergunta à plateia: \`question\`/\`poll\`; código: \`code\`, guiado: \`codewalk\`; screenshot com foco: \`spotlight\`; API ao vivo: \`api\`; conta/calculadora: \`solution\`/\`calc\`; algoritmo animado: \`algo\`; redes/rotas: \`graphlab\`; código executável pelo aluno: \`codelab\`; experimento: \`playground\`; decisão: \`decisionlab\`; duelo/terminais/turnos: \`duel\`/\`terminals\`/\`turns\`; carrossel: \`carousel\`; funil/pirâmide/agenda: \`funnel\`/\`pyramid\`/\`agenda\`; grade/consulta: \`bento\`/\`mosaic\`/\`ribbon\`/\`dossier\`; caminhos com volta ao mapa: \`hub\`+\`goto\`/\`back\`/\`next\`; manchete: \`headline\`; imagem total: \`full\`/\`image\`. Diagramas simples (\`diagram: loop/flow/venn…\`) e gráficos também existem como ELEMENTOS, dentro de figure/content/side.
|
|
73
73
|
- Temas disponíveis: ${themes}.
|
|
74
74
|
- YAML: coloque entre aspas duplas todo texto que comece com marcação (\`**\`, \`*\`, \`==\`, \`^^\`, \`~~\`, \`[\`) ou que contenha ": ".
|
|
75
75
|
- Em sequências inline ([{...}]): coloque entre aspas todo valor de texto com vírgula ("3,5 peixes por m³"). Sem aspas a vírgula parte o objeto e o dado sai truncado.
|
|
@@ -782,7 +782,7 @@ export function applyPatch(base, patch) {
|
|
|
782
782
|
const STUDIO_KEYS = ["layout", "notes", "time", "id", "uid", "review", "original", "master", "visualEdits", "auto", "fiscalOk", "from", "image_prompt", "image_ref", "density", "deco", "theme", "palette", "tone",
|
|
783
783
|
"bg", "fg", "background", "backgroundStyle", "footer", "header", "transition", "steps", "markStyle", "maxWords", "fit", "titleAs", "context"];
|
|
784
784
|
const FREE_TEXT_KEYS = new Set(["code", "mermaid", "svg", "html", "request", "realtime", "body", "headers", "response", "notes", "consulta", "output", "json"]);
|
|
785
|
-
const PATCH_WORDS = new Set(["slides", "insert", "delete", "edit", "deck", "variants", "test", "ver"]);
|
|
785
|
+
const PATCH_WORDS = new Set(["slides", "insert", "delete", "edit", "deck", "variants", "test", "ver", "mapa"]);
|
|
786
786
|
let knownKeysCache = null;
|
|
787
787
|
function knownSlideKeys() {
|
|
788
788
|
if (knownKeysCache) return knownKeysCache;
|
|
@@ -925,6 +925,13 @@ function parseEditText(text, base) {
|
|
|
925
925
|
const raw = parseYaml(yaml);
|
|
926
926
|
if (!raw || typeof raw !== "object") throw new Error("O bloco yaml precisa ser um objeto com deck/slides/insert/delete (ou variants).");
|
|
927
927
|
if (raw.variants) return parseVariants(raw.variants, base, prose);
|
|
928
|
+
// mapa: [ações] → a IA precisa de dados geográficos (lugares, linhas, contornos, web, coordenadas); quem busca é o
|
|
929
|
+
// Studio (src/ai/map-data.js), que chama de novo com o que gravou
|
|
930
|
+
if (raw.mapa) {
|
|
931
|
+
const acoes = [].concat(raw.mapa?.acoes || raw.mapa).filter((a) => a && typeof a === "object" && a.tipo);
|
|
932
|
+
if (!acoes.length) throw new Error("mapa: mande a lista de ações (tipo: lugares | linhas | contorno | web | coordenadas).");
|
|
933
|
+
return { spec: base, prose, changed: [], geo: acoes.slice(0, 4) };
|
|
934
|
+
}
|
|
928
935
|
// ver: [arquivos] → a IA quer OLHAR o material (recorte, página inteira) antes de responder; quem mostra é o Studio
|
|
929
936
|
if (raw.ver || raw.look) {
|
|
930
937
|
const files = [].concat(raw.ver || raw.look).map((f) => String(f || "").trim()).filter(Boolean);
|
|
@@ -974,7 +981,7 @@ function commandEnvNames(apiContext) {
|
|
|
974
981
|
|
|
975
982
|
// Chat lateral do Studio: aplica um pedido em linguagem natural ao deck.
|
|
976
983
|
export async function editDeck({ spec, instruction, targetSlide = null, issues = [], images = false, imageOptions = {}, history = [], onProgress,
|
|
977
|
-
visuals = [], renderNotes = [], apiContext = null, drawCheck = null, runCommand = null, materials = [], deferImages = false, maxImages, styles = null, signal, reviewCheck = null, reviewDepth = 0, repairSlides = null }) {
|
|
984
|
+
visuals = [], renderNotes = [], apiContext = null, drawCheck = null, runCommand = null, materials = [], deferImages = false, maxImages, styles = null, signal, reviewCheck = null, reviewDepth = 0, repairSlides = null, mapData = null }) {
|
|
978
985
|
const deck = promptSpec(spec);
|
|
979
986
|
const { slides: _slides, ...numbered } = deck;
|
|
980
987
|
const slidesYaml = deck.slides.map((s, i) => `# ── slide ${i + 1} ──\n${YAML.stringify([s], { indent: 2 })}`).join("");
|
|
@@ -1012,7 +1019,7 @@ Antes de responder, verifique (e siga as Regras de edição):
|
|
|
1012
1019
|
: text;
|
|
1013
1020
|
const convo = conversationFor(history);
|
|
1014
1021
|
const messages = [
|
|
1015
|
-
{ role: "system", content: `${systemPrompt({ images, ...(maxImages ? { maxImages } : {}) })}\n\n${AUTOMATION_NOTES}\n\n${EDIT_RULES}\n\n${CONVERSATION_RULES}\n\n${PATCH_FORMAT}\n\n${VARIANTS_FORMAT}\n\n${API_RULES}\n\n${runCommand ? runCommand.description || `${COMMAND_RULES}\n${VIDEO_COMMAND_RULES}${commandEnvNames(apiContext)}` : "Comandos: indisponíveis aqui (só no Studio local, com a apresentação salva na biblioteca). Não peça run:."}` },
|
|
1022
|
+
{ role: "system", content: `${systemPrompt({ images, ...(maxImages ? { maxImages } : {}) })}\n\n${AUTOMATION_NOTES}\n\n${EDIT_RULES}\n\n${CONVERSATION_RULES}\n\n${PATCH_FORMAT}\n\n${VARIANTS_FORMAT}\n\n${API_RULES}\n\n${runCommand ? runCommand.description || `${COMMAND_RULES}\n${VIDEO_COMMAND_RULES}${commandEnvNames(apiContext)}` : "Comandos: indisponíveis aqui (só no Studio local, com a apresentação salva na biblioteca). Não peça run:."}${mapData ? `\n\n${mapData}` : ""}` },
|
|
1016
1023
|
// a conversa deste deck: o que a pessoa disse lá atrás (compactado) + as últimas trocas inteiras
|
|
1017
1024
|
...(convo.memory ? [{ role: "user", content: convo.memory }, { role: "assistant", content: "Certo, levo isso em conta." }] : []),
|
|
1018
1025
|
...convo.recent,
|
|
@@ -1020,7 +1027,7 @@ Antes de responder, verifique (e siga as Regras de edição):
|
|
|
1020
1027
|
];
|
|
1021
1028
|
let motifObjected = false;
|
|
1022
1029
|
const drawWarned = new Set();
|
|
1023
|
-
const { spec: edited, prose, attempts, changed = [], talk, options = [], variants, imagesDropped, visionRouted, test = [], commands = [], transform, style, review, look } =
|
|
1030
|
+
const { spec: edited, prose, attempts, changed = [], talk, options = [], variants, imagesDropped, visionRouted, test = [], commands = [], transform, style, review, look, geo } =
|
|
1024
1031
|
await askUntilValid(messages, async (t) => {
|
|
1025
1032
|
const parsed = parseEditText(t, spec);
|
|
1026
1033
|
if (repairSlides && (parsed.spec.slides.length !== spec.slides.length || parsed.changed?.some(i => !repairSlides.includes(i)))) throw new Error('A revisão só pode corrigir os slides indicados, sem inserir, excluir ou alterar outros slides.');
|
|
@@ -1043,6 +1050,8 @@ Antes de responder, verifique (e siga as Regras de edição):
|
|
|
1043
1050
|
if (transform) return { reply: prose || "Vou transformar a apresentação.", spec, actions, targetSlide, transform };
|
|
1044
1051
|
// quer ver o material antes de responder: o Studio mostra as imagens e chama de novo (src/studio/ai-chat-route.js)
|
|
1045
1052
|
if (look) return { reply: prose || "Vou olhar o material.", spec, actions, targetSlide, look };
|
|
1053
|
+
// precisa de dados para o mapa: o Studio busca e chama de novo (src/studio/ai-chat-route.js)
|
|
1054
|
+
if (geo) return { reply: prose || "Vou buscar os dados para o mapa.", spec, actions, targetSlide, geo };
|
|
1046
1055
|
// estilo (Brand Kit): quem aplica/salva é o servidor, com a biblioteca
|
|
1047
1056
|
if (style) return { reply: prose || "Certo.", spec, actions, targetSlide, style };
|
|
1048
1057
|
// versões para escolher: nada muda até a pessoa escolher uma
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Diagnóstico da IA configurada, sem tela: cada recurso (texto, visão, imagem, pesquisa, vídeo) com uma chamada de
|
|
2
|
+
// verdade ao provedor, todos ao mesmo tempo, cada resultado avisado assim que chega. Para quem configura e para agentes
|
|
3
|
+
// (Copilot, Claude) saberem em segundos o que funciona, sem abrir o Studio. `sagadeck ia-check`, a ferramenta
|
|
4
|
+
// sagadeck_ia_check do MCP e POST /api/ia/check usam esta mesma função.
|
|
5
|
+
//
|
|
6
|
+
// Vídeo: por padrão só confere a chave e o modelo no catálogo, sem custo; com videoReal: true gera um clipe curto de
|
|
7
|
+
// verdade, baixa e confere que é um MP4 (é o único jeito de provar; nem todo provedor tem catálogo).
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import zlib from "node:zlib";
|
|
12
|
+
import { chat, generateImage, llmConfig, llmConfigured } from "./llm.js";
|
|
13
|
+
import { videoOperation, videoCatalog, videoSetup } from "./video-generation.js";
|
|
14
|
+
|
|
15
|
+
export const CHECKS = { texto: "Texto", visao: "Visão", imagem: "Geração de imagem", pesquisa: "Pesquisa na web", video: "Geração de vídeo" };
|
|
16
|
+
// prazo de cada um (s): um modelo travado não segura os outros
|
|
17
|
+
const PRAZO = { texto: 30, visao: 45, imagem: 120, pesquisa: 60 };
|
|
18
|
+
|
|
19
|
+
// ---- a imagem do teste de visão: duas faixas de cor, desenhada aqui mesmo (PNG mínimo, sem navegador)
|
|
20
|
+
const CORES = { vermelho: [229, 57, 53], azul: [30, 99, 233], verde: [46, 158, 68], amarelo: [245, 197, 24] };
|
|
21
|
+
const SINONIMOS = { vermelho: /vermelh|\bred\b/, azul: /\bazul|\bblue\b/, verde: /\bverde|\bgreen\b/, amarelo: /amarel|\byellow\b/ };
|
|
22
|
+
export function twoColorPNG(left, right, w = 96, h = 48) {
|
|
23
|
+
const row = Buffer.alloc(1 + w * 3);
|
|
24
|
+
for (let x = 0; x < w; x++) row.set(CORES[x < w / 2 ? left : right], 1 + x * 3);
|
|
25
|
+
const chunk = (type, data) => {
|
|
26
|
+
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
27
|
+
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
|
28
|
+
const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32(body));
|
|
29
|
+
return Buffer.concat([len, body, crc]);
|
|
30
|
+
};
|
|
31
|
+
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
|
|
32
|
+
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", ihdr),
|
|
33
|
+
chunk("IDAT", zlib.deflateSync(Buffer.concat(Array.from({ length: h }, () => row)))), chunk("IEND", Buffer.alloc(0))]);
|
|
34
|
+
}
|
|
35
|
+
// as duas cores: sorteadas (um palpite certo por acaso é improvável) ou fixas pelo seed (testes)
|
|
36
|
+
export function colorsFor(seed) {
|
|
37
|
+
const nomes = Object.keys(CORES), i = seed == null ? Math.floor(Math.random() * 12) : Number(seed) % 12;
|
|
38
|
+
const a = Math.floor(i / 3), b = (a + 1 + (i % 3)) % 4;
|
|
39
|
+
return [nomes[a], nomes[b]];
|
|
40
|
+
}
|
|
41
|
+
const IMG_MAGIC = [["image/png", "89504e47"], ["image/jpeg", "ffd8ff"], ["image/webp", "52494646"], ["image/gif", "47494638"]];
|
|
42
|
+
const today = (offsetH = -3) => new Date(Date.now() + offsetH * 3600_000).toISOString().slice(0, 10);
|
|
43
|
+
const quick = (cfg, s) => ({ ...cfg, timeoutMs: s * 1000, firstTimeoutMs: s * 1000 });
|
|
44
|
+
const ask = (cfg, model, content, s) => chat([{ role: "user", content }], { cfg: quick(cfg, s), model, think: false, temperature: 0, maxTokens: 80, allowTruncated: true, retries: 0 });
|
|
45
|
+
const said = (t) => `respondeu "${String(t || "").trim().replace(/\s+/g, " ").slice(0, 80)}"`;
|
|
46
|
+
|
|
47
|
+
const RUN = {
|
|
48
|
+
async texto({ cfg }) {
|
|
49
|
+
const r = await ask(cfg, cfg.textModel, "Responda só com a palavra: pronto", PRAZO.texto);
|
|
50
|
+
return /pronto/i.test(r.text) ? { status: "ok", modelo: cfg.textModel, detalhe: said(r.text) } : { status: "falhou", modelo: cfg.textModel, detalhe: `${said(r.text)} (esperado: pronto)` };
|
|
51
|
+
},
|
|
52
|
+
async visao({ cfg, seed }) {
|
|
53
|
+
const [esq, dir] = colorsFor(seed);
|
|
54
|
+
const url = `data:image/png;base64,${twoColorPNG(esq, dir).toString("base64")}`;
|
|
55
|
+
const r = await ask(cfg, cfg.visionModel, [{ type: "text", text: "A imagem tem duas faixas verticais de cor. Quais são as cores, da esquerda para a direita? Responda só as duas cores, em português, separadas por vírgula." }, { type: "image_url", image_url: { url } }], PRAZO.visao);
|
|
56
|
+
const modelo = r.visionRouted || cfg.visionModel;
|
|
57
|
+
if (r.imagesDropped) return { status: "falhou", modelo, detalhe: "o modelo não aceitou a imagem: respondeu sem vê-la. Escolha em Configurar IA um modelo de visão que aceite imagem." };
|
|
58
|
+
const t = String(r.text || "").toLowerCase(), m = (c) => t.search(SINONIMOS[c]);
|
|
59
|
+
const ok = m(esq) >= 0 && m(dir) >= 0 && m(esq) < m(dir);
|
|
60
|
+
return { status: ok ? "ok" : "falhou", modelo, detalhe: `${said(r.text)}${ok ? "" : ` (a imagem era ${esq}, ${dir})`}` };
|
|
61
|
+
},
|
|
62
|
+
async imagem({ cfg, outDir }) {
|
|
63
|
+
const img = await Promise.race([generateImage("a single red circle centered on a plain white background, flat, minimal", { cfg: quick(cfg, PRAZO.imagem) }),
|
|
64
|
+
new Promise((_, no) => setTimeout(() => no(new Error(`passou de ${PRAZO.imagem} s sem devolver a imagem`)), PRAZO.imagem * 1000).unref())]);
|
|
65
|
+
const hex = img.data.subarray(0, 4).toString("hex"), tipo = IMG_MAGIC.find(([, m]) => hex.startsWith(m))?.[0];
|
|
66
|
+
if (!tipo || img.data.length < 60) return { status: "falhou", modelo: cfg.imageModel, detalhe: `o que voltou não é uma imagem (${img.data.length} bytes)` };
|
|
67
|
+
const arquivo = path.join(outDir(), `imagem${tipo === "image/jpeg" ? ".jpg" : "." + tipo.split("/")[1]}`);
|
|
68
|
+
fs.writeFileSync(arquivo, img.data);
|
|
69
|
+
return { status: "ok", modelo: cfg.imageModel, detalhe: `${tipo}, ${Math.round(img.data.length / 1024)} KB`, arquivo };
|
|
70
|
+
},
|
|
71
|
+
async pesquisa({ cfg }) {
|
|
72
|
+
if (!cfg.searchModel) return { status: "nao_configurado", detalhe: "sem modelo de pesquisa (opcional: sem ele, a pesquisa usa os buscadores diretos)" };
|
|
73
|
+
const r = await ask(cfg, cfg.searchModel, "Pesquise na web: qual é a data de hoje no horário de Brasília? Responda só no formato AAAA-MM-DD.", PRAZO.pesquisa);
|
|
74
|
+
const d = String(r.text || "").match(/(\d{4})-(\d{2})-(\d{2})/)?.[0];
|
|
75
|
+
const dias = d ? Math.abs(Date.parse(d) - Date.parse(today())) / 86400_000 : Infinity;
|
|
76
|
+
return dias <= 1 ? { status: "ok", modelo: cfg.searchModel, detalhe: said(r.text) }
|
|
77
|
+
: { status: "falhou", modelo: cfg.searchModel, detalhe: `${said(r.text)}; hoje é ${today()} (não parece ter pesquisado)` };
|
|
78
|
+
},
|
|
79
|
+
// o mesmo caminho do produto (videoSetup): o modelo de vídeo do Configurar IA, com a chave e o adaptador de lá, ou as
|
|
80
|
+
// variáveis SAGADECK_VIDEO_KEY / OPENROUTER_API_KEY para quem já usava assim
|
|
81
|
+
async video({ cfg, env, fetcher, videoReal, videoTimeoutS, videoPollMs, outDir }) {
|
|
82
|
+
const setup = videoSetup({ env, cfg }), modelo = setup.model, de = ` (via ${setup.origem})`;
|
|
83
|
+
if (!setup.key && !setup.adaptador) return { status: "nao_configurado", modelo, detalhe: "ponha um modelo de vídeo em Configurar IA (usa a chave de lá) ou defina SAGADECK_VIDEO_KEY / OPENROUTER_API_KEY" };
|
|
84
|
+
const net = { setup, ...(fetcher !== fetch ? { fetcher } : {}) };
|
|
85
|
+
if (!videoReal) {
|
|
86
|
+
const cat = await videoCatalog(net);
|
|
87
|
+
if (!cat.ok) return { status: "sem_prova", modelo, detalhe: `${cat.motivo}; só o teste real prova: --video-real${de}` };
|
|
88
|
+
const meta = cat.models.find((m) => m.id === modelo || m.canonical_slug === modelo);
|
|
89
|
+
return meta ? { status: "sem_prova", modelo, detalhe: `o modelo está no catálogo, mas a chave e a geração só se provam gerando: --video-real (é cobrado)${de}` }
|
|
90
|
+
: { status: "falhou", modelo, detalhe: `o modelo não está no catálogo do provedor (${cat.models.length} modelos)` };
|
|
91
|
+
}
|
|
92
|
+
const cwd = outDir();
|
|
93
|
+
const sub = await videoOperation({ action: "submit", model: modelo, prompt: "a small red ball rolling slowly across a plain white table", duration: "min", resolution: "min", out: "video.mp4" }, { cwd, ...net });
|
|
94
|
+
const limite = Date.now() + videoTimeoutS * 1000;
|
|
95
|
+
let st = { status: sub.status };
|
|
96
|
+
while (!["completed", "failed", "error", "cancelled"].includes(st.status)) {
|
|
97
|
+
if (Date.now() > limite) return { status: "falhou", modelo, detalhe: `o clipe não ficou pronto em ${videoTimeoutS} s (job ${sub.id}, último estado: ${st.status || "?"})` };
|
|
98
|
+
await new Promise((r) => setTimeout(r, videoPollMs));
|
|
99
|
+
st = await videoOperation({ action: "status", id: sub.id }, { cwd, ...net });
|
|
100
|
+
}
|
|
101
|
+
if (st.status !== "completed") return { status: "falhou", modelo, detalhe: `o provedor encerrou com ${st.status}${st.error ? `: ${typeof st.error === "string" ? st.error : JSON.stringify(st.error)}` : ""}` };
|
|
102
|
+
const dl = await videoOperation({ action: "download", id: sub.id }, { cwd, ...net });
|
|
103
|
+
return { status: "ok", modelo, detalhe: `MP4 de ${Math.round(dl.bytes / 1024)} KB${dl.cost != null ? `, custou $${Number(dl.cost).toFixed(2)}` : ""}${de}`, arquivo: path.join(cwd, dl.video) };
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const NEEDS_LLM = new Set(["texto", "visao", "imagem", "pesquisa"]);
|
|
108
|
+
// only: lista de ids (padrão: todos); rapido: sem imagem e vídeo (os lentos e cobrados); videoReal: gera um clipe
|
|
109
|
+
export async function iaCheck({ only, rapido = false, videoReal = false, videoTimeoutS = 600, videoPollMs = 5000, cfg = llmConfig(), env = process.env, fetcher = fetch, seed, outDir, onResult = () => {} } = {}) {
|
|
110
|
+
const ids = (only?.length ? only : Object.keys(CHECKS)).filter((id) => CHECKS[id] && !(rapido && (id === "imagem" || id === "video")));
|
|
111
|
+
let dir = outDir;
|
|
112
|
+
const out = () => (dir ||= fs.mkdtempSync(path.join(os.tmpdir(), "sagadeck-ia-check-")));
|
|
113
|
+
const t0 = Date.now();
|
|
114
|
+
const resultados = await Promise.all(ids.map(async (id) => {
|
|
115
|
+
const start = Date.now();
|
|
116
|
+
let r;
|
|
117
|
+
if (NEEDS_LLM.has(id) && !llmConfigured(cfg)) r = { status: "nao_configurado", detalhe: cfg.problem || "a IA não está configurada (Configurar IA, no Studio, ou ~/.sagadeck/ia.json)" };
|
|
118
|
+
else {
|
|
119
|
+
try { r = await RUN[id]({ cfg, env, fetcher, seed, videoReal, videoTimeoutS, videoPollMs, outDir: out }); }
|
|
120
|
+
catch (e) { r = { status: "falhou", detalhe: e?.message || String(e) }; }
|
|
121
|
+
}
|
|
122
|
+
const res = { id, nome: CHECKS[id], ms: Date.now() - start, ...r };
|
|
123
|
+
onResult(res);
|
|
124
|
+
return res;
|
|
125
|
+
}));
|
|
126
|
+
return { ok: resultados.every((r) => r.status !== "falhou"), ms: Date.now() - t0, provedor: cfg.url || cfg.adaptador || "", resultados };
|
|
127
|
+
}
|
package/src/ai/ia-config.js
CHANGED
|
@@ -17,10 +17,10 @@ export const PROVIDERS = {
|
|
|
17
17
|
// responde, o problema é o ambiente (rede, chave), não o modelo escolhido. Depois, cada um põe o modelo que quiser.
|
|
18
18
|
export const RECOMMENDED = {
|
|
19
19
|
provider: "openrouter",
|
|
20
|
-
models: { text: "deepseek/deepseek-v4.1-flash", vision: "deepseek/deepseek-v4.1-flash", image: "google/gemini-3.1-flash-image", search: "deepseek/deepseek-v4.1-flash:online" },
|
|
20
|
+
models: { text: "deepseek/deepseek-v4.1-flash", vision: "deepseek/deepseek-v4.1-flash", image: "google/gemini-3.1-flash-image", search: "deepseek/deepseek-v4.1-flash:online", video: "google/veo-3.1-lite" },
|
|
21
21
|
};
|
|
22
22
|
// os papéis que o sagadeck usa; cada um aponta para um modelo do provedor
|
|
23
|
-
export const ROLES = ["text", "vision", "image", "search"];
|
|
23
|
+
export const ROLES = ["text", "vision", "image", "search", "video"];
|
|
24
24
|
|
|
25
25
|
export function iaFile(env = process.env, home = os.homedir()) {
|
|
26
26
|
return env.SAGADECK_IA || path.join(home, ".sagadeck", "ia.json");
|
package/src/ai/llm.js
CHANGED
|
@@ -48,6 +48,8 @@ export function llmConfig(env = process.env) {
|
|
|
48
48
|
visionModel: env.SAGADECK_VISION_MODEL || m.vision || m.text || "vision",
|
|
49
49
|
// busca na web pelo modelo (no OpenRouter, um modelo ":online"); sem ele, a pesquisa usa os buscadores diretos
|
|
50
50
|
searchModel: env.SAGADECK_SEARCH_MODEL || m.search || (fromEnv ? "search" : ""),
|
|
51
|
+
// vídeo (opcional): com ele, a geração de vídeo usa esta mesma camada (src/ai/video-generation.js, videoSetup)
|
|
52
|
+
videoModel: env.SAGADECK_VIDEO_MODEL || m.video || "",
|
|
51
53
|
headers: ia?.headers || {},
|
|
52
54
|
adaptador: ia?.adaptador || "",
|
|
53
55
|
source: fromEnv ? "variável" : ia ? "arquivo" : "nenhuma",
|
|
@@ -61,7 +63,7 @@ export const llmConfigured = (cfg = llmConfig()) => !!(cfg.url || cfg.adaptador)
|
|
|
61
63
|
|
|
62
64
|
// o papel (text, vision, image, search) vira o modelo configurado; outro nome passa como veio
|
|
63
65
|
export function resolveModel(cfg, name) {
|
|
64
|
-
const roles = { text: cfg.textModel, vision: cfg.visionModel, image: cfg.imageModel, search: cfg.searchModel };
|
|
66
|
+
const roles = { text: cfg.textModel, vision: cfg.visionModel, image: cfg.imageModel, search: cfg.searchModel, video: cfg.videoModel };
|
|
65
67
|
return roles[name] || name;
|
|
66
68
|
}
|
|
67
69
|
|
|
@@ -74,6 +76,8 @@ function headers(cfg) {
|
|
|
74
76
|
// Adaptador (opcional, da pessoa): um .mjs cujo default é (url, init) => Response. Serve para o que não é compatível
|
|
75
77
|
// com a API da OpenAI (outro jeito de autenticar, outro endereço) sem mexer no sagadeck. Sem ele, o fetch normal.
|
|
76
78
|
const adapters = new Map();
|
|
79
|
+
// (exportado: a geração de vídeo manda pelo mesmo caminho, com o mesmo adaptador)
|
|
80
|
+
export const providerFetch = (cfg, url, init) => send(cfg, url, init);
|
|
77
81
|
async function send(cfg, url, init) {
|
|
78
82
|
if (!cfg.adaptador) return fetch(url, init);
|
|
79
83
|
if (!adapters.has(cfg.adaptador)) adapters.set(cfg.adaptador, import(pathToFileURL(path.resolve(cfg.adaptador)).href).then((mod) => mod.default));
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Análises do mapa pedidas pela IA no bloco `mapa:` (entrega 5): o código calcula, grava o resultado no projeto e
|
|
2
|
+
// devolve números para a IA narrar. mais_proximo, contar (em raio ou em área), faixa em volta de uma linha, sugerir
|
|
3
|
+
// onde instalar, rota com tempo, área alcançável e encaixar um rastro de GPS nas ruas. Lê as camadas do projeto (CSV
|
|
4
|
+
// de pontos, GeoJSON, GPX, KML). Quem chama é runMapData (src/ai/map-data.js), que já cuidou do serviço bloqueado.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import * as Project from "../studio/project.js";
|
|
7
|
+
import { parseCSV } from "../csv.js";
|
|
8
|
+
import { pointsFromRows, fromGPX, fromKML } from "../map.js";
|
|
9
|
+
import { geocodeOne } from "../map-services.js";
|
|
10
|
+
import { nearest, withinRadius, inPolygon, pointLine, circle, lineBuffer, suggestSites, fmtMetros } from "../map-analysis.js";
|
|
11
|
+
import { route, isochrone, matchTrace, MODOS } from "../map-routing.js";
|
|
12
|
+
|
|
13
|
+
export const ANALYSES = new Set(["mais_proximo", "contar", "faixa", "sugerir", "rota", "alcance", "encaixar"]);
|
|
14
|
+
|
|
15
|
+
const okFile = (f, ext) => typeof f === "string" && /^contexto\/[\w\-. /]+$/.test(f) && f.toLowerCase().endsWith(ext) && !f.includes("..");
|
|
16
|
+
const nameOf = (props, i) => props?.nome || props?.name || props?.titulo || `item ${i + 1}`;
|
|
17
|
+
const fc = (features) => JSON.stringify({ type: "FeatureCollection", features });
|
|
18
|
+
|
|
19
|
+
async function readPoints(P, file) {
|
|
20
|
+
const { rows } = parseCSV(fs.readFileSync(Project.resolveIn(P, file), "utf8"));
|
|
21
|
+
const head = (rows[0] || []).map((h) => String(h).trim());
|
|
22
|
+
return pointsFromRows(head, rows.slice(1)).features.map((f) => ({ lat: f.geometry.coordinates[1], lon: f.geometry.coordinates[0], props: f.properties }));
|
|
23
|
+
}
|
|
24
|
+
function readShapes(P, file) {
|
|
25
|
+
const text = fs.readFileSync(Project.resolveIn(P, file), "utf8");
|
|
26
|
+
if (/\.gpx$/i.test(file)) return fromGPX(text);
|
|
27
|
+
if (/\.kml$/i.test(file)) return fromKML(text);
|
|
28
|
+
const j = JSON.parse(text);
|
|
29
|
+
return j.type === "FeatureCollection" ? j.features : j.type === "Feature" ? [j] : [{ type: "Feature", properties: {}, geometry: j }];
|
|
30
|
+
}
|
|
31
|
+
async function placeOf(x, cfg) {
|
|
32
|
+
if (Array.isArray(x) && x.length === 2) return [Number(x[0]), Number(x[1])];
|
|
33
|
+
const hit = await geocodeOne(String(x), { cfg });
|
|
34
|
+
if (!hit) throw new Error(`não achei "${x}" no mapa`);
|
|
35
|
+
return [hit.lat, hit.lon];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function runAnalysis(a, { P, cfg, onProgress = () => {} }) {
|
|
39
|
+
if (a.tipo === "mais_proximo") {
|
|
40
|
+
const A = await readPoints(P, a.de), B = await readPoints(P, a.ate);
|
|
41
|
+
if (!A.length || !B.length) throw new Error("as planilhas precisam de pontos com posição");
|
|
42
|
+
const res = nearest(A.map((p) => [p.lat, p.lon]), B.map((p) => [p.lat, p.lon]));
|
|
43
|
+
const arquivo = okFile(a.arquivo, ".csv") ? a.arquivo : `${a.de.replace(/\.csv$/i, "")}-mais-proximo.csv`;
|
|
44
|
+
const cols = Object.keys(A[0].props).filter((c) => !c.startsWith("_"));
|
|
45
|
+
const rows = [[...cols, "latitude", "longitude", "mais_proximo", "distancia_m"],
|
|
46
|
+
...A.map((p, i) => [...cols.map((c) => p.props[c] ?? ""), String(p.lat), String(p.lon), nameOf(B[res[i].j]?.props, res[i].j), String(Math.round(res[i].metros))])];
|
|
47
|
+
Project.writeSheet(P, arquivo, rows);
|
|
48
|
+
const media = res.reduce((s, r) => s + r.metros, 0) / res.length, far = res.reduce((m, r) => (r.metros > m.metros ? r : m), res[0]);
|
|
49
|
+
return { tipo: a.tipo, arquivo, linhas: A.length, oque: `distância de cada item de ${a.de} até o mais próximo de ${a.ate}: média ${fmtMetros(media)}, maior ${fmtMetros(far.metros)} (${nameOf(A[far.i].props, far.i)} até ${nameOf(B[far.j].props, far.j)})` };
|
|
50
|
+
}
|
|
51
|
+
if (a.tipo === "contar") {
|
|
52
|
+
const pts = await readPoints(P, a.pontos);
|
|
53
|
+
if (a.area) {
|
|
54
|
+
const areas = readShapes(P, a.area).filter((f) => /Polygon/.test(f.geometry?.type || ""));
|
|
55
|
+
const counts = areas.map((f, k) => ({ nome: nameOf(f.properties, k), n: pts.filter((p) => inPolygon([p.lat, p.lon], f.geometry)).length }));
|
|
56
|
+
return { tipo: a.tipo, arquivo: a.area, linhas: counts.length, oque: `pontos de ${a.pontos} dentro de cada área: ${counts.map((c) => `${c.nome}: ${c.n}`).join("; ")}` };
|
|
57
|
+
}
|
|
58
|
+
const centro = await placeOf(a.centro, cfg), metros = Number(a.metros) || 1000;
|
|
59
|
+
const dentro = withinRadius(pts.map((p) => [p.lat, p.lon]), centro, metros);
|
|
60
|
+
const arquivo = okFile(a.arquivo, ".geojson") ? a.arquivo : `contexto/raio-${Math.round(metros)}m.geojson`;
|
|
61
|
+
Project.writeText(P, arquivo, fc([{ type: "Feature", properties: { nome: `raio de ${fmtMetros(metros)}`, dentro: dentro.length }, geometry: circle(centro, metros) }]));
|
|
62
|
+
const nomes = dentro.slice(0, 8).map((i) => nameOf(pts[i].props, i)).join(", ");
|
|
63
|
+
return { tipo: a.tipo, arquivo, linhas: dentro.length, oque: `${dentro.length} de ${pts.length} pontos de ${a.pontos} ficam a até ${fmtMetros(metros)}${nomes ? ` (${nomes}${dentro.length > 8 ? "…" : ""})` : ""}; o círculo foi gravado` };
|
|
64
|
+
}
|
|
65
|
+
if (a.tipo === "faixa") {
|
|
66
|
+
const metros = Number(a.metros) || 50;
|
|
67
|
+
const lines = readShapes(P, a.linha).filter((f) => /LineString/.test(f.geometry?.type || ""));
|
|
68
|
+
if (!lines.length) throw new Error(`${a.linha} não tem linhas`);
|
|
69
|
+
const arquivo = okFile(a.arquivo, ".geojson") ? a.arquivo : `${a.linha.replace(/\.[^.]+$/, "")}-faixa-${Math.round(metros)}m.geojson`;
|
|
70
|
+
Project.writeText(P, arquivo, fc(lines.map((f, k) => ({ type: "Feature", properties: { nome: `${nameOf(f.properties, k)} (faixa de ${fmtMetros(metros)})`, _faixa: true }, geometry: lineBuffer(f.geometry, metros) }))));
|
|
71
|
+
let extra = "";
|
|
72
|
+
if (a.pontos) {
|
|
73
|
+
const pts = await readPoints(P, a.pontos);
|
|
74
|
+
const perto = pts.filter((p) => lines.some((f) => pointLine([p.lat, p.lon], f.geometry) <= metros));
|
|
75
|
+
extra = `; ${perto.length} de ${pts.length} pontos de ${a.pontos} ficam a até ${fmtMetros(metros)} da linha${perto.length ? ` (${perto.slice(0, 8).map((p, i) => nameOf(p.props, i)).join(", ")}${perto.length > 8 ? "…" : ""})` : ""}`;
|
|
76
|
+
}
|
|
77
|
+
return { tipo: a.tipo, arquivo, linhas: lines.length, oque: `faixa de ${fmtMetros(metros)} em volta de ${a.linha}${extra}` };
|
|
78
|
+
}
|
|
79
|
+
if (a.tipo === "sugerir") {
|
|
80
|
+
const pts = await readPoints(P, a.existentes);
|
|
81
|
+
const area = a.area ? readShapes(P, a.area).find((f) => /Polygon/.test(f.geometry?.type || ""))?.geometry : null;
|
|
82
|
+
const sug = suggestSites(pts.map((p) => [p.lat, p.lon]), { area, quantos: Math.min(Number(a.quantos) || 3, 10) });
|
|
83
|
+
const arquivo = okFile(a.arquivo, ".csv") ? a.arquivo : "contexto/sugestoes.csv";
|
|
84
|
+
Project.writeSheet(P, arquivo, [["nome", "latitude", "longitude", "distancia_m"], ...sug.map((s, k) => [`Sugestão ${k + 1}`, s.lat.toFixed(6), s.lon.toFixed(6), String(Math.round(s.metros ?? 0))])]);
|
|
85
|
+
return { tipo: a.tipo, arquivo, linhas: sug.length, oque: `lugares mais longe dos existentes em ${a.existentes}${a.area ? ` dentro de ${a.area}` : ""}: ${sug.map((s, k) => `Sugestão ${k + 1} a ${fmtMetros(s.metros)} do mais próximo`).join("; ")} (sugestão geométrica: confira acesso, terreno e regras antes)` };
|
|
86
|
+
}
|
|
87
|
+
if (a.tipo === "rota") {
|
|
88
|
+
onProgress("calculando a rota…");
|
|
89
|
+
const de = await placeOf(a.de, cfg), para = await placeOf(a.para, cfg);
|
|
90
|
+
const r = await route([de, para], { modo: a.modo, cfg });
|
|
91
|
+
const arquivo = okFile(a.arquivo, ".geojson") ? a.arquivo : "contexto/rota.geojson";
|
|
92
|
+
Project.writeText(P, arquivo, fc([{ type: "Feature", properties: { nome: `${typeof a.de === "string" ? a.de : "origem"} até ${typeof a.para === "string" ? a.para : "destino"}`, distancia_m: Math.round(r.distancia), minutos: Math.round(r.duracao / 60), modo: MODOS[r.modo] }, geometry: { type: "LineString", coordinates: r.coords } }]));
|
|
93
|
+
return { tipo: a.tipo, arquivo, linhas: 1, oque: `rota ${MODOS[r.modo]}: ${fmtMetros(r.distancia)}, cerca de ${Math.round(r.duracao / 60)} min` };
|
|
94
|
+
}
|
|
95
|
+
if (a.tipo === "alcance") {
|
|
96
|
+
onProgress("calculando a área alcançável…");
|
|
97
|
+
const minutos = Math.min(Math.max(Number(a.minutos) || 10, 1), 60);
|
|
98
|
+
const origens = typeof a.de === "string" && /\.csv$/i.test(a.de)
|
|
99
|
+
? (await readPoints(P, a.de)).slice(0, 8).map((p, i) => ({ at: [p.lat, p.lon], nome: nameOf(p.props, i) }))
|
|
100
|
+
: [{ at: await placeOf(a.de, cfg), nome: String(a.de) }];
|
|
101
|
+
const feats = [];
|
|
102
|
+
for (const o of origens) feats.push({ type: "Feature", properties: { nome: `${o.nome}: ${minutos} min ${MODOS[a.modo] || "a pé"}` }, geometry: await isochrone(o.at, minutos, { modo: a.modo, cfg }) });
|
|
103
|
+
const arquivo = okFile(a.arquivo, ".geojson") ? a.arquivo : `contexto/alcance-${minutos}min.geojson`;
|
|
104
|
+
Project.writeText(P, arquivo, fc(feats));
|
|
105
|
+
return { tipo: a.tipo, arquivo, linhas: feats.length, oque: `área alcançável em ${minutos} min ${MODOS[a.modo] || "a pé"} a partir de ${origens.length} ${origens.length === 1 ? "ponto" : "pontos"}` };
|
|
106
|
+
}
|
|
107
|
+
if (a.tipo === "encaixar") {
|
|
108
|
+
onProgress("encaixando o rastro nas ruas…");
|
|
109
|
+
const f = readShapes(P, a.trilha).find((x) => /LineString/.test(x.geometry?.type || ""));
|
|
110
|
+
if (!f) throw new Error(`${a.trilha} não tem um rastro (linha)`);
|
|
111
|
+
const coords = f.geometry.type === "LineString" ? f.geometry.coordinates : f.geometry.coordinates.flat();
|
|
112
|
+
const step = Math.max(1, Math.ceil(coords.length / 100)); // o serviço aceita até 100 pontos: amostra o rastro
|
|
113
|
+
const out = await matchTrace(coords.filter((_, i) => i % step === 0).map(([lon, lat]) => [lat, lon]), { modo: a.modo, cfg });
|
|
114
|
+
const arquivo = okFile(a.arquivo, ".geojson") ? a.arquivo : `${a.trilha.replace(/\.[^.]+$/, "")}-nas-ruas.geojson`;
|
|
115
|
+
Project.writeText(P, arquivo, fc([{ type: "Feature", properties: { nome: `${nameOf(f.properties, 0)} (nas ruas)` }, geometry: { type: "LineString", coordinates: out } }]));
|
|
116
|
+
return { tipo: a.tipo, arquivo, linhas: 1, oque: `rastro de ${a.trilha} encaixado nas ruas (${out.length} pontos)` };
|
|
117
|
+
}
|
|
118
|
+
throw new Error(`análise desconhecida: ${a.tipo}`);
|
|
119
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// A IA busca dados geográficos para o mapa (entrega 4), no mesmo molde do "ver o material": a IA que edita o deck,
|
|
2
|
+
// quando precisa de dados de fora, responde um bloco `mapa:` com as ações (lugares do OpenStreetMap, linhas, contornos,
|
|
3
|
+
// uma tabela da web, coordenadas de uma planilha da pessoa). O código executa, grava planilha ou GeoJSON no projeto
|
|
4
|
+
// com a FONTE de cada linha e chama a IA de novo com o que gravou; ela monta o slide. Regras:
|
|
5
|
+
// - a decisão é do modelo (prompt), com o estado de cada serviço à vista; o código recusa, sem pedido de rede, o
|
|
6
|
+
// serviço bloqueado nesta execução (uma tentativa e para);
|
|
7
|
+
// - dados da pessoa (endereços de uma planilha dela) só saem com o sim dela: sem confirmação na conversa, o código
|
|
8
|
+
// não envia e a resposta pergunta (quantos, para qual serviço, quanto tempo);
|
|
9
|
+
// - geometria nunca inventada: vem do OpenStreetMap, da web com fonte, ou dos arquivos.
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { loadMapConfig, mapaFile } from "../map-config.js";
|
|
13
|
+
import { callService, serviceState, geocodeOne, geocodeInterval, ServiceBlocked, SERVICES } from "../map-services.js";
|
|
14
|
+
import * as Project from "../studio/project.js";
|
|
15
|
+
import { addressPlan } from "../studio/geo-routes.js";
|
|
16
|
+
import { chat } from "./llm.js";
|
|
17
|
+
import { jsonOf, runResearch, defaultWeb } from "../research/research.js";
|
|
18
|
+
import { runAnalysis } from "./map-analysis-run.js";
|
|
19
|
+
|
|
20
|
+
// tabela da web: a pesquisa que já existe (busca, lê, anota com a URL) e uma extração que só usa o que leu
|
|
21
|
+
export async function webResearchDefault(pergunta, { saveDir = null } = {}) {
|
|
22
|
+
const r = await runResearch({ pesquisar: true, motivo: pergunta, buscas: [pergunta], academico: false }, { briefing: pergunta, web: defaultWeb, saveDir, maxSources: 5 });
|
|
23
|
+
return r.materials;
|
|
24
|
+
}
|
|
25
|
+
export async function extractTableDefault(pergunta, cols, docs) {
|
|
26
|
+
if (!docs.length) return [];
|
|
27
|
+
const fontes = docs.map((d) => `${d.name}\n${d.detail || ""}\n${clip(d.text, 6000)}`).join("\n\n---\n\n");
|
|
28
|
+
const r = await chat([{ role: "user", content: `Monte a tabela pedida SÓ com o que está nas fontes abaixo. Pedido: ${pergunta}
|
|
29
|
+
Colunas: ${cols.join(", ")}. Cada linha leva também "fonte": a URL da fonte de onde ela saiu (está no detalhe de cada fonte).
|
|
30
|
+
Não invente linhas, nomes nem endereços; o que a fonte não diz fica vazio. Responda só JSON: {"linhas": [{${cols.map((c) => `"${c}": "…"`).join(", ")}, "fonte": "https://…"}]}
|
|
31
|
+
|
|
32
|
+
Fontes:
|
|
33
|
+
${fontes}` }], { maxTokens: 6000, think: false, allowTruncated: true, retries: 0 });
|
|
34
|
+
const j = jsonOf(r.text) || {};
|
|
35
|
+
return (Array.isArray(j.linhas) ? j.linhas : []).filter((x) => x && typeof x === "object");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const clip = (s, n) => (String(s ?? "").length > n ? `${String(s).slice(0, n)}…` : String(s ?? ""));
|
|
39
|
+
const slug = (s) => String(s || "dados").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "dados";
|
|
40
|
+
const okFile = (f, ext) => typeof f === "string" && /^contexto\/[\w\-. /]+$/.test(f) && f.toLowerCase().endsWith(ext) && !f.includes("..");
|
|
41
|
+
|
|
42
|
+
const stateLine = (st) => Object.entries(st).map(([k, v]) => `- ${SERVICES[k]}: ${v.status === "bloqueado" ? `BLOQUEADO nesta rede (${v.motivo}); não peça` : v.status === "ok" ? "funcionando" : "ainda não usado (deve funcionar)"}`).join("\n");
|
|
43
|
+
|
|
44
|
+
// ---- 1. o que a IA principal sabe (vai no prompt de src/ai/deck-ai.js quando há projeto) --------------------------
|
|
45
|
+
// Quem decide é a IA que edita o deck: quando precisa de dados geográficos, ela responde um bloco `mapa:` com as
|
|
46
|
+
// ações (como o `ver:` do material); o Studio executa (runMapData) e chama de novo com o resultado. Sem pedido de
|
|
47
|
+
// mapa, nenhuma chamada a mais.
|
|
48
|
+
export function mapDataPrompt() {
|
|
49
|
+
return `DADOS PARA O MAPA (slide layout: map): quando a pessoa pedir para mostrar no mapa algo que ela NÃO deu (lugares de um
|
|
50
|
+
tipo, linhas como ciclovias ou rios, o contorno de um lugar com nome, uma lista que só existe na web, ou as coordenadas
|
|
51
|
+
dos endereços de uma planilha dela), NÃO invente coordenadas nem geometria: responda uma frase e um bloco yaml só com
|
|
52
|
+
\`\`\`yaml
|
|
53
|
+
mapa:
|
|
54
|
+
- { tipo: lugares, oque: cinemas, filtro_osm: '["amenity"="cinema"]', perto_de: "Centro, Campinas, SP, Brasil", raio_km: 5, arquivo: contexto/cinemas.csv }
|
|
55
|
+
- { tipo: linhas, oque: ciclovias, filtro_osm: '["highway"="cycleway"]', perto_de: "…", raio_km: 3, arquivo: contexto/ciclovias.geojson }
|
|
56
|
+
- { tipo: contorno, nome: "Centro, Campinas, SP, Brasil", arquivo: contexto/centro.geojson }
|
|
57
|
+
- { tipo: web, pergunta: "unidades do INSS em Pernambuco com endereço", colunas: [nome, endereco, cidade], arquivo: contexto/inss-pe.csv }
|
|
58
|
+
- { tipo: coordenadas, planilha: contexto/unidades.csv, confirmado: false }
|
|
59
|
+
\`\`\`
|
|
60
|
+
O Studio busca (OpenStreetMap, web), grava os arquivos com a fonte de cada linha e te devolve o que gravou; aí você monta o
|
|
61
|
+
slide com points:/geojson: e a fonte. filtro_osm são as tags do OpenStreetMap do tipo de lugar. Até 4 ações. Dados que já
|
|
62
|
+
estão nas planilhas do projeto com latitude e longitude não precisam de busca.
|
|
63
|
+
ANÁLISES (contas que o Studio faz sobre os arquivos do projeto; você só narra os números que ele devolver, nunca calcule
|
|
64
|
+
distância, contagem ou tempo de cabeça), no mesmo bloco mapa:
|
|
65
|
+
- { tipo: mais_proximo, de: contexto/pedidos.csv, ate: contexto/lojas.csv } # cada ponto de "de" com o mais próximo de "ate" e a distância
|
|
66
|
+
- { tipo: contar, pontos: contexto/sensores.csv, area: contexto/bairros.geojson } # quantos em cada área (ou centro: [lat, lon] ou "endereço", metros: 500)
|
|
67
|
+
- { tipo: faixa, linha: contexto/rede.geojson, metros: 50, pontos: contexto/imoveis.csv } # faixa em volta da linha e quantos pontos dentro
|
|
68
|
+
- { tipo: sugerir, existentes: contexto/sensores.csv, area: contexto/area.geojson, quantos: 3 } # onde instalar: os lugares mais longe dos existentes
|
|
69
|
+
- { tipo: rota, de: "Rua A, 10, Recife", para: [-8.06, -34.9], modo: pe } # rota pelas ruas com distância e tempo (pe, carro, bicicleta)
|
|
70
|
+
- { tipo: alcance, de: contexto/unidades.csv, minutos: 10, modo: pe } # área alcançável em X minutos (precisa do OpenRouteService)
|
|
71
|
+
- { tipo: encaixar, trilha: contexto/percurso.gpx, modo: carro } # rastro de GPS tremido encaixado nas ruas
|
|
72
|
+
Depois, ponha o resultado no slide (a camada gravada e uma frase com o número) e diga a fonte e o limite da conta.
|
|
73
|
+
Mandar endereços de uma planilha DA PESSOA para um serviço de fora (tipo coordenadas) só com o sim explícito dela nesta
|
|
74
|
+
conversa, dado depois de você dizer quantos endereços, para qual serviço e quanto tempo leva; sem esse sim,
|
|
75
|
+
confirmado: false (o Studio pergunta por você). Dados públicos (lugares, contornos, web) não precisam perguntar.
|
|
76
|
+
Serviços do mapa nesta máquina:
|
|
77
|
+
${stateLine(serviceState())}
|
|
78
|
+
Nunca peça um serviço BLOQUEADO: diga à pessoa o que não está disponível e ofereça a alternativa (planilha com
|
|
79
|
+
coordenadas, os contornos embutidos de estados e países, a planilha do projeto).`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
// ---- 2. executar -------------------------------------------------------------------------------------------------
|
|
84
|
+
// serviços que cada ação usa (as análises sem rede não usam nenhum; rota com endereço em texto usa o geocoder também)
|
|
85
|
+
const NEEDS = { lugares: ["geocoder", "busca"], linhas: ["geocoder", "busca"], contorno: ["geocoder"], web: ["geocoder"], coordenadas: ["geocoder"],
|
|
86
|
+
mais_proximo: [], contar: [], faixa: [], sugerir: [], rota: ["rotas"], alcance: ["rotas"], encaixar: ["rotas"] };
|
|
87
|
+
const ANALYSES = new Set(["mais_proximo", "contar", "faixa", "sugerir", "rota", "alcance", "encaixar"]); // src/ai/map-analysis-run.js
|
|
88
|
+
|
|
89
|
+
async function centerOf(where, cfg) {
|
|
90
|
+
const hit = await geocodeOne(where, { cfg });
|
|
91
|
+
if (!hit) throw new Error(`não achei "${where}" no mapa`);
|
|
92
|
+
return hit;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function overpass(query, cfg) {
|
|
96
|
+
const res = await callService("busca", cfg.busca.url, { method: "POST", cfg, headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: `data=${encodeURIComponent(query)}`, timeoutMs: 40000 });
|
|
97
|
+
return (await res.json()).elements || [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const tagAddress = (t) => [t["addr:street"] && `${t["addr:street"]}${t["addr:housenumber"] ? `, ${t["addr:housenumber"]}` : ""}`, t["addr:suburb"], t["addr:city"]].filter(Boolean).join(" - ");
|
|
101
|
+
|
|
102
|
+
export async function runMapData(acoes, { P, cfg = loadMapConfig(mapaFile()), onProgress = () => {}, webResearch = (q) => webResearchDefault(q, { saveDir: P?.dir }), extractTable = extractTableDefault } = {}) {
|
|
103
|
+
const feitos = [], perguntas = [], falhas = [];
|
|
104
|
+
Project.ensureMeta(P); // contexto/ existe antes de gravar (writeSheet não cria a pasta)
|
|
105
|
+
for (const a of acoes) {
|
|
106
|
+
if (!NEEDS[a.tipo]) { falhas.push(`${a.tipo}: ação desconhecida`); continue; }
|
|
107
|
+
const blocked = NEEDS[a.tipo].map((s) => serviceState()[s]).find((s) => s.status === "bloqueado");
|
|
108
|
+
if (blocked) { falhas.push(`${a.tipo}${a.oque || a.nome ? ` (${a.oque || a.nome})` : ""}: serviço bloqueado nesta rede (${blocked.motivo}); não tentei`); continue; }
|
|
109
|
+
try {
|
|
110
|
+
if (a.tipo === "lugares" || a.tipo === "linhas") {
|
|
111
|
+
const arquivo = okFile(a.arquivo, a.tipo === "lugares" ? ".csv" : ".geojson") ? a.arquivo : `contexto/${slug(a.oque)}.${a.tipo === "lugares" ? "csv" : "geojson"}`;
|
|
112
|
+
const filtro = String(a.filtro_osm || "").replace(/[;(){}]/g, "");
|
|
113
|
+
if (!/^(\[[^\]]+\])+$/.test(filtro)) throw new Error(`filtro do OpenStreetMap inválido: ${filtro}`);
|
|
114
|
+
onProgress(`procurando ${a.oque || a.tipo} perto de ${a.perto_de}…`);
|
|
115
|
+
const c = await centerOf(a.perto_de, cfg);
|
|
116
|
+
const r = Math.round(Math.min(Math.max(Number(a.raio_km) || 3, 0.2), 30) * 1000);
|
|
117
|
+
if (a.tipo === "lugares") {
|
|
118
|
+
const els = await overpass(`[out:json][timeout:30];(node${filtro}(around:${r},${c.lat},${c.lon});way${filtro}(around:${r},${c.lat},${c.lon}););out center tags 300;`, cfg);
|
|
119
|
+
const rows = [["nome", "endereco", "latitude", "longitude", "fonte"]];
|
|
120
|
+
for (const e of els) {
|
|
121
|
+
const lat = e.lat ?? e.center?.lat, lon = e.lon ?? e.center?.lon;
|
|
122
|
+
if (lat == null || lon == null) continue;
|
|
123
|
+
rows.push([e.tags?.name || "(sem nome)", tagAddress(e.tags || {}), String(lat), String(lon), `https://www.openstreetmap.org/${e.type}/${e.id}`]);
|
|
124
|
+
}
|
|
125
|
+
Project.writeSheet(P, arquivo, rows);
|
|
126
|
+
feitos.push({ tipo: a.tipo, arquivo, linhas: rows.length - 1, oque: a.oque, perto_de: a.perto_de, fonte: "OpenStreetMap (© colaboradores do OpenStreetMap)" });
|
|
127
|
+
} else {
|
|
128
|
+
const els = await overpass(`[out:json][timeout:30];way${filtro}(around:${r},${c.lat},${c.lon});out geom tags 300;`, cfg);
|
|
129
|
+
const features = els.filter((e) => Array.isArray(e.geometry) && e.geometry.length > 1).map((e) => ({ type: "Feature", properties: { nome: e.tags?.name || "(sem nome)", fonte: `https://www.openstreetmap.org/way/${e.id}` }, geometry: { type: "LineString", coordinates: e.geometry.map((g) => [g.lon, g.lat]) } }));
|
|
130
|
+
Project.writeText(P, arquivo, JSON.stringify({ type: "FeatureCollection", features }));
|
|
131
|
+
feitos.push({ tipo: a.tipo, arquivo, linhas: features.length, oque: a.oque, perto_de: a.perto_de, fonte: "OpenStreetMap (© colaboradores do OpenStreetMap)" });
|
|
132
|
+
}
|
|
133
|
+
} else if (a.tipo === "contorno") {
|
|
134
|
+
const arquivo = okFile(a.arquivo, ".geojson") ? a.arquivo : `contexto/${slug(a.nome)}.geojson`;
|
|
135
|
+
onProgress(`procurando o contorno de ${a.nome}…`);
|
|
136
|
+
const g = cfg.geocoder;
|
|
137
|
+
const res = await callService("geocoder", `${g.url.replace(/\/+$/, "")}/search?format=jsonv2&limit=1&polygon_geojson=1&polygon_threshold=0.0005&q=${encodeURIComponent(a.nome)}`, { cfg });
|
|
138
|
+
const hit = (await res.json())?.[0];
|
|
139
|
+
if (!hit?.geojson || !/Polygon/.test(hit.geojson.type)) throw new Error(`não achei o contorno de "${a.nome}"`);
|
|
140
|
+
Project.writeText(P, arquivo, JSON.stringify({ type: "FeatureCollection", features: [{ type: "Feature", properties: { nome: a.nome, fonte: `https://www.openstreetmap.org/${hit.osm_type}/${hit.osm_id}` }, geometry: hit.geojson }] }));
|
|
141
|
+
feitos.push({ tipo: a.tipo, arquivo, linhas: 1, oque: a.nome, fonte: "OpenStreetMap (© colaboradores do OpenStreetMap)" });
|
|
142
|
+
} else if (a.tipo === "web") {
|
|
143
|
+
if (!webResearch || !extractTable) throw new Error("pesquisa na web indisponível");
|
|
144
|
+
const arquivo = okFile(a.arquivo, ".csv") ? a.arquivo : `contexto/${slug(a.pergunta)}.csv`;
|
|
145
|
+
onProgress(`pesquisando na web: ${a.pergunta}…`);
|
|
146
|
+
const docs = await webResearch(a.pergunta);
|
|
147
|
+
const cols = (Array.isArray(a.colunas) && a.colunas.length ? a.colunas : ["nome", "endereco"]).map(String).slice(0, 8);
|
|
148
|
+
const table = await extractTable(a.pergunta, cols, docs); // [{…colunas, fonte}]
|
|
149
|
+
const rows = [[...cols, "latitude", "longitude", "fonte"]];
|
|
150
|
+
const wait = geocodeInterval(cfg);
|
|
151
|
+
let last = 0;
|
|
152
|
+
for (const t of table.slice(0, 200)) {
|
|
153
|
+
const q = [t.endereco, t.cidade, t.uf].filter(Boolean).join(", ") || t.nome;
|
|
154
|
+
let hit = null;
|
|
155
|
+
if (q) { const pause = last + wait - Date.now(); if (pause > 0) await new Promise((r) => setTimeout(r, pause)); last = Date.now(); hit = await geocodeOne(q, { cfg }).catch((e) => { if (e instanceof ServiceBlocked) throw e; return null; }); }
|
|
156
|
+
rows.push([...cols.map((c) => String(t[c] ?? "")), hit ? String(hit.lat) : "", hit ? String(hit.lon) : "", String(t.fonte || "")]);
|
|
157
|
+
}
|
|
158
|
+
Project.writeSheet(P, arquivo, rows);
|
|
159
|
+
feitos.push({ tipo: a.tipo, arquivo, linhas: rows.length - 1, oque: a.pergunta, fonte: "web (a coluna fonte de cada linha diz de onde veio; revise antes de apresentar)" });
|
|
160
|
+
} else if (ANALYSES.has(a.tipo)) {
|
|
161
|
+
feitos.push(await runAnalysis(a, { P, cfg, onProgress }));
|
|
162
|
+
} else if (a.tipo === "coordenadas") {
|
|
163
|
+
const abs = Project.resolveIn(P, a.planilha);
|
|
164
|
+
const sheet = (await Project.readSheet(abs)).sheets[0];
|
|
165
|
+
const pl = addressPlan(sheet.rows, a.coluna);
|
|
166
|
+
if (pl.error) throw new Error(pl.error);
|
|
167
|
+
if (!pl.items.length) { feitos.push({ tipo: a.tipo, arquivo: a.planilha, linhas: 0, oque: "todas já tinham coordenadas" }); continue; }
|
|
168
|
+
if (a.confirmado !== true) {
|
|
169
|
+
const host = (() => { try { return new URL(cfg.geocoder.url).host; } catch { return cfg.geocoder.url; } })();
|
|
170
|
+
perguntas.push(`Para pôr no mapa, preciso achar as coordenadas de ${pl.items.length} endereço${pl.items.length === 1 ? "" : "s"} da planilha ${a.planilha} (coluna ${pl.column}). Isso envia esses endereços ao serviço ${host} e leva cerca de ${Math.max(1, Math.ceil((pl.items.length * geocodeInterval(cfg)) / 60000))} min. Posso enviar? (Também dá para fazer pela planilha, no botão Achar coordenadas.)`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
onProgress(`achando as coordenadas de ${pl.items.length} endereços…`);
|
|
174
|
+
const rows = sheet.rows.map((r) => [...r]);
|
|
175
|
+
let { iLat, iLon } = pl;
|
|
176
|
+
if (iLat < 0 || iLon < 0) { rows[0].push("latitude", "longitude"); iLat = rows[0].length - 2; iLon = rows[0].length - 1; }
|
|
177
|
+
const wait = geocodeInterval(cfg);
|
|
178
|
+
let ok = 0, last = 0;
|
|
179
|
+
for (const it of pl.items) {
|
|
180
|
+
const pause = last + wait - Date.now(); if (pause > 0) await new Promise((r) => setTimeout(r, pause)); last = Date.now();
|
|
181
|
+
const hit = await geocodeOne(it.q, { cfg });
|
|
182
|
+
if (hit) { ok++; const r = rows[it.row]; while (r.length < rows[0].length) r.push(""); r[iLat] = String(hit.lat); r[iLon] = String(hit.lon); }
|
|
183
|
+
}
|
|
184
|
+
const out = /\.(csv|tsv)$/i.test(a.planilha) ? a.planilha : a.planilha.replace(/\.[^.]+$/, "") + ".csv";
|
|
185
|
+
Project.writeSheet(P, out, rows);
|
|
186
|
+
feitos.push({ tipo: a.tipo, arquivo: out, linhas: ok, oque: `coordenadas de ${ok} de ${pl.items.length} endereços` });
|
|
187
|
+
}
|
|
188
|
+
} catch (e) {
|
|
189
|
+
falhas.push(`${a.tipo}${a.oque || a.nome ? ` (${a.oque || a.nome})` : ""}: ${e.message}`);
|
|
190
|
+
if (e instanceof ServiceBlocked) continue;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { feitos, perguntas, falhas };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// o que a IA recebe junto do pedido: o que foi gravado (para usar no slide) e o que falhou (para dizer à pessoa)
|
|
197
|
+
export function mapDataInstruction({ feitos, falhas }) {
|
|
198
|
+
if (!feitos.length && !falhas.length) return "";
|
|
199
|
+
const lines = feitos.map((f) => `- ${f.arquivo}: ${f.linhas} ${f.tipo === "contorno" ? "contorno" : f.tipo === "linhas" ? "linhas" : "linhas"} (${f.oque}${f.perto_de ? `, perto de ${f.perto_de}` : ""}); fonte: ${f.fonte || "planilha da pessoa"}`);
|
|
200
|
+
const vazios = feitos.filter((f) => (f.tipo === "lugares" || f.tipo === "linhas") && !f.linhas);
|
|
201
|
+
return `[Dados geográficos buscados pelo sagadeck agora]\n${lines.join("\n")}${vazios.length ? `\nA busca não achou nada em ${vazios.map((f) => f.oque).join(", ")} nesse raio: diga isso e ofereça buscar num raio maior (ou outro lugar), sem montar mapa vazio.` : ""}${falhas.length ? `\nNão deu: ${falhas.join("; ")}. Diga isso à pessoa sem inventar o que faltou.` : ""}
|
|
202
|
+
Use esses arquivos no slide de mapa (points: para .csv, geojson: para .geojson), com a fonte numa nota ou em source: ("Fonte: OpenStreetMap"). Dado da web: avise que vale revisar.`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// planilhas do projeto, para a decisão saber o que já existe
|
|
206
|
+
export async function projectSheets(P) {
|
|
207
|
+
const out = [];
|
|
208
|
+
const dir = path.join(P.dir, "contexto");
|
|
209
|
+
const walk = (d) => { for (const e of fs.existsSync(d) ? fs.readdirSync(d, { withFileTypes: true }) : []) { const f = path.join(d, e.name); if (e.isDirectory()) walk(f); else if (/\.(csv|tsv|xlsx)$/i.test(e.name)) out.push(f); } };
|
|
210
|
+
walk(dir);
|
|
211
|
+
const sheets = [];
|
|
212
|
+
for (const f of out.slice(0, 12)) {
|
|
213
|
+
try {
|
|
214
|
+
const rows = (await Project.readSheet(f)).sheets[0].rows;
|
|
215
|
+
const rel = path.relative(P.dir, f).split(path.sep).join("/");
|
|
216
|
+
const pl = addressPlan(rows);
|
|
217
|
+
sheets.push({ path: rel, columns: (rows[0] || []).map(String).slice(0, 12), rows: Math.max(0, rows.length - 1), semPosicao: pl.error ? 0 : pl.items.length });
|
|
218
|
+
} catch { /* planilha que não abre: fica de fora */ }
|
|
219
|
+
}
|
|
220
|
+
return sheets;
|
|
221
|
+
}
|
package/src/ai/quality.js
CHANGED
|
@@ -51,7 +51,7 @@ export function auditText(spec, indices, { briefing = '', render = null } = {})
|
|
|
51
51
|
// título; o layout da figura já reserva a área. Vale para os layouts com cabeçalho comum (não capa/seção/manchete).
|
|
52
52
|
for (const index of indices) {
|
|
53
53
|
const s = slides[index];
|
|
54
|
-
if (!s || !Number.isFinite(Number(s.titleSize)) || ONE_LINER.has(s.layout) || ['cover', 'section', 'end', '
|
|
54
|
+
if (!s || !Number.isFinite(Number(s.titleSize)) || ONE_LINER.has(s.layout) || ['cover', 'section', 'end', 'full'].includes(s.layout)) continue;
|
|
55
55
|
if (Number(s.titleSize) < 60) issues.push({ slide: index + 1, text: `Título com titleSize ${s.titleSize}, bem menor que o dos outros slides: a hierarquia do deck fica desigual. Tire o titleSize (o layout já dá a área da figura); se o título não couber, encurte o texto.` });
|
|
56
56
|
}
|
|
57
57
|
// muitas telas de frase solta: a partir da terceira (ou de 15% do deck), cada uma precisa justificar o lugar
|