@noeva/analytics-core 0.5.0 → 0.5.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.
- package/CHANGELOG.md +23 -0
- package/contenuto/lib/mcp-locale/costruisci.py +4 -0
- package/contenuto/lib/mcp-locale/server/esecuzione.py +405 -0
- package/contenuto/lib/mcp-locale/server/noeva_mcp.py +81 -10
- package/contenuto/lib/modello/istruzioni.json +5 -2
- package/contenuto/lib/modello/istruzioni.py +6 -1
- package/contenuto/lib/modello/scheda.py +11 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,29 @@
|
|
|
3
3
|
Le versioni del pacchetto che i repository dei progetti installano (ADR 0005). Prima di
|
|
4
4
|
`analytics upgrade` si legge cosa cambia fra la versione installata e la nuova.
|
|
5
5
|
|
|
6
|
+
## 0.5.1 — 12 novembre 2026
|
|
7
|
+
|
|
8
|
+
- Il servizio MCP dei PC (`noeva-<id>.exe`) non si blocca piu' su una query lenta
|
|
9
|
+
(11/11/2026). Gli strumenti di Wren erano sincroni e giravano nell'event loop:
|
|
10
|
+
una query lunga fermava tutte le altre richieste e l'annullamento. Ora run_sql,
|
|
11
|
+
dry_run e query_cube girano in piu' thread, ognuno con la sua connessione
|
|
12
|
+
(`connessioni` in noeva.json, 3 per il vivo), con un tempo massimo per query
|
|
13
|
+
(`limite_query_s`, 120 s) fatto rispettare dal driver ODBC, che annulla la query su
|
|
14
|
+
SQL Server, e dal servizio stesso se il driver non basta. Claude riceve un errore
|
|
15
|
+
che dice di ridurre il periodo, non di riprovare in parallelo. Quando Claude annulla
|
|
16
|
+
la richiesta MCP, la query si ferma anche sul database (`server/esecuzione.py`).
|
|
17
|
+
- `noeva-<id>.exe --verifica` dice se il servizio aperto in Claude ha una query in
|
|
18
|
+
corso oltre il tempo massimo (il file di stato nella cartella temporanea), e in quel
|
|
19
|
+
caso chiude con un problema.
|
|
20
|
+
- Le regole delle query sul vivo (un mese poi l'anno, mai query pesanti in parallelo,
|
|
21
|
+
aggregare prima di unire, ridurre il periodo dopo un timeout) stanno in
|
|
22
|
+
`lib/modello/istruzioni.json` (`regole_query`): nelle istruzioni di initialize, in
|
|
23
|
+
locale e dal gateway, e nella scheda `00-progetto`, che arriva col modello
|
|
24
|
+
pubblicato anche ai servizi gia' installati. Anche nella descrizione di run_sql.
|
|
25
|
+
- Per le nuove regole nella scheda il progetto ripubblica il modello
|
|
26
|
+
(`analytics modello`, il job `pubblica` della CI); per il tempo massimo e le
|
|
27
|
+
connessioni serve un rilascio dell'eseguibile (`rilascio-windows.yml`).
|
|
28
|
+
|
|
6
29
|
## 0.5.0 — 12 novembre 2026
|
|
7
30
|
|
|
8
31
|
- **L'AI passa solo da NOEVA.** I moduli di pagina non offrono piu' la CLI di Claude:
|
|
@@ -261,6 +261,10 @@ def argomenti_pyinstaller(nome: str, build: pathlib.Path) -> list:
|
|
|
261
261
|
# e dal repository si compila con le trasformazioni di lib/modello/consegna.py.
|
|
262
262
|
"--paths", QUI / "server", "--paths", QUI.parent / "modello",
|
|
263
263
|
"--hidden-import", "modello_locale", "--hidden-import", "consegna", "--hidden-import", "scheda",
|
|
264
|
+
# Le query in thread, col tempo massimo e l'annullamento (server/esecuzione.py);
|
|
265
|
+
# la scheda legge le regole delle query da istruzioni.json, che sta accanto al modulo.
|
|
266
|
+
"--hidden-import", "esecuzione", "--hidden-import", "istruzioni",
|
|
267
|
+
"--add-data", f"{QUI.parent / 'modello' / 'istruzioni.json'}{os.pathsep}.",
|
|
264
268
|
QUI / "server" / "noeva_mcp.py",
|
|
265
269
|
]
|
|
266
270
|
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Come il servizio locale esegue le query: piu' connessioni, un tempo massimo, l'annullamento.
|
|
2
|
+
|
|
3
|
+
L'11/11/2026 il connettore di un cliente e' rimasto fermo mezz'ora: quattro query pesanti
|
|
4
|
+
lanciate in parallelo, nessuna finita, e nel frattempo nessuna risposta, nemmeno
|
|
5
|
+
alle richieste piu' semplici. Il motivo: nel server MCP di Wren (wrenai 0.15) gli
|
|
6
|
+
strumenti sono funzioni sincrone, e FastMCP (mcp 1.30) le chiama dentro l'event
|
|
7
|
+
loop. Una query lunga fermava tutto il processo: le altre richieste, i ping e
|
|
8
|
+
l'annullamento di Claude. Da fuori sembrava spento.
|
|
9
|
+
|
|
10
|
+
Qui gli strumenti che toccano il database (run_sql, dry_run, query_cube) diventano
|
|
11
|
+
asincroni e girano in un gruppo di thread, ognuno con il suo motore e la sua
|
|
12
|
+
connessione:
|
|
13
|
+
|
|
14
|
+
- una query lenta occupa un thread, le altre passano sugli altri (`connessioni`,
|
|
15
|
+
3 per il database vivo);
|
|
16
|
+
- ogni query ha un tempo massimo (`limite_query_s`, 120 s): lo fa rispettare il
|
|
17
|
+
driver ODBC (il timeout della connessione, che su SQL Server annulla davvero la
|
|
18
|
+
query sul server); se non bastasse, allo scadere piu' un margine il servizio
|
|
19
|
+
annulla il cursore lui stesso e risponde comunque;
|
|
20
|
+
- quando Claude annulla la richiesta MCP (notifications/cancelled) il servizio
|
|
21
|
+
annulla il cursore: la query si ferma anche sul database;
|
|
22
|
+
- l'errore del tempo scaduto dice a Claude cosa fare: ridurre il periodo, non
|
|
23
|
+
rilanciare la stessa query ne' spezzarla in parallelo.
|
|
24
|
+
|
|
25
|
+
Le query in corso stanno in un file di stato nella cartella temporanea, riscritto
|
|
26
|
+
ogni pochi secondi da un thread a parte: `--verifica` lo legge e dice se una query
|
|
27
|
+
e' in corso da troppo tempo (stato_in_corso).
|
|
28
|
+
|
|
29
|
+
Niente qui importa mcp o wren: si prova con un motore finto (test/test_esecuzione.py).
|
|
30
|
+
Niente stampa su stdout: e' il canale MCP.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import asyncio
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import pathlib
|
|
38
|
+
import tempfile
|
|
39
|
+
import threading
|
|
40
|
+
import time
|
|
41
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
42
|
+
from typing import Any, Callable
|
|
43
|
+
|
|
44
|
+
LIMITE_S = 120
|
|
45
|
+
CONNESSIONI = 3
|
|
46
|
+
# Oltre il limite del driver: il tempo che il driver ha per annullare e rispondere
|
|
47
|
+
# prima che il servizio annulli il cursore da se'.
|
|
48
|
+
MARGINE_S = 15
|
|
49
|
+
# Ogni quanto il file di stato si riscrive; oltre STATO_VECCHIO_S il servizio che lo
|
|
50
|
+
# scriveva non c'e' piu'.
|
|
51
|
+
BATTITO_S = 5
|
|
52
|
+
STATO_VECCHIO_S = 60
|
|
53
|
+
|
|
54
|
+
# Gli strumenti di Wren che vanno sul database.
|
|
55
|
+
STRUMENTI_DB = ("run_sql", "dry_run", "query_cube")
|
|
56
|
+
|
|
57
|
+
REGOLE = (
|
|
58
|
+
"Il database e' quello di produzione, che usa anche il gestionale: ogni query ha un tempo "
|
|
59
|
+
"massimo di {minuti}. Prima prova la query su un mese, poi allarga all'anno. Una query pesante "
|
|
60
|
+
"alla volta, mai in parallelo. Aggrega ogni tabella (GROUP BY) prima di unirla alle altre: mai "
|
|
61
|
+
"un join riga per riga tra tabelle grandi (per esempio listino e fatture). Se una query va in "
|
|
62
|
+
"timeout riduci il periodo o le colonne, non spezzarla in piu' query in parallelo."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def minuti(secondi: float) -> str:
|
|
67
|
+
if secondi % 60 == 0:
|
|
68
|
+
m = int(secondi // 60)
|
|
69
|
+
return "1 minuto" if m == 1 else f"{m} minuti"
|
|
70
|
+
return f"{int(secondi)} secondi"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def regole_testo(limite_s: float = LIMITE_S) -> str:
|
|
74
|
+
return REGOLE.format(minuti=minuti(limite_s))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TempoScaduto(Exception):
|
|
78
|
+
"""La query ha superato il tempo massimo ed e' stata annullata."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, limite_s: float):
|
|
81
|
+
super().__init__(
|
|
82
|
+
f"NOEVA ha fermato la query dopo {minuti(limite_s)}, il tempo massimo, e l'ha annullata anche "
|
|
83
|
+
"sul database. Non rilanciarla uguale e non dividerla in piu' query da lanciare in parallelo: "
|
|
84
|
+
"finirebbero in coda e scadrebbero anche quelle. Riducila: prova su un solo mese, poi allarga; "
|
|
85
|
+
"aggrega ogni tabella (GROUP BY) prima di unirla alle altre, mai un join riga per riga "
|
|
86
|
+
"(per esempio listino con fatture); togli le colonne che non servono."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def e_un_timeout(e: BaseException | None) -> bool:
|
|
91
|
+
"""Il timeout del driver, dentro gli errori che Wren avvolge (WrenError, DatabaseTimeoutError)."""
|
|
92
|
+
visti = 0
|
|
93
|
+
while e is not None and visti < 10:
|
|
94
|
+
if isinstance(e, (TimeoutError, TempoScaduto)) or type(e).__name__ == "DatabaseTimeoutError":
|
|
95
|
+
return True
|
|
96
|
+
testo = str(e)
|
|
97
|
+
if "HYT00" in testo or "timeout expired" in testo.lower():
|
|
98
|
+
return True
|
|
99
|
+
e = e.__cause__ or e.__context__
|
|
100
|
+
visti += 1
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def connessione_persa(e: BaseException | None) -> bool:
|
|
105
|
+
"""Errori dopo i quali la connessione non serve piu' (rete, server riavviato)."""
|
|
106
|
+
visti = 0
|
|
107
|
+
while e is not None and visti < 10:
|
|
108
|
+
testo = str(e)
|
|
109
|
+
if any(s in testo for s in ("08S01", "08003", "08001", "Communication link failure")):
|
|
110
|
+
return True
|
|
111
|
+
e = e.__cause__ or e.__context__
|
|
112
|
+
visti += 1
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Annullabile:
|
|
117
|
+
"""Una connessione pyodbc che ricorda il cursore in uso, per annullarlo da un altro thread.
|
|
118
|
+
|
|
119
|
+
Il connettore MSSQL di Wren apre un cursore per query e non lo espone;
|
|
120
|
+
pyodbc permette Cursor.cancel() da un altro thread, ed e' il modo di fermare
|
|
121
|
+
la query sul server."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, connessione: Any):
|
|
124
|
+
object.__setattr__(self, "_connessione", connessione)
|
|
125
|
+
object.__setattr__(self, "corrente", None)
|
|
126
|
+
|
|
127
|
+
def cursor(self, *a, **k):
|
|
128
|
+
c = self._connessione.cursor(*a, **k)
|
|
129
|
+
object.__setattr__(self, "corrente", c)
|
|
130
|
+
return c
|
|
131
|
+
|
|
132
|
+
def annulla(self) -> None:
|
|
133
|
+
c = self.corrente
|
|
134
|
+
if c is not None:
|
|
135
|
+
try:
|
|
136
|
+
c.cancel()
|
|
137
|
+
except Exception: # noqa: BLE001 - cursore gia' chiuso: niente da annullare
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
def __getattr__(self, nome: str):
|
|
141
|
+
return getattr(self._connessione, nome)
|
|
142
|
+
|
|
143
|
+
def __setattr__(self, nome: str, valore: Any) -> None:
|
|
144
|
+
setattr(self._connessione, nome, valore)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def annulla_motore(motore: Any) -> None:
|
|
148
|
+
"""Ferma la query in corso sul motore: il cursore su SQL Server, interrupt() su DuckDB."""
|
|
149
|
+
connettore = getattr(motore, "_connector", None)
|
|
150
|
+
conn = getattr(connettore, "connection", None)
|
|
151
|
+
if isinstance(conn, Annullabile):
|
|
152
|
+
conn.annulla()
|
|
153
|
+
elif conn is not None and hasattr(conn, "interrupt"):
|
|
154
|
+
try:
|
|
155
|
+
conn.interrupt()
|
|
156
|
+
except Exception: # noqa: BLE001
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def rendi_annullabile(motore: Any) -> Any:
|
|
161
|
+
"""Avvolge la connessione del motore appena si apre (il connettore di Wren e' pigro)."""
|
|
162
|
+
originale = motore._get_connector
|
|
163
|
+
|
|
164
|
+
def apri():
|
|
165
|
+
c = originale()
|
|
166
|
+
conn = getattr(c, "connection", None)
|
|
167
|
+
if conn is not None and hasattr(conn, "cursor") and not hasattr(conn, "interrupt") \
|
|
168
|
+
and not isinstance(conn, Annullabile):
|
|
169
|
+
c.connection = Annullabile(conn)
|
|
170
|
+
return c
|
|
171
|
+
|
|
172
|
+
motore._get_connector = apri
|
|
173
|
+
return motore
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class Lavoro:
|
|
177
|
+
def __init__(self, descrizione: str):
|
|
178
|
+
self.descrizione = descrizione
|
|
179
|
+
self.creato = time.time()
|
|
180
|
+
self.inizio: float | None = None # time.monotonic() quando un thread lo prende
|
|
181
|
+
self.inizio_epoch: float | None = None
|
|
182
|
+
self.annullato = False
|
|
183
|
+
self.motore: Any = None
|
|
184
|
+
|
|
185
|
+
def annulla(self) -> None:
|
|
186
|
+
self.annullato = True
|
|
187
|
+
if self.motore is not None:
|
|
188
|
+
annulla_motore(self.motore)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _lascia(futuro: asyncio.Future) -> None:
|
|
192
|
+
"""Un lavoro che non si attende piu': il suo errore (l'annullamento) non va nel log."""
|
|
193
|
+
futuro.add_done_callback(lambda f: f.cancelled() or f.exception())
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Postazioni:
|
|
197
|
+
"""I thread che eseguono le query, ognuno col suo motore e la sua connessione.
|
|
198
|
+
|
|
199
|
+
`crea_motore()` fa un motore nuovo (senza aprire la connessione: Wren la apre
|
|
200
|
+
alla prima query). `motore()` e' quello del thread corrente: nei thread delle
|
|
201
|
+
query il suo, altrove quello di base, che serve agli strumenti senza database
|
|
202
|
+
(dry_plan, list_functions) e non apre mai una connessione."""
|
|
203
|
+
|
|
204
|
+
def __init__(self, crea_motore: Callable[[], Any], connessioni: int = CONNESSIONI,
|
|
205
|
+
limite_s: float = LIMITE_S, margine_s: float = MARGINE_S,
|
|
206
|
+
stato: pathlib.Path | None = None, server: str = ""):
|
|
207
|
+
self._crea = crea_motore
|
|
208
|
+
self.connessioni = max(1, int(connessioni))
|
|
209
|
+
self.limite_s = float(limite_s)
|
|
210
|
+
self.margine_s = float(margine_s)
|
|
211
|
+
self._locale = threading.local()
|
|
212
|
+
self._motori: list[Any] = []
|
|
213
|
+
self._blocco = threading.Lock()
|
|
214
|
+
self._lavori: set[Lavoro] = set()
|
|
215
|
+
self._pool = ThreadPoolExecutor(max_workers=self.connessioni, thread_name_prefix="noeva-query")
|
|
216
|
+
self.base = crea_motore()
|
|
217
|
+
self.server = server
|
|
218
|
+
self.stato = stato
|
|
219
|
+
self._fermo = threading.Event()
|
|
220
|
+
if stato is not None:
|
|
221
|
+
threading.Thread(target=self._battito, name="noeva-stato", daemon=True).start()
|
|
222
|
+
|
|
223
|
+
# --- i motori ---------------------------------------------------------------
|
|
224
|
+
def motore(self) -> Any:
|
|
225
|
+
m = getattr(self._locale, "motore", None)
|
|
226
|
+
return m if m is not None else self.base
|
|
227
|
+
|
|
228
|
+
def _motore_del_thread(self) -> Any:
|
|
229
|
+
m = getattr(self._locale, "motore", None)
|
|
230
|
+
if m is None:
|
|
231
|
+
m = rendi_annullabile(self._crea())
|
|
232
|
+
self._locale.motore = m
|
|
233
|
+
with self._blocco:
|
|
234
|
+
self._motori.append(m)
|
|
235
|
+
return m
|
|
236
|
+
|
|
237
|
+
# --- l'esecuzione -------------------------------------------------------------
|
|
238
|
+
def _lavora(self, lavoro: Lavoro, fn: Callable[[], Any]) -> Any:
|
|
239
|
+
if lavoro.annullato:
|
|
240
|
+
raise asyncio.CancelledError("annullata prima di partire")
|
|
241
|
+
m = self._motore_del_thread()
|
|
242
|
+
lavoro.motore = m
|
|
243
|
+
lavoro.inizio = time.monotonic()
|
|
244
|
+
lavoro.inizio_epoch = time.time()
|
|
245
|
+
try:
|
|
246
|
+
return fn()
|
|
247
|
+
except Exception as e:
|
|
248
|
+
if connessione_persa(e):
|
|
249
|
+
# La prossima query riapre la connessione invece di fallire per sempre.
|
|
250
|
+
try:
|
|
251
|
+
m.close()
|
|
252
|
+
except Exception: # noqa: BLE001
|
|
253
|
+
pass
|
|
254
|
+
if e_un_timeout(e):
|
|
255
|
+
raise TempoScaduto(self.limite_s) from e
|
|
256
|
+
raise
|
|
257
|
+
finally:
|
|
258
|
+
lavoro.motore = None
|
|
259
|
+
|
|
260
|
+
async def esegui(self, fn: Callable[[], Any], descrizione: str = "") -> Any:
|
|
261
|
+
"""Esegue fn in un thread delle query; fn usa motore(), cioe' il motore di quel thread.
|
|
262
|
+
|
|
263
|
+
Il tempo si conta da quando un thread prende il lavoro, non dalla coda: in
|
|
264
|
+
coda si aspetta che una query finisca o scada."""
|
|
265
|
+
lavoro = Lavoro(descrizione)
|
|
266
|
+
with self._blocco:
|
|
267
|
+
self._lavori.add(lavoro)
|
|
268
|
+
futuro = asyncio.wrap_future(self._pool.submit(self._lavora, lavoro, fn))
|
|
269
|
+
try:
|
|
270
|
+
while True:
|
|
271
|
+
fatti, _ = await asyncio.wait({futuro}, timeout=0.5)
|
|
272
|
+
if fatti:
|
|
273
|
+
return futuro.result()
|
|
274
|
+
if lavoro.inizio is not None and time.monotonic() - lavoro.inizio > self.limite_s + self.margine_s:
|
|
275
|
+
# Il driver non l'ha fermata (la lettura delle righe, un driver
|
|
276
|
+
# senza timeout): la si annulla da qui e si risponde comunque.
|
|
277
|
+
lavoro.annulla()
|
|
278
|
+
_lascia(futuro)
|
|
279
|
+
raise TempoScaduto(self.limite_s)
|
|
280
|
+
except asyncio.CancelledError:
|
|
281
|
+
# Claude ha annullato la richiesta: la query si ferma anche sul database.
|
|
282
|
+
lavoro.annulla()
|
|
283
|
+
futuro.cancel()
|
|
284
|
+
_lascia(futuro)
|
|
285
|
+
raise
|
|
286
|
+
finally:
|
|
287
|
+
with self._blocco:
|
|
288
|
+
self._lavori.discard(lavoro)
|
|
289
|
+
|
|
290
|
+
def chiudi(self) -> None:
|
|
291
|
+
self._fermo.set()
|
|
292
|
+
with self._blocco:
|
|
293
|
+
lavori = list(self._lavori)
|
|
294
|
+
motori = list(self._motori)
|
|
295
|
+
for lv in lavori:
|
|
296
|
+
lv.annulla()
|
|
297
|
+
self._pool.shutdown(wait=False, cancel_futures=True)
|
|
298
|
+
for m in [*motori, self.base]:
|
|
299
|
+
try:
|
|
300
|
+
m.close()
|
|
301
|
+
except Exception: # noqa: BLE001
|
|
302
|
+
pass
|
|
303
|
+
if self.stato is not None:
|
|
304
|
+
try:
|
|
305
|
+
self.stato.unlink()
|
|
306
|
+
except OSError:
|
|
307
|
+
pass
|
|
308
|
+
|
|
309
|
+
# --- lo stato per --verifica ----------------------------------------------------
|
|
310
|
+
def fotografia(self) -> dict:
|
|
311
|
+
with self._blocco:
|
|
312
|
+
lavori = list(self._lavori)
|
|
313
|
+
return {
|
|
314
|
+
"server": self.server,
|
|
315
|
+
"pid": os.getpid(),
|
|
316
|
+
"aggiornato": time.time(),
|
|
317
|
+
"limite_s": self.limite_s,
|
|
318
|
+
"connessioni": self.connessioni,
|
|
319
|
+
"lavori": [
|
|
320
|
+
{"in_coda": lv.inizio_epoch is None, "dal": lv.inizio_epoch or lv.creato,
|
|
321
|
+
"query": lv.descrizione[:300]}
|
|
322
|
+
for lv in sorted(lavori, key=lambda x: x.creato)
|
|
323
|
+
],
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
def _battito(self) -> None:
|
|
327
|
+
while not self._fermo.is_set():
|
|
328
|
+
scrivi_stato(self.stato, self.fotografia())
|
|
329
|
+
self._fermo.wait(BATTITO_S)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class MotoreCorrente:
|
|
333
|
+
"""Al posto di ctx.engine negli strumenti di Wren: il motore del thread che li esegue."""
|
|
334
|
+
|
|
335
|
+
def __init__(self, postazioni: Postazioni):
|
|
336
|
+
object.__setattr__(self, "_postazioni", postazioni)
|
|
337
|
+
|
|
338
|
+
def __getattr__(self, nome: str):
|
|
339
|
+
return getattr(self._postazioni.motore(), nome)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def rendi_asincroni(server: Any, postazioni: Postazioni, regole: bool = True) -> list[str]:
|
|
343
|
+
"""Gli strumenti di Wren che vanno sul database, eseguiti nei thread delle query.
|
|
344
|
+
|
|
345
|
+
FastMCP chiama `fn(**argomenti)` e la attende se `is_async`: la funzione
|
|
346
|
+
sincrona di Wren resta quella, con lo stesso schema degli argomenti, e gira in
|
|
347
|
+
Postazioni.esegui. A run_sql si aggiungono le regole delle query."""
|
|
348
|
+
strumenti = server._tool_manager._tools
|
|
349
|
+
fatti = []
|
|
350
|
+
for nome in STRUMENTI_DB:
|
|
351
|
+
t = strumenti.get(nome)
|
|
352
|
+
if t is None:
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
def avvolgi(sincrona: Callable[..., Any]) -> Callable[..., Any]:
|
|
356
|
+
async def asincrona(**argomenti):
|
|
357
|
+
descrizione = argomenti.get("sql") or json.dumps(argomenti, ensure_ascii=False, default=str)
|
|
358
|
+
return await postazioni.esegui(lambda: sincrona(**argomenti), str(descrizione))
|
|
359
|
+
return asincrona
|
|
360
|
+
|
|
361
|
+
t.fn = avvolgi(t.fn)
|
|
362
|
+
t.is_async = True
|
|
363
|
+
if nome == "run_sql" and regole:
|
|
364
|
+
t.description = f"{t.description.rstrip()}\n\n{regole_testo(postazioni.limite_s)}"
|
|
365
|
+
fatti.append(nome)
|
|
366
|
+
return fatti
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def cartella_stato() -> pathlib.Path:
|
|
370
|
+
return pathlib.Path(os.environ.get("NOEVA_STATO") or tempfile.gettempdir()) / "noeva-stato"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def file_stato(server: str, pid: int | None = None) -> pathlib.Path:
|
|
374
|
+
return cartella_stato() / f"{server}-{pid or os.getpid()}.json"
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def scrivi_stato(percorso: pathlib.Path, dati: dict) -> None:
|
|
378
|
+
try:
|
|
379
|
+
percorso.parent.mkdir(parents=True, exist_ok=True)
|
|
380
|
+
tmp = percorso.with_suffix(".tmp")
|
|
381
|
+
tmp.write_text(json.dumps(dati, ensure_ascii=False), encoding="utf-8")
|
|
382
|
+
os.replace(tmp, percorso)
|
|
383
|
+
except OSError:
|
|
384
|
+
pass # lo stato serve a --verifica, non al servizio
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def stato_in_corso(server: str, ora: float | None = None) -> list[dict]:
|
|
388
|
+
"""Gli stati dei servizi vivi di questo server; quelli fermi da tempo si cancellano."""
|
|
389
|
+
ora = ora or time.time()
|
|
390
|
+
vivi = []
|
|
391
|
+
for f in sorted(cartella_stato().glob(f"{server}-*.json")):
|
|
392
|
+
try:
|
|
393
|
+
dati = json.loads(f.read_text(encoding="utf-8"))
|
|
394
|
+
except (OSError, ValueError):
|
|
395
|
+
continue
|
|
396
|
+
if dati.get("server") != server:
|
|
397
|
+
continue # un altro server con un nome che comincia allo stesso modo
|
|
398
|
+
if ora - float(dati.get("aggiornato") or 0) > STATO_VECCHIO_S:
|
|
399
|
+
try:
|
|
400
|
+
f.unlink()
|
|
401
|
+
except OSError:
|
|
402
|
+
pass
|
|
403
|
+
continue
|
|
404
|
+
vivi.append(dati)
|
|
405
|
+
return vivi
|
|
@@ -18,7 +18,14 @@ dashboard su DuckDB come ripiego offline.
|
|
|
18
18
|
noeva-<id> --sorgente erp serve MCP su stdio (lo lancia Claude Code)
|
|
19
19
|
noeva-<id> --verifica controlla driver, password e connessione di
|
|
20
20
|
tutte le sorgenti e dice cosa manca: si lancia
|
|
21
|
-
a mano dal PowerShell quando /mcp dice "failed"
|
|
21
|
+
a mano dal PowerShell quando /mcp dice "failed";
|
|
22
|
+
dice anche se il servizio aperto in Claude ha una
|
|
23
|
+
query in corso da troppo tempo
|
|
24
|
+
|
|
25
|
+
Le query girano in piu' thread, ognuno con la sua connessione, con un tempo
|
|
26
|
+
massimo e l'annullamento sul database (esecuzione.py): una query lenta non ferma
|
|
27
|
+
piu' le altre. In noeva.json, per sorgente: `limite_query_s` (default 120) e
|
|
28
|
+
`connessioni` (default 3; 1 per lo snapshot). NOEVA_LIMITE_QUERY_S forza il limite.
|
|
22
29
|
|
|
23
30
|
Su stdio lo stdout e' il canale del protocollo: fuori da --verifica qui non si
|
|
24
31
|
stampa niente, i log vanno su stderr.
|
|
@@ -31,6 +38,7 @@ import json
|
|
|
31
38
|
import os
|
|
32
39
|
import pathlib
|
|
33
40
|
import sys
|
|
41
|
+
import time
|
|
34
42
|
|
|
35
43
|
# Congelato da PyInstaller la cartella e' quella dell'eseguibile; da sorgente,
|
|
36
44
|
# quella sopra server/. NOEVA_DIR la forza (per provare da un'altra macchina).
|
|
@@ -53,6 +61,13 @@ class Problema(Exception):
|
|
|
53
61
|
"""Un errore di configurazione da spiegare a chi installa, non uno stack."""
|
|
54
62
|
|
|
55
63
|
|
|
64
|
+
def limite_query(s: dict) -> float:
|
|
65
|
+
"""Il tempo massimo di una query, in secondi."""
|
|
66
|
+
import esecuzione
|
|
67
|
+
|
|
68
|
+
return float(os.environ.get("NOEVA_LIMITE_QUERY_S") or s.get("limite_query_s") or esecuzione.LIMITE_S)
|
|
69
|
+
|
|
70
|
+
|
|
56
71
|
def configurazione() -> dict:
|
|
57
72
|
p = QUI / "noeva.json"
|
|
58
73
|
if not p.is_file():
|
|
@@ -95,11 +110,20 @@ def connessione_mssql(s: dict) -> dict:
|
|
|
95
110
|
"user": utente or None,
|
|
96
111
|
"password": password or None,
|
|
97
112
|
"driver": driver,
|
|
98
|
-
|
|
113
|
+
# statement_timeout diventa il timeout della connessione pyodbc: allo
|
|
114
|
+
# scadere il driver annulla la query sul server (esecuzione.py).
|
|
115
|
+
"kwargs": {"TrustServerCertificate": "yes", "statement_timeout": str(int(limite_query(s))),
|
|
116
|
+
**(s.get("opzioni") or {})},
|
|
99
117
|
}
|
|
100
118
|
|
|
101
119
|
|
|
102
120
|
def motore(s: dict):
|
|
121
|
+
progetto, crea = fabbrica(s)
|
|
122
|
+
return progetto, crea()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def fabbrica(s: dict):
|
|
126
|
+
"""(progetto, crea_motore): il modello si cerca una volta, i motori si fanno uguali."""
|
|
103
127
|
from wren.engine import WrenEngine
|
|
104
128
|
|
|
105
129
|
import modello_locale
|
|
@@ -119,27 +143,36 @@ def motore(s: dict):
|
|
|
119
143
|
raise Problema(f"nessun .duckdb in {dati}")
|
|
120
144
|
# Il connettore DuckDB attacca in sola lettura ogni .duckdb della
|
|
121
145
|
# cartella, con alias = nome del file: e' il catalogo del modello.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return progetto, eng
|
|
146
|
+
return progetto, lambda: WrenEngine(manifest_str=manifest, data_source="duckdb",
|
|
147
|
+
connection_info={"url": str(dati), "format": "duckdb"})
|
|
148
|
+
info = connessione_mssql(s)
|
|
149
|
+
return progetto, lambda: WrenEngine(manifest_str=manifest, data_source="mssql", connection_info=info)
|
|
127
150
|
|
|
128
151
|
|
|
129
152
|
def servi(conf: dict, nome: str) -> None:
|
|
130
153
|
s = conf["sorgenti"][nome]
|
|
131
|
-
progetto,
|
|
154
|
+
progetto, crea = fabbrica(s)
|
|
155
|
+
import esecuzione
|
|
156
|
+
import modello_locale
|
|
132
157
|
from wren.mcp_server import ServeContext, build_server
|
|
133
158
|
|
|
134
|
-
|
|
159
|
+
limite = limite_query(s)
|
|
160
|
+
postazioni = esecuzione.Postazioni(
|
|
161
|
+
crea, connessioni=s.get("connessioni") or (esecuzione.CONNESSIONI if s["tipo"] == "mssql" else 1),
|
|
162
|
+
limite_s=limite, stato=esecuzione.file_stato(s["server"]), server=s["server"])
|
|
163
|
+
# Gli strumenti di Wren leggono ctx.engine: il motore del thread che li esegue.
|
|
164
|
+
server = build_server(ServeContext(project=progetto, engine=esecuzione.MotoreCorrente(postazioni),
|
|
165
|
+
allow_write=False, no_connect=False))
|
|
166
|
+
esecuzione.rendi_asincroni(server, postazioni, regole=s["tipo"] == "mssql")
|
|
135
167
|
# FastMCP non espone un setter: nome e istruzioni stanno sul server di basso
|
|
136
168
|
# livello, ed e' quello che il client legge all'initialize.
|
|
137
169
|
server._mcp_server.name = s["server"]
|
|
138
170
|
server._mcp_server.instructions = s["istruzioni"]
|
|
171
|
+
modello_locale.log(f"{s['server']}: {postazioni.connessioni} connessioni, query fermate dopo {int(limite)} s")
|
|
139
172
|
try:
|
|
140
173
|
server.run(transport="stdio")
|
|
141
174
|
finally:
|
|
142
|
-
|
|
175
|
+
postazioni.chiudi()
|
|
143
176
|
|
|
144
177
|
|
|
145
178
|
def verifica(conf: dict, nomi: list[str]) -> int:
|
|
@@ -170,10 +203,48 @@ def verifica(conf: dict, nomi: list[str]) -> int:
|
|
|
170
203
|
falliti += 1
|
|
171
204
|
break
|
|
172
205
|
eng.close()
|
|
206
|
+
if not query_in_corso(s):
|
|
207
|
+
falliti += 1
|
|
173
208
|
print("\ntutto a posto" if not falliti else f"\n{falliti} sorgente/i con problemi")
|
|
174
209
|
return 1 if falliti else 0
|
|
175
210
|
|
|
176
211
|
|
|
212
|
+
def query_in_corso(s: dict) -> bool:
|
|
213
|
+
"""Le query del servizio aperto in Claude (il file di stato di esecuzione.py).
|
|
214
|
+
|
|
215
|
+
Falso se una e' in corso da oltre il tempo massimo: il servizio dovrebbe
|
|
216
|
+
averla gia' fermata, quindi e' bloccato."""
|
|
217
|
+
import esecuzione
|
|
218
|
+
|
|
219
|
+
vivi = esecuzione.stato_in_corso(s["server"])
|
|
220
|
+
if not vivi:
|
|
221
|
+
print(" .. servizio in Claude: non aperto, o di una versione che non lo dice")
|
|
222
|
+
return True
|
|
223
|
+
ora = time.time()
|
|
224
|
+
bene = True
|
|
225
|
+
for st in vivi:
|
|
226
|
+
lavori = st.get("lavori") or []
|
|
227
|
+
limite = float(st.get("limite_s") or esecuzione.LIMITE_S)
|
|
228
|
+
if not lavori:
|
|
229
|
+
print(f" ok servizio in Claude (processo {st.get('pid')}): nessuna query in corso")
|
|
230
|
+
continue
|
|
231
|
+
in_coda = sum(1 for lv in lavori if lv.get("in_coda"))
|
|
232
|
+
for lv in lavori:
|
|
233
|
+
if lv.get("in_coda"):
|
|
234
|
+
continue
|
|
235
|
+
durata = ora - float(lv.get("dal") or ora)
|
|
236
|
+
testo = " ".join(str(lv.get("query") or "").split())[:160]
|
|
237
|
+
if durata > limite + esecuzione.MARGINE_S + esecuzione.BATTITO_S:
|
|
238
|
+
bene = False
|
|
239
|
+
print(f" NO query in corso da {int(durata // 60)} min {int(durata % 60)} s, oltre il massimo "
|
|
240
|
+
f"di {esecuzione.minuti(limite)}: il servizio e' bloccato, chiudi e riapri Claude. Query: {testo}")
|
|
241
|
+
else:
|
|
242
|
+
print(f" .. query in corso da {int(durata)} s: {testo}")
|
|
243
|
+
if in_coda:
|
|
244
|
+
print(f" .. {in_coda} query in coda (connessioni: {st.get('connessioni')})")
|
|
245
|
+
return bene
|
|
246
|
+
|
|
247
|
+
|
|
177
248
|
def main(argv: list[str] | None = None) -> int:
|
|
178
249
|
ap = argparse.ArgumentParser(prog=pathlib.Path(sys.argv[0]).stem)
|
|
179
250
|
ap.add_argument("--sorgente", help="quale database servire (default: la prima di noeva.json)")
|
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
"istruzioni.js: i segnaposto sono {progetto} (l'id del registro e di analytics.json) e,",
|
|
7
7
|
"per lo snapshot, {data} (la data del dato). Cio' che e' vero del progetto (cliente,",
|
|
8
8
|
"cosa coprono i dati, dashboard, contatto) sta nella scheda knowledge/rules/00-progetto.md,",
|
|
9
|
-
"non qui (NW-1261)."
|
|
9
|
+
"non qui (NW-1261).",
|
|
10
|
+
"regole_query: come si interroga un database vivo senza bloccarlo (11/11/2026); si",
|
|
11
|
+
"aggiunge alle istruzioni del vivo e alla scheda 00-progetto, che arriva col modello pubblicato."
|
|
10
12
|
],
|
|
11
13
|
"nome": "noeva-{progetto}",
|
|
12
14
|
"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."
|
|
15
|
+
"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.",
|
|
16
|
+
"regole_query": "Il database vivo e' quello di produzione, che usa anche il gestionale, e ogni query ha un tempo massimo di 2 minuti: prova la query su un mese, poi sull'anno; una query pesante alla volta, mai in parallelo; aggrega ogni tabella (GROUP BY) prima di unirla alle altre, mai un join riga per riga tra tabelle grandi (per esempio listino e fatture); se una query va in timeout riduci il periodo invece di spezzarla in piu' query in parallelo."
|
|
14
17
|
}
|
|
@@ -27,8 +27,13 @@ def nome_server(progetto: str) -> str:
|
|
|
27
27
|
return _riempi(TESTI["nome"], progetto=progetto)
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
def regole_query() -> str:
|
|
31
|
+
"""Come si interroga un database vivo senza bloccarlo: anche nella scheda 00-progetto."""
|
|
32
|
+
return TESTI["regole_query"]
|
|
33
|
+
|
|
34
|
+
|
|
30
35
|
def istruzioni_vivo(progetto: str) -> str:
|
|
31
|
-
return _riempi(TESTI["vivo"], progetto=progetto)
|
|
36
|
+
return _riempi(TESTI["vivo"], progetto=progetto) + " " + regole_query()
|
|
32
37
|
|
|
33
38
|
|
|
34
39
|
def istruzioni_snapshot(progetto: str, data: str | None) -> str:
|
|
@@ -21,6 +21,7 @@ Legge, tutti facoltativi tranne analytics.json:
|
|
|
21
21
|
models/ quanti modelli
|
|
22
22
|
apps/index.html il titolo del progetto, che e' quello dell'indice
|
|
23
23
|
apps/*/index.html le dashboard, col titolo della pagina
|
|
24
|
+
istruzioni.json le regole delle query sul vivo (regole_query), se ci sono sorgenti vive
|
|
24
25
|
|
|
25
26
|
Il registro dei progetti (lib/rilascio/progetti.json) sta nel monorepo di 4D e il
|
|
26
27
|
repository di un cliente non lo vede (il pacchetto non nomina clienti, ADR 0005):
|
|
@@ -41,6 +42,11 @@ import sys
|
|
|
41
42
|
|
|
42
43
|
import yaml
|
|
43
44
|
|
|
45
|
+
try:
|
|
46
|
+
from istruzioni import regole_query
|
|
47
|
+
except (ImportError, OSError): # un eseguibile costruito senza istruzioni.json: la scheda resta senza
|
|
48
|
+
regole_query = None
|
|
49
|
+
|
|
44
50
|
FILE = "00-progetto.md"
|
|
45
51
|
CONTENITORE = "https://analytics.noeva.ai"
|
|
46
52
|
# Le cartelle di apps/ che non sono dashboard: il motore e i moduli di pagina.
|
|
@@ -119,6 +125,11 @@ def scheda(sorgente: pathlib.Path, conf: dict | None = None) -> str:
|
|
|
119
125
|
r.append("- La copertura per sorgente non e' descritta (`cosa` in consegna.yml): la dicono "
|
|
120
126
|
"le regole e la descrizione dei modelli.")
|
|
121
127
|
|
|
128
|
+
if conf.get("sorgenti") and regole_query is not None:
|
|
129
|
+
# Arriva col modello pubblicato anche ai servizi gia' installati, prima del
|
|
130
|
+
# loro aggiornamento (11/11/2026: query pesanti in parallelo, connettore fermo).
|
|
131
|
+
r += ["", "## Le query sul database vivo", "", regole_query()]
|
|
132
|
+
|
|
122
133
|
r += ["", "## Le dashboard", "",
|
|
123
134
|
"Dietro il login NOEVA: le apre chi ha il progetto nel suo workspace.", "",
|
|
124
135
|
f"- Indice: {base}"]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noeva/analytics-core",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Gli strumenti di un progetto NOEVA Analytics nel suo repository: moduli di pagina, snapshot, verifica dello standard e kit dell'agente (ADR 0005).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|