@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 +63 -0
- package/contenuto/assets/chat.js +22 -66
- package/contenuto/assets/commento.js +31 -16
- package/contenuto/assets/inferenza.js +4 -5
- package/contenuto/assets/presentazione.js +95 -31
- package/contenuto/assets/vista.js +3 -1
- package/contenuto/lib/ai/inferenza.py +102 -0
- package/contenuto/lib/mcp-locale/costruisci.py +4 -0
- package/contenuto/lib/mcp-locale/server/esecuzione.py +405 -0
- package/contenuto/lib/mcp-locale/server/noeva_mcp.py +81 -10
- package/contenuto/lib/modello/istruzioni.json +5 -2
- package/contenuto/lib/modello/istruzioni.py +6 -1
- package/contenuto/lib/modello/pubblica.py +15 -5
- package/contenuto/lib/modello/scheda.py +11 -0
- package/contenuto/lib/snapshot/ai.py +46 -1
- package/contenuto/lib/snapshot/worker/installa.ps1 +9 -4
- package/contenuto/lib/snapshot/worker/ultimi-giri.ps1 +202 -0
- package/core.js +43 -0
- package/package.json +1 -1
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
|
package/contenuto/assets/chat.js
CHANGED
|
@@ -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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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
|
|
131
|
-
// dove il contenitore lo offre)
|
|
132
|
-
//
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
409
|
-
//
|
|
410
|
-
const motore = PROVA ?
|
|
411
|
-
if (motore
|
|
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
|
|
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
|
-
*
|
|
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.
|
|
9
|
-
*
|
|
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
|
|
14
|
-
* e la conversazione.
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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
|
|
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
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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 = !
|
|
608
|
-
messaggio(
|
|
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'è
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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 ||
|
|
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),
|