@noeva/analytics-core 0.3.2 → 0.4.0

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.
@@ -60,11 +60,17 @@ def versione_di(radice: pathlib.Path) -> str:
60
60
 
61
61
 
62
62
  def estrai_da_head(radice: pathlib.Path, dest: pathlib.Path) -> None:
63
- """I file del modello com'erano nel commit, senza toccare il disco del repository."""
64
- presenti = [p for p in [*FILE_MODELLO, "consegna.yml"] if git(radice, "ls-tree", "--name-only", "HEAD", p).strip()]
63
+ """I file del modello com'erano nel commit, senza toccare il disco del repository.
64
+
65
+ Con loro quelli da cui si genera la scheda del progetto (scheda.py): analytics.json
66
+ e le index.html di apps/, per il titolo del progetto e delle dashboard."""
67
+ presenti = [p for p in [*FILE_MODELLO, "consegna.yml", "analytics.json"]
68
+ if git(radice, "ls-tree", "--name-only", "HEAD", p).strip()]
65
69
  mancanti = {"wren_project.yml", "relationships.yml", "models"} - set(presenti)
66
70
  if mancanti:
67
71
  raise SystemExit(f"nel commit mancano {sorted(mancanti)}: non e' il repository di un progetto con un modello")
72
+ presenti += [p for p in git(radice, "ls-tree", "-r", "--name-only", "HEAD", "apps").splitlines()
73
+ if p.count("/") <= 2 and p.endswith("/index.html")]
68
74
  archivio = subprocess.run(["git", "archive", "--format=tar", "HEAD", *presenti], cwd=radice,
69
75
  check=True, capture_output=True).stdout
70
76
  with tempfile.TemporaryFile() as t:
@@ -0,0 +1,146 @@
1
+ """La scheda del progetto: la regola knowledge/rules/00-progetto.md, generata (NW-1286).
2
+
3
+ Il plugin noeva-analytics porta il metodo, uguale per tutti; il server MCP del
4
+ progetto porta cio' che e' vero del progetto (NW-1261). La scheda e' la parte di
5
+ quel vero che serve prima di ogni domanda: di chi sono i dati, cosa coprono,
6
+ dove stanno le dashboard, chi e' il contatto in 4D. La skill `analisi` la trova
7
+ con get_instructions.
8
+
9
+ Si genera dal repository del progetto, mai a mano: modello_di_consegna (consegna.py)
10
+ la scrive nel modello che si consegna, e da li' arriva uguale al modello pubblicato
11
+ su R2, al servizio locale che compila il repository aperto e alla cartella
12
+ consegnata. Nel repository non c'e': una scheda in git sarebbe una copia da tenere
13
+ allineata, e prima o poi qualcuno la ritoccherebbe.
14
+
15
+ Legge, tutti facoltativi tranne analytics.json:
16
+
17
+ analytics.json l'id del progetto
18
+ consegna.yml cliente, descrizione, contatto_4d, la `cosa` di ogni sorgente,
19
+ modello_r2.url (il contenitore, default https://analytics.noeva.ai)
20
+ wren_project.yml il tipo di database
21
+ models/ quanti modelli
22
+ apps/index.html il titolo del progetto, che e' quello dell'indice
23
+ apps/*/index.html le dashboard, col titolo della pagina
24
+
25
+ Il registro dei progetti (lib/rilascio/progetti.json) sta nel monorepo di 4D e il
26
+ repository di un cliente non lo vede (il pacchetto non nomina clienti, ADR 0005):
27
+ la scheda dice solo cio' che il repository del progetto sa di se'.
28
+
29
+ Nessuna data e nessuna versione dentro: la stessa sorgente da' la stessa scheda,
30
+ byte per byte, da qualunque strada passi.
31
+
32
+ python3 lib/modello/scheda.py <repository> stampa la scheda
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import html
37
+ import json
38
+ import pathlib
39
+ import re
40
+ import sys
41
+
42
+ import yaml
43
+
44
+ FILE = "00-progetto.md"
45
+ CONTENITORE = "https://analytics.noeva.ai"
46
+ # Le cartelle di apps/ che non sono dashboard: il motore e i moduli di pagina.
47
+ NON_DASHBOARD = {"assets", "dati", "vendor"}
48
+ TITOLO = re.compile(r"<title>(.*?)</title>", re.S | re.I)
49
+
50
+
51
+ def _leggi_yaml(p: pathlib.Path) -> dict:
52
+ return (yaml.safe_load(p.read_text(encoding="utf-8")) or {}) if p.is_file() else {}
53
+
54
+
55
+ def _titolo(p: pathlib.Path) -> str | None:
56
+ if not p.is_file():
57
+ return None
58
+ m = TITOLO.search(p.read_text(encoding="utf-8", errors="replace"))
59
+ return " ".join(html.unescape(m.group(1)).split()) if m else None
60
+
61
+
62
+ def identificativo(sorgente: pathlib.Path) -> str:
63
+ """L'id del progetto: analytics.json, o il nome della cartella project-<id> del monorepo."""
64
+ f = sorgente / "analytics.json"
65
+ if f.is_file():
66
+ return json.loads(f.read_text(encoding="utf-8"))["progetto"]
67
+ return sorgente.name.removeprefix("project-")
68
+
69
+
70
+ def dashboard(sorgente: pathlib.Path) -> list[tuple[str, str]]:
71
+ """(cartella, titolo) delle dashboard in apps/, in ordine di cartella."""
72
+ apps = sorgente / "apps"
73
+ if not apps.is_dir():
74
+ return []
75
+ esito = []
76
+ for d in sorted(p for p in apps.iterdir() if p.is_dir() and p.name not in NON_DASHBOARD):
77
+ t = _titolo(d / "index.html")
78
+ if t:
79
+ esito.append((d.name, t))
80
+ return esito
81
+
82
+
83
+ def scheda(sorgente: pathlib.Path, conf: dict | None = None) -> str:
84
+ """Il testo di 00-progetto.md per il repository in `sorgente`.
85
+
86
+ `conf` e' consegna.yml intero (default: quello di `sorgente`); chi costruisce
87
+ con un'altra configurazione (costruisci.py --config) la passa qui."""
88
+ conf = _leggi_yaml(sorgente / "consegna.yml") if conf is None else conf
89
+ ident = identificativo(sorgente)
90
+ progetto = _leggi_yaml(sorgente / "wren_project.yml")
91
+ titolo = _titolo(sorgente / "apps" / "index.html")
92
+ cliente = conf.get("cliente") or titolo or ident
93
+ base = f"{((conf.get('modello_r2') or {}).get('url') or CONTENITORE).rstrip('/')}/{ident}/"
94
+ modelli = sorted(p.parent.name for p in (sorgente / "models").glob("*/metadata.yml"))
95
+ sorgenti = [(k, s.get("cosa")) for k, s in (conf.get("sorgenti") or {}).items() if s.get("cosa")]
96
+
97
+ r = [
98
+ f"# Il progetto: {cliente}",
99
+ "",
100
+ "Scheda generata dal repository del progetto insieme al modello che si consegna:",
101
+ "non si scrive a mano e si rigenera con il modello. Dice di chi sono i dati, cosa",
102
+ "coprono, dove stanno le dashboard e chi e' il contatto in 4D; cosa significano i",
103
+ "numeri lo dicono le altre regole.",
104
+ "",
105
+ f"- **Cliente**: {cliente}",
106
+ f"- **Progetto NOEVA Analytics**: `{ident}`" + (f", «{titolo}»" if titolo and titolo != cliente else ""),
107
+ ]
108
+ if conf.get("descrizione"):
109
+ r.append(f"- **In breve**: {' '.join(str(conf['descrizione']).split())}")
110
+ r += ["", "## Cosa coprono i dati", ""]
111
+ if len(sorgenti) == 1:
112
+ r.append(f"- {sorgenti[0][1]}")
113
+ else:
114
+ r += [f"- `{k}`: {cosa}" for k, cosa in sorgenti]
115
+ db = progetto.get("data_source")
116
+ r.append(f"- Il modello: {len(modelli)} modelli" + (f" su {db}" if db else "")
117
+ + "; l'elenco con list_models, il significato con describe_model.")
118
+ if not sorgenti:
119
+ r.append("- La copertura per sorgente non e' descritta (`cosa` in consegna.yml): la dicono "
120
+ "le regole e la descrizione dei modelli.")
121
+
122
+ r += ["", "## Le dashboard", "",
123
+ "Dietro il login NOEVA: le apre chi ha il progetto nel suo workspace.", "",
124
+ f"- Indice: {base}"]
125
+ r += [f"- {t}: {base}{cartella}/" for cartella, t in dashboard(sorgente)]
126
+
127
+ r += ["", "## Il contatto in 4D", ""]
128
+ contatto = conf.get("contatto_4d")
129
+ if contatto:
130
+ r.append(f"- {' '.join(str(contatto).split())}")
131
+ else:
132
+ r.append("- Non indicato (`contatto_4d` in consegna.yml).")
133
+ r.append("- Per scrivere a 4D (una quadratura che non torna, una modifica) c'e' la Richiesta a 4D.")
134
+ return "\n".join(r) + "\n"
135
+
136
+
137
+ def scrivi(sorgente: pathlib.Path, rules: pathlib.Path, conf: dict | None = None) -> pathlib.Path:
138
+ """Scrive la scheda in `rules`/00-progetto.md, sopra un'eventuale copia a mano."""
139
+ rules.mkdir(parents=True, exist_ok=True)
140
+ f = rules / FILE
141
+ f.write_text(scheda(sorgente, conf), encoding="utf-8")
142
+ return f
143
+
144
+
145
+ if __name__ == "__main__":
146
+ sys.stdout.write(scheda(pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()))
@@ -40,12 +40,18 @@ def carica_commento(progetto: pathlib.Path):
40
40
  modulo = importlib.util.module_from_spec(spec)
41
41
  sys.path.insert(0, str(progetto))
42
42
  spec.loader.exec_module(modulo)
43
- for nome in ("istruzioni", "conoscenza", "MODI", "SCHEMA", "LINGUE"):
43
+ for nome in ("conoscenza", "LINGUE"):
44
44
  if not hasattr(modulo, nome):
45
45
  raise SystemExit(f"{progetto.name}/commento.py non ha {nome}: non e' allineato allo standard")
46
46
  return modulo
47
47
 
48
48
 
49
+ def ha_tour(c) -> bool:
50
+ """Il commento guidato e' facoltativo: un progetto con la sola chat ha in
51
+ commento.py la conoscenza ma non istruzioni, modi e schema del tour."""
52
+ return all(hasattr(c, nome) for nome in ("istruzioni", "MODI", "SCHEMA"))
53
+
54
+
49
55
  def istruzioni_sito(chat, lingua: str) -> str:
50
56
  """Le istruzioni della chat per il sito: li' ha solo run_sql sullo snapshot, non recall_queries,
51
57
  quindi senza la riga della memoria delle domande confermate (GUIDA_MEMORIA di chat.py)."""
@@ -84,7 +90,7 @@ def main(argv: list[str]) -> int:
84
90
  uscita.mkdir(parents=True, exist_ok=True)
85
91
  adesso = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
86
92
  scritti = 0
87
- for pagina in pagine(progetto):
93
+ for pagina in pagine(progetto) if ha_tour(c) else []:
88
94
  for lingua in c.LINGUE:
89
95
  sistema = c.istruzioni(lingua) + "\n\n# Knowledge base del progetto\n\n" + c.conoscenza(pagina)
90
96
  (uscita / f"commento-{pagina}.{lingua}.json").write_text(json.dumps(
@@ -104,6 +110,8 @@ def main(argv: list[str]) -> int:
104
110
  "generato": adesso},
105
111
  ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
106
112
  n_chat += 1
113
+ if not scritti and not n_chat:
114
+ raise SystemExit(f"{progetto.name}: ne' commento guidato ne' chat, nessun prompt da scrivere")
107
115
  print(f" apps/dati/ai: {scritti} prompt del commento, {n_chat} della chat ({len(c.LINGUE)} lingue)")
108
116
  return 0
109
117
 
@@ -101,6 +101,8 @@ import pyarrow as pa
101
101
  import pyarrow.parquet as pq
102
102
  import yaml
103
103
 
104
+ from manifesto import id_progetto
105
+
104
106
  ROMA = ZoneInfo("Europe/Rome")
105
107
 
106
108
 
@@ -403,7 +405,8 @@ def main(argv: list[str]) -> int:
403
405
  adesso = dt.datetime.now(ROMA).isoformat(timespec="seconds")
404
406
  costanti = {**(conf.get("costanti") or {}), **opt["costanti"]}
405
407
  manifesto = {
406
- "progetto": progetto.name.removeprefix("project-"),
408
+ # l'id nel contenitore (`<id>`, non la cartella): la Vista della pagina lo dichiara (standard §5)
409
+ "progetto": id_progetto(progetto),
407
410
  "generato": adesso,
408
411
  "data_del_dato": data_del_dato,
409
412
  "sorgente": sorgente,
@@ -19,6 +19,27 @@ import pathlib
19
19
  import sys
20
20
 
21
21
 
22
+ def id_progetto(cartella: pathlib.Path) -> str:
23
+ """L'id del progetto nel contenitore (`<id>`), non il nome della cartella.
24
+
25
+ Il repository di un progetto (ADR 0005) lo scrive in analytics.json, alla sua
26
+ radice: si cerca risalendo da `cartella` (il progetto, o la sua apps/dati).
27
+ Senza, il nome della cartella del progetto senza il prefisso: `project-<id>`
28
+ nel monorepo, `noeva-analytics-<id>` per un clone del repository, `<id>` per
29
+ il pacchetto del worker."""
30
+ cartella = cartella.resolve()
31
+ for d in (cartella, *cartella.parents):
32
+ f = d / "analytics.json"
33
+ if f.is_file():
34
+ voce = json.loads(f.read_text(encoding="utf-8")).get("progetto")
35
+ if voce:
36
+ return voce
37
+ break
38
+ if cartella.name == "dati" and cartella.parent.name == "apps":
39
+ cartella = cartella.parent.parent
40
+ return cartella.name.removeprefix("project-").removeprefix("noeva-analytics-")
41
+
42
+
22
43
  def main(argv: list[str]) -> int:
23
44
  if len(argv) < 2:
24
45
  print(__doc__)
@@ -36,7 +57,8 @@ def main(argv: list[str]) -> int:
36
57
  generato = json.loads(f.read_text(encoding="utf-8")).get("generato")
37
58
  data_del_dato = str(generato)[:10] if generato else None
38
59
  generato = dt.datetime.fromtimestamp(piu_recente).astimezone().isoformat(timespec="seconds")
39
- manifesto = {"generato": generato, "data_del_dato": data_del_dato or generato[:10], "file": voci}
60
+ manifesto = {"progetto": id_progetto(cartella), "generato": generato,
61
+ "data_del_dato": data_del_dato or generato[:10], "file": voci}
40
62
  (cartella / "snapshot.json").write_text(json.dumps(manifesto, ensure_ascii=False, indent=1), encoding="utf-8")
41
63
  print(f" snapshot.json: {len(voci)} file, dato al {manifesto['data_del_dato']}")
42
64
  return 0
package/core.js CHANGED
@@ -247,6 +247,17 @@ export function verifica(radice, config) {
247
247
  errori.push(`file gestito ${d.stato}: ${d.percorso} — lo riscrive \`analytics upgrade\`, o va in non_gestiti`);
248
248
  }
249
249
 
250
+ // Le risposte alle domande si registrano in un modo solo (standard §6.4): la
251
+ // pagina del progetto che mostra le domande usa risposte.js, che salva sul Mac,
252
+ // online dietro il cancello, e dove non si salva esporta su file.
253
+ const paginaProgetto = join(radice, 'apps', 'progetto');
254
+ if (existsSync(paginaProgetto)) {
255
+ const testi = fileSotto(paginaProgetto).filter((f) => /\.(html|js)$/.test(f)).map((f) => readFileSync(f, 'utf8'));
256
+ if (testi.some((x) => x.includes('lista-domande')) && !testi.some((x) => x.includes('montaRisposte'))) {
257
+ errori.push('apps/progetto mostra le domande ma non registra le risposte con montaRisposte di apps/assets/risposte.js (standard §6.4)');
258
+ }
259
+ }
260
+
250
261
  const tracciati = trackedDaGit(radice);
251
262
  if (tracciati) {
252
263
  for (const f of tracciati) {
@@ -44,11 +44,18 @@ USER wren
44
44
  WORKDIR /home/wren
45
45
 
46
46
  # mcp<2: wrenai importa mcp.server.fastmcp, tolto nell'SDK 2.x.
47
+ # `oracle` porta oracledb in modalita' thin (Oracle 12.1 e successivi, niente
48
+ # Instant Client).
47
49
  ARG WRENAI_VERSION=0.15.0
48
50
  RUN pip install --user \
49
- "wrenai[mssql,clickhouse,postgres,memory-onnx,mcp,main]==${WRENAI_VERSION}" \
51
+ "wrenai[mssql,clickhouse,postgres,oracle,memory-onnx,mcp,main]==${WRENAI_VERSION}" \
50
52
  "mcp[cli]>=1.19,<2"
51
53
 
54
+ # Il connettore Oracle di wrenai 0.15.0 tronca i decimali dei NUMBER senza
55
+ # precisione: vedi correggi-oracle.py.
56
+ COPY correggi-oracle.py /tmp/correggi-oracle.py
57
+ RUN python /tmp/correggi-oracle.py
58
+
52
59
  RUN python -c "import pyodbc; \
53
60
  drivers = pyodbc.drivers(); \
54
61
  assert any('SQL Server' in d for d in drivers), f'driver ODBC assente: {drivers}'; \
@@ -0,0 +1,39 @@
1
+ """Corregge il connettore Oracle di wrenai 0.15.0, che tronca i decimali.
2
+
3
+ `wren/connector/oracle.py` traduce un NUMBER senza precisione in int64: e' il tipo
4
+ delle colonne non vincolate (molte, nei data mart Oracle) e di ogni espressione
5
+ calcolata da Oracle (SUM, divisioni, ROUND). Il risultato e' 400,68 € letto come
6
+ 400, senza errori. Qui, quando i metadati non dicono la precisione, decidono i
7
+ valori: tutti interi -> int64, altrimenti float64. Un FLOAT(p) (scala -127: e'
8
+ anche il CAST(... AS DOUBLE) che il motore traduce in DOUBLE PRECISION) diventava
9
+ decimal(38,0) e la query falliva: diventa float64.
10
+
11
+ Si applica in build (Dockerfile) e si ferma se il connettore e' cambiato: a una
12
+ versione nuova di wrenai si controlla se la correzione serve ancora.
13
+ """
14
+ import pathlib
15
+ import sys
16
+
17
+ import wren.connector.oracle as connettore
18
+
19
+ file = pathlib.Path(connettore.__file__)
20
+ sorgente = file.read_text()
21
+ prima = """ if db_type == oracledb.DB_TYPE_NUMBER:
22
+ arrow_type = _ora_number_type(precision, scale)"""
23
+ dopo = """ if db_type == oracledb.DB_TYPE_NUMBER and not precision and scale in (None, -127):
24
+ # NUMBER senza precisione: decidono i valori (correggi-oracle.py).
25
+ vals = [v for v in col_values[i] if v is not None]
26
+ arrow_type = pa.int64() if all(isinstance(v, int) for v in vals) else pa.float64()
27
+ elif db_type == oracledb.DB_TYPE_NUMBER and scale == -127:
28
+ # FLOAT(p), anche CAST(... AS DOUBLE PRECISION): virgola mobile.
29
+ arrow_type = pa.float64()
30
+ elif db_type == oracledb.DB_TYPE_NUMBER:
31
+ arrow_type = _ora_number_type(precision, scale)"""
32
+
33
+ if dopo in sorgente:
34
+ print("connettore Oracle: gia' corretto")
35
+ sys.exit(0)
36
+ if sorgente.count(prima) != 1:
37
+ sys.exit("connettore Oracle cambiato: rivedere correggi-oracle.py")
38
+ file.write_text(sorgente.replace(prima, dopo))
39
+ print("connettore Oracle: corretto", file)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noeva/analytics-core",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
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": {