@noeva/analytics-core 0.1.0 → 0.3.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 +35 -0
- package/README.md +4 -0
- package/cli.js +34 -4
- package/contenuto/lib/mcp-locale/ambiente/.python-version +1 -0
- package/contenuto/lib/mcp-locale/ambiente/pyproject.toml +23 -0
- package/contenuto/lib/mcp-locale/ambiente/uv.lock +1103 -0
- package/contenuto/lib/mcp-locale/cartella/CLAUDE.md +52 -0
- package/contenuto/lib/mcp-locale/cartella/LEGGIMI.md +163 -0
- package/contenuto/lib/mcp-locale/cartella/imposta-password.ps1 +40 -0
- package/contenuto/lib/mcp-locale/cartella/installa.ps1 +286 -0
- package/contenuto/lib/mcp-locale/costruisci.py +495 -0
- package/contenuto/lib/mcp-locale/prova.py +93 -0
- package/contenuto/lib/mcp-locale/server/modello_locale.py +167 -0
- package/contenuto/lib/mcp-locale/server/noeva_mcp.py +203 -0
- package/contenuto/lib/modello/consegna.py +134 -0
- package/contenuto/lib/modello/pubblica.py +131 -0
- package/contenuto/lib/snapshot/ascolta.py +213 -0
- package/contenuto/lib/snapshot/impacchetta.py +184 -0
- package/contenuto/lib/snapshot/noeva_snapshot.py +107 -0
- package/contenuto/lib/snapshot/pubblica.py +35 -15
- package/contenuto/lib/snapshot/worker/installa.ps1 +340 -0
- package/core.js +18 -2
- package/modello/repository/github/workflows/ci.yml +53 -3
- package/modello/repository/github/workflows/rilascio-windows.yml +207 -0
- package/modello/repository/gitignore +2 -1
- package/package.json +2 -1
- package/pubblica.js +205 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Costruisce cio' che si consegna al cliente per usare un progetto con Claude sui suoi PC.
|
|
3
|
+
|
|
4
|
+
Spike del 20/10/2026 (docs/spike-mcpb-windows.md). Il cliente lavora con Claude
|
|
5
|
+
Code (CLI o tab Code di Claude Desktop) su una cartella che gli consegniamo al
|
|
6
|
+
posto del repository; il server MCP del progetto e' un eseguibile dentro la
|
|
7
|
+
cartella, che Claude Code avvia da solo via `.mcp.json`. Niente Docker, niente
|
|
8
|
+
Python, niente repository.
|
|
9
|
+
|
|
10
|
+
uv run --with pyyaml python lib/mcp-locale/costruisci.py project-<id>
|
|
11
|
+
uv run --with pyyaml python lib/mcp-locale/costruisci.py project-<id> --mcpb
|
|
12
|
+
uv run --with pyyaml python $(npx analytics percorso)/lib/mcp-locale/costruisci.py .
|
|
13
|
+
|
|
14
|
+
Il progetto e' una cartella del monorepo (project-<id>, con la configurazione in
|
|
15
|
+
lib/mcp-locale/<id>.yml) o il repository di un progetto (ADR 0005): la cartella
|
|
16
|
+
con analytics.json, la configurazione in consegna.yml e l'uscita in dist/ del
|
|
17
|
+
repository. Dal repository gira la CI che rilascia (rilascio-windows.yml): il
|
|
18
|
+
ripiego offline sullo snapshot vuole i dati, che in git non ci sono, e si
|
|
19
|
+
costruisce solo dove i dati ci sono.
|
|
20
|
+
|
|
21
|
+
Scrive in dist/mcp-locale/:
|
|
22
|
+
|
|
23
|
+
noeva-<id>/ LA CARTELLA DA CONSEGNARE
|
|
24
|
+
.mcp.json i due server: noeva-<id> (vivo) e noeva-<id>-snapshot
|
|
25
|
+
.claude/ settings.json (server abilitati, permessi)
|
|
26
|
+
CLAUDE.md le istruzioni per Claude (cartella/CLAUDE.md, compilato)
|
|
27
|
+
LEGGIMI.md l'installazione, per chi installa
|
|
28
|
+
imposta-password.ps1 chiede la password e la mette nella variabile dell'utente
|
|
29
|
+
installa.ps1 installa o aggiorna la cartella passo per passo, con l'esito finale
|
|
30
|
+
noeva/ l'eseguibile (PyInstaller, onedir) e cio' che legge:
|
|
31
|
+
noeva-<id>[.exe], _internal/, noeva.json
|
|
32
|
+
modello/ il modello completo con i nomi dei database di produzione
|
|
33
|
+
snapshot/ il ripiego offline: modello dello snapshot e dati/<id>.duckdb
|
|
34
|
+
noeva-<id>.zip la cartella zippata
|
|
35
|
+
noeva-<id>.mcpb con --mcpb: l'estensione Claude Desktop di tipo uv, solo snapshot
|
|
36
|
+
|
|
37
|
+
L'eseguibile si costruisce per il sistema su cui gira il builder: su macOS esce
|
|
38
|
+
un eseguibile macOS (serve a provare la ricetta), quello per i PC del cliente
|
|
39
|
+
va costruito su Windows (PyInstaller non fa cross-compilazione; LEGGIMI del
|
|
40
|
+
builder: docs/spike-mcpb-windows.md, «Costruire su Windows»). Il resto della
|
|
41
|
+
cartella e' lo stesso.
|
|
42
|
+
|
|
43
|
+
Prima di zippare prova l'eseguibile come Claude Code: --verifica sullo
|
|
44
|
+
snapshot, poi initialize, list_tools e un COUNT(*) per tabella via stdio
|
|
45
|
+
(prova.py).
|
|
46
|
+
"""
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import argparse
|
|
50
|
+
import datetime as dt
|
|
51
|
+
import json
|
|
52
|
+
import os
|
|
53
|
+
import pathlib
|
|
54
|
+
import shutil
|
|
55
|
+
import subprocess
|
|
56
|
+
import sys
|
|
57
|
+
|
|
58
|
+
import yaml
|
|
59
|
+
|
|
60
|
+
RADICE = pathlib.Path(__file__).resolve().parents[2]
|
|
61
|
+
QUI = pathlib.Path(__file__).resolve().parent
|
|
62
|
+
# Nel repository di un progetto diventa <repository>/dist/mcp-locale (progetto()).
|
|
63
|
+
USCITA = RADICE / "dist" / "mcp-locale"
|
|
64
|
+
|
|
65
|
+
# Le dipendenze dell'estensione .mcpb (solo snapshot, niente pyodbc); quelle
|
|
66
|
+
# dell'eseguibile stanno in ambiente/pyproject.toml.
|
|
67
|
+
DIPENDENZE = [
|
|
68
|
+
"wrenai[mcp]==0.15.0",
|
|
69
|
+
"wren-core-py==0.8.0",
|
|
70
|
+
"mcp[cli]>=1.19,<2",
|
|
71
|
+
"duckdb==1.5.6",
|
|
72
|
+
]
|
|
73
|
+
PYTHON = "3.11"
|
|
74
|
+
MCPB = "@anthropic-ai/mcpb@2.1.2"
|
|
75
|
+
WINDOWS = sys.platform == "win32"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def esegui(cmd: list, cwd: pathlib.Path | None = None, **kw) -> subprocess.CompletedProcess:
|
|
79
|
+
print(" $", " ".join(str(c) for c in cmd), flush=True)
|
|
80
|
+
return subprocess.run([str(c) for c in cmd], cwd=cwd, check=True, **kw)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def dump_yaml(dati, path: pathlib.Path) -> None:
|
|
84
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
path.write_text(yaml.safe_dump(dati, allow_unicode=True, sort_keys=False, width=100), encoding="utf-8")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def pyproject(dest: pathlib.Path, nome: str, versione: str, deps: list[str]) -> None:
|
|
89
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
(dest / "pyproject.toml").write_text(
|
|
91
|
+
f'[project]\nname = "{nome}"\nversion = "{versione}"\n'
|
|
92
|
+
f'requires-python = "=={PYTHON}.*"\n'
|
|
93
|
+
"dependencies = [\n" + "".join(f' "{d}",\n' for d in deps) + "]\n"
|
|
94
|
+
"\n[tool.uv]\npackage = false\n",
|
|
95
|
+
encoding="utf-8",
|
|
96
|
+
)
|
|
97
|
+
# Claude Desktop installa le estensioni uv con un `uv sync` senza --python:
|
|
98
|
+
# senza questo file uv sceglierebbe il Python piu' recente ammesso.
|
|
99
|
+
(dest / ".python-version").write_text(PYTHON + "\n", encoding="utf-8")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- il modello dello snapshot -------------------------------------------------
|
|
103
|
+
def modello_snapshot(sorgente: pathlib.Path, dest: pathlib.Path, catalogo: str, manifesto: dict) -> list[dict]:
|
|
104
|
+
"""Un progetto Wren su DuckDB dallo snapshot delle dashboard (ADR 0003)."""
|
|
105
|
+
conf = yaml.safe_load((sorgente / "snapshot.yml").read_text(encoding="utf-8"))
|
|
106
|
+
estrazioni = {e["nome"]: e for e in conf.get("estrazioni", [])}
|
|
107
|
+
|
|
108
|
+
dump_yaml({"schema_version": 5, "name": catalogo, "version": "1.0", "catalog": "wren",
|
|
109
|
+
"schema": "public", "data_source": "duckdb"}, dest / "wren_project.yml")
|
|
110
|
+
modelli = []
|
|
111
|
+
for m in manifesto["mdl"]["models"]:
|
|
112
|
+
nome = m["name"]
|
|
113
|
+
e = estrazioni.get(nome, {})
|
|
114
|
+
descr = (e.get("descrizione") or m.get("properties", {}).get("description") or "").strip()
|
|
115
|
+
if e.get("sql"):
|
|
116
|
+
descr += ("\n\nEstratta dal modello semantico completo con questa query "
|
|
117
|
+
"(i nomi sono quelli del modello, non di questo snapshot):\n" + e["sql"].strip())
|
|
118
|
+
modello = {
|
|
119
|
+
"name": nome,
|
|
120
|
+
"table_reference": {"catalog": catalogo, "schema": "main", "table": nome},
|
|
121
|
+
"columns": [{"name": c["name"], "type": c["type"]} for c in m["columns"]],
|
|
122
|
+
"properties": {"description": descr},
|
|
123
|
+
}
|
|
124
|
+
dump_yaml(modello, dest / "models" / nome / "metadata.yml")
|
|
125
|
+
modelli.append(modello)
|
|
126
|
+
dump_yaml({"relationships": manifesto["mdl"].get("relationships") or []}, dest / "relationships.yml")
|
|
127
|
+
|
|
128
|
+
# Le regole valgono anche qui (dicono cosa significano i numeri); le query di
|
|
129
|
+
# knowledge/sql no: sono scritte sul modello completo e qui fallirebbero.
|
|
130
|
+
k = dest / "knowledge"
|
|
131
|
+
(k / "rules").mkdir(parents=True)
|
|
132
|
+
if (sorgente / "knowledge" / "knowledge.yml").is_file():
|
|
133
|
+
shutil.copy2(sorgente / "knowledge" / "knowledge.yml", k / "knowledge.yml")
|
|
134
|
+
for f in sorted((sorgente / "knowledge" / "rules").glob("*.md")):
|
|
135
|
+
shutil.copy2(f, k / "rules" / f.name)
|
|
136
|
+
date = [f"- {manifesto.get('data_del_dato')}: vendite, rete, clienti, agenti (DWH)"]
|
|
137
|
+
for g, info in (manifesto.get("gruppi") or {}).items():
|
|
138
|
+
date.append(f"- {info.get('data_del_dato')}: {', '.join(info.get('estrazioni', []))} (gruppo {g})")
|
|
139
|
+
righe = "\n".join(f"- `{f['nome']}`: {f['righe']:,} righe".replace(",", ".") for f in manifesto["file"])
|
|
140
|
+
(k / "rules" / "00-snapshot.md").write_text(
|
|
141
|
+
f"""# Questo e' uno snapshot, non il database
|
|
142
|
+
|
|
143
|
+
Le tabelle interrogabili sono le estrazioni dello snapshot delle dashboard, non
|
|
144
|
+
le tabelle del modello completo (fatture_vendita, versamenti, giacenze...) di cui
|
|
145
|
+
parlano le altre regole. Le regole restano valide per capire cosa significano i
|
|
146
|
+
numeri; le query vanno scritte sulle tabelle qui sotto. La descrizione di ogni
|
|
147
|
+
tabella riporta la query con cui e' stata estratta dal modello completo: dice
|
|
148
|
+
quali filtri e quali regole sono gia' applicati.
|
|
149
|
+
|
|
150
|
+
Data del dato:
|
|
151
|
+
{chr(10).join(date)}
|
|
152
|
+
|
|
153
|
+
Snapshot generato il {manifesto.get('generato')}.
|
|
154
|
+
|
|
155
|
+
Tabelle:
|
|
156
|
+
{righe}
|
|
157
|
+
|
|
158
|
+
Se una domanda richiede un dettaglio che lo snapshot non ha (una singola
|
|
159
|
+
fattura, un movimento, un dato dopo le date sopra), dirlo invece di stimarlo.
|
|
160
|
+
""",
|
|
161
|
+
encoding="utf-8",
|
|
162
|
+
)
|
|
163
|
+
return modelli
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# --- il modello completo, con i nomi di produzione ------------------------------
|
|
167
|
+
# Le trasformazioni (rinomina, catalogo_senza_nome) stanno in lib/modello/consegna.py:
|
|
168
|
+
# le usano anche il repository del progetto, che pubblica il modello su R2, e il
|
|
169
|
+
# servizio locale, che lo compila dal repository (ADR 0005, punto 6).
|
|
170
|
+
# lib/modello sta accanto a lib/mcp-locale sia nel monorepo sia nel pacchetto
|
|
171
|
+
# @noeva/analytics-core (contenuto/lib/).
|
|
172
|
+
sys.path.insert(0, str(QUI.parent / "modello"))
|
|
173
|
+
from consegna import leggi_config # noqa: E402
|
|
174
|
+
from consegna import modello_di_consegna as modello_vivo # noqa: E402
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def progetto(arg: str, config: str | None = None) -> tuple[pathlib.Path, str, dict, pathlib.Path]:
|
|
178
|
+
"""(sorgente, id, configurazione, uscita) di un progetto del monorepo o di un repository.
|
|
179
|
+
|
|
180
|
+
Un repository si riconosce da analytics.json: l'id viene da li', la
|
|
181
|
+
configurazione da consegna.yml, l'uscita e' la sua dist/."""
|
|
182
|
+
cartella = pathlib.Path(arg).expanduser()
|
|
183
|
+
if (cartella / "analytics.json").is_file():
|
|
184
|
+
sorgente = cartella.resolve()
|
|
185
|
+
ident = json.loads((sorgente / "analytics.json").read_text(encoding="utf-8"))["progetto"]
|
|
186
|
+
conf = yaml.safe_load(pathlib.Path(config).read_text(encoding="utf-8")) if config else leggi_config(sorgente)
|
|
187
|
+
return sorgente, ident, conf, sorgente / "dist" / "mcp-locale"
|
|
188
|
+
sorgente = (RADICE / arg).resolve()
|
|
189
|
+
ident = sorgente.name.removeprefix("project-")
|
|
190
|
+
conf_path = pathlib.Path(config) if config else QUI / f"{ident}.yml"
|
|
191
|
+
conf = yaml.safe_load(conf_path.read_text(encoding="utf-8")) if conf_path.is_file() else {}
|
|
192
|
+
return sorgente, ident, conf, RADICE / "dist" / "mcp-locale"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def relativo(p: pathlib.Path) -> str:
|
|
196
|
+
"""Il percorso da stampare: relativo alla radice del monorepo o del repository, se ci sta sotto."""
|
|
197
|
+
for base in (USCITA.parents[1], RADICE):
|
|
198
|
+
try:
|
|
199
|
+
return str(p.relative_to(base))
|
|
200
|
+
except ValueError:
|
|
201
|
+
pass
|
|
202
|
+
return str(p)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# --- l'ambiente di build ---------------------------------------------------------
|
|
206
|
+
AMBIENTE = QUI / "ambiente" # pyproject.toml + uv.lock committati: le stesse versioni ovunque
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def ambiente() -> pathlib.Path:
|
|
210
|
+
"""La venv con le dipendenze esatte dell'eseguibile, PyInstaller compreso."""
|
|
211
|
+
esegui(["uv", "sync", "--frozen", "--project", AMBIENTE])
|
|
212
|
+
return AMBIENTE
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def uv_run(amb: pathlib.Path, *args) -> None:
|
|
216
|
+
esegui(["uv", "run", "--frozen", "--project", amb, *args], cwd=amb)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def costruisci_duckdb(amb: pathlib.Path, db: pathlib.Path, parquet: pathlib.Path, tabelle: list[str]) -> None:
|
|
220
|
+
# Con la stessa versione di DuckDB che lo leggera': il formato del file la segue.
|
|
221
|
+
script = (
|
|
222
|
+
"import duckdb, sys, pathlib\n"
|
|
223
|
+
"db, cartella = sys.argv[1], pathlib.Path(sys.argv[2])\n"
|
|
224
|
+
"con = duckdb.connect(db)\n"
|
|
225
|
+
"for n in sys.argv[3:]:\n"
|
|
226
|
+
" p = str(cartella / (n + '.parquet')).replace(\"'\", \"''\")\n"
|
|
227
|
+
" con.execute(f'CREATE TABLE \"{n}\" AS SELECT * FROM read_parquet(\\'{p}\\')')\n"
|
|
228
|
+
"con.execute('CHECKPOINT'); con.close()\n"
|
|
229
|
+
)
|
|
230
|
+
db.parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
uv_run(amb, "python", "-c", script, db, parquet, *tabelle)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def argomenti_pyinstaller(nome: str, build: pathlib.Path) -> list:
|
|
235
|
+
return [
|
|
236
|
+
"--noconfirm", "--clean", "--onedir", "--console",
|
|
237
|
+
"--name", nome,
|
|
238
|
+
"--distpath", build / "dist", "--workpath", build / "work", "--specpath", build,
|
|
239
|
+
# wren carica i connettori con importlib (wren/connector/factory.py) e
|
|
240
|
+
# sqlglot i dialetti: PyInstaller non li vede dagli import.
|
|
241
|
+
"--collect-submodules", "wren", "--collect-data", "wren",
|
|
242
|
+
"--collect-submodules", "sqlglot",
|
|
243
|
+
# pyodbc su macOS vuole unixODBC anche solo per essere analizzato: la
|
|
244
|
+
# build sul Mac serve a provare la ricetta sullo snapshot, il vivo no.
|
|
245
|
+
*(["--exclude-module", "pyodbc"] if sys.platform == "darwin" else ["--hidden-import", "pyodbc"]),
|
|
246
|
+
"--copy-metadata", "wrenai", "--copy-metadata", "mcp",
|
|
247
|
+
# memory (lancedb, onnx) non serve: recall ricade sulla ricerca testuale.
|
|
248
|
+
"--exclude-module", "lancedb", "--exclude-module", "onnxruntime",
|
|
249
|
+
"--exclude-module", "tkinter", "--exclude-module", "IPython",
|
|
250
|
+
# Nessuna dipendenza usa pkg_resources, ma se setuptools e' nella venv
|
|
251
|
+
# il suo hook parte all'avvio e muore su jaraco/backports.
|
|
252
|
+
"--exclude-module", "pkg_resources", "--exclude-module", "setuptools",
|
|
253
|
+
# Il modello si cerca anche nel repository e su R2 (server/modello_locale.py),
|
|
254
|
+
# e dal repository si compila con le trasformazioni di lib/modello/consegna.py.
|
|
255
|
+
"--paths", QUI / "server", "--paths", QUI.parent / "modello",
|
|
256
|
+
"--hidden-import", "modello_locale", "--hidden-import", "consegna",
|
|
257
|
+
QUI / "server" / "noeva_mcp.py",
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def costruisci_exe(amb: pathlib.Path | None, nome: str, build: pathlib.Path) -> pathlib.Path:
|
|
262
|
+
"""L'eseguibile onedir: noeva-<id>[.exe] + _internal/.
|
|
263
|
+
|
|
264
|
+
Con l'ambiente uv del builder, oppure (amb None) con l'interprete che sta
|
|
265
|
+
girando: e' --solo-exe, per costruire dove uv non c'e' (il container)."""
|
|
266
|
+
if build.exists():
|
|
267
|
+
shutil.rmtree(build)
|
|
268
|
+
if amb is None:
|
|
269
|
+
esegui([sys.executable, "-m", "PyInstaller", *argomenti_pyinstaller(nome, build)])
|
|
270
|
+
else:
|
|
271
|
+
uv_run(amb, "pyinstaller", *argomenti_pyinstaller(nome, build))
|
|
272
|
+
return build / "dist" / nome
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# --- la cartella da consegnare ---------------------------------------------------
|
|
276
|
+
def compila(modello: str, valori: dict) -> str:
|
|
277
|
+
testo = (QUI / "cartella" / modello).read_text(encoding="utf-8")
|
|
278
|
+
for k, v in valori.items():
|
|
279
|
+
testo = testo.replace("{{" + k + "}}", str(v))
|
|
280
|
+
return testo
|
|
281
|
+
|
|
282
|
+
|
|
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
|
+
def main(argv: list[str]) -> int:
|
|
300
|
+
ap = argparse.ArgumentParser()
|
|
301
|
+
ap.add_argument("progetto", help="project-<id> del monorepo, o il repository del progetto (con analytics.json)")
|
|
302
|
+
ap.add_argument("--config", help="default: lib/mcp-locale/<id>.yml, o consegna.yml del repository")
|
|
303
|
+
ap.add_argument("--con-snapshot", action="store_true", help="anche il ripiego offline sullo snapshot")
|
|
304
|
+
ap.add_argument("--senza-exe", action="store_true", help="non costruire l'eseguibile (solo la cartella)")
|
|
305
|
+
ap.add_argument("--senza-prova", action="store_true", help="non provare l'eseguibile")
|
|
306
|
+
ap.add_argument("--mcpb", action="store_true", help="anche l'estensione Claude Desktop (uv, solo snapshot)")
|
|
307
|
+
ap.add_argument("--versione", default=None)
|
|
308
|
+
ap.add_argument("--solo-exe", metavar="CARTELLA",
|
|
309
|
+
help="solo l'eseguibile, con l'interprete corrente, in CARTELLA/dist/noeva-<id>")
|
|
310
|
+
a = ap.parse_args(argv[1:])
|
|
311
|
+
global USCITA
|
|
312
|
+
sorgente, ident, conf, USCITA = progetto(a.progetto, a.config)
|
|
313
|
+
if a.solo_exe:
|
|
314
|
+
print(costruisci_exe(None, f"noeva-{ident}", pathlib.Path(a.solo_exe).resolve()))
|
|
315
|
+
return 0
|
|
316
|
+
|
|
317
|
+
nome = f"noeva-{ident}"
|
|
318
|
+
cliente = conf.get("cliente") or ident.capitalize()
|
|
319
|
+
oggi = dt.date.today()
|
|
320
|
+
versione = a.versione or f"{oggi.year}.{oggi.month}.{oggi.day}"
|
|
321
|
+
vive = conf.get("sorgenti") or {}
|
|
322
|
+
con_snapshot = a.con_snapshot or a.mcpb or not vive
|
|
323
|
+
# Lo snapshot serve al ripiego offline e a dire nelle istruzioni a che data
|
|
324
|
+
# sono i dati; in un repository appena clonato (la CI) non c'e', e va bene
|
|
325
|
+
# finche' il ripiego non e' chiesto.
|
|
326
|
+
f_snapshot = sorgente / "apps" / "dati" / "snapshot.json"
|
|
327
|
+
manifesto = json.loads(f_snapshot.read_text(encoding="utf-8")) if f_snapshot.is_file() else {}
|
|
328
|
+
if con_snapshot and not manifesto:
|
|
329
|
+
raise SystemExit(f"il ripiego offline vuole lo snapshot ({relativo(f_snapshot)}), che qui non c'e': "
|
|
330
|
+
"si costruisce dove ci sono i dati, o senza --con-snapshot")
|
|
331
|
+
|
|
332
|
+
USCITA.mkdir(parents=True, exist_ok=True)
|
|
333
|
+
amb = ambiente()
|
|
334
|
+
|
|
335
|
+
cartella = USCITA / nome
|
|
336
|
+
print(f"{nome} -> {relativo(cartella)}")
|
|
337
|
+
if cartella.exists():
|
|
338
|
+
shutil.rmtree(cartella)
|
|
339
|
+
noeva = cartella / "noeva"
|
|
340
|
+
if not a.senza_exe:
|
|
341
|
+
shutil.copytree(costruisci_exe(amb, nome, USCITA / "_pyinstaller"), noeva)
|
|
342
|
+
else:
|
|
343
|
+
noeva.mkdir(parents=True)
|
|
344
|
+
|
|
345
|
+
# Le sorgenti: una connessione e un pezzo di modello ciascuna.
|
|
346
|
+
sorgenti = {}
|
|
347
|
+
for k, s in vive.items():
|
|
348
|
+
rel = "modello" if len(vive) == 1 else f"modello-{k}"
|
|
349
|
+
conta = modello_vivo(sorgente, noeva / rel, s)
|
|
350
|
+
print(f" {k}: {conta['rinominati']} riferimenti rinominati, {conta['catalogo']} modelli col catalogo "
|
|
351
|
+
f"{s.get('catalogo_senza_nome') or '-'}")
|
|
352
|
+
uv_run(amb, "wren", "context", "build", "--path", noeva / rel)
|
|
353
|
+
sorgenti[k] = {
|
|
354
|
+
"tipo": "mssql", "server": s["server"], "cosa": s.get("cosa", ""), "progetto": rel,
|
|
355
|
+
**{c: s[c] for c in ("host", "porta", "database", "utente", "password_env", "driver", "opzioni")
|
|
356
|
+
if c in s},
|
|
357
|
+
"istruzioni": istruzioni_vivo(cliente, s),
|
|
358
|
+
"verifiche": s.get("verifiche") or {},
|
|
359
|
+
# Il modello aggiornato dal contenitore, se il progetto lo pubblica (ADR 0005, punto 6).
|
|
360
|
+
**({"modello_r2": {"progetto": ident, **conf["modello_r2"]}} if conf.get("modello_r2") else {}),
|
|
361
|
+
}
|
|
362
|
+
if con_snapshot:
|
|
363
|
+
modelli = modello_snapshot(sorgente, noeva / "snapshot" / "modello", ident, manifesto)
|
|
364
|
+
costruisci_duckdb(amb, noeva / "snapshot" / "dati" / f"{ident}.duckdb",
|
|
365
|
+
sorgente / "apps" / "dati", [m["name"] for m in modelli])
|
|
366
|
+
uv_run(amb, "wren", "context", "build", "--path", noeva / "snapshot" / "modello")
|
|
367
|
+
sn = conf.get("snapshot") or {}
|
|
368
|
+
sorgenti["snapshot"] = {
|
|
369
|
+
"tipo": "duckdb", "server": sn.get("server", f"{nome}-snapshot"),
|
|
370
|
+
"cosa": "lo snapshot delle dashboard, offline", "progetto": "snapshot/modello",
|
|
371
|
+
"dati": "snapshot/dati", "istruzioni": istruzioni_snapshot(cliente, manifesto),
|
|
372
|
+
"verifiche": sn.get("verifiche") or {},
|
|
373
|
+
}
|
|
374
|
+
(noeva / "noeva.json").write_text(json.dumps({"nome": nome, "sorgenti": sorgenti},
|
|
375
|
+
ensure_ascii=False, indent=1), encoding="utf-8")
|
|
376
|
+
|
|
377
|
+
# .mcp.json e .claude/settings.json dalle sorgenti; il resto dai modelli in cartella/.
|
|
378
|
+
exe = f"noeva/{nome}.exe"
|
|
379
|
+
server = {
|
|
380
|
+
s["server"]: {"type": "stdio", "command": exe, "args": ["--sorgente", k], "env": {}}
|
|
381
|
+
for k, s in sorgenti.items()
|
|
382
|
+
}
|
|
383
|
+
(cartella / ".mcp.json").write_text(json.dumps({"mcpServers": server}, indent=2) + "\n", encoding="utf-8")
|
|
384
|
+
vivi = [s["server"] for s in sorgenti.values() if s["tipo"] == "mssql"]
|
|
385
|
+
ripieghi = [s["server"] for s in sorgenti.values() if s["tipo"] != "mssql"]
|
|
386
|
+
impostazioni = {
|
|
387
|
+
# I server della cartella partono senza chiedere conferma; il ripiego
|
|
388
|
+
# offline resta spento finche' qualcuno non lo accende da /mcp.
|
|
389
|
+
"enabledMcpjsonServers": vivi or ripieghi,
|
|
390
|
+
"disabledMcpjsonServers": ripieghi if vivi else [],
|
|
391
|
+
"permissions": {
|
|
392
|
+
"allow": [f"mcp__{n}__*" for n in server],
|
|
393
|
+
# La cartella noeva/ e' il motore: Claude la legge, non la cambia.
|
|
394
|
+
"deny": ["Edit(noeva/**)", "Write(noeva/**)"],
|
|
395
|
+
},
|
|
396
|
+
}
|
|
397
|
+
(cartella / ".claude").mkdir()
|
|
398
|
+
(cartella / ".claude" / "settings.json").write_text(json.dumps(impostazioni, indent=2) + "\n", encoding="utf-8")
|
|
399
|
+
|
|
400
|
+
primo = next(iter(vive.values()), {})
|
|
401
|
+
valori = {
|
|
402
|
+
"nome": nome, "cliente": cliente, "exe": exe, "exe_ps": f".\\noeva\\{nome}.exe",
|
|
403
|
+
"server": primo.get("server", nome), "server_snapshot": ", ".join(ripieghi) or "-",
|
|
404
|
+
"cosa": primo.get("cosa", ""),
|
|
405
|
+
"data_dwh": manifesto.get("data_del_dato") or "l'ultimo aggiornamento delle dashboard",
|
|
406
|
+
"host": primo.get("host", ""), "database": primo.get("database", ""),
|
|
407
|
+
"dwh": primo.get("catalogo_senza_nome", ""), "utente": primo.get("utente", ""),
|
|
408
|
+
"password_env": primo.get("password_env", ""), "versione": versione,
|
|
409
|
+
"chiave_env": (conf.get("modello_r2") or {}).get("chiave_env", ""),
|
|
410
|
+
"con_snapshot": "sì" if con_snapshot else "no",
|
|
411
|
+
# La domanda che installa.ps1 propone per la prova in Claude.
|
|
412
|
+
"domanda": conf.get("domanda_prova") or "Quali tabelle ci sono e quante righe hanno?",
|
|
413
|
+
}
|
|
414
|
+
for modello, dest in [("CLAUDE.md", "CLAUDE.md"), ("LEGGIMI.md", "LEGGIMI.md"),
|
|
415
|
+
("imposta-password.ps1", "imposta-password.ps1"), ("installa.ps1", "installa.ps1")]:
|
|
416
|
+
testo = compila(modello, valori)
|
|
417
|
+
if dest.endswith(".ps1"):
|
|
418
|
+
# PowerShell 5.1 legge gli script senza BOM come ANSI: gli accenti
|
|
419
|
+
# diventerebbero mojibake.
|
|
420
|
+
(cartella / dest).write_text(testo, encoding="utf-8-sig", newline="\r\n")
|
|
421
|
+
else:
|
|
422
|
+
(cartella / dest).write_text(testo, encoding="utf-8")
|
|
423
|
+
|
|
424
|
+
if not a.senza_exe and not a.senza_prova:
|
|
425
|
+
binario = noeva / (f"{nome}.exe" if WINDOWS else nome)
|
|
426
|
+
print("prova dell'eseguibile")
|
|
427
|
+
# --verifica su tutte le sorgenti: quelle vive passano solo se driver,
|
|
428
|
+
# password e rete ci sono; sul Mac di sviluppo di solito no, e non e' un errore.
|
|
429
|
+
subprocess.run([str(binario), "--verifica"])
|
|
430
|
+
if con_snapshot:
|
|
431
|
+
uv_run(amb, "python", QUI / "prova.py", "--comando", binario, "--sorgente", "snapshot",
|
|
432
|
+
"--snapshot", sorgente / "apps" / "dati" / "snapshot.json")
|
|
433
|
+
|
|
434
|
+
archivio = shutil.make_archive(str(USCITA / nome), "zip", root_dir=USCITA, base_dir=nome)
|
|
435
|
+
print(f"cartella: {relativo(cartella)} · zip {pathlib.Path(archivio).stat().st_size // 1024 // 1024} MB")
|
|
436
|
+
|
|
437
|
+
if a.mcpb:
|
|
438
|
+
costruisci_mcpb(sorgente, nome, cliente, versione, manifesto, noeva, sorgenti["snapshot"])
|
|
439
|
+
return 0
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# --- facoltativo: l'estensione Claude Desktop ------------------------------------
|
|
443
|
+
def costruisci_mcpb(sorgente, nome, cliente, versione, manifesto, noeva, snapshot: dict) -> None:
|
|
444
|
+
"""L'estensione uv (manifest 0.4) sullo snapshot: il primo esito dello spike."""
|
|
445
|
+
est = USCITA / f"{nome}-mcpb"
|
|
446
|
+
if est.exists():
|
|
447
|
+
shutil.rmtree(est)
|
|
448
|
+
(est / "server").mkdir(parents=True)
|
|
449
|
+
shutil.copytree(noeva / "snapshot", est / "snapshot")
|
|
450
|
+
shutil.copy2(QUI / "server" / "noeva_mcp.py", est / "server" / "noeva_mcp.py")
|
|
451
|
+
logo = sorgente / "apps" / "assets" / "noeva-su-chiaro.png"
|
|
452
|
+
if logo.is_file():
|
|
453
|
+
shutil.copy2(logo, est / "icon.png")
|
|
454
|
+
if shutil.which("sips"): # solo macOS: il logo quadrato
|
|
455
|
+
subprocess.run(["sips", "-p", "512", "512", "--padColor", "FFFFFF", str(est / "icon.png")],
|
|
456
|
+
check=True, capture_output=True)
|
|
457
|
+
(est / "noeva.json").write_text(json.dumps({"nome": nome, "sorgenti": {"snapshot": snapshot}},
|
|
458
|
+
ensure_ascii=False, indent=1), encoding="utf-8")
|
|
459
|
+
pyproject(est, nome, versione, DIPENDENZE)
|
|
460
|
+
(est / ".mcpbignore").write_text(".venv/\n__pycache__/\n*.pyc\n.mcpbignore\n", encoding="utf-8")
|
|
461
|
+
manifest = {
|
|
462
|
+
"manifest_version": "0.4",
|
|
463
|
+
"name": nome,
|
|
464
|
+
"display_name": f"NOEVA Analytics · {cliente}",
|
|
465
|
+
"version": versione,
|
|
466
|
+
"description": f"I dati di {cliente} in Claude, sullo snapshot delle dashboard NOEVA Analytics.",
|
|
467
|
+
"author": {"name": "Gruppo 4D"},
|
|
468
|
+
"icon": "icon.png",
|
|
469
|
+
"server": {
|
|
470
|
+
"type": "uv",
|
|
471
|
+
"entry_point": "server/noeva_mcp.py",
|
|
472
|
+
"mcp_config": {
|
|
473
|
+
"command": "uv",
|
|
474
|
+
"args": ["run", "--directory", "${__dirname}", "--frozen", "--no-dev",
|
|
475
|
+
"--python", PYTHON, "python", "server/noeva_mcp.py", "--sorgente", "snapshot"],
|
|
476
|
+
"env": {"PYTHONIOENCODING": "utf-8"},
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
"tools_generated": True,
|
|
480
|
+
"compatibility": {"platforms": ["win32", "darwin"], "runtimes": {"python": f"=={PYTHON}.*"}},
|
|
481
|
+
"keywords": ["noeva", "analytics", cliente.lower()],
|
|
482
|
+
"license": "Proprietary",
|
|
483
|
+
}
|
|
484
|
+
(est / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
485
|
+
esegui(["uv", "lock", "--python", PYTHON], cwd=est)
|
|
486
|
+
esegui(["uv", "sync", "--frozen", "--python", PYTHON], cwd=est)
|
|
487
|
+
esegui(["uv", "run", "--frozen", "python", QUI / "prova.py", "--manifest", est, "--sorgente", "snapshot",
|
|
488
|
+
"--snapshot", sorgente / "apps" / "dati" / "snapshot.json"], cwd=est)
|
|
489
|
+
pacchetto = USCITA / f"{nome}.mcpb"
|
|
490
|
+
esegui(["npx", "-y", MCPB, "pack", est, pacchetto])
|
|
491
|
+
print(f"estensione: {relativo(pacchetto)} ({pacchetto.stat().st_size // 1024} KB)")
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
if __name__ == "__main__":
|
|
495
|
+
sys.exit(main(sys.argv))
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Prova il server MCP di una consegna come lo avvierebbe il client, via stdio.
|
|
3
|
+
|
|
4
|
+
prova.py --comando dist/mcp-locale/noeva-<id>/noeva/noeva-<id> --sorgente vivo
|
|
5
|
+
prova.py --comando .../noeva-<id> --sorgente snapshot --snapshot project-<id>/apps/dati/snapshot.json
|
|
6
|
+
prova.py --manifest dist/mcp-locale/noeva-<id>-mcpb --sorgente snapshot --snapshot ...
|
|
7
|
+
|
|
8
|
+
--comando: un eseguibile, lanciato con `--sorgente <s>` (come da .mcp.json).
|
|
9
|
+
--manifest: la cartella di un'estensione .mcpb; comando e argomenti da
|
|
10
|
+
manifest.json, con ${__dirname} sostituito.
|
|
11
|
+
|
|
12
|
+
Fa initialize, list_tools, get_instructions; con --snapshot conta le righe di
|
|
13
|
+
ogni tabella contro snapshot.json; con --sql esegue quella query e la stampa.
|
|
14
|
+
Ambiente minimo (PATH, HOME, piu' le NOEVA_* e le *_PASSWORD presenti), come un
|
|
15
|
+
client che non passa altro. Esce con 1 se qualcosa non torna.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import pathlib
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
|
|
27
|
+
from mcp import ClientSession, StdioServerParameters
|
|
28
|
+
from mcp.client.stdio import stdio_client
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parametri(a) -> StdioServerParameters:
|
|
32
|
+
env = {k: v for k, v in os.environ.items()
|
|
33
|
+
if k in ("PATH", "HOME", "SYSTEMROOT", "USERPROFILE", "TEMP", "TMP")
|
|
34
|
+
or k.startswith("NOEVA_") or k.endswith("_PASSWORD")}
|
|
35
|
+
if a.manifest:
|
|
36
|
+
est = pathlib.Path(a.manifest).resolve()
|
|
37
|
+
mc = json.loads((est / "manifest.json").read_text(encoding="utf-8"))["server"]["mcp_config"]
|
|
38
|
+
args = [x.replace("${__dirname}", str(est)) for x in mc["args"]]
|
|
39
|
+
env.update({k: v for k, v in mc.get("env", {}).items() if "${" not in v})
|
|
40
|
+
return StdioServerParameters(command=mc["command"], args=args, env=env)
|
|
41
|
+
return StdioServerParameters(command=str(pathlib.Path(a.comando).resolve()),
|
|
42
|
+
args=["--sorgente", a.sorgente], env=env)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def prova(a) -> int:
|
|
46
|
+
t = time.time()
|
|
47
|
+
errori = 0
|
|
48
|
+
async with stdio_client(parametri(a)) as (r, w):
|
|
49
|
+
async with ClientSession(r, w) as s:
|
|
50
|
+
init = await s.initialize()
|
|
51
|
+
print(f" server: {init.serverInfo.name} · avvio {time.time() - t:.1f}s")
|
|
52
|
+
print(f" istruzioni: {init.instructions}")
|
|
53
|
+
strumenti = [x.name for x in (await s.list_tools()).tools]
|
|
54
|
+
print(f" strumenti ({len(strumenti)}): {', '.join(strumenti)}")
|
|
55
|
+
istr = await s.call_tool("get_instructions", {})
|
|
56
|
+
print(f" get_instructions: {len(istr.content[0].text)} caratteri")
|
|
57
|
+
modelli = await s.call_tool("list_models", {})
|
|
58
|
+
print(f" list_models: {modelli.content[0].text[:200].replace(chr(10), ' ')}…")
|
|
59
|
+
if a.snapshot:
|
|
60
|
+
manifesto = json.loads(pathlib.Path(a.snapshot).read_text(encoding="utf-8"))
|
|
61
|
+
for f in manifesto["file"]:
|
|
62
|
+
res = await s.call_tool("run_sql", {"sql": f"SELECT COUNT(*) AS n FROM {f['nome']}"})
|
|
63
|
+
try:
|
|
64
|
+
n = json.loads(res.content[0].text)["rows"][0]["n"]
|
|
65
|
+
except Exception:
|
|
66
|
+
n = None
|
|
67
|
+
if n != f["righe"]:
|
|
68
|
+
errori += 1
|
|
69
|
+
print(f" NON TORNA {f['nome']}: {res.content[0].text[:300]}")
|
|
70
|
+
print(f" COUNT(*) su {len(manifesto['file'])} tabelle: {len(manifesto['file']) - errori} tornano")
|
|
71
|
+
for sql in a.sql or []:
|
|
72
|
+
t = time.time()
|
|
73
|
+
res = await s.call_tool("run_sql", {"sql": sql, "limit": 20})
|
|
74
|
+
testo = res.content[0].text
|
|
75
|
+
print(f" run_sql ({time.time() - t:.1f}s) {sql[:90]}\n {testo[:700]}")
|
|
76
|
+
if res.isError:
|
|
77
|
+
errori += 1
|
|
78
|
+
return 1 if errori else 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main(argv: list[str]) -> int:
|
|
82
|
+
ap = argparse.ArgumentParser()
|
|
83
|
+
g = ap.add_mutually_exclusive_group(required=True)
|
|
84
|
+
g.add_argument("--comando")
|
|
85
|
+
g.add_argument("--manifest")
|
|
86
|
+
ap.add_argument("--sorgente", default="vivo")
|
|
87
|
+
ap.add_argument("--snapshot", help="snapshot.json: conta le righe di ogni tabella")
|
|
88
|
+
ap.add_argument("--sql", action="append", help="una query da eseguire (ripetibile)")
|
|
89
|
+
return asyncio.run(prova(ap.parse_args(argv[1:])))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
sys.exit(main(sys.argv))
|