@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
|
@@ -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:
|
|
@@ -80,6 +80,20 @@ def estrai_da_head(radice: pathlib.Path, dest: pathlib.Path) -> None:
|
|
|
80
80
|
tar.extractall(dest, filter="data")
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
def costruisci(radice: pathlib.Path, tmp: pathlib.Path) -> tuple[pathlib.Path, dict]:
|
|
84
|
+
"""Il modello consegnato dal commit di `radice`, compilato, in `tmp`/modello.
|
|
85
|
+
|
|
86
|
+
E' il modello pubblicato: lo usa main() per caricarlo e il backend del gateway
|
|
87
|
+
(lib/gateway/backend/modello.py) per metterlo nell'immagine, cosi' le due strade
|
|
88
|
+
non possono divergere. Restituisce la cartella e i conteggi delle trasformazioni."""
|
|
89
|
+
sorgente, dest = tmp / "sorgente", tmp / "modello"
|
|
90
|
+
estrai_da_head(radice, sorgente)
|
|
91
|
+
# Le trasformazioni di consegna.yml anche loro dal commit, non dal disco.
|
|
92
|
+
conta = modello_di_consegna(sorgente, dest, leggi_consegna(sorgente))
|
|
93
|
+
compila(dest)
|
|
94
|
+
return dest, conta
|
|
95
|
+
|
|
96
|
+
|
|
83
97
|
def file_della_versione(dest: pathlib.Path) -> list[pathlib.Path]:
|
|
84
98
|
return sorted(p for p in dest.rglob("*") if p.is_file() and not p.name.startswith("."))
|
|
85
99
|
|
|
@@ -99,11 +113,7 @@ def main(argv: list[str]) -> int:
|
|
|
99
113
|
versione = versione_di(radice)
|
|
100
114
|
|
|
101
115
|
with tempfile.TemporaryDirectory() as tmp:
|
|
102
|
-
|
|
103
|
-
estrai_da_head(radice, sorgente)
|
|
104
|
-
# Le trasformazioni di consegna.yml anche loro dal commit, non dal disco.
|
|
105
|
-
conta = modello_di_consegna(sorgente, dest, leggi_consegna(sorgente))
|
|
106
|
-
compila(dest)
|
|
116
|
+
dest, conta = costruisci(radice, pathlib.Path(tmp))
|
|
107
117
|
file = file_della_versione(dest)
|
|
108
118
|
byte = sum(p.stat().st_size for p in file)
|
|
109
119
|
print(f"▸ {progetto}: modello {versione}, {numero_modelli(dest)} modelli, {len(file)} file, "
|
|
@@ -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}"]
|
|
@@ -9,6 +9,12 @@ dalle stesse funzioni di commento.py, cosi' le regole restano in un posto solo.
|
|
|
9
9
|
|
|
10
10
|
apps/dati/ai/commento-<pagina>.<lingua>.json {sistema, modi, schema, generato}
|
|
11
11
|
apps/dati/ai/chat-<pagina>.<lingua>.json {sistema, testi, generato} se c'e' chat.py
|
|
12
|
+
apps/dati/ai/presentazione-<pagina>.<lingua>.json
|
|
13
|
+
{sistema, sistema_modifica, schema, schema_modifica, partenze, generato} se c'e' presentazione.py
|
|
14
|
+
|
|
15
|
+
Questi file sono l'unica strada dell'AI delle pagine: chat.py, commento.py e
|
|
16
|
+
presentazione.py del progetto tengono solo prompt e regole, mai una chiamata a un
|
|
17
|
+
modello (la CLI di Claude non si usa; `analytics ci` lo controlla).
|
|
12
18
|
|
|
13
19
|
Le pagine sono le cartelle di apps/ con un index.html, piu' l'indice; le lingue
|
|
14
20
|
quelle di commento.LINGUE (apps/en/ non e' una pagina: e' la gemella inglese, che
|
|
@@ -73,6 +79,35 @@ def carica_chat(progetto: pathlib.Path):
|
|
|
73
79
|
return modulo
|
|
74
80
|
|
|
75
81
|
|
|
82
|
+
def carica_presentazione(progetto: pathlib.Path):
|
|
83
|
+
"""presentazione.py del progetto, o None: il PowerPoint e' facoltativo."""
|
|
84
|
+
if not (progetto / "presentazione.py").exists():
|
|
85
|
+
return None
|
|
86
|
+
spec = importlib.util.spec_from_file_location(f"presentazione_{progetto.name}", progetto / "presentazione.py")
|
|
87
|
+
modulo = importlib.util.module_from_spec(spec)
|
|
88
|
+
spec.loader.exec_module(modulo)
|
|
89
|
+
for nome in ("istruzioni", "LINGUE", "SCHEMA", "SCHEMA_MODIFICA", "PRESET", "MODIFICA"):
|
|
90
|
+
if not hasattr(modulo, nome):
|
|
91
|
+
raise SystemExit(f"{progetto.name}/presentazione.py non ha {nome}: non e' allineato allo standard")
|
|
92
|
+
return modulo
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def prompt_presentazione(ppt, c, pagina: str, lingua: str) -> dict:
|
|
96
|
+
"""Il prompt delle slide come lo costruiva presentazione.py sul Mac: per scriverle
|
|
97
|
+
(istruzioni) e per sistemarle in chat (istruzioni piu' MODIFICA), con la knowledge base."""
|
|
98
|
+
kb = "\n\n# Knowledge base del progetto\n\n" + c.conoscenza(pagina)
|
|
99
|
+
risposta = ppt.LINGUE[lingua].get("risposta", "nella lingua della pagina")
|
|
100
|
+
return {
|
|
101
|
+
"sistema": ppt.istruzioni(lingua) + kb,
|
|
102
|
+
"sistema_modifica": ppt.istruzioni(lingua) + "\n\n" + ppt.MODIFICA.replace("{RISPOSTA}", risposta) + kb,
|
|
103
|
+
"schema": ppt.SCHEMA,
|
|
104
|
+
"schema_modifica": ppt.SCHEMA_MODIFICA,
|
|
105
|
+
"partenze": [{"id": k, "nome": v["nome"].get(lingua) or v["nome"]["it"],
|
|
106
|
+
"descrizione": v["descrizione"].get(lingua) or v["descrizione"]["it"],
|
|
107
|
+
"richiesta": v["richiesta"]} for k, v in ppt.PRESET.items()],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
76
111
|
def pagine(progetto: pathlib.Path) -> list[str]:
|
|
77
112
|
apps = progetto / "apps"
|
|
78
113
|
trovate = sorted(p.name for p in apps.iterdir()
|
|
@@ -110,9 +145,19 @@ def main(argv: list[str]) -> int:
|
|
|
110
145
|
"generato": adesso},
|
|
111
146
|
ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
|
112
147
|
n_chat += 1
|
|
148
|
+
ppt = carica_presentazione(progetto)
|
|
149
|
+
n_ppt = 0
|
|
150
|
+
if ppt:
|
|
151
|
+
for pagina in pagine(progetto):
|
|
152
|
+
for lingua in ppt.LINGUE:
|
|
153
|
+
(uscita / f"presentazione-{pagina}.{lingua}.json").write_text(json.dumps(
|
|
154
|
+
{**prompt_presentazione(ppt, c, pagina, lingua), "generato": adesso},
|
|
155
|
+
ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
|
156
|
+
n_ppt += 1
|
|
113
157
|
if not scritti and not n_chat:
|
|
114
158
|
raise SystemExit(f"{progetto.name}: ne' commento guidato ne' chat, nessun prompt da scrivere")
|
|
115
|
-
print(f" apps/dati/ai: {scritti} prompt del commento, {n_chat} della chat
|
|
159
|
+
print(f" apps/dati/ai: {scritti} prompt del commento, {n_chat} della chat, {n_ppt} della presentazione"
|
|
160
|
+
f" ({len(c.LINGUE)} lingue)")
|
|
116
161
|
return 0
|
|
117
162
|
|
|
118
163
|
|
|
@@ -310,8 +310,13 @@ if ($SoloControllo) {
|
|
|
310
310
|
} else {
|
|
311
311
|
Write-Host " Ogni $minuti minuti lancia 'ascolta --una-volta' come $env:USERNAME, anche senza sessione aperta:"
|
|
312
312
|
Write-Host ' per questo Windows chiede la password di questo utente (quella di Windows, non del database).'
|
|
313
|
-
|
|
314
|
-
|
|
313
|
+
# In console, non con Get-Credential: in desktop remoto la finestrella finisce dietro e lo
|
|
314
|
+
# script sembra fermo (12/11/2026, sulla VM di un cliente).
|
|
315
|
+
$utenteWin = "$env:USERDOMAIN\$env:USERNAME"
|
|
316
|
+
$pwWin = ChiediSegreto "Password Windows di $utenteWin (non si vede mentre scrivi, poi Invio)"
|
|
317
|
+
if (-not $pwWin) { Ferma 'attivita''' 'nessuna password Windows: senza, l''attivita'' non gira a sessione chiusa' }
|
|
318
|
+
$credenziali = [pscustomobject]@{ UserName = $utenteWin }
|
|
319
|
+
Write-Host ' Registro l''attivita'' con Windows...'
|
|
315
320
|
$log = Join-Path $destinazione 'lavoro\ascolta.log'
|
|
316
321
|
$azione = New-ScheduledTaskAction -Execute 'cmd.exe' -WorkingDirectory $destinazione `
|
|
317
322
|
-Argument "/c `"`"$exe`" ascolta --config `"$(Join-Path $destinazione 'worker.json')`" --una-volta >> `"$log`" 2>&1`""
|
|
@@ -320,13 +325,13 @@ if ($SoloControllo) {
|
|
|
320
325
|
-StartWhenAvailable -DontStopIfGoingOnBatteries -AllowStartIfOnBatteries
|
|
321
326
|
try {
|
|
322
327
|
Register-ScheduledTask -TaskName $attivita -Action $azione -Trigger $quando -Settings $regole `
|
|
323
|
-
-User $credenziali.UserName -Password $
|
|
328
|
+
-User $credenziali.UserName -Password $pwWin -RunLevel Limited -Force -ErrorAction Stop | Out-Null
|
|
324
329
|
Enable-ScheduledTask -TaskName $attivita | Out-Null
|
|
325
330
|
Esito 'attivita''' 'ok' "ogni $minuti minuti come $($credenziali.UserName); registro in $log"
|
|
326
331
|
} catch {
|
|
327
332
|
Esito 'attivita''' 'NO' "Windows l'ha rifiutata (password giusta?): $($_.Exception.Message)"
|
|
328
333
|
}
|
|
329
|
-
$credenziali = $null
|
|
334
|
+
$credenziali = $null; $pwWin = $null
|
|
330
335
|
}
|
|
331
336
|
|
|
332
337
|
# 8. La dashboard ------------------------------------------------------------------------------
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# Gli ultimi giri del worker degli snapshot e il loro esito, senza cambiare niente.
|
|
2
|
+
#
|
|
3
|
+
# powershell -ExecutionPolicy Bypass -File C:\NOEVA\snapshot\ultimi-giri.ps1
|
|
4
|
+
# powershell -ExecutionPolicy Bypass -File C:\NOEVA\snapshot\ultimi-giri.ps1 -Giri 20
|
|
5
|
+
#
|
|
6
|
+
# Legge l'attivita' pianificata, lavoro\ascolta.log (tutti i giri, con l'esito) e il
|
|
7
|
+
# giro.json degli ultimi giri tenuti in lavoro\<progetto>\ (le cifre di controllo che
|
|
8
|
+
# hanno avvisato o fermato). Non legge chiavi ne' password e non chiama NOEVA.
|
|
9
|
+
# Lo lancia l'utente del worker, o chiunque possa leggere C:\NOEVA\snapshot.
|
|
10
|
+
|
|
11
|
+
param(
|
|
12
|
+
[int]$Giri = 10,
|
|
13
|
+
[string]$Cartella = ''
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Di solito accanto a noeva-snapshot (C:\NOEVA\snapshot); copiato altrove, guarda li'.
|
|
17
|
+
if (-not $Cartella) {
|
|
18
|
+
$Cartella = if ($PSScriptRoot -and (Test-Path (Join-Path $PSScriptRoot 'worker.json'))) { $PSScriptRoot } else { 'C:\NOEVA\snapshot' }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
$ErrorActionPreference = 'Continue'
|
|
22
|
+
# Il nome lo da' installa.ps1: "NOEVA snapshot <progetto>", dal progetto di worker.json.
|
|
23
|
+
$attivita = 'NOEVA snapshot'
|
|
24
|
+
$log = Join-Path $Cartella 'lavoro\ascolta.log'
|
|
25
|
+
$conf = Join-Path $Cartella 'worker.json'
|
|
26
|
+
|
|
27
|
+
function Riga([string]$etichetta, [string]$stato, [string]$testo) {
|
|
28
|
+
$colore = switch ($stato) { 'ok' { 'Green' } 'avviso' { 'Yellow' } 'NO' { 'Red' } default { 'Gray' } }
|
|
29
|
+
Write-Host (' {0,-8}' -f $stato) -ForegroundColor $colore -NoNewline
|
|
30
|
+
Write-Host (' {0,-22} {1}' -f $etichetta, $testo)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function Durata([timespan]$d) {
|
|
34
|
+
if ($d.TotalMinutes -lt 1) { return '{0} s' -f [int]$d.TotalSeconds }
|
|
35
|
+
if ($d.TotalHours -lt 1) { return '{0} min' -f [int]$d.TotalMinutes }
|
|
36
|
+
if ($d.TotalDays -lt 1) { return '{0:0.#} ore' -f $d.TotalHours }
|
|
37
|
+
return '{0:0.#} giorni' -f $d.TotalDays
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (Test-Path $conf) {
|
|
41
|
+
try {
|
|
42
|
+
$w = Get-Content $conf -Raw | ConvertFrom-Json
|
|
43
|
+
$progetti = @($w.pipeline.PSObject.Properties | ForEach-Object { $_.Value.progetto })
|
|
44
|
+
if ($progetti.Count -eq 1) { $attivita = "NOEVA snapshot $($progetti[0])" }
|
|
45
|
+
} catch { }
|
|
46
|
+
}
|
|
47
|
+
# Senza worker.json leggibile: la prima attivita' del worker che c'e' su questa macchina.
|
|
48
|
+
if ($attivita -eq 'NOEVA snapshot' -and (Get-Command Get-ScheduledTask -ErrorAction SilentlyContinue)) {
|
|
49
|
+
$trovata = Get-ScheduledTask -TaskName 'NOEVA snapshot *' -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
50
|
+
if ($trovata) { $attivita = $trovata.TaskName }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
Write-Host ''
|
|
54
|
+
Write-Host "NOEVA Analytics - gli ultimi giri del worker degli snapshot" -ForegroundColor Cyan
|
|
55
|
+
Write-Host ("macchina {0} - {1} - {2}" -f $env:COMPUTERNAME, $Cartella, (Get-Date -Format 'dd/MM/yyyy HH:mm'))
|
|
56
|
+
|
|
57
|
+
# --- l'attivita' pianificata ------------------------------------------------------
|
|
58
|
+
Write-Host ''
|
|
59
|
+
Write-Host "== L'attivita' pianificata '$attivita'"
|
|
60
|
+
$t = if (Get-Command Get-ScheduledTask -ErrorAction SilentlyContinue) { Get-ScheduledTask -TaskName $attivita -ErrorAction SilentlyContinue }
|
|
61
|
+
if (-not $t) {
|
|
62
|
+
Riga 'attivita''' 'NO' "non c'e': va rilanciato installa.ps1"
|
|
63
|
+
} else {
|
|
64
|
+
$info = Get-ScheduledTaskInfo -TaskName $attivita
|
|
65
|
+
$codici = @{
|
|
66
|
+
'0' = 'ok'
|
|
67
|
+
'267009' = 'in corso adesso'
|
|
68
|
+
'267011' = 'non e'' mai partita'
|
|
69
|
+
'2147943785' = 'Windows rifiuta l''accesso: manca il diritto "Accesso come processo batch"'
|
|
70
|
+
'2147942402' = 'file non trovato: la cartella e'' cambiata, rilancia installa.ps1'
|
|
71
|
+
'2147943726' = 'password Windows sbagliata o cambiata: rilancia installa.ps1'
|
|
72
|
+
}
|
|
73
|
+
$r = [int64]$info.LastTaskResult
|
|
74
|
+
if ($r -lt 0) { $r += 4294967296 }
|
|
75
|
+
$significato = if ($codici.ContainsKey("$r")) { $codici["$r"] } else { 'codice 0x{0:X8}' -f $r }
|
|
76
|
+
$stato = if ($t.State -eq 'Disabled') { 'NO' } elseif ($r -in 0, 267009) { 'ok' } elseif ($r -eq 267011) { 'avviso' } else { 'NO' }
|
|
77
|
+
Riga 'stato' $stato ("{0}, ultimo avvio {1:dd/MM HH:mm}, {2}" -f $t.State, $info.LastRunTime, $significato)
|
|
78
|
+
if ($t.State -eq 'Disabled') {
|
|
79
|
+
Riga 'disattivata' 'NO' "non parte finche' non si riaccende: i giri di NOEVA restano in coda"
|
|
80
|
+
Write-Host " Enable-ScheduledTask -TaskName '$attivita'; Start-ScheduledTask -TaskName '$attivita'" -ForegroundColor Yellow
|
|
81
|
+
Write-Host " (installa.ps1 la spegne durante un aggiornamento e la riaccende al passo 7)" -ForegroundColor Yellow
|
|
82
|
+
}
|
|
83
|
+
Riga 'prossimo avvio' 'info' ("{0:dd/MM HH:mm} (ogni 5 minuti chiede a NOEVA se c'e' un giro)" -f $info.NextRunTime)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# --- ascolta.log ------------------------------------------------------------------
|
|
87
|
+
Write-Host ''
|
|
88
|
+
Write-Host "== I giri in ascolta.log"
|
|
89
|
+
if (-not (Test-Path $log)) {
|
|
90
|
+
Riga 'ascolta.log' 'avviso' "non c'e' ancora: nessun giro"
|
|
91
|
+
Write-Host ''
|
|
92
|
+
exit 0
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
$righe = Get-Content $log -Encoding UTF8
|
|
96
|
+
$reTs = '^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d) '
|
|
97
|
+
$tutti = New-Object System.Collections.ArrayList
|
|
98
|
+
$corrente = $null
|
|
99
|
+
$ultimoAscolto = $null
|
|
100
|
+
$erroriApi = New-Object System.Collections.ArrayList
|
|
101
|
+
|
|
102
|
+
foreach ($riga in $righe) {
|
|
103
|
+
if ($riga -match ($reTs + 'in ascolto per')) {
|
|
104
|
+
$ultimoAscolto = [datetime]$Matches[1]
|
|
105
|
+
} elseif ($riga -match ($reTs + 'non riesco a chiedere lavoro all''API: (.*)$')) {
|
|
106
|
+
[void]$erroriApi.Add([pscustomobject]@{ Quando = [datetime]$Matches[1]; Testo = $Matches[2] })
|
|
107
|
+
} elseif ($riga -match ($reTs + '(\S+): processo (\S+), giro in (.+)$')) {
|
|
108
|
+
$corrente = [pscustomobject]@{
|
|
109
|
+
Inizio = [datetime]$Matches[1]; Pipeline = $Matches[2]; Processo = $Matches[3]
|
|
110
|
+
Cartella = $Matches[4].Trim(); Fine = $null; Esito = 'interrotto'; Dettaglio = ''
|
|
111
|
+
Versione = ''; DataDato = ''; Avvisi = 0
|
|
112
|
+
}
|
|
113
|
+
[void]$tutti.Add($corrente)
|
|
114
|
+
} elseif ($corrente -and $riga -match '^\s+data del dato: (\S+)') {
|
|
115
|
+
$corrente.DataDato = $Matches[1]
|
|
116
|
+
} elseif ($corrente -and $riga -match '^\s+(\d+) cifre di controllo spostate entro la tolleranza') {
|
|
117
|
+
$corrente.Avvisi = [int]$Matches[1]
|
|
118
|
+
} elseif ($corrente -and $riga -match ($reTs + '\S+: pubblicata (\S+) in ([\d.]+)s')) {
|
|
119
|
+
$corrente.Fine = [datetime]$Matches[1]; $corrente.Esito = 'pubblicato'; $corrente.Versione = $Matches[2]
|
|
120
|
+
$corrente = $null
|
|
121
|
+
} elseif ($corrente -and $riga -match ($reTs + '\S+: FALLITO, (.*)$')) {
|
|
122
|
+
$corrente.Fine = [datetime]$Matches[1]; $corrente.Esito = 'FALLITO'; $corrente.Dettaglio = $Matches[2]
|
|
123
|
+
$corrente = $null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
$ok = @($tutti | Where-Object Esito -eq 'pubblicato')
|
|
128
|
+
$ko = @($tutti | Where-Object Esito -eq 'FALLITO')
|
|
129
|
+
Riga 'giri nel log' 'info' ("{0} in tutto: {1} pubblicati, {2} falliti, dal {3:dd/MM/yyyy}" -f $tutti.Count, $ok.Count, $ko.Count, $(if ($tutti.Count) { $tutti[0].Inizio } else { Get-Date }))
|
|
130
|
+
|
|
131
|
+
if ($ultimoAscolto) {
|
|
132
|
+
$fa = (Get-Date) - $ultimoAscolto
|
|
133
|
+
$stato = if ($fa.TotalMinutes -le 15) { 'ok' } else { 'NO' }
|
|
134
|
+
$nota = if ($stato -eq 'ok') { '' } else { ': l''attivita'' non parte, guarda lo stato qui sopra' }
|
|
135
|
+
Riga 'ultimo controllo' $stato ("{0:dd/MM HH:mm}, {1} fa{2}" -f $ultimoAscolto, (Durata $fa), $nota)
|
|
136
|
+
}
|
|
137
|
+
if ($ok.Count) {
|
|
138
|
+
$u = $ok[-1]
|
|
139
|
+
$fa = (Get-Date) - $u.Fine
|
|
140
|
+
Riga 'ultimo pubblicato' $(if ($fa.TotalHours -le 26) { 'ok' } else { 'avviso' }) ("{0:dd/MM HH:mm}, {1} fa, versione {2}, dato al {3}" -f $u.Fine, (Durata $fa), $u.Versione, $u.DataDato)
|
|
141
|
+
} else {
|
|
142
|
+
Riga 'ultimo pubblicato' 'NO' 'nessun giro pubblicato in ascolta.log'
|
|
143
|
+
}
|
|
144
|
+
$recenti = @($erroriApi | Where-Object { $_.Quando -gt (Get-Date).AddDays(-1) })
|
|
145
|
+
if ($recenti.Count) {
|
|
146
|
+
Riga 'errori verso NOEVA' 'avviso' ("{0} nelle ultime 24 ore, l'ultimo {1:dd/MM HH:mm}: {2}" -f $recenti.Count, $recenti[-1].Quando, $recenti[-1].Testo)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
Write-Host ''
|
|
150
|
+
Write-Host ("== Gli ultimi {0} giri (il piu' recente in alto)" -f [Math]::Min($Giri, $tutti.Count))
|
|
151
|
+
if (-not $tutti.Count) { Write-Host ' nessun giro ancora' }
|
|
152
|
+
$mostrati = @($tutti | Select-Object -Last $Giri)
|
|
153
|
+
[array]::Reverse($mostrati)
|
|
154
|
+
foreach ($g in $mostrati) {
|
|
155
|
+
$durata = if ($g.Fine) { Durata ($g.Fine - $g.Inizio) } else { '-' }
|
|
156
|
+
switch ($g.Esito) {
|
|
157
|
+
'pubblicato' {
|
|
158
|
+
$avv = if ($g.Avvisi) { ", $($g.Avvisi) avvisi" } else { '' }
|
|
159
|
+
Riga ('{0:ddd dd/MM HH:mm}' -f $g.Inizio) 'ok' ("pubblicato {0}, dato al {1}, {2}{3}" -f $g.Versione, $g.DataDato, $durata, $avv)
|
|
160
|
+
}
|
|
161
|
+
'FALLITO' {
|
|
162
|
+
Riga ('{0:ddd dd/MM HH:mm}' -f $g.Inizio) 'NO' ("FALLITO dopo {0}: {1}" -f $durata, $g.Dettaglio)
|
|
163
|
+
}
|
|
164
|
+
default {
|
|
165
|
+
$inCorso = ((Get-Date) - $g.Inizio).TotalMinutes -lt 30 -and $g -eq $tutti[-1]
|
|
166
|
+
Riga ('{0:ddd dd/MM HH:mm}' -f $g.Inizio) $(if ($inCorso) { 'info' } else { 'avviso' }) $(if ($inCorso) { 'in corso adesso' } else { 'interrotto senza esito (macchina spenta o processo chiuso?)' })
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# --- le cifre di controllo degli ultimi giri ----------------------------------------
|
|
172
|
+
# giro.json resta per gli ultimi 5 giri di ogni progetto (ascolta.py, GIRI_TENUTI).
|
|
173
|
+
$ultimi = @($tutti | Where-Object { $_.Cartella -and (Test-Path -LiteralPath ([IO.Path]::Combine($_.Cartella, 'giro.json'))) } | Select-Object -Last 1)
|
|
174
|
+
if ($ultimi.Count) {
|
|
175
|
+
$g = $ultimi[0]
|
|
176
|
+
Write-Host ''
|
|
177
|
+
Write-Host ("== Le cifre di controllo del giro del {0:dd/MM HH:mm}" -f $g.Inizio)
|
|
178
|
+
try {
|
|
179
|
+
$rep = Get-Content -LiteralPath ([IO.Path]::Combine($g.Cartella, 'giro.json')) -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
180
|
+
$controlli = @($rep.controlli | Where-Object { $_.PSObject.Properties.Name -contains 'esito' })
|
|
181
|
+
$fuori = @($controlli | Where-Object { $_.esito -ne 'ok' })
|
|
182
|
+
Riga 'controlli' $(if (@($fuori | Where-Object esito -eq 'diverso').Count) { 'NO' } elseif ($fuori.Count) { 'avviso' } else { 'ok' }) ("{0} in tutto, {1} ok" -f $controlli.Count, ($controlli.Count - $fuori.Count))
|
|
183
|
+
foreach ($c in $fuori) {
|
|
184
|
+
$scarto = if ([double]$c.atteso -ne 0) { ' ({0:+0.00;-0.00}%)' -f ((([double]$c.trovato - [double]$c.atteso) / [Math]::Abs([double]$c.atteso)) * 100) } else { '' }
|
|
185
|
+
$stato = if ($c.esito -eq 'diverso') { 'NO' } else { 'avviso' }
|
|
186
|
+
Write-Host (' {0,-8}' -f $stato) -ForegroundColor $(if ($stato -eq 'NO') { 'Red' } else { 'Yellow' }) -NoNewline
|
|
187
|
+
Write-Host (" {0}" -f $c.nome)
|
|
188
|
+
Write-Host (" trovato {0:N2}, atteso {1:N2}{2}" -f [double]$c.trovato, [double]$c.atteso, $scarto)
|
|
189
|
+
}
|
|
190
|
+
if (@($fuori | Where-Object esito -eq 'diverso').Count) {
|
|
191
|
+
Write-Host ''
|
|
192
|
+
Write-Host ' Una cifra fuori tolleranza ferma la pubblicazione: online resta lo snapshot di prima.' -ForegroundColor Yellow
|
|
193
|
+
Write-Host ' Si guarda la cifra (snapshot.yml del progetto), non si forza.' -ForegroundColor Yellow
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
Riga 'giro.json' 'avviso' "non leggibile: $($_.Exception.Message)"
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
Write-Host ''
|
|
201
|
+
Write-Host "Il log completo: $log"
|
|
202
|
+
Write-Host ''
|
package/core.js
CHANGED
|
@@ -209,6 +209,47 @@ export const ASSETSIGNORE = [
|
|
|
209
209
|
const ROTTE_DEL_CONTENITORE = new Set(['', 'vendor', 'sessione', 'logout']);
|
|
210
210
|
const RIFERIMENTO = /(?:(?:href|src)=["']|fetch\(\s*["'`]|from\s+["']|import\(\s*["'`]|url\(\s*["']?)\/([^"'`)/?#\s]*)/g;
|
|
211
211
|
|
|
212
|
+
// L'AI passa solo da NOEVA (inferenza NOEVA o NOEVA Agents, ADR 0006): nessun file
|
|
213
|
+
// del progetto lancia la CLI di Claude o chiama un'API di modelli diretta, e
|
|
214
|
+
// serve-app.py non espone le rotte che la lanciavano. Non c'e' eccezione da
|
|
215
|
+
// dichiarare: un token AI che non passa da NOEVA ferma la verifica.
|
|
216
|
+
export const AI_FUORI_DA_NOEVA = [
|
|
217
|
+
[/\bwhich\(\s*["'`]claude["'`]/, 'cerca la CLI di Claude'],
|
|
218
|
+
[/["'`]claude["'`]\s*,\s*["'`]-(?:p|-print|-resume|-session-id|-output-format)\b/, 'lancia la CLI di Claude'],
|
|
219
|
+
[/\bclaude\s+(?:-p|--print)\b/, 'lancia la CLI di Claude'],
|
|
220
|
+
[/\.local\/bin\/claude\b/, 'cerca la CLI di Claude'],
|
|
221
|
+
[/\bapi\.anthropic\.com\b|@anthropic-ai\/|^\s*(?:import|from)\s+anthropic\b|\bANTHROPIC_API_KEY\b/m, "chiama l'API di Anthropic diretta"],
|
|
222
|
+
[/\bapi\.openai\.com\b|^\s*(?:import|from)\s+openai\b|\bOPENAI_API_KEY\b/m, "chiama l'API di OpenAI diretta"],
|
|
223
|
+
[/\bbedrock-runtime\b|generativelanguage\.googleapis\.com/, 'chiama un servizio di modelli diretto'],
|
|
224
|
+
[/["'`]\/ai\/(?:chat|chat\/storia|tour|presentazione\/modifica)["'`]/, 'serve una rotta che lanciava la CLI di Claude'],
|
|
225
|
+
];
|
|
226
|
+
const FUORI_DALLA_RICERCA = new Set(['node_modules', '.git', '.wren', '.venv', 'venv', '__pycache__', 'target', 'dist',
|
|
227
|
+
'vendor', '.claude', '.wrangler', 'dati']);
|
|
228
|
+
|
|
229
|
+
function fileDelCodice(cartella) {
|
|
230
|
+
const fuori = [];
|
|
231
|
+
for (const voce of readdirSync(cartella, { withFileTypes: true })) {
|
|
232
|
+
if (voce.isDirectory()) {
|
|
233
|
+
if (!FUORI_DALLA_RICERCA.has(voce.name)) fuori.push(...fileDelCodice(join(cartella, voce.name)));
|
|
234
|
+
} else if (/\.(py|js|mjs|cjs|ts|sh|ps1|html)$/.test(voce.name)) fuori.push(join(cartella, voce.name));
|
|
235
|
+
}
|
|
236
|
+
return fuori;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function aiFuoriDaNoeva(radice) {
|
|
240
|
+
const trovati = new Map();
|
|
241
|
+
for (const f of fileDelCodice(radice)) {
|
|
242
|
+
const testo = readFileSync(f, 'utf8');
|
|
243
|
+
for (const [re, cosa] of AI_FUORI_DA_NOEVA) {
|
|
244
|
+
const m = re.exec(testo);
|
|
245
|
+
if (!m) continue;
|
|
246
|
+
const dove = `${posix(relative(radice, f))}:${testo.slice(0, m.index).split('\n').length}`;
|
|
247
|
+
if (!trovati.has(dove)) trovati.set(dove, `${dove} ${cosa}: l'AI passa solo da NOEVA (inferenza o Agents)`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return [...trovati.values()];
|
|
251
|
+
}
|
|
252
|
+
|
|
212
253
|
function trackedDaGit(radice) {
|
|
213
254
|
if (!existsSync(join(radice, '.git'))) return null;
|
|
214
255
|
try {
|
|
@@ -258,6 +299,8 @@ export function verifica(radice, config) {
|
|
|
258
299
|
}
|
|
259
300
|
}
|
|
260
301
|
|
|
302
|
+
errori.push(...aiFuoriDaNoeva(radice));
|
|
303
|
+
|
|
261
304
|
const tracciati = trackedDaGit(radice);
|
|
262
305
|
if (tracciati) {
|
|
263
306
|
for (const f of tracciati) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noeva/analytics-core",
|
|
3
|
-
"version": "0.
|
|
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": {
|