@noeva/analytics-core 0.4.1 → 0.5.1

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/CHANGELOG.md CHANGED
@@ -3,6 +3,69 @@
3
3
  Le versioni del pacchetto che i repository dei progetti installano (ADR 0005). Prima di
4
4
  `analytics upgrade` si legge cosa cambia fra la versione installata e la nuova.
5
5
 
6
+ ## 0.5.1 — 12 novembre 2026
7
+
8
+ - Il servizio MCP dei PC (`noeva-<id>.exe`) non si blocca piu' su una query lenta
9
+ (11/11/2026). Gli strumenti di Wren erano sincroni e giravano nell'event loop:
10
+ una query lunga fermava tutte le altre richieste e l'annullamento. Ora run_sql,
11
+ dry_run e query_cube girano in piu' thread, ognuno con la sua connessione
12
+ (`connessioni` in noeva.json, 3 per il vivo), con un tempo massimo per query
13
+ (`limite_query_s`, 120 s) fatto rispettare dal driver ODBC, che annulla la query su
14
+ SQL Server, e dal servizio stesso se il driver non basta. Claude riceve un errore
15
+ che dice di ridurre il periodo, non di riprovare in parallelo. Quando Claude annulla
16
+ la richiesta MCP, la query si ferma anche sul database (`server/esecuzione.py`).
17
+ - `noeva-<id>.exe --verifica` dice se il servizio aperto in Claude ha una query in
18
+ corso oltre il tempo massimo (il file di stato nella cartella temporanea), e in quel
19
+ caso chiude con un problema.
20
+ - Le regole delle query sul vivo (un mese poi l'anno, mai query pesanti in parallelo,
21
+ aggregare prima di unire, ridurre il periodo dopo un timeout) stanno in
22
+ `lib/modello/istruzioni.json` (`regole_query`): nelle istruzioni di initialize, in
23
+ locale e dal gateway, e nella scheda `00-progetto`, che arriva col modello
24
+ pubblicato anche ai servizi gia' installati. Anche nella descrizione di run_sql.
25
+ - Per le nuove regole nella scheda il progetto ripubblica il modello
26
+ (`analytics modello`, il job `pubblica` della CI); per il tempo massimo e le
27
+ connessioni serve un rilascio dell'eseguibile (`rilascio-windows.yml`).
28
+
29
+ ## 0.5.0 — 12 novembre 2026
30
+
31
+ - **L'AI passa solo da NOEVA.** I moduli di pagina non offrono piu' la CLI di Claude:
32
+ `inferenza.js` ha solo NOEVA · database e NOEVA · snapshot, `chat.js` risponde solo
33
+ dall'inferenza NOEVA (o NOEVA Agents), `commento.js` fa il tour solo con l'inferenza
34
+ (il tour di prova, `?commento=prova`, si costruisce nella pagina, senza AI).
35
+ - **La presentazione con l'inferenza NOEVA**: scaletta e modifiche in chat passano da
36
+ `ai/converse` come tour e chat, anche sul sito pubblicato; le partenze arrivano col
37
+ prompt. `lib/snapshot/ai.py` scrive anche
38
+ `apps/dati/ai/presentazione-<pagina>.<lingua>.json` da `presentazione.py`
39
+ (`istruzioni`, `LINGUE`, `SCHEMA`, `SCHEMA_MODIFICA`, `PRESET`, `MODIFICA`). Le
40
+ scalette gia' pronte restano buone e si usano prima.
41
+ - `analytics ci` si ferma se un file del progetto lancia la CLI di Claude, chiama
42
+ un'API di modelli diretta (Anthropic, OpenAI, Bedrock, Gemini) o se `serve-app.py`
43
+ serve `/ai/chat`, `/ai/chat/storia`, `/ai/tour`, `/ai/presentazione/modifica`. Non
44
+ c'e' eccezione da dichiarare.
45
+ - Prima di aggiornare, nel progetto: `chat.py`, `commento.py` e `presentazione.py`
46
+ tengono solo prompt, regole e schema (niente `subprocess`, niente `claude`);
47
+ `serve-app.py` toglie le rotte della CLI; si rilancia `ai.py` per i prompt della
48
+ presentazione. Le frasi nuove da tradurre in `en.js`: «Tappa di prova, senza AI.»,
49
+ «Tour di prova», «Sistema con l'AI», «Sto scrivendo la scaletta…», «Sto scrivendo la
50
+ scaletta: di solito uno o due minuti, tre per l'analisi completa.», «la presentazione
51
+ non ha slide», «il prompt della presentazione di questa pagina non è pronto»,
52
+ «partenza sconosciuta: {p}», «la richiesta è troppo lunga: massimo 2000 caratteri»,
53
+ «l'AI di questo progetto non è configurata», «per questa vista non c'è una scaletta
54
+ pronta e l'AI di questo progetto non è attiva», «per questa vista non c'è un commento
55
+ pronto e l'AI di questo progetto non è attiva», «La presentazione è pronta: {n} slide.
56
+ Qui la sfogli, la presenti e generi il PowerPoint; la chat per sistemarla c'è dove
57
+ l'AI del progetto è attiva.».
58
+ - Tolto `lib/snapshot/misura-memoria.py` (lanciava la CLI).
59
+ - `lib/ai/inferenza.py` (in `contenuto/lib/ai/`): l'inferenza NOEVA da Python per gli
60
+ strumenti del progetto sul Mac che non sono pagine (es. la traduzione della knowledge
61
+ base). `converse(radice, sistema, testo, funzione=...)`; la chiave
62
+ `NOEVA_INFERENZA_CHIAVE` sta nell'ambiente o in `.wren/.env`, il workspace in
63
+ `analytics.json`, il modello in `NOEVA_INFERENZA_MODELLO` (predefinito Haiku 5.5).
64
+ - Anche, da lavori di altre sessioni dopo la 0.4.1: `vista.js` scrive il titolo della
65
+ barra senza il separatore iniziale («· Vendite e CRM» → «Vendite e CRM»); il worker
66
+ degli snapshot ha `ultimi-giri.ps1` (gli ultimi giri e il loro esito sulla macchina del
67
+ cliente) e `installa.ps1` chiede la password Windows in console.
68
+
6
69
  ## 0.4.1 — 10 novembre 2026
7
70
 
8
71
  - `voce.js`: `montaSceltaVoce("#voce", { lingue: ["it"] })` mostra solo le lingue in cui
@@ -9,17 +9,17 @@
9
9
  * nella testa e ricordati nel browser per tutte le pagine (VISTE): laterale (il
10
10
  * default), meta' schermo con il report che si stringe accanto, schermo intero, e
11
11
  * finestra mobile da trascinare e ridimensionare sopra il report. Sotto i 768 px
12
- * e' sempre a schermo intero. Ogni domanda va a
13
- * serve-app.py (/ai/chat, chat.py) con i filtri e il contesto della pagina, come
14
- * per il commento; la risposta arriva in streaming e si vede mentre viene scritta,
15
- * con le query eseguite in chiaro sotto ogni risposta. La sessione e' vera: la
16
- * CLI ricorda le domande precedenti, e ricaricando la pagina si riprende
17
- * l'ultima conversazione (l'id sta in localStorage, la storia sul server).
12
+ * e' sempre a schermo intero. Ogni domanda va all'inferenza NOEVA (ai/converse
13
+ * del contenitore) con i filtri e il contesto della pagina e il prompt di chat.py
14
+ * esportato da lib/snapshot/ai.py; la pagina tiene il giro, con run_sql sullo
15
+ * snapshot o sul database vivo (serve-app.py, Mac), e mostra le query eseguite
16
+ * sotto ogni risposta. Mai la CLI di Claude. La conversazione sta nel browser e
17
+ * ricaricando la pagina si riprende.
18
18
  *
19
- * Solo sul Mac: dietro il cancello il server non c'e' e il pulsante non compare.
19
+ * Senza inferenza la chat passa a NOEVA Agents, se c'e'; altrimenti non compare.
20
20
  */
21
21
  import { contesto } from "./commento.js";
22
- import { conServer, rotta, fileDati, sha256 } from "./servizio.js";
22
+ import { fileDati, sha256 } from "./servizio.js";
23
23
  import { giro, nuovaSessione, motori, motoreScelto, selettoreMotore } from "./inferenza.js";
24
24
  import { t, LINGUA } from "./lingua.js";
25
25
  import { PROGETTO } from "./progetto.js";
@@ -127,9 +127,9 @@ export function montaChat({ pagina }) {
127
127
  if (document.getElementById("chat")) return;
128
128
  const barra = document.querySelector(".topbar");
129
129
  if (!barra) return;
130
- // Il pannello compare dove qualcuno puo' rispondere: NOEVA Agents (ADR 0006,
131
- // dove il contenitore lo offre), l'inferenza NOEVA del contenitore (anche sul
132
- // sito pubblicato) o, senza, il server sul Mac.
130
+ // Il pannello compare dove qualcuno puo' rispondere, sempre da NOEVA: NOEVA
131
+ // Agents (ADR 0006, dove il contenitore lo offre) o l'inferenza NOEVA del
132
+ // contenitore. Senza nessuno dei due la chat non c'e'.
133
133
  // Al caricamento basta sapere se NOEVA Agents c'e': il token si chiede quando
134
134
  // la chat si apre (NW-1279).
135
135
  agentiDisponibili().then(async (a) => {
@@ -168,8 +168,7 @@ export function montaChat({ pagina }) {
168
168
  let montato = null;
169
169
  if (agenti) for (const s of ["#ch-msgs", "#ch-stato", "#ch-form", "#ch-nuova"]) $(s).remove();
170
170
  const CHIAVE = `${PROGETTO.id}-chat-${pagina}-${LINGUA}`;
171
- const st = { sessione: null, inCorso: null, msgs: [] };
172
- try { st.sessione = localStorage.getItem(CHIAVE); } catch { /* niente memoria */ }
171
+ const st = { inCorso: null, msgs: [] };
173
172
  // Con l'inferenza la conversazione sta nel browser: i turni (quello che si
174
173
  // e' mandato al modello e quello che ha risposto), l'impronta del contesto
175
174
  // dell'ultima domanda e l'id di sessione del trace.
@@ -178,8 +177,7 @@ export function montaChat({ pagina }) {
178
177
  const scriviAI = (c) => { try { localStorage.setItem(CHIAVE_AI, JSON.stringify(c)); } catch { /* piena: si perde la memoria, non la risposta */ } };
179
178
  const nuovaAI = () => ({ sessione: nuovaSessione("chat", pagina), impronta: "", turni: [] });
180
179
  // Il motore si legge a ogni domanda: il selettore (sul Mac) lo puo' cambiare.
181
- // Le due modalita' NOEVA condividono la conversazione; la CLI ha la sua.
182
- const conNoeva = async () => (await motoreScelto())?.startsWith("noeva");
180
+ // Le due modalita' NOEVA condividono la conversazione.
183
181
  if (!agenti) {
184
182
  selettoreMotore({ classe: "ch" }).then((sel) => sel && $("#ch-nuova").before(sel));
185
183
  addEventListener("noeva-motore", () => { if (!st.inCorso) riprendi(); });
@@ -208,30 +206,15 @@ export function montaChat({ pagina }) {
208
206
 
209
207
  async function riprendi() {
210
208
  $("#ch-msgs").innerHTML = "";
211
- if (await conNoeva()) {
212
- const c = leggiAI();
213
- for (const x of c?.turni || []) {
214
- if (x.ruolo === "utente") utente(x.testo); else await claude(messaggio("claude"), x.testo, x.strumenti || []);
215
- }
216
- if (c?.turni?.length) { stato(t("conversazione ripresa")); return; }
217
- suggerimenti();
218
- return;
219
- }
220
- if (st.sessione) {
221
- try {
222
- const r = await fetch(rotta(`ai/chat/storia?sessione=${encodeURIComponent(st.sessione)}`), { cache: "no-store" });
223
- const j = await r.json();
224
- if (!r.ok || j.errore) throw new Error(j.errore || r.status);
225
- for (const x of j.messaggi || []) {
226
- if (x.ruolo === "utente") utente(x.testo); else await claude(messaggio("claude"), x.testo, x.strumenti || []);
227
- }
228
- if ((j.messaggi || []).length) { stato(t("conversazione ripresa")); return; }
229
- } catch { nuova(false); }
209
+ const c = leggiAI();
210
+ for (const x of c?.turni || []) {
211
+ if (x.ruolo === "utente") utente(x.testo); else await claude(messaggio("claude"), x.testo, x.strumenti || []);
230
212
  }
213
+ if (c?.turni?.length) { stato(t("conversazione ripresa")); return; }
231
214
  suggerimenti();
232
215
  }
233
216
  function nuova(pulisci = true) {
234
- st.sessione = null;
217
+ // CHIAVE era l'id della sessione della CLI, nelle versioni prima della 0.5.0
235
218
  try { localStorage.removeItem(CHIAVE); localStorage.removeItem(CHIAVE_AI); } catch { /* */ }
236
219
  if (pulisci) { $("#ch-msgs").innerHTML = ""; suggerimenti(); stato(""); $("#ch-testo").focus(); }
237
220
  }
@@ -337,39 +320,12 @@ export function montaChat({ pagina }) {
337
320
  stato(t("Claude legge la pagina…"));
338
321
  const t0 = performance.now();
339
322
  const orologio = setInterval(() => stato(`${t("Claude sta rispondendo…")} ${Math.round((performance.now() - t0) / 1000)} s`), 1000);
340
- let testo = "", strumenti = [], fine = null, errore = null;
341
323
  try {
342
324
  const motore = await motoreScelto();
343
- if (motore?.startsWith("noeva")) {
344
- const g = await domandaAI(domanda, motore === "noeva-vivo" ? "vivo" : "snapshot", ctrl.signal, (s) => { strumenti.push(s); stato(t("interrogo il modello…")); });
345
- await claude(m, g.risultato, g.strumenti);
346
- stato(`${Math.round((performance.now() - t0) / 1000)} s` + (g.turni > 1 ? ` · ${t("{n} passaggi", { n: g.turni })}` : ""));
347
- return;
348
- }
349
- const r = await fetch(rotta("ai/chat"), { method: "POST", headers: { "content-type": "application/json" }, signal: ctrl.signal,
350
- body: JSON.stringify({ pagina, lingua: LINGUA, sessione: st.sessione, domanda, contesto: contesto() }) });
351
- if (!r.ok) throw new Error(`${r.status}`);
352
- const lettore = r.body.getReader(), dec = new TextDecoder();
353
- let resto = "";
354
- for (;;) {
355
- const { value, done } = await lettore.read();
356
- if (done) break;
357
- resto += dec.decode(value, { stream: true });
358
- const righe = resto.split("\n"); resto = righe.pop();
359
- for (const riga of righe) {
360
- if (!riga.trim()) continue;
361
- let ev; try { ev = JSON.parse(riga); } catch { continue; }
362
- if (ev.tipo === "sessione") { st.sessione = ev.sessione; try { localStorage.setItem(CHIAVE, ev.sessione); } catch { /* */ } }
363
- else if (ev.tipo === "testo") { testo += ev.testo; m.textContent = testo; giu(); }
364
- else if (ev.tipo === "strumento") { strumenti.push(ev); stato(ev.sql ? t("interrogo il modello…") : `${ev.nome}…`); }
365
- else if (ev.tipo === "fine") fine = ev;
366
- else if (ev.tipo === "errore") errore = ev.errore;
367
- }
368
- }
369
- if (errore) throw new Error(errore);
370
- if (!fine && !testo) throw new Error(t("nessuna risposta"));
371
- await claude(m, fine?.testo || testo, strumenti);
372
- stato(`${Math.round((performance.now() - t0) / 1000)} s` + (fine?.turni > 1 ? ` · ${t("{n} passaggi", { n: fine.turni })}` : ""));
325
+ if (!motore?.startsWith("noeva")) throw new Error(t("l'AI di questo progetto non è configurata"));
326
+ const g = await domandaAI(domanda, motore === "noeva-vivo" ? "vivo" : "snapshot", ctrl.signal, () => stato(t("interrogo il modello…")));
327
+ await claude(m, g.risultato, g.strumenti);
328
+ stato(`${Math.round((performance.now() - t0) / 1000)} s` + (g.turni > 1 ? ` · ${t("{n} passaggi", { n: g.turni })}` : ""));
373
329
  } catch (err) {
374
330
  m.classList.remove("scrive"); m.classList.add("errore");
375
331
  m.textContent = err.name === "AbortError" ? t("Fermata.") : t("Non ci sono riuscito: {errore}", { errore: err.message || err });
@@ -13,10 +13,10 @@
13
13
  * tappa puo' indicare. Cosi' quello che Claude commenta e' esattamente quello
14
14
  * che il cliente vede, con i filtri di quel momento.
15
15
  *
16
- * Tour e voce li fa serve-app.py (/ai/tour, /ai/voce, vedi commento.py), con la
17
- * cache su disco: la vista iniziale, preparata prima, parte subito e senza rete.
18
- * Dietro il cancello il server non c'e': restano i tour e le voci gia' pronti,
19
- * trovati con la stessa chiave del server (servizio.js), e la voce la fa il
16
+ * Il tour lo scrive l'inferenza NOEVA (ai/converse del contenitore), con il
17
+ * prompt di commento.py esportato da lib/snapshot/ai.py: mai la CLI di Claude.
18
+ * I tour gia' pronti (dati/commento/, trovati con la chiave di servizio.js)
19
+ * partono prima e senza rete. La voce la fa serve-app.py sul Mac (/ai/voce) o il
20
20
  * contenitore (ai/voci, voce.js) con la voce scelta nella home del progetto.
21
21
  */
22
22
  import { conServer, chiaveServer, sha256, fileDati, urlDati, conLingua, rotta } from "./servizio.js";
@@ -223,12 +223,29 @@ tr.evid > td { background: color-mix(in srgb, var(--primary) 16%, var(--surface)
223
223
  @media (max-width: 560px) { #commento { right: 0; bottom: 0; max-width: 100vw; width: 100vw; } }
224
224
  `;
225
225
 
226
- // ?commento=prova nell'indirizzo: tour fisso costruito dai numeri della pagina, senza Claude
226
+ // ?commento=prova nell'indirizzo: tour fisso costruito dai numeri della pagina, senza AI
227
227
  const PROVA = new URLSearchParams(location.search).get("commento") === "prova";
228
228
 
229
+ /* Il tour di prova: per provare evidenziazioni e voce senza rete e senza
230
+ * inferenza. Due indicatori e un elemento per i grafici e le tabelle, al
231
+ * massimo quattro tappe. */
232
+ export function tourDiProva(ctx) {
233
+ const tappe = [];
234
+ for (const r of ctx.riquadri || []) {
235
+ if (r.tipo === "indicatore" && tappe.length < 2) {
236
+ tappe.push({ target: r.id, valore: "", titolo: r.titolo, testo: `${r.titolo}: ${r.testo || ""}. ${t("Tappa di prova, senza AI.")}` });
237
+ } else if ((r.tipo === "grafico mensile" || r.tipo === "tabella") && r.elementi?.length) {
238
+ const el = r.elementi[Math.min(1, r.elementi.length - 1)];
239
+ tappe.push({ target: r.id, valore: el, titolo: `${r.titolo}: ${el}`, testo: `${r.titolo}: ${el}. ${t("Tappa di prova, senza AI.")}` });
240
+ }
241
+ if (tappe.length >= 4) break;
242
+ }
243
+ return { titolo: t("Tour di prova"), tappe, _motore: { tipo: "prova" } };
244
+ }
245
+
229
246
  /* Il tour dall'inferenza NOEVA: il prompt di commento.py (esportato da
230
247
  * lib/snapshot/ai.py in dati/ai/), il contesto della pagina come richiesta,
231
- * e il giro con run_sql sullo snapshot. Come genera_tour sul Mac, le tappe su
248
+ * e il giro con run_sql sullo snapshot. Le tappe su
232
249
  * riquadri che la pagina non ha si scartano. */
233
250
  async function tourConInferenza(pagina, modo, ctx, dati, alPasso) {
234
251
  const p = await fileDati(`ai/commento-${pagina}.${LINGUA}.json`);
@@ -405,10 +422,13 @@ export function montaCommento({ pagina }) {
405
422
  // configurato) il tour si prepara qui, su qualunque vista; i tour gia'
406
423
  // pronti restano buoni e si usano prima, salvo Maiusc+Avvia.
407
424
  // Chi risponde: la modalita' scelta nel selettore (inferenza NOEVA sul
408
- // database o sullo snapshot, o la CLI sul Mac); senza nessuna, i
409
- // commenti gia' pronti. La prova (?commento=prova) passa dal server.
410
- const motore = PROVA ? ((await conServer()) ? "cli" : null) : await motoreScelto();
411
- if (motore?.startsWith("noeva")) {
425
+ // database o sullo snapshot); senza nessuna, i commenti gia' pronti.
426
+ // La prova (?commento=prova) si costruisce qui, senza AI.
427
+ const motore = PROVA ? "prova" : await motoreScelto();
428
+ if (motore === "prova") {
429
+ j = tourDiProva(ctx);
430
+ if (!j.tappe.length) throw new Error(t("il tour non ha tappe su riquadri della pagina"));
431
+ } else if (motore?.startsWith("noeva")) {
412
432
  const chiave = await chiaveServer(conLingua([pagina, st.modo, ctx]));
413
433
  j = forza ? null : await fileDati(`commento/tour-${pagina}-${st.modo}-${chiave}.json`);
414
434
  if (j) j._da_cache = true;
@@ -416,16 +436,11 @@ export function montaCommento({ pagina }) {
416
436
  if (gen !== st.gen) return;
417
437
  $("#cm-tappa").textContent = t("passaggio {n}", { n: p.turno }) + (p.query ? ` · ${t("{n} query", { n: p.query })}` : "");
418
438
  });
419
- } else if (motore === "cli") {
420
- const r = await fetch(rotta("ai/tour"), { method: "POST", headers: { "content-type": "application/json" },
421
- body: JSON.stringify({ pagina, modo: st.modo, contesto: ctx, forza, lingua: LINGUA, ...(PROVA ? { motore: "prova" } : {}) }) });
422
- j = await r.json();
423
- if (!r.ok || j.errore) throw new Error(j.errore || r.status);
424
439
  } else {
425
440
  // dietro il cancello: solo i commenti gia' pronti, con la chiave che calcola il server
426
441
  const chiave = await chiaveServer(conLingua([pagina, st.modo, ctx]));
427
442
  j = await fileDati(`commento/tour-${pagina}-${st.modo}-${chiave}.json`);
428
- if (!j) throw new Error(t("per questa vista non c'è un commento pronto: si prepara dal Mac, con questi filtri"));
443
+ if (!j) throw new Error(t("per questa vista non c'è un commento pronto e l'AI di questo progetto non è attiva"));
429
444
  j._da_cache = true;
430
445
  }
431
446
  if (gen !== st.gen) return;
@@ -31,16 +31,16 @@ export function inferenza() {
31
31
  }
32
32
 
33
33
  /* --- il motore: chi risponde e su quali dati ---------------------------------------
34
- * Tre modalita', offerte solo se ci sono i pezzi:
34
+ * L'AI passa solo dall'inferenza NOEVA (ai/converse del contenitore): nessun
35
+ * modulo chiama la CLI di Claude o un'API di modelli diretta, e `analytics ci`
36
+ * si ferma se un progetto lo fa. Due modalita', offerte solo se ci sono i pezzi:
35
37
  * noeva-vivo inferenza NOEVA, run_sql sul database vivo via serve-app.py (Mac)
36
38
  * noeva-snapshot inferenza NOEVA, run_sql sullo snapshot nel browser (Mac e sito)
37
- * cli la CLI di Claude con l'MCP del progetto, via serve-app.py (Mac)
38
39
  * La scelta si ricorda nel browser; senza scelta vale la prima disponibile in
39
40
  * quest'ordine. Senza nessuna, la pagina resta ai commenti gia' pronti. */
40
41
  export const MOTORI = {
41
42
  "noeva-vivo": "NOEVA · database",
42
43
  "noeva-snapshot": "NOEVA · snapshot",
43
- cli: "Claude CLI · database",
44
44
  };
45
45
  const CHIAVE_MOTORE = "noeva-analytics-motore";
46
46
 
@@ -57,8 +57,7 @@ let _motori = null;
57
57
  export function motori() {
58
58
  _motori ||= Promise.all([inferenza(), conServer()]).then(async ([ai, vivo]) => {
59
59
  const db = vivo ? await sqlVivo() : null;
60
- return Object.keys(MOTORI).filter((m) =>
61
- m === "noeva-vivo" ? ai && db : m === "noeva-snapshot" ? ai : vivo);
60
+ return Object.keys(MOTORI).filter((m) => (m === "noeva-vivo" ? ai && db : ai));
62
61
  });
63
62
  return _motori;
64
63
  }
@@ -5,13 +5,15 @@
5
5
  * montaPresentazione({ pagina: "venduto" });
6
6
  *
7
7
  * Un pulsante nella barra in alto apre una finestra: due partenze pronte (sintesi
8
- * per la direzione, analisi completa) o un prompt libero. Claude, dalla CLI sul
9
- * Mac (presentazione.py), scrive la scaletta: titoli, punti, note del relatore e
8
+ * per la direzione, analisi completa) o un prompt libero. L'inferenza NOEVA
9
+ * (ai/converse del contenitore, con il prompt di presentazione.py esportato da
10
+ * lib/snapshot/ai.py; mai la CLI di Claude) scrive la scaletta: titoli, punti, note del relatore e
10
11
  * quale riquadro mostrare in ogni slide. La scaletta si vede subito come slide
11
12
  * nella pagina (gli stessi KPI, grafici e tabelle dello schermo), si presenta a
12
13
  * tutto schermo con la regia di animazione.js, si corregge a mano o in chat ("accorcia la terza", "aggiungi una
13
- * slide sui mercati che perdono"): ogni richiesta torna a Claude con la scaletta
14
- * e la conversazione. Il .pptx lo costruisce questa pagina solo alla fine, con
14
+ * slide sui mercati che perdono"): ogni richiesta torna all'inferenza con la scaletta
15
+ * e la conversazione. Le scalette gia' pronte (dati/presentazioni/) si usano prima,
16
+ * anche senza AI. Il .pptx lo costruisce questa pagina solo alla fine, con
15
17
  * PptxGenJS, dalla scaletta sistemata: grafici (l'SVG della dashboard, reso in
16
18
  * immagine), tabelle (vere tabelle PowerPoint, modificabili) e KPI vengono dallo
17
19
  * schermo, con i filtri di quel momento.
@@ -22,6 +24,7 @@
22
24
  import { contesto, trovaRiquadro, pulisci, evidenzia, pulito } from "./commento.js";
23
25
  import { animaSlide } from "./animazione.js";
24
26
  import { conServer, chiaveServer, fileDati, conLingua, rotta } from "./servizio.js";
27
+ import { giro, nuovaSessione, motoreScelto } from "./inferenza.js";
25
28
  import { t, LINGUA, LOCALE } from "./lingua.js";
26
29
  import { PROGETTO } from "./progetto.js";
27
30
 
@@ -29,6 +32,68 @@ const PIATTO = (s) => String(s ?? "").replace(/\s+/g, " ").trim();
29
32
  const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
30
33
  const asset = (f) => new URL(f, import.meta.url).href;
31
34
 
35
+ /* --- l'AI: il prompt di presentazione.py e il giro con l'inferenza NOEVA ---------- */
36
+
37
+ // {sistema, sistema_modifica, schema, schema_modifica, partenze}, scritto da lib/snapshot/ai.py
38
+ const promptPpt = (pagina) => fileDati(`ai/presentazione-${pagina}.${LINGUA}.json`);
39
+ const datiDi = (motore) => (motore === "noeva-vivo" ? "vivo" : "snapshot");
40
+
41
+ /* I riquadri che la pagina non ha si tolgono: la slide mostrerebbe un vuoto
42
+ * (come _ripulisci di presentazione.py). */
43
+ function ripulisci(ppt, ctx) {
44
+ const validi = new Set((ctx.riquadri || []).map((r) => r.id));
45
+ const slide = (ppt?.slide || []).map((sl) => {
46
+ const x = { ...sl, riquadri: (sl.riquadri || []).filter((r) => validi.has(r)) };
47
+ if ((x.tipo === "numeri" || x.tipo === "riquadro") && !x.riquadri.length) x.tipo = "punti";
48
+ if (x.tipo === "riquadro") x.riquadri = x.riquadri.slice(0, 1);
49
+ return x;
50
+ });
51
+ if (!slide.length) throw new Error(t("la presentazione non ha slide"));
52
+ return { ...ppt, slide };
53
+ }
54
+
55
+ const CONTESTO = (ctx) => `Contesto della pagina, come la vede ora il cliente (JSON):\n\n${JSON.stringify(ctx, null, 1)}`;
56
+
57
+ async function scalettaConInferenza({ pagina, partenza, prompt, ctx, motore, alPasso }) {
58
+ const p = await promptPpt(pagina);
59
+ if (!p?.sistema || !p.schema) throw new Error(t("il prompt della presentazione di questa pagina non è pronto"));
60
+ const preset = (p.partenze || []).find((x) => x.id === partenza);
61
+ const richiestaPreset = partenza === "libera"
62
+ ? `Presentazione su richiesta, 4-12 slide oltre alla copertina. Ecco cosa chiede chi la presenta:\n\n<richiesta>\n${prompt}\n</richiesta>`
63
+ : preset?.richiesta;
64
+ if (!richiestaPreset) throw new Error(t("partenza sconosciuta: {p}", { p: partenza }));
65
+ const g = await giro({
66
+ funzione: "presentazione", pagina, sistema: p.sistema, sessione: nuovaSessione("presentazione", pagina), dati: datiDi(motore),
67
+ richiesta: `Pagina: ${pagina}. ${richiestaPreset}\n\n${CONTESTO(ctx)}`,
68
+ consegna: { nome: "consegna_scaletta", descrizione: "Consegna la scaletta finita: il titolo, il sottotitolo e le slide, nell'ordine.", schema: p.schema },
69
+ maxQuery: partenza === "analisi" ? 6 : 4, alPasso,
70
+ });
71
+ return { ...ripulisci(g.risultato, ctx), _partenza: partenza, _lingua: LINGUA,
72
+ _motore: { tipo: "noeva", dati: g.dati, modello: g.modello, turni: g.turni, query: g.query, token: g.token } };
73
+ }
74
+
75
+ /* La scaletta sistemata secondo la richiesta, con la conversazione finora (come
76
+ * modifica() di presentazione.py: la copertina e' la slide 1). */
77
+ async function modificaConInferenza({ pagina, scaletta, richiesta, storia, ctx, motore }) {
78
+ const p = await promptPpt(pagina);
79
+ if (!p?.sistema_modifica || !p.schema_modifica) throw new Error(t("il prompt della presentazione di questa pagina non è pronto"));
80
+ const attuale = Object.fromEntries(Object.entries(scaletta).filter(([k]) => !k.startsWith("_")));
81
+ attuale.slide = (attuale.slide || []).map((sl, i) => ({ numero: i + 2, ...sl }));
82
+ const conversazione = (storia || []).slice(-12)
83
+ .map((m) => `- ${m.ruolo === "utente" ? "Chi presenta" : "Tu"}: ${String(m.testo || "").slice(0, 600)}`).join("\n") || "(nessuna)";
84
+ const g = await giro({
85
+ funzione: "presentazione", pagina, sistema: p.sistema_modifica, sessione: nuovaSessione("presentazione", pagina), dati: datiDi(motore),
86
+ richiesta: `Pagina: ${pagina}.\n\nScaletta attuale (JSON):\n\n${JSON.stringify(attuale, null, 1)}\n\n`
87
+ + `Conversazione finora:\n${conversazione}\n\nNuova richiesta di chi presenta:\n<richiesta>\n${richiesta}\n</richiesta>\n\n${CONTESTO(ctx)}`,
88
+ consegna: { nome: "consegna_scaletta", descrizione: "Consegna la scaletta completa aggiornata e la risposta per chi presenta.", schema: p.schema_modifica },
89
+ maxQuery: 3,
90
+ });
91
+ const nuova = ripulisci(g.risultato, ctx);
92
+ for (const k of ["_partenza", "_file", "_lingua"]) if (k in scaletta) nuova[k] = scaletta[k];
93
+ nuova._motore = { tipo: "noeva", dati: g.dati, modello: g.modello, turni: g.turni, query: g.query, token: g.token };
94
+ return nuova;
95
+ }
96
+
32
97
  /* --- PptxGenJS si carica solo quando serve: 470 kB ------------------------------- */
33
98
  let caricamento = null;
34
99
  function pptxgen() {
@@ -462,7 +527,7 @@ function montaAnteprima({ pagina, titoloPagina }) {
462
527
  <button type="button" id="an-av" aria-label="${esc(t("Slide successiva"))}">▶</button></div></div>
463
528
  </div>
464
529
  <aside class="an-lato" id="an-lato" aria-label="${esc(t("Sistema la presentazione in chat"))}">
465
- <h3>${t("Sistema con Claude")}</h3>
530
+ <h3>${t("Sistema con l'AI")}</h3>
466
531
  <div class="an-msgs" id="an-msgs" aria-live="polite"></div>
467
532
  <div class="an-stato" id="an-stato" role="status"></div>
468
533
  <form id="an-form"><textarea id="an-testo" aria-label="${esc(t("Cosa cambiare"))}" placeholder="${esc(t("Es.: accorcia la slide 3, aggiungi una slide sui mercati che perdono di più, togli i Service Items"))}"></textarea>
@@ -557,7 +622,7 @@ function montaAnteprima({ pagina, titoloPagina }) {
557
622
  });
558
623
  addEventListener("resize", () => { if (!an.hidden) scala(); });
559
624
 
560
- /* --- la chat con la CLI --- */
625
+ /* --- la chat con l'inferenza NOEVA --- */
561
626
  $("#an-form").onsubmit = async (e) => {
562
627
  e.preventDefault();
563
628
  const richiesta = $("#an-testo").value.trim();
@@ -568,10 +633,10 @@ function montaAnteprima({ pagina, titoloPagina }) {
568
633
  const t0 = performance.now();
569
634
  const orologio = setInterval(() => (attesa.textContent = `${t("Sto sistemando la scaletta…")} ${Math.round((performance.now() - t0) / 1000)} s`), 1000);
570
635
  try {
571
- const r = await fetch(rotta("ai/presentazione/modifica"), { method: "POST", headers: { "content-type": "application/json" },
572
- body: JSON.stringify({ pagina, scaletta: st.sc, richiesta, storia: st.storia, contesto: st.ctx, lingua: LINGUA }) });
573
- const j = await r.json();
574
- if (!r.ok || j.errore) throw new Error(j.errore || r.status);
636
+ if (richiesta.length > 2000) throw new Error(t("la richiesta è troppo lunga: massimo 2000 caratteri"));
637
+ const motore = await motoreScelto();
638
+ if (!motore) throw new Error(t("l'AI di questo progetto non è configurata"));
639
+ const j = await modificaConInferenza({ pagina, scaletta: st.sc, richiesta, storia: st.storia, ctx: st.ctx, motore });
575
640
  const risposta = j.risposta || t("Fatto.");
576
641
  delete j.risposta;
577
642
  st.sc = j; st.storia.push({ ruolo: "utente", testo: richiesta }, { ruolo: "claude", testo: risposta });
@@ -601,13 +666,15 @@ function montaAnteprima({ pagina, titoloPagina }) {
601
666
  finally { b.disabled = false; }
602
667
  };
603
668
 
604
- function apri(scaletta, ctx, daDove, vivo = true) {
669
+ // ai: l'inferenza NOEVA c'e' (la chat per sistemarla); vivo: serve-app.py sul
670
+ // Mac, che salva la scaletta sistemata al posto di quella gia' pronta
671
+ function apri(scaletta, ctx, daDove, { ai = false, vivo = false } = {}) {
605
672
  st.sc = scaletta; st.ctx = ctx; st.i = -1; st.storia = []; st.daDove = daDove; st.vivo = vivo;
606
673
  $("#an-msgs").innerHTML = ""; stato("");
607
- $("#an-form").hidden = !vivo;
608
- messaggio(vivo
674
+ $("#an-form").hidden = !ai;
675
+ messaggio(ai
609
676
  ? t("La presentazione è pronta: {n} slide. Sfogliala, correggi a mano quello che vuoi o dimmi cosa cambiare. Il file si genera alla fine.", { n: scaletta.slide.length + 1 })
610
- : t("La presentazione è pronta: {n} slide. Qui la sfogli, la presenti e generi il PowerPoint; la chat per sistemarla c'è nella versione sul Mac.", { n: scaletta.slide.length + 1 }), "claude");
677
+ : t("La presentazione è pronta: {n} slide. Qui la sfogli, la presenti e generi il PowerPoint; la chat per sistemarla c'è dove l'AI del progetto è attiva.", { n: scaletta.slide.length + 1 }), "claude");
611
678
  an.hidden = false; document.body.style.overflow = "hidden";
612
679
  disegna(); $("#an-av").focus();
613
680
  }
@@ -686,7 +753,7 @@ export function montaPresentazione({ pagina }) {
686
753
  document.body.append(d);
687
754
  const $ = (s) => d.querySelector(s);
688
755
 
689
- // le partenze del server (presentazione.py) arrivano nella lingua della pagina
756
+ // le partenze di presentazione.py arrivano col prompt, nella lingua della pagina
690
757
  const scelte = [
691
758
  { id: "direzione", nome: t("Sintesi per la direzione"), descrizione: "6-7 slide" },
692
759
  { id: "analisi", nome: t("Analisi completa della pagina"), descrizione: "10-14 slide" },
@@ -702,7 +769,7 @@ export function montaPresentazione({ pagina }) {
702
769
  }));
703
770
  };
704
771
  disegnaScelte(scelte);
705
- fetch(rotta(`ai/presentazione?lingua=${LINGUA}`)).then((r) => r.json()).then((j) => Array.isArray(j) && disegnaScelte(j)).catch(() => {});
772
+ promptPpt(pagina).then((p) => Array.isArray(p?.partenze) && p.partenze.length && disegnaScelte(p.partenze)).catch(() => {});
706
773
  const partenza = () => d.querySelector('input[name="partenza"]:checked')?.value;
707
774
 
708
775
  const stato = (t, errore = false) => { $("#ppt-stato").textContent = t; $("#ppt-stato").classList.toggle("errore", errore); };
@@ -718,24 +785,21 @@ export function montaPresentazione({ pagina }) {
718
785
  const p = partenza(), prompt = $("#ppt-prompt").value.trim();
719
786
  if (p === "libera" && prompt.length < 10) { stato(t("Scrivi cosa deve raccontare la presentazione."), true); $("#ppt-prompt").focus(); return; }
720
787
  inCorso = true; $("#ppt-genera").disabled = true;
721
- stato(t("Claude sta scrivendo la scaletta: di solito uno o due minuti, tre per l'analisi completa."));
722
788
  try {
723
789
  const ctx = contesto();
724
- const vivo = await conServer();
725
- let j;
726
- if (vivo) {
727
- const r = await fetch(rotta("ai/presentazione"), { method: "POST", headers: { "content-type": "application/json" },
728
- body: JSON.stringify({ pagina, partenza: p, prompt, contesto: ctx, forza: e.shiftKey, lingua: LINGUA }) });
729
- j = await r.json();
730
- if (!r.ok || j.errore) throw new Error(j.errore || r.status);
731
- } else {
732
- // dietro il cancello: solo le scalette gia' pronte, con la chiave che calcola il server
733
- const chiave = await chiaveServer(conLingua(["presentazione", pagina, p, p === "libera" ? prompt : "", ctx]));
734
- j = await fileDati(`presentazioni/ppt-${pagina}-${p}-${chiave}.json`);
735
- if (!j) throw new Error(t("per questa vista non c'è una scaletta pronta: si prepara dal Mac, con questi filtri"));
736
- }
790
+ const [motore, vivo] = await Promise.all([motoreScelto(), conServer()]);
791
+ // prima la scaletta gia' pronta di questa vista (salvo Maiusc), poi l'inferenza NOEVA
792
+ const chiave = await chiaveServer(conLingua(["presentazione", pagina, p, p === "libera" ? prompt : "", ctx]));
793
+ const file = `ppt-${pagina}-${p}-${chiave}.json`;
794
+ let j = e.shiftKey && motore ? null : await fileDati(`presentazioni/${file}`);
795
+ if (j) j = { ...j, _file: file, _da_cache: true };
796
+ else if (motore) {
797
+ stato(t("Sto scrivendo la scaletta: di solito uno o due minuti, tre per l'analisi completa."));
798
+ j = await scalettaConInferenza({ pagina, partenza: p, prompt, ctx, motore,
799
+ alPasso: (x) => stato(t("Sto scrivendo la scaletta…") + ` ${t("passaggio {n}", { n: x.turno })}` + (x.query ? ` · ${t("{n} query", { n: x.query })}` : "")) });
800
+ } else throw new Error(t("per questa vista non c'è una scaletta pronta e l'AI di questo progetto non è attiva"));
737
801
  stato(""); d.close();
738
- anteprima.apri(j, ctx, btn, vivo);
802
+ anteprima.apri(j, ctx, btn, { ai: !!motore, vivo });
739
803
  } catch (err) {
740
804
  stato(t("La presentazione non è disponibile: {errore}", { errore: err.message || err }), true);
741
805
  } finally { inCorso = false; $("#ppt-genera").disabled = false; }
@@ -43,6 +43,8 @@ export const EVENTO = "noeva-vista";
43
43
  /* --- la vista come dato ------------------------------------------------------------ */
44
44
 
45
45
  const pulito = (s) => String(s ?? "").replace(/[▸▾✕]/g, "").replace(/\s+/g, " ").trim();
46
+ // Il titolo della barra: in alcuni progetti l'h1 comincia con un separatore («· Vendite e CRM»)
47
+ const titoloDi = (s) => pulito(s).replace(/^[·•|–—-]\s*/, "");
46
48
 
47
49
  /* I filtri dalla query dell'URL, nell'ordine dei dichiarati (poi gli altri, se
48
50
  * non ce ne sono di dichiarati). `dichiarati`: { chiave: "Nome" } oppure
@@ -192,7 +194,7 @@ export function montaVista({ dashboard, meta = {}, filtri = null, provenienza =
192
194
  function aggiorna() {
193
195
  attuale = componiVista({
194
196
  progetto: { id: meta.progetto || PROGETTO.id, nome: PROGETTO.nome },
195
- dashboard: { id: dashboard, titolo: titolo || pulito(document.querySelector(".topbar h1")?.textContent) || pulito(document.title) },
197
+ dashboard: { id: dashboard, titolo: titolo || titoloDi(document.querySelector(".topbar h1")?.textContent) || titoloDi(document.title) },
196
198
  lingua: LINGUA,
197
199
  url: location.href,
198
200
  filtri: filtriDaUrl(location.search, filtri),