@noeva/analytics-core 0.3.3 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,252 @@
1
+ // Modulo condiviso delle dashboard NOEVA Analytics. La sorgente e' lib/pagina/vista.js;
2
+ // la copia in apps/assets/ la scrive lib/snapshot/installa.sh --pagina. Non modificare la copia.
3
+ /* La Vista dichiarata (standard §6.2, punto 9; NW-1265): una dashboard con i suoi
4
+ * filtri e l'eventuale fuoco, su uno snapshot preciso. La pagina la dichiara in
5
+ * due posti, scritti entrambi da qui con lo stesso oggetto:
6
+ *
7
+ * - la riga di provenienza visibile (al posto di #pie e #prov scritti a mano):
8
+ * data del dato, data e ora assolute dello snapshot con l'eta' accanto,
9
+ * fonte, regola di lettura, la frase propria del progetto e il pulsante
10
+ * «Copia la citazione». La legge chi guarda e la legge get_page_text;
11
+ * - il blocco <script type="application/json" id="noeva-vista">, riscritto a
12
+ * ogni cambio di filtri o di fuoco: lo leggono contesto() di commento.js
13
+ * (chat, commento guidato, presentazione) e Claude nel browser integrato.
14
+ *
15
+ * Niente meta (sono statici e non seguono i filtri) e niente oggetti globali.
16
+ *
17
+ * const vista = montaVista({
18
+ * dashboard: "vendite", // l'id della pagina
19
+ * meta: D.meta, // snapshot.json
20
+ * filtri: { es: "Esercizio", vista: { nome: "Periodo", etichetta: (v) => NOMI[v] } },
21
+ * provenienza: { sorgente: "il DWH del cliente", regola: "regole in knowledge/rules/general.md" },
22
+ * frase: "…", // facoltativa, HTML del progetto
23
+ * dove: "#pie", // la riga; senza, #provenienza (o una nuova in fondo)
24
+ * });
25
+ *
26
+ * I filtri sono le chiavi dell'URL (sincronizzaUrl): la chiave fa fede,
27
+ * l'etichetta leggibile sta accanto. Una chiave non dichiarata in `filtri` non
28
+ * e' un filtro (?commento=prova, ?r=2); senza `filtri` lo e' ogni chiave. Il
29
+ * fuoco e' quello che la pagina mostra in #fuoco (montaFuoco).
30
+ *
31
+ * La vista si riscrive da sola quando sincronizzaUrl scrive l'URL e quando il
32
+ * fuoco cambia (l'evento "noeva-vista" di tema.js, e #fuoco osservato); una
33
+ * pagina che cambia la vista per altre vie chiama vista.aggiorna().
34
+ */
35
+ import { t, LINGUA, LOCALE } from "./lingua.js";
36
+ import { PROGETTO } from "./progetto.js";
37
+ import { etaSnapshot } from "./tema.js";
38
+
39
+ export const ID_BLOCCO = "noeva-vista";
40
+ export const FUSO = "Europe/Rome";
41
+ export const EVENTO = "noeva-vista";
42
+
43
+ /* --- la vista come dato ------------------------------------------------------------ */
44
+
45
+ const pulito = (s) => String(s ?? "").replace(/[▸▾✕]/g, "").replace(/\s+/g, " ").trim();
46
+
47
+ /* I filtri dalla query dell'URL, nell'ordine dei dichiarati (poi gli altri, se
48
+ * non ce ne sono di dichiarati). `dichiarati`: { chiave: "Nome" } oppure
49
+ * { chiave: { nome, etichetta(valore) } }. */
50
+ export function filtriDaUrl(query, dichiarati = null) {
51
+ const p = new URLSearchParams(query);
52
+ const chiavi = dichiarati ? Object.keys(dichiarati).filter((k) => p.has(k)) : [...new Set(p.keys())];
53
+ return chiavi.map((chiave) => {
54
+ const valore = p.get(chiave);
55
+ const d = dichiarati?.[chiave];
56
+ const nome = (typeof d === "string" ? d : d?.nome) || chiave;
57
+ const etichetta = (typeof d === "object" && d?.etichetta ? d.etichetta(valore) : null) ?? valore;
58
+ return { chiave, valore, nome: String(nome), etichetta: String(etichetta) };
59
+ });
60
+ }
61
+
62
+ /* "6 novembre 2026" da "2026-11-06" (o da un timestamp: conta il giorno scritto). */
63
+ export function giornoLungo(iso, locale = LOCALE) {
64
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso ?? ""));
65
+ if (!m) return String(iso ?? "");
66
+ return new Intl.DateTimeFormat(locale, { timeZone: "UTC", day: "numeric", month: "long", year: "numeric" })
67
+ .format(new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])));
68
+ }
69
+
70
+ /* "8 novembre 2026, 22:21": data e ora assolute dello snapshot, nel fuso di Roma. */
71
+ export function dataOraAssoluta(iso, locale = LOCALE) {
72
+ const d = new Date(iso);
73
+ if (Number.isNaN(d.getTime())) return String(iso ?? "");
74
+ const giorno = new Intl.DateTimeFormat(locale, { timeZone: FUSO, day: "numeric", month: "long", year: "numeric" }).format(d);
75
+ const ora = new Intl.DateTimeFormat(locale, { timeZone: FUSO, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(d);
76
+ return `${giorno}, ${ora}`;
77
+ }
78
+
79
+ /* La vista: tutto quello che la pagina dichiara, in un oggetto solo. */
80
+ export function componiVista({ progetto, dashboard, lingua, url, filtri = [], fuoco = null, meta = {}, provenienza = {}, eta }) {
81
+ const generato = meta.generato ?? null;
82
+ const e = eta || (generato ? etaSnapshot(generato) : null);
83
+ return {
84
+ progetto: { id: progetto?.id ?? null, nome: progetto?.nome ?? null },
85
+ dashboard: { id: dashboard?.id ?? null, titolo: dashboard?.titolo ?? null },
86
+ lingua,
87
+ url,
88
+ filtri,
89
+ fuoco,
90
+ data_del_dato: meta.data_del_dato ? String(meta.data_del_dato).slice(0, 10) : null,
91
+ snapshot: {
92
+ generato,
93
+ data_ora: generato ? dataOraAssoluta(generato) : null,
94
+ fuso: FUSO,
95
+ eta: e?.testo ?? null,
96
+ stantio: e?.stantio ?? null,
97
+ },
98
+ provenienza: {
99
+ sorgente: provenienza.sorgente || null,
100
+ regola: provenienza.regola || t("letta in sola lettura attraverso il modello semantico NOEVA"),
101
+ },
102
+ };
103
+ }
104
+
105
+ /* Le voci della provenienza, le stesse per la riga visibile e per la citazione:
106
+ * cosi' le due non possono dire cose diverse. */
107
+ export function vociProvenienza(v) {
108
+ const voci = [];
109
+ if (v.data_del_dato) voci.push({ nome: t("Data del dato"), testo: giornoLungo(v.data_del_dato) });
110
+ if (v.snapshot?.data_ora) voci.push({ nome: t("Snapshot"), testo: v.snapshot.data_ora, eta: v.snapshot.eta, stantio: v.snapshot.stantio });
111
+ if (v.provenienza?.sorgente) voci.push({ nome: t("Fonte"), testo: v.provenienza.sorgente });
112
+ if (v.provenienza?.regola) voci.push({ nome: t("Regola di lettura"), testo: v.provenienza.regola });
113
+ return voci;
114
+ }
115
+
116
+ const testoVoce = (x) => `${x.nome}: ${x.testo}${x.eta ? ` (${x.eta})` : ""}`;
117
+
118
+ /* La citazione: la vista in testo, da incollare in una mail o in una richiesta a 4D. */
119
+ export function testoCitazione(v) {
120
+ const righe = [[v.progetto?.nome, v.dashboard?.titolo].filter(Boolean).join(" · ")];
121
+ if (v.url) righe.push(v.url);
122
+ righe.push(`${t("Filtri")}: ${v.filtri?.length
123
+ ? v.filtri.map((f) => `${f.nome}: ${f.etichetta} (${f.chiave}=${f.valore})`).join(" · ")
124
+ : t("nessuno")}`);
125
+ if (v.fuoco) righe.push(`${t("A fuoco")}: ${v.fuoco.etichetta}`);
126
+ righe.push(vociProvenienza(v).map(testoVoce).join(" · "));
127
+ return righe.filter(Boolean).join("\n");
128
+ }
129
+
130
+ /* Il contenuto del blocco: JSON con "<" scritto <, cosi' resta un blocco
131
+ * solo anche quando la pagina si serializza (un "</script>" nei dati lo chiuderebbe). */
132
+ export const jsonBlocco = (v) => JSON.stringify(v, null, 1).replace(/</g, "\\u003c");
133
+
134
+ /* La vista dichiarata dalla pagina, o null se la pagina non ne ha una. */
135
+ export function leggiVista(doc = document) {
136
+ const el = doc.getElementById(ID_BLOCCO);
137
+ if (!el) return null;
138
+ try { return JSON.parse(el.textContent); } catch { return null; }
139
+ }
140
+
141
+ /* --- la pagina --------------------------------------------------------------------- */
142
+
143
+ async function copia(testo) {
144
+ // come la copia delle tabelle (tema.js): navigator.clipboard vuole un contesto
145
+ // sicuro e un gesto recente; se manca si ripiega sul metodo vecchio
146
+ if (navigator.clipboard?.writeText) {
147
+ try { return await navigator.clipboard.writeText(testo); } catch { /* si ripiega */ }
148
+ }
149
+ const ta = document.createElement("textarea");
150
+ ta.value = testo;
151
+ ta.style.cssText = "position:fixed;top:-1000px;opacity:0";
152
+ document.body.append(ta);
153
+ ta.select();
154
+ const fatto = document.execCommand("copy");
155
+ ta.remove();
156
+ if (!fatto) throw new Error("copia non riuscita");
157
+ }
158
+
159
+ function fuocoDellaPagina() {
160
+ const el = document.getElementById("fuoco");
161
+ if (!el || el.hidden) return null;
162
+ const etichetta = pulito(el.querySelector("b")?.textContent);
163
+ return etichetta ? { etichetta } : null;
164
+ }
165
+
166
+ export function montaVista({ dashboard, meta = {}, filtri = null, provenienza = {}, frase = null, dove = null, titolo = null }) {
167
+ const riga = (typeof dove === "string" ? document.querySelector(dove) : dove)
168
+ || document.getElementById("provenienza") || nuovaRiga();
169
+ riga.classList.add("provenienza");
170
+
171
+ let blocco = document.getElementById(ID_BLOCCO);
172
+ if (!blocco) {
173
+ blocco = document.createElement("script");
174
+ blocco.type = "application/json";
175
+ blocco.id = ID_BLOCCO;
176
+ riga.after(blocco);
177
+ }
178
+
179
+ const voci = document.createElement("span");
180
+ voci.className = "pv-voci";
181
+ const propria = document.createElement("span");
182
+ propria.className = "pv-frase";
183
+ const btn = document.createElement("button");
184
+ btn.type = "button";
185
+ btn.className = "pv-copia";
186
+ btn.textContent = t("Copia la citazione");
187
+ btn.title = t("Copia la vista in testo: dashboard, indirizzo, filtri, fuoco, dati e provenienza");
188
+ // separatori scritti, non solo margini: get_page_text legge il testo, non il CSS
189
+ riga.replaceChildren(voci, propria, " ", btn);
190
+
191
+ let attuale = null, torna = null;
192
+ function aggiorna() {
193
+ attuale = componiVista({
194
+ progetto: { id: meta.progetto || PROGETTO.id, nome: PROGETTO.nome },
195
+ dashboard: { id: dashboard, titolo: titolo || pulito(document.querySelector(".topbar h1")?.textContent) || pulito(document.title) },
196
+ lingua: LINGUA,
197
+ url: location.href,
198
+ filtri: filtriDaUrl(location.search, filtri),
199
+ fuoco: fuocoDellaPagina(),
200
+ meta,
201
+ provenienza,
202
+ });
203
+ blocco.textContent = jsonBlocco(attuale);
204
+ voci.replaceChildren(...vociProvenienza(attuale).flatMap((x, i) => {
205
+ const nome = document.createElement("b");
206
+ nome.textContent = `${x.nome}:`;
207
+ const parti = [...(i ? [" · "] : []), nome, ` ${x.testo}`];
208
+ if (x.eta) {
209
+ const eta = document.createElement("span");
210
+ eta.className = x.stantio ? "pv-eta stantio" : "pv-eta";
211
+ eta.textContent = `(${x.eta})`;
212
+ parti.push(" ", eta);
213
+ }
214
+ return parti;
215
+ }));
216
+ const f = typeof frase === "function" ? frase(attuale) : frase;
217
+ propria.innerHTML = f ? ` · ${f}` : "";
218
+ propria.hidden = !f;
219
+ return attuale;
220
+ }
221
+
222
+ btn.onclick = async () => {
223
+ try {
224
+ await copia(testoCitazione(aggiorna()));
225
+ btn.textContent = t("Citazione copiata");
226
+ } catch {
227
+ btn.textContent = t("Copia non riuscita");
228
+ }
229
+ clearTimeout(torna);
230
+ torna = setTimeout(() => { btn.textContent = t("Copia la citazione"); }, 2000);
231
+ };
232
+
233
+ document.addEventListener(EVENTO, aggiorna);
234
+ addEventListener("popstate", aggiorna);
235
+ // il fuoco lo disegna la pagina in #fuoco: anche quando lo toglie con Esc o col suo ✕
236
+ const elFuoco = document.getElementById("fuoco");
237
+ if (elFuoco && typeof MutationObserver === "function") {
238
+ new MutationObserver(aggiorna).observe(elFuoco, { attributes: true, attributeFilter: ["hidden"], childList: true, subtree: true, characterData: true });
239
+ }
240
+ // l'eta' dello snapshot cresce anche a pagina ferma
241
+ setInterval(aggiorna, 5 * 60_000).unref?.();
242
+
243
+ aggiorna();
244
+ return { aggiorna, vista: () => attuale };
245
+ }
246
+
247
+ function nuovaRiga() {
248
+ const p = document.createElement("p");
249
+ p.id = "provenienza";
250
+ (document.querySelector("#pagina") || document.querySelector("main") || document.body).append(p);
251
+ return p;
252
+ }
@@ -0,0 +1,132 @@
1
+ // Modulo condiviso delle dashboard NOEVA Analytics. La sorgente e' lib/pagina/voce.js;
2
+ // la copia in apps/assets/ la scrive lib/snapshot/installa.sh --pagina. Non modificare la copia.
3
+ /* La voce del commento guidato sul sito pubblicato (NW-1091).
4
+ *
5
+ * Il catalogo e la voce li ha il contenitore (lib/analytics/voce.js, rotta
6
+ * ai/voci): la chiave ElevenLabs sta li', in un secret. Qui si sceglie la voce
7
+ * e si chiede l'mp3 di un testo.
8
+ *
9
+ * montaSceltaVoce("#voce") nella home del progetto: per ogni lingua le
10
+ * voci, con «Ascolta»; non compare se il
11
+ * contenitore non ha la voce online
12
+ * montaSceltaVoce("#voce", { lingue: ["it"] }) solo le lingue del progetto
13
+ * await voceOnline() true se il contenitore fa la voce
14
+ * voceScelta("it") l'id scelto per la lingua, o null
15
+ * await parla(testo, { pagina }) l'URL (blob:) dell'mp3, dalla voce scelta
16
+ *
17
+ * La scelta e' di chi guarda: resta nel suo browser, per progetto e per lingua
18
+ * (`<progetto>-voce-it`, `<progetto>-voce-en`), come la lingua ricordata.
19
+ */
20
+ import { rotta } from "./servizio.js";
21
+ import { t, LINGUA } from "./lingua.js";
22
+ import { PROGETTO } from "./progetto.js";
23
+
24
+ const NOMI_LINGUE = { it: "Italiano", en: "English" };
25
+ const PROVA = {
26
+ it: (nome) => `Buongiorno, sono ${nome}. Racconterò questa dashboard tappa per tappa.`,
27
+ en: (nome) => `Hello, I'm ${nome}. I'll walk you through this dashboard step by step.`,
28
+ };
29
+ const chiave = (lingua) => `${PROGETTO.id}-voce-${lingua}`;
30
+ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
31
+
32
+ let _catalogo = null;
33
+ /* Il catalogo del contenitore, o null dove la voce online non c'e' (sul Mac
34
+ * con il solo serve-app.py, su un server statico, o senza ELEVENLABS_API_KEY). */
35
+ export function catalogoVoci() {
36
+ _catalogo ||= fetch(rotta("ai/voci"), { cache: "no-cache" })
37
+ .then(async (r) => (r.ok && (r.headers.get("content-type") || "").includes("json") ? r.json() : null))
38
+ .then((c) => (c && c.lingue ? c : null))
39
+ .catch(() => null);
40
+ return _catalogo;
41
+ }
42
+
43
+ export async function voceOnline() {
44
+ return Boolean(await catalogoVoci());
45
+ }
46
+
47
+ export function voceScelta(lingua = LINGUA) {
48
+ try { return localStorage.getItem(chiave(lingua)) || null; } catch { return null; }
49
+ }
50
+
51
+ export function scegliVoce(lingua, id) {
52
+ try {
53
+ if (id) localStorage.setItem(chiave(lingua), id);
54
+ else localStorage.removeItem(chiave(lingua));
55
+ } catch { /* senza memoria vale la predefinita */ }
56
+ }
57
+
58
+ /* L'mp3 del testo letto dalla voce scelta (o dalla predefinita della lingua),
59
+ * come URL da dare a un <audio>. L'errore porta il messaggio del contenitore. */
60
+ export async function parla(testo, { lingua = LINGUA, voce = voceScelta(lingua), pagina = "" } = {}) {
61
+ const r = await fetch(rotta("ai/voci"), {
62
+ method: "POST", headers: { "content-type": "application/json" },
63
+ body: JSON.stringify({ testo, lingua, ...(voce ? { voce } : {}), ...(pagina ? { pagina } : {}) }),
64
+ });
65
+ if (!r.ok || !(r.headers.get("content-type") || "").includes("audio")) {
66
+ let j = {};
67
+ try { j = await r.json(); } catch { /* non JSON */ }
68
+ throw new Error(j.errore || `ai/voci: ${r.status}`);
69
+ }
70
+ return URL.createObjectURL(await r.blob());
71
+ }
72
+
73
+ /* La scelta della voce, nella home del progetto. Si monta in un contenitore
74
+ * qualunque (`dove`: selettore o elemento); resta vuoto e nascosto se il
75
+ * contenitore Analytics non fa la voce. `lingue`: quelle in cui il progetto ha
76
+ * pagine con il commento (di default tutte quelle del catalogo). */
77
+ export async function montaSceltaVoce(dove, { lingue: soloLingue } = {}) {
78
+ const el = typeof dove === "string" ? document.querySelector(dove) : dove;
79
+ if (!el) return null;
80
+ el.hidden = true;
81
+ const cat = await catalogoVoci();
82
+ if (!cat) return null;
83
+
84
+ // prima la lingua della pagina, poi le altre
85
+ const lingue = Object.keys(cat.lingue)
86
+ .filter((l) => !soloLingue || soloLingue.includes(l))
87
+ .sort((a, b) => (a === LINGUA ? -1 : b === LINGUA ? 1 : 0));
88
+ if (!lingue.length) return null;
89
+ el.classList.add("scelta-voce");
90
+ el.innerHTML = `
91
+ <h3>${t("La voce del commento")}</h3>
92
+ <p class="nota">${t("Chi racconta le dashboard nel commento guidato, per ogni lingua. La scelta resta in questo browser.")}</p>
93
+ ${lingue.map((l) => {
94
+ const scelta = voceScelta(l) || cat.predefinite?.[l];
95
+ return `<div class="riga-voce" data-lingua="${esc(l)}">
96
+ <label for="voce-${esc(l)}">${esc(NOMI_LINGUE[l] || l)}</label>
97
+ <select id="voce-${esc(l)}">${cat.lingue[l].map((v) => `<option value="${esc(v.id)}"${v.id === scelta ? " selected" : ""}>${esc(v.nome)} · ${esc(v.descrizione?.[LINGUA] || v.descrizione?.it || "")}</option>`).join("")}</select>
98
+ <button type="button" data-ascolta="${esc(l)}">${t("Ascolta")}</button>
99
+ <span class="stato-voce" role="status"></span>
100
+ </div>`;
101
+ }).join("")}`;
102
+ el.hidden = false;
103
+
104
+ const audio = new Audio();
105
+ let corrente = null;
106
+ for (const riga of el.querySelectorAll(".riga-voce")) {
107
+ const l = riga.dataset.lingua;
108
+ const sel = riga.querySelector("select");
109
+ const stato = riga.querySelector(".stato-voce");
110
+ sel.addEventListener("change", () => {
111
+ // la predefinita non si scrive: se il catalogo cambia, chi non ha scelto la segue
112
+ scegliVoce(l, sel.value === cat.predefinite?.[l] ? null : sel.value);
113
+ stato.textContent = t("salvata");
114
+ });
115
+ riga.querySelector("[data-ascolta]").addEventListener("click", async () => {
116
+ const v = cat.lingue[l].find((x) => x.id === sel.value);
117
+ audio.pause();
118
+ stato.textContent = t("preparo…");
119
+ try {
120
+ const url = await parla(PROVA[l]?.(v?.nome || "") || v?.nome || "", { lingua: l, voce: sel.value, pagina: "home" });
121
+ if (corrente) URL.revokeObjectURL(corrente);
122
+ corrente = url;
123
+ audio.src = url;
124
+ await audio.play();
125
+ stato.textContent = "";
126
+ } catch (e) {
127
+ stato.textContent = t("voce non disponibile: {errore}", { errore: e.message || e });
128
+ }
129
+ });
130
+ }
131
+ return el;
132
+ }
@@ -1,39 +1,18 @@
1
+ <!-- noeva-analytics-cartella:inizio — blocco della cartella consegnata, lo riscrive l'aggiornamento di NOEVA -->
1
2
  # NOEVA Analytics · {{cliente}}
2
3
 
3
- Questa cartella serve a interrogare i dati di {{cliente}} con Claude. Chi la usa sono gli
4
- analisti di {{cliente}}: rispondi in italiano, con numeri verificabili, senza gergo tecnico se
5
- non lo chiedono.
4
+ Questa cartella collega Claude ai dati di {{cliente}}, su questo PC. Come si risponde a
5
+ una domanda sui dati lo dice il plugin `noeva-analytics` (skill `analisi`,
6
+ `/noeva-analytics:analisi`): se in questa sessione il plugin non c'è, dillo all'analista,
7
+ che lo fa installare a 4D. Qui ci sono solo il collegamento e le regole di sicurezza.
6
8
 
7
- ## Da dove vengono i dati
9
+ ## Il collegamento
8
10
 
9
11
  Il server MCP **{{server}}** legge in sola lettura il database di produzione: {{cosa}}.
10
- È il modello semantico NOEVA: tabelle con nomi leggibili (`fatture_vendita`, `versamenti`,
11
- `giacenze`, …) e le regole di business già scritte dentro o accanto.
12
-
13
12
  Server SQL {{host}}, raggiungibile solo dalla rete di {{cliente}} (o in VPN).
14
13
 
15
- ## Come si risponde a una domanda sui dati
16
-
17
- 1. A inizio sessione chiama `get_instructions`: sono le regole di business (quali documenti
18
- sono fatturato, quale data usare, i segni degli importi, le regole P1-P14 della produzione
19
- e M1-M10 del magazzino). Valgono più di quello che sembra ovvio dai nomi delle colonne.
20
- 2. `list_models` e `describe_model` per le tabelle e le colonne; `recall_queries` per le
21
- domande simili già risolte.
22
- 3. Scrivi la SQL sul modello, validala con `dry_run`, eseguila con `run_sql`.
23
- 4. Aggrega nel database (SUM, COUNT, GROUP BY) invece di scaricare righe, e metti sempre
24
- un `limit`: è il database di produzione, in orario di lavoro lo usa anche l'ERP.
25
- 5. Nella risposta di' periodo, filtri e regole applicate. Se l'analista lo chiede, mostra
26
- la query.
27
-
28
- ## Cosa non fare
29
-
30
- - Non inventare né stimare cifre. Se una query fallisce o un dato manca, dillo.
31
- - Non collegarti al database per altre strade (sqlcmd, script, ODBC): solo {{server}}.
32
- - Non scrivere mai la password in chat, in un file o in un comando. Sta nella variabile
33
- d'ambiente `{{password_env}}` dell'utente Windows e la legge solo il server.
34
- - Non modificare la cartella `noeva/`: è il motore (eseguibile, modello, regole). Le regole
35
- si leggono in `noeva/modello/knowledge/rules/`, si cambiano solo con un aggiornamento
36
- di NOEVA.
14
+ Ripiego offline (snapshot delle dashboard): {{server_snapshot}}. È spento; si accende da
15
+ `/mcp` solo se l'analista lo chiede.
37
16
 
38
17
  ## Se {{server}} non risponde
39
18
 
@@ -48,5 +27,12 @@ Dice cosa manca: la password (`imposta-password.ps1`), il driver "ODBC Driver 18
48
27
  Server", la rete verso {{host}}. Spiegalo all'analista con le istruzioni di `LEGGIMI.md`.
49
28
  Dopo aver impostato la password bisogna chiudere e riaprire Claude Code.
50
29
 
51
- Ripiego offline (snapshot delle dashboard): {{server_snapshot}}. È spento; si accende da
52
- `/mcp` solo se l'analista lo chiede, e le risposte devono dire che il dato è dello snapshot.
30
+ ## Sicurezza
31
+
32
+ - Ai dati si arriva solo attraverso {{server}}: niente sqlcmd, script, ODBC, né letture
33
+ dirette dei file sotto `noeva/`.
34
+ - La password non va mai in chat, in un file o in un comando. Sta nella variabile
35
+ d'ambiente `{{password_env}}` dell'utente Windows e la legge solo il server.
36
+ - La cartella `noeva/` è il motore (eseguibile, modello, regole): si legge, non si
37
+ modifica. Cambia solo con un aggiornamento di NOEVA.
38
+ <!-- noeva-analytics-cartella:fine -->
@@ -23,7 +23,8 @@ Scrive in dist/mcp-locale/:
23
23
  noeva-<id>/ LA CARTELLA DA CONSEGNARE
24
24
  .mcp.json i due server: noeva-<id> (vivo) e noeva-<id>-snapshot
25
25
  .claude/ settings.json (server abilitati, permessi)
26
- CLAUDE.md le istruzioni per Claude (cartella/CLAUDE.md, compilato)
26
+ CLAUDE.md collegamento e sicurezza per Claude, fra marcatori (cartella/CLAUDE.md;
27
+ il metodo sta nel plugin noeva-analytics)
27
28
  LEGGIMI.md l'installazione, per chi installa
28
29
  imposta-password.ps1 chiede la password e la mette nella variabile dell'utente
29
30
  installa.ps1 installa o aggiorna la cartella passo per passo, con l'esito finale
@@ -100,7 +101,8 @@ def pyproject(dest: pathlib.Path, nome: str, versione: str, deps: list[str]) ->
100
101
 
101
102
 
102
103
  # --- il modello dello snapshot -------------------------------------------------
103
- def modello_snapshot(sorgente: pathlib.Path, dest: pathlib.Path, catalogo: str, manifesto: dict) -> list[dict]:
104
+ def modello_snapshot(sorgente: pathlib.Path, dest: pathlib.Path, catalogo: str, manifesto: dict,
105
+ conf: dict) -> list[dict]:
104
106
  """Un progetto Wren su DuckDB dallo snapshot delle dashboard (ADR 0003)."""
105
107
  conf = yaml.safe_load((sorgente / "snapshot.yml").read_text(encoding="utf-8"))
106
108
  estrazioni = {e["nome"]: e for e in conf.get("estrazioni", [])}
@@ -133,6 +135,7 @@ def modello_snapshot(sorgente: pathlib.Path, dest: pathlib.Path, catalogo: str,
133
135
  shutil.copy2(sorgente / "knowledge" / "knowledge.yml", k / "knowledge.yml")
134
136
  for f in sorted((sorgente / "knowledge" / "rules").glob("*.md")):
135
137
  shutil.copy2(f, k / "rules" / f.name)
138
+ scheda.scrivi(sorgente, k / "rules", conf)
136
139
  date = [f"- {manifesto.get('data_del_dato')}: vendite, rete, clienti, agenti (DWH)"]
137
140
  for g, info in (manifesto.get("gruppi") or {}).items():
138
141
  date.append(f"- {info.get('data_del_dato')}: {', '.join(info.get('estrazioni', []))} (gruppo {g})")
@@ -172,6 +175,10 @@ fattura, un movimento, un dato dopo le date sopra), dirlo invece di stimarlo.
172
175
  sys.path.insert(0, str(QUI.parent / "modello"))
173
176
  from consegna import leggi_config # noqa: E402
174
177
  from consegna import modello_di_consegna as modello_vivo # noqa: E402
178
+ # Le istruzioni di initialize sono quelle che il gateway da' per lo stesso progetto
179
+ # (lib/modello/istruzioni.json, NW-1286); la scheda e' la stessa del modello pubblicato.
180
+ from istruzioni import istruzioni_snapshot, istruzioni_vivo # noqa: E402
181
+ import scheda # noqa: E402
175
182
 
176
183
 
177
184
  def progetto(arg: str, config: str | None = None) -> tuple[pathlib.Path, str, dict, pathlib.Path]:
@@ -253,7 +260,7 @@ def argomenti_pyinstaller(nome: str, build: pathlib.Path) -> list:
253
260
  # Il modello si cerca anche nel repository e su R2 (server/modello_locale.py),
254
261
  # e dal repository si compila con le trasformazioni di lib/modello/consegna.py.
255
262
  "--paths", QUI / "server", "--paths", QUI.parent / "modello",
256
- "--hidden-import", "modello_locale", "--hidden-import", "consegna",
263
+ "--hidden-import", "modello_locale", "--hidden-import", "consegna", "--hidden-import", "scheda",
257
264
  QUI / "server" / "noeva_mcp.py",
258
265
  ]
259
266
 
@@ -280,22 +287,6 @@ def compila(modello: str, valori: dict) -> str:
280
287
  return testo
281
288
 
282
289
 
283
- def istruzioni_snapshot(cliente: str, manifesto: dict) -> str:
284
- return (
285
- f"NOEVA Analytics per {cliente}, SNAPSHOT delle dashboard (DWH al {manifesto.get('data_del_dato')}). "
286
- "Prima di scrivere SQL leggi get_instructions: la regola 00-snapshot dice quali tabelle esistono e "
287
- "a che data e' il dato. Dillo sempre nella risposta: e' il ripiego offline, non il database vivo."
288
- )
289
-
290
-
291
- def istruzioni_vivo(cliente: str, s: dict) -> str:
292
- return (
293
- f"NOEVA Analytics per {cliente}, dal vivo sul database di produzione ({s.get('cosa', '')}), in sola "
294
- "lettura. Prima di scrivere SQL leggi get_instructions e describe_model: le regole di business stanno "
295
- "li'. Valida con dry_run, poi run_sql con un limit; aggrega nel database invece di scaricare righe."
296
- )
297
-
298
-
299
290
  def main(argv: list[str]) -> int:
300
291
  ap = argparse.ArgumentParser()
301
292
  ap.add_argument("progetto", help="project-<id> del monorepo, o il repository del progetto (con analytics.json)")
@@ -346,7 +337,7 @@ def main(argv: list[str]) -> int:
346
337
  sorgenti = {}
347
338
  for k, s in vive.items():
348
339
  rel = "modello" if len(vive) == 1 else f"modello-{k}"
349
- conta = modello_vivo(sorgente, noeva / rel, s)
340
+ conta = modello_vivo(sorgente, noeva / rel, s, conf)
350
341
  print(f" {k}: {conta['rinominati']} riferimenti rinominati, {conta['catalogo']} modelli col catalogo "
351
342
  f"{s.get('catalogo_senza_nome') or '-'}")
352
343
  uv_run(amb, "wren", "context", "build", "--path", noeva / rel)
@@ -354,13 +345,13 @@ def main(argv: list[str]) -> int:
354
345
  "tipo": "mssql", "server": s["server"], "cosa": s.get("cosa", ""), "progetto": rel,
355
346
  **{c: s[c] for c in ("host", "porta", "database", "utente", "password_env", "driver", "opzioni")
356
347
  if c in s},
357
- "istruzioni": istruzioni_vivo(cliente, s),
348
+ "istruzioni": istruzioni_vivo(ident),
358
349
  "verifiche": s.get("verifiche") or {},
359
350
  # Il modello aggiornato dal contenitore, se il progetto lo pubblica (ADR 0005, punto 6).
360
351
  **({"modello_r2": {"progetto": ident, **conf["modello_r2"]}} if conf.get("modello_r2") else {}),
361
352
  }
362
353
  if con_snapshot:
363
- modelli = modello_snapshot(sorgente, noeva / "snapshot" / "modello", ident, manifesto)
354
+ modelli = modello_snapshot(sorgente, noeva / "snapshot" / "modello", ident, manifesto, conf)
364
355
  costruisci_duckdb(amb, noeva / "snapshot" / "dati" / f"{ident}.duckdb",
365
356
  sorgente / "apps" / "dati", [m["name"] for m in modelli])
366
357
  uv_run(amb, "wren", "context", "build", "--path", noeva / "snapshot" / "modello")
@@ -368,7 +359,7 @@ def main(argv: list[str]) -> int:
368
359
  sorgenti["snapshot"] = {
369
360
  "tipo": "duckdb", "server": sn.get("server", f"{nome}-snapshot"),
370
361
  "cosa": "lo snapshot delle dashboard, offline", "progetto": "snapshot/modello",
371
- "dati": "snapshot/dati", "istruzioni": istruzioni_snapshot(cliente, manifesto),
362
+ "dati": "snapshot/dati", "istruzioni": istruzioni_snapshot(ident, manifesto.get("data_del_dato")),
372
363
  "verifiche": sn.get("verifiche") or {},
373
364
  }
374
365
  (noeva / "noeva.json").write_text(json.dumps({"nome": nome, "sorgenti": sorgenti},
@@ -15,6 +15,9 @@ Lo usano in tre (ADR 0005, punto 6), e per questo sta in un modulo solo:
15
15
  - il servizio locale (lib/mcp-locale/server/noeva_mcp.py), che quando Claude Code
16
16
  e' aperto nel repository compila il modello che sta li', non quello pubblicato.
17
17
 
18
+ E nel modello consegnato scrive la scheda del progetto, knowledge/rules/00-progetto.md
19
+ (scheda.py, NW-1286): generata qui, arriva uguale per tutte e tre le strade.
20
+
18
21
  Le trasformazioni:
19
22
 
20
23
  rinomina: {erp_restore: erp_prod} <db>.dbo. nella SQL dei modelli
@@ -30,6 +33,8 @@ import shutil
30
33
 
31
34
  import yaml
32
35
 
36
+ import scheda
37
+
33
38
  # Cosa del repository fa parte del modello: il resto (apps/, scripts/, docs/) no.
34
39
  FILE_MODELLO = ["wren_project.yml", "relationships.yml", "models", "knowledge"]
35
40
  # Le chiavi delle trasformazioni: il resto della configurazione di consegna
@@ -70,9 +75,12 @@ def leggi_consegna(radice: pathlib.Path) -> dict:
70
75
  return trasformazioni(leggi_config(radice))
71
76
 
72
77
 
73
- def modello_di_consegna(sorgente: pathlib.Path, dest: pathlib.Path, conf: dict) -> dict:
78
+ def modello_di_consegna(sorgente: pathlib.Path, dest: pathlib.Path, conf: dict,
79
+ config: dict | None = None) -> dict:
74
80
  """Scrive in `dest` il modello di `sorgente` con le trasformazioni di `conf`.
75
81
 
82
+ `config` e' la configurazione intera (consegna.yml, default quella di
83
+ `sorgente`): la scheda ne legge cliente, sorgenti e contatto.
76
84
  Restituisce i conteggi delle trasformazioni, che chi costruisce stampa."""
77
85
  rinomina = conf.get("rinomina") or {}
78
86
  senza_nome = conf.get("catalogo_senza_nome")
@@ -119,6 +127,7 @@ def modello_di_consegna(sorgente: pathlib.Path, dest: pathlib.Path, conf: dict)
119
127
  if (k / cartella).is_dir():
120
128
  shutil.copytree(k / cartella, dest / "knowledge" / cartella,
121
129
  ignore=shutil.ignore_patterns(".gitkeep"))
130
+ scheda.scrivi(sorgente, dest / "knowledge" / "rules", config)
122
131
  return conta
123
132
 
124
133
 
@@ -0,0 +1,14 @@
1
+ {
2
+ "_commento": [
3
+ "Le istruzioni che il server MCP di un progetto da' all'initialize, uguali in locale",
4
+ "(lib/mcp-locale, le scrive costruisci.py in noeva.json) e dal gateway (lib/gateway, il",
5
+ "Worker riscrive la risposta di initialize). Una fonte sola, letta da istruzioni.py e da",
6
+ "istruzioni.js: i segnaposto sono {progetto} (l'id del registro e di analytics.json) e,",
7
+ "per lo snapshot, {data} (la data del dato). Cio' che e' vero del progetto (cliente,",
8
+ "cosa coprono i dati, dashboard, contatto) sta nella scheda knowledge/rules/00-progetto.md,",
9
+ "non qui (NW-1261)."
10
+ ],
11
+ "nome": "noeva-{progetto}",
12
+ "vivo": "NOEVA Analytics, progetto {progetto}: i dati del cliente dal vivo, in sola lettura. Prima di scrivere SQL leggi get_instructions: la regola 00-progetto dice di chi sono i dati, cosa coprono, dove stanno le dashboard e chi e' il contatto in 4D; le altre regole e describe_model dicono cosa significano i numeri. Valida con dry_run, poi run_sql con un limit; aggrega nel database invece di scaricare righe.",
13
+ "snapshot": "NOEVA Analytics, progetto {progetto}: SNAPSHOT delle dashboard (dato al {data}), il ripiego offline, non il database vivo. Prima di scrivere SQL leggi get_instructions: la regola 00-snapshot dice quali tabelle esistono e a che data e' il dato, la 00-progetto di chi sono i dati e dove stanno le dashboard. Dillo sempre nella risposta: e' il ripiego offline."
14
+ }
@@ -0,0 +1,48 @@
1
+ """Le istruzioni di initialize del server MCP di un progetto, da istruzioni.json (NW-1286).
2
+
3
+ Il testo sta in istruzioni.json, una fonte sola per i due trasporti: qui lo legge
4
+ lib/mcp-locale/costruisci.py, che lo scrive in noeva.json per il servizio locale;
5
+ lib/gateway/istruzioni.js lo legge per il Worker, che riscrive la risposta di
6
+ initialize dei server Wren dietro il gateway. lib/gateway/test/istruzioni.test.js
7
+ prova che i due escono uguali.
8
+
9
+ python3 lib/modello/istruzioni.py <progetto> [vivo|snapshot] [--data <data>]
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import pathlib
15
+ import sys
16
+
17
+ TESTI = json.loads((pathlib.Path(__file__).resolve().parent / "istruzioni.json").read_text(encoding="utf-8"))
18
+
19
+
20
+ def _riempi(modello: str, **valori: str) -> str:
21
+ for k, v in valori.items():
22
+ modello = modello.replace("{" + k + "}", str(v))
23
+ return modello
24
+
25
+
26
+ def nome_server(progetto: str) -> str:
27
+ return _riempi(TESTI["nome"], progetto=progetto)
28
+
29
+
30
+ def istruzioni_vivo(progetto: str) -> str:
31
+ return _riempi(TESTI["vivo"], progetto=progetto)
32
+
33
+
34
+ def istruzioni_snapshot(progetto: str, data: str | None) -> str:
35
+ return _riempi(TESTI["snapshot"], progetto=progetto, data=data or "l'ultimo aggiornamento delle dashboard")
36
+
37
+
38
+ if __name__ == "__main__":
39
+ import argparse
40
+
41
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
42
+ ap.add_argument("progetto")
43
+ ap.add_argument("modo", nargs="?", default="vivo", choices=["vivo", "snapshot"])
44
+ ap.add_argument("--data")
45
+ a = ap.parse_args()
46
+ testo = istruzioni_vivo(a.progetto) if a.modo == "vivo" else istruzioni_snapshot(a.progetto, a.data)
47
+ json.dump({"nome": nome_server(a.progetto), "istruzioni": testo}, sys.stdout, ensure_ascii=False)
48
+ sys.stdout.write("\n")