@noeva/analytics-core 0.4.1 → 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.
@@ -0,0 +1,102 @@
1
+ """L'inferenza NOEVA da Python, per gli strumenti del progetto che girano sul Mac.
2
+
3
+ Le pagine hanno il loro giro (lib/pagina/inferenza.js, attraverso ai/converse del
4
+ contenitore). Qui c'e' lo stesso servizio per chi non e' una pagina: per esempio
5
+ la traduzione della knowledge base in inglese durante l'export. E' l'unica strada
6
+ di un modello da Python: mai la CLI di Claude ne' un'API di modelli diretta
7
+ (`analytics ci` si ferma se un file del progetto ne usa una).
8
+
9
+ testo = converse(radice, sistema, testo_utente, funzione="traduzione")
10
+
11
+ La chiave e' quella dell'utente tecnico del workspace del progetto, come nel
12
+ secret NOEVA_INFERENZA del contenitore: NOEVA_INFERENZA_CHIAVE nell'ambiente o in
13
+ <radice>/.wren/.env (mai in git, mai stampata). Il workspace e' quello di
14
+ analytics.json; il modello NOEVA_INFERENZA_MODELLO (predefinito Haiku 5.5),
15
+ l'API NOEVA_API (predefinita https://api.noeva.ai).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import time
22
+ import urllib.error
23
+ import urllib.parse
24
+ import urllib.request
25
+ from pathlib import Path
26
+
27
+ MODELLO_PREDEFINITO = "eu.anthropic.claude-haiku-5-5"
28
+ API_PREDEFINITA = "https://api.noeva.ai"
29
+ TENTATIVI = 3
30
+
31
+
32
+ class ErroreInferenza(RuntimeError):
33
+ pass
34
+
35
+
36
+ def _dal_file_env(radice: Path, nome: str) -> str:
37
+ f = radice / ".wren" / ".env"
38
+ if not f.exists():
39
+ return ""
40
+ for riga in f.read_text(encoding="utf-8").splitlines():
41
+ if riga.strip().startswith(f"{nome}="):
42
+ return riga.split("=", 1)[1].strip().strip('"').strip("'")
43
+ return ""
44
+
45
+
46
+ def _valore(radice: Path, nome: str, predefinito: str = "") -> str:
47
+ return os.environ.get(nome) or _dal_file_env(radice, nome) or predefinito
48
+
49
+
50
+ def configurazione(radice: Path) -> dict:
51
+ """Chiave, workspace, modello e API, o ErroreInferenza se manca qualcosa."""
52
+ radice = Path(radice)
53
+ chiave = _valore(radice, "NOEVA_INFERENZA_CHIAVE")
54
+ if not chiave.startswith("noeva_sk_"):
55
+ raise ErroreInferenza("manca NOEVA_INFERENZA_CHIAVE (la chiave noeva_sk_ dell'utente tecnico del workspace) "
56
+ "nell'ambiente o in .wren/.env")
57
+ try:
58
+ workspace = json.loads((radice / "analytics.json").read_text(encoding="utf-8")).get("workspace") or ""
59
+ except (OSError, ValueError):
60
+ workspace = ""
61
+ workspace = _valore(radice, "NOEVA_INFERENZA_WORKSPACE", workspace)
62
+ if not workspace:
63
+ raise ErroreInferenza("analytics.json senza workspace: l'inferenza NOEVA non sa per chi lavora")
64
+ return {"chiave": chiave, "workspace": workspace,
65
+ "modello": _valore(radice, "NOEVA_INFERENZA_MODELLO", MODELLO_PREDEFINITO),
66
+ "api": _valore(radice, "NOEVA_API", API_PREDEFINITA).rstrip("/")}
67
+
68
+
69
+ def converse(radice: Path, sistema: str, testo: str, *, funzione: str, max_token: int = 8000,
70
+ tempo_max: int = 600) -> str:
71
+ """Una domanda e la sua risposta in testo, dall'inferenza NOEVA."""
72
+ c = configurazione(radice)
73
+ corpo = {
74
+ "system": [{"text": sistema}],
75
+ "messages": [{"role": "user", "content": [{"text": testo}]}],
76
+ "inferenceConfig": {"maxTokens": max_token},
77
+ "noevaTrace": {"name": f"Analytics: {funzione}", "tags": ["noeva-analytics", funzione],
78
+ "metadata": {"prodotto": "analytics", "funzione": funzione}},
79
+ }
80
+ richiesta = urllib.request.Request(
81
+ f"{c['api']}/api/inference/model/{urllib.parse.quote(c['modello'], safe='')}/converse",
82
+ data=json.dumps(corpo).encode("utf-8"), method="POST",
83
+ headers={"Authorization": f"Bearer {c['chiave']}", "x-noeva-workspace": c["workspace"],
84
+ "Content-Type": "application/json"})
85
+ for tentativo in range(1, TENTATIVI + 1):
86
+ try:
87
+ with urllib.request.urlopen(richiesta, timeout=tempo_max) as r:
88
+ esito = json.loads(r.read().decode("utf-8"))
89
+ break
90
+ except urllib.error.HTTPError as e:
91
+ # l'orchestratore a volte risponde 500 senza motivo: si riprova; 4xx sono risposte vere
92
+ if e.code < 500 or tentativo == TENTATIVI:
93
+ raise ErroreInferenza(f"inferenza NOEVA {e.code}: {e.read().decode(errors='replace')[:300]}")
94
+ except urllib.error.URLError as e:
95
+ if tentativo == TENTATIVI:
96
+ raise ErroreInferenza(f"inferenza NOEVA non raggiungibile: {e.reason}")
97
+ time.sleep(1.5 * tentativo)
98
+ blocchi = ((esito.get("output") or {}).get("message") or {}).get("content") or []
99
+ risposta = "".join(b.get("text", "") for b in blocchi if isinstance(b, dict)).strip()
100
+ if not risposta:
101
+ raise ErroreInferenza("risposta vuota dall'inferenza NOEVA")
102
+ return risposta
@@ -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
- "kwargs": {"TrustServerCertificate": "yes", **(s.get("opzioni") or {})},
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
- eng = WrenEngine(manifest_str=manifest, data_source="duckdb",
123
- connection_info={"url": str(dati), "format": "duckdb"})
124
- else:
125
- eng = WrenEngine(manifest_str=manifest, data_source="mssql", connection_info=connessione_mssql(s))
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, eng = motore(s)
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
- server = build_server(ServeContext(project=progetto, engine=eng, allow_write=False, no_connect=False))
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
- eng.close()
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)")