@noeva/analytics-core 0.3.3 → 0.4.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.
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noeva/analytics-core",
3
- "version": "0.3.3",
3
+ "version": "0.4.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": {