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