@noeva/analytics-core 0.4.1 → 0.5.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/CHANGELOG.md +40 -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/modello/pubblica.py +15 -5
- 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,46 @@
|
|
|
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.0 — 12 novembre 2026
|
|
7
|
+
|
|
8
|
+
- **L'AI passa solo da NOEVA.** I moduli di pagina non offrono piu' la CLI di Claude:
|
|
9
|
+
`inferenza.js` ha solo NOEVA · database e NOEVA · snapshot, `chat.js` risponde solo
|
|
10
|
+
dall'inferenza NOEVA (o NOEVA Agents), `commento.js` fa il tour solo con l'inferenza
|
|
11
|
+
(il tour di prova, `?commento=prova`, si costruisce nella pagina, senza AI).
|
|
12
|
+
- **La presentazione con l'inferenza NOEVA**: scaletta e modifiche in chat passano da
|
|
13
|
+
`ai/converse` come tour e chat, anche sul sito pubblicato; le partenze arrivano col
|
|
14
|
+
prompt. `lib/snapshot/ai.py` scrive anche
|
|
15
|
+
`apps/dati/ai/presentazione-<pagina>.<lingua>.json` da `presentazione.py`
|
|
16
|
+
(`istruzioni`, `LINGUE`, `SCHEMA`, `SCHEMA_MODIFICA`, `PRESET`, `MODIFICA`). Le
|
|
17
|
+
scalette gia' pronte restano buone e si usano prima.
|
|
18
|
+
- `analytics ci` si ferma se un file del progetto lancia la CLI di Claude, chiama
|
|
19
|
+
un'API di modelli diretta (Anthropic, OpenAI, Bedrock, Gemini) o se `serve-app.py`
|
|
20
|
+
serve `/ai/chat`, `/ai/chat/storia`, `/ai/tour`, `/ai/presentazione/modifica`. Non
|
|
21
|
+
c'e' eccezione da dichiarare.
|
|
22
|
+
- Prima di aggiornare, nel progetto: `chat.py`, `commento.py` e `presentazione.py`
|
|
23
|
+
tengono solo prompt, regole e schema (niente `subprocess`, niente `claude`);
|
|
24
|
+
`serve-app.py` toglie le rotte della CLI; si rilancia `ai.py` per i prompt della
|
|
25
|
+
presentazione. Le frasi nuove da tradurre in `en.js`: «Tappa di prova, senza AI.»,
|
|
26
|
+
«Tour di prova», «Sistema con l'AI», «Sto scrivendo la scaletta…», «Sto scrivendo la
|
|
27
|
+
scaletta: di solito uno o due minuti, tre per l'analisi completa.», «la presentazione
|
|
28
|
+
non ha slide», «il prompt della presentazione di questa pagina non è pronto»,
|
|
29
|
+
«partenza sconosciuta: {p}», «la richiesta è troppo lunga: massimo 2000 caratteri»,
|
|
30
|
+
«l'AI di questo progetto non è configurata», «per questa vista non c'è una scaletta
|
|
31
|
+
pronta e l'AI di questo progetto non è attiva», «per questa vista non c'è un commento
|
|
32
|
+
pronto e l'AI di questo progetto non è attiva», «La presentazione è pronta: {n} slide.
|
|
33
|
+
Qui la sfogli, la presenti e generi il PowerPoint; la chat per sistemarla c'è dove
|
|
34
|
+
l'AI del progetto è attiva.».
|
|
35
|
+
- Tolto `lib/snapshot/misura-memoria.py` (lanciava la CLI).
|
|
36
|
+
- `lib/ai/inferenza.py` (in `contenuto/lib/ai/`): l'inferenza NOEVA da Python per gli
|
|
37
|
+
strumenti del progetto sul Mac che non sono pagine (es. la traduzione della knowledge
|
|
38
|
+
base). `converse(radice, sistema, testo, funzione=...)`; la chiave
|
|
39
|
+
`NOEVA_INFERENZA_CHIAVE` sta nell'ambiente o in `.wren/.env`, il workspace in
|
|
40
|
+
`analytics.json`, il modello in `NOEVA_INFERENZA_MODELLO` (predefinito Haiku 5.5).
|
|
41
|
+
- Anche, da lavori di altre sessioni dopo la 0.4.1: `vista.js` scrive il titolo della
|
|
42
|
+
barra senza il separatore iniziale («· Vendite e CRM» → «Vendite e CRM»); il worker
|
|
43
|
+
degli snapshot ha `ultimi-giri.ps1` (gli ultimi giri e il loro esito sulla macchina del
|
|
44
|
+
cliente) e `installa.ps1` chiede la password Windows in console.
|
|
45
|
+
|
|
6
46
|
## 0.4.1 — 10 novembre 2026
|
|
7
47
|
|
|
8
48
|
- `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),
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""L'inferenza NOEVA da Python, per gli strumenti del progetto che girano sul Mac.
|
|
2
|
+
|
|
3
|
+
Le pagine hanno il loro giro (lib/pagina/inferenza.js, attraverso ai/converse del
|
|
4
|
+
contenitore). Qui c'e' lo stesso servizio per chi non e' una pagina: per esempio
|
|
5
|
+
la traduzione della knowledge base in inglese durante l'export. E' l'unica strada
|
|
6
|
+
di un modello da Python: mai la CLI di Claude ne' un'API di modelli diretta
|
|
7
|
+
(`analytics ci` si ferma se un file del progetto ne usa una).
|
|
8
|
+
|
|
9
|
+
testo = converse(radice, sistema, testo_utente, funzione="traduzione")
|
|
10
|
+
|
|
11
|
+
La chiave e' quella dell'utente tecnico del workspace del progetto, come nel
|
|
12
|
+
secret NOEVA_INFERENZA del contenitore: NOEVA_INFERENZA_CHIAVE nell'ambiente o in
|
|
13
|
+
<radice>/.wren/.env (mai in git, mai stampata). Il workspace e' quello di
|
|
14
|
+
analytics.json; il modello NOEVA_INFERENZA_MODELLO (predefinito Haiku 5.5),
|
|
15
|
+
l'API NOEVA_API (predefinita https://api.noeva.ai).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.parse
|
|
24
|
+
import urllib.request
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
MODELLO_PREDEFINITO = "eu.anthropic.claude-haiku-5-5"
|
|
28
|
+
API_PREDEFINITA = "https://api.noeva.ai"
|
|
29
|
+
TENTATIVI = 3
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ErroreInferenza(RuntimeError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _dal_file_env(radice: Path, nome: str) -> str:
|
|
37
|
+
f = radice / ".wren" / ".env"
|
|
38
|
+
if not f.exists():
|
|
39
|
+
return ""
|
|
40
|
+
for riga in f.read_text(encoding="utf-8").splitlines():
|
|
41
|
+
if riga.strip().startswith(f"{nome}="):
|
|
42
|
+
return riga.split("=", 1)[1].strip().strip('"').strip("'")
|
|
43
|
+
return ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _valore(radice: Path, nome: str, predefinito: str = "") -> str:
|
|
47
|
+
return os.environ.get(nome) or _dal_file_env(radice, nome) or predefinito
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def configurazione(radice: Path) -> dict:
|
|
51
|
+
"""Chiave, workspace, modello e API, o ErroreInferenza se manca qualcosa."""
|
|
52
|
+
radice = Path(radice)
|
|
53
|
+
chiave = _valore(radice, "NOEVA_INFERENZA_CHIAVE")
|
|
54
|
+
if not chiave.startswith("noeva_sk_"):
|
|
55
|
+
raise ErroreInferenza("manca NOEVA_INFERENZA_CHIAVE (la chiave noeva_sk_ dell'utente tecnico del workspace) "
|
|
56
|
+
"nell'ambiente o in .wren/.env")
|
|
57
|
+
try:
|
|
58
|
+
workspace = json.loads((radice / "analytics.json").read_text(encoding="utf-8")).get("workspace") or ""
|
|
59
|
+
except (OSError, ValueError):
|
|
60
|
+
workspace = ""
|
|
61
|
+
workspace = _valore(radice, "NOEVA_INFERENZA_WORKSPACE", workspace)
|
|
62
|
+
if not workspace:
|
|
63
|
+
raise ErroreInferenza("analytics.json senza workspace: l'inferenza NOEVA non sa per chi lavora")
|
|
64
|
+
return {"chiave": chiave, "workspace": workspace,
|
|
65
|
+
"modello": _valore(radice, "NOEVA_INFERENZA_MODELLO", MODELLO_PREDEFINITO),
|
|
66
|
+
"api": _valore(radice, "NOEVA_API", API_PREDEFINITA).rstrip("/")}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def converse(radice: Path, sistema: str, testo: str, *, funzione: str, max_token: int = 8000,
|
|
70
|
+
tempo_max: int = 600) -> str:
|
|
71
|
+
"""Una domanda e la sua risposta in testo, dall'inferenza NOEVA."""
|
|
72
|
+
c = configurazione(radice)
|
|
73
|
+
corpo = {
|
|
74
|
+
"system": [{"text": sistema}],
|
|
75
|
+
"messages": [{"role": "user", "content": [{"text": testo}]}],
|
|
76
|
+
"inferenceConfig": {"maxTokens": max_token},
|
|
77
|
+
"noevaTrace": {"name": f"Analytics: {funzione}", "tags": ["noeva-analytics", funzione],
|
|
78
|
+
"metadata": {"prodotto": "analytics", "funzione": funzione}},
|
|
79
|
+
}
|
|
80
|
+
richiesta = urllib.request.Request(
|
|
81
|
+
f"{c['api']}/api/inference/model/{urllib.parse.quote(c['modello'], safe='')}/converse",
|
|
82
|
+
data=json.dumps(corpo).encode("utf-8"), method="POST",
|
|
83
|
+
headers={"Authorization": f"Bearer {c['chiave']}", "x-noeva-workspace": c["workspace"],
|
|
84
|
+
"Content-Type": "application/json"})
|
|
85
|
+
for tentativo in range(1, TENTATIVI + 1):
|
|
86
|
+
try:
|
|
87
|
+
with urllib.request.urlopen(richiesta, timeout=tempo_max) as r:
|
|
88
|
+
esito = json.loads(r.read().decode("utf-8"))
|
|
89
|
+
break
|
|
90
|
+
except urllib.error.HTTPError as e:
|
|
91
|
+
# l'orchestratore a volte risponde 500 senza motivo: si riprova; 4xx sono risposte vere
|
|
92
|
+
if e.code < 500 or tentativo == TENTATIVI:
|
|
93
|
+
raise ErroreInferenza(f"inferenza NOEVA {e.code}: {e.read().decode(errors='replace')[:300]}")
|
|
94
|
+
except urllib.error.URLError as e:
|
|
95
|
+
if tentativo == TENTATIVI:
|
|
96
|
+
raise ErroreInferenza(f"inferenza NOEVA non raggiungibile: {e.reason}")
|
|
97
|
+
time.sleep(1.5 * tentativo)
|
|
98
|
+
blocchi = ((esito.get("output") or {}).get("message") or {}).get("content") or []
|
|
99
|
+
risposta = "".join(b.get("text", "") for b in blocchi if isinstance(b, dict)).strip()
|
|
100
|
+
if not risposta:
|
|
101
|
+
raise ErroreInferenza("risposta vuota dall'inferenza NOEVA")
|
|
102
|
+
return risposta
|
|
@@ -80,6 +80,20 @@ def estrai_da_head(radice: pathlib.Path, dest: pathlib.Path) -> None:
|
|
|
80
80
|
tar.extractall(dest, filter="data")
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
def costruisci(radice: pathlib.Path, tmp: pathlib.Path) -> tuple[pathlib.Path, dict]:
|
|
84
|
+
"""Il modello consegnato dal commit di `radice`, compilato, in `tmp`/modello.
|
|
85
|
+
|
|
86
|
+
E' il modello pubblicato: lo usa main() per caricarlo e il backend del gateway
|
|
87
|
+
(lib/gateway/backend/modello.py) per metterlo nell'immagine, cosi' le due strade
|
|
88
|
+
non possono divergere. Restituisce la cartella e i conteggi delle trasformazioni."""
|
|
89
|
+
sorgente, dest = tmp / "sorgente", tmp / "modello"
|
|
90
|
+
estrai_da_head(radice, sorgente)
|
|
91
|
+
# Le trasformazioni di consegna.yml anche loro dal commit, non dal disco.
|
|
92
|
+
conta = modello_di_consegna(sorgente, dest, leggi_consegna(sorgente))
|
|
93
|
+
compila(dest)
|
|
94
|
+
return dest, conta
|
|
95
|
+
|
|
96
|
+
|
|
83
97
|
def file_della_versione(dest: pathlib.Path) -> list[pathlib.Path]:
|
|
84
98
|
return sorted(p for p in dest.rglob("*") if p.is_file() and not p.name.startswith("."))
|
|
85
99
|
|
|
@@ -99,11 +113,7 @@ def main(argv: list[str]) -> int:
|
|
|
99
113
|
versione = versione_di(radice)
|
|
100
114
|
|
|
101
115
|
with tempfile.TemporaryDirectory() as tmp:
|
|
102
|
-
|
|
103
|
-
estrai_da_head(radice, sorgente)
|
|
104
|
-
# Le trasformazioni di consegna.yml anche loro dal commit, non dal disco.
|
|
105
|
-
conta = modello_di_consegna(sorgente, dest, leggi_consegna(sorgente))
|
|
106
|
-
compila(dest)
|
|
116
|
+
dest, conta = costruisci(radice, pathlib.Path(tmp))
|
|
107
117
|
file = file_della_versione(dest)
|
|
108
118
|
byte = sum(p.stat().st_size for p in file)
|
|
109
119
|
print(f"▸ {progetto}: modello {versione}, {numero_modelli(dest)} modelli, {len(file)} file, "
|
|
@@ -9,6 +9,12 @@ dalle stesse funzioni di commento.py, cosi' le regole restano in un posto solo.
|
|
|
9
9
|
|
|
10
10
|
apps/dati/ai/commento-<pagina>.<lingua>.json {sistema, modi, schema, generato}
|
|
11
11
|
apps/dati/ai/chat-<pagina>.<lingua>.json {sistema, testi, generato} se c'e' chat.py
|
|
12
|
+
apps/dati/ai/presentazione-<pagina>.<lingua>.json
|
|
13
|
+
{sistema, sistema_modifica, schema, schema_modifica, partenze, generato} se c'e' presentazione.py
|
|
14
|
+
|
|
15
|
+
Questi file sono l'unica strada dell'AI delle pagine: chat.py, commento.py e
|
|
16
|
+
presentazione.py del progetto tengono solo prompt e regole, mai una chiamata a un
|
|
17
|
+
modello (la CLI di Claude non si usa; `analytics ci` lo controlla).
|
|
12
18
|
|
|
13
19
|
Le pagine sono le cartelle di apps/ con un index.html, piu' l'indice; le lingue
|
|
14
20
|
quelle di commento.LINGUE (apps/en/ non e' una pagina: e' la gemella inglese, che
|
|
@@ -73,6 +79,35 @@ def carica_chat(progetto: pathlib.Path):
|
|
|
73
79
|
return modulo
|
|
74
80
|
|
|
75
81
|
|
|
82
|
+
def carica_presentazione(progetto: pathlib.Path):
|
|
83
|
+
"""presentazione.py del progetto, o None: il PowerPoint e' facoltativo."""
|
|
84
|
+
if not (progetto / "presentazione.py").exists():
|
|
85
|
+
return None
|
|
86
|
+
spec = importlib.util.spec_from_file_location(f"presentazione_{progetto.name}", progetto / "presentazione.py")
|
|
87
|
+
modulo = importlib.util.module_from_spec(spec)
|
|
88
|
+
spec.loader.exec_module(modulo)
|
|
89
|
+
for nome in ("istruzioni", "LINGUE", "SCHEMA", "SCHEMA_MODIFICA", "PRESET", "MODIFICA"):
|
|
90
|
+
if not hasattr(modulo, nome):
|
|
91
|
+
raise SystemExit(f"{progetto.name}/presentazione.py non ha {nome}: non e' allineato allo standard")
|
|
92
|
+
return modulo
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def prompt_presentazione(ppt, c, pagina: str, lingua: str) -> dict:
|
|
96
|
+
"""Il prompt delle slide come lo costruiva presentazione.py sul Mac: per scriverle
|
|
97
|
+
(istruzioni) e per sistemarle in chat (istruzioni piu' MODIFICA), con la knowledge base."""
|
|
98
|
+
kb = "\n\n# Knowledge base del progetto\n\n" + c.conoscenza(pagina)
|
|
99
|
+
risposta = ppt.LINGUE[lingua].get("risposta", "nella lingua della pagina")
|
|
100
|
+
return {
|
|
101
|
+
"sistema": ppt.istruzioni(lingua) + kb,
|
|
102
|
+
"sistema_modifica": ppt.istruzioni(lingua) + "\n\n" + ppt.MODIFICA.replace("{RISPOSTA}", risposta) + kb,
|
|
103
|
+
"schema": ppt.SCHEMA,
|
|
104
|
+
"schema_modifica": ppt.SCHEMA_MODIFICA,
|
|
105
|
+
"partenze": [{"id": k, "nome": v["nome"].get(lingua) or v["nome"]["it"],
|
|
106
|
+
"descrizione": v["descrizione"].get(lingua) or v["descrizione"]["it"],
|
|
107
|
+
"richiesta": v["richiesta"]} for k, v in ppt.PRESET.items()],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
76
111
|
def pagine(progetto: pathlib.Path) -> list[str]:
|
|
77
112
|
apps = progetto / "apps"
|
|
78
113
|
trovate = sorted(p.name for p in apps.iterdir()
|
|
@@ -110,9 +145,19 @@ def main(argv: list[str]) -> int:
|
|
|
110
145
|
"generato": adesso},
|
|
111
146
|
ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
|
112
147
|
n_chat += 1
|
|
148
|
+
ppt = carica_presentazione(progetto)
|
|
149
|
+
n_ppt = 0
|
|
150
|
+
if ppt:
|
|
151
|
+
for pagina in pagine(progetto):
|
|
152
|
+
for lingua in ppt.LINGUE:
|
|
153
|
+
(uscita / f"presentazione-{pagina}.{lingua}.json").write_text(json.dumps(
|
|
154
|
+
{**prompt_presentazione(ppt, c, pagina, lingua), "generato": adesso},
|
|
155
|
+
ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
|
156
|
+
n_ppt += 1
|
|
113
157
|
if not scritti and not n_chat:
|
|
114
158
|
raise SystemExit(f"{progetto.name}: ne' commento guidato ne' chat, nessun prompt da scrivere")
|
|
115
|
-
print(f" apps/dati/ai: {scritti} prompt del commento, {n_chat} della chat
|
|
159
|
+
print(f" apps/dati/ai: {scritti} prompt del commento, {n_chat} della chat, {n_ppt} della presentazione"
|
|
160
|
+
f" ({len(c.LINGUE)} lingue)")
|
|
116
161
|
return 0
|
|
117
162
|
|
|
118
163
|
|
|
@@ -310,8 +310,13 @@ if ($SoloControllo) {
|
|
|
310
310
|
} else {
|
|
311
311
|
Write-Host " Ogni $minuti minuti lancia 'ascolta --una-volta' come $env:USERNAME, anche senza sessione aperta:"
|
|
312
312
|
Write-Host ' per questo Windows chiede la password di questo utente (quella di Windows, non del database).'
|
|
313
|
-
|
|
314
|
-
|
|
313
|
+
# In console, non con Get-Credential: in desktop remoto la finestrella finisce dietro e lo
|
|
314
|
+
# script sembra fermo (12/11/2026, sulla VM di un cliente).
|
|
315
|
+
$utenteWin = "$env:USERDOMAIN\$env:USERNAME"
|
|
316
|
+
$pwWin = ChiediSegreto "Password Windows di $utenteWin (non si vede mentre scrivi, poi Invio)"
|
|
317
|
+
if (-not $pwWin) { Ferma 'attivita''' 'nessuna password Windows: senza, l''attivita'' non gira a sessione chiusa' }
|
|
318
|
+
$credenziali = [pscustomobject]@{ UserName = $utenteWin }
|
|
319
|
+
Write-Host ' Registro l''attivita'' con Windows...'
|
|
315
320
|
$log = Join-Path $destinazione 'lavoro\ascolta.log'
|
|
316
321
|
$azione = New-ScheduledTaskAction -Execute 'cmd.exe' -WorkingDirectory $destinazione `
|
|
317
322
|
-Argument "/c `"`"$exe`" ascolta --config `"$(Join-Path $destinazione 'worker.json')`" --una-volta >> `"$log`" 2>&1`""
|
|
@@ -320,13 +325,13 @@ if ($SoloControllo) {
|
|
|
320
325
|
-StartWhenAvailable -DontStopIfGoingOnBatteries -AllowStartIfOnBatteries
|
|
321
326
|
try {
|
|
322
327
|
Register-ScheduledTask -TaskName $attivita -Action $azione -Trigger $quando -Settings $regole `
|
|
323
|
-
-User $credenziali.UserName -Password $
|
|
328
|
+
-User $credenziali.UserName -Password $pwWin -RunLevel Limited -Force -ErrorAction Stop | Out-Null
|
|
324
329
|
Enable-ScheduledTask -TaskName $attivita | Out-Null
|
|
325
330
|
Esito 'attivita''' 'ok' "ogni $minuti minuti come $($credenziali.UserName); registro in $log"
|
|
326
331
|
} catch {
|
|
327
332
|
Esito 'attivita''' 'NO' "Windows l'ha rifiutata (password giusta?): $($_.Exception.Message)"
|
|
328
333
|
}
|
|
329
|
-
$credenziali = $null
|
|
334
|
+
$credenziali = $null; $pwWin = $null
|
|
330
335
|
}
|
|
331
336
|
|
|
332
337
|
# 8. La dashboard ------------------------------------------------------------------------------
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# Gli ultimi giri del worker degli snapshot e il loro esito, senza cambiare niente.
|
|
2
|
+
#
|
|
3
|
+
# powershell -ExecutionPolicy Bypass -File C:\NOEVA\snapshot\ultimi-giri.ps1
|
|
4
|
+
# powershell -ExecutionPolicy Bypass -File C:\NOEVA\snapshot\ultimi-giri.ps1 -Giri 20
|
|
5
|
+
#
|
|
6
|
+
# Legge l'attivita' pianificata, lavoro\ascolta.log (tutti i giri, con l'esito) e il
|
|
7
|
+
# giro.json degli ultimi giri tenuti in lavoro\<progetto>\ (le cifre di controllo che
|
|
8
|
+
# hanno avvisato o fermato). Non legge chiavi ne' password e non chiama NOEVA.
|
|
9
|
+
# Lo lancia l'utente del worker, o chiunque possa leggere C:\NOEVA\snapshot.
|
|
10
|
+
|
|
11
|
+
param(
|
|
12
|
+
[int]$Giri = 10,
|
|
13
|
+
[string]$Cartella = ''
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Di solito accanto a noeva-snapshot (C:\NOEVA\snapshot); copiato altrove, guarda li'.
|
|
17
|
+
if (-not $Cartella) {
|
|
18
|
+
$Cartella = if ($PSScriptRoot -and (Test-Path (Join-Path $PSScriptRoot 'worker.json'))) { $PSScriptRoot } else { 'C:\NOEVA\snapshot' }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
$ErrorActionPreference = 'Continue'
|
|
22
|
+
# Il nome lo da' installa.ps1: "NOEVA snapshot <progetto>", dal progetto di worker.json.
|
|
23
|
+
$attivita = 'NOEVA snapshot'
|
|
24
|
+
$log = Join-Path $Cartella 'lavoro\ascolta.log'
|
|
25
|
+
$conf = Join-Path $Cartella 'worker.json'
|
|
26
|
+
|
|
27
|
+
function Riga([string]$etichetta, [string]$stato, [string]$testo) {
|
|
28
|
+
$colore = switch ($stato) { 'ok' { 'Green' } 'avviso' { 'Yellow' } 'NO' { 'Red' } default { 'Gray' } }
|
|
29
|
+
Write-Host (' {0,-8}' -f $stato) -ForegroundColor $colore -NoNewline
|
|
30
|
+
Write-Host (' {0,-22} {1}' -f $etichetta, $testo)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function Durata([timespan]$d) {
|
|
34
|
+
if ($d.TotalMinutes -lt 1) { return '{0} s' -f [int]$d.TotalSeconds }
|
|
35
|
+
if ($d.TotalHours -lt 1) { return '{0} min' -f [int]$d.TotalMinutes }
|
|
36
|
+
if ($d.TotalDays -lt 1) { return '{0:0.#} ore' -f $d.TotalHours }
|
|
37
|
+
return '{0:0.#} giorni' -f $d.TotalDays
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (Test-Path $conf) {
|
|
41
|
+
try {
|
|
42
|
+
$w = Get-Content $conf -Raw | ConvertFrom-Json
|
|
43
|
+
$progetti = @($w.pipeline.PSObject.Properties | ForEach-Object { $_.Value.progetto })
|
|
44
|
+
if ($progetti.Count -eq 1) { $attivita = "NOEVA snapshot $($progetti[0])" }
|
|
45
|
+
} catch { }
|
|
46
|
+
}
|
|
47
|
+
# Senza worker.json leggibile: la prima attivita' del worker che c'e' su questa macchina.
|
|
48
|
+
if ($attivita -eq 'NOEVA snapshot' -and (Get-Command Get-ScheduledTask -ErrorAction SilentlyContinue)) {
|
|
49
|
+
$trovata = Get-ScheduledTask -TaskName 'NOEVA snapshot *' -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
50
|
+
if ($trovata) { $attivita = $trovata.TaskName }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
Write-Host ''
|
|
54
|
+
Write-Host "NOEVA Analytics - gli ultimi giri del worker degli snapshot" -ForegroundColor Cyan
|
|
55
|
+
Write-Host ("macchina {0} - {1} - {2}" -f $env:COMPUTERNAME, $Cartella, (Get-Date -Format 'dd/MM/yyyy HH:mm'))
|
|
56
|
+
|
|
57
|
+
# --- l'attivita' pianificata ------------------------------------------------------
|
|
58
|
+
Write-Host ''
|
|
59
|
+
Write-Host "== L'attivita' pianificata '$attivita'"
|
|
60
|
+
$t = if (Get-Command Get-ScheduledTask -ErrorAction SilentlyContinue) { Get-ScheduledTask -TaskName $attivita -ErrorAction SilentlyContinue }
|
|
61
|
+
if (-not $t) {
|
|
62
|
+
Riga 'attivita''' 'NO' "non c'e': va rilanciato installa.ps1"
|
|
63
|
+
} else {
|
|
64
|
+
$info = Get-ScheduledTaskInfo -TaskName $attivita
|
|
65
|
+
$codici = @{
|
|
66
|
+
'0' = 'ok'
|
|
67
|
+
'267009' = 'in corso adesso'
|
|
68
|
+
'267011' = 'non e'' mai partita'
|
|
69
|
+
'2147943785' = 'Windows rifiuta l''accesso: manca il diritto "Accesso come processo batch"'
|
|
70
|
+
'2147942402' = 'file non trovato: la cartella e'' cambiata, rilancia installa.ps1'
|
|
71
|
+
'2147943726' = 'password Windows sbagliata o cambiata: rilancia installa.ps1'
|
|
72
|
+
}
|
|
73
|
+
$r = [int64]$info.LastTaskResult
|
|
74
|
+
if ($r -lt 0) { $r += 4294967296 }
|
|
75
|
+
$significato = if ($codici.ContainsKey("$r")) { $codici["$r"] } else { 'codice 0x{0:X8}' -f $r }
|
|
76
|
+
$stato = if ($t.State -eq 'Disabled') { 'NO' } elseif ($r -in 0, 267009) { 'ok' } elseif ($r -eq 267011) { 'avviso' } else { 'NO' }
|
|
77
|
+
Riga 'stato' $stato ("{0}, ultimo avvio {1:dd/MM HH:mm}, {2}" -f $t.State, $info.LastRunTime, $significato)
|
|
78
|
+
if ($t.State -eq 'Disabled') {
|
|
79
|
+
Riga 'disattivata' 'NO' "non parte finche' non si riaccende: i giri di NOEVA restano in coda"
|
|
80
|
+
Write-Host " Enable-ScheduledTask -TaskName '$attivita'; Start-ScheduledTask -TaskName '$attivita'" -ForegroundColor Yellow
|
|
81
|
+
Write-Host " (installa.ps1 la spegne durante un aggiornamento e la riaccende al passo 7)" -ForegroundColor Yellow
|
|
82
|
+
}
|
|
83
|
+
Riga 'prossimo avvio' 'info' ("{0:dd/MM HH:mm} (ogni 5 minuti chiede a NOEVA se c'e' un giro)" -f $info.NextRunTime)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# --- ascolta.log ------------------------------------------------------------------
|
|
87
|
+
Write-Host ''
|
|
88
|
+
Write-Host "== I giri in ascolta.log"
|
|
89
|
+
if (-not (Test-Path $log)) {
|
|
90
|
+
Riga 'ascolta.log' 'avviso' "non c'e' ancora: nessun giro"
|
|
91
|
+
Write-Host ''
|
|
92
|
+
exit 0
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
$righe = Get-Content $log -Encoding UTF8
|
|
96
|
+
$reTs = '^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d) '
|
|
97
|
+
$tutti = New-Object System.Collections.ArrayList
|
|
98
|
+
$corrente = $null
|
|
99
|
+
$ultimoAscolto = $null
|
|
100
|
+
$erroriApi = New-Object System.Collections.ArrayList
|
|
101
|
+
|
|
102
|
+
foreach ($riga in $righe) {
|
|
103
|
+
if ($riga -match ($reTs + 'in ascolto per')) {
|
|
104
|
+
$ultimoAscolto = [datetime]$Matches[1]
|
|
105
|
+
} elseif ($riga -match ($reTs + 'non riesco a chiedere lavoro all''API: (.*)$')) {
|
|
106
|
+
[void]$erroriApi.Add([pscustomobject]@{ Quando = [datetime]$Matches[1]; Testo = $Matches[2] })
|
|
107
|
+
} elseif ($riga -match ($reTs + '(\S+): processo (\S+), giro in (.+)$')) {
|
|
108
|
+
$corrente = [pscustomobject]@{
|
|
109
|
+
Inizio = [datetime]$Matches[1]; Pipeline = $Matches[2]; Processo = $Matches[3]
|
|
110
|
+
Cartella = $Matches[4].Trim(); Fine = $null; Esito = 'interrotto'; Dettaglio = ''
|
|
111
|
+
Versione = ''; DataDato = ''; Avvisi = 0
|
|
112
|
+
}
|
|
113
|
+
[void]$tutti.Add($corrente)
|
|
114
|
+
} elseif ($corrente -and $riga -match '^\s+data del dato: (\S+)') {
|
|
115
|
+
$corrente.DataDato = $Matches[1]
|
|
116
|
+
} elseif ($corrente -and $riga -match '^\s+(\d+) cifre di controllo spostate entro la tolleranza') {
|
|
117
|
+
$corrente.Avvisi = [int]$Matches[1]
|
|
118
|
+
} elseif ($corrente -and $riga -match ($reTs + '\S+: pubblicata (\S+) in ([\d.]+)s')) {
|
|
119
|
+
$corrente.Fine = [datetime]$Matches[1]; $corrente.Esito = 'pubblicato'; $corrente.Versione = $Matches[2]
|
|
120
|
+
$corrente = $null
|
|
121
|
+
} elseif ($corrente -and $riga -match ($reTs + '\S+: FALLITO, (.*)$')) {
|
|
122
|
+
$corrente.Fine = [datetime]$Matches[1]; $corrente.Esito = 'FALLITO'; $corrente.Dettaglio = $Matches[2]
|
|
123
|
+
$corrente = $null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
$ok = @($tutti | Where-Object Esito -eq 'pubblicato')
|
|
128
|
+
$ko = @($tutti | Where-Object Esito -eq 'FALLITO')
|
|
129
|
+
Riga 'giri nel log' 'info' ("{0} in tutto: {1} pubblicati, {2} falliti, dal {3:dd/MM/yyyy}" -f $tutti.Count, $ok.Count, $ko.Count, $(if ($tutti.Count) { $tutti[0].Inizio } else { Get-Date }))
|
|
130
|
+
|
|
131
|
+
if ($ultimoAscolto) {
|
|
132
|
+
$fa = (Get-Date) - $ultimoAscolto
|
|
133
|
+
$stato = if ($fa.TotalMinutes -le 15) { 'ok' } else { 'NO' }
|
|
134
|
+
$nota = if ($stato -eq 'ok') { '' } else { ': l''attivita'' non parte, guarda lo stato qui sopra' }
|
|
135
|
+
Riga 'ultimo controllo' $stato ("{0:dd/MM HH:mm}, {1} fa{2}" -f $ultimoAscolto, (Durata $fa), $nota)
|
|
136
|
+
}
|
|
137
|
+
if ($ok.Count) {
|
|
138
|
+
$u = $ok[-1]
|
|
139
|
+
$fa = (Get-Date) - $u.Fine
|
|
140
|
+
Riga 'ultimo pubblicato' $(if ($fa.TotalHours -le 26) { 'ok' } else { 'avviso' }) ("{0:dd/MM HH:mm}, {1} fa, versione {2}, dato al {3}" -f $u.Fine, (Durata $fa), $u.Versione, $u.DataDato)
|
|
141
|
+
} else {
|
|
142
|
+
Riga 'ultimo pubblicato' 'NO' 'nessun giro pubblicato in ascolta.log'
|
|
143
|
+
}
|
|
144
|
+
$recenti = @($erroriApi | Where-Object { $_.Quando -gt (Get-Date).AddDays(-1) })
|
|
145
|
+
if ($recenti.Count) {
|
|
146
|
+
Riga 'errori verso NOEVA' 'avviso' ("{0} nelle ultime 24 ore, l'ultimo {1:dd/MM HH:mm}: {2}" -f $recenti.Count, $recenti[-1].Quando, $recenti[-1].Testo)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
Write-Host ''
|
|
150
|
+
Write-Host ("== Gli ultimi {0} giri (il piu' recente in alto)" -f [Math]::Min($Giri, $tutti.Count))
|
|
151
|
+
if (-not $tutti.Count) { Write-Host ' nessun giro ancora' }
|
|
152
|
+
$mostrati = @($tutti | Select-Object -Last $Giri)
|
|
153
|
+
[array]::Reverse($mostrati)
|
|
154
|
+
foreach ($g in $mostrati) {
|
|
155
|
+
$durata = if ($g.Fine) { Durata ($g.Fine - $g.Inizio) } else { '-' }
|
|
156
|
+
switch ($g.Esito) {
|
|
157
|
+
'pubblicato' {
|
|
158
|
+
$avv = if ($g.Avvisi) { ", $($g.Avvisi) avvisi" } else { '' }
|
|
159
|
+
Riga ('{0:ddd dd/MM HH:mm}' -f $g.Inizio) 'ok' ("pubblicato {0}, dato al {1}, {2}{3}" -f $g.Versione, $g.DataDato, $durata, $avv)
|
|
160
|
+
}
|
|
161
|
+
'FALLITO' {
|
|
162
|
+
Riga ('{0:ddd dd/MM HH:mm}' -f $g.Inizio) 'NO' ("FALLITO dopo {0}: {1}" -f $durata, $g.Dettaglio)
|
|
163
|
+
}
|
|
164
|
+
default {
|
|
165
|
+
$inCorso = ((Get-Date) - $g.Inizio).TotalMinutes -lt 30 -and $g -eq $tutti[-1]
|
|
166
|
+
Riga ('{0:ddd dd/MM HH:mm}' -f $g.Inizio) $(if ($inCorso) { 'info' } else { 'avviso' }) $(if ($inCorso) { 'in corso adesso' } else { 'interrotto senza esito (macchina spenta o processo chiuso?)' })
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# --- le cifre di controllo degli ultimi giri ----------------------------------------
|
|
172
|
+
# giro.json resta per gli ultimi 5 giri di ogni progetto (ascolta.py, GIRI_TENUTI).
|
|
173
|
+
$ultimi = @($tutti | Where-Object { $_.Cartella -and (Test-Path -LiteralPath ([IO.Path]::Combine($_.Cartella, 'giro.json'))) } | Select-Object -Last 1)
|
|
174
|
+
if ($ultimi.Count) {
|
|
175
|
+
$g = $ultimi[0]
|
|
176
|
+
Write-Host ''
|
|
177
|
+
Write-Host ("== Le cifre di controllo del giro del {0:dd/MM HH:mm}" -f $g.Inizio)
|
|
178
|
+
try {
|
|
179
|
+
$rep = Get-Content -LiteralPath ([IO.Path]::Combine($g.Cartella, 'giro.json')) -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
180
|
+
$controlli = @($rep.controlli | Where-Object { $_.PSObject.Properties.Name -contains 'esito' })
|
|
181
|
+
$fuori = @($controlli | Where-Object { $_.esito -ne 'ok' })
|
|
182
|
+
Riga 'controlli' $(if (@($fuori | Where-Object esito -eq 'diverso').Count) { 'NO' } elseif ($fuori.Count) { 'avviso' } else { 'ok' }) ("{0} in tutto, {1} ok" -f $controlli.Count, ($controlli.Count - $fuori.Count))
|
|
183
|
+
foreach ($c in $fuori) {
|
|
184
|
+
$scarto = if ([double]$c.atteso -ne 0) { ' ({0:+0.00;-0.00}%)' -f ((([double]$c.trovato - [double]$c.atteso) / [Math]::Abs([double]$c.atteso)) * 100) } else { '' }
|
|
185
|
+
$stato = if ($c.esito -eq 'diverso') { 'NO' } else { 'avviso' }
|
|
186
|
+
Write-Host (' {0,-8}' -f $stato) -ForegroundColor $(if ($stato -eq 'NO') { 'Red' } else { 'Yellow' }) -NoNewline
|
|
187
|
+
Write-Host (" {0}" -f $c.nome)
|
|
188
|
+
Write-Host (" trovato {0:N2}, atteso {1:N2}{2}" -f [double]$c.trovato, [double]$c.atteso, $scarto)
|
|
189
|
+
}
|
|
190
|
+
if (@($fuori | Where-Object esito -eq 'diverso').Count) {
|
|
191
|
+
Write-Host ''
|
|
192
|
+
Write-Host ' Una cifra fuori tolleranza ferma la pubblicazione: online resta lo snapshot di prima.' -ForegroundColor Yellow
|
|
193
|
+
Write-Host ' Si guarda la cifra (snapshot.yml del progetto), non si forza.' -ForegroundColor Yellow
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
Riga 'giro.json' 'avviso' "non leggibile: $($_.Exception.Message)"
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
Write-Host ''
|
|
201
|
+
Write-Host "Il log completo: $log"
|
|
202
|
+
Write-Host ''
|
package/core.js
CHANGED
|
@@ -209,6 +209,47 @@ export const ASSETSIGNORE = [
|
|
|
209
209
|
const ROTTE_DEL_CONTENITORE = new Set(['', 'vendor', 'sessione', 'logout']);
|
|
210
210
|
const RIFERIMENTO = /(?:(?:href|src)=["']|fetch\(\s*["'`]|from\s+["']|import\(\s*["'`]|url\(\s*["']?)\/([^"'`)/?#\s]*)/g;
|
|
211
211
|
|
|
212
|
+
// L'AI passa solo da NOEVA (inferenza NOEVA o NOEVA Agents, ADR 0006): nessun file
|
|
213
|
+
// del progetto lancia la CLI di Claude o chiama un'API di modelli diretta, e
|
|
214
|
+
// serve-app.py non espone le rotte che la lanciavano. Non c'e' eccezione da
|
|
215
|
+
// dichiarare: un token AI che non passa da NOEVA ferma la verifica.
|
|
216
|
+
export const AI_FUORI_DA_NOEVA = [
|
|
217
|
+
[/\bwhich\(\s*["'`]claude["'`]/, 'cerca la CLI di Claude'],
|
|
218
|
+
[/["'`]claude["'`]\s*,\s*["'`]-(?:p|-print|-resume|-session-id|-output-format)\b/, 'lancia la CLI di Claude'],
|
|
219
|
+
[/\bclaude\s+(?:-p|--print)\b/, 'lancia la CLI di Claude'],
|
|
220
|
+
[/\.local\/bin\/claude\b/, 'cerca la CLI di Claude'],
|
|
221
|
+
[/\bapi\.anthropic\.com\b|@anthropic-ai\/|^\s*(?:import|from)\s+anthropic\b|\bANTHROPIC_API_KEY\b/m, "chiama l'API di Anthropic diretta"],
|
|
222
|
+
[/\bapi\.openai\.com\b|^\s*(?:import|from)\s+openai\b|\bOPENAI_API_KEY\b/m, "chiama l'API di OpenAI diretta"],
|
|
223
|
+
[/\bbedrock-runtime\b|generativelanguage\.googleapis\.com/, 'chiama un servizio di modelli diretto'],
|
|
224
|
+
[/["'`]\/ai\/(?:chat|chat\/storia|tour|presentazione\/modifica)["'`]/, 'serve una rotta che lanciava la CLI di Claude'],
|
|
225
|
+
];
|
|
226
|
+
const FUORI_DALLA_RICERCA = new Set(['node_modules', '.git', '.wren', '.venv', 'venv', '__pycache__', 'target', 'dist',
|
|
227
|
+
'vendor', '.claude', '.wrangler', 'dati']);
|
|
228
|
+
|
|
229
|
+
function fileDelCodice(cartella) {
|
|
230
|
+
const fuori = [];
|
|
231
|
+
for (const voce of readdirSync(cartella, { withFileTypes: true })) {
|
|
232
|
+
if (voce.isDirectory()) {
|
|
233
|
+
if (!FUORI_DALLA_RICERCA.has(voce.name)) fuori.push(...fileDelCodice(join(cartella, voce.name)));
|
|
234
|
+
} else if (/\.(py|js|mjs|cjs|ts|sh|ps1|html)$/.test(voce.name)) fuori.push(join(cartella, voce.name));
|
|
235
|
+
}
|
|
236
|
+
return fuori;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function aiFuoriDaNoeva(radice) {
|
|
240
|
+
const trovati = new Map();
|
|
241
|
+
for (const f of fileDelCodice(radice)) {
|
|
242
|
+
const testo = readFileSync(f, 'utf8');
|
|
243
|
+
for (const [re, cosa] of AI_FUORI_DA_NOEVA) {
|
|
244
|
+
const m = re.exec(testo);
|
|
245
|
+
if (!m) continue;
|
|
246
|
+
const dove = `${posix(relative(radice, f))}:${testo.slice(0, m.index).split('\n').length}`;
|
|
247
|
+
if (!trovati.has(dove)) trovati.set(dove, `${dove} ${cosa}: l'AI passa solo da NOEVA (inferenza o Agents)`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return [...trovati.values()];
|
|
251
|
+
}
|
|
252
|
+
|
|
212
253
|
function trackedDaGit(radice) {
|
|
213
254
|
if (!existsSync(join(radice, '.git'))) return null;
|
|
214
255
|
try {
|
|
@@ -258,6 +299,8 @@ export function verifica(radice, config) {
|
|
|
258
299
|
}
|
|
259
300
|
}
|
|
260
301
|
|
|
302
|
+
errori.push(...aiFuoriDaNoeva(radice));
|
|
303
|
+
|
|
261
304
|
const tracciati = trackedDaGit(radice);
|
|
262
305
|
if (tracciati) {
|
|
263
306
|
for (const f of tracciati) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noeva/analytics-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Gli strumenti di un progetto NOEVA Analytics nel suo repository: moduli di pagina, snapshot, verifica dello standard e kit dell'agente (ADR 0005).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|