@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.
- package/CHANGELOG.md +63 -0
- package/contenuto/assets/chat.js +22 -66
- package/contenuto/assets/commento.js +31 -16
- package/contenuto/assets/inferenza.js +4 -5
- package/contenuto/assets/presentazione.js +95 -31
- package/contenuto/assets/vista.js +3 -1
- package/contenuto/lib/ai/inferenza.py +102 -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/pubblica.py +15 -5
- package/contenuto/lib/modello/scheda.py +11 -0
- package/contenuto/lib/snapshot/ai.py +46 -1
- package/contenuto/lib/snapshot/worker/installa.ps1 +9 -4
- package/contenuto/lib/snapshot/worker/ultimi-giri.ps1 +202 -0
- package/core.js +43 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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)")
|