@noeva/analytics-core 0.3.2 → 0.4.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 +41 -0
- package/contenuto/assets/agenti.js +27 -10
- package/contenuto/assets/base.css +23 -0
- package/contenuto/assets/chat.js +13 -7
- package/contenuto/assets/commento.js +52 -15
- package/contenuto/assets/risposte.js +166 -0
- package/contenuto/assets/tema.js +4 -1
- package/contenuto/assets/vista.js +252 -0
- package/contenuto/assets/voce.js +127 -0
- package/contenuto/lib/mcp-locale/cartella/CLAUDE.md +17 -31
- package/contenuto/lib/mcp-locale/costruisci.py +14 -23
- package/contenuto/lib/modello/consegna.py +10 -1
- package/contenuto/lib/modello/istruzioni.json +14 -0
- package/contenuto/lib/modello/istruzioni.py +48 -0
- package/contenuto/lib/modello/pubblica.py +8 -2
- package/contenuto/lib/modello/scheda.py +146 -0
- package/contenuto/lib/snapshot/ai.py +10 -2
- package/contenuto/lib/snapshot/esporta.py +4 -1
- package/contenuto/lib/snapshot/manifesto.py +23 -1
- package/core.js +11 -0
- package/modello/contenitore/Dockerfile +8 -1
- package/modello/contenitore/correggi-oracle.py +39 -0
- package/package.json +1 -1
|
@@ -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,127 @@
|
|
|
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
|
+
* await voceOnline() true se il contenitore fa la voce
|
|
13
|
+
* voceScelta("it") l'id scelto per la lingua, o null
|
|
14
|
+
* await parla(testo, { pagina }) l'URL (blob:) dell'mp3, dalla voce scelta
|
|
15
|
+
*
|
|
16
|
+
* La scelta e' di chi guarda: resta nel suo browser, per progetto e per lingua
|
|
17
|
+
* (`<progetto>-voce-it`, `<progetto>-voce-en`), come la lingua ricordata.
|
|
18
|
+
*/
|
|
19
|
+
import { rotta } from "./servizio.js";
|
|
20
|
+
import { t, LINGUA } from "./lingua.js";
|
|
21
|
+
import { PROGETTO } from "./progetto.js";
|
|
22
|
+
|
|
23
|
+
const NOMI_LINGUE = { it: "Italiano", en: "English" };
|
|
24
|
+
const PROVA = {
|
|
25
|
+
it: (nome) => `Buongiorno, sono ${nome}. Racconterò questa dashboard tappa per tappa.`,
|
|
26
|
+
en: (nome) => `Hello, I'm ${nome}. I'll walk you through this dashboard step by step.`,
|
|
27
|
+
};
|
|
28
|
+
const chiave = (lingua) => `${PROGETTO.id}-voce-${lingua}`;
|
|
29
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
30
|
+
|
|
31
|
+
let _catalogo = null;
|
|
32
|
+
/* Il catalogo del contenitore, o null dove la voce online non c'e' (sul Mac
|
|
33
|
+
* con il solo serve-app.py, su un server statico, o senza ELEVENLABS_API_KEY). */
|
|
34
|
+
export function catalogoVoci() {
|
|
35
|
+
_catalogo ||= fetch(rotta("ai/voci"), { cache: "no-cache" })
|
|
36
|
+
.then(async (r) => (r.ok && (r.headers.get("content-type") || "").includes("json") ? r.json() : null))
|
|
37
|
+
.then((c) => (c && c.lingue ? c : null))
|
|
38
|
+
.catch(() => null);
|
|
39
|
+
return _catalogo;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function voceOnline() {
|
|
43
|
+
return Boolean(await catalogoVoci());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function voceScelta(lingua = LINGUA) {
|
|
47
|
+
try { return localStorage.getItem(chiave(lingua)) || null; } catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function scegliVoce(lingua, id) {
|
|
51
|
+
try {
|
|
52
|
+
if (id) localStorage.setItem(chiave(lingua), id);
|
|
53
|
+
else localStorage.removeItem(chiave(lingua));
|
|
54
|
+
} catch { /* senza memoria vale la predefinita */ }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* L'mp3 del testo letto dalla voce scelta (o dalla predefinita della lingua),
|
|
58
|
+
* come URL da dare a un <audio>. L'errore porta il messaggio del contenitore. */
|
|
59
|
+
export async function parla(testo, { lingua = LINGUA, voce = voceScelta(lingua), pagina = "" } = {}) {
|
|
60
|
+
const r = await fetch(rotta("ai/voci"), {
|
|
61
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
62
|
+
body: JSON.stringify({ testo, lingua, ...(voce ? { voce } : {}), ...(pagina ? { pagina } : {}) }),
|
|
63
|
+
});
|
|
64
|
+
if (!r.ok || !(r.headers.get("content-type") || "").includes("audio")) {
|
|
65
|
+
let j = {};
|
|
66
|
+
try { j = await r.json(); } catch { /* non JSON */ }
|
|
67
|
+
throw new Error(j.errore || `ai/voci: ${r.status}`);
|
|
68
|
+
}
|
|
69
|
+
return URL.createObjectURL(await r.blob());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* La scelta della voce, nella home del progetto. Si monta in un contenitore
|
|
73
|
+
* qualunque (`dove`: selettore o elemento); resta vuoto e nascosto se il
|
|
74
|
+
* contenitore Analytics non fa la voce. */
|
|
75
|
+
export async function montaSceltaVoce(dove) {
|
|
76
|
+
const el = typeof dove === "string" ? document.querySelector(dove) : dove;
|
|
77
|
+
if (!el) return null;
|
|
78
|
+
el.hidden = true;
|
|
79
|
+
const cat = await catalogoVoci();
|
|
80
|
+
if (!cat) return null;
|
|
81
|
+
|
|
82
|
+
// prima la lingua della pagina, poi le altre
|
|
83
|
+
const lingue = Object.keys(cat.lingue).sort((a, b) => (a === LINGUA ? -1 : b === LINGUA ? 1 : 0));
|
|
84
|
+
el.classList.add("scelta-voce");
|
|
85
|
+
el.innerHTML = `
|
|
86
|
+
<h3>${t("La voce del commento")}</h3>
|
|
87
|
+
<p class="nota">${t("Chi racconta le dashboard nel commento guidato, per ogni lingua. La scelta resta in questo browser.")}</p>
|
|
88
|
+
${lingue.map((l) => {
|
|
89
|
+
const scelta = voceScelta(l) || cat.predefinite?.[l];
|
|
90
|
+
return `<div class="riga-voce" data-lingua="${esc(l)}">
|
|
91
|
+
<label for="voce-${esc(l)}">${esc(NOMI_LINGUE[l] || l)}</label>
|
|
92
|
+
<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>
|
|
93
|
+
<button type="button" data-ascolta="${esc(l)}">${t("Ascolta")}</button>
|
|
94
|
+
<span class="stato-voce" role="status"></span>
|
|
95
|
+
</div>`;
|
|
96
|
+
}).join("")}`;
|
|
97
|
+
el.hidden = false;
|
|
98
|
+
|
|
99
|
+
const audio = new Audio();
|
|
100
|
+
let corrente = null;
|
|
101
|
+
for (const riga of el.querySelectorAll(".riga-voce")) {
|
|
102
|
+
const l = riga.dataset.lingua;
|
|
103
|
+
const sel = riga.querySelector("select");
|
|
104
|
+
const stato = riga.querySelector(".stato-voce");
|
|
105
|
+
sel.addEventListener("change", () => {
|
|
106
|
+
// la predefinita non si scrive: se il catalogo cambia, chi non ha scelto la segue
|
|
107
|
+
scegliVoce(l, sel.value === cat.predefinite?.[l] ? null : sel.value);
|
|
108
|
+
stato.textContent = t("salvata");
|
|
109
|
+
});
|
|
110
|
+
riga.querySelector("[data-ascolta]").addEventListener("click", async () => {
|
|
111
|
+
const v = cat.lingue[l].find((x) => x.id === sel.value);
|
|
112
|
+
audio.pause();
|
|
113
|
+
stato.textContent = t("preparo…");
|
|
114
|
+
try {
|
|
115
|
+
const url = await parla(PROVA[l]?.(v?.nome || "") || v?.nome || "", { lingua: l, voce: sel.value, pagina: "home" });
|
|
116
|
+
if (corrente) URL.revokeObjectURL(corrente);
|
|
117
|
+
corrente = url;
|
|
118
|
+
audio.src = url;
|
|
119
|
+
await audio.play();
|
|
120
|
+
stato.textContent = "";
|
|
121
|
+
} catch (e) {
|
|
122
|
+
stato.textContent = t("voce non disponibile: {errore}", { errore: e.message || e });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return el;
|
|
127
|
+
}
|
|
@@ -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
|
|
4
|
-
|
|
5
|
-
non
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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")
|