Compare commits

...
8 Commits
95 changed files with 11159 additions and 759 deletions

No files matched your search

+29 -7
View File
@@ -1,17 +1,39 @@
global_defs {
enable_script_security
script_user root
}
vrrp_script chk_ha {
script "/usr/local/sbin/keepalived-check-ha.sh"
interval 5
weight -30
fall 3
rise 2
user root
}
vrrp_instance VI_1 {
state MASTER
state BACKUP
interface eth0
virtual_router_id 51
priority 101 # 101 = Priorità più alta (Master)
advert_int 1
priority 101
advert_int 2
preempt_delay 30
unicast_src_ip 192.168.128.80
unicast_peer {
192.168.128.81
}
authentication {
auth_type PASS
auth_pass @Dedelove1
}
virtual_ipaddress {
192.168.128.85 # Il nostro VIP
192.168.128.85
}
notify_master "/home/daniely/rete/scripts/ha-failover.sh assume-tier-a"
notify_backup "/home/daniely/rete/scripts/ha-failover.sh release-tier-a"
notify_fault "/home/daniely/rete/scripts/ha-failover.sh fault"
track_script {
chk_ha
}
notify_master "/usr/local/sbin/keepalived-notify-master.sh"
notify_backup "/usr/local/sbin/keepalived-notify-backup.sh"
notify_fault "/usr/local/sbin/keepalived-notify-fault.sh"
}
+27 -5
View File
@@ -1,9 +1,28 @@
global_defs {
enable_script_security
script_user root
}
vrrp_script chk_ha {
script "/usr/local/sbin/keepalived-check-ha.sh"
interval 5
weight -30
fall 3
rise 2
user root
}
vrrp_instance VI_1 {
state BACKUP
interface eth0
virtual_router_id 51
priority 100 # 100 = Priorità più bassa (Backup)
advert_int 1
priority 100
advert_int 2
preempt_delay 30
unicast_src_ip 192.168.128.81
unicast_peer {
192.168.128.80
}
authentication {
auth_type PASS
auth_pass @Dedelove1
@@ -11,7 +30,10 @@ vrrp_instance VI_1 {
virtual_ipaddress {
192.168.128.85
}
notify_master "/home/daniely/rete/scripts/ha-failover.sh assume-tier-a"
notify_backup "/home/daniely/rete/scripts/ha-failover.sh release-tier-a"
notify_fault "/home/daniely/rete/scripts/ha-failover.sh fault"
track_script {
chk_ha
}
notify_master "/usr/local/sbin/keepalived-notify-master.sh"
notify_backup "/usr/local/sbin/keepalived-notify-backup.sh"
notify_fault "/usr/local/sbin/keepalived-notify-fault.sh"
}
+4
View File
@@ -3,3 +3,7 @@
# DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
# REBOOT_ON_SUCCESS=false # unico modo per saltare il reboot fisso di fine manutenzione
# Cron consigliato: 0 4 * * 6 (nessun conflitto irrigazione, che gira su Pi2)
# Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script:
# turni-app:live-latest
# Dopo Watchtower: pull registry da rete/compose/failover anche senza container.
# Override: FAILOVER_COMPOSE_DIR=...
+4
View File
@@ -4,3 +4,7 @@
# CHECK_PIP3=true
# DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
# Cron consigliato: 40 0 * * 6 (tra irrigazione serale ~19:30 e notturna ~02:30)
# Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script:
# irrigazione, turni-app:beta/alpha, meteo-alert, loogle-casa, ewelink_smart_home
# Dopo Watchtower: pull registry da rete/compose/failover (Paperless, VW, Stalwart, …)
# anche senza container. Override: FAILOVER_COMPOSE_DIR=...
+47 -47
View File
@@ -1,57 +1,57 @@
#!/bin/bash
# Clona NPM master (Pi1) → backup Pi2 + clone dormiente DS920 (NPM3).
# Su DS920: extract solo se extrema-npm NON è running (evita SQLite corrotto in B1).
# Synology: SFTP spesso chrootato → verso DS920 si usa stream tar via SSH (non scp).
set -euo pipefail
# --- CONFIGURAZIONE ---
REMOTE_IP="192.168.128.81"
REMOTE_USER="daniely"
# Cartella che contiene 'data' e 'letsencrypt'
SOURCE_BASE="/home/daniely/docker/npm"
TEMP_FILE="/tmp/npm_full_clone.tar.gz"
REMOTE_IP="${REMOTE_IP:-192.168.128.81}"
REMOTE_USER="${REMOTE_USER:-daniely}"
DS920_IP="${DS920_IP:-192.168.128.100}"
DS920_USER="${DS920_USER:-daniely}"
DS920_NPM_ROOT="${DS920_NPM_ROOT:-/volume1/extrema/npm}"
SOURCE_BASE="${SOURCE_BASE:-/home/daniely/docker/npm}"
TEMP_FILE="${TEMP_FILE:-/tmp/npm_full_clone.tar.gz}"
echo "[$(date)] Avvio clonazione totale NPM (Master -> Backup)..."
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] sync-npm: $*"; }
log "Avvio clonazione NPM (Master → Pi2 + DS920)..."
# 1. Crea un archivio compresso di TUTTO (Database SQL + Certificati)
# Usiamo sudo per leggere i file di root senza permessi negati
# Escludiamo i log per risparmiare spazio
sudo tar --exclude='*.log' -czf "$TEMP_FILE" -C "$SOURCE_BASE" data letsencrypt
sudo chown "${USER}:${USER}" "$TEMP_FILE"
# 2. Assegna l'archivio all'utente corrente (per poterlo spedire via SCP)
sudo chown $USER:$USER "$TEMP_FILE"
# 3. Spedisce l'archivio al Pi-2
echo "Invio archivio al Pi-2 ($REMOTE_IP)..."
scp -o ConnectTimeout=10 "$TEMP_FILE" "$REMOTE_USER@$REMOTE_IP:/tmp/"
if [ $? -eq 0 ]; then
echo "Trasferimento riuscito. Applicazione sul Backup..."
# 4. Comanda al Pi-2 di:
# a) Arrestare NPM (per sbloccare il database)
# b) Scompattare sovrascrivendo tutto
# c) Ripristinare i permessi di root
# d) Riavviare NPM
ssh "$REMOTE_USER@$REMOTE_IP" "
echo ' ...Stop NPM...';
docker stop npm;
echo ' ...Estrazione dati...';
# Pulisce la cartella di destinazione prima di estrarre per evitare residui
sudo rm -rf /home/daniely/docker/npm/data/*
sudo rm -rf /home/daniely/docker/npm/letsencrypt/*
sudo tar -xzf /tmp/npm_full_clone.tar.gz -C /home/daniely/docker/npm/;
echo ' ...Fix Permessi...';
sudo chown -R root:root /home/daniely/docker/npm/;
echo ' ...Start NPM...';
docker start npm;
# Pulizia remota
rm /tmp/npm_full_clone.tar.gz
"
echo "[$(date)] Sincronizzazione Completata con Successo."
# --- Pi-2 (hot backup: stop/extract/start) ---
log "Invio archivio al Pi-2 ($REMOTE_IP)..."
if scp -o ConnectTimeout=10 -o BatchMode=yes "$TEMP_FILE" "${REMOTE_USER}@${REMOTE_IP}:/tmp/npm_full_clone.tar.gz"; then
ssh -o ConnectTimeout=15 -o BatchMode=yes "${REMOTE_USER}@${REMOTE_IP}" bash -s <<'EOS'
set -e
echo " ...Stop NPM..."
docker stop npm 2>/dev/null || true
echo " ...Estrazione dati..."
sudo rm -rf /home/daniely/docker/npm/data/* /home/daniely/docker/npm/letsencrypt/*
sudo tar -xzf /tmp/npm_full_clone.tar.gz -C /home/daniely/docker/npm/
sudo chown -R root:root /home/daniely/docker/npm/
echo " ...Start NPM..."
docker start npm
rm -f /tmp/npm_full_clone.tar.gz
EOS
log "Pi-2 sincronizzato OK"
else
echo "[$(date)] ERRORE CRITICO: Trasferimento fallito."
log "ERRORE: trasferimento Pi-2 fallito"
fi
# --- DS920 NPM3 (dormiente) ---
log "Stream archivio al DS920 ($DS920_IP)..."
if ssh -o ConnectTimeout=15 -o BatchMode=yes "${DS920_USER}@${DS920_IP}" \
"export PATH=/usr/local/bin:\$PATH; docker ps --format '{{.Names}}' 2>/dev/null | grep -qx extrema-npm"; then
log "SKIP DS920: extrema-npm running (B1 armato) — sync al prossimo disarm"
else
if cat "$TEMP_FILE" | ssh -o ConnectTimeout=180 -o BatchMode=yes "${DS920_USER}@${DS920_IP}" \
"export PATH=/usr/local/bin:\$PATH; NPM_ROOT=$(printf %q "$DS920_NPM_ROOT"); mkdir -p \"\$NPM_ROOT/data\" \"\$NPM_ROOT/letsencrypt\"; find \"\$NPM_ROOT/data\" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true; find \"\$NPM_ROOT/letsencrypt\" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true; tar -xzf - -C \"\$NPM_ROOT\"; chmod -R a+rX \"\$NPM_ROOT\" 2>/dev/null || true; echo NPM3_EXTRACT_OK; ls \"\$NPM_ROOT/data/nginx/proxy_host\" 2>/dev/null | head -5"; then
log "DS920 NPM3 sincronizzato OK (dormiente)"
else
log "ERRORE: extract DS920 fallito"
fi
fi
# 5. Pulizia locale
sudo rm -f "$TEMP_FILE"
log "Sincronizzazione NPM completata."
+3 -71
View File
@@ -1,71 +1,3 @@
#!/bin/bash
# ================================================
# SENTINELLA DI BACKUP (Gira su Pi-1 Master)
# Controlla Pi-2 e attiva failover bundle IoT su Pi-1 se down.
# ================================================
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
HA_FAILOVER="/home/daniely/rete/scripts/ha-failover.sh"
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
echo "Errore: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&2
exit 1
fi
TARGET_IP="192.168.128.81"
TARGET_NAME="🍓 Pi-2 (Backup & Monitor)"
STATE_FILE="/mnt/ha-apps/.metadata/pi2-watchdog.state"
SIM_STATE="/mnt/ha-apps/.metadata/failover-sim.state"
LEGACY_STATE_FILE="/tmp/pi2_watchdog.state"
SSH_OPTS=(-o ConnectTimeout=5 -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
if [[ -f "$SIM_STATE" ]] && [[ "$(cat "$SIM_STATE" 2>/dev/null)" != "none" ]]; then
exit 0
fi
send_alert() {
local title="$1"
local body="$2"
local severity="$3"
"$LOOGLE_NOTIFY" --title "$title" --body "$body" --category watchdog_pi2 \
--severity "$severity" &
}
pi2_reachable() {
ping -c 3 -W 2 "$TARGET_IP" > /dev/null 2>&1 && return 0
ssh "${SSH_OPTS[@]}" pi2 "exit 0" 2>/dev/null
}
if [ ! -f "$STATE_FILE" ]; then
if [ -f "$LEGACY_STATE_FILE" ]; then
cp "$LEGACY_STATE_FILE" "$STATE_FILE"
else
echo "UP" > "$STATE_FILE"
fi
fi
LAST_STATE=$(cat "$STATE_FILE")
if pi2_reachable; then
if [ "$LAST_STATE" == "DOWN" ]; then
send_alert \
"Pi-2 online" \
"RISOLTO: $TARGET_NAME è tornato ONLINE! Failback bundle IoT in corso." \
"info"
echo "UP" > "$STATE_FILE"
if [[ -x "$HA_FAILOVER" ]]; then
sudo bash -c "\"$HA_FAILOVER\" stop-iot-bundle >> /var/log/ha-failover.log 2>&1 &"
fi
fi
else
if [ "$LAST_STATE" == "UP" ]; then
send_alert \
"Pi-2 offline" \
"ALLARME: $TARGET_NAME è OFFLINE! Avvio failover bundle IoT su Pi-1." \
"warning"
echo "DOWN" > "$STATE_FILE"
if [[ -x "$HA_FAILOVER" ]]; then
sudo bash -c "\"$HA_FAILOVER\" start-iot-bundle >> /var/log/ha-failover.log 2>&1 &"
fi
fi
fi
#!/usr/bin/env bash
# Wrapper legacy — redirige al modulo infra-monitor
exec /home/daniely/rete/infra-monitor/pi2-watchdog.sh "$@"
+137 -13
View File
@@ -20,6 +20,8 @@ PIHOLE_BIN="/usr/local/bin/pihole"
REBOOT_DELAY_MIN=2
WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
WATCHTOWER_TIMEOUT=900
# Compose HA usati al pivot: Watchtower aggiorna solo i container esistenti.
FAILOVER_COMPOSE_DIR="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
HOST_LABEL="$(hostname -s)"
REBOOT_ON_SUCCESS=true
@@ -45,12 +47,21 @@ done
case "$(hostname -s)" in
pi1)
HOST_LABEL="Pi-1 (Master)"
# Immagini build locali (no registry pubblico) → escluse da Watchtower
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
;;
pi2)
HOST_LABEL="Pi-2 (Backup)"
CHECK_PIP3=true
DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
# Immagini build locali (no registry pubblico) → escluse da Watchtower
DOCKER_IGNORE_IMAGES=(
"irrigazione:latest"
"turni-app:beta-latest"
"turni-app:alpha-latest"
"meteo-alert:latest"
"loogle-casa:latest"
"ewelink_smart_home:1.4.6"
)
;;
esac
@@ -143,34 +154,52 @@ image_is_ignored() {
return 1
}
ensure_watchtower_labels() {
command -v docker >/dev/null 2>&1 || return 0
local name image
# Elenco nomi container da escludere (immagini locali / no registry).
# Nota: `docker update --label-add` non esiste → usiamo WATCHTOWER_DISABLE_CONTAINERS.
watchtower_disabled_containers() {
local name image disabled=()
while IFS= read -r line; do
[[ -z "$line" ]] && continue
name=${line%%|*}
image=${line#*|}
image_is_ignored "$image" || continue
docker update --label-add com.centurylinklabs.watchtower.enable=false "$name" >/dev/null 2>&1 || true
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
if image_is_ignored "$image"; then
disabled+=("$name")
fi
done < <(docker ps -a --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
if ((${#disabled[@]} > 0)); then
local IFS=,
printf '%s' "${disabled[*]}"
fi
}
run_watchtower() {
command -v docker >/dev/null 2>&1 || return 0
ensure_watchtower_labels
local disable_list
disable_list=$(watchtower_disabled_containers)
log "▶ Watchtower (run-once, container aggiornabili da registry)"
append_report ""
append_report "=== Watchtower run-once ==="
if [[ -n "$disable_list" ]]; then
log "Watchtower: esclusi container locali: $disable_list"
append_report "Esclusi (immagini locali): $disable_list"
fi
local -a env_args=(
-e WATCHTOWER_CLEANUP=true
-e WATCHTOWER_ROLLING_RESTART=false
-e "WATCHTOWER_TIMEOUT=${WATCHTOWER_TIMEOUT}"
-e TZ=Europe/Rome
)
if [[ -n "$disable_list" ]]; then
env_args+=(-e "WATCHTOWER_DISABLE_CONTAINERS=${disable_list}")
fi
local output rc=0
output=$(docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-e WATCHTOWER_CLEANUP=true \
-e WATCHTOWER_ROLLING_RESTART=false \
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
-e TZ=Europe/Rome \
"${env_args[@]}" \
"$WATCHTOWER_IMAGE" \
--run-once 2>&1) || rc=$?
@@ -191,6 +220,99 @@ run_watchtower() {
fi
}
# Espande ${VAR:-default} (usato nei compose Turni). Altri ${VAR} → scarta.
expand_compose_image() {
local raw="$1"
while [[ "$raw" =~ \$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\} ]]; do
raw="${raw/${BASH_REMATCH[0]}/${BASH_REMATCH[2]}}"
done
[[ "$raw" == *'$'* ]] && return 1
printf '%s' "$raw"
}
# Build locali / no registry: non fare docker pull (fallirebbe o scaricherebbe un omonimo Hub).
failover_image_is_local_build() {
local image="$1"
local name="${image%%:*}"
image_is_ignored "$image" && return 0
case "$name" in
nodus-backend|nodus-frontend|loogle-casa|loogle-mcp|irrigazione|ewelink_smart_home|turni-app)
return 0
;;
esac
return 1
}
collect_failover_registry_images() {
local dir="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
local f line img expanded
[[ -d "$dir" ]] || return 1
while IFS= read -r f; do
[[ -f "$f" ]] || continue
while IFS= read -r line; do
[[ -z "$line" ]] && continue
img=$(expand_compose_image "$line") || continue
failover_image_is_local_build "$img" && continue
printf '%s\n' "$img"
done < <(sed -n 's/^[[:space:]]*image:[[:space:]]*//p' "$f" | sed 's/["'\'']//g')
done < <(find "$dir" -type f \( -name '*.yml' -o -name '*.yaml' \) | sort)
}
pull_failover_standby_images() {
command -v docker >/dev/null 2>&1 || return 0
local dir="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
log "▶ Pull immagini failover (registry, anche senza container)"
append_report ""
append_report "=== Pull immagini failover (standby) ==="
append_report "Compose: $dir"
if [[ ! -d "$dir" ]]; then
WARNINGS+=("Directory compose failover assente: $dir")
append_report "Directory assente"
return 0
fi
local -a images=()
local img
while IFS= read -r img; do
[[ -z "$img" ]] && continue
images+=("$img")
done < <(collect_failover_registry_images | sort -u)
if ((${#images[@]} == 0)); then
WARNINGS+=("Nessuna immagine registry nei compose failover")
append_report "Lista vuota"
return 0
fi
local pulled=0 failed=0 skipped=0
local before after
for img in "${images[@]}"; do
before=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || true)
if docker pull "$img" >> "$REPORT_FILE" 2>&1; then
after=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || true)
if [[ -n "$before" && "$before" == "$after" ]]; then
skipped=$((skipped + 1))
log " = $img (già aggiornata)"
append_report "unchanged: $img"
else
pulled=$((pulled + 1))
log "$img"
append_report "updated: $img"
fi
else
failed=$((failed + 1))
WARNINGS+=("docker pull fallito: $img")
log "$img"
fi
done
NOTES+=("Failover images: ${#images[@]} registry, $pulled aggiornate, $skipped già ok, $failed errori")
append_report "Riepilogo: ${#images[@]} immagini, aggiornate=$pulled, invariate=$skipped, errori=$failed"
log "✓ Pull failover: aggiornate=$pulled invariate=$skipped errori=$failed"
}
audit_apt() {
local holds
holds=$(apt-mark showhold 2>/dev/null || true)
@@ -413,8 +535,10 @@ fi
# 3. EEPROM firmware
audit_eeprom
# 4. Aggiornamento container Docker (Watchtower run-once, sostituisce il daemon schedulato)
# 4. Aggiornamento container Docker (Watchtower run-once; il daemon è MONITOR_ONLY)
run_watchtower
# 4b. Immagini registry dei compose failover (Paperless, Vaultwarden, …) anche se non c'è container
pull_failover_standby_images
# 5. Audit residui
audit_docker
+36 -16
View File
@@ -1,36 +1,47 @@
#!/bin/bash
# ==============================================================================
# 💾 AUTO GIT BACKUP - DINAMICO
# AUTO GIT BACKUP - DINAMICO
# Sincronizza automaticamente tutti gli script .sh e .py dal Pi2 e dal Pi1
# verso il repository Gitea locale, poi esegue il push.
# ==============================================================================
set -u
# --- CONFIGURAZIONE ---
REPO_DIR="/home/daniely/loogle-repo"
LOG_FILE="/var/log/git-backup.log"
DATE=$(date +"%Y-%m-%d %H:%M")
export GIT_SSH_COMMAND="ssh -o BatchMode=yes -o ConnectTimeout=10"
# IP del Pi1 (Master) da cui prelevare i file remoti
PI1_IP="192.168.128.80"
PI1_USER="daniely"
# Redirige tutto l'output (stdout e stderr) nel log
exec >> $LOG_FILE 2>&1
exec >> "$LOG_FILE" 2>&1
echo "=== Inizio Backup Git: $DATE ==="
fail() {
echo "$1"
echo "=== Fine Backup Git (ERRORE) ==="
exit 1
}
# 1. PREPARAZIONE REPOSITORY
# --------------------------
if [ -d "$REPO_DIR" ]; then
cd "$REPO_DIR" || { echo "❌ Errore: Impossibile entrare in $REPO_DIR"; exit 1; }
cd "$REPO_DIR" || fail "Impossibile entrare in $REPO_DIR"
else
echo "❌ Errore: La cartella $REPO_DIR non esiste."; exit 1;
fail "La cartella $REPO_DIR non esiste."
fi
# Aggiorna il repository locale (pull) per evitare conflitti
echo "🔄 Eseguo Git Pull..."
git pull origin main
if ! git pull --ff-only origin main; then
echo "⚠️ Git pull fallito (proseguo con commit locale se possibile)."
fi
# Assicuriamoci che le cartelle di destinazione esistano
mkdir -p ./scripts/pi2-backup
@@ -43,7 +54,6 @@ mkdir -p ./configs
echo "📂 Raccolta dinamica file locali (Pi-2)..."
# A. Script nella Home (solo .sh) -> scripts/pi2-backup
# --include='*.sh' prende gli script, --exclude='*' ignora tutto il resto
rsync -av --include='*.sh' --exclude='*' /home/daniely/ "$REPO_DIR/scripts/pi2-backup/"
# B. Script del Bot (tutti .py e .sh) -> services/telegram-bot
@@ -61,32 +71,42 @@ fi
echo "📡 Raccolta dinamica file remoti (Pi-1)..."
# A. Script nella Home remota (.sh e .py) -> scripts/pi1-master
# Nota: Richiede che le chiavi SSH siano configurate per non chiedere password
rsync -av -e "ssh -q" --include='*.sh' --include='*.py' --exclude='*' $PI1_USER@$PI1_IP:/home/daniely/ "$REPO_DIR/scripts/pi1-master/"
rsync -av -e "ssh -q" --include='*.sh' --include='*.py' --exclude='*' \
"$PI1_USER@$PI1_IP:/home/daniely/" "$REPO_DIR/scripts/pi1-master/" \
|| echo "⚠️ rsync Pi1 fallito (ignorato)"
# B. Recupero file specifici fuori dalla home (Legacy da vecchio script)
# Se dhcp-alert.sh esiste ancora in /usr/local/bin, lo prendiamo
scp -q $PI1_USER@$PI1_IP:/usr/local/bin/dhcp-alert.sh ./scripts/pi1-master/ 2>/dev/null || echo "⚠️ dhcp-alert.sh non trovato su Pi1 (ignorato)"
scp -q "$PI1_USER@$PI1_IP:/usr/local/bin/dhcp-alert.sh" ./scripts/pi1-master/ 2>/dev/null \
|| echo "⚠️ dhcp-alert.sh non trovato su Pi1 (ignorato)"
# C. Configurazione Keepalived Remota
scp -q $PI1_USER@$PI1_IP:/etc/keepalived/keepalived.conf ./configs/keepalived_pi1.conf 2>/dev/null
if [ $? -eq 0 ]; then
if scp -q "$PI1_USER@$PI1_IP:/etc/keepalived/keepalived.conf" ./configs/keepalived_pi1.conf 2>/dev/null; then
echo "✅ Configurazione Keepalived Pi1 scaricata."
else
echo "⚠️ Impossibile scaricare Keepalived conf da Pi1."
fi
# D. Sorgente Loogle MCP (app completa: qui vive solo su Pi1, fuori da git)
# .env resta escluso: contiene JWT secret e token Paperless/Gitea/OpenAI.
mkdir -p "$REPO_DIR/services/loogle-mcp"
rsync -av --delete -e "ssh -q" \
--exclude='.env' --exclude='data/' --exclude='__pycache__/' --exclude='*.pyc' --exclude='.cursor/' \
"$PI1_USER@$PI1_IP:/home/daniely/docker/loogle-mcp/" "$REPO_DIR/services/loogle-mcp/" \
|| echo "⚠️ rsync sorgente MCP fallito (ignorato)"
# 4. GIT PUSH
# -----------
# Verifica se ci sono cambiamenti reali
if [[ `git status --porcelain` ]]; then
if [[ -n $(git status --porcelain) ]]; then
echo "📝 Rilevati cambiamenti. Eseguo Commit e Push..."
git add .
git commit -m "Backup automatico script del $DATE"
git push -u origin main
git commit -m "Backup automatico script del $DATE" || fail "Commit fallito"
if git push origin main; then
echo "✅ Push completato con successo."
else
fail "Push su Gitea fallito (SSH/auth/Gitea down?)."
fi
else
echo "️ Nessun cambiamento rilevato. Repository già aggiornato."
fi
+2 -142
View File
@@ -1,142 +1,2 @@
#!/bin/bash
# ================================================
# 🔍 SUPER WATCHDOG DI RETE (Gira su Pi-2)
# ================================================
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
echo "ERRORE: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&2
exit 1
fi
STATE_DIR="/tmp/watchdog_states"
mkdir -p "$STATE_DIR"
# Riavvio giornaliero AP WiFi: silenzio allarmi per REBOOT_GRACE_MIN minuti
# dall'orario programmato. Se ancora DOWN dopo la finestra → allarme.
REBOOT_GRACE_MIN=5
TARGETS=(
"🍓 Pi-1 (Master)|192.168.128.80"
"🗄️ NAS DS920+|192.168.128.100"
"🌍 Internet (Google)|8.8.8.8"
"📡 Router Main|192.168.128.1"
"🗄️ NAS DS214|192.168.128.90"
"🔌 Switch Sala (.105)|192.168.128.105"
"🔌 Switch Taverna (.106)|192.168.128.106"
"🔌 Switch Lavanderia (.107)|192.168.128.107"
"📶 WiFi Sala (.101)|192.168.128.101"
"📶 WiFi Luca (.102)|192.168.128.102"
"📶 WiFi Taverna (.103)|192.168.128.103"
"📶 WiFi Dado (.104)|192.168.128.104"
"📶 WiFi Esterno (.108)|192.168.128.108"
"📶 WiFi Pozzo (.109)|192.168.128.109"
"📷 Cam Matrimoniale|192.168.135.2"
"📷 Cam Luca|192.168.135.3"
"📷 Cam Ingresso|192.168.135.4"
"📷 Cam Sala|192.168.135.5"
"📷 Cam Taverna|192.168.135.6"
"📷 Cam Retro|192.168.135.7"
)
send_alert() {
local title="$1"
local body="$2"
local severity="$3"
"$LOOGLE_NOTIFY" --title "$title" --body "$body" --category super_watchdog \
--severity "$severity" &
}
# Restituisce i minuti da mezzanotte dell'inizio riavvio (o vuoto se non programmato).
# 04:00 → Dado (.104), Sala (.101)
# 04:30 → Luca (.102), Taverna (.103)
# 14:00 → Esterno (.108), Pozzo (.109)
wifi_reboot_start_min() {
case "$1" in
192.168.128.101|192.168.128.104) echo 240 ;; # 04:00
192.168.128.102|192.168.128.103) echo 270 ;; # 04:30
192.168.128.108|192.168.128.109) echo 840 ;; # 14:00
*) echo "" ;;
esac
}
# 0 se siamo nella finestra [start, start+grace) del riavvio programmato.
in_wifi_reboot_window() {
local start
start=$(wifi_reboot_start_min "$1")
[[ -n "$start" ]] || return 1
local now_min=$((10#$(date +%H) * 60 + 10#$(date +%M)))
local end=$((start + REBOOT_GRACE_MIN))
[[ $now_min -ge $start && $now_min -lt $end ]]
}
echo "--- Inizio controllo $(date) ---"
for target_line in "${TARGETS[@]}"; do
NAME=$(echo "$target_line" | cut -d'|' -f1)
IP=$(echo "$target_line" | cut -d'|' -f2)
SAFE_IP="${IP//./_}"
STATE_FILE="$STATE_DIR/${SAFE_IP}.state"
if [ -f "$STATE_FILE" ]; then
LAST_STATE=$(cat "$STATE_FILE")
else
LAST_STATE="UP"
echo "UP" > "$STATE_FILE"
fi
ping -c 2 -W 1 "$IP" > /dev/null 2>&1
PING_RESULT=$?
if [ $PING_RESULT -eq 0 ]; then
if [ "$LAST_STATE" == "DOWN" ]; then
send_alert \
"Dispositivo online" \
"RISOLTO: $NAME è tornato ONLINE! IP: $IP" \
"info"
echo "UP" > "$STATE_FILE"
echo "--> $NAME tornato UP. Notifica inviata."
elif [ "$LAST_STATE" == "REBOOT_DOWN" ]; then
# Ripristino dopo riavvio programmato: nessun allarme off/on
echo "UP" > "$STATE_FILE"
echo "--> $NAME tornato UP dopo riavvio programmato. Nessuna notifica."
fi
else
if [ "$LAST_STATE" == "UP" ]; then
if in_wifi_reboot_window "$IP"; then
echo "REBOOT_DOWN" > "$STATE_FILE"
echo "--> $NAME DOWN in finestra riavvio programmato. Silenzio."
else
ICON="⚠️"
if [[ "$NAME" == *"🚨"* || "$NAME" == *"🌍"* ]]; then ICON="🚨 CRITICO:"; fi
send_alert \
"Dispositivo offline" \
"$ICON ALLARME: $NAME è OFFLINE! IP: $IP non risponde." \
"warning"
echo "DOWN" > "$STATE_FILE"
echo "--> $NAME andato DOWN. Notifica inviata."
fi
elif [ "$LAST_STATE" == "REBOOT_DOWN" ]; then
if in_wifi_reboot_window "$IP"; then
echo "$NAME ancora DOWN in finestra riavvio. Silenzio."
else
# Oltre i 5 minuti dal riavvio programmato: allarme reale
ICON="⚠️"
send_alert \
"Dispositivo offline" \
"$ICON ALLARME: $NAME è OFFLINE oltre il riavvio programmato! IP: $IP non risponde." \
"warning"
echo "DOWN" > "$STATE_FILE"
echo "--> $NAME ancora DOWN dopo finestra riavvio. Notifica inviata."
fi
else
echo "$NAME ancora DOWN. Nessuna notifica."
fi
fi
done
echo "--- Fine controllo ---"
#!/usr/bin/env bash
exec /home/daniely/rete/infra-monitor/perimeter-watch.sh "$@"
+136 -12
View File
@@ -20,6 +20,8 @@ PIHOLE_BIN="/usr/local/bin/pihole"
REBOOT_DELAY_MIN=2
WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
WATCHTOWER_TIMEOUT=900
# Compose HA usati al pivot: Watchtower aggiorna solo i container esistenti.
FAILOVER_COMPOSE_DIR="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
HOST_LABEL="$(hostname -s)"
REBOOT_ON_SUCCESS=true
@@ -45,12 +47,21 @@ done
case "$(hostname -s)" in
pi1)
HOST_LABEL="Pi-1 (Master)"
# Immagini build locali (no registry pubblico) → escluse da Watchtower
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
;;
pi2)
HOST_LABEL="Pi-2 (Backup)"
CHECK_PIP3=true
DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
# Immagini build locali (no registry pubblico) → escluse da Watchtower
DOCKER_IGNORE_IMAGES=(
"irrigazione:latest"
"turni-app:beta-latest"
"turni-app:alpha-latest"
"meteo-alert:latest"
"loogle-casa:latest"
"ewelink_smart_home:1.4.6"
)
;;
esac
@@ -143,34 +154,52 @@ image_is_ignored() {
return 1
}
ensure_watchtower_labels() {
command -v docker >/dev/null 2>&1 || return 0
local name image
# Elenco nomi container da escludere (immagini locali / no registry).
# Nota: `docker update --label-add` non esiste → usiamo WATCHTOWER_DISABLE_CONTAINERS.
watchtower_disabled_containers() {
local name image disabled=()
while IFS= read -r line; do
[[ -z "$line" ]] && continue
name=${line%%|*}
image=${line#*|}
image_is_ignored "$image" || continue
docker update --label-add com.centurylinklabs.watchtower.enable=false "$name" >/dev/null 2>&1 || true
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
if image_is_ignored "$image"; then
disabled+=("$name")
fi
done < <(docker ps -a --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
if ((${#disabled[@]} > 0)); then
local IFS=,
printf '%s' "${disabled[*]}"
fi
}
run_watchtower() {
command -v docker >/dev/null 2>&1 || return 0
ensure_watchtower_labels
local disable_list
disable_list=$(watchtower_disabled_containers)
log "▶ Watchtower (run-once, container aggiornabili da registry)"
append_report ""
append_report "=== Watchtower run-once ==="
if [[ -n "$disable_list" ]]; then
log "Watchtower: esclusi container locali: $disable_list"
append_report "Esclusi (immagini locali): $disable_list"
fi
local -a env_args=(
-e WATCHTOWER_CLEANUP=true
-e WATCHTOWER_ROLLING_RESTART=false
-e "WATCHTOWER_TIMEOUT=${WATCHTOWER_TIMEOUT}"
-e TZ=Europe/Rome
)
if [[ -n "$disable_list" ]]; then
env_args+=(-e "WATCHTOWER_DISABLE_CONTAINERS=${disable_list}")
fi
local output rc=0
output=$(docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-e WATCHTOWER_CLEANUP=true \
-e WATCHTOWER_ROLLING_RESTART=false \
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
-e TZ=Europe/Rome \
"${env_args[@]}" \
"$WATCHTOWER_IMAGE" \
--run-once 2>&1) || rc=$?
@@ -191,6 +220,99 @@ run_watchtower() {
fi
}
# Espande ${VAR:-default} (usato nei compose Turni). Altri ${VAR} → scarta.
expand_compose_image() {
local raw="$1"
while [[ "$raw" =~ \$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\} ]]; do
raw="${raw/${BASH_REMATCH[0]}/${BASH_REMATCH[2]}}"
done
[[ "$raw" == *'$'* ]] && return 1
printf '%s' "$raw"
}
# Build locali / no registry: non fare docker pull (fallirebbe o scaricherebbe un omonimo Hub).
failover_image_is_local_build() {
local image="$1"
local name="${image%%:*}"
image_is_ignored "$image" && return 0
case "$name" in
nodus-backend|nodus-frontend|loogle-casa|loogle-mcp|irrigazione|ewelink_smart_home|turni-app)
return 0
;;
esac
return 1
}
collect_failover_registry_images() {
local dir="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
local f line img expanded
[[ -d "$dir" ]] || return 1
while IFS= read -r f; do
[[ -f "$f" ]] || continue
while IFS= read -r line; do
[[ -z "$line" ]] && continue
img=$(expand_compose_image "$line") || continue
failover_image_is_local_build "$img" && continue
printf '%s\n' "$img"
done < <(sed -n 's/^[[:space:]]*image:[[:space:]]*//p' "$f" | sed 's/["'\'']//g')
done < <(find "$dir" -type f \( -name '*.yml' -o -name '*.yaml' \) | sort)
}
pull_failover_standby_images() {
command -v docker >/dev/null 2>&1 || return 0
local dir="${FAILOVER_COMPOSE_DIR:-/home/daniely/rete/compose/failover}"
log "▶ Pull immagini failover (registry, anche senza container)"
append_report ""
append_report "=== Pull immagini failover (standby) ==="
append_report "Compose: $dir"
if [[ ! -d "$dir" ]]; then
WARNINGS+=("Directory compose failover assente: $dir")
append_report "Directory assente"
return 0
fi
local -a images=()
local img
while IFS= read -r img; do
[[ -z "$img" ]] && continue
images+=("$img")
done < <(collect_failover_registry_images | sort -u)
if ((${#images[@]} == 0)); then
WARNINGS+=("Nessuna immagine registry nei compose failover")
append_report "Lista vuota"
return 0
fi
local pulled=0 failed=0 skipped=0
local before after
for img in "${images[@]}"; do
before=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || true)
if docker pull "$img" >> "$REPORT_FILE" 2>&1; then
after=$(docker image inspect "$img" --format '{{.Id}}' 2>/dev/null || true)
if [[ -n "$before" && "$before" == "$after" ]]; then
skipped=$((skipped + 1))
log " = $img (già aggiornata)"
append_report "unchanged: $img"
else
pulled=$((pulled + 1))
log "$img"
append_report "updated: $img"
fi
else
failed=$((failed + 1))
WARNINGS+=("docker pull fallito: $img")
log "$img"
fi
done
NOTES+=("Failover images: ${#images[@]} registry, $pulled aggiornate, $skipped già ok, $failed errori")
append_report "Riepilogo: ${#images[@]} immagini, aggiornate=$pulled, invariate=$skipped, errori=$failed"
log "✓ Pull failover: aggiornate=$pulled invariate=$skipped errori=$failed"
}
audit_apt() {
local holds
holds=$(apt-mark showhold 2>/dev/null || true)
@@ -415,6 +537,8 @@ audit_eeprom
# 4. Aggiornamento container Docker (Watchtower run-once, sostituisce il daemon schedulato)
run_watchtower
# 4b. Immagini registry dei compose failover (Paperless, Vaultwarden, …) anche se non c'è container
pull_failover_standby_images
# 5. Audit residui
audit_docker
+51 -5
View File
@@ -1,16 +1,50 @@
#!/bin/bash
# raspiBackup post extension — notifica Loogle Casa (solo WebApp).
#
# RaspiBackup chiama il post hook PRIMA di STARTSERVICES: su Pi2 Casa è giù
# (before-stop ferma loogle-casa, STOPSERVICES ferma docker). Non notificare
# subito: systemd-run stacca dal cgroup e riprova finché l'API risponde.
#
# Sourced da raspiBackup: $1 = return code backup.
# Eseguito da systemd-run: $1 = --wait-send
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
MAX_TRIES="${LOOGLE_BACKUP_NOTIFY_TRIES:-36}"
SLEEP_SECS="${LOOGLE_BACKUP_NOTIFY_SLEEP:-5}"
wait_send() {
local title="$1" body="$2" severity="$3" tag="$4"
local i
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
echo "loogle-notify.sh assente" >&2
exit 0
fi
for ((i = 1; i <= MAX_TRIES; i++)); do
if "$LOOGLE_NOTIFY" --title "$title" --body "$body" --category raspi_backup \
--severity "$severity" --tag "$tag"; then
echo "notify ok try=$i tag=$tag"
exit 0
fi
echo "notify retry $i/$MAX_TRIES tag=$tag" >&2
sleep "$SLEEP_SECS"
done
echo "notify failed after ${MAX_TRIES} tries tag=$tag" >&2
exit 1
}
if [[ "${1:-}" == "--wait-send" ]]; then
wait_send "${2:-}" "${3:-}" "${4:-info}" "${5:-raspi_backup}"
exit $?
fi
RC="${1:-1}"
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
HOST="${HOSTNAME:-$(hostname)}"
HOST="${HOST%%.*}"
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
return 0 2>/dev/null || exit 0
fi
HOST="${HOSTNAME:-$(hostname)}"
if (( RC == 0 )); then
TITLE="Backup completato"
BODY="Backup di ${HOST} terminato con successo."
@@ -21,7 +55,19 @@ else
SEVERITY="error"
fi
"$LOOGLE_NOTIFY" --title "$TITLE" --body "$BODY" --category raspi_backup \
--severity "$SEVERITY" &
TAG="raspi_backup-${HOST}"
SELF="$(readlink -f "${BASH_SOURCE[0]:-$0}" 2>/dev/null || echo /usr/local/bin/raspiBackup_loogle_post.sh)"
# Stacca dal servizio raspiBackup (KillMode=control-group) e aspetta Casa.
if command -v systemd-run >/dev/null 2>&1; then
systemd-run --quiet --collect \
--description="Loogle Casa raspiBackup notify ${HOST}" \
/bin/bash "$SELF" --wait-send "$TITLE" "$BODY" "$SEVERITY" "$TAG" \
|| true
else
nohup /bin/bash "$SELF" --wait-send "$TITLE" "$BODY" "$SEVERITY" "$TAG" \
>/dev/null 2>&1 &
disown 2>/dev/null || true
fi
return 0 2>/dev/null || exit 0
@@ -0,0 +1,85 @@
# Generato da .env.example — personalizza prima del deploy
MCP_BASE_URL=https://mcp.loogle.it
MCP_PORT=8700
MCP_JWT_SECRET='k9#mP2$vQ8!zX5_wL3*bF7%hR4&jN6+yT1-pC9@dG2#fK5^sV8~xL4?hZ1'
MCP_OAUTH_CLIENT_ID=loogle-mcp-public
PAPERLESS_URL=https://docs.loogle.it
# Token API utente (docs.loogle.it → Profilo → Token API) — vedi docs/PAPERLESS-TOKEN.md
# Opzionale fallback admin: PAPERLESS_API_TOKEN=
PAPERLESS_API_TOKEN_DANIELE=d75d378c1ce654578956eb595c0436cb96213b5c
PAPERLESS_API_TOKEN_LUCIA=b3124dab2d42f230e503adafaac7ff42551c1c56
PAPERLESS_API_TOKEN_DAVIDE=c50ecb5780eee62d5882bff980dbed8c7cf1b860
PAPERLESS_API_TOKEN_LUCA=45792e52a58d7f2a34fcf0f237b81770ac2f8fdc
# Gitea API — vedi docs/GITEA-TOKEN.md
GITEA_URL=https://git.loogle.it
GITEA_API_URL=http://192.168.128.81:3002
GITEA_API_TOKEN_DANIELE=86faeed42981e855762b34f86b53dc342766fa44
GITEA_API_TOKEN_LUCIA=478e0ac9436b412357fab144710ff74ab927e7fd
GITEA_API_TOKEN_DAVIDE=36a097519686ce64cc1103f66691555adbc3735d
GITEA_API_TOKEN_LUCA=051239ae1e8e60a89619249abed1b65550d29414
# Qdrant remoto su DS920 (consigliato) — lasciare vuoto per fallback SQLite su Pi
QDRANT_URL=http://192.168.128.100:6333
# Qdrant locale disabilitato su Pi5 (page size 16K) — vedi compose profile qdrant-local
OLLAMA_URL=http://192.168.128.100:11434
OLLAMA_EMBED_MODEL=nomic-embed-text
OPENAI_API_KEY=
OPENAI_EMBED_MODEL=text-embedding-3-small
INDEXER_INTERVAL_MINUTES=30
# Thermal gate DS920 — profilo lento/regolare (1 core Ollama + delay lunghi)
# Soft anticipato: un embed può alzare la temp di diversi °C
THERMAL_GATE_ENABLED=yes
THERMAL_TEMP_SOFT_C=58
THERMAL_TEMP_HARD_C=70
THERMAL_CPU_TARGET_PCT=40
THERMAL_POLL_S=30
THERMAL_RESUME_MARGIN_C=2
DS920_THERMAL_URL=http://192.168.128.100:9191/thermal
OLLAMA_NUM_THREAD=1
OLLAMA_EMBED_DELAY_S=10
OLLAMA_EMBED_COOL_DELAY_S=3
OLLAMA_KEEP_ALIVE_DEFAULT=120
OLLAMA_KEEP_ALIVE_COOL=300
# no = non scaricare il modello per HARD (container sempre su; solo pausa indexer)
OLLAMA_UNLOAD_ON_HARD=no
# RAG Gitea (P3)
GITEA_INDEX_ENABLED=yes
GITEA_INDEX_REPOS_DANIELE=daniele/rete,daniele/loogle-scripts
GITEA_INDEX_REPOS_LUCIA=lucia/nodus,lucia/progetti
GITEA_INDEX_REPOS_DAVIDE=davide/progetti
GITEA_INDEX_REPOS_LUCA=luca/progetti
GITEA_INDEX_MAX_FILES_PER_REPO=40
GITEA_INDEX_MAX_FILE_BYTES=120000
# P5 — Loogle Casa + Home Assistant (read-only)
LOOGLE_CASA_URL=https://casa.loogle.it
LOOGLE_CASA_API_URL=http://192.168.128.81:5602
LOOGLE_CASA_PASSWORD_DANIELE=@Dedelove1
LOOGLE_CASA_PASSWORD_LUCIA=lucia
LOOGLE_CASA_PASSWORD_DAVIDE=davide
LOOGLE_CASA_PASSWORD_LUCA=luca
HA_URL=https://ha.loogle.it
HA_API_URL=http://192.168.128.81:8123
HA_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI0ZGVhYzczOGYzMTA0Y2QzYTQ3YTViNDRlZTY2NjNhNiIsImlhdCI6MTc4MTE2NjE3NSwiZXhwIjoyMDk2NTI2MTc1fQ.2sou8f41sEcN_gn1NxJfb1MLNftMVHAsReQB0hP-jPo
# P6 — Irrigazione + Turni (read-only)
IRRIGAZIONE_URL=https://irri.loogle.it
IRRIGAZIONE_API_URL=http://192.168.128.81:5601
IRRIGAZIONE_PASSWORD_DANIELE=@Dedelove1
IRRIGAZIONE_PASSWORD_LUCIA=lucia
IRRIGAZIONE_PASSWORD_DAVIDE=dado
IRRIGAZIONE_PASSWORD_LUCA=luca
TURNI_URL=https://turni.loogle.it
TURNI_API_URL=https://turni.loogle.it
TURNI_PASSWORD_DANIELE=@Dedelove1
# P7 — RAG Irrigazione + Turni
APPS_INDEX_ENABLED=yes
APPS_INDEX_USER=daniele
+81
View File
@@ -0,0 +1,81 @@
# Loogle MCP Hub — copia in .env e personalizza
MCP_BASE_URL=https://mcp.loogle.it
MCP_PORT=8700
MCP_JWT_SECRET=change-me-to-a-long-random-string
MCP_OAUTH_CLIENT_ID=loogle-mcp-public
# Paperless API — vedi docs/PAPERLESS-TOKEN.md
PAPERLESS_URL=https://docs.loogle.it
# Fallback admin (opzionale se usi token per utente sotto)
PAPERLESS_API_TOKEN=
PAPERLESS_API_TOKEN_DANIELE=
PAPERLESS_API_TOKEN_LUCIA=
PAPERLESS_API_TOKEN_DAVIDE=
PAPERLESS_API_TOKEN_LUCA=
# Gitea API — vedi docs/GITEA-TOKEN.md
GITEA_URL=https://git.loogle.it
# Opzionale: URL API raggiungibile dal container MCP (es. http://192.168.128.81:3002)
# GITEA_API_URL=
GITEA_API_TOKEN=
GITEA_API_TOKEN_DANIELE=
GITEA_API_TOKEN_LUCIA=
GITEA_API_TOKEN_DAVIDE=
GITEA_API_TOKEN_LUCA=
# Qdrant (container locale o remoto)
QDRANT_URL=http://192.168.128.100:6333
# Embedding via Ollama su DS920+
OLLAMA_URL=http://192.168.128.100:11434
OLLAMA_EMBED_MODEL=nomic-embed-text
# Fallback cloud embedding (opzionale)
OPENAI_API_KEY=
OPENAI_EMBED_MODEL=text-embedding-3-small
# Indicizzazione
INDEXER_INTERVAL_MINUTES=30
# Thermal gate DS920 — evita overheating durante embedding
THERMAL_GATE_ENABLED=yes
THERMAL_TEMP_SOFT_C=58
THERMAL_TEMP_HARD_C=70
THERMAL_CPU_TARGET_PCT=40
THERMAL_POLL_S=30
THERMAL_RESUME_MARGIN_C=2
DS920_THERMAL_URL=http://192.168.128.100:9191/thermal
OLLAMA_NUM_THREAD=1
OLLAMA_EMBED_DELAY_S=10
OLLAMA_EMBED_COOL_DELAY_S=3
# RAG Gitea (P3) — indicizza markdown/codice da git.loogle.it
GITEA_INDEX_ENABLED=yes
# Opzionale: limita repo per utente (CSV). Vuoto = tutti i repo accessibili via API
# GITEA_INDEX_REPOS_DANIELE=daniele/rete,daniele/loogle-scripts
# GITEA_INDEX_REPOS_DAVIDE=davide/progetti
# GITEA_INDEX_REPOS_LUCA=luca/progetti
GITEA_INDEX_MAX_FILES_PER_REPO=150
GITEA_INDEX_MAX_FILE_BYTES=120000
# P5 — Loogle Casa + Home Assistant (read-only)
LOOGLE_CASA_URL=https://casa.loogle.it
# LOOGLE_CASA_API_URL=http://192.168.128.81:5602
LOOGLE_CASA_PASSWORD_DANIELE=
LOOGLE_CASA_PASSWORD_LUCIA=
LOOGLE_CASA_PASSWORD_DAVIDE=
LOOGLE_CASA_PASSWORD_LUCA=
HA_URL=https://ha.loogle.it
# HA_API_URL=http://192.168.128.81:8123
HA_TOKEN=
# P6 — Irrigazione + Turni (read-only)
IRRIGAZIONE_URL=https://irri.loogle.it
# IRRIGAZIONE_API_URL=http://192.168.128.81:5601
IRRIGAZIONE_PASSWORD_DANIELE=
TURNI_URL=https://turni.loogle.it
TURNI_PASSWORD_DANIELE=
# P7 — RAG Irrigazione + Turni
APPS_INDEX_ENABLED=yes
APPS_INDEX_USER=daniele
+4
View File
@@ -0,0 +1,4 @@
data/
.env
*.pyc
__pycache__/
+24
View File
@@ -0,0 +1,24 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 TZ=Europe/Rome
RUN apt-get update && apt-get install -y --no-install-recommends curl openssh-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /srv
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY worker ./worker
COPY scripts ./scripts
COPY scripts/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENV PYTHONPATH=/srv
VOLUME /data
EXPOSE 8700
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8700"]
+23
View File
@@ -0,0 +1,23 @@
# Loogle MCP Hub
Piattaforma MCP multi-utente per homelab LOOGLE.IT.
## Documentazione
| Documento | Per chi |
|-----------|---------|
| [ARCHITETTURA.md](docs/ARCHITETTURA.md) | Architettura, parametri, DNS, NPM, env |
| [GUIDA-ADMIN.md](docs/GUIDA-ADMIN.md) | Daniele — gestione e uso avanzato |
| [GUIDA-UTENTI.md](docs/GUIDA-UTENTI.md) | Lucia, Davide, Luca — client AI e uso quotidiano |
| [ONBOARDING.md](docs/ONBOARDING.md) | Dettaglio tecnico connector MCP |
## URL
- MCP: https://mcp.loogle.it/mcp
- Dashboard: https://mcp.loogle.it/dashboard
## Setup DNS + NPM
```bash
sudo /home/daniely/rete/scripts/setup-mcp-dns-npm.sh
```
View File
Whitespace-only changes.
+34
View File
@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
"""Audit log for MCP tool invocations."""
import json
from typing import Optional
from .db import get_conn
def log_tool(username: str, tool_name: str, resource_id: Optional[str] = None, detail: Optional[dict] = None) -> None:
conn = get_conn()
conn.execute(
"INSERT INTO audit_log(username,tool_name,resource_id,detail) VALUES (?,?,?,?)",
(username, tool_name, resource_id, json.dumps(detail or {}, ensure_ascii=False)),
)
conn.execute(
"DELETE FROM audit_log WHERE id NOT IN "
"(SELECT id FROM audit_log ORDER BY id DESC LIMIT 5000)"
)
conn.commit()
def list_audit(limit: int = 100, username: Optional[str] = None) -> list:
conn = get_conn()
if username:
rows = conn.execute(
"SELECT * FROM audit_log WHERE username=? ORDER BY id DESC LIMIT ?",
(username, limit),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM audit_log ORDER BY id DESC LIMIT ?", (limit,)
).fetchall()
return [dict(r) for r in rows]
+146
View File
@@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-
"""Autenticazione utenti famiglia — pattern Loogle Casa."""
import base64
import datetime
import hashlib
import hmac
import os
import secrets
import time
from typing import Optional
from fastapi import HTTPException, Request
from .db import get_conn
SESSION_DAYS = 30
PBKDF2_ITER = 240_000
MAX_ATTEMPTS = 8
WINDOW_S = 600
_attempts: dict = {}
FAMILY_USERS = (
("daniele", True),
("lucia", False),
("davide", False),
("luca", False),
)
DEFAULT_SCOPES = (
"context:read context:write knowledge:read knowledge:write gitea:read gitea:write"
)
def hash_password(password: str) -> str:
salt = os.urandom(16)
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITER)
return "pbkdf2$%d$%s$%s" % (
PBKDF2_ITER,
base64.b64encode(salt).decode(),
base64.b64encode(dk).decode(),
)
def verify_password(password: str, stored: str) -> bool:
try:
_, iters, salt_b64, dk_b64 = stored.split("$")
salt = base64.b64decode(salt_b64)
expected = base64.b64decode(dk_b64)
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, int(iters))
return hmac.compare_digest(dk, expected)
except Exception:
return False
def ensure_family_users() -> None:
conn = get_conn()
for username, is_admin in FAMILY_USERS:
row = conn.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()
if row:
continue
conn.execute(
"INSERT INTO users(username,password_hash,is_admin,must_change_password)"
" VALUES (?,?,?,1)",
(username, hash_password(username), 1 if is_admin else 0),
)
conn.commit()
def throttle(ip: str) -> None:
now = time.time()
hist = [t for t in _attempts.get(ip, []) if now - t < WINDOW_S]
_attempts[ip] = hist
if len(hist) >= MAX_ATTEMPTS:
raise HTTPException(429, "Troppi tentativi: riprova tra qualche minuto")
def record_attempt(ip: str) -> None:
_attempts.setdefault(ip, []).append(time.time())
def authenticate(username: str, password: str) -> Optional[dict]:
conn = get_conn()
row = conn.execute("SELECT * FROM users WHERE username=?", (username.strip(),)).fetchone()
if not row or not verify_password(password, row["password_hash"]):
return None
return dict(row)
def get_user_by_id(user_id: int) -> Optional[dict]:
row = get_conn().execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
return dict(row) if row else None
def get_user_by_username(username: str) -> Optional[dict]:
row = get_conn().execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
return dict(row) if row else None
def change_password(user_id: int, old_password: str, new_password: str) -> bool:
row = get_conn().execute("SELECT password_hash FROM users WHERE id=?", (user_id,)).fetchone()
if not row or not verify_password(old_password, row["password_hash"]):
return False
conn = get_conn()
conn.execute(
"UPDATE users SET password_hash=?, must_change_password=0 WHERE id=?",
(hash_password(new_password), user_id),
)
conn.commit()
return True
def current_user_from_cookie(request: Request) -> dict:
token = request.cookies.get("mcp_session", "")
if not token:
raise HTTPException(401, "Non autenticato")
row = get_conn().execute(
"SELECT u.id,u.username,u.is_admin,u.must_change_password"
" FROM sessions s JOIN users u ON u.id=s.user_id"
" WHERE s.token=? AND s.expires_at > datetime('now')",
(token,),
).fetchone()
if not row:
raise HTTPException(401, "Sessione scaduta")
return dict(row)
def require_admin(request: Request) -> dict:
user = current_user_from_cookie(request)
if not user["is_admin"]:
raise HTTPException(403, "Riservato all'amministratore")
return user
def ensure_sessions_table() -> None:
get_conn().execute(
"""
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
)
"""
)
get_conn().commit()
Whitespace-only changes.
@@ -0,0 +1,154 @@
# -*- coding: utf-8 -*-
"""Collegamento progetti MCP ↔ repository Gitea."""
from __future__ import annotations
import logging
from typing import Optional
from ..knowledge import gitea
LOGGER = logging.getLogger("loogle_mcp.context.gitea_link")
README_CANDIDATES = ("README.md", "readme.md", "Readme.md", "README.MD")
DOCS_DIR = "docs"
MAX_README_CHARS = 12_000
MAX_DOC_FILES = 25
def normalize_gitea_repo(repo: Optional[str]) -> Optional[str]:
if repo is None:
return None
cleaned = repo.strip()
if not cleaned:
return None
owner, name = gitea.parse_repo(cleaned)
return f"{owner}/{name}"
def verify_repo_access(username: str, repo: str) -> None:
owner, name = gitea.parse_repo(repo)
gitea._request("GET", f"/repos/{owner}/{name}", username=username)
def fetch_readme(username: str, repo: str) -> Optional[dict]:
for path in README_CANDIDATES:
try:
data = gitea.get_file(repo, path, username=username)
except FileNotFoundError:
continue
except Exception as exc:
LOGGER.warning("README %s/%s non leggibile: %s", repo, path, exc)
continue
if data.get("type") != "file":
continue
content = (data.get("content") or "").strip()
if not content:
continue
truncated = len(content) > MAX_README_CHARS
return {
"path": path,
"sha": data.get("sha"),
"html_url": data.get("html_url"),
"content": content[:MAX_README_CHARS],
"truncated": truncated,
}
return None
def fetch_docs_index(username: str, repo: str) -> list[dict]:
try:
data = gitea.get_file(repo, DOCS_DIR, username=username)
except FileNotFoundError:
return []
except Exception as exc:
LOGGER.warning("Directory docs/ non leggibile per %s: %s", repo, exc)
return []
if data.get("type") != "dir":
return []
entries = []
for item in data.get("entries") or []:
if item.get("type") != "file":
continue
path = item.get("path") or ""
if not path.lower().endswith((".md", ".txt", ".rst")):
continue
entries.append(
{
"path": path,
"size": item.get("size"),
}
)
if len(entries) >= MAX_DOC_FILES:
break
return entries
def project_enrichment(username: str, gitea_repo: str) -> dict:
repo = normalize_gitea_repo(gitea_repo)
if not repo:
return {"linked": False}
base = {
"linked": True,
"repo": repo,
"html_url": f"{gitea.public_base_url()}/{repo}",
}
if not gitea.is_configured(username):
return {
**base,
"available": False,
"error": "Gitea non configurato per questo utente",
}
try:
verify_repo_access(username, repo)
readme = fetch_readme(username, repo)
docs = fetch_docs_index(username, repo)
open_issues = None
try:
issues = gitea.list_issues(repo, state="open", limit=1, username=username)
open_issues = issues.get("count", 0)
except Exception:
pass
return {
**base,
"available": True,
"readme": readme,
"docs_files": docs,
"open_issues": open_issues,
}
except Exception as exc:
return {
**base,
"available": False,
"error": str(exc),
}
def readme_seed_block(repo: str, readme: dict) -> str:
path = readme.get("path") or "README.md"
content = readme.get("content") or ""
truncated_note = "\n\n*(README troncato — usa get_file per il testo completo)*" if readme.get("truncated") else ""
return (
f"<!-- seed:gitea {repo} {path} -->\n\n"
f"## Sorgente Gitea: `{repo}`\n\n"
f"Contenuto iniziale da `{path}`.\n\n"
f"{content.rstrip()}{truncated_note}\n"
)
def seed_context_from_readme(username: str, repo: str, context_md: str) -> tuple[str, bool]:
"""Importa README nel context se non già presente un seed Gitea."""
if "<!-- seed:gitea " in context_md:
return context_md, False
readme = fetch_readme(username, repo)
if not readme:
return context_md, False
block = readme_seed_block(repo, readme)
if context_md.strip():
return context_md.rstrip() + "\n\n---\n\n" + block, True
return block, True
+252
View File
@@ -0,0 +1,252 @@
# -*- coding: utf-8 -*-
"""Filesystem-backed project context store."""
import json
import os
import re
import secrets
from datetime import datetime
from typing import Optional
from . import gitea_link
from ..knowledge import gitea
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
def _context_root() -> str:
return os.environ.get("MCP_CONTEXT_ROOT", "/data/context")
def _user_root(username: str) -> str:
path = os.path.join(_context_root(), username, "projects")
os.makedirs(path, exist_ok=True)
return path
def _project_dir(username: str, project_id: str) -> str:
if not SLUG_RE.match(project_id):
raise ValueError("ID progetto non valido")
return os.path.join(_user_root(username), project_id)
def _load_meta(username: str, project_id: str) -> tuple[str, dict]:
proj_dir = _project_dir(username, project_id)
meta_path = os.path.join(proj_dir, "meta.json")
if not os.path.isfile(meta_path):
raise FileNotFoundError("Progetto non trovato")
meta = json.load(open(meta_path, encoding="utf-8"))
return meta_path, meta
def _save_meta(meta_path: str, meta: dict) -> None:
meta["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
def _apply_gitea_link(
username: str,
meta: dict,
gitea_repo: Optional[str],
*,
verify: bool = True,
seed_from_gitea: bool = False,
) -> dict:
repo = gitea_link.normalize_gitea_repo(gitea_repo)
if verify and repo:
if not gitea.is_configured(username):
raise RuntimeError(
"Gitea non configurato per questo utente — impossibile collegare il repository"
)
gitea_link.verify_repo_access(username, repo)
if repo:
meta["gitea_repo"] = repo
else:
meta.pop("gitea_repo", None)
if seed_from_gitea and repo:
proj_dir = _project_dir(username, meta["id"])
ctx_path = os.path.join(proj_dir, "context.md")
context_md = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else ""
new_md, changed = gitea_link.seed_context_from_readme(username, repo, context_md)
if changed:
with open(ctx_path, "w", encoding="utf-8") as f:
f.write(new_md)
meta["gitea_seeded_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
return meta
def list_projects(username: str, include_archived: bool = False) -> list:
root = _user_root(username)
projects = []
if not os.path.isdir(root):
return projects
for name in sorted(os.listdir(root)):
meta_path = os.path.join(root, name, "meta.json")
if not os.path.isfile(meta_path):
continue
meta = json.load(open(meta_path, encoding="utf-8"))
if meta.get("archived") and not include_archived:
continue
projects.append(meta)
return projects
def create_project(
username: str,
title: str,
tags: Optional[list[str]] = None,
gitea_repo: Optional[str] = None,
seed_from_gitea: bool = False,
) -> dict:
slug_base = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "progetto"
project_id = slug_base[:40]
root = _user_root(username)
while os.path.exists(os.path.join(root, project_id)):
project_id = f"{slug_base[:32]}-{secrets.token_hex(2)}"
proj_dir = _project_dir(username, project_id)
os.makedirs(os.path.join(proj_dir, "sessions"), exist_ok=True)
os.makedirs(os.path.join(proj_dir, "artifacts"), exist_ok=True)
now = datetime.now().astimezone().isoformat(timespec="seconds")
meta = {
"id": project_id,
"title": title.strip(),
"tags": tags or [],
"status": "active",
"archived": False,
"created_at": now,
"updated_at": now,
}
repo = gitea_link.normalize_gitea_repo(gitea_repo)
if repo:
meta["gitea_repo"] = repo
with open(os.path.join(proj_dir, "meta.json"), "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
with open(os.path.join(proj_dir, "context.md"), "w", encoding="utf-8") as f:
f.write(f"# {title.strip()}\n\n")
if repo:
meta = _apply_gitea_link(
username,
meta,
repo,
verify=True,
seed_from_gitea=seed_from_gitea,
)
_save_meta(os.path.join(proj_dir, "meta.json"), meta)
return meta
def link_project_repo(
username: str,
project_id: str,
gitea_repo: Optional[str] = None,
seed_from_gitea: bool = False,
) -> dict:
meta_path, meta = _load_meta(username, project_id)
meta = _apply_gitea_link(
username,
meta,
gitea_repo,
verify=bool(gitea_link.normalize_gitea_repo(gitea_repo)),
seed_from_gitea=seed_from_gitea,
)
_save_meta(meta_path, meta)
return meta
def get_project_context(
username: str,
project_id: str,
session_limit: int = 5,
include_gitea: bool = True,
) -> dict:
proj_dir = _project_dir(username, project_id)
meta_path = os.path.join(proj_dir, "meta.json")
if not os.path.isfile(meta_path):
raise FileNotFoundError("Progetto non trovato")
meta = json.load(open(meta_path, encoding="utf-8"))
ctx_path = os.path.join(proj_dir, "context.md")
context_md = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else ""
sessions_dir = os.path.join(proj_dir, "sessions")
sessions = []
if os.path.isdir(sessions_dir):
files = sorted(os.listdir(sessions_dir), reverse=True)[:session_limit]
for fname in files:
path = os.path.join(sessions_dir, fname)
if os.path.isfile(path):
sessions.append({"name": fname, "content": open(path, encoding="utf-8").read()})
result = {"meta": meta, "context_md": context_md, "recent_sessions": sessions}
gitea_repo = meta.get("gitea_repo")
if include_gitea and gitea_repo:
result["gitea"] = gitea_link.project_enrichment(username, gitea_repo)
return result
def save_context(username: str, project_id: str, content: str, mode: str = "append") -> dict:
proj_dir = _project_dir(username, project_id)
meta_path = os.path.join(proj_dir, "meta.json")
if not os.path.isfile(meta_path):
raise FileNotFoundError("Progetto non trovato")
ctx_path = os.path.join(proj_dir, "context.md")
if mode == "replace":
text = content
else:
existing = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else ""
text = existing.rstrip() + "\n\n" + content.strip() + "\n"
with open(ctx_path, "w", encoding="utf-8") as f:
f.write(text)
meta = json.load(open(meta_path, encoding="utf-8"))
_save_meta(meta_path, meta)
snapshot = datetime.now().strftime("%Y%m%d-%H%M%S") + ".md"
with open(os.path.join(proj_dir, "sessions", snapshot), "w", encoding="utf-8") as f:
f.write(content)
return meta
def archive_project(username: str, project_id: str, archived: bool = True) -> dict:
meta_path, meta = _load_meta(username, project_id)
meta["archived"] = archived
meta["status"] = "archived" if archived else "active"
_save_meta(meta_path, meta)
return meta
def list_resources(username: str) -> list:
resources = []
for meta in list_projects(username, include_archived=False):
desc = f"Contesto progetto {meta['id']}"
if meta.get("gitea_repo"):
desc += f" (Gitea: {meta['gitea_repo']})"
resources.append(
{
"uri": f"loogle://context/{username}/{meta['id']}",
"name": meta["title"],
"description": desc,
"mimeType": "text/markdown",
}
)
return resources
def read_resource(username: str, uri: str) -> dict:
prefix = f"loogle://context/{username}/"
if not uri.startswith(prefix):
raise FileNotFoundError("Risorsa non trovata")
project_id = uri[len(prefix):]
data = get_project_context(username, project_id, session_limit=0, include_gitea=False)
text = data["context_md"]
gitea_repo = data["meta"].get("gitea_repo")
if gitea_repo:
text = f"<!-- gitea_repo: {gitea_repo} -->\n\n" + text
return {
"uri": uri,
"mimeType": "text/markdown",
"text": text,
}
+118
View File
@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
"""SQLite schema for Loogle MCP Hub."""
import os
import sqlite3
import threading
DB_PATH = os.environ.get("MCP_DB", "/data/loogle_mcp.db")
_local = threading.local()
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
must_change_password INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS oauth_clients (
client_id TEXT PRIMARY KEY,
client_name TEXT NOT NULL,
redirect_uris TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS oauth_codes (
code TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
redirect_uri TEXT NOT NULL,
scope TEXT NOT NULL,
code_challenge TEXT,
code_challenge_method TEXT,
expires_at TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id TEXT NOT NULL,
scope TEXT NOT NULL,
expires_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS revoked_jtis (
jti TEXT PRIMARY KEY,
revoked_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
tool_name TEXT NOT NULL,
resource_id TEXT,
detail TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS indexed_documents (
doc_id INTEGER PRIMARY KEY,
title TEXT,
owner TEXT,
visibility TEXT NOT NULL DEFAULT 'family',
indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
chunk_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS indexed_gitea_files (
repo TEXT NOT NULL,
path TEXT NOT NULL,
sha TEXT,
owner TEXT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'family',
chunk_count INTEGER NOT NULL DEFAULT 0,
indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (repo, path)
);
CREATE INDEX IF NOT EXISTS idx_gitea_indexed_repo ON indexed_gitea_files(repo);
CREATE INDEX IF NOT EXISTS idx_gitea_indexed_at ON indexed_gitea_files(indexed_at DESC);
CREATE TABLE IF NOT EXISTS indexed_apps_records (
source TEXT NOT NULL,
record_id TEXT NOT NULL,
title TEXT,
owner TEXT NOT NULL DEFAULT 'family',
chunk_count INTEGER NOT NULL DEFAULT 0,
indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (source, record_id)
);
CREATE INDEX IF NOT EXISTS idx_apps_indexed_source ON indexed_apps_records(source);
CREATE INDEX IF NOT EXISTS idx_apps_indexed_at ON indexed_apps_records(indexed_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_codes(expires_at);
"""
def get_conn() -> sqlite3.Connection:
conn = getattr(_local, "conn", None)
if conn is None:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
_local.conn = conn
return conn
def init_db() -> None:
get_conn().executescript(SCHEMA)
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""Client REST verso app homelab LOOGLE."""
@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
"""Client Loogle Casa — dashboard, meteo, rete."""
import os
from typing import Any, Optional
from .session_client import SessionApiClient
MCP_USERS = ("daniele", "lucia", "davide", "luca")
# Daniele MCP → admin su Loogle Casa
MCP_TO_SERVICE_USER = {
"daniele": "admin",
"lucia": "lucia",
"davide": "davide",
"luca": "luca",
}
def _service_username(mcp_username: str) -> str:
return MCP_TO_SERVICE_USER.get(mcp_username.lower(), mcp_username.lower())
PUBLIC_URL = os.environ.get("LOOGLE_CASA_URL", "https://casa.loogle.it").rstrip("/")
API_URL = os.environ.get("LOOGLE_CASA_API_URL", PUBLIC_URL).rstrip("/")
_client: Optional[SessionApiClient] = None
def _client_instance() -> SessionApiClient:
global _client
if _client is None:
verify = os.environ.get("LOOGLE_CASA_VERIFY_SSL", "true").strip().lower() not in (
"0", "false", "no", "off",
)
client = SessionApiClient(
service="LOOGLE_CASA",
base_url=API_URL,
verify_ssl=verify,
)
client.map_username = _service_username # type: ignore[attr-defined]
_client = client
return _client
def is_configured(username: Optional[str] = None) -> bool:
user = (username or "daniele").lower()
if user in MCP_USERS:
key = f"LOOGLE_CASA_PASSWORD_{user.upper()}"
if os.environ.get(key, "").strip():
return True
if os.environ.get("LOOGLE_CASA_PASSWORD", "").strip():
return True
return user in MCP_USERS
def get_dashboard(username: str) -> dict:
return _client_instance().get("/api/dashboard", username=username)
def get_weather_home(username: str) -> dict:
return _client_instance().get("/api/weather/home", username=username)
def get_network_overview(username: str) -> dict:
return _client_instance().get("/api/network/overview", username=username)
def get_network_failover_status(username: str) -> dict:
return _client_instance().get("/api/network/failover/status", username=username)
def get_network_mcp_status(username: str) -> dict:
return _client_instance().get("/api/network/mcp", username=username)
def get_alerts_cards(username: str) -> Any:
return _client_instance().get("/api/alerts/cards", username=username)
@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
"""Client Home Assistant REST API (read-only)."""
import os
from typing import Any, Optional
from urllib.parse import urljoin
import httpx
PUBLIC_URL = os.environ.get("HA_URL", "https://ha.loogle.it").rstrip("/")
API_URL = os.environ.get("HA_API_URL", PUBLIC_URL).rstrip("/")
HA_TOKEN = os.environ.get("HA_TOKEN", "").strip()
def is_configured() -> bool:
return bool(HA_TOKEN)
def _headers() -> dict:
if not HA_TOKEN:
raise RuntimeError(
"HA_TOKEN non configurato in .env — crea un long-lived token in Home Assistant"
)
return {"Authorization": f"Bearer {HA_TOKEN}", "Content-Type": "application/json"}
def _request(method: str, path: str, *, params: Optional[dict] = None) -> Any:
verify = os.environ.get("HA_VERIFY_SSL", "true").strip().lower() not in (
"0", "false", "no", "off",
)
url = path if path.startswith("http") else urljoin(API_URL + "/", path.lstrip("/"))
with httpx.Client(timeout=30.0, verify=verify) as client:
resp = client.request(method, url, headers=_headers(), params=params)
if resp.status_code >= 400:
raise RuntimeError(f"Home Assistant {path}: HTTP {resp.status_code} {resp.text[:200]}")
return resp.json()
def get_config() -> dict:
return _request("GET", "/api/config")
def get_entity(entity_id: str) -> dict:
return _request("GET", f"/api/states/{entity_id}")
def list_entities(domain: Optional[str] = None, limit: int = 100) -> list:
states = _request("GET", "/api/states")
if domain:
prefix = domain if domain.endswith(".") else f"{domain}."
states = [s for s in states if s.get("entity_id", "").startswith(prefix)]
return states[:limit]
def search_entities(query: str, limit: int = 30) -> list:
q = query.lower()
matches = []
for state in _request("GET", "/api/states"):
eid = state.get("entity_id", "")
name = (state.get("attributes") or {}).get("friendly_name", "")
blob = f"{eid} {name}".lower()
if q in blob:
matches.append({
"entity_id": eid,
"state": state.get("state"),
"friendly_name": name,
"last_changed": state.get("last_changed"),
})
if len(matches) >= limit:
break
return matches
@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
"""Client Irrigazione Smart — irri.loogle.it."""
import os
from typing import Any, Optional
from .session_client import SessionApiClient
MCP_USERS = ("daniele", "lucia", "davide", "luca")
MCP_TO_SERVICE_USER = {
"daniele": "admin",
"lucia": "lucia",
"davide": "dado",
"luca": "luca",
}
PUBLIC_URL = os.environ.get("IRRIGAZIONE_URL", "https://irri.loogle.it").rstrip("/")
API_URL = os.environ.get("IRRIGAZIONE_API_URL", PUBLIC_URL).rstrip("/")
_client: Optional[SessionApiClient] = None
def _service_username(mcp_username: str) -> str:
return MCP_TO_SERVICE_USER.get(mcp_username.lower(), mcp_username.lower())
def _client_instance() -> SessionApiClient:
global _client
if _client is None:
verify = os.environ.get("IRRIGAZIONE_VERIFY_SSL", "true").strip().lower() not in (
"0", "false", "no", "off",
)
client = SessionApiClient(
service="IRRIGAZIONE",
base_url=API_URL,
verify_ssl=verify,
)
client.map_username = _service_username # type: ignore[attr-defined]
_client = client
return _client
def is_configured(username: Optional[str] = None) -> bool:
user = (username or "daniele").lower()
if os.environ.get(f"IRRIGAZIONE_PASSWORD_{user.upper()}", "").strip():
return True
if os.environ.get("IRRIGAZIONE_PASSWORD", "").strip():
return True
return user in MCP_USERS
def get_status(username: str) -> dict:
return _client_instance().get("/api/status", username=username)
def get_zones(username: str) -> Any:
return _client_instance().get("/api/zones", username=username)
def get_history(username: str, limit: int = 30) -> Any:
data = _client_instance().get("/api/history", username=username)
if isinstance(data, list):
return data[:limit]
if isinstance(data, dict) and "items" in data:
items = data["items"]
return items[:limit] if isinstance(items, list) else data
return data
def get_events(username: str, limit: int = 50) -> Any:
data = _client_instance().get("/api/events", username=username)
if isinstance(data, list):
return data[:limit]
return data
def get_lavori_summary(username: str) -> Any:
return _client_instance().get("/api/lavori/summary", username=username)
@@ -0,0 +1,122 @@
# -*- coding: utf-8 -*-
"""Client HTTP con sessione cookie (Loogle Casa, Irrigazione)."""
import logging
import threading
import time
from typing import Any, Optional
from urllib.parse import urljoin
import httpx
LOGGER = logging.getLogger("loogle_mcp.session_client")
_sessions: dict[str, tuple[str, float]] = {}
_sessions_lock = threading.Lock()
SESSION_TTL = 3600 * 12
class SessionApiClient:
"""Login cookie-based con cache per utente MCP."""
def __init__(
self,
*,
service: str,
base_url: str,
login_path: str = "/api/login",
verify_ssl: bool = True,
) -> None:
self.service = service
self.base_url = base_url.rstrip("/")
self.login_path = login_path
self.verify_ssl = verify_ssl
def _password_for_user(self, username: str) -> Optional[str]:
import os
user = username.lower()
env_key = f"{self.service}_PASSWORD_{user.upper()}"
pwd = os.environ.get(env_key, "").strip()
if pwd:
return pwd
fallback = os.environ.get(f"{self.service}_PASSWORD", "").strip()
if fallback:
return fallback
return user
def _cache_key(self, username: str) -> str:
return f"{self.service}:{username.lower()}"
def _get_cached_cookie(self, username: str) -> Optional[str]:
key = self._cache_key(username)
with _sessions_lock:
row = _sessions.get(key)
if not row:
return None
cookie, expires = row
if time.time() > expires:
_sessions.pop(key, None)
return None
return cookie
def _store_cookie(self, username: str, cookie: str) -> None:
key = self._cache_key(username)
with _sessions_lock:
_sessions[key] = (cookie, time.time() + SESSION_TTL)
def login(self, username: str) -> str:
cached = self._get_cached_cookie(username)
if cached:
return cached
service_user = username
if hasattr(self, "map_username"):
service_user = self.map_username(username) # type: ignore[attr-defined]
password = self._password_for_user(username)
if not password:
raise RuntimeError(
f"Password {self.service} non configurata per {username}. "
f"Imposta {self.service}_PASSWORD_{username.upper()} in .env"
)
url = urljoin(self.base_url + "/", self.login_path.lstrip("/"))
with httpx.Client(timeout=30.0, verify=self.verify_ssl) as client:
resp = client.post(
url, json={"username": service_user, "password": password},
)
if resp.status_code != 200:
raise RuntimeError(
f"Login {self.service} fallito per {username}: HTTP {resp.status_code}"
)
cookie = resp.cookies.get("session")
if not cookie:
raise RuntimeError(f"Login {self.service}: cookie session mancante")
self._store_cookie(username, cookie)
return cookie
def request(
self,
method: str,
path: str,
*,
username: str,
params: Optional[dict] = None,
json_body: Optional[dict] = None,
) -> Any:
cookie = self.login(username)
url = path if path.startswith("http") else urljoin(self.base_url + "/", path.lstrip("/"))
headers = {"Cookie": f"session={cookie}"}
with httpx.Client(timeout=60.0, verify=self.verify_ssl) as client:
resp = client.request(method, url, headers=headers, params=params, json=json_body)
if resp.status_code == 401:
with _sessions_lock:
_sessions.pop(self._cache_key(username), None)
cookie = self.login(username)
headers = {"Cookie": f"session={cookie}"}
resp = client.request(method, url, headers=headers, params=params, json=json_body)
if resp.status_code >= 400:
raise RuntimeError(f"{self.service} {method} {path}: HTTP {resp.status_code} {resp.text[:200]}")
if resp.headers.get("content-type", "").startswith("application/json"):
return resp.json()
return resp.text
def get(self, path: str, *, username: str, params: Optional[dict] = None) -> Any:
return self.request("GET", path, username=username, params=params)
@@ -0,0 +1,208 @@
# -*- coding: utf-8 -*-
"""Client Turni-Live — turni.loogle.it (JWT Bearer)."""
import logging
import os
import threading
import time
from typing import Any, Optional
from urllib.parse import urljoin
import httpx
LOGGER = logging.getLogger("loogle_mcp.turni")
MCP_USERS = ("daniele", "lucia", "davide", "luca")
MCP_TO_SERVICE_USER = {
"daniele": "daniely",
"lucia": "lucia",
"davide": "davide",
"luca": "luca",
}
PUBLIC_URL = os.environ.get("TURNI_URL", "https://turni.loogle.it").rstrip("/")
API_URL = os.environ.get("TURNI_API_URL", PUBLIC_URL).rstrip("/")
_jwt_cache: dict[str, tuple[str, float]] = {}
_jwt_lock = threading.Lock()
JWT_TTL = 3600 * 6
def _service_username(mcp_username: str) -> str:
return MCP_TO_SERVICE_USER.get(mcp_username.lower(), mcp_username.lower())
def _password_for_user(mcp_username: str) -> Optional[str]:
user = mcp_username.lower()
pwd = os.environ.get(f"TURNI_PASSWORD_{user.upper()}", "").strip()
if pwd:
return pwd
return os.environ.get("TURNI_PASSWORD", "").strip() or None
def _jwt_for_user(mcp_username: str) -> Optional[str]:
user = mcp_username.lower()
direct = os.environ.get(f"TURNI_JWT_{user.upper()}", "").strip()
if direct:
return direct
return os.environ.get("TURNI_JWT", "").strip() or None
def is_configured(username: Optional[str] = None) -> bool:
user = (username or "daniele").lower()
if _jwt_for_user(user):
return True
if _password_for_user(user):
return True
return False
def _store_jwt(mcp_username: str, token: str) -> None:
with _jwt_lock:
_jwt_cache[mcp_username.lower()] = (token, time.time() + JWT_TTL)
def _cached_jwt(mcp_username: str) -> Optional[str]:
with _jwt_lock:
row = _jwt_cache.get(mcp_username.lower())
if not row:
return None
token, expires = row
if time.time() > expires:
_jwt_cache.pop(mcp_username.lower(), None)
return None
return token
def login(mcp_username: str) -> str:
cached = _cached_jwt(mcp_username)
if cached:
return cached
preset = _jwt_for_user(mcp_username)
if preset:
_store_jwt(mcp_username, preset)
return preset
password = _password_for_user(mcp_username)
if not password:
raise RuntimeError(
f"Turni non configurato per {mcp_username}. "
f"Imposta TURNI_PASSWORD_{mcp_username.upper()} o TURNI_JWT_{mcp_username.upper()}"
)
service_user = _service_username(mcp_username)
verify = os.environ.get("TURNI_VERIFY_SSL", "true").strip().lower() not in (
"0", "false", "no", "off",
)
url = urljoin(API_URL + "/", "api/auth/login")
with httpx.Client(timeout=30.0, verify=verify) as client:
resp = client.post(url, json={"username": service_user, "password": password})
if resp.status_code != 200:
raise RuntimeError(f"Login Turni fallito: HTTP {resp.status_code}")
data = resp.json()
token = data.get("token")
if not token:
raise RuntimeError("Login Turni: token JWT mancante")
_store_jwt(mcp_username, token)
return token
def _request(
method: str,
path: str,
*,
mcp_username: str,
params: Optional[dict] = None,
) -> Any:
token = login(mcp_username)
verify = os.environ.get("TURNI_VERIFY_SSL", "true").strip().lower() not in (
"0", "false", "no", "off",
)
url = path if path.startswith("http") else urljoin(API_URL + "/", path.lstrip("/"))
headers = {"Authorization": f"Bearer {token}"}
with httpx.Client(timeout=60.0, verify=verify) as client:
resp = client.request(method, url, headers=headers, params=params)
if resp.status_code == 401:
with _jwt_lock:
_jwt_cache.pop(mcp_username.lower(), None)
headers["Authorization"] = f"Bearer {login(mcp_username)}"
resp = client.request(method, url, headers=headers, params=params)
if resp.status_code >= 400:
raise RuntimeError(f"Turni {path}: HTTP {resp.status_code} {resp.text[:200]}")
return resp.json()
def get_status() -> dict:
verify = os.environ.get("TURNI_VERIFY_SSL", "true").strip().lower() not in (
"0", "false", "no", "off",
)
url = urljoin(API_URL + "/", "api/status")
with httpx.Client(timeout=30.0, verify=verify) as client:
resp = client.get(url)
resp.raise_for_status()
return resp.json()
def list_doctors(mcp_username: str) -> Any:
return _request("GET", "/api/doctors", mcp_username=mcp_username)
def get_shift_assignments(
mcp_username: str,
*,
from_date: Optional[str] = None,
to_date: Optional[str] = None,
limit: int = 100,
) -> Any:
params: dict = {}
if from_date:
params["from"] = from_date
if to_date:
params["to"] = to_date
data = _request("GET", "/api/shift-assignments", mcp_username=mcp_username, params=params or None)
if isinstance(data, list):
return data[:limit]
if isinstance(data, dict):
items = data.get("assignments") or data.get("items") or data.get("results")
if isinstance(items, list):
return items[:limit]
return data
def get_my_shifts(
mcp_username: str,
*,
from_date: Optional[str] = None,
to_date: Optional[str] = None,
limit: int = 50,
) -> dict:
"""Turni dell'utente MCP: filtra per doctorId collegato o per nome medico."""
user_info = _request("GET", "/api/users/me", mcp_username=mcp_username)
doctor_id = user_info.get("doctorId")
assignments = get_shift_assignments(
mcp_username, from_date=from_date, to_date=to_date, limit=500,
)
if not isinstance(assignments, list):
return {"user": user_info, "assignments": assignments}
if doctor_id:
mine = [a for a in assignments if a.get("doctorId") == doctor_id or a.get("doctor_id") == doctor_id]
else:
service_user = _service_username(mcp_username)
doctors = list_doctors(mcp_username)
doc_ids = set()
if isinstance(doctors, list):
for doc in doctors:
name = (doc.get("name") or doc.get("fullName") or "").lower()
if service_user.lower() in name or mcp_username.lower() in name:
doc_ids.add(doc.get("id") or doc.get("doctorId"))
mine = [
a for a in assignments
if (a.get("doctorId") or a.get("doctor_id")) in doc_ids
] if doc_ids else assignments[:limit]
return {
"user": {
"username": user_info.get("username"),
"role": user_info.get("role"),
"doctorId": doctor_id,
},
"assignments": mine[:limit],
"count": len(mine),
}
+136
View File
@@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
"""JWT utilities for OAuth access tokens."""
import datetime
import os
import secrets
import uuid
from typing import Optional
import jwt
from .db import get_conn
JWT_ALG = "HS256"
ACCESS_TOKEN_HOURS = 1
REFRESH_TOKEN_DAYS = 30
def jwt_secret() -> str:
secret = os.environ.get("MCP_JWT_SECRET", "").strip()
if not secret:
secret = secrets.token_urlsafe(48)
os.environ["MCP_JWT_SECRET"] = secret
return secret
def base_url() -> str:
return os.environ.get("MCP_BASE_URL", "https://mcp.loogle.it").rstrip("/")
def scopes_for_user(user: dict, requested: str) -> str:
parts = [s for s in requested.split() if s]
allowed = {
"context:read",
"context:write",
"knowledge:read",
"knowledge:write",
"gitea:read",
"gitea:write",
"home:read",
"irrigation:read",
"turni:read",
}
if user.get("is_admin"):
allowed.add("admin")
filtered = [s for s in parts if s in allowed]
if not filtered:
filtered = [
"context:read",
"context:write",
"knowledge:read",
"knowledge:write",
"gitea:read",
"gitea:write",
"home:read",
"irrigation:read",
"turni:read",
]
return " ".join(filtered)
def create_access_token(user: dict, scope: str, client_id: str) -> tuple[str, str]:
jti = str(uuid.uuid4())
now = datetime.datetime.utcnow()
payload = {
"iss": base_url(),
"sub": user["username"],
"uid": user["id"],
"scope": scope,
"client_id": client_id,
"jti": jti,
"iat": now,
"exp": now + datetime.timedelta(hours=ACCESS_TOKEN_HOURS),
}
token = jwt.encode(payload, jwt_secret(), algorithm=JWT_ALG)
return token, jti
def create_refresh_token(user_id: int, scope: str, client_id: str) -> str:
token = secrets.token_urlsafe(48)
expires = (
datetime.datetime.utcnow() + datetime.timedelta(days=REFRESH_TOKEN_DAYS)
).strftime("%Y-%m-%d %H:%M:%S")
get_conn().execute(
"INSERT INTO refresh_tokens(token,user_id,client_id,scope,expires_at) VALUES (?,?,?,?,?)",
(token, user_id, client_id, scope, expires),
)
get_conn().commit()
return token
def decode_access_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, jwt_secret(), algorithms=[JWT_ALG], issuer=base_url())
except jwt.PyJWTError:
return None
jti = payload.get("jti")
if not jti:
return None
row = get_conn().execute(
"SELECT 1 FROM revoked_jtis WHERE jti=? AND expires_at > datetime('now')",
(jti,),
).fetchone()
if row:
return None
return payload
def revoke_jti(jti: str, expires_at: datetime.datetime) -> None:
get_conn().execute(
"INSERT OR IGNORE INTO revoked_jtis(jti,expires_at) VALUES (?,?)",
(jti, expires_at.strftime("%Y-%m-%d %H:%M:%S")),
)
get_conn().commit()
def revoke_refresh_token(token: str) -> None:
get_conn().execute("UPDATE refresh_tokens SET revoked=1 WHERE token=?", (token,))
get_conn().commit()
def consume_refresh_token(token: str) -> Optional[dict]:
row = get_conn().execute(
"SELECT * FROM refresh_tokens WHERE token=? AND revoked=0 AND expires_at > datetime('now')",
(token,),
).fetchone()
if not row:
return None
return dict(row)
def has_scope(claims: dict, scope: str) -> bool:
scopes = set((claims.get("scope") or "").split())
if "admin" in scopes:
return True
return scope in scopes
Whitespace-only changes.
@@ -0,0 +1,295 @@
# -*- coding: utf-8 -*-
"""RAG su export Irrigazione + Turni (P7)."""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime, timezone
from typing import Any, Optional
from ..db import get_conn
from ..integrations import irrigazione, turni
from . import embeddings, qdrant_store
from .text_chunk import chunk_text as _chunk_text
LOGGER = logging.getLogger("loogle_mcp.apps_indexer")
INDEX_USER = os.environ.get("APPS_INDEX_USER", "daniele").lower()
def _int_env(name: str, default: int) -> int:
try:
return int(os.environ.get(name, str(default)))
except ValueError:
return default
def _enabled() -> bool:
flag = os.environ.get("APPS_INDEX_ENABLED", "yes").strip().lower()
return flag not in ("0", "false", "no", "off")
def _doc_id(source: str, record_id: str) -> int:
key = f"{source}:{record_id}"
return abs(hash(key)) % (2**31 - 1)
def _index_text(source: str, record_id: str, title: str, text: str) -> dict:
chunks = _chunk_text(_truncate(text))
if len(chunks) > 8:
chunks = chunks[:8]
if not chunks:
return {"source": source, "record_id": record_id, "chunks": 0, "skipped": True}
vectors = embeddings.embed_texts(chunks)
collection = qdrant_store.APPS_SHARED_COLLECTION
doc_id = _doc_id(source, record_id)
qdrant_store.delete_by_doc(collection, doc_id)
ids = []
payloads = []
for i, chunk in enumerate(chunks):
point_id = f"app-{source}-{record_id}-chunk-{i}"
ids.append(point_id)
payloads.append(
{
"doc_id": doc_id,
"source": source,
"record_id": record_id,
"chunk_index": i,
"title": title,
"text": chunk,
"owner": "family",
"visibility": "family",
}
)
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
get_conn().execute(
"INSERT INTO indexed_apps_records(source,record_id,title,owner,chunk_count,indexed_at)"
" VALUES (?,?,?,?,?,datetime('now'))"
" ON CONFLICT(source,record_id) DO UPDATE SET"
" title=excluded.title, chunk_count=excluded.chunk_count, indexed_at=datetime('now')",
(source, record_id, title, "family", len(chunks)),
)
get_conn().commit()
return {"source": source, "record_id": record_id, "chunks": len(chunks), "collection": collection}
def _format_irrigation_history_item(item: dict, idx: int) -> str:
parts = [f"Irrigazione storico #{idx}"]
for key in ("started_at", "ended_at", "zone", "zone_name", "duration_min", "volume_l", "mode", "note"):
if item.get(key) is not None:
parts.append(f"{key}: {item[key]}")
return "\n".join(parts)
def _format_irrigation_event(item: dict, idx: int) -> str:
parts = [f"Irrigazione evento #{idx}"]
for key in ("ts", "time", "type", "level", "message", "zone", "detail"):
if item.get(key) is not None:
parts.append(f"{key}: {item[key]}")
return "\n".join(parts)
def _format_turni_assignment(item: dict, idx: int) -> str:
parts = [f"Turno #{idx}"]
for key in (
"date", "startDate", "endDate", "doctorId", "doctorName", "doctor_name",
"slotId", "slotName", "slot_name", "uoc", "uocName", "shiftType", "notes",
):
if item.get(key) is not None:
parts.append(f"{key}: {item[key]}")
return "\n".join(parts)
def _truncate(text: str, limit: int = 6000) -> str:
if len(text) <= limit:
return text
return text[: limit - 20] + "\n… [truncated]"
def _summarize_irrigation_status(status: dict) -> str:
lines = ["Irrigazione — snapshot stato"]
for key in ("plan_mode", "program", "simulation", "hibernation", "draining"):
if key in status:
lines.append(f"{key}: {status[key]}")
ha = status.get("ha") or {}
lines.append(f"ha_connected: {ha.get('connected')}")
zones = status.get("zones") or []
lines.append(f"zone_count: {len(zones)}")
for z in zones[:12]:
if isinstance(z, dict):
lines.append(
f" - {z.get('name', z.get('id'))}: state={z.get('ha_state')} excluded={z.get('excluded')}"
)
analysis = status.get("analysis")
if isinstance(analysis, dict):
for k, v in list(analysis.items())[:8]:
lines.append(f"analysis.{k}: {v}")
elif analysis:
lines.append(f"analysis: {analysis}")
return "\n".join(lines)
def _summarize_zones(zones: Any) -> str:
items = zones if isinstance(zones, list) else _normalize_list(zones)
lines = [f"Irrigazione — zone ({len(items)})"]
for z in items[:20]:
if not isinstance(z, dict):
continue
lines.append(
f"- {z.get('name', z.get('id'))}: ha={z.get('ha_state')} "
f"rate_mmh={z.get('rate_mmh')} flow={z.get('zone_flow_lph')}"
)
return "\n".join(lines)
def _normalize_list(data: Any) -> list:
if isinstance(data, list):
return data
if isinstance(data, dict):
for key in ("items", "results", "history", "events", "assignments", "records"):
val = data.get(key)
if isinstance(val, list):
return val
return []
def index_irrigazione(*, username: Optional[str] = None, history_limit: Optional[int] = None, events_limit: Optional[int] = None) -> dict:
user = username or INDEX_USER
history_limit = history_limit if history_limit is not None else _int_env("APPS_INDEX_HISTORY_LIMIT", 25)
events_limit = events_limit if events_limit is not None else _int_env("APPS_INDEX_EVENTS_LIMIT", 40)
if not irrigazione.is_configured(user):
return {"source": "irrigazione", "skipped": True, "reason": "not configured"}
indexed = 0
errors = 0
try:
status = irrigazione.get_status(user)
status_text = _summarize_irrigation_status(status)
_index_text("irrigazione", "status-snapshot", "Irrigazione — stato attuale", status_text)
indexed += 1
except Exception as exc:
LOGGER.warning("Irrigazione status index failed: %s", exc)
errors += 1
try:
zones = irrigazione.get_zones(user)
zones_text = _summarize_zones(zones)
_index_text("irrigazione", "zones-snapshot", "Irrigazione — zone", zones_text)
indexed += 1
except Exception as exc:
LOGGER.warning("Irrigazione zones index failed: %s", exc)
errors += 1
try:
history = irrigazione.get_history(user, limit=history_limit)
for i, item in enumerate(_normalize_list(history)):
if not isinstance(item, dict):
continue
rid = str(item.get("id") or item.get("started_at") or i)
text = _format_irrigation_history_item(item, i)
_index_text("irrigazione", f"history-{rid}", f"Irrigazione storico {rid}", text)
indexed += 1
except Exception as exc:
LOGGER.warning("Irrigazione history index failed: %s", exc)
errors += 1
try:
events = irrigazione.get_events(user, limit=events_limit)
for i, item in enumerate(_normalize_list(events)):
if not isinstance(item, dict):
continue
rid = str(item.get("id") or item.get("ts") or item.get("time") or i)
text = _format_irrigation_event(item, i)
_index_text("irrigazione", f"event-{rid}", f"Irrigazione evento {rid}", text)
indexed += 1
except Exception as exc:
LOGGER.warning("Irrigazione events index failed: %s", exc)
errors += 1
try:
lavori = irrigazione.get_lavori_summary(user)
lavori_text = _truncate(json.dumps(lavori, ensure_ascii=False, indent=2), 4000)
_index_text("irrigazione", "lavori-summary", "Irrigazione — lavori manutenzione", lavori_text)
indexed += 1
except Exception as exc:
LOGGER.warning("Irrigazione lavori index failed: %s", exc)
errors += 1
return {"source": "irrigazione", "indexed": indexed, "errors": errors}
def index_turni(*, username: Optional[str] = None, assignments_limit: Optional[int] = None) -> dict:
user = username or INDEX_USER
assignments_limit = assignments_limit if assignments_limit is not None else _int_env("APPS_INDEX_ASSIGNMENTS_LIMIT", 80)
if not turni.is_configured(user):
return {"source": "turni", "skipped": True, "reason": "not configured"}
indexed = 0
errors = 0
try:
status = turni.get_status()
status_text = _truncate(json.dumps(status, ensure_ascii=False, indent=2), 2000)
_index_text("turni", "status-snapshot", "Turni — stato servizio", status_text)
indexed += 1
except Exception as exc:
LOGGER.warning("Turni status index failed: %s", exc)
errors += 1
try:
doctors = turni.list_doctors(user)
lines = ["Turni — medici"]
for d in (_normalize_list(doctors) if not isinstance(doctors, list) else doctors)[:40]:
if isinstance(d, dict):
lines.append(f"- {d.get('name', d.get('fullName'))} id={d.get('id')}")
_index_text("turni", "doctors-list", "Turni — elenco medici", "\n".join(lines))
indexed += 1
except Exception as exc:
LOGGER.warning("Turni doctors index failed: %s", exc)
errors += 1
try:
assignments = turni.get_shift_assignments(user, limit=assignments_limit)
for i, item in enumerate(_normalize_list(assignments)):
if not isinstance(item, dict):
continue
rid = str(item.get("id") or item.get("date") or i)
text = _format_turni_assignment(item, i)
_index_text("turni", f"assignment-{rid}", f"Turno {rid}", text)
indexed += 1
except Exception as exc:
LOGGER.warning("Turni assignments index failed: %s", exc)
errors += 1
return {"source": "turni", "indexed": indexed, "errors": errors}
def index_all(*, username: Optional[str] = None) -> dict:
if not _enabled():
return {"skipped": True, "reason": "APPS_INDEX_ENABLED=no"}
user = username or INDEX_USER
return {
"irrigazione": index_irrigazione(username=user),
"turni": index_turni(username=user),
"at": datetime.now(timezone.utc).isoformat(),
}
def search_apps_knowledge(query: str, limit: int = 8, source: Optional[str] = None) -> list:
vectors = embeddings.embed_texts([query])
flt = {"source": source} if source else None
hits = qdrant_store.search(
[qdrant_store.APPS_SHARED_COLLECTION],
vectors[0],
limit=limit,
visibility_filter=flt,
)
for hit in hits:
hit.setdefault("source_type", hit.get("source", "apps"))
return hits
def list_indexed_records(source: Optional[str] = None, limit: int = 40) -> list:
conn = get_conn()
if source:
rows = conn.execute(
"SELECT * FROM indexed_apps_records WHERE source=? ORDER BY indexed_at DESC LIMIT ?",
(source, limit),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM indexed_apps_records ORDER BY indexed_at DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows]
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
"""Embedding providers — con thermal gate e keep_alive adattivo."""
import logging
import os
import time
from typing import Optional
import httpx
from . import thermal
LOGGER = logging.getLogger("loogle_mcp.embeddings")
def embed_texts(texts: list[str]) -> list[list[float]]:
if not texts:
return []
ollama_url = os.environ.get("OLLAMA_URL", "").strip()
if ollama_url:
try:
return _embed_ollama(texts, ollama_url)
except Exception as exc:
LOGGER.warning("Ollama embedding failed: %s", exc)
openai_key = os.environ.get("OPENAI_API_KEY", "").strip()
if openai_key:
return _embed_openai(texts, openai_key)
raise RuntimeError("Nessun provider embedding configurato (OLLAMA_URL o OPENAI_API_KEY)")
def _embed_ollama(texts: list[str], base_url: str) -> list[list[float]]:
model = os.environ.get("OLLAMA_EMBED_MODEL", "nomic-embed-text")
vectors = []
timeout = httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)
with httpx.Client(timeout=timeout) as client:
for i, text in enumerate(texts):
status = thermal.wait_for_headroom(context=f"embed:{i+1}/{len(texts)}")
keep_alive = thermal.suggested_keep_alive(status)
delay = thermal.suggested_delay_s(status)
payload = {"model": model, "prompt": text, "keep_alive": keep_alive}
# options.num_thread limita i thread CPU lato Ollama (se supportato)
num_thread = os.environ.get("OLLAMA_NUM_THREAD", "").strip()
if num_thread:
try:
payload["options"] = {"num_thread": int(num_thread)}
except ValueError:
pass
resp = client.post(f"{base_url.rstrip('/')}/api/embeddings", json=payload)
if resp.status_code >= 400:
LOGGER.warning(
"Ollama embeddings HTTP %s: %s — payload keys=%s",
resp.status_code,
resp.text[:300],
list(payload.keys()),
)
resp.raise_for_status()
vectors.append(resp.json()["embedding"])
if delay > 0 and i + 1 < len(texts):
time.sleep(delay)
# Unload solo se esplicitamente richiesto (zona HARD) — evita spike da reload
if keep_alive == 0:
try:
client.post(
f"{base_url.rstrip('/')}/api/generate",
json={"model": model, "keep_alive": 0},
timeout=30.0,
)
except Exception:
pass
return vectors
def _embed_openai(texts: list[str], api_key: str) -> list[list[float]]:
model = os.environ.get("OPENAI_EMBED_MODEL", "text-embedding-3-small")
with httpx.Client(timeout=120.0) as client:
resp = client.post(
"https://api.openai.com/v1/embeddings",
headers={"Authorization": f"Bearer {api_key}"},
json={"model": model, "input": texts},
)
resp.raise_for_status()
data = resp.json()["data"]
return [item["embedding"] for item in sorted(data, key=lambda x: x["index"])]
+495
View File
@@ -0,0 +1,495 @@
# -*- coding: utf-8 -*-
"""Gitea REST API client — token per utente MCP."""
import base64
import json
import logging
import os
import re
from typing import Any, Optional
from urllib.parse import quote
import httpx
LOGGER = logging.getLogger("loogle_mcp.gitea")
GITEA_URL = os.environ.get("GITEA_URL", "https://git.loogle.it").rstrip("/")
MCP_USERS = ("daniele", "lucia", "davide", "luca")
GITEA_API_TOKEN_SCOPES = "read:repository,write:repository,write:issue,write:user,read:user"
TEXT_EXTENSIONS = {
".md", ".txt", ".py", ".sh", ".yml", ".yaml", ".json", ".toml", ".ini",
".conf", ".js", ".ts", ".tsx", ".jsx", ".html", ".css", ".sql", ".go",
".rs", ".env", ".service", ".timer", ".xml", ".csv",
}
_tokens_cache: Optional[dict[str, str]] = None
def _load_tokens() -> dict[str, str]:
global _tokens_cache
if _tokens_cache is not None:
return _tokens_cache
tokens: dict[str, str] = {}
json_map = os.environ.get("GITEA_API_TOKENS", "").strip()
if json_map:
try:
parsed = json.loads(json_map)
if isinstance(parsed, dict):
tokens.update({k.lower(): v for k, v in parsed.items() if v})
except json.JSONDecodeError:
LOGGER.warning("GITEA_API_TOKENS non è JSON valido")
fallback = os.environ.get("GITEA_API_TOKEN", "").strip()
for user in MCP_USERS:
env_key = f"GITEA_API_TOKEN_{user.upper()}"
token = os.environ.get(env_key, "").strip()
if token:
tokens[user] = token
elif user not in tokens and fallback:
tokens[user] = fallback
if not tokens and fallback:
tokens["daniele"] = fallback
_tokens_cache = tokens
return tokens
def list_configured_users() -> list[str]:
return list(_load_tokens().keys())
def is_configured(username: Optional[str] = None) -> bool:
tokens = _load_tokens()
if not tokens:
return False
if username:
user = username.lower()
return user in tokens or "daniele" in tokens or bool(tokens)
return True
def _headers(username: Optional[str] = None) -> dict[str, str]:
tokens = _load_tokens()
if not tokens:
raise RuntimeError(
"Nessun token Gitea configurato. "
"Imposta GITEA_API_TOKEN o GITEA_API_TOKEN_{USER} in .env — vedi docs/GITEA-TOKEN.md"
)
user = (username or "daniele").lower()
token = tokens.get(user) or tokens.get("daniele") or next(iter(tokens.values()))
return {"Authorization": f"token {token}"}
def parse_repo(repo: str) -> tuple[str, str]:
cleaned = repo.strip().strip("/")
if cleaned.count("/") != 1:
raise ValueError("repo deve essere nel formato owner/name (es. daniele/rete)")
owner, name = cleaned.split("/", 1)
if not owner or not name:
raise ValueError("repo deve essere nel formato owner/name (es. daniele/rete)")
return owner, name
def api_base_url() -> str:
return os.environ.get("GITEA_API_URL", GITEA_URL).rstrip("/")
def public_base_url() -> str:
return os.environ.get("GITEA_URL", "https://git.loogle.it").rstrip("/")
def _api_url(path: str) -> str:
return f"{api_base_url()}/api/v1{path}"
def _request(
method: str,
path: str,
*,
username: Optional[str] = None,
params: Optional[dict] = None,
json_body: Optional[dict] = None,
) -> Any:
with httpx.Client(timeout=60.0, verify=True) as client:
resp = client.request(
method,
_api_url(path),
headers=_headers(username),
params=params,
json=json_body,
)
if resp.status_code == 404:
raise FileNotFoundError(resp.text or "Risorsa Gitea non trovata")
resp.raise_for_status()
if resp.content:
return resp.json()
return {}
def list_repos(
username: Optional[str] = None,
page: int = 1,
limit: int = 50,
) -> dict:
data = _request(
"GET",
"/user/repos",
username=username,
params={"page": page, "limit": limit, "sort": "updated"},
)
repos = []
for repo in data if isinstance(data, list) else []:
full_name = repo.get("full_name") or ""
if not full_name and repo.get("owner"):
full_name = f"{repo['owner'].get('login', '')}/{repo.get('name', '')}"
repos.append(
{
"full_name": full_name,
"description": repo.get("description") or "",
"private": bool(repo.get("private")),
"html_url": repo.get("html_url") or f"{public_base_url()}/{full_name}",
"default_branch": repo.get("default_branch") or "main",
"updated_at": repo.get("updated_at"),
}
)
return {"repos": repos, "page": page, "count": len(repos)}
def _decode_content(entry: dict) -> str:
encoding = (entry.get("encoding") or "").lower()
raw = entry.get("content") or ""
if encoding == "base64":
return base64.b64decode(raw).decode("utf-8", errors="replace")
return raw
def get_file(
repo: str,
path: str,
ref: Optional[str] = None,
username: Optional[str] = None,
) -> dict:
owner, name = parse_repo(repo)
file_path = path.lstrip("/")
params = {}
if ref:
params["ref"] = ref
encoded_path = "/".join(quote(part, safe="") for part in file_path.split("/"))
data = _request(
"GET",
f"/repos/{owner}/{name}/contents/{encoded_path}",
username=username,
params=params or None,
)
if isinstance(data, list):
entries = [
{
"name": item.get("name"),
"path": item.get("path"),
"type": item.get("type"),
"size": item.get("size"),
}
for item in data
]
return {
"repo": f"{owner}/{name}",
"path": file_path or "/",
"type": "dir",
"entries": entries,
}
content = _decode_content(data)
return {
"repo": f"{owner}/{name}",
"path": data.get("path") or file_path,
"type": data.get("type") or "file",
"size": data.get("size"),
"sha": data.get("sha"),
"html_url": data.get("html_url") or f"{public_base_url()}/{owner}/{name}/src/branch/{ref or 'main'}/{file_path}",
"content": content,
}
def search_code(
query: str,
repo: Optional[str] = None,
limit: int = 20,
username: Optional[str] = None,
) -> dict:
q = query.strip()
if not q:
raise ValueError("query obbligatoria")
params: dict[str, Any] = {"q": q, "limit": min(max(limit, 1), 50)}
if repo:
owner, name = parse_repo(repo)
params["repo"] = f"{owner}/{name}"
try:
data = _request("GET", "/search/code", username=username, params=params)
hits = []
for item in data.get("data") or []:
repo_name = item.get("repository", {}).get("full_name") or item.get("repository", {}).get("name")
hits.append(
{
"repo": repo_name,
"path": item.get("path"),
"sha": item.get("sha"),
"html_url": item.get("url") or item.get("html_url"),
"language": item.get("language"),
"snippet": (item.get("content") or item.get("text") or "")[:500],
}
)
return {"query": q, "repo": repo, "results": hits, "count": len(hits)}
except FileNotFoundError:
return _search_code_fallback(q, repo, limit, username)
except httpx.HTTPStatusError as exc:
if exc.response.status_code not in (404, 422):
raise
return _search_code_fallback(q, repo, limit, username)
def _search_code_fallback(
query: str,
repo: Optional[str],
limit: int,
username: Optional[str],
) -> dict:
"""Fallback se /search/code non disponibile: tree + grep su file testo."""
repos: list[str] = []
if repo:
owner, name = parse_repo(repo)
repos.append(f"{owner}/{name}")
else:
listed = list_repos(username=username, limit=20)
repos = [r["full_name"] for r in listed["repos"] if r.get("full_name")]
terms = [t.lower() for t in re.split(r"\s+", query) if t]
hits: list[dict] = []
max_files = min(limit * 3, 40)
for full_name in repos:
owner, name = parse_repo(full_name)
try:
tree = _request(
"GET",
f"/repos/{owner}/{name}/git/trees/HEAD",
username=username,
params={"recursive": "1"},
)
except Exception:
continue
scanned = 0
for node in tree.get("tree") or []:
if node.get("type") != "blob":
continue
path = node.get("path") or ""
ext = os.path.splitext(path)[1].lower()
if ext and ext not in TEXT_EXTENSIONS:
continue
if any(term in path.lower() for term in terms):
pass
scanned += 1
if scanned > max_files:
break
try:
file_data = get_file(full_name, path, username=username)
except Exception:
continue
content = (file_data.get("content") or "").lower()
if not any(term in content or term in path.lower() for term in terms):
continue
snippet = file_data.get("content") or ""
idx = snippet.lower().find(terms[0]) if terms else 0
if idx < 0:
idx = 0
hits.append(
{
"repo": full_name,
"path": path,
"sha": node.get("sha"),
"html_url": file_data.get("html_url"),
"snippet": snippet[max(0, idx - 80): idx + 420],
}
)
if len(hits) >= limit:
break
if len(hits) >= limit:
break
return {"query": query, "repo": repo, "results": hits[:limit], "count": len(hits[:limit]), "mode": "fallback"}
def list_issues(
repo: str,
state: str = "open",
page: int = 1,
limit: int = 20,
username: Optional[str] = None,
) -> dict:
owner, name = parse_repo(repo)
data = _request(
"GET",
f"/repos/{owner}/{name}/issues",
username=username,
params={"state": state, "page": page, "limit": limit, "type": "issues"},
)
issues = []
for item in data if isinstance(data, list) else []:
issues.append(
{
"number": item.get("number"),
"title": item.get("title"),
"state": item.get("state"),
"user": (item.get("user") or {}).get("login"),
"html_url": item.get("html_url"),
"created_at": item.get("created_at"),
"updated_at": item.get("updated_at"),
"labels": [lbl.get("name") for lbl in (item.get("labels") or [])],
}
)
return {"repo": f"{owner}/{name}", "state": state, "issues": issues, "count": len(issues)}
def get_issue(
repo: str,
number: int,
username: Optional[str] = None,
) -> dict:
owner, name = parse_repo(repo)
item = _request("GET", f"/repos/{owner}/{name}/issues/{number}", username=username)
return {
"repo": f"{owner}/{name}",
"number": item.get("number"),
"title": item.get("title"),
"state": item.get("state"),
"body": item.get("body") or "",
"user": (item.get("user") or {}).get("login"),
"html_url": item.get("html_url"),
"created_at": item.get("created_at"),
"updated_at": item.get("updated_at"),
"labels": [lbl.get("name") for lbl in (item.get("labels") or [])],
}
def create_issue(
repo: str,
title: str,
body: str = "",
labels: Optional[list[str]] = None,
username: Optional[str] = None,
) -> dict:
owner, name = parse_repo(repo)
payload: dict[str, Any] = {"title": title.strip(), "body": body or ""}
if labels:
payload["labels"] = labels
item = _request(
"POST",
f"/repos/{owner}/{name}/issues",
username=username,
json_body=payload,
)
return {
"repo": f"{owner}/{name}",
"number": item.get("number"),
"title": item.get("title"),
"state": item.get("state"),
"html_url": item.get("html_url"),
}
def assert_repo_owner(username: str, repo: str, *, is_admin: bool = False) -> tuple[str, str]:
owner, name = parse_repo(repo)
if not is_admin and owner.lower() != username.lower():
raise PermissionError(
f"Puoi scrivere solo su repository di cui sei owner (repo {owner}/{name}, utente {username})"
)
return owner, name
def create_repo(
name: str,
username: Optional[str] = None,
*,
private: bool = True,
description: str = "",
auto_init: bool = True,
) -> dict:
repo_name = name.strip().lower()
if not repo_name or not re.match(r"^[a-z0-9][a-z0-9._-]{0,99}$", repo_name):
raise ValueError("name repo non valido (usa lettere minuscole, numeri, -, _, .)")
payload: dict[str, Any] = {
"name": repo_name,
"private": private,
"auto_init": auto_init,
"description": description.strip(),
}
item = _request("POST", "/user/repos", username=username, json_body=payload)
full_name = item.get("full_name") or f"{username}/{repo_name}"
return {
"full_name": full_name,
"private": bool(item.get("private", private)),
"html_url": item.get("html_url") or f"{public_base_url()}/{full_name}",
"default_branch": item.get("default_branch") or "main",
"description": item.get("description") or description,
}
def create_or_update_file(
repo: str,
path: str,
content: str,
message: str,
*,
branch: Optional[str] = None,
username: Optional[str] = None,
) -> dict:
owner, repo_name = parse_repo(repo)
file_path = path.lstrip("/")
if not file_path:
raise ValueError("path obbligatorio")
if not message.strip():
raise ValueError("message commit obbligatorio")
encoded_path = "/".join(quote(part, safe="") for part in file_path.split("/"))
params = {}
if branch:
params["ref"] = branch
sha = None
action = "create"
try:
existing = get_file(repo, file_path, ref=branch, username=username)
if existing.get("type") == "file":
sha = existing.get("sha")
action = "update"
except FileNotFoundError:
pass
body: dict[str, Any] = {
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
"message": message.strip(),
}
if sha:
body["sha"] = sha
if branch:
body["branch"] = branch
method = "PUT" if sha else "POST"
item = _request(
method,
f"/repos/{owner}/{repo_name}/contents/{encoded_path}",
username=username,
params=params or None,
json_body=body,
)
commit = item.get("commit") or {}
content_obj = item.get("content") or {}
return {
"repo": f"{owner}/{repo_name}",
"path": file_path,
"action": action,
"branch": branch or "default",
"sha": content_obj.get("sha"),
"commit_sha": commit.get("sha"),
"html_url": content_obj.get("html_url")
or f"{public_base_url()}/{owner}/{repo_name}/src/branch/{branch or 'main'}/{file_path}",
}
@@ -0,0 +1,369 @@
# -*- coding: utf-8 -*-
"""Indicizzazione semantica (RAG) su file testo dei repository Gitea."""
from __future__ import annotations
import logging
import os
import re
import zlib
from typing import Optional
from ..db import get_conn
from . import embeddings, gitea, qdrant_store
from .text_chunk import CHUNK_OVERLAP, CHUNK_SIZE, chunk_text as _chunk_text
LOGGER = logging.getLogger("loogle_mcp.gitea_indexer")
SKIP_PATH_PARTS = (
"node_modules/",
"vendor/",
".git/",
"dist/",
"build/",
"__pycache__/",
".venv/",
"venv/",
".tox/",
"coverage/",
)
PRIORITY_PREFIXES = ("docs/", "ha/", "doc/", "README")
def _enabled() -> bool:
flag = os.environ.get("GITEA_INDEX_ENABLED", "yes").strip().lower()
return flag not in ("0", "false", "no", "off")
def _max_files_per_repo() -> int:
return int(os.environ.get("GITEA_INDEX_MAX_FILES_PER_REPO", "150"))
def _max_file_bytes() -> int:
return int(os.environ.get("GITEA_INDEX_MAX_FILE_BYTES", "120000"))
def _repos_for_user(username: str) -> list[str]:
env_key = f"GITEA_INDEX_REPOS_{username.upper()}"
raw = os.environ.get(env_key, "").strip()
if raw:
repos: list[str] = []
for item in raw.split(","):
item = item.strip()
if not item:
continue
owner, name = gitea.parse_repo(item)
repos.append(f"{owner}/{name}")
return repos
repos: list[str] = []
page = 1
while page <= 5:
data = gitea.list_repos(username=username, page=page, limit=50)
batch = [r["full_name"] for r in data.get("repos") or [] if r.get("full_name")]
if not batch:
break
repos.extend(batch)
if len(batch) < 50:
break
page += 1
return repos
def _should_index_path(path: str) -> bool:
lowered = path.lower()
if any(part in lowered for part in SKIP_PATH_PARTS):
return False
ext = os.path.splitext(path)[1].lower()
return bool(ext and ext in gitea.TEXT_EXTENSIONS)
def _path_priority(path: str) -> tuple[int, str]:
lowered = path.lower()
for idx, prefix in enumerate(PRIORITY_PREFIXES):
if lowered.startswith(prefix.lower()) or os.path.basename(lowered).startswith(prefix.lower()):
return (idx, path)
return (len(PRIORITY_PREFIXES), path)
def list_repo_text_files(repo: str, username: str) -> list[dict]:
owner, name = gitea.parse_repo(repo)
tree = gitea._request(
"GET",
f"/repos/{owner}/{name}/git/trees/HEAD",
username=username,
params={"recursive": "1"},
)
max_files = _max_files_per_repo()
max_bytes = _max_file_bytes()
files: list[dict] = []
for node in tree.get("tree") or []:
if node.get("type") != "blob":
continue
path = node.get("path") or ""
if not _should_index_path(path):
continue
size = int(node.get("size") or 0)
if size > max_bytes:
continue
files.append({"path": path, "sha": node.get("sha"), "size": size})
files.sort(key=lambda item: _path_priority(item["path"]))
return files[:max_files]
def _repo_owner(repo: str) -> str:
owner, _ = gitea.parse_repo(repo)
return owner.lower()
def _repo_visibility(private: bool, owner: str) -> str:
if private:
return "personal"
if owner in gitea.MCP_USERS:
return "family"
return "family"
def _collection_for_repo(private: bool, owner: str) -> str:
visibility = _repo_visibility(private, owner)
if visibility == "personal":
return qdrant_store.gitea_collection(owner)
return qdrant_store.GITEA_SHARED_COLLECTION
def _file_doc_id(repo: str, path: str) -> int:
return zlib.adler32(f"{repo}:{path}".encode("utf-8")) & 0x7FFFFFFF
def _file_title(repo: str, path: str) -> str:
return f"{repo}/{path}"
def index_file(
repo: str,
path: str,
*,
username: str,
private: bool,
force: bool = False,
) -> dict:
owner = _repo_owner(repo)
conn = get_conn()
row = conn.execute(
"SELECT sha, chunk_count FROM indexed_gitea_files WHERE repo=? AND path=?",
(repo, path),
).fetchone()
file_data = gitea.get_file(repo, path, username=username)
sha = file_data.get("sha") or ""
if row and row["sha"] == sha and not force:
return {"repo": repo, "path": path, "skipped": True, "sha": sha}
text = (file_data.get("content") or "").strip()
if not text:
return {"repo": repo, "path": path, "chunks": 0, "sha": sha}
header = f"# {_file_title(repo, path)}\n\nSource: gitea:{repo}:{path}\n\n"
chunks = _chunk_text(header + text)
if not chunks:
return {"repo": repo, "path": path, "chunks": 0, "sha": sha}
visibility = _repo_visibility(private, owner)
collection = _collection_for_repo(private, owner)
doc_id = _file_doc_id(repo, path)
qdrant_store.delete_by_doc(collection, doc_id)
vectors = embeddings.embed_texts(chunks)
ids = []
payloads = []
for i, chunk in enumerate(chunks):
point_id = f"gitea-{repo}-{path}-chunk-{i}"
ids.append(point_id)
payloads.append(
{
"source": "gitea",
"doc_id": doc_id,
"repo": repo,
"path": path,
"chunk_index": i,
"title": _file_title(repo, path),
"text": chunk,
"owner": owner,
"visibility": visibility,
"sha": sha,
}
)
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
stored = qdrant_store.count_by_doc(collection, doc_id)
if stored < len(chunks):
raise RuntimeError(
f"Qdrant upsert incompleto per {repo}/{path}: attesi {len(chunks)} chunk, trovati {stored}"
)
conn.execute(
"INSERT INTO indexed_gitea_files(repo,path,sha,owner,visibility,chunk_count,indexed_at)"
" VALUES (?,?,?,?,?,?,datetime('now'))"
" ON CONFLICT(repo,path) DO UPDATE SET"
" sha=excluded.sha, owner=excluded.owner, visibility=excluded.visibility,"
" chunk_count=excluded.chunk_count, indexed_at=datetime('now')",
(repo, path, sha, owner, visibility, len(chunks)),
)
conn.commit()
return {
"repo": repo,
"path": path,
"sha": sha,
"chunks": len(chunks),
"collection": collection,
"visibility": visibility,
}
def index_repo(
repo: str,
*,
username: Optional[str] = None,
private: Optional[bool] = None,
force: bool = False,
max_files: Optional[int] = None,
) -> dict:
gitea_user = username or _repo_owner(repo)
if not gitea.is_configured(gitea_user):
raise RuntimeError(f"Gitea non configurato per {gitea_user}")
if private is None:
owner, name = gitea.parse_repo(repo)
meta = gitea._request("GET", f"/repos/{owner}/{name}", username=gitea_user)
private = bool(meta.get("private"))
files = list_repo_text_files(repo, gitea_user)
if max_files is not None:
files = files[: max(1, max_files)]
indexed = 0
skipped = 0
errors = 0
chunks = 0
for item in files:
try:
result = index_file(
repo,
item["path"],
username=gitea_user,
private=bool(private),
force=force,
)
if result.get("skipped"):
skipped += 1
else:
indexed += 1
chunks += int(result.get("chunks") or 0)
except Exception as exc:
LOGGER.warning("Index gitea %s/%s failed: %s", repo, item.get("path"), exc)
errors += 1
return {
"repo": repo,
"files_seen": len(files),
"files_indexed": indexed,
"files_skipped": skipped,
"errors": errors,
"chunks": chunks,
"private": bool(private),
}
def index_all(max_files_per_repo: Optional[int] = None) -> dict:
if not _enabled():
return {"enabled": False, "indexed_files": 0}
total_indexed = 0
total_skipped = 0
total_errors = 0
total_chunks = 0
repos_done: list[str] = []
users = gitea.list_configured_users() or []
for username in users:
try:
repos = _repos_for_user(username)
except Exception as exc:
LOGGER.error("Lista repo Gitea fallita per %s: %s", username, exc)
continue
for repo in repos:
try:
result = index_repo(
repo,
username=username,
force=False,
max_files=max_files_per_repo,
)
repos_done.append(repo)
total_indexed += int(result.get("files_indexed") or 0)
total_skipped += int(result.get("files_skipped") or 0)
total_errors += int(result.get("errors") or 0)
total_chunks += int(result.get("chunks") or 0)
except Exception as exc:
LOGGER.warning("Index repo %s failed (%s): %s", repo, username, exc)
total_errors += 1
return {
"enabled": True,
"repos": repos_done,
"files_indexed": total_indexed,
"files_skipped": total_skipped,
"errors": total_errors,
"chunks": total_chunks,
"users": users,
}
def gitea_collections_for_user(username: str, is_admin: bool = False) -> list[str]:
cols = [qdrant_store.GITEA_SHARED_COLLECTION, qdrant_store.gitea_collection(username)]
if is_admin:
for user in gitea.MCP_USERS:
cols.append(qdrant_store.gitea_collection(user))
return list(dict.fromkeys(cols))
def search_gitea_knowledge(username: str, query: str, limit: int = 8, is_admin: bool = False) -> list:
vectors = embeddings.embed_texts([query])
collections = gitea_collections_for_user(username, is_admin=is_admin)
hits = qdrant_store.search(collections, vectors[0], limit=limit)
for hit in hits:
hit["source"] = hit.get("source") or "gitea"
return hits
def list_indexed_files(limit: int = 30, repo: Optional[str] = None) -> list:
conn = get_conn()
if repo:
rows = conn.execute(
"SELECT * FROM indexed_gitea_files WHERE repo=? ORDER BY indexed_at DESC LIMIT ?",
(repo, limit),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM indexed_gitea_files ORDER BY indexed_at DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows]
def index_stats() -> dict:
conn = get_conn()
row = conn.execute(
"SELECT COUNT(*), COALESCE(SUM(chunk_count), 0) FROM indexed_gitea_files"
).fetchone()
files_count = int(row[0] if row else 0)
chunks_meta = int(row[1] if row else 0)
collections = {qdrant_store.GITEA_SHARED_COLLECTION}
for user in gitea.MCP_USERS:
collections.add(qdrant_store.gitea_collection(user))
qdrant_points = sum(qdrant_store.collection_point_count(c) for c in collections)
return {
"files_indexed": files_count,
"chunks_in_metadata": chunks_meta,
"qdrant_points": qdrant_points,
"collections": {c: qdrant_store.collection_point_count(c) for c in sorted(collections)},
}
@@ -0,0 +1,167 @@
# -*- coding: utf-8 -*-
"""Paperless → Qdrant indexing pipeline."""
import logging
from typing import Optional
LOGGER = logging.getLogger("loogle_mcp.indexer")
from ..db import get_conn
from . import embeddings, gitea_indexer, paperless, qdrant_store
from .text_chunk import chunk_text as _chunk_text
def index_document(doc_id: int, force: bool = False, paperless_user: Optional[str] = None) -> dict:
conn = get_conn()
existing = conn.execute(
"SELECT doc_id FROM indexed_documents WHERE doc_id=?", (doc_id,)
).fetchone()
if existing and not force:
return {"doc_id": doc_id, "skipped": True}
doc = paperless.get_document(doc_id, username=paperless_user)
text = paperless.download_document_text(doc_id, username=paperless_user)
owner = paperless.document_owner(doc)
visibility = paperless.document_visibility(doc, owner)
chunks = _chunk_text(text)
if not chunks:
return {"doc_id": doc_id, "chunks": 0}
vectors = embeddings.embed_texts(chunks)
collection = qdrant_store.SHARED_COLLECTION if visibility == "family" else qdrant_store.kb_collection(owner)
qdrant_store.delete_by_doc(collection, doc_id)
ids = []
payloads = []
for i, chunk in enumerate(chunks):
point_id = f"doc-{doc_id}-chunk-{i}"
ids.append(point_id)
payloads.append(
{
"doc_id": doc_id,
"chunk_index": i,
"title": doc.get("title") or f"Documento {doc_id}",
"text": chunk,
"owner": owner,
"visibility": visibility,
}
)
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
conn.execute(
"INSERT INTO indexed_documents(doc_id,title,owner,visibility,chunk_count,indexed_at)"
" VALUES (?,?,?,?,?,datetime('now'))"
" ON CONFLICT(doc_id) DO UPDATE SET"
" title=excluded.title, owner=excluded.owner, visibility=excluded.visibility,"
" chunk_count=excluded.chunk_count, indexed_at=datetime('now')",
(doc_id, doc.get("title"), owner, visibility, len(chunks)),
)
conn.commit()
return {"doc_id": doc_id, "chunks": len(chunks), "collection": collection}
def index_all(max_pages: int = 20) -> dict:
indexed = 0
errors = 0
seen: set[int] = set()
users = paperless.list_configured_users() or ["daniele"]
for paperless_user in users:
page = 1
while page <= max_pages:
try:
data = paperless.list_documents(page=page, page_size=25, username=paperless_user)
except Exception as exc:
LOGGER.error("Paperless list failed for %s: %s", paperless_user, exc)
break
results = data.get("results") or []
if not results:
break
for doc in results:
doc_id = doc["id"]
if doc_id in seen:
continue
seen.add(doc_id)
try:
index_document(doc_id, paperless_user=paperless_user)
indexed += 1
except Exception as exc:
LOGGER.warning("Index doc %s failed (%s): %s", doc_id, paperless_user, exc)
errors += 1
if not data.get("next"):
break
page += 1
return {"indexed": indexed, "errors": errors, "users": users}
def index_context_snippet(
username: str,
project_id: str,
text: str,
title: str,
snippet_id: Optional[str] = None,
) -> None:
chunks = _chunk_text(text)
if not chunks:
return
vectors = embeddings.embed_texts(chunks)
collection = qdrant_store.ctx_collection(username)
doc_key = f"ctx-{username}-{project_id}"
if snippet_id:
doc_key = f"{doc_key}-{snippet_id}"
doc_id = hash(doc_key) % (2**31)
qdrant_store.delete_by_doc(collection, doc_id)
ids = []
payloads = []
for i, chunk in enumerate(chunks):
point_id = f"{doc_key}-chunk-{i}"
ids.append(point_id)
payloads.append(
{
"doc_id": doc_id,
"project_id": project_id,
"chunk_index": i,
"title": title,
"text": chunk,
"owner": username,
"visibility": "personal",
"source": "context",
"snippet_id": snippet_id or "latest",
}
)
qdrant_store.upsert_chunks(collection, ids, vectors, payloads)
def search_knowledge(username: str, query: str, limit: int = 8, is_admin: bool = False) -> list:
vectors = embeddings.embed_texts([query])
collections = [
qdrant_store.SHARED_COLLECTION,
qdrant_store.kb_collection(username),
qdrant_store.ctx_collection(username),
]
collections.extend(gitea_indexer.gitea_collections_for_user(username, is_admin=is_admin))
collections.append(qdrant_store.APPS_SHARED_COLLECTION)
if is_admin:
for u in ("daniele", "lucia", "davide", "luca"):
collections.append(qdrant_store.kb_collection(u))
collections = list(dict.fromkeys(collections))
hits = qdrant_store.search(collections, vectors[0], limit=limit)
for hit in hits:
if hit.get("repo") and not hit.get("source"):
hit["source"] = "gitea"
elif hit.get("source") in ("irrigazione", "turni"):
hit["source_type"] = "apps"
elif hit.get("doc_id") and not hit.get("source"):
hit["source"] = "paperless"
return hits
def search_gitea_knowledge(username: str, query: str, limit: int = 8, is_admin: bool = False) -> list:
return gitea_indexer.search_gitea_knowledge(username, query, limit=limit, is_admin=is_admin)
def search_context(username: str, query: str, limit: int = 8) -> list:
vectors = embeddings.embed_texts([query])
return qdrant_store.search([qdrant_store.ctx_collection(username)], vectors[0], limit=limit)
def list_recent_documents(limit: int = 20) -> list:
rows = get_conn().execute(
"SELECT * FROM indexed_documents ORDER BY indexed_at DESC LIMIT ?", (limit,)
).fetchall()
return [dict(r) for r in rows]
@@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
"""Paperless-ngx REST API client — supporto token per utente MCP."""
import json
import logging
import os
from typing import Optional
import httpx
LOGGER = logging.getLogger("loogle_mcp.paperless")
PAPERLESS_URL = os.environ.get("PAPERLESS_URL", "https://docs.loogle.it").rstrip("/")
MCP_USERS = ("daniele", "lucia", "davide", "luca")
_tokens_cache: Optional[dict[str, str]] = None
def _load_tokens() -> dict[str, str]:
"""Carica token Paperless per utente MCP.
Priorità per ogni utente:
1. PAPERLESS_API_TOKEN_{USERNAME} (es. PAPERLESS_API_TOKEN_LUCIA)
2. Chiavi in PAPERLESS_API_TOKENS JSON (es. {"daniele":"...", "lucia":"..."})
3. PAPERLESS_API_TOKEN globale (fallback per tutti, tipico account admin)
"""
global _tokens_cache
if _tokens_cache is not None:
return _tokens_cache
tokens: dict[str, str] = {}
json_map = os.environ.get("PAPERLESS_API_TOKENS", "").strip()
if json_map:
try:
parsed = json.loads(json_map)
if isinstance(parsed, dict):
tokens.update({k.lower(): v for k, v in parsed.items() if v})
except json.JSONDecodeError:
LOGGER.warning("PAPERLESS_API_TOKENS non è JSON valido")
fallback = os.environ.get("PAPERLESS_API_TOKEN", "").strip()
for user in MCP_USERS:
env_key = f"PAPERLESS_API_TOKEN_{user.upper()}"
token = os.environ.get(env_key, "").strip()
if token:
tokens[user] = token
elif user not in tokens and fallback:
tokens[user] = fallback
if not tokens and fallback:
tokens["daniele"] = fallback
_tokens_cache = tokens
return tokens
def list_configured_users() -> list[str]:
return list(_load_tokens().keys())
def _headers(username: Optional[str] = None) -> dict:
tokens = _load_tokens()
if not tokens:
raise RuntimeError(
"Nessun token Paperless configurato. "
"Imposta PAPERLESS_API_TOKEN o PAPERLESS_API_TOKEN_{USER} in .env"
)
user = (username or "daniele").lower()
token = tokens.get(user) or tokens.get("daniele") or next(iter(tokens.values()))
return {"Authorization": f"Token {token}"}
def list_documents(
page: int = 1,
page_size: int = 25,
ordering: str = "-modified",
username: Optional[str] = None,
) -> dict:
with httpx.Client(timeout=60.0, verify=True) as client:
resp = client.get(
f"{PAPERLESS_URL}/api/documents/",
headers=_headers(username),
params={"page": page, "page_size": page_size, "ordering": ordering},
)
resp.raise_for_status()
return resp.json()
def get_document(doc_id: int, username: Optional[str] = None) -> dict:
with httpx.Client(timeout=60.0, verify=True) as client:
resp = client.get(
f"{PAPERLESS_URL}/api/documents/{doc_id}/",
headers=_headers(username),
)
resp.raise_for_status()
return resp.json()
def download_document_text(doc_id: int, username: Optional[str] = None) -> str:
doc = get_document(doc_id, username=username)
content = (doc.get("content") or "").strip()
if content:
return content
title = doc.get("title") or f"Documento {doc_id}"
return f"# {title}\n\n(Nessun testo OCR disponibile)"
def document_visibility(doc: dict, owner_username: str) -> str:
tags = doc.get("tags") or []
tag_names = []
for t in tags:
if isinstance(t, dict):
tag_names.append((t.get("name") or "").lower())
elif isinstance(t, int):
continue
else:
tag_names.append(str(t).lower())
if any(t in ("personal", "privato", "private") for t in tag_names):
return "personal"
if any(t in ("admin-only", "admin") for t in tag_names):
return "admin"
return "family"
def document_owner(doc: dict, default: str = "daniele") -> str:
owner = doc.get("owner")
if isinstance(owner, int):
pass
owner_username = doc.get("owner_username") or doc.get("owner_name")
if isinstance(owner_username, str):
name = owner_username.lower()
for user in MCP_USERS:
if user in name:
return user
correspondent = doc.get("correspondent")
if isinstance(correspondent, dict):
name = (correspondent.get("name") or "").lower()
for user in MCP_USERS:
if user in name:
return user
return default
@@ -0,0 +1,251 @@
# -*- coding: utf-8 -*-
"""Vector store — Qdrant remoto (DS920) con fallback SQLite locale su Pi ARM."""
import json
import logging
import math
import os
import sqlite3
import uuid
from typing import Optional
LOGGER = logging.getLogger("loogle_mcp.vector_store")
VECTOR_SIZE = 768
_local = sqlite3.connect(":memory:", check_same_thread=False) # placeholder
_qdrant_client = None
_qdrant_checked = False
_use_fallback = False
def _fallback_path() -> str:
return os.environ.get("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db")
def _fallback_conn() -> sqlite3.Connection:
path = _fallback_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
conn = sqlite3.connect(path, timeout=30)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS vectors (
id TEXT PRIMARY KEY,
collection TEXT NOT NULL,
vector TEXT NOT NULL,
payload TEXT NOT NULL
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_vectors_collection ON vectors(collection)")
conn.commit()
return conn
def _get_qdrant():
global _qdrant_client, _qdrant_checked, _use_fallback
if _qdrant_checked:
return None if _use_fallback else _qdrant_client
_qdrant_checked = True
url = os.environ.get("QDRANT_URL", "").strip()
if not url:
_use_fallback = True
LOGGER.warning("QDRANT_URL non impostato — fallback SQLite")
return None
try:
from qdrant_client import QdrantClient
from qdrant_client.http import models as qm
client = QdrantClient(url=url, timeout=60)
client.get_collections()
_qdrant_client = client
globals()["qm"] = qm
LOGGER.info("Qdrant connesso: %s", url)
return client
except Exception as exc:
_use_fallback = True
LOGGER.warning("Qdrant non disponibile (%s) — fallback SQLite", exc)
return None
def ensure_collection(name: str, vector_size: int = VECTOR_SIZE) -> None:
client = _get_qdrant()
if client is None:
return
from qdrant_client.http import models as qm
names = {c.name for c in client.get_collections().collections}
if name in names:
return
client.create_collection(
collection_name=name,
vectors_config=qm.VectorParams(size=vector_size, distance=qm.Distance.COSINE),
)
def kb_collection(username: str) -> str:
return f"kb_personal_{username}"
def ctx_collection(username: str) -> str:
return f"ctx_{username}"
SHARED_COLLECTION = "kb_shared_family"
GITEA_SHARED_COLLECTION = "gitea_shared_family"
APPS_SHARED_COLLECTION = "apps_shared_family"
def gitea_collection(username: str) -> str:
return f"gitea_personal_{username}"
def _point_id(name: str) -> str:
return str(uuid.uuid5(uuid.NAMESPACE_URL, name))
def _cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a)) or 1.0
nb = math.sqrt(sum(x * x for x in b)) or 1.0
return dot / (na * nb)
def upsert_chunks(
collection: str,
ids: list[str],
vectors: list[list[float]],
payloads: list[dict],
) -> None:
if not vectors:
return
ensure_collection(collection, len(vectors[0]))
client = _get_qdrant()
if client is not None:
from qdrant_client.http import models as qm
points = [
qm.PointStruct(id=_point_id(pid), vector=vec, payload=payload)
for pid, vec, payload in zip(ids, vectors, payloads)
]
client.upsert(collection_name=collection, points=points)
return
conn = _fallback_conn()
for pid, vec, payload in zip(ids, vectors, payloads):
conn.execute(
"INSERT OR REPLACE INTO vectors(id,collection,vector,payload) VALUES (?,?,?,?)",
(_point_id(pid), collection, json.dumps(vec), json.dumps(payload, ensure_ascii=False)),
)
conn.commit()
def delete_by_doc(collection: str, doc_id: int) -> None:
client = _get_qdrant()
if client is not None:
from qdrant_client.http import models as qm
ensure_collection(collection)
client.delete(
collection_name=collection,
points_selector=qm.FilterSelector(
filter=qm.Filter(
must=[qm.FieldCondition(key="doc_id", match=qm.MatchValue(value=doc_id))]
)
),
)
return
conn = _fallback_conn()
rows = conn.execute("SELECT id,payload FROM vectors WHERE collection=?", (collection,)).fetchall()
for row_id, payload_raw in rows:
payload = json.loads(payload_raw)
if payload.get("doc_id") == doc_id:
conn.execute("DELETE FROM vectors WHERE id=?", (row_id,))
conn.commit()
def count_by_doc(collection: str, doc_id: int) -> int:
client = _get_qdrant()
if client is not None:
from qdrant_client.http import models as qm
ensure_collection(collection)
result = client.count(
collection_name=collection,
count_filter=qm.Filter(
must=[qm.FieldCondition(key="doc_id", match=qm.MatchValue(value=doc_id))]
),
exact=True,
)
return int(result.count)
conn = _fallback_conn()
rows = conn.execute("SELECT payload FROM vectors WHERE collection=?", (collection,)).fetchall()
count = 0
for (payload_raw,) in rows:
payload = json.loads(payload_raw)
if payload.get("doc_id") == doc_id:
count += 1
return count
def collection_point_count(collection: str) -> int:
client = _get_qdrant()
if client is not None:
try:
info = client.get_collection(collection)
return int(info.points_count or 0)
except Exception:
return 0
conn = _fallback_conn()
row = conn.execute(
"SELECT COUNT(*) FROM vectors WHERE collection=?", (collection,)
).fetchone()
return int(row[0] if row else 0)
def search(
collections: list[str],
vector: list[float],
limit: int = 8,
visibility_filter: Optional[dict] = None,
) -> list[dict]:
results: list[dict] = []
client = _get_qdrant()
if client is not None:
from qdrant_client.http import models as qm
for collection in collections:
ensure_collection(collection, len(vector))
flt = None
if visibility_filter:
must = [
qm.FieldCondition(key=k, match=qm.MatchValue(value=v))
for k, v in visibility_filter.items()
]
if must:
flt = qm.Filter(must=must)
hits = client.search(
collection_name=collection,
query_vector=vector,
limit=limit,
query_filter=flt,
)
for hit in hits:
payload = dict(hit.payload or {})
payload["score"] = hit.score
payload["collection"] = collection
results.append(payload)
else:
conn = _fallback_conn()
for collection in collections:
rows = conn.execute(
"SELECT vector,payload FROM vectors WHERE collection=?", (collection,)
).fetchall()
for vec_raw, payload_raw in rows:
payload = dict(json.loads(payload_raw))
if visibility_filter:
if any(payload.get(k) != v for k, v in visibility_filter.items()):
continue
score = _cosine(vector, json.loads(vec_raw))
payload["score"] = score
payload["collection"] = collection
results.append(payload)
results.sort(key=lambda x: x.get("score", 0), reverse=True)
return results[:limit]
@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
"""Utility condivise per chunking testo RAG."""
import re
CHUNK_SIZE = 900
CHUNK_OVERLAP = 150
def chunk_text(text: str) -> list[str]:
text = re.sub(r"\n{3,}", "\n\n", text.strip())
if len(text) <= CHUNK_SIZE:
return [text] if text else []
chunks = []
start = 0
while start < len(text):
end = min(len(text), start + CHUNK_SIZE)
chunks.append(text[start:end])
if end >= len(text):
break
start = max(end - CHUNK_OVERLAP, start + 1)
return chunks
@@ -0,0 +1,242 @@
# -*- coding: utf-8 -*-
"""Thermal / load gate per proteggere il DS920 durante gli embedding Ollama.
Legge temperatura CPU e load average dal NAS e regola pause/keep_alive.
"""
from __future__ import annotations
import json
import logging
import os
import subprocess
import time
from typing import Optional
import httpx
LOGGER = logging.getLogger("loogle_mcp.thermal")
# Soft: rallenta. Hard: pausa (raro se il profilo lento tiene).
# Profilo "lento ma regolare": anticipare soft, cap load basso.
DEFAULT_SOFT_C = 58.0
DEFAULT_HARD_C = 70.0
DEFAULT_CPU_TARGET_PCT = 40.0 # load1 <= nproc * 0.40
def _float_env(name: str, default: float) -> float:
try:
return float(os.environ.get(name, str(default)))
except ValueError:
return default
def soft_temp_c() -> float:
return _float_env("THERMAL_TEMP_SOFT_C", DEFAULT_SOFT_C)
def hard_temp_c() -> float:
return _float_env("THERMAL_TEMP_HARD_C", DEFAULT_HARD_C)
def cpu_target_pct() -> float:
return _float_env("THERMAL_CPU_TARGET_PCT", DEFAULT_CPU_TARGET_PCT)
def enabled() -> bool:
flag = os.environ.get("THERMAL_GATE_ENABLED", "yes").strip().lower()
return flag not in ("0", "false", "no", "off")
def _read_via_http() -> Optional[dict]:
url = os.environ.get("DS920_THERMAL_URL", "").strip()
if not url:
# default probe se non configurato
url = os.environ.get(
"DS920_THERMAL_URL_DEFAULT",
"http://192.168.128.100:9191/thermal",
).strip()
try:
with httpx.Client(timeout=3.0) as client:
resp = client.get(url)
if resp.status_code != 200:
return None
data = resp.json()
if isinstance(data, dict) and "cpu_temp_c" in data:
return data
except Exception as exc:
LOGGER.debug("Thermal HTTP probe fallita: %s", exc)
return None
def _read_via_ssh() -> Optional[dict]:
host = os.environ.get("DS920_SSH_HOST", "192.168.128.100").strip()
user = os.environ.get("DS920_SSH_USER", "daniely").strip()
key = os.environ.get("DS920_SSH_KEY", "").strip()
if not host:
return None
remote = (
"python3 -c \"import json,os;"
"b='/sys/class/hwmon/hwmon0';"
"t=[int(open(f'{b}/'+n).read())/1000 for n in sorted(os.listdir(b)) "
"if n.startswith('temp') and n.endswith('_input')];"
"l=os.getloadavg();"
"print(json.dumps({'cpu_temp_c':max(t) if t else None,"
"'load1':l[0],'load5':l[1],'nproc':os.cpu_count() or 4}))\""
)
cmd = [
"ssh",
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new",
]
if key and os.path.isfile(key):
cmd.extend(["-i", key])
cmd.append(f"{user}@{host}")
cmd.append(remote)
try:
out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, timeout=12, text=True)
data = json.loads(out.strip())
if isinstance(data, dict) and data.get("cpu_temp_c") is not None:
return data
except Exception as exc:
LOGGER.debug("Thermal SSH probe fallita: %s", exc)
return None
def read_status() -> Optional[dict]:
"""Ritorna {cpu_temp_c, load1, load5?, nproc} oppure None se non raggiungibile."""
data = _read_via_http()
if data:
data["source"] = "http"
return data
data = _read_via_ssh()
if data:
data["source"] = "ssh"
return data
return None
def load_over_target(status: dict) -> bool:
load1 = float(status.get("load1") or 0)
nproc = float(status.get("nproc") or 4)
target = nproc * (cpu_target_pct() / 100.0)
return load1 > target
def suggested_keep_alive(status: Optional[dict]) -> int:
"""Secondi keep_alive Ollama.
Il container resta sempre acceso: non si fa unload per throttling termico
(evita cicli load/unload). Si scarica solo se OLLAMA_UNLOAD_ON_HARD=yes.
"""
cool = int(_float_env("OLLAMA_KEEP_ALIVE_COOL", 300))
default = int(_float_env("OLLAMA_KEEP_ALIVE_DEFAULT", 120))
if not status or status.get("cpu_temp_c") is None:
return default
temp = float(status["cpu_temp_c"])
unload = os.environ.get("OLLAMA_UNLOAD_ON_HARD", "no").strip().lower()
if temp >= hard_temp_c() and unload in ("1", "true", "yes", "on"):
return 0
return cool
def suggested_delay_s(status: Optional[dict]) -> float:
"""Duty-cycle lento: delay base sempre presente; cresce con temp/load."""
base = _float_env("OLLAMA_EMBED_DELAY_S", 8.0)
if not status or status.get("cpu_temp_c") is None:
return max(base, 5.0)
temp = float(status["cpu_temp_c"])
hard = hard_temp_c()
soft = soft_temp_c()
if temp >= hard:
return max(base, 45.0)
if temp >= soft:
# soft→hard: ~base*1.5 … ~35s (continuo, non on/off)
ratio = (temp - soft) / max(hard - soft, 1.0)
return max(base, base * 1.5 + ratio * 25.0)
if load_over_target(status):
return max(base, base * 2.0)
if temp >= soft - 4:
return max(base, base * 1.25)
return base
def wait_for_headroom(*, context: str = "embed") -> Optional[dict]:
"""Attende headroom: HARD = pausa lunga; altrimenti delay proporzionale.
Obiettivo: ritmo lento e regolare, evitando oscillazioni start/stop.
"""
if not enabled():
return None
poll = _float_env("THERMAL_POLL_S", 30.0)
hard = hard_temp_c()
soft = soft_temp_c()
# Riprendi solo quando sotto soft - 2°C (isteresi anti-oscillazione)
resume_below = soft - _float_env("THERMAL_RESUME_MARGIN_C", 2.0)
while True:
status = read_status()
if status is None:
LOGGER.warning("Thermal gate: probe non disponibile — delay conservativo")
time.sleep(max(suggested_delay_s(None), 5.0))
return None
temp = float(status.get("cpu_temp_c") or 0)
load1 = float(status.get("load1") or 0)
if temp >= hard:
LOGGER.warning(
"Thermal gate [%s]: PAUSA HARD temp=%.1f°C (tetto=%.0f°C resume<=%.0f°C "
"load1=%.2f) — riprovo tra %.0fs",
context,
temp,
hard,
resume_below,
load1,
poll,
)
time.sleep(poll)
# Isteresi: resta in pausa finché non scende sotto soft
while True:
cooled = read_status()
if cooled is None:
time.sleep(poll)
continue
t2 = float(cooled.get("cpu_temp_c") or 0)
if t2 <= resume_below and not load_over_target(cooled):
LOGGER.info(
"Thermal gate [%s]: ripresa dopo HARD (temp=%.1f°C)",
context,
t2,
)
status = cooled
break
time.sleep(poll)
# dopo ripresa applica comunque un delay soft prima dell'embed
time.sleep(suggested_delay_s(status))
return status
if temp >= soft or load_over_target(status):
delay = suggested_delay_s(status)
LOGGER.info(
"Thermal gate [%s]: rallento temp=%.1f°C load1=%.2f — delay %.1fs (source=%s)",
context,
temp,
load1,
delay,
status.get("source"),
)
time.sleep(delay)
again = read_status()
if again and float(again.get("cpu_temp_c") or 0) >= hard:
status = again
continue
return again or status
# Zona fredda: piccolo delay fisso per duty-cycle regolare
cool_delay = _float_env("OLLAMA_EMBED_COOL_DELAY_S", 0.0)
if cool_delay > 0:
time.sleep(cool_delay)
return status
+368
View File
@@ -0,0 +1,368 @@
# -*- coding: utf-8 -*-
"""Loogle MCP Hub — gateway FastAPI + OAuth + MCP Streamable HTTP."""
import json
import logging
import os
import secrets
from typing import Optional
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from pydantic import BaseModel
from . import audit, auth, jwt_utils, oauth
from .db import get_conn, init_db
from .mcp import server as mcp_server
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
LOGGER = logging.getLogger("loogle_mcp.main")
app = FastAPI(title="Loogle MCP Hub", docs_url=None, redoc_url=None)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://claude.ai",
"https://chatgpt.com",
"https://chat.openai.com",
],
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
)
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
@app.on_event("startup")
def on_startup() -> None:
init_db()
auth.ensure_sessions_table()
auth.ensure_family_users()
oauth.ensure_default_client()
if not os.environ.get("MCP_JWT_SECRET", "").strip():
secret = secrets.token_urlsafe(48)
os.environ["MCP_JWT_SECRET"] = secret
LOGGER.warning("MCP_JWT_SECRET generato — salvalo in .env: %s", secret)
# ------------------------------------------------------------------ Health
@app.get("/health")
def health():
return {"ok": True, "service": "loogle-mcp", "port": int(os.environ.get("MCP_PORT", "8700"))}
# ------------------------------------------------------------------ OAuth metadata
@app.get("/.well-known/oauth-authorization-server")
def oauth_metadata():
return oauth.authorization_server_metadata()
@app.get("/.well-known/oauth-protected-resource")
def protected_resource():
return oauth.protected_resource_metadata()
@app.get("/.well-known/oauth-protected-resource/mcp")
def protected_resource_mcp():
return oauth.protected_resource_metadata()
@app.get("/.well-known/openid-configuration")
def openid_configuration():
"""Fallback discovery usato da Claude se oauth-authorization-server non basta."""
return oauth.authorization_server_metadata()
# ------------------------------------------------------------------ OAuth endpoints
class RegisterBody(BaseModel):
client_name: str
redirect_uris: list[str]
@app.post("/oauth/register")
def oauth_register(body: RegisterBody):
try:
return oauth.register_client(body.client_name, body.redirect_uris)
except HTTPException as exc:
return JSONResponse(
status_code=exc.status_code,
content={"error": "invalid_client_metadata", "error_description": str(exc.detail)},
)
# Alias root-level OAuth (Claude/ChatGPT fallback se la discovery RFC 8414 fallisce)
@app.post("/register")
def oauth_register_root(body: RegisterBody):
return oauth_register(body)
@app.get("/oauth/authorize")
def oauth_authorize_get(
response_type: str,
client_id: str,
redirect_uri: str,
scope: str = "context:read context:write knowledge:read knowledge:write gitea:read gitea:write",
state: str = "",
code_challenge: Optional[str] = None,
code_challenge_method: Optional[str] = None,
):
if response_type != "code":
raise HTTPException(400, "response_type must be code")
html = _login_form(client_id, redirect_uri, scope, state, code_challenge, code_challenge_method)
return HTMLResponse(html)
@app.post("/oauth/authorize")
def oauth_authorize_post(
request: Request,
client_id: str = Form(...),
redirect_uri: str = Form(...),
scope: str = Form("context:read context:write knowledge:read knowledge:write gitea:read gitea:write"),
state: str = Form(""),
code_challenge: Optional[str] = Form(None),
code_challenge_method: Optional[str] = Form(None),
username: str = Form(...),
password: str = Form(...),
):
ip = request.client.host if request.client else "?"
auth.throttle(ip)
user = auth.authenticate(username, password)
if not user:
auth.record_attempt(ip)
html = _login_form(
client_id, redirect_uri, scope, state, code_challenge, code_challenge_method,
error="Credenziali non valide",
)
return HTMLResponse(html, status_code=401)
url = oauth.build_authorize_redirect(
client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, user["id"]
)
return RedirectResponse(url, status_code=302)
@app.get("/authorize")
def oauth_authorize_get_root(
response_type: str,
client_id: str,
redirect_uri: str,
scope: str = "context:read context:write knowledge:read knowledge:write gitea:read gitea:write",
state: str = "",
code_challenge: Optional[str] = None,
code_challenge_method: Optional[str] = None,
):
return oauth_authorize_get(
response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method
)
@app.post("/authorize")
def oauth_authorize_post_root(
request: Request,
client_id: str = Form(...),
redirect_uri: str = Form(...),
scope: str = Form("context:read context:write knowledge:read knowledge:write gitea:read gitea:write"),
state: str = Form(""),
code_challenge: Optional[str] = Form(None),
code_challenge_method: Optional[str] = Form(None),
username: str = Form(...),
password: str = Form(...),
):
return oauth_authorize_post(
request, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, username, password
)
@app.post("/oauth/token")
async def oauth_token(request: Request):
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
body = await request.json()
else:
form = await request.form()
body = dict(form)
grant_type = body.get("grant_type")
client_id = body.get("client_id") or os.environ.get("MCP_OAUTH_CLIENT_ID", "loogle-mcp-public")
if grant_type == "authorization_code":
return oauth.exchange_code(
body.get("code", ""),
client_id,
body.get("redirect_uri", ""),
body.get("code_verifier"),
)
if grant_type == "refresh_token":
return oauth.refresh_access_token(body.get("refresh_token", ""), client_id)
raise HTTPException(400, "grant_type non supportato")
@app.post("/token")
async def oauth_token_root(request: Request):
return await oauth_token(request)
# ------------------------------------------------------------------ MCP endpoint
@app.post("/mcp")
async def mcp_post(request: Request):
auth_header = request.headers.get("authorization", "")
claims = oauth.bearer_claims_from_header(auth_header)
try:
payload = await request.json()
except Exception:
raise HTTPException(400, "JSON non valido")
if isinstance(payload, list):
responses = mcp_server.handle_batch(payload, claims)
return JSONResponse(responses)
response = mcp_server.handle_message(payload, claims)
if not claims and payload.get("method") not in ("initialize", "notifications/initialized", "ping"):
return JSONResponse(response, status_code=401, headers=_auth_challenge_headers())
return JSONResponse(response)
@app.get("/mcp")
def mcp_get():
return JSONResponse(
{"error": "Use POST for MCP JSON-RPC"},
status_code=405,
headers=_auth_challenge_headers(),
)
def _auth_challenge_headers() -> dict:
base = jwt_utils.base_url()
resource_metadata = f"{base}/.well-known/oauth-protected-resource/mcp"
return {
"WWW-Authenticate": f'Bearer realm="mcp", resource_metadata="{resource_metadata}"',
}
# ------------------------------------------------------------------ Dashboard web
class LoginBody(BaseModel):
username: str
password: str
class PasswordBody(BaseModel):
old_password: str
new_password: str
class RevokeBody(BaseModel):
refresh_token: str
@app.post("/api/login")
def api_login(body: LoginBody, request: Request, response: Response):
ip = request.client.host if request.client else "?"
auth.throttle(ip)
user = auth.authenticate(body.username.strip(), body.password)
if not user:
auth.record_attempt(ip)
raise HTTPException(401, "Credenziali non valide")
import datetime
token = secrets.token_urlsafe(32)
expires = (
datetime.datetime.utcnow() + datetime.timedelta(days=auth.SESSION_DAYS)
).strftime("%Y-%m-%d %H:%M:%S")
get_conn().execute(
"INSERT INTO sessions(token,user_id,expires_at) VALUES (?,?,?)",
(token, user["id"], expires),
)
get_conn().commit()
response.set_cookie("mcp_session", token, max_age=auth.SESSION_DAYS * 86400, httponly=True, samesite="lax", path="/")
return {"ok": True, "user": {"username": user["username"], "is_admin": user["is_admin"]}}
@app.post("/api/logout")
def api_logout(request: Request, response: Response):
token = request.cookies.get("mcp_session", "")
if token:
get_conn().execute("DELETE FROM sessions WHERE token=?", (token,))
get_conn().commit()
response.delete_cookie("mcp_session", path="/")
return {"ok": True}
@app.get("/api/me")
def api_me(user=Depends(auth.current_user_from_cookie)):
return user
@app.post("/api/password")
def api_password(body: PasswordBody, user=Depends(auth.current_user_from_cookie)):
if len(body.new_password.strip()) < 6:
raise HTTPException(400, "La nuova password deve avere almeno 6 caratteri")
if not auth.change_password(user["id"], body.old_password, body.new_password):
raise HTTPException(400, "Password attuale errata")
return {"ok": True}
@app.get("/api/projects")
def api_projects(user=Depends(auth.current_user_from_cookie)):
from .context import store as context_store
return context_store.list_projects(user["username"])
@app.get("/api/audit")
def api_audit(limit: int = 100, user=Depends(auth.current_user_from_cookie)):
if user["is_admin"]:
return audit.list_audit(limit=min(limit, 500))
return audit.list_audit(limit=min(limit, 200), username=user["username"])
@app.post("/api/admin/revoke-refresh")
def api_revoke_refresh(body: RevokeBody, _=Depends(auth.require_admin)):
jwt_utils.revoke_refresh_token(body.refresh_token)
return {"ok": True}
@app.get("/dashboard")
def dashboard_page():
path = os.path.join(STATIC_DIR, "dashboard.html")
return HTMLResponse(open(path, encoding="utf-8").read())
@app.get("/")
def root():
return RedirectResponse("/dashboard")
def _login_form(
client_id: str,
redirect_uri: str,
scope: str,
state: str,
code_challenge: Optional[str],
code_challenge_method: Optional[str],
error: str = "",
) -> str:
err = f'<p class="error">{error}</p>' if error else ""
return f"""<!DOCTYPE html>
<html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Loogle MCP Login</title>
<style>
body{{font-family:system-ui,sans-serif;max-width:420px;margin:4rem auto;padding:1rem;background:#0f172a;color:#e2e8f0}}
h1{{font-size:1.4rem}} .card{{background:#1e293b;padding:1.5rem;border-radius:12px}}
label{{display:block;margin:.75rem 0 .25rem}} input{{width:100%;padding:.5rem;border-radius:6px;border:1px solid #334155;background:#0f172a;color:#e2e8f0}}
button{{margin-top:1rem;width:100%;padding:.65rem;background:#2563eb;color:#fff;border:none;border-radius:8px;font-size:1rem;cursor:pointer}}
.error{{color:#f87171}} .hint{{font-size:.85rem;color:#94a3b8;margin-top:1rem}}
</style></head><body>
<h1>Loogle MCP Hub</h1>
<p>Accedi con le credenziali famiglia per collegare Claude, ChatGPT o Gemini.</p>
<div class="card">{err}
<form method="post" action="/authorize">
<input type="hidden" name="client_id" value="{client_id}">
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
<input type="hidden" name="scope" value="{scope}">
<input type="hidden" name="state" value="{state}">
<input type="hidden" name="code_challenge" value="{code_challenge or ''}">
<input type="hidden" name="code_challenge_method" value="{code_challenge_method or ''}">
<label>Utente</label><input name="username" autocomplete="username" required>
<label>Password</label><input name="password" type="password" autocomplete="current-password" required>
<button type="submit">Autorizza accesso MCP</button>
</form>
<p class="hint">Primo accesso: password = username (es. lucia/lucia). Cambiala dal dashboard.</p>
</div></body></html>"""
Whitespace-only changes.
+78
View File
@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
"""MCP JSON-RPC handler (Streamable HTTP compatible)."""
import json
import logging
from typing import Any, Optional
from . import tools
LOGGER = logging.getLogger("loogle_mcp.server")
PROTOCOL_VERSION = "2024-11-05"
def _error(req_id: Any, code: int, message: str) -> dict:
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
def _result(req_id: Any, result: dict) -> dict:
return {"jsonrpc": "2.0", "id": req_id, "result": result}
def handle_message(body: dict, claims: Optional[dict]) -> dict:
method = body.get("method")
req_id = body.get("id")
params = body.get("params") or {}
if method == "initialize":
return _result(
req_id,
{
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"tools": {}, "resources": {}},
"serverInfo": {"name": "loogle-mcp", "version": "1.0.0"},
},
)
if method == "notifications/initialized":
return _result(req_id, {})
if method == "ping":
return _result(req_id, {})
if not claims:
return _error(req_id, -32001, "Autenticazione richiesta (Bearer token OAuth)")
if method == "tools/list":
return _result(req_id, {"tools": tools.tool_definitions()})
if method == "tools/call":
name = params.get("name")
arguments = params.get("arguments") or {}
try:
tool_result = tools.call_tool(name, arguments, claims)
return _result(req_id, tool_result)
except PermissionError as exc:
return _error(req_id, -32003, str(exc))
except FileNotFoundError as exc:
return _error(req_id, -32004, str(exc))
except Exception as exc:
LOGGER.exception("Tool %s failed", name)
return _error(req_id, -32000, str(exc))
if method == "resources/list":
return _result(req_id, {"resources": tools.list_resources(claims)})
if method == "resources/read":
uri = params.get("uri")
try:
resource = tools.read_resource(uri, claims)
return _result(req_id, {"contents": [resource]})
except FileNotFoundError as exc:
return _error(req_id, -32004, str(exc))
return _error(req_id, -32601, f"Metodo non supportato: {method}")
def handle_batch(messages: list, claims: Optional[dict]) -> list:
return [handle_message(msg, claims) for msg in messages if isinstance(msg, dict)]
+962
View File
@@ -0,0 +1,962 @@
# -*- coding: utf-8 -*-
"""MCP tool definitions and handlers."""
import json
from typing import Any, Optional
from .. import audit
from ..context import store as context_store
from ..jwt_utils import has_scope
from ..knowledge import apps_indexer, gitea, gitea_indexer, indexer, paperless
from ..integrations import casa, homeassistant, irrigazione, turni
def tool_definitions() -> list[dict]:
return [
{
"name": "ping",
"description": "Verifica che il server MCP Loogle risponda",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "whoami",
"description": "Restituisce l'utente autenticato e gli scope attivi",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "list_projects",
"description": "Elenca i progetti dell'utente corrente",
"inputSchema": {
"type": "object",
"properties": {"include_archived": {"type": "boolean", "default": False}},
},
},
{
"name": "create_project",
"description": "Crea un nuovo progetto con archivio contesto; opzionalmente collega un repo Gitea",
"inputSchema": {
"type": "object",
"required": ["title"],
"properties": {
"title": {"type": "string"},
"tags": {"type": "array", "items": {"type": "string"}},
"gitea_repo": {
"type": "string",
"description": "Repository Gitea owner/name (es. daniele/rete)",
},
"seed_from_gitea": {
"type": "boolean",
"default": False,
"description": "Importa README.md nel context.md se collegato a Gitea",
},
},
},
},
{
"name": "link_project_repo",
"description": "Collega o scollega un repository Gitea da un progetto esistente",
"inputSchema": {
"type": "object",
"required": ["project_id"],
"properties": {
"project_id": {"type": "string"},
"gitea_repo": {
"type": "string",
"description": "owner/name da collegare; omit o stringa vuota per scollegare",
},
"seed_from_gitea": {
"type": "boolean",
"default": False,
"description": "Importa README.md nel context.md (solo se non già importato)",
},
},
},
},
{
"name": "get_project_context",
"description": "Recupera meta, context.md, sessioni recenti e arricchimento Gitea del progetto",
"inputSchema": {
"type": "object",
"required": ["project_id"],
"properties": {
"project_id": {"type": "string"},
"session_limit": {"type": "integer", "default": 5},
"include_gitea": {
"type": "boolean",
"default": True,
"description": "Include README/docs dal repo Gitea collegato",
},
},
},
},
{
"name": "save_context",
"description": "Salva o appende memoria persistente in un progetto",
"inputSchema": {
"type": "object",
"required": ["project_id", "content"],
"properties": {
"project_id": {"type": "string"},
"content": {"type": "string"},
"mode": {"type": "string", "enum": ["append", "replace"], "default": "append"},
},
},
},
{
"name": "archive_project",
"description": "Archivia o ripristina un progetto",
"inputSchema": {
"type": "object",
"required": ["project_id"],
"properties": {
"project_id": {"type": "string"},
"archived": {"type": "boolean", "default": True},
},
},
},
{
"name": "search_context",
"description": "Ricerca semantica nei contesti salvati dell'utente",
"inputSchema": {
"type": "object",
"required": ["query"],
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "default": 8},
},
},
},
{
"name": "search_knowledge",
"description": "Ricerca semantica su Paperless, contesti salvati e repository Gitea indicizzati",
"inputSchema": {
"type": "object",
"required": ["query"],
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "default": 8},
},
},
},
{
"name": "search_gitea_knowledge",
"description": "Ricerca semantica solo sui file Gitea indicizzati (runbook, markdown, codice)",
"inputSchema": {
"type": "object",
"required": ["query"],
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "default": 8},
},
},
},
{
"name": "list_gitea_indexed_files",
"description": "Elenca gli ultimi file Gitea indicizzati nel vector store",
"inputSchema": {
"type": "object",
"properties": {
"repo": {"type": "string", "description": "Filtra per owner/name"},
"limit": {"type": "integer", "default": 30},
},
},
},
{
"name": "reindex_gitea_repo",
"description": "Re-indicizza i file testo di un repository Gitea (admin o owner repo)",
"inputSchema": {
"type": "object",
"required": ["repo"],
"properties": {
"repo": {"type": "string", "description": "owner/name, es. daniele/rete"},
"max_files": {
"type": "integer",
"description": "Limite file per questa esecuzione (default: config globale)",
},
},
},
},
{
"name": "get_document",
"description": "Recupera il contenuto testuale di un documento Paperless per ID",
"inputSchema": {
"type": "object",
"required": ["doc_id"],
"properties": {"doc_id": {"type": "integer"}},
},
},
{
"name": "list_recent_documents",
"description": "Elenca gli ultimi documenti indicizzati",
"inputSchema": {
"type": "object",
"properties": {"limit": {"type": "integer", "default": 20}},
},
},
{
"name": "reindex_document",
"description": "Re-indicizza un documento Paperless (admin)",
"inputSchema": {
"type": "object",
"required": ["doc_id"],
"properties": {"doc_id": {"type": "integer"}},
},
},
{
"name": "list_repos",
"description": "Elenca i repository Gitea accessibili all'utente corrente",
"inputSchema": {
"type": "object",
"properties": {
"page": {"type": "integer", "default": 1},
"limit": {"type": "integer", "default": 50},
},
},
},
{
"name": "get_file",
"description": "Legge un file (o elenca una directory) da un repository Gitea",
"inputSchema": {
"type": "object",
"required": ["repo", "path"],
"properties": {
"repo": {"type": "string", "description": "owner/name, es. daniele/rete"},
"path": {"type": "string", "description": "Percorso nel repo, es. ha/RUNBOOK-failover.md"},
"ref": {"type": "string", "description": "Branch o tag (default: branch predefinito del repo)"},
},
},
},
{
"name": "search_code",
"description": "Cerca testo nel codice o nei file di un repository Gitea",
"inputSchema": {
"type": "object",
"required": ["query"],
"properties": {
"query": {"type": "string"},
"repo": {"type": "string", "description": "Limita la ricerca a owner/name"},
"limit": {"type": "integer", "default": 20},
},
},
},
{
"name": "list_issues",
"description": "Elenca le issue di un repository Gitea",
"inputSchema": {
"type": "object",
"required": ["repo"],
"properties": {
"repo": {"type": "string"},
"state": {"type": "string", "enum": ["open", "closed", "all"], "default": "open"},
"page": {"type": "integer", "default": 1},
"limit": {"type": "integer", "default": 20},
},
},
},
{
"name": "get_issue",
"description": "Recupera una issue Gitea per numero",
"inputSchema": {
"type": "object",
"required": ["repo", "number"],
"properties": {
"repo": {"type": "string"},
"number": {"type": "integer"},
},
},
},
{
"name": "create_issue",
"description": "Crea una nuova issue su un repository Gitea",
"inputSchema": {
"type": "object",
"required": ["repo", "title"],
"properties": {
"repo": {"type": "string"},
"title": {"type": "string"},
"body": {"type": "string", "default": ""},
"labels": {"type": "array", "items": {"type": "string"}},
},
},
},
{
"name": "create_gitea_repo",
"description": "Crea un nuovo repository Gitea sotto l'utente corrente (workspace personale)",
"inputSchema": {
"type": "object",
"required": ["name"],
"properties": {
"name": {"type": "string", "description": "Nome repo (es. progetti)"},
"private": {"type": "boolean", "default": True},
"description": {"type": "string", "default": ""},
"auto_init": {
"type": "boolean",
"default": True,
"description": "Crea README.md iniziale",
},
},
},
},
{
"name": "create_or_update_file",
"description": "Crea o aggiorna un file su Gitea (commit singolo via API, equivalente a push di un file)",
"inputSchema": {
"type": "object",
"required": ["repo", "path", "content", "message"],
"properties": {
"repo": {"type": "string", "description": "owner/name"},
"path": {"type": "string", "description": "Percorso file nel repo"},
"content": {"type": "string", "description": "Contenuto testo del file"},
"message": {"type": "string", "description": "Messaggio di commit"},
"branch": {"type": "string", "description": "Branch (default: branch principale del repo)"},
"reindex": {
"type": "boolean",
"default": True,
"description": "Aggiorna subito il vector store RAG per questo file",
},
},
},
},
{
"name": "get_home_dashboard",
"description": "Dashboard Loogle Casa: meteo, alert, rete, notifiche",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "get_home_weather",
"description": "Meteo attuale e previsioni per casa (Loogle Casa)",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "get_network_overview",
"description": "Panoramica rete domestica: dispositivi online/offline, IP pubblico",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "get_network_failover_status",
"description": "Stato cluster failover LOOGLE (Pi, NAS, servizi)",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "get_ha_entity",
"description": "Legge lo stato di un'entità Home Assistant (read-only)",
"inputSchema": {
"type": "object",
"required": ["entity_id"],
"properties": {
"entity_id": {
"type": "string",
"description": "Es. switch.pompa_pozzo, sensor.temperatura_sala",
},
},
},
},
{
"name": "list_ha_entities",
"description": "Elenca entità Home Assistant, opzionalmente filtrate per dominio",
"inputSchema": {
"type": "object",
"properties": {
"domain": {
"type": "string",
"description": "Filtra per dominio: switch, sensor, light, climate, …",
},
"limit": {"type": "integer", "default": 50},
},
},
},
{
"name": "search_ha_entities",
"description": "Cerca entità Home Assistant per nome o entity_id",
"inputSchema": {
"type": "object",
"required": ["query"],
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "default": 20},
},
},
},
{
"name": "get_irrigation_status",
"description": "Stato irrigazione: zone, programma, pozzo, sensori, connessione HA",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "get_irrigation_zones",
"description": "Elenco zone irrigazione con stato valvole e portata",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "get_irrigation_history",
"description": "Storico irrigazioni recenti",
"inputSchema": {
"type": "object",
"properties": {"limit": {"type": "integer", "default": 30}},
},
},
{
"name": "get_turni_status",
"description": "Stato servizio Turni-Live (versione, ambiente)",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "get_my_shifts",
"description": "Turni di lavoro dell'utente corrente (Turni-Live)",
"inputSchema": {
"type": "object",
"properties": {
"from_date": {"type": "string", "description": "ISO date YYYY-MM-DD"},
"to_date": {"type": "string", "description": "ISO date YYYY-MM-DD"},
"limit": {"type": "integer", "default": 50},
},
},
},
{
"name": "list_turni_doctors",
"description": "Elenco medici/operatori in Turni-Live",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "search_apps_knowledge",
"description": "Ricerca semantica su storico Irrigazione e Turni indicizzati",
"inputSchema": {
"type": "object",
"required": ["query"],
"properties": {
"query": {"type": "string"},
"source": {
"type": "string",
"enum": ["irrigazione", "turni"],
"description": "Filtra per sorgente app",
},
"limit": {"type": "integer", "default": 8},
},
},
},
{
"name": "list_apps_indexed",
"description": "Elenca record Irrigazione/Turni indicizzati nel vector store",
"inputSchema": {
"type": "object",
"properties": {
"source": {"type": "string", "enum": ["irrigazione", "turni"]},
"limit": {"type": "integer", "default": 30},
},
},
},
{
"name": "reindex_apps",
"description": "Forza re-indicizzazione RAG da Irrigazione e/o Turni (admin)",
"inputSchema": {
"type": "object",
"properties": {
"source": {
"type": "string",
"enum": ["irrigazione", "turni", "all"],
"default": "all",
},
},
},
},
]
def _text_result(payload: Any) -> dict:
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False, indent=2)}]}
def call_tool(name: str, arguments: dict, claims: dict) -> dict:
username = claims["sub"]
is_admin = "admin" in (claims.get("scope") or "").split()
if name == "ping":
audit.log_tool(username, name)
return _text_result({"pong": True, "service": "loogle-mcp"})
if name == "whoami":
audit.log_tool(username, name)
return _text_result({"username": username, "scope": claims.get("scope"), "is_admin": is_admin})
if name == "list_projects":
if not has_scope(claims, "context:read"):
raise PermissionError("Scope context:read richiesto")
projects = context_store.list_projects(username, arguments.get("include_archived", False))
audit.log_tool(username, name)
return _text_result({"projects": projects})
if name == "create_project":
if not has_scope(claims, "context:write"):
raise PermissionError("Scope context:write richiesto")
gitea_repo = arguments.get("gitea_repo")
if gitea_repo and not has_scope(claims, "gitea:read"):
raise PermissionError("Scope gitea:read richiesto per collegare un repository")
try:
meta = context_store.create_project(
username,
arguments["title"],
arguments.get("tags"),
gitea_repo=gitea_repo,
seed_from_gitea=bool(arguments.get("seed_from_gitea")),
)
except RuntimeError as exc:
raise PermissionError(str(exc)) from exc
except ValueError as exc:
raise ValueError(str(exc)) from exc
audit.log_tool(username, name, meta["id"])
return _text_result(meta)
if name == "link_project_repo":
if not has_scope(claims, "context:write"):
raise PermissionError("Scope context:write richiesto")
gitea_repo = arguments.get("gitea_repo")
if gitea_repo and not has_scope(claims, "gitea:read"):
raise PermissionError("Scope gitea:read richiesto per collegare un repository")
try:
meta = context_store.link_project_repo(
username,
arguments["project_id"],
gitea_repo=gitea_repo,
seed_from_gitea=bool(arguments.get("seed_from_gitea")),
)
except RuntimeError as exc:
raise PermissionError(str(exc)) from exc
except ValueError as exc:
raise ValueError(str(exc)) from exc
audit.log_tool(username, name, arguments["project_id"])
return _text_result(meta)
if name == "get_project_context":
if not has_scope(claims, "context:read"):
raise PermissionError("Scope context:read richiesto")
include_gitea = arguments.get("include_gitea", True)
if include_gitea and not has_scope(claims, "gitea:read"):
include_gitea = False
data = context_store.get_project_context(
username,
arguments["project_id"],
arguments.get("session_limit", 5),
include_gitea=include_gitea,
)
if include_gitea is False and arguments.get("include_gitea", True):
meta = data.get("meta") or {}
if meta.get("gitea_repo"):
data["gitea"] = {
"linked": True,
"repo": meta["gitea_repo"],
"available": False,
"error": "Scope gitea:read richiesto per arricchimento repository",
}
audit.log_tool(username, name, arguments["project_id"])
return _text_result(data)
if name == "save_context":
if not has_scope(claims, "context:write"):
raise PermissionError("Scope context:write richiesto")
meta = context_store.save_context(
username,
arguments["project_id"],
arguments["content"],
arguments.get("mode", "append"),
)
try:
indexer.index_context_snippet(
username, arguments["project_id"], arguments["content"], meta["title"]
)
except Exception:
pass
audit.log_tool(username, name, arguments["project_id"])
return _text_result(meta)
if name == "archive_project":
if not has_scope(claims, "context:write"):
raise PermissionError("Scope context:write richiesto")
meta = context_store.archive_project(
username, arguments["project_id"], arguments.get("archived", True)
)
audit.log_tool(username, name, arguments["project_id"])
return _text_result(meta)
if name == "search_context":
if not has_scope(claims, "context:read"):
raise PermissionError("Scope context:read richiesto")
hits = indexer.search_context(username, arguments["query"], arguments.get("limit", 8))
audit.log_tool(username, name, detail={"query": arguments["query"]})
return _text_result({"results": hits})
if name == "search_knowledge":
if not has_scope(claims, "knowledge:read"):
raise PermissionError("Scope knowledge:read richiesto")
hits = indexer.search_knowledge(
username, arguments["query"], arguments.get("limit", 8), is_admin=is_admin
)
audit.log_tool(username, name, detail={"query": arguments["query"]})
return _text_result({"results": hits})
if name == "search_gitea_knowledge":
if not has_scope(claims, "knowledge:read"):
raise PermissionError("Scope knowledge:read richiesto")
if not gitea.is_configured(username):
raise PermissionError("Gitea non configurato per questo utente")
hits = indexer.search_gitea_knowledge(
username, arguments["query"], arguments.get("limit", 8), is_admin=is_admin
)
audit.log_tool(username, name, detail={"query": arguments["query"]})
return _text_result({"results": hits})
if name == "list_gitea_indexed_files":
if not has_scope(claims, "knowledge:read"):
raise PermissionError("Scope knowledge:read richiesto")
files = gitea_indexer.list_indexed_files(
arguments.get("limit", 30),
repo=arguments.get("repo"),
)
audit.log_tool(username, name, detail={"repo": arguments.get("repo")})
return _text_result({"files": files, "count": len(files)})
if name == "reindex_gitea_repo":
if not has_scope(claims, "gitea:read"):
raise PermissionError("Scope gitea:read richiesto")
if not gitea.is_configured(username):
raise PermissionError("Gitea non configurato per questo utente")
repo = arguments["repo"]
owner = repo.split("/", 1)[0].lower()
if not is_admin and owner != username:
raise PermissionError("Puoi re-indicizzare solo repository di cui sei owner")
try:
result = gitea_indexer.index_repo(
repo,
username=username,
force=True,
max_files=arguments.get("max_files"),
)
except Exception as exc:
raise PermissionError(f"Re-indicizzazione Gitea fallita: {exc}") from exc
audit.log_tool(username, name, detail={"repo": repo})
return _text_result(result)
if name == "get_document":
if not has_scope(claims, "knowledge:read"):
raise PermissionError("Scope knowledge:read richiesto")
try:
text = paperless.download_document_text(int(arguments["doc_id"]), username=username)
except Exception as exc:
raise PermissionError(f"Documento non accessibile con il tuo account Paperless: {exc}")
audit.log_tool(username, name, str(arguments["doc_id"]))
return _text_result({"doc_id": arguments["doc_id"], "content": text})
if name == "list_recent_documents":
if not has_scope(claims, "knowledge:read"):
raise PermissionError("Scope knowledge:read richiesto")
docs = indexer.list_recent_documents(arguments.get("limit", 20))
audit.log_tool(username, name)
return _text_result({"documents": docs})
if name == "reindex_document":
if not is_admin:
raise PermissionError("Scope admin richiesto")
result = indexer.index_document(int(arguments["doc_id"]), force=True)
audit.log_tool(username, name, str(arguments["doc_id"]))
return _text_result(result)
if name in (
"list_repos", "get_file", "search_code", "list_issues", "get_issue", "create_issue",
"create_gitea_repo", "create_or_update_file",
):
if not gitea.is_configured(username):
raise PermissionError(
"Gitea non configurato per questo utente. "
"Aggiungi GITEA_API_TOKEN_{USER} in .env — vedi docs/GITEA-TOKEN.md"
)
if name == "list_repos":
if not has_scope(claims, "gitea:read"):
raise PermissionError("Scope gitea:read richiesto")
try:
result = gitea.list_repos(
username=username,
page=int(arguments.get("page", 1)),
limit=int(arguments.get("limit", 50)),
)
except Exception as exc:
raise PermissionError(f"Gitea non accessibile: {exc}") from exc
audit.log_tool(username, name)
return _text_result(result)
if name == "get_file":
if not has_scope(claims, "gitea:read"):
raise PermissionError("Scope gitea:read richiesto")
try:
result = gitea.get_file(
arguments["repo"],
arguments["path"],
ref=arguments.get("ref"),
username=username,
)
except FileNotFoundError as exc:
raise ValueError(str(exc)) from exc
except Exception as exc:
raise PermissionError(f"File Gitea non accessibile: {exc}") from exc
audit.log_tool(username, name, detail={"repo": arguments["repo"], "path": arguments["path"]})
return _text_result(result)
if name == "search_code":
if not has_scope(claims, "gitea:read"):
raise PermissionError("Scope gitea:read richiesto")
try:
result = gitea.search_code(
arguments["query"],
repo=arguments.get("repo"),
limit=int(arguments.get("limit", 20)),
username=username,
)
except Exception as exc:
raise PermissionError(f"Ricerca Gitea fallita: {exc}") from exc
audit.log_tool(username, name, detail={"query": arguments["query"], "repo": arguments.get("repo")})
return _text_result(result)
if name == "list_issues":
if not has_scope(claims, "gitea:read"):
raise PermissionError("Scope gitea:read richiesto")
try:
result = gitea.list_issues(
arguments["repo"],
state=arguments.get("state", "open"),
page=int(arguments.get("page", 1)),
limit=int(arguments.get("limit", 20)),
username=username,
)
except Exception as exc:
raise PermissionError(f"Issue Gitea non accessibili: {exc}") from exc
audit.log_tool(username, name, detail={"repo": arguments["repo"]})
return _text_result(result)
if name == "get_issue":
if not has_scope(claims, "gitea:read"):
raise PermissionError("Scope gitea:read richiesto")
try:
result = gitea.get_issue(
arguments["repo"],
int(arguments["number"]),
username=username,
)
except Exception as exc:
raise PermissionError(f"Issue Gitea non accessibile: {exc}") from exc
audit.log_tool(username, name, detail={"repo": arguments["repo"], "number": arguments["number"]})
return _text_result(result)
if name == "create_issue":
if not has_scope(claims, "gitea:write"):
raise PermissionError("Scope gitea:write richiesto")
try:
result = gitea.create_issue(
arguments["repo"],
arguments["title"],
body=arguments.get("body", ""),
labels=arguments.get("labels"),
username=username,
)
except Exception as exc:
raise PermissionError(f"Creazione issue Gitea fallita: {exc}") from exc
audit.log_tool(username, name, detail={"repo": arguments["repo"], "title": arguments["title"]})
return _text_result(result)
if name == "create_gitea_repo":
if not has_scope(claims, "gitea:write"):
raise PermissionError("Scope gitea:write richiesto")
try:
result = gitea.create_repo(
arguments["name"],
username=username,
private=bool(arguments.get("private", True)),
description=arguments.get("description", ""),
auto_init=bool(arguments.get("auto_init", True)),
)
except Exception as exc:
raise PermissionError(f"Creazione repo Gitea fallita: {exc}") from exc
audit.log_tool(username, name, detail={"name": arguments["name"]})
return _text_result(result)
if name == "create_or_update_file":
if not has_scope(claims, "gitea:write"):
raise PermissionError("Scope gitea:write richiesto")
repo = arguments["repo"]
try:
gitea.assert_repo_owner(username, repo, is_admin=is_admin)
result = gitea.create_or_update_file(
repo,
arguments["path"],
arguments["content"],
arguments["message"],
branch=arguments.get("branch"),
username=username,
)
if arguments.get("reindex", True):
try:
owner, repo_name = gitea.parse_repo(repo)
meta = gitea._request(
"GET", f"/repos/{owner}/{repo_name}", username=username
)
private = bool(meta.get("private"))
idx = gitea_indexer.index_file(
repo,
arguments["path"],
username=username,
private=private,
force=True,
)
result["reindex"] = idx
except Exception:
result["reindex"] = {"skipped": True}
except PermissionError:
raise
except Exception as exc:
raise PermissionError(f"Scrittura file Gitea fallita: {exc}") from exc
audit.log_tool(
username,
name,
detail={"repo": repo, "path": arguments["path"], "action": result.get("action")},
)
return _text_result(result)
if name in (
"get_home_dashboard", "get_home_weather", "get_network_overview",
"get_network_failover_status",
):
if not has_scope(claims, "home:read"):
raise PermissionError("Scope home:read richiesto")
if not casa.is_configured(username):
raise PermissionError(
"Loogle Casa non configurato. Imposta LOOGLE_CASA_PASSWORD_{USER} in .env"
)
try:
if name == "get_home_dashboard":
payload = casa.get_dashboard(username)
elif name == "get_home_weather":
payload = casa.get_weather_home(username)
elif name == "get_network_overview":
payload = casa.get_network_overview(username)
else:
payload = casa.get_network_failover_status(username)
except Exception as exc:
raise PermissionError(f"Loogle Casa non accessibile: {exc}") from exc
audit.log_tool(username, name)
return _text_result(payload)
if name in ("get_ha_entity", "list_ha_entities", "search_ha_entities"):
if not has_scope(claims, "home:read"):
raise PermissionError("Scope home:read richiesto")
if not homeassistant.is_configured():
raise PermissionError("Home Assistant non configurato — imposta HA_TOKEN in .env")
try:
if name == "get_ha_entity":
payload = homeassistant.get_entity(arguments["entity_id"])
elif name == "list_ha_entities":
payload = {
"entities": homeassistant.list_entities(
domain=arguments.get("domain"),
limit=int(arguments.get("limit", 50)),
),
}
else:
payload = {
"results": homeassistant.search_entities(
arguments["query"],
limit=int(arguments.get("limit", 20)),
),
}
except Exception as exc:
raise PermissionError(f"Home Assistant non accessibile: {exc}") from exc
audit.log_tool(username, name, detail=arguments.get("entity_id") or arguments.get("query"))
return _text_result(payload)
if name in ("get_irrigation_status", "get_irrigation_zones", "get_irrigation_history"):
if not has_scope(claims, "irrigation:read"):
raise PermissionError("Scope irrigation:read richiesto")
if not irrigazione.is_configured(username):
raise PermissionError(
"Irrigazione non configurata. Imposta IRRIGAZIONE_PASSWORD_{USER} in .env"
)
try:
if name == "get_irrigation_status":
payload = irrigazione.get_status(username)
elif name == "get_irrigation_zones":
payload = irrigazione.get_zones(username)
else:
payload = irrigazione.get_history(username, int(arguments.get("limit", 30)))
except Exception as exc:
raise PermissionError(f"Irrigazione non accessibile: {exc}") from exc
audit.log_tool(username, name)
return _text_result(payload)
if name in ("get_turni_status", "get_my_shifts", "list_turni_doctors"):
if name != "get_turni_status" and not has_scope(claims, "turni:read"):
raise PermissionError("Scope turni:read richiesto")
try:
if name == "get_turni_status":
payload = turni.get_status()
elif name == "list_turni_doctors":
if not turni.is_configured(username):
raise PermissionError("Turni non configurato per questo utente")
payload = {"doctors": turni.list_doctors(username)}
else:
if not turni.is_configured(username):
raise PermissionError("Turni non configurato per questo utente")
payload = turni.get_my_shifts(
username,
from_date=arguments.get("from_date"),
to_date=arguments.get("to_date"),
limit=int(arguments.get("limit", 50)),
)
except PermissionError:
raise
except Exception as exc:
raise PermissionError(f"Turni non accessibile: {exc}") from exc
audit.log_tool(username, name)
return _text_result(payload)
if name == "search_apps_knowledge":
if not has_scope(claims, "knowledge:read"):
raise PermissionError("Scope knowledge:read richiesto")
hits = apps_indexer.search_apps_knowledge(
arguments["query"],
limit=int(arguments.get("limit", 8)),
source=arguments.get("source"),
)
audit.log_tool(username, name, detail={"query": arguments["query"]})
return _text_result({"results": hits})
if name == "list_apps_indexed":
if not has_scope(claims, "knowledge:read"):
raise PermissionError("Scope knowledge:read richiesto")
records = apps_indexer.list_indexed_records(
source=arguments.get("source"),
limit=int(arguments.get("limit", 30)),
)
audit.log_tool(username, name)
return _text_result({"records": records, "count": len(records)})
if name == "reindex_apps":
if not is_admin:
raise PermissionError("Scope admin richiesto")
src = arguments.get("source", "all")
if src == "irrigazione":
result = apps_indexer.index_irrigazione()
elif src == "turni":
result = apps_indexer.index_turni()
else:
result = apps_indexer.index_all()
audit.log_tool(username, name, detail={"source": src})
return _text_result(result)
raise ValueError(f"Tool sconosciuto: {name}")
def list_resources(claims: dict) -> list:
username = claims["sub"]
return context_store.list_resources(username)
def read_resource(uri: str, claims: dict) -> dict:
username = claims["sub"]
return context_store.read_resource(username, uri)
+322
View File
@@ -0,0 +1,322 @@
# -*- coding: utf-8 -*-
"""OAuth 2.1 Authorization Code + PKCE."""
import base64
import datetime
import hashlib
import json
import os
import secrets
from typing import Optional
from urllib.parse import urlencode, urlparse
from fastapi import HTTPException
from . import auth
from .db import get_conn
from .jwt_utils import (
ACCESS_TOKEN_HOURS,
base_url,
create_access_token,
create_refresh_token,
decode_access_token,
revoke_refresh_token,
scopes_for_user,
consume_refresh_token,
)
def _pkce_valid(code_verifier: str, challenge: str, method: str) -> bool:
if method != "S256":
return False
digest = hashlib.sha256(code_verifier.encode()).digest()
computed = base64.urlsafe_b64encode(digest).decode().rstrip("=")
return computed == challenge
TRUSTED_REDIRECT_URIS = frozenset({
"https://claude.ai/api/mcp/auth_callback",
"https://chatgpt.com/connector_platform_oauth_redirect",
"https://chat.openai.com/connector_platform_oauth_redirect",
# Cursor IDE / Agents (docs.cursor.com/mcp)
"https://www.cursor.com/agents/mcp/oauth/callback",
"http://localhost:8787/callback",
# Legacy Cursor desktop
"cursor://anysphere.cursor-mcp/oauth/callback",
})
def ensure_default_client() -> None:
clients = [
(
os.environ.get("MCP_OAUTH_CLIENT_ID", "loogle-mcp-public"),
"Loogle MCP Public",
[
"https://chatgpt.com/connector_platform_oauth_redirect",
"https://chat.openai.com/connector_platform_oauth_redirect",
"https://claude.ai/api/mcp/auth_callback",
"https://www.cursor.com/agents/mcp/oauth/callback",
"http://localhost:8787/callback",
"cursor://anysphere.cursor-mcp/oauth/callback",
"http://127.0.0.1:*/callback",
"http://localhost:*/callback",
],
),
(
"cursor",
"Cursor IDE",
[
"https://www.cursor.com/agents/mcp/oauth/callback",
"http://localhost:8787/callback",
"cursor://anysphere.cursor-mcp/oauth/callback",
],
),
(
"claude-desktop",
"Claude Desktop/App",
["https://claude.ai/api/mcp/auth_callback"],
),
(
"claude-ai",
"Claude.ai",
["https://claude.ai/api/mcp/auth_callback"],
),
]
conn = get_conn()
for client_id, client_name, redirect_uris in clients:
row = conn.execute(
"SELECT client_id FROM oauth_clients WHERE client_id=?", (client_id,)
).fetchone()
if row:
conn.execute(
"UPDATE oauth_clients SET client_name=?, redirect_uris=? WHERE client_id=?",
(client_name, json.dumps(redirect_uris), client_id),
)
else:
conn.execute(
"INSERT INTO oauth_clients(client_id,client_name,redirect_uris) VALUES (?,?,?)",
(client_id, client_name, json.dumps(redirect_uris)),
)
conn.commit()
def _is_trusted_redirect_uri(redirect_uri: str) -> bool:
if redirect_uri in TRUSTED_REDIRECT_URIS:
return True
parsed = urlparse(redirect_uri)
if parsed.scheme == "http" and parsed.hostname in ("127.0.0.1", "localhost"):
if (parsed.path or "").endswith("/callback"):
return True
return False
def register_client(client_name: str, redirect_uris: list[str]) -> dict:
client_id = secrets.token_urlsafe(16)
for uri in redirect_uris:
if not _is_trusted_redirect_uri(uri):
raise HTTPException(
status_code=400,
detail=f"Redirect URI non consentito: {uri}",
)
get_conn().execute(
"INSERT INTO oauth_clients(client_id,client_name,redirect_uris) VALUES (?,?,?)",
(client_id, client_name, json.dumps(redirect_uris)),
)
get_conn().commit()
return {"client_id": client_id, "client_name": client_name, "redirect_uris": redirect_uris}
def _client_redirect_uris(client_id: str) -> list[str]:
row = get_conn().execute(
"SELECT redirect_uris FROM oauth_clients WHERE client_id=?", (client_id,)
).fetchone()
if not row:
return []
return json.loads(row["redirect_uris"])
def _ensure_client_for_redirect(client_id: str, redirect_uri: str) -> None:
"""Registra client OAuth al volo (Claude usa spesso client_id = username)."""
conn = get_conn()
row = conn.execute(
"SELECT redirect_uris FROM oauth_clients WHERE client_id=?", (client_id,)
).fetchone()
if row:
uris = set(json.loads(row["redirect_uris"]))
if redirect_uri not in uris:
uris.add(redirect_uri)
conn.execute(
"UPDATE oauth_clients SET redirect_uris=? WHERE client_id=?",
(json.dumps(sorted(uris)), client_id),
)
conn.commit()
return
conn.execute(
"INSERT INTO oauth_clients(client_id,client_name,redirect_uris) VALUES (?,?,?)",
(client_id, f"MCP client {client_id}", json.dumps([redirect_uri])),
)
conn.commit()
def _redirect_allowed(client_id: str, redirect_uri: str) -> bool:
if _is_trusted_redirect_uri(redirect_uri):
_ensure_client_for_redirect(client_id, redirect_uri)
return True
allowed = _client_redirect_uris(client_id)
if redirect_uri in allowed:
return True
parsed = urlparse(redirect_uri)
for pattern in allowed:
if "*" in pattern:
pp = urlparse(pattern.replace("*", "placeholder"))
if parsed.scheme == pp.scheme and parsed.netloc.endswith(pp.netloc.split("placeholder")[-1]):
return True
return False
def create_auth_code(
client_id: str,
user_id: int,
redirect_uri: str,
scope: str,
code_challenge: Optional[str],
code_challenge_method: Optional[str],
) -> str:
code = secrets.token_urlsafe(32)
expires = (
datetime.datetime.utcnow() + datetime.timedelta(minutes=10)
).strftime("%Y-%m-%d %H:%M:%S")
get_conn().execute(
"INSERT INTO oauth_codes(code,client_id,user_id,redirect_uri,scope,code_challenge,code_challenge_method,expires_at)"
" VALUES (?,?,?,?,?,?,?,?)",
(code, client_id, user_id, redirect_uri, scope, code_challenge, code_challenge_method, expires),
)
get_conn().commit()
return code
def exchange_code(
code: str,
client_id: str,
redirect_uri: str,
code_verifier: Optional[str],
) -> dict:
row = get_conn().execute(
"SELECT * FROM oauth_codes WHERE code=? AND used=0 AND expires_at > datetime('now')",
(code,),
).fetchone()
if not row:
raise HTTPException(400, "Codice non valido o scaduto")
row = dict(row)
if row["client_id"] != client_id or row["redirect_uri"] != redirect_uri:
raise HTTPException(400, "Client o redirect URI non validi")
if row.get("code_challenge"):
if not code_verifier or not _pkce_valid(code_verifier, row["code_challenge"], row.get("code_challenge_method") or "S256"):
raise HTTPException(400, "PKCE verification failed")
user = auth.get_user_by_id(row["user_id"])
if not user:
raise HTTPException(400, "Utente non trovato")
get_conn().execute("UPDATE oauth_codes SET used=1 WHERE code=?", (code,))
get_conn().commit()
scope = scopes_for_user(user, row["scope"])
access_token, _ = create_access_token(user, scope, client_id)
refresh = create_refresh_token(user["id"], scope, client_id)
return {
"access_token": access_token,
"token_type": "Bearer",
"expires_in": ACCESS_TOKEN_HOURS * 3600,
"refresh_token": refresh,
"scope": scope,
}
def refresh_access_token(refresh_token: str, client_id: str) -> dict:
row = consume_refresh_token(refresh_token)
if not row or row["client_id"] != client_id:
raise HTTPException(400, "Refresh token non valido")
user = auth.get_user_by_id(row["user_id"])
if not user:
raise HTTPException(400, "Utente non trovato")
revoke_refresh_token(refresh_token)
scope = row["scope"]
access_token, _ = create_access_token(user, scope, client_id)
refresh = create_refresh_token(user["id"], scope, client_id)
return {
"access_token": access_token,
"token_type": "Bearer",
"expires_in": ACCESS_TOKEN_HOURS * 3600,
"refresh_token": refresh,
"scope": scope,
}
def authorization_server_metadata() -> dict:
base = base_url()
return {
"issuer": base,
"authorization_endpoint": f"{base}/authorize",
"token_endpoint": f"{base}/token",
"registration_endpoint": f"{base}/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
"scopes_supported": [
"context:read",
"context:write",
"knowledge:read",
"knowledge:write",
"gitea:read",
"gitea:write",
"home:read",
"irrigation:read",
"turni:read",
"admin",
],
}
def protected_resource_metadata() -> dict:
base = base_url()
return {
"resource": f"{base}/mcp",
"authorization_servers": [base],
"scopes_supported": [
"context:read",
"context:write",
"knowledge:read",
"knowledge:write",
"gitea:read",
"gitea:write",
"home:read",
"irrigation:read",
"turni:read",
],
"bearer_methods_supported": ["header"],
}
def build_authorize_redirect(
client_id: str,
redirect_uri: str,
scope: str,
state: str,
code_challenge: Optional[str],
code_challenge_method: Optional[str],
user_id: int,
) -> str:
if not _redirect_allowed(client_id, redirect_uri):
raise HTTPException(400, "Redirect URI non autorizzato")
code = create_auth_code(
client_id, user_id, redirect_uri, scope, code_challenge, code_challenge_method
)
params = {"code": code, "state": state}
sep = "&" if "?" in redirect_uri else "?"
return f"{redirect_uri}{sep}{urlencode(params)}"
def bearer_claims_from_header(authorization: str) -> Optional[dict]:
if not authorization.lower().startswith("bearer "):
return None
token = authorization[7:].strip()
return decode_access_token(token)
@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Loogle MCP Dashboard</title>
<style>
:root { --bg:#0f172a; --card:#1e293b; --text:#e2e8f0; --muted:#94a3b8; --accent:#2563eb; }
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 1rem; }
h1 { font-size: 1.5rem; margin-bottom: .25rem; }
.sub { color: var(--muted); margin-bottom: 1.5rem; }
.card { background: var(--card); border-radius: 12px; padding: 1rem 1.25rem; margin-bottom: 1rem; }
button { background: var(--accent); color: #fff; border: none; border-radius: 8px; padding: .55rem 1rem; cursor: pointer; }
button.secondary { background: #334155; }
input { width: 100%; padding: .5rem; border-radius: 6px; border: 1px solid #334155; background: var(--bg); color: var(--text); margin: .35rem 0 .75rem; }
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
th, td { text-align: left; padding: .45rem .25rem; border-bottom: 1px solid #334155; }
.hidden { display: none; }
.error { color: #f87171; }
code { background: #0b1220; padding: .15rem .35rem; border-radius: 4px; font-size: .85rem; }
ul { padding-left: 1.2rem; }
</style>
</head>
<body>
<h1>Loogle MCP Hub</h1>
<p class="sub">Archivio contesto e knowledge base locale per AI famiglia</p>
<div id="loginView" class="card">
<h2>Accedi</h2>
<label>Utente</label>
<input id="loginUser" autocomplete="username">
<label>Password</label>
<input id="loginPass" type="password" autocomplete="current-password">
<button onclick="doLogin()">Entra</button>
<p id="loginErr" class="error hidden"></p>
</div>
<div id="appView" class="hidden">
<div class="card">
<strong id="welcome"></strong>
<button class="secondary" onclick="doLogout()" style="float:right">Esci</button>
<p>MCP URL: <code>https://mcp.loogle.it/mcp</code></p>
</div>
<div class="card" id="pwdCard">
<h3>Cambia password</h3>
<label>Password attuale</label><input id="oldPwd" type="password">
<label>Nuova password</label><input id="newPwd" type="password">
<button onclick="changePwd()">Salva</button>
<p id="pwdMsg"></p>
</div>
<div class="card hidden" id="adminCard">
<h3>Admin — revoca refresh token</h3>
<label>Refresh token</label><input id="revokeToken">
<button onclick="revokeRefresh()">Revoca</button>
<p id="revokeMsg"></p>
</div>
<div class="card">
<h3>Progetti</h3>
<div id="projects"></div>
</div>
<div class="card">
<h3>Audit log recente</h3>
<table><thead><tr><th>Ora</th><th>Utente</th><th>Tool</th><th>Risorsa</th></tr></thead><tbody id="auditBody"></tbody></table>
</div>
<div class="card">
<h3>Collegamenti AI</h3>
<ul>
<li><strong>ChatGPT:</strong> Impostazioni → Developer → Add MCP Connector → URL sopra</li>
<li><strong>Claude:</strong> Settings → Connectors → Add remote MCP server</li>
<li><strong>Gemini CLI:</strong> configura server remoto in settings MCP</li>
</ul>
</div>
</div>
<script>
async function api(path, opts={}) {
const r = await fetch(path, { credentials: 'same-origin', headers: {'Content-Type':'application/json', ...(opts.headers||{})}, ...opts });
if (!r.ok) throw new Error(await r.text());
return r.json();
}
function show(el, on) { document.getElementById(el).classList.toggle('hidden', !on); }
async function boot() {
try {
const me = await api('/api/me');
show('loginView', false); show('appView', true);
document.getElementById('welcome').textContent = 'Ciao ' + me.username + (me.is_admin ? ' (admin)' : '');
if (me.is_admin) document.getElementById('adminCard').classList.remove('hidden');
loadProjects(); loadAudit();
} catch { show('loginView', true); show('appView', false); }
}
async function doLogin() {
try {
await api('/api/login', { method:'POST', body: JSON.stringify({ username: loginUser.value, password: loginPass.value }) });
boot();
} catch (e) {
loginErr.textContent = 'Credenziali non valide'; loginErr.classList.remove('hidden');
}
}
async function doLogout() { await api('/api/logout', { method:'POST', body:'{}' }); boot(); }
async function changePwd() {
try {
await api('/api/password', { method:'POST', body: JSON.stringify({ old_password: oldPwd.value, new_password: newPwd.value }) });
pwdMsg.textContent = 'Password aggiornata';
} catch (e) { pwdMsg.textContent = 'Errore: password attuale errata'; }
}
async function loadProjects() {
const rows = await api('/api/projects');
projects.innerHTML = rows.length ? rows.map(p => `<div><strong>${p.title}</strong> <small>${p.id}</small> — agg. ${p.updated_at||p.created_at}</div>`).join('') : '<em>Nessun progetto</em>';
}
async function loadAudit() {
const rows = await api('/api/audit?limit=30');
auditBody.innerHTML = rows.map(r => `<tr><td>${r.created_at}</td><td>${r.username}</td><td>${r.tool_name}</td><td>${r.resource_id||''}</td></tr>`).join('');
}
async function revokeRefresh() {
try {
await api('/api/admin/revoke-refresh', { method:'POST', body: JSON.stringify({ refresh_token: revokeToken.value }) });
revokeMsg.textContent = 'Token revocato';
} catch (e) { revokeMsg.textContent = 'Errore revoca'; }
}
boot();
</script>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
services:
loogle-mcp:
build:
context: .
dockerfile: Dockerfile
image: loogle-mcp:latest
container_name: loogle-mcp
restart: always
ports:
- "8700:8700"
env_file:
- .env
environment:
- TZ=Europe/Rome
- MCP_DB=/data/loogle_mcp.db
- MCP_CONTEXT_ROOT=/data/context
- MCP_AUDIT_LOG=/data/audit.log
- MCP_VECTOR_FALLBACK=/data/vector_fallback.db
volumes:
- ./data:/data
- /mnt/ha-apps/mcp/context:/data/context
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8700/health"]
interval: 30s
timeout: 5s
retries: 3
loogle-mcp-indexer:
build:
context: .
dockerfile: Dockerfile
image: loogle-mcp:latest
container_name: loogle-mcp-indexer
restart: always
env_file:
- .env
environment:
- TZ=Europe/Rome
- MCP_DB=/data/loogle_mcp.db
- MCP_CONTEXT_ROOT=/data/context
- MCP_VECTOR_FALLBACK=/data/vector_fallback.db
- DS920_SSH_HOST=192.168.128.100
- DS920_SSH_USER=daniely
- DS920_SSH_KEY=/run/secrets/ds920_ssh_key
- DS920_THERMAL_URL=http://192.168.128.100:9191/thermal
command: ["python", "-m", "worker.indexer_main"]
volumes:
- ./data:/data
- /mnt/ha-apps/mcp/context:/data/context
- /home/daniely/.ssh/id_ed25519:/run/secrets/ds920_ssh_key:ro
depends_on:
- loogle-mcp
# Qdrant locale: solo su x86/DS920 — su Pi5 ARM 16K page size non supportato
qdrant-local:
profiles: ["qdrant-local"]
image: qdrant/qdrant:v1.13.2
container_name: loogle_mcp_qdrant
restart: always
ports:
- "6333:6333"
volumes:
- /mnt/ha-apps/mcp/qdrant:/qdrant/storage
@@ -0,0 +1,91 @@
# Integrazione app homelab — P5/P6/P7
Tool live e RAG su **Loogle Casa**, **Home Assistant**, **Irrigazione** e **Turni**.
---
## P5 — Casa + Home Assistant
| Tool | Scope | Sorgente |
|------|-------|----------|
| `get_home_dashboard` | home:read | Loogle Casa |
| `get_home_weather` | home:read | Loogle Casa |
| `get_network_overview` | home:read | Loogle Casa |
| `get_network_failover_status` | home:read | Loogle Casa |
| `get_ha_entity` | home:read | Home Assistant |
| `list_ha_entities` | home:read | Home Assistant |
| `search_ha_entities` | home:read | Home Assistant |
### Variabili `.env`
```bash
LOOGLE_CASA_URL=https://casa.loogle.it
LOOGLE_CASA_API_URL=http://192.168.128.81:5602 # opzionale, LAN
LOOGLE_CASA_PASSWORD_DANIELE=... # daniele MCP → admin Casa
HA_URL=https://ha.loogle.it
HA_API_URL=http://192.168.128.81:8123
HA_TOKEN=<long-lived token HA>
```
**Mapping utenti MCP → app:**
- `daniele` → Casa `admin`, Irrigazione `admin`, Turni `daniely`
---
## P6 — Irrigazione + Turni
| Tool | Scope | Sorgente |
|------|-------|----------|
| `get_irrigation_status` | irrigation:read | irri.loogle.it |
| `get_irrigation_zones` | irrigation:read | irri.loogle.it |
| `get_irrigation_history` | irrigation:read | irri.loogle.it |
| `get_turni_status` | — (pubblico) | turni.loogle.it |
| `get_my_shifts` | turni:read | turni.loogle.it |
| `list_turni_doctors` | turni:read | turni.loogle.it |
```bash
IRRIGAZIONE_URL=https://irri.loogle.it
IRRIGAZIONE_API_URL=http://192.168.128.81:5601
IRRIGAZIONE_PASSWORD_DANIELE=...
TURNI_URL=https://turni.loogle.it
TURNI_PASSWORD_DANIELE=... # utente Turni: daniely
# oppure TURNI_JWT_DANIELE=...
```
---
## P7 — RAG storico app
Il worker `loogle-mcp-indexer` indicizza periodicamente:
- snapshot stato/zone Irrigazione
- storico irrigazioni ed eventi
- lavori manutenzione giardino
- turni e anagrafica medici Turni
| Collection Qdrant | Contenuto |
|-------------------|-----------|
| `apps_shared_family` | Irrigazione + Turni (famiglia) |
| Tool | Scope | Funzione |
|------|-------|----------|
| `search_apps_knowledge` | knowledge:read | RAG solo app |
| `search_knowledge` | knowledge:read | Include anche app |
| `list_apps_indexed` | knowledge:read | Stato indicizzazione |
| `reindex_apps` | admin | Forza sync |
```bash
APPS_INDEX_ENABLED=yes
APPS_INDEX_USER=daniele
```
---
## Test
```bash
docker exec loogle-mcp python3 /srv/scripts/test_p5_home.py
docker exec loogle-mcp python3 /srv/scripts/test_p6_apps.py
docker exec loogle-mcp python3 /srv/scripts/test_p7_apps_rag.py
```
+297
View File
@@ -0,0 +1,297 @@
# Loogle MCP Hub — Architettura e parametri
Documento di riferimento per la piattaforma MCP domestica LOOGLE.IT.
Ultimo aggiornamento: 2026-08-22
---
## Scopo
**Loogle MCP Hub** espone agli assistenti AI (Claude, ChatGPT, Gemini, Cursor) due capacità principali:
1. **Archivio contesto** — memoria persistente per progetti e task, isolata per utente
2. **Knowledge base RAG** — ricerca semantica su documenti Paperless e sui contesti salvati
Tutti i dati restano in LAN/NAS; laccesso esterno avviene solo via HTTPS + OAuth.
---
## Architettura logica
```
┌─────────────────────────────────────────────────────────────┐
│ Client AI (ChatGPT / Claude / Gemini / Cursor) │
└───────────────────────────┬─────────────────────────────────┘
│ HTTPS + OAuth Bearer
┌─────────────────────────────────────────────────────────────┐
│ NPM VIP 192.168.128.85 → mcp.loogle.it:443 │
└───────────────────────────┬─────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Pi-1 :8700 loogle-mcp (FastAPI) │
│ ├── /mcp JSON-RPC MCP │
│ ├── /oauth/* OAuth 2.1 PKCE │
│ └── /dashboard UI famiglia │
│ Pi-1 :8700 loogle-mcp-indexer (worker Paperless→vector) │
└───────┬─────────────────────────────┬───────────────────────┘
│ │
▼ ▼
┌───────────────┐ ┌─────────────────────┐
│ SQLite │ │ Vector store │
│ /data/*.db │ │ Qdrant DS920 :6333 │
│ utenti OAuth │ │ o fallback SQLite │
└───────────────┘ └─────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ NFS /mnt/ha-apps/mcp/context/{utente}/projects/... │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Paperless docs.loogle.it (sorgente documenti + OCR) │
└─────────────────────────────────────────────────────────────┘
```
---
## Componenti e percorsi
| Componente | Host | Porta | Percorso / container |
|------------|------|-------|----------------------|
| MCP Gateway | Pi-1 | 8700 | `loogle-mcp` |
| Indexer | Pi-1 | — | `loogle-mcp-indexer` |
| Qdrant (primario) | DS920 | 6333 | opzionale, compose extrema |
| Vector fallback | Pi-1 | — | `/data/vector_fallback.db` |
| Context NFS | NAS | — | `/mnt/ha-apps/mcp/context/` |
| DB SQLite | Pi-1 | — | `/home/daniely/docker/loogle-mcp/data/loogle_mcp.db` |
| Compose locale | Pi-1 | — | `/home/daniely/docker/loogle-mcp/` |
| Compose failover | Pi-1/Pi-2 | — | `/home/daniely/rete/compose/failover/loogle-mcp-compose.yml` |
| Runbook | — | — | `/home/daniely/rete/ha/RUNBOOK-mcp.md` |
---
## URL e endpoint
| Servizio | URL |
|----------|-----|
| MCP (client AI) | `https://mcp.loogle.it/mcp` |
| Dashboard web | `https://mcp.loogle.it/dashboard` |
| Health | `https://mcp.loogle.it/health` |
| OAuth authorize | `https://mcp.loogle.it/oauth/authorize` |
| OAuth token | `https://mcp.loogle.it/oauth/token` |
| Metadata OAuth | `https://mcp.loogle.it/.well-known/oauth-authorization-server` |
| Metadata risorsa MCP | `https://mcp.loogle.it/.well-known/oauth-protected-resource` |
---
## DNS
| Record | Tipo | Valore | Dove configurato |
|--------|------|--------|------------------|
| `mcp.loogle.it` | A | `192.168.128.85` (VIP NPM) | Pi-hole `/etc/pihole/pihole.toml` → hosts |
| Risoluzione LAN | — | Pi-hole → Unbound → split-horizon | Come `docs.loogle.it`, `pass.loogle.it` |
**Script setup:** `/home/daniely/rete/scripts/setup-mcp-dns-npm.sh`
**Verifica:**
```bash
dig +short mcp.loogle.it @127.0.0.1 # → 192.168.128.85
curl -sf https://mcp.loogle.it/health
```
---
## NPM (reverse proxy)
| Parametro | Valore |
|-----------|--------|
| Proxy host ID | 30 |
| Dominio | `mcp.loogle.it` |
| Upstream normale | `192.168.128.80:8700` |
| Certificato TLS | `npm-65` (Let's Encrypt) |
| Failover route | `rete/ha/npm-routes.conf` riga `30\|mcp.loogle.it\|8700\|...` |
| Websocket | ON |
| Force SSL | ON |
**Failover:** `npm-failover-route.sh` aggiorna upstream su Pi-2 / DS920 in base allo scenario HA.
---
## Variabili d'ambiente (`.env`)
File: `/home/daniely/docker/loogle-mcp/.env`
| Variabile | Descrizione | Esempio |
|-----------|-------------|---------|
| `MCP_BASE_URL` | URL pubblico (issuer JWT) | `https://mcp.loogle.it` |
| `MCP_JWT_SECRET` | Segreto firma token OAuth | *(stringa lunga casuale)* |
| `MCP_OAUTH_CLIENT_ID` | Client OAuth pre-registrato | `loogle-mcp-public` |
| `PAPERLESS_URL` | API documenti | `https://docs.loogle.it` |
| `PAPERLESS_API_TOKEN` | Token API admin (fallback) | vedi [PAPERLESS-TOKEN.md](PAPERLESS-TOKEN.md) |
| `PAPERLESS_API_TOKEN_{USER}` | Token API per utente | `DANIELE`, `LUCIA`, `DAVIDE`, `LUCA` |
| `QDRANT_URL` | Vector DB remoto | `http://192.168.128.100:6333` |
| `OLLAMA_URL` | Embedding locale | `http://192.168.128.100:11434` |
| `OLLAMA_EMBED_MODEL` | Modello embedding | `nomic-embed-text` |
| `OPENAI_API_KEY` | Fallback embedding cloud | *(opzionale)* |
| `INDEXER_INTERVAL_MINUTES` | Intervallo sync Paperless | `30` |
| `THERMAL_TEMP_HARD_C` | Pausa embedding se CPU DS920 ≥ °C | `70` |
| `THERMAL_TEMP_SOFT_C` | Rallenta embedding sopra °C | `58` |
| `THERMAL_CPU_TARGET_PCT` | Cap load1 (nproc × %) | `40` |
| `OLLAMA_NUM_THREAD` | Thread CPU per richiesta embed | `1` |
| `OLLAMA_EMBED_DELAY_S` | Pausa tra embed (duty-cycle lento) | `10` |
| `DS920_THERMAL_URL` | Probe temp/load DS920 | `http://192.168.128.100:9191/thermal` |
---
## Utenti e isolamento
| Persona | Username | Ruolo | Password iniziale |
|---------|----------|-------|-------------------|
| Daniele | `daniele` | admin | `daniele` *(cambiarla)* |
| Lucia | `lucia` | user | `lucia` |
| Davide | `davide` | user | `davide` |
| Luca | `luca` | user | `luca` |
- Ogni utente vede **solo** i propri progetti in `/mnt/ha-apps/mcp/context/{username}/`
- I token OAuth JWT contengono `sub` = username; ogni tool filtra per utente
- Ladmin può `reindex_document`, vedere audit completo, revocare refresh token
**Scope OAuth:** `context:read`, `context:write`, `knowledge:read`, `knowledge:write`, `gitea:read`, `gitea:write`, `home:read`, `irrigation:read`, `turni:read`, `admin` (solo daniele)
---
## Tool MCP esposti
| Tool | Scope minimo | Funzione |
|------|--------------|----------|
| `ping` | — | Test connettività |
| `whoami` | — | Utente e scope attivi |
| `list_projects` | context:read | Elenco progetti |
| `create_project` | context:write | Nuovo progetto |
| `get_project_context` | context:read | Legge memoria progetto |
| `save_context` | context:write | Salva/appende memoria |
| `archive_project` | context:write | Archivia progetto |
| `search_context` | context:read | Ricerca semantica contesti |
| `search_knowledge` | knowledge:read | RAG Paperless + contesti + Gitea + app |
| `search_gitea_knowledge` | knowledge:read | RAG solo Gitea |
| `search_apps_knowledge` | knowledge:read | RAG Irrigazione + Turni |
| `list_apps_indexed` | knowledge:read | Stato indicizzazione app |
| `reindex_apps` | admin | Re-sync RAG app |
| `get_document` | knowledge:read | Testo documento Paperless |
| `list_recent_documents` | knowledge:read | Ultimi doc indicizzati |
| `reindex_document` | admin | Re-indicizza documento |
| `get_home_dashboard` | home:read | Dashboard Loogle Casa |
| `get_home_weather` | home:read | Meteo casa |
| `get_network_overview` | home:read | Panoramica rete LAN |
| `get_network_failover_status` | home:read | Stato failover cluster |
| `get_ha_entity` | home:read | Entità Home Assistant |
| `list_ha_entities` | home:read | Elenco entità HA |
| `search_ha_entities` | home:read | Cerca entità HA |
| `get_irrigation_status` | irrigation:read | Stato irrigazione |
| `get_irrigation_zones` | irrigation:read | Zone irrigazione |
| `get_irrigation_history` | irrigation:read | Storico irrigazioni |
| `get_turni_status` | — | Stato servizio Turni |
| `get_my_shifts` | turni:read | Turni utente corrente |
| `list_turni_doctors` | turni:read | Medici Turni-Live |
Vedi anche tool Gitea in `docs/GITEA-TOKEN.md` e app homelab in `docs/APPS-INTEGRATION.md`.
---
## Storage contesto (per progetto)
```
/mnt/ha-apps/mcp/context/{username}/projects/{project-id}/
meta.json # titolo, tag, date, stato, gitea_repo (opz.)
context.md # memoria persistente
sessions/ # snapshot conversazioni
artifacts/ # file generati dagli agenti
```
---
## Vector store / RAG
| Collection Qdrant | Contenuto |
|-------------------|-----------|
| `kb_shared_family` | Documenti famiglia (tag non personali) |
| `kb_personal_{user}` | Documenti personali |
| `ctx_{user}` | Embedding dei contesti agente |
| `gitea_shared_family` | File testo da repo Gitea non privati |
| `gitea_personal_{user}` | File testo da repo Gitea privati dell'owner |
| `apps_shared_family` | Export Irrigazione + Turni (P7) |
**Nota Pi 5:** Qdrant locale su ARM (page size 16K) non è supportato. Default: Qdrant su DS920 o fallback SQLite in `/data/vector_fallback.db`.
**Pipeline indexer:** ogni 30 min:
1. Paperless → chunk → embedding (Ollama DS920) → upsert `kb_*`
2. Gitea (repo configurati in `GITEA_INDEX_REPOS_*`) → file `.md`/`.py`/`.sh` → chunk → upsert `gitea_*`
3. Irrigazione + Turni → snapshot/storico → upsert `apps_shared_family` (vedi `docs/APPS-INTEGRATION.md`)
---
## Sicurezza
- TLS terminato su NPM (Let's Encrypt)
- OAuth 2.1 Authorization Code + PKCE
- JWT access token 1h + refresh token 30 giorni (revocabili)
- Audit log: `{timestamp, user, tool, resource_id}` in SQLite
- CrowdSec attivo su NPM
- Nessun accesso cross-user ai dati
---
## Operazioni comuni
```bash
# Stato servizi
cd /home/daniely/docker/loogle-mcp && docker compose ps
# Log
docker logs -f loogle-mcp
docker logs -f loogle-mcp-indexer
# Redeploy
./scripts/deploy.sh
# DNS + NPM (se reinstall)
sudo /home/daniely/rete/scripts/setup-mcp-dns-npm.sh
# Failover route
/home/daniely/rete/scripts/npm-failover-route.sh normal
# Backup dati MCP
/home/daniely/rete/infra-monitor/mcp-restic-backup.sh
```
---
## Failover (tier-b)
| Scenario | Comportamento |
|----------|---------------|
| Pi-1 down, Pi-2 up | Stack MCP su Pi-2, NPM upstream → `.81:8700` |
| Extrema DS920 | NPM3 + upstream DS920 |
| Monitoraggio | Probe `mcp_ok` in `failover-status.sh` |
---
## Da completare (post-install)
1. **`PAPERLESS_API_TOKEN_*`** in `.env` — vedi [PAPERLESS-TOKEN.md](PAPERLESS-TOKEN.md)
2. **Ollama su DS920** con `nomic-embed-text` per embedding locali
3. **Qdrant su DS920** (opzionale): `rete/compose/extrema-ds920/loogle-mcp-compose.yml`
4. **Cambio password** per tutti gli utenti dal dashboard
5. **`MCP_JWT_SECRET`** — impostare valore robusto in produzione
---
## Riferimenti
- [Guida utenti famiglia](docs/GUIDA-UTENTI.md)
- [Onboarding tecnico client AI](docs/ONBOARDING.md)
- [Runbook operativo](/home/daniely/rete/ha/RUNBOOK-mcp.md)
- [README progetto](README.md)
+209
View File
@@ -0,0 +1,209 @@
# Token Gitea per Loogle MCP
## Cosa usare: Personal Access Token (PAT)
Gitea espone l'API REST su `https://git.loogle.it/api/v1/` con header:
```
Authorization: token <token>
```
Ogni utente Gitea ha i propri repository e permessi. L'hub MCP usa un token **per utente famiglia**, come Paperless.
---
## Dove generarlo
Per ogni utente che deve usare i tool Gitea da Claude/Cursor (daniele, lucia, …):
1. Accedi a https://git.loogle.it con quell'account
2. **Impostazioni****Applicazioni** → **Genera nuovo token**
3. Nome suggerito: `loogle-mcp`
4. Scope minimi:
- `read:repository` — list_repos, get_file, search_code, list_issues, get_issue, RAG
- `write:repository`**P4:** create_or_update_file
- `write:user`**P4:** create_gitea_repo (`POST /user/repos`)
- `write:issue` — create_issue
- `read:user` — consigliato per `/user/repos`
5. Copia il token (mostrato una sola volta) in `.env`
### Alternativa CLI (admin, sul nodo con Gitea attivo)
```bash
docker exec -u git gitea gitea admin user generate-access-token \
--username daniele \
--token-name loogle-mcp \
--scopes 'read:repository,write:repository,write:issue,write:user,read:user'
```
---
## Configurazione in Loogle MCP
Modifica `/home/daniely/docker/loogle-mcp/.env`:
```env
GITEA_URL=https://git.loogle.it
GITEA_API_TOKEN_DANIELE=
GITEA_API_TOKEN_LUCIA=
GITEA_API_TOKEN_DAVIDE=
GITEA_API_TOKEN_LUCA=
```
| Utente MCP | Variabile `.env` | Account Gitea |
|------------|------------------|---------------|
| Daniele | `GITEA_API_TOKEN_DANIELE` | daniele |
| Lucia | `GITEA_API_TOKEN_LUCIA` | lucia |
| Davide | `GITEA_API_TOKEN_DAVIDE` | davide |
| Luca | `GITEA_API_TOKEN_LUCA` | luca |
### URL API interno (opzionale)
Se `https://git.loogle.it` non è raggiungibile dal container MCP (rete LAN, failover), imposta un URL diretto:
```env
GITEA_API_URL=http://192.168.128.81:3002
```
I link nei risultati restano su `GITEA_URL` pubblico.
### Fallback admin
```env
GITEA_API_TOKEN=<token-admin>
```
Tutti gli utenti MCP useranno lo stesso token (sconsigliato salvo test).
---
## Tool MCP Gitea
| Tool | Scope | Descrizione |
|------|-------|-------------|
| `list_repos` | gitea:read | Repository accessibili |
| `get_file` | gitea:read | Legge file o elenca directory |
| `search_code` | gitea:read | Cerca nel codice (API `/search/code` o fallback tree) |
| `list_issues` | gitea:read | Issue aperte/chiuse |
| `get_issue` | gitea:read | Dettaglio issue |
| `create_issue` | gitea:write | Crea issue |
| `create_gitea_repo` | gitea:write | Crea repo sotto l'utente MCP corrente |
| `create_or_update_file` | gitea:write | Commit singolo di un file (create/update via Contents API) |
Dopo modifica `.env`:
```bash
cd /home/daniely/docker/loogle-mcp && docker compose up -d --build
```
---
## Verifica
```bash
/home/daniely/docker/loogle-mcp/scripts/test_gitea_integration.py
```
Oppure da MCP (con token OAuth): tool `list_repos` e `get_file` su `daniele/rete`.
---
## Collegamento progetto ↔ repository (P2)
Ogni progetto MCP può referenziare un repo Gitea in `meta.json`:
```json
"gitea_repo": "daniele/rete"
```
### Tool
| Tool | Azione |
|------|--------|
| `create_project` | Parametri opzionali `gitea_repo`, `seed_from_gitea` |
| `link_project_repo` | Collega, cambia repo o scollega (`gitea_repo` vuoto) |
| `get_project_context` | Aggiunge blocco `gitea` con README, `docs/` e issue aperte |
### Seed README
Con `seed_from_gitea: true` il README del repo viene importato in `context.md` (una sola volta, marker `<!-- seed:gitea ... -->`).
### Verifica P2
```bash
/home/daniely/docker/loogle-mcp/scripts/test_project_gitea_p2.py
```
---
## RAG su repository Gitea (P3)
Il worker `loogle-mcp-indexer` indicizza periodicamente file testo dai repo Gitea (`.md`, `.py`, `.sh`, …) in collection Qdrant dedicate:
| Collection | Contenuto |
|------------|-----------|
| `gitea_shared_family` | Repo non privati |
| `gitea_personal_{user}` | Repo privati dell'owner |
### Config `.env`
```env
GITEA_INDEX_ENABLED=yes
GITEA_INDEX_REPOS_DANIELE=daniele/rete,daniele/loogle-scripts
GITEA_INDEX_MAX_FILES_PER_REPO=150
GITEA_INDEX_MAX_FILE_BYTES=120000
```
Se `GITEA_INDEX_REPOS_{USER}` è vuoto → tutti i repo accessibili via API.
### Tool MCP
| Tool | Descrizione |
|------|-------------|
| `search_gitea_knowledge` | Ricerca semantica solo su Gitea |
| `search_knowledge` | Include Paperless + contesti + Gitea |
| `list_gitea_indexed_files` | Stato indicizzazione |
| `reindex_gitea_repo` | Forza re-index di un repo |
### Verifica P3
```bash
docker exec loogle-mcp python3 /srv/scripts/test_gitea_rag_p3.py
```
---
## Scrittura su Gitea (P4)
L'AI può creare repository e committare singoli file via API Gitea (senza git push locale).
| Tool | Scope | Azione |
|------|-------|--------|
| `create_gitea_repo` | gitea:write | Crea repo sotto l'utente MCP autenticato |
| `create_or_update_file` | gitea:write | Crea o aggiorna un file (Contents API + commit) |
Regole:
- Scrittura consentita solo su repo di cui l'utente è **owner** (salvo admin MCP).
- Dopo scrittura, opzionalmente re-indicizza il file nel RAG (`reindex: true` default).
- Token Gitea: `write:user` (create repo) + `write:repository` (file).
### Workspace davide e luca
| Utente | Repo Gitea | Progetto MCP |
|--------|------------|--------------|
| davide | `davide/progetti` | Progetti Davide |
| luca | `luca/progetti` | Progetti Luca |
Setup iniziale:
```bash
docker exec loogle-mcp python3 /srv/scripts/setup_gitea_p4_repos.py
```
### Verifica P4
```bash
docker exec loogle-mcp python3 /srv/scripts/test_gitea_p4.py
```
+211
View File
@@ -0,0 +1,211 @@
# Guida amministratore — Daniele
Guida operativa per gestire e usare Loogle MCP Hub.
---
## Accesso rapido
| Cosa | URL / comando |
|------|----------------|
| Dashboard admin | https://mcp.loogle.it/dashboard (login `daniele`) |
| Health | `curl -sf https://mcp.loogle.it/health` |
| Log gateway | `docker logs -f loogle-mcp` |
| Log indexer | `docker logs -f loogle-mcp-indexer` |
| Compose | `cd /home/daniely/docker/loogle-mcp` |
---
## Configurazione iniziale (checklist)
- [x] Stack Docker avviato (`docker compose up -d`)
- [x] DNS Pi-hole: `mcp.loogle.it``192.168.128.85`
- [x] NPM proxy host 30 + TLS Let's Encrypt
- [ ] Token Paperless in `.env` — vedi [PAPERLESS-TOKEN.md](PAPERLESS-TOKEN.md) (Profilo utente, non «Applicazione social»)
- [ ] `MCP_JWT_SECRET` robusto in `.env`
- [ ] Ollama su DS920 con `ollama pull nomic-embed-text`
- [ ] Qdrant su DS920 (opzionale, vedi compose extrema)
- [ ] Password cambiate per tutta la famiglia
- [ ] Ogni familiare ha collegato almeno un client AI
**Script utili:**
```bash
sudo /home/daniely/rete/scripts/setup-mcp-dns-npm.sh # DNS + NPM
./scripts/deploy.sh # rebuild + restart
/home/daniely/rete/scripts/npm-failover-route.sh normal # upstream failover
```
---
## Come usare tu (Daniele)
### Dashboard
1. https://mcp.loogle.it/dashboard — login `daniele`
2. Vedi progetti, audit log completo, revoca refresh token (sezione admin)
### Con Cursor / Claude / ChatGPT
Stesso URL MCP: `https://mcp.loogle.it/mcp`
**Cursor** — aggiungi in `.cursor/mcp.json` del progetto:
```json
{
"mcpServers": {
"loogle-mcp": {
"url": "https://mcp.loogle.it/mcp"
}
}
}
```
### Workflow consigliato per un task
1. **create_project** — es. «Homelab MCP», tag `rete`, `2026`
2. Lavori con lAI (Cursor, Claude, ecc.)
3. **save_context** — a fine sessione salvi decisioni e stato
4. **search_knowledge** — recuperi doc da Paperless quando serve
5. **search_context** — ritrovi discussioni passate semanticamente
### Digest giornaliero agenti → `homelab-loogle`
Ogni sera (cron `22:45` su Pi-1) lo script `scripts/export_daily_agent_digest.py` aggiorna un blocco `<!-- daily-digest:YYYY-MM-DD -->` sul progetto **homelab-loogle**, con:
- query e conclusioni dagli agent transcript Cursor
- tool MCP usati (audit_log)
- commit git del giorno (`rete`, `loogle-mcp`)
- sync `~/.cursor/plans/*.plan.md``artifacts/plans/` + indice in `context.md` (solo i piani toccati quel giorno compaiono nel digest)
Claude (e Cursor) lo leggono con `get_project_context` su `homelab-loogle`.
```bash
cd /home/daniely/docker/loogle-mcp
python3 scripts/export_daily_agent_digest.py --dry-run
python3 scripts/export_daily_agent_digest.py --force --no-index
# opzionale: indicizza il blocco in Qdrant ctx_* (più lento, Ollama)
python3 scripts/export_daily_agent_digest.py --force
```
Log: `data/daily-digest.log`. Idempotente; `--force` sovrascrive il giorno.
### Tool solo admin
- `reindex_document` — forza re-indicizzazione di un doc Paperless
- Revoca token refresh da dashboard
- Audit log di tutti gli utenti via `/api/audit`
---
## Gestione utenti
Gli utenti sono in SQLite (`loogle_mcp.db`). Password hash PBKDF2.
| Utente | Admin | Reset password |
|--------|-------|----------------|
| daniele | sì | dashboard o diretto DB |
| lucia, davide, luca | no | dashboard (self-service) |
Password iniziale = username. `must_change_password` è attivo al primo login consigliato.
Per aggiungere utenti in futuro: estendi `FAMILY_USERS` in `app/auth.py` e redeploy.
---
## Paperless → knowledge base
Vedi guida completa: [PAPERLESS-TOKEN.md](PAPERLESS-TOKEN.md)
**Non usare** i token «Applicazione social» dell'admin Django (schermata nell'immagine): servono al login OAuth web di Paperless, non all'API REST MCP.
**Usa** i token API utente: docs.loogle.it → menu utente → **Profilo** → pulsante freccia circolare accanto a «Token API».
Configura in `.env` (un token per ogni account Paperless della famiglia):
```bash
PAPERLESS_API_TOKEN_DANIELE=<token account Daniele>
PAPERLESS_API_TOKEN_LUCIA=<token account Lucia>
PAPERLESS_API_TOKEN_DAVIDE=<token account Davide>
PAPERLESS_API_TOKEN_LUCA=<token account Luca>
```
Ogni familiare genera il proprio token da docs.loogle.it → **Profilo** → Token API. Se un utente non ha ancora account Paperless, lascia la riga vuota finché non lo crei.
Alternativa: solo `PAPERLESS_API_TOKEN=` con token admin (Daniele superuser) — tutti gli utenti MCP condividono lo stesso scope.
Riavvia indexer:
```bash
cd /home/daniely/docker/loogle-mcp && docker compose restart loogle-mcp loogle-mcp-indexer
```
**Visibilità documenti (tag Paperless):**
- Tag `personal` / `privato` → solo collection personale del owner
- Senza tag personali → `kb_shared_family` (tutti in famiglia)
---
## Embedding e Qdrant
| Componente | Dove | Note |
|----------|------|------|
| Ollama | DS920 `:11434` | `OLLAMA_EMBED_MODEL=nomic-embed-text` |
| Qdrant | DS920 `:6333` | Non gira su Pi5 (limitazione ARM) |
| Fallback | `/data/vector_fallback.db` | Automatico se Qdrant non raggiungibile |
Deploy Qdrant su DS920:
```bash
# sul DS920, path mirror extrema
docker compose -f /volume1/extrema/compose/extrema-ds920/loogle-mcp-compose.yml up -d qdrant
```
---
## DNS e NPM — cosa è stato configurato
**Pi-hole** (`/etc/pihole/pihole.toml`):
```
"192.168.128.85 mcp.loogle.it"
```
**NPM** proxy host ID 30:
- Dominio: `mcp.loogle.it`
- Backend: `192.168.128.80:8700`
- Cert: `npm-65`
**Failover** (`npm-routes.conf`):
```
30|mcp.loogle.it|8700|pi1|pi1|local|ds920
```
Se reinstalli NPM da zero, riesegui:
```bash
sudo /home/daniely/rete/scripts/setup-mcp-dns-npm.sh
/home/daniely/sync-npm-full.sh # sync Pi2 + DS920
```
---
## Monitoraggio e backup
- **Failover status:** probe `mcp_ok` / `mcp_be` in `failover-status.sh`
- **Runbook incidenti:** `/home/daniely/rete/ha/RUNBOOK-mcp.md`
- **Backup restic:** `/home/daniely/rete/infra-monitor/mcp-restic-backup.sh`
---
## Onboarding famiglia
Invia a ciascuno:
- [Guida utenti](GUIDA-UTENTI.md) — istruzioni semplici per ChatGPT/Claude/Gemini
- URL dashboard per cambio password
- Username personale (non condividere password)
Progetto demo già creato per ogni utente (es. `progetti-personali` per Lucia).
---
## Documentazione tecnica
- [Architettura e parametri](ARCHITETTURA.md)
- [Onboarding client AI (dettaglio)](ONBOARDING.md)
- [Runbook HA](/home/daniely/rete/ha/RUNBOOK-mcp.md)
@@ -0,0 +1,237 @@
# Paperless — Guida per la famiglia LOOGLE
**Archivio documenti di casa:** https://docs.loogle.it
Questa guida spiega **perché** usiamo Paperless, **come** configurarlo e **come** installare lapp sul telefono.
---
## Perché usiamo Paperless
Paperless è l**archivio digitale di casa**: bollette, contratti, manuali, referti, ricevute, documenti scolastici, ecc.
| Prima (cartaceo) | Con Paperless |
|------------------|---------------|
| Documenti sparsi in cassetti e cartelle | Tutto in un unico archivio searchable |
| Difficile trovare «la bolletta luce del 2024» | Cerca per parola, tag, mittente, data |
| Fotocopie e PDF persi nel telefono | PDF centralizzati, backup su NAS di casa |
| Solo chi ha il foglio fisico | Chi ha laccount può consultare (con permessi) |
**Vantaggi per noi:**
- **Resta in casa** — i file sono sui nostri dispositivi (Pi + NAS), non su Google Drive o iCloud di terzi
- **OCR in italiano** — Paperless legge il testo nei PDF e nelle scansioni, così puoi cercare anche dentro il documento
- **Integrazione** — lassistente AI (MCP) può cercare documenti già archiviati quando chiedi «trova la bolletta gas»
- **Accesso da telefono** — consulta e carica documenti ovunque (in rete casa o via VPN)
---
## Cos’è, in pratica
Paperless-ngx è un sito web dove:
1. **Carichi** un PDF o una foto (upload, email, cartella sul telefono)
2. **Paperless** fa OCR, propone titolo, data, tag
3. **Tu** confermi o correggi (opzionale)
4. **Archivi** — il documento resta indicizzato e ricercabile
**Indirizzo:** https://docs.loogle.it
Funziona da browser (PC, tablet, telefono) o da app dedicata.
---
## Account famiglia
| Nome | Username | Password iniziale |
|------|----------|-------------------|
| Daniele | `daniele` | *(chiedi a Daniele)* |
| Lucia | `lucia` | `lucia` |
| Davide | `davide` | `davide` |
| Luca | `luca` | `luca` |
**Al primo accesso cambia la password** (vedi sotto).
Ogni persona vede i **propri** documenti e quelli **condivisi in famiglia** (secondo i permessi impostati).
---
## Primo accesso e configurazione (browser)
### 1. Accedi al sito
1. Apri **https://docs.loogle.it** (da casa o in VPN — vedi sotto)
2. Inserisci **username** e **password**
3. Se è la prima volta, Paperless può chiederti di completare il profilo
### 2. Cambia la password
1. Clicca sul **nome utente** in alto a destra
2. Vai su **Profilo** / **Impostazioni**
3. Sezione **Password** → inserisci la vecchia e la nuova
4. Salva
### 3. Conosci linterfaccia
| Area | A cosa serve |
|------|----------------|
| **Dashboard** | Panoramica, documenti recenti |
| **Documenti** | Elenco e ricerca di tutti i PDF |
| **Inbox** (se attiva) | Documenti appena caricati da revisionare |
| **Tag / Tipi / Corrispondenti** | Organizzazione (es. tag `bollette`, `salute`) |
### 4. Caricare un documento
**Da computer:**
1. **Documenti****Carica** (o trascina il PDF nella finestra)
2. Attendi lOCR (qualche secondo)
3. Controlla titolo, data, tag → **Salva**
**Da telefono (browser):**
- Stesso procedimento dal browser mobile su docs.loogle.it
**Suggerimento:** usa tag semplici e coerenti, es. `bollette`, `casa`, `auto`, `salute`, `scuola`.
### 5. Cercare un documento
- Barra **Cerca** in alto: parole chiave (es. «enel», «bolletta», «assicurazione»)
- Filtri per **tag**, **tipo documento**, **data**
- Paperless cerca anche **dentro** il testo del PDF (OCR)
### 6. Scaricare o condividere
- Apri un documento → icona **Download** o **Condividi**
- Puoi inviare il PDF via email/WhatsApp dal telefono
---
## Accesso da fuori casa (VPN)
`docs.loogle.it` è raggiungibile **in rete di casa** (WiFi domestico o dati se sei a casa).
**Se sei fuori** (ufficio, viaggio): connettiti prima alla **VPN di casa**:
- Indirizzo: **https://vpn.loogle.it**
- Credenziali WireGuard: chiedi a Daniele (file `.conf` o QR code)
Dopo la VPN, apri normalmente https://docs.loogle.it o lapp Paperless.
---
## App sul telefono
Paperless **non ha unapp ufficiale** del team Paperless-ngx, ma esistono app **compatibili** che si collegano al nostro server.
### Consigliate
| App | Android | iPhone/iPad |
|-----|---------|-------------|
| **Paperless Mobile** | [Google Play](https://play.google.com/store/apps/details?id=de.astubenbord.paperless_mobile) | Cerca «Paperless Mobile» su App Store |
| **Paperless Go** | [Google Play](https://play.google.com/store/apps/details?id=com.github.iweinzierl.paperlessgo) / F-Droid | [App Store](https://apps.apple.com/app/paperless-go/id6738696198) |
Per la famiglia consigliamo **Paperless Mobile** (molto usata, italiano supportato) oppure **Paperless Go** (interfaccia moderna, scansione documenti).
---
## Installazione app — passo passo
### Android (Paperless Mobile)
1. Apri **Google Play Store**
2. Cerca **«Paperless Mobile»** (sviluppatore: Andrei Stübenbord)
3. **Installa**
4. Apri lapp → **Aggiungi account** / **Connect**
5. **URL server:** `https://docs.loogle.it`
6. **Username** e **password** (le tue credenziali Paperless)
7. Accetta eventuale avviso certificato (connessione sicura LOOGLE)
8. Fatto — vedi lelenco documenti
### iPhone / iPad (Paperless Mobile o Paperless Go)
1. Apri **App Store**
2. Cerca **«Paperless Mobile»** o **«Paperless Go»**
3. **Scarica** e apri
4. Inserisci:
- **Server URL:** `https://docs.loogle.it`
- **Username** / **Password**
5. Opzionale: attiva **Face ID / Touch ID** per proteggere lapp
6. Fatto
### Scansione con il telefono (Paperless Go)
1. Nellapp → **Scansiona** / icona fotocamera
2. Inquadra il documento (bolletta, lettera)
3. Ritaglia e conferma
4. Scegli tag / tipo se richiesto → **Carica**
5. Il PDF compare in Paperless dopo pochi secondi
---
## Configurazione consigliata nellapp
| Impostazione | Consiglio |
|--------------|-----------|
| **Tema scuro** | Più comodo di sera |
| **Biometria** | Face ID / impronta per aprire lapp |
| **Notifiche** | Opzionali (nuovi documenti in inbox) |
| **Account multipli** | Un account per persona (non condividere la stessa login) |
---
## Buone abitudini
1. **Carica subito** bollette e documenti importanti (non lasciare pile di carta)
2. **Usa tag** — pochi ma chiari (`luce`, `gas`, `banca`, `medico`)
3. **Controlla linbox** — correggi titolo/data se Paperless sbaglia
4. **Non eliminare** loriginale cartaceo finché non sei sicuro che il PDF sia ok
5. **Cambia password** se pensi che qualcuno labbia vista
---
## Collegamento con lassistente AI (MCP)
Se usi ChatGPT, Claude o Cursor collegati a **Loogle MCP** (`https://mcp.loogle.it`):
- Puoi chiedere: *«Cerca in Paperless la bolletta luce dellultimo trimestre»*
- LAI cerca solo nei documenti **che il tuo account può vedere**
- Per funzionare, i documenti devono **già essere** in Paperless
La guida MCP è separata: `GUIDA-UTENTI.pdf` nella stessa cartella.
---
## Problemi comuni
| Problema | Cosa fare |
|----------|-----------|
| «Sito non raggiungibile» | Sei in rete casa? Se no, attiva **VPN** (vpn.loogle.it) |
| Login fallisce | Controlla username/password; prova da browser prima |
| App «connessione fallita» | URL esatto: `https://docs.loogle.it` (con **https**) |
| Certificato / SSL | Accetta il certificato LOOGLE; data/ora telefono corretta |
| Non vedo un documento | Potrebbe essere personale di un altro utente; chiedi a Daniele |
| OCR illeggibile | Scansiona con buona luce; PDF meglio di foto storta |
---
## Contatti
**Daniele** — account, permessi, VPN, problemi tecnici.
---
## Scheda rapida
```
Sito: https://docs.loogle.it
VPN fuori: https://vpn.loogle.it
App Android: Paperless Mobile (Play Store)
App iPhone: Paperless Mobile o Paperless Go (App Store)
Server URL: https://docs.loogle.it
Login: il tuo username (lucia / davide / luca)
Primo passo: cambia password → installa app → carica una bolletta di prova
```
---
*Loogle — documenti di famiglia, a casa nostra.*
+326
View File
@@ -0,0 +1,326 @@
# Guida utenti — Loogle MCP Hub
Questa guida è per **Lucia, Davide e Luca** (e chiunque usi gli assistenti AI collegati a casa).
---
## Cos’è
Loogle MCP è la **memoria di casa** per i tuoi assistenti AI. Permette a ChatGPT, Claude o Gemini di:
- **Ricordare** i tuoi progetti (es. ristrutturazione, studio, hobby)
- **Cercare** documenti che abbiamo già archiviato in Paperless (bollette, manuali, PDF)
- **Salvare** note e riassunti che lAI produce durante un lavoro, così non devi ripetere tutto ogni volta
I tuoi dati restano **solo sui nostri dispositivi** a casa, non nel cloud dellAI.
---
## Le tue credenziali
| Nome | Username | Password iniziale |
|------|----------|-------------------|
| Lucia | `lucia` | `lucia` |
| Davide | `davide` | `davide` |
| Luca | `luca` | `luca` |
**Al primo accesso cambia la password:**
1. Apri https://mcp.loogle.it/dashboard
2. Accedi con username e password
3. Sezione **Cambia password** → inserisci la nuova password (minimo 6 caratteri)
Ogni persona vede **solo i propri progetti**. Non puoi vedere (né lAI) i progetti degli altri familiari.
---
## Collegare ChatGPT
Richiede account **Plus, Pro, Business o Edu**.
1. Apri ChatGPT → **Impostazioni**
2. Attiva **Developer Mode** (se non già attivo)
3. Vai a **Connectors** → **Add MCP Connector**
4. Inserisci lURL del server:
```
https://mcp.loogle.it/mcp
```
5. Clicca **Connect** — si apre una pagina di login Loogle
6. Accedi con **il tuo** username e password (es. `lucia` / la tua nuova password)
7. Autorizza laccesso
Nelle conversazioni, abilita i tool del connector **Loogle MCP** quando vuoi usare memoria o documenti.
### Esempi di richieste a ChatGPT
- *«Usa list_projects per vedere i miei progetti»*
- *«Leggi il contesto del progetto X e riassumilo»*
- *«Cerca nei documenti di casa informazioni sulla caldaia»*
- *«Salva in save_context questo riepilogo del lavoro fatto oggi»*
---
## Collegare Claude (App / Desktop)
1. Apri Claude → **Settings** → **Connectors**
2. **Add remote MCP server**
3. URL:
```
https://mcp.loogle.it/mcp
```
4. Al primo utilizzo: login con le **tue** credenziali Loogle MCP
5. Autorizza
Claude potrà usare gli stessi tool (progetti, ricerca documenti, salvataggio contesto).
### Far usare MCP ogni giorno (Desktop + mobile)
Claude **non** salva il contesto da solo: va istruito una volta e poi richiamato con abitudini brevi.
**1. Istruzioni personalizzate (Desktop e App — stesso account)**
Settings → **Profile** / **Custom instructions** (o *What should Claude know about you?*) e incolla:
```
Hai il connector Loogle MCP (mcp.loogle.it). Per lavori su casa, documenti, progetti o codice famiglia:
- Allinizio: list_projects → get_project_context sul progetto rilevante.
- Per documenti/bollette/manuali: search_knowledge (non inventare).
- Per codice/runbook Gitea: search_gitea_knowledge o get_file.
- A fine chat utile: save_context (append) con decisioni e next step.
Se non esiste un progetto adatto, create_project prima di salvare.
```
**2. Abilita sempre i tool del connector**
In ogni chat nuova, assicurati che **Loogle MCP** sia attivo nei tool/connectors (su Desktop a volte va riacceso per conversazione).
**3. Frasi-ancora (funzionano anche da mobile)**
Usa allinizio o alla fine, senza ricordare i nomi tool:
- *«Controlla prima su Loogle MCP il mio progetto e i documenti rilevanti.»*
- *«A fine risposta salva su MCP un riepilogo nel progetto giusto.»*
- *«Cerca in Paperless via MCP la bolletta / il manuale …»*
- *«Come sta lirrigazione / i turni oggi? Usa MCP.»*
**4. Un progetto = un tema**
Es. «Rinnovo bagno», «Homelab», «Studio». Più il `context.md` è pieno di decisioni reali, più Claude lo riuserà da solo.
**5. Mobile**
Stesso account = stesse custom instructions. MCP remoto dipende dal supporto app Claude; se i tool non compaiono, usa Desktop/web per i salvataggi e da mobile chiedi almeno *«ricorda di aggiornare MCP quando torno al computer»* oppure ripeti la frase-ancora quando i connector sono disponibili.
**6. Verifica**
Dashboard https://mcp.loogle.it/dashboard → ultime azioni: devono comparire `get_project_context` / `save_context` / `search_knowledge` dopo le chat.
---
## Collegare Cursor IDE
1. Crea o modifica `~/.cursor/mcp.json` (globale) oppure `.cursor/mcp.json` nel progetto:
```json
{
"mcpServers": {
"loogle-mcp": {
"url": "https://mcp.loogle.it/mcp",
"auth": {
"CLIENT_ID": "cursor",
"scopes": [
"context:read", "context:write",
"knowledge:read", "knowledge:write",
"gitea:read", "gitea:write",
"home:read", "irrigation:read", "turni:read"
]
}
}
}
}
```
2. **Riavvia Cursor completamente** (esci dallapp, non solo chiudi la finestra)
3. **Cursor Settings****Tools & MCP****Connect** su `loogle-mcp` (**una sola volta**, attendi 1015 s)
4. Si apre il browser su **Loogle MCP — Login** → accedi con le **tue** credenziali MCP
5. In chat, chiedi all'AI di usare i tool (`list_projects`, `search_knowledge`, …)
### Cursor: «Unauthorized» nei log
| Log Cursor | Significato |
|------------|-------------|
| `MCP OAuth redirect` + `Unauthorized` | **Normale prima del login** — clicca **Connect** e completa il browser |
| `Redirect URI non consentito` | Errore server (già risolto) — riprova **Reconnect** |
| `credentials cleared` | Hai cliccato Connect/Disconnect in loop — riavvia Cursor e riprova una volta |
**Password:** se hai cambiato quella iniziale, usa quella attuale (es. admin `daniele` non usa più `daniele`/`daniele`). Verifica su https://mcp.loogle.it/dashboard
**Se il browser non si apre:** prova la config con `"CLIENT_ID": "cursor"` sopra, oppure apri manualmente https://mcp.loogle.it/dashboard per testare le credenziali.
---
## Collegare Gemini
**Gemini CLI** (da terminale / PC):
1. Apri il file di configurazione MCP di Gemini (es. `~/.gemini/settings.json`)
2. Aggiungi:
```json
{
"mcpServers": {
"loogle-mcp": {
"url": "https://mcp.loogle.it/mcp"
}
}
}
```
3. Al primo avvio completa il login OAuth nel browser con le tue credenziali
Lapp Gemini mobile potrebbe non supportare ancora MCP remoto; in quel caso usa ChatGPT o Claude.
---
## Dashboard web
Indirizzo: **https://mcp.loogle.it/dashboard**
Da qui puoi:
- Vedere lelenco dei **tuoi progetti**
- **Cambiare password**
- Consultare le **ultime azioni** che lAI ha fatto (audit log)
---
## Come usarlo nel quotidiano
### 1. Crea un progetto per ogni tema importante
Chiedi allAI:
> «Crea un progetto chiamato "Rinnovo bagno" con tag casa, 2026»
(Usa il tool `create_project` — lAI lo farà automaticamente se il connector è attivo.)
### 2. Lavora e fai salvare la memoria
A fine sessione:
> «Salva in save_context un riepilogo di quello che abbiamo deciso oggi, nel progetto rinnovo-bagno»
La prossima volta lAI potrà rileggere tutto con `get_project_context`.
### 3. Cerca documenti di casa
> «Cerca in search_knowledge la bolletta luce dellultimo trimestre»
> «Trova nel archivio documenti il manuale della caldaia»
(I documenti devono essere già in Paperless su docs.loogle.it.)
### 4. Cerca tra i tuoi appunti passati
> «search_context: cosa avevamo deciso sul parquet?»
### 5. Codice e runbook su Gitea
> «list_repos: quali repository ho su git.loogle.it?»
> «get_file su daniele/rete ha/RUNBOOK-failover.md»
> «search_code tier-b nel repo daniele/rete»
### 6. Progetto MCP collegato al codice
> «create_project "Infra Rete" con gitea_repo daniele/rete e seed_from_gitea true»
> «link_project_repo sul progetto homelab-loogle → daniele/rete»
> «get_project_context sul progetto attivo» — include README e elenco file in `docs/` dal repo collegato
> «search_gitea_knowledge: come funziona il failover tier-b?»
> «reindex_gitea_repo daniele/rete» — forza aggiornamento indicizzazione
### 7. Ricerca semantica su runbook Git (RAG)
> «search_gitea_knowledge failover su daniele/rete»
> «search_knowledge keepalived VIP» — cerca anche in Paperless e Gitea indicizzati
L'indexer in background indicizza `.md`, script e config dai repo collegati (vedi `docs/GITEA-TOKEN.md` sezione P3).
### 8. Casa, rete e Home Assistant (P5)
> «get_home_weather: che tempo fa a casa?»
> «get_network_overview: qualcosa è offline in rete?»
> «get_network_failover_status: com'è lo scenario failover?»
> «get_ha_entity switch.pompa_pozzo» — stato entità Home Assistant (solo lettura)
> «search_ha_entities caldaia»
### 9. Irrigazione e turni (P6)
> «get_irrigation_status: programma irrigazione oggi»
> «get_irrigation_zones: quali zone sono attive?»
> «get_my_shifts: i miei turni di lavoro questo mese»
> «list_turni_doctors» — elenco medici in Turni-Live
### 10. Ricerca storica app (P7)
> «search_apps_knowledge irrigazione prato ieri»
> «search_knowledge valvola pozzo» — include anche Irrigazione/Turni indicizzati
Dettagli tecnici e configurazione: `docs/APPS-INTEGRATION.md`.
---
## Cosa può e non può fare
| Può | Non può |
|-----|---------|
| Leggere i **tuoi** progetti | Vedere progetti di altri familiari |
| Cercare documenti **famiglia** e **personali** (secondo tag Paperless) | Modificare Paperless o cancellare PDF |
| Aggiungere testo alla **tua** memoria progetto | Accedere senza login OAuth |
| Cercare semanticamente (capisce il significato, non solo parole esatte) | Funzionare senza internet verso casa* |
| Leggere codice e issue da **Gitea** (repo a cui hai accesso) | Push/commit git via MCP (usa git normalmente) |
| Meteo/rete da **Loogle Casa**, sensori **Home Assistant** (lettura) | Controllare luci/valvole via MCP (solo lettura HA) |
| Stato **irrigazione** e **turni** (se configurati per te) | Modificare programmi irrigazione o turni via MCP |
\*Da fuori casa serve VPN WireGuard (`vpn.loogle.it`) o connessione alla rete di casa.
---
## Problemi comuni
| Problema | Cosa fare |
|----------|-----------|
| Login fallisce | Verifica username/password; reset da dashboard |
| ChatGPT non trova il server | URL esatto: `https://mcp.loogle.it/mcp` (con https) |
| «Knowledge vuota» | I documenti Paperless potrebbero non essere ancora indicizzati — chiedi a Daniele |
| Token scaduto | Riconnetti il connector OAuth (disconnect + connect) |
| Cursor «Unauthorized» | Clicca **Connect** una volta; login browser; usa config con `"CLIENT_ID": "cursor"` |
| Sito non raggiungibile | Sei in VPN/rete casa? Prova https://mcp.loogle.it/health |
---
## Contatti
Per problemi tecnici (token Paperless, servizio down, nuovo progetto di gruppo): **Daniele**.
Per la password dimenticata: chiedi a Daniele (admin) o usa il dashboard se ricordi quella vecchia.
---
## Scheda rapida
```
URL MCP: https://mcp.loogle.it/mcp
Dashboard: https://mcp.loogle.it/dashboard
Login: il tuo username (lucia / davide / luca)
Primo passo: cambia password → collega ChatGPT o Claude → crea un progetto
```
+157
View File
@@ -0,0 +1,157 @@
# Onboarding client AI — Loogle MCP Hub
Endpoint MCP: `https://mcp.loogle.it/mcp`
OAuth: al primo collegamento compare la pagina login Loogle. Usa le credenziali famiglia.
Password iniziale = username (es. `lucia` / `lucia`). Cambiala da https://mcp.loogle.it/dashboard
---
## Daniele (admin)
- Username: `daniele`
- Tool admin: `reindex_document`, audit completo, revoca token refresh
## Lucia
- Username: `lucia`
- Progetti isolati in `/mnt/ha-apps/mcp/context/lucia/`
## Davide
- Username: `davide`
## Luca
- Username: `luca`
---
## ChatGPT (Plus/Pro/Business)
1. Abilita **Developer Mode** nelle impostazioni ChatGPT
2. Settings → Connectors → **Add MCP Connector**
3. URL server: `https://mcp.loogle.it/mcp`
4. Completa OAuth con le tue credenziali
5. Abilita i tool desiderati nella conversazione
Redirect URI supportati (pre-registrati):
- `https://chatgpt.com/connector_platform_oauth_redirect`
- `https://chat.openai.com/connector_platform_oauth_redirect`
## Claude App / Claude Desktop
1. Settings → Connectors → **Add remote MCP server**
2. URL: `https://mcp.loogle.it/mcp`
3. Autenticazione OAuth al primo uso
Redirect URI: `https://claude.ai/api/mcp/auth_callback`
## Cursor IDE
Cursor supporta MCP remoto con **OAuth automatico** (Streamable HTTP).
### Configurazione
**Globale** (tutti i progetti): `~/.cursor/mcp.json`
**Solo questo repo**: `.cursor/mcp.json` (già presente nel progetto loogle-mcp)
```json
{
"mcpServers": {
"loogle-mcp": {
"url": "https://mcp.loogle.it/mcp"
}
}
}
```
### Collegamento
1. Apri **Cursor Settings** → **Tools & MCP**
2. Trova **loogle-mcp** → clic **Connect**
3. Si apre il browser → login con le **tue** credenziali Loogle (`daniele`, `lucia`, …)
4. Autorizza → Cursor torna con i tool attivi
Redirect OAuth Cursor (già registrati sul server):
- `https://www.cursor.com/agents/mcp/oauth/callback` (web / Agents)
- `http://localhost:8787/callback` (desktop app)
### Uso in chat
Chiedi all'agente di usare i tool MCP, ad esempio:
> «Usa whoami e list_projects per vedere i miei progetti Loogle»
Se il server risulta disconnesso: **Reconnect** da Tools & MCP.
---
## Gemini CLI
Aggiungi in `~/.gemini/settings.json` (o equivalente):
```json
{
"mcpServers": {
"loogle": {
"url": "https://mcp.loogle.it/mcp",
"transport": "http"
}
}
}
```
Al primo avvio completa il flow OAuth nel browser.
## Cursor IDE
File `.cursor/mcp.json` nel progetto:
```json
{
"mcpServers": {
"loogle-mcp": {
"url": "https://mcp.loogle.it/mcp"
}
}
}
```
---
## Tool disponibili
| Tool | Descrizione |
|------|-------------|
| `ping` | Test connettività |
| `whoami` | Utente e scope |
| `list_projects` | Progetti utente |
| `create_project` | Nuovo archivio contesto |
| `get_project_context` | Legge memoria progetto |
| `save_context` | Salva/appende memoria |
| `search_context` | Ricerca semantica contesti |
| `search_knowledge` | RAG su Paperless + contesti + Gitea indicizzati |
| `search_gitea_knowledge` | RAG solo su file Gitea (runbook, markdown) |
| `list_gitea_indexed_files` | File Gitea nel vector store |
| `reindex_gitea_repo` | Re-indicizza un repo (admin/owner) |
| `get_document` | Testo documento Paperless |
| `list_recent_documents` | Ultimi doc indicizzati |
| `reindex_document` | Solo admin |
| `list_repos` | Repository Gitea accessibili |
| `get_file` | Legge file da repo Gitea |
| `search_code` | Cerca nel codice sorgente |
| `list_issues` / `get_issue` | Issue su repo Gitea |
| `create_issue` | Crea issue (scope gitea:write) |
| `link_project_repo` | Collega/scollega repo Gitea a un progetto |
## Esempio prompt agente
> "Usa list_projects per vedere i miei progetti, poi get_project_context sul progetto attivo e search_knowledge per trovare documenti rilevanti su [argomento]."
## Troubleshooting
- **401 su MCP**: token scaduto — riconnetti il connector OAuth
- **Knowledge vuota**: verifica token Paperless e Ollama su DS920
- **Dashboard**: https://mcp.loogle.it/dashboard
+108
View File
@@ -0,0 +1,108 @@
# Token Paperless per Loogle MCP
## Cosa NON usare: token «Applicazione social»
Nell'admin Django di Paperless (`/admin/socialaccount/socialtoken/`) compare il form **«Aggiungi token dell'applicazione social»** con campi Token, Token segreto, Scade il.
Questi token servono a **django-allauth** per il login OAuth web (Google, Microsoft, ecc.) su Paperless. **Non** sono token dell'API REST di Paperless e **non** vanno usati per Loogle MCP.
Non devi configurare nulla in quella schermata per il nostro hub MCP.
---
## Cosa usare: token API utente
Paperless espone un'API REST autenticata con header:
```
Authorization: Token <token>
```
Ogni utente Paperless ha il proprio token, legato ai permessi di quell'account (documenti visibili, tag, ecc.).
### Dove generarlo
Per **ogni** familiare con account Paperless (daniele, lucia, davide, luca):
1. Accedi a https://docs.loogle.it con quell'account
2. Menu utente (in alto a destra) → **Profilo** / **Il mio profilo**
3. Sezione **Token API** → pulsante freccia circolare (rigenera token)
4. Copia il token nella riga corrispondente di `.env`
Alternativa admin: Django admin → **Token** (app `authtoken`), associato all'utente.
---
## Configurazione in Loogle MCP
Modifica `/home/daniely/docker/loogle-mcp/.env`:
### Opzione consigliata — token per utente (4 familiari)
```env
PAPERLESS_URL=https://docs.loogle.it
PAPERLESS_API_TOKEN_DANIELE=
PAPERLESS_API_TOKEN_LUCIA=
PAPERLESS_API_TOKEN_DAVIDE=
PAPERLESS_API_TOKEN_LUCA=
```
| Utente MCP | Variabile `.env` | Account Paperless |
|------------|------------------|-------------------|
| Daniele | `PAPERLESS_API_TOKEN_DANIELE` | utente admin / superuser |
| Lucia | `PAPERLESS_API_TOKEN_LUCIA` | utente Lucia |
| Davide | `PAPERLESS_API_TOKEN_DAVIDE` | utente Davide |
| Luca | `PAPERLESS_API_TOKEN_LUCA` | utente Luca |
Righe vuote = utente saltato dall'indexer finché non inserisci il token.
### Opzione alternativa — un solo token admin
Se preferisci un unico account Paperless (es. Daniele superuser):
```env
PAPERLESS_API_TOKEN=<token-daniele>
```
Tutti gli utenti MCP useranno quel token in fallback.
### Opzione C — mappa JSON
```env
PAPERLESS_API_TOKENS={"daniele":"...","lucia":"...","davide":"...","luca":"..."}
```
---
## Comportamento nel sistema
| Componente | Comportamento |
|------------|---------------|
| **Indexer** | Usa tutti i token configurati, unisce l'elenco documenti (deduplica per ID) |
| **`search_knowledge`** | Cerca su tutto ciò che è stato indicizzato |
| **`get_document`** | Usa il token dell'utente MCP loggato; se manca, fallback su `PAPERLESS_API_TOKEN` o token Daniele |
Con token separati, ogni familiare può scaricare via MCP solo i documenti visibili al proprio account Paperless.
---
## Applicare le modifiche
```bash
cd /home/daniely/docker/loogle-mcp
# modifica .env con i token reali
docker compose restart loogle-mcp loogle-mcp-indexer
docker logs loogle-mcp-indexer --tail 30
```
Log atteso: indicizzazione da `daniele`, `lucia`, `davide`, `luca` (solo utenti con token impostato).
---
## Checklist onboarding Paperless
- [ ] Account Paperless creato per daniele, lucia, davide, luca (se non esistono)
- [ ] Token API generato per ciascuno (Profilo → Token API)
- [ ] Quattro righe compilate in `.env`
- [ ] `docker compose restart loogle-mcp loogle-mcp-indexer`
- [ ] Log indexer senza errori 401
Binary file not shown.
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
fastapi==0.115.12
uvicorn[standard]==0.34.2
python-multipart==0.0.20
PyJWT==2.10.1
cryptography==44.0.2
httpx==0.28.1
qdrant-client==1.13.3
requests==2.32.3
apscheduler==3.10.4
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
docker compose build
docker compose up -d
echo "Attendo health..."
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8700/health >/dev/null; then
echo "OK — loogle-mcp attivo su :8700"
exit 0
fi
sleep 2
done
echo "Health check fallito — vedi docker logs loogle-mcp" >&2
exit 1
@@ -0,0 +1,13 @@
#!/bin/sh
set -e
mkdir -p /data /data/context
# Chiave SSH per thermal gate (permessi OpenSSH)
if [ -f /run/secrets/ds920_ssh_key ]; then
mkdir -p /root/.ssh
cp /run/secrets/ds920_ssh_key /root/.ssh/ds920_key
chmod 600 /root/.ssh/ds920_key
export DS920_SSH_KEY=/root/.ssh/ds920_key
fi
exec "$@"
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Probe temperatura/load CPU DS920 — HTTP GET /thermal su :9191.
Avvio (sul DS920, utente daniely):
nohup python3 ds920_thermal_probe.py >> /tmp/thermal-probe.log 2>&1 &
O via Task Scheduler Synology all'avvio.
"""
from __future__ import annotations
import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PORT = int(os.environ.get("THERMAL_PROBE_PORT", "9191"))
HWMON = os.environ.get("THERMAL_HWMON", "/sys/class/hwmon/hwmon0")
def read_metrics() -> dict:
temps = []
try:
for name in sorted(os.listdir(HWMON)):
if name.startswith("temp") and name.endswith("_input"):
with open(os.path.join(HWMON, name), encoding="utf-8") as f:
temps.append(int(f.read().strip()) / 1000.0)
except OSError:
pass
load1, load5, load15 = os.getloadavg()
nproc = os.cpu_count() or 4
return {
"ok": True,
"host": os.uname().nodename,
"cpu_temp_c": max(temps) if temps else None,
"temps_c": temps,
"load1": load1,
"load5": load5,
"load15": load15,
"nproc": nproc,
"cpu_target_load": round(nproc * 0.75, 2),
}
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None: # noqa: A003
return
def do_GET(self) -> None: # noqa: N802
if self.path.split("?")[0] not in ("/", "/thermal", "/health"):
self.send_response(404)
self.end_headers()
return
body = json.dumps(read_metrics()).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def main() -> None:
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
print(f"thermal probe listening on 0.0.0.0:{PORT}", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
@@ -0,0 +1,721 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Esporta un blocco giornaliero ricco su progetto MCP (default: homelab-loogle).
Fonti (massimo contesto utile, non dump grezzo):
- transcript agenti Cursor (query utente + conclusioni assistente)
- audit_log tool MCP del giorno
- commit git recenti in repo tipici (rete, loogle-mcp)
- piani Cursor `~/.cursor/plans/*.plan.md` artifacts/plans/ + indice in context.md
Idempotente: marker <!-- daily-digest:YYYY-MM-DD --> con --force sostituisce il blocco del giorno.
Esempi:
python3 scripts/export_daily_agent_digest.py
python3 scripts/export_daily_agent_digest.py --date 2026-09-03 --force
python3 scripts/export_daily_agent_digest.py --dry-run
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import sqlite3
import subprocess
import sys
from collections import Counter
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any, Optional
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
MARKER_RE = re.compile(r"<!--\s*daily-digest:(\d{4}-\d{2}-\d{2})\s*-->")
USER_QUERY_RE = re.compile(
r"<timestamp>(.*?)</timestamp>\s*<user_query>\s*(.*?)\s*</user_query>",
re.DOTALL | re.IGNORECASE,
)
TS_PREFIX_RE = re.compile(r"^\[?\d{4}-\d{2}-\d{2}")
FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
DEFAULT_TRANSCRIPT_ROOTS = [
Path.home() / ".cursor/projects/home-daniely/agent-transcripts",
Path.home() / ".cursor/projects/home-daniely-docker-loogle-mcp/agent-transcripts",
]
DEFAULT_GIT_REPOS = [
Path.home() / "rete",
Path.home() / "docker/loogle-mcp",
]
DEFAULT_PLANS_DIR = Path.home() / ".cursor/plans"
def _load_dotenv() -> None:
env_path = ROOT / ".env"
if not env_path.is_file():
return
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
if key and key not in os.environ:
os.environ[key] = value.strip().strip("'").strip('"')
def _configure_paths() -> None:
if Path("/.dockerenv").is_file() or (Path("/data").is_dir() and os.access("/data", os.W_OK)):
os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db")
os.environ.setdefault("MCP_CONTEXT_ROOT", "/data/context")
os.environ.setdefault("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db")
return
os.environ.setdefault("MCP_DB", str(ROOT / "data" / "loogle_mcp.db"))
os.environ.setdefault("MCP_CONTEXT_ROOT", "/mnt/ha-apps/mcp/context")
os.environ.setdefault("MCP_VECTOR_FALLBACK", str(ROOT / "data" / "vector_fallback.db"))
def _parse_day(s: Optional[str]) -> date:
if not s:
return date.today()
return date.fromisoformat(s)
def _day_bounds(day: date) -> tuple[datetime, datetime]:
start = datetime.combine(day, datetime.min.time())
end = start + timedelta(days=1)
return start, end
def _is_noise_query(q: str) -> bool:
q = (q or "").strip()
if len(q) < 12:
return True
low = q.lower()
if q.startswith("<") or q.startswith("[REDACTED]"):
return True
noise_prefixes = (
"you are ",
"you have access",
"start multitasking",
"briefly inform the user",
"perform any necessary follow-up",
"the following task has finished",
"<mcp_",
"<dynamic_tools>",
"<agent_transcripts>",
)
return any(low.startswith(p) or p in low[:80] for p in noise_prefixes)
def _truncate(text: str, limit: int) -> str:
text = re.sub(r"\s+", " ", (text or "").strip())
if len(text) <= limit:
return text
return text[: limit - 1].rstrip() + ""
def _extract_text_blocks(message: Any) -> list[str]:
if not isinstance(message, dict):
return []
content = message.get("content")
if isinstance(content, str):
return [content]
out: list[str] = []
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get("type") == "text":
t = part.get("text") or ""
if t and t != "[REDACTED]":
out.append(t)
return out
def _parse_event_time(raw: str, file_mtime: float) -> Optional[datetime]:
raw = (raw or "").strip()
cleaned = re.sub(r"\s*\([^)]*\)\s*$", "", raw).strip()
for fmt in (
"%A, %b %d, %Y, %I:%M %p",
"%A, %B %d, %Y, %I:%M %p",
"%Y-%m-%dT%H:%M:%S%z",
"%Y-%m-%d %H:%M:%S",
):
try:
return datetime.strptime(cleaned, fmt)
except ValueError:
continue
return datetime.fromtimestamp(file_mtime)
def collect_transcripts(day: date, roots: list[Path], max_chats: int = 25) -> list[dict]:
start, end = _day_bounds(day)
chats: list[dict] = []
files: list[Path] = []
for root in roots:
if not root.is_dir():
continue
for path in root.rglob("*.jsonl"):
if "subagents" in path.parts:
continue
files.append(path)
for path in sorted(files, key=lambda p: p.stat().st_mtime, reverse=True):
mtime = path.stat().st_mtime
# quick skip: file entirely older than day-1 or newer handled by content
if datetime.fromtimestamp(mtime) < start - timedelta(days=2):
continue
user_queries: list[str] = []
assistant_tails: list[str] = []
paths_touched: Counter[str] = Counter()
day_hit = False
try:
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
role = obj.get("role")
for text in _extract_text_blocks(obj.get("message") or {}):
if role == "user":
for ts_raw, query in USER_QUERY_RE.findall(text):
when = _parse_event_time(ts_raw, mtime)
if when and start <= when < end:
day_hit = True
q = _truncate(query, 280)
if not _is_noise_query(q):
user_queries.append(q)
# bare user text without wrapper — solo se breve messaggio umano
if (
"<user_query>" not in text
and start.timestamp() <= mtime < end.timestamp()
and len(text) > 40
and not _is_noise_query(text)
and "<" not in text[:20]
):
day_hit = True
user_queries.append(_truncate(text, 280))
elif role == "assistant":
if start.timestamp() <= mtime < end.timestamp() or day_hit:
if (
len(text) > 80
and not text.startswith("[REDACTED]")
and not text.startswith("<")
and "tool_use" not in text[:40]
):
# preferisci paragrafi conclusivi (markdown grassetto / verdetto)
assistant_tails.append(_truncate(text, 320))
# tool paths
msg = obj.get("message") or {}
content = msg.get("content") if isinstance(msg, dict) else None
if isinstance(content, list):
for part in content:
if not isinstance(part, dict) or part.get("type") != "tool_use":
continue
inp = part.get("input") or {}
for key in ("path", "target_notebook", "file_path"):
if key in inp and isinstance(inp[key], str):
paths_touched[inp[key]] += 1
except OSError:
continue
if not day_hit and not user_queries:
# include if file modified that day and has substance
if not (start.timestamp() <= mtime < end.timestamp()):
continue
if not assistant_tails:
continue
# dedupe queries
seen = set()
uniq_q = []
for q in user_queries:
if q not in seen:
seen.add(q)
uniq_q.append(q)
chats.append(
{
"id": path.parent.name if path.parent.name != "agent-transcripts" else path.stem,
"queries": uniq_q[:8],
"conclusions": assistant_tails[-3:],
"paths": [p for p, _ in paths_touched.most_common(8)],
}
)
if len(chats) >= max_chats:
break
return chats
def collect_audit(day: date, db_path: Path) -> dict:
if not db_path.is_file():
return {"tools": [], "total": 0}
start = day.isoformat()
end = (day + timedelta(days=1)).isoformat()
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT tool_name, COUNT(*) AS n FROM audit_log"
" WHERE created_at >= ? AND created_at < ? AND username=?"
" GROUP BY tool_name ORDER BY n DESC",
(start, end, "daniele"),
).fetchall()
samples = conn.execute(
"SELECT created_at, tool_name, detail FROM audit_log"
" WHERE created_at >= ? AND created_at < ? AND username=?"
" ORDER BY id DESC LIMIT 15",
(start, end, "daniele"),
).fetchall()
conn.close()
return {
"tools": [(r["tool_name"], r["n"]) for r in rows],
"total": sum(r["n"] for r in rows),
"samples": [
{
"at": r["created_at"],
"tool": r["tool_name"],
"detail": _truncate(r["detail"] or "", 120),
}
for r in samples
],
}
def collect_git(day: date, repos: list[Path], limit: int = 12) -> list[str]:
since = day.isoformat()
until = (day + timedelta(days=1)).isoformat()
lines: list[str] = []
for repo in repos:
if not (repo / ".git").exists():
continue
try:
out = subprocess.check_output(
[
"git",
"-C",
str(repo),
"log",
f"--since={since}",
f"--until={until}",
"--pretty=format:%h %s",
f"-n{limit}",
],
stderr=subprocess.DEVNULL,
text=True,
timeout=15,
).strip()
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
continue
if out:
for line in out.splitlines():
lines.append(f"{repo.name}: {line}")
return lines[:limit]
def _parse_plan_file(path: Path) -> dict:
text = path.read_text(encoding="utf-8", errors="replace")
name = path.stem
overview = ""
todos_total = 0
todos_done = 0
m = FRONTMATTER_RE.match(text)
if m:
fm = m.group(1)
nm = re.search(r"^name:\s*(.+)$", fm, re.M)
if nm:
name = nm.group(1).strip().strip("\"'")
ov = re.search(r"^overview:\s*(.+)$", fm, re.M)
if ov:
overview = ov.group(1).strip().strip("\"'")
# overview può essere su più righe YAML quoted — fallback grezzo
if overview.startswith("|") or not overview:
ov2 = re.search(r"^overview:\s*[>|]?\s*\n((?:[ \t]+.+\n)+)", fm, re.M)
if ov2:
overview = " ".join(line.strip() for line in ov2.group(1).splitlines())
statuses = re.findall(r"^\s+status:\s*(\w+)", fm, re.M)
todos_total = len(statuses)
todos_done = sum(1 for s in statuses if s == "completed")
mtime = datetime.fromtimestamp(path.stat().st_mtime)
return {
"file": path.name,
"name": name,
"overview": _truncate(overview, 220),
"todos_total": todos_total,
"todos_done": todos_done,
"mtime": mtime,
"text": text,
"path": path,
}
def collect_and_sync_plans(
day: date,
plans_dir: Path,
dest_dir: Path,
*,
dry_run: bool = False,
) -> dict:
"""Copia tutti i .plan.md in artifacts/plans/; ritorna catalogo + aggiornati nel giorno."""
start, end = _day_bounds(day)
plans: list[dict] = []
if plans_dir.is_dir():
for path in sorted(plans_dir.glob("*.plan.md")):
try:
plans.append(_parse_plan_file(path))
except OSError:
continue
copied = 0
updated_today: list[dict] = []
if not dry_run:
dest_dir.mkdir(parents=True, exist_ok=True)
for plan in plans:
target = dest_dir / plan["file"]
shutil.copy2(plan["path"], target)
copied += 1
if start <= plan["mtime"] < end:
updated_today.append(plan)
# INDEX.md per lettura umana / futuri tool
idx_lines = [
"# Piani Cursor (sync)",
"",
f"_Aggiornato {datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %Z')} "
f"da `~/.cursor/plans` → `artifacts/plans/`._",
"",
]
for plan in sorted(plans, key=lambda p: p["mtime"], reverse=True):
prog = (
f"{plan['todos_done']}/{plan['todos_total']}"
if plan["todos_total"]
else "?"
)
idx_lines.append(
f"- **{plan['name']}** (`{plan['file']}`, todos {prog}, "
f"mtime {plan['mtime'].date().isoformat()})"
)
if plan["overview"]:
idx_lines.append(f" - {plan['overview']}")
idx_lines.append("")
(dest_dir / "INDEX.md").write_text("\n".join(idx_lines), encoding="utf-8")
else:
updated_today = [p for p in plans if start <= p["mtime"] < end]
return {
"plans": plans,
"copied": copied,
"updated_today": updated_today,
"dest": str(dest_dir),
}
def upsert_plans_index_section(context_md: str, plans: list[dict]) -> str:
"""Sezione stabile in context.md (non nel digest giornaliero) con indice piani."""
begin = "<!-- BEGIN CURSOR PLANS -->"
end = "<!-- END CURSOR PLANS -->"
lines = [
begin,
"## Piani Cursor (indice sync)",
"",
"_File completi in `artifacts/plans/`. Qui solo indice per `get_project_context`._",
"",
]
if not plans:
lines.append("- Nessun piano in `~/.cursor/plans`.")
else:
for plan in sorted(plans, key=lambda p: p["mtime"], reverse=True):
prog = (
f"{plan['todos_done']}/{plan['todos_total']} done"
if plan["todos_total"]
else "n/d"
)
lines.append(
f"- **{plan['name']}** — {prog} — `{plan['file']}` "
f"({plan['mtime'].date().isoformat()})"
)
if plan["overview"]:
lines.append(f" - {plan['overview']}")
lines.extend(["", end, ""])
block = "\n".join(lines)
if begin in context_md and end in context_md:
pattern = re.compile(
re.escape(begin) + r".*?" + re.escape(end),
re.DOTALL,
)
return pattern.sub(block.strip(), context_md)
# inserisci prima dei daily digests se presenti
dig = "<!-- BEGIN DAILY DIGESTS -->"
if dig in context_md:
head, tail = context_md.split(dig, 1)
return head.rstrip() + "\n\n" + block + "\n" + dig + tail
return context_md.rstrip() + "\n\n" + block
def build_markdown(
day: date,
chats: list[dict],
audit: dict,
git_lines: list[str],
project_id: str,
plans_updated: Optional[list[dict]] = None,
) -> str:
lines: list[str] = [
f"<!-- daily-digest:{day.isoformat()} -->",
f"## Digest agenti {day.isoformat()} — `{project_id}`",
"",
f"_Generato automaticamente da `export_daily_agent_digest.py` "
f"({datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %Z')})._",
"",
]
lines.append("### Chat / agenti Cursor")
if not chats:
lines.append("- Nessun transcript rilevante per questo giorno.")
else:
lines.append(f"- Sessioni considerate: **{len(chats)}**")
for i, chat in enumerate(chats, 1):
title = chat["queries"][0] if chat["queries"] else chat["id"]
lines.append(f"{i}. **{_truncate(title, 120)}** `[{chat['id'][:8]}]`")
for q in chat["queries"][1:4]:
lines.append(f" - Q: {_truncate(q, 160)}")
for c in chat["conclusions"][-2:]:
lines.append(f" - → {_truncate(c, 200)}")
if chat["paths"]:
short_paths = ", ".join(_truncate(p, 60) for p in chat["paths"][:5])
lines.append(f" - File: `{short_paths}`")
lines.append("")
lines.append("### Tool MCP usati")
if not audit.get("total"):
lines.append("- Nessuna chiamata tool in audit_log.")
else:
lines.append(f"- Totale chiamate: **{audit['total']}**")
top = ", ".join(f"`{name}`×{n}" for name, n in audit["tools"][:10])
lines.append(f"- Top: {top}")
for s in (audit.get("samples") or [])[:8]:
lines.append(f" - {s['at']} `{s['tool']}` {s['detail']}")
lines.append("")
lines.append("### Commit git (homelab)")
if not git_lines:
lines.append("- Nessun commit nel giorno.")
else:
for g in git_lines:
lines.append(f"- `{g}`")
lines.append("")
lines.append("### Piani Cursor aggiornati oggi")
plans_updated = plans_updated or []
if not plans_updated:
lines.append("- Nessun `.plan.md` modificato in questa data.")
else:
for plan in plans_updated:
prog = (
f"{plan['todos_done']}/{plan['todos_total']}"
if plan["todos_total"]
else "?"
)
lines.append(
f"- **{plan['name']}** (`artifacts/plans/{plan['file']}`, todos {prog})"
)
if plan["overview"]:
lines.append(f" - {plan['overview']}")
lines.append("")
lines.append("### Per Claude / prossimi agenti")
lines.append(
"- Usa questo blocco come memoria del giorno; per dettagli codice preferisci "
"`search_gitea_knowledge` / `get_file` sui path citati."
)
lines.append(
"- Piani completi: sezione **Piani Cursor** in context.md + file in `artifacts/plans/`."
)
lines.append(
"- Non ripetere setup già conclusi; riparti da decisioni e next step qui sopra."
)
lines.append("")
return "\n".join(lines)
def upsert_daily_block(context_md: str, day: date, block: str) -> str:
"""Rimuove eventuale blocco del giorno e inserisce il nuovo in cima alla sezione digest."""
pattern = re.compile(
rf"<!--\s*daily-digest:{day.isoformat()}\s*-->.*?"
rf"(?=<!--\s*daily-digest:\d{{4}}-\d{{2}}-\d{{2}}\s*-->|<!--\s*END DAILY DIGESTS\s*-->|\Z)",
re.DOTALL,
)
context_md = pattern.sub("", context_md)
begin = "<!-- BEGIN DAILY DIGESTS -->"
end = "<!-- END DAILY DIGESTS -->"
if begin not in context_md:
context_md = context_md.rstrip() + f"\n\n{begin}\n\n{end}\n"
# Assicura END
if end not in context_md:
context_md = context_md.rstrip() + f"\n\n{end}\n"
head, rest = context_md.split(begin, 1)
# rest inizia dopo BEGIN; togli END temporaneamente dalla porzione digest
if end in rest:
mid, tail = rest.split(end, 1)
else:
mid, tail = rest, ""
mid = mid.strip()
new_mid = block.strip() + ("\n\n" + mid if mid else "")
return head.rstrip() + f"\n\n{begin}\n\n{new_mid}\n\n{end}" + tail
def has_daily_block(context_md: str, day: date) -> bool:
return f"<!-- daily-digest:{day.isoformat()} -->" in context_md
def main() -> int:
parser = argparse.ArgumentParser(description="Digest giornaliero agenti → MCP context")
parser.add_argument("--date", help="YYYY-MM-DD (default: oggi)")
parser.add_argument("--user", default="daniele")
parser.add_argument("--project", default="homelab-loogle")
parser.add_argument("--force", action="store_true", help="Sostituisci blocco del giorno se già presente")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--no-index", action="store_true", help="Non aggiornare Qdrant ctx_*")
parser.add_argument("--max-chats", type=int, default=20)
parser.add_argument(
"--no-plans",
action="store_true",
help="Non sincronizzare ~/.cursor/plans",
)
args = parser.parse_args()
_load_dotenv()
_configure_paths()
day = _parse_day(args.date)
proj = Path(os.environ["MCP_CONTEXT_ROOT"]) / args.user / "projects" / args.project
plans_dest = proj / "artifacts" / "plans"
plans_info: dict = {"plans": [], "copied": 0, "updated_today": [], "dest": str(plans_dest)}
if not args.no_plans:
plans_info = collect_and_sync_plans(
day,
DEFAULT_PLANS_DIR,
plans_dest,
dry_run=args.dry_run,
)
chats = collect_transcripts(day, DEFAULT_TRANSCRIPT_ROOTS, max_chats=args.max_chats)
audit = collect_audit(day, Path(os.environ["MCP_DB"]))
git_lines = collect_git(day, DEFAULT_GIT_REPOS)
block = build_markdown(
day,
chats,
audit,
git_lines,
args.project,
plans_updated=plans_info.get("updated_today") or [],
)
if args.dry_run:
print(block)
if plans_info.get("plans"):
print("\n# plans index preview", file=sys.stderr)
for p in plans_info["plans"][:5]:
print(f"# - {p['name']} ({p['file']})", file=sys.stderr)
print(
f"\n# dry-run chats={len(chats)} audit={audit.get('total', 0)} "
f"git={len(git_lines)} plans={len(plans_info.get('plans') or [])} "
f"plans_today={len(plans_info.get('updated_today') or [])} chars={len(block)}",
file=sys.stderr,
)
return 0
from app.context import store as context_store
from app.db import init_db
from app.knowledge import indexer
init_db()
data = context_store.get_project_context(args.user, args.project, session_limit=1, include_gitea=False)
existing = data.get("context_md") or ""
# I piani si sincronizzano sempre; il digest può essere skippato se già presente
existing = upsert_plans_index_section(existing, plans_info.get("plans") or [])
digest_skipped = False
if has_daily_block(existing, day) and not args.force:
digest_skipped = True
new_md = existing
print(f"SKIP digest: {day.isoformat()} già presente (usa --force); plans sync ok")
else:
new_md = upsert_daily_block(existing, day, block)
ctx_path = proj / "context.md"
meta_path = proj / "meta.json"
sessions = proj / "sessions"
sessions.mkdir(parents=True, exist_ok=True)
ctx_path.write_text(new_md, encoding="utf-8")
meta = json.loads(meta_path.read_text(encoding="utf-8"))
meta["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
snap = None
if not digest_skipped:
snap = sessions / f"{day.isoformat()}-daily.md"
snap.write_text(block, encoding="utf-8")
if not args.no_index:
try:
if not digest_skipped:
indexer.index_context_snippet(
args.user,
args.project,
block,
f"Digest {day.isoformat()}{meta.get('title', args.project)}",
snippet_id=f"daily-{day.isoformat()}",
)
# indicizza ogni piano (testo ridotto: name+overview+body troncato)
for plan in plans_info.get("plans") or []:
body = plan["text"]
if len(body) > 12000:
body = body[:12000] + "\n…[troncato]"
indexer.index_context_snippet(
args.user,
args.project,
f"# Piano Cursor: {plan['name']}\n\n{plan['overview']}\n\n{body}",
f"Plan: {plan['name']}",
snippet_id=f"plan-{plan['file']}",
)
print("indexed: ok")
except Exception as exc:
print(f"indexed: skip ({exc})")
print(
json.dumps(
{
"ok": True,
"day": day.isoformat(),
"project": args.project,
"digest_skipped": digest_skipped,
"chats": len(chats),
"audit_calls": audit.get("total", 0),
"git_commits": len(git_lines),
"plans_synced": plans_info.get("copied", 0),
"plans_updated_today": len(plans_info.get("updated_today") or []),
"chars": len(block),
"context_chars": len(new_md),
"session": str(snap) if snap else None,
"plans_dest": plans_info.get("dest"),
},
ensure_ascii=False,
indent=2,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Grant view permissions on non-personal Paperless documents to family users."""
import os
import sys
import httpx
PAPERLESS_URL = os.environ.get("PAPERLESS_URL", "https://docs.loogle.it").rstrip("/")
ADMIN_TOKEN = os.environ.get("PAPERLESS_API_TOKEN_DANIELE") or os.environ.get("PAPERLESS_API_TOKEN", "")
FAMILY_USER_IDS = [4, 5, 6] # lucia, davide, luca
PERSONAL_TAG_NAMES = {"personal", "privato", "private"}
BATCH_SIZE = 50
def main() -> int:
if not ADMIN_TOKEN:
print("Missing PAPERLESS_API_TOKEN_DANIELE", file=sys.stderr)
return 1
headers = {"Authorization": f"Token {ADMIN_TOKEN}", "Content-Type": "application/json"}
tags_resp = httpx.get(f"{PAPERLESS_URL}/api/tags/", headers=headers, timeout=60)
tags_resp.raise_for_status()
personal_tag_ids = {
t["id"]
for t in tags_resp.json().get("results", [])
if (t.get("name") or "").lower() in PERSONAL_TAG_NAMES
}
doc_ids: list[int] = []
page = 1
while True:
resp = httpx.get(
f"{PAPERLESS_URL}/api/documents/",
headers=headers,
params={"page": page, "page_size": 100},
timeout=60,
)
resp.raise_for_status()
data = resp.json()
for doc in data.get("results", []):
if set(doc.get("tags") or []) & personal_tag_ids:
continue
doc_ids.append(doc["id"])
if not data.get("next"):
break
page += 1
print(f"Documenti da condividere (senza tag personali): {len(doc_ids)}")
updated = 0
for i in range(0, len(doc_ids), BATCH_SIZE):
chunk = doc_ids[i : i + BATCH_SIZE]
payload = {
"documents": chunk,
"method": "set_permissions",
"parameters": {
"set_permissions": {
"view": {"users": FAMILY_USER_IDS, "groups": []},
"change": {"users": [], "groups": []},
},
"merge": True,
},
}
resp = httpx.post(
f"{PAPERLESS_URL}/api/documents/bulk_edit/",
headers=headers,
json=payload,
timeout=120,
)
resp.raise_for_status()
updated += len(chunk)
print(f" Aggiornati {updated}/{len(doc_ids)}")
for name, env_key in [
("lucia", "PAPERLESS_API_TOKEN_LUCIA"),
("davide", "PAPERLESS_API_TOKEN_DAVIDE"),
("luca", "PAPERLESS_API_TOKEN_LUCA"),
]:
token = os.environ.get(env_key, "")
if not token:
continue
count = httpx.get(
f"{PAPERLESS_URL}/api/documents/?page_size=1",
headers={"Authorization": f"Token {token}"},
timeout=30,
).json().get("count", 0)
print(f"{name}: documenti visibili = {count}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Indicizzazione Gitea one-shot — eseguire con indexer fermo per evitare OOM."""
from __future__ import annotations
import argparse
import json
import os
import sys
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
def _load_dotenv() -> None:
env_path = os.path.join(ROOT, ".env")
if not os.path.isfile(env_path):
return
with open(env_path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
if key.strip() and key.strip() not in os.environ:
os.environ[key.strip()] = value.strip().strip("'").strip('"')
def main() -> int:
parser = argparse.ArgumentParser(description="Indicizza repo Gitea nel vector store")
parser.add_argument("--repo", help="owner/name (default: tutti i repo configurati)")
parser.add_argument("--user", default="daniele", help="Utente MCP/Gitea token")
parser.add_argument("--max-files", type=int, default=0, help="Limite file per repo (0=config)")
parser.add_argument("--force", action="store_true", help="Re-indicizza anche file invariati")
args = parser.parse_args()
_load_dotenv()
if os.path.isfile("/.dockerenv"):
os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db")
os.environ.setdefault("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db")
from app.db import init_db
from app.knowledge import gitea_indexer
init_db()
max_files = args.max_files or None
if args.repo:
result = gitea_indexer.index_repo(
args.repo,
username=args.user,
force=args.force,
max_files=max_files,
)
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
result = gitea_indexer.index_all(max_files_per_repo=max_files)
print(json.dumps(result, ensure_ascii=False, indent=2))
stats = gitea_indexer.index_stats()
print("stats", json.dumps(stats, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Crea un progetto demo per ogni utente famiglia."""
import sys
sys.path.insert(0, "/srv")
from app.context import store as context_store
USERS = {
"daniele": "Homelab LOOGLE",
"lucia": "Progetti personali",
"davide": "Studio e task",
"luca": "Progetti Luca",
}
for username, title in USERS.items():
root = context_store._user_root(username)
existing = context_store.list_projects(username)
if existing:
print(f"{username}: skip ({len(existing)} progetti)")
continue
meta = context_store.create_project(username, title, tags=["demo"])
context_store.save_context(
username,
meta["id"],
f"Progetto demo iniziale per {username}. Usa save_context per ampliare la memoria agente.",
)
print(f"{username}: creato {meta['id']}")
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Crea repo progetti per davide/luca e progetti Loogle collegati."""
from __future__ import annotations
import os
import sys
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
def _load_dotenv() -> None:
path = os.path.join(ROOT, ".env")
if not os.path.isfile(path):
return
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
os.environ[k.strip()] = v.strip().strip("'").strip('"')
def ensure_repo(user: str, repo_name: str, description: str) -> str:
from app.knowledge import gitea
gitea._tokens_cache = None
full = f"{user}/{repo_name}"
existing = {r["full_name"] for r in gitea.list_repos(username=user)["repos"]}
if full in existing:
print(f"repo_exists: {full}")
return full
created = gitea.create_repo(
repo_name,
username=user,
private=True,
description=description,
auto_init=True,
)
print(f"repo_created: {created['full_name']}")
return created["full_name"]
def ensure_mcp_project(user: str, project_title: str, gitea_repo: str) -> None:
from app.context import store as context_store
projects = {p["id"]: p for p in context_store.list_projects(user, include_archived=True)}
for meta in projects.values():
if meta.get("gitea_repo") == gitea_repo:
print(f"project_linked: {user}/{meta['id']} -> {gitea_repo}")
return
slug = project_title.lower().replace(" ", "-")
if slug in projects:
meta = context_store.link_project_repo(user, slug, gitea_repo=gitea_repo, seed_from_gitea=True)
print(f"project_updated: {user}/{meta['id']} -> {gitea_repo}")
return
meta = context_store.create_project(
user,
project_title,
tags=["gitea", "p4"],
gitea_repo=gitea_repo,
seed_from_gitea=True,
)
print(f"project_created: {user}/{meta['id']} -> {gitea_repo}")
def main() -> int:
_load_dotenv()
os.environ.setdefault("MCP_DB", os.environ.get("MCP_DB", "/data/loogle_mcp.db"))
from app.db import init_db
init_db()
ensure_repo(
"davide",
"progetti",
"Workspace personale — codice e note generate con Loogle MCP",
)
ensure_repo(
"luca",
"progetti",
"Workspace personale — codice e note generate con Loogle MCP",
)
ensure_mcp_project("davide", "Progetti Davide", "davide/progetti")
ensure_mcp_project("luca", "Progetti Luca", "luca/progetti")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Test integrazione Gitea MCP — mock server + opzionale live API."""
from __future__ import annotations
import json
import os
import sys
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
MOCK_TOKEN = "test-gitea-token"
MOCK_PORT = 18799
os.environ["GITEA_URL"] = "https://git.loogle.it"
os.environ["GITEA_API_URL"] = f"http://127.0.0.1:{MOCK_PORT}"
os.environ["GITEA_API_TOKEN_DANIELE"] = MOCK_TOKEN
os.environ["MCP_DB"] = "/tmp/loogle_mcp_test.db"
class GiteaMockHandler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
return
def _auth_ok(self) -> bool:
auth = self.headers.get("Authorization", "")
return auth == f"token {MOCK_TOKEN}"
def _json(self, code: int, payload):
body = json.dumps(payload).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if not self._auth_ok():
self._json(401, {"message": "invalid token"})
return
path = urlparse(self.path).path
qs = parse_qs(urlparse(self.path).query)
if path == "/api/v1/user/repos":
self._json(200, [
{
"full_name": "daniele/rete",
"description": "Infra HA",
"private": True,
"html_url": "https://git.loogle.it/daniele/rete",
"default_branch": "main",
"updated_at": "2026-08-22T10:00:00Z",
}
])
return
if path == "/api/v1/repos/daniele/rete/contents/ha/RUNBOOK-failover.md":
content = "# Failover tier-b\n\nloogle-mcp nel pivot tier-b.\n"
import base64
self._json(
200,
{
"type": "file",
"path": "ha/RUNBOOK-failover.md",
"content": base64.b64encode(content.encode()).decode(),
"encoding": "base64",
"size": len(content),
"sha": "abc123",
"html_url": "https://git.loogle.it/daniele/rete/src/branch/main/ha/RUNBOOK-failover.md",
},
)
return
if path == "/api/v1/search/code":
q = (qs.get("q") or [""])[0]
self._json(
200,
{
"data": [
{
"repository": {"full_name": "daniele/rete"},
"path": "ha/RUNBOOK-failover.md",
"sha": "abc123",
"content": f"...{q}...",
}
]
},
)
return
if path == "/api/v1/repos/daniele/rete/issues":
self._json(
200,
[
{
"number": 1,
"title": "Test issue",
"state": "open",
"user": {"login": "daniele"},
"html_url": "https://git.loogle.it/daniele/rete/issues/1",
"created_at": "2026-08-22T10:00:00Z",
"updated_at": "2026-08-22T10:00:00Z",
"labels": [],
}
],
)
return
if path == "/api/v1/repos/daniele/rete/issues/1":
self._json(
200,
{
"number": 1,
"title": "Test issue",
"state": "open",
"body": "Corpo issue di test",
"user": {"login": "daniele"},
"html_url": "https://git.loogle.it/daniele/rete/issues/1",
"created_at": "2026-08-22T10:00:00Z",
"updated_at": "2026-08-22T10:00:00Z",
"labels": [],
},
)
return
self._json(404, {"message": f"not found: {path}"})
def do_POST(self):
if not self._auth_ok():
self._json(401, {"message": "invalid token"})
return
path = urlparse(self.path).path
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length).decode() or "{}")
if path == "/api/v1/repos/daniele/rete/issues":
self._json(
201,
{
"number": 42,
"title": body.get("title"),
"state": "open",
"html_url": "https://git.loogle.it/daniele/rete/issues/42",
},
)
return
self._json(404, {"message": f"not found: {path}"})
def run_mock_tests() -> None:
from app.db import init_db
from app.knowledge import gitea as gitea_mod
from app.mcp import tools
init_db()
gitea_mod._tokens_cache = None
server = HTTPServer(("127.0.0.1", MOCK_PORT), GiteaMockHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
claims = {"sub": "daniele", "scope": "gitea:read gitea:write"}
repos = tools.call_tool("list_repos", {}, claims)
assert "daniele/rete" in repos["content"][0]["text"]
file_data = tools.call_tool(
"get_file",
{"repo": "daniele/rete", "path": "ha/RUNBOOK-failover.md"},
claims,
)
assert "tier-b" in file_data["content"][0]["text"]
search = tools.call_tool(
"search_code",
{"query": "tier-b", "repo": "daniele/rete"},
claims,
)
assert "RUNBOOK-failover" in search["content"][0]["text"]
issues = tools.call_tool("list_issues", {"repo": "daniele/rete"}, claims)
assert "Test issue" in issues["content"][0]["text"]
issue = tools.call_tool(
"get_issue",
{"repo": "daniele/rete", "number": 1},
claims,
)
assert "Corpo issue" in issue["content"][0]["text"]
created = tools.call_tool(
"create_issue",
{"repo": "daniele/rete", "title": "Da MCP", "body": "Test"},
claims,
)
assert "42" in created["content"][0]["text"]
server.shutdown()
print("OK mock: list_repos, get_file, search_code, list_issues, get_issue, create_issue")
def _load_dotenv() -> None:
env_path = os.path.join(ROOT, ".env")
if not os.path.isfile(env_path):
return
with open(env_path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip("'").strip('"')
if key.startswith("GITEA_"):
os.environ[key] = value
def run_live_tests() -> None:
_load_dotenv()
from app.knowledge import gitea as gitea_mod
from app.mcp import tools
token = os.environ.get("GITEA_API_TOKEN_DANIELE", "").strip()
if not token or token == MOCK_TOKEN:
print("SKIP live: GITEA_API_TOKEN_DANIELE non impostato (token reale in .env)")
return
api_url = gitea_mod.api_base_url()
if str(MOCK_PORT) in api_url:
print("SKIP live: GITEA_API_URL punta al mock")
return
gitea_mod._tokens_cache = None
claims = {"sub": "daniele", "scope": "gitea:read gitea:write"}
repos = gitea_mod.list_repos(username="daniele")
if not repos.get("repos"):
raise RuntimeError("live list_repos: nessun repository")
first = repos["repos"][0]["full_name"]
tools.call_tool("list_repos", {}, claims)
print(f"OK live list_repos: {len(repos['repos'])} repo, primo={first}")
def main() -> int:
run_mock_tests()
run_live_tests()
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Test P4: scrittura Gitea (create repo, create/update file)."""
from __future__ import annotations
import json
import os
import sys
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
def _load_dotenv() -> None:
path = os.path.join(ROOT, ".env")
if not os.path.isfile(path):
return
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
os.environ[k.strip()] = v.strip().strip("'").strip('"')
def main() -> int:
_load_dotenv()
os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db")
from app.db import init_db
from app.knowledge import gitea
from app.mcp import tools
init_db()
gitea._tokens_cache = None
if not os.environ.get("GITEA_API_TOKEN_DANIELE"):
print("ERR: token daniele assente")
return 1
claims = {"sub": "daniele", "scope": "gitea:read gitea:write knowledge:read admin"}
test_repo = "daniele/mcp-p4-test"
test_path = "docs/p4-verify.md"
test_content = "# P4 verify\n\nFile creato da test_gitea_p4.py\n"
# cleanup file if exists from prior run
try:
gitea.get_file(test_repo, test_path, username="daniele")
gitea.create_or_update_file(
test_repo,
test_path,
test_content + "\n(updated)\n",
"test p4 update",
username="daniele",
)
action = "update"
except FileNotFoundError:
# ensure repo exists - use temp repo name under daniele
repos = {r["full_name"] for r in gitea.list_repos(username="daniele")["repos"]}
if test_repo not in repos:
created = tools.call_tool(
"create_gitea_repo",
{
"name": "mcp-p4-test",
"private": True,
"description": "Repo temporaneo test P4",
},
claims,
)
print("create_repo", created["content"][0]["text"][:200])
written = tools.call_tool(
"create_or_update_file",
{
"repo": test_repo,
"path": test_path,
"content": test_content,
"message": "test p4 create",
"reindex": False,
},
claims,
)
payload = json.loads(written["content"][0]["text"])
action = payload.get("action")
print("write", action, payload.get("path"))
read = gitea.get_file(test_repo, test_path, username="daniele")
assert "P4 verify" in read.get("content", ""), read
print("read_ok", read["path"])
for user, repo in (("davide", "davide/progetti"), ("luca", "luca/progetti")):
token_key = f"GITEA_API_TOKEN_{user.upper()}"
if not os.environ.get(token_key):
print(f"SKIP {user}: no token")
continue
gitea._tokens_cache = None
listed = gitea.list_repos(username=user)
names = {r["full_name"] for r in listed["repos"]}
if repo not in names:
raise RuntimeError(f"repo mancante per {user}: {repo}")
print(f"{user}_repo_ok", repo)
print("OK P4: create_or_update_file + repos davide/luca/progetti")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Test P3: RAG semantico su file Gitea indicizzati."""
from __future__ import annotations
import json
import os
import sys
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
def _load_dotenv() -> None:
env_path = os.path.join(ROOT, ".env")
if not os.path.isfile(env_path):
return
with open(env_path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
if key and key not in os.environ:
os.environ[key] = value.strip().strip("'").strip('"')
def _configure_paths() -> str:
in_container = os.path.isfile("/.dockerenv") or (
os.path.isdir("/data") and os.access("/data", os.W_OK)
)
if in_container:
os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db")
os.environ.setdefault("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db")
return "container"
os.environ["MCP_DB"] = "/tmp/loogle_mcp_p3_test.db"
os.environ["MCP_VECTOR_FALLBACK"] = "/tmp/loogle_mcp_p3_vectors.db"
return "host"
def main() -> int:
_load_dotenv()
mode = _configure_paths()
print(f"mode={mode} db={os.environ['MCP_DB']} vector={os.environ['MCP_VECTOR_FALLBACK']}")
from app.db import get_conn, init_db
from app.knowledge import gitea_indexer
from app.mcp import tools
init_db()
if not get_conn().execute(
"SELECT 1 FROM sqlite_master WHERE name='indexed_gitea_files'"
).fetchone():
raise RuntimeError("tabella indexed_gitea_files assente")
if not os.environ.get("GITEA_API_TOKEN_DANIELE", "").strip():
print("SKIP: GITEA_API_TOKEN_DANIELE assente")
return 1
repo = "daniele/rete"
existing = gitea_indexer.list_indexed_files(limit=5, repo=repo)
stats = gitea_indexer.index_stats()
if len(existing) < 1 or stats.get("qdrant_points", 0) < 1:
index_result = gitea_indexer.index_repo(
repo,
username="daniele",
force=True,
max_files=1,
)
print("index", json.dumps(index_result, ensure_ascii=False))
else:
print("index_skip", "already", len(existing), "files")
files = gitea_indexer.list_indexed_files(limit=5, repo=repo)
if not files:
raise RuntimeError("indexed_gitea_files vuota")
print("indexed_sample", [f["path"] for f in files[:3]])
claims = {"sub": "daniele", "scope": "knowledge:read gitea:read admin", "admin": "admin"}
for query in ("failover tier-b", "runbook"):
hits = gitea_indexer.search_gitea_knowledge("daniele", query, limit=5, is_admin=True)
if not hits:
raise RuntimeError(f"search_gitea_knowledge senza risultati per: {query}")
top = hits[0]
print(
f"search_ok[{query}]",
top.get("path") or top.get("title"),
round(float(top.get("score", 0)), 3),
)
tool_out = tools.call_tool(
"search_gitea_knowledge",
{"query": "failover", "limit": 5},
claims,
)
if not json.loads(tool_out["content"][0]["text"]).get("results"):
raise RuntimeError("tool search_gitea_knowledge vuoto")
unified = tools.call_tool(
"search_knowledge",
{"query": "FAILOVER CENSIMENTO documenti servizi", "limit": 12},
claims,
)
unified_payload = json.loads(unified["content"][0]["text"])
results = unified_payload.get("results") or []
sources = set()
for r in results:
if r.get("repo"):
sources.add("gitea")
elif r.get("source"):
sources.add(r["source"])
else:
sources.add("paperless")
print("search_knowledge_sources", sorted(sources))
if "gitea" not in sources:
raise RuntimeError("search_knowledge non include risultati Gitea")
list_tool = tools.call_tool("list_gitea_indexed_files", {"repo": repo, "limit": 5}, claims)
if json.loads(list_tool["content"][0]["text"]).get("count", 0) <= 0:
raise RuntimeError("list_gitea_indexed_files vuoto")
print("OK P3: search + unified + list (reindex opzionale via tool reindex_gitea_repo)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Verifica P5: tool live Loogle Casa + Home Assistant."""
import json
import os
import sys
sys.path.insert(0, "/srv" if os.path.isdir("/srv/app") else os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def main() -> int:
from app.integrations import casa, homeassistant
from app.mcp import tools
user = os.environ.get("MCP_TEST_USER", "daniele")
claims = {
"sub": user,
"scope": "home:read context:read knowledge:read gitea:read admin",
}
errors = []
print("=== P5 integrations ===")
print("casa configured:", casa.is_configured(user))
print("ha configured:", homeassistant.is_configured())
if casa.is_configured(user):
for tool, args in (
("get_home_dashboard", {}),
("get_home_weather", {}),
("get_network_overview", {}),
("get_network_failover_status", {}),
):
try:
out = tools.call_tool(tool, args, claims)
text = out["content"][0]["text"]
data = json.loads(text)
print(f"OK {tool}: keys={list(data.keys())[:8]}")
except Exception as exc:
print(f"FAIL {tool}: {exc}")
errors.append(tool)
else:
errors.append("casa-not-configured")
if homeassistant.is_configured():
try:
cfg = homeassistant.get_config()
print(f"OK ha config: version={cfg.get('version')}")
except Exception as exc:
print(f"FAIL ha config: {exc}")
errors.append("ha-config")
try:
out = tools.call_tool("list_ha_entities", {"domain": "switch", "limit": 5}, claims)
data = json.loads(out["content"][0]["text"])
print(f"OK list_ha_entities: count={len(data.get('entities', []))}")
except Exception as exc:
print(f"FAIL list_ha_entities: {exc}")
errors.append("list_ha_entities")
else:
errors.append("ha-not-configured")
if errors:
print("P5 FAILED:", errors)
return 1
print("P5 OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Verifica P6: tool live Irrigazione + Turni."""
import json
import os
import sys
sys.path.insert(0, "/srv" if os.path.isdir("/srv/app") else os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def main() -> int:
from app.integrations import irrigazione, turni
from app.mcp import tools
user = os.environ.get("MCP_TEST_USER", "daniele")
claims = {
"sub": user,
"scope": "irrigation:read turni:read home:read admin",
}
errors = []
print("=== P6 integrations ===")
print("irrigazione configured:", irrigazione.is_configured(user))
print("turni configured:", turni.is_configured(user))
if irrigazione.is_configured(user):
for tool, args in (
("get_irrigation_status", {}),
("get_irrigation_zones", {}),
("get_irrigation_history", {"limit": 5}),
):
try:
out = tools.call_tool(tool, args, claims)
data = json.loads(out["content"][0]["text"])
print(f"OK {tool}: type={type(data).__name__}")
except Exception as exc:
print(f"FAIL {tool}: {exc}")
errors.append(tool)
else:
errors.append("irrigazione-not-configured")
try:
out = tools.call_tool("get_turni_status", {}, claims)
data = json.loads(out["content"][0]["text"])
print(f"OK get_turni_status: build={data.get('buildVersion', '')[:30]}")
except Exception as exc:
print(f"FAIL get_turni_status: {exc}")
errors.append("get_turni_status")
if turni.is_configured(user):
for tool, args in (
("list_turni_doctors", {}),
("get_my_shifts", {"limit": 10}),
):
try:
out = tools.call_tool(tool, args, claims)
data = json.loads(out["content"][0]["text"])
print(f"OK {tool}: keys={list(data.keys())[:6]}")
except Exception as exc:
print(f"FAIL {tool}: {exc}")
errors.append(tool)
else:
errors.append("turni-not-configured")
if errors:
print("P6 FAILED:", errors)
return 1
print("P6 OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Verifica P7: RAG Irrigazione + Turni."""
import json
import os
import sys
sys.path.insert(0, "/srv" if os.path.isdir("/srv/app") else os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def main() -> int:
from app.db import init_db
from app.knowledge import apps_indexer, indexer
from app.mcp import tools
init_db()
user = os.environ.get("MCP_TEST_USER", "daniele")
claims = {
"sub": user,
"scope": "knowledge:read irrigation:read turni:read admin",
}
errors = []
print("=== P7 apps RAG ===")
existing = apps_indexer.list_indexed_records(limit=5)
if len(existing) >= 2:
print(f"skip re-index: {len(existing)} records already present")
result = {"irrigazione": {"indexed": len(existing), "skipped_reindex": True}, "turni": {}}
else:
result = {
"irrigazione": apps_indexer.index_irrigazione(history_limit=0, events_limit=0),
"turni": apps_indexer.index_turni(assignments_limit=3),
}
print("index_all:", json.dumps(result, ensure_ascii=False)[:500])
irr = result.get("irrigazione", {})
turn = result.get("turni", {})
if irr.get("skipped") and turn.get("skipped"):
print("P7 FAILED: both sources skipped")
return 1
if irr.get("indexed", 0) + turn.get("indexed", 0) < 2 and not irr.get("skipped_reindex"):
errors.append("insufficient-indexed-records")
records = apps_indexer.list_indexed_records(limit=10)
print(f"indexed records: {len(records)}")
if not records:
errors.append("no-records")
try:
hits = apps_indexer.search_apps_knowledge("irrigazione zona prato", limit=3)
print(f"search_apps_knowledge irrigazione: {len(hits)} hits")
if not hits:
errors.append("search-irrigazione-empty")
except Exception as exc:
print(f"FAIL search_apps: {exc}")
errors.append("search_apps")
try:
out = tools.call_tool("search_apps_knowledge", {"query": "turno guardia", "limit": 3}, claims)
data = json.loads(out["content"][0]["text"])
print(f"OK tool search_apps_knowledge: {len(data.get('results', []))} hits")
except Exception as exc:
print(f"FAIL tool search_apps_knowledge: {exc}")
errors.append("tool-search")
try:
combined = indexer.search_knowledge(user, "irrigazione valvola", limit=5, is_admin=True)
apps_hits = [h for h in combined if h.get("source") in ("irrigazione", "turni")]
print(f"search_knowledge includes apps: {len(apps_hits)} app hits / {len(combined)} total")
except Exception as exc:
print(f"FAIL search_knowledge: {exc}")
errors.append("search_knowledge")
if errors:
print("P7 FAILED:", errors)
return 1
print("P7 OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
from app.db import init_db
from app.knowledge.apps_indexer import (
_summarize_irrigation_status,
_index_text,
list_indexed_records,
search_apps_knowledge,
)
from app.integrations import irrigazione
init_db()
print("1 db ok")
existing = list_indexed_records(source="irrigazione", limit=1)
if existing and existing[0].get("record_id") == "status-snapshot":
print("2 skip index (status-snapshot exists)")
else:
s = irrigazione.get_status("daniele")
print("3 status ok", len(s))
text = _summarize_irrigation_status(s)
print("4 summary", len(text))
r = _index_text("irrigazione", "status-snapshot", "Stato", text)
print("5 indexed", r)
hits = search_apps_knowledge("irrigazione zona", limit=2)
print("6 hits", len(hits))
print("STEP OK")
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Test P2: collegamento progetti MCP ↔ repository Gitea."""
from __future__ import annotations
import json
import os
import shutil
import sys
import tempfile
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
os.environ["MCP_DB"] = "/tmp/loogle_mcp_p2_test.db"
TEST_ROOT = tempfile.mkdtemp(prefix="mcp-p2-")
def _load_dotenv() -> None:
env_path = os.path.join(ROOT, ".env")
if not os.path.isfile(env_path):
return
with open(env_path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip().strip("'").strip('"'))
def run_unit_tests() -> None:
os.environ["MCP_CONTEXT_ROOT"] = TEST_ROOT
from app.db import init_db
from app.context import store as context_store
from app.mcp import tools
init_db()
claims = {
"sub": "daniele",
"scope": "context:read context:write gitea:read gitea:write",
}
meta = context_store.create_project(
"daniele",
"Progetto unit test",
tags=["test"],
)
meta_path = os.path.join(TEST_ROOT, "daniele", "projects", meta["id"], "meta.json")
meta["gitea_repo"] = "daniele/rete"
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
ctx = tools.call_tool(
"get_project_context",
{"project_id": meta["id"], "include_gitea": False},
claims,
)
payload = json.loads(ctx["content"][0]["text"])
assert payload["meta"]["gitea_repo"] == "daniele/rete"
unlinked = tools.call_tool(
"link_project_repo",
{"project_id": meta["id"], "gitea_repo": ""},
claims,
)
payload = json.loads(unlinked["content"][0]["text"])
assert "gitea_repo" not in payload
print("OK unit: meta gitea_repo + scollegamento locale")
def run_live_tests() -> None:
_load_dotenv()
token = os.environ.get("GITEA_API_TOKEN_DANIELE", "").strip()
if not token:
print("SKIP live: GITEA_API_TOKEN_DANIELE assente")
return
live_root = os.path.join(TEST_ROOT, "live")
os.makedirs(live_root, exist_ok=True)
os.environ["MCP_CONTEXT_ROOT"] = live_root
from app.context import store as context_store
from app.mcp import tools
from app.knowledge import gitea
gitea._tokens_cache = None
claims = {
"sub": "daniele",
"scope": "context:read context:write gitea:read gitea:write",
}
project_id = "rete-ha-p2-test"
proj_dir = os.path.join(live_root, "daniele", "projects", project_id)
if os.path.isdir(proj_dir):
shutil.rmtree(proj_dir)
created = tools.call_tool(
"create_project",
{
"title": "Rete HA P2 test",
"tags": ["infra", "test"],
"gitea_repo": "daniele/rete",
"seed_from_gitea": True,
},
claims,
)
payload = json.loads(created["content"][0]["text"])
project_id = payload["id"]
assert payload.get("gitea_repo") == "daniele/rete"
assert payload.get("gitea_seeded_at")
ctx_path = os.path.join(live_root, "daniele", "projects", project_id, "context.md")
context_md = open(ctx_path, encoding="utf-8").read()
assert "seed:gitea daniele/rete" in context_md
assert len(context_md) > 200
enriched = tools.call_tool(
"get_project_context",
{"project_id": project_id, "include_gitea": True, "session_limit": 0},
claims,
)
data = json.loads(enriched["content"][0]["text"])
assert data["meta"]["gitea_repo"] == "daniele/rete"
assert data["gitea"]["available"] is True
assert data["gitea"]["readme"] is not None
assert isinstance(data["gitea"].get("docs_files"), list)
link_existing = tools.call_tool(
"link_project_repo",
{"project_id": project_id, "gitea_repo": "daniele/rete", "seed_from_gitea": True},
claims,
)
meta2 = json.loads(link_existing["content"][0]["text"])
assert "gitea_seeded_at" in meta2
shutil.rmtree(proj_dir)
print(f"OK live: create+seed+enrichment su daniele/rete (project {project_id})")
def main() -> int:
try:
run_unit_tests()
run_live_tests()
return 0
finally:
shutil.rmtree(TEST_ROOT, ignore_errors=True)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
import json, os, sys
os.environ.setdefault('MCP_DB','/data/loogle_mcp.db')
os.environ.setdefault('MCP_VECTOR_FALLBACK','/data/vector_fallback.db')
from app.db import init_db, get_conn
from app.knowledge import gitea_indexer
from app.mcp import tools
init_db()
assert get_conn().execute("SELECT 1 FROM sqlite_master WHERE name='indexed_gitea_files'").fetchone()
repo='daniele/rete'
for path in ['ha/FAILOVER-VOLUMES.md','ha/RUNBOOK-failover.md']:
r=gitea_indexer.index_file(repo, path, username='daniele', private=True, force=True)
print('indexed', path, r.get('chunks'), r.get('skipped'), flush=True)
files=gitea_indexer.list_indexed_files(limit=10, repo=repo)
print('db_files', [f['path'] for f in files], flush=True)
for q in ('failover tier-b','runbook'):
hits=gitea_indexer.search_gitea_knowledge('daniele', q, limit=3, is_admin=True)
print('search', q, hits[0]['path'] if hits else None, round(float(hits[0]['score']),3) if hits else None, flush=True)
if not hits: sys.exit(1)
claims={'sub':'daniele','scope':'knowledge:read gitea:read admin'}
for tool,args in [
('search_gitea_knowledge',{'query':'failover tier-b','limit':5}),
('list_gitea_indexed_files',{'repo':repo,'limit':10}),
('reindex_gitea_repo',{'repo':repo,'max_files':1}),
('search_knowledge',{'query':'failover keepalived','limit':8}),
]:
out=tools.call_tool(tool,args,claims)
p=json.loads(out['content'][0]['text'])
if tool=='search_knowledge':
src=sorted({r.get('source') for r in p['results']})
print(tool, src, flush=True)
assert 'gitea' in src
elif tool=='list_gitea_indexed_files':
print(tool, p['count'], flush=True)
else:
print(tool, 'ok', flush=True)
print('OK P3 COMPLETE', flush=True)
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
import json, os, sys
os.environ.setdefault('MCP_DB','/data/loogle_mcp.db')
os.environ.setdefault('MCP_VECTOR_FALLBACK','/data/vector_fallback.db')
from app.db import init_db, get_conn
from app.knowledge import gitea_indexer
from app.mcp import tools
init_db()
print('table_ok', bool(get_conn().execute("SELECT 1 FROM sqlite_master WHERE name='indexed_gitea_files'").fetchone()), flush=True)
files=gitea_indexer.list_indexed_files(limit=10, repo='daniele/rete')
print('indexed', [f['path'] for f in files], flush=True)
if not files:
print('WARN: nessun file indicizzato — indicizzo stacks.conf (piccolo)', flush=True)
r=gitea_indexer.index_file('daniele/rete','ha/stacks.conf', username='daniele', private=True, force=True)
print('new_index', r, flush=True)
files=gitea_indexer.list_indexed_files(limit=10, repo='daniele/rete')
for q in ('failover','censimento','tier-b'):
hits=gitea_indexer.search_gitea_knowledge('daniele', q, limit=3, is_admin=True)
print('search', q, hits[0]['path'] if hits else 'NONE', round(float(hits[0]['score']),3) if hits else None, flush=True)
if not hits and q=='failover':
sys.exit(1)
claims={'sub':'daniele','scope':'knowledge:read gitea:read admin'}
sg=tools.call_tool('search_gitea_knowledge',{'query':'failover tier-b','limit':5},claims)
assert json.loads(sg['content'][0]['text'])['results']
print('tool search_gitea_knowledge ok', flush=True)
li=tools.call_tool('list_gitea_indexed_files',{'repo':'daniele/rete','limit':10},claims)
print('tool list', json.loads(li['content'][0]['text'])['count'], flush=True)
ri=tools.call_tool('reindex_gitea_repo',{'repo':'daniele/rete','max_files':1},claims)
print('tool reindex', json.loads(ri['content'][0]['text']).get('files_indexed'), flush=True)
sk=tools.call_tool('search_knowledge',{'query':'failover keepalived','limit':8},claims)
src=sorted({r.get('source') for r in json.loads(sk['content'][0]['text'])['results']})
print('tool search_knowledge sources', src, flush=True)
assert 'gitea' in src
print('OK P3 SEARCH VERIFIED', flush=True)
@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
"""Background indexer worker — cicli separati Paperless / Gitea / Apps."""
import logging
import os
import time
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
LOGGER = logging.getLogger("loogle_mcp.worker")
from app.db import init_db # noqa: E402
from app.knowledge import apps_indexer, gitea_indexer, indexer # noqa: E402
def _run_gitea() -> None:
if os.environ.get("GITEA_INDEX_ENABLED", "yes").strip().lower() in ("0", "false", "no", "off"):
LOGGER.info("Indicizzazione Gitea disabilitata (GITEA_INDEX_ENABLED)")
return
gitea_result = gitea_indexer.index_all()
stats = gitea_indexer.index_stats()
LOGGER.info("Indicizzazione Gitea completata: %s stats=%s", gitea_result, stats)
def _run_paperless() -> None:
result = indexer.index_all(max_pages=10)
LOGGER.info("Indicizzazione Paperless completata: %s", result)
def _run_apps() -> None:
apps_result = apps_indexer.index_all()
LOGGER.info("Indicizzazione Apps (P7) completata: %s", apps_result)
def main() -> None:
init_db()
interval = int(os.environ.get("INDEXER_INTERVAL_MINUTES", "30")) * 60
LOGGER.info("Indexer avviato, intervallo %ds", interval)
from app.knowledge import thermal # noqa: WPS433
status = thermal.read_status()
if status:
LOGGER.info(
"Thermal probe OK: temp=%.1f°C load1=%.2f nproc=%s source=%s (hard=%.0f°C soft=%.0f°C cpu_target=%.0f%%)",
float(status.get("cpu_temp_c") or 0),
float(status.get("load1") or 0),
status.get("nproc"),
status.get("source"),
thermal.hard_temp_c(),
thermal.soft_temp_c(),
thermal.cpu_target_pct(),
)
else:
LOGGER.warning("Thermal probe non raggiungibile all'avvio — gate userà delay conservativi")
# Gitea per primo: repo piccoli, non bloccato da Paperless lento
try:
thermal.wait_for_headroom(context="cycle:gitea-boot")
_run_gitea()
except Exception as exc:
LOGGER.error("Indicizzazione Gitea fallita: %s", exc)
while True:
try:
thermal.wait_for_headroom(context="cycle:gitea")
_run_gitea()
except Exception as exc:
LOGGER.error("Indicizzazione Gitea fallita: %s", exc)
try:
thermal.wait_for_headroom(context="cycle:paperless")
_run_paperless()
except Exception as exc:
LOGGER.error("Indicizzazione Paperless fallita: %s", exc)
try:
thermal.wait_for_headroom(context="cycle:apps")
_run_apps()
except Exception as exc:
LOGGER.error("Indicizzazione Apps fallita: %s", exc)
time.sleep(interval)
if __name__ == "__main__":
main()
+14 -4
View File
@@ -337,7 +337,7 @@ def load_state() -> Dict:
return default
def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = None, last_notification_utc: Optional[str] = None) -> None:
def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = None, last_notification_utc: Optional[str] = None, summary: Optional[str] = None) -> None:
try:
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
state_data = {
@@ -354,6 +354,8 @@ def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = N
"casa_first_thr_time": casa_data.get("first_thr_time", ""),
"casa_duration_hours": casa_data.get("duration_hours", 0.0),
})
if summary:
state_data["summary"] = summary[:3000]
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state_data, f, ensure_ascii=False, indent=2)
except Exception as e:
@@ -1694,7 +1696,15 @@ def analyze_snow(chat_ids: Optional[List[str]] = None, debug_mode: bool = False)
msg.append("<i>Fonte dati: Open-Meteo</i>")
# Unisci con <br> (sarà convertito in \n in telegram_send_html)
ok = telegram_send_html("<br>".join(msg), chat_ids=chat_ids)
html_msg = "<br>".join(msg)
ok = telegram_send_html(html_msg, chat_ids=chat_ids)
snow_summary = None
try:
from webapp_alert import publish_web_alert, message_to_plain
snow_summary = message_to_plain(html_msg, is_html=True)
publish_web_alert(html_msg, "snow", "warning", is_html=True, title="Allerta neve")
except Exception as e:
LOGGER.debug("Web summary failed: %s", e)
# Genera e invia grafico (solo se abbiamo dati per Casa)
chart_generated = False
@@ -1731,11 +1741,11 @@ def analyze_snow(chat_ids: Optional[List[str]] = None, debug_mode: bool = False)
# Salva timestamp dell'ultima notifica
now_utc = datetime.datetime.now(datetime.timezone.utc)
save_state(True, sig, casa_data, last_notification_utc=now_utc.isoformat(timespec="seconds"))
save_state(True, sig, casa_data, last_notification_utc=now_utc.isoformat(timespec="seconds"), summary=snow_summary)
else:
LOGGER.warning("Notifica neve NON inviata (token mancante o errore Telegram).")
# Salva comunque lo state (senza aggiornare last_notification_utc)
save_state(True, sig, casa_data, last_notification_utc=state.get("last_notification_utc", ""))
save_state(True, sig, casa_data, last_notification_utc=state.get("last_notification_utc", ""), summary=snow_summary)
else:
LOGGER.info("Allerta attiva ma nessun cambiamento significativo. Motivo: %s", change_reason)
# Salva lo state anche se non inviamo (per mantenere alert_active e last_notification_utc)
+23 -1
View File
@@ -758,6 +758,14 @@ async def meteo_viaggio_command(update: Update, context: ContextTypes.DEFAULT_TY
async def scheduled_morning_report(context: ContextTypes.DEFAULT_TYPE) -> None:
# Stesso comportamento di `/meteo` senza argomenti: Casa (+ viaggio se attivo) per utente.
try:
from telegram_gate import telegram_alerts_enabled
if not telegram_alerts_enabled():
logger.info("Report meteo mattutino sospeso (LOOGLE_TELEGRAM_ALERTS=0).")
return
except Exception:
pass
report_casa = call_meteo_script(["--home"])
for uid in ALLOWED_IDS:
chat_id = str(uid)
@@ -892,7 +900,21 @@ def main():
application.add_handler(CallbackQueryHandler(button_handler))
job_queue = application.job_queue
job_queue.run_daily(scheduled_morning_report, time=datetime.time(hour=7, minute=15, tzinfo=TZINFO), days=(0, 1, 2, 3, 4, 5, 6))
# Report meteo quotidiano 07:15 — sospeso se LOOGLE_TELEGRAM_ALERTS=0 (cron-alerts.env)
try:
from telegram_gate import telegram_alerts_enabled
morning_enabled = telegram_alerts_enabled()
except Exception:
morning_enabled = True
if morning_enabled:
job_queue.run_daily(
scheduled_morning_report,
time=datetime.time(hour=7, minute=15, tzinfo=TZINFO),
days=(0, 1, 2, 3, 4, 5, 6),
)
logger.info("Job scheduled_morning_report attivo (07:15).")
else:
logger.info("Job scheduled_morning_report NON registrato (Telegram sospeso).")
application.run_polling()
+40 -11
View File
@@ -78,7 +78,7 @@ def get_bot_token():
sys.exit(1)
def save_current_state(state, report_meta=None):
def save_current_state(state, report_meta=None, summary=None):
try:
# Aggiungi timestamp corrente per tracciare quando è stato salvato lo stato
if report_meta is None:
@@ -87,7 +87,19 @@ def save_current_state(state, report_meta=None):
"points": state,
"last_update": datetime.datetime.now().isoformat(),
"report_meta": report_meta,
"alert_active": any(int(v or 0) > 0 for v in (state or {}).values()),
}
if summary:
state_with_meta["summary"] = str(summary)[:3000]
else:
# Mantieni l'ultimo summary Telegram se non ci sono nuovi aggiornamenti
try:
with open(STATE_FILE, "r") as rf:
prev = json.load(rf)
if isinstance(prev, dict) and prev.get("summary"):
state_with_meta["summary"] = str(prev["summary"])[:3000]
except Exception:
pass
with open(STATE_FILE, 'w') as f:
json.dump(state_with_meta, f)
except Exception as e:
@@ -2251,7 +2263,26 @@ def main():
append_report(new_alerts, improvement_msg, important, report_meta, DEBUG_MODE)
# Genera e invia mappa solo quando ci sono aggiornamenti
ice_summary = None
if new_alerts or solved_alerts:
parts = []
if new_alerts:
parts.append("Aggiornamenti rischio:\n" + "\n\n".join(new_alerts[:12]))
if solved_alerts:
parts.append("Rientri:\n" + "\n".join(solved_alerts[:8]))
ice_summary = "\n\n".join(parts)
try:
from webapp_alert import publish_web_alert
publish_web_alert(
ice_summary,
"ghiaccio",
"warning",
is_html=True,
title="Rischio ghiaccio stradale",
)
except Exception as e:
if DEBUG_MODE:
print(f"Web summary failed: {e}")
if DEBUG_MODE:
print(f"Generazione mappa per {len(map_points_data)} punti...")
map_path = os.path.join(SCRIPT_DIR, "ice_risk_map.png")
@@ -2265,18 +2296,16 @@ def main():
f"🕒 {now.strftime('%d/%m/%Y %H:%M')}\n"
f"📊 Punti monitorati: {len(map_points_data)}"
)
# Invia anche i report testuali (allineati a Telegram/WebApp)
for block in (new_alerts + solved_alerts)[:15]:
try:
send_telegram_broadcast(token, block, debug_mode=DEBUG_MODE)
except Exception:
pass
photo_sent = send_telegram_photo(token, map_path, caption, debug_mode=DEBUG_MODE)
if DEBUG_MODE:
print(f"Mappa inviata via Telegram: {photo_sent}")
# Pulisci file temporaneo solo se non in debug mode (per permettere verifica)
if not DEBUG_MODE:
try:
if os.path.exists(map_path):
os.remove(map_path)
except Exception:
pass
elif DEBUG_MODE:
print(f"File mappa mantenuto per debug: {map_path}")
# Mantieni la mappa per la WebApp (non cancellare)
print("Mappa inviata.")
else:
if DEBUG_MODE:
@@ -2288,7 +2317,7 @@ def main():
print("Nessuna variazione.")
if not DEBUG_MODE:
save_current_state(current_state, report_meta=report_meta)
save_current_state(current_state, report_meta=report_meta, summary=ice_summary)
if __name__ == "__main__":
main()
+61 -22
View File
@@ -52,6 +52,12 @@ TARGET_ZONES = {
"EMR-D1": "Pianura bolognese",
}
# Annotazioni territoriali (San Marino adotta il sistema Emilia-Romagna)
ZONE_NOTES = {
"Alta collina romagnola": "include Repubblica di San Marino",
"Pianura romagnola": "area adiacente a San Marino",
}
# Mappa codice zona regionale Arpae -> nome leggibile (deriva da TARGET_ZONES,
# togliendo il prefisso "EMR-": es. EMR-D1 -> D1 "Pianura bolognese").
REGIONAL_TARGET_ZONES = {code.split("-")[-1]: name for code, name in TARGET_ZONES.items()}
@@ -177,15 +183,12 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
message_html = re.sub(r"<\s*br\s*/?\s*>", "\n", message_html, flags=re.IGNORECASE)
try:
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
mirror_alert_to_web = lambda *a, **k: False # type: ignore
if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip civil_protection")
if message_html:
mirror_alert_to_web(message_html, "civil_protection", "warning", is_html=True)
return False
token = load_bot_token()
@@ -219,15 +222,6 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
except Exception as e:
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
if sent_ok:
try:
import sys
sys.path.insert(0, "/home/daniely/docker/shared")
from loogle_core.alert_dispatcher import mirror_to_web
mirror_to_web(message_html, "civil_protection", "warning", is_html=True)
except Exception:
pass
return sent_ok
def load_state() -> dict:
@@ -441,7 +435,9 @@ def format_message(parsed: dict) -> str:
lines.append(f"📅 <b>{html_lib.escape(day.get('date_label',''))}</b>")
for zone in sorted(alerts.keys()):
lines.append(f"📍 <b>{html_lib.escape(zone)}</b>")
note = ZONE_NOTES.get(zone)
zlabel = f"{zone} · {note}" if note else zone
lines.append(f"📍 <b>{html_lib.escape(zlabel)}</b>")
for entry in alerts[zone]:
lines.append(html_lib.escape(entry))
lines.append("")
@@ -452,7 +448,9 @@ def format_message(parsed: dict) -> str:
lines.append(f"🗺️ <b>{html_lib.escape(titolo)}</b>")
alerts = doc.get("alerts", {})
for zone in sorted(alerts.keys()):
lines.append(f"📍 <b>{html_lib.escape(zone)}</b>")
note = ZONE_NOTES.get(zone)
zlabel = f"{zone} · {note}" if note else zone
lines.append(f"📍 <b>{html_lib.escape(zlabel)}</b>")
for entry in alerts[zone]:
lines.append(html_lib.escape(entry))
lines.append("")
@@ -462,6 +460,18 @@ def format_message(parsed: dict) -> str:
lines.append("<i>Fonte: mappe.protezionecivile.gov.it</i>")
return "\n".join(lines)
def _web_body_without_header(plain: str) -> str:
"""Rimuove la riga titolo 'PROTEZIONE CIVILE…' già usata come title WebApp."""
lines = (plain or "").splitlines()
if not lines:
return ""
first = lines[0].strip().upper()
if "PROTEZIONE CIVILE" in first:
return "\n".join(lines[1:]).strip()
return plain.strip()
# =============================================================================
# Main
# =============================================================================
@@ -516,21 +526,50 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False):
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato")
elif sig == last_sig:
LOGGER.info("Allerta già notificata e invariata. Nessuna nuova notifica.")
# Aggiorna solo la data di calendario così Casa non tratta il bollettino
# come «di ieri» mentre le allerte regionali valgono ancora per oggi.
today = today_str_italy()
if state.get("date") != today:
state = dict(state)
state["date"] = today
save_state(state)
LOGGER.info("Stato DPC: date aggiornata a %s (firma invariata).", today)
return
# A questo punto: ci sono allerte e sono nuove -> prova invio
msg = format_message(parsed)
sent_ok = telegram_send_html(msg, chat_ids=chat_ids)
if sent_ok:
LOGGER.info("Notifica allerta inviata con successo.")
save_state({
web_ok = False
st = {
"date": today_str_italy(),
"last_alert_signature": sig,
})
}
try:
from webapp_alert import publish_web_alert, remember_summary, message_to_plain
plain = message_to_plain(msg, is_html=True)
card_body = _web_body_without_header(plain)
remember_summary(st, card_body)
web_ok = bool(
publish_web_alert(
card_body,
"civil_protection",
"warning",
is_html=False,
title="Allerta Protezione Civile / Arpae",
state=st,
)
)
except Exception as e:
LOGGER.debug("Web summary failed: %s", e)
if sent_ok or web_ok:
LOGGER.info(
"Notifica allerta consegnata (%s).",
"Telegram+WebApp" if sent_ok and web_ok else ("Telegram" if sent_ok else "WebApp"),
)
save_state(st)
else:
# Non aggiorniamo lo stato: quando risolvi token/rete, reinvierà.
LOGGER.warning("Invio non riuscito (token mancante o errore Telegram). Stato NON aggiornato.")
LOGGER.warning("Invio non riuscito (Telegram/WebApp). Stato NON aggiornato.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Civil protection alert")
+3 -1
View File
@@ -1,2 +1,4 @@
# 0 = sospende Telegram per script meteo/speedtest (WebApp invariata dove configurata)
# 0 = sospende Telegram per script meteo/speedtest (canale primario: WebApp Casa)
# Le variabili LOOGLE_INTERNAL_TOKEN / LOOGLE_CASA_URL / LOOGLE_NOTIFY_MODE
# vengono caricate automaticamente da /home/daniely/docker/loogle-casa/.env
LOOGLE_TELEGRAM_ALERTS=0
+21 -1
View File
@@ -507,6 +507,13 @@ def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = Fals
msg = "".join(msg_parts)
ok = telegram_send_html(msg, chat_ids=chat_ids)
web_ok = False
try:
from webapp_alert import publish_web_alert
web_ok = bool(publish_web_alert(msg, "freeze", "warning", is_html=True, state=state,
title="Allerta gelo"))
except Exception as e:
LOGGER.debug("Web summary failed: %s", e)
if ok:
LOGGER.info("Allerta gelo inviata. Tmin=%.1f°C at %s, nuove fasce: %d",
min_temp_val, min_temp_time.isoformat(), len(new_periods))
@@ -516,8 +523,15 @@ def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = Fals
"start": start.isoformat(),
"end": end.isoformat(),
})
elif web_ok:
LOGGER.info("Allerta gelo pubblicata su WebApp. Tmin=%.1f°C", min_temp_val)
for start, end in new_periods:
notified_periods.append({
"start": start.isoformat(),
"end": end.isoformat(),
})
else:
LOGGER.warning("Allerta gelo NON inviata (token mancante o errore Telegram).")
LOGGER.warning("Allerta gelo NON consegnata (Telegram/WebApp).")
else:
LOGGER.info("Gelo già notificato (nessuna nuova fascia oraria, peggioramento < 2°C). Tmin=%.1f°C", min_temp_val)
@@ -528,6 +542,12 @@ def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = Fals
"min_time": min_temp_time.isoformat(),
"notified_periods": notified_periods,
})
if not state.get("summary"):
state["summary"] = (
f"Allerta gelo a {LOCATION_NAME}.\n"
f"Minima prevista {min_temp_val:.1f}°C alle {fmt_dt(min_temp_time)} "
f"(prossime {HOURS_AHEAD}h)."
)
save_state(state)
return
+42 -1
View File
@@ -19,6 +19,7 @@ EXCLUDED_FILES = {
"irrigation_cron.log",
"road_weather.log",
"snow_radar.log",
"nowcast_120m_alert.log", # sostituito da meteo-alert (ADR-019, disabilitato 2026-07-25)
}
SEASONAL_EXCLUDED_FILES = {
"freeze_alert.log",
@@ -44,9 +45,45 @@ CATEGORIES = {
"telegram_error": re.compile(r"Telegram error|Bad Request|chat not found|can't parse entities", re.IGNORECASE),
"traceback": re.compile(r"Traceback", re.IGNORECASE),
"exception": re.compile(r"\bERROR\b|Exception", re.IGNORECASE),
"token_missing": re.compile(r"token missing|Token Telegram assente", re.IGNORECASE),
# Solo token realmente assente (non i messaggi ambigui con Telegram sospeso)
"token_missing": re.compile(
r"Telegram token missing|Token Telegram assente|Token Telegram mancante",
re.IGNORECASE,
),
}
# Comportamento atteso con LOOGLE_TELEGRAM_ALERTS=0 / canale WebApp: non sono problemi.
IGNORE_ISSUE_PATTERNS = [
re.compile(r"Telegram sospeso", re.IGNORECASE),
re.compile(r"Alert NON inviato \(token missing o errore Telegram\)", re.IGNORECASE),
re.compile(r"NOT sent \(token missing or Telegram error\)", re.IGNORECASE),
re.compile(r"Notifica NON inviata \(token/telegram\)", re.IGNORECASE),
re.compile(r"token missing o errore Telegram", re.IGNORECASE),
re.compile(r"token mancante o errore Telegram", re.IGNORECASE),
re.compile(r"Telegram saltato/sospeso", re.IGNORECASE),
re.compile(r"All-clear Telegram skip/fail", re.IGNORECASE),
re.compile(r"publish_web_alert failed", re.IGNORECASE), # gestito sotto come web_notify se serve
]
def telegram_alerts_suspended() -> bool:
env_path = os.path.join(BASE_DIR, "cron-alerts.env")
try:
with open(env_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line.startswith("LOOGLE_TELEGRAM_ALERTS="):
val = line.split("=", 1)[1].strip().strip('"').strip("'").lower()
return val in ("0", "off", "false", "no", "disabled")
except OSError:
pass
val = os.environ.get("LOOGLE_TELEGRAM_ALERTS", "1").strip().lower()
return val in ("0", "off", "false", "no", "disabled")
def should_ignore_issue_line(line: str) -> bool:
return any(p.search(line) for p in IGNORE_ISSUE_PATTERNS)
def load_text_file(path: str) -> str:
try:
@@ -126,6 +163,8 @@ def analyze_logs(files: List[str], since: datetime.datetime, max_lines: int) ->
last_ts = ts
if not last_ts or last_ts < since:
continue
if should_ignore_issue_line(line):
continue
for cat, regex in CATEGORIES.items():
if regex.search(line):
category_hits[cat].append((last_ts, path, line))
@@ -169,6 +208,8 @@ def format_report(
lines.append(f"🧾 Log Monitor - ultimi {days} giorni")
lines.append(f"Intervallo: {since.strftime('%Y-%m-%d %H:%M')}{now.strftime('%Y-%m-%d %H:%M')}")
lines.append(f"File analizzati: {len(files)}")
if telegram_alerts_suspended():
lines.append("️ Telegram sospeso (LOOGLE_TELEGRAM_ALERTS=0): canale primario WebApp Casa")
lines.append("")
# Sezione log non aggiornati
+15 -16
View File
@@ -17,6 +17,7 @@ from open_meteo_precip import (
CASA_TZ,
daily_precip_from_hourly,
hourly_precip_mm,
hourly_table_should_show,
is_casa,
)
@@ -499,16 +500,15 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
day_date = dt.date()
is_new_day = (current_day is not None and day_date != current_day)
# Determina se mostrare questo timestamp in base alla posizione nelle 48h
# Prime 24h: ogni ora (step=1)
# Dalla 25a alla 48a: ogni 2 ore (step=2)
if hours_from_start < 24:
step = 1 # Prime 24h: dettaglio 1 ora
else:
step = 2 # Dalla 25a alla 48a: dettaglio 2 ore
# Controlla se questo timestamp deve essere mostrato
should_show = (hours_from_start % step == 0)
# Prime 24h: ogni ora. Dalla 25a alla 48a: ogni 2 ore, ma mai nascondere
# un'ora con precipitazione (altrimenti il picco cade sulle ore dispari
# e la tabella 48h mostra 5 mm mentre Meteo7 ha 22.4 mm sul giorno).
Pr_early = get_val(l_prec[idx], 0)
Rain_early = get_val(l_rain[idx], 0)
Showers_early = get_val(l_showers[idx], 0) if idx < len(l_showers) else 0
Code_early = int(get_val(l_code[idx], 0))
Pr_display = hourly_precip_mm(Pr_early, Rain_early, Showers_early)
should_show = hourly_table_should_show(hours_from_start, Pr_display, Code_early)
# Se è un nuovo giorno, chiudi il blocco precedente
if is_new_day and current_block_lines:
@@ -541,12 +541,11 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
elif diff >= 2.5: t_suffix = "H"
t_s = f"{int(round(T))}{t_suffix}"
Pr = get_val(l_prec[idx], 0)
Pr = Pr_early
Sn = get_val(l_snow[idx], 0)
Code = int(get_val(l_code[idx], 0))
Rain = get_val(l_rain[idx], 0)
Showers = get_val(l_showers[idx], 0) if idx < len(l_showers) else 0
Pr_display = hourly_precip_mm(Pr, Rain, Showers)
Code = Code_early
Rain = Rain_early
Showers = Showers_early
# Determina se è neve
is_snowing = Sn > 0 or (Code in [71, 73, 75, 77, 85, 86])
@@ -676,7 +675,7 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
legend = {
"temp": "W=wind chill, H=heat index",
"precip": "G=grandine, Z=ghiacciato, N=neve",
"precip": "G=grandine, Z=ghiacciato, N=neve. Dalla 25ª ora: secco ogni 2h, pioggia sempre visibile",
"cloud": "FOG=nebbia",
"sky": "Icona condizioni (☀️🌧️⛈️…)",
"sx": "☃️ neve · 🧊 ghiaccio · ⚡/🌪️ temporali · 🥵 caldo · ☔️ pioggia · 💨 vento forte",
+31 -18
View File
@@ -1,11 +1,18 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""DEPRECATED 2026-07-25 (ADR-019).
Sostituito da ``meteo-alert imminent`` (layer NWP 0120 in meteo-alert).
Il cron è disabilitato. Per forzare questo script legacy:
FORCE_LEGACY_NOWCAST_120M=1 python3 nowcast_120m_alert.py
"""
import argparse
import datetime
import json
import logging
import os
import sys
import time
from logging.handlers import RotatingFileHandler
from typing import Dict, List, Optional, Tuple
@@ -15,6 +22,14 @@ import requests
from dateutil import parser
from open_meteo_client import open_meteo_get
if os.environ.get("FORCE_LEGACY_NOWCAST_120M", "").strip() != "1":
print(
"DEPRECATED: use `meteo-alert imminent` (ADR-019). "
"Set FORCE_LEGACY_NOWCAST_120M=1 to run this script.",
file=sys.stderr,
)
raise SystemExit(0)
# =========================
# CONFIG
# =========================
@@ -129,14 +144,12 @@ def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -
return False
try:
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
mirror_alert_to_web = lambda *a, **k: False # type: ignore
if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip nowcast_120m")
mirror_alert_to_web(message, "nowcast_120m", "warning", is_html=False)
return False
token = load_bot_token()
@@ -169,15 +182,6 @@ def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -
except Exception as e:
LOGGER.exception("Errore invio Telegram chat_id=%s: %s", chat_id, e)
if ok_any:
try:
import sys
sys.path.insert(0, "/home/daniely/docker/shared")
from loogle_core.alert_dispatcher import mirror_to_web
mirror_to_web(message, "nowcast_120m", "warning", is_html=False)
except Exception:
pass
return ok_any
@@ -991,16 +995,25 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
)
ok = telegram_send_markdown(msg, chat_ids=chat_ids)
if ok:
LOGGER.info("Notifica inviata.")
web_ok = False
try:
from webapp_alert import publish_web_alert
web_ok = bool(publish_web_alert(msg, "nowcast_120m", "warning", is_html=False, state=state))
except Exception as e:
LOGGER.debug("Web summary failed: %s", e)
# Salva state con eventi attivi aggiornati
state["active_events"] = active_events
if ok or web_ok:
state["last_sent_utc"] = now_utc.isoformat(timespec="seconds")
save_state(state)
LOGGER.info("Notifica consegnata (%s).", "Telegram" if ok else "WebApp")
else:
LOGGER.error("Notifica NON inviata (token/telegram).")
# Salva comunque lo state aggiornato
state["active_events"] = active_events
LOGGER.warning("Notifica NON consegnata (Telegram/WebApp).")
try:
from webapp_alert import remember_summary, message_to_plain
remember_summary(state, message_to_plain(msg, is_html=False))
except Exception:
pass
save_state(state)
+70 -1
View File
@@ -41,6 +41,9 @@ ICON_DAILY_VARS = (
PRECIP_HOURLY_KEYS = ("precipitation", "rain", "showers", "snowfall")
PRECIP_DAILY_KEYS = ("precipitation_sum", "rain_sum", "showers_sum", "snowfall_sum", "precipitation_hours")
WMO_PRECIP_CODES = frozenset(
list(range(51, 68)) + list(range(71, 78)) + list(range(80, 83)) + [85, 86, 95, 96, 99]
)
def is_casa(lat: float, lon: float, tol: float = 0.01) -> bool:
@@ -84,6 +87,61 @@ def daily_precip_from_hourly(hourly: Dict) -> Dict[str, float]:
return dict(out)
def daily_component_from_hourly(hourly: Dict, key: str) -> Dict[str, float]:
"""Somma un campo orario (rain, showers, snowfall) per data locale YYYY-MM-DD."""
times = hourly.get("time") or []
arr = hourly.get(key) or []
out: Dict[str, float] = defaultdict(float)
for i, t in enumerate(times):
if not t or i >= len(arr) or arr[i] is None:
continue
try:
out[str(t)[:10]] += float(arr[i])
except (TypeError, ValueError):
continue
return dict(out)
def apply_hourly_daily_precip(daily: Dict, hourly: Dict) -> Dict:
"""Allinea i totali daily alla somma delle ore (stessa metrica della tabella oraria)."""
daily = dict(daily)
times = daily.get("time") or []
mapping = {
"precipitation_sum": daily_precip_from_hourly(hourly),
"rain_sum": daily_component_from_hourly(hourly, "rain"),
"showers_sum": daily_component_from_hourly(hourly, "showers"),
"snowfall_sum": daily_component_from_hourly(hourly, "snowfall"),
}
for key, totals in mapping.items():
arr = list(daily.get(key) or [])
while len(arr) < len(times):
arr.append(None)
for i, t in enumerate(times):
d = str(t)[:10]
if d in totals:
arr[i] = round(totals[d], 2)
daily[key] = arr
return daily
def hourly_table_should_show(hours_from_start: int, precip_mm: float, weathercode: int = 0) -> bool:
"""
Tabella 48h: prime 24 ore tutte; dalle 25 alle 48 ogni 2 ore,
ma un'ora con precipitazione (mm o codice WMO) non viene mai omessa.
"""
if hours_from_start < 24:
return True
if hours_from_start % 2 == 0:
return True
if float(precip_mm or 0) >= 0.1:
return True
try:
code = int(weathercode or 0)
except (TypeError, ValueError):
code = 0
return code in WMO_PRECIP_CODES
def daily_precip_sum(daily: Dict, date_str: str) -> Optional[float]:
times = daily.get("time") or []
arr = daily.get("precipitation_sum") or []
@@ -125,8 +183,19 @@ def fetch_icon_italia(
return None
def _time_key(t) -> str:
if not t:
return ""
return str(t).strip()[:16]
def _index_by_time(section: Dict) -> Dict[str, int]:
return {str(t): i for i, t in enumerate(section.get("time") or [])}
out: Dict[str, int] = {}
for i, t in enumerate(section.get("time") or []):
k = _time_key(t)
if k:
out[k] = i
return out
def overlay_icon_precip_on_hourly(target: Dict, icon_hourly: Dict) -> Dict:
+295 -194
View File
@@ -18,7 +18,7 @@ from open_meteo_precip import (
CASA_LAT,
CASA_LON,
CASA_TZ,
daily_precip_from_hourly,
apply_hourly_daily_precip,
fetch_icon_italia,
hourly_precip_at_index,
hourly_precip_series,
@@ -342,6 +342,38 @@ def _median_or_single(values):
return median(nums)
def _merge_weathercode(values, preferred=None):
"""
Unisce codici WMO categorici con moda (mai mediana: 61+71 66 falso gelicidio).
In pareggio preferisce `preferred` se presente tra i valori, altrimenti il primo.
"""
ints = []
for v in values:
if v is None:
continue
try:
ints.append(int(v))
except (TypeError, ValueError):
pass
if not ints:
return None
if preferred is not None:
try:
preferred = int(preferred)
except (TypeError, ValueError):
preferred = None
counts = {}
for c in ints:
counts[c] = counts.get(c, 0) + 1
max_count = max(counts.values())
modes = [c for c, n in counts.items() if n == max_count]
if len(modes) == 1:
return modes[0]
if preferred is not None and preferred in modes:
return preferred
return ints[0]
# Chiavi solo ICON Italia (precip 02d: niente mediana con AROME HD a San Marino)
HOURLY_KEYS_ICON_ONLY = [
"snow_depth", "showers", "precipitation", "rain", "snowfall",
@@ -349,6 +381,7 @@ HOURLY_KEYS_ICON_ONLY = [
DAILY_KEYS_ICON_ONLY = [
"showers_sum", "precipitation_sum", "rain_sum", "snowfall_sum", "precipitation_hours",
]
PREFERRED_WEATHERCODE_MODEL = "italia_meteo_arpae_icon_2i"
def _merge_hourly_median(hourly_by_model, single_source_keys=None, single_source_model=None):
@@ -397,16 +430,25 @@ def _merge_hourly_median(hourly_by_model, single_source_keys=None, single_source
out[key].append(val)
else:
vals = []
preferred_wc = None
for _m, h in hourly_by_model:
times = h.get("time", []) or []
arr = h.get(key, []) or []
for i, t in enumerate(times):
if _normalize_time_key(str(t)) == ref_k and i < len(arr) and arr[i] is not None:
try:
if key == "weathercode":
vals.append(int(arr[i]))
if _m == PREFERRED_WEATHERCODE_MODEL:
preferred_wc = int(arr[i])
else:
vals.append(float(arr[i]))
except (TypeError, ValueError):
pass
break
if key == "weathercode":
out[key].append(_merge_weathercode(vals, preferred=preferred_wc) if vals else None)
else:
out[key].append(_median_or_single(vals) if vals else None)
n = len(out["time"])
if n > 1:
@@ -461,16 +503,25 @@ def _merge_daily_median(daily_by_model, single_source_keys=None, single_source_m
out[key].append(val)
else:
vals = []
preferred_wc = None
for _m, d in daily_by_model:
times = d.get("time", []) or []
arr = d.get(key, []) or []
for i, t in enumerate(times):
if str(t)[:10] == date_str and i < len(arr) and arr[i] is not None:
try:
if key == "weathercode":
vals.append(int(arr[i]))
if _m == PREFERRED_WEATHERCODE_MODEL:
preferred_wc = int(arr[i])
else:
vals.append(float(arr[i]))
except (TypeError, ValueError):
pass
break
if key == "weathercode":
out[key].append(_merge_weathercode(vals, preferred=preferred_wc) if vals else None)
else:
out[key].append(_median_or_single(vals) if vals else None)
# Ordina cronologicamente (evita buchi nel report se l'unione non era ordinata)
n = len(out["time"])
@@ -509,6 +560,7 @@ def merge_multi_model_forecast(models_data, forecast_days=10):
"snowfall": [],
"snow_depth": [],
"rain": [],
"showers": [],
"weathercode": [],
"windspeed_10m": [],
"winddirection_10m": [],
@@ -675,7 +727,7 @@ def format_day_label(day_index: int, daily_time_list, with_relative: bool = True
return f"giorno {day_index + 1}"
def analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10):
"""Analizza trend temperatura per identificare fronti caldi/freddi con dettaglio completo"""
"""Analizza trend di Tmax e Tmin (non la media giornaliera) per identificare cali/rialzi."""
if not daily_temps_max or not daily_temps_min:
return None
@@ -683,93 +735,105 @@ def analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10):
if max_days < 3:
return None
# Filtra valori None e calcola temperature medie giornaliere
avg_temps = []
valid_indices = []
tmax_series = []
tmin_series = []
for i in range(max_days):
t_max = daily_temps_max[i]
t_min = daily_temps_min[i]
if t_max is not None and t_min is not None:
avg_temps.append((float(t_max) + float(t_min)) / 2)
valid_indices.append(i)
tmax_series.append(float(t_max))
tmin_series.append(float(t_min))
else:
avg_temps.append(None)
tmax_series.append(None)
tmin_series.append(None)
if len([t for t in avg_temps if t is not None]) < 3:
valid_max = [t for t in tmax_series if t is not None]
valid_min = [t for t in tmin_series if t is not None]
if len(valid_max) < 3 or len(valid_min) < 3:
return None
# Analizza tendenza generale (prime 3 giorni vs ultimi 3 giorni validi)
valid_temps = [t for t in avg_temps if t is not None]
if len(valid_temps) < 3:
return None
first_max = mean(valid_max[:3])
last_max = mean(valid_max[-3:])
first_min = mean(valid_min[:3])
last_min = mean(valid_min[-3:])
delta_max = last_max - first_max
delta_min = last_min - first_min
# Delta dominante per classificare il tipo (maggiore |Δ|)
if abs(delta_max) >= abs(delta_min):
primary_delta = delta_max
primary_series = "max"
else:
primary_delta = delta_min
primary_series = "min"
first_avg = mean(valid_temps[:3])
last_avg = mean(valid_temps[-3:])
diff = last_avg - first_avg
# Contesto: massime finali ancora elevate → niente retorica "fronte freddo" invernale
warm_context = last_max >= 25.0
trend_type = None
trend_intensity = "moderato"
if diff > 5:
trend_type = "fronte_caldo"
trend_intensity = "forte" if diff > 8 else "moderato"
elif diff > 2:
if primary_delta > 5:
trend_type = "fronte_caldo" if not warm_context or primary_delta > 8 else "riscaldamento"
trend_intensity = "forte" if primary_delta > 8 else "moderato"
elif primary_delta > 2:
trend_type = "riscaldamento"
trend_intensity = "moderato"
elif diff < -5:
elif primary_delta < -5:
if warm_context:
trend_type = "calo_termico"
else:
trend_type = "fronte_freddo"
trend_intensity = "forte" if diff < -8 else "moderato"
elif diff < -2:
trend_intensity = "forte" if primary_delta < -8 else "moderato"
elif primary_delta < -2:
trend_type = "raffreddamento"
trend_intensity = "moderato"
else:
trend_type = "stabile"
# Identifica giorni di cambio significativo
change_days = []
prev_temp = None
for i, temp in enumerate(avg_temps):
if temp is not None:
if prev_temp is not None:
day_diff = temp - prev_temp
if abs(day_diff) > 3: # Cambio significativo (>3°C)
prev_max = prev_min = None
for i in range(max_days):
tm = tmax_series[i]
tn = tmin_series[i]
if tm is not None and tn is not None:
if prev_max is not None and prev_min is not None:
d_max = tm - prev_max
d_min = tn - prev_min
if abs(d_max) > 3:
change_days.append({
"day": i,
"delta": round(day_diff, 1),
"from": round(prev_temp, 1),
"to": round(temp, 1)
"series": "max",
"delta": round(d_max, 1),
"from": round(prev_max, 1),
"to": round(tm, 1),
})
prev_temp = temp
# Analisi per periodi (primi 3 giorni, medio termine, lungo termine)
period_analysis = {}
if len(valid_temps) >= 7:
period_analysis["short_term"] = {
"avg": round(mean(valid_temps[:3]), 1),
"range": round(max(valid_temps[:3]) - min(valid_temps[:3]), 1)
}
mid_start = len(valid_temps) // 3
mid_end = (len(valid_temps) * 2) // 3
period_analysis["mid_term"] = {
"avg": round(mean(valid_temps[mid_start:mid_end]), 1),
"range": round(max(valid_temps[mid_start:mid_end]) - min(valid_temps[mid_start:mid_end]), 1)
}
period_analysis["long_term"] = {
"avg": round(mean(valid_temps[-3:]), 1),
"range": round(max(valid_temps[-3:]) - min(valid_temps[-3:]), 1)
}
if abs(d_min) > 3:
change_days.append({
"day": i,
"series": "min",
"delta": round(d_min, 1),
"from": round(prev_min, 1),
"to": round(tn, 1),
})
prev_max, prev_min = tm, tn
return {
"type": trend_type,
"intensity": trend_intensity,
"delta": round(diff, 1),
"delta": round(primary_delta, 1),
"delta_max": round(delta_max, 1),
"delta_min": round(delta_min, 1),
"primary_series": primary_series,
"warm_context": warm_context,
"change_days": change_days,
"first_avg": round(first_avg, 1),
"last_avg": round(last_avg, 1),
"period_analysis": period_analysis,
"daily_avg_temps": avg_temps,
"first_max": round(first_max, 1),
"last_max": round(last_max, 1),
"first_min": round(first_min, 1),
"last_min": round(last_min, 1),
# Compatibilità chiavi legacy (usate da generate_practical_advice)
"first_avg": round((first_max + first_min) / 2, 1),
"last_avg": round((last_max + last_min) / 2, 1),
"daily_max": daily_temps_max[:max_days],
"daily_min": daily_temps_min[:max_days]
"daily_min": daily_temps_min[:max_days],
}
def analyze_weather_transitions(daily_weathercodes):
@@ -807,13 +871,18 @@ def analyze_weather_transitions(daily_weathercodes):
return transitions
def get_precip_type(code):
"""Definisce il tipo di precipitazione in base al codice WMO."""
if (71 <= code <= 77) or code in [85, 86]:
def get_precip_type(code, temp=None):
"""Definisce il tipo di precipitazione in base al codice WMO (gate termico per neve/gelicidio)."""
try:
code = int(code) if code is not None else 0
except (TypeError, ValueError):
code = 0
cold_enough = temp is None or float(temp) <= 1.0
if ((71 <= code <= 77) or code in [85, 86]) and cold_enough:
return "❄️ Neve"
if code in [96, 99]:
return "⚡🌨 Grandine"
if code in [66, 67]:
if code in [66, 67] and cold_enough:
return "🧊☔ Pioggia Congelantesi"
return "☔ Pioggia"
@@ -824,6 +893,40 @@ def get_intensity_label(mm_h):
return "Moderata"
return "Forte ⚠️"
def _add_one_hour_hhmm(hhmm):
"""Somma 1 ora a una stringa HH:MM (mod 24)."""
try:
h, m = hhmm.split(":")
return f"{(int(h) + 1) % 24:02d}:{int(m):02d}"
except (ValueError, AttributeError):
return hhmm
def _format_event_hours(times, start_idx, end_idx_inclusive):
"""
Formato HH:MM-HH:MM per bucket orari Open-Meteo.
Una sola ora 20:00-21:00 (mai 20:00-20:00). Fine = ora successiva all'ultima inclusa.
"""
if not times or start_idx < 0 or start_idx >= len(times):
return "??:??-??:??"
end_idx_inclusive = max(start_idx, min(end_idx_inclusive, len(times) - 1))
start_time = times[start_idx].split("T")[1][:5] if "T" in str(times[start_idx]) else str(times[start_idx])[:5]
next_idx = end_idx_inclusive + 1
if next_idx < len(times):
end_time = times[next_idx].split("T")[1][:5] if "T" in str(times[next_idx]) else str(times[next_idx])[:5]
else:
last_t = times[end_idx_inclusive].split("T")[1][:5] if "T" in str(times[end_idx_inclusive]) else str(times[end_idx_inclusive])[:5]
end_time = _add_one_hour_hhmm(last_t)
if start_time == end_time:
end_time = _add_one_hour_hhmm(start_time)
return f"{start_time}-{end_time}"
ICE_EVENT_MAX_AIR_TEMP = 2.0 # scarta pericoli invernali se Tmin blocco > questa soglia
GELICIDIO_MAX_AIR_TEMP = 1.0
def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, snowfalls=None, rains=None, soil_temps=None, cloud_covers=None, wind_speeds=None):
"""Scansiona le 24 ore e trova blocchi di eventi continui."""
events = []
@@ -838,14 +941,13 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
if cloud_covers is None:
cloud_covers = [None] * len(times)
if wind_speeds is None:
wind_speeds = [None] * len(times)
wind_speeds = winds if winds else [None] * len(times)
# Calcola precipitazioni cumulative delle 3h precedenti per ogni punto
# Calcola precipitazioni cumulate nelle 3h precedenti per ogni ora
precip_3h_sum = []
rain_3h_sum = []
snow_3h_sum = []
for i in range(len(times)):
# Somma delle 3 ore precedenti (i-3, i-2, i-1)
start_idx = max(0, i - 3)
precip_sum = sum([float(p) if p is not None else 0.0 for p in precip[start_idx:i]])
rain_sum = sum([float(r) if r is not None else 0.0 for r in rains[start_idx:i]])
@@ -854,7 +956,7 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
rain_3h_sum.append(rain_sum)
snow_3h_sum.append(snow_sum)
# 1. PERICOLI (Ghiaccio, Gelo, Brina) - Logica migliorata allineata a check_ghiaccio.py
# 1. PERICOLI (Ghiaccio, Gelo, Brina) - con gate termici anti falsi positivi estivi
in_ice = False
start_ice = 0
ice_type = ""
@@ -883,7 +985,7 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
try:
hour = int(times[i].split("T")[1].split(":")[0]) if "T" in times[i] else 12
is_night = (hour >= 18) or (hour <= 6)
except:
except Exception:
is_night = False
# Calcola temperatura suolo: usa valore misurato se disponibile, altrimenti stima (1-2°C più fredda)
@@ -891,48 +993,41 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
t_soil = t - 1.5 # Approssimazione conservativa
# Applica raffreddamento radiativo: cielo sereno + notte + vento debole
# Riduce la temperatura del suolo di 0.5-1.5°C (come in check_ghiaccio.py)
t_soil_adjusted = t_soil
if is_night and cloud is not None and cloud < 20.0:
if wind is None or wind < 5.0:
cooling = 1.5 # Vento molto debole = più raffreddamento
cooling = 1.5
elif wind < 10.0:
cooling = 1.0
else:
cooling = 0.5
t_soil_adjusted = t_soil - cooling
# Precipitazioni nelle 3h precedenti
p_3h = precip_3h_sum[i] if i < len(precip_3h_sum) else 0.0
r_3h = rain_3h_sum[i] if i < len(rain_3h_sum) else 0.0
s_3h = snow_3h_sum[i] if i < len(snow_3h_sum) else 0.0
# LOGICA MIGLIORATA (allineata a check_ghiaccio.py):
current_ice_condition = None
# 1. GELICIDIO (Freezing Rain) - priorità massima
# 1. GELICIDIO: codice 66/67 richiede anche T aria <= soglia (evita WMO spurii in estate)
is_raining_code = (50 <= c <= 69) or (80 <= c <= 82)
if c in [66, 67] or (p > 0 and t <= 0 and is_raining_code):
if (c in [66, 67] and t <= GELICIDIO_MAX_AIR_TEMP) or (p > 0 and t <= 0 and is_raining_code):
current_ice_condition = "🧊☠️ GELICIDIO"
# 2. Black Ice o Neve Ghiacciata - Precipitazione nelle 3h precedenti + suolo gelato
elif p_3h > 0.1 and t_soil_adjusted < 0.0:
# Distingue tra neve e pioggia
# 2. Black Ice o Neve Ghiacciata
elif p_3h > 0.1 and t_soil_adjusted < 0.0 and t <= ICE_EVENT_MAX_AIR_TEMP:
has_snow = (s_3h > 0.1) or (snowfall_curr > 0.1)
has_rain = (r_3h > 0.1) or (rain_curr > 0.1)
if has_snow:
current_ice_condition = "⛸️⚠️ Neve ghiacciata (suolo gelato)"
elif has_rain:
current_ice_condition = "⛸️⚠️ Black Ice (strada bagnata + suolo gelato)"
else:
current_ice_condition = "⛸️⚠️ Black Ice (strada bagnata + suolo gelato)"
# 3. BRINA (Hoar Frost) - Suolo <= 0°C e punto di rugiada > suolo ma < 0°C
elif p_3h <= 0.1 and t_soil_adjusted <= 0.0 and d is not None:
# 3. BRINA
elif p_3h <= 0.1 and t_soil_adjusted <= 0.0 and d is not None and t <= ICE_EVENT_MAX_AIR_TEMP:
if d > t_soil_adjusted and d < 0.0:
current_ice_condition = "⛸️⚠️ GHIACCIO/BRINA"
# 4. GELATA - Temperatura aria < 0°C (senza altre condizioni)
# 4. GELATA
elif t < 0:
current_ice_condition = "🧊 Gelata"
@@ -941,28 +1036,51 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
start_ice = i
ice_type = current_ice_condition
elif (not current_ice_condition and in_ice) or (in_ice and current_ice_condition != ice_type) or (in_ice and i == len(times)-1):
end_idx = i if not current_ice_condition else i
if end_idx > start_ice:
start_time = times[start_ice].split("T")[1][:5]
end_time = times[min(end_idx, len(times)-1)].split("T")[1][:5]
temp_block = temps[start_ice:min(end_idx+1, len(temps))]
temp_block_clean = [t for t in temp_block if t is not None]
min_t = min(temp_block_clean) if temp_block_clean else 0
# Per GHIACCIO/BRINA, verifica che la temperatura minima sia effettivamente sotto/sopra soglia critica
# Se la temperatura minima è > 1.5°C, non è un rischio reale
if ice_type == "⛸️⚠️ GHIACCIO/BRINA" and min_t > 1.5:
# Non segnalare se la temperatura minima è troppo alta
pass
if not current_ice_condition and in_ice:
end_inclusive = i - 1
elif in_ice and current_ice_condition and current_ice_condition != ice_type:
end_inclusive = i - 1
else:
events.append(f"{ice_type}: {start_time}-{end_time} (Min: {min_t:.0f}°C)")
end_inclusive = i
if end_inclusive >= start_ice:
hours_str = _format_event_hours(times, start_ice, end_inclusive)
temp_block = temps[start_ice:end_inclusive + 1]
temp_block_clean = [tv for tv in temp_block if tv is not None]
min_t = min(temp_block_clean) if temp_block_clean else 0
# Gate termico su tutti i pericoli invernali (non solo brina)
if min_t <= ICE_EVENT_MAX_AIR_TEMP:
events.append(f"{ice_type}: {hours_str} (Min: {min_t:.0f}°C)")
in_ice = False
if current_ice_condition:
in_ice = True
start_ice = i
ice_type = current_ice_condition
# 2. PRECIPITAZIONI
# 2. PRECIPITAZIONI — tot_mm è sempre la somma del solo blocco orario (mai il totale giorno)
def _precip_type_at(idx):
code_val = codes[idx] if idx < len(codes) and codes[idx] is not None else 0
t_val = temps[idx] if idx < len(temps) and temps[idx] is not None else None
try:
code_val = int(code_val) if code_val is not None else 0
except (ValueError, TypeError):
code_val = 0
return get_precip_type(code_val, temp=t_val)
def _emit_rain(start, end_inclusive, rain_type):
if end_inclusive < start:
return
block_precip = precip[start:end_inclusive + 1]
block_precip_clean = [p for p in block_precip if p is not None]
tot_mm = sum(float(p) for p in block_precip_clean)
if tot_mm <= 0:
return
hours_str = _format_event_hours(times, start, end_inclusive)
avg_intensity = tot_mm / len(block_precip) if block_precip else 0
events.append(
f"{rain_type} ({get_intensity_label(avg_intensity)}):\n"
f" 🕒 {hours_str} | 💧 {tot_mm:.1f}mm"
)
in_rain = False
start_idx = 0
current_rain_type = ""
@@ -970,54 +1088,23 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
for i in range(len(times)):
p_val = precip[i] if i < len(precip) and precip[i] is not None else 0
is_raining = p_val >= MIN_MM_PER_EVENTO
is_last = i == len(times) - 1
if is_raining and not in_rain:
in_rain = True
start_idx = i
code_val = codes[i] if i < len(codes) and codes[i] is not None else 0
try:
code_val = int(code_val) if code_val is not None else 0
except (ValueError, TypeError):
code_val = 0
current_rain_type = get_precip_type(code_val)
elif in_rain and is_raining and i < len(codes):
code_val = codes[i] if codes[i] is not None else 0
try:
code_val = int(code_val) if code_val is not None else 0
except (ValueError, TypeError):
code_val = 0
new_type = get_precip_type(code_val)
current_rain_type = _precip_type_at(i)
elif in_rain and is_raining:
new_type = _precip_type_at(i)
if new_type != current_rain_type:
end_idx = i
block_precip = precip[start_idx:end_idx] if end_idx <= len(precip) else precip[start_idx:]
block_precip_clean = [p for p in block_precip if p is not None]
tot_mm = sum(block_precip_clean)
start_time = times[start_idx].split("T")[1][:5]
end_time = times[end_idx].split("T")[1][:5] if end_idx < len(times) else times[-1].split("T")[1][:5]
avg_intensity = tot_mm / len(block_precip) if block_precip else 0
events.append(
f"{current_rain_type} ({get_intensity_label(avg_intensity)}):\n"
f" 🕒 {start_time}-{end_time} | 💧 {tot_mm:.1f}mm"
)
_emit_rain(start_idx, i - 1, current_rain_type)
start_idx = i
current_rain_type = new_type
elif (not is_raining and in_rain) or (in_rain and i == len(times)-1):
if in_rain and (not is_raining or is_last):
end_inclusive = i if is_raining else i - 1
_emit_rain(start_idx, end_inclusive, current_rain_type)
in_rain = False
end_idx = i if not is_raining else i + 1
block_precip = precip[start_idx:end_idx] if end_idx <= len(precip) else precip[start_idx:]
block_precip_clean = [p for p in block_precip if p is not None]
tot_mm = sum(block_precip_clean)
if tot_mm > 0:
start_time = times[start_idx].split("T")[1][:5]
end_time = times[min(end_idx-1, len(times)-1)].split("T")[1][:5]
avg_intensity = tot_mm / len(block_precip) if block_precip else 0
events.append(
f"{current_rain_type} ({get_intensity_label(avg_intensity)}):\n"
f" 🕒 {start_time}-{end_time} | 💧 {tot_mm:.1f}mm"
)
# 3. VENTO
if winds:
@@ -1027,10 +1114,10 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
if max_wind > SOGLIA_VENTO_KMH:
try:
peak_idx = winds.index(max_wind)
except ValueError:
peak_idx = 0
peak_time = times[min(peak_idx, len(times)-1)].split("T")[1][:5]
events.append(f"💨 Vento Forte: Picco {max_wind:.0f}km/h alle {peak_time}")
peak_time = times[peak_idx].split("T")[1][:5]
events.append(f"💨 Picco vento: {max_wind:.0f}km/h alle {peak_time}")
except (ValueError, IndexError, AttributeError):
events.append(f"💨 Picco vento: {max_wind:.0f}km/h")
return events
@@ -1042,6 +1129,8 @@ def generate_practical_advice(trend, transitions, events_summary, daily_data):
if trend:
if trend["type"] == "fronte_freddo" and trend["intensity"] == "forte":
advice.append("❄️ <b>Fronte Freddo in Arrivo:</b> Preparati a temperature in calo significativo. Controlla riscaldamento, proteggi piante sensibili.")
elif trend["type"] == "calo_termico" and trend["intensity"] == "forte":
advice.append("📉 <b>Calo termico:</b> Massime e/o minime in netto ribasso rispetto all'inizio periodo, senza necessariamente gelo.")
elif trend["type"] == "fronte_caldo" and trend["intensity"] == "forte":
advice.append("🔥 <b>Ondata di Calore:</b> Temperature in aumento. Mantieni case fresche, idratazione importante, attenzione a persone fragili.")
elif trend["type"] == "raffreddamento":
@@ -1075,55 +1164,58 @@ def generate_practical_advice(trend, transitions, events_summary, daily_data):
return advice
def format_detailed_trend_explanation(trend, daily_time_list=None, display_days=DISPLAY_FORECAST_DAYS):
"""Genera spiegazione dettagliata del trend temperatura sui giorni in previsione."""
"""Genera spiegazione dettagliata del trend su Tmax e Tmin (non temperature medie)."""
if not trend:
return ""
explanation = []
explanation.append(f"📊 <b>EVOLUZIONE TEMPERATURE ({display_days} GIORNI)</b>\n")
# Trend principale con spiegazione chiara
trend_type = trend["type"]
intensity = trend["intensity"]
delta = trend['delta']
first_avg = trend['first_avg']
last_avg = trend['last_avg']
first_max = trend["first_max"]
last_max = trend["last_max"]
first_min = trend["first_min"]
last_min = trend["last_min"]
delta_max = trend["delta_max"]
delta_min = trend["delta_min"]
if trend_type == "fronte_caldo":
trend_desc = "🔥 <b>Fronte Caldo in Arrivo</b>"
desc_text = f"Arrivo di aria più calda: temperatura media passerà da {first_avg:.1f}°C a {last_avg:.1f}°C (+{delta:.1f}°C)."
elif trend_type == "fronte_freddo":
trend_desc = "❄️ <b>Fronte Freddo in Arrivo</b>"
desc_text = f"Arrivo di aria più fredda: temperatura media scenderà da {first_avg:.1f}°C a {last_avg:.1f}°C ({delta:+.1f}°C)."
elif trend_type == "calo_termico":
trend_desc = "📉 <b>Calo Termico</b>"
elif trend_type == "riscaldamento":
trend_desc = "📈 <b>Riscaldamento Progressivo</b>"
desc_text = f"Tendenza al rialzo delle temperature: da {first_avg:.1f}°C a {last_avg:.1f}°C (+{delta:.1f}°C)."
elif trend_type == "raffreddamento":
trend_desc = "📉 <b>Raffreddamento Progressivo</b>"
desc_text = f"Tendenza al ribasso delle temperature: da {first_avg:.1f}°C a {last_avg:.1f}°C ({delta:+.1f}°C)."
elif trend_type == "stabile":
trend_desc = "➡️ <b>Temperature Stabili</b>"
desc_text = f"Temperature medie sostanzialmente stabili: da {first_avg:.1f}°C a {last_avg:.1f}°C (variazione {delta:+.1f}°C)."
else:
trend_desc = "🌡️ <b>Variazione Termica</b>"
desc_text = f"Evoluzione temperature: da {first_avg:.1f}°C a {last_avg:.1f}°C ({delta:+.1f}°C)."
intensity_text = " (variazione significativa)" if intensity == "forte" else " (variazione moderata)"
explanation.append(f"{trend_desc}{intensity_text}")
explanation.append(f"{desc_text}")
explanation.append(
f"Massime: {first_max:.1f}{last_max:.1f}°C ({delta_max:+.1f}°C). "
f"Minime: {first_min:.1f}{last_min:.1f}°C ({delta_min:+.1f}°C)."
)
# Aggiungi solo picchi significativi in modo sintetico (entro i giorni in tabella)
if trend.get("change_days"):
significant_changes = [
c for c in trend["change_days"]
if abs(c["delta"]) > 3.0 and c["day"] < display_days
][:3]
][:4]
if significant_changes:
change_texts = []
for change in significant_changes:
day_name = format_day_label(change["day"], daily_time_list or [])
direction = "" if change['delta'] > 0 else ""
change_texts.append(f"{direction} {day_name}: {change['from']:.0f}°→{change['to']:.0f}°C")
direction = "" if change["delta"] > 0 else ""
series_lbl = "max" if change.get("series") == "max" else "min"
change_texts.append(
f"{direction} {day_name} {series_lbl}: {change['from']:.0f}°→{change['to']:.0f}°C"
)
if change_texts:
explanation.append(f"Picchi: {', '.join(change_texts)}")
@@ -1146,16 +1238,7 @@ def _apply_unified_precip(hourly: Dict, daily: Dict, casa: bool) -> Tuple[Dict,
if icon_d.get("time"):
daily = overlay_icon_precip_on_daily(daily, icon_d)
hourly["precipitation"] = hourly_precip_series(hourly)
totals = daily_precip_from_hourly(hourly)
times = daily.get("time") or []
psum = list(daily.get("precipitation_sum") or [])
while len(psum) < len(times):
psum.append(None)
for i, t in enumerate(times):
d = str(t)[:10]
if d in totals:
psum[i] = round(totals[d], 2)
daily["precipitation_sum"] = psum
daily = apply_hourly_daily_precip(daily, hourly)
return hourly, daily
@@ -1272,8 +1355,10 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
threshold_mm = 5.0 # Soglia default per pioggia
if precip_amount > 0.1:
# Se snowfall è disponibile e positivo, usa quello (più preciso)
if snow_sum_day > 0.1:
# Se snowfall è disponibile e positivo, usa quello (più preciso) solo con aria fredda
temps_clean_day = [float(t) for t in d_temps_day if t is not None]
day_t_min_early = min(temps_clean_day) if temps_clean_day else None
if snow_sum_day > 0.1 and day_t_min_early is not None and day_t_min_early <= ICE_EVENT_MAX_AIR_TEMP:
# Se c'è neve (anche poca), il simbolo è sempre ❄️ (priorità alla neve)
precip_type_symbol = "❄️" # Neve
threshold_mm = 0.5 # Soglia più bassa per neve (anche pochi mm sono significativi)
@@ -1284,12 +1369,14 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
hail_codes = [96, 99] # Codici WMO per grandine/temporale
snow_count = sum(1 for c in d_codes_day if c is not None and int(c) in snow_codes)
hail_count = sum(1 for c in d_codes_day if c is not None and int(c) in hail_codes)
temps_clean = [float(t) for t in d_temps_day if t is not None]
day_t_min = min(temps_clean) if temps_clean else None
if hail_count > 0:
precip_type_symbol = "⛈️" # Grandine/Temporale
threshold_mm = 5.0
elif snow_count > 0:
# Solo se weathercode indica esplicitamente neve
elif snow_count > 0 and day_t_min is not None and day_t_min <= ICE_EVENT_MAX_AIR_TEMP:
# Weathercode neve solo se aria vicino allo zero (evita falsi ❄️ estivi)
precip_type_symbol = "❄️" # Neve
threshold_mm = 0.5 # Soglia più bassa per neve
@@ -1493,8 +1580,9 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
pass # Gestito separatamente per l'icona meteo
if precip_sum > 0.1:
cold_enough_day = t_min <= ICE_EVENT_MAX_AIR_TEMP
# Priorità 1: Se sta nevicando (snowfall > 0) e c'è manto nevoso, considera entrambi
if has_snow_depth_data and max_snow_depth > 0:
if cold_enough_day and has_snow_depth_data and max_snow_depth > 0:
# C'è sia neve in caduta che manto nevoso persistente
if rain_sum > 0.1 or showers_sum > 0.1:
precip_type = "mixed" # Neve + pioggia/temporali
@@ -1505,7 +1593,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
# Il tipo di precipitazione resta quello basato su snowfall/rain
pass
# Priorità 2: Usa dati daily se disponibili
elif snowfall_sum > 0.1:
elif cold_enough_day and snowfall_sum > 0.1:
# C'è neve significativa
if snowfall_sum >= precip_sum * 0.5:
precip_type = "snow"
@@ -1527,7 +1615,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
else:
# Fallback: usa dati hourly se daily non disponibili
snow_sum_day = sum([float(s) for s in d_snow if s is not None]) if d_snow else 0.0
if snow_sum_day > 0.1:
if snow_sum_day > 0.1 and t_min <= ICE_EVENT_MAX_AIR_TEMP:
if snow_sum_day >= precip_sum * 0.5:
precip_type = "snow"
else:
@@ -1543,7 +1631,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
if hail_count > 0:
precip_type = "hail"
elif snow_count > rain_count:
elif snow_count > rain_count and t_min is not None and float(t_min) <= ICE_EVENT_MAX_AIR_TEMP:
precip_type = "snow"
else:
precip_type = "rain"
@@ -1563,8 +1651,8 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
weather_icon = "🌨️" # Precipitazione mista
else:
weather_icon = "🌧️" # Pioggia
elif has_snow_depth_data and max_snow_depth > 0:
# C'è manto nevoso persistente anche senza precipitazioni
elif has_snow_depth_data and max_snow_depth > 0 and t_min <= ICE_EVENT_MAX_AIR_TEMP:
# C'è manto nevoso persistente anche senza precipitazioni (solo se aria abbastanza fredda)
# Mostra icona neve anche se non sta nevicando
weather_icon = "❄️" # Manto nevoso presente
elif t_min < 0:
@@ -1698,23 +1786,36 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
if day_info['precip_sum'] > 0.1:
# Caratterizza usando dati daily se disponibili
precip_parts = []
snow_sum = day_info.get("snowfall_sum", 0) or 0
rain_sum = day_info.get("rain_sum", 0) or 0
showers_sum = day_info.get("showers_sum", 0) or 0
precip_sum = day_info.get("precip_sum", 0) or 0
# Neve
if day_info.get('snowfall_sum', 0) > 0.1:
precip_parts.append(f"❄️ {day_info['snowfall_sum']:.1f}cm")
if snow_sum > 0.1 and day_info["t_min"] <= ICE_EVENT_MAX_AIR_TEMP:
precip_parts.append(f"❄️ {snow_sum:.1f}cm")
# Pioggia
if day_info.get('rain_sum', 0) > 0.1:
precip_parts.append(f"🌧️ {day_info['rain_sum']:.1f}mm")
# Temporali (showers)
if day_info.get('showers_sum', 0) > 0.1:
precip_parts.append(f"⛈️ {day_info['showers_sum']:.1f}mm")
# Se non abbiamo dati daily dettagliati, usa il tipo generale
if not precip_parts:
precip_symbol = "❄️" if day_info['precip_type'] == "snow" else "" if day_info['precip_type'] in ("hail", "thunderstorms") else "🌨️" if day_info['precip_type'] == "mixed" else "🌧️"
precip_parts.append(f"{precip_symbol} {day_info['precip_sum']:.1f}mm")
overlapping = (
rain_sum > 0.1 and showers_sum > 0.1 and (
abs(rain_sum - precip_sum) < 0.25
or abs(showers_sum - precip_sum) < 0.25
or (rain_sum + showers_sum) > precip_sum + 0.3
)
)
if overlapping or (rain_sum <= 0.1 and showers_sum <= 0.1):
if precip_sum > 0.1:
precip_symbol = (
"" if day_info["precip_type"] == "snow"
else "⛈️" if day_info["precip_type"] in ("hail", "thunderstorms")
else "🌨️" if day_info["precip_type"] == "mixed"
else "🌧️"
)
if not (day_info["precip_type"] == "snow" and snow_sum > 0.1 and day_info["t_min"] <= ICE_EVENT_MAX_AIR_TEMP):
precip_parts.append(f"{precip_symbol} {precip_sum:.1f}mm")
else:
if rain_sum > 0.1:
precip_parts.append(f"🌧️ {rain_sum:.1f}mm")
if showers_sum > 0.1:
precip_parts.append(f"⛈️ {showers_sum:.1f}mm")
line += f" | {' + '.join(precip_parts)}"
@@ -1738,7 +1839,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
elif snow_depth_avg is not None and snow_depth_avg > 0:
snow_depth_end = snow_depth_avg # Usa la media come fallback
if snow_depth_end is not None and snow_depth_end > 0:
if snow_depth_end is not None and snow_depth_end > 0 and day_info['t_min'] <= ICE_EVENT_MAX_AIR_TEMP:
snow_depth_str = f"❄️ Manto nevoso: {snow_depth_end:.1f} cm"
# Mostra evoluzione rispetto al giorno precedente
if prev_snow_depth_end is not None:
+17 -19
View File
@@ -273,15 +273,12 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
"""
try:
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
mirror_alert_to_web = lambda *a, **k: False # type: ignore
if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip severe_weather")
if message_html:
mirror_alert_to_web(message_html, "severe_weather", "warning", is_html=True)
return False
token = load_bot_token()
@@ -315,15 +312,6 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
except Exception as e:
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
if sent_ok:
try:
import sys
sys.path.insert(0, "/home/daniely/docker/shared")
from loogle_core.alert_dispatcher import mirror_to_web
mirror_to_web(message_html, "severe_weather", "warning", is_html=True)
except Exception as e:
LOGGER.debug("Web dispatch failed: %s", e)
return sent_ok
@@ -1859,10 +1847,7 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
msg = f"{headline}\n{meta}\n{body}{footer}"
ok = telegram_send_html(msg, chat_ids=chat_ids)
if ok:
LOGGER.info("Alert sent successfully.")
else:
LOGGER.warning("Alert NOT sent (token missing or Telegram error).")
web_ok = False
# IMPORTANTE: Imposta alert_active = True solo se c'è una vera allerta,
# non se è solo un messaggio informativo in modalità debug
@@ -1879,10 +1864,23 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
state["alert_active"] = True
state["last_alert_type"] = alert_types if alert_types else None
state["last_alert_time"] = now.isoformat()
if ok:
try:
from webapp_alert import publish_web_alert
web_ok = bool(publish_web_alert(msg, "severe_weather", "warning", is_html=True, state=state))
except Exception as e:
LOGGER.debug("Web summary failed: %s", e)
if ok or web_ok:
record_notify_message(now, state, alert_signature)
save_state(state)
if ok:
LOGGER.info("Alert sent successfully (Telegram).")
elif web_ok:
LOGGER.info("Alert published on WebApp.")
else:
LOGGER.warning("Alert NOT delivered (Telegram/WebApp).")
if debug_message_only:
# In debug mode senza vere allerte, non modificare alert_active
LOGGER.debug("[DEBUG MODE] Messaggio inviato ma alert_active non modificato (nessuna vera allerta)")
return
@@ -1942,7 +1940,7 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
if ok:
LOGGER.info("All-clear sent successfully.")
else:
LOGGER.warning("All-clear NOT sent (token missing or Telegram error).")
LOGGER.info("All-clear Telegram skip/fail (WebApp primaria se attiva).")
state = {
"alert_active": False,
@@ -814,12 +814,30 @@ def analyze_all_locations(debug_mode: bool = False) -> None:
return
ok = telegram_send_html(msg, chat_ids=[TELEGRAM_CHAT_IDS[0]] if debug_mode else None)
if ok:
LOGGER.info("Alert inviato (%s) per %d località significative", category, len(significant_locations))
else:
LOGGER.warning("Alert NON inviato (token missing o errore Telegram)")
web_ok = False
try:
from webapp_alert import publish_web_alert
web_ok = bool(publish_web_alert(msg, "severe_circondario", "warning", is_html=True, state=state))
except Exception as e:
LOGGER.debug("Web summary failed: %s", e)
if ok and not debug_mode:
delivered = bool(ok or web_ok)
if ok:
LOGGER.info("Alert inviato su Telegram (%s) per %d località significative", category, len(significant_locations))
elif web_ok:
LOGGER.info("Alert pubblicato su WebApp (%s) per %d località significative", category, len(significant_locations))
else:
try:
from telegram_gate import telegram_alerts_enabled
suspended = not telegram_alerts_enabled()
except Exception:
suspended = False
if suspended:
LOGGER.warning("Alert NON pubblicato su WebApp (Telegram sospeso)")
else:
LOGGER.warning("Alert NON inviato (errore Telegram o WebApp)")
if delivered and not debug_mode:
record_notify(category, now, state)
state["last_signature"] = signature
state["last_signature_date"] = today
+271 -147
View File
@@ -135,6 +135,34 @@ def hhmm(dt: datetime.datetime) -> str:
return dt.strftime("%H:%M")
_WEEKDAYS_IT = ("lun", "mar", "mer", "gio", "ven", "sab", "dom")
def format_clock(dt: datetime.datetime, ref: Optional[datetime.datetime] = None) -> str:
"""HH:MM, con giorno corto se diverso da ref (default: oggi locale)."""
ref = ref or now_local()
label = hhmm(dt)
if dt.date() != ref.date():
return f"{_WEEKDAYS_IT[dt.weekday()]} {label}"
return label
def format_fascia(
start: Optional[datetime.datetime],
end: Optional[datetime.datetime],
ref: Optional[datetime.datetime] = None,
) -> str:
"""Fascia leggibile ~HH:MM~HH:MM (con giorno se serve)."""
if start is None:
return ""
ref = ref or now_local()
a = format_clock(start, ref)
if end is None or end == start:
return f"~{a}"
b = format_clock(end, ref)
return f"~{a}~{b}"
# =============================================================================
# Telegram
# =============================================================================
@@ -202,20 +230,100 @@ def load_state() -> Dict:
return default
def save_state(alert_active: bool, signature: str) -> None:
def save_state(alert_active: bool, signature: str, detail: Optional[Dict] = None) -> None:
try:
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
payload: Dict = {
"alert_active": alert_active,
"signature": signature,
"updated": now_local().isoformat(),
}
if detail:
payload.update(detail)
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(
{"alert_active": alert_active, "signature": signature, "updated": now_local().isoformat()},
f,
ensure_ascii=False,
indent=2,
)
json.dump(payload, f, ensure_ascii=False, indent=2)
except Exception as e:
LOGGER.exception("State write error: %s", e)
def build_plain_summary(bo_alerts: Dict, route_alerts: List[Dict]) -> str:
"""Testo leggibile per WebApp (dove / eventi / quando)."""
any_snow = bool(bo_alerts.get("snow_alert")) or any(x.get("snow_alert") for x in route_alerts)
any_rain = bool(bo_alerts.get("rain_alert")) or any(x.get("rain_alert") for x in route_alerts)
soglie: List[str] = []
if any_snow:
soglie.append(f"neve ≥{PERSIST_HOURS}h consecutive")
if any_rain:
soglie.append(
f"pioggia 3h ≥{SOGLIA_PIOGGIA_3H_MM:.0f} mm per ≥{PERSIST_HOURS}h"
)
lines: List[str] = [
f"Percorso scuola Bologna ↔ rientro · prossime {HOURS_AHEAD}h",
]
if soglie:
lines.append("Soglie: " + " · ".join(soglie))
lines.extend([
"",
"A Bologna:",
])
if bo_alerts.get("snow_alert"):
fascia = bo_alerts.get("snow_fascia") or f"~{bo_alerts.get('snow_run_time') or ''}"
lines.append(
f"• Neve {fascia} "
f"(12h {bo_alerts.get('snow_12h', 0):.1f} cm · 24h {bo_alerts.get('snow_24h', 0):.1f} cm)"
)
if bo_alerts.get("rain_alert"):
r3 = float(bo_alerts.get("rain3_max") or 0)
fascia = bo_alerts.get("rain_fascia") or f"~{bo_alerts.get('rain_persist_time') or ''}"
lines.append(f"• Pioggia forte {fascia} (max 3h {r3:.1f} mm)")
if not bo_alerts.get("snow_alert") and not bo_alerts.get("rain_alert"):
lines.append("• Nessuna criticità persistente (trigger Bologna assente nel dettaglio punti)")
issues = [x for x in route_alerts if x.get("snow_alert") or x.get("rain_alert")]
lines.append("")
lines.append("Caselli A14 / tratto:")
if not issues:
lines.append("• Nessuna criticità lungo il percorso")
else:
for x in issues:
parts: List[str] = []
if x.get("snow_alert"):
fascia = x.get("snow_fascia") or f"~{x.get('snow_run_time') or ''}"
parts.append(f"neve {fascia} (24h {x.get('snow_24h', 0):.1f} cm)")
if x.get("rain_alert"):
r3 = float(x.get("rain3_max") or 0)
fascia = x.get("rain_fascia") or f"~{x.get('rain_persist_time') or ''}"
parts.append(f"pioggia {fascia} (max 3h {r3:.1f} mm)")
lines.append(f"{x.get('name', '?')}: " + " | ".join(parts))
lines.append("")
lines.append("Fonte: Open-Meteo (AROME / confronto ICON IT)")
return "\n".join(lines)
def first_event_time(bo_alerts: Dict, route_alerts: List[Dict]) -> Optional[str]:
candidates: List[str] = []
for src in [bo_alerts, *route_alerts]:
if src.get("snow_alert") and src.get("snow_run_time"):
candidates.append(str(src["snow_run_time"]))
if src.get("rain_alert") and src.get("rain_persist_time"):
candidates.append(str(src["rain_persist_time"]))
return min(candidates) if candidates else None
def notify_webapp(title: str, body: str, severity: str = "warning") -> None:
try:
sys_path = "/home/daniely/docker/shared"
if sys_path not in __import__("sys").path:
__import__("sys").path.insert(0, sys_path)
from loogle_core.alert_dispatcher import dispatch_alert
dispatch_alert(title, body, category="student", severity=severity)
except Exception as e:
LOGGER.warning("WebApp notify fallita: %s", e)
def get_forecast(session: requests.Session, lat: float, lon: float, model: str) -> Optional[Dict]:
params = {
"latitude": lat,
@@ -357,10 +465,28 @@ def rolling_sum_3h(values: List[float]) -> List[float]:
return out
def first_persistent_run(values: List[float], threshold: float, persist: int) -> Tuple[bool, int, int, float]:
def first_persistent_run(
values: List[float], threshold: float, persist: int
) -> Tuple[bool, int, int, float]:
"""Prima run continua con valori >= soglia e lunghezza >= persist.
Ritorna (ok, start_idx, end_idx inclusivo, run_max). La run viene
estesa fino alla fine (non si ferma al minimo persist).
"""
consec = 0
run_start = -1
run_max = 0.0
found_start = -1
found_end = -1
found_max = 0.0
def _flush() -> None:
nonlocal found_start, found_end, found_max
if consec >= persist and found_start < 0:
found_start = run_start
found_end = run_start + consec - 1
found_max = run_max
for i, v in enumerate(values):
vv = float(v) if v is not None else 0.0
if vv >= threshold:
@@ -370,30 +496,19 @@ def first_persistent_run(values: List[float], threshold: float, persist: int) ->
else:
run_max = max(run_max, vv)
consec += 1
if consec >= persist:
return True, run_start, consec, run_max
else:
_flush()
if found_start >= 0:
break
consec = 0
return False, -1, 0, 0.0
def max_consecutive_gt(values: List[float], eps: float) -> Tuple[int, int]:
best_len = 0
best_start = -1
consec = 0
start = -1
for i, v in enumerate(values):
vv = float(v) if v is not None else 0.0
if vv > eps:
if consec == 0:
start = i
consec += 1
if consec > best_len:
best_len = consec
best_start = start
run_start = -1
run_max = 0.0
else:
consec = 0
return best_len, best_start
_flush()
if found_start >= 0:
return True, found_start, found_end, found_max
return False, -1, -1, 0.0
def compute_stats(data: Dict) -> Optional[Dict]:
@@ -428,100 +543,48 @@ def compute_stats(data: Dict) -> Optional[Dict]:
rain3_max_idx = rain3.index(rain3_max) if rain3 else -1
rain3_max_time = hhmm(dt_w[rain3_max_idx]) if (rain3_max_idx >= 0 and rain3_max_idx < len(dt_w)) else ""
rain_persist_ok, rain_run_start, rain_run_len, rain_run_max = first_persistent_run(
rain_persist_ok, rain3_start, rain3_end, rain_run_max = first_persistent_run(
rain3, SOGLIA_PIOGGIA_3H_MM, PERSIST_HOURS
)
rain_persist_time = hhmm(dt_w[rain_run_start]) if (rain_persist_ok and rain_run_start < len(dt_w)) else ""
rain_run_len = (rain3_end - rain3_start + 1) if rain_persist_ok else 0
rain_persist_dt_start: Optional[datetime.datetime] = None
rain_persist_dt_end: Optional[datetime.datetime] = None
rain_persist_time = ""
rain_persist_end_time = ""
rain_fascia = ""
if rain_persist_ok and 0 <= rain3_start < len(dt_w):
# Fascia = ore di calendario coperte dalle finestre rolling (ultima = end+2)
covered_end_idx = min(rain3_end + 2, len(dt_w) - 1)
rain_persist_dt_start = dt_w[rain3_start]
rain_persist_dt_end = dt_w[covered_end_idx]
rain_persist_time = format_clock(rain_persist_dt_start)
rain_persist_end_time = format_clock(rain_persist_dt_end)
rain_fascia = format_fascia(rain_persist_dt_start, rain_persist_dt_end)
# Analizza evento pioggia completa (48h): rileva inizio e calcola durata e accumulo totale
rain_start_idx = None
rain_end_idx = None
total_rain_accumulation = 0.0
rain_duration_hours = 0.0
max_rain_intensity = 0.0
# Codici meteo che indicano pioggia (WMO)
RAIN_WEATHER_CODES = [61, 63, 65, 66, 67, 80, 81, 82]
# Trova inizio evento pioggia (prima occorrenza con precipitation > 0 OPPURE weathercode pioggia)
# Estendi l'analisi a 48 ore se disponibile
extended_end_idx = min(start_idx + 48, n) # Estendi a 48 ore
precip_extended = precip[start_idx:extended_end_idx]
weathercode_extended = [int(x) if x is not None else None for x in weathercode[start_idx:extended_end_idx]] if len(weathercode) > start_idx else []
for i, (p_val, code) in enumerate(zip(precip_extended, weathercode_extended if len(weathercode_extended) == len(precip_extended) else [None] * len(precip_extended))):
p_val_float = float(p_val) if p_val is not None else 0.0
is_rain = (p_val_float > 0.0) or (code is not None and code in RAIN_WEATHER_CODES)
if is_rain and rain_start_idx is None:
rain_start_idx = i
break
# Se trovato inizio, calcola durata e accumulo totale su 48 ore
if rain_start_idx is not None:
# Trova fine evento pioggia (ultima occorrenza con pioggia)
for i in range(len(precip_extended) - 1, rain_start_idx - 1, -1):
p_val = precip_extended[i] if i < len(precip_extended) else None
code = weathercode_extended[i] if i < len(weathercode_extended) else None
p_val_float = float(p_val) if p_val is not None else 0.0
is_rain = (p_val_float > 0.0) or (code is not None and code in RAIN_WEATHER_CODES)
if is_rain:
rain_end_idx = i
break
if rain_end_idx is not None:
# Calcola durata
times_extended = times[start_idx:extended_end_idx]
dt_extended = [parse_time_to_local(t) for t in times_extended]
if rain_end_idx < len(dt_extended) and rain_start_idx < len(dt_extended):
rain_duration_hours = (dt_extended[rain_end_idx] - dt_extended[rain_start_idx]).total_seconds() / 3600.0
# Calcola accumulo totale (somma di tutti i precipitation > 0)
total_rain_accumulation = sum(float(p) for p in precip_extended[rain_start_idx:rain_end_idx+1] if p is not None and float(p) > 0.0)
# Calcola intensità massima oraria
max_rain_intensity = max((float(p) for p in precip_extended[rain_start_idx:rain_end_idx+1] if p is not None), default=0.0)
# Analizza nevicata completa (48h): rileva inizio usando snowfall > 0 OPPURE weathercode
# Calcola durata e accumulo totale
snow_start_idx = None
snow_end_idx = None
total_snow_accumulation = 0.0
snow_duration_hours = 0.0
# Trova inizio nevicata (prima occorrenza con snowfall > 0 OPPURE weathercode neve)
for i, (s_val, code) in enumerate(zip(snow_w, weathercode_w if len(weathercode_w) == len(snow_w) else [None] * len(snow_w))):
is_snow = (s_val > 0.0) or (code is not None and code in SNOW_WEATHER_CODES)
if is_snow and snow_start_idx is None:
snow_start_idx = i
break
# Se trovato inizio, calcola durata e accumulo totale
if snow_start_idx is not None:
# Trova fine nevicata (ultima occorrenza con neve)
for i in range(len(snow_w) - 1, snow_start_idx - 1, -1):
s_val = snow_w[i]
# Flag orari neve: accumulo orario sopra eps OPPURE weathercode neve
snow_flags: List[float] = []
for i, s_val in enumerate(snow_w):
code = weathercode_w[i] if i < len(weathercode_w) else None
is_snow = (s_val > 0.0) or (code is not None and code in SNOW_WEATHER_CODES)
if is_snow:
snow_end_idx = i
break
is_snow = (s_val > SNOW_HOURLY_EPS_CM) or (
code is not None and code in SNOW_WEATHER_CODES
)
snow_flags.append(1.0 if is_snow else 0.0)
if snow_end_idx is not None:
# Calcola durata
snow_duration_hours = (dt_w[snow_end_idx] - dt_w[snow_start_idx]).total_seconds() / 3600.0
# Calcola accumulo totale (somma di tutti i snowfall > 0)
total_snow_accumulation = sum(s for s in snow_w[snow_start_idx:snow_end_idx+1] if s > 0.0)
# Per compatibilità con logica esistente
snow_run_len, snow_run_start = max_consecutive_gt(snow_w, eps=SNOW_HOURLY_EPS_CM)
snow_run_time = hhmm(dt_w[snow_run_start]) if (snow_run_start >= 0 and snow_run_start < len(dt_w)) else ""
# Se trovato inizio nevicata, usa quello invece del run
if snow_start_idx is not None:
snow_run_time = hhmm(dt_w[snow_start_idx])
# Durata minima per alert: almeno 2 ore
if snow_duration_hours >= PERSIST_HOURS:
snow_run_len = int(snow_duration_hours)
else:
snow_run_len = 0 # Durata troppo breve
snow_ok, snow_run_start, snow_run_end, _ = first_persistent_run(
snow_flags, 1.0, PERSIST_HOURS
)
snow_run_len = (snow_run_end - snow_run_start + 1) if snow_ok else 0
snow_persist_dt_start: Optional[datetime.datetime] = None
snow_persist_dt_end: Optional[datetime.datetime] = None
snow_run_time = ""
snow_run_end_time = ""
snow_fascia = ""
if snow_ok and 0 <= snow_run_start < len(dt_w) and 0 <= snow_run_end < len(dt_w):
snow_persist_dt_start = dt_w[snow_run_start]
snow_persist_dt_end = dt_w[snow_run_end]
snow_run_time = format_clock(snow_persist_dt_start)
snow_run_end_time = format_clock(snow_persist_dt_end)
snow_fascia = format_fascia(snow_persist_dt_start, snow_persist_dt_end)
snow_12h = sum(s for s in snow_w[: min(12, len(snow_w))] if s > 0.0)
snow_24h = sum(s for s in snow_w[: min(24, len(snow_w))] if s > 0.0)
@@ -531,17 +594,16 @@ def compute_stats(data: Dict) -> Optional[Dict]:
"rain3_max_time": rain3_max_time,
"rain_persist_ok": bool(rain_persist_ok),
"rain_persist_time": rain_persist_time,
"rain_persist_end_time": rain_persist_end_time,
"rain_fascia": rain_fascia,
"rain_persist_run_max": float(rain_run_max),
"rain_persist_run_len": int(rain_run_len),
"rain_duration_hours": float(rain_duration_hours),
"total_rain_accumulation_mm": float(total_rain_accumulation),
"max_rain_intensity_mm_h": float(max_rain_intensity),
"snow_run_len": int(snow_run_len),
"snow_run_time": snow_run_time,
"snow_run_end_time": snow_run_end_time,
"snow_fascia": snow_fascia,
"snow_12h": float(snow_12h),
"snow_24h": float(snow_24h),
"snow_duration_hours": float(snow_duration_hours),
"total_snow_accumulation_cm": float(total_snow_accumulation),
}
@@ -556,14 +618,15 @@ def point_alerts(point_name: str, stats: Dict) -> Dict:
"snow_24h": stats["snow_24h"],
"snow_run_len": stats["snow_run_len"],
"snow_run_time": stats["snow_run_time"],
"snow_run_end_time": stats.get("snow_run_end_time", ""),
"snow_fascia": stats.get("snow_fascia", ""),
"rain3_max": stats["rain3_max"],
"rain3_max_time": stats["rain3_max_time"],
"rain_persist_time": stats["rain_persist_time"],
"rain_persist_end_time": stats.get("rain_persist_end_time", ""),
"rain_fascia": stats.get("rain_fascia", ""),
"rain_persist_run_max": stats["rain_persist_run_max"],
"rain_persist_run_len": stats["rain_persist_run_len"],
"rain_duration_hours": stats.get("rain_duration_hours", 0.0),
"total_rain_accumulation_mm": stats.get("total_rain_accumulation_mm", 0.0),
"max_rain_intensity_mm_h": stats.get("max_rain_intensity_mm_h", 0.0),
}
@@ -660,7 +723,10 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
msg.append("🎓 <b>A BOLOGNA</b>")
bo_comp = comparisons.get(bo["name"])
if bo_alerts["snow_alert"]:
msg.append(f"❄️ Neve (≥{PERSIST_HOURS}h) da ~<b>{html.escape(bo_alerts['snow_run_time'] or '')}</b> (run ~{bo_alerts['snow_run_len']}h).")
fascia = bo_alerts.get("snow_fascia") or f"~{bo_alerts.get('snow_run_time') or ''}"
msg.append(
f"❄️ Neve (≥{PERSIST_HOURS}h) <b>{html.escape(fascia)}</b>."
)
msg.append(f"• Accumulo: 12h <b>{bo_alerts['snow_12h']:.1f} cm</b> | 24h <b>{bo_alerts['snow_24h']:.1f} cm</b>")
if bo_comp and bo_comp.get("snow"):
comp = bo_comp["snow"]
@@ -670,13 +736,12 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
msg.append(f"❄️ Neve: nessuna persistenza ≥ {PERSIST_HOURS}h (24h {bo_alerts['snow_24h']:.1f} cm).")
if bo_alerts["rain_alert"]:
rain_duration = bo_alerts.get("rain_duration_hours", 0.0)
total_rain = bo_alerts.get("total_rain_accumulation_mm", 0.0)
max_intensity = bo_alerts.get("max_rain_intensity_mm_h", 0.0)
msg.append(f"🌧️ Pioggia molto forte (3h ≥ {SOGLIA_PIOGGIA_3H_MM:.0f} mm, ≥{PERSIST_HOURS}h) da ~<b>{html.escape(bo_alerts['rain_persist_time'] or '')}</b>.")
if rain_duration > 0:
msg.append(f"⏱️ <b>Durata totale evento (48h):</b> ~{rain_duration:.0f} ore | <b>Accumulo totale:</b> ~{total_rain:.1f} mm | <b>Intensità max:</b> {max_intensity:.1f} mm/h")
fascia = bo_alerts.get("rain_fascia") or f"~{bo_alerts.get('rain_persist_time') or ''}"
msg.append(
f"🌧️ Pioggia molto forte (3h ≥ {SOGLIA_PIOGGIA_3H_MM:.0f} mm, ≥{PERSIST_HOURS}h) "
f"<b>{html.escape(fascia)}</b> "
f"(max 3h <b>{bo_alerts['rain3_max']:.1f} mm</b>)."
)
if bo_comp and bo_comp.get("rain"):
comp = bo_comp["rain"]
icon_r3 = bo_comp["icon_stats"]["rain3_max"]
@@ -695,14 +760,17 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
line = f"• <b>{html.escape(x['name'])}</b>: "
parts: List[str] = []
if x["snow_alert"]:
parts.append(f"❄️ neve (≥{PERSIST_HOURS}h) da ~{html.escape(x['snow_run_time'] or '')} (24h {x['snow_24h']:.1f} cm)")
fascia = x.get("snow_fascia") or f"~{x.get('snow_run_time') or ''}"
parts.append(
f"❄️ neve (≥{PERSIST_HOURS}h) {html.escape(fascia)} "
f"(24h {x['snow_24h']:.1f} cm)"
)
if x["rain_alert"]:
rain_dur = x.get("rain_duration_hours", 0.0)
rain_tot = x.get("total_rain_accumulation_mm", 0.0)
if rain_dur > 0:
parts.append(f"🌧️ pioggia forte da ~{html.escape(x['rain_persist_time'] or '')} (durata ~{rain_dur:.0f}h, totale ~{rain_tot:.1f}mm)")
else:
parts.append(f"🌧️ pioggia forte da ~{html.escape(x['rain_persist_time'] or '')}")
fascia = x.get("rain_fascia") or f"~{x.get('rain_persist_time') or ''}"
parts.append(
f"🌧️ pioggia forte {html.escape(fascia)} "
f"(max 3h {x['rain3_max']:.1f} mm)"
)
line += " | ".join(parts)
msg.append(line)
@@ -725,15 +793,65 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
msg.append("<i>Fonte dati: Open-Meteo</i>")
# FIX: usare \n invece di <br/>
ok = telegram_send_html("\n".join(msg), chat_ids=chat_ids)
html_msg = "\n".join(msg)
plain = build_plain_summary(bo_alerts, route_alerts)
ok = telegram_send_html(html_msg, chat_ids=chat_ids)
if ok:
LOGGER.info("Notifica inviata.")
LOGGER.info("Notifica inviata su Telegram.")
else:
LOGGER.warning("Notifica NON inviata.")
LOGGER.info("Telegram saltato/sospeso; consegna via WebApp.")
save_state(True, sig)
detail = {
"summary": plain,
"window_hours": HOURS_AHEAD,
"persist_hours": PERSIST_HOURS,
"rain_threshold_mm_3h": SOGLIA_PIOGGIA_3H_MM,
"first_event_time": first_event_time(bo_alerts, route_alerts),
"bologna": {
"snow_alert": bool(bo_alerts.get("snow_alert")),
"rain_alert": bool(bo_alerts.get("rain_alert")),
"snow_run_time": bo_alerts.get("snow_run_time"),
"snow_run_end_time": bo_alerts.get("snow_run_end_time"),
"snow_fascia": bo_alerts.get("snow_fascia"),
"rain_persist_time": bo_alerts.get("rain_persist_time"),
"rain_persist_end_time": bo_alerts.get("rain_persist_end_time"),
"rain_fascia": bo_alerts.get("rain_fascia"),
"snow_24h": bo_alerts.get("snow_24h"),
"rain3_max": bo_alerts.get("rain3_max"),
},
"route_issues": [
{
"name": x.get("name"),
"snow_alert": bool(x.get("snow_alert")),
"rain_alert": bool(x.get("rain_alert")),
"snow_run_time": x.get("snow_run_time"),
"snow_run_end_time": x.get("snow_run_end_time"),
"snow_fascia": x.get("snow_fascia"),
"rain_persist_time": x.get("rain_persist_time"),
"rain_persist_end_time": x.get("rain_persist_end_time"),
"rain_fascia": x.get("rain_fascia"),
"snow_24h": x.get("snow_24h"),
"rain3_max": x.get("rain3_max"),
"rain_persist_run_len": x.get("rain_persist_run_len"),
}
for x in route_alerts
if x.get("snow_alert") or x.get("rain_alert")
],
}
save_state(True, sig, detail)
notify_webapp("Allerta percorso scuola", plain, severity="warning")
else:
LOGGER.info("Allerta già notificata e invariata.")
# Aggiorna comunque summary nello state se manca (card WebApp)
if not state.get("summary"):
plain = build_plain_summary(bo_alerts, route_alerts)
save_state(True, sig, {
"summary": plain,
"window_hours": HOURS_AHEAD,
"persist_hours": PERSIST_HOURS,
"rain_threshold_mm_3h": SOGLIA_PIOGGIA_3H_MM,
"first_event_time": first_event_time(bo_alerts, route_alerts),
})
return
# --- Scenario B: Rientro ---
@@ -747,12 +865,18 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
f"di neve (≥{PERSIST_HOURS}h) o pioggia 3h sopra soglia (≥{PERSIST_HOURS}h).\n"
"<i>Fonte dati: Open-Meteo</i>"
)
plain = (
f"Allerta rientrata (Bologna / percorso scuola).\n"
f"Nelle prossime {HOURS_AHEAD} ore non risultano più neve persistente "
f"(≥{PERSIST_HOURS}h) né pioggia 3h sopra soglia (≥{PERSIST_HOURS}h)."
)
ok = telegram_send_html(msg, chat_ids=chat_ids)
if ok:
LOGGER.info("Rientro notificato.")
LOGGER.info("Rientro notificato su Telegram.")
else:
LOGGER.warning("Rientro NON inviato.")
LOGGER.info("Rientro Telegram saltato/sospeso; consegna via WebApp.")
save_state(False, "")
notify_webapp("Allerta percorso scuola rientrata", plain, severity="info")
return
# --- Scenario C: Tranquillo ---
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Regressione visibilità ore nella tabella meteo 48h."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from open_meteo_precip import (
apply_hourly_daily_precip,
hourly_table_should_show,
overlay_icon_precip_on_hourly,
)
def test_first_24h_all_shown():
for h in range(24):
assert hourly_table_should_show(h, 0.0, 3) is True
def test_second_day_even_hours_shown():
assert hourly_table_should_show(24, 0.0, 3) is True # lun 08
assert hourly_table_should_show(32, 5.0, 63) is True # lun 16
def test_second_day_odd_dry_hidden():
assert hourly_table_should_show(31, 0.0, 3) is False # lun 15 secco
def test_second_day_odd_wet_shown():
# Caso reale 17/08/2026: 15:00 = 17.4 mm (code 65) era nascosto → 48h mostrava solo 5 mm alle 16
assert hourly_table_should_show(31, 17.4, 65) is True
assert hourly_table_should_show(31, 0.0, 65) is True # codice precip anche con mm 0
def test_apply_hourly_daily_precip_matches_hours():
hourly = {
"time": [f"2026-08-17T{h:02d}:00" for h in range(24)],
"precipitation": [0.0] * 24,
"rain": [0.0] * 24,
"showers": [0.0] * 24,
"snowfall": [0.0] * 24,
}
hourly["rain"][15] = 17.4
hourly["rain"][16] = 5.0
hourly["precipitation"][15] = 17.4
hourly["precipitation"][16] = 5.0
daily = {
"time": ["2026-08-17"],
"precipitation_sum": [18.0],
"rain_sum": [18.0],
"showers_sum": [20.0],
"snowfall_sum": [0.0],
}
out = apply_hourly_daily_precip(daily, hourly)
assert out["precipitation_sum"][0] == 22.4
assert out["rain_sum"][0] == 22.4
assert out["showers_sum"][0] == 0.0
def test_overlay_matches_normalized_timestamps():
target = {
"time": ["2026-08-17T15:00"],
"precipitation": [0.0],
"rain": [0.0],
"showers": [0.0],
"snowfall": [0.0],
}
icon = {
"time": ["2026-08-17T15:00:00"],
"precipitation": [17.4],
"rain": [17.4],
"showers": [0.0],
"snowfall": [0.0],
}
out = overlay_icon_precip_on_hourly(target, icon)
assert abs(out["precipitation"][0] - 17.4) < 0.01
if __name__ == "__main__":
test_first_24h_all_shown()
test_second_day_even_hours_shown()
test_second_day_odd_dry_hidden()
test_second_day_odd_wet_shown()
test_apply_hourly_daily_precip_matches_hours()
test_overlay_matches_normalized_timestamps()
print("OK hourly_table_should_show")
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Regression checks for Meteo7 interpretation (weathercode mode, ice gates, hours, trend max/min)."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from previsione7 import (
_merge_weathercode,
_format_event_hours,
analyze_daily_events,
analyze_temperature_trend,
format_detailed_trend_explanation,
get_precip_type,
)
def test_weathercode_mode_not_median():
# 61 (rain) + 71 (snow) → mediana numerica sarebbe 66 (FZRA); moda deve evitare 66
assert _merge_weathercode([61, 71], preferred=61) == 61
assert _merge_weathercode([61, 71], preferred=71) == 71
assert _merge_weathercode([61, 61, 71]) == 61
assert _merge_weathercode([66, 66, 61]) == 66
print("OK weathercode mode")
def test_get_precip_type_temp_gate():
assert "Congelantesi" not in get_precip_type(67, temp=28.0)
assert "Pioggia" in get_precip_type(67, temp=28.0)
assert "Congelantesi" in get_precip_type(67, temp=0.5)
assert "Neve" not in get_precip_type(71, temp=20.0)
assert "Neve" in get_precip_type(71, temp=-1.0)
print("OK precip type temp gate")
def test_gelicidio_hot_air_no_event():
times = [f"2026-07-21T{h:02d}:00" for h in range(24)]
codes = [0] * 24
codes[20] = 67 # FZRA spurio
precip = [0.0] * 24
precip[20] = 1.2
temps = [28.0] * 24
dewpoints = [18.0] * 24
winds = [5.0] * 24
events = analyze_daily_events(
times, codes, None, precip, winds, temps, dewpoints,
snowfalls=[0.0] * 24, rains=precip[:], soil_temps=[25.0] * 24,
cloud_covers=[50.0] * 24, wind_speeds=winds,
)
ice_like = [e for e in events if "GELICIDIO" in e or "BRINA" in e or "Gelata" in e or "Black Ice" in e]
assert not ice_like, f"unexpected ice events in heat: {ice_like}"
rain_like = [e for e in events if "Pioggia" in e or "Congelantesi" in e]
assert rain_like, "expected rain event"
assert all("Congelantesi" not in e for e in rain_like)
print("OK no gelicidio at 28°C")
def test_single_hour_event_range():
times = [f"2026-07-21T{h:02d}:00" for h in range(24)]
assert _format_event_hours(times, 20, 20) == "20:00-21:00"
assert _format_event_hours(times, 20, 21) == "20:00-22:00"
# last hour of day
assert _format_event_hours(times, 23, 23) == "23:00-00:00"
codes = [0] * 24
precip = [0.0] * 24
precip[20] = 2.0
temps = [22.0] * 24
events = analyze_daily_events(
times, codes, None, precip, [5.0] * 24, temps, [12.0] * 24,
snowfalls=[0.0] * 24, rains=precip[:],
)
rain_ev = [e for e in events if "🕒" in e]
assert rain_ev, events
assert "20:00-21:00" in rain_ev[0]
assert "20:00-20:00" not in rain_ev[0]
print("OK single-hour 20:00-21:00")
def test_trend_max_min_not_media():
# Heatwave: max stay high, slight drop → calo_termico not fronte_freddo
tmax = [34, 35, 36, 33, 30, 29, 28, 27, 26, 25]
tmin = [22, 23, 24, 21, 20, 19, 18, 17, 16, 15]
trend = analyze_temperature_trend(tmax, tmin, days=10)
assert trend is not None
assert "first_max" in trend and "first_min" in trend
assert trend["type"] == "calo_termico"
text = format_detailed_trend_explanation(trend, daily_time_list=[f"2026-07-{21+i:02d}" for i in range(10)])
assert "media" not in text.lower()
assert "Massime:" in text and "Minime:" in text
assert "Fronte Freddo" not in text
print("OK trend max/min calo_termico")
def test_real_gelicidio_cold():
times = [f"2026-01-10T{h:02d}:00" for h in range(24)]
codes = [0] * 24
codes[8] = 67
precip = [0.0] * 24
precip[8] = 0.5
temps = [5.0] * 24
temps[8] = -0.5
events = analyze_daily_events(
times, codes, None, precip, [3.0] * 24, temps, [-1.0] * 24,
snowfalls=[0.0] * 24, rains=precip[:], soil_temps=[-1.0] * 24,
cloud_covers=[80.0] * 24, wind_speeds=[3.0] * 24,
)
assert any("GELICIDIO" in e for e in events), events
print("OK real gelicidio at subzero")
def test_monday_storm_event_mm_is_hourly_sum():
"""15:00=17.4 + 16:00=5.0 → un evento 22.4 mm, non 5 mm né 22.4 ripetuto."""
times = [f"2026-08-17T{h:02d}:00" for h in range(24)]
precip = [0.0] * 24
precip[15] = 17.4
precip[16] = 5.0
codes = [3] * 24
codes[15] = 65
codes[16] = 63
temps = [26.0] * 24
events = analyze_daily_events(
times, codes, None, precip, [18.0] * 24, temps, [16.0] * 24,
snowfalls=[0.0] * 24, rains=precip[:],
)
rain_ev = [e for e in events if "🕒" in e]
assert len(rain_ev) == 1, rain_ev
assert "15:00-17:00" in rain_ev[0]
assert "22.4mm" in rain_ev[0]
assert rain_ev[0].count("22.4mm") == 1
print("OK lun 17 15-17h = 22.4mm")
def test_three_pulses_keep_own_mm_not_daily_total():
times = [f"2026-08-16T{h:02d}:00" for h in range(24)]
precip = [0.0] * 24
precip[10] = 18.0
precip[14] = 19.0
precip[18] = 20.0
events = analyze_daily_events(
times, [63] * 24, None, precip, [10.0] * 24, [24.0] * 24, [14.0] * 24,
snowfalls=[0.0] * 24, rains=precip[:],
)
rain_ev = [e for e in events if "🕒" in e]
assert len(rain_ev) == 3, rain_ev
assert "18.0mm" in rain_ev[0]
assert "19.0mm" in rain_ev[1]
assert "20.0mm" in rain_ev[2]
assert all("57.0mm" not in e for e in rain_ev)
print("OK tre fasce con mm propri")
def test_last_hour_rain_is_emitted():
times = [f"2026-08-17T{h:02d}:00" for h in range(24)]
precip = [0.0] * 24
precip[23] = 4.2
events = analyze_daily_events(
times, [61] * 24, None, precip, [8.0] * 24, [20.0] * 24, [12.0] * 24,
snowfalls=[0.0] * 24, rains=precip[:],
)
rain_ev = [e for e in events if "🕒" in e]
assert rain_ev, events
assert "23:00-00:00" in rain_ev[0]
assert "4.2mm" in rain_ev[0]
print("OK pioggia ultima ora del giorno")
if __name__ == "__main__":
test_weathercode_mode_not_median()
test_get_precip_type_temp_gate()
test_gelicidio_hot_air_no_event()
test_single_hour_event_range()
test_trend_max_min_not_media()
test_real_gelicidio_cold()
test_monday_storm_event_mm_is_hourly_sum()
test_three_pulses_keep_own_mm_not_daily_total()
test_last_hour_rain_is_emitted()
print("\nAll interpretation regression checks passed.")
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Helper: testo plain + summary state + mirror WebApp per allerte meteo."""
from __future__ import annotations
import logging
import re
import sys
from typing import Any, Dict, Optional
LOGGER = logging.getLogger("webapp_alert")
def message_to_plain(message: str, is_html: bool = False) -> str:
text = message or ""
if is_html:
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
text = re.sub(r"</p\s*>", "\n", text, flags=re.I)
text = re.sub(r"<[^>]+>", "", text)
text = (
text.replace("*", "")
.replace("_", "")
.replace("`", "")
.replace("&nbsp;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
)
lines = [ln.rstrip() for ln in text.splitlines()]
# collassa righe vuote multiple
out: list[str] = []
blank = False
for ln in lines:
if not ln.strip():
if not blank:
out.append("")
blank = True
else:
out.append(ln.strip())
blank = False
return "\n".join(out).strip()
def remember_summary(state: Optional[Dict[str, Any]], plain: str, limit: int = 3000) -> str:
summary = (plain or "").strip()[:limit]
if state is not None and summary:
state["summary"] = summary
return summary
def publish_web_alert(
message: str,
category: str,
severity: str = "warning",
*,
is_html: bool = False,
title: Optional[str] = None,
state: Optional[Dict[str, Any]] = None,
) -> bool:
"""Salva summary nello state (se passato) e notifica la WebApp (solo canale web)."""
plain = message_to_plain(message, is_html=is_html)
if not plain:
return False
remember_summary(state, plain)
head = (title or plain.split("\n", 1)[0]).strip()[:80] or category
body = plain[:2500]
try:
shared = "/home/daniely/docker/shared"
if shared not in sys.path:
sys.path.insert(0, shared)
from loogle_core.alert_dispatcher import send_web
ok = send_web(head, body, category=category, severity=severity)
if not ok:
LOGGER.debug("send_web returned False for %s", category)
return bool(ok)
except Exception as exc:
LOGGER.warning("publish_web_alert failed (%s): %s", category, exc)
return False