@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.
@@ -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
- sorgente, dest = pathlib.Path(tmp) / "sorgente", pathlib.Path(tmp) / "modello"
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 ({len(c.LINGUE)} lingue)")
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
- $credenziali = Get-Credential -UserName "$env:USERDOMAIN\$env:USERNAME" -Message "Password Windows di $env:USERNAME"
314
- if (-not $credenziali) { Ferma 'attivita''' 'nessuna password Windows: senza, l''attivita'' non gira a sessione chiusa' }
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 $credenziali.GetNetworkCredential().Password -RunLevel Limited -Force | Out-Null
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.4.1",
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": {