-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapi_server.py
More file actions
1656 lines (1470 loc) · 69.5 KB
/
Copy pathapi_server.py
File metadata and controls
1656 lines (1470 loc) · 69.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# api_server.py | ECOSYSTEM_OS | v1.5 | 2026-06-11
# v1.5: hardening sicurezza (red-team #38) — denylist .env/_VAULT in path_allowed,
# validazione path-aware (no prefix-match), auth X-API-Key per chiamate remote
# Flask API server — serve dati reali al dashboard React
# Endpoints: STATE.json, mente_digest.json, trigger scanner, update state
# Run: python api_server.py (porta 5001)
import sys
import os
import re
import json
import subprocess
import logging
import threading
from pathlib import Path
from datetime import datetime
if sys.stdout is not None and sys.stdout.encoding != "utf-8":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# Processo hidden (avviato da watchdog/START_LOGIN con -WindowStyle Hidden):
# sys.stdout/stderr sono None. torch/HuggingFace/tqdm/chromadb scrivono progress
# bar e warning su stderr -> scrivere su None = crash nativo del worker (reset
# connessione, non eccezione Python). Reindirizza gli stream None su devnull cosi'
# qualunque libreria caricata in-process (es. rag_engine sul GPU) non puo' uccidere
# il processo. Additivo, nessun output utile perso (gira senza console). (#42 blackout)
_devnull = open(os.devnull, "w", encoding="utf-8")
if sys.stdout is None:
sys.stdout = _devnull
if sys.stderr is None:
sys.stderr = _devnull
sys.path.insert(0, str(Path(__file__).resolve().parent))
try:
from CORE.log import get_logger
logger = get_logger("api_server")
except Exception:
logging.basicConfig(level=logging.INFO, format="%(asctime)s [api_server] %(levelname)s %(message)s")
logger = logging.getLogger("api_server")
logger.info("api_server.py caricato")
from flask import Flask, jsonify, request, send_file
from flask_cors import CORS
app = Flask(__name__)
# CORS ristretto: solo dashboard locale, LAN privata e Tailscale — NON "*".
# L'API ha endpoint di lettura/scrittura file: con "*" qualsiasi pagina web aperta
# nel browser potrebbe chiamarla (drive-by su localhost). Niente IP hardcoded: regex di rete.
CORS(app, origins=[
re.compile(r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$"),
re.compile(r"^https?://192\.168\.\d{1,3}\.\d{1,3}(:\d+)?$"), # LAN privata
re.compile(r"^https?://100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}(:\d+)?$"), # Tailscale CGNAT
])
_screen_jobs: dict = {} # job_id → {status, task, result, thread}
# ── RAG GUARD (#53, attacco 04 P2 — cura alla radice del "commit leak") ────────
# Il load dei modelli RAG (torch+chroma) e' costoso e, se fallisce (es. HNSW corrotto
# post-blackout), OGNI retry per-request riserva GB di commit Windows finche' il sistema
# satura (MemoryError su qualunque processo). Regole: UN solo load alla volta (lock),
# e dopo un fallimento si entra in COOLDOWN 300s — niente martello dalla dashboard.
_rag_lock = threading.Lock()
_rag_state: dict = {"fn": None, "failed_at": 0.0}
_RAG_COOLDOWN_S = 300
def _get_rag_search():
"""Ritorna rag_engine.search caricandolo UNA volta (lock + failure-latch)."""
import time as _time
with _rag_lock:
if _rag_state["fn"]:
return _rag_state["fn"]
wait = _RAG_COOLDOWN_S - (_time.time() - _rag_state["failed_at"])
if wait > 0:
raise RuntimeError(f"RAG in cooldown dopo un load fallito — riprova tra {int(wait)}s "
"(protezione commit-leak; se persiste: SERVICES/rag_recover.ps1)")
try:
sys.path.insert(0, str(ROOT))
from NODES.MENTE_RAG.rag_engine import search as _fn
_rag_state["fn"] = _fn
return _fn
except BaseException:
_rag_state["failed_at"] = _time.time()
raise
# ── AUTH: localhost passa libero (la dashboard è same-host); le chiamate REMOTE
# (LAN/Tailscale) richiedono X-API-Key == TI_API_KEY. CORS difende il browser,
# non un curl diretto da un device della tailnet: questo sì. Fail-closed da remoto.
_API_KEY = os.environ.get("TI_API_KEY", "").strip()
_LOCAL_IPS = {"127.0.0.1", "::1", "localhost"}
@app.before_request
def _require_auth():
if request.method == "OPTIONS" or request.path == "/api/health":
return None
if (request.remote_addr or "") in _LOCAL_IPS:
return None # stessa macchina: la dashboard locale
if _API_KEY and request.headers.get("X-API-Key", "") == _API_KEY:
return None # remoto autenticato
return jsonify({"ok": False, "error": "non autorizzato (chiamata remota senza X-API-Key valida)"}), 401
# ── PERCORSI ─────────────────────────────────────────────────
ROOT = Path(__file__).parent
STATE_FILE = ROOT / "BRAIN" / "STATE.json"
DIGEST_FILE = ROOT / "DATA" / "mente_digest.json"
SCANNER = ROOT / "CORE" / "scanner.py" # nuovo path post-riorganizzazione CORE/
SCANNER_LEGACY = ROOT / "NODES" / "MENTE_SCANNER" / "scanner.py" # fallback se CORE/ non ancora migrato
if not SCANNER.exists() and SCANNER_LEGACY.exists():
SCANNER = SCANNER_LEGACY
_home = Path.home()
MENTE_DIR = Path(os.environ.get("MENTE_DIR", str(_home / "MICROINDUSTRY" / "MENTE")))
CONTENT_ENGINE = Path(os.environ.get("CONTENT_ENGINE_DIR", str(_home / "MICROINDUSTRY" / "CONTENT_ENGINE")))
CONTENT_DIR = CONTENT_ENGINE / "produzione_contenuti"
FOTO_DIR = Path(os.environ.get("FOTO_DIR", str(_home / "MICROINDUSTRY" / "FOTO")))
MICROINDUSTRY = _home / "MICROINDUSTRY"
# Radici consentite per /api/open e /api/media (sicurezza)
ALLOWED_ROOTS = [
ROOT.resolve(),
MENTE_DIR.resolve(),
CONTENT_ENGINE.resolve(),
FOTO_DIR.resolve(),
MICROINDUSTRY.resolve(),
]
# File/cartelle con segreti: dentro ROOT ma MAI servibili via API.
# (.env e _VAULT/ vivono dentro ROOT → la sola allowlist non basta a proteggerli)
_DENY_NAMES = {".env"}
_DENY_DIRS = {"_VAULT", "BACKUPS"}
def path_allowed(target: Path) -> bool:
"""Validazione path-aware: target dentro una radice consentita (no prefix-match
su stringa, che lascerebbe passare TITANIUM_OS_OLD) e fuori dalla denylist segreti."""
if target.name in _DENY_NAMES or any(part in _DENY_DIRS for part in target.parts):
return False
return any(target == r or r in target.parents for r in ALLOWED_ROOTS)
# ── HELPERS ──────────────────────────────────────────────────
def read_json(path: Path) -> dict:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception as e:
return {"error": str(e)}
def write_json(path: Path, data: dict):
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
# ── ROUTES ───────────────────────────────────────────────────
@app.get("/api/state")
def get_state():
"""Legge BRAIN/STATE.json live."""
return jsonify(read_json(STATE_FILE))
@app.get("/api/nina/status")
def nina_status():
"""Stato del nodo RAG Nina (canone sess.#44: generazione automatica).
Conta gli EP_N2 nel canone, i semi archiviati (EP_AV in _ARCHIVIO) e riporta
l'ultimo generato dal loop (DATA/nina_state.json). Alimenta il pannello RAG Nina."""
av_dir = ROOT / "CONTENT_ENGINE" / "DATABASE" / "episodes" / "S_AVVENTURA"
archivio = av_dir / "_ARCHIVIO"
canon = sorted(p.name for p in av_dir.glob("EP_N2_*.md")) if av_dir.exists() else []
semi = sorted(p.name for p in archivio.glob("EP_AV_*.md")) if archivio.exists() else []
st = read_json(ROOT / "DATA" / "nina_state.json")
if "error" in st:
st = {}
return jsonify({
"ok": True,
"canon_count": len(canon),
"seed_archive_count": len(semi),
"generated_total": st.get("generated_total", 0),
"last_generated": st.get("last_generated"),
"last_loop": st.get("last_loop"),
"seeds": semi,
"auto": True, # niente gate: il sistema genera e promuove da solo
})
@app.get("/api/nina/archived")
def nina_archived():
"""Tutti gli episodi Nina ARCHIVIATI (per 'RAG Nina = tutti, anche quelli archiviati'):
le ORIGINI (EP_AV, il mondo prima del canone EP_N2) e le VERSIONI PRECEDENTI (reemit).
Sola lettura; il contenuto si apre via /api/file."""
arch = ROOT / "CONTENT_ENGINE" / "DATABASE" / "episodes" / "S_AVVENTURA" / "_ARCHIVIO"
out = []
if arch.exists():
for p in sorted(arch.rglob("*.md")):
rel = p.relative_to(ROOT).as_posix()
categoria = "versione" if "reemit_" in rel else "origine"
title = p.stem
try:
head = p.read_text(encoding="utf-8", errors="ignore")[:600]
m = re.search(r"^title:\s*(.+)$", head, flags=re.M)
if m:
title = m.group(1).strip()
except Exception:
pass
mid = re.match(r"(EP_[A-Z0-9]+_(?:FIN_\d+|M\d+|\d+))", p.stem)
out.append({
"id": mid.group(1) if mid else p.stem,
"title": title, "categoria": categoria,
"file": p.name, "path": str(p),
})
origine = [e for e in out if e["categoria"] == "origine"]
versioni = [e for e in out if e["categoria"] == "versione"]
return jsonify({"ok": True, "total": len(out),
"origine": origine, "versioni": versioni})
@app.patch("/api/state")
def patch_state():
"""Aggiorna campi specifici di STATE.json (merge superficiale)."""
state = read_json(STATE_FILE)
patch = request.get_json(force=True) or {}
state.update(patch)
state["last_update"] = datetime.now().isoformat()
write_json(STATE_FILE, state)
return jsonify({"ok": True, "state": state})
@app.patch("/api/state/pillar/<pillar_id>")
def patch_pillar(pillar_id: str):
"""Aggiorna un pillar specifico."""
state = read_json(STATE_FILE)
patch = request.get_json(force=True) or {}
if "pillars" not in state:
state["pillars"] = {}
state["pillars"].setdefault(pillar_id, {}).update(patch)
state["last_update"] = datetime.now().isoformat()
write_json(STATE_FILE, state)
return jsonify({"ok": True, "pillar": state["pillars"][pillar_id]})
@app.get("/api/digest")
def get_digest():
"""Legge DATA/mente_digest.json."""
if not DIGEST_FILE.exists():
return jsonify({"error": "digest non trovato — esegui scanner prima"}), 404
return jsonify(read_json(DIGEST_FILE))
@app.post("/api/scan")
def run_scan():
"""Lancia MENTE_SCANNER e restituisce il digest aggiornato."""
try:
result = subprocess.run(
[sys.executable, str(SCANNER)],
capture_output=True, text=True, timeout=120,
encoding="utf-8", errors="replace"
)
if result.returncode != 0:
return jsonify({"ok": False, "error": result.stderr[-500:]}), 500
digest = read_json(DIGEST_FILE)
return jsonify({"ok": True, "digest": digest, "stdout": result.stdout[-1000:]})
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "error": "timeout dopo 120s"}), 504
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.get("/api/file")
def read_file():
"""Restituisce il contenuto testo di un file (max 100KB) per preview nel dashboard."""
raw = request.args.get("path", "").strip()
if not raw:
return jsonify({"ok": False, "error": "path mancante"}), 400
target = Path(raw).resolve()
if not path_allowed(target):
return jsonify({"ok": False, "error": "percorso non consentito"}), 403
if not target.exists():
return jsonify({"ok": False, "error": f"non trovato: {target}"}), 404
if target.is_dir():
# Per le cartelle: elenca i figli
children = [
{"name": p.name, "is_dir": p.is_dir(), "size_kb": round(p.stat().st_size / 1024, 1) if p.is_file() else None}
for p in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name))
]
return jsonify({"ok": True, "type": "dir", "path": str(target), "children": children})
size = target.stat().st_size
if size > 200_000:
return jsonify({"ok": False, "error": f"file troppo grande ({size // 1024}KB) — apri esternamente"}), 413
try:
text = target.read_text(encoding="utf-8", errors="replace")
return jsonify({"ok": True, "type": "file", "path": str(target), "content": text, "size_kb": round(size / 1024, 1)})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.post("/api/open")
def open_path():
"""Apre un file o cartella con l'applicazione predefinita Windows (os.startfile)."""
data = request.get_json(force=True) or {}
raw = data.get("path", "").strip()
if not raw:
return jsonify({"ok": False, "error": "path mancante"}), 400
target = Path(raw).resolve()
# Verifica che il path sia dentro una radice consentita
if not path_allowed(target):
return jsonify({"ok": False, "error": "percorso non consentito"}), 403
if not target.exists():
return jsonify({"ok": False, "error": f"non trovato: {target}"}), 404
try:
os.startfile(str(target))
return jsonify({"ok": True, "opened": str(target)})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.get("/api/md-files")
def list_md_files():
"""Lista tutti i file .md in TITANIUM_OS, ordinati per data modifica decrescente."""
SKIP_DIRS = {"BACKUPS", "VERSIONS", "node_modules", "__pycache__", ".git", "venv", ".venv"}
files = []
for p in ROOT.rglob("*.md"):
if any(part in SKIP_DIRS for part in p.parts):
continue
try:
stat = p.stat()
files.append({
"path": str(p),
"rel": str(p.relative_to(ROOT)),
"name": p.name,
"size_kb": round(stat.st_size / 1024, 1),
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
})
except Exception:
pass
files.sort(key=lambda f: f["modified"], reverse=True)
return jsonify({"ok": True, "total": len(files), "files": files})
@app.post("/api/md-save")
def save_md():
"""Salva contenuto in un file .md esistente (solo dentro ROOT)."""
data = request.get_json(force=True) or {}
path_str = data.get("path", "").strip()
content = data.get("content", "")
if not path_str:
return jsonify({"ok": False, "error": "path mancante"}), 400
target = Path(path_str).resolve()
if not path_allowed(target) or not (target == ROOT.resolve() or ROOT.resolve() in target.parents):
return jsonify({"ok": False, "error": "percorso non consentito"}), 403
if target.suffix.lower() != ".md":
return jsonify({"ok": False, "error": "solo file .md consentiti"}), 400
if not target.exists():
return jsonify({"ok": False, "error": "file non trovato"}), 404
target.write_text(content, encoding="utf-8")
return jsonify({"ok": True, "saved": str(target), "size_kb": round(len(content.encode()) / 1024, 1)})
@app.get("/api/digest/search")
def search_digest():
"""Cerca nel digest. Query param: q=testo. Cerca in decisions, specs, milestones, path file."""
q = request.args.get("q", "").strip().lower()
if not q:
return jsonify({"ok": False, "error": "param q mancante"}), 400
if not DIGEST_FILE.exists():
return jsonify({"ok": False, "error": "digest non trovato — esegui scanner prima"}), 404
digest = read_json(DIGEST_FILE)
results = []
for file_entry in digest.get("by_file", []):
hits = []
extracts = file_entry.get("extracts", {})
# Cerca in ogni categoria
for category, items in extracts.items():
for item in items:
if q in item.lower():
hits.append({"category": category, "text": item})
# Cerca anche nel path file
if q in file_entry.get("path", "").lower():
hits.append({"category": "filename", "text": file_entry["path"]})
if hits:
results.append({
"file": file_entry["path"],
"type": file_entry.get("type", ""),
"hits": hits,
})
return jsonify({
"ok": True,
"query": q,
"total_results": len(results),
"results": results[:50], # max 50 file
})
@app.get("/api/content-files")
def list_content_files():
"""Lista i file .md in CONTENT_ENGINE/produzione_contenuti/, ordinati per data decrescente."""
if not CONTENT_DIR.exists():
return jsonify({"ok": True, "total": 0, "files": []})
files = []
for p in sorted(CONTENT_DIR.glob("*.md"), key=lambda x: x.stat().st_mtime, reverse=True):
try:
stat = p.stat()
files.append({
"path": str(p),
"name": p.name,
"size_kb": round(stat.st_size / 1024, 1),
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
})
except Exception:
pass
return jsonify({"ok": True, "total": len(files), "files": files})
@app.get("/api/rag/search")
def rag_search():
"""Ricerca semantica RAG su MENTE/ via ChromaDB."""
q = request.args.get("q", "").strip()
top_k = int(request.args.get("top_k", 5))
# demote=STORIE,... (attacco #2 #8): malus di selezione per le cartelle "eco",
# cosi' i generatori si groundano sul FATTI curato, non sugli episodi grezzi
demote = tuple(d.strip() for d in request.args.get("demote", "").split(",") if d.strip())
if not q:
return jsonify({"ok": False, "error": "param q mancante"}), 400
try:
rag_search_fn = _get_rag_search() # lock + failure-latch (#53): niente commit-leak
results = rag_search_fn(q, top_k=top_k, demote_dirs=demote)
return jsonify({"ok": True, "query": q, "total": len(results), "results": results})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.post("/api/rag/rebuild")
def rag_rebuild():
"""Forza rebuild indice ChromaDB da MENTE/."""
try:
result = subprocess.run(
[sys.executable, str(ROOT / "NODES" / "MENTE_RAG" / "rag_engine.py"), "--rebuild"],
capture_output=True, text=True, timeout=300,
encoding="utf-8", errors="replace",
env={**os.environ, "MENTE_DIR": str(MENTE_DIR), "PYTHONPATH": str(ROOT)},
)
return jsonify({"ok": result.returncode == 0, "output": result.stdout[-500:]})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.post("/api/daily-brief")
def daily_brief():
"""Genera il brief giornaliero da AUTOMATIONS/core/daily_brief.py."""
script = ROOT / "AUTOMATIONS" / "core" / "daily_brief.py"
if not script.exists():
return jsonify({"ok": False, "error": "daily_brief.py non trovato"}), 404
try:
result = subprocess.run(
[sys.executable, str(script)],
capture_output=True, text=True, timeout=120,
encoding="utf-8", errors="replace",
env={**os.environ, "PYTHONPATH": str(ROOT)},
)
if result.returncode != 0:
return jsonify({"ok": False, "error": result.stderr[-500:]}), 500
return jsonify({"ok": True, "message": "Brief generato", "output": result.stdout[-500:]})
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "error": "timeout dopo 120s"}), 504
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.get("/api/health")
def health():
rag_dir = ROOT / "NODES" / "MENTE_RAG" / "chroma_db"
return jsonify({
"status": "ok",
"time": datetime.now().isoformat(),
"state_exists": STATE_FILE.exists(),
"digest_exists": DIGEST_FILE.exists(),
"scanner_exists": SCANNER.exists(),
"rag_exists": rag_dir.exists(),
"mente_dir": str(MENTE_DIR),
})
# ── CONTENT ENGINE — CE-1/CE-2/CE-3/CE-4 ─────────────────────
ARCHIVE_DIR = CONTENT_ENGINE / "archive"
SA_AUTO_DIR = ROOT / "CONTENT_ENGINE" / "DATABASE" / "episodes" / "SA_AUTO"
@app.post("/api/content/trigger")
def content_trigger():
"""
CE-1 — Webhook trigger per n8n.
Payload: {"tema": str, "formato": str, "source": str (opz)}
Lancia archivista.py in background e ritorna job_id.
"""
data = request.get_json(force=True) or {}
tema = data.get("tema", "").strip()
formato = data.get("formato", "full").strip()
if not tema:
return jsonify({"ok": False, "error": "campo 'tema' obbligatorio"}), 400
archivista = ROOT / "AUTOMATIONS" / "core" / "archivista.py"
if not archivista.exists():
return jsonify({"ok": False, "error": "archivista.py non trovato"}), 500
try:
# Avvia in background — n8n non aspetta la risposta
import threading
def _run():
subprocess.run(
[sys.executable, str(archivista), "--tema", tema, "--formato", formato],
cwd=str(ROOT), capture_output=True
)
t = threading.Thread(target=_run, daemon=True)
t.start()
job_id = f"{tema.replace(' ','_')}_{datetime.now().strftime('%H%M%S')}"
return jsonify({"ok": True, "job_id": job_id, "tema": tema, "formato": formato})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.get("/api/content/archive")
def get_archive():
"""
CE-2 — Legge l'archivio per n8n.
Query params: tema (opz), limit (default 10)
Ritorna lista file con excerpt.
"""
tema = request.args.get("tema", "").strip().lower()
limit = int(request.args.get("limit", 10))
results = []
for d in [ARCHIVE_DIR, ROOT / "ARCHIVE"]:
if not d.exists():
continue
for f in d.rglob("*.md"):
try:
content = f.read_text(encoding="utf-8", errors="replace")
relevant = not tema or tema in content.lower()[:500] or tema in f.name.lower()
if relevant:
results.append({
"file": f.name,
"path": str(f),
"excerpt": content[:800],
"size": len(content),
})
except IOError:
continue
# Ordina per dimensione decrescente, limita
results.sort(key=lambda x: -x["size"])
results = results[:limit]
return jsonify({"ok": True, "total": len(results), "archive": results})
@app.post("/api/content/generate")
def generate_content():
"""
CE-3 — Genera storytelling via Claude API (sincrono).
Payload: {"tema": str, "formato": str, "archive_excerpts": [...] (opz)}
Ritorna: {"ok": true, "file": path, "content": testo}
"""
data = request.get_json(force=True) or {}
tema = data.get("tema", "").strip()
formato = data.get("formato", "full")
if not tema:
return jsonify({"ok": False, "error": "campo 'tema' obbligatorio"}), 400
archivista_path = ROOT / "AUTOMATIONS" / "core" / "archivista.py"
if not archivista_path.exists():
return jsonify({"ok": False, "error": "archivista.py non trovato"}), 500
try:
result = subprocess.run(
[sys.executable, str(archivista_path), "--tema", tema, "--formato", formato],
capture_output=True, text=True, timeout=120,
encoding="utf-8", errors="replace", cwd=str(ROOT)
)
if result.returncode != 0:
return jsonify({"ok": False, "error": result.stderr[-500:]}), 500
# Trova il file generato più recente in produzione_contenuti
output_dir = CONTENT_ENGINE / "produzione_contenuti"
files = sorted(output_dir.glob("*.md"), key=lambda f: f.stat().st_mtime, reverse=True)
if not files:
return jsonify({"ok": False, "error": "nessun file generato"}), 500
latest = files[0]
return jsonify({
"ok": True,
"file": str(latest),
"name": latest.name,
"content": latest.read_text(encoding="utf-8", errors="replace"),
})
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "error": "timeout dopo 120s"}), 504
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.get("/api/view/<path:rel_path>")
def get_view(rel_path: str):
"""
CE-4 + #25 — Serve la view JSON strutturata di un .md.
Generata da md_view_pipeline.py.
Se non esiste, la genera al volo.
"""
views_dir = ROOT / "DATA" / "views"
view_key = rel_path.replace("/", "__") + ".json"
view_path = views_dir / view_key
if not view_path.exists():
# Genera al volo
md_path = ROOT / rel_path
if not md_path.exists():
return jsonify({"ok": False, "error": f"file non trovato: {rel_path}"}), 404
try:
sys.path.insert(0, str(ROOT / "AUTOMATIONS" / "core"))
from md_view_pipeline import deploy_view
deploy_view(md_path)
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
if view_path.exists():
return jsonify(json.loads(view_path.read_text(encoding="utf-8")))
return jsonify({"ok": False, "error": "view non generabile"}), 500
@app.get("/api/view-index")
def get_view_index():
"""Ritorna DATA/view_index.json — indice di tutte le view disponibili."""
index_path = ROOT / "DATA" / "view_index.json"
if not index_path.exists():
return jsonify({"ok": False, "error": "indice non trovato — esegui md_view_pipeline --rebuild"}), 404
return jsonify(json.loads(index_path.read_text(encoding="utf-8")))
@app.get("/api/semantic/search")
def semantic_search():
"""
Ricerca semantica nel database SQLite.
Query param: q=keyword, limit (default 20)
"""
q = request.args.get("q", "").strip()
limit = int(request.args.get("limit", 20))
if not q:
return jsonify({"ok": False, "error": "param q mancante"}), 400
try:
sys.path.insert(0, str(ROOT / "AUTOMATIONS" / "core"))
from semantic_indexer import search
results = search(q, limit=limit)
return jsonify({"ok": True, "query": q, "total": len(results), "results": results})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.get("/api/watchdog/status")
def watchdog_status():
"""Legge DATA/watchdog_status.json — stato ultimo ciclo watchdog."""
status_path = ROOT / "DATA" / "watchdog_status.json"
if not status_path.exists():
return jsonify({"ok": False, "error": "watchdog non ancora avviato"}), 404
return jsonify({"ok": True, **json.loads(status_path.read_text(encoding="utf-8"))})
@app.get("/api/tasks/notturne")
def tasks_notturne():
"""Stato live dei task notturni da Windows Task Scheduler (TI_*).
Usa Get-ScheduledTask via PowerShell: State/LastTaskResult sono enum/codici
indipendenti dalla lingua (a differenza del CSV di schtasks, localizzato)."""
ps = (
"$ErrorActionPreference='SilentlyContinue';"
"@(Get-ScheduledTask -TaskName 'TI_*' | ForEach-Object {"
" $i=$_|Get-ScheduledTaskInfo;"
" [pscustomobject]@{"
" name=$_.TaskName; state=[string]$_.State;"
" next=if($i.NextRunTime){$i.NextRunTime.ToString('yyyy-MM-dd HH:mm')}else{''};"
" last=if($i.LastRunTime){$i.LastRunTime.ToString('yyyy-MM-dd HH:mm')}else{''};"
" result=[int]$i.LastTaskResult"
" }}) | ConvertTo-Json -Compress"
)
RESULT_LABELS = {
0: "OK", 1: "errore generico",
267009: "in esecuzione", 267010: "pronto", 267011: "mai eseguito",
267014: "terminato", 2147750687: "istanza gia' attiva",
}
try:
r = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps],
capture_output=True, text=True, timeout=25,
encoding="utf-8", errors="replace",
)
out = (r.stdout or "").strip()
if not out:
return jsonify({"ok": True, "tasks": {}, "note": "nessun task TI_ trovato"})
parsed = json.loads(out)
if isinstance(parsed, dict):
parsed = [parsed]
tasks = {}
for t in parsed:
res = t.get("result")
tasks[t.get("name", "?")] = {
"state": t.get("state", ""),
"next_run": t.get("next", ""),
"last_run": t.get("last", ""),
"last_result": res,
"last_result_label": RESULT_LABELS.get(res, (f"0x{res:08X}" if isinstance(res, int) else str(res))),
"active": t.get("state") in ("Ready", "Running"),
}
return jsonify({"ok": True, "tasks": tasks})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.get("/api/llm/local/status")
def llm_local_status():
"""Stato del Personal LLM locale: pronto solo dopo il primo night_finetune."""
try:
sys.path.insert(0, str(ROOT))
from NODES.LOCAL_LLM.infer import is_ready, ADAPTER_DIR
return jsonify({"ok": True, "ready": is_ready(), "model": "titanium_llm_v1", "path": str(ADAPTER_DIR)})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.post("/api/llm/local")
def llm_local():
"""Inferenza sul Personal LLM locale (TinyLlama+LoRA prodotto da night_finetune).
Consuma il modello che il finetune produce -> chiude il loop. 503 se non ancora addestrato."""
data = request.get_json(force=True, silent=True) or {}
prompt = (data.get("prompt") or "").strip()
if not prompt:
return jsonify({"ok": False, "error": "campo 'prompt' obbligatorio"}), 400
try:
sys.path.insert(0, str(ROOT))
from NODES.LOCAL_LLM.infer import is_ready, generate
if not is_ready():
return jsonify({"ok": False, "ready": False,
"error": "modello locale non ancora addestrato (primo finetune domenica 01:00)"}), 503
answer = generate(prompt, max_new_tokens=int(data.get("max_tokens", 256)))
return jsonify({"ok": True, "ready": True, "model": "titanium_llm_v1", "answer": answer})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.get("/api/llm/ollama/status")
def llm_ollama_status():
"""Stato della leva locale Ollama+Qwen (P4b) per il toggle della chat RAG.
available=False quando la leva e' spenta (Ollama non installato): la chat
fa fallback a Claude, niente teatro."""
try:
sys.path.insert(0, str(ROOT))
from NODES.LOCAL_LLM.ollama_client import status as ollama_status
return jsonify({"ok": True, **ollama_status()})
except Exception as e:
return jsonify({"ok": False, "available": False, "error": str(e)}), 200
@app.get("/api/critiche/auto")
def critiche_auto():
"""Cartella clinica viva (P1b): le critiche generate dal self-audit
notturno (night_audit.py su Sonnet). La dashboard le mostra LIVE accanto
al canone manuale (criticheData.ts) -> le critiche crescono da sole."""
f = ROOT / "DATA" / "audit" / "critiche_auto.json"
if not f.exists():
return jsonify({"ok": True, "findings": [], "total": 0, "open": 0}), 200
try:
items = json.loads(f.read_text(encoding="utf-8"))
if not isinstance(items, list):
items = []
open_n = sum(1 for c in items if c.get("status") == "open")
# piu' recenti prima
items.sort(key=lambda c: c.get("last_seen") or c.get("date") or "", reverse=True)
return jsonify({"ok": True, "findings": items, "total": len(items), "open": open_n})
except Exception as e:
return jsonify({"ok": False, "error": str(e), "findings": []}), 500
@app.get("/api/critiche/manuali")
def critiche_manuali():
"""Canone manuale delle critiche, LIVE (#54): la fonte e' DATA/audit/
critiche_manuali.json (editabile senza rebuild della dash — era baked in
criticheData.ts). La vista fa fetch qui con fallback sulla copia baked."""
f = ROOT / "DATA" / "audit" / "critiche_manuali.json"
if not f.exists():
return jsonify({"ok": False, "error": "critiche_manuali.json assente"}), 404
try:
d = json.loads(f.read_text(encoding="utf-8"))
root = d.get("root")
if not isinstance(root, dict):
return jsonify({"ok": False, "error": "campo root mancante"}), 500
return jsonify({"ok": True, "root": root, "updated": d.get("updated")})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.get("/api/bussola/todos")
def bussola_todos():
"""Bussola viva (DA_FARE_FATTO.md) -> todo strutturati per la vista CRITICHE.
Il file e' rigenerato dal night_audit (deterministico): la scaletta da-fare/fatto
di Matteo appare in dashboard accanto alle critiche di sistema."""
f = ROOT / "DATA" / "audit" / "bussola_todos.json"
if not f.exists():
return jsonify({"ok": True, "todos": [], "total": 0, "open": 0}), 200
try:
items = json.loads(f.read_text(encoding="utf-8"))
if not isinstance(items, list):
items = []
open_n = sum(1 for t in items if t.get("stato") in ("da_fare", "in_corso", "non_fatto"))
return jsonify({"ok": True, "todos": items, "total": len(items), "open": open_n})
except Exception as e:
return jsonify({"ok": False, "error": str(e), "todos": []}), 500
@app.get("/api/sanitizer/report")
def sanitizer_report():
"""Legge l'ultimo report del sanitizer."""
report_path = ROOT / "DATA" / "sanitizer_report.json"
if not report_path.exists():
return jsonify({"ok": False, "error": "nessun report — esegui sanitizer.py prima"}), 404
return jsonify({"ok": True, **json.loads(report_path.read_text(encoding="utf-8"))})
# ── CONTENT ENGINE CE-5 / CE-6 / CE-7 ────────────────────────
CORE_PATH = ROOT / "AUTOMATIONS" / "core"
@app.post("/api/content/tts")
def content_tts():
"""CE-5 — Converte file .md in .mp3 tramite ElevenLabs."""
data = request.get_json(force=True) or {}
fname = data.get("file", "").strip()
if not fname:
return jsonify({"ok": False, "error": "campo 'file' obbligatorio"}), 400
md_path = CONTENT_DIR / fname
if not md_path.exists():
return jsonify({"ok": False, "error": f"file non trovato: {fname}"}), 404
tts_script = CORE_PATH / "elevenlabs_tts.py"
try:
result = subprocess.run(
[sys.executable, str(tts_script), "--file", str(md_path)],
capture_output=True, text=True, timeout=180,
encoding="utf-8", errors="replace"
)
if result.returncode != 0:
return jsonify({"ok": False, "error": result.stderr[-300:]}), 500
audio_dir = CONTENT_ENGINE / "audio"
mp3_files = sorted(audio_dir.glob("*.mp3"), key=lambda f: f.stat().st_mtime, reverse=True)
if not mp3_files:
return jsonify({"ok": False, "error": "nessun .mp3 generato"}), 500
latest = mp3_files[0]
return jsonify({"ok": True, "audio_file": latest.name, "path": str(latest), "source": fname})
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "error": "timeout TTS (180s)"}), 504
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.post("/api/content/video")
def content_video():
"""CE-6 — Genera video avatar da script .md tramite HeyGen/D-ID."""
data = request.get_json(force=True) or {}
fname = data.get("file", "").strip()
if not fname:
return jsonify({"ok": False, "error": "campo 'file' obbligatorio"}), 400
md_path = CONTENT_DIR / fname
if not md_path.exists():
return jsonify({"ok": False, "error": f"file non trovato: {fname}"}), 404
video_script = CORE_PATH / "video_generator.py"
try:
result = subprocess.run(
[sys.executable, str(video_script), "--file", str(md_path)],
capture_output=True, text=True, timeout=600,
encoding="utf-8", errors="replace"
)
if result.returncode != 0:
return jsonify({"ok": False, "error": result.stderr[-300:]}), 500
video_dir = CONTENT_ENGINE / "video"
mp4_files = sorted(video_dir.glob("*.mp4"), key=lambda f: f.stat().st_mtime, reverse=True)
if not mp4_files:
return jsonify({"ok": False, "error": "nessun .mp4 generato"}), 500
latest = mp4_files[0]
return jsonify({"ok": True, "video_file": latest.name, "path": str(latest), "source": fname})
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "error": "timeout video (600s)"}), 504
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.post("/api/content/distribute")
def content_distribute():
"""CE-7 — Pubblica .md su LinkedIn e/o Telegram."""
data = request.get_json(force=True) or {}
fname = data.get("file", "").strip()
channels = [c.strip() for c in data.get("channels", "linkedin,telegram").split(",")]
if not fname:
return jsonify({"ok": False, "error": "campo 'file' obbligatorio"}), 400
md_path = CONTENT_DIR / fname
if not md_path.exists():
return jsonify({"ok": False, "error": f"file non trovato: {fname}"}), 404
dist_script = CORE_PATH / "social_distributor.py"
flags = ["--file", str(md_path)]
if "linkedin" in channels:
flags.append("--linkedin")
if "telegram" in channels:
flags.append("--telegram")
try:
result = subprocess.run(
[sys.executable, str(dist_script)] + flags,
capture_output=True, text=True, timeout=60,
encoding="utf-8", errors="replace"
)
return jsonify({
"ok": result.returncode == 0,
"channels": channels, "source": fname,
"output": result.stdout[-500:],
"error": result.stderr[-200:] if result.returncode != 0 else None,
})
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "error": "timeout (60s)"}), 504
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.get("/api/episodes")
def get_episodes():
"""Lista episodi auto-generati da CONTENT_ENGINE/DATABASE/episodes/SA_AUTO/."""
import re as _re
if not SA_AUTO_DIR.exists():
return jsonify({"ok": True, "total": 0, "episodes": []})
episodes = []
for md_file in sorted(SA_AUTO_DIR.glob("EP_AUTO_*.md"), key=lambda f: f.name):
try:
text = md_file.read_text(encoding="utf-8", errors="replace")
fm_match = _re.search(r'---\n(.*?)\n---', text, _re.DOTALL)
fm = {}
if fm_match:
for line in fm_match.group(1).splitlines():
if ":" in line:
k, _, v = line.partition(":")
fm[k.strip()] = v.strip().strip('"')
body = text.split("---\n", 2)[-1] if text.count("---") >= 2 else text
episodes.append({
"id": fm.get("id", md_file.stem),
"title": fm.get("title", md_file.stem),
"sottotitolo": fm.get("sottotitolo", "Milestone verificato"),
"stagione": fm.get("stagione", "AUTO"),
"data_evento": fm.get("data_evento", ""),
"status": fm.get("status", "ready"),
"durata_min": int(fm.get("durata_min", 8)),
"tags": [t.strip() for t in fm.get("tags", "").strip("[]").split(",") if t.strip()],
"milestone": fm.get("milestone_originale", ""),
"preview": body.strip()[:200],
"file": md_file.name,
"modified": datetime.fromtimestamp(md_file.stat().st_mtime).isoformat(),
})
except Exception:
continue
return jsonify({"ok": True, "total": len(episodes), "episodes": episodes})
# ── MEDIA — serve foto e PDF nel browser ─────────────────────
MEDIA_EXTENSIONS = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".gif": "image/gif", ".webp": "image/webp",
".pdf": "application/pdf",
".mp4": "video/mp4", ".mov": "video/quicktime",
}
@app.get("/api/media/<path:rel_path>")
def serve_media(rel_path: str):
"""
Serve immagini e PDF inline nel browser.
rel_path: percorso relativo a MICROINDUSTRY/
Es: GET /api/media/FOTO/V32_BUILD/Config_G/stato_20260528/V32_20260528_01_telaio_frontale_taverna.jpeg
"""
target = (MICROINDUSTRY / rel_path).resolve()
if not path_allowed(target):
return jsonify({"ok": False, "error": "percorso non consentito"}), 403
if not target.exists() or not target.is_file():
return jsonify({"ok": False, "error": "file non trovato"}), 404
mime = MEDIA_EXTENSIONS.get(target.suffix.lower())
if not mime:
return jsonify({"ok": False, "error": "tipo file non supportato"}), 415
return send_file(str(target), mimetype=mime)
@app.get("/api/photos")
def list_photos():
"""
Lista foto in FOTO/V32_BUILD/ con URL servibili.
?subdir=Config_G/stato_20260528 filtra per sottocartella.
"""
subdir = request.args.get("subdir", "")
base = FOTO_DIR / "V32_BUILD"
if subdir:
base = base / subdir
if not base.exists():
return jsonify({"ok": True, "total": 0, "photos": []})
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
photos = []
for f in sorted(base.rglob("*")):
if f.suffix.lower() not in IMAGE_EXT:
continue
rel = f.relative_to(MICROINDUSTRY).as_posix()
stat = f.stat()
photos.append({
"name": f.name,
"rel_path": rel,
"url": f"/api/media/{rel}",
"subdir": str(f.parent.relative_to(FOTO_DIR / "V32_BUILD")),
"size_kb": round(stat.st_size / 1024, 1),
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
})